@rse/ase 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dst/ase-bash.js +618 -0
- package/dst/ase-hook.js +27 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/meta/ase-format-arch.md +882 -0
- package/plugin/meta/ase-format-meta.md +51 -0
- package/plugin/meta/ase-format-spec.md +1278 -0
- package/plugin/meta/ase-skill.md +8 -0
- package/plugin/package.json +1 -1
- package/plugin/skills/ase-code-craft/SKILL.md +4 -3
- package/plugin/skills/ase-code-refactor/SKILL.md +4 -3
- package/plugin/skills/ase-code-resolve/SKILL.md +4 -3
- package/plugin/skills/ase-meta-brainstorm/SKILL.md +2 -2
- package/plugin/skills/ase-task-condense/SKILL.md +267 -0
- package/plugin/skills/ase-task-condense/help.md +77 -0
- package/plugin/skills/ase-task-edit/SKILL.md +26 -26
- package/plugin/skills/ase-task-edit/help.md +10 -10
- package/plugin/skills/ase-task-grill/SKILL.md +8 -8
- package/plugin/.claude/settings.local.json +0 -7
- package/plugin/meta/ase-format-adr.md +0 -199
package/dst/ase-bash.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
|
|
5
|
+
*/
|
|
6
|
+
import { parse } from "unbash";
|
|
7
|
+
/* the default verdict when no rule matches a leaf command */
|
|
8
|
+
const DEFAULT = "passthrough";
|
|
9
|
+
/* transparent wrappers whose first non-flag argument is the real command
|
|
10
|
+
to classify (e.g. "env FOO=bar ls" or "xargs rm" resolve to "ls"/"rm") */
|
|
11
|
+
const WRAPPERS = new Set(["env", "xargs", "nice", "nohup", "command", "builtin"]);
|
|
12
|
+
/* privilege escalators are a hard barrier (not an allow-unwrapper): they
|
|
13
|
+
cap the aggregate verdict at "ask" regardless of the inner command's
|
|
14
|
+
safety, yet the inner command is still traversed so an inner
|
|
15
|
+
catastrophic command can still escalate the verdict to "deny" */
|
|
16
|
+
const PRIVILEGE = new Set(["sudo", "doas"]);
|
|
17
|
+
/* decide whether a single "sed" script argument is verified-safe, i.e.
|
|
18
|
+
provably free of any file-writing ("w"/"W" command or "s///w" flag),
|
|
19
|
+
file-reading ("r"/"R"), or shell-executing ("e" command or "s///e"
|
|
20
|
+
flag) construct. Substring matching is unreliable here because sed's
|
|
21
|
+
one-letter commands collide with substitution data and the delimiter
|
|
22
|
+
is freely chosen, so this instead allow-lists only a small, common,
|
|
23
|
+
obviously-inert grammar and rejects (-> "passthrough") anything else:
|
|
24
|
+
a ";"/newline-separated sequence of either a substitution
|
|
25
|
+
"s<D>...<D>...<D>[flags]" whose flags are restricted to the inert set
|
|
26
|
+
"g/p/i/I/m/M/N" (notably excluding "e" and "w"), or an
|
|
27
|
+
optionally-addressed plain command drawn from the inert command set
|
|
28
|
+
"p/P/d/D/q/Q/l/n/N/h/H/g/G/x/z/=" (no operand). Addresses are limited
|
|
29
|
+
to line numbers, "$", and "/regex/" forms. Anything richer (a "w"/"r"/
|
|
30
|
+
"e"/"a"/"i"/"c"/"y"/"{ }" block, an unfamiliar flag, a custom label,
|
|
31
|
+
etc.) simply fails the guard and falls back to the host prompt. */
|
|
32
|
+
const SED_ADDR = "(?:[0-9]+|\\$|/(?:\\\\.|[^/\\\\])*/[IM]?)";
|
|
33
|
+
const SED_RANGE = `(?:${SED_ADDR}(?:,${SED_ADDR})?)?`;
|
|
34
|
+
const SED_SUBST = "s([^\\sa-zA-Z0-9\\\\])(?:\\\\.|(?!\\1).)*\\1(?:\\\\.|(?!\\1).)*\\1[gpiImMN0-9]*";
|
|
35
|
+
const SED_PLAIN = "[pPdDqQlnNhHgGxz=]";
|
|
36
|
+
const SED_CMD = new RegExp(`^${SED_RANGE}\\s*(?:${SED_SUBST}|${SED_PLAIN})$`);
|
|
37
|
+
const sedScriptText = (text) => {
|
|
38
|
+
/* split one script into its individual commands on ";" and newlines
|
|
39
|
+
(a conservative split: any embedded ";"/newline inside a regex or
|
|
40
|
+
replacement just yields a fragment that fails to match below and
|
|
41
|
+
thus correctly denies auto-approval rather than mis-approving) */
|
|
42
|
+
const parts = text.split(/[;\n]/).map((p) => p.trim()).filter((p) => p !== "");
|
|
43
|
+
if (parts.length === 0)
|
|
44
|
+
return false;
|
|
45
|
+
return parts.every((p) => SED_CMD.test(p));
|
|
46
|
+
};
|
|
47
|
+
/* validate a whole "sed" argument vector: locate every script source and
|
|
48
|
+
require each to be verified-safe, while letting flags and trailing file
|
|
49
|
+
operands pass freely. The script comes either from each "-e"/"--expression"
|
|
50
|
+
option (its glued or following value) or, when no "-e" is present, from
|
|
51
|
+
the first non-flag positional token (the remaining positionals are input
|
|
52
|
+
files). A "--" terminator ends option processing. Any "-f"/"--file" is
|
|
53
|
+
already denied upstream via "denyFlags", so a script read from a file
|
|
54
|
+
never reaches this guard. */
|
|
55
|
+
const sedArgsAreSafe = (args) => {
|
|
56
|
+
const scripts = [];
|
|
57
|
+
let sawExpr = false;
|
|
58
|
+
let endOpts = false;
|
|
59
|
+
let firstPositional = true;
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const arg = args[i];
|
|
62
|
+
if (!endOpts && arg === "--") {
|
|
63
|
+
endOpts = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!endOpts && (arg === "-e" || arg === "--expression")) {
|
|
67
|
+
/* the script is the following token (must exist) */
|
|
68
|
+
if (i + 1 >= args.length)
|
|
69
|
+
return false;
|
|
70
|
+
scripts.push(args[++i]);
|
|
71
|
+
sawExpr = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!endOpts && arg.startsWith("--expression=")) {
|
|
75
|
+
scripts.push(arg.slice("--expression=".length));
|
|
76
|
+
sawExpr = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!endOpts && arg.startsWith("-e") && arg.length > 2) {
|
|
80
|
+
/* glued short form "-eSCRIPT" */
|
|
81
|
+
scripts.push(arg.slice(2));
|
|
82
|
+
sawExpr = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!endOpts && arg.startsWith("-"))
|
|
86
|
+
/* any other flag (e.g. "-n", "-E", "-r", "-z", "-u") is inert
|
|
87
|
+
here: the mutating ones were denied upstream by "denyFlags" */
|
|
88
|
+
continue;
|
|
89
|
+
/* a positional: the first is the script when no "-e" was given,
|
|
90
|
+
every other positional is an input file and needs no check */
|
|
91
|
+
if (!sawExpr && firstPositional)
|
|
92
|
+
scripts.push(arg);
|
|
93
|
+
firstPositional = false;
|
|
94
|
+
}
|
|
95
|
+
if (scripts.length === 0)
|
|
96
|
+
return false;
|
|
97
|
+
return scripts.every((s) => sedScriptText(s));
|
|
98
|
+
};
|
|
99
|
+
/* the ordered rule set: first match wins. "allow" rules enumerate a
|
|
100
|
+
conservative, genuinely-inert command set; "risk" rules enumerate the
|
|
101
|
+
known-dangerous ones (mostly "ask", with only a tiny catastrophic set
|
|
102
|
+
"deny"). Everything else falls through to the "passthrough" default. */
|
|
103
|
+
const RULES = [
|
|
104
|
+
/* catastrophic, near-never-legitimate operations -> actively deny.
|
|
105
|
+
These come first so first-match-wins beats the broader "ask" rules
|
|
106
|
+
below (e.g. "rm -rf /" denies before the generic "rm -rf" asks). */
|
|
107
|
+
{ cmdFamily: "mkfs", permission: "deny", reason: "filesystem creation is destructive" },
|
|
108
|
+
{ cmd: "dd", argSubstr: ["of=/dev/"], permission: "deny", reason: "raw write to a device is catastrophic" },
|
|
109
|
+
{ cmd: "rm", flags: ["-r", "-f"], aliases: { "-r": ["--recursive", "-R"], "-f": ["--force"] }, argTokens: ["/"], permission: "deny", reason: "recursive forced removal of root is catastrophic" },
|
|
110
|
+
/* genuinely-inert, read-only commands -> auto-approve */
|
|
111
|
+
{ commands: ["ls"], permission: "allow", reason: "directory listing is read-only" },
|
|
112
|
+
{ commands: ["pwd"], permission: "allow", reason: "print working directory is read-only" },
|
|
113
|
+
{ commands: ["echo"], permission: "allow", reason: "echo is inert" },
|
|
114
|
+
{ commands: ["cat"], permission: "allow", reason: "file read is non-mutating" },
|
|
115
|
+
{ commands: ["head"], permission: "allow", reason: "file read is non-mutating" },
|
|
116
|
+
{ commands: ["tail"], permission: "allow", reason: "file read is non-mutating" },
|
|
117
|
+
{ commands: ["wc"], permission: "allow", reason: "counting is read-only" },
|
|
118
|
+
{ commands: ["which"], permission: "allow", reason: "executable lookup is read-only" },
|
|
119
|
+
{ commands: ["grep"], permission: "allow", denyFlags: ["-o", "--output", "-r", "-R"], reason: "pattern search is read-only" },
|
|
120
|
+
{ commands: ["find"], permission: "allow", denyFlags: ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf"], reason: "filesystem search is read-only" },
|
|
121
|
+
{ commands: ["awk"], permission: "allow", denyFlags: ["-i", "--in-place", "-f", "--file"], denyArgSubstr: ["system(", "print>", "print >", "printf>", "printf >", "fflush", "|getline", "| getline", "getline<", "getline <", ">\"", "> \"", ">>", "|\"", "| \""], reason: "text processing is read-only" },
|
|
122
|
+
{ commands: ["sed"], permission: "allow", denyFlags: ["-i", "--in-place", "-f", "--file"], argGuard: sedArgsAreSafe, reason: "stream editing is read-only when its script writes/executes nothing" },
|
|
123
|
+
{ commands: ["node"], permission: "allow", denyFlags: ["-e", "--eval", "-p", "--print"], reason: "node without an inline eval/print flag is treated as inert" },
|
|
124
|
+
{ commands: ["git", "status"], permission: "allow", reason: "git status is read-only" },
|
|
125
|
+
{ commands: ["git", "log"], permission: "allow", reason: "git log is read-only" },
|
|
126
|
+
{ commands: ["git", "diff"], permission: "allow", reason: "git diff is read-only" },
|
|
127
|
+
{ commands: ["git", "branch"], permission: "allow", reason: "git branch listing is read-only" },
|
|
128
|
+
{ commands: ["git", "show"], permission: "allow", reason: "git show is read-only" },
|
|
129
|
+
/* known-dangerous operations -> actively ask */
|
|
130
|
+
{ cmd: "rm", flags: ["-r", "-f"], aliases: { "-r": ["--recursive", "-R"], "-f": ["--force"] }, permission: "ask", reason: "recursive forced removal is destructive" },
|
|
131
|
+
{ cmd: "git", subcommands: ["push"], permission: "ask", reason: "git push mutates the remote" },
|
|
132
|
+
{ cmd: "chmod", permission: "ask", reason: "permission change is mutating" },
|
|
133
|
+
{ cmd: "chown", permission: "ask", reason: "ownership change is mutating" },
|
|
134
|
+
{ cmd: "kill", permission: "ask", reason: "signalling processes is impactful" },
|
|
135
|
+
{ cmd: "curl", permission: "ask", reason: "network access is side-effecting" },
|
|
136
|
+
{ cmd: "wget", permission: "ask", reason: "network access is side-effecting" }
|
|
137
|
+
];
|
|
138
|
+
/* normalize a leaf command's argument flags into a set of canonical flag
|
|
139
|
+
tokens: split bundled short flags ("-rf" -> "-r","-f"), keep long flags
|
|
140
|
+
("--force") intact, and fold long-form aliases back onto their canonical
|
|
141
|
+
short flag via the rule's "aliases" map (so "--force" counts as "-f") */
|
|
142
|
+
const flagSet = (args, aliases) => {
|
|
143
|
+
const set = new Set();
|
|
144
|
+
for (const arg of args) {
|
|
145
|
+
if (arg.startsWith("--"))
|
|
146
|
+
set.add(arg);
|
|
147
|
+
else if (arg.startsWith("-") && arg.length > 1)
|
|
148
|
+
for (const ch of arg.slice(1))
|
|
149
|
+
set.add("-" + ch);
|
|
150
|
+
}
|
|
151
|
+
if (aliases !== undefined)
|
|
152
|
+
for (const canonical of Object.keys(aliases))
|
|
153
|
+
for (const alias of aliases[canonical])
|
|
154
|
+
if (set.has(alias))
|
|
155
|
+
set.add(canonical);
|
|
156
|
+
return set;
|
|
157
|
+
};
|
|
158
|
+
/* classify a single resolved leaf command (its name plus literal argument
|
|
159
|
+
tokens) against the ordered rule set, returning the first match's
|
|
160
|
+
verdict, or the "passthrough" default when nothing matches */
|
|
161
|
+
const classifyLeaf = (name, args) => {
|
|
162
|
+
for (const rule of RULES) {
|
|
163
|
+
if ("commands" in rule) {
|
|
164
|
+
/* allow rule: flag-aware token-prefix over [name, ...args] */
|
|
165
|
+
const tokens = [name, ...args];
|
|
166
|
+
const literal = tokens.filter((t) => !t.startsWith("-"));
|
|
167
|
+
let matched = true;
|
|
168
|
+
for (let i = 0; i < rule.commands.length; i++)
|
|
169
|
+
if (literal[i] !== rule.commands[i]) {
|
|
170
|
+
matched = false;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
if (!matched)
|
|
174
|
+
continue;
|
|
175
|
+
if (rule.denyFlags !== undefined) {
|
|
176
|
+
const flags = flagSet(args);
|
|
177
|
+
if (rule.denyFlags.some((f) => flags.has(f) || args.includes(f)))
|
|
178
|
+
return "passthrough";
|
|
179
|
+
}
|
|
180
|
+
if (rule.denyArgSubstr !== undefined && args.some((a) => rule.denyArgSubstr.some((s) => a.includes(s))))
|
|
181
|
+
return "passthrough";
|
|
182
|
+
if (rule.argGuard !== undefined && !rule.argGuard(args))
|
|
183
|
+
return "passthrough";
|
|
184
|
+
return "allow";
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
/* risk rule: the command name plus every present predicate
|
|
188
|
+
("subcommands"/"flags"/"argTokens"/"argSubstr") must hold */
|
|
189
|
+
let nameOk = false;
|
|
190
|
+
if (rule.cmd !== undefined)
|
|
191
|
+
nameOk = name === rule.cmd;
|
|
192
|
+
else if (rule.cmdFamily !== undefined)
|
|
193
|
+
nameOk = name === rule.cmdFamily || name.startsWith(rule.cmdFamily + ".");
|
|
194
|
+
if (!nameOk)
|
|
195
|
+
continue;
|
|
196
|
+
if (rule.subcommands !== undefined
|
|
197
|
+
&& !rule.subcommands.every((s) => args.includes(s)))
|
|
198
|
+
continue;
|
|
199
|
+
if (rule.flags !== undefined && rule.flags.length > 0) {
|
|
200
|
+
const flags = flagSet(args, rule.aliases);
|
|
201
|
+
if (!rule.flags.every((f) => flags.has(f)))
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (rule.argTokens !== undefined
|
|
205
|
+
&& !rule.argTokens.every((t) => args.includes(t)))
|
|
206
|
+
continue;
|
|
207
|
+
if (rule.argSubstr !== undefined
|
|
208
|
+
&& !rule.argSubstr.every((s) => args.some((a) => a.includes(s))))
|
|
209
|
+
continue;
|
|
210
|
+
return rule.permission;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return DEFAULT;
|
|
214
|
+
};
|
|
215
|
+
/* combine two verdicts with safety-first precedence
|
|
216
|
+
"deny" > "ask" > "allow" > "passthrough": once any leaf (or hard gate)
|
|
217
|
+
has yielded "deny" the aggregate is "deny", an "ask" wins over a mere
|
|
218
|
+
"allow", and an "allow" only survives when nothing weaker contradicts it */
|
|
219
|
+
const PRECEDENCE = {
|
|
220
|
+
"deny": 3,
|
|
221
|
+
"ask": 2,
|
|
222
|
+
"allow": 1,
|
|
223
|
+
"passthrough": 0
|
|
224
|
+
};
|
|
225
|
+
const combine = (a, b) => PRECEDENCE[a] >= PRECEDENCE[b] ? a : b;
|
|
226
|
+
/* determine whether a word is a plain literal (no expansion parts such as
|
|
227
|
+
"$(...)", "${...}", or process substitution): only such words may name
|
|
228
|
+
an auto-approvable command and only such suffix tokens are trustworthy */
|
|
229
|
+
const isLiteralWord = (word) => {
|
|
230
|
+
if (word.parts === undefined)
|
|
231
|
+
return true;
|
|
232
|
+
return word.parts.every((p) => p.type === "Literal"
|
|
233
|
+
|| p.type === "SingleQuoted" || p.type === "DoubleQuoted");
|
|
234
|
+
};
|
|
235
|
+
/* whether a word part embeds a nested Script (command/process/arithmetic
|
|
236
|
+
substitution) that must itself be traversed and may not be auto-approved */
|
|
237
|
+
const partHasScript = (part) => part.type === "CommandExpansion"
|
|
238
|
+
|| part.type === "ProcessSubstitution"
|
|
239
|
+
|| part.type === "ArithmeticExpansion";
|
|
240
|
+
/* the inert sink paths a write redirect may target without mutating the
|
|
241
|
+
filesystem (so "cmd >/dev/null" stays as harmless as "cmd" itself) */
|
|
242
|
+
const INERT_SINKS = new Set(["/dev/null", "/dev/stdout", "/dev/stderr"]);
|
|
243
|
+
/* classify whether a redirect actually mutates the filesystem (and thus
|
|
244
|
+
must trip the hard safety gate) or is benign for an otherwise-inert
|
|
245
|
+
command. Benign cases: input reads ("<"), heredocs/herestrings
|
|
246
|
+
("<<"/"<<-"/"<<<"), file-descriptor duplications ("<&"/">&", whose
|
|
247
|
+
target is an fd number, not a path), and writes whose literal target is
|
|
248
|
+
a known inert sink ("/dev/null" etc.). Everything else -- writes and
|
|
249
|
+
appends to a real file path, or any write whose target is non-literal
|
|
250
|
+
and thus unverifiable -- is mutating and gates. */
|
|
251
|
+
const redirectMutates = (redirect) => {
|
|
252
|
+
switch (redirect.operator) {
|
|
253
|
+
case "<":
|
|
254
|
+
case "<<":
|
|
255
|
+
case "<<-":
|
|
256
|
+
case "<<<":
|
|
257
|
+
case "<&":
|
|
258
|
+
case ">&":
|
|
259
|
+
/* reads, heredocs, and fd duplications never write a file */
|
|
260
|
+
return false;
|
|
261
|
+
case ">":
|
|
262
|
+
case ">>":
|
|
263
|
+
case ">|":
|
|
264
|
+
case "&>":
|
|
265
|
+
case "&>>":
|
|
266
|
+
/* a write/append mutates unless its literal target is an
|
|
267
|
+
inert sink; a non-literal target is unverifiable -> mutates */
|
|
268
|
+
if (redirect.target !== undefined
|
|
269
|
+
&& isLiteralWord(redirect.target)
|
|
270
|
+
&& INERT_SINKS.has(redirect.target.value))
|
|
271
|
+
return false;
|
|
272
|
+
return true;
|
|
273
|
+
default:
|
|
274
|
+
/* "<>" (read-write) and anything unexpected -> fail safe */
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
/* the Bash classifier: parse the command into an AST and recursively walk
|
|
279
|
+
the complete real "Node" union, classifying each resolved leaf command
|
|
280
|
+
and aggregating the per-leaf verdicts under safety-first precedence and
|
|
281
|
+
the hard safety gates. Wrapped by classifyBash() in a try/catch so any
|
|
282
|
+
parse error, thrown exception, or unmatched leaf fails safe. */
|
|
283
|
+
class Walker {
|
|
284
|
+
state = { verdict: "passthrough", gated: false };
|
|
285
|
+
/* trip a hard safety gate (downgrades any "allow" to "passthrough") */
|
|
286
|
+
gate() {
|
|
287
|
+
this.state.gated = true;
|
|
288
|
+
}
|
|
289
|
+
/* fold a leaf verdict into the running aggregate */
|
|
290
|
+
add(verdict) {
|
|
291
|
+
this.state.verdict = combine(this.state.verdict, verdict);
|
|
292
|
+
}
|
|
293
|
+
/* classify and walk a redirect: only a genuinely-mutating redirect
|
|
294
|
+
(a write/append to a real file) is a hard gate -- benign reads,
|
|
295
|
+
heredocs, fd duplications, and writes to inert sinks ("/dev/null")
|
|
296
|
+
leave an inert command auto-approvable; a redirect target word may
|
|
297
|
+
still embed a nested substitution and is always walked */
|
|
298
|
+
walkRedirect(redirect) {
|
|
299
|
+
if (redirectMutates(redirect))
|
|
300
|
+
this.gate();
|
|
301
|
+
if (redirect.target !== undefined)
|
|
302
|
+
this.walkWord(redirect.target);
|
|
303
|
+
if (redirect.body !== undefined)
|
|
304
|
+
this.walkWord(redirect.body);
|
|
305
|
+
}
|
|
306
|
+
/* walk a single word, descending into every nested Script embedded in
|
|
307
|
+
its parts (command/process/arithmetic substitution); a word carrying
|
|
308
|
+
any such substitution is also a hard gate (never auto-approvable) */
|
|
309
|
+
walkWord(word) {
|
|
310
|
+
if (word.parts === undefined)
|
|
311
|
+
return;
|
|
312
|
+
for (const part of word.parts) {
|
|
313
|
+
if (partHasScript(part))
|
|
314
|
+
this.gate();
|
|
315
|
+
if (part.type === "CommandExpansion" && part.script !== undefined)
|
|
316
|
+
this.walkScript(part.script);
|
|
317
|
+
else if (part.type === "ProcessSubstitution" && part.script !== undefined)
|
|
318
|
+
this.walkScript(part.script);
|
|
319
|
+
else if (part.type === "ArithmeticExpansion" && part.expression !== undefined)
|
|
320
|
+
this.walkArithmetic(part.expression);
|
|
321
|
+
else if (part.type === "ParameterExpansion") {
|
|
322
|
+
/* a parameter expansion may embed a substitution in its
|
|
323
|
+
default/alternate operand or its replace pattern, each a
|
|
324
|
+
nested word that must be walked and gated as well */
|
|
325
|
+
if (part.operand !== undefined) {
|
|
326
|
+
if (!isLiteralWord(part.operand))
|
|
327
|
+
this.gate();
|
|
328
|
+
this.walkWord(part.operand);
|
|
329
|
+
}
|
|
330
|
+
if (part.replace !== undefined) {
|
|
331
|
+
if (!isLiteralWord(part.replace.replacement))
|
|
332
|
+
this.gate();
|
|
333
|
+
this.walkWord(part.replace.pattern);
|
|
334
|
+
this.walkWord(part.replace.replacement);
|
|
335
|
+
}
|
|
336
|
+
if (part.slice !== undefined) {
|
|
337
|
+
this.walkWord(part.slice.offset);
|
|
338
|
+
if (part.slice.length !== undefined)
|
|
339
|
+
this.walkWord(part.slice.length);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else if (part.type === "DoubleQuoted" || part.type === "LocaleString")
|
|
343
|
+
for (const child of part.parts)
|
|
344
|
+
if (child.type === "CommandExpansion" && child.script !== undefined) {
|
|
345
|
+
this.gate();
|
|
346
|
+
this.walkScript(child.script);
|
|
347
|
+
}
|
|
348
|
+
else if (child.type === "ArithmeticExpansion" && child.expression !== undefined) {
|
|
349
|
+
this.gate();
|
|
350
|
+
this.walkArithmetic(child.expression);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/* walk an arithmetic expression, descending into any embedded command
|
|
355
|
+
substitution ("$(( $(cmd) ))") which is itself a hard gate */
|
|
356
|
+
walkArithmetic(expr) {
|
|
357
|
+
switch (expr.type) {
|
|
358
|
+
case "ArithmeticBinary":
|
|
359
|
+
this.walkArithmetic(expr.left);
|
|
360
|
+
this.walkArithmetic(expr.right);
|
|
361
|
+
break;
|
|
362
|
+
case "ArithmeticUnary":
|
|
363
|
+
this.walkArithmetic(expr.operand);
|
|
364
|
+
break;
|
|
365
|
+
case "ArithmeticTernary":
|
|
366
|
+
this.walkArithmetic(expr.test);
|
|
367
|
+
this.walkArithmetic(expr.consequent);
|
|
368
|
+
this.walkArithmetic(expr.alternate);
|
|
369
|
+
break;
|
|
370
|
+
case "ArithmeticGroup":
|
|
371
|
+
this.walkArithmetic(expr.expression);
|
|
372
|
+
break;
|
|
373
|
+
case "ArithmeticWord":
|
|
374
|
+
break;
|
|
375
|
+
case "ArithmeticCommandExpansion":
|
|
376
|
+
this.gate();
|
|
377
|
+
if (expr.script !== undefined)
|
|
378
|
+
this.walkScript(expr.script);
|
|
379
|
+
break;
|
|
380
|
+
default:
|
|
381
|
+
/* unknown arithmetic node -> fail safe (gate, do not approve) */
|
|
382
|
+
this.gate();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/* walk a risky assignment prefix ("FOO=$(cmd) cmd"): a prefix that
|
|
386
|
+
assigns a substituted value is a hard gate and is itself traversed */
|
|
387
|
+
walkPrefix(prefix) {
|
|
388
|
+
if (prefix.value !== undefined) {
|
|
389
|
+
if (!isLiteralWord(prefix.value))
|
|
390
|
+
this.gate();
|
|
391
|
+
this.walkWord(prefix.value);
|
|
392
|
+
}
|
|
393
|
+
if (prefix.array !== undefined)
|
|
394
|
+
for (const word of prefix.array) {
|
|
395
|
+
if (!isLiteralWord(word))
|
|
396
|
+
this.gate();
|
|
397
|
+
this.walkWord(word);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/* classify a leaf "Command" node: unwrap transparent wrappers and
|
|
401
|
+
privilege escalators, walk every suffix word for embedded scripts,
|
|
402
|
+
require a plain-literal command name, and classify the resolved
|
|
403
|
+
name plus literal suffix tokens against the rule engine */
|
|
404
|
+
walkCommand(command) {
|
|
405
|
+
/* redirects and risky assignment prefixes are hard gates */
|
|
406
|
+
for (const redirect of command.redirects)
|
|
407
|
+
this.walkRedirect(redirect);
|
|
408
|
+
for (const prefix of command.prefix)
|
|
409
|
+
this.walkPrefix(prefix);
|
|
410
|
+
/* a command without a literal name cannot be auto-approved */
|
|
411
|
+
if (command.name === undefined)
|
|
412
|
+
return;
|
|
413
|
+
if (!isLiteralWord(command.name))
|
|
414
|
+
this.gate();
|
|
415
|
+
/* every suffix word is walked for embedded substitutions */
|
|
416
|
+
for (const word of command.suffix)
|
|
417
|
+
this.walkWord(word);
|
|
418
|
+
/* reconstruct the literal "[name, ...args]" token stream, stopping
|
|
419
|
+
the trustworthy literal args at the first non-literal word */
|
|
420
|
+
let name = command.name.value;
|
|
421
|
+
const args = [];
|
|
422
|
+
for (const word of command.suffix) {
|
|
423
|
+
if (!isLiteralWord(word))
|
|
424
|
+
break;
|
|
425
|
+
args.push(word.value);
|
|
426
|
+
}
|
|
427
|
+
/* unwrap transparent wrappers ("env"/"xargs"/...) and privilege
|
|
428
|
+
escalators ("sudo"/"doas") to the real inner command; privilege
|
|
429
|
+
escalation caps the verdict at "ask" but still classifies inner */
|
|
430
|
+
let privileged = false;
|
|
431
|
+
while (WRAPPERS.has(name) || PRIVILEGE.has(name)) {
|
|
432
|
+
if (PRIVILEGE.has(name))
|
|
433
|
+
privileged = true;
|
|
434
|
+
/* drop the wrapper's own flags and any "VAR=val" arguments,
|
|
435
|
+
then take the next bare token as the inner command name */
|
|
436
|
+
let i = 0;
|
|
437
|
+
while (i < args.length && (args[i].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(args[i])))
|
|
438
|
+
i++;
|
|
439
|
+
if (i >= args.length) {
|
|
440
|
+
/* bare wrapper with no inner command -> nothing to approve */
|
|
441
|
+
name = "";
|
|
442
|
+
args.length = 0;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
name = args[i];
|
|
446
|
+
args.splice(0, i + 1);
|
|
447
|
+
}
|
|
448
|
+
if (name === "")
|
|
449
|
+
return;
|
|
450
|
+
/* classify the resolved leaf and fold in the verdict, capping at
|
|
451
|
+
"ask" when a privilege escalator wrapped this command */
|
|
452
|
+
let verdict = classifyLeaf(name, args);
|
|
453
|
+
if (privileged && PRECEDENCE[verdict] < PRECEDENCE["ask"])
|
|
454
|
+
verdict = "ask";
|
|
455
|
+
this.add(verdict);
|
|
456
|
+
}
|
|
457
|
+
/* recursively visit the complete real "Node" union via an exhaustive
|
|
458
|
+
switch with a TypeScript "never" exhaustiveness guard, so a future
|
|
459
|
+
unbash node type fails the build rather than slipping through */
|
|
460
|
+
walkNode(node) {
|
|
461
|
+
switch (node.type) {
|
|
462
|
+
case "Command":
|
|
463
|
+
this.walkCommand(node);
|
|
464
|
+
break;
|
|
465
|
+
case "Pipeline":
|
|
466
|
+
for (const cmd of node.commands)
|
|
467
|
+
this.walkNode(cmd);
|
|
468
|
+
break;
|
|
469
|
+
case "AndOr":
|
|
470
|
+
for (const cmd of node.commands)
|
|
471
|
+
this.walkNode(cmd);
|
|
472
|
+
break;
|
|
473
|
+
case "If":
|
|
474
|
+
this.walkNode(node.clause);
|
|
475
|
+
this.walkNode(node.then);
|
|
476
|
+
if (node.else !== undefined)
|
|
477
|
+
this.walkNode(node.else);
|
|
478
|
+
break;
|
|
479
|
+
case "For":
|
|
480
|
+
for (const word of node.wordlist)
|
|
481
|
+
this.walkWord(word);
|
|
482
|
+
this.walkNode(node.body);
|
|
483
|
+
break;
|
|
484
|
+
case "ArithmeticFor":
|
|
485
|
+
if (node.initialize !== undefined)
|
|
486
|
+
this.walkArithmetic(node.initialize);
|
|
487
|
+
if (node.test !== undefined)
|
|
488
|
+
this.walkArithmetic(node.test);
|
|
489
|
+
if (node.update !== undefined)
|
|
490
|
+
this.walkArithmetic(node.update);
|
|
491
|
+
this.walkNode(node.body);
|
|
492
|
+
break;
|
|
493
|
+
case "Select":
|
|
494
|
+
for (const word of node.wordlist)
|
|
495
|
+
this.walkWord(word);
|
|
496
|
+
this.walkNode(node.body);
|
|
497
|
+
break;
|
|
498
|
+
case "While":
|
|
499
|
+
this.walkNode(node.clause);
|
|
500
|
+
this.walkNode(node.body);
|
|
501
|
+
break;
|
|
502
|
+
case "Function":
|
|
503
|
+
this.walkNode(node.body);
|
|
504
|
+
for (const redirect of node.redirects)
|
|
505
|
+
this.walkRedirect(redirect);
|
|
506
|
+
break;
|
|
507
|
+
case "Subshell":
|
|
508
|
+
this.walkNode(node.body);
|
|
509
|
+
break;
|
|
510
|
+
case "BraceGroup":
|
|
511
|
+
this.walkNode(node.body);
|
|
512
|
+
break;
|
|
513
|
+
case "CompoundList":
|
|
514
|
+
this.walkCompoundList(node);
|
|
515
|
+
break;
|
|
516
|
+
case "Case":
|
|
517
|
+
this.walkWord(node.word);
|
|
518
|
+
for (const item of node.items)
|
|
519
|
+
this.walkNode(item.body);
|
|
520
|
+
break;
|
|
521
|
+
case "Coproc":
|
|
522
|
+
this.walkNode(node.body);
|
|
523
|
+
for (const redirect of node.redirects)
|
|
524
|
+
this.walkRedirect(redirect);
|
|
525
|
+
break;
|
|
526
|
+
case "TestCommand":
|
|
527
|
+
/* test expressions take no auto-approval verdict, but their
|
|
528
|
+
operand words may embed a substitution that must be walked
|
|
529
|
+
and gated, so descend into the whole test expression */
|
|
530
|
+
this.walkTest(node.expression);
|
|
531
|
+
break;
|
|
532
|
+
case "ArithmeticCommand":
|
|
533
|
+
if (node.expression !== undefined)
|
|
534
|
+
this.walkArithmetic(node.expression);
|
|
535
|
+
break;
|
|
536
|
+
case "Statement":
|
|
537
|
+
this.walkStatement(node);
|
|
538
|
+
break;
|
|
539
|
+
default:
|
|
540
|
+
/* exhaustiveness guard: a future unbash node type makes
|
|
541
|
+
this a compile-time error; at runtime, fail safe */
|
|
542
|
+
this.exhaustive(node);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/* walk a "Statement": background "&" and statement-level redirects are
|
|
546
|
+
hard gates, then descend into the wrapped command node */
|
|
547
|
+
walkStatement(statement) {
|
|
548
|
+
if (statement.background === true)
|
|
549
|
+
this.gate();
|
|
550
|
+
for (const redirect of statement.redirects)
|
|
551
|
+
this.walkRedirect(redirect);
|
|
552
|
+
this.walkNode(statement.command);
|
|
553
|
+
}
|
|
554
|
+
/* walk a "TestCommand" expression, descending into every operand
|
|
555
|
+
word so an embedded substitution is gated and never auto-approved */
|
|
556
|
+
walkTest(expr) {
|
|
557
|
+
switch (expr.type) {
|
|
558
|
+
case "TestUnary":
|
|
559
|
+
this.walkWord(expr.operand);
|
|
560
|
+
break;
|
|
561
|
+
case "TestBinary":
|
|
562
|
+
this.walkWord(expr.left);
|
|
563
|
+
this.walkWord(expr.right);
|
|
564
|
+
break;
|
|
565
|
+
case "TestLogical":
|
|
566
|
+
this.walkTest(expr.left);
|
|
567
|
+
this.walkTest(expr.right);
|
|
568
|
+
break;
|
|
569
|
+
case "TestNot":
|
|
570
|
+
this.walkTest(expr.operand);
|
|
571
|
+
break;
|
|
572
|
+
case "TestGroup":
|
|
573
|
+
this.walkTest(expr.expression);
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/* walk a "CompoundList" (a sequence of statements) */
|
|
578
|
+
walkCompoundList(list) {
|
|
579
|
+
for (const statement of list.commands)
|
|
580
|
+
this.walkNode(statement);
|
|
581
|
+
}
|
|
582
|
+
/* walk a parsed "Script" (the top-level and every nested one) */
|
|
583
|
+
walkScript(script) {
|
|
584
|
+
for (const statement of script.commands)
|
|
585
|
+
this.walkNode(statement);
|
|
586
|
+
}
|
|
587
|
+
/* compile-time exhaustiveness assertion; the "never" parameter makes
|
|
588
|
+
an unhandled node type a build error, while the runtime gate keeps
|
|
589
|
+
an unexpected node from ever being auto-approved */
|
|
590
|
+
exhaustive(_node) {
|
|
591
|
+
this.gate();
|
|
592
|
+
}
|
|
593
|
+
/* classify a whole parsed script and return the aggregate verdict,
|
|
594
|
+
downgrading any "allow" to "passthrough" when a hard gate tripped */
|
|
595
|
+
classify(script) {
|
|
596
|
+
this.walkScript(script);
|
|
597
|
+
if (this.state.gated && this.state.verdict === "allow")
|
|
598
|
+
return "passthrough";
|
|
599
|
+
return this.state.verdict;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/* classify a raw "Bash" command string into an "allow"/"ask"/"deny"/
|
|
603
|
+
"passthrough" verdict. The whole classification is fail-safe: any parse
|
|
604
|
+
error, thrown exception, or unmatched/unexpected construct yields
|
|
605
|
+
"passthrough" so the host agent's default prompt flow takes over.
|
|
606
|
+
Never crashes, never auto-approves on doubt. */
|
|
607
|
+
export const classifyBash = (command) => {
|
|
608
|
+
try {
|
|
609
|
+
const script = parse(command);
|
|
610
|
+
if (script.errors !== undefined && script.errors.length > 0)
|
|
611
|
+
return "passthrough";
|
|
612
|
+
return new Walker().classify(script);
|
|
613
|
+
}
|
|
614
|
+
catch (_e) {
|
|
615
|
+
/* fail safe on any unexpected parser or walker error */
|
|
616
|
+
return "passthrough";
|
|
617
|
+
}
|
|
618
|
+
};
|
package/dst/ase-hook.js
CHANGED
|
@@ -11,6 +11,27 @@ import { quote } from "shell-quote";
|
|
|
11
11
|
import * as v from "valibot";
|
|
12
12
|
import Version from "./ase-version.js";
|
|
13
13
|
import { Config, configSchema, parseScope } from "./ase-config.js";
|
|
14
|
+
const addonMcpServers = [
|
|
15
|
+
"chat-alibaba-qwen",
|
|
16
|
+
"chat-deepseek",
|
|
17
|
+
"chat-google-gemini",
|
|
18
|
+
"chat-openai-chatgpt",
|
|
19
|
+
"chat-xai-grok",
|
|
20
|
+
"chat-zai-glm",
|
|
21
|
+
"search-brave",
|
|
22
|
+
"search-exa",
|
|
23
|
+
"search-perplexity"
|
|
24
|
+
];
|
|
25
|
+
/* build a per-tool regular expression matching the tool names exposed
|
|
26
|
+
by the addon MCP servers: Claude Code prefixes them as
|
|
27
|
+
"mcp__<server>__<tool>", whereas Copilot CLI prefixes them as
|
|
28
|
+
"<server>-<tool>" */
|
|
29
|
+
const addonMcpToolNamePattern = (prefix, suffix) => {
|
|
30
|
+
const alternatives = addonMcpServers
|
|
31
|
+
.map((server) => server.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
32
|
+
.join("|");
|
|
33
|
+
return new RegExp(`^${prefix}(?:${alternatives})${suffix}`);
|
|
34
|
+
};
|
|
14
35
|
const toolSpecs = {
|
|
15
36
|
"claude": {
|
|
16
37
|
toolNameField: "tool_name",
|
|
@@ -18,6 +39,7 @@ const toolSpecs = {
|
|
|
18
39
|
toolInputIsString: false,
|
|
19
40
|
bashToolName: "Bash",
|
|
20
41
|
mcpToolNamePattern: /^mcp__plugin_ase_ase__.+/,
|
|
42
|
+
addonMcpToolNamePattern: addonMcpToolNamePattern("mcp__", "__.+"),
|
|
21
43
|
preToolUseWrapped: true,
|
|
22
44
|
preToolUseEvent: "PreToolUse"
|
|
23
45
|
},
|
|
@@ -27,6 +49,7 @@ const toolSpecs = {
|
|
|
27
49
|
toolInputIsString: true,
|
|
28
50
|
bashToolName: "bash",
|
|
29
51
|
mcpToolNamePattern: /^ase-.+/,
|
|
52
|
+
addonMcpToolNamePattern: addonMcpToolNamePattern("", "-.+"),
|
|
30
53
|
preToolUseWrapped: false,
|
|
31
54
|
preToolUseEvent: "preToolUse"
|
|
32
55
|
}
|
|
@@ -347,6 +370,10 @@ export default class HookCommand {
|
|
|
347
370
|
approve = true;
|
|
348
371
|
reason = "ASE MCP tool invocation auto-approved";
|
|
349
372
|
}
|
|
373
|
+
else if (spec.addonMcpToolNamePattern.test(toolName)) {
|
|
374
|
+
approve = true;
|
|
375
|
+
reason = "ASE addon MCP tool invocation auto-approved";
|
|
376
|
+
}
|
|
350
377
|
else if (toolName === "Edit") {
|
|
351
378
|
const sessionId = this.pickSessionId(input);
|
|
352
379
|
const activeSkill = this.readActiveSkill(sessionId);
|