Skip to content

feat(#3996): gate workspace-escaping generated-media paths behind user confirmation - #4026

Draft
aheritier wants to merge 1 commit into
workspace-media-materializationfrom
workspace-media-escape-confirm
Draft

feat(#3996): gate workspace-escaping generated-media paths behind user confirmation#4026
aheritier wants to merge 1 commit into
workspace-media-materializationfrom
workspace-media-escape-confirm

Conversation

@aheritier

@aheritier aheritier commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

What

Requires user confirmation before generated-media paths escape the workspace, including classification, CLI/runtime flow, session manifests, and TUI schema coverage.

Why

Prevents generated files from silently writing outside the configured workspace.

Validation

task test; workspace-media, runtime escape, CLI, session, and TUI tests.

Test instructions

Run:

task test

Request generated-media output whose destination is outside the configured workspace. Expected: the operation asks for confirmation; approving writes to the requested destination, while denying it performs no outside-workspace write and reports the denial safely.

@aheritier aheritier added area/cli CLI commands, flags, output formatting area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/sessions For features/issues/fixes related to session lifecycle (resume, persistence, export) area/tui For features/issues/fixes related to the TUI kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Aug 22, 2026
@aheritier
aheritier force-pushed the workspace-media-escape-confirm branch from 9b6e49d to 8e76a13 Compare August 24, 2026 15:13
@aheritier
aheritier force-pushed the workspace-media-escape-confirm branch from 8e76a13 to 871dd1e Compare August 25, 2026 08:00
@aheritier
aheritier force-pushed the workspace-media-escape-confirm branch 2 times, most recently from e76fa5b to 0fe2b64 Compare August 25, 2026 08:32
@aheritier
aheritier force-pushed the workspace-media-escape-confirm branch from 0fe2b64 to d6b3c12 Compare August 25, 2026 20:24
@aheritier
aheritier force-pushed the workspace-media-escape-confirm branch from d6b3c12 to 9a926c1 Compare August 26, 2026 11:09
@aheritier
aheritier force-pushed the workspace-media-escape-confirm branch 2 times, most recently from 426aea6 to 9a926c1 Compare September 1, 2026 16:10
@aheritier
aheritier force-pushed the workspace-media-escape-confirm branch from 9a926c1 to c442c63 Compare September 1, 2026 16:30
…r confirmation

A prompt-directed save target that is absolute, traverses above the
workspace via "..", or is "~"-rooted is now refused by default and raised
as a runtime-native elicitation (same waiter registry and
ResumeElicitation plumbing as MCP elicitations, so every existing
embedder surface can answer it). Confirmation is a schema-backed form
(MediaEscapeDecisionSchema) whose safe "keep it in the workspace" choice
is listed first and therefore the default selection: a bare Enter/submit
never authorizes the write, and the runtime independently verifies the
submitted value so a permissive client accepting with empty or free-form
content cannot either.

On an explicit affirmative choice the bytes are written to the confirmed
external target with the same O_EXCL/dash-suffix/atomic mechanics as
workspace writes, persisted as ArtifactRootExternal with the confirmed
absolute path, and manifest-gated like any workspace file. A target that
resolves to an existing directory means "save inside it": the generated
filename (MIME-corrected extension included) is appended before the user
confirms, so the confirmed path is always the exact file written; a
directory appearing at the confirmed path after confirmation is rejected
outright rather than dash-suffixed into an unconfirmed sibling.

On decline/cancel or non-interactive/headless surfaces the bytes are
redirected to the workspace root under the sanitized basename with a
sanitized warning - already-generated bytes are never discarded, and no
requested path, raw error, or reference internals leak into warnings.
The non-JSON CLI declines the form (it has no form UI) but keeps
draining the event stream, so the redirect warning and the assistant
response still arrive and the turn persists.

chat.MediaDelta.RequestedPath plus workspacemedia.ClassifyRequestedPath
are the internal hooks for the upcoming response-marker extraction; no
marker parsing ships in this slice, and provider display names keep the
existing generic-name fallback.
@aheritier
aheritier force-pushed the workspace-media-escape-confirm branch from c442c63 to 161aaa6 Compare September 1, 2026 19:58

@aheritier aheritier left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the three-dot delta of this PR only (base workspace-media-materialization @ 41c56eb), at head 161aaa6f0. CI is green on that SHA (13/13 check-runs, incl. windows-tests — the #4025 pkg/hooks flake did not recur). Re-validated locally in a throwaway extract: go build ./..., GOOS=windows go build ./..., go test -count=1 -race on pkg/{runtime,workspacemedia,session,cli,chat,tui/dialog}/..., golangci-lint — all green, 0 issues. COMMENT rather than approve because the PR is still a draft.

Strong slice. The confirmation is fail-closed by construction rather than by convention: the elicitation carries an enum schema with the safe choice first (pkg/runtime/media_escape.go:141-176) and the runtime independently requires the exact affirmative value (:206-211), so accept-with-empty/free-form/other content cannot authorize a write. The TUI side is pinned against the real dialog (pkg/tui/dialog/media_escape_schema_test.go:38-74), not a mock. Directory targets are pre-resolved to the final filename before the prompt (:240-242) and a directory appearing at the confirmed path afterwards is refused outright (pkg/workspacemedia/external.go:56-58). The CLI now declines without abandoning the stream (pkg/cli/runner.go:248-258), proven on an unbuffered stream (runner_test.go:607-654). I mutation-tested the suite (go test -overlay): dropping the content check, the directory pre-resolution, or the CLI continue each fails tests.

[should-fix] A2A and ACP have no fail-safe — the confirmation parks the turn. pkg/a2a/adapter.go:113-123 and pkg/acp/agent.go:140-151 construct the runtime without runtime.WithNonInteractive (contrast pkg/mcp/server.go:275), neither package references elicitation at all, and both consume rt.RunStream directly (adapter.go:131, agent.go:607) without ever calling ResumeElicitation. So hasElicitationSink() is false, nothing answers the event, and tools.InteractivePromptsAllowed defaults true (pkg/tools/interactive.go:38-41) — neither fast-decline at pkg/runtime/elicitation.go:498 nor :523 fires, and confirmMediaEscape blocks in the select at :597-611 until the client cancels. The commit message's "non-interactive/headless surfaces the bytes are redirected" is not true for these two. Latent today (nothing populates chat.MediaDelta.RequestedPath at this head — only loop.go:1397 reads it), so it becomes reachable when marker extraction lands in #4027; please fix by then. A bounded wait that falls into the existing redirect path is probably the cheapest option — note that a plain !hasElicitationSink() ⇒ decline would break the CLI, which answers off the stream.

[should-fix] The symlink-parent escape branch is untested. media_escape.go:66-71 handles the one escape lexical classification cannot see (a contained path whose parent symlinks outside the root). Deleting that branch under -overlay leaves the whole ./pkg/runtime suite green; materialize_generated_media_test.go:387-391 only covers the provider-named retry. Worth a test: workspace root with link -> <outside>, RequestedPath: "link/cat.png" ⇒ redirect + warning, nothing written outside.

[optional] A Windows-style absolute path on a Unix host is classified PathEscaping (classify_test.go:24) but externalMediaTarget:226-238 anchors it under the workspace root, so the prompt says "OUTSIDE the workspace" for <root>/C:/abs/cat.png. Exact path shown, so nothing unsafe — just misleading wording.

[optional] A collision silently writes a name the user did not confirm: confirming …/cat.png when it exists writes …/cat-1.png with no notice (external_test.go:30-46, media_escape_test.go:133-155, which asserts no warnings). Never overwriting is right; a bounded notice when the final path differs from the confirmed one would preserve the "what you confirmed is what was written" promise.

[optional] generatedDocumentName:254-259 uses filepath.Base of the confirmed path instead of chat.SanitizeDisplayName, so an external item's display name can carry </> and exceed MaxSanitizedFieldBytes (pkg/chat/display_name.go:49-72), unlike every other display name.

Checked and fine: accept-then-write-failure drops the item with a sanitized warning (no path/raw-error leak) consistent with the existing per-item contract; the manifest add boundary rejects mis-rooted records and unknown roots, and unrecorded absolute lookups still fail closed (generated_media_manifest_test.go:114-180); migration 29's missing DownSQL is not a deviation (11/29 lack one, and DownSQL is never executed).

Cross-PR note: this PR's external write path has no per-item size cap either — same gap already raised on #4025, not re-raised here. When the blob round lands, root_kind needs to renumber 29 → 30 with the ID, zero-padded name, pinned digest and sequential test moving in lockstep.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands, flags, output formatting area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/sessions For features/issues/fixes related to session lifecycle (resume, persistence, export) area/tui For features/issues/fixes related to the TUI kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant