Fix: require an explicit destination directory so skill script output never lands in process.cwd() - #353
Open
AmaadMartin wants to merge 4 commits into
Open
Conversation
This was referenced Jul 31, 2026
Open
Fix: persist skill script output files to the artifact service instead of the agent process cwd
#410
Open
added 2 commits
August 2, 2026 12:36
materializeFiles() defaulted its destination to process.cwd(), and both skill script tools called it without a directory, so every file an executed skill script emitted landed in whatever directory the host Node process was started from -- the repository root, for a contributor running the dev server from a clone. The names come from the executed script, and a colliding one is suffixed rather than overwritten, so the writes accumulated unboundedly in a sensitive location. The default is gone: dir is now required, which is what stops a future caller reintroducing the footgun by omitting it. The tools write only when the embedding application declared SkillToolset's outputDir, and otherwise touch the host filesystem not at all -- the bytes are already returned inline on the tool response, which is the whole contract, and matches adk-python, where the skill toolset materializes only inside the code executor's own temporary directory. Also removes output.txt from the repository root: an unreferenced five-byte artifact this defect left behind.
Adds core/test/tools/skills/skill_script_output_dir_test.ts, which deliberately does not mock file_utils -- the two existing skill tool test files mock it at module scope, so neither can observe what actually reaches the disk. It covers both tools writing under the configured outputDir, both writing nothing when it is unset (asserted against a before/after snapshot of the working directory), collision suffixing inside that directory, and an output name escaping it surfacing as an EXECUTION_ERROR. The mocked tool tests now pin the two-argument call and the not-called case; file_utils gains a case for a destination that does not exist yet; the integration tests read from a temp directory and assert the working directory stays clean.
AmaadMartin
force-pushed
the
fix/skill-script-explicit-output-dir
branch
from
August 2, 2026 19:48
b1e876e to
b335d96
Compare
added 2 commits
August 2, 2026 13:17
tests/integration/skills/script_js spawns a real agent and asserted that the skill script's three output files appeared in the agent's own working directory -- which only happened because materializeFiles defaulted to process.cwd(). With the default gone the agent writes nothing and the test failed with ENOENT. The agent now takes its outputDir from ADK_SKILL_OUTPUT_DIR, and the test creates a per-run temp directory, passes it through the spawn env, reads the files back from there and additionally asserts the three names are absent from the project directory. That inverts the case: it used to pin the leak, it now pins its absence. Also corrects the materializeFiles doc comment, which claimed names resolving outside dir are rejected. The check is a string-prefix comparison, so a sibling sharing the prefix (/a/out vs ../out_leak.txt) passes it; the comment now says so rather than advertising a boundary that is not enforced.
Drops three restatements a complexity review flagged: the SkillToolset field comment (which repeated the option doc 18 lines below it, and which no sibling field carries), the collision-suffix rule on the option (which materializeFiles already documents), and the worked path-escape example in the materializeFiles caveat (the actionable half is that the check is lexical, not a sandbox). Also reverts the module mock in run_skill_script_tool_test.ts to a bare vi.fn(): no test in that file needs materializeFiles to return its input, so passing one was shared fixture state for nothing. Restores a blank line this branch had incidentally deleted.
This was referenced Aug 3, 2026
Open
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(), and both skill script tools called it with no directory at all:So every file an executed skill script emitted was written into whatever directory the host Node process happened to be launched from — for a contributor running the dev server from a clone of this repo, the repository root. Four things make that worse than a cosmetic annoyance:
File.namecomes back from the code executor, i.e. from output the model or the skill author produced. The only guard is a lexical path-containment check that keeps writes under the base directory — and the base directory here is the caller's cwd, which is precisely the sensitive location._2,_3, … So repeated invocations pile up files with no cap, no rotation and no cleanup, inside a long-running agent process.content,contentEncodingandmimeTypeinline. The disk write serves no in-process consumer; its only observable effect is the pollution.output.txt(contenthello, 5 bytes) was tracked at the repository root. Its name and content match the fixture incore/test/tools/skills/run_skill_script_tool_test.ts, and nothing in the repository reads it. This PR deletes it.Solution: writing to the host filesystem is a side effect a library must be told to perform.
materializeFiles(files, dir)— the= process.cwd()default is deleted anddirbecomes required. This is what stops a future caller reintroducing the footgun by omitting the argument;tscrejects it.run_skill_script/run_skill_inline_scriptwrite only when the embedding application declaredSkillToolset'soutputDir. When it is unset they touch the host filesystem not at all — the bytes are still returned inline on the tool response, which is the whole contract.try, so a rejected traversal is converted by the existingcatchinto the normalEXECUTION_ERRORresponse rather than escaping the tool.core/src/code_executors/unsafe_local_code_executor.tsis untouched: it already passes an explicittempDir.materializeFilesdoc comment is corrected while it is being rewritten anyway. It previously said names resolving outsidedirare rejected; the check isfullPath.startsWith(resolvedBaseDir), a raw string-prefix comparison, so a sibling sharing the prefix (dirof/a/out, name../out_leak.txt→/a/out_leak.txt) passes it. The comment now calls the check lexical anddira tidiness boundary rather than a security one; the worked example above is the long form, deliberately kept out of the source comment. No logic changed — fixing the guard is a documented non-goal here and Fix: test path containment, not string prefix, in materializeFiles #371/Fix: enforce path containment by segment, not string prefix, in materializeFiles #523 already own it — but the newoutputDiroption is exactly what makes that boundary user-visible, so the docstring should not advertise a guarantee the code does not provide.This matches
adk-python, whereskill_toolset.py's generated wrapper materializes only inside the code executor's owntempfile.TemporaryDirectory()and chdirs back in afinally— there is no host-side output-file materialization anywhere in that file.This is an intended behavioural change and should land in release notes: skill script output files no longer appear in the agent process's working directory. Anyone who relied on that should pass
outputDirtoSkillToolset. It is not a public API break —materializeFilesis internal (exported from neithercore/src/index.tsnorcore/src/common.ts), andoutputDiris an additive optional field.Deliberate scope limits
Per the approved design, the following are out of scope and deliberately not done here:
toolContext.saveArtifact()(the pattern incode_execution_request_processor.ts). See the collision notes — two open PRs already pursue that.materializeFilespure with respect to its inputFileobjects. It still mutatesfile.namein the de-duplication loop, on purpose:unsafe_local_code_executor.tsdecides which files in its temp dir are outputs by comparing against those same inputFile.namevalues, so untangling it is a separate change..gitignore. An ignore entry foroutput.txtwould hide a recurrence of exactly this bug, so none was added.Collision check
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000plusgh pr diff --name-onlyon every plausibly adjacent PR. Findings:fix/skill-script-output-dir— this PR is stacked on it, and it must merge first. Fix: materialize skill script output into a declared directory, never process.cwd() #298 adds theoutputDiroption but deliberately keeps theprocess.cwd()default, so it ships the knob without fixing the defect. This PR is based on that branch rather thanmainand supplies the behavioural half: requireddir, and no host write whenoutputDiris unset.fix/skill-tools-declared-output-dir— a direct conflict, also stacked on Fix: materialize skill script output into a declared directory, never process.cwd() #298. It makesoutputDirprivate behind a getter that resolves the default toprocess.cwd()("Defaults to the agent process's current working directory"). That is the opposite design: it keeps writing to the launch directory. These two cannot both land; this PR is the one that stops the writes.output.txtstandalone. This PR deletes it too, because it is the direct artifact of the defect being fixed and the approved design lists it. Identical deletions merge cleanly; if one of those lands first, drop this one-line hunk.materializeFiles(segment-wise instead of string-prefix). Same function, disjoint lines — this PR keeps both guards verbatim and changes only the signature and the doc comment.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.
New —
core/test/tools/skills/skill_script_output_dir_test.ts(6 cases). This file deliberately does notvi.mockfile_utils. The two existing skill tool test files mock that module at module scope, so neither of them can observe what actually reaches the disk; this one exercises the real filesystem out of a per-testfs.mkdtempand tears it down inafterEach. It builds a realInvocationContext(new LlmAgent+createSession+new PluginManager) rather than casting a literal, so it adds noas unknown as. Cases:run_skill_scriptwrites output files into the configuredoutputDir.run_skill_scriptwrites nothing whenoutputDiris unset — asserted against a before/afterSetsnapshot offs.readdir(process.cwd()), plus an empty temp dir, plus the executor's array returned unchanged. This is the regression test for the reported bug.run_skill_inline_script.outputDir(output.txt,output_2.txt) and the returned names match the on-disk ones.../escape.txt, against a nestedoutputDirso the escape target stays inside the test's own temp tree) surfaces as{error: /Path traversal detected/, errorCode: 'EXECUTION_ERROR'}, and no file is created at the escape target.Modified — existing tests that encoded the defective behaviour. Declaring these explicitly, since each destroys a prior assertion:
core/test/utils/file_utils_test.ts: removed'should default the base directory to the working directory of each call'. It asserted, via aprocess.cwdspy, that omittingdirfollows the cwd at call time — i.e. it pinned the exact default this PR deletes, and it cannot compile against the required-dirsignature. Its replacement signal is'should require an explicit destination directory', which assertsmaterializeFiles.length === 2(arity drops to 1 the moment a default is reintroduced), plusnpm run ts:check.core/test/tools/skills/run_skill_script_tool_test.tsandrun_skill_inline_script_tool_test.ts: the case assertingtoHaveBeenCalledWith([testFile], undefined)became'does not write output files when no output directory is configured', assertingexpect(materializeFiles).not.toHaveBeenCalled()and thatresult.outputFilesis the executor's array unchanged. The old assertion pinned "call it anyway and let it pick cwd", which is the bug.tests/integration/tools/run_skill_script_tool_test.tsandrun_skill_inline_script_tool_test.ts:'creates files in process.cwd returned from execution'asserted the file was created inprocess.cwd()and thenunlinked it. Split into'returns output files inline without writing to disk when no output directory is configured'(asserts cwd stays clean and the bytes are on the result) and'creates files in the configured output directory'(temp dir viamkdtemp, cleaned infinally). The collision case now pre-creates its fixture in the temp dir instead of the repo root.tests/integration/skills/script_js/— the one a first review round caught me missing. This suite spawns a real agent (npm run start) and asserted that the skill script's three output files (ephemeral_entanglement.md,sketch.js,index.html) appeared in the agent's own project directory, cleaning them up inafterAll. That only ever worked because of theprocess.cwd()default —UnsafeLocalCodeExecutorruns the script in anos.tmpdir()directory andfs.rms it in afinally— so with the default gone the test failed withENOENT.agent.tsnow takes itsoutputDirfromADK_SKILL_OUTPUT_DIR;agent_test.tscreates a per-runfs.mkdtempdirectory, passes it through thespawnenv, reads the files back from there, and additionally asserts the three names are absent from the project directory. The case used to pin the leak; it now pins its absence, and it is the end-to-end demonstration of the migration path this PR gives users.core/test/tools/skills/run_skill_script_tool_test.tskeeps its barevi.fn()module mock. An earlier revision of this branch gave it amockImplementation(files => files)for symmetry with the inline-tool file; no test here needsmaterializeFilesto return its input, so that was shared fixture state buying nothing and it was reverted..only'd or weakened. The five pre-existingfile_utils_testcases already passedtempDirexplicitly and are untouched; that they still compile against the required-dirsignature is itself a useful signal.Added —
core/test/utils/file_utils_test.ts:'should create the target directory when it does not exist', passingpath.join(tempDir, 'nested', 'out').Proof the tests can fail (mutation testing)
Every new test was run against mutated source and observed to FAIL. Exact mutations and messages:
M1 — restore the defect wholesale: put
dir = process.cwd()back infile_utils.tsand revert both tools tomaterializeFiles(result.outputFiles). 10 tests failed, and the run reproduced the bug end to end by leakingoutput.txt…output_6.txtinto the repository root:M2 — keep the guard but re-add a fallback:
const outputDir = this.toolset.outputDir ?? process.cwd(); if (true) {. 4 tests failed — precisely the four "writes nothing" tests, which is the property this PR is about:M3 — traversal guard. Case 6 is not killed by M1 or M2, so it was mutated directly. Worth flagging as a finding:
materializeFileshas two containment checks (before de-duplication and after), and disabling either one alone leaves all tests green — the surviving one still catches the escape:if (!fullPath.startsWith(resolvedBaseDir))→if (false): 6/6 pass.if (!finalPath.startsWith(resolvedBaseDir))→if (false): 13/13 pass.if (false): 3 tests fail, including the new case 6 —→ expected { stdout: '', stderr: '', …(1) } to deeply equal { error: StringMatching{…}, …(1) }alongside the two pre-existing
file_utilstraversal tests.So for a name that escapes before de-duplication the second check is redundant. Changing it is out of scope here (and #371/#523 already rework that guard), but the redundancy is real and a reviewer of those PRs should know a single-guard mutant is currently undetectable.
M5 — the
script_jsagent suite against the restored default: the same source mutation as M1, run against the spawn-based agent test. It fails, and the mutated run leaks the three files straight back into the repository working tree — the defect reproduced through a real agent process rather than a unit fixture:M4 —
fs.mkdir(path.dirname(finalPath), {recursive: true})deleted: the newfile_utilscase fails with→ ENOENT: no such file or directory, open '…/file_utils_test_3uPX1q/nested/out/test.txt'.Source files were byte-compared against pristine copies after every mutation, and every leaked file was removed;
git status --porcelainis empty.Coverage
100% line and branch coverage of all new/changed executable lines, verified by cross-referencing the v8 JSON report's uncovered-line list against the diff rather than by reading a percentage:
skill_toolset.ts—this.outputDir = options.outputDir;covered; not in the uncovered set.run_skill_script_tool.tslines 144–151 andrun_skill_inline_script_tool.tslines 143–150 — no uncovered statements, and neitherif (outputDir)appears in the uncovered-branch set, so both arms execute.file_utils.ts— the only change is the parameter signature; there is no new executable line. Its remaining uncovered lines (67-70,108-111, branches at58,66) are all pre-existing code this PR does not touch: the second containment guard discussed above,guessMimeType, and a ternary in the de-duplication loop.Commands run
core/test/code_executors/unsafe_local_code_executor_test.tswas re-run unchanged. It is the existing integration-level exercise ofmaterializeFilesthrough a real subprocess, and it proves the signature change disturbed neither the executor's input-file materialization nor its output-file scanning.No suppressions of any kind were added — no
any,as any,as unknown as,@ts-expect-error,eslint-disable, or coverage-ignore comment appears anywhere in this diff.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
LlmAgentwith anUnsafeLocalCodeExecutorand aSkillToolsetcontaining a skill whosescripts/write.jsdoesrequire('node:fs').writeFileSync('output.txt', 'hello').run_skill_scriptthree times. Before this changegit statusat the repo root showed untrackedoutput.txt, thenoutput_2.txt, thenoutput_3.txt. After it,git statusstays clean and the tool result still carries the file inline asoutputFiles[0].content.new SkillToolset(skills, {codeExecutor, outputDir: '/tmp/adk-skill-out'})and invoke three more times./tmp/adk-skill-outnow holdsoutput.txt,output_2.txt,output_3.txt, and the result'soutputFiles[].namevalues match those on-disk names.git statusis still clean — the original symptom was a test-adjacent run leaving a file in the repository.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.