Skip to content
Closed
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
19 changes: 19 additions & 0 deletions docs/shell-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ The in-memory grant disappears on restart. The decision record does not: an answ
`decision` spine event at `scope: external-read`, including the paths and whether the grant was
remembered. Each later allowed command records a verdict sourced to `read-outside-grant`.

## Native commit signing

Copse's native `git_commit` tool honours the repository's Git signing configuration while keeping
the commit subprocess inside the project sandbox. On macOS, Settings › Permissions offers an
off-by-default grant that lets only that commit subprocess connect to the single Unix socket named
by `SSH_AUTH_SOCK`. The path must be absolute, normalised, and a socket at the time of use. Internet
access remains denied.

The grant is explicit because ssh-agent has no commit-only operation: Git hooks inherit the commit
sandbox and can ask the agent to use any loaded key. Recommend `ssh-add -c` when enabling it. Linux
does not receive the grant because seccomp cannot restrict Unix sockets by path; Windows has no
project sandbox.

When `user.signingKey` names a private-key path, Copse reads only its non-symlink `.pub` sibling
in the trusted main process and passes the public identity to Git as an inline `key::` value. The
sandbox never gains read access to the private key or the `.ssh` directory. A small pinned patch to
`@anthropic-ai/sandbox-runtime` makes its documented per-spawn `allowUnixSockets` option reach the
macOS seatbelt profile; remove that patch once upstream ships the equivalent fix.

## What an approval prompt says

Classifier reasons are **identifiers, not copy**. The regex pass and the token pass share them
Expand Down
13 changes: 13 additions & 0 deletions patches/@anthropic-ai__sandbox-runtime@0.0.74.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
diff --git a/dist/sandbox/sandbox-manager.js b/dist/sandbox/sandbox-manager.js
index 3a2d9c8abd01fefff36edea0cd76e9c31a3ed411..7e422b7a159da0ae00902cefcd5d38ac501c223c 100644
--- a/dist/sandbox/sandbox-manager.js
+++ b/dist/sandbox/sandbox-manager.js
@@ -1293,7 +1293,7 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal, opt
unsetEnvVars: credentialRestrictions.unsetEnvVars,
setEnvVars: credentialRestrictions.setEnvVars,
maskedFileBinds: credentialRestrictions.maskedFileBinds,
- allowUnixSockets: getAllowUnixSockets(),
+ allowUnixSockets: customConfig?.network?.allowUnixSockets ?? getAllowUnixSockets(),
allowAllUnixSockets: getAllowAllUnixSockets(),
allowLocalBinding: getAllowLocalBinding(),
allowMachLookup: getAllowMachLookup(),
9 changes: 7 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ allowBuilds:
protobufjs: true
sharp: true

patchedDependencies:
'@anthropic-ai/sandbox-runtime@0.0.74': patches/@anthropic-ai__sandbox-runtime@0.0.74.patch

# Release packaging cross-builds Intel and Apple Silicon apps on one macOS
# runner. Keep both native keyring packages installed so electron-builder can
# place the matching binary in each target instead of inheriting the host CPU.
Expand Down
195 changes: 195 additions & 0 deletions src/main/project-sandbox/git-commit-signing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { createServer } from 'node:net'
import { createRequire } from 'node:module'
import { SandboxManager } from '@anthropic-ai/sandbox-runtime'
import {
gitCommitSigningSandboxOverlay,
parseSshPublicKey,
resolveInlineSshPublicSigningKey,
resolveSshAgentSocketAllowList,
sshAgentSocketAllowList,
} from './git-commit-signing.ts'

const LAUNCHD_SOCKET = '/private/tmp/com.apple.launchd.example/Listeners'
const require = createRequire(import.meta.url)

describe('sandbox-runtime unix-socket patch', () => {
it('honours the per-spawn socket list instead of the process-global list', async () => {
const entry = require.resolve('@anthropic-ai/sandbox-runtime')
const manager = join(dirname(entry), 'sandbox', 'sandbox-manager.js')
const source = await readFile(manager, 'utf8')
assert.match(
source,
/allowUnixSockets: customConfig\?\.network\?\.allowUnixSockets \?\? getAllowUnixSockets\(\)/,
)
})

it(
'emits the per-spawn socket in the generated macOS seatbelt profile',
{ skip: process.platform !== 'darwin' },
async () => {
await SandboxManager.initialize(
{
network: { allowedDomains: [], deniedDomains: [] },
filesystem: { denyRead: [], allowWrite: [], denyWrite: [], allowGitConfig: true },
},
() => Promise.resolve(false),
false,
)
try {
const wrapped = await SandboxManager.wrapWithSandboxArgv('true', '/bin/zsh', {
network: {
allowedDomains: [],
deniedDomains: [],
allowUnixSockets: [LAUNCHD_SOCKET],
},
})
assert.ok(
wrapped.argv.some((arg) =>
arg.includes(`network-outbound (remote unix-socket (subpath "${LAUNCHD_SOCKET}"))`),
),
)
} finally {
await SandboxManager.reset()
}
},
)
})

describe('sshAgentSocketAllowList', () => {
it('admits exactly the named socket on macOS after opt-in', () => {
assert.deepEqual(
sshAgentSocketAllowList({
enabled: true,
authSock: LAUNCHD_SOCKET,
platform: 'darwin',
isSocket: true,
}),
[LAUNCHD_SOCKET],
)
})

it('fails closed for opt-out, unsupported platforms, and unsafe paths', () => {
const base = { enabled: true, platform: 'darwin' as const, isSocket: true }
assert.deepEqual(
sshAgentSocketAllowList({ ...base, enabled: false, authSock: LAUNCHD_SOCKET }),
[],
)
assert.deepEqual(
sshAgentSocketAllowList({ ...base, platform: 'linux', authSock: LAUNCHD_SOCKET }),
[],
)
assert.deepEqual(sshAgentSocketAllowList({ ...base, authSock: 'relative.sock' }), [])
assert.deepEqual(
sshAgentSocketAllowList({ ...base, authSock: '/private/tmp/../tmp/agent.sock' }),
[],
)
assert.deepEqual(sshAgentSocketAllowList({ ...base, authSock: '/', isSocket: false }), [])
})

it('checks the path is a real unix socket', async () => {
const dir = await mkdtemp(join(tmpdir(), 'copse-signing-socket-'))
const socketPath = join(dir, 'agent.sock')
const server = createServer()
await new Promise<void>((resolve) => server.listen(socketPath, resolve))
try {
assert.deepEqual(
await resolveSshAgentSocketAllowList({
enabled: true,
authSock: socketPath,
platform: 'darwin',
}),
[socketPath],
)
assert.deepEqual(
await resolveSshAgentSocketAllowList({
enabled: true,
authSock: dir,
platform: 'darwin',
}),
[],
)
} finally {
await new Promise<void>((resolve) => {
server.close(() => {
resolve()
})
})
await rm(dir, { recursive: true, force: true })
}
})

it('adds only the socket capability to the normal commit sandbox', () => {
const overlay = gitCommitSigningSandboxOverlay('/tmp/project', [LAUNCHD_SOCKET])
assert.deepEqual(overlay.network, {
allowedDomains: [],
deniedDomains: [],
allowLocalBinding: false,
allowUnixSockets: [LAUNCHD_SOCKET],
})
assert.ok(overlay.filesystem)
})
})

describe('SSH public signing identity', () => {
const publicLine = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey comment@example.test'
const privateKeyMarker = ['-----BEGIN OPENSSH', 'PRIVATE KEY-----'].join(' ')

it('normalizes one public key and drops its comment', () => {
assert.equal(
parseSshPublicKey(`${publicLine}\n`),
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey',
)
assert.equal(parseSshPublicKey(privateKeyMarker), null)
assert.equal(parseSshPublicKey(`${publicLine}\n${publicLine}`), null)
})

it('uses a public sibling instead of exposing the configured private-key path', async () => {
const dir = await mkdtemp(join(tmpdir(), 'copse-signing-key-'))
const privatePath = join(dir, 'id_ed25519')
await writeFile(`${privatePath}.pub`, `${publicLine}\n`)
try {
assert.equal(
await resolveInlineSshPublicSigningKey(privatePath),
'key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey',
)
} finally {
await rm(dir, { recursive: true, force: true })
}
})

it('accepts an explicitly configured public-key path', async () => {
const dir = await mkdtemp(join(tmpdir(), 'copse-signing-public-key-'))
const publicPath = join(dir, 'signing.pub')
await writeFile(publicPath, publicLine)
try {
assert.equal(
await resolveInlineSshPublicSigningKey(publicPath),
'key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey',
)
} finally {
await rm(dir, { recursive: true, force: true })
}
})

it('refuses symlinks and files that are not public keys', async () => {
const dir = await mkdtemp(join(tmpdir(), 'copse-signing-refusal-'))
const target = join(dir, 'target.pub')
const link = join(dir, 'link.pub')
const invalid = join(dir, 'invalid.pub')
await writeFile(target, publicLine)
await symlink(target, link)
await writeFile(invalid, privateKeyMarker)
try {
assert.equal(await resolveInlineSshPublicSigningKey(link), null)
assert.equal(await resolveInlineSshPublicSigningKey(invalid), null)
assert.equal(await resolveInlineSshPublicSigningKey(join(dir, 'missing')), null)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
111 changes: 111 additions & 0 deletions src/main/project-sandbox/git-commit-signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { constants } from 'node:fs'
import { open, stat } from 'node:fs/promises'
import { isAbsolute, normalize } from 'node:path'
import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime'
import { workspaceSandboxOverlay } from './config.ts'

const MAX_PUBLIC_KEY_BYTES = 16 * 1024

/**
* Return the one macOS unix socket that a native git commit may use for SSH
* signing. Every rejected input fails closed to the unchanged sandbox profile.
*/
export function sshAgentSocketAllowList(input: {
readonly enabled: boolean
readonly authSock: string | undefined
readonly platform: NodeJS.Platform
readonly isSocket: boolean
}): string[] {
if (!input.enabled || input.platform !== 'darwin') return []
const socketPath = input.authSock?.trim()
if (!socketPath || !isAbsolute(socketPath) || normalize(socketPath) !== socketPath) return []
return input.isSocket ? [socketPath] : []
}

/** Resolve and validate the socket named by the environment given to git. */
export async function resolveSshAgentSocketAllowList(input: {
readonly enabled: boolean
readonly authSock: string | undefined
readonly platform: NodeJS.Platform
}): Promise<string[]> {
const socketPath = input.authSock?.trim()
let isSocket = false
if (socketPath) {
try {
isSocket = (await stat(socketPath)).isSocket()
} catch {
isSocket = false
}
}
return sshAgentSocketAllowList({ ...input, isSocket })
}

/**
* Add the socket to this one git subprocess without widening its existing
* filesystem or internet policy.
*/
export function gitCommitSigningSandboxOverlay(
workspaceRoot: string,
socketPaths: readonly string[],
): Partial<SandboxRuntimeConfig> {
const base = workspaceSandboxOverlay(workspaceRoot)
if (socketPaths.length === 0) return base
const network = base.network
if (!network) throw new Error('workspaceSandboxOverlay must define a network config')
return {
...base,
network: {
...network,
allowUnixSockets: [...new Set(socketPaths)],
},
}
}

/** Parse one OpenSSH public-key line and discard its optional comment. */
export function parseSshPublicKey(text: string): string | null {
const line = text.trim()
if (!line || /[\r\n]/.test(line)) return null
const match = /^(ssh-|ecdsa-|sk-)([^\s]+)\s+([A-Za-z0-9+/]+={0,2})(?:\s+.*)?$/.exec(line)
if (!match) return null
const prefix = match[1]
const suffix = match[2]
const blob = match[3]
if (!prefix || !suffix || !blob) return null
const algorithm = `${prefix}${suffix}`
try {
if (Buffer.from(blob, 'base64').length === 0) return null
} catch {
return null
}
return `${algorithm} ${blob}`
}

/**
* Convert a configured SSH signing-key path into Git's inline public-key form.
*
* Git commonly stores the private-key path even when ssh-agent performs the
* private operation. The project sandbox must not read that private key, so use
* its sibling .pub file instead. Reading happens in Copse's trusted main process;
* the sandboxed git process receives only the public identity.
*/
export async function resolveInlineSshPublicSigningKey(
configuredPath: string,
): Promise<string | null> {
const trimmed = configuredPath.trim()
if (!trimmed || trimmed.startsWith('key::')) return null
const publicPath = trimmed.endsWith('.pub') ? trimmed : `${trimmed}.pub`
if (!isAbsolute(publicPath) || normalize(publicPath) !== publicPath) return null

let handle
try {
handle = await open(publicPath, constants.O_RDONLY | constants.O_NOFOLLOW)
const info = await handle.stat()
if (!info.isFile() || info.size <= 0 || info.size > MAX_PUBLIC_KEY_BYTES) return null
const publicKey = parseSshPublicKey(await handle.readFile({ encoding: 'utf8' }))
return publicKey ? `key::${publicKey}` : null
} catch {
return null
} finally {
await handle?.close().catch(() => {})
}
}
Loading
Loading