pi-landstrip 0.18.28 → 0.18.30

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.
Files changed (4) hide show
  1. package/README.md +28 -0
  2. package/api.ts +45 -0
  3. package/dist/index.ts +123 -14
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -160,6 +160,34 @@ read Pi authentication and inherited credential environment variables. Install
160
160
  only trusted worker plugins and use credentials appropriate for the sandboxed
161
161
  task.
162
162
 
163
+ ## Permission ask providers
164
+
165
+ A separate Pi extension can resolve agent permission rules that return `ask` by
166
+ registering one `LandstripPermissionAskProvider`:
167
+
168
+ ```ts
169
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
170
+ import { provideLandstripPermissionAsk } from 'pi-landstrip/api';
171
+
172
+ export default function permissionReviewer(pi: ExtensionAPI): void {
173
+ provideLandstripPermissionAsk(pi, {
174
+ id: 'example-reviewer',
175
+ async decide(request) {
176
+ if (request.toolName === 'bash' && request.input.command === 'git status') {
177
+ return { decision: 'allow' };
178
+ }
179
+ return { decision: 'abstain' };
180
+ },
181
+ });
182
+ }
183
+ ```
184
+
185
+ The provider receives the tool name and input, pending permission/resource pairs,
186
+ the primary or subagent context, and an abort signal. `allow` approves that call,
187
+ `deny` blocks it, and `abstain` uses the normal UI prompt or headless denial.
188
+ Provider errors also fall back safely. Only one provider may be registered at a
189
+ time; the helper works regardless of extension load order and returns a disposer.
190
+
163
191
  ## License
164
192
 
165
193
  `pi-landstrip` is licensed under `Apache-2.0`. The bundled Landstrip package is
package/api.ts CHANGED
@@ -56,6 +56,33 @@ export interface LandstripShellProvider {
56
56
  ): LandstripShellInvocation | Promise<LandstripShellInvocation>;
57
57
  }
58
58
 
59
+ export interface LandstripPermissionAsk {
60
+ readonly permission: string;
61
+ readonly resource: string;
62
+ }
63
+
64
+ export interface LandstripPermissionAskRequest {
65
+ readonly context: LandstripContextV2;
66
+ readonly toolName: string;
67
+ readonly input: Readonly<Record<string, unknown>>;
68
+ readonly permissions: readonly LandstripPermissionAsk[];
69
+ /** Description of the subagent task that originated the request, when applicable. */
70
+ readonly taskDescription?: string;
71
+ readonly signal?: AbortSignal;
72
+ }
73
+
74
+ export type LandstripPermissionAskDecision =
75
+ | { readonly decision: 'allow' }
76
+ | { readonly decision: 'deny'; readonly reason?: string }
77
+ | { readonly decision: 'abstain' };
78
+
79
+ export interface LandstripPermissionAskProvider {
80
+ readonly id: string;
81
+ decide(
82
+ request: LandstripPermissionAskRequest,
83
+ ): LandstripPermissionAskDecision | Promise<LandstripPermissionAskDecision>;
84
+ }
85
+
59
86
  export interface LandstripProcessOptions {
60
87
  readonly command: string;
61
88
  readonly args: readonly string[];
@@ -112,6 +139,7 @@ export interface PiLandstripRuntimeV2 {
112
139
  readonly version: 2;
113
140
  getContext(ctx?: ExtensionContext): LandstripContextV2;
114
141
  registerShellProvider(provider: LandstripShellProvider): () => void;
142
+ registerPermissionAskProvider(provider: LandstripPermissionAskProvider): () => void;
115
143
  prepareProcess(options: LandstripProcessOptions): Promise<LandstripPreparedProcess>;
116
144
  registerWorkerExtension(extension: LandstripWorkerExtension): () => void;
117
145
  getWorkerExtensions(): readonly LandstripWorkerExtension[];
@@ -150,6 +178,7 @@ function isRuntime(value: unknown): value is PiLandstripRuntimeV2 {
150
178
  value.version === LANDSTRIP_RUNTIME_VERSION &&
151
179
  typeof value.getContext === 'function' &&
152
180
  typeof value.registerShellProvider === 'function' &&
181
+ typeof value.registerPermissionAskProvider === 'function' &&
153
182
  typeof value.prepareProcess === 'function' &&
154
183
  typeof value.registerWorkerExtension === 'function' &&
155
184
  typeof value.getWorkerExtensions === 'function' &&
@@ -202,6 +231,22 @@ export function provideLandstripShell(
202
231
  };
203
232
  }
204
233
 
234
+ export function provideLandstripPermissionAsk(
235
+ pi: ExtensionAPI,
236
+ provider: LandstripPermissionAskProvider,
237
+ ): () => void {
238
+ let unregisterProvider: (() => void) | undefined;
239
+ const stopDiscovery = useLandstrip(pi, (runtime) => {
240
+ const unregisterNext = runtime.registerPermissionAskProvider(provider);
241
+ unregisterProvider?.();
242
+ unregisterProvider = unregisterNext;
243
+ });
244
+ return () => {
245
+ stopDiscovery();
246
+ unregisterProvider?.();
247
+ };
248
+ }
249
+
205
250
  export function publishLandstripRuntime(
206
251
  pi: ExtensionAPI,
207
252
  runtime: PiLandstripRuntimeV2,
package/dist/index.ts CHANGED
@@ -2215,27 +2215,49 @@ class Semaphore {
2215
2215
 
2216
2216
  class PermissionBroker {
2217
2217
  prompts;
2218
+ resolveAsk;
2218
2219
  grants = new Set;
2219
2220
  resetController = new AbortController;
2220
- constructor(prompts) {
2221
+ constructor(prompts, resolveAsk = async () => ({ decision: "abstain" })) {
2221
2222
  this.prompts = prompts;
2223
+ this.resolveAsk = resolveAsk;
2222
2224
  }
2223
- async ask(ctx, task, permission, resource, signal) {
2224
- await this.askMany(ctx, task, [{ permission, resource }], signal);
2225
+ async ask(ctx, task, permission, resource, signal, details) {
2226
+ await this.askMany(ctx, task, [{ permission, resource }], signal, details);
2225
2227
  }
2226
- async askMany(ctx, task, requests, signal) {
2228
+ async askMany(ctx, task, requests, signal, details) {
2227
2229
  const unique = [
2228
2230
  ...new Map(requests.map((request) => [`${request.permission}\x00${request.resource}`, request])).values()
2229
2231
  ];
2230
2232
  const pending = () => unique.filter(({ permission, resource }) => !this.grants.has(`${permission}\x00${resource}`));
2231
2233
  if (pending().length === 0)
2232
2234
  return;
2233
- if (!ctx.hasUI) {
2234
- const request = pending()[0];
2235
- throw new Error(`Permission required: ${request.permission} ${request.resource}`);
2236
- }
2237
2235
  const combined = combineAbortSignals(signal, this.resetController.signal);
2238
2236
  try {
2237
+ if (details) {
2238
+ let decision = { decision: "abstain" };
2239
+ try {
2240
+ decision = await this.resolveWithSignal({ ...details, permissions: pending(), signal: combined.signal }, combined.signal);
2241
+ } catch (error) {
2242
+ if (combined.signal.aborted)
2243
+ throw new Error("Permission request cancelled");
2244
+ console.error(`pi-landstrip: permission ask provider failed: ${formatError(error)}`);
2245
+ }
2246
+ if (combined.signal.aborted)
2247
+ throw new Error("Permission request cancelled");
2248
+ if (pending().length === 0)
2249
+ return;
2250
+ if (decision.decision === "allow")
2251
+ return;
2252
+ if (decision.decision === "deny") {
2253
+ const request = pending()[0];
2254
+ throw new Error(decision.reason ?? `Permission denied: ${request.permission} ${request.resource}`);
2255
+ }
2256
+ }
2257
+ if (!ctx.hasUI) {
2258
+ const request = pending()[0];
2259
+ throw new Error(`Permission required: ${request.permission} ${request.resource}`);
2260
+ }
2239
2261
  await this.prompts.resolve(() => pending().length === 0 ? true : undefined, async (promptSignal) => {
2240
2262
  const requested = pending();
2241
2263
  const choice = await ctx.ui.select(`${task}: permission required
@@ -2264,6 +2286,23 @@ ${requested.map(({ permission, resource }) => `${permission}: ${resource}`).join
2264
2286
  this.grants.clear();
2265
2287
  controller.abort();
2266
2288
  }
2289
+ async resolveWithSignal(request, signal) {
2290
+ if (signal.aborted)
2291
+ throw new Error("Permission request cancelled");
2292
+ let abort;
2293
+ try {
2294
+ return await Promise.race([
2295
+ this.resolveAsk(request),
2296
+ new Promise((_resolve, reject) => {
2297
+ abort = () => reject(new Error("Permission request cancelled"));
2298
+ signal.addEventListener("abort", abort, { once: true });
2299
+ })
2300
+ ]);
2301
+ } finally {
2302
+ if (abort)
2303
+ signal.removeEventListener("abort", abort);
2304
+ }
2305
+ }
2267
2306
  }
2268
2307
  function renderTaskResult(id, state, value) {
2269
2308
  const tag = state === "error" ? "task_error" : "task_result";
@@ -2488,7 +2527,13 @@ function registerSubagentWorker(pi, config) {
2488
2527
  }
2489
2528
  if (decision !== "ask")
2490
2529
  continue;
2491
- const value = await ctx.ui.input(CONTROL_TITLE, JSON.stringify({ type: "permission", permission, resource }), { signal: ctx.signal });
2530
+ const value = await ctx.ui.input(CONTROL_TITLE, JSON.stringify({
2531
+ type: "permission",
2532
+ permission,
2533
+ resource,
2534
+ toolName: event.toolName,
2535
+ toolInput: input
2536
+ }), { signal: ctx.signal });
2492
2537
  try {
2493
2538
  parseControlResponse(value);
2494
2539
  } catch (error) {
@@ -2559,7 +2604,10 @@ class SubagentRuntime {
2559
2604
  this.integration = integration;
2560
2605
  this.createWorker = createWorker;
2561
2606
  this.loadCatalog = loadCatalog;
2562
- this.broker = new PermissionBroker(permissionPrompts);
2607
+ this.broker = new PermissionBroker(permissionPrompts, (request) => {
2608
+ const resolveAsk = this.integration.resolvePermissionAsk;
2609
+ return typeof resolveAsk === "function" ? resolveAsk.call(this.integration, request) : Promise.resolve({ decision: "abstain" });
2610
+ });
2563
2611
  }
2564
2612
  register() {
2565
2613
  this.pi.registerTool(this.createTaskTool());
@@ -2649,7 +2697,11 @@ ${this.primaryAgent.prompt}` };
2649
2697
  }
2650
2698
  if (requests.length > 0 && this.primaryAgent) {
2651
2699
  try {
2652
- await this.broker.askMany(ctx, `@${this.primaryAgent.name}`, requests, ctx.signal);
2700
+ await this.broker.askMany(ctx, `@${this.primaryAgent.name}`, requests, ctx.signal, {
2701
+ context: this.permissionContext(ctx),
2702
+ toolName: event.toolName,
2703
+ input
2704
+ });
2653
2705
  } catch (error) {
2654
2706
  return { block: true, reason: formatError(error) };
2655
2707
  }
@@ -3409,7 +3461,12 @@ ${catalog.diagnostics.join(`
3409
3461
  if (taskPermission === "deny")
3410
3462
  throw new Error(`Task permission denied for ${agent.name}`);
3411
3463
  if (taskPermission === "ask") {
3412
- await this.broker.ask(ctx, input.description, "task", agent.name, signal);
3464
+ await this.broker.ask(ctx, input.description, "task", agent.name, signal, {
3465
+ context: parentTask ? this.taskContext(parentTask, ctx) : this.permissionContext(ctx),
3466
+ toolName: "task",
3467
+ input: { ...input },
3468
+ taskDescription: parentTask?.description
3469
+ });
3413
3470
  }
3414
3471
  const depth = (parentTask?.depth ?? -1) + 1;
3415
3472
  if (depth > MAX_DEPTH)
@@ -3868,6 +3925,18 @@ ${catalog.diagnostics.join(`
3868
3925
  depth: task.depth
3869
3926
  };
3870
3927
  }
3928
+ permissionContext(ctx) {
3929
+ const context = this.integration.getContext?.(ctx) ?? {
3930
+ version: 2,
3931
+ host: "pi",
3932
+ role: "primary",
3933
+ sandbox: "unavailable",
3934
+ cwd: ctx.cwd,
3935
+ sessionId: ctx.sessionManager.getSessionId(),
3936
+ depth: 0
3937
+ };
3938
+ return { ...context, agent: this.primaryAgent?.name };
3939
+ }
3871
3940
  piInvocation() {
3872
3941
  const argvEntry = process.argv[1];
3873
3942
  if (argvEntry && /(?:^|[/\\])cli\.(?:js|mjs|cjs|ts)$/.test(argvEntry)) {
@@ -3897,10 +3966,20 @@ ${catalog.diagnostics.join(`
3897
3966
  throw new Error("Invalid worker control request");
3898
3967
  }
3899
3968
  if (control.type === "permission") {
3900
- if (typeof control.permission !== "string" || typeof control.resource !== "string") {
3969
+ if (typeof control.permission !== "string" || typeof control.resource !== "string" || typeof control.toolName !== "string" || !isRecord2(control.toolInput)) {
3901
3970
  throw new Error("Invalid permission request");
3902
3971
  }
3903
- await this.broker.ask(ctx, task.description, control.permission, control.resource, this.controllers.get(task.id)?.signal);
3972
+ const derivedPermission = permissionName(control.toolName);
3973
+ const derivedResources = permissionResources(control.toolName, control.toolInput, ctx.cwd);
3974
+ if (control.permission !== derivedPermission || !derivedResources.includes(control.resource) || permissionDecision(rules, control.permission, control.resource) !== "ask") {
3975
+ throw new Error("Invalid permission request");
3976
+ }
3977
+ await this.broker.ask(ctx, task.description, control.permission, control.resource, this.controllers.get(task.id)?.signal, {
3978
+ context: this.taskContext(task, ctx),
3979
+ toolName: control.toolName,
3980
+ input: control.toolInput,
3981
+ taskDescription: task.description
3982
+ });
3904
3983
  response = { ok: true };
3905
3984
  } else {
3906
3985
  if (!isRecord2(control.input))
@@ -5066,6 +5145,7 @@ function createLandstripIntegrationWithPrompts(options, permissionPrompts) {
5066
5145
  let unpublishRuntime;
5067
5146
  const defaultShellProvider = createPosixShellProvider();
5068
5147
  let externalShellProvider;
5148
+ let externalPermissionAskProvider;
5069
5149
  const eventHandlers = new Set;
5070
5150
  const workerExtensions = new Map;
5071
5151
  const sessionAllowedDomains = [];
@@ -5126,6 +5206,33 @@ function createLandstripIntegrationWithPrompts(options, permissionPrompts) {
5126
5206
  externalShellProvider = undefined;
5127
5207
  };
5128
5208
  }
5209
+ function registerPermissionAskProvider(provider) {
5210
+ const id = provider.id.trim();
5211
+ if (!id)
5212
+ throw new Error("Permission ask provider id must not be empty");
5213
+ if (externalPermissionAskProvider) {
5214
+ throw new Error(`Permission ask provider "${externalPermissionAskProvider.id}" is already registered; cannot register "${id}"`);
5215
+ }
5216
+ externalPermissionAskProvider = provider;
5217
+ let disposed = false;
5218
+ return () => {
5219
+ if (disposed)
5220
+ return;
5221
+ disposed = true;
5222
+ if (externalPermissionAskProvider === provider)
5223
+ externalPermissionAskProvider = undefined;
5224
+ };
5225
+ }
5226
+ async function resolvePermissionAsk(request) {
5227
+ const provider = externalPermissionAskProvider;
5228
+ if (!provider)
5229
+ return { decision: "abstain" };
5230
+ const result = await provider.decide(request);
5231
+ if (typeof result !== "object" || result === null || !("decision" in result) || !["allow", "deny", "abstain"].includes(String(result.decision)) || "reason" in result && result.reason !== undefined && typeof result.reason !== "string") {
5232
+ throw new Error(`Permission ask provider "${provider.id}" returned an invalid decision`);
5233
+ }
5234
+ return result;
5235
+ }
5129
5236
  function activeShellProvider() {
5130
5237
  return externalShellProvider ?? defaultShellProvider;
5131
5238
  }
@@ -6568,6 +6675,8 @@ ${target.path}`);
6568
6675
  version: LANDSTRIP_RUNTIME_VERSION,
6569
6676
  getContext,
6570
6677
  registerShellProvider,
6678
+ registerPermissionAskProvider,
6679
+ resolvePermissionAsk,
6571
6680
  prepareProcess,
6572
6681
  prepareRpcWorker,
6573
6682
  authorizeFilesystemToolAccess,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.18.28",
3
+ "version": "0.18.30",
4
4
  "description": "Sandboxed Bash and process-backed agents for Pi",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -39,7 +39,7 @@
39
39
  "ci:test": "npm run test"
40
40
  },
41
41
  "dependencies": {
42
- "@landstrip/landstrip": "^0.18.28",
42
+ "@landstrip/landstrip": "^0.18.30",
43
43
  "jsonc-parser": "^3.3.1",
44
44
  "minimatch": "^10.2.5",
45
45
  "yaml": "^2.9.0"