opencode-landstrip 0.3.10 → 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.
- package/README.md +19 -7
- package/index.ts +464 -158
- package/landstrip.d.ts +3 -0
- package/package.json +9 -8
- package/sandbox.json +7 -47
- package/tui.ts +265 -43
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
|
|
61
|
+
source?: string;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
interface SandboxConfigOverrides {
|
|
@@ -74,10 +75,59 @@ interface BashSandboxState {
|
|
|
74
75
|
stop: (() => Promise<void>) | null;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
type SandboxPermissionKind = 'read' | 'write' | 'domain';
|
|
79
|
+
|
|
80
|
+
interface SandboxPermissionDecision {
|
|
81
|
+
status: 'allow' | 'ask' | 'deny';
|
|
82
|
+
kind: SandboxPermissionKind;
|
|
83
|
+
resource: string;
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
77
87
|
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
78
88
|
|
|
79
|
-
const LANDSTRIP_VERSION = [0, 11,
|
|
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
|
+
]);
|
|
80
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
|
+
}
|
|
81
131
|
|
|
82
132
|
const DEFAULT_CONFIG: SandboxConfig = {
|
|
83
133
|
enabled: true,
|
|
@@ -86,36 +136,14 @@ const DEFAULT_CONFIG: SandboxConfig = {
|
|
|
86
136
|
allowLocalBinding: false,
|
|
87
137
|
allowAllUnixSockets: false,
|
|
88
138
|
allowUnixSockets: [],
|
|
89
|
-
allowedDomains: [
|
|
90
|
-
'npmjs.org',
|
|
91
|
-
'*.npmjs.org',
|
|
92
|
-
'registry.npmjs.org',
|
|
93
|
-
'registry.yarnpkg.com',
|
|
94
|
-
'pypi.org',
|
|
95
|
-
'*.pypi.org',
|
|
96
|
-
'github.com',
|
|
97
|
-
'*.github.com',
|
|
98
|
-
'api.github.com',
|
|
99
|
-
'raw.githubusercontent.com',
|
|
100
|
-
'crates.io',
|
|
101
|
-
'*.crates.io',
|
|
102
|
-
'static.crates.io',
|
|
103
|
-
],
|
|
139
|
+
allowedDomains: [],
|
|
104
140
|
deniedDomains: [],
|
|
105
141
|
},
|
|
106
142
|
filesystem: {
|
|
107
143
|
denyRead: ['/Users', '/home'],
|
|
108
|
-
allowRead: [
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
'~/.config/opencode',
|
|
112
|
-
'~/.config/git',
|
|
113
|
-
'~/.gitconfig',
|
|
114
|
-
'~/.local',
|
|
115
|
-
'~/.cargo',
|
|
116
|
-
],
|
|
117
|
-
allowWrite: ['.', '/tmp', '/dev/null'],
|
|
118
|
-
denyWrite: ['.env', '.env.*', '*.pem', '*.key'],
|
|
144
|
+
allowRead: ['.', '~/.gitconfig', '/dev/null'],
|
|
145
|
+
allowWrite: ['.', '/dev/null'],
|
|
146
|
+
denyWrite: ['**/.env', '**/.env.*', '**/*.pem', '**/*.key'],
|
|
119
147
|
},
|
|
120
148
|
};
|
|
121
149
|
|
|
@@ -189,16 +217,30 @@ function normalizeOptions(options: PluginOptions | undefined): SandboxConfigOver
|
|
|
189
217
|
return normalizeConfig(isRecord(options.config) ? options.config : options);
|
|
190
218
|
}
|
|
191
219
|
|
|
220
|
+
function mergeArray(base: string[], override?: string[]): string[] {
|
|
221
|
+
if (!override) return base;
|
|
222
|
+
return [...new Set([...base, ...override])];
|
|
223
|
+
}
|
|
224
|
+
|
|
192
225
|
function deepMerge(base: SandboxConfig, overrides: SandboxConfigOverrides): SandboxConfig {
|
|
226
|
+
const network = overrides.network;
|
|
227
|
+
const filesystem = overrides.filesystem;
|
|
228
|
+
|
|
193
229
|
return {
|
|
194
230
|
enabled: overrides.enabled ?? base.enabled,
|
|
195
231
|
network: {
|
|
196
|
-
|
|
197
|
-
|
|
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),
|
|
198
238
|
},
|
|
199
239
|
filesystem: {
|
|
200
|
-
|
|
201
|
-
|
|
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),
|
|
202
244
|
},
|
|
203
245
|
};
|
|
204
246
|
}
|
|
@@ -239,6 +281,33 @@ function configuredShellPath(config: unknown): string | undefined {
|
|
|
239
281
|
return typeof config.shell === 'string' ? config.shell : undefined;
|
|
240
282
|
}
|
|
241
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
|
+
|
|
242
311
|
function canonicalizePath(filePath: string, baseDirectory: string): string {
|
|
243
312
|
const abs = expandPath(filePath, baseDirectory);
|
|
244
313
|
|
|
@@ -434,8 +503,91 @@ function firstBlockedDomain(
|
|
|
434
503
|
return null;
|
|
435
504
|
}
|
|
436
505
|
|
|
506
|
+
function evaluateReadPermission(
|
|
507
|
+
path: string,
|
|
508
|
+
config: SandboxConfig,
|
|
509
|
+
baseDirectory: string,
|
|
510
|
+
effectiveAllowRead: string[],
|
|
511
|
+
): SandboxPermissionDecision {
|
|
512
|
+
const filePath = canonicalizePath(path, baseDirectory);
|
|
513
|
+
|
|
514
|
+
if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) {
|
|
515
|
+
return { status: 'allow', kind: 'read', resource: filePath, message: '' };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
|
|
519
|
+
return {
|
|
520
|
+
status: 'deny',
|
|
521
|
+
kind: 'read',
|
|
522
|
+
resource: filePath,
|
|
523
|
+
message: `Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
status: 'ask',
|
|
529
|
+
kind: 'read',
|
|
530
|
+
resource: filePath,
|
|
531
|
+
message: `Sandbox: read access requires approval for "${filePath}" (not in filesystem.allowRead).`,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function evaluateWritePermission(
|
|
536
|
+
path: string,
|
|
537
|
+
config: SandboxConfig,
|
|
538
|
+
baseDirectory: string,
|
|
539
|
+
effectiveAllowWrite: string[],
|
|
540
|
+
): SandboxPermissionDecision {
|
|
541
|
+
const filePath = canonicalizePath(path, baseDirectory);
|
|
542
|
+
|
|
543
|
+
if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) {
|
|
544
|
+
return { status: 'allow', kind: 'write', resource: filePath, message: '' };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
|
|
548
|
+
return {
|
|
549
|
+
status: 'deny',
|
|
550
|
+
kind: 'write',
|
|
551
|
+
resource: filePath,
|
|
552
|
+
message: `Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
status: 'ask',
|
|
558
|
+
kind: 'write',
|
|
559
|
+
resource: filePath,
|
|
560
|
+
message: `Sandbox: write access requires approval for "${filePath}" (not in filesystem.allowWrite).`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function evaluateDomainPermission(
|
|
565
|
+
domain: string,
|
|
566
|
+
config: SandboxConfig,
|
|
567
|
+
): SandboxPermissionDecision {
|
|
568
|
+
if (config.network.allowNetwork || domainMatchesAny(domain, config.network.allowedDomains)) {
|
|
569
|
+
return { status: 'allow', kind: 'domain', resource: domain, message: '' };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (domainMatchesAny(domain, config.network.deniedDomains)) {
|
|
573
|
+
return {
|
|
574
|
+
status: 'deny',
|
|
575
|
+
kind: 'domain',
|
|
576
|
+
resource: domain,
|
|
577
|
+
message: `Sandbox: network access denied for "${domain}" (is blocked by network.deniedDomains).`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
status: 'ask',
|
|
583
|
+
kind: 'domain',
|
|
584
|
+
resource: domain,
|
|
585
|
+
message: `Sandbox: network access requires approval for "${domain}" (not in network.allowedDomains).`,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
437
589
|
function landstripVersion(): string | null {
|
|
438
|
-
const result = spawnSync(
|
|
590
|
+
const result = spawnSync(landstripBinaryPath(), ['--version'], { encoding: 'utf-8' });
|
|
439
591
|
if (result.status !== 0) return null;
|
|
440
592
|
return result.stdout.trim();
|
|
441
593
|
}
|
|
@@ -472,25 +624,19 @@ function parseLandstripErrors(output: string): LandstripErrorResponse[] {
|
|
|
472
624
|
if (key.length > 0 && value.length > 0) fields[key] = value;
|
|
473
625
|
}
|
|
474
626
|
|
|
475
|
-
if (
|
|
476
|
-
fields.reason &&
|
|
477
|
-
['Other', 'LaunchFailed', 'SetupFailed', 'Usage'].includes(fields.reason) &&
|
|
478
|
-
fields.source
|
|
479
|
-
) {
|
|
627
|
+
if (fields.reason && isLandstripErrorReason(fields.reason)) {
|
|
480
628
|
const error: LandstripErrorResponse = {
|
|
481
|
-
reason: fields.reason
|
|
482
|
-
source: fields.source,
|
|
629
|
+
reason: fields.reason,
|
|
483
630
|
};
|
|
484
631
|
|
|
485
632
|
if (fields.file) error.file = fields.file;
|
|
633
|
+
if (fields.operation && isLandstripOperation(fields.operation)) {
|
|
634
|
+
error.operation = fields.operation;
|
|
635
|
+
}
|
|
486
636
|
if (fields.program) error.program = fields.program;
|
|
637
|
+
if (fields.source) error.source = fields.source;
|
|
487
638
|
|
|
488
|
-
if (
|
|
489
|
-
fields.type &&
|
|
490
|
-
['filesystem', 'network', 'platform', 'launch', 'encoding'].includes(fields.type)
|
|
491
|
-
) {
|
|
492
|
-
error.type = fields.type as LandstripErrorResponse['type'];
|
|
493
|
-
}
|
|
639
|
+
if (fields.type && isLandstripErrorType(fields.type)) error.type = fields.type;
|
|
494
640
|
|
|
495
641
|
errors.push(error);
|
|
496
642
|
}
|
|
@@ -507,13 +653,16 @@ function formatLandstripErrors(errors: LandstripErrorResponse[]): string {
|
|
|
507
653
|
if (err.file) {
|
|
508
654
|
parts.push(` (${err.file})`);
|
|
509
655
|
}
|
|
656
|
+
if (err.operation) {
|
|
657
|
+
parts.push(` ${err.operation}`);
|
|
658
|
+
}
|
|
510
659
|
if (err.program) {
|
|
511
660
|
parts.push(` ${err.program}`);
|
|
512
661
|
}
|
|
513
662
|
if (err.type) {
|
|
514
663
|
parts.push(`:${err.type}`);
|
|
515
664
|
}
|
|
516
|
-
parts.push(`: ${err.source}`);
|
|
665
|
+
if (err.source) parts.push(`: ${err.source}`);
|
|
517
666
|
|
|
518
667
|
return parts.join('');
|
|
519
668
|
})
|
|
@@ -746,12 +895,12 @@ function shellArgs(shell: string, command: string): string[] {
|
|
|
746
895
|
function buildWrappedCommand(policyPath: string, shell: string, command: string): string {
|
|
747
896
|
const args = ['-p', policyPath, ...shellArgs(shell, command)];
|
|
748
897
|
|
|
749
|
-
return [
|
|
898
|
+
return [landstripBinaryPath(), ...args].map(shellQuote).join(' ');
|
|
750
899
|
}
|
|
751
900
|
|
|
752
901
|
function isGeneratedWrappedCommand(command: string): boolean {
|
|
753
902
|
return (
|
|
754
|
-
command.startsWith(`${shellQuote(
|
|
903
|
+
command.startsWith(`${shellQuote(landstripBinaryPath())} `) &&
|
|
755
904
|
command.includes(` ${shellQuote('-p')} `) &&
|
|
756
905
|
command.includes('opencode-landstrip-')
|
|
757
906
|
);
|
|
@@ -829,69 +978,14 @@ function errorWithConfigPaths(baseDirectory: string, message: string): Error {
|
|
|
829
978
|
return new Error(`${message}\n\nUpdate sandbox config in:\n ${projectPath}\n ${globalPath}`);
|
|
830
979
|
}
|
|
831
980
|
|
|
832
|
-
function assertReadAllowed(
|
|
833
|
-
path: string,
|
|
834
|
-
config: SandboxConfig,
|
|
835
|
-
baseDirectory: string,
|
|
836
|
-
effectiveAllowRead: string[],
|
|
837
|
-
): void {
|
|
838
|
-
const filePath = canonicalizePath(path, baseDirectory);
|
|
839
|
-
|
|
840
|
-
if (!shouldPromptForRead(filePath, effectiveAllowRead, baseDirectory)) return;
|
|
841
|
-
|
|
842
|
-
if (isBlockedByDenyRead(filePath, config, baseDirectory)) {
|
|
843
|
-
throw errorWithConfigPaths(
|
|
844
|
-
baseDirectory,
|
|
845
|
-
`Sandbox: read access denied for "${filePath}" (denyRead overrides allowRead).`,
|
|
846
|
-
);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
throw errorWithConfigPaths(
|
|
850
|
-
baseDirectory,
|
|
851
|
-
`Sandbox: read access denied for "${filePath}" (not in filesystem.allowRead).`,
|
|
852
|
-
);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function assertWriteAllowed(
|
|
856
|
-
path: string,
|
|
857
|
-
config: SandboxConfig,
|
|
858
|
-
baseDirectory: string,
|
|
859
|
-
effectiveAllowWrite: string[],
|
|
860
|
-
): void {
|
|
861
|
-
const filePath = canonicalizePath(path, baseDirectory);
|
|
862
|
-
|
|
863
|
-
if (!shouldPromptForWrite(filePath, effectiveAllowWrite, baseDirectory)) return;
|
|
864
|
-
|
|
865
|
-
if (matchesPattern(filePath, config.filesystem.denyWrite, baseDirectory)) {
|
|
866
|
-
throw errorWithConfigPaths(
|
|
867
|
-
baseDirectory,
|
|
868
|
-
`Sandbox: write access denied for "${filePath}" (in filesystem.denyWrite).`,
|
|
869
|
-
);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
throw errorWithConfigPaths(
|
|
873
|
-
baseDirectory,
|
|
874
|
-
`Sandbox: write access denied for "${filePath}" (not in filesystem.allowWrite).`,
|
|
875
|
-
);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function assertApplyPatchAllowed(
|
|
879
|
-
args: Record<string, unknown>,
|
|
880
|
-
config: SandboxConfig,
|
|
881
|
-
baseDirectory: string,
|
|
882
|
-
effectiveAllowWrite: string[],
|
|
883
|
-
): void {
|
|
884
|
-
if (typeof args.patchText !== 'string') return;
|
|
885
|
-
for (const path of extractPatchPaths(args.patchText))
|
|
886
|
-
assertWriteAllowed(path, config, baseDirectory, effectiveAllowWrite);
|
|
887
|
-
}
|
|
888
|
-
|
|
889
981
|
const plugin: Plugin = async ({ client, directory }: PluginInput, options?: PluginOptions) => {
|
|
890
982
|
const optionOverrides = normalizeOptions(options);
|
|
891
983
|
const activeBash = new Map<string, BashSandboxState>();
|
|
892
984
|
const notified = new Set<string>();
|
|
893
985
|
const sessionAllowedReadPaths: string[] = [];
|
|
894
986
|
const sessionAllowedWritePaths: string[] = [];
|
|
987
|
+
const sessionAllowedDomains: string[] = [];
|
|
988
|
+
const callAllowances = new Set<string>();
|
|
895
989
|
let enabledNotified = false;
|
|
896
990
|
let sandboxDisabled = false;
|
|
897
991
|
let configuredShell: string | undefined;
|
|
@@ -905,6 +999,40 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
905
999
|
return [...config.filesystem.allowWrite, ...sessionAllowedWritePaths];
|
|
906
1000
|
}
|
|
907
1001
|
|
|
1002
|
+
function getEffectiveAllowedDomains(config: SandboxConfig): string[] {
|
|
1003
|
+
return [...config.network.allowedDomains, ...sessionAllowedDomains];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function allowanceKey(callID: string, kind: SandboxPermissionKind, resource: string): string {
|
|
1007
|
+
return `${callID}:${kind}:${resource}`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function rememberCallAllowance(
|
|
1011
|
+
callID: string | undefined,
|
|
1012
|
+
decision: SandboxPermissionDecision,
|
|
1013
|
+
): void {
|
|
1014
|
+
if (!callID || decision.status === 'deny') return;
|
|
1015
|
+
callAllowances.add(allowanceKey(callID, decision.kind, decision.resource));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function hasCallAllowance(callID: string, decision: SandboxPermissionDecision): boolean {
|
|
1019
|
+
return callAllowances.has(allowanceKey(callID, decision.kind, decision.resource));
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function enforcePermission(callID: string, decision: SandboxPermissionDecision): void {
|
|
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);
|
|
1033
|
+
throw errorWithConfigPaths(directory, decision.message);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
908
1036
|
function pushCommandText(
|
|
909
1037
|
input: { sessionID: string },
|
|
910
1038
|
output: { parts: unknown[] },
|
|
@@ -922,7 +1050,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
922
1050
|
function sandboxSummary(config: SandboxConfig): string {
|
|
923
1051
|
const { globalPath, projectPath } = getConfigPaths(directory);
|
|
924
1052
|
const networkMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
|
|
925
|
-
const allowed = config.
|
|
1053
|
+
const allowed = getEffectiveAllowedDomains(config).join(', ') || '(none)';
|
|
926
1054
|
const denied = config.network.deniedDomains.join(', ') || '(none)';
|
|
927
1055
|
const denyRead = config.filesystem.denyRead.join(', ') || '(none)';
|
|
928
1056
|
const allowRead = getEffectiveAllowRead(config).join(', ') || '(none)';
|
|
@@ -933,7 +1061,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
933
1061
|
'# Sandbox Configuration',
|
|
934
1062
|
'',
|
|
935
1063
|
`Status: ${sandboxDisabled ? 'disabled for this session' : 'active'}`,
|
|
936
|
-
`landstrip: ${
|
|
1064
|
+
`landstrip package binary: ${landstripBinaryPath()}`,
|
|
937
1065
|
'',
|
|
938
1066
|
'Config files:',
|
|
939
1067
|
`- project: ${projectPath}`,
|
|
@@ -1020,7 +1148,17 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1020
1148
|
return landstripCheck;
|
|
1021
1149
|
}
|
|
1022
1150
|
|
|
1023
|
-
|
|
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
|
+
|
|
1024
1162
|
if (!version) {
|
|
1025
1163
|
landstripCheck = {
|
|
1026
1164
|
ok: false,
|
|
@@ -1032,7 +1170,7 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1032
1170
|
if (!hasMinimumVersion(version, LANDSTRIP_VERSION)) {
|
|
1033
1171
|
landstripCheck = {
|
|
1034
1172
|
ok: false,
|
|
1035
|
-
reason: `landstrip
|
|
1173
|
+
reason: `landstrip ${REQUIRED_LANDSTRIP_VERSION} or newer is required; found: ${version}`,
|
|
1036
1174
|
};
|
|
1037
1175
|
return landstripCheck;
|
|
1038
1176
|
}
|
|
@@ -1045,7 +1183,14 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1045
1183
|
if (sandboxDisabled) return null;
|
|
1046
1184
|
|
|
1047
1185
|
const config = loadConfig(directory, optionOverrides);
|
|
1048
|
-
if (!config.enabled)
|
|
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
|
+
}
|
|
1049
1194
|
|
|
1050
1195
|
const check = checkLandstrip();
|
|
1051
1196
|
if (!check?.ok) {
|
|
@@ -1130,27 +1275,37 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1130
1275
|
}
|
|
1131
1276
|
|
|
1132
1277
|
const allowNetwork = config.network.allowNetwork;
|
|
1278
|
+
const callAllowedDomains: string[] = [];
|
|
1279
|
+
const effectiveConfig = {
|
|
1280
|
+
...config,
|
|
1281
|
+
network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
|
|
1282
|
+
};
|
|
1133
1283
|
|
|
1134
1284
|
if (!allowNetwork) {
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
`Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
|
|
1144
|
-
);
|
|
1285
|
+
for (const domain of extractDomainsFromCommand(args.command as string)) {
|
|
1286
|
+
const decision = evaluateDomainPermission(domain, effectiveConfig);
|
|
1287
|
+
if (decision.status === 'allow') continue;
|
|
1288
|
+
if (decision.status === 'ask' && hasCallAllowance(callID, decision)) {
|
|
1289
|
+
callAllowedDomains.push(domain);
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
throw errorWithConfigPaths(directory, decision.message);
|
|
1145
1293
|
}
|
|
1146
1294
|
}
|
|
1147
1295
|
|
|
1148
|
-
|
|
1296
|
+
if (callAllowedDomains.length > 0) {
|
|
1297
|
+
effectiveConfig.network = {
|
|
1298
|
+
...effectiveConfig.network,
|
|
1299
|
+
allowedDomains: [...effectiveConfig.network.allowedDomains, ...callAllowedDomains],
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const proxy = allowNetwork ? null : await startProxy(effectiveConfig);
|
|
1149
1304
|
const proxyPort = proxy ? proxy.port : null;
|
|
1150
1305
|
let policy: { dir: string; path: string };
|
|
1151
1306
|
|
|
1152
1307
|
try {
|
|
1153
|
-
policy = writePolicyFile(
|
|
1308
|
+
policy = writePolicyFile(effectiveConfig, directory, proxyPort);
|
|
1154
1309
|
} catch (error) {
|
|
1155
1310
|
if (proxy) await proxy.stop().catch(() => undefined);
|
|
1156
1311
|
throw error;
|
|
@@ -1181,6 +1336,80 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1181
1336
|
configuredShell = configuredShellPath(config);
|
|
1182
1337
|
},
|
|
1183
1338
|
|
|
1339
|
+
'permission.ask': async (input, output) => {
|
|
1340
|
+
const config = await activeConfig();
|
|
1341
|
+
if (!config) return;
|
|
1342
|
+
|
|
1343
|
+
const request = input as Record<string, unknown>;
|
|
1344
|
+
const permission =
|
|
1345
|
+
typeof request.type === 'string'
|
|
1346
|
+
? request.type
|
|
1347
|
+
: typeof request.permission === 'string'
|
|
1348
|
+
? request.permission
|
|
1349
|
+
: typeof request.action === 'string'
|
|
1350
|
+
? request.action
|
|
1351
|
+
: '';
|
|
1352
|
+
const metadata = isRecord(request.metadata) ? request.metadata : {};
|
|
1353
|
+
const tool = isRecord(request.tool) ? request.tool : undefined;
|
|
1354
|
+
const callID =
|
|
1355
|
+
typeof request.callID === 'string'
|
|
1356
|
+
? request.callID
|
|
1357
|
+
: typeof tool?.callID === 'string'
|
|
1358
|
+
? tool.callID
|
|
1359
|
+
: undefined;
|
|
1360
|
+
const patterns = Array.isArray(request.patterns)
|
|
1361
|
+
? request.patterns.filter((item): item is string => typeof item === 'string')
|
|
1362
|
+
: typeof request.pattern === 'string'
|
|
1363
|
+
? [request.pattern]
|
|
1364
|
+
: Array.isArray(request.resources)
|
|
1365
|
+
? request.resources.filter((item): item is string => typeof item === 'string')
|
|
1366
|
+
: [];
|
|
1367
|
+
|
|
1368
|
+
const decisions: SandboxPermissionDecision[] = [];
|
|
1369
|
+
const effectiveAllowRead = getEffectiveAllowRead(config);
|
|
1370
|
+
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1371
|
+
|
|
1372
|
+
if (permission === 'read') {
|
|
1373
|
+
for (const pattern of patterns) {
|
|
1374
|
+
decisions.push(evaluateReadPermission(pattern, config, directory, effectiveAllowRead));
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
if (permission === 'glob' || permission === 'grep' || permission === 'list') {
|
|
1379
|
+
const searchPath = typeof metadata.path === 'string' ? metadata.path : '.';
|
|
1380
|
+
decisions.push(evaluateReadPermission(searchPath, config, directory, effectiveAllowRead));
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (permission === 'edit') {
|
|
1384
|
+
const filepath =
|
|
1385
|
+
typeof metadata.filepath === 'string'
|
|
1386
|
+
? metadata.filepath
|
|
1387
|
+
: patterns.length === 1
|
|
1388
|
+
? patterns[0]
|
|
1389
|
+
: undefined;
|
|
1390
|
+
if (filepath) {
|
|
1391
|
+
decisions.push(evaluateWritePermission(filepath, config, directory, effectiveAllowWrite));
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (permission === 'bash') {
|
|
1396
|
+
const command = typeof metadata.command === 'string' ? metadata.command : patterns[0];
|
|
1397
|
+
if (typeof command === 'string' && !config.network.allowNetwork) {
|
|
1398
|
+
for (const domain of extractDomainsFromCommand(command)) {
|
|
1399
|
+
decisions.push(evaluateDomainPermission(domain, config));
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const decision =
|
|
1405
|
+
decisions.find((item) => item.status === 'deny') ??
|
|
1406
|
+
decisions.find((item) => item.status === 'ask');
|
|
1407
|
+
if (!decision) return;
|
|
1408
|
+
|
|
1409
|
+
output.status = decision.status;
|
|
1410
|
+
rememberCallAllowance(callID, decision);
|
|
1411
|
+
},
|
|
1412
|
+
|
|
1184
1413
|
'tool.execute.before': async (input, output) => {
|
|
1185
1414
|
if (!isRecord(output.args)) return;
|
|
1186
1415
|
|
|
@@ -1197,23 +1426,40 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1197
1426
|
|
|
1198
1427
|
if (input.tool === 'read') {
|
|
1199
1428
|
const path = getToolPath(output.args);
|
|
1200
|
-
if (path)
|
|
1429
|
+
if (path)
|
|
1430
|
+
enforcePermission(
|
|
1431
|
+
input.callID,
|
|
1432
|
+
evaluateReadPermission(path, config, directory, effectiveAllowRead),
|
|
1433
|
+
);
|
|
1201
1434
|
return;
|
|
1202
1435
|
}
|
|
1203
1436
|
|
|
1204
1437
|
if (input.tool === 'glob' || input.tool === 'grep' || input.tool === 'list') {
|
|
1205
|
-
|
|
1438
|
+
enforcePermission(
|
|
1439
|
+
input.callID,
|
|
1440
|
+
evaluateReadPermission(getSearchPath(output.args), config, directory, effectiveAllowRead),
|
|
1441
|
+
);
|
|
1206
1442
|
return;
|
|
1207
1443
|
}
|
|
1208
1444
|
|
|
1209
1445
|
if (input.tool === 'write' || input.tool === 'edit') {
|
|
1210
1446
|
const path = getToolPath(output.args);
|
|
1211
|
-
if (path)
|
|
1447
|
+
if (path)
|
|
1448
|
+
enforcePermission(
|
|
1449
|
+
input.callID,
|
|
1450
|
+
evaluateWritePermission(path, config, directory, effectiveAllowWrite),
|
|
1451
|
+
);
|
|
1212
1452
|
return;
|
|
1213
1453
|
}
|
|
1214
1454
|
|
|
1215
|
-
if (input.tool === 'apply_patch')
|
|
1216
|
-
|
|
1455
|
+
if (input.tool === 'apply_patch' && typeof output.args.patchText === 'string') {
|
|
1456
|
+
for (const path of extractPatchPaths(output.args.patchText)) {
|
|
1457
|
+
enforcePermission(
|
|
1458
|
+
input.callID,
|
|
1459
|
+
evaluateWritePermission(path, config, directory, effectiveAllowWrite),
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1217
1463
|
},
|
|
1218
1464
|
|
|
1219
1465
|
'shell.env': async (input, output) => {
|
|
@@ -1258,31 +1504,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1258
1504
|
|
|
1259
1505
|
const blockedPath = extractBlockedWritePath(outputText, directory, state.originalCommand);
|
|
1260
1506
|
if (blockedPath) {
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
) {
|
|
1267
|
-
sessionAllowedWritePaths.push(blockedPath);
|
|
1268
|
-
await notifyOnce(
|
|
1269
|
-
`write-allow:${blockedPath}`,
|
|
1270
|
-
`Write access granted for session: "${blockedPath}"`,
|
|
1271
|
-
'warning',
|
|
1272
|
-
);
|
|
1273
|
-
}
|
|
1274
|
-
if (
|
|
1275
|
-
!sessionAllowedReadPaths.includes(blockedPath) &&
|
|
1276
|
-
!matchesPattern(blockedPath, config.filesystem.allowRead, directory) &&
|
|
1277
|
-
!matchesPattern(blockedPath, config.filesystem.denyRead, directory)
|
|
1278
|
-
) {
|
|
1279
|
-
sessionAllowedReadPaths.push(blockedPath);
|
|
1280
|
-
await notifyOnce(
|
|
1281
|
-
`read-allow:${blockedPath}`,
|
|
1282
|
-
`Read access granted for session: "${blockedPath}"`,
|
|
1283
|
-
'warning',
|
|
1284
|
-
);
|
|
1285
|
-
}
|
|
1507
|
+
await notifyOnce(
|
|
1508
|
+
`blocked:${blockedPath}`,
|
|
1509
|
+
`Sandbox blocked access to "${blockedPath}". Approve the related OpenCode permission prompt and retry if needed.`,
|
|
1510
|
+
'warning',
|
|
1511
|
+
);
|
|
1286
1512
|
}
|
|
1287
1513
|
|
|
1288
1514
|
await cleanupBash(input.callID);
|
|
@@ -1292,6 +1518,11 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1292
1518
|
if (input.command.trim() === '/sandbox') {
|
|
1293
1519
|
const config = loadConfig(directory, optionOverrides);
|
|
1294
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);
|
|
1295
1526
|
return;
|
|
1296
1527
|
}
|
|
1297
1528
|
|
|
@@ -1310,6 +1541,15 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1310
1541
|
output,
|
|
1311
1542
|
'Sandbox disabled for this session. Use /sandbox-enable to re-enable.',
|
|
1312
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);
|
|
1313
1553
|
return;
|
|
1314
1554
|
}
|
|
1315
1555
|
|
|
@@ -1323,7 +1563,30 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1323
1563
|
return;
|
|
1324
1564
|
}
|
|
1325
1565
|
sandboxDisabled = false;
|
|
1326
|
-
|
|
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
|
+
}
|
|
1327
1590
|
return;
|
|
1328
1591
|
}
|
|
1329
1592
|
|
|
@@ -1337,17 +1600,60 @@ const plugin: Plugin = async ({ client, directory }: PluginInput, options?: Plug
|
|
|
1337
1600
|
const effectiveAllowWrite = getEffectiveAllowWrite(config);
|
|
1338
1601
|
|
|
1339
1602
|
for (const path of extractCandidatePaths(shellCommand)) {
|
|
1340
|
-
|
|
1341
|
-
|
|
1603
|
+
const readDecision = evaluateReadPermission(path, config, directory, effectiveAllowRead);
|
|
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);
|
|
1614
|
+
throw errorWithConfigPaths(directory, readDecision.message);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const writeDecision = evaluateWritePermission(
|
|
1618
|
+
path,
|
|
1619
|
+
config,
|
|
1620
|
+
directory,
|
|
1621
|
+
effectiveAllowWrite,
|
|
1622
|
+
);
|
|
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);
|
|
1633
|
+
throw errorWithConfigPaths(directory, writeDecision.message);
|
|
1634
|
+
}
|
|
1342
1635
|
}
|
|
1343
1636
|
|
|
1344
1637
|
if (!config.network.allowNetwork) {
|
|
1345
|
-
const
|
|
1638
|
+
const effectiveConfig = {
|
|
1639
|
+
...config,
|
|
1640
|
+
network: { ...config.network, allowedDomains: getEffectiveAllowedDomains(config) },
|
|
1641
|
+
};
|
|
1642
|
+
const blockedDomain = firstBlockedDomain(shellCommand, effectiveConfig);
|
|
1346
1643
|
if (blockedDomain) {
|
|
1347
1644
|
const reason =
|
|
1348
1645
|
blockedDomain.reason === 'deniedDomains'
|
|
1349
1646
|
? 'is blocked by network.deniedDomains'
|
|
1350
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);
|
|
1351
1657
|
throw errorWithConfigPaths(
|
|
1352
1658
|
directory,
|
|
1353
1659
|
`Sandbox: network access denied for "${blockedDomain.domain}" (${reason}).`,
|