From ea4b940ceffda96ae3d2b7be201f5c4de11fc2e8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 19:21:43 +0200 Subject: [PATCH] review fixes: block Codeman's own credential-bearing JSON, make the inside-anchor test bite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening the servable extensions to EDITABLE_EXTENSIONS made ~/.codeman JSON previewable for the first time, and the blocklist named only state.json. But settings.json holds a credential BY SCHEMA (voiceSettings.apiKey), push-keys.json holds the VAPID PRIVATE key, and intents.json is written 0600 precisely because captured prompts can carry secrets — all three were one authenticated click away once an agent printed the path. Blocked alongside state.json, whose rule now also catches state-* siblings. The never-re-cuts-inside-an-anchor test used an unmatchable URL tail, so it passed with the guard deleted; the fixture now carries a matchable /tmp path. Co-Authored-By: Claude Fable 5 --- src/web/sensitive-path.ts | 23 +++++++++++++++++------ test/response-viewer-file-links.test.ts | 7 +++++-- test/sensitive-path.test.ts | 8 ++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/web/sensitive-path.ts b/src/web/sensitive-path.ts index bb20c37f..29caede8 100644 --- a/src/web/sensitive-path.ts +++ b/src/web/sensitive-path.ts @@ -80,12 +80,23 @@ const SENSITIVE_PATTERNS: RegExp[] = [ /\/\.claude\/\.credentials\.json$/, /\/\.codeman[^/]*\/hook-secret$/, /\/\.codeman[^/]*\/users\.json$/, - // Codeman's own state file. Named once `.json` became previewable outside the - // workspace: `SessionState.envOverrides` persists whatever the user set for a - // session, and the env allowlist admits key-shaped names (`GEMINI_API_KEY`, - // `CLAUDE_CODE_*`), so this file can hold a live credential. Same reasoning - // as the two entries above, and it leaves the rest of ~/.codeman attachable. - /\/\.codeman[^/]*\/state\.json$/, + // Codeman's own state files. Named once `.json` became previewable outside + // the workspace: `SessionState.envOverrides` persists whatever the user set + // for a session, and the env allowlist admits key-shaped names + // (`GEMINI_API_KEY`, `CLAUDE_CODE_*`), so state can hold a live credential. + // `state[^/]*` rather than `state`: siblings like state-inner.json carry the + // same payload. Same reasoning as the two entries above, and it leaves the + // rest of ~/.codeman attachable. + /\/\.codeman[^/]*\/state[^/]*\.json$/, + // settings.json holds a credential BY SCHEMA (`voiceSettings.apiKey`, the + // Deepgram key); push-keys.json holds the VAPID PRIVATE key (enough to forge + // push notifications to every subscribed device); intents.json is written + // 0600 precisely because captured prompts can contain secrets, and is + // deliberately kept out of /api/search — it must not be readable through a + // different route instead. + /\/\.codeman[^/]*\/settings\.json$/, + /\/\.codeman[^/]*\/push-keys\.json$/, + /\/\.codeman[^/]*\/intents\.json$/, ]; /** diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index 07835e45..6caababf 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -100,11 +100,14 @@ describe('response viewer file-path linkifier', () => { it('never re-cuts text already inside an anchor', () => { // marked autolinks URLs; a path-looking tail inside one must stay whole, and // a nested is invalid markup that would swallow the outer link's click. - const root = linkify('

https://example.com/x/y.png

'); + // ⚠️ The URL's tail MUST be a string the pattern matches on its own + // (`/tmp/...` here): with an unmatchable tail this test passes with the + // inside-anchor guard deleted, i.e. it pins nothing. + const root = linkify('

https://example.com/tmp/shot.png

'); expect(paths(root)).toHaveLength(0); expect(root.querySelectorAll('a')).toHaveLength(1); - expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/x/y.png'); + expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/tmp/shot.png'); }); it('leaves text with no path untouched', () => { diff --git a/test/sensitive-path.test.ts b/test/sensitive-path.test.ts index 9b5013cb..79329925 100644 --- a/test/sensitive-path.test.ts +++ b/test/sensitive-path.test.ts @@ -79,6 +79,14 @@ describe('isSensitivePath', () => { // workspace. ['codeman state file', `${HOME}/.codeman/state.json`], ['codeman state file on a named instance', `${HOME}/.codeman-beta/state.json`], + ['codeman state sibling (same payload)', `${HOME}/.codeman/state-inner.json`], + // settings.json holds voiceSettings.apiKey by schema; push-keys.json holds + // the VAPID PRIVATE key; intents.json is 0600 because captured prompts can + // contain secrets and is deliberately kept out of /api/search. + ['codeman settings (Deepgram key)', `${HOME}/.codeman/settings.json`], + ['codeman push keys (VAPID private)', `${HOME}/.codeman/push-keys.json`], + ['codeman intent profiles', `${HOME}/.codeman/intents.json`], + ['codeman intents on a named instance', `${HOME}/.codeman-beta/intents.json`], ]; it.each(blocked)('blocks the %s', (_label, path) => {