Skip to content

The owner sets a sandbox grant policy once; the orchestrator grants within it - #458

Open
avifenesh wants to merge 6 commits into
mainfrom
tower-crane/T467-the-owner-sets-a-sandbox-grant-policy-on
Open

avifenesh wants to merge 6 commits into
mainfrom
tower-crane/T467-the-owner-sets-a-sandbox-grant-policy-on

Conversation

@avifenesh

@avifenesh avifenesh commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

The owner sets a sandbox grant policy once. Inside it, the orchestrator widens a task's sandbox itself.

  • project set --sandbox-policy JSON records sandbox_policy: { version, orchestrator_may_grant: { read, write }, owner_only }. Paths are absolute or start with ~ or <project>, with *, ? and ** globs. Setting it or adding a path is sandbox.policy (owner-required). Removing paths or null is sandbox.policy.narrow (operational). The orchestrator can narrow or clear the policy but not widen it.
  • task update ID --sandbox JSON is a per-task widening, stored as sandbox_grants records. Every later dispatch for that task adds it to the rung's sandbox. If every added item lies inside the policy and outside the owner-only classes, the command is sandbox.grant (operational). Any other item opens the owner's decision as before, and its why names each item and the reason. ladder set RUNG --sandbox follows the same rule, and project.json sandbox_grants records the rung items added under the policy.
  • Owner-only classes apply even when the policy's paths cover them: credentials (well-known stores under ~, the state's homes/brokers/ci-artifacts, owner_config_dir, any env_file, and any path holding one of these), network (domains), session_bus, and sockets (existing sockets, a directory that holds one at any depth or a link to one, such as an ssh-agent directory or /tmp/.X11-unix, a directory the check cannot read through, /run, /var/run, the runtime dir). A read grant counts too, since connect() needs no write access. Per the scope change relayed by the orchestrator, orchestrator_may_grant.sockets is refused: the docker socket and the session bus stay owner-only.
  • Audit: each grant writes the setting audit, a sandbox grant event (by, role, policy_version, granted, removed) and sandbox evidence on the task. tower-crane evidence --type sandbox is refused. task show lists sandbox widenings: and status lists open tasks with active widenings.
  • Revocation: a policy change removes the grants it no longer covers in the same write, with a sandbox revoke event and evidence. Every dispatch judges policy-backed grants again on canonical paths, links included. A grant that now falls outside, such as a granted path that has become a link into ~/.ssh, is left out and named in a spawn notice.
  • ask --kind credential --setting sandbox --sandbox JSON: if every item is inside the policy, the question goes to the orchestrator (technical: true, routed.sandbox_policy). Otherwise it stays with the owner.
  • docs/state.md: authority rows, a #sandbox-policy section (including the note that /run/docker.sock is root-equivalent), plus the sandbox_grants fields, the sandbox evidence type and the new events. docs/cli.md regenerated. Both skills got one line each.

Why

Owner direction, relayed 2026-10-21: the owner should be asked only when really needed. Path grants for a worker no longer wait on the owner. Docker-style host actions move to T468, so sockets stay owner-only here.

Verification

  • test/sandbox-policy.test.js:
    • An in-process variant table covers glob matching, every owner-only class (including a dangling link into ~/.ssh and a socket under a listed /run/**), and narrowing versus widening.
    • A CLI test covers the full flow: the owner sets the policy; the orchestrator grants a write and a read path to T1, with the audit, event, evidence, task show and status checked, and dispatch shows T1 writes the path while T2 does not. A grant outside the policy is refused and opens a decision. The orchestrator cannot widen the policy. Each owner-only class stays the owner's. The worker ask is routed. A rung grant works. Narrowing revokes, and the next dispatch no longer writes the path.
    • A third test covers dispatch-time revocation when a granted path turns into a link to a credential store. It also refuses a grant of a directory that holds a socket, and leaves out a granted directory that comes to hold one at the next dispatch.
    • The variant table rows for a socket-holding directory, a directory with a link to a socket and an unreadable directory, and the CLI socket checks, fail with lib/sandbox-policy.js reverted to the previous head (2 of 3 tests fail) and pass with it.
  • These tests fail without the change: the flags and module do not exist.
  • Ran test/authority, board-controls, owner-burden, project, commands, package, shared-files, evidence, decision-access and state-compatibility: all pass.
  • After merging main: authority, board-controls, evidence, project, render, settings, spawn-cmd pass; spawn passes except 2 tests that bind a broker socket under a temp path too long for this sandbox (no short writable path here). The host tests gate runs them.
  • test/sandbox-extensions, isolation, spawn and others fail the same 59 tests in the worker sandbox with or without this change. A claude spawn needs a temp directory under the real ~/.cache, which the sandbox keeps read-only, so the dispatch checks in the new test use a codex stub. The host tests gate runs those files in full.
  • npm run check:shared -- --base origin/main passes.

Limits

  • Rung-level grants are a convenience. A rung grant only counts when no other owner-required field is in the same ladder set.
  • Without a policy, the orchestrator may now remove items from a rung's sandbox. A change that would widen the effective sandbox, such as clearing a narrower rung list that overrides the project's, is still judged as a widening.
  • A running worker keeps its sandbox until it exits; revocation applies at the next dispatch.

Note

High Risk
Changes sandbox confinement and who can widen agent filesystem/network access; misconfigured policy or grant logic could expose credential paths or host sockets.

Overview
Introduces a sandbox grant policy so the owner defines once which read/write paths the orchestrator may add without an approval, while credentials, network, session bus, and sockets stay owner-only regardless of policy.

project set --sandbox-policy stores versioned sandbox_policy with glob paths (~, <project>, *, **). Widening the policy is owner-required; narrowing or clearing it is operational. task update --sandbox and matching ladder set --sandbox widen one task or rung: additions fully inside the policy become operational sandbox.grant; anything else still opens an owner decision. Grants are audited (sandbox grant / sandbox revoke events, sandbox evidence on tasks), shown in task show / status, and re-checked at every dispatch—policy narrowing or path resolution changes revoke stale grants.

ask --kind credential --setting sandbox --sandbox JSON routes to the orchestrator when every requested item fits the policy. New lib/sandbox-policy.js implements parsing, matching, dispatch merge into spawn settings, and board/settings UI hooks.

Reviewed by Cursor Bugbot for commit 35cb9ea. Configure here.

Pre-submit review

  • Head: 9841d65
  • Fresh context: no
  • Acceptance: owner-set policy: project set --sandbox-policy (lib/project.js), test "the orchestrator grants paths inside the owner's policy" sets it as owner and checks version and owner_only: pass. Orchestrator per-task grant with audit and evidence: lib/tasks.js taskUpdate; the same test checks the setting audit (sandbox.grant), the sandbox grant event, sandbox evidence, task show, status, and that T1's dispatch writes the path while T2's does not: pass. Per-rung grant: ladder set medium --sandbox and its sandbox_grants record: pass. Outside the policy stays owner-required: refusal, {task, sandbox} escalation and why, no change to T1: pass. Owner-only classes never delegate: credentials, sockets (including a directory holding one), network and session_bus refused under ~/** and /run/**, in the CLI tests and the variant table: pass. Orchestrator cannot widen the policy: two refusals that open decisions: pass. Narrowing revokes at next dispatch: sandbox revoke events, task and rung removal, next dispatches without the paths; the drift test checks dispatch-time revocation for a link and for a socket that appears later: pass.
  • Rework coverage: merge conflicts in bin/tower-crane.js and docs/state.md resolved by keeping main's ci-local receipts text and adding this branch's --sandbox usage, flag, description and sandbox_grants field; docs regenerated, check:shared passes. Revuto P1 (directory holding a socket skips the owner): failure class is socket reach through a granted path that is not itself a socket. Siblings: a socket at depth, a link to a socket, an unreadable directory, read versus write grants, grant time versus dispatch time (live()), task versus rung grants (same ownerOnly). Table rows in test/sandbox-policy.test.js variant table plus the CLI drift test; both fail without the fix and pass with it.
  • Revuto at head: capped at the 2-round limit (nonblocking per ci checks); its two socket findings are fixed in lib/sandbox-policy.js and show as no longer open.
  • CI at head: shared files and CodeQL pending
  • Left open: none
  • Result: pass

🤖 Generated with Claude Code

avifenesh and others added 3 commits October 21, 2026 00:58
…ithin it

project set --sandbox-policy records the read and write paths the
orchestrator may add to one task's sandbox (task update --sandbox) or a
rung's (ladder set --sandbox). Setting or widening it is owner-required;
narrowing or clearing it is operational. Credentials, network hosts, the
session bus and sockets stay owner-only whatever the policy lists, and
anything outside it opens the owner's decision.

Each grant records a setting audit, a sandbox grant event and sandbox
evidence naming who granted it, the policy version and the items.
task show and status list active widenings. A policy change revokes
grants it no longer covers, and every dispatch judges them again. A
worker's credential ask for sandbox items inside the policy goes to the
orchestrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 20, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 04c4a007-2bff-43c7-951b-11666e676811)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 20, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 4b72a100-278b-4bee-b271-327f707740ed)

@revuto-review revuto-review 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.

This is an auto review done by revuto.


I found one problem in how the owner-only sockets class is enforced. Credentials and the session bus are refused when a grant path holds them (within(d, p)). Sockets are only refused when the item is itself a socket or touches /run, /var/run or the runtime dir. A directory that contains a socket elsewhere passes sandbox.grant without the owner, and the check at dispatch time has the same gap. This goes against docs/state.md#sandbox-policy, which says "no policy hands one out".

Comment thread lib/sandbox-policy.js Outdated
Comment on lines +236 to +238
let socket = false;
try { socket = fs.statSync(p).isSocket(); } catch { /* not there yet */ }
if (socket || touches(r.sockets)) return 'sockets';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is an auto review done by revuto.


Granting a directory that holds a socket skips the owner. The sockets class only catches an item that is itself a socket (fs.statSync(p).isSocket()) or that touches /run, /var/run or the runtime dir. A directory holding a socket outside those places is not caught. Credentials and the bus, by contrast, are refused for any path that holds one.

So under a policy like /tmp/** or ~/**, the orchestrator can grant itself:

  • read /tmp/ssh-XXXX (an ssh-agent socket),
  • /tmp/.X11-unix or /tmp/tmux-$UID (input injection, or commands run outside the sandbox),
  • ~/.colima/default or a podman machine dir (a docker-equivalent socket).

ownerOnly passes each of these, and live() at dispatch never notices either. Even a read grant is enough here: Linux connect() on a Unix socket is not blocked by a read-only mount. This repo already treats socket reach as path-based; see wholeMount in lib/agents.js, where a codex write path holding the runtime dir mounts the bus.

That breaks the #sandbox-policy promise that "no policy hands one out". A fix would be to also return sockets when the item is a directory that holds a socket. One way is a bounded walk or a find -type s-style scan. The other is to treat "holds a socket" as owner-only, with the same within(d, p) direction the credentials check uses. That judgement should also run again at dispatch.

@revuto-review revuto-review 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.

This is an auto review done by revuto.


The earlier owner-only socket finding is still open. Granting a directory containing a socket outside the recognized runtime locations can bypass owner approval.

Comment thread lib/sandbox-policy.js Outdated
if (touches(r.bus)) return 'session_bus';
let socket = false;
try { socket = fs.statSync(p).isSocket(); } catch { /* not there yet */ }
if (socket || touches(r.sockets)) return 'sockets';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is an auto review done by revuto.


[P1] Still open: directory grants bypass the owner-only socket check. fs.statSync(p).isSocket() checks only the granted path itself, and r.sockets contains only /run, /var/run and the runtime directory. Under a policy covering /tmp/**, a write grant for /tmp/ssh-XXXX therefore passes even when it contains an ssh-agent socket, exposing that socket without owner approval. live() uses the same check, so dispatch retains the grant. Reject directories containing sockets too, and add a regression covering both grant-time and dispatch-time checks.

avifenesh and others added 2 commits October 21, 2026 05:05
…licy

The sockets class caught only an item that was a socket itself or lay near
/run, /var/run or the runtime directory. A granted directory holding an
ssh-agent, X11, tmux or colima socket passed, and connect() needs no write
access. ownerOnly now walks a granted directory for sockets and links to
them, counting an unreadable directory as one that may hold a socket, and
live() runs the same check at every dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 2fc5617d-26a7-4e31-9796-dff70189a1cd)

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