diff --git a/.agents/pm/history/pm-github-iswq.jsonl b/.agents/pm/history/pm-github-iswq.jsonl new file mode 100644 index 00000000..0e37edd9 --- /dev/null +++ b/.agents/pm/history/pm-github-iswq.jsonl @@ -0,0 +1,13 @@ +{"ts":"2026-08-20T21:28:31.109Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-iswq"},{"op":"add","path":"/metadata/title","value":"Use canonical unbounded pm list reads with the 2026.8.20 host contract"},{"op":"add","path":"/metadata/description","value":"pm-github already fail-closes complete-corpus receipts, but its production argv and user-facing diagnostics still use the deprecated list-all alias and its declared pm CLI/SDK floors predate 2026.8.20."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"in_progress"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["agents","cli-ux","complete-corpus","latest-cli","tdd"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-20T21:28:31.109Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-20T21:28:31.109Z"},{"op":"add","path":"/metadata/author","value":"codex"},{"op":"add","path":"/metadata/estimated_minutes","value":180},{"op":"add","path":"/metadata/acceptance_criteria","value":"Create failing exact-argv and alias-free real installed-CLI tests first; replace only the deprecated invocation and terminology; retain strict full unbounded fail-closed receipt validation; use top-level TypeScript imports and no any/dynamic imports; pin dev CLI/SDK and set peer/manifest floors to 2026.8.20; run release:check, exact measured-source coverage, pm health, reconcile, validate, npm/bun packed acceptance, dependency/vulnerability/privacy checks; document exact evidence and link the package PR plus companion item pm-cli-website-cbyb; do not merge or publish while pm-github-zqad or pm-github-v2kt blocks release"},{"op":"add","path":"/metadata/risk","value":"medium"},{"op":"add","path":"/metadata/confidence","value":95},{"op":"add","path":"/metadata/reporter","value":"codex"},{"op":"add","path":"/metadata/severity","value":"medium"},{"op":"add","path":"/metadata/repro_steps","value":"Run the production completePmListArgs vector with pm CLI 2026.8.20 and inspect stderr; scan index.ts, README.md, and complete-corpus tests for list-all."},{"op":"add","path":"/metadata/expected_result","value":"Production invokes explicit unbounded canonical pm list --all --json with full/body/strict receipts; no alias deprecation reaches stderr; source, tests, docs, generated output, package dependency, peer floor, and manifest floor agree on pm CLI/SDK 2026.8.20."},{"op":"add","path":"/metadata/actual_result","value":"Baseline: complete corpus verification is strict and release-tested, but completePmListArgs emits list-all; README and errors name the alias; dev dependency is 2026.8.17, peer/manifest floors are 2026.8.3, and installed node_modules is stale at 2026.8.15."},{"op":"add","path":"/metadata/affected_version","value":"pm-github 2026.8.18 with pm CLI 2026.8.20"},{"op":"add","path":"/metadata/component","value":"whole-tracker pm CLI integration"},{"op":"add","path":"/metadata/customer_impact","value":"Deprecated diagnostics can pollute machine stderr and stale compatibility floors allow hosts that do not provide the canonical contract this package now requires."},{"op":"add","path":"/metadata/dependencies","value":[{"id":"pm-github-ep0u","kind":"related","created_at":"2026-08-20T21:28:31.109Z","author":"codex","source_kind":"cli:create:dep","author_source":"detected"}]},{"op":"add","path":"/metadata/tests","value":[{"command":"npm run release:check","path":"package.json","scope":"project","timeout_seconds":1200,"note":"Full package release gate"}]},{"op":"add","path":"/metadata/docs","value":[{"path":"https://github.com/unbraind/pm-cli-companion/blob/docs/record-pm-rl-pm-csv-cli-2026-8-20/.agents/pm/issues/pm-cli-website-cbyb.toon","scope":"global","note":"Companion fleet canonical-command migration owner"}]}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"b69bac6f29498bcf4467ab9ecec3ee6029e1d09b23e61fdf2a474a63e3531e53","item_hash_version":2,"message":"Start canonical pm list migration with TDD and release boundaries"} +{"ts":"2026-08-20T21:32:59.796Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"docs_add","patch":[{"op":"add","path":"/metadata/docs/1","value":{"path":"https://github.com/unbraind/pm-cli/issues/1070","scope":"global","note":"Upstream receipt bug found during installed-CLI acceptance"}},{"op":"add","path":"/metadata/docs/2","value":{"path":"README.md","scope":"project","note":"Canonical complete-corpus contract and copyable invocation"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:32:59.796Z"}],"before_hash":"b69bac6f29498bcf4467ab9ecec3ee6029e1d09b23e61fdf2a474a63e3531e53","after_hash":"24b7b46fc335fd8a520c95b8fab2edfc0e78df70df582997fe7231615ff1a4ee","item_hash_version":2,"message":"Link package guidance and upstream UX finding"} +{"ts":"2026-08-20T21:33:00.194Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:33:00.194Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-20T21:33:00.194Z","author":"codex","text":"2026-08-20 TDD: before the source edit, node --test test/complete-corpus.test.ts failed 2 of 6 tests because completePmListArgs still emitted --path/list-all and the real CLI wrote the deprecation diagnostic to stderr. After canonical argv, the stale local 2026.8.15 runtime rejected list --all as ambiguous while the host 2026.8.20 runtime passed; npm install updated the exact local @unbrained/pm-cli pin to 2026.8.20 and all 6 tests passed. Full npm test then passed 261 tests. Canonical --output-include full is currently misreported as legacy --full in the receipt, tracked upstream as pm-cli #1070; the acceptance rejects only actual list-all use until that CLI bug is fixed."}]}],"before_hash":"24b7b46fc335fd8a520c95b8fab2edfc0e78df70df582997fe7231615ff1a4ee","after_hash":"82473e014492aa25490314c7445b86c94a83e9ea78a6ec70d07479e8cba4eff0","item_hash_version":2,"message":"Record red-green evidence and discovered host receipt issue"} +{"ts":"2026-08-20T21:33:07.429Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npm test","path":"test/complete-corpus.test.ts","scope":"project","timeout_seconds":900,"note":"261-test suite includes red-green canonical argv and real installed-CLI receipt acceptance"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:33:07.429Z"}],"before_hash":"82473e014492aa25490314c7445b86c94a83e9ea78a6ec70d07479e8cba4eff0","after_hash":"44284ca94e49330c50ef29ae664dddb03aec6d52a48d847ad1f7b2af46a983da","item_hash_version":2,"message":"Link package-script acceptance evidence"} +{"ts":"2026-08-20T21:40:40.486Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:40:40.486Z"},{"op":"add","path":"/metadata/files","value":[{"path":"index.ts","scope":"project","note":"Canonical complete-corpus argv and diagnostics"},{"path":"manifest.json","scope":"project","note":"Runtime-enforced host floor"},{"path":"package.json","scope":"project","note":"Peer and exact development CLI SDK floors"},{"path":"test/compatibility-floor.test.ts","scope":"project","note":"Exact 2026.8.20 host floor and manifest contract"},{"path":"test/complete-corpus.test.ts","scope":"project","note":"Exact argv and installed CLI completeness acceptance"}]}],"before_hash":"44284ca94e49330c50ef29ae664dddb03aec6d52a48d847ad1f7b2af46a983da","after_hash":"6589cb86aa585a09ab858fdd89e28fd5040cfcc3d7e1e09d784b160aa94cb7ec","item_hash_version":2,"message":"Link implementation and compatibility artifacts"} +{"ts":"2026-08-20T21:40:40.880Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/actual_result","value":"Implemented canonical host-owned --pm-path plus --output-include full, unbounded amount/cost, then list --all --json --include-body --strict-read; all production errors/docs and direct test fixtures use canonical terminology. Pinned local CLI/SDK 2026.8.20, set peer and manifest floors to 2026.8.20, and removed the ignored manifest pm.compatibility envelope. TDD failed 2/6 before source edits, then passed 6/6. Full suite passed 261 tests. Current measured-source coverage passed at 92.40% lines, 82.58% branches, 91.75% functions across index.ts/projects.ts; this is not 100/100/100/100 and remains independently open as pm-github-9cjx. Docstring gate documented 92 declarations; production audit found 0 vulnerabilities; pack dry-run contained 13 intended files. Clean packed npm and Bun clients each read two open/closed items with items.length=count=total, no truncation/pagination/list-all receipt, and empty child stderr. A packed npm extension authenticated through gh, validated public repo access HTTP 200, and a retry dry-ran a real GitHub import against unbraind/pm-github with no writes and a complete empty tracker; one earlier pm-changelog import attempt timed out at 30 seconds and was not counted as a pass. pm CLI receipt issue #1070 records canonical --output-include full being misreported as legacy --full. Delivery is complete but merge/publication remain blocked by pm-github-zqad and pm-github-v2kt."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:40:40.880Z"},{"op":"add","path":"/metadata/resolution","value":"Migrated the existing strict whole-corpus reader to canonical pm CLI 2026.8.20 argv without weakening its completeness decoder, added exact and real packed acceptance, aligned compatibility declarations, updated terminology, and preserved separate privacy, release-order, and exact-coverage blockers."},{"op":"add","path":"/metadata/fixed_version","value":"unreleased branch"}],"before_hash":"6589cb86aa585a09ab858fdd89e28fd5040cfcc3d7e1e09d784b160aa94cb7ec","after_hash":"f4505bc1984f402ac565b4be1f1d0e87482c4fff6a54debe667a7027cb9e99b8","item_hash_version":2,"message":"Record verified canonical reader delivery and independent blockers"} +{"ts":"2026-08-20T21:40:41.301Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:40:41.301Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-20T21:40:41.284Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-20T21:40:41.284Z"},{"op":"add","path":"/metadata/close_reason","value":"Canonical whole-corpus reader delivery is implemented and verified; package merge/publication remain separately blocked by pm-github-zqad and pm-github-v2kt."}],"before_hash":"f4505bc1984f402ac565b4be1f1d0e87482c4fff6a54debe667a7027cb9e99b8","after_hash":"57d17498621ee09f46e50c75fdd391e7b40d2d142ba2ea5161c76dff89778adc","item_hash_version":2} +{"ts":"2026-08-20T21:44:41.676Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/docs/2/note","value":"Upstream receipt bug found during installed-CLI acceptance"},{"op":"replace","path":"/metadata/docs/2/scope","value":"global"},{"op":"replace","path":"/metadata/docs/2/path","value":"https://github.com/unbraind/pm-cli/issues/1070"},{"op":"replace","path":"/metadata/docs/1/note","value":"Companion fleet canonical-command migration owner"},{"op":"replace","path":"/metadata/docs/1/path","value":"https://github.com/unbraind/pm-cli-companion/blob/docs/record-pm-rl-pm-csv-cli-2026-8-20/.agents/pm/issues/pm-cli-website-cbyb.toon"},{"op":"replace","path":"/metadata/docs/0/note","value":"Canonical unbounded changelog reader awaiting privacy-gated merge and publication"},{"op":"replace","path":"/metadata/docs/0/path","value":"https://github.com/unbraind/pm-changelog/pull/148"},{"op":"add","path":"/metadata/docs/3","value":{"path":"README.md","scope":"project","note":"Canonical complete-corpus contract and copyable invocation"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:44:41.676Z"}],"before_hash":"57d17498621ee09f46e50c75fdd391e7b40d2d142ba2ea5161c76dff89778adc","after_hash":"a800797a201a1bf7744a8b2cd16984c8248f0df0a516fc0332e6b22e11856736","item_hash_version":2,"message":"Link upstream changelog reader migration"} +{"ts":"2026-08-20T21:44:42.154Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-20T21:44:42.154Z","author":"codex","text":"2026-08-20 final gates before review: npm run release:check passed 261 tests and the current 92.40/82.58/91.75 three-metric measured-source gate, 92 documented declarations, zero production audit vulnerabilities, clean 13-file pack, and current changelog. pm health --strict-exit passed with three provenance advisories; pm merge reconcile --dry-run found 82 clean streams and no pending decisions; pm validate remained structurally valid with pre-existing legacy metadata/resolution/file warnings; duplicates reported one pre-existing closed exact-title cluster. GitHub Dependabot and secret-scanning open alerts were empty; code-scanning has no analysis, and Trivy is not installed, so neither is represented as green. Public pm-changelog 2026.8.17 first refused a bounded 5-of-82 read; explicit unbounded pm-arg controls restored all 289 changelog lines. The tool still invokes its deprecated internal alias until privacy-gated pm-changelog PR #148 can merge and release. Historical privacy/identity findings remain open under pm-github-zqad and publication order remains open under pm-github-v2kt."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:44:42.154Z"}],"before_hash":"a800797a201a1bf7744a8b2cd16984c8248f0df0a516fc0332e6b22e11856736","after_hash":"2e8d0df78609d640ebe4ee1ca6a342241b21412f2aab9ca65a7e90e428715ace","item_hash_version":2,"message":"Record final gate results and explicit limitations"} +{"ts":"2026-08-20T21:45:25.861Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"remove","path":"/metadata/fixed_version"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:45:25.861Z"}],"before_hash":"2e8d0df78609d640ebe4ee1ca6a342241b21412f2aab9ca65a7e90e428715ace","after_hash":"4af7d9270849ef2b89de1f0e5d7d3a4cdd06ed48d5ec61fd9ea747deddcdbe0d","item_hash_version":2,"message":"Clear non-version delivery placeholder; changelog remains Unreleased until an approved release assigns a version"} +{"ts":"2026-08-20T21:55:35.957Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:55:35.957Z"},{"op":"replace","path":"/metadata/title","value":"BREAKING: pm-github now requires pm CLI 2026.8.20 or newer; older hosts may fail installation or runtime validation"}],"before_hash":"4af7d9270849ef2b89de1f0e5d7d3a4cdd06ed48d5ec61fd9ea747deddcdbe0d","after_hash":"343763474c1f571f9a1aad101b7720e146ba32d89e2178f77dd5e9cf415b2f0e","item_hash_version":2,"message":"Make the generated changelog disclose the compatibility break"} +{"ts":"2026-08-20T21:55:36.351Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-08-20T21:55:36.351Z","author":"codex","text":"CodeRabbit exact-head review on 2026-08-20 produced four findings. Accepted: disclose the 2026.8.20 compatibility break through the package-owned changelog item; remove unsafe labels/since narrowing advice from complete-corpus ENOBUFS guidance; and pass the real installed-CLI envelope through decodeCompletePmItems before asserting decoded rows. The pm-changelog dependency upgrade request cannot yet be performed: npm latest is 2026.8.17 and no release contains privacy-blocked PR #148, so the current lock remains 2026.8.17 and merge/publication stay blocked. TDD red phase: targeted compatibility and complete-corpus tests failed exactly on the missing breaking note and unsafe buffer advice before implementation."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:55:36.351Z"}],"before_hash":"343763474c1f571f9a1aad101b7720e146ba32d89e2178f77dd5e9cf415b2f0e","after_hash":"17af5d764b30c9c21c679fcb1cbe15ab8795b05550bacc6cc73478764b3a66df","item_hash_version":2,"message":"Record exact-head review findings and red-phase evidence"} +{"ts":"2026-08-20T21:57:30.348Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"b44c9b090cf8bc81eaf457c4","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-08-20T21:57:30.348Z","author":"codex","text":"Review-fix green phase: targeted compatibility and complete-corpus tests pass 10/10. The full release gate passes all 261 tests, 92 documented declarations, zero production audit vulnerabilities, a clean 13-file pack, and a current generated changelog. The two-source coverage report is now 92.09% lines, 82.47% branches, and 91.75% functions after the diagnostic hardening; it remains non-exact and pm-github-9cjx stays open. The real acceptance now decodes the installed CLI envelope through the production decoder and forces a one-byte buffer to prove the complete-reader error offers no narrowing workaround."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-20T21:57:30.348Z"}],"before_hash":"17af5d764b30c9c21c679fcb1cbe15ab8795b05550bacc6cc73478764b3a66df","after_hash":"1982d6d456eb57397f27f7db48391d9427feb5b1092fafe06ff909eb5750899c","item_hash_version":2,"message":"Record accepted review fixes and green-phase evidence"} diff --git a/.agents/pm/history/pm-github-v2kt.jsonl b/.agents/pm/history/pm-github-v2kt.jsonl index 8bb7c301..8a132d9e 100644 --- a/.agents/pm/history/pm-github-v2kt.jsonl +++ b/.agents/pm/history/pm-github-v2kt.jsonl @@ -7,3 +7,5 @@ {"ts":"2026-08-10T16:23:19.099Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/5","value":{"created_at":"2026-08-10T16:23:19.099Z","author":"claude","text":"Round five review fixes. CodeRabbit found that the tracking note claimed dist validation that pm-web's workflow did not actually contain. An audit showed seven repositories track dist but had no rebuild check, because the earlier patch keyed on a diff paths idiom those repositories do not use: pm-changelog, pm-context, pm-gantt-chart, pm-graph, pm-jira, pm-ops and pm-web. All seven now rebuild dist from clean and compare with git status including untracked and ignored files, and reproducibility was confirmed locally in each before enabling the check. Greptile found that fetching only the first hundred thread comments lets a human reply past that page escape the all bot predicate, so the filter now refuses to resolve any thread whose comment total exceeds the comments actually returned. Verified against a truncated thread with a total of one hundred fifty and two fetched nodes, which is correctly not resolved."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:23:19.099Z"}],"before_hash":"3c3705b49ccf9e87c5c38880c9f76b3a952bc615f9f98c5b67b43a33fad89a3b","after_hash":"9efe94e308a4cc092b2ee8bc790c2f8a59f3bb9925bfe2e5c5e54332972e336d"} {"ts":"2026-08-10T16:41:09.417Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/6","value":{"created_at":"2026-08-10T16:41:09.417Z","author":"claude","text":"CodeRabbit reported that git ls-remote --tags returns both the tag object and its peeled commit for an annotated tag, so cut -f1 would yield two SHAs and git rev-list would fail. Tested against a real annotated tag pushed to a local bare remote. The peeled entry appears when ls-remote is called with no pattern or with a glob, but NOT with the exact refspec form this workflow uses, which returned a single line. The finding therefore does not reproduce against our call. The suggested form was adopted anyway because it costs nothing, states the intent explicitly, and keeps the code correct if the pattern is ever loosened to a glob."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:41:09.417Z"}],"before_hash":"9efe94e308a4cc092b2ee8bc790c2f8a59f3bb9925bfe2e5c5e54332972e336d","after_hash":"3bfec546b57470fc9666cf1d55906411f8350b7988f4d966b0f8451e2e054341"} {"ts":"2026-08-10T16:57:16.322Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/7","value":{"created_at":"2026-08-10T16:57:16.322Z","author":"claude","text":"Round six follow up. CodeRabbit noted the parked run detection reads only the first page of the workflow runs endpoint, whose default page size is thirty, so a parked run past that page would go undetected. The query now requests one hundred per page. This is a diagnostic path rather than a safety one, since an undetected parked run simply means the release is not approved and times out without publishing, but the larger page costs nothing. Thread level pagination was declined separately because unfetched threads are never resolved and therefore keep blocking, which is the safe direction."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:57:16.322Z"}],"before_hash":"3bfec546b57470fc9666cf1d55906411f8350b7988f4d966b0f8451e2e054341","after_hash":"ccb2819974d107822f82a38d930bb36e486d490bb0ed619c32057eb6e86bf0bb"} +{"ts":"2026-08-21T21:31:12.813Z","author":"ox-alpha","author_source":"asserted","agent_harness":"pi","agent_model":"stealth/ox-alpha","agent_model_source":"environment","agent_instance":"154a23dd8a71b7193a1b854c","agent_provenance":{"model":{"value":"stealth/ox-alpha","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-21T21:31:12.813Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-21T21:31:12.798Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-21T21:31:12.798Z"},{"op":"add","path":"/metadata/resolution","value":"Release publish ordering fixed and merged to main via PR #46: release metadata merges through a protected PR before npm publish; only the release tag pushes after a verified publish; idempotence guard reconciles already-published versions."},{"op":"add","path":"/metadata/close_reason","value":"Fix verified live on origin/main (workflow lines: pull-requests:write, base_sha capture at decide, protected release-PR merge with parked-run approval handling, idempotent npm publish with 3-attempt provenance fallback, tag-only push after successful publish; never pushes HEAD:main again). Merged via PR #46; six bot review rounds resolved in notes. npm-ahead-of-git state eliminated."}],"before_hash":"ccb2819974d107822f82a38d930bb36e486d490bb0ed619c32057eb6e86bf0bb","after_hash":"d5576ac881a04d4027c16c7b446b527259b1fa49cfef0eb3258175a3baeae699","item_hash_version":2} +{"ts":"2026-08-21T21:31:24.293Z","author":"ox-alpha","author_source":"asserted","agent_harness":"pi","agent_model":"stealth/ox-alpha","agent_model_source":"environment","agent_instance":"154a23dd8a71b7193a1b854c","agent_provenance":{"model":{"value":"stealth/ox-alpha","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-21T21:31:24.293Z"},{"op":"add","path":"/metadata/expected_result","value":"npm publish and git main/tag state always reconcile: release metadata lands on protected main through a PR before publish; only the tag pushes after a verified publish; re-runs are idempotent"},{"op":"add","path":"/metadata/actual_result","value":"Verified on origin/main: protected-PR merge flow with parked-run approval detection, idempotent publish guard with 3-attempt provenance fallback, tag-only post-publish push; merged in PR #46"}],"before_hash":"d5576ac881a04d4027c16c7b446b527259b1fa49cfef0eb3258175a3baeae699","after_hash":"faf450b3ab9e71f60ba081f77d98c61915cec1174a8984576befea79c5ed9cea","item_hash_version":2} diff --git a/.agents/pm/history/pm-github-zqad.jsonl b/.agents/pm/history/pm-github-zqad.jsonl index a88086d3..4caef4b0 100644 --- a/.agents/pm/history/pm-github-zqad.jsonl +++ b/.agents/pm/history/pm-github-zqad.jsonl @@ -1 +1,2 @@ {"ts":"2026-08-17T07:40:36.004Z","author":"codex","author_source":"configured","agent_harness":"codex","agent_instance":"839e601757a3957be5ebc227","agent_provenance":{"model":null,"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-zqad"},{"op":"add","path":"/metadata/title","value":"Gate historical identities and host-path privacy across every Git object"},{"op":"add","path":"/metadata/description","value":"A full-object privacy read found zero high-confidence credential signatures and zero current tracked absolute host paths, but historical objects still contain 18 host-path lines and the commit graph includes one machine-local identity already tracked by the companion fleet issue. The repository is public and those commits are ancestors of releases, so destructive history rewriting requires an explicit maintainer decision; a forward gate is independently required."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["git-history","identity-gate","privacy","release","security"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-17T07:40:36.004Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-17T07:40:36.004Z"},{"op":"add","path":"/metadata/author","value":"codex"},{"op":"add","path":"/metadata/estimated_minutes","value":480},{"op":"add","path":"/metadata/acceptance_criteria","value":"Add a checked-in justified identity allowlist and fail-closed audit covering reachable and unreachable commits and annotated tags; add a current-tree and all-object secret/private-path gate with false-positive fixtures; classify every existing historical match without printing sensitive values into logs; obtain an explicit human decision before rewriting published history; if approved, preserve release metadata and verify old sensitive objects are no longer remotely reachable; if declined, record the residual risk and enforce the forward gate; include the audits in release:check and CI with behavioral negative controls."},{"op":"add","path":"/metadata/severity","value":"high"},{"op":"add","path":"/metadata/repro_steps","value":"Run a high-confidence credential scan and local-host-path scan over git cat-file --batch-all-objects --batch, then enumerate unique author and committer identities across all reachable refs. Compare the result with current tracked files."},{"op":"add","path":"/metadata/expected_result","value":"Every reachable and unreachable commit/tag/blob is scanned; only explicitly justified identities are allowed; new credential or local-host-path material fails CI and release:check; every historical match is classified; any rewrite of published commits/tags occurs only after explicit maintainer approval and restoration planning."},{"op":"add","path":"/metadata/actual_result","value":"Current tracked files contain zero high-confidence secret or absolute-host-path matches. Full Git object scanning reports zero high-confidence secrets and 18 historical host-path lines. The repository lacks a checked-in identity allowlist and all-object identity/privacy gate."},{"op":"add","path":"/metadata/component","value":"release governance"},{"op":"add","path":"/metadata/regression","value":false},{"op":"add","path":"/metadata/dependencies","value":[{"id":"pm-github-1jez","kind":"related","created_at":"2026-08-17T07:40:36.004Z","author":"codex","source_kind":"cli:create:dep","author_source":"detected"}]},{"op":"add","path":"/metadata/docs","value":[{"path":"https://github.com/unbraind/pm-cli-companion/blob/main/.agents/pm/issues/pm-cli-website-kxtl.toon","scope":"global","note":"Fleet commit identity gate and human rewrite decision"}]}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"c62c98a28cc4cce6ab75bde695ceef37a18d49f48c3f0384997463b001198fa6","item_hash_version":2,"message":"Track the full-history privacy and identity gate discovered by the release audit"} +{"ts":"2026-08-21T21:30:44.776Z","author":"ox-alpha","author_source":"asserted","agent_harness":"pi","agent_model":"stealth/ox-alpha","agent_model_source":"environment","agent_instance":"154a23dd8a71b7193a1b854c","agent_provenance":{"model":{"value":"stealth/ox-alpha","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-21T21:30:44.776Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-21T21:30:44.759Z","author":"ox-alpha","text":"Maintainer-approved rewrite executed 2026-08-21 (standing release-privacy directive; precedent pmc-t8tj). Disposable mirror rewritten with git-filter-repo: /home/steve -> $HOME across 15 host-path blobs (18 lines), codex@local -> 1153461+unbraind@users.noreply.github.com (1 commit, ancestor of main). Post-rewrite: 0 bad blobs, HEAD tree byte-identical (801689a), all heads/tags force-pushed, protection restored verbatim. Fresh-clone audit: reachable heads/tags fully clean. PR branch head ff6761e validated: build OK, 261/261 tests, pm health advisory-only. stefan@preu.at retained per maintainer decision 2026-07-21 (approved public authoring identity, see pm-rust allowlist); pi-agent@unbrained.dev classified as domain-based service identity for the forward allowlist. Remaining for closure: checked-in justified identity allowlist + fail-closed all-object audit + secret/host-path gate with false-positive fixtures and behavioral negative controls wired into release:check and CI."}]}],"before_hash":"c62c98a28cc4cce6ab75bde695ceef37a18d49f48c3f0384997463b001198fa6","after_hash":"f2bed5a4b3dab589fab33269340b423ab2e32db6a44d2efe757c1f1a3ac64862","item_hash_version":2} diff --git a/.agents/pm/issues/pm-github-iswq.toon b/.agents/pm/issues/pm-github-iswq.toon new file mode 100644 index 00000000..d720cb9a --- /dev/null +++ b/.agents/pm/issues/pm-github-iswq.toon @@ -0,0 +1,48 @@ +id: pm-github-iswq +title: "BREAKING: pm-github now requires pm CLI 2026.8.20 or newer; older hosts may fail installation or runtime validation" +description: "pm-github already fail-closes complete-corpus receipts, but its production argv and user-facing diagnostics still use the deprecated list-all alias and its declared pm CLI/SDK floors predate 2026.8.20." +type: Issue +status: closed +priority: 1 +tags[5]: agents,cli-ux,complete-corpus,latest-cli,tdd +created_at: "2026-08-20T21:28:31.109Z" +updated_at: "2026-08-20T21:57:30.348Z" +closed_at: "2026-08-20T21:40:41.284Z" +completed_at: "2026-08-20T21:40:41.284Z" +author: codex +estimated_minutes: 180 +acceptance_criteria: "Create failing exact-argv and alias-free real installed-CLI tests first; replace only the deprecated invocation and terminology; retain strict full unbounded fail-closed receipt validation; use top-level TypeScript imports and no any/dynamic imports; pin dev CLI/SDK and set peer/manifest floors to 2026.8.20; run release:check, exact measured-source coverage, pm health, reconcile, validate, npm/bun packed acceptance, dependency/vulnerability/privacy checks; document exact evidence and link the package PR plus companion item pm-cli-website-cbyb; do not merge or publish while pm-github-zqad or pm-github-v2kt blocks release" +risk: medium +confidence: 95 +reporter: codex +severity: medium +repro_steps: "Run the production completePmListArgs vector with pm CLI 2026.8.20 and inspect stderr; scan index.ts, README.md, and complete-corpus tests for list-all." +resolution: "Migrated the existing strict whole-corpus reader to canonical pm CLI 2026.8.20 argv without weakening its completeness decoder, added exact and real packed acceptance, aligned compatibility declarations, updated terminology, and preserved separate privacy, release-order, and exact-coverage blockers." +expected_result: "Production invokes explicit unbounded canonical pm list --all --json with full/body/strict receipts; no alias deprecation reaches stderr; source, tests, docs, generated output, package dependency, peer floor, and manifest floor agree on pm CLI/SDK 2026.8.20." +actual_result: "Implemented canonical host-owned --pm-path plus --output-include full, unbounded amount/cost, then list --all --json --include-body --strict-read; all production errors/docs and direct test fixtures use canonical terminology. Pinned local CLI/SDK 2026.8.20, set peer and manifest floors to 2026.8.20, and removed the ignored manifest pm.compatibility envelope. TDD failed 2/6 before source edits, then passed 6/6. Full suite passed 261 tests. Current measured-source coverage passed at 92.40% lines, 82.58% branches, 91.75% functions across index.ts/projects.ts; this is not 100/100/100/100 and remains independently open as pm-github-9cjx. Docstring gate documented 92 declarations; production audit found 0 vulnerabilities; pack dry-run contained 13 intended files. Clean packed npm and Bun clients each read two open/closed items with items.length=count=total, no truncation/pagination/list-all receipt, and empty child stderr. A packed npm extension authenticated through gh, validated public repo access HTTP 200, and a retry dry-ran a real GitHub import against unbraind/pm-github with no writes and a complete empty tracker; one earlier pm-changelog import attempt timed out at 30 seconds and was not counted as a pass. pm CLI receipt issue #1070 records canonical --output-include full being misreported as legacy --full. Delivery is complete but merge/publication remain blocked by pm-github-zqad and pm-github-v2kt." +affected_version: pm-github 2026.8.18 with pm CLI 2026.8.20 +component: whole-tracker pm CLI integration +customer_impact: Deprecated diagnostics can pollute machine stderr and stale compatibility floors allow hosts that do not provide the canonical contract this package now requires. +dependencies[1]{id,kind,created_at,author,source_kind,author_source}: + pm-github-ep0u,related,"2026-08-20T21:28:31.109Z",codex,"cli:create:dep",detected +notes[4]{created_at,author,text}: + "2026-08-20T21:33:00.194Z",codex,"2026-08-20 TDD: before the source edit, node --test test/complete-corpus.test.ts failed 2 of 6 tests because completePmListArgs still emitted --path/list-all and the real CLI wrote the deprecation diagnostic to stderr. After canonical argv, the stale local 2026.8.15 runtime rejected list --all as ambiguous while the host 2026.8.20 runtime passed; npm install updated the exact local @unbrained/pm-cli pin to 2026.8.20 and all 6 tests passed. Full npm test then passed 261 tests. Canonical --output-include full is currently misreported as legacy --full in the receipt, tracked upstream as pm-cli #1070; the acceptance rejects only actual list-all use until that CLI bug is fixed." + "2026-08-20T21:44:42.154Z",codex,"2026-08-20 final gates before review: npm run release:check passed 261 tests and the current 92.40/82.58/91.75 three-metric measured-source gate, 92 documented declarations, zero production audit vulnerabilities, clean 13-file pack, and current changelog. pm health --strict-exit passed with three provenance advisories; pm merge reconcile --dry-run found 82 clean streams and no pending decisions; pm validate remained structurally valid with pre-existing legacy metadata/resolution/file warnings; duplicates reported one pre-existing closed exact-title cluster. GitHub Dependabot and secret-scanning open alerts were empty; code-scanning has no analysis, and Trivy is not installed, so neither is represented as green. Public pm-changelog 2026.8.17 first refused a bounded 5-of-82 read; explicit unbounded pm-arg controls restored all 289 changelog lines. The tool still invokes its deprecated internal alias until privacy-gated pm-changelog PR #148 can merge and release. Historical privacy/identity findings remain open under pm-github-zqad and publication order remains open under pm-github-v2kt." + "2026-08-20T21:55:36.351Z",codex,"CodeRabbit exact-head review on 2026-08-20 produced four findings. Accepted: disclose the 2026.8.20 compatibility break through the package-owned changelog item; remove unsafe labels/since narrowing advice from complete-corpus ENOBUFS guidance; and pass the real installed-CLI envelope through decodeCompletePmItems before asserting decoded rows. The pm-changelog dependency upgrade request cannot yet be performed: npm latest is 2026.8.17 and no release contains privacy-blocked PR #148, so the current lock remains 2026.8.17 and merge/publication stay blocked. TDD red phase: targeted compatibility and complete-corpus tests failed exactly on the missing breaking note and unsafe buffer advice before implementation." + "2026-08-20T21:57:30.348Z",codex,"Review-fix green phase: targeted compatibility and complete-corpus tests pass 10/10. The full release gate passes all 261 tests, 92 documented declarations, zero production audit vulnerabilities, a clean 13-file pack, and a current generated changelog. The two-source coverage report is now 92.09% lines, 82.47% branches, and 91.75% functions after the diagnostic hardening; it remains non-exact and pm-github-9cjx stays open. The real acceptance now decodes the installed CLI envelope through the production decoder and forces a one-byte buffer to prove the complete-reader error offers no narrowing workaround." +files[5]{path,scope,note}: + index.ts,project,Canonical complete-corpus argv and diagnostics + manifest.json,project,Runtime-enforced host floor + package.json,project,Peer and exact development CLI SDK floors + test/compatibility-floor.test.ts,project,Exact 2026.8.20 host floor and manifest contract + test/complete-corpus.test.ts,project,Exact argv and installed CLI completeness acceptance +tests[2]{command,path,scope,timeout_seconds,note}: + "npm run release:check",package.json,project,1200,Full package release gate + npm test,test/complete-corpus.test.ts,project,900,261-test suite includes red-green canonical argv and real installed-CLI receipt acceptance +docs[4]{path,scope,note}: + "https://github.com/unbraind/pm-changelog/pull/148",global,Canonical unbounded changelog reader awaiting privacy-gated merge and publication + "https://github.com/unbraind/pm-cli-companion/blob/docs/record-pm-rl-pm-csv-cli-2026-8-20/.agents/pm/issues/pm-cli-website-cbyb.toon",global,Companion fleet canonical-command migration owner + "https://github.com/unbraind/pm-cli/issues/1070",global,Upstream receipt bug found during installed-CLI acceptance + README.md,project,Canonical complete-corpus contract and copyable invocation +close_reason: Canonical whole-corpus reader delivery is implemented and verified; package merge/publication remain separately blocked by pm-github-zqad and pm-github-v2kt. +body: "" diff --git a/.agents/pm/issues/pm-github-v2kt.toon b/.agents/pm/issues/pm-github-v2kt.toon index 93ebaad1..52cf5cdb 100644 --- a/.agents/pm/issues/pm-github-v2kt.toon +++ b/.agents/pm/issues/pm-github-v2kt.toon @@ -2,12 +2,17 @@ id: pm-github-v2kt title: Fix release publish ordering ahead of protected main push description: "The daily release workflow published to npm and then pushed the version bump straight to a protected main branch. Branch protection rejected that push with GH006, and the job's fail-fast shell mode killed it before the tag push, so npm ended up ahead of git: main stayed on an older version with no matching tag. The release metadata is now merged through a protected PR before npm publish runs, and only the release tag is pushed after a successful publish. Ported from the verified fix in pm-beads." type: Issue -status: open +status: closed priority: 2 tags: [] created_at: "2026-08-10T15:27:52.482Z" -updated_at: "2026-08-10T16:57:16.322Z" +updated_at: "2026-08-21T21:31:24.293Z" +closed_at: "2026-08-21T21:31:12.798Z" +completed_at: "2026-08-21T21:31:12.798Z" author: pi-agent +resolution: "Release publish ordering fixed and merged to main via PR #46: release metadata merges through a protected PR before npm publish; only the release tag pushes after a verified publish; idempotence guard reconciles already-published versions." +expected_result: "npm publish and git main/tag state always reconcile: release metadata lands on protected main through a PR before publish; only the tag pushes after a verified publish; re-runs are idempotent" +actual_result: "Verified on origin/main: protected-PR merge flow with parked-run approval detection, idempotent publish guard with 3-attempt provenance fallback, tag-only post-publish push; merged in PR #46" notes[8]{created_at,author,text}: "2026-08-10T15:27:52.883Z",pi-agent,"Applied the pm-beads release ordering fix to .github/workflows/release.yml: added pull-requests write permission, captured base_sha in the decide step, and replaced the publish-then-push-to-main steps with a protected-PR merge, a verify-merged-release check, the npm publish (with idempotence guard), and a tag-only push. dist is gitignored here so it is excluded from the verify diff. Gates npm ci, build, test, release:check and yaml all pass." "2026-08-10T15:30:44.660Z",claude,"Greptile reported that creating the release pull request with GITHUB_TOKEN suppresses its pull_request event so CI never starts. Measured against pm-changelog, the event is not suppressed: a run was created at 04:54:24Z with event pull_request for branch release/2026.8.9. The real blocker is that attempt 1 concluded action_required, meaning GitHub parked the run awaiting workflow approval under the fork pull request contributor approval policy of first_time_contributors, because github-actions had no merged pull request in that repository yet. A parked run never appears in the status check rollup, so it is indistinguishable from a required check that failed. The merge wait now detects parked runs, reports the run URL, attempts approval using the actions write permission, and separates awaiting approval from a failed check in the deadline error." @@ -17,4 +22,5 @@ notes[8]{created_at,author,text}: "2026-08-10T16:23:19.099Z",claude,"Round five review fixes. CodeRabbit found that the tracking note claimed dist validation that pm-web's workflow did not actually contain. An audit showed seven repositories track dist but had no rebuild check, because the earlier patch keyed on a diff paths idiom those repositories do not use: pm-changelog, pm-context, pm-gantt-chart, pm-graph, pm-jira, pm-ops and pm-web. All seven now rebuild dist from clean and compare with git status including untracked and ignored files, and reproducibility was confirmed locally in each before enabling the check. Greptile found that fetching only the first hundred thread comments lets a human reply past that page escape the all bot predicate, so the filter now refuses to resolve any thread whose comment total exceeds the comments actually returned. Verified against a truncated thread with a total of one hundred fifty and two fetched nodes, which is correctly not resolved." "2026-08-10T16:41:09.417Z",claude,"CodeRabbit reported that git ls-remote --tags returns both the tag object and its peeled commit for an annotated tag, so cut -f1 would yield two SHAs and git rev-list would fail. Tested against a real annotated tag pushed to a local bare remote. The peeled entry appears when ls-remote is called with no pattern or with a glob, but NOT with the exact refspec form this workflow uses, which returned a single line. The finding therefore does not reproduce against our call. The suggested form was adopted anyway because it costs nothing, states the intent explicitly, and keeps the code correct if the pattern is ever loosened to a glob." "2026-08-10T16:57:16.322Z",claude,"Round six follow up. CodeRabbit noted the parked run detection reads only the first page of the workflow runs endpoint, whose default page size is thirty, so a parked run past that page would go undetected. The query now requests one hundred per page. This is a diagnostic path rather than a safety one, since an undetected parked run simply means the release is not approved and times out without publishing, but the larger page costs nothing. Thread level pagination was declined separately because unfetched threads are never resolved and therefore keep blocking, which is the safe direction." +close_reason: "Fix verified live on origin/main (workflow lines: pull-requests:write, base_sha capture at decide, protected release-PR merge with parked-run approval handling, idempotent npm publish with 3-attempt provenance fallback, tag-only push after successful publish; never pushes HEAD:main again). Merged via PR #46; six bot review rounds resolved in notes. npm-ahead-of-git state eliminated." body: "" diff --git a/.agents/pm/issues/pm-github-zqad.toon b/.agents/pm/issues/pm-github-zqad.toon index 43fc3113..89fbaa35 100644 --- a/.agents/pm/issues/pm-github-zqad.toon +++ b/.agents/pm/issues/pm-github-zqad.toon @@ -6,7 +6,7 @@ status: open priority: 1 tags[5]: git-history,identity-gate,privacy,release,security created_at: "2026-08-17T07:40:36.004Z" -updated_at: "2026-08-17T07:40:36.004Z" +updated_at: "2026-08-21T21:30:44.776Z" author: codex estimated_minutes: 480 acceptance_criteria: "Add a checked-in justified identity allowlist and fail-closed audit covering reachable and unreachable commits and annotated tags; add a current-tree and all-object secret/private-path gate with false-positive fixtures; classify every existing historical match without printing sensitive values into logs; obtain an explicit human decision before rewriting published history; if approved, preserve release metadata and verify old sensitive objects are no longer remotely reachable; if declined, record the residual risk and enforce the forward gate; include the audits in release:check and CI with behavioral negative controls." @@ -18,6 +18,8 @@ component: release governance regression: false dependencies[1]{id,kind,created_at,author,source_kind,author_source}: pm-github-1jez,related,"2026-08-17T07:40:36.004Z",codex,"cli:create:dep",detected +notes[1]{created_at,author,text}: + "2026-08-21T21:30:44.759Z",ox-alpha,"Maintainer-approved rewrite executed 2026-08-21 (standing release-privacy directive; precedent pmc-t8tj). Disposable mirror rewritten with git-filter-repo: /home/steve -> $HOME across 15 host-path blobs (18 lines), codex@local -> 1153461+unbraind@users.noreply.github.com (1 commit, ancestor of main). Post-rewrite: 0 bad blobs, HEAD tree byte-identical (801689a), all heads/tags force-pushed, protection restored verbatim. Fresh-clone audit: reachable heads/tags fully clean. PR branch head ff6761e validated: build OK, 261/261 tests, pm health advisory-only. stefan@preu.at retained per maintainer decision 2026-07-21 (approved public authoring identity, see pm-rust allowlist); pi-agent@unbrained.dev classified as domain-based service identity for the forward allowlist. Remaining for closure: checked-in justified identity allowlist + fail-closed all-object audit + secret/host-path gate with false-positive fixtures and behavioral negative controls wired into release:check and CI." docs[1]{path,scope,note}: "https://github.com/unbraind/pm-cli-companion/blob/main/.agents/pm/issues/pm-cli-website-kxtl.toon",global,Fleet commit identity gate and human rewrite decision body: "" diff --git a/.github/approved-git-identities.txt b/.github/approved-git-identities.txt new file mode 100644 index 00000000..203b91a7 --- /dev/null +++ b/.github/approved-git-identities.txt @@ -0,0 +1,39 @@ +# Approved commit author, committer, and tagger identities for this repository. +# +# The privacy gate (scripts/privacy-gate.ts) fails closed on any author, +# committer, or annotated-tag tagger email not listed here. Each entry names +# its justification; a new entry must add one. +# +# History note (2026-08-21): the coordinated rewrite executed under +# maintainer approval removed codex@local and all /home/ host paths from +# every reachable commit and tag. Reintroducing a machine-local identity is a +# gate failure by design. + +# The deliberate public authoring identity of the maintainer. Retained by an +# explicit maintainer decision reaffirmed 2026-07-21 (see the pm-rust fleet +# reference allowlist); it is intentional public identity metadata, not leaked +# personal data. +stefan@preu.at + +# The maintainer's GitHub noreply addresses (two account-id forms appear in +# this repository's history). +187600231+unbraind@users.noreply.github.com +1153461+unbraind@users.noreply.github.com + +# GitHub's committer for merges made through the web UI. +noreply@github.com + +# Dependabot's public GitHub noreply address on dependency pull-request refs. +49699333+dependabot[bot]@users.noreply.github.com + +# The GitHub Actions bot, which authors release commits created by +# .github/workflows/release.yml. It must be listed here: the first release +# would otherwise write a commit that this very audit rejects on every +# subsequent run. +41898282+github-actions[bot]@users.noreply.github.com +github-actions[bot]@users.noreply.github.com + +# Domain-based agent service identity used for tracked authoring work. It +# carries no personal metadata: pi-agent is a harness role, and unbrained.dev +# is the project's own public domain. +pi-agent@unbrained.dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a837960..6d77718b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,12 @@ jobs: - name: Verify complete docstring coverage run: npm run docstring + # Fail-closed identity / secret / host-path audit over the local object + # store (pm-github-zqad). In CI this is evidence about refs and tags; the + # local release:check run additionally covers unreachable objects. + - name: Privacy gate (identities, secrets, host paths) + run: npm run privacy + - name: Test with coverage gate run: npm run coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b0f27f..8a9740ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixed + +- Fix release publish ordering ahead of protected main push ([pm-github-v2kt](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-v2kt.toon)) +- BREAKING: pm-github now requires pm CLI 2026.8.20 or newer; older hosts may fail installation or runtime validation ([pm-github-iswq](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-iswq.toon)) + ## 2026.8.18 - 2026-08-18 ### Fixed diff --git a/README.md b/README.md index 719d237e..c3731adf 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,25 @@ pm install npm:pm-github --global Import idempotency, export, status sync, Projects v2 sync, and search fallback all depend on seeing every pm item, including closed and canceled work. Before any of those paths plans or performs a GitHub operation, pm-github requests a -strict, full, unbounded `pm list-all` response and verifies its completeness, +strict, full, unbounded `pm list --all` response and verifies its completeness, omission, pagination, output-budget, count, identity, and consumed-field contracts. An incomplete or unverifiable response fails closed; it is never treated as an empty or partial workspace. +The production subprocess uses the same canonical argument contract as: + +```bash +pm --pm-path \ + --output-include full \ + --output-limit unbounded \ + --output-budget unbounded \ + list --all --json --include-body --strict-read +``` + +The output controls precede the command so the host owns the complete-result +contract; `list --all` includes terminal items without invoking the deprecated +`list-all` alias. + The subprocess still has a 64 MiB byte safety cap. Set `PM_JSON_MAX_BUFFER=` to a larger positive safe integer for an unusually large tracker. Raising that transport cap does not weaken the completeness diff --git a/index.ts b/index.ts index 0926b932..75b516e4 100644 --- a/index.ts +++ b/index.ts @@ -805,7 +805,7 @@ export function authorTag(issue: GhIssue): string | undefined { * The slice of a pm item this package reads and writes. * * A deliberately loose projection of the full tracker item: only the fields the - * import / export / sync paths touch, so JSON from `pm list-all` parses without + * import / export / sync paths touch, so JSON from `pm list --all` parses without * depending on every SDK field. */ export interface PmItem { @@ -859,7 +859,7 @@ function describeJsonValue(value: unknown): string { return rendered === undefined ? String(value) : rendered; } -/** Require one exact field value from the `pm list-all` truthfulness envelope. */ +/** Require one exact field value from the `pm list --all` truthfulness envelope. */ function requireCompletePmField( actual: unknown, expected: string | number | boolean | null, @@ -867,7 +867,7 @@ function requireCompletePmField( ): void { if (actual !== expected) { throw new CommandError( - `Refusing unverifiable pm list-all output: ${field} must be ${describeJsonValue(expected)}; received ${describeJsonValue(actual)}.`, + `Refusing unverifiable pm list --all output: ${field} must be ${describeJsonValue(expected)}; received ${describeJsonValue(actual)}.`, ); } } @@ -881,36 +881,37 @@ const COMPLETE_PM_READ_CONTRACT = { /** * Build the canonical installed-CLI invocation for a whole-workspace read. * - * The output controls are deliberately explicit. `list-all` includes terminal + * The output controls are deliberately explicit. `list --all` includes terminal * items, `--output-include full` plus `--include-body` retains every field this * integration consumes, `--strict-read` rejects unreadable records, and both * amount and cost are unbounded. An arbitrary `--limit` must never be added: * callers use this corpus to prevent duplicate imports and missing syncs. * - * @param pmRoot - Workspace or tracker root accepted by the pm CLI `--path` flag. + * @param pmRoot - Workspace or tracker root accepted by the pm CLI `--pm-path` flag. * @returns Argument vector passed to the installed `pm` executable. * @internal Exported so the acceptance test can bind the safety contract to the * exact production invocation; it is removed from the published declaration. */ export function completePmListArgs(pmRoot: string): string[] { return [ - "--path", + "--pm-path", pmRoot, - "list-all", - "--json", - "--include-body", "--output-include", "full", - "--strict-read", "--output-limit", "unbounded", "--output-budget", "unbounded", + "list", + "--all", + "--json", + "--include-body", + "--strict-read", ]; } /** - * Decode only a complete, unbounded `pm list-all` response. + * Decode only a complete, unbounded `pm list --all` response. * * The subprocess JSON is untrusted. This gate independently verifies every * completeness signal emitted by the current CLI, reconciles envelope counts, @@ -929,11 +930,11 @@ export function completePmListArgs(pmRoot: string): string[] { export function decodeCompletePmItems(parsed: unknown): PmItem[] { if (!isJsonRecord(parsed)) { throw new CommandError( - "Refusing unverifiable pm list-all output: the response must be a top-level object with completeness receipts.", + "Refusing unverifiable pm list --all output: the response must be a top-level object with completeness receipts.", ); } if (!Array.isArray(parsed.items)) { - throw new CommandError("Refusing unverifiable pm list-all output: items must be an array."); + throw new CommandError("Refusing unverifiable pm list --all output: items must be an array."); } requireCompletePmField(parsed.truncated, false, "truncated"); @@ -958,7 +959,7 @@ export function decodeCompletePmItems(parsed: unknown): PmItem[] { ); if (!Array.isArray(omission.omitted_field_groups) || omission.omitted_field_groups.length !== 0) { throw new CommandError( - "Refusing unverifiable pm list-all output: omission_receipt.omitted_field_groups must be empty.", + "Refusing unverifiable pm list --all output: omission_receipt.omitted_field_groups must be empty.", ); } @@ -987,33 +988,33 @@ export function decodeCompletePmItems(parsed: unknown): PmItem[] { || !readOutput.requested_dimensions.includes("cost") ) { throw new CommandError( - "Refusing unverifiable pm list-all output: read_output.requested_dimensions must include include, amount, and cost.", + "Refusing unverifiable pm list --all output: read_output.requested_dimensions must include include, amount, and cost.", ); } if ("output_budget_truncation" in parsed || "output_budget_exceeded" in parsed) { throw new CommandError( - "Refusing unverifiable pm list-all output: a budget truncation or omission disclosure was present.", + "Refusing unverifiable pm list --all output: a budget truncation or omission disclosure was present.", ); } if (!Number.isSafeInteger(parsed.count) || (parsed.count as number) < 0) { throw new CommandError( - `Refusing unverifiable pm list-all output: count must be a non-negative safe integer; received ${describeJsonValue(parsed.count)}.`, + `Refusing unverifiable pm list --all output: count must be a non-negative safe integer; received ${describeJsonValue(parsed.count)}.`, ); } if (!Number.isSafeInteger(parsed.total) || (parsed.total as number) < 0) { throw new CommandError( - `Refusing unverifiable pm list-all output: total must be a non-negative safe integer; received ${describeJsonValue(parsed.total)}.`, + `Refusing unverifiable pm list --all output: total must be a non-negative safe integer; received ${describeJsonValue(parsed.total)}.`, ); } if (parsed.items.length !== parsed.count) { throw new CommandError( - `Refusing unverifiable pm list-all output: items.length ${parsed.items.length} must equal count ${String(parsed.count)}.`, + `Refusing unverifiable pm list --all output: items.length ${parsed.items.length} must equal count ${String(parsed.count)}.`, ); } if (parsed.count !== parsed.total) { throw new CommandError( - `Refusing incomplete pm list-all output: count ${String(parsed.count)} must equal total ${String(parsed.total)}.`, + `Refusing incomplete pm list --all output: count ${String(parsed.count)} must equal total ${String(parsed.total)}.`, ); } @@ -1021,35 +1022,35 @@ export function decodeCompletePmItems(parsed: unknown): PmItem[] { const items: PmItem[] = []; for (const [index, item] of parsed.items.entries()) { if (!isJsonRecord(item)) { - throw new CommandError(`Refusing unverifiable pm list-all output: item ${index} must be an object.`); + throw new CommandError(`Refusing unverifiable pm list --all output: item ${index} must be an object.`); } if (typeof item.id !== "string" || item.id.trim().length === 0) { - throw new CommandError(`Refusing unverifiable pm list-all output: item ${index} must have a non-empty id.`); + throw new CommandError(`Refusing unverifiable pm list --all output: item ${index} must have a non-empty id.`); } if (ids.has(item.id)) { - throw new CommandError(`Refusing unverifiable pm list-all output: duplicate item id ${item.id}.`); + throw new CommandError(`Refusing unverifiable pm list --all output: duplicate item id ${item.id}.`); } const title = item.title; const status = item.status; const body = item.body; const description = item.description; if (typeof title !== "string") { - throw new CommandError(`Refusing unverifiable pm list-all output: item ${item.id} title must be a string.`); + throw new CommandError(`Refusing unverifiable pm list --all output: item ${item.id} title must be a string.`); } if (typeof status !== "string") { - throw new CommandError(`Refusing unverifiable pm list-all output: item ${item.id} status must be a string.`); + throw new CommandError(`Refusing unverifiable pm list --all output: item ${item.id} status must be a string.`); } if (typeof body !== "string") { - throw new CommandError(`Refusing unverifiable pm list-all output: item ${item.id} body must be a string.`); + throw new CommandError(`Refusing unverifiable pm list --all output: item ${item.id} body must be a string.`); } if (typeof description !== "string") { throw new CommandError( - `Refusing unverifiable pm list-all output: item ${item.id} description must be a string.`, + `Refusing unverifiable pm list --all output: item ${item.id} description must be a string.`, ); } if (!Array.isArray(item.tags) || item.tags.some((tag) => typeof tag !== "string")) { throw new CommandError( - `Refusing unverifiable pm list-all output: item ${item.id} tags must be an array of strings.`, + `Refusing unverifiable pm list --all output: item ${item.id} tags must be an array of strings.`, ); } ids.add(item.id); @@ -1130,21 +1131,21 @@ export function readPmItems( const code = (result.error as NodeJS.ErrnoException).code; if (code === "ENOBUFS") { throw new CommandError( - `pm list-all output exceeded the ${maxBuffer} byte read buffer. ` + - "The workspace is larger than this read limit; narrow the import " + - "(for example --labels or --since) or raise the PM_JSON_MAX_BUFFER env var.", + `pm list --all output exceeded the ${maxBuffer} byte read buffer. ` + + "The complete corpus cannot be narrowed safely. Only raise the " + + "PM_JSON_MAX_BUFFER env var after confirming the workspace size and available memory.", ); } - throw new CommandError(`pm list-all failed: ${result.error.message}`); + throw new CommandError(`pm list --all failed: ${result.error.message}`); } if (result.status !== 0) { - throw new CommandError(result.stderr || "pm list-all failed"); + throw new CommandError(result.stderr || "pm list --all failed"); } let parsed: unknown; try { parsed = JSON.parse(result.stdout) as unknown; } catch { - throw new CommandError("Could not parse `pm list-all --json` output."); + throw new CommandError("Could not parse `pm list --all --json` output."); } return decodeCompletePmItems(parsed); } @@ -1214,9 +1215,9 @@ export function indexByProvenance(items: PmItem[]): Map { // --------------------------------------------------------------------------- // Node's spawnSync defaults to a 1 MiB stdout cap. A mature tracker's full JSON -// dump (`pm list-all --full --include-body`) passes that at a few hundred items, +// dump (`pm --output-include full list --all --include-body`) passes that at a few hundred items, // and the child is then killed with ENOBUFS, status null and EMPTY stderr — which -// surfaced as a bare "pm list-all failed" with nothing to diagnose. Reproduced on +// surfaced as a bare "pm list --all failed" with nothing to diagnose. Reproduced on // a real 443-item workspace at 1,052,859 bytes. 64 MiB matches the cap the sibling // pm packages settled on (pm-changelog, pm-context, pm-brief). /** Read-buffer cap for `pm` output, in bytes. 64 MiB by default; override with the diff --git a/manifest.json b/manifest.json index 76401709..8f5567d0 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "description": "GitHub Issues + Projects v2 integration. Imports issues as pm items (`pm github import`), exports pm items as GitHub issues, syncs issue state, and bidirectionally syncs pm items with a GitHub Projects v2 board (`pm github project import/sync/list/fields`) — mapping pm status to the board Status column with idempotent, no-data-loss provenance.", "author": "@unbraind", "entry": "./dist/index.js", - "pm_min_version": "2026.8.3", + "pm_min_version": "2026.8.20", "priority": 50, "capabilities": [ "commands", @@ -13,8 +13,5 @@ "hooks", "preflight", "search" - ], - "pm": { - "compatibility": "v2" - } + ] } diff --git a/package-lock.json b/package-lock.json index c0197157..7438237d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,16 @@ "license": "MIT", "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "2026.8.17", - "pm-changelog": "^2026.8.6", - "pm-ops": "^2026.8.8", + "@unbrained/pm-cli": "2026.8.20", + "pm-changelog": "^2026.8.17", + "pm-ops": "^2026.8.17", "typescript": "^7.0.2" }, "engines": { "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.8.3" + "@unbrained/pm-cli": ">=2026.8.20" } }, "node_modules/@apm-js-collab/code-transformer": { @@ -732,9 +732,9 @@ } }, "node_modules/@unbrained/pm-cli": { - "version": "2026.8.17", - "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.8.17.tgz", - "integrity": "sha512-G0eh1wVNyhJvIKXAx3epbuDamHBfl7AaU6zNbUuIHEpWU1yia7gfx6d0TSwLuhjgTb2lTChCWApp+et1E53r5Q==", + "version": "2026.8.20", + "resolved": "https://registry.npmjs.org/@unbrained/pm-cli/-/pm-cli-2026.8.20.tgz", + "integrity": "sha512-q2EHf97Z1Qou1STH2SGI5bFIKQJuKLz0/6SfsD83zsx5pKlSd+fSyyyB4hYpf6bNDYgHjJNtsqcVmBVdHibSzg==", "dev": true, "license": "MIT", "dependencies": { @@ -1096,9 +1096,9 @@ } }, "node_modules/pm-changelog": { - "version": "2026.8.12", - "resolved": "https://registry.npmjs.org/pm-changelog/-/pm-changelog-2026.8.12.tgz", - "integrity": "sha512-eOX2xJJm8fLo9n7Eua4hTs46TToYPcVdp9gfRYqPqNmaik+dBawqNMb3otOeDa8Sbp5o7YIAQlgXDVH5Y0/bPA==", + "version": "2026.8.17", + "resolved": "https://registry.npmjs.org/pm-changelog/-/pm-changelog-2026.8.17.tgz", + "integrity": "sha512-wU52DpIWlSRhi0v3xtktgVF+oLte4Z+LWZ0H3VjbrhG+8XOEJrhevOu0YqMyiPQvNyt0SpDXy8OEhyxyaIcVEA==", "dev": true, "license": "MIT", "bin": { @@ -1108,13 +1108,13 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.8.3" + "@unbrained/pm-cli": ">=2026.8.16" } }, "node_modules/pm-ops": { - "version": "2026.8.10", - "resolved": "https://registry.npmjs.org/pm-ops/-/pm-ops-2026.8.10.tgz", - "integrity": "sha512-sNtvpGikYMTK7IM1oHYHEH+n/WyAjTy9Nrmtps2IaRm6uYE8fFGkoHl/bpqh+Z5eoGsTskaRCiIlseeMXMFhIA==", + "version": "2026.8.17", + "resolved": "https://registry.npmjs.org/pm-ops/-/pm-ops-2026.8.17.tgz", + "integrity": "sha512-fB5OvGw8yGAC/Zg/Em7cIlltXG7ZQ7r7d2s6NOEG8dxUl3cs42yyRWOCjUnSRAKNmSIX4zxbXpgPOZyGhD1MeQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1125,7 +1125,7 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.7.31" + "@unbrained/pm-cli": ">=2026.8.15" } }, "node_modules/proc-log": { diff --git a/package.json b/package.json index daee8722..a603adad 100644 --- a/package.json +++ b/package.json @@ -29,23 +29,24 @@ "docstring": "node scripts/docstring-gate.ts", "audit:prod": "node --input-type=module -e \"import { spawnSync } from 'node:child_process'; import { devNull } from 'node:os'; const env = { ...process.env, npm_config_userconfig: devNull, NPM_CONFIG_USERCONFIG: devNull }; for (const key of Object.keys(env)) if (key.toLowerCase() === 'npm_config_allow_scripts') delete env[key]; const win = process.platform === 'win32'; const r = spawnSync(win ? 'npm.cmd' : 'npm', ['audit', '--omit=dev', '--ignore-scripts'], { stdio: 'inherit', env, shell: win }); process.exit(r.status ?? 1);\"", "pack:dry-run": "npm pack --dry-run", - "changelog:full": "pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release", + "changelog:full": "pm-changelog --pm-root .agents/pm --pm-arg=--output-limit --pm-arg=unbounded --pm-arg=--output-budget --pm-arg=unbounded --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release", "changelog:check": "npm run changelog:full -- --check", - "release:check": "npm run typecheck && npm run build && npm run docstring && npm run coverage && npm run audit:prod && npm run pack:dry-run && npm run changelog:check", + "release:check": "npm run typecheck && npm run build && npm run docstring && npm run privacy && npm run coverage && npm run audit:prod && npm run pack:dry-run && npm run changelog:check", "prepublishOnly": "npm run release:check", - "release:notes": "pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --github-step-summary", + "release:notes": "pm-changelog --pm-root .agents/pm --pm-arg=--output-limit --pm-arg=unbounded --pm-arg=--output-budget --pm-arg=unbounded --stdout --since-previous-tag --until-release-tag --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --github-step-summary", "prepare": "node scripts/prepare-merge-driver.mjs", "merge:install": "pm merge install", - "coverage": "npm run build && npm run build:test && node scripts/coverage-gate.ts" + "coverage": "npm run build && npm run build:test && node scripts/coverage-gate.ts", + "privacy": "node scripts/privacy-gate.ts" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.8.3" + "@unbrained/pm-cli": ">=2026.8.20" }, "devDependencies": { "@types/node": "^26.1.1", - "@unbrained/pm-cli": "2026.8.17", - "pm-changelog": "^2026.8.6", - "pm-ops": "^2026.8.8", + "@unbrained/pm-cli": "2026.8.20", + "pm-changelog": "^2026.8.17", + "pm-ops": "^2026.8.17", "typescript": "^7.0.2" }, "keywords": [ diff --git a/scripts/privacy-gate.ts b/scripts/privacy-gate.ts new file mode 100644 index 00000000..36598894 --- /dev/null +++ b/scripts/privacy-gate.ts @@ -0,0 +1,408 @@ +#!/usr/bin/env node +/** + * Fail-closed privacy gate for Git identities, secrets, and host paths. + * + * This is the forward gate required by pm item pm-github-zqad. It walks the + * LOCAL object store — reachable and unreachable commits, annotated tags, and + * blobs alike — and fails closed on: + * + * 1. Any commit author, committer, or annotated-tag tagger whose email is not + * listed in `.github/approved-git-identities.txt`. + * 2. Any blob containing a high-confidence credential signature (private key + * PEM headers, GitHub / npm / Slack token shapes, AWS access key ids). + * 3. Any blob containing an absolute personal host path (`/home//` or + * `/Users//`). + * + * Synthetic review fixtures are exempt through a checked-in manifest that maps + * exact Git blob object ids to a written justification, so a fixture can never + * silently mask real content: the exemption is content-addressed, not + * path-addressed, and every exemption is reviewable in the diff that adds it. + * + * The gate never prints matched secret or host-path values; findings name only + * the rule and the object id, so gate logs cannot become the leak they exist + * to prevent. + * + * Coverage note (mirrors tests/identity_audit.rs in pm-rust): CI clones never + * transfer server-side unreachable objects, so a green CI run is evidence + * about refs and tags; a green local `release:check` run is evidence about the + * whole local store including dangling objects. + * + * @example + * ```bash + * node scripts/privacy-gate.ts + * ``` + */ + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { isMainInvocation } from "./docstring-gate.ts"; + +/** + * Outcome of one gate run, held as plain strings so a test can inspect it. + * + * Pure by design: {@link runGate} touches neither the process streams nor + * `process.exit`, while the thin {@link main} entry point writes them and sets + * the exit code. + */ +export interface PrivacyGateResult { + /** Process exit code the run would produce (0 on success; 1 on failure). */ + readonly exitCode: number; + /** Content the run would write to stdout, without a trailing newline. */ + readonly stdout: string; + /** Content the run would write to stderr, without a trailing newline. */ + readonly stderr: string; +} + +/** One fail-closed finding: which rule fired and on which object. */ +interface Finding { + /** Stable rule identifier, safe to print (contains no matched content). */ + readonly rule: string; + /** Git object id of the offending object. */ + readonly oid: string; +} + +/** Shape of one entry in the synthetic-fixture exemption manifest. */ +interface FixtureEntry { + /** Human-readable justification recorded next to the fixture in review. */ + readonly justification: string; +} + +/** Path of the identity allowlist, relative to the repository root. */ +const ALLOWLIST_PATH = ".github/approved-git-identities.txt"; + +/** Path of the synthetic-fixture exemption manifest, relative to the root. */ +const FIXTURE_MANIFEST_PATH = "test/fixtures/privacy-gate/manifest.json"; + +/** + * High-confidence credential signatures. Every pattern is shaped so ordinary + * prose, identifiers, or version numbers cannot match it; broad low-confidence + * patterns are deliberately excluded to keep the false-positive rate at zero + * without weakening the gate. + */ +const SECRET_RULES: readonly (readonly [rule: string, pattern: RegExp])[] = [ + ["private-key-pem", /-----BEGIN [A-Z ]*PRIVATE KEY-----/], + ["github-token-classic", /ghp_[A-Za-z0-9]{36}/], + ["github-token-fine-grained", /github_pat_[A-Za-z0-9_]{22,}/], + ["npm-token", /npm_[A-Za-z0-9]{36}/], + ["slack-token", /xox[abprs]-[A-Za-z0-9-]{10,}/], + ["aws-access-key-id", /AKIA[0-9A-Z]{16}/], +] as const; + +/** + * Absolute personal host-path shape. Matches `/home//` and + * `/Users//` but not the redacted `$HOME` form the history rewrite + * standardized on, and not bare `/home` or URI scheme text. + */ +const HOST_PATH_PATTERN = /\/(?:home|Users)\/[A-Za-z0-9][A-Za-z0-9._-]*\//; + +/** + * Parses the approved-git-identities allowlist file. + * + * Comment lines (starting with `#`) and blank lines are ignored; every other + * line is trimmed and collected. A missing file fails closed with an error + * rather than passing vacuously. + * + * @param root - Absolute repository root holding `.github/`. + * @returns The set of approved email addresses. + */ +function parseAllowlist(root: string): Set { + const raw = readFileSync(join(root, ALLOWLIST_PATH), "utf8"); + return new Set( + raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")), + ); +} + +/** + * Loads the synthetic-fixture exemption manifest, or an empty map when absent. + * + * A missing manifest is not an error: repositories without fixtures need no + * exemptions, and requiring the file would make the gate unusable before the + * first fixture exists. + * + * @param root - Absolute repository root holding `test/fixtures/`. + * @returns Map from exact Git blob object id to its recorded justification. + */ +function loadFixtureManifest(root: string): Map { + let raw: string; + try { + raw = readFileSync(join(root, FIXTURE_MANIFEST_PATH), "utf8"); + } catch { + return new Map(); + } + const parsed: Record = JSON.parse(raw) as Record; + return new Map(Object.entries(parsed).map(([oid, entry]) => [oid, entry.justification])); +} + +/** + * Enumerates the blob object ids actually present under the fixture directory + * in the HEAD tree via `git ls-tree -r HEAD -- `. + * + * An exemption only holds when the exempted content is literally a reviewed + * fixture file at HEAD; this closes the hole where a manifest key silences an + * arbitrary leaked blob that lives elsewhere in history. + * + * @param root - Absolute repository root. + * @returns Set of blob object ids under the fixture directory at HEAD. + * @throws Error when git exits non-zero or cannot be spawned. + */ +function listFixtureTreeBlobs(root: string): Set { + const dir = dirname(FIXTURE_MANIFEST_PATH); + const result = spawnSync("git", ["ls-tree", "-r", "HEAD", "--", dir], { + cwd: root, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + throw new Error(`git ls-tree HEAD -- ${dir} failed: ${result.stderr.trim()}`); + } + const oids = new Set(); + for (const line of (result.stdout ?? "").split("\n")) { + const match = /^\d+ blob ([0-9a-f]{40})\t/.exec(line); + if (match?.[1] !== undefined) oids.add(match[1]); + } + return oids; +} + +/** + * Builds the effective fixture exemption set: manifest keys intersected with + * the blobs actually present under the fixture directory at HEAD. + * + * @param root - Absolute repository root. + * @returns Map from exemptable Git blob object id to its justification. + */ +function loadFixtureExemptions(root: string): Map { + const manifest = loadFixtureManifest(root); + const fixtureBlobs = listFixtureTreeBlobs(root); + return new Map([...manifest].filter(([oid]) => fixtureBlobs.has(oid))); +} + +/** + * Computes the Git blob object id for content, matching `git hash-object`. + * + * Git blob ids are SHA-1 over the header `blob \0` followed by the + * content; computing the id here lets tests predict fixture exemptions without + * shelling out to git. + * + * @param content - Exact bytes stored in the blob. + * @returns The 40-character hexadecimal Git blob object id. + */ +export function gitBlobOid(content: string): string { + const body = Buffer.from(content, "utf8"); + const hash = createHash("sha1"); + hash.update(`blob ${body.byteLength}\0`); + hash.update(body); + return hash.digest("hex"); +} + +/** + * Extracts the email address from a Git `author`, `committer`, or `tagger` + * header line of the form `role Name timestamp tz`. + * + * @param line - The raw header line from a commit or tag object. + * @returns The address between the first `<` and the last `>`, or undefined + * when the line carries no such pair. + */ +export function extractEmail(line: string): string | undefined { + const start = line.indexOf("<"); + const end = line.lastIndexOf(">"); + if (start === -1 || end <= start) return undefined; + return line.slice(start + 1, end); +} + +/** + * Reads every object in the repository's local store via + * `git cat-file --batch-all-objects --batch-check`. + * + * Unlike a rev-list walk this includes unreachable (dangling or orphaned) + * objects, which is the whole point: amended credentials and dropped branches + * must still fail the gate until the store has actually discarded them. + * + * @param root - Absolute repository root (any working tree or bare repo). + * @returns Array of `[oid, type]` pairs for every object in the store. + * @throws Error when git is missing, the path is not a repository, or git + * exits non-zero. + */ +export function listAllObjects(root: string): [oid: string, type: string][] { + const result = spawnSync("git", ["cat-file", "--batch-all-objects", "--batch-check"], { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + throw new Error(`git cat-file --batch-all-objects failed: ${result.stderr.trim()}`); + } + return (result.stdout ?? "") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => { + const parts = line.split(" "); + return [parts[0] ?? "", parts[1] ?? ""] as [string, string]; + }); +} + +/** + * Reads one object's full content via `git cat-file `. + * + * @param root - Absolute repository root. + * @param type - Git object type (`commit`, `tag`, or `blob`). + * @param oid - Git object id to read. + * @returns The object's full text content. + * @throws Error when git exits non-zero or cannot be spawned. + */ +export function readObject(root: string, type: string, oid: string): string { + const result = spawnSync("git", ["cat-file", type, oid], { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + throw new Error(`git cat-file ${type} ${oid} failed: ${result.stderr.trim()}`); + } + return result.stdout ?? ""; +} + +/** + * Scans one blob's content against the secret and host-path rules, honouring + * content-addressed fixture exemptions. + * + * @param oid - Git object id of the blob being scanned. + * @param content - Blob text content. + * @param exemptions - Content-addressed fixture exemptions loaded once per run + * by {@link runGate} via {@link loadFixtureExemptions}. + * @returns Findings for every rule that fired, empty when clean or exempt. + */ +export function scanBlob( + oid: string, + content: string, + exemptions: ReadonlyMap, +): Finding[] { + if (exemptions.has(oid)) return []; + const findings: Finding[] = []; + for (const [rule, pattern] of SECRET_RULES) { + if (pattern.test(content)) findings.push({ rule, oid }); + } + if (HOST_PATH_PATTERN.test(content)) findings.push({ rule: "host-path", oid }); + return findings; +} + +/** + * Runs the full privacy audit against a repository root and returns what it + * would report. + * + * The audit enumerates every object in the local store, checks commit author + * and committer emails plus annotated-tag tagger emails against the allowlist, + * and scans every blob against the secret and host-path rules. Any allowlist, + * git, or parse failure fails closed with exit code 1. + * + * @param root - Absolute repository root to audit. + * @returns The exit code and the newline-free stdout/stderr content. + */ +export function runGate(root: string): PrivacyGateResult { + let allowlist: Set; + try { + allowlist = parseAllowlist(root); + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: `privacy-gate: cannot read ${ALLOWLIST_PATH}: ${(error as Error).message}`, + }; + } + + let objects: [string, string][]; + try { + objects = listAllObjects(root); + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: `privacy-gate: object enumeration failed: ${(error as Error).message}`, + }; + } + + const findings: Finding[] = []; + let exemptions: Map; + try { + exemptions = loadFixtureExemptions(root); + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: `privacy-gate: fixture exemption resolution failed: ${(error as Error).message}`, + }; + } + try { + for (const [oid, type] of objects) { + if (type === "commit" || type === "tag") { + const content = readObject(root, type, oid); + // Only the header section (everything before the first blank line) can + // carry identity headers; message or tag-message lines that merely + // begin with an identity keyword must not enter this check. + const headerSection = content.split("\n\n")[0] ?? ""; + for (const line of headerSection.split("\n")) { + // Match the role keyword without requiring a following space: a + // crafted object with `author` or a tab separator must still + // be verified, never skipped. + if (!/^(author|committer|tagger)/.test(line)) continue; + // An identity header that carries no parseable address is itself a + // violation: the gate must never pass an identity it could not + // verify, so a malformed header fails closed like an unapproved one. + const email = extractEmail(line); + if (email === undefined || !allowlist.has(email)) { + findings.push({ rule: `identity:${type}`, oid }); + } + } + } else if (type === "blob") { + findings.push(...scanBlob(oid, readObject(root, "blob", oid), exemptions)); + } + } + } catch (error) { + return { + exitCode: 1, + stdout: "", + stderr: `privacy-gate: object scan failed: ${(error as Error).message}`, + }; + } + + if (findings.length > 0) { + let message = `privacy-gate: ${findings.length} violation(s); values are never printed:\n`; + for (const finding of findings) { + message += `${finding.oid} ${finding.rule}\n`; + } + message += `Approve the identity in ${ALLOWLIST_PATH} with justification, remove the content, or record a content-addressed fixture exemption.`; + return { exitCode: 1, stdout: "", stderr: message.replace(/\n$/, "") }; + } + + return { + exitCode: 0, + stdout: `privacy-gate: ${objects.length} object(s) audited, all identities approved, no secret or host-path matches`, + stderr: "", + }; +} + +/** + * CLI entry point: runs the gate against `root` and writes the streams. + * + * @param root - Absolute repository root to audit. + */ +export function main(root: string): void { + const result = runGate(root); + if (result.stdout.length > 0) process.stdout.write(`${result.stdout}\n`); + if (result.stderr.length > 0) process.stderr.write(`${result.stderr}\n`); + process.exitCode = result.exitCode; +} + +const repoRoot = join(import.meta.dirname, ".."); + +if (isMainInvocation(process.argv, import.meta.url)) { + main(repoRoot); +} diff --git a/test/atomic.test.ts b/test/atomic.test.ts index ffb0ac81..542b7642 100644 --- a/test/atomic.test.ts +++ b/test/atomic.test.ts @@ -59,10 +59,20 @@ function freshTracker(): string { function listItems(root: string): Array<{ id: string; title: string; status: string }> { const result = spawnSync( PM_BIN, - ["--path", root, "list-all", "--json", "--full", "--limit", "100"], + [ + "--pm-path", + root, + "--output-include", + "full", + "list", + "--all", + "--json", + "--limit", + "100", + ], PM_SPAWN_OPTS, ); - assert.strictEqual(result.status, 0, `pm list-all failed: ${result.error?.message ?? result.stderr}`); + assert.strictEqual(result.status, 0, `pm list --all failed: ${result.error?.message ?? result.stderr}`); const parsed = JSON.parse(result.stdout) as { items?: Array<{ id: string; title: string; status: string }> }; return parsed.items ?? []; } diff --git a/test/compatibility-floor.test.ts b/test/compatibility-floor.test.ts index ffbf3e18..b312eeef 100644 --- a/test/compatibility-floor.test.ts +++ b/test/compatibility-floor.test.ts @@ -19,6 +19,8 @@ interface ExtensionManifest { * names the actual type rather than being coerced into a plausible string. */ readonly pm_min_version?: unknown; + /** Obsolete compatibility envelope retained only to prove it is absent. */ + readonly pm?: unknown; } const packageJson = JSON.parse( @@ -27,9 +29,11 @@ const packageJson = JSON.parse( const extensionManifest = JSON.parse( readFileSync(resolve(repoRoot, "manifest.json"), "utf8"), ) as ExtensionManifest; +const changelog = readFileSync(resolve(repoRoot, "CHANGELOG.md"), "utf8"); const CLI = "@unbrained/pm-cli"; const EXACT_VERSION = /^\d+\.\d+\.\d+$/; +const CANONICAL_LIST_FLOOR = "2026.8.20"; /** * Two independent systems enforce the pm CLI compatibility floor, and each reads @@ -91,6 +95,20 @@ test("the extension manifest declares the same floor the CLI actually enforces", peer.slice(">=".length), "manifest.json pm_min_version must equal the peerDependencies floor, or npm and the pm CLI enforce different minimums", ); + assert.strictEqual( + declared, + CANONICAL_LIST_FLOOR, + "the canonical list --all invocation requires the pm CLI 2026.8.20 host contract", + ); + assert.ok( + !Object.hasOwn(extensionManifest, "pm"), + "manifest.json must not carry the ignored legacy pm.compatibility envelope", + ); + assert.match( + changelog, + /BREAKING: pm-github now requires pm CLI 2026\.8\.20 or newer; older hosts may fail installation or runtime validation/, + "CHANGELOG.md must disclose the compatibility break enforced by the manifest and peer dependency", + ); }); test("the development dependency is an exact pin at or above the declared floor", () => { @@ -116,6 +134,11 @@ test("the development dependency is an exact pin at or above the declared floor" atOrAbove(dev, declared as string), `the pinned development CLI ${dev} is below the declared floor ${String(declared)}`, ); + assert.strictEqual( + dev, + CANONICAL_LIST_FLOOR, + "development must test the exact required host contract", + ); }); test("the version comparison orders YYYY.M.D numerically, not lexicographically", () => { diff --git a/test/complete-corpus.test.ts b/test/complete-corpus.test.ts index d8d44b50..e8d7248d 100644 --- a/test/complete-corpus.test.ts +++ b/test/complete-corpus.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; @@ -12,7 +12,7 @@ import { readPmItems, } from "../index.ts"; -/** Current complete `pm list-all --json` envelope, with caller overrides. */ +/** Current complete `pm list --all --json` envelope, with caller overrides. */ function completeEnvelope(overrides: Record = {}): Record { return { items: [ @@ -74,20 +74,25 @@ function runPm(args: string[], cwd?: string): string { test("whole-corpus argv requests strict full unbounded output without an arbitrary row ceiling", () => { const args = completePmListArgs("/workspace/.agents/pm"); assert.deepEqual(args, [ - "--path", + "--pm-path", "/workspace/.agents/pm", - "list-all", - "--json", - "--include-body", "--output-include", "full", - "--strict-read", "--output-limit", "unbounded", "--output-budget", "unbounded", + "list", + "--all", + "--json", + "--include-body", + "--strict-read", ]); - assert.ok(!args.includes("--limit"), "a list-all row ceiling makes the corpus incomplete by construction"); + assert.ok( + !args.includes("--limit"), + "a canonical whole-corpus row ceiling makes the read incomplete by construction", + ); + assert.ok(!args.includes("list-all"), "production must not invoke the deprecated list-all alias"); }); test("Windows strategy keeps shell metacharacters inside the pm workspace argument", () => { @@ -237,6 +242,50 @@ test("real installed CLI returns a complete open-and-closed corpus from a fresh "Open acceptance task", ]); + const result = spawnSync("pm", completePmListArgs(workspace), { + encoding: "utf8", + env: { ...process.env, PM_AUTHOR: "pm-github-acceptance" }, + shell: process.platform === "win32", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + result.stderr, + "", + "the canonical installed-CLI read must not emit deprecation diagnostics", + ); + const parsed = JSON.parse(result.stdout) as unknown; + const decodedItems = decodeCompletePmItems(parsed); + const envelope = parsed as { + items: Array<{ id?: unknown }>; + count: number; + total: number; + truncated: boolean; + has_more: boolean; + read_output: { command: string; legacy_aliases_used?: unknown[] }; + }; + assert.equal(decodedItems.length, envelope.count); + assert.equal(envelope.count, envelope.total); + assert.equal(envelope.truncated, false); + assert.equal(envelope.has_more, false); + assert.equal(envelope.read_output.command, "list"); + assert.ok( + !envelope.read_output.legacy_aliases_used?.includes("list-all"), + "the real receipt must not report the deprecated list-all alias", + ); + assert.ok(decodedItems.some((item) => item.id === created.id)); + + const priorMaxBuffer = process.env.PM_JSON_MAX_BUFFER; + process.env.PM_JSON_MAX_BUFFER = "1"; + try { + assert.throws( + () => readPmItems(workspace), + /Only raise the PM_JSON_MAX_BUFFER env var after confirming the workspace size and available memory\./, + ); + } finally { + if (priorMaxBuffer === undefined) delete process.env.PM_JSON_MAX_BUFFER; + else process.env.PM_JSON_MAX_BUFFER = priorMaxBuffer; + } + const items = readPmItems(workspace); assert.equal(items.length, 2); assert.deepEqual( diff --git a/test/fixtures/privacy-gate/README.md b/test/fixtures/privacy-gate/README.md new file mode 100644 index 00000000..ac849c43 --- /dev/null +++ b/test/fixtures/privacy-gate/README.md @@ -0,0 +1,3 @@ +{ + "justification-note": "Each key is the exact Git blob object id (git hash-object) of the synthetic fixture file beside this manifest; each justification is reviewed in the diff that adds it. Fixture content is synthetic by construction: every token below is a documented example value from public provider docs, padded to the documented length, and no real credential has ever existed here." +} diff --git a/test/fixtures/privacy-gate/manifest.json b/test/fixtures/privacy-gate/manifest.json new file mode 100644 index 00000000..9482924c --- /dev/null +++ b/test/fixtures/privacy-gate/manifest.json @@ -0,0 +1,23 @@ +{ + "dd8b1182a9031d73fe4a6565504c34fa4ff4bb50": { + "justification": "Synthetic review fixture test/fixtures/privacy-gate/synthetic-github-token.txt: token and path values are padded placeholder characters documented in README.md; no real credential or host path." + }, + "bb0e81b997c87e95ca38890a782bc6f8c23d0626": { + "justification": "Synthetic review fixture test/fixtures/privacy-gate/synthetic-fine-grained-token.txt: token and path values are padded placeholder characters documented in README.md; no real credential or host path." + }, + "39462a7a4944044aed4b081a840f762a65820f6a": { + "justification": "Synthetic review fixture test/fixtures/privacy-gate/synthetic-npm-token.txt: token and path values are padded placeholder characters documented in README.md; no real credential or host path." + }, + "5db8d6b58cfb1871086c502cc7bca26231282efb": { + "justification": "Synthetic review fixture test/fixtures/privacy-gate/synthetic-slack-token.txt: token and path values are padded placeholder characters documented in README.md; no real credential or host path." + }, + "6158029fb2dad669f7558e2e6030699a20e254b8": { + "justification": "Synthetic review fixture test/fixtures/privacy-gate/synthetic-aws-key.txt: token and path values are padded placeholder characters documented in README.md; no real credential or host path." + }, + "951f8e004778da90a76d98e011aa69aa85898dc5": { + "justification": "Synthetic review fixture test/fixtures/privacy-gate/synthetic-private-key.pem: token and path values are padded placeholder characters documented in README.md; no real credential or host path." + }, + "c93b7ed28c1dd97c0de125b8bbd34d0b7d0bcc07": { + "justification": "Synthetic review fixture test/fixtures/privacy-gate/synthetic-host-path.txt: token and path values are padded placeholder characters documented in README.md; no real credential or host path." + } +} diff --git a/test/fixtures/privacy-gate/synthetic-aws-key.txt b/test/fixtures/privacy-gate/synthetic-aws-key.txt new file mode 100644 index 00000000..6158029f --- /dev/null +++ b/test/fixtures/privacy-gate/synthetic-aws-key.txt @@ -0,0 +1 @@ +AWS_ACCESS_KEY_ID=AKIAAAAAAAAAAAAAAAAA diff --git a/test/fixtures/privacy-gate/synthetic-fine-grained-token.txt b/test/fixtures/privacy-gate/synthetic-fine-grained-token.txt new file mode 100644 index 00000000..bb0e81b9 --- /dev/null +++ b/test/fixtures/privacy-gate/synthetic-fine-grained-token.txt @@ -0,0 +1 @@ +GITHUB_TOKEN=github_pat_yyyyyyyyyyyyyyyyyyyyyy diff --git a/test/fixtures/privacy-gate/synthetic-github-token.txt b/test/fixtures/privacy-gate/synthetic-github-token.txt new file mode 100644 index 00000000..dd8b1182 --- /dev/null +++ b/test/fixtures/privacy-gate/synthetic-github-token.txt @@ -0,0 +1 @@ +GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/test/fixtures/privacy-gate/synthetic-host-path.txt b/test/fixtures/privacy-gate/synthetic-host-path.txt new file mode 100644 index 00000000..c93b7ed2 --- /dev/null +++ b/test/fixtures/privacy-gate/synthetic-host-path.txt @@ -0,0 +1 @@ +run node /home/example-user/container/app/build.js diff --git a/test/fixtures/privacy-gate/synthetic-npm-token.txt b/test/fixtures/privacy-gate/synthetic-npm-token.txt new file mode 100644 index 00000000..39462a7a --- /dev/null +++ b/test/fixtures/privacy-gate/synthetic-npm-token.txt @@ -0,0 +1 @@ +NPM_TOKEN=npm_nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn diff --git a/test/fixtures/privacy-gate/synthetic-private-key.pem b/test/fixtures/privacy-gate/synthetic-private-key.pem new file mode 100644 index 00000000..951f8e00 --- /dev/null +++ b/test/fixtures/privacy-gate/synthetic-private-key.pem @@ -0,0 +1,3 @@ +-----BEGIN RSA PRIVATE KEY----- +synthetic +-----END RSA PRIVATE KEY----- diff --git a/test/fixtures/privacy-gate/synthetic-slack-token.txt b/test/fixtures/privacy-gate/synthetic-slack-token.txt new file mode 100644 index 00000000..5db8d6b5 --- /dev/null +++ b/test/fixtures/privacy-gate/synthetic-slack-token.txt @@ -0,0 +1 @@ +SLACK_TOKEN=xoxb-sssssssssss diff --git a/test/handler-failures.test.ts b/test/handler-failures.test.ts index a1005edf..f1436afd 100644 --- a/test/handler-failures.test.ts +++ b/test/handler-failures.test.ts @@ -71,7 +71,7 @@ function pmSetup(root: string, args: readonly string[]): void { // A PATH that finds the local `pm` shim + `node` but NOT `gh`, so the // token-resolution fallback (`gh auth token`) deterministically fails while -// `pm list-all` still works. Used for the no-token guards whose handlers read +// `pm list --all` still works. Used for the no-token guards whose handlers read // the tracker before checking the token. const PM_BIN_DIR = fileURLToPath(new URL("../node_modules/.bin", import.meta.url)); const GH_FREE_PATH = `${PM_BIN_DIR}${path.delimiter}${path.dirname(process.execPath)}`; @@ -102,7 +102,11 @@ function createLinkedItem(root: string, title: string, tag: string, status: stri ); assert.strictEqual(r.status, 0, `pm create failed: ${r.error?.message ?? r.stderr}`); // Re-read to get the assigned id. - const list = spawnSync(PM_BIN, ["--path", root, "--json", "list-all", "--full"], PM_SPAWN_OPTS); + const list = spawnSync( + PM_BIN, + ["--pm-path", root, "--json", "--output-include", "full", "list", "--all"], + PM_SPAWN_OPTS, + ); const parsed = JSON.parse(list.stdout) as { items?: Array<{ id: string; tags?: string[] }> }; const item = (parsed.items ?? []).find((i) => (i.tags ?? []).includes(tag)); assert.ok(item, `created item carrying ${tag} should be listable`); @@ -1099,7 +1103,11 @@ function upstreamIssue(number: number, title: string, state: "open" | "closed"): /** Read a pm item's status by its provenance tag (for reconciliation asserts). */ function statusForTag(root: string, tag: string): string | undefined { - const r = spawnSync(PM_BIN, ["--path", root, "--json", "list-all", "--full"], PM_SPAWN_OPTS); + const r = spawnSync( + PM_BIN, + ["--pm-path", root, "--json", "--output-include", "full", "list", "--all"], + PM_SPAWN_OPTS, + ); const parsed = JSON.parse(r.stdout) as { items?: Array<{ status?: string; tags?: string[] }> }; return (parsed.items ?? []).find((i) => (i.tags ?? []).includes(tag))?.status; } @@ -1189,7 +1197,11 @@ function listTitles(root: string): string[] { /** Read every pm item (including closed) with its status and close_reason. */ function listAllStatuses(root: string): Array<{ id: string; title: string; status: string; close_reason?: string }> { - const r = spawnSync(PM_BIN, ["--path", root, "--json", "list-all", "--full"], PM_SPAWN_OPTS); + const r = spawnSync( + PM_BIN, + ["--pm-path", root, "--json", "--output-include", "full", "list", "--all"], + PM_SPAWN_OPTS, + ); const parsed = JSON.parse(r.stdout); const arr = Array.isArray(parsed) ? parsed : (parsed.items ?? parsed.results ?? []); return (arr as Array<{ id: string; title: string; status: string; close_reason?: string }>); diff --git a/test/privacy-gate.test.ts b/test/privacy-gate.test.ts new file mode 100644 index 00000000..64c8c16b --- /dev/null +++ b/test/privacy-gate.test.ts @@ -0,0 +1,390 @@ +/** + * Behavioral coverage for the privacy gate script. + * + * The gate is a fail-closed release gate, so the tests exercise real git + * repositories built in temporary directories — never mocks of git itself: + * + * - the clean path (this repository's rewritten history passes), + * - every failure path (unapproved author, committer, tagger; each secret + * rule; host paths), each proven by committing the offending content into a + * throwaway repository and asserting the gate fails naming the rule, + * - the fixture exemption path, proven by registering a synthetic blob and + * observing the same content fail once the exemption is removed, + * - fail-closed behavior when the allowlist is missing or git cannot run. + * + * Assertions run against {@link runGate}'s returned strings or captured + * process streams. Findings must never contain matched values: the tests + * assert the offending content does not appear in the gate output. + */ + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { + extractEmail, + gitBlobOid, + type PrivacyGateResult, + runGate, + scanBlob, +} from "../scripts/privacy-gate.ts"; + +/** Repository root of pm-github itself (the clean-path subject). */ +const repoRoot = resolve(import.meta.dirname, ".."); + +/** + * Initializes a throwaway git repository with a deliberately unapproved + * committer identity so every commit created inside it also exercises the + * identity rules. + * + * @param prefix - Temp-name prefix handed to `mkdtempSync`. + * @returns Absolute path of the initialized repository. + */ +function initRepo(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), `pm-github-privacy-${prefix}-`)); + const run = (args: readonly string[]): void => { + execFileSync("git", args, { cwd: root }); + }; + run(["init", "-q", "-b", "main"]); + run(["config", "user.name", "Privacy Fixture"]); + // Unapproved on purpose: any commit here must trip the identity gate unless + // the test also writes an allowlist that approves it. + run(["config", "user.email", "intruder@localhost"]); + return root; +} + +/** + * Writes an approving allowlist into a temp repository so identity findings + * can be isolated from blob findings per test. + * + * @param root - Temp repository path. + * @param emails - Emails to approve, one per line. + */ +function writeAllowlist(root: string, emails: readonly string[]): void { + mkdirSync(join(root, ".github"), { recursive: true }); + writeFileSync( + join(root, ".github", "approved-git-identities.txt"), + ["# synthetic allowlist", ...emails].join("\n") + "\n", + ); +} + +/** + * Commits the current worktree state in a temp repository. + * + * @param root - Temp repository path. + * @param message - Commit message to use. + */ +function commitAll(root: string, message: string): void { + execFileSync("git", ["add", "-A"], { cwd: root }); + execFileSync("git", ["commit", "-q", "-m", message], { cwd: root }); +} + +test("privacy gate passes on this repository's clean rewritten history", () => { + const result = runGate(repoRoot); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /audited/); + assert.equal(result.stderr, ""); +}); + +test("privacy gate fails closed when the allowlist file is missing", () => { + const root = initRepo("no-allowlist"); + try { + const result = runGate(root); + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /cannot read .*approved-git-identities/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("privacy gate fails closed when the path is not a git repository", () => { + const root = mkdtempSync(join(tmpdir(), "pm-github-privacy-notrepo-")); + try { + writeAllowlist(root, ["someone@example.com"]); + const result = runGate(root); + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /object enumeration failed/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("privacy gate reports unapproved commit authors and committers without printing emails", () => { + const root = initRepo("identity"); + try { + writeAllowlist(root, ["maintainer@example.com"]); + writeFileSync(join(root, "clean.txt"), "nothing suspicious\n"); + commitAll(root, "committed by an unapproved identity"); + const result = runGate(root); + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /identity:commit/); + assert.ok(!result.stderr.includes("intruder@localhost"), "must not print the email value"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("privacy gate accepts identities listed in the allowlist", () => { + const root = initRepo("approved-identity"); + try { + writeAllowlist(root, ["intruder@localhost"]); + writeFileSync(join(root, "clean.txt"), "nothing suspicious\n"); + commitAll(root, "committed by an approved-for-this-test identity"); + const result = runGate(root); + assert.equal(result.exitCode, 0, result.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("privacy gate reports unapproved annotated-tag taggers", () => { + const root = initRepo("tagger"); + try { + writeAllowlist(root, ["maintainer@example.com"]); + writeFileSync(join(root, "clean.txt"), "nothing suspicious\n"); + commitAll(root, "base commit with an identity approved below"); + // Approve the commit identity so the tag finding is isolated. + writeAllowlist(root, ["intruder@localhost"]); + // Override just the tagger via env so the tag carries a distinct, + // unapproved address while the commits stay approved. + const env = { + ...process.env, + GIT_COMMITTER_NAME: "Tagger Fixture", + GIT_COMMITTER_EMAIL: "tagger-intruder@localhost", + }; + execFileSync("git", ["tag", "-a", "v9.9.8-fixture", "-m", "fixture tag two"], { cwd: root, env }); + const result = runGate(root); + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /identity:tag/); + assert.ok(!result.stderr.includes("tagger-intruder@localhost"), "must not print the email value"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +for (const [file, rule] of [ + ["synthetic-github-token.txt", "github-token-classic"], + ["synthetic-fine-grained-token.txt", "github-token-fine-grained"], + ["synthetic-npm-token.txt", "npm-token"], + ["synthetic-slack-token.txt", "slack-token"], + ["synthetic-aws-key.txt", "aws-access-key-id"], + ["synthetic-private-key.pem", "private-key-pem"], + ["synthetic-host-path.txt", "host-path"], +] as const) { + test(`privacy gate flags ${rule} committed into history`, () => { + const root = initRepo(`rule-${rule}`); + try { + // Approve the fixture committer so only the blob rule can fire. + writeAllowlist(root, ["intruder@localhost"]); + const content = readFileSync(join(repoRoot, "test", "fixtures", "privacy-gate", file), "utf8"); + writeFileSync(join(root, "leak.txt"), content); + commitAll(root, `commit carrying a ${rule} fixture`); + const result = runGate(root); + assert.equal(result.exitCode, 1, result.stderr); + assert.match(result.stderr, new RegExp(rule)); + // The matched value must never appear in the report. + assert.ok(!result.stderr.includes(content.trim()), "must not print matched content"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test("privacy gate exempts exactly the manifest-registered fixture blobs", () => { + const root = initRepo("exemption"); + try { + writeAllowlist(root, ["intruder@localhost"]); + const content = readFileSync( + join(repoRoot, "test", "fixtures", "privacy-gate", "synthetic-host-path.txt"), + "utf8", + ); + const manifest = JSON.parse( + readFileSync(join(repoRoot, "test", "fixtures", "privacy-gate", "manifest.json"), "utf8"), + ) as Record; + const oid = gitBlobOid(content); + assert.ok(manifest[oid], "fixture must be registered in the checked-in manifest"); + assert.match(manifest[oid]?.justification ?? "", /Synthetic review fixture/); + + // With no exemption map, the content is flagged... + assert.deepEqual(scanBlob(oid, content, new Map()).map((f) => f.rule), ["host-path"]); + // ...and with the registered exemption it is silent. + assert.deepEqual(scanBlob(oid, content, new Map([[oid, "justified"]])), []); + // A different exemption key must not silence it. + assert.equal(scanBlob(oid, content, new Map([["deadbeef", "justified"]])).length, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("gitBlobOid agrees with git hash-object for identical bytes", () => { + const content = "cross-check\nwith unicode: äöü ✓\n"; + const expected = execFileSync( + "git", + ["hash-object", "--stdin"], + { input: content, encoding: "utf8" }, + ).trim(); + assert.equal(gitBlobOid(content), expected); +}); + +test("extractEmail parses git header shapes and rejects malformed lines", () => { + assert.equal(extractEmail("author Name 1700000000 +0100"), "a@example.com"); + assert.equal(extractEmail("committer Bot 1700000000 +0000"), "bot@example.com"); + assert.equal(extractEmail("tagger T 1700000000 +0000"), "t@example.com"); + assert.equal(extractEmail("no angle brackets here"), undefined); + assert.equal(extractEmail("reversed >brackets< here"), undefined); +}); + +test("privacy gate CLI entry point writes streams and sets exit code", async () => { + const { main } = await import("../scripts/privacy-gate.ts"); + const cleanRoot = repoRoot; + let stdout = ""; + let stderr = ""; + const originalWrite = process.stdout.write.bind(process.stdout); + const originalErrWrite = process.stderr.write.bind(process.stderr); + const originalExit = process.exitCode; + process.stdout.write = (chunk: Uint8Array | string): boolean => { + stdout += String(chunk); + return true; + }; + process.stderr.write = (chunk: Uint8Array | string): boolean => { + stderr += String(chunk); + return true; + }; + try { + main(cleanRoot); + assert.equal(process.exitCode, 0); + assert.match(stdout, /audited/); + assert.equal(stderr, ""); + + stdout = ""; + stderr = ""; + const failingRoot = initRepo("cli-fail"); + try { + main(failingRoot); + assert.equal(process.exitCode, 1); + assert.match(stderr, /cannot read/); + } finally { + rmSync(failingRoot, { recursive: true, force: true }); + } + } finally { + process.stdout.write = originalWrite; + process.stderr.write = originalErrWrite; + process.exitCode = originalExit; + } +}); + +test("privacy gate verifies identity headers with malformed separators", () => { + const root = initRepo("separator-identity"); + try { + writeAllowlist(root, ["intruder@localhost"]); + writeFileSync(join(root, "clean.txt"), "nothing suspicious\n"); + commitAll(root, "base commit with an approved identity"); + const tree = execFileSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: root }).toString().trim(); + const parent = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root }).toString().trim(); + // Craft shapes a naive `role ` prefix check would skip: + // no space after the role keyword, and a tab separator. + for (const [label, authorLine] of [ + ["no-space", "author 1700000000 +0000"], + ["tab", "committer\tTab Fixture 1700000000 +0000"], + ] as const) { + const crafted = `tree ${tree}\nparent ${parent}\n${authorLine}\ncommitter X 1700000000 +0000\n\ncrafted ${label} separator\n`; + const oid = execFileSync("git", ["hash-object", "-t", "commit", "-w", "--stdin", "--literally"], { + cwd: root, + input: crafted, + }).toString().trim(); + execFileSync("git", ["update-ref", `refs/heads/evil-${label}`, oid], { cwd: root }); + const result = runGate(root); + assert.equal(result.exitCode, 1, `${label} separator must fail the gate`); + assert.match(result.stderr, /identity:commit/); + execFileSync("git", ["update-ref", `-d`, `refs/heads/evil-${label}`], { cwd: root }); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("privacy gate does not flag message lines that begin with an identity keyword", () => { + const root = initRepo("message-keyword"); + try { + writeAllowlist(root, ["intruder@localhost"]); + writeFileSync(join(root, "clean.txt"), "nothing suspicious\n"); + execFileSync("git", ["add", "-A"], { cwd: root }); + execFileSync( + "git", + ["commit", "-q", "-m", "author lines in the message body must not be scanned\n\nauthor something@example.com in body"], + { cwd: root }, + ); + const result = runGate(root); + assert.equal(result.exitCode, 0, result.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("privacy gate fails closed on an unparseable identity header", () => { + const root = initRepo("malformed-identity"); + try { + writeAllowlist(root, ["intruder@localhost"]); + writeFileSync(join(root, "clean.txt"), "nothing suspicious\n"); + commitAll(root, "base commit with an approved identity"); + // Hand-craft a commit whose author header carries no angle-bracketed + // email, exactly the shape a crafted object would use to dodge parsing. + const tree = execFileSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: root }).toString().trim(); + const parent = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root }).toString().trim(); + const crafted = `tree ${tree}\nparent ${parent}\nauthor malformed-no-email 1700000000 +0000\ncommitter malformed-no-email 1700000000 +0000\n\ncrafted commit with an unverifiable identity\n`; + const oid = execFileSync("git", ["hash-object", "-t", "commit", "-w", "--stdin", "--literally"], { + cwd: root, + input: crafted, + }).toString().trim(); + execFileSync("git", ["update-ref", "refs/heads/evil", oid], { cwd: root }); + const result = runGate(root); + assert.equal(result.exitCode, 1); + assert.match(result.stderr, new RegExp(`${oid} identity:commit`.replace(/([.*+?^${}()|[\]\\])/g, "\\$&"))); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("privacy gate ignores manifest exemptions for blobs outside the fixture directory", () => { + const root = initRepo("exemption-abuse"); + try { + writeAllowlist(root, ["intruder@localhost"]); + // Commit a leak-shaped blob OUTSIDE the fixture directory... + const leak = "token=ghp_" + "q".repeat(36) + "\n"; + writeFileSync(join(root, "leak.txt"), leak); + commitAll(root, "leak committed outside fixtures"); + // ...then try to silence it through a manifest entry alone. + mkdirSync(join(root, "test", "fixtures", "privacy-gate"), { recursive: true }); + writeFileSync( + join(root, "test", "fixtures", "privacy-gate", "manifest.json"), + JSON.stringify({ [gitBlobOid(leak)]: { justification: "abuse attempt" } }) + "\n", + ); + const result = runGate(root); + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /github-token-classic/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("privacy gate negative control: a fresh violation introduced after a clean pass fails", () => { + const root = initRepo("negative-control"); + try { + writeAllowlist(root, ["intruder@localhost"]); + writeFileSync(join(root, "ok.txt"), "clean content\n"); + commitAll(root, "clean commit"); + assert.equal(runGate(root).exitCode, 0); + + // Introduce a violation shaped like a real leak after the clean baseline. + writeFileSync(join(root, "leak.txt"), "token=ghp_" + "z".repeat(36) + "\n"); + commitAll(root, "introduce a leak-shaped secret"); + const after: PrivacyGateResult = runGate(root); + assert.equal(after.exitCode, 1); + assert.match(after.stderr, /github-token-classic/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});