Skip to content

Shell execution Phase 1: the run_command lane, and a never-list that parses with the standard library - #647

Merged
0x-copilot-dev merged 2 commits into
devfrom
claude/desktop-app-ui-ux-9af65c
Aug 27, 2026
Merged

0x-copilot-dev merged 2 commits into
devfrom
claude/desktop-app-ui-ux-9af65c

Conversation

@0x-copilot-dev

Copy link
Copy Markdown
Owner

Builds the vertical Phase 0 declared — a run_command tool 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.py is the single wiring site; the desktop side projects a per-workspace shell bit with WorkspaceShellAccess as 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 approve sudo rm -rf / is a worse design than being refused.
  • The executor uses exec, not shell=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._split are gone, replaced by re.split on the chaining operators plus shlex per segment.

The old lexer's docstring gave three reasons shlex wouldn't do. Two dissolved:

  • "shlex raises on an unbalanced quote" — catch it and refuse. Failing closed beats the total lexer running to end-of-string, and the corpus scores 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. Split on the operators first and the && vs | distinction survives.

The third — raw token spelling — is real and kept, because three call sites need it: always_grant_patterns must not offer a glob for a quoted head ("sudo" rm -rf /), and the two credential-path rules check both spellings.

_fork_bomb deliberately stays a raw-string check. :(){ :|:& };: tokenises to garbage under shlex, 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:

count
refused by both 41
refused by new only (improvement) 2
allowed by new, refused by old (regression) 0
allowed by both 38

And the product check that mattered: pytest -q, npm test, git status, make build, cargo test all 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_note was written, unit-tested, and had no product caller. So a timed-out command reached the model with exit_code absent entirely (exclude_none drops the null) and no prose at all — the model could not distinguish a timeout from a refusal, nor learn that a larger timeout_s exists. _result now attaches it, and TestTimeoutIsExplained asserts 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_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'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:

  • The NEVER tier protects only / and ~ as rm targets. rm -rf /usr, /etc, /var, /Users all pass to ASK.
  • 15 clearly-not-ordinary commands reach ASK rather than NEVER, including cat ~/.config/gh/hosts.yml (a live OAuth token), cat /etc/shadow, chmod -R 777 /, and python -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.py and check_service_boundaries.py green.

Two ai-backend failures are local-only worktree venv skew (deepagents 0.7.1 installed vs 0.7.4 pinned, langgraph 1.2.9 vs 1.2.10) — they compare installed-to-pinned versions and pass in CI, which installs the pins.

🤖 Generated with Claude Code

0x-copilot-dev and others added 2 commits August 27, 2026 17:44
…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>
@0x-copilot-dev
0x-copilot-dev merged commit ed72f5c into dev Aug 27, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant