opencode-landstrip 0.15.1 → 0.15.3

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 (4) hide show
  1. package/index.ts +25 -16
  2. package/package.json +3 -3
  3. package/shared.ts +5 -3
  4. package/tui.ts +51 -45
package/index.ts CHANGED
@@ -55,7 +55,7 @@ interface SandboxPermissionDecision {
55
55
 
56
56
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
57
57
 
58
- const LANDSTRIP_VERSION = [0, 15, 8] as const;
58
+ const LANDSTRIP_VERSION = [0, 15, 9] as const;
59
59
  const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
60
60
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
61
61
 
@@ -102,11 +102,11 @@ function globToRegExp(globPattern: string): RegExp {
102
102
  let regex = '';
103
103
 
104
104
  for (let i = 0; i < globPattern.length; i++) {
105
- const char = globPattern[i];
105
+ const char = globPattern.charAt(i);
106
106
  if (char === '*') {
107
- if (globPattern[i + 1] === '*') {
107
+ if (globPattern.charAt(i + 1) === '*') {
108
108
  i++;
109
- if (globPattern[i + 1] === '/') {
109
+ if (globPattern.charAt(i + 1) === '/') {
110
110
  i++;
111
111
  regex += '(?:.*/)?';
112
112
  } else {
@@ -222,19 +222,19 @@ function extractBlockedPath(
222
222
  let match = output.match(
223
223
  /(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
224
224
  );
225
- if (match) return normalizeBlockedPath(match[1], baseDirectory);
225
+ if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
226
226
 
227
227
  // ls/cat/cp: cannot open/access/stat '/path': Permission denied
228
228
  match = output.match(
229
229
  /^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
230
230
  );
231
- if (match) return normalizeBlockedPath(match[1], baseDirectory);
231
+ if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
232
232
 
233
233
  // Generic: cmd: /absolute/path: Permission denied or Operation not permitted
234
234
  match = output.match(
235
235
  /^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
236
236
  );
237
- if (match) return normalizeBlockedPath(match[1], baseDirectory);
237
+ if (match?.[1]) return normalizeBlockedPath(match[1], baseDirectory);
238
238
 
239
239
  // Landstrip structured trap format carrying a denied path
240
240
  const landstripTraps = parseLandstripTraps(output);
@@ -390,8 +390,11 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
390
390
  if (!parsed) return false;
391
391
 
392
392
  for (let i = 0; i < minimum.length; i++) {
393
- if (parsed[i] > minimum[i]) return true;
394
- if (parsed[i] < minimum[i]) return false;
393
+ const parsedPart = parsed[i];
394
+ const minimumPart = minimum[i];
395
+ if (parsedPart === undefined || minimumPart === undefined) return false;
396
+ if (parsedPart > minimumPart) return true;
397
+ if (parsedPart < minimumPart) return false;
395
398
  }
396
399
 
397
400
  return true;
@@ -399,7 +402,7 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
399
402
 
400
403
  function splitHostPort(target: string, defaultPort: number): { host: string; port: number } | null {
401
404
  const bracketMatch = target.match(/^\[([^\]]+)\](?::(\d+))?$/);
402
- if (bracketMatch) {
405
+ if (bracketMatch?.[1]) {
403
406
  return {
404
407
  host: bracketMatch[1],
405
408
  port: bracketMatch[2] ? Number(bracketMatch[2]) : defaultPort,
@@ -497,7 +500,13 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
497
500
 
498
501
  async function handleHttp(client: Socket, headerText: string, rest: Buffer): Promise<void> {
499
502
  const lines = headerText.split(/\r?\n/);
500
- const [method, rawTarget, version] = lines[0].split(' ');
503
+ const requestLine = lines[0];
504
+ if (!requestLine) {
505
+ denyProxyRequest(client, '400 Bad Request');
506
+ return;
507
+ }
508
+
509
+ const [method, rawTarget, version] = requestLine.split(' ');
501
510
 
502
511
  if (!method || !rawTarget || !version) {
503
512
  denyProxyRequest(client, '400 Bad Request');
@@ -567,10 +576,10 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
567
576
  const header = buffered.subarray(0, headerEnd).toString('utf-8');
568
577
  const rest = buffered.subarray(headerEnd + 4);
569
578
  const firstLine = header.split(/\r?\n/, 1)[0];
570
- const [method, target] = firstLine.split(' ');
579
+ const [method, target] = (firstLine ?? '').split(' ');
571
580
 
572
581
  const task =
573
- method?.toUpperCase() === 'CONNECT'
582
+ method?.toUpperCase() === 'CONNECT' && target
574
583
  ? handleConnect(client, target, rest)
575
584
  : handleHttp(client, header, rest);
576
585
  task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
@@ -724,13 +733,13 @@ function extractPatchPaths(patchText: string): string[] {
724
733
 
725
734
  for (const line of patchText.split(/\r?\n/)) {
726
735
  const fileMatch = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/);
727
- if (fileMatch) {
736
+ if (fileMatch?.[1]) {
728
737
  paths.push(fileMatch[1].trim());
729
738
  continue;
730
739
  }
731
740
 
732
741
  const moveMatch = line.match(/^\*\*\* Move to: (.+)$/);
733
- if (moveMatch) paths.push(moveMatch[1].trim());
742
+ if (moveMatch?.[1]) paths.push(moveMatch[1].trim());
734
743
  }
735
744
 
736
745
  return paths;
@@ -1025,7 +1034,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1025
1034
 
1026
1035
  if (isGeneratedWrappedCommand(args.command as string)) {
1027
1036
  const policyMatch = (args.command as string).match(/\s'-p'\s+'([^']+)'/);
1028
- if (policyMatch && existsSync(policyMatch[1])) {
1037
+ if (policyMatch?.[1] && existsSync(policyMatch[1])) {
1029
1038
  if (typeof args.description === 'string')
1030
1039
  args.description = landstripDescription(args.description);
1031
1040
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -43,13 +43,13 @@
43
43
  "check": "tsc --noEmit",
44
44
  "test": "node --test test/*.test.mjs",
45
45
  "all": "npm run fmt && npm run lint && npm run check && npm test",
46
- "ci:fmt": "oxfmt --check index.ts tui.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
46
+ "ci:fmt": "oxfmt --check index.ts tui.ts shared.ts package.json tsconfig.json sandbox.json .oxfmtrc.json README.md test/*.test.mjs",
47
47
  "ci:lint": "npm run lint",
48
48
  "ci:check": "npm run check",
49
49
  "ci:test": "npm test"
50
50
  },
51
51
  "dependencies": {
52
- "@landstrip/landstrip": "^0.15.8"
52
+ "@landstrip/landstrip": "^0.15.9"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@opencode-ai/plugin": "^1.17.7",
package/shared.ts CHANGED
@@ -238,7 +238,7 @@ export function extractDomainsFromCommand(command: string): string[] {
238
238
  let match: RegExpExecArray | null;
239
239
 
240
240
  while ((match = urlRegex.exec(command)) !== null) {
241
- domains.add(match[1]);
241
+ if (match[1]) domains.add(match[1]);
242
242
  }
243
243
 
244
244
  return [...domains];
@@ -358,9 +358,11 @@ export function decodeLandstripTrap(value: unknown): LandstripTrap | null {
358
358
  case 'filesystem': {
359
359
  const { operation, path } = value;
360
360
  if (!isLandstripOperation(operation) || typeof path !== 'string') return null;
361
+ const trap: LandstripTrap = { kind: 'filesystem', operation, path, mechanism };
361
362
  const state = decodeTrapState(value.state);
362
- const queryId = typeof value.query_id === 'number' ? value.query_id : undefined;
363
- return { kind: 'filesystem', operation, path, mechanism, state, queryId };
363
+ if (state) trap.state = state;
364
+ if (typeof value.query_id === 'number') trap.queryId = value.query_id;
365
+ return trap;
364
366
  }
365
367
  case 'network': {
366
368
  const { operation, target } = value;
package/tui.ts CHANGED
@@ -35,16 +35,17 @@ interface PendingPermission {
35
35
 
36
36
  type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
37
37
 
38
- type WriteChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
38
+ type QueryChoice = 'once' | 'session' | 'project' | 'global' | 'deny';
39
39
 
40
- // A landstrip write query held pending over the fd-3 socket. It shares the
41
- // dialog stack with permission prompts so the two never overlap, hence the
42
- // common `id`/`kind` shape.
43
- interface WriteQueryEntry {
44
- kind: 'write-query';
40
+ // A landstrip filesystem query (read or write) held pending over the fd-3
41
+ // socket. It shares the dialog stack with permission prompts so the two never
42
+ // overlap, hence the common `id`/`kind` shape.
43
+ interface FsQueryEntry {
44
+ kind: 'fs-query';
45
45
  id: string;
46
46
  socket: NetSocket;
47
47
  queryId: number;
48
+ operation: 'read' | 'write';
48
49
  path: string;
49
50
  }
50
51
 
@@ -54,7 +55,7 @@ interface PermissionEntry {
54
55
  permission: PendingPermission;
55
56
  }
56
57
 
57
- type QueueEntry = PermissionEntry | WriteQueryEntry;
58
+ type QueueEntry = PermissionEntry | FsQueryEntry;
58
59
 
59
60
  function list(values: string[]): string {
60
61
  return values.join(', ') || '(none)';
@@ -120,10 +121,11 @@ const tui: TuiPlugin = async (api, options, meta) => {
120
121
  // server regenerates the policy from on-disk config each run — so it affects
121
122
  // only live socket decisions, not the static policy.
122
123
  const sessionAllowedWritePaths = new Set<string>();
124
+ const sessionAllowedReadPaths = new Set<string>();
123
125
 
124
- // Write queries still awaiting a response, so cleanup can release held
126
+ // Filesystem queries still awaiting a response, so cleanup can release held
125
127
  // syscalls instead of letting the child hang.
126
- const liveQueries = new Set<WriteQueryEntry>();
128
+ const liveQueries = new Set<FsQueryEntry>();
127
129
 
128
130
  function pump(): void {
129
131
  if (activeId !== undefined) return;
@@ -131,7 +133,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
131
133
  while (next && resolved.has(next.id)) next = queue.shift();
132
134
  if (!next) return;
133
135
  if (next.kind === 'permission') showPermission(next.permission);
134
- else showWriteQuery(next);
136
+ else showFsQuery(next);
135
137
  }
136
138
 
137
139
  function enqueueEntry(entry: QueueEntry): void {
@@ -267,49 +269,56 @@ const tui: TuiPlugin = async (api, options, meta) => {
267
269
  );
268
270
  }
269
271
 
270
- function respondWriteQuery(socket: NetSocket, queryId: number, action: 'allow' | 'deny'): void {
272
+ function respondFsQuery(socket: NetSocket, queryId: number, action: 'allow' | 'deny'): void {
271
273
  if (!socket.destroyed) socket.write(JSON.stringify({ query_id: queryId, action }) + '\n');
272
274
  }
273
275
 
274
- function resolveWriteQuery(entry: WriteQueryEntry, choice: WriteChoice): void {
276
+ function resolveFsQuery(entry: FsQueryEntry, choice: QueryChoice): void {
275
277
  if (resolved.has(entry.id)) return;
276
278
  const action = choice === 'deny' ? 'deny' : 'allow';
279
+ const verb = entry.operation === 'read' ? 'Read' : 'Write';
277
280
 
278
281
  try {
279
282
  if (action === 'allow') {
280
283
  if (choice === 'session') {
281
- sessionAllowedWritePaths.add(entry.path);
284
+ const sessionPaths =
285
+ entry.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
286
+ sessionPaths.add(entry.path);
282
287
  } else if (choice === 'project' || choice === 'global') {
283
288
  const directory = api.state.path.directory || process.cwd();
284
289
  const { globalPath, projectPath } = getConfigPaths(directory);
285
290
  const update = updateForPermission({
286
- permission: 'write',
291
+ permission: entry.operation,
287
292
  metadata: { filepath: entry.path },
288
293
  });
289
294
  if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
290
295
  }
291
296
  }
292
297
 
293
- respondWriteQuery(entry.socket, entry.queryId, action);
298
+ respondFsQuery(entry.socket, entry.queryId, action);
294
299
  api.ui.toast({
295
300
  title: 'Sandbox',
296
- message: action === 'deny' ? `Write denied: ${entry.path}` : `Write allowed (${choice})`,
301
+ message:
302
+ action === 'deny' ? `${verb} denied: ${entry.path}` : `${verb} allowed (${choice})`,
297
303
  variant: action === 'deny' ? 'warning' : 'success',
298
304
  });
299
305
  } catch {
300
306
  // Persisting failed — still release the held syscall by denying it.
301
- respondWriteQuery(entry.socket, entry.queryId, 'deny');
307
+ respondFsQuery(entry.socket, entry.queryId, 'deny');
302
308
  } finally {
303
309
  liveQueries.delete(entry);
304
310
  finishActive(entry.id);
305
311
  }
306
312
  }
307
313
 
308
- function showWriteQuery(entry: WriteQueryEntry): void {
314
+ function showFsQuery(entry: FsQueryEntry): void {
309
315
  activeId = entry.id;
316
+ const verb = entry.operation === 'read' ? 'Read' : 'Write';
317
+ const noun = entry.operation;
318
+ const listName = entry.operation === 'read' ? 'allowRead' : 'allowWrite';
310
319
 
311
320
  void api.attention.notify({
312
- title: 'Sandbox write blocked',
321
+ title: `Sandbox ${noun} blocked`,
313
322
  message: entry.path,
314
323
  sound: { name: 'permission' },
315
324
  notification: true,
@@ -322,48 +331,48 @@ const tui: TuiPlugin = async (api, options, meta) => {
322
331
 
323
332
  api.ui.dialog.replace(
324
333
  () =>
325
- api.ui.DialogSelect<WriteChoice>({
326
- title: 'Sandbox Write Blocked',
327
- placeholder: `Write blocked: ${entry.path}`,
334
+ api.ui.DialogSelect<QueryChoice>({
335
+ title: `Sandbox ${verb} Blocked`,
336
+ placeholder: `${verb} blocked: ${entry.path}`,
328
337
  options: [
329
338
  {
330
339
  title: 'Allow once',
331
340
  value: 'once',
332
- category: 'This write',
333
- description: 'Permit this write and continue',
341
+ category: `This ${noun}`,
342
+ description: `Permit this ${noun} and continue`,
334
343
  },
335
344
  {
336
345
  title: 'Allow for session',
337
346
  value: 'session',
338
- category: 'This write',
339
- description: 'Permit writes to this path for the rest of this session',
347
+ category: `This ${noun}`,
348
+ description: `Permit ${noun}s to this path for the rest of this session`,
340
349
  },
341
350
  {
342
351
  title: 'Allow for project',
343
352
  value: 'project',
344
353
  category: 'Persist to sandbox.json',
345
- description: 'Add to .opencode/sandbox.json allowWrite and permit',
354
+ description: `Add to .opencode/sandbox.json ${listName} and permit`,
346
355
  },
347
356
  {
348
357
  title: 'Allow globally',
349
358
  value: 'global',
350
359
  category: 'Persist to sandbox.json',
351
- description: 'Add to ~/.config/opencode/sandbox.json allowWrite and permit',
360
+ description: `Add to ~/.config/opencode/sandbox.json ${listName} and permit`,
352
361
  },
353
362
  {
354
363
  title: 'Deny',
355
364
  value: 'deny',
356
365
  category: 'Deny',
357
- description: 'Block this write',
366
+ description: `Block this ${noun}`,
358
367
  },
359
368
  ],
360
369
  onSelect: (option) => {
361
370
  selectionMade = true;
362
- resolveWriteQuery(entry, option.value);
371
+ resolveFsQuery(entry, option.value);
363
372
  },
364
373
  }),
365
374
  () => {
366
- if (!selectionMade) resolveWriteQuery(entry, 'deny');
375
+ if (!selectionMade) resolveFsQuery(entry, 'deny');
367
376
  },
368
377
  );
369
378
  }
@@ -408,26 +417,23 @@ const tui: TuiPlugin = async (api, options, meta) => {
408
417
  buffer = buffer.slice(newline + 1);
409
418
 
410
419
  for (const trap of parseLandstripTraps(line)) {
411
- if (
412
- trap.kind !== 'filesystem' ||
413
- trap.state !== 'query' ||
414
- trap.operation !== 'write'
415
- ) {
416
- continue;
417
- }
420
+ if (trap.kind !== 'filesystem' || trap.state !== 'query') continue;
418
421
  if (typeof trap.queryId !== 'number' || seen.has(trap.queryId)) continue;
419
422
  seen.add(trap.queryId);
420
423
 
421
- if (sessionAllowedWritePaths.has(trap.path)) {
422
- respondWriteQuery(socket, trap.queryId, 'allow');
424
+ const sessionPaths =
425
+ trap.operation === 'read' ? sessionAllowedReadPaths : sessionAllowedWritePaths;
426
+ if (sessionPaths.has(trap.path)) {
427
+ respondFsQuery(socket, trap.queryId, 'allow');
423
428
  continue;
424
429
  }
425
430
 
426
- const entry: WriteQueryEntry = {
427
- kind: 'write-query',
428
- id: `landstrip-write:${socketId}:${trap.queryId}`,
431
+ const entry: FsQueryEntry = {
432
+ kind: 'fs-query',
433
+ id: `landstrip-${trap.operation}:${socketId}:${trap.queryId}`,
429
434
  socket,
430
435
  queryId: trap.queryId,
436
+ operation: trap.operation,
431
437
  path: trap.path,
432
438
  };
433
439
  liveQueries.add(entry);
@@ -572,10 +578,10 @@ const tui: TuiPlugin = async (api, options, meta) => {
572
578
  unsubscribeAsked();
573
579
  unsubscribeReplied();
574
580
 
575
- // Deny any still-held writes so the sandboxed children don't hang, then
581
+ // Deny any still-held queries so the sandboxed children don't hang, then
576
582
  // tear down the socket server and drop the discovery file.
577
583
  for (const entry of liveQueries) {
578
- respondWriteQuery(entry.socket, entry.queryId, 'deny');
584
+ respondFsQuery(entry.socket, entry.queryId, 'deny');
579
585
  liveQueries.delete(entry);
580
586
  }
581
587
  for (const socket of sockets) socket.destroy();