opencode-workflow-guard 1.4.0 → 1.5.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/README.md +26 -2
- package/package.json +3 -2
- package/src/lib/learning.ts +104 -0
- package/src/lib/project-memory.ts +157 -0
- package/src/lib/state.ts +16 -2
- package/src/lib/types.ts +62 -0
- package/src/lib/utils.ts +36 -3
- package/src/lib/verify.ts +38 -0
- package/src/policies/boundary.ts +71 -21
- package/src/policies/destructive.ts +41 -2
- package/src/policies/git.ts +16 -0
- package/src/policies/interpreter.ts +19 -4
- package/src/policies/secrets.ts +12 -0
- package/src/policies/shell-safety.ts +25 -6
- package/src/workflow-guard.ts +236 -58
package/src/policies/boundary.ts
CHANGED
|
@@ -127,23 +127,29 @@ export function teeTargetsIn(segment: string): string[] {
|
|
|
127
127
|
return targets;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
)
|
|
134
|
-
|
|
130
|
+
function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
131
|
+
const mutations: ShellMutation[] = [];
|
|
132
|
+
const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?)\s*["']?([^\s>&|;"']+)/g;
|
|
133
|
+
for (const redirectMatch of segment.matchAll(redirectRe)) {
|
|
134
|
+
if (!redirectMatch[1] || !redirectMatch[2]) continue;
|
|
135
135
|
const op = redirectMatch[1];
|
|
136
136
|
const target = redirectMatch[2];
|
|
137
137
|
// Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
|
|
138
138
|
const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
|
|
139
139
|
if (!isFdDup && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
|
|
140
|
-
|
|
140
|
+
mutations.push({
|
|
141
141
|
kind: "redirect",
|
|
142
142
|
target,
|
|
143
143
|
what: `file redirect to '${target}'`,
|
|
144
|
-
};
|
|
144
|
+
});
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
return mutations;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
151
|
+
const redirectMutation = redirectMutationsIn(segment)[0];
|
|
152
|
+
if (redirectMutation) return redirectMutation;
|
|
147
153
|
const teeTargets = teeTargetsIn(segment);
|
|
148
154
|
if (teeTargets.length > 0) {
|
|
149
155
|
return {
|
|
@@ -152,10 +158,11 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
152
158
|
what: `tee to '${teeTargets[0]!}'`,
|
|
153
159
|
};
|
|
154
160
|
}
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
const
|
|
158
|
-
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;
|
|
159
166
|
return { kind: "command", target, what: `sed -i on '${target}'` };
|
|
160
167
|
}
|
|
161
168
|
const transfer = filesystemTransferInfo(segment);
|
|
@@ -192,12 +199,57 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
192
199
|
return undefined;
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate"]);
|
|
203
|
+
const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln"]);
|
|
204
|
+
|
|
195
205
|
export function simpleFilesystemMutations(segment: string): ShellMutation[] {
|
|
196
206
|
const words = shellWords(unwrapShellCommand(segment));
|
|
197
207
|
const command = words[0];
|
|
198
|
-
if (!command
|
|
208
|
+
if (!command) {
|
|
199
209
|
return [];
|
|
200
210
|
}
|
|
211
|
+
if (command === "dd") {
|
|
212
|
+
return words
|
|
213
|
+
.slice(1)
|
|
214
|
+
.filter((word) => word.startsWith("of=") && word.length > 3)
|
|
215
|
+
.map((word) => {
|
|
216
|
+
const target = word.slice(3);
|
|
217
|
+
return {
|
|
218
|
+
kind: "command" as const,
|
|
219
|
+
target,
|
|
220
|
+
what: `dd output to '${target}'`,
|
|
221
|
+
};
|
|
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
|
+
}
|
|
252
|
+
if (!SIMPLE_MUTATION_COMMANDS.has(command)) return [];
|
|
201
253
|
return words
|
|
202
254
|
.slice(1)
|
|
203
255
|
.filter((word) => !word.startsWith("-"))
|
|
@@ -212,7 +264,7 @@ export function filesystemTransferInfo(
|
|
|
212
264
|
segment: string,
|
|
213
265
|
): { sources: string[]; destination?: string } | undefined {
|
|
214
266
|
const words = shellWords(unwrapShellCommand(segment));
|
|
215
|
-
if (!words[0] || !
|
|
267
|
+
if (!words[0] || !TRANSFER_COMMANDS.has(words[0])) return undefined;
|
|
216
268
|
const operands: string[] = [];
|
|
217
269
|
let targetDirectory: string | undefined;
|
|
218
270
|
for (let i = 1; i < words.length; i++) {
|
|
@@ -295,14 +347,12 @@ export async function guardShellMutation(
|
|
|
295
347
|
target,
|
|
296
348
|
what: `tee to '${target}'`,
|
|
297
349
|
}));
|
|
298
|
-
const
|
|
299
|
-
const mutations = simpleMutations
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
? [fallbackMutation]
|
|
305
|
-
: [];
|
|
350
|
+
const redirectMutations = redirectMutationsIn(segment.trim());
|
|
351
|
+
const mutations = [...simpleMutations, ...teeMutations, ...redirectMutations];
|
|
352
|
+
if (mutations.length === 0) {
|
|
353
|
+
const fallbackMutation = shellMutationIn(segment.trim());
|
|
354
|
+
if (fallbackMutation) mutations.push(fallbackMutation);
|
|
355
|
+
}
|
|
306
356
|
for (const mutation of mutations) {
|
|
307
357
|
hasMutation = true;
|
|
308
358
|
const secret = secretIn(segment);
|
|
@@ -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
|
+
}
|
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
|
}
|
|
@@ -38,7 +38,9 @@ export function checkPackageHygiene(command: string): { isViolating: boolean; na
|
|
|
38
38
|
|
|
39
39
|
// AI agents running in non-interactive terminal subshells hang indefinitely when an
|
|
40
40
|
// interactive prompt (vim, nano, less, top, sudo, npm init without -y, apt without -y,
|
|
41
|
-
// git rebase -i) waits for TTY input.
|
|
41
|
+
// git rebase -i) waits for TTY input. Process monitors are handled separately in
|
|
42
|
+
// checkProcessMonitorCommand because `top` can run in a safe batch mode, while the
|
|
43
|
+
// other monitors cannot.
|
|
42
44
|
|
|
43
45
|
const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice: string }> = [
|
|
44
46
|
{
|
|
@@ -51,11 +53,6 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
51
53
|
name: "terminal pager",
|
|
52
54
|
advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
|
|
53
55
|
},
|
|
54
|
-
{
|
|
55
|
-
regex: /\b(?:top|htop|btop|atop|glances)\b/i,
|
|
56
|
-
name: "interactive process monitor",
|
|
57
|
-
advice: "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.",
|
|
58
|
-
},
|
|
59
56
|
{
|
|
60
57
|
regex: /\bsudo\b/i,
|
|
61
58
|
name: "sudo with password prompt",
|
|
@@ -88,7 +85,29 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
|
|
|
88
85
|
},
|
|
89
86
|
];
|
|
90
87
|
|
|
88
|
+
const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
|
|
89
|
+
// Process monitoring tokens are matched on whitespace/segment boundaries so that
|
|
90
|
+
// an unrelated word such as `desktop`, a hyphenated filename like `top-level-dir`,
|
|
91
|
+
// or a quoted argument like `echo "top"` is not mistaken for the `top` command.
|
|
92
|
+
const ALWAYS_INTERACTIVE_MONITOR_RE = /(?:^|[;&|\s])(?:htop|btop|atop|glances)(?:$|[;&|\s])/i;
|
|
93
|
+
const TOP_COMMAND_RE = /(?:^|[;&|\s])top(?:$|[;&|\s])/i;
|
|
94
|
+
const TOP_BATCH_FLAG_RE = /(?:^|\s)(?:--batch|-[A-Za-z]*b[A-Za-z]*)(?:\s|$)/i;
|
|
95
|
+
|
|
96
|
+
function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
97
|
+
for (const segment of command.split(/[;&|\n]+/)) {
|
|
98
|
+
if (ALWAYS_INTERACTIVE_MONITOR_RE.test(segment)) {
|
|
99
|
+
return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
|
|
100
|
+
}
|
|
101
|
+
if (TOP_COMMAND_RE.test(segment) && !TOP_BATCH_FLAG_RE.test(segment)) {
|
|
102
|
+
return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { isInteractive: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
|
|
109
|
+
const monitor = checkProcessMonitorCommand(command);
|
|
110
|
+
if (monitor.isInteractive) return monitor;
|
|
92
111
|
for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
|
|
93
112
|
if (regex.test(command)) {
|
|
94
113
|
return { isInteractive: true, name, advice };
|