Skip to content

feat(server): save_to_file — write full tool output to a whitelisted file (spec 076) - #1073

Draft
electrolobzik wants to merge 2 commits into
smart-mcp-proxy:mainfrom
HaloCollar:feat/tool-response-save-to-file
Draft

electrolobzik wants to merge 2 commits into
smart-mcp-proxy:mainfrom
HaloCollar:feat/tool-response-save-to-file

Conversation

@electrolobzik

@electrolobzik electrolobzik commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds an optional save_to_file argument to call_tool_read, call_tool_write and call_tool_destructive, together with save_format and save_overwrite. When it is set, the full, untruncated upstream response is written to the given absolute path. The call then returns a small JSON envelope instead of the body:

{"saved_to": "...", "bytes": 123456, "sha256": "...", "format": "text|json", "content_blocks": 3, "non_text_blocks": 0, "preview": "...", "truncated_preview": true}

Motivation: agents sometimes need a large upstream payload, such as a file tree, design metadata or a long log. Today they must either raise tool_response_limit globally or request the same payload again in slices. With save_to_file, the agent gets the whole response on disk once and can grep or page it locally.

Security model (internal/outputfile)

Where files can go

  • A path must resolve inside one of the new tool_output_roots config entries. These are absolute paths, set in the JSON config only; there is deliberately no Settings-UI field.
  • The config rejects overlapping or nested roots as a load error. This check is case-insensitive on Windows and macOS.
  • Resolve also fails closed at call time if two roots alias each other on disk. It compares the literal, resolved and parent-resolved forms of each root. It also walks each root's path one component at a time and follows every symlink hop by hand, so a root reached through another root is refused, even when a planted symlink would make the fully resolved forms disjoint.
  • UNC and device volumes (\\host\share, \\?\, \??\, GLOBALROOT) are refused before any filesystem call, unless a configured root is on that exact volume.

How the root is opened

  • Resolve matches the target against the whitelist after running EvalSymlinks on its deepest existing ancestor.
  • It creates the root with mode 0700 if the root is missing.
  • It opens a single Go os.Root handle and checks that handle's identity against both the resolved root and the configured path.
  • On Unix it also checks ownership. Every symlink on the configured root's path must be owned by root or by the proxy's user. The root itself must be owned by the proxy's user and must not be world-writable.

How the file is written

  • Write uses only that handle: MkdirAll, an O_EXCL 0600 temp file, Sync, then Rename. A symlink swapped into an intermediate directory after validation is refused, so the write can never land outside the directory that Resolve validated.
  • The size is capped by tool_output_max_bytes (default 50 MiB).
  • Nothing is overwritten unless save_overwrite: true is set.
  • Temp files are cleaned up on every error path.

What is saved and returned

  • Redaction (Spec 112 scrubbing) and output validation (Spec 056) both run before the save. A blocked result is never written to disk.
  • The preview envelope is spotlighted like any other forwarded content (Spec 054).
  • A failed save is still recorded in the tool-call record and the activity log with status=error. It is never silently forwarded inline.

Checks before dispatch

These are all rejected before the upstream call, so a destructive tool never runs only to have its result thrown away:

  • argument types and the save_format enum;
  • an empty save_to_file;
  • save_format or save_overwrite given without save_to_file;
  • (new) the path itself: feature disabled, path outside the roots, or an existing file without save_overwrite.

Denials are audited (auditAuthz deny) and emitted as a blocked policy decision.

Other changes

Merge with current main

upstream/main (9894081) was merged in; the branch was not rebased. Conflicts were resolved in cmd/mcpproxy/call_cmd.go, docs/configuration/config-file.md, internal/server/content_forward.go, internal/server/mcp.go and oas/docs.go.

Tests

  • go build ./... and go build -tags server ./... pass.
  • golangci-lint v2.9.0 reports 0 issues, both plain and with --build-tags server.
  • GOOS=windows go vet passes on the touched packages.
  • New tests:
    • internal/outputfile/overlap_test.go: alias, overlap and UNC/device volumes;
    • ownership_unix_test.go;
    • pre-flight tests in internal/server/save_to_file_test.go that assert the upstream is not called;
    • a config overlap validation test.
  • Some failures show up only in an offline sandbox and also fail on pristine main: the tokenizer-dependent tests (TestRuntime_CalculateTokenSavings_Wiring, TestDescribeTool_DefinitionTokenBudget, TestDeferredDirect_TokenReduction_*). Fork CI has not run yet.

Known limitations

  • The Unix ownership rule refuses roots owned by another user. That covers a daemon running as root that writes into a user's directory, and directories shared through ACLs or bind mounts.
  • Two concurrent saves to the same new path, both with save_overwrite=false, can race. The last Rename wins.
  • The activity record does not yet include saved_to.
  • The saved body is still stored in the BoltDB tool-call history. This is existing behaviour and unchanged.
  • The frontend bundle was not rebuilt. frontend/src/views/settings/fields.ts only gains two read-only field descriptions.

Still a draft; happy to split or rework any part of this.

🤖 Generated with Claude Code

Roman Chernyak and others added 2 commits August 27, 2026 14:14
…file (spec 076)

Adds an optional `save_to_file` (+ `save_format`, `save_overwrite`) argument
to call_tool_read/write/destructive. When set, the full untruncated upstream
response is written to the given absolute path and the call returns a small
JSON envelope (saved_to, bytes, sha256, format, content_blocks,
non_text_blocks, preview, truncated_preview) instead of the body, so large
responses no longer force raising tool_response_limit globally.

Security model (internal/outputfile): paths must resolve inside one of the
new `tool_output_roots` config entries; `Resolve` matches the whitelist
after EvalSymlinks on the deepest existing ancestor, creates the root 0700
if missing, opens a single Go 1.25 os.Root handle and identity-checks it;
`Write` uses only that handle (MkdirAll / O_EXCL 0600 temp / Sync / Rename),
so intermediate-symlink swaps after validation are refused and the write can
never be redirected outside the directory Resolve validated. Size capped by
`tool_output_max_bytes` (default 50 MiB), no overwrite unless asked, temp
files cleaned on every error path. Redaction runs before the save; failed
saves still reach the tool-call record and activity log with status=error
and are never silently forwarded inline.

Argument validation runs before upstream dispatch (types, save_format enum,
empty save_to_file, save_format/save_overwrite without save_to_file) so a
destructive tool is never executed only to discard its result. CLI:
`mcpproxy call tool --save-to-file/--save-format/--save-overwrite`.

Config: `tool_output_roots` (JSON file only; deliberately no Settings-UI
field) and `tool_output_max_bytes`, both hot-reloaded. Docs in
docs/configuration.md; spec in specs/076-tool-output-save-to-file/.

Known gaps: oas/docs.go was hand-mirrored (make swagger-verify not run) and
the frontend bundle was not rebuilt.
Bring spec 076 (save_to_file) up to date with upstream main (9894081).

Conflict resolutions:
- cmd/mcpproxy/call_cmd.go: save flags moved onto the tool-read /
  tool-write / tool-destructive subcommands.
- internal/server/mcp.go, content_forward.go: the save branch now sits
  before TOON encoding, uses the smart-mcp-proxy#935 activity status and Spec 116
  recordToolCallAt; the token recount is TOON-aware.
- oas/docs.go, oas/swagger.yaml: regenerated with swag v2.0.0-rc4.
- docs/configuration/config-file.md: merged both sections.

Follow-ups needed by upstream changes:
- callToolMetaKeys includes the save_* keys (smart-mcp-proxy#1317 flattening).
- ToolOutputMaxBytes added to the numeric omitempty allowlist (smart-mcp-proxy#1175).
- The Spec 085 menu-surface test pins the save_* parameters.
- The EmbeddedResource round-trip test was updated for mcp-go v1.0.0.

Security hardening from review:
- Path pre-flight (disabled, outside roots, existing file) runs before
  dispatch, with auditAuthz deny and a blocked policy decision.
- Output validation (Spec 056) runs before saving; the preview envelope
  is spotlighted (Spec 054).
- Overlapping or aliased tool_output_roots are a config load error and
  fail closed in Resolve, including aliases reached through symlink hops
  (manual physical-path walk).
- Unix: the root and every symlink on its path must be owned by the
  proxy user (or root for symlinks); the root must not be world-writable.
- UNC and device volumes (including \??\ forms) are refused unless a
  root lives on that exact volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant