Skip to main content
explanation

What does Ethos guarantee, and what is outside its security boundary?

You are about to deploy an agent that reads your filesystem, makes network calls, runs commands, and speaks on channels your users can see. Before you do, you need one thing this page exists to give you: a list of what the framework promises to be correct about, so you know what is left for you to add.

Most security documentation answers which controls exist. That is not the question. The question is which controls are promises — the ones where a bypass is a vulnerability we own, rather than a bug we will fix on the normal queue. Until that list is written down, every reader assembles their own version of it from a threat model, a controls catalogue, and the source. Nobody outside the repo can do that assembly.

This page is the list. It has three parts: the tiers (which code is held to a promise), the guarantee register (the thirteen promises, each with the file and line that enforces it), and the non-boundaries (the things you would most reasonably mistake for a promise and should not).

Context​

Ethos partitions its own source into three tiers of ownership, and publishes a closed list of guarantees that the top tier is held to. One rule makes the partition real rather than decorative:

A Tier 2 extension must not be able to weaken a Tier 0 guarantee. If it can, either the extension is wrong or the guarantee was never in Tier 0.

Everything below is that rule's application. The corollary is what keeps it honest: a narrow boundary is only defensible if everything inside it is actually true. A wide, vague scope absorbs over-claims — nobody can hold us to "bundled extensions under extensions/" because nobody, including us, knows what it promised. A narrow published boundary has the opposite property: it converts every over-claim inside it into a valid external finding on the day it is published. That is the trade we are making deliberately.

Four readers use this page for four different decisions:

ReaderThe decisionWhat they read
OperatorWhat must I add myself before I can deploy this?The register's If you need more field, plus the production hardening checklist
Buyer or auditorWhat can I put in a security questionnaire and defend?The register, plus the tier roster
Security researcherIs my finding in scope?The decision table in SECURITY.md
ContributorWhat bar is my PR held to?The tier roster and .architecture-state.yaml

Discussion​

The three tiers​

Tiers are assigned per workspace package, committed and dated in .architecture-state.yaml at the repo root. That file is authoritative for any individual package; the roster below names the members so you can read the shape without opening it. 150 packages: 11 at Tier 0, 27 at Tier 1, 112 at Tier 2.

The assignment is committed before any report arrives. A tier decided in the same week a report lands against that module is evidence of nothing, and a reporter reading git log can say so with a timestamp.

Tier 0 — the security kernel. Guaranteed. CVE-eligible. No exception path under ARCHITECTURE.md §VIII.

  • @ethosagent/types — the contracts that shape the boundary
  • The seven safety packages: @ethosagent/safety-injection, safety-network, safety-redact, safety-channel, safety-scanner, safety-watcher, safety-groundtruth
  • @ethosagent/core — enforcement points only, not all of core: the tool registry, the per-call enforcement, tool-processing and tool-rejection stages, the injection prelude and post-read downgrade sites in agent-loop.ts and context-assembly.ts, the approval-posture guard, result defense, the scoped/* decorators, fs-reach.ts, path-boundary.ts, url-validator.ts, the capability validator and resolver, the hook registry, script-safe.ts
  • @ethosagent/storage-fs — ScopedStorage and the always-deny floor
  • @ethosagent/wiring — safety composition only: the AgentSafety bundle assembly, danger-predicate.ts, approval-seams.ts, smart-approver.ts, decision-site.ts and decision-injection-classifier.ts (the decision-provider threshold, shadow and fallback rules), resolve-execution-posture.ts

The per-file lists for core and wiring are in the sidecar under kernel_paths. The rest of each package is Tier 1 by default — including two files a reader might expect here and will not find. sanitize-output.ts (stripAnsiEscapes) is a pure string utility every caller opts into rather than a chokepoint anything guarantees runs; safety-conformance.ts tests the injected safety bundle and sits on no request path. The sidecar records both exclusions with their reasons.

Tier 1 — guarded surfaces. Not the kernel; they sit on a trust boundary. They get kernel-adjacent review and they are in disclosure scope, but the promise is "we review this hard", not "we guarantee this".

GroupModules
Ingressgateway and the seven adapters — platform-discord, platform-email, platform-meeting, platform-slack, platform-telegram, platform-voice, platform-whatsapp
Executiontools-terminal, tools-code, execution-docker, execution-local, execution-ssh, execution-pi, execution-coding-agents
Third-party codetools-mcp, skills, skill-evolver, plugin-loader
Network surfaceapps/web-api
Data at rest and credentialssession-sqlite, storage-crypto, secrets-aws, plugin-sdk, worker-router
Audit and deliveryobservability-sqlite, delivery-ledger

Tier 2 — extensions, not owned. The remaining 112 packages. Best-effort. A bug is a bug, not a CVE. They inherit exactly what the kernel enforces and nothing more.

The way to make a Tier 2 extension safe is not to review it harder. It is to make the kernel enforce the property.

The tier contract​

TierWhat we promiseDisclosure treatmentChange controlTest bar
0The named guarantee holds, or it is a vulnerabilityCVE-eligible; 90-day coordinated disclosureUnanimous maintainers; no §VIII exception pathConformance suite, adversarial tests, validator rule
1We review this on a trust boundary and respond to reportsIn disclosure scope; fix-or-ETA commitment, no correctness guaranteeTwo maintainers; §VIII exception permitted with expiryAdversarial tests for the boundary it sits on
2Nothing beyond what the kernel enforcesOut of scope; triaged as a normal bugNormal reviewNormal tests

Tier location tells you where a bug lives. The register tells you whether it matters. Triage reads the second, not the first — a Tier 2 finding that reaches a Tier 0 guarantee is a Tier 0 finding, and the vulnerability is the kernel's failure to contain it, not the extension.

The guarantee register​

Thirteen guarantees. Each carries five fields, and all five are required: a claim an operator can act on, the file:line that enforces it, the adjacent thing that is explicitly not covered, how to verify it yourself, and what to do if you need more. A register with only the first two fields is marketing. With the first three it is a disclaimer. The fourth makes it falsifiable; the fifth makes it worth your time.

Anything not in this register is not guaranteed. It is a closed list, not a highlight reel — if it were a selection of our best controls, absence would mean nothing and you would be back to assembling the boundary by hand.

G-TOOLS — Tool allowlist​

  • Claim. A personality (a directory of files that decides an agent's tools, memory, and model) can only call the tools listed in its toolset. The list is enforced twice: the model is never shown a tool outside it, and a call to a tool outside it is refused at execution. A refusal is always signalled — every rejected call still emits a terminal error result, so a blocked tool can never be mistaken for one that ran. The surface a sandboxed script may call is the same list intersected with a static exclusion policy: a subset, never a superset.
  • Enforced at. tool-registry.ts:228 — toDefinitions filters what the LLM sees — and tool-registry.ts:403, where executeParallel re-checks the identical condition. The intent is stated in the source at :296: "allowedTools + filterOpts enforce tool access at execution time (belt-and-suspenders)." The shared refusal path is tool-rejection.ts:58 (emitToolRejection), and the script surface is derived at script-safe.ts:130 (scriptCallableFor).
  • Explicitly not covered. What an allowed tool then does with its authority. The allowlist decides which tools run, never what they may do once running — a personality granted terminal has the authority of terminal. The closest thing to a bound on that authority is G-CAP, and it bounds only the capabilities a tool takes through its injected context.
  • Verify it yourself. packages/core/src/__tests__/tool-registry.test.ts, tool-registry-exclude.test.ts, and script-safe.test.ts; or read the two call sites, which are 150 lines apart and check the same thing.
  • If you need more. Narrow the toolset, or run the exec-bearing tools under a container posture so the tool's authority is bounded by something other than the agent process's.

G-CAP — Tool capability scoping​

  • Claim. A tool reaches only the capabilities it declared, intersected with the personality's policy. Five capabilities are scoped this way — filesystem reach, network hosts, spawnable binaries, named secrets, and inbound attachments — and the intersection has one derivation, computed per call, so the surface a tool is given cannot drift from the surface the personality granted. A tool declaring more than the personality permits is rejected at registration rather than at first use, and an unknown or absent personality id resolves to deny-all, never to a wider set.
  • Enforced at. capability-validator.ts:19 (validateRegistration, the registration-time intersection check), capability-resolver.ts:130 (resolveCapabilities, the single derivation), and the three scoped decorators that carry the refusals: scoped-process.ts:127 (BINARY_NOT_ALLOWED), scoped-secrets.ts:12 (SECRET_NOT_DECLARED, with the segment-wise prefix match that refuses .. traversal), and scoped-attachments.ts:22 (ref, URL and scheme confinement). Filesystem reach and network egress have their own rows — G-FS and G-NET — because their floors hold regardless of what was declared.
  • Explicitly not covered. Three things, and the first is the big one. (a) This scopes what a tool takes through its injected context, not what a tool's own module does. A tool that imports node:child_process or globalThis.fetch directly bypasses every decorator here; keeping tools on the injected seams is Law 7's job and it is enforced by review and a lint test, not by this boundary. (b) A wildcard declaration defers to the personality. A tool declaring allowedHosts: ['*'] reaches exactly the personality's safety.network.allow list — and any public host when the personality has none, where absent, [] and ['*'] all count as none (resolveCapabilities, pinned by packages/wiring/src/__tests__/personality-network-policy.test.ts and the '*' tool on a personality with no allow list — real safeFetch cases in packages/core/src/__tests__/capability-resolver.test.ts); G-NET's floor applies on top. A tool declaring allowedBinaries: ['*'] is scoped to everything the OS permits. (c) The authority inside a granted capability: a tool permitted to spawn bash has the authority of bash.
  • Verify it yourself. packages/core/src/__tests__/capability-validator.test.ts, capability-resolver.test.ts, capability-integration.test.ts, and scoped-attachments.test.ts; or run ethos personality show <id> and compare the resolved reach against the tool declarations.
  • If you need more. Audit the capability declarations of the tools you actually grant — a * in a declaration is where this guarantee stops narrowing — and run exec-bearing tools under a container posture, which bounds the process rather than the declaration.

G-FS — Filesystem reach​

  • Claim. A personality cannot read or write outside its declared fs_reach (the per-personality filesystem allowlist). The allowlist has one derivation, feeding both the app-layer ScopedStorage prefixes and the container's bind mounts, so the two cannot disagree. The check normalises the path lexically and walks every path segment with lstat, following any symbolic link and re-judging the floor and the allowlist against where it lands: a link that escapes is refused, one that stays inside is allowed — per-segment, because a symlinked parent escapes with a non-symlink leaf. Inside that reach, every personality's definition files (SOUL.md, config.yaml, toolset.yaml, mcp.yaml, tools.yaml, ETHOS.md, skills/, commands/, and the .expression-history/ snapshots a revert restores into SOUL.md) are write-denied — its own on every branch of the derivation, and every other personality's under the Ethos state directory, including one whose directory a turn has just created — and so are the global skills/ and commands/ and the team manifests (teams/<name>.yaml) under the state directory: readable, never writable by any turn. The learning inbox (learning/) and the operator's policy files — constitution.yaml, evolve-config.json, allowlist.json and approval-leases.json — are neither readable nor writable by a turn. That covers a new personality's birth marker (learning/birth/<id>.json, birthMarkerPath in extensions/personalities/src/birth.ts), so a turn cannot end or restart its own birth ritual. The one governed path by which a personality changes its own definition is a request it files with propose_self_amendment: tool membership in toolset.yaml (target toolset), or name, description and display.emoji in config.yaml (target identity, never display.avatar_url — identityUpdates in packages/wiring/src/amendments.ts). Filing writes only a pending record; the definition file changes when a person applies it at a terminal (AmendmentService.apply, same file, whose one applying caller is the TTY-gated ethos personality amendments apply). The state directories these floors cover are ~/.ethos, ETHOS_STATE_DIR and the data directory the host wired, so a custom data folder (the desktop app's) is covered too. Every deny — the always-deny floor, the definition write-deny and the shared-turn memory deny — compares case-insensitively and normalization-insensitively on every platform (a full lower-upper-lower fold, so .ẞh (U+1E9E) is .ssh as APFS opens it), so Toolset.yaml, memory.md or ~/.ETHOS/keys.json are refused where a case-insensitive file system would map them onto the protected file, and so are macOS's other spellings of a path — /System/Volumes/Data/… and /.nofollow/…; a path that names a file by inode (/.vol/…, /.resolve/…) is refused for every operation; allowlist matching stays exact. The Ethos state directories are known by their lexical names and their real paths, and each deny is judged again against the real path of the target, so a state directory or reach reached through a symbolic link is protected under either name. The Ethos state dir is not reachable through a grant that is merely its ancestor: with the process cwd at ~ or /, a path under ~/.ethos must be granted by a prefix at or below the state dir (the personality's own directory, skills/, or an explicit ${ETHOS_HOME}/), and a cwd at or inside the state dir is dropped from the default reach — so another personality's MEMORY.md/USER.md and the unlisted stores stay out of the default reach wherever the process was started. That exclusion is judged on the grant as spelled and again on where it really leads, so a granted symbolic link to an ancestor of the state dir does not reach into it either; it covers the same state directories as the floors (the host's wired data directory included) and compares case-insensitively on every platform. Once a run has read untrusted content, it writes nothing into any of those state directories outside a personality's files/. In the Docker sandbox, a writable mount that is a state directory or contains one is mounted read-only on every host, with the personality's own files/ writable beneath, and personalities/, learning/, skills/ and commands/ get read-only mounts of their own; on a case-insensitive host (macOS, Windows) a writable mount that contains those read-only mounts is itself mounted read-only too, because a case-variant path would walk past them. A mount spelled through /.vol, /.resolve or /.nofollow is refused.
  • Enforced at. fs-reach.ts:152 (deriveFsReachPaths, the single derivation), scoped-fs.ts:290 (checkReach — the deny floor, the lexical prefix test, and the segment walk) with the symlink refusal at :341, scoped-storage.ts:279, and path-boundary.ts:17 (assertWithinBase). Which paths are any personality's definition is isPersonalityDefinitionPath in packages/types/src/personality-definition.ts, applied by every ScopedStorage through personalityDefinitionFloor (packages/storage-fs/src/sensitive-paths.ts, which also lists learning and the policy files in the state-dir deny) and handed to every ScopedFsImpl by wiring, with the wired data directory passed to both floors (packages/wiring/src/build-infrastructure.ts, shippedScopedStorageFactory in build-agent-loop.ts; pinned by packages/wiring/src/__tests__/custom-datadir-floor.test.ts); the container half is DockerExecutionBackend.mountsFor. The fold is foldForDeny and the inode-alias refusal isUnmappablePathAlias (packages/types/src/deny-fold.ts, applied in matchesDenyPrefix / matchesAny); the real-path re-check is checkRealTarget in both ScopedStorage and ScopedFsImpl, over realPathOfLongestExistingAncestor, and the real state-directory names come from ethosStateDirs (packages/storage-fs/src/sensitive-paths.ts). The ancestor-grant exclusion is shadowsStateDir (layer 2b, in both boundary files) and, on the real target, reachesStateDirThroughLink (called from checkRealTarget in both); the tainted state-dir write refusal is writesEthosState in scoped-fs.ts. All three compare foldForDeny keys over ethosStateDirs plus the wired data directory (ScopedStorageScope.stateDirs; CapabilityBackends.stateDirs, handed to every ScopedFsImpl by packages/wiring/src/build-infrastructure.ts). The cwd drop is in deriveFsReachPaths and compares foldDenyKey keys (case-folded on macOS and Windows only, with the macOS volume-alias prefixes dropped). Pinned by packages/core/src/__tests__/scoped-fs-casefold-statedir.test.ts, packages/storage-fs/src/__tests__/scoped-storage-casefold-statedir.test.ts and packages/wiring/src/__tests__/custom-datadir-floor.test.ts. Pinned by the case-variant and symlinked-state-dir blocks in scoped-storage.test.ts, scoped-fs.test.ts, state-dir-deny.test.ts and mounts.test.ts.
  • Explicitly not covered. TOCTOU between check and use. An attacker who can swap a path between the walk and the open still wins. Closing that requires fd-level openat semantics that Node does not expose — which is why remediation has to move to the container. This is the same class Snyk used for roughly 25% arbitrary read/write against OpenClaw, and their fix had to move to the container too. The definition write-deny under local execution. A personality holding terminal whose tools run locally — the operator's execution.containerized: true or ETHOS_EXECUTION_BACKEND=local, or execution.allowLocalFallback: true with no Docker (resolveExecutionPosture, packages/wiring/src/resolve-execution-posture.ts) — runs a shell no Storage mediates, so it can still edit its own — or any other personality's — definition files and learning/, and it can run ethos personality amendments apply itself. That command refuses when ETHOS_TOOL_PROCESS=1, which the host paths of terminal, process_start and the code tools set (TOOL_PROCESS_ENV_VAR, packages/types/src/amendment.ts), but that is a tripwire, not a boundary: env -u ETHOS_TOOL_PROCESS defeats it; the read-only mounts hold only in the Docker sandbox, and a read-only mount is still readable, so a container whose reach spans the state directory can read learning/ and other personalities' directories. Self-amendment filed from text the taint check cannot see. The filing intake's no-untrusted-content check (taintRefusal, packages/wiring/src/amendments.ts) reads the session's stored messages, not what the prompt assembles from files: text that reached MEMORY.md or USER.md in an earlier session rides the memory snapshot into a fresh session unseen; so does project context (AGENTS.md, CLAUDE.md in the working directory, which a turn with the working directory in its write reach can edit) and a skill loaded from the project or the home directory; and a trusted tool that re-serves third-party text from its own store — team memory, kanban comments, cron run output, goal output, pending skills, session titles — is judged by that tool's own trust. Any of these can steer a filing. A web chat message sent with a bearer key into an owner's web: session is stored like the owner's own, because a stored message does not record how it was authenticated, so a later cookie turn in that session is not refused for it. The owner's review is the control; the rationale is a claim. The data directory seen by shell guards. The terminal and process argv floors and the write_file pre-check know ~/.ethos and ETHOS_STATE_DIR only; a data directory known only to wiring reaches them when the host exports it, which the desktop app does (apps/desktop/src/main/serve.ts). Case-folding Linux hosts. mountsFor decides case sensitivity by platform (defaultHostFoldsCase); a Linux host whose shared directories use ext4 casefold is treated as case-sensitive, so a case-variant path there can walk past a read-only mount nested in a writable mount below a state directory (a writable mount holding a state directory — by its written path or, through a symbolic link, its real one — is read-only on every host). Writable mounts the operator declared inside a state directory. A fs_reach write entry strictly inside a state directory, such as ${ETHOS_HOME}/cron/, stays a writable mount in the container: mountsFor (extensions/execution-docker/src/index.ts) makes read-only only a mount that holds a state directory, and a mount at or below a definition entry, learning/, skills/ or commands/, so whatever else the operator opened there — cron/jobs.json in this example — a shell in the container can rewrite. An explicit state-dir grant. A personality that declares fs_reach naming the state dir itself or a directory inside it reaches what it names, other personalities' directories included (their definition entries stay write-denied). Case folding of the ancestor-grant exclusion on Linux. The deny lists fold on every platform, but foldDenyKey treats Linux as case-sensitive, so a case-insensitive mount there (vfat, an ext4 casefold directory) is not folded for the exclusion. A reach that covers /. Nothing clamps it: every deny above still applies, but the allowlist then admits the whole filesystem outside them. Allow-side symlinks outside the state directory. The real-path re-check covers the Ethos state directories; other always-deny entries (/etc/passwd, ~/.ssh) are matched by their written names, so on macOS /private/etc/passwd is not matched by the /etc/passwd entry and is refused only when it lies outside the reach.
  • Verify it yourself. packages/core/src/__tests__/fs-reach.test.ts, the symlink cases in extensions/tools-file/src/__tests__/boundary.test.ts, and the definition write-deny — its own and every other personality's — in packages/storage-fs/src/__tests__/scoped-storage.test.ts, packages/core/src/__tests__/scoped-fs.test.ts and extensions/execution-docker/src/__tests__/mounts.test.ts, with the learning/, policy-file and custom-data-directory cases in packages/storage-fs/src/__tests__/state-dir-deny.test.ts; or run ethos personality show <id> and read the ## Filesystem reach block, which prints the resolved allowlist.
  • If you need more. Container posture. fs_reach is a correctness boundary against a mistaken agent, not a containment boundary against a hostile one; the container is the layer that survives a symlink race.

G-NET — Network egress​

  • Claim. Outbound HTTP from tools goes through safeFetch, which resolves the hostname, validates the resolved IP against private-network and cloud-metadata rules, enforces an http/https scheme allowlist, and revalidates on every redirect hop rather than trusting the platform's automatic redirect follow. The floor is not overridable from above it: a tool's declared host allowlist narrows the destination set, and a URL that clears the allowlist still goes through the floor. Operator-supplied base URLs that are literals rather than runtime destinations — LLM endpoints, webhook targets — are checked at construction time by the same rules minus DNS resolution.
  • Enforced at. safe-fetch.ts:80, with the per-hop revalidation loop at :91, auto-redirect disabled at :100 (redirect: 'manual'), each hop's connection pinned to the addresses validation accepted at :154 (pinnedFetch), the scheme gate at scheme.ts:21, and cross-origin auth-header stripping at safe-fetch.ts:378. The per-tool allowlist that sits over the floor is at scoped-fetch.ts:57 (HOST_NOT_ALLOWED), and the synchronous construction-time validator is url-validator.ts:135 (validateUrl).
  • Explicitly not covered. Exfiltration through an allowed destination. DNS-over-HTTPS. Encrypted side channels. DNS rebinding where the connection is not pinned — a caller-injected fetchImpl (a test seam; the one production caller that passes one is the vision input resolver), and browser sessions, whose route guard (installRouteGuard) validates the URL while Chromium resolves the name again when it connects. web_extract is pinned: it connects through ctx.scopedFetch → safeFetch, and its own checkSsrf lookup is only an early refusal, never the address connected to (pinned by extensions/tools-web/src/__tests__/web-extract-pinning.test.ts). The module's own header at :11 states the pinning limits. And one deliberate widening: safety.network.allow_private_urls opts a personality into RFC1918, loopback, and link-local destinations. It is off by default, per-personality, and flagged by ethos security-audit; it cannot reach cloud metadata, which stays blocked regardless.
  • Verify it yourself. packages/safety/network/src/__tests__/; packages/core/src/__tests__/url-validator.test.ts.
  • If you need more. Egress filtering at the network layer — a proxy or firewall with a destination allowlist. An SSRF guard bounds where a request may go, never what it may carry.

G-INJ — Injection defense​

  • Claim. Untrusted content reaching the model is wrapped in provenance markers, run through a two-tier classifier (regex tier always, LLM tier by policy), and followed by a post-read downgrade that withdraws dangerous tools for a bounded number of loop iterations after untrusted content enters context. For the rest of that run it also refuses what would persist that content or schedule it into a later run — the memory and skill writers, propose_self_amendment, cron create/update, goal_create, the kanban creators, kanban_block (whose reason the ticket's next dispatch prompt carries), background delegate_task and background route_to_agent, watcher_create with a wake, scaffold_personality and scaffold_team (which write through the compose-time Storage, not the turn's scoped filesystem, so they are refused by name), and scoped file writes into the Ethos state dir outside a personality's files/ folder — and a sub-agent run started inside one of its tool calls inherits the taint (and passes its own back). The mesh tools carry the taint across processes: from a tainted run, route_to_agent, dispatch_team and broadcast_to_agents send untrustedOrigin: true with the prompt, and the peer's ACP server starts that run with the downgrade armed; every mesh answer is itself untrusted content, so it taints the run that asked. A run started by an inbound A2A peer message always starts with the downgrade armed, whatever the peer sends. A tainted run skips the opt-in turn-end memory flush, and a background job whose run read untrusted content — or that ran on the ACP coding-agent or Pi runner, whose reads nothing reports — is recorded as tainted, so the gateway's parent-review turn of that job starts with the downgrade armed. A background job or goal run takes its taint from the job alone, never from the tool batch that happened to start it. No personality field can switch off the wrapping, the prelude or the regex tier; safety.injectionDefense.postReadDowngrade.enabled: false does switch off the downgrade for that personality.
  • Enforced at. wrap.ts:35 (wrapUntrusted), classifier.ts:60 (createLLMClassifier), downgrade.ts:34 (resolveDowngradedTools); applied at context-assembly.ts:489 (the prelude, pushed unconditionally), agent-loop.ts:746 (resolveRunDowngrade — the post-read downgrade, fresh per run and pre-armed in a sub-agent run started by a tainted one, V2-SEC-2), and tool-processing.ts:800 (handleUntrustedResult on every untrusted tool result), the mesh marker sent at index.ts:960 (meshTaintParams) and honoured at acp-server/src/index.ts:516 (the prompt case; the streamed prompt at :755), the A2A inbound arming at serve-a2a-runner.ts:106, whose wrap-then-two-tier-classify body is result-defense.ts:31. When a personality enables a decision provider for the injection site (decisions.sites.injection in its config.yaml, resolved per call by resolvePersonalityDecisionSite) and the operator configured that provider, the LLM tier is composed at decision-injection-classifier.ts:89 (createDecisionInjectionClassifier), which falls back to createLLMClassifier and acts on the provider's answer only at or above the measured threshold (decision-site.ts:214, meetsThreshold); the redaction, shadow and fallback rules are decision-site.ts:231 (runDecisionSite). Whichever classifier runs can only add a flag: result-defense.ts:91 ORs its verdict with the Tier-1 hit.
  • Explicitly not covered. The run-scoped refusal follows a sub-agent only while the tool call that started it is running (runToolsInTaintScope, packages/core/src/agent-loop/stages/per-call-enforcement.ts); terminal, process_start and run_code scripts on the host are window-only and can still write any file their process can reach (approval-gated, and execution: docker mounts the personality directory read-only); the mesh taint marker is honoured only by an Ethos AcpServer — a mesh peer that is not one, or a modified one, can ignore it, so the carried taint holds only between Ethos processes the operator runs (a peer gains nothing by dropping it that it did not already have); the team-supervisor Dispatcher and /notify wake are not mesh tools and send no marker — the kanban tools that feed them are refused in a tainted run instead. Jobs on the ACP and Pi runners are marked tainted whatever they read, so their review turns are always downgraded. Adversarially-iterated injection. Bluntly: the regex tier is a published v1 floor and it is not the load-bearing half. The structural defences — provenance wrapping and post-read tool downgrade — are what constrain a successful injection, because they change what the model can do rather than guessing what the text means. A classifier operating on an attacker-controlled string loses to an attacker who can iterate.
  • Verify it yourself. packages/safety/injection/src/__tests__/; or grep your personalities for injectionDefense and confirm no field disables the pipeline. For the injected bundle as a whole, runAgentSafetyConformance in packages/core/src/safety-conformance.ts feeds the kit input it must change and asserts it changed — a no-op implementation that satisfies the types fails it; packages/wiring/src/__tests__/safety-conformance-wiring.test.ts runs it against the shipped composition.
  • If you need more. Assume injection succeeds and bound the blast radius: narrow the toolset, run under a container posture, and keep the approval gate on for consequential tools.

G-SEC — Secrets​

  • Claim. SecretsResolver is the sole storage and retrieval path for credential material. No secret value is written to a config file, personality file, MCP server config, export archive, or any storage path outside it. Config may reference a secret by name, never by value. And where a credential name is propagated into a third-party subprocess's environment — the mcp_env_passthrough path, by which a skill asks for an env var to reach an MCP server — the propagation is gated three times: only skills the active personality admits contribute names, only names the personality lists under safety.allowed_skill_permissions.mcp_env_passthrough survive (absent means none, true means any an admitted skill asks for), and only MCP servers that personality has attached receive them. Both the policy and the attached set are whitelists, so an empty one propagates nothing. Host-posture command execution never hands a child the process's own environment, which holds every provider key, bot token and ~/.ethos/.env entry: the local terminal/run_tests/lint path, process_start and the local execution backend pass only an allowlist of operational variables plus the vars a call names explicitly: the shell basics (PATH, HOME, USER, LOGNAME, SHELL, LANG, TERM, COLORTERM, TMPDIR, TZ), SSH_AUTH_SOCK, the proxy variables in both cases, the toolchain roots (NODE_PATH, GOPATH, GOROOT, GOBIN, JAVA_HOME, CARGO_HOME, RUSTUP_HOME, PYENV_ROOT, PYTHONPATH, VIRTUAL_ENV) and the LC_*, NVM_*, CONDA_*, XDG_* and TERM_* families. A name that looks like a credential (KEY, TOKEN, SECRET, PASSW, CREDENTIAL, an API segment, an AWS_ prefix) never passes, even inside an allowed family, and neither does any name loadDotEnv copied in from ~/.ethos/.env (it records them in ETHOS_DOTENV_KEYS, packages/storage-fs/src/env-secrets.ts). minimalHostEnv in packages/core/src/scoped/scoped-process.ts, extensions/tools-process/src/spawn.ts, extensions/execution-local/src/index.ts and extensions/execution-process-backend/src/index.ts — four copies that change together, pinned by packages/core/src/__tests__/scoped-process-lifetime.test.ts, the env case in extensions/tools-terminal/src/__tests__/tools-terminal.test.ts, extensions/tools-process/src/__tests__/spawn.test.ts, extensions/execution-local/src/__tests__/local.test.ts and extensions/execution-process-backend/src/__tests__/process-backend.test.ts. A proxy URL with embedded credentials (http://user:pass@proxy) is forwarded as set; that is the operator's own variable. A caller opts into the full env only with SpawnOpts.inheritEnv.
  • Enforced at. secrets.ts:18 — the four-method contract — and ARCHITECTURE.md §V S9, which states "No exception path exists for this rule", with no exception entry in the sidecar. The passthrough gate is skill-passthrough.ts:40 (applySkillPassthrough, the attached-server whitelist), fed by deriveSkillPassthrough at :16, which admits only skills that clear filterSkill for the personality and intersects their requests with the personality's mcp_env_passthrough policy.
  • Explicitly not covered. A secret the operator pastes into a prompt. Once a credential is conversation text it is history, context, and — subject to the observability policy — a stored row. The resolver governs where secrets are kept, not what a human types. Nor does the passthrough gate bound what an attached server does with a var it legitimately received: the whitelist decides which subprocess gets the credential, never what that third-party process then does with it. And the env allowlist bounds what a host shell inherits, not what it can read: a local shell runs as the Ethos user, so a command that opens ~/.ethos/.env or keys.json by a path the argv floor does not recognise still reads them. execution: docker is the boundary for that.
  • Verify it yourself. rg -n 'apiKey|api_key|token' ~/.ethos/config.yaml should return refs, not values; packages/storage-fs/src/__tests__/ covers the resolver paths, and packages/wiring/src/__tests__/skill-passthrough.test.ts covers all three parts of the passthrough gate.
  • If you need more. An external secrets backend — @ethosagent/secrets-aws exists — and operator discipline about prompts, which no framework can enforce for you.

G-RED — Credential redaction​

  • Claim. Values matching the known credential pattern set are replaced before they reach observability.db, and before the Slack and Discord approval cards render tool args into a channel.
  • Enforced at. redact/index.ts:259 (detectSecrets), :271 (redactString), :288 (redactJson), applied on the write path at store.ts:206, :344, :397, and :527; and on the approval surface at surface-kit/approval-args.ts:41 (formatApprovalArgs, the one formatter the Slack, Discord and Telegram approval cards share).
  • Explicitly not covered. Say it plainly: this is a leak-reducer, not a containment boundary. A credential in a format the pattern set does not know is not redacted. The set covers Anthropic and OpenAI key shapes, generic bearer tokens, and AWS access keys; your internal token format is not in it unless you add it. And the channel coverage is the approval card's tool args only — not the model's own message text, tool results, or error strings, which reach a channel unredacted. An adapter surface is covered when it is named above and not otherwise.
  • Verify it yourself. packages/safety/redact/src/__tests__/; or write a known key shape into a tool error and read the row back out of observability.db. For the approval surface, extensions/platform-slack/src/__tests__/approval.test.ts and extensions/platform-discord/src/__tests__/approval.test.ts.
  • If you need more. Add your own patterns through the extra-patterns seam the store already threads, and treat observability.db as credential-adjacent storage regardless.

G-APP — Approval gating​

  • Claim. Tool calls classified as dangerous are held in front of an approval surface before execution. The smart reviewer is fail-closed: an unparseable verdict, a timeout, or a thrown error all resolve to ask, never to approve. Approval modes only ever make the flagged set stricter — the composition is a union, never an override. The gate is also not silently omissible: a composition root that declares its loop gated while registering no policy behind the framework's fire site is refused with a typed error at dispatch, rather than running ungated.
  • Enforced at. danger-predicate.ts:451, smart-approver.ts:311 ({ decision: 'ask', reason: 'reviewer gave no usable verdict' }) and :319 ('reviewer error (fail-closed): …'); with a decision provider on the approver site, its approve / deny is acted on only at or above decisions.thresholds.approver.approve / .deny at :150 (approverVerdictFrom), and anything else falls to the LLM reviewer, with the union at danger-predicate.ts:405. The single fire site every tool call passes through — the LLM batch path, the script bridge, and the realtime voice host's direct dispatch (realtime-host.ts, createRealtimeToolHost) — is per-call-enforcement.ts:86 (enforceBeforeToolCall, where a handler error blocks the call, where safety.denyRules refuses a matching call at :90 before any handler runs, and where handler-rewritten args are fired through the handlers a second time, judge-only (a handler error still blocks; args returned on that fire are ignored), so the guards judge exactly what executes), over the sequential execution model at hook-registry.ts:94 (fireModifying). The two seams that make the mode dispatch actually consulted are bound at approval-seams.ts:151, and the posture guard that refuses an unbacked gated claim is approval-posture.ts:38.
  • Explicitly not covered. An approval gate is a heuristic over an attacker-influenced string. It is not a boundary against an adversarial LLM. It reduces accidental damage and gives the model a clear stop signal. It does not sandbox a hostile or compromised agent. And the posture guard checks consistency, not quality: an embedder may legitimately declare its loop ungated, which is permitted and logged once — never silent, but also never blocked.
  • Verify it yourself. packages/wiring/src/__tests__/ for the fail-closed paths, approval-seams.test.ts for the seam binding; ethos personality show <id> for the resolved execution posture the gate sits over.
  • If you need more. Do not treat the gate as containment. Bound the tools instead — toolset, container posture, and an operator who reads what they are approving.

G-EXEC — Execution posture honesty​

  • Claim. The resolved execution posture never claims containment it is not providing. A personality holding an exec-bearing tool resolves to one of exactly three outcomes: a sandboxed backend; an honestly labelled un-sandboxed local posture carrying hostFallback with the reason; or a refusal that makes the exec tools unavailable, taken when the constitution forbids the host fallback or the operator has not opted into it with execution.allowLocalFallback: true. There is no fourth path where code runs on the host while the character sheet says sandboxed. Where the Docker daemon is merely down, the posture stays docker and produces a typed decision the operator must answer — it never auto-downgrades to the host behind their back. A personality that declares execution: remote is stronger still: on a deployment with no configured target its execution tools are refused outright, whatever the constitution permits, because a permitting constitution grants the host — the one machine that personality ruled out.
  • Enforced at. resolve-execution-posture.ts:267 (resolveExecutionPosture, the posture selection), with the build-impossible branch (and its allowLocalFallback opt-in) at :324, the hostFallback labelling at :365, the daemon-unavailable decision at :445, and the unmet-remote-requirement refusal at :422.
  • Explicitly not covered. This is an honesty guarantee, not a containment one. It promises the posture describes reality; the containment itself is Docker's, and the local posture provides none at all. It says nothing about what an exec tool does inside a container — network mode, mounts, and memory are resolved here, but a tool with a shell in a container has a shell. It also does not cover a surface that renders the posture incorrectly: the resolver's output is the boundary, and a UI that mislabels it is a bug in that UI.
  • Verify it yourself. packages/wiring/src/__tests__/resolve-execution-posture.test.ts; or run ethos personality show <id> and read the execution-posture block, which prints the backend, the network mode, and any hostFallback reason.
  • If you need more. Run the whole agent process inside a container you control. The posture tells you truthfully whether the framework sandboxed the tool; it cannot sandbox the process it is running in.

G-WATCH — Watcher​

  • Claim. A separate observer in the same process (createWatcherTap) reads the agent's tool events and halts the turn on rate-limit (counted across turns), token-budget, compounding-error, and suspicious-sequence rules. Its rules do not depend on the per-call checks getting the classification right.
  • Enforced at. rules.ts:20 (rateLimitRule), :79 (tokenBudgetRule), :110 (compoundingErrorRule), :202 (suspiciousSequenceRule), composed at :246; constructed in wiring at build-agent-loop.ts:682.
  • Explicitly not covered. It observes and halts; it does not prevent the first bad call. By construction it fires on a pattern, and a pattern needs at least one instance. A single catastrophic call inside budget is exactly what it does not catch. The suspicious-sequence rule fires when a credential-shaped read is followed, within the last four tool calls, by an exfiltration-shaped tool (isExfilShapedTool, rules.ts:193) or any mcp__* call; the name list is drift-gated by packages/wiring/src/__tests__/watcher-exfil-tool-names.test.ts.
  • Verify it yourself. packages/safety/watcher/src/__tests__/; the halt events in observability.db after tripping a rule deliberately.
  • If you need more. Per-turn budgets you set yourself, and the approval gate for the first-call case the watcher structurally cannot cover.

G-CHAN — Channel admission​

  • Claim. Inbound platform messages pass an admission filter before reaching the agent: a sender allowlist (owner plus configured recipients), a one-time DM pairing flow for unknown senders, a mention gate in group channels, and a context-visibility filter that strips quoted or threaded content from non-allowlisted senders — including the channel-history backfill an adapter attaches to the first message in a lane.
  • Enforced at. channel-filter.ts:124 (checkMessage, whose header documents the eight-step order), :93 (isSenderAllowed), :24 (dmPolicy), :33 (contextVisibility), with pairing codes in pairing-store.ts.
  • Explicitly not covered. Three things. (a) An allowlisted sender who is themselves the attacker — admission answers who may speak, never what they may say; that is G-INJ's problem, and G-INJ concedes the hard case. (b) The filter is per-platform opt-in: no platform config, or enabled: false, means allow-everything by design. An unconfigured platform is an ungated platform. (c) The quoted-content half of the context-visibility filter needs the adapter to name the quoted message's author. Slack and Discord do; an adapter that cannot leaves that half inert, because a missing author is indistinguishable from no quote at all. The history half takes the opposite posture — history an adapter does not attribute line by line is dropped whole.
  • Verify it yourself. packages/safety/channel/src/__tests__/; the audit.block events with code channel.allowlist.blocked or channel.mention_gate in observability.db.
  • If you need more. Configure every platform you actually run — and remember that in a group channel the mention gate is a routing decision, not an authorization one.

G-AUDIT — Audit substrate​

  • Claim. Channel drops, watcher halts, approval verdicts, and tool-call refusals (hook, downgrade and injection refusals, all audit.block) land as queryable events in observability.db, with G-RED's credential redaction applied on the write path.
  • Enforced at. observability-sqlite/src/store.ts:206 — the single write path, with redaction applied at :206, :344, :397, and :527.
  • Explicitly not covered. There is no tamper-evidence. An operator with disk access can edit the rows. This is an audit substrate, not an audit log in the compliance sense — no hash chain, no append-only enforcement, no external anchor. Nor is it complete: a filesystem reach refusal is returned to the model as a tool error and writes no event, audit.transition and audit.redacted are defined but never emitted, and policy snapshots are not written (Security controls has the emitted-category table).
  • Verify it yourself. Open observability.db with any SQLite client and edit a row. That is the demonstration.
  • If you need more. Mirror the event stream to an off-host target at wiring time. The integrity property has to come from somewhere the operator of the box cannot reach.

G-MEM — Private memory in shared conversations​

  • Claim. A turn marked shared (a conversation more than one person can read, or one whose origin Ethos cannot prove is private) neither reads nor writes private memory: the personality's MEMORY.md, USER.md and memory bookkeeping files, the users/ tree, team memory, the vector store's memory.db, and a configured vault. It makes no private-memory provider call during context assembly, cannot see or run the memory, session-reading, shell and deferred-prompt tools, and never runs the turn-end memory flush. Through a file tool, a shared turn can read nothing under the Ethos state directory except its own files/ and ui/ folders, its own SOUL.md and skills, the global skills, and the cached attachments of the message it is answering (exemptAttachmentReads, packages/core/src/capability-resolver.ts) — not cron run output, compaction transcripts, sessions.db, the job, kanban and goal stores, config.yaml, another conversation's attachments or another personality's directory — and it can write only its own files/ and ui/. That holds on the path as written, under the macOS /System/Volumes/Data firmlink and /.nofollow prefix, in any letter case, and on wherever a symbolic link lands. "Shared" only narrows: once a session runs a shared turn, every later turn in it is shared.
  • Enforced at. audience.ts:106 (resolveTurnAudience, the one place the audience is computed) and :202 (sharedTurnDenyFor); applied in turn-setup.ts:144 (resolve and stamp), :250 (the file deny for the turn's Storage) and :494 (the tool exclusion), and turn-end.ts:351 (the flush guard). Which paths are private is memory-paths.ts:150 (isPrivateMemoryPath) and what else under a state directory a shared turn may reach is :240 (sharedTurnPathDeny), both case-, normalization- and firmlink-folded by foldForDeny, judged against the wired data directory, ~/.ethos and ETHOS_STATE_DIR together with their real paths (sharedTurnDenyFor, pinned by the G1-5 and E4 cases in shared-audience.test.ts), refused by ScopedStorage on every symlink hop at scoped-storage.ts:334. The same predicate reaches ScopedFsImpl.checkReach through resolveCapabilities, and the memory read is skipped in context-assembly.ts; those files are cited by G-FS, G-CAP and G-INJ.
  • Explicitly not covered. Who marks a turn shared is decided outside this row, by each entry point: the gateway marks a group, channel or thread shared and a direct message private (Gateway.audienceFor, pinned by extensions/gateway/src/__tests__/memory-boundary-e2e.test.ts), unless the operator lists the room under gateway.private_chats.<platform> — read when the gateway starts, so un-listing a room takes effect only after a restart. A direct message from someone other than the configured owner withholds the personality's memory but keeps that sender's own users/<id> profile (withPersonalityMemoryWithheld, same file as resolveTurnAudience, pinned by the same end-to-end test). Schedules, watchers, webhooks, goals and background jobs inherit the audience they were created under, and one whose origin cannot be proved private runs shared (Keep memory out of group chats). A CLI, web app or ACP turn that names no audience runs as its session does (ACP names one only to forward 'shared', and only the gateway records a judged-private stamp, RunOptions.judgeAudience): shared when the session is stamped shared or is a pre-upgrade group session recognised by its key (isSharedSession, packages/core/src/chat-audience.ts), private otherwise; a fork of a shared session is stamped shared (forkSession). After the turn. Post-turn learners skip a session whose conversation was not the owner's through one helper, turnWasShared (same file): its shared stamp, a pre-upgrade group key, or the marker a non-owner DM turn leaves (pinned by chat-audience.test.ts and extensions/memory-capture/src/__tests__/runner.test.ts). Known residuals. A kanban task with no stamp (web board, CLI, pre-upgrade) runs private; a dashboard panel added from a group before the upgrade refreshes private; ethos chat -q records no initiator; plugin before_prompt_build and OpenClaw agent_end / agent_done hook payloads carry no audience; a context engine's onTurnComplete hook gets no audience and one compaction store per personality (<data>/compaction/<id>/, buildStore in packages/core/src/agent-loop/turn-complete.ts) shared by its private and shared sessions, so an engine that carries text across sessions through that store can carry it into a room; MCP tools run in operator-configured server processes the exclusion list cannot name (every tool declaring capabilities.process is otherwise excluded or justified, pinned by packages/wiring/src/__tests__/shared-audience-process-tools.test.ts). Where a private turn posts. A private turn can still send text into a group with send_message; this row governs what a turn reads and writes, not where it replies. Shells and containers. A personality allowed a shell whose tools run locally bypasses Storage, and Docker mounts the personality directory read-only into every container — which is why shell and exec tools are excluded on shared turns. Plugins and MCP memory servers that read memory themselves. Symlink races (TOCTOU), as G-FS states.
  • Verify it yourself. extensions/gateway/src/__tests__/memory-boundary-e2e.test.ts (a group, a thread, a listed trusted room, the owner's DM, a non-owner DM, a delegated child of a group turn, a group-created watcher's wake, a cron job delivering to a group and an unverified email, end to end), packages/core/src/__tests__/shared-audience.test.ts (the G1-5 cases drive read_file through the real transport against MEMORY.md, a symlink to it, users/, memory.db and a vault, and read the asset folder and a template successfully), packages/types/src/__tests__/memory-paths.test.ts, and the denyWhen cases in packages/storage-fs/src/__tests__/scoped-storage.test.ts and packages/core/src/__tests__/scoped-fs.test.ts.
  • If you need more. Keep private memory out of the process a shared room talks to: run a separate personality, with no memory of its own, for group chats.

What is not a boundary​

The register is a closed list, which leaves you to notice absences. These are the absences you would most reasonably assume are covered. Naming them is cheaper than being corrected in public.

IS a boundaryIs NOT a boundary
The personality toolset — double-enforced at definition and executionThe approval gate against an adversarial LLM — a heuristic over an attacker-influenced string
ScopedStorage plus the fs_reach check — lexical normalisation and the per-segment symlink walkCredential redaction — a leak-reducer, not a containment
safeFetch plus DNS validation on every redirect hopThe skill and plugin static scanner — pre-install, advisory, trivially evaded by string concatenation
The OS and the containerThe injection classifier's regex tier — an explicit v1 floor
The capabilities a tool takes through its injected contextA tool's own imports — node:child_process or globalThis.fetch inside a tool module bypass every scoped decorator
The execution posture's honesty about where code runsThe local posture as containment — it is the label for running un-sandboxed on the host
observability.db — no tamper-evidence; the operator with disk access can edit rows

A second published list sits alongside this one: the out-of-scope column of What is the threat model? — OS-level RCE, network MITM, physical access, malicious owner, deep transitive-dependency CVEs, adversarially-iterated injection, and insider threat among co-operators sharing a profile. The register points at both rather than duplicating them, so there is one copy of each statement to keep true.

Gaps, disclosed​

A published gap is a roadmap item. An unpublished one is a finding. The difference is entirely in whether we named the field, the contradiction, the fix, and the date — so that is the form every gap takes here. A weasel clause ("some controls may be configurable in certain deployments") earns none of this; it reads as a pre-emptive excuse and invites the reporter to go looking for what it is hiding.

Closed in this release. Three contradictions between a published claim and the code were closed in the same change that published this page.

  • The fs_reach symlink hole. The reach check was a normalised lexical prefix test with no symlink resolution, while Security controls published symlink misdirection as Shipped. A symlink planted inside an allowed prefix, pointing outside it, passed both the always-deny floor and the allow check — the floor compares the same lexically-resolved string, so it never compensated. Two other surfaces had independently discovered this and each patched it locally, and neither patch reached the boundary. It is now fixed: checkReach walks every path segment with lstat, follows any symbolic link, and refuses the path when the link lands outside the allowlist or on the floor. The walk exists as four hand-maintained copies — ScopedFsImpl.checkReach, ScopedStorage.check, the backup restore's containedPath, and DocumentsService.reachable — which must change together; all four fail closed when a segment cannot be lstated (Security controls). The residual is TOCTOU, which G-FS states plainly and which container-level remediation is the only real answer to.
  • The injection-defense opt-out. The personality schema shipped an injectionDefense.enabled master switch that skipped wrapping, pattern check, and post-read downgrade — contradicting ARCHITECTURE.md §V S6 (opt_out: forbidden) and the published claim that per-personality knobs only ever narrow policy. In a multi-personality process, one personality setting it to false was exactly the composition hazard that claim promised could not happen. The field has been removed from the personality schema. A test-only need is a test-harness concern, not a personality field.
  • Enforcement reachable only through the shipped composition root. Approval gating and the watcher were assembled in @ethosagent/wiring, so an embedder consuming @ethosagent/core with its own composition root got a fully functional agent loop with no approval gate and no error saying so. Enforcement now sits behind a policy seam that core requires: wiring still decides what is dangerous, core now requires that something decided. An embedder who omits it gets a typed refusal at construction rather than a silently ungated loop.

Closed since publication. Two gaps this section listed as open have since closed. Saying so is part of the same discipline as listing them.

  • The kernel now has a layer in the constitution. ARCHITECTURE.md §II names security-kernel as a layer with its own dependency rules, and §IX carries the machine-readable form the validator reads. The safety packages are no longer governed by a single hand-written test.
  • The register is now mechanically tied to the code. scripts/check-architecture.mjs fails CI in both directions: a row whose citation stops resolving to the enforcement point it names, a Tier 0 enforcement point published by no row, and a row that disagrees with the tier roster about what is kernel. Each citation is anchored to a symbol, not only a line number — so code that moves under a claim fails the same check as code that is deleted, which is the quieter half of the failure mode that produced this section.

Open and tracked. These are live today, with the phase they close in.

GapWhat is true todayFixPhase
Plugins execute in-process with full Node privilegesThe loader import()s a plugin entry directly into the process. A loaded plugin shares the process, the environment, the filesystem, and your API keys. The static scanner is pre-install and advisory.Accepted, documented Tier 1 property, plus a recorded per-plugin capability grant at install time. Out-of-process isolation is a later phase.Phase 5
Two GitHub orgs are privileged in compiled codeA red scanner finding in anything under ethosagent or anthropic is operator-overridable by default, and yellow findings auto-acknowledge.Make the trusted-org set operator-configurable; drop auto-acknowledge for everything except builtin.Phase 5

Triage responses​

Four verdicts, four responses, written in advance by someone who is not annoyed. The verdict is not negotiable; the tone is entirely ours to choose, and it costs nothing. None of them says "not a security issue" — that phrasing is untrue (a Tier 2 bug can be a real bug with real consequences) and it is the fastest way to convert a courteous reporter into a public one.

Verdict 1 — in scope, kernel. The finding defeats a named Tier 0 guarantee, wherever the bug lives.

Thank you. This crosses G-<ID> in our published guarantee register, so it is in scope and we are treating it as a security issue. We are handling it under 90-day coordinated disclosure and will confirm a fix commitment within <N> business days. The root cause will be fixed in the kernel rather than at the site you reported it against — the guarantee is ours to hold, wherever the composition chain runs. We will keep you on the thread through disclosure and credit you unless you ask us not to.

Verdict 2 — in scope, guarded surface. Tier 1, and it does not reach a Tier 0 guarantee.

Thank you. <module> is Tier 1 in our published tier roster — a guarded surface, in disclosure scope. We are committing to a fix and will give you a target date by <date>. To be precise about what that commits us to: Tier 1 carries a response commitment, not a correctness guarantee, and this finding does not cross a Tier 0 guarantee in the register. We will coordinate disclosure with you on that basis.

Verdict 3 — out of scope, extension. Tier 2, no Tier 0 guarantee crossed.

Thank you for the report — this is a real bug and we have filed it as #<N>. It is out of scope under our published security boundary: <module> is Tier 2 in the tier roster, and the finding does not cross a guarantee in the register. Concretely, that means it goes on the ordinary bug queue rather than the security-response queue: no embargo, no CVE, and no security SLA. You are under no obligation to hold it. If you think it composes into one of the register's guarantees through a step we missed, tell us how and we will re-triage — that path is what makes verdict 1 available regardless of which tier the bug lives in.

Verdict 4 — out of scope, published non-goal.

Thank you for the report. This lands on something we publish as explicitly not a boundary: <the non-boundary list entry, or the threat-model out-of-scope row>, at <link>. That statement predates your report and is the reason we are closing this rather than a judgement we are forming now. If you can show a path from here to one of the guarantees in the register, that is a different report and we would like to see it.

Trade-offs​

A narrow boundary raises the truth bar inside it​

The wide scope this replaced quietly absorbed every over-claim. Publishing "the personality toolset is a guarantee, and injection defense cannot be opted out" converts an internal inconsistency into a valid external finding on day one. That is the single biggest cost of publishing a boundary — and it is also the argument for it, because it is the only mechanism that has reliably forced these fixes. An inconsistency nobody can cite is an inconsistency nobody prioritises. The three gaps closed in this release were all found by writing the register.

Out-of-scope invites unilateral publication​

A Tier 2 reporter owes us no embargo, and they owe it to us because we told them the finding is not a security issue to us. That is the deal, stated in both directions. Expect Tier 2 findings to appear on a blog before they appear in the issue tracker. It is the price of the filter and it is worth paying — the alternative is an embargo obligation over 87 extension directories, which is the promise this boundary replaced.

A boundary used as a shield decays into a reflex​

The decay is invisible from inside: each individual close looks correct. The structural defence is verdict 1 — because a Tier 2 bug reaching a Tier 0 guarantee is a Tier 0 finding, "it's in an extension" is never a complete argument. That is necessary and not sufficient, which is why reports closed as out-of-scope get a quarterly review read specifically for a composition chain someone missed. A filter with no audit is an opinion with a table.

Stability of the register​

Weakening or removing a published guarantee is a breaking change.

It requires the ARCHITECTURE.md §VI amendment class matching its blast radius — Substantive for narrowing a claim, Structural for removing one or changing which layer enforces it, Schema where a frozen schema field carries it — plus a release-note entry naming what stopped being true. Not "security hardening in 0.7": the sentence that is no longer accurate, and the sentence that replaces it.

Adding a guarantee is additive and cheap — one row, one enforcement point, ordinary review. The asymmetry is deliberate and it is the entire reason the register can be relied on. A list that can quietly shrink is a list you have to re-read before every upgrade, which is the same as having no list.

See also​