What happened
Since 1.4.1, agents-cli scaffold create --agent <remote git URL> silently drops every
symlink in the template source. A template that uses an intra-repo symlink to share a
library across several template variants scaffolds "successfully" into a project that is
missing that library and fails on import.
This is a regression: 1.4.0 copied a symlinked directory as real files.
The guard is copy_files.should_skip() in
google/agents/cli/scaffold/utils/template.py:
# Never follow symlinks from untrusted remote template sources
if path.is_symlink():
logging.warning(
f"Skipping symlink in template source (symlinks are not allowed): {path}"
)
return True
I understand the CWE-59 motivation (a template shipping id_rsa -> ~/.ssh/id_rsa). The
problem is that the check rejects all symlinks, including ones whose target never leaves
the cloned repository, and it does so without failing the command.
Reproduction
A repo laid out like this, pushed to GitHub, with the template at template/:
myrepo/
├── shared/ # single source of truth
│ └── config.py
└── template/
├── agents-cli-manifest.yaml
└── app/
├── agent.py # `from .shared.config import ...`
└── shared -> ../../shared # committed as a symlink (git mode 120000)
agents-cli scaffold create demo \
--agent https://github.com/<org>/myrepo/template@main --prototype
Expected: demo/app/shared/config.py exists (this is what 1.4.0 did).
Actual: demo/app/shared/ does not exist. The command prints
✅ Success! Your agent project is ready. and the project fails at import.
Version bisect
I ran each release's own copy_files over the same template directory:
| Version |
app/shared/ in output |
| 1.0.0 / 1.2.0 / 1.3.0 / 1.4.0 |
present (symlink dereferenced) |
| 1.4.1 / 1.5.0 |
missing |
The Never follow symlinks from untrusted remote string first appears in 1.4.1.
Two things make this hard to notice
- It is a warning, not an error. Scaffolding reports success, and the failure only
shows up later as an ImportError far from its cause.
local@ does not exercise the same path. agents-cli scaffold create --agent local@<path> pre-copies the template with
shutil.copytree(local_path, template_source_path, ignore=...)
(scaffold/commands/create.py), and copytree defaults to symlinks=False, i.e. it
dereferences. So a template author's CI that validates with local@ — which the docs
present as the way to test a template locally — passes, while every real user of the
published URL gets a broken project. Our CI has been green this whole time.
Why this matters for multi-variant templates
We maintain an internal ADK template with several variants (a default ReAct agent, an A2UI
variant, a multi-agent GKE variant). All of them share one library — DI wiring for model
tiers, Secret Manager, artifact storage, logging/tracing — plus one ruff.toml and one
ty.toml.
The scaffolder only copies the directory --agent points at, so the only way to share
code across variants without duplicating it is an intra-repo symlink from each variant
into the shared directory. That is exactly what 1.4.1 broke. This is the same use case as
#62, which we're otherwise happy with.
The workaround is to vendor N physical copies of the shared library into the repo (one per
variant) and add a sync script plus a CI drift check. That works, but it means the "single
source of truth" only exists by convention, and every shared-code review carries N
mechanical duplicate diffs.
Suggested fixes, in order of preference
-
Allow a symlink whose resolved target stays inside the fetched repository.
Path.resolve() the link and require it to be under the clone root, skipping it
otherwise. That preserves the CWE-59 protection completely — an escaping link is still
refused — while letting a template share code within its own repo.
One detail: the containment check needs to be against the cloned repo root, not the
template subdirectory. A shared library naturally sits at the repo root while the
template is a subdirectory, so the link legitimately points "up" out of the template
dir but never out of the repo.
-
An opt-in in the template manifest, e.g. settings.follow_symlinks: true, if you'd
rather the decision be explicit per template.
-
At minimum, fail loudly. If symlinks stay banned, please make the command error out
(or print to stderr and list every dropped path in the summary) instead of logging a
warning and reporting success. Silently producing a broken project is the worst
outcome. It would also help to have local@ skip symlinks the same way remote fetches
do, so a template author's local validation reflects what users will actually get.
Environment
- agents-cli 1.5.0 (and 1.4.1); last working: 1.4.0
- Linux, Python 3.13 / 3.14
What happened
Since 1.4.1,
agents-cli scaffold create --agent <remote git URL>silently drops everysymlink in the template source. A template that uses an intra-repo symlink to share a
library across several template variants scaffolds "successfully" into a project that is
missing that library and fails on import.
This is a regression: 1.4.0 copied a symlinked directory as real files.
The guard is
copy_files.should_skip()ingoogle/agents/cli/scaffold/utils/template.py:I understand the CWE-59 motivation (a template shipping
id_rsa -> ~/.ssh/id_rsa). Theproblem is that the check rejects all symlinks, including ones whose target never leaves
the cloned repository, and it does so without failing the command.
Reproduction
A repo laid out like this, pushed to GitHub, with the template at
template/:Expected:
demo/app/shared/config.pyexists (this is what 1.4.0 did).Actual:
demo/app/shared/does not exist. The command prints✅ Success! Your agent project is ready.and the project fails at import.Version bisect
I ran each release's own
copy_filesover the same template directory:app/shared/in outputThe
Never follow symlinks from untrusted remotestring first appears in 1.4.1.Two things make this hard to notice
shows up later as an
ImportErrorfar from its cause.local@does not exercise the same path.agents-cli scaffold create --agent local@<path>pre-copies the template withshutil.copytree(local_path, template_source_path, ignore=...)(
scaffold/commands/create.py), andcopytreedefaults tosymlinks=False, i.e. itdereferences. So a template author's CI that validates with
local@— which the docspresent as the way to test a template locally — passes, while every real user of the
published URL gets a broken project. Our CI has been green this whole time.
Why this matters for multi-variant templates
We maintain an internal ADK template with several variants (a default ReAct agent, an A2UI
variant, a multi-agent GKE variant). All of them share one library — DI wiring for model
tiers, Secret Manager, artifact storage, logging/tracing — plus one
ruff.tomland onety.toml.The scaffolder only copies the directory
--agentpoints at, so the only way to sharecode across variants without duplicating it is an intra-repo symlink from each variant
into the shared directory. That is exactly what 1.4.1 broke. This is the same use case as
#62, which we're otherwise happy with.
The workaround is to vendor N physical copies of the shared library into the repo (one per
variant) and add a sync script plus a CI drift check. That works, but it means the "single
source of truth" only exists by convention, and every shared-code review carries N
mechanical duplicate diffs.
Suggested fixes, in order of preference
Allow a symlink whose resolved target stays inside the fetched repository.
Path.resolve()the link and require it to be under the clone root, skipping itotherwise. That preserves the CWE-59 protection completely — an escaping link is still
refused — while letting a template share code within its own repo.
One detail: the containment check needs to be against the cloned repo root, not the
template subdirectory. A shared library naturally sits at the repo root while the
template is a subdirectory, so the link legitimately points "up" out of the template
dir but never out of the repo.
An opt-in in the template manifest, e.g.
settings.follow_symlinks: true, if you'drather the decision be explicit per template.
At minimum, fail loudly. If symlinks stay banned, please make the command error out
(or print to stderr and list every dropped path in the summary) instead of logging a
warning and reporting success. Silently producing a broken project is the worst
outcome. It would also help to have
local@skip symlinks the same way remote fetchesdo, so a template author's local validation reflects what users will actually get.
Environment