@ory/argus 1.2.2 → 1.2.4
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/dist/bash-parser.js +212 -47
- package/dist/build-info.json +4 -4
- package/dist/contract-suite.js +6 -1
- package/dist/permission-mode.d.ts +11 -0
- package/dist/permission-mode.js +135 -36
- package/dist/permissions.d.ts +3 -3
- package/dist/permissions.js +123 -11
- package/dist/testing.d.ts +12 -4
- package/dist/testing.js +21 -5
- package/dist/tool-catalog.d.ts +10 -8
- package/dist/tool-catalog.js +39 -8
- package/package.json +1 -1
package/dist/bash-parser.js
CHANGED
|
@@ -46,12 +46,75 @@ const LITERAL_LEAF_TYPES = new Set(["word", "string_content", "raw_string"]);
|
|
|
46
46
|
*/
|
|
47
47
|
const WRAPPERS = new Set([
|
|
48
48
|
"sudo", "doas", "env", "nohup", "setsid", "command", "exec", "nice",
|
|
49
|
-
"ionice", "chrt", "stdbuf", "time", "timeout", "xargs",
|
|
49
|
+
"ionice", "chrt", "stdbuf", "time", "timeout", "xargs", "builtin",
|
|
50
50
|
]);
|
|
51
|
-
/**
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
/** Wrapper options whose following token is an option value, not a command. */
|
|
52
|
+
const WRAPPER_OPTIONS_WITH_VALUES = {
|
|
53
|
+
doas: new Set(["-a", "-C", "-u"]),
|
|
54
|
+
sudo: new Set([
|
|
55
|
+
"-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt",
|
|
56
|
+
"-C", "--close-from", "-T", "--command-timeout", "-R", "--chroot",
|
|
57
|
+
"-D", "--chdir", "-r", "--role", "-t", "--type",
|
|
58
|
+
]),
|
|
59
|
+
env: new Set([
|
|
60
|
+
"-u", "--unset", "-C", "--chdir",
|
|
61
|
+
]),
|
|
62
|
+
nice: new Set(["-n", "--adjustment"]),
|
|
63
|
+
exec: new Set(["-a"]),
|
|
64
|
+
ionice: new Set([
|
|
65
|
+
"-c", "--class", "-n", "--classdata", "-p", "--pid", "-P", "--pgid",
|
|
66
|
+
"-u", "--uid",
|
|
67
|
+
]),
|
|
68
|
+
timeout: new Set(["-s", "--signal", "-k", "--kill-after"]),
|
|
69
|
+
stdbuf: new Set(["-i", "--input", "-o", "--output", "-e", "--error"]),
|
|
70
|
+
xargs: new Set([
|
|
71
|
+
"-a", "--arg-file", "-E", "--eof", "-I", "--replace", "-L",
|
|
72
|
+
"--max-lines", "-n", "--max-args", "-P", "--max-procs", "-s",
|
|
73
|
+
"--max-chars",
|
|
74
|
+
]),
|
|
75
|
+
};
|
|
76
|
+
/** Common standalone options that do not consume the following command token. */
|
|
77
|
+
const WRAPPER_OPTIONS_WITHOUT_VALUES = {
|
|
78
|
+
doas: new Set(["-L", "-n", "-s"]),
|
|
79
|
+
sudo: new Set([
|
|
80
|
+
"-A", "--askpass", "-b", "--background", "-E", "--preserve-env",
|
|
81
|
+
"-H", "--set-home", "-K", "-k", "--reset-timestamp", "-N",
|
|
82
|
+
"--no-update", "-n", "--non-interactive", "-S", "--stdin", "-V",
|
|
83
|
+
"--version", "-v", "--validate",
|
|
84
|
+
]),
|
|
85
|
+
env: new Set([
|
|
86
|
+
"-i", "--ignore-environment", "-0", "--null", "-v", "--debug",
|
|
87
|
+
"--ignore-signal", "--default-signal", "--block-signal",
|
|
88
|
+
]),
|
|
89
|
+
command: new Set(["-p", "-v", "-V"]),
|
|
90
|
+
chrt: new Set([
|
|
91
|
+
"-a", "--all-tasks", "-b", "--batch", "-d", "--deadline", "-f", "--fifo",
|
|
92
|
+
"-i", "--idle", "-m", "--max", "-o", "--other", "-r", "--rr",
|
|
93
|
+
]),
|
|
94
|
+
exec: new Set(["-c", "-l"]),
|
|
95
|
+
setsid: new Set(["-c", "--ctty", "-f", "--fork", "-w", "--wait"]),
|
|
96
|
+
time: new Set(["-p"]),
|
|
97
|
+
timeout: new Set(["--foreground", "--preserve-status", "-v", "--verbose"]),
|
|
98
|
+
xargs: new Set([
|
|
99
|
+
"-0", "--null", "-r", "--no-run-if-empty", "-t", "--verbose", "-x",
|
|
100
|
+
"--exit",
|
|
101
|
+
]),
|
|
102
|
+
};
|
|
103
|
+
const WRAPPER_OPTIONS_WITH_ATTACHED_VALUES = {
|
|
104
|
+
doas: /^-[aCu].+/,
|
|
105
|
+
sudo: /^-[ughCpTDRrt].+/,
|
|
106
|
+
nice: /^-(?:n.+|\d+)$/,
|
|
107
|
+
exec: /^-a.+/,
|
|
108
|
+
ionice: /^-[cnpPu].+/,
|
|
109
|
+
timeout: /^-[sk].+/,
|
|
110
|
+
stdbuf: /^-[ioe].+/,
|
|
111
|
+
xargs: /^-[ELnPs].+/,
|
|
112
|
+
};
|
|
113
|
+
/** Wrappers with a required positional operand before the wrapped command. */
|
|
114
|
+
const WRAPPER_POSITIONAL_OPERANDS = {
|
|
115
|
+
timeout: 1,
|
|
116
|
+
chrt: 1,
|
|
117
|
+
};
|
|
55
118
|
/** Shells whose `-c "<script>"` argument is re-parsed as a nested command. */
|
|
56
119
|
const SHELL_DASH_C = new Set(["sh", "bash", "dash", "zsh", "ksh", "ash"]);
|
|
57
120
|
/** Cap re-parse recursion (`sh -c "sh -c …"`) so a pathological input can't loop. */
|
|
@@ -122,6 +185,8 @@ function lastLeaf(node) {
|
|
|
122
185
|
function literalText(node) {
|
|
123
186
|
switch (node.type) {
|
|
124
187
|
case "word":
|
|
188
|
+
// Unquoted globs depend on the runtime filesystem and are not literal.
|
|
189
|
+
return /[*?[]/.test(node.text) ? null : node.text;
|
|
125
190
|
case "number":
|
|
126
191
|
return node.text;
|
|
127
192
|
case "raw_string":
|
|
@@ -150,10 +215,10 @@ function literalText(node) {
|
|
|
150
215
|
function resolveCommandName(nameNode) {
|
|
151
216
|
const child = nameNode.namedChild(0);
|
|
152
217
|
if (!child)
|
|
153
|
-
return basename(nameNode.text);
|
|
218
|
+
return /[*?[]/.test(nameNode.text) ? null : basename(nameNode.text);
|
|
154
219
|
switch (child.type) {
|
|
155
220
|
case "word":
|
|
156
|
-
return basename(child.text);
|
|
221
|
+
return /[*?[]/.test(child.text) ? null : basename(child.text);
|
|
157
222
|
case "raw_string":
|
|
158
223
|
return basename(stripQuotes(child.text));
|
|
159
224
|
case "simple_expansion":
|
|
@@ -227,20 +292,55 @@ function collectRedirects(cmd) {
|
|
|
227
292
|
* {@link ShellCommand} for that command (and recursing through nested wrappers,
|
|
228
293
|
* e.g. `sudo env A=1 curl`).
|
|
229
294
|
*/
|
|
230
|
-
function descendWrapper(wrapper, args, pipeline, state) {
|
|
231
|
-
|
|
295
|
+
function descendWrapper(wrapper, args, pipeline, parser, depth, state) {
|
|
296
|
+
let positionalOperands = WRAPPER_POSITIONAL_OPERANDS[wrapper] ?? 0;
|
|
297
|
+
let optionsEnded = false;
|
|
232
298
|
for (let i = 0; i < args.length; i++) {
|
|
233
299
|
const txt = literalText(args[i]);
|
|
234
300
|
if (txt === null) {
|
|
235
301
|
state.tooComplex = true;
|
|
236
302
|
return;
|
|
237
303
|
}
|
|
238
|
-
if (
|
|
304
|
+
if (wrapper === "env" && /^--s[^=]*=/.test(txt)) {
|
|
305
|
+
state.tooComplex = true;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (wrapper === "env" && (txt === "-S" || txt === "--split-string")) {
|
|
309
|
+
// env's split-string language is not shell syntax. Parsing it with the
|
|
310
|
+
// Bash grammar can reinterpret comments and quoting, so fail closed.
|
|
311
|
+
state.tooComplex = true;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!optionsEnded && txt.startsWith("-")) {
|
|
315
|
+
if (txt === "--") {
|
|
316
|
+
optionsEnded = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (txt.includes("="))
|
|
320
|
+
continue;
|
|
321
|
+
if (WRAPPER_OPTIONS_WITH_ATTACHED_VALUES[wrapper]?.test(txt))
|
|
322
|
+
continue;
|
|
323
|
+
if (WRAPPER_OPTIONS_WITH_VALUES[wrapper]?.has(txt)) {
|
|
324
|
+
if (!args[i + 1] || literalText(args[i + 1]) === null) {
|
|
325
|
+
state.tooComplex = true;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
i++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (!WRAPPER_OPTIONS_WITHOUT_VALUES[wrapper]?.has(txt)) {
|
|
332
|
+
// An unknown option may consume the next token. Do not guess which
|
|
333
|
+
// token is the command because a wrong guess would silently bypass it.
|
|
334
|
+
state.tooComplex = true;
|
|
335
|
+
}
|
|
239
336
|
continue;
|
|
337
|
+
}
|
|
240
338
|
if (/^[A-Za-z_]\w*=/.test(txt))
|
|
241
339
|
continue;
|
|
242
|
-
if (
|
|
340
|
+
if (positionalOperands > 0) {
|
|
341
|
+
positionalOperands--;
|
|
243
342
|
continue;
|
|
343
|
+
}
|
|
244
344
|
const wrapped = basename(txt);
|
|
245
345
|
if (wrapped.length === 0)
|
|
246
346
|
return;
|
|
@@ -252,11 +352,92 @@ function descendWrapper(wrapper, args, pipeline, state) {
|
|
|
252
352
|
redirects: [],
|
|
253
353
|
pipeline,
|
|
254
354
|
});
|
|
255
|
-
if (
|
|
256
|
-
|
|
355
|
+
if (SHELL_DASH_C.has(wrapped) && state.pipelineIds.has(pipeline)) {
|
|
356
|
+
if (shellReadsPipelineInput(rest))
|
|
357
|
+
state.tooComplex = true;
|
|
358
|
+
}
|
|
359
|
+
if (WRAPPERS.has(wrapped)) {
|
|
360
|
+
descendWrapper(wrapped, rest, pipeline, parser, depth, state);
|
|
361
|
+
}
|
|
362
|
+
inspectLiteralScriptInvocation(wrapped, rest, parser, state, depth);
|
|
257
363
|
return;
|
|
258
364
|
}
|
|
259
365
|
}
|
|
366
|
+
function shellReadsPipelineInput(args) {
|
|
367
|
+
for (let i = 0; i < args.length; i++) {
|
|
368
|
+
const arg = literalText(args[i]);
|
|
369
|
+
if (arg === null)
|
|
370
|
+
return true;
|
|
371
|
+
if (arg === "--")
|
|
372
|
+
return i + 1 >= args.length;
|
|
373
|
+
if (arg === "-O" || arg === "-o" || arg === "--init-file" || arg === "--rcfile") {
|
|
374
|
+
i++;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (arg.startsWith("-")) {
|
|
378
|
+
if (/^-[^-]*c/.test(arg))
|
|
379
|
+
return false;
|
|
380
|
+
if (/^-[^-]*s/.test(arg))
|
|
381
|
+
return true;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const normalized = arg.startsWith("/")
|
|
385
|
+
? `/${arg.split("/").reduce((parts, part) => {
|
|
386
|
+
if (!part || part === ".")
|
|
387
|
+
return parts;
|
|
388
|
+
if (part === "..")
|
|
389
|
+
parts.pop();
|
|
390
|
+
else
|
|
391
|
+
parts.push(part);
|
|
392
|
+
return parts;
|
|
393
|
+
}, []).join("/")}`
|
|
394
|
+
: arg;
|
|
395
|
+
if (arg === "-"
|
|
396
|
+
|| normalized === "/dev/stdin"
|
|
397
|
+
|| /^\/dev\/fd\/0+$/.test(normalized)
|
|
398
|
+
|| /^\/proc\/self\/fd\/0+$/.test(normalized))
|
|
399
|
+
return true;
|
|
400
|
+
// A positional operand is a script file, so pipeline stdin is data rather
|
|
401
|
+
// than shell source and does not hide additional command words.
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
function inspectLiteralScriptInvocation(name, args, parser, state, depth) {
|
|
407
|
+
if (SHELL_DASH_C.has(name)) {
|
|
408
|
+
for (let i = 0; i < args.length; i++) {
|
|
409
|
+
const text = literalText(args[i]);
|
|
410
|
+
if (text === null) {
|
|
411
|
+
state.tooComplex = true;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
if (text === "--" || !text.startsWith("-"))
|
|
415
|
+
break;
|
|
416
|
+
if (text === "-O" || text === "-o" || text === "--init-file" || text === "--rcfile") {
|
|
417
|
+
i++;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (/^-[^-]*c/.test(text)) {
|
|
421
|
+
const target = args[i + 1];
|
|
422
|
+
const script = target ? literalText(target) : null;
|
|
423
|
+
if (script === null)
|
|
424
|
+
state.tooComplex = true;
|
|
425
|
+
else
|
|
426
|
+
reparse(script, parser, state, depth + 1);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (name === "eval") {
|
|
432
|
+
const parts = args.map(literalText);
|
|
433
|
+
if (parts.length === 0 || parts.some((part) => part === null)) {
|
|
434
|
+
state.tooComplex = true;
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
reparse(parts.join(" "), parser, state, depth + 1);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
260
441
|
/** Recursively walk the tree, building the command model. */
|
|
261
442
|
function walk(node, pipeline, parser, state, depth, treeDepth) {
|
|
262
443
|
// Bound recursion so a maliciously deep AST can't overflow the stack (which
|
|
@@ -267,6 +448,7 @@ function walk(node, pipeline, parser, state, depth, treeDepth) {
|
|
|
267
448
|
}
|
|
268
449
|
if (node.type === "pipeline") {
|
|
269
450
|
const id = state.pipelineCounter++;
|
|
451
|
+
state.pipelineIds.add(id);
|
|
270
452
|
for (const child of node.children)
|
|
271
453
|
walk(child, id, parser, state, depth, treeDepth + 1);
|
|
272
454
|
return;
|
|
@@ -291,41 +473,15 @@ function walk(node, pipeline, parser, state, depth, treeDepth) {
|
|
|
291
473
|
redirects: collectRedirects(node),
|
|
292
474
|
pipeline: pid,
|
|
293
475
|
});
|
|
294
|
-
if (name && WRAPPERS.has(name))
|
|
295
|
-
descendWrapper(name, args, pid, state);
|
|
296
|
-
if (name && SHELL_DASH_C.has(name)) {
|
|
297
|
-
const flagIdx = args.findIndex((a) => {
|
|
298
|
-
const t = literalText(a);
|
|
299
|
-
return t !== null && t.startsWith("-") && t.includes("c");
|
|
300
|
-
});
|
|
301
|
-
if (flagIdx >= 0) {
|
|
302
|
-
const target = args[flagIdx + 1];
|
|
303
|
-
if (!target)
|
|
304
|
-
state.tooComplex = true;
|
|
305
|
-
else {
|
|
306
|
-
const script = literalText(target);
|
|
307
|
-
if (script === null)
|
|
308
|
-
state.tooComplex = true;
|
|
309
|
-
else
|
|
310
|
-
reparse(script, parser, state, depth + 1);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
476
|
+
if (name && WRAPPERS.has(name)) {
|
|
477
|
+
descendWrapper(name, args, pid, parser, depth, state);
|
|
313
478
|
}
|
|
314
|
-
if (name
|
|
315
|
-
|
|
316
|
-
let dynamic = false;
|
|
317
|
-
for (const a of args) {
|
|
318
|
-
const t = literalText(a);
|
|
319
|
-
if (t === null)
|
|
320
|
-
dynamic = true;
|
|
321
|
-
else
|
|
322
|
-
parts.push(t);
|
|
323
|
-
}
|
|
324
|
-
if (dynamic || parts.length === 0)
|
|
479
|
+
if (name && SHELL_DASH_C.has(name) && state.pipelineIds.has(pid)) {
|
|
480
|
+
if (shellReadsPipelineInput(args))
|
|
325
481
|
state.tooComplex = true;
|
|
326
|
-
else
|
|
327
|
-
reparse(parts.join(" "), parser, state, depth + 1);
|
|
328
482
|
}
|
|
483
|
+
if (name)
|
|
484
|
+
inspectLiteralScriptInvocation(name, args, parser, state, depth);
|
|
329
485
|
// Descend into arguments for nested command substitutions ($(curl …)).
|
|
330
486
|
for (const child of node.children)
|
|
331
487
|
walk(child, -1, parser, state, depth, treeDepth + 1);
|
|
@@ -337,7 +493,11 @@ function walk(node, pipeline, parser, state, depth, treeDepth) {
|
|
|
337
493
|
}
|
|
338
494
|
/** Parse a nested script string and fold its commands into `state`. */
|
|
339
495
|
function reparse(script, parser, state, depth) {
|
|
340
|
-
if (depth > MAX_REPARSE_DEPTH
|
|
496
|
+
if (depth > MAX_REPARSE_DEPTH) {
|
|
497
|
+
state.tooComplex = true;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (script.trim().length === 0)
|
|
341
501
|
return;
|
|
342
502
|
const tree = parser.parse(script);
|
|
343
503
|
if (!tree) {
|
|
@@ -370,7 +530,12 @@ async function extractShellCommands(command) {
|
|
|
370
530
|
}
|
|
371
531
|
if (!tree)
|
|
372
532
|
return { commands: [], tooComplex: true };
|
|
373
|
-
const state = {
|
|
533
|
+
const state = {
|
|
534
|
+
commands: [],
|
|
535
|
+
tooComplex: false,
|
|
536
|
+
pipelineCounter: 0,
|
|
537
|
+
pipelineIds: new Set(),
|
|
538
|
+
};
|
|
374
539
|
if (command.includes("\\\n"))
|
|
375
540
|
state.tooComplex = true;
|
|
376
541
|
if (tree.rootNode.hasError)
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repo": "ory-agent-plugins",
|
|
3
|
-
"commit": "
|
|
4
|
-
"commitShort": "
|
|
3
|
+
"commit": "8e70fe38343bc8599c65c5e1bd4af54861f633be",
|
|
4
|
+
"commitShort": "8e70fe3",
|
|
5
5
|
"branch": "main",
|
|
6
|
-
"commitDate": "2026-09-
|
|
6
|
+
"commitDate": "2026-09-14T08:17:01-07:00",
|
|
7
7
|
"dirty": false,
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-14T15:20:26.275Z"
|
|
9
9
|
}
|
package/dist/contract-suite.js
CHANGED
|
@@ -205,7 +205,12 @@ function runHarnessContractSuite(adapter) {
|
|
|
205
205
|
const observeEvents = (0, testing_js_1.getActivityEvents)(client, "permission.observe_deny");
|
|
206
206
|
const blockObservedEvents = (0, testing_js_1.getActivityEvents)(client, "permission.block_observed");
|
|
207
207
|
(0, vitest_1.expect)(observeEvents).toHaveLength(0);
|
|
208
|
-
|
|
208
|
+
if (check === "denied" && mode === "observe") {
|
|
209
|
+
(0, vitest_1.expect)(blockObservedEvents.length).toBeGreaterThanOrEqual(1);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
(0, vitest_1.expect)(blockObservedEvents).toHaveLength(0);
|
|
213
|
+
}
|
|
209
214
|
});
|
|
210
215
|
(0, vitest_1.it)("records tool.invoke when an interactive tool proceeds", async () => {
|
|
211
216
|
process.env.ORY_INTERACTIVE_TOOLS = adapter.tool;
|
|
@@ -61,8 +61,19 @@ export interface AdditionalModeSubject {
|
|
|
61
61
|
subject: ModeSubject;
|
|
62
62
|
scope: "agent" | "subagent";
|
|
63
63
|
}
|
|
64
|
+
export interface PermissionModeBatchPlan {
|
|
65
|
+
checks: PermissionCheck[];
|
|
66
|
+
resolve(results: Array<{
|
|
67
|
+
allowed: boolean;
|
|
68
|
+
error?: string;
|
|
69
|
+
}>): ResolvedPermissionMode;
|
|
70
|
+
}
|
|
64
71
|
/** Drop all in-memory caches. Test-only (reassigns the WeakMap). */
|
|
65
72
|
export declare function resetPermissionModeCache(): void;
|
|
73
|
+
export declare function preparePermissionModeBatch(client: OryAgentClient, subject: ModeSubject, opts?: {
|
|
74
|
+
now?: number;
|
|
75
|
+
additionalSubjects?: AdditionalModeSubject[];
|
|
76
|
+
}): PermissionModeBatchPlan;
|
|
66
77
|
/**
|
|
67
78
|
* Resolve the current permission mode for `subject`. Never throws — a Keto
|
|
68
79
|
* error falls back to the persistent cache, then `observe`. See the module
|
package/dist/permission-mode.js
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
*/
|
|
50
50
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
51
|
exports.resetPermissionModeCache = resetPermissionModeCache;
|
|
52
|
+
exports.preparePermissionModeBatch = preparePermissionModeBatch;
|
|
52
53
|
exports.resolvePermissionMode = resolvePermissionMode;
|
|
53
54
|
exports.warmPermissionModeCache = warmPermissionModeCache;
|
|
54
55
|
const config_js_1 = require("./config.js");
|
|
@@ -123,6 +124,139 @@ function projectPostureSubject(namespace) {
|
|
|
123
124
|
function resetPermissionModeCache() {
|
|
124
125
|
clientMemo = new WeakMap();
|
|
125
126
|
}
|
|
127
|
+
function preparePermissionModeBatch(client, subject, opts = {}) {
|
|
128
|
+
const now = opts.now ?? Date.now();
|
|
129
|
+
const projectUrl = (0, config_js_1.resolveConfig)().projectUrl;
|
|
130
|
+
const namespace = resolveNamespace();
|
|
131
|
+
const subjects = [
|
|
132
|
+
{ subject, scope: "principal", key: subjectKey(subject) },
|
|
133
|
+
...(opts.additionalSubjects ?? []).map((item) => ({ ...item, key: subjectKey(item.subject) })),
|
|
134
|
+
].filter((item, index, all) => all.findIndex((candidate) => candidate.key === item.key) === index);
|
|
135
|
+
const memo = memoFor(client);
|
|
136
|
+
const fresh = (key) => {
|
|
137
|
+
const entry = memo.get(key);
|
|
138
|
+
if (entry && now - entry.fetchedAt < ttlMs())
|
|
139
|
+
return entry;
|
|
140
|
+
memo.delete(key);
|
|
141
|
+
return undefined;
|
|
142
|
+
};
|
|
143
|
+
const cachedProject = fresh(PROJECT_SCOPE_KEY);
|
|
144
|
+
const cachedSubjects = subjects.map((item) => fresh(item.key));
|
|
145
|
+
if (cachedProject?.mode === "enforce" || (cachedProject && cachedSubjects.every(Boolean))) {
|
|
146
|
+
const enforcing = cachedProject.mode === "enforce"
|
|
147
|
+
? cachedProject
|
|
148
|
+
: cachedSubjects.find((entry) => entry?.mode === "enforce");
|
|
149
|
+
const resolved = {
|
|
150
|
+
mode: enforcing?.mode ?? "observe",
|
|
151
|
+
source: enforcing?.source ?? cachedProject.source,
|
|
152
|
+
};
|
|
153
|
+
return { checks: [], resolve: () => resolved };
|
|
154
|
+
}
|
|
155
|
+
const pending = [
|
|
156
|
+
...(!cachedProject ? [{ scope: "project", key: PROJECT_SCOPE_KEY, subject: projectPostureSubject(namespace) }] : []),
|
|
157
|
+
...subjects.flatMap((item, index) => cachedSubjects[index] ? [] : [item]),
|
|
158
|
+
];
|
|
159
|
+
const checks = pending.map((item) => ({
|
|
160
|
+
namespace,
|
|
161
|
+
object: opl_js_1.PERMISSION_MODE_OBJECT,
|
|
162
|
+
relation: opl_js_1.RELATION_ENFORCED_SUBJECTS,
|
|
163
|
+
...item.subject,
|
|
164
|
+
}));
|
|
165
|
+
return {
|
|
166
|
+
checks,
|
|
167
|
+
resolve(results) {
|
|
168
|
+
const consultedKeys = [];
|
|
169
|
+
let sawError = false;
|
|
170
|
+
const read = (key, cached) => {
|
|
171
|
+
consultedKeys.push(key);
|
|
172
|
+
if (cached)
|
|
173
|
+
return cached;
|
|
174
|
+
const index = pending.findIndex((item) => item.key === key);
|
|
175
|
+
const result = results[index];
|
|
176
|
+
if (!result || result.error) {
|
|
177
|
+
sawError = true;
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const entry = {
|
|
181
|
+
mode: result.allowed ? "enforce" : "observe",
|
|
182
|
+
source: "server",
|
|
183
|
+
fetchedAt: now,
|
|
184
|
+
};
|
|
185
|
+
memo.set(key, entry);
|
|
186
|
+
return entry;
|
|
187
|
+
};
|
|
188
|
+
const ordered = [
|
|
189
|
+
{ key: PROJECT_SCOPE_KEY, scope: "project", cached: cachedProject },
|
|
190
|
+
...subjects.map((item, index) => ({ ...item, cached: cachedSubjects[index] })),
|
|
191
|
+
];
|
|
192
|
+
for (const item of ordered) {
|
|
193
|
+
const entry = read(item.key, item.cached);
|
|
194
|
+
if (entry?.mode === "enforce") {
|
|
195
|
+
client.logger.activity("permission.mode_resolve", "ok", {
|
|
196
|
+
attributes: { permissionMode: "enforce", enforceGranted: true, scope: item.scope, source: "server" },
|
|
197
|
+
});
|
|
198
|
+
persistPermissionModeMemo(client, projectUrl, now, consultedKeys);
|
|
199
|
+
return { mode: "enforce", source: "server" };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (consultedKeys.some((key) => memo.get(key)?.source === "server")) {
|
|
203
|
+
persistPermissionModeMemo(client, projectUrl, now, consultedKeys);
|
|
204
|
+
}
|
|
205
|
+
if (sawError)
|
|
206
|
+
return fallbackPermissionMode(projectUrl, subjects.map((item) => item.key));
|
|
207
|
+
client.logger.activity("permission.mode_resolve", "ok", {
|
|
208
|
+
attributes: { permissionMode: "observe", enforceGranted: false, scope: "principal", source: "server" },
|
|
209
|
+
});
|
|
210
|
+
return { mode: "observe", source: "server" };
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function fallbackPermissionMode(projectUrl, subjectKeys) {
|
|
215
|
+
const cache = (0, config_js_1.resolveConfig)().permissionModeCache;
|
|
216
|
+
const scoped = cache && cache.projectUrl === projectUrl ? cache.scopes : undefined;
|
|
217
|
+
const project = scoped?.[PROJECT_SCOPE_KEY];
|
|
218
|
+
const subjectEntries = subjectKeys.map((key) => scoped?.[key]);
|
|
219
|
+
const scopedMode = project?.mode === "enforce" || subjectEntries.some((entry) => entry?.mode === "enforce")
|
|
220
|
+
? "enforce"
|
|
221
|
+
: project && subjectEntries.every(Boolean)
|
|
222
|
+
? "observe"
|
|
223
|
+
: undefined;
|
|
224
|
+
const legacyMode = cache && !cache.scopes && (!cache.projectUrl || cache.projectUrl === projectUrl)
|
|
225
|
+
? cache.mode
|
|
226
|
+
: undefined;
|
|
227
|
+
const mode = scopedMode ?? (legacyMode === "enforce" ? "enforce" : undefined);
|
|
228
|
+
return mode ? { mode, source: "cache" } : { mode: "observe", source: "default" };
|
|
229
|
+
}
|
|
230
|
+
function persistPermissionModeMemo(client, projectUrl, now, requestedKeys) {
|
|
231
|
+
const updates = {};
|
|
232
|
+
for (const key of new Set(requestedKeys)) {
|
|
233
|
+
const entry = memoFor(client).get(key);
|
|
234
|
+
if (entry?.source === "server")
|
|
235
|
+
updates[key] = { mode: entry.mode, fetchedAt: entry.fetchedAt };
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
(0, config_js_1.mutateConfig)((config) => {
|
|
239
|
+
const current = config.permissionModeCache;
|
|
240
|
+
const scopes = { ...(current && current.projectUrl === projectUrl ? current.scopes ?? {} : {}) };
|
|
241
|
+
const projectMode = updates[PROJECT_SCOPE_KEY]?.mode ?? scopes[PROJECT_SCOPE_KEY]?.mode ?? "observe";
|
|
242
|
+
let changed = current?.mode !== projectMode || current?.projectUrl !== projectUrl;
|
|
243
|
+
for (const [key, entry] of Object.entries(updates)) {
|
|
244
|
+
const refreshed = { mode: entry.mode, fetchedAt: now };
|
|
245
|
+
if (scopes[key]?.mode === refreshed.mode && scopes[key]?.fetchedAt === refreshed.fetchedAt)
|
|
246
|
+
continue;
|
|
247
|
+
scopes[key] = refreshed;
|
|
248
|
+
changed = true;
|
|
249
|
+
}
|
|
250
|
+
if (!changed)
|
|
251
|
+
return undefined;
|
|
252
|
+
const boundedScopes = Object.fromEntries(Object.entries(scopes).sort(([, left], [, right]) => right.fetchedAt - left.fetchedAt).slice(0, 256));
|
|
253
|
+
return { ...config, permissionModeCache: { mode: projectMode, fetchedAt: now, projectUrl, scopes: boundedScopes } };
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Persistence is best-effort; a later process can read the server again.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
126
260
|
/**
|
|
127
261
|
* Resolve the current permission mode for `subject`. Never throws — a Keto
|
|
128
262
|
* error falls back to the persistent cache, then `observe`. See the module
|
|
@@ -266,42 +400,7 @@ async function warmPermissionModeCache(client, subject, opts = {}) {
|
|
|
266
400
|
});
|
|
267
401
|
if (resolved.source === "server") {
|
|
268
402
|
const requestedKeys = [PROJECT_SCOPE_KEY, subjectKey(subject), ...(opts.additionalSubjects ?? []).map((item) => subjectKey(item.subject))];
|
|
269
|
-
|
|
270
|
-
for (const key of new Set(requestedKeys)) {
|
|
271
|
-
const entry = memoFor(client).get(key);
|
|
272
|
-
if (entry?.source === "server")
|
|
273
|
-
updates[key] = { mode: entry.mode, fetchedAt: entry.fetchedAt };
|
|
274
|
-
}
|
|
275
|
-
try {
|
|
276
|
-
(0, config_js_1.mutateConfig)((config) => {
|
|
277
|
-
const current = config.permissionModeCache;
|
|
278
|
-
const scopes = { ...(current && current.projectUrl === projectUrl ? current.scopes ?? {} : {}) };
|
|
279
|
-
const projectMode = updates[PROJECT_SCOPE_KEY]?.mode
|
|
280
|
-
?? scopes[PROJECT_SCOPE_KEY]?.mode
|
|
281
|
-
?? "observe";
|
|
282
|
-
let changed = current?.mode !== projectMode || current?.projectUrl !== projectUrl;
|
|
283
|
-
for (const [key, entry] of Object.entries(updates)) {
|
|
284
|
-
const refreshed = { mode: entry.mode, fetchedAt: now };
|
|
285
|
-
if (scopes[key]?.mode === refreshed.mode && scopes[key]?.fetchedAt === refreshed.fetchedAt)
|
|
286
|
-
continue;
|
|
287
|
-
scopes[key] = refreshed;
|
|
288
|
-
changed = true;
|
|
289
|
-
}
|
|
290
|
-
if (!changed)
|
|
291
|
-
return undefined;
|
|
292
|
-
const boundedScopes = Object.fromEntries(Object.entries(scopes)
|
|
293
|
-
.sort(([, left], [, right]) => right.fetchedAt - left.fetchedAt)
|
|
294
|
-
.slice(0, 256));
|
|
295
|
-
return {
|
|
296
|
-
...config,
|
|
297
|
-
permissionModeCache: { mode: projectMode, fetchedAt: now, projectUrl, scopes: boundedScopes },
|
|
298
|
-
};
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
catch {
|
|
302
|
-
// Best-effort — a write failure just means the next process re-reads
|
|
303
|
-
// from the server (or starts at observe until it can).
|
|
304
|
-
}
|
|
403
|
+
persistPermissionModeMemo(client, projectUrl, now, requestedKeys);
|
|
305
404
|
}
|
|
306
405
|
return resolved;
|
|
307
406
|
}
|
package/dist/permissions.d.ts
CHANGED
|
@@ -231,13 +231,13 @@ export interface GateToolCallArgs {
|
|
|
231
231
|
/** Override the resolved {@link PermissionMode}. Tests use this. */
|
|
232
232
|
modeOverride?: PermissionMode;
|
|
233
233
|
/**
|
|
234
|
-
* Raw shell command string when {@link toolName} is the harness's shell tool
|
|
234
|
+
* Raw shell command string(s) when {@link toolName} is the harness's shell tool
|
|
235
235
|
* (issue #76). When present and the tool is a shell tool, the gate decomposes
|
|
236
|
-
*
|
|
236
|
+
* each command into `ShellTool:<word>#use` sub-checks in addition to the
|
|
237
237
|
* top-level tool check. Absent / undefined ⇒ decomposition is skipped and the
|
|
238
238
|
* gate behaves exactly as before.
|
|
239
239
|
*/
|
|
240
|
-
shellCommand?: string;
|
|
240
|
+
shellCommand?: string | readonly string[];
|
|
241
241
|
/**
|
|
242
242
|
* The acting machine principals, so an explicit block on the agent install,
|
|
243
243
|
* this session, the sub-agent kind, or this spawn can deny the call. Omitted ⇒
|
package/dist/permissions.js
CHANGED
|
@@ -220,6 +220,9 @@ async function gateToolCall(client, args) {
|
|
|
220
220
|
// every read 401s: `session_inactive` denies every tool under `enforce`, and
|
|
221
221
|
// under `observe` stops checking anything at all (#242).
|
|
222
222
|
await (0, read_credential_js_1.ensureReadCredential)(client, { harness: args.harness });
|
|
223
|
+
if (!(args.shellCommand && (0, tool_catalog_js_1.isShellTool)(args.harness, args.toolName))) {
|
|
224
|
+
return batchNonShellGate(client, args);
|
|
225
|
+
}
|
|
223
226
|
// Resolve the mode ONCE for this gate — a per-principal Keto permission read
|
|
224
227
|
// (server → cache → observe), cached with a short TTL. Threaded downstream as
|
|
225
228
|
// `modeOverride` so the whole gate (top-level check + every decomposed shell
|
|
@@ -240,16 +243,7 @@ async function gateToolCall(client, args) {
|
|
|
240
243
|
// than before it, so the extra read costs no serial latency, and let a block
|
|
241
244
|
// override an allow afterwards.
|
|
242
245
|
const [decision, blocked] = await Promise.all([
|
|
243
|
-
|
|
244
|
-
? // Shell tools (issue #76): decompose the command into per-word sub-checks
|
|
245
|
-
// in addition to the top-level tool check. Only when the harness marks
|
|
246
|
-
// this a shell tool AND the raw command was threaded through — otherwise
|
|
247
|
-
// fall back to the plain single check (never breaks).
|
|
248
|
-
decomposeAndCheck(client, { ...args, modeOverride: mode })
|
|
249
|
-
: checkAndDecide(client, args.check, {
|
|
250
|
-
activityAttributes: args.activityAttributes,
|
|
251
|
-
modeOverride: mode,
|
|
252
|
-
}),
|
|
246
|
+
decomposeAndCheck(client, { ...args, modeOverride: mode }),
|
|
253
247
|
findBlockedPrincipal(client, {
|
|
254
248
|
namespace: args.check.namespace,
|
|
255
249
|
object: args.check.object,
|
|
@@ -273,6 +267,77 @@ async function gateToolCall(client, args) {
|
|
|
273
267
|
}
|
|
274
268
|
return applyPrincipalBlock(client, { args, mode, decision, blocked });
|
|
275
269
|
}
|
|
270
|
+
async function batchNonShellGate(client, args) {
|
|
271
|
+
const additionalSubjects = [
|
|
272
|
+
{ scope: "agent", subject: (0, subject_js_1.resolveAgentSubject)(client) },
|
|
273
|
+
{ scope: "subagent", subject: (0, subject_js_1.resolveSubAgentSubject)(args.principals?.subAgentClientId) },
|
|
274
|
+
].filter((item) => item.subject !== undefined);
|
|
275
|
+
const modeSubject = { subjectId: args.check.subjectId, subjectSet: args.check.subjectSet };
|
|
276
|
+
const modePlan = args.modeOverride ? undefined : (0, permission_mode_js_1.preparePermissionModeBatch)(client, modeSubject, { additionalSubjects });
|
|
277
|
+
const candidates = principalBlockCandidates(client, args.principals);
|
|
278
|
+
const effectiveCheck = { ...args.check, relation: resolveCheckRelation(args.check.relation) };
|
|
279
|
+
const checks = [
|
|
280
|
+
...(modePlan?.checks ?? []),
|
|
281
|
+
effectiveCheck,
|
|
282
|
+
...candidates.map(({ ref }) => ({
|
|
283
|
+
namespace: args.check.namespace,
|
|
284
|
+
object: args.check.object,
|
|
285
|
+
relation: opl_js_1.RELATION_BLOCKED_SUBJECTS,
|
|
286
|
+
...ref,
|
|
287
|
+
})),
|
|
288
|
+
];
|
|
289
|
+
try {
|
|
290
|
+
const batch = await client.batchCheckPermissions(checks, {
|
|
291
|
+
activityAttributes: { ...args.activityAttributes, source: "tool_gate" },
|
|
292
|
+
});
|
|
293
|
+
const modeCount = modePlan?.checks.length ?? 0;
|
|
294
|
+
const mode = args.modeOverride ?? modePlan.resolve(batch.results.slice(0, modeCount)).mode;
|
|
295
|
+
const toolResult = batch.results[modeCount];
|
|
296
|
+
if (!toolResult || toolResult.error)
|
|
297
|
+
throw new Error(toolResult?.error ?? "missing tool result");
|
|
298
|
+
const result = { allowed: toolResult.allowed, checkedAt: batch.checkedAt, check: args.check };
|
|
299
|
+
const decision = await decidePermissionResult(client, result, effectiveCheck, {
|
|
300
|
+
activityAttributes: args.activityAttributes,
|
|
301
|
+
modeOverride: mode,
|
|
302
|
+
});
|
|
303
|
+
let blocked;
|
|
304
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
305
|
+
const block = batch.results[modeCount + 1 + index];
|
|
306
|
+
if (block?.allowed && !block.error) {
|
|
307
|
+
blocked = {
|
|
308
|
+
subject: candidate.level === "project" ? "project" : (0, subject_js_1.subjectLabel)(candidate.ref),
|
|
309
|
+
level: candidate.level,
|
|
310
|
+
};
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return applyBlockedDecision(client, args, mode, decision, blocked);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
const mode = args.modeOverride ?? (await (0, permission_mode_js_1.warmPermissionModeCache)(client, modeSubject, { additionalSubjects })).mode;
|
|
318
|
+
const [decision, blocked] = await Promise.all([
|
|
319
|
+
checkAndDecide(client, args.check, { activityAttributes: args.activityAttributes, modeOverride: mode }),
|
|
320
|
+
findBlockedPrincipal(client, { namespace: args.check.namespace, object: args.check.object, principals: args.principals }),
|
|
321
|
+
]);
|
|
322
|
+
return applyBlockedDecision(client, args, mode, decision, blocked);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function applyBlockedDecision(client, args, mode, decision, blocked) {
|
|
326
|
+
if (!blocked)
|
|
327
|
+
return decision;
|
|
328
|
+
if (decision.kind === "deny") {
|
|
329
|
+
return {
|
|
330
|
+
...decision,
|
|
331
|
+
activityAttributes: {
|
|
332
|
+
...decision.activityAttributes,
|
|
333
|
+
blockReason: "explicit_block",
|
|
334
|
+
blockedPrincipal: blocked.subject,
|
|
335
|
+
blockedPrincipalLevel: blocked.level,
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return applyPrincipalBlock(client, { args, mode, decision, blocked });
|
|
340
|
+
}
|
|
276
341
|
/**
|
|
277
342
|
* Turn a machine-principal block into the gate's outcome, respecting
|
|
278
343
|
* `permissionMode` exactly as any other deny does: `enforce` blocks, `observe`
|
|
@@ -342,11 +407,36 @@ async function decomposeAndCheck(client, args) {
|
|
|
342
407
|
// letting the exception bubble to the harness catch, which fails open.
|
|
343
408
|
let parsed;
|
|
344
409
|
try {
|
|
345
|
-
|
|
410
|
+
const commands = typeof args.shellCommand === "string"
|
|
411
|
+
? [args.shellCommand]
|
|
412
|
+
: args.shellCommand ?? [];
|
|
413
|
+
const words = [];
|
|
414
|
+
const seen = new Set();
|
|
415
|
+
let tooComplex = false;
|
|
416
|
+
let parserUnavailable = false;
|
|
417
|
+
for (const command of commands) {
|
|
418
|
+
const result = await (0, bash_parser_js_1.extractBashCommandWords)(command);
|
|
419
|
+
tooComplex ||= result.tooComplex;
|
|
420
|
+
parserUnavailable ||= result.parserUnavailable === true;
|
|
421
|
+
for (const word of result.words) {
|
|
422
|
+
if (!seen.has(word)) {
|
|
423
|
+
seen.add(word);
|
|
424
|
+
words.push(word);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
parsed = { words, tooComplex, parserUnavailable };
|
|
346
429
|
}
|
|
347
430
|
catch {
|
|
348
431
|
parsed = { words: [], tooComplex: true };
|
|
349
432
|
}
|
|
433
|
+
if (args.toolName.toLowerCase() === "powershell"
|
|
434
|
+
|| (args.harness === "cline" && args.toolName === "run_commands" && process.platform === "win32")) {
|
|
435
|
+
// The bundled grammar models POSIX shell syntax, not PowerShell. Retain its
|
|
436
|
+
// Never retain words parsed with the incompatible grammar: PowerShell
|
|
437
|
+
// literals could otherwise be persisted as if they were command names.
|
|
438
|
+
parsed = { words: [], tooComplex: true, parserUnavailable: false };
|
|
439
|
+
}
|
|
350
440
|
if (parsed.parserUnavailable) {
|
|
351
441
|
client.logger.warn("bash.parser_unavailable", {
|
|
352
442
|
harness: args.harness,
|
|
@@ -610,6 +700,10 @@ async function checkAndDecide(client, check, opts = {}) {
|
|
|
610
700
|
// Report the decision against the caller's original check (relation `use`),
|
|
611
701
|
// not the internal permit rewrite, so callers see the coordinates they passed.
|
|
612
702
|
result = { ...result, check };
|
|
703
|
+
return decidePermissionResult(client, result, effectiveCheck, opts);
|
|
704
|
+
}
|
|
705
|
+
async function decidePermissionResult(client, result, effectiveCheck, opts) {
|
|
706
|
+
const check = result.check;
|
|
613
707
|
// A deny from the native `use` permit is necessarily an explicit block.
|
|
614
708
|
// Retain the diagnostic probe only for other grant-based public checks.
|
|
615
709
|
const blockReason = result.allowed
|
|
@@ -640,6 +734,24 @@ const PROJECT_BLOCK_SUBJECT = {
|
|
|
640
734
|
relation: opl_js_1.RELATION_ENFORCED_SUBJECTS,
|
|
641
735
|
},
|
|
642
736
|
};
|
|
737
|
+
function principalBlockCandidates(client, principals) {
|
|
738
|
+
const p = principals ?? {};
|
|
739
|
+
return [
|
|
740
|
+
{ level: "project", ref: PROJECT_BLOCK_SUBJECT },
|
|
741
|
+
{ level: "agent", ref: (0, subject_js_1.resolveAgentSubject)(client) },
|
|
742
|
+
{ level: "agent_session", ref: (0, subject_js_1.resolveAgentSessionSubject)(client, p.sessionId) },
|
|
743
|
+
{ level: "subagent", ref: (0, subject_js_1.resolveSubAgentSubject)(p.subAgentClientId) },
|
|
744
|
+
{
|
|
745
|
+
level: "subagent_spawn",
|
|
746
|
+
ref: p.subAgentType ? (0, subject_js_1.resolveSubAgentSpawnSubject)(client, {
|
|
747
|
+
subAgentClientId: p.subAgentClientId,
|
|
748
|
+
subAgentType: p.subAgentType,
|
|
749
|
+
perSpawnId: p.perSpawnId,
|
|
750
|
+
sessionId: p.sessionId,
|
|
751
|
+
}) : undefined,
|
|
752
|
+
},
|
|
753
|
+
].filter((candidate) => candidate.ref !== undefined);
|
|
754
|
+
}
|
|
643
755
|
/**
|
|
644
756
|
* Check whether the project or any acting machine principal is explicitly blocked from this
|
|
645
757
|
* tool, and name the one that is.
|
package/dist/testing.d.ts
CHANGED
|
@@ -241,13 +241,21 @@ export declare function stubOAuth2Inactive(client: OryAgentClient): import("vite
|
|
|
241
241
|
*/
|
|
242
242
|
export declare function setTestPermissionMode(mode: PermissionMode): void;
|
|
243
243
|
/** Stub checkPermission to allow. */
|
|
244
|
-
export declare function stubPermissionAllowed(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable
|
|
244
|
+
export declare function stubPermissionAllowed(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable> & {
|
|
245
|
+
batch: import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
246
|
+
};
|
|
245
247
|
/** Stub checkPermission to deny (the tool check; the mode check still answers `currentTestMode`). */
|
|
246
|
-
export declare function stubPermissionDenied(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable
|
|
248
|
+
export declare function stubPermissionDenied(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable> & {
|
|
249
|
+
batch: import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
250
|
+
};
|
|
247
251
|
/** Stub checkPermission to throw a network error. */
|
|
248
|
-
export declare function stubPermissionNetworkError(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable
|
|
252
|
+
export declare function stubPermissionNetworkError(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable> & {
|
|
253
|
+
batch: import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
254
|
+
};
|
|
249
255
|
/** Stub checkPermission to throw a rate-limit error. */
|
|
250
|
-
export declare function stubPermissionRateLimited(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable
|
|
256
|
+
export declare function stubPermissionRateLimited(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable> & {
|
|
257
|
+
batch: import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
258
|
+
};
|
|
251
259
|
/** Stub both checkPermission (server allow) and batchCheckPermission (both allow). */
|
|
252
260
|
export declare function stubMcpAllowed(client: OryAgentClient): import("vitest").Mock<import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Procedure | import(".pnpm/@vitest+spy@4.1.7/node_modules/@vitest/spy", { with: { "resolution-mode": "import" } }).Constructable>;
|
|
253
261
|
/** Stub checkPermission to deny (server-only MCP check). */
|
package/dist/testing.js
CHANGED
|
@@ -454,7 +454,7 @@ function ensurePermissionCredential(client) {
|
|
|
454
454
|
/** Stub checkPermission to allow. */
|
|
455
455
|
function stubPermissionAllowed(client) {
|
|
456
456
|
ensurePermissionCredential(client);
|
|
457
|
-
stubApi(client, "permission", "batchCheckPermission", (request) => {
|
|
457
|
+
const batch = stubApi(client, "permission", "batchCheckPermission", (request) => {
|
|
458
458
|
const tuples = request.batchCheckPermissionBody?.tuples ?? [];
|
|
459
459
|
return Promise.resolve({
|
|
460
460
|
data: {
|
|
@@ -466,22 +466,38 @@ function stubPermissionAllowed(client) {
|
|
|
466
466
|
},
|
|
467
467
|
});
|
|
468
468
|
});
|
|
469
|
-
|
|
469
|
+
const check = stubApi(client, "permission", "checkPermission", checkPermissionResponder(exports.PERMISSION_ALLOWED));
|
|
470
|
+
return Object.assign(check, { batch });
|
|
470
471
|
}
|
|
471
472
|
/** Stub checkPermission to deny (the tool check; the mode check still answers `currentTestMode`). */
|
|
472
473
|
function stubPermissionDenied(client) {
|
|
473
474
|
ensurePermissionCredential(client);
|
|
474
|
-
|
|
475
|
+
const batch = stubApi(client, "permission", "batchCheckPermission", (request) => {
|
|
476
|
+
const tuples = request.batchCheckPermissionBody?.tuples ?? [];
|
|
477
|
+
return Promise.resolve({
|
|
478
|
+
data: {
|
|
479
|
+
results: tuples.map((tuple) => ({
|
|
480
|
+
allowed: tuple.relation === "enforcedSubjects" && currentTestMode === "enforce",
|
|
481
|
+
})),
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
const check = stubApi(client, "permission", "checkPermission", checkPermissionResponder(exports.PERMISSION_DENIED));
|
|
486
|
+
return Object.assign(check, { batch });
|
|
475
487
|
}
|
|
476
488
|
/** Stub checkPermission to throw a network error. */
|
|
477
489
|
function stubPermissionNetworkError(client) {
|
|
478
490
|
ensurePermissionCredential(client);
|
|
479
|
-
|
|
491
|
+
const batch = stubApi(client, "permission", "batchCheckPermission", () => Promise.reject(makeNetworkError()));
|
|
492
|
+
const check = stubApi(client, "permission", "checkPermission", () => Promise.reject(makeNetworkError()));
|
|
493
|
+
return Object.assign(check, { batch });
|
|
480
494
|
}
|
|
481
495
|
/** Stub checkPermission to throw a rate-limit error. */
|
|
482
496
|
function stubPermissionRateLimited(client) {
|
|
483
497
|
ensurePermissionCredential(client);
|
|
484
|
-
|
|
498
|
+
const batch = stubApi(client, "permission", "batchCheckPermission", () => Promise.reject(makeRateLimitError()));
|
|
499
|
+
const check = stubApi(client, "permission", "checkPermission", () => Promise.reject(makeRateLimitError()));
|
|
500
|
+
return Object.assign(check, { batch });
|
|
485
501
|
}
|
|
486
502
|
// ─── MCP Stub Presets ─────────────────────────────────────────────
|
|
487
503
|
/** Stub both checkPermission (server allow) and batchCheckPermission (both allow). */
|
package/dist/tool-catalog.d.ts
CHANGED
|
@@ -14,17 +14,17 @@
|
|
|
14
14
|
* Built-in tool names known to ship with each supported harness.
|
|
15
15
|
*/
|
|
16
16
|
export declare const HARNESS_TOOL_CATALOG: {
|
|
17
|
-
readonly "claude-code": readonly ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "WebFetch", "WebSearch", "NotebookEdit", "ToolSearch", "Skill", "TodoWrite", "Agent", "Task"];
|
|
18
|
-
readonly "claude-agent-sdk": readonly ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "WebFetch", "WebSearch", "NotebookEdit", "ToolSearch", "Skill", "TodoWrite", "Agent", "Task"];
|
|
17
|
+
readonly "claude-code": readonly ["Read", "Write", "Edit", "Bash", "Monitor", "PowerShell", "Workflow", "Glob", "Grep", "WebFetch", "WebSearch", "NotebookEdit", "ToolSearch", "Skill", "TodoWrite", "Agent", "Task"];
|
|
18
|
+
readonly "claude-agent-sdk": readonly ["Read", "Write", "Edit", "Bash", "Monitor", "PowerShell", "Workflow", "Glob", "Grep", "WebFetch", "WebSearch", "NotebookEdit", "ToolSearch", "Skill", "TodoWrite", "Agent", "Task"];
|
|
19
19
|
readonly codex: readonly ["Bash", "apply_patch"];
|
|
20
20
|
readonly "gemini-cli": readonly ["read_file", "write_file", "edit_file", "list_files", "search_files", "shell", "web_search"];
|
|
21
21
|
readonly openclaw: readonly ["exec", "read", "write", "edit", "apply_patch", "web_search", "web_fetch", "browser"];
|
|
22
22
|
readonly opencode: readonly ["read", "write", "edit", "bash", "glob", "grep", "webfetch"];
|
|
23
23
|
readonly continue: readonly ["Bash", "Read", "Edit", "Write", "Grep", "Glob", "WebSearch"];
|
|
24
24
|
readonly goose: readonly ["shell", "text_editor", "read_file", "write_file", "list_windows", "screen_capture"];
|
|
25
|
-
readonly cline: readonly ["execute_command", "read_file", "write_to_file", "replace_in_file", "search_files", "list_files", "use_mcp_tool"];
|
|
25
|
+
readonly cline: readonly ["execute_command", "run_commands", "read_file", "write_to_file", "replace_in_file", "search_files", "list_files", "use_mcp_tool"];
|
|
26
26
|
readonly amp: readonly ["Bash", "Read", "create_file", "edit_file", "undo_edit", "glob", "Grep", "finder", "read_web_page", "web_search", "todo_read", "todo_write", "oracle", "Task"];
|
|
27
|
-
readonly pi: readonly ["read", "write", "edit", "bash", "grep", "find", "ls"];
|
|
27
|
+
readonly pi: readonly ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"];
|
|
28
28
|
readonly antigravity: readonly ["run_command", "view_file", "write_to_file", "replace_file_content", "multi_replace_file_content", "list_directory", "grep_search", "find", "read_url_content", "search_web", "call_mcp_tool"];
|
|
29
29
|
readonly cursor: readonly ["Read", "Write", "Edit", "Delete", "Shell", "List", "Search", "MCP", "Task"];
|
|
30
30
|
};
|
|
@@ -109,10 +109,12 @@ export declare function getShellCommandCatalog(): readonly string[];
|
|
|
109
109
|
*/
|
|
110
110
|
export declare function resolveShellCommandNamespace(): string;
|
|
111
111
|
/**
|
|
112
|
-
* Extract
|
|
112
|
+
* Extract shell command strings from a harness's tool-args object — the same
|
|
113
113
|
* object the handler already passes to `summarizeToolInput`. Returns `undefined`
|
|
114
114
|
* when the tool isn't a shell tool for this harness or no command field is
|
|
115
|
-
* present
|
|
116
|
-
*
|
|
115
|
+
* present. Cline's `run_commands` is the exception: malformed command data is
|
|
116
|
+
* represented as an unresolvable command so enforce mode fails closed. Cline's
|
|
117
|
+
* command array remains an array so independently executed entries cannot
|
|
118
|
+
* change how neighboring entries parse.
|
|
117
119
|
*/
|
|
118
|
-
export declare function extractShellCommand(harness: string, toolName: string, args: unknown): string | undefined;
|
|
120
|
+
export declare function extractShellCommand(harness: string, toolName: string, args: unknown): string | readonly string[] | undefined;
|
package/dist/tool-catalog.js
CHANGED
|
@@ -35,6 +35,9 @@ exports.HARNESS_TOOL_CATALOG = {
|
|
|
35
35
|
"Write",
|
|
36
36
|
"Edit",
|
|
37
37
|
"Bash",
|
|
38
|
+
"Monitor",
|
|
39
|
+
"PowerShell",
|
|
40
|
+
"Workflow",
|
|
38
41
|
"Glob",
|
|
39
42
|
"Grep",
|
|
40
43
|
"WebFetch",
|
|
@@ -54,6 +57,9 @@ exports.HARNESS_TOOL_CATALOG = {
|
|
|
54
57
|
"Write",
|
|
55
58
|
"Edit",
|
|
56
59
|
"Bash",
|
|
60
|
+
"Monitor",
|
|
61
|
+
"PowerShell",
|
|
62
|
+
"Workflow",
|
|
57
63
|
"Glob",
|
|
58
64
|
"Grep",
|
|
59
65
|
"WebFetch",
|
|
@@ -103,6 +109,7 @@ exports.HARNESS_TOOL_CATALOG = {
|
|
|
103
109
|
],
|
|
104
110
|
cline: [
|
|
105
111
|
"execute_command",
|
|
112
|
+
"run_commands",
|
|
106
113
|
"read_file",
|
|
107
114
|
"write_to_file",
|
|
108
115
|
"replace_in_file",
|
|
@@ -126,7 +133,7 @@ exports.HARNESS_TOOL_CATALOG = {
|
|
|
126
133
|
"oracle",
|
|
127
134
|
"Task",
|
|
128
135
|
],
|
|
129
|
-
pi: ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
|
136
|
+
pi: ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"],
|
|
130
137
|
antigravity: [
|
|
131
138
|
"run_command",
|
|
132
139
|
"view_file",
|
|
@@ -245,8 +252,8 @@ function isInteractiveTool(harness, toolName) {
|
|
|
245
252
|
* precise map is used rather than the {@link isBashLikeTool} heuristic.
|
|
246
253
|
*/
|
|
247
254
|
exports.SHELL_TOOL_CATALOG = {
|
|
248
|
-
"claude-code": ["Bash"],
|
|
249
|
-
"claude-agent-sdk": ["Bash"],
|
|
255
|
+
"claude-code": ["Bash", "Monitor", "PowerShell"],
|
|
256
|
+
"claude-agent-sdk": ["Bash", "Monitor", "PowerShell"],
|
|
250
257
|
codex: ["Bash"],
|
|
251
258
|
// Gemini CLI's hook payload has been observed with both the short `shell`
|
|
252
259
|
// name and the fuller `run_shell_command` id across versions; accept both so
|
|
@@ -256,10 +263,11 @@ exports.SHELL_TOOL_CATALOG = {
|
|
|
256
263
|
opencode: ["bash"],
|
|
257
264
|
continue: ["Bash"],
|
|
258
265
|
goose: ["shell"],
|
|
259
|
-
cline: ["execute_command"],
|
|
266
|
+
cline: ["execute_command", "run_commands"],
|
|
260
267
|
amp: ["Bash"],
|
|
261
|
-
pi: ["bash"],
|
|
268
|
+
pi: ["bash", "powershell"],
|
|
262
269
|
antigravity: ["run_command"],
|
|
270
|
+
cursor: ["Shell"],
|
|
263
271
|
};
|
|
264
272
|
function parseEnvList(raw) {
|
|
265
273
|
if (!raw)
|
|
@@ -346,16 +354,39 @@ const SHELL_COMMAND_FIELDS = [
|
|
|
346
354
|
"commandLine",
|
|
347
355
|
"CommandLine",
|
|
348
356
|
];
|
|
357
|
+
const UNRESOLVED_SHELL_COMMAND = "$ORY_UNRESOLVED_SHELL_COMMAND";
|
|
358
|
+
function extractClineRunCommands(args) {
|
|
359
|
+
if (!args || typeof args !== "object")
|
|
360
|
+
return [UNRESOLVED_SHELL_COMMAND];
|
|
361
|
+
const value = args.commands;
|
|
362
|
+
if (typeof value !== "string")
|
|
363
|
+
return [UNRESOLVED_SHELL_COMMAND];
|
|
364
|
+
try {
|
|
365
|
+
const commands = JSON.parse(value);
|
|
366
|
+
if (!Array.isArray(commands) || !commands.every((command) => typeof command === "string")) {
|
|
367
|
+
return [UNRESOLVED_SHELL_COMMAND];
|
|
368
|
+
}
|
|
369
|
+
return commands.length > 0 ? commands : undefined;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return [UNRESOLVED_SHELL_COMMAND];
|
|
373
|
+
}
|
|
374
|
+
}
|
|
349
375
|
/**
|
|
350
|
-
* Extract
|
|
376
|
+
* Extract shell command strings from a harness's tool-args object — the same
|
|
351
377
|
* object the handler already passes to `summarizeToolInput`. Returns `undefined`
|
|
352
378
|
* when the tool isn't a shell tool for this harness or no command field is
|
|
353
|
-
* present
|
|
354
|
-
*
|
|
379
|
+
* present. Cline's `run_commands` is the exception: malformed command data is
|
|
380
|
+
* represented as an unresolvable command so enforce mode fails closed. Cline's
|
|
381
|
+
* command array remains an array so independently executed entries cannot
|
|
382
|
+
* change how neighboring entries parse.
|
|
355
383
|
*/
|
|
356
384
|
function extractShellCommand(harness, toolName, args) {
|
|
357
385
|
if (!isShellTool(harness, toolName))
|
|
358
386
|
return undefined;
|
|
387
|
+
if (harness === "cline" && toolName === "run_commands") {
|
|
388
|
+
return extractClineRunCommands(args);
|
|
389
|
+
}
|
|
359
390
|
if (!args || typeof args !== "object")
|
|
360
391
|
return undefined;
|
|
361
392
|
const obj = args;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ory/argus",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "Ory Argus: the core API for building authentication, authorization, and audit into AI agent harness plugins, extensions, and custom integrations",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://ory.com",
|