assent-cli 0.0.0-stage → 0.6.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 +157 -2
- package/dist/args.d.ts +29 -0
- package/dist/args.js +103 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +45 -0
- package/dist/claude-code.d.ts +19 -0
- package/dist/claude-code.js +144 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.js +636 -0
- package/dist/client.d.ts +224 -0
- package/dist/client.js +112 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +98 -0
- package/dist/diff.d.ts +1 -0
- package/dist/diff.js +46 -0
- package/dist/exit-codes.d.ts +19 -0
- package/dist/exit-codes.js +27 -0
- package/dist/hook-ask.d.ts +39 -0
- package/dist/hook-ask.js +241 -0
- package/dist/hook.d.ts +20 -0
- package/dist/hook.js +177 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +24 -0
- package/dist/mcp-bridge.d.ts +11 -0
- package/dist/mcp-bridge.js +100 -0
- package/dist/redact.d.ts +2 -0
- package/dist/redact.js +32 -0
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +12 -0
- package/dist/usage.d.ts +5 -0
- package/dist/usage.js +90 -0
- package/package.json +22 -4
package/dist/hook-ask.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_WAIT_SECONDS = exports.DEFAULT_WAIT_SECONDS = exports.DEFAULT_ASK_PATTERNS = void 0;
|
|
4
|
+
exports.globMatches = globMatches;
|
|
5
|
+
exports.wordTailMatcher = wordTailMatcher;
|
|
6
|
+
exports.commandSegments = commandSegments;
|
|
7
|
+
exports.canonicalWords = canonicalWords;
|
|
8
|
+
exports.shellCandidates = shellCandidates;
|
|
9
|
+
exports.askMatch = askMatch;
|
|
10
|
+
exports.needsAssent = needsAssent;
|
|
11
|
+
exports.hookAskSettings = hookAskSettings;
|
|
12
|
+
exports.visible = visible;
|
|
13
|
+
exports.askAssent = askAssent;
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const config_1 = require("./config");
|
|
16
|
+
const exit_codes_1 = require("./exit-codes");
|
|
17
|
+
const redact_1 = require("./redact");
|
|
18
|
+
const shell_1 = require("./shell");
|
|
19
|
+
exports.DEFAULT_ASK_PATTERNS = [
|
|
20
|
+
{ tool: "Bash", target: "git push*" },
|
|
21
|
+
{ tool: "Bash", target: "git reset --hard*" },
|
|
22
|
+
{ tool: "Bash", target: "rm -r*" },
|
|
23
|
+
{ tool: "Bash", target: "rm -fr*" },
|
|
24
|
+
{ tool: "Bash", target: "*deploy*" },
|
|
25
|
+
{ tool: "Bash", target: "npm publish*" },
|
|
26
|
+
{ tool: "Bash", target: "terraform apply*" },
|
|
27
|
+
{ tool: "Bash", target: "terraform destroy*" },
|
|
28
|
+
{ tool: "Bash", target: "kubectl delete*" },
|
|
29
|
+
{ tool: "Bash", target: "docker push*" },
|
|
30
|
+
];
|
|
31
|
+
exports.DEFAULT_WAIT_SECONDS = 120;
|
|
32
|
+
exports.MAX_WAIT_SECONDS = 3600;
|
|
33
|
+
const POLL_MS = 3000;
|
|
34
|
+
const RULE_TARGET_MAX = 500;
|
|
35
|
+
const RULE_ACTIONS = [{ id: "allow", label: "Allow", style: "primary" }, { id: "deny", label: "Block", style: "danger" }];
|
|
36
|
+
const ONE_OFF_ACTIONS = [{ id: "allow", label: "Allow", style: "primary" }, { id: "block", label: "Block", style: "secondary" }];
|
|
37
|
+
const SEGMENT_BREAK = /&&|\|\||\$\(|[;&|\n\r`(){}]/;
|
|
38
|
+
const GIT_OPTIONS_WITH_VALUE = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix"]);
|
|
39
|
+
const RECURSIVE_RM_FLAG = /^(?:-[A-Za-z]*r[A-Za-z]*|--recursive)$/i;
|
|
40
|
+
const DISPLAY_LIMIT = 4000;
|
|
41
|
+
const HIDDEN_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff\ufff9-\ufffb\u{e0000}-\u{e007f}]/gu;
|
|
42
|
+
const LEADING_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/;
|
|
43
|
+
const LEADING_WRAPPER = /^(?:sudo|env|command|exec|nohup|time)\s+/i;
|
|
44
|
+
const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
|
|
45
|
+
function normalise(text) {
|
|
46
|
+
return text.trim().replace(/\s+/g, " ");
|
|
47
|
+
}
|
|
48
|
+
function globSource(pattern) {
|
|
49
|
+
let source = "";
|
|
50
|
+
const chars = [...normalise(pattern)];
|
|
51
|
+
for (let i = 0; i < chars.length; i++) {
|
|
52
|
+
const ch = chars[i];
|
|
53
|
+
if (ch === "\\" && i + 1 < chars.length)
|
|
54
|
+
source += chars[++i].replace(REGEX_SPECIAL, "\\$&");
|
|
55
|
+
else
|
|
56
|
+
source += ch === "*" ? ".*" : ch.replace(REGEX_SPECIAL, "\\$&");
|
|
57
|
+
}
|
|
58
|
+
return source;
|
|
59
|
+
}
|
|
60
|
+
function globMatches(pattern, value) {
|
|
61
|
+
return new RegExp(`^${globSource(pattern)}$`, "is").test(normalise(value));
|
|
62
|
+
}
|
|
63
|
+
function wordTailMatcher(pattern) {
|
|
64
|
+
const source = globSource(pattern);
|
|
65
|
+
return normalise(pattern).startsWith("*") ? new RegExp(`^${source}$`, "is") : new RegExp(`(?:^| )(?:${source})$`, "is");
|
|
66
|
+
}
|
|
67
|
+
function bareCommand(segment) {
|
|
68
|
+
let current = segment.trim();
|
|
69
|
+
for (let previous = ""; previous !== current;) {
|
|
70
|
+
previous = current;
|
|
71
|
+
current = current.replace(LEADING_ASSIGNMENT, "").replace(LEADING_WRAPPER, "");
|
|
72
|
+
}
|
|
73
|
+
return current;
|
|
74
|
+
}
|
|
75
|
+
function commandSegments(command) {
|
|
76
|
+
return command.split(SEGMENT_BREAK).map(bareCommand).filter((segment) => segment.length > 0);
|
|
77
|
+
}
|
|
78
|
+
function baseName(word) {
|
|
79
|
+
const slash = word.lastIndexOf("/");
|
|
80
|
+
return slash >= 0 && slash < word.length - 1 ? word.slice(slash + 1) : word;
|
|
81
|
+
}
|
|
82
|
+
function canonicalWords(words) {
|
|
83
|
+
const recursive = words.some((w) => RECURSIVE_RM_FLAG.test(w));
|
|
84
|
+
const out = [];
|
|
85
|
+
for (let i = 0; i < words.length; i++) {
|
|
86
|
+
const word = baseName(words[i]);
|
|
87
|
+
out.push(word);
|
|
88
|
+
if (word === "git") {
|
|
89
|
+
while (i + 1 < words.length && words[i + 1].startsWith("-"))
|
|
90
|
+
i += GIT_OPTIONS_WITH_VALUE.has(words[i + 1]) ? 2 : 1;
|
|
91
|
+
}
|
|
92
|
+
if (word === "rm" && recursive)
|
|
93
|
+
out.push("-r");
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function shellCandidates(command) {
|
|
98
|
+
const unquoted = command.replace(/["'\\]/g, "");
|
|
99
|
+
const segments = [command, unquoted].flatMap(commandSegments).map(normalise);
|
|
100
|
+
return [...segments.flatMap((segment) => [segment, canonicalWords(segment.split(" ")).join(" ")]), normalise(command), normalise(unquoted)];
|
|
101
|
+
}
|
|
102
|
+
function askMatch(patterns, tool, raw) {
|
|
103
|
+
const shell = (0, shell_1.isShellTool)(tool);
|
|
104
|
+
const candidates = shell ? shellCandidates(raw) : [raw];
|
|
105
|
+
for (const p of patterns) {
|
|
106
|
+
if (!globMatches(p.tool, tool))
|
|
107
|
+
continue;
|
|
108
|
+
if (p.target === undefined)
|
|
109
|
+
return raw;
|
|
110
|
+
const matcher = shell ? wordTailMatcher(p.target) : new RegExp(`^${globSource(p.target)}$`, "is");
|
|
111
|
+
const hit = candidates.find((c) => matcher.test(shell ? c : normalise(c)));
|
|
112
|
+
if (hit !== undefined)
|
|
113
|
+
return hit;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function needsAssent(patterns, tool, raw) {
|
|
118
|
+
return askMatch(patterns, tool, raw) !== null;
|
|
119
|
+
}
|
|
120
|
+
function invalid(message) {
|
|
121
|
+
throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `hook settings: ${message}`);
|
|
122
|
+
}
|
|
123
|
+
function parsePattern(value) {
|
|
124
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
125
|
+
invalid("each hook.ask entry must be an object like {\"tool\": \"Bash\", \"target\": \"git push*\"}");
|
|
126
|
+
const { tool, target } = value;
|
|
127
|
+
if (typeof tool !== "string" || !tool.trim())
|
|
128
|
+
invalid("each hook.ask entry needs a tool, for example \"Bash\" or \"mcp__*\"");
|
|
129
|
+
if (target !== undefined && (typeof target !== "string" || !target.trim()))
|
|
130
|
+
invalid("hook.ask target must be a non-empty pattern when given");
|
|
131
|
+
return target === undefined ? { tool } : { tool, target };
|
|
132
|
+
}
|
|
133
|
+
function parseWait(seconds, from) {
|
|
134
|
+
if (typeof seconds !== "number" || !Number.isInteger(seconds) || seconds < 1 || seconds > exports.MAX_WAIT_SECONDS)
|
|
135
|
+
invalid(`${from} must be a whole number of seconds from 1 to ${exports.MAX_WAIT_SECONDS}`);
|
|
136
|
+
return seconds;
|
|
137
|
+
}
|
|
138
|
+
function hookAskSettings(file, env) {
|
|
139
|
+
const hook = file.hook === undefined ? {} : file.hook;
|
|
140
|
+
if (hook === null || typeof hook !== "object" || Array.isArray(hook))
|
|
141
|
+
invalid("hook must be an object");
|
|
142
|
+
const { ask, waitSeconds } = hook;
|
|
143
|
+
if (ask !== undefined && !Array.isArray(ask))
|
|
144
|
+
invalid("hook.ask must be a list of {tool, target} patterns");
|
|
145
|
+
const envWait = (0, config_1.assentEnv)(env, "HOOK_WAIT_SECONDS");
|
|
146
|
+
return {
|
|
147
|
+
patterns: ask === undefined ? exports.DEFAULT_ASK_PATTERNS : ask.map(parsePattern),
|
|
148
|
+
waitSeconds: envWait !== undefined ? parseWait(Number(envWait), "ASSENT_HOOK_WAIT_SECONDS") : waitSeconds === undefined ? exports.DEFAULT_WAIT_SECONDS : parseWait(waitSeconds, "hook.waitSeconds"),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function waitLabel(seconds) {
|
|
152
|
+
return seconds % 60 === 0 ? `${seconds / 60} minute${seconds === 60 ? "" : "s"}` : `${seconds} seconds`;
|
|
153
|
+
}
|
|
154
|
+
function chosenAction(task) {
|
|
155
|
+
const answer = task.answer;
|
|
156
|
+
return typeof answer?.actionId === "string" ? answer.actionId : undefined;
|
|
157
|
+
}
|
|
158
|
+
function answerExpired(task, now) {
|
|
159
|
+
return task.answerExpiresAt !== null && Date.parse(task.answerExpiresAt) <= now;
|
|
160
|
+
}
|
|
161
|
+
async function quietly(work) {
|
|
162
|
+
try {
|
|
163
|
+
await work();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function ruleable(call) {
|
|
170
|
+
return call.exact && call.target.replace(/[\\*]/g, "\\$&").length <= RULE_TARGET_MAX;
|
|
171
|
+
}
|
|
172
|
+
function visible(text) {
|
|
173
|
+
return text.replace(HIDDEN_CHARACTERS, "\ufffd");
|
|
174
|
+
}
|
|
175
|
+
function askDetail(call) {
|
|
176
|
+
return [
|
|
177
|
+
`Matched: ${visible((0, redact_1.redactSecrets)(call.matched))}`,
|
|
178
|
+
`Full ${(0, shell_1.isShellTool)(call.tool) ? "command" : "target"}:`,
|
|
179
|
+
visible(call.target),
|
|
180
|
+
`Summary: ${visible(call.summary)}`,
|
|
181
|
+
call.cwd ? `Working directory: ${visible((0, redact_1.redact)(call.cwd))}` : "",
|
|
182
|
+
].filter(Boolean).join("\n");
|
|
183
|
+
}
|
|
184
|
+
function sameAsk(task, call, rules) {
|
|
185
|
+
if (task.created === false || task.deduped === true || task.kind !== "approve")
|
|
186
|
+
return false;
|
|
187
|
+
return !rules || (task.action === call.tool && task.target === call.target);
|
|
188
|
+
}
|
|
189
|
+
function withdraw(api, uuid, note) {
|
|
190
|
+
return quietly(() => api.resolve(uuid, { outcome: "withdrawn", note }));
|
|
191
|
+
}
|
|
192
|
+
async function waitForAnswer(api, clock, task, waitSeconds) {
|
|
193
|
+
const deadline = clock.now() + waitSeconds * 1000;
|
|
194
|
+
let current = task;
|
|
195
|
+
try {
|
|
196
|
+
while (current.status === "open") {
|
|
197
|
+
if (clock.now() >= deadline || answerExpired(current, clock.now())) {
|
|
198
|
+
await withdraw(api, current.uuid, "No answer in time, so the tool call was blocked");
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
await clock.sleep(Math.min(POLL_MS, deadline - clock.now()));
|
|
202
|
+
current = await api.task(current.uuid);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
await withdraw(api, task.uuid, "Assent could not be reached while waiting, so the tool call was blocked");
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
return current;
|
|
210
|
+
}
|
|
211
|
+
async function askAssent(api, clock, call, waitSeconds) {
|
|
212
|
+
if ((0, redact_1.redactSecrets)(call.raw).length > DISPLAY_LIMIT)
|
|
213
|
+
return { decision: "deny", reason: "This call is too long to show in full in Assent, so it was blocked. Split it into smaller steps." };
|
|
214
|
+
const rules = ruleable(call);
|
|
215
|
+
const task = await api.ask({
|
|
216
|
+
kind: "approve",
|
|
217
|
+
title: `Allow ${call.tool}: ${visible(call.target).replace(/\s+/g, " ")}`.slice(0, 500),
|
|
218
|
+
question: "Let Claude Code make this tool call?",
|
|
219
|
+
why: `Claude Code is paused on this call and blocks it if there is no answer within ${waitLabel(waitSeconds)}.`,
|
|
220
|
+
detail: askDetail(call),
|
|
221
|
+
actions: rules ? RULE_ACTIONS : ONE_OFF_ACTIONS,
|
|
222
|
+
allowDuplicate: true,
|
|
223
|
+
...(rules ? { action: call.tool, target: call.target } : {}),
|
|
224
|
+
}, (0, node_crypto_1.randomUUID)());
|
|
225
|
+
if (!sameAsk(task, call, rules)) {
|
|
226
|
+
if (task.created !== false)
|
|
227
|
+
await withdraw(api, task.uuid, "Assent returned a different ask, so the tool call was blocked");
|
|
228
|
+
return { decision: "deny", reason: "Assent matched this call to a different ask, so it was blocked. Ask the person before trying again." };
|
|
229
|
+
}
|
|
230
|
+
const current = await waitForAnswer(api, clock, task, waitSeconds);
|
|
231
|
+
if (current === null)
|
|
232
|
+
return { decision: "deny", reason: `No answer in Assent within ${waitLabel(waitSeconds)}, so the call was blocked. Ask the person before trying again.` };
|
|
233
|
+
const answered = current.status === "answered" || current.status === "done";
|
|
234
|
+
if (current.status === "answered")
|
|
235
|
+
await quietly(() => api.pickedUp(current.uuid));
|
|
236
|
+
if (answered && chosenAction(current) === "allow")
|
|
237
|
+
return { decision: "allow", reason: "Allowed in Assent" };
|
|
238
|
+
if (answered)
|
|
239
|
+
return { decision: "deny", reason: "Blocked in Assent. Do not retry it; ask the person what to do instead." };
|
|
240
|
+
return { decision: "deny", reason: `The Assent ask was ${current.status}, so the call was blocked` };
|
|
241
|
+
}
|
package/dist/hook.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FetchLike } from "./client";
|
|
2
|
+
export type HookDecision = "allow" | "deny" | "ask";
|
|
3
|
+
export interface HookDeps {
|
|
4
|
+
env: NodeJS.ProcessEnv;
|
|
5
|
+
home: string;
|
|
6
|
+
readFile(path: string): string | null;
|
|
7
|
+
readStdin(): Promise<string>;
|
|
8
|
+
stdout(line: string): void;
|
|
9
|
+
sleep(ms: number): Promise<void>;
|
|
10
|
+
now(): number;
|
|
11
|
+
fetchImpl?: FetchLike;
|
|
12
|
+
}
|
|
13
|
+
export declare function resolvedPath(path: string, cwd?: string): string;
|
|
14
|
+
export declare function denyCheckPath(tool: string, raw: string, cwd?: string): string | null;
|
|
15
|
+
export declare function hookRawTarget(tool: string, input: Record<string, unknown>, cwd?: string): string;
|
|
16
|
+
export declare function hookTarget(tool: string, input: Record<string, unknown>, cwd?: string): string;
|
|
17
|
+
export declare function exactTarget(tool: string, raw: string): boolean;
|
|
18
|
+
export declare function hookSummary(tool: string, input: Record<string, unknown>, target: string): string;
|
|
19
|
+
export declare function hookOutput(decision: HookDecision, reason: string): string;
|
|
20
|
+
export declare function runHook(argv: string[], deps: HookDeps): Promise<number>;
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolvedPath = resolvedPath;
|
|
4
|
+
exports.denyCheckPath = denyCheckPath;
|
|
5
|
+
exports.hookRawTarget = hookRawTarget;
|
|
6
|
+
exports.hookTarget = hookTarget;
|
|
7
|
+
exports.exactTarget = exactTarget;
|
|
8
|
+
exports.hookSummary = hookSummary;
|
|
9
|
+
exports.hookOutput = hookOutput;
|
|
10
|
+
exports.runHook = runHook;
|
|
11
|
+
const node_path_1 = require("node:path");
|
|
12
|
+
const client_1 = require("./client");
|
|
13
|
+
const config_1 = require("./config");
|
|
14
|
+
const hook_ask_1 = require("./hook-ask");
|
|
15
|
+
const redact_1 = require("./redact");
|
|
16
|
+
const shell_1 = require("./shell");
|
|
17
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 3000;
|
|
18
|
+
const TARGET_LIMIT = 4000;
|
|
19
|
+
const SUMMARY_LIMIT = 200;
|
|
20
|
+
const ACTION_NAME = /^[A-Za-z0-9:._/@+-]{1,120}$/;
|
|
21
|
+
const PATH_TOOLS = new Set(["Edit", "Write", "Read", "MultiEdit", "NotebookEdit"]);
|
|
22
|
+
const PARENT_SEGMENT = /(?:^|\/)\.\.(?:\/|$)/;
|
|
23
|
+
const ASK_REQUEST_TIMEOUT_MS = 5000;
|
|
24
|
+
const UNINSTALLED_MAX_WAIT_SECONDS = 45;
|
|
25
|
+
const INSPECT_LIMIT = 65536;
|
|
26
|
+
function field(input, ...names) {
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const value = input[name];
|
|
29
|
+
if (typeof value === "string" && value)
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
function normalisedPath(path, cwd) {
|
|
35
|
+
if (node_path_1.posix.isAbsolute(path) || !cwd || !node_path_1.posix.isAbsolute(cwd))
|
|
36
|
+
return node_path_1.posix.normalize(path);
|
|
37
|
+
return node_path_1.posix.resolve(cwd, path);
|
|
38
|
+
}
|
|
39
|
+
function resolvedPath(path, cwd) {
|
|
40
|
+
return PARENT_SEGMENT.test(path) ? path : normalisedPath(path, cwd);
|
|
41
|
+
}
|
|
42
|
+
function denyCheckPath(tool, raw, cwd) {
|
|
43
|
+
if (!PATH_TOOLS.has(tool) || !PARENT_SEGMENT.test(raw))
|
|
44
|
+
return null;
|
|
45
|
+
const normal = normalisedPath(raw, cwd);
|
|
46
|
+
return normal === raw ? null : normal;
|
|
47
|
+
}
|
|
48
|
+
function hookRawTarget(tool, input, cwd) {
|
|
49
|
+
const path = PATH_TOOLS.has(tool) ? field(input, "file_path", "notebook_path") : undefined;
|
|
50
|
+
const direct = tool === "Bash" ? field(input, "command")
|
|
51
|
+
: path !== undefined ? resolvedPath(path, cwd)
|
|
52
|
+
: tool === "WebFetch" ? field(input, "url")
|
|
53
|
+
: tool === "WebSearch" ? field(input, "query")
|
|
54
|
+
: undefined;
|
|
55
|
+
return direct ?? JSON.stringify(input);
|
|
56
|
+
}
|
|
57
|
+
function hookTarget(tool, input, cwd) {
|
|
58
|
+
return (0, redact_1.redactSecrets)(hookRawTarget(tool, input, cwd)).slice(0, TARGET_LIMIT);
|
|
59
|
+
}
|
|
60
|
+
function exactPath(tool, raw) {
|
|
61
|
+
return !PATH_TOOLS.has(tool) || (node_path_1.posix.isAbsolute(raw) && !PARENT_SEGMENT.test(raw));
|
|
62
|
+
}
|
|
63
|
+
function exactTarget(tool, raw) {
|
|
64
|
+
return raw.length <= TARGET_LIMIT && (0, redact_1.redactSecrets)(raw) === raw && (!(0, shell_1.isShellTool)(tool) || (0, shell_1.isSimpleCommand)(raw)) && exactPath(tool, raw);
|
|
65
|
+
}
|
|
66
|
+
function hookSummary(tool, input, target) {
|
|
67
|
+
const described = field(input, "description");
|
|
68
|
+
return (0, redact_1.redact)(described ?? `${tool} ${target}`).slice(0, SUMMARY_LIMIT);
|
|
69
|
+
}
|
|
70
|
+
function hookOutput(decision, reason) {
|
|
71
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: decision, permissionDecisionReason: reason } });
|
|
72
|
+
}
|
|
73
|
+
function hookTimeout(env) {
|
|
74
|
+
const value = Number((0, config_1.assentEnv)(env, "HOOK_TIMEOUT_MS"));
|
|
75
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_HOOK_TIMEOUT_MS;
|
|
76
|
+
}
|
|
77
|
+
function parseEvent(raw) {
|
|
78
|
+
try {
|
|
79
|
+
const event = JSON.parse(raw);
|
|
80
|
+
const tool = event?.tool_name;
|
|
81
|
+
const input = event?.tool_input;
|
|
82
|
+
if (typeof tool !== "string" || !ACTION_NAME.test(tool))
|
|
83
|
+
return null;
|
|
84
|
+
return { tool, input: input !== null && typeof input === "object" && !Array.isArray(input) ? input : {}, ...(typeof event.cwd === "string" ? { cwd: event.cwd } : {}) };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function reasonFor(decision, label, reason) {
|
|
91
|
+
if (decision === "ask")
|
|
92
|
+
return "No Assent rule covers this action";
|
|
93
|
+
const rule = `${decision === "allow" ? "Allowed" : "Blocked"} by Assent rule: ${label ?? "unnamed rule"}`;
|
|
94
|
+
return reason ? `${rule}. ${reason}` : rule;
|
|
95
|
+
}
|
|
96
|
+
function sources(deps) {
|
|
97
|
+
return { env: deps.env, home: deps.home, readFile: deps.readFile, exists: (path) => deps.readFile(path) !== null };
|
|
98
|
+
}
|
|
99
|
+
function agentApi(deps, maxTimeoutMs = Infinity) {
|
|
100
|
+
return client_1.AgentApi.fromConfig({ ...(0, config_1.loadCliConfig)(sources(deps)), timeoutMs: Math.min(hookTimeout(deps.env), maxTimeoutMs) }, deps.fetchImpl);
|
|
101
|
+
}
|
|
102
|
+
function maxWait(flags) {
|
|
103
|
+
const at = flags.indexOf("--max-wait");
|
|
104
|
+
if (at < 0)
|
|
105
|
+
return UNINSTALLED_MAX_WAIT_SECONDS;
|
|
106
|
+
const seconds = Number(flags[at + 1]);
|
|
107
|
+
return Number.isInteger(seconds) && seconds > 0 ? seconds : "--max-wait needs a whole number of seconds";
|
|
108
|
+
}
|
|
109
|
+
async function normalisedDeny(api, event) {
|
|
110
|
+
const normal = denyCheckPath(event.tool, hookRawTarget(event.tool, event.input, event.cwd), event.cwd);
|
|
111
|
+
if (normal === null)
|
|
112
|
+
return null;
|
|
113
|
+
const target = (0, redact_1.redactSecrets)(normal).slice(0, TARGET_LIMIT);
|
|
114
|
+
const result = await api.rulesCheck({ action: event.tool, target, summary: hookSummary(event.tool, event.input, target) });
|
|
115
|
+
return result.decision === "deny" ? result : null;
|
|
116
|
+
}
|
|
117
|
+
async function checkRules(api, event) {
|
|
118
|
+
const target = hookTarget(event.tool, event.input, event.cwd);
|
|
119
|
+
const first = await api.rulesCheck({ action: event.tool, target, summary: hookSummary(event.tool, event.input, target) });
|
|
120
|
+
const result = first.decision === "deny" ? first : (await normalisedDeny(api, event)) ?? first;
|
|
121
|
+
const decision = result.decision === "allow" || result.decision === "deny" ? result.decision : "ask";
|
|
122
|
+
if (decision === "allow" && !exactTarget(event.tool, hookRawTarget(event.tool, event.input, event.cwd)))
|
|
123
|
+
return { decision: "ask", reason: "An Assent rule allows this, but the command is chained, too long or holds a secret, so asking" };
|
|
124
|
+
return { decision, reason: reasonFor(decision, result.label, result.reason) };
|
|
125
|
+
}
|
|
126
|
+
function askSettings(deps) {
|
|
127
|
+
try {
|
|
128
|
+
return (0, hook_ask_1.hookAskSettings)((0, config_1.readConfigFile)(sources(deps)), deps.env);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return err.message;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function guarded(deps, event, flags) {
|
|
135
|
+
const settings = askSettings(deps);
|
|
136
|
+
if (typeof settings === "string")
|
|
137
|
+
return { decision: "deny", reason: `Assent cannot read its hook settings (${settings}), so the call was blocked` };
|
|
138
|
+
const limit = maxWait(flags);
|
|
139
|
+
if (typeof limit === "string")
|
|
140
|
+
return { decision: "deny", reason: `Assent cannot read its hook settings (${limit}), so the call was blocked` };
|
|
141
|
+
const raw = hookRawTarget(event.tool, event.input, event.cwd);
|
|
142
|
+
if ((0, shell_1.isShellTool)(event.tool) && raw.length > INSPECT_LIMIT)
|
|
143
|
+
return { decision: "deny", reason: "This command is too long for Assent to inspect, so it was blocked. Split it into smaller steps." };
|
|
144
|
+
const matched = (0, hook_ask_1.askMatch)(settings.patterns, event.tool, raw);
|
|
145
|
+
if (matched === null)
|
|
146
|
+
return checkRules(agentApi(deps), event);
|
|
147
|
+
try {
|
|
148
|
+
const api = agentApi(deps, ASK_REQUEST_TIMEOUT_MS);
|
|
149
|
+
const checked = await checkRules(api, event);
|
|
150
|
+
if (checked.decision !== "ask")
|
|
151
|
+
return checked;
|
|
152
|
+
const target = hookTarget(event.tool, event.input, event.cwd);
|
|
153
|
+
const call = { tool: event.tool, raw, target, matched, summary: hookSummary(event.tool, event.input, target), exact: exactTarget(event.tool, raw), cwd: event.cwd };
|
|
154
|
+
return await (0, hook_ask_1.askAssent)(api, deps, call, Math.min(settings.waitSeconds, limit));
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
return { decision: "deny", reason: `Assent is unavailable (${err.message}), so the call was blocked` };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function decide(deps, flags) {
|
|
161
|
+
const event = parseEvent(await deps.readStdin());
|
|
162
|
+
if (!event)
|
|
163
|
+
return { decision: "ask", reason: "Assent could not read this tool call, so asking" };
|
|
164
|
+
return flags.includes("--ask") ? guarded(deps, event, flags) : checkRules(agentApi(deps), event);
|
|
165
|
+
}
|
|
166
|
+
async function runHook(argv, deps) {
|
|
167
|
+
const [name, ...flags] = argv;
|
|
168
|
+
let outcome;
|
|
169
|
+
try {
|
|
170
|
+
outcome = name === "pre-tool-use" ? await decide(deps, flags) : { decision: "ask", reason: `Unknown Assent hook ${name ?? ""}, use hook pre-tool-use` };
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
outcome = { decision: "ask", reason: `Assent is unavailable (${err.message}), so asking` };
|
|
174
|
+
}
|
|
175
|
+
deps.stdout(hookOutput(outcome.decision, outcome.reason));
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { EXIT, CliError, exitCodeForStatus } from "./exit-codes";
|
|
2
|
+
export type { ExitCode } from "./exit-codes";
|
|
3
|
+
export { redact } from "./redact";
|
|
4
|
+
export { assentEnv, configPath, DEFAULT_API_URL, loadCliConfig, readFileOrNull } from "./config";
|
|
5
|
+
export type { CliConfig, ConfigSources } from "./config";
|
|
6
|
+
export { HttpClient, AgentApi, defaultFetch } from "./client";
|
|
7
|
+
export type { FetchLike, FetchInit, FetchResponse, HttpOptions, TaskView, LockResult, AskBody } from "./client";
|
|
8
|
+
export { parseCommandArgs } from "./args";
|
|
9
|
+
export type { OptionSpec, Parsed } from "./args";
|
|
10
|
+
export { USAGE, VERSION } from "./usage";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VERSION = exports.USAGE = exports.parseCommandArgs = exports.defaultFetch = exports.AgentApi = exports.HttpClient = exports.readFileOrNull = exports.loadCliConfig = exports.DEFAULT_API_URL = exports.configPath = exports.assentEnv = exports.redact = exports.exitCodeForStatus = exports.CliError = exports.EXIT = void 0;
|
|
4
|
+
var exit_codes_1 = require("./exit-codes");
|
|
5
|
+
Object.defineProperty(exports, "EXIT", { enumerable: true, get: function () { return exit_codes_1.EXIT; } });
|
|
6
|
+
Object.defineProperty(exports, "CliError", { enumerable: true, get: function () { return exit_codes_1.CliError; } });
|
|
7
|
+
Object.defineProperty(exports, "exitCodeForStatus", { enumerable: true, get: function () { return exit_codes_1.exitCodeForStatus; } });
|
|
8
|
+
var redact_1 = require("./redact");
|
|
9
|
+
Object.defineProperty(exports, "redact", { enumerable: true, get: function () { return redact_1.redact; } });
|
|
10
|
+
var config_1 = require("./config");
|
|
11
|
+
Object.defineProperty(exports, "assentEnv", { enumerable: true, get: function () { return config_1.assentEnv; } });
|
|
12
|
+
Object.defineProperty(exports, "configPath", { enumerable: true, get: function () { return config_1.configPath; } });
|
|
13
|
+
Object.defineProperty(exports, "DEFAULT_API_URL", { enumerable: true, get: function () { return config_1.DEFAULT_API_URL; } });
|
|
14
|
+
Object.defineProperty(exports, "loadCliConfig", { enumerable: true, get: function () { return config_1.loadCliConfig; } });
|
|
15
|
+
Object.defineProperty(exports, "readFileOrNull", { enumerable: true, get: function () { return config_1.readFileOrNull; } });
|
|
16
|
+
var client_1 = require("./client");
|
|
17
|
+
Object.defineProperty(exports, "HttpClient", { enumerable: true, get: function () { return client_1.HttpClient; } });
|
|
18
|
+
Object.defineProperty(exports, "AgentApi", { enumerable: true, get: function () { return client_1.AgentApi; } });
|
|
19
|
+
Object.defineProperty(exports, "defaultFetch", { enumerable: true, get: function () { return client_1.defaultFetch; } });
|
|
20
|
+
var args_1 = require("./args");
|
|
21
|
+
Object.defineProperty(exports, "parseCommandArgs", { enumerable: true, get: function () { return args_1.parseCommandArgs; } });
|
|
22
|
+
var usage_1 = require("./usage");
|
|
23
|
+
Object.defineProperty(exports, "USAGE", { enumerable: true, get: function () { return usage_1.USAGE; } });
|
|
24
|
+
Object.defineProperty(exports, "VERSION", { enumerable: true, get: function () { return usage_1.VERSION; } });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FetchLike } from "./client";
|
|
2
|
+
export interface BridgeDeps {
|
|
3
|
+
env: NodeJS.ProcessEnv;
|
|
4
|
+
home: string;
|
|
5
|
+
readFile(path: string): string | null;
|
|
6
|
+
stdout(line: string): void;
|
|
7
|
+
stderr(line: string): void;
|
|
8
|
+
stdinLines(): AsyncIterable<string>;
|
|
9
|
+
fetchImpl?: FetchLike;
|
|
10
|
+
}
|
|
11
|
+
export declare function runMcpBridge(deps: BridgeDeps): Promise<number>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runMcpBridge = runMcpBridge;
|
|
4
|
+
const client_1 = require("./client");
|
|
5
|
+
const config_1 = require("./config");
|
|
6
|
+
const exit_codes_1 = require("./exit-codes");
|
|
7
|
+
const BRIDGE_TIMEOUT_MS = 90000;
|
|
8
|
+
const API_VERSION_SUFFIX = /\/api\/v1$/;
|
|
9
|
+
const PARSE_ERROR = -32700;
|
|
10
|
+
const SERVER_ERROR = -32000;
|
|
11
|
+
function requestId(message) {
|
|
12
|
+
if (message === null || typeof message !== "object" || Array.isArray(message) || !("id" in message))
|
|
13
|
+
return undefined;
|
|
14
|
+
const id = message.id;
|
|
15
|
+
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
16
|
+
}
|
|
17
|
+
function rpcError(id, code, message) {
|
|
18
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
19
|
+
}
|
|
20
|
+
function parsed(text) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isRpc(body) {
|
|
29
|
+
return Array.isArray(body) || (body !== null && typeof body === "object" && "jsonrpc" in body);
|
|
30
|
+
}
|
|
31
|
+
function failure(status, body, text) {
|
|
32
|
+
const message = body !== null && typeof body === "object" && typeof body.message === "string" ? body.message : text.slice(0, 300);
|
|
33
|
+
return `Assent returned ${status}${message ? `: ${message}` : ""}`;
|
|
34
|
+
}
|
|
35
|
+
async function post(url, agentKey, line, fetchImpl) {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => controller.abort(), BRIDGE_TIMEOUT_MS);
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetchImpl(url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { accept: "application/json, text/event-stream", "content-type": "application/json", "x-ordia-agent-key": agentKey },
|
|
42
|
+
body: line,
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
redirect: "error",
|
|
45
|
+
});
|
|
46
|
+
return { status: response.status, text: (await response.text()).trim() };
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function forward(line, url, agentKey, deps) {
|
|
53
|
+
const message = parsed(line);
|
|
54
|
+
if (message === undefined) {
|
|
55
|
+
deps.stdout(rpcError(null, PARSE_ERROR, "Parse error"));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const id = requestId(message);
|
|
59
|
+
let problem;
|
|
60
|
+
try {
|
|
61
|
+
const { status, text } = await post(url, agentKey, line, deps.fetchImpl ?? client_1.defaultFetch);
|
|
62
|
+
const body = text ? parsed(text) : undefined;
|
|
63
|
+
if (isRpc(body)) {
|
|
64
|
+
deps.stdout(JSON.stringify(body));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (status < 400 && !text)
|
|
68
|
+
return;
|
|
69
|
+
problem = status < 400 ? "Assent sent a response that is not JSON-RPC" : failure(status, body, text);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
problem = `Assent is unreachable at ${url}: ${err.message}`;
|
|
73
|
+
}
|
|
74
|
+
if (id === undefined)
|
|
75
|
+
deps.stderr(JSON.stringify({ error: problem }));
|
|
76
|
+
else
|
|
77
|
+
deps.stdout(rpcError(id, SERVER_ERROR, problem));
|
|
78
|
+
}
|
|
79
|
+
async function runMcpBridge(deps) {
|
|
80
|
+
const sources = { env: deps.env, home: deps.home, readFile: deps.readFile, exists: (path) => deps.readFile(path) !== null };
|
|
81
|
+
let config;
|
|
82
|
+
try {
|
|
83
|
+
config = (0, config_1.loadCliConfig)(sources);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const { message, exitCode } = err;
|
|
87
|
+
deps.stderr(JSON.stringify({ error: message, exitCode, status: null }));
|
|
88
|
+
return exitCode;
|
|
89
|
+
}
|
|
90
|
+
const url = `${config.apiUrl.replace(API_VERSION_SUFFIX, "")}/api/v1/mcp`;
|
|
91
|
+
const pending = new Set();
|
|
92
|
+
for await (const line of deps.stdinLines()) {
|
|
93
|
+
if (!line.trim())
|
|
94
|
+
continue;
|
|
95
|
+
const job = forward(line, url, config.agentKey, deps).finally(() => pending.delete(job));
|
|
96
|
+
pending.add(job);
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(pending);
|
|
99
|
+
return exit_codes_1.EXIT.ok;
|
|
100
|
+
}
|
package/dist/redact.d.ts
ADDED
package/dist/redact.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.redactSecrets = redactSecrets;
|
|
4
|
+
exports.redact = redact;
|
|
5
|
+
const EMAIL_LOCAL = "[A-Za-z0-9._%+-]";
|
|
6
|
+
const EMAIL_DOMAIN = "(?:[A-Za-z0-9-]{1,63}\\.){1,10}[A-Za-z]{2,63}";
|
|
7
|
+
const RULES = [
|
|
8
|
+
[/-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----[\s\S]{0,20000}?(?:-----END [A-Z ]{0,40}PRIVATE KEY-----|$)/g, "[key]"],
|
|
9
|
+
[/\b([a-z][a-z0-9+.-]{1,20}:\/\/)[^\s\/@:]{1,256}:[^\s\/@]{1,256}@/gi, "$1[redacted]@"],
|
|
10
|
+
[/\bBasic\s{1,64}[A-Za-z0-9+\/=]{8,}/g, "Basic [token]"],
|
|
11
|
+
[/(--(?:password|passwd|token|secret|api-key|apikey|access-token|auth-token|client-secret)(?:=|\s{1,16}))(?:"[^"\n]{0,512}"|'[^'\n]{0,512}'|[^\s"']+)/gi, "$1[redacted]"],
|
|
12
|
+
[/(?<![A-Za-z0-9_-])([A-Za-z0-9_-]{0,60}[_-](?:key|secret|token|password|passwd|pwd))(["']?\s{0,16}[:=]\s{0,16})(?:"[^"\n]{0,512}"|'[^'\n]{0,512}'|["']?[^\s"',;&]+)/gi, "$1$2[redacted]"],
|
|
13
|
+
[/(?<![A-Za-z0-9])(?:github_pat_[A-Za-z0-9_]{20,}|gh[sur]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|npm_[A-Za-z0-9]{36}|AIza[0-9A-Za-z_-]{35})/g, "[key]"],
|
|
14
|
+
[/(?<![A-Za-z0-9_-])eyJ(?=([A-Za-z0-9_-]{8,}))\1\.(?=([A-Za-z0-9_-]{8,}))\2\.[A-Za-z0-9_-]*/g, "[token]"],
|
|
15
|
+
[new RegExp(`(?<!${EMAIL_LOCAL})(?=(${EMAIL_LOCAL}+))\\1@${EMAIL_DOMAIN}(?:(?=(${EMAIL_LOCAL}*))\\2@${EMAIL_DOMAIN})*`, "g"), "[email]"],
|
|
16
|
+
[/Bearer\s{1,64}[A-Za-z0-9._~+\/=-]+/gi, "Bearer [token]"],
|
|
17
|
+
[/(?<![A-Za-z0-9])(password|passwd|secret|token|api_key)(["']?\s{0,16}[:=]\s{0,16})(?:"[^"\n]{0,512}"|'[^'\n]{0,512}'|["']?[^\s"',;&]+)/gi, "$1$2[redacted]"],
|
|
18
|
+
[/(?<![A-Za-z0-9])(ztk|oak|ock|lck|pps|ubk|inv|ghp|gho|vcp|svk|sva|ubrt|wfr)_[A-Za-z0-9_-]{6,}/g, "[key]"],
|
|
19
|
+
[/(?<![A-Za-z0-9])sk_(?:live|test)_[A-Za-z0-9]{8,}/g, "[key]"],
|
|
20
|
+
[/(?<![A-Za-z0-9_])sk-[A-Za-z0-9_-]{10,}/g, "[key]"],
|
|
21
|
+
[/(?<![A-Za-z0-9])FLWSECK(?:_TEST)?[-_]?[A-Za-z0-9-]{8,}/g, "[key]"],
|
|
22
|
+
[/(?<![A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])/g, "[key]"],
|
|
23
|
+
[/(?<!\d)(?:(?:\+44|0044)[\s-]?(?:\(0\)[\s-]?)?|0)7\d{3}[\s-]?\d{3}[\s-]?\d{3}(?!\d)/g, "[phone]"],
|
|
24
|
+
[/(?<![A-Za-z0-9_])(?=([a-f0-9]{32,}))\1(?![A-Za-z0-9_])/gi, "[hash]"],
|
|
25
|
+
];
|
|
26
|
+
const HOME_PATH = /\/Users\/[^\/\s]+/g;
|
|
27
|
+
function redactSecrets(text) {
|
|
28
|
+
return RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text);
|
|
29
|
+
}
|
|
30
|
+
function redact(text) {
|
|
31
|
+
return redactSecrets(text).replace(HOME_PATH, "~");
|
|
32
|
+
}
|
package/dist/shell.d.ts
ADDED
package/dist/shell.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isShellTool = isShellTool;
|
|
4
|
+
exports.isSimpleCommand = isSimpleCommand;
|
|
5
|
+
const SHELL_TOOLS = /^(bash|sh|zsh|shell|powershell|exec|exec_command|run_shell_command|run_terminal_cmd)$/i;
|
|
6
|
+
const SHELL_CONTROL = /[;&|`$<>()\n\r]/;
|
|
7
|
+
function isShellTool(name) {
|
|
8
|
+
return SHELL_TOOLS.test(name);
|
|
9
|
+
}
|
|
10
|
+
function isSimpleCommand(command) {
|
|
11
|
+
return command.trim().length > 0 && !SHELL_CONTROL.test(command);
|
|
12
|
+
}
|