@hank-warren/pi-auto-permissions 0.3.0 → 0.4.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/NOTICE.md CHANGED
@@ -4,4 +4,4 @@ This package is a fork of [`@ogulcancelik/pi-auto-permissions`](https://github.c
4
4
 
5
5
  The original work is Copyright (c) 2026 Can Celik and is used under the MIT License included in [`LICENSE`](LICENSE).
6
6
 
7
- Fork-specific changes are Copyright (c) 2026 Hank Warren and are released under the same MIT License. They include the `reviewEvidence.userAnswerTools` dialog-answer evidence feature, proposed upstream in [ogulcancelik/pi-extensions#24](https://github.com/ogulcancelik/pi-extensions/issues/24) / [PR #25](https://github.com/ogulcancelik/pi-extensions/pull/25), the prompted-review evaluation log with explicit user feedback, and the single-line animated review status widget.
7
+ Fork-specific changes are Copyright (c) 2026 Hank Warren and are released under the same MIT License. They include the `reviewEvidence.userAnswerTools` dialog-answer evidence feature, proposed upstream in [ogulcancelik/pi-extensions#24](https://github.com/ogulcancelik/pi-extensions/issues/24) / [PR #25](https://github.com/ogulcancelik/pi-extensions/pull/25), the prompted-review evaluation log with explicit user feedback, the single-line animated review status widget, guardian dispatch through the host model runtime so extension-registered provider transports apply, and subagent-aware review context for pi-subagents child sessions.
package/README.md CHANGED
@@ -275,6 +275,16 @@ gh
275
275
 
276
276
  Group names come from your configured rules. A trusted group bypasses all review for that group, so use it only in projects you control.
277
277
 
278
+ ## Subagent sessions
279
+
280
+ When a session is a [pi-subagents](https://github.com/nicobailon/pi-subagents) child (`PI_SUBAGENT_CHILD=1`), the guardian receives additional execution facts with each review — run id, nesting depth, whether the cwd is a linked git worktree, and the checked-out branch — plus a prompt section telling it to judge risk by effect scope and reversibility relative to the subagent's own workspace instead of by command name. Mutations confined to the subagent's isolated worktree, its own feature branch, or resources it created are approvable when they serve the delegated task; `ask_user` is reserved for effects that escape that scope (shared or default branches, host-level configuration, production systems, credentials, data leaving the machine).
281
+
282
+ Subagent sessions have no interactive user, so an `ask_user` verdict blocks the command immediately with a reason instructing the child to route around the gated operation or report the blocker. Reviewer usage records from subagent sessions carry `"subagent": true` in the usage sidecar.
283
+
284
+ ## Guardian dispatch
285
+
286
+ Reviewer requests dispatch through the host's model runtime rather than pi-ai's compat layer, so provider transports registered by other extensions (for example `@gotgenes/pi-anthropic-auth` OAuth request shaping) apply to guardian calls. When the runtime seam is unavailable, dispatch falls back to `compat.completeSimple`.
287
+
278
288
  ## Failure behavior
279
289
 
280
290
  A missing reviewer model, unavailable credentials, malformed response, timeout, cancellation, or oversized review context never auto-approves a command.
@@ -0,0 +1,42 @@
1
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
2
+
3
+ /**
4
+ * Guardian model dispatch seam.
5
+ *
6
+ * pi-ai's `compat.completeSimple` dispatches through pi-ai's own api registry,
7
+ * which extension-registered provider transports never reach: pi routes
8
+ * `pi.registerProvider()` configs only through the coding agent's
9
+ * `ModelRuntime` -> `provider-composer.streamWith`. Auth-shaping extensions
10
+ * such as `@gotgenes/pi-anthropic-auth` install their transport wrapper there,
11
+ * so a reviewer request issued via `compat.completeSimple` reaches Anthropic
12
+ * unshaped, and Claude Pro/Max OAuth requests fail with the misleading
13
+ * "extra usage" HTTP 400 (see gotgenes/pi-anthropic-auth
14
+ * docs/architecture.md, "The remaining gap: pi-ai compat dispatch").
15
+ *
16
+ * `ExtensionContext.modelRegistry` is a deliberately narrowed facade over
17
+ * `ModelRuntime` that re-exports `complete()` but not `completeSimple()` —
18
+ * and only the simple layer translates the api-agnostic `reasoning` option
19
+ * into each api's native thinking config. So reach the runtime's
20
+ * `completeSimple` through the facade's `runtime` field (a public JS field
21
+ * marked `private` in the .d.ts), and fall back to compat dispatch when a
22
+ * future pi release renames the seam — degrading to the historical behavior
23
+ * instead of crashing.
24
+ */
25
+ export type GuardianCompleteSimple = typeof completeSimple;
26
+
27
+ let warnedCompatFallback = false;
28
+
29
+ export function resolveGuardianCompleteSimple(modelRegistry: unknown): GuardianCompleteSimple {
30
+ const runtime = (modelRegistry as { runtime?: unknown } | null | undefined)?.runtime;
31
+ const candidate = (runtime as { completeSimple?: unknown } | null | undefined)?.completeSimple;
32
+ if (typeof candidate === "function") {
33
+ return candidate.bind(runtime) as GuardianCompleteSimple;
34
+ }
35
+ if (!warnedCompatFallback) {
36
+ warnedCompatFallback = true;
37
+ console.warn(
38
+ "[pi-auto-permissions] host ModelRuntime completeSimple is unavailable; falling back to pi-ai compat dispatch. Extension-registered provider transports (e.g. Anthropic OAuth shaping) will not apply to reviewer calls.",
39
+ );
40
+ }
41
+ return completeSimple;
42
+ }
package/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cleanupSessionResources, type Message } from "@earendil-works/pi-ai";
2
- import { completeSimple } from "@earendil-works/pi-ai/compat";
2
+ import { resolveGuardianCompleteSimple } from "./guardian-transport.js";
3
+ import { detectSubagentContext } from "./subagent-context.js";
3
4
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
4
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
6
  import { Type } from "typebox";
@@ -28,6 +29,7 @@ import {
28
29
  buildReviewEnvelope,
29
30
  collectReviewEvidence,
30
31
  parsePermissionVerdict,
32
+ SUBAGENT_CONTEXT_SYSTEM_PROMPT,
31
33
  type PermissionVerdict,
32
34
  type ReviewEvidenceRecord,
33
35
  } from "./review.js";
@@ -283,7 +285,14 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
283
285
  discardReviewerLineage();
284
286
  throw error;
285
287
  }
286
- const systemPrompt = buildReviewerSystemPrompt(config.systemPrompt, projectInstructions);
288
+ // Compose policy text first (base + subagent section), then let
289
+ // buildReviewerSystemPrompt append the untrusted project-instructions
290
+ // evidence block, keeping policy contiguous and evidence terminal.
291
+ const subagentContext = detectSubagentContext(ctx.cwd);
292
+ const policyPrompt = subagentContext
293
+ ? `${config.systemPrompt}\n\n${SUBAGENT_CONTEXT_SYSTEM_PROMPT}`
294
+ : config.systemPrompt;
295
+ const systemPrompt = buildReviewerSystemPrompt(policyPrompt, projectInstructions);
287
296
  const fingerprint = reviewerFingerprint(mainSessionId, model, config, systemPrompt, projectTrusted);
288
297
  const evidence = collectReviewEvidence(
289
298
  ctx.sessionManager.buildContextEntries(),
@@ -308,6 +317,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
308
317
  cwd: ctx.cwd,
309
318
  gate: gate.label,
310
319
  group: gate.group,
320
+ ...(subagentContext ? { execution: subagentContext } : {}),
311
321
  };
312
322
  const makeUserMessage = (records: readonly ReviewEvidenceRecord[], mode: "full" | "delta"): Message => ({
313
323
  role: "user",
@@ -345,7 +355,9 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
345
355
  if (!sessionActive || reviewSignal.aborted || reviewerGeneration !== attemptGeneration) {
346
356
  throw new Error("review timed out or was cancelled");
347
357
  }
348
- const response = await completeSimple(
358
+ // Dispatch through the host ModelRuntime so extension-registered provider
359
+ // transports (e.g. pi-anthropic-auth OAuth shaping) apply; see guardian-transport.ts.
360
+ const response = await resolveGuardianCompleteSimple(ctx.modelRegistry)(
349
361
  model,
350
362
  { systemPrompt, messages },
351
363
  {
@@ -365,7 +377,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
365
377
  if (response.stopReason === "error") {
366
378
  throw new Error(response.errorMessage ?? "reviewer request failed");
367
379
  }
368
- recordReviewerUsage(config, model, response.usage);
380
+ recordReviewerUsage(config, model, response.usage, subagentContext !== undefined);
369
381
  const verdict = parsePermissionVerdict(assistantText(response.content));
370
382
  if (!sessionActive || signal?.aborted || reviewerGeneration !== attemptGeneration) {
371
383
  throw new Error("review timed out or was cancelled");
@@ -392,10 +404,10 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
392
404
  * Reviewer calls never reach the session transcript, so their usage is invisible to
393
405
  * tooling that reads session files. Record content-free counters in a sidecar instead.
394
406
  */
395
- function recordReviewerUsage(config: AutoPermissionsConfig, model: { provider: string; id: string }, usage: unknown): void {
407
+ function recordReviewerUsage(config: AutoPermissionsConfig, model: { provider: string; id: string }, usage: unknown, subagent: boolean): void {
396
408
  if (!config.usageLog.enabled) return;
397
409
  try {
398
- appendUsageRecord(config.usageLog.path, buildUsageLogRecord(model.provider, model.id, usage));
410
+ appendUsageRecord(config.usageLog.path, buildUsageLogRecord(model.provider, model.id, usage, "guardian", "auto-permissions", subagent));
399
411
  } catch {
400
412
  // Usage accounting is optional and must never block a permission decision.
401
413
  }
@@ -634,7 +646,10 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
634
646
  showReviewDisplay(ctx, config, gate, command, target, "ask_user", detail);
635
647
  if (!ctx.hasUI) {
636
648
  showReviewDisplay(ctx, config, gate, command, target, "blocked", detail, true);
637
- return { block: true, reason: `${gate.label} requires user approval: ${detail}` };
649
+ return {
650
+ block: true,
651
+ reason: `${gate.label} requires user approval: ${detail}\nThis session has no interactive user to ask. Prefer an approach that avoids the gated operation, or report this blocker in your final output instead of retrying the same command.`,
652
+ };
638
653
  }
639
654
 
640
655
  const evaluationContext = config.evaluationLog.enabled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-auto-permissions",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Fork of @ogulcancelik/pi-auto-permissions with dialog-answer user evidence: context-aware Bash permissions for Pi with automated guardian review.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -40,6 +40,8 @@
40
40
  "NOTICE.md",
41
41
  "index.ts",
42
42
  "config.ts",
43
+ "guardian-transport.ts",
44
+ "subagent-context.ts",
43
45
  "evaluation-log.ts",
44
46
  "gates.ts",
45
47
  "review.ts",
package/review.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SubagentExecutionContext } from "./subagent-context.js";
2
+
1
3
  export type PermissionDecision = "approve" | "revise" | "ask_user";
2
4
 
3
5
  export interface PermissionVerdict {
@@ -11,6 +13,8 @@ export interface CommandReviewRequest {
11
13
  cwd: string;
12
14
  gate: string;
13
15
  group: string;
16
+ /** Present only in subagent child sessions; runtime facts for the reviewer. */
17
+ execution?: SubagentExecutionContext;
14
18
  }
15
19
 
16
20
  export interface ReviewEvidenceRecord {
@@ -52,6 +56,20 @@ Examples:
52
56
  Return strict JSON only with this shape:
53
57
  {"decision":"approve"|"revise"|"ask_user","reason":"one concise sentence"}`;
54
58
 
59
+ /**
60
+ * Appended to the reviewer system prompt only when the session is a subagent
61
+ * child (detectSubagentContext). Facts arrive in the request's "execution"
62
+ * object; this section tells the reviewer how to weigh them.
63
+ */
64
+ export const SUBAGENT_CONTEXT_SYSTEM_PROMPT = `SUBAGENT EXECUTION CONTEXT
65
+ This session is an autonomous subagent executing a task delegated by a supervising agent session. There is no interactive user: an "ask_user" decision does not open a prompt, it aborts this step with a block. The latest proposed action carries an "execution" object with runtime facts (isolated worktree, checked-out branch, nesting depth).
66
+ The delegated task arrives as this session's USER evidence and carries the supervising session's authorization for the work it describes.
67
+ Judge risk by effect scope and reversibility relative to the subagent's own workspace, not by command name:
68
+ - Mutations confined to the subagent's isolated worktree, or to resources the evidence shows the subagent created (scratch files, branches it created or first pushed, containers, images, test databases), are at most medium risk; approve them when they serve the delegated task.
69
+ - History rewrites and force-pushes are approvable only for branches the evidence shows the subagent created or that the delegated task explicitly directs it to rewrite; a branch being checked out in the subagent's worktree does not by itself establish ownership.
70
+ - Lifecycle commands (including with sudo) for containers or services the subagent itself stood up are part of that workflow. Restarting, reconfiguring, or disabling shared host daemons and system services is host-level configuration, not subagent scope.
71
+ - Reserve "ask_user" for effects that escape the subagent's scope: shared or default branches, remote resources it does not own, host-level configuration, production systems, credential or secret access, and data leaving the machine.`;
72
+
55
73
  const GENERIC_ARGUMENT_KEYS = ["command", "path", "action", "query", "target", "url", "method", "cwd"] as const;
56
74
  const NATIVE_COMPACTION_KIND = "openai-codex-native-compaction";
57
75
  const NATIVE_COMPACTION_VERSION = 1;
@@ -0,0 +1,60 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ /**
5
+ * Runtime facts about a subagent child session, forwarded to the guardian as
6
+ * evidence. Facts only — the guardian judges what they imply; see
7
+ * SUBAGENT_CONTEXT_SYSTEM_PROMPT in review.ts.
8
+ *
9
+ * Detection is based on the environment contract of `pi-subagents`
10
+ * (nicobailon/pi-subagents), which spawns children with `PI_SUBAGENT_CHILD=1`
11
+ * plus run id and nesting depth. Absent that env, sessions are treated as
12
+ * ordinary sessions and review behavior is unchanged.
13
+ */
14
+ export interface SubagentExecutionContext {
15
+ subagent: true;
16
+ /** pi-subagents run id (PI_SUBAGENT_RUN_ID), when present. */
17
+ runId?: string;
18
+ /** Nesting depth for nested subagents (PI_SUBAGENT_DEPTH), when > 0. */
19
+ depth?: number;
20
+ /** True when cwd is a linked git worktree (`.git` is a gitdir pointer file). */
21
+ isolatedWorktree: boolean;
22
+ /** Branch checked out in cwd, when it can be read from git metadata. */
23
+ branch?: string;
24
+ }
25
+
26
+ export function detectSubagentContext(
27
+ cwd: string,
28
+ env: Record<string, string | undefined> = process.env,
29
+ ): SubagentExecutionContext | undefined {
30
+ if (env.PI_SUBAGENT_CHILD !== "1") return undefined;
31
+
32
+ const context: SubagentExecutionContext = { subagent: true, isolatedWorktree: false };
33
+ // The run id is an attacker-influenced string that reaches the guardian
34
+ // prompt: cap length and charset so it stays an identifier, not a payload.
35
+ const runId = env.PI_SUBAGENT_RUN_ID?.trim().replace(/[^\w.:-]/g, "").slice(0, 128);
36
+ if (runId) context.runId = runId;
37
+ const depth = Number(env.PI_SUBAGENT_DEPTH ?? "");
38
+ if (Number.isInteger(depth) && depth > 0) context.depth = depth;
39
+
40
+ try {
41
+ const gitPath = join(cwd, ".git");
42
+ // A linked worktree has a `.git` pointer *file*; a primary checkout has a
43
+ // `.git` directory. statSync throws when cwd is not a git checkout at all.
44
+ if (!statSync(gitPath).isFile()) return context;
45
+ const gitdirRaw = readFileSync(gitPath, "utf8").match(/^gitdir:\s*(.+?)\s*$/m)?.[1];
46
+ if (!gitdirRaw) return context;
47
+ const gitdir = isAbsolute(gitdirRaw) ? gitdirRaw : join(cwd, gitdirRaw);
48
+ // Submodule checkouts also use a `.git` pointer file, but their gitdirs
49
+ // live under `.git/modules/<name>` inside a shared primary checkout — they
50
+ // are not isolated. Linked-worktree gitdirs are guaranteed to live under
51
+ // `<common>/.git/worktrees/<name>`.
52
+ if (!/[\\/]worktrees[\\/]/.test(gitdir)) return context;
53
+ context.isolatedWorktree = true;
54
+ const branch = readFileSync(join(gitdir, "HEAD"), "utf8").match(/^ref:\s*refs\/heads\/(\S+)/m)?.[1];
55
+ if (branch) context.branch = branch;
56
+ } catch {
57
+ // Not a git checkout or unreadable metadata: report the facts we have.
58
+ }
59
+ return context;
60
+ }
package/usage-log.ts CHANGED
@@ -27,6 +27,8 @@ export interface UsageLogRecord {
27
27
  label: string;
28
28
  provider: string;
29
29
  model: string;
30
+ /** Present (true) only for reviews issued inside a subagent child session. */
31
+ subagent?: true;
30
32
  usage: UsageLogTotals;
31
33
  }
32
34
 
@@ -54,6 +56,7 @@ export function buildUsageLogRecord(
54
56
  usage: unknown,
55
57
  label = "guardian",
56
58
  source = "auto-permissions",
59
+ subagent = false,
57
60
  ): UsageLogRecord {
58
61
  return {
59
62
  v: 1,
@@ -63,6 +66,7 @@ export function buildUsageLogRecord(
63
66
  label,
64
67
  provider,
65
68
  model,
69
+ ...(subagent ? { subagent: true as const } : {}),
66
70
  usage: usageLogTotals(usage),
67
71
  };
68
72
  }
package/widget-status.ts CHANGED
@@ -17,7 +17,7 @@ export interface ReviewStatusFrame {
17
17
  }
18
18
 
19
19
  export const WAITING_FRAMES = ["✶", "✸", "✻", "✽"] as const;
20
- export const WAITING_FRAME_INTERVAL_MS = 120;
20
+ export const WAITING_FRAME_INTERVAL_MS = 300;
21
21
 
22
22
  export function reviewStatusFrame(
23
23
  state: ReviewDisplayState,