Skip to content

feat(tools): default shell timeout that detaches, and reads outside the working directory through the approval ladder (F47, F48) - #452

Open
plombeer31 wants to merge 3 commits into
harness/14-fusion-loop-tuningfrom
harness/15-session-boundaries
Open

plombeer31 wants to merge 3 commits into
harness/14-fusion-loop-tuningfrom
harness/15-session-boundaries

Conversation

@plombeer31

@plombeer31 plombeer31 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

What

Two session-boundary defaults from the second verification matrix (bench 2, findings 30–31), in the shape the operator asked for after reviewing the first version of this PR:

  • F47 — a default shell timeout that detaches instead of killing. os.shell.run had no timeout unless the model passed timeoutMs; a cloud session ran a recursive grep over the home directory for 21 minutes, then another for 20. Now, when tools.shell.defaultTimeoutMs (default 10 minutes, 0 = none) elapses and the model did not pass its own timeoutMs, the command is not killed: the tool returns to the model with the output so far and a job id — still running after 10 min (job 3) — output so far below; os.shell.run {"wait": 3} keeps waiting, {"kill": 3} stops it, pass timeoutMs for a longer first wait. {wait}, {kill} and {jobs} are new call forms; a job dies when its turn ends unless the call carried keep: true, always when the session ends, at tools.shell.jobMaxMs (default 60 min), and the oldest un-kept job is evicted past tools.shell.maxJobs (default 3). An explicit timeoutMs still kills at its limit — the model asked for a bound. Nothing legitimate is killed at 10 minutes; a runaway command is bounded by the model's own decision, the turn's end, or the ceiling.
  • F48 — reads outside the working directory go through the approval ladder. Fusion workers already ran under a read scope; plain sessions read anything the account could read, and one read a sibling job's files and the harness's own artefacts. The worker mechanism moves to src/tools/read-scope/ and applies to every session: read tools and shell paths outside the working directory and the paths the user named in their own messages raise the new approval category fs_read_outside. At the asking levels the standard prompt names the path and the directory a "yes" widens to for the rest of the session; at level 5 / --no-approval it just runs; agent.readScope: unrestricted never asks; fusion workers keep the hard refusal (ephemeral sessions have no one to ask). System prefixes and the OS temp directory are never asked about.

Config version 66 → 67: tools.shell.defaultTimeoutMs, tools.shell.jobMaxMs, tools.shell.maxJobs, agent.readScope. Existing files upgrade additively.

Why

An agent that can block on an unbounded command or read anything the account can read is one bad step away from a 20-minute stall or from reading what it was never asked to read. Both defaults now put the decision where it belongs — the model for a long command, the user for a read outside the project — using mechanisms that already exist (the tool-result contract, the approval ladder), with the operator's one-line opt-outs kept (defaultTimeoutMs: 0, readScope: unrestricted).

How it was verified

  • npm run lint clean
  • npx vitest run src/approval src/tools src/runtime src/config src/agent src/sandbox src/session — 230 files / 2,835 tests green, 3 skipped
  • Full suite on the head of the stack: 928/929 files, 10,815 passed, 3 skipped, 1 failed — the pre-existing send-message-concurrency baseline (the whole stack rebased onto main at v0.6.2, 2026-09-17)
  • F47: the default elapses → detached result with job id and partial output, process still alive; wait returns the exit; kill ends the process group with no surviving child; un-kept jobs die at turn end, keep: true survives it; maxJobs eviction; jobMaxMs ceiling; explicit timeoutMs still kills; without the change the detach tests fail.
  • F48: an outside read at an asking level asks with the path and the widened directory, a yes silences the next read under it and not a sibling, a no is the refusal sentence; level 5 and user-named paths never ask; unrestricted never asks; workers still refuse (fs and shell); a shell path asks under the same category with the command; parallel reads under one directory ask once.
  • Live: the bench-2 matrix that found both ran with an OS-level stand-in for exactly these two rules from the cloud-control phase on — no further out-of-tree reads, no further runaway commands; re-run of the two local-planner failures (Gemma bugfix, Qwen site) and a local site first attempt on this exact build is in progress; results land as a comment on this PR

Stacked on #439 (harness/14-fusion-loop-tuning); merge in order.

…readScope

USER_CONFIG_VERSION 66 -> 67, one step carrying four fields; 66 joins the
supported input versions and the upgrade is additive for all of them (an
older file has none of the fields and takes the defaults).

`tools.shell.defaultTimeoutMs` (default 600 000) is the wall-clock wait
for an `os.shell.run` call whose `timeoutMs` the model omitted. A command
still running when it elapses is detached as a job the model can `wait`
for or `kill`, with its output so far in the result; `0` keeps the old
unbounded behaviour. An explicit per-call `timeoutMs` (including `0`)
always wins, and kills at its limit. An existing file has no `tools`
block and takes the default; the value is validated as a non-negative
finite integer.

`tools.shell.jobMaxMs` (default 3 600 000) is the absolute ceiling for a
shell job the default timeout detached, counted from its start;
`tools.shell.maxJobs` (default 3) is how many such jobs a session may
have running at once. Both positive integers: a `0` ceiling would mean
"kill at once" and a `0` limit "never detach", so both are refused.

`agent.readScope` (`"working-dir"` | `"unrestricted"`, default
`"working-dir"`): v66 files are accepted on input and take the default;
the parser rejects any other value.

The runtime config carries them as `config.tools.shell.{defaultTimeoutMs,
jobMaxMs, maxJobs}` for the shell tool to read and as `agent.readScope`
for the read scope. The mechanisms that read them land in the F47 and
F48 commits.
…g it — wait, kill, jobs, keep

`os.shell.run` without `timeoutMs` used to run unbounded; a recursive
grep over a home directory ran for twenty minutes until a person killed
it, and `agent.toolTimeoutMs` never applied to the shell. At
`tools.shell.defaultTimeoutMs` (10 min) the call now returns `ok` with
`details.detached: true`, `details.jobId`, `details.pid`, the output so
far, and a first line `still running after 10 min (job 3) — output so
far below; os.shell.run {"wait": 3} keeps waiting (up to another 10 min
per call), {"kill": 3} stops it, pass timeoutMs for a longer first
wait`. A build the operator's default interrupted is not a build the
model wanted stopped, so the process keeps running in its own process
group; its stdout/stderr keep being captured, head + tail, 1 MiB per
stream.

New call forms on the same tool: `{wait: id, timeoutMs?, keep?}` returns
the exit shaped like a fresh result (`details.jobId` kept) or the same
still-running result when the wait — the explicit `timeoutMs`, else the
default — elapses; a cancelled turn returns from a wait at once and
leaves the job running. `{kill: id}` is the group kill, `details.killed:
true`, the tail of the output. `{jobs: true}` lists the session's jobs
with id, command head, start and state. A call that mixes forms is
refused with the forms it named; unknown keys stay refused (F40). An
explicit `timeoutMs` still kills at its limit: the model asked for a
bound. The still-running and killed results show only the last lines
of output, because the result compressor keeps a twelve-line tail and
the notice above the command line has to be inside it.

The explicit-`timeoutMs` kill path — the limit resolution in
shell-timeout.ts (`source: "explicit" | "default"`), `details.timedOut`
and the `stopped after <n> s (timeoutMs) — output so far below` first
line — is the first version of this fix, which killed at the default
too; only the default's outcome changed.

Ownership (`src/tools/os/shell-jobs.ts`, one registry owned by the
bootstrap): a job dies when its turn ends (`executeTurn`'s `finally`,
the choke point every turn passes) unless the call that started it or a
later `wait` carried `keep: true`; every job dies when its session is
deleted, when `finish` completes it, at shutdown, and at
`tools.shell.jobMaxMs` from its start; at most `tools.shell.maxJobs`
run per session — the next detach stops the oldest un-kept job (the
oldest kept one when all are kept) and the result says so. A fusion
worker's jobs die with its turn. A tool built without a registry
(embedders, tests) gets a private one whose jobs die only at the
ceiling.

The primitive is `startCommandJob` (`src/sandbox/command-job.ts`): a
spawn whose lifetime is not tied to one await — `waitFor`, `stop`
(SIGTERM, SIGKILL after 2 s), `kill` — with `CappedOutput` behind it.
`runCommand` keeps its direct tree-kill; the process group (`detached`
on POSIX, `-pid` on timeout so a subshell's `sleep 30 &` cannot hold the
stdio pipes and the tool result) lives in the job, and so do its tests.
shell.ts is split to stay under the file limit: the interpretation
helpers into shell-interpretation.ts, the result rendering into
shell-result.ts, the job forms into shell-job-calls.ts. The descriptor
and the strict JSON schema carry the four forms; nothing is required at
the schema level because each form requires a different key.

Not covered: a runtime killed with SIGKILL cannot stop its jobs — they
run on until they exit or write to the closed pipe; the ceiling timer
died with the process.
… ask through the approval ladder

Until now only fusion workers had their reads confined; every other
session could read anywhere on disk, and a bench run showed a plain
cloud session reading a sibling run's solution, the harness's screen
dumps and the benchmark's own checker from far outside its working
directory. From config v67 (agent.readScope, default "working-dir")
every session's filesystem reads and shell path arguments are scoped to
the working directory plus the absolute / ~-prefixed paths the user
named in their own messages — never in model output; parsed from user
turns, recomputed per step by the step executor and handed down as
ToolContext.readRoots (a named file widens to that file, a named
directory to that directory; quoted paths keep their spaces, sentence
punctuation is stripped). Lexical or canonical containment both count.
src/tools/read-scope/ replaces src/tools/fusion/worker-read-scope.ts;
the worker check is unchanged (its tests moved, byte-for-byte in
meaning) and the worker names stay exported from src/tools/fusion as
aliases.

Covered: os.fs.read/list/grep/glob/diff/hash/watch/read_document/
archive.*, vision.describe and verify.syntax, wrapped at the registry;
and os.shell.run through a narrow token check — a token that is an
absolute path under home or the directory homes live in, and lies
outside every root, or a `..` climb that escapes every root. System
prefixes (/usr /bin /sbin /opt /dev /etc /System /Library
/Applications, C:\Windows, C:\Program Files) are never questioned, and
neither is the OS temp directory: a path under os.tmpdir() (plus /tmp
and /var/tmp on POSIX) is scratch space, always in scope, fs and shell
alike — models routinely write a helper to /tmp and run it, or redirect
into /tmp/x.txt and read it back, and the threat was other users' homes
and a benchmark's sibling trees. Fusion workers keep their narrower
rule (working directory plus the fan-out's write scope, never the
brief) as a hard refusal for reads and shell alike. The opt-out is one
config line, agent.readScope: "unrestricted", read per call, so it
needs no restart.

A read outside that scope is not a one-line refusal at every approval
level (which would make the operator's answer a config edit). It is a
question, on the same ladder as every other gated action:

- New ApprovalCategory "fs_read_outside" (label "read outside the
  working directory"), pinned at level 5 like trust_config and other,
  grantable. At the asking levels (1-4) a read-class call or a shell
  command naming a path outside the working directory, the user-named
  paths and the directories approved so far raises the standard prompt
  through requireApproval / the gate / the router, on the TUI modal,
  the CLI stdin prompt and the Telegram bridge alike. The prompt names
  the path, the working directory and what a yes means.
- A directory grant widens the session's read roots: `y` remembers the
  directory the call named (or the parent of the file it named) in
  ReadScopeGrants on the gate, keyed by session id and dropped by
  clearSessionGrants with the category grants, so the next read under
  it runs unasked. `[s]` grants the category: read anywhere this
  session. A `n` answers the model with the one-line refusal naming
  the path, the working directory and the opt-out.
- Auto-allowed at level 5 / --no-approval: nothing asks, nothing is
  refused, like the other categories pinned there.
- `agent.readScope: unrestricted` never asks: the scope check is off
  for every session, read per call, no restart.
- Fusion workers keep the hard refusal for reads and shell alike: an
  ephemeral session has nobody to ask; WORKER_READ_REFUSAL_REASON stays.

Questions are asked one at a time per session (a per-session queue in
ReadOutsideApprover): read tools run in parallel inside a batch, and a
second prompt raised while the first waits would strand one of them on
a surface that shows a single pending request. The second read waits
for the answer and re-checks against the widened roots, so three reads
under one new directory ask once.

Wiring: confineReads takes the ladder's DangerousToolOptions and throws
when given a readScope without it, so the scope cannot be half-wired
into a silent refusal. The public surface of read-scope is confineReads,
findSessionReadOutside (the path, not the refusal), userNamedPaths and
findShellPathOutsideScope; the shell check shares shellCommandLine for
the prompt's preview. The batch planner's per-tool category table does
not list fs_read_outside under os.shell.run / verify.run on purpose: it
would pin every shell batch solo below level 5 for a check that fires
only when a command names an outside path, and a denial there changes
no state.

Known cost: a path the user described but did not name ("my Downloads
folder") is asked about rather than read. That is the intended trade.
@plombeer31
plombeer31 force-pushed the harness/15-session-boundaries branch from 40d52cd to 1ff5148 Compare September 16, 2026 22:03
@plombeer31
plombeer31 force-pushed the harness/14-fusion-loop-tuning branch from 5e9c3ad to 309ec92 Compare September 16, 2026 22:03
@plombeer31 plombeer31 changed the title feat(tools): default shell timeout and reads confined to the working directory (F47, F48) feat(tools): default shell timeout that detaches, and reads outside the working directory through the approval ladder (F47, F48) Sep 16, 2026
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