opencode-workflow-guard 1.19.0 → 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 +1 -1
- package/docs/policies.md +11 -3
- package/docs/testing.md +2 -1
- package/package.json +1 -1
- package/src/lib/guard-dispatcher.ts +104 -13
- package/src/lib/permission-seat.ts +79 -1
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +80 -1
- package/src/lib/types.ts +7 -0
- package/src/lib/v2-plugin.ts +20 -0
- 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 +27 -3
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +660 -20
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 **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. An opt-in dogfood gate can additionally require a recorded run of the change on real hardware before a PR opens.
|
|
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.
|
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
|
|
|
@@ -158,9 +163,12 @@ This is a policy and enforcement layer, not an agent harness. Policies may const
|
|
|
158
163
|
|
|
159
164
|
### 18. Interpreter Inline Evasion Scanner
|
|
160
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.
|
|
161
167
|
|
|
162
168
|
### 19. Conflict-Free Pre-Flight Guard
|
|
163
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.
|
|
164
172
|
|
|
165
173
|
### 20. Merged Branch & Base Freshness Guard
|
|
166
174
|
- Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
|
package/docs/testing.md
CHANGED
|
@@ -53,7 +53,8 @@ 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. |
|
package/package.json
CHANGED
|
@@ -24,14 +24,16 @@ import {
|
|
|
24
24
|
import {
|
|
25
25
|
dynamicShellSyntaxIn,
|
|
26
26
|
extractCommands,
|
|
27
|
+
isLeadingEvalSegment,
|
|
28
|
+
maskDataHeredocBodies,
|
|
27
29
|
normalize,
|
|
30
|
+
planShellSegments,
|
|
28
31
|
shellWrappersChangeCwd,
|
|
29
32
|
splitShellSegments,
|
|
30
33
|
unwrapShellCommand,
|
|
31
34
|
} from "./shell.ts";
|
|
32
35
|
import { asRecord, extractTargetPath } from "./utils.ts";
|
|
33
36
|
import type { PolicyDecision } from "./types.ts";
|
|
34
|
-
import { getProjectMemoryIdentity } from "./project-memory.ts";
|
|
35
37
|
import {
|
|
36
38
|
detectVerifyCommand,
|
|
37
39
|
getCurrentGitCommitHash,
|
|
@@ -42,7 +44,7 @@ import {
|
|
|
42
44
|
resolveVerifyTimeoutMs,
|
|
43
45
|
snipVerifyOutput,
|
|
44
46
|
} from "./verify.ts";
|
|
45
|
-
import { detectShellMutation, extractPatchPaths, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
|
|
47
|
+
import { detectShellMutation, extractPatchPaths, foreignSegmentMutationReason, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, nestedPayloadMutationReason, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
|
|
46
48
|
import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyHasLiteralLineBreakEscapes, prBodyIncludesChangelog } from "../policies/changelog.ts";
|
|
47
49
|
import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
|
|
48
50
|
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
@@ -56,11 +58,14 @@ import {
|
|
|
56
58
|
checkBranchBaseIsUpToDate,
|
|
57
59
|
checkMergeConflicts,
|
|
58
60
|
currentGitBranch,
|
|
61
|
+
findWorktreeForBranch,
|
|
59
62
|
hasUnsafeGitAlias,
|
|
60
63
|
isBranchAlreadyMergedOrClosed,
|
|
64
|
+
isGitTreeWriteText,
|
|
61
65
|
normalizeGitCommands,
|
|
62
66
|
onProtectedBranch,
|
|
63
67
|
parseGitInvocation,
|
|
68
|
+
prSourceBranch,
|
|
64
69
|
pushedProtectedBranchIn,
|
|
65
70
|
tagRefspecIn,
|
|
66
71
|
} from "../policies/git.ts";
|
|
@@ -278,7 +283,7 @@ export async function guardToolCallImpl(
|
|
|
278
283
|
// that would restate the misattribution.
|
|
279
284
|
const tail = snipVerifyOutput(result.output, result.timedOut === true).slice(-500);
|
|
280
285
|
const reason = result.timedOut
|
|
281
|
-
? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}).
|
|
286
|
+
? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Only the user can raise the limit, in the project .opencode/workflow-guard.json or .jsonc (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS set before the guard starts; ask them, then complete the tasks again. Output tail: ${tail}`
|
|
282
287
|
: `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
|
|
283
288
|
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
284
289
|
return block("verification", result.timedOut ? "verification_timed_out" : "verification_failed", reason);
|
|
@@ -448,9 +453,13 @@ export async function guardToolCallImpl(
|
|
|
448
453
|
if (!SHELL_TOOL_NAMES.has(toolName)) return allow();
|
|
449
454
|
const shellWorkdir = asRecord(input)?.workdir;
|
|
450
455
|
const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
456
|
+
// A workdir outside the workspace is only usable when it is a linked
|
|
457
|
+
// worktree of the SAME repository: those commands mutate a tree of this
|
|
458
|
+
// workspace. A genuinely foreign repository cannot be judged here at all,
|
|
459
|
+
// because the command executes there while relative targets would be
|
|
460
|
+
// validated against the workspace root.
|
|
461
|
+
const foreignShellRoot = isPathOutsideWorkspace(requestedShellRoot, currentRoot) && !isSameGitRepo(requestedShellRoot, currentRoot);
|
|
462
|
+
const shellRoot = foreignShellRoot ? currentRoot : requestedShellRoot;
|
|
454
463
|
|
|
455
464
|
for (const raw of extractCommands(input)) {
|
|
456
465
|
const dynamicSyntax = dynamicShellSyntaxIn(raw);
|
|
@@ -471,11 +480,50 @@ export async function guardToolCallImpl(
|
|
|
471
480
|
}
|
|
472
481
|
const normalizedCommand = normalizeGitCommands(command);
|
|
473
482
|
if (hasUnsafeGitAlias(command)) return block("git", "unsafe_git_alias", "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.");
|
|
474
|
-
|
|
483
|
+
// Each segment is judged in the directory it actually runs in. A `cd`
|
|
484
|
+
// earlier in the command moves the ones that follow, so parsing them all
|
|
485
|
+
// against the shell's initial `workdir` hid a mutation in another tree:
|
|
486
|
+
// `cd <other-repo> && git commit` was judged as a commit in the session's
|
|
487
|
+
// own checkout. Plan from `raw`: `normalize` folds newlines into spaces,
|
|
488
|
+
// which erases the separator that makes a newline-separated `cd` a real
|
|
489
|
+
// directory change.
|
|
490
|
+
const segmentPlan = planShellSegments(raw, shellRoot);
|
|
491
|
+
const gitInvocations = segmentPlan
|
|
492
|
+
.map(({ segment, root }) => parseGitInvocation(segment.trim(), root))
|
|
493
|
+
.filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
|
|
475
494
|
const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
|
|
495
|
+
// The command executes in `requestedShellRoot`, not in the workspace, so
|
|
496
|
+
// every relative target in it would be validated against the wrong tree:
|
|
497
|
+
// `rm -rf dist` in a foreign workdir would be checked as
|
|
498
|
+
// `<workspace>/dist`. Reads elsewhere are fine, mutations are not.
|
|
499
|
+
const mutatesTree = (invocation: NonNullable<ReturnType<typeof parseGitInvocation>>): boolean =>
|
|
500
|
+
isGitTreeWriteText(`git ${invocation.rest}`);
|
|
501
|
+
if (foreignShellRoot && (detectShellMutation(command) || gitInvocations.some(mutatesTree))) {
|
|
502
|
+
logPolicyBlock(`[workflow-guard] blocked mutation in a workdir outside workspace: ${requestedShellRoot}`);
|
|
503
|
+
return block(
|
|
504
|
+
"boundary",
|
|
505
|
+
"workspace_escape",
|
|
506
|
+
`Blocked: the shell's workdir '${requestedShellRoot}' is outside workspace root (${currentRoot}) and is not a linked worktree of this repository, so relative targets cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const foreignSegmentReason = foreignSegmentMutationReason(segmentPlan, currentRoot);
|
|
510
|
+
if (foreignSegmentReason) {
|
|
511
|
+
logPolicyBlock(`[workflow-guard] blocked mutation in a directory outside workspace: ${command.slice(0, 120)}`);
|
|
512
|
+
return block("boundary", "workspace_escape", foreignSegmentReason);
|
|
513
|
+
}
|
|
476
514
|
for (const invocation of gitInvocations) {
|
|
477
515
|
const normalizedInvocation = `git ${invocation.rest}`;
|
|
478
|
-
|
|
516
|
+
// A linked worktree of the SAME repository is not foreign state: the
|
|
517
|
+
// shell's workdir legitimately lives there when a session works in an
|
|
518
|
+
// isolated checkout, and judging those commands is the point of the
|
|
519
|
+
// workdir-aware parse above. A genuinely different repository stays
|
|
520
|
+
// confined, exactly as before.
|
|
521
|
+
// An indeterminate repository is foreign: `findGitRoot` walks up to
|
|
522
|
+
// the nearest existing ancestor, so `git -C $OTHER_REPO commit`
|
|
523
|
+
// resolved to the workspace's own root and read as the same repo.
|
|
524
|
+
const foreignRepo = hasUnresolvableVariable(invocation.repoDir)
|
|
525
|
+
|| (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && !isSameGitRepo(invocation.repoDir, currentRoot));
|
|
526
|
+
if (foreignRepo && isGitTreeWriteText(normalizedInvocation)) {
|
|
479
527
|
logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
480
528
|
return block(
|
|
481
529
|
"boundary",
|
|
@@ -522,13 +570,25 @@ export async function guardToolCallImpl(
|
|
|
522
570
|
logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
|
|
523
571
|
return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
|
|
524
572
|
}
|
|
525
|
-
|
|
573
|
+
// Mask from `raw`, not the normalized copy: `normalize` folds newlines
|
|
574
|
+
// into spaces, which erases the heredoc shape the mask needs to find.
|
|
575
|
+
for (const segment of maskDataHeredocBodies(raw).split(/[\n|;&]+/)) {
|
|
526
576
|
const secretFile = secretFileReadIn(segment.trim());
|
|
527
577
|
if (secretFile) {
|
|
528
578
|
logPolicyBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
|
|
529
579
|
return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
|
|
530
580
|
}
|
|
531
581
|
}
|
|
582
|
+
// A nested payload changes a tree this session may not judge when it
|
|
583
|
+
// changes directory itself, or names a foreign `cwd`. The outer shell
|
|
584
|
+
// sees one word, so the top-level walk above cannot see it. Plan from
|
|
585
|
+
// `raw` for the same reason the top-level walk does: a heredoc body's
|
|
586
|
+
// own lines are real commands to the shell that consumes it.
|
|
587
|
+
const nestedTreeReason = nestedPayloadMutationReason(extractInterpreterPayload, writePathsInPayload, raw, shellRoot, currentRoot);
|
|
588
|
+
if (nestedTreeReason) {
|
|
589
|
+
logPolicyBlock(`[workflow-guard] blocked nested interpreter mutation outside workspace: ${command.slice(0, 120)}`);
|
|
590
|
+
return block("boundary", "workspace_escape", nestedTreeReason);
|
|
591
|
+
}
|
|
532
592
|
for (const payload of extractInterpreterPayload(raw)) {
|
|
533
593
|
const outsidePath = outsideWritePathInPayload(payload, currentRoot);
|
|
534
594
|
if (outsidePath) {
|
|
@@ -561,7 +621,13 @@ export async function guardToolCallImpl(
|
|
|
561
621
|
const secretPath = secretPathInPayload(payload);
|
|
562
622
|
if (secretPath) return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`);
|
|
563
623
|
}
|
|
564
|
-
|
|
624
|
+
// `eval` runs its body in the current shell, so a `cd` inside it moves the
|
|
625
|
+
// directory of later segments. Another launcher's payload runs in a child
|
|
626
|
+
// process and cannot move this shell, so only `eval` is forwarded. The
|
|
627
|
+
// predicate lives in the shell walk so both call sites agree on which
|
|
628
|
+
// `eval` is a command.
|
|
629
|
+
const leadingEvalBody = (s: string): string | undefined => (isLeadingEvalSegment(s) ? extractInterpreterPayload(s)[0] : undefined);
|
|
630
|
+
const shellMutationReason = await guardShellMutation(raw, context?.sessionID, !context?.simulate, shellRoot, leadingEvalBody);
|
|
565
631
|
if (shellMutationReason) {
|
|
566
632
|
logPolicyBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
|
|
567
633
|
return block("boundary", "shell_mutation", shellMutationReason);
|
|
@@ -605,15 +671,40 @@ export async function guardToolCallImpl(
|
|
|
605
671
|
}
|
|
606
672
|
}
|
|
607
673
|
if (hasPrCreateInvocation(raw)) {
|
|
608
|
-
|
|
674
|
+
// The PR is about a BRANCH, not about whatever the shell's checkout
|
|
675
|
+
// happens to have checked out. Resolve the branch the command names
|
|
676
|
+
// (`gh --head`, `az --source-branch`) and then the checkout that
|
|
677
|
+
// holds it: a linked worktree is the normal case, because the
|
|
678
|
+
// orchestrator session may sit in the primary checkout while the
|
|
679
|
+
// reviewed branch lives in an isolated worktree. Every preflight
|
|
680
|
+
// check then judges that tree instead of the caller's.
|
|
681
|
+
const prSegment = splitShellSegments(raw).find((segment) => hasPrCreateInvocation(segment));
|
|
682
|
+
// Quote-aware on the raw segment: `unwrapShellCommand` joins the
|
|
683
|
+
// words back without their quotes, so re-parsing the joined text
|
|
684
|
+
// read a `--head`-looking token inside a quoted `--body` as the
|
|
685
|
+
// source branch.
|
|
686
|
+
const sourceBranch = prSegment ? prSourceBranch(prSegment) : undefined;
|
|
687
|
+
let prRoot = shellRoot;
|
|
688
|
+
let branch = currentGitBranch(shellRoot);
|
|
689
|
+
let missingSourceCheckout: string | undefined;
|
|
690
|
+
if (sourceBranch) {
|
|
691
|
+
const holder = findWorktreeForBranch(shellRoot, sourceBranch) ?? findWorktreeForBranch(currentRoot, sourceBranch);
|
|
692
|
+
prRoot = holder ?? shellRoot;
|
|
693
|
+
branch = sourceBranch;
|
|
694
|
+
// Review and merge evidence bind to the CONTENT of a checkout.
|
|
695
|
+
// Substituting the caller's checkout would let an approval of one
|
|
696
|
+
// tree satisfy a PR that publishes another, so a named branch no
|
|
697
|
+
// local checkout holds is refused instead.
|
|
698
|
+
if (!holder) missingSourceCheckout = sourceBranch;
|
|
699
|
+
}
|
|
609
700
|
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
610
701
|
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
611
702
|
const descFlag = isAz ? "--description" : "--body";
|
|
612
703
|
const preflightFailures: string[] = [];
|
|
704
|
+
if (missingSourceCheckout) preflightFailures.push(`Source branch '${missingSourceCheckout}' is not checked out in any worktree of this repository, so review and merge evidence cannot be bound to the content this PR would publish. Check that branch out (a linked worktree is fine) and re-run, or omit the source-branch flag to judge the current checkout.`);
|
|
613
705
|
if (prBodyHasLiteralLineBreakEscapes(raw)) preflightFailures.push(`PR description contains literal \\n/\\r escapes that will render as text; use real newlines, ANSI-C quoting ($'...\\n...'), or a body/description file instead.`);
|
|
614
|
-
const conflictCheck = checkMergeConflicts(prRoot);
|
|
706
|
+
const conflictCheck = checkMergeConflicts(prRoot, branch);
|
|
615
707
|
if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
|
|
616
|
-
const branch = currentGitBranch(prRoot);
|
|
617
708
|
if (branch) {
|
|
618
709
|
if (context?.simulate) remotePrStateUnchecked = true;
|
|
619
710
|
const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch, !context?.simulate);
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
|
|
30
30
|
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
31
31
|
import { getSessionBoundWorkspace, runWithRuntimeState } from "./state.ts";
|
|
32
|
+
import { GIT_BRANCH_CREATE_RE, GIT_WRITE_RE, PUSH_TO_MAIN_RE, parseGitInvocation } from "../policies/git.ts";
|
|
33
|
+
import { hasPrCreateInvocation } from "../policies/changelog.ts";
|
|
34
|
+
import { splitShellSegments } from "./shell.ts";
|
|
32
35
|
import { mcpMutationTool } from "../policies/mcp.ts";
|
|
33
36
|
import type { PolicyDecision } from "./types.ts";
|
|
34
37
|
|
|
@@ -61,6 +64,55 @@ export interface PermissionSeatResult {
|
|
|
61
64
|
|
|
62
65
|
const EDIT_ACTIONS = new Set(["edit", "write", "patch", "apply_patch"]);
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Commands whose verdict depends on WHICH checkout they run in.
|
|
69
|
+
*
|
|
70
|
+
* A bare `git push`, `git commit`, or branch creation is judged against the
|
|
71
|
+
* repository the shell's working directory resolves to, and PR preflight is
|
|
72
|
+
* judged against the branch the PR publishes. This seat sees normalized
|
|
73
|
+
* `action`/`resources` only, never the tool's `workdir`, so for those commands
|
|
74
|
+
* the repository is an assumption rather than a fact: the session-bound
|
|
75
|
+
* checkout may be the primary tree while the command targets a linked
|
|
76
|
+
* worktree. Deciding on the assumption denied legitimate pushes and PRs, so
|
|
77
|
+
* the seat defers them to `execute.before`, which sees the raw arguments.
|
|
78
|
+
*
|
|
79
|
+
* Two cases stay decisive:
|
|
80
|
+
*
|
|
81
|
+
* - An explicit `-C`/`--git-dir`/`--work-tree`/`GIT_DIR` names the repository
|
|
82
|
+
* in the command text, so it is a fact the seat can judge.
|
|
83
|
+
* - A push whose destination is an explicitly named protected branch is a
|
|
84
|
+
* finding in every checkout, so no `workdir` can change the verdict.
|
|
85
|
+
*/
|
|
86
|
+
function repositoryDependent(command: string): boolean {
|
|
87
|
+
for (const segment of splitShellSegments(command)) {
|
|
88
|
+
// PR creation names no repository at all: the checkout is cwd-derived,
|
|
89
|
+
// and a `--head`/`--source-branch` may live in another worktree.
|
|
90
|
+
if (hasPrCreateInvocation(segment)) return true;
|
|
91
|
+
const parsed = parseGitInvocation(segment);
|
|
92
|
+
if (!parsed) continue;
|
|
93
|
+
const rest = `git ${parsed.rest}`;
|
|
94
|
+
if (parsed.explicitRepoDir) continue;
|
|
95
|
+
if (PUSH_TO_MAIN_RE.test(rest)) continue;
|
|
96
|
+
const repositorySensitive =
|
|
97
|
+
/\bgit\s+push\b/.test(rest) ||
|
|
98
|
+
GIT_WRITE_RE.test(rest) ||
|
|
99
|
+
GIT_BRANCH_CREATE_RE.test(rest);
|
|
100
|
+
if (repositorySensitive) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Policies whose verdict is derived from the selected checkout.
|
|
107
|
+
*
|
|
108
|
+
* A block from one of these may be an artifact of judging the wrong tree: the
|
|
109
|
+
* original defect was a push denied for the protected branch of the primary
|
|
110
|
+
* checkout while the command targeted a linked feature worktree. A block from
|
|
111
|
+
* any other policy (destructive, shell-safety, secrets, tamper, todo) is a
|
|
112
|
+
* finding about the command text itself, so no working directory can change it.
|
|
113
|
+
*/
|
|
114
|
+
const CHECKOUT_DERIVED_POLICIES = new Set(["git", "pr-preflight"]);
|
|
115
|
+
|
|
64
116
|
/**
|
|
65
117
|
* Map one simulated policy decision onto the seat's effect.
|
|
66
118
|
*
|
|
@@ -117,12 +169,38 @@ export async function evaluatePermissionSeat(
|
|
|
117
169
|
|
|
118
170
|
const mapping = mappedToolFor(event.action);
|
|
119
171
|
if (mapping) {
|
|
172
|
+
let deferred: PermissionSeatResult | undefined;
|
|
120
173
|
for (const resource of resources) {
|
|
174
|
+
// Where the command runs is part of the decision for some commands,
|
|
175
|
+
// and this seat cannot see the tool's `workdir`. Both directions of a
|
|
176
|
+
// checkout-derived verdict are then unreliable: a block may be the
|
|
177
|
+
// protected branch of the wrong tree, and an allow may be a feature
|
|
178
|
+
// checkout that hides a push to a protected destination. Defer those
|
|
179
|
+
// to `execute.before`, which resolves the shell's workdir.
|
|
180
|
+
const cwdDependent = mapping.tool === "bash" && repositoryDependent(resource);
|
|
121
181
|
const decision = await decide(mapping.tool, mapping.input(resource));
|
|
122
182
|
const verdict = seatVerdict(decision, mapping.tool);
|
|
183
|
+
if (cwdDependent) {
|
|
184
|
+
// A block from any OTHER policy is a finding about the command
|
|
185
|
+
// text itself (`cat .env` or a destructive live command in the
|
|
186
|
+
// same compound command), so no working directory can change it
|
|
187
|
+
// and it keeps its denial. Everything else defers.
|
|
188
|
+
if (verdict?.effect === "deny" && !CHECKOUT_DERIVED_POLICIES.has(decision.policy ?? "")) return verdict;
|
|
189
|
+
deferred = {
|
|
190
|
+
effect: "abstain",
|
|
191
|
+
mappedTool: mapping.tool,
|
|
192
|
+
decision: {
|
|
193
|
+
status: "needs_approval",
|
|
194
|
+
policy: decision.policy ?? "git",
|
|
195
|
+
code: "repository_unidentified",
|
|
196
|
+
message: "[workflow-guard permission.evaluate] Deferred: this command's verdict depends on the directory it runs in, which the permission seat cannot see. `execute.before` resolves the shell's workdir and enforces there.",
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
123
201
|
if (verdict) return verdict;
|
|
124
202
|
}
|
|
125
|
-
return { effect: "allow", mappedTool: mapping.tool };
|
|
203
|
+
return deferred ?? { effect: "allow", mappedTool: mapping.tool };
|
|
126
204
|
}
|
|
127
205
|
|
|
128
206
|
// `<server>_<tool>` MCP actions: only the guarded live-system servers are
|