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.
- package/README.md +28 -0
- package/api.ts +45 -0
- package/dist/index.ts +123 -14
- 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({
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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"
|