Repository navigation
Shell execution Phase 1: the run_command lane, and a never-list that parses with the standard library - #647
Merged
Conversation
…d the workspace shell grant Builds the vertical Phase 0 declared: a `run_command` tool the model can call, an executor that spawns it, a never-list floor screened BEFORE the PDP, and the desktop grant + settings surface that turn it on per workspace. Shape of the lane, so the seams are findable: * `capabilities/shell/` — `run_command_tool` (the model-visible tool), `executor` (asyncio.create_subprocess_exec — no shell interpretation, so a smuggled `;` cannot chain even if the screen missed it), `policy_gate` (the PEP: never-list screen, then PDP, then the approval lane), `never_list` (the floor + the always-grant question), `environment` (the env allowlist), `contracts`/`config`/`descriptor`. * `runtime_worker/shell_composition.py` — the single wiring site. * desktop: grant-store projects a per-workspace shell bit; `WorkspaceShellAccess` is the settings surface; the composer gates on it. Two properties worth naming because a later refactor could dissolve them: * `never_list.screen()` runs PRE-PDP (policy_gate.py:285), so a never-listed command is refused rather than offered to a human. Being ASKED to approve `sudo rm -rf /` is a worse design than being refused. * The executor uses exec, not `shell=True`. That is a security property, not a style choice, and it is what keeps chaining out of a single process. Also vendors `vendored_deepagents_safety.py` — `RECOMMENDED_SAFE_SHELL_COMMANDS` and `is_shell_command_allowed` copied verbatim from langchain-ai/deepagents (MIT, as is this repo). It is not wired as the gate: upstream's list is 25 READERS and does not contain pytest/npm/git, so making it the gate would stop the agent running your test suite. It is the honest basis for a later auto-approve tier, and it is kept byte-identical so it stays diffable against upstream. Tests: 521 shell + 106 adjacent (ai-backend), 1688 (chat-surface), 802 (desktop). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…was built and never called
Two changes that happen to share a file tree.
**1. The never-list parses with the standard library now.** ~400 lines of
hand-rolled `CommandToken` / `CommandLexer` / `_read_single` / `_read_double` /
`ParsedCommandLine._split` are gone, replaced by `re.split` on the chaining
operators plus `shlex` per segment — the shape deepagents_code uses. The lexer's
docstring gave three reasons shlex would not do; two dissolved:
* "shlex raises on an unbalanced quote" — catch it and refuse. Failing CLOSED on
a malformed command is better than the total lexer running to end-of-string,
and the corpus shows it as a strict improvement: `cat 'unbalanced` was
ALLOWED by the old floor and is refused now.
* "shlex discards operators" — true of the convenience wrapper only; splitting
on the operators first keeps the distinction §8.3 needs.
The third — raw token spelling — is real, and is kept, because it has three
call sites that need it (`always_grant_patterns` must not offer a glob for a
quoted head; the two credential-path rules check both spellings).
`_fork_bomb` deliberately stays a RAW-STRING check: `:(){ :|:& };:` tokenises to
garbage under shlex, which does not model function definitions. A hazard that is
a syntactic form rather than an executable name cannot be asked of the tokens.
Refusal coverage was measured, not assumed — 81 commands through both the old
floor and the new one: **0 regressions**, 2 improvements, and `pytest -q`,
`npm test`, `git status`, `make build` all still reach the ASK tier rather than
being hard-refused.
**2. `ShellCommandExecutor.timeout_note` had no caller.** It was written, unit
tested, and never invoked, so a timed-out command reached the model with
`exit_code` absent entirely (`exclude_none` drops the null) and no prose saying
what happened — the model could not tell a timeout from a refusal, and could not
learn that a larger `timeout_s` exists. `_result` now attaches it, and
`TestTimeoutIsExplained` asserts it THROUGH the tool, so removing the wiring
fails again rather than going quiet.
Also baselines three dark-wiring rows with hand-written reasons. Two are scan
blind spots and say so: `RunCommandResult.exit_code` originates as
`await process.wait()` bound to a same-named local, so every hop the scanner can
see looks like a copy of itself; `reason` is originated by ten product sites and
projected through one `reason=self.reason`. The third, `auto_approvable`, is
genuinely dark on purpose — it is the AUTO tier, and Phase 1's rule is that
every command asks.
Tests: 619 shell (+4), 10782 ai-backend unit. dark-wiring and service-boundaries green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds the vertical Phase 0 declared — a
run_commandtool the model can actually call — and then reduces the floor it landed with.The lane
capabilities/shell/holds the tool, the executor, the PEP, the never-list floor and the env allowlist;runtime_worker/shell_composition.pyis the single wiring site; the desktop side projects a per-workspace shell bit withWorkspaceShellAccessas its settings surface.Two properties are load-bearing and easy to dissolve in a later refactor, so they're named here:
never_list.screen()runs PRE-PDP (policy_gate.py:285). A never-listed command is refused, not offered to a human. Being asked to approvesudo rm -rf /is a worse design than being refused.exec, notshell=True. That's a security property, not a style choice: it's what keeps a smuggled;from chaining inside one process.Then: the floor parses with the standard library
~400 lines of hand-rolled
CommandToken/CommandLexer/_read_single/ParsedCommandLine._splitare gone, replaced byre.spliton the chaining operators plusshlexper segment.The old lexer's docstring gave three reasons
shlexwouldn't do. Two dissolved:cat 'unbalancedwas allowed by the old floor and is refused now.&&vs|distinction survives.The third — raw token spelling — is real and kept, because three call sites need it:
always_grant_patternsmust not offer a glob for a quoted head ("sudo" rm -rf /), and the two credential-path rules check both spellings._fork_bombdeliberately stays a raw-string check.:(){ :|:& };:tokenises to garbage undershlex, which doesn't model function definitions — a hazard that is a syntactic form rather than an executable name can't be asked of the tokens.Coverage was measured, not assumed
81 commands through both the old floor and the new one:
And the product check that mattered:
pytest -q,npm test,git status,make build,cargo testall still reach the ASK tier rather than being hard-refused. The vendored allow-list is 25 readers and contains none of those — making it the gate would have stopped the agent running your test suite, which is the whole point of the feature.A built-and-never-called helper, now delivered
ShellCommandExecutor.timeout_notewas written, unit-tested, and had no product caller. So a timed-out command reached the model withexit_codeabsent entirely (exclude_nonedrops the null) and no prose at all — the model could not distinguish a timeout from a refusal, nor learn that a largertimeout_sexists._resultnow attaches it, andTestTimeoutIsExplainedasserts it through the tool, so un-wiring it fails again instead of going quiet.Dark-wiring rows
Three baselined with hand-written reasons. Two are scan blind spots and say so:
RunCommandResult.exit_codeoriginates asawait process.wait()bound to a same-named local, so every hop the scanner can see looks like a copy of itself;reasonis originated by ten product sites and projected through onereason=self.reason. The third,auto_approvable, is genuinely dark on purpose — it's the AUTO tier, and Phase 1's rule is that every command asks.Known gaps, stated rather than buried
Found by the corpus run, pre-existing in both floors — not regressions from this change, and not fixed here:
/and~asrmtargets.rm -rf /usr,/etc,/var,/Usersall pass to ASK.cat ~/.config/gh/hosts.yml(a live OAuth token),cat /etc/shadow,chmod -R 777 /, andpython -c 'import os;os.system("rm -rf /")'.Also honest about the reduction itself:
never_list.py's code shrank 646 → 529 lines (−117), but gained 245 lines of docstring, so the file is net +124. The prose wants trimming.Tests
619 shell (+4), 10,782 ai-backend unit, 1,688 chat-surface, 802 desktop.
check_dark_wiring.pyandcheck_service_boundaries.pygreen.Two ai-backend failures are local-only worktree venv skew (
deepagents0.7.1 installed vs 0.7.4 pinned,langgraph1.2.9 vs 1.2.10) — they compare installed-to-pinned versions and pass in CI, which installs the pins.🤖 Generated with Claude Code