Skip to content

Fix: reject zip-slip entries and non-bare skill names when loading zipped skills (adk-python parity) - #312

Open
AmaadMartin wants to merge 9 commits into
mainfrom
fix/skills-zip-slip-and-name-guards
Open

Fix: reject zip-slip entries and non-bare skill names when loading zipped skills (adk-python parity)#312
AmaadMartin wants to merge 9 commits into
mainfrom
fix/skills-zip-slip-and-name-guards

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    No existing issue.
  2. Or, if no issue exists, describe the change:
    Problem: loadSkillFromZipBuffer() (core/src/skills/loader.ts) is the trust boundary for Agent Skills downloaded from a remote registry — GCPSkillRegistry.getSkill() base64-decodes an API response and hands the raw bytes straight in, so the archive is authored by whoever published the skill, not by the user running the agent. It read SKILL.md and the references/ / assets/ / scripts/ trees out of the archive with no validation of member names, and never checked that the skill name was a bare path segment. The adk-python counterpart, _load_skill_from_zip_bytes in src/google/adk/skills/_utils.py, has both guards, so the identical archive was rejected by adk-python and accepted by adk-js.

entryName is not sanitised on read — verified empirically against the pinned adm-zip@0.5.17 (core/package.json): a zip written with the members ../evil.txt, /abs.txt, references/../../esc.txt round-trips through new AdmZip(buffer).getEntries() with those exact strings intact. The resource maps are keyed by relative path, so loadZipDir('references') turns a member named references/../../esc.txt into the map key ../../esc.txt, which escapes for any caller that writes Skill.resources to disk.

Solution: Port both adk-python guards into loadSkillFromZipBuffer, with the error strings copied character-for-character.

Condition (checked in this order) Message
Any member name absolute / ../-prefixed / containing /../ Dangerous zip entry ignored: <entryName>
No SKILL.md (unchanged) SKILL.md not found in zipped filesystem.
Frontmatter name missing or falsy SKILL.md frontmatter must contain 'name'
name not a string, or not a bare path segment Invalid skill name in SKILL.md: <name>

Three things worth calling out explicitly:

(a) Which strings were copied verbatim, and why one of them reads oddly. All four messages above are taken verbatim from src/google/adk/skills/_utils.py, including Dangerous zip entry ignored: — the word "ignored" is misleading (both implementations reject the entire archive rather than skipping the entry; adk-python raises at _utils.py, it does not skip-and-warn), but parity on the observable string wins over nicer prose, so it is not reworded.

(b) The name guard has to run before schema validation, which is why parseSkillMdContent was split. parseSkillMdContent() validated with zod internally and wrapped every failure as Invalid YAML in frontmatter: …, so a name of ../evil never reached a check placed after it — a guard added downstream would be unreachable dead code. It is now split into two module-private helpers mirroring the Python structure (_parse_skill_md_content returns an unvalidated dict; Frontmatter.model_validate runs after the name checks): parseFrontmatterYaml() returns the raw mapping, validateFrontmatter() applies FrontmatterSchema. parseSkillMdContent's exported signature, return value and every one of its error strings are unchanged — the five existing tests asserting them are untouched and still pass. The only behavioural addition inside it is Array.isArray(parsed) in the mapping check, needed so raw['name'] really is read off a mapping (it also matches Python's isinstance(parsed, dict)); it is thrown from inside the same try, so it still surfaces composed as Invalid YAML in frontmatter: SKILL.md frontmatter must be a YAML mapping.

(c) path.basename needs an explicit ./.. case to match pathlib. path.basename('..') === '..' whereas pathlib.Path('..').name === '', so a naive path.basename(name) === name check accepts .. where adk-python rejects it. isBareSkillName rejects . and .. explicitly. There is a test pinning exactly this (case name: ..), and it fails against the naive version — see the mutation log below.

Honest scope note on guard #2. I verified against the built schema that FrontmatterSchema already rejects every name this guard rejects (../evil, a/b, .., ., /etc/passwd, 123, missing) because SNAKE_OR_KEBAB_NAME_PATTERN admits only [a-z0-9] with single - or _. So guard #2 does not change which archives are accepted today; it changes the observable error message from a 150-character zod dump prefixed Invalid YAML in frontmatter: (which blames YAML for something that is not a YAML problem) to adk-python's string. It is kept as an independent check because that regex is a naming-style rule, not a traversal defence: if it is ever relaxed (dots for versioned names, uppercase, …) the traversal property would disappear silently. Guard #1 (zip slip) is not redundant — nothing else in the file rejects a traversal member name.

Breaking change: intentional and the point of the change. An archive whose SKILL.md name contains a path separator or is ./.. previously threw Invalid YAML in frontmatter: <zod dump> and now throws Invalid skill name in SKILL.md: <name>. No test asserted the old string. Archives with traversal member names previously loaded and now throw. Exported API surface, loadSkillFromDir, validateSkillDir, loadAllSkillsInDir and GCPSkillRegistry's signature are all unchanged; no new exports, no dependency or lockfile changes.

Collision check (run before writing any code, per pipeline policy): gh pr list --repo AmaadMartin/adk-js --state open --limit 100 returned 13 open skill-related PRs; I diffed the six that touch core/src/skills/loader.ts (#282, #262, #239, #284, #263, #242). None implements either guard, so this is not a duplicate. Three of them overlap textually: #239 and #262 remove the redundant second FrontmatterSchema.parse(...) in loadSkillFromZipBuffer (which disappears here too, as a consequence of calling validateFrontmatter(raw) directly), and #282 restructures the same try block in parseSkillMdContent. I branched from main rather than stacking because those three are mutually conflicting siblings — stacking on any one of them would be arbitrary and would pull an unrelated, unreviewed change into this security fix's base. Whichever lands first, the other diffs are a small textual rebase. I deliberately left loadSkillFile's identical double-parse alone for the same reason (it is claimed by #239/#262), and did not touch ALLOWED_FRONTMATTER_KEYS or validateSkillDir, which other siblings are editing.

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.

12 new cases in a describe('loadSkillFromZipBuffer', …) block in core/test/skills/loader_test.ts: benign archive loads all three resource maps; the three dangerous member names (/etc/passwd, ../evil.txt, references/../../esc.txt); a dangerous member in an archive with no SKILL.md (pins that the zip-slip loop runs before the SKILL.md lookup, matching _utils.py); the three non-bare names (../evil, a/b, ..); a non-string name (123); a missing name; a YAML-sequence frontmatter; and a regression guard that a benign archive without SKILL.md still reports SKILL.md not found in zipped filesystem..

Fixture note: AdmZip.addFile() canonicalises the name it is given ('../evil.txt' is stored as 'evil.txt'), so a test written the obvious way builds a benign archive and passes for the wrong reason. The helper adds a placeholder member and reassigns entryName afterwards, which round-trips through toBuffer() intact.

Commands run (targeted only, no full-suite run):

npx vitest run --project unit:core core/test/skills/loader_test.ts                     # 42 passed
npx vitest run --project unit:core core/test/tools/skills/skill_registry_test.ts       # 35 passed (untouched, still green)
npm run build                                                                          # clean
npm run docs:check                                                                     # clean (typedoc, warnings-as-errors)
npx tsc --noEmit                                                                       # no errors in either touched file
npx eslint core/src/skills/loader.ts core/test/skills/loader_test.ts                   # clean
npx prettier --check core/src/skills/loader.ts core/test/skills/loader_test.ts         # clean

Coverage: 100% line and branch coverage of the new code, measured with --coverage.include='core/src/skills/loader.ts' and checked per-line against the coverage JSON — every uncovered line in the file is pre-existing code the change does not touch (the loadDir walk, validateSkillDir's catch, and the dead catch around data.toString('utf-8') in loadZipDir). No coverage thresholds were changed and no coverage-tool suppressions were added.

Proof the tests can fail. Each new guard was mutated in core/src/skills/loader.ts and the suite re-run; every mutation was killed:

Mutation Result
Delete the zip-slip loop entirely 4 failed — e.g. rejects the whole archive for the dangerous entry ../evil.txt: expected [Function] to throw an error; and reports the dangerous entry even when SKILL.md is absent: expected … 'Dangerous zip entry ignored: ../evil.…' but got 'SKILL.md not found in zipped filesyst…' (this one also pins the guard ordering)
isBareSkillName → naive path.basename(name) === name 1 failedrejects the non-bare skill name ..: expected … 'Invalid skill name in SKILL.md: ..' but got 'Invalid YAML in frontmatter: [\n {\n…'
Delete both name guards 5 failed — all four name cases plus rejects frontmatter with no name: expected … 'SKILL.md frontmatter must contain 'n…' but got 'Invalid YAML in frontmatter: [\n {\n…'
Drop the typeof skillName !== 'string' disjunct 1 failedrejects a skill name that is not a string: expected … 'Invalid skill name in SKILL.md: 123' but got 'Invalid YAML in frontmatter: [\n {\n…'
Drop Array.isArray(parsed) from the mapping check 1 failedreports a YAML sequence as a non-mapping, not as a missing name: expected … 'Invalid YAML in frontmatter: SKILL.md…' but got 'SKILL.md frontmatter must contain 'n…'

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

No credentials or network needed. From the repo root, after npm ci && npm run build, run a scratch ESM script that imports from the public entry point:

import AdmZip from 'adm-zip';
import {loadSkillFromZipBuffer} from '@google/adk';

const skillMd =
  '---\nname: test-skill\ndescription: A test skill\n---\nInstruction body';

// (1) hostile archive: entryName must be reassigned, addFile() would sanitise it
const zip = new AdmZip();
zip.addFile('SKILL.md', Buffer.from(skillMd, 'utf-8'));
zip.addFile('placeholder.txt', Buffer.from('x', 'utf-8'));
zip.getEntries().find((e) => e.entryName === 'placeholder.txt').entryName =
  '../evil.txt';
loadSkillFromZipBuffer(zip.toBuffer()); // throws: Dangerous zip entry ignored: ../evil.txt

// (2) benign archive still loads all three resource maps
const ok = new AdmZip();
ok.addFile('SKILL.md', Buffer.from(skillMd, 'utf-8'));
ok.addFile('references/ref1.md', Buffer.from('ref content', 'utf-8'));
ok.addFile('assets/asset1.txt', Buffer.from('asset content', 'utf-8'));
ok.addFile('scripts/run.sh', Buffer.from('echo hello', 'utf-8'));
console.log(loadSkillFromZipBuffer(ok.toBuffer()).resources);

Observed output against the built package, matching adk-python's strings exactly:

throws: "Dangerous zip entry ignored: ../evil.txt"
throws: "Dangerous zip entry ignored: /etc/passwd"
throws: "Dangerous zip entry ignored: references/../../esc.txt"
throws: "Invalid skill name in SKILL.md: ../evil"
throws: "Invalid skill name in SKILL.md: a/b"
throws: "Invalid skill name in SKILL.md: .."
throws: "Invalid skill name in SKILL.md: 123"
benign: test-skill {"references":{"ref1.md":"ref content"},"assets":{"asset1.txt":"asset content"},"scripts":{"run.sh":{"src":"echo hello"}}}

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.


CI note: the first validation run failed on run-tests (macos-latest) with Error: Test timed out in 40000ms in tests/integration/app_loader/app_loader_test.ts > should discover apps vs agents across directories and standalone files (2521 passed, 1 failed), which cancelled the other two matrix legs. That is the known cold-start AgentLoader discovery flake already being addressed by open PRs on this fork (#260, #256, #247, #235) and is unrelated to this change — no skills test was involved. A re-run of the failed jobs passed on all three platforms: run-tests (ubuntu-latest) pass 5m27s, run-tests (macos-latest) pass 5m50s, run-tests (windows-latest) pass 8m1s.

AmaadMartin and others added 9 commits July 28, 2026 14:48
* docs: document minimum supported Node.js version in README

Add a short prerequisite note under the Installation section stating that
ADK for TypeScript requires Node.js 18 or newer, so new users know which
Node.js runtime they need before running npm install @google/adk.

The version reflects the mandated fallback: no engines.node field is
declared in any package.json in the repo.

* docs: reference current Node.js LTS instead of a fixed version

Node.js 18 is EOL and any hard-coded minimum version goes stale over time.
Reword the installation prerequisite to point readers at the current Node.js
LTS releases, which stays accurate without future edits.

Addresses PR review feedback on #526.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
…#536)

`ToolAuthHandler` accepted an `authCredential` and then never read it. When
no auth response was present it went straight to `requestCredential()`, so a
credential handed to `OpenAPIToolset`/`RestApiTool` at construction time was
ignored and the tool returned `{pending: true}` on every call. For `apiKey`,
`http` and `serviceAccount` schemes nothing ever resolves that request — no
user interaction is involved — so the tool could never complete.

Fall back to the configured credential when there is no auth response, which
mirrors `_get_auth_response() or self.auth_credential` in adk-python.

Also narrow what gets written to session state. The credential store exists
to avoid repeating work that either cannot be repeated (an auth response is
readable once) or is expensive (an exchange costs a round trip). A static
credential that needed no exchange is neither, so it is no longer persisted —
that would only copy the developer's secret into the session store.
…hon parity (#542)

* Feat: add LoadMcpResourceTool and MCPToolset resource access

Port adk-python's LoadMcpResourceTool to adk-js for cross-language parity.

- Add listResources/getResourceInfo/readResource to MCPToolset, following
  the existing create -> try -> closeSession-in-finally session idiom.
- Add LoadMcpResourceTool (mirrors the in-repo LoadArtifactsTool idiom):
  declares load_mcp_resource({resource_names}), and processLlmRequest injects
  resolved resource contents (text + base64 binary, no decode step) into the
  LlmRequest.
- Export the tool from core/src/index.ts (@google/adk public API).

* test: cover LoadMcpResourceTool and MCPToolset resource access

Add full unit coverage (100% line + branch of the new code):

- load_mcp_resource_tool_test.ts: init, declaration, runAsync (incl. default),
  list injection (incl. empty + swallowed list errors), text/binary/unknown
  content, base64 blob passthrough + default mime type, swallowed read errors,
  and all no-op guard paths (non-matching/absent function response, missing
  parts).
- mcp_toolset_test.ts: listResources/getResourceInfo/readResource happy paths
  and error paths (unknown name, missing URI), plus session-cleanup assertions
  for success and failure (closeSession in finally, no leaked sessions).

* test(e2e): exercise LoadMcpResourceTool against a real MCP server

Add a no-mock end-to-end test that spawns a real MCP server over stdio
(mcp_resource_server.mjs, exposing a text and a binary resource) and drives
the real MCPToolset + LoadMcpResourceTool: listing/resolving/reading resources
and injecting their contents (text + base64 binary) into an LlmRequest.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
* feat(tools): add ExampleTool for few-shot examples

Port adk-python's ExampleTool to adk-js. The tool accepts a static
Example[] or a BaseExampleProvider and, on each outgoing LLM request,
appends a few-shot <EXAMPLES> block (built via buildExampleSi from the
latest user query) to the system instruction. It is never declared to
the model (mirrors PreloadMemoryTool) and is a no-op when no user text
is present. Exported from the public @google/adk API.

* test(tools): cover ExampleTool unit and end-to-end paths

Add Vitest coverage for ExampleTool: static list and provider paths,
model-style passthrough, no-op branches (missing user content, empty
parts, text-less first part), runAsync throwing, and the public export.
Includes an end-to-end block that drives processLlmRequest through a
real Context/InvocationContext (no mocks). 100% line/branch coverage of
the new tool.

* refactor(tools): apply simplicity audit feedback

Use a constructor parameter property for `examples` (repo convention),
and drop the redundant provider end-to-end test whose only unique aspect
was a spy — keeping the no-mock e2e block strictly mock-free. The
provider selection path stays fully covered by the unit tests; the tool
retains 100% line/branch coverage.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
* feat(agents): support clone() for RoutedAgent

RoutedAgent derives its routing targets from config.agents rather than
subAgents, so the inherited BaseAgent.clone() rebuilt the agent from the
already-parented originals and threw "already has a parent agent".

Add a RoutedAgent.clone() override that deep-clones the routing targets
(via a private cloneRoutingTargets helper) and passes them through the
agents override, so super.clone() rebuilds the constructor with fresh,
detached copies that are re-parented onto the clone. The array-vs-record
shape and record keys are preserved so the clone routes identically, and
parent-override rejection plus the detached-root guarantee are still
enforced by the base implementation.

Remove the now-obsolete "documented limitation" test (and its unused
RoutedAgent import) from base_agent_test; positive coverage lives in
routed_agent_test.

* test(agents): cover RoutedAgent.clone()

Add a clone describe suite exercising the new override and the
cloneRoutingTargets helper: array and record forms, deep-clone and
re-parenting of targets, originals left untouched, functional routing on
the clone (record form), verbatim agents override, non-agents overrides,
and parentAgent-override rejection. Includes a no-mock end-to-end case
that clones a RoutedAgent whose targets are real LlmAgents.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
* Feat(tools): add SSRF-safe load_web_page tool for adk-python parity

Ports the adk-python load_web_page tool to adk-js. Fetches a URL and
returns its extracted, readable text, hardened against SSRF:

- only http/https schemes are fetched
- localhost-style hostnames and hosts resolving to non-global IPs
  (private, loopback, link-local, shared/CGNAT, reserved, multicast,
  IPv4-mapped IPv6) are rejected before any connection
- redirects are never followed (redirect: 'manual')
- a configurable timeout (default 30s) bounds every request
- expected failures return the parity string "Failed to fetch url: <url>"
  instead of throwing

Exposes loadWebPage(), the LOAD_WEB_PAGE FunctionTool, and the
LoadWebPageOptions type via the @google/adk public API.

* Refactor(tools): inline single-use failure prefix in load_web_page

Addresses simplicity-audit feedback: the FAILURE_PREFIX constant had a
single caller, so its literal is inlined into failedToFetchMessage, which
remains the sole formatter of the parity failure string.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
…n parity) (#525)

* feat(tools): add EnterpriseWebSearchTool for Gemini web grounding

Ports adk-python's EnterpriseWebSearchTool to adk-js, closing a
cross-language parity gap. The tool is a Gemini 2+ built-in grounding
source that appends {enterpriseWebSearch: {}} to the outgoing LlmRequest
config; it performs no client-side execution. Mirrors the
google_maps_grounding_tool idiom (extracted applyEnterpriseWebSearch
function + ADK_DISABLE_GEMINI_MODEL_ID_CHECK escape hatch). Exported
from the public API via common.ts.

* test(tools): add unit tests for EnterpriseWebSearchTool

Covers every branch of applyEnterpriseWebSearch (100% line/branch):
model-unset guard, Gemini 2+ (plain + path form), config
initialization, Gemini 1.x with/without other tools, non-Gemini
rejection, and the ADK_DISABLE_GEMINI_MODEL_ID_CHECK escape hatch, plus
the runAsync no-op and the exported singleton. Mock-free; imports via
the @google/adk public entry point.

---------

Co-authored-by: Amaad Martin <amaadmartin@google.com>
…loading zipped skills

loadSkillFromZipBuffer read SKILL.md and the references/assets/scripts
trees straight out of an attacker-authored archive with no validation of
member names, and never checked that the skill name was a bare path
segment. adk-python's _load_skill_from_zip_bytes has both guards, so the
same archive was rejected there and accepted here.

Port both guards with the error strings copied verbatim from
src/google/adk/skills/_utils.py. The name guard has to run on the raw
YAML mapping before schema validation, so parseSkillMdContent is split
into parseFrontmatterYaml + validateFrontmatter; its own signature and
error strings are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants