opencode-workflow-guard 1.3.1 → 1.3.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 +3 -3
- package/package.json +1 -1
- package/src/lib/audit.ts +23 -4
- package/src/policies/boundary.ts +92 -18
- package/src/policies/git.ts +17 -1
- package/src/policies/interpreter.ts +53 -1
- package/src/policies/secrets.ts +29 -4
- package/src/policies/shell-safety.ts +15 -2
- package/src/workflow-guard.ts +40 -9
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Add to your project's `opencode.json` or global `~/.config/opencode/opencode.jso
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
npm run typecheck # Strict TypeScript check (0 errors)
|
|
30
|
-
npm test # Run
|
|
30
|
+
npm test # Run 400+ unit and adversarial tests
|
|
31
31
|
npm run test:all # Run full verification suite (typecheck + unit + live e2e)
|
|
32
32
|
```
|
|
33
33
|
|
|
@@ -43,11 +43,11 @@ opencode-workflow-guard/
|
|
|
43
43
|
│ ├── policies/ # Per-policy implementations (task gate, git, secrets, shell-safety, ...)
|
|
44
44
|
│ └── lib/ # Engine services (state, verify, audit, review, worktree, utils, types)
|
|
45
45
|
├── test/ # Test suites
|
|
46
|
-
│ ├── test.mts #
|
|
46
|
+
│ ├── test.mts # 400+ in-memory unit & adversarial tests
|
|
47
47
|
│ └── test-e2e.mts # npm tarball resolution + live runtime OpenCode loader tests
|
|
48
48
|
├── docs/ # In-depth documentation & guides
|
|
49
49
|
│ ├── installation.md # Full setup options, worktrees & permissions
|
|
50
|
-
│ ├── policies.md # Detailed
|
|
50
|
+
│ ├── policies.md # Detailed 24-policy reference & overrides
|
|
51
51
|
│ ├── troubleshooting.md # Error diagnosis & bypass explanations
|
|
52
52
|
│ └── testing.md # Test architecture & coverage matrix
|
|
53
53
|
├── .changeset/ # Fragment-based versioning & release changelogs
|
package/package.json
CHANGED
package/src/lib/audit.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, openSync, readSync, closeSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { AuditEntry, VerifyResult } from "./types.ts";
|
|
@@ -46,13 +46,32 @@ export function loadVerifyCache(): VerifyResult | undefined {
|
|
|
46
46
|
return undefined;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Reads recent audit entries from the end of the JSONL audit log with a bounded
|
|
51
|
+
* buffer window to prevent memory spikes on long-lived installations.
|
|
52
|
+
*/
|
|
49
53
|
export function getRecentAuditEntries(limit = 10): AuditEntry[] {
|
|
50
54
|
try {
|
|
51
55
|
if (!existsSync(AUDIT_FILE)) return [];
|
|
52
|
-
const
|
|
56
|
+
const stat = statSync(AUDIT_FILE);
|
|
57
|
+
if (stat.size === 0) return [];
|
|
58
|
+
|
|
59
|
+
const maxBytesToRead = Math.min(stat.size, 65_536);
|
|
60
|
+
const buffer = Buffer.alloc(maxBytesToRead);
|
|
61
|
+
const fd = openSync(AUDIT_FILE, "r");
|
|
62
|
+
try {
|
|
63
|
+
readSync(fd, buffer, 0, maxBytesToRead, stat.size - maxBytesToRead);
|
|
64
|
+
} finally {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const text = buffer.toString("utf8");
|
|
69
|
+
const lines = text.trim().split("\n");
|
|
70
|
+
const completeLines = maxBytesToRead < stat.size ? lines.slice(1) : lines;
|
|
71
|
+
|
|
53
72
|
const entries: AuditEntry[] = [];
|
|
54
|
-
for (let i =
|
|
55
|
-
const line =
|
|
73
|
+
for (let i = completeLines.length - 1; i >= 0 && entries.length < limit; i--) {
|
|
74
|
+
const line = completeLines[i]?.trim();
|
|
56
75
|
if (line) {
|
|
57
76
|
try {
|
|
58
77
|
entries.push(JSON.parse(line));
|
package/src/policies/boundary.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import type { ShellMutation } from "../lib/types.ts";
|
|
4
5
|
import {
|
|
5
6
|
getWorkspaceRoot,
|
|
@@ -16,9 +17,47 @@ import {
|
|
|
16
17
|
hasActiveTodo,
|
|
17
18
|
} from "./todo.ts";
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Expands leading `~`, `~user`, `$HOME`, and `${HOME}` in candidate targets so
|
|
22
|
+
* shell-level expansion cannot evade workspace boundary checks. Paths that
|
|
23
|
+
* contain unresolvable `$VARIABLE` references return `null` so callers treat
|
|
24
|
+
* indeterminate destinations as outside-workspace (fail-closed).
|
|
25
|
+
*/
|
|
26
|
+
export function expandShellTargetPath(targetPath: string): string | null {
|
|
27
|
+
if (!targetPath) return targetPath;
|
|
28
|
+
const trimmed = targetPath.trim().replace(/^["']|["']$/g, "");
|
|
29
|
+
if (!trimmed) return trimmed;
|
|
30
|
+
|
|
31
|
+
const home = process.env.HOME || homedir();
|
|
32
|
+
|
|
33
|
+
if (trimmed === "~") return home;
|
|
34
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
35
|
+
return join(home, trimmed.slice(2));
|
|
36
|
+
}
|
|
37
|
+
if (/^~[A-Za-z0-9_.-]+(?:\/|\\|$)/.test(trimmed)) {
|
|
38
|
+
const parts = trimmed.slice(1).split(/[/\\]/);
|
|
39
|
+
const user = parts[0]!;
|
|
40
|
+
const rest = parts.slice(1);
|
|
41
|
+
return join(dirname(home), user, ...rest);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let out = trimmed;
|
|
45
|
+
if (/^\$(?:HOME|\{HOME\})(?=$|[/\\])/.test(out)) {
|
|
46
|
+
out = out.replace(/^\$(?:HOME|\{HOME\})/, home);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (out.includes("$")) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
export function isPathOutsideWorkspace(targetPath: string, root: string): boolean {
|
|
20
57
|
if (!targetPath) return false;
|
|
21
|
-
const
|
|
58
|
+
const expanded = expandShellTargetPath(targetPath);
|
|
59
|
+
if (expanded === null) return true;
|
|
60
|
+
const resolved = resolve(root, expanded);
|
|
22
61
|
const normalizedRoot = root.endsWith("/") ? root : root + "/";
|
|
23
62
|
if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
|
|
24
63
|
return true;
|
|
@@ -64,26 +103,53 @@ export function extractPatchPaths(patchText: string): string[] {
|
|
|
64
103
|
return paths;
|
|
65
104
|
}
|
|
66
105
|
|
|
106
|
+
export function teeTargetsIn(segment: string): string[] {
|
|
107
|
+
const words = shellWords(unwrapShellCommand(segment));
|
|
108
|
+
if (words[0] !== "tee") {
|
|
109
|
+
const teeIdx = words.indexOf("tee");
|
|
110
|
+
if (teeIdx === -1) return [];
|
|
111
|
+
}
|
|
112
|
+
const startIdx = words[0] === "tee" ? 1 : words.indexOf("tee") + 1;
|
|
113
|
+
const targets: string[] = [];
|
|
114
|
+
let stopFlags = false;
|
|
115
|
+
for (let i = startIdx; i < words.length; i++) {
|
|
116
|
+
const w = words[i]!;
|
|
117
|
+
if (/^[|;&<>]/.test(w)) break;
|
|
118
|
+
if (!stopFlags) {
|
|
119
|
+
if (w === "--") {
|
|
120
|
+
stopFlags = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (w.startsWith("-")) continue;
|
|
124
|
+
}
|
|
125
|
+
targets.push(w);
|
|
126
|
+
}
|
|
127
|
+
return targets;
|
|
128
|
+
}
|
|
129
|
+
|
|
67
130
|
export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
68
131
|
const redirectMatch = segment.match(
|
|
69
|
-
/(
|
|
132
|
+
/(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/,
|
|
70
133
|
);
|
|
71
|
-
if (redirectMatch?.[1]) {
|
|
72
|
-
|
|
73
|
-
|
|
134
|
+
if (redirectMatch?.[1] && redirectMatch?.[2]) {
|
|
135
|
+
const op = redirectMatch[1];
|
|
136
|
+
const target = redirectMatch[2];
|
|
137
|
+
// Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
|
|
138
|
+
const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
|
|
139
|
+
if (!isFdDup && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
|
|
140
|
+
return {
|
|
141
|
+
kind: "redirect",
|
|
142
|
+
target,
|
|
143
|
+
what: `file redirect to '${target}'`,
|
|
144
|
+
};
|
|
74
145
|
}
|
|
75
|
-
return {
|
|
76
|
-
kind: "redirect",
|
|
77
|
-
target: redirectMatch[1],
|
|
78
|
-
what: `file redirect to '${redirectMatch[1]}'`,
|
|
79
|
-
};
|
|
80
146
|
}
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
147
|
+
const teeTargets = teeTargetsIn(segment);
|
|
148
|
+
if (teeTargets.length > 0) {
|
|
83
149
|
return {
|
|
84
150
|
kind: "command",
|
|
85
|
-
target:
|
|
86
|
-
what: `tee to '${
|
|
151
|
+
target: teeTargets[0]!,
|
|
152
|
+
what: `tee to '${teeTargets[0]!}'`,
|
|
87
153
|
};
|
|
88
154
|
}
|
|
89
155
|
const sedMatch = segment.match(/\bsed\s+(?:-(?:[a-zA-Z]*i[a-zA-Z]*|i\S*)\s+)+[^\s;&|]+/);
|
|
@@ -205,12 +271,20 @@ export async function guardShellMutation(
|
|
|
205
271
|
}
|
|
206
272
|
}
|
|
207
273
|
const simpleMutations = simpleFilesystemMutations(segment);
|
|
274
|
+
const teeTargets = teeTargetsIn(segment);
|
|
275
|
+
const teeMutations: ShellMutation[] = teeTargets.map((target) => ({
|
|
276
|
+
kind: "command" as const,
|
|
277
|
+
target,
|
|
278
|
+
what: `tee to '${target}'`,
|
|
279
|
+
}));
|
|
208
280
|
const fallbackMutation = shellMutationIn(segment.trim());
|
|
209
281
|
const mutations = simpleMutations.length > 0
|
|
210
282
|
? simpleMutations
|
|
211
|
-
:
|
|
212
|
-
?
|
|
213
|
-
:
|
|
283
|
+
: teeMutations.length > 0
|
|
284
|
+
? teeMutations
|
|
285
|
+
: fallbackMutation
|
|
286
|
+
? [fallbackMutation]
|
|
287
|
+
: [];
|
|
214
288
|
for (const mutation of mutations) {
|
|
215
289
|
hasMutation = true;
|
|
216
290
|
const secret = secretIn(segment);
|
package/src/policies/git.ts
CHANGED
|
@@ -44,6 +44,9 @@ const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
|
|
|
44
44
|
"--exec-path",
|
|
45
45
|
]);
|
|
46
46
|
|
|
47
|
+
const GIT_ENV_DIR_RE =
|
|
48
|
+
/(?:^|\s)(?:env\s+(?:-\S+\s+)*)?(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR)=(?:"([^"]*)"|'([^']*)'|(\S+))/;
|
|
49
|
+
|
|
47
50
|
export function parseGitInvocation(command: string): GitInvocation | undefined {
|
|
48
51
|
const root = getWorkspaceRoot();
|
|
49
52
|
const tokens = unwrapShellWords(command);
|
|
@@ -51,12 +54,22 @@ export function parseGitInvocation(command: string): GitInvocation | undefined {
|
|
|
51
54
|
let i = 1;
|
|
52
55
|
let repoDir = root;
|
|
53
56
|
let sawDirOption = false;
|
|
57
|
+
|
|
58
|
+
const envDirMatch = command.match(GIT_ENV_DIR_RE);
|
|
59
|
+
if (envDirMatch) {
|
|
60
|
+
const envVal = envDirMatch[1] ?? envDirMatch[2] ?? envDirMatch[3];
|
|
61
|
+
if (envVal) {
|
|
62
|
+
repoDir = resolve(root, envVal);
|
|
63
|
+
sawDirOption = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
while (i < tokens.length) {
|
|
55
68
|
const tok = tokens[i]!;
|
|
56
69
|
if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
|
|
57
70
|
const value = tokens[i + 1];
|
|
58
71
|
if (value === undefined) return { repoDir, rest: "" };
|
|
59
|
-
if (tok === "-C" || tok === "--git-dir") {
|
|
72
|
+
if (tok === "-C" || tok === "--git-dir" || tok === "--work-tree") {
|
|
60
73
|
repoDir = resolve(root, value);
|
|
61
74
|
sawDirOption = true;
|
|
62
75
|
}
|
|
@@ -67,6 +80,9 @@ export function parseGitInvocation(command: string): GitInvocation | undefined {
|
|
|
67
80
|
if (tok.startsWith("--git-dir=")) {
|
|
68
81
|
repoDir = resolve(root, tok.slice("--git-dir=".length));
|
|
69
82
|
sawDirOption = true;
|
|
83
|
+
} else if (tok.startsWith("--work-tree=")) {
|
|
84
|
+
repoDir = resolve(root, tok.slice("--work-tree=".length));
|
|
85
|
+
sawDirOption = true;
|
|
70
86
|
}
|
|
71
87
|
i += 1;
|
|
72
88
|
continue;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { isSecretPath } from "./secrets.ts";
|
|
2
|
+
import { isPathOutsideWorkspace } from "./boundary.ts";
|
|
3
|
+
|
|
1
4
|
export function extractInterpreterPayload(segment: string): string[] {
|
|
2
5
|
const payloads: string[] = [];
|
|
3
6
|
const inlineMatch = segment.match(
|
|
4
|
-
/\b(?:python3?|node|perl|ruby|osascript)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
|
|
7
|
+
/\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
|
|
5
8
|
);
|
|
6
9
|
if (inlineMatch?.[1] || inlineMatch?.[2]) {
|
|
7
10
|
payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
|
|
@@ -26,3 +29,52 @@ export function extractInterpreterPayload(segment: string): string[] {
|
|
|
26
29
|
}
|
|
27
30
|
return payloads;
|
|
28
31
|
}
|
|
32
|
+
|
|
33
|
+
const FILE_ACCESS_VERB_RE =
|
|
34
|
+
/\b(?:open|cat|read|readFile|readFileSync|read_text|readTextFile|write|writeFile|writeFileSync|write_text|writeTextFile|source|slurp|appendFile|appendFileSync|createReadStream|createWriteStream|load|loads|exec|execSync|spawn|spawnSync)\b/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Detects sensitive credential/secret file targets referenced inside inline
|
|
38
|
+
* interpreter scripts or subshells.
|
|
39
|
+
*/
|
|
40
|
+
export function secretPathInPayload(payload: string): string | undefined {
|
|
41
|
+
if (!FILE_ACCESS_VERB_RE.test(payload)) return undefined;
|
|
42
|
+
const tokens = payload.match(/["'][^"']*["']|[^\s(){}[\];,|&<>]+/g) ?? [];
|
|
43
|
+
for (const token of tokens) {
|
|
44
|
+
const cleaned = token.replace(/^["']|["']$/g, "");
|
|
45
|
+
if (cleaned && isSecretPath(cleaned)) {
|
|
46
|
+
return cleaned;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const WRITE_VERB_RE =
|
|
53
|
+
/\b(?:write|writeFile|writeFileSync|write_text|writeTextFile|appendFile|appendFileSync|createWriteStream|unlink|unlinkSync|rm|rmSync|rmdir|rmdirSync|mkdir|mkdirSync|rename|renameSync|copy|cp|cpSync|truncate|move|shutil|remove|delete)\b|\bopen\s*\(\s*["'][^"']*["']\s*,\s*["'][wa]/i;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Detects workspace boundary escapes (writes/redirects to paths outside the
|
|
57
|
+
* workspace root) initiated from inline interpreter payloads.
|
|
58
|
+
*/
|
|
59
|
+
export function outsideWritePathInPayload(payload: string, root: string): string | undefined {
|
|
60
|
+
const hasWrite = WRITE_VERB_RE.test(payload) || /[>]{1,2}\s*\S/.test(payload);
|
|
61
|
+
if (!hasWrite) return undefined;
|
|
62
|
+
|
|
63
|
+
const candidates = new Set<string>();
|
|
64
|
+
const redirectMatches = payload.matchAll(/[>]{1,2}\s*["']?([^\s;&"'|)]+)/g);
|
|
65
|
+
for (const m of redirectMatches) {
|
|
66
|
+
if (m[1]) candidates.add(m[1]);
|
|
67
|
+
}
|
|
68
|
+
const literalMatches = payload.matchAll(/["']([^"']{2,})["']/g);
|
|
69
|
+
for (const m of literalMatches) {
|
|
70
|
+
if (m[1]) candidates.add(m[1]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
if (/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(candidate)) continue;
|
|
75
|
+
if (isPathOutsideWorkspace(candidate, root)) {
|
|
76
|
+
return candidate;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
package/src/policies/secrets.ts
CHANGED
|
@@ -100,18 +100,43 @@ export function isEnvFilePath(targetPath: string): boolean {
|
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
102
|
* Parses a raw .env file and returns a sanitized schema mask where variable
|
|
103
|
-
* names and comment structures are preserved, but sensitive values
|
|
104
|
-
* redacted to '********'.
|
|
103
|
+
* names and comment structures are preserved, but sensitive values (including
|
|
104
|
+
* multiline secret continuations) are redacted to '********'.
|
|
105
105
|
*/
|
|
106
106
|
export function generateMaskedEnvSchema(content: string): string {
|
|
107
107
|
const lines = content.split("\n");
|
|
108
|
+
let inMultilineQuote: string | null = null;
|
|
109
|
+
|
|
108
110
|
return lines
|
|
109
111
|
.map((line) => {
|
|
110
112
|
const trimmed = line.trim();
|
|
111
|
-
if (!trimmed || trimmed.startsWith("#"))
|
|
113
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
114
|
+
inMultilineQuote = null;
|
|
115
|
+
return line;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (inMultilineQuote) {
|
|
119
|
+
if (trimmed.endsWith(inMultilineQuote) || trimmed.includes(inMultilineQuote)) {
|
|
120
|
+
inMultilineQuote = null;
|
|
121
|
+
}
|
|
122
|
+
return "********";
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
const eqIdx = line.indexOf("=");
|
|
113
|
-
if (eqIdx === -1)
|
|
126
|
+
if (eqIdx === -1) {
|
|
127
|
+
return "********";
|
|
128
|
+
}
|
|
129
|
+
|
|
114
130
|
const key = line.slice(0, eqIdx);
|
|
131
|
+
const val = line.slice(eqIdx + 1).trim();
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
(val.startsWith('"') && !val.slice(1).includes('"')) ||
|
|
135
|
+
(val.startsWith("'") && !val.slice(1).includes("'"))
|
|
136
|
+
) {
|
|
137
|
+
inMultilineQuote = val[0]!;
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
return `${key}=********`;
|
|
116
141
|
})
|
|
117
142
|
.join("\n");
|
|
@@ -97,6 +97,17 @@ export function checkInteractiveTtyCommand(command: string): { isInteractive: bo
|
|
|
97
97
|
return { isInteractive: false };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Escapes strings safely for interpolation into AppleScript string literals.
|
|
102
|
+
* Replaces newlines/CRs with spaces, escapes backslashes first, then double quotes.
|
|
103
|
+
*/
|
|
104
|
+
export function escapeAppleScriptString(str: string): string {
|
|
105
|
+
return str
|
|
106
|
+
.replace(/[\r\n]+/g, " ")
|
|
107
|
+
.replace(/\\/g, "\\\\")
|
|
108
|
+
.replace(/"/g, '\\"');
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
/**
|
|
101
112
|
* Dispatches a native OS desktop notification (macOS osascript or Linux notify-send)
|
|
102
113
|
* on important guard events (policy blocks, verification completion).
|
|
@@ -104,11 +115,11 @@ export function checkInteractiveTtyCommand(command: string): { isInteractive: bo
|
|
|
104
115
|
*/
|
|
105
116
|
export function sendDesktopNotification(title: string, message: string): void {
|
|
106
117
|
if (process.env.WORKFLOW_GUARD_NOTIFY === "0") return;
|
|
107
|
-
const safeTitle = title.replace(/"/g, '\\"');
|
|
108
|
-
const safeMsg = message.slice(0, 150).replace(/"/g, '\\"');
|
|
109
118
|
|
|
110
119
|
try {
|
|
111
120
|
if (process.platform === "darwin") {
|
|
121
|
+
const safeTitle = escapeAppleScriptString(title);
|
|
122
|
+
const safeMsg = escapeAppleScriptString(message.slice(0, 150));
|
|
112
123
|
const child = spawn(
|
|
113
124
|
"osascript",
|
|
114
125
|
["-e", `display notification "${safeMsg}" with title "${safeTitle}"`],
|
|
@@ -117,6 +128,8 @@ export function sendDesktopNotification(title: string, message: string): void {
|
|
|
117
128
|
child.on("error", () => {});
|
|
118
129
|
child.unref();
|
|
119
130
|
} else if (process.platform === "linux") {
|
|
131
|
+
const safeTitle = title.replace(/[\r\n\0]/g, " ");
|
|
132
|
+
const safeMsg = message.slice(0, 150).replace(/[\0]/g, "");
|
|
120
133
|
const child = spawn("notify-send", [safeTitle, safeMsg], {
|
|
121
134
|
stdio: "ignore",
|
|
122
135
|
detached: true,
|
package/src/workflow-guard.ts
CHANGED
|
@@ -201,8 +201,12 @@ import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMasked
|
|
|
201
201
|
|
|
202
202
|
export { isSecretPath, isEnvFilePath, generateMaskedEnvSchema };
|
|
203
203
|
|
|
204
|
-
import {
|
|
205
|
-
|
|
204
|
+
import {
|
|
205
|
+
extractInterpreterPayload,
|
|
206
|
+
secretPathInPayload,
|
|
207
|
+
outsideWritePathInPayload,
|
|
208
|
+
} from "./policies/interpreter.ts";
|
|
209
|
+
export { extractInterpreterPayload, secretPathInPayload, outsideWritePathInPayload };
|
|
206
210
|
|
|
207
211
|
import { branchHasDocumentationChange } from "./policies/docs.ts";
|
|
208
212
|
export { branchHasDocumentationChange };
|
|
@@ -211,13 +215,14 @@ import {
|
|
|
211
215
|
checkInteractiveTtyCommand,
|
|
212
216
|
checkPackageHygiene,
|
|
213
217
|
sendDesktopNotification,
|
|
218
|
+
escapeAppleScriptString,
|
|
214
219
|
} from "./policies/shell-safety.ts";
|
|
215
220
|
|
|
216
221
|
import { checkCompletionClaims } from "./policies/completion.ts";
|
|
217
222
|
|
|
218
223
|
export { checkCompletionClaims };
|
|
219
224
|
|
|
220
|
-
export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification };
|
|
225
|
+
export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification, escapeAppleScriptString };
|
|
221
226
|
|
|
222
227
|
const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
223
228
|
|
|
@@ -610,8 +615,18 @@ async function guardToolCallImpl(
|
|
|
610
615
|
}
|
|
611
616
|
|
|
612
617
|
// ── Policy 18: interpreter inline evasion scanner ──
|
|
613
|
-
|
|
614
|
-
|
|
618
|
+
for (const payload of extractInterpreterPayload(command)) {
|
|
619
|
+
// Workspace boundary check inside inline interpreter payloads has no
|
|
620
|
+
// allow-live override (same invariant as file redirects).
|
|
621
|
+
const outsidePath = outsideWritePathInPayload(payload, currentRoot);
|
|
622
|
+
if (outsidePath) {
|
|
623
|
+
logBlock(
|
|
624
|
+
`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`,
|
|
625
|
+
);
|
|
626
|
+
return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (!allowLive) {
|
|
615
630
|
const normPayload = normalizeGitCommands(normalize(payload));
|
|
616
631
|
const liveCheck = liveMutationIn(normPayload);
|
|
617
632
|
if (liveCheck) {
|
|
@@ -626,6 +641,16 @@ async function guardToolCallImpl(
|
|
|
626
641
|
);
|
|
627
642
|
return PROTECTED_PATH_REASON;
|
|
628
643
|
}
|
|
644
|
+
const secretPath = secretPathInPayload(payload);
|
|
645
|
+
if (secretPath) {
|
|
646
|
+
logBlock(
|
|
647
|
+
`[workflow-guard] blocked interpreter payload accessing secret file: ${secretPath}`,
|
|
648
|
+
);
|
|
649
|
+
return (
|
|
650
|
+
`Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted. ` +
|
|
651
|
+
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
652
|
+
);
|
|
653
|
+
}
|
|
629
654
|
}
|
|
630
655
|
}
|
|
631
656
|
|
|
@@ -993,14 +1018,20 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
993
1018
|
.describe("Base ref to diff against (default: origin/main, origin/master, main)"),
|
|
994
1019
|
},
|
|
995
1020
|
execute: async (args) => {
|
|
996
|
-
const
|
|
997
|
-
|
|
1021
|
+
const sanitizedBase =
|
|
1022
|
+
typeof args.base === "string" &&
|
|
1023
|
+
!args.base.startsWith("-") &&
|
|
1024
|
+
!/[\s;'"\0]/.test(args.base)
|
|
1025
|
+
? args.base
|
|
1026
|
+
: undefined;
|
|
1027
|
+
const bases = sanitizedBase
|
|
1028
|
+
? [sanitizedBase]
|
|
998
1029
|
: ["origin/main", "origin/master", "main", "master"];
|
|
999
1030
|
let diffText = "";
|
|
1000
1031
|
for (const base of bases) {
|
|
1001
1032
|
const res = spawnSync(
|
|
1002
1033
|
"git",
|
|
1003
|
-
["diff", `${base}...HEAD`],
|
|
1034
|
+
["diff", "--", `${base}...HEAD`],
|
|
1004
1035
|
{ cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 },
|
|
1005
1036
|
);
|
|
1006
1037
|
if (res.status === 0 && res.stdout.trim()) {
|
|
@@ -1009,7 +1040,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1009
1040
|
}
|
|
1010
1041
|
}
|
|
1011
1042
|
if (!diffText) {
|
|
1012
|
-
const last = spawnSync("git", ["diff", "HEAD~1"], {
|
|
1043
|
+
const last = spawnSync("git", ["diff", "--", "HEAD~1"], {
|
|
1013
1044
|
cwd: getWorkspaceRoot(),
|
|
1014
1045
|
encoding: "utf8",
|
|
1015
1046
|
timeout: 10_000,
|