Fix: consolidate path containment into one shared helper and stop over-rejecting '..'-prefixed artifact filenames - #658
Open
AmaadMartin wants to merge 3 commits into
Open
Conversation
added 3 commits
August 4, 2026 14:49
isInsideDir was module-private, so the artifacts layer could not reuse it and grew its own containment checks instead. Export it, resolve both arguments internally so callers need not pre-resolve, and document it as a lexical name check rather than a filesystem sandbox. materializeFiles is unaffected: it already passed resolved absolute paths and path.resolve is idempotent on those. Adds direct unit tests for the predicate, including the separator-boundary property that a plain startsWith prefix match would lose.
…h two dots
getArtifactDir rejected a filename whenever path.relative() returned a
string starting with '..', which is a bare prefix test with no separator
boundary. A legitimate artifact named '..foo.txt' resolves strictly inside
the scope root but was refused, while the same name inside a subdirectory
('sub/..evil.txt') was accepted -- the outcome depended only on where the
prefix happened to land.
Both containment checks in this file now delegate to the shared
isInsideDir predicate, so the rejection set is exactly 'does not resolve
inside the scope root'. '..', '../x' and absolute filenames are still
rejected with the unchanged message. This matches adk-python, which uses
Path.relative_to() and accepts '..foo.txt' and '...'.
assertInsideRoot keeps its signature, export and message verbatim; it is
now the thin artifacts-layer wrapper that owns the feature-specific error
text.
isInsideDir resolves both arguments itself, so materializeFiles no longer needs to pre-resolve its base directory; path.resolve is idempotent, so passing dir straight through is behaviour-identical. Also trims the predicate's docstring to the facts that affect callers. The previous wording told readers not to use it as a security boundary, which this file's own caller contradicts -- assertInsideRoot guards the artifact storage root and is exactly that. State the limitation instead of prescribing the conclusion.
This was referenced Aug 5, 2026
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.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A — no public issue is open for this.
Problem:
core/srchad three separate implementations of "is this resolved path inside that directory?", and one of them was wrong.core/src/utils/file_utils.ts—isInsideDir(), module-private, correct and separator-aware.core/src/artifacts/file_artifact_service.ts—assertInsideRoot(), the same correct predicate spelled differently, but with[FileArtifactService]baked into its error string.core/src/artifacts/file_artifact_service.ts— the inline check ingetArtifactDir(), which was a defect:relative.startsWith('..')is a bare string-prefix test with no path-separator boundary.path.relative()returns'..foo.txt'for an artifact literally named..foo.txt, so the file was rejected even though it resolves strictly inside the scope root. This is an over-rejection (a false positive), not a security hole. It was also internally inconsistent:sub/..evil.txtwas accepted while..evil.txtat the top level was rejected, purely because of where the prefix landed.Reproduction on
main:Solution: export the one correct predicate from the shared utils module and delegate both artifact-layer call sites to it, which fixes the defect as a side effect of the consolidation.
core/src/utils/file_utils.ts—isInsideDirbecomes exported, resolves both arguments internally, and its doc comment is now caller-agnostic. It records honestly that the comparison is lexical: case-sensitive on every platform, and silent about symlinks or a TOCTOU race with the following filesystem call. Because the helper now resolves for its callers,materializeFiles'resolvedBaseDirpre-resolve became redundant and was dropped;path.resolveis idempotent, so passingdirstraight through is behaviour-identical.core/src/artifacts/file_artifact_service.ts—assertInsideRootis now the thin artifacts-layer wrapper that owns the feature-specific[FileArtifactService]error text; its signature, export, and message are byte-identical (visible in the diff: the message line is unchanged context). The inlinepath.relativearithmetic ingetArtifactDiris replaced by anisInsideDircall.core/src/artifacts/file_artifact_service.tsnow contains zero inline containment arithmetic, and exactly one containment predicate exists incore/src.No traversal rejection is lost. Every path that resolves outside
basehas a relative form starting with..(or absolute, e.g. a different Windows drive), so the new rejection set is a strict subset of the old one, and every path newly accepted provably resolves insidebase. The change can only turn false positives into accepts; it cannot turn a reject into an escape...,../x,../../secret.txtand absolute filenames are still rejected with the unchanged messageArtifact filename <name> escapes storage directory.Behaviour change (one, intended) and its adk-python parity justification. A
saveArtifactcall with a filename whose first segment merely starts with..(e.g...foo.txt,...,..foo/bar.txt) previously threw and now succeeds. This aligns adk-js with adk-python, which is the parity gold standard for artifact-key semantics:src/google/adk/artifacts/file_artifact_service.py_resolve_scoped_artifact_path()usescandidate.relative_to(scope_root_resolved)and raises only on a genuineValueError, so Python accepts..foo.txtand...and rejects..and../x— exactly the corrected behaviour here. No stored data is reinterpreted (these names could not previously be written), so there is no migration and no read-path compatibility concern.The shared helper is deliberately NOT added to the public exports.
isInsideDiris exported at module scope only. It is not added tocore/src/index.tsorcore/src/common.ts, because relocating/exposing an internal helper does not make it public API.assertInsideRoot,assertSafeSegment,getUserRootandgetSessionArtifactsDirremain module-level exports consumed only by the test file, exactly as before.Collision check (required before implementing):
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000returned 556 open PRs; I filtered for path/containment/artifact-adjacent titles and diffed the candidates withgh pr diff <n> --name-only. Findings:materializeFilessibling-prefix hole and touchcore/src/utils/file_utils.ts. Neither collides: that fix already landed upstream as868ca1f3(Fix: gate the Gemini Live e2e suite on a real Vertex project instead of on CI #603), which is the prerequisite this PR builds on, so both are superseded bymainrather than by this PR. Neither touches the artifacts layer or the..-prefix defect.file_utils.tsbut only the skill-script output-directory plumbing, not the containment predicate.file_artifact_service.tsbut none touchgetArtifactDir's containment check orassertInsideRoot.No live PR lands this change, and nothing here needs to be stacked.
Note on the base branch. This branch was cut from a fork
mainthat was 5 commits behindgoogle/adk-jsmainand therefore did not contain the prerequisite868ca1f3(#603). Rather than re-implementisInsideDir(which would have duplicated a merged upstream commit), I fast-forwarded the fork'smainto upstreammainand rebased onto it. The diff below is only this change.Review round 1 (complexity audit). Two findings, both accepted and fixed in
fe1b438:materializeFiles'const resolvedBaseDir = path.resolve(dir)became dead weight once the helper resolved internally — removed, and its two uses now passdirdirectly.assertInsideRootguards the artifact storage root and is exactly that boundary). The six lines of caveat prose are now two that state the limitation rather than prescribing a conclusion.The reviewer's non-blocking note — that
assertInsideRoot's parameter is still namedresolvedPath— is deliberately not actioned: keeping that signature byte-identical is a requirement of this change, and a parameter rename is not worth spending it.After both fixes I re-ran mutation M1 to confirm the simplified call sites still route through the shared helper: dropping the separator arm still fails both the new predicate test and the pre-existing #603
materializeFilessibling test.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Commands run on the exact pushed commit:
New tests added (no existing test was edited, deleted, skipped, or weakened):
core/test/utils/file_utils_test.ts, newdescribe('isInsideDir')— accepts the base itself (equality arm); accepts a nested path (separator arm); rejects a sibling sharing the base's prefix (/tmp/agentvs/tmp/agent-evil/x); rejects the parent; accepts<base>/..foo.txt; resolves unnormalized arguments. All usepath.join/path.sepso they hold on Windows CI.core/test/artifacts/file_artifact_service_test.ts, newits indescribe('path security')—..foo.txtsaves, lands at<sessionArtifacts>/..foo.txt/versions/0, and round-trips throughloadArtifact; a bare..filename still rejects withescapes storage directory; a.filename still lands under theartifactsentinel directory; andassertInsideRootrejects a sibling sharing a name prefix with root.Coverage. Every line and branch added or changed by this PR is covered — measured with
--coverage.provider=v8and read out ofcoverage-final.json, the uncovered-line sets for both touched source files contain none of the changed lines. Both arms of the||inisInsideDirand both sides of all three guards in the touched artifact code execute (non-zero branch counters). The file-level percentages are below the repo's global threshold, but every remaining gap is pre-existing and outside this diff (materializeFiles' second defence-in-depth guard,guessMimeType, and unrelatedfile_artifact_service.tserror paths); I did not touch them, since widening this diff to chase a file-level number is exactly the unrelated churn the contribution guide asks us not to bundle.Proof the tests can fail (each mutation applied to the fixed code, tests run, then reverted):
isInsideDir(return resolvedTarget.startsWith(resolvedBase);). Two failures: the newrejects a sibling whose name shares the base directory prefix(AssertionError: expected true to be false) and the pre-existing Fix: gate the Gemini Live e2e suite on a real Vertex project instead of on CI #603 testshould throw an error if file attempts to escape into a sibling directory sharing a name prefix(AssertionError: promise resolved "[ { …(4) } ]" instead of rejecting) — confirming the exported helper really is the onematerializeFilesuses.getArtifactDir(relative.startsWith('..') || path.isAbsolute(relative)).accepts a filename that starts with two dotsfails with exactly the reported bug:Error: Artifact filename ..foo.txt escapes storage directory.artifactDir === resolvedScopeRootearly return.stores a '.' filename under the artifact sentinel directoryfails.if (isInsideDir(...)) throw). The three pre-existingassertInsideRoottests fail along with 20+ others, confirming the wrapper is still wired into every path.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Run
npm run build, then against the built package constructnew FileArtifactService(await fs.mkdtemp(...))and:report.pdf,..foo.txt,nested/dir/report.txtanduser:notes.txt. All four round-trip and all four appear inlistArtifactKeys;..foo.txtcomes back under its original name...foo.txtis stored inside the scope root at<root>/users/<uid>/sessions/<sid>/artifacts/..foo.txt/versions/0.saveArtifactwithfilename: '../../secret.txt'still rejects withescapes storage directory, and thatfs.accesson the parent-directory targets rejects — nothing was written outside the temp root.materializeFilesstill writesout/result.txtinside its directory and still rejects both../escape.txtand a sibling-prefix escape, with no sibling directory created.I ran exactly this as a scratch script against the built package; all checks passed.
Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.
Note:
npm run ts:checkis red on this branch, but it is equally red onmain— 281 pre-existingTS2345errors aboutBASE_AGENT_SIGNATURE_SYMBOLacross the test tree. I diffed the error sets before and after my change and they are identical, so nothing here contributes to it and I did not attempt to fix unrelated breakage.