Summary
Agent safe-outputs are NDJSON validated by a trusted job before they are
applied. The validator spreads the raw agent item and validates only the
schema-declared fields, so any field the agent adds that the validator does not
declare is forwarded to the applier verbatim. Wherever an applier reads such a
field, the agent controls it. This is a mass-assignment class that yields several
scope escapes, including overwriting another user's comment and retargeting or
closing arbitrary pull requests.
I verified this against current main at commit 966ccb8 (2026-07-12). It was
first verified at bdfdb8e (2026-06-24, ~v0.80.9); the safe-outputs refactors in
between changed only Go config-parsing modules and did not alter the field
handling.
Details
In validateItem, the normalized item is built as
const normalizedItem = { ...item }
(actions/setup/js/safe_output_type_validator.cjs:680), which spreads the raw
agent item. The validation loop then iterates only the schema-declared fields
(:692-707) and returns the spread item unchanged for everything else (:713).
Undeclared fields are not stripped; actions/setup/js/collect_ndjson_output.cjs
pushes the item (:330) and writes it to the agent output file (:344) with the
extra fields intact.
Confirmed instances (applier reads a field absent from the validator schema in
pkg/workflow/safe_outputs_validation_config.go):
add_comment -> comment_id. Declared on the MCP tool but absent from the
validator config; reaches issues.updateComment in
actions/setup/js/add_comment.cjs with a positive-integer check and no
ownership check, so a steered agent overwrites any comment by id.
Tool-reachable on any engine because the tool schema exposes comment_id.
update_pull_request -> base, state. Reaches pulls.update in
actions/setup/js/update_pull_request.cjs with no allowed_base_branches gate
on the update path, so the agent retargets a PR's base branch or closes
arbitrary PRs.
upload_asset -> targetFileName. Used as the destination of
fs.copyFileSync with no containment
(actions/setup/js/upload_assets.cjs:131,163), giving a runner filesystem
write. Writing into .git/hooks or .git/config escalates to code execution
in the write-token posting job.
create_issue -> assignees; create_discussion -> labels;
close_issue -> state_reason; push_to_pull_request_branch -> diff_size
(forging 0 skips the max_patch_size guard). All via the raw-NDJSON path.
PoC
For the add_comment case I set comment_id on the NDJSON item and overwrote
another user's comment by id on my own repo, then reverted. For
update_pull_request I retargeted a PR base branch on my own repo, then
reverted. The remaining instances were confirmed by tracing each applier against
the validator config.
Impact
A confused-deputy family: the agent influences state that the safe-output
contract was meant to gate (comment integrity, PR base branch, PR state, runner
filesystem). One fix closes the class.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:H/A:L (High). Suggested vector; adjust per
instance.
Suggested fix
In validateItem, build normalizedItem from the type's declared schema fields
only, instead of spreading { ...item }.
Summary
Agent safe-outputs are NDJSON validated by a trusted job before they are
applied. The validator spreads the raw agent item and validates only the
schema-declared fields, so any field the agent adds that the validator does not
declare is forwarded to the applier verbatim. Wherever an applier reads such a
field, the agent controls it. This is a mass-assignment class that yields several
scope escapes, including overwriting another user's comment and retargeting or
closing arbitrary pull requests.
I verified this against current
mainat commit966ccb8(2026-07-12). It wasfirst verified at
bdfdb8e(2026-06-24, ~v0.80.9); the safe-outputs refactors inbetween changed only Go config-parsing modules and did not alter the field
handling.
Details
In
validateItem, the normalized item is built asconst normalizedItem = { ...item }(
actions/setup/js/safe_output_type_validator.cjs:680), which spreads the rawagent item. The validation loop then iterates only the schema-declared fields
(
:692-707) and returns the spread item unchanged for everything else (:713).Undeclared fields are not stripped;
actions/setup/js/collect_ndjson_output.cjspushes the item (
:330) and writes it to the agent output file (:344) with theextra fields intact.
Confirmed instances (applier reads a field absent from the validator schema in
pkg/workflow/safe_outputs_validation_config.go):add_comment->comment_id. Declared on the MCP tool but absent from thevalidator config; reaches
issues.updateCommentinactions/setup/js/add_comment.cjswith a positive-integer check and noownership check, so a steered agent overwrites any comment by id.
Tool-reachable on any engine because the tool schema exposes
comment_id.update_pull_request->base,state. Reachespulls.updateinactions/setup/js/update_pull_request.cjswith noallowed_base_branchesgateon the update path, so the agent retargets a PR's base branch or closes
arbitrary PRs.
upload_asset->targetFileName. Used as the destination offs.copyFileSyncwith no containment(
actions/setup/js/upload_assets.cjs:131,163), giving a runner filesystemwrite. Writing into
.git/hooksor.git/configescalates to code executionin the write-token posting job.
create_issue->assignees;create_discussion->labels;close_issue->state_reason;push_to_pull_request_branch->diff_size(forging
0skips themax_patch_sizeguard). All via the raw-NDJSON path.PoC
For the
add_commentcase I setcomment_idon the NDJSON item and overwroteanother user's comment by id on my own repo, then reverted. For
update_pull_requestI retargeted a PR base branch on my own repo, thenreverted. The remaining instances were confirmed by tracing each applier against
the validator config.
Impact
A confused-deputy family: the agent influences state that the safe-output
contract was meant to gate (comment integrity, PR base branch, PR state, runner
filesystem). One fix closes the class.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:H/A:L (High). Suggested vector; adjust per
instance.
Suggested fix
In
validateItem, buildnormalizedItemfrom the type's declared schema fieldsonly, instead of spreading
{ ...item }.