Security controls
Most controls on this page are shipped: code in packages/ and extensions/, tests next to it, and an audit event in observability.db where the entry names an audit category. Some are partial or not shipped, and each entry says so in its status line. Where a control has no enforcer, the entry states that as a limitation rather than as a guarantee.
Where a control has a per-personality knob, the entry names it and its default. Controls without a knob are either global or set by the operator in ~/.ethos/config.yaml, and the entry says which.
The controls fire in the order documented in the runtime precedence diagram. If two controls conflict, the earlier one wins.
Source
| Layer | Source |
|---|---|
| Channel controls | packages/safety/channel/src/ |
| Tool boundary | packages/core/src/tool-registry.ts |
| Filesystem boundary | packages/core/src/scoped/scoped-fs.ts, packages/storage-fs/src/scoped-storage.ts |
| Network reach | packages/safety/network/src/, packages/core/src/scoped/scoped-fetch.ts |
| Injection defenses | packages/safety/injection/src/, packages/core/src/agent-loop/result-defense.ts |
| Watcher | packages/safety/watcher/src/ |
| Install scanner | packages/safety/scanner/src/ |
| Redaction | packages/safety/redact/src/ |
| Audit store | extensions/observability-sqlite/src/, categories in packages/wiring/src/observability/ethos-observability.ts (ETHOS_EVENT_CATEGORIES) |
| Personality schema | packages/types/src/personality.ts (PersonalitySafetyConfig) |
Status legend
| Tag | Meaning |
|---|---|
| Shipped | Code lives at the linked path and tests cover it. Where the entry names an audit category, the event lands in observability.db. |
| Partial | Part of the control is enforced; the entry names the part that is not. |
| Not shipped / Not covered | No code enforces it today. Documented so you can plan around the gap. |
Channel-level controls
A channel adapter is the front door. If anyone who knows your bot's handle can DM it, every other layer has to clean up after the first compromise.
Channel controls are operator settings, one block per platform under channel_filter.<platform>.* in ~/.ethos/config.yaml. They are not personality fields. A platform with no channel_filter block admits every sender (checkMessage returns allow when the platform has no config).
Channel allowlist
Status: Shipped.
Per-platform sender allowlists: the platform's ownerUserId plus recipientAllowlist. An entry is an exact sender id (a Telegram numeric user ID, a Discord snowflake, a Slack U… ID) or, for email, a leading-* suffix pattern such as *@example.com (matchesGlob, which handles only a leading wildcard).
A non-allowlisted sender in a group is dropped. A non-allowlisted sender in a DM follows dmPolicy: pairing (the default) answers with a pairing code instead of passing the message to the agent loop; allowlist, reject, silent-drop and queue all drop it.
- Source:
checkMessageinpackages/safety/channel/src/channel-filter.ts - Tests:
packages/safety/channel/src/__tests__/channel-filter.test.ts - Audit: a drop is recorded as
audit.blockby the gateway, codechannel.allowlist.blockedfor a DM andchannel.mention_gatefor any group drop (extensions/gateway/src/index.ts). An admitted message records nothing.channel.allowis recorded when an owner approves a pairing code (codechannel.pairing.approved);channel.denywhen an owner runs/deny(codechannel.allowlist.removed).
One-time DM pairing codes
Status: Shipped.
An unknown sender who DMs the bot on a platform with dmPolicy: pairing receives a one-time code (generateCode, called from checkMessage). The owner redeems it to approve that sender: /allow <code> in the gateway, or /allow <code> in the chat REPL (runPairingCommand in apps/ethos/src/commands/pairing-commands.ts). The sender does not redeem the code.
| Property | What the code does | Enforced by |
|---|---|---|
| Bound to one sender | The code row stores the sender_id and platform it was issued for; redemption approves exactly that sender | consumeAndAllow in packages/safety/channel/src/pairing-store.ts |
| Owner-only redemption (gateway) | /allow is refused unless the caller is the code platform's ownerUserId | senderIsOwner check in the gateway's /allow handler, extensions/gateway/src/index.ts |
| Random | 8 random bytes (randomBytes(8)), hashed to an 8-character code. Uniqueness rests on the table's primary key; a collision throws rather than retrying | generateCode, makeCode |
| Expires | One hour | CODE_TTL_MS |
| Single use | The pending → consumed flip and the allowlist insert run in one transaction; a replay finds consumed | consumeAndAllow |
| Issue rate limit | One pending code per sender per platform per 10 minutes; further DMs in that window are dropped | generateCode (RATE_LIMIT_WINDOW_MS) |
| Redeem rate limit | 5 failed redemptions per owner in 10 minutes, or 20 failed redemptions globally, pauses that owner for 24 hours | recordFailedAttempt (LOCKOUT_THRESHOLD, GLOBAL_LOCKOUT_THRESHOLD, LOCKOUT_DURATION_MS) |
The redeem rate limit applies only when an owner id is passed, which the gateway does. The chat REPL's /allow passes none, because the person at the REPL is the operator.
- Tests:
packages/safety/channel/src/__tests__/pairing-store.test.ts - Audit category:
channel.pairing, recorded by the gateway (Gateway.recordPairing→recordChannelPairing,extensions/gateway/src/index.ts) with codechannel.pairing.issuedwhen a code is issued andchannel.pairing.redeem_failedwhen an/allowfails. The outcome is one ofissued,not_owner,not_found,consumed,expired,sender_mismatchorowner_paused; the code itself is never recorded. A rate-limited issue writes nochannel.pairingevent — it shows only as thechannel.allowlist.blockeddrop. A successful approval, including/communications approve-all, recordschannel.allow(codechannel.pairing.approved). Pinned byextensions/gateway/src/__tests__/pairing-audit.test.ts. - Limitation: the chat REPL's
/allow(runPairingCommand) does not go through the gateway and writes no audit event — neitherchannel.pairingon failure norchannel.allowon success.
Mention-gate (groups only)
Status: Shipped.
In a group, an allowlisted sender's message is dropped unless it mentions the bot. Drive-by hijacking by pasting a wall of text into a public channel doesn't reach the LLM at all. The owner bypasses the gate, so /allow works from any channel; non-owners cannot.
- Source: step 6 of
checkMessageinpackages/safety/channel/src/channel-filter.ts
Context-visibility filter
Status: Shipped.
Two separate mechanisms handle content the user did not author.
Provenance marking. The gateway wraps every admitted inbound message with wrapUntrusted before the agent loop sees it: the message text as channel_message, and any adapter-supplied channel history as channel_history (extensions/gateway/src/index.ts). This applies whatever contextVisibility says. Nothing handles forwarded content as a separate case.
Visibility filter. channel_filter.<platform>.contextVisibility removes content from non-allowlisted senders:
| Mode | Behaviour |
|---|---|
all (default) | Nothing removed. |
allowlist | When the message replies to a non-allowlisted sender (replyToUserId set and not on the allowlist), >-quoted lines are stripped. Channel history keeps only lines whose author is allowlisted; history the adapter did not attribute per line is dropped whole (filterPriorContext). If the adapter does not supply replyToUserId, quoted lines are kept. |
allowlist_quote | Alias of allowlist. Same behaviour. |
- Source: step 7 of
checkMessageinpackages/safety/channel/src/channel-filter.ts
Tool-level controls
Per-personality toolset enforcement
Status: Shipped.
The personality's toolset.yaml is a hard allowlist for built-in tools, enforced at the framework layer. DefaultToolRegistry.toDefinitions(allowedTools) filters the tool list the LLM sees, and executeParallel rejects calls outside the allowlist with a tool_result carrying is_error: true (preserving the Anthropic message contract).
Three kinds of tool are not gated by toolset.yaml:
| Tool kind | Gated by |
|---|---|
MCP tools (mcp__<server>__<tool>) | The personality's MCP server allowlist and per-server tool allowlist (passesFilter) |
| Plugin tools | The personality's plugins allowlist (passesFilter) |
Tools that set alwaysInclude | Nothing at the toolset layer; only the surface's excludeTools removes them |
- Source:
toDefinitionsandpassesFilterinpackages/core/src/tool-registry.ts - Example: the built-in
researcherpersonality'stoolset.yamlhas noterminal(the shell tool), so a skill that tells it to run a command cannot: the tool never reaches the model.
Hardline blocklist
Status: Shipped.
A small set of operations is always-deny, regardless of personality, regardless of approval, on every surface except the web. The danger predicate fires before any approval check. On the CLI, TUI, ACP and gateway loops the terminal and process guard hooks refuse the call outright. The web profile asks instead of blocking: a hardline call always reaches the approval card, never a stored grant or lease, and approving it covers that one call only.
- Source:
packages/wiring/src/danger-predicate.ts(hardlineReason),packages/wiring/src/compose-tools.ts(guard hooks, non-web),apps/web-api/src/services/approvals.service.ts(web:requestApprovalandapprove), pinned byapps/web-api/src/__tests__/services/approvals-hardline.test.ts - Audit: a refused call is recorded as
audit.blockwith codetool_blocked— the generic code everybefore_tool_callrefusal records (refuseinpackages/core/src/agent-loop/stages/per-call-enforcement.ts). No code distinguishes a hardline refusal from any other hook refusal; the event'scausecarries the reason text. - Shell-string shapes on the list include the inline-eval wrappers (
bash -cand its siblings,eval,python -c,node -e, anything piped into a shell) and case-variantrm. The wrappers are read from each command's arguments, so an eval flag after other options (bash -o pipefail -c) or a wrapper in front of the shell (| sudo -u root sh) is still caught:inlineEvalReasonandPATTERNSinextensions/tools-terminal/src/guard.ts, copied inextensions/tools-process/src/guard.ts.perl -e,ruby -eandphp -rare not on the list; the file's header names what else gets past it. - Command substitution (
$(…)and backticks) is not on the list. It requires approval instead, sokill $(lsof -t -i:3000)can run once a human says yes. See Approval modal.
Risk classifier (mode-aware, per-call)
Status: Shipped (rule-based, per call). Not shipped (sandbox attestation relaxation).
Rules decide whether a tool call runs, asks for approval, or is refused. No score is involved. createDangerPredicate applies them per call, for the personality's approval mode: a hardline command is refused, a call in a flag set asks (see Approval modal), and every other call runs.
The pattern check and the LLM classifier in packages/safety/injection/ do not judge tool calls. They check the results of tools that declare outputIsUntrusted (handleUntrustedResult in packages/core/src/agent-loop/result-defense.ts). See Two-tier classifier.
Sandbox attestation relaxes nothing today. Execution backends implement attest(), but nothing on a running turn's path reads the result. The only caller of isStrictAttestation is the backend conformance suite (packages/core/src/execution/conformance.ts), as packages/types/src/sandbox.ts records.
- Rules:
packages/wiring/src/danger-predicate.ts(createDangerPredicate,hardlineReason,approvalRequiredReason) - Sandbox attestation contract (no consumer on the turn path):
packages/types/src/sandbox.ts
Approval modal
Status: Shipped.
When any of the previous checks flag a call, the request waits for a human on three surfaces: the web UI modal (apps/web-api/src/services/approval-hook.ts), the Slack, Telegram and Discord approval cards (createSlackApprovalHook in apps/ethos/src/approval-coordinator.ts, registered by wireApprovalFlow in apps/ethos/src/commands/gateway.ts), and the operator's terminal in ethos chat. The approval is binary, sender-attributable, and persisted as an audit event.
ethos chat asks before a flagged call. The readline REPL prints the tool, the reason and a redacted, 300-character preview of the args, then reads y/N; anything but y or yes refuses. The TUI shows the same in a modal. The gate is wireTerminalApprovalGate with createTerminalApprovalSource (apps/ethos/src/terminal-approval.ts); the surfaces are attachCliApprovalPrompt (apps/ethos/src/lib/cli-approval-prompt.ts) and ApprovalModal (apps/tui/src/components/ApprovalModal.tsx). It flags the same calls as the web modal and the cards (createApprovalDangerPredicate in packages/wiring/src/approval-seams.ts), refuses a hardline call before asking, denies an unanswered prompt after approvalTimeoutMs (default 10 minutes, ApprovalCoordinator.requestApproval), and audits each decision. Pinned by apps/ethos/src/__tests__/terminal-approval.test.ts and apps/tui/src/__tests__/approval-modal.test.ts.
Where nobody can answer, a flagged call is refused rather than run:
| Run | Gate |
|---|---|
ethos chat -q, ethos chat on piped stdin, ethos acp | wireTerminalApprovalGate with coordinator: null (apps/ethos/src/terminal-approval.ts) |
ethos -z, ethos batch, ethos eval, ethos personality judge and the nightly scoring pass, ethos bench, the ethos mcp serve operator console | gateNonInteractiveLoop (apps/ethos/src/lib/non-interactive-approval.ts), pinned by apps/ethos/src/__tests__/non-interactive-approval.test.ts |
ethos cron run, ethos cron daemon | gateCronLoop (same file), which reuses the gateway's wireUnattendedApprovalGate; pinned by apps/ethos/src/__tests__/cron-approval-parity.test.ts |
Limitation: ethos acp does not forward an approval to its client through session/request_permission, so an ACP client cannot approve a flagged call; it is refused.
No surface asks about a call that will be refused anyway. A tool outside the personality's toolset is refused without a card, prompt or modal (notPermittedRefusal in packages/wiring/src/approval-seams.ts, passed as refusedAnyway to createSlackApprovalHook and to createWebApprovalHook).
- Source:
apps/web-api/src/services/approval-hook.ts,apps/ethos/src/approval-coordinator.ts,apps/ethos/src/terminal-approval.ts - Audit category:
audit.approval - Per-personality knob:
safety.approvalMode—manual|smart|off(packages/types/src/personality.ts). Default ismanual.offauto-approves a flagged call through theallowAutoApproveDangerousToolscapability ofcreateDangerPredicate, which two callers pass.wireTerminalApprovalGatealways passes it, sooffruns flagged calls unasked inethos chat,ethos acpand the non-interactive commands above.wireUnattendedApprovalGatepasses it only when the operator setsallowUnattendedDangerousTools: true; that covers the gateway's cron/dream loop andethos cron. Everywhere elseoffbehaves asmanual. A hardline call is refused in every mode. - What is flagged:
createDangerPredicateinpackages/wiring/src/danger-predicate.ts. Every mode flagsAPPROVAL_SURFACE_ALWAYS_ASK;smartaddsSMART_MODE_CONSEQUENTIAL_TOOLS; and when the personality runs on a host-local, non-containerized execution posture, every mode addsLOCAL_POSTURE_CONSEQUENTIAL_TOOLS(terminal,process_start,run_tests,lint). - Command substitution: a
terminal,run_tests,lintorprocess_startcommand containing$(…)or backticks is flagged in every mode, on any execution posture (approvalRequiredReasoninpackages/wiring/src/danger-predicate.ts). The web modal, the chat approval cards and theethos chatprompt ask; the terminal and process guards leave it to the gate because the gate marks itself withmarkHostApprovalGate. Theoffcapability never auto-approves it (createDangerPredicatein the same file), so underapprovalMode: offethos chatstill asks, and the unattended gate refuses it even withallowUnattendedDangerousTools: true. A surface with nobody to ask refuses it: the unattended gate, a chat surface with no cards, the MCP export, and the runs in the table above. Pinned bypackages/wiring/src/__tests__/command-substitution-guard.test.tsandapps/ethos/src/commands/__tests__/command-substitution-approval.test.ts.
Filesystem controls
ScopedStorage and BoundaryError
Status: Shipped (file-tool and storage boundaries). Not covered (a global guarantee that every ~/.ethos/ access is scoped).
A personality's filesystem reach is its fs_reach allowlist plus a global always-deny floor for sensitive paths (.ssh, .aws/credentials, /etc/passwd, and similar). Two decorators enforce it:
| Decorator | Guards | Refusal |
|---|---|---|
ScopedFsImpl (packages/core/src/scoped/scoped-fs.ts), built from fs_reach in packages/core/src/capability-resolver.ts | The file tools' ctx.scopedFs | Error whose message starts PATH_NOT_REACHABLE:; the file tool turns it into a tool error |
ScopedStorage (packages/storage-fs/src/scoped-storage.ts) | A Storage handed to a personality-scoped consumer | BoundaryError, which the surface turns into a tool error |
Not every read or write under ~/.ethos/ goes through either decorator. The SQLite stores, locks, the backup engine and the other modules on the raw-node:fs carve-out list in CLAUDE.md open paths directly; they are framework code, not tool reach.
- Cross-personality isolation tests:
extensions/tools-file/src/__tests__/boundary.test.ts(for example,researcher CANNOT read engineer's MEMORY.md) - Audit: a reach refusal is returned to the model as a tool error. No audit event is written for it.
Symlink-misdirection handling
Status: Shipped (misdirection defense). Not covered (check-then-open race).
The reach check does two things. First it normalises the path lexically — normalize(resolve(path)) — so .., ., and redundant-slash traversal cannot walk out of an allowed prefix. Then, for a path already inside the reach, it walks every segment below the matched prefix with lstat. When it finds a symbolic link, it follows it and re-judges the deny floor and the allowlist against where the link lands, up to MAX_SYMLINK_HOPS (32) links.
| Link | Outcome |
|---|---|
Lands outside the allowlist, e.g. <allowed>/notes.md → ~/.ssh/id_rsa | Refused at the link, before anything opens the target |
| Lands on the always-deny floor | Refused |
| Lands inside the allowlist | Allowed (ALLOWS a symlink whose target is inside the allowlist in boundary.test.ts) |
| Chain longer than 32 links | Refused |
The walk is per-segment rather than leaf-only because a symlinked parent escapes with a non-symlink leaf: <allowed>/data → /etc makes <allowed>/data/passwd an ordinary file whose link path passes any prefix test.
Normalisation alone does not close this. resolve() is a string operation and a symlink is a filesystem fact — the lexically-resolved link path is neither under a denied prefix nor outside the allowed one, so it passes the always-deny floor and the allow check both. The segment walk is what re-judges where the read actually lands.
The same walk exists as four hand-maintained copies, because the layer model stops them sharing code. All four follow a link, refuse an escape, and fail closed when a segment cannot be lstated for any reason other than not existing:
| Copy | Guards |
|---|---|
ScopedFsImpl.checkReach (packages/core/src/scoped/scoped-fs.ts) | File tools |
ScopedStorage.check (packages/storage-fs/src/scoped-storage.ts) | Scoped Storage |
containedPath / followFirstSymlink (packages/wiring/src/backup/restore.ts) | Backup restore destinations |
DocumentsService.reachable (apps/web-api/src/services/documents.service.ts) | The web Documents root |
A fix to one copy that is not applied to the others leaves the escape open on whichever path the caller takes. The comment above followFirstSymlink in scoped-fs.ts records the rule that all four change together.
What this does not close is the check-then-open race: an attacker who can swap a path between the walk and the open() can still redirect the read. Closing it needs openat-style directory handles with no-follow semantics, which Node does not expose. No code addresses it; treat it as container-level remediation.
- Tests:
extensions/tools-file/src/__tests__/boundary.test.ts;apps/web-api/src/__tests__/services/documents.service.fail-closed.test.ts(the Documents copy refuses on anylstaterror other thanENOENT) - History: this defense was originally implemented as a path-canonicalisation call inside the file tools and was dropped during a refactor that centralised normalisation at the boundary, without this page being updated. See Pre-launch hardening pass, entry 8 for the dated correction.
Bash + filesystem boundary
Status: Shipped (execution-posture gating). Not shipped (a config-load gate on sandbox attestation).
There is no tool named bash; the shell tool is terminal. What stops a personality with shell access from running unconfined on the host is the resolved execution posture, not an attestation check.
| Situation | Outcome | Enforced by |
|---|---|---|
| Personality holds an exec tool, host is not containerized | Posture is docker | resolveExecutionPosture in packages/wiring/src/resolve-execution-posture.ts |
| Docker cannot be built in this process | Refused, unless the operator sets execution.allowLocalFallback: true; then an honestly labelled local posture | resolveExecutionPosture (fallbackToLocal) |
The operator constitution sets execution.requireSandbox or execution.forbidLocal | The local posture is refused | constitutionForbidsLocal |
| Personality runs on a host-local posture | terminal, process_start, run_tests and lint need approval in every mode | LOCAL_POSTURE_CONSEQUENTIAL_TOOLS in packages/wiring/src/danger-predicate.ts |
Not shipped: SandboxAttestation and isStrictAttestation() exist in packages/types/src/sandbox.ts, but no config-load validator reads them and no runtime warning names an unsandboxed shell. The only caller of isStrictAttestation is the backend conformance suite.
- Posture tests:
packages/wiring/src/__tests__/resolve-execution-posture.test.ts - See also: G-EXEC in the security boundary
Network controls
Per-personality network policy
Status: Shipped.
A personality's safety.network block narrows which hosts its tools may reach. It lists hosts only — no ports, no protocols. The scheme is a separate global gate (see Scheme allowlist).
safety:
network:
allow:
- api.github.com
- "*.slack.com"
deny:
- uploads.example.com
allow_private_urls: false
| Field | Meaning |
|---|---|
allow | Host patterns: an exact host, a leading *. that matches the domain and its subdomains, or a bare * that matches every host (hostnameMatches in packages/safety/network/src/policy.ts). Absent, [] and ['*'] all mean no allow list. |
deny | Same pattern grammar. Checked before allow, so deny wins (checkAllowDeny). A bare * denies every host. |
allow_private_urls | Opts into RFC1918, loopback and link-local destinations. Default false. Cloud-metadata hosts stay blocked regardless. |
The set of hosts a tool can reach is the intersection of what the tool declares (capabilities.network.allowedHosts) and the personality's allow (resolveCapabilities in packages/core/src/capability-resolver.ts; refusals are HOST_NOT_ALLOWED from ScopedFetchImpl):
| Tool declares | No safety.network.allow | With safety.network.allow |
|---|---|---|
| Specific hosts (e.g. a search API) | The tool's declared hosts | Declared hosts that a personality pattern covers |
['*'] (e.g. web_extract) | Any public host | The personality's allow list |
With no allow list, a ['*'] tool reaches any public host, but only over http(s): private, loopback, link-local and cloud-metadata destinations stay refused by safeFetch (validateUrl in packages/safety/network/src/safe-fetch.ts; see SSRF protection). Declare an allow list on any personality whose web tools should reach only named hosts.
- Source:
PersonalitySafetyConfig.networkinpackages/types/src/personality.ts; parsed inextensions/personalities/src/index.ts - Tests:
packages/wiring/src/__tests__/personality-network-policy.test.ts;packages/core/src/__tests__/capability-resolver.test.ts('*' tool on a personality with no allow list — real safeFetch);packages/safety/network/src/__tests__/policy.test.tsandsafe-fetch.test.ts
SSRF protection
Status: Shipped.
safeFetch rejects requests whose resolved addresses are private (RFC1918), loopback, or link-local, unless the personality sets allow_private_urls: true. Operator-initiated A2A peering (ethos a2a peer add and the web Add-peer dialog) reads no personality's policy: its card fetch opts into private destinations only when ~/.ethos/config.yaml sets a2a.peering.allowPrivateUrls: true (A2aPeeringService.fetchVerified in packages/wiring/src/a2a-peering-service.ts; pinned by packages/wiring/src/__tests__/a2a-peering-private-urls.test.ts). Cloud-metadata hosts are rejected even then: 169.254.169.254, metadata.google.internal, metadata, metadata.azure.com, metadata.aws.amazon.com, fd00:ec2::254, Alibaba's 100.100.100.200, and Oracle's 169.254.0.23.
- Source:
validateUrlandsafeFetchinpackages/safety/network/src/safe-fetch.ts - Cloud metadata blocklist:
isCloudMetadataHostinpackages/safety/network/src/cloud-metadata.ts
Scheme allowlist
Status: Shipped.
URLs must use http or https. file://, gopher://, ftp://, data: and every other scheme are rejected, and so is a URL with embedded credentials (http://user:pass@host). safeFetch follows redirects manually, at most 5 hops, and re-runs the full check on every hop — a server-side 302 to file:///etc/passwd is rejected at the redirect, not at the request.
- Source:
checkSchemeinpackages/safety/network/src/scheme.ts; the per-hop loop insafeFetch(DEFAULT_MAX_REDIRECTS)
DNS pinning per HTTP client
Status: Shipped for safeFetch's default transport. Not covered: an injected fetchImpl, and the browser route guard.
safeFetch resolves the hostname once, validates every returned address, and then connects with pinnedFetch: undici's fetch with a per-request Agent whose connect.lookup (pinnedLookup) answers only with the addresses validation accepted. The socket connects to an address that was checked, so there is no second resolution for a DNS-rebinding attacker to win. Each redirect hop is re-validated and gets its own pinned Agent. The hostname still drives the Host header, TLS SNI and certificate verification.
Tools reach the network through ctx.scopedFetch — ScopedFetchImpl, which calls the real safeFetch on its default transport (the capability backends in packages/wiring/src/build-infrastructure.ts) — so web_extract and every other tool using ctx.scopedFetch is pinned. web_extract's own checkSsrf lookup (extensions/tools-web/src/ssrf.ts) runs first as an early refusal only; the connection still goes through safeFetch.
| Path | Pinned? |
|---|---|
safeFetch default transport, and every ctx.scopedFetch caller | Yes |
A caller that injects its own fetchImpl — the one production case is the vision input resolver's ctx.fetchImpl seam (extensions/tools-vision/src/input-resolver.ts) | No: the injected function resolves however it likes |
Browser sessions (installRouteGuard) | No: the guard validates the URL, and Chromium resolves the name again when it connects |
- Source:
pinnedFetchandpinnedLookupinpackages/safety/network/src/safe-fetch.ts - Tests:
packages/safety/network/src/__tests__/safe-fetch.test.ts("connection pinning");extensions/tools-web/src/__tests__/web-extract-pinning.test.ts(web_extractconnects through the pinned transport)
The browser SSRF guard does not survive an upstream proxy
Status: Not covered. Read this before setting browser.proxy.server.
Browser sessions carry their own SSRF guard: installRouteGuard puts a context.route('**/*') handler on every Playwright context, and each request is checked by the same validateUrl the fetch path uses, with hostnames resolved through node:dns/promises#lookup.
That resolution happens in the Ethos process. A proxied navigation is never resolved there. Chromium sends CONNECT <host>:<port> to the proxy and the proxy resolves the name, on its own resolver, inside its own network. So the guard checks an address the connection does not use, and a hostname that answers with a public IP here can answer with 10.0.0.5 inside the proxy's LAN. Ethos cannot see that and does not block it.
Literal IPs are unaffected — a URL with a private address in it is rejected before any resolution happens, proxy or not. The gap is names only, and it is not a bug we can close from this side: the resolver that matters belongs to the proxy.
Deploy a proxy you trust not to act as an SSRF pivot — one that cannot reach your internal network, or that enforces its own egress policy. The browser route guard is not that control and must not be relied on as one.
A takeover hands a human the agent's live browser
Status: Shipped, opt-in per personality.
browser_request_takeover pauses the agent and hands the live browser session to whoever answers the request. That session is not a blank window: it holds every cookie and every logged-in tab the agent accumulated, and when browser.profiles.enabled is on it is signed into whatever that personality's persistent profile is signed into — across turns, and across /new.
Two things follow. The window itself is open on the machine running Ethos, so anyone at that machine can drive it. And the request is raised with answerableBy: 'anyone', so in a group chat any member — not only the person the agent was talking to — may cancel it or follow the link into the web chat to hand it back.
Grant the tool in toolset.yaml only to personalities whose browser you are willing to hand over, and keep a persistent profile signed into accounts you would put in front of that audience.
Prompt-injection defenses
Tool results that re-enter the LLM context are the dominant vector for indirect prompt injection. Three layers handle this, and all three act on the results of tools that declare outputIsUntrusted.
Provenance wrapping
Status: Shipped.
Every result from a tool that declares outputIsUntrusted — success or error — is wrapped with provenance markers before it enters the LLM context (handleUntrustedResult in packages/core/src/agent-loop/result-defense.ts, called from packages/core/src/agent-loop/stages/tool-processing.ts). The marker names the tool and, where the arguments carry one, the source path, URL, command or query (describeSource). A result from a tool that does not declare outputIsUntrusted is not wrapped.
Two related mechanisms sit beside it:
| Mechanism | What it covers | Enforced by |
|---|---|---|
| Result delimiter fence | Every successful result, and every error that went through the untrusted path, is fenced in ===TOOL_RESULT_START:<name>=== … ===TOOL_RESULT_END===, with forged delimiters in the content escaped. On by default; safety.injectionDefense.toolResultDelimiters: false turns it off | tool-processing.ts |
| Inbound channel messages | The gateway wraps each admitted message and its channel history (see Context-visibility filter) | wrapUntrusted calls in extensions/gateway/src/index.ts |
Skill and context files are not wrapped. Their lines are sanitized instead (see Memory injection scanning).
- Source:
wrapUntrustedinpackages/safety/injection/src/wrap.ts - System prompt:
INJECTION_DEFENSE_PRELUDE, pushed into every personality's prompt bypackages/core/src/agent-loop/stages/context-assembly.ts(a compact variant whenpromptBudget.compactPreludeis set)
Two-tier classifier
Status: Shipped.
Tier 1 runs on every untrusted result. It is regex-based:
| Check | Rules |
|---|---|
shortPatternCheck (packages/safety/injection/src/pattern-check.ts) | ignore-instructions, disregard, forget-instructions, role-override, new-instructions, inline-system, inline-assistant, template-token, bidi-override, zero-width |
c2PatternCheck (same file) | Exfiltration phrasing (c2-exfiltrate-*), c2-system-override, memory-persistence phrasing (c2-memory-write, c2-remember-always, c2-persist-instruct), secret-read phrasing (c2-read-secrets, c2-exfil-keys), and others |
| Template-token strip | Chat-template tokens are stripped by wrapUntrusted; a strip counts as a Tier-1 hit |
Tier 1 has no base64-blob detector; only the phrase rule c2-exfiltrate-encode touches encoding.
Tier 2 is an LLM classifier (createLLMClassifier in packages/safety/injection/src/classifier.ts, wired in packages/wiring/src/build-agent-loop.ts). It runs when Tier 1 hit, when the content is longer than 500 characters, or when safety.injectionDefense.classifier.alwaysCallLLM is true (shouldCallLLM in result-defense.ts). There is no sampling budget. Limitation: a payload of 500 characters or fewer that Tier 1 misses is not sent to Tier 2 unless alwaysCallLLM is set.
A Tier-2 failure is fail-open: the turn continues on the Tier-1 verdict and records audit.block with code injection_classifier_failed. A flagged result records audit.block with code injection_detected and shows the user a warning.
- Sources:
packages/safety/injection/src/classifier.ts,packages/safety/injection/src/pattern-check.ts,packages/core/src/agent-loop/result-defense.ts
Post-read tool downgrade
Status: Shipped.
Any result from an outputIsUntrusted tool — flagged or not, success or error — arms the downgrade (untrustedReadThisIteration in tool-processing.ts, then advanceDowngrade in packages/core/src/agent-loop/stages/per-call-enforcement.ts). While it is armed, calls to the downgraded tools are refused with DOWNGRADE_REJECTION_MESSAGE (isDowngraded, same file). It stays armed for the next turns loop iterations (default 2) within the same run, and clears when the user sends a new message, because each run starts the counter at zero. The three persistence tools — memory_write, team_memory_write, skill_propose (RUN_SCOPED_DOWNGRADE_TOOLS) — are the exception: once the run has seen any untrusted result they stay refused until the run ends, because the untrusted text is still in context after the window lifts. The refusal names no retry. Tool calls a run_code script makes follow the same rule against the same run state: ScriptToolBridge (packages/core/src/agent-loop/stages/script-tool-bridge.ts) checks isDowngraded before each call, and an untrusted result inside the script arms the downgrade at once (armDowngrade), so a later call in the same script is refused too (pinned by packages/core/src/__tests__/downgrade-memory-writes.test.ts).
The run-scoped refusal also covers what would carry the untrusted text past this run (V2-SEC-2). The tools that schedule a later run from text this run writes — cron create/update, goal_create, kanban_create, kanban_create_goal, kanban_create_swarm, kanban_decompose, delegate_task with background: true, route_to_agent with background: true (a detached job on a mesh peer, whose BackgroundExecutor reads no taint), and watcher_create with a wake (its prompt_prefix is prepended to every wake prompt; a deliver-only watcher stays allowed) — and kanban_block, whose reason becomes the blocked run's summary that the ticket's next dispatch prompt carries (renderOperatorContext, extensions/kanban-store/src/prompt-thread.ts; pinned by extensions/tools-kanban/src/__tests__/untrusted-taint.test.ts; a tainted worker can still park its ticket with kanban_update_status(status: "blocked"), which ends the open run with no summary, and the refusal text says so — without it, a standalone ethos serve reclaims the stale ticket to ready and dispatches it again, apps/ethos/src/lib/kanban-poll.ts), are refused for the rest of the run (RUN_SCOPED_SCHEDULERS in per-call-enforcement.ts; refused rather than marked tainted-origin, because the schedulers that start those runs are not the loop and a mark nothing reads would enforce nothing). scaffold_personality and scaffold_team write a new personality's SOUL.md or a team manifest through the compose-time Storage, not the turn's ScopedFsImpl, so the state-dir write refusal below never sees them; they are refused by name instead (RUN_SCOPED_PROMPT_WRITERS, same file; pinned by extensions/tools-personality-design/src/__tests__/untrusted-taint.test.ts, and watcher_create by extensions/tools-watchers/src/__tests__/untrusted-taint.test.ts). A scoped filesystem write into the Ethos state dir — the personality's own MEMORY.md/USER.md, team memory, skills, another personality's files, cron/jobs.json — is refused by ScopedFsImpl.checkReach (writesEthosState, packages/core/src/scoped/scoped-fs.ts), except under a personality's files/ asset folder; this is what stops write_file/patch_file (window-only tools) from writing the page's text into MEMORY.md once the window lifts. A sub-agent run started inside one of the run's tool calls (delegate_task, mixture_of_agents) inherits the taint — it starts with the window and the refusals armed — and a sub-agent that reads untrusted content taints its parent, whose context receives its answer (resolveRunDowngrade and runToolsInTaintScope, same file, through packages/core/src/scoped/run-taint.ts). Pinned by packages/core/src/__tests__/downgrade-derived-runs.test.ts, extensions/tools-delegation/src/__tests__/untrusted-taint.test.ts and extensions/tools-file/src/__tests__/untrusted-state-writes.test.ts. A tainted run also skips the opt-in turn-end memory flush (TurnEndCtx.untrustedSeen, checked first in runMemoryFlush, packages/core/src/agent-loop/turn-end.ts; pinned by 'memory flush skips a run that read untrusted content' in packages/core/src/__tests__/turn-end-consolidation.test.ts). A background job's own run is detached from the tool call that spawned it, so its taint is carried on the job instead: EthosJobRunner passes RunOptions.onUntrustedRead, the executor records BackgroundJob.tainted at the terminal transition (job-store schema v10), and Gateway.admitWakeReview starts a deliver: 'parent' review turn of a tainted job with RunOptions.untrustedOrigin, which arms the downgrade before its first call (a replayed review reads the flag back from the job store and, when it cannot, starts armed). Pinned by extensions/job-runner/src/__tests__/untrusted-taint.test.ts and the tainted-job cases in extensions/gateway/src/__tests__/parent-review.test.ts. A job on the ACP coding-agent or Pi runner is marked tainted before the external agent starts (AcpJobRunner/PiJobRunner call JobRunnerContext.markTainted; pinned by the 'the job is marked tainted' cases in their __tests__/runner.test.ts), because nothing reports what that process read. The taint link is scoped to the run that owns it: the shared BackgroundExecutor and GoalRunner start every job and goal run in the async context they were constructed in, not in the tool call that nudged them (detached in extensions/job-runner/src/index.ts and extensions/goal-runner/src/index.ts), and a run with jobId or reviewOfJobId ignores any ambient link (resolveRunDowngrade), so one session's tainted batch neither arms nor is tainted by another session's job (pinned by extensions/job-runner/src/__tests__/taint-isolation.test.ts and extensions/goal-runner/src/__tests__/taint-isolation.test.ts). The mesh tools carry the taint across the process boundary (MESH-TAINT), where no AsyncLocalStorage link reaches: from a tainted run, route_to_agent, dispatch_team and broadcast_to_agents add untrustedOrigin: true to the peer's prompt params (meshTaintParams, extensions/tools-delegation/src/index.ts, reading runIsTainted), and AcpServer starts that run with RunOptions.untrustedOrigin on both the HTTP and the streamed prompt (apps/acp-server/src/index.ts), so the peer's run starts with the window and the run-scoped refusals armed. The marker only adds refusals. Every mesh answer is untrusted content (outputIsUntrusted on all three tools), so an untainted run that asks a peer is tainted by the answer, whatever that peer read. A run started by an inbound A2A peer message is always armed (createA2aRunner, apps/ethos/src/commands/serve-a2a-runner.ts): the message is another operator's agent's text, already fenced as untrusted (wrapUntrusted in packages/a2a/src/rpc.ts), and no wire field can opt it out. The cost has two parts. The step window: a peer-invoked skill cannot use a downgraded tool (terminal, write_file, …) in its first model steps (2 by default). And the run-scoped refusals, which last for the WHOLE run, not just those steps: memory_write, team_memory_write and skill_propose; the schedulers above (cron, goal_create, the kanban_create* tools and kanban_decompose, kanban_block, delegate_task and route_to_agent with background: true, watcher_create with a wake); scaffold_personality and scaffold_team; every scoped write into the Ethos state dir; and the turn-end memory flush. So a peer-invoked skill that needs to remember, schedule or scaffold anything is refused for the entire run (isDowngraded and the RUN_SCOPED_* sets in packages/core/src/agent-loop/stages/per-call-enforcement.ts, armed through resolveRunDowngrade). postReadDowngrade.enabled: false is the per-personality off switch for both. Pinned by apps/acp-server/src/__tests__/mesh-taint-e2e.test.ts (real AgentLoops on both sides of a real socket, including a case that strips the marker in transit and sees the peer write memory) and the MESH-TAINT cases in apps/ethos/src/commands/__tests__/serve-a2a-runner.test.ts. The refusal text says the run "is handling untrusted content", read directly or inherited. Not covered: host terminal/process_start/run_code processes (window-only; approval-gated); a mesh peer that is not an Ethos AcpServer can ignore the marker.
The default tool list is DEFAULT_DOWNGRADED_TOOLS in packages/safety/injection/src/downgrade.ts: terminal, run_code, run_tests, write_file, patch_file, web_extract, browse_url, browser_click, browser_type, process_start, process_stop, memory_write, team_memory_write, skill_propose. A hijacked agent that has just read a poisoned page cannot immediately open a shell or type into a form, and it cannot persist the page's instructions into MEMORY.md, team memory or a skill proposal — text that every later system prompt would carry (pinned by packages/core/src/__tests__/downgrade-memory-writes.test.ts).
- Source:
packages/safety/injection/src/downgrade.ts; the gate inpackages/core/src/agent-loop/stages/tool-processing.ts - Audit: a refused call records
audit.blockwith codetool_downgraded_post_untrusted_read - Per-personality knob:
safety.injectionDefense.postReadDowngrade—{ enabled, turns, tools }.enableddefaults totrue, andenabled: falseturns the downgrade off for that personality.toolsreplaces the default list. - Other opt-outs in the same block:
toolResultDelimiters: falseremoves the delimiter fence, andblockSecretResults: falsestops secret redaction of tool results (see Credential redaction). Wrapping, the prelude and Tier 1 have no knob.
Memory injection scanning
Status: Shipped.
Memory content — MEMORY.md, USER.md, and team topic files — is sanitized with sanitize (packages/safety/injection/src/prompt-sanitize.ts). Any line matching an injection pattern such as "ignore previous instructions", [SYSTEM], or a role-override phrase is replaced with [line removed by injection guard].
Memory is a higher-risk surface than context files. A single poisoned line in MEMORY.md re-injects into the system prompt on every subsequent turn and every future session. A poisoned USER.md crosses personality boundaries, so every personality the user talks to sees it.
| Point | Personality memory | Team memory |
|---|---|---|
| Write | memory_write sanitizes before sync() (extensions/tools-memory/src/index.ts) | team_memory_write sanitizes before sync() |
| Tool read | memory_read sanitizes what it returns | team_memory_read sanitizes what it returns |
| Prompt assembly | The memory snapshot is sanitized before the system prompt is built (packages/core/src/agent-loop/stages/context-assembly.ts) | Only topic names are injected, not content |
The read-time pass catches content written before scanning existed or edited by hand on disk.
- Audit: none. A removed line is not recorded as an event.
Watcher
Status: Shipped (fixed default rule set).
The watcher is a separate observer in the same process. createWatcherTap (packages/core/src/agent-loop/watcher-tap.ts) feeds it every tool event synchronously, and the loop consults its decision at iteration boundaries and before each call (consultWatcherHalt). Its rules do not depend on any other check classifying a call correctly.
| Rule | Fires when | Decision |
|---|---|---|
rate-limit | More than 60 tool_end events in 60 seconds, counted across turns | pause |
token-budget | More than 50,000 output tokens in one turn | pause |
compounding-error | 5 consecutive failures of the same tool | pause |
suspicious-sequence | A read_file, search_files or terminal call on a credential-shaped path (.ssh, .aws/credentials, .gnupg, .netrc, /etc/passwd, /etc/shadow, /etc/sudoers, authorized_keys), followed within the last 4 calls by an exfiltration-shaped tool: one in EXFIL_TOOL_NAMES (web_extract, browse_url, browser_navigate, browser_computed_style, video_analyze, meet_join, browser_type, browser_vision_type, send_message, call, a2a_send, route_to_agent, broadcast_to_agents, dispatch_team) or any mcp__* tool (isExfilShapedTool) | terminate |
A pause emits a halt event, rejects the pending calls, and ends the turn with one closing model call that has no tools (replyAfterWatcherPause). No human review queue holds the call. A terminate ends the turn with an error event whose code is watcher_<rule>.
The list is drawn by destination: it names tools that can carry agent-chosen content to a destination the agent chooses, so a tool that only talks to a fixed provider (web_search) is not on it, and terminal counts only as a credential read. The list is pinned against the registered tools by packages/wiring/src/__tests__/watcher-exfil-tool-names.test.ts: every name must be a real tool, and every tool that declares allowedHosts: ['*'] must be listed or exempted there with a reason.
- Source:
packages/safety/watcher/src/watcher.ts,packages/safety/watcher/src/rules.ts(defaultRules) - Audit category:
audit.watcher - Configuration: none per personality. Wiring always constructs the watcher with
defaultRules()(packages/wiring/src/build-agent-loop.ts). The only bypass is the run optionallowDangerousToolCalls.
Credential redaction
Status: Shipped.
Redaction runs in two places.
Before disk. The observability store redacts every trace attribute, span attribute, event detail and event cause it writes (packages/safety/redact/src/index.ts redactString / redactJson, applied in extensions/observability-sqlite/src/store.ts). The built-in pattern set covers Anthropic, OpenAI, Groq and xAI API keys, GitHub tokens, AWS access keys, Google API keys, Telegram, Discord, Slack and Stripe tokens, private keys, JWTs, bearer tokens, a generic-secret shape, and the value of any secret-named assignment or JSON key (DISCORD_BOT_TOKEN=…, export ELEVENLABS_API_KEY=…, "api_key": "…", tagged [REDACTED:secret-value]), Authorization: Basic credentials (a base64-shaped value only, so prose such as "Basic auth" is left alone), and the password in URL userinfo (postgres://user:pw@…, redis://:pw@…). A templated or placeholder value is left alone: a URL password that is wholly ${DB_PASS}, $REDIS_PASSWORD, {{…}}, %(password)s, %DB_PASS%, <password>, **** or xxxx (a real password that merely starts with $, %, * or {, such as $ecret… or a percent-encoded %40…, is still redacted), and a secret-named key set to required, none, null or your_…_here (pinned by the 'V3-5 letters-only secrets', 'V3-9 URL credentials' and 'V4-1' cases in packages/safety/redact/src/__tests__/redact-roster-s13.test.ts). Vendor prefixes match only at an identifier boundary, so a long kebab-case path such as disk-usage-report-… (which contains sk- mid-word) is left alone. A match is replaced with a labelled tag such as [REDACTED:anthropic-key].
Before the model. Every tool result's value, error and structured leaves are scanned (redactToolResultSecrets in packages/core/src/agent-loop/stages/result-redaction.ts) before any consumer sees the result, including the next turn's context. A detection records a secret_in_tool_result safety event, and the detected text is replaced unless the personality sets safety.injectionDefense.blockSecretResults: false.
Knob (safety.observability) | Values | What it does |
|---|---|---|
storeToolArgs | none | redacted (default) | full | none drops tool-call args from spans. full skips the personality's redactPatterns; the built-in patterns still apply. |
storeLlmPayloads | none | metadata | full | full stores message content on the LLM span; it is redacted like every other attribute. |
redactPatterns | regex strings | Extra patterns, replaced with [REDACTED:custom]. |
storeToolBodies | none | redacted | full | Reserved. Accepted and shown on the character sheet, but tool result bodies are never stored in observability.db at any setting; only the result size is recorded (pinned by packages/core/src/__tests__/tool-body-not-stored.test.ts). |
ethos support bundle reads events that were already redacted in the store and strips secret-shaped fields from the config it includes (stripSecrets in apps/ethos/src/commands/support.ts). --anonymize only replaces the home directory, working directory, hostname and username.
- Source:
packages/safety/redact/src/index.ts(extensions/observability-sqlite/src/redact.tsre-exports it) - Audit:
audit.redactedis not emitted today.recordRedactedexists inpackages/wiring/src/observability/ethos-observability.tsand nothing calls it.
Skill and plugin install controls
Static-analysis pattern scanner
Status: Shipped.
Skills and plugins are scanned at install and promotion time.
| Scanner | Rules |
|---|---|
packages/safety/scanner/src/skill-scanner.ts | prompt-injection, hidden-unicode, base64-blob, sensitive-tool-instruction, role-override, external-url-instruction |
packages/safety/scanner/src/plugin-scanner.ts | dynamic-code-exec, credential-access, fs-write-outside-safe-path, exfil-shape, and shell-exec / network-access for capabilities the plugin uses but did not declare in ethos.permissions (checkShellExec, checkNetworkAccess) |
The scanner has no rule for declared-but-unused permissions and none for required-tool inflation.
The scanner is pre-install and advisory. It reads text before the code is installed; it sandboxes nothing, constrains nothing at runtime, and is evaded by string concatenation. It is published as a non-boundary — see What is not a boundary — and nothing on this page should be read as strengthening that.
- Audit category
install.scan: one event per scan decision, with codeinstall.scan.pass,install.scan.warn,install.scan.needs_ackorinstall.scan.blocked, built byinstallScanEvent(packages/wiring/src/observability/install-scan.ts). Details carry the kind, source, tier, verdict, finding counts and rule ids — never excerpts, file bodies or URL credentials. Emitted byethos skills install(scanSkillDirinapps/ethos/src/commands/skills.ts→recordInstallScaninapps/ethos/src/wiring.ts),ethos plugin install(installPlugininapps/ethos/src/commands/plugin.ts→recordInstallScan), and learning promotion (learningPromoteDepsinpackages/wiring/src/learning-pipeline.ts→recordSkillScan). Pinned bypackages/wiring/src/__tests__/install-scan.test.ts,packages/wiring/src/__tests__/learning-pipeline.test.tsandapps/ethos/src/__tests__/install-scan-audit.test.ts. - Limitation: three scan paths write no
install.scanevent — plugin install from the web API, the plugin loader's load-time rescans, andUniversalScannerdiscovery. - Audit category
install.event: agent-mesh journal entries andethos data reset.
Trust tiers
Status: Shipped.
There are four tiers. A tier is derived from the source string the skill or plugin was installed from, on every scan. It is not assigned per skill, cannot be promoted, and produces no audit event — there is no promotion action to record.
| Tier | Derived from | Yellow findings | Red findings |
|---|---|---|---|
builtin | The literal source builtin — code shipped inside this repository | Auto-acknowledged | Installs with --force |
trusted-repo | github.com/<org>/<repo> where <org> is listed in security.trusted_github_orgs | Acknowledgment required; --force stands in for it | Installs with --force |
community | Any other github.com/…, clawhub/…, or hermeshub/… source | Acknowledgment required; --force stands in for it | Blocked; --force is ignored |
untrusted | A local path or a raw URL | Acknowledgment required; --force stands in for it | Blocked; --force is ignored |
builtin is the only tier that auto-acknowledges yellow findings. Overriding a red finding with --force is the only privilege trusted-repo holds over community.
security.trusted_github_orgs is operator-configurable and replaces the shipped default (ethosagent, anthropic) rather than extending it. Set it to a different list to trust different organizations, or to an empty value to trust no organization by name. Organization matching is exact on the path segment. A github.com/ source with a . or .. segment yields no organization (extractGitHubOrg), so it is never trusted-repo; it is derived as community, not refused. See security.trusted_github_orgs.
Residual risk: a red finding in a configured organization is overridable with --force.
- Source:
deriveTierinpackages/safety/scanner/src/trust-tiers.ts
Plugin capability grants
Status: Shipped.
A grant is a consent record, not runtime enforcement. It is not a boundary and it does not confine a plugin.
PluginLoader import()s a plugin's entry module directly into the Ethos process. The plugin shares the process, the environment, the filesystem, and your API keys. Installing a plugin is equivalent to running arbitrary code as your user. This is an accepted, documented Tier 1 property — see Gaps, disclosed.
At install the operator is shown what the plugin declares in ethos.permissions (shell — intent to shell out; network — declared hosts, or none), told in plain words what installing it means, and their agreement is recorded durably alongside the scan findings as they stood at that moment. The record's field is named capabilities, and there are no per-capability toggles: it records what was declared and what was agreed to. Nothing checks a plugin against its declaration while it runs, and an undeclared capability is not blocked.
| Command | What it does |
|---|---|
ethos plugin grants | Lists every recorded grant — package, version, source, what was declared, the scan result at install, and any revocation date |
ethos plugin revoke <pluginId> | Withdraws the grant. The record is kept with a revokedAt date rather than deleted |
Revocation prevents the next load and blocks lockfile auto-install. It cannot claw back anything an already-loaded plugin did — that code has already run as the user.
- Source:
extensions/plugin-loader/src/grants.ts - Record:
<pluginsDir>/grants.json - Load-path gate:
extensions/plugin-loader/src/index.ts
MCP environment minimization
Status: Shipped.
When Ethos spawns a stdio MCP server, the child's environment is built from an allowlist, not from the parent's environment minus a denylist (buildMcpEnv, called from extensions/tools-mcp/src/index.ts):
| Variable | Child receives |
|---|---|
PATH, USER, LANG, LC_ALL, TERM, SHELL | Passed through |
Names listed in the server's mcpEnvPassthrough | Passed through |
A name matching KEY, TOKEN, SECRET or PASSWORD as a _-separated word | Dropped, unless listed in mcpEnvPassthrough |
HOME, TMPDIR, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME | Set to a persistent per-server directory, ~/.ethos/mcp-runtime/<serverId>/ (mode 0700); the server's own env config cannot override them (PINNED_MCP_KEYS) |
| Everything else | Dropped |
Limitation: this changes what the child inherits, not what it can read. The MCP server runs as your user, and a server that opens /Users/<you>/.aws/credentials by absolute path still reads it.
- Source:
packages/safety/scanner/src/mcp-env.ts
Allowed skill permissions
Status: Shipped.
safety.allowed_skill_permissions limits the permissions a skill may declare, per category. It is not a list of tool names.
safety:
allowed_skill_permissions:
fs_read: ["~/notes"]
fs_write: false
network: ["api.github.com"]
mcp_env_passthrough: false
Each of fs_read, fs_write, network and mcp_env_passthrough takes a list (only those values), true (any value), or false / absent (none). checkSkillPermissions in extensions/skills/src/ingest-filter.ts compares each value a skill declares against the policy and omits the skill ({ include: false, reason }) on the first disallowed value. There is no typed error. When the personality declares no allowed_skill_permissions block at all, declared permissions are only warned about, not enforced. mcp_env_passthrough is deny-by-default when the block is present (deriveSkillPassthrough).
A skill's required_tools is a separate check: every required tool must be in the personality's effective tool reach — its toolset plus the tools of its attached MCP servers and plugins — or the skill is omitted (toolNamesForPersonality in packages/core/src/tool-registry.ts, applied in ingest-filter.ts).
- Source:
extensions/skills/src/ingest-filter.ts;allowed_skill_permissionsinpackages/types/src/personality.ts
Audit substrate — observability.db
Status: Shipped (write path and the categories marked emitted). Not shipped (audit.transition, audit.redacted, policy snapshots).
Safety events land in observability.db as typed events. EventCategory is a string (packages/types/src/observability.ts); the Ethos vocabulary is ETHOS_EVENT_CATEGORIES in packages/wiring/src/observability/ethos-observability.ts.
| Category | Emitted today? | What it records |
|---|---|---|
audit.approval | Yes | Operator approved or denied a tool call, with sender attribution (recordSafetyApproval) |
audit.block | Yes | Any safety refusal. The code field says which: tool_blocked (hook refusal, including hardline), tool_downgraded_post_untrusted_read, injection_detected, injection_classifier_failed, channel.allowlist.blocked, channel.mention_gate, and others |
audit.watcher | Yes | Watcher paused or terminated a turn (recordWatcherDecision) |
audit.injection_flag | Yes, gateway only | Tier 1 matched an inbound channel message (code channel.injection_detected) or a background-job summary. Flags on tool results are audit.block / injection_detected instead |
channel.pairing | Yes, gateway only | Pairing code issued; /allow failed (Gateway.recordPairing) |
channel.allow / channel.deny | Yes, gateway only | Owner approved a pairing code / ran /deny |
install.scan | Yes, on the CLI install paths and learning promotion | Skill or plugin scan decision (installScanEvent); see Static-analysis pattern scanner for the paths not covered |
install.event | Yes | Agent-mesh journal entries and ethos data reset |
audit.transition | No | recordSafetyTransition exists; nothing calls it. ethos audit and the support bundle filter on it and find nothing |
audit.redacted | No | recordRedacted exists; nothing calls it |
The store uses STRICT-mode SQLite tables in WAL mode with synchronous = NORMAL (see the durability table in CLAUDE.md). It has no FTS5 index. Retention is set per domain prefix, not per category, with retention.events.<domain> lines in ~/.ethos/config.yaml, and a per-personality override with personalities.<id>.retention.events.<domain> lines in the same file:
| Prefix | Default retention |
|---|---|
error | 90 days |
audit.* | 365 days |
channel.* | 365 days |
install.* | Forever |
Policy snapshots are not written today. The snapshot table and recordSnapshot exist in extensions/observability-sqlite/src/service.ts, but nothing calls recordSnapshot and startTurnTrace is never given a snapshotId. You cannot yet reconstruct a personality's network policy at the time of an event from the store.
- Source:
extensions/observability-sqlite/src/store.ts,extensions/observability-sqlite/src/service.ts,extensions/observability-sqlite/src/retention.ts
Cron output path containment
Status: Shipped (lexical containment). Not covered (symlinks).
CronScheduler.readRunOutput() and readRunProgress() read only paths inside the scheduler's outputDir. assertInOutputDir resolves the path and refuses it when the path relative to outputDir starts with .. or is absolute. A path that contains .. but resolves inside outputDir is allowed. The check is lexical: it does not walk symlinks, so a link inside outputDir that points elsewhere is followed.
- Source:
assertInOutputDirinextensions/cron/src/index.ts - Tests:
extensions/cron/src/__tests__/cron.test.ts,extensions/cron/src/__tests__/run-progress.test.ts
Web dashboard and admin authentication
Status: Shipped.
The web API accepts two credentials. There is no ethos token create command and no OS-keychain token store on the server.
| Credential | How it is issued | Where it is accepted | Enforced by |
|---|---|---|---|
ethos_auth cookie | Unclaimed instance: ethos serve prints a ?t=<token> URL; GET /auth/exchange (or a token pasted into POST /auth/token-login) sets the cookie (HttpOnly, SameSite=Strict). Claimed instance: POST /auth/login (username + password, argon2id hash in <dataDir>/web-admin.json) issues a server-side session id. Machine clients mint the cookie from <dataDir>/web-token directly; one shared verifier accepts a session id or the raw token | /rpc/*, /sse/*, /openapi/*, /setup/whatsapp/*, and the voice, satellite and takeover WebSockets | authRoutes in apps/web-api/src/routes/auth.ts; authMiddleware in apps/web-api/src/middleware/auth.ts; dualAuth in apps/web-api/src/middleware/dual-auth.ts |
Bearer API key (sk-ethos-…) | ethos api-key create --name <label> [--scopes <a,b>] (default scope chat), or the web Settings page through the apiKeys.create RPC. Stored hashed in sessions.db by SqliteApiKeyStore (extensions/session-sqlite/src/api-key-store.ts) | /rpc/* and /sse/* methods whose namespace maps to the key's scope in SCOPE_MAP, plus the OpenAI-compatible /v1/* surface (scope chat), /metrics (metrics:read) and /cron/fire (cron) | dualAuth and resolveScope in apps/web-api/src/middleware/dual-auth.ts; bearerAuth in apps/web-api/src/middleware/bearer-auth.ts |
Claiming is opt-in. An instance with no web-admin.json is unclaimed and stays in token mode indefinitely. Claiming it at /welcome (bootstrap token + username + password) is what retires browser token login: from then on the exchange URL redirects to /welcome/reset with the token prefilled and grants no session, while the raw token stays cookie-valid for machine clients. ETHOS_WEB_TOKEN in the environment replaces the generated token file — at least 24 characters, checked at boot — and suppresses the printed URL. Reset lives at /welcome/reset, gated by the bootstrap token; the login screen carries no reset link, and a successful reset invalidates all human sessions. All the /auth POSTs are rate-limited and Origin-checked.
A request with neither credential receives 401 Unauthorized. A bearer key is refused with 403 Forbidden in these cases:
- Unmapped namespaces. Any namespace absent from
SCOPE_MAP, which includesadminandoutbox, is cookie-only.dualAuthfails closed on it. - Cookie-only methods. Methods mapped to
COOKIE_ONLY, such aspersonalities.createandtools.test, reject every bearer key. - The
apiKeysnamespace. A bearer key cannot mint or revoke keys.cookieOnlyGuardrefuses it at the route, anddualAuthrefuses it again. - Missing scope. A key without the method's required scope is refused.
The admin namespace has a second gate. Every admin procedure calls requireAdmin (apps/web-api/src/services/admin.service.ts), which returns 403 unless admin.enabled: true is set in ~/.ethos/config.yaml. The default is disabled.
When ethos serve is not given an API-key store, /rpc/* and /sse/* fall back to authMiddleware, which accepts the cookie only (apps/web-api/src/routes/index.ts).
- Pinned by:
apps/web-api/src/__tests__/routes/auth-and-rpc.test.ts(cookie exchange,401without a cookie),apps/web-api/src/__tests__/middleware/dual-auth-scope.test.ts(fail-closed and cookie-only methods),apps/web-api/src/__tests__/middleware/apikeys-auth-bypass.test.ts(apiKeysnamespace),apps/web-api/src/__tests__/routes/admin.test.ts(admin.enabledgate) - Limitation: no test sends a bearer key to an
adminprocedure. The refusal comes from the generic unmapped-namespace branch indualAuth, whichdual-auth-scope.test.tsexercises throughoutbox. - Cross-ref: Authenticate your dashboard users
Read-only plugin data source access
Status: Shipped.
Plugin data sources expose SQLite databases to the dashboard. Read-only enforcement lives at the SQLite connection, not in a keyword filter: both query paths open the database with new Database(dbPath, { readonly: true }), so the engine refuses every write regardless of what the statement text says.
Connection-level read-only is the stronger property. A denylist of write keywords is a guess about what a string means, and SQL offers cheap ways to make a write not look like one — a leading comment, unexpected casing, a WITH prefix, or a keyword that never appears at the position the filter inspects. A connection opened read-only does not interpret intent; SQLite refuses the write at execution.
| Layer | Where | What it does |
|---|---|---|
| Read-only connection | runPluginQuery in dashboards.service.ts; refreshSinglePanel in dashboard-refresh.ts | Engine-level refusal of all writes. Covers both query paths. |
| SELECT-only statement guard | assertSelectOnlySql in interpolate-params.ts | Write-time. Requires a single statement beginning with SELECT, and rejects embedded ;. addPanel applies it to every SQL panel, and updatePanel applies it whenever a patch sets sqlQuery. |
| Param allowlist | findInvalidParamKeys in interpolate-params.ts | Values interpolated into panel SQL must match a declared select/options option or a YYYY-MM-DD date. Template positions cannot be ?-bound, so this allowlist is the injection defense on that path. |
| Keyword prefix denylist | MUTATING in runPluginQuery | Ad-hoc dashboards.runQuery RPC only. Rejects eight leading keywords. It tests only the statement's first word — a usability guard, not a boundary. |
Both paths that read a plugin data source carry the read-only connection: runPluginQuery, behind the ad-hoc dashboards.runQuery RPC, and refreshSinglePanel, behind scheduled and manual panel refresh. There is no third path — getDataSourcePath in extensions/plugin-loader/src/index.ts has exactly these two callers.
Registration itself is not validated. registerDataSource(id, path) (PluginApiImpl in packages/plugin-sdk/src/index.ts) records whatever identifier and filesystem path the plugin passes, with no path containment, extension check, or identifier constraint. A plugin can therefore point a data source at any SQLite file the host process can open. That is consistent with the plugin trust model — plugin code already runs in-process — but it means the boundary here is read-only access, not restricted reach.
- Source:
extensions/dashboard/src/dashboards.service.ts,extensions/dashboard/src/dashboard-refresh.ts,extensions/dashboard/src/interpolate-params.ts - Cross-ref: Register a plugin data source
Desktop remote connection security
Status: Shipped.
When Mission Control connects to a remote Ethos instance, the web token is encrypted with Electron safeStorage (whose key the OS keychain holds) and stored in the app's keychain.json, not in plaintext config (apps/desktop/src/main/keychain.ts). The main process writes it onto the remote origin as the ethos_auth cookie before navigating; the window then loads the remote server's own SPA same-origin, so there is no cross-origin request to authorize. /auth/exchange is deliberately not used — the app already holds the token, and the token stays cookie-valid whether or not the instance is claimed, so the desktop flow is unchanged by web claiming.
The renderer never receives the token itself. The keychain:preview IPC returns a masked preview — the first 3 and last 4 characters — for display (maskApiKey in apps/desktop/src/main/ipc.ts).
- Source:
apps/desktop/src/main/connection.ts,apps/desktop/src/main/keychain.ts - Cross-ref: Deploy Mission Control with a remote Ethos
Removed empty safety stubs
Five directories under extensions/ carried safety-package names and shipped no code: safety-injection/ and safety-scanner/, removed earlier, and safety-channel/, safety-network/, and safety-watcher/, removed in this release. All five are gone. The real implementations live under packages/safety/ — injection/, scanner/, channel/, network/, watcher/, redact/, and groundtruth/ — which are the source paths listed throughout this page and the Tier 0 members named in the security boundary.
Empty directories with kernel names in the extensions tree are not cosmetic. They point a reader looking for the kernel at the wrong tier, which is the one thing the tier roster exists to prevent.
Per-personality vs. global
This table shows where each control's policy is set. "Operator" means ~/.ethos/config.yaml, which applies to the whole deployment.
| Control | Per-personality | Global or operator |
|---|---|---|
| Channel allowlist + pairing | no | operator, per platform (off for a platform with no channel_filter block) |
| Toolset enforcement | yes (toolset.yaml) | no |
| Hardline blocklist | no | yes |
| Risk classifier | yes (approvalMode) | yes (rules) |
| Filesystem reach | yes (fs_reach) | yes (always-deny floor) |
| Network policy | yes (safety.network) | yes (SSRF, scheme, cloud-metadata) |
| Provenance wrapping | no | yes |
| Post-read tool downgrade | yes (enabled, turns, tools) | no |
| Watcher rules | no | yes (fixed default rules) |
| Credential redaction | yes (storeToolArgs, storeLlmPayloads, redactPatterns, blockSecretResults) | yes (built-in pattern set) |
| Skill / plugin scanner | no | yes (trusted orgs are operator-set) |
| Audit substrate | no | yes (write path); operator retention, with per-personality override |
| Admin panel token auth | no | yes |
| Read-only SQL enforcement | no | yes |
| Desktop remote connection security | no | yes |
The engine is global and the policy is per-personality, so different roles can take different risk postures. A researcher personality can list more hosts in safety.network.allow than an engineer personality without weakening the SSRF or cloud-metadata controls — those apply to both.
Verifying these controls yourself
Every control above lists a source path. Read the code. Read the tests next to it. Run the test suite:
pnpm check
The tests include adversarial bypass attempts — encoding tricks, redirect chains, symlink chains — not just happy-path verification. If a test fails on your branch, you've found a regression in a control we depend on.
See also
- What does Ethos guarantee, and what is outside its security boundary? — which of these controls are published guarantees, and which are not.
- How does Ethos defend against the threats it knows about? — the layered model and runtime precedence.
- What is the threat model? — what each control is defending against.
- Pre-launch hardening pass — the issues a pre-launch review surfaced and how each was folded in.
- Responsible disclosure — how to report a control bypass.