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.
- package/README.md +1 -1
- package/docs/policies.md +21 -10
- package/docs/testing.md +3 -1
- package/package.json +1 -1
- package/src/lib/guard-api.ts +85 -0
- package/src/lib/guard-dispatcher.ts +104 -13
- package/src/lib/permission-seat.ts +82 -2
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +80 -1
- package/src/lib/tool-descriptions.ts +36 -0
- package/src/lib/types.ts +14 -1
- package/src/lib/v2-plugin.ts +213 -133
- package/src/policies/boundary.ts +307 -24
- package/src/policies/continuation.ts +41 -11
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +44 -82
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +922 -20
|
@@ -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 (!
|
|
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
|
}
|