@skanl/brambo-sandbox-local 0.1.1
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/LICENSE +21 -0
- package/dist/cgroup.d.ts +19 -0
- package/dist/cgroup.js +74 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +15 -0
- package/dist/linux.d.ts +9 -0
- package/dist/linux.js +88 -0
- package/dist/macos.d.ts +9 -0
- package/dist/macos.js +79 -0
- package/dist/shared.d.ts +31 -0
- package/dist/shared.js +657 -0
- package/dist/windows.d.ts +6 -0
- package/dist/windows.js +117 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SKANL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/cgroup.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CgroupFilesystem {
|
|
2
|
+
readFile(path: string): Promise<string>;
|
|
3
|
+
mkdir(path: string): Promise<void>;
|
|
4
|
+
writeFile(path: string, value: string): Promise<void>;
|
|
5
|
+
rm(path: string): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface CgroupSession {
|
|
8
|
+
/** Whether this session can place a child in the cgroup before it executes. */
|
|
9
|
+
readonly containsStartup: boolean;
|
|
10
|
+
attach(pid: number | undefined): Promise<void>;
|
|
11
|
+
teardown(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare function detectCgroupV2(filesystem?: CgroupFilesystem, root?: string): Promise<boolean>;
|
|
14
|
+
export declare function createCgroupSession(filesystem?: CgroupFilesystem, root?: string, limits?: {
|
|
15
|
+
readonly memoryBytes?: number;
|
|
16
|
+
readonly processCount?: number;
|
|
17
|
+
readonly cpuQuotaMicros?: number;
|
|
18
|
+
readonly cpuPeriodMicros?: number;
|
|
19
|
+
}): Promise<CgroupSession>;
|
package/dist/cgroup.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
|
|
3
|
+
const nativeFilesystem = { readFile: (path) => readFile(path, 'utf8'), mkdir: (path) => mkdir(path), writeFile: (path, value) => writeFile(path, value), rm: (path) => rm(path, { recursive: true, force: true }) };
|
|
4
|
+
function unavailable(message, cause) {
|
|
5
|
+
return new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, message, { cause });
|
|
6
|
+
}
|
|
7
|
+
export async function detectCgroupV2(filesystem = nativeFilesystem, root = '/sys/fs/cgroup') {
|
|
8
|
+
try {
|
|
9
|
+
const controllers = (await filesystem.readFile(`${root}/cgroup.controllers`)).split(/\s+/).filter(Boolean);
|
|
10
|
+
return controllers.includes('memory') && controllers.includes('pids');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function createCgroupSession(filesystem = nativeFilesystem, root = '/sys/fs/cgroup', limits = {}) {
|
|
17
|
+
try {
|
|
18
|
+
const controllers = (await filesystem.readFile(`${root}/cgroup.controllers`)).split(/\s+/).filter(Boolean);
|
|
19
|
+
if (limits.memoryBytes !== undefined && !controllers.includes('memory'))
|
|
20
|
+
throw new Error('cgroup v2 memory controller is unavailable');
|
|
21
|
+
if (limits.processCount !== undefined && !controllers.includes('pids'))
|
|
22
|
+
throw new Error('cgroup v2 pids controller is unavailable');
|
|
23
|
+
if (limits.cpuQuotaMicros !== undefined && !controllers.includes('cpu'))
|
|
24
|
+
throw new Error('cgroup v2 cpu controller is unavailable');
|
|
25
|
+
if (limits.cpuQuotaMicros !== undefined && limits.cpuPeriodMicros === undefined)
|
|
26
|
+
throw new Error('cgroup v2 CPU quota requires a period');
|
|
27
|
+
if (limits.cpuQuotaMicros === undefined && limits.cpuPeriodMicros !== undefined)
|
|
28
|
+
throw new Error('cgroup v2 CPU period requires a quota');
|
|
29
|
+
const path = `${root}/brambo-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
30
|
+
await filesystem.mkdir(path);
|
|
31
|
+
try {
|
|
32
|
+
if (limits.memoryBytes !== undefined)
|
|
33
|
+
await filesystem.writeFile(`${path}/memory.max`, String(limits.memoryBytes));
|
|
34
|
+
if (limits.processCount !== undefined)
|
|
35
|
+
await filesystem.writeFile(`${path}/pids.max`, String(limits.processCount));
|
|
36
|
+
if (limits.cpuQuotaMicros !== undefined && limits.cpuPeriodMicros !== undefined) {
|
|
37
|
+
await filesystem.writeFile(`${path}/cpu.max`, `${limits.cpuQuotaMicros} ${limits.cpuPeriodMicros}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
await filesystem.rm(path).catch(() => undefined);
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
// Writing a PID after node:child_process.spawn has returned leaves an
|
|
46
|
+
// unbounded execution window. This direct cgroup-v2 implementation has no
|
|
47
|
+
// pre-exec hook, so callers must refuse resource-limited execution.
|
|
48
|
+
containsStartup: false,
|
|
49
|
+
async attach(pid) {
|
|
50
|
+
try {
|
|
51
|
+
if (pid === undefined)
|
|
52
|
+
throw new Error('sandbox child has no pid for cgroup attachment');
|
|
53
|
+
await filesystem.writeFile(`${path}/cgroup.procs`, String(pid));
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw unavailable('sandbox cgroup attachment failed', error);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async teardown() {
|
|
60
|
+
try {
|
|
61
|
+
await filesystem.rm(path);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw unavailable('sandbox cgroup teardown failed', error);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof BramboError)
|
|
71
|
+
throw error;
|
|
72
|
+
throw unavailable('sandbox cgroup setup failed', error);
|
|
73
|
+
}
|
|
74
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LocalSandboxProvider, LocalSandboxProviderOptions } from './shared.ts';
|
|
2
|
+
export type { LocalDiscovery, LocalPlatform, LocalSandboxProvider, LocalSandboxProviderOptions } from './shared.ts';
|
|
3
|
+
export { createLinuxSandboxProvider } from './linux.ts';
|
|
4
|
+
export { createMacosSandboxProvider } from './macos.ts';
|
|
5
|
+
export { createWindowsSandboxProvider } from './windows.ts';
|
|
6
|
+
/** Dispatches by process.platform unless an explicit platform is supplied for deterministic tests. */
|
|
7
|
+
export declare function createLocalSandboxProvider(options?: LocalSandboxProviderOptions): Promise<LocalSandboxProvider>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createLinuxSandboxProvider } from './linux.js';
|
|
2
|
+
import { createMacosSandboxProvider } from './macos.js';
|
|
3
|
+
import { createWindowsSandboxProvider } from './windows.js';
|
|
4
|
+
export { createLinuxSandboxProvider } from './linux.js';
|
|
5
|
+
export { createMacosSandboxProvider } from './macos.js';
|
|
6
|
+
export { createWindowsSandboxProvider } from './windows.js';
|
|
7
|
+
/** Dispatches by process.platform unless an explicit platform is supplied for deterministic tests. */
|
|
8
|
+
export async function createLocalSandboxProvider(options = {}) {
|
|
9
|
+
switch (options.platform ?? process.platform) {
|
|
10
|
+
case 'linux': return createLinuxSandboxProvider(options);
|
|
11
|
+
case 'darwin': return createMacosSandboxProvider(options);
|
|
12
|
+
case 'win32': return createWindowsSandboxProvider(options);
|
|
13
|
+
default: throw new Error(`local sandbox provider is unavailable on '${options.platform ?? process.platform}'`);
|
|
14
|
+
}
|
|
15
|
+
}
|
package/dist/linux.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SandboxExecutionRequest } from '@skanl/brambo-contracts';
|
|
2
|
+
import type { LocalSandboxAuditCallback, LocalSandboxProvider, LocalSandboxProviderOptions } from './shared.ts';
|
|
3
|
+
export type LinuxSandboxProviderOptions = LocalSandboxProviderOptions & {
|
|
4
|
+
readonly audit?: LocalSandboxAuditCallback;
|
|
5
|
+
};
|
|
6
|
+
/** Builds only bwrap tokens. The target argv is appended after `--` without shell interpretation. */
|
|
7
|
+
export declare function buildBubblewrapArgv(request: SandboxExecutionRequest): readonly [string, ...string[]];
|
|
8
|
+
export declare function buildPrlimitArgv(request: SandboxExecutionRequest, baseArgv: readonly [string, ...string[]]): readonly [string, ...string[]];
|
|
9
|
+
export declare function createLinuxSandboxProvider(options: LinuxSandboxProviderOptions): Promise<LocalSandboxProvider>;
|
package/dist/linux.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createProvider, DEFAULT_TIMEOUT_MS, probe } from './shared.js';
|
|
3
|
+
import { createCgroupSession, detectCgroupV2 } from './cgroup.js';
|
|
4
|
+
const RUNTIME_DIRECTORIES = ['/usr', '/bin', '/lib', '/lib64', '/etc'];
|
|
5
|
+
function directoriesToCreate(path) {
|
|
6
|
+
const segments = path.split('/').filter(Boolean);
|
|
7
|
+
return segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}`);
|
|
8
|
+
}
|
|
9
|
+
/** Builds only bwrap tokens. The target argv is appended after `--` without shell interpretation. */
|
|
10
|
+
export function buildBubblewrapArgv(request) {
|
|
11
|
+
const argv = [
|
|
12
|
+
'bwrap', '--die-with-parent', '--new-session', '--unshare-user', '--unshare-pid',
|
|
13
|
+
...(request.policy.networkMode === 'unrestricted' ? [] : ['--unshare-net']), '--clearenv',
|
|
14
|
+
'--tmpfs', '/', '--proc', '/proc', '--dev', '/dev',
|
|
15
|
+
'--dir', '/tmp', '--tmpfs', '/tmp',
|
|
16
|
+
];
|
|
17
|
+
for (const directory of RUNTIME_DIRECTORIES)
|
|
18
|
+
argv.push('--dir', directory, '--ro-bind', directory, directory);
|
|
19
|
+
for (const directory of directoriesToCreate(request.policy.workspaceRoot)) {
|
|
20
|
+
if (!RUNTIME_DIRECTORIES.includes(directory) && directory !== '/tmp')
|
|
21
|
+
argv.push('--dir', directory);
|
|
22
|
+
}
|
|
23
|
+
argv.push(request.policy.mode === 'workspace-write' ? '--bind' : '--ro-bind', request.policy.workspaceRoot, request.policy.workspaceRoot);
|
|
24
|
+
for (const directory of directoriesToCreate(request.cwd)) {
|
|
25
|
+
if (directory !== request.policy.workspaceRoot)
|
|
26
|
+
argv.push('--dir', directory);
|
|
27
|
+
}
|
|
28
|
+
argv.push('--chdir', request.cwd);
|
|
29
|
+
for (const [key, value] of Object.entries(request.environment)) {
|
|
30
|
+
if (!/(?:token|secret|password|credential|api[_-]?key|authorization|cookie)/i.test(key))
|
|
31
|
+
argv.push('--setenv', key, value);
|
|
32
|
+
}
|
|
33
|
+
argv.push('--', ...request.argv);
|
|
34
|
+
return argv;
|
|
35
|
+
}
|
|
36
|
+
async function functionalBubblewrap() {
|
|
37
|
+
const argv = buildBubblewrapArgv({
|
|
38
|
+
argv: ['/bin/true'], cwd: '/tmp', environment: {},
|
|
39
|
+
policy: { version: 1, mode: 'read-only', workspaceRoot: '/tmp', requiredCapabilities: { filesystem: 'full' } },
|
|
40
|
+
});
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: 'ignore', windowsHide: true });
|
|
43
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 3_000);
|
|
44
|
+
child.once('error', () => { clearTimeout(timer); resolve(false); });
|
|
45
|
+
child.once('close', (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function functionalPrlimit(options) {
|
|
49
|
+
const argv = ['/usr/bin/prlimit', '--version'];
|
|
50
|
+
if (options.inspect !== undefined)
|
|
51
|
+
return options.inspect(argv);
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const child = spawn(argv[0], [argv[1]], { shell: false, stdio: 'ignore', windowsHide: true });
|
|
54
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(false); }, 3_000);
|
|
55
|
+
child.once('error', () => { clearTimeout(timer); resolve(false); });
|
|
56
|
+
child.once('close', (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export function buildPrlimitArgv(request, baseArgv) {
|
|
60
|
+
const fileSizeBytes = request.policy.resourceLimits?.fileSizeBytes;
|
|
61
|
+
if (fileSizeBytes === undefined)
|
|
62
|
+
return baseArgv;
|
|
63
|
+
return ['/usr/bin/prlimit', `--fsize=${fileSizeBytes}`, '--', ...baseArgv];
|
|
64
|
+
}
|
|
65
|
+
export async function createLinuxSandboxProvider(options) {
|
|
66
|
+
const isLinux = (options.platform ?? process.platform) === 'linux';
|
|
67
|
+
const bubblewrap = isLinux && await functionalBubblewrap();
|
|
68
|
+
const prlimit = isLinux && await functionalPrlimit(options);
|
|
69
|
+
const landlock = isLinux && (await probe(options, ['landlock', '--version']));
|
|
70
|
+
const cgroup = isLinux && await detectCgroupV2(options.cgroupFilesystem, options.cgroupRoot);
|
|
71
|
+
return createProvider('local-linux', { bubblewrap, landlock, cgroup, seatbelt: false, windowsSandboxBroker: false, jobObjectHelper: false }, bubblewrap ? 'full' : 'none', options.timeoutMs ?? DEFAULT_TIMEOUT_MS, (request) => buildPrlimitArgv(request, bubblewrap ? buildBubblewrapArgv(request) : request.argv), options.runner, options.audit, {
|
|
72
|
+
network: bubblewrap ? 'full' : 'none',
|
|
73
|
+
process: bubblewrap ? 'full' : 'none',
|
|
74
|
+
resources: cgroup ? 'full' : 'none',
|
|
75
|
+
}, async (policy) => {
|
|
76
|
+
const limits = policy.resourceLimits;
|
|
77
|
+
if (limits === undefined || (limits.memoryBytes === undefined &&
|
|
78
|
+
limits.processCount === undefined &&
|
|
79
|
+
limits.cpuQuotaMicros === undefined &&
|
|
80
|
+
limits.cpuPeriodMicros === undefined))
|
|
81
|
+
return undefined;
|
|
82
|
+
if (!cgroup)
|
|
83
|
+
throw new Error(limits.memoryBytes !== undefined || limits.processCount !== undefined
|
|
84
|
+
? 'requested resource limits require cgroup v2 memory/pids enforcement'
|
|
85
|
+
: 'requested CPU limits require cgroup v2 CPU enforcement');
|
|
86
|
+
return createCgroupSession(options.cgroupFilesystem, options.cgroupRoot, limits);
|
|
87
|
+
}, prlimit ? ['fileSizeBytes'] : [], bubblewrap ? ['unrestricted'] : []);
|
|
88
|
+
}
|
package/dist/macos.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SandboxExecutionRequest } from '@skanl/brambo-contracts';
|
|
2
|
+
import type { LocalSandboxAuditCallback, LocalSandboxProvider, LocalSandboxProviderOptions } from './shared.ts';
|
|
3
|
+
type MacosSandboxProviderOptions = LocalSandboxProviderOptions & {
|
|
4
|
+
readonly audit?: LocalSandboxAuditCallback;
|
|
5
|
+
};
|
|
6
|
+
/** Builds a Seatbelt profile and exact sandbox-exec argv; the target command is never interpreted by a shell. */
|
|
7
|
+
export declare function buildSeatbeltArgv(request: SandboxExecutionRequest): readonly [string, ...string[]];
|
|
8
|
+
export declare function createMacosSandboxProvider(options: MacosSandboxProviderOptions): Promise<LocalSandboxProvider>;
|
|
9
|
+
export {};
|
package/dist/macos.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createProvider, DEFAULT_TIMEOUT_MS, probe } from './shared.js';
|
|
7
|
+
const SYSTEM_READ_PATHS = ['/System', '/usr', '/bin', '/sbin', '/Library', '/private/etc', '/opt/homebrew'];
|
|
8
|
+
const SENSITIVE_ENVIRONMENT = /(?:token|secret|password|credential|api[_-]?key|authorization|cookie)/i;
|
|
9
|
+
const ENVIRONMENT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10
|
+
function quoted(value) {
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
}
|
|
13
|
+
function safeEnvironment(environment) {
|
|
14
|
+
return Object.entries(environment)
|
|
15
|
+
.filter(([key]) => ENVIRONMENT_KEY.test(key) && !SENSITIVE_ENVIRONMENT.test(key))
|
|
16
|
+
.map(([key, value]) => `${key}=${value}`);
|
|
17
|
+
}
|
|
18
|
+
/** Builds a Seatbelt profile and exact sandbox-exec argv; the target command is never interpreted by a shell. */
|
|
19
|
+
export function buildSeatbeltArgv(request) {
|
|
20
|
+
const workspace = quoted(request.policy.workspaceRoot);
|
|
21
|
+
const profile = [
|
|
22
|
+
'(version 1)',
|
|
23
|
+
'(deny default)',
|
|
24
|
+
'(import "system.sb")',
|
|
25
|
+
'(deny network*)',
|
|
26
|
+
'(allow process-exec)',
|
|
27
|
+
'(allow process-fork)',
|
|
28
|
+
'(allow signal (target same-sandbox))',
|
|
29
|
+
'(allow sysctl-read)',
|
|
30
|
+
'(allow mach-lookup)',
|
|
31
|
+
'(allow file-read-metadata)',
|
|
32
|
+
...SYSTEM_READ_PATHS.map((path) => `(allow file-read* (subpath ${quoted(path)}))`),
|
|
33
|
+
'(allow file-read* (literal "/dev/null"))',
|
|
34
|
+
'(allow file-read* (literal "/dev/urandom"))',
|
|
35
|
+
`(allow file-read* (subpath ${workspace}))`,
|
|
36
|
+
...(request.policy.mode === 'workspace-write' ? [`(allow file-write* (subpath ${workspace}))`] : []),
|
|
37
|
+
].join('\n');
|
|
38
|
+
return [
|
|
39
|
+
'sandbox-exec', '-p', profile, '/usr/bin/env', '-i', 'PATH=/usr/bin:/bin:/usr/sbin:/sbin',
|
|
40
|
+
...safeEnvironment(request.environment), '--', ...request.argv,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
function run(argv) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: 'ignore', windowsHide: true });
|
|
46
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 3_000);
|
|
47
|
+
child.once('error', () => { clearTimeout(timer); resolve(false); });
|
|
48
|
+
child.once('close', (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async function functionalSeatbelt() {
|
|
52
|
+
const workspace = await mkdtemp(join(tmpdir(), 'brambo-seatbelt-'));
|
|
53
|
+
try {
|
|
54
|
+
const writableFile = join(workspace, 'writable');
|
|
55
|
+
const deniedFile = join(workspace, 'denied');
|
|
56
|
+
const writable = await run(buildSeatbeltArgv({
|
|
57
|
+
argv: ['/usr/bin/touch', writableFile], cwd: workspace, environment: {},
|
|
58
|
+
policy: { version: 1, mode: 'workspace-write', workspaceRoot: workspace, requiredCapabilities: { filesystem: 'full' } },
|
|
59
|
+
}));
|
|
60
|
+
if (!writable || !existsSync(writableFile))
|
|
61
|
+
return false;
|
|
62
|
+
const readOnly = await run(buildSeatbeltArgv({
|
|
63
|
+
argv: ['/usr/bin/touch', deniedFile], cwd: workspace, environment: {},
|
|
64
|
+
policy: { version: 1, mode: 'read-only', workspaceRoot: workspace, requiredCapabilities: { filesystem: 'full' } },
|
|
65
|
+
}));
|
|
66
|
+
return !readOnly && !existsSync(deniedFile);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await rm(workspace, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function createMacosSandboxProvider(options) {
|
|
76
|
+
const available = process.platform === 'darwin' && await probe(options, ['sandbox-exec', '-h']);
|
|
77
|
+
const seatbelt = available && await functionalSeatbelt();
|
|
78
|
+
return createProvider('local-macos', { bubblewrap: false, landlock: false, cgroup: false, seatbelt, windowsSandboxBroker: false, jobObjectHelper: false }, seatbelt ? 'full' : 'none', options.timeoutMs ?? DEFAULT_TIMEOUT_MS, seatbelt ? buildSeatbeltArgv : undefined, undefined, options.audit);
|
|
79
|
+
}
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ChildProcess, SpawnOptions } from 'node:child_process';
|
|
2
|
+
import type { SandboxCapabilityFacts, SandboxAuditEvent, SandboxControlEvidence, SandboxExecutionRequest, SandboxNetworkMode, SandboxProvider, SandboxSessionRequest } from '@skanl/brambo-contracts';
|
|
3
|
+
import type { CgroupSession, CgroupFilesystem } from './cgroup.ts';
|
|
4
|
+
declare const DEFAULT_TIMEOUT_MS: number;
|
|
5
|
+
export type LocalPlatform = 'linux' | 'darwin' | 'win32';
|
|
6
|
+
export interface LocalSandboxProviderOptions {
|
|
7
|
+
readonly platform?: NodeJS.Platform;
|
|
8
|
+
/** Test seam for executable probes. It never establishes full enforcement. */
|
|
9
|
+
readonly inspect?: (argv: readonly [string, ...string[]]) => Promise<boolean>;
|
|
10
|
+
readonly timeoutMs?: number;
|
|
11
|
+
readonly runner?: LocalSandboxRunner;
|
|
12
|
+
readonly cgroupFilesystem?: CgroupFilesystem;
|
|
13
|
+
readonly cgroupRoot?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface LocalDiscovery {
|
|
16
|
+
readonly bubblewrap: boolean;
|
|
17
|
+
readonly landlock: boolean;
|
|
18
|
+
readonly cgroup: boolean;
|
|
19
|
+
readonly seatbelt: boolean;
|
|
20
|
+
readonly windowsSandboxBroker: boolean;
|
|
21
|
+
readonly jobObjectHelper: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface LocalSandboxProvider extends SandboxProvider {
|
|
24
|
+
readonly discovery: LocalDiscovery;
|
|
25
|
+
}
|
|
26
|
+
export type LocalSandboxRunner = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
|
|
27
|
+
export type LocalSandboxAuditCallback = (event: SandboxAuditEvent) => void | PromiseLike<void>;
|
|
28
|
+
export declare function containedWorkspace(cwd: string, workspaceRoot: string): Promise<boolean>;
|
|
29
|
+
export declare function createProvider(id: string, discovery: LocalDiscovery, filesystem: SandboxControlEvidence, timeoutMs: number, buildArgv?: (request: SandboxExecutionRequest) => readonly [string, ...string[]], runner?: LocalSandboxRunner, audit?: LocalSandboxAuditCallback, controlEvidence?: Partial<SandboxCapabilityFacts['controls']>, cgroupFactory?: (policy: SandboxSessionRequest['policy']) => Promise<CgroupSession | undefined>, supportedResourceLimits?: readonly ('fileSizeBytes')[], supportedNetworkModes?: readonly SandboxNetworkMode[]): LocalSandboxProvider;
|
|
30
|
+
export declare function probe(options: LocalSandboxProviderOptions, argv: readonly [string, ...string[]]): Promise<boolean>;
|
|
31
|
+
export { DEFAULT_TIMEOUT_MS };
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { constants, existsSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstat, open, realpath, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { delimiter, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { BRAMBO_ERROR_CODES, BramboError, SANDBOX_ERROR_CODES, validateSandboxCapabilities, validateSandboxAuditEvent, validateSandboxExecutionRequest, validateSandboxPolicy, validateSandboxSnapshot, } from '@skanl/brambo-contracts';
|
|
7
|
+
const OUTPUT_CAP_BYTES = 1024 * 1024;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
const SENSITIVE_ENVIRONMENT = /(?:token|secret|password|credential|api[_-]?key|authorization|cookie)/i;
|
|
10
|
+
function abortedStdio(message) {
|
|
11
|
+
return new BramboError(SANDBOX_ERROR_CODES.aborted, message);
|
|
12
|
+
}
|
|
13
|
+
function unavailableStdio(message, cause) {
|
|
14
|
+
return new BramboError(SANDBOX_ERROR_CODES.unavailable, message, cause === undefined ? {} : { cause });
|
|
15
|
+
}
|
|
16
|
+
function controls(value) {
|
|
17
|
+
return Object.freeze({ filesystem: value, network: 'none', process: 'none', resources: 'none' });
|
|
18
|
+
}
|
|
19
|
+
function unavailable(enforcement, message) {
|
|
20
|
+
return {
|
|
21
|
+
status: 'unavailable', stdout: '', stderr: '', enforcement,
|
|
22
|
+
error: { code: SANDBOX_ERROR_CODES.unavailable, message },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function scrubEnvironment(environment) {
|
|
26
|
+
const scrubbed = Object.fromEntries(Object.entries(environment).filter(([key]) => !SENSITIVE_ENVIRONMENT.test(key)));
|
|
27
|
+
// Wrapper lookup must not use a PATH supplied by the execution caller.
|
|
28
|
+
scrubbed.PATH = process.env['PATH'] ?? (process.platform === 'win32' ? '' : '/usr/bin:/bin');
|
|
29
|
+
return scrubbed;
|
|
30
|
+
}
|
|
31
|
+
function hasUnsupportedResourceLimits(policy, supportsFileSize) {
|
|
32
|
+
const limits = policy.resourceLimits;
|
|
33
|
+
return limits?.fileSizeBytes !== undefined && !supportsFileSize;
|
|
34
|
+
}
|
|
35
|
+
function hasUnenforcedResourceLimits(policy, cgroup) {
|
|
36
|
+
const limits = policy.resourceLimits;
|
|
37
|
+
return cgroup === undefined && (limits?.memoryBytes !== undefined || limits?.processCount !== undefined);
|
|
38
|
+
}
|
|
39
|
+
function cannotContainStartup(policy, cgroup) {
|
|
40
|
+
const limits = policy.resourceLimits;
|
|
41
|
+
return cgroup !== undefined && !cgroup.containsStartup && (limits?.memoryBytes !== undefined || limits?.processCount !== undefined);
|
|
42
|
+
}
|
|
43
|
+
export async function containedWorkspace(cwd, workspaceRoot) {
|
|
44
|
+
try {
|
|
45
|
+
const [physicalCwd, physicalRoot] = await Promise.all([realpath(cwd), realpath(workspaceRoot)]);
|
|
46
|
+
const path = relative(physicalRoot, physicalCwd);
|
|
47
|
+
return path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function terminate(child) {
|
|
54
|
+
if (child.pid !== undefined && process.platform !== 'win32') {
|
|
55
|
+
try {
|
|
56
|
+
return process.kill(-child.pid, 'SIGKILL');
|
|
57
|
+
}
|
|
58
|
+
catch { /* fall through to direct child termination */ }
|
|
59
|
+
}
|
|
60
|
+
return child.kill('SIGKILL');
|
|
61
|
+
}
|
|
62
|
+
async function runExact(request, policy, enforcement, wallTimeMs, outputBytes, cleanupTimeoutMs, argv, runner, cgroup, isActive, register, invalidate, recordTeardownFailure) {
|
|
63
|
+
if (request.signal?.aborted)
|
|
64
|
+
return { status: 'aborted', stdout: '', stderr: '', enforcement, error: { code: SANDBOX_ERROR_CODES.aborted, message: 'sandbox process was aborted before spawn' } };
|
|
65
|
+
if (!(await containedWorkspace(request.cwd, policy.workspaceRoot)))
|
|
66
|
+
return unavailable(enforcement, 'sandbox cwd cannot be physically proven inside workspace');
|
|
67
|
+
if (!isActive())
|
|
68
|
+
return { status: 'aborted', stdout: '', stderr: '', enforcement, error: { code: SANDBOX_ERROR_CODES.aborted, message: 'sandbox session was disposed before spawn' } };
|
|
69
|
+
if (cannotContainStartup(policy, cgroup))
|
|
70
|
+
return unavailable(enforcement, 'sandbox provider cannot contain cgroup-limited process startup before execution');
|
|
71
|
+
return new Promise((resolveResult) => {
|
|
72
|
+
const [command, ...args] = argv;
|
|
73
|
+
const child = runner(command, args, {
|
|
74
|
+
cwd: request.cwd,
|
|
75
|
+
env: scrubEnvironment(request.environment),
|
|
76
|
+
shell: false,
|
|
77
|
+
detached: process.platform !== 'win32',
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
});
|
|
81
|
+
let stdout = '';
|
|
82
|
+
let stderr = '';
|
|
83
|
+
let settled = false;
|
|
84
|
+
let status;
|
|
85
|
+
let disposalTimer;
|
|
86
|
+
let attachmentComplete = cgroup === undefined;
|
|
87
|
+
let attachmentError;
|
|
88
|
+
let childClosed = false;
|
|
89
|
+
let childExitCode = null;
|
|
90
|
+
const cleanup = new Promise((resolveCleanup, rejectCleanup) => {
|
|
91
|
+
child.once('close', () => resolveCleanup());
|
|
92
|
+
child.once('error', rejectCleanup);
|
|
93
|
+
});
|
|
94
|
+
const abortForDisposal = () => {
|
|
95
|
+
status = 'aborted';
|
|
96
|
+
try {
|
|
97
|
+
if (terminate(child)) {
|
|
98
|
+
if (!settled)
|
|
99
|
+
disposalTimer = setTimeout(() => finish({ status: 'aborted', stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.aborted, message: 'sandbox process cleanup timed out during disposal' } }), cleanupTimeoutMs);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
finish({ status, stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.aborted, message: 'sandbox process could not be terminated during disposal' } });
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
finish({ status, stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.aborted, message: 'sandbox process could not be terminated during disposal' } });
|
|
108
|
+
throw new Error('child termination was not accepted');
|
|
109
|
+
};
|
|
110
|
+
register(child, cleanup, abortForDisposal);
|
|
111
|
+
const finish = (result) => {
|
|
112
|
+
if (settled)
|
|
113
|
+
return;
|
|
114
|
+
settled = true;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
if (disposalTimer !== undefined)
|
|
117
|
+
clearTimeout(disposalTimer);
|
|
118
|
+
request.signal?.removeEventListener('abort', abort);
|
|
119
|
+
resolveResult(result);
|
|
120
|
+
};
|
|
121
|
+
const truncate = (chunk, target) => {
|
|
122
|
+
const next = target === 'stdout' ? stdout + chunk.toString('utf8') : stderr + chunk.toString('utf8');
|
|
123
|
+
if (Buffer.byteLength(next) > outputBytes) {
|
|
124
|
+
status = 'failed';
|
|
125
|
+
terminate(child);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (target === 'stdout')
|
|
129
|
+
stdout = next;
|
|
130
|
+
else
|
|
131
|
+
stderr = next;
|
|
132
|
+
};
|
|
133
|
+
const abort = () => { status = 'aborted'; terminate(child); };
|
|
134
|
+
const timer = setTimeout(() => { status = 'timed-out'; terminate(child); }, wallTimeMs);
|
|
135
|
+
request.signal?.addEventListener('abort', abort, { once: true });
|
|
136
|
+
child.stdout?.on('data', (chunk) => truncate(chunk, 'stdout'));
|
|
137
|
+
child.stderr?.on('data', (chunk) => truncate(chunk, 'stderr'));
|
|
138
|
+
child.on('error', (error) => finish({ status: 'failed', stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.runnerFailed, message: error.message } }));
|
|
139
|
+
child.on('close', (exitCode) => {
|
|
140
|
+
childClosed = true;
|
|
141
|
+
childExitCode = exitCode;
|
|
142
|
+
if (!attachmentComplete)
|
|
143
|
+
return;
|
|
144
|
+
finishAfterAttachment();
|
|
145
|
+
});
|
|
146
|
+
const finishAfterAttachment = () => {
|
|
147
|
+
if (attachmentError !== undefined)
|
|
148
|
+
return;
|
|
149
|
+
const exitCode = childExitCode;
|
|
150
|
+
if (status === 'timed-out')
|
|
151
|
+
return finish({ status, stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.timedOut, message: 'sandbox process exceeded its timeout' } });
|
|
152
|
+
if (status === 'aborted')
|
|
153
|
+
return finish({ status, stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.aborted, message: 'sandbox process was aborted' } });
|
|
154
|
+
if (status === 'failed')
|
|
155
|
+
return finish({ status, stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.runnerFailed, message: 'sandbox process exceeded its output cap' } });
|
|
156
|
+
if (exitCode === 0)
|
|
157
|
+
return finish({ status: 'ok', stdout, stderr, exitCode, enforcement });
|
|
158
|
+
return finish({ status: 'failed', stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.runnerFailed, message: `sandbox process exited with code ${exitCode ?? 'unknown'}` } });
|
|
159
|
+
};
|
|
160
|
+
void cgroup?.attach(child.pid).then(() => {
|
|
161
|
+
attachmentComplete = true;
|
|
162
|
+
if (childClosed)
|
|
163
|
+
finishAfterAttachment();
|
|
164
|
+
}).catch((error) => {
|
|
165
|
+
attachmentError = error;
|
|
166
|
+
let terminated = false;
|
|
167
|
+
try {
|
|
168
|
+
terminated = terminate(child);
|
|
169
|
+
}
|
|
170
|
+
catch (terminationError) {
|
|
171
|
+
recordTeardownFailure(new AggregateError([error, terminationError], 'cgroup attachment and child termination both failed'));
|
|
172
|
+
}
|
|
173
|
+
if (!terminated) {
|
|
174
|
+
recordTeardownFailure(error);
|
|
175
|
+
finish({ status: 'failed', stdout, stderr, enforcement, error: { code: SANDBOX_ERROR_CODES.runnerFailed, message: 'sandbox cgroup attachment failed and the child could not be terminated' } });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
invalidate(error);
|
|
179
|
+
finish(unavailable(enforcement, `sandbox cgroup attachment failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
class Session {
|
|
184
|
+
#disposed = false;
|
|
185
|
+
#invalidated = false;
|
|
186
|
+
#teardownFailure;
|
|
187
|
+
#disposePromise;
|
|
188
|
+
id;
|
|
189
|
+
policy;
|
|
190
|
+
enforcement;
|
|
191
|
+
timeoutMs;
|
|
192
|
+
buildArgv;
|
|
193
|
+
runner;
|
|
194
|
+
snapshots;
|
|
195
|
+
active = new Map();
|
|
196
|
+
cleanups = new Map();
|
|
197
|
+
snapshotContent = new Map();
|
|
198
|
+
constructor(id, policy, enforcement, timeoutMs, buildArgv, runner, audit, cgroup, supportsFileSize, snapshots = []) {
|
|
199
|
+
this.id = id;
|
|
200
|
+
this.policy = policy;
|
|
201
|
+
this.enforcement = enforcement;
|
|
202
|
+
this.timeoutMs = timeoutMs;
|
|
203
|
+
this.buildArgv = buildArgv;
|
|
204
|
+
this.runner = runner;
|
|
205
|
+
this.cgroup = cgroup;
|
|
206
|
+
this.supportsFileSize = supportsFileSize;
|
|
207
|
+
this.audit = audit;
|
|
208
|
+
this.snapshots = Object.freeze(snapshots.map((snapshot) => Object.freeze({ ...snapshot })));
|
|
209
|
+
}
|
|
210
|
+
audit;
|
|
211
|
+
cgroup;
|
|
212
|
+
supportsFileSize;
|
|
213
|
+
emitAudit(kind) {
|
|
214
|
+
if (this.policy.mode !== 'danger-full-access' || this.audit === undefined)
|
|
215
|
+
return;
|
|
216
|
+
try {
|
|
217
|
+
const event = validateSandboxAuditEvent({
|
|
218
|
+
providerId: this.enforcement.providerId,
|
|
219
|
+
sessionId: this.id,
|
|
220
|
+
mode: 'danger-full-access',
|
|
221
|
+
timestamp: new Date().toISOString(),
|
|
222
|
+
kind,
|
|
223
|
+
});
|
|
224
|
+
void Promise.resolve(this.audit(event)).catch(() => undefined);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Audit delivery is best effort and must not affect sandbox behavior.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async execute(value) {
|
|
231
|
+
if (this.#disposed || this.#invalidated)
|
|
232
|
+
return unavailable(this.enforcement, 'sandbox session is unavailable');
|
|
233
|
+
const request = validateSandboxExecutionRequest(value);
|
|
234
|
+
if (!samePolicy(this.policy, request.policy)) {
|
|
235
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, `sandbox execution policy does not match session '${this.id}' policy`);
|
|
236
|
+
}
|
|
237
|
+
validateSandboxCapabilities(this.policy, this.enforcement);
|
|
238
|
+
if (this.buildArgv === undefined && this.policy.mode !== 'danger-full-access')
|
|
239
|
+
return unavailable(this.enforcement, 'safe sandbox mode has no verified execution backend');
|
|
240
|
+
if (hasUnsupportedResourceLimits(this.policy, this.supportsFileSize))
|
|
241
|
+
return unavailable(this.enforcement, 'sandbox provider cannot prove all requested resource limits');
|
|
242
|
+
if (hasUnenforcedResourceLimits(this.policy, this.cgroup))
|
|
243
|
+
return unavailable(this.enforcement, 'sandbox provider cannot prove requested memory/process limits');
|
|
244
|
+
this.emitAudit('execution-started');
|
|
245
|
+
try {
|
|
246
|
+
const limits = this.policy.resourceLimits;
|
|
247
|
+
return await runExact(request, this.policy, this.enforcement, limits?.wallTimeMs ?? this.timeoutMs, limits?.outputBytes ?? OUTPUT_CAP_BYTES, this.timeoutMs, this.buildArgv?.(request) ?? request.argv, this.runner, this.cgroup, () => !this.#disposed && !this.#invalidated, (child, cleanup, abort) => this.registerChild(child, cleanup, abort), (error) => this.invalidate(error), (error) => this.recordTeardownFailure(error));
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.emitAudit('execution-completed');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async openStdio(value) {
|
|
254
|
+
if (this.#disposed || this.#invalidated)
|
|
255
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, 'sandbox session is unavailable');
|
|
256
|
+
const request = validateSandboxExecutionRequest(value);
|
|
257
|
+
if (!samePolicy(this.policy, request.policy))
|
|
258
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, `sandbox execution policy does not match session '${this.id}' policy`);
|
|
259
|
+
validateSandboxCapabilities(this.policy, this.enforcement);
|
|
260
|
+
if (request.signal?.aborted)
|
|
261
|
+
throw new BramboError(SANDBOX_ERROR_CODES.aborted, 'stdio sandbox process was aborted before spawn');
|
|
262
|
+
if (this.buildArgv === undefined && this.policy.mode !== 'danger-full-access')
|
|
263
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'safe sandbox mode has no verified execution backend');
|
|
264
|
+
if (hasUnsupportedResourceLimits(this.policy, this.supportsFileSize))
|
|
265
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'sandbox provider cannot prove all requested resource limits');
|
|
266
|
+
if (hasUnenforcedResourceLimits(this.policy, this.cgroup))
|
|
267
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'sandbox provider cannot prove requested memory/process limits');
|
|
268
|
+
if (cannotContainStartup(this.policy, this.cgroup))
|
|
269
|
+
throw unavailableStdio('sandbox provider cannot contain cgroup-limited process startup before execution');
|
|
270
|
+
if (!(await containedWorkspace(request.cwd, this.policy.workspaceRoot)))
|
|
271
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'sandbox cwd cannot be physically proven inside workspace');
|
|
272
|
+
if (request.signal?.aborted)
|
|
273
|
+
throw abortedStdio('stdio process was aborted before spawn');
|
|
274
|
+
if (!(await containedWorkspace(request.cwd, this.policy.workspaceRoot)))
|
|
275
|
+
throw unavailableStdio('sandbox cwd cannot be physically proven inside workspace');
|
|
276
|
+
this.emitAudit('execution-started');
|
|
277
|
+
try {
|
|
278
|
+
const [command, ...args] = this.buildArgv?.(request) ?? request.argv;
|
|
279
|
+
const child = this.runner(command, args, { cwd: request.cwd, env: scrubEnvironment(request.environment), shell: false, detached: process.platform !== 'win32', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
280
|
+
let buffer = '';
|
|
281
|
+
const frames = [];
|
|
282
|
+
const waiters = [];
|
|
283
|
+
const sendWaiters = new Set();
|
|
284
|
+
let processClosed = false;
|
|
285
|
+
let terminalError;
|
|
286
|
+
let outputBytes = 0;
|
|
287
|
+
const timeout = setTimeout(() => stop(new BramboError(SANDBOX_ERROR_CODES.timedOut, 'stdio process exceeded its timeout')), this.policy.resourceLimits?.wallTimeMs ?? this.timeoutMs);
|
|
288
|
+
const rejectWaiters = (error) => {
|
|
289
|
+
terminalError ??= error;
|
|
290
|
+
while (waiters.length > 0) {
|
|
291
|
+
const waiter = waiters.shift();
|
|
292
|
+
waiter.signal?.removeEventListener('abort', waiter.abort);
|
|
293
|
+
waiter.reject(error);
|
|
294
|
+
}
|
|
295
|
+
for (const reject of sendWaiters)
|
|
296
|
+
reject(error);
|
|
297
|
+
sendWaiters.clear();
|
|
298
|
+
};
|
|
299
|
+
const stop = (error) => {
|
|
300
|
+
rejectWaiters(error);
|
|
301
|
+
if (!processClosed && !child.killed && !terminate(child)) {
|
|
302
|
+
this.invalidate(new Error('child termination was not accepted'));
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
child.stdout?.setEncoding('utf8');
|
|
306
|
+
child.stdout?.on('data', (chunk) => {
|
|
307
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
308
|
+
if (outputBytes > Math.min(this.policy.resourceLimits?.outputBytes ?? OUTPUT_CAP_BYTES, OUTPUT_CAP_BYTES)) {
|
|
309
|
+
stop(new BramboError(SANDBOX_ERROR_CODES.runnerFailed, 'stdio process exceeded its output cap'));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
buffer += chunk;
|
|
313
|
+
let newline = buffer.indexOf('\n');
|
|
314
|
+
while (newline >= 0) {
|
|
315
|
+
const frame = buffer.slice(0, newline);
|
|
316
|
+
buffer = buffer.slice(newline + 1);
|
|
317
|
+
const waiter = waiters.shift();
|
|
318
|
+
if (waiter) {
|
|
319
|
+
waiter.signal?.removeEventListener('abort', waiter.abort);
|
|
320
|
+
waiter.resolve(frame);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
frames.push(frame);
|
|
324
|
+
}
|
|
325
|
+
newline = buffer.indexOf('\n');
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
// Always consume stderr so a noisy child cannot block on a full pipe.
|
|
329
|
+
child.stderr?.on('data', (chunk) => {
|
|
330
|
+
outputBytes += Buffer.byteLength(chunk.toString());
|
|
331
|
+
if (outputBytes > Math.min(this.policy.resourceLimits?.outputBytes ?? OUTPUT_CAP_BYTES, OUTPUT_CAP_BYTES)) {
|
|
332
|
+
stop(new BramboError(SANDBOX_ERROR_CODES.runnerFailed, 'stdio process exceeded its output cap'));
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
const cleanup = new Promise((resolveCleanup, rejectCleanup) => {
|
|
336
|
+
child.once('close', () => {
|
|
337
|
+
clearTimeout(timeout);
|
|
338
|
+
processClosed = true;
|
|
339
|
+
rejectWaiters(terminalError ?? unavailableStdio('stdio process closed before a frame was received'));
|
|
340
|
+
resolveCleanup();
|
|
341
|
+
});
|
|
342
|
+
child.once('error', (error) => {
|
|
343
|
+
clearTimeout(timeout);
|
|
344
|
+
processClosed = true;
|
|
345
|
+
rejectWaiters(terminalError ?? unavailableStdio('stdio process failed before a frame was received'));
|
|
346
|
+
rejectCleanup(error);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
const abort = () => {
|
|
350
|
+
if (!processClosed && !child.killed && !terminate(child))
|
|
351
|
+
throw new Error('child termination was not accepted');
|
|
352
|
+
};
|
|
353
|
+
this.registerChild(child, cleanup, abort);
|
|
354
|
+
try {
|
|
355
|
+
await this.cgroup?.attach(child.pid);
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
let terminationError;
|
|
359
|
+
try {
|
|
360
|
+
if (!terminate(child))
|
|
361
|
+
terminationError = new Error('child termination was not accepted');
|
|
362
|
+
}
|
|
363
|
+
catch (cause) {
|
|
364
|
+
terminationError = cause;
|
|
365
|
+
}
|
|
366
|
+
if (terminationError !== undefined) {
|
|
367
|
+
const failure = new AggregateError([error, terminationError], 'stdio cgroup attachment and child termination both failed');
|
|
368
|
+
this.recordTeardownFailure(failure);
|
|
369
|
+
throw unavailableStdio('stdio cgroup attachment failed and child termination could not be verified', failure);
|
|
370
|
+
}
|
|
371
|
+
this.invalidate(error);
|
|
372
|
+
throw unavailableStdio('stdio cgroup attachment failed', error);
|
|
373
|
+
}
|
|
374
|
+
let closePromise;
|
|
375
|
+
const close = () => {
|
|
376
|
+
if (closePromise !== undefined)
|
|
377
|
+
return closePromise;
|
|
378
|
+
closePromise = (async () => {
|
|
379
|
+
let terminationError;
|
|
380
|
+
try {
|
|
381
|
+
abort();
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
terminationError = error;
|
|
385
|
+
this.recordTeardownFailure(error);
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await this.boundedCleanup(cleanup);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
throw unavailableStdio('stdio process cleanup failed', error);
|
|
392
|
+
}
|
|
393
|
+
if (terminationError !== undefined)
|
|
394
|
+
throw unavailableStdio('stdio process could not be terminated', terminationError);
|
|
395
|
+
})();
|
|
396
|
+
return closePromise;
|
|
397
|
+
};
|
|
398
|
+
return Object.freeze({
|
|
399
|
+
sendFrame: async (frame, signal) => {
|
|
400
|
+
if (signal?.aborted)
|
|
401
|
+
throw abortedStdio('stdio send was aborted');
|
|
402
|
+
if (frame.includes('\n') || frame.includes('\r'))
|
|
403
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, 'stdio frames cannot contain line breaks');
|
|
404
|
+
const stdin = child.stdin;
|
|
405
|
+
if (terminalError !== undefined)
|
|
406
|
+
throw terminalError;
|
|
407
|
+
if (stdin === null || processClosed || stdin.destroyed || stdin.writableEnded)
|
|
408
|
+
throw unavailableStdio('stdio process input is unavailable');
|
|
409
|
+
await new Promise((resolveSend, rejectSend) => {
|
|
410
|
+
let settled = false;
|
|
411
|
+
const finish = (error) => {
|
|
412
|
+
if (settled)
|
|
413
|
+
return;
|
|
414
|
+
settled = true;
|
|
415
|
+
sendWaiters.delete(onClose);
|
|
416
|
+
signal?.removeEventListener('abort', onAbort);
|
|
417
|
+
stdin.removeListener('drain', onDrain);
|
|
418
|
+
if (error === undefined)
|
|
419
|
+
resolveSend();
|
|
420
|
+
else
|
|
421
|
+
rejectSend(error);
|
|
422
|
+
};
|
|
423
|
+
const onAbort = () => finish(abortedStdio('stdio send was aborted'));
|
|
424
|
+
const onDrain = () => finish();
|
|
425
|
+
const onClose = (error) => finish(error);
|
|
426
|
+
sendWaiters.add(onClose);
|
|
427
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
428
|
+
try {
|
|
429
|
+
if (signal?.aborted)
|
|
430
|
+
return onAbort();
|
|
431
|
+
if (stdin.write(`${frame}\n`))
|
|
432
|
+
finish();
|
|
433
|
+
else
|
|
434
|
+
stdin.once('drain', onDrain);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
finish(error);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
},
|
|
441
|
+
receiveFrame: async (signal) => {
|
|
442
|
+
if (signal?.aborted)
|
|
443
|
+
throw abortedStdio('stdio receive was aborted');
|
|
444
|
+
if (frames.length > 0)
|
|
445
|
+
return frames.shift();
|
|
446
|
+
if (processClosed)
|
|
447
|
+
throw unavailableStdio('stdio process output is unavailable');
|
|
448
|
+
if (terminalError !== undefined)
|
|
449
|
+
throw terminalError;
|
|
450
|
+
return new Promise((resolveFrame, rejectFrame) => {
|
|
451
|
+
const waiter = {
|
|
452
|
+
resolve: resolveFrame,
|
|
453
|
+
reject: rejectFrame,
|
|
454
|
+
abort: () => {
|
|
455
|
+
const index = waiters.indexOf(waiter);
|
|
456
|
+
if (index >= 0)
|
|
457
|
+
waiters.splice(index, 1);
|
|
458
|
+
signal?.removeEventListener('abort', waiter.abort);
|
|
459
|
+
rejectFrame(abortedStdio('stdio receive was aborted'));
|
|
460
|
+
},
|
|
461
|
+
signal,
|
|
462
|
+
};
|
|
463
|
+
signal?.addEventListener('abort', waiter.abort, { once: true });
|
|
464
|
+
if (signal?.aborted)
|
|
465
|
+
waiter.abort();
|
|
466
|
+
else
|
|
467
|
+
waiters.push(waiter);
|
|
468
|
+
});
|
|
469
|
+
},
|
|
470
|
+
close,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
this.emitAudit('execution-completed');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async snapshot(paths) {
|
|
478
|
+
if (this.#disposed || this.#invalidated)
|
|
479
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, 'sandbox session is unavailable');
|
|
480
|
+
const snapshots = [];
|
|
481
|
+
for (const path of paths) {
|
|
482
|
+
const absolute = resolve(this.policy.workspaceRoot, path);
|
|
483
|
+
if (!(await containedWorkspace(absolute, this.policy.workspaceRoot))) {
|
|
484
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'snapshot path is outside the workspace');
|
|
485
|
+
}
|
|
486
|
+
let content;
|
|
487
|
+
let kind;
|
|
488
|
+
let directoryInfo;
|
|
489
|
+
try {
|
|
490
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
491
|
+
try {
|
|
492
|
+
const current = await handle.stat();
|
|
493
|
+
if (!current.isFile())
|
|
494
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'snapshot path is not a regular file');
|
|
495
|
+
if (await realpath(absolute) !== absolute) {
|
|
496
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'snapshot path is symbolic link');
|
|
497
|
+
}
|
|
498
|
+
content = await handle.readFile();
|
|
499
|
+
kind = 'file';
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
await handle.close();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
if (error instanceof BramboError)
|
|
507
|
+
throw error;
|
|
508
|
+
const info = await lstat(absolute);
|
|
509
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
510
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'snapshot path is not a regular file or directory');
|
|
511
|
+
}
|
|
512
|
+
directoryInfo = info;
|
|
513
|
+
kind = 'directory';
|
|
514
|
+
}
|
|
515
|
+
const digest = kind === 'file'
|
|
516
|
+
? createHash('sha256').update(content).digest('hex')
|
|
517
|
+
: createHash('sha256').update(`${kind}:${directoryInfo.size}:${directoryInfo.mtimeMs}`).digest('hex');
|
|
518
|
+
snapshots.push(validateSandboxSnapshot({ version: 1, path, kind, digest }));
|
|
519
|
+
if (kind === 'file')
|
|
520
|
+
this.snapshotContent.set(digest, content);
|
|
521
|
+
}
|
|
522
|
+
return Object.freeze(snapshots);
|
|
523
|
+
}
|
|
524
|
+
async restore(snapshots) {
|
|
525
|
+
if (this.#disposed || this.#invalidated)
|
|
526
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, 'sandbox session is unavailable');
|
|
527
|
+
for (const snapshot of snapshots.map((entry) => validateSandboxSnapshot(entry))) {
|
|
528
|
+
if (snapshot.kind !== 'file')
|
|
529
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'directory snapshot restore is unavailable');
|
|
530
|
+
const content = this.snapshotContent.get(snapshot.digest);
|
|
531
|
+
if (content === undefined)
|
|
532
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'snapshot content is not owned by this session');
|
|
533
|
+
const absolute = resolve(this.policy.workspaceRoot, snapshot.path);
|
|
534
|
+
if (!(await containedWorkspace(absolute, this.policy.workspaceRoot)))
|
|
535
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'snapshot restore path is outside the workspace');
|
|
536
|
+
await writeFile(absolute, content, { flag: 'w' });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
dispose() {
|
|
540
|
+
if (this.#disposePromise !== undefined)
|
|
541
|
+
return this.#disposePromise;
|
|
542
|
+
this.#disposed = true;
|
|
543
|
+
const cleanups = [...this.active.entries()].map(([child, abort]) => {
|
|
544
|
+
try {
|
|
545
|
+
abort();
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
this.recordTeardownFailure(error);
|
|
549
|
+
}
|
|
550
|
+
const cleanup = this.cleanups.get(child);
|
|
551
|
+
return cleanup === undefined
|
|
552
|
+
? Promise.resolve()
|
|
553
|
+
: this.boundedCleanup(cleanup).catch((cleanupError) => {
|
|
554
|
+
this.recordTeardownFailure(cleanupError);
|
|
555
|
+
throw cleanupError;
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
this.#disposePromise = Promise.allSettled(cleanups).then(async (outcomes) => {
|
|
559
|
+
for (const outcome of outcomes) {
|
|
560
|
+
if (outcome.status === 'rejected')
|
|
561
|
+
this.recordTeardownFailure(outcome.reason);
|
|
562
|
+
}
|
|
563
|
+
const cleanupFailure = this.#teardownFailure;
|
|
564
|
+
let cgroupFailure;
|
|
565
|
+
try {
|
|
566
|
+
await this.cgroup?.teardown();
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
cgroupFailure = error;
|
|
570
|
+
this.recordTeardownFailure(error);
|
|
571
|
+
}
|
|
572
|
+
if (cleanupFailure !== undefined && cgroupFailure !== undefined)
|
|
573
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, `sandbox session '${this.id}' child cleanup and cgroup teardown outcomes are uncertain`, { cause: new AggregateError([cleanupFailure, cgroupFailure]) });
|
|
574
|
+
if (cleanupFailure !== undefined)
|
|
575
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, `sandbox session '${this.id}' teardown outcome is uncertain`, { cause: cleanupFailure });
|
|
576
|
+
if (cgroupFailure !== undefined)
|
|
577
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, `sandbox session '${this.id}' cgroup teardown outcome is uncertain`, { cause: cgroupFailure });
|
|
578
|
+
});
|
|
579
|
+
return this.#disposePromise;
|
|
580
|
+
}
|
|
581
|
+
boundedCleanup(cleanup) {
|
|
582
|
+
return new Promise((resolveCleanup, rejectCleanup) => {
|
|
583
|
+
const timer = setTimeout(() => rejectCleanup(new Error(`sandbox session '${this.id}' child cleanup timed out`)), this.timeoutMs);
|
|
584
|
+
void cleanup.then(() => { clearTimeout(timer); resolveCleanup(); }, (error) => { clearTimeout(timer); rejectCleanup(error); });
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
invalidate(error) {
|
|
588
|
+
this.#invalidated = true;
|
|
589
|
+
void error;
|
|
590
|
+
}
|
|
591
|
+
recordTeardownFailure(error) {
|
|
592
|
+
this.#invalidated = true;
|
|
593
|
+
this.#teardownFailure ??= error ?? new Error('unknown child cleanup failure');
|
|
594
|
+
}
|
|
595
|
+
registerChild(child, cleanup, abort) {
|
|
596
|
+
this.active.set(child, abort);
|
|
597
|
+
this.cleanups.set(child, cleanup);
|
|
598
|
+
void cleanup.then(() => this.removeChild(child), (error) => {
|
|
599
|
+
this.recordTeardownFailure(error);
|
|
600
|
+
this.removeChild(child);
|
|
601
|
+
});
|
|
602
|
+
if (this.#disposed)
|
|
603
|
+
terminate(child);
|
|
604
|
+
}
|
|
605
|
+
removeChild(child) {
|
|
606
|
+
this.active.delete(child);
|
|
607
|
+
this.cleanups.delete(child);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function samePolicy(left, right) {
|
|
611
|
+
if (left.version !== right.version ||
|
|
612
|
+
left.mode !== right.mode ||
|
|
613
|
+
left.workspaceRoot !== right.workspaceRoot ||
|
|
614
|
+
(left.networkMode ?? 'deny') !== (right.networkMode ?? 'deny') ||
|
|
615
|
+
left.allowDangerous !== right.allowDangerous) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
const leftEntries = Object.entries(left.requiredCapabilities).sort(([a], [b]) => a.localeCompare(b));
|
|
619
|
+
const rightEntries = Object.entries(right.requiredCapabilities).sort(([a], [b]) => a.localeCompare(b));
|
|
620
|
+
if (!(leftEntries.length === rightEntries.length && leftEntries.every(([key, value], index) => rightEntries[index]?.[0] === key && rightEntries[index]?.[1] === value)))
|
|
621
|
+
return false;
|
|
622
|
+
const leftLimits = Object.entries(left.resourceLimits ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
623
|
+
const rightLimits = Object.entries(right.resourceLimits ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
624
|
+
return leftLimits.length === rightLimits.length && leftLimits.every(([key, value], index) => rightLimits[index]?.[0] === key && rightLimits[index]?.[1] === value);
|
|
625
|
+
}
|
|
626
|
+
export function createProvider(id, discovery, filesystem, timeoutMs, buildArgv, runner = (command, args, options) => spawn(command, [...args], options), audit, controlEvidence = {}, cgroupFactory, supportedResourceLimits = [], supportedNetworkModes = []) {
|
|
627
|
+
const capabilities = Object.freeze({
|
|
628
|
+
version: 1,
|
|
629
|
+
providerId: id,
|
|
630
|
+
enforcement: filesystem === 'none' ? 'partial' : 'os',
|
|
631
|
+
controls: Object.freeze({ ...controls(filesystem), ...controlEvidence }),
|
|
632
|
+
});
|
|
633
|
+
let sessions = 0;
|
|
634
|
+
return Object.freeze({
|
|
635
|
+
id,
|
|
636
|
+
discovery: Object.freeze(discovery),
|
|
637
|
+
capabilities,
|
|
638
|
+
async createSession(value) {
|
|
639
|
+
const policy = validateSandboxPolicy(value.policy);
|
|
640
|
+
if (policy.networkMode !== undefined && policy.networkMode !== 'deny' && !supportedNetworkModes.includes(policy.networkMode)) {
|
|
641
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxCapabilityUnavailable, `local provider '${id}' cannot prove network mode '${policy.networkMode}'`);
|
|
642
|
+
}
|
|
643
|
+
value.snapshots.forEach(validateSandboxSnapshot);
|
|
644
|
+
validateSandboxCapabilities(policy, capabilities);
|
|
645
|
+
const cgroup = cgroupFactory === undefined ? undefined : await cgroupFactory(policy);
|
|
646
|
+
sessions += 1;
|
|
647
|
+
return new Session(`${id}-${sessions}`, policy, capabilities, timeoutMs, buildArgv, runner, audit, cgroup, supportedResourceLimits.includes('fileSizeBytes'), value.snapshots);
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
export async function probe(options, argv) {
|
|
652
|
+
if (options.inspect !== undefined)
|
|
653
|
+
return options.inspect(argv);
|
|
654
|
+
const executable = argv[0];
|
|
655
|
+
return executable !== undefined && (existsSync(executable) || process.env['PATH']?.split(delimiter).some((entry) => existsSync(resolve(entry, executable))) === true);
|
|
656
|
+
}
|
|
657
|
+
export { DEFAULT_TIMEOUT_MS };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LocalSandboxAuditCallback, LocalSandboxProvider, LocalSandboxProviderOptions } from './shared.ts';
|
|
2
|
+
type WindowsSandboxProviderOptions = LocalSandboxProviderOptions & {
|
|
3
|
+
readonly audit?: LocalSandboxAuditCallback;
|
|
4
|
+
};
|
|
5
|
+
export declare function createWindowsSandboxProvider(options: WindowsSandboxProviderOptions): Promise<LocalSandboxProvider>;
|
|
6
|
+
export {};
|
package/dist/windows.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { BramboError, SANDBOX_ERROR_CODES, validateSandboxCapabilities, validateSandboxPolicy, validateSandboxSnapshot } from '@skanl/brambo-contracts';
|
|
3
|
+
import { createProvider, DEFAULT_TIMEOUT_MS, probe } from './shared.js';
|
|
4
|
+
const BROKER = 'brambo-windows-sandbox-broker';
|
|
5
|
+
const SELF_TEST_TIMEOUT_MS = 10_000;
|
|
6
|
+
function brokerCapabilities(available) {
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
version: 1,
|
|
9
|
+
providerId: 'local-windows',
|
|
10
|
+
// A missing broker is not partial enforcement: this provider has no
|
|
11
|
+
// verified execution substrate and must fail closed.
|
|
12
|
+
enforcement: available ? 'os' : 'none',
|
|
13
|
+
controls: Object.freeze({
|
|
14
|
+
filesystem: available ? 'full' : 'none',
|
|
15
|
+
process: available ? 'full' : 'none',
|
|
16
|
+
network: available ? 'full' : 'none',
|
|
17
|
+
// The broker self-test does not prove resource quotas.
|
|
18
|
+
resources: available ? 'partial' : 'none',
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function brokerPolicy(policy) {
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
...policy,
|
|
25
|
+
requiredCapabilities: Object.freeze({ filesystem: 'full' }),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function withEnforcement(result, enforcement) {
|
|
29
|
+
return Object.freeze({ ...result, enforcement });
|
|
30
|
+
}
|
|
31
|
+
function unavailableSession(id, enforcement) {
|
|
32
|
+
const unavailable = () => Object.freeze({
|
|
33
|
+
status: 'unavailable',
|
|
34
|
+
stdout: '',
|
|
35
|
+
stderr: '',
|
|
36
|
+
enforcement,
|
|
37
|
+
error: { code: SANDBOX_ERROR_CODES.unavailable, message: 'Windows Sandbox broker is unavailable' },
|
|
38
|
+
});
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
id,
|
|
41
|
+
execute: async () => unavailable(),
|
|
42
|
+
openStdio: async () => {
|
|
43
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'Windows Sandbox broker is unavailable');
|
|
44
|
+
},
|
|
45
|
+
dispose: async () => undefined,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function brokerSession(session, enforcement, policy) {
|
|
49
|
+
const delegatedPolicy = brokerPolicy(policy);
|
|
50
|
+
const delegatedRequest = (request) => ({ ...request, policy: delegatedPolicy });
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
id: session.id,
|
|
53
|
+
execute: async (request) => withEnforcement(await session.execute(delegatedRequest(request)), enforcement),
|
|
54
|
+
openStdio: async (request) => {
|
|
55
|
+
if (session.openStdio === undefined)
|
|
56
|
+
throw new BramboError(SANDBOX_ERROR_CODES.unavailable, 'Windows Sandbox broker stdio is unavailable');
|
|
57
|
+
return session.openStdio(delegatedRequest(request));
|
|
58
|
+
},
|
|
59
|
+
dispose: () => session.dispose(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function usableBroker(options) {
|
|
63
|
+
if (!(await probe(options, [BROKER, '--version'])))
|
|
64
|
+
return false;
|
|
65
|
+
const argv = [BROKER, 'self-test', '--network', 'disabled', '--cleanup'];
|
|
66
|
+
// `inspect` is the injected execution seam. Production discovery remains
|
|
67
|
+
// conservative: a broker is unusable until its own isolation self-test passes.
|
|
68
|
+
if (options.inspect !== undefined)
|
|
69
|
+
return options.inspect(argv);
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const child = spawn(argv[0], argv.slice(1), { shell: false, windowsHide: true, stdio: 'ignore' });
|
|
72
|
+
let settled = false;
|
|
73
|
+
const finish = (result) => {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
resolve(result);
|
|
79
|
+
};
|
|
80
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); finish(false); }, SELF_TEST_TIMEOUT_MS);
|
|
81
|
+
child.once('error', () => finish(false));
|
|
82
|
+
child.once('close', (code) => finish(code === 0));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export async function createWindowsSandboxProvider(options) {
|
|
86
|
+
const windowsSandboxBroker = await usableBroker(options);
|
|
87
|
+
const jobObjectHelper = await probe(options, ['brambo-windows-job-helper', '--version']);
|
|
88
|
+
const discovery = { bubblewrap: false, landlock: false, cgroup: false, seatbelt: false, windowsSandboxBroker, jobObjectHelper };
|
|
89
|
+
const capabilities = brokerCapabilities(windowsSandboxBroker);
|
|
90
|
+
const base = createProvider('local-windows', discovery, windowsSandboxBroker ? 'full' : 'none', options.timeoutMs ?? DEFAULT_TIMEOUT_MS, windowsSandboxBroker
|
|
91
|
+
? (request) => [
|
|
92
|
+
BROKER,
|
|
93
|
+
'run',
|
|
94
|
+
'--workspace-root', request.policy.workspaceRoot,
|
|
95
|
+
'--working-directory', request.cwd,
|
|
96
|
+
'--network', 'disabled',
|
|
97
|
+
'--workspace-transfer', 'controlled',
|
|
98
|
+
'--cleanup', 'always',
|
|
99
|
+
'--',
|
|
100
|
+
...request.argv,
|
|
101
|
+
]
|
|
102
|
+
: undefined, undefined, options.audit);
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
id: base.id,
|
|
105
|
+
discovery: base.discovery,
|
|
106
|
+
capabilities,
|
|
107
|
+
async createSession(value) {
|
|
108
|
+
const policy = validateSandboxPolicy(value.policy);
|
|
109
|
+
value.snapshots.forEach(validateSandboxSnapshot);
|
|
110
|
+
validateSandboxCapabilities(policy, capabilities);
|
|
111
|
+
if (!windowsSandboxBroker)
|
|
112
|
+
return unavailableSession('local-windows-unavailable', capabilities);
|
|
113
|
+
const session = await base.createSession({ ...value, policy: brokerPolicy(policy) });
|
|
114
|
+
return brokerSession(session, capabilities, policy);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skanl/brambo-sandbox-local",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Conservative local operating-system sandbox provider for brambo.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-agent",
|
|
7
|
+
"brambo",
|
|
8
|
+
"sandbox",
|
|
9
|
+
"local"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/SKANL/brambo#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/SKANL/brambo/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/SKANL/brambo.git",
|
|
18
|
+
"directory": "packages/sandbox-local"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"brambo-source": "./src/index.ts",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@skanl/brambo-contracts": "0.1.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^24.13.3",
|
|
40
|
+
"typescript": "~7.0.2",
|
|
41
|
+
"vitest": "^4.1.11"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"lint": "eslint .",
|
|
50
|
+
"build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
|
|
51
|
+
}
|
|
52
|
+
}
|