opencode-landstrip 0.3.11 → 0.3.12

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 (5) hide show
  1. package/README.md +19 -7
  2. package/index.ts +202 -55
  3. package/package.json +5 -5
  4. package/sandbox.json +7 -47
  5. package/tui.ts +265 -43
package/README.md CHANGED
@@ -20,7 +20,7 @@ manually:
20
20
  ```json
21
21
  {
22
22
  "$schema": "https://opencode.ai/config.json",
23
- "plugin": ["opencode-landstrip"]
23
+ "plugin": ["opencode-landstrip/tui"]
24
24
  }
25
25
  ```
26
26
 
@@ -34,7 +34,8 @@ On unsupported platforms the plugin loads but leaves sandboxing disabled.
34
34
  ## Configure
35
35
 
36
36
  Create `.opencode/sandbox.json` in a project or
37
- `~/.config/opencode/sandbox.json` globally. Project config takes precedence.
37
+ `~/.config/opencode/sandbox.json` globally. Project config takes precedence and
38
+ array fields are merged with global/default values.
38
39
 
39
40
  See [`sandbox.json`](./sandbox.json) for a starter config.
40
41
 
@@ -42,14 +43,22 @@ See [`sandbox.json`](./sandbox.json) for a starter config.
42
43
 
43
44
  The plugin wraps opencode's AI `bash` tool in `landstrip`, routes proxy-aware
44
45
  network traffic through an allowlist proxy, and blocks read/write tool access
45
- outside configured filesystem allowlists.
46
+ outside configured filesystem allowlists. The default policy is strict: network
47
+ access is off unless domains are allowed, reads are limited to the project,
48
+ `~/.gitconfig`, and `/dev/null`, and writes are limited to the project and
49
+ `/dev/null`.
46
50
 
47
51
  Run `/sandbox` in the TUI to inspect the active sandbox configuration.
48
52
 
49
- opencode's current server plugin API does not expose Pi-style custom permission
50
- dialogs or a way to rewrite manually typed shell-mode commands. The `/sandbox`
51
- status view is provided by the TUI plugin entrypoint. Blocked access fails with
52
- an error that points at the sandbox config files.
53
+ When OpenCode asks for a sandboxed permission, the TUI plugin adds choices to
54
+ allow once, allow for the session, persist for the project, persist globally, or
55
+ reject. Project approvals are written to `.opencode/sandbox.json`; global
56
+ approvals are written to `~/.config/opencode/sandbox.json`.
57
+
58
+ OpenCode's current plugin API allows wrapping AI `bash` tool calls, but does not
59
+ allow a plugin to replace manually typed shell-mode commands with a landstrip
60
+ wrapper. Those commands can still receive the proxy environment from OpenCode,
61
+ but they are not process-sandboxed by this plugin.
53
62
 
54
63
  ## Disable
55
64
 
@@ -66,3 +75,6 @@ Set `enabled` to `false` in `sandbox.json`, or pass plugin options:
66
75
 
67
76
  `opencode-landstrip` is licensed under `MIT`. See [LICENSE](LICENSE) for more
68
77
  information.
78
+
79
+ The bundled `@jarkkojs/landstrip` package is licensed separately as
80
+ `Apache-2.0 AND LGPL-2.1-or-later`.
package/index.ts CHANGED
@@ -53,11 +53,12 @@ interface LandstripPolicy {
53
53
  }
54
54
 
55
55
  interface LandstripErrorResponse {
56
- reason: 'Other' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
56
+ reason: 'Other' | 'AccessDenied' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
57
57
  file?: string;
58
+ operation?: 'read' | 'write';
58
59
  program?: string;
59
60
  type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
60
- source: string;
61
+ source?: string;
61
62
  }
62
63
 
63
64
  interface SandboxConfigOverrides {
@@ -85,8 +86,48 @@ interface SandboxPermissionDecision {
85
86
 
86
87
  type ToastVariant = 'info' | 'success' | 'warning' | 'error';
87
88
 
88
- const LANDSTRIP_VERSION = [0, 11, 0] as const;
89
+ const LANDSTRIP_VERSION = [0, 11, 9] as const;
90
+ const REQUIRED_LANDSTRIP_VERSION = LANDSTRIP_VERSION.join('.');
91
+ const LANDSTRIP_ERROR_REASONS = new Set<LandstripErrorResponse['reason']>([
92
+ 'Other',
93
+ 'AccessDenied',
94
+ 'LaunchFailed',
95
+ 'SetupFailed',
96
+ 'Usage',
97
+ ]);
98
+ const LANDSTRIP_OPERATIONS = new Set<NonNullable<LandstripErrorResponse['operation']>>([
99
+ 'read',
100
+ 'write',
101
+ ]);
102
+ const LANDSTRIP_ERROR_TYPES = new Set<NonNullable<LandstripErrorResponse['type']>>([
103
+ 'filesystem',
104
+ 'network',
105
+ 'platform',
106
+ 'launch',
107
+ 'encoding',
108
+ ]);
89
109
  const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
110
+ const LANDSTRIP_PACKAGE_NAMES = new Set([
111
+ '@jarkkojs/landstrip',
112
+ '@jarkkojs/landstrip-darwin-arm64',
113
+ '@jarkkojs/landstrip-darwin-x64',
114
+ '@jarkkojs/landstrip-linux-x64',
115
+ '@jarkkojs/landstrip-win32-x64',
116
+ ]);
117
+
118
+ function isLandstripErrorReason(value: string): value is LandstripErrorResponse['reason'] {
119
+ return LANDSTRIP_ERROR_REASONS.has(value as LandstripErrorResponse['reason']);
120
+ }
121
+
122
+ function isLandstripOperation(
123
+ value: string,
124
+ ): value is NonNullable<LandstripErrorResponse['operation']> {
125
+ return LANDSTRIP_OPERATIONS.has(value as NonNullable<LandstripErrorResponse['operation']>);
126
+ }
127
+
128
+ function isLandstripErrorType(value: string): value is NonNullable<LandstripErrorResponse['type']> {
129
+ return LANDSTRIP_ERROR_TYPES.has(value as NonNullable<LandstripErrorResponse['type']>);
130
+ }
90
131
 
91
132
  const DEFAULT_CONFIG: SandboxConfig = {
92
133
  enabled: true,
@@ -95,36 +136,14 @@ const DEFAULT_CONFIG: SandboxConfig = {
95
136
  allowLocalBinding: false,
96
137
  allowAllUnixSockets: false,
97
138
  allowUnixSockets: [],
98
- allowedDomains: [
99
- 'npmjs.org',
100
- '*.npmjs.org',
101
- 'registry.npmjs.org',
102
- 'registry.yarnpkg.com',
103
- 'pypi.org',
104
- '*.pypi.org',
105
- 'github.com',
106
- '*.github.com',
107
- 'api.github.com',
108
- 'raw.githubusercontent.com',
109
- 'crates.io',
110
- '*.crates.io',
111
- 'static.crates.io',
112
- ],
139
+ allowedDomains: [],
113
140
  deniedDomains: [],
114
141
  },
115
142
  filesystem: {
116
143
  denyRead: ['/Users', '/home'],
117
- allowRead: [
118
- '.',
119
- '/dev/null',
120
- '~/.config/opencode',
121
- '~/.config/git',
122
- '~/.gitconfig',
123
- '~/.local',
124
- '~/.cargo',
125
- ],
126
- allowWrite: ['.', '/tmp', '/dev/null'],
127
- denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
144
+ allowRead: ['.', '~/.gitconfig', '/dev/null'],
145
+ allowWrite: ['.', '/dev/null'],
146
+ denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
128
147
  },
129
148
  };
130
149
 
@@ -198,16 +217,30 @@ function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOver
198
217
  return normalizeConfig(isRecord(options.config) ? options.config : options);
199
218
  }
200
219
 
220
+ function mergeArray(base: string[], override?: string[]): string[] {
221
+ if (!override) return base;
222
+ return [...new Set([...base, ...override])];
223
+ }
224
+
201
225
  function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
226
+ const network = overrides.network;
227
+ const filesystem = overrides.filesystem;
228
+
202
229
  return {
203
230
  enabled: overrides.enabled ?? base.enabled,
204
231
  network: {
205
- ...base.network,
206
- ...overrides.network,
232
+ allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
233
+ allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
234
+ allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
235
+ allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
236
+ allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
237
+ deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
207
238
  },
208
239
  filesystem: {
209
- ...base.filesystem,
210
- ...overrides.filesystem,
240
+ denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
241
+ allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
242
+ allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
243
+ denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
211
244
  },
212
245
  };
213
246
  }
@@ -248,6 +281,33 @@ function configuredShellPath(config: unknown): string | undefined {
248
281
  return typeof config.shell === 'string' ? config.shell : undefined;
249
282
  }
250
283
 
284
+ function landstripBinaryPath(): string {
285
+ const filePath = realpathSync.native(binaryPath());
286
+ let probe = dirname(filePath);
287
+
288
+ while (true) {
289
+ const manifestPath = join(probe, 'package.json');
290
+ if (existsSync(manifestPath)) {
291
+ try {
292
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
293
+ if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
294
+ return filePath;
295
+ }
296
+ } catch {
297
+ // malformed package.json — continue walking to parent
298
+ }
299
+ }
300
+
301
+ const parent = dirname(probe);
302
+ if (parent === probe) break;
303
+ probe = parent;
304
+ }
305
+
306
+ throw new Error(
307
+ `Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
308
+ );
309
+ }
310
+
251
311
  function canonicalizePath(filePath: string, baseDirectory: string): string {
252
312
  const abs = expandPath(filePath, baseDirectory);
253
313
 
@@ -527,7 +587,7 @@ function evaluateDomainPermission(
527
587
  }
528
588
 
529
589
  function landstripVersion(): string | null {
530
- const result = spawnSync(binaryPath(), ['--version'], { encoding: 'utf-8' });
590
+ const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
531
591
  if (result.status !== 0) return null;
532
592
  return result.stdout.trim();
533
593
  }
@@ -564,25 +624,19 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
564
624
  if (key.length > 0 && value.length > 0) fields[key] = value;
565
625
  }
566
626
 
567
- if (
568
- fields.reason &&
569
- ['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
570
- fields.source
571
- ) {
627
+ if (fields.reason && isLandstripErrorReason(fields.reason)) {
572
628
  const error: LandstripErrorResponse = {
573
- reason: fields.reason as LandstripErrorResponse['reason'],
574
- source: fields.source,
629
+ reason: fields.reason,
575
630
  };
576
631
 
577
632
  if (fields.file) error.file = fields.file;
633
+ if (fields.operation && isLandstripOperation(fields.operation)) {
634
+ error.operation = fields.operation;
635
+ }
578
636
  if (fields.program) error.program = fields.program;
637
+ if (fields.source) error.source = fields.source;
579
638
 
580
- if (
581
- fields.type &&
582
- ['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
583
- ) {
584
- error.type = fields.type as LandstripErrorResponse['type'];
585
- }
639
+ if (fields.type && isLandstripErrorType(fields.type)) error.type = fields.type;
586
640
 
587
641
  errors.push(error);
588
642
  }
@@ -599,13 +653,16 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
599
653
  if (err.file) {
600
654
  parts.push(` (${err.file})`);
601
655
  }
656
+ if (err.operation) {
657
+ parts.push(` ${err.operation}`);
658
+ }
602
659
  if (err.program) {
603
660
  parts.push(` ${err.program}`);
604
661
  }
605
662
  if (err.type) {
606
663
  parts.push(`:${err.type}`);
607
664
  }
608
- parts.push(`: ${err.source}`);
665
+ if (err.source) parts.push(`: ${err.source}`);
609
666
 
610
667
  return parts.join('');
611
668
  })
@@ -838,12 +895,12 @@ function shellArgs(shell: string, command: string): string[] {
838
895
  function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
839
896
  const args = ['-p', policyPath, ...shellArgs(shell, command)];
840
897
 
841
- return [binaryPath(), ...args].map(shellQuote).join(' ');
898
+ return [landstripBinaryPath(), ...args].map(shellQuote).join(' ');
842
899
  }
843
900
 
844
901
  function isGeneratedWrappedCommand(command: string): boolean {
845
902
  return (
846
- command.startsWith(`${shellQuote(binaryPath())} `) &&
903
+ command.startsWith(`${shellQuote(landstripBinaryPath())} `) &&
847
904
  command.includes(` ${shellQuote('-p')} `) &&
848
905
  command.includes('opencode-landstrip-')
849
906
  );
@@ -964,6 +1021,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
964
1021
 
965
1022
  function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
966
1023
  if (decision.status === 'allow' || hasCallAllowance(callID, decision)) return;
1024
+ client.tui
1025
+ ?.showToast?.({
1026
+ body: {
1027
+ title: 'Sandbox blocked',
1028
+ message: decision.message.slice(0, 120),
1029
+ variant: 'error',
1030
+ },
1031
+ })
1032
+ ?.catch?.(() => undefined);
967
1033
  throw errorWithConfigPaths(directory, decision.message);
968
1034
  }
969
1035
 
@@ -995,7 +1061,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
995
1061
  '# Sandbox Configuration',
996
1062
  '',
997
1063
  `Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
998
- `landstrip: ${binaryPath()}`,
1064
+ `landstrip package binary: ${landstripBinaryPath()}`,
999
1065
  '',
1000
1066
  'Config files:',
1001
1067
  `- project: ${projectPath}`,
@@ -1082,7 +1148,17 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1082
1148
  return landstripCheck;
1083
1149
  }
1084
1150
 
1085
- const version = landstripVersion();
1151
+ let version: string | null;
1152
+ try {
1153
+ version = landstripVersion();
1154
+ } catch (error) {
1155
+ landstripCheck = {
1156
+ ok: false,
1157
+ reason: error instanceof Error ? error.message : String(error),
1158
+ };
1159
+ return landstripCheck;
1160
+ }
1161
+
1086
1162
  if (!version) {
1087
1163
  landstripCheck = {
1088
1164
  ok: false,
@@ -1094,7 +1170,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1094
1170
  if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
1095
1171
  landstripCheck = {
1096
1172
  ok: false,
1097
- reason: `landstrip 0.11.0 or newer is required; found: ${version}`,
1173
+ reason: `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
1098
1174
  };
1099
1175
  return landstripCheck;
1100
1176
  }
@@ -1107,7 +1183,14 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1107
1183
  if (sandboxDisabled) return null;
1108
1184
 
1109
1185
  const config = loadConfig(directory, optionOverrides);
1110
- if (!config.enabled) return null;
1186
+ if (!config.enabled) {
1187
+ await notifyOnce(
1188
+ `not-configured:${directory}`,
1189
+ 'Sandbox is not configured — no sandbox.json5 found',
1190
+ 'info',
1191
+ );
1192
+ return null;
1193
+ }
1111
1194
 
1112
1195
  const check = checkLandstrip();
1113
1196
  if (!check?.ok) {
@@ -1435,6 +1518,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1435
1518
  if (input.command.trim() === '/sandbox') {
1436
1519
  const config = loadConfig(directory, optionOverrides);
1437
1520
  pushCommandText(input, output, sandboxSummary(config));
1521
+ await client.tui
1522
+ ?.showToast?.({
1523
+ body: { title: 'Sandbox', message: `Config loaded for ${directory}`, variant: 'info' },
1524
+ })
1525
+ ?.catch?.(() => undefined);
1438
1526
  return;
1439
1527
  }
1440
1528
 
@@ -1453,6 +1541,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1453
1541
  output,
1454
1542
  'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
1455
1543
  );
1544
+ await client.tui
1545
+ ?.showToast?.({
1546
+ body: {
1547
+ title: 'Sandbox',
1548
+ message: 'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
1549
+ variant: 'warning',
1550
+ },
1551
+ })
1552
+ ?.catch?.(() => undefined);
1456
1553
  return;
1457
1554
  }
1458
1555
 
@@ -1466,7 +1563,30 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1466
1563
  return;
1467
1564
  }
1468
1565
  sandboxDisabled = false;
1469
- pushCommandText(input, output, 'Sandbox re-enabled.');
1566
+ const config = await activeConfig();
1567
+ if (!config) {
1568
+ pushCommandText(
1569
+ input,
1570
+ output,
1571
+ 'Sandbox re-enabled but no sandbox.json5 found — no rules active.\nCreate sandbox.json5 to enforce sandboxing.',
1572
+ );
1573
+ await client.tui
1574
+ ?.showToast?.({
1575
+ body: {
1576
+ title: 'Sandbox',
1577
+ message: 'Sandbox re-enabled but no sandbox.json5 found — no rules active.',
1578
+ variant: 'warning',
1579
+ },
1580
+ })
1581
+ ?.catch?.(() => undefined);
1582
+ } else {
1583
+ pushCommandText(input, output, 'Sandbox re-enabled.');
1584
+ await client.tui
1585
+ ?.showToast?.({
1586
+ body: { title: 'Sandbox', message: 'Sandbox re-enabled.', variant: 'success' },
1587
+ })
1588
+ ?.catch?.(() => undefined);
1589
+ }
1470
1590
  return;
1471
1591
  }
1472
1592
 
@@ -1482,6 +1602,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1482
1602
  for (const path of extractCandidatePaths(shellCommand)) {
1483
1603
  const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
1484
1604
  if (readDecision.status === 'deny') {
1605
+ client.tui
1606
+ ?.showToast?.({
1607
+ body: {
1608
+ title: 'Sandbox blocked',
1609
+ message: readDecision.message.slice(0, 120),
1610
+ variant: 'error',
1611
+ },
1612
+ })
1613
+ ?.catch?.(() => undefined);
1485
1614
  throw errorWithConfigPaths(directory, readDecision.message);
1486
1615
  }
1487
1616
 
@@ -1492,6 +1621,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1492
1621
  effectiveAllowWrite,
1493
1622
  );
1494
1623
  if (writeDecision.status === 'deny') {
1624
+ client.tui
1625
+ ?.showToast?.({
1626
+ body: {
1627
+ title: 'Sandbox blocked',
1628
+ message: writeDecision.message.slice(0, 120),
1629
+ variant: 'error',
1630
+ },
1631
+ })
1632
+ ?.catch?.(() => undefined);
1495
1633
  throw errorWithConfigPaths(directory, writeDecision.message);
1496
1634
  }
1497
1635
  }
@@ -1507,6 +1645,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
1507
1645
  blockedDomain.reason === 'deniedDomains'
1508
1646
  ? 'is blocked by network.deniedDomains'
1509
1647
  : 'is not in network.allowedDomains';
1648
+ client.tui
1649
+ ?.showToast?.({
1650
+ body: {
1651
+ title: 'Sandbox blocked',
1652
+ message: `Network access denied for "${blockedDomain.domain}"`,
1653
+ variant: 'error',
1654
+ },
1655
+ })
1656
+ ?.catch?.(() => undefined);
1510
1657
  throw errorWithConfigPaths(
1511
1658
  directory,
1512
1659
  `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-landstrip",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Landlock-based sandboxing for opencode with landstrip",
5
5
  "keywords": [
6
6
  "landlock",
@@ -48,10 +48,10 @@
48
48
  "ci:test": "npm test"
49
49
  },
50
50
  "dependencies": {
51
- "@jarkkojs/landstrip": "^0.11.0"
51
+ "@jarkkojs/landstrip": "^0.11.11"
52
52
  },
53
53
  "devDependencies": {
54
- "@opencode-ai/plugin": "^1.17.3",
54
+ "@opencode-ai/plugin": "^1.17.6",
55
55
  "@opentui/core": ">=0.3.4",
56
56
  "@opentui/keymap": ">=0.3.4",
57
57
  "@opentui/solid": ">=0.3.4",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^5.8.2"
62
62
  },
63
63
  "peerDependencies": {
64
- "@opencode-ai/plugin": "^1.17.3"
64
+ "@opencode-ai/plugin": "^1.17.6"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@opencode-ai/plugin": {
@@ -69,6 +69,6 @@
69
69
  }
70
70
  },
71
71
  "engines": {
72
- "opencode": ">=1.17.3"
72
+ "opencode": ">=1.17.6"
73
73
  }
74
74
  }
package/sandbox.json CHANGED
@@ -1,57 +1,17 @@
1
1
  {
2
2
  "enabled": true,
3
3
  "network": {
4
- "allowLocalBinding": true,
4
+ "allowNetwork": false,
5
+ "allowLocalBinding": false,
5
6
  "allowAllUnixSockets": false,
6
7
  "allowUnixSockets": [],
7
- "allowedDomains": [
8
- "github.com",
9
- "*.github.com",
10
- "api.github.com",
11
- "raw.githubusercontent.com",
12
- "objects.githubusercontent.com",
13
- "codeload.github.com",
14
- "registry.npmjs.org",
15
- "npmjs.org",
16
- "*.npmjs.org",
17
- "nodejs.org",
18
- "*.nodejs.org",
19
- "crates.io",
20
- "*.crates.io",
21
- "static.crates.io"
22
- ],
8
+ "allowedDomains": [],
23
9
  "deniedDomains": []
24
10
  },
25
11
  "filesystem": {
26
- "denyRead": ["/home"],
27
- "allowRead": [
28
- ".",
29
- "/tmp",
30
- "/var/tmp",
31
- "/dev/null",
32
- "~/.config/opencode",
33
- "~/.config/git",
34
- "~/.gitconfig",
35
- "~/.local",
36
- "~/.cargo",
37
- "~/.rustup",
38
- "~/.npm",
39
- "~/.cache",
40
- "~/.bun",
41
- "~/.node-gyp"
42
- ],
43
- "allowWrite": [
44
- ".",
45
- "/tmp",
46
- "/var/tmp",
47
- "/dev/null",
48
- "~/.cargo",
49
- "~/.rustup",
50
- "~/.npm",
51
- "~/.cache",
52
- "~/.bun",
53
- "~/.node-gyp"
54
- ],
55
- "denyWrite": [".env", ".env.*", "*.pem", "*.key"]
12
+ "denyRead": ["/Users", "/home"],
13
+ "allowRead": [".", "~/.gitconfig", "/dev/null"],
14
+ "allowWrite": [".", "/dev/null"],
15
+ "denyWrite": ["**/.env", "**/.env.*", "**/*.pem", "**/*.key"]
56
16
  }
57
17
  }
package/tui.ts CHANGED
@@ -5,9 +5,9 @@ import type { TuiPlugin } from '@opencode-ai/plugin/tui';
5
5
 
6
6
  import { binaryPath } from '@jarkkojs/landstrip';
7
7
 
8
- import { existsSync, readFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
- import { join } from 'node:path';
10
+ import { dirname, join } from 'node:path';
11
11
 
12
12
  interface SandboxFilesystemConfig {
13
13
  denyRead: string[];
@@ -44,38 +44,23 @@ const DEFAULT_CONFIG: SandboxConfig = {
44
44
  allowLocalBinding: false,
45
45
  allowAllUnixSockets: false,
46
46
  allowUnixSockets: [],
47
- allowedDomains: [
48
- 'npmjs.org',
49
- '*.npmjs.org',
50
- 'registry.npmjs.org',
51
- 'registry.yarnpkg.com',
52
- 'pypi.org',
53
- '*.pypi.org',
54
- 'github.com',
55
- '*.github.com',
56
- 'api.github.com',
57
- 'raw.githubusercontent.com',
58
- 'crates.io',
59
- '*.crates.io',
60
- 'static.crates.io',
61
- ],
47
+ allowedDomains: [],
62
48
  deniedDomains: [],
63
49
  },
64
50
  filesystem: {
65
51
  denyRead: ['/Users', '/home'],
66
- allowRead: [
67
- '.',
68
- '/dev/null',
69
- '~/.config/opencode',
70
- '~/.config/git',
71
- '~/.gitconfig',
72
- '~/.local',
73
- '~/.cargo',
74
- ],
75
- allowWrite: ['.', '/tmp', '/dev/null'],
76
- denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
52
+ allowRead: ['.', '~/.gitconfig', '/dev/null'],
53
+ allowWrite: ['.', '/dev/null'],
54
+ denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
77
55
  },
78
56
  };
57
+ const LANDSTRIP_PACKAGE_NAMES = new Set([
58
+ '@jarkkojs/landstrip',
59
+ '@jarkkojs/landstrip-darwin-arm64',
60
+ '@jarkkojs/landstrip-darwin-x64',
61
+ '@jarkkojs/landstrip-linux-x64',
62
+ '@jarkkojs/landstrip-win32-x64',
63
+ ]);
79
64
 
80
65
  function isRecord(value: unknown): value is Record<string, unknown> {
81
66
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -147,16 +132,30 @@ function normalizeOptions(options: unknown): SandboxConfigOverrides {
147
132
  return normalizeConfig(isRecord(options.config) ? options.config : options);
148
133
  }
149
134
 
135
+ function mergeArray(base: string[], override?: string[]): string[] {
136
+ if (!override) return base;
137
+ return [...new Set([...base, ...override])];
138
+ }
139
+
150
140
  function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
141
+ const network = overrides.network;
142
+ const filesystem = overrides.filesystem;
143
+
151
144
  return {
152
145
  enabled: overrides.enabled ?? base.enabled,
153
146
  network: {
154
- ...base.network,
155
- ...overrides.network,
147
+ allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
148
+ allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
149
+ allowAllUnixSockets: network?.allowAllUnixSockets ?? base.network.allowAllUnixSockets,
150
+ allowUnixSockets: mergeArray(base.network.allowUnixSockets, network?.allowUnixSockets),
151
+ allowedDomains: mergeArray(base.network.allowedDomains, network?.allowedDomains),
152
+ deniedDomains: mergeArray(base.network.deniedDomains, network?.deniedDomains),
156
153
  },
157
154
  filesystem: {
158
- ...base.filesystem,
159
- ...overrides.filesystem,
155
+ denyRead: mergeArray(base.filesystem.denyRead, filesystem?.denyRead),
156
+ allowRead: mergeArray(base.filesystem.allowRead, filesystem?.allowRead),
157
+ allowWrite: mergeArray(base.filesystem.allowWrite, filesystem?.allowWrite),
158
+ denyWrite: mergeArray(base.filesystem.denyWrite, filesystem?.denyWrite),
160
159
  },
161
160
  };
162
161
  }
@@ -168,20 +167,61 @@ function getConfigPaths(baseDirectory: string): { globalPath: string; projectPat
168
167
  };
169
168
  }
170
169
 
171
- function readConfigFile(configPath: string): SandboxConfigOverrides {
170
+ function readConfigFile(configPath: string): SandboxConfigOverrides | null {
172
171
  if (!existsSync(configPath)) return {};
173
172
 
174
173
  try {
175
174
  return normalizeConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
176
175
  } catch {
177
- return {};
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function landstripBinaryPath(): string {
181
+ const filePath = realpathSync.native(binaryPath());
182
+ let probe = dirname(filePath);
183
+
184
+ while (true) {
185
+ const manifestPath = join(probe, 'package.json');
186
+ if (existsSync(manifestPath)) {
187
+ try {
188
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as unknown;
189
+ if (isRecord(manifest) && LANDSTRIP_PACKAGE_NAMES.has(String(manifest.name))) {
190
+ return filePath;
191
+ }
192
+ } catch {
193
+ // malformed package.json — continue walking to parent
194
+ }
195
+ }
196
+
197
+ const parent = dirname(probe);
198
+ if (parent === probe) break;
199
+ probe = parent;
200
+ }
201
+
202
+ throw new Error(
203
+ `Refusing to use landstrip binary outside official @jarkkojs/landstrip packages: ${filePath}`,
204
+ );
205
+ }
206
+
207
+ function writeConfigFile(configPath: string, update: SandboxConfigOverrides): void {
208
+ const current = readConfigFile(configPath);
209
+ if (current === null) {
210
+ throw new Error(`Config file ${configPath} is corrupted; refusing to overwrite`);
178
211
  }
212
+
213
+ const next = deepMerge(deepMerge(DEFAULT_CONFIG, current), update);
214
+
215
+ mkdirSync(dirname(configPath), { recursive: true });
216
+ writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
179
217
  }
180
218
 
181
219
  function loadConfig(baseDirectory: string, optionOverrides: SandboxConfigOverrides): SandboxConfig {
182
220
  const { globalPath, projectPath } = getConfigPaths(baseDirectory);
221
+ const globalConfig = readConfigFile(globalPath);
222
+ const projectConfig = readConfigFile(projectPath);
183
223
  return deepMerge(
184
- deepMerge(deepMerge(DEFAULT_CONFIG, readConfigFile(globalPath)), readConfigFile(projectPath)),
224
+ deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig ?? {}), projectConfig ?? {}),
185
225
  optionOverrides,
186
226
  );
187
227
  }
@@ -201,7 +241,7 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
201
241
 
202
242
  return [
203
243
  `Status: ${config.enabled ? 'active' : 'disabled by config'}`,
204
- `landstrip: ${binaryPath()}`,
244
+ `landstrip package binary: ${landstripBinaryPath()}`,
205
245
  '',
206
246
  'Config files',
207
247
  configPathLine('project', projectPath),
@@ -223,7 +263,150 @@ function sandboxSummary(baseDirectory: string, optionOverrides: SandboxConfigOve
223
263
  ].join('\n');
224
264
  }
225
265
 
266
+ type PermissionChoice = 'once' | 'session' | 'project' | 'global' | 'reject';
267
+
268
+ function permissionType(permission: Record<string, unknown>, fallback = ''): string {
269
+ if (typeof permission.permission === 'string') return permission.permission;
270
+ if (typeof permission.action === 'string') return permission.action;
271
+ if (typeof permission.type === 'string') return permission.type;
272
+ return fallback;
273
+ }
274
+
275
+ function permissionPattern(permission: Record<string, unknown>): string | undefined {
276
+ const patterns = permission.patterns;
277
+ if (Array.isArray(patterns))
278
+ return patterns.find((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.find((item): item is string => typeof item === 'string');
284
+
285
+ return undefined;
286
+ }
287
+
288
+ function domainsFromCommand(command: string): string[] {
289
+ const domains = new Set<string>();
290
+ const urlRegex = /https?:\/\/([^\s/:?#'"]+)(?::\d+)?(?:[/?#]|\s|$)/g;
291
+ let match: RegExpExecArray | null;
292
+
293
+ while ((match = urlRegex.exec(command)) !== null) domains.add(match[1]);
294
+
295
+ return [...domains];
296
+ }
297
+
298
+ function updateForPermission(permission: Record<string, unknown>): SandboxConfigOverrides | null {
299
+ const metadata = isRecord(permission.metadata) ? permission.metadata : {};
300
+ const type = permissionType(permission);
301
+ const pattern = permissionPattern(permission);
302
+
303
+ if (type === 'bash') {
304
+ const command = typeof metadata.command === 'string' ? metadata.command : pattern;
305
+ const domains = typeof command === 'string' ? domainsFromCommand(command) : [];
306
+ return domains.length > 0 ? { network: { allowedDomains: domains } } : null;
307
+ }
308
+
309
+ if (type === 'read' || type === 'glob' || type === 'grep' || type === 'list') {
310
+ const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
311
+ return filePath ? { filesystem: { allowRead: [filePath] } } : null;
312
+ }
313
+
314
+ if (type === 'edit' || type === 'write' || type === 'apply_patch') {
315
+ const filePath = typeof metadata.filepath === 'string' ? metadata.filepath : pattern;
316
+ return filePath ? { filesystem: { allowWrite: [filePath] } } : null;
317
+ }
318
+
319
+ return null;
320
+ }
321
+
322
+ function permissionLabel(permission: Record<string, unknown>): string {
323
+ const type = permissionType(permission, 'permission');
324
+ const title = typeof permission.title === 'string' ? permission.title : type;
325
+ const pattern = permissionPattern(permission);
326
+ return pattern ? `${title}: ${pattern}` : title;
327
+ }
328
+
226
329
  const tui: TuiPlugin = async (api, options) => {
330
+ const handledPermissions = new Set<string>();
331
+
332
+ async function replyPermission(
333
+ permission: Record<string, unknown>,
334
+ choice: PermissionChoice,
335
+ ): Promise<void> {
336
+ const id = typeof permission.id === 'string' ? permission.id : undefined;
337
+ if (!id || typeof permission.sessionID !== 'string') return;
338
+
339
+ const directory = api.state.path.directory || process.cwd();
340
+ const { globalPath, projectPath } = getConfigPaths(directory);
341
+
342
+ try {
343
+ if (choice === 'project' || choice === 'global') {
344
+ const update = updateForPermission(permission);
345
+ if (update) writeConfigFile(choice === 'project' ? projectPath : globalPath, update);
346
+ }
347
+
348
+ await api.client.permission.reply({
349
+ requestID: id,
350
+ reply: choice === 'reject' ? 'reject' : choice === 'once' ? 'once' : 'always',
351
+ });
352
+
353
+ api.ui.toast({
354
+ title: 'Sandbox',
355
+ message: choice === 'reject' ? 'Permission rejected' : `Permission allowed for ${choice}`,
356
+ variant: choice === 'reject' ? 'warning' : 'success',
357
+ });
358
+ } catch {
359
+ api.ui.toast({
360
+ title: 'Sandbox',
361
+ message: 'Permission was already handled or could not be updated',
362
+ variant: 'warning',
363
+ });
364
+ } finally {
365
+ api.ui.dialog.clear();
366
+ }
367
+ }
368
+
369
+ function showPermission(permission: Record<string, unknown>): void {
370
+ const id = typeof permission.id === 'string' ? permission.id : undefined;
371
+ if (!id || handledPermissions.has(id)) return;
372
+ handledPermissions.add(id);
373
+
374
+ api.ui.dialog.replace(
375
+ () =>
376
+ api.ui.DialogSelect<PermissionChoice>({
377
+ title: 'Sandbox Permission',
378
+ placeholder: permissionLabel(permission),
379
+ options: [
380
+ { title: 'Allow once', value: 'once', description: 'Approve only this request' },
381
+ {
382
+ title: 'Allow for session',
383
+ value: 'session',
384
+ description: 'Use OpenCode session approval for matching requests',
385
+ },
386
+ {
387
+ title: 'Allow for project',
388
+ value: 'project',
389
+ description: 'Persist to .opencode/sandbox.json and approve this session',
390
+ },
391
+ {
392
+ title: 'Allow globally',
393
+ value: 'global',
394
+ description: 'Persist to ~/.config/opencode/sandbox.json and approve this session',
395
+ },
396
+ { title: 'Reject', value: 'reject', description: 'Deny this request' },
397
+ ],
398
+ onSelect: (option) => {
399
+ void replyPermission(permission, option.value);
400
+ },
401
+ }),
402
+ () => api.ui.dialog.clear(),
403
+ );
404
+ }
405
+
406
+ api.event.on('permission.asked', (event) => {
407
+ showPermission(event.properties as Record<string, unknown>);
408
+ });
409
+
227
410
  const showSandbox = () => {
228
411
  const directory = api.state.path.directory || process.cwd();
229
412
  const message = sandboxSummary(directory, normalizeOptions(options));
@@ -239,32 +422,71 @@ const tui: TuiPlugin = async (api, options) => {
239
422
  );
240
423
  };
241
424
 
425
+ const executeServerCommand = async (command: string): Promise<boolean> => {
426
+ await api.client.tui.executeCommand({ command });
427
+ return true;
428
+ };
429
+
242
430
  api.keymap.registerLayer({
243
431
  commands: [
244
432
  {
245
- namespace: 'palette',
246
- name: 'landstrip.sandbox.show',
247
- title: 'Show sandbox configuration',
248
- desc: 'Show landstrip sandbox status and rules',
249
- description: 'Show landstrip sandbox status and rules',
433
+ name: 'sandbox',
434
+ title: 'Sandbox',
435
+ description: 'Show sandbox configuration',
250
436
  category: 'Sandbox',
251
437
  suggested: true,
252
- slashName: 'sandbox',
438
+ slash: { name: 'sandbox' },
253
439
  run: showSandbox,
254
440
  },
441
+ {
442
+ name: 'sandbox-disable',
443
+ title: 'Disable sandbox',
444
+ description: 'Disable sandbox for this session',
445
+ category: 'Sandbox',
446
+ suggested: true,
447
+ slash: { name: 'sandbox-disable' },
448
+ run: () => executeServerCommand('sandbox-disable'),
449
+ },
450
+ {
451
+ name: 'sandbox-enable',
452
+ title: 'Enable sandbox',
453
+ description: 'Re-enable sandbox for this session',
454
+ category: 'Sandbox',
455
+ suggested: true,
456
+ slash: { name: 'sandbox-enable' },
457
+ run: () => executeServerCommand('sandbox-enable'),
458
+ },
255
459
  ],
256
460
  });
257
461
 
258
462
  api.command?.register(() => [
259
463
  {
260
464
  title: 'Sandbox',
261
- value: 'landstrip.sandbox.show',
465
+ value: 'sandbox',
262
466
  description: 'Show sandbox configuration',
263
467
  category: 'Sandbox',
264
468
  suggested: true,
265
469
  slash: { name: 'sandbox' },
266
470
  onSelect: showSandbox,
267
471
  },
472
+ {
473
+ title: 'Disable sandbox',
474
+ value: 'sandbox-disable',
475
+ description: 'Disable sandbox for this session',
476
+ category: 'Sandbox',
477
+ suggested: true,
478
+ slash: { name: 'sandbox-disable' },
479
+ onSelect: () => executeServerCommand('sandbox-disable'),
480
+ },
481
+ {
482
+ title: 'Enable sandbox',
483
+ value: 'sandbox-enable',
484
+ description: 'Re-enable sandbox for this session',
485
+ category: 'Sandbox',
486
+ suggested: true,
487
+ slash: { name: 'sandbox-enable' },
488
+ onSelect: () => executeServerCommand('sandbox-enable'),
489
+ },
268
490
  ]);
269
491
  };
270
492