Compare commits

...
30 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub f62b7e0019 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-12 03:24:21 +00:00
github-actions[bot]andGitHub e98ec6032c chore(deps): update module github.com/mark3labs/mcp-go to v1.2.0
Automated dependency update. All tests passed.
2026-10-11 03:18:49 +00:00
lukaszraczylo 5c9b0f967f chore(brew): publish mcp-filepuff cask to the tap 2026-10-03 23:12:15 +01:00
lukaszraczylo cefd6ba7b1 ci(deps): update dependencies with Renovate [skip ci] 2026-10-03 18:44:28 +01:00
lukaszraczyloandGitHub d82479c02e Update go.mod and go.sum (#43) 2026-09-30 04:13:22 +01:00
lukaszraczylo f6ba02e892 ci(lint): migrate golangci config to the v2 schema
The config used v1 keys under a v2 header, so golangci-lint config verify
rejected it and the Lint job failed. This change moves the exclusions to
linters.exclusions and the output order to output.sort-order.

The old linter settings never took effect. Enforcing them reports 62 issues
in the current code. Only the staticcheck setting stays, because it reports
none. The gocyclo, errcheck and govet settings need a separate cleanup.
2026-09-30 01:42:00 +01:00
lukaszraczylo 2344b38d59 ci: build with Go 1.26 to match go.mod
The go.mod file requires Go 1.26.0. The release job runs Go 1.25 with
GOTOOLCHAIN=local, so it fails before it runs any test. The lint and
security jobs use the same pin. This change sets all three to 1.26.
2026-09-30 01:35:43 +01:00
lukaszraczylo 2158228ddb perf(tools): lower default result sizes to cut response tokens
The default limits let broad queries flood the model context. This
change sets smaller defaults. An explicit parameter still overrides each one.

- file_search: max_results 1000 to 50, context_lines 2 to 0.
- ast_query: max_results 100 to 50.
- ast_query compact output omits the node type unless verbose is set.

On a fixed seven-call suite, response size fell from 198529 to 61960 bytes.
2026-09-30 00:20:19 +01:00
lukaszraczyloandGitHub f4f5135ac2 Update go.mod and go.sum (#42) 2026-09-28 04:15:14 +01:00
lukaszraczyloandGitHub 964e987776 Update go.mod and go.sum (#41) 2026-09-24 04:11:15 +01:00
lukaszraczyloandGitHub a7fde76871 Update go.mod and go.sum (#40) 2026-09-16 04:11:14 +01:00
lukaszraczyloandGitHub ed8611d07b Update go.mod and go.sum (#39) 2026-09-09 04:11:28 +01:00
lukaszraczyloandGitHub 700c6e818c Update go.mod and go.sum (#38) 2026-09-03 05:25:49 +01:00
lukaszraczyloandGitHub 25ee197acd Update go.mod and go.sum (#37) 2026-08-12 04:44:27 +01:00
lukaszraczyloandGitHub a7dcab5146 Update go.mod and go.sum (#36) 2026-08-07 04:58:19 +01:00
lukaszraczyloandGitHub d9d8332f77 Update go.mod and go.sum (#35) 2026-07-24 06:46:58 +01:00
lukaszraczyloandGitHub a4bfdefdab Update go.mod and go.sum (#34) 2026-07-10 05:23:17 +01:00
lukaszraczyloandGitHub 32b0a7f69a Update go.mod and go.sum (#33) 2026-07-09 05:20:53 +01:00
lukaszraczyloandGitHub 76316de768 Update go.mod and go.sum (#32) 2026-07-07 05:22:27 +01:00
lukaszraczyloandGitHub 9abab7a550 Update go.mod and go.sum (#31) 2026-06-26 05:30:00 +01:00
lukaszraczylo 658c895318 fix(lsp): detect, evict, and respawn crashed language servers
A crashed language server stayed cached with ready==true: readLoop exited and
drained in-flight calls but never cleared the running flag, and GetServer keyed
reuse on ready alone. Every subsequent hover/definition/references then passed
the running guard, wrote to a dead stdin, and blocked the full request timeout —
permanently, since active use kept the server off the idle reaper. A single
crash wedged that language at ~10s/call with no self-heal.

- readLoop now flips running=false on exit, so Call fails fast instead of hanging.
- GetServer treats ready && client.IsRunning() as the liveness condition; a stale
  entry is evicted (closed async — its process has already exited) and a fresh
  server is spawned in its place.
- drainPending's send is now non-blocking, matching the dispatch path, so a
  duplicate-id race can't deadlock it under c.mu.

Test (proven to fail without the fix): a client whose process exits is marked
not-running and its next Call fails fast rather than blocking.
2026-06-21 02:25:23 +01:00
lukaszraczylo d24c8218fd refactor: remove dead scaffolding from parser and edit
edit.ValidateLanguage had zero callers (only its own godoc referenced it) —
removed.

yaml_json.go: contrary to the audit's claim, the file is NOT entirely dead —
ParseYAML/ParseJSON are live via the Parse() dispatch (parser.go:198-200) and
are kept with their tests. Removed only the genuinely unreachable scaffolding
that nothing constructs or calls: the YAMLNode/JSONNode types, WalkYAML,
GetYAMLNodeText, GetYAMLNodeLocation, QueryYAML, QueryJSON, ValidateYAML,
ValidateJSON, and ToSitterTree, plus their now-orphaned tests. YAML/JSON parsing
behavior is unchanged (TestParseYAML/TestParseJSON/TestRegistryParse_YAML_JSON
still pass).
2026-06-21 02:21:37 +01:00
lukaszraczylo f4c34c41e4 test(edit): cover selector_index bounds, overlap, and CRLF splice
Add behavior tests for the highest-risk under-tested mutation paths: findExactText
(overlapping-match index resolution + out-of-range/empty/not-found errors),
findRegexPattern (non-overlapping index resolution + out-of-range/invalid-regex/
not-found errors), and spliceContent's CRLF pullback (an edit ending between \r
and \n must not orphan a bare LF). These resolve the byte range that gets mutated,
so wrong handling means wrong-location or line-ending-corrupting writes.
2026-06-21 02:18:15 +01:00
lukaszraczylo 749076518d perf(lsp): default find_references to compact output
References defaulted to one line per reference; compact (one line per file,
columns collapsed) existed only as opt-in. Compact preserves every line:col pair
with zero information loss while cutting roughly one line per reference for the
LLM consumer. Default it to true; compact=false and the session compact_refs
pref still select the verbose per-reference layout (explicit override wins).
2026-06-21 02:16:40 +01:00
lukaszraczylo 664b7dac45 perf(file_read): compact line-number prefix by default
The default line prefix was the padded '  42| ' form; the compact '42|' form
existed only as opt-in (compact_line_numbers). The padding and trailing space
carry no information an LLM needs, costing ~3 chars/line — ~300 tokens on a
400-line read, the highest-volume path in the server. Flip the default to
compact; compact_line_numbers=false, mode/line_numbers=full, and the session
'full' pref all still restore the padded alignment (explicit override wins).

Updates the two tests that pinned the old default and refreshes stale comments.
2026-06-21 02:15:49 +01:00
lukaszraczylo d0c07f0157 fix(edit): re-validate path immediately before write (TOCTOU hardening)
edit_apply validated the target with IsPathAllowed in the handler, then later
wrote it via os.WriteFile on the raw path after reading+parsing+diffing. A
symlink swapped in during that window could redirect the write outside the
workspace root. Inject the admission check into the edit Engine
(NewEngineWithValidator) and re-run it inside a new writeValidatedFile helper
immediately before the write — IsPathAllowed resolves symlinks at write time, so
a swapped-in link now resolving outside the root is rejected. This narrows the
TOCTOU window to near-zero while still permitting legitimate in-workspace symlink
edits (unlike a blanket O_NOFOLLOW). writeValidatedFile also dedups the
perm-preserving write shared by AST and text modes.

Tests: rejected-path edit leaves the file untouched; file mode is preserved.
2026-06-21 02:12:57 +01:00
lukaszraczylo a841210092 perf(lsp): emit workspace-relative paths in definition/references
lsp_query printed absolute paths from URIToFile (e.g. the full
/Users/.../internal/... prefix repeated per result) while file_search and the
AST summary already relativize. On a find_references across many files that
~60-char root prefix repeats per file group — wasted tokens for the LLM
consumer. Extract a Server.relPath helper (dedups the AST summary's inline
block) and apply it to definition and references display paths; the absolute
path is kept where the file is actually re-read for previews.

Adds a relPath unit test (in-workspace relativized, out-of-workspace unchanged).
2026-06-21 02:03:43 +01:00
lukaszraczylo 98a499ab20 fix(parser): guard FindNodeAtPosition against position underflow
line/col are 1-indexed and converted via uint32(line-1); a caller passing 0
wrapped to 4294967295, feeding a garbage Point into tree-sitter. Reachable from
the LSP-down AST fallback with caller-supplied coordinates. Reject non-positive
positions up front. Adds tests (the function was previously 0% covered).
2026-06-21 02:01:50 +01:00
lukaszraczylo 8d940022b7 fix(search): drop matches outside the workspace root (symlink escape)
file_search runs ripgrep with --follow on by default, so a symlink inside the
workspace pointing outside it (e.g. link -> /etc) caused rg to traverse out and
return matches from external files — leaking their content to the caller despite
the workspace-scoping contract. parseOutput emitted match paths verbatim with no
re-validation.

Filter every match through IsPathAllowed (resolving rg's workspace-relative path
against the root first); IsPathAllowed's EvalSymlinks rejects the escape while
still permitting legitimate worktree siblings. Defense in depth that also covers
--no-ignore and any other traversal that reaches outside the root.

Adds a security regression test (proven to fail without the filter).
2026-06-21 01:59:53 +01:00
lukaszraczylo 99de8a93b9 test(search): cover cross-file context attribution in parseOutput
parseOutput's pendingFile guard attaches before-context only when it belongs to the same file as the following match. Add tests for both the cross-file leak guard (fileA context must not attach to a fileB match) and the same-file positive case. Test-only; no behavior change.
2026-06-21 00:07:08 +01:00
31 changed files with 550 additions and 345 deletions
+7 -7
View File
@@ -1,4 +1,4 @@
name: Autoupdate go.mod and go.sum
name: Update dependencies
on:
workflow_dispatch:
@@ -6,16 +6,16 @@ on:
- cron: "0 3 * * *"
permissions:
contents: write
actions: write
contents: write
issues: write
pull-requests: write
security-events: write
jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
update:
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with:
go-version: ">=1.24"
release-workflow: "release.yaml"
node: false
release-workflow: release.yaml
lfs: true
secrets: inherit
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: Run golangci-lint
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
with:
go-version: "1.25"
go-version: "1.26"
rolling-release-tag: "v1"
platforms: >-
[
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: Install govulncheck
+14 -20
View File
@@ -2,7 +2,7 @@ version: "2"
run:
timeout: 5m
go: '1.25'
go: '1.26'
linters:
enable:
@@ -13,32 +13,26 @@ linters:
- unused # Check for unused code
- gocyclo # Check cyclomatic complexity
- misspell # Check for misspelled words
settings:
staticcheck:
checks: ["all"]
exclusions:
rules:
# Exclude some linters from running on tests
- path: _test\.go
linters:
- gocyclo
- errcheck
- gosec
formatters:
enable:
- gofmt # Check formatting
linters-settings:
gocyclo:
min-complexity: 15
errcheck:
check-blank: true
govet:
enable-all: true
staticcheck:
checks: ["all"]
issues:
exclude-rules:
# Exclude some linters from running on tests
- path: _test\.go
linters:
- gocyclo
- errcheck
- gosec
max-issues-per-linter: 0
max-same-issues: 0
output:
sort-results: true
sort-order:
- file
+18
View File
@@ -95,6 +95,24 @@ release:
```
homebrew_casks:
- name: mcp-filepuff
repository:
owner: lukaszraczylo
name: homebrew-taps
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Casks
homepage: https://github.com/lukaszraczylo/filepuff-mcp
description: "AST-aware file operations and LSP integration MCP server for Claude Code"
license: MIT
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{staged_path}/mcp-filepuff"]
end
dockers:
- image_templates:
- "ghcr.io/lukaszraczylo/filepuff-mcp:{{ .Tag }}"
+2 -2
View File
@@ -283,7 +283,7 @@ Search for text patterns in files using ripgrep.
- `file_types`: File types to search (e.g., ["go", "ts", "py"])
- `ignore_case`: Case insensitive search
- `regex`: Treat pattern as regex (default: true)
- `context_lines`: Number of context lines around matches (default: 2)
- `context_lines`: Number of context lines around matches (default: 0)
- `max_results`: Maximum number of results to return
---
@@ -352,7 +352,7 @@ Search for AST patterns in code files using structural pattern matching.
- `name_matches`: Regex pattern to filter by name
- `name_exact`: Exact name to match
- `kind_in`: Node types to match (e.g., function_declaration)
- `max_results`: Maximum number of results (default: 100)
- `max_results`: Maximum number of results (default: 50)
**Examples**:
```json
+2 -2
View File
@@ -70,7 +70,7 @@ Search for text patterns in files using ripgrep. Supports regex patterns, file t
| `file_types` | `array[string]` | No | File types to search (e.g., ['go', 'ts', 'py']) |
| `ignore_case` | `boolean` | No | Case insensitive search |
| `regex` | `boolean` | No | Treat pattern as regex (default: true) |
| `context_lines` | `number` | No | Number of context lines around matches (default: 2) |
| `context_lines` | `number` | No | Number of context lines around matches (default: 0) |
| `max_results` | `number` | No | Maximum number of results to return |
**Examples:**
@@ -153,7 +153,7 @@ Search for AST patterns in code files. Use code patterns with $VAR placeholders
| `name_matches` | `string` | No | Regex pattern to filter by name |
| `name_exact` | `string` | No | Exact name to match |
| `kind_in` | `array[string]` | No | Node types to match (e.g., function_declaration, class_declaration) |
| `max_results` | `number` | No | Maximum number of results to return (default: 100) |
| `max_results` | `number` | No | Maximum number of results to return (default: 50) |
**Examples:**
+5 -5
View File
@@ -1,12 +1,12 @@
module github.com/lukaszraczylo/mcp-filepuff
go 1.25.5
go 1.26.0
require (
github.com/cespare/xxhash/v2 v2.3.0
github.com/goccy/go-json v0.10.6
github.com/goccy/go-json v0.11.2
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mark3labs/mcp-go v0.55.0
github.com/mark3labs/mcp-go v1.2.1
github.com/sergi/go-diff v1.4.0
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
gopkg.in/yaml.v3 v3.0.1
@@ -15,8 +15,8 @@ require (
require (
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/text v0.43.0 // indirect
)
+10 -8
View File
@@ -7,8 +7,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
@@ -24,14 +24,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mark3labs/mcp-go v1.2.1 h1:gd8VD3feMAVycwAM0x1nhv42Q1oLLjfOyowoUzHULB8=
github.com/mark3labs/mcp-go v1.2.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
@@ -44,8 +44,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+1 -1
View File
@@ -33,7 +33,7 @@ const (
DefaultSearchTimeout = 30 * time.Second
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
DefaultMaxSearchResults = 1000
DefaultMaxSearchResults = 50
DefaultMaxEditSize = 100 * 1024 // 100 KB
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
)
+35 -31
View File
@@ -63,17 +63,46 @@ type Engine struct {
registry *parser.Registry
dmp *diffmatchpatch.DiffMatchPatch
fileLocks sync.Map // map[string]*sync.Mutex for per-file locking
// pathAllowed re-checks that a target path is admissible (within the
// workspace root) immediately before a write. Injected to avoid coupling
// edit to config; nil disables the check (used in unit tests).
pathAllowed func(string) bool
}
// NewEngine creates a new edit engine.
// NewEngine creates a new edit engine with no write-time path validation.
func NewEngine(registry *parser.Registry) *Engine {
return NewEngineWithValidator(registry, nil)
}
// NewEngineWithValidator creates an edit engine that re-validates each target
// path with pathAllowed immediately before writing it, closing most of the
// time-of-check/time-of-use window between the handler's admission check and the
// actual write (a symlink swapped in afterward is rejected).
func NewEngineWithValidator(registry *parser.Registry, pathAllowed func(string) bool) *Engine {
return &Engine{
registry: registry,
dmp: diffmatchpatch.New(),
fileLocks: sync.Map{},
registry: registry,
dmp: diffmatchpatch.New(),
fileLocks: sync.Map{},
pathAllowed: pathAllowed,
}
}
// writeValidatedFile writes content to path, preserving the file's existing
// permission bits, after re-checking that path is still admissible. The re-check
// resolves symlinks at write time, so a symlink swapped in after the caller's
// original validation cannot redirect the write outside the workspace root.
func (e *Engine) writeValidatedFile(path string, content []byte) error {
if e.pathAllowed != nil && !e.pathAllowed(path) {
return errors.New(errors.ErrPathNotAllowed, "path is outside the workspace root").
WithContext("path", path)
}
perm := os.FileMode(0o600) // default fallback when the file can't be stat'd
if info, err := os.Stat(path); err == nil {
perm = info.Mode().Perm()
}
return os.WriteFile(path, content, perm)
}
// lockFile acquires a lock for the specified file and returns an unlock function.
// This prevents concurrent edits to the same file which could cause corruption.
func (e *Engine) lockFile(filePath string) func() {
@@ -179,14 +208,7 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool)
// Apply changes if requested
if apply {
// Preserve original file permissions
fileInfo, err := os.Stat(edit.File)
perm := os.FileMode(0o600) // default fallback
if err == nil {
perm = fileInfo.Mode().Perm()
}
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
structuredErr := errors.NewFileNotWritableError(edit.File, err)
return &EditResult{
Success: false,
@@ -236,14 +258,7 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) (
// Apply changes if requested
if apply {
// Preserve original file permissions
fileInfo, err := os.Stat(edit.File)
perm := os.FileMode(0o600) // default fallback
if err == nil {
perm = fileInfo.Mode().Perm()
}
if err := os.WriteFile(edit.File, newContent, perm); err != nil {
if err := e.writeValidatedFile(edit.File, newContent); err != nil {
structuredErr := errors.NewFileNotWritableError(edit.File, err)
return &EditResult{
Success: false,
@@ -866,14 +881,3 @@ func truncateString(s string, maxLen int) string {
}
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
}
// ValidateLanguage checks if AST editing is supported for a file.
// Returns nil for supported languages, error for unsupported.
// Note: Text-based editing is always available regardless of this check.
func ValidateLanguage(filename string) error {
lang := protocol.DetectLanguage(filename)
if lang == protocol.LangUnknown {
return fmt.Errorf("unsupported file type for AST editing: %s (text-based editing is available)", filename)
}
return nil
}
+144
View File
@@ -369,6 +369,73 @@ func Hello() {
}
}
// TestApplyRevalidatesPathBeforeWrite verifies the write-time admission re-check:
// an Engine whose validator rejects the target must refuse the write and leave
// the file untouched (the TOCTOU guard against a symlink swapped in after the
// handler's original IsPathAllowed check).
func TestApplyRevalidatesPathBeforeWrite(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.go")
original := "package main\n\nfunc Hello() {}\n"
if err := os.WriteFile(tmpFile, []byte(original), 0o600); err != nil {
t.Fatal(err)
}
// Validator denies the path (as IsPathAllowed would for a path that now
// resolves outside the workspace root).
e := NewEngineWithValidator(registry, func(string) bool { return false })
result, err := e.Apply(context.Background(), &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{Kind: "function_declaration"},
NewContent: "func Evil() {}",
})
if err != nil {
t.Fatalf("apply returned transport error: %v", err)
}
if result.Success || result.Applied {
t.Errorf("rejected-path edit must not apply (Success=%v Applied=%v)", result.Success, result.Applied)
}
if got, _ := os.ReadFile(tmpFile); string(got) != original {
t.Errorf("file must be unchanged when path validation fails; got:\n%s", got)
}
}
// TestApplyPreservesFileMode verifies an edit keeps the file's existing
// permission bits rather than resetting them.
func TestApplyPreservesFileMode(t *testing.T) {
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "script.go")
if err := os.WriteFile(tmpFile, []byte("package main\n\nfunc Hello() {}\n"), 0o755); err != nil {
t.Fatal(err)
}
result, err := e.Apply(context.Background(), &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{Kind: "function_declaration"},
NewContent: "func Hi() {}",
})
if err != nil || !result.Success {
t.Fatalf("apply failed: err=%v result=%+v", err, result)
}
info, err := os.Stat(tmpFile)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o755 {
t.Errorf("file mode not preserved: got %o, want 0755", perm)
}
}
// An edit on a CRLF file must normalize inserted LF content to CRLF so the file
// keeps a single, consistent line-ending convention (a documented guarantee).
func TestApplyPreservesCRLF(t *testing.T) {
@@ -1751,3 +1818,80 @@ func TestRegressionLineMergeDelete(t *testing.T) {
})
}
}
// TestFindExactTextIndexAndOverlap pins selector_index resolution for literal
// text matches, including overlapping matches and the out-of-range guard — the
// range these return is the span that gets mutated, so wrong indices = wrong-
// location writes.
func TestFindExactTextIndexAndOverlap(t *testing.T) {
e := NewEngine(parser.NewRegistry())
content := []byte("aaaa") // "aa" matches at 0,1,2 (overlapping)
for i, want := range []struct{ start, end int }{{0, 2}, {1, 3}, {2, 4}} {
start, end, err := e.findExactText(content, "aa", i)
if err != nil {
t.Fatalf("index %d: unexpected error: %v", i, err)
}
if start != want.start || end != want.end {
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
}
}
if _, _, err := e.findExactText(content, "aa", 3); err == nil {
t.Error("index 3 (only 3 matches) must be out-of-range error")
}
if _, _, err := e.findExactText(content, "zz", 0); err == nil {
t.Error("missing text must error")
}
if _, _, err := e.findExactText(content, "", 0); err == nil {
t.Error("empty text selector must error")
}
}
// TestFindRegexPatternIndexAndError pins selector_index and error paths for regex
// selection (non-overlapping matches via FindAllIndex).
func TestFindRegexPatternIndexAndError(t *testing.T) {
e := NewEngine(parser.NewRegistry())
content := []byte("a1b2c3") // [0-9] matches at 1,3,5
for i, want := range []struct{ start, end int }{{1, 2}, {3, 4}, {5, 6}} {
start, end, err := e.findRegexPattern(content, "[0-9]", i)
if err != nil {
t.Fatalf("index %d: unexpected error: %v", i, err)
}
if start != want.start || end != want.end {
t.Errorf("index %d: got [%d,%d), want [%d,%d)", i, start, end, want.start, want.end)
}
}
if _, _, err := e.findRegexPattern(content, "[0-9]", 3); err == nil {
t.Error("index 3 (only 3 matches) must be out-of-range error")
}
if _, _, err := e.findRegexPattern(content, "zzz", 0); err == nil {
t.Error("non-matching pattern must error")
}
if _, _, err := e.findRegexPattern(content, "[unclosed", 0); err == nil {
t.Error("invalid regex must error")
}
}
// TestSpliceContentCRLFNoBareLF verifies the CRLF pullback: when an edit range
// ends between the \r and \n of a terminator, the \r\n must stay intact in the
// tail rather than being split into a bare LF (which would corrupt the file's
// line-ending convention).
func TestSpliceContentCRLFNoBareLF(t *testing.T) {
// Replace [0,4) which ends on the \n of "foo\r\n" (index 4, preceded by \r@3).
got, err := spliceContent(EditReplace, []byte("foo\r\nbar\r\n"), 0, 4, "X", "\r\n")
if err != nil {
t.Fatalf("splice: %v", err)
}
if string(got) != "X\r\nbar\r\n" {
t.Fatalf("pullback failed: got %q, want %q", got, "X\r\nbar\r\n")
}
// No \n may appear without a preceding \r.
for i, b := range got {
if b == '\n' && (i == 0 || got[i-1] != '\r') {
t.Errorf("bare LF at index %d in %q", i, got)
}
}
}
+19 -1
View File
@@ -304,7 +304,12 @@ func (c *Client) send(msg interface{}) error {
// On exit (for any reason), it drains all pending Call waiters with a
// synthetic error so that goroutines blocked in Call are unblocked.
func (c *Client) readLoop() {
// On exit (EOF/error from a crashed or closed server) mark the client not
// running so subsequent Calls fail fast — otherwise they pass the running
// guard, send to a dead stdin, and block until their own timeout. Then
// unblock any already-in-flight waiters. LIFO: setRunning(false) runs first.
defer c.drainPending()
defer c.setRunning(false)
reader := bufio.NewReader(c.stdout)
@@ -380,13 +385,19 @@ func (c *Client) drainPending() {
defer c.mu.Unlock()
for id, ch := range c.pending {
ch <- &Response{
// Non-blocking, matching the dispatch path: the channel is buffered(1)
// and read once, but a duplicate-id race could have already filled it —
// never block here while holding c.mu.
select {
case ch <- &Response{
JSONRPC: "2.0",
ID: id,
Error: &ResponseError{
Code: -32603, // InternalError
Message: "LSP client readLoop terminated",
},
}:
default:
}
delete(c.pending, id)
}
@@ -398,3 +409,10 @@ func (c *Client) IsRunning() bool {
defer c.runningMu.RUnlock()
return c.running
}
// setRunning sets the running flag under its lock.
func (c *Client) setRunning(v bool) {
c.runningMu.Lock()
c.running = v
c.runningMu.Unlock()
}
+38
View File
@@ -1,10 +1,48 @@
package lsp
import (
"context"
"os/exec"
"strings"
"testing"
"time"
)
// TestClientMarksNotRunningOnProcessExit verifies crash detection: when the
// underlying server process exits (stdout EOF), readLoop must flip the client to
// not-running so later Calls fail fast instead of blocking until their timeout.
func TestClientMarksNotRunningOnProcessExit(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0") // exits immediately, closing stdout
c, err := NewClient(cmd)
if err != nil {
t.Skipf("cannot start helper process: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
deadline := time.Now().Add(2 * time.Second)
for c.IsRunning() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if c.IsRunning() {
t.Fatal("client should be marked not running after its process exits")
}
// A Call on the dead client must fail fast (the running guard), not hang.
done := make(chan error, 1)
go func() {
_, callErr := c.Call(context.Background(), "test/method", nil)
done <- callErr
}()
select {
case callErr := <-done:
if callErr == nil {
t.Error("Call on a dead client should return an error")
}
case <-time.After(2 * time.Second):
t.Error("Call on a dead client hung instead of failing fast")
}
}
// drainStderr retains a bounded tail of server stderr for diagnostics; capture
// must keep recent bytes and never grow past StderrCaptureMax.
func TestClientStderrCapture(t *testing.T) {
+14 -2
View File
@@ -128,7 +128,11 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
srv, exists := m.servers[lang]
m.mu.RUnlock()
if exists && srv.ready {
// A cached server is only reusable if it is both initialized and still
// running — a crashed language server stays in the map with ready==true, so
// without the liveness check every later call would be routed to a dead
// process and block until timeout.
if exists && srv.ready && srv.client.IsRunning() {
// Update lastUsed with server's own lock to avoid race condition
srv.mu.Lock()
srv.lastUsed = time.Now()
@@ -141,13 +145,21 @@ func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*Manag
defer m.mu.Unlock()
// Double-check after acquiring write lock
if srv, ok := m.servers[lang]; ok && srv.ready {
if srv, ok := m.servers[lang]; ok && srv.ready && srv.client.IsRunning() {
srv.mu.Lock()
srv.lastUsed = time.Now()
srv.mu.Unlock()
return srv, nil
}
// Evict a stale entry (crashed or never-ready) before spawning a replacement.
// Its process has already exited, so close it asynchronously to avoid holding
// m.mu on the (fast) cmd.Wait().
if stale, ok := m.servers[lang]; ok {
delete(m.servers, lang)
go func() { _ = stale.client.Close() }()
}
// Check if server config exists
config, ok := DefaultServerConfigs[lang]
if !ok {
+8
View File
@@ -16,6 +16,14 @@ func FindNodeAtPosition(tree *sitter.Tree, line, col int) *sitter.Node {
return nil
}
// Positions are 1-indexed; reject non-positive values so the uint32
// conversion below can't underflow to a huge Row/Column (line/col == 0
// would wrap to 4294967295). Reachable from the LSP-down AST fallback with
// caller-supplied coordinates.
if line < 1 || col < 1 {
return nil
}
// Convert to 0-indexed
point := sitter.Point{
Row: uint32(line - 1), // #nosec G115 - line numbers are bounded by file size
+45
View File
@@ -0,0 +1,45 @@
package parser
import (
"context"
"testing"
)
func TestFindNodeAtPosition(t *testing.T) {
r := NewRegistry()
content := []byte("package main\n\nfunc Hello() string { return \"hi\" }\n")
res, err := r.Parse(context.Background(), "test.go", content)
if err != nil {
t.Fatalf("parse: %v", err)
}
t.Run("nil tree", func(t *testing.T) {
if FindNodeAtPosition(nil, 3, 6) != nil {
t.Error("nil tree must return nil")
}
})
// Guard against the uint32 underflow: non-positive coordinates must return
// nil, never wrap to a huge Row/Column.
t.Run("non-positive coordinates", func(t *testing.T) {
for _, c := range []struct{ line, col int }{{0, 0}, {0, 5}, {3, 0}, {-1, 4}} {
if got := FindNodeAtPosition(res.Tree, c.line, c.col); got != nil {
t.Errorf("FindNodeAtPosition(tree, %d, %d) = %v, want nil", c.line, c.col, got.Type())
}
}
})
t.Run("out of range line returns nil", func(t *testing.T) {
if got := FindNodeAtPosition(res.Tree, 99999, 1); got != nil {
t.Errorf("far-out-of-range line should return nil, got %v", got.Type())
}
})
t.Run("valid position returns a node", func(t *testing.T) {
// Line 3, column 6 lands inside "Hello".
got := FindNodeAtPosition(res.Tree, 3, 6)
if got == nil {
t.Fatal("expected a node at a valid in-range position, got nil")
}
})
}
+8 -145
View File
@@ -4,70 +4,45 @@ package parser
import (
"context"
"encoding/json"
"fmt"
"gopkg.in/yaml.v3"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
sitter "github.com/smacker/go-tree-sitter"
)
// YAMLNode wraps yaml.Node to provide tree-sitter-like interface
type YAMLNode struct {
*yaml.Node
Content []byte
}
// JSONNode represents a JSON AST node
type JSONNode struct {
Value any
Type string
Children []*JSONNode
Line int
Column int
}
// ParseYAML parses YAML content and returns a tree-sitter-compatible result
// ParseYAML parses YAML content and returns a tree-sitter-compatible result.
// YAML does not use tree-sitter, so the returned ParseResult.Tree is nil.
func (r *Registry) ParseYAML(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
// Check file size
if len(content) > maxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
}
// Parse YAML
var root yaml.Node
if err := yaml.Unmarshal(content, &root); err != nil {
return nil, errors.NewParseError("yaml", filename, err)
}
// Extract syntax errors from YAML parse
syntaxErrors := extractYAMLErrors()
// Create a pseudo tree-sitter tree for compatibility
// We'll use nil for the tree since YAML doesn't use tree-sitter
return &ParseResult{
Tree: nil, // YAML uses yaml.Node instead
Tree: nil, // YAML uses yaml.Node instead of tree-sitter
Language: protocol.LangYAML,
Errors: syntaxErrors,
Errors: extractYAMLErrors(),
Content: content,
}, nil
}
// ParseJSON parses JSON content and returns a tree-sitter-compatible result
// ParseJSON parses JSON content and returns a tree-sitter-compatible result.
// JSON does not use tree-sitter, so the returned ParseResult.Tree is nil.
func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byte) (*ParseResult, error) {
// Check file size
if len(content) > maxFileSize {
return nil, errors.NewFileTooLarge(filename, int64(len(content)), maxFileSize)
}
// Parse JSON to validate syntax
var jsonData any
if err := json.Unmarshal(content, &jsonData); err != nil {
return nil, errors.NewParseError("json", filename, err)
}
// JSON parsing succeeded, no syntax errors
return &ParseResult{
Tree: nil, // JSON uses native Go structures
Language: protocol.LangJSON,
@@ -76,120 +51,8 @@ func (r *Registry) ParseJSON(ctx context.Context, filename string, content []byt
}, nil
}
// extractYAMLErrors extracts errors from YAML nodes
// extractYAMLErrors returns YAML syntax errors. yaml.Unmarshal already reports
// parse errors, so reaching here means the document was syntactically valid.
func extractYAMLErrors() []SyntaxError {
// YAML parser already validates during unmarshal
// If we got here, there are no syntax errors
// However, we could add semantic validation here in the future
return []SyntaxError{}
}
// WalkYAML walks a YAML AST and calls fn for each node
func WalkYAML(node *yaml.Node, fn func(*yaml.Node) bool) {
if node == nil || !fn(node) {
return
}
for _, child := range node.Content {
WalkYAML(child, fn)
}
}
// GetYAMLNodeText returns the text representation of a YAML node
func GetYAMLNodeText(node *yaml.Node) string {
if node == nil {
return ""
}
switch node.Kind {
case yaml.DocumentNode:
if len(node.Content) > 0 {
return GetYAMLNodeText(node.Content[0])
}
return ""
case yaml.MappingNode:
return node.Value
case yaml.SequenceNode:
return node.Value
case yaml.ScalarNode:
return node.Value
case yaml.AliasNode:
return node.Value
default:
return ""
}
}
// GetYAMLNodeLocation returns the location of a YAML node
func GetYAMLNodeLocation(node *yaml.Node) protocol.Location {
if node == nil {
return protocol.Location{Line: 1, Column: 1}
}
return protocol.Location{
Line: node.Line,
Column: node.Column,
}
}
// QueryYAML performs a simple query on YAML content
// Example: "$.metadata.name" to find the name field in metadata
func QueryYAML(content []byte, query string) ([]*yaml.Node, error) {
var root yaml.Node
if err := yaml.Unmarshal(content, &root); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
// Simple path-based query implementation
// This is a basic implementation - can be extended with more sophisticated queries
var results []*yaml.Node
WalkYAML(&root, func(node *yaml.Node) bool {
if node.Value == query || node.Tag == query {
results = append(results, node)
}
return true
})
return results, nil
}
// QueryJSON performs a simple query on JSON content
func QueryJSON(content []byte, query string) ([]any, error) {
var data any
if err := json.Unmarshal(content, &data); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
// Basic implementation - can be extended with JSONPath support
var results []any
// For now, just validate that it's valid JSON
results = append(results, data)
return results, nil
}
// ValidateYAML validates YAML content without parsing to full AST
func ValidateYAML(content []byte) error {
var node yaml.Node
if err := yaml.Unmarshal(content, &node); err != nil {
return fmt.Errorf("YAML validation failed: %w", err)
}
return nil
}
// ValidateJSON validates JSON content
func ValidateJSON(content []byte) error {
var data any
if err := json.Unmarshal(content, &data); err != nil {
return fmt.Errorf("JSON validation failed: %w", err)
}
return nil
}
// ToSitterTree is a placeholder that returns nil for YAML/JSON
// These formats don't use tree-sitter, but we keep this for interface compatibility
func (yn *YAMLNode) ToSitterTree() *sitter.Tree {
return nil
}
-81
View File
@@ -4,8 +4,6 @@ import (
"context"
"testing"
"gopkg.in/yaml.v3"
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
)
@@ -202,82 +200,3 @@ version: 1.0.0`)
t.Errorf("expected YAML language for .yml extension, got %s", ymlResult.Language)
}
}
func TestWalkYAML(t *testing.T) {
content := []byte(`metadata:
name: test
labels:
app: myapp
env: prod`)
var root yaml.Node
if err := yaml.Unmarshal(content, &root); err != nil {
t.Fatalf("failed to parse YAML: %v", err)
}
nodeCount := 0
WalkYAML(&root, func(node *yaml.Node) bool {
nodeCount++
return true
})
if nodeCount == 0 {
t.Error("expected to visit nodes, but count is 0")
}
}
func TestValidateYAML(t *testing.T) {
tests := []struct {
name string
content []byte
shouldError bool
}{
{
name: "valid YAML",
content: []byte("name: test\nvalue: 100"),
shouldError: false,
},
{
name: "invalid YAML",
content: []byte("name: test\n bad:\n[unclosed"),
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateYAML(tt.content)
if (err != nil) != tt.shouldError {
t.Errorf("ValidateYAML() error = %v, shouldError = %v", err, tt.shouldError)
}
})
}
}
func TestValidateJSON(t *testing.T) {
tests := []struct {
name string
content []byte
shouldError bool
}{
{
name: "valid JSON",
content: []byte(`{"name": "test", "value": 100}`),
shouldError: false,
},
{
name: "invalid JSON",
content: []byte(`{"name": "test", "value": 100`),
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateJSON(tt.content)
if (err != nil) != tt.shouldError {
t.Errorf("ValidateJSON() error = %v, shouldError = %v", err, tt.shouldError)
}
})
}
}
+5 -1
View File
@@ -509,11 +509,15 @@ func formatResults(results []MatchResult, maxResults int, format string, offset
case "compact":
for i := 0; i < renderCount; i++ {
r := results[i]
firstLine := firstLineOf(r.Text, 80)
if !verbose {
fmt.Fprintf(&sb, "%s:%d %s\n", r.File, r.Location.Line, firstLine)
continue
}
nodeType := "unknown"
if r.Node != nil {
nodeType = r.Node.Type()
}
firstLine := firstLineOf(r.Text, 80)
fmt.Fprintf(&sb, "%s:%d (%s) %s\n", r.File, r.Location.Line, nodeType, firstLine)
}
+19
View File
@@ -258,6 +258,17 @@ func (s *Searcher) validatePaths(paths []string) error {
WithRemediation("Provide paths within the workspace root")
}
// resultPathAllowed reports whether a ripgrep result path is within the
// workspace root. ripgrep runs with Dir=WorkspaceRoot and emits paths relative
// to it, so a relative path is joined to the root before the check; IsPathAllowed
// then resolves symlinks, rejecting any path that --follow led outside the root.
func (s *Searcher) resultPathAllowed(p string) bool {
if !filepath.IsAbs(p) {
p = filepath.Join(s.cfg.WorkspaceRoot, p)
}
return s.cfg.IsPathAllowed(p)
}
// parseOutput parses ripgrep JSON output.
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
results := &SearchResults{
@@ -288,6 +299,14 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
continue
}
// Defense in depth: never surface a file outside the workspace root.
// ripgrep with --follow (default on) can traverse a symlink whose
// target lives outside the root; dropping those matches keeps search
// from leaking external file content.
if !s.resultPathAllowed(match.Path.Text) {
continue
}
// Check max results
if maxResults > 0 && len(results.Results) >= maxResults {
results.Truncated = true
+87
View File
@@ -13,6 +13,51 @@ import (
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
)
// parseOutput must not attach one file's pending before-context to a match in a
// different file (cross-file leak guard in the JSON stream parser).
func TestParseOutputCrossFileContextNotLeaked(t *testing.T) {
s := newTestSearcher(t)
var buf bytes.Buffer
// fileA emits a before-context with no following match in fileA; then fileB
// has a match. fileA's pending context must NOT attach to fileB's match.
buf.WriteString(`{"type":"context","data":{"path":{"text":"fileA.go"},"lines":{"text":"orphan a ctx\n"},"line_number":1}}` + "\n")
buf.WriteString(`{"type":"match","data":{"path":{"text":"fileB.go"},"lines":{"text":"hit B\n"},"line_number":5,"submatches":[{"match":{"text":"hit"},"start":0,"end":3}]}}` + "\n")
res, err := s.parseOutput(&buf, 0)
if err != nil {
t.Fatalf("parseOutput: %v", err)
}
if len(res.Results) != 1 {
t.Fatalf("want 1 result, got %d", len(res.Results))
}
if res.Results[0].File != "fileB.go" {
t.Errorf("result file = %q, want fileB.go", res.Results[0].File)
}
if len(res.Results[0].Context.Before) != 0 {
t.Errorf("fileA context leaked into fileB match: %v", res.Results[0].Context.Before)
}
}
// parseOutput must attach a same-file before-context to the following match.
func TestParseOutputSameFileBeforeContext(t *testing.T) {
s := newTestSearcher(t)
var buf bytes.Buffer
buf.WriteString(`{"type":"context","data":{"path":{"text":"f.go"},"lines":{"text":"before line\n"},"line_number":4}}` + "\n")
buf.WriteString(`{"type":"match","data":{"path":{"text":"f.go"},"lines":{"text":"the match\n"},"line_number":5,"submatches":[{"match":{"text":"match"},"start":4,"end":9}]}}` + "\n")
res, err := s.parseOutput(&buf, 0)
if err != nil {
t.Fatalf("parseOutput: %v", err)
}
if len(res.Results) != 1 {
t.Fatalf("want 1 result, got %d", len(res.Results))
}
got := res.Results[0].Context.Before
if len(got) != 1 || got[0] != "before line" {
t.Errorf("same-file before-context not attached: %v", got)
}
}
// truncateLine must back off to a rune boundary so multibyte characters are
// never split into invalid UTF-8.
func TestTruncateLineRuneSafe(t *testing.T) {
@@ -357,3 +402,45 @@ func TestSearchEmptyPattern(t *testing.T) {
t.Error("expected error for empty pattern")
}
}
// TestSearchSymlinkEscapeNotLeaked is a security regression guard: with
// FollowSymlinks on (the default), ripgrep can traverse a symlink whose target
// is outside the workspace root. Such matches must never be returned — search
// must not leak the content of files outside the workspace.
func TestSearchSymlinkEscapeNotLeaked(t *testing.T) {
const secret = "SUPERSECRETLEAKCANARY"
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte(secret+"\n"), 0o600); err != nil {
t.Fatal(err)
}
workspace := t.TempDir()
// A regular in-workspace file that does NOT contain the secret.
if err := os.WriteFile(filepath.Join(workspace, "ok.txt"), []byte("nothing here\n"), 0o600); err != nil {
t.Fatal(err)
}
// A symlink inside the workspace pointing at the outside directory.
if err := os.Symlink(outside, filepath.Join(workspace, "escape")); err != nil {
t.Skipf("symlinks unsupported: %v", err)
}
cfg := config.Default()
cfg.WorkspaceRoot = workspace
if !cfg.FollowSymlinks {
t.Fatal("precondition: FollowSymlinks should default true so rg --follow is exercised")
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
s, err := New(cfg, logger)
if err != nil {
t.Skip("ripgrep not installed")
}
results, err := s.Search(context.Background(), &Request{Pattern: secret})
if err != nil {
t.Fatalf("search: %v", err)
}
for _, r := range results.Results {
t.Errorf("symlink escape leaked an out-of-workspace match: file=%q text=%q", r.File, r.MatchText)
}
}
+14
View File
@@ -213,6 +213,20 @@ func TestASTQuerySkipsDependencyDirs(t *testing.T) {
}
}
// TestRelPath verifies workspace-relative display paths (token saving) with a
// safe fallback to the original path for anything outside the root.
func TestRelPath(t *testing.T) {
srv, tmpDir := newFeaturesServer(t)
if got := srv.relPath(filepath.Join(tmpDir, "pkg", "a.go")); got != filepath.Join("pkg", "a.go") {
t.Errorf("in-workspace path should be relativized, got %q", got)
}
// Outside the workspace root → returned unchanged (never a ../ escape string).
outside := "/definitely/outside/x.go"
if got := srv.relPath(outside); got != outside {
t.Errorf("out-of-workspace path should be returned as-is, got %q", got)
}
}
// ---- Feature 3 (ast_query): pagination cursor ----
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
+14 -10
View File
@@ -60,7 +60,7 @@ func (s *Server) resolveASTQueryParams(request mcp.CallToolRequest) (*astQueryPa
prefsMaxResults = sp.DefaultMaxResults
prefsFormat = sp.ASTQueryFormat
}
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 100)
p.maxResults = effectiveInt(request, "max_results", prefsMaxResults, 50)
p.format = request.GetString("format", "")
if p.format == "" {
@@ -263,15 +263,7 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
var sb strings.Builder
// Get relative path
relPath := path
if absPath, err := filepath.Abs(path); err == nil {
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
relPath = rel
}
}
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", relPath, countLines(content), lang)
fmt.Fprintf(&sb, "**%s** (%d lines, %s)\n\n", s.relPath(path), countLines(content), lang)
sb.WriteString("Symbols:\n")
for _, sym := range symbols {
@@ -282,6 +274,18 @@ func (s *Server) generateASTSummary(ctx context.Context, path string, content []
return sb.String()
}
// relPath renders path relative to the workspace root for display, saving the
// tokens an absolute path would cost. Falls back to the original path when it
// can't be relativized (outside the root, or on error).
func (s *Server) relPath(path string) string {
if absPath, err := filepath.Abs(path); err == nil {
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
}
return path
}
// symbolKindIcon returns an icon/prefix for a symbol kind.
func symbolKindIcon(kind protocol.SymbolKind) string {
switch kind {
+8 -4
View File
@@ -51,7 +51,7 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
fileTypes := request.GetStringSlice("file_types", nil)
ignoreCase := request.GetBool("ignore_case", false)
regex := request.GetBool("regex", true)
contextLines := request.GetInt("context_lines", 2)
contextLines := request.GetInt("context_lines", 0)
// Consult session prefs for max_results and cluster when not explicitly supplied.
prefs := s.sessionPrefs.Load()
@@ -326,9 +326,13 @@ type lineNumberOpts struct {
// resolveLineNumberOpts resolves per-call vs session-pref line-number options.
func (s *Server) resolveLineNumberOpts(request mcp.CallToolRequest) lineNumberOpts {
opts := lineNumberOpts{
noLineNumbers: request.GetBool("no_line_numbers", false),
lineInterval: request.GetInt("line_number_interval", 1),
compactLineNums: request.GetBool("compact_line_numbers", false),
noLineNumbers: request.GetBool("no_line_numbers", false),
lineInterval: request.GetInt("line_number_interval", 1),
// Compact prefix ("12│content") is the default — it drops the 4-wide
// padding and trailing space of the legacy " 12│ " form, saving ~3
// chars/line with no information loss. Pass compact_line_numbers=false
// (or mode/line_numbers="full") to restore the padded alignment.
compactLineNums: request.GetBool("compact_line_numbers", true),
}
// Apply session line_numbers pref when no explicit per-call override was supplied.
+13 -9
View File
@@ -589,19 +589,23 @@ func TestCompactLineNumbers(t *testing.T) {
}
}
func TestCompactLineNumbersOffByDefault(t *testing.T) {
func TestCompactLineNumbersDefaultAndOptOut(t *testing.T) {
tmpDir := t.TempDir()
srv := newTestServer(t, tmpDir)
content := "line one\nline two\n"
f := writeFile(t, tmpDir, "test.txt", content)
// Default: no compact_line_numbers
out := callRead(t, srv, map[string]interface{}{"path": f})
// Default is now the compact prefix (token-saving): "1│line one".
def := callRead(t, srv, map[string]interface{}{"path": f})
if !strings.Contains(def, "1│line one") || strings.Contains(def, " 1│ line one") {
t.Errorf("default should use compact prefix, got:\n%s", def)
}
// Should have padded format
if !strings.Contains(out, " 1│ line one") {
t.Errorf("default should have padded format, got:\n%s", out)
// Explicit opt-out restores the padded alignment.
padded := callRead(t, srv, map[string]interface{}{"path": f, "compact_line_numbers": false})
if !strings.Contains(padded, " 1│ line one") {
t.Errorf("compact_line_numbers=false should restore padded format, got:\n%s", padded)
}
}
@@ -679,9 +683,9 @@ func Hello() {
if !strings.Contains(out, `println("hello")`) {
t.Errorf("full mode missing body, got:\n%s", out)
}
// Padded line numbers
if !strings.Contains(out, " 1│ package main") {
t.Errorf("default should have padded line numbers, got:\n%s", out)
// Default line numbers use the compact prefix.
if !strings.Contains(out, "1│package main") {
t.Errorf("default should have compact line numbers, got:\n%s", out)
}
// etag present
if !strings.Contains(out, "[etag:") {
+8 -4
View File
@@ -74,12 +74,14 @@ func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest
case "references":
includeDecl := request.GetBool("include_declaration", true)
// compact: explicit call-time > session compact_refs pref > false
// compact: explicit call-time > session compact_refs pref > true.
// Compact (one line per file) is the default — it preserves every
// line:col pair while cutting ~one line per reference for the LLM consumer.
var prefsCompact *bool
if sp := s.sessionPrefs.Load(); sp != nil {
prefsCompact = sp.CompactRefs
}
compact := effectiveBool(request, "compact", prefsCompact, false)
compact := effectiveBool(request, "compact", prefsCompact, true)
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
default:
@@ -181,7 +183,7 @@ func (s *Server) lspDefinition(ctx context.Context, file string, line, col int,
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
fmt.Fprintf(&output, "**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1)
fmt.Fprintf(&output, "**%s:%d:%d**\n", s.relPath(filePath), loc.Range.Start.Line+1, loc.Range.Start.Character+1)
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
if preview != "" {
@@ -210,7 +212,9 @@ func (s *Server) lspReferences(ctx context.Context, file string, line, col int,
fileGroups := make(map[string][]lsp.Location)
fileOrder := make([]string, 0)
for _, loc := range locations {
filePath := lsp.URIToFile(loc.URI)
// Relativize for display; references are not re-read so the absolute
// path isn't needed downstream.
filePath := s.relPath(lsp.URIToFile(loc.URI))
if _, seen := fileGroups[filePath]; !seen {
fileOrder = append(fileOrder, filePath)
}
+2 -2
View File
@@ -49,7 +49,7 @@ const helpFileSearch = "# file_search — flags and examples\n\n" +
"| `cluster=true` | Coalesce consecutive match lines into ranges (`L12-14│ text`). Drops context lines for density. |\n" +
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
"| `max_results` | Page size for pagination. Re-run with `cursor` to get next page. |\n" +
"| `context_lines` | Number of context lines around matches (default: 2). |\n" +
"| `context_lines` | Number of context lines around matches (default: 0). |\n" +
"| `ignore_case` | Case-insensitive search. |\n" +
"| `regex` | Treat pattern as regex (default: true). |\n" +
"| `file_types` | Restrict to file extensions, e.g. `[\"go\", \"ts\"]`. |\n" +
@@ -77,7 +77,7 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
"| `verbose=true` | Emit `Found N match(es):` preamble (v1 behaviour). Default: false. |\n" +
"| `format` | `compact` (default, one line per match) \\| `verbose` (full code+captures) \\| `location` (file:line only) |\n" +
"| `cursor` | Opaque pagination token from a previous truncated response — fetches next page. |\n" +
"| `max_results` | Page size (default: 100). |\n" +
"| `max_results` | Page size (default: 50). |\n" +
"| `name_exact` | Exact symbol name to match. |\n" +
"| `name_matches` | Regex pattern to filter by name. |\n" +
"| `kind_in` | Node types to match (e.g. `function_declaration`, `class_declaration`). |\n" +
+5 -5
View File
@@ -54,7 +54,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
logger: logger,
parser: parserRegistry,
matcher: query.NewMatcher(parserRegistry),
editor: edit.NewEngine(parserRegistry),
editor: edit.NewEngineWithValidator(parserRegistry, cfg.IsPathAllowed),
readSem: make(chan struct{}, MaxConcurrentReads),
querySem: make(chan struct{}, MaxConcurrentQueries),
}
@@ -136,7 +136,7 @@ func (s *Server) registerTools() {
mcp.Description("Treat pattern as regex (default: true)"),
),
mcp.WithNumber("context_lines",
mcp.Description("Number of context lines around matches (default: 2)"),
mcp.Description("Number of context lines around matches (default: 0)"),
),
mcp.WithNumber("max_results",
mcp.Description("Maximum number of results to return (page size for pagination)"),
@@ -206,7 +206,7 @@ func (s *Server) registerTools() {
mcp.Description("Print line numbers only every N lines (default: 1 = every line). E.g. 10 = anchor every 10th line plus first/last. 0 = no line numbers."),
),
mcp.WithBoolean("compact_line_numbers",
mcp.Description("Use compact line prefix '12\u2502' instead of ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default off."),
mcp.Description("Use compact line prefix '12\u2502' instead of padded ' 12\u2502 ' (no padding, no trailing space). Works with line_number_interval. Default true; set false to restore padded alignment."),
),
mcp.WithBoolean("collapse_blank_lines",
mcp.Description("Collapse runs of consecutive blank lines to a single blank line. Useful for token savings on heavily-spaced code."),
@@ -252,7 +252,7 @@ func (s *Server) registerTools() {
mcp.WithStringItems(),
),
mcp.WithNumber("max_results",
mcp.Description("Maximum number of results to return (default: 100, page size for pagination)"),
mcp.Description("Maximum number of results to return (default: 50, page size for pagination)"),
),
mcp.WithString("format",
mcp.Description("Output format: \"compact\" (default, one line per match), \"verbose\" (full code+captures), \"location\" (file:line only)"),
@@ -299,7 +299,7 @@ func (s *Server) registerTools() {
mcp.Description("Include the declaration in results. Only valid for action=references (default: true)."),
),
mcp.WithBoolean("compact",
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: false."),
mcp.Description("Compact output: one line per file with all refs in brackets. Only valid for action=references. Default: true; set false for one line per reference."),
),
mcp.WithBoolean("verbose",
mcp.Description("Emit count/header preamble. Applies to all actions. Default: false."),
+2 -2
View File
@@ -48,7 +48,7 @@ func TestSessionPrefsFileReadLineNumbersNone(t *testing.T) {
t.Fatalf("handleFileRead: %v", err)
}
text := result.Content[0].(mcp.TextContent).Text
// Standard line-number format is " 1│ "; with no_line_numbers it's absent.
// line_numbers=none suppresses the prefix entirely (no "│" gutter at all).
if strings.Contains(text, " 1│") {
t.Errorf("session line_numbers=none: expected no line-number prefix, got:\n%s", text)
}
@@ -87,7 +87,7 @@ func TestSessionPrefsFileReadLineNumbersCompact(t *testing.T) {
req.Params.Arguments = map[string]interface{}{"path": testFile}
result, _ := srv.handleFileRead(ctx, req)
text := result.Content[0].(mcp.TextContent).Text
// Standard padded prefix is " 1│ "; compact is "1│".
// Padded prefix is " 1│ "; compact is "1│".
if strings.Contains(text, " 1\u2502") {
t.Errorf("session line_numbers=compact should use compact prefix, got:\n%s", text)
}