{"slug": "empty-is-not-clean-five-fail-open-bugs-in-an-ai-agent", "title": "Empty Is Not Clean: Five Fail-Open Bugs in an AI Agent", "summary": "An engineer discovered five fail-open bugs in the policy engine of an MCP-mediated Slack AI agent, claude-code-slack-channel. The bugs allowed scoped deny rules to be silently bypassed when required input data was absent, defaulting to permissive behavior instead of failing closed. The fixes ensure that missing data triggers escalation to a human and that empty audit logs fail verification rather than passing as clean.", "body_md": "A policy said `deny`\n\nanything under `/etc`\n\n. A Bash call read `/etc/shadow`\n\nand came back `{allow, rule: 'trust-bash'}`\n\n. No human in the loop. The deny rule was still there, still first in the list, still scoped exactly the way an operator would write it. It just never fired.\n\nTwo cold-contributor reviewers reproduced it independently on this policy:\n\n``` js\nconst policy = [\n  { effect: 'deny', tool: 'Bash', pathPrefix: '/etc' },\n  { effect: 'auto_approve', tool: 'Bash', rule: 'trust-bash' },\n];\n// Bash → /etc/shadow  ⇒  { allow: true, rule: 'trust-bash' }\n```\n\nThe gate is the policy engine inside `claude-code-slack-channel`\n\n— an MCP-mediated Slack agent whose whole reason to exist is that it can *prove* what a tool did: a signed, offline-verifiable audit journal plus a per-tool-call policy engine. If the deny rule can be walked past, none of that matters. This is the story of that bug, and the four other bugs that turned out to be the same bug wearing different clothes.\n\nEvery one of these had the same shape — the same fail-open bug. When the data a check needs to make its decision was *absent*, the check defaulted to the permissive answer — allow, pass, clean, covers-all — instead of the safe one. A missing argument. An empty log. A property that isn't there. A chain link that got sheared off. A scope field left unset.\n\nThe principle that closes all five: **when a check has nothing to check, it must fail closed.** Default-deny, not default-allow. Absence is not a green light. Absence is the alarm.\n\nThe engine's `evaluate()`\n\ntakes a `ToolCall`\n\nand walks the rules. `matchApplies()`\n\ndecides whether a rule's scope — `pathPrefix`\n\n, `argEquals`\n\n— matches the call. The exploit lived one level up, in the *only* production caller.\n\nThe MCP `permission_request`\n\nhandler in `server.ts`\n\nnever receives structured tool arguments. It gets an `input_preview`\n\nstring for display and nothing else. So it built the call like this:\n\n``` js\n// server.ts — the sole production caller\nconst call: ToolCall = {\n  tool: req.tool_name,\n  input: {},                      // ← no structured args available here\n};\nconst decision = policy.evaluate(call);\n```\n\nA `deny`\n\nscoped by `pathPrefix: '/etc'`\n\nasks: does `input.path`\n\nstart with `/etc`\n\n? Against `input: {}`\n\nthe answer is no. `matchApplies()`\n\nreturns no-match, the deny is *silently skipped*, and the next broad `auto_approve`\n\nfor Bash swallows the call. The scoped rule the operator wrote was invisible precisely because the engine couldn't see what it was scoped on.\n\nThe fix does not try to reconstruct the missing args. It can't — they aren't there. Instead, when the gate cannot see the input a scoped `deny`\n\n/`require_approval`\n\nrule needs, it stops trusting the fall-through:\n\n```\n// If a scoped deny/require rule COULD apply but we lack the input\n// to know, escalate to a human instead of falling through to auto_approve.\nif (scopedRuleNeedsInput && inputIsUnavailable(call)) {\n  return { allow: false, escalate: 'require_approval', reason: 'input-unavailable' };\n}\n```\n\nNot knowing whether a deny applies is treated as \"it might\" — and \"it might deny\" routes to a human, never to auto-approve.\n\nThe journal's verifier walks the event chain and reports broken links. Zero events means zero broken links. So an *empty* `audit.log`\n\nverified clean. An attacker who truncates the entire log to nothing gets a green check — the strongest possible signal from the weakest possible file.\n\nThe fix is a floor. `--min-events N`\n\nmakes verification *fail* when the log holds fewer events than expected:\n\n``` bash\n$ verify audit.log --min-events 500\nFAIL: expected ≥500 events, found 0\n```\n\nZero is no longer \"clean.\" Zero is \"someone deleted the evidence.\" The flag parser is deliberately fail-closed too: a malformed value throws rather than silently disabling the floor, because a floor you can turn off by fat-fingering it is not a floor. (A later fix routed a space-form negative like `--v2-floor-seq -5`\n\nto the value validator so it throws the accurate \"must be a non-negative integer\" instead of a misleading \"missing value.\")\n\nChannel-policy reads (`access.channels[id]`\n\n) were scattered across seven call sites with inconsistent guards. A bare index read inherits `Object.prototype`\n\nmembers: ask for the channel `'constructor'`\n\nor `'toString'`\n\nand you get back a truthy function that never belonged to anyone's config — enough to slip past a truthiness-based gate.\n\nNot exploitable today; Slack channel ids match `[CD][A-Z0-9]+`\n\nand never take prototype-key forms. But it's a footgun class, and seven inconsistent guards is seven chances for one to drift wrong. The fix routes every read through one chokepoint that returns a policy only for an *own* property:\n\n```\nexport function getChannelPolicy(access: Access, id: string): ChannelPolicy | undefined {\n  return Object.hasOwn(access.channels, id) ? access.channels[id] : undefined;\n}\n```\n\nOne helper, seven callers, no drift. And this is where a fail-closed refactor nearly shipped a fail-open crash.\n\n`access.channels`\n\nis *typed* as always-present. It isn't. An `Access`\n\nloaded from disk can arrive without it, and `Object.hasOwn(undefined, id)`\n\n**throws**. A Gemini audit flagged it HIGH. The old scattered code used `access.channels?.[id]`\n\n— optional chaining that safely returns `undefined`\n\nwhen the object is missing. The refactor, in the name of hardening, would have converted a *defined-safe* absence into a thrown exception on every channel read. Whether an unhandled throw at the gate ends up allowing or denying depends on which handler catches it — and \"depends on the handler\" is exactly the uncertainty a security gate must not have. Defined behavior became undefined behavior at the one point that has to stay defined.\n\nThe guard belongs *inside* the one helper, so it can't be forgotten at any of the seven sites:\n\n```\nexport function getChannelPolicy(access: Access, id: string): ChannelPolicy | undefined {\n  const channels = access.channels;\n  return channels && Object.hasOwn(channels, id) ? channels[id] : undefined;\n}\n```\n\nA fail-closed accessor must fail closed. It must not throw. Same discipline, one recursion deeper — the fix for a fail-open bug has to itself fail closed on *its* missing input.\n\nHardening each of the seven reads in place would have worked on the day it was written. It also would have created seven independent copies of a subtle guard, and the eighth read — the one added six months from now by someone who copied the *simplest* nearby example — would skip it. A chokepoint is not just less code. It's the only version where the correct behavior is the *default* behavior. You can't call the channel-policy lookup wrong because there's only one way to call it.\n\nThe genesis-seq pin from an earlier hardening pass was documented as defeating head-truncation. It does — but only on *signed*, key-verified (v2) chains. On an unsigned v1 chain the links are bare keyless SHA-256:\n\n```\nhash = sha256(prevHash + canonicalJson(event))\n```\n\nA writer-capable attacker shears the head off, renumbers the survivors so the new first event is `seq=1`\n\n, and recomputes every hash forward. No secret is required — the formula is public and the file contains everything it needs. The sheared file verifies clean because it *is* internally consistent. It's a smaller, valid-looking journal that never happened.\n\nThe close is two optional verification anchors:\n\n```\nverify(log, {\n  pinnedGenesisHash: 'a1b2…',  // event[0].prevHash MUST equal this operator-held value\n  v2FloorSeq: 1,               // every event at seq ≥ N MUST be v2/signed\n});\n```\n\n`pinnedGenesisHash`\n\nis the key move — and it's a different mechanism from the older genesis-*seq* pin. That earlier pin fixed the sequence number, which a renumbering attacker just recomputes. `pinnedGenesisHash`\n\nfixes the first event's `prevHash`\n\nagainst a value the operator captured *outside* the file. When an attacker renumbers the chain, the new first event's `prevHash`\n\nno longer matches the pinned value — and there's no way to forge a match, because the attacker can't reach into the operator's out-of-band record to change it. That's also why it holds on an unsigned chain: the protection comes from the anchor being external, not from a key.\n\nThe obvious instinct is to make the file carry its own anchor — stamp the \"true\" genesis hash into a header, or sign a manifest of the chain's own metadata. That fails for a reason worth stating plainly: **the only forgery a file can reproduce from itself is one an in-file anchor also validates.** If the attacker can rewrite the chain, the attacker can rewrite any anchor stored alongside it. A self-attested anchor gets forged in the same edit as everything it was supposed to protect. A *signed* in-file manifest is only better if the signing key lives somewhere the attacker can't reach — at which point the key is the out-of-band secret, and you've relocated the external-fact requirement, not removed it. The anchor has to be a fact the attacker *cannot* reproduce — and on an unsigned chain the only such fact is one that was never in the file to begin with.\n\nThe tradeoff is honest and belongs in the docs: the anchors are only as strong as the operator's out-of-band capture. A key rotation that forgets to record the new head hash loses the cross-file link. Anchors are opt-in — absent, verification is byte-identical to before. You buy real protection with real operational discipline, and the system says so out loud rather than pretending the file can bootstrap its own trust.\n\n`matchSubsetOrEqual()`\n\n— the subset check behind both `detectShadowing()`\n\nand `checkMonotonicity()`\n\n— compared tool, channel, actor, `pathPrefix`\n\n, and `argEquals`\n\n. It never compared `thread_ts`\n\n. So a rule scoped to one Slack thread was treated as a rule covering *every* thread.\n\n```\n// before: thread_ts absent from the comparison ⇒ \"matches all threads\"\nfunction matchSubsetOrEqual(a: Rule, b: Rule): boolean {\n  return sameTool(a, b) && sameChannel(a, b)\n      && sameActor(a, b) && subsetPath(a, b) && subsetArgs(a, b);\n  // thread_ts never checked\n}\n```\n\nThe absent field defaulted to \"matches all\" when the safe default is \"matches none.\" A narrowly-scoped rule got read as maximally broad — the same failure shape as the deny that ignored `/etc`\n\n, one layer up in the analysis code instead of the enforcement path. The fix honors `thread_ts`\n\nin the subset check, and an unset scope now correctly covers nothing rather than everything.\n\nLine them up and it's one bug five times. Empty args → skip the deny. Empty log → verified clean. Missing property → truthy prototype value (plus a thrown exception the fix itself almost added). Sheared chain → recomputes valid. Absent thread scope → covers all threads. Every one defaults to *allow / pass / clean / covers-all* when the discriminating data is missing. The permissive branch is the one that runs when there's nothing to run against.\n\nThe fix never changes. Make absence fail closed. A gate that can't see its input escalates to a human. A verifier with too few events fails. An accessor with no property returns `undefined`\n\nand doesn't throw. A chain proves itself against a fact it can't forge. A scope with no value covers nothing. Same principle, five layers. The one honest exception is the sheared chain: when the discriminating fact can only live *outside* the file, fail-closed becomes something the operator opts into by capturing that fact out-of-band — and the system says so plainly instead of pretending a file can bootstrap its own trust.\n\nNone of these surfaced from writing new code. They surfaced from adversarial review pointed at the load-bearing core: a hardening review of the signed journal, two cold-contributor reviewers who reproduced the `/etc/shadow`\n\nwalk-past, a Gemini audit that caught the crash-on-absent-channels the *fix* almost introduced. Every one was proven revert-to-red — revert the guard and a test goes red, so the guard is doing work no other line does. The suite sits at ~1304 tests, ~96.6% line / 98.2% function coverage, nine gates green.\n\n| Disguise | What was absent | Permissive default | Fail-closed fix |\n|---|---|---|---|\n| Empty args | Structured tool input to scope on | Deny rule skipped; `auto_approve` wins |\nEscalate to a human when a scoped rule lacks its input |\n| Wiped audit log | Any events in the file | Empty log verifies clean | Fail below a `--min-events` floor |\n| Missing property | An own property on the config | Prototype key returns a truthy function |\n`Object.hasOwn` chokepoint; return `undefined` , never throw |\n| Sheared chain | An out-of-band genesis fact | Renumbered chain recomputes valid | Pin the genesis hash externally; a mismatch fails |\n| Unset thread scope |\n`thread_ts` in the subset check |\nRule covers every thread | Compare `thread_ts` ; no scope covers none |\n\nAlongside the fail-open work, a set of transport-contract fixes in the same spirit: start the MCP stdio transport *before* Socket Mode so the protocol handshake never blocks on Slack; route all structured logs to stderr because stdout *is* the MCP channel and one stray log line corrupts the protocol; and extract the socket-start and manifest-identity guards into testable seams. A decision-record spike (ADR-004) settled a build-vs-keep question: this system's job is to *prove* what an agent did — offline, self-hosted, deterministic per tool call — so the signed journal is what matters, not prettier approval cards.\n\nThat's the throughline for all of it. Guardrails for an AI agent are not features you add on top — they're the property that lets the agent hold a tool at all. An agent you can trust with `Bash`\n\nis one whose deny rule fires even when the caller forgot to pass the arguments. Fail-closed discipline is what earns the trust; empty is not clean.", "url": "https://wpnews.pro/news/empty-is-not-clean-five-fail-open-bugs-in-an-ai-agent", "canonical_source": "https://dev.to/jeremy_longshore/empty-is-not-clean-five-fail-open-bugs-in-an-ai-agent-5fd8", "published_at": "2026-07-15 13:07:49+00:00", "updated_at": "2026-07-15 13:29:13.898699+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-ethics", "developer-tools"], "entities": ["claude-code-slack-channel", "MCP"], "alternates": {"html": "https://wpnews.pro/news/empty-is-not-clean-five-fail-open-bugs-in-an-ai-agent", "markdown": "https://wpnews.pro/news/empty-is-not-clean-five-fail-open-bugs-in-an-ai-agent.md", "text": "https://wpnews.pro/news/empty-is-not-clean-five-fail-open-bugs-in-an-ai-agent.txt", "jsonld": "https://wpnews.pro/news/empty-is-not-clean-five-fail-open-bugs-in-an-ai-agent.jsonld"}}