Contents

Terret Security Posture (v1)

This is plan §13 written out in full, not the four-sentence version. It names Terret’s threat model, the defaults each seam ships with, and what those defaults do not cover, stated as plainly as the rest of this house tries to be. Read it alongside docs/exec.md (the seams these defaults sit on), docs/lifecycle.md (the durable approval and hot-policy machinery the model below depends on), and docs/protocol.md (the socket’s own authority surface, §13’s other concern).

The threat model

Tool results are data. Whatever a tool returns (a file’s contents, a command’s stdout, a fetched web page) enters the session log as an ordinary payload and rides into the next model request as an ordinary message part. Terret never executes an instruction found inside a tool result; the model is the only interpreter of what a tool returns, and the only path from “a tool result contains something that looks like a command” to “something runs” is the model choosing to call a tool in response, exactly like any other model decision. This holds regardless of profile.

Whether a human stands between that model decision and its effect varies by profile. Two backstops exist, and either, both, or neither can be in place: durable human approval (ctx[:approvals], docs/lifecycle.md) parks a call until a human resolves it; the hot-reloadable allow list (Terret::Tools::AllowList, docs/mcp.md, docs/lifecycle.md) vetoes a call before it runs at all, deny-by-default, driven by policy-as-code with no person in the loop. Terret’s primary workload is autonomous agentic systems, and that workload mostly skips the human backstop in favor of the allow list. Approvals are an opt-in row, not the default. An autonomous profile with a permissive allow list and sandbox: none has no backstop between a prompt-injected instruction and a shell command. The sandbox (below) bounds the blast radius when that happens; it does not prevent it.

The tool floor

The deny-by-default allow list is enforced as an authoritative floor, a single predicate the tool registry consults after the pre_execute waterfall has run, on the exact call about to execute, rewrites included. That is a different thing from one tools/pre_execute listener among peers: no other row’s listener can register ahead of the floor and short-circuit past it, so it stays unbypassable regardless of listener registration order. The bug that motivated this was real: mounted in a later loader pass, the floor once sat behind a no-inject row’s listener in the waterfall, and a listener that admitted a call without delegating never reached it. As the registry gate now, the floor sees every admitted call, and its veto is final.

The consequence is a one-way valve. A pre_execute listener can make policy stricter (a veto there stops the call) but never looser. An admission it returns cannot resurrect a tool the floor denies, because that admission is exactly what the floor gate re-checks before execution. A per-agent AllowList (docs/mcp.md, docs/lifecycle.md) rides the agent’s forked context as such a listener, so it can only narrow what the gate would already admit. A veto it adds is honored; an admission it returns cannot lift a denial the gate makes. Deny-by-default is the ground state, and nothing a row registers, in any order, can widen it.

install_floor, which replaces that gate, is a privileged plugin capability. A mounted plugin can install or replace the floor, consistent with the rest of this house: a mounted plugin is trusted code the operator chose to boot (see the consent model below). A model, a tool result, and a socket frame cannot reach it. A second install replacing an active floor now warns; a legitimate re-mount or hot reconfigure disposes the old floor first and stays silent. An unexpected swap of the autonomous safety mechanism leaves a trace.

The patterns the gate enforces are a separate matter from the gate itself. They are the per-session, hot-reloadable policy: the last durable policy/updated in the session, or the bundle’s install-time floor when a session never updated (docs/lifecycle.md). A bearer token can rewrite them for its agent over the socket via set_policy, which changes what the gate admits for that one session; it does not, and cannot, replace the gate. That token authority is the socket’s concern, below.

Sandbox defaults

docker, with network: none, is the default for untrusted work; none requires explicit per-profile opt-in (plan §13, docs/exec.md §4). The isolation the container buys is process isolation. It is not filesystem isolation: the bind-mount design (docs/exec.md §1) means ctx[:fs] operations run host-side against the same bytes in both worlds. A workspace containment bug (below) is exactly as exploitable inside the container as outside it. What the container changes is what an escaped or malicious process can reach: no network by default, no view of the host process table, no access to anything not bind-mounted.

“No network by default” is a claim about spawned processes, and the one tool it does not cover is WebFetch: it egresses HOST-side through Net::HTTP, so network: none never touches it. WebFetch is governed solely by its own domain allow list (deny-by-default), plus its own SSRF floor. That floor resolves each target, on the model’s URL and on every redirect hop, and refuses loopback and link-local addresses, so an allowlisted name cannot launder a fetch to 127.0.0.1 or the 169.254.169.254 cloud-metadata endpoint. The floor resolves the bracket-stripped hostname, so an IPv6 bracket literal such as [::1] is caught the same way 127.0.0.1 always was. uri.host keeps the brackets, which no resolver recognizes; the check uses uri.hostname instead, which strips them. The domain policy (allow/deny) is a separate, coarser thing: it matches an IPv6 literal only when the pattern spells it bracketed ([::1]), because it globs the host as written and never resolves it. The floor is what stops the IPv6 loopback reach; the domain policy is IP-as-hostname string matching.

That floor is not full SSRF control on three counts. Private ranges stay reachable by default: a block_private_ranges knob to close them is a recorded §14 deferral (M9). It is resolve-then-connect; it does not pin the IP, so it offers no DNS-rebinding protection (also §14). WebFetch also has per-phase timeouts but no total wall-clock deadline, so a slowloris-shaped server can hold a fiber longer than any single phase allows. In practice that’s bounded by MAX_REDIRECTS × timeout, recorded in §14.

landlock (Linux) and seatbelt (macOS) are named in plan §6.6 as future providers. They are not built in M7; only none and docker exist. A profile that needs OS-native sandboxing without a container has no seam for it yet.

Approval defaults

Mutating fs tools (Write, Edit) default to :policy: they ask a human only where the approvals row is mounted at all, and even then only because they are mutating. Read/Glob/Grep never ask. Two tools have an approval that is not a static default. Bash and job_start both derive theirs from sandbox isolation at registration (docs/exec.md §5): :always when the sandbox is not isolating (ctx[:sandbox].isolated? is false), :policy when it is. The reasoning is the same for both. job_start runs bash -lc <cmd> in a fresh shell, so outside a sandbox it is arbitrary shell execution exactly as Bash is, and gets asked about every time regardless of policy. Inside one, the container is already a backstop, so each is governed like any other mutating tool, with no special case. Both captures are re-derived through the config/updated listener on a hot sandbox swap, so neither is left at the weaker bar after the isolation underneath it changes. (job_collect reads a buffer and never asks; job_stop is a static :policy.) WebFetch defaults to :policy behind its own domain-allow row (docs/exec.md §5).

Every one of these is a default a profile can turn off. :policy inside a profile with no approvals row mounted at all reduces to the allow list alone. Nobody should read :policy as a guarantee a human is watching.

Workspace containment

Every fs path and every workspace-relative glob is realpath-contained to the granted workspace: list (docs/exec.md §3): expand, resolve the deepest existing prefix’s symlinks, require the result inside a granted directory with a trailing-separator guard. Both traversal (../..) and a symlink planted inside the workspace pointing outside it fail closed through that same check. There is no separate traversal filter to get out of sync with the containment logic; both are just paths that resolve somewhere and get checked the same way everything else does. An empty or unconfigured workspace list denies every fs op; there is no ungranted-but-permitted state.

Containment is the last line for fs; every op also dispatches an fs/authorize waterfall after containment passes, so a profile can veto access to a specific path or pattern inside an otherwise-granted workspace (docs/exec.md §2) without touching the containment logic itself.

Redaction

Two layers (docs/exec.md §6): a tools/post_execute redactor rewriting tool results before they are logged, and a Sessions#register_scrubber backstop running inside normalize_payload at the append boundary. Every event type gets scrubbed before it becomes durable, tool results included, and the log-invariant digest sees the same scrubbed bytes on both sides by construction.

The redactor’s patterns are config, regexp source strings, so they catch known shapes. A pattern that doesn’t match a secret’s actual shape doesn’t catch it. This is detection of known shapes, not a guarantee that no credential can ever reach the log. What closes that gap for a credential the harness resolved is ctx[:credentials], below.

ctx[:credentials] (plan §6.9) now exists, and its security point is exactly that loop. It resolves a provider’s secret ENV-first by convention (<PROVIDER>_API_KEY), then from an optional AES-256-GCM file store, master key in TERRET_CREDENTIALS_KEY. A store present with no key REFUSES; it never falls back to anything unprotected. ENV always wins. Every value it resolves is fed to the append-boundary scrubber (Sessions#register_scrubber) as an exact-string pattern, so a resolved credential is caught by its literal bytes. A deployment doesn’t need to have named its shape in advance, even if a tool echoes it straight back into a result. The on-disk store format is documented in credentials.rb; a trt credentials set writer CLI and an OS-keychain backend are deferred (§14), so today a deployment writes the store itself.

Four boundaries belong in any threat model built on this (docs/exec.md §6 carries the mechanism). A log is append-only, so turning a redactor on protects what is appended afterwards and never what is already stored. A tools/pre_execute veto skips the post_execute layer entirely. The append backstop is the only cover for that result. Ordering among post_execute listeners is unpinned, so middleware registered ahead of the redactor reads results before they are rewritten. The log’s own structural identifiers are exempt from scrubbing by design. The exemption covers both the identifier VALUES and the field NAMES that carry them (Sessions::STRUCTURAL_KEYS), because a pattern that rewrote a tool call id, or the verdict key the approvals gate reads back, would break the session, not protect it. A secret-shaped Hash key deeper in content (an MCP tool’s structured_content, say) is scrubbed like any leaf. It is only where a structural identifier belongs that a credential a model plants there is not caught. Folding keys has one fail-closed corner: two content keys in the same mapping that redact to the same token would collide. The append raises: it never silently drops one. The turn fails, no secret leaks, and the cost is a low-grade denial of service a model would have to engineer against itself.

The socket’s authority model

A connection’s bearer token authorizes one agent completely (docs/protocol.md); there is no per-frame capability split. That token can inject, cancel, resolve approvals, and set_policy, replacing the very allow list that is often the only backstop an autonomous profile has. Plan §14 records this plainly: whoever holds the token for an agent can rewrite what that agent is allowed to do, a power that reaches well past choosing its next action. That is a deliberate v1 stance. Splitting per-frame capabilities from the bearer token is real design work that belongs with the multi-tenant story below, not with M7. Treat a bearer token as equivalent to full operator access to that agent, because it is.

Replay is the socket’s other authority surface. It is bounded: a reconnect’s from_seq cannot make the server read an unbounded history. Two caps enforce it. replay_limit (default 10,000) pulls a from_seq reaching further back than the window forward to the newest replay_limit events, and tells the client so with a replay_truncated frame. max_concurrent_replays (default 4) gates how many replays read the log at once, so a burst of reconnects cannot stampede it. This is the concrete cap plan §9.4 promised; the wire detail lives in docs/protocol.md.

Config is data; code is consent. A Terret profile is portable configuration an operator may have downloaded from anywhere, so the two places a profile could smuggle in code execution are both gated behind an explicit --allow-config-ruby flag (docs/composition.md, §5): a !ruby scalar, and a plugins: entry that names a filesystem PATH instead of a load-path feature name. Requiring a path is code execution with a YAML extension: it reaches Ruby the operator never installed. It needs the same consent the !ruby tag does. A load-path feature name (terret/exec) is different: it resolves only through gems the operator’s Bundler already put on the path, so it requires no flag.

A bundle’s own requires: are not gated the same way, and the asymmetry is deliberate. A bundle ships inside an installed gem: it is the operator-installed gem’s own trusted decision about what to load before its rows resolve, whereas a profile is config that merely names bundles. Trust follows installation, not authorship of a YAML file.

doctor is safe-by-default: it resolves and reports on a profile’s rows without booting. By default it refuses a path-shaped plugins: require; it does not run it. (It still loads load-path feature names, which are operator-installed code.) trt doctor <untrusted profile> inspects the profile. It does not execute the code the profile carries.

Multi-tenancy

Agents inside one process share a reactor and a service tree; the isolation between them is the forked Context (plan §4.1), not the OS. That is adequate for agents under common ownership: one team’s fleet of agents, none of which has reason to attack another. It is not adequate for mutually untrusted agents. A forked context is what is supposed to keep one agent’s tool registrations and listeners from leaking into another’s, and that guarantee has a specific gap this milestone closes: Registry#register recorded its effect on the root context regardless of who called it, so a tool an agent registered for itself survived that agent’s disposal (plan §14’s recorded bleed). That gap matters more now than it used to, because the tools an agent can register carry filesystem and subprocess authority (docs/exec.md), beyond conversational state. Closing it stops one specific leak; it does not turn a fork into a security boundary. A wedged fiber, a memory leak, or process-wide resource exhaustion still touches every agent sharing the process. Where the work is mutually untrusted, the boundary that matters is a separate process plus the sandbox (docs/exec.md §4), not a fork and not this seam.

At-least-once and idempotency

Crash recovery is at-least-once for tool calls (docs/lifecycle.md, “Resuming an open turn”): a call whose tool/result never made it into the log before the process died may have already run, in whole or in part, and resume runs it again regardless. Write and Bash are where this is visible, not harmless: a repeated shell command is not idempotent by default. Terret’s contract stops at “the harness never loses a call and never invents a fake result for one that hasn’t run”; it does not extend to “a tool never runs twice.” Idempotency is the tool’s own concern in v1. Harness-level idempotency keys, a way to let a tool declare “this call, if seen again with the same id, is a no-op,” are a recorded M7+ candidate (plan §14). This milestone does not build them.