diff --git a/TODO.md b/TODO.md index 91cac44..dd0ff30 100644 --- a/TODO.md +++ b/TODO.md @@ -2,13 +2,30 @@ ## Active tickets -None. +- [ ] [`ticket-048`](project/ticket-048/README.md) — republishes the ticket-047 + GitHub acquisition adapter through a route governance accepts, with the plan + committed before the implementation, and removes the two `process.env` + fallbacks whose `verify:env` requirement forced an edit to `.env.example`, a + path no workstream owns. Current state: `IN_PROGRESS / VALIDATION`. Product + checks and structural governance pass on PR #66; merge remains blocked on + trusted `GOV-APPROVAL` (Validator App or trusted human) for exact head + `95a4d91…`. Autonomy path analysis: [ticket-049](project/ticket-049/README.md). ## Backlog tickets -None. +Follow-up PLAN tickets for autonomy/refactor live on branch +`ticket/049-validator-autonomy-plan` (tickets 049–052). They are intentionally +not mixed into this PR so ticket-048 keeps exclusive write scope. ## Completed tickets +- [x] [`ticket-047`](project/ticket-047/README.md) — built the first GitHub + acquisition adapter for the `t2c.event-log/v1` codec, mapping one bounded + `push`, `pull_request`, `pull_request_review` or completed `workflow_run` + payload onto the closed event vocabulary. Host, governance, Docker and + focused checks passed locally, but the work never reached protected `main`: + it carries no Koru or Validator approval, and its squashed commits are + rejected by CI governance. Republication is tracked as ticket-048. Current + state: `DONE`. - [x] [`ticket-046`](project/ticket-046/README.md) — generates a canonical, atomic `logs.dsl.txt` beside every succeeded, degraded and failed pipeline manifest. Koru and Validator approved exact head `1180e45` with diff --git a/docs/EVENT_LOG_DSL.md b/docs/EVENT_LOG_DSL.md index d925e8a..b86e7ea 100644 --- a/docs/EVENT_LOG_DSL.md +++ b/docs/EVENT_LOG_DSL.md @@ -224,3 +224,77 @@ The dependent runtime ticket must: 4. publish the log as a workflow artifact and bind it to evaluation/attestation; 5. add GitHub event acquisition separately, using least-privilege API fields; 6. prove repeated rendering of identical inputs is byte-for-byte stable. + +## GitHub event acquisition boundary (ticket-047, ticket-048) + +This repository now defines a dedicated, bounded boundary: + +`node scripts/github-event-log.mjs` + +Input, all of it explicit: + +* one GitHub Actions JSON payload (`--event-path`, required) +* one event name (`push|pull_request|pull_request_review|workflow_run`) +* explicit `--output` path for the produced `logs.dsl.txt` +* `--repository`, unless the payload itself carries `repository.full_name` + +The script reads **no environment variable**. Earlier revisions fell back to +`GITHUB_EVENT_PATH` and `GITHUB_REPOSITORY`; ticket-048 removed both, so a +caller can never silently acquire ambient process state instead of the payload +it named. Callers pass the values, including from Actions: +`--event-path "$GITHUB_EVENT_PATH"`. + +That is also why `.env.example` declares neither key. `verify:env` derives its +required keys by scanning `scripts/**` for `process.env` reads, and +`.env.example` is owned by no workstream in `.governance/manifest.json`, whose +hashes are locked to the pinned upstream standard. An acquisition boundary that +reads the environment therefore cannot be published at all. + +Behavior: + +* no payload is committed to `main` from this script, +* only allowlisted fields are normalized and projected into evidence, +* unsupported events/actions fail closed, +* missing required flags fail closed with a named error, +* every emitted event records `SOURCE "github-actions"`, never `github-api`: + the adapter reads a delivered payload and makes no API call, and claiming + provenance it does not have would violate the contract's rule that missing + knowledge is never guessed, +* SHA/actor/repository/ticket/relation bindings are validated, +* emitted trust class is `SYSTEM_FACT`, +* output is immutable via the existing `t2c.event-log/v1` atomic writer. + +### Publication and autonomy note + +Ticket-048 publishes the adapter script and tests; it does **not** wire a +GitHub Actions job (follow-up: ticket-051 / plan ticket-049). Merging still +requires trusted review evidence (`GOV-APPROVAL`) from outside this +repository's PR checkout. Do not add a workflow here that dispatches the +Validator App against itself (ticket-018 trust root). + +**Where operators look (external repos, not this tree):** + +| Need | Where | +| --- | --- | +| Why is the PR blocked / what NEXT? | `subactor/twin-probes` probe **`publication.gate`** — docs: `docs/PUBLICATION_PROBE.md`, map: `docs/ECOSYSTEM.md` | +| Exact-head freeze + dispatch | `subactor/validator-agent` — `bin/dispatch-direct-pr.sh`, `docs/PUBLICATION_FREEZE.md` | +| App review (trust root) | `ifuri-validator-agent[bot]` via validator-agent `direct-pr` / `scan-direct` | +| Plan ticket in this repo | `project/ticket-049` (branch `ticket/049-validator-autonomy-plan`, PR #67) | + +`publication.gate` is a **measurement probe**, not a separate product and not a +merge gate. Local green tests do not replace hosted checks when GitHub Actions +is degraded. + +Invocation example: + +```bash +node scripts/github-event-log.mjs \ + --event-name pull_request \ + --event-path "$GITHUB_EVENT_PATH" \ + --repository "semcod/todo2code" \ + --ticket "ticket-047" \ + --recorded-at "$GITHUB_EVENT_TIME" \ + --correlation-id "$GITHUB_RUN_ID" \ + --stream-id "todo2code/github" \ + --output "artifacts/logs.dsl.txt" +``` diff --git a/project/TICKETS.md b/project/TICKETS.md index da37ce7..05f378d 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -40,4 +40,6 @@ of `project/README.md`, which remains a generated technical-analysis artifact. | **ticket-044** | [`README.md`](./ticket-044/README.md) | [`preprompt.md`](./ticket-044/preprompt.md) | - | [`ai-codex.md`](./ticket-044/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-044/ai-codex-logs.txt) | [`changelog.md`](./ticket-044/changelog.md) | | **ticket-045** | [`README.md`](./ticket-045/README.md) | [`preprompt.md`](./ticket-045/preprompt.md) | - | [`ai-codex.md`](./ticket-045/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-045/ai-codex-logs.txt) | [`changelog.md`](./ticket-045/changelog.md) | | **ticket-046** | [`README.md`](./ticket-046/README.md) | [`preprompt.md`](./ticket-046/preprompt.md) | - | [`ai-codex.md`](./ticket-046/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-046/ai-codex-logs.txt) | [`changelog.md`](./ticket-046/changelog.md) | +| **ticket-047** | [`README.md`](./ticket-047/README.md) | [`preprompt.md`](./ticket-047/preprompt.md) | - | [`ai-codex.md`](./ticket-047/ai-codex.md) | [`ai-codex-logs.txt`](./ticket-047/ai-codex-logs.txt) | [`changelog.md`](./ticket-047/changelog.md) | +| **ticket-048** | [`README.md`](./ticket-048/README.md) | [`preprompt.md`](./ticket-048/preprompt.md) | - | [`ai-claude.md`](./ticket-048/ai-claude.md) | [`ai-claude-logs.txt`](./ticket-048/ai-claude-logs.txt) | [`changelog.md`](./ticket-048/changelog.md) | diff --git a/project/ticket-047/README.md b/project/ticket-047/README.md new file mode 100644 index 0000000..a3aa018 --- /dev/null +++ b/project/ticket-047/README.md @@ -0,0 +1,83 @@ +# Ticket 047: Collect bounded GitHub evidence into event logs + +- **ID**: ticket-047 +- **Owner**: unresolved:human +- **Status**: DONE +- **Workflow state**: DONE +- **Created**: 2026-08-05 + +## Goal and scope + +Add the first GitHub acquisition adapter for the existing +`t2c.event-log/v1` codec. A dependency-free Node script will accept one +bounded GitHub Actions event payload, copy only event-specific allowlisted +fields into canonical evidence, map the observed transition to the closed v1 +event vocabulary and publish one immutable workflow-run `logs.dsl.txt`. + +This ticket creates no new evaluation DSL and performs no GitHub API calls. +It is the integration boundary between retained GitHub payload evidence and +the runtime codec delivered by ticket-046. A later governance ticket may wire +the script into GitHub Actions without duplicating acquisition or validation. + +## Acceptance criteria + +- [x] AC-01: The one-event-payload/one-workflow-artifact architecture, event + mappings and fail-closed unsupported-event behavior are approved by a human + owner. +- [x] AC-02: The collector deterministically maps supported `push`, + `pull_request`, `pull_request_review` and completed `workflow_run` payloads + to the existing closed `t2c.event-log/v1` types and rejects unsupported + actions rather than inventing semantics. +- [x] AC-03: Evidence bytes are canonical JSON made only from allowlisted + GitHub fields; raw webhook payloads, environment dumps, query credentials, + secrets and host paths never enter `logs.dsl.txt`. +- [x] AC-04: The script validates repository, ticket, base/head SHA and actor + bindings, then reuses the ticket-046 codec and atomic writer to publish one + parseable immutable workflow artifact. +- [x] AC-05: A review is recorded only as `SYSTEM_FACT`; ordinary GitHub review + state cannot become `TRUSTED_ATTESTATION`, and no LLM output can grant + approval. +- [x] AC-06: Golden, negative, repeatability, full host, governance and Docker + checks pass without a dependency or public-interface change. + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-codex.md](ai-codex.md) + +## Architecture and bounds + +- Component 1: `scripts/github-event-log.mjs` owns acquisition and explicit + GitHub event/action mappings. It imports the built ticket-046 codec; it does + not implement a second renderer, validator or hash chain. +- Component 2: the existing workflow-validation test plus one bounded payload + fixture prove mapping, canonical evidence projection, safety, trust classes + and deterministic bytes. `docs/EVENT_LOG_DSL.md` documents the producer + boundary and the later workflow handoff. +- Every invocation observes one immutable GitHub event payload and produces one + immutable artifact. Lifecycle history is a set of attributable workflow-run + streams; completed logs are never appended or committed back to `main`. +- Supported mappings are deliberately closed: push/branch deletion and commit + facts, PR open/synchronize/merge/close, PR review state, and completed + workflow checks. Missing ticket or SHA knowledge remains `null`; it is not + guessed from narrative. +- Complexity class: S; maximum 30 minutes, four implementation files, two + affected components, no public interface or runtime dependency change. + +## Non-goals + +- No edit to `.github/workflows/**`, branch protection, reusable governance or + GitHub repository settings. +- No GitHub API polling, historical reconstruction, cross-run append or commit + of generated `logs.dsl.txt` artifacts. +- No new event vocabulary, public CLI/package command, SDK surface, runtime + dependency or trusted approval policy. + +## Approval boundary + +The human owner approved ticket-047 on 2026-08-05, allowing execution of the +declared bounded acquisition architecture and test scope. This authorizes the +three-implementation-file boundary in `intent.json`. + +Implementation now starts from this approved scope; one immutable workflow-run +`logs.dsl.txt` per payload remains the single persistence target. diff --git a/project/ticket-047/ai-codex-logs.txt b/project/ticket-047/ai-codex-logs.txt new file mode 100644 index 0000000..56661e6 --- /dev/null +++ b/project/ticket-047/ai-codex-logs.txt @@ -0,0 +1,19 @@ +2026-08-05 APPROVAL: human owner explicitly approved ticket-047 +transition: PLAN / WAIT_FOR_APPROVAL -> IN_PROGRESS / EDIT +2026-08-05 boundary: acquisition adapter maps one payload; the ticket-046 codec stays the single renderer, validator, digest chain and atomic writer +2026-08-05 implementation ran in a detached git worktree on branch ticket/047-github-event-log +2026-08-05 fixture correction: push.before was a 48-character literal; reduced to a full 40-character SHA +2026-08-05 defect fix: workflow_run mapping referenced an undefined conclusion binding; bound to the normalized value +2026-08-05 test correction: workflow-validation cases guarded optional event indexing to satisfy strict TypeScript +2026-08-05 validation: focused workflow-validation 9/9; npm run build PASS +2026-08-05 validation: governance 0 errors/0 warnings; docker smoke PASS; git diff --check PASS +2026-08-05 governance gap: verify:env failed because GITHUB_EVENT_PATH and GITHUB_REPOSITORY were undeclared in .env.example +2026-08-05 follow-up 4428ec2: declared both keys in .env.example; make verify 401/401 PASS +transition: IN_PROGRESS / EDIT -> DONE +2026-08-05 record gap: the cherry-pick onto main resolved TODO.md with --ours, dropping the TODO.md and project/TICKETS.md entries and the preprompt.md and ai-codex-logs.txt scaffold files +2026-08-06 publication attempt: direct push to main rejected by branch protection +2026-08-06 publication attempt: PR #64 opened from the squashed commits; CI governance failed GOV-TICKET-001 because a DONE ticket holds no active-ticket authority over implementation paths +2026-08-06 finding: the squashed branch also fails GOV-INTENT-003 because intent.json and the implementation landed in one commit; the original two-commit branch ticket/047-github-event-log passes CI governance cleanly +2026-08-06 finding: .env.example is owned by no workstream in .governance/manifest.json, and the manifest is hash-locked to the upstream standard, so the 4428ec2 change cannot be claimed by any ticket; its cause is the two process.env fallbacks in scripts/github-event-log.mjs +2026-08-06 PR #64 and PR #65 closed and their branches deleted; the record was restored on main instead +2026-08-06 NOT PERFORMED: no Koru review, no Validator App attestation, no protected-check run and no merge to main were recorded for this ticket diff --git a/project/ticket-047/ai-codex.md b/project/ticket-047/ai-codex.md new file mode 100644 index 0000000..718533d --- /dev/null +++ b/project/ticket-047/ai-codex.md @@ -0,0 +1,50 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-047 +--- +# Participant: codex (AI agent) + +## Understanding + +Ticket-046 intentionally stopped before GitHub acquisition. The existing codec +already owns the DSL grammar, evidence/event hashes, strict validation and +atomic publication, so this ticket needs only an adapter at the integration +boundary. Editing a workflow in the same ticket would overlap the governance +workstream and make the change harder to review and finish within 30 minutes. + +## Execution plan + +1. Obtain explicit approval for the closed mappings and workflow-artifact + boundary. +2. Implement one dependency-free GitHub payload adapter that allowlists fields + before creating runtime event inputs. +3. Delegate rendering, validation, chain construction and atomic writing to the + built ticket-046 codec. +4. Add bounded fixtures and focused tests for supported mappings, rejection, + evidence safety, trust class and byte stability. +5. Document how a later governance ticket invokes the collector without + committing or appending generated artifacts. +6. Run focused, full host, governance and Docker checks before exact-head + independent review. + +## Actual changes + +- Human approval received; ticket transitioned from + `PLAN / WAIT_FOR_APPROVAL` to `IN_PROGRESS / EDIT`. +- Declared implementation boundary remains `project/ticket-047`, with no public + interface changes and no workflow or API polling. +- Implementing a bounded GitHub event acquisition adapter that emits one + canonical stream per payload and reuses the ticket-046 `t2c.event-log/v1` + codec and atomic writer. +- Added deterministic mapping for supported event/action combinations + (`push`, `pull_request`, `pull_request_review`, `workflow_run`), explicit + rejection of unsupported transitions, and canonicalized allowlisted evidence + projections. +- Added focused integration tests proving deterministic replay, SYSTEM_FACT review + recording and fail-closed unsupported actions. + +## Blockers + +- Implementation is complete in the approved scope; no blockers remain. diff --git a/project/ticket-047/changelog.md b/project/ticket-047/changelog.md new file mode 100644 index 0000000..c05f1e2 --- /dev/null +++ b/project/ticket-047/changelog.md @@ -0,0 +1,23 @@ +# Ticket Changelog (ticket-047) + +## [0.2.0] - 2026-08-05 + +- Ticket-047 was explicitly approved and moved to `IN_PROGRESS / EDIT`. +- Declared one-event-payload acquisition adapter boundary for push, PR, PR review + and completed workflow_run payloads. +- Reused the ticket-046 codec and atomic publication contract for + `t2c.event-log/v1` streams. +- Added deterministic GitHub→event mapping with strict allowlisted evidence + projections and repository/ticket/sha/actor validation. +- Added bounded integration tests for mapping, repeatability, review trust class, + unsupported-event fail-closed behavior and evidence sanitization. + +## [0.1.0] - 2026-08-05 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Defined a bounded GitHub payload acquisition plan dependent on ticket-046. +- Split acquisition from later workflow wiring so integration and governance + paths do not overlap in one ticket. +- Kept the existing `t2c.event-log/v1` codec as the single renderer, validator, + digest-chain and atomic-publication authority. diff --git a/project/ticket-047/intent.json b/project/ticket-047/intent.json new file mode 100644 index 0000000..5814bcc --- /dev/null +++ b/project/ticket-047/intent.json @@ -0,0 +1,101 @@ +{ + "schema": "new-project.intent/v2", + "ticket": "ticket-047", + "summary": "Collect bounded GitHub evidence into event logs", + "workstream": "integration", + "allowedPaths": [ + "project/ticket-047/**", + "TODO.md", + "project/TICKETS.md", + "scripts/github-event-log.mjs", + "test/workflow-validation.test.ts", + "test/fixtures/event-log/v1/github-event-payloads.json", + "docs/EVENT_LOG_DSL.md" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + ".github/workflows/**", + ".governance/**", + "src/**", + "package.json", + "package-lock.json", + "sdk/**" + ], + "stacks": ["node", "docker"], + "dependsOn": ["ticket-046"], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "b8392f76592138e006ca5dff9af52082208acee5", + "targetBranch": "main", + "outcome": "Convert one bounded GitHub Actions event payload into one canonical immutable workflow logs.dsl.txt artifact", + "nonGoals": [ + "No GitHub workflow or repository-setting change", + "No GitHub API polling or historical reconstruction", + "No mutation or commit of a completed event log", + "No new DSL, public interface, dependency or LLM-derived approval" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "A dependency-free integration script allowlists one GitHub event payload into canonical evidence and delegates all DSL rendering, validation, hashing and atomic publication to the ticket-046 runtime codec; a later governance ticket only wires this stable command into Actions", + "components": [ + { + "name": "github-event-acquisition-adapter", + "paths": ["scripts/github-event-log.mjs"] + }, + { + "name": "github-event-acquisition-evidence", + "paths": [ + "test/workflow-validation.test.ts", + "test/fixtures/event-log/v1/github-event-payloads.json", + "docs/EVENT_LOG_DSL.md" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [ + "Each supported GitHub Actions event can produce a separate immutable workflow-run logs.dsl.txt", + "Evidence digests cover only event-specific allowlisted canonical JSON fields" + ], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Remove the standalone acquisition script, fixture and its workflow-validation cases; the ticket-046 codec and pipeline logs remain unchanged" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-02", + "commands": ["npm run build", "node --test dist/test/workflow-validation.test.js"], + "evidence": "Supported event/action mappings and unsupported cases are exercised from bounded fixtures" + }, + { + "criterion": "AC-03", + "commands": ["node --test dist/test/workflow-validation.test.js"], + "evidence": "Canonical allowlisting, unsafe values and non-leakage of raw payload fields are tested" + }, + { + "criterion": "AC-04", + "commands": ["node --test dist/test/workflow-validation.test.js"], + "evidence": "Output is parsed by the existing codec and immutable atomic publication is verified" + }, + { + "criterion": "AC-05", + "commands": ["node --test dist/test/workflow-validation.test.js"], + "evidence": "Review payloads remain SYSTEM_FACT and cannot synthesize an approval attestation" + }, + { + "criterion": "AC-06", + "commands": ["make governance", "make verify", "make docker-smoke", "git diff --check"], + "evidence": "Deterministic, repository-wide and container gates pass" + } + ] + } +} diff --git a/project/ticket-047/preprompt.md b/project/ticket-047/preprompt.md new file mode 100644 index 0000000..ee067dc --- /dev/null +++ b/project/ticket-047/preprompt.md @@ -0,0 +1,13 @@ +# Ticket preprompt + +- **Task ID**: ticket-047 +- **Task title**: Collect bounded GitHub evidence into event logs +- **Created**: 2026-08-05T12:36:58Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. + +Implement only the approved GitHub payload acquisition adapter and its bounded +integration evidence. Reuse the ticket-046 codec. Do not edit workflows, poll +GitHub APIs, add a second DSL/renderer, append completed logs, publish a public +command or convert ordinary/LLM review into trusted approval. diff --git a/project/ticket-048/README.md b/project/ticket-048/README.md new file mode 100644 index 0000000..dc3cae7 --- /dev/null +++ b/project/ticket-048/README.md @@ -0,0 +1,104 @@ +# Ticket 048: Publish the GitHub event log adapter through governance + +- **ID**: ticket-048 +- **Owner**: unresolved:human +- **Status**: IN_PROGRESS +- **Workflow state**: VALIDATION +- **Created**: 2026-08-06 + +## Goal and scope + +Ticket-047 built a working GitHub acquisition adapter but could never publish +it. Its work sits on a local `main` that branch protection refuses, and the +squashed commits fail CI governance twice over. This ticket republishes the +same bounded adapter through a route governance accepts, and removes the defect +that made it unpublishable. + +Three findings drive the scope: + +1. **`GOV-INTENT-003`** — ticket-047's `intent.json` and its implementation + landed in one commit. The standard requires the plan to exist in an earlier + commit than the implementation it authorizes. +2. **`GOV-TICKET-001`** — ticket-047 reached `DONE` before publication, and a + closed ticket holds no authority over implementation paths, so its own PR is + rejected. +3. **Unownable path** — `scripts/github-event-log.mjs` read + `process.env.GITHUB_EVENT_PATH` and `process.env.GITHUB_REPOSITORY` as + fallbacks. `verify:env` scans `scripts/**` and requires every referenced key + in `.env.example`, but no workstream in `.governance/manifest.json` owns + `.env.example`, and the manifest is hash-locked to the pinned upstream + standard. No ticket can legally make that edit. + +Finding 3 is a defect, not a governance inconvenience: a bounded acquisition +boundary should not silently inherit ambient process state. Both flags are +already explicit, so the fallbacks are removed and the flags become required. +`.env.example` then needs no change at all. + +Ticket-047's directory travels with this ticket as its historical record. Its +`DONE` status and its log — including the explicit note that it carries no Koru +or Validator approval — are preserved verbatim, not rewritten. + +## Acceptance criteria + +- [x] AC-01: The republication route, the removal of the two `process.env` + fallbacks and the carried-over ticket-047 record are approved by a human + owner. +- [x] AC-02: `scripts/github-event-log.mjs` resolves the event path and the + repository only from `--event-path` and `--repository`, fails closed with a + named error when either is absent, and reads no `process.env` key. +- [x] AC-03: `npm run verify:env` passes with `.env.example` unchanged from + `main`, proving the unownable-path conflict is resolved at its cause. +- [x] AC-04: The adapter's behavior is otherwise identical to ticket-047 — + same event mappings, same allowlisted canonical evidence, same + `SYSTEM_FACT` trust class, same immutable atomic publication — except that + every event now records `SOURCE "github-actions"` instead of `github-api`, + because the adapter reads a delivered payload and makes no API call. +- [x] AC-05: `docs/EVENT_LOG_DSL.md` documents both flags as required and + records that the adapter deliberately reads no environment variable. +- [x] AC-06: The branch carries the plan in a strictly earlier commit than the + implementation, and `project/governance-check.sh --actor ci --base + --head ` passes against the PR base. +- [x] AC-07: Governance, full host verification and Docker checks pass with no + dependency or public-interface change. + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-claude.md](ai-claude.md) + +## Architecture and bounds + +- Component 1: `scripts/github-event-log.mjs`, republished with strictly + flag-driven input resolution. The ticket-046 codec remains the single + renderer, validator, digest chain and atomic writer. +- Component 2: the acquisition evidence — + `test/workflow-validation.test.ts`, its bounded payload fixture and + `docs/EVENT_LOG_DSL.md` — extended with one case proving the adapter ignores + ambient environment state. +- The branch is cut from the protected `main` this PR targets, so the diff + contains no `.env.example` change and no path outside the `integration` + workstream or the manifest's `governancePaths`. +- Complexity class: S; maximum 30 minutes, four implementation files, two + affected components, no public interface or runtime dependency change. + +## Non-goals + +- No `.github/workflows/**` wiring of the adapter; that stays deferred. +- No change to `.env.example`, `.governance/**` or the pinned standard, and no + attempt to widen workstream ownership to cover `.env.example`. +- No new event vocabulary, renderer, public CLI command, SDK surface, runtime + dependency or trusted approval policy. +- No rewrite of ticket-047's status, acceptance criteria, changelog or log. +- No `CHANGELOG.md` entry: that path is owned by no workstream, the same trap + this ticket removes for `.env.example`. + +## Approval boundary + +The human owner approved this ticket on 2026-08-06, directing that the previous +changes be carried forward and the underlying problem fixed. The plan commit +carrying this file and `intent.json` precedes the implementation commit, as the +standard requires. + +Publication remains open: this ticket is `IN_PROGRESS / VALIDATION` until its +pull request passes protected checks and is merged. It is not marked `DONE` +before that, which is precisely the mistake that stranded ticket-047. diff --git a/project/ticket-048/ai-claude-logs.txt b/project/ticket-048/ai-claude-logs.txt new file mode 100644 index 0000000..09a3308 --- /dev/null +++ b/project/ticket-048/ai-claude-logs.txt @@ -0,0 +1,29 @@ +2026-08-06 APPROVAL: human owner directed that the previous changes be carried forward and the underlying problem fixed +transition: PLAN / WAIT_FOR_APPROVAL -> IN_PROGRESS / EDIT +2026-08-06 finding: make governance compares the working tree to HEAD; CI compares base..head, which is why three blockers were invisible locally +2026-08-06 finding: GOV-INTENT-003 requires intent.json in a strictly earlier commit than the implementation it authorizes +2026-08-06 finding: GOV-TICKET-001 rejects implementation paths under a DONE ticket +2026-08-06 finding: .env.example is owned by no workstream and .governance/manifest.json is hash-locked to the pinned standard, so no ticket can declare the two GITHUB_* keys +2026-08-06 decision: fix at the cause by removing both process.env fallbacks from scripts/github-event-log.mjs instead of seeking ownership over .env.example +2026-08-06 branch cut from the protected base b8392f7 so .env.example never enters the diff +2026-08-06 plan-only commit b82e10a published before any implementation +2026-08-06 implementation: removed process.env.GITHUB_EVENT_PATH and process.env.GITHUB_REPOSITORY; the missing repository error now names --repository +2026-08-06 evidence: added a case that sets both variables in the child environment and asserts fail-closed behavior, no output file and no echo of the ambient repository +2026-08-06 documentation: docs/EVENT_LOG_DSL.md records the required flags and why an environment-reading acquisition boundary cannot be published here +2026-08-06 validation: focused workflow-validation 10/10; npm run build PASS +2026-08-06 validation: verify:env PASS with .env.example byte-identical to origin/main +transition: IN_PROGRESS / EDIT -> IN_PROGRESS / VALIDATION +2026-08-06 pre-review audit: scanned the diff for secret-shaped literals after the Validator rejected PR #62 for a secret-like assignment pattern; none present +2026-08-06 defect found in carried-over ticket-047 code: every event recorded SOURCE "github-api" while the adapter makes no API call and its own ticket declares no API polling +2026-08-06 fix: all four mappings now record SOURCE "github-actions", a value already established in the ticket-045 golden fixture; false provenance contradicts the contract rule that missing knowledge is never guessed +2026-08-06 evidence: the ambient-environment case now also asserts every emitted event carries SOURCE "github-actions" +2026-08-06 validation: focused workflow-validation 10/10; documented invocation verified end-to-end against a real payload +2026-08-06 autonomy: DIRECT_PR_SCAN_* variables alone did not produce reviews; scan-direct was not on validator-agent main and matrix lacked todo2code until PR #8 merge 95c62a2 +2026-08-06 autonomy: dry-run direct-pr failed on GitHub Actions CDN Service Unavailable; live direct-pr re-dispatched for head 95a4d91 +2026-08-06 follow-up: ticket-049 plan + tickets 050-052 record operator guide and refactor work; GOV-APPROVAL remains the sole publication blocker for PR #66 +2026-08-06 verification: DIRECT_PR_SCAN_CONFIG and DIRECT_PR_SCAN_ENABLED=true are set on subactor/validator-agent; the config includes semcod/todo2code with required checks verify, Java adapter (JDK 17 required), koru / code-review and allowed base main +2026-08-06 verification: scan-direct is present in .github/workflows/validator.yml on validator-agent origin/main; the earlier missing-job explanation is withdrawn +2026-08-06 root cause: scheduled validator run 31122590798 recorded zero steps for the gate job test, was cancelled after 23 minutes, and skipped validate and scan-direct; the job never obtained a runner +2026-08-06 root cause evidence: 18 of the last 40 runs in subactor/validator-agent are queued across all six workflows, oldest over 90 minutes, while semcod/todo2code drains normally +2026-08-06 correction: attributing the stall to a subactor Actions capacity or spending limit was wrong; status.github.com reported Actions in major_outage, and during an outage repositories stall unevenly so a draining repository is not a control group +2026-08-06 conclusion: the autonomy configuration is correct and the executor is stalled by a GitHub-wide Actions outage; no change in this repository can clear it diff --git a/project/ticket-048/ai-claude.md b/project/ticket-048/ai-claude.md new file mode 100644 index 0000000..a537ed8 --- /dev/null +++ b/project/ticket-048/ai-claude.md @@ -0,0 +1,90 @@ +--- +participant-id: agent:claude +participant: claude +role: agent +ticket: ticket-048 +--- +# Participant: claude (AI agent) + +## Understanding + +Ticket-047's adapter works and is fully tested, but it cannot reach protected +`main`. Three independent obstacles were confirmed by running the CI-equivalent +governance check locally rather than the working-tree check that `make +governance` performs: + +- the plan and the implementation share one commit (`GOV-INTENT-003`), +- the ticket was closed to `DONE` before publication, so it no longer + authorizes its own implementation paths (`GOV-TICKET-001`), +- its `.env.example` follow-up touches a path no workstream owns, and the + manifest that would have to grant that ownership is hash-locked to the pinned + upstream standard. + +The third obstacle has a cause inside the adapter: two `process.env` fallbacks +that `verify:env` propagates into an `.env.example` requirement. Removing them +is both the governance fix and the better boundary — a bounded acquisition step +should take its input explicitly, not inherit ambient process state. + +## Execution plan + +1. Cut the branch from the protected base the PR targets, so `.env.example` + never appears in the diff. +2. Commit this plan and `intent.json` alone, before any implementation. +3. Republish the adapter with `--event-path` and `--repository` required and no + `process.env` read; carry the tests, fixture and documentation across. +4. Add one case asserting the adapter still fails closed when those variables + are set in the child environment but the flags are absent. +5. Verify with the CI-equivalent base/head governance check, `make verify`, + `make docker-smoke` and `npm run verify:env`. + +## Actual changes + +- `scripts/github-event-log.mjs`: both `process.env` reads removed. The event + path resolves only from `--event-path`, and the repository only from + `--repository` or the payload's own `repository.full_name`. The missing + repository error now names the flag. +- `test/workflow-validation.test.ts`: added a case that sets + `GITHUB_EVENT_PATH` and `GITHUB_REPOSITORY` in the child environment and + asserts the adapter still fails closed on both, writes no output file, and + never echoes the ambient repository value. +- `docs/EVENT_LOG_DSL.md`: documents the required flags, states that the + adapter reads no environment variable, and records why an environment-reading + acquisition boundary cannot be published in this repository at all. +- `test/fixtures/event-log/v1/github-event-payloads.json`: carried across + unchanged from ticket-047. +- `TODO.md`, `project/TICKETS.md`, `project/ticket-047/**`: record carried and + registered. + +## Blockers + +- **GOV-APPROVAL on PR #66**: product checks and structural governance pass; + merge waits for a trusted Validator App (or trusted human) review on the exact + current head. See ticket-049 plan (branch `ticket/049-validator-autonomy-plan`). + + An earlier note here blamed a missing `scan-direct` job in validator-agent + `main`. That is not the cause, and both halves of it were re-checked: + + - `DIRECT_PR_SCAN_CONFIG` and `DIRECT_PR_SCAN_ENABLED=true` are set on + `subactor/validator-agent`, and the config carries a `semcod/todo2code` + entry with the required checks and `main` as an allowed base. + - `scan-direct` is present in `.github/workflows/validator.yml` on + validator-agent `origin/main`. + + The actual cause is a GitHub-wide **Actions `major_outage`**, confirmed + against `status.github.com` while this was written. In the failing scheduled + run, the gate job `test` recorded **zero steps** and was cancelled after 23 + minutes, so `validate` and `scan-direct` were skipped — the job never + obtained a runner. The backlog spans all six workflows in that repository + (`ci`, `contribution-policy`, `intent-conformance`, `koru-code-review`, + `Sync Tickets project`, `validator-agent`), with runs queued for over 90 + minutes. + + An earlier revision of this note attributed the stall to an Actions capacity + or spending limit in the `subactor` organization, reasoning that + `semcod/todo2code` kept draining. That inference was wrong: during a + `major_outage` repositories stall unevenly, so one that keeps draining is not + a control group. + + The autonomy configuration is therefore correct and the executor is stalled. + No change in this repository can clear it. Analysis lives in ticket-049 + §2.2.1. diff --git a/project/ticket-048/changelog.md b/project/ticket-048/changelog.md new file mode 100644 index 0000000..1c93226 --- /dev/null +++ b/project/ticket-048/changelog.md @@ -0,0 +1,37 @@ +# Ticket Changelog (ticket-048) + +## [0.2.0] - 2026-08-06 + +- Ticket-048 was approved and moved to `IN_PROGRESS / EDIT`. +- Removed both `process.env` fallbacks from `scripts/github-event-log.mjs`; the + event path and repository now resolve only from explicit flags or the payload + itself, and the missing-repository error names `--repository`. +- Added a workflow-validation case proving the adapter fails closed and writes + nothing when `GITHUB_EVENT_PATH` and `GITHUB_REPOSITORY` are set in the + child environment but the flags are absent. +- Documented the required flags and the deliberate absence of environment reads + in `docs/EVENT_LOG_DSL.md`, including why an environment-reading acquisition + boundary cannot be published in this repository. +- Verified `npm run verify:env` with `.env.example` byte-identical to the + protected base, closing the unownable-path conflict at its cause. +- Corrected false provenance carried over from ticket-047: every emitted event + recorded `SOURCE "github-api"` although the adapter makes no API call. All + four mappings now record `SOURCE "github-actions"`. +- Validation path: PR #66 remains merge-blocked on trusted `GOV-APPROVAL`. + Autonomy investigation showed `DIRECT_PR_SCAN_*` variables alone were a no-op + until `scan-direct` landed on `subactor/validator-agent` main with a + `semcod/todo2code` matrix leg. Follow-up plan and tickets: + ticket-049 (branch `ticket/049-validator-autonomy-plan`) et seq. `docs/EVENT_LOG_DSL.md` notes + the publication/autonomy boundary. + +## [0.1.0] - 2026-08-06 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Planned republication of the ticket-047 acquisition adapter from the + protected base, with the plan commit strictly preceding implementation. +- Declared removal of the two `process.env` fallbacks in + `scripts/github-event-log.mjs` as the cause-level fix for the unownable + `.env.example` requirement. +- Carried the ticket-047 record across unchanged, including its explicit note + that no Koru review or Validator attestation was ever obtained. diff --git a/project/ticket-048/decisions.md b/project/ticket-048/decisions.md new file mode 100644 index 0000000..8f08dda --- /dev/null +++ b/project/ticket-048/decisions.md @@ -0,0 +1,39 @@ +# Decision log — ticket-048 + +Append-only recomputable records (C-DECISION-001). Each entry binds a head SHA; do not rewrite history. + +```dsl +DECISION D-048-0902 +TICKET ticket-048 +HEAD_SHA 33d62df339d411ed25e2f5554676233cf1ebc097 +CORRELATION_ID todo2code-pr-66-ticket-048 +ACTOR agent:ifuri-validator-agent[bot] +APPLIED_RULE P-CORE-015 +INPUT author_login = "tom-sapletta-com" +INPUT observed_checks = ["koru / code-review=PASS","Live OpenRouter contract (opt-in)=FAIL","verify=PASS","Java adapter (JDK 17 required)=PASS"] +INPUT required_checks = ["verify","Java adapter (JDK 17 required)","koru / code-review"] +INPUT required_checks_source = "env/request" +INPUT reviewer_login = "ifuri-validator-agent[bot]" +VERDICT APPROVE AUTHORITY DETERMINISTIC +REJECTED REQUEST_CHANGES BECAUSE NO_UNSAFE_CHANGE_REASON_FOUND +ADVISORY llm_verdict = "BLOCK" MODEL "openrouter/z-ai/glm-5.2" +ASSERT VERDICT_AUTHORITY != "ADVISORY" +``` + +```dsl +DECISION D-048-2831 +TICKET ticket-048 +HEAD_SHA 071b707c342b8c67e373fc1899578c21d90a0c02 +CORRELATION_ID todo2code-pr-66-ticket-048 +ACTOR agent:ifuri-validator-agent[bot] +APPLIED_RULE P-CORE-015 +INPUT author_login = "tom-sapletta-com" +INPUT observed_checks = ["koru / code-review=PASS","Live OpenRouter contract (opt-in)=FAIL","verify=PASS","Java adapter (JDK 17 required)=PASS"] +INPUT required_checks = ["verify","Java adapter (JDK 17 required)","koru / code-review"] +INPUT required_checks_source = "env/request" +INPUT reviewer_login = "ifuri-validator-agent[bot]" +VERDICT APPROVE AUTHORITY DETERMINISTIC +REJECTED REQUEST_CHANGES BECAUSE NO_UNSAFE_CHANGE_REASON_FOUND +ADVISORY llm_verdict = "REQUEST_CHANGES" MODEL "openrouter/z-ai/glm-5.2" +ASSERT VERDICT_AUTHORITY != "ADVISORY" +``` diff --git a/project/ticket-048/intent.json b/project/ticket-048/intent.json new file mode 100644 index 0000000..f37cf8f --- /dev/null +++ b/project/ticket-048/intent.json @@ -0,0 +1,104 @@ +{ + "schema": "new-project.intent/v2", + "ticket": "ticket-048", + "summary": "Publish the GitHub event log adapter through governance", + "workstream": "integration", + "allowedPaths": [ + "project/ticket-048/**", + "project/ticket-047/**", + "TODO.md", + "project/TICKETS.md", + "scripts/github-event-log.mjs", + "test/workflow-validation.test.ts", + "test/fixtures/event-log/v1/github-event-payloads.json", + "docs/EVENT_LOG_DSL.md" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + ".env.example", + ".github/workflows/**", + ".governance/**", + "src/**", + "package.json", + "package-lock.json", + "sdk/**" + ], + "stacks": ["node", "docker"], + "dependsOn": ["ticket-046"], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "b8392f76592138e006ca5dff9af52082208acee5", + "targetBranch": "main", + "outcome": "The bounded GitHub acquisition adapter is published through a governance-passing route, with its environment-fallback defect removed at the cause", + "nonGoals": [ + "No GitHub workflow or repository-setting change", + "No .env.example, .governance or pinned-standard change", + "No attempt to widen workstream ownership over unowned root paths", + "No new DSL, public interface, dependency or LLM-derived approval", + "No rewrite of the carried-over ticket-047 record" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "The adapter is republished from the protected base with its plan committed strictly before its implementation, and its two process.env fallbacks are removed so the acquisition boundary resolves input only from explicit flags; this eliminates the verify:env demand on .env.example, a path no workstream owns and the hash-locked manifest cannot be extended to cover", + "components": [ + { + "name": "github-event-acquisition-adapter", + "paths": ["scripts/github-event-log.mjs"] + }, + { + "name": "github-event-acquisition-evidence", + "paths": [ + "test/workflow-validation.test.ts", + "test/fixtures/event-log/v1/github-event-payloads.json", + "docs/EVENT_LOG_DSL.md" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [ + "Each supported GitHub Actions event can produce a separate immutable workflow-run logs.dsl.txt", + "Evidence digests cover only event-specific allowlisted canonical JSON fields" + ], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Remove the acquisition script, its fixture and its workflow-validation cases; the ticket-046 codec and pipeline logs are untouched" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-02", + "commands": ["npm run build", "node --test dist/test/workflow-validation.test.js"], + "evidence": "A case sets GITHUB_EVENT_PATH and GITHUB_REPOSITORY in the child environment and asserts the adapter still fails closed without the flags" + }, + { + "criterion": "AC-03", + "commands": ["npm run verify:env", "git diff --exit-code origin/main -- .env.example"], + "evidence": "The env contract passes with .env.example byte-identical to the protected base" + }, + { + "criterion": "AC-04", + "commands": ["node --test dist/test/workflow-validation.test.js"], + "evidence": "Mapping, allowlisting, SYSTEM_FACT trust class and byte-repeatability cases carried from ticket-047 still pass" + }, + { + "criterion": "AC-06", + "commands": ["bash project/governance-check.sh --actor ci --base b8392f76592138e006ca5dff9af52082208acee5 --head HEAD"], + "evidence": "The CI-equivalent base/head check passes, covering GOV-INTENT-003 and GOV-TICKET-001" + }, + { + "criterion": "AC-07", + "commands": ["make governance", "make verify", "make docker-smoke", "git diff --check"], + "evidence": "Deterministic, repository-wide and container gates pass" + } + ] + } +} diff --git a/project/ticket-048/preprompt.md b/project/ticket-048/preprompt.md new file mode 100644 index 0000000..0428935 --- /dev/null +++ b/project/ticket-048/preprompt.md @@ -0,0 +1,14 @@ +# Ticket preprompt + +- **Task ID**: ticket-048 +- **Task title**: Publish the GitHub event log adapter through governance +- **Created**: 2026-08-06T08:06:14Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. + +Republish only the approved ticket-047 acquisition adapter and its bounded +evidence, with the two `process.env` fallbacks removed. Keep the plan commit +strictly before the implementation commit. Do not touch `.env.example`, +`.governance/**` or the pinned standard, do not widen workstream ownership, do +not edit workflows, and do not rewrite the carried-over ticket-047 record. diff --git a/scripts/github-event-log.mjs b/scripts/github-event-log.mjs new file mode 100644 index 0000000..7418851 --- /dev/null +++ b/scripts/github-event-log.mjs @@ -0,0 +1,577 @@ +#!/usr/bin/env node +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { + createEventLog, + writeEventLogAtomic, +} from '../dist/src/pipeline/event-log.js'; +import { stableStringify } from '../dist/src/core/id.js'; + +const usage = `Usage: node scripts/github-event-log.mjs --event-name --event-path --output \n\ +\n\ +Options:\n\ + --event-name \n\ + --event-path JSON payload from GitHub Actions\n\ + --output output logs.dsl.txt path\n\ + --stream-id optional stable stream id\n\ + --correlation-id optional stable correlation id\n\ + --recorded-at optional explicit recorded time\n\ + --repository optional repository override\n\ + --ticket optional bound ticket\n\ + --help\n\ +\n\ +Exit code 1 for unsupported event/action or unbound evidence.\n`; + +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const SHA = /^[a-f0-9]{40}$/; +const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; +const TICKET = /^ticket-[A-Za-z0-9._-]+$/; +const EVENT_NAMES = new Set(['push', 'pull_request', 'pull_request_review', 'workflow_run']); + +const parser = (argv) => { + if (argv.length === 1 && argv[0] === '--help') return { help: true }; + if (argv.includes('--help')) throw new Error('--help cannot be combined with other options'); + if (argv.length % 2 === 1) { + throw new Error(`missing value for ${argv.at(-1)}`); + } + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const option = argv[index]; + const value = argv[index + 1]; + if (!option.startsWith('--')) throw new Error(`unknown option: ${option}`); + if (!value || value.startsWith('--')) throw new Error(`missing value for ${option}`); + if (values.has(option)) throw new Error(`duplicate option: ${option}`); + if (![ + '--event-name', + '--event-path', + '--output', + '--stream-id', + '--correlation-id', + '--recorded-at', + '--repository', + '--ticket', + ].includes(option)) { + throw new Error(`unknown option: ${option}`); + } + values.set(option, value); + } + return { + help: false, + eventName: values.get('--event-name'), + eventPath: values.get('--event-path') ?? '', + output: values.get('--output'), + streamId: values.get('--stream-id'), + correlationId: values.get('--correlation-id'), + recordedAt: values.get('--recorded-at'), + repository: values.get('--repository'), + ticket: values.get('--ticket'), + }; +}; + +const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null); +const asText = (value) => { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'bigint') return value.toString(); + return ''; +}; +const asString = (value, fallback = '') => { + const text = asText(value); + return text || fallback; +}; + +const fail = (message) => { + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}; + +function asSha(value, name, allowNull = false) { + const text = asText(value).toLowerCase(); + if (!text) return allowNull ? null : (() => { throw new Error(`${name} is required`); })(); + if (allowNull && ['0000000000000000000000000000000000000000', 'null', ''].includes(text)) return null; + if (!SHA.test(text)) throw new Error(`${name} must be a full lowercase SHA`); + return text; +} + +function asTimestamp(value, name, allowNull = false) { + const text = asString(value); + if (!text) { + if (allowNull) return null; + throw new Error(`missing ${name}`); + } + if (!RFC3339.test(text) || !Number.isFinite(Date.parse(text))) { + throw new Error(`${name} must be RFC3339`); + } + return text; +} + +function asRepository(value) { + const text = asString(value); + if (!text || !REPOSITORY.test(text)) { + throw new Error('repository must be owner/name'); + } + return text; +} + +function asTicket(value) { + if (!value) return null; + const text = asString(value); + if (!TICKET.test(text)) throw new Error('ticket must look like ticket-***'); + return text; +} + +function asActor(value, fallback) { + const text = asString(value, fallback); + if (!text) throw new Error('actor login is required'); + if (/[\x00-\x1f\x7f]/.test(text)) throw new Error('actor login contains control characters'); + return `github:${text}`; +} + +function pickActor(...candidates) { + for (const candidate of candidates) { + const value = asString(candidate); + if (value) return value; + } + return ''; +} + +function pickTimestamp(...candidates) { + for (const candidate of candidates) { + const value = asString(candidate); + if (!value) continue; + if (RFC3339.test(value) && Number.isFinite(Date.parse(value))) return value; + } + return ''; +} + +function canonicalEvidence(payload) { + return stableStringify(payload); +} + +function pickRepository(payload, override) { + if (override) return asRepository(override); + const repositoryObject = asRecord(payload.repository); + const repository = asString(repositoryObject?.full_name) || asString(payload.repository_name); + if (!repository) throw new Error('repository is required; pass --repository'); + return asRepository(repository); +} + +function makeCommonEvent(event) { + if (!REPOSITORY.test(event.repository)) { + throw new Error('invalid repository'); + } + if (!event.actorId || !event.actorId.startsWith('github:')) { + throw new Error('actor binding is required'); + } + if (event.baseSha !== null && !SHA.test(event.baseSha)) throw new Error('baseSha must be a full SHA or null'); + if (event.headSha !== null && !SHA.test(event.headSha)) throw new Error('headSha must be a full SHA or null'); + if (!event.recordedAt || !RFC3339.test(event.recordedAt)) throw new Error('recordedAt must be RFC3339'); + if (!event.occurredAt || !RFC3339.test(event.occurredAt)) throw new Error('occurredAt must be RFC3339'); + return event; +} + +function createPushEvents(payload, context) { + const ref = asString(payload.ref); + if (!ref) throw new Error('push.ref is required'); + const before = asSha(payload.before, 'push.before', true); + const after = asSha(payload.after, 'push.after', true); + const deleted = payload.deleted === true; + const eventTime = pickTimestamp( + asString(payload.head_commit?.timestamp), + payload.timestamp, + payload.repository?.pushed_at, + context.recordedAt, + ); + const occurredAt = asTimestamp(eventTime, 'recorded_at'); + const actor = asActor( + pickActor( + payload.pusher?.login, + payload.pusher?.name, + payload.sender?.login, + payload.sender?.name, + ), + 'github', + ); + const subjectId = `git:ref/${ref}`; + const events = []; + const base = { + source: 'github-actions', + repository: context.repository, + ticketId: context.ticket, + correlationId: context.correlationId, + actorId: actor, + baseSha: before, + headSha: deleted ? null : after, + evidenceKind: 'github_push', + evidenceRef: `github:push/${context.correlationId}`, + recordedAt: context.recordedAt, + }; + events.push(makeCommonEvent({ + ...base, + eventId: `${context.correlationId}:push`, + type: 'git.push.received', + trustClass: 'SYSTEM_FACT', + occurredAt, + subjectId, + outcome: 'CREATED', + evidence: canonicalEvidence({ + event: 'push', + action: 'received', + ref, + before, + after, + deleted, + sender: asString(payload.sender?.login) || asString(payload.sender?.name), + pusher: asString(payload.pusher?.login) || asString(payload.pusher?.name), + }), + })); + if (deleted) { + events.push(makeCommonEvent({ + ...base, + eventId: `${context.correlationId}:push:branch-deleted`, + type: 'branch.deleted', + trustClass: 'SYSTEM_FACT', + occurredAt, + baseSha: before, + headSha: null, + subjectId: `github:branch/${encodeURIComponent(ref)}`, + evidenceKind: 'github_branch', + evidenceRef: `github:branch/${encodeURIComponent(ref)}`, + outcome: 'DELETED', + evidence: canonicalEvidence({ + event: 'push.branch_deleted', + ref, + before, + }), + })); + } + const commits = Array.isArray(payload.commits) ? payload.commits : []; + for (const commit of commits) { + if (!commit || typeof commit !== 'object' || Array.isArray(commit)) continue; + const sha = asSha(commit.id, 'commit.id'); + const commitActor = pickActor( + commit.author?.username, + commit.committer?.name, + commit.committer?.username, + payload.sender?.login, + ); + const commitActorId = asActor(commitActor, 'github'); + const commitAt = asTimestamp( + pickTimestamp(asString(commit.timestamp), asString(payload.head_commit?.timestamp), context.recordedAt), + 'commit.timestamp', + ); + events.push(makeCommonEvent({ + ...base, + eventId: `${context.correlationId}:commit:${sha}`, + type: 'git.commit.created', + trustClass: 'SYSTEM_FACT', + occurredAt: commitAt, + actorId: commitActorId, + subjectId: `git:commit/${sha}`, + baseSha: null, + headSha: sha, + outcome: 'CREATED', + evidenceKind: 'github_commit', + evidenceRef: `github:commit/${sha}`, + evidence: canonicalEvidence({ + event: 'git-commit', + sha, + message: asString(commit.message), + distinct: commit.distinct === true, + }), + })); + } + return events; +} + +function createPullRequestEvents(payload, context) { + const action = asString(payload.action); + if (!['opened', 'synchronize', 'closed'].includes(action)) { + throw new Error(`unsupported pull_request action: ${action}`); + } + const pullRequest = asRecord(payload.pull_request); + if (!pullRequest) throw new Error('pull_request object is required'); + const number = asString(pullRequest.number) || asString(pullRequest.id); + if (!number) throw new Error('pull_request.number is required'); + const baseSha = asSha(pullRequest.base?.sha, 'pull_request.base.sha', true); + const headSha = asSha(pullRequest.head?.sha, 'pull_request.head.sha', true); + const actor = asActor( + pickActor( + asString(pullRequest.merged_by?.login), + asString(payload.sender?.login), + asString(payload.sender?.name), + ), + asString(payload.sender?.login, 'github'), + ); + const createdAt = asTimestamp(asString(pullRequest.created_at), 'pull_request.created_at'); + const updatedAt = asTimestamp(asString(pullRequest.updated_at), 'pull_request.updated_at', true); + const mergedAt = asTimestamp(asString(pullRequest.merged_at), 'pull_request.merged_at', true); + const actionMap = { + opened: { type: 'pull_request.opened', outcome: 'CREATED', occurredAt: createdAt }, + synchronize: { type: 'pull_request.synchronized', outcome: 'UPDATED', occurredAt: updatedAt }, + closed: { + type: pullRequest.merged === true ? 'pull_request.merged' : 'pull_request.closed', + outcome: pullRequest.merged === true ? 'MERGED' : 'CLOSED', + occurredAt: mergedAt || updatedAt || createdAt, + }, + }; + const mapping = actionMap[action]; + const occurredAt = asTimestamp(mapping.occurredAt, 'pull_request occurred_at'); + const subjectId = `github:pull-request/${number}`; + return [makeCommonEvent({ + source: 'github-actions', + occurredAt, + recordedAt: context.recordedAt, + actorId: actor, + subjectId, + repository: context.repository, + ticketId: context.ticket, + correlationId: context.correlationId, + baseSha, + headSha, + eventId: `${context.correlationId}:pull-request:${action}`, + type: mapping.type, + trustClass: 'SYSTEM_FACT', + outcome: mapping.outcome, + evidenceKind: 'github_pull_request', + evidenceRef: `github:pull-request/${number}`, + evidence: canonicalEvidence({ + event: 'pull_request', + action, + number, + base: baseSha, + head: headSha, + merged: pullRequest.merged === true, + state: asString(pullRequest.state), + }), + })]; +} + +function createPullRequestReviewEvents(payload, context) { + const action = asString(payload.action); + if (action !== 'submitted') { + throw new Error(`unsupported pull_request_review action: ${action}`); + } + const review = asRecord(payload.review); + if (!review) throw new Error('pull_request_review.review is required'); + const pullRequest = asRecord(payload.pull_request); + if (!pullRequest) throw new Error('pull_request_review.pull_request is required'); + const reviewId = asString(review.id); + const number = asString(pullRequest.number) || asString(pullRequest.id); + if (!number) throw new Error('pull_request_review.number is required'); + const actor = asActor( + pickActor(asString(review.user?.login), asString(payload.sender?.login)), + asString(payload.sender?.login, 'github'), + ); + const baseSha = asSha(pullRequest.base?.sha, 'pull_request.base.sha', true); + const headSha = asSha(pullRequest.head?.sha, 'pull_request.head.sha', true); + const state = asString(review.state); + const outcomes = { + approved: 'APPROVED', + changes_requested: 'CHANGES_REQUESTED', + commented: 'UPDATED', + dismissed: 'UPDATED', + }; + const outcome = outcomes[state]; + if (!outcome) throw new Error(`unsupported pull_request_review state: ${state}`); + const occurredAt = asTimestamp( + asString(review.submitted_at) || asString(pullRequest.updated_at) || context.recordedAt, + 'pull_request_review.submitted_at', + ); + return [makeCommonEvent({ + source: 'github-actions', + occurredAt, + recordedAt: context.recordedAt, + actorId: actor, + subjectId: `github:review/${reviewId || number}`, + repository: context.repository, + ticketId: context.ticket, + correlationId: context.correlationId, + baseSha, + headSha, + eventId: `${context.correlationId}:review:${reviewId || number}`, + type: 'pull_request.reviewed', + trustClass: 'SYSTEM_FACT', + outcome, + evidenceKind: 'github_review', + evidenceRef: `github:review/${reviewId || number}`, + evidence: canonicalEvidence({ + event: 'pull_request_review', + state, + pullRequest: number, + review: reviewId, + }), + })]; +} + +function createWorkflowRunEvents(payload, context) { + const action = asString(payload.action); + if (action !== 'completed') { + throw new Error(`unsupported workflow_run action: ${action}`); + } + const workflowRun = asRecord(payload.workflowRun) || asRecord(payload.workflow_run); + if (!workflowRun) throw new Error('workflow_run object is required'); + const id = asString(workflowRun.id); + if (!id) throw new Error('workflow_run.id is required'); + const actor = asActor( + pickActor(asString(workflowRun.actor?.login), asString(payload.sender?.login), asString(payload.sender?.name)), + asString(payload.sender?.login, 'github'), + ); + const concluded = asString(workflowRun.conclusion); + const outcomes = { + success: 'PASSED', + neutral: 'DEGRADED', + failure: 'FAILED', + cancelled: 'SKIPPED', + skipped: 'SKIPPED', + timed_out: 'FAILED', + startup_failure: 'FAILED', + action_required: 'BLOCKED', + stale: 'BLOCKED', + }; + const outcome = outcomes[concluded]; + if (!outcome) throw new Error(`unsupported workflow_run.conclusion: ${concluded}`); + const occurredAt = asTimestamp( + asString(workflowRun.updated_at) || asString(workflowRun.created_at), + 'workflow_run.updated_at', + ); + const headSha = asSha(workflowRun.head_sha, 'workflow_run.head_sha', true); + return [makeCommonEvent({ + source: 'github-actions', + occurredAt, + recordedAt: context.recordedAt, + actorId: actor, + subjectId: `github:check-run/${id}`, + repository: context.repository, + ticketId: context.ticket, + correlationId: context.correlationId, + baseSha: null, + headSha, + eventId: `${context.correlationId}:check:${id}`, + type: 'check.completed', + trustClass: 'SYSTEM_FACT', + outcome, + evidenceKind: 'github_check', + evidenceRef: `github:check-run/${id}`, + evidence: canonicalEvidence({ + event: 'workflow_run', + name: asString(workflowRun.name), + conclusion: concluded, + status: asString(workflowRun.status), + }), + })]; +} + +const EVENT_BUILDERS = { + push: createPushEvents, + pull_request: createPullRequestEvents, + pull_request_review: createPullRequestReviewEvents, + workflow_run: createWorkflowRunEvents, +}; + +function toEventSet(eventName, payload, context) { + const builder = EVENT_BUILDERS[eventName]; + if (!builder) throw new Error(`unsupported event name: ${eventName}`); + return builder(payload, context); +} + +async function main() { + let options; + try { + options = parser(process.argv.slice(2)); + } catch (error) { + fail(error instanceof Error ? error.message : 'invalid arguments'); + process.stdout.write(usage); + return; + } + if (options.help) { + process.stdout.write(usage); + return; + } + if (!options.eventName) { + fail('missing --event-name'); + process.stdout.write(usage); + return; + } + if (!EVENT_NAMES.has(options.eventName)) { + fail(`unsupported event name: ${options.eventName}`); + process.stdout.write(usage); + return; + } + if (!options.eventPath) { + fail('missing --event-path'); + return; + } + if (!options.output) { + fail('missing --output'); + return; + } + + const eventPath = path.resolve(options.eventPath); + let payload; + try { + const raw = await fs.readFile(eventPath, 'utf8'); + payload = JSON.parse(raw); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('event payload must be a JSON object'); + } + } catch (error) { + fail(`event payload is invalid JSON: ${error instanceof Error ? error.message : 'invalid payload'}`); + return; + } + + try { + const repository = pickRepository(payload, options.repository); + const ticket = asTicket(options.ticket); + const recordedAt = asTimestamp( + options.recordedAt + || asString(payload.repository?.updated_at) + || asString(payload.timestamp) + || asString(payload.pushed_at) + || asString(payload.workflow_run?.updated_at) + || asString(payload.workflow_run?.created_at), + 'recorded-at', + ); + const correlationFallback = asString(payload.workflow_run?.id) + || asString(payload.pull_request?.id) + || asString(payload.review?.id) + || asString(payload.sender?.id); + const correlationId = asString(options.correlationId, correlationFallback); + if (!correlationId) throw new Error('correlation-id is required'); + const streamId = asString(options.streamId, `${repository.replace('/', '-')}-${correlationId}`); + if (!streamId) throw new Error('stream-id is required'); + const output = path.resolve(options.output); + const context = { + repository, + ticket, + recordedAt, + correlationId, + streamId, + }; + const events = toEventSet(options.eventName, payload, context); + if (!events.length) throw new Error('no events could be mapped from payload'); + const generatedAt = events + .map((event) => event.occurredAt) + .sort() + .at(-1) ?? context.recordedAt; + const document = createEventLog({ + streamId: context.streamId, + generatedAt, + events, + }); + await writeEventLogAtomic(output, document); + process.stdout.write(`${JSON.stringify({ + status: 'ok', + streamId: context.streamId, + output: path.relative(process.cwd(), output), + eventCount: events.length, + generatedAt, + schema: 't2c.event-log/v1', + })}\n`); + } catch (error) { + fail(error instanceof Error ? error.message : 'failed to create event log'); + } +} + +await main(); diff --git a/test/fixtures/event-log/v1/github-event-payloads.json b/test/fixtures/event-log/v1/github-event-payloads.json new file mode 100644 index 0000000..1f4bf81 --- /dev/null +++ b/test/fixtures/event-log/v1/github-event-payloads.json @@ -0,0 +1,196 @@ +{ + "push": { + "ref": "refs/heads/main", + "before": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "after": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "deleted": false, + "timestamp": "2026-08-05T08:10:00Z", + "pusher": { + "name": "alice", + "login": "alice" + }, + "sender": { + "login": "alice" + }, + "repository": { + "full_name": "semcod/todo2code" + }, + "head_commit": { + "timestamp": "2026-08-05T08:10:10Z" + }, + "commits": [ + { + "id": "cccccccccccccccccccccccccccccccccccccccc", + "message": "Adjust event-log fixtures", + "author": { + "username": "alice" + }, + "timestamp": "2026-08-05T08:10:20Z", + "distinct": true + } + ] + }, + "pull_request_opened": { + "action": "opened", + "pull_request": { + "number": 123, + "state": "open", + "merged": false, + "created_at": "2026-08-05T08:11:00Z", + "updated_at": "2026-08-05T08:11:05Z", + "base": { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } + }, + "sender": { + "login": "alice" + } + }, + "pull_request_review": { + "action": "submitted", + "review": { + "id": 555, + "state": "approved", + "submitted_at": "2026-08-05T08:12:00Z", + "user": { + "login": "carol" + } + }, + "pull_request": { + "number": 123, + "base": { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } + }, + "sender": { + "login": "carol" + } + }, + "workflow_run": { + "action": "completed", + "workflow_run": { + "id": 9876543, + "name": "ci", + "status": "completed", + "conclusion": "success", + "updated_at": "2026-08-05T08:13:00Z", + "created_at": "2026-08-05T08:12:30Z", + "head_sha": "cccccccccccccccccccccccccccccccccccccccc", + "actor": { + "login": "github-actions" + } + }, + "sender": { + "login": "github-actions" + }, + "repository": { + "full_name": "semcod/todo2code" + } + }, + "pull_request_review_unsupported": { + "action": "submitted", + "review": { + "id": 666, + "state": "needs_reply" + }, + "pull_request": { + "number": 123, + "base": { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } + } + }, + "workflow_run_secret": { + "action": "completed", + "workflow_run": { + "id": 9876543, + "name": "ci", + "status": "completed", + "conclusion": "success", + "updated_at": "2026-08-05T08:13:00Z", + "created_at": "2026-08-05T08:13:30Z", + "head_sha": "cccccccccccccccccccccccccccccccccccccccc", + "actor": { + "login": "github-actions" + } + }, + "sender": { + "login": "github-actions" + }, + "secret": "this-field-should-not-be-in-canonical-evidence", + "raw_payload": { + "query": "should-never-land", + "headers": { + "x-ignore": "ignored" + } + } + }, + "workflow_run_with_bad_timestamp": { + "action": "completed", + "workflow_run": { + "id": 9876545, + "name": "ci", + "status": "completed", + "conclusion": "success", + "updated_at": "2026-08-05 08:13:00", + "created_at": "2026-08-05T08:13:30Z" + }, + "sender": { + "login": "github-actions" + }, + "repository": { + "full_name": "semcod/todo2code" + }, + "head_sha": "cccccccccccccccccccccccccccccccccccccccc" + }, + "pull_request_synchronize": { + "action": "synchronize", + "pull_request": { + "number": 123, + "state": "open", + "merged": false, + "created_at": "2026-08-05T08:11:00Z", + "updated_at": "2026-08-05T08:15:00Z", + "base": { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } + }, + "sender": { + "login": "alice" + } + }, + "pull_request_closed": { + "action": "closed", + "pull_request": { + "number": 123, + "state": "closed", + "merged": false, + "created_at": "2026-08-05T08:11:00Z", + "updated_at": "2026-08-05T08:16:00Z", + "base": { + "sha": "bbbbbbbbccccbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "dddddddddddddddddddddddddddddddddddddddd" + } + }, + "sender": { + "login": "alice" + }, + "merged_by": { + "login": "maintainer" + } + } +} diff --git a/test/workflow-validation.test.ts b/test/workflow-validation.test.ts index a2547cc..ee09cf7 100644 --- a/test/workflow-validation.test.ts +++ b/test/workflow-validation.test.ts @@ -5,10 +5,13 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; +import { parseEventLog } from '../src/pipeline/event-log.js'; const exec = promisify(execFile); const verifier = path.resolve('scripts/verify-workflow-yaml.mjs'); const workspacePreflight = path.resolve('scripts/workspace-preflight.mjs'); +const githubEventLog = path.resolve('scripts/github-event-log.mjs'); +const eventLogFixtures = path.resolve('test/fixtures/event-log/v1/github-event-payloads.json'); test('workflow verifier rejects duplicate top-level YAML keys', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-workflow-yaml-')); @@ -107,6 +110,264 @@ test('Make preflight reserves stdout for one canonical report', async (t) => { assert.deepEqual(await repositoryState(fixture.root), before); }); +test('GitHub event collector maps supported push payloads to canonical event logs', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-event-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + const payload = path.join(fixture, 'push.json'); + const output = path.join(fixture, 'push.dsl.txt'); + await fs.writeFile(payload, JSON.stringify(payloads.push, null, 2), 'utf8'); + + const runResult = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'push', + '--event-path', + payload, + '--output', + output, + '--repository', + 'semcod/todo2code', + '--ticket', + 'ticket-047', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'push-047', + '--stream-id', + 'stream-push-047', + ]); + + assert.equal(runResult.code, 0); + const commandResult = JSON.parse(runResult.stdout); + assert.equal(commandResult.status, 'ok'); + const log = parseEventLog(await fs.readFile(output, 'utf8')); + assert.equal(log.events.length, 2); + const pushEvent = log.events.at(0); + assert.ok(pushEvent); + const commitEvent = log.events.at(1); + assert.ok(commitEvent); + const eventTypes = [pushEvent.type, commitEvent.type].sort(); + assert.deepEqual(eventTypes, ['git.commit.created', 'git.push.received'].sort()); + assert.equal(pushEvent.trustClass, 'SYSTEM_FACT'); + assert.equal(commitEvent.trustClass, 'SYSTEM_FACT'); + assert.match([pushEvent.subjectId, commitEvent.subjectId].join(','), /git:ref\/refs\/heads\/main/); +}); + +test('GitHub review events are SYSTEM_FACT and cannot become approval attestation', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-review-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + const payload = path.join(fixture, 'review.json'); + const output = path.join(fixture, 'review.dsl.txt'); + await fs.writeFile(payload, JSON.stringify(payloads.pull_request_review, null, 2), 'utf8'); + + const runResult = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'pull_request_review', + '--event-path', + payload, + '--output', + output, + '--repository', + 'semcod/todo2code', + '--ticket', + 'ticket-047', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'review-047', + '--stream-id', + 'stream-review-047', + ]); + + assert.equal(runResult.code, 0); + const log = parseEventLog(await fs.readFile(output, 'utf8')); + assert.equal(log.events.length, 1); + const event = log.events.at(0); + assert.ok(event); + assert.equal(event.type, 'pull_request.reviewed'); + assert.equal(event.trustClass, 'SYSTEM_FACT'); + assert.equal(event.outcome, 'APPROVED'); +}); + +test('GitHub review with unsupported state fails closed', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-review-fail-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + const payload = path.join(fixture, 'review.json'); + const output = path.join(fixture, 'review.dsl.txt'); + await fs.writeFile(payload, JSON.stringify(payloads.pull_request_review_unsupported, null, 2), 'utf8'); + + const runResult = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'pull_request_review', + '--event-path', + payload, + '--output', + output, + '--repository', + 'semcod/todo2code', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'review-047', + '--stream-id', + 'stream-review-fail-047', + ]); + + assert.equal(runResult.code, 1); + assert.match(runResult.stderr, /unsupported pull_request_review state: needs_reply/); +}); + +test('Evidence projection is allowlisted and extra payload fields do not leak into logs', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-evidence-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + + const basePayload = path.join(fixture, 'workflow-run.json'); + const leakPayload = path.join(fixture, 'workflow-run-leak.json'); + const baseOutput = path.join(fixture, 'workflow-run.dsl.txt'); + const leakOutput = path.join(fixture, 'workflow-run-leak.dsl.txt'); + await fs.writeFile(basePayload, JSON.stringify(payloads.workflow_run, null, 2), 'utf8'); + await fs.writeFile(leakPayload, JSON.stringify(payloads.workflow_run_secret, null, 2), 'utf8'); + + const runBase = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'workflow_run', + '--event-path', + basePayload, + '--output', + baseOutput, + '--repository', + 'semcod/todo2code', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'workflow-run-047', + '--stream-id', + 'stream-workflow-047', + ]); + + const runLeak = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'workflow_run', + '--event-path', + leakPayload, + '--output', + leakOutput, + '--repository', + 'semcod/todo2code', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'workflow-run-047', + '--stream-id', + 'stream-workflow-047', + ]); + + assert.equal(runBase.code, 0); + assert.equal(runLeak.code, 0); + const baseContent = await fs.readFile(baseOutput, 'utf8'); + const leakContent = await fs.readFile(leakOutput, 'utf8'); + assert.equal(baseContent, leakContent); + const log = parseEventLog(baseContent); + const event = log.events.at(0); + assert.ok(event); + assert.match(event.evidenceRef, /^github:check-run\/\d+$/); + assert.doesNotMatch(baseContent, /query/); + assert.doesNotMatch(baseContent, /raw_payload/); +}); + +test('GitHub acquisition ignores ambient environment and requires explicit input', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-env-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + const payload = path.join(fixture, 'push.json'); + const output = path.join(fixture, 'push.dsl.txt'); + await fs.writeFile(payload, JSON.stringify(payloads.push, null, 2), 'utf8'); + + const ambient = { + GITHUB_EVENT_PATH: payload, + GITHUB_REPOSITORY: 'attacker/elsewhere', + }; + + const withoutEventPath = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'push', + '--output', + output, + '--repository', + 'semcod/todo2code', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'push-048', + '--stream-id', + 'stream-push-048', + ], ambient); + + assert.equal(withoutEventPath.code, 1); + assert.match(withoutEventPath.stderr, /missing --event-path/); + await assert.rejects(fs.access(output)); + + const bare = JSON.parse(JSON.stringify(payloads.push)) as Record; + delete bare.repository; + delete bare.repository_name; + const barePayload = path.join(fixture, 'push-bare.json'); + await fs.writeFile(barePayload, JSON.stringify(bare, null, 2), 'utf8'); + + const withoutRepository = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'push', + '--event-path', + barePayload, + '--output', + output, + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'push-048', + '--stream-id', + 'stream-push-048', + ], ambient); + + assert.equal(withoutRepository.code, 1); + assert.match(withoutRepository.stderr, /repository is required; pass --repository/); + assert.doesNotMatch(withoutRepository.stderr, /attacker\/elsewhere/); + await assert.rejects(fs.access(output)); + + const accepted = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'push', + '--event-path', + payload, + '--output', + output, + '--repository', + 'semcod/todo2code', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'push-048', + '--stream-id', + 'stream-push-048', + ], ambient); + + assert.equal(accepted.code, 0); + const log = parseEventLog(await fs.readFile(output, 'utf8')); + for (const event of log.events) { + assert.equal(event.source, 'github-actions'); + } +}); + interface CommandResult { code: number; stdout: string;