feat(#3996): gate workspace-escaping generated-media paths behind user confirmation - #4026
feat(#3996): gate workspace-escaping generated-media paths behind user confirmation#4026aheritier wants to merge 1 commit into
Conversation
9b6e49d to
8e76a13
Compare
8e76a13 to
871dd1e
Compare
e76fa5b to
0fe2b64
Compare
0fe2b64 to
d6b3c12
Compare
d6b3c12 to
9a926c1
Compare
426aea6 to
9a926c1
Compare
9a926c1 to
c442c63
Compare
…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.
c442c63 to
161aaa6
Compare
aheritier
left a comment
There was a problem hiding this comment.
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.
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 testRequest 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.