opencode-workflow-guard 1.4.1 → 1.5.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/README.md +24 -1
- package/package.json +1 -1
- package/src/lib/learning.ts +104 -0
- package/src/lib/project-memory.ts +157 -0
- package/src/lib/state.ts +10 -0
- package/src/lib/types.ts +63 -0
- package/src/lib/utils.ts +168 -11
- package/src/policies/boundary.ts +33 -4
- package/src/policies/changelog.ts +114 -15
- package/src/policies/continuation.ts +83 -0
- package/src/policies/destructive.ts +41 -2
- package/src/policies/git.ts +16 -0
- package/src/policies/interpreter.ts +19 -4
- package/src/policies/planning.ts +50 -0
- package/src/policies/secrets.ts +12 -0
- package/src/policies/todo.ts +8 -2
- package/src/workflow-guard.ts +296 -62
package/src/policies/boundary.ts
CHANGED
|
@@ -158,10 +158,11 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
158
158
|
what: `tee to '${teeTargets[0]!}'`,
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
const
|
|
164
|
-
const target =
|
|
161
|
+
const sedWords = shellWords(unwrapShellCommand(segment));
|
|
162
|
+
if (sedWords[0] === "sed" && sedWords.slice(1).some((word) => /^-(?:[a-zA-Z]*i|i\S*)$/.test(word) || /^--in-place(?:=.*)?$/.test(word))) {
|
|
163
|
+
const operands = sedWords.slice(1).filter((word) => !word.startsWith("-"));
|
|
164
|
+
const target = operands.at(-1);
|
|
165
|
+
if (!target) return undefined;
|
|
165
166
|
return { kind: "command", target, what: `sed -i on '${target}'` };
|
|
166
167
|
}
|
|
167
168
|
const transfer = filesystemTransferInfo(segment);
|
|
@@ -220,6 +221,34 @@ export function simpleFilesystemMutations(segment: string): ShellMutation[] {
|
|
|
220
221
|
};
|
|
221
222
|
});
|
|
222
223
|
}
|
|
224
|
+
if (command === "sed") {
|
|
225
|
+
let inPlace = false;
|
|
226
|
+
let scriptSupplied = false;
|
|
227
|
+
const targets: string[] = [];
|
|
228
|
+
for (let i = 1; i < words.length; i++) {
|
|
229
|
+
const word = words[i]!;
|
|
230
|
+
if (/^-(?:[a-zA-Z]*i|i\S*)$/.test(word) || /^--in-place(?:=.*)?$/.test(word)) {
|
|
231
|
+
inPlace = true;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (word === "-e" || word === "--expression" || word === "-f" || word === "--file") {
|
|
235
|
+
scriptSupplied = true;
|
|
236
|
+
i++;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (/^(?:-e|--expression=|-f|--file=)/.test(word) || word.startsWith("-")) {
|
|
240
|
+
if (/^(?:-e|--expression=)/.test(word)) scriptSupplied = true;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!scriptSupplied) {
|
|
244
|
+
scriptSupplied = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
targets.push(word);
|
|
248
|
+
}
|
|
249
|
+
if (!inPlace) return [];
|
|
250
|
+
return targets.map((target) => ({ kind: "command" as const, target, what: `sed -i on '${target}'` }));
|
|
251
|
+
}
|
|
223
252
|
if (!SIMPLE_MUTATION_COMMANDS.has(command)) return [];
|
|
224
253
|
return words
|
|
225
254
|
.slice(1)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
4
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
5
|
-
import { splitShellSegments, unwrapShellCommand } from "../lib/utils.ts";
|
|
5
|
+
import { splitShellSegments, unwrapShellCommand, unwrapShellWords } from "../lib/utils.ts";
|
|
6
6
|
|
|
7
7
|
export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(?::|$)/im;
|
|
8
8
|
|
|
@@ -20,6 +20,84 @@ export function hasPrCreateInvocation(command: string): boolean {
|
|
|
20
20
|
// Accepts either a CHANGELOG file modification or a .changeset/*.md file.
|
|
21
21
|
const CHANGELOG_OR_CHANGESET_RE = /(?:changelog|\.changeset\/[^/]+\.md$)/i;
|
|
22
22
|
|
|
23
|
+
function decodeAnsiCLineBreaks(value: string): string {
|
|
24
|
+
let decoded = "";
|
|
25
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
26
|
+
if (value[i] !== "\\" || i + 1 >= value.length) {
|
|
27
|
+
decoded += value[i];
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const next = value[++i];
|
|
31
|
+
if (next === "n") decoded += "\n";
|
|
32
|
+
else if (next === "r") decoded += "\r";
|
|
33
|
+
else if (next === "\\") decoded += "\\";
|
|
34
|
+
else decoded += `\\${next}`;
|
|
35
|
+
}
|
|
36
|
+
return decoded;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function preserveAnsiCLineBreaks(command: string): string {
|
|
40
|
+
return command.replace(/\$'((?:\\.|[^'])*)'/g, (_match, value: string) => {
|
|
41
|
+
const decoded = decodeAnsiCLineBreaks(value).replace(/'/g, "'\\''");
|
|
42
|
+
return `'${decoded}'`;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function prShellWords(command: string): string[] {
|
|
47
|
+
const words: string[] = [];
|
|
48
|
+
let word = "";
|
|
49
|
+
let quote: "'" | '"' | "ansi" | undefined;
|
|
50
|
+
let started = false;
|
|
51
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
52
|
+
const char = command[i]!;
|
|
53
|
+
if (!quote && char === "$" && command[i + 1] === "'") {
|
|
54
|
+
quote = "ansi";
|
|
55
|
+
started = true;
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (quote === "ansi") {
|
|
60
|
+
if (char === "'") {
|
|
61
|
+
quote = undefined;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (char === "\\" && i + 1 < command.length) {
|
|
65
|
+
let escape = char + command[++i];
|
|
66
|
+
word += decodeAnsiCLineBreaks(escape);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
word += char;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === "'" || char === '"') {
|
|
73
|
+
if (!quote) quote = char;
|
|
74
|
+
else if (quote === char) quote = undefined;
|
|
75
|
+
else word += char;
|
|
76
|
+
started = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (char === "\\" && quote !== "'") {
|
|
80
|
+
const next = command[++i];
|
|
81
|
+
if (next !== "\n") {
|
|
82
|
+
if (quote === '"' && next && !['$', '`', '"', '\\'].includes(next)) word += "\\";
|
|
83
|
+
word += next ?? "\\";
|
|
84
|
+
}
|
|
85
|
+
started = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!quote && /\s/.test(char)) {
|
|
89
|
+
if (started) words.push(word);
|
|
90
|
+
word = "";
|
|
91
|
+
started = false;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
word += char;
|
|
95
|
+
started = true;
|
|
96
|
+
}
|
|
97
|
+
if (started) words.push(word);
|
|
98
|
+
return words;
|
|
99
|
+
}
|
|
100
|
+
|
|
23
101
|
export function branchHasChangelogChange(root: string): boolean {
|
|
24
102
|
try {
|
|
25
103
|
const baseCandidates = ["origin/HEAD", "origin/main", "origin/master"];
|
|
@@ -54,23 +132,44 @@ export function branchHasChangelogChange(root: string): boolean {
|
|
|
54
132
|
}
|
|
55
133
|
}
|
|
56
134
|
|
|
57
|
-
export function prBodyIncludesChangelog(command: string): boolean {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
135
|
+
export function prBodyIncludesChangelog(command: string, invocationRoot: string | null = getWorkspaceRoot()): boolean {
|
|
136
|
+
const parsedWords = prShellWords(command);
|
|
137
|
+
const envIndex = parsedWords.indexOf("env");
|
|
138
|
+
const usesEnvSplitString = envIndex >= 0 && parsedWords.slice(envIndex + 1).some((word) =>
|
|
139
|
+
word === "-S" || word.startsWith("-S") || word === "--split-string" || word.startsWith("--split-string="));
|
|
140
|
+
const commandWords = usesEnvSplitString
|
|
141
|
+
? unwrapShellWords(preserveAnsiCLineBreaks(command))
|
|
142
|
+
: parsedWords;
|
|
143
|
+
const cliIndex = commandWords.findIndex((word) => word === "gh" || word === "az");
|
|
144
|
+
const words = cliIndex >= 0 ? commandWords.slice(cliIndex) : [];
|
|
145
|
+
let body = "";
|
|
146
|
+
let bodyFile: string | undefined;
|
|
147
|
+
const valueOptions = new Set([
|
|
148
|
+
"--assignee", "--base", "--head", "--head-repo", "--label", "--milestone",
|
|
149
|
+
"--project", "--recover", "--reviewer", "--template", "--title",
|
|
150
|
+
"--bypass-policy-reason", "--labels", "--merge-commit-message", "--org",
|
|
151
|
+
"--repository", "--required-reviewer-ids", "--reviewer-ids", "--source-branch",
|
|
152
|
+
"--target-branch", "--work-items", "-a", "-B", "-H", "-l", "-m", "-p", "-r", "-T", "-t",
|
|
153
|
+
]);
|
|
154
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
155
|
+
const word = words[i]!;
|
|
156
|
+
if (["--description", "-d"].includes(word)) {
|
|
157
|
+
const lines: string[] = [];
|
|
158
|
+
while (i + 1 < words.length && !words[i + 1]!.startsWith("-")) lines.push(words[++i]!);
|
|
159
|
+
body = lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
else if (["--body", "-b"].includes(word)) body = words[++i] ?? "";
|
|
162
|
+
else if (/^(?:--body|--description)=/.test(word)) body = word.slice(word.indexOf("=") + 1);
|
|
163
|
+
else if (["--body-file", "--description-file", "-F"].includes(word)) bodyFile = words[++i];
|
|
164
|
+
else if (/^(?:--body-file|--description-file)=/.test(word)) bodyFile = word.slice(word.indexOf("=") + 1);
|
|
165
|
+
else if (valueOptions.has(word)) i += 1;
|
|
64
166
|
}
|
|
65
|
-
|
|
66
|
-
/(?:--body-file|--description-file|-F)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
|
|
67
|
-
);
|
|
68
|
-
const bodyFile =
|
|
69
|
-
bodyFileMatch?.[1] ?? bodyFileMatch?.[2] ?? bodyFileMatch?.[3];
|
|
167
|
+
if (CHANGELOG_SECTION_RE.test(body)) return true;
|
|
70
168
|
if (bodyFile) {
|
|
71
169
|
try {
|
|
170
|
+
if (!isAbsolute(bodyFile) && invocationRoot === null) return false;
|
|
72
171
|
return CHANGELOG_SECTION_RE.test(
|
|
73
|
-
readFileSync(
|
|
172
|
+
readFileSync(isAbsolute(bodyFile) ? bodyFile : resolve(invocationRoot!, bodyFile), "utf8"),
|
|
74
173
|
);
|
|
75
174
|
} catch {
|
|
76
175
|
return false;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getSdkClient } from "../lib/state.ts";
|
|
3
|
+
import { effectiveTodosWithOwner, hasActiveTodo } from "./todo.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_CONSECUTIVE_CONTINUATIONS = 3;
|
|
6
|
+
type ContinuationState = {
|
|
7
|
+
counts: Map<string, number>;
|
|
8
|
+
generatedMessageIDs: Map<string, Set<string>>;
|
|
9
|
+
inFlight: Set<string>;
|
|
10
|
+
};
|
|
11
|
+
const states = new WeakMap<object, ContinuationState>();
|
|
12
|
+
|
|
13
|
+
function getState(): ContinuationState | undefined {
|
|
14
|
+
const client = getSdkClient();
|
|
15
|
+
if (!client || (typeof client !== "object" && typeof client !== "function")) return undefined;
|
|
16
|
+
let state = states.get(client as object);
|
|
17
|
+
if (!state) {
|
|
18
|
+
state = { counts: new Map(), generatedMessageIDs: new Map(), inFlight: new Set() };
|
|
19
|
+
states.set(client as object, state);
|
|
20
|
+
}
|
|
21
|
+
return state;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function continueUnfinishedSession(sessionID: string): Promise<boolean> {
|
|
25
|
+
const state = getState();
|
|
26
|
+
if (!state || state.inFlight.has(sessionID)) return false;
|
|
27
|
+
state.inFlight.add(sessionID);
|
|
28
|
+
try {
|
|
29
|
+
const effective = await effectiveTodosWithOwner(sessionID);
|
|
30
|
+
if (!effective || !hasActiveTodo(effective.todos) || effective.ownerSessionID !== sessionID) return false;
|
|
31
|
+
|
|
32
|
+
const count = state.counts.get(sessionID) ?? 0;
|
|
33
|
+
if (count >= MAX_CONSECUTIVE_CONTINUATIONS) return false;
|
|
34
|
+
|
|
35
|
+
const session = getSdkClient()?.session;
|
|
36
|
+
if (typeof session?.promptAsync !== "function") return false;
|
|
37
|
+
|
|
38
|
+
const messageID = `workflow-guard-${randomUUID()}`;
|
|
39
|
+
state.counts.set(sessionID, count + 1);
|
|
40
|
+
const generatedIDs = state.generatedMessageIDs.get(sessionID) ?? new Set<string>();
|
|
41
|
+
generatedIDs.add(messageID);
|
|
42
|
+
state.generatedMessageIDs.set(sessionID, generatedIDs);
|
|
43
|
+
try {
|
|
44
|
+
await session.promptAsync({
|
|
45
|
+
path: { id: sessionID },
|
|
46
|
+
body: {
|
|
47
|
+
messageID,
|
|
48
|
+
parts: [{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: "Workflow Guard: unfinished todos remain. Continue working through them. If you need user input, use the question tool instead of ending the run.",
|
|
51
|
+
}],
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
state.counts.set(sessionID, count);
|
|
57
|
+
generatedIDs.delete(messageID);
|
|
58
|
+
if (generatedIDs.size === 0) state.generatedMessageIDs.delete(sessionID);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
state.inFlight.delete(sessionID);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function recordUserMessage(sessionID: string, messageID?: string): void {
|
|
67
|
+
const state = getState();
|
|
68
|
+
if (!state) return;
|
|
69
|
+
const generatedIDs = state.generatedMessageIDs.get(sessionID);
|
|
70
|
+
if (messageID && generatedIDs?.delete(messageID)) {
|
|
71
|
+
if (generatedIDs.size === 0) state.generatedMessageIDs.delete(sessionID);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
state.counts.delete(sessionID);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function clearContinuationState(sessionID: string): void {
|
|
78
|
+
const state = getState();
|
|
79
|
+
if (!state) return;
|
|
80
|
+
state.counts.delete(sessionID);
|
|
81
|
+
state.generatedMessageIDs.delete(sessionID);
|
|
82
|
+
state.inFlight.delete(sessionID);
|
|
83
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { asRecord } from "../lib/utils.ts";
|
|
1
|
+
import { asRecord, shellWords, splitShellSegments } from "../lib/utils.ts";
|
|
2
2
|
|
|
3
3
|
export interface LivePattern {
|
|
4
4
|
re: RegExp;
|
|
@@ -62,9 +62,48 @@ export const LIVE_MUTATION_PATTERNS: LivePattern[] = [
|
|
|
62
62
|
{ re: /\bmknod\s+\S+\s+p\b/, what: ["named FIFO pipe ", "creation (reverse shell backpipe)"].join("") },
|
|
63
63
|
];
|
|
64
64
|
|
|
65
|
+
const KUBECTL_VALUE_GLOBALS = new Set([
|
|
66
|
+
"--as", "--as-group", "--as-uid", "--as-user-extra", "--cache-dir", "--certificate-authority", "--client-certificate", "--client-key",
|
|
67
|
+
"--cluster", "--context", "--kubeconfig", "--kuberc", "--namespace", "--password", "--profile", "--profile-output", "--request-timeout",
|
|
68
|
+
"--server", "--storage-driver-buffer-duration", "--storage-driver-db", "--storage-driver-host", "--storage-driver-password", "--storage-driver-table",
|
|
69
|
+
"--storage-driver-user", "--tls-server-name", "--token", "--user", "--username", "-n", "-s",
|
|
70
|
+
]);
|
|
71
|
+
const KUBECTL_BOOLEAN_GLOBALS = new Set(["--disable-compression", "--insecure-skip-tls-verify", "--match-server-version", "--storage-driver-secure", "--version", "--warnings-as-errors"]);
|
|
72
|
+
|
|
73
|
+
function normalizedKubectlCommands(command: string): string {
|
|
74
|
+
const normalized: string[] = [];
|
|
75
|
+
for (const segment of splitShellSegments(command)) {
|
|
76
|
+
const words = shellWords(segment);
|
|
77
|
+
for (let i = 0; i < words.length; i++) {
|
|
78
|
+
if (words[i] !== "kubectl") continue;
|
|
79
|
+
let j = i + 1;
|
|
80
|
+
while (j < words.length) {
|
|
81
|
+
const option = words[j];
|
|
82
|
+
const name = option.split("=", 1)[0];
|
|
83
|
+
if (/^-[ns].+/.test(option) && !option.startsWith("--")) {
|
|
84
|
+
j++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (KUBECTL_VALUE_GLOBALS.has(name)) {
|
|
88
|
+
j += option.includes("=") ? 1 : 2;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (KUBECTL_BOOLEAN_GLOBALS.has(name)) {
|
|
92
|
+
j++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (j < words.length) normalized.push(`kubectl ${words.slice(j).join(" ")}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return normalized.join(" ; ");
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
export function liveMutationIn(command: string): string | undefined {
|
|
104
|
+
const optionNormalized = `${normalizedKubectlCommands(command)} ; ${command.replace(/\b(terraform|tofu)\s+(?:(?:-chdir(?:=\S+|\s+\S+)|-(?:no-color|version))\s+)*/g, "$1 ")}`;
|
|
66
105
|
for (const { re, what } of LIVE_MUTATION_PATTERNS) {
|
|
67
|
-
if (re.test(command)) {
|
|
106
|
+
if (re.test(command) || re.test(optionNormalized)) {
|
|
68
107
|
return what;
|
|
69
108
|
}
|
|
70
109
|
}
|
package/src/policies/git.ts
CHANGED
|
@@ -16,6 +16,22 @@ export const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
|
|
16
16
|
export const PUSH_TO_MAIN_RE =
|
|
17
17
|
/\bgit\s+push\b[^|;&]*(?:^|\s)\+?[\w./-]*:(?:main|master)(?![\w./-])|\bgit\s+push\b[^|;&]*(?:\s|\/)(?:main|master)(?![\w./-])/;
|
|
18
18
|
|
|
19
|
+
export function hasUnsafeGitAlias(command: string): boolean {
|
|
20
|
+
return splitShellSegments(command).some((segment) => {
|
|
21
|
+
const words = unwrapShellWords(segment);
|
|
22
|
+
const gitIndex = words.findIndex((word, index) =>
|
|
23
|
+
word === "git" && words.slice(0, index).every((prefix) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(prefix)),
|
|
24
|
+
);
|
|
25
|
+
if (gitIndex < 0) return false;
|
|
26
|
+
const args = words.slice(gitIndex + 1);
|
|
27
|
+
return args.some((arg, index) =>
|
|
28
|
+
/^-calias\./i.test(arg) ||
|
|
29
|
+
/^--config-env=alias\./i.test(arg) ||
|
|
30
|
+
((arg === "-c" || arg === "--config-env") && /^alias\./i.test(args[index + 1] ?? "")),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
export const GIT_BRANCH_CREATE_RE =
|
|
20
36
|
/\bgit\s+(?:checkout\s+-b|switch\s+(?:-c|--create))\b/;
|
|
21
37
|
|
|
@@ -4,7 +4,7 @@ import { isPathOutsideWorkspace } from "./boundary.ts";
|
|
|
4
4
|
export function extractInterpreterPayload(segment: string): string[] {
|
|
5
5
|
const payloads: string[] = [];
|
|
6
6
|
const inlineMatch = segment.match(
|
|
7
|
-
/\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce]\s
|
|
7
|
+
/\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce])\s*(?:"([^"]*)"|'([^']*)')/i,
|
|
8
8
|
);
|
|
9
9
|
if (inlineMatch?.[1] || inlineMatch?.[2]) {
|
|
10
10
|
payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
|
|
@@ -49,9 +49,14 @@ export function secretPathInPayload(payload: string): string | undefined {
|
|
|
49
49
|
return undefined;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const
|
|
52
|
+
const WRITE_TARGET_PATTERNS: RegExp[] = [
|
|
53
53
|
/\b(?:writeFileSync|writeFile|appendFile|appendFileSync|createWriteStream|unlink|unlinkSync|rmSync|rmdirSync|mkdirSync|renameSync|copyFileSync|cpSync|truncateSync)\s*\(\s*["']([^"']+)["']/g,
|
|
54
|
-
/\b(?:
|
|
54
|
+
/\b(?:renameSync|copyFileSync|cpSync)\s*\(\s*["'][^"']+["']\s*,\s*["']([^"']+)["']/g,
|
|
55
|
+
/\b(?:os\.remove|os\.unlink|os\.rmdir|os\.mkdir|os\.makedirs|os\.rename|shutil\.rmtree|shutil\.copy|shutil\.copy2|shutil\.move)\s*\(\s*["']([^"']+)["']/g,
|
|
56
|
+
/\b(?:os\.rename|shutil\.copy|shutil\.copy2|shutil\.move)\s*\(\s*["'][^"']+["']\s*,\s*["']([^"']+)["']/g,
|
|
57
|
+
/\bopen\s*\(\s*["']([^"']+)["']\s*,\s*["'][^"']*[wax+][^"']*["']/g,
|
|
58
|
+
/\bPath\s*\(\s*["']([^"']+)["']\s*\)\s*\.\s*(?:write_text|write_bytes|unlink|mkdir|touch|rename|replace)\s*\(/g,
|
|
59
|
+
/\bPath\s*\(\s*["'][^"']+["']\s*\)\s*\.\s*(?:rename|replace)\s*\(\s*["']([^"']+)["']/g,
|
|
55
60
|
/[>]{1,2}\s*["']?([^\s;&"'|)]+)/g,
|
|
56
61
|
];
|
|
57
62
|
|
|
@@ -63,7 +68,7 @@ const FUNCTION_TARGET_PATTERNS: RegExp[] = [
|
|
|
63
68
|
export function outsideWritePathInPayload(payload: string, root: string): string | undefined {
|
|
64
69
|
const candidates = new Set<string>();
|
|
65
70
|
|
|
66
|
-
for (const pattern of
|
|
71
|
+
for (const pattern of WRITE_TARGET_PATTERNS) {
|
|
67
72
|
const matches = payload.matchAll(pattern);
|
|
68
73
|
for (const m of matches) {
|
|
69
74
|
if (m[1]) candidates.add(m[1]);
|
|
@@ -78,3 +83,13 @@ export function outsideWritePathInPayload(payload: string, root: string): string
|
|
|
78
83
|
}
|
|
79
84
|
return undefined;
|
|
80
85
|
}
|
|
86
|
+
|
|
87
|
+
export function writePathsInPayload(payload: string): string[] {
|
|
88
|
+
const candidates = new Set<string>();
|
|
89
|
+
for (const pattern of WRITE_TARGET_PATTERNS) {
|
|
90
|
+
for (const match of payload.matchAll(pattern)) {
|
|
91
|
+
if (match[1]) candidates.add(match[1]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...candidates];
|
|
95
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
const PRIMARY_TODO_NAMES = ["TODO.md", "todo.md"];
|
|
5
|
+
const FALLBACK_NAMES = [
|
|
6
|
+
"ROADMAP.md", "roadmap.md",
|
|
7
|
+
"PLAN.md", "plan.md",
|
|
8
|
+
"TASKS.md", "tasks.md",
|
|
9
|
+
"BACKLOG.md", "backlog.md",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export interface PlanningSource {
|
|
13
|
+
path: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function discoverPlanningSources(root: string): PlanningSource[] {
|
|
18
|
+
let realRoot: string;
|
|
19
|
+
try {
|
|
20
|
+
realRoot = realpathSync(root);
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const isConfined = (path: string): boolean => {
|
|
25
|
+
const rel = relative(realRoot, realpathSync(path));
|
|
26
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
27
|
+
};
|
|
28
|
+
const readSources = (paths: string[]): PlanningSource[] => paths.flatMap((path) => {
|
|
29
|
+
try {
|
|
30
|
+
// Planning discovery is read-only and must not become a symlink escape hatch.
|
|
31
|
+
if (!lstatSync(path).isFile() || !isConfined(path)) return [];
|
|
32
|
+
return [{ path: relative(root, path), content: readFileSync(path, "utf8").slice(0, 20_000) }];
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const primary = readSources(PRIMARY_TODO_NAMES.map((name) => join(root, name)));
|
|
39
|
+
if (primary.length > 0) return primary;
|
|
40
|
+
|
|
41
|
+
const candidates = FALLBACK_NAMES.flatMap((name) => [join(root, name), join(root, "docs", name)]);
|
|
42
|
+
const plansDir = join(root, "docs", "plans");
|
|
43
|
+
try {
|
|
44
|
+
if (!lstatSync(plansDir).isDirectory() || !isConfined(plansDir)) return readSources(candidates);
|
|
45
|
+
for (const entry of readdirSync(plansDir, { withFileTypes: true })) {
|
|
46
|
+
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) candidates.push(join(plansDir, entry.name));
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
return readSources(candidates);
|
|
50
|
+
}
|
package/src/policies/secrets.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
3
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
4
|
+
import { shellWords, unwrapShellCommand } from "../lib/utils.ts";
|
|
4
5
|
|
|
5
6
|
export const SAFE_ENV_FIXTURE_RE =
|
|
6
7
|
/\.env\.(example|sample|template|dist|schema)(\.[\w-]+)*$/i;
|
|
@@ -154,5 +155,16 @@ export function secretFileReadIn(segment: string): string | undefined {
|
|
|
154
155
|
}
|
|
155
156
|
const simpleMatch = segment.match(SIMPLE_FILE_READ_COMMAND_RE);
|
|
156
157
|
if (simpleMatch?.[1] && isSecretPath(simpleMatch[1])) return simpleMatch[1];
|
|
158
|
+
const words = shellWords(unwrapShellCommand(segment));
|
|
159
|
+
if (["cat", "head", "tail", "less", "more", "od", "hexdump", "strings", "base64", "xxd", "nl", "view", "nano", "vim"].includes(words[0] ?? "")) {
|
|
160
|
+
for (const operand of words.slice(1).filter((word) => !word.startsWith("-"))) {
|
|
161
|
+
if (isSecretPath(operand)) return operand;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (words[0] === "dd") {
|
|
165
|
+
for (const word of words.slice(1)) {
|
|
166
|
+
if (word.startsWith("if=") && isSecretPath(word.slice(3))) return word.slice(3);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
157
169
|
return undefined;
|
|
158
170
|
}
|
package/src/policies/todo.ts
CHANGED
|
@@ -35,6 +35,12 @@ export async function fetchParentSessionID(sessionID: string): Promise<string |
|
|
|
35
35
|
export async function effectiveTodos(
|
|
36
36
|
sessionID: string | undefined,
|
|
37
37
|
): Promise<TodoItem[] | undefined> {
|
|
38
|
+
return (await effectiveTodosWithOwner(sessionID))?.todos;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function effectiveTodosWithOwner(
|
|
42
|
+
sessionID: string | undefined,
|
|
43
|
+
): Promise<{ todos: TodoItem[]; ownerSessionID?: string } | undefined> {
|
|
38
44
|
if (!sessionID) return undefined;
|
|
39
45
|
const seen = new Set<string>();
|
|
40
46
|
let current: string | undefined = sessionID;
|
|
@@ -42,10 +48,10 @@ export async function effectiveTodos(
|
|
|
42
48
|
seen.add(current);
|
|
43
49
|
const todos = await fetchSessionTodos(current);
|
|
44
50
|
if (todos === undefined) return undefined;
|
|
45
|
-
if (todos.length > 0) return todos;
|
|
51
|
+
if (todos.length > 0) return { todos, ownerSessionID: current };
|
|
46
52
|
current = await fetchParentSessionID(current);
|
|
47
53
|
}
|
|
48
|
-
return [];
|
|
54
|
+
return { todos: [] };
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export async function effectiveTodoOwnerSessionID(
|