Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions emrg/sandbox/escalation.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@

__all__ = [
"APPROVAL_TIMEOUT_SECONDS",
"ESCALATION_PARAMETERS",
"ESCALATION_TARGETS",
"ApprovalOutcome",
"EscalationRefused",
Expand All @@ -69,6 +70,41 @@
#: what is reachable from where it actually stands.
ESCALATION_TARGETS: tuple[str, ...] = ("workspace-write", "danger-full-access")

#: The two fields a tier-limited tool advertises, **as one object**.
#:
#: ``bash``, ``pwsh``, ``write`` and ``edit`` each consume the tier (``write``
#: and ``edit`` the writable roots with it, ``_ROOT_CONSUMING_TOOLS``), so all
#: four may ask for one hop and all four must say so in the same words: a model
#: reading two schemas that describe one mechanism two ways has to decide which
#: one it is. Four hand-written copies were what this replaced — **equal by
#: value**, which is precisely why a value comparison could not tell them apart
#: from one source (issue #2734). Each tool spreads this dict into its
#: ``properties``, so the dicts that reach the wire *are* these dicts and a fifth
#: copy is a failure at the moment it is written rather than an equivalence
#: somebody has to notice later.
#:
#: ``enum`` is the whole target set, not the current tier's row: the blueprint
#: advertises what is reachable in principle and lets :func:`validate_hop` decide
#: what *this* call may reach (``escalation.ts:41``).
ESCALATION_PARAMETERS: dict[str, dict] = {
"sandbox_permissions": {
"type": "string",
"enum": list(ESCALATION_TARGETS),
"description": "Ask for a one-hop wider sandbox tier for THIS "
"call only. Must be sent together with `justification`, and it "
"is refused unless the host approves: it never changes the "
"session's default tier, and it reaches only the tiers the "
"call's current one lists (from `read-only`: `workspace-write` "
"or `danger-full-access`).",
},
"justification": {
"type": "string",
"description": "Why this call needs the wider tier — a non-empty "
"sentence the host reads before approving. Required with "
"`sandbox_permissions`.",
},
}

#: The strict-wider table, verbatim from ``escalation.ts:28-31``. A mode with
#: no entry has nowhere to escalate to, and an entry listing no mode is the same
#: statement — both are read through :func:`hops_from` so there is one table.
Expand Down
19 changes: 18 additions & 1 deletion emrg/sandbox/fence.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@

import os

from emrg.sandbox.escalation import with_retry_hint
from emrg.sandbox.grants import GrantKindNotEnforced
from emrg.sandbox.policy import DANGER_FULL_ACCESS, SandboxPolicy
from emrg.sandbox.roots import canonical_path, named_grants, writable_roots
Expand Down Expand Up @@ -132,10 +133,26 @@ def file_refusal(file_path: str, policy: SandboxPolicy) -> str | None:
# the same file. Compared as the canonical identity both sides of the
# comparison use, so one assertion holds on either platform.
resolved = f" (resolves to {target!r})" if target != file_path else ""
return (
refusal = (
f"{policy.mode} sandbox: blocked file write to {file_path!r}{resolved} — "
f"outside every root this tier grants ({granted})"
)
# A file refusal that names no way out reads as a dead end, and a dead end
# invites the workaround rather than the question: the model would echo the
# file from `bash`, which is the tool that *can* ask — the escalation moving
# from the front door to the side door, with the host told in neither case
# (issue #2734). So the sentence carries the shell's own next step, through
# the one producer of it; `with_retry_hint` appends nothing at a tier with no
# hop, which is why a `danger-full-access` refusal stays as it was — it
# already returned above, and an instruction that could only fail is not
# advice.
#
# The two branches above keep no hint, and the reason is the cause rather
# than the shape: a protected daemon file is refused for being the host's own
# state, and an unenforceable grant for a platform that cannot apply it.
# Neither is a root boundary, so "ask for one wider tier" is not their
# remedy — one remedy for every cause is false for most of them.
return with_retry_hint(refusal, mode=policy.mode)


def _is_within_root(target: str, root: str) -> bool:
Expand Down
9 changes: 7 additions & 2 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -4497,7 +4497,12 @@ async def _apply_escalation(

:returns: ``None`` to run the call, or the refusal text to report instead.
"""
if tc_name not in SHELL_TOOL_NAMES:
# The set that may ask is the set the tier and the roots reach, not the
# shell's names: `write` and `edit` consume both, so a refusal that told
# them apart from a command was an answer about the tool rather than
# about the boundary (issue #2734). One name for the set, read here and
# at the injection site, so the two cannot drift.
if tc_name not in _ROOT_CONSUMING_TOOLS:
return None
raw_target = args.get("sandbox_permissions")
raw_reason = args.get("justification")
Expand All @@ -4523,7 +4528,7 @@ async def _apply_escalation(
# The command does NOT run at the wider tier, and saying so is the
# whole point: a silent "no" reads as a broken command.
return (
f"⛔ escalation refused ({outcome.reason}): this command runs at "
f"⛔ escalation refused ({outcome.reason}): this call runs at "
f"{hop.from_mode!r}. Nothing was widened, and the session's "
"default tier is unchanged."
)
Expand Down
19 changes: 2 additions & 17 deletions emrg/tools/bash_tool_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
from emrg.server.redaction import redact_argv
from emrg.server.tool_types import ToolDefinition, ToolResult
from emrg.tools import command_scan
from emrg.sandbox.escalation import ESCALATION_TARGETS
from emrg.sandbox.escalation import ESCALATION_PARAMETERS
from emrg.sandbox.escalation import hops_from as escalation_hops
from emrg.tools.base import ToolExecutor

Expand Down Expand Up @@ -674,22 +674,7 @@ def definition(self) -> ToolDefinition:
"caller's to set: it is also the writable boundary, and a "
"value passed here does not widen it.",
},
"sandbox_permissions": {
"type": "string",
"enum": list(ESCALATION_TARGETS),
"description": "Ask for a one-hop wider sandbox tier for THIS "
"call only. Must be sent together with `justification`, and it "
"is refused unless the host approves: it never changes the "
"session's default tier, and it reaches only the tiers the "
"call's current one lists (from `read-only`: `workspace-write` "
"or `danger-full-access`).",
},
"justification": {
"type": "string",
"description": "Why this call needs the wider tier — a non-empty "
"sentence the host reads before approving. Required with "
"`sandbox_permissions`.",
},
**ESCALATION_PARAMETERS,
"intent": {
"type": "string",
"description": "The purpose of this call: why you are invoking it and what you want to achieve. "
Expand Down
2 changes: 2 additions & 0 deletions emrg/tools/edit_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
from pathlib import Path

from emrg.sandbox.escalation import ESCALATION_PARAMETERS
from emrg.sandbox.fence import file_refusal
from emrg.sandbox.policy import grants_from_arguments, resolve_policy
from emrg.server.tool_types import ToolDefinition, ToolResult
Expand Down Expand Up @@ -70,6 +71,7 @@ def definition(self) -> ToolDefinition:
"edit writes to."
),
},
**ESCALATION_PARAMETERS,
"intent": {
"type": "string",
"description": "The purpose of this call: why you are invoking it and what you want to achieve. "
Expand Down
19 changes: 2 additions & 17 deletions emrg/tools/pwsh_tool_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
from emrg.server.redaction import redact_argv
from emrg.server.tool_types import ToolDefinition, ToolResult
from emrg.tools import command_scan
from emrg.sandbox.escalation import ESCALATION_TARGETS
from emrg.sandbox.escalation import ESCALATION_PARAMETERS
from emrg.sandbox.escalation import hops_from as escalation_hops
from emrg.tools.base import ToolExecutor
from emrg.tools.shell_env import confined_env, runner_import_env
Expand Down Expand Up @@ -792,22 +792,7 @@ def definition(self) -> ToolDefinition:
"caller's to set: it is also the writable boundary, and a "
"value passed here does not widen it.",
},
"sandbox_permissions": {
"type": "string",
"enum": list(ESCALATION_TARGETS),
"description": "Ask for a one-hop wider sandbox tier for THIS "
"call only. Must be sent together with `justification`, and it "
"is refused unless the host approves: it never changes the "
"session's default tier, and it reaches only the tiers the "
"call's current one lists (from `read-only`: `workspace-write` "
"or `danger-full-access`).",
},
"justification": {
"type": "string",
"description": "Why this call needs the wider tier — a non-empty "
"sentence the host reads before approving. Required with "
"`sandbox_permissions`.",
},
**ESCALATION_PARAMETERS,
"intent": {
"type": "string",
"description": "The purpose of this call: why you are invoking it and what you want to achieve. "
Expand Down
2 changes: 2 additions & 0 deletions emrg/tools/write_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
from pathlib import Path

from emrg.sandbox.escalation import ESCALATION_PARAMETERS
from emrg.sandbox.fence import file_refusal
from emrg.sandbox.policy import grants_from_arguments, resolve_policy
from emrg.server.tool_types import ToolDefinition, ToolResult
Expand Down Expand Up @@ -119,6 +120,7 @@ def definition(self) -> ToolDefinition:
"type": "string",
"description": "The complete content to write to the file.",
},
**ESCALATION_PARAMETERS,
"intent": {
"type": "string",
"description": "The purpose of this call: why you are invoking it and what you want to achieve. "
Expand Down
Loading
Loading