Skip to content

Promoted config cache outlives its leaf in the shared pool sandbox, so a configRef-less leaf can answer from it #216

Description

@pdettori

Not a security report — routing note first

Per SECURITY.md I checked whether this belonged in a private
advisory. I do not think it does, and I want that reasoning visible rather than assumed:

  • P2 §9 makes intra-pod cross-leaf isolation an explicit non-goal: "A compromised leaf could
    read sibling worktrees and the shared object store. This is acceptable… one trust domain in P2,"
    with Kata isolation in P3 (P3: Sandbox sharing-ratio experiments (CAP/N tuning) #48) as the resolution.
  • ADR-0031 already records that "memory files reaching the sandbox means they land on a shared
    pool's volume."

So a leaf reading another leaf's files is known and accepted. What I am filing is narrower and, I
think, not yet recorded: the promoted config cache outlives the leaf that created it, and a
later leaf with no configRef at all can answer from it. If a maintainer reads the threat model
differently, say so and I will move it to an advisory.

What happens

configOverlay materialises the bundle into a digest-keyed cache in the shared pool sandbox:

/workspace/.sh-config/sha256-<digest>/
├── context/agents/0-CLAUDE.md
├── context/MEMORY.md
├── memory/<one-fact-per-file>.md
├── prompts/, prompt/
└── skills/<name>/… (including references/ subtrees)

Nothing removes it. buildCleanupScript (harness/src/converge.ts:44) removes only the leaf
worktree (/workspace/leaves/<run>); grep -rn "sh-config" harness/src finds no rm of the shared
path. That is deliberate — the cache exists to be reused, and config-overlay.ts notes the per-leaf
link is kept "under the leaf workspace so cleanupWorkspace remains the only teardown path" — but
the consequence is that promoted CLAUDE.md and memory/ persist on the pool volume indefinitely,
world-readable (dr-xr-xr-x), after every leaf that used them has finished.

Why it is worth tracking anyway: it makes goal 6 false end to end

Spec §2 goal 6 is "Absent a promoted bundle, harness behavior is unchanged," and
harness/src/run-turn.ts:382-384 deliberately makes that assertable — with no promoted bundle
the loader options have "EXACTLY the four base keys, which is what makes an absent configRef
byte-identical to today rather than merely intended to be."

That guarantee holds in the harness process. It does not hold observably, because the agent's
tools execute in the sandbox, and the sandbox still has the previous run's bundle on disk. A
configRef-less leaf can explore the filesystem, find another workflow's skill and memory, and use
them.

Evidence

Measured while building the promoted-workflow demo (#215), not hypothesised. The demo dispatches one
prompt twice — once bare, once promoted — as a control. Second run of the demo, bare arm, on a
sandbox where the previous run's promoted leaf had run:

Perfect! Now I can write the ship note following the house rules. Here's the ship note:

SHIP NOTE · auth-idle-reaper-v2
WHAT:   Fixed auth sessions dropping after 30 seconds of inactivity…
TICKET: KAG-4471
RISK:   low — fix ships behind auth.idle_reaper_v2 flag and is reversible without redeploy
TOKEN:  SHIPNOTE-7F3A-SANDBOX-OK

That request carried no configRef. "The house rules" is language from a promoted CLAUDE.md;
KAG-4471 exists only in a promoted memory file; the token exists only in a promoted skill's
references/ subdirectory. The first run's bare arm, before any promoted leaf had run on that
sandbox, answered "The workspace appears to be empty. Could you provide me with…" — so the delta is
the cache, not model variance.

Two practical consequences beyond confidentiality:

  1. A bare leaf's behaviour becomes a function of what unrelated leaves promoted earlier, which is
    a reproducibility problem for the replay contract, not only a privacy one.
  2. Any A/B that uses a bare run as a control is silently invalid on a warm pool. Mine looked
    perfectly plausible while proving nothing; only an explicit "the control must NOT know this"
    assertion caught it.

Reproduce

# 1. promote anything and dispatch it once
kubectl exec -n default sandbox-0 -- find /workspace/.sh-config -type f
# ... bundle contents, still present after the leaf finished

# 2. dispatch the same prompt with NO configRef and let the agent look around

Or run make demo-promoted-workflow twice with the purge in demo-promoted-workflow.sh disabled
(it exists precisely to work around this).

Options, not a prescription

  • Tear the digest cache down when the last lease referencing it is released (keeps reuse within a
    run, drops it between).
  • Key the cache path by tenant/lease as well as digest, so it cannot be found by an unrelated leaf.
  • TTL or LRU it, so persistence is bounded rather than indefinite.
  • Or decide this is simply P3's (P3: Sandbox sharing-ratio experiments (CAP/N tuning) #48) to fix, and record it explicitly in ADR-0031 and P2 §9 —
    ADR-0031 currently speaks to write-protection (chmod -R a-w), not visibility or lifetime, so a
    reader can reasonably finish it believing the shared-volume exposure ends with the leaf.

Whichever way it goes, the demo's purge step and the note in its "Notes and limits" can be dropped
once the cache no longer outlives its leaf.

Refs: #215 (where this was measured), #48 (P3 Kata isolation), ADR-0030, ADR-0031, P2 §9, spec §2
goal 6.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions