opencode-workflow-guard 1.14.0 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/agents/lessons.md +9 -0
- package/docs/installation.md +2 -0
- package/docs/policies.md +5 -2
- package/docs/troubleshooting.md +10 -0
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +42 -12
- package/src/lib/guard-dispatcher.ts +55 -18
- package/src/lib/state.ts +13 -11
- package/src/lib/types.ts +11 -0
- package/src/lib/v2-plugin.ts +4 -1
- package/src/lib/verify.ts +33 -13
- package/src/lib/worktree.ts +123 -9
- package/src/policies/shell-safety.ts +32 -12
- package/src/policies/stale-write.ts +17 -0
- package/src/workflow-guard.ts +11 -1
- package/test/test.mts +348 -9
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run policy probes with Ope
|
|
|
45
45
|
|
|
46
46
|
The plugin enforces **24 deterministic policy rules** across four main pillars:
|
|
47
47
|
|
|
48
|
-
* **Branch & History Protection:** Enforces feature-branch workflows (blocks edits/commits on `main`/`master`), hard-blocks direct or forced pushes to protected branches, enforces mergeability pre-flight checks, enforces secondary review approval before PR creation by default, and requires PR changelogs / changesets.
|
|
48
|
+
* **Branch & History Protection:** Enforces feature-branch workflows (blocks edits/commits on `main`/`master`), hard-blocks direct or forced pushes to protected branches, enforces mergeability pre-flight checks, enforces secondary review approval (bound to the reviewed worktree content) before PR creation by default, and requires PR changelogs / changesets.
|
|
49
49
|
* **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks with subagent inheritance, blocks concurrent direct edits to the same canonical file across sessions, requires same-session fresh reads before `edit`/`write` can replace existing files, blocks silent task deletion, enforces fresh test verification evidence before task completion, and journals completion claims vs. verification mismatches.
|
|
50
50
|
* **Secrets & Workspace Confinement:** Confines edit tools and recognized shell/git mutations within the workspace root (including symlink and `../` escape checks), scrubs sensitive environment variables in agent subshells, and redacts `.env` reads with safe schema masks. Arbitrary executables are not OS-sandboxed; use containers or filesystem isolation when hard confinement is required.
|
|
51
51
|
* **Destructive CLI & Environment Safety:** Intercepts destructive cloud/database/infrastructure commands, stops script laundering and interpreter evasion, blocks TTY hangs (`vim`, `nano`, `sudo`), and guards against dangerous package manager flags.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Agent loop lessons
|
|
2
|
+
|
|
3
|
+
Append-only, evidence-backed lessons from base-loop iterations. Each item: id, evidence, one bullet.
|
|
4
|
+
|
|
5
|
+
- **LL-001 — Git revision/pathspec argument order is a silent failure mode; assert on diff contents, not wrapper text.** Evidence: `guard_review_rubric` in `src/lib/custom-tools.ts` passed `--` before the revision (`git diff -- <base>...HEAD`, `git diff -- HEAD~1`), so git parsed the revision as a pathspec: `git diff -- HEAD~1` returned 0 bytes while `git diff --no-ext-diff HEAD~1` returned 68,709 bytes, and a live rubric call returned 1,714 bytes with an empty `diff` block (12 rubric invocations present in the device audit log). No existing test caught it because the rubric tests asserted the static heading `Code Diff Under Review` rather than the diff body; the fix (revision before `--`, plus `--no-ext-diff`) is covered by a regression test that asserts a changed path/content marker, and post-fix the live rubric grew to 31,714 bytes with real diff markers.
|
|
6
|
+
- **LL-002 — Don't tighten a shell-pattern gate by replacing a quote-agnostic raw-string detector with a parsed-invocation-only detector; a "fail-closed" rewrite regressed quote-wrapped forms and unresolvable refs.** Evidence: the branch-base staleness fix (archived, not merged, on `loop/branch-base-start-point`) judged `git switch -c new <base>` by its start point; the dispatcher's fallback was changed to require a delimiter before `git` (`(?:^|[\s;|&/])git\s+`), so `bash -c "git switch -c new"` and `sh -c '...'` stopped matching even though the previous `GIT_BRANCH_CREATE_RE` raw regex matched the quoted text and ran the HEAD check. Separately, an unresolvable/variable start point (`git switch -c new $START`) passed because `rev-list` failed and the check reported not-behind, losing the old HEAD backstop. Four review rounds each surfaced another spelling (attached `-bnew`, `-B`/`-C`/`--force-create`, option reordering, `@`/`refs/heads/` aliases, quote wrappers, `$VAR`, `git -C<dir>`), showing branch-create detection needs a proper shell-token extraction or the original conservative HEAD check — not incremental regex patching. Negative result archived with the 1006-passing suite (the passing tests only prove the covered spellings); lesson: for adversarial shell gates, keep at least the pre-existing conservative detector as a fallback and add fail-closed handling for unresolvable operands rather than removing coverage.
|
|
7
|
+
- **LL-003 — A freshness gate that only learns from reads makes agents re-read their own writes; seed observations from successful mutations, gated on actual change.** Evidence: the stale-write gate (`src/policies/stale-write.ts`) recorded per-session fingerprints only from successful `read`, so a follow-up `edit` to a file the session had just created or written was blocked with "has not been read by this session" — the top guard block on this device (64 of the audit's `stale_write` decisions, including agent-authored temp scripts such as `order-processing/.tmp-rsi-iter7/step1-transform.js`). The fix adds `recordMutationObservation(path, sessionID, beforeDigest)` called from the V1/V2 after-hooks over the existing post-edit snapshots; it seeds only when the pre/post digest differs, so no-op/failed calls seed nothing and external changes after the mutation still block (tests: write-then-edit allowed, edit-chained allowed, external change blocked, no-op `apply_patch` does not seed a later edit). Lesson: when a concurrency/freshness gate is keyed on prior observation, treat the session's own successful mutation as an observation of the resulting bytes (gated on the bytes actually changing), or it will generate pure-friction re-read loops.
|
|
8
|
+
- **LL-004 — A token detector must distinguish command position from argument position; argument-position matches generate false positives that eventually block the guard's own tooling.** Evidence: `checkProcessMonitorCommand` (`src/policies/shell-safety.ts`) matched the monitor word anywhere as a whitespace-delimited token, so an Azure key-vault secret lookup whose `--name` value was that literal word was blocked as an "interactive process monitor" (device audit, `interactive_tty`), and during this loop the stale installed detector blocked a verification command and a commit whose text merely contained the word — proving the defect against real tooling. The fix flags only the executable of a quote-aware segment after wrapper unwrapping (`sudo`/`env`/`timeout`/…), handles `busybox <cmd>`, mirrors the pager recursion for `eval`/`sh -c`, scopes the batch-mode exemption to the monitor's own flags, and documents residual indirection (`watch`/`xargs`/`man`, `find -exec`/`su -c`, shell loop bodies) plus the pre-existing decode-escape quirk. Lesson: when a detector classifies a token as a command, verify it is in command position (executable after wrappers), and test both the false-positive argument case and the detection cases it replaced.
|
|
9
|
+
- **LL-005 — Lifecycle tools must handle the partially-gone state idempotently, not just the happy path.** Evidence: `cleanupGitWorktree` (`src/lib/worktree.ts`) returned "Worktree path '...' does not exist." for a registered worktree whose directory was missing, so the prunable admin entries that `git worktree list` shows after a manual removal, a crashed add, or external cleanup could not be cleared through the tool. The fix validates ownership first and, for a missing registered path, deregisters the stale entry with `git worktree prune` (exit status reported via `describeGitFailure`) and returns success; outside-storage, unregistered, and primary-tree cases stay refused and no raw deletion is used. Tests assert the prunable state exists before cleanup, that cleanup succeeds and deregisters, and that an unregistered missing path is still refused. Lesson: a create/cleanup pair should treat "the artifact's directory/state is partly gone" as a first-class cleanup case (deregister what exists, refuse what is outside the owned scope), or agents accumulate stale metadata they cannot clear.
|
package/docs/installation.md
CHANGED
|
@@ -127,6 +127,8 @@ OpenCode's model/provider request timeout is separate from Workflow Guard. In Op
|
|
|
127
127
|
|
|
128
128
|
Workflow Guard enforces secondary review approval (`requireReview`) by default during PR creation (`gh pr create` and `az repos pr create`). Before a pull request can be opened, an agent must invoke a secondary review subagent and record passing review approval using `record_review` evaluated against `guard_review_rubric`.
|
|
129
129
|
|
|
130
|
+
The recorded verdict is bound to the reviewed repository content (commit hash + tracked-content worktree fingerprint of the directory it was recorded against), and the PR preflight accepts any approval whose binding matches the tree the PR would publish. A review recorded by a subagent rooted in a linked git worktree therefore satisfies a `pr create` run from that worktree, even from a different session than the one that spawned the reviewer. Review freshness is scoped to tracked content: deleting an untracked scratch file does not invalidate an approval, but any tracked edit or new commit does. Because reviewer agents are frequently configured with read-only toolsets that exclude `record_review`, the tool accepts recorders of any session type and binds the verdict to the recording session's directory; the audit trail records who recorded it and where it is bound.
|
|
131
|
+
|
|
130
132
|
To opt out of the PR secondary review gate for a project, set `requireReview: false` in `.opencode/workflow-guard.json`:
|
|
131
133
|
|
|
132
134
|
```json
|
package/docs/policies.md
CHANGED
|
@@ -18,7 +18,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
18
18
|
- **Durable Planning Context Discovery:** `guard_next_tasks` prefers a repository-root `TODO.md`; when it is absent, it reads conventional `ROADMAP.md`, `PLAN.md`, `TASKS.md`, `BACKLOG.md`, their `docs/` counterparts, and Markdown files under `docs/plans/`. The tool exposes candidate planning context without selecting, prioritizing, or sequencing tasks. These files never replace OpenCode's native runtime todo state.
|
|
19
19
|
- **Subagent Mutation Budget:** Subagent sessions are protected by a mutation safety budget (`maxSubagentMutations` in project config or `WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS` env, default 100) to terminate runaway edit loops deterministically.
|
|
20
20
|
- **Concurrent File Claims:** Direct `edit`, `write`, and `apply_patch` calls claim each canonical target for the tool-call lifetime. A different active session is blocked from racing the same file (including through symlink aliases); claims are released after the call, with session idle/deletion as stale-claim cleanup when an after hook is missed. Shell writers are not covered, and isolated worktrees remain preferred for substantial parallel mutations.
|
|
21
|
-
- **Stale-Write Protection:** A successful `read` records a same-session fingerprint for an existing regular file using its canonical path, filesystem identity, size, nanosecond mtime, and SHA-256 content digest. Before `edit` or `write` replaces an existing file, that fingerprint must exist and still match; otherwise the call is blocked and must re-read. New-file creation is allowed without a prior read. Fingerprints are not inherited between parent/subagent sessions and are cleared on session idle/deletion. `apply_patch` and shell writers are deliberately outside this bounded mechanism; the pre-execution comparison also cannot make external changes between the check and OpenCode's write atomic.
|
|
21
|
+
- **Stale-Write Protection:** A successful `read` records a same-session fingerprint for an existing regular file using its canonical path, filesystem identity, size, nanosecond mtime, and SHA-256 content digest; a successful `edit`/`write` also records the resulting bytes (only when the file actually changed), since the session authored them, so iterating on a just-created or just-modified file needs no redundant re-read. Before `edit` or `write` replaces an existing file, that fingerprint must exist and still match; otherwise the call is blocked and must re-read. New-file creation is allowed without a prior read. Fingerprints are not inherited between parent/subagent sessions and are cleared on session idle/deletion. `apply_patch` and shell writers are deliberately outside this bounded mechanism (an `apply_patch` result still seeds the session observation, but its clobbering itself is not pre-checked); the pre-execution comparison also cannot make external changes between the check and OpenCode's write atomic.
|
|
22
22
|
|
|
23
23
|
### 2. No Pushes to Protected Branches
|
|
24
24
|
- `git push ... main` and `git push ... master` are blocked in all shell commands, including direct refs, refspecs (`git push origin HEAD:main`, `git push origin feature/x:main`), deletion refspecs (`git push origin :main`), and forced refspecs (`git push origin +main`).
|
|
@@ -149,7 +149,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
149
149
|
- Only **README.md** (root or package level) and files under **`docs/`** count as documentation updates; arbitrary markdown (e.g. `.changeset/*.md` fragments or `CHANGELOG.md`) does not satisfy this gate.
|
|
150
150
|
|
|
151
151
|
### 22. Non-Interactive Shell & TTY Hang Guard
|
|
152
|
-
- Blocks commands that spawn interactive text editors (`nano`, `vim`, `emacs`), interactive pagers (`less`, `more`) including through common shell wrappers, interactive monitors (`top`, `htop`), interactive rebase/patch prompts (`git rebase -i`, `git add -p`), `sudo`, or package managers missing non-interactive confirmation flags (`npm init` without `-y`, `apt-get` without `-y`). Pager names appearing only
|
|
152
|
+
- Blocks commands that spawn interactive text editors (`nano`, `vim`, `emacs`), interactive pagers (`less`, `more`) including through common shell wrappers, interactive monitors (`top`, `htop`), interactive rebase/patch prompts (`git rebase -i`, `git add -p`), `sudo`, or package managers missing non-interactive confirmation flags (`npm init` without `-y`, `apt-get` without `-y`). Pager and monitor names appearing only as command arguments (for example `az keyvault secret show --name top` or `echo top`) are not treated as pager/monitor execution; monitors are only flagged in command position (after wrappers such as `sudo`/`env`/`timeout`, through `eval` and `sh -c`, and via `busybox`), and `top` batch mode stays allowed. Indirection this does not model (`watch`/`xargs`/`man`, `find -exec`/`su -c`, and shell loop bodies such as `while true; do top; done`) is a known limitation.
|
|
153
153
|
- Prevents subshell agents from hanging indefinitely waiting for user stdin in background execution.
|
|
154
154
|
- **Desktop Notifications:** Emits native OS notifications (Linux `notify-send`, macOS `osascript`) when a policy blocks a command or when verification runs finish, keeping developers informed when their terminal is backgrounded. Configurable via `WORKFLOW_GUARD_NOTIFY=0`.
|
|
155
155
|
|
|
@@ -189,6 +189,7 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
|
|
|
189
189
|
### `guard_worktree_cleanup`
|
|
190
190
|
- Commits a final snapshot of any remaining changes (`chore(worktree): auto-snapshot before cleanup`, excluding the plugin-created `node_modules` symlink) and then removes the worktree directory with `git worktree remove --force`.
|
|
191
191
|
- **Ownership validation:** only registered worktrees of the current repository under the configured worktree storage directory can be cleaned up; arbitrary directories, other repositories' worktrees, and the primary working tree are refused. A failed `git worktree remove` is an error, never a fallback to raw directory deletion.
|
|
192
|
+
- **Idempotent for prunable entries:** a registered worktree whose directory is already gone (manual removal, a crashed add, external cleanup) is deregistered (`git worktree prune`, reported with its exit status on failure) and reported as cleaned, so stale admin entries that `git worktree list` shows as prunable can be cleared through the tool. The prune is repo-wide for the current repository: every worktree whose directory is missing at that moment is deregistered, so a temporarily unavailable path's admin entry can be dropped (worktree contents are never touched; use `git worktree repair` if a path returns).
|
|
192
193
|
- **Lossless by construction:** if the snapshot commit cannot be established (failing hook, missing identity, lock error), cleanup aborts and the worktree is left fully intact.
|
|
193
194
|
|
|
194
195
|
### Hook-Context Safety
|
|
@@ -197,6 +198,8 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
|
|
|
197
198
|
### Inspection Tools
|
|
198
199
|
- `guard_status`, `guard_audit`, `guard_why`, `guard_review_rubric`, `record_review`, `guard_review_followups`, and `guard_review_followup_resolve` provide runtime introspection of guardrail state, audit entries, block explanations, reviewer rubric definitions, reviewer decisions, and durable local review debt. `guard_status` also exposes the effective Ralph opt-in, iteration budget, and calling-session outcome when applicable.
|
|
199
200
|
- **Priority-Ranked Review Gate:** Secondary review approval (`requireReview`) is enforced by default during PR preflight (`gh pr create` / `az repos pr create`). Before creating a pull request, a secondary reviewer subagent must evaluate the changes using `guard_review_rubric` and record approval with `record_review`. Approvals that include active P0 (blocker) or P1 (major defect) issues are rejected until blockers are resolved. Projects can opt out via `.opencode/workflow-guard.json` (`{ "requireReview": false }`) or environment variable `WORKFLOW_GUARD_REQUIRE_REVIEW=0`. Conversely, `WORKFLOW_GUARD_REQUIRE_REVIEW=1` forces the review requirement regardless of project config.
|
|
201
|
+
- **Review binding is content, not session identity:** a recorded approval satisfies the PR preflight when its binding (same repository, same commit hash, same tracked-content worktree fingerprint) matches the tree the PR would publish - regardless of which session records the verdict or creates the PR. Reviewers record from subagent sessions (binding the verdict to their parent or their own directory) and sessions can move between directories (`session_move`) or spawn subagents from other roots, so keying the gate on the PR-creating session's ID made the documented flow impossible to satisfy from linked worktrees. Freshness is scoped to the reviewed diff (tracked content): untracked scratch-file cleanup and mutations in sibling worktrees cannot invalidate an approval, while any tracked edit or new commit does.
|
|
202
|
+
- **`record_review` accepts any recorder session:** reviewer agents configured with a read-only toolset (e.g. glob/grep/read/webfetch only) cannot call `record_review` themselves, so the tool no longer rejects root/orchestrator sessions; the verdict binds to the recording session's resolved directory, the tool result echoes that binding, and the audit entry (`record_review.verdict`) records the binding key and recorder lineage (`recorderRole: root | subagent`). Prefer relaying through an independent secondary reviewer subagent; the tool result reminds the recorder when it runs from a root session. A verdict recorded against a directory without a computable tracked fingerprint (not a git repository) is rejected loudly and never recorded.
|
|
200
203
|
- **Durable Lower-Priority Follow-ups:** Accepted review summaries containing a P2 or P3 finding are persisted in the project's local Workflow Guard SQLite database. Open findings are included in compacted agent context and remain open across sessions until explicitly resolved with `guard_review_followup_resolve`; they are not silently discarded with the review session.
|
|
201
204
|
- **Privacy-Safe Local Telemetry:** Guard decisions remain in the local audit JSONL. Tool after-hook observations add `callID` and duration records for local diagnosis and performance analysis; the current hook API does not distinguish success from every failure path, so these records do not claim a success outcome. Command and patch bodies are never written to the journal; only their byte count and SHA-256 fingerprint are retained for correlation.
|
|
202
205
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -100,3 +100,13 @@ After the command reports `Detected server + tui targets`, confirm it reports re
|
|
|
100
100
|
1. V2 message parts record the tool name in the `tool` field; V1 used `name`. Code reading `part.name` silently gets `undefined` on V2.
|
|
101
101
|
2. V2's builtin tool registry contains only `read`, `glob`, `grep`, `edit`, `write`, `patch`, `shell`, `webfetch`, `websearch`, `question`, `skill`, `subagent`, and `execute`. There is no builtin `todowrite` (the V1 name), and the V1 `task` subagent tool was renamed to `subagent`. `todowrite`/`bash` tool parts observed in V2 sessions come from ACP agents (for example pi) that supply their own tools; those tools are not in the server-side registry and cannot be reached by `ctx.tool.transform`.
|
|
102
102
|
- **Solution:** Read the tool name as `part.tool ?? part.name` and treat tool-part names as agent-reported, not as the builtin registry. `ToolEditor.update` ignores missing IDs, so enriching a tool that does not exist is a harmless no-op.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### 12. Review recorded with `record_review`, but `pr create` is still blocked
|
|
107
|
+
|
|
108
|
+
- **Symptoms:** `record_review` returned "Review recorded as APPROVED" and `guard_status` showed a fresh `lastReview`, yet `gh pr create` / `az repos pr create` was blocked with "Passing secondary review approval is required"; or re-calling `record_review` returned success but `guard_status` still showed no fresh review.
|
|
109
|
+
- **Root Cause (fixed by content binding):** The PR preflight previously also required the review's target session to equal the PR-creating session's ID. `record_review` records from a secondary session (binding the verdict to its parent or its own directory), sessions can move between directories mid-flight (`session_move`), and subagents spawn from other roots — so a review recorded against a linked git worktree could never satisfy a PR created from another session there. Mutation invalidation additionally erased the whole review record on any mutation, including unrelated untracked-file cleanup.
|
|
110
|
+
- **Binding rule:** a recorded approval satisfies the preflight when its binding matches the tree the PR publishes — same repository, same commit hash, same tracked-content worktree fingerprint. Review freshness is scoped to tracked content, so deleting an untracked scratch file does not invalidate it; a tracked edit or new commit does. When the tracked fingerprint is unchanged but HEAD moved (for example a review recorded with changes already staged, then committed), the preflight names the commit drift specifically.
|
|
111
|
+
- **Diagnosis:** `guard_audit` now records the binding key (workspace, commit hash, fingerprint prefix, recorder session and `recorderRole`) on every `record_review.verdict` entry, and a `pr-preflight.review-binding` entry when a PR preflight accepts a review. If a verdict cannot bind at all (directory is not a git repository), `record_review` rejects loudly with the reason instead of returning a success string.
|
|
112
|
+
- **Worktree creation failure leaving a branch behind:** `guard_worktree_create` failures previously surfaced only git's stderr (often checkout progress) and could leave a half-created branch. Failures are now reported with git's exit status, the created branch is rolled back (pre-existing branches are preserved), and stale worktree admin entries are pruned only for the newly-created path. A live registered worktree whose sanitized name collides with the requested branch is left fully intact — rollback never removes pre-existing worktrees. Large checkouts that exceed the 15s timeout can set `WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS`.
|
package/package.json
CHANGED
package/src/lib/custom-tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import type { LearningEvidenceKind } from "./types.ts";
|
|
3
|
+
import type { AuditEntry, LearningEvidenceKind } from "./types.ts";
|
|
4
4
|
import type { ProjectMemoryStore } from "./project-memory.ts";
|
|
5
5
|
import {
|
|
6
6
|
exportProjectKnowledge,
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from "./state.ts";
|
|
35
35
|
import { getRalphOutcome } from "../policies/continuation.ts";
|
|
36
36
|
import { loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
37
|
-
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint } from "./verify.ts";
|
|
37
|
+
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
38
38
|
import { buildReviewRubric } from "./review.ts";
|
|
39
39
|
import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
|
|
40
40
|
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
@@ -218,7 +218,12 @@ export function createCustomTools(options: {
|
|
|
218
218
|
const verifyCommand = detectVerifyCommand(root);
|
|
219
219
|
const verifyFresh = Boolean(lastV && verifyEvidence && lastV.passed && lastV.command === verifyCommand && isEvidenceFresh(verifyEvidence, subject, lastMut));
|
|
220
220
|
const reviewRequired = isReviewRequired(root);
|
|
221
|
-
|
|
221
|
+
// Review freshness is content-scoped, matching the PR preflight:
|
|
222
|
+
// the approval covers the tracked tree it reviewed, so it stays
|
|
223
|
+
// fresh until that content changes. Untracked scratch cleanup
|
|
224
|
+
// (or mutations in a sibling worktree) must not read as stale,
|
|
225
|
+
// while any tracked edit or new commit does.
|
|
226
|
+
const reviewFresh = Boolean(lastR && reviewEvidenceRecord && lastR.passed && isEvidenceFresh(reviewEvidenceRecord, { ...subject, worktreeFingerprint: getTrackedWorktreeFingerprint(root), sessionID: lastR.targetSessionID }, 0));
|
|
222
227
|
const documentationRequired = isDocumentationRequired(root);
|
|
223
228
|
const outstandingRequirements = [
|
|
224
229
|
...(verifyCommand && !verifyFresh ? ["verification"] : []),
|
|
@@ -258,18 +263,43 @@ export function createCustomTools(options: {
|
|
|
258
263
|
directory: tool.schema.string().optional().describe("Target repository directory being reviewed (defaults to parent session's repository or active directory)"),
|
|
259
264
|
},
|
|
260
265
|
execute: async (args, toolContext) => {
|
|
261
|
-
const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict } });
|
|
262
|
-
const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
|
|
263
|
-
if (!parentSessionID) { auditVerdict("rejected", "missing_parent_session"); return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session."; }
|
|
266
|
+
const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string, binding?: NonNullable<AuditEntry["evidence"]>["binding"]) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict, ...(binding ? { binding } : {}) } });
|
|
264
267
|
const axesRefs = ["test integrity", "task completeness", "cleanliness", "security", "platform"];
|
|
265
268
|
const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
|
|
266
269
|
if (referenced.length < 3) { auditVerdict("rejected", "insufficient_rubric_axes"); return `[workflow-guard] Review rejected: summary must reference the review axes (found ${referenced.length}/5). Call guard_review_rubric to get the rubric, evaluate each axis, and include findings per axis in the summary.`; }
|
|
267
270
|
if (args.passed && /(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) { auditVerdict("rejected", "approval_contains_blocker"); return "[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. Resolve all P0/P1 issues before approving or record review with passed=false."; }
|
|
271
|
+
// Any session may record a verdict so that reviewer agents whose
|
|
272
|
+
// toolset lacks record_review can have their verdict relayed.
|
|
273
|
+
// The verdict is bound to the reviewed DIRECTORY content (commit
|
|
274
|
+
// hash + tracked worktree fingerprint); the recorder's lineage is
|
|
275
|
+
// captured in the audit trail. The review-before-PR requirement
|
|
276
|
+
// itself is unchanged.
|
|
268
277
|
const reviewWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
278
|
+
const bindingFingerprint = getTrackedWorktreeFingerprint(reviewWorkspace);
|
|
279
|
+
if (!bindingFingerprint) {
|
|
280
|
+
auditVerdict("rejected", "review_workspace_unbindingable", { workspace: reviewWorkspace, recorderSessionID: toolContext.sessionID });
|
|
281
|
+
return `[workflow-guard] Review rejected: the verdict cannot be bound because no tracked-content fingerprint is available for '${reviewWorkspace}' (not a git repository, or git is unavailable). The verdict was NOT recorded. Call record_review with 'directory' set to the reviewed repository worktree, or run it from a session rooted there.`;
|
|
282
|
+
}
|
|
283
|
+
const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
|
|
284
|
+
const bindingCommitHash = getCurrentGitCommitHash(reviewWorkspace);
|
|
285
|
+
const binding = {
|
|
286
|
+
workspace: reviewWorkspace,
|
|
287
|
+
commitHash: bindingCommitHash,
|
|
288
|
+
worktreeFingerprint: bindingFingerprint.slice(0, 16),
|
|
289
|
+
recorderSessionID: toolContext.sessionID,
|
|
290
|
+
recorderRole: parentSessionID ? ("subagent" as const) : ("root" as const),
|
|
291
|
+
targetSessionID: parentSessionID ?? toolContext.sessionID,
|
|
292
|
+
};
|
|
293
|
+
recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID ?? toolContext.sessionID, reviewWorkspace);
|
|
294
|
+
if (followupStore && !secretIn(args.summary)) for (const finding of extractReviewFollowups(args.summary)) recordReviewFollowup(followupStore, { severity: finding.severity, summary: finding.summary, reviewer: args.reviewer, sessionID: toolContext.sessionID, commit: bindingCommitHash });
|
|
295
|
+
auditVerdict(args.passed ? "approved" : "changes_requested", args.passed ? "approved" : "changes_requested", binding);
|
|
296
|
+
const recorderNote = parentSessionID
|
|
297
|
+
? `Recorded by secondary session ${toolContext.sessionID} for parent ${parentSessionID}.`
|
|
298
|
+
: `Recorded by root session ${toolContext.sessionID}; prefer relaying through an independent secondary reviewer subagent when one is available.`;
|
|
299
|
+
const boundTo = `Bound to ${reviewWorkspace} (commit ${bindingCommitHash?.slice(0, 12) ?? "unversioned"}, tracked fingerprint ${bindingFingerprint.slice(0, 12)}).`;
|
|
300
|
+
return args.passed
|
|
301
|
+
? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}. ${boundTo} ${recorderNote}`
|
|
302
|
+
: `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}. ${boundTo} ${recorderNote}`;
|
|
273
303
|
},
|
|
274
304
|
}),
|
|
275
305
|
guard_review_followups: tool({ description: "List durable local P2/P3 review follow-ups (technical debt) for this project. Proactively check during planning or before final verification to address open findings.", args: {}, execute: async () => JSON.stringify(followupStore ? listReviewFollowups(followupStore) : [], null, 2) }),
|
|
@@ -285,8 +315,8 @@ export function createCustomTools(options: {
|
|
|
285
315
|
const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
|
|
286
316
|
const rubricWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, directory: args?.directory, fallback: toolContext?.worktree || toolContext?.directory || effectiveRoot });
|
|
287
317
|
let diffText = "";
|
|
288
|
-
for (const base of bases) { const res = spawnSync("git", ["diff", "--", `${base}...HEAD`], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); if (res.status === 0 && res.stdout.trim()) { diffText = res.stdout; break; } }
|
|
289
|
-
if (!diffText) { const last = spawnSync("git", ["diff", "--", "HEAD~1"], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); diffText = last.status === 0 ? last.stdout : "(no diff available)"; }
|
|
318
|
+
for (const base of bases) { const res = spawnSync("git", ["diff", "--no-ext-diff", `${base}...HEAD`], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); if (res.status === 0 && res.stdout.trim()) { diffText = res.stdout; break; } }
|
|
319
|
+
if (!diffText) { const last = spawnSync("git", ["diff", "--no-ext-diff", "HEAD~1"], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); diffText = last.status === 0 ? last.stdout : "(no diff available)"; }
|
|
290
320
|
return buildReviewRubric(diffText);
|
|
291
321
|
},
|
|
292
322
|
}),
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { audit, loadVerifyCache } from "./audit.ts";
|
|
4
4
|
import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
5
5
|
import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
getLastReviewResultForWorkspace,
|
|
8
8
|
getLiveControlPlaneRoots,
|
|
9
9
|
getSdkClient,
|
|
10
10
|
getWorkspaceRoot,
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
recordMutation,
|
|
16
16
|
recordVerifyResult,
|
|
17
17
|
sessionMutationTimestamps,
|
|
18
|
-
sessionReviews,
|
|
19
18
|
sessionVerifyResults,
|
|
20
19
|
} from "./state.ts";
|
|
21
20
|
import {
|
|
@@ -34,6 +33,7 @@ import {
|
|
|
34
33
|
getCurrentGitCommitHash,
|
|
35
34
|
getGitStatusSummary,
|
|
36
35
|
getGitWorktreeFingerprint,
|
|
36
|
+
getTrackedWorktreeFingerprint,
|
|
37
37
|
runVerify,
|
|
38
38
|
resolveVerifyTimeoutMs,
|
|
39
39
|
snipVerifyOutput,
|
|
@@ -440,16 +440,45 @@ export async function guardToolCallImpl(
|
|
|
440
440
|
if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
|
|
441
441
|
}
|
|
442
442
|
if (isReviewRequired(prRoot)) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
443
|
+
// Review evidence binds to the reviewed repository CONTENT: a
|
|
444
|
+
// passing approval satisfies this PR when its recorded subject
|
|
445
|
+
// (same repository, same commit hash, same tracked-content
|
|
446
|
+
// worktree fingerprint) matches the tree this PR would publish.
|
|
447
|
+
// It deliberately does NOT bind to the PR-creating session's ID:
|
|
448
|
+
// record_review records from a secondary session (binding the
|
|
449
|
+
// verdict to the recorder's parent or its own directory), and
|
|
450
|
+
// sessions can move between directories (session_move) and
|
|
451
|
+
// spawn subagents from many roots, so the previous
|
|
452
|
+
// session-identity requirement made the documented
|
|
453
|
+
// review->PR flow impossible to satisfy from linked worktrees.
|
|
454
|
+
// Content binding is the security property; the requirement
|
|
455
|
+
// itself is not removed or weakened.
|
|
456
|
+
const prCommitHash = getCurrentGitCommitHash(prRoot);
|
|
457
|
+
const prFingerprint = getTrackedWorktreeFingerprint(prRoot);
|
|
458
|
+
const review = getLastReviewResultForWorkspace(prRoot);
|
|
459
|
+
const matchedReview = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: prCommitHash, worktreeFingerprint: prFingerprint, sessionID: review.targetSessionID }, 0) ? review : undefined;
|
|
460
|
+
if (matchedReview) {
|
|
461
|
+
if (!context?.simulate) {
|
|
462
|
+
audit({
|
|
463
|
+
ts: new Date().toISOString(),
|
|
464
|
+
sessionID: context?.sessionID,
|
|
465
|
+
tool: "pr-preflight.review-binding",
|
|
466
|
+
decision: "allow",
|
|
467
|
+
phase: "event",
|
|
468
|
+
reason: "review_evidence_matched",
|
|
469
|
+
evidence: {
|
|
470
|
+
evidenceId: reviewEvidence(matchedReview).id,
|
|
471
|
+
reviewer: matchedReview.reviewer,
|
|
472
|
+
reviewTimestamp: matchedReview.timestamp,
|
|
473
|
+
binding: {
|
|
474
|
+
workspace: projectRootKey(prRoot),
|
|
475
|
+
commitHash: prCommitHash,
|
|
476
|
+
worktreeFingerprint: prFingerprint?.slice(0, 16),
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
});
|
|
448
480
|
}
|
|
449
|
-
}
|
|
450
|
-
const prFingerprint = getGitWorktreeFingerprint(prRoot);
|
|
451
|
-
const reviewMatchesContext = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: getCurrentGitCommitHash(prRoot), worktreeFingerprint: prFingerprint, sessionID: review.targetSessionID }, 0) && (!review.targetSessionID || review.targetSessionID === context?.sessionID);
|
|
452
|
-
if (!reviewMatchesContext) {
|
|
481
|
+
} else {
|
|
453
482
|
// When an approval exists but cannot be matched to this
|
|
454
483
|
// worktree, the message must name the actual cause instead
|
|
455
484
|
// of the generic "review required" (which sends the agent
|
|
@@ -457,13 +486,21 @@ export async function guardToolCallImpl(
|
|
|
457
486
|
// Cause attribution only makes sense for approvals of THIS
|
|
458
487
|
// repository; a review of another repo is not evidence here.
|
|
459
488
|
const reviewIsForThisRepo = typeof review?.workspace === "string" && (projectRootKey(review.workspace) === projectRootKey(prRoot) || isSameGitRepo(review.workspace, prRoot));
|
|
460
|
-
const
|
|
461
|
-
if (
|
|
489
|
+
const sameRepoReview = review && reviewIsForThisRepo ? review : undefined;
|
|
490
|
+
if (sameRepoReview && !sameRepoReview.passed) {
|
|
491
|
+
preflightFailures.push(`The most recent secondary review for this repository (by '${sameRepoReview.reviewer}') requested changes; address the findings and obtain a fresh passing approval with record_review.`);
|
|
492
|
+
} else if (sameRepoReview && prFingerprint === undefined) {
|
|
462
493
|
preflightFailures.push("Worktree fingerprint could not be computed (git unavailable or worktree unreadable), so review evidence cannot be bound to this worktree; resolve worktree readability and re-run the secondary review.");
|
|
463
|
-
} else if (
|
|
464
|
-
preflightFailures.push(`The recorded review approval from '${
|
|
465
|
-
} else if (
|
|
466
|
-
preflightFailures.push(`The recorded review approval from '${
|
|
494
|
+
} else if (sameRepoReview && !sameRepoReview.worktreeFingerprint) {
|
|
495
|
+
preflightFailures.push(`The recorded review approval from '${sameRepoReview.reviewer}' has no worktree fingerprint binding (recorded against an unreadable worktree or an older guard version); re-run the secondary review and record it with record_review.`);
|
|
496
|
+
} else if (sameRepoReview && sameRepoReview.worktreeFingerprint !== prFingerprint) {
|
|
497
|
+
preflightFailures.push(`The recorded review approval from '${sameRepoReview.reviewer}' does not match the current worktree contents (tracked content changed after review); re-run the secondary review and record it with record_review.`);
|
|
498
|
+
} else if (sameRepoReview && sameRepoReview.commitHash && prCommitHash && sameRepoReview.commitHash !== prCommitHash) {
|
|
499
|
+
// Tracked fingerprint can be unchanged while HEAD moves
|
|
500
|
+
// (for example a review recorded with changes already
|
|
501
|
+
// staged, then committed): name the commit drift instead
|
|
502
|
+
// of the generic review-required message.
|
|
503
|
+
preflightFailures.push(`The recorded review approval from '${sameRepoReview.reviewer}' is bound to commit ${sameRepoReview.commitHash.slice(0, 12)} but this worktree is at commit ${prCommitHash.slice(0, 12)}; re-run the secondary review at the current commit and record it with record_review.`);
|
|
467
504
|
} else {
|
|
468
505
|
preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
|
|
469
506
|
}
|
package/src/lib/state.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
|
|
5
5
|
import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
6
|
-
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
|
|
6
|
+
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
7
7
|
import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
|
|
8
8
|
import type {
|
|
9
9
|
TodoSdkClient,
|
|
@@ -104,15 +104,14 @@ export function recordMutation(sessionID?: string, actorSessionID?: string): voi
|
|
|
104
104
|
sessionMutationTimestamps.set(id, lastMutationTimestamp);
|
|
105
105
|
sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
|
|
106
106
|
sessionVerifyResults.delete(id);
|
|
107
|
-
sessionReviews.delete(id);
|
|
108
|
-
}
|
|
109
|
-
if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID || lastReview.targetSessionID === actorSessionID) {
|
|
110
|
-
lastReview = undefined;
|
|
111
|
-
try {
|
|
112
|
-
const p = getReviewCacheFilePath();
|
|
113
|
-
if (existsSync(p)) unlinkSync(p);
|
|
114
|
-
} catch {}
|
|
115
107
|
}
|
|
108
|
+
// Review evidence is intentionally NOT erased here. Recorded approvals bind
|
|
109
|
+
// to the reviewed tracked content (commit hash + tracked worktree
|
|
110
|
+
// fingerprint), so freshness is re-evaluated against content at
|
|
111
|
+
// consumption time. Erasing on any mutation made an unrelated untracked
|
|
112
|
+
// scratch-file deletion (or a mutation in a sibling worktree) invalidate a
|
|
113
|
+
// valid approval and let a re-record silently fail to stick; any change to
|
|
114
|
+
// the reviewed tracked content still makes the evidence stale.
|
|
116
115
|
}
|
|
117
116
|
|
|
118
117
|
export function getMutationCount(sessionID?: string): number {
|
|
@@ -195,7 +194,7 @@ export function recordReviewResult(
|
|
|
195
194
|
passed: boolean,
|
|
196
195
|
targetSessionID?: string,
|
|
197
196
|
workspace?: string,
|
|
198
|
-
):
|
|
197
|
+
): ReviewResult {
|
|
199
198
|
const root = projectRootKey(workspace ?? getWorkspaceRoot());
|
|
200
199
|
lastReview = {
|
|
201
200
|
reviewer,
|
|
@@ -206,12 +205,15 @@ export function recordReviewResult(
|
|
|
206
205
|
workspace: root,
|
|
207
206
|
commitHash: getCurrentGitCommitHash(root),
|
|
208
207
|
gitStatus: getGitStatusSummary(root),
|
|
209
|
-
|
|
208
|
+
// Bind to tracked repository content only: the review covered the
|
|
209
|
+
// reviewed diff, and untracked scratch files are not part of it.
|
|
210
|
+
worktreeFingerprint: getTrackedWorktreeFingerprint(root),
|
|
210
211
|
};
|
|
211
212
|
if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
|
|
212
213
|
if (passed) {
|
|
213
214
|
persistReviewCache(lastReview);
|
|
214
215
|
}
|
|
216
|
+
return lastReview;
|
|
215
217
|
}
|
|
216
218
|
|
|
217
219
|
export function getLastReviewResult(): typeof lastReview {
|
package/src/lib/types.ts
CHANGED
|
@@ -130,6 +130,17 @@ export interface AuditEntry {
|
|
|
130
130
|
};
|
|
131
131
|
allowLive?: boolean;
|
|
132
132
|
reviewVerdict?: "approved" | "changes_requested" | "rejected";
|
|
133
|
+
evidenceId?: string;
|
|
134
|
+
reviewer?: string;
|
|
135
|
+
reviewTimestamp?: number;
|
|
136
|
+
binding?: {
|
|
137
|
+
workspace?: string;
|
|
138
|
+
commitHash?: string;
|
|
139
|
+
worktreeFingerprint?: string;
|
|
140
|
+
recorderSessionID?: string;
|
|
141
|
+
recorderRole?: "root" | "subagent";
|
|
142
|
+
targetSessionID?: string;
|
|
143
|
+
};
|
|
133
144
|
};
|
|
134
145
|
}
|
|
135
146
|
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -55,7 +55,7 @@ import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTod
|
|
|
55
55
|
import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
|
|
56
56
|
import { checkCompletionClaims } from "../policies/completion.ts";
|
|
57
57
|
import { releaseFileClaims } from "../policies/file-claims.ts";
|
|
58
|
-
import { beginReadObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
|
|
58
|
+
import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
|
|
59
59
|
import { editTargets, runPostEditValidators, snapshotFile } from "../policies/post-edit-validation.ts";
|
|
60
60
|
import { audit, summarizeInput } from "./audit.ts";
|
|
61
61
|
import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
|
|
@@ -281,6 +281,9 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
281
281
|
const pending = toolLifecycle.takePostEditSnapshots(event.sessionID, event.id);
|
|
282
282
|
if (!pending) return;
|
|
283
283
|
await runWithRuntimeState(pending.root, client, async () => {
|
|
284
|
+
// Seed the session's observation of the bytes it just mutated so a
|
|
285
|
+
// follow-up edit needs no redundant re-read (V1 parity).
|
|
286
|
+
for (const before of pending.snapshots) recordMutationObservation(before.path, event.sessionID, before.digest);
|
|
284
287
|
const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
|
|
285
288
|
const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
|
|
286
289
|
if (!report) return;
|
package/src/lib/verify.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, type Hash } from "node:crypto";
|
|
3
3
|
import { lstatSync, readFileSync, readlinkSync, existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { dynamicShellSyntaxIn, normalize } from "./shell.ts";
|
|
@@ -141,6 +141,35 @@ function warnFingerprintSkip(root: string, file: string, detail: string): void {
|
|
|
141
141
|
} catch {}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Content hash over tracked repository content only: the index listing
|
|
146
|
+
* (staged/tracked blob hashes) plus the worktree-vs-index diff (unstaged
|
|
147
|
+
* edits). Review evidence binds to this fingerprint, not to the full one:
|
|
148
|
+
* untracked scratch files are not part of the reviewed diff, so creating,
|
|
149
|
+
* modifying, or deleting them must never invalidate a recorded approval.
|
|
150
|
+
*/
|
|
151
|
+
function trackedContentHash(root: string): Hash | undefined {
|
|
152
|
+
const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
timeout: 5_000,
|
|
156
|
+
});
|
|
157
|
+
const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
|
|
158
|
+
cwd: root,
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
timeout: 5_000,
|
|
161
|
+
});
|
|
162
|
+
if (staged.status !== 0 || unstaged.status !== 0) return undefined;
|
|
163
|
+
return createHash("sha256").update(staged.stdout).update(unstaged.stdout);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getTrackedWorktreeFingerprint(root: string): string | undefined {
|
|
167
|
+
try {
|
|
168
|
+
return trackedContentHash(root)?.digest("hex");
|
|
169
|
+
} catch {}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
144
173
|
export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
145
174
|
try {
|
|
146
175
|
// Index listing captures the exact staged/tracked content (blob hashes);
|
|
@@ -148,23 +177,14 @@ export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
|
148
177
|
// contents cover files git does not yet track. Together these bind the
|
|
149
178
|
// fingerprint to file contents regardless of whether HEAD exists, so
|
|
150
179
|
// freshly initialized (commitless) repositories are covered too.
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
encoding: "utf8",
|
|
154
|
-
timeout: 5_000,
|
|
155
|
-
});
|
|
156
|
-
const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
|
|
157
|
-
cwd: root,
|
|
158
|
-
encoding: "utf8",
|
|
159
|
-
timeout: 5_000,
|
|
160
|
-
});
|
|
180
|
+
const hash = trackedContentHash(root);
|
|
181
|
+
if (!hash) return undefined;
|
|
161
182
|
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
162
183
|
cwd: root,
|
|
163
184
|
encoding: "utf8",
|
|
164
185
|
timeout: 5_000,
|
|
165
186
|
});
|
|
166
|
-
if (
|
|
167
|
-
const hash = createHash("sha256").update(staged.stdout).update(unstaged.stdout);
|
|
187
|
+
if (untracked.status !== 0) return undefined;
|
|
168
188
|
for (const file of untracked.stdout.split("\0").filter(Boolean).sort()) {
|
|
169
189
|
// The path itself is always part of the fingerprint, so a skipped
|
|
170
190
|
// entry still binds its presence even when its contents do not.
|