Fix: contain skill script output files in a declared output directory instead of process.cwd() - #556
Open
AmaadMartin wants to merge 5 commits into
Open
Fix: contain skill script output files in a declared output directory instead of process.cwd()#556AmaadMartin wants to merge 5 commits into
AmaadMartin wants to merge 5 commits into
Conversation
added 5 commits
August 2, 2026 18:52
…rocess.cwd() materializeFiles() defaulted its destination to process.cwd(), and both skill script tools called it with no directory, so script-chosen filenames were written into whichever directory the host process was launched from. Make dir a required parameter and route both tools through a new materializeScriptOutputs helper that writes under SkillToolset.outputDir when the application declares one, and otherwise into a fresh per-execution mkdtemp directory it reports back on the tool response. Also replace the raw startsWith() containment test with a path-segment comparison: with dir=/var/app/out, a name of ../outX/leak.txt resolved to /var/app/outX/leak.txt and passed the prefix check. That was latent while dir was the whole working directory and becomes load-bearing once outputDir names a small declared directory. Deletes the tracked root output.txt, which is an artifact this defect produced.
…casting The fixture hoisted out of the test body carried an 'as File' cast that tsc rejects (TS2352) because the string literal does not narrow to FileContentEncoding. Declare it as File with the enum member.
path.relative(process.cwd(), <tmpdir>) returns an absolute path on the Windows runner, where the working directory is on D: and the temp directory on C:, so the test's own precondition failed there. Move the working directory to the suite's temp root for the duration instead, which pins the resolution property directly and works on every platform.
The 'relative paths resolve against the working directory' and 'the directory is never deleted' caveats were repeated in three places. Keep them on SkillToolset's outputDir option, which is the public surface a user configures, and cut the internal helper's doc to a summary plus tags. materializeFiles's @param no longer argues why dir has no default: the signature says so. Also inlines the single-use OUTPUT_DIR_PREFIX constant.
.foundry_review_brief.md.complexity is tooling metadata that a git add -A swept in; it is not part of this change.
This was referenced Aug 3, 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
Not applicable — no public issue tracks this.
Problem:
materializeFiles()declared its destination asdir = process.cwd()(core/src/utils/file_utils.ts), and both skill script tools called it with no directory at all:So every file an executed skill script emitted was written into whichever directory the host Node process happened to be launched from — the repository root when running the dev server from a checkout. The filenames are not ours: they come back from the code executor, i.e. from the script, and for
run_skill_inline_scriptfrom script content the model authored. That is how a stray root-leveloutput.txt(contentshello) ended up tracked in this repository; this PR deletes it.The rest of the codebase already does this correctly.
UnsafeLocalCodeExecutorowns a scratch directory underos.tmpdir(), materializes into it with an explicittempDir, runs the child withcwdset to it, and removes it in afinally. Only the two skill tools wrote to the ambient working directory.A second, smaller defect lives in the same function. The containment guard was a raw string-prefix test:
startsWithdoes not respect path boundaries, so a sibling directory whose name merely extends the base name passes it. Withdir = /var/app/out, a file named../outX/leak.txtresolves to/var/app/outX/leak.txt, whichstartsWith('/var/app/out')— the write escapes. This was latent whiledirwas the whole working directory; it becomes load-bearing the momentoutputDirnames a small, declared directory, so it is fixed here.Solution: writing to the host filesystem is a side effect a library must be told where to perform.
materializeFiles(files, dir)—diris now required. Deleting the default is what stops a future caller reintroducing the footgun by omitting the argument:tscrejects it. Internal change only — the symbol is exported from neithercore/src/index.tsnorcore/src/common.ts. Both remaining in-repo call sites are updated (unsafe_local_code_executor.tsalready passed an explicittempDir).core/src/tools/skill/script_output_utils.tswithmaterializeScriptOutputs(result, outputDir?)and theSkillScriptResultresponse type. Co-located with the skill tools rather than incore/src/utils/, on the same justification ascode_executors/code_execution_utils.ts: it is meaningless outside them. It returns a new object rather than mutating the executor'sresultin place.SkillToolsetgains anoutputDir?: stringoption, exposed aspublic readonly outputDir. Resolution to an absolute path happens at use, not at construction, so a host that changes its working directory is not pinned to a stale value. There is deliberately no getter that falls back toprocess.cwd()— that would reinstate the defect behind a different name.outputDiris unset, each execution that produces output files gets a fresh directory fromfs.mkdtemp(path.join(os.tmpdir(), 'adk-skill-outputs-'))and the tool response reports its absolute path, so the files stay findable.mkdtemprather than a hand-built name: a fixed name in the world-writable temp directory can be pre-created as a symlink by another local user.output.txtis deleted, and it is the smoking gun rather than incidental cleanup. It was committed byfeat: skills: support script execution (#276)(commit8d5cc0a) — the same PR that added these tools — its contents are the 5 byteshello, and nothing in the repository reads it. Its name and content match the skill-script test fixture: it is precisely the artifact this defect produces, sitting at the repository root ever since.isContained(baseDir, fullPath)replacesstartsWithat both guard sites (before, and again after the collision-rename loop). The thrown message is byte-identical, so the existing/Path traversal detected/assertions keep passing unmodified.materializeFilesJSDoc is rewritten. It previously said "Creates files with the given paths in the current working directory". It now describes the_2/_3collision suffixing and the return value, and states plainly that the containment check is a lexical comparison of resolved paths and not a sandbox — it does not survive symlinks, hardlinks, bind mounts, or a TOCTOU race.Behavioural change, called out deliberately. The default output location moves from
process.cwd()to a per-execution temp directory. This is the point of the fix and it is observable. Nothing is lost:CodeExecutionResult.outputFilesalready carries the file bytes inline on the tool response, and the newoutputDirfield reports the new location. Both skill script tools are@experimental. Anyone who relied on the old behaviour setsoutputDirtoprocess.cwd().Intentional asymmetry: unlike the code executor's scratch directory, the output directory is not deleted — it holds the artifacts the script was asked to produce. Unconfigured runs therefore rely on OS temp cleanup; an application that wants a managed lifetime sets
outputDir. This is stated in theoutputDirJSDoc.Cross-language parity: not applicable, deliberately.
adk-pythonnever materializes script outputs onto the host at all —skill_toolset.py's generated wrapper runs inside atempfile.TemporaryDirectory()and chdirs back in afinally, and itsUnsafeLocalCodeExecutorreturnsoutput_files=[]. Writing toprocess.cwd()is a JS-only defect, not a parity gap, so nothing was ported.Deliberate scope limits
ArtifactService. Persisting script outputs as session artifacts changes what the model sees in the tool response; it is a different capability and belongs in its own change on top of this one. (Fix: persist skill script output files to the artifact service instead of the agent process cwd #410 and Feat: Save skill script output files as artifacts (opt-in saveOutputsAsArtifacts) #517 pursue that route.)UnsafeLocalCodeExecutoris untouched; its scratch-directory naming is a separate task (Test: pin that UnsafeLocalCodeExecutor removes its scratch directory on both the success and failure paths #355).materializeFilesstill mutatesfile.namein the de-duplication loop. Untangling that is Fix: stop materializeFiles mutating its inputs, and bound and atomize collision resolution #409's scope, not this PR's..gitignoreentry foroutput.txt— an ignore rule would hide a recurrence of exactly this bug.node:fs/promises,node:osandnode:pathare already used incore/src.Collision check
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000, thengh pr diff --name-onlyon every plausibly adjacent PR. This area is heavily contested — the reviewer should treat these as mutually exclusive and pick one:fix/skill-script-output-diradds theoutputDiroption but keeps theprocess.cwd()default, so it ships the knob without fixing the defect. Fix: require an explicit destination directory so skill script output never lands in process.cwd() #353 and Fix: require an explicit output directory and resolve the default in SkillToolset #437 are both stacked on it and are opposite designs: Fix: require an explicit destination directory so skill script output never lands in process.cwd() #353 writes nothing at all whenoutputDiris unset, Fix: require an explicit output directory and resolve the default in SkillToolset #437 resolves the default back toprocess.cwd(). This PR is a third answer — write to a declared directory, or to a reported per-execution temp directory — and is branched frommainrather than stacked, because it reverses Fix: require an explicit destination directory so skill script output never lands in process.cwd() #353's central decision and could not stack on it honestly.outputDirand the change is four lines. If either of those lands first, drop that hunk here.output.txt. This PR deletes it too, as the artifact the defect produced. Whichever lands first makes the rest a no-op.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.
npm run ts:checkstill reports pre-existing errors elsewhere in the repo (that gate is red onmain; #370 / #487 own it). It reported one error in a file here —TS2352on anas Filecast that already existed onmainand that this PR moves — so it is fixed properly (File+FileContentEncoding.UTF8) rather than carried.New unit tests
core/test/tools/skills/script_output_utils_test.ts(new): explicit directory, relative-directory resolution, the unconfigured temp-directory default, a distinct directory per call, the no-output-files early return, traversal rejection, and collision suffixing. The suite redirectsTMPDIR/TEMP/TMPto a root it owns for the duration, so "nothing was created" is an exact observation rather than a race against a sharedos.tmpdir().core/test/utils/file_utils_test.ts: two cases added, none of the five existing cases modified — a sibling directory whose name extends the base name is rejected, and a nested path whose segment merely starts with..(..data/report.txt) is allowed.core/test/tools/skills/skill_toolset_test.ts:outputDiris exposed when configured andundefinedwhen not. Noprocess.cwd()fallback is asserted, because there must not be one.Existing tests rewritten — 7 cases, enumerated, with reasons
No test case is deleted. Every one of these pinned the behaviour that is the bug, so there is no version of this fix that leaves them passing untouched; each replacement is strictly stronger, asserting the file is absent from
process.cwd().core/test/tools/skills/run_skill_script_tool_test.ts—calls materializeFiles with output files from executormaterializeFileswas called. The tool no longer calls it, so the assertion pinned an internal call rather than a behaviour. Nowmaterializes output files with no directory when none is configured, asserting onmaterializeScriptOutputs.core/test/tools/skills/run_skill_inline_script_tool_test.ts— same case, same renametests/integration/tools/run_skill_script_tool_test.ts—creates files in process.cwd returned from executionprocess.cwd()as the output location, i.e. asserted the defect. Nowwrites output files into the configured outputDir.tests/integration/tools/run_skill_inline_script_tool_test.ts— same case, same renametests/integration/tools/run_skill_script_tool_test.ts—handles file collisions by appending a numeric suffix_2assertions, now inside a per-testmkdtempdirectory.tests/integration/tools/run_skill_inline_script_tool_test.ts— same casetests/integration/skills/script_js/agent_test.ts—should run agent with skills successfullyADK_SKILL_OUTPUT_DIRand additionally asserts all three are absent from the project directory.Two further disclosures on that set:
run_skill_script_tool_test.tsalso restructures the surviving assertions behind newexecutorReturning()andrunTool()helpers. That is more churn than the behaviour change strictly forces — it is a readability refactor sharing setup across the three cases in that block, kept deliberately, and flagged here so the refactor is distinguishable from the behaviour change in review.main: the spawned agent child process never gets pastnpm run starthere. I could not run it green either before or after, so that one rewrite is verified by reading only.Proof the tests can fail. Each new test was run against mutated source; recorded failures:
isContained()→return fullPath.startsWith(baseDir)rejects a sibling directory whose name extends the base directory nameAssertionError: promise resolved "[ { name: '../outX/leak.txt', …(3) } ]" instead of rejecting!rel.startsWith('..')allows a nested path whose segment merely starts with '..'Error: Path traversal detected: ..data/report.txt resolves outside of …/file_utils_test_wUCAGZprocess.cwd()writes to a fresh temp directory when no output directory is configured,creates a distinct directory per call, and integrationdoes not write output files into the working directory by defaultAssertionError: expected '<repo root>' to be '<tmpdir>'outputFiles.length === 0early returnreturns the result unchanged and creates nothing when there are no output filesAssertionError: expected { stdout: 'out', stderr: 'err', …(2) } to be { stdout: 'out', stderr: 'err', …(1) }undefinedinstead ofthis.toolset.outputDirpasses the toolset outputDir throughin both tool suitesAssertionError: expected "spy" to be called with arguments: [ …(2) ]Coverage.
script_output_utils.ts(the new file) is at 100% statements / branches / functions / lines.file_utils.tsmeasures 94.93% lines and 80% branches; the shortfall is lines 83–86, the throw body of the second, post-collision containment guard. That guard is defense in depth and is unreachable through the public surface, because the rename loop only appends a suffix to the basename inside the same directory — a path contained before the loop is still contained after. It is kept deliberately (the invariant is "checked both before and after the rename loop") rather than deleted to make the number go up.resolves a relative output directory against the working directorymoves the process working directory to the test's own temp root for the duration (restored in afinally) rather than deriving a relative path from the real one — on Windows the two sit on different drives, where no relative path between them exists, and the first revision of this PR failed the Windows leg for exactly that reason. Vitest's defaultforkspool gives each test file its own process, so thechdircannot leak into another suite.One test-hygiene note worth flagging: the integration suite registers a tool-chosen
outputDirfor cleanup only after asserting it is directly underos.tmpdir()(trackToolOutputDir). Without that ordering, running theprocess.cwd()mutation above makesafterEachrecursively delete the repository — which is exactly what happened to me once while producing the table above.CI note — a flaky Windows leg, disclosed with numbers.
run-testsis green on ubuntu-latest, macos-latest and windows-latest. Getting Windows green took several attempts: across this branch it failed 5 of 9 runs, always on the same test —core/test/code_executors/unsafe_local_code_executor_test.ts > should execute shell code and return stdout,Test timed out in 5000ms. Evidence that it is not this change:git rm --cacheds a stray markdown file — on top of a commit whose Windows leg had already passed. The tree those runs executed is behaviourally identical to one that passed.mainbranch currently has a red Windows leg too, on a different flake (tests/integration/adk_web/webui_test.ts, "CLI exited prematurely with code 1"). Windows is unstable here at baseline.The underlying cause is a real-subprocess test running against vitest's 5s default on a cold PowerShell; #498 is the open fix for exactly that. Not addressed here, to keep this diff to the change it is for — but a reviewer re-running this PR should expect to need a retry on Windows.
Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
npm run build.SkillToolsethas anUnsafeLocalCodeExecutorand a skill script that writes a file —tests/integration/skills/script_js/agent.tsis a ready-made fixture — with the dev server started from the repository root.git status. After it,git statusis clean and the tool response carriesoutputDirpointing at aadk-skill-outputs-*directory under the OS temp directory that contains the file.outputDirset on the toolset (the fixture readsADK_SKILL_OUTPUT_DIR) and confirm the file lands there instead.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.