@stratusagent/permissions 0.14.1 → 0.14.3
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/LICENSE +202 -21
- package/NOTICE +5 -0
- package/dist/index.d.ts +37 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +298 -29
- package/dist/index.js.map +1 -1
- package/dist/review.d.ts +148 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +511 -0
- package/dist/review.js.map +1 -0
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { sessionTaintedBy, sessionTrustOf } from '@stratusagent/core';
|
|
|
6
6
|
*/
|
|
7
7
|
export { atLeastAsRisky } from '@stratusagent/core';
|
|
8
8
|
import { downloadInsideWorkspace, gitInsideWorkspace, gitPushInsideWorkspace, readsInsideWorkspace } from "./workspace.js";
|
|
9
|
+
import { builtInReviewRule, describeReviewRule, matchReviewRule, readReviewAnswer, ReviewFailure, } from "./review.js";
|
|
10
|
+
export { builtInReviewRule, describeReviewRule, matchReviewRule, parseReviewRule, readReviewAnswer, REVIEW_CODES, ReviewFailure, } from "./review.js";
|
|
9
11
|
export { downloadInsideWorkspace, gitInsideWorkspace, gitPushInsideWorkspace, readsInsideWorkspace } from "./workspace.js";
|
|
10
12
|
import { analyzeCommand, describeCommandScope, findCoveringScopes, findMatchingScope, normalizeCommandScope, SAFE_COMMAND_SCOPES, } from "./commands.js";
|
|
11
13
|
import { describeOriginScope, findMatchingOriginScope, originScopeFor, trustedDomainOf, } from "./origins.js";
|
|
@@ -77,6 +79,8 @@ const READ_ORIGIN_TOOL = 'web.fetch';
|
|
|
77
79
|
const YES = new Set(['y', 'yes', 'always', 'a']);
|
|
78
80
|
const ALWAYS = new Set(['always', 'a']);
|
|
79
81
|
const ABORTED = Symbol('aborted');
|
|
82
|
+
/** How long one review may take before it counts as failed (`approvals.review.timeoutMs`). */
|
|
83
|
+
export const DEFAULT_REVIEW_TIMEOUT_MS = 20_000;
|
|
80
84
|
/**
|
|
81
85
|
* Waits for `work`, but gives up the moment the turn is cancelled.
|
|
82
86
|
*
|
|
@@ -197,7 +201,9 @@ const awaitPrompt = async (context, ask, command, origin,
|
|
|
197
201
|
* is the only place that knows. Offering a lifetime the engine will not
|
|
198
202
|
* create is the same defect as hiding one it will.
|
|
199
203
|
*/
|
|
200
|
-
alwaysMeans
|
|
204
|
+
alwaysMeans,
|
|
205
|
+
/** Why the review posture is asking, shown above the question. */
|
|
206
|
+
note) => {
|
|
201
207
|
const { call, risk, session } = context;
|
|
202
208
|
// The command, when there is one: for a shell call the tool name is the
|
|
203
209
|
// least interesting half of the question, and an approver shown only
|
|
@@ -218,7 +224,7 @@ alwaysMeans) => {
|
|
|
218
224
|
: argumentSummary !== undefined
|
|
219
225
|
? `${acting} (${risk}): ${argumentSummary}`
|
|
220
226
|
: `${acting} (${risk})`;
|
|
221
|
-
const pending = ask(`Allow ${what} for ${session.agent.name}? [y]es / [a]lways (${alwaysMeans}) / [N]o: `);
|
|
227
|
+
const pending = ask(`${note === undefined ? '' : `Review: ${note}\n`}Allow ${what} for ${session.agent.name}? [y]es / [a]lways (${alwaysMeans}) / [N]o: `);
|
|
222
228
|
// The answer is never read after this point if the turn ends first,
|
|
223
229
|
// so a late yes cannot execute a tool for work that no longer exists.
|
|
224
230
|
pending.catch(() => undefined);
|
|
@@ -243,7 +249,7 @@ alwaysMeans) => {
|
|
|
243
249
|
* its call was not approved and carry on, not crash on the machinery that
|
|
244
250
|
* was supposed to ask.
|
|
245
251
|
*/
|
|
246
|
-
const awaitRemote = async (context, request, origin, oneShot, always) => {
|
|
252
|
+
const awaitRemote = async (context, request, origin, oneShot, always, note) => {
|
|
247
253
|
const pending = request({
|
|
248
254
|
session: context.session,
|
|
249
255
|
call: context.call,
|
|
@@ -253,6 +259,7 @@ const awaitRemote = async (context, request, origin, oneShot, always) => {
|
|
|
253
259
|
...(oneShot ? { oneShot } : {}),
|
|
254
260
|
...(always !== undefined ? { always } : {}),
|
|
255
261
|
...(context.parkedAt ? { parkedAt: context.parkedAt } : {}),
|
|
262
|
+
...(note !== undefined ? { reviewNote: note } : {}),
|
|
256
263
|
...(context.signal ? { signal: context.signal } : {}),
|
|
257
264
|
}).then((outcome) => (typeof outcome === 'string' ? { answer: outcome } : outcome), () => ({ answer: 'deny' }));
|
|
258
265
|
return untilAborted(pending, context.signal);
|
|
@@ -298,7 +305,7 @@ const commandNames = (analysis) => {
|
|
|
298
305
|
return analysis.base;
|
|
299
306
|
};
|
|
300
307
|
export const createPermissionPolicy = (options) => {
|
|
301
|
-
const { mode, ask, request, onDecision, commands, destinations, origins, grants, gateExternalContent, openFor } = options;
|
|
308
|
+
const { mode, ask, request, onDecision, commands, destinations, origins, grants, gateExternalContent, openFor, review } = options;
|
|
302
309
|
if (mode === 'interactive' && !ask) {
|
|
303
310
|
throw new Error('interactive permission mode needs an `ask` function to reach a human.');
|
|
304
311
|
}
|
|
@@ -329,6 +336,14 @@ export const createPermissionPolicy = (options) => {
|
|
|
329
336
|
const rememberForProcess = (tier, agentId, grant) => {
|
|
330
337
|
tier.set(agentId, [...(tier.get(agentId) ?? []), grant]);
|
|
331
338
|
};
|
|
339
|
+
/**
|
|
340
|
+
* The review posture's bookkeeping, keyed by the call's context so the
|
|
341
|
+
* many `report` sites need no new arguments: which contexts are a shadow
|
|
342
|
+
* review (decisions marked `shadow`, nobody asked), and what a rule or
|
|
343
|
+
* the reviewer answered for a call that went on to a person.
|
|
344
|
+
*/
|
|
345
|
+
const shadowContexts = new WeakSet();
|
|
346
|
+
const reviewNotes = new WeakMap();
|
|
332
347
|
/** A site this agent may read without asking: trusted, or approved for `web.fetch`. */
|
|
333
348
|
const readableSite = async (agentId, site) => {
|
|
334
349
|
if (trustedDomainOf(site, origins?.trustedDomainsFor?.(agentId) ?? []) !== undefined) {
|
|
@@ -341,6 +356,13 @@ export const createPermissionPolicy = (options) => {
|
|
|
341
356
|
return findMatchingOriginScope(site, granted, READ_ORIGIN_TOOL) !== undefined;
|
|
342
357
|
};
|
|
343
358
|
const report = (context, layer, allowed, reason, command, destination, origin, grant, destinationGrant) => {
|
|
359
|
+
const note = reviewNotes.get(context);
|
|
360
|
+
const shadow = shadowContexts.has(context);
|
|
361
|
+
const reviewed = {
|
|
362
|
+
...(note === undefined ? {} : { verdict: note.verdict }),
|
|
363
|
+
...(note?.reviewCode === undefined ? {} : { reviewCode: note.reviewCode }),
|
|
364
|
+
...(shadow ? { shadow: true } : {}),
|
|
365
|
+
};
|
|
344
366
|
onDecision?.({
|
|
345
367
|
allowed,
|
|
346
368
|
mode,
|
|
@@ -348,6 +370,7 @@ export const createPermissionPolicy = (options) => {
|
|
|
348
370
|
toolName: context.call.toolName,
|
|
349
371
|
sessionId: context.session.id,
|
|
350
372
|
agentId: context.session.agent.id,
|
|
373
|
+
callId: context.call.id,
|
|
351
374
|
reason,
|
|
352
375
|
...(command === undefined ? {} : { command }),
|
|
353
376
|
...(destination === undefined ? {} : { destination }),
|
|
@@ -355,21 +378,202 @@ export const createPermissionPolicy = (options) => {
|
|
|
355
378
|
...(grant === undefined ? {} : { grant }),
|
|
356
379
|
...(destinationGrant === undefined ? {} : { destinationGrant }),
|
|
357
380
|
layer,
|
|
381
|
+
...reviewed,
|
|
358
382
|
});
|
|
359
|
-
|
|
383
|
+
// The same three fields the log gets, and nothing else: the runner's
|
|
384
|
+
// event carries no command, grant or destination record, so what a
|
|
385
|
+
// dashboard shows is a classification, never the call's input. The
|
|
386
|
+
// review fields are a verdict and a fixed code, never the reviewer's
|
|
387
|
+
// words.
|
|
388
|
+
context.decided?.({ allowed, layer, reason, ...reviewed });
|
|
389
|
+
// The reason is what the agent is told: it names the check that
|
|
390
|
+
// refused and what would cover the call, which is what lets the agent
|
|
391
|
+
// pick another way instead of trying the same call again.
|
|
392
|
+
return allowed ? true : { allowed: false, reason };
|
|
360
393
|
};
|
|
361
|
-
|
|
394
|
+
/**
|
|
395
|
+
* A refusal whose words for the agent differ from the log's: the
|
|
396
|
+
* reviewer's line can echo the call's input, so it goes to the agent
|
|
397
|
+
* (and, for an ask, the approver) and never into a decision.
|
|
398
|
+
*/
|
|
399
|
+
const refuse = (context, layer, reason, told) => {
|
|
400
|
+
report(context, layer, false, reason);
|
|
401
|
+
return { allowed: false, reason: told };
|
|
402
|
+
};
|
|
403
|
+
/**
|
|
404
|
+
* Puts one call to the reviewer, bounded by `timeoutMs` and the turn's
|
|
405
|
+
* signal. Never throws: a review that couldn't run is a failure kind,
|
|
406
|
+
* which the caller turns into an ask.
|
|
407
|
+
*/
|
|
408
|
+
const runReview = async (context, timeoutMs, resolved) => {
|
|
409
|
+
if (context.signal?.aborted) {
|
|
410
|
+
return ABORTED;
|
|
411
|
+
}
|
|
412
|
+
const reviewer = review?.reviewer;
|
|
413
|
+
if (reviewer === undefined) {
|
|
414
|
+
return { failed: 'no reviewer' };
|
|
415
|
+
}
|
|
416
|
+
const controller = new AbortController();
|
|
417
|
+
const onTurnAbort = () => controller.abort();
|
|
418
|
+
context.signal?.addEventListener('abort', onTurnAbort, { once: true });
|
|
419
|
+
let timer;
|
|
420
|
+
const timedOut = new Promise((resolve) => {
|
|
421
|
+
timer = setTimeout(() => {
|
|
422
|
+
controller.abort();
|
|
423
|
+
resolve({ failed: 'timeout' });
|
|
424
|
+
}, timeoutMs);
|
|
425
|
+
});
|
|
426
|
+
try {
|
|
427
|
+
// Inside a promise, so a reviewer that throws before it returns one
|
|
428
|
+
// is a failed review like any other, never a failed turn.
|
|
429
|
+
const asked = new Promise((resolve) => {
|
|
430
|
+
resolve(reviewer({
|
|
431
|
+
session: context.session,
|
|
432
|
+
call: context.call,
|
|
433
|
+
tool: context.tool,
|
|
434
|
+
risk: context.risk,
|
|
435
|
+
...resolved,
|
|
436
|
+
signal: controller.signal,
|
|
437
|
+
}));
|
|
438
|
+
}).then((raw) => {
|
|
439
|
+
const answer = readReviewAnswer(raw);
|
|
440
|
+
return answer === undefined ? { failed: 'unparseable' } : { answer };
|
|
441
|
+
}, (error) => ({ failed: error instanceof ReviewFailure ? error.kind : 'error' }));
|
|
442
|
+
const settled = await untilAborted(Promise.race([asked, timedOut]), context.signal);
|
|
443
|
+
return settled;
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
clearTimeout(timer);
|
|
447
|
+
context.signal?.removeEventListener('abort', onTurnAbort);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const policy = {
|
|
362
451
|
async approve(context) {
|
|
363
452
|
const { call, risk, session } = context;
|
|
364
453
|
if (risk === 'safe') {
|
|
365
454
|
return report(context, 'risk', true, `${call.toolName} is safe and runs without approval`);
|
|
366
455
|
}
|
|
367
|
-
|
|
456
|
+
// The review posture, for a gated call. A shadow review is this same
|
|
457
|
+
// method run again on a marked copy of the context, off the call
|
|
458
|
+
// path: it works the review out and asks nobody.
|
|
459
|
+
const reviewSettings = risk === 'gated' ? review?.settingsFor(session.agent.id) : undefined;
|
|
460
|
+
const shadowRun = shadowContexts.has(context);
|
|
461
|
+
const reviewing = reviewSettings !== undefined && (shadowRun || reviewSettings.shadow !== true);
|
|
462
|
+
if (reviewSettings?.shadow === true && !shadowRun && context.parkedAt === undefined) {
|
|
463
|
+
const shadowContext = { ...context };
|
|
464
|
+
shadowContexts.add(shadowContext);
|
|
465
|
+
void policy.approve(shadowContext).catch(() => undefined);
|
|
466
|
+
}
|
|
467
|
+
// A call parked before a restart is owed a person's answer: the fast
|
|
468
|
+
// path never takes it, whatever the posture has become since.
|
|
469
|
+
if (risk === 'gated' && !reviewing && context.parkedAt === undefined
|
|
470
|
+
&& (openFor?.(session.agent.id) === true || reviewSettings?.shadow === true)) {
|
|
368
471
|
const closedByContent = sessionTrustOf(session) === 'external' && gateExternalContent?.(session.agent.id) === true;
|
|
369
472
|
if (!closedByContent) {
|
|
370
|
-
return report(context, 'risk', true,
|
|
473
|
+
return report(context, 'risk', true, reviewSettings?.shadow === true
|
|
474
|
+
? `${call.toolName} runs without asking (approvals.review.shadow: reviewed off the call path, not enforced)`
|
|
475
|
+
: `${call.toolName} runs without asking (approvals.posture is open)`);
|
|
371
476
|
}
|
|
372
477
|
}
|
|
478
|
+
// Steps 3 to 5 of the review, before any grant is read: the
|
|
479
|
+
// operator's rules, the unreviewed run for a conversation that has
|
|
480
|
+
// read nothing from outside, and the built-in rules. An ask from any
|
|
481
|
+
// of them goes to a person and skips everything a grant could cover,
|
|
482
|
+
// since the first step that settles a call wins.
|
|
483
|
+
let reviewAsk;
|
|
484
|
+
const reviewHooks = [context.tool.commandFor, context.tool.destinationFor, context.tool.originFor]
|
|
485
|
+
.filter((hook) => hook !== undefined).length;
|
|
486
|
+
const reviewed = reviewing && reviewHooks <= 1
|
|
487
|
+
? {
|
|
488
|
+
command: context.tool.commandFor?.(call.input),
|
|
489
|
+
destination: context.tool.destinationFor?.(call.input),
|
|
490
|
+
origin: originScopeFor(context.tool.originFor?.(session, call.input) ?? '')?.origin,
|
|
491
|
+
}
|
|
492
|
+
: {};
|
|
493
|
+
const resolvedForReview = Object.fromEntries(Object.entries(reviewed).filter(([, value]) => value !== undefined));
|
|
494
|
+
// A call parked before a restart was waiting on a person, and goes
|
|
495
|
+
// back to one: nothing that would allow it now (a rule, the
|
|
496
|
+
// unreviewed path, a scope, the reviewer) may turn a pending ask into
|
|
497
|
+
// an allow. Only a block can still stop it first.
|
|
498
|
+
const resumed = reviewing && context.parkedAt !== undefined;
|
|
499
|
+
if (reviewing) {
|
|
500
|
+
const matched = matchReviewRule(reviewSettings.rules ?? [], { toolName: call.toolName, ...resolvedForReview });
|
|
501
|
+
if (matched !== undefined) {
|
|
502
|
+
reviewNotes.set(context, { verdict: matched.verdict });
|
|
503
|
+
const named = `the operator's rule (${describeReviewRule(matched)}) in approvals.rules`;
|
|
504
|
+
if (matched.verdict === 'allow' && !resumed) {
|
|
505
|
+
return report(context, 'rule', true, `${call.toolName} runs under ${named}`);
|
|
506
|
+
}
|
|
507
|
+
if (matched.verdict === 'block') {
|
|
508
|
+
return report(context, 'rule', false, `${call.toolName} is blocked by ${named}; only the operator can change it, so a confirmation won't help`);
|
|
509
|
+
}
|
|
510
|
+
if (matched.verdict === 'ask') {
|
|
511
|
+
reviewAsk = {
|
|
512
|
+
layer: 'rule',
|
|
513
|
+
reason: `${call.toolName} is asked about by ${named}`,
|
|
514
|
+
told: `${call.toolName} is asked about by ${named}`,
|
|
515
|
+
note: `asked about by the operator's rule (${describeReviewRule(matched)})`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (reviewAsk === undefined && !resumed && sessionTrustOf(session) !== 'external') {
|
|
520
|
+
return report(context, 'risk', true, `${call.toolName} not reviewed: this conversation has read nothing from outside`);
|
|
521
|
+
}
|
|
522
|
+
// The stored values, for the rule against sending one out, read
|
|
523
|
+
// within the review's time and the turn. A store that can't be read
|
|
524
|
+
// (or doesn't answer) asks, like any other part of the review that
|
|
525
|
+
// couldn't run: never a failed turn, never an allow. The metadata
|
|
526
|
+
// and token rules need no stored value, so they still run first.
|
|
527
|
+
let secrets = [];
|
|
528
|
+
let secretsUnreadable = false;
|
|
529
|
+
if (reviewAsk === undefined) {
|
|
530
|
+
secretsUnreadable = true;
|
|
531
|
+
let timer;
|
|
532
|
+
try {
|
|
533
|
+
const lookup = Promise.resolve(review?.secretsFor?.(session.agent.id));
|
|
534
|
+
const timedOut = new Promise((resolve) => {
|
|
535
|
+
timer = setTimeout(() => resolve(ABORTED), reviewSettings.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS);
|
|
536
|
+
});
|
|
537
|
+
const read = await untilAborted(Promise.race([lookup, timedOut]), context.signal);
|
|
538
|
+
if (read !== ABORTED) {
|
|
539
|
+
secrets = read ?? [];
|
|
540
|
+
secretsUnreadable = false;
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
lookup.catch(() => undefined);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// Unreadable: stays an ask below.
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
clearTimeout(timer);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (reviewAsk === undefined) {
|
|
554
|
+
const hit = builtInReviewRule({ toolName: call.toolName, input: call.input, ...(resolvedForReview.command === undefined ? {} : { command: resolvedForReview.command }) }, secrets);
|
|
555
|
+
if (hit !== undefined) {
|
|
556
|
+
reviewNotes.set(context, { verdict: hit.verdict });
|
|
557
|
+
if (hit.verdict === 'block') {
|
|
558
|
+
return report(context, 'rule', false, `${hit.reason}; only the operator can change that (approvals.rules)`);
|
|
559
|
+
}
|
|
560
|
+
reviewAsk = { layer: 'rule', reason: hit.reason, told: hit.reason, note: hit.reason };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (reviewAsk === undefined && secretsUnreadable) {
|
|
564
|
+
reviewNotes.set(context, { verdict: 'ask', reviewCode: 'review-failed' });
|
|
565
|
+
const reason = `${call.toolName} needs a person's yes because the review couldn't run: secrets-unreadable`;
|
|
566
|
+
reviewAsk = { layer: 'review', reason, told: reason, note: 'the review couldn\'t run: secrets-unreadable' };
|
|
567
|
+
}
|
|
568
|
+
if (reviewAsk === undefined && resumed) {
|
|
569
|
+
reviewNotes.set(context, { verdict: 'ask' });
|
|
570
|
+
const reason = `${call.toolName} was waiting for a person before a restart, and goes back to them without another review`;
|
|
571
|
+
reviewAsk = { layer: 'review', reason, told: reason, note: 'waiting for you before a restart; not reviewed again' };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// Nothing a grant or a scope could cover applies to a call a review
|
|
575
|
+
// rule already sent to a person.
|
|
576
|
+
const grantsConsidered = reviewAsk === undefined;
|
|
373
577
|
// What this *invocation* would run, for a tool whose danger lives in
|
|
374
578
|
// its arguments. Resolved before anything else so that the tool-wide
|
|
375
579
|
// "always" below can never apply to it: one yes to `git status` must
|
|
@@ -428,8 +632,11 @@ export const createPermissionPolicy = (options) => {
|
|
|
428
632
|
// operator turns off, and then it guards nothing. A stranger's
|
|
429
633
|
// message, the other common `unknown`, is what `principals.admit`
|
|
430
634
|
// exists to refuse before the turn starts.
|
|
635
|
+
// The review posture includes the gate: after outside content, a
|
|
636
|
+
// grant answered a question about calls the agent chose, not calls a
|
|
637
|
+
// page might be choosing.
|
|
431
638
|
const externalGate = sessionTrustOf(session) === 'external'
|
|
432
|
-
&& gateExternalContent?.(session.agent.id) === true;
|
|
639
|
+
&& (gateExternalContent?.(session.agent.id) === true || reviewing);
|
|
433
640
|
/**
|
|
434
641
|
* Workspace autonomy for one command in one directory: a read inside
|
|
435
642
|
* it, local git, a plain download from a readable site, or a push of
|
|
@@ -451,7 +658,7 @@ export const createPermissionPolicy = (options) => {
|
|
|
451
658
|
// And not for an agent the gate is on for at all: the shell
|
|
452
659
|
// marks its output `unknown`, not `external`, so a page read
|
|
453
660
|
// this way would never close the gate it exists to close.
|
|
454
|
-
const gated = gateExternalContent?.(session.agent.id) === true;
|
|
661
|
+
const gated = gateExternalContent?.(session.agent.id) === true || reviewing;
|
|
455
662
|
const site = gated ? undefined : await downloadInsideWorkspace(stage, cwd, workspace);
|
|
456
663
|
if (site !== undefined && await readableSite(session.agent.id, site)) {
|
|
457
664
|
return true;
|
|
@@ -514,7 +721,7 @@ export const createPermissionPolicy = (options) => {
|
|
|
514
721
|
// commands nothing covered.
|
|
515
722
|
const grantRoot = analysis ? commands?.grantRootFor?.(session.agent.id) : undefined;
|
|
516
723
|
let judgement;
|
|
517
|
-
if (analysis) {
|
|
724
|
+
if (analysis && grantsConsidered) {
|
|
518
725
|
if (!analysis.disqualifiedBy) {
|
|
519
726
|
// The built-in safe scopes survive the gate and granted ones do
|
|
520
727
|
// not: `git status` was judged safe for any caller, while a
|
|
@@ -614,14 +821,14 @@ export const createPermissionPolicy = (options) => {
|
|
|
614
821
|
return report(context, judgement.coveredBy, true, `${call.toolName} ran ${names.join(', ')}, each inside what ${session.agent.id} may run unattended`, command);
|
|
615
822
|
}
|
|
616
823
|
}
|
|
617
|
-
else if (analysis.disqualifiedBy && mode === 'headless') {
|
|
824
|
+
else if (analysis.disqualifiedBy && mode === 'headless' && !reviewing) {
|
|
618
825
|
// Never auto-approved, whatever the base command is: a safe scope
|
|
619
826
|
// in front of a backgrounding `&` is the exact shape this rule
|
|
620
827
|
// exists for.
|
|
621
828
|
return report(context, 'parse', false, `${call.toolName} cannot run unattended: ${analysis.disqualifiedBy}`, command);
|
|
622
829
|
}
|
|
623
830
|
}
|
|
624
|
-
else if (risk === 'gated' && unscoped && !externalGate) {
|
|
831
|
+
else if (risk === 'gated' && unscoped && !externalGate && grantsConsidered) {
|
|
625
832
|
// The standing grant: this process's answers first, then the file.
|
|
626
833
|
// Read per call, never cached here, so a revocation through the
|
|
627
834
|
// store is the next call's answer — the reason a revoke needs no
|
|
@@ -636,17 +843,20 @@ export const createPermissionPolicy = (options) => {
|
|
|
636
843
|
}
|
|
637
844
|
else if (risk !== 'dangerous'
|
|
638
845
|
&& !externalGate
|
|
846
|
+
&& grantsConsidered
|
|
639
847
|
&& !scopedByOrigin
|
|
640
848
|
&& !multiplyScoped
|
|
641
849
|
&& alwaysAllowed.has(sessionKey(session.id, call.toolName))) {
|
|
642
850
|
return report(context, 'grant', true, `${call.toolName} was approved for the rest of this session`);
|
|
643
851
|
}
|
|
644
852
|
// Which workspace this destination is in right now, for binding a
|
|
645
|
-
// grant to it (see `DestinationScopeOptions.accountFor`).
|
|
646
|
-
|
|
853
|
+
// grant to it (see `DestinationScopeOptions.accountFor`). Only looked
|
|
854
|
+
// up when a grant could answer: a call an ask rule or the gate sends
|
|
855
|
+
// to a person doesn't wait on, or fail with, the lookup.
|
|
856
|
+
const account = destination !== undefined && !externalGate && grantsConsidered
|
|
647
857
|
? await destinations?.accountFor?.(session.agent.id, destination)
|
|
648
858
|
: undefined;
|
|
649
|
-
if (destination !== undefined && !externalGate) {
|
|
859
|
+
if (destination !== undefined && !externalGate && grantsConsidered) {
|
|
650
860
|
// Read per call, never cached here, so a revoke is the next call's answer.
|
|
651
861
|
const standing = findMatchingDestinationGrant(call.toolName, destination, account, contributor, [
|
|
652
862
|
...(processDestinations.get(session.agent.id) ?? []),
|
|
@@ -666,6 +876,7 @@ export const createPermissionPolicy = (options) => {
|
|
|
666
876
|
// standing yes, so a page the conversation read cannot steer a post.
|
|
667
877
|
if (destination !== undefined
|
|
668
878
|
&& !externalGate
|
|
879
|
+
&& grantsConsidered
|
|
669
880
|
&& contributor === undefined
|
|
670
881
|
&& context.tool.name === call.toolName
|
|
671
882
|
&& STANDING_CHANNEL_TOOLS.includes(call.toolName)
|
|
@@ -673,7 +884,7 @@ export const createPermissionPolicy = (options) => {
|
|
|
673
884
|
&& await destinations?.conversationKindFor?.(session.agent.id, destination) === 'channel') {
|
|
674
885
|
return report(context, 'grant', true, `${call.toolName} to ${destination} ran under the operator's standing channel authorization (approvals.channels)`, undefined, destination);
|
|
675
886
|
}
|
|
676
|
-
if (origin !== undefined) {
|
|
887
|
+
if (origin !== undefined && grantsConsidered) {
|
|
677
888
|
const granted = findMatchingOriginScope(origin, grantedOrigins, call.toolName);
|
|
678
889
|
if (granted) {
|
|
679
890
|
return report(context, 'grant', true, `${call.toolName} acted on the approved site ${describeOriginScope(granted)}`, undefined, undefined, origin);
|
|
@@ -695,13 +906,63 @@ export const createPermissionPolicy = (options) => {
|
|
|
695
906
|
// reports to a channel is the job such a schedule is approved for,
|
|
696
907
|
// so withdrawing it would refuse every firing; and what the page can
|
|
697
908
|
// steer is only the words, sent to the one place a person picked.
|
|
698
|
-
if (risk === 'gated' && singlyScoped && destinations) {
|
|
909
|
+
if (risk === 'gated' && singlyScoped && destinations && grantsConsidered) {
|
|
699
910
|
const destination = context.tool.destinationFor?.(call.input);
|
|
700
911
|
if (destination !== undefined
|
|
701
912
|
&& await destinations.isPreauthorized(session, destination)) {
|
|
702
913
|
return report(context, 'grant', true, `${call.toolName} to ${destination} was pre-authorized when this schedule was approved`, undefined, destination);
|
|
703
914
|
}
|
|
704
915
|
}
|
|
916
|
+
// Step 7: nothing above settled it, so the reviewer does. (A resumed
|
|
917
|
+
// call never gets here: it was sent back to its person above.)
|
|
918
|
+
if (reviewing && reviewAsk === undefined) {
|
|
919
|
+
const outcome = await runReview(context, reviewSettings.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS, resolvedForReview);
|
|
920
|
+
if (outcome === ABORTED) {
|
|
921
|
+
return report(context, 'cancelled', false, `${call.toolName} was cancelled while it was being reviewed`);
|
|
922
|
+
}
|
|
923
|
+
if ('failed' in outcome) {
|
|
924
|
+
reviewNotes.set(context, { verdict: 'ask', reviewCode: 'review-failed' });
|
|
925
|
+
const reason = `${call.toolName} needs a person's yes because the review couldn't run: ${outcome.failed}`;
|
|
926
|
+
reviewAsk = { layer: 'review', reason, told: reason, note: `the review couldn't run: ${outcome.failed}` };
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
const { answer } = outcome;
|
|
930
|
+
reviewNotes.set(context, { verdict: answer.verdict, reviewCode: answer.code });
|
|
931
|
+
if (answer.verdict === 'allow') {
|
|
932
|
+
// The review took time, and a page can move in it: a yes given
|
|
933
|
+
// for one site must not land a click on another. Same rule as
|
|
934
|
+
// a person's yes (`allowUnlessMoved`).
|
|
935
|
+
if (context.tool.originFor !== undefined && reviewHooks <= 1) {
|
|
936
|
+
const settled = originScopeFor(context.tool.originFor(session, call.input) ?? '')?.origin;
|
|
937
|
+
if (settled !== resolvedForReview.origin) {
|
|
938
|
+
return report(context, 'review', false, `${call.toolName} was allowed by the reviewer on ${resolvedForReview.origin ?? 'a page with no origin'}, `
|
|
939
|
+
+ `but the conversation is on ${settled ?? 'no page'} now — it did not run`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return report(context, 'review', true, `${call.toolName} was allowed by the reviewer (${answer.code})`);
|
|
943
|
+
}
|
|
944
|
+
if (answer.verdict === 'block') {
|
|
945
|
+
// No flag the agent can set to escalate: an injected page could
|
|
946
|
+
// tell it to set one. The person's confirmation in their next
|
|
947
|
+
// message is part of what the reviewer sees next time.
|
|
948
|
+
return refuse(context, 'review', `${call.toolName} was blocked by the reviewer (${answer.code})`, `${call.toolName} was blocked: ${answer.reason.replace(/[.\s]+$/, '')}. If the person you're working for really wants this, tell them what it would do and ask them to confirm.`);
|
|
949
|
+
}
|
|
950
|
+
reviewAsk = {
|
|
951
|
+
layer: 'review',
|
|
952
|
+
reason: `${call.toolName} was asked about by the reviewer (${answer.code})`,
|
|
953
|
+
told: `${call.toolName} needs a person's yes: ${answer.reason.replace(/[.\s]+$/, '')}`,
|
|
954
|
+
note: answer.reason,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// A shadow review stops here: it reports what it would have asked,
|
|
959
|
+
// and asks nobody.
|
|
960
|
+
if (shadowRun) {
|
|
961
|
+
return report(context, reviewAsk?.layer ?? 'review', false, `${reviewAsk?.reason ?? `${call.toolName} would need a person`} (would ask)`);
|
|
962
|
+
}
|
|
963
|
+
if (mode === 'headless' && reviewAsk !== undefined) {
|
|
964
|
+
return refuse(context, reviewAsk.layer, `${reviewAsk.reason}; nobody is set up to approve this`, `${reviewAsk.told}; nobody is set up to approve this`);
|
|
965
|
+
}
|
|
705
966
|
if (mode === 'headless') {
|
|
706
967
|
return report(context,
|
|
707
968
|
// A `dangerous` tool before the gate: no grant covers one either
|
|
@@ -801,7 +1062,10 @@ export const createPermissionPolicy = (options) => {
|
|
|
801
1062
|
// front of the approver was composed after a page had its say, and
|
|
802
1063
|
// "always" there would let the page talk a person into exactly the
|
|
803
1064
|
// standing authority the gate withdrew.
|
|
1065
|
+
// And under the review posture every ask is one-shot: an "always"
|
|
1066
|
+
// there would promise a grant the policy then sets aside.
|
|
804
1067
|
const oneShot = risk === 'dangerous'
|
|
1068
|
+
|| reviewing
|
|
805
1069
|
|| externalGate
|
|
806
1070
|
|| multiplyScoped
|
|
807
1071
|
|| (scopedByOrigin && origin === undefined)
|
|
@@ -823,13 +1087,15 @@ export const createPermissionPolicy = (options) => {
|
|
|
823
1087
|
const alwaysMeans = oneShot
|
|
824
1088
|
? risk === 'dangerous'
|
|
825
1089
|
? 'not remembered — a dangerous tool asks every time'
|
|
826
|
-
:
|
|
827
|
-
? 'not remembered —
|
|
828
|
-
:
|
|
829
|
-
? 'not remembered — this
|
|
830
|
-
:
|
|
831
|
-
? 'not remembered — this
|
|
832
|
-
:
|
|
1090
|
+
: reviewing
|
|
1091
|
+
? 'not remembered — under the review posture an answer covers this call only'
|
|
1092
|
+
: externalGate
|
|
1093
|
+
? 'not remembered — this conversation has read external content, so it asks every time'
|
|
1094
|
+
: multiplyScoped
|
|
1095
|
+
? 'not remembered — this tool names more than one kind of scope'
|
|
1096
|
+
: analysis !== undefined
|
|
1097
|
+
? 'not remembered — this command cannot be reduced to a scope'
|
|
1098
|
+
: 'not remembered — there is no page to grant'
|
|
833
1099
|
: always === 'scope'
|
|
834
1100
|
? usePlan && plan !== undefined
|
|
835
1101
|
? `always ${[...plan.names, ...plan.scopes.map(describeCommandScope)].join(', ')} for ${session.agent.name}`
|
|
@@ -843,8 +1109,8 @@ export const createPermissionPolicy = (options) => {
|
|
|
843
1109
|
? `always ${destination} for ${session.agent.name}, until revoked`
|
|
844
1110
|
: 'always this session';
|
|
845
1111
|
const outcome = mode === 'remote'
|
|
846
|
-
? await awaitRemote(context, request, origin, oneShot, always)
|
|
847
|
-
: await awaitPrompt(context, ask, command, origin, alwaysMeans);
|
|
1112
|
+
? await awaitRemote(context, request, origin, oneShot, always, reviewAsk?.note)
|
|
1113
|
+
: await awaitPrompt(context, ask, command, origin, alwaysMeans, reviewAsk?.note);
|
|
848
1114
|
if (outcome === ABORTED) {
|
|
849
1115
|
return report(context, 'cancelled', false, `${call.toolName} was cancelled while awaiting approval`);
|
|
850
1116
|
}
|
|
@@ -893,12 +1159,14 @@ export const createPermissionPolicy = (options) => {
|
|
|
893
1159
|
+ ' — it did not run', undefined, undefined, origin);
|
|
894
1160
|
};
|
|
895
1161
|
if (answer === 'always') {
|
|
896
|
-
if (externalGate) {
|
|
1162
|
+
if (externalGate || reviewing) {
|
|
897
1163
|
// Before every branch that remembers something, because each of
|
|
898
1164
|
// them would: `oneShot` told the transport not to offer this, and
|
|
899
1165
|
// a prompt someone typed "always" into is not a transport that
|
|
900
1166
|
// listened.
|
|
901
|
-
return allowUnlessMoved(
|
|
1167
|
+
return allowUnlessMoved(reviewing
|
|
1168
|
+
? `${call.toolName} was approved once; under the review posture nothing is remembered`
|
|
1169
|
+
: `${call.toolName} was approved once; this conversation has read external content, so nothing is remembered`, command);
|
|
902
1170
|
}
|
|
903
1171
|
if (scopedByOrigin) {
|
|
904
1172
|
// The tool goes in with the site: a grant for one origin-scoped
|
|
@@ -1108,6 +1376,7 @@ export const createPermissionPolicy = (options) => {
|
|
|
1108
1376
|
return allowUnlessMoved(`${call.toolName} was approved once`, command);
|
|
1109
1377
|
},
|
|
1110
1378
|
};
|
|
1379
|
+
return policy;
|
|
1111
1380
|
};
|
|
1112
1381
|
export { assertDerivedStatePath, assertDerivedStatePathSync, linkedDerivedComponent, linkedDerivedComponentMessage, linkedDerivedComponentSync, isSymlinkedStatePath, isSymlinkedStatePathSync, symlinkedStateDirectoryMessage, symlinkedStateFileMessage, } from "./state-directory.js";
|
|
1113
1382
|
//# sourceMappingURL=index.js.map
|