Skip to content

deploy-local persists an archive whose .mcp.json failed validation, and the create path consumes it #2006

Description

@AndriiPasternak31

Summary

deploy-local validates an archive-supplied .mcp.json after it has already copied the archive into /data/deployed-templates/<version_name>/ — a live member of _LOCAL_TEMPLATE_ROOTS. The 400 aborts the deploy but nothing removes the directory, so a config the validator refused stays on disk and is reachable by a subsequent POST /api/agents {"template": "local:<version_name>"}.

ent#213 closed the workspace ingress (_prepopulate_workspace_from_template). This is the template-directory ingress, which the same guard sits below.

The ordering

line services/agent_service/deploy.py
:534 shutil.copytree(extract_root, dest_path)/data/deployed-templates/<version_name>/
:566-571 _validate_archive_mcp_config(...) raises 400, 32 lines later
:645-649 finally removes temp_dir only — dest_path is untouched (the only other rmtree, :532, is the pre-copy overwrite)

crud.py:150-153_LOCAL_TEMPLATE_ROOTS includes Path("/data/deployed-templates").
deploy.py:550 — the deploy path's own create call uses template=f"local:{version_name}", i.e. exactly the id that resolves to the residue.
validate_mcp_config has three call sites (routers/credentials.py:331, credential_encryption.py:71, deploy.py:74) — none on the create path.

version_name is derived from the caller's own base_name via get_next_version_name, so the residue is addressable by the person who created it.

Reproduction (executed against a real backend container, not inferred)

Baseline: /data/deployed-templates did not exist.

validator verdict      : REJECTED (Server 'ebook-mcp': args[1] contains shell metacharacters)
deploy result          : HTTP 400
deploy detail          : Invalid .mcp.json in archive: ...
RESIDUE dir exists     : True
RESIDUE contents       : ['.mcp.json', 'CLAUDE.md', 'template.yaml']
_LOCAL_TEMPLATE_ROOTS  : ['/agent-configs/templates', '/data/deployed-templates']
residue inside a root  : True   -> local:resid-probe-1929 resolves here

The rejected .mcp.json was on disk verbatim, including the ${LD_PRELOAD} reserved env-ref the validator had just refused.

Consuming it (no container created — resolver + credential writer only):

local:resid-probe-1929 resolves to : /data/deployed-templates/resid-probe-1929
generate_credential_files OUTPUT   : args ['--directory', '', 'run', 'ebook-mcp']
                                     env  {'LD_PRELOAD_REF': ''}
validator on OUTPUT                : ACCEPTED

Two things worth noting from that last line:

  1. The create path launders the file. generate_credential_files: .mcp.json placeholder handling contradicts the platform validator (${VAR:-default} blanked, command unsubstituted, output never validated) #1929's ${VAR:-default}"" blanking removed the very characters the validator rejected, so the generated config passes cleanly. Any fix of the shape "validate the generated output" would go green on this case.
  2. Configs rejected for reasons substitution does not touch pass through verbatim — unknown top-level field, trinity redefined as stdio, oversize. And _stage_config_files mounts the whole template directory at /template, so startup.sh copies the raw file into the agent home regardless of what the credential writer does.

Severity

Within the deploying creator's own trust boundary — same framing ent#213 itself used ("defense-in-depth / consistency, not critical RCE"). Filing P2, not as a security escalation. What it costs is the validation guarantee: a creator can get an arbitrary rejected .mcp.json onto an agent, and Claude Code auto-loads ~/.mcp.json via --mcp-config on the next execution.

Suggested fix

Validate before copytree (the guard only needs the extracted extract_root / ".mcp.json", which exists at that point), or remove dest_path on any failure after the copy. The first is preferable — a guard that runs after the persist is not a gate.

Acceptance criteria

  • A deploy whose .mcp.json fails validation leaves no directory under /data/deployed-templates/
  • local:<version_name> for a rejected deploy resolves to nothing (create returns the normal not-found path)
  • A valid deploy is unaffected — same behaviour, same 200
  • Regression test exercising the ordering, not the guard in isolation. ent#213's suite tests _validate_archive_mcp_config directly ("the full deploy flow needs Docker" — test_213_deploy_mcp_validation.py:17-19), which is why this ordering was never covered. The repro above mocks only the docker/db lookups preceding the window and runs the real flow, so it works without Docker.

Related

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions