opencode-workflow-guard 1.18.1 → 1.19.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 +4 -4
- package/docs/installation.md +23 -0
- package/docs/policies.md +31 -3
- package/docs/testing.md +3 -1
- package/package.json +2 -2
- package/src/lib/audit.ts +52 -2
- package/src/lib/custom-tools.ts +154 -3
- package/src/lib/dogfood.ts +147 -0
- package/src/lib/evidence.ts +19 -1
- package/src/lib/guard-dispatcher.ts +134 -13
- package/src/lib/permission-seat.ts +79 -1
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +301 -2
- package/src/lib/types.ts +71 -1
- package/src/lib/v2-plugin.ts +32 -1
- package/src/policies/boundary.ts +307 -24
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +56 -5
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +1286 -20
package/README.md
CHANGED
|
@@ -43,9 +43,9 @@ WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run policy probes with Ope
|
|
|
43
43
|
|
|
44
44
|
## Core Guardrails Overview
|
|
45
45
|
|
|
46
|
-
The plugin enforces **
|
|
46
|
+
The plugin enforces **27 deterministic policy rules** across four main pillars:
|
|
47
47
|
|
|
48
|
-
* **Branch & History Protection:** Enforces feature-branch workflows and blocks edits and commits on `main`/`master`. Direct and forced pushes to protected branches are hard-blocked. The guard checks mergeability before PR creation, requires secondary review approval bound to the reviewed worktree content, and requires a PR changelog or changeset.
|
|
48
|
+
* **Branch & History Protection:** Enforces feature-branch workflows and blocks edits and commits on `main`/`master`. Direct and forced pushes to protected branches are hard-blocked. The guard checks mergeability before PR creation, requires secondary review approval bound to the reviewed worktree content, and requires a PR changelog or changeset. An opt-in dogfood gate can additionally require a recorded run of the change on real hardware before a PR opens. Verdicts follow the tree the command actually runs in: git parsing takes the shell's `workdir` as its base, an in-command `cd`/`pushd`/`popd` and a wrapper `-C`/`-D` move the tree later segments are judged in, and a nested interpreter payload is judged by its own `cd` or `cwd` option. A linked worktree of the same repository is judged rather than boundary-blocked, a PR preflight resolves `--head`/`--source-branch` to the checkout holding that branch, and the V2 permission seat defers cwd-dependent commands to `execute.before` instead of judging the session-bound checkout. A `workdir` in a different repository is not judged: mutations and git writes there are blocked, because relative targets would be validated against the wrong tree.
|
|
49
49
|
* **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks, which subagents inherit. A task can be marked completed only after its own recorded work. Review and investigation tasks use an audited `[read-only]` marker instead. The guard also blocks concurrent direct edits to the same canonical file across sessions. It requires a same-session fresh read before `edit`/`write` can replace an existing file. Silent task deletion is blocked, and task completion requires fresh test verification evidence. Completion claims and sweeping repository claims are journaled against their evidence. Repeated failures force a live-documentation lookup instead of a guess from training data.
|
|
50
50
|
* **Secrets & Workspace Confinement:** Confines edit tools and recognized shell/git mutations to the workspace root, including symlink and `../` escape checks. Agent subshells have sensitive environment variables scrubbed, and `.env` reads return a safe schema mask. 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, and infrastructure commands. Stops script laundering and interpreter evasion, blocks TTY hangs such as `vim`, `nano`, and `sudo`, and guards against dangerous package manager flags.
|
|
@@ -59,7 +59,7 @@ For complete policy specifications and override rules, see [docs/policies.md](do
|
|
|
59
59
|
Enforcement is only half the surface. The plugin also registers first-class agent tools under the same names on OpenCode 1.x and 2.x, so disciplined workflow is the path of least resistance:
|
|
60
60
|
|
|
61
61
|
* **Task Planning:** `guard_next_tasks` surfaces durable repository planning context at session start or during planning. It reads `TODO.md`, `ROADMAP.md`, `PLAN.md`, `TASKS.md`, `BACKLOG.md`, their `docs/` counterparts, and plans under `docs/plans/`. `guard_status` reports active guardrails, branch protection, mutation counts, outstanding verification and review gates, a read-only git-hygiene snapshot, and the loaded `pluginVersion`.
|
|
62
|
-
* **Review & Verification:** before PR creation, a secondary reviewer subagent evaluates the diff against `guard_review_rubric`. The rubric covers five axes: test integrity, task completeness, cleanliness, security, and platform. The reviewer records its verdict with `record_review`, and approvals bind to the reviewed worktree content.
|
|
62
|
+
* **Review & Verification:** before PR creation, a secondary reviewer subagent evaluates the diff against `guard_review_rubric`. The rubric covers five axes: test integrity, task completeness, cleanliness, security, and platform. The reviewer records its verdict with `record_review`, and approvals bind to the reviewed worktree content. An opt-in dogfood gate adds a recorded end-to-end run on real hardware with `record_dogfood`.
|
|
63
63
|
* **Worktree Isolation:** `guard_worktree_create` and `guard_worktree_cleanup` give parallel subagents isolated linked worktrees, so concurrent mutations never collide.
|
|
64
64
|
* **Technical Debt:** `guard_review_followups` lists open P2/P3 durable review follow-ups. `guard_review_followup_resolve` closes one after the underlying issue is fixed and verified.
|
|
65
65
|
* **Policy Simulation:** `guard_why` returns the structured policy decision for a tool call or command without performing it. `guard_audit` shows recent audited policy entries.
|
|
@@ -74,7 +74,7 @@ The context modules are project memory, Socratic learning, and recovery checkpoi
|
|
|
74
74
|
|
|
75
75
|
| Guide | Description |
|
|
76
76
|
|---|---|
|
|
77
|
-
| [**Policy Reference**](docs/policies.md) | Specification of all
|
|
77
|
+
| [**Policy Reference**](docs/policies.md) | Specification of all 27 enforced policies, invariants, and the proactive tool surface (`guard_*`, `project_memory_*`, `learning_*`) |
|
|
78
78
|
| [**Installation & Configuration**](docs/installation.md) | Setup options, global and local install, worktree isolation, and project configuration |
|
|
79
79
|
| [**Static Policy Posture**](docs/policy-posture.md) | How the guard emits host-level policies for invariants a repository cannot lift. Covers both host envelopes (`experimental.policies` on 2.0.x, top-level `policies` on 2.1.x), precedence, and residual risk |
|
|
80
80
|
| [**Managed Deployment**](docs/managed-deployment.md) | Administrator-managed OpenCode policy, platform locations, and startup diagnostics |
|
package/docs/installation.md
CHANGED
|
@@ -245,6 +245,29 @@ For stricter environments, set `requireSubagentReview: true` in `.opencode/workf
|
|
|
245
245
|
|
|
246
246
|
---
|
|
247
247
|
|
|
248
|
+
## Pre-Deployment Dogfood Gate
|
|
249
|
+
|
|
250
|
+
Policy 27 gates PR creation on a recorded run of the change on real hardware. It is **off by default**, because a gate is only meaningful once a repository has a runner that can produce a real run. Enable it after that runner exists:
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"requireDogfood": true,
|
|
255
|
+
"dogfoodDeployMatrix": ["bluefin", "windows-ci"]
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`WORKFLOW_GUARD_REQUIRE_DOGFOOD=1` enables the gate for any project, and `WORKFLOW_GUARD_REQUIRE_DOGFOOD=0` disables it even when project config enables it. `WORKFLOW_GUARD_DOGFOOD_DEPLOY_MATRIX` (comma-separated) overrides the matrix.
|
|
260
|
+
|
|
261
|
+
The guard supplies the gate, the evidence record, and the recursion refusal. It does not spawn the actor session; the plugin may constrain, record, and explain, but it does not delegate. A repository supplies the runner, the frozen scenario bank, and the taxonomy, then records the outcome with `record_dogfood`.
|
|
262
|
+
|
|
263
|
+
A recorded run binds to the commit hash and tracked-content worktree fingerprint of the directory it covered, exactly like review evidence. Commit every tracked change and run the scenario afterwards, so one pass satisfies both the review and dogfood gates. Only a `pass` verdict is durable, and only the newest record per workspace and host is kept. A `hold` stays diagnosable in memory with its taxonomy class and never becomes evidence. Recording a `hold` also discards that host's cached passes, so a process restart cannot re-open the gate on an older green run.
|
|
264
|
+
|
|
265
|
+
Records accumulate, so a multi-host deploy matrix keeps one pass per host. Only the newest record per host counts. A newer `hold` on a host therefore reopens the gate even when that host passed earlier, so a later regression cannot hide behind an older green run. Retention and hold eviction are scoped to the workspace, so one repository's runs never touch another's evidence.
|
|
266
|
+
|
|
267
|
+
The runner sets `WORKFLOW_GUARD_DOGFOOD_ACTOR=1` and `WORKFLOW_GUARD_DOGFOOD_DEPTH` on the session it spawns. That session runs the full plugin with every policy intact, and the marker only refuses `record_dogfood`, so a tested session cannot certify its own run.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
248
271
|
## Live-Documentation Research Floor
|
|
249
272
|
|
|
250
273
|
Policy 25 is enabled by default. A session that accumulates failed tool calls with no live-documentation lookup since gets its next mutating or shell call blocked. The block holds until the agent consults a current source. Only non-research failures count, so a failed `websearch` never deepens the block. Reads, greps, and research tools are never gated.
|
package/docs/policies.md
CHANGED
|
@@ -85,16 +85,20 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
85
85
|
- The agent is prompted to create a feature branch first (`git switch -c feat/my-feature`).
|
|
86
86
|
|
|
87
87
|
### 8. Workspace Boundary Guard
|
|
88
|
-
- File modification tools (`edit`, `write`, `apply_patch`) and recognized shell mutations are validated so detected targets cannot escape the current workspace root. The recognized shell mutations are redirection `>`, `>>`, `&>`, `>&`, `tee`, `sed -i`, `cp`/`mv`/`ln`, `touch`, `mkdir`, `rm`, `truncate`, `dd of=`, `git apply`, and `git am`. The check covers `../` traversal, symlinks, absolute paths, and shell expansions (`~`, `~user`, `$HOME`). Unresolvable `$VARIABLE` references fail closed. This is command-level enforcement, not filesystem sandboxing. Arbitrary programs and dynamically computed paths require the OS isolation described under Known Limits.
|
|
89
|
-
- **Unresolvable variable cause attribution:** A target containing an unresolvable `$VARIABLE` reference is indeterminate without executing shell semantics. Examples are `mkdir -p $D/sub` and `> $CACHE/out.txt`. The guard therefore treats it as outside the workspace. The block message says so ("contains an unresolvable variable reference … use a literal workspace-relative path") instead of showing the generic escape message. Deterministically expandable forms (`~`, `~user`, `$HOME`, `${HOME}`) keep the generic message. Cache-path and other literal destinations inside the workspace are unaffected.
|
|
88
|
+
- File modification tools (`edit`, `write`, `apply_patch`) and recognized shell mutations are validated so detected targets cannot escape the current workspace root. The recognized shell mutations are redirection `>`, `>>`, `&>`, `>&`, `tee`, `sed -i`, `cp`/`mv`/`ln`, `touch`, `mkdir`, `rm`, `truncate`, `dd of=`, `git apply`, and `git am`. The check covers `../` traversal, symlinks, absolute paths, and shell expansions (`~`, `~user`, `$HOME`). Unresolvable `$VARIABLE` references fail closed. This is command-level enforcement, not filesystem sandboxing. Arbitrary programs and dynamically computed paths require the OS isolation described under Known Limits. Another user's home cannot be read portably, so `~user` is guessed as the name joined onto this home's parent. A workspace rooted at that parent therefore contains the guess. Keep the workspace below the home directory, which is the usual layout, and the guess stays outside it.
|
|
89
|
+
- **Unresolvable variable cause attribution:** A target containing an unresolvable `$VARIABLE` reference is indeterminate without executing shell semantics. Examples are `mkdir -p $D/sub` and `> $CACHE/out.txt`. The guard therefore treats it as outside the workspace. The block message says so ("contains an unresolvable variable reference … use a literal workspace-relative path") instead of showing the generic escape message. Deterministically expandable forms (`~`, `~user`, `$HOME`, `${HOME}`) keep the generic message. Cache-path and other literal destinations inside the workspace are unaffected. A git repository selection is judged the same way: `git -C $OTHER_REPO commit` stays a boundary block even when the shell's own workspace is a repository. The same-repository exemption that admits linked worktrees resolves the nearest existing ancestor, so an indeterminate value there would otherwise read as the workspace's own root, and the write would look local. Known limit: another user's home is not readable portably, so `~user` joins the name onto this home's parent. A workspace rooted at that parent therefore contains the guessed path, and a `~user`-relative mutation there is judged local. Keep the workspace root below the home directory, which is the normal layout, and this guess lands outside it.
|
|
90
90
|
- **Scratch-directory cause attribution:** `/tmp/opencode` is the environment's designated agent scratch space, but the workspace boundary is workspace-absolute and does not cover it. Writes there stay blocked. The block message names the directory and points to the remedy instead of reading as a guard bug. The remedy is an untracked scratch file inside the workspace, for example `.tmp-notes.md`. The enforcement decision is unchanged.
|
|
91
91
|
- **Symlink-alias acceptance:** A target whose lexical path falls outside the workspace root but whose canonical resolution is inside it is accepted. One example is `/home/<user>/...` on hosts where `/home` is a symlink into `/var/home` and the workspace root lives under `/var/home`. A target stays blocked whenever the canonical resolution is outside the canonicalized root. That includes symlinks inside the workspace that point outside, and not-yet-created files under them (nearest existing ancestor). When canonicalization is indeterminate, the lexical verdict stands (fail closed). A path is outside the workspace only when both its lexical and its canonical resolution are outside.
|
|
92
92
|
- `tee` flag parsing generically skips option flags (`-a`, `--append`, `-ai`, `--`) and validates all specified target files against the boundary.
|
|
93
93
|
- Embedded non-shell comparisons are not mutations. A bare `>` whose target is purely numeric (`WHERE count > 5`, awk `$3 > 100`) and `>=` or `==` operators are treated as comparison operands, not redirects. Real redirects are unaffected (`> src/a.ts`, `>>`, `&>`, `>&`, and fd targets like `/dev/null` still follow their existing rules).
|
|
94
94
|
- `mv` sources are validated too. Moving a file from outside the workspace is blocked, since `mv` mutates the source. The same applies to moving a protected settings or plugin file to an innocuous name.
|
|
95
95
|
- External repository git writes (`git -C /other-repo commit`, `git --git-dir=/other-repo/.git push`, `GIT_DIR=/other-repo/.git git push`, `GIT_WORK_TREE=...`) are also confined to the workspace.
|
|
96
|
+
- A **linked worktree of the same repository** is not foreign state. The shell's `workdir` legitimately lives there when a session works in an isolated checkout, so git mutations and pushes there are judged rather than boundary-blocked. A genuinely different repository keeps the boundary block. Git invocation parsing takes the shell's `workdir` as its base directory, so branch, mergeability, and protected-branch checks judge the tree the command actually runs in rather than the session-bound checkout. Recognized shell mutations are validated against that same tree, so a relative target in a linked worktree is judged where it actually lands. A `workdir` that is a genuinely different repository cannot be judged at all, because the command runs there while its relative targets would be resolved against the workspace root. Any recognized mutation or git write in that workdir is therefore blocked as a workspace escape, and only read-only commands there are allowed.
|
|
97
|
+
- **An in-command directory change moves the tree later segments are judged in.** `planShellSegments` walks `cd`, `pushd`, and `popd` and resolves a root per segment. Without it, `cd /other-repo && git commit` read as a commit in the session's own checkout, which both allowed a foreign write and denied nothing. The resolved root drives git parsing, shell-mutation validation, redirect snapshots, and the protected-branch check, so `cd nested && echo x > artifact.txt` is judged where the file lands. A `cd` inside a grouping subshell is scoped to it, and the subshell's own directory stack goes with it, so `( cd /other && ls ) && rm -rf dist` stays a workspace removal. A `cd` inside a conditional or loop body (`then`, `else`, `do`, a `case` arm, a brace group) is not scoped, because that body runs in the current shell. Which body runs depends on a condition the walk cannot evaluate, so if any body changes directory the later directory is undetermined and a mutation after the construct fails closed. A body that does not change directory leaves the tree intact. A `case` arm counts wherever the `case` keyword sits, including after `then`, `else`, `do`, or `elif`, and a pattern's own `)` closes no group. A wrapper that changes directory (`env -C`, `sudo -D`, `--chdir`) is followed for its own command only. A newline separates commands exactly like `;`, so a newline-separated `cd` moves the tree of the lines that follow. Planning runs on the raw command text rather than a whitespace-flattened copy, because flattening erased that separator. A heredoc body stays inside the segment that introduces it, and it is code only when the command reading it executes it. Only deterministically expandable targets are resolved (`~`, `~user`, `~/…`, `$HOME`, `${HOME}`). An indeterminate target (`cd $DIR`, `cd -`, a bare or rotating `pushd`, `pushd +N`/`-N`/`-n`, a rotating `popd +N`/`-N`, a `popd` with no tracked push) followed by a mutation fails closed, because relative targets there cannot be validated against the tree the command would actually change. Reads stay allowed.
|
|
98
|
+
- **A nested interpreter payload is judged against its own declared tree.** `bash -c 'cd /other-repo && git commit'` is one shell word to the outer shell, so its nested `cd` and mutation were invisible to the top-level walk. Each payload is re-planned, recursively, so a launcher inside a launcher (`bash -c "bash -c 'cd /other && git commit'"`) is judged at its innermost tree. A payload's `git -C <repo>` is checked against the workspace like any top-level git write. A working directory counts only where one actually applies: a Node spawn-family `cwd` (`execSync`, `spawn`, `fork`) or `process.chdir`. An unrelated `{cwd: …}` object key names none, so it cannot foreign-flag a payload that writes in the workspace, and `regex.exec(str, {cwd})` is not mistaken for a spawned process. A payload that mutates outside the workspace, and is not a linked worktree of it, is refused. A non-literal working directory (`{cwd: process.env.OTHER}`) fails closed for a mutation, matching the shell rule. A payload launched from an indeterminate `cd` fails closed for a mutation too, instead of being assumed to run in the workspace root. A heredoc body is data for the command that consumes it, so it is scanned as code only when that command executes it (`bash <<'EOF' … EOF`, `source /dev/stdin <<'EOF'`, or a body piped into a command). A pipe hands the body to *every* stage, so a filter followed by a shell (`| grep x | sh`) is code too. `|&` is a pipe as well. `||` is not: it runs the right side only on failure, so it delivers no body. A stage counts as data only when its command word is a known filter (`cat`, `grep`, `jq`, `column`, and similar). Programs that can run a command named in their own arguments are not filters, so `sed` (`sed 'e'`), `awk` (`awk '{system($0)}'`), and `sort` (`sort --compress-program`) are scanned as code. Any other downstream command, including an uncommon shell such as `ash`, is scanned as code, because naming only the common launchers let those bodies run unread. A body feeding `cat` or another file writer is prose, so documentation quoting a blocked command shape stays writable, and that applies to the interpreter, shell-mutation, and secret-read scans alike. One header line may introduce several bodies, and an unterminated body is scanned to end of input because the shell still runs it. Heredocs are located with the splitter's own quote tracking, so a quoted `"<<"` opens no body and cannot mask the code after it. Only a leading `eval` (optionally behind `builtin`/`command`/`time` or a `NAME=value` assignment) forwards its body as shell text; a prose mention of `eval` does not. Known limits: only the Node forms are modelled, so another interpreter's own directory change (`python -c "os.chdir(x); open('a','w')"`) is not detected, though its absolute write targets are still caught by the interpreter scanner. A working directory whose base is a literal but whose tail is computed (`{cwd: path.join('<workspace>', 'sub')}`) is reported as indeterminate and blocked, which is conservative rather than an escape.
|
|
99
|
+
- **A repository claim is only an assignment.** `GIT_DIR=`/`GIT_WORK_TREE=`/`GIT_COMMON_DIR=` select a repository only when consumed as an assignment before the command word, including through `env`/`sudo`/`timeout` wrappers. A value that merely appears inside an argument is message text, not a claim, so `git commit -m GIT_DIR=/repo` is parsed as a normal commit in the shell's checkout. A selector expands through the shell's own forms, so `git -C ~/repo` names the home-directory repository and `~user/path` keeps the path after the home, and the attached short form (`git -C/path`) names one too. A selector this walk cannot expand keeps its literal text, so an unresolvable `$VARIABLE` still fails closed instead of resolving to the workspace root.
|
|
96
100
|
- The boundary has **no override**: `WORKFLOW_GUARD_ALLOW_LIVE=1` covers live-system commands only and never weakens the workspace confinement.
|
|
97
|
-
- **The boundary is bound once, then only the host may widen it.** A session adopts its workspace from its own first tool-call context. That context is a call-argument path, or the host-provided per-call worktree. After that, a path outside the boundary is foreign state. The boundary policy refuses mutations there, and the path never re-anchors the session. Only an explicit
|
|
101
|
+
- **The boundary is bound once, then only the host may widen it.** A session adopts its workspace from its own first tool-call context. That context is a call-argument path, or the host-provided per-call worktree. After that, a path outside the boundary is foreign state. The boundary policy refuses mutations there, and the path never re-anchors the session. Only a host statement widens a bound session: an explicit per-call worktree, or a host session move. `session_move` relocates a session between checkouts, and the host announces it as V1 `session.updated` or as V2 `session.moved`. The guard rebinds the session to the moved directory and journals `workspace.binding` / `workspace_rebind_session_move`. Only a genuine move counts. An announced `session.moved` counts by itself, and so does an update naming a directory different from the last one the host named for that session. A routine title or cost update names the same directory and changes nothing, because a session bound to a linked worktree by its own tool-call `workdir` legitimately differs from the session's creation directory. On OpenCode 1.x there is no move event, so a first observation falls back to the repository itself: a directory that resolves to a DIFFERENT repository is a move and rebinds, while one that resolves to a linked worktree of the bound repository only records the baseline. Worktree isolation and relay flows set it deliberately. A subagent is bound by the same rule through its nearest bound ancestor. With no direct binding of its own, it already inherits the parent's workspace for policy decisions. Its first foreign read is therefore refused as a widening rather than adopted as a first bind. Every granted widening is journaled (`workspace.binding` / `workspace_rebind_host_worktree`). Every refused one is journaled with the read-or-mutation label (`workspace.binding` / `workspace_rebind_refused`). This closes a defect where one allowed read of another repository moved the entire write boundary there, because `external_directory` defaults to allow.
|
|
98
102
|
|
|
99
103
|
### 9. Script-Laundering Guard
|
|
100
104
|
- Content written via `edit`, `write`, or `apply_patch` is scanned for destructive CLI commands.
|
|
@@ -132,6 +136,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
132
136
|
- On OpenCode 2, the V2 plugin also *decides* at that seat. `ctx.permission.hook("evaluate")` maps the host's normalized `action` and `resources` back onto the policy core and sets `effect = "deny"` with an attributable message, so the tool never starts. Shell resources feed the shell, git, destructive, and workspace policies. `read` resources feed the secret policy. `edit`, `write`, `patch`, and `apply_patch` resources feed the boundary, tamper, secret, protected-branch, and todo policies. Guarded MCP servers (`GitHub`, `Azure DevOps`) feed the MCP policy.
|
|
133
137
|
- The seat can only tighten. An explicit configured `deny` is final and never reaches the hook. An unmapped host action (`external_directory`, `glob`, `grep`, `webfetch`, `websearch`, `question`, `subagent`) is left to host policy. If the seat itself throws, it fails closed with a deny instead of becoming a pass.
|
|
134
138
|
- **Only a finding denies; an undecidable simulation abstains.** The seat always evaluates the policy core with `simulate: true`, which skips the remote GitHub/Azure PR probe on purpose. The core reports `needs_approval` (`remote_state_unchecked`) when it cannot decide without that probe. Collapsing that status into a denial blocked every non-protected `git push`, so the seat now abstains: it leaves the host's configured effect and message untouched and defers the real decision to `execute.before`, which runs the probe. The abstention is journaled with `decision: "abstain"`, `reason: "deferred_to_execute_before"`, and the preserved `needs_approval` decision, so a skipped probe stays visible instead of reading as a plain allow. `guard_why` keeps reporting `needs_approval`; this is a change to the seat, not to the policy core.
|
|
139
|
+
- **A cwd-dependent command is deferred, not judged.** The seat sees normalized `action`/`resources`, never the shell tool's `workdir`. For a bare `git push`, `git commit`, other history-mutating git writes, branch creation, and PR creation, the repository is therefore an assumption rather than a fact. The session binding may be the primary checkout while the command targets a linked worktree. Both directions of that verdict are unreliable: a deny may be the protected branch of the wrong tree, and an allow may be a feature checkout that hides a push to a protected destination. Those commands therefore abstain with code `repository_unidentified`, and `execute.before` resolves the workdir and enforces. Two cases stay decisive: an explicit `-C`/`--git-dir`/`--work-tree`/`GIT_DIR` names the repository in the command text, and a push whose destination is `main` or `master` is a finding in every checkout. A configured `protectedBranches` entry is not that case: the seat recognizes the built-in pair only, so a push to a configured protected branch abstains and `execute.before` judges it against the resolved workdir. A trailing `-C`/`--git-dir` with no value names no repository but still carries the flag, so it counts as an explicit claim and both seats agree the command is unclassifiable. A block from any other policy (`cat .env`, a destructive live command) is a finding about the command text itself, so it keeps its denial even in the same compound command.
|
|
135
140
|
- `execute.before` is kept as the second, last-resort seat. It sees raw tool input, so todo lifecycle, stale-write observations, and post-edit snapshots stay there. Its thrown message carries the specific remediation text to the model.
|
|
136
141
|
- `client.app.log()` complements the on-disk trail with in-app logs.
|
|
137
142
|
|
|
@@ -142,6 +147,7 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
142
147
|
- Active Git branch name and protected branch status.
|
|
143
148
|
- Test verification status (passed/failed, test command, and commit hash).
|
|
144
149
|
- Secondary review verdicts (reviewer name and approval status).
|
|
150
|
+
- Dogfood evidence state when Policy 27 is enabled, naming the deploy-matrix hosts still missing a passing run.
|
|
145
151
|
- Uncommitted mutation counts.
|
|
146
152
|
- Compacted project-memory context points back to `project_memory_search` for deeper historical retrieval instead of attempting to embed the full durable store.
|
|
147
153
|
- Preserves policy-relevant context across session compactions without assigning, prioritizing, or sequencing tasks for the model.
|
|
@@ -157,9 +163,12 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
157
163
|
|
|
158
164
|
### 18. Interpreter Inline Evasion Scanner
|
|
159
165
|
- Decodes and inspects inline interpreter payloads to prevent smuggling live destructive commands past shell pattern checks. Smuggled content includes settings tampering, secret reads, and out-of-workspace writes. The inspected forms are `python -c`, `node -e`, `perl -e`, `ruby -e`, `osascript -e`, `bash -c`, `sh -c`, `zsh -c`, `powershell -enc`, and `echo <base64> | base64 -d | sh`.
|
|
166
|
+
- A payload is read as one whole shell word, not as the text between the first pair of quotes. Concatenated spans are joined and quote escapes are decoded, because that is what one shell argument is. The `'\''` idiom closes and reopens a single-quoted span, so reading only up to the first closing quote truncated `bash -c 'bash -c '\''cd /other && rm -rf x'\'''` to a bare `bash -c `, and the real nested command escaped the scan. The decoded body is re-planned at its own tree. Nesting past the modelled depth is refused rather than skipped.
|
|
160
167
|
|
|
161
168
|
### 19. Conflict-Free Pre-Flight Guard
|
|
162
169
|
- Evaluates `git merge-tree` against the base branch (`origin/main`, `origin/master`, `main`) before PR creation or final task handoff, ensuring changes can be merged cleanly without conflicts.
|
|
170
|
+
- **A PR is judged against the branch it publishes, not against the caller's checkout.** The preflight reads `gh --head`/`-H` and `az --source-branch` to learn the source branch. It then resolves that branch to the checkout that holds it with `git worktree list`, so a session sitting in the primary checkout still judges a branch reviewed in a linked worktree. Accuracy requires the caller to supply the PR source branch and the linked worktree: a bare PR command with neither falls back to the invoking checkout.
|
|
171
|
+
- The merge check takes the resolved ref, so a branch that is not checked out anywhere locally is judged by its own ref instead of silently checking `HEAD`. Resolution is limited to what the checkout can see: a ref that does not exist in the repository at all produces no merge base against any base candidate, so the check reports no conflict rather than guessing at one.
|
|
163
172
|
|
|
164
173
|
### 20. Merged Branch & Base Freshness Guard
|
|
165
174
|
- Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
|
|
@@ -203,6 +212,17 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
203
212
|
- Uses the `experimental.text.complete` hook to compare a sweeping claim against this session's read coverage (`sessionReadFileCount`: distinct files read or authored). A sweeping claim backed by fewer than 2 touched files is journaled to the audit trail (`Sweeping claim '<phrase>' with N file(s) read`) and logged at info level.
|
|
204
213
|
- **Observability, not gating**, matching Policy 24. The response is never blocked or rewritten, because a grep-heavy investigation can legitimately support "no other callers" while touching few files. Prior art covers the EPISTEMIC LEDGER "bare assertion" (arXiv 2602.07549) and the agent-control-tower false-completion study. Both find that more context does not fix overclaiming, and that the check belongs in the system, not in self-report.
|
|
205
214
|
|
|
215
|
+
### 27. Pre-Deployment Dogfood Evidence (opt-in)
|
|
216
|
+
- Gates PR creation on a recorded run of the change on real hardware. Off by default: a repository enables it with `.opencode/workflow-guard.json` (`requireDogfood: true`) or `WORKFLOW_GUARD_REQUIRE_DOGFOOD=1`, and disables it with `WORKFLOW_GUARD_REQUIRE_DOGFOOD=0`.
|
|
217
|
+
- The guard owns the gate, the evidence record, and the recursion refusal. It does not spawn the actor session, because the plugin's scope excludes planning and delegation. A repository supplies its own runner, scenario bank, and frozen taxonomy, then records the outcome with `record_dogfood`.
|
|
218
|
+
- **Evidence, not a receipt.** A run is recorded as `dogfood` evidence bound to the same subject as review evidence: workspace, commit hash, and tracked-content worktree fingerprint. Any tracked edit or new commit after the run makes the evidence stale, so the gate asks for a re-run rather than trusting a stale pass. No pre-commit layer is involved.
|
|
219
|
+
- **A HOLD is never durable.** A `hold` verdict stays in memory so its taxonomy class is diagnosable, and it is never written to the disk cache. Only a `pass` is cached, so a failed run can never later read as evidence. Recording a HOLD also drops that host's cached passes, because otherwise a process restart would drop the in-memory HOLD and serve the older green run. The cache keeps only the newest record per workspace and host, so a HOLD leaves no record behind for its key and a later PASS on another host cannot resurrect the evicted one.
|
|
220
|
+
- **A pass must be complete.** `record_dogfood` refuses a `pass` verdict when any check row is not `pass`, and refuses a record with no check rows at all. A skipped check is a HOLD by the design's own rule, so accepting it would let the gate pass by omission. An empty check list asserts nothing, so it is refused rather than read as a trivially complete pass. A HOLD must name a taxonomy class.
|
|
221
|
+
- **Deploy matrix.** `dogfoodDeployMatrix` (or `WORKFLOW_GUARD_DOGFOOD_DEPLOY_MATRIX`, comma-separated) lists the host labels that each need a passing record. Unset means the recorded host alone satisfies the gate. Records accumulate rather than replacing each other, so one host's pass never overwrites another's. Only the newest record per host counts, which means a newer HOLD on a host reopens the gate even if that host passed earlier. Retention and HOLD eviction are keyed on workspace plus host, never globally, so repeated runs on a busy host cannot evict another host's only pass and a run in one repository never touches another's evidence. A host outside the matrix cannot block the gate, so its HOLD is never reported as the blocking cause. The disk cache keeps only the newest record per workspace and host, so a HOLD leaves no durable record for its key and a later PASS on a different host cannot resurrect an evicted pass.
|
|
222
|
+
- **Recursion by refusal, not absence.** The runner sets `WORKFLOW_GUARD_DOGFOOD_ACTOR=1` and `WORKFLOW_GUARD_DOGFOOD_DEPTH` on the session it spawns. Every policy still applies in that session, and the plugin is present and enforcing. The marker only refuses `record_dogfood`, so the tested session cannot certify itself. An unparseable marker fails closed to a non-zero depth.
|
|
223
|
+
- Freshness is content-scoped like the review gate, so a nested run in a linked worktree can inherit the binding for identical tracked content. A record for unrelated content never satisfies the gate.
|
|
224
|
+
- `guard_status` reports `lastDogfood` and adds `dogfood` to `outstandingRequirements` when the gate is unsatisfied, naming the matrix hosts still missing a passing run.
|
|
225
|
+
|
|
206
226
|
### Tool Description Honesty & Cross-Model Adherence
|
|
207
227
|
- Via the `tool.definition` hook, the guard enriches tool descriptions to keep them honest and proactive across all model families. The families are OpenAI, Gemini, Claude, and open-weights models:
|
|
208
228
|
- `todowrite`: Explains its replacement-list lifecycle, finalization verification gate, and directs models to `guard_next_tasks` for roadmap discovery and `guard_status` for gate checks.
|
|
@@ -246,6 +266,14 @@ Besides enforcement hooks, the guard registers companion tools in OpenCode:
|
|
|
246
266
|
- **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. They remain open across sessions until explicitly resolved with `guard_review_followup_resolve`, and they are not silently discarded with the review session.
|
|
247
267
|
- **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.
|
|
248
268
|
|
|
269
|
+
### `record_dogfood`
|
|
270
|
+
- Records a pre-deployment dogfood run for Policy 27: `record_dogfood(scenarioId, scenarioSha256, taxonomySha256, verdict, checks, hostLabel, ...)`.
|
|
271
|
+
- `checks` is a JSON array of `{id, status, expected, observed, detail}` rows. `expected` sits beside `observed` so a HOLD is diagnosable from the record alone, without re-running the scenario.
|
|
272
|
+
- The verdict binds to the recording session's resolved directory, or to an explicit `directory`. It is refused, loudly and without recording, when no tracked-content fingerprint can be computed for that directory.
|
|
273
|
+
- A `pass` requires every check to be `pass`, and a `hold` must name a taxonomy class. A record with no check rows is refused: an empty list asserts nothing about the run. Every refusal is journaled under `record_dogfood.verdict` with `decision: "block"`.
|
|
274
|
+
- A session carrying `WORKFLOW_GUARD_DOGFOOD_ACTOR=1` or a non-zero `WORKFLOW_GUARD_DOGFOOD_DEPTH` is refused. The tested session cannot certify its own run.
|
|
275
|
+
- The recorded digest pair is part of the evidence source, so replaying a passing run onto a different scenario or taxonomy changes the evidence id.
|
|
276
|
+
|
|
249
277
|
---
|
|
250
278
|
|
|
251
279
|
## Overrides ("Unless Otherwise Specified")
|
package/docs/testing.md
CHANGED
|
@@ -53,8 +53,10 @@ npm run test:all
|
|
|
53
53
|
| **Completion Claims & Tool Honesty** | Verifies the claims-vs-evidence detector (missing/failing/stale/fresh states), `experimental.text.complete` journaling, and `tool.definition` enrichment idempotence. |
|
|
54
54
|
| **Compaction & TUI Feedback** | Verifies active task injection in `experimental.session.compacting`, the static TUI badge, guard block warning toasts, and clean error propagation. |
|
|
55
55
|
| **Runtime Instance Isolation** | Verifies concurrent plugin instances keep workspace root and SDK client state isolated via `runWithRuntimeState`. |
|
|
56
|
-
| **V2 Permission Seat Abstention** | Verifies the simulated seat denies a real finding, abstains on
|
|
56
|
+
| **V2 Permission Seat Abstention** | Verifies the simulated seat denies a real finding, abstains on a decision it cannot make instead of denying, never upgrades a configured `ask`, and journals the abstention as `deferred_to_execute_before` with the preserved decision. Covers both the remote-probe deferral (`remote_state_unchecked`) and the cwd-dependent deferral (`repository_unidentified`). |
|
|
57
|
+
| **Worktree-Aware Git Verdicts** | Verifies a cwd-dependent push or PR creation abstains with `repository_unidentified` instead of being judged against the session-bound checkout, that a non-checkout finding (`cat .env`) in the same compound command is still denied, that an explicit `-C` still runs the core, that a push to `main`/`master` is denied regardless of working directory while a push to a configured `protectedBranches` destination defers, that an explicitly named protected checkout is denied, that an explicitly named repository outside the workspace stays a boundary finding, and that a journaled deferral carries its own code. Also verifies that a `GIT_DIR=` value inside a commit message is not read as a repository claim while a real assignment, including through `env`/`sudo`/`timeout` wrappers, is, that an unresolvable repository selection (`git -C $OTHER_REPO`) stays a boundary block even inside a git workspace, and that a host session move rebinds only on a genuine move rather than on every `session.updated`. Also verifies that a relative shell mutation or git write in a `workdir` outside the workspace is blocked while a read-only command there, and a mutation in a linked worktree of the same repository, are allowed. Also verifies in-command `cd` tracking: `cd <foreign> && git commit`, `cd <foreign> && echo x > f` are blocked while a `cd` there followed by a read stays allowed, a newline-separated `cd <foreign>` moves the tree of the lines that follow for a removal, a git write, and a redirect, while its read-only form stays allowed, a heredoc shell body that cds into a foreign repository is blocked for a removal and a git write, including when the body is unterminated, read by `source`, or piped into a shell or an uncommon executor such as `ash`, `busybox sh`, `make -f -`, or `xargs sh -c`, while a body piped into a known filter such as `grep` stays prose, a body reaching a shell through a later pipeline stage (`| grep x | sh`, `| cat | bash`, `| tee /dev/null | sh`), a body delivered through `|&` to a shell (`|& sh`, `|& ash`) while `|&` into a filter and `||` stay prose, is blocked, a body consumed by a program that can run a command named in its own arguments (`sed 'e'`, `sed -n '1e echo hi'`, `awk '{system($0)}'`, `sort --compress-program`) is blocked, a data heredoc quoting a blocked command shape or a bare `rm` line stays writable and a second body on the same header line stays data, a quoted `"<<"` does not mask a later interpreter payload, a tilde repository selector (`git -C ~/repo`, `git -C~/repo`, `~root/path`) expands through the shell instead of naming a literal `~` directory inside the workspace, an assignment-prefixed `eval` moves the tree of later segments while a harmless one does not, an in-command `cd` into a workspace subdirectory is judged where it lands rather than as a foreign tree, an in-command `cd` into a linked worktree is allowed, an indeterminate `cd $VAR` before a write fails closed, `env -C <foreign> git commit` is blocked, a `cd` inside a grouping subshell does not move a later segment's tree, and nested payloads are judged by their own tree (`bash -c 'cd <foreign> && git commit'` blocked, `execSync(..., {cwd: '<foreign>'})` blocked, `process.chdir('<foreign>')` before a write blocked, an unrelated `{cwd: …}` object key not treated as a working directory, `regex.exec(str, {cwd})` not treated as a spawn, a non-literal `{cwd: process.env.X}` failing closed, a payload launched from an indeterminate `cd` failing closed while its read-only form stays allowed, a doubly nested launcher re-planned to its innermost tree, a payload `git -C` into a foreign repository blocked while a linked worktree stays allowed, a spawn `cwd` after a nested call still read as the working directory, a `pushd` inside a grouping subshell not leaking into a later outer `popd`, a `pushd +N`/`-n` rotation failing closed before a mutation while its read-only form stays allowed, a `cd` inside a `then`/`do`/`case`/brace-group body moving the tree of later segments, a `cd ~user` not read as a workspace-relative path, and a `--head` token inside a quoted PR body not read as the source branch, in-workspace and linked-worktree payloads allowed). |
|
|
57
58
|
| **V2 Todo Capability** | Verifies the `todowrite` registry probe (`available` / `unavailable` / `unknown`), that an empty history gates only when planning is possible, the once-per-session fail-open warning and audit record, and `todoCapability` in `guard_status`. |
|
|
58
59
|
| **Per-Task Completion Evidence** | Verifies a task cannot move to `completed` without a mutation in its own window, that one mutation cannot vouch for several completions, that staggered windows reject a claim whose window saw no work, that mutations before a task became active never count, that `[read-only]` completes freely and is journaled, that the marker can be appended to an already-planned task without tripping the no-silent-deletion rule, that duplicate content cannot hide a new completion, and that `guard_why` reports the same verdict while moving no window. |
|
|
59
60
|
| **Static Policy Posture** | Verifies the emitted posture is deny-only, emits both host envelopes (`experimental.policies`/`permission` for 2.0.x, top-level `policies`/`tool.use` for 2.1.x), requires both to reach the same verdict for every probed resource, and matches the intended commands under a replica of the host's whole-string glob matcher, including the compound-command evasion it does not cover. |
|
|
61
|
+
| **Dogfood Gate (Policy 27)** | Verifies the gate stays off until a repository opts in, that PR creation is blocked without fresh evidence and allowed with it, that a tracked edit and a new commit each invalidate the record with a named cause, that a HOLD is never durable and removes that host's cached pass so a restart cannot re-satisfy the gate, that a same-millisecond HOLD and PASS resolve to the HOLD in the durable cache so a restart stays unsatisfied, that a later pass on another host cannot resurrect an evicted one, that a HOLD in a cold process or in another workspace leaves unrelated hosts' passes intact, that an out-of-matrix HOLD is not reported as the blocking cause, that a `pass` is refused when any check is skipped or the check list is empty, that a HOLD must name a taxonomy class, that an actor session cannot record its own verdict, that the deploy matrix needs one fresh pass per host, that a newer HOLD on a host reopens the gate, that the PR audit names every covered host, and that per-host retention cannot evict another host's pass. |
|
|
60
62
|
| **Modular Exports & Shape** | Verifies V1 `PluginModule` `{ id, server }` structure, modular policy imports, and `output.args` hook contract. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.1",
|
|
4
4
|
"description": "Deterministic workflow and safety policy enforcement for OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"prepublishOnly": "npm run typecheck"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@opencode-ai/plugin": "1.19.
|
|
53
|
+
"@opencode-ai/plugin": "1.19.4",
|
|
54
54
|
"@opencode/ai": "2.2.0",
|
|
55
55
|
"@opencode/plugin": "2.2.0",
|
|
56
56
|
"@opentui/solid": "0.7.0",
|
package/src/lib/audit.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, readSync, closeSync, renameSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, readSync, closeSync, renameSync, statSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { Database, type SqliteDatabase } from "./sqlite.ts";
|
|
6
|
-
import type { AuditEntry, PolicyDecision, VerifyResult, ReviewResult } from "./types.ts";
|
|
6
|
+
import type { AuditEntry, PolicyDecision, VerifyResult, ReviewResult, DogfoodResult } from "./types.ts";
|
|
7
7
|
import { asRecord } from "./utils.ts";
|
|
8
8
|
|
|
9
9
|
const AUDIT_DIR = join(
|
|
@@ -15,6 +15,7 @@ const AUDIT_FILE = join(AUDIT_DIR, "workflow-guard.jsonl");
|
|
|
15
15
|
const VERIFY_CACHE_FILE = join(AUDIT_DIR, "last-verify.json");
|
|
16
16
|
const VERIFY_HISTORY_FILE = join(AUDIT_DIR, "verify-history.jsonl");
|
|
17
17
|
const REVIEW_CACHE_FILE = join(AUDIT_DIR, "last-review.json");
|
|
18
|
+
const DOGFOOD_CACHE_FILE = join(AUDIT_DIR, "last-dogfood.json");
|
|
18
19
|
const MAX_AUDIT_BYTES = 4 * 1024 * 1024;
|
|
19
20
|
const RETAIN_AUDIT_BYTES = 2 * 1024 * 1024;
|
|
20
21
|
const MAX_VERIFY_HISTORY_BYTES = 1024 * 1024;
|
|
@@ -67,6 +68,10 @@ export function getReviewCacheFilePath(): string {
|
|
|
67
68
|
return REVIEW_CACHE_FILE;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
export function getDogfoodCacheFilePath(): string {
|
|
72
|
+
return DOGFOOD_CACHE_FILE;
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
function appendBoundedJsonlUnlocked(path: string, value: unknown, maxBytes: number, retainBytes: number): void {
|
|
71
76
|
appendFileSync(path, JSON.stringify(value) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
72
77
|
chmodSync(path, 0o600);
|
|
@@ -188,6 +193,51 @@ export function loadReviewCache(): ReviewResult | undefined {
|
|
|
188
193
|
return undefined;
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Persists the passing dogfood records. Only passes are durable: a HOLD stays
|
|
198
|
+
* in memory so the taxonomy is diagnosable without a failed run later reading
|
|
199
|
+
* as evidence. Records are kept as an array, because a deploy matrix needs one
|
|
200
|
+
* pass per host.
|
|
201
|
+
*
|
|
202
|
+
* Never throws: a cache write must not break a tool call. A failure returns
|
|
203
|
+
* false so the caller can journal it, because a failed HOLD eviction would
|
|
204
|
+
* otherwise leave a stale pass that re-opens the gate after a restart.
|
|
205
|
+
*/
|
|
206
|
+
export function persistDogfoodCache(dogfoodData: DogfoodResult[]): boolean {
|
|
207
|
+
try {
|
|
208
|
+
// An empty list means no passing evidence remains, so the file is
|
|
209
|
+
// removed rather than left as an empty array. A hold on the only host
|
|
210
|
+
// must leave no durable evidence behind.
|
|
211
|
+
if (dogfoodData.length === 0) {
|
|
212
|
+
if (existsSync(DOGFOOD_CACHE_FILE)) unlinkSync(DOGFOOD_CACHE_FILE);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
216
|
+
writeFileSync(DOGFOOD_CACHE_FILE, JSON.stringify(dogfoodData, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
217
|
+
chmodSync(DOGFOOD_CACHE_FILE, 0o600);
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Loads durable passing dogfood records from disk. A cache written by an older
|
|
226
|
+
* single-record format is read as a one-element list rather than discarded.
|
|
227
|
+
*/
|
|
228
|
+
export function loadDogfoodCache(): DogfoodResult[] {
|
|
229
|
+
try {
|
|
230
|
+
if (!existsSync(DOGFOOD_CACHE_FILE)) return [];
|
|
231
|
+
const raw = readFileSync(DOGFOOD_CACHE_FILE, "utf8");
|
|
232
|
+
const data = JSON.parse(raw);
|
|
233
|
+
const candidates = Array.isArray(data) ? data : [data];
|
|
234
|
+
return candidates.filter((entry): entry is DogfoodResult =>
|
|
235
|
+
Boolean(entry) && typeof entry.scenarioId === "string" && typeof entry.timestamp === "number" && entry.verdict === "pass",
|
|
236
|
+
);
|
|
237
|
+
} catch {}
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
|
|
191
241
|
/**
|
|
192
242
|
* Reads recent audit entries from the end of the JSONL audit log with a bounded
|
|
193
243
|
* buffer window to prevent memory spikes on long-lived installations.
|
package/src/lib/custom-tools.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import type { AuditEntry, LearningEvidenceKind } from "./types.ts";
|
|
4
|
+
import type { AuditEntry, DogfoodCheck, DogfoodResult, LearningEvidenceKind } from "./types.ts";
|
|
5
5
|
import type { ProjectMemoryStore } from "./project-memory.ts";
|
|
6
6
|
import {
|
|
7
7
|
exportProjectKnowledge,
|
|
@@ -13,22 +13,26 @@ import {
|
|
|
13
13
|
searchProjectMemory,
|
|
14
14
|
} from "./project-memory.ts";
|
|
15
15
|
import { loadLearnerProfile, recordLearningEvidence, selectLearningOpportunity, updateLearnerProfile } from "./learning.ts";
|
|
16
|
-
import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
16
|
+
import { isEvidenceFresh, dogfoodEvidence, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
17
17
|
import {
|
|
18
18
|
getLearningInterventionBudget,
|
|
19
19
|
getOperationProfile,
|
|
20
20
|
getProjectConfig,
|
|
21
21
|
getRalphMaxIterations,
|
|
22
|
+
getLastDogfoodResultForWorkspace,
|
|
23
|
+
getDogfoodDeployMatrix,
|
|
22
24
|
getLastReviewResultForWorkspace,
|
|
23
25
|
getLastVerifyResultForWorkspace,
|
|
24
26
|
getWorkspaceMutationCount,
|
|
25
27
|
getWorkspaceMutationTimestamp,
|
|
26
28
|
getWorkspaceRoot,
|
|
29
|
+
isDogfoodActor,
|
|
27
30
|
isDocumentationRequired,
|
|
28
31
|
isRecoveryCheckpointsEnabled,
|
|
29
32
|
isRalphModeEnabled,
|
|
30
33
|
isReviewRequired,
|
|
31
34
|
isSubagentReviewRequired,
|
|
35
|
+
recordDogfoodResult,
|
|
32
36
|
recordMutation,
|
|
33
37
|
recordReviewResult,
|
|
34
38
|
resolveEffectiveWorkspace,
|
|
@@ -39,6 +43,7 @@ import { getRalphOutcome } from "../policies/continuation.ts";
|
|
|
39
43
|
import { todoEvidenceReport } from "../policies/todo.ts";
|
|
40
44
|
import { findGitRoot, loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
41
45
|
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
46
|
+
import { evaluateDogfoodGate } from "./dogfood.ts";
|
|
42
47
|
import { buildReviewRubric } from "./review.ts";
|
|
43
48
|
import { createGitWorktree, cleanupGitWorktree, validateHostWorktreeDirectory } from "./worktree.ts";
|
|
44
49
|
import type { HostWorktreePort } from "./host-worktree.ts";
|
|
@@ -73,6 +78,12 @@ export function buildWorkflowGuardSystemGuidance(options: {
|
|
|
73
78
|
projectMemoryEnabled?: boolean;
|
|
74
79
|
learningEnabled?: boolean;
|
|
75
80
|
recoveryCheckpointsEnabled?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Whether this repository's PR preflight also requires dogfood evidence.
|
|
83
|
+
* Reported only when the gate is enabled, so an unopted repository never
|
|
84
|
+
* advertises a requirement it does not have.
|
|
85
|
+
*/
|
|
86
|
+
dogfoodRequired?: boolean;
|
|
76
87
|
isReadOnly?: boolean;
|
|
77
88
|
}): string {
|
|
78
89
|
const sections: string[] = [
|
|
@@ -106,6 +117,12 @@ export function buildWorkflowGuardSystemGuidance(options: {
|
|
|
106
117
|
);
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
if (options.dogfoodRequired && !options.isReadOnly) {
|
|
121
|
+
sections.push(
|
|
122
|
+
"- Dogfood Gate: This repository requires a recorded end-to-end run on real hardware before a PR opens. Run the repository's dogfood runner for the change, then record the observed checks with `record_dogfood`. Commit first: the evidence binds to the commit and tracked worktree content, so a later tracked edit or commit makes it stale.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
if (options.recoveryCheckpointsEnabled) {
|
|
110
127
|
sections.push(
|
|
111
128
|
"- Recovery: If workspace state needs rollback to the pre-run checkpoint, use `guard_recovery_restore`.",
|
|
@@ -272,9 +289,16 @@ export function createCustomTools(options: {
|
|
|
272
289
|
// while any tracked edit or new commit does.
|
|
273
290
|
const reviewFresh = Boolean(lastR && reviewEvidenceRecord && lastR.passed && isEvidenceFresh(reviewEvidenceRecord, { ...subject, worktreeFingerprint: getTrackedWorktreeFingerprint(root), sessionID: lastR.targetSessionID }, 0));
|
|
274
291
|
const documentationRequired = isDocumentationRequired(root);
|
|
292
|
+
// Dogfood is evaluated through the same helper the PR preflight
|
|
293
|
+
// uses, so the two can never disagree about the gate.
|
|
294
|
+
const dogfoodGate = evaluateDogfoodGate(root);
|
|
295
|
+
const lastD = dogfoodGate.matched ?? getLastDogfoodResultForWorkspace(root);
|
|
296
|
+
const lastDogfoodEvidence = lastD ? dogfoodEvidence(lastD) : undefined;
|
|
297
|
+
const dogfoodFresh = Boolean(dogfoodGate.required && dogfoodGate.missingHosts.length === 0);
|
|
275
298
|
const outstandingRequirements = [
|
|
276
299
|
...(verifyCommand && !verifyFresh ? ["verification"] : []),
|
|
277
300
|
...(reviewRequired && !reviewFresh ? ["review"] : []),
|
|
301
|
+
...(dogfoodGate.required && !dogfoodFresh ? ["dogfood"] : []),
|
|
278
302
|
...(documentationRequired && !branchHasDocumentationChange(root) ? ["documentation"] : []),
|
|
279
303
|
];
|
|
280
304
|
const recommendedActions: string[] = [];
|
|
@@ -284,6 +308,9 @@ export function createCustomTools(options: {
|
|
|
284
308
|
if (outstandingRequirements.includes("review")) {
|
|
285
309
|
recommendedActions.push("Call guard_review_rubric to retrieve the 5-axis review rubric, then spawn a secondary reviewer subagent via task to evaluate changes and record verdict with record_review.");
|
|
286
310
|
}
|
|
311
|
+
if (outstandingRequirements.includes("dogfood")) {
|
|
312
|
+
recommendedActions.push(`Run the repository's dogfood runner for this change, then record the result with record_dogfood.${dogfoodGate.missingHosts.length > 0 ? ` Hosts still missing a passing run: ${dogfoodGate.missingHosts.join(", ")}.` : ""}`);
|
|
313
|
+
}
|
|
287
314
|
if (outstandingRequirements.includes("documentation")) {
|
|
288
315
|
recommendedActions.push("Update documentation files (e.g. README.md, docs/) to satisfy the documentation gate before opening a PR.");
|
|
289
316
|
}
|
|
@@ -297,7 +324,7 @@ export function createCustomTools(options: {
|
|
|
297
324
|
}
|
|
298
325
|
const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
|
|
299
326
|
const taskEvidence = await runWithRuntimeState(root, client, () => todoEvidenceReport(toolContext.sessionID));
|
|
300
|
-
return JSON.stringify({ pluginVersion: loadedPluginVersion() ?? null, qualifiedHostVersion: qualifiedHostVersion() ?? null, hostVersion: hostVersion?.() ?? null, workspaceRoot: root, branch, onProtectedBranch: isProtected, todoCapability: todoCapability ? todoCapability() : "available", taskEvidence, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, requireSubagentReview: isSubagentReviewRequired(root), recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) }, gitHygiene: gitHygieneSnapshot(root) ?? null }, null, 2);
|
|
327
|
+
return JSON.stringify({ pluginVersion: loadedPluginVersion() ?? null, qualifiedHostVersion: qualifiedHostVersion() ?? null, hostVersion: hostVersion?.() ?? null, workspaceRoot: root, branch, onProtectedBranch: isProtected, todoCapability: todoCapability ? todoCapability() : "available", taskEvidence, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, lastDogfood: lastD && lastDogfoodEvidence ? { scenarioId: lastD.scenarioId, verdict: lastD.verdict, host: lastD.host.label, taxonomyClass: lastD.taxonomyClass ?? null, runCount: lastD.runCount, fresh: dogfoodGate.required ? dogfoodFresh : null, evidenceId: lastDogfoodEvidence.id, missingHosts: dogfoodGate.missingHosts, coveredHosts: (dogfoodGate.matchedRecords ?? []).map((record) => record.host.label) } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, requireSubagentReview: isSubagentReviewRequired(root), requireDogfood: dogfoodGate.required, dogfoodDeployMatrix: dogfoodGate.required ? getDogfoodDeployMatrix(root) : [], recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) }, gitHygiene: gitHygieneSnapshot(root) ?? null }, null, 2);
|
|
301
328
|
},
|
|
302
329
|
}),
|
|
303
330
|
guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
|
|
@@ -371,6 +398,130 @@ export function createCustomTools(options: {
|
|
|
371
398
|
: `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}. ${boundTo} ${recorderNote}`;
|
|
372
399
|
},
|
|
373
400
|
}),
|
|
401
|
+
record_dogfood: tool({
|
|
402
|
+
description: "Record the verdict of a dogfood run: a change exercised end to end on real hardware before deployment. The runner produces the checks and the transcript; this tool binds the result to the repository content it ran against. A HOLD records the taxonomy class so the failure is diagnosable. Actor sessions spawned by the runner cannot record their own verdict.",
|
|
403
|
+
args: {
|
|
404
|
+
scenarioId: tool.schema.string().describe("Scenario identifier the actor ran, for example 'permission-rule-reorder'"),
|
|
405
|
+
scenarioSha256: tool.schema.string().describe("SHA-256 of the scenario file the actor ran, so the evidence names the frozen input"),
|
|
406
|
+
taxonomySha256: tool.schema.string().describe("SHA-256 of the frozen taxonomy file the judge classified against"),
|
|
407
|
+
verdict: tool.schema.string().describe("'pass' when every deterministic check passed, otherwise 'hold'"),
|
|
408
|
+
checks: tool.schema.string().describe("JSON array of check rows: [{id, status: 'pass'|'hold'|'skip', expected, observed, detail}]"),
|
|
409
|
+
hostLabel: tool.schema.string().describe("Stable host label the deploy matrix is written against, for example 'bluefin'"),
|
|
410
|
+
taxonomyClass: tool.schema.string().optional().describe("Taxonomy class naming why a HOLD happened; omit on a pass"),
|
|
411
|
+
runCount: tool.schema.number().optional().describe("Number of runs behind this verdict (default 1); single runs are flaky"),
|
|
412
|
+
hostOs: tool.schema.string().optional().describe("Host OS reported by the run"),
|
|
413
|
+
hostArch: tool.schema.string().optional().describe("Host architecture reported by the run"),
|
|
414
|
+
opencodeVersion: tool.schema.string().optional().describe("OpenCode version the actor session ran under"),
|
|
415
|
+
actorModel: tool.schema.string().optional().describe("Model pin used for the actor session"),
|
|
416
|
+
judgeModel: tool.schema.string().optional().describe("Model pin used for the judge session"),
|
|
417
|
+
stagedTreeHash: tool.schema.string().optional().describe("Hash of the staged harness tree the actor exercised"),
|
|
418
|
+
directory: tool.schema.string().optional().describe("Repository directory the run covered. Pass it explicitly when the recorder session has no workspace binding."),
|
|
419
|
+
},
|
|
420
|
+
execute: async (args, toolContext) => {
|
|
421
|
+
const auditVerdict = (verdict: "pass" | "hold" | "rejected", reason: string, binding?: NonNullable<AuditEntry["evidence"]>["binding"]) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_dogfood.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { dogfoodVerdict: verdict, ...(binding ? { binding } : {}) } });
|
|
422
|
+
// The tested session cannot certify itself. The marker is set by
|
|
423
|
+
// the runner on the actor it spawns, so this is a refusal, not an
|
|
424
|
+
// absence: the plugin is present and enforcing in that session.
|
|
425
|
+
if (isDogfoodActor()) {
|
|
426
|
+
auditVerdict("rejected", "dogfood_actor_self_certification", { recorderSessionID: toolContext?.sessionID });
|
|
427
|
+
return `[workflow-guard] Dogfood record rejected: this session is a dogfood actor (WORKFLOW_GUARD_DOGFOOD_ACTOR/DEPTH is set), and the tested session cannot certify itself. The parent runner session records the verdict from the checks it observed.`;
|
|
428
|
+
}
|
|
429
|
+
const verdict = args.verdict.trim().toLowerCase();
|
|
430
|
+
if (verdict !== "pass" && verdict !== "hold") {
|
|
431
|
+
auditVerdict("rejected", "invalid_dogfood_verdict");
|
|
432
|
+
return "[workflow-guard] Dogfood record rejected: verdict must be 'pass' or 'hold'.";
|
|
433
|
+
}
|
|
434
|
+
// Empty identifiers would make the record unattributable: a scenario
|
|
435
|
+
// id names the frozen input, and a host label is what the deploy
|
|
436
|
+
// matrix is written against.
|
|
437
|
+
if (!args.scenarioId.trim() || !args.hostLabel.trim()) {
|
|
438
|
+
auditVerdict("rejected", "dogfood_missing_identity");
|
|
439
|
+
return "[workflow-guard] Dogfood record rejected: scenarioId and hostLabel must both be non-empty, because they identify the frozen input and the host the deploy matrix names.";
|
|
440
|
+
}
|
|
441
|
+
let checks: DogfoodCheck[];
|
|
442
|
+
try {
|
|
443
|
+
const parsed = JSON.parse(args.checks);
|
|
444
|
+
if (!Array.isArray(parsed)) throw new Error("not-array");
|
|
445
|
+
checks = parsed;
|
|
446
|
+
} catch {
|
|
447
|
+
auditVerdict("rejected", "invalid_dogfood_checks");
|
|
448
|
+
return "[workflow-guard] Dogfood record rejected: checks must be a JSON array of {id, status, expected, observed, detail} rows.";
|
|
449
|
+
}
|
|
450
|
+
const invalidCheck = checks.find((check) => !check || typeof check.id !== "string" || !["pass", "hold", "skip"].includes(check.status));
|
|
451
|
+
if (invalidCheck) {
|
|
452
|
+
auditVerdict("rejected", "invalid_dogfood_check_row");
|
|
453
|
+
return "[workflow-guard] Dogfood record rejected: every check row needs a string 'id' and a 'status' of pass, hold, or skip.";
|
|
454
|
+
}
|
|
455
|
+
// A record with no checks is a claim, not an observation. Without
|
|
456
|
+
// this, an empty array would slip past the per-row checks below
|
|
457
|
+
// because `[].some(...)` is false, and a pass would mean nothing.
|
|
458
|
+
if (checks.length === 0) {
|
|
459
|
+
auditVerdict("rejected", "dogfood_without_checks");
|
|
460
|
+
return "[workflow-guard] Dogfood record rejected: at least one check row is required. A verdict with no observed checks carries no information about the run.";
|
|
461
|
+
}
|
|
462
|
+
// A pass is only credible when every check actually ran. A skipped
|
|
463
|
+
// check is a HOLD by the design's own rule, so accepting it here
|
|
464
|
+
// would let the gate pass by omission.
|
|
465
|
+
if (verdict === "pass" && checks.some((check) => check.status !== "pass")) {
|
|
466
|
+
auditVerdict("rejected", "dogfood_pass_with_nonpassing_check");
|
|
467
|
+
return "[workflow-guard] Dogfood record rejected: a 'pass' verdict requires every check to be 'pass'. A skipped or held check is a HOLD.";
|
|
468
|
+
}
|
|
469
|
+
if (verdict === "hold" && !args.taxonomyClass) {
|
|
470
|
+
auditVerdict("rejected", "dogfood_hold_without_taxonomy_class");
|
|
471
|
+
return "[workflow-guard] Dogfood record rejected: a HOLD must name its taxonomy class so the failure is diagnosable.";
|
|
472
|
+
}
|
|
473
|
+
const hostProvidesWorkspace = Boolean(toolContext?.worktree);
|
|
474
|
+
const sessionBinding = await resolveSessionBoundWorkspace(toolContext?.sessionID);
|
|
475
|
+
if (!args.directory && !sessionBinding && !hostProvidesWorkspace) {
|
|
476
|
+
auditVerdict("rejected", "dogfood_binding_unresolved", { recorderSessionID: toolContext?.sessionID });
|
|
477
|
+
return "[workflow-guard] Dogfood record rejected: the run's workspace could not be resolved from this call (no explicit 'directory', no session workspace binding, no per-call worktree). Call record_dogfood with 'directory' set to the repository the run exercised.";
|
|
478
|
+
}
|
|
479
|
+
const dogfoodWorkspace = await resolveCallWorkspace(toolContext, args.directory);
|
|
480
|
+
const bindingFingerprint = getTrackedWorktreeFingerprint(dogfoodWorkspace);
|
|
481
|
+
if (!bindingFingerprint) {
|
|
482
|
+
auditVerdict("rejected", "dogfood_workspace_unbindingable", { workspace: dogfoodWorkspace, recorderSessionID: toolContext.sessionID });
|
|
483
|
+
return `[workflow-guard] Dogfood record rejected: the verdict cannot be bound because no tracked-content fingerprint is available for '${dogfoodWorkspace}' (not a git repository, or git is unavailable). The verdict was NOT recorded. Call record_dogfood with 'directory' set to the run's worktree, or run it from a session rooted there.`;
|
|
484
|
+
}
|
|
485
|
+
const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
|
|
486
|
+
const bindingCommitHash = getCurrentGitCommitHash(dogfoodWorkspace);
|
|
487
|
+
const binding = {
|
|
488
|
+
workspace: dogfoodWorkspace,
|
|
489
|
+
commitHash: bindingCommitHash,
|
|
490
|
+
worktreeFingerprint: bindingFingerprint.slice(0, 16),
|
|
491
|
+
recorderSessionID: toolContext.sessionID,
|
|
492
|
+
recorderRole: parentSessionID ? ("subagent" as const) : ("root" as const),
|
|
493
|
+
targetSessionID: parentSessionID ?? toolContext.sessionID,
|
|
494
|
+
};
|
|
495
|
+
const recorded = recordDogfoodResult({
|
|
496
|
+
verdict: verdict as DogfoodResult["verdict"],
|
|
497
|
+
scenarioId: args.scenarioId,
|
|
498
|
+
scenarioSha256: args.scenarioSha256,
|
|
499
|
+
taxonomySha256: args.taxonomySha256,
|
|
500
|
+
taxonomyClass: args.taxonomyClass,
|
|
501
|
+
checks,
|
|
502
|
+
runCount: typeof args.runCount === "number" && Number.isFinite(args.runCount) && args.runCount >= 1 ? Math.floor(args.runCount) : 1,
|
|
503
|
+
host: {
|
|
504
|
+
label: args.hostLabel,
|
|
505
|
+
os: args.hostOs,
|
|
506
|
+
arch: args.hostArch,
|
|
507
|
+
opencodeVersion: args.opencodeVersion,
|
|
508
|
+
actorModel: args.actorModel,
|
|
509
|
+
judgeModel: args.judgeModel,
|
|
510
|
+
stagedTreeHash: args.stagedTreeHash,
|
|
511
|
+
},
|
|
512
|
+
workspace: dogfoodWorkspace,
|
|
513
|
+
}, parentSessionID ?? toolContext.sessionID);
|
|
514
|
+
auditVerdict(recorded.verdict, recorded.verdict === "pass" ? "dogfood_passed" : "dogfood_held", binding);
|
|
515
|
+
const boundTo = `Bound to ${dogfoodWorkspace} (commit ${bindingCommitHash?.slice(0, 12) ?? "unversioned"}, tracked fingerprint ${bindingFingerprint.slice(0, 12)}).`;
|
|
516
|
+
const recordedNote = parentSessionID
|
|
517
|
+
? `Recorded for parent session ${parentSessionID}.`
|
|
518
|
+
: `Recorded by root session ${toolContext.sessionID}.`;
|
|
519
|
+
const passedChecks = checks.filter((check) => check.status === "pass").length;
|
|
520
|
+
return recorded.verdict === "pass"
|
|
521
|
+
? `[workflow-guard] Dogfood recorded as PASS for scenario '${recorded.scenarioId}' on host '${recorded.host.label}' (${passedChecks}/${checks.length} checks passed, ${recorded.runCount} run(s)). ${boundTo} ${recordedNote}`
|
|
522
|
+
: `[workflow-guard] Dogfood recorded as HOLD for scenario '${recorded.scenarioId}' on host '${recorded.host.label}' with class '${recorded.taxonomyClass}'. A HOLD is not durable evidence: fix the finding and re-run. ${boundTo} ${recordedNote}`;
|
|
523
|
+
},
|
|
524
|
+
}),
|
|
374
525
|
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) }),
|
|
375
526
|
guard_review_followup_resolve: tool({ description: "Resolve a durable local review follow-up after the underlying issue has been fixed and verified.", args: { id: tool.schema.string() }, execute: async (args) => followupStore && resolveReviewFollowup(followupStore, args.id) ? `[workflow-guard] Review follow-up ${args.id} resolved.` : `[workflow-guard] Review follow-up ${args.id} was not open or was not found.` }),
|
|
376
527
|
guard_review_rubric: tool({
|