opencode-landstrip 0.16.15 → 0.16.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.
Files changed (2) hide show
  1. package/index.ts +107 -18
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -24,7 +24,6 @@ import {
24
24
  parseLandstripTraps,
25
25
  permissionPatterns,
26
26
  permissionType,
27
- readDiscoveryPort,
28
27
  sandboxSummary,
29
28
  sessionScopeFor,
30
29
  } from './shared.js';
@@ -46,6 +45,9 @@ interface BashSandboxState {
46
45
  policyDir: string;
47
46
  port: number | null;
48
47
  stop: (() => Promise<void>) | null;
48
+ trapServer: ReturnType<typeof createServer> | null;
49
+ trapServerPort: number | null;
50
+ trapLines: string[];
49
51
  }
50
52
 
51
53
  type SandboxPermissionKind = 'read' | 'write' | 'domain';
@@ -633,20 +635,83 @@ function shellArgs(shell: string, command: string): string[] {
633
635
  return [shell, '-lc', command];
634
636
  }
635
637
 
636
- // The query-response port is published by the TUI plugin and is Linux-only (the
637
- // socket protocol exists only in landstrip's seccomp broker).
638
- function socketQueryPort(baseDirectory: string): number | null {
639
- if (process.platform !== 'linux') return null;
640
- return readDiscoveryPort(baseDirectory);
641
- }
638
+ // Start a local TCP server that landstrip connects its trap fd to. Traps are
639
+ // handled in-process: query traps are answered immediately against the active
640
+ // config, and info traps are collected for post-execution error reporting.
641
+ function startTrapServer(
642
+ effectiveAllowRead: string[],
643
+ effectiveAllowWrite: string[],
644
+ denyRead: string[],
645
+ denyWrite: string[],
646
+ baseDirectory: string,
647
+ sessionAllowedReadPaths: Set<string>,
648
+ sessionAllowedWritePaths: Set<string>,
649
+ ): Promise<{ server: ReturnType<typeof createServer>; port: number; trapLines: string[] }> {
650
+ const trapLines: string[] = [];
651
+ const server = createServer((trapSocket) => {
652
+ let buffer = '';
653
+ trapSocket.on('data', (data: Buffer) => {
654
+ buffer += data.toString('utf8');
655
+ let nl = buffer.indexOf('\n');
656
+ while (nl !== -1) {
657
+ const line = buffer.slice(0, nl);
658
+ buffer = buffer.slice(nl + 1);
659
+ nl = buffer.indexOf('\n');
660
+ if (line.length === 0) continue;
661
+ let obj: Record<string, unknown> | null = null;
662
+ try {
663
+ const parsed: unknown = JSON.parse(line);
664
+ if (typeof parsed === 'object' && parsed !== null) {
665
+ obj = parsed as Record<string, unknown>;
666
+ }
667
+ } catch {
668
+ obj = null;
669
+ }
670
+ if (
671
+ obj &&
672
+ obj.state === 'query' &&
673
+ typeof obj.query_id === 'number' &&
674
+ (obj.operation === 'read' || obj.operation === 'write') &&
675
+ typeof obj.path === 'string'
676
+ ) {
677
+ const path = canonicalizePath(obj.path, baseDirectory);
678
+ const operation = obj.operation as 'read' | 'write';
679
+ // Per landstrip policy: a deny rule overrides allow only when more
680
+ // specific; a tie or more-specific allow carves the path back in.
681
+ const denyReadDepth = matchDepth(path, denyRead, baseDirectory);
682
+ const allowReadDepth = matchDepth(path, effectiveAllowRead, baseDirectory);
683
+ const denyWriteDepth = matchDepth(path, denyWrite, baseDirectory);
684
+ const allowWriteDepth = matchDepth(path, effectiveAllowWrite, baseDirectory);
685
+ const allowed =
686
+ operation === 'read'
687
+ ? !(denyReadDepth > allowReadDepth) && allowReadDepth >= 0
688
+ : !(denyWriteDepth > allowWriteDepth) && allowWriteDepth >= 0;
689
+ if (allowed) {
690
+ trapSocket.write(JSON.stringify({ query_id: obj.query_id, action: 'allow' }) + '\n');
691
+ } else {
692
+ // Auto-grant via session scope and allow so the command proceeds.
693
+ const scope = sessionScopeFor(path, baseDirectory);
694
+ if (operation === 'read') sessionAllowedReadPaths.add(scope);
695
+ else sessionAllowedWritePaths.add(scope);
696
+ trapSocket.write(JSON.stringify({ query_id: obj.query_id, action: 'allow' }) + '\n');
697
+ trapLines.push(line);
698
+ }
699
+ } else {
700
+ trapLines.push(line);
701
+ }
702
+ }
703
+ });
704
+ trapSocket.on('error', () => {});
705
+ });
642
706
 
643
- async function awaitQueryPort(baseDirectory: string): Promise<number | null> {
644
- for (let attempt = 0; attempt < 5; attempt++) {
645
- const port = socketQueryPort(baseDirectory);
646
- if (port !== null) return port;
647
- await new Promise((resolve) => setTimeout(resolve, 50));
648
- }
649
- return null;
707
+ return new Promise((resolve, reject) => {
708
+ server.on('error', reject);
709
+ server.listen(0, '127.0.0.1', () => {
710
+ server.removeListener('error', reject);
711
+ const address = server.address() as AddressInfo;
712
+ resolve({ server, port: address.port, trapLines });
713
+ });
714
+ });
650
715
  }
651
716
 
652
717
  function buildWrappedCommand(
@@ -1001,6 +1066,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1001
1066
 
1002
1067
  activeBash.delete(callID);
1003
1068
  if (state.stop) await state.stop().catch(() => undefined);
1069
+ if (state.trapServer) {
1070
+ await new Promise<void>((resolve) => {
1071
+ state.trapServer!.close(() => resolve());
1072
+ });
1073
+ }
1004
1074
  rmSync(state.policyDir, { recursive: true, force: true });
1005
1075
  }
1006
1076
 
@@ -1083,11 +1153,24 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1083
1153
  }
1084
1154
 
1085
1155
  const originalCommand = args.command as string;
1156
+
1157
+ // Start a local trap server so landstrip's query traps are answered
1158
+ // in-process instead of relying on the TUI plugin's discovery port.
1159
+ const trapServer = await startTrapServer(
1160
+ effectiveConfig.filesystem.allowRead,
1161
+ effectiveConfig.filesystem.allowWrite,
1162
+ effectiveConfig.filesystem.denyRead,
1163
+ effectiveConfig.filesystem.denyWrite,
1164
+ directory,
1165
+ sessionAllowedReadPaths,
1166
+ sessionAllowedWritePaths,
1167
+ );
1168
+
1086
1169
  const wrappedCommand = buildWrappedCommand(
1087
1170
  policy.path,
1088
1171
  configuredShell ?? process.env.SHELL ?? '/bin/sh',
1089
1172
  originalCommand,
1090
- await awaitQueryPort(directory),
1173
+ trapServer.port,
1091
1174
  );
1092
1175
 
1093
1176
  activeBash.set(callID, {
@@ -1096,6 +1179,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1096
1179
  policyDir: policy.dir,
1097
1180
  port: proxyPort,
1098
1181
  stop: proxy ? proxy.stop : null,
1182
+ trapServer: trapServer.server,
1183
+ trapServerPort: trapServer.port,
1184
+ trapLines: trapServer.trapLines,
1099
1185
  });
1100
1186
 
1101
1187
  args.command = wrappedCommand;
@@ -1239,9 +1325,12 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1239
1325
  }
1240
1326
 
1241
1327
  const outputText = output?.output ?? '';
1242
- // Query traps were already resolved interactively over the socket by the
1243
- // TUI plugin; only terminal (info) traps belong in the after-the-fact toast.
1244
- const errors = parseLandstripTraps(outputText).filter(
1328
+ // Query traps were already resolved in-process by the local trap server;
1329
+ // only terminal (info) traps and trap-server-collected lines belong in
1330
+ // the after-the-fact toast.
1331
+ const serverTrapOutput = state.trapLines.join('\n');
1332
+ const combinedOutput = serverTrapOutput ? outputText + '\n' + serverTrapOutput : outputText;
1333
+ const errors = parseLandstripTraps(combinedOutput).filter(
1245
1334
  (trap: LandstripTrap) => !(trap.kind === 'filesystem' && trap.state === 'query'),
1246
1335
  );
1247
1336
  if (errors.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.16.15",
3
+ "version": "0.16.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.16.11"
52
+ "@landstrip/landstrip": "^0.16.13"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@opencode-ai/plugin": "^1.17.7",