diff --git a/.context/MAP.md b/.context/MAP.md index bd8d0bf..3ad3460 100644 --- a/.context/MAP.md +++ b/.context/MAP.md @@ -161,9 +161,9 @@ Obsidian integration is file-based only: `cmap obsidian export` writes Markdown - Import graph, route v2, and pack v2 are paused historical ideas; the current roadmap is HTML review first, then AI relation candidates. ## Verification Summary -Run `pnpm test`, `pnpm typecheck`, and `pnpm build` before claiming implementation status. For CLI behavior, prefer integration tests that spawn `tsx src/cli.ts` in temporary project directories. Brief/Obsidian behavior is covered by `tests/integration/m6-brief-obsidian.test.ts`; MapPatch/update-agent behavior is covered by `tests/integration/m7-update-agent.test.ts`; generated evidence, inbox status, and stale checks are covered by `tests/integration/m8-evidence-stale-inbox.test.ts`; route context pack behavior is covered by `tests/integration/m10-route-context-pack.test.ts`; context size controls are covered by `tests/integration/m11-context-size-controls.test.ts`; route benchmark context metrics are covered by `tests/integration/m12-route-benchmark-context.test.ts`; context pack budget/redaction behavior is covered by `tests/integration/m16-context-pack.test.ts`. +Run `pnpm test`, `pnpm typecheck`, and `pnpm build` before claiming implementation status. For CLI behavior, prefer integration tests that spawn `tsx src/cli.ts` in temporary project directories. Brief/Obsidian behavior is covered by `tests/integration/m6-brief-obsidian.test.ts`; MapPatch/update-agent behavior is covered by `tests/integration/m7-update-agent.test.ts`; generated evidence, inbox status, and stale checks are covered by `tests/integration/m8-evidence-stale-inbox.test.ts`; route context pack behavior is covered by `tests/integration/m10-route-context-pack.test.ts`; context size controls are covered by `tests/integration/m11-context-size-controls.test.ts`; route benchmark context metrics are covered by `tests/integration/m12-route-benchmark-context.test.ts`; context pack budget/redaction behavior is covered by `tests/integration/m16-context-pack.test.ts`; inbox evidence path safety is covered by `tests/integration/m24-inbox-path-escape.test.ts`; structured candidate visibility in HTML view is covered by `tests/integration/m25-view-structured-candidates.test.ts`; HTML view secret redaction is covered by `tests/unit/redact.test.ts`. ## Handoff Notes -Current implementation covers v0.1 CLI commands plus explicit `CHECKPOINT.md` handoff, AI brief, budgeted/redacted `pack`, Obsidian view-layer export/check/pull dry-run, changed-file coverage checks, relation checks, route benchmarking with context-pack metrics and CI thresholds, conservative GSD v1/v2 dry-run reconciliation, MapPatch v1/v2 policy gate, generated/canonical evidence separation, generated stats store, freshness v2, controlled low-risk inbox promotion, observe/assist hook evidence collection, assist hook session brief generation, Codex-first lifecycle render/ingest, Claude hook lifecycle render/test compatibility, deterministic graph projections, graph explanation, route context packing from reviewed module relations plus module-owned verification commands, `--max-context` size controls, CI Markdown verify reports, GitHub Actions quality gate, and refreshed product showcase. +Current implementation covers v0.1 CLI commands plus explicit `CHECKPOINT.md` handoff, AI brief, budgeted/redacted `pack`, Obsidian view-layer export/check/pull dry-run, changed-file coverage checks, relation checks, route benchmarking with context-pack metrics and CI thresholds, conservative GSD v1/v2 dry-run reconciliation, MapPatch v1/v2 policy gate, generated/canonical evidence separation, generated stats store, freshness v2, controlled low-risk inbox promotion with project-root evidence path validation, structured candidate visibility in the HTML review view, strengthened HTML secret redaction, observe/assist hook evidence collection, assist hook session brief generation, Codex-first lifecycle render/ingest, Claude hook lifecycle render/test compatibility, deterministic graph projections, graph explanation, route context packing from reviewed module relations plus module-owned verification commands, `--max-context` size controls, CI Markdown verify reports, GitHub Actions quality gate, and refreshed product showcase. Next roadmap is v0.2 Trust Boundary + Human Review Layer: PR-B `cmap view export` read-only HTML dashboard, PR-C trust-boundary hygiene/lifecycle ingest/Codex workflow/generated evidence migration, PR-C2 Freshness v2, and PR-D AI Relation Candidate Workflow. Old import graph/test ownership, route v2, and pack v2 are paused historical ideas, not current work. diff --git a/.context/modules/evidence.md b/.context/modules/evidence.md index 2eec833..a280ab4 100644 --- a/.context/modules/evidence.md +++ b/.context/modules/evidence.md @@ -14,6 +14,7 @@ paths: - src/core/generated-store.ts - src/core/generated-stats.ts - src/core/freshness.ts + - src/fs/safe-path.ts aliases: - evidence - generated evidence @@ -44,6 +45,7 @@ Maintain deterministic support evidence, generated module/route usage stats, and - `src/commands/evidence.ts` - `src/commands/inbox.ts` - `src/core/candidate-store.ts` +- `src/fs/safe-path.ts` ## Responsibilities - Append bounded generated evidence to `.context/generated/evidence/modules/.jsonl`. @@ -61,7 +63,7 @@ Maintain deterministic support evidence, generated module/route usage stats, and - Print unified candidate counts plus legacy Markdown warnings through `cmap inbox status`. - Group unified inbox candidates by risk and type through `cmap inbox triage`. - Preview candidate promotion guidance through `cmap inbox promote --dry-run` without editing canonical context. -- Apply only low-risk alias/path/evidence candidates from legacy or structured candidate stores through `cmap inbox promote --apply` with backup, audit, verify, and archive. +- Apply only low-risk alias/path/evidence candidates from legacy or structured candidate stores through `cmap inbox promote --apply` with project-root evidence path validation, backup, audit, verify, and archive. - Reject false candidates through `cmap inbox reject --reason "..."` while retaining the original candidate in archive. - Move reviewed candidates into `.context/inbox/archive/` through `cmap inbox archive ` without deleting data. - Count simple high-risk inbox markers so semantic backlog remains visible. @@ -135,6 +137,7 @@ User, assist hook, or MapPatch v2 provides explicit evidence -> generated-store - `pnpm test tests/integration/m13-policy-stats.test.ts` - `pnpm test tests/integration/m9-hooks-assist.test.ts` - `pnpm test tests/integration/m18-freshness-inbox-promote.test.ts` +- `pnpm test tests/integration/m24-inbox-path-escape.test.ts` - `pnpm test tests/integration/m21-candidate-store.test.ts` - `pnpm dev evidence append --module route --file src/commands/route.ts --summary "Route inspected"` - `pnpm dev evidence list --module route` diff --git a/.context/modules/tests.md b/.context/modules/tests.md index fbeb22f..63b4afb 100644 --- a/.context/modules/tests.md +++ b/.context/modules/tests.md @@ -41,8 +41,11 @@ Prove public CLI behavior with reproducible integration tests and built-CLI smok - `tests/integration/m16-context-pack.test.ts` - `tests/integration/m17-hooks-ingest-codex.test.ts` - `tests/integration/m18-freshness-inbox-promote.test.ts` +- `tests/integration/m24-inbox-path-escape.test.ts` +- `tests/integration/m25-view-structured-candidates.test.ts` - `tests/integration/cli-errors.test.ts` - `tests/integration/verify-l0.test.ts` +- `tests/unit/redact.test.ts` - `scripts/smoke-test.mjs` ## Responsibilities @@ -63,9 +66,12 @@ Prove public CLI behavior with reproducible integration tests and built-CLI smok - Assert policy defaults, generated module activity stats, and policy-backed inbox thresholds. - Assert route usage stats are written when policy allows stats updates. - Assert freshness snapshot/review warnings, low-risk inbox promote apply backup/audit/archive behavior, and explicit inbox reject archive behavior. +- Assert inbox promotion rejects evidence paths that escape the project root. - Assert graph projections, graph explanation, and graph-mode route output. - Assert CI Markdown verify output and benchmark threshold failure behavior. - Assert context pack budget enforcement, route-neighborhood selection, and secret-looking value redaction. +- Assert HTML view redaction covers auth headers, cloud SDK credential fields, and PEM private key blocks without over-redacting innocent identifiers. +- Assert `view export --include-inbox` surfaces structured `cmap.candidate.v1` files from `.context/inbox/candidates/*.json`. - Run built `dist/cli.js` against a real temp project through `pnpm smoke`. ## Depends On @@ -104,6 +110,9 @@ Temporary directories under the system temp path. - `pnpm test tests/integration/m16-context-pack.test.ts` - `pnpm test tests/integration/m17-hooks-ingest-codex.test.ts` - `pnpm test tests/integration/m18-freshness-inbox-promote.test.ts` +- `pnpm test tests/integration/m24-inbox-path-escape.test.ts` +- `pnpm test tests/integration/m25-view-structured-candidates.test.ts` +- `pnpm test tests/unit/redact.test.ts` - `pnpm smoke` ## When to Update This Doc diff --git a/.context/modules/view.md b/.context/modules/view.md index 3ae709d..62ae363 100644 --- a/.context/modules/view.md +++ b/.context/modules/view.md @@ -5,6 +5,8 @@ paths: - src/view - src/commands/view.ts - tests/integration/m19-view-export.test.ts + - tests/integration/m25-view-structured-candidates.test.ts + - tests/unit/redact.test.ts aliases: - view - dashboard @@ -27,7 +29,8 @@ Render a read-only, single-file HTML review dashboard from trusted `.context` pr - Emits `cmap.view_data.v1` as embedded JSON for deterministic checks and future UI iteration. - Default export shows canonical Overview, Modules, Canonical Relations, Verification, and Warnings. - `--include-generated`, `--include-inbox`, and `--include-freshness` gate generated/support-layer detail sections. -- Treats generated evidence, inbox candidates, freshness metadata, and relation candidates as support signals only. +- Treats generated evidence, legacy inbox Markdown, structured inbox candidates, freshness metadata, and relation candidates as support signals only. +- Reads structured `cmap.candidate.v1` JSON files from `.context/inbox/candidates/*.json` so candidate-store output is visible in the human review dashboard. - Marks relation candidates as Candidate / Non-canonical and never offers browser-side apply/promote. - Missing support layers must degrade to warnings and "Not available", not hard failures. - `--check` compares normalized full HTML, not only embedded JSON, so renderer/template drift is caught while volatile `generatedAt` is ignored. @@ -36,13 +39,15 @@ Render a read-only, single-file HTML review dashboard from trusted `.context` pr ## Safety - Escape all rendered text. -- Redact obvious token/secret/password/API key strings before HTML output. +- Redact obvious token/secret/password/API key strings, common auth headers, cloud SDK key fields, and PEM private key blocks before HTML output. - Do not load CDN assets, execute eval, or read owned source-code bodies for display. - Keep the initial dashboard static and local-only. - Use DOM text APIs and data attributes for interactivity; do not inject unsanitized HTML. ## Verification - `pnpm test tests/integration/m19-view-export.test.ts` +- `pnpm test tests/integration/m25-view-structured-candidates.test.ts` +- `pnpm test tests/unit/redact.test.ts` - `pnpm dev view export --out _cmap-view` - `pnpm dev view export --check --out _cmap-view` - `pnpm typecheck` diff --git a/package.json b/package.json index 1f96242..32e0bb8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ }, "dependencies": { "commander": "14.0.3", - "fast-glob": "3.3.3", "gray-matter": "4.0.3", "zod": "4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a4096e..edc2cf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: commander: specifier: 14.0.3 version: 14.0.3 - fast-glob: - specifier: 3.3.3 - version: 3.3.3 gray-matter: specifier: 4.0.3 version: 4.0.3 @@ -223,18 +220,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@oxc-project/types@0.128.0': resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} @@ -539,10 +524,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -616,13 +597,6 @@ packages: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -632,10 +606,6 @@ packages: picomatch: optional: true - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -647,10 +617,6 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - gray-matter@4.0.3: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} @@ -659,18 +625,6 @@ packages: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -771,14 +725,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -806,10 +752,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -843,9 +785,6 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -857,10 +796,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.0-rc.18: resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -871,9 +806,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -932,10 +864,6 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1190,18 +1118,6 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - '@oxc-project/types@0.128.0': {} '@rolldown/binding-android-arm64@1.0.0-rc.18': @@ -1403,10 +1319,6 @@ snapshots: assertion-error@2.0.1: {} - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -1479,26 +1391,10 @@ snapshots: dependencies: is-extendable: 0.1.1 - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -1512,10 +1408,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - gray-matter@4.0.3: dependencies: js-yaml: 3.14.2 @@ -1525,14 +1417,6 @@ snapshots: is-extendable@0.1.1: {} - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - joycon@3.1.1: {} js-yaml@3.14.2: @@ -1601,13 +1485,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -1633,8 +1510,6 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.2: {} - picomatch@4.0.4: {} pirates@4.0.7: {} @@ -1658,16 +1533,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - queue-microtask@1.2.3: {} - readdirp@4.1.2: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} - reusify@1.1.0: {} - rolldown@1.0.0-rc.18: dependencies: '@oxc-project/types': 0.128.0 @@ -1720,10 +1591,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -1774,10 +1641,6 @@ snapshots: tinyrainbow@3.1.0: {} - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} diff --git a/src/commands/inbox.ts b/src/commands/inbox.ts index 43e3f5b..b7f4ab4 100644 --- a/src/commands/inbox.ts +++ b/src/commands/inbox.ts @@ -8,7 +8,7 @@ import { appendModuleEvidence, appendVerificationEvidence } from "../core/genera import { loadModuleIndex } from "../core/module-index.js"; import { CmapCommandError } from "../errors.js"; import { createBackup, restoreBackup } from "../fs/backup.js"; -import { projectRelative } from "../fs/safe-path.js"; +import { projectRelative, resolveInsideRoot } from "../fs/safe-path.js"; import { verifyContext } from "./verify.js"; type InboxCandidate = { @@ -340,7 +340,10 @@ async function applyInboxCandidate(cwd: string, candidate: InboxCandidate): Prom const evidence = stringArrayField(candidate.data.evidence); for (const item of evidence) { - if (!(await fileExists(path.join(cwd, item)))) { + // Reject path-escape (e.g. "../outside") and symlinks pointing outside repo. + // Aligns with relation-patch.ts / map-patch.ts / pack.ts which already use resolveInsideRoot. + const resolved = await resolveInsideRoot(cwd, item); + if (!(await fileExists(resolved))) { throw new CmapCommandError(`Evidence file does not exist: ${item}`, 2); } } diff --git a/src/fs/safe-path.ts b/src/fs/safe-path.ts index 4a6832b..ddd8cfd 100644 --- a/src/fs/safe-path.ts +++ b/src/fs/safe-path.ts @@ -5,7 +5,9 @@ import { CmapCommandError } from "../errors.js"; export async function resolveInsideRoot(root: string, inputPath: string): Promise { const absolute = path.resolve(root, inputPath); if (!isInside(root, absolute)) { - throw new CmapCommandError(`Path escapes project root: ${inputPath}`); + throw new CmapCommandError( + `Path escapes project root: ${inputPath}. Output paths must stay inside the project — use a relative path (e.g. _cmap-view/ or .context/out/view.html) or an absolute path inside ${root}.` + ); } try { @@ -13,7 +15,9 @@ export async function resolveInsideRoot(root: string, inputPath: string): Promis if (info.isSymbolicLink()) { const resolved = await realpath(absolute); if (!isInside(root, resolved)) { - throw new CmapCommandError(`Symlink escapes project root: ${inputPath}`); + throw new CmapCommandError( + `Symlink escapes project root: ${inputPath} -> ${resolved}. The link target must also stay inside ${root}.` + ); } } } catch (error) { diff --git a/src/view/collect.ts b/src/view/collect.ts index 067e7a5..c2174b4 100644 --- a/src/view/collect.ts +++ b/src/view/collect.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { promisify } from "node:util"; import matter from "gray-matter"; import { fileExists } from "../context/scanner.js"; +import { parseCmapCandidate } from "../core/candidate-store.js"; import { generatedRoot, listModuleEvidence, type ModuleEvidence } from "../core/generated-store.js"; import { readFreshnessIndex, type FreshnessIndex } from "../core/freshness.js"; import { loadModuleIndex, loadProjectInfo, type ContextModule } from "../core/module-index.js"; @@ -176,12 +177,23 @@ async function collectInboxCandidates( }); } } + // v0.2 candidate-store: read structured candidates under .context/inbox/candidates/*.json. + // Without this, the HTML review dashboard cannot see candidates produced by + // `cmap update --agent --write-inbox` / relate ingest — defeating the whole + // "human review layer" purpose of the v0.2 trust boundary. + const structured = await collectStructuredCandidates(cwd, candidates.length); + candidates.push(...structured.candidates); + relationCandidates.push(...structured.relationCandidates); + if (structured.omitted > 0) { + warnings.push(`Structured candidates omitted: ${structured.omitted}`); + } + relationCandidates.push(...await collectRelationCandidateFiles(cwd, warnings)); if (topLevelFiles.length > MAX_CANDIDATES) { warnings.push(`Inbox candidates omitted: ${topLevelFiles.length - MAX_CANDIDATES}`); } - if (files.length === 0) { + if (candidates.length === 0) { warnings.push("Inbox candidates: Not available"); } if (relationCandidates.length === 0) { @@ -191,6 +203,60 @@ async function collectInboxCandidates( return { candidates, relationCandidates }; } +async function collectStructuredCandidates( + cwd: string, + alreadyCollected: number +): Promise<{ candidates: InboxCandidateView[]; relationCandidates: RelationCandidateView[]; omitted: number }> { + const root = path.join(cwd, ".context", "inbox", "candidates"); + if (!(await fileExists(root))) { + return { candidates: [], relationCandidates: [], omitted: 0 }; + } + const entries = (await readdir(root)).filter((entry) => entry.endsWith(".json")).sort(); + const budget = Math.max(0, MAX_CANDIDATES - alreadyCollected); + const accepted = entries.slice(0, budget); + const omitted = entries.length - accepted.length; + const candidates: InboxCandidateView[] = []; + const relationCandidates: RelationCandidateView[] = []; + for (const entry of accepted) { + const absolutePath = path.join(root, entry); + const parsed = parseCmapCandidate(await readFile(absolutePath, "utf8")); + if (!parsed) { + continue; + } + const moduleId = + stringField((parsed.fields as Record).module) || + stringField((parsed.fields as Record).moduleId) || + parsed.target || + "Not available"; + const base: InboxCandidateView = { + id: parsed.id, + file: projectRelative(cwd, absolutePath), + type: parsed.type, + risk: parsed.risk, + moduleId, + summary: parsed.summary, + suggestedCommands: [ + { label: "Dry run", command: `cmap inbox promote ${parsed.id} --dry-run` } + ] + }; + candidates.push(base); + if (parsed.type.includes("relation")) { + relationCandidates.push({ + id: parsed.id, + file: base.file, + from: stringField((parsed.fields as Record).from) || moduleId, + to: stringField((parsed.fields as Record).to) || "Not available", + relation: stringField((parsed.fields as Record).relation) || parsed.type, + summary: parsed.summary, + suggestedCommands: [ + { label: "Dry run", command: `cmap relate promote ${parsed.id} --dry-run` } + ] + }); + } + } + return { candidates, relationCandidates, omitted }; +} + async function collectRelationCandidateFiles(cwd: string, warnings: string[]): Promise { const root = path.join(cwd, ".context", "inbox", "relations"); if (!(await fileExists(root))) { diff --git a/src/view/render.ts b/src/view/render.ts index 3e88fd7..57726ee 100644 --- a/src/view/render.ts +++ b/src/view/render.ts @@ -205,8 +205,15 @@ export function redactViewData(data: CmapViewData): CmapViewData { function redact(value: string): string { return value - .replace(/\b(api[_-]?key|token|secret|password)(\s*[:=]\s*)(["']?)[^\s"'`<>&]+/gi, "$1$2[REDACTED]") - .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/g, "Bearer [REDACTED]"); + // Common credential field names (key=val or key: val); covers cloud SDK env-var idioms. + .replace( + /\b(api[_-]?key|token|secret|password|authorization|client[_-]?secret|access[_-]?key|access[_-]?token|refresh[_-]?token|private[_-]?key|x[_-]api[_-]key)(\s*[:=]\s*)(["']?)[^\s"'`<>&]+/gi, + "$1$2[REDACTED]" + ) + // HTTP Bearer header + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/g, "Bearer [REDACTED]") + // PEM private key blocks (covers RSA, OPENSSH, EC, DSA, ENCRYPTED variants) + .replace(/-----BEGIN[^-\n]+PRIVATE KEY-----[\s\S]*?-----END[^-\n]+PRIVATE KEY-----/g, "[REDACTED PRIVATE KEY]"); } function escapeHtml(value: string): string { diff --git a/tests/integration/m24-inbox-path-escape.test.ts b/tests/integration/m24-inbox-path-escape.test.ts new file mode 100644 index 0000000..1814429 --- /dev/null +++ b/tests/integration/m24-inbox-path-escape.test.ts @@ -0,0 +1,98 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { createTempProject, runCmap } from "../helpers.js"; + +async function createMinProject(name: string): Promise { + const cwd = await createTempProject(name); + await runCmap(["init", "--auto"], cwd); + await mkdir(path.join(cwd, "src/commands"), { recursive: true }); + await writeFile(path.join(cwd, "src/commands/route.ts"), "export const route = true;\n", "utf8"); + await mkdir(path.join(cwd, ".context/modules"), { recursive: true }); + await writeFile( + path.join(cwd, ".context/modules/route.md"), + `--- +context_type: module +module: route +paths: + - src/commands/route.ts +aliases: + - route +confidence: ai-drafted +--- +# Module: route + +## Purpose +Recommend module docs for a task. +`, + "utf8" + ); + return cwd; +} + +async function writeStructuredCandidate( + cwd: string, + id: string, + evidence: string[] +): Promise { + const dir = path.join(cwd, ".context", "inbox", "candidates"); + await mkdir(dir, { recursive: true }); + const candidate = { + schema: "cmap.candidate.v1", + id, + fingerprint: id, + createdAt: "2026-05-14T00:00:00Z", + source: "manual", + type: "evidence.merge", + target: ".context/modules/route.md", + risk: "routine", + confidence: 0.9, + summary: "Test candidate for path-escape rejection.", + evidence, + fields: { module: "route", summary: "Test merge." }, + canonical: false + }; + await writeFile( + path.join(dir, `${id}.json`), + JSON.stringify(candidate, null, 2), + "utf8" + ); + await writeFile( + path.join(dir, `${id}.md`), + `---\ncandidate_schema: cmap.candidate.v1\ncandidate_id: ${id}\nrisk: routine\nconfidence: 0.9\nsummary: Test candidate.\n---\n# Test\n`, + "utf8" + ); +} + +describe("M24 inbox promote evidence path-escape", () => { + test("rejects evidence with ../ path-escape", async () => { + const cwd = await createMinProject("m24-escape-relative"); + await writeStructuredCandidate(cwd, "escape-relative", ["../outside-file.txt"]); + + const result = await runCmap(["inbox", "promote", "escape-relative", "--apply"], cwd); + + expect(result.code).not.toBe(0); + expect(result.stderr + result.stdout).toContain("Path escapes project root"); + }); + + test("rejects evidence with absolute path outside the project", async () => { + const cwd = await createMinProject("m24-escape-absolute"); + await writeStructuredCandidate(cwd, "escape-absolute", ["/etc/passwd"]); + + const result = await runCmap(["inbox", "promote", "escape-absolute", "--apply"], cwd); + + expect(result.code).not.toBe(0); + expect(result.stderr + result.stdout).toContain("Path escapes project root"); + }); + + test("accepts evidence inside the project (sanity check)", async () => { + const cwd = await createMinProject("m24-inside"); + await writeStructuredCandidate(cwd, "inside-ok", ["src/commands/route.ts"]); + + const result = await runCmap(["inbox", "promote", "inside-ok", "--apply"], cwd); + + // Inside-path evidence should not trigger path-escape rejection; whatever + // downstream behavior the candidate causes is not the concern of this test. + expect(result.stderr + result.stdout).not.toContain("Path escapes project root"); + }); +}); diff --git a/tests/integration/m25-view-structured-candidates.test.ts b/tests/integration/m25-view-structured-candidates.test.ts new file mode 100644 index 0000000..1dba051 --- /dev/null +++ b/tests/integration/m25-view-structured-candidates.test.ts @@ -0,0 +1,95 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { createTempProject, runCmap } from "../helpers.js"; + +async function setupProject(name: string): Promise { + const cwd = await createTempProject(name); + await runCmap(["init", "--auto"], cwd); + await mkdir(path.join(cwd, "src/commands"), { recursive: true }); + await writeFile(path.join(cwd, "src/commands/route.ts"), "export const route = true;\n", "utf8"); + await mkdir(path.join(cwd, ".context/modules"), { recursive: true }); + await writeFile( + path.join(cwd, ".context/modules/route.md"), + `--- +context_type: module +module: route +paths: + - src/commands/route.ts +aliases: + - route +confidence: ai-drafted +--- +# Module: route + +## Purpose +Route module docs. +`, + "utf8" + ); + return cwd; +} + +async function writeStructuredCandidate( + cwd: string, + id: string, + type: string +): Promise { + const dir = path.join(cwd, ".context", "inbox", "candidates"); + await mkdir(dir, { recursive: true }); + const candidate = { + schema: "cmap.candidate.v1", + id, + fingerprint: id, + createdAt: "2026-05-14T00:00:00Z", + source: "manual", + type, + target: ".context/modules/route.md", + risk: "medium", + confidence: 0.85, + summary: `Structured candidate ${id} summary for view dashboard test.`, + evidence: ["src/commands/route.ts"], + fields: { module: "route", alias: "route-map" }, + canonical: false + }; + await writeFile(path.join(dir, `${id}.json`), JSON.stringify(candidate, null, 2), "utf8"); + await writeFile( + path.join(dir, `${id}.md`), + `---\ncandidate_schema: cmap.candidate.v1\ncandidate_id: ${id}\nrisk: medium\nsummary: Stub.\n---\n# Stub\n`, + "utf8" + ); +} + +describe("M25 view dashboard reads structured candidates", () => { + test("view export --include-inbox surfaces .context/inbox/candidates/*.json", async () => { + const cwd = await setupProject("m25-structured"); + await writeStructuredCandidate(cwd, "cand-A", "module.alias.add"); + + const result = await runCmap( + ["view", "export", "--include-inbox", "--out", "_cmap-view"], + cwd + ); + expect(result.code).toBe(0); + + const html = await readFile(path.join(cwd, "_cmap-view/index.html"), "utf8"); + // Structured candidate id and summary should appear in the rendered dashboard. + expect(html).toContain("cand-A"); + expect(html).toContain("Structured candidate cand-A summary"); + }); + + test("dashboard shows summary + suggested dry-run command for structured candidates", async () => { + const cwd = await setupProject("m25-suggested-cmd"); + await writeStructuredCandidate(cwd, "cand-B", "evidence.merge"); + + const result = await runCmap( + ["view", "export", "--include-inbox", "--out", "_cmap-view"], + cwd + ); + expect(result.code).toBe(0); + + const html = await readFile(path.join(cwd, "_cmap-view/index.html"), "utf8"); + expect(html).toContain("cand-B"); + // Reviewer should be able to copy the dry-run command directly from the dashboard. + expect(html).toContain("cmap inbox promote cand-B --dry-run"); + }); +}); diff --git a/tests/unit/redact.test.ts b/tests/unit/redact.test.ts new file mode 100644 index 0000000..7055780 --- /dev/null +++ b/tests/unit/redact.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; +import { redactViewData } from "../../src/view/render.js"; +import type { CmapViewData } from "../../src/view/schema.js"; + +function withSummary(summary: string): CmapViewData { + return { + schema: "cmap.view_data.v1", + generatedAt: "2026-05-14T00:00:00Z", + projectRootName: "test", + included: { generated: false, inbox: false, freshness: false }, + project: { id: "test", name: "test" }, + overview: { currentTask: summary, lastVerified: "" }, + verify: { requiredCommands: [], manualChecks: [] }, + summary: { moduleCount: 0, evidenceCount: 0, candidateCount: 0, warningCount: 0 }, + modules: [], + evidence: [], + candidates: [], + relationCandidates: [], + warnings: [] + }; +} + +function extractSummary(redacted: CmapViewData): string { + return redacted.overview.currentTask ?? ""; +} + +describe("view redaction", () => { + test("redacts api_key/token/secret/password (baseline)", () => { + const result = extractSummary(redactViewData(withSummary("api_key=AKIA1234567890ABCDEF and token: secret-token-value"))); + expect(result).toContain("api_key=[REDACTED]"); + expect(result).toContain("token: [REDACTED]"); + }); + + test("redacts Authorization header values", () => { + const result = extractSummary(redactViewData(withSummary("Authorization: my-app-token-value-here"))); + expect(result).toContain("Authorization: [REDACTED]"); + expect(result).not.toContain("my-app-token-value-here"); + }); + + test("redacts x-api-key header values", () => { + const result = extractSummary(redactViewData(withSummary("x-api-key: hunter2-token-abc"))); + expect(result).toContain("x-api-key: [REDACTED]"); + expect(result).not.toContain("hunter2-token-abc"); + }); + + test("redacts client_secret / access_key / refresh_token / private_key cloud SDK idioms", () => { + const result = extractSummary(redactViewData(withSummary("client_secret=CS-VALUE access_key=AK-VALUE refresh_token=RT-VALUE private_key=PK-VALUE"))); + expect(result).toContain("client_secret=[REDACTED]"); + expect(result).toContain("access_key=[REDACTED]"); + expect(result).toContain("refresh_token=[REDACTED]"); + expect(result).toContain("private_key=[REDACTED]"); + expect(result).not.toContain("CS-VALUE"); + expect(result).not.toContain("AK-VALUE"); + expect(result).not.toContain("RT-VALUE"); + expect(result).not.toContain("PK-VALUE"); + }); + + test("redacts PEM private key blocks (RSA / OPENSSH / EC)", () => { + const block = "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Q\n-----END RSA PRIVATE KEY-----"; + const result = extractSummary(redactViewData(withSummary(block))); + expect(result).toContain("[REDACTED PRIVATE KEY]"); + expect(result).not.toContain("MIIBOgIBAAJBAKj"); + }); + + test("retains Bearer redaction (baseline regression)", () => { + const result = extractSummary(redactViewData(withSummary("Authorization header uses Bearer aaaaaaaaaaaaaaaa1234567890"))); + expect(result).toContain("Bearer [REDACTED]"); + }); + + test("does not over-redact innocent identifiers", () => { + const result = extractSummary(redactViewData(withSummary("This module handles user-tokenization in src/lib/tokens.ts"))); + // 'tokenization' or 'tokens.ts' should not be redacted because they aren't field=value patterns + expect(result).toContain("tokenization"); + expect(result).toContain("tokens.ts"); + }); +});