diff --git a/.changeset/dry-laws-fetch.md b/.changeset/dry-laws-fetch.md new file mode 100644 index 000000000..7e983f995 --- /dev/null +++ b/.changeset/dry-laws-fetch.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +fix(local-sandbox): allow SRT proxy sockets on Linux diff --git a/packages/trueforge/scripts/local-sandbox/lima.yaml b/packages/trueforge/scripts/local-sandbox/lima.yaml index 3c1cb6bc6..a2add181e 100644 --- a/packages/trueforge/scripts/local-sandbox/lima.yaml +++ b/packages/trueforge/scripts/local-sandbox/lima.yaml @@ -39,6 +39,7 @@ provision: python3 \ python3-pip \ python3-setuptools \ + python3-venv \ ca-certificates \ curl \ gnupg diff --git a/packages/trueforge/src/sandbox/local/core/hostRun.ts b/packages/trueforge/src/sandbox/local/core/hostRun.ts index c96f7f9c7..2ca76071d 100644 --- a/packages/trueforge/src/sandbox/local/core/hostRun.ts +++ b/packages/trueforge/src/sandbox/local/core/hostRun.ts @@ -42,6 +42,16 @@ function codeModeSocketParentAllow(): string[] { return codeModeSocketParentPath === undefined ? [] : [codeModeSocketParentPath]; } +function linuxNetworkSocketAllow(platform: LocalSandboxPlatform): string[] { + if (platform !== 'linux') { + return []; + } + + return [SandboxManager.getLinuxHttpSocketPath(), SandboxManager.getLinuxSocksSocketPath()].filter( + (socketPath): socketPath is string => socketPath !== undefined, + ); +} + function requireActivePlatform(): LocalSandboxPlatform { if (activePlatform === undefined) { throw new Error('SRT platform is not set; call initSrt({ platform }) first'); @@ -279,7 +289,12 @@ function filesystemPolicy(params: { sandboxRootPath: string; platform: LocalSand allowWrite: [params.sandboxRootPath], denyWrite: denySharedDefaultWritePaths(), denyRead: ['/'], - allowRead: [params.sandboxRootPath, ...codeModeSocketParentAllow(), ...platformAllowRead(params.platform)], + allowRead: [ + params.sandboxRootPath, + ...codeModeSocketParentAllow(), + ...linuxNetworkSocketAllow(params.platform), + ...platformAllowRead(params.platform), + ], }; } diff --git a/packages/trueforge/tests/sandbox/local/smoke.test.ts b/packages/trueforge/tests/sandbox/local/smoke.test.ts index 07be25cee..75d8c2547 100644 --- a/packages/trueforge/tests/sandbox/local/smoke.test.ts +++ b/packages/trueforge/tests/sandbox/local/smoke.test.ts @@ -37,6 +37,8 @@ const DEFAULT_TMP_CLAUDE = '/tmp/claude'; const DELETE_TARGET = join(DEFAULT_TMP_CLAUDE, 'poc-delete-target.txt'); const SECRET_CONTENTS = 'host-secret-should-not-leak\n'; const HOST_HOME = process.env['HOME']; +const HOST_HOME_SECRET_CONTENTS = 'host-home-secret-should-not-leak\n'; +const HOST_HOME_SECRET = HOST_HOME === undefined ? undefined : join(HOST_HOME, `.tfy-poc-home-secret-${randomUUID()}`); const ENV_LEAK_MARKER = 'TFY_SMOKE_HOST_SECRET'; const ENV_LEAK_VALUE = 'host-env-must-not-reach-sandbox'; const ENV_INHERIT_MARKER = 'TFY_SMOKE_INHERIT'; @@ -57,11 +59,68 @@ async function prepareHostProbeFiles(): Promise { await mkdir(SANDBOXES, { recursive: true, mode: 0o700 }); await writeFile(DELETE_TARGET, 'delete-me\n', { mode: 0o600 }); await writeFile(DENY_READ_SECRET, SECRET_CONTENTS, { mode: 0o600 }); + if (HOST_HOME_SECRET !== undefined) { + await writeFile(HOST_HOME_SECRET, HOST_HOME_SECRET_CONTENTS, { mode: 0o600 }); + } } async function cleanupHostProbeFiles(): Promise { await rm(DELETE_TARGET, { force: true }); await rm(DENY_READ_SECRET, { force: true }); + if (HOST_HOME_SECRET !== undefined) { + await rm(HOST_HOME_SECRET, { force: true }); + } +} + +/** + * Linux SRT proxy bridge must remain reachable without exposing all of host /tmp. + * Regression: denyRead ['/'] previously hid SRT's /tmp/claude-http-*.sock, + * causing sandboxed HTTPS/pip traffic to fail with ProxyError. + */ +async function smokeLinuxSrtProxySocketReadGate(params: { + provider: LocalSandboxProvider; + sandboxId: string; + platform: 'darwin' | 'linux'; +}): Promise { + if (params.platform !== 'linux') { + return; + } + + const httpSocketPath = SandboxManager.getLinuxHttpSocketPath(); + assert.ok(httpSocketPath, 'Linux SRT HTTP bridge socket path missing after initialization'); + + const unrelatedTmpPath = join(tmpdir(), `tfy-srt-unrelated-${newId().slice(0, 10)}`); + await writeFile(unrelatedTmpPath, 'host-tmp-must-stay-hidden\n', { mode: 0o600 }); + + try { + const probe = await params.provider.exec({ + sandboxId: params.sandboxId, + command: [ + "python3 - <<'PY'", + 'import os, stat', + `socket_path = ${JSON.stringify(httpSocketPath)}`, + `sentinel_path = ${JSON.stringify(unrelatedTmpPath)}`, + 'st = os.stat(socket_path)', + 'if not stat.S_ISSOCK(st.st_mode):', + ' raise SystemExit("SRT HTTP bridge path is not a Unix socket")', + 'if os.path.exists(sentinel_path):', + ' raise SystemExit("unrelated host /tmp path is visible")', + 'print("srt-http-socket-visible")', + 'print("unrelated-host-tmp-hidden")', + 'PY', + ].join('\n'), + }); + + assert.equal(probe.success, true); + if (!probe.success) throw new Error('unreachable'); + assert.equal(probe.response.exitCode, 0, probe.response.result); + assert.match(probe.response.result, /srt-http-socket-visible/); + assert.match(probe.response.result, /unrelated-host-tmp-hidden/); + + console.log('ok: Linux SRT proxy socket visible while unrelated host /tmp remains hidden'); + } finally { + await rm(unrelatedTmpPath, { force: true }); + } } function sleep(ms: number): Promise { @@ -1671,6 +1730,12 @@ async function main(): Promise { platform: support.platform, }); + await smokeLinuxSrtProxySocketReadGate({ + provider, + sandboxId, + platform: support.platform, + }); + const write = await provider.exec({ sandboxId, command: "printf 'sandbox-ok\\n' > note.txt && cat note.txt", @@ -1729,7 +1794,22 @@ async function main(): Promise { console.log('ok: host secret outside sandbox blocked'); assert.ok(HOST_HOME && HOST_HOME.length > 0); - await assertExecFails(provider, sandboxId, `ls ${JSON.stringify(HOST_HOME)}`, 'host home listing denied'); + assert.ok(HOST_HOME_SECRET !== undefined); + await access(HOST_HOME_SECRET); + + const hostHomeSecretRead = await provider.exec({ + sandboxId, + command: `cat ${JSON.stringify(HOST_HOME_SECRET)}`, + }); + assert.equal(hostHomeSecretRead.success, true); + if (!hostHomeSecretRead.success) throw new Error('unreachable'); + assert.notEqual( + hostHomeSecretRead.response.exitCode, + 0, + `host home secret unexpectedly readable\n${hostHomeSecretRead.response.result}`, + ); + assert.ok(!hostHomeSecretRead.response.result.includes(HOST_HOME_SECRET_CONTENTS.trim())); + console.log('ok: host home secret file blocked'); await smokeHostPackageManagerDenied(provider, sandboxId);