Skip to content

Commit 0263e66

Browse files
jonathanKingstonCopse
andcommitted
fix: allow native SSH-signed commits in sandbox
Add an explicit macOS permission for the native git_commit subprocess to reach the configured SSH agent socket while preserving the existing filesystem and network sandbox. Supply path-configured signing keys as inline public identities, and patch sandbox-runtime so per-spawn Unix socket allowances reach the seatbelt profile. Co-Authored-By: Copse <noreply@copse.dev> Copse-Models: acp:codex-acp#gpt-5.6-sol
1 parent d5a94cd commit 0263e66

12 files changed

Lines changed: 568 additions & 5 deletions

docs/shell-permissions.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ The in-memory grant disappears on restart. The decision record does not: an answ
6464
`decision` spine event at `scope: external-read`, including the paths and whether the grant was
6565
remembered. Each later allowed command records a verdict sourced to `read-outside-grant`.
6666

67+
## Native commit signing
68+
69+
Copse's native `git_commit` tool honours the repository's Git signing configuration while keeping
70+
the commit subprocess inside the project sandbox. On macOS, Settings › Permissions offers an
71+
off-by-default grant that lets only that commit subprocess connect to the single Unix socket named
72+
by `SSH_AUTH_SOCK`. The path must be absolute, normalised, and a socket at the time of use. Internet
73+
access remains denied.
74+
75+
The grant is explicit because ssh-agent has no commit-only operation: Git hooks inherit the commit
76+
sandbox and can ask the agent to use any loaded key. Recommend `ssh-add -c` when enabling it. Linux
77+
does not receive the grant because seccomp cannot restrict Unix sockets by path; Windows has no
78+
project sandbox.
79+
80+
When `user.signingKey` names a private-key path, Copse reads only its non-symlink `.pub` sibling
81+
in the trusted main process and passes the public identity to Git as an inline `key::` value. The
82+
sandbox never gains read access to the private key or the `.ssh` directory. A small pinned patch to
83+
`@anthropic-ai/sandbox-runtime` makes its documented per-spawn `allowUnixSockets` option reach the
84+
macOS seatbelt profile; remove that patch once upstream ships the equivalent fix.
85+
6786
## What an approval prompt says
6887

6988
Classifier reasons are **identifiers, not copy**. The regex pass and the token pass share them
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
diff --git a/dist/sandbox/sandbox-manager.js b/dist/sandbox/sandbox-manager.js
2+
index 3a2d9c8abd01fefff36edea0cd76e9c31a3ed411..7e422b7a159da0ae00902cefcd5d38ac501c223c 100644
3+
--- a/dist/sandbox/sandbox-manager.js
4+
+++ b/dist/sandbox/sandbox-manager.js
5+
@@ -1293,7 +1293,7 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal, opt
6+
unsetEnvVars: credentialRestrictions.unsetEnvVars,
7+
setEnvVars: credentialRestrictions.setEnvVars,
8+
maskedFileBinds: credentialRestrictions.maskedFileBinds,
9+
- allowUnixSockets: getAllowUnixSockets(),
10+
+ allowUnixSockets: customConfig?.network?.allowUnixSockets ?? getAllowUnixSockets(),
11+
allowAllUnixSockets: getAllowAllUnixSockets(),
12+
allowLocalBinding: getAllowLocalBinding(),
13+
allowMachLookup: getAllowMachLookup(),

pnpm-lock.yaml

Lines changed: 7 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ allowBuilds:
1616
protobufjs: true
1717
sharp: true
1818

19+
patchedDependencies:
20+
'@anthropic-ai/sandbox-runtime@0.0.74': patches/@anthropic-ai__sandbox-runtime@0.0.74.patch
21+
1922
# Release packaging cross-builds Intel and Apple Silicon apps on one macOS
2023
# runner. Keep both native keyring packages installed so electron-builder can
2124
# place the matching binary in each target instead of inheriting the host CPU.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { describe, it } from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
4+
import { tmpdir } from 'node:os'
5+
import { dirname, join } from 'node:path'
6+
import { createServer } from 'node:net'
7+
import { createRequire } from 'node:module'
8+
import { SandboxManager } from '@anthropic-ai/sandbox-runtime'
9+
import {
10+
gitCommitSigningSandboxOverlay,
11+
parseSshPublicKey,
12+
resolveInlineSshPublicSigningKey,
13+
resolveSshAgentSocketAllowList,
14+
sshAgentSocketAllowList,
15+
} from './git-commit-signing.ts'
16+
17+
const LAUNCHD_SOCKET = '/private/tmp/com.apple.launchd.example/Listeners'
18+
const require = createRequire(import.meta.url)
19+
20+
describe('sandbox-runtime unix-socket patch', () => {
21+
it('honours the per-spawn socket list instead of the process-global list', async () => {
22+
const entry = require.resolve('@anthropic-ai/sandbox-runtime')
23+
const manager = join(dirname(entry), 'sandbox', 'sandbox-manager.js')
24+
const source = await readFile(manager, 'utf8')
25+
assert.match(
26+
source,
27+
/allowUnixSockets: customConfig\?\.network\?\.allowUnixSockets \?\? getAllowUnixSockets\(\)/,
28+
)
29+
})
30+
31+
it(
32+
'emits the per-spawn socket in the generated macOS seatbelt profile',
33+
{ skip: process.platform !== 'darwin' },
34+
async () => {
35+
await SandboxManager.initialize(
36+
{
37+
network: { allowedDomains: [], deniedDomains: [] },
38+
filesystem: { denyRead: [], allowWrite: [], denyWrite: [], allowGitConfig: true },
39+
},
40+
() => Promise.resolve(false),
41+
false,
42+
)
43+
try {
44+
const wrapped = await SandboxManager.wrapWithSandboxArgv('true', '/bin/zsh', {
45+
network: {
46+
allowedDomains: [],
47+
deniedDomains: [],
48+
allowUnixSockets: [LAUNCHD_SOCKET],
49+
},
50+
})
51+
assert.ok(
52+
wrapped.argv.some((arg) =>
53+
arg.includes(`network-outbound (remote unix-socket (subpath "${LAUNCHD_SOCKET}"))`),
54+
),
55+
)
56+
} finally {
57+
await SandboxManager.reset()
58+
}
59+
},
60+
)
61+
})
62+
63+
describe('sshAgentSocketAllowList', () => {
64+
it('admits exactly the named socket on macOS after opt-in', () => {
65+
assert.deepEqual(
66+
sshAgentSocketAllowList({
67+
enabled: true,
68+
authSock: LAUNCHD_SOCKET,
69+
platform: 'darwin',
70+
isSocket: true,
71+
}),
72+
[LAUNCHD_SOCKET],
73+
)
74+
})
75+
76+
it('fails closed for opt-out, unsupported platforms, and unsafe paths', () => {
77+
const base = { enabled: true, platform: 'darwin' as const, isSocket: true }
78+
assert.deepEqual(
79+
sshAgentSocketAllowList({ ...base, enabled: false, authSock: LAUNCHD_SOCKET }),
80+
[],
81+
)
82+
assert.deepEqual(
83+
sshAgentSocketAllowList({ ...base, platform: 'linux', authSock: LAUNCHD_SOCKET }),
84+
[],
85+
)
86+
assert.deepEqual(sshAgentSocketAllowList({ ...base, authSock: 'relative.sock' }), [])
87+
assert.deepEqual(
88+
sshAgentSocketAllowList({ ...base, authSock: '/private/tmp/../tmp/agent.sock' }),
89+
[],
90+
)
91+
assert.deepEqual(sshAgentSocketAllowList({ ...base, authSock: '/', isSocket: false }), [])
92+
})
93+
94+
it('checks the path is a real unix socket', async () => {
95+
const dir = await mkdtemp(join(tmpdir(), 'copse-signing-socket-'))
96+
const socketPath = join(dir, 'agent.sock')
97+
const server = createServer()
98+
await new Promise<void>((resolve) => server.listen(socketPath, resolve))
99+
try {
100+
assert.deepEqual(
101+
await resolveSshAgentSocketAllowList({
102+
enabled: true,
103+
authSock: socketPath,
104+
platform: 'darwin',
105+
}),
106+
[socketPath],
107+
)
108+
assert.deepEqual(
109+
await resolveSshAgentSocketAllowList({
110+
enabled: true,
111+
authSock: dir,
112+
platform: 'darwin',
113+
}),
114+
[],
115+
)
116+
} finally {
117+
await new Promise<void>((resolve) => {
118+
server.close(() => {
119+
resolve()
120+
})
121+
})
122+
await rm(dir, { recursive: true, force: true })
123+
}
124+
})
125+
126+
it('adds only the socket capability to the normal commit sandbox', () => {
127+
const overlay = gitCommitSigningSandboxOverlay('/tmp/project', [LAUNCHD_SOCKET])
128+
assert.deepEqual(overlay.network, {
129+
allowedDomains: [],
130+
deniedDomains: [],
131+
allowLocalBinding: false,
132+
allowUnixSockets: [LAUNCHD_SOCKET],
133+
})
134+
assert.ok(overlay.filesystem)
135+
})
136+
})
137+
138+
describe('SSH public signing identity', () => {
139+
const publicLine = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey comment@example.test'
140+
const privateKeyMarker = ['-----BEGIN OPENSSH', 'PRIVATE KEY-----'].join(' ')
141+
142+
it('normalizes one public key and drops its comment', () => {
143+
assert.equal(
144+
parseSshPublicKey(`${publicLine}\n`),
145+
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey',
146+
)
147+
assert.equal(parseSshPublicKey(privateKeyMarker), null)
148+
assert.equal(parseSshPublicKey(`${publicLine}\n${publicLine}`), null)
149+
})
150+
151+
it('uses a public sibling instead of exposing the configured private-key path', async () => {
152+
const dir = await mkdtemp(join(tmpdir(), 'copse-signing-key-'))
153+
const privatePath = join(dir, 'id_ed25519')
154+
await writeFile(`${privatePath}.pub`, `${publicLine}\n`)
155+
try {
156+
assert.equal(
157+
await resolveInlineSshPublicSigningKey(privatePath),
158+
'key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey',
159+
)
160+
} finally {
161+
await rm(dir, { recursive: true, force: true })
162+
}
163+
})
164+
165+
it('accepts an explicitly configured public-key path', async () => {
166+
const dir = await mkdtemp(join(tmpdir(), 'copse-signing-public-key-'))
167+
const publicPath = join(dir, 'signing.pub')
168+
await writeFile(publicPath, publicLine)
169+
try {
170+
assert.equal(
171+
await resolveInlineSshPublicSigningKey(publicPath),
172+
'key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey',
173+
)
174+
} finally {
175+
await rm(dir, { recursive: true, force: true })
176+
}
177+
})
178+
179+
it('refuses symlinks and files that are not public keys', async () => {
180+
const dir = await mkdtemp(join(tmpdir(), 'copse-signing-refusal-'))
181+
const target = join(dir, 'target.pub')
182+
const link = join(dir, 'link.pub')
183+
const invalid = join(dir, 'invalid.pub')
184+
await writeFile(target, publicLine)
185+
await symlink(target, link)
186+
await writeFile(invalid, privateKeyMarker)
187+
try {
188+
assert.equal(await resolveInlineSshPublicSigningKey(link), null)
189+
assert.equal(await resolveInlineSshPublicSigningKey(invalid), null)
190+
assert.equal(await resolveInlineSshPublicSigningKey(join(dir, 'missing')), null)
191+
} finally {
192+
await rm(dir, { recursive: true, force: true })
193+
}
194+
})
195+
})
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { constants } from 'node:fs'
2+
import { open, stat } from 'node:fs/promises'
3+
import { isAbsolute, normalize } from 'node:path'
4+
import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime'
5+
import { workspaceSandboxOverlay } from './config.ts'
6+
7+
const MAX_PUBLIC_KEY_BYTES = 16 * 1024
8+
9+
/**
10+
* Return the one macOS unix socket that a native git commit may use for SSH
11+
* signing. Every rejected input fails closed to the unchanged sandbox profile.
12+
*/
13+
export function sshAgentSocketAllowList(input: {
14+
readonly enabled: boolean
15+
readonly authSock: string | undefined
16+
readonly platform: NodeJS.Platform
17+
readonly isSocket: boolean
18+
}): string[] {
19+
if (!input.enabled || input.platform !== 'darwin') return []
20+
const socketPath = input.authSock?.trim()
21+
if (!socketPath || !isAbsolute(socketPath) || normalize(socketPath) !== socketPath) return []
22+
return input.isSocket ? [socketPath] : []
23+
}
24+
25+
/** Resolve and validate the socket named by the environment given to git. */
26+
export async function resolveSshAgentSocketAllowList(input: {
27+
readonly enabled: boolean
28+
readonly authSock: string | undefined
29+
readonly platform: NodeJS.Platform
30+
}): Promise<string[]> {
31+
const socketPath = input.authSock?.trim()
32+
let isSocket = false
33+
if (socketPath) {
34+
try {
35+
isSocket = (await stat(socketPath)).isSocket()
36+
} catch {
37+
isSocket = false
38+
}
39+
}
40+
return sshAgentSocketAllowList({ ...input, isSocket })
41+
}
42+
43+
/**
44+
* Add the socket to this one git subprocess without widening its existing
45+
* filesystem or internet policy.
46+
*/
47+
export function gitCommitSigningSandboxOverlay(
48+
workspaceRoot: string,
49+
socketPaths: readonly string[],
50+
): Partial<SandboxRuntimeConfig> {
51+
const base = workspaceSandboxOverlay(workspaceRoot)
52+
if (socketPaths.length === 0) return base
53+
const network = base.network
54+
if (!network) throw new Error('workspaceSandboxOverlay must define a network config')
55+
return {
56+
...base,
57+
network: {
58+
...network,
59+
allowUnixSockets: [...new Set(socketPaths)],
60+
},
61+
}
62+
}
63+
64+
/** Parse one OpenSSH public-key line and discard its optional comment. */
65+
export function parseSshPublicKey(text: string): string | null {
66+
const line = text.trim()
67+
if (!line || /[\r\n]/.test(line)) return null
68+
const match = /^(ssh-|ecdsa-|sk-)([^\s]+)\s+([A-Za-z0-9+/]+={0,2})(?:\s+.*)?$/.exec(line)
69+
if (!match) return null
70+
const prefix = match[1]
71+
const suffix = match[2]
72+
const blob = match[3]
73+
if (!prefix || !suffix || !blob) return null
74+
const algorithm = `${prefix}${suffix}`
75+
try {
76+
if (Buffer.from(blob, 'base64').length === 0) return null
77+
} catch {
78+
return null
79+
}
80+
return `${algorithm} ${blob}`
81+
}
82+
83+
/**
84+
* Convert a configured SSH signing-key path into Git's inline public-key form.
85+
*
86+
* Git commonly stores the private-key path even when ssh-agent performs the
87+
* private operation. The project sandbox must not read that private key, so use
88+
* its sibling .pub file instead. Reading happens in Copse's trusted main process;
89+
* the sandboxed git process receives only the public identity.
90+
*/
91+
export async function resolveInlineSshPublicSigningKey(
92+
configuredPath: string,
93+
): Promise<string | null> {
94+
const trimmed = configuredPath.trim()
95+
if (!trimmed || trimmed.startsWith('key::')) return null
96+
const publicPath = trimmed.endsWith('.pub') ? trimmed : `${trimmed}.pub`
97+
if (!isAbsolute(publicPath) || normalize(publicPath) !== publicPath) return null
98+
99+
let handle
100+
try {
101+
handle = await open(publicPath, constants.O_RDONLY | constants.O_NOFOLLOW)
102+
const info = await handle.stat()
103+
if (!info.isFile() || info.size <= 0 || info.size > MAX_PUBLIC_KEY_BYTES) return null
104+
const publicKey = parseSshPublicKey(await handle.readFile({ encoding: 'utf8' }))
105+
return publicKey ? `key::${publicKey}` : null
106+
} catch {
107+
return null
108+
} finally {
109+
await handle?.close().catch(() => {})
110+
}
111+
}

0 commit comments

Comments
 (0)