fix(task-graph): route RunPrivateCacheRepo by-ref through *ForRun to prevent cross-run leak (follow-up to #611)#612
Open
sroussey wants to merge 3 commits into
Conversation
…itory Adds three optional runId-scoped by-ref methods to the base interface: getOutputByRefForRun, getOutputStreamByRefForRun, deleteOutputByRefForRun. Each carries a JSDoc contract mirroring the base by-ref idempotency: a ref not produced by a *ForRun writer for the given runId resolves to undefined (readers) or is a no-op (delete). Interface only, no implementations touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nPCEAuvb2Np5zqB6JLRna
Adds getOutputByRefForRun / getOutputStreamByRefForRun / deleteOutputByRefForRun to FsFolderTaskOutputRepository. Each parses the ref through REF_PATTERN and requires the captured blob name to start with sanitize(runScopePrefix(runId)); otherwise the reader returns undefined and the delete is a no-op. Shared sidecar-reading logic factored into a small private helper (blobPathInRunScope). The RunPrivateCacheRepo wrapper still forwards through the unscoped by-ref methods; the follow-up commit rewires it to close the cross-run leak. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nPCEAuvb2Np5zqB6JLRna
…prevent cross-run leak RunPrivateCacheRepo previously forwarded getOutputByRef / getOutputStreamByRef / deleteOutputByRef straight to the backing's runId-agnostic methods, so a CacheRef minted by one run's wrapper resolved (and could be deleted) through another run's wrapper on the same backing — the private tier's whole point is that runs cannot see each other, and this made them observable and mutable across the boundary. Rewire the wrapper to route by-ref reads and deletes exclusively through the new *ForRun variants, threading its own runId. A foreign ref (another run's blob, an unscoped deterministic write, malformed / foreign-scheme) resolves to undefined at the backing and delete becomes a no-op — matching the base contract's cache-miss idempotency. The unscoped variants are no longer touched by the wrapper. New tests cover cross-run read/delete rejection, positive same-run regression, malformed / foreign-scheme rejection, and a strengthened clearRun() assertion that a run's ref does not resolve through another run's wrapper after cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nPCEAuvb2Np5zqB6JLRna
Coverage Report
File CoverageNo changed files found. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
RunPrivateCacheRepowraps aTaskOutputRepositoryso entries are namespaced byrunId, but its by-ref forwarders passedCacheRefs straight to the backing's runId-agnosticgetOutputByRef/getOutputStreamByRef/deleteOutputByRef. Any wrapper on the same backing could therefore resolve — and delete — another run's blob just by receiving itsCacheRef. The private tier's whole point is per-run isolation; this made runs observable and mutable across the boundary.The fix pushes enforcement into the backing: three optional
*ForRunby-ref methods on the base interface, and anFsFolderimplementation that rejects any ref whose blob name does not start with the sanitizedrunScopePrefix(runId). The wrapper now routes exclusively through those variants, threading its ownrunId; the unscoped surface is never called from the wrapper again.Failure scenario
Two
RunPrivateCacheRepowrappers (run-A,run-B) share oneFsFolderTaskOutputRepository.run-Bwrites a streamed port and getsrefB. Before this change,wrapperA.getOutputStreamByRef(refB)returnedrun-B's bytes,wrapperA.getOutputByRef(refB)returnedrun-B's blob, andwrapperA.deleteOutputByRef(refB)unlinked it — a cross-run read/write channel via any leaked ref.Approach
*ForRunby-ref methods toTaskOutputRepository:getOutputByRefForRun,getOutputStreamByRefForRun,deleteOutputByRefForRun. Each carries a JSDoc contract that a foreign ref MUST resolve toundefined(readers) or be a no-op (delete), matching the base by-ref idempotency contract.FsFolderTaskOutputRepository. A sharedblobPathInRunScope(ref, runId)helper parses the ref through the existingREF_PATTERNand requires the captured blob name to start withsanitize(runScopePrefix(runId)). On a mismatch the readers returnundefinedand delete never touches disk.RunPrivateCacheRepo's constructor by-ref forwarders to route only through the*ForRunvariants, gated on the existingcanStreamForRun || canStreamPortForRuncapability. Delete the forwards tobacking.getOutputByRef/.getOutputStreamByRef/.deleteOutputByRefentirely. Backings without the*ForRunref methods leave the wrapper's methodundefined(private tier degrades gracefully to accumulation for reads).Test coverage
New
describe("run-scope enforcement", ...)block inRunPrivateFsFolderStream.test.ts:wrapperA.getOutputStreamByRef(refB)andwrapperA.getOutputByRef(refB)both resolve toundefined.wrapperA.deleteOutputByRef(refB)resolves without throwing;wrapperBcan still read the blob and the blob count is unchanged. Case carries a comment justifying the silent no-op: matches the base contract's best-effort idempotency; alarms belong in operator observability, not the security-invariant path.wrapperAreads and deletes its ownrefAthrough all three methods; delete prunes exactly one blob.makeCacheRef({ $ref: "fsfolder://blobs/foreign.bin" })(missing run-scope prefix) andmakeCacheRef({ $ref: "other://something" })are both uniformly rejected: readersundefined, delete silent no-op.Strengthened
clearRun()assertion: afterrepoA.clearRun(),repoB.getOutputStreamByRef(refA)also resolves toundefined.Sequencing
Three commits, in order:
feat(task-graph): add optional *ForRun ref methods to TaskOutputRepository— interface-only. No behavior change; every existing implementer compiles unchanged.feat(task-graph): implement runId-scoped ref readers on FsFolder— the three*ForRunmethods with the sanitized-prefix check. Existing tests still pass; wrapper still uses the unscoped path so the fix is not yet observable.fix(task-graph): route RunPrivateCacheRepo by-ref through *ForRun to prevent cross-run leak— wrapper rewire + drop the unscoped forwarders + four new tests + strengthenedclearRunassertion. The vulnerability closes here.Verification
bun run build:types— clean.bun scripts/test.ts graph vitest— 1033 passed / 1033 total (up from 1029 pre-change; four new tests added, no existing test weakened).grep -rn 'fsfolder://' packages --include='*.ts'— the only hand-constructedfsfolder://refs outside the sanctioned writer paths inFsFolderTaskOutputRepository.tsare in test files (pre-existing edge-case tests plus the new negative-edge test added here).Base is PR #611's head (
claude/task-graph-streaming-phase3-yg5msj), notmain, because #611 is not yet merged.Generated by Claude Code