Skip to content
Merged
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
@@ -1,3 +1,16 @@
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(),
diff --git a/dist/sandbox/sandbox-utils.js b/dist/sandbox/sandbox-utils.js
index 32831bf4b8fb14d9ea6fc5bcfe0dc621702c91f4..f53b047ebb0cd376a0f12a9deb3de08d185145b4 100644
--- a/dist/sandbox/sandbox-utils.js
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

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

12 changes: 9 additions & 3 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ minimumReleaseAgeExclude:
- ip-address
- undici

# Two performance patches to @anthropic-ai/sandbox-runtime, both in one patch
# file. Drop each once upstream does the same; the named test fails loudly if a
# version bump silently drops the patch.
# Three patches to @anthropic-ai/sandbox-runtime, all in one patch file. Drop
# each once upstream does the same; the named test fails loudly if a version
# bump silently drops the patch.
#
# 1. `whichSync` forks `/usr/bin/which` via spawnSync on every sandboxed command
# to locate the shell binary. That is free under Bun (`Bun.which`) but a
Expand All @@ -90,5 +90,11 @@ minimumReleaseAgeExclude:
# duration of one synchronous profile build and clears on the next microtask,
# so no resolution is ever reused by a later command. Guarded by
# `sandbox-normalize-path-memo.test.ts`.
#
# 3. `wrapWithSandbox` read `allowUnixSockets` only from the global getter, so a
# caller could not widen it for one command. Native SSH commit signing needs
# the ssh-agent socket and nothing else, so the patch lets `customConfig`
# supply the list and falls back to the getter. Guarded by
# `git-commit-signing.test.ts`.
patchedDependencies:
'@anthropic-ai/sandbox-runtime@0.0.74': patches/@anthropic-ai__sandbox-runtime@0.0.74.patch
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 })
}
})
})
Loading
Loading