Skip to content

feat: redact absolute local paths from server responses by default - #97

Open
lyeith wants to merge 1 commit into
rebel0789:mainfrom
lyeith:fix/path-redaction
Open

feat: redact absolute local paths from server responses by default#97
lyeith wants to merge 1 commit into
rebel0789:mainfrom
lyeith:fix/path-redaction

Conversation

@lyeith

@lyeith lyeith commented Aug 18, 2026

Copy link
Copy Markdown

What leaks today

Every response CodexPro sends carries the operator's real filesystem layout — including their username — to the connector client.

MCP tool results. structuredContent and text content blocks echo absolute paths verbatim:

{ "workspace_id": "ws_1a2b...", "root": "/home/alice/Projects/api", "path": "src/x.ts" }

The authenticated HTTP diagnostics endpoints. GET /healthz and GET /admin/profile bypass the tool-result path entirely, and both echo config. They are reachable with the same bearer token an MCP client already holds, so they are part of the same client-visible surface. On a live deployment I measured 4 occurrences of /home/<user> in /healthz (defaultRoot, allowedRoots) and 7 in /admin/profile (profile_path, effective.codexDir, runtime.defaultRoot, …).

For a connector reached over a tunnel this is a standing disclosure of the host's directory structure — and the account name — to a third party, for no functional benefit. The model never needs the absolute prefix: every tool already addresses files workspace-relative.

The redaction scheme

Absolute local paths are rewritten to stable labels before a response leaves the server:

/home/alice/Projects/api/src/x.ts  ->  [workspace:ws_1a2b.../src/x.ts]
/home/alice/.codex/sessions/a.json ->  [codex-data]/sessions/a.json
/home/alice                        ->  [home]

Labels on this branch are [workspace:<id>] (discovered from the payload, because workspace ids are assigned at runtime and appear nowhere in config), [workspace], [allowed-root], [codex-data] and [home].

Two properties matter for correctness:

Longest-match-first. A workspace nested inside the home directory must be labelled [workspace:...], not [home]/Projects/api. Entries are sorted by descending path length before substitution.

Path-boundary-aware matching. This one bit me in deployment and is worth calling out, because the naive implementation looks completely fine in testing. A plain substring replace produces:

/home/alice/.codexpro/profiles/x.json  ->  [codex-data]pro/profiles/x.json

~/.codex is a string prefix of ~/.codexpro, so the Codex-data label swallowed part of an unrelated segment. Not a leak, but it misreports which directory a path belongs to. A registered path now only matches when followed by a path separator or end of string, so ~/.codex no longer touches ~/.codexpro or ~/.codex-backup; those fall through to [home] as intended.

One extra rule for the two diagnostics endpoints. They echo stored profile content, which can contain paths from an older saved profile that the running config knows nothing about — so config-derived labels cannot cover them by construction. Any string that is still a bare absolute path after labelling collapses to [path]. The pattern is anchored at both ends, leaving URLs (https://host/p), already-labelled paths ([home]/.codex) and prose containing a slash untouched. This fallback is deliberately not applied to tool results, where file content and command output must survive intact.

Redacting GET /admin/profile is safe for the admin UI, which I verified before changing it: the onboarding form is rendered server-side from profileValues(), and the page's only fetch is the POST save. No labelled value can round-trip into a stored profile.

What is not touched

Workspace-relative paths, so tool arguments and results still round-trip — read(path: "src/x.ts") is unaffected, as is file content, command output and diffs.

Escape hatch

CODEXPRO_EXPOSE_ABSOLUTE_PATHS=1 restores the previous behaviour for local debugging, and server_config reports which mode is active.

Default: ON — and the honest cost

I've defaulted this to ON, on the grounds that leaking the host layout should be opt-in rather than opt-out, and that a connector talking to a hosted model is the common case.

The visible cost is that 13 smoke harnesses need CODEXPRO_EXPOSE_ABSOLUTE_PATHS = '1' at the top of the file, because they realpath() and stat() the paths CodexPro returns. I chose a one-line explicit opt-out per harness over rewriting their assertions, so existing coverage keeps testing exactly what it tested before. That is 13 of the 18 changed files, and it is the main thing to weigh if you'd rather this defaulted to OFF — the code supports either, and flipping the default is a one-word change in config.ts.

Verification

npm run build and all 12 smoke scripts pass.

New coverage, both written to fail against the unpatched code:

  • scripts/smoke.mjs runs a client with redaction on and asserts five tool results contain no absolute path, that the workspace root equals its label, that relative paths and file content survive, and that the escape hatch still returns a real root.
  • scripts/http-smoke.mjs runs a server with redaction on and asserts both endpoints are free of every local path, that healthz still reports labelled roots and working diagnostics, and that no label swallowed part of a path segment. It builds that server with the layout that exposed the prefix bug (<h>/.codex as the Codex dir, <h>/.codexpro as CODEXPRO_HOME); against the pre-fix code it fails with:
Error: /admin/profile produced a label that swallowed part of a path segment: [codex-data]p

The leak assertion names the offending JSON path rather than only the value, which is what turned the original finding into a quick diagnosis.

Beyond the test suite, this is running on a real deployment reached over a private network. Scanning the client-visible surface there — tools/list, list_projects, open_workspace, read, tree, server_config, /healthz, /admin/profile — returns zero absolute paths and zero malformed labels, where /healthz and /admin/profile previously returned 4 and 7.

🤖 Generated with Claude Code

Every tool result, and both authenticated diagnostics endpoints, currently carry
the operator's real filesystem layout to the connector client: the workspace root,
the home directory and the Codex data directory appear verbatim in
structuredContent, in text content blocks, in GET /healthz and in
GET /admin/profile. For a connector reached over a tunnel that is a standing
disclosure of the host's directory structure to a third party, for no functional
benefit.

Absolute local paths are now rewritten to stable labels before a response leaves
the server:

  /home/you/Projects/api/src/x.ts -> [workspace:ws_1a2b.../src/x.ts]
  /home/you                       -> [home]

Labels apply longest-match-first, so a workspace nested inside the home directory
is labelled [workspace:...] rather than [home]/..., and only at a path boundary,
so /home/you/.codex never rewrites the unrelated /home/you/.codexpro into
"[codex-data]pro". Workspace roots are discovered from the payload because
workspace ids are assigned at runtime; the default root, allowed roots and the
Codex directory come from config.

Workspace-relative paths are untouched, so tool arguments and results still
round-trip: read(path: "src/x.ts") is unaffected, as is file content and command
output.

The two diagnostics endpoints get one extra rule: they echo *stored* profile
content, which can hold paths from an older profile the running config knows
nothing about, so a string that is still a bare absolute path after labelling
collapses to [path]. The pattern is anchored at both ends, leaving URLs,
already-labelled paths and prose containing a slash alone. It is deliberately not
applied to tool results, where file content must survive intact.

Redacting GET /admin/profile is safe for the admin UI: the onboarding form is
rendered server-side from profileValues(), and the page's only fetch is the POST
save, so no labelled value can round-trip into a stored profile.

CODEXPRO_EXPOSE_ABSOLUTE_PATHS=1 restores the previous behaviour for local
debugging, and server_config reports which mode is active. The smoke harnesses
realpath and stat the paths CodexPro returns, so they set it explicitly at the
top of each file rather than having their assertions rewritten.

New coverage: scripts/smoke.mjs asserts five tool results contain no absolute
path, that the workspace root equals its label, that relative paths and file
content survive, and that the escape hatch still returns a real root.
scripts/http-smoke.mjs runs a server with redaction on and asserts both endpoints
are free of every local path, that no label swallowed part of a path segment, and
that healthz still reports labelled roots and working diagnostics.

npm run build and the full smoke suite pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant