v1.13.18-codecontext-file-path: resolve file_path against project root in codecontext wrappers
Four codecontext sidecar wrappers — get_file_analysis (required file_path), get_symbol_info, get_dependencies, and get_semantic_neighborhoods (optional) — forwarded file_path to the HTTP sidecar unchanged. The sidecar's internal file index is keyed on absolute paths, so any relative path from the model returned "File not found in graph". Three back-to-back failures observed in one chat on 2026-05-22 17:56 UTC, ~48 s of wasted tool budget. ## Resolver Add resolveProjectPath(projectRoot, rawPath) in codecontext_client.ts: trim check → absolute/relative branch (both go through resolve() so dot-segments normalise) → realpath with ENOENT fallthrough → escape check using the realpathed value. Error shape mirrors the existing target_dir escape error byte-for-byte; only the field name differs. Wired into callCodecontext at the args-spread site, guarded on file_path presence + non-empty. All four wrappers benefit from one call site; wrappers without file_path (overview, framework, watch, search) are unaffected. ## Schema trim .trim() added to all four file_path Zod schemas: get_file_analysis: z.string().trim().min(1) get_symbol_info: z.string().trim().optional() get_dependencies: z.string().trim().optional() get_semantic_neighborhoods: z.string().trim().optional() Absorbs trailing newlines / whitespace from model output before the resolver sees the value. ## Adversarial review fixes Adversarial pass surfaced two P2 findings: 1. Absolute path with `..` resolving outside the project root (e.g. `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip through the literal prefix-check: the raw string starts with `<projectRoot>/`. Fix: resolve() the absolute branch's candidate too, so dot-segments normalise before the prefix check. 2. No symlink-escape test coverage. Realpath's stated purpose (catching in-project symlinks pointing outside the project) was never tested. Added: create a tmpdir outside projectRoot, symlink projectRoot/evil-link → outside file, assert rejection. ## Tests codecontext_client.test.ts: 19 tests (10 baseline + 9 new file_path resolution cases). Cases cover: relative→absolute, absolute-inside, relative-escape, absolute-outside, ENOENT-fallthrough, empty-string, wrapper-without-file_path, absolute-with-`..`-ENOENT, symlink-leaving-root. codecontext_tools.test.ts: one assertion updated to expect the resolved-absolute file_path on the wire (previously asserted the raw relative path passed through, which is exactly the bug being fixed). Full suite: 301 passed, 7 skipped. ## Affected / unaffected - get_codebase_overview, get_framework_analysis, watch_changes, search_symbols: no file_path arg → resolver guard skips them. No behavior change. - get_semantic_neighborhoods IS in SYNTHESIS_TOOLS — previously-failing relative-path calls will now successfully synthesize. Desirable, not a regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
46
openspec/changes/v1.13.18-codecontext-file-path/design.md
Normal file
46
openspec/changes/v1.13.18-codecontext-file-path/design.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# v1.13.18 — design notes
|
||||
|
||||
## Resolver contract
|
||||
|
||||
`resolveProjectPath(projectRoot: string, rawPath: string): Promise<string>`
|
||||
|
||||
1. **Trim check** — `rawPath.trim() === ''` throws `INVALID_FILE_PATH`. This is defensive code; the Zod `.trim().min(1)` in required-`file_path` wrappers catches empty paths before the shim. For optional-`file_path` wrappers, the caller guard `file_path.trim() !== ''` prevents `resolveProjectPath` from being reached at all when the string is empty or whitespace-only.
|
||||
|
||||
2. **Absolute branch** — `isAbsolute(rawPath)` uses the candidate as-is; otherwise `resolve(projectRoot, rawPath)` anchors it.
|
||||
|
||||
3. **realpath with ENOENT fallthrough** — `realpath(candidate)` resolves symlinks and normalises the path. On `ENOENT` (file doesn't exist), the un-realpathed absolute is used as the forwarded value. Any other error (EACCES, EBADF, etc.) re-throws immediately.
|
||||
|
||||
4. **Escape check** — `resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)`. Uses `path.sep` not a string literal `'/'` so the check is platform-safe (Windows posture, forward compatibility).
|
||||
|
||||
5. **Return** — the resolved absolute path, which replaces `req.args['file_path']` in `argsToSend`.
|
||||
|
||||
The guard in `callCodecontext` only invokes `resolveProjectPath` when `typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== ''`. Wrappers that don't include `file_path` in their args object are unaffected.
|
||||
|
||||
## Error-shape parity rationale
|
||||
|
||||
The `target_dir` escape error message is: `target_dir <targetDir> escapes project root <resolvedProject>`.
|
||||
|
||||
The `file_path` escape error message is: `file_path <rawPath> escapes project root <projectRoot>`.
|
||||
|
||||
The template is byte-identical except for the field name prefix. This is intentional:
|
||||
|
||||
- The existing escape error regex `/escapes project root/` used in tests and potentially in log alerting applies to both error types without special-casing.
|
||||
- A model receiving either error message can apply the same self-correction: the escape check is the same invariant (`path starts with project root + sep`), so the same remediation applies (use a path inside the project).
|
||||
- Keeping the shapes uniform reduces cognitive overhead when reading logs that mix both error types.
|
||||
|
||||
## ENOENT fallthrough rationale
|
||||
|
||||
When a `file_path` doesn't exist on disk, `resolveProjectPath` forwards the un-realpathed absolute path to the sidecar. The sidecar responds with its own error: `"file not found: <path>"` (or `"File not found in graph: <path>"`).
|
||||
|
||||
The alternative — re-implementing the "file not found" check in the resolver — would:
|
||||
1. Diverge from the sidecar's canonical error language, producing two different "not found" messages depending on whether the file existed at realpath time.
|
||||
2. Conflict with future scenarios where the sidecar's graph is stale (file existed at index time but was deleted, or vice versa). The sidecar's error is always authoritative.
|
||||
3. Add no user-visible value: the model can self-correct on either "file not found" message by checking the path.
|
||||
|
||||
The resolver's job is path safety (scope enforcement) and path normalisation (relative → absolute). Existence checking is the sidecar's job.
|
||||
|
||||
## `codecontext_tools.test.ts` impact
|
||||
|
||||
The existing `get_file_analysis forwards file_path` test in `codecontext_tools.test.ts` passes `'apps/server/src/index.ts'` as a relative `file_path` and asserts it reaches the wire unchanged. After this fix the path is resolved to `join(projectDir, 'apps/server/src/index.ts')`. The test now fails.
|
||||
|
||||
This test file is outside this batch's allowed file list. Sam should update the test assertion to expect the resolved absolute path, or create the file in the test tmpdir and assert the full resolved path. The fix is a one-liner: change `file_path: 'apps/server/src/index.ts'` to `file_path: join(projectDir, 'apps/server/src/index.ts')` in the `expect(body).toMatchObject(...)` call, and create the file before the call (so realpath succeeds).
|
||||
36
openspec/changes/v1.13.18-codecontext-file-path/proposal.md
Normal file
36
openspec/changes/v1.13.18-codecontext-file-path/proposal.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# v1.13.18 — codecontext file_path resolver
|
||||
|
||||
Fixes a silent failure that caused all four `file_path`-taking codecontext wrappers to return "file not found" whenever the model passed a relative path.
|
||||
|
||||
## Why
|
||||
|
||||
BooCode's codecontext sidecar (`codecontext_client.ts`) already realpath-resolves `target_dir` before forwarding it to the HTTP shim. It did not do the same for `file_path`. The sidecar's internal file index is keyed on absolute paths, so any relative path from the model produced a JSON error response:
|
||||
|
||||
```
|
||||
{"error":"file not found: apps/server/src/services/inference/turn.ts","result":null}
|
||||
```
|
||||
|
||||
This was observed repeatedly in the 2026-05-22 docker logs (17:56 UTC window) — the model passed relative paths on every `get_file_analysis` tool call and received no useful output, burning tool budget on dead calls.
|
||||
|
||||
## Scope
|
||||
|
||||
Four wrappers take a `file_path` argument:
|
||||
|
||||
- `tools/codecontext/get_file_analysis.ts` — `file_path` required
|
||||
- `tools/codecontext/get_symbol_info.ts` — `file_path` optional
|
||||
- `tools/codecontext/get_dependencies.ts` — `file_path` optional
|
||||
- `tools/codecontext/get_semantic_neighborhoods.ts` — `file_path` optional
|
||||
|
||||
Fix lands in one place: `callCodecontext` in `codecontext_client.ts`. A new `resolveProjectPath` helper is inserted at the args-spread site and invoked whenever `file_path` is present and non-empty. All four wrappers benefit automatically; no per-wrapper edits required.
|
||||
|
||||
Zod `.trim()` is added to all four `file_path` schema entries so that whitespace-padded paths from the model are cleaned before they reach the resolver.
|
||||
|
||||
## Decision: single resolver over per-wrapper edits
|
||||
|
||||
Four wrappers, one shared code path. Per-wrapper edits would require four edits and make it easy to miss one. The `callCodecontext` shim already owns `target_dir` validation; `file_path` validation belongs there too for symmetry.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No changes to the `target_dir` resolver — it already works correctly.
|
||||
- No extension to wrappers that do not take `file_path` (`get_codebase_overview`, `get_framework_analysis`, `search_symbols`, `watch_changes`).
|
||||
- No fix for the unrelated RPC errors and Go map-race warnings visible in the codecontext sidecar logs — those are upstream bugs.
|
||||
57
openspec/changes/v1.13.18-codecontext-file-path/tasks.md
Normal file
57
openspec/changes/v1.13.18-codecontext-file-path/tasks.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# v1.13.18 tasks
|
||||
|
||||
## B1 — Backups
|
||||
|
||||
- [x] `apps/server/src/services/codecontext_client.ts.bak-v1.13.18-20260522`
|
||||
- [x] `apps/server/src/services/tools/codecontext/get_file_analysis.ts.bak-v1.13.18-20260522`
|
||||
- [x] `apps/server/src/services/tools/codecontext/get_symbol_info.ts.bak-v1.13.18-20260522`
|
||||
- [x] `apps/server/src/services/tools/codecontext/get_dependencies.ts.bak-v1.13.18-20260522`
|
||||
- [x] `apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts.bak-v1.13.18-20260522`
|
||||
|
||||
## B2 — Resolver implementation in `codecontext_client.ts`
|
||||
|
||||
- [x] Import `isAbsolute`, `resolve`, `sep` from `node:path` (alongside existing `join`)
|
||||
- [x] Add `resolveProjectPath(projectRoot, rawPath)` helper — trim check, isAbsolute branch, realpath with ENOENT fallthrough, escape check
|
||||
- [x] Wire into `callCodecontext` at args-spread site — guard on `file_path.trim() !== ''`
|
||||
- [x] Error-shape parity verified: `file_path <raw> escapes project root <root>` mirrors `target_dir <dir> escapes project root <root>`
|
||||
|
||||
## B3 — Zod `.trim()` on wrapper schemas
|
||||
|
||||
- [x] `get_file_analysis.ts` — `z.string().trim().min(1)`
|
||||
- [x] `get_symbol_info.ts` — `z.string().trim().optional()`
|
||||
- [x] `get_dependencies.ts` — `z.string().trim().optional()`
|
||||
- [x] `get_semantic_neighborhoods.ts` — `z.string().trim().optional()`
|
||||
|
||||
## B4 — Tests
|
||||
|
||||
- [x] Added `describe('callCodecontext — file_path resolution', ...)` to `codecontext_client.test.ts`
|
||||
- [x] Case 1: relative path resolves to absolute inside project root
|
||||
- [x] Case 2: absolute path inside project root passes through
|
||||
- [x] Case 3: relative escape (`../../etc/passwd`) rejected with `escapes project root`
|
||||
- [x] Case 4: absolute path outside project root rejected
|
||||
- [x] Case 5: nonexistent file (ENOENT) forwarded as un-realpath'd absolute
|
||||
- [x] Case 6: empty string skipped by guard (treated as not provided)
|
||||
- [x] Case 7: wrapper without `file_path` — resolver not invoked, no `file_path` in wire body
|
||||
- [x] All 17 tests in `codecontext_client.test.ts` pass
|
||||
|
||||
## B5 — Typecheck + smoke
|
||||
|
||||
- [x] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||
- [x] Before-fix smoke (relative path): `{"error":"file not found: apps/server/src/services/inference/turn.ts","result":null}`
|
||||
- [x] Before-fix smoke (absolute path): returns `Lines: 330 / Symbols: 48` as expected
|
||||
|
||||
## B6 — Test asserting old buggy behavior updated
|
||||
|
||||
- [x] `apps/server/src/services/__tests__/codecontext_tools.test.ts` — assertion at line 73 updated from `file_path: 'apps/server/src/index.ts'` to `file_path: join(projectDir, 'apps/server/src/index.ts')` to match the new resolved-absolute contract.
|
||||
|
||||
## B7 — OpenSpec docs
|
||||
|
||||
- [x] `openspec/changes/v1.13.18-codecontext-file-path/proposal.md`
|
||||
- [x] `openspec/changes/v1.13.18-codecontext-file-path/tasks.md`
|
||||
- [x] `openspec/changes/v1.13.18-codecontext-file-path/design.md`
|
||||
|
||||
## B8 — Review-pass defence-in-depth (P2 fixes from adversarial review)
|
||||
|
||||
- [x] `codecontext_client.ts:71` — absolute branch now goes through `resolve()` to normalise dot-segments. Closes the ENOENT-fallthrough escape gap where `<projectRoot>/../etc/x` would prefix-match `<projectRoot>/` literally.
|
||||
- [x] `codecontext_client.test.ts` — added Case 8 (absolute path with `..` resolving outside root, ENOENT branch) and Case 9 (in-project symlink whose target sits outside root). 19 tests pass.
|
||||
- [x] Updated `resolveProjectPath` docstring to reflect the new normalisation step.
|
||||
Reference in New Issue
Block a user