fix(sandbox): PR-B fold-ins — allow read ~/.claude + skip wrap under test mock

Live PI231 verification of d0dcd28 surfaced two real-runtime issues:

1. ~/.claude was denyRead, but claude CLI MUST read its own OAuth
   credentials file to authenticate. Result: 100% of anthropic requests
   on sandboxed PI231 failed with "Not logged in · Please run /login".
   Live transcript:
     {"event":"fallback_hop_error", "provider":"anthropic",
      "error":"Not logged in · Please run /login"}

   The cross-tenant risk for ~/.claude was a false security trade-off:
   (a) ~/.claude is the spawn's OWN auth, not cross-tenant material
       (all OLP clients share the same Anthropic OAuth — the file is
       not multi-tenant secret)
   (b) Real protection for the model accidentally exfiltrating creds
       via tool_use is Phase 6c's --system-prompt suppressing tool
       descriptions; sandbox-runtime can only blanket-deny a path, not
       distinguish "claude reads creds" from "model emits Read tool"
   Fix: remove ~/.claude from denyRead. Keep ~/.olp, ~/.ssh, ~/.config,
   ~/.codex (all genuinely cross-tenant).
   Suite 44a (cat ~/.olp/keys.json MUST fail) unchanged — still proves
   the core acceptance.
   Suite 44c added (regression guard): in-sandbox cat ~/.claude/.credentials.json
   MUST succeed when the file exists.

2. anthropic.mjs::_spawnAndStream called wrapSpawn() unconditionally,
   including when a test had set __setSpawnImpl(mockFn). The wrap
   rewrites bin to '/bin/sh -c <wrapped-string>' which breaks every HTTP
   integration test that asserts on spawn args. Result on PI231 (sandbox
   active): 16 production-shape tests failed because their mocks were
   never called with the expected bin/args.
   Fix: skip wrapSpawn when spawnImpl !== defaultSpawn (mock active).
   Mocks don't exec, so isolation is meaningless there anyway. Real-CLI
   spawns (production + Suite 44) still get wrapped.

Tests: 813 → 813 (44c is also PI231-gated, skips on Mac with file
absent; the fold-in does not change Mac test count).

Authority:
- @anthropic-ai/sandbox-runtime v0.0.52 — wrapWithSandbox() shell-string
  return shape is the proximate cause of (2)
- Live PI231 2026-05-28 transcript: /v1/chat/completions returning
  content=null + "Not logged in" from anthropic; OLP_E2E_SANDBOX=1 npm
  test showing 16 prior pass tests now fail
- ADR 0014 § 4.1 PR-B acceptance criteria — the negative test (44a)
  continues to pass; the false-positive denyRead (~/.claude) is corrected

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 17:34:36 +10:00
co-authored by Claude Opus 4.7
parent d0dcd281ef
commit 28642756b5
3 changed files with 89 additions and 11 deletions
+49
View File
@@ -18222,6 +18222,11 @@ describe('Suite 43 — Phase 7 PR-B: lib/sandbox/manager.mjs', () => {
const _RUN_SANDBOX_E2E = Boolean(process.env.OLP_E2E_SANDBOX);
// Suite 44c (fold-in 2026-05-28) needs `join`. statSync already imported at
// the Suite 17 boundary above. We just need a local `join` alias here since
// the file-top `join` was bound as `_pathJoinForSetup`.
const join = _pathJoinForSetup;
describe('Suite 44 — sandbox negative security test (PI231 only)', { skip: !_RUN_SANDBOX_E2E }, () => {
before(async () => {
@@ -18325,4 +18330,48 @@ describe('Suite 44 — sandbox negative security test (PI231 only)', { skip: !_R
assert.ok(stdout.includes('SANDBOX_PROOF'),
`stdout must contain SANDBOX_PROOF; got: ${stdout.slice(0, 100)}`);
});
it('44c: in-sandbox spawn CAN read ~/.claude/.credentials.json (regression guard for fold-in 2026-05-28)', async () => {
// Phase 7 PR-B fold-in (commit pending): ~/.claude removed from denyRead.
// The spawn's own OAuth file MUST be readable, otherwise claude CLI fails
// with "Not logged in" — and live PI231 verification produces empty
// response bodies via the anthropic fallback path.
//
// The cross-tenant protection for ~/.claude relies on Phase 6c
// --system-prompt suppressing tool descriptions, not on sandbox denyRead.
// See manager.mjs comment block above denyRead for full rationale.
const { spawn: realSpawn } = await import('node:child_process');
const credPath = join(homedir(), '.claude', '.credentials.json');
// If the credentials file isn't present (e.g. dev machine without OAuth),
// this test is meaningless — skip the assertion but log.
let credStat;
try { credStat = statSync(credPath); } catch { credStat = null; }
if (!credStat) {
// No OAuth file present; cannot test read. Pass with note.
assert.ok(true, `No ${credPath} on this host — skipping read-allowed verification`);
return;
}
const wrapped = await wrapSpawn({
bin: 'cat',
args: [credPath],
env: { ...process.env },
cwd: undefined,
allowedDomains: [],
});
const { exitCode } = await new Promise((resolve) => {
const child = realSpawn(wrapped.bin, wrapped.args, {
env: wrapped.env,
cwd: wrapped.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
child.on('exit', code => resolve({ exitCode: code }));
});
assert.equal(exitCode, 0,
`cat ${credPath} inside sandbox exited with code ${exitCode} — sandbox is denying read on a path the spawn legitimately needs. ` +
`~/.claude must NOT be in denyRead per the 2026-05-28 fold-in.`);
});
});