Skip to content

(MOT-3872) feat(shell): deny-only policy — commands, fs jail, per-call env - #428

Merged
ytallo merged 18 commits into
mainfrom
feat/shell-deny-only
Jul 8, 2026
Merged

(MOT-3872) feat(shell): deny-only policy — commands, fs jail, per-call env#428
ytallo merged 18 commits into
mainfrom
feat/shell-deny-only

Conversation

@ytallo

@ytallo ytallo commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

The shell worker is now permissive-first across every axis it gates: no
command allowlist, the fs jail is opt-in rather than defaulted on, and the
per-call env override on shell::exec/exec_bg is deny-only. Allow/ask
policy for commands lives entirely in the approval-gate; the sandbox
backend is the real security boundary for untrusted exec.

Deny-only command policy

  • allowlist config key removed and hard-rejected at parse (0.8.0
    removed-key migration with a configuration::set hint), matching the
    0.7.0 precedent.
  • The planted-binary guard is removed with it: it existed solely to prevent
    allowlist bypass, and blocked the legitimate case of executing your own
    build output inside the jail.
  • Seeds, docs, unit/integration/e2e tests updated; contract tests
    (plain-string Handler rejections, policy-before-backend-dispatch) are
    preserved via the denylist path.

Unjailed default

  • config.yaml/seed_default() now set fs.allow_unjailed: true with
    empty fs.host_rootsshell::fs::* and shell::exec's per-call cwd
    operate against the real filesystem by default, confined only by
    fs.denylist_paths, matching shell::exec itself (deny-only, never
    confinement-based).
  • coder::* is unaffected: it falls back to its own default roots (engine
    workspace cwd + /tmp) whenever host_roots is empty, regardless of
    allow_unjailed.
  • Default::default() (used when an operator config omits the fs section
    entirely) is unchanged — still fails closed unless explicitly opted in.
    Only the shipped seed opts in explicitly.
  • Existing deployments with an explicit fs.host_roots already stored are
    unaffected — the stored value always wins over the seed. Only a fresh
    zero-config install, or a stored value that gets nulled, picks up the new
    unjailed default.

Deny-only per-call env override

  • The per-call env override on shell::exec/exec_bg no longer requires
    a key to be in env.allow — only the hardcoded dangerous-key denylist
    (PATH, IFS, HOME, LD_/DYLD_, interpreter startup keys, ...) applies,
    unconditionally.
  • env.allow keeps its other, unrelated job: which vars get forwarded from
    the worker's own environment when env.inherit is false.
  • Pure widening — nothing that worked before now fails, no stored config
    needs a rewrite.

Docs

README/ARCHITECTURE/CHANGELOG/SKILL.md updated throughout for all three
changes, including a new "Upgrading to 0.8.0" section with a runnable
configuration::set migration example, a C-code table for coder::* that
didn't exist before, and a documented tilde-expansion caveat on path
handling.

Breaking: stored shell configurations carrying allowlist (even []) fail
closed at boot until rewritten. A fresh install now boots with the fs jail
off by default — see shell/README.md#upgrading-to-080 if you want the old
jailed-to-/tmp behavior. The per-call env change is purely additive. See
shell/CHANGELOG.md 0.8.0 for all three.

Spec: tech-specs/2026-06-agentic/shell-deny-only-policy.md

Test plan

  • cargo test (unit + integration, all targets) — passes cleanly
  • cargo clippy --all-targets -- -D warnings, cargo fmt --check — clean
  • e2e suite shell/tests/e2e/run-tests.sh (deny-only cases) — 170/170 passed against a live engine
  • Removed-key rejection verified through both funnels (yaml seed + stored JSON)
  • Live-tested against a real engine: planted-binary execution, denylist rejection, removed-key boot failure/migration, unjailed fs access, and a non-allowlisted per-call env var reaching the child — all verified end-to-end

Fixes MOT-3872

@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jul 8, 2026 12:36pm
workers-tech-spec Ready Ready Preview, Comment Jul 8, 2026 12:36pm

Request Review

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 39 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ytallo, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4d4bdd5e-33df-4db3-b1b1-6da1715200e3

📥 Commits

Reviewing files that changed from the base of the PR and between 239d532 and a99d45a.

📒 Files selected for processing (2)
  • .github/workflows/shell-e2e.yml
  • README.md
📝 Walkthrough

Walkthrough

The shell worker removes allowlist-based command gating, switches execution policy to denylist-only matching, updates removed-key migration handling, and changes shipped defaults and docs to reflect unjailed-by-default filesystem behavior. Tests, fixtures, changelog, and versioning are updated to match.

Changes

Deny-only shell exec policy

Layer / File(s) Summary
Tech spec and policy framing
tech-specs/2026-06-agentic/shell-deny-only-policy.md
Defines the deny-only shell policy, migration behavior, scope boundaries, and related documentation changes.
Config schema and migration
shell/src/config.rs, shell/src/configuration.rs, shell/config.yaml, shell/config.collect.yaml
Drops the allowlist field, updates removed-key metadata and error formatting, changes seed/default fs behavior, and adds rejection coverage for removed keys and updated defaults.
Command gating and help text
shell/src/exec/*, shell/src/functions/*, shell/src/main.rs, shell/src/telemetry.rs, shell/iii.worker.yaml, README.md, .github/workflows/shell-e2e.yml
Exec policy code now rejects empty argv, relies on denylist regex matching only, and updates backend, host, policy, help text, telemetry, workflow, and manifest wording to denylist terminology.
Unit and e2e coverage
shell/tests/function_handlers.rs, shell/tests/e2e/*
Handler tests and e2e harness cases are updated to use denylist-based configs and to assert denylist rejections, OS spawn failures, and permitted command paths under the new policy.
Docs, changelog, and versioning
shell/README.md, shell/ARCHITECTURE.md, shell/CHANGELOG.md, shell/skills/SKILL.md, shell/Cargo.toml
The README, architecture guide, changelog, skill doc, and package version are updated to describe deny-only execution, unjailed defaults, migration steps, and the 0.8.0 release.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • iii-hq/workers#391: Similar shell config and exec-policy work, including legacy-key rejection and denylist-based command handling updates.

Poem

I hopped past the allowlist gate,
With denylist checks that watch my crate.
The shell now hums in 0.8.0 cheer,
With docs and tests all crystal-clear.
🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is clear, concise, and matches the main shell policy changes around deny-only commands, filesystem jail behavior, and per-call env handling.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shell-deny-only

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
shell/README.md (1)

300-304: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore a recovery hint for allowlist parse failures.

Dropping the allowlist troubleshooting row leaves no README guidance for the new 0.8.0 fail-closed parse error, so operators have to infer the fix from elsewhere. Please add a short note that allowlist must be removed and the stored value rewritten via configuration::set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shell/README.md` around lines 300 - 304, Restore the missing troubleshooting
entry in the README’s recovery hints so `allowlist` parse failures are
documented alongside the other boot/reload errors. Update the existing
recovery-hints list to mention that when the new fail-closed `allowlist` parse
error appears, the stored value must be removed and rewritten through
`configuration::set`; keep the wording consistent with the surrounding
`fs.host_roots` and `config keys removed` notes.
🧹 Nitpick comments (1)
tech-specs/2026-06-agentic/shell-deny-only-policy.md (1)

53-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Spec text doesn't match the actual implementation location/helper.

This says the removal is wired via deleted("allowlist") added to REMOVED_TOP_LEVEL_KEYS, but shell/src/config.rs instead adds a new REMOVED_EXEC_KEYS table populated via a new deleted_in("allowlist", "0.8.0") helper (since deleted() hardcodes "0.7.0"). Since config.rs explicitly points back to this spec file in its doc comment, keeping the two in sync avoids confusing future readers/maintainers who go looking for REMOVED_TOP_LEVEL_KEYS.

✏️ Suggested wording fix
-- Add `deleted("allowlist")` to `REMOVED_TOP_LEVEL_KEYS` (`src/config.rs`),
-  following the 0.7.0 hard-migration convention: any stored value or YAML
+- Add a new `REMOVED_EXEC_KEYS` table (`src/config.rs`) with
+  `deleted_in("allowlist", "0.8.0")`, following the 0.7.0 hard-migration
+  convention: any stored value or YAML
   seed still carrying the key — including the inert `allowlist: []` the old
   seed wrote — is rejected at parse with the
   `configuration::set (id: shell)` hint. No silent tolerance.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tech-specs/2026-06-agentic/shell-deny-only-policy.md` around lines 53 - 61,
The spec text is out of sync with the implementation in ShellConfig::config.rs:
it describes wiring removal through REMOVED_TOP_LEVEL_KEYS with
deleted("allowlist"), but the code actually uses REMOVED_EXEC_KEYS with
deleted_in("allowlist", "0.8.0"). Update the wording in this spec to match the
real helper/table names and versioned migration path so readers are directed to
the correct location in shell/src/config.rs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@shell/README.md`:
- Around line 300-304: Restore the missing troubleshooting entry in the README’s
recovery hints so `allowlist` parse failures are documented alongside the other
boot/reload errors. Update the existing recovery-hints list to mention that when
the new fail-closed `allowlist` parse error appears, the stored value must be
removed and rewritten through `configuration::set`; keep the wording consistent
with the surrounding `fs.host_roots` and `config keys removed` notes.

---

Nitpick comments:
In `@tech-specs/2026-06-agentic/shell-deny-only-policy.md`:
- Around line 53-61: The spec text is out of sync with the implementation in
ShellConfig::config.rs: it describes wiring removal through
REMOVED_TOP_LEVEL_KEYS with deleted("allowlist"), but the code actually uses
REMOVED_EXEC_KEYS with deleted_in("allowlist", "0.8.0"). Update the wording in
this spec to match the real helper/table names and versioned migration path so
readers are directed to the correct location in shell/src/config.rs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ee20cf5a-01a0-4952-8ee5-ee4529f979cb

📥 Commits

Reviewing files that changed from the base of the PR and between 0e34acb and a3e6192.

⛔ Files ignored due to path filters (1)
  • shell/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (27)
  • shell/ARCHITECTURE.md
  • shell/CHANGELOG.md
  • shell/Cargo.toml
  • shell/README.md
  • shell/config.collect.yaml
  • shell/config.yaml
  • shell/iii.worker.yaml
  • shell/src/config.rs
  • shell/src/configuration.rs
  • shell/src/exec/backend.rs
  • shell/src/exec/host.rs
  • shell/src/exec/policy.rs
  • shell/src/functions/exec.rs
  • shell/src/functions/types.rs
  • shell/src/main.rs
  • shell/src/telemetry.rs
  • shell/tests/e2e/README.md
  • shell/tests/e2e/config-jailed.yaml
  • shell/tests/e2e/config.yaml
  • shell/tests/e2e/run-tests.sh
  • shell/tests/e2e/workers/harness/src/cases-edge.ts
  • shell/tests/e2e/workers/harness/src/cases-exec-break.ts
  • shell/tests/e2e/workers/harness/src/cases-exec-sandbox.ts
  • shell/tests/e2e/workers/harness/src/cases-exec-stdin.ts
  • shell/tests/e2e/workers/harness/src/cases-safety.ts
  • shell/tests/function_handlers.rs
  • tech-specs/2026-06-agentic/shell-deny-only-policy.md
💤 Files with no reviewable changes (3)
  • shell/tests/e2e/config.yaml
  • shell/config.collect.yaml
  • shell/tests/e2e/config-jailed.yaml

@ytallo ytallo changed the title (MOT-3872) feat(shell): deny-only command policy — remove the allowlist (MOT-3872) feat(shell): deny-only command policy + unjailed default Jul 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@shell/skills/SKILL.md`:
- Around line 55-59: Clarify the `shell::fs::*` boundary description so it
matches the actual path-validation behavior: the current statement that symlinks
are never followed is misleading. Update the `shell::fs::*` docs to say symlinks
may be resolved during containment checks for jail enforcement, while denylisted
paths are still always rejected and absolute/relative path rules remain
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d9da5963-61b5-4625-ac43-85d7d4880a0e

📥 Commits

Reviewing files that changed from the base of the PR and between a3e6192 and 239d532.

📒 Files selected for processing (10)
  • .github/workflows/shell-e2e.yml
  • README.md
  • shell/ARCHITECTURE.md
  • shell/CHANGELOG.md
  • shell/README.md
  • shell/config.yaml
  • shell/skills/SKILL.md
  • shell/src/config.rs
  • shell/src/configuration.rs
  • shell/tests/e2e/README.md
✅ Files skipped from review due to trivial changes (3)
  • README.md
  • shell/tests/e2e/README.md
  • .github/workflows/shell-e2e.yml
🚧 Files skipped from review as they are similar to previous changes (2)
  • shell/src/configuration.rs
  • shell/src/config.rs

Comment thread shell/skills/SKILL.md
Comment on lines +55 to +59
- `shell::fs::*` honors `fs.host_roots` as a jail WHEN it's set — empty (the
shipped default) means unjailed, confined only by `fs.denylist_paths` — and
always refuses denylisted paths regardless; paths must be absolute (unless
jailed, where a relative path resolves against the primary root) and
symlinks are never followed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the referenced files and inspect the relevant sections.
git ls-files | rg '(^|/)(SKILL\.md|ARCHITECTURE\.md|README\.md)$'

for f in shell/skills/SKILL.md ARCHITECTURE.md README.md; do
  if [ -f "$f" ]; then
    echo "===== $f ====="
    wc -l "$f"
  fi
done

echo "===== SKILL.md excerpt ====="
sed -n '45,70p' shell/skills/SKILL.md

echo "===== ARCHITECTURE.md symlink/path-validation excerpts ====="
rg -n -C 3 'canonicaliz|symlink|jail|S215|C215' ARCHITECTURE.md

echo "===== README.md symlink/path-validation excerpts ====="
rg -n -C 3 'canonicaliz|symlink|jail|S215|C215' README.md

Repository: iii-hq/workers

Length of output: 3559


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== shell/ARCHITECTURE.md ====="
wc -l shell/ARCHITECTURE.md
rg -n -C 3 'canonicaliz|symlink|jail|S215|C215|resolve' shell/ARCHITECTURE.md

echo "===== shell/README.md ====="
wc -l shell/README.md
rg -n -C 3 'canonicaliz|symlink|jail|S215|C215|resolve' shell/README.md

Repository: iii-hq/workers

Length of output: 22920


Clarify symlink handling in shell::fs::* boundaries
symlinks are never followed conflicts with the path-validation docs: containment checks canonicalize the existing path portion and reject symlinks that escape the jail. Update this line to say symlinks in the path are resolved for jail checks, not treated as opaque.

🧰 Tools
🪛 SkillSpector (2.3.7)

[error] 6: [TM1] Tool Parameter Abuse: Tool parameters are crafted to achieve unintended or unsafe behavior. Parameter abuse can bypass intended safety checks (e.g. shell=True, --force, dangerous glob patterns).

Remediation: Validate all tool parameters against an allowlist. Reject dangerous parameter values (shell=True, --force, -rf /) and use safe defaults.

(Tool Misuse (TM1))

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shell/skills/SKILL.md` around lines 55 - 59, Clarify the `shell::fs::*`
boundary description so it matches the actual path-validation behavior: the
current statement that symlinks are never followed is misleading. Update the
`shell::fs::*` docs to say symlinks may be resolved during containment checks
for jail enforcement, while denylisted paths are still always rejected and
absolute/relative path rules remain unchanged.

@ytallo ytallo changed the title (MOT-3872) feat(shell): deny-only command policy + unjailed default (MOT-3872) feat(shell): deny-only policy — commands, fs jail, per-call env Jul 7, 2026
@ytallo
ytallo force-pushed the feat/shell-deny-only branch from 4be0fbf to 4caf7da Compare July 7, 2026 02:56
ytallo added 6 commits July 8, 2026 09:35
Flip fs.allow_unjailed to true and clear host_roots in the shipped
seed_default()/config.yaml so shell::fs::* and shell::exec's per-call
cwd match shell::exec's own deny-only security model instead of being
confinement-based by default. coder::* is unaffected — it falls back
to its own default roots (cwd + /tmp) whenever host_roots is empty.
Default::default() (used when an operator config omits the fs section
entirely) still fails closed; only this explicit seed opts in.
… doc gaps

- README: Upgrading to 0.8.0 section (allowlist removal with a runnable
  configuration::set example, plus the fs-jail default change and how to
  keep the old jailed-to-/tmp behavior), matching troubleshooting entries,
  tilde-expansion caveat, coder::*/shell::fs::* jail divergence, C-code
  table, config-status agent-callable caveat, npm install line in Quick
  start.
- config.yaml: fix the comment conflating the fs jail with the sandbox as
  a joint security boundary for exec — only the sandbox is one.
- ARCHITECTURE.md/SKILL.md/configuration.rs: fix stale "jailed to /tmp"
  and "shell::fs::* is jailed" claims now that the shipped default is
  unjailed.
- CHANGELOG: add the fs-jail-default change as a second 0.8.0 breaking
  entry with a Migration section, matching the 0.6.0/0.7.0 structure.
- Fix e2e test-count drift across three docs (143+1 / ~27 / 144 vs the
  actual 170) by pointing at the harness's own report instead of a
  number that goes stale every time a case is added.
Drop the env.allow gate on shell::exec/exec_bg's per-call `env`
override — an agent may now set any key except the ones in
DANGEROUS_ENV_KEYS, matching the same deny-only philosophy already
applied to command exec and the fs jail. env.allow keeps its other job
unchanged: which vars get forwarded from the worker's own environment
when env.inherit is false.
…a/descriptions

Update ExecRequest/ExecBgRequest doc comments (the LLM-facing schema)
and the shell::exec/exec_bg .description() strings to describe the
per-call env override as deny-only, matching the src/exec/policy.rs
change.
README/ARCHITECTURE/config.yaml/CHANGELOG updated for the env.allow
change: Configure section, per-call env section, Functions table, S210
error row, config table, shipped seed comments, and a new Upgrading to
0.8.0 entry (pure widening, no action required). Also fixed two
leftover stale references in policy.rs's own doc comments the first
pass missed.
@ytallo
ytallo force-pushed the feat/shell-deny-only branch from 4caf7da to a99d45a Compare July 8, 2026 12:35
@ytallo
ytallo merged commit adbd0b9 into main Jul 8, 2026
15 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