Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,10 @@ Uses two different patterns:

A few paths are writable without being listed: the child's stdio and `/tmp/claude`, and as a convenience `~/.npm/_logs` and `~/.claude/debug`. Those two home directories are dropped when a `denyRead` entry covers them (and kept when an `allowRead` entry beneath that deny re-opens them), so list them in `allowWrite` if you want them writable under a home read-deny.

**Automatic SSH key protection:**

On initialization the runtime parses `~/.ssh/config` (including `Include` chains) and appends read denies for `IdentityFile`, `CertificateFile`, `ControlPath`, and `IdentityAgent` targets — keys often live outside `~/.ssh`, where a `denyRead` on the directory would not cover them — plus ssh's default key filenames (`~/.ssh/id_rsa`, `~/.ssh/id_ed25519`, etc.). Targets are denied whether or not they exist yet (a `ControlPath` socket appears only when the first master connection opens). This is additive only: it never removes configured protection, and a malformed ssh config never fails initialization. To make a specific key readable inside the sandbox, add an `allowRead` entry with the key's EXACT path — an `allowRead` on a parent directory does not override a file-specific deny (most-specific-wins).

**Path Syntax (macOS):**

Paths support git-style glob patterns on macOS, similar to `.gitignore` syntax:
Expand Down
56 changes: 48 additions & 8 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
decodeSandboxedCommand,
encodeSandboxedCommand,
} from './sandbox-utils.js'
import { collectSshKeyDenyPaths } from './ssh-config-deny.js'
import {
SandboxViolationStore,
sanitizeUnregisteredCommandKey,
Expand Down Expand Up @@ -177,6 +178,16 @@ let windowsWfpVerified = false
// and so reset()'s revoke/restore addresses the SAME binary the
// grants/stamps were applied with even if `config` mutated between.
let srtWinSpawn: SrtWinSpawn | undefined
/**
* SSH key material referenced by the user's ssh config, plus ssh's default
* key filenames — see collectSshKeyDenyPaths for the exact contract.
* Computed at initialize()/updateConfig() (a re-scan: the ssh config may
* have changed on disk), appended to every effective denyRead set as its
* own named source (unionDenyReadPaths third arm + the Windows stamp
* set), and cleared at reset(). Append-only: widens protection, never
* narrows what the caller configured.
*/
let sshKeyDenyPaths: string[] = []
const sandboxViolationStore = new SandboxViolationStore()
// Per-session sentinel↔real-value map for masked credentials. Lives only in
// process memory; never written to disk or logged. Cleared on reset().
Expand Down Expand Up @@ -644,6 +655,11 @@ async function initialize(
// Store config for use by other functions
config = runtimeConfig

// Widen read protection to SSH key material referenced by the user's ssh
// config (keys often live outside ~/.ssh). Never fails initialization —
// a malformed config yields fewer additions, not an error.
sshKeyDenyPaths = collectSshKeyDenyPaths()

// Resolve parent/upstream proxy from config or HTTP_PROXY env before we
// start our own listeners (which will later shadow those vars in the child).
parentProxy = resolveParentProxy(runtimeConfig.network.parentProxy)
Expand Down Expand Up @@ -1251,7 +1267,19 @@ function unionDenyReadPaths(
denyRead: readonly string[],
credentialRestrictions: CredentialRestrictionConfig,
): string[] {
return [...new Set([...denyRead, ...credentialRestrictions.denyReadPaths])]
// Three named sources: caller config, credential file denies, and the
// ssh-config key protection. The ssh arm is separate (NOT smuggled
// through the credential channel) because getCredentialRestrictions
// early-returns for configs with no credentials block — riding that
// channel would silently drop the ssh denies for the common
// credentials-less config.
return [
...new Set([
...denyRead,
...credentialRestrictions.denyReadPaths,
...sshKeyDenyPaths,
]),
]
}

/**
Expand Down Expand Up @@ -1413,6 +1441,9 @@ function computeWindowsFsAccessSet(c: SandboxRuntimeConfig): {
...new Set([
...(fs?.denyRead ?? []),
...getCredentialDenyReadPaths(c.credentials),
// SSH key protection: session-wide stamp only (the per-exec
// path deliberately excludes it — already stamped here).
...sshKeyDenyPaths,
]),
],
{ mode: 'deny' },
Expand Down Expand Up @@ -1447,6 +1478,7 @@ function rawWindowsFsInputs(c: SandboxRuntimeConfig) {
allowRead: [...(c.filesystem.allowRead ?? [])],
allowWrite: [...c.filesystem.allowWrite],
credFiles: getCredentialDenyReadPaths(c.credentials),
sshFiles: [...sshKeyDenyPaths],
}
}

Expand All @@ -1466,7 +1498,8 @@ function sameRawWindowsFsInputs(
setEq(a.denyWrite, b.denyWrite) &&
setEq(a.allowRead, b.allowRead) &&
setEq(a.allowWrite, b.allowWrite) &&
setEq(a.credFiles, b.credFiles)
setEq(a.credFiles, b.credFiles) &&
setEq(a.sshFiles, b.sshFiles)
)
}

Expand Down Expand Up @@ -2032,22 +2065,28 @@ function getConfig(): SandboxRuntimeConfig | undefined {
* @param newConfig - The new configuration to use
*/
function updateConfig(newConfig: SandboxRuntimeConfig): void {
// Built before anything is swapped, so a malformed range leaves the
// previous config (and ssh key set) fully in effect.
const nextGuard = createResolvedAddressGuard(newConfig.network)
// Re-scan the ssh config (it may have changed on disk since
// initialize()) before the staleness compare so it sees the fresh set —
// recomputing after the compare would make ssh-derived drift
// invisible to the warning and then desynchronize silently.
sshKeyDenyPaths = collectSshKeyDenyPaths()
if (
getPlatform() === 'windows' &&
config &&
!sameWindowsStampSet(newConfig)
) {
logForDebugging(
`[Sandbox Windows] updateConfig: the resolved file-access set ` +
`(filesystem.* ∪ credentials.files) changed but the ACL ` +
`stamp/grant is session-wide — call reset() then initialize() ` +
`to apply. The previously-applied set stays in effect.`,
`(filesystem.* ∪ credentials.files ∪ ssh-config keys) changed ` +
`but the ACL stamp/grant is session-wide — call reset() then ` +
`initialize() to apply. The previously-applied set stays in ` +
`effect.`,
{ level: 'warn' },
)
}
// Built before anything is swapped, so a malformed range leaves the
// previous config fully in effect.
const nextGuard = createResolvedAddressGuard(newConfig.network)
// Deep clone the config to avoid mutations. structuredClone cannot clone
// functions, so pull filterRequest out, clone the rest, and put it back —
// a function reference is immutable in the sense that matters here.
Expand Down Expand Up @@ -2223,6 +2262,7 @@ async function reset(): Promise<void> {
windowsFsStampedSet = undefined
windowsFsSbUserSid = undefined
windowsFsRawInputs = undefined
sshKeyDenyPaths = []
srtWinSpawn = undefined
// windowsWfpVerified is NOT cleared — per-process, not per-session.

Expand Down
6 changes: 3 additions & 3 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,13 @@ export function isSymlinkOutsideBoundary(
* valid POSIX filename byte — `~\foo` is a legal relative filename
* on Linux/macOS and must NOT tilde-expand there.
*/
export function expandTilde(p: string): string {
if (p === '~') return homedir()
export function expandTilde(p: string, home: string = homedir()): string {
if (p === '~') return home
if (
p.startsWith('~/') ||
(process.platform === 'win32' && p.startsWith('~\\'))
) {
return homedir() + p.slice(1)
return home + p.slice(1)
}
return p
}
Expand Down
Loading
Loading