Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export type {SkillRegistry} from './skills/skill_registry.js';
export {ListSkillsTool} from './tools/skill/list_skills_tool.js';
export {LoadSkillResourceTool} from './tools/skill/load_skill_resource_tool.js';
export {LoadSkillTool} from './tools/skill/load_skill_tool.js';
export type {SkillScriptResult} from './tools/skill/script_output_utils.js';
export {SearchSkillsTool} from './tools/skill/search_skills_tool.js';
export {SkillToolset} from './tools/skill/skill_toolset.js';

Expand Down
7 changes: 2 additions & 5 deletions core/src/tools/skill/run_skill_inline_script_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {Context} from '../../agents/context.js';
import {isLlmAgent} from '../../agents/llm_agent.js';
import {CodeExecutionLanguage} from '../../code_executors/code_execution_utils.js';
import {experimental} from '../../utils/experimental.js';
import {materializeFiles} from '../../utils/file_utils.js';
import {BaseTool, RunAsyncToolRequest} from '../base_tool.js';
import {materializeScriptOutputs} from './script_output_utils.js';
import {SkillToolset} from './skill_toolset.js';

/**
Expand Down Expand Up @@ -140,10 +140,7 @@ export class RunSkillInlineScriptTool extends BaseTool {
},
});

// Final filename could be different if there was a collision, so update the result.
result.outputFiles = await materializeFiles(result.outputFiles);

return result;
return materializeScriptOutputs(result, this.toolset.outputDir);
} catch (e: unknown) {
return {
error: `Failed to execute inline script: ${(e as Error).message}`,
Expand Down
7 changes: 2 additions & 5 deletions core/src/tools/skill/run_skill_script_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import {
getMimeTypeAndEncoding,
getScriptLanguageByExtension,
} from '../../utils/file_extension_utils.js';
import {materializeFiles} from '../../utils/file_utils.js';
import {BaseTool, RunAsyncToolRequest} from '../base_tool.js';
import {materializeScriptOutputs} from './script_output_utils.js';
import {SkillToolset} from './skill_toolset.js';

@experimental
Expand Down Expand Up @@ -141,10 +141,7 @@ export class RunSkillScriptTool extends BaseTool {
},
});

// Final filename could be different if there was a collision, so update the result.
result.outputFiles = await materializeFiles(result.outputFiles);

return result;
return materializeScriptOutputs(result, this.toolset.outputDir);
} catch (e: unknown) {
return {
error: `Failed to execute script '${scriptPath}': ${(e as Error).message}`,
Expand Down
53 changes: 53 additions & 0 deletions core/src/tools/skill/script_output_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import {CodeExecutionResult} from '../../code_executors/code_execution_utils.js';
import {materializeFiles} from '../../utils/file_utils.js';

/**
* The result of a skill script execution, annotated with the directory its
* output files were written to.
*/
export interface SkillScriptResult extends CodeExecutionResult {
/**
* Absolute path of the directory the output files were written to. Absent
* when the script produced no output files.
*/
outputDir?: string;
}

/**
* Writes the output files of a skill script execution under `outputDir`, or
* into a fresh per-execution directory in the OS temp directory when it is
* unset. Nothing is written when the script produced no output files.
*
* @param result The result returned by the code executor.
* @param outputDir Directory to write the output files into. See
* `SkillToolset`'s option of the same name for the lifetime policy.
* @returns The result with each output file name rewritten relative to the
* output directory, plus the absolute `outputDir` the files went to.
*/
export async function materializeScriptOutputs(
result: CodeExecutionResult,
outputDir?: string,
): Promise<SkillScriptResult> {
if (result.outputFiles.length === 0) {
return result;
}

const dir = outputDir
? path.resolve(outputDir)
: await fs.mkdtemp(path.join(os.tmpdir(), 'adk-skill-outputs-'));

return {
...result,
outputFiles: await materializeFiles(result.outputFiles, dir),
outputDir: dir,
};
}
18 changes: 18 additions & 0 deletions core/src/tools/skill/skill_toolset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class SkillToolset extends BaseToolset {
public registry?: SkillRegistry;
private toolCache = new Map<string, BaseTool[]>();
private fetchedSkillCache = new Map<string, Map<string, Skill>>();
public readonly outputDir?: string;

constructor(
skills: Record<string, Skill> | Skill[],
Expand All @@ -63,6 +64,22 @@ export class SkillToolset extends BaseToolset {
* confirmation.
*/
allowInlineScripts?: boolean;
/**
* Directory that output files produced by `run_skill_script` and
* `run_skill_inline_script` are written into. A relative path is
* resolved against the host process's working directory.
*
* When unset, each execution that produces output files gets a fresh
* directory under the OS temp directory and the tool response reports
* its absolute path. Set this to keep script output in a location the
* application manages.
*
* The directory is never deleted, unlike the code executor's own scratch
* directory: it holds the artifacts the script was asked to produce.
* Unconfigured runs therefore rely on OS temp-directory cleanup, so an
* application that needs a managed lifetime should set this.
*/
outputDir?: string;
} = {},
) {
super([], 'adk_skill_toolset');
Expand All @@ -72,6 +89,7 @@ export class SkillToolset extends BaseToolset {
this.codeExecutor = options.codeExecutor;
this.additionalTools = options.additionalTools || [];
this.registry = options.registry;
this.outputDir = options.outputDir;

this.tools = [
new ListSkillsTool(this),
Expand Down
35 changes: 30 additions & 5 deletions core/src/utils/file_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,44 @@ import * as path from 'node:path';
import {File} from '../code_executors/code_execution_utils.js';

/**
* Creates files with the given paths in the current working directory.
* @param files The files to materialize.
* Reports whether `fullPath` is a strict descendant of `baseDir`. Both
* arguments must already be resolved absolute paths.
*/
function isContained(baseDir: string, fullPath: string): boolean {
const rel = path.relative(baseDir, fullPath);

return (
rel !== '' && !path.isAbsolute(rel) && !rel.split(path.sep).includes('..')
);
}

/**
* Writes the given in-memory files under `dir`, creating parent directories as
* needed. An existing file is never overwritten: a numeric suffix is appended
* instead (`report.txt` -> `report_2.txt` -> `report_3.txt`).
*
* A name that does not resolve to a strict descendant of `dir` is rejected with
* a `Path traversal detected` error. That containment check is a lexical
* comparison of resolved paths and is **not** a sandbox: it does not survive
* symlinks, hardlinks, bind mounts, or a TOCTOU race between the check and the
* write.
*
* @param files The files to materialize. `name` is updated in place when a
* collision forces a rename.
* @param dir Base directory to write under.
* @returns The written files, each `name` rewritten to the final path relative
* to `dir`.
*/
export async function materializeFiles(
files: File[],
dir = process.cwd(),
dir: string,
): Promise<File[]> {
const resolvedBaseDir = path.resolve(dir);
const createdFiles: File[] = [];
for (const file of files) {
const fullPath = path.resolve(dir, file.name);

if (!fullPath.startsWith(resolvedBaseDir)) {
if (!isContained(resolvedBaseDir, fullPath)) {
throw new Error(
`Path traversal detected: ${file.name} resolves outside of ${dir}`,
);
Expand Down Expand Up @@ -51,7 +76,7 @@ export async function materializeFiles(
}
}

if (!finalPath.startsWith(resolvedBaseDir)) {
if (!isContained(resolvedBaseDir, finalPath)) {
throw new Error(
`Path traversal detected: ${file.name} resolves outside of ${dir}`,
);
Expand Down
83 changes: 78 additions & 5 deletions core/test/tools/skills/run_skill_inline_script_tool_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ import {
LlmAgent,
RunSkillInlineScriptErrorCode,
RunSkillInlineScriptTool,
SkillScriptResult,
SkillToolset,
} from '@google/adk';
import {describe, expect, it, vi} from 'vitest';
import {materializeScriptOutputs} from '../../../src/tools/skill/script_output_utils.js';
import {ToolConfirmation} from '../../../src/tools/tool_confirmation.js';
import {materializeFiles} from '../../../src/utils/file_utils.js';

vi.mock('../../../src/utils/file_utils.js', () => ({
materializeFiles: vi.fn().mockImplementation((files) => files),
vi.mock('../../../src/tools/skill/script_output_utils.js', () => ({
materializeScriptOutputs: vi
.fn()
.mockImplementation((result: CodeExecutionResult) => result),
}));

class MockCodeExecutor extends BaseCodeExecutor {
Expand Down Expand Up @@ -51,6 +54,15 @@ interface ToolErrorResponse {
errorCode: RunSkillInlineScriptErrorCode;
}

function outputFile(): File {
return {
name: 'output.txt',
content: 'hello',
contentEncoding: FileContentEncoding.UTF8,
mimeType: 'text/plain',
};
}

describe('RunSkillInlineScriptTool', () => {
function createMockContext(
agentName = 'test-agent',
Expand Down Expand Up @@ -220,7 +232,7 @@ describe('RunSkillInlineScriptTool', () => {
});
});

it('calls materializeFiles with output files from executor', async () => {
it('materializes output files with no directory when none is configured', async () => {
const mockExecutor = new MockCodeExecutor();
const testFile: File = {
name: 'output.txt',
Expand All @@ -247,7 +259,68 @@ describe('RunSkillInlineScriptTool', () => {
}),
});

expect(materializeFiles).toHaveBeenCalledWith([testFile]);
expect(materializeScriptOutputs).toHaveBeenCalledWith(
mockExecutor.mockResult,
undefined,
);
});

it('passes the toolset outputDir through', async () => {
const mockExecutor = new MockCodeExecutor();
mockExecutor.mockResult = {
stdout: '',
stderr: '',
outputFiles: [outputFile()],
};

const toolset = new SkillToolset([], {
codeExecutor: mockExecutor,
outputDir: '/configured/dir',
});
const tool = new RunSkillInlineScriptTool(toolset);

await tool.runAsync({
args: {
script_content: 'console.log("test");',
language: CodeExecutionLanguage.JAVASCRIPT,
},
toolContext: createMockContext('test-agent', undefined, {
toolConfirmation: confirmed(),
}),
});

expect(materializeScriptOutputs).toHaveBeenCalledWith(
mockExecutor.mockResult,
'/configured/dir',
);
});

it('returns the outputDir reported by the helper', async () => {
const mockExecutor = new MockCodeExecutor();
mockExecutor.mockResult = {
stdout: '',
stderr: '',
outputFiles: [outputFile()],
};
vi.mocked(materializeScriptOutputs).mockResolvedValueOnce({
...mockExecutor.mockResult,
outputDir: '/somewhere',
});

const toolset = new SkillToolset([], {codeExecutor: mockExecutor});
const tool = new RunSkillInlineScriptTool(toolset);

const result = (await tool.runAsync({
args: {
script_content: 'console.log("test");',
language: CodeExecutionLanguage.JAVASCRIPT,
},
toolContext: createMockContext('test-agent', undefined, {
toolConfirmation: confirmed(),
}),
})) as SkillScriptResult;

expect(result.outputDir).toBe('/somewhere');
});

it('successfully passes array arguments to code executor', async () => {
Expand Down
Loading
Loading