Files
Codeman/test/sensitive-path.test.ts
T
Codeman maintainer da999b130e feat(files): preview text files from outside the workspace, and stop routing them at a viewer that cannot read them
A .json/.log/.yaml/code path outside the session workspace was refused as an
unsupported type, and clicking one in the terminal made it worse: text goes to
the log viewer, which spawns `tail -f` and allows only the workspace, /var/log
and ~/logs, so it answered "Path must be within working directory or allowed
log directories" while the same path clicked in the response viewer previewed
fine. Two surfaces, two answers, for a file the session can already cat.

- TEXT_ATTACHMENT_EXTENSIONS IS EDITABLE_EXTENSIONS (config/file-editing.ts),
  not a second curated list that would drift from it. The rule reads: if the
  viewer would open a file for editing inside the workspace, the same file
  outside it can be read. The suffix was never the confidentiality gate here,
  the path guard is (sensitive-file blocklist, /root and /etc trees, realpath
  before the check), and it still runs on every registration.
- Widening what can be READ must not widen what can RUN. html/htm join svg in
  serveRawFile's download-only branch, so markup is never served with a
  renderable type on our own origin; other text goes out as inert
  text/plain; charset=utf-8 with nosniff, matching what the path picker does.
  The preview reads through fetch(), which ignores the disposition, so a
  clicked .html still shows its source.
- ~/.codeman*/state.json joins isSensitivePath. It persists
  SessionState.envOverrides and the env allowlist admits key-shaped names
  (GEMINI_API_KEY, CLAUDE_CODE_*), so it can hold a live credential. Same
  treatment as hook-secret and users.json, and the rest of the tree stays
  attachable.
- The terminal sends an out-of-workspace path to the preview instead of the log
  viewer. In-workspace text keeps the tail viewer, which is the point of it, and
  file-stream-manager's allowlist is untouched: no `tail -f` on arbitrary host
  paths.
- The by-id text preview is bounded like the workspace one: a Range request for
  the first 512KB (a real partial read, not a discarded 50MB download) plus a
  500-line cap, with the footer saying so.

Verified on an isolated instance: a 1.1MB external log opens in ~1.8s showing
500 lines with "showing first 500 lines" in the footer; json, yaml and code
preview; an .html carrying a script tag renders as source and does not execute;
.svg is still refused; a terminal click on an external .yaml opens the preview
with no log viewer and no attachment card; an in-workspace .log still opens the
streaming tail viewer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:12:00 +02:00

118 lines
5.6 KiB
TypeScript

/**
* @fileoverview The shared sensitive-path blocklist (`src/web/sensitive-path.ts`).
*
* This list guards every browser-facing file surface: workspace download,
* cross-workspace attachment registration, raw/preview serving, and the
* filesystem path picker.
*
* It became load-bearing when the picker gained `showHidden` (issue #221).
* Before that, the picker refused any path with a dot-prefixed segment, so most
* of the credential locations below were unreachable by construction and the
* list only had to cover secrets that sit in plain sight. Opting into hidden
* entries removes that accident, which is why each entry is pinned here: a
* pattern silently dropped in a refactor would re-expose a real token.
*
* The list is a BLOCKLIST by design (cross-workspace attachment is a supported
* feature), so the "stays attachable" cases matter just as much: over-blocking
* breaks the publish skill and the review-card loop.
*/
import { describe, expect, it } from 'vitest';
import { isSensitivePath } from '../src/web/sensitive-path.js';
const HOME = '/home/dev';
describe('isSensitivePath', () => {
describe('blocks', () => {
const blocked: Array<[string, string]> = [
['system shadow file', '/etc/shadow'],
['system gshadow file', '/etc/gshadow'],
['BSD master password db', '/etc/master.passwd'],
['ssh keys in home', `${HOME}/.ssh/id_ed25519`],
// Not only under homedir(): a deploy key in a project is the same secret,
// and the old homedir()-anchored pattern was captured at module load.
['ssh keys anywhere', '/srv/deploy/.ssh/id_rsa'],
['gpg keyring', `${HOME}/.gnupg/private-keys-v1.d/key.key`],
['dotenv', '/srv/app/.env'],
['suffixed dotenv', '/srv/app/.env.production'],
// Pre-existing and deliberate: `.env.*` is blocked wholesale, so even a
// committed `.env.example` is refused rather than risking the one repo
// whose "example" holds a live key.
['a dotenv example', '/srv/app/.env.example'],
['generic credentials file', '/srv/app/credentials'],
['json credentials', '/srv/app/credentials.json'],
['toml credentials', '/srv/app/credentials.toml'],
['aws credentials', `${HOME}/.aws/credentials`],
['aws config', `${HOME}/.aws/config`],
['aws sso cache', `${HOME}/.aws/sso/cache/abc.json`],
['legacy gcloud credential db', `${HOME}/.gcloud/credentials.db`],
['modern gcloud config tree', `${HOME}/.config/gcloud/application_default_credentials.json`],
['azure profile', `${HOME}/.azure/accessTokens.json`],
['docker registry auth', `${HOME}/.docker/config.json`],
['kubernetes context', `${HOME}/.kube/config`],
['npm token', `${HOME}/.npmrc`],
['yarn token', `${HOME}/.yarnrc.yml`],
['git credential store', `${HOME}/.git-credentials`],
['gh cli token', `${HOME}/.config/gh/hosts.yml`],
['hub token', `${HOME}/.config/hub`],
['netrc', `${HOME}/.netrc`],
['windows netrc', `${HOME}/_netrc`],
['pypi token', `${HOME}/.pypirc`],
['rubygems token', `${HOME}/.gem/credentials`],
['cargo token', `${HOME}/.cargo/credentials.toml`],
['terraform cli config', `${HOME}/.terraformrc`],
['terraform credentials dir', `${HOME}/.terraform.d/credentials.tfrc.json`],
['postgres password file', `${HOME}/.pgpass`],
['mysql client config', `${HOME}/.my.cnf`],
['claude oauth token', `${HOME}/.claude/.credentials.json`],
['codeman hook secret', `${HOME}/.codeman/hook-secret`],
['codeman user table', `${HOME}/.codeman/users.json`],
['codeman hook secret on a named instance', `${HOME}/.codeman-beta/hook-secret`],
// state.json persists SessionState.envOverrides, and the env allowlist
// admits key-shaped names (GEMINI_API_KEY, CLAUDE_CODE_*), so it can hold
// a live credential. Named once .json became previewable from outside the
// workspace.
['codeman state file', `${HOME}/.codeman/state.json`],
['codeman state file on a named instance', `${HOME}/.codeman-beta/state.json`],
];
it.each(blocked)('blocks the %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(true);
});
});
describe('leaves ordinary files attachable', () => {
const allowed: Array<[string, string]> = [
['a source file', '/srv/app/src/index.ts'],
['a dotfile that carries no secret', '/srv/app/.gitignore'],
['a hidden CI directory', '/srv/app/.github/workflows/ci.yml'],
// The publish skill and the review-card loop attach from these trees, so
// only their named secret members are blocked, never the whole tree.
['a codeman screenshot', `${HOME}/.codeman/screenshots/shot.png`],
['a codeman lifecycle log', `${HOME}/.codeman/session-lifecycle.jsonl`],
['a claude transcript', `${HOME}/.claude/projects/proj/session.jsonl`],
['a claude team inbox', `${HOME}/.claude/teams/alpha/inboxes/bob.json`],
// isUnderTree-style separator awareness: a sibling name that merely starts
// with a blocked segment must not be caught.
['an unrelated sshd notes file', '/srv/notes/.sshd-setup.md'],
['a file named credentials-policy.md', '/srv/app/credentials-policy.md'],
];
it.each(allowed)('allows %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(false);
});
});
it('matches on the resolved path, so callers must realpath first', () => {
// The function itself is pure string matching; this pins the contract its
// docblock states, which every caller depends on.
expect(isSensitivePath('/srv/app/looks-innocent')).toBe(false);
expect(isSensitivePath(`${HOME}/.ssh/looks-innocent`)).toBe(true);
});
});