opencode-workflow-guard 1.3.8 → 1.4.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/ACKNOWLEDGEMENTS.md +3 -0
- package/README.md +3 -1
- package/package.json +3 -2
- package/src/lib/review.ts +7 -1
- package/src/lib/state.ts +18 -2
- package/src/lib/types.ts +4 -0
- package/src/lib/utils.ts +82 -10
- package/src/lib/verify.ts +67 -5
- package/src/policies/boundary.ts +56 -17
- package/src/policies/changelog.ts +59 -0
- package/src/policies/destructive.ts +12 -0
- package/src/policies/shell-safety.ts +25 -6
- package/src/policies/tamper.ts +3 -3
- package/src/workflow-guard.ts +113 -6
package/ACKNOWLEDGEMENTS.md
CHANGED
|
@@ -19,5 +19,8 @@
|
|
|
19
19
|
* **[`changesets`](https://github.com/changesets/changesets)** by the [Changesets Team](https://github.com/changesets/changesets)
|
|
20
20
|
* For the fragment-based changelog and versioning model that prevents merge conflicts across parallel feature PRs.
|
|
21
21
|
|
|
22
|
+
* **[`oh-my-pi`](https://github.com/can1357/oh-my-pi)** by [@can1357](https://github.com/can1357), **`omp-guardrails`** by [@breathi3552](https://github.com/breathi3552), **`agentsflow`** by [@xzhang17](https://github.com/xzhang17), **`tools`** by [@marcelsud](https://github.com/marcelsud), and the **Claude Code** security hook community
|
|
23
|
+
* Inspired the hardened shell wrapper tokenization/normalization in **`unwrapShellWords`**, multi-ecosystem verification detection in **Policy 10**, structured severity ranking (P0-P3 blocker gate) for the secondary review rubric in **Policy 19 / `guard_review_rubric`**, disk wipe, reverse shell & system permission signatures in **Policy 4**, lockfile synchronization checks in **Policy 3**, and subagent read-only role confinement & mutation budgets in **Policy 1**.
|
|
24
|
+
|
|
22
25
|
* **[OpenCode Team at Anomaly](https://github.com/anomalyco/opencode)**
|
|
23
26
|
* For building the OpenCode terminal AI coding agent and its extensible plugin architecture (`@opencode-ai/plugin`).
|
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@ Add to your project's `opencode.json` or global `~/.config/opencode/opencode.jso
|
|
|
25
25
|
|
|
26
26
|
*Requires OpenCode >= 1.18.* See [docs/installation.md](docs/installation.md) for full setup options and TUI companion badge configuration.
|
|
27
27
|
|
|
28
|
+
For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json` as well. Do not use `opencode-workflow-guard/ui`; npm parses that unscoped slash form as GitHub shorthand rather than a package export, which can trigger Git credential prompts. The package exposes OpenCode's native `./server` and `./tui` entrypoints so the package root is sufficient in both configs.
|
|
29
|
+
|
|
28
30
|
### 2. Verification
|
|
29
31
|
|
|
30
32
|
```bash
|
|
@@ -41,7 +43,7 @@ The plugin enforces **24 deterministic policy rules** across four main pillars:
|
|
|
41
43
|
|
|
42
44
|
* **Branch & History Protection:** Enforces feature-branch workflows (blocks edits/commits on `main`/`master`), hard-blocks direct or forced pushes to protected branches, enforces mergeability pre-flight checks, and requires PR changelogs / changesets.
|
|
43
45
|
* **Task & Verification Discipline:** Gates file mutations on active `todowrite` tasks with subagent inheritance, blocks silent task deletion, enforces fresh test verification evidence before task completion, and journals completion claims vs. verification mismatches.
|
|
44
|
-
* **Secrets & Workspace Confinement:** Confines
|
|
46
|
+
* **Secrets & Workspace Confinement:** Confines edit tools and recognized shell/git mutations within the workspace root (including symlink and `../` escape checks), scrubs sensitive environment variables in agent subshells, and redacts `.env` reads with safe schema masks. Arbitrary executables are not OS-sandboxed; use containers or filesystem isolation when hard confinement is required.
|
|
45
47
|
* **Destructive CLI & Environment Safety:** Intercepts destructive cloud/database/infrastructure commands, stops script laundering and interpreter evasion, blocks TTY hangs (`vim`, `nano`, `sudo`), and guards against dangerous package manager flags.
|
|
46
48
|
|
|
47
49
|
For complete policy specifications and override rules, see [docs/policies.md](docs/policies.md).
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "src/workflow-guard.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/workflow-guard.ts",
|
|
10
|
-
"./
|
|
10
|
+
"./server": "./src/workflow-guard.ts",
|
|
11
|
+
"./tui": "./src/workflow-guard-ui.ts"
|
|
11
12
|
},
|
|
12
13
|
"publishConfig": {
|
|
13
14
|
"access": "public"
|
package/src/lib/review.ts
CHANGED
|
@@ -34,12 +34,18 @@ export function buildReviewRubric(diffText: string, taskPrompt?: string): string
|
|
|
34
34
|
"### 5. Platform & Architecture Fit (GitHub & Azure DevOps)",
|
|
35
35
|
"- Does the change fit established repository patterns and CI/CD pipelines?",
|
|
36
36
|
"",
|
|
37
|
+
"### Finding Severity & Priority Tiers (inspired by OMP Advisor):",
|
|
38
|
+
"- **P0**: Blocker / security vulnerability / data loss risk / release-blocking regression (must block approval).",
|
|
39
|
+
"- **P1**: Major defect / missing core requirement / broken error handling (must block approval).",
|
|
40
|
+
"- **P2**: Minor bug / edge case / hygiene issue / missing unit test.",
|
|
41
|
+
"- **P3**: Suggestion / nitpick / optimization idea.",
|
|
42
|
+
"",
|
|
37
43
|
taskPrompt ? `### User Request / Context:\n${taskPrompt}\n` : "",
|
|
38
44
|
"### Code Diff Under Review:",
|
|
39
45
|
"```diff",
|
|
40
46
|
diffText.slice(0, 30_000),
|
|
41
47
|
"```",
|
|
42
48
|
"",
|
|
43
|
-
"Provide your verdict: `[APPROVE]` or `[REQUEST_CHANGES]` with concise, actionable findings.",
|
|
49
|
+
"Provide your verdict: `[APPROVE]` or `[REQUEST_CHANGES]` with concise, actionable findings ranked by priority (P0-P3).",
|
|
44
50
|
].join("\n");
|
|
45
51
|
}
|
package/src/lib/state.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { realpathSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
import { persistVerifyCache } from "./audit.ts";
|
|
5
|
-
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary } from "./verify.ts";
|
|
5
|
+
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
|
|
6
6
|
import type {
|
|
7
7
|
TodoSdkClient,
|
|
8
8
|
ProjectConfig,
|
|
@@ -149,13 +149,17 @@ export function recordReviewResult(
|
|
|
149
149
|
targetSessionID?: string,
|
|
150
150
|
workspace?: string,
|
|
151
151
|
): void {
|
|
152
|
+
const root = resolve(workspace ?? getWorkspaceRoot());
|
|
152
153
|
lastReview = {
|
|
153
154
|
reviewer,
|
|
154
155
|
summary: summary.slice(-4000),
|
|
155
156
|
passed,
|
|
156
157
|
timestamp: Date.now(),
|
|
157
158
|
targetSessionID,
|
|
158
|
-
workspace,
|
|
159
|
+
workspace: root,
|
|
160
|
+
commitHash: getCurrentGitCommitHash(root),
|
|
161
|
+
gitStatus: getGitStatusSummary(root),
|
|
162
|
+
worktreeFingerprint: getGitWorktreeFingerprint(root),
|
|
159
163
|
};
|
|
160
164
|
if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
|
|
161
165
|
}
|
|
@@ -275,3 +279,15 @@ export function isDocumentationRequired(root: string): boolean {
|
|
|
275
279
|
const cfg = getProjectConfig(root);
|
|
276
280
|
return cfg.requireDocumentation === true;
|
|
277
281
|
}
|
|
282
|
+
|
|
283
|
+
export function getSubagentMutationBudget(root: string): number {
|
|
284
|
+
if (process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS) {
|
|
285
|
+
const parsed = parseInt(process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS, 10);
|
|
286
|
+
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
|
287
|
+
}
|
|
288
|
+
const cfg = getProjectConfig(root);
|
|
289
|
+
if (typeof cfg.maxSubagentMutations === "number" && cfg.maxSubagentMutations > 0) {
|
|
290
|
+
return cfg.maxSubagentMutations;
|
|
291
|
+
}
|
|
292
|
+
return 50;
|
|
293
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface ProjectConfig {
|
|
|
21
21
|
verifyCommand?: string;
|
|
22
22
|
requireReview?: boolean;
|
|
23
23
|
requireDocumentation?: boolean;
|
|
24
|
+
maxSubagentMutations?: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface VerifyResult {
|
|
@@ -41,6 +42,9 @@ export interface ReviewResult {
|
|
|
41
42
|
timestamp: number;
|
|
42
43
|
targetSessionID?: string;
|
|
43
44
|
workspace?: string;
|
|
45
|
+
commitHash?: string;
|
|
46
|
+
gitStatus?: string;
|
|
47
|
+
worktreeFingerprint?: string;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export interface AuditEntry {
|
package/src/lib/utils.ts
CHANGED
|
@@ -29,6 +29,20 @@ export function normalize(cmd: string): string {
|
|
|
29
29
|
return cmd.replace(/\s+/g, " ");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export function decodeShellEscapes(text: string): string {
|
|
33
|
+
return text
|
|
34
|
+
// ANSI-C quoting wrapper: $'...' -> ...
|
|
35
|
+
.replace(/\$'([^']*)'/g, "$1")
|
|
36
|
+
// Hex escapes: \xHH
|
|
37
|
+
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
38
|
+
// Unicode escapes: \uHHHH
|
|
39
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
40
|
+
// Octal escapes: \0OO or \OOO (e.g. \162 -> 'r')
|
|
41
|
+
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
|
|
42
|
+
// Single character escapes: \c -> c (e.g. \r\m -> rm)
|
|
43
|
+
.replace(/\\(.)/g, "$1");
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
export function shellWords(command: string): string[] {
|
|
33
47
|
return (command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((word) =>
|
|
34
48
|
word.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2"),
|
|
@@ -69,14 +83,69 @@ export function splitShellSegments(command: string): string[] {
|
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
export function unwrapShellWords(command: string): string[] {
|
|
72
|
-
|
|
86
|
+
let cleanCmd = decodeShellEscapes(command.trim());
|
|
87
|
+
// Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
|
|
88
|
+
while (
|
|
89
|
+
(cleanCmd.startsWith("(") && cleanCmd.endsWith(")")) ||
|
|
90
|
+
(cleanCmd.startsWith("{") && cleanCmd.endsWith("}"))
|
|
91
|
+
) {
|
|
92
|
+
cleanCmd = cleanCmd.slice(1, -1).trim();
|
|
93
|
+
if (cleanCmd.endsWith(";")) cleanCmd = cleanCmd.slice(0, -1).trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const words = shellWords(cleanCmd);
|
|
73
97
|
let i = 0;
|
|
74
98
|
while (i < words.length) {
|
|
75
|
-
|
|
99
|
+
const rawWord = words[i]!;
|
|
100
|
+
const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
|
|
101
|
+
if (!word) {
|
|
102
|
+
i++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (word === "command" || word === "exec" || word === "eval" || word === "nohup") {
|
|
107
|
+
i++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (word === "nice") {
|
|
111
|
+
i++;
|
|
112
|
+
if (i < words.length && (words[i] === "-n" || words[i]?.startsWith("-n"))) {
|
|
113
|
+
if (words[i] === "-n") i += 2;
|
|
114
|
+
else i++;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (word === "timeout") {
|
|
119
|
+
i++;
|
|
120
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
121
|
+
const opt = words[i++]!;
|
|
122
|
+
if ((opt === "-k" || opt === "-s" || opt === "--signal" || opt === "--kill-after") && i < words.length) {
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (i < words.length && /^\d+[smhd]?$/.test(words[i]!)) {
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (word === "stdbuf") {
|
|
76
132
|
i++;
|
|
133
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
134
|
+
const opt = words[i++]!;
|
|
135
|
+
if ((opt === "-i" || opt === "-o" || opt === "-e") && i < words.length) {
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (word === "time") {
|
|
142
|
+
i++;
|
|
143
|
+
while (i < words.length && words[i]!.startsWith("-")) {
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
77
146
|
continue;
|
|
78
147
|
}
|
|
79
|
-
if (
|
|
148
|
+
if (word === "sudo" || word === "doas") {
|
|
80
149
|
i++;
|
|
81
150
|
const valueOptions = new Set([
|
|
82
151
|
"-u",
|
|
@@ -100,7 +169,7 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
100
169
|
}
|
|
101
170
|
continue;
|
|
102
171
|
}
|
|
103
|
-
if (
|
|
172
|
+
if (word === "env") {
|
|
104
173
|
i++;
|
|
105
174
|
const valueOptions = new Set([
|
|
106
175
|
"-u",
|
|
@@ -111,24 +180,27 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
111
180
|
"--split-string",
|
|
112
181
|
]);
|
|
113
182
|
while (i < words.length) {
|
|
114
|
-
const
|
|
115
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(
|
|
183
|
+
const w = words[i]!;
|
|
184
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
|
|
116
185
|
i++;
|
|
117
186
|
continue;
|
|
118
187
|
}
|
|
119
|
-
if (!
|
|
188
|
+
if (!w.startsWith("-")) break;
|
|
120
189
|
i++;
|
|
121
|
-
if (valueOptions.has(
|
|
190
|
+
if (valueOptions.has(w) && i < words.length) i++;
|
|
122
191
|
}
|
|
123
192
|
continue;
|
|
124
193
|
}
|
|
125
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(
|
|
194
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
|
126
195
|
i++;
|
|
127
196
|
continue;
|
|
128
197
|
}
|
|
129
198
|
break;
|
|
130
199
|
}
|
|
131
|
-
return words
|
|
200
|
+
return words
|
|
201
|
+
.slice(i)
|
|
202
|
+
.map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
|
|
203
|
+
.filter(Boolean);
|
|
132
204
|
}
|
|
133
205
|
|
|
134
206
|
export function unwrapShellCommand(command: string): string {
|
package/src/lib/verify.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { getCleanEnv, normalize } from "./utils.ts";
|
|
5
6
|
import { getProjectConfig } from "./state.ts";
|
|
@@ -18,11 +19,35 @@ export function detectVerifyCommand(root: string): string | undefined {
|
|
|
18
19
|
return cmd || undefined;
|
|
19
20
|
}
|
|
20
21
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const pkgPath = join(root, "package.json");
|
|
23
|
+
if (existsSync(pkgPath)) {
|
|
24
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
|
|
25
|
+
scripts?: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
if (pkg.scripts?.test) return "npm test";
|
|
28
|
+
if (pkg.scripts?.typecheck) return "npm run typecheck";
|
|
29
|
+
if (pkg.scripts?.check) return "npm run check";
|
|
30
|
+
if (pkg.scripts?.build) return "npm run build";
|
|
31
|
+
}
|
|
25
32
|
} catch {}
|
|
33
|
+
|
|
34
|
+
if (existsSync(join(root, "Cargo.toml"))) {
|
|
35
|
+
return "cargo test";
|
|
36
|
+
}
|
|
37
|
+
if (existsSync(join(root, "go.mod"))) {
|
|
38
|
+
return "go test ./...";
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
existsSync(join(root, "pytest.ini")) ||
|
|
42
|
+
existsSync(join(root, "pyproject.toml")) ||
|
|
43
|
+
existsSync(join(root, "setup.py"))
|
|
44
|
+
) {
|
|
45
|
+
return "pytest";
|
|
46
|
+
}
|
|
47
|
+
if (existsSync(join(root, "deno.json")) || existsSync(join(root, "deno.jsonc"))) {
|
|
48
|
+
return "deno test";
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
return undefined;
|
|
27
52
|
}
|
|
28
53
|
|
|
@@ -92,6 +117,43 @@ export function getGitStatusSummary(root: string): string | undefined {
|
|
|
92
117
|
return undefined;
|
|
93
118
|
}
|
|
94
119
|
|
|
120
|
+
export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
121
|
+
try {
|
|
122
|
+
// Index listing captures the exact staged/tracked content (blob hashes);
|
|
123
|
+
// a worktree-vs-index diff captures unstaged edits; untracked file
|
|
124
|
+
// contents cover files git does not yet track. Together these bind the
|
|
125
|
+
// fingerprint to file contents regardless of whether HEAD exists, so
|
|
126
|
+
// freshly initialized (commitless) repositories are covered too.
|
|
127
|
+
const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
|
|
128
|
+
cwd: root,
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
timeout: 5_000,
|
|
131
|
+
});
|
|
132
|
+
const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
|
|
133
|
+
cwd: root,
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
timeout: 5_000,
|
|
136
|
+
});
|
|
137
|
+
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
138
|
+
cwd: root,
|
|
139
|
+
encoding: "utf8",
|
|
140
|
+
timeout: 5_000,
|
|
141
|
+
});
|
|
142
|
+
if (staged.status !== 0 || unstaged.status !== 0 || untracked.status !== 0) return undefined;
|
|
143
|
+
const hash = createHash("sha256").update(staged.stdout).update(unstaged.stdout);
|
|
144
|
+
for (const file of untracked.stdout.split("\0").filter(Boolean).sort()) {
|
|
145
|
+
hash.update("\0" + file + "\0");
|
|
146
|
+
try {
|
|
147
|
+
hash.update(readFileSync(join(root, file)));
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return hash.digest("hex");
|
|
153
|
+
} catch {}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
95
157
|
export async function runVerify(
|
|
96
158
|
command: string,
|
|
97
159
|
root: string,
|
package/src/policies/boundary.ts
CHANGED
|
@@ -127,23 +127,29 @@ export function teeTargetsIn(segment: string): string[] {
|
|
|
127
127
|
return targets;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
)
|
|
134
|
-
|
|
130
|
+
function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
131
|
+
const mutations: ShellMutation[] = [];
|
|
132
|
+
const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/g;
|
|
133
|
+
for (const redirectMatch of segment.matchAll(redirectRe)) {
|
|
134
|
+
if (!redirectMatch[1] || !redirectMatch[2]) continue;
|
|
135
135
|
const op = redirectMatch[1];
|
|
136
136
|
const target = redirectMatch[2];
|
|
137
137
|
// Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
|
|
138
138
|
const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
|
|
139
139
|
if (!isFdDup && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
|
|
140
|
-
|
|
140
|
+
mutations.push({
|
|
141
141
|
kind: "redirect",
|
|
142
142
|
target,
|
|
143
143
|
what: `file redirect to '${target}'`,
|
|
144
|
-
};
|
|
144
|
+
});
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
return mutations;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
151
|
+
const redirectMutation = redirectMutationsIn(segment)[0];
|
|
152
|
+
if (redirectMutation) return redirectMutation;
|
|
147
153
|
const teeTargets = teeTargetsIn(segment);
|
|
148
154
|
if (teeTargets.length > 0) {
|
|
149
155
|
return {
|
|
@@ -192,12 +198,29 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
192
198
|
return undefined;
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate"]);
|
|
202
|
+
const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln"]);
|
|
203
|
+
|
|
195
204
|
export function simpleFilesystemMutations(segment: string): ShellMutation[] {
|
|
196
205
|
const words = shellWords(unwrapShellCommand(segment));
|
|
197
206
|
const command = words[0];
|
|
198
|
-
if (!command
|
|
207
|
+
if (!command) {
|
|
199
208
|
return [];
|
|
200
209
|
}
|
|
210
|
+
if (command === "dd") {
|
|
211
|
+
return words
|
|
212
|
+
.slice(1)
|
|
213
|
+
.filter((word) => word.startsWith("of=") && word.length > 3)
|
|
214
|
+
.map((word) => {
|
|
215
|
+
const target = word.slice(3);
|
|
216
|
+
return {
|
|
217
|
+
kind: "command" as const,
|
|
218
|
+
target,
|
|
219
|
+
what: `dd output to '${target}'`,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (!SIMPLE_MUTATION_COMMANDS.has(command)) return [];
|
|
201
224
|
return words
|
|
202
225
|
.slice(1)
|
|
203
226
|
.filter((word) => !word.startsWith("-"))
|
|
@@ -212,7 +235,7 @@ export function filesystemTransferInfo(
|
|
|
212
235
|
segment: string,
|
|
213
236
|
): { sources: string[]; destination?: string } | undefined {
|
|
214
237
|
const words = shellWords(unwrapShellCommand(segment));
|
|
215
|
-
if (!words[0] || !
|
|
238
|
+
if (!words[0] || !TRANSFER_COMMANDS.has(words[0])) return undefined;
|
|
216
239
|
const operands: string[] = [];
|
|
217
240
|
let targetDirectory: string | undefined;
|
|
218
241
|
for (let i = 1; i < words.length; i++) {
|
|
@@ -244,6 +267,24 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
|
|
|
244
267
|
return undefined;
|
|
245
268
|
}
|
|
246
269
|
|
|
270
|
+
export function detectShellMutation(command: string): ShellMutation | undefined {
|
|
271
|
+
for (const segment of command.split(/[\n|;&]+/)) {
|
|
272
|
+
const simpleMutations = simpleFilesystemMutations(segment);
|
|
273
|
+
if (simpleMutations.length > 0) return simpleMutations[0];
|
|
274
|
+
const teeTargets = teeTargetsIn(segment);
|
|
275
|
+
if (teeTargets.length > 0) {
|
|
276
|
+
return {
|
|
277
|
+
kind: "command",
|
|
278
|
+
target: teeTargets[0],
|
|
279
|
+
what: `tee to '${teeTargets[0]}'`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const fallback = shellMutationIn(segment.trim());
|
|
283
|
+
if (fallback) return fallback;
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
247
288
|
export async function guardShellMutation(
|
|
248
289
|
command: string,
|
|
249
290
|
sessionID: string | undefined,
|
|
@@ -277,14 +318,12 @@ export async function guardShellMutation(
|
|
|
277
318
|
target,
|
|
278
319
|
what: `tee to '${target}'`,
|
|
279
320
|
}));
|
|
280
|
-
const
|
|
281
|
-
const mutations = simpleMutations
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
? [fallbackMutation]
|
|
287
|
-
: [];
|
|
321
|
+
const redirectMutations = redirectMutationsIn(segment.trim());
|
|
322
|
+
const mutations = [...simpleMutations, ...teeMutations, ...redirectMutations];
|
|
323
|
+
if (mutations.length === 0) {
|
|
324
|
+
const fallbackMutation = shellMutationIn(segment.trim());
|
|
325
|
+
if (fallbackMutation) mutations.push(fallbackMutation);
|
|
326
|
+
}
|
|
288
327
|
for (const mutation of mutations) {
|
|
289
328
|
hasMutation = true;
|
|
290
329
|
const secret = secretIn(segment);
|
|
@@ -78,3 +78,62 @@ export function prBodyIncludesChangelog(command: string): boolean {
|
|
|
78
78
|
}
|
|
79
79
|
return false;
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifest?: string; lockfile?: string; reason?: string } {
|
|
83
|
+
try {
|
|
84
|
+
let modifiedFiles: string[] = [];
|
|
85
|
+
const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
|
|
86
|
+
for (const base of baseCandidates) {
|
|
87
|
+
const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
88
|
+
if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
|
|
89
|
+
const diff = spawnSync("git", ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
90
|
+
if (diff.status === 0 && diff.stdout.trim()) {
|
|
91
|
+
modifiedFiles = diff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const headDiff = spawnSync("git", ["diff", "--name-only", "HEAD"], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
96
|
+
if (headDiff.status === 0 && headDiff.stdout.trim()) {
|
|
97
|
+
const uncommitted = headDiff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
98
|
+
modifiedFiles = Array.from(new Set([...modifiedFiles, ...uncommitted]));
|
|
99
|
+
}
|
|
100
|
+
const untracked = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
101
|
+
if (untracked.status === 0 && untracked.stdout.trim()) {
|
|
102
|
+
const files = untracked.stdout.split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
|
|
103
|
+
modifiedFiles = Array.from(new Set([...modifiedFiles, ...files]));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const manifestToLockfiles: Array<{ manifest: RegExp; lockfiles: RegExp; name: string }> = [
|
|
107
|
+
{
|
|
108
|
+
manifest: /(?:^|\/)package\.json$/,
|
|
109
|
+
lockfiles: /(?:^|\/)(?:package-lock\.json|bun\.lock|bun\.lockb|pnpm-lock\.yaml|yarn\.lock)$/,
|
|
110
|
+
name: "package.json",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
manifest: /(?:^|\/)Cargo\.toml$/,
|
|
114
|
+
lockfiles: /(?:^|\/)Cargo\.lock$/,
|
|
115
|
+
name: "Cargo.toml",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
manifest: /(?:^|\/)go\.mod$/,
|
|
119
|
+
lockfiles: /(?:^|\/)go\.sum$/,
|
|
120
|
+
name: "go.mod",
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
for (const item of manifestToLockfiles) {
|
|
125
|
+
const hasManifest = modifiedFiles.some((f) => item.manifest.test(f));
|
|
126
|
+
if (hasManifest) {
|
|
127
|
+
const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
|
|
128
|
+
if (!hasLockfile) {
|
|
129
|
+
return {
|
|
130
|
+
isOutOfSync: true,
|
|
131
|
+
manifest: item.name,
|
|
132
|
+
reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
return { isOutOfSync: false };
|
|
139
|
+
}
|
|
@@ -48,6 +48,18 @@ export const LIVE_MUTATION_PATTERNS: LivePattern[] = [
|
|
|
48
48
|
{ re: /\b(?:curl|wget)\b[^;&]*\|\s*(?:bash|sh|zsh)\b/, what: ["remote ", "download piped directly to a shell"].join("") },
|
|
49
49
|
// Destructive git operations
|
|
50
50
|
{ re: /\bgit\s+push\b[^|;&]*(?:--force\b|--force-with-lease\b|\s-f\b)/, what: ["force ", "push"].join("") },
|
|
51
|
+
// Low-level disk / filesystem / partition destruction
|
|
52
|
+
{ re: /\b(?:mkfs(?:\.[a-z0-9]+)?|wipefs|parted|sfdisk|gdisk)\b/, what: ["disk/filesystem ", "format or partition manipulation"].join("") },
|
|
53
|
+
{ re: /\bdd\s+[^|;&]*\bof=\/dev\/(?:sd[a-z]|nvme\d|vd[a-z]|hd[a-z]|disk\d|rdisk\d|loop\d)/, what: ["raw disk/block device ", "overwrite via dd"].join("") },
|
|
54
|
+
{ re: /\bshred\s+[^|;&]*\/dev\/(?:sd[a-z]|nvme\d|vd[a-z]|hd[a-z]|disk\d|rdisk\d|loop\d)/, what: ["disk device ", "shredding"].join("") },
|
|
55
|
+
// Recursive permission/ownership clobbering on root or home
|
|
56
|
+
{ re: /\bchmod\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+\S+\s+(?:\/|~|\$HOME)(?:\s|$)/, what: ["recursive ", "permission clobbering of root/home path"].join("") },
|
|
57
|
+
{ re: /\bchown\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+\S+\s+(?:\/|~|\$HOME)(?:\s|$)/, what: ["recursive ", "ownership clobbering of root/home path"].join("") },
|
|
58
|
+
// Network reverse shell / raw socket execution / data exfiltration channels
|
|
59
|
+
{ re: /\/dev\/(?:tcp|udp)\/[a-zA-Z0-9_.-]+\/\d+/, what: ["raw network socket / ", "reverse shell channel (/dev/tcp or /dev/udp)"].join("") },
|
|
60
|
+
{ re: /\b(?:nc|ncat|netcat)\b[^|;&]*-[a-zA-Z]*e[a-zA-Z]*\s+(?:\/bin\/(?:ba)?sh|sh|bash|cmd\.exe|powershell)/i, what: ["netcat reverse ", "shell execution (-e)"].join("") },
|
|
61
|
+
{ re: /\bsocat\b[^|;&]*\bexec:\s*['"]?(?:\/bin\/(?:ba)?sh|sh|bash)/i, what: ["socat interactive ", "reverse shell spawn"].join("") },
|
|
62
|
+
{ re: /\bmknod\s+\S+\s+p\b/, what: ["named FIFO pipe ", "creation (reverse shell backpipe)"].join("") },
|
|
51
63
|
];
|
|
52
64
|
|
|
53
65
|
export function liveMutationIn(command: string): string | undefined {
|
|
@@ -38,7 +38,9 @@ export function checkPackageHygiene(command: string): { isViolating: boolean; na
|
|
|
38
38
|
|
|
39
39
|
// AI agents running in non-interactive terminal subshells hang indefinitely when an
|
|
40
40
|
// interactive prompt (vim, nano, less, top, sudo, npm init without -y, apt without -y,
|
|
41
|
-
// git rebase -i) waits for TTY input.
|
|
41
|
+
// git rebase -i) waits for TTY input. Process monitors are handled separately in
|
|
42
|
+
// checkProcessMonitorCommand because `top` can run in a safe batch mode, while the
|
|
43
|
+
// other monitors cannot.
|
|
42
44
|
|
|
43
45
|
const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
|
|
44
46
|
{
|
|
@@ -51,11 +53,6 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
51
53
|
name: "terminal pager",
|
|
52
54
|
advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
|
|
53
55
|
},
|
|
54
|
-
{
|
|
55
|
-
regex: /\b(?:top|htop|btop|atop|glances)\b/i,
|
|
56
|
-
name: "interactive process monitor",
|
|
57
|
-
advice: "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.",
|
|
58
|
-
},
|
|
59
56
|
{
|
|
60
57
|
regex: /\bsudo\b/i,
|
|
61
58
|
name: "sudo with password prompt",
|
|
@@ -88,7 +85,29 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
88
85
|
},
|
|
89
86
|
];
|
|
90
87
|
|
|
88
|
+
const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
|
|
89
|
+
// Process monitoring tokens are matched on whitespace/segment boundaries so that
|
|
90
|
+
// an unrelated word such as `desktop`, a hyphenated filename like `top-level-dir`,
|
|
91
|
+
// or a quoted argument like `echo "top"` is not mistaken for the `top` command.
|
|
92
|
+
const ALWAYS_INTERACTIVE_MONITOR_RE = /(?:^|[;&|\s])(?:htop|btop|atop|glances)(?:$|[;&|\s])/i;
|
|
93
|
+
const TOP_COMMAND_RE = /(?:^|[;&|\s])top(?:$|[;&|\s])/i;
|
|
94
|
+
const TOP_BATCH_FLAG_RE = /(?:^|\s)(?:--batch|-[A-Za-z]*b[A-Za-z]*)(?:\s|$)/i;
|
|
95
|
+
|
|
96
|
+
function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
97
|
+
for (const segment of command.split(/[;&|\n]+/)) {
|
|
98
|
+
if (ALWAYS_INTERACTIVE_MONITOR_RE.test(segment)) {
|
|
99
|
+
return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
|
|
100
|
+
}
|
|
101
|
+
if (TOP_COMMAND_RE.test(segment) && !TOP_BATCH_FLAG_RE.test(segment)) {
|
|
102
|
+
return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { isInteractive: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
109
|
+
const monitor = checkProcessMonitorCommand(command);
|
|
110
|
+
if (monitor.isInteractive) return monitor;
|
|
92
111
|
for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
|
|
93
112
|
if (regex.test(command)) {
|
|
94
113
|
return { isInteractive: true, name, advice };
|
package/src/policies/tamper.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { basename, relative, resolve } from "node:path";
|
|
3
3
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
4
|
+
import { decodeShellEscapes } from "../lib/utils.ts";
|
|
4
5
|
|
|
5
6
|
export const PROTECTED_PATH_REASON =
|
|
6
7
|
"Blocked: modifying Open" +
|
|
@@ -23,10 +24,9 @@ export const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
|
|
|
23
24
|
];
|
|
24
25
|
|
|
25
26
|
export function normalizeShellEvasion(text: string): string {
|
|
26
|
-
return text
|
|
27
|
+
return decodeShellEscapes(text)
|
|
27
28
|
.replace(/'([^']*)'/g, "$1")
|
|
28
|
-
.replace(/"([^"]*)"/g, "$1")
|
|
29
|
-
.replace(/\\(.)/g, "$1");
|
|
29
|
+
.replace(/"([^"]*)"/g, "$1");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function normalizeGlobPathEvasion(text: string): string {
|
package/src/workflow-guard.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
stripJsonComments,
|
|
48
48
|
isReviewRequired,
|
|
49
49
|
isDocumentationRequired,
|
|
50
|
+
getSubagentMutationBudget,
|
|
50
51
|
recordReviewResult,
|
|
51
52
|
getLastReviewResult,
|
|
52
53
|
resetReviewState,
|
|
@@ -68,6 +69,7 @@ export {
|
|
|
68
69
|
stripJsonComments,
|
|
69
70
|
isReviewRequired,
|
|
70
71
|
isDocumentationRequired,
|
|
72
|
+
getSubagentMutationBudget,
|
|
71
73
|
recordReviewResult,
|
|
72
74
|
getLastReviewResult,
|
|
73
75
|
resetReviewState,
|
|
@@ -108,6 +110,7 @@ import {
|
|
|
108
110
|
snipVerifyOutput,
|
|
109
111
|
getCurrentGitCommitHash,
|
|
110
112
|
getGitStatusSummary,
|
|
113
|
+
getGitWorktreeFingerprint,
|
|
111
114
|
} from "./lib/verify.ts";
|
|
112
115
|
|
|
113
116
|
export {
|
|
@@ -116,6 +119,7 @@ export {
|
|
|
116
119
|
snipVerifyOutput,
|
|
117
120
|
getCurrentGitCommitHash,
|
|
118
121
|
getGitStatusSummary,
|
|
122
|
+
getGitWorktreeFingerprint,
|
|
119
123
|
};
|
|
120
124
|
|
|
121
125
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
@@ -179,8 +183,11 @@ import {
|
|
|
179
183
|
hasPrCreateInvocation,
|
|
180
184
|
branchHasChangelogChange,
|
|
181
185
|
prBodyIncludesChangelog,
|
|
186
|
+
checkLockfileSync,
|
|
182
187
|
} from "./policies/changelog.ts";
|
|
183
188
|
|
|
189
|
+
export { checkLockfileSync };
|
|
190
|
+
|
|
184
191
|
import { liveMutationIn, extractEditContent } from "./policies/destructive.ts";
|
|
185
192
|
|
|
186
193
|
import { mcpMutationTool } from "./policies/mcp.ts";
|
|
@@ -197,6 +204,7 @@ import {
|
|
|
197
204
|
isPathOutsideWorkspace,
|
|
198
205
|
extractPatchPaths,
|
|
199
206
|
guardShellMutation,
|
|
207
|
+
detectShellMutation,
|
|
200
208
|
} from "./policies/boundary.ts";
|
|
201
209
|
|
|
202
210
|
import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMaskedEnvSchema } from "./policies/secrets.ts";
|
|
@@ -240,13 +248,38 @@ function logBlock(message: string): void {
|
|
|
240
248
|
} catch {}
|
|
241
249
|
}
|
|
242
250
|
|
|
251
|
+
const READ_ONLY_ROLES = new Set([
|
|
252
|
+
"reviewer",
|
|
253
|
+
"planner",
|
|
254
|
+
"advisor",
|
|
255
|
+
"critic",
|
|
256
|
+
"explorer",
|
|
257
|
+
"scout",
|
|
258
|
+
"evaluator",
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
export function isReadOnlyRole(agent?: string): boolean {
|
|
262
|
+
if (!agent) return false;
|
|
263
|
+
const lower = agent.toLowerCase().trim();
|
|
264
|
+
return READ_ONLY_ROLES.has(lower) || Array.from(READ_ONLY_ROLES).some((r) => lower.includes(r));
|
|
265
|
+
}
|
|
266
|
+
|
|
243
267
|
async function guardToolCallImpl(
|
|
244
268
|
toolName: string,
|
|
245
269
|
input: unknown,
|
|
246
|
-
context?: { sessionID?: string },
|
|
270
|
+
context?: { sessionID?: string; worktree?: string; directory?: string; agent?: string },
|
|
247
271
|
): Promise<string | undefined> {
|
|
248
272
|
const currentRoot = getWorkspaceRoot();
|
|
249
273
|
|
|
274
|
+
// ── Role confinement: subagents in read-only roles cannot mutate files ──
|
|
275
|
+
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
276
|
+
if (EDIT_TOOL_NAMES.has(toolName) || toolName.startsWith("guard_worktree_")) {
|
|
277
|
+
const reason = `Blocked: subagent with read-only role '${context.agent}' cannot perform file mutations or lifecycle changes.`;
|
|
278
|
+
logBlock(`[workflow-guard] ${reason}`);
|
|
279
|
+
return reason;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
250
283
|
// ── Policy 17: secret file read block via read tool ──
|
|
251
284
|
if (toolName === "read" || toolName === "read_file") {
|
|
252
285
|
const record = asRecord(input);
|
|
@@ -499,6 +532,18 @@ async function guardToolCallImpl(
|
|
|
499
532
|
"starting new work."
|
|
500
533
|
);
|
|
501
534
|
}
|
|
535
|
+
if (context?.sessionID) {
|
|
536
|
+
const parentID = await fetchParentSessionID(context.sessionID);
|
|
537
|
+
if (parentID) {
|
|
538
|
+
const count = getMutationCount(context.sessionID);
|
|
539
|
+
const budget = getSubagentMutationBudget(currentRoot);
|
|
540
|
+
if (count >= budget) {
|
|
541
|
+
const reason = `Blocked: subagent session '${context.sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
|
|
542
|
+
logBlock(`[workflow-guard] ${reason}`);
|
|
543
|
+
return reason;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
502
547
|
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
503
548
|
return undefined;
|
|
504
549
|
}
|
|
@@ -765,8 +810,11 @@ async function guardToolCallImpl(
|
|
|
765
810
|
: getLastReviewResult();
|
|
766
811
|
const reviewMatchesContext =
|
|
767
812
|
review?.passed === true &&
|
|
768
|
-
|
|
769
|
-
(!review.targetSessionID || review.targetSessionID === context?.sessionID)
|
|
813
|
+
review.workspace === resolve(currentRoot) &&
|
|
814
|
+
(!review.targetSessionID || review.targetSessionID === context?.sessionID) &&
|
|
815
|
+
review.commitHash === getCurrentGitCommitHash(currentRoot) &&
|
|
816
|
+
review.gitStatus === getGitStatusSummary(currentRoot) &&
|
|
817
|
+
review.worktreeFingerprint === getGitWorktreeFingerprint(currentRoot);
|
|
770
818
|
if (!reviewMatchesContext) {
|
|
771
819
|
logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
|
|
772
820
|
return (
|
|
@@ -802,6 +850,13 @@ async function guardToolCallImpl(
|
|
|
802
850
|
`'Changelog:' section in the PR description (${descFlag}).`
|
|
803
851
|
);
|
|
804
852
|
}
|
|
853
|
+
|
|
854
|
+
// Lockfile synchronization pre-flight gate
|
|
855
|
+
const lockCheck = checkLockfileSync(currentRoot);
|
|
856
|
+
if (lockCheck.isOutOfSync) {
|
|
857
|
+
logBlock(`[workflow-guard] blocked ${prTool}: ${lockCheck.reason}`);
|
|
858
|
+
return `Blocked: ${lockCheck.reason}`;
|
|
859
|
+
}
|
|
805
860
|
}
|
|
806
861
|
|
|
807
862
|
const hasGitMutation = gitInvocations.some((invocation) => {
|
|
@@ -811,6 +866,35 @@ async function guardToolCallImpl(
|
|
|
811
866
|
/\bgit\s+(?:switch|checkout)\b/.test(normalizedInvocation)
|
|
812
867
|
);
|
|
813
868
|
});
|
|
869
|
+
|
|
870
|
+
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
871
|
+
const mutation = detectShellMutation(command);
|
|
872
|
+
if (mutation) {
|
|
873
|
+
logBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
|
|
874
|
+
return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`;
|
|
875
|
+
}
|
|
876
|
+
if (hasGitMutation) {
|
|
877
|
+
logBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
|
|
878
|
+
return `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const shellMut = detectShellMutation(command);
|
|
883
|
+
if (hasGitMutation || shellMut) {
|
|
884
|
+
if (context?.sessionID) {
|
|
885
|
+
const parentID = await fetchParentSessionID(context.sessionID);
|
|
886
|
+
if (parentID) {
|
|
887
|
+
const count = getMutationCount(context.sessionID);
|
|
888
|
+
const budget = getSubagentMutationBudget(currentRoot);
|
|
889
|
+
if (count >= budget) {
|
|
890
|
+
const reason = `Blocked: subagent session '${context.sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
|
|
891
|
+
logBlock(`[workflow-guard] ${reason}`);
|
|
892
|
+
return reason;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
814
898
|
if (hasGitMutation) {
|
|
815
899
|
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
816
900
|
}
|
|
@@ -826,9 +910,13 @@ async function guardToolCallImpl(
|
|
|
826
910
|
export async function guardToolCall(
|
|
827
911
|
toolName: string,
|
|
828
912
|
input: unknown,
|
|
829
|
-
context?: { sessionID?: string },
|
|
913
|
+
context?: { sessionID?: string; worktree?: string; directory?: string; agent?: string },
|
|
830
914
|
): Promise<string | undefined> {
|
|
831
|
-
const
|
|
915
|
+
const customRoot = context?.worktree || context?.directory;
|
|
916
|
+
const runImpl = () => guardToolCallImpl(toolName, input, context);
|
|
917
|
+
const reason = customRoot
|
|
918
|
+
? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
|
|
919
|
+
: await runImpl();
|
|
832
920
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
833
921
|
const isMutation = EDIT_TOOL_NAMES.has(toolName);
|
|
834
922
|
const record = asRecord(input);
|
|
@@ -998,6 +1086,14 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
998
1086
|
`evaluate each axis, and include findings per axis in the summary.`
|
|
999
1087
|
);
|
|
1000
1088
|
}
|
|
1089
|
+
if (args.passed) {
|
|
1090
|
+
if (/(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) {
|
|
1091
|
+
return (
|
|
1092
|
+
"[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. " +
|
|
1093
|
+
"Resolve all P0/P1 issues before approving or record review with passed=false."
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1001
1097
|
recordReviewResult(
|
|
1002
1098
|
args.reviewer,
|
|
1003
1099
|
args.summary,
|
|
@@ -1116,12 +1212,23 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1116
1212
|
},
|
|
1117
1213
|
|
|
1118
1214
|
"tool.execute.before": async (input, output) => {
|
|
1119
|
-
|
|
1215
|
+
const toolWorktree =
|
|
1216
|
+
(input as { worktree?: string })?.worktree ||
|
|
1217
|
+
(input as { directory?: string })?.directory ||
|
|
1218
|
+
(output as { worktree?: string })?.worktree ||
|
|
1219
|
+
(output as { directory?: string })?.directory ||
|
|
1220
|
+
effectiveRoot;
|
|
1221
|
+
const toolAgent =
|
|
1222
|
+
(input as { agent?: string })?.agent ||
|
|
1223
|
+
(output as { agent?: string })?.agent;
|
|
1224
|
+
return runWithRuntimeState(toolWorktree, ctx.client, async () => {
|
|
1120
1225
|
const args =
|
|
1121
1226
|
(output as { args?: unknown } | undefined)?.args ??
|
|
1122
1227
|
(input as { args?: unknown }).args;
|
|
1123
1228
|
const reason = await guardToolCall(input.tool, args, {
|
|
1124
1229
|
sessionID: input.sessionID,
|
|
1230
|
+
worktree: toolWorktree,
|
|
1231
|
+
agent: toolAgent,
|
|
1125
1232
|
});
|
|
1126
1233
|
if (reason !== undefined) {
|
|
1127
1234
|
await emitBlockFeedback(reason);
|