| name | fleet-event-spawn-trust |
| description | Secure the path from an inbound event (webhook, email, SMS, GitHub comment, file, cron) to an agent spawn in the Port Daddy fleet. Use when wiring trigger sources to agents, reviewing the io-wiring registry, adding a trust/permission gate, hardening output sinks (SSRF/path traversal), scoping tools by provenance, or proving the spawn path is safe against prompt-injection → tool-abuse. Encodes ADR-0093 and the red-team threat model. NOT for generic Coast Guard sandboxing (ADR-0050), the macaroon push-grant gate alone (ADR-0053), or non-fleet code. |
| metadata | {"type":"reference"} |
Fleet Event→Spawn Trust
The governing truth (from OWASP LLM Top-10 2025, CaMeL, and two red-team passes):
Prompt-level defenses are probabilistic and broken by adaptive attackers.
Only ARCHITECTURAL defenses are sound: least-privilege tool gating by
provenance, allowlists, capability credentials, dual-LLM, and human gates.
When an inbound webhook/email/SMS/GitHub-comment becomes an agent's task text,
the attacker's words are the agent's instructions — and the agent may hold
Bash(curl*)/Bash(git*). This skill is how you stop that.
The one distinction that drives everything
Transport auth ≠ content trust. An HMAC-verified webhook proves the relay
is genuine; it says nothing about the author of the forwarded payload. Classify
by content source, never by the HTTP wrapper. Violating this is the highest-
severity attack ("webhook relay laundering").
Where it slots
#539 wires trigger → runAgentOnce() directly. The trust gate inserts in the
io-dispatch.startTrigger onFire callback, before requestAgentRun:
trigger fires ─► classifyTrust ─► validateAllowedToolsForTier ─► (approval?) ─► spawn
(provenance) (least privilege, fail-closed) (gated) (macaroon-scoped)
Pattern 1 — classify provenance by content source (lib/fleet/trust.ts)
export function classifyTrust(input, policy = {}) {
const kind = (input.source ?? '').trim().toLowerCase();
if (kind === 'pd') return 'OPERATOR';
if (INTERNAL_TRIGGER_KINDS.has(kind)) return 'INTERNAL';
if (EXTERNAL_TRIGGER_KINDS.has(kind)) {
const author = (input.metadata?.sender ?? '').trim().toLowerCase();
const allow = (policy.allowlistedAuthors ?? []).map(a => a.trim().toLowerCase());
if (author && allow.includes(author) && input.metadata?.consent_verified === true)
return 'AUTHENTICATED_EXTERNAL';
return 'ANONYMOUS_EXTERNAL';
}
return 'ANONYMOUS_EXTERNAL';
}
Pattern 2 — least-privilege, fail-closed tool gating
Allowlist per tier (never denylist — OWASP LLM05). Absent allowedTools means
"unrestricted" to the engine, which is the worst case for an untrusted trigger,
so it is refused:
export function validateAllowedToolsForTier(tier, spec) {
if (tier === 'OPERATOR') return { ok: true, ... };
const declared = parseAllowedTools(spec);
if (declared.size === 0)
return { ok: false, reason: 'tier requires an explicit allowedTools set', offendingTools: [] };
const offending = [...declared].filter(t => !toolAllowedForTier(tier, t)).sort();
return offending.length
? { ok: false, reason: `tools [${offending}] exceed the safe set for ${tier}`, offendingTools: offending }
: { ok: true, ... };
}
Tool-name normalization defeats unicode/case/glob smuggling — Bash(gh*) still
grants bash:
export function normalizeToolName(raw) {
const nfc = (raw ?? '').normalize('NFC').trim().toLowerCase();
const paren = nfc.indexOf('(');
return (paren >= 0 ? nfc.slice(0, paren) : nfc).trim();
}
Approval is a whitelist of trusted tiers (a typo/unknown tier fails to gated):
const TRUSTED_TIERS = new Set(['OPERATOR', 'INTERNAL']);
export const requiresApproval = (tier) => !TRUSTED_TIERS.has(tier);
Pattern 3 — SSRF guard on outbound sinks (lib/fleet/url-guard.ts)
Any sink that fetch()es an attacker-influenceable URL must call this first. It
classifies IPv4 literals in all legal forms (dotted/decimal/octal/hex), blocks
loopback/private/link-local/metadata/CGNAT, IPv6 loopback/ULA/mapped, non-http(s)
schemes, and embedded creds:
assertSafeOutboundUrl(payload.recipient, allowlist ? { allowlist } : {});
Pattern 4 — path containment on file sinks (lib/fleet/path-guard.ts)
const expanded = containPath(payload.recipient);
Attack → defense (each has a test; the test name is the evidence)
| Attack | Defense | Test |
|---|
| webhook relay laundering | classify by content, not transport | fleet-trust "defeats webhook-relay-laundering" |
| prompt-injection → tool abuse | allowedTools ⊆ safeSet(tier) | fleet-trust "defeats injection-tool-abuse" |
| "no restriction + untrusted = full" | absent tools = deny (non-trusted) | fleet-trust "defeats absent-allowedTools-means-full" |
| silent approval bypass | whitelist tiers, fail closed | fleet-trust "defeats silent-approval-bypass" |
| unicode/case/glob tool bypass | NFC+lowercase+base-capability | fleet-trust normalize block |
| SSRF (metadata/loopback/obfusc IP) | assertSafeOutboundUrl | fleet-url-guard (15 vectors) |
| path traversal / abs / symlink | containPath | fleet-path-guard |
| sensitive write in home | sensitive-segment denylist | fleet-path-guard "defeats sensitive-subpath-write" |
Anti-patterns (refuse these)
- Tiering on transport auth. "the webhook HMAC verified, so trust the body."
No — HMAC ⇒ relay genuine, not author trusted.
- Denylist of dangerous tools. You cannot enumerate every dangerous tool;
allowlist the safe ones per tier.
- Letting absent
allowedTools mean "full". For untrusted triggers that is
total compromise; require an explicit set.
- Trusting the docstring. #539's webhook output said "SSRF-guarded" and was
not. Read the code; write the regression test.
- A weaker TS fallback verifier. If the Rust macaroon kernel is absent, fail
closed — never verify with weaker semantics (caveat-omission attack).
- Bare green assurance. Every safety score shows its scan time and links to
the underlying evidence (digest-with-zoom).
- Credentialless evidence mutation. Loopback, Unix socket possession,
headers, process labels, and reusable actor credentials do not authorize an
external caller to append, terminalize, backfill, or delete canonical
transcripts. Keep evidence production daemon-owned and in-process until a
one-use broker-redeemed action boundary is actually wired; remove the old
route instead of preserving a downgrade.
- Evidence-producer laundering. A body or imported snapshot that names an
internal producer, trusted tier, automatic trigger, or spawned-agent id is
still caller data. Canonical lifecycle code stamps reserved provenance
itself; legacy rows stay explicitly untrusted instead of being upgraded by
inference.
Quality gates
Residual (sound fixes not yet shipped — see ADR-0093 §10)
Dual-LLM/CaMeL for 2nd-order injection; DNS-rebinding resolve-and-pin; O_NOFOLLOW
for path TOCTOU; per-call gating for runtime-loaded MCP tools; signed outbox.
State these; never let green over-claim.
Reference
- ADR-0093
docs/adr/0093-event-spawn-trust-substrate.md (full threat model + log)
lib/fleet/trust.ts, lib/fleet/url-guard.ts, lib/fleet/path-guard.ts
- Tests:
tests/unit/fleet-trust.test.js, tests/unit/fleet-url-guard.test.js, tests/unit/fleet-path-guard.test.js
- Builds on: ADR-0050 (Coast Guard), ADR-0053 (macaroon gate),
lib/macaroon/*