Files
boocode/openspec/changes/v1.13.18-codecontext-file-path/design.md
indifferentketchup 1a889dcde3 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>
2026-05-22 21:54:16 +00:00

3.9 KiB

v1.13.18 — design notes

Resolver contract

resolveProjectPath(projectRoot: string, rawPath: string): Promise<string>

  1. Trim checkrawPath.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 branchisAbsolute(rawPath) uses the candidate as-is; otherwise resolve(projectRoot, rawPath) anchors it.

  3. realpath with ENOENT fallthroughrealpath(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 checkresolved !== 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).