opencode-workflow-guard 1.3.8 → 1.4.0
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/package.json +1 -1
- package/src/lib/review.ts +7 -1
- package/src/lib/state.ts +12 -0
- package/src/lib/types.ts +1 -0
- package/src/lib/utils.ts +82 -10
- package/src/lib/verify.ts +29 -5
- package/src/policies/boundary.ts +18 -0
- package/src/policies/changelog.ts +59 -0
- package/src/policies/destructive.ts +12 -0
- package/src/policies/tamper.ts +3 -3
- package/src/workflow-guard.ts +106 -4
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/package.json
CHANGED
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
|
@@ -275,3 +275,15 @@ export function isDocumentationRequired(root: string): boolean {
|
|
|
275
275
|
const cfg = getProjectConfig(root);
|
|
276
276
|
return cfg.requireDocumentation === true;
|
|
277
277
|
}
|
|
278
|
+
|
|
279
|
+
export function getSubagentMutationBudget(root: string): number {
|
|
280
|
+
if (process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS) {
|
|
281
|
+
const parsed = parseInt(process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS, 10);
|
|
282
|
+
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
|
283
|
+
}
|
|
284
|
+
const cfg = getProjectConfig(root);
|
|
285
|
+
if (typeof cfg.maxSubagentMutations === "number" && cfg.maxSubagentMutations > 0) {
|
|
286
|
+
return cfg.maxSubagentMutations;
|
|
287
|
+
}
|
|
288
|
+
return 50;
|
|
289
|
+
}
|
package/src/lib/types.ts
CHANGED
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,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getCleanEnv, normalize } from "./utils.ts";
|
|
5
5
|
import { getProjectConfig } from "./state.ts";
|
|
@@ -18,11 +18,35 @@ export function detectVerifyCommand(root: string): string | undefined {
|
|
|
18
18
|
return cmd || undefined;
|
|
19
19
|
}
|
|
20
20
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const pkgPath = join(root, "package.json");
|
|
22
|
+
if (existsSync(pkgPath)) {
|
|
23
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
|
|
24
|
+
scripts?: Record<string, string>;
|
|
25
|
+
};
|
|
26
|
+
if (pkg.scripts?.test) return "npm test";
|
|
27
|
+
if (pkg.scripts?.typecheck) return "npm run typecheck";
|
|
28
|
+
if (pkg.scripts?.check) return "npm run check";
|
|
29
|
+
if (pkg.scripts?.build) return "npm run build";
|
|
30
|
+
}
|
|
25
31
|
} catch {}
|
|
32
|
+
|
|
33
|
+
if (existsSync(join(root, "Cargo.toml"))) {
|
|
34
|
+
return "cargo test";
|
|
35
|
+
}
|
|
36
|
+
if (existsSync(join(root, "go.mod"))) {
|
|
37
|
+
return "go test ./...";
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
existsSync(join(root, "pytest.ini")) ||
|
|
41
|
+
existsSync(join(root, "pyproject.toml")) ||
|
|
42
|
+
existsSync(join(root, "setup.py"))
|
|
43
|
+
) {
|
|
44
|
+
return "pytest";
|
|
45
|
+
}
|
|
46
|
+
if (existsSync(join(root, "deno.json")) || existsSync(join(root, "deno.jsonc"))) {
|
|
47
|
+
return "deno test";
|
|
48
|
+
}
|
|
49
|
+
|
|
26
50
|
return undefined;
|
|
27
51
|
}
|
|
28
52
|
|
package/src/policies/boundary.ts
CHANGED
|
@@ -244,6 +244,24 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
|
|
|
244
244
|
return undefined;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
export function detectShellMutation(command: string): ShellMutation | undefined {
|
|
248
|
+
for (const segment of command.split(/[\n|;&]+/)) {
|
|
249
|
+
const simpleMutations = simpleFilesystemMutations(segment);
|
|
250
|
+
if (simpleMutations.length > 0) return simpleMutations[0];
|
|
251
|
+
const teeTargets = teeTargetsIn(segment);
|
|
252
|
+
if (teeTargets.length > 0) {
|
|
253
|
+
return {
|
|
254
|
+
kind: "command",
|
|
255
|
+
target: teeTargets[0],
|
|
256
|
+
what: `tee to '${teeTargets[0]}'`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const fallback = shellMutationIn(segment.trim());
|
|
260
|
+
if (fallback) return fallback;
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
247
265
|
export async function guardShellMutation(
|
|
248
266
|
command: string,
|
|
249
267
|
sessionID: string | undefined,
|
|
@@ -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 {
|
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,
|
|
@@ -179,8 +181,11 @@ import {
|
|
|
179
181
|
hasPrCreateInvocation,
|
|
180
182
|
branchHasChangelogChange,
|
|
181
183
|
prBodyIncludesChangelog,
|
|
184
|
+
checkLockfileSync,
|
|
182
185
|
} from "./policies/changelog.ts";
|
|
183
186
|
|
|
187
|
+
export { checkLockfileSync };
|
|
188
|
+
|
|
184
189
|
import { liveMutationIn, extractEditContent } from "./policies/destructive.ts";
|
|
185
190
|
|
|
186
191
|
import { mcpMutationTool } from "./policies/mcp.ts";
|
|
@@ -197,6 +202,7 @@ import {
|
|
|
197
202
|
isPathOutsideWorkspace,
|
|
198
203
|
extractPatchPaths,
|
|
199
204
|
guardShellMutation,
|
|
205
|
+
detectShellMutation,
|
|
200
206
|
} from "./policies/boundary.ts";
|
|
201
207
|
|
|
202
208
|
import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMaskedEnvSchema } from "./policies/secrets.ts";
|
|
@@ -240,13 +246,38 @@ function logBlock(message: string): void {
|
|
|
240
246
|
} catch {}
|
|
241
247
|
}
|
|
242
248
|
|
|
249
|
+
const READ_ONLY_ROLES = new Set([
|
|
250
|
+
"reviewer",
|
|
251
|
+
"planner",
|
|
252
|
+
"advisor",
|
|
253
|
+
"critic",
|
|
254
|
+
"explorer",
|
|
255
|
+
"scout",
|
|
256
|
+
"evaluator",
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
export function isReadOnlyRole(agent?: string): boolean {
|
|
260
|
+
if (!agent) return false;
|
|
261
|
+
const lower = agent.toLowerCase().trim();
|
|
262
|
+
return READ_ONLY_ROLES.has(lower) || Array.from(READ_ONLY_ROLES).some((r) => lower.includes(r));
|
|
263
|
+
}
|
|
264
|
+
|
|
243
265
|
async function guardToolCallImpl(
|
|
244
266
|
toolName: string,
|
|
245
267
|
input: unknown,
|
|
246
|
-
context?: { sessionID?: string },
|
|
268
|
+
context?: { sessionID?: string; worktree?: string; directory?: string; agent?: string },
|
|
247
269
|
): Promise<string | undefined> {
|
|
248
270
|
const currentRoot = getWorkspaceRoot();
|
|
249
271
|
|
|
272
|
+
// ── Role confinement: subagents in read-only roles cannot mutate files ──
|
|
273
|
+
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
274
|
+
if (EDIT_TOOL_NAMES.has(toolName) || toolName.startsWith("guard_worktree_")) {
|
|
275
|
+
const reason = `Blocked: subagent with read-only role '${context.agent}' cannot perform file mutations or lifecycle changes.`;
|
|
276
|
+
logBlock(`[workflow-guard] ${reason}`);
|
|
277
|
+
return reason;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
250
281
|
// ── Policy 17: secret file read block via read tool ──
|
|
251
282
|
if (toolName === "read" || toolName === "read_file") {
|
|
252
283
|
const record = asRecord(input);
|
|
@@ -499,6 +530,18 @@ async function guardToolCallImpl(
|
|
|
499
530
|
"starting new work."
|
|
500
531
|
);
|
|
501
532
|
}
|
|
533
|
+
if (context?.sessionID) {
|
|
534
|
+
const parentID = await fetchParentSessionID(context.sessionID);
|
|
535
|
+
if (parentID) {
|
|
536
|
+
const count = getMutationCount(context.sessionID);
|
|
537
|
+
const budget = getSubagentMutationBudget(currentRoot);
|
|
538
|
+
if (count >= budget) {
|
|
539
|
+
const reason = `Blocked: subagent session '${context.sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
|
|
540
|
+
logBlock(`[workflow-guard] ${reason}`);
|
|
541
|
+
return reason;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
502
545
|
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
503
546
|
return undefined;
|
|
504
547
|
}
|
|
@@ -802,6 +845,13 @@ async function guardToolCallImpl(
|
|
|
802
845
|
`'Changelog:' section in the PR description (${descFlag}).`
|
|
803
846
|
);
|
|
804
847
|
}
|
|
848
|
+
|
|
849
|
+
// Lockfile synchronization pre-flight gate
|
|
850
|
+
const lockCheck = checkLockfileSync(currentRoot);
|
|
851
|
+
if (lockCheck.isOutOfSync) {
|
|
852
|
+
logBlock(`[workflow-guard] blocked ${prTool}: ${lockCheck.reason}`);
|
|
853
|
+
return `Blocked: ${lockCheck.reason}`;
|
|
854
|
+
}
|
|
805
855
|
}
|
|
806
856
|
|
|
807
857
|
const hasGitMutation = gitInvocations.some((invocation) => {
|
|
@@ -811,6 +861,35 @@ async function guardToolCallImpl(
|
|
|
811
861
|
/\bgit\s+(?:switch|checkout)\b/.test(normalizedInvocation)
|
|
812
862
|
);
|
|
813
863
|
});
|
|
864
|
+
|
|
865
|
+
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
866
|
+
const mutation = detectShellMutation(command);
|
|
867
|
+
if (mutation) {
|
|
868
|
+
logBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
|
|
869
|
+
return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`;
|
|
870
|
+
}
|
|
871
|
+
if (hasGitMutation) {
|
|
872
|
+
logBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
|
|
873
|
+
return `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const shellMut = detectShellMutation(command);
|
|
878
|
+
if (hasGitMutation || shellMut) {
|
|
879
|
+
if (context?.sessionID) {
|
|
880
|
+
const parentID = await fetchParentSessionID(context.sessionID);
|
|
881
|
+
if (parentID) {
|
|
882
|
+
const count = getMutationCount(context.sessionID);
|
|
883
|
+
const budget = getSubagentMutationBudget(currentRoot);
|
|
884
|
+
if (count >= budget) {
|
|
885
|
+
const reason = `Blocked: subagent session '${context.sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
|
|
886
|
+
logBlock(`[workflow-guard] ${reason}`);
|
|
887
|
+
return reason;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
814
893
|
if (hasGitMutation) {
|
|
815
894
|
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
816
895
|
}
|
|
@@ -826,9 +905,13 @@ async function guardToolCallImpl(
|
|
|
826
905
|
export async function guardToolCall(
|
|
827
906
|
toolName: string,
|
|
828
907
|
input: unknown,
|
|
829
|
-
context?: { sessionID?: string },
|
|
908
|
+
context?: { sessionID?: string; worktree?: string; directory?: string; agent?: string },
|
|
830
909
|
): Promise<string | undefined> {
|
|
831
|
-
const
|
|
910
|
+
const customRoot = context?.worktree || context?.directory;
|
|
911
|
+
const runImpl = () => guardToolCallImpl(toolName, input, context);
|
|
912
|
+
const reason = customRoot
|
|
913
|
+
? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
|
|
914
|
+
: await runImpl();
|
|
832
915
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
833
916
|
const isMutation = EDIT_TOOL_NAMES.has(toolName);
|
|
834
917
|
const record = asRecord(input);
|
|
@@ -998,6 +1081,14 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
998
1081
|
`evaluate each axis, and include findings per axis in the summary.`
|
|
999
1082
|
);
|
|
1000
1083
|
}
|
|
1084
|
+
if (args.passed) {
|
|
1085
|
+
if (/(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) {
|
|
1086
|
+
return (
|
|
1087
|
+
"[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. " +
|
|
1088
|
+
"Resolve all P0/P1 issues before approving or record review with passed=false."
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1001
1092
|
recordReviewResult(
|
|
1002
1093
|
args.reviewer,
|
|
1003
1094
|
args.summary,
|
|
@@ -1116,12 +1207,23 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1116
1207
|
},
|
|
1117
1208
|
|
|
1118
1209
|
"tool.execute.before": async (input, output) => {
|
|
1119
|
-
|
|
1210
|
+
const toolWorktree =
|
|
1211
|
+
(input as { worktree?: string })?.worktree ||
|
|
1212
|
+
(input as { directory?: string })?.directory ||
|
|
1213
|
+
(output as { worktree?: string })?.worktree ||
|
|
1214
|
+
(output as { directory?: string })?.directory ||
|
|
1215
|
+
effectiveRoot;
|
|
1216
|
+
const toolAgent =
|
|
1217
|
+
(input as { agent?: string })?.agent ||
|
|
1218
|
+
(output as { agent?: string })?.agent;
|
|
1219
|
+
return runWithRuntimeState(toolWorktree, ctx.client, async () => {
|
|
1120
1220
|
const args =
|
|
1121
1221
|
(output as { args?: unknown } | undefined)?.args ??
|
|
1122
1222
|
(input as { args?: unknown }).args;
|
|
1123
1223
|
const reason = await guardToolCall(input.tool, args, {
|
|
1124
1224
|
sessionID: input.sessionID,
|
|
1225
|
+
worktree: toolWorktree,
|
|
1226
|
+
agent: toolAgent,
|
|
1125
1227
|
});
|
|
1126
1228
|
if (reason !== undefined) {
|
|
1127
1229
|
await emitBlockFeedback(reason);
|