From 9b6e49de77bf953935c726fe2f8c0e0fa23860f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Fri, 31 Jul 2026 17:59:44 +0200 Subject: [PATCH] feat(#3996): gate workspace-escaping generated-media paths behind user 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. --- pkg/chat/document.go | 12 + pkg/chat/media.go | 10 + pkg/cli/runner.go | 9 +- pkg/cli/runner_test.go | 58 ++- pkg/runtime/elicitation.go | 65 ++- pkg/runtime/loop.go | 62 +-- pkg/runtime/media_escape.go | 273 ++++++++++++ pkg/runtime/media_escape_test.go | 419 +++++++++++++++++++ pkg/session/generated_media_manifest.go | 120 ++++-- pkg/session/generated_media_manifest_test.go | 79 +++- pkg/session/migrations.go | 6 + pkg/session/migrations_pinned_test.go | 2 +- pkg/tui/dialog/media_escape_schema_test.go | 72 ++++ pkg/workspacemedia/classify.go | 82 ++++ pkg/workspacemedia/classify_test.go | 66 +++ pkg/workspacemedia/external.go | 80 ++++ pkg/workspacemedia/external_test.go | 103 +++++ pkg/workspacemedia/writer.go | 20 +- pkg/workspacemedia/writer_test.go | 20 + 19 files changed, 1482 insertions(+), 76 deletions(-) create mode 100644 pkg/runtime/media_escape.go create mode 100644 pkg/runtime/media_escape_test.go create mode 100644 pkg/tui/dialog/media_escape_schema_test.go create mode 100644 pkg/workspacemedia/classify.go create mode 100644 pkg/workspacemedia/classify_test.go create mode 100644 pkg/workspacemedia/external.go create mode 100644 pkg/workspacemedia/external_test.go diff --git a/pkg/chat/document.go b/pkg/chat/document.go index 9f822eec70..0272775f07 100644 --- a/pkg/chat/document.go +++ b/pkg/chat/document.go @@ -21,6 +21,15 @@ type ArtifactRootKind string // reference is never resolved and surfaces as unavailable. const ArtifactRootWorkspace ArtifactRootKind = "workspace" +// ArtifactRootExternal means ArtifactPath is the ABSOLUTE path of a target +// OUTSIDE the workspace that the user explicitly confirmed via a runtime +// elicitation (see pkg/runtime's generated-media escape confirmation). It is +// the one root kind whose path is not workspace-relative. Resolution must +// still verify the (owner session, path) pair against the generated-media +// manifest — an external reference in tampered session JSON selects nothing +// without a matching manifest record. +const ArtifactRootExternal ArtifactRootKind = "external" + // DocumentSource holds the actual content of a document. Exactly one of the // fields should be set. type DocumentSource struct { @@ -44,6 +53,9 @@ type DocumentSource struct { // resolution must also verify the (owner session, path) pair against // the generated-media manifest (session.GeneratedMediaManifest), // which only materialization writes. + // - ArtifactRootExternal: the ABSOLUTE user-confirmed path, exactly as + // returned by workspacemedia.WriteExternal, and equally gated on a + // matching manifest record. // - empty: the root is unknown — never resolved; the part surfaces // as unavailable. ArtifactPath string `json:"artifact_path,omitempty"` diff --git a/pkg/chat/media.go b/pkg/chat/media.go index 0ea0c48f99..f09859061f 100644 --- a/pkg/chat/media.go +++ b/pkg/chat/media.go @@ -23,6 +23,16 @@ type MediaDelta struct { // runtime accumulator synthesizes one when needed. Name string `json:"name,omitempty"` + // RequestedPath is the prompt-directed target path the model asked for + // (e.g. echoed from an "as sunshine.jpg" instruction), when one exists. + // It is untrusted model input: the runtime routes it through + // workspacemedia.ClassifyRequestedPath, and a path escaping the workspace + // requires an explicit user confirmation before it is honored. Response + // marker extraction (the "[media-file: ...]" protocol) will populate it; + // until that lands, providers leave it empty and materialization falls + // back to Name. + RequestedPath string `json:"requested_path,omitempty"` + // Size is the byte length of Data, cached because Data itself is // dropped once the artifact is materialized. Size int64 `json:"size,omitempty"` diff --git a/pkg/cli/runner.go b/pkg/cli/runner.go index e730fb2df9..3ac62b8a6a 100644 --- a/pkg/cli/runner.go +++ b/pkg/cli/runner.go @@ -248,9 +248,14 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess case *runtime.ElicitationRequestEvent: serverURL, ok := e.Meta["docker-agent/server_url"].(string) if !ok || serverURL == "" { - slog.WarnContext(ctx, "Skipping elicitation: missing or invalid server_url (non-interactive session?)") + // Non-OAuth elicitation (e.g. an MCP form or the runtime's + // workspace-escape confirmation): the CLI has no form UI, so + // decline — but keep draining the stream, otherwise the + // runtime blocks emitting the follow-up events (redirect + // warning, assistant response) and the turn never persists. + slog.WarnContext(ctx, "Declining elicitation without form support in CLI mode", "message", e.Message) _ = rt.ResumeElicitation(ctx, "decline", nil, e.ElicitationID) - return nil + continue } result := out.PromptOAuthAuthorization(ctx, serverURL) diff --git a/pkg/cli/runner_test.go b/pkg/cli/runner_test.go index dc3c850310..30fe14e5c0 100644 --- a/pkg/cli/runner_test.go +++ b/pkg/cli/runner_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "testing" + "time" "gotest.tools/v3/assert" @@ -33,6 +34,10 @@ func TestMain(m *testing.M) { // It emits pre-configured events from RunStream and records Resume calls. type mockRuntime struct { events []runtime.Event + // runStreamFn, when set, replaces the default pre-buffered RunStream — + // used to model a live runtime that only makes progress while the + // consumer keeps draining. + runStreamFn func(context.Context, *session.Session) <-chan runtime.Event mu sync.Mutex resumes []runtime.ResumeRequest @@ -127,7 +132,10 @@ func (m *mockRuntime) Resume(_ context.Context, req runtime.ResumeRequest) { m.resumes = append(m.resumes, req) } -func (m *mockRuntime) RunStream(_ context.Context, _ *session.Session) <-chan runtime.Event { +func (m *mockRuntime) RunStream(ctx context.Context, sess *session.Session) <-chan runtime.Event { + if m.runStreamFn != nil { + return m.runStreamFn(ctx, sess) + } ch := make(chan runtime.Event, len(m.events)) for _, e := range m.events { ch <- e @@ -578,3 +586,51 @@ func TestErrorEventReturnedNotPrinted(t *testing.T) { assert.Equal(t, errors.As(err, &runtimeErr), true) assert.Equal(t, strings.Contains(buf.String(), "model failed"), false) } + +// A non-OAuth elicitation (e.g. the runtime's workspace-escape +// confirmation) must be declined in CLI mode WITHOUT abandoning the event +// stream: the runtime only makes progress while the consumer drains, so +// returning early would stall the follow-up events (redirect warning, +// assistant response) and lose the turn. The unbuffered stream below makes +// the test fail (bounded, not wedged) if Run stops consuming after the +// decline. +func TestNonOAuthElicitationDeclinedAndStreamDrained(t *testing.T) { + t.Parallel() + + drained := make(chan struct{}) + rt := &mockRuntime{ + runStreamFn: func(context.Context, *session.Session) <-chan runtime.Event { + ch := make(chan runtime.Event) // unbuffered: every send needs a live consumer + go func() { + defer close(ch) + defer close(drained) + ch <- &runtime.ElicitationRequestEvent{Type: "elicitation_request", Message: "Save outside the workspace?"} + ch <- runtime.Warning("Requested save location for generated media item 1/1 is outside the workspace and was not confirmed; saved as cat.png in the workspace instead", "test") + ch <- runtime.AgentChoice("test", "sess", "Saved your image.") + }() + return ch + }, + } + + var buf bytes.Buffer + out := NewPrinter(&buf) + sess := session.New() + + err := Run(t.Context(), out, Config{}, rt, sess, []string{"hello"}) + assert.NilError(t, err) + + select { + case <-drained: + case <-time.After(10 * time.Second): + t.Fatal("the CLI stopped draining the stream after declining the elicitation") + } + + rt.mu.Lock() + defer rt.mu.Unlock() + assert.Equal(t, rt.elicitationDeclines, 1) + assert.Equal(t, rt.elicitationLastAction, tools.ElicitationAction("decline")) + assert.Check(t, strings.Contains(buf.String(), "saved as cat.png in the workspace instead"), + "the redirect warning must be surfaced: %q", buf.String()) + assert.Check(t, strings.Contains(buf.String(), "Saved your image."), + "the assistant response must still be printed: %q", buf.String()) +} diff --git a/pkg/runtime/elicitation.go b/pkg/runtime/elicitation.go index 85bb1ee0e2..c1c63a2f36 100644 --- a/pkg/runtime/elicitation.go +++ b/pkg/runtime/elicitation.go @@ -455,21 +455,63 @@ func backgroundElicitationDeclinedNote(message string) string { ) } +// elicitationSpec describes one elicitation request independently of its +// origin: an MCP server (see elicitationHandler) or the runtime itself +// (e.g. the generated-media workspace-escape confirmation in +// media_escape.go). Both are answered through the same waiter registry and +// ResumeElicitation plumbing. +type elicitationSpec struct { + message string + mode string + schema any + url string + // serverElicitationID is the originating MCP server's wire ID, if any. + // Informational only — never a routing key (#3584 review item 2a). + serverElicitationID string + meta map[string]any + // agentName and sessionID override the runtime-derived defaults (the + // shared current-agent slot and the ctx conversation ID) when the caller + // knows the owning agent/session more precisely. + agentName string + sessionID string +} + // elicitationHandler is the MCP-toolset-side hook that turns an inbound // elicitation request from a server into an ElicitationRequest event and // waits for the embedder's response, correlated by elicitation ID. func (r *LocalRuntime) elicitationHandler(ctx context.Context, req *mcp.ElicitParams) (tools.ElicitationResult, error) { slog.DebugContext(ctx, "Elicitation request received from MCP server", "message", req.Message) + return r.requestElicitation(ctx, elicitationSpec{ + message: req.Message, + mode: req.Mode, + schema: req.RequestedSchema, + url: req.URL, + serverElicitationID: req.ElicitationID, + meta: req.Meta, + }) +} +// requestElicitation emits spec as an ElicitationRequest event and waits for +// the embedder's response, correlated by elicitation ID. +func (r *LocalRuntime) requestElicitation(ctx context.Context, spec elicitationSpec) (tools.ElicitationResult, error) { // In non-interactive mode (e.g., MCP serve), there is no user to respond // to elicitation requests. Decline immediately instead of blocking forever. if r.nonInteractive { - slog.DebugContext(ctx, "Declining elicitation in non-interactive mode", "message", req.Message) + slog.DebugContext(ctx, "Declining elicitation in non-interactive mode", "message", spec.message) return tools.ElicitationResult{ Action: tools.ElicitationActionDecline, }, nil } + sessionID := spec.sessionID + if sessionID == "" { + sessionID = genai.ConversationIDFromContext(ctx) + } + agentName := spec.agentName + if agentName == "" { + agentName = r.currentAgentName() + } + // A background session (run_background_agent) marks its context so // toolset Start() OAuth fails fast instead of eliciting (#3200). Mid-call // elicitations reach here regardless of that marker, so extend the same @@ -479,8 +521,8 @@ func (r *LocalRuntime) elicitationHandler(ctx context.Context, req *mcp.ElicitPa // all can answer this request. Decline immediately with a model-readable // note instead of parking a goroutine forever (#3584). if !tools.InteractivePromptsAllowed(ctx) && !r.hasElicitationSink() { - slog.WarnContext(ctx, "Declining elicitation: background session has no UI to answer it", "message", req.Message) - r.elicitationDeclines.record(genai.ConversationIDFromContext(ctx), backgroundElicitationDeclinedNote(req.Message)) + slog.WarnContext(ctx, "Declining elicitation: background session has no UI to answer it", "message", spec.message) + r.elicitationDeclines.record(sessionID, backgroundElicitationDeclinedNote(spec.message)) return tools.ElicitationResult{ Action: tools.ElicitationActionDecline, }, nil @@ -494,7 +536,7 @@ func (r *LocalRuntime) elicitationHandler(ctx context.Context, req *mcp.ElicitPa // The registry key (and the ElicitationID surfaced to clients for // ResumeElicitation routing) is always a freshly generated, internal - // ID — never the MCP wire req.ElicitationID. The wire value is only + // ID — never the MCP wire elicitation ID. The wire value is only // ever set for URL-mode elicitations and is chosen by the originating // MCP server; two independent servers (e.g. two background jobs each // talking to their own MCP process) can legitimately reuse the same @@ -513,16 +555,15 @@ func (r *LocalRuntime) elicitationHandler(ctx context.Context, req *mcp.ElicitPa defer r.elicitationWaiters.abandon(correlationID, wt) slog.DebugContext(ctx, "Sending elicitation request event to client", - "message", req.Message, - "mode", req.Mode, - "requested_schema", req.RequestedSchema, - "url", req.URL, + "message", spec.message, + "mode", spec.mode, + "requested_schema", spec.schema, + "url", spec.url, "elicitation_id", correlationID, - "server_elicitation_id", req.ElicitationID) - slog.DebugContext(ctx, "Elicitation request meta", "meta", req.Meta) + "server_elicitation_id", spec.serverElicitationID) + slog.DebugContext(ctx, "Elicitation request meta", "meta", spec.meta) - sessionID := genai.ConversationIDFromContext(ctx) - ev := ElicitationRequest(req.Message, req.Mode, req.RequestedSchema, req.URL, correlationID, req.ElicitationID, sessionID, req.Meta, r.currentAgentName()) + ev := ElicitationRequest(spec.message, spec.mode, spec.schema, spec.url, correlationID, spec.serverElicitationID, sessionID, spec.meta, agentName) // Reliable delivery: invoked synchronously, unconditionally, and exactly // once, BEFORE anything that could block (#3584 review item 1). This diff --git a/pkg/runtime/loop.go b/pkg/runtime/loop.go index acc7ffe55c..082b2c499a 100644 --- a/pkg/runtime/loop.go +++ b/pkg/runtime/loop.go @@ -2,10 +2,8 @@ package runtime import ( "context" - "errors" "fmt" "log/slog" - "path" "path/filepath" "reflect" "regexp" @@ -1317,13 +1315,21 @@ func sanitizeToolCallName(name string) string { // generated-media manifest ([session.GeneratedMediaManifest]), the trust // anchor a resolver must consult before reading a workspace path back. // -// The requested filename is the sanitized provider display name when one -// exists, otherwise a generic "generated-N"; the writer owns MIME/extension +// The requested filename is the prompt-directed path when the provider +// surfaced one ([chat.MediaDelta.RequestedPath], populated by marker +// extraction once that lands), otherwise the sanitized provider display +// name, otherwise a generic "generated-N"; the writer owns MIME/extension // correction and collision suffixing, and the part persists the exact final -// relative path it returns. A corrected extension additionally surfaces a -// bounded user-visible notice naming the final path. Explicit -// prompt-directed naming (and its out-of-workspace confirmation flow) is -// intentionally not implemented here yet. +// path it returns. A corrected extension additionally surfaces a bounded +// user-visible notice naming the final path. A prompt-directed path that +// escapes the workspace (absolute, "..", or "~"-rooted) is refused unless +// the user confirms the resolved target through a runtime-native +// elicitation; refusal redirects the bytes into the workspace root under +// the sanitized basename with a sanitized warning — see +// [LocalRuntime.writeGeneratedMedia] in media_escape.go for the policy. An +// accepted external write persists [chat.ArtifactRootExternal] plus the +// confirmed absolute path and is manifest-gated exactly like a workspace +// write. // // When no workspace root is available (no provenance anywhere in the parent // chain, or a malformed stored value) every item fails with the same @@ -1383,25 +1389,26 @@ func (r *LocalRuntime) materializeGeneratedMedia(ctx context.Context, sess *sess continue } - requested := safeName - generic := fmt.Sprintf("generated-%d", i+1) - if requested == "" { - requested = generic - } - res, err := workspacemediaWrite(root, requested, m.Data, m.MimeType) - if err != nil && requested != generic && errors.Is(err, workspacemedia.ErrPathEscape) { - // A provider display name the writer refuses even after display - // sanitization (e.g. a Windows-reserved name like "CON.png") must - // not cost the user the item; there is no user-chosen path to - // honor at this stage, so fall back to the generic name. - res, err = workspacemediaWrite(root, generic, m.Data, m.MimeType) - } + write, err := r.writeGeneratedMedia(ctx, generatedMediaItem{ + workspaceRoot: root, + requestedPath: m.RequestedPath, + providerName: safeName, + genericName: fmt.Sprintf("generated-%d", i+1), + data: m.Data, + mimeType: m.MimeType, + safeMimeType: safeMimeType, + agentName: agentName, + sessionID: sess.ID, + index: i + 1, + total: len(media), + }, events) if err != nil { warnItemFailed(err) continue } + res := write.res - if err := r.recordGeneratedFile(ctx, sess.ID, res.RelPath, safeMimeType); err != nil { + if err := r.recordGeneratedFile(ctx, sess.ID, write.root, res.RelPath, safeMimeType); err != nil { // The file is already a real workspace deliverable, so keep the // reference; without the manifest record inline display will // refuse to render it (fail closed), which the user should hear @@ -1423,12 +1430,12 @@ func (r *LocalRuntime) materializeGeneratedMedia(ctx context.Context, sess *sess parts = append(parts, chat.MessagePart{ Type: chat.MessagePartTypeDocument, Document: &chat.Document{ - Name: path.Base(res.RelPath), + Name: generatedDocumentName(write.root, res.RelPath), MimeType: safeMimeType, Size: m.Size, Source: chat.DocumentSource{ ArtifactPath: res.RelPath, - ArtifactRoot: chat.ArtifactRootWorkspace, + ArtifactRoot: write.root, ArtifactOwnerSessionID: sess.ID, }, }, @@ -1447,15 +1454,16 @@ func (r *LocalRuntime) sessionLookup() session.Lookup { } // recordGeneratedFile writes one manifest record after a successful -// workspace write — materialization is the only writer of the manifest. -func (r *LocalRuntime) recordGeneratedFile(ctx context.Context, sessionID, relPath, mimeType string) error { +// write — materialization is the only writer of the manifest. +func (r *LocalRuntime) recordGeneratedFile(ctx context.Context, sessionID string, root chat.ArtifactRootKind, finalPath, mimeType string) error { manifest, ok := r.sessionStore.(session.GeneratedMediaManifest) if !ok { return fmt.Errorf("session store %T does not implement the generated-media manifest", r.sessionStore) } return manifest.AddGeneratedFile(ctx, session.GeneratedFile{ SessionID: sessionID, - RelPath: relPath, + RelPath: finalPath, + Root: root, MimeType: mimeType, CreatedAt: r.now(), }) diff --git a/pkg/runtime/media_escape.go b/pkg/runtime/media_escape.go new file mode 100644 index 0000000000..6c75985d61 --- /dev/null +++ b/pkg/runtime/media_escape.go @@ -0,0 +1,273 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/workspacemedia" +) + +// generatedMediaItem carries the per-item context needed to route one +// generated media blob through the naming and workspace-escape policy. +type generatedMediaItem struct { + workspaceRoot string + // requestedPath is the prompt-directed target (untrusted model input, + // see chat.MediaDelta.RequestedPath); empty when the model named + // nothing explicitly. + requestedPath string + // providerName is the sanitized provider display name; genericName the + // deterministic "generated-N" fallback. + providerName string + genericName string + data []byte + // mimeType is the raw provider MIME (the writer derives the extension + // from it); safeMimeType the sanitized form for anything user-visible. + mimeType string + safeMimeType string + agentName string + sessionID string + index, total int +} + +// generatedMediaWrite is the outcome of a routed write: the writer result +// plus the artifact root kind its RelPath is interpreted against. +type generatedMediaWrite struct { + res workspacemedia.Result + root chat.ArtifactRootKind +} + +// writeGeneratedMedia writes one generated media item according to the +// approved escape policy: a prompt-directed workspace-relative path is +// honored directly; an absolute/traversing/home-rooted path is refused +// unless the user explicitly confirms the resolved external target via a +// runtime-native elicitation; a declined, cancelled, unanswerable, or +// invalid request redirects the bytes to the workspace root under the +// sanitized basename — already-generated bytes are never discarded solely +// because the requested path escaped. Without a prompt-directed path the +// provider display name (then the generic name) is used, as before. +func (r *LocalRuntime) writeGeneratedMedia(ctx context.Context, item generatedMediaItem, events EventSink) (generatedMediaWrite, error) { + if item.requestedPath == "" { + return r.writeProviderNamedMedia(item) + } + + class, cleaned := workspacemedia.ClassifyRequestedPath(item.requestedPath) + switch class { + case workspacemedia.PathWorkspaceRelative: + res, err := workspacemediaWrite(item.workspaceRoot, cleaned, item.data, item.mimeType) + if err != nil && errors.Is(err, workspacemedia.ErrPathEscape) { + // Lexically contained but escaping at I/O time (a symlinked + // parent directory): same policy as an unconfirmed escape. + return r.redirectEscapedMedia(item, events, "escapes the workspace") + } + return generatedMediaWrite{res: res, root: chat.ArtifactRootWorkspace}, err + case workspacemedia.PathEscaping: + target, confirmable := externalMediaTarget(item.workspaceRoot, item.requestedPath, item.externalDirFilename()) + if confirmable && r.confirmMediaEscape(ctx, target, item) { + res, err := workspacemediaWriteExternal(target, item.data, item.mimeType) + return generatedMediaWrite{res: res, root: chat.ArtifactRootExternal}, err + } + return r.redirectEscapedMedia(item, events, "is outside the workspace and was not confirmed") + default: + return r.redirectEscapedMedia(item, events, "is not a usable path") + } +} + +// externalDirFilename is the filename used when the confirmed external +// target is an existing directory: the provider display name (then the +// generic fallback) with the exact extension the writer would give it, so +// the user confirms the full final path, not a bare directory. +func (item generatedMediaItem) externalDirFilename() string { + name := item.providerName + if name == "" { + name = item.genericName + } + return workspacemedia.DefaultFilename(name, item.mimeType) +} + +// writeProviderNamedMedia is the pre-existing no-explicit-name flow: the +// sanitized provider display name, then the generic name. +func (r *LocalRuntime) writeProviderNamedMedia(item generatedMediaItem) (generatedMediaWrite, error) { + requested := item.providerName + if requested == "" { + requested = item.genericName + } + res, err := workspacemediaWrite(item.workspaceRoot, requested, item.data, item.mimeType) + if err != nil && requested != item.genericName && errors.Is(err, workspacemedia.ErrPathEscape) { + // A provider display name the writer refuses even after display + // sanitization (e.g. a Windows-reserved name like "CON.png") must + // not cost the user the item; there is no user-chosen path to + // honor at this stage, so fall back to the generic name. + res, err = workspacemediaWrite(item.workspaceRoot, item.genericName, item.data, item.mimeType) + } + return generatedMediaWrite{res: res, root: chat.ArtifactRootWorkspace}, err +} + +// redirectEscapedMedia lands a refused prompt-directed item in the +// workspace root under the requested path's basename (generic fallback) and +// emits a sanitized warning explaining the redirect. The warning names only +// the final written path — never the requested one, a raw error, or any +// reference internals. +func (r *LocalRuntime) redirectEscapedMedia(item generatedMediaItem, events EventSink, reason string) (generatedMediaWrite, error) { + base := workspacemedia.RequestedBasename(item.requestedPath) + if base == "" { + base = item.genericName + } + res, err := workspacemediaWrite(item.workspaceRoot, base, item.data, item.mimeType) + if err != nil && base != item.genericName && errors.Is(err, workspacemedia.ErrPathEscape) { + res, err = workspacemediaWrite(item.workspaceRoot, item.genericName, item.data, item.mimeType) + } + if err != nil { + return generatedMediaWrite{}, err + } + if events != nil { + warning := fmt.Sprintf("Requested save location for generated media item %d/%d %s; saved as %s in the workspace instead", + item.index, item.total, reason, res.RelPath) + events.Emit(Warning(chat.TruncateUTF8Bytes(warning, maxPlaceholderOrWarningBytes), item.agentName)) + } + return generatedMediaWrite{res: res, root: chat.ArtifactRootWorkspace}, nil +} + +// mediaEscapeElicitationTitle is surfaced via the event meta key +// "cagent/title", which the TUI elicitation dialog uses as its title. +const mediaEscapeElicitationTitle = "Save outside the workspace?" + +// The escape confirmation is a schema-backed form so accepting is an +// explicit act: the safe "keep in workspace" choice is listed first, making +// it every form client's default selection — a bare submit (Enter in the +// TUI) yields the decline choice, never an external write. The runtime +// additionally verifies the submitted value, so a permissive client that +// accepts with empty or free-form content still cannot authorize the write. +const ( + // MediaEscapeDecisionField is the single required property of + // [MediaEscapeDecisionSchema]. + MediaEscapeDecisionField = "decision" + // MediaEscapeAcceptChoice is the only MediaEscapeDecisionField value + // that authorizes writing outside the workspace. + MediaEscapeAcceptChoice = "Save outside the workspace" + // MediaEscapeDeclineChoice keeps the file in the workspace; listed + // first in the enum so it is the default selection. + MediaEscapeDeclineChoice = "Keep it in the workspace" +) + +// MediaEscapeDecisionSchema is the requested schema attached to the +// workspace-escape confirmation elicitation. Exported so client surfaces +// (e.g. the TUI elicitation dialog tests) can prove how they interpret +// this exact schema. +func MediaEscapeDecisionSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + MediaEscapeDecisionField: map[string]any{ + "type": "string", + "title": "Decision", + "description": fmt.Sprintf("Only an explicit %q writes outside the workspace.", MediaEscapeAcceptChoice), + "enum": []any{MediaEscapeDeclineChoice, MediaEscapeAcceptChoice}, + }, + }, + "required": []any{MediaEscapeDecisionField}, + } +} + +// confirmMediaEscape asks the user to confirm writing one generated media +// item to target, through the same waiter registry and ResumeElicitation +// plumbing as MCP elicitations. Only an explicit accept counts: decline, +// cancel, a cancelled ctx, non-interactive mode, and headless background +// sessions (auto-declined inside requestElicitation, so the stream is never +// blocked with nobody to answer) all return false. +func (r *LocalRuntime) confirmMediaEscape(ctx context.Context, target string, item generatedMediaItem) bool { + message := fmt.Sprintf( + "Agent %q wants to save generated media (%s, item %d/%d) OUTSIDE the workspace, at: %s — choose %q to write it there; otherwise it is kept in the workspace under a safe name.", + item.agentName, item.safeMimeType, item.index, item.total, target, MediaEscapeAcceptChoice) + result, err := r.requestElicitation(ctx, elicitationSpec{ + message: message, + mode: "form", + schema: MediaEscapeDecisionSchema(), + meta: map[string]any{"cagent/title": mediaEscapeElicitationTitle}, + agentName: item.agentName, + sessionID: item.sessionID, + }) + if err != nil { + slog.DebugContext(ctx, "Workspace-escape confirmation was not answered; redirecting into the workspace", + "agent", item.agentName, "session_id", item.sessionID, "error", err) + return false + } + if result.Action != tools.ElicitationActionAccept { + return false + } + // Accept alone is not enough: the submitted form must carry the exact + // affirmative choice, so a bare/empty accept (e.g. Enter on a free-form + // prompt in an older client) can never authorize an external write. + choice, _ := result.Content[MediaEscapeDecisionField].(string) + return choice == MediaEscapeAcceptChoice +} + +// externalMediaTarget resolves an escaping requested path to the absolute +// target the user will be asked to confirm: separators normalized, a +// leading "~" expanded to the home directory, and a relative traversal +// anchored at the workspace root. ok is false when no faithfully +// displayable target can be derived — an unexpandable "~user" prefix, +// control characters, invalid UTF-8, or an over-long path — because the +// user must never be asked to confirm something other than the exact path +// that would be written. A target that resolves to an existing directory +// means "save inside it": dirFilename (the generated filename, extension +// included) is appended before confirmation, so accepting a directory can +// never produce a dash-suffixed sibling of that directory. +func externalMediaTarget(workspaceRoot, requested, dirFilename string) (target string, ok bool) { + p := filepath.FromSlash(strings.ReplaceAll(requested, `\`, `/`)) + if rest, isHome := strings.CutPrefix(p, "~"); isHome { + if rest != "" && !strings.HasPrefix(rest, string(filepath.Separator)) { + return "", false + } + home, err := os.UserHomeDir() + if err != nil { + return "", false + } + p = filepath.Join(home, rest) + } + if !filepath.IsAbs(p) { + p = filepath.Join(workspaceRoot, p) + } + p = filepath.Clean(p) + if info, err := os.Stat(p); err == nil && info.IsDir() { + p = filepath.Join(p, dirFilename) + } + return p, displayableEscapeTarget(p) +} + +// displayableEscapeTarget reports whether p can appear verbatim in the +// confirmation prompt: valid UTF-8, no control characters, bounded like +// every other user-visible generated-media string. +func displayableEscapeTarget(p string) bool { + if len(p) > maxPlaceholderOrWarningBytes || !utf8.ValidString(p) { + return false + } + for _, r := range p { + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +// generatedDocumentName derives the display name for a written item: +// workspace paths are slash-separated, external ones OS-native. +func generatedDocumentName(root chat.ArtifactRootKind, finalPath string) string { + if root == chat.ArtifactRootExternal { + return filepath.Base(finalPath) + } + return path.Base(finalPath) +} + +// workspacemediaWriteExternal is [workspacemedia.WriteExternal] behind the +// same test-only indirection as workspacemediaWrite (see loop.go). +// Production code must never reassign it. +var workspacemediaWriteExternal = workspacemedia.WriteExternal diff --git a/pkg/runtime/media_escape_test.go b/pkg/runtime/media_escape_test.go new file mode 100644 index 0000000000..75c3fba495 --- /dev/null +++ b/pkg/runtime/media_escape_test.go @@ -0,0 +1,419 @@ +package runtime + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/team" + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/workspacemedia" +) + +// newEscapeTestRuntime is newMediaTestRuntime plus the agent router the +// native elicitation path dereferences (user-input hooks, agent name). +func newEscapeTestRuntime(t *testing.T) (*LocalRuntime, session.Store) { + t.Helper() + r, store, _ := newMediaTestRuntime(t) + r.agents = newAgentRouter(team.New(), "root") + return r, store +} + +// materializeWithAnswer drives one prompt-directed materialization on a +// separate goroutine — exactly how the runtime loop blocks on an +// elicitation while the embedder keeps consuming events — answers the +// escape confirmation with action, and returns the resulting parts, the +// emitted warnings, and the request event. Bounded waits double as the +// no-deadlock regression: a hang fails the test instead of wedging it. +func materializeWithAnswer(t *testing.T, r *LocalRuntime, sess *session.Session, media []chat.MediaDelta, action tools.ElicitationAction, content map[string]any) ([]chat.MessagePart, *collectingSink, *ElicitationRequestEvent) { + t.Helper() + requests := make(chan Event, 1) + r.OnElicitationRequest(func(e Event) { requests <- e }) + + sink := &collectingSink{} + done := make(chan []chat.MessagePart, 1) + go func() { done <- r.materializeGeneratedMedia(t.Context(), sess, media, "root", sink) }() + + var req *ElicitationRequestEvent + select { + case e := <-requests: + var ok bool + req, ok = e.(*ElicitationRequestEvent) + require.True(t, ok, "the sink must receive an elicitation request event, got %T", e) + case <-time.After(10 * time.Second): + t.Fatal("no elicitation request was emitted for the escaping path") + } + require.NoError(t, r.ResumeElicitation(t.Context(), action, content, req.ElicitationID)) + + select { + case parts := <-done: + return parts, sink, req + case <-time.After(10 * time.Second): + t.Fatal("materialization did not finish after the elicitation response") + return nil, nil, nil + } +} + +// escapeAcceptContent is the explicit affirmative form answer required to +// authorize an external write; a bare accept is never enough. +func escapeAcceptContent() map[string]any { + return map[string]any{MediaEscapeDecisionField: MediaEscapeAcceptChoice} +} + +// escapeMedia builds the canonical two-item batch: one prompt-directed +// escaping item plus one plain provider-named sibling, so every outcome +// test also proves sibling preservation. +func escapeMedia(requestedPath string) []chat.MediaDelta { + return []chat.MediaDelta{ + {Data: []byte{0xAA}, MimeType: "image/png", Name: "cat.png", RequestedPath: requestedPath, Size: 1}, + {Data: []byte{0xBB}, MimeType: "image/png", Name: "sibling.png", Size: 1}, + } +} + +// assertSiblingPreserved verifies the non-escaping second item of +// escapeMedia landed normally in the workspace. +func assertSiblingPreserved(t *testing.T, parts []chat.MessagePart, root string) { + t.Helper() + require.Len(t, parts, 2, "the sibling item must survive the escape handling") + sibling := parts[1].Document + require.NotNil(t, sibling) + assert.Equal(t, chat.ArtifactRootWorkspace, sibling.Source.ArtifactRoot) + assert.Equal(t, "sibling.png", sibling.Source.ArtifactPath) + data, err := os.ReadFile(filepath.Join(root, "sibling.png")) + require.NoError(t, err) + assert.Equal(t, []byte{0xBB}, data) +} + +// TestMaterializeGeneratedMedia_EscapeAccept is the accept contract: an +// explicit accept writes to the confirmed external target with the regular +// writer mechanics, persists the external root kind plus confirmed path, +// records manifest membership, and the request event carries a safe prompt. +func TestMaterializeGeneratedMedia_EscapeAccept(t *testing.T) { + r, store := newEscapeTestRuntime(t) + sess, root := workspaceSession(t, "sess-escape-accept") + target := filepath.Join(t.TempDir(), "exports", "cat.png") + + parts, sink, req := materializeWithAnswer(t, r, sess, escapeMedia(target), tools.ElicitationActionAccept, escapeAcceptContent()) + + assert.Equal(t, "form", req.Mode) + assert.Equal(t, MediaEscapeDecisionSchema(), req.Schema, "the confirmation must carry the explicit-choice schema") + assert.Equal(t, mediaEscapeElicitationTitle, req.Meta["cagent/title"]) + assert.Equal(t, sess.ID, req.SessionID) + assert.NotEmpty(t, req.ElicitationID) + assert.Contains(t, req.Message, target, "the user must see the exact target being confirmed") + assert.Contains(t, req.Message, "image/png") + assert.NotContains(t, req.Message, sess.ID, "the prompt must not leak reference internals") + + doc := parts[0].Document + require.NotNil(t, doc) + assert.Equal(t, "cat.png", doc.Name) + assert.Equal(t, target, doc.Source.ArtifactPath) + assert.Equal(t, chat.ArtifactRootExternal, doc.Source.ArtifactRoot) + assert.Equal(t, sess.ID, doc.Source.ArtifactOwnerSessionID) + assert.True(t, isGeneratedMediaPart(parts[0]), "external parts must keep the strip/no-resend marker") + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, []byte{0xAA}, data) + + file, err := manifestOf(t, store).LookupGeneratedFile(t.Context(), sess.ID, target) + require.NoError(t, err, "an accepted external write must be manifest-gated like a workspace write") + assert.Equal(t, chat.ArtifactRootExternal, file.Root) + + assertSiblingPreserved(t, parts, root) + assert.Empty(t, sink.warnings(), "a confirmed external save must not warn") +} + +// TestMaterializeGeneratedMedia_EscapeAcceptCollision: the confirmed target +// already existing must never be overwritten — the dash-suffixed final path +// is what gets persisted and recorded. +func TestMaterializeGeneratedMedia_EscapeAcceptCollision(t *testing.T) { + r, store := newEscapeTestRuntime(t) + sess, _ := workspaceSession(t, "sess-escape-collision") + dir := t.TempDir() + target := filepath.Join(dir, "cat.png") + require.NoError(t, os.WriteFile(target, []byte("existing"), 0o644)) + + parts, _, _ := materializeWithAnswer(t, r, sess, escapeMedia(target), tools.ElicitationActionAccept, escapeAcceptContent()) + + final := filepath.Join(dir, "cat-1.png") + doc := parts[0].Document + require.NotNil(t, doc) + assert.Equal(t, final, doc.Source.ArtifactPath) + + existing, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, []byte("existing"), existing, "the pre-existing external file must be untouched") + + _, err = manifestOf(t, store).LookupGeneratedFile(t.Context(), sess.ID, final) + require.NoError(t, err) +} + +// TestMaterializeGeneratedMedia_EscapeRefused: decline and cancel both +// redirect the bytes into the workspace root under the sanitized basename, +// with a sanitized warning that names only the final workspace path. +func TestMaterializeGeneratedMedia_EscapeRefused(t *testing.T) { + for _, action := range []tools.ElicitationAction{tools.ElicitationActionDecline, tools.ElicitationActionCancel} { + t.Run(string(action), func(t *testing.T) { + r, store := newEscapeTestRuntime(t) + sess, root := workspaceSession(t, "sess-escape-"+string(action)) + extDir := t.TempDir() + target := filepath.Join(extDir, "cat.png") + + parts, sink, _ := materializeWithAnswer(t, r, sess, escapeMedia(target), action, nil) + + doc := parts[0].Document + require.NotNil(t, doc) + assert.Equal(t, "cat.png", doc.Source.ArtifactPath) + assert.Equal(t, chat.ArtifactRootWorkspace, doc.Source.ArtifactRoot) + + data, err := os.ReadFile(filepath.Join(root, "cat.png")) + require.NoError(t, err, "refused bytes must be redirected, never discarded") + assert.Equal(t, []byte{0xAA}, data) + _, err = os.Stat(target) + assert.True(t, os.IsNotExist(err), "nothing may be written outside the workspace without an accept") + + file, err := manifestOf(t, store).LookupGeneratedFile(t.Context(), sess.ID, "cat.png") + require.NoError(t, err) + assert.Equal(t, chat.ArtifactRootWorkspace, file.Root) + + warnings := sink.warnings() + require.Len(t, warnings, 1, "the redirect must be explained") + assert.Contains(t, warnings[0].Message, "outside the workspace") + assert.Contains(t, warnings[0].Message, "saved as cat.png") + assert.NotContains(t, warnings[0].Message, extDir, "the warning must not echo the requested location") + + assertSiblingPreserved(t, parts, root) + }) + } +} + +// TestMaterializeGeneratedMedia_EscapeNonInteractive: with no user to ask, +// the runtime must redirect immediately — no elicitation event, no blocked +// stream. +func TestMaterializeGeneratedMedia_EscapeNonInteractive(t *testing.T) { + r, _ := newEscapeTestRuntime(t) + r.nonInteractive = true + r.OnElicitationRequest(func(e Event) { t.Errorf("no elicitation must be emitted in non-interactive mode, got %T", e) }) + sess, root := workspaceSession(t, "sess-escape-noninteractive") + target := filepath.Join(t.TempDir(), "cat.png") + + sink := &collectingSink{} + parts := r.materializeGeneratedMedia(t.Context(), sess, escapeMedia(target), "root", sink) + + require.Len(t, parts, 2) + assert.Equal(t, "cat.png", parts[0].Document.Source.ArtifactPath) + assert.Equal(t, chat.ArtifactRootWorkspace, parts[0].Document.Source.ArtifactRoot) + _, err := os.Stat(target) + assert.True(t, os.IsNotExist(err)) + require.Len(t, sink.warnings(), 1) + assertSiblingPreserved(t, parts, root) +} + +// TestMaterializeGeneratedMedia_EscapeHeadlessBackground: a background +// session with no elicitation sink auto-declines (with the model-readable +// note) instead of parking the run forever. +func TestMaterializeGeneratedMedia_EscapeHeadlessBackground(t *testing.T) { + r, _ := newEscapeTestRuntime(t) + sess, root := workspaceSession(t, "sess-escape-headless") + target := filepath.Join(t.TempDir(), "cat.png") + + sink := &collectingSink{} + ctx := tools.WithoutInteractivePrompts(t.Context()) + parts := r.materializeGeneratedMedia(ctx, sess, escapeMedia(target), "root", sink) + + require.Len(t, parts, 2) + assert.Equal(t, chat.ArtifactRootWorkspace, parts[0].Document.Source.ArtifactRoot) + _, err := os.Stat(target) + assert.True(t, os.IsNotExist(err)) + assert.NotEmpty(t, r.elicitationDeclines.drain(sess.ID), "the auto-decline must leave a model-readable note") + assertSiblingPreserved(t, parts, root) +} + +// TestMaterializeGeneratedMedia_RequestedPathInWorkspace: a prompt-directed +// path contained in the workspace (including one that only cleans to a +// contained path) is honored without any confirmation. +func TestMaterializeGeneratedMedia_RequestedPathInWorkspace(t *testing.T) { + tests := []struct { + requested string + finalPath string + }{ + {"images/cat.png", "images/cat.png"}, + {"a/../b.png", "b.png"}, + } + for _, tt := range tests { + t.Run(tt.requested, func(t *testing.T) { + r, store := newEscapeTestRuntime(t) + r.OnElicitationRequest(func(e Event) { t.Errorf("a contained path must not elicit, got %T", e) }) + sess, root := workspaceSession(t, "sess-contained") + + sink := &collectingSink{} + parts := r.materializeGeneratedMedia(t.Context(), sess, []chat.MediaDelta{ + {Data: []byte{0x01}, MimeType: "image/png", RequestedPath: tt.requested, Size: 1}, + }, "root", sink) + + require.Len(t, parts, 1) + assert.Equal(t, tt.finalPath, parts[0].Document.Source.ArtifactPath) + assert.Equal(t, chat.ArtifactRootWorkspace, parts[0].Document.Source.ArtifactRoot) + _, err := os.Stat(filepath.Join(root, filepath.FromSlash(tt.finalPath))) + require.NoError(t, err) + _, err = manifestOf(t, store).LookupGeneratedFile(t.Context(), sess.ID, tt.finalPath) + require.NoError(t, err) + assert.Empty(t, sink.warnings()) + }) + } +} + +// TestMaterializeGeneratedMedia_RequestedPathUnusable: paths that are +// neither containable nor confirmable (unusable names, unexpandable "~user", +// control characters an elicitation prompt could not display faithfully) +// redirect without ever emitting an elicitation. +func TestMaterializeGeneratedMedia_RequestedPathUnusable(t *testing.T) { + for name, requested := range map[string]string{ + "reserved name": "CON.png", + "unexpandable tilde": "~nosuchuser/cat.png", + "control-char target": "/external/bad\nname/cat.png", + } { + t.Run(name, func(t *testing.T) { + r, _ := newEscapeTestRuntime(t) + r.OnElicitationRequest(func(e Event) { t.Errorf("an unusable path must not elicit, got %T", e) }) + sess, root := workspaceSession(t, "sess-unusable") + + sink := &collectingSink{} + parts := r.materializeGeneratedMedia(t.Context(), sess, []chat.MediaDelta{ + {Data: []byte{0x01}, MimeType: "image/png", RequestedPath: requested, Size: 1}, + }, "root", sink) + + require.Len(t, parts, 1, "unusable requested paths must not cost the user the item") + doc := parts[0].Document + assert.Equal(t, chat.ArtifactRootWorkspace, doc.Source.ArtifactRoot) + _, err := os.Stat(filepath.Join(root, filepath.FromSlash(doc.Source.ArtifactPath))) + require.NoError(t, err) + require.Len(t, sink.warnings(), 1) + assert.Contains(t, sink.warnings()[0].Message, "saved as "+doc.Source.ArtifactPath) + }) + } +} + +// TestMaterializeGeneratedMedia_EscapeAcceptWriteFails: an accepted external +// write that then fails I/O keeps the existing per-item warning contract +// (sanitized, no raw error) and preserves siblings. +func TestMaterializeGeneratedMedia_EscapeAcceptWriteFails(t *testing.T) { + r, _ := newEscapeTestRuntime(t) + sess, root := workspaceSession(t, "sess-escape-fail") + target := filepath.Join(t.TempDir(), "cat.png") + + original := workspacemediaWriteExternal + workspacemediaWriteExternal = func(string, []byte, string) (workspacemedia.Result, error) { + return workspacemedia.Result{}, os.ErrPermission + } + t.Cleanup(func() { workspacemediaWriteExternal = original }) + + parts, sink, _ := materializeWithAnswer(t, r, sess, escapeMedia(target), tools.ElicitationActionAccept, escapeAcceptContent()) + + require.Len(t, parts, 1, "the failed item is dropped, the sibling survives") + assert.Equal(t, "sibling.png", parts[0].Document.Source.ArtifactPath) + _, err := os.ReadFile(filepath.Join(root, "sibling.png")) + require.NoError(t, err) + + warnings := sink.warnings() + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0].Message, "Failed to save generated media item 1/2") + assert.NotContains(t, warnings[0].Message, target, "the warning must not leak the external path") + assert.NotContains(t, warnings[0].Message, os.ErrPermission.Error(), "the warning must not leak the raw error") +} + +// TestMaterializeGeneratedMedia_EscapeAcceptRequiresExplicitChoice: the +// accept ACTION alone must never authorize an external write — only the +// exact affirmative form value does. This covers bare submits, empty +// free-form answers, the safe default choice, and permissive clients that +// accept with arbitrary content. +func TestMaterializeGeneratedMedia_EscapeAcceptRequiresExplicitChoice(t *testing.T) { + for name, content := range map[string]map[string]any{ + "nil content": nil, + "empty content": {}, + "default choice": {MediaEscapeDecisionField: MediaEscapeDeclineChoice}, + "free-form answer": {"response": "yes"}, + "non-string value": {MediaEscapeDecisionField: true}, + } { + t.Run(name, func(t *testing.T) { + r, _ := newEscapeTestRuntime(t) + sess, root := workspaceSession(t, "sess-escape-implicit") + extDir := t.TempDir() + target := filepath.Join(extDir, "cat.png") + + parts, sink, _ := materializeWithAnswer(t, r, sess, escapeMedia(target), tools.ElicitationActionAccept, content) + + doc := parts[0].Document + require.NotNil(t, doc) + assert.Equal(t, "cat.png", doc.Source.ArtifactPath) + assert.Equal(t, chat.ArtifactRootWorkspace, doc.Source.ArtifactRoot) + _, err := os.Stat(target) + assert.True(t, os.IsNotExist(err), "an accept without the explicit affirmative choice must not write outside the workspace") + require.Len(t, sink.warnings(), 1, "the redirect must be explained") + assertSiblingPreserved(t, parts, root) + }) + } +} + +// TestMaterializeGeneratedMedia_EscapeDirectoryTarget: confirming an +// existing directory means "write the generated filename inside it" — the +// user is shown, and confirms, the exact final file path, never a +// dash-suffixed sibling of the directory. +func TestMaterializeGeneratedMedia_EscapeDirectoryTarget(t *testing.T) { + r, store := newEscapeTestRuntime(t) + sess, root := workspaceSession(t, "sess-escape-dir") + dir := filepath.Join(t.TempDir(), "exports") + require.NoError(t, os.Mkdir(dir, 0o755)) + + parts, sink, req := materializeWithAnswer(t, r, sess, escapeMedia(dir), tools.ElicitationActionAccept, escapeAcceptContent()) + + final := filepath.Join(dir, "cat.png") + assert.Contains(t, req.Message, final, "the user must see the exact file that will be written inside the directory") + + doc := parts[0].Document + require.NotNil(t, doc) + assert.Equal(t, final, doc.Source.ArtifactPath) + assert.Equal(t, chat.ArtifactRootExternal, doc.Source.ArtifactRoot) + + data, err := os.ReadFile(final) + require.NoError(t, err) + assert.Equal(t, []byte{0xAA}, data) + + siblings, err := os.ReadDir(filepath.Dir(dir)) + require.NoError(t, err) + require.Len(t, siblings, 1, "nothing may be written next to the confirmed directory") + + _, err = manifestOf(t, store).LookupGeneratedFile(t.Context(), sess.ID, final) + require.NoError(t, err) + + assertSiblingPreserved(t, parts, root) + assert.Empty(t, sink.warnings()) +} + +// TestMaterializeGeneratedMedia_EscapeDirectoryTargetGenericName: with no +// provider display name the generic fallback (extension included, so the +// confirmed path is the final path) lands inside the confirmed directory. +func TestMaterializeGeneratedMedia_EscapeDirectoryTargetGenericName(t *testing.T) { + r, _ := newEscapeTestRuntime(t) + sess, _ := workspaceSession(t, "sess-escape-dir-generic") + dir := filepath.Join(t.TempDir(), "exports") + require.NoError(t, os.Mkdir(dir, 0o755)) + + parts, _, req := materializeWithAnswer(t, r, sess, []chat.MediaDelta{ + {Data: []byte{0xAA}, MimeType: "image/png", RequestedPath: dir, Size: 1}, + }, tools.ElicitationActionAccept, escapeAcceptContent()) + + final := filepath.Join(dir, "generated-1.png") + assert.Contains(t, req.Message, final) + require.Len(t, parts, 1) + assert.Equal(t, final, parts[0].Document.Source.ArtifactPath) + _, err := os.Stat(final) + require.NoError(t, err) +} diff --git a/pkg/session/generated_media_manifest.go b/pkg/session/generated_media_manifest.go index 5887c487e0..256c86735c 100644 --- a/pkg/session/generated_media_manifest.go +++ b/pkg/session/generated_media_manifest.go @@ -6,8 +6,11 @@ import ( "errors" "fmt" "io/fs" + "path/filepath" "strings" "time" + + "github.com/docker/docker-agent/pkg/chat" ) var ( @@ -17,21 +20,34 @@ var ( ErrGeneratedFileNotFound = errors.New("generated file not found in manifest") // ErrInvalidGeneratedFilePath is returned for a path that can never be a - // workspacemedia.Write result (empty, absolute, traversal, NUL, ...). + // pkg/workspacemedia write result for its root kind (empty, traversal, + // NUL, wrong absolute/relative shape, ...). ErrInvalidGeneratedFilePath = errors.New("invalid generated file path") + + // ErrInvalidGeneratedFileRoot is returned for a root kind the manifest + // does not record. + ErrInvalidGeneratedFileRoot = errors.New("invalid generated file root kind") ) -// GeneratedFile is one generated-media manifest record: a workspace file -// written by materialization on behalf of the owning session. +// GeneratedFile is one generated-media manifest record: a file written by +// materialization on behalf of the owning session. type GeneratedFile struct { // SessionID is the OWNING session — the session active when the media // was generated, permanent across branch/fork. SessionID string - // RelPath is the workspace-relative, slash-separated path exactly as - // returned by workspacemedia.Write. + // RelPath is the exact final path written: workspace-relative and + // slash-separated for Root workspace (as returned by + // workspacemedia.Write), the absolute user-confirmed OS path for Root + // external (as returned by workspacemedia.WriteExternal). RelPath string + // Root is the artifact root kind the path is interpreted against: + // chat.ArtifactRootWorkspace (the default — an empty value is + // normalized to it) or chat.ArtifactRootExternal. Resolution must + // require it to match the reference's ArtifactRoot. + Root chat.ArtifactRootKind + // MimeType is the sanitized MIME type of the written content. MimeType string @@ -39,33 +55,76 @@ type GeneratedFile struct { CreatedAt time.Time } -// GeneratedMediaManifest records which workspace files generated-media +// GeneratedMediaManifest records which files generated-media // materialization wrote. It is the trust anchor for resolving a -// workspace-rooted artifact reference (chat.ArtifactRootWorkspace): a -// workspace path may only be read back if the (owner session, path) pair -// was recorded here by materialization itself — session JSON alone must -// never be able to select an arbitrary workspace file such as ".env" or a -// source file. Only materialization may call AddGeneratedFile. +// generated-media artifact reference (chat.ArtifactRootWorkspace or +// chat.ArtifactRootExternal): a path may only be read back if the (owner +// session, path) pair was recorded here by materialization itself — session +// JSON alone must never be able to select an arbitrary file such as ".env" +// or a source file. Only materialization may call AddGeneratedFile. // // Implemented by the built-in session stores; resolvers obtain it by type // asserting their session.Store. type GeneratedMediaManifest interface { // AddGeneratedFile records file. The path is validated against the - // workspacemedia.Write output shape and rejected with - // ErrInvalidGeneratedFilePath otherwise. + // pkg/workspacemedia output shape for file.Root and rejected with + // ErrInvalidGeneratedFilePath (or ErrInvalidGeneratedFileRoot) + // otherwise. AddGeneratedFile(ctx context.Context, file GeneratedFile) error // LookupGeneratedFile returns the record for (sessionID, relPath), or // ErrGeneratedFileNotFound when materialization never wrote that path // for that session. Invalid inputs fail with ErrInvalidGeneratedFilePath - // (or ErrEmptyID) rather than being normalized. + // (or ErrEmptyID) rather than being normalized. Callers must additionally + // require the returned Root to match their reference's ArtifactRoot. LookupGeneratedFile(ctx context.Context, sessionID, relPath string) (*GeneratedFile, error) } +// normalizeGeneratedFileRoot maps the zero value to the workspace root kind, +// so pre-external callers and legacy rows keep their meaning. +func normalizeGeneratedFileRoot(root chat.ArtifactRootKind) chat.ArtifactRootKind { + if root == "" { + return chat.ArtifactRootWorkspace + } + return root +} + +// validateGeneratedFileRecord vets a manifest record at the add boundary: +// fail closed on any (root, path) combination pkg/workspacemedia could never +// have produced, so neither a buggy writer nor a tampered caller can smuggle +// a mis-rooted path into the manifest. +func validateGeneratedFileRecord(sessionID string, root chat.ArtifactRootKind, relPath string) error { + if err := validateGeneratedFileKey(sessionID, relPath); err != nil { + return err + } + switch normalizeGeneratedFileRoot(root) { + case chat.ArtifactRootWorkspace: + if isExternalGeneratedFilePath(relPath) { + return fmt.Errorf("%w: workspace record with absolute path %q", ErrInvalidGeneratedFilePath, relPath) + } + case chat.ArtifactRootExternal: + if !isExternalGeneratedFilePath(relPath) { + return fmt.Errorf("%w: external record with non-absolute or unclean path %q", ErrInvalidGeneratedFilePath, relPath) + } + default: + return fmt.Errorf("%w: %q", ErrInvalidGeneratedFileRoot, root) + } + return nil +} + +// isExternalGeneratedFilePath reports whether p has the one shape +// workspacemedia.WriteExternal can return: an absolute, already-clean OS +// path. +func isExternalGeneratedFilePath(p string) bool { + return filepath.IsAbs(p) && filepath.Clean(p) == p +} + // validateGeneratedFileKey vets a manifest key at the API boundary, on both -// write and lookup: fail closed on anything workspacemedia.Write could never -// have returned, so neither a buggy writer nor a tampered session JSON can -// smuggle an absolute or traversing path through the manifest. +// write and lookup. A key is either the workspace-relative shape +// workspacemedia.Write guarantees or the absolute external shape +// workspacemedia.WriteExternal guarantees; anything else — traversal, NUL, +// stray backslashes in a relative path — fails closed so a tampered session +// JSON cannot probe arbitrary files through the manifest. func validateGeneratedFileKey(sessionID, relPath string) error { if sessionID == "" { return ErrEmptyID @@ -73,7 +132,13 @@ func validateGeneratedFileKey(sessionID, relPath string) error { if relPath == "" { return fmt.Errorf("%w: empty path", ErrInvalidGeneratedFilePath) } - if strings.ContainsAny(relPath, "\x00\\") { + if strings.ContainsRune(relPath, '\x00') { + return fmt.Errorf("%w: %q", ErrInvalidGeneratedFilePath, relPath) + } + if isExternalGeneratedFilePath(relPath) { + return nil + } + if strings.ContainsRune(relPath, '\\') { return fmt.Errorf("%w: %q", ErrInvalidGeneratedFilePath, relPath) } // fs.ValidPath rejects absolute paths, ".." segments, empty segments, @@ -93,9 +158,10 @@ func generatedFileKey(sessionID, relPath string) string { } func (s *InMemorySessionStore) AddGeneratedFile(_ context.Context, file GeneratedFile) error { - if err := validateGeneratedFileKey(file.SessionID, file.RelPath); err != nil { + if err := validateGeneratedFileRecord(file.SessionID, file.Root, file.RelPath); err != nil { return err } + file.Root = normalizeGeneratedFileRoot(file.Root) s.generatedFiles.Store(generatedFileKey(file.SessionID, file.RelPath), file) return nil } @@ -127,16 +193,17 @@ func (s *InMemorySessionStore) deleteGeneratedFiles(sessionID string) { } func (s *SQLiteSessionStore) AddGeneratedFile(ctx context.Context, file GeneratedFile) error { - if err := validateGeneratedFileKey(file.SessionID, file.RelPath); err != nil { + if err := validateGeneratedFileRecord(file.SessionID, file.Root, file.RelPath); err != nil { return err } _, err := s.db.ExecContext(ctx, ` - INSERT INTO generated_media_manifest (session_id, rel_path, mime_type, created_at) - VALUES (?, ?, ?, ?) + INSERT INTO generated_media_manifest (session_id, rel_path, root_kind, mime_type, created_at) + VALUES (?, ?, ?, ?, ?) ON CONFLICT (session_id, rel_path) DO UPDATE SET + root_kind = excluded.root_kind, mime_type = excluded.mime_type, created_at = excluded.created_at - `, file.SessionID, file.RelPath, file.MimeType, file.CreatedAt.UTC().Format(time.RFC3339Nano)) + `, file.SessionID, file.RelPath, string(normalizeGeneratedFileRoot(file.Root)), file.MimeType, file.CreatedAt.UTC().Format(time.RFC3339Nano)) return err } @@ -145,17 +212,18 @@ func (s *SQLiteSessionStore) LookupGeneratedFile(ctx context.Context, sessionID, return nil, err } file := GeneratedFile{SessionID: sessionID, RelPath: relPath} - var createdAt string + var root, createdAt string err := s.db.QueryRowContext(ctx, ` - SELECT mime_type, created_at FROM generated_media_manifest + SELECT root_kind, mime_type, created_at FROM generated_media_manifest WHERE session_id = ? AND rel_path = ? - `, sessionID, relPath).Scan(&file.MimeType, &createdAt) + `, sessionID, relPath).Scan(&root, &file.MimeType, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: %q", ErrGeneratedFileNotFound, relPath) } if err != nil { return nil, err } + file.Root = normalizeGeneratedFileRoot(chat.ArtifactRootKind(root)) file.CreatedAt = parseCreatedAt(createdAt) return &file, nil } diff --git a/pkg/session/generated_media_manifest_test.go b/pkg/session/generated_media_manifest_test.go index 9a32888bae..6bc1c64a59 100644 --- a/pkg/session/generated_media_manifest_test.go +++ b/pkg/session/generated_media_manifest_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/chat" ) // manifestStores runs a subtest against both built-in Store implementations, @@ -69,15 +71,14 @@ func TestGeneratedMediaManifest_RefusesUnrecordedPaths(t *testing.T) { } // TestGeneratedMediaManifest_RejectsInvalidPaths pins the API-boundary -// validation on BOTH write and lookup: shapes workspacemedia.Write can never -// return (absolute, traversal, backslashes, NUL, empty/dot segments) fail -// with ErrInvalidGeneratedFilePath before touching storage. +// validation on BOTH write and lookup: shapes pkg/workspacemedia can never +// return (traversal, backslashes in relative paths, NUL, empty/dot +// segments, unclean absolutes) fail with ErrInvalidGeneratedFilePath before +// touching storage. func TestGeneratedMediaManifest_RejectsInvalidPaths(t *testing.T) { t.Parallel() invalid := []string{ "", - "/etc/passwd", - "/abs/cat.png", "../outside.png", "images/../../outside.png", "images/./cat.png", @@ -85,6 +86,8 @@ func TestGeneratedMediaManifest_RejectsInvalidPaths(t *testing.T) { "images/cat.png/", `images\cat.png`, "cat\x00.png", + "/abs\x00/cat.png", + "/abs/../cat.png", ".", "..", } @@ -107,6 +110,72 @@ func TestGeneratedMediaManifest_RejectsInvalidPaths(t *testing.T) { }) } +// TestGeneratedMediaManifest_ExternalRoundTrip: a user-confirmed external +// record stores the absolute confirmed path with the external root kind and +// returns both on lookup, so a resolver can require root agreement. +func TestGeneratedMediaManifest_ExternalRoundTrip(t *testing.T) { + t.Parallel() + manifestStores(t, func(t *testing.T, _ Store, manifest GeneratedMediaManifest) { + t.Helper() + require.NoError(t, manifest.AddGeneratedFile(t.Context(), GeneratedFile{ + SessionID: "owner", + RelPath: "/tmp/exports/cat.png", + Root: chat.ArtifactRootExternal, + MimeType: "image/png", + CreatedAt: time.Now(), + })) + + got, err := manifest.LookupGeneratedFile(t.Context(), "owner", "/tmp/exports/cat.png") + require.NoError(t, err) + assert.Equal(t, chat.ArtifactRootExternal, got.Root) + assert.Equal(t, "/tmp/exports/cat.png", got.RelPath) + }) +} + +// TestGeneratedMediaManifest_WorkspaceRootNormalized: pre-external records +// (empty root kind) keep their workspace meaning on read-back. +func TestGeneratedMediaManifest_WorkspaceRootNormalized(t *testing.T) { + t.Parallel() + manifestStores(t, func(t *testing.T, _ Store, manifest GeneratedMediaManifest) { + t.Helper() + require.NoError(t, manifest.AddGeneratedFile(t.Context(), GeneratedFile{ + SessionID: "owner", RelPath: "cat.png", MimeType: "image/png", CreatedAt: time.Now(), + })) + got, err := manifest.LookupGeneratedFile(t.Context(), "owner", "cat.png") + require.NoError(t, err) + assert.Equal(t, chat.ArtifactRootWorkspace, got.Root) + }) +} + +// TestGeneratedMediaManifest_RejectsMisRootedRecords: the add boundary +// refuses any (root, path) combination pkg/workspacemedia could never have +// produced, and unrecorded absolute paths still fail closed on lookup — a +// tampered session JSON cannot probe /etc/passwd through the manifest. +func TestGeneratedMediaManifest_RejectsMisRootedRecords(t *testing.T) { + t.Parallel() + manifestStores(t, func(t *testing.T, _ Store, manifest GeneratedMediaManifest) { + t.Helper() + add := func(root chat.ArtifactRootKind, p string) error { + return manifest.AddGeneratedFile(t.Context(), GeneratedFile{ + SessionID: "owner", RelPath: p, Root: root, MimeType: "image/png", CreatedAt: time.Now(), + }) + } + require.ErrorIs(t, add("", "/etc/passwd"), ErrInvalidGeneratedFilePath, + "a workspace record must never carry an absolute path") + require.ErrorIs(t, add(chat.ArtifactRootWorkspace, "/abs/cat.png"), ErrInvalidGeneratedFilePath) + require.ErrorIs(t, add(chat.ArtifactRootExternal, "cat.png"), ErrInvalidGeneratedFilePath, + "an external record must carry an absolute path") + require.ErrorIs(t, add(chat.ArtifactRootExternal, "/abs/../cat.png"), ErrInvalidGeneratedFilePath, + "an external record must carry a clean path") + require.ErrorIs(t, add("attacker-root", "/abs/cat.png"), ErrInvalidGeneratedFileRoot) + + for _, p := range []string{"/etc/passwd", "/abs/cat.png"} { + _, err := manifest.LookupGeneratedFile(t.Context(), "owner", p) + require.ErrorIs(t, err, ErrGeneratedFileNotFound, "unrecorded absolute path %q must be refused", p) + } + }) +} + // TestGeneratedMediaManifest_DeleteSessionPrunesRecords: the manifest table // has no foreign key (the session row may not exist yet when materialization // records a file), so DeleteSession must prune records explicitly. diff --git a/pkg/session/migrations.go b/pkg/session/migrations.go index 8576232e43..faeec625b6 100644 --- a/pkg/session/migrations.go +++ b/pkg/session/migrations.go @@ -462,6 +462,12 @@ func getAllMigrations() []Migration { `, DownSQL: `DROP TABLE IF EXISTS generated_media_manifest`, }, + { + ID: 29, + Name: "029_add_root_kind_to_generated_media_manifest", + Description: "Add root_kind to generated_media_manifest so user-confirmed out-of-workspace generated files stay manifest-gated alongside workspace-relative ones", + UpSQL: `ALTER TABLE generated_media_manifest ADD COLUMN root_kind TEXT NOT NULL DEFAULT 'workspace'`, + }, } } diff --git a/pkg/session/migrations_pinned_test.go b/pkg/session/migrations_pinned_test.go index f25a66f654..ee075528ac 100644 --- a/pkg/session/migrations_pinned_test.go +++ b/pkg/session/migrations_pinned_test.go @@ -39,7 +39,7 @@ func TestMigrationCatalogIsContentPinned(t *testing.T) { got := digestMigrationCatalog(getAllMigrations()) - const wantDigest = "18f9416ab037c50b6cfef0c9ea42787ce53dc303ed3c119974b4eab24eba55e6" + const wantDigest = "3d7db51380ef6b4ef5958a85eee1be279a6a96f7a11bf77b1b68f3a821218d9d" if got != wantDigest { t.Fatalf(`migration catalogue content has changed. diff --git a/pkg/tui/dialog/media_escape_schema_test.go b/pkg/tui/dialog/media_escape_schema_test.go new file mode 100644 index 0000000000..240cb8a118 --- /dev/null +++ b/pkg/tui/dialog/media_escape_schema_test.go @@ -0,0 +1,72 @@ +package dialog + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +// These tests pin how the real elicitation dialog interprets the runtime's +// workspace-escape confirmation schema (runtime.MediaEscapeDecisionSchema): +// a bare Enter must submit the safe decline choice, and the affirmative +// choice must require an explicit selection. Together with the runtime-side +// content check in confirmMediaEscape they guarantee no external write +// happens without an explicit affirmative answer. + +func newMediaEscapeDialog(t *testing.T) *ElicitationDialog { + t.Helper() + d, ok := NewElicitationDialog("Save cat.png outside the workspace?", runtime.MediaEscapeDecisionSchema(), nil, "elic-1").(*ElicitationDialog) + require.True(t, ok) + d.Init() + d.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) + return d +} + +func elicitationResponse(t *testing.T, cmd tea.Cmd) messages.ElicitationResponseMsg { + t.Helper() + resp, ok := firstMsgOfType[messages.ElicitationResponseMsg](collectMsgs(cmd)) + require.True(t, ok, "submitting must produce an elicitation response") + return resp +} + +func TestElicitationDialog_MediaEscapeSchema_BareEnterIsSafeChoice(t *testing.T) { + t.Parallel() + d := newMediaEscapeDialog(t) + + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + resp := elicitationResponse(t, cmd) + assert.Equal(t, tools.ElicitationActionAccept, resp.Action) + assert.Equal(t, runtime.MediaEscapeDeclineChoice, resp.Content[runtime.MediaEscapeDecisionField], + "a bare Enter must submit the safe choice, never the external-write one") +} + +func TestElicitationDialog_MediaEscapeSchema_ExplicitSelectionAccepts(t *testing.T) { + t.Parallel() + d := newMediaEscapeDialog(t) + + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + resp := elicitationResponse(t, cmd) + assert.Equal(t, tools.ElicitationActionAccept, resp.Action) + assert.Equal(t, runtime.MediaEscapeAcceptChoice, resp.Content[runtime.MediaEscapeDecisionField], + "the affirmative choice must be reachable by explicit selection") +} + +func TestElicitationDialog_MediaEscapeSchema_EscapeCancels(t *testing.T) { + t.Parallel() + d := newMediaEscapeDialog(t) + + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + resp := elicitationResponse(t, cmd) + assert.Equal(t, tools.ElicitationActionCancel, resp.Action) + assert.Empty(t, resp.Content) +} diff --git a/pkg/workspacemedia/classify.go b/pkg/workspacemedia/classify.go new file mode 100644 index 0000000000..abdbdac959 --- /dev/null +++ b/pkg/workspacemedia/classify.go @@ -0,0 +1,82 @@ +package workspacemedia + +import ( + "slices" + "strings" +) + +// PathClass is the outcome of classifying a model-requested target path +// before any I/O. Classification is purely lexical: a PathWorkspaceRelative +// path can still be refused at write time when a symlinked parent resolves +// outside the root (surfaced as [ErrPathEscape] by [Write]). +type PathClass int + +const ( + // PathInvalid marks a path that names nothing usable: empty, only + // separators/dots, an invalid or Windows-reserved final segment. It is + // not confirmable — callers should fall back to a safe generated name. + PathInvalid PathClass = iota + + // PathWorkspaceRelative marks a path contained under the workspace + // root, directly writable via [Write]. + PathWorkspaceRelative + + // PathEscaping marks a path that targets a location outside the + // workspace: absolute, traversing above the root via "..", or rooted at + // a home directory via a leading "~" segment. Callers must obtain an + // explicit user confirmation before writing there. + PathEscaping +) + +// ClassifyRequestedPath classifies requested and, for PathWorkspaceRelative, +// returns the normalized slash-separated relative path to hand to [Write] +// (both separator styles accepted; interior "." and ".." segments resolved +// lexically, so "a/../b.png" is contained rather than escaping). For every +// other class the second return is "". +func ClassifyRequestedPath(requested string) (PathClass, string) { + if isAbsolutePath(requested) { + return PathEscaping, "" + } + segments := splitPathSegments(requested) + if len(segments) > 0 && strings.HasPrefix(segments[0], "~") { + return PathEscaping, "" + } + + var stack []string + for _, seg := range segments { + switch seg { + case ".": + case "..": + if len(stack) == 0 { + return PathEscaping, "" + } + stack = stack[:len(stack)-1] + default: + stack = append(stack, seg) + } + } + cleaned := strings.Join(stack, "/") + if _, _, _, err := splitRequestedPath(cleaned); err != nil { + return PathInvalid, "" + } + return PathWorkspaceRelative, cleaned +} + +// RequestedBasename returns the final meaningful segment of a requested +// path — the name to redirect to when the full path is refused — or "" +// when none exists (empty, separators only, or only "."/".." segments). +func RequestedBasename(requested string) string { + segments := splitPathSegments(requested) + for _, seg := range slices.Backward(segments) { + if seg != "." && seg != ".." { + return seg + } + } + return "" +} + +// splitPathSegments splits on both separator styles, mirroring +// splitRequestedPath: model-provided paths may be Windows-style. +func splitPathSegments(requested string) []string { + return strings.FieldsFunc(requested, func(r rune) bool { return r == '/' || r == '\\' }) +} diff --git a/pkg/workspacemedia/classify_test.go b/pkg/workspacemedia/classify_test.go new file mode 100644 index 0000000000..18bc168e2d --- /dev/null +++ b/pkg/workspacemedia/classify_test.go @@ -0,0 +1,66 @@ +package workspacemedia + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClassifyRequestedPath(t *testing.T) { + t.Parallel() + tests := []struct { + requested string + class PathClass + cleaned string + }{ + {"cat.png", PathWorkspaceRelative, "cat.png"}, + {"images/cat.png", PathWorkspaceRelative, "images/cat.png"}, + {`images\cat.png`, PathWorkspaceRelative, "images/cat.png"}, + {"./images/cat.png", PathWorkspaceRelative, "images/cat.png"}, + {"a/../b.png", PathWorkspaceRelative, "b.png"}, + {"a/b/../../c.png", PathWorkspaceRelative, "c.png"}, + + {"/abs/cat.png", PathEscaping, ""}, + {`\abs\cat.png`, PathEscaping, ""}, + {`C:\abs\cat.png`, PathEscaping, ""}, + {"../cat.png", PathEscaping, ""}, + {"a/../../cat.png", PathEscaping, ""}, + {"~/cat.png", PathEscaping, ""}, + {"~", PathEscaping, ""}, + {"~user/cat.png", PathEscaping, ""}, + + {"//", PathEscaping, ""}, + + {"", PathInvalid, ""}, + {".", PathInvalid, ""}, + {"a/..", PathInvalid, ""}, + {"CON.png", PathInvalid, ""}, + {"...", PathInvalid, ""}, + } + for _, tt := range tests { + class, cleaned := ClassifyRequestedPath(tt.requested) + assert.Equal(t, tt.class, class, "class of %q", tt.requested) + assert.Equal(t, tt.cleaned, cleaned, "cleaned form of %q", tt.requested) + } +} + +func TestRequestedBasename(t *testing.T) { + t.Parallel() + tests := []struct { + requested string + want string + }{ + {"cat.png", "cat.png"}, + {"/abs/dir/cat.png", "cat.png"}, + {"../outside/cat.png", "cat.png"}, + {`C:\dir\cat.png`, "cat.png"}, + {"dir/name/..", "name"}, + {"", ""}, + {"/", ""}, + {"../..", ""}, + {"./.", ""}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, RequestedBasename(tt.requested), "basename of %q", tt.requested) + } +} diff --git a/pkg/workspacemedia/external.go b/pkg/workspacemedia/external.go new file mode 100644 index 0000000000..b3e140c42d --- /dev/null +++ b/pkg/workspacemedia/external.go @@ -0,0 +1,80 @@ +package workspacemedia + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +// WriteExternal stores data at the user-confirmed absolute target with the +// same guarantees as [Write]: the final name is claimed with +// O_CREATE|O_EXCL (dash-suffixed on collision, never overwriting), bytes are +// published atomically, and a conflicting extension is corrected to match +// the MIME type. Missing parent directories are created. Result.RelPath +// carries the ABSOLUTE final path written. +// +// Unlike [Write] there is no containment root: the target was explicitly +// confirmed by the user, so symlinked parents are followed as any user path +// would be. Only the basename's extension is adjusted; everything else must +// be written exactly as confirmed, which is why a non-absolute or unclean +// target — something the confirmation flow could never have shown — is +// rejected with [ErrPathEscape], as is a target that already exists as a +// directory (the confirmation flow pre-resolves directory targets to a +// file inside them). +func WriteExternal(confirmedPath string, data []byte, mimeType string) (Result, error) { + return writeExternal(confirmedPath, bytes.NewReader(data), mimeType) +} + +func writeExternal(confirmedPath string, r io.Reader, mimeType string) (Result, error) { + if !filepath.IsAbs(confirmedPath) { + return Result{}, escapeError(confirmedPath, errors.New("external target must be absolute")) + } + if filepath.Clean(confirmedPath) != confirmedPath { + return Result{}, escapeError(confirmedPath, errors.New("external target must be a clean path")) + } + dir, base := filepath.Dir(confirmedPath), filepath.Base(confirmedPath) + if base == "." || base == string(filepath.Separator) || strings.TrimRight(base, ". ") == "" { + return Result{}, escapeError(confirmedPath, errors.New("external target has no filename")) + } + // The confirmation flow resolves an existing-directory target to a file + // inside it before asking the user (see pkg/runtime.externalMediaTarget), + // so a directory here means the target changed after confirmation. + // Dash-suffixing next to it would silently write a sibling the user + // never confirmed; fail loudly instead. + if info, err := os.Stat(confirmedPath); err == nil && info.IsDir() { + return Result{}, escapeError(confirmedPath, errors.New("external target is an existing directory")) + } + + requestedExt := path.Ext(base) + if requestedExt == base { + // Dotfile-style name (".name"): the whole segment is the base. + requestedExt = "" + } + base = strings.TrimSuffix(base, requestedExt) + ext, corrected := finalExtension(requestedExt, mimeType) + + if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // ordinary user directories, same 0o755 convention as Write + return Result{}, fmt.Errorf("create directory %q: %w", dir, err) + } + root, err := os.OpenRoot(dir) + if err != nil { + return Result{}, fmt.Errorf("open target directory: %w", err) + } + defer root.Close() + + rel, err := claimAndPublish(root, "", base, ext, r) + if err != nil { + return Result{}, err + } + res := Result{RelPath: filepath.Join(dir, rel)} + if corrected { + res.ExtensionCorrected = true + res.RequestedExtension = requestedExt + } + return res, nil +} diff --git a/pkg/workspacemedia/external_test.go b/pkg/workspacemedia/external_test.go new file mode 100644 index 0000000000..ae5b2cfb80 --- /dev/null +++ b/pkg/workspacemedia/external_test.go @@ -0,0 +1,103 @@ +package workspacemedia + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteExternal_WritesConfirmedTarget(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "exports", "cat.png") + + res, err := WriteExternal(target, []byte{0x01, 0x02}, "image/png") + require.NoError(t, err) + assert.Equal(t, target, res.RelPath) + assert.False(t, res.ExtensionCorrected) + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, []byte{0x01, 0x02}, data) + + entries, err := os.ReadDir(filepath.Dir(target)) + require.NoError(t, err) + require.Len(t, entries, 1, "no temp file may survive the atomic publish") +} + +func TestWriteExternal_NeverOverwrites(t *testing.T) { + t.Parallel() + dir := t.TempDir() + target := filepath.Join(dir, "cat.png") + require.NoError(t, os.WriteFile(target, []byte("existing"), 0o644)) + + res, err := WriteExternal(target, []byte("new"), "image/png") + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "cat-1.png"), res.RelPath, "an existing file must get a dash suffix") + + existing, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, []byte("existing"), existing, "the pre-existing file must be untouched") + + written, err := os.ReadFile(res.RelPath) + require.NoError(t, err) + assert.Equal(t, []byte("new"), written) +} + +func TestWriteExternal_CorrectsExtension(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + res, err := WriteExternal(filepath.Join(dir, "cat.txt"), []byte{0x01}, "image/png") + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "cat.png"), res.RelPath) + assert.True(t, res.ExtensionCorrected) + assert.Equal(t, ".txt", res.RequestedExtension) +} + +func TestWriteExternal_RejectsUnconfirmableTargets(t *testing.T) { + t.Parallel() + for _, target := range []string{ + "relative/cat.png", + "cat.png", + "", + "/abs/../cat.png", + "/", + } { + _, err := WriteExternal(target, []byte{0x01}, "image/png") + require.ErrorIs(t, err, ErrPathEscape, "target %q must be rejected", target) + } +} + +func TestWriteExternal_FailedWriteLeavesNoClaim(t *testing.T) { + t.Parallel() + dir := t.TempDir() + target := filepath.Join(dir, "cat.png") + + // An unreadable source is not injectable through the []byte API, so + // provoke the failure via the reader-level implementation. + _, err := writeExternal(target, failingReader{}, "image/png") + require.Error(t, err) + + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr), "a failed write must not leave an empty claim behind") +} + +func TestWriteExternal_RejectsExistingDirectoryTarget(t *testing.T) { + t.Parallel() + parent := t.TempDir() + target := filepath.Join(parent, "exports") + require.NoError(t, os.Mkdir(target, 0o755)) + + _, err := WriteExternal(target, []byte{0x01}, "image/png") + require.ErrorIs(t, err, ErrPathEscape, "a directory at the confirmed path must be rejected, not dash-suffixed") + + entries, err := os.ReadDir(parent) + require.NoError(t, err) + require.Len(t, entries, 1, "no sibling file may appear next to the directory") + inside, err := os.ReadDir(target) + require.NoError(t, err) + assert.Empty(t, inside, "nothing may be written inside the directory without a confirmed file path") +} diff --git a/pkg/workspacemedia/writer.go b/pkg/workspacemedia/writer.go index 481d015b48..8968295ad3 100644 --- a/pkg/workspacemedia/writer.go +++ b/pkg/workspacemedia/writer.go @@ -50,8 +50,9 @@ var ErrPathEscape = errors.New("path escapes the workspace or has invalid segmen // Result describes a completed write. type Result struct { - // RelPath is the exact final path written, relative to the workspace - // root and slash-separated. Persist this verbatim. + // RelPath is the exact final path written: relative to the workspace + // root and slash-separated for [Write], the absolute OS path for + // [WriteExternal]. Persist this verbatim. RelPath string // ExtensionCorrected reports that the requested filename's extension @@ -295,6 +296,21 @@ func isReservedName(segment string) bool { return windowsReservedNames[strings.ToUpper(name)] } +// DefaultFilename returns the filename [Write] or [WriteExternal] would +// claim for name with mimeType, before any collision suffixing: name keeps +// its extension when compatible with the MIME type, otherwise the +// MIME-derived one replaces (or supplies) it. Used to pre-resolve the exact +// final name when a user-confirmed external target is a directory. +func DefaultFilename(name, mimeType string) string { + ext := path.Ext(name) + if ext == name { + // Dotfile-style name (".name"): the whole segment is the base. + ext = "" + } + final, _ := finalExtension(ext, mimeType) + return strings.TrimSuffix(name, ext) + final +} + // finalExtension picks the filename extension: the MIME-derived one when // the type is known, otherwise the requested one (".bin" when neither // exists). corrected is true only when a requested extension conflicted diff --git a/pkg/workspacemedia/writer_test.go b/pkg/workspacemedia/writer_test.go index 47c034ca86..418e0f061c 100644 --- a/pkg/workspacemedia/writer_test.go +++ b/pkg/workspacemedia/writer_test.go @@ -300,3 +300,23 @@ func TestWrite_ParentDirIsFileFails(t *testing.T) { require.Error(t, err) assert.NotErrorIs(t, err, ErrPathEscape) } + +func TestDefaultFilename(t *testing.T) { + t.Parallel() + tests := []struct { + name, mimeType, want string + }{ + {"cat.png", "image/png", "cat.png"}, + {"cat.jpeg", "image/jpeg", "cat.jpeg"}, + {"cat.txt", "image/png", "cat.png"}, + {"generated-1", "image/png", "generated-1.png"}, + {"generated-1", "", "generated-1.bin"}, + {".env", "image/png", ".env.png"}, + } + for _, tt := range tests { + t.Run(tt.name+"/"+tt.mimeType, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, DefaultFilename(tt.name, tt.mimeType)) + }) + } +}