@sema-agent/core 7.1.0 → 7.2.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/CHANGELOG.md +36 -0
- package/dist/agents/cross-session-envelope.d.ts +138 -0
- package/dist/agents/cross-session-envelope.js +191 -0
- package/dist/agents/cross-session-judge.d.ts +119 -0
- package/dist/agents/cross-session-judge.js +184 -0
- package/dist/agents/cross-session-ref.d.ts +52 -0
- package/dist/agents/cross-session-ref.js +64 -0
- package/dist/agents/send-message-tool.d.ts +13 -0
- package/dist/agents/send-message-tool.js +36 -12
- package/dist/core/checkpoint-store.d.ts +189 -3
- package/dist/core/checkpoint-store.js +56 -16
- package/dist/core/hooks.d.ts +15 -8
- package/dist/core/hooks.js +6 -3
- package/dist/core/permission-rule-consent.d.ts +72 -23
- package/dist/core/permission-rule-consent.js +115 -26
- package/dist/core/permission-rule-model.d.ts +245 -51
- package/dist/core/permission-rule-model.js +312 -54
- package/dist/core/permission-rule-org.js +13 -6
- package/dist/core/remote-env.d.ts +8 -1
- package/dist/core/runner/assemble-result.js +2 -1
- package/dist/core/runner/prepare-task.d.ts +39 -1
- package/dist/core/runner/prepare-task.js +278 -113
- package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
- package/dist/core/runner/prepare-workspace-restore.js +2 -1
- package/dist/core/runner/runtask.js +13 -3
- package/dist/core/task-notification.d.ts +64 -5
- package/dist/core/task-notification.js +25 -4
- package/dist/core/tool-policy.d.ts +11 -0
- package/dist/core/types.d.ts +23 -0
- package/dist/core/untrusted-text.js +17 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +125 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
1
2
|
import { parsePermissionRule } from "./permission-rules.js";
|
|
3
|
+
import { compileReadDeny } from "../tools/fs/read-deny.js";
|
|
2
4
|
import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js";
|
|
3
5
|
import { inlineUntrusted } from "./untrusted-text.js";
|
|
4
6
|
export const MAX_RULE_TEXT_CHARS = 512;
|
|
@@ -193,15 +195,53 @@ export function parseAllowRuleText(text, opts) {
|
|
|
193
195
|
if (parsed.ruleContent === undefined) {
|
|
194
196
|
return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`);
|
|
195
197
|
}
|
|
198
|
+
if (parsed.toolName === "Read") {
|
|
199
|
+
const content = parsed.ruleContent;
|
|
200
|
+
const m = /^\/\/(.+)\/\*\*$/.exec(content);
|
|
201
|
+
if (m === null) {
|
|
202
|
+
return reject("invalid.grammar", `only the Read(//abs-dir/**) directory form is a rule this version reads ("${text}") — relative, single-file, single-slash and no-tail spellings have no rule form`);
|
|
203
|
+
}
|
|
204
|
+
const bodyR = m[1];
|
|
205
|
+
if (bodyR.includes("*")) {
|
|
206
|
+
return reject("unsupported.wildcard", `a \`*\` inside the directory body matches anything at that position, which has no rule form ("${text}")`);
|
|
207
|
+
}
|
|
208
|
+
const segments = bodyR.split("/");
|
|
209
|
+
if (segments.some((s) => s === "" || s.trim() === "")) {
|
|
210
|
+
return reject("invalid.grammar", `the directory body of "${text}" is not in the canonical spelling (an empty or whitespace-only path segment) — the lexical normal form is the only accepted spelling`);
|
|
211
|
+
}
|
|
212
|
+
if (segments.some((s) => s === "." || s === "..")) {
|
|
213
|
+
return reject("invalid.path_traversal", `the directory body of "${text}" carries a \`.\` or \`..\` segment — the lexical normal form is the only accepted spelling, and a traversal segment is not normalized away`);
|
|
214
|
+
}
|
|
215
|
+
const dir = "/" + bodyR;
|
|
216
|
+
return { rule: { rule: formatAllowRuleText(dir, "subpath"), tool: "Read", match: "subpath", command: dir } };
|
|
217
|
+
}
|
|
196
218
|
if (parsed.toolName !== "Bash") {
|
|
197
|
-
return reject("unsupported.tool", `only Bash rules are supported in this version (got "${parsed.toolName}")`);
|
|
219
|
+
return reject("unsupported.tool", `only Bash and Read rules are supported in this version (got "${parsed.toolName}")`);
|
|
198
220
|
}
|
|
199
221
|
const content = parsed.ruleContent;
|
|
200
222
|
const prefixBody = /^(.+):\*$/.exec(content)?.[1];
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
223
|
+
let match;
|
|
224
|
+
let body;
|
|
225
|
+
if (prefixBody !== undefined) {
|
|
226
|
+
if (prefixBody.includes("*")) {
|
|
227
|
+
return reject("unsupported.wildcard", `a \`*\` inside a prefix body matches anything at that position, which has no rule form in this version ("${text}")`);
|
|
228
|
+
}
|
|
229
|
+
match = "prefix";
|
|
230
|
+
body = prefixBody;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
const wildcardBody = /^(.+) \*$/.exec(content)?.[1];
|
|
234
|
+
if (wildcardBody !== undefined && !hasUnescapedStar(wildcardBody)) {
|
|
235
|
+
match = "wildcard";
|
|
236
|
+
body = wildcardBody;
|
|
237
|
+
}
|
|
238
|
+
else if (hasUnescapedStar(content)) {
|
|
239
|
+
return reject("unsupported.wildcard", `a \`*\` before the end of the pattern matches anything at that position, which has no rule form in this version — only the trailing \`<command> *\` form does ("${text}")`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
match = "exact";
|
|
243
|
+
body = content;
|
|
244
|
+
}
|
|
205
245
|
}
|
|
206
246
|
const shape = ruleLaneShapeOf(body, MATCH_READING);
|
|
207
247
|
if ("reject" in shape) {
|
|
@@ -209,8 +249,11 @@ export function parseAllowRuleText(text, opts) {
|
|
|
209
249
|
? reject("invalid.empty_command", `rule "${text}" names no command`)
|
|
210
250
|
: reject("invalid.not_simple_command", `rule "${text}" is not a command this lane can name (${shape.reject})`);
|
|
211
251
|
}
|
|
252
|
+
if (match === "wildcard" && shape.segments.length > 1) {
|
|
253
|
+
return reject("unsupported.wildcard", `a \`<chain> *\` pattern names more than one command, and the trailing-star form has no chain reading — the compound-prefix form spells one with the \`:*\` marker ("${text}")`);
|
|
254
|
+
}
|
|
212
255
|
const head = commandBasename(shape.names[shape.names.length - 1] ?? "");
|
|
213
|
-
if (match === "prefix" && BARE_INTERPRETER_NAMES.has(head) && opts?.direction !== "tighten") {
|
|
256
|
+
if ((match === "prefix" || match === "wildcard") && BARE_INTERPRETER_NAMES.has(head) && opts?.direction !== "tighten") {
|
|
214
257
|
return reject("invalid.bare_interpreter_prefix", shape.segments.length > 1
|
|
215
258
|
? `prefix rule "${text}" leaves its final segment open-ended under the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`
|
|
216
259
|
: `prefix rule "${text}" is headed by the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`);
|
|
@@ -222,7 +265,9 @@ export function parseAllowRuleText(text, opts) {
|
|
|
222
265
|
return { rule: { rule: formatAllowRuleText(command, match), tool: "Bash", match, command } };
|
|
223
266
|
}
|
|
224
267
|
export function formatAllowRuleText(command, match) {
|
|
225
|
-
|
|
268
|
+
if (match === "subpath")
|
|
269
|
+
return `Read(//${command.startsWith("/") ? command.slice(1) : command}/**)`;
|
|
270
|
+
return `Bash(${command}${match === "prefix" ? ":*" : match === "wildcard" ? " *" : ""})`;
|
|
226
271
|
}
|
|
227
272
|
function hasUnescapedStar(s) {
|
|
228
273
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -236,33 +281,6 @@ function hasUnescapedStar(s) {
|
|
|
236
281
|
}
|
|
237
282
|
return false;
|
|
238
283
|
}
|
|
239
|
-
function isImportedWildcardContent(content) {
|
|
240
|
-
return !content.endsWith(":*") && hasUnescapedStar(content);
|
|
241
|
-
}
|
|
242
|
-
export function translateImportedWildcardRule(text) {
|
|
243
|
-
const parsed = parsePermissionRule(text);
|
|
244
|
-
const content = parsed.ruleContent;
|
|
245
|
-
if (content === undefined || parsed.toolName !== "Bash" || !isImportedWildcardContent(content))
|
|
246
|
-
return undefined;
|
|
247
|
-
const body = /^(.+) \*$/.exec(content)?.[1];
|
|
248
|
-
if (body === undefined || body.trim() === "") {
|
|
249
|
-
return {
|
|
250
|
-
skip: "unsupported.wildcard: only the trailing `<command> *` form has a prefix-rule equivalent here — it stays in the settings file, unimported",
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
if (hasUnescapedStar(body)) {
|
|
254
|
-
return {
|
|
255
|
-
skip: "unsupported.wildcard: a `*` before the end of the pattern matches anything at that position, which has no rule form in this version — it stays in the settings file, unimported",
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
const bodyShape = ruleLaneShapeOf(body, MATCH_READING);
|
|
259
|
-
if (!("reject" in bodyShape) && bodyShape.segments.length > 1) {
|
|
260
|
-
return {
|
|
261
|
-
skip: "unsupported.wildcard: a `<chain> *` pattern names more than one command, and the trailing-star form has no equivalent for a chain — it stays in the settings file, unimported",
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
return { rule: formatAllowRuleText(body, "prefix") };
|
|
265
|
-
}
|
|
266
284
|
export function ruleAdmitsCommand(rule, command) {
|
|
267
285
|
return admitsUnder(rule, command, MATCH_READING);
|
|
268
286
|
}
|
|
@@ -270,6 +288,8 @@ export function ruleAdmitsProgramRun(rule, command) {
|
|
|
270
288
|
return admitsUnder(rule, command, PROGRAM_RUNS_READING);
|
|
271
289
|
}
|
|
272
290
|
function admitsUnder(rule, command, reading) {
|
|
291
|
+
if (rule.match === "subpath")
|
|
292
|
+
return false;
|
|
273
293
|
const shape = ruleLaneShapeOf(command, reading);
|
|
274
294
|
if ("reject" in shape)
|
|
275
295
|
return false;
|
|
@@ -285,6 +305,30 @@ function admitsUnder(rule, command, reading) {
|
|
|
285
305
|
return false;
|
|
286
306
|
return folded === rule.command || folded.startsWith(rule.command + " ");
|
|
287
307
|
}
|
|
308
|
+
export function ruleBreadthWarningsOf(rule) {
|
|
309
|
+
if (rule.match !== "prefix" && rule.match !== "wildcard")
|
|
310
|
+
return [];
|
|
311
|
+
const bodyShape = ruleLaneShapeOf(rule.command, MATCH_READING);
|
|
312
|
+
if ("reject" in bodyShape)
|
|
313
|
+
return [];
|
|
314
|
+
if (bodyShape.segments.length > 1) {
|
|
315
|
+
return [
|
|
316
|
+
{
|
|
317
|
+
code: "compound_prefix",
|
|
318
|
+
message: `this rule's body is a command chain — it admits every run of the whole chain "${rule.command}", with any text extending its final segment`,
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
if (rule.command.split(/\s+/).filter((t) => t !== "").length === 1) {
|
|
323
|
+
return [
|
|
324
|
+
{
|
|
325
|
+
code: "broad_prefix",
|
|
326
|
+
message: `this rule admits every argument form of "${rule.command}" — any \`${rule.command} …\` command, not just the one on this card`,
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
288
332
|
export function ruleLaneSegmentsOf(command) {
|
|
289
333
|
const shape = ruleLaneShapeOf(command, PROGRAM_RUNS_READING);
|
|
290
334
|
return "reject" in shape ? undefined : shape.segments;
|
|
@@ -295,6 +339,134 @@ export function pathWithinRoot(path, root) {
|
|
|
295
339
|
const base = root.endsWith("/") ? root : root + "/";
|
|
296
340
|
return path.startsWith(base);
|
|
297
341
|
}
|
|
342
|
+
export function lexicalNormalAbsolutePathOf(path) {
|
|
343
|
+
if (typeof path !== "string" || !path.startsWith("/"))
|
|
344
|
+
return undefined;
|
|
345
|
+
const out = [];
|
|
346
|
+
for (const seg of path.split("/")) {
|
|
347
|
+
if (seg === "" || seg === ".")
|
|
348
|
+
continue;
|
|
349
|
+
if (seg === "..") {
|
|
350
|
+
if (out.length === 0)
|
|
351
|
+
return undefined;
|
|
352
|
+
out.pop();
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
out.push(seg);
|
|
356
|
+
}
|
|
357
|
+
return out.length === 0 ? "/" : "/" + out.join("/");
|
|
358
|
+
}
|
|
359
|
+
function isLexicalNormalAbsoluteDir(path) {
|
|
360
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//"))
|
|
361
|
+
return false;
|
|
362
|
+
const segments = path.split("/").slice(1);
|
|
363
|
+
return segments.length > 0 && segments.every((s) => s !== "" && s !== "." && s !== "..");
|
|
364
|
+
}
|
|
365
|
+
export function directoryRuleAdmits(rule, path) {
|
|
366
|
+
if (rule.tool !== "Read" || rule.match !== "subpath")
|
|
367
|
+
return false;
|
|
368
|
+
if (!isLexicalNormalAbsoluteDir(rule.command))
|
|
369
|
+
return false;
|
|
370
|
+
if (!isLexicalNormalAbsoluteDir(path))
|
|
371
|
+
return false;
|
|
372
|
+
return pathWithinRoot(path, rule.command);
|
|
373
|
+
}
|
|
374
|
+
const CD_TARGET_REJECT_CHARS = /[*?[\]{}$`\\'"<>\n\r]/;
|
|
375
|
+
function cdSegmentDirectoryOf(segment, opts) {
|
|
376
|
+
if (/[^\S \t]/.test(segment))
|
|
377
|
+
return undefined;
|
|
378
|
+
const tokens = segment.split(/[ \t]+/).filter((t) => t !== "");
|
|
379
|
+
if (tokens.length !== 2 || tokens[0] !== "cd")
|
|
380
|
+
return undefined;
|
|
381
|
+
const target = tokens[1];
|
|
382
|
+
if (CD_TARGET_REJECT_CHARS.test(target))
|
|
383
|
+
return undefined;
|
|
384
|
+
if (target.startsWith("-") || target.startsWith("~"))
|
|
385
|
+
return undefined;
|
|
386
|
+
let resolved;
|
|
387
|
+
if (target.startsWith("//"))
|
|
388
|
+
return undefined;
|
|
389
|
+
if (target.startsWith("/")) {
|
|
390
|
+
resolved = target;
|
|
391
|
+
}
|
|
392
|
+
else if (target.startsWith("./")) {
|
|
393
|
+
if (opts.priorCd || opts.cwd === undefined)
|
|
394
|
+
return undefined;
|
|
395
|
+
if (!opts.cwd.startsWith("/") || opts.cwd.startsWith("//"))
|
|
396
|
+
return undefined;
|
|
397
|
+
resolved = opts.cwd.replace(/\/+$/, "") + target.slice(1);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
if (!isLexicalNormalAbsoluteDir(resolved))
|
|
403
|
+
return undefined;
|
|
404
|
+
if (HOME_TOP_SHAPE.test(resolved))
|
|
405
|
+
return undefined;
|
|
406
|
+
if (HOME_ANCESTOR_SHAPE.test(resolved))
|
|
407
|
+
return undefined;
|
|
408
|
+
const processHome = userHomeTopLevel();
|
|
409
|
+
if (processHome !== undefined && (resolved === processHome || isStrictAncestorDir(resolved, processHome))) {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
return resolved;
|
|
413
|
+
}
|
|
414
|
+
function isStrictAncestorDir(ancestor, path) {
|
|
415
|
+
return ancestor !== "/" && path.startsWith(`${ancestor}/`);
|
|
416
|
+
}
|
|
417
|
+
const HOME_TOP_SHAPE = /^(?:\/home\/[^/]+|\/Users\/[^/]+|\/root|\/var\/root)$/;
|
|
418
|
+
const HOME_ANCESTOR_SHAPE = /^(?:\/home|\/Users|\/var)$/;
|
|
419
|
+
let HOME_TOP_LEVEL = null;
|
|
420
|
+
function userHomeTopLevel() {
|
|
421
|
+
if (HOME_TOP_LEVEL === null) {
|
|
422
|
+
try {
|
|
423
|
+
const h = homedir();
|
|
424
|
+
HOME_TOP_LEVEL = typeof h === "string" && h.startsWith("/") && !h.startsWith("//") ? h.replace(/\/+$/, "") : undefined;
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
HOME_TOP_LEVEL = undefined;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return HOME_TOP_LEVEL === null ? undefined : HOME_TOP_LEVEL;
|
|
431
|
+
}
|
|
432
|
+
const CURSOR_MOVING_HEADS = new Set(["cd", "pushd", "popd"]);
|
|
433
|
+
const OPAQUE_CURSOR_MOVERS = new Set(["eval", "source", ".", "trap", "enable", "builtin", "command", "time"]);
|
|
434
|
+
function pushdPopdLeavesCursor(head, args) {
|
|
435
|
+
for (const a of args) {
|
|
436
|
+
if (a === "--")
|
|
437
|
+
return false;
|
|
438
|
+
if (/^-[a-zA-Z]*n[a-zA-Z]*$/.test(a))
|
|
439
|
+
return true;
|
|
440
|
+
if (head === "popd" && /^\+0*[1-9]\d*$/.test(a))
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
function movesShellCursor(segment) {
|
|
446
|
+
if (carriesShellRedirection(segment))
|
|
447
|
+
return true;
|
|
448
|
+
const words = segment
|
|
449
|
+
.split(/[ \t]+/)
|
|
450
|
+
.map((w) => w.replace(/['"]/g, ""))
|
|
451
|
+
.filter((t) => t !== "");
|
|
452
|
+
const head = words[0];
|
|
453
|
+
if (head === undefined)
|
|
454
|
+
return false;
|
|
455
|
+
if (OPAQUE_CURSOR_MOVERS.has(head))
|
|
456
|
+
return true;
|
|
457
|
+
if (!CURSOR_MOVING_HEADS.has(head))
|
|
458
|
+
return false;
|
|
459
|
+
return !((head === "pushd" || head === "popd") && pushdPopdLeavesCursor(head, words.slice(1)));
|
|
460
|
+
}
|
|
461
|
+
function resolveCdSegmentDirectories(segments, cwd) {
|
|
462
|
+
let priorCd = false;
|
|
463
|
+
return segments.map((segment) => {
|
|
464
|
+
const dir = cdSegmentDirectoryOf(segment, { cwd, priorCd });
|
|
465
|
+
if (!priorCd && movesShellCursor(segment))
|
|
466
|
+
priorCd = true;
|
|
467
|
+
return dir;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
298
470
|
export function scopeCoversCwd(scope, cwd, sessionId) {
|
|
299
471
|
if (scope.kind === "global")
|
|
300
472
|
return true;
|
|
@@ -327,11 +499,16 @@ export function findAdmittingRule(rules, call) {
|
|
|
327
499
|
const whole = firstEligibleAdmittingRule(rules, call.command, call);
|
|
328
500
|
if (whole !== undefined)
|
|
329
501
|
return [whole];
|
|
330
|
-
|
|
331
|
-
|
|
502
|
+
const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
|
|
503
|
+
if (shape.segments.length < 2) {
|
|
504
|
+
const dir = cdDirs[0];
|
|
505
|
+
const lone = dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined;
|
|
506
|
+
return lone !== undefined ? [lone] : undefined;
|
|
507
|
+
}
|
|
332
508
|
const covering = [];
|
|
333
|
-
for (const segment of shape.segments) {
|
|
334
|
-
const
|
|
509
|
+
for (const [i, segment] of shape.segments.entries()) {
|
|
510
|
+
const dir = cdDirs[i];
|
|
511
|
+
const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined);
|
|
335
512
|
if (first === undefined)
|
|
336
513
|
return undefined;
|
|
337
514
|
if (!covering.includes(first))
|
|
@@ -339,6 +516,15 @@ export function findAdmittingRule(rules, call) {
|
|
|
339
516
|
}
|
|
340
517
|
return covering;
|
|
341
518
|
}
|
|
519
|
+
function firstEligibleDirectoryRule(rules, directory, call) {
|
|
520
|
+
for (const rule of rules) {
|
|
521
|
+
if (!eligiblePersisted(rule, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }))
|
|
522
|
+
continue;
|
|
523
|
+
if (directoryRuleAdmits(rule, directory))
|
|
524
|
+
return rule;
|
|
525
|
+
}
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
342
528
|
export function segmentCoverageOf(command, rules, call) {
|
|
343
529
|
const folded = foldSpacing(command);
|
|
344
530
|
if (folded === undefined)
|
|
@@ -347,12 +533,31 @@ export function segmentCoverageOf(command, rules, call) {
|
|
|
347
533
|
if ("reject" in shape)
|
|
348
534
|
return undefined;
|
|
349
535
|
const proposed = rules.proposed ?? [];
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
536
|
+
const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
|
|
537
|
+
return shape.segments.map((segment, i) => {
|
|
538
|
+
const dir = cdDirs[i];
|
|
539
|
+
return {
|
|
540
|
+
segment: segment.trim(),
|
|
541
|
+
covered: firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
|
|
542
|
+
(dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) ||
|
|
543
|
+
proposed.some((p) => p.rule.tool === "Read"
|
|
544
|
+
? dir !== undefined &&
|
|
545
|
+
eligibleContext({ tool: p.rule.tool, scope: p.scope }, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }) &&
|
|
546
|
+
directoryRuleAdmits(p.rule, dir)
|
|
547
|
+
: eligibleContext({ tool: p.rule.tool, scope: p.scope }, call) && ruleAdmitsCommand(p.rule, segment)),
|
|
548
|
+
};
|
|
549
|
+
});
|
|
355
550
|
}
|
|
551
|
+
export const UNCOVERED_SEGMENT_REASON_BASELINE = {
|
|
552
|
+
redirection: "This part carries a redirection, so it is approved per use — no rule can cover this spelling. If you trust its redirection-free form, a rule can be written for that form (the card's edit row, or settings).",
|
|
553
|
+
no_rule_form: "No rule form exists for this part; it can be approved per use.",
|
|
554
|
+
cap_overflow: "This batch does not cover this part; you will be asked again when it comes up.",
|
|
555
|
+
};
|
|
556
|
+
export const RULE_OFFERS_ABSENCE_BASELINE = {
|
|
557
|
+
mandated: "This approval is required by the deployment's policy each time; a rule cannot waive it.",
|
|
558
|
+
lane_cannot_speak: "This card has no rule to offer for this exact command; it can be approved per use. A coverable form of what you trust can still get a rule — through a card drawn for that form, or settings.",
|
|
559
|
+
shadowed: "A rule you already have matches this call and still asks — that standing rule keeps deciding, so a new rule minted here would change nothing; review your existing rules instead.",
|
|
560
|
+
};
|
|
356
561
|
export function suggestRulesForCommand(command, ctx) {
|
|
357
562
|
const shape = ruleLaneShapeOf(command, OFFER_READING);
|
|
358
563
|
if ("reject" in shape)
|
|
@@ -367,16 +572,44 @@ export function suggestRulesForCommand(command, ctx) {
|
|
|
367
572
|
}
|
|
368
573
|
const tokensOf = (s) => s.split(/\s+/).filter((t) => t !== "");
|
|
369
574
|
const bodyOf = (tokens) => longestReviewedBody(tokens) ?? genericPrefixBody(tokens);
|
|
575
|
+
const directoryGateOpen = (ctx?.scope?.kind === "project" || ctx?.scope?.kind === "session") && scopeCoversCwd(ctx.scope, ctx?.cwd, ctx?.sessionId);
|
|
576
|
+
const deniesDirectory = ctx?.deniesDirectoryRead ?? defaultDeniesDirectoryRead;
|
|
577
|
+
const mintDirectoryMember = (directory, segment) => {
|
|
578
|
+
if (!directoryGateOpen || directory === undefined)
|
|
579
|
+
return undefined;
|
|
580
|
+
let denied;
|
|
581
|
+
try {
|
|
582
|
+
denied = deniesDirectory(directory) === true;
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
denied = true;
|
|
586
|
+
}
|
|
587
|
+
if (denied)
|
|
588
|
+
return undefined;
|
|
589
|
+
const parsed = parseAllowRuleText(formatAllowRuleText(directory, "subpath"));
|
|
590
|
+
if (!("rule" in parsed) || parsed.rule.match !== "subpath")
|
|
591
|
+
return undefined;
|
|
592
|
+
if (!directoryRuleAdmits(parsed.rule, directory))
|
|
593
|
+
return undefined;
|
|
594
|
+
return { kind: "directoryRead", rule: parsed.rule.rule, directory: parsed.rule.command, segment };
|
|
595
|
+
};
|
|
370
596
|
if (shape.segments.length === 1) {
|
|
371
597
|
if (offers.length === 1) {
|
|
372
598
|
const body = bodyOf(tokensOf(folded));
|
|
373
599
|
if (body !== undefined) {
|
|
374
|
-
const parsed = parseAllowRuleText(formatAllowRuleText(body, "
|
|
375
|
-
if ("rule" in parsed && parsed.rule.match === "
|
|
376
|
-
offers.push({ kind: "single", rule: parsed.rule.rule, match: "
|
|
600
|
+
const parsed = parseAllowRuleText(formatAllowRuleText(body, "wildcard"));
|
|
601
|
+
if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, command)) {
|
|
602
|
+
offers.push({ kind: "single", rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command });
|
|
377
603
|
}
|
|
378
604
|
}
|
|
379
605
|
}
|
|
606
|
+
const lone = folded.trim();
|
|
607
|
+
const loneCovered = ctx?.coverage !== undefined && ctx.coverage.length === 1 && ctx.coverage[0]?.segment === lone && ctx.coverage[0]?.covered === true;
|
|
608
|
+
if (!loneCovered) {
|
|
609
|
+
const member = mintDirectoryMember(resolveCdSegmentDirectories([lone], ctx?.execCwd ?? ctx?.cwd)[0], lone);
|
|
610
|
+
if (member !== undefined)
|
|
611
|
+
offers.push({ kind: "batch", rules: [member], uncoveredSegments: 0, uncoveredDetail: [] });
|
|
612
|
+
}
|
|
380
613
|
return offers;
|
|
381
614
|
}
|
|
382
615
|
const foldedShape = ruleLaneShapeOf(folded, OFFER_READING);
|
|
@@ -391,9 +624,9 @@ export function suggestRulesForCommand(command, ctx) {
|
|
|
391
624
|
return undefined;
|
|
392
625
|
const body = bodyOf(tokensOf(segment));
|
|
393
626
|
if (body !== undefined) {
|
|
394
|
-
const parsed = parseAllowRuleText(formatAllowRuleText(body, "
|
|
395
|
-
if ("rule" in parsed && parsed.rule.match === "
|
|
396
|
-
return { rule: parsed.rule.rule, match: "
|
|
627
|
+
const parsed = parseAllowRuleText(formatAllowRuleText(body, "wildcard"));
|
|
628
|
+
if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, segment)) {
|
|
629
|
+
return { rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command };
|
|
397
630
|
}
|
|
398
631
|
}
|
|
399
632
|
const segExact = parseAllowRuleText(formatAllowRuleText(segment, "exact"));
|
|
@@ -402,14 +635,19 @@ export function suggestRulesForCommand(command, ctx) {
|
|
|
402
635
|
}
|
|
403
636
|
return undefined;
|
|
404
637
|
};
|
|
638
|
+
const cdDirs = resolveCdSegmentDirectories(segments, ctx?.execCwd ?? ctx?.cwd);
|
|
405
639
|
const minted = [];
|
|
406
640
|
for (let i = 0; i < segments.length; i++) {
|
|
407
641
|
if (coveredAt(i))
|
|
408
642
|
continue;
|
|
409
643
|
const segment = segments[i];
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
644
|
+
const dirMember = mintDirectoryMember(cdDirs[i], segment);
|
|
645
|
+
const member = dirMember ?? (() => {
|
|
646
|
+
const rule = mintSegmentRule(segment);
|
|
647
|
+
return rule !== undefined ? { kind: "command", ...rule, segment } : undefined;
|
|
648
|
+
})();
|
|
649
|
+
if (member !== undefined && !minted.some((m) => m.rule === member.rule))
|
|
650
|
+
minted.push(member);
|
|
413
651
|
}
|
|
414
652
|
const batchRules = minted.slice(0, 5);
|
|
415
653
|
if (batchRules.length > 0) {
|
|
@@ -417,8 +655,28 @@ export function suggestRulesForCommand(command, ctx) {
|
|
|
417
655
|
const p = parseAllowRuleText(r.rule);
|
|
418
656
|
return "rule" in p ? [p.rule] : [];
|
|
419
657
|
});
|
|
420
|
-
const
|
|
421
|
-
|
|
658
|
+
const uncoveredDetail = [];
|
|
659
|
+
for (let i = 0; i < segments.length; i++) {
|
|
660
|
+
const segment = segments[i];
|
|
661
|
+
if (coveredAt(i))
|
|
662
|
+
continue;
|
|
663
|
+
if (parsedBatch.some((p) => p.tool !== "Read" && ruleAdmitsCommand(p, segment)))
|
|
664
|
+
continue;
|
|
665
|
+
const dir = cdDirs[i];
|
|
666
|
+
if (dir !== undefined && parsedBatch.some((p) => directoryRuleAdmits(p, dir)))
|
|
667
|
+
continue;
|
|
668
|
+
const reason = carriesShellRedirection(segment) ? "redirection" : mintSegmentRule(segment) === undefined ? "no_rule_form" : "cap_overflow";
|
|
669
|
+
uncoveredDetail.push({ segment, reason });
|
|
670
|
+
}
|
|
671
|
+
offers.push({ kind: "batch", rules: batchRules, uncoveredSegments: uncoveredDetail.length, uncoveredDetail });
|
|
422
672
|
}
|
|
423
673
|
return offers;
|
|
424
674
|
}
|
|
675
|
+
let DEFAULT_DIRECTORY_DENY;
|
|
676
|
+
function defaultDeniesDirectoryRead(directory) {
|
|
677
|
+
if (DEFAULT_DIRECTORY_DENY === undefined) {
|
|
678
|
+
const matcher = compileReadDeny([], "directoryRead mint floor");
|
|
679
|
+
DEFAULT_DIRECTORY_DENY = (d) => matcher.matchPath(d) !== null;
|
|
680
|
+
}
|
|
681
|
+
return DEFAULT_DIRECTORY_DENY(directory);
|
|
682
|
+
}
|
|
@@ -191,8 +191,12 @@ export function unenforceableOrgRules(rules) {
|
|
|
191
191
|
if (typeof r?.rule !== "string" || r.rule === "")
|
|
192
192
|
continue;
|
|
193
193
|
const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
|
|
194
|
-
if ("reject" in parsed)
|
|
194
|
+
if ("reject" in parsed) {
|
|
195
195
|
out.push({ rule: r.rule, code: parsed.reject.code });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (parsed.rule.tool !== "Bash")
|
|
199
|
+
out.push({ rule: r.rule, code: "unsupported.tool" });
|
|
196
200
|
}
|
|
197
201
|
return out;
|
|
198
202
|
}
|
|
@@ -259,11 +263,14 @@ export async function effectivePermissionRules(opts) {
|
|
|
259
263
|
const orgDenies = (opts.orgSnapshot?.rules ?? []).filter((r) => r.behavior === "deny");
|
|
260
264
|
const out = [];
|
|
261
265
|
for (const r of listed.rules) {
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
266
|
+
const shadowed = r.tool === "Bash" &&
|
|
267
|
+
(() => {
|
|
268
|
+
const segments = ruleLaneSegmentsOf(r.command);
|
|
269
|
+
return orgDenies.some((d) => {
|
|
270
|
+
const parsed = parseAllowRuleText(d.rule, { direction: "tighten" });
|
|
271
|
+
return !("reject" in parsed) && parsed.rule.tool === r.tool && orgRuleReaches(parsed.rule, r.command, segments);
|
|
272
|
+
});
|
|
273
|
+
})();
|
|
267
274
|
out.push({ rule: r.rule, scope: r.scope, status: shadowed ? "shadowed-by-org" : "live" });
|
|
268
275
|
}
|
|
269
276
|
for (const t of listed.tombstones) {
|
|
@@ -383,8 +383,15 @@ export interface RemoteExecutionEnv extends ExecutionEnv {
|
|
|
383
383
|
* 🔴 Ordering red line (design/48 §5/#6): at-rest encryption of the memory snapshot must be ensured BEFORE
|
|
384
384
|
* secrets are injected — never let plaintext credentials land in an unencrypted snapshot. That encryption is a
|
|
385
385
|
* service-side property of the snapshot store; this method must fail-and-clean if it cannot be guaranteed.
|
|
386
|
+
*
|
|
387
|
+
* `opts.abortSignal` (design/384 slice 2, ADDITIVE — implementations are not forced to change): an
|
|
388
|
+
* early-release channel for a caller whose wait on this init is bounded (the park fence's paused-VM
|
|
389
|
+
* compensation). Best-effort exactly like {@link suspendVM}'s — an implementation may ignore it; the
|
|
390
|
+
* caller bounds its own wait and treats a deaf adapter as failed.
|
|
386
391
|
*/
|
|
387
|
-
postResumeInit(
|
|
392
|
+
postResumeInit(opts?: {
|
|
393
|
+
abortSignal?: AbortSignal;
|
|
394
|
+
}): Promise<{
|
|
388
395
|
ok: true;
|
|
389
396
|
value: void;
|
|
390
397
|
} | {
|
|
@@ -180,5 +180,6 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
|
|
|
180
180
|
void _internalCompaction;
|
|
181
181
|
if (flags.unpricedSpend)
|
|
182
182
|
delete publicStats.costMicroUsd;
|
|
183
|
-
|
|
183
|
+
const stampHaltedByUser = flags.userHalted === true && status !== "suspended" && status !== "needs_review";
|
|
184
|
+
return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(stampHaltedByUser ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: [...flags.remoteEnvFailures] } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
|
|
184
185
|
}
|
|
@@ -24,13 +24,14 @@ import { type ToolManifestRow } from "../../prompt-assembly/tool-catalog.js";
|
|
|
24
24
|
import type { ToolDisclosureManifest } from "../trace.js";
|
|
25
25
|
import { type ClearedProjectionLedger, type ContextEditMachine, type OccurrenceIndex } from "../context-edit.js";
|
|
26
26
|
import type { TaskNotificationPayload } from "../task-notification.js";
|
|
27
|
+
import type { RemoteExecutionEnv, SnapshotId } from "../remote-env.js";
|
|
27
28
|
import { type CwdRef, type ReadFace } from "../../tools/fs/index.js";
|
|
28
29
|
import { type WorkflowSizeGuideline } from "../../orchestration/workflow-size-guideline.js";
|
|
29
30
|
import type { Runner } from "./runtask.js";
|
|
30
31
|
import { type CheckpointGate, type CheckpointState, type CheckpointToken, type ResourceLedger, type PlatformLimitReason, type ResourceLimitReason } from "../checkpoint-store.js";
|
|
31
32
|
import { type WiringManifest } from "../wiring-manifest.js";
|
|
32
33
|
import type { ActiveWorktreeSession, AgentMessage, AgentTool, ExecutionEnv } from "../../internal/harness.js";
|
|
33
|
-
import type { NestedUsageAccum, RunnerDeps, TaskEvent, TaskResult, TaskSpec, ToolActivity, ToolEffect } from "../types.js";
|
|
34
|
+
import type { NestedUsageAccum, RemoteEnvFailureNote, RunnerDeps, TaskEvent, TaskResult, TaskSpec, ToolActivity, ToolEffect } from "../types.js";
|
|
34
35
|
import type { RepairBundle } from "../../agents/repair-loop.js";
|
|
35
36
|
/** Test seam (mirrors `__resetToolModelGateAnnouncements`): never called by production code.
|
|
36
37
|
* Re-arms BOTH arms (a WeakMap has no clear — it is re-minted). */
|
|
@@ -1896,6 +1897,43 @@ export declare function batchContextAt(messages: AgentMessage[], currentId: stri
|
|
|
1896
1897
|
batchToolCallIds: string[];
|
|
1897
1898
|
completedCallIds: string[];
|
|
1898
1899
|
};
|
|
1900
|
+
/**
|
|
1901
|
+
* design/384 slice 2 — the ONE compensation for a paused-but-unparked VM, shared by the fence's
|
|
1902
|
+
* post-pause checkpoints (③ in the park closure, ④ in the saga) and the saga's put-failure absent
|
|
1903
|
+
* arm (previously inline there: same two hops, one implementation now, so the three sites cannot
|
|
1904
|
+
* drift). Restores the workspace (bounded transient retry) then re-establishes consistency
|
|
1905
|
+
* (`postResumeInit`), both hops under one INDEPENDENT `AbortSignal.timeout(io.boundMs)` —
|
|
1906
|
+
* deliberately NOT the run/cut signal: the remote contract answers an already-aborted signal
|
|
1907
|
+
* `{ok:false,"aborted"}`, so gating the restore on the very signal whose firing caused the
|
|
1908
|
+
* compensation made it die instantly and strand the paused VM on exactly the run-abort arm that
|
|
1909
|
+
* needs it most. The adapter signal is best-effort (a deaf adapter ignores it), so the caller's
|
|
1910
|
+
* await is ALSO raced against the same bound — bounded decision, three outcomes:
|
|
1911
|
+
* · settled ok ⇒ the VM is running again ({ok:true});
|
|
1912
|
+
* · settled not-ok / threw ⇒ recorded + disclosed, {ok:false} — the caller takes its fatal
|
|
1913
|
+
* fail-closed arm (abort the run; never continue on a paused VM);
|
|
1914
|
+
* · bound fires with the adapter still in flight ⇒ {ok:false} NOW, and the in-flight call
|
|
1915
|
+
* continues DETACHED + swallow-guarded with the late-settlement split:
|
|
1916
|
+
* – late SUCCESS: a running VM now exists with no run and no committed row to own it — the
|
|
1917
|
+
* detached continuation compensates the compensation with `destroy()` (the adapter's own
|
|
1918
|
+
* best-effort contract, swallow-guarded, disclosed);
|
|
1919
|
+
* – late FAILURE: disclosed; the VM is most likely still paused — provider-side TTL/GC
|
|
1920
|
+
* territory, and the disclosure is the end of this engine's obligation (a deaf adapter's
|
|
1921
|
+
* stranded VM is that adapter's own defect surface).
|
|
1922
|
+
* Decide-then-disclose throughout: `io.disclose`/`io.noteFailure` must be swallow-guarded by the
|
|
1923
|
+
* caller's binding, and nothing they do can change the returned verdict. Never throws. Exported for
|
|
1924
|
+
* direct unit pinning (the bounded/deaf/late arms need a small bound; production binds the
|
|
1925
|
+
* `PARK_COMPENSATION_TIMEOUT_MS` constant at the one call-site closure).
|
|
1926
|
+
*/
|
|
1927
|
+
export declare function compensateUnparkedPause(remoteEnv: RemoteExecutionEnv, snapshotId: SnapshotId, io: {
|
|
1928
|
+
boundMs: number;
|
|
1929
|
+
noteFailure: (note: RemoteEnvFailureNote) => void;
|
|
1930
|
+
disclose: (err: unknown) => void;
|
|
1931
|
+
}): Promise<{
|
|
1932
|
+
ok: true;
|
|
1933
|
+
} | {
|
|
1934
|
+
ok: false;
|
|
1935
|
+
reason: string;
|
|
1936
|
+
}>;
|
|
1899
1937
|
/**
|
|
1900
1938
|
* rescan C5 — the ONE spelling of the placement fields' empty-string discipline: `""` is absence
|
|
1901
1939
|
* wearing clothes (the resume entry's principal-rung posture), and every placement read that must
|