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:
| Reader | The decision | What they read |
|---|---|---|
| Operator | What must I add myself before I can deploy this? | The register's If you need more field, plus the production hardening checklist |
| Buyer or auditor | What can I put in a security questionnaire and defend? | The register, plus the tier roster |
| Security researcher | Is my finding in scope? | The decision table in SECURITY.md |
| Contributor | What 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 inagent-loop.tsandcontext-assembly.ts, the approval-posture guard, result defense, thescoped/*decorators,fs-reach.ts,path-boundary.ts,url-validator.ts, the capability validator and resolver, the hook registry,script-safe.ts@ethosagent/storage-fs—ScopedStorageand the always-deny floor@ethosagent/wiring— safety composition only: theAgentSafetybundle assembly,danger-predicate.ts,approval-seams.ts,smart-approver.ts,decision-site.tsanddecision-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".
| Group | Modules |
|---|---|
| Ingress | gateway and the seven adapters — platform-discord, platform-email, platform-meeting, platform-slack, platform-telegram, platform-voice, platform-whatsapp |
| Execution | tools-terminal, tools-code, execution-docker, execution-local, execution-ssh, execution-pi, execution-coding-agents |
| Third-party code | tools-mcp, skills, skill-evolver, plugin-loader |
| Network surface | apps/web-api |
| Data at rest and credentials | session-sqlite, storage-crypto, secrets-aws, plugin-sdk, worker-router |
| Audit and delivery | observability-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
| Tier | What we promise | Disclosure treatment | Change control | Test bar |
|---|---|---|---|---|
| 0 | The named guarantee holds, or it is a vulnerability | CVE-eligible; 90-day coordinated disclosure | Unanimous maintainers; no §VIII exception path | Conformance suite, adversarial tests, validator rule |
| 1 | We review this on a trust boundary and respond to reports | In disclosure scope; fix-or-ETA commitment, no correctness guarantee | Two maintainers; §VIII exception permitted with expiry | Adversarial tests for the boundary it sits on |
| 2 | Nothing beyond what the kernel enforces | Out of scope; triaged as a normal bug | Normal review | Normal 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—toDefinitionsfilters what the LLM sees — andtool-registry.ts:403, whereexecuteParallelre-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 istool-rejection.ts:58(emitToolRejection), and the script surface is derived atscript-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
terminalhas the authority ofterminal. 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, andscript-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), andscoped-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_processorglobalThis.fetchdirectly 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 declaringallowedHosts: ['*']reaches exactly the personality'ssafety.network.allowlist — and any public host when the personality has none, where absent,[]and['*']all count as none (resolveCapabilities, pinned bypackages/wiring/src/__tests__/personality-network-policy.test.tsand the'*' tool on a personality with no allow list — real safeFetchcases inpackages/core/src/__tests__/capability-resolver.test.ts); G-NET's floor applies on top. A tool declaringallowedBinaries: ['*']is scoped to everything the OS permits. (c) The authority inside a granted capability: a tool permitted to spawnbashhas the authority ofbash. - Verify it yourself.
packages/core/src/__tests__/capability-validator.test.ts,capability-resolver.test.ts,capability-integration.test.ts, andscoped-attachments.test.ts; or runethos 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-layerScopedStorageprefixes and the container's bind mounts, so the two cannot disagree. The check normalises the path lexically and walks every path segment withlstat, 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 intoSOUL.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 globalskills/andcommands/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.jsonandapproval-leases.json— are neither readable nor writable by a turn. That covers a new personality's birth marker (learning/birth/<id>.json,birthMarkerPathinextensions/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 withpropose_self_amendment: tool membership intoolset.yaml(targettoolset), orname,descriptionanddisplay.emojiinconfig.yaml(targetidentity, neverdisplay.avatar_url—identityUpdatesinpackages/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-gatedethos personality amendments apply). The state directories these floors cover are~/.ethos,ETHOS_STATE_DIRand 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.sshas APFS opens it), soToolset.yaml,memory.mdor~/.ETHOS/keys.jsonare 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~/.ethosmust 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'sMEMORY.md/USER.mdand 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'sfiles/. 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 ownfiles/writable beneath, andpersonalities/,learning/,skills/andcommands/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,/.resolveor/.nofollowis 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, andpath-boundary.ts:17(assertWithinBase). Which paths are any personality's definition isisPersonalityDefinitionPathinpackages/types/src/personality-definition.ts, applied by everyScopedStoragethroughpersonalityDefinitionFloor(packages/storage-fs/src/sensitive-paths.ts, which also listslearningand the policy files in the state-dir deny) and handed to everyScopedFsImplby wiring, with the wired data directory passed to both floors (packages/wiring/src/build-infrastructure.ts,shippedScopedStorageFactoryinbuild-agent-loop.ts; pinned bypackages/wiring/src/__tests__/custom-datadir-floor.test.ts); the container half isDockerExecutionBackend.mountsFor. The fold isfoldForDenyand the inode-alias refusalisUnmappablePathAlias(packages/types/src/deny-fold.ts, applied inmatchesDenyPrefix/matchesAny); the real-path re-check ischeckRealTargetin bothScopedStorageandScopedFsImpl, overrealPathOfLongestExistingAncestor, and the real state-directory names come fromethosStateDirs(packages/storage-fs/src/sensitive-paths.ts). The ancestor-grant exclusion isshadowsStateDir(layer 2b, in both boundary files) and, on the real target,reachesStateDirThroughLink(called fromcheckRealTargetin both); the tainted state-dir write refusal iswritesEthosStateinscoped-fs.ts. All three comparefoldForDenykeys overethosStateDirsplus the wired data directory (ScopedStorageScope.stateDirs;CapabilityBackends.stateDirs, handed to everyScopedFsImplbypackages/wiring/src/build-infrastructure.ts). The cwd drop is inderiveFsReachPathsand comparesfoldDenyKeykeys (case-folded on macOS and Windows only, with the macOS volume-alias prefixes dropped). Pinned bypackages/core/src/__tests__/scoped-fs-casefold-statedir.test.ts,packages/storage-fs/src/__tests__/scoped-storage-casefold-statedir.test.tsandpackages/wiring/src/__tests__/custom-datadir-floor.test.ts. Pinned by the case-variant and symlinked-state-dir blocks inscoped-storage.test.ts,scoped-fs.test.ts,state-dir-deny.test.tsandmounts.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
openatsemantics 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 holdingterminalwhose tools run locally — the operator'sexecution.containerized: trueorETHOS_EXECUTION_BACKEND=local, orexecution.allowLocalFallback: truewith 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 andlearning/, and it can runethos personality amendments applyitself. That command refuses whenETHOS_TOOL_PROCESS=1, which the host paths ofterminal,process_startand 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_PROCESSdefeats 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 readlearning/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 reachedMEMORY.mdorUSER.mdin an earlier session rides the memory snapshot into a fresh session unseen; so does project context (AGENTS.md,CLAUDE.mdin 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'sweb: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 thewrite_filepre-check know~/.ethosandETHOS_STATE_DIRonly; 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.mountsFordecides case sensitivity by platform (defaultHostFoldsCase); a Linux host whose shared directories use ext4casefoldis 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. Afs_reachwrite 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/orcommands/, so whatever else the operator opened there —cron/jobs.jsonin this example — a shell in the container can rewrite. An explicit state-dir grant. A personality that declaresfs_reachnaming 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, butfoldDenyKeytreats Linux as case-sensitive, so a case-insensitive mount there (vfat, an ext4casefolddirectory) 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/passwdis not matched by the/etc/passwdentry and is refused only when it lies outside the reach. - Verify it yourself.
packages/core/src/__tests__/fs-reach.test.ts, the symlink cases inextensions/tools-file/src/__tests__/boundary.test.ts, and the definition write-deny — its own and every other personality's — inpackages/storage-fs/src/__tests__/scoped-storage.test.ts,packages/core/src/__tests__/scoped-fs.test.tsandextensions/execution-docker/src/__tests__/mounts.test.ts, with thelearning/, policy-file and custom-data-directory cases inpackages/storage-fs/src/__tests__/state-dir-deny.test.ts; or runethos personality show <id>and read the## Filesystem reachblock, which prints the resolved allowlist. - If you need more. Container posture.
fs_reachis 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 anhttp/httpsscheme 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 atscheme.ts:21, and cross-origin auth-header stripping atsafe-fetch.ts:378. The per-tool allowlist that sits over the floor is atscoped-fetch.ts:57(HOST_NOT_ALLOWED), and the synchronous construction-time validator isurl-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_extractis pinned: it connects throughctx.scopedFetch→safeFetch, and its owncheckSsrflookup is only an early refusal, never the address connected to (pinned byextensions/tools-web/src/__tests__/web-extract-pinning.test.ts). The module's own header at:11states the pinning limits. And one deliberate widening:safety.network.allow_private_urlsopts a personality into RFC1918, loopback, and link-local destinations. It is off by default, per-personality, and flagged byethos 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), backgrounddelegate_taskand backgroundroute_to_agent,watcher_createwith a wake,scaffold_personalityandscaffold_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'sfiles/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_teamandbroadcast_to_agentssenduntrustedOrigin: truewith 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: falsedoes switch off the downgrade for that personality. - Enforced at.
wrap.ts:35(wrapUntrusted),classifier.ts:60(createLLMClassifier),downgrade.ts:34(resolveDowngradedTools); applied atcontext-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), andtool-processing.ts:800(handleUntrustedResulton every untrusted tool result), the mesh marker sent atindex.ts:960(meshTaintParams) and honoured atacp-server/src/index.ts:516(thepromptcase; the streamedpromptat:755), the A2A inbound arming atserve-a2a-runner.ts:106, whose wrap-then-two-tier-classify body isresult-defense.ts:31. When a personality enables a decision provider for the injection site (decisions.sites.injectionin itsconfig.yaml, resolved per call byresolvePersonalityDecisionSite) and the operator configured that provider, the LLM tier is composed atdecision-injection-classifier.ts:89(createDecisionInjectionClassifier), which falls back tocreateLLMClassifierand acts on the provider's answer only at or above the measured threshold (decision-site.ts:214,meetsThreshold); the redaction, shadow and fallback rules aredecision-site.ts:231(runDecisionSite). Whichever classifier runs can only add a flag:result-defense.ts:91ORs 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_startandrun_codescripts on the host are window-only and can still write any file their process can reach (approval-gated, andexecution: dockermounts the personality directory read-only); the mesh taint marker is honoured only by an EthosAcpServer— 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-supervisorDispatcherand/notifywake 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 forinjectionDefenseand confirm no field disables the pipeline. For the injected bundle as a whole,runAgentSafetyConformanceinpackages/core/src/safety-conformance.tsfeeds 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.tsruns 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.
SecretsResolveris 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 — themcp_env_passthroughpath, 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 undersafety.allowed_skill_permissions.mcp_env_passthroughsurvive (absent means none,truemeans 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/.enventry: the localterminal/run_tests/lintpath,process_startand 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 theLC_*,NVM_*,CONDA_*,XDG_*andTERM_*families. A name that looks like a credential (KEY,TOKEN,SECRET,PASSW,CREDENTIAL, anAPIsegment, anAWS_prefix) never passes, even inside an allowed family, and neither does any nameloadDotEnvcopied in from~/.ethos/.env(it records them inETHOS_DOTENV_KEYS,packages/storage-fs/src/env-secrets.ts).minimalHostEnvinpackages/core/src/scoped/scoped-process.ts,extensions/tools-process/src/spawn.ts,extensions/execution-local/src/index.tsandextensions/execution-process-backend/src/index.ts— four copies that change together, pinned bypackages/core/src/__tests__/scoped-process-lifetime.test.ts, the env case inextensions/tools-terminal/src/__tests__/tools-terminal.test.ts,extensions/tools-process/src/__tests__/spawn.test.ts,extensions/execution-local/src/__tests__/local.test.tsandextensions/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 withSpawnOpts.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 isskill-passthrough.ts:40(applySkillPassthrough, the attached-server whitelist), fed byderiveSkillPassthroughat:16, which admits only skills that clearfilterSkillfor the personality and intersects their requests with the personality'smcp_env_passthroughpolicy. - 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/.envorkeys.jsonby a path the argv floor does not recognise still reads them.execution: dockeris the boundary for that. - Verify it yourself.
rg -n 'apiKey|api_key|token' ~/.ethos/config.yamlshould return refs, not values;packages/storage-fs/src/__tests__/covers the resolver paths, andpackages/wiring/src/__tests__/skill-passthrough.test.tscovers all three parts of the passthrough gate. - If you need more. An external secrets backend —
@ethosagent/secrets-awsexists — 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 atstore.ts:206,:344,:397, and:527; and on the approval surface atsurface-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 ofobservability.db. For the approval surface,extensions/platform-slack/src/__tests__/approval.test.tsandextensions/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.dbas 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 toapprove. 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, itsapprove/denyis acted on only at or abovedecisions.thresholds.approver.approve/.denyat:150(approverVerdictFrom), and anything else falls to the LLM reviewer, with the union atdanger-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) — isper-call-enforcement.ts:86(enforceBeforeToolCall, where a handler error blocks the call, wheresafety.denyRulesrefuses a matching call at:90before 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 athook-registry.ts:94(fireModifying). The two seams that make the mode dispatch actually consulted are bound atapproval-seams.ts:151, and the posture guard that refuses an unbackedgatedclaim isapproval-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.tsfor 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
localposture carryinghostFallbackwith 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 withexecution.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 staysdockerand produces a typed decision the operator must answer — it never auto-downgrades to the host behind their back. A personality that declaresexecution: remoteis 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 itsallowLocalFallbackopt-in) at:324, thehostFallbacklabelling 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
localposture 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 runethos personality show <id>and read the execution-posture block, which prints the backend, the network mode, and anyhostFallbackreason. - 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 atbuild-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 anymcp__*call; the name list is drift-gated bypackages/wiring/src/__tests__/watcher-exfil-tool-names.test.ts. - Verify it yourself.
packages/safety/watcher/src/__tests__/; thehaltevents inobservability.dbafter 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 inpairing-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__/; theaudit.blockevents with codechannel.allowlist.blockedorchannel.mention_gateinobservability.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 inobservability.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.transitionandaudit.redactedare defined but never emitted, and policy snapshots are not written (Security controls has the emitted-category table). - Verify it yourself. Open
observability.dbwith 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.mdand memory bookkeeping files, theusers/tree, team memory, the vector store'smemory.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 ownfiles/andui/folders, its ownSOUL.mdand 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 ownfiles/andui/. That holds on the path as written, under the macOS/System/Volumes/Datafirmlink and/.nofollowprefix, 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 inturn-setup.ts:144(resolve and stamp),:250(the file deny for the turn's Storage) and:494(the tool exclusion), andturn-end.ts:351(the flush guard). Which paths are private ismemory-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 byfoldForDeny, judged against the wired data directory,~/.ethosandETHOS_STATE_DIRtogether with their real paths (sharedTurnDenyFor, pinned by the G1-5 and E4 cases inshared-audience.test.ts), refused byScopedStorageon every symlink hop atscoped-storage.ts:334. The same predicate reachesScopedFsImpl.checkReachthroughresolveCapabilities, and the memory read is skipped incontext-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 byextensions/gateway/src/__tests__/memory-boundary-e2e.test.ts), unless the operator lists the room undergateway.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 ownusers/<id>profile (withPersonalityMemoryWithheld, same file asresolveTurnAudience, 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 bychat-audience.test.tsandextensions/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 -qrecords no initiator; pluginbefore_prompt_buildand OpenClawagent_end/agent_donehook payloads carry no audience; a context engine'sonTurnCompletehook gets no audience and one compaction store per personality (<data>/compaction/<id>/,buildStoreinpackages/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 declaringcapabilities.processis otherwise excluded or justified, pinned bypackages/wiring/src/__tests__/shared-audience-process-tools.test.ts). Where a private turn posts. A private turn can still send text into a group withsend_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 driveread_filethrough the real transport againstMEMORY.md, a symlink to it,users/,memory.dband a vault, and read the asset folder and a template successfully),packages/types/src/__tests__/memory-paths.test.ts, and thedenyWhencases inpackages/storage-fs/src/__tests__/scoped-storage.test.tsandpackages/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 boundary | Is NOT a boundary |
|---|---|
| The personality toolset — double-enforced at definition and execution | The 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 walk | Credential redaction — a leak-reducer, not a containment |
safeFetch plus DNS validation on every redirect hop | The skill and plugin static scanner — pre-install, advisory, trivially evaded by string concatenation |
| The OS and the container | The injection classifier's regex tier — an explicit v1 floor |
| The capabilities a tool takes through its injected context | A 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 runs | The 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_reachsymlink 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:checkReachwalks every path segment withlstat, 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'scontainedPath, andDocumentsService.reachable— which must change together; all four fail closed when a segment cannot belstated (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.enabledmaster 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 tofalsewas 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/corewith 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-kernelas 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.mjsfails 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.
| Gap | What is true today | Fix | Phase |
|---|---|---|---|
| Plugins execute in-process with full Node privileges | The 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 code | A 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
- Security policy — the triage decision table and how to report.
- What is the threat model? — the in-scope threats and the out-of-scope column this page cites.
- Security controls — the full control catalogue with source paths and status tags.
- Pre-launch hardening pass — the historical fix log, including the symlink entry this release corrects.
- Production hardening checklist — what the register's If you need more fields turn into operationally.