NEP-0002
Lethal-trifecta detection with a Rule-of-Two human gate
Draft · Standards Track · opened 2026-07-19 · 1650 words
NEP-0002 · Lethal-trifecta detection with a Rule-of-Two human gate #
- NEP: 0002 (0001 is the reserved
nika: v1language surface) - Title: Make the lethal trifecta a static check with a required human gate
- Author: Thibaut Melen (SuperNovae Studio)
- Status: Draft
- Type: Standards Track
- Created: 2026-07-19
- Amended: v2.0 · 2026-07-20 (realized-flow judgment · agent whitelist refinement · the infer/agent integrity inversion) · v2.1 · 2026-07-29 (over-approximation posture written · the finding names its disjunct)
Abstract #
Add a static check that fires when a workflow's declared capability boundary
grants all three legs of the lethal trifecta · private-data access, untrusted-
content ingress, and external egress · without a human gate on the egress path.
It makes the trifecta decidable from the permits: block alone and enforces the
Rule of Two (at most two of the three unattended) as a check, not a guideline.
Motivation #
The lethal trifecta (Willison) and the Agents Rule of Two (Meta) are the two
dominant 2026 heuristics for agent safety, but today they live in prose and
human vigilance. Because a Nika workflow declares its private reads (fs.read),
its untrusted-content tools (tools: / nika:fetch) and its egress (net.http
/ workspace-escaping fs.write / exec) explicitly and up front, the
composition is a decidable property of a file a human already reviews. No other
runtime can compute it, precisely because no other runtime carries the
capability boundary as authored source. This is a security feature and the
sharpest one-line statement of the language's thesis: the trifecta becomes a
check.
Specification #
Legs, read off the declared boundary:
- ① private-data:
permits.fs.readis non-empty. (v2 refinement: a sensitivity classification over read paths; v1 treats any declared read as ①.) - ② untrusted ingress (amended v2.0): the boundary grants an ingress-capable
tool AND the task graph realizes untrusted content — a
nika:fetchbuiltin invoked (a glob likenika:*covers the grant), anmcp:*tool invoked (server-provided content is untrusted by construction; the catalogcontent_trustmark is the follow-on refinement, absent = untrusted), or anagent:whosetools:whitelist admits an ingress-capable tool (a browsing agent's final message is attacker-influenced even with a statically clean prompt). First-party LOCAL builtins (nika:read·nika:write· …) are NOT ingress: a private read is ①'s domain, a write ③'s. Untrusted content propagates throughwith:bindings,for_eachitems,tasks.*.outputreads, andon_error.recoverreads; `infer:`/`agent:` outputs carry it when their prompt sees it — the integrity direction, where the "model output is not a verbatim echo" carve-out (confidentiality-only) does not apply: a summary of an attacker's page carries the attacker's payload. Anexec:task is content-tainted when it has a tainted data ancestor OR (fs.readnon-empty ∧ a tainted task capable of writing — an fs-write builtin or another exec — ran earlier in the run order): the filesystem-mediated flow a static argv scan cannot see. (v1.1 note: the coarse any-grant reading flagged the spec's own permits-fit fixturedeep/014-permits-fit-valid—tools: [nika:read, nika:write]declares no ingress.) - ③ external egress:
permits.net.httpis non-empty, OR apermits.fs.writeglob escapes the declared workspace, ORpermits.execis enabled.
Rule (amended v2.0): if ①∧②∧③ hold, the check emits `NIKA-SEC-009` for
every egress-capable task whose effect surface the untrusted content reaches
(a realized flow · the finding names its witness source → sink), UNLESS a
blocking invoke: nika:prompt human gate (no default: arg) dominates it
(Rule of Two: with a gate, the third leg is a human decision, not an autonomous
capability). An agent: task is egress-capable iff its whitelist admits an
egress-effecting tool (per the one effect table; mcp:* fail-closed) — a
pure-compute whitelist is not egress. (SEC-008 is reserved by the AUTH plane —
canon/laws/authority.yaml FINDING-AUTH-02 allocates NIKA-AUTH-001..005 +
NIKA-SEC-008 — so the trifecta takes the next free id. One gate mitigates
every task it dominates; the gate runs once per run.)
Severity: a finding at check time (exit code 2 · CI-gating). The only escape is
the human gate task · never a suppression flag · so the enforced invariant is
structural. The check reads only the static permits: block and the DAG: no
model call, no runtime state, fully reproducible (consistent with the rest of
the check ladder).
Over-approximation posture (v2.1 · the gate is honest about its coarseness) #
The check over-approximates, on purpose, and its finding says so. The v2.0 realized-flow refinement narrowed leg ② to flows the DAG actually realizes; the leg predicates themselves stay boundary-coarse. The known false-positive classes, on the record:
- ③ · literal-url fetch —
net.httpnon-empty arms leg ③ even when every realized fetch carries a LITERAL url and no tainted value reaches url, body or headers. A literal-url GET cannot carry data OUT, so "net.httpnon-empty" is a coarse proxy for can-send. Witnessed 2026-07-29: two independent reviewers hit the gate on an honest boundary and recommended narrowing (engineace795ae9— verified, the semantics kept). - ③ · static exec —
permits.execarms leg ③ even for a fully static argv that writes nowhere external (the sandbox-refinement rejection below explains why this stays: a host-dependent verdict is a horoscope). - ③ · unrealized write-escape — an
fs.writeglob escaping the workspace arms leg ③ even when no tainted flow ever reaches a write task.
The semantics stand; the message narrows. Narrowing a security gate on
one afternoon's probe is the shape of the mistake that produced the
literal_root fail-open (fixed in engine 0e73adc28 · guarded since by the
differential proptest). The shipped posture is therefore:
- Fail-closed, no known false negative — whenever the static analysis
cannot decide (a templated url · an
mcp:*tool without effect hints · an opaque exec), the leg HOLDS. Within the declared+realized model, no real trifecta — untrusted content reaching an ungated egress-capable sink — passes the gate. - The finding names the approximation — since engine
ace795ae9the SEC-009 message names WHICH disjunct satisfied each leg and the witness task, so an author sees the coarse proxy instead of meeting unexplained ceremony. - Narrowing is an amendment, never a hotfix — each class above graduates only through its own amendment carrying conformance fixtures that prove the narrowed gate still refuses every fixture of this NEP's suite (the false positive dies · no false negative is born). First in line: per-sink realized egress, the same move v2.0 made for leg ② — trigger: field data showing the literal-url class bites adopters.
The cheap fix stays better than any narrowing: a workflow flagged on an
unused grant drops the grant (net.http emptied is a better boundary than
a smarter checker) — and the finding teaches exactly that move.
Conformance test #
New fixtures under conformance/security/, each with its expected verdict, join
the Core suite: trifecta-complete-refuse (all three legs, no gate → the
diagnostic) · trifecta-gated-pass (a gate dominates the egress path → clean) ·
three two-of-three-pass cases (drop each leg → clean) ·
gate-present-not-dominating-refuse (a gate that does not guard the egress path
→ the diagnostic). v2.0 adds: trifecta-granted-not-invoked-pass (the grant
realizes nothing → clean) · trifecta-flow-through-infer-refuse (a model
summary carries the payload) · trifecta-recovery-read-refuse ·
trifecta-exec-opacity-refuse (the file-mediated channel) ·
trifecta-parallel-clean-egress-pass · trifecta-pure-agent-pass ·
trifecta-ingress-agent-refuse · trifecta-gate-once-dominates-pass. A
Standards Track NEP does not leave Discussion without these.
Compatibility impact #
None for workflows that already grant at most two of the three legs unattended,
or that already gate egress · surfaces checked: permits.{fs,net,exec,tools},
the nika:prompt gate, the DAG dominance relation. The change is a NEW finding
on workflows that were already granting all three legs with no human in the
loop; those were the workflows the heuristic warns about.
v2.0: v2.0 findings are a strict subset of v1.1 findings — every v2.0 finding is a realized flow v1.1 already flagged. No previously-refused workflow needs changes; workflows flagged under v1.1 solely by granted-but-never-invoked ingress (or by egress tasks no untrusted content reaches) become clean. Previously-passing workflows are unaffected in both directions (a gate that dominated still dominates; a narrowed permit still drops a leg).
Migration plan #
Additive: no existing valid workflow changes shape. A workflow newly flagged
adds one invoke: nika:prompt gate on the egress path (or narrows a permit to
drop a leg). nika check teaches the fix in the finding.
Rejected alternatives #
- A denylist of dangerous tool/host combinations · rejected. The CVE record (e.g. a command denylist bypassed by obfuscation) is exactly why the boundary is computed over the GRANTED set (an allowlist) instead of enumerating bad ones. The trifecta check is an allowlist-native property.
- A learned / heuristic risk score · rejected. The point is total interpretability: the finding must be explainable by pointing at three lines of the file. A score is neither reproducible nor reviewable.
- A warning instead of an error · rejected as the default. The "compile
error" framing is the contribution; a warning is ignorable and does not enforce
the Rule of Two. (Open for Discussion: an opt-in
--warnmode for adoption.) - Read-path sensitivity as a ① refinement (v2.0) · rejected: classifying
fs.readglobs (ordinary vs. sensitive) is a false-negative factory —./data/**IS private in every threat model that matters, and a "non-sensitive" verdict tells an attacker exactly which directory to target. - Sandbox-dependent exec refinement (v2.0) · rejected: an
exec:allowlist is egress on every platform; a static check whose verdict depends on the host OS is not a check, it is a horoscope (the NEP's own reproducibility clause). - Informed gate placement (the gate must sit between source and sink) · deferred with a trigger condition: consent-before-fetch is still consent; when a runtime prompt UX renders the pending egress payload, placement becomes enforceable value.
- Private-read taint (the dual lattice) · deferred: the exec-opacity hole makes sink-level ① unsound-to-honest; boundary-level ① is the right static grain. Revisit with v2.0 field data.
- Exec stdout as an ingress source · rejected: operator-program output; arming ② on it makes every exec pipeline trifecta-armed.