opencode-workflow-guard 1.3.1 → 1.3.3
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/lib/state.ts +68 -1
- 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/policies/tamper.ts +1 -1
- package/src/workflow-guard.ts +42 -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/lib/state.ts
CHANGED
|
@@ -171,6 +171,72 @@ export function resetReviewState(): void {
|
|
|
171
171
|
|
|
172
172
|
export let cachedProjectConfig: ProjectConfig | undefined;
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Strips single-line comments, block comments, and trailing commas from JSONC
|
|
176
|
+
* text without corrupting comment markers inside string literals or URLs.
|
|
177
|
+
*/
|
|
178
|
+
export function stripJsonComments(jsonc: string): string {
|
|
179
|
+
let insideString = false;
|
|
180
|
+
let stringQuote = "";
|
|
181
|
+
let escaped = false;
|
|
182
|
+
let output = "";
|
|
183
|
+
let i = 0;
|
|
184
|
+
|
|
185
|
+
while (i < jsonc.length) {
|
|
186
|
+
const char = jsonc[i]!;
|
|
187
|
+
const next = jsonc[i + 1];
|
|
188
|
+
|
|
189
|
+
if (escaped) {
|
|
190
|
+
output += char;
|
|
191
|
+
escaped = false;
|
|
192
|
+
i++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (char === "\\") {
|
|
197
|
+
output += char;
|
|
198
|
+
escaped = true;
|
|
199
|
+
i++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (char === '"' || char === "'") {
|
|
204
|
+
if (!insideString) {
|
|
205
|
+
insideString = true;
|
|
206
|
+
stringQuote = char;
|
|
207
|
+
} else if (stringQuote === char) {
|
|
208
|
+
insideString = false;
|
|
209
|
+
}
|
|
210
|
+
output += char;
|
|
211
|
+
i++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!insideString) {
|
|
216
|
+
if (char === "/" && next === "/") {
|
|
217
|
+
i += 2;
|
|
218
|
+
while (i < jsonc.length && jsonc[i] !== "\n" && jsonc[i] !== "\r") {
|
|
219
|
+
i++;
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (char === "/" && next === "*") {
|
|
224
|
+
i += 2;
|
|
225
|
+
while (i < jsonc.length && !(jsonc[i] === "*" && jsonc[i + 1] === "/")) {
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
i += 2;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
output += char;
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return output.replace(/,(\s*[}\]])/g, "$1");
|
|
238
|
+
}
|
|
239
|
+
|
|
174
240
|
export function loadProjectConfig(root: string): ProjectConfig {
|
|
175
241
|
const candidates = [
|
|
176
242
|
join(root, ".opencode", "workflow-guard.json"),
|
|
@@ -181,7 +247,8 @@ export function loadProjectConfig(root: string): ProjectConfig {
|
|
|
181
247
|
for (const candidate of candidates) {
|
|
182
248
|
try {
|
|
183
249
|
const raw = readFileSync(candidate, "utf8");
|
|
184
|
-
|
|
250
|
+
const sanitized = stripJsonComments(raw);
|
|
251
|
+
return JSON.parse(sanitized);
|
|
185
252
|
} catch {}
|
|
186
253
|
}
|
|
187
254
|
return {};
|
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 FUNCTION_TARGET_PATTERNS: RegExp[] = [
|
|
53
|
+
/\b(?:writeFileSync|writeFile|appendFile|appendFileSync|createWriteStream|unlink|unlinkSync|rmSync|rmdirSync|mkdirSync|renameSync|copyFileSync|cpSync|truncateSync)\s*\(\s*["']([^"']+)["']/g,
|
|
54
|
+
/\b(?:open|os\.remove|os\.unlink|os\.rmdir|os\.mkdir|os\.makedirs|os\.rename|shutil\.rmtree|shutil\.copy|shutil\.copy2|shutil\.move|Path)\s*\(\s*["']([^"']+)["']/g,
|
|
55
|
+
/[>]{1,2}\s*["']?([^\s;&"'|)]+)/g,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Detects workspace boundary escapes (writes/redirects to paths outside the
|
|
60
|
+
* workspace root) initiated from inline interpreter payloads. Extracts destination
|
|
61
|
+
* file arguments from file-writing APIs and shell redirects.
|
|
62
|
+
*/
|
|
63
|
+
export function outsideWritePathInPayload(payload: string, root: string): string | undefined {
|
|
64
|
+
const candidates = new Set<string>();
|
|
65
|
+
|
|
66
|
+
for (const pattern of FUNCTION_TARGET_PATTERNS) {
|
|
67
|
+
const matches = payload.matchAll(pattern);
|
|
68
|
+
for (const m of matches) {
|
|
69
|
+
if (m[1]) candidates.add(m[1]);
|
|
70
|
+
}
|
|
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/policies/tamper.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
|
|
|
18
18
|
new RegExp(`${GT}\\s*["']?[\\w\\/.~-]*(?:${C_LIST})`, "i"),
|
|
19
19
|
new RegExp(`(?:^|\\s)(?:${V_LIST})\\s+[^|;&]*?[\\w\\/.~-]*(?:${U_LIST})`, "i"),
|
|
20
20
|
new RegExp(`${GT}\\s*["']?[\\w\\/.~-]*(?:${U_LIST})`, "i"),
|
|
21
|
-
new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:auth|config|permission)\\b", "i"),
|
|
21
|
+
new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:-[^|;&]*\\s+)*(?:auth|config|permission)\\b", "i"),
|
|
22
22
|
new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:run\\s+)?--auto\\b", "i"),
|
|
23
23
|
];
|
|
24
24
|
|
package/src/workflow-guard.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
sessionVerifyResults,
|
|
45
45
|
loadProjectConfig,
|
|
46
46
|
reloadProjectConfig,
|
|
47
|
+
stripJsonComments,
|
|
47
48
|
isReviewRequired,
|
|
48
49
|
isDocumentationRequired,
|
|
49
50
|
recordReviewResult,
|
|
@@ -64,6 +65,7 @@ export {
|
|
|
64
65
|
resetVerifyState,
|
|
65
66
|
loadProjectConfig,
|
|
66
67
|
reloadProjectConfig,
|
|
68
|
+
stripJsonComments,
|
|
67
69
|
isReviewRequired,
|
|
68
70
|
isDocumentationRequired,
|
|
69
71
|
recordReviewResult,
|
|
@@ -201,8 +203,12 @@ import { isSecretPath, secretIn, secretFileReadIn, isEnvFilePath, generateMasked
|
|
|
201
203
|
|
|
202
204
|
export { isSecretPath, isEnvFilePath, generateMaskedEnvSchema };
|
|
203
205
|
|
|
204
|
-
import {
|
|
205
|
-
|
|
206
|
+
import {
|
|
207
|
+
extractInterpreterPayload,
|
|
208
|
+
secretPathInPayload,
|
|
209
|
+
outsideWritePathInPayload,
|
|
210
|
+
} from "./policies/interpreter.ts";
|
|
211
|
+
export { extractInterpreterPayload, secretPathInPayload, outsideWritePathInPayload };
|
|
206
212
|
|
|
207
213
|
import { branchHasDocumentationChange } from "./policies/docs.ts";
|
|
208
214
|
export { branchHasDocumentationChange };
|
|
@@ -211,13 +217,14 @@ import {
|
|
|
211
217
|
checkInteractiveTtyCommand,
|
|
212
218
|
checkPackageHygiene,
|
|
213
219
|
sendDesktopNotification,
|
|
220
|
+
escapeAppleScriptString,
|
|
214
221
|
} from "./policies/shell-safety.ts";
|
|
215
222
|
|
|
216
223
|
import { checkCompletionClaims } from "./policies/completion.ts";
|
|
217
224
|
|
|
218
225
|
export { checkCompletionClaims };
|
|
219
226
|
|
|
220
|
-
export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification };
|
|
227
|
+
export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification, escapeAppleScriptString };
|
|
221
228
|
|
|
222
229
|
const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
|
|
223
230
|
|
|
@@ -610,8 +617,18 @@ async function guardToolCallImpl(
|
|
|
610
617
|
}
|
|
611
618
|
|
|
612
619
|
// ── Policy 18: interpreter inline evasion scanner ──
|
|
613
|
-
|
|
614
|
-
|
|
620
|
+
for (const payload of extractInterpreterPayload(command)) {
|
|
621
|
+
// Workspace boundary check inside inline interpreter payloads has no
|
|
622
|
+
// allow-live override (same invariant as file redirects).
|
|
623
|
+
const outsidePath = outsideWritePathInPayload(payload, currentRoot);
|
|
624
|
+
if (outsidePath) {
|
|
625
|
+
logBlock(
|
|
626
|
+
`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`,
|
|
627
|
+
);
|
|
628
|
+
return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (!allowLive) {
|
|
615
632
|
const normPayload = normalizeGitCommands(normalize(payload));
|
|
616
633
|
const liveCheck = liveMutationIn(normPayload);
|
|
617
634
|
if (liveCheck) {
|
|
@@ -626,6 +643,16 @@ async function guardToolCallImpl(
|
|
|
626
643
|
);
|
|
627
644
|
return PROTECTED_PATH_REASON;
|
|
628
645
|
}
|
|
646
|
+
const secretPath = secretPathInPayload(payload);
|
|
647
|
+
if (secretPath) {
|
|
648
|
+
logBlock(
|
|
649
|
+
`[workflow-guard] blocked interpreter payload accessing secret file: ${secretPath}`,
|
|
650
|
+
);
|
|
651
|
+
return (
|
|
652
|
+
`Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted. ` +
|
|
653
|
+
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
654
|
+
);
|
|
655
|
+
}
|
|
629
656
|
}
|
|
630
657
|
}
|
|
631
658
|
|
|
@@ -993,14 +1020,20 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
993
1020
|
.describe("Base ref to diff against (default: origin/main, origin/master, main)"),
|
|
994
1021
|
},
|
|
995
1022
|
execute: async (args) => {
|
|
996
|
-
const
|
|
997
|
-
|
|
1023
|
+
const sanitizedBase =
|
|
1024
|
+
typeof args.base === "string" &&
|
|
1025
|
+
!args.base.startsWith("-") &&
|
|
1026
|
+
!/[\s;'"\0]/.test(args.base)
|
|
1027
|
+
? args.base
|
|
1028
|
+
: undefined;
|
|
1029
|
+
const bases = sanitizedBase
|
|
1030
|
+
? [sanitizedBase]
|
|
998
1031
|
: ["origin/main", "origin/master", "main", "master"];
|
|
999
1032
|
let diffText = "";
|
|
1000
1033
|
for (const base of bases) {
|
|
1001
1034
|
const res = spawnSync(
|
|
1002
1035
|
"git",
|
|
1003
|
-
["diff", `${base}...HEAD`],
|
|
1036
|
+
["diff", "--", `${base}...HEAD`],
|
|
1004
1037
|
{ cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 },
|
|
1005
1038
|
);
|
|
1006
1039
|
if (res.status === 0 && res.stdout.trim()) {
|
|
@@ -1009,7 +1042,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1009
1042
|
}
|
|
1010
1043
|
}
|
|
1011
1044
|
if (!diffText) {
|
|
1012
|
-
const last = spawnSync("git", ["diff", "HEAD~1"], {
|
|
1045
|
+
const last = spawnSync("git", ["diff", "--", "HEAD~1"], {
|
|
1013
1046
|
cwd: getWorkspaceRoot(),
|
|
1014
1047
|
encoding: "utf8",
|
|
1015
1048
|
timeout: 10_000,
|