Skip to content

Commit b261df8

Browse files
committed
harden: read-surface PR parity, bind policy, and ship docs
Add changeset and serve-bind-policy with runHttpServer enforcement; align validate/serve consumer surfaces (CLI help, MCP, glossary, README, agent-content); extend symlink containment tests.
1 parent 328b5fd commit b261df8

17 files changed

Lines changed: 150 additions & 41 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": patch
3+
---
4+
5+
Harden read surfaces: `codemap query --format …` blocks index mutations via the same read-only guard as `--json`; `codemap serve` requires `--token` when `--host` is not loopback; `codemap validate` (and MCP/HTTP `validate`) can return `rejected` rows with a `reason` when a path escapes the project root or resolves outside via symlink.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ codemap dead-code --json # outcome alias →
5050
codemap query --json --recipe fan-out # recipe SQL by id (alias: -r)
5151
codemap query --json "SELECT name, file_path FROM symbols WHERE name = 'foo'" # ad-hoc SQL
5252
codemap --files src/a.ts src/b.tsx # targeted re-index after edits
53-
codemap validate --json # detect stale / missing / unindexed files
53+
codemap validate --json # detect stale / missing / unindexed / rejected files
5454
codemap context --compact --for "refactor auth" # JSON envelope + intent-matched recipes
5555
codemap ingest-coverage coverage/coverage-final.json --json # Istanbul / LCOV (auto-detected) → coverage table; joins with symbols
5656
NODE_V8_COVERAGE=.cov bun test && codemap ingest-coverage .cov --runtime --json # V8 protocol (per-process dumps); local-only
@@ -162,9 +162,9 @@ codemap query --format diff-json 'SELECT "README.md" AS file_path, 1 AS line_sta
162162
codemap --with-fts --full
163163
codemap query --recipe text-in-deprecated-functions # demonstrates FTS5 ⨯ symbols ⨯ coverage JOIN
164164
# HTTP API — same tool taxonomy as `codemap mcp`, exposed over POST /tool/{name} for
165-
# non-MCP consumers (CI scripts, curl, IDE plugins). Loopback default; optional --token.
165+
# non-MCP consumers (CI scripts, curl, IDE plugins). Loopback default; --token required on non-loopback.
166166
TOKEN=$(openssl rand -hex 32)
167-
codemap serve --port 7878 --token "$TOKEN" &
167+
codemap serve --port 7878 --token "$TOKEN" & # --token required when --host is not loopback
168168
curl -s -X POST http://127.0.0.1:7878/tool/query \
169169
-H 'Content-Type: application/json' \
170170
-H "Authorization: Bearer $TOKEN" \

docs/glossary.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ CI-aggregate flag on `codemap query` and `codemap audit`. Aliases `--format sari
125125

126126
### `codemap validate`
127127

128-
CLI subcommand comparing on-disk SHA-256 against `files.content_hash`. Statuses: `stale | missing | unindexed`. Exits `1` on any drift.
128+
CLI subcommand comparing on-disk SHA-256 against `files.content_hash`. Statuses: `stale | missing | unindexed | rejected` (`rejected` carries optional `reason` when a path escapes the project root or resolves outside via symlink). Exits `1` on any drift.
129129

130130
### `module_cycles` (table) / circular imports
131131

@@ -565,7 +565,7 @@ Long-running process that subscribes to filesystem changes via [chokidar v5](htt
565565

566566
### `codemap serve` / HTTP server
567567

568-
Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); optional `--token <secret>` requires `Authorization: Bearer <secret>` on every request. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope, or `baseline` returns a diff envelope (incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); parity twins (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context`, `ingest-coverage`, `ingest-churn`) always emit JSON on CLI without `--json`; other tools match their CLI `--json` payloads when that flag is set; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` / `"badge"` (markdown) as `text/plain; charset=utf-8`, `format: "diff-json"` / `"codeclimate"` / `"badge"` + `badge_style: "json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: <semver>`. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage).
568+
Long-running HTTP server exposing the same tool taxonomy as `codemap mcp` over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Default bind **`127.0.0.1:7878`** (loopback only — refuse `0.0.0.0` unless explicitly opted in via `--host 0.0.0.0`); `--token <secret>` is optional on loopback binds and **required** on non-loopback binds — when set, every request needs `Authorization: Bearer <secret>`. HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); `query` / `query_recipe` match `codemap query --json` row arrays unless `summary` / `group_by` reshape the envelope, or `baseline` returns a diff envelope (incompatible with non-`json` `format` / `group_by`; save/list/drop remain separate tools); parity twins (`query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context`, `ingest-coverage`, `ingest-churn`) always emit JSON on CLI without `--json`; other tools match their CLI `--json` payloads when that flag is set; `format: "sarif"` payloads ship as `application/sarif+json`, `format: "annotations"` / `"mermaid"` / `"diff"` / `"badge"` (markdown) as `text/plain; charset=utf-8`, `format: "diff-json"` / `"codeclimate"` / `"badge"` + `badge_style: "json"` as `application/json; charset=utf-8`. Routes: `POST /tool/{name}` (every MCP tool), `GET /resources/{encoded-uri}` (resource handler for `codemap://recipes`, `codemap://recipes/{id}`, `codemap://schema`, `codemap://skill`, `codemap://rule`, `codemap://mcp-instructions`, `codemap://files/{path}`, and `codemap://symbols/{name}`), `GET /health` (auth-exempt liveness probe — does not start the watcher), `GET /tools` / `GET /resources` (catalogs). With `--watch`, chokidar is refcount-gated per request and stops 5s after the last client (`HTTP_WATCH_RELEASE_GRACE_MS`) — distinct from MCP idle shutdown; the HTTP process keeps listening. Pure transport — same `tool-handlers.ts` / `resource-handlers.ts` MCP uses; no engine duplication. Errors → `{"error": "..."}` with HTTP status 400 / 401 / 403 / 404 / 500. SIGINT / SIGTERM → graceful drain. Every response carries `X-Codemap-Version: <semver>`. **CSRF + DNS-rebinding guard:** every request (including auth-exempt `/health`) is evaluated against `Sec-Fetch-Site` / `Origin` / `Host` when present — modern browsers send `Sec-Fetch-Site` and `Origin` on cross-origin fetches (header presence varies by request type, browser, and privacy settings), so the guard rejects browser-driven cross-origin requests like a malicious local webpage `fetch`-ing `http://127.0.0.1:7878/tool/save_baseline` to mutate `.codemap/index.db`. `Host` mismatch on a loopback bind blocks DNS rebinding (an attacker resolving `evil.com` to `127.0.0.1` post-load). Non-browser clients (curl, fetch from Node, MCP hosts, CI scripts) typically omit these headers and pass through. Implementation: `src/cli/cmd-serve.ts` (CLI shell) + `src/application/http-server.ts` (transport). See [`architecture.md` § HTTP wiring](./architecture.md#cli-usage).
569569

570570
### Code Climate format (`codeclimate`)
571571

docs/plans/security-hardening-orchestrator.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121

2222
## PR schedule
2323

24-
| PR | Plan | Status | Blocks |
25-
| ----- | -------------------------------------------------------------- | ---------------------------- | ----------------------------------- |
26-
| **1** | [`security-hardening-wave1.md`](./security-hardening-wave1.md) | **implemented, uncommitted** ||
27-
| **2** | [`impact-inpath-homonyms.md`](./impact-inpath-homonyms.md) | **pending** | PR **1** merged |
28-
| **3** | [`runtime-test-isolation.md`](./runtime-test-isolation.md) | **pending** | PR **1** merged (PR **2** optional) |
24+
| PR | Plan | Status | Blocks |
25+
| ----- | -------------------------------------------------------------- | ------------------------- | ----------------------------------- |
26+
| **1** | [`security-hardening-wave1.md`](./security-hardening-wave1.md) | **committed, PR pending** ||
27+
| **2** | [`impact-inpath-homonyms.md`](./impact-inpath-homonyms.md) | **pending** | PR **1** merged |
28+
| **3** | [`runtime-test-isolation.md`](./runtime-test-isolation.md) | **pending** | PR **1** merged (PR **2** optional) |
2929

3030
||| **deferred** | golden `schema.test.ts` + path guards |
3131
||| **skip** | atomic `ensureStateConfig` writes |
@@ -64,15 +64,15 @@ Evaluated 2026-06 against [roadmap § Floors](../roadmap.md#floors-v1-product-sh
6464

6565
## Session log
6666

67-
| Date | Event | Notes |
68-
| ---------- | ---------- | ----------------------------------------------------------------------------- |
69-
| 2026-06-10 | Triage | ROI on 7 slices; 3-PR program adopted. |
70-
| 2026-06-10 | PR 1 impl | PR **1** code complete; **not committed**. Docs: orchestrator + per-PR plans. |
71-
|| PR 1 merge | _PR URL · merge SHA · update plan status → closed_ |
72-
|| PR 2 start | _from `main`_ |
73-
|| PR 2 merge | _fill_ |
74-
|| PR 3 start | _from `main`_ |
75-
|| PR 3 merge | _fill · close orchestrator_ |
67+
| Date | Event | Notes |
68+
| ---------- | ---------- | ---------------------------------------------------------------------------- |
69+
| 2026-06-10 | Triage | ROI on 7 slices; 3-PR program adopted. |
70+
| 2026-06-10 | PR 1 impl | PR **1** committed on `fix/security-hardening-wave1`; harden pass in flight. |
71+
|| PR 1 merge | _PR URL · merge SHA · update plan status → closed_ |
72+
|| PR 2 start | _from `main`_ |
73+
|| PR 2 merge | _fill_ |
74+
|| PR 3 start | _from `main`_ |
75+
|| PR 3 merge | _fill · close orchestrator_ |
7676

7777
---
7878

docs/plans/security-hardening-wave1.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PR 1 — query / serve / validate hardening
22

3-
> **Status:** open (implemented, uncommitted) · **PR:** 1 of 3 · **Effort:** S
3+
> **Status:** open (committed, PR pending) · **PR:** 1 of 3 · **Effort:** S
44
>
55
> **Orchestrator:** [`security-hardening-orchestrator.md`](./security-hardening-orchestrator.md)
66
>
@@ -49,7 +49,7 @@ codemap validate <paths>
4949
| 3.2 | `validate` `rejected` + `rejectValidatePath` | **done** | `bun test src/cli/cmd-validate.test.ts` |
5050
| 3.3 | Test `../../../etc/passwd``rejected` | **done** | same |
5151
| 1.x | `docs/architecture.md` lift | **done** | `bun run format:check` |
52-
| 1.s | Commit + PR + CI | **pending** | `bun run check` |
52+
| 1.s | Commit + PR + CI | **pending** | `bun run check` (committed; PR + CI open) |
5353

5454
---
5555

src/application/http-server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import type { IndexFreshness } from "./index-freshness";
2121
import { MCP_TOOL_NAMES } from "./mcp-tool-allowlist";
2222
import { buildHttpToolCatalogEntry } from "./mcp-tool-annotations";
2323
import { listResources, readResource } from "./resource-handlers";
24+
import {
25+
assertServeBindRequiresToken,
26+
isLoopbackHost,
27+
} from "./serve-bind-policy";
2428
import {
2529
bindWatchClientRelease,
2630
createManagedWatchSession,
@@ -119,6 +123,7 @@ export interface HttpServerOpts {
119123
* to JSON `{"error": "..."}` with appropriate status codes.
120124
*/
121125
export async function runHttpServer(opts: HttpServerOpts): Promise<void> {
126+
assertServeBindRequiresToken(opts.host, opts.token);
122127
await bootstrapForServe(opts);
123128

124129
let managedWatchSession: ManagedWatchSession | undefined;
@@ -692,7 +697,7 @@ function csrfCheck(
692697
return `cross-origin request rejected (Sec-Fetch-Site: ${String(fetchSite)}). codemap serve does not accept browser-driven cross-origin requests.`;
693698
}
694699

695-
if (host === "127.0.0.1" || host === "localhost" || host === "::1") {
700+
if (isLoopbackHost(host)) {
696701
const hostHeader = req.headers.host;
697702
if (hostHeader !== undefined) {
698703
const allowed = new Set([

src/application/mcp-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ function registerValidateTool(server: McpServer): void {
267267
"validate",
268268
withToolAnnotations("validate", {
269269
description:
270-
"Compare on-disk SHA-256 of indexed files to the indexed `files.content_hash` column. Returns only out-of-sync rows with status `stale` / `missing` / `unindexed` (fresh paths omitted). Empty `paths` validates every indexed file. Useful for 'codemap doctor' agents that diagnose a stale index before issuing structural queries.",
270+
"Compare on-disk SHA-256 of indexed files to the indexed `files.content_hash` column. Returns only out-of-sync rows with status `stale` / `missing` / `unindexed` / `rejected` (fresh paths omitted; `rejected` includes optional `reason` when a path escapes the project root or resolves outside via symlink). Empty `paths` validates every indexed file. Useful for 'codemap doctor' agents that diagnose a stale index before issuing structural queries.",
271271
inputSchema: validateArgsSchema,
272272
}),
273273
(args) => wrapToolResult(handleValidate(args)),

src/application/path-containment.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,25 @@ describe("pathTraversesSymlinkOutsideRoot", () => {
4646
true,
4747
);
4848
});
49+
50+
it("returns true when an intermediate directory symlinks outside the root", () => {
51+
const base = mkdtempSync(join(tmpdir(), "codemap-symlink-dir-"));
52+
const root = join(base, "proj");
53+
const outside = join(base, "outside");
54+
mkdirSync(root, { recursive: true });
55+
mkdirSync(join(outside, "nested"), { recursive: true });
56+
writeFileSync(
57+
join(outside, "nested", "secret.ts"),
58+
"export const s = 1;\n",
59+
);
60+
symlinkSync(outside, join(root, "linked-dir"));
61+
expect(
62+
pathTraversesSymlinkOutsideRoot(
63+
root,
64+
join(root, "linked-dir", "nested", "secret.ts"),
65+
),
66+
).toBe(true);
67+
});
4968
});
5069

5170
describe("resolvePathWithinRoot", () => {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import {
4+
assertServeBindRequiresToken,
5+
isLoopbackHost,
6+
serveBindTokenRequiredMessage,
7+
} from "./serve-bind-policy";
8+
9+
describe("isLoopbackHost", () => {
10+
it.each([
11+
["127.0.0.1", true],
12+
["localhost", true],
13+
["::1", true],
14+
["[::1]", true],
15+
["0.0.0.0", false],
16+
["::", false],
17+
["192.168.1.1", false],
18+
] as const)("host %s → %s", (host, expected) => {
19+
expect(isLoopbackHost(host)).toBe(expected);
20+
});
21+
});
22+
23+
describe("serveBindTokenRequiredMessage", () => {
24+
it("requires token on non-loopback binds", () => {
25+
expect(serveBindTokenRequiredMessage("0.0.0.0", undefined)).toContain(
26+
"non-loopback bind requires --token",
27+
);
28+
expect(serveBindTokenRequiredMessage("::", undefined)).toContain(
29+
"non-loopback bind requires --token",
30+
);
31+
});
32+
33+
it("allows loopback binds without token", () => {
34+
expect(
35+
serveBindTokenRequiredMessage("127.0.0.1", undefined),
36+
).toBeUndefined();
37+
expect(serveBindTokenRequiredMessage("[::1]", undefined)).toBeUndefined();
38+
});
39+
40+
it("assertServeBindRequiresToken throws with the same message", () => {
41+
expect(() => assertServeBindRequiresToken("0.0.0.0", undefined)).toThrow(
42+
/non-loopback bind requires --token/,
43+
);
44+
});
45+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/** Hosts safe to bind without mandatory auth (loopback only). */
2+
export function isLoopbackHost(host: string): boolean {
3+
const h = host.toLowerCase();
4+
return h === "127.0.0.1" || h === "localhost" || h === "::1" || h === "[::1]";
5+
}
6+
7+
/** Error message when a non-loopback bind lacks a token; `undefined` when OK. */
8+
export function serveBindTokenRequiredMessage(
9+
host: string,
10+
token: string | undefined,
11+
): string | undefined {
12+
if (!isLoopbackHost(host) && (token === undefined || token.length === 0)) {
13+
return (
14+
"codemap serve: non-loopback bind requires --token (use a long random secret). " +
15+
"Example: codemap serve --host 0.0.0.0 --token $(openssl rand -hex 32)"
16+
);
17+
}
18+
return undefined;
19+
}
20+
21+
/** Fail fast before `listen` when bind policy is violated. */
22+
export function assertServeBindRequiresToken(
23+
host: string,
24+
token: string | undefined,
25+
): void {
26+
const message = serveBindTokenRequiredMessage(host, token);
27+
if (message !== undefined) throw new Error(message);
28+
}

0 commit comments

Comments
 (0)