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.
- package/README.md +5 -11
- package/index.ts +98 -35
- 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
|
-
|
|
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(
|
|
476
|
-
const result = spawnSync(
|
|
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(
|
|
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(
|
|
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',
|
|
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
|
-
|
|
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(
|
|
1019
|
+
const version = landstripVersion();
|
|
960
1020
|
if (!version) {
|
|
961
1021
|
sandboxEnabled = false;
|
|
962
1022
|
sandboxReady = false;
|
|
963
|
-
ctx.ui.notify(
|
|
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.
|
|
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: ${
|
|
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.
|
|
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",
|