@stratusagent/permissions 0.14.2 → 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/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
- return allowed;
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
- return {
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
- if (risk === 'gated' && openFor?.(session.agent.id) === true) {
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, `${call.toolName} runs without asking (approvals.posture is open)`);
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
- const account = destination !== undefined
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
- : externalGate
827
- ? 'not remembered — this conversation has read external content, so it asks every time'
828
- : multiplyScoped
829
- ? 'not remembered — this tool names more than one kind of scope'
830
- : analysis !== undefined
831
- ? 'not remembered — this command cannot be reduced to a scope'
832
- : 'not remembered — there is no page to grant'
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(`${call.toolName} was approved once; this conversation has read external content, so nothing is remembered`, command);
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