opencode-workflow-guard 1.19.0 → 1.19.2

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.
@@ -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,18 +169,46 @@ 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
129
207
  // mapped (GitHub/Azure DevOps), matching the execute.before MCP policy.
208
+ // Cache the host action's mutability to avoid recomputing it on every iteration.
209
+ const actionIsMutating = mcpMutationTool(event.action);
130
210
  for (const resource of resources) {
131
- if (!mcpMutationTool(event.action) && !mcpMutationTool(resource)) continue;
211
+ if (!actionIsMutating && !mcpMutationTool(resource)) continue;
132
212
  const decision = await decide(event.action, {});
133
213
  return seatVerdict(decision, event.action) ?? { effect: "allow", mappedTool: event.action };
134
214
  }