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. 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: 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, and 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 deliberate: it is exactly where §13’s
threat lives. The risk M7 defends against is untrusted code execution. File
access from a trusted harness is not part of that threat model, so the
container’s isolation is spent where the threat lives. 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
demonstrates this concretely: it edits a file host-side through ctx[:fs],
then reads the same file back with Bash from inside the container.
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. The workspace list (§3) contains it; the
sandbox does not.
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
when the target string appears zero times or more than once in the file.
Guessing would just paper over a bug in the caller’s plan. 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. No spawn path in Terret
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, not the thread (confirmed
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; that 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. It does not
try to recover a shell that may be in an unknown state; the next run gets a
fresh session.
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. ctx[:fs] authorizes against that same list, and in the Docker world,
the same list is 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 its real
target), and the result must fall inside one of the granted directories. A
trailing-separator guard means a workspace at /ws never accidentally admits
/ws-evil.
That same check closes both escapes on its own, with no special case for
either: a ../ traversal resolves to its real destination 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 that gets 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, and each workspace
directory bind-mounted at the same absolute path (§1). That path is 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. 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 outright: it does not exist inside the
container, so there is nothing to relocate it to. isolated? is true.
Two limits are inherent to the docker exec model rather than to this
implementation. spawn(env:) configures the docker CLI on the host, not the
process inside, so environment does not cross. Cancellation has the same gap:
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 deliberate. 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. It gets no special treatment. 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. A stale value never survives 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). 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 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, not 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. 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, so 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. The scrubber runs there instead of downstream of it, so 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. The scrubber lives inside normalize_payload, not as a read-time
filter over the projection, and that placement is what keeps the
“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). 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, not 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 needs its own adjustment, with a real cost. 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 apply. 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, not a present
guarantee. 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). 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 plainly: the redaction is not
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 is 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. That is 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.” This
contract has real consequences 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 yields the fiber
rather than the thread, and that is not something Ruby guarantees for free.
It was verified empirically on this Ruby: 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. None of this was
assumed. A tool seam that turned out not to cooperate would stall every agent
in the process on the first slow child, which is why proving it mattered more
than trusting it.
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)
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.