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)) + }) + } +}