pi-better-subagents 0.14.0 → 0.14.1

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.
@@ -18,6 +18,7 @@ import { DISPOSITION_TOOL, describeOpenTargets, foldToolEnd, foldToolStart,
18
18
  newIncidentModel, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
19
19
 
20
20
  const { createBashToolDefinition } = PiCodingAgent;
21
+ import { PERMISSION_RESOURCES } from "./shared-permission-blocker.ts";
21
22
 
22
23
  // Each intent field also admits `null`, which means "not declared" (withoutAbsentIntent). Models send
23
24
  // optional fields as explicit null; accepting it in the schema means Pi hands execute the same raw
@@ -133,6 +134,7 @@ export function failureDispositionTool(cwd: () => string) {
133
134
  "expected: the failure was intentional. open: it still needs the parent's action (this asks for attention).",
134
135
  "targets: incident ids, the attemptId/operationId you declared on bash, or tool call ids. Unknown or already-disposed incidents are rejected.",
135
136
  "Never claim recovery without a successful later attempt as evidence.",
137
+ "For open only, permissionResource optionally reports a permission blocker on the failed attempt; this is agent-reported, not a proven policy denial or remote authorization outcome.",
136
138
  ].join(" "),
137
139
  promptSnippet: "Classify handled tool failures (recovered, superseded, expected) or flag one as open for the parent.",
138
140
  parameters: {
@@ -141,7 +143,8 @@ export function failureDispositionTool(cwd: () => string) {
141
143
  disposition: { type: "string", enum: ["recovered", "superseded", "expected", "open"] },
142
144
  targets: { type: "array", items: { type: "string", minLength: 1, maxLength: 200 }, minItems: 1, maxItems: 20 },
143
145
  reason: { type: "string", minLength: 1, maxLength: 400 },
144
- evidence: { type: "string", minLength: 1, maxLength: 200, description: "attemptId or tool call id of the successful later attempt; required for recovered and superseded." },
146
+ evidence: { ...nullable({ type: "string", minLength: 1, maxLength: 200 }), description: "attemptId or tool call id of the successful later attempt; required for recovered and superseded." },
147
+ permissionResource: { ...nullable({ type: "string", enum: [...PERMISSION_RESOURCES] }), description: "Optional permission resource for open only. Bound to an unresolved failed worker attempt; remote outcome remains unknown." },
145
148
  },
146
149
  required: ["disposition", "targets", "reason"],
147
150
  additionalProperties: false,
@@ -158,7 +161,8 @@ export function failureDispositionTool(cwd: () => string) {
158
161
  const incidents = outcome.event.incidents ?? [];
159
162
  return {
160
163
  content: [{ type: "text" as const, text: `Recorded ${parsed.request.disposition} for ${incidents.length} incident${incidents.length === 1 ? "" : "s"}: ${incidents.join(", ")}.` }],
161
- details: { disposition: parsed.request.disposition, incidents },
164
+ details: { disposition: parsed.request.disposition, incidents,
165
+ ...(outcome.event.permissionBlockers ? { permissionBlockers: outcome.event.permissionBlockers } : {}) },
162
166
  };
163
167
  },
164
168
  };
@@ -66,6 +66,40 @@ Trust is decided once per scan, when the scan begins. A running trusted run whos
66
66
 
67
67
  Unconfined children keep the exact-retry rule, and this is intentional: an unconfined child can rewrite its own log and metadata, so the parent cannot trust intent or dispositions it reports. Background tasks, which the parent agent launches itself, accept `operation_id` and `expected_exit_codes` directly (see the background-tasks README).
68
68
 
69
+ ## Permission Blockers
70
+
71
+ A trusted worker can attach `permissionResource` to an `open` disposition of
72
+ an unresolved failed tool attempt. Supported categories include
73
+ `credential-files`, `process-inspection`, `project-files`,
74
+ `outside-project-files`, `runtime-control-files`, `command-execution`,
75
+ `network-access`, and `unknown`. For example:
76
+
77
+ ```json
78
+ {
79
+ "disposition": "open",
80
+ "targets": ["failed-attempt-id"],
81
+ "reason": "Local credential-cache access needs human review",
82
+ "permissionResource": "credential-files"
83
+ }
84
+ ```
85
+
86
+ The parent validates the failed-attempt reference and worker provenance before
87
+ exposing actionable reports in `subagent_result` and `subagent_output` details.
88
+ The report is explicitly **agent-reported**, with **remote outcome unknown**.
89
+ It does not prove sandbox causation, expired login, an IAM denial, a foreground
90
+ restriction, or successful validation. Raw `EPERM` output is not classified
91
+ into this contract. Failed attempts remain failed and history is retained after
92
+ later recovery.
93
+
94
+ Inspecting an actionable report can permission-hold the active goal. Ordinary
95
+ questions, unrelated completions, and settings changes do not release the hold.
96
+ The human's `/goal resume` or resume shortcut confirms one same-operation,
97
+ same-scope retry; it does not grant additional access or authorize product tests
98
+ or mutations. Changed worker settings require a fresh worker because running
99
+ workers retain their launch policy. See
100
+ [ADR 0012](../../../docs/adr/0012-actionable-permission-blockers.md) for the
101
+ transport limitations and bounded-turn recovery contract.
102
+
69
103
  ## Evidence health
70
104
 
71
105
  The collector scans complete structured records independently of the finite progress tail. Missing terminal logs, unreadable records, and detected truncation are shown as **observation incomplete**. Failure classification uses structured error and exit fields; output text and domain-specific status codes are not failure signals.
package/docs/usage.md CHANGED
@@ -362,6 +362,14 @@ Pi SDK 0.82.1 or newer; it is not a replacement user-facing Pi command.
362
362
 
363
363
  ### Git-mutating subagents and linked worktrees
364
364
 
365
+ Confined bash commands default to `GIT_OPTIONAL_LOCKS=0`, unless the inherited
366
+ environment or the call supplies a value. This keeps inventory commands such as
367
+ `git status` from creating an optional `index.lock` in the parent repository's
368
+ linked-worktree metadata. On macOS, Outside project = Write can permit creation
369
+ there but refuse unlink, leaving an empty lock even when status exits zero.
370
+ The default skips optional index refreshes; it does not disable required locks
371
+ for `git add` or commits, grant metadata removal, or remove existing locks.
372
+
365
373
  A sandboxed subagent that will mutate Git should set **`git_clone_workspace:true`**
366
374
  on `subagent_spawn`. The parent prepares a fresh, self-contained Git clone whose
367
375
  `.git/` directory lives **inside the sandbox writable root**, then runs the child
package/failures.ts CHANGED
@@ -201,7 +201,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
201
201
  scan = { offset: 0, head, identity, model: newIncidentModel(false) };
202
202
  } else {
203
203
  trustUnknownSince.delete(id);
204
- scan = { offset: 0, head, identity, model: newIncidentModel(trust === "trusted") };
204
+ scan = { offset: 0, head, identity, model: newIncidentModel(trust === "trusted", id) };
205
205
  // The gap is settled after this scan has folded the log (settleMetadataGap), and again after
206
206
  // every later scan, once no leftover of the exact-rule scan that trust would have read
207
207
  // differently is still unresolved (#332).
package/incident-model.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS,
12
12
  REJECTED_INTENT_CATEGORY, readCommandIntent, withoutAbsentIntent, type CommandIntent, type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
13
+ import { isPermissionResource, type PermissionBlocker, type PermissionResource } from "./shared-permission-blocker.ts";
13
14
 
14
15
  export const DISPOSITION_TOOL = "failure_disposition";
15
16
 
@@ -75,6 +76,7 @@ interface Finished extends Attempt {
75
76
  export interface IncidentModel {
76
77
  /** Honour operationId / attemptId / expectedExitCodes / dispositions (confined runtime only). */
77
78
  structuredIntent: boolean;
79
+ runId?: string;
78
80
  sequence: number;
79
81
  open: Map<string, Attempt>;
80
82
  finished: Map<string, Finished>;
@@ -96,8 +98,8 @@ export interface IncidentModel {
96
98
  * (intent bash + failure_disposition). Anywhere else the intent fields and disposition
97
99
  * requests are ignored and the exact-retry identity rule applies.
98
100
  */
99
- export function newIncidentModel(structuredIntent = false): IncidentModel {
100
- return { structuredIntent, sequence: 0, open: new Map(), finished: new Map(), attemptIds: new Map(), namedAttempts: new Set(), operationIds: new Map(),
101
+ export function newIncidentModel(structuredIntent = false, runId?: string): IncidentModel {
102
+ return { structuredIntent, ...(runId ? { runId } : {}), sequence: 0, open: new Map(), finished: new Map(), attemptIds: new Map(), namedAttempts: new Set(), operationIds: new Map(),
101
103
  failed: new Map(), incidentSequence: new Map(), pendingDispositions: new Map() };
102
104
  }
103
105
  export interface IncidentSink {
@@ -114,8 +116,11 @@ export interface DispositionRequest {
114
116
  targets: string[];
115
117
  reason: string;
116
118
  evidence?: string;
119
+ permissionResource?: PermissionResource;
117
120
  }
118
121
  export function readDispositionRequest(args: unknown): { request?: DispositionRequest; error?: string } {
122
+ if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some(key =>
123
+ !["disposition", "targets", "reason", "evidence", "permissionResource"].includes(key))) return { error: "Unknown disposition fields" };
119
124
  const input = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
120
125
  const disposition = input.disposition;
121
126
  if (typeof disposition !== "string" || !INCIDENT_DISPOSITIONS.includes(disposition as IncidentDisposition)) {
@@ -126,9 +131,16 @@ export function readDispositionRequest(args: unknown): { request?: DispositionRe
126
131
  return { error: "targets must name 1-20 incidents, attempt ids, or operation ids" };
127
132
  }
128
133
  if (typeof input.reason !== "string" || !input.reason.trim()) return { error: "reason is required" };
129
- if (input.evidence !== undefined && (typeof input.evidence !== "string" || !input.evidence.trim())) return { error: "evidence must be an attempt id" };
134
+ // The SDK may drop optional nulls; the session/process log retains them. Both mean absent.
135
+ const evidence = input.evidence ?? undefined;
136
+ const permissionResource = input.permissionResource ?? undefined;
137
+ if (evidence !== undefined && (typeof evidence !== "string" || !evidence.trim())) return { error: "evidence must be an attempt id" };
138
+ if (permissionResource !== undefined && (disposition !== "open" || !isPermissionResource(permissionResource))) {
139
+ return { error: "permissionResource requires open and a supported permission resource" };
140
+ }
130
141
  return { request: { disposition: disposition as IncidentDisposition, targets: [...targets] as string[], reason: input.reason.trim(),
131
- ...(typeof input.evidence === "string" ? { evidence: input.evidence.trim() } : {}) } };
142
+ ...(permissionResource !== undefined ? { permissionResource: permissionResource as PermissionResource } : {}),
143
+ ...(typeof evidence === "string" ? { evidence: evidence.trim() } : {}) } };
132
144
  }
133
145
 
134
146
  function attemptRef(model: IncidentModel, ref: string): Finished | undefined {
@@ -146,6 +158,9 @@ function attemptLabel(attempt: Finished): string {
146
158
  * recovery additionally needs the same operation. Everything else fails closed.
147
159
  */
148
160
  export function resolveDisposition(model: IncidentModel, state: FailureState, request: DispositionRequest, requestId: string): { event?: FailureEvent; error?: string } {
161
+ if (request.permissionResource !== undefined && (!model.structuredIntent || request.disposition !== "open" || !isPermissionResource(request.permissionResource))) {
162
+ return { error: "Permission reports require trusted worker open dispositions" };
163
+ }
149
164
  const incidents: string[] = [];
150
165
  for (const target of request.targets) {
151
166
  let id: string | undefined;
@@ -189,6 +204,11 @@ export function resolveDisposition(model: IncidentModel, state: FailureState, re
189
204
  }
190
205
  const event: FailureEvent = { id: `disposition:${requestId}`, operation: "incident-disposition", kind: "disposition",
191
206
  disposition: request.disposition, incidents, reason: request.reason, ...(evidence ? { evidence } : {}) };
207
+ if (request.permissionResource !== undefined) {
208
+ event.permissionBlockers = incidents.map(id => ({ version: 1, kind: "permission-blocker", context: "worker",
209
+ resource: request.permissionResource!, basis: "agent-reported", operation: findIncident(state, id)?.operation ?? "unknown",
210
+ remoteOutcome: "unknown", incidentId: id, ...(model.runId ? { runId: model.runId } : {}) } satisfies PermissionBlocker));
211
+ }
192
212
  const error = validateDisposition(state, event);
193
213
  return error ? { error } : { event };
194
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,7 @@
2
2
  import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync, statSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
+ import { isPermissionBlocker, permissionBlockerKey, type PermissionBlocker } from "./shared-permission-blocker.ts";
5
6
 
6
7
  /**
7
8
  * Explicit, append-only incident dispositions (#315).
@@ -30,6 +31,7 @@ export interface FailureEvent {
30
31
  disposition?: IncidentDisposition;
31
32
  /** Disposition events only: why the incidents are classified this way. */
32
33
  reason?: string;
34
+ permissionBlockers?: PermissionBlocker[];
33
35
  }
34
36
  export interface DispositionRecord {
35
37
  eventId: string;
@@ -38,6 +40,7 @@ export interface DispositionRecord {
38
40
  reason: string;
39
41
  evidence?: string;
40
42
  at: number;
43
+ permissionBlockers?: PermissionBlocker[];
41
44
  }
42
45
  export interface FailureObservation {
43
46
  id: string;
@@ -56,6 +59,8 @@ export interface FailureObservation {
56
59
  resolvedAt?: number;
57
60
  /** Latest explicit disposition applied to this incident. */
58
61
  disposition?: DispositionRecord;
62
+ /** Retained even after recovery; active surfaces filter by current actionability. */
63
+ permissionBlockers?: PermissionBlocker[];
59
64
  }
60
65
  export interface FailureState {
61
66
  version: 1;
@@ -247,12 +252,26 @@ export function validateDisposition(state: FailureState, event: FailureEvent): s
247
252
  if (current.status !== "unresolved") return `Incident ${id} is already disposed (${failureLabel(current).toLowerCase()})`;
248
253
  if (event.disposition === "open" && current.disposition?.disposition === "open") return `Incident ${id} is already open`;
249
254
  }
255
+ if (event.permissionBlockers !== undefined) {
256
+ if (event.disposition !== "open" || !Array.isArray(event.permissionBlockers) || !event.permissionBlockers.length ||
257
+ event.permissionBlockers.length > 20 || !event.permissionBlockers.every(isPermissionBlocker)) return "Invalid permission blocker metadata";
258
+ const bound = new Set<string>();
259
+ for (const blocker of event.permissionBlockers) {
260
+ const current = blocker.incidentId ? findIncident(state, blocker.incidentId) : undefined;
261
+ if (!current || !ids.includes(current.id) || current.category !== "tool" || current.operation !== blocker.operation ||
262
+ blocker.context !== "worker" || blocker.basis !== "agent-reported" || blocker.remoteOutcome !== "unknown" ||
263
+ blocker.policySnapshotId !== undefined || bound.has(current.id)) return "Permission blocker must name an unresolved failed worker attempt";
264
+ bound.add(current.id);
265
+ }
266
+ if (bound.size !== ids.length) return "Permission blocker metadata must cover every target";
267
+ }
250
268
  return undefined;
251
269
  }
252
270
 
253
271
  /** Pure transition. Lifecycle is deliberately not an input or an output. */
254
272
  export function reduceFailure(state: FailureState, event: FailureEvent, observedAt: number): FailureState {
255
273
  if (state.seen.includes(event.id)) return state;
274
+ if (event.permissionBlockers !== undefined && event.kind !== "disposition") return state;
256
275
  // Invalid dispositions fail closed: nothing changes and the id is not consumed.
257
276
  if (event.kind === "disposition" && validateDisposition(state, event)) return state;
258
277
  const next: FailureState = { ...state, seen: [...state.seen, event.id],
@@ -265,11 +284,13 @@ export function reduceFailure(state: FailureState, event: FailureEvent, observed
265
284
  const at = event.at ?? observedAt;
266
285
  const record: DispositionRecord = { eventId: event.id, disposition: event.disposition!, incidents: [...event.incidents!],
267
286
  reason: text(event.reason, ""), ...(event.evidence ? { evidence: text(event.evidence, "") } : {}), at };
287
+ if (event.permissionBlockers) record.permissionBlockers = event.permissionBlockers.map(x => ({ ...x }));
268
288
  for (const id of record.incidents) {
269
289
  const [key, current] = Object.entries(next.observations).find(([, item]) => item.id === id)!;
270
290
  const status = record.disposition === "recovered" ? "resolved" : record.disposition === "superseded" ? "superseded"
271
291
  : record.disposition === "expected" ? "expected" : current.status;
272
292
  next.observations[key] = { ...current, status, disposition: record,
293
+ ...(record.permissionBlockers ? { permissionBlockers: record.permissionBlockers.filter(x => x.incidentId === id) } : {}),
273
294
  ...(status === "resolved" || status === "superseded" ? { resolvedAt: at } : {}) };
274
295
  if (status === "resolved" || status === "superseded") next.resolved = { ...next.resolved, [id]: at };
275
296
  }
@@ -298,6 +319,7 @@ export function reduceFailure(state: FailureState, event: FailureEvent, observed
298
319
  lastObservedAt: observedAt, lastSequence: next.seen.length, at: event.at,
299
320
  count: active ? previous.count + 1 : 1,
300
321
  ...(active && previous.disposition ? { disposition: previous.disposition } : {}),
322
+ ...(active && previous.permissionBlockers ? { permissionBlockers: previous.permissionBlockers } : {}),
301
323
  };
302
324
  return next;
303
325
  }
@@ -315,6 +337,16 @@ export function activeFailures(state: FailureState): FailureObservation[] {
315
337
  export function actionableFailures(state: FailureState): FailureObservation[] {
316
338
  return activeFailures(state).filter(needsAction);
317
339
  }
340
+ /** Only currently actionable reports, not closed history or arbitrary tool-result claims. */
341
+ export function actionablePermissionBlockers(state: FailureState): PermissionBlocker[] {
342
+ const unique = new Map<string, PermissionBlocker>();
343
+ for (const incident of actionableFailures(state)) {
344
+ for (const blocker of incident.permissionBlockers ?? []) {
345
+ if (isPermissionBlocker(blocker)) unique.set(permissionBlockerKey(blocker), { ...blocker });
346
+ }
347
+ }
348
+ return [...unique.values()];
349
+ }
318
350
  function closedOrder(a: FailureObservation, b: FailureObservation): number {
319
351
  return (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt;
320
352
  }
@@ -449,12 +481,20 @@ export function shortEvidence(evidence: string): string {
449
481
  function failureRow(x: FailureObservation, detail: IncidentDetail = "compact"): string {
450
482
  const time = x.at === undefined ? `observed ${new Date(x.firstObservedAt).toISOString()}` : new Date(x.at).toISOString();
451
483
  const compact = detail !== "full";
484
+ const permission = Boolean(x.permissionBlockers?.length);
485
+ let annotation = "";
486
+ if (x.permissionBlockers?.length) {
487
+ const resources = x.permissionBlockers.map(b => b.resource).join(", ");
488
+ annotation = ` · Worker permission blocker: ${resources} (agent-reported; remote outcome unknown; not a foreground denial). No remote authorization or successful validation inferred.`;
489
+ if (needsAction(x)) annotation += " Changed worker settings require a fresh launch.";
490
+ }
452
491
  // Full rows (raw evidence) show the journal summary verbatim; compact rows unwrap and cap it.
453
- const summary = compact ? capUtf8(dropLoneSurrogates(unwrapToolResultText(x.summary)).replace(/\s+/g, " ").trim(), COMPACT_EXCERPT_BYTES) : x.summary;
492
+ const summary = compact && permission ? "Permission access failure" : compact ? capUtf8(dropLoneSurrogates(unwrapToolResultText(x.summary)).replace(/\s+/g, " ").trim(), COMPACT_EXCERPT_BYTES) : x.summary;
454
493
  const reason = x.disposition ? text(x.disposition.reason, "") : "";
455
- const disposition = x.disposition ? ` · ${x.disposition.disposition}: ${compact ? capUtf8(reason, COMPACT_EXCERPT_BYTES) : reason}` : "";
456
- const evidence = x.evidence ? text(x.evidence, "") : "";
457
- return `${failureLabel(x)} · ${time} · ${summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${disposition}${evidence ? ` · evidence: ${compact ? shortEvidence(evidence) : evidence}` : ""}`;
494
+ const disposition = x.disposition ? ` · ${x.disposition.disposition}${compact && permission ? "" : `: ${compact ? capUtf8(reason, COMPACT_EXCERPT_BYTES) : reason}`}` : "";
495
+ const evidence = x.evidence && !(compact && permission) ? text(x.evidence, "") : "";
496
+ const proof = !compact && permission && x.disposition?.evidence ? ` · disposition evidence: ${text(x.disposition.evidence, "")}` : "";
497
+ return `${failureLabel(x)} · ${time} · ${summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${disposition}${evidence ? ` · evidence: ${compact ? shortEvidence(evidence) : evidence}` : ""}${proof}${annotation}`;
458
498
  }
459
499
 
460
500
  /**
@@ -573,13 +613,15 @@ export interface FailureIncidentPage {
573
613
  */
574
614
  export function failureRevision(state: FailureState): string {
575
615
  return failureIdentity(Object.values(state.observations)
576
- .map((item) => [item.id, item.status, item.category, item.count, item.lastSequence ?? 0, item.summary, item.evidence ?? ""])
616
+ .map((item) => [item.id, item.status, item.category, item.count, item.lastSequence ?? 0, item.summary, item.evidence ?? "",
617
+ ...(item.permissionBlockers ? [item.permissionBlockers.map(permissionBlockerKey).sort()] : [])])
577
618
  .sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
578
619
  }
579
620
 
580
621
  /** Digest of the rows a cursor pages. Scope and detail are part of it: the same incidents render different bytes. */
581
622
  function incidentRevision(state: FailureState, scope: IncidentScope = "actionable", detail: IncidentDetail = "compact"): string {
582
- return failureIdentity(scope, detail, scopedFailures(state, scope).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? ""])).slice(0, 16);
623
+ return failureIdentity(scope, detail, scopedFailures(state, scope).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? "",
624
+ ...(item.permissionBlockers ? [item.permissionBlockers.map(permissionBlockerKey).sort()] : [])])).slice(0, 16);
583
625
  }
584
626
 
585
627
  /** Cursors carry a digest of their resource/scope, not the scope text. */
@@ -999,6 +1041,8 @@ function readStoredState(path: string): FailureState {
999
1041
  !Number.isFinite(row.observedAt) || Math.abs(row.observedAt) > 8.64e15 ||
1000
1042
  (e.at !== undefined && (!Number.isFinite(e.at) || Math.abs(e.at) > 8.64e15)) ||
1001
1043
  (e.incidents !== undefined && (!Array.isArray(e.incidents) || !e.incidents.every((id: unknown) => typeof id === "string"))) ||
1044
+ (e.permissionBlockers !== undefined && (e.kind !== "disposition" ||
1045
+ (!state.seen.includes(e.id) && validateDisposition(state, e) !== undefined))) ||
1002
1046
  [e.summary, e.category, e.evidence, e.reason].some((v) => v !== undefined && typeof v !== "string")) throw new Error("invalid record");
1003
1047
  state = reduceFailure(state, e, row.observedAt);
1004
1048
  } catch { state = storageProblem(state, "Failure journal contains unreadable records; observations may be incomplete"); }
@@ -1021,6 +1065,7 @@ export function observeFailures(path: string, events: readonly FailureEvent[], n
1021
1065
  }
1022
1066
  // Rejected dispositions are never journaled or marked seen.
1023
1067
  if (raw.kind === "disposition" && validateDisposition(state, raw)) continue;
1068
+ if (raw.permissionBlockers !== undefined && raw.kind !== "disposition") continue;
1024
1069
  const event = { ...raw, ...(raw.summary ? { summary: text(raw.summary, "") } : {}),
1025
1070
  ...(raw.evidence ? { evidence: text(raw.evidence, "") } : {}), ...(raw.reason ? { reason: text(raw.reason, "") } : {}) };
1026
1071
  try {
@@ -0,0 +1,48 @@
1
+ // Generated from packages/failure-observations/permission-blocker.ts. Do not edit directly.
2
+ /** Privacy-safe wire contract. Context and identity must be assigned by a trusted adapter. */
3
+ export const PERMISSION_RESOURCES = Object.freeze([
4
+ "credential-files", "process-inspection", "project-files", "outside-project-files",
5
+ "runtime-control-files", "command-execution", "network-access", "unknown",
6
+ ] as const);
7
+ export type PermissionResource = typeof PERMISSION_RESOURCES[number];
8
+ export interface PermissionBlocker {
9
+ version: 1;
10
+ kind: "permission-blocker";
11
+ context: "worker" | "foreground";
12
+ resource: PermissionResource;
13
+ basis: "policy-refusal" | "os-permission-error" | "agent-reported";
14
+ /** A stable hash or bounded logical identifier, never a command or path. */
15
+ operation: string;
16
+ remoteOutcome: "unknown" | "not-started";
17
+ incidentId?: string;
18
+ runId?: string;
19
+ policySnapshotId?: string;
20
+ }
21
+ const FIELDS = new Set(["version", "kind", "context", "resource", "basis", "operation", "remoteOutcome", "incidentId", "runId", "policySnapshotId"]);
22
+ const LOGICAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
23
+ // Responses SDK references join call_id and item_id with "|"; these are opaque evidence ids,
24
+ // not caller-chosen logical operations. Keep the same bound and exclude paths and prose.
25
+ const INCIDENT_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:|\-]{0,199}$/;
26
+ export function isPermissionResource(value: unknown): value is PermissionResource {
27
+ return typeof value === "string" && (PERMISSION_RESOURCES as readonly string[]).includes(value);
28
+ }
29
+ /** Pure and strict: no coercion, unknown fields, paths, argv, or free-form prose. */
30
+ export function isPermissionBlocker(value: unknown): value is PermissionBlocker {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
32
+ const prototype = Object.getPrototypeOf(value);
33
+ if (prototype !== Object.prototype && prototype !== null) return false;
34
+ if (Reflect.ownKeys(value).some(key => typeof key !== "string" || !FIELDS.has(key))) return false;
35
+ const v = value as Record<string, unknown>;
36
+ if (v.version !== 1 || v.kind !== "permission-blocker" || !["worker", "foreground"].includes(v.context as string) ||
37
+ !isPermissionResource(v.resource) || !["policy-refusal", "os-permission-error", "agent-reported"].includes(v.basis as string) ||
38
+ !["unknown", "not-started"].includes(v.remoteOutcome as string) ||
39
+ typeof v.operation !== "string" || v.operation.length > 64 || !LOGICAL_ID.test(v.operation)) return false;
40
+ if (Object.hasOwn(v, "incidentId") && (typeof v.incidentId !== "string" || !INCIDENT_REFERENCE.test(v.incidentId))) return false;
41
+ return ["runId", "policySnapshotId"].every(key => !Object.hasOwn(v, key) ||
42
+ (typeof v[key] === "string" && LOGICAL_ID.test(v[key] as string)));
43
+ }
44
+ /** Order-independent identity without I/O or a platform-specific crypto dependency. */
45
+ export function permissionBlockerKey(blocker: PermissionBlocker): string {
46
+ return JSON.stringify([blocker.version, blocker.kind, blocker.context, blocker.resource, blocker.basis,
47
+ blocker.operation, blocker.remoteOutcome, blocker.incidentId ?? null, blocker.runId ?? null, blocker.policySnapshotId ?? null]);
48
+ }
@@ -118,7 +118,8 @@ export function createTaskBashOperations(
118
118
  const { trackDetachedChildPid, untrackDetachedChildPid } = await import(pathToFileURL(join(sdkUtils, "shell.js")).href);
119
119
  if (shell.commandTransport === "stdin") throw new Error("Sandbox: this shell cannot be confined by the available backend.");
120
120
  const scratch = plan.policy.runtimeWrite?.[0];
121
- const taskEnv = { ...process.env, ...options.env,
121
+ // Git inventory otherwise creates optional locks in an outside gitdir it cannot unlink (#419).
122
+ const taskEnv = { GIT_OPTIONAL_LOCKS: "0", ...process.env, ...options.env,
122
123
  ...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
123
124
  if (options.signal?.aborted) throw new Error("aborted");
124
125
  if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
@@ -72,6 +72,8 @@ export interface ToolSource {
72
72
  export interface DiscoveredTool {
73
73
  name: string;
74
74
  package: string;
75
+ /** Explicit SDK provenance for display only; never a package or admission identity. */
76
+ source?: "builtin";
75
77
  /**
76
78
  * What to load and admit: the package root directory, or, for an extension
77
79
  * file with no package manifest, that file itself. Loading a manifest-less
@@ -80,7 +82,7 @@ export interface DiscoveredTool {
80
82
  root: string;
81
83
  }
82
84
 
83
- /** The owning package of a registered tool, or undefined for builtins and inline registrations. */
85
+ /** The owning package of a registered tool, or undefined for synthetic core/inline sources. */
84
86
  export function toolPackage(tool: ToolSource): DiscoveredTool | undefined {
85
87
  const info = tool.sourceInfo;
86
88
  if (!info?.path || info.path.startsWith("<")) return undefined;
@@ -93,14 +95,16 @@ export function toolPackage(tool: ToolSource): DiscoveredTool | undefined {
93
95
  return { name: tool.name, package: pkg, root: base };
94
96
  }
95
97
 
96
- /** Candidate trusted tools in a running Pi, excluding builtins, guarded names and this harness. */
98
+ /** Candidate trusted tools in a running Pi, excluding core builtins, guarded names and this harness. */
97
99
  export function discoverTrustedTools(tools: readonly ToolSource[]): DiscoveredTool[] {
98
100
  const found: DiscoveredTool[] = [];
99
101
  for (const tool of tools) {
100
102
  if (RESERVED_TOOL_NAMES.includes(tool.name)) continue;
101
103
  const owner = toolPackage(tool);
102
104
  if (!owner || [owner.package, owner.root].some((id) => HARNESS_PACKAGES.test(id.replace(/\/+$/, "")))) continue;
103
- if (!found.some((t) => t.name === owner.name && t.package === owner.package)) found.push(owner);
105
+ if (!found.some((t) => t.name === owner.name && t.package === owner.package)) {
106
+ found.push(tool.sourceInfo?.source === "builtin" ? { ...owner, source: "builtin" } : owner);
107
+ }
104
108
  }
105
109
  return found.sort((a, b) => a.name.localeCompare(b.name) || a.package.localeCompare(b.package));
106
110
  }
package/tools.ts CHANGED
@@ -42,7 +42,10 @@ import {
42
42
  unknownRunError,
43
43
  type SubagentToolSession,
44
44
  } from "./output-payload.ts";
45
- import { readOutputControls } from "./shared-log-utils.ts";
45
+ import { budgetFor, readOutputControls } from "./shared-log-utils.ts";
46
+ import { readRunFailures } from "./failures.ts";
47
+ import { actionablePermissionBlockers } from "./shared-failure-observations.ts";
48
+ import type { PermissionBlocker } from "./shared-permission-blocker.ts";
46
49
  import { describeTiming } from "./timing.ts";
47
50
  import {
48
51
  extractChildEventFactsFromLog,
@@ -112,6 +115,23 @@ const INCLUDE_DESCRIPTION = "Explicit opt-in facts omitted by default: \"cost\"
112
115
  /** pi's tool-result text shape. */
113
116
  export const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
114
117
 
118
+ /** Metadata has its own bounded allowance; it never embeds raw evidence or unbounded history. */
119
+ function permissionDetails(id: string, maxBytes: number): { permissionBlockers?: PermissionBlocker[]; permissionBlockersOmitted?: number } {
120
+ const all = actionablePermissionBlockers(readRunFailures(id));
121
+ if (!all.length) return {};
122
+ const permissionBlockers: PermissionBlocker[] = [];
123
+ const allowance = Math.min(4096, maxBytes);
124
+ if (Buffer.byteLength(JSON.stringify({ permissionBlockers, permissionBlockersOmitted: all.length })) > allowance) {
125
+ throw new Error("Permission blocker metadata cannot fit this budget; retry with a larger max_bytes.");
126
+ }
127
+ for (const blocker of all) {
128
+ if (permissionBlockers.length === 32 || Buffer.byteLength(JSON.stringify({ permissionBlockers: [...permissionBlockers, blocker],
129
+ permissionBlockersOmitted: all.length - permissionBlockers.length - 1 })) > allowance) break;
130
+ permissionBlockers.push(blocker);
131
+ }
132
+ return { permissionBlockers, ...(all.length > permissionBlockers.length ? { permissionBlockersOmitted: all.length - permissionBlockers.length } : {}) };
133
+ }
134
+
115
135
  const SUBAGENT_RESULT_PREVIEW_LINES = 8;
116
136
 
117
137
  function resultTextContent(result: unknown): string {
@@ -409,7 +429,9 @@ export function subagentOutputTool(Type: TypeModule, baseSession: SubagentToolSe
409
429
  if (access.kind === "denied") return text(access.payload);
410
430
  const scopeKey = requestScopeKey(p, session);
411
431
  const healthLine = diagnosticLines(access.meta);
412
- return text(assembleSubagentOutput(p.id, access.meta, p, healthLine, scopeKey));
432
+ const result = text(assembleSubagentOutput(p.id, access.meta, p, healthLine, scopeKey));
433
+ const details = permissionDetails(p.id, budgetFor("log", p.maxBytes));
434
+ return Object.keys(details).length ? { ...result, details } : result;
413
435
  },
414
436
  } as ToolDefinition;
415
437
  }
@@ -451,20 +473,24 @@ export function subagentResultTool(Type: TypeModule, baseSession: SubagentToolSe
451
473
  }
452
474
  if (access.kind === "denied") return subagentResultText(access.payload);
453
475
  const meta = access.meta;
476
+ const resultText = (body: string) => {
477
+ const result = subagentResultText(body);
478
+ return { ...result, details: { ...result.details, ...permissionDetails(p.id, budgetFor("answer", p.maxBytes)) } };
479
+ };
454
480
  const scopeKey = requestScopeKey(p, session);
455
481
  const st = effectiveStatus(meta);
456
482
  const healthLine = diagnosticLines(meta);
457
483
  if (!isFinalResultStatus(st)) {
458
484
  if (st === "orphaned") {
459
- return subagentResultText(assembleOrphanedResult(p.id, meta, healthLine, p, scopeKey));
485
+ return resultText(assembleOrphanedResult(p.id, meta, healthLine, p, scopeKey));
460
486
  }
461
- return subagentResultText(assembleRunningResult(p.id, meta, p, scopeKey, describeTiming({ ...meta, status: "running" })?.line));
487
+ return resultText(assembleRunningResult(p.id, meta, p, scopeKey, describeTiming({ ...meta, status: "running" })?.line));
462
488
  }
463
489
  const body = buildSubagentResultPayload(p.id, p, healthLine, scopeKey);
464
490
  if (body === null) {
465
- return subagentResultText(assembleRunningResult(p.id, meta, p, scopeKey, describeTiming({ ...meta, status: "running" })?.line));
491
+ return resultText(assembleRunningResult(p.id, meta, p, scopeKey, describeTiming({ ...meta, status: "running" })?.line));
466
492
  }
467
- return subagentResultText(body);
493
+ return resultText(body);
468
494
  },
469
495
  } as ToolDefinition;
470
496
  }