pi-better-subagents 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/usage.md +26 -10
- package/index.ts +10 -2
- package/package.json +1 -1
- package/permission-policy.ts +4 -1
- package/shared-callback-batcher.ts +67 -4
- package/shared-sandbox-core.ts +2 -0
- package/shared-task-process-list.ts +203 -0
- package/shared-task-sandbox.ts +8 -1
- package/shared-task-tools.ts +1 -1
- package/subagent-tools.ts +5 -0
- package/task-guard.ts +3 -1
- package/task-policy.ts +4 -1
package/docs/usage.md
CHANGED
|
@@ -246,13 +246,17 @@ given.
|
|
|
246
246
|
Its context can't clog the parent, its crash can't corrupt parent state, and
|
|
247
247
|
its output is durable in a log file.
|
|
248
248
|
- **Completions trigger one batched turn that fetches durable results.** Ordinary
|
|
249
|
-
terminal callbacks
|
|
249
|
+
terminal callbacks stay in the harness queue while the foreground agent is
|
|
250
|
+
busy. Once Pi is idle, they accumulate for 100 ms and share one
|
|
250
251
|
`pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true })` notification
|
|
251
252
|
with background-task completions from the same Pi host. Each row contains only
|
|
252
253
|
source, id, label, terminal status, and the `subagent_result` lookup. Full
|
|
253
254
|
results and logs stay in durable tools and are never embedded in the callback.
|
|
254
255
|
Set `PI_BETTER_CALLBACK_BATCH_MS` to `0` through `5000` milliseconds to tune
|
|
255
|
-
the accumulation window; invalid values use 100 ms.
|
|
256
|
+
the accumulation window; invalid values use 100 ms. `agent_settled` schedules
|
|
257
|
+
the aggregate without starting a model run inside the lifecycle handler.
|
|
258
|
+
Completions arriving during that model run, including bounded overflow,
|
|
259
|
+
stay queued for the next available boundary. A failed handoff keeps the
|
|
256
260
|
pending records retryable, and `/reload` recovers records explicitly marked
|
|
257
261
|
pending. `callback:false` sends no model message; read the durable result later
|
|
258
262
|
with `subagent_result`.
|
|
@@ -275,7 +279,8 @@ it does not depend on any other extension being installed.
|
|
|
275
279
|
denials retain precedence. See the permission details below.
|
|
276
280
|
- **Tool allowlist.** Confined children admit only verified read/write/edit/bash
|
|
277
281
|
implementations, the guarded `apply_patch`, and the trusted tools ticked in
|
|
278
|
-
`/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.
|
|
279
284
|
- **No runaway recursion.** Confined children cannot spawn nested agents.
|
|
280
285
|
`allow_nested:true` loads nested-agent support only for unconfined children.
|
|
281
286
|
|
|
@@ -287,9 +292,10 @@ use Project files = Write & delete and Outside project = **Write**: tasks write
|
|
|
287
292
|
across home and temp (tool caches such as `~/.gradle` and `~/.npm` just work), but
|
|
288
293
|
outside the workspace they can only remove or rename files in temp, hidden home
|
|
289
294
|
entries, and worktree folders (`.worktrees/`, `*-worktrees/`). Credential files
|
|
290
|
-
default to
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
293
299
|
Shell startup files, `~/.pi`, `~/.claude`,
|
|
294
300
|
`~/.agents`, and harness state cannot be written, removed, or renamed. Rename-based
|
|
295
301
|
saves and git commits in a sibling repository fail under Write; set Outside
|
|
@@ -308,6 +314,15 @@ credential-file rules; there are no writable lock exceptions. Runtime code,
|
|
|
308
314
|
configuration, and control files remain protected from task writes.
|
|
309
315
|
|
|
310
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).
|
|
311
326
|
Network Off blocks task network access while Pi's provider transport remains
|
|
312
327
|
available. The confined file worker has an 8 MiB file limit and rejects larger
|
|
313
328
|
files explicitly; use confined commands for larger files when commands are On.
|
|
@@ -649,10 +664,11 @@ only after a successful handoff and dedupe across reloads and repeated health ti
|
|
|
649
664
|
Persisted unmarked orphaned/lost states are recovered on the health ticker after
|
|
650
665
|
`/reload` even when process evidence does not produce a fresh transition.
|
|
651
666
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
follow-ups
|
|
655
|
-
|
|
667
|
+
Ordinary completion batching does not depend on Pi's `followUpMode`: events
|
|
668
|
+
stay in the harness until availability rather than becoming separate queued
|
|
669
|
+
follow-ups during the foreground run. Urgent health/failure alerts retain their
|
|
670
|
+
existing immediate follow-up behavior. This extension does not change Pi core
|
|
671
|
+
or Pi's default follow-up mode.
|
|
656
672
|
|
|
657
673
|
### Surfacing health (tools + passive widget)
|
|
658
674
|
|
package/index.ts
CHANGED
|
@@ -116,7 +116,7 @@ import {
|
|
|
116
116
|
getSharedCapacityGate,
|
|
117
117
|
} from "./capacity.mjs";
|
|
118
118
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
119
|
-
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
119
|
+
import { cancelCallbackBatch, getCallbackBatcher, setCallbackBatchContext } from "./shared-callback-batcher.ts";
|
|
120
120
|
import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
|
|
121
121
|
import { collectRunFailures, failurePath, markFailureAttentionDelivered, pendingFailureAttention } from "./failures.ts";
|
|
122
122
|
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
@@ -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);
|
|
@@ -2340,6 +2345,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2340
2345
|
|
|
2341
2346
|
// ---- live-status lifecycle -----------------------------------------
|
|
2342
2347
|
pi.on("agent_start", async (_event, ctx) => {
|
|
2348
|
+
setCallbackBatchContext(pi, ctx);
|
|
2343
2349
|
uiCtx = ctx;
|
|
2344
2350
|
mainAgentStartedAt = Date.now();
|
|
2345
2351
|
mainAgentTools.clear();
|
|
@@ -2372,6 +2378,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2372
2378
|
});
|
|
2373
2379
|
|
|
2374
2380
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
2381
|
+
setCallbackBatchContext(pi, ctx);
|
|
2375
2382
|
mainAgentStartedAt = undefined;
|
|
2376
2383
|
mainAgentTools.clear();
|
|
2377
2384
|
refreshBackgroundWorkNavigator(ctx);
|
|
@@ -2387,6 +2394,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2387
2394
|
// alive, resume the ticking widget. Deferred out of the factory per pi's
|
|
2388
2395
|
// "no background resources at load" rule.
|
|
2389
2396
|
pi.on("session_start", async (_event, ctx) => {
|
|
2397
|
+
setCallbackBatchContext(pi, ctx);
|
|
2390
2398
|
agentSessionSettings.restore(ctx);
|
|
2391
2399
|
restoreDelegationMode(ctx);
|
|
2392
2400
|
uiCtx = ctx;
|
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
|
|
|
@@ -3,6 +3,8 @@ export type CallbackSource = "subagent" | "background-task";
|
|
|
3
3
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
4
4
|
|
|
5
5
|
export interface CallbackBatchHost {
|
|
6
|
+
/** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
|
|
7
|
+
events?: object;
|
|
6
8
|
sendMessage(
|
|
7
9
|
message: { customType: string; content: string; display: boolean },
|
|
8
10
|
options: Record<string, unknown>,
|
|
@@ -66,6 +68,8 @@ export interface UrgentCallbackEvent {
|
|
|
66
68
|
export interface CallbackBatcherOptions {
|
|
67
69
|
windowMs?: number;
|
|
68
70
|
retryMs?: number;
|
|
71
|
+
/** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
|
|
72
|
+
isAvailable?: () => boolean;
|
|
69
73
|
/** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
|
|
70
74
|
maxBytes?: number;
|
|
71
75
|
}
|
|
@@ -73,6 +77,10 @@ export interface CallbackBatcherOptions {
|
|
|
73
77
|
export interface CallbackBatcher {
|
|
74
78
|
enqueue(event: CallbackBatchEvent): boolean;
|
|
75
79
|
flush(): Promise<boolean>;
|
|
80
|
+
/** Refresh the API wrapper after reload without losing handoff receipts. */
|
|
81
|
+
setHost(host: CallbackBatchHost): void;
|
|
82
|
+
/** Update the session predicate and schedule an asynchronous drain when available. */
|
|
83
|
+
setAvailability(isAvailable: () => boolean): void;
|
|
76
84
|
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
77
85
|
cancel(): void;
|
|
78
86
|
pendingCount(): number;
|
|
@@ -95,6 +103,7 @@ interface PendingEvent {
|
|
|
95
103
|
|
|
96
104
|
interface SharedCallbackBatcherState {
|
|
97
105
|
byHost: WeakMap<object, CallbackBatcher>;
|
|
106
|
+
bySession?: WeakMap<object, CallbackBatcher>;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
@@ -435,6 +444,19 @@ export function createCallbackBatcher(
|
|
|
435
444
|
let sequence = 0;
|
|
436
445
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
437
446
|
let flushPromise: Promise<boolean> | undefined;
|
|
447
|
+
let isAvailable = options.isAvailable ?? (() => true);
|
|
448
|
+
let gated = options.isAvailable !== undefined;
|
|
449
|
+
let awaitingRun = false;
|
|
450
|
+
|
|
451
|
+
const available = (): boolean => {
|
|
452
|
+
try {
|
|
453
|
+
if (!isAvailable()) {
|
|
454
|
+
awaitingRun = false;
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
return !awaitingRun;
|
|
458
|
+
} catch { return false; }
|
|
459
|
+
};
|
|
438
460
|
|
|
439
461
|
const cancelTimer = (): void => {
|
|
440
462
|
if (timer) clearTimeout(timer);
|
|
@@ -443,10 +465,12 @@ export function createCallbackBatcher(
|
|
|
443
465
|
|
|
444
466
|
const schedule = (delayMs: number): void => {
|
|
445
467
|
if (timer || pending.size === 0) return;
|
|
468
|
+
// Some idle transitions (manual compaction) have no agent_settled event.
|
|
469
|
+
const delay = available() ? delayMs : retryMs;
|
|
446
470
|
timer = setTimeout(() => {
|
|
447
471
|
timer = undefined;
|
|
448
472
|
void api.flush();
|
|
449
|
-
}, Math.max(
|
|
473
|
+
}, Math.max(1, delay));
|
|
450
474
|
timer.unref?.();
|
|
451
475
|
};
|
|
452
476
|
|
|
@@ -461,6 +485,10 @@ export function createCallbackBatcher(
|
|
|
461
485
|
|
|
462
486
|
const performFlush = async (): Promise<boolean> => {
|
|
463
487
|
cancelTimer();
|
|
488
|
+
if (!available()) {
|
|
489
|
+
schedule(retryMs);
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
464
492
|
const snapshot = [...pending.entries()]
|
|
465
493
|
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
466
494
|
pending.clear();
|
|
@@ -509,6 +537,9 @@ export function createCallbackBatcher(
|
|
|
509
537
|
const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
|
|
510
538
|
|
|
511
539
|
try {
|
|
540
|
+
// Pi may defer the requested run while settled handlers finish, leaving
|
|
541
|
+
// isIdle() true. Permit no second handoff until a run starts.
|
|
542
|
+
awaitingRun = gated;
|
|
512
543
|
await host.sendMessage(
|
|
513
544
|
{
|
|
514
545
|
customType: "background-completion-batch",
|
|
@@ -518,6 +549,7 @@ export function createCallbackBatcher(
|
|
|
518
549
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
519
550
|
);
|
|
520
551
|
} catch {
|
|
552
|
+
awaitingRun = false;
|
|
521
553
|
for (const [key] of deliverable) inFlight.delete(key);
|
|
522
554
|
const retryItems = [...deliverable, ...pending.entries()]
|
|
523
555
|
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
@@ -596,10 +628,19 @@ export function createCallbackBatcher(
|
|
|
596
628
|
const api: CallbackBatcher = {
|
|
597
629
|
enqueue,
|
|
598
630
|
flush,
|
|
631
|
+
setHost(next) { host = next; },
|
|
632
|
+
setAvailability(check) {
|
|
633
|
+
gated = true;
|
|
634
|
+
isAvailable = check;
|
|
635
|
+
available();
|
|
636
|
+
cancelTimer();
|
|
637
|
+
schedule(windowMs);
|
|
638
|
+
},
|
|
599
639
|
deliverUrgent,
|
|
600
640
|
cancel() {
|
|
601
641
|
cancelTimer();
|
|
602
642
|
pending.clear();
|
|
643
|
+
awaitingRun = false;
|
|
603
644
|
},
|
|
604
645
|
pendingCount() {
|
|
605
646
|
return pending.size;
|
|
@@ -613,16 +654,38 @@ export function getCallbackBatcher(
|
|
|
613
654
|
options: CallbackBatcherOptions = {},
|
|
614
655
|
): CallbackBatcher {
|
|
615
656
|
const state = globalState();
|
|
616
|
-
const key = host
|
|
657
|
+
const key = host.events ?? host;
|
|
617
658
|
const existing = state.byHost.get(key);
|
|
618
|
-
if (existing)
|
|
659
|
+
if (existing) {
|
|
660
|
+
existing.setHost(host);
|
|
661
|
+
return existing;
|
|
662
|
+
}
|
|
619
663
|
const created = createCallbackBatcher(host, options);
|
|
620
664
|
state.byHost.set(key, created);
|
|
621
665
|
return created;
|
|
622
666
|
}
|
|
623
667
|
|
|
624
668
|
export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
625
|
-
globalState().byHost.get(host
|
|
669
|
+
const batcher = globalState().byHost.get(host.events ?? host);
|
|
670
|
+
batcher?.setAvailability(() => false);
|
|
671
|
+
batcher?.cancel();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Called on session_start, agent_start and agent_settled by both extensions. */
|
|
675
|
+
export function setCallbackBatchContext(
|
|
676
|
+
host: CallbackBatchHost,
|
|
677
|
+
ctx: { isIdle(): boolean; sessionManager?: object },
|
|
678
|
+
): void {
|
|
679
|
+
const state = globalState();
|
|
680
|
+
const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
|
|
681
|
+
const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
|
|
682
|
+
const batcher = shared ?? getCallbackBatcher(host);
|
|
683
|
+
if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
|
|
684
|
+
state.byHost.set(host.events ?? host, batcher);
|
|
685
|
+
batcher.setHost(host);
|
|
686
|
+
// Schedule rather than await delivery inside agent_settled: its handlers are
|
|
687
|
+
// still settling the previous run, and sendMessage may start a new one.
|
|
688
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
626
689
|
}
|
|
627
690
|
|
|
628
691
|
function globalState(): SharedCallbackBatcherState {
|
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
|
|
|
@@ -182,6 +183,8 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
182
183
|
admitExtensionTool?: (name: string, input: unknown, sourcePath: string | undefined) => boolean;
|
|
183
184
|
/** Register the guarded apply_patch adapter as a task builtin. */
|
|
184
185
|
applyPatch?: boolean;
|
|
186
|
+
/** Human-controlled access to the fixed read-only inventory adapter. */
|
|
187
|
+
processAccess?: () => "off" | "read";
|
|
185
188
|
/** Build the admitted bash definition from the confined operations (default: the SDK bash tool). */
|
|
186
189
|
bashDefinition?: (cwd: string, operations: BashOperations) => ReturnType<typeof createBashToolDefinition>;
|
|
187
190
|
}) {
|
|
@@ -205,6 +208,7 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
205
208
|
pi.registerTool(own(createWriteToolDefinition(cwd, { operations: files.write })));
|
|
206
209
|
pi.registerTool(own(createEditToolDefinition(cwd, { operations: files.edit })));
|
|
207
210
|
pi.registerTool(own(options.bashDefinition ? options.bashDefinition(cwd, bash) : createBashToolDefinition(cwd, { operations: bash })));
|
|
211
|
+
pi.registerTool(own(createProcessListToolDefinition(cwd, controller, options.processAccess ?? (() => "off"))) as any);
|
|
208
212
|
if (options.applyPatch) {
|
|
209
213
|
pi.registerTool(own(createApplyPatchToolDefinition(cwd, {
|
|
210
214
|
readFile: files.read.readFile, writeFile: files.write.writeFile, mkdir: files.write.mkdir,
|
|
@@ -226,6 +230,9 @@ export function installTaskTools(pi: ExtensionAPI, options: {
|
|
|
226
230
|
if (event.toolName === "bash" && plan.policy.permissions?.commands === false) {
|
|
227
231
|
return { block: true, reason: "Sandbox: Run commands & applications is Off." };
|
|
228
232
|
}
|
|
233
|
+
if (event.toolName === PROCESS_LIST_TOOL && options.processAccess?.() !== "read") {
|
|
234
|
+
return { block: true, reason: "Sandbox: Process access is Off. Enable Read in /sandbox." };
|
|
235
|
+
}
|
|
229
236
|
return;
|
|
230
237
|
}
|
|
231
238
|
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-]+$/;
|
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,
|