diff --git a/internal/mcp/physical_evidence_open_nonwindows.go b/internal/mcp/physical_evidence_open_nonwindows.go new file mode 100644 index 00000000..6751b957 --- /dev/null +++ b/internal/mcp/physical_evidence_open_nonwindows.go @@ -0,0 +1,15 @@ +//go:build !windows + +package mcp + +import ( + "os" + "syscall" +) + +// openPhysicalEvidenceFile prevents a repository-local FIFO or device from +// blocking the MCP request if the path changes after the pre-open Lstat. The +// caller still verifies the opened handle with f.Stat before reading. +func openPhysicalEvidenceFile(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) +} diff --git a/internal/mcp/physical_evidence_open_windows.go b/internal/mcp/physical_evidence_open_windows.go new file mode 100644 index 00000000..42da0c2b --- /dev/null +++ b/internal/mcp/physical_evidence_open_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package mcp + +import "os" + +// Windows repository paths do not expose POSIX FIFOs. The caller performs +// Lstat before open and f.Stat immediately afterward to reject non-regular +// filesystem objects and replacement races. +func openPhysicalEvidenceFile(path string) (*os.File, error) { + return os.Open(path) +} diff --git a/internal/mcp/read_file_physical_evidence_nonwindows_test.go b/internal/mcp/read_file_physical_evidence_nonwindows_test.go new file mode 100644 index 00000000..9e2e6ab5 --- /dev/null +++ b/internal/mcp/read_file_physical_evidence_nonwindows_test.go @@ -0,0 +1,39 @@ +//go:build !windows + +package mcp + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestReadFilePhysicalEvidenceRejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "evidence.fifo") + require.NoError(t, syscall.Mkfifo(path, 0o600)) + + done := make(chan error, 1) + go func() { + _, _, err := readPhysicalFileEvidence(path) + done <- err + }() + + select { + case err := <-done: + require.Error(t, err) + require.Contains(t, err.Error(), "requires a regular file") + case <-time.After(500 * time.Millisecond): + // Unblock the vulnerable implementation before failing so the test does + // not leak a goroutine or leave cleanup waiting on an open FIFO. + writer, err := os.OpenFile(path, os.O_WRONLY|syscall.O_NONBLOCK, 0) + if err == nil { + _ = writer.Close() + } + <-done + t.Fatal("physical evidence blocked while opening a FIFO") + } +} diff --git a/internal/mcp/read_file_physical_evidence_test.go b/internal/mcp/read_file_physical_evidence_test.go new file mode 100644 index 00000000..7f90f992 --- /dev/null +++ b/internal/mcp/read_file_physical_evidence_test.go @@ -0,0 +1,276 @@ +package mcp + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadFilePhysicalEvidenceHashesFullDiskBuffer(t *testing.T) { + srv, dir := setupTestServer(t) + content := []byte("alpha\nbeta\ngamma\n") + target := filepath.Join(dir, "evidence.txt") + require.NoError(t, os.WriteFile(target, content, 0o644)) + + result := callTool(t, srv, "read_file", map[string]any{ + "path": "evidence.txt", + "physical_evidence": true, + "digest": "sha256", + "offset": 2, + "limit": 1, + }) + require.False(t, result.IsError) + got := decodeFileOpsResult(t, result) + sum := sha256.Sum256(content) + + require.Equal(t, "beta", got["content"]) + require.Equal(t, hex.EncodeToString(sum[:]), got["content_sha256"]) + require.Equal(t, "sha256", got["hash_algorithm"]) + require.Equal(t, "full_file", got["hash_scope"]) + require.Equal(t, "disk", got["hash_source"]) + require.Equal(t, "disk", got["content_source"]) + require.Equal(t, true, got["disk_verified"]) + require.Equal(t, false, got["same_buffer_as_content"]) + require.NotContains(t, got, "content_truncated") + require.Equal(t, float64(len(content)), got["byte_count"]) + resolvedTarget, err := filepath.EvalSymlinks(target) + require.NoError(t, err) + require.Equal(t, resolvedTarget, got["resolved_path"]) + require.Equal(t, "regular", got["file_kind"]) + require.NotEmpty(t, got["read_at"]) +} + +func TestReadFilePhysicalEvidenceSameBufferForFullText(t *testing.T) { + srv, dir := setupTestServer(t) + content := []byte("unchanged full text\n") + require.NoError(t, os.WriteFile(filepath.Join(dir, "full.txt"), content, 0o644)) + + spec, ok := srv.facades.operation("read", "file") + require.True(t, ok) + arguments := normalizeFacadeArguments(spec, map[string]any{ + "operation": "file", + "target": map[string]any{"file": "full.txt"}, + "options": map[string]any{ + "physical_evidence": true, + "digest": "sha256", + }, + }) + result := callTool(t, srv, spec.Legacy, arguments) + require.False(t, result.IsError, toolResultText(result)) + got := decodeFileOpsResult(t, result) + require.Equal(t, string(content), got["content"]) + require.Equal(t, true, got["same_buffer_as_content"]) + require.NotContains(t, got, "content_truncated") +} + +func TestReadFilePhysicalEvidenceBinaryAndEmptyDigests(t *testing.T) { + for _, test := range []struct { + name string + content []byte + }{ + {name: "binary", content: []byte{0x00, 0xff, 0x10, 0x80}}, + {name: "empty", content: []byte{}}, + } { + t.Run(test.name, func(t *testing.T) { + srv, dir := setupTestServer(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "blob.bin"), test.content, 0o644)) + result := callTool(t, srv, "read_file", map[string]any{ + "path": "blob.bin", + "physical_evidence": true, + }) + require.False(t, result.IsError) + got := decodeFileOpsResult(t, result) + sum := sha256.Sum256(test.content) + require.Equal(t, hex.EncodeToString(sum[:]), got["content_sha256"]) + require.Equal(t, float64(len(test.content)), got["byte_count"]) + if test.name == "binary" { + require.Equal(t, false, got["same_buffer_as_content"]) + require.NotContains(t, got, "content_truncated") + } + }) + } +} + +func TestReadFilePhysicalEvidenceMaxCharsRetainsTruncationContract(t *testing.T) { + srv, dir := setupTestServer(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bounded.txt"), []byte("abcdef"), 0o644)) + got := decodeFileOpsResult(t, callTool(t, srv, "read_file", map[string]any{ + "path": "bounded.txt", + "physical_evidence": true, + "max_chars": 3, + })) + require.Equal(t, "abc", got["content"]) + require.Equal(t, true, got["content_truncated"]) + require.Equal(t, float64(3), got["max_chars"]) + require.Equal(t, false, got["same_buffer_as_content"]) +} + +func TestReadFilePhysicalEvidenceETagIgnoresObservationTime(t *testing.T) { + srv, dir := setupTestServer(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stable.txt"), []byte("stable\n"), 0o644)) + args := map[string]any{"path": "stable.txt", "physical_evidence": true} + + first := decodeFileOpsResult(t, callTool(t, srv, "read_file", args)) + second := decodeFileOpsResult(t, callTool(t, srv, "read_file", args)) + require.Equal(t, first["etag"], second["etag"]) +} + +func TestReadFilePhysicalEvidenceRejectsInvalidDigestContract(t *testing.T) { + srv, _ := setupTestServer(t) + + for _, args := range []map[string]any{ + {"path": "main.go", "physical_evidence": true, "digest": "md5"}, + {"path": "main.go", "digest": "sha256"}, + } { + result := callTool(t, srv, "read_file", args) + require.True(t, result.IsError) + } +} + +func TestReadFilePhysicalEvidenceRejectsSameSizeDriftDuringObservation(t *testing.T) { + path := filepath.Join(t.TempDir(), "drift.txt") + require.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) + before, err := os.Stat(path) + require.NoError(t, err) + _, _, err = readPhysicalFileEvidenceObserved(path, func() { + require.NoError(t, os.WriteFile(path, []byte("after!"), 0o644)) + require.NoError(t, os.Chtimes(path, before.ModTime(), before.ModTime())) + }) + require.Error(t, err) + require.Contains(t, err.Error(), "changed while it was being read") +} + +func TestReadFilePhysicalEvidenceRejectsPathReplacementDuringObservation(t *testing.T) { + if os.PathSeparator == '\\' { + t.Skip("replacing an open file is not reliably available on Windows CI") + } + dir := t.TempDir() + path := filepath.Join(dir, "observed.txt") + replacement := filepath.Join(dir, "replacement.txt") + require.NoError(t, os.WriteFile(path, []byte("first"), 0o644)) + require.NoError(t, os.WriteFile(replacement, []byte("other"), 0o644)) + _, _, err := readPhysicalFileEvidenceObserved(path, func() { + require.NoError(t, os.Rename(replacement, path)) + }) + require.Error(t, err) + require.Contains(t, err.Error(), "changed while it was being read") +} + +func TestReadFilePhysicalEvidenceReportsRequestedFileSymlink(t *testing.T) { + if os.PathSeparator == '\\' { + t.Skip("symlink creation is not reliably available on Windows CI") + } + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + link := filepath.Join(dir, "link.txt") + require.NoError(t, os.WriteFile(target, []byte("target"), 0o644)) + require.NoError(t, os.Symlink(target, link)) + content, evidence, err := readPhysicalFileEvidence(link) + require.NoError(t, err) + require.Equal(t, []byte("target"), content) + require.True(t, evidence.symlinkResolved) + resolved, err := filepath.EvalSymlinks(link) + require.NoError(t, err) + require.Equal(t, resolved, evidence.resolvedPath) +} + +func TestReadFilePhysicalEvidenceAncestorSymlinkDoesNotMarkFileAsLink(t *testing.T) { + if os.PathSeparator == '\\' { + t.Skip("symlink creation is not reliably available on Windows CI") + } + dir := t.TempDir() + realDir := filepath.Join(dir, "real") + linkedDir := filepath.Join(dir, "linked") + require.NoError(t, os.Mkdir(realDir, 0o755)) + require.NoError(t, os.Symlink(realDir, linkedDir)) + path := filepath.Join(realDir, "file.txt") + require.NoError(t, os.WriteFile(path, []byte("content"), 0o644)) + _, evidence, err := readPhysicalFileEvidence(filepath.Join(linkedDir, "file.txt")) + require.NoError(t, err) + require.False(t, evidence.symlinkResolved) + require.True(t, strings.HasSuffix(filepath.ToSlash(evidence.resolvedPath), "/real/file.txt")) +} + +func TestReadFilePhysicalEvidenceRejectsOutAndBackSymlinkTarget(t *testing.T) { + if os.PathSeparator == '\\' { + t.Skip("symlink creation is not reliably available on Windows CI") + } + srv, repo := setupTestServer(t) + inside := filepath.Join(repo, "inside.txt") + outside := filepath.Join(t.TempDir(), "outside.txt") + link := filepath.Join(repo, "evidence-link.txt") + require.NoError(t, os.WriteFile(inside, []byte("inside"), 0o644)) + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644)) + require.NoError(t, os.Symlink(inside, link)) + require.NoError(t, srv.guardSymlinkWithinRepo(link)) + + require.NoError(t, os.Remove(link)) + require.NoError(t, os.Symlink(outside, link)) + content, evidence, err := readPhysicalFileEvidence(link) + require.NoError(t, err) + require.Equal(t, []byte("outside"), content) + + require.NoError(t, os.Remove(link)) + require.NoError(t, os.Symlink(inside, link)) + require.NoError(t, srv.guardSymlinkWithinRepo(link), "a fresh post-read resolution alone sees only the restored inside target") + err = srv.guardResolvedPathWithinRepo(link, evidence.resolvedPath) + require.Error(t, err) + require.ErrorIs(t, err, errPathEscape) +} + +func TestReadFilePhysicalEvidenceRequiresSecretIntentForDigest(t *testing.T) { + srv, dir := setupTestServer(t) + content := []byte("PASSWORD=hunter2\n") + path := filepath.Join(dir, ".env") + require.NoError(t, os.WriteFile(path, content, 0o600)) + sum := sha256.Sum256(content) + digest := hex.EncodeToString(sum[:]) + + refused := callTool(t, srv, "read_file", map[string]any{ + "path": ".env", "physical_evidence": true, + }) + require.True(t, refused.IsError) + require.Contains(t, toolResultText(refused), "requires allow_secrets=true") + require.NotContains(t, toolResultText(refused), digest) + + allowed := callTool(t, srv, "read_file", map[string]any{ + "path": ".env", "physical_evidence": true, "allow_secrets": true, + }) + require.False(t, allowed.IsError, toolResultText(allowed)) + got := decodeFileOpsResult(t, allowed) + require.Equal(t, digest, got["content_sha256"]) + require.Equal(t, string(content), got["content"]) +} + +func TestReadFilePhysicalEvidenceInvalidUTF8IsNotSameWireBuffer(t *testing.T) { + srv, dir := setupTestServer(t) + content := []byte{0xff, 0xfe, 'x'} + require.NoError(t, os.WriteFile(filepath.Join(dir, "invalid.txt"), content, 0o644)) + + result := callTool(t, srv, "read_file", map[string]any{ + "path": "invalid.txt", "physical_evidence": true, + }) + require.False(t, result.IsError, toolResultText(result)) + got := decodeFileOpsResult(t, result) + sum := sha256.Sum256(content) + require.Equal(t, hex.EncodeToString(sum[:]), got["content_sha256"]) + require.Equal(t, false, got["same_buffer_as_content"]) +} + +func TestReadFilePhysicalEvidenceIsPublishedByFacadeSchema(t *testing.T) { + srv, _ := setupTestServer(t) + spec, ok := srv.facades.operation("read", "file") + require.True(t, ok) + capability := srv.facadeCapability(spec, true) + schema := capability["input_schema"].(map[string]any) + properties := schema["properties"].(map[string]any) + options := properties["options"].(map[string]any) + fields := options["properties"].(map[string]any) + require.Contains(t, fields, "physical_evidence") + require.Contains(t, fields, "digest") +} diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 828a58ef..e45da2b2 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -137,6 +137,8 @@ func (s *Server) registerCodingTools() { mcp.WithNumber("limit", mcp.Description("Maximum number of lines to return starting at offset. Omit or 0 to read to end of file. Set this (with or without offset) to read a bounded window instead of the whole file.")), mcp.WithBoolean("compress_bodies", mcp.Description("Replace function/method bodies with elided stubs (default: false)")), mcp.WithBoolean("allow_secrets", mcp.Description("Serve secret-shaped values in config / data-leaf files (.env, *.yaml, *.toml, *.properties, ...) verbatim. By default such values are withheld and only their keys are shown. Default: false.")), + mcp.WithBoolean("physical_evidence", mcp.Description("Return disk evidence. Includes SHA-256, byte count, resolved path, provenance, and same-buffer status for the full regular file. The digest covers raw disk bytes before transforms or editor overlays. Default: false.")), + mcp.WithString("digest", mcp.Description("Select the digest. Only sha256 is supported; defaults to sha256 when physical_evidence=true.")), mcp.WithString("keep", mcp.Description("Comma-separated symbol names, IDs, or node kinds whose bodies stay verbatim when compress_bodies is set — every other body in the file is still stubbed. Ignored unless compress_bodies is true.")), mcp.WithString("fidelity_globs", mcp.Description(fidelityGlobsParamDescription)), mcp.WithNumber("max_lines", mcp.Description("When the file exceeds this many lines, collapse runs of leaf statements inside function bodies into `… N lines elided …` markers while keeping declarations and the control-flow skeleton. Falls back to a plain head cut for non-code files. Omit or 0 to disable.")), diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index 8945f92d..e51d10e9 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -1,13 +1,18 @@ package mcp import ( + "bytes" "context" + "crypto/sha256" "errors" "fmt" + "io" "os" "path/filepath" "sort" "strings" + "time" + "unicode/utf8" "github.com/mark3labs/mcp-go/mcp" @@ -351,10 +356,6 @@ func pathContainedIn(abs, root string) bool { // target, or a control client with no known roots, is left to the normal read // path — there is nothing to leak. func (s *Server) guardSymlinkWithinRepo(absPath string) error { - roots := s.guardRepoRoots() - if len(roots) == 0 { - return nil // no known roots (control client / unindexed) — nothing to enforce - } real, err := filepath.EvalSymlinks(absPath) if err != nil { // Not-yet-created file (or broken symlink): EvalSymlinks can't resolve @@ -365,12 +366,26 @@ func (s *Server) guardSymlinkWithinRepo(absPath string) error { // refused below. real = resolveNearestExistingAncestor(absPath) } + return s.guardResolvedPathWithinRepo(absPath, real) +} + +// guardResolvedPathWithinRepo validates the resolved target that was actually +// observed, not a fresh resolution of the caller's path. Physical reads use +// this to bind repository confinement to the file handle whose bytes were +// hashed, even if a symlink is retargeted out of the repo and restored before +// the request returns. +func (s *Server) guardResolvedPathWithinRepo(requestedPath, resolvedPath string) error { + roots := s.guardRepoRoots() + if len(roots) == 0 { + return nil // no known roots (control client / unindexed) — nothing to enforce + } + real := filepath.Clean(resolvedPath) for _, root := range roots { if pathContainedIn(real, root) { return nil } } - return fmt.Errorf("%w: %q resolves to %q, outside every indexed repository root", errPathEscape, absPath, real) + return fmt.Errorf("%w: %q resolves to %q, outside every indexed repository root", errPathEscape, requestedPath, real) } // resolveNearestExistingAncestor symlink-resolves the longest existing prefix @@ -1113,6 +1128,102 @@ func capReadFileContent(content []byte, maxChars int, binary bool) ([]byte, bool return []byte(strings.ToValidUTF8(string(prefix), "")), true } +type physicalReadEvidence struct { + resolvedPath string + contentSHA256 string + byteCount int + symlinkResolved bool + readAt time.Time +} + +func samePhysicalFileVersion(a, b os.FileInfo) bool { + return a != nil && b != nil && a.Mode().IsRegular() && b.Mode().IsRegular() && + os.SameFile(a, b) && a.Size() == b.Size() && a.ModTime().Equal(b.ModTime()) +} + +// readPhysicalFileEvidence hashes the exact buffer returned to the caller from +// one file-handle read. Metadata and path identity checks bound replacement or +// in-place drift during that read without doubling file I/O or peak memory. +func readPhysicalFileEvidence(absPath string) ([]byte, physicalReadEvidence, error) { + return readPhysicalFileEvidenceObserved(absPath, nil) +} + +func readPhysicalFileEvidenceObserved(absPath string, afterRead func()) ([]byte, physicalReadEvidence, error) { + linkInfo, err := os.Lstat(absPath) + if err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not inspect physical path: %w", err) + } + if !linkInfo.Mode().IsRegular() && linkInfo.Mode()&os.ModeSymlink == 0 { + return nil, physicalReadEvidence{}, fmt.Errorf("physical evidence requires a regular file, got %s", linkInfo.Mode().Type()) + } + resolvedBefore, err := filepath.EvalSymlinks(absPath) + if err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not resolve physical file: %w", err) + } + f, err := openPhysicalEvidenceFile(absPath) + if err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not open physical file: %w", err) + } + defer f.Close() + + before, err := f.Stat() + if err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not stat physical file: %w", err) + } + if !before.Mode().IsRegular() { + return nil, physicalReadEvidence{}, fmt.Errorf("physical evidence requires a regular file, got %s", before.Mode().Type()) + } + content, err := io.ReadAll(f) + if err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not read physical file: %w", err) + } + sum := sha256.Sum256(content) + if afterRead != nil { + afterRead() + } + + // Verify the snapshot with a second streaming hash on the same handle. + // This doubles I/O only for explicit physical evidence, while retaining one + // full buffer and detecting in-place same-size rewrites whose mtime was + // restored. Metadata-only checks cannot prove that invariant. + if _, err := f.Seek(0, io.SeekStart); err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not rewind physical file for verification: %w", err) + } + verificationHash := sha256.New() + if _, err := io.Copy(verificationHash, f); err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not verify physical file content: %w", err) + } + if !bytes.Equal(sum[:], verificationHash.Sum(nil)) { + return nil, physicalReadEvidence{}, errors.New("physical file changed while it was being read; retry") + } + + after, err := f.Stat() + if err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not restat physical file: %w", err) + } + pathInfo, err := os.Stat(absPath) + if err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not verify physical path: %w", err) + } + resolvedAfter, err := filepath.EvalSymlinks(absPath) + if err != nil { + return nil, physicalReadEvidence{}, fmt.Errorf("could not verify physical path resolution: %w", err) + } + if filepath.Clean(resolvedBefore) != filepath.Clean(resolvedAfter) || + !samePhysicalFileVersion(before, after) || + !samePhysicalFileVersion(after, pathInfo) { + return nil, physicalReadEvidence{}, errors.New("physical file changed while it was being read; retry") + } + + return content, physicalReadEvidence{ + resolvedPath: resolvedAfter, + contentSHA256: fmt.Sprintf("%x", sum), + byteCount: len(content), + symlinkResolved: linkInfo.Mode()&os.ModeSymlink != 0, + readAt: time.Now().UTC(), + }, nil +} + func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { rawPath, err := req.RequireString("path") if err != nil { @@ -1132,6 +1243,41 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* if info.IsDir() { return mcp.NewToolResultError(fmt.Sprintf("path %q is a directory", rawPath)), nil } + + physicalEvidenceRequested := req.GetBool("physical_evidence", false) + digest := strings.ToLower(strings.TrimSpace(req.GetString("digest", ""))) + if digest != "" && !physicalEvidenceRequested { + return mcp.NewToolResultError("digest requires physical_evidence=true"), nil + } + if physicalEvidenceRequested { + if digest == "" { + digest = "sha256" + } + if digest != "sha256" { + return mcp.NewToolResultError(fmt.Sprintf("unsupported physical evidence digest %q; only sha256 is supported", digest)), nil + } + } + + var diskContent []byte + var physicalEvidence physicalReadEvidence + if physicalEvidenceRequested { + var readErr error + diskContent, physicalEvidence, readErr = readPhysicalFileEvidence(absPath) + if readErr != nil { + return mcp.NewToolResultError(readErr.Error()), nil + } + // Bind confinement to the resolved target that supplied the hashed + // bytes. Re-resolving only absPath is insufficient when a symlink is + // retargeted outside the repo for the read and restored afterward. + if guardErr := s.guardResolvedPathWithinRepo(absPath, physicalEvidence.resolvedPath); guardErr != nil { + return mcp.NewToolResultError(guardErr.Error()), nil + } + // Also reject a path retargeted outside after the evidence snapshot. + if guardErr := s.guardSymlinkWithinRepo(absPath); guardErr != nil { + return mcp.NewToolResultError(guardErr.Error()), nil + } + } + // Honour the editor-buffer overlay if one is active for this path. A // drifted overlay is already rejected upstream by the overlay view // guard; what reaches here is a live buffer, which we flag as such so @@ -1141,6 +1287,8 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* if buf, ok := s.overlayContentFor(ctx, absPath); ok { content = []byte(buf) servedFromOverlay = true + } else if physicalEvidenceRequested { + content = diskContent } else { b, rerr := os.ReadFile(absPath) if rerr != nil { @@ -1213,12 +1361,16 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* // caller explicitly opts out. Keys stay readable; only secret-shaped values // are replaced. secretsRedacted := false + allowSecrets := req.GetBool("allow_secrets", false) if !isBinary { - if red, did := s.maybeRedactConfigLeaf(language, relPath, req.GetBool("allow_secrets", false), string(content)); did { + if red, did := s.maybeRedactConfigLeaf(language, relPath, allowSecrets, string(content)); did { content = []byte(red) secretsRedacted = true } } + if physicalEvidenceRequested && secretsRedacted { + return mcp.NewToolResultError("physical_evidence for redacted content requires allow_secrets=true"), nil + } maxChars := req.GetInt("max_chars", 0) content, contentTruncated := capReadFileContent(content, maxChars, isBinary) @@ -1256,6 +1408,24 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* "total_lines": winTotal, } } + if physicalEvidenceRequested { + contentAltered := servedFromOverlay || isBinary || bodiesElided || salienceTruncated || windowed || secretsRedacted || contentTruncated || !utf8.Valid(content) + contentSource := "disk" + if servedFromOverlay { + contentSource = "overlay" + } + result["resolved_path"] = physicalEvidence.resolvedPath + result["file_kind"] = "regular" + result["byte_count"] = physicalEvidence.byteCount + result["content_sha256"] = physicalEvidence.contentSHA256 + result["hash_algorithm"] = "sha256" + result["hash_scope"] = "full_file" + result["hash_source"] = "disk" + result["content_source"] = contentSource + result["disk_verified"] = true + result["same_buffer_as_content"] = !contentAltered + result["symlink_resolved"] = physicalEvidence.symlinkResolved + } // Omission notes: tell the model what the payload deliberately // leaves out or reshapes, so it does not reason about absent code. @@ -1297,6 +1467,11 @@ func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (* return notModifiedResult(etag), nil } result["etag"] = etag + if physicalEvidenceRequested { + // Keep the observation timestamp outside the ETag so conditional reads + // remain stable while the verified disk bytes are unchanged. + result["read_at"] = physicalEvidence.readAt.Format(time.RFC3339Nano) + } // Server-side accounting only — read_file is the heaviest source // fetch and must show up in the savings ledger even when nothing