The Terret Execution World (v1)
M7 gives an agent hands: a workspace-scoped filesystem, a subprocess and shell seam, long-lived terminals, and a sandbox boundary in front of all of it (plan §6.6/§6.7). This is that primer, written before any of it is code.
1. One execution world
Two seams that could have been unrelated — file access and subprocess spawn
— share one execution world in Terret, and that sharing is the entire
reason M7’s acceptance is a single patch row instead of four coordinated
ones. ctx[:fs] and ctx[:subprocess] both resolve against the same
workspace; when a config row on the sandbox seam swaps none for the
Docker provider (§4), every tool built on either seam moves into the
container automatically, tool code untouched (plan §12’s acceptance,
literally: Read, Write, Edit, Bash, and PTY move together).
The Docker provider’s design is a bind mount, and it is worth stating
plainly rather than letting a reader assume something friendlier: each
granted workspace directory is mounted into the container at the same
absolute path it has on the host. ctx[:fs] operations run host-side,
against that mount — the bytes Read and Write touch are literally the
host’s filesystem, not a copy inside the container. What runs inside the
container is processes: ctx[:subprocess], the shell, and the terminal
seams wrap their argv into docker exec, and everything a spawned process
does — reading environment variables, opening a socket, forking a child,
walking /proc — happens behind the container boundary. That split is not
an implementation shortcut; it is exactly where §13’s threat lives. The
risk M7 defends against is untrusted code execution, not untrusted file
access from a trusted harness, so the container’s isolation is spent where
the threat actually is. Path containment (§3) is enforced identically in
both worlds, because it never depended on which side of the sandbox
boundary the syscall runs on. examples/exec_demo.rb walks the concrete
proof: the demo edits a file host-side through ctx[:fs], then reads it
back with Bash from inside the container — one file, one path, both
worlds.
2. The seams
Four services, one dependency chain: ctx[:terminals] and ctx[:shell]
both sit on ctx[:subprocess], and ctx[:subprocess] sits on
ctx[:sandbox] (§4). ctx[:fs] stands alone, contained by the workspace
list (§3) rather than by the sandbox.
ctx[:fs] — read(path), write(path, content),
edit(path, old, new), stat(path), glob(pattern). Every path is
realpath-contained to the granted workspace list before the op runs (§3),
and every op that passes containment also dispatches an fs/authorize
waterfall ({op:, path:}) that any plugin can veto; a Tools::Veto there
renders as a Terret::Exec::Denied tool error, the same shape containment
failures use. A listener may veto or admit only — a rewritten :path in a
listener’s return value is ignored, so containment is never delegated to a
listener. edit is a uniqueness-checked string replace: it raises
Terret::Exec::EditAmbiguous rather than guessing when the target string
appears zero times or more than once in the file — an ambiguous edit is a
bug in the caller’s plan, not something to resolve by picking the first
match. fs.watch is not part of v1; it stays out until a consumer needs
it.
ctx[:subprocess] — spawn(argv, cwd:, env:, stdin:, timeout:) and
pty_spawn(argv). Every argv passes ctx[:sandbox].wrap(argv, cwd:, tty:)
before it reaches Process.spawn or PTY.spawn — there is no spawn path
in Terret that bypasses the sandbox seam, by construction, because nothing
else is allowed to build the final argv. Timeout is cooperative
cancellation: a deadline loop backed by Process.wait(pid,
Process::WNOHANG), escalating from SIGTERM to SIGKILL after a grace
period if the child ignores the first signal. Both spawn and PTY reads
park the calling fiber rather than the thread — verified empirically on
this Ruby under Async (§8) — so a slow child never stalls another
agent’s turn.
ctx[:shell] — one persistent bash process per key (ordinarily an
agent id), built on pty_spawn. run(cmd) drives it with a sentinel
protocol: the command is written followed by a marker that echoes the exit
status, and the shell reads until it sees the marker. Because the same
bash process serves every call for a key, cd and export from one call
are visible to the next — state persists the way a human’s terminal
session would, which is the entire point of the seam existing separately
from a one-shot spawn. A command that times out is killed and the bash
session restarts rather than trying to recover a shell that may be in an
unknown state; the next run gets a fresh session, stated honestly rather
than pretended away.
ctx[:terminals] — named, long-lived PTYs, capped at max_terminals
(default 8). open(name, argv) registers a handle; input(name, text)
and read(name) round-trip against it; close(name) reaps the process and
is idempotent. Terminals outlive a single tool call by design — they are
how a long-running interactive process (a REPL, a dev server) stays
addressable across a turn — and an agent’s disposal closes every terminal
it opened.
3. Workspace scoping
An agent is granted one or more directories through the workspace:
config row — the same list ctx[:fs] authorizes against and, in the
Docker world, the same list bind-mounted into the container (§1).
Containment is realpath-based, not string-based: a path is expanded, its
deepest existing prefix is resolved with File.realpath (so a symlink is
followed to what it actually points at), and the result must fall inside
one of the granted directories — with a trailing-separator guard, so a
workspace at /ws never accidentally admits /ws-evil.
Two escapes fail closed through that same check rather than needing
special cases: a ../ traversal resolves to wherever it actually points
before containment is checked, and a symlink created inside the workspace
that points outside it is followed to its real target before the check
runs, so both land outside the granted list and both raise
Terret::Exec::Denied. An empty workspace list denies everything — there
is no “no restriction configured” state that fails open.
4. The sandbox seam
ctx[:sandbox] is the seam every argv passes through before it becomes a
real process (§2). Its contract is small on purpose: wrap(argv, cwd:,
tty:) returns the argv actually spawned, isolated? reports whether that
argv runs inside a process boundary, and workspace_ready! is the hook a
provider uses to make sure its execution world exists before the first
spawn (a no-op for none; for Docker, it starts the long-lived container
if one is not already running).
tty: is how the calling path declares what it is. pty_spawn passes
true and spawn never does, because a provider that puts a terminal on
the far side of the seam has to be told when one is wanted and cannot
guess: docker exec -i -t against pipe stdin fails outright, so the flag
cannot simply be always-on. none accepts and ignores it — the host pty
the caller already holds is the terminal.
none is the identity provider: wrap returns its argument
unchanged, isolated? is false. It is the explicit, opt-in-only trusted
mode (§13) — a profile that wants it says so.
docker is the default-isolation provider (§13): a long-lived
container per boot, --network none unless config overrides it, each
workspace directory bind-mounted at the same absolute path (§1) — the
realpath, the same resolution ctx[:fs] applies to its own roots, so
both services agree on what a workspace directory is called. It runs as
the host’s uid:gid by default, so files the container creates in that
read-write mount stay editable by ctx[:fs]; user: nil opts back into
root. wrap(argv, cwd:, tty:) turns argv into
["docker", "exec", "-i", ("-t" when tty:), "-w", cwd, container_id, *argv]
-i is always present, because without it docker exec does not attach
stdin at all and neither a written stdin: nor ctx[:shell]’s protocol
would reach the command. -t rides the PTY path only, and it is not
cosmetic: without a terminal inside the container stty -echo has nothing
to quiet while the host pty keeps echoing, so the echoed request line —
session sentinel and all — lands in what ctx[:shell] reads back as the
command’s output. A cwd outside the granted workspace is refused rather
than relocated, because it does not exist inside the container.
isolated? is true.
Two limits are inherent to the docker exec model rather than to this
implementation. Environment does not cross: spawn(env:) configures the
docker CLI on the host, not the process inside. And neither does
cancellation — every kill signals the host-side CLI, so a timed-out
command is abandoned but keeps running inside the container, and
ctx[:shell]’s process-group sweep does not reach it. Stopping the
container is what ends it.
Swapping one for the other is a single patch row (plan §7) — the mechanism M7’s acceptance stands on:
- id: sandbox
plugin: Terret::Sandbox::Docker
config: { image: "...", network: "none", workspace: [...] }
Because every tool built on ctx[:fs]/ctx[:subprocess] reaches a real
process only through this row, that one row moves Bash, Read, Write,
Edit, and PTY into the container together. No tool file changes.
5. The std tools and their names
Terret’s std tools carry Claude Code’s tool names verbatim: Read,
Write, Edit, Glob, Grep, Bash, WebFetch, plus four with no CC
equivalent — terminal_open, terminal_input, terminal_read,
terminal_close. There is no alias map.
That decision (plan §6.7) is not cosmetic. Orchestrator allow lists — the
AllowList patterns a deployment ships — are already written against CC’s
names, and the pattern format they’re written in (File.fnmatch,
case-sensitive) has already hardened around those exact strings.
set_policy (docs/protocol.md) ships CC-shaped patterns over the wire
today. Inventing Terret-native names would mean every existing allow list
needs a translation layer that does nothing but rename, forever. The
mcp__server__tool double-underscore namespace (docs/mcp.md) is untouched
by this — it names a different kind of tool source, and MCP tools keep
their own convention.
| Tool | mutating | approval | concurrency |
|---|---|---|---|
Read |
false |
:never |
:parallel |
Glob |
false |
:never |
:parallel |
Grep |
false |
:never |
:parallel |
Write |
true |
:policy |
:serial |
Edit |
true |
:policy |
:serial |
Bash |
true |
:always unsandboxed / :policy sandboxed |
:serial |
WebFetch |
false |
:policy |
:serial |
terminal_open/input/read/close |
true |
:policy |
:serial |
WebFetch is the one tool in this roster that does not move into the
container with the others: it egresses host-side through Net::HTTP, so a
sandbox row’s network: mode does not govern it. It is bounded instead by
its own deny-by-default domain allow list and an SSRF floor that refuses
loopback and link-local targets (docs/security.md); everything else here
runs behind ctx[:sandbox].
Bash’s approval is the one entry in this table that is not a static
value: it is derived from ctx[:sandbox].isolated? at registration
time (§13 — outside a sandbox, an agent that can run arbitrary shell
commands needs a human every time; inside one, the container is already a
backstop, so Bash is declared like any other mutating tool instead of
specially). That derivation is captured once, at registration, not read
live on every call — which matters when the sandbox changes hot: the
std-tools service listens for config/updated, re-derives the verdict,
and re-registers Bash when it has moved, rather than leaving a stale
value in place after a live sandbox swap.
What that derivation changes today is what the Definition declares,
not what a caller experiences. The only consumer is the approvals gate,
whose rule is always || (policy && mutating) — and Bash is mutating in
both states, so :policy and :always park it identically. The
distinction is real metadata that a future consumer can act on (an M8
candidate: a gate that treats :policy as “ask once per session” or
defers to per-agent policy while :always keeps asking every time), and
gems/terret-core/test/approvals_test.rb pins the present collapse so
that the day the two stop behaving alike is a deliberate one. Until then,
do not read this row as “a sandbox makes Bash stop asking”.
concurrency: is declared metadata, not yet enforced. The loop keeps
executing every call in a step sequentially in M7; the field exists so
M8’s tool barrier has something honest to read when it starts letting
:parallel-declared calls actually run concurrently. Task, job_*, and
TodoWrite are the M8 tools that live on ctx[:jobs] and the subagent
seam; they carry their own roster in docs/subagents.md rather than this
one. job_start in particular derives its approval from sandbox isolation
exactly as Bash does above — :always unsandboxed, :policy sandboxed,
re-derived on a hot sandbox swap through the same config/updated
listener — because it runs bash -lc <cmd> in a fresh shell, so a
background command is never silently at a weaker bar than a foreground one
(job_collect reads a buffer and never asks; job_stop is a static
:policy). Their names follow the same rule as the rest of it: Claude
Code’s spelling verbatim where CC has the tool, snake_case where it does
not — which is why Task and TodoWrite are capitalized and the job
tools are not (docs/subagents.md).
6. Redaction
Two layers, doing different jobs. tools/post_execute is a waterfall
every tool result already passes through
(docs/terret-implementation-plan.md §6.3); a redactor listening there
rewrites a Result’s content and error before either becomes the durable
tool/result payload, catching the common case — a tool that happened to return a
credential — before it is ever logged.
That is not sufficient on its own, because a secret can enter the log
through a path that never touches a tool result at all — a user/message,
a context/injected steer, a plugin event. The backstop is
Sessions#register_scrubber(callable), an effect (like a prompt section —
disposing unregisters it) that runs over every String value inside
normalize_payload, the append boundary every event of every type passes
through. Because the scrubber runs there rather than downstream of it,
both sides of the log invariant see identical, already-scrubbed bytes: the
stored event and every projection derived from it (derive_messages, the
digest assert_log_invariant! checks a request against) agree by
construction, not by two independent redaction passes that could drift
apart. This is deliberate: putting the scrubber inside normalize_payload,
rather than as a read-time filter over the projection, is what keeps
CLAUDE.md’s “model-visible means logged” invariant honest — a filtered
read would mean the log itself still held the secret.
The scrubber does not reach two kinds of value, both deliberately. It
skips the log’s own structural identifiers — tool call ids, the part tag
decode_part dispatches on, lineage, verdicts, the live allow list
(Sessions::STRUCTURAL_KEYS) — because a pattern generic enough to match
a credential matches a hex id too, and collapsing two tool call ids into
one replacement token is a request every provider rejects, permanently, in
an append-only log. That exemption is positional rather than by name: it
holds at the top of a payload and inside an assistant/message’s encoded
parts, and stops the moment anything content-bearing is entered, so the
args[:content] a Write call carries is scrubbed like any other text.
A tool NAME is deliberately not on that list: the model chooses it, so it
is content, and redacting one fails safe — the name stops resolving and
the call comes back as a not-found error, where a collapsed id would
instead poison the session for good.
Streamed text is the other adjustment, and it costs something worth
naming. A provider’s deltas break at token boundaries, so a secret split
across two of them defeats a pattern that matches it perfectly — and a
scrubber can only be trusted with text it sees whole. So whenever any
scrubber is registered, the loop holds an entire run of assistant text and
appends it as ONE assistant/chunk event when the run ends (a tool call,
the message stop, or the end of the stream). Mount a redactor and the
chunk log stops carrying live token-by-token progress for that agent;
that is the trade, and it is affordable only because chunks are
replay/UI fidelity — derive_messages never projects them, so the digest
is untouched, and only their concatenation is contractual. With no
scrubber registered nothing changes: chunks stay delta-for-delta what the
provider sent. A stream that raises mid-run loses that run’s chunks
entirely; run_turn closes the failed turn, so no assistant/message
lands for that step either and the two logs agree.
One split survives that: a secret straddling a RUN boundary, where a model
emits a tool call partway through a credential, still reassembles from the
two chunks either side of it. What that costs is bounded — the
authoritative assistant/message is scrubbed whole, so the model’s own
context never carries the secret, and only a reader concatenating raw
chunk events can recover it. It is a far narrower hole than the
token-boundary split it replaced, which leaked on every stream long enough
to have one.
Four more limits worth stating plainly. Enabling a redactor does not
redact history already in the log — the log is append-only, so the
scrubber governs what is appended from that moment on and nothing before
it. A tools/pre_execute veto short-circuits tools/post_execute
entirely, so a vetoed call’s result is covered by the append backstop
alone. Ordering among post_execute listeners is not pinned: middleware
registered ahead of the redactor sees unredacted results, and a contract
for that ordering is an M8 note rather than something to assume. And
resume refuses to replay an owed tool call whose stored name OR arguments
carry the replacement token, because re-running a command with a
substituted literal is a different command (§7) — and a redacted name
would otherwise come back as “no such tool”, telling a model its roster is
broken when the log simply rewrote its own record. Only the redactor’s own
token is recognized, so a scrubber registered directly with some other
replacement does not trigger that refusal.
Patterns are config — regexp source strings, compiled by the redactor
plugin — until ctx[:credentials] (plan §6.9) lands in M8 and can drive
them from something more structured. State that limit rather than
implying the redaction is comprehensive: it catches known shapes, not
unknown ones.
Pick the replacement token with tool names in mind. A deployment whose
patterns could plausibly match a tool name wants a token matching
[A-Za-z0-9_-]+ rather than the default [REDACTED], because a redacted
name travels into the function names of projected assistant history —
permanently, the log being append-only — and a provider may reject a
function name carrying brackets. An over-broad pattern also rewrites words
inside the refusal message resume appends, which is merely cosmetic and
has the same cure: patterns narrow enough to match credentials and not
prose.
7. Destructive tools and at-least-once
The M6 resume contract (docs/lifecycle.md, “Resuming an open turn”) is
at-least-once for tool calls: a crash between a tool’s side effect and its
logged tool/result means resume re-executes the call, because the log
cannot tell the difference between “ran and didn’t log” and “never ran.”
That contract has real teeth here. Write and Bash are exactly the
tools where “ran twice” is observable — a re-run Write overwrites its
own prior output (harmlessly idempotent, as it happens), while a re-run
Bash command re-executes whatever shell command the model asked for,
verbatim, a second time. Terret does not paper over this: idempotency is
the tool’s own concern, not something the harness guarantees.
Harness-level idempotency keys remain a recorded M7+ item (plan §14), not
built here.
8. Concurrency
One reactor, no user-facing threads (plan §8): every agent’s turn, every
tool call, and every subprocess spawn or PTY read runs as an Async task
on the same Fiber scheduler. That only works if a blocking call actually
yields the fiber rather than the thread, and that is not something Ruby
guarantees for free — it was verified empirically on this Ruby rather than
assumed: PTY.spawn’s IO reads and Process.wait park only the calling
fiber under Async (a ticker fiber kept ticking through a blocking read in
the probe), the same way Thread::Queue#pop already did for M6’s parking
primitive. A tool seam that turned out not to cooperate would stall every
agent in the process on the first slow child, which is why this was proven
rather than trusted.
concurrency: metadata (§5) is declared now and honored by nobody yet —
the loop executes a step’s tool calls sequentially through M7, and M8’s
tool barrier is what will let :parallel-declared calls (Read, Glob,
Grep) actually run at once. Loop::MAX_STEPS (docs/lifecycle.md) still
bounds a turn the same way it always has; a tool-heavy turn that leans on
Bash and terminals in a loop is bounded by the same 25-step ceiling as any
other.