pi-better-subagents 0.13.1 → 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.
- package/child-incidents.ts +6 -2
- package/docs/failure-observations.md +34 -0
- package/docs/usage.md +23 -4
- package/failures.ts +1 -1
- package/incident-model.ts +24 -4
- package/index.ts +6 -1
- package/package.json +1 -1
- package/permission-policy.ts +4 -1
- package/shared-failure-observations.ts +51 -6
- package/shared-permission-blocker.ts +48 -0
- package/shared-sandbox-core.ts +2 -0
- package/shared-task-process-list.ts +203 -0
- package/shared-task-sandbox.ts +10 -2
- package/shared-task-tools.ts +8 -4
- package/subagent-tools.ts +5 -0
- package/task-guard.ts +3 -1
- package/task-policy.ts +4 -1
- package/tools.ts +32 -6
package/child-incidents.ts
CHANGED
|
@@ -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
|
@@ -279,7 +279,8 @@ it does not depend on any other extension being installed.
|
|
|
279
279
|
denials retain precedence. See the permission details below.
|
|
280
280
|
- **Tool allowlist.** Confined children admit only verified read/write/edit/bash
|
|
281
281
|
implementations, the guarded `apply_patch`, and the trusted tools ticked in
|
|
282
|
-
`/sandbox`.
|
|
282
|
+
`/sandbox`. The fixed `process_list` adapter is available with Process access
|
|
283
|
+
set to Read. Other requested tools are reported as unavailable, with the reason.
|
|
283
284
|
- **No runaway recursion.** Confined children cannot spawn nested agents.
|
|
284
285
|
`allow_nested:true` loads nested-agent support only for unconfined children.
|
|
285
286
|
|
|
@@ -291,9 +292,10 @@ use Project files = Write & delete and Outside project = **Write**: tasks write
|
|
|
291
292
|
across home and temp (tool caches such as `~/.gradle` and `~/.npm` just work), but
|
|
292
293
|
outside the workspace they can only remove or rename files in temp, hidden home
|
|
293
294
|
entries, and worktree folders (`.worktrees/`, `*-worktrees/`). Credential files
|
|
294
|
-
default to
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
default to Write & delete, so authenticated CLIs can maintain their credential
|
|
296
|
+
databases and token state. Tasks can also modify or delete these credentials,
|
|
297
|
+
subject to stricter protected-path rules. Set Stored credentials to Read to
|
|
298
|
+
deny writes or Off to hide them. Explicit saved profiles keep their values.
|
|
297
299
|
Shell startup files, `~/.pi`, `~/.claude`,
|
|
298
300
|
`~/.agents`, and harness state cannot be written, removed, or renamed. Rename-based
|
|
299
301
|
saves and git commits in a sibling repository fail under Write; set Outside
|
|
@@ -312,6 +314,15 @@ credential-file rules; there are no writable lock exceptions. Runtime code,
|
|
|
312
314
|
configuration, and control files remain protected from task writes.
|
|
313
315
|
|
|
314
316
|
Commands Off still permits file tools according to their file permissions.
|
|
317
|
+
Process access defaults to Off. Set it to Read for the fixed `process_list`
|
|
318
|
+
tool, which returns current-user PIDs and names only, even with Commands Off.
|
|
319
|
+
It accepts a literal name filter and a bounded limit, not shell commands or
|
|
320
|
+
process-control actions. It does not expose arguments or environments, and
|
|
321
|
+
does not start an emulator or allocate test resources. The setting governs
|
|
322
|
+
the fixed adapter only, not every OS process operation available through bash
|
|
323
|
+
or trusted tools. New launches capture changes; running children retain their
|
|
324
|
+
launch-time setting. Raw macOS `/bin/ps` remains subject to setuid execution
|
|
325
|
+
restrictions. See [ADR 0011](../../../docs/adr/0011-fixed-read-only-process-inventory.md).
|
|
315
326
|
Network Off blocks task network access while Pi's provider transport remains
|
|
316
327
|
available. The confined file worker has an 8 MiB file limit and rejects larger
|
|
317
328
|
files explicitly; use confined commands for larger files when commands are On.
|
|
@@ -351,6 +362,14 @@ Pi SDK 0.82.1 or newer; it is not a replacement user-facing Pi command.
|
|
|
351
362
|
|
|
352
363
|
### Git-mutating subagents and linked worktrees
|
|
353
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
|
+
|
|
354
373
|
A sandboxed subagent that will mutate Git should set **`git_clone_workspace:true`**
|
|
355
374
|
on `subagent_spawn`. The parent prepares a fresh, self-contained Git clone whose
|
|
356
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
|
-
|
|
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
|
-
...(
|
|
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/index.ts
CHANGED
|
@@ -1761,14 +1761,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1761
1761
|
if (p.tools === undefined) names.push(...permissionPlan.tools.trusted.map((entry) => entry.name));
|
|
1762
1762
|
allow = [...new Set(names)].join(",");
|
|
1763
1763
|
}
|
|
1764
|
+
if (sandboxEnabled && p.tools === undefined && permissionPlan.permissions?.processAccess === "read") {
|
|
1765
|
+
allow = [...new Set([...allow.split(","), "process_list"])].join(",");
|
|
1766
|
+
}
|
|
1764
1767
|
const taskTools = sandboxEnabled ? planTaskTools({
|
|
1765
1768
|
requested: allow.split(",").filter((name) => name && !requestedExcludes.has(name)),
|
|
1766
1769
|
settings: permissionPlan.tools, network: permissionPlan.permissions?.network ?? true,
|
|
1770
|
+
processAccess: permissionPlan.permissions?.processAccess ?? "off",
|
|
1767
1771
|
builtins: TASK_BUILTINS, registered: pi.getAllTools?.() ?? [],
|
|
1768
1772
|
toolExtensions: cfg.toolExtensions, resolvePath: resolveExtensionPath,
|
|
1769
1773
|
}) : undefined;
|
|
1770
1774
|
const resolution = resolveExtensions({
|
|
1771
|
-
|
|
1775
|
+
// process_list is installed by the mandatory task guard, not an SDK builtin or extension mapping.
|
|
1776
|
+
tools: sandboxEnabled ? allow.split(",").filter((name) => name !== "process_list" && (TASK_BUILTINS as readonly string[]).includes(name)).join(",") : allow,
|
|
1772
1777
|
model, clean, allowNested: sandboxEnabled ? false : p.allow_nested, config: cfg,
|
|
1773
1778
|
});
|
|
1774
1779
|
const { args: resolvedExtArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
|
package/package.json
CHANGED
package/permission-policy.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type PermissionProfile = Readonly<{
|
|
|
12
12
|
storedCredentials: CredentialAccess;
|
|
13
13
|
commands: boolean;
|
|
14
14
|
network: boolean;
|
|
15
|
+
processAccess?: "off" | "read";
|
|
15
16
|
}>;
|
|
16
17
|
export type PermissionSnapshot = Readonly<{
|
|
17
18
|
permissions?: PermissionProfile;
|
|
@@ -38,12 +39,14 @@ function profile(value: unknown): PermissionProfile {
|
|
|
38
39
|
const credential = (v: unknown): v is CredentialAccess => v === "off" || v === "read" || v === "read-write";
|
|
39
40
|
const access = (v: unknown): v is Access => credential(v) || v === "write";
|
|
40
41
|
if (typeof p.enabled !== "boolean" || typeof p.commands !== "boolean" || typeof p.network !== "boolean" ||
|
|
41
|
-
!access(p.projectFiles) || !access(p.outsideProject) || !credential(p.storedCredentials)
|
|
42
|
+
!access(p.projectFiles) || !access(p.outsideProject) || !credential(p.storedCredentials) ||
|
|
43
|
+
(p.processAccess !== undefined && p.processAccess !== "off" && p.processAccess !== "read")) {
|
|
42
44
|
throw new Error("Invalid sandbox permission profile; update permissions in the sandbox UI.");
|
|
43
45
|
}
|
|
44
46
|
return Object.freeze({
|
|
45
47
|
enabled: p.enabled, commands: p.commands, network: p.network,
|
|
46
48
|
projectFiles: p.projectFiles, outsideProject: p.outsideProject, storedCredentials: p.storedCredentials,
|
|
49
|
+
...(p.processAccess === undefined ? {} : { processAccess: p.processAccess }),
|
|
47
50
|
});
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -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
|
-
|
|
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 ?? ""
|
|
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
|
+
}
|
package/shared-sandbox-core.ts
CHANGED
|
@@ -60,6 +60,8 @@ export type SandboxPermissions = {
|
|
|
60
60
|
storedCredentials: "off" | "read" | "read-write";
|
|
61
61
|
commands: boolean;
|
|
62
62
|
network: boolean;
|
|
63
|
+
/** Fixed process_list adapter only; omitted legacy profiles keep it Off. */
|
|
64
|
+
processAccess?: "off" | "read";
|
|
63
65
|
};
|
|
64
66
|
|
|
65
67
|
/** Whether a file access level allows creating and overwriting files. */
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Generated from packages/task-sandbox/process-list.ts. Do not edit directly.
|
|
2
|
+
/** Fixed, read-only current-user process inventory. Never accepts command arguments. */
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
6
|
+
import { unlink } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { maybeBuildSandboxCommand, type SandboxWritePolicy } from "./shared-sandbox-core.ts";
|
|
9
|
+
import type { TaskFileController } from "./shared-task-files.ts";
|
|
10
|
+
|
|
11
|
+
export const PROCESS_LIST_TOOL = "process_list";
|
|
12
|
+
|
|
13
|
+
const EXECUTABLE = "/usr/bin/pgrep";
|
|
14
|
+
const MAX_STDOUT = 1024 * 1024;
|
|
15
|
+
const MAX_STDERR = 2048;
|
|
16
|
+
const TIMEOUT_MS = 5000;
|
|
17
|
+
|
|
18
|
+
const parameters = {
|
|
19
|
+
type: "object",
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
properties: {
|
|
22
|
+
name: { type: "string", maxLength: 128, description: "Literal case-insensitive process-name substring." },
|
|
23
|
+
limit: { type: "integer", minimum: 1, maximum: 200, default: 100 },
|
|
24
|
+
},
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
type Inventory = { processes: { pid: number; name: string }[]; truncated: boolean; scope: "current-user" };
|
|
28
|
+
|
|
29
|
+
function validateParams(params: unknown): { name: string | undefined; limit: number } {
|
|
30
|
+
if (!params || typeof params !== "object" || Array.isArray(params) ||
|
|
31
|
+
Reflect.ownKeys(params).some((key) => key !== "name" && key !== "limit")) {
|
|
32
|
+
throw new Error("process_list accepts only optional name and limit fields.");
|
|
33
|
+
}
|
|
34
|
+
const { name, limit } = params as { name?: unknown; limit?: unknown };
|
|
35
|
+
if (name !== undefined && (typeof name !== "string" || [...name].length > 128)) {
|
|
36
|
+
throw new Error("process_list name must be a string of at most 128 characters.");
|
|
37
|
+
}
|
|
38
|
+
if (limit !== undefined && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 200)) {
|
|
39
|
+
throw new Error("process_list limit must be an integer from 1 to 200.");
|
|
40
|
+
}
|
|
41
|
+
return { name: name as string | undefined, limit: limit as number | undefined ?? 100 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function executableIdentity(): string {
|
|
45
|
+
try {
|
|
46
|
+
if (realpathSync.native(EXECUTABLE) !== EXECUTABLE) throw new Error("non-canonical executable path");
|
|
47
|
+
// Root-owned, non-replaceable ancestors prevent task users retargeting the
|
|
48
|
+
// checked path. Normal Linux utilities need no filesystem immutable flag.
|
|
49
|
+
for (const path of ["/", "/usr", "/usr/bin", EXECUTABLE]) {
|
|
50
|
+
const info = lstatSync(path, { bigint: true });
|
|
51
|
+
if (info.uid !== 0n || (info.mode & 0o022n) !== 0n ||
|
|
52
|
+
(path === EXECUTABLE ? !info.isFile() : !info.isDirectory())) {
|
|
53
|
+
throw new Error("path must be root-owned and not group/world writable, without symlinks");
|
|
54
|
+
}
|
|
55
|
+
if (path === EXECUTABLE) {
|
|
56
|
+
if ((info.mode & 0o6000n) !== 0n) throw new Error("setuid/setgid executable is forbidden");
|
|
57
|
+
if ((info.mode & 0o111n) === 0n) throw new Error("executable permission is missing");
|
|
58
|
+
return [info.dev, info.ino, info.uid, info.gid, info.mode, info.size, info.mtimeNs, info.ctimeNs].join(":");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new Error(`process_list unsupported: fixed ${EXECUTABLE} is unavailable or unsafe (${error instanceof Error ? error.message : String(error)}).`);
|
|
63
|
+
}
|
|
64
|
+
throw new Error("process_list unsupported: fixed executable identity unavailable.");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseInventory(stdout: Buffer, name: string | undefined, limit: number): Inventory {
|
|
68
|
+
const processes: Inventory["processes"] = [];
|
|
69
|
+
const seen = new Set<number>();
|
|
70
|
+
const needle = name?.toLowerCase();
|
|
71
|
+
let matches = 0;
|
|
72
|
+
for (const line of stdout.toString("utf8").split("\n")) {
|
|
73
|
+
if (!line) continue;
|
|
74
|
+
// pgrep -l (without -f/-a) emits PID, one separator, and the comm/name.
|
|
75
|
+
// Keep spaces in names; never interpret the model's filter as a regex.
|
|
76
|
+
const match = /^[ \t]*([0-9]+)[ \t](.+)$/.exec(line);
|
|
77
|
+
const pid = match ? Number(match[1]) : NaN;
|
|
78
|
+
if (!match || !Number.isSafeInteger(pid) || pid <= 0 || seen.has(pid) || /[\x00-\x1f\x7f]/.test(match[2]!)) {
|
|
79
|
+
throw new Error("process_list received malformed pgrep process-name output.");
|
|
80
|
+
}
|
|
81
|
+
seen.add(pid);
|
|
82
|
+
const processName = match[2]!;
|
|
83
|
+
if (needle !== undefined && !processName.toLowerCase().includes(needle)) continue;
|
|
84
|
+
matches++;
|
|
85
|
+
if (processes.length < limit) processes.push({ pid, name: processName });
|
|
86
|
+
}
|
|
87
|
+
return { processes, truncated: matches > limit, scope: "current-user" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function aborted(): Error {
|
|
91
|
+
return Object.assign(new Error("process_list aborted."), { name: "AbortError" });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function run(file: string, argv: string[], cwd: string, home: string, signal?: AbortSignal): Promise<Buffer> {
|
|
95
|
+
if (signal?.aborted) return Promise.reject(aborted());
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const child = spawn(file, argv, {
|
|
98
|
+
cwd, detached: true, shell: false, stdio: ["ignore", "pipe", "pipe"],
|
|
99
|
+
env: { LC_ALL: "C", HOME: home, PATH: "/usr/bin:/bin" },
|
|
100
|
+
});
|
|
101
|
+
const chunks: Buffer[] = [];
|
|
102
|
+
let size = 0;
|
|
103
|
+
let stderr = Buffer.alloc(0);
|
|
104
|
+
let failure: Error | undefined;
|
|
105
|
+
let closed = false;
|
|
106
|
+
const stop = (error: Error) => {
|
|
107
|
+
if (closed || failure) return;
|
|
108
|
+
failure = error;
|
|
109
|
+
// Only the owned helper's detached group, never an inventory PID.
|
|
110
|
+
if (child.pid !== undefined) {
|
|
111
|
+
try { process.kill(-child.pid, "SIGKILL"); }
|
|
112
|
+
catch { child.kill("SIGKILL"); }
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const onAbort = () => stop(aborted());
|
|
116
|
+
const timer = setTimeout(() => stop(new Error("process_list timed out after 5 seconds.")), TIMEOUT_MS);
|
|
117
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
118
|
+
if (signal?.aborted) onAbort();
|
|
119
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
120
|
+
size += chunk.length;
|
|
121
|
+
if (size > MAX_STDOUT) stop(new Error("process_list stdout exceeds 1 MiB inventory limit."));
|
|
122
|
+
else if (!failure) chunks.push(chunk);
|
|
123
|
+
});
|
|
124
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
125
|
+
if (stderr.length < MAX_STDERR) stderr = Buffer.concat([stderr, chunk.subarray(0, MAX_STDERR - stderr.length)]);
|
|
126
|
+
});
|
|
127
|
+
child.on("error", (error) => { failure ??= error; });
|
|
128
|
+
child.on("close", (code, exitSignal) => {
|
|
129
|
+
closed = true;
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
signal?.removeEventListener("abort", onAbort);
|
|
132
|
+
if (failure) return reject(failure);
|
|
133
|
+
if (code === 1 && size === 0 && stderr.length === 0) return resolve(Buffer.alloc(0));
|
|
134
|
+
if (code !== 0) {
|
|
135
|
+
return reject(new Error(`process_list helper failed (${exitSignal ?? code}): ${stderr.toString("utf8") || "no diagnostic"}`));
|
|
136
|
+
}
|
|
137
|
+
resolve(Buffer.concat(chunks, size));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createProcessListToolDefinition(cwd: string, controller: TaskFileController, access: () => "off" | "read") {
|
|
143
|
+
let identity: string | undefined;
|
|
144
|
+
const checkAccess = () => {
|
|
145
|
+
if (access() !== "read") throw new Error("Sandbox: Process access is Off. Enable Read in /sandbox.");
|
|
146
|
+
};
|
|
147
|
+
const checkExecutable = () => {
|
|
148
|
+
const current = executableIdentity();
|
|
149
|
+
if (identity !== undefined && current !== identity) throw new Error("process_list unsupported: fixed executable identity changed.");
|
|
150
|
+
identity = current;
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
name: PROCESS_LIST_TOOL,
|
|
154
|
+
label: "Process List",
|
|
155
|
+
description: "Read current-user process IDs and process names only. Optional literal case-insensitive name filter; no arguments, environment, paths, or process control.",
|
|
156
|
+
promptSnippet: "List current-user process IDs and names with an optional literal name filter.",
|
|
157
|
+
parameters,
|
|
158
|
+
async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) {
|
|
159
|
+
checkAccess();
|
|
160
|
+
const { name, limit } = validateParams(params);
|
|
161
|
+
if (signal?.aborted) throw aborted();
|
|
162
|
+
const plan = controller.requireLaunchPlan();
|
|
163
|
+
if ((process.platform !== "darwin" && process.platform !== "linux") || typeof process.getuid !== "function") {
|
|
164
|
+
throw new Error(`process_list unsupported on ${process.platform}.`);
|
|
165
|
+
}
|
|
166
|
+
const uid = process.getuid();
|
|
167
|
+
if (!Number.isSafeInteger(uid) || uid < 0) throw new Error("process_list unsupported: current user ID unavailable.");
|
|
168
|
+
checkExecutable();
|
|
169
|
+
const argv = ["-l", "-u", String(uid), "."];
|
|
170
|
+
let profilePath: string | undefined;
|
|
171
|
+
try {
|
|
172
|
+
let file = EXECUTABLE;
|
|
173
|
+
let fileArgs = argv;
|
|
174
|
+
if (plan.confined) {
|
|
175
|
+
const helperPolicy: SandboxWritePolicy = {
|
|
176
|
+
...plan.policy,
|
|
177
|
+
permissions: {
|
|
178
|
+
projectFiles: "read-write", outsideProject: "read", storedCredentials: "read",
|
|
179
|
+
...plan.policy.permissions, commands: true, network: false,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
profilePath = `${plan.profilePath}.${randomUUID()}.process-list.sb`;
|
|
183
|
+
const command = maybeBuildSandboxCommand({
|
|
184
|
+
execPath: EXECUTABLE, execArgs: argv, internalHelperExecutable: true,
|
|
185
|
+
profilePath, policy: helperPolicy,
|
|
186
|
+
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
187
|
+
if (!command) throw new Error("process_list sandbox backend unavailable.");
|
|
188
|
+
file = command.file;
|
|
189
|
+
fileArgs = command.fileArgs;
|
|
190
|
+
}
|
|
191
|
+
checkExecutable();
|
|
192
|
+
checkAccess();
|
|
193
|
+
const stdout = await run(file, fileArgs, cwd, plan.confined ? plan.policy.home : homedir(), signal);
|
|
194
|
+
const result = parseInventory(stdout, name, limit);
|
|
195
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
|
|
196
|
+
} finally {
|
|
197
|
+
if (profilePath) await unlink(profilePath).catch((error: NodeJS.ErrnoException) => {
|
|
198
|
+
if (error.code !== "ENOENT") throw error;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
package/shared-task-sandbox.ts
CHANGED
|
@@ -14,8 +14,9 @@ import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:p
|
|
|
14
14
|
import { canonicalizePath, compileWritePolicy, isRemovableUnderWrite, maybeBuildSandboxCommand, type SandboxPermissions } from "./shared-sandbox-core.ts";
|
|
15
15
|
import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
|
|
16
16
|
import { APPLY_PATCH_TOOL, createApplyPatchToolDefinition } from "./shared-task-apply-patch.ts";
|
|
17
|
+
import { createProcessListToolDefinition, PROCESS_LIST_TOOL } from "./shared-task-process-list.ts";
|
|
17
18
|
|
|
18
|
-
export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash"] as const);
|
|
19
|
+
export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash", PROCESS_LIST_TOOL] as const);
|
|
19
20
|
/** Harness adapters that follow the file rules; each is a task builtin only when the profile enables it. */
|
|
20
21
|
export const GUARDED_TASK_TOOLS = Object.freeze([APPLY_PATCH_TOOL] as const);
|
|
21
22
|
|
|
@@ -117,7 +118,8 @@ export function createTaskBashOperations(
|
|
|
117
118
|
const { trackDetachedChildPid, untrackDetachedChildPid } = await import(pathToFileURL(join(sdkUtils, "shell.js")).href);
|
|
118
119
|
if (shell.commandTransport === "stdin") throw new Error("Sandbox: this shell cannot be confined by the available backend.");
|
|
119
120
|
const scratch = plan.policy.runtimeWrite?.[0];
|
|
120
|
-
|
|
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,
|
|
121
123
|
...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
|
|
122
124
|
if (options.signal?.aborted) throw new Error("aborted");
|
|
123
125
|
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
|
|
@@ -182,6 +184,8 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
182
184
|
admitExtensionTool?: (name: string, input: unknown, sourcePath: string | undefined) => boolean;
|
|
183
185
|
/** Register the guarded apply_patch adapter as a task builtin. */
|
|
184
186
|
applyPatch?: boolean;
|
|
187
|
+
/** Human-controlled access to the fixed read-only inventory adapter. */
|
|
188
|
+
processAccess?: () => "off" | "read";
|
|
185
189
|
/** Build the admitted bash definition from the confined operations (default: the SDK bash tool). */
|
|
186
190
|
bashDefinition?: (cwd: string, operations: BashOperations) => ReturnType<typeof createBashToolDefinition>;
|
|
187
191
|
}) {
|
|
@@ -205,6 +209,7 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
205
209
|
pi.registerTool(own(createWriteToolDefinition(cwd, { operations: files.write })));
|
|
206
210
|
pi.registerTool(own(createEditToolDefinition(cwd, { operations: files.edit })));
|
|
207
211
|
pi.registerTool(own(options.bashDefinition ? options.bashDefinition(cwd, bash) : createBashToolDefinition(cwd, { operations: bash })));
|
|
212
|
+
pi.registerTool(own(createProcessListToolDefinition(cwd, controller, options.processAccess ?? (() => "off"))) as any);
|
|
208
213
|
if (options.applyPatch) {
|
|
209
214
|
pi.registerTool(own(createApplyPatchToolDefinition(cwd, {
|
|
210
215
|
readFile: files.read.readFile, writeFile: files.write.writeFile, mkdir: files.write.mkdir,
|
|
@@ -226,6 +231,9 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
226
231
|
if (event.toolName === "bash" && plan.policy.permissions?.commands === false) {
|
|
227
232
|
return { block: true, reason: "Sandbox: Run commands & applications is Off." };
|
|
228
233
|
}
|
|
234
|
+
if (event.toolName === PROCESS_LIST_TOOL && options.processAccess?.() !== "read") {
|
|
235
|
+
return { block: true, reason: "Sandbox: Process access is Off. Enable Read in /sandbox." };
|
|
236
|
+
}
|
|
229
237
|
return;
|
|
230
238
|
}
|
|
231
239
|
if (options.admitExtensionTool?.(event.toolName, event.input, tool?.sourceInfo?.path)) return;
|
package/shared-task-tools.ts
CHANGED
|
@@ -41,7 +41,7 @@ export function isNetworkTool(name: string): boolean {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** Names never offered as trusted: builtins and the guarded adapters. */
|
|
44
|
-
export const RESERVED_TOOL_NAMES = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls", "powershell", "apply_patch"]);
|
|
44
|
+
export const RESERVED_TOOL_NAMES = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls", "powershell", "apply_patch", "process_list"]);
|
|
45
45
|
|
|
46
46
|
/** Packages that are this harness: their tools are never offered as trusted tools. */
|
|
47
47
|
const HARNESS_PACKAGES = /(^|[/:])pi-better-[a-z-]+$/;
|
|
@@ -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
|
|
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))
|
|
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/subagent-tools.ts
CHANGED
|
@@ -30,6 +30,7 @@ export function planTaskTools(options: {
|
|
|
30
30
|
requested: readonly string[];
|
|
31
31
|
settings: SubagentToolSettings;
|
|
32
32
|
network: boolean;
|
|
33
|
+
processAccess?: "off" | "read";
|
|
33
34
|
builtins: readonly string[];
|
|
34
35
|
/** Tools registered in the parent Pi (`pi.getAllTools()`), used to find a ticked tool's package. */
|
|
35
36
|
registered: readonly ToolSource[];
|
|
@@ -38,6 +39,10 @@ export function planTaskTools(options: {
|
|
|
38
39
|
}): TaskToolPlan {
|
|
39
40
|
const plan: TaskToolPlan = { applyPatch: false, trusted: [], refused: [] };
|
|
40
41
|
for (const name of options.requested) {
|
|
42
|
+
if (name === "process_list" && options.processAccess !== "read") {
|
|
43
|
+
plan.refused.push({ name, reason: "Process access is Off; enable Read in /sandbox" });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
41
46
|
if (options.builtins.includes(name)) continue;
|
|
42
47
|
if (name === APPLY_PATCH) {
|
|
43
48
|
if (options.settings.applyPatch) plan.applyPatch = true;
|
package/task-guard.ts
CHANGED
|
@@ -47,6 +47,7 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
|
|
|
47
47
|
// Structured command intent (#315): validated before the confined command runs.
|
|
48
48
|
bashDefinition: (cwd, operations) => intentBashDefinition(cwd, operations) as any,
|
|
49
49
|
applyPatch: policy.applyPatch,
|
|
50
|
+
processAccess: () => policy.permissions.processAccess ?? "off",
|
|
50
51
|
// The disposition tool performs no file or command I/O; only this inline registration is admitted.
|
|
51
52
|
// Trusted tools are admitted by name and package (ADR 0009).
|
|
52
53
|
admitExtensionTool: (name, _input, sourcePath) => (name === DISPOSITION_TOOL && sourcePath === TRUSTED_INLINE_SOURCE) ||
|
|
@@ -68,7 +69,8 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
|
|
|
68
69
|
currentCwd = ctx.cwd;
|
|
69
70
|
boundary.register(ctx.cwd);
|
|
70
71
|
const builtins: readonly string[] = policy.applyPatch ? [...TASK_BUILTINS, ...GUARDED_TASK_TOOLS] : TASK_BUILTINS;
|
|
71
|
-
const selected = policy.tools.filter((name) => builtins.includes(name)
|
|
72
|
+
const selected = policy.tools.filter((name) => builtins.includes(name) &&
|
|
73
|
+
(name !== "process_list" || policy.permissions.processAccess === "read"));
|
|
72
74
|
boundary.assertInstalled(selected);
|
|
73
75
|
const inventory = pi.getAllTools();
|
|
74
76
|
const trusted: string[] = [];
|
package/task-policy.ts
CHANGED
|
@@ -39,7 +39,8 @@ export type TaskExtensionTool = Readonly<{
|
|
|
39
39
|
* the fixed deny list). Matches `pi-better-sandbox`'s default Subagents column.
|
|
40
40
|
*/
|
|
41
41
|
export const DEFAULT_TASK_PERMISSIONS: Readonly<SandboxPermissions> = Object.freeze({
|
|
42
|
-
projectFiles: "read-write", outsideProject: "write", storedCredentials: "read", commands: true, network: true,
|
|
42
|
+
projectFiles: "read-write", outsideProject: "write", storedCredentials: "read-write", commands: true, network: true,
|
|
43
|
+
processAccess: "off",
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
export function parseTaskPolicy(value: unknown): TaskPolicy {
|
|
@@ -57,6 +58,7 @@ export function parseTaskPolicy(value: unknown): TaskPolicy {
|
|
|
57
58
|
}
|
|
58
59
|
if (!["off", "read", "read-write"].includes(String(p.storedCredentials))) throw new Error("Invalid task sandbox storedCredentials.");
|
|
59
60
|
if (typeof p.commands !== "boolean" || typeof p.network !== "boolean") throw new Error("Invalid task sandbox capabilities.");
|
|
61
|
+
if (p.processAccess !== undefined && p.processAccess !== "off" && p.processAccess !== "read") throw new Error("Invalid task sandbox processAccess.");
|
|
60
62
|
if (!Array.isArray(v.denyWrite) || !v.denyWrite.every((path) => typeof path === "string" && isAbsolute(path))) throw new Error("Invalid task sandbox protected paths.");
|
|
61
63
|
if (!Array.isArray(v.tools) || !v.tools.every((name) => typeof name === "string" && name.length > 0)) throw new Error("Invalid task sandbox tool selection.");
|
|
62
64
|
if (v.applyPatch !== undefined && typeof v.applyPatch !== "boolean") throw new Error("Invalid task sandbox apply_patch setting.");
|
|
@@ -71,6 +73,7 @@ export function parseTaskPolicy(value: unknown): TaskPolicy {
|
|
|
71
73
|
permissions: Object.freeze({
|
|
72
74
|
projectFiles: p.projectFiles, outsideProject: p.outsideProject, storedCredentials: p.storedCredentials,
|
|
73
75
|
commands: p.commands, network: p.network,
|
|
76
|
+
processAccess: p.processAccess ?? "off",
|
|
74
77
|
} as SandboxPermissions),
|
|
75
78
|
denyWrite: Object.freeze([...v.denyWrite]), tools: Object.freeze([...v.tools]),
|
|
76
79
|
applyPatch: v.applyPatch === true,
|
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
|
-
|
|
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
|
|
485
|
+
return resultText(assembleOrphanedResult(p.id, meta, healthLine, p, scopeKey));
|
|
460
486
|
}
|
|
461
|
-
return
|
|
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
|
|
491
|
+
return resultText(assembleRunningResult(p.id, meta, p, scopeKey, describeTiming({ ...meta, status: "running" })?.line));
|
|
466
492
|
}
|
|
467
|
-
return
|
|
493
|
+
return resultText(body);
|
|
468
494
|
},
|
|
469
495
|
} as ToolDefinition;
|
|
470
496
|
}
|