Contents

Terret Security Posture (v1)

This is plan §13 written out as the real thing rather than four sentences. It names Terret’s threat model, the defaults each seam ships with, and — as plainly as the rest of this house tries to be — what those defaults do not cover. 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.

What is not universal is whether a human stands between that model decision and its effect. Two backstops exist, and either, both, or neither can be in place for a given profile: 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 rather than a person. 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. Say the quiet part: 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, not as one tools/pre_execute listener among peers. The distinction is the whole point, because it is what makes the floor unbypassable regardless of listener registration order. The floor is a single predicate the tool registry consults after the pre_execute waterfall has run, on the exact call that is about to execute — rewrites and all — rather than as a listener some other row’s listener could register ahead of and short-circuit past. That ordering bug was real: mounted in a later loader pass, the floor 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, it sees the 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, but 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 — replacing that gate — is a privileged plugin capability, not a model-reachable one. A mounted plugin can install or replace the floor, which is 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), so an unexpected swap of the autonomous safety mechanism leaves a trace rather than happening in silence.

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, 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 — it 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, so the check uses uri.hostname, 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 rather than resolving it. The floor is what actually 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), not shipped here. It is resolve-then-connect rather than IP-pinned, so it is not DNS-rebinding protection (also §14). And WebFetch 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 — bounded in practice by MAX_REDIRECTS × timeout, recorded in §14.

landlock (Linux) and seatbelt (macOS) are named in plan §6.6 as future providers and 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 single time regardless of policy; inside one, the container is already a backstop, so each is governed like any other mutating tool instead of specially. 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 — stated so nobody reads “: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, because 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, not the only one: 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, so every event type — not just tool results — gets scrubbed before it becomes durable, 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 actually 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, and a store present with no key REFUSES rather than falling back to anything unprotected. ENV always wins. The payoff is not the lookup but what it does with the answer: 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 rather than by a shape a deployment had to name 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, leaving the append backstop as 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. And 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 rather than 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, and the append raises rather than silently dropping 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 — critically — 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, not just what it does next. That is a deliberate v1 stance, not an oversight left implicit — 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, and it is bounded rather than open: 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, and 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 rather than a load-path feature name. Requiring a path is code execution with a YAML extension — it reaches Ruby the operator never installed — so 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, and by default it refuses a path-shaped plugins: require rather than running it (it still loads load-path feature names, which are operator-installed code). So trt doctor <untrusted profile> inspects the profile rather than executing code it 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 — and 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), not just 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 regardless. Where the work is actually 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 rather than 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), not something this milestone builds.