opencode-landstrip 0.15.15 → 0.15.17
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 +59 -127
- package/package.json +2 -2
- package/shared.ts +22 -0
package/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
loadConfig,
|
|
23
23
|
normalizeOptions,
|
|
24
24
|
parseLandstripTraps,
|
|
25
|
+
permissionPatterns,
|
|
26
|
+
permissionType,
|
|
25
27
|
readDiscoveryPort,
|
|
26
28
|
} from './shared.js';
|
|
27
29
|
|
|
@@ -55,7 +57,7 @@ interface SandboxPermissionDecision {
|
|
|
55
57
|
|
|
56
58
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
57
59
|
|
|
58
|
-
const LANDSTRIP_VERSION = [0, 15,
|
|
60
|
+
const LANDSTRIP_VERSION = [0, 15, 14] as const;
|
|
59
61
|
const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
|
|
60
62
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
61
63
|
|
|
@@ -125,21 +127,37 @@ function globToRegExp(globPattern: string): RegExp {
|
|
|
125
127
|
return new RegExp(`^${regex}$`);
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
// Component count of an absolute path; "/" is 0. Used to rank how specific a
|
|
131
|
+
// matching pattern is so the most specific allow/deny rule wins.
|
|
132
|
+
function pathDepth(absolutePath: string): number {
|
|
133
|
+
return absolutePath.split('/').filter((segment) => segment.length > 0).length;
|
|
134
|
+
}
|
|
130
135
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
// The depth of the most specific pattern that matches `filePath`, or -1 when
|
|
137
|
+
// none match. A glob is anchored to the whole path, so it ranks at the path's
|
|
138
|
+
// own depth; a literal pattern ranks at the depth of the prefix it covers.
|
|
139
|
+
function matchDepth(filePath: string, patterns: string[], baseDirectory: string): number {
|
|
140
|
+
const abs = canonicalizePath(filePath, baseDirectory);
|
|
141
|
+
let depth = -1;
|
|
135
142
|
|
|
143
|
+
for (const pattern of patterns) {
|
|
136
144
|
if (pattern.includes('*')) {
|
|
137
|
-
|
|
145
|
+
const absPattern = expandPath(pattern, baseDirectory);
|
|
146
|
+
if (globToRegExp(absPattern).test(abs)) depth = Math.max(depth, pathDepth(abs));
|
|
147
|
+
} else {
|
|
148
|
+
const absPattern = canonicalizePath(pattern, baseDirectory);
|
|
149
|
+
const sep = absPattern.endsWith('/') ? '' : '/';
|
|
150
|
+
if (abs === absPattern || abs.startsWith(absPattern + sep)) {
|
|
151
|
+
depth = Math.max(depth, pathDepth(absPattern));
|
|
152
|
+
}
|
|
138
153
|
}
|
|
154
|
+
}
|
|
139
155
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
return depth;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function matchesPattern(filePath: string, patterns: string[], baseDirectory: string): boolean {
|
|
160
|
+
return matchDepth(filePath, patterns, baseDirectory) >= 0;
|
|
143
161
|
}
|
|
144
162
|
|
|
145
163
|
function resolveFilesystemPatterns(patterns: string[], baseDirectory: string): string[] {
|
|
@@ -162,10 +180,6 @@ function resolveFilesystemConfig(
|
|
|
162
180
|
};
|
|
163
181
|
}
|
|
164
182
|
|
|
165
|
-
function shouldPromptForRead(path: string, allowRead: string[], baseDirectory: string): boolean {
|
|
166
|
-
return allowRead.length === 0 || !matchesPattern(path, allowRead, baseDirectory);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
183
|
function shouldPromptForWrite(path: string, allowWrite: string[], baseDirectory: string): boolean {
|
|
170
184
|
return allowWrite.length === 0 || !matchesPattern(path, allowWrite, baseDirectory);
|
|
171
185
|
}
|
|
@@ -265,27 +279,6 @@ function extractBlockedWritePath(
|
|
|
265
279
|
return extractBlockedPath(output, baseDirectory, command);
|
|
266
280
|
}
|
|
267
281
|
|
|
268
|
-
function isBlockedByDenyRead(path: string, config: SandboxConfig, baseDirectory: string): boolean {
|
|
269
|
-
return matchesPattern(path, config.filesystem.denyRead, baseDirectory);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function firstBlockedDomain(
|
|
273
|
-
command: string,
|
|
274
|
-
config: SandboxConfig,
|
|
275
|
-
): { domain: string; reason: 'allowedDomains' | 'deniedDomains' } | null {
|
|
276
|
-
for (const domain of extractDomainsFromCommand(command)) {
|
|
277
|
-
if (domainMatchesAny(domain, config.network.deniedDomains)) {
|
|
278
|
-
return { domain, reason: 'deniedDomains' };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (!domainMatchesAny(domain, config.network.allowedDomains)) {
|
|
282
|
-
return { domain, reason: 'allowedDomains' };
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
282
|
function evaluateReadPermission(
|
|
290
283
|
path: string,
|
|
291
284
|
config: SandboxConfig,
|
|
@@ -293,8 +286,13 @@ function evaluateReadPermission(
|
|
|
293
286
|
effectiveAllowRead: string[],
|
|
294
287
|
): SandboxPermissionDecision {
|
|
295
288
|
const filePath = canonicalizePath(path, baseDirectory);
|
|
289
|
+
const allowDepth = matchDepth(filePath, effectiveAllowRead, baseDirectory);
|
|
290
|
+
const denyDepth = matchDepth(filePath, config.filesystem.denyRead, baseDirectory);
|
|
296
291
|
|
|
297
|
-
|
|
292
|
+
// The most specific rule wins, matching landstrip's read policy so the bash
|
|
293
|
+
// and read tools agree: a denyRead overrides allowRead only when it is more
|
|
294
|
+
// specific, while a tie or a more specific allowRead carves the path back in.
|
|
295
|
+
if (denyDepth > allowDepth) {
|
|
298
296
|
return {
|
|
299
297
|
status: 'deny',
|
|
300
298
|
kind: 'read',
|
|
@@ -303,7 +301,7 @@ function evaluateReadPermission(
|
|
|
303
301
|
};
|
|
304
302
|
}
|
|
305
303
|
|
|
306
|
-
if (
|
|
304
|
+
if (allowDepth >= 0) {
|
|
307
305
|
return { status: 'allow', kind: 'read', resource: filePath, message: '' };
|
|
308
306
|
}
|
|
309
307
|
|
|
@@ -754,27 +752,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
754
752
|
const optionOverrides = normalizeOptions(options);
|
|
755
753
|
const activeBash = new Map<string, BashSandboxState>();
|
|
756
754
|
const notified = new Set<string>();
|
|
757
|
-
const sessionAllowedReadPaths: string[] = [];
|
|
758
|
-
const sessionAllowedWritePaths: string[] = [];
|
|
759
|
-
const sessionAllowedDomains: string[] = [];
|
|
760
755
|
const callAllowances = new Set<string>();
|
|
761
756
|
let enabledNotified = false;
|
|
762
757
|
let sandboxDisabled = false;
|
|
763
758
|
let configuredShell: string | undefined;
|
|
764
759
|
let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
|
|
765
760
|
|
|
766
|
-
function getEffectiveAllowRead(config: SandboxConfig): string[] {
|
|
767
|
-
return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function getEffectiveAllowWrite(config: SandboxConfig): string[] {
|
|
771
|
-
return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
|
|
775
|
-
return [...config.network.allowedDomains, ...sessionAllowedDomains];
|
|
776
|
-
}
|
|
777
|
-
|
|
778
761
|
function allowanceKey(callID: string, kind: SandboxPermissionKind, resource: string): string {
|
|
779
762
|
return `${callID}:${kind}:${resource}`;
|
|
780
763
|
}
|
|
@@ -791,8 +774,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
791
774
|
return callAllowances.has(allowanceKey(callID, decision.kind, decision.resource));
|
|
792
775
|
}
|
|
793
776
|
|
|
794
|
-
function
|
|
795
|
-
if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
|
|
777
|
+
function reportBlocked(decision: SandboxPermissionDecision): never {
|
|
796
778
|
client.tui
|
|
797
779
|
?.showToast?.({
|
|
798
780
|
body: {
|
|
@@ -805,6 +787,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
805
787
|
throw errorWithConfigPaths(directory, decision.message);
|
|
806
788
|
}
|
|
807
789
|
|
|
790
|
+
function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
|
|
791
|
+
if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
|
|
792
|
+
reportBlocked(decision);
|
|
793
|
+
}
|
|
794
|
+
|
|
808
795
|
function pushCommandText(
|
|
809
796
|
input: { sessionID: string },
|
|
810
797
|
output: { parts: unknown[] },
|
|
@@ -822,11 +809,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
822
809
|
function sandboxSummary(config: SandboxConfig): string {
|
|
823
810
|
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
824
811
|
const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
|
|
825
|
-
const allowed =
|
|
812
|
+
const allowed = config.network.allowedDomains.join(', ') || '(none)';
|
|
826
813
|
const denied = config.network.deniedDomains.join(', ') || '(none)';
|
|
827
814
|
const denyRead = config.filesystem.denyRead.join(', ') || '(none)';
|
|
828
|
-
const allowRead =
|
|
829
|
-
const allowWrite =
|
|
815
|
+
const allowRead = config.filesystem.allowRead.join(', ') || '(none)';
|
|
816
|
+
const allowWrite = config.filesystem.allowWrite.join(', ') || '(none)';
|
|
830
817
|
const denyWrite = config.filesystem.denyWrite.join(', ') || '(none)';
|
|
831
818
|
|
|
832
819
|
return [
|
|
@@ -1050,7 +1037,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1050
1037
|
const callAllowedDomains: string[] = [];
|
|
1051
1038
|
const effectiveConfig = {
|
|
1052
1039
|
...config,
|
|
1053
|
-
network: { ...config.network
|
|
1040
|
+
network: { ...config.network },
|
|
1054
1041
|
};
|
|
1055
1042
|
|
|
1056
1043
|
if (!allowNetwork) {
|
|
@@ -1114,14 +1101,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1114
1101
|
if (!config) return;
|
|
1115
1102
|
|
|
1116
1103
|
const request = input as Record<string, unknown>;
|
|
1117
|
-
const permission =
|
|
1118
|
-
typeof request.type === 'string'
|
|
1119
|
-
? request.type
|
|
1120
|
-
: typeof request.permission === 'string'
|
|
1121
|
-
? request.permission
|
|
1122
|
-
: typeof request.action === 'string'
|
|
1123
|
-
? request.action
|
|
1124
|
-
: '';
|
|
1104
|
+
const permission = permissionType(request);
|
|
1125
1105
|
const metadata = isRecord(request.metadata) ? request.metadata : {};
|
|
1126
1106
|
const tool = isRecord(request.tool) ? request.tool : undefined;
|
|
1127
1107
|
const callID =
|
|
@@ -1130,17 +1110,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1130
1110
|
: typeof tool?.callID === 'string'
|
|
1131
1111
|
? tool.callID
|
|
1132
1112
|
: undefined;
|
|
1133
|
-
const patterns =
|
|
1134
|
-
? request.patterns.filter((item): item is string => typeof item === 'string')
|
|
1135
|
-
: typeof request.pattern === 'string'
|
|
1136
|
-
? [request.pattern]
|
|
1137
|
-
: Array.isArray(request.resources)
|
|
1138
|
-
? request.resources.filter((item): item is string => typeof item === 'string')
|
|
1139
|
-
: [];
|
|
1113
|
+
const patterns = permissionPatterns(request);
|
|
1140
1114
|
|
|
1141
1115
|
const decisions: SandboxPermissionDecision[] = [];
|
|
1142
|
-
const effectiveAllowRead =
|
|
1143
|
-
const effectiveAllowWrite =
|
|
1116
|
+
const effectiveAllowRead = config.filesystem.allowRead;
|
|
1117
|
+
const effectiveAllowWrite = config.filesystem.allowWrite;
|
|
1144
1118
|
|
|
1145
1119
|
if (permission === 'read') {
|
|
1146
1120
|
for (const pattern of patterns) {
|
|
@@ -1189,8 +1163,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1189
1163
|
const config = await activeConfig();
|
|
1190
1164
|
if (!config) return;
|
|
1191
1165
|
|
|
1192
|
-
const effectiveAllowRead =
|
|
1193
|
-
const effectiveAllowWrite =
|
|
1166
|
+
const effectiveAllowRead = config.filesystem.allowRead;
|
|
1167
|
+
const effectiveAllowWrite = config.filesystem.allowWrite;
|
|
1194
1168
|
|
|
1195
1169
|
if (input.tool === 'bash') {
|
|
1196
1170
|
await prepareBash(input.callID, output.args, config);
|
|
@@ -1373,23 +1347,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1373
1347
|
const config = await activeConfig();
|
|
1374
1348
|
if (!config) return;
|
|
1375
1349
|
|
|
1376
|
-
const effectiveAllowRead =
|
|
1377
|
-
const effectiveAllowWrite =
|
|
1350
|
+
const effectiveAllowRead = config.filesystem.allowRead;
|
|
1351
|
+
const effectiveAllowWrite = config.filesystem.allowWrite;
|
|
1378
1352
|
|
|
1379
1353
|
for (const path of extractCandidatePaths(shellCommand)) {
|
|
1380
1354
|
const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
|
|
1381
|
-
if (readDecision.status === 'deny')
|
|
1382
|
-
client.tui
|
|
1383
|
-
?.showToast?.({
|
|
1384
|
-
body: {
|
|
1385
|
-
title: 'Sandbox blocked',
|
|
1386
|
-
message: readDecision.message.slice(0, 120),
|
|
1387
|
-
variant: 'error',
|
|
1388
|
-
},
|
|
1389
|
-
})
|
|
1390
|
-
?.catch?.(() => undefined);
|
|
1391
|
-
throw errorWithConfigPaths(directory, readDecision.message);
|
|
1392
|
-
}
|
|
1355
|
+
if (readDecision.status === 'deny') reportBlocked(readDecision);
|
|
1393
1356
|
|
|
1394
1357
|
const writeDecision = evaluateWritePermission(
|
|
1395
1358
|
path,
|
|
@@ -1397,44 +1360,13 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1397
1360
|
directory,
|
|
1398
1361
|
effectiveAllowWrite,
|
|
1399
1362
|
);
|
|
1400
|
-
if (writeDecision.status === 'deny')
|
|
1401
|
-
client.tui
|
|
1402
|
-
?.showToast?.({
|
|
1403
|
-
body: {
|
|
1404
|
-
title: 'Sandbox blocked',
|
|
1405
|
-
message: writeDecision.message.slice(0, 120),
|
|
1406
|
-
variant: 'error',
|
|
1407
|
-
},
|
|
1408
|
-
})
|
|
1409
|
-
?.catch?.(() => undefined);
|
|
1410
|
-
throw errorWithConfigPaths(directory, writeDecision.message);
|
|
1411
|
-
}
|
|
1363
|
+
if (writeDecision.status === 'deny') reportBlocked(writeDecision);
|
|
1412
1364
|
}
|
|
1413
1365
|
|
|
1414
1366
|
if (!config.network.allowNetwork) {
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
};
|
|
1419
|
-
const blockedDomain = firstBlockedDomain(shellCommand, effectiveConfig);
|
|
1420
|
-
if (blockedDomain) {
|
|
1421
|
-
const reason =
|
|
1422
|
-
blockedDomain.reason === 'deniedDomains'
|
|
1423
|
-
? 'is blocked by network.deniedDomains'
|
|
1424
|
-
: 'is not in network.allowedDomains';
|
|
1425
|
-
client.tui
|
|
1426
|
-
?.showToast?.({
|
|
1427
|
-
body: {
|
|
1428
|
-
title: 'Sandbox blocked',
|
|
1429
|
-
message: `Network access denied for "${blockedDomain.domain}"`,
|
|
1430
|
-
variant: 'error',
|
|
1431
|
-
},
|
|
1432
|
-
})
|
|
1433
|
-
?.catch?.(() => undefined);
|
|
1434
|
-
throw errorWithConfigPaths(
|
|
1435
|
-
directory,
|
|
1436
|
-
`Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
|
|
1437
|
-
);
|
|
1367
|
+
for (const domain of extractDomainsFromCommand(shellCommand)) {
|
|
1368
|
+
const decision = evaluateDomainPermission(domain, config);
|
|
1369
|
+
if (decision.status !== 'allow') reportBlocked(decision);
|
|
1438
1370
|
}
|
|
1439
1371
|
}
|
|
1440
1372
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.17",
|
|
4
4
|
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ci:test": "npm test"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@landstrip/landstrip": "^0.15.
|
|
52
|
+
"@landstrip/landstrip": "^0.15.14"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@opencode-ai/plugin": "^1.17.7",
|
package/shared.ts
CHANGED
|
@@ -267,6 +267,28 @@ export function permissionPattern(permission: Record<string, unknown>): string |
|
|
|
267
267
|
return undefined;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Every string pattern a permission carries, in declaration order, read from
|
|
272
|
+
* `patterns`, `pattern`, or `resources`. The plural complement to
|
|
273
|
+
* {@link permissionPattern} for callers that must inspect all patterns.
|
|
274
|
+
*/
|
|
275
|
+
export function permissionPatterns(permission: Record<string, unknown>): string[] {
|
|
276
|
+
const patterns = permission.patterns;
|
|
277
|
+
if (Array.isArray(patterns))
|
|
278
|
+
return patterns.filter((item): item is string => typeof item === 'string');
|
|
279
|
+
|
|
280
|
+
const pattern = permission.pattern;
|
|
281
|
+
if (typeof pattern === 'string') return [pattern];
|
|
282
|
+
if (Array.isArray(pattern))
|
|
283
|
+
return pattern.filter((item): item is string => typeof item === 'string');
|
|
284
|
+
|
|
285
|
+
const resources = permission.resources;
|
|
286
|
+
if (Array.isArray(resources))
|
|
287
|
+
return resources.filter((item): item is string => typeof item === 'string');
|
|
288
|
+
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
|
|
270
292
|
export function permissionLabel(permission: Record<string, unknown>): string {
|
|
271
293
|
const type = permissionType(permission, 'permission');
|
|
272
294
|
const title = typeof permission.title === 'string' ? permission.title : type;
|