pi-landstrip 0.2.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +5 -11
  2. package/index.ts +98 -35
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -3,23 +3,17 @@
3
3
  Landlock-based sandboxing for [pi](https://pi.dev/) using
4
4
  [`landstrip`](https://github.com/jarkkojs/landstrip).
5
5
 
6
- ## Prerequisites
7
-
8
- Install `landstrip` and make sure it is on the `PATH` used to launch pi:
9
-
10
- ```bash
11
- cargo install landstrip
12
- ```
13
-
14
- `landstrip` supports Linux, macOS, and Windows. On other platforms this extension loads
15
- but leaves sandboxing disabled.
16
-
17
6
  ## Install
18
7
 
19
8
  ```bash
20
9
  pi install npm:pi-landstrip
21
10
  ```
22
11
 
12
+ This installs `pi-landstrip` and its `@jarkkojs/landstrip` dependency, which
13
+ includes platform-specific native binaries for Linux, macOS, and Windows.
14
+
15
+ On unsupported platforms the extension loads but leaves sandboxing disabled.
16
+
23
17
  ## Configure
24
18
 
25
19
  Create `.pi/sandbox.json` in a project or `~/.pi/agent/sandbox.json` globally.
package/index.ts CHANGED
@@ -10,6 +10,8 @@ import type {
10
10
  ExtensionContext,
11
11
  } from '@earendil-works/pi-coding-agent';
12
12
 
13
+ import { binaryPath } from '@jarkkojs/landstrip';
14
+
13
15
  import { spawn, spawnSync } from 'node:child_process';
14
16
  import {
15
17
  existsSync,
@@ -50,16 +52,10 @@ interface SandboxNetworkConfig {
50
52
  deniedDomains: string[];
51
53
  }
52
54
 
53
- interface LandstripConfig {
54
- command: string;
55
- debug: boolean;
56
- }
57
-
58
55
  interface SandboxConfig {
59
56
  enabled: boolean;
60
57
  network: SandboxNetworkConfig;
61
58
  filesystem: SandboxFilesystemConfig;
62
- landstrip: LandstripConfig;
63
59
  }
64
60
 
65
61
  interface LandstripPolicy {
@@ -72,7 +68,15 @@ interface LandstripPolicy {
72
68
  filesystem: SandboxFilesystemConfig;
73
69
  }
74
70
 
75
- const LANDSTRIP_VERSION = [0, 8, 3] as const;
71
+ interface LandstripErrorResponse {
72
+ category: 'policy' | 'tool' | 'platform' | 'system';
73
+ file?: string;
74
+ program?: string;
75
+ type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
76
+ message: string;
77
+ }
78
+
79
+ const LANDSTRIP_VERSION = [0, 9, 5] as const;
76
80
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
77
81
 
78
82
  const DEFAULT_CONFIG: SandboxConfig = {
@@ -104,10 +108,6 @@ const DEFAULT_CONFIG: SandboxConfig = {
104
108
  allowWrite: ['.', '/tmp'],
105
109
  denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
106
110
  },
107
- landstrip: {
108
- command: 'landstrip',
109
- debug: false,
110
- },
111
111
  };
112
112
 
113
113
  type PermissionChoice = 'abort' | 'session' | 'project' | 'global';
@@ -176,10 +176,6 @@ function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): Sand
176
176
  ...base.filesystem,
177
177
  ...overrides.filesystem,
178
178
  },
179
- landstrip: {
180
- ...base.landstrip,
181
- ...overrides.landstrip,
182
- },
183
179
  };
184
180
  }
185
181
 
@@ -344,6 +340,55 @@ function extractBlockedWritePath(output: string, cwd: string): string | null {
344
340
  return match ? normalizeBlockedPath(match[1], cwd) : null;
345
341
  }
346
342
 
343
+ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
344
+ const errors: LandstripErrorResponse[] = [];
345
+
346
+ for (const line of output.split('\n')) {
347
+ try {
348
+ const parsed = JSON.parse(line);
349
+
350
+ if (
351
+ typeof parsed === 'object' &&
352
+ parsed !== null &&
353
+ typeof parsed.category === 'string' &&
354
+ ['policy', 'tool', 'platform', 'system'].includes(parsed.category) &&
355
+ (parsed.type === undefined ||
356
+ (typeof parsed.type === 'string' &&
357
+ ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(parsed.type))) &&
358
+ typeof parsed.message === 'string' &&
359
+ parsed.message.length > 0
360
+ ) {
361
+ errors.push(parsed as LandstripErrorResponse);
362
+ }
363
+ } catch {
364
+ // ignore non-JSON lines
365
+ }
366
+ }
367
+
368
+ return errors;
369
+ }
370
+
371
+ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
372
+ return errors
373
+ .map((err) => {
374
+ const parts: string[] = [`landstrip: ${err.category}`];
375
+
376
+ if (err.file) {
377
+ parts.push(` (${err.file})`);
378
+ }
379
+ if (err.program) {
380
+ parts.push(` ${err.program}`);
381
+ }
382
+ if (err.type) {
383
+ parts.push(`:${err.type}`);
384
+ }
385
+ parts.push(`: ${err.message}`);
386
+
387
+ return parts.join('');
388
+ })
389
+ .join('\n');
390
+ }
391
+
347
392
  async function showPermissionPrompt(
348
393
  ctx: ExtensionContext,
349
394
  title: string,
@@ -472,8 +517,8 @@ function promptWriteBlock(ctx: ExtensionContext, filePath: string): Promise<Perm
472
517
  );
473
518
  }
474
519
 
475
- function landstripVersion(command: string): string | null {
476
- const result = spawnSync(command, ['--version'], { encoding: 'utf-8' });
520
+ function landstripVersion(): string | null {
521
+ const result = spawnSync(binaryPath(), ['--version'], { encoding: 'utf-8' });
477
522
  if (result.status !== 0) return null;
478
523
  return result.stdout.trim();
479
524
  }
@@ -785,23 +830,18 @@ export default function (pi: ExtensionAPI) {
785
830
  });
786
831
  }
787
832
 
788
- function createLandstripBashOps(ctx: ExtensionContext): BashOperations {
833
+ function createLandstripBashOps(
834
+ ctx: ExtensionContext,
835
+ onStderr: (data: Buffer) => void = () => {},
836
+ ): BashOperations {
789
837
  return {
790
838
  async exec(command, cwd, { onData, signal, timeout, env }) {
791
839
  if (!existsSync(cwd)) throw new Error(`Working directory does not exist: ${cwd}`);
792
840
 
793
- const config = loadConfig(cwd);
794
841
  const { shell, args } = getShellConfig(userShellPath);
795
842
  const proxy = await startProxy(ctx, cwd);
796
843
  const policy = writePolicyFile(cwd, proxy.port);
797
- const landstripArgs = [
798
- ...(config.landstrip.debug ? ['--debug'] : []),
799
- '-p',
800
- policy.path,
801
- shell,
802
- ...args,
803
- command,
804
- ];
844
+ const landstripArgs = ['-p', policy.path, shell, ...args, command];
805
845
 
806
846
  return new Promise((resolvePromise, reject) => {
807
847
  let timeoutHandle: NodeJS.Timeout | undefined;
@@ -817,7 +857,7 @@ export default function (pi: ExtensionAPI) {
817
857
  rmSync(policy.dir, { recursive: true, force: true });
818
858
  };
819
859
 
820
- const child = spawn(config.landstrip.command, landstripArgs, {
860
+ const child = spawn(binaryPath(), landstripArgs, {
821
861
  cwd,
822
862
  env: proxyEnv(env, proxy.port),
823
863
  detached: true,
@@ -846,7 +886,10 @@ export default function (pi: ExtensionAPI) {
846
886
 
847
887
  signal?.addEventListener('abort', onAbort, { once: true });
848
888
  child.stdout?.on('data', onData);
849
- child.stderr?.on('data', onData);
889
+ child.stderr?.on('data', (data: Buffer) => {
890
+ onStderr(data);
891
+ onData(data);
892
+ });
850
893
 
851
894
  child.on('error', (error) => {
852
895
  cleanup();
@@ -875,17 +918,34 @@ export default function (pi: ExtensionAPI) {
875
918
  onUpdate: AgentToolUpdateCallback<BashToolDetails | undefined> | undefined,
876
919
  ctx: ExtensionContext,
877
920
  ): Promise<AgentToolResult<BashToolDetails | undefined>> {
921
+ let landstripStderr = '';
878
922
  const sandboxedBash = createBashToolDefinition(localCwd, {
879
- operations: createLandstripBashOps(ctx),
923
+ operations: createLandstripBashOps(ctx, (data) => {
924
+ landstripStderr += data.toString('utf8');
925
+ }),
880
926
  shellPath: userShellPath,
881
927
  });
882
928
 
883
929
  const run = () => sandboxedBash.execute(id, params, signal, onUpdate, ctx);
884
- const result = await run();
930
+ let result: AgentToolResult<BashToolDetails | undefined>;
931
+ try {
932
+ result = await run();
933
+ } catch (error) {
934
+ const landstripErrors = parseLandstripErrors(landstripStderr);
935
+ if (landstripErrors.length > 0) {
936
+ throw new Error(formatLandstripErrors(landstripErrors));
937
+ }
938
+ throw error;
939
+ }
885
940
  const outputText = result.content
886
941
  .filter((content) => content.type === 'text')
887
942
  .map((content) => content.text)
888
943
  .join('\n');
944
+ const landstripErrors = parseLandstripErrors(landstripStderr);
945
+ if (landstripErrors.length > 0) {
946
+ const message = formatLandstripErrors(landstripErrors);
947
+ result.content.unshift({ type: 'text', text: `\n${message}\n` });
948
+ }
889
949
  const blockedPath = extractBlockedWritePath(outputText, ctx.cwd);
890
950
 
891
951
  if (!blockedPath || !ctx.hasUI) return result;
@@ -956,18 +1016,21 @@ export default function (pi: ExtensionAPI) {
956
1016
  return false;
957
1017
  }
958
1018
 
959
- const version = landstripVersion(config.landstrip.command);
1019
+ const version = landstripVersion();
960
1020
  if (!version) {
961
1021
  sandboxEnabled = false;
962
1022
  sandboxReady = false;
963
- ctx.ui.notify(`landstrip was not found. Install it with: cargo install landstrip`, 'error');
1023
+ ctx.ui.notify(
1024
+ `landstrip was not found. Reinstall with: npm install @jarkkojs/landstrip`,
1025
+ 'error',
1026
+ );
964
1027
  return false;
965
1028
  }
966
1029
 
967
1030
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
968
1031
  sandboxEnabled = false;
969
1032
  sandboxReady = false;
970
- ctx.ui.notify(`landstrip 0.8.3 or newer is required; found: ${version}`, 'error');
1033
+ ctx.ui.notify(`landstrip 0.9.5 or newer is required; found: ${version}`, 'error');
971
1034
  return false;
972
1035
  }
973
1036
 
@@ -1127,7 +1190,7 @@ export default function (pi: ExtensionAPI) {
1127
1190
  'Sandbox Configuration',
1128
1191
  ` Project config: ${projectPath}`,
1129
1192
  ` Global config: ${globalPath}`,
1130
- ` landstrip: ${config.landstrip.command}`,
1193
+ ` landstrip: ${binaryPath()}`,
1131
1194
  '',
1132
1195
  'Network (bash through HTTP proxy):',
1133
1196
  ` Allowed domains: ${config.network.allowedDomains.join(', ') || '(none)'}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -29,7 +29,8 @@
29
29
  "ci:check": "npm run check"
30
30
  },
31
31
  "dependencies": {
32
- "@earendil-works/pi-tui": "^0.78.0"
32
+ "@earendil-works/pi-tui": "^0.78.0",
33
+ "@jarkkojs/landstrip": "^0.9.5"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@earendil-works/pi-coding-agent": "^0.78.0",