opencode-landstrip 0.3.7 → 0.3.9
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 +276 -80
- package/package.json +2 -2
- package/tui.ts +28 -14
package/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ interface SandboxFilesystemConfig {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
interface SandboxNetworkConfig {
|
|
30
|
+
allowNetwork: boolean;
|
|
30
31
|
allowLocalBinding: boolean;
|
|
31
32
|
allowAllUnixSockets: boolean;
|
|
32
33
|
allowUnixSockets: string[];
|
|
@@ -42,20 +43,21 @@ interface SandboxConfig {
|
|
|
42
43
|
|
|
43
44
|
interface LandstripPolicy {
|
|
44
45
|
network: {
|
|
46
|
+
allowNetwork: boolean;
|
|
45
47
|
allowLocalBinding: boolean;
|
|
46
48
|
allowAllUnixSockets: boolean;
|
|
47
49
|
allowUnixSockets: string[];
|
|
48
|
-
httpProxyPort
|
|
50
|
+
httpProxyPort?: number;
|
|
49
51
|
};
|
|
50
52
|
filesystem: SandboxFilesystemConfig;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
interface LandstripErrorResponse {
|
|
54
|
-
|
|
56
|
+
reason: 'Other' | 'LaunchFailed' | 'SetupFailed' | 'Usage';
|
|
55
57
|
file?: string;
|
|
56
58
|
program?: string;
|
|
57
59
|
type?: 'filesystem' | 'network' | 'platform' | 'launch' | 'encoding';
|
|
58
|
-
|
|
60
|
+
source: string;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
interface SandboxConfigOverrides {
|
|
@@ -68,18 +70,19 @@ interface BashSandboxState {
|
|
|
68
70
|
originalCommand: string;
|
|
69
71
|
wrappedCommand: string;
|
|
70
72
|
policyDir: string;
|
|
71
|
-
port: number;
|
|
72
|
-
stop: () => Promise<void
|
|
73
|
+
port: number | null;
|
|
74
|
+
stop: (() => Promise<void>) | null;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
76
78
|
|
|
77
|
-
const LANDSTRIP_VERSION = [0,
|
|
79
|
+
const LANDSTRIP_VERSION = [0, 11, 0] as const;
|
|
78
80
|
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'darwin', 'win32']);
|
|
79
81
|
|
|
80
82
|
const DEFAULT_CONFIG: SandboxConfig = {
|
|
81
83
|
enabled: true,
|
|
82
84
|
network: {
|
|
85
|
+
allowNetwork: false,
|
|
83
86
|
allowLocalBinding: false,
|
|
84
87
|
allowAllUnixSockets: false,
|
|
85
88
|
allowUnixSockets: [],
|
|
@@ -102,8 +105,16 @@ const DEFAULT_CONFIG: SandboxConfig = {
|
|
|
102
105
|
},
|
|
103
106
|
filesystem: {
|
|
104
107
|
denyRead: ['/Users', '/home'],
|
|
105
|
-
allowRead: [
|
|
106
|
-
|
|
108
|
+
allowRead: [
|
|
109
|
+
'.',
|
|
110
|
+
'/dev/null',
|
|
111
|
+
'~/.config/opencode',
|
|
112
|
+
'~/.config/git',
|
|
113
|
+
'~/.gitconfig',
|
|
114
|
+
'~/.local',
|
|
115
|
+
'~/.cargo',
|
|
116
|
+
],
|
|
117
|
+
allowWrite: ['.', '/tmp', '/dev/null'],
|
|
107
118
|
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
108
119
|
},
|
|
109
120
|
};
|
|
@@ -121,6 +132,7 @@ function normalizeNetworkConfig(value: unknown): Partial<SandboxNetworkConfig> |
|
|
|
121
132
|
if (!isRecord(value)) return undefined;
|
|
122
133
|
|
|
123
134
|
const config: Partial<SandboxNetworkConfig> = {};
|
|
135
|
+
if (typeof value.allowNetwork === 'boolean') config.allowNetwork = value.allowNetwork;
|
|
124
136
|
if (typeof value.allowLocalBinding === 'boolean')
|
|
125
137
|
config.allowLocalBinding = value.allowLocalBinding;
|
|
126
138
|
if (typeof value.allowAllUnixSockets === 'boolean')
|
|
@@ -330,6 +342,81 @@ function allowsAllDomains(allowedDomains: string[]): boolean {
|
|
|
330
342
|
return allowedDomains.includes('*');
|
|
331
343
|
}
|
|
332
344
|
|
|
345
|
+
function normalizeBlockedPath(path: string, baseDirectory: string): string {
|
|
346
|
+
return canonicalizePath(isAbsolute(path) ? path : join(baseDirectory, path), baseDirectory);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function extractCandidatePaths(command: string): string[] {
|
|
350
|
+
const paths: string[] = [];
|
|
351
|
+
const tokens = command.match(/[^\s"']+|"[^"]*"|'[^']*'/g) ?? [];
|
|
352
|
+
for (const token of tokens) {
|
|
353
|
+
const clean = token.replace(/^["']|["']$/g, '').replace(/[,;]$/, '');
|
|
354
|
+
if (
|
|
355
|
+
clean.startsWith('/') ||
|
|
356
|
+
clean.startsWith('~/') ||
|
|
357
|
+
clean === '~' ||
|
|
358
|
+
clean.startsWith('./') ||
|
|
359
|
+
clean.startsWith('../')
|
|
360
|
+
) {
|
|
361
|
+
paths.push(clean);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return paths;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function extractBlockedPath(
|
|
368
|
+
output: string,
|
|
369
|
+
baseDirectory: string,
|
|
370
|
+
command?: string,
|
|
371
|
+
): string | null {
|
|
372
|
+
// bash/sh: line X: /path: Permission denied
|
|
373
|
+
let match = output.match(
|
|
374
|
+
/(?:\/bin\/bash|bash|sh): (?:line \d+: )?([^:\n]+): (?:Operation not permitted|Permission denied)/,
|
|
375
|
+
);
|
|
376
|
+
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
377
|
+
|
|
378
|
+
// ls/cat/cp: cannot open/access/stat '/path': Permission denied
|
|
379
|
+
match = output.match(
|
|
380
|
+
/^[a-zA-Z0-9_-]+: cannot (?:open|access|stat|create)(?: directory)? '?([^'\n]+?)'?(?: for (?:reading|writing))?: Permission denied$/m,
|
|
381
|
+
);
|
|
382
|
+
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
383
|
+
|
|
384
|
+
// Generic: cmd: /absolute/path: Permission denied or Operation not permitted
|
|
385
|
+
match = output.match(
|
|
386
|
+
/^[a-zA-Z0-9_-]+: (\/[^\n:]+): (?:Operation not permitted|Permission denied)$/m,
|
|
387
|
+
);
|
|
388
|
+
if (match) return normalizeBlockedPath(match[1], baseDirectory);
|
|
389
|
+
|
|
390
|
+
// Landstrip structured error format with file field
|
|
391
|
+
const landstripErrors = parseLandstripErrors(output);
|
|
392
|
+
for (const error of landstripErrors) {
|
|
393
|
+
if (error.file) return normalizeBlockedPath(error.file, baseDirectory);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// If landstrip reported an error but without a file field, try to
|
|
397
|
+
// extract the blocked path from the command itself
|
|
398
|
+
if (landstripErrors.length > 0 && command) {
|
|
399
|
+
for (const candidate of extractCandidatePaths(command)) {
|
|
400
|
+
const resolved = canonicalizePath(candidate, baseDirectory);
|
|
401
|
+
return resolved;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function extractBlockedWritePath(
|
|
409
|
+
output: string,
|
|
410
|
+
baseDirectory: string,
|
|
411
|
+
command?: string,
|
|
412
|
+
): string | null {
|
|
413
|
+
return extractBlockedPath(output, baseDirectory, command);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isBlockedByDenyRead(path: string, config: SandboxConfig, baseDirectory: string): boolean {
|
|
417
|
+
return matchesPattern(path, config.filesystem.denyRead, baseDirectory);
|
|
418
|
+
}
|
|
419
|
+
|
|
333
420
|
function firstBlockedDomain(
|
|
334
421
|
command: string,
|
|
335
422
|
config: SandboxConfig,
|
|
@@ -374,25 +461,38 @@ function hasMinimumVersion(version: string, minimum: readonly [number, number, n
|
|
|
374
461
|
function parseLandstripErrors(output: string): LandstripErrorResponse[] {
|
|
375
462
|
const errors: LandstripErrorResponse[] = [];
|
|
376
463
|
|
|
377
|
-
for (const
|
|
378
|
-
|
|
379
|
-
|
|
464
|
+
for (const block of output.trim().split(/\n\n+/)) {
|
|
465
|
+
const fields: Record<string, string> = {};
|
|
466
|
+
|
|
467
|
+
for (const line of block.split('\n')) {
|
|
468
|
+
const colonIndex = line.indexOf(':');
|
|
469
|
+
if (colonIndex === -1) continue;
|
|
470
|
+
const key = line.slice(0, colonIndex).trim();
|
|
471
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
472
|
+
if (key.length > 0 && value.length > 0) fields[key] = value;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (
|
|
476
|
+
fields.reason &&
|
|
477
|
+
['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
|
|
478
|
+
fields.source
|
|
479
|
+
) {
|
|
480
|
+
const error: LandstripErrorResponse = {
|
|
481
|
+
reason: fields.reason as LandstripErrorResponse['reason'],
|
|
482
|
+
source: fields.source,
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
if (fields.file) error.file = fields.file;
|
|
486
|
+
if (fields.program) error.program = fields.program;
|
|
380
487
|
|
|
381
488
|
if (
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
typeof parsed.category === 'string' &&
|
|
385
|
-
['policy', 'tool', 'platform', 'system'].includes(parsed.category) &&
|
|
386
|
-
(parsed.type === undefined ||
|
|
387
|
-
(typeof parsed.type === 'string' &&
|
|
388
|
-
['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(parsed.type))) &&
|
|
389
|
-
typeof parsed.message === 'string' &&
|
|
390
|
-
parsed.message.length > 0
|
|
489
|
+
fields.type &&
|
|
490
|
+
['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
|
|
391
491
|
) {
|
|
392
|
-
|
|
492
|
+
error.type = fields.type as LandstripErrorResponse['type'];
|
|
393
493
|
}
|
|
394
|
-
|
|
395
|
-
|
|
494
|
+
|
|
495
|
+
errors.push(error);
|
|
396
496
|
}
|
|
397
497
|
}
|
|
398
498
|
|
|
@@ -402,7 +502,7 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
|
|
|
402
502
|
function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
|
|
403
503
|
return errors
|
|
404
504
|
.map((err) => {
|
|
405
|
-
const parts: string[] = [`landstrip: ${err.
|
|
505
|
+
const parts: string[] = [`landstrip: ${err.reason}`];
|
|
406
506
|
|
|
407
507
|
if (err.file) {
|
|
408
508
|
parts.push(` (${err.file})`);
|
|
@@ -413,7 +513,7 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
|
|
|
413
513
|
if (err.type) {
|
|
414
514
|
parts.push(`:${err.type}`);
|
|
415
515
|
}
|
|
416
|
-
parts.push(`: ${err.
|
|
516
|
+
parts.push(`: ${err.source}`);
|
|
417
517
|
|
|
418
518
|
return parts.join('');
|
|
419
519
|
})
|
|
@@ -458,14 +558,15 @@ function pipeSockets(client: Socket, upstream: Socket, initialData?: Buffer): vo
|
|
|
458
558
|
function buildLandstripPolicy(
|
|
459
559
|
config: SandboxConfig,
|
|
460
560
|
baseDirectory: string,
|
|
461
|
-
proxyPort: number,
|
|
561
|
+
proxyPort: number | null,
|
|
462
562
|
): LandstripPolicy {
|
|
463
563
|
return {
|
|
464
564
|
network: {
|
|
565
|
+
allowNetwork: config.network.allowNetwork,
|
|
465
566
|
allowLocalBinding: config.network.allowLocalBinding,
|
|
466
567
|
allowAllUnixSockets: config.network.allowAllUnixSockets,
|
|
467
568
|
allowUnixSockets: config.network.allowUnixSockets,
|
|
468
|
-
httpProxyPort: proxyPort,
|
|
569
|
+
...(proxyPort !== null ? { httpProxyPort: proxyPort } : {}),
|
|
469
570
|
},
|
|
470
571
|
filesystem: resolveFilesystemConfig(config.filesystem, baseDirectory),
|
|
471
572
|
};
|
|
@@ -474,7 +575,7 @@ function buildLandstripPolicy(
|
|
|
474
575
|
function writePolicyFile(
|
|
475
576
|
config: SandboxConfig,
|
|
476
577
|
baseDirectory: string,
|
|
477
|
-
proxyPort: number,
|
|
578
|
+
proxyPort: number | null,
|
|
478
579
|
): { dir: string; path: string } {
|
|
479
580
|
const dir = mkdtempSync(join(tmpdir(), 'opencode-landstrip-XXXXXX'));
|
|
480
581
|
const path = join(dir, 'policy.json');
|
|
@@ -612,7 +713,8 @@ function startProxy(config: SandboxConfig): Promise<{ port: number; stop: () =>
|
|
|
612
713
|
});
|
|
613
714
|
}
|
|
614
715
|
|
|
615
|
-
function proxyEnv(port: number): Record<string, string> {
|
|
716
|
+
function proxyEnv(port: number | null): Record<string, string> | undefined {
|
|
717
|
+
if (port === null) return undefined;
|
|
616
718
|
const url = `http://127.0.0.1:${port}`;
|
|
617
719
|
|
|
618
720
|
return {
|
|
@@ -687,9 +789,22 @@ function errorWithConfigPaths(baseDirectory: string, message: string): Error {
|
|
|
687
789
|
return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
|
|
688
790
|
}
|
|
689
791
|
|
|
690
|
-
function assertReadAllowed(
|
|
792
|
+
function assertReadAllowed(
|
|
793
|
+
path: string,
|
|
794
|
+
config: SandboxConfig,
|
|
795
|
+
baseDirectory: string,
|
|
796
|
+
effectiveAllowRead: string[],
|
|
797
|
+
): void {
|
|
691
798
|
const filePath = canonicalizePath(path, baseDirectory);
|
|
692
|
-
|
|
799
|
+
|
|
800
|
+
if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) return;
|
|
801
|
+
|
|
802
|
+
if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
|
|
803
|
+
throw errorWithConfigPaths(
|
|
804
|
+
baseDirectory,
|
|
805
|
+
`Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
693
808
|
|
|
694
809
|
throw errorWithConfigPaths(
|
|
695
810
|
baseDirectory,
|
|
@@ -697,9 +812,16 @@ function assertReadAllowed(path: string, config: SandboxConfig, baseDirectory: s
|
|
|
697
812
|
);
|
|
698
813
|
}
|
|
699
814
|
|
|
700
|
-
function assertWriteAllowed(
|
|
815
|
+
function assertWriteAllowed(
|
|
816
|
+
path: string,
|
|
817
|
+
config: SandboxConfig,
|
|
818
|
+
baseDirectory: string,
|
|
819
|
+
effectiveAllowWrite: string[],
|
|
820
|
+
): void {
|
|
701
821
|
const filePath = canonicalizePath(path, baseDirectory);
|
|
702
822
|
|
|
823
|
+
if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) return;
|
|
824
|
+
|
|
703
825
|
if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
|
|
704
826
|
throw errorWithConfigPaths(
|
|
705
827
|
baseDirectory,
|
|
@@ -707,8 +829,6 @@ function assertWriteAllowed(path: string, config: SandboxConfig, baseDirectory:
|
|
|
707
829
|
);
|
|
708
830
|
}
|
|
709
831
|
|
|
710
|
-
if (!shouldPromptForWrite(filePath, config.filesystem.allowWrite, baseDirectory)) return;
|
|
711
|
-
|
|
712
832
|
throw errorWithConfigPaths(
|
|
713
833
|
baseDirectory,
|
|
714
834
|
`Sandbox: write access denied for "${filePath}" (not in filesystem.allowWrite).`,
|
|
@@ -719,20 +839,32 @@ function assertApplyPatchAllowed(
|
|
|
719
839
|
args: Record<string, unknown>,
|
|
720
840
|
config: SandboxConfig,
|
|
721
841
|
baseDirectory: string,
|
|
842
|
+
effectiveAllowWrite: string[],
|
|
722
843
|
): void {
|
|
723
844
|
if (typeof args.patchText !== 'string') return;
|
|
724
845
|
for (const path of extractPatchPaths(args.patchText))
|
|
725
|
-
assertWriteAllowed(path, config, baseDirectory);
|
|
846
|
+
assertWriteAllowed(path, config, baseDirectory, effectiveAllowWrite);
|
|
726
847
|
}
|
|
727
848
|
|
|
728
849
|
const plugin: Plugin = async ({ client, directory }: PluginInput, options?: PluginOptions) => {
|
|
729
850
|
const optionOverrides = normalizeOptions(options);
|
|
730
851
|
const activeBash = new Map<string, BashSandboxState>();
|
|
731
852
|
const notified = new Set<string>();
|
|
853
|
+
const sessionAllowedDomains: string[] = [];
|
|
854
|
+
const sessionAllowedReadPaths: string[] = [];
|
|
855
|
+
const sessionAllowedWritePaths: string[] = [];
|
|
732
856
|
let enabledNotified = false;
|
|
733
857
|
let configuredShell: string | undefined;
|
|
734
858
|
let landstripCheck: { ok: true; version: string } | { ok: false; reason: string } | undefined;
|
|
735
859
|
|
|
860
|
+
function getEffectiveAllowRead(config: SandboxConfig): string[] {
|
|
861
|
+
return [...config.filesystem.allowRead, ...sessionAllowedReadPaths];
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function getEffectiveAllowWrite(config: SandboxConfig): string[] {
|
|
865
|
+
return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
|
|
866
|
+
}
|
|
867
|
+
|
|
736
868
|
client.app
|
|
737
869
|
.log({
|
|
738
870
|
body: {
|
|
@@ -801,7 +933,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
801
933
|
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
802
934
|
landstripCheck = {
|
|
803
935
|
ok: false,
|
|
804
|
-
reason: `landstrip 0.
|
|
936
|
+
reason: `landstrip 0.11.0 or newer is required; found: ${version}`,
|
|
805
937
|
};
|
|
806
938
|
return landstripCheck;
|
|
807
939
|
}
|
|
@@ -826,20 +958,28 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
826
958
|
|
|
827
959
|
if (!enabledNotified) {
|
|
828
960
|
enabledNotified = true;
|
|
829
|
-
|
|
830
|
-
? 'all domains'
|
|
831
|
-
: `${config.network.allowedDomains.length} domains`;
|
|
832
|
-
await notifyOnce(
|
|
833
|
-
'enabled',
|
|
834
|
-
`Sandbox enabled: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
|
|
835
|
-
'info',
|
|
836
|
-
);
|
|
837
|
-
if (allowsAllDomains(config.network.allowedDomains)) {
|
|
961
|
+
if (config.network.allowNetwork) {
|
|
838
962
|
await notifyOnce(
|
|
839
|
-
'network-
|
|
840
|
-
'Network sandbox
|
|
963
|
+
'network-allow',
|
|
964
|
+
'Network sandbox is disabled because network.allowNetwork is true.',
|
|
841
965
|
'warning',
|
|
842
966
|
);
|
|
967
|
+
} else {
|
|
968
|
+
const networkLabel = allowsAllDomains(config.network.allowedDomains)
|
|
969
|
+
? 'all domains'
|
|
970
|
+
: `${config.network.allowedDomains.length} domains`;
|
|
971
|
+
await notifyOnce(
|
|
972
|
+
'enabled',
|
|
973
|
+
`Sandbox enabled: ${networkLabel}, ${config.filesystem.allowWrite.length} write paths`,
|
|
974
|
+
'info',
|
|
975
|
+
);
|
|
976
|
+
if (allowsAllDomains(config.network.allowedDomains)) {
|
|
977
|
+
await notifyOnce(
|
|
978
|
+
'network-all',
|
|
979
|
+
'Network sandbox allows all domains because network.allowedDomains contains "*".',
|
|
980
|
+
'warning',
|
|
981
|
+
);
|
|
982
|
+
}
|
|
843
983
|
}
|
|
844
984
|
}
|
|
845
985
|
|
|
@@ -851,7 +991,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
851
991
|
if (!state) return;
|
|
852
992
|
|
|
853
993
|
activeBash.delete(callID);
|
|
854
|
-
await state.stop().catch(() => undefined);
|
|
994
|
+
if (state.stop) await state.stop().catch(() => undefined);
|
|
855
995
|
rmSync(state.policyDir, { recursive: true, force: true });
|
|
856
996
|
}
|
|
857
997
|
|
|
@@ -880,25 +1020,30 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
880
1020
|
return;
|
|
881
1021
|
}
|
|
882
1022
|
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1023
|
+
const allowNetwork = config.network.allowNetwork;
|
|
1024
|
+
|
|
1025
|
+
if (!allowNetwork) {
|
|
1026
|
+
const blockedDomain = firstBlockedDomain(args.command, config);
|
|
1027
|
+
if (blockedDomain) {
|
|
1028
|
+
const reason =
|
|
1029
|
+
blockedDomain.reason === 'deniedDomains'
|
|
1030
|
+
? 'is blocked by network.deniedDomains'
|
|
1031
|
+
: 'is not in network.allowedDomains';
|
|
1032
|
+
throw errorWithConfigPaths(
|
|
1033
|
+
directory,
|
|
1034
|
+
`Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
893
1037
|
}
|
|
894
1038
|
|
|
895
|
-
const proxy = await startProxy(config);
|
|
1039
|
+
const proxy = allowNetwork ? null : await startProxy(config);
|
|
1040
|
+
const proxyPort = proxy ? proxy.port : null;
|
|
896
1041
|
let policy: { dir: string; path: string };
|
|
897
1042
|
|
|
898
1043
|
try {
|
|
899
|
-
policy = writePolicyFile(config, directory,
|
|
1044
|
+
policy = writePolicyFile(config, directory, proxyPort);
|
|
900
1045
|
} catch (error) {
|
|
901
|
-
await proxy.stop().catch(() => undefined);
|
|
1046
|
+
if (proxy) await proxy.stop().catch(() => undefined);
|
|
902
1047
|
throw error;
|
|
903
1048
|
}
|
|
904
1049
|
|
|
@@ -913,8 +1058,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
913
1058
|
originalCommand,
|
|
914
1059
|
wrappedCommand,
|
|
915
1060
|
policyDir: policy.dir,
|
|
916
|
-
port:
|
|
917
|
-
stop: proxy.stop,
|
|
1061
|
+
port: proxyPort,
|
|
1062
|
+
stop: proxy ? proxy.stop : null,
|
|
918
1063
|
});
|
|
919
1064
|
|
|
920
1065
|
args.command = wrappedCommand;
|
|
@@ -935,6 +1080,8 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
935
1080
|
` Global config: ${globalPath}`,
|
|
936
1081
|
` landstrip: ${binaryPath()} (v${version})`,
|
|
937
1082
|
'',
|
|
1083
|
+
` Allow network: ${config.network.allowNetwork}`,
|
|
1084
|
+
'',
|
|
938
1085
|
'Network (bash commands go through HTTP proxy):',
|
|
939
1086
|
` Allow local binding: ${config.network.allowLocalBinding}`,
|
|
940
1087
|
` Allow all Unix sockets: ${config.network.allowAllUnixSockets}`,
|
|
@@ -965,6 +1112,9 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
965
1112
|
const config = await activeConfig();
|
|
966
1113
|
if (!config) return;
|
|
967
1114
|
|
|
1115
|
+
const effectiveAllowRead = getEffectiveAllowRead(config);
|
|
1116
|
+
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1117
|
+
|
|
968
1118
|
if (input.tool === 'bash') {
|
|
969
1119
|
await prepareBash(input.callID, output.args, config);
|
|
970
1120
|
return;
|
|
@@ -972,22 +1122,23 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
972
1122
|
|
|
973
1123
|
if (input.tool === 'read') {
|
|
974
1124
|
const path = getToolPath(output.args);
|
|
975
|
-
if (path) assertReadAllowed(path, config, directory);
|
|
1125
|
+
if (path) assertReadAllowed(path, config, directory, effectiveAllowRead);
|
|
976
1126
|
return;
|
|
977
1127
|
}
|
|
978
1128
|
|
|
979
1129
|
if (input.tool === 'glob' || input.tool === 'grep' || input.tool === 'list') {
|
|
980
|
-
assertReadAllowed(getSearchPath(output.args), config, directory);
|
|
1130
|
+
assertReadAllowed(getSearchPath(output.args), config, directory, effectiveAllowRead);
|
|
981
1131
|
return;
|
|
982
1132
|
}
|
|
983
1133
|
|
|
984
1134
|
if (input.tool === 'write' || input.tool === 'edit') {
|
|
985
1135
|
const path = getToolPath(output.args);
|
|
986
|
-
if (path) assertWriteAllowed(path, config, directory);
|
|
1136
|
+
if (path) assertWriteAllowed(path, config, directory, effectiveAllowWrite);
|
|
987
1137
|
return;
|
|
988
1138
|
}
|
|
989
1139
|
|
|
990
|
-
if (input.tool === 'apply_patch')
|
|
1140
|
+
if (input.tool === 'apply_patch')
|
|
1141
|
+
assertApplyPatchAllowed(output.args, config, directory, effectiveAllowWrite);
|
|
991
1142
|
},
|
|
992
1143
|
|
|
993
1144
|
'shell.env': async (input, output) => {
|
|
@@ -995,16 +1146,21 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
995
1146
|
const state = activeBash.get(input.callID);
|
|
996
1147
|
if (!state) return;
|
|
997
1148
|
|
|
998
|
-
|
|
1149
|
+
const envVars = proxyEnv(state.port);
|
|
1150
|
+
if (envVars) Object.assign(output.env, envVars);
|
|
999
1151
|
},
|
|
1000
1152
|
|
|
1001
1153
|
'tool.execute.after': async (input, output) => {
|
|
1002
1154
|
if (input.tool !== 'bash') return;
|
|
1003
1155
|
|
|
1004
1156
|
const state = activeBash.get(input.callID);
|
|
1005
|
-
if (!state)
|
|
1157
|
+
if (!state) {
|
|
1158
|
+
await cleanupBash(input.callID);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1006
1161
|
|
|
1007
|
-
const
|
|
1162
|
+
const outputText = output?.output ?? '';
|
|
1163
|
+
const errors = parseLandstripErrors(outputText);
|
|
1008
1164
|
if (errors.length > 0) {
|
|
1009
1165
|
const message = formatLandstripErrors(errors);
|
|
1010
1166
|
await client.tui
|
|
@@ -1025,20 +1181,60 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1025
1181
|
.catch(() => undefined);
|
|
1026
1182
|
}
|
|
1027
1183
|
|
|
1184
|
+
const blockedPath = extractBlockedWritePath(outputText, directory, state.originalCommand);
|
|
1185
|
+
if (blockedPath) {
|
|
1186
|
+
const config = loadConfig(directory, optionOverrides);
|
|
1187
|
+
if (
|
|
1188
|
+
!sessionAllowedWritePaths.includes(blockedPath) &&
|
|
1189
|
+
!matchesPattern(blockedPath, config.filesystem.allowWrite, directory) &&
|
|
1190
|
+
!matchesPattern(blockedPath, config.filesystem.denyWrite, directory)
|
|
1191
|
+
) {
|
|
1192
|
+
sessionAllowedWritePaths.push(blockedPath);
|
|
1193
|
+
await notifyOnce(
|
|
1194
|
+
`write-allow:${blockedPath}`,
|
|
1195
|
+
`Write access granted for session: "${blockedPath}"`,
|
|
1196
|
+
'warning',
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1028
1201
|
await cleanupBash(input.callID);
|
|
1029
1202
|
},
|
|
1030
1203
|
|
|
1031
1204
|
'command.execute.before': async (input, output) => {
|
|
1032
|
-
if (input.command
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1205
|
+
if (input.command === 'sandbox' || input.command === '/sandbox') {
|
|
1206
|
+
const config = loadConfig(directory, optionOverrides);
|
|
1207
|
+
const summary = buildConfigSummary(config);
|
|
1208
|
+
await client.tui
|
|
1209
|
+
.showToast({
|
|
1210
|
+
body: { title: 'Sandbox', message: summary, variant: 'info', duration: 15000 },
|
|
1211
|
+
query: { directory },
|
|
1212
|
+
})
|
|
1213
|
+
.catch(() => undefined);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Check domain in user shell commands (commands starting with !)
|
|
1218
|
+
if (input.command.startsWith('!')) {
|
|
1219
|
+
const shellCommand = input.command.slice(1).trim();
|
|
1220
|
+
const config = await activeConfig();
|
|
1221
|
+
if (!config || config.network.allowNetwork) return;
|
|
1222
|
+
|
|
1223
|
+
const blockedDomain = firstBlockedDomain(shellCommand, config);
|
|
1224
|
+
if (blockedDomain) {
|
|
1225
|
+
const reason =
|
|
1226
|
+
blockedDomain.reason === 'deniedDomains'
|
|
1227
|
+
? 'is blocked by network.deniedDomains'
|
|
1228
|
+
: 'is not in network.allowedDomains';
|
|
1229
|
+
output.parts.push({
|
|
1230
|
+
type: 'text',
|
|
1231
|
+
text: `Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
|
|
1232
|
+
id: '',
|
|
1233
|
+
sessionID: input.sessionID,
|
|
1234
|
+
messageID: '',
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1042
1238
|
},
|
|
1043
1239
|
|
|
1044
1240
|
dispose: async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-landstrip",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Landlock-based sandboxing for opencode with landstrip",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"landlock",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"ci:test": "npm test"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@jarkkojs/landstrip": "^0.
|
|
50
|
+
"@jarkkojs/landstrip": "^0.11.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@opencode-ai/plugin": "^1.16.2",
|
package/tui.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import type { TuiPlugin } from '@opencode-ai/plugin/tui';
|
|
5
|
+
|
|
6
|
+
const tui: TuiPlugin = async (api) => {
|
|
5
7
|
try {
|
|
6
8
|
api.keymap.registerLayer({
|
|
7
9
|
commands: [
|
|
@@ -10,24 +12,40 @@ const tui = async (api: any) => {
|
|
|
10
12
|
title: 'Sandbox',
|
|
11
13
|
description: 'Show sandbox configuration',
|
|
12
14
|
category: 'plugin',
|
|
15
|
+
keybind: 'ctrl+x b',
|
|
16
|
+
suggested: true,
|
|
13
17
|
slash: { name: 'sandbox' },
|
|
14
18
|
run: async () => {
|
|
15
|
-
await api.client.tui.executeCommand({
|
|
19
|
+
await api.client.tui.executeCommand({ command: 'sandbox' });
|
|
16
20
|
return true;
|
|
17
21
|
},
|
|
18
22
|
},
|
|
19
23
|
],
|
|
20
24
|
});
|
|
21
25
|
|
|
26
|
+
if (api.command) {
|
|
27
|
+
api.command.register(() => [
|
|
28
|
+
{
|
|
29
|
+
title: 'Sandbox',
|
|
30
|
+
value: 'sandbox',
|
|
31
|
+
description: 'Show sandbox configuration',
|
|
32
|
+
category: 'plugin',
|
|
33
|
+
suggested: true,
|
|
34
|
+
slash: { name: 'sandbox' },
|
|
35
|
+
onSelect: async () => {
|
|
36
|
+
await api.client.tui.executeCommand({ command: 'sandbox' });
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
const client = api.client;
|
|
23
43
|
if (client?.tui?.showToast) {
|
|
24
44
|
client.tui
|
|
25
45
|
.showToast({
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
variant: 'info',
|
|
30
|
-
},
|
|
46
|
+
title: 'Sandbox',
|
|
47
|
+
message: '/sandbox command registered',
|
|
48
|
+
variant: 'info',
|
|
31
49
|
})
|
|
32
50
|
.catch(() => undefined);
|
|
33
51
|
}
|
|
@@ -36,17 +54,13 @@ const tui = async (api: any) => {
|
|
|
36
54
|
if (client?.tui?.showToast) {
|
|
37
55
|
client.tui
|
|
38
56
|
.showToast({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
variant: 'error',
|
|
43
|
-
},
|
|
57
|
+
title: 'Sandbox error',
|
|
58
|
+
message: err instanceof Error ? err.message : String(err),
|
|
59
|
+
variant: 'error',
|
|
44
60
|
})
|
|
45
61
|
.catch(() => undefined);
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
|
-
|
|
49
|
-
return {};
|
|
50
64
|
};
|
|
51
65
|
|
|
52
66
|
export { tui };
|