Skip to content

Enforce at the sink, not only at the read: provenance backstop on the reply #399

Description

@KenTaniguchi-R

Four open issues look independent and are not. #392 (the guard cannot see MCP I/O or a SKILL.md body load), #393 (directories cannot be labelled without asking someone), #394 (an opaque source cannot carry one sound label), #391 (Codex cannot enforce a read decision) are all consequences of enforcing at the read.

Read-side confinement has to be re-solved for every source type, every tool surface, and every runtime — and it fails at exactly the places that make the product useful.

There is one exit: the reply at listener.ts:419, where redactOutbound already runs. Our code, always executed, runtime-independent.

Design: docs/superpowers/specs/2026-08-06-sink-side-provenance-design.md
Evidence: sink-side provenance · label-creep spike · derived access inheritance

What it is

Add a backstop at the sink, keeping the read guard. Not an inversion — an earlier framing proposed replacing the read check, and the evidence rejects that: in LIO the read-side clearance is exactly what permits relaxing the output channel, and #387 independently argues the read check is the better UX.

It is the floating label / high-water mark construction, and it is the same rule FIDES ships at a different sinkmax_allowed_confidentiality on post_comment versus ours on the reply.

It is not content classification. Provenance is recorded fact; classification is the thing already rejected on measurement (Presidio at 0.07 recall).

Why nobody ships it

Seventeen systems surveyed. Every AI-DLP product, AI gateway, and agent-framework output guard decides on the text. No shipping system refuses an output based on which sources were read.

The reason is structural and it is in our favour: in all of them the answer goes to the principal who already owns the data. AgentCall is the one shape where the recipient is not the data owner — the same property that makes every industry fail-open default inapplicable here.

Slices — in order, none skippable

  • 1. Build the provenance record. It does not exist. tools.log carries every call but only the tool name and verdict (guard.ts:340-342); the resolved path reaches calls.log only when noteworthy — a denial or flag (guard.ts:344-355). An allowed read of a permitted file leaves no entry. Needs sequence numbers, a gap marker, and a decision about the record's own sensitivity — it becomes a full inventory of what the owner's agent read, in a file this codebase notes gets pasted into bug reports.
  • 2. Make observe mode able to report a gap. onFailure = ALLOW with no write (guard.ts:283) is the silent fail-open: a failed decide yields an unrecorded call and the sink concludes nothing was touched.
  • 3. Add the sink check at listener.ts:419, before redactOutbound.
  • 4. Decide and document the Bash-under-Codex hole.
  • 5. Run the adversarial label-creep test (see below) before treating the backstop as optional.

Constraints that are not negotiable

Completeness is claimable over an enumerable set, not an open-ended one — SLSA's own retreat from L4 dependency completeness to "best effort" in v1.0 is the precedent. Cryptography does not close it: tamper-evidence and completeness are different properties. The buildable shape is the Linux audit subsystem's loss counter kept outside the record, and in-toto failing when expected link metadata is missing.

Bash under Codex cannot be recorded. The guard inspects a command string; it structurally cannot know which files a command read. Either a Bash-using run cannot release a reply above the caller's clearance, or the guarantee is documented as not holding for Bash. Silence is not an option — that is the defect #390 just fixed, and it must not reappear in the runtime.

Per-value taint is unavailable. CaMeL gets it by owning a Python interpreter; we observe tool calls, not reasoning. Coarse is defensible (FIDES as shipped is coarse), and Vassena et al. show coarse and fine are equally expressive given a scope-limiting primitive — which we lack. That raises #387's constrained output from nice-to-have to structural.

Cost

Over-refusal has the field's name — label creep — measured at up to 24.5 percentage points for FIDES with enforcement on.

The mitigation is already shipped: buildPrompt names the readable sources, so the sink check is a backstop. A pilot measured 0 out-of-scope reads in 6 calls, with the one deliberately nosy question producing zero tool calls at all. That is n=6 — it bounds the rate at ~40% and no better — and the adversarial case was not tested. ReadSecBench measures up to 85% exfiltration through content-borne instructions in a permitted source. Until slice 5 runs, treat the backstop as load-bearing.

Related

#391 · #392 · #393 · #394 · #387 · #173 · #372

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions