pi-landstrip 0.3.1 → 0.3.2
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/index.ts +193 -151
- package/landstrip.d.ts +3 -0
- package/package.json +2 -1
package/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
|
+
/// <reference path="./landstrip.d.ts" />
|
|
5
|
+
|
|
4
6
|
import type {
|
|
5
7
|
AgentToolResult,
|
|
6
8
|
AgentToolUpdateCallback,
|
|
@@ -593,16 +595,33 @@ function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): vo
|
|
|
593
595
|
upstream.pipe(client);
|
|
594
596
|
}
|
|
595
597
|
|
|
598
|
+
type LandstripBashTool = ReturnType<typeof createBashToolDefinition>;
|
|
599
|
+
|
|
600
|
+
export interface LandstripIntegrationOptions {
|
|
601
|
+
registerBashTool?: boolean;
|
|
602
|
+
cwd?: string;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export interface LandstripIntegration {
|
|
606
|
+
createBashTool(cwd: string, ctx?: ExtensionContext): LandstripBashTool;
|
|
607
|
+
register(pi: ExtensionAPI): void;
|
|
608
|
+
}
|
|
609
|
+
|
|
596
610
|
export default function (pi: ExtensionAPI) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
}
|
|
611
|
+
createLandstripIntegration().register(pi);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function createLandstripIntegration(
|
|
615
|
+
options: LandstripIntegrationOptions = {},
|
|
616
|
+
): LandstripIntegration {
|
|
617
|
+
const shouldRegisterBashTool = options.registerBashTool ?? true;
|
|
618
|
+
const localCwd = options.cwd ?? process.cwd();
|
|
602
619
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
620
|
+
function createPlainBashTool(cwd: string): LandstripBashTool {
|
|
621
|
+
return createBashToolDefinition(cwd, {
|
|
622
|
+
shellPath: SettingsManager.create(cwd).getShellPath(),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
606
625
|
|
|
607
626
|
let sandboxEnabled = false;
|
|
608
627
|
let sandboxReady = false;
|
|
@@ -838,7 +857,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
838
857
|
async exec(command, cwd, { onData, signal, timeout, env }) {
|
|
839
858
|
if (!existsSync(cwd)) throw new Error(`Working directory does not exist: ${cwd}`);
|
|
840
859
|
|
|
841
|
-
const { shell, args } = getShellConfig(
|
|
860
|
+
const { shell, args } = getShellConfig(SettingsManager.create(cwd).getShellPath());
|
|
842
861
|
const proxy = await startProxy(ctx, cwd);
|
|
843
862
|
const policy = writePolicyFile(cwd, proxy.port);
|
|
844
863
|
const landstripArgs = ['-p', policy.path, shell, ...args, command];
|
|
@@ -919,11 +938,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
919
938
|
ctx: ExtensionContext,
|
|
920
939
|
): Promise<AgentToolResult<BashToolDetails | undefined>> {
|
|
921
940
|
let landstripStderr = '';
|
|
922
|
-
const sandboxedBash = createBashToolDefinition(
|
|
941
|
+
const sandboxedBash = createBashToolDefinition(ctx.cwd, {
|
|
923
942
|
operations: createLandstripBashOps(ctx, (data) => {
|
|
924
943
|
landstripStderr += data.toString('utf8');
|
|
925
944
|
}),
|
|
926
|
-
shellPath:
|
|
945
|
+
shellPath: SettingsManager.create(ctx.cwd).getShellPath(),
|
|
927
946
|
});
|
|
928
947
|
|
|
929
948
|
const run = () => sandboxedBash.execute(id, params, signal, onUpdate, ctx);
|
|
@@ -1041,178 +1060,201 @@ export default function (pi: ExtensionAPI) {
|
|
|
1041
1060
|
return true;
|
|
1042
1061
|
}
|
|
1043
1062
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
label: 'bash (landstrip)',
|
|
1047
|
-
async execute(id, params, signal, onUpdate, ctx) {
|
|
1048
|
-
if (!sandboxEnabled || !sandboxReady)
|
|
1049
|
-
return localBash.execute(id, params, signal, onUpdate, ctx);
|
|
1063
|
+
function createBashTool(cwd: string, ctx?: ExtensionContext): LandstripBashTool {
|
|
1064
|
+
const localBash = createPlainBashTool(cwd);
|
|
1050
1065
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1066
|
+
return {
|
|
1067
|
+
...localBash,
|
|
1068
|
+
label: 'bash (landstrip)',
|
|
1069
|
+
async execute(id, params, signal, onUpdate, callCtx) {
|
|
1070
|
+
const effectiveCtx = callCtx ?? ctx;
|
|
1071
|
+
if (!sandboxEnabled || !sandboxReady || !effectiveCtx)
|
|
1072
|
+
return localBash.execute(id, params, signal, onUpdate, effectiveCtx);
|
|
1073
|
+
|
|
1074
|
+
return runBashWithOptionalRetry(id, params, signal, onUpdate, effectiveCtx);
|
|
1075
|
+
},
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1054
1078
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
return {
|
|
1062
|
-
result: {
|
|
1063
|
-
output: `Blocked: "${blockedDomain}" is not allowed by the sandbox. Use /sandbox to review your config.`,
|
|
1064
|
-
exitCode: 1,
|
|
1065
|
-
cancelled: false,
|
|
1066
|
-
truncated: false,
|
|
1067
|
-
},
|
|
1068
|
-
};
|
|
1069
|
-
}
|
|
1079
|
+
function register(pi: ExtensionAPI): void {
|
|
1080
|
+
const maybePi = pi as ExtensionAPI & {
|
|
1081
|
+
getFlag?: (name: string) => unknown;
|
|
1082
|
+
registerCommand?: ExtensionAPI['registerCommand'];
|
|
1083
|
+
registerFlag?: ExtensionAPI['registerFlag'];
|
|
1084
|
+
};
|
|
1070
1085
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1086
|
+
maybePi.registerFlag?.('no-sandbox', {
|
|
1087
|
+
description: 'Disable landstrip sandboxing for bash commands',
|
|
1088
|
+
type: 'boolean',
|
|
1089
|
+
default: false,
|
|
1090
|
+
});
|
|
1073
1091
|
|
|
1074
|
-
|
|
1075
|
-
if (!sandboxEnabled) return;
|
|
1092
|
+
if (shouldRegisterBashTool) pi.registerTool(createBashTool(localCwd));
|
|
1076
1093
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1094
|
+
pi.on('user_bash', async (event, ctx) => {
|
|
1095
|
+
if (!sandboxEnabled || !sandboxReady) return;
|
|
1096
|
+
if (!loadConfig(ctx.cwd).enabled) return;
|
|
1079
1097
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
if (sandboxReady && isToolCallEventType('bash', event)) {
|
|
1083
|
-
const blockedDomain = await preflightCommandDomains(event.input.command, ctx);
|
|
1098
|
+
const blockedDomain = await preflightCommandDomains(event.command, ctx);
|
|
1084
1099
|
if (blockedDomain) {
|
|
1085
1100
|
return {
|
|
1086
|
-
|
|
1087
|
-
|
|
1101
|
+
result: {
|
|
1102
|
+
output: `Blocked: "${blockedDomain}" is not allowed by the sandbox. Use /sandbox to review your config.`,
|
|
1103
|
+
exitCode: 1,
|
|
1104
|
+
cancelled: false,
|
|
1105
|
+
truncated: false,
|
|
1106
|
+
},
|
|
1088
1107
|
};
|
|
1089
1108
|
}
|
|
1090
|
-
}
|
|
1091
1109
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1110
|
+
return { operations: createLandstripBashOps(ctx) };
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
1114
|
+
if (!sandboxEnabled) return;
|
|
1115
|
+
|
|
1116
|
+
const config = loadConfig(ctx.cwd);
|
|
1117
|
+
if (!config.enabled) return;
|
|
1118
|
+
|
|
1119
|
+
const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
|
|
1120
|
+
|
|
1121
|
+
if (sandboxReady && isToolCallEventType('bash', event)) {
|
|
1122
|
+
const blockedDomain = await preflightCommandDomains(event.input.command, ctx);
|
|
1123
|
+
if (blockedDomain) {
|
|
1097
1124
|
return {
|
|
1098
1125
|
block: true,
|
|
1099
|
-
reason: `
|
|
1126
|
+
reason: `Network access to "${blockedDomain}" is blocked by the sandbox.`,
|
|
1100
1127
|
};
|
|
1101
1128
|
}
|
|
1102
|
-
await applyReadChoice(choice, filePath, ctx.cwd);
|
|
1103
1129
|
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
if (isToolCallEventType('write', event) || isToolCallEventType('edit', event)) {
|
|
1107
|
-
const filePath = canonicalizePath((event.input as { path: string }).path);
|
|
1108
1130
|
|
|
1109
|
-
if (
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1131
|
+
if (isToolCallEventType('read', event)) {
|
|
1132
|
+
const filePath = canonicalizePath(event.input.path);
|
|
1133
|
+
if (!matchesPattern(filePath, getEffectiveAllowRead(ctx.cwd))) {
|
|
1134
|
+
const choice = await promptReadBlock(ctx, filePath);
|
|
1135
|
+
if (choice === 'abort') {
|
|
1136
|
+
return {
|
|
1137
|
+
block: true,
|
|
1138
|
+
reason: `Sandbox: read access denied for "${filePath}"`,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
await applyReadChoice(choice, filePath, ctx.cwd);
|
|
1142
|
+
}
|
|
1116
1143
|
}
|
|
1117
1144
|
|
|
1118
|
-
if (
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1145
|
+
if (isToolCallEventType('write', event) || isToolCallEventType('edit', event)) {
|
|
1146
|
+
const filePath = canonicalizePath((event.input as { path: string }).path);
|
|
1147
|
+
|
|
1148
|
+
if (matchesPattern(filePath, config.filesystem.denyWrite)) {
|
|
1121
1149
|
return {
|
|
1122
1150
|
block: true,
|
|
1123
|
-
reason:
|
|
1151
|
+
reason:
|
|
1152
|
+
`Sandbox: write access denied for "${filePath}" (in denyWrite). ` +
|
|
1153
|
+
`To change this, edit denyWrite in:\n ${projectPath}\n ${globalPath}`,
|
|
1124
1154
|
};
|
|
1125
1155
|
}
|
|
1126
|
-
await applyWriteChoice(choice, filePath, ctx.cwd);
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
});
|
|
1130
|
-
|
|
1131
|
-
pi.on('session_start', async (_event, ctx) => {
|
|
1132
|
-
const noSandbox = pi.getFlag('no-sandbox') as boolean;
|
|
1133
1156
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
return;
|
|
1147
|
-
}
|
|
1157
|
+
if (shouldPromptForWrite(filePath, getEffectiveAllowWrite(ctx.cwd), matchesPattern)) {
|
|
1158
|
+
const choice = await promptWriteBlock(ctx, filePath);
|
|
1159
|
+
if (choice === 'abort') {
|
|
1160
|
+
return {
|
|
1161
|
+
block: true,
|
|
1162
|
+
reason: `Sandbox: write access denied for "${filePath}" (not in allowWrite)`,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
await applyWriteChoice(choice, filePath, ctx.cwd);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1148
1169
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1170
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
1171
|
+
const noSandbox = maybePi.getFlag?.('no-sandbox') as boolean;
|
|
1151
1172
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
ctx.ui.notify('Sandbox is already enabled', 'info');
|
|
1173
|
+
if (noSandbox) {
|
|
1174
|
+
sandboxEnabled = false;
|
|
1175
|
+
sandboxReady = false;
|
|
1176
|
+
ctx.ui.notify('Sandbox disabled via --no-sandbox', 'warning');
|
|
1157
1177
|
return;
|
|
1158
1178
|
}
|
|
1159
1179
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
description: 'Disable the landstrip sandbox for this session',
|
|
1166
|
-
handler: async (_args, ctx) => {
|
|
1167
|
-
if (!sandboxEnabled) {
|
|
1168
|
-
ctx.ui.notify('Sandbox is already disabled', 'info');
|
|
1180
|
+
const config = loadConfig(ctx.cwd);
|
|
1181
|
+
if (!config.enabled) {
|
|
1182
|
+
sandboxEnabled = false;
|
|
1183
|
+
sandboxReady = false;
|
|
1184
|
+
ctx.ui.notify('Sandbox disabled via config', 'info');
|
|
1169
1185
|
return;
|
|
1170
1186
|
}
|
|
1171
1187
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
ctx.ui.setStatus('sandbox', '');
|
|
1175
|
-
ctx.ui.notify('Sandbox disabled', 'info');
|
|
1176
|
-
},
|
|
1177
|
-
});
|
|
1188
|
+
enableSandbox(ctx);
|
|
1189
|
+
});
|
|
1178
1190
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1191
|
+
maybePi.registerCommand?.('sandbox-enable', {
|
|
1192
|
+
description: 'Enable the landstrip sandbox for this session',
|
|
1193
|
+
handler: async (_args, ctx) => {
|
|
1194
|
+
if (sandboxEnabled) {
|
|
1195
|
+
ctx.ui.notify('Sandbox is already enabled', 'info');
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1186
1198
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
'',
|
|
1202
|
-
'
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1199
|
+
if (enableSandbox(ctx)) ctx.ui.notify('Sandbox enabled', 'info');
|
|
1200
|
+
},
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
maybePi.registerCommand?.('sandbox-disable', {
|
|
1204
|
+
description: 'Disable the landstrip sandbox for this session',
|
|
1205
|
+
handler: async (_args, ctx) => {
|
|
1206
|
+
if (!sandboxEnabled) {
|
|
1207
|
+
ctx.ui.notify('Sandbox is already disabled', 'info');
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
sandboxEnabled = false;
|
|
1212
|
+
sandboxReady = false;
|
|
1213
|
+
ctx.ui.setStatus('sandbox', '');
|
|
1214
|
+
ctx.ui.notify('Sandbox disabled', 'info');
|
|
1215
|
+
},
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
maybePi.registerCommand?.('sandbox', {
|
|
1219
|
+
description: 'Show sandbox configuration',
|
|
1220
|
+
handler: async (_args, ctx) => {
|
|
1221
|
+
if (!sandboxEnabled) {
|
|
1222
|
+
ctx.ui.notify('Sandbox is disabled', 'info');
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const config = loadConfig(ctx.cwd);
|
|
1227
|
+
const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
|
|
1228
|
+
const lines = [
|
|
1229
|
+
'Sandbox Configuration',
|
|
1230
|
+
` Project config: ${projectPath}`,
|
|
1231
|
+
` Global config: ${globalPath}`,
|
|
1232
|
+
` landstrip: ${binaryPath()}`,
|
|
1233
|
+
'',
|
|
1234
|
+
'Network (bash through HTTP proxy):',
|
|
1235
|
+
` Allowed domains: ${config.network.allowedDomains.join(', ') || '(none)'}`,
|
|
1236
|
+
` Denied domains: ${config.network.deniedDomains.join(', ') || '(none)'}`,
|
|
1237
|
+
...(sessionAllowedDomains.length > 0
|
|
1238
|
+
? [` Session allowed: ${sessionAllowedDomains.join(', ')}`]
|
|
1239
|
+
: []),
|
|
1240
|
+
'',
|
|
1241
|
+
'Filesystem (bash + read/write/edit tools):',
|
|
1242
|
+
` Deny Read: ${config.filesystem.denyRead.join(', ') || '(none)'}`,
|
|
1243
|
+
` Allow Read: ${config.filesystem.allowRead.join(', ') || '(none)'}`,
|
|
1244
|
+
` Allow Write: ${config.filesystem.allowWrite.join(', ') || '(none)'}`,
|
|
1245
|
+
` Deny Write: ${config.filesystem.denyWrite.join(', ') || '(none)'}`,
|
|
1246
|
+
...(sessionAllowedReadPaths.length > 0
|
|
1247
|
+
? [` Session read: ${sessionAllowedReadPaths.join(', ')}`]
|
|
1248
|
+
: []),
|
|
1249
|
+
...(sessionAllowedWritePaths.length > 0
|
|
1250
|
+
? [` Session write: ${sessionAllowedWritePaths.join(', ')}`]
|
|
1251
|
+
: []),
|
|
1252
|
+
];
|
|
1253
|
+
|
|
1254
|
+
ctx.ui.notify(lines.join('\n'), 'info');
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
return { createBashTool, register };
|
|
1218
1260
|
}
|
package/landstrip.d.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-landstrip",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Landlock-based sandboxing for pi with interactive permission prompts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landstrip",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"index.ts",
|
|
18
|
+
"landstrip.d.ts",
|
|
18
19
|
"README.md",
|
|
19
20
|
"sandbox.json"
|
|
20
21
|
],
|