Files
orca/src/relay/git-exec-validator.test.ts
Neil 080c95940f fix(ssh): let fork-PR worktrees add their contributor remote via the relay (#15827)
* fix(ssh): let fork-PR worktrees add their contributor remote via the relay

Creating a workspace from a fork PR on an SSH host failed with "Destructive
git remote operations are not allowed via exec". The relay's git.exec
allowlist blocked every `remote` write subcommand, but SSH fork-PR creation
has to run `git remote add <fork> <url>` on the host before it can fetch and
track the contributor's branch, so the whole create aborted.

Allow exactly the two shapes that flow needs -- `remote add <name> <url>` and
`remote remove <name>` -- validated with the same remote-name and URL rules
the relay already applies to every pushTarget-carrying RPC. Everything else
(set-url, rename, prune, extra operands, flags before the action) stays
blocked, and the URL must be a github.com clone/ssh URL, so no new reach is
granted beyond what push/fetch already accept.

`remote remove` was blocked too, which silently leaked fork remotes on SSH
hosts: worktree removal swallows the cleanup error. It works again now.

A host still running an older relay gets an actionable "reconnect to deploy
the latest relay" message instead of the raw policy error.

* test(git-exec): pin remote read/write mutation classification

Misclassifying `git remote` / `remote get-url` as mutating would flush the
relay and SSH provider git read caches on every remote probe, so pin both
directions.
2026-08-21 14:44:08 -07:00

268 lines
8.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { validateGitExecArgs } from './git-exec-validator'
function expectAllowed(args: string[]): void {
expect(() => validateGitExecArgs(args)).not.toThrow()
}
function expectBlocked(args: string[], message: string): void {
expect(() => validateGitExecArgs(args)).toThrow(message)
}
describe('validateGitExecArgs', () => {
describe('allowed read-only subcommands', () => {
it.each([
[['rev-parse', '--show-toplevel']],
[['branch', '--list']],
[['log', '--oneline', '-10']],
[['show-ref', '--heads']],
[['ls-remote', 'origin']],
[['remote', '-v']],
[['remote', 'get-url', 'origin']],
[['remote', 'show', 'origin']],
[['symbolic-ref', 'HEAD']],
[['symbolic-ref', '--short', 'HEAD']],
[['merge-base', 'main', 'HEAD']],
[['diff', '--cached', '--name-status']],
[['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff']],
[['ls-files', '--error-unmatch', 'foo.txt']],
[['config', '--get', 'user.name']],
[['config', '--get-all', 'remote.origin.url']],
[['config', '--list']],
[['config', '-l']],
[['config', '--get-regexp', 'user']],
[['check-ref-format', '--branch', 'feature/ssh-pr-head']],
[['for-each-ref', '--format=%(refname)', 'refs/remotes']],
[
[
'for-each-ref',
'--format=%(refname)%00%(refname:short)',
'--sort=-committerdate',
'refs/heads/*foo*'
]
]
])('allows %j', (args) => {
expectAllowed(args)
})
})
describe('blocked subcommands', () => {
it('rejects empty args', () => {
expectBlocked([], 'git subcommand not allowed: (empty)')
})
it.each([
'push',
'pull',
'checkout',
'reset',
'rebase',
'merge',
'stash',
'clean',
'gc',
'reflog',
'tag',
'fetch',
'worktree'
])('rejects %s', (cmd) => {
expectBlocked([cmd], 'git subcommand not allowed')
})
})
describe('global flags before subcommand', () => {
it.each([
[['-c', 'core.sshCommand=evil', 'log']],
[['--no-pager', 'log']],
[['-C', '/tmp', 'status']]
])('rejects %j', (args) => {
expectBlocked(args, 'Global git flags before the subcommand are not allowed')
})
})
describe('global denied flags', () => {
it.each([
[['log', '--output', '/tmp/leak']],
[['log', '--output=/tmp/leak']],
[['log', '-o', '/tmp/leak']],
[['rev-parse', '--exec-path=/evil']],
[['log', '--work-tree=/other']],
[['log', '--git-dir=/other/.git']],
// Pin global-deny coverage on for-each-ref so a future allowlist
// refactor that bypassed GLOBAL_DENIED_FLAGS for this subcommand fails
// loudly. The first round of for-each-ref enablement omitted these.
[['for-each-ref', '--git-dir=/other/.git', '--format=%(refname)']],
[['for-each-ref', '--output=/tmp/leak', '--format=%(refname)']],
[['for-each-ref', '--work-tree=/other']]
])('rejects %j', (args) => {
expectBlocked(args, 'Dangerous git flags are not allowed')
})
it('does not false-positive on unrelated =value flags', () => {
expectAllowed(['log', '--format=%H'])
expectAllowed(['log', '--pretty=oneline'])
})
})
describe('git config', () => {
it('rejects config without read-only flag', () => {
expectBlocked(['config', 'user.name', 'Evil'], 'restricted to read-only operations')
})
it.each([
['--add'],
['--unset'],
['--unset-all'],
['--replace-all'],
['--rename-section'],
['--remove-section'],
['--edit'],
['-e'],
['--file=/etc/passwd'],
['-f'],
['--global'],
['--system']
])('rejects config with write flag %s', (flag) => {
expectBlocked(
['config', '--list', flag, 'val'],
'git config write operations are not allowed'
)
})
})
describe('git branch', () => {
it('allows safe branch flags', () => {
expectAllowed(['branch', '--list'])
expectAllowed(['branch', '-a'])
expectAllowed(['branch', '-r'])
})
it.each(['-d', '-D', '--delete', '-m', '-M', '--move', '-c', '-C', '--copy'])(
'rejects branch %s',
(flag) => {
expectBlocked(['branch', flag, 'name'], 'Destructive git branch flags')
}
)
it('catches --delete=value compound syntax', () => {
expectBlocked(['branch', '--delete=feature'], 'Destructive git branch flags')
})
})
describe('git remote', () => {
it.each(['rm', 'rename', 'set-url', 'set-head', 'set-branches', 'prune', 'update'])(
'rejects remote %s',
(subcmd) => {
expectBlocked(['remote', subcmd, 'arg'], 'Destructive git remote operations')
}
)
it('skips flags when finding remote subcommand', () => {
expectBlocked(['remote', '-v', 'add', 'evil', 'url'], 'Destructive git remote operations')
})
it('allows the fork-PR remote add and remove shapes', () => {
expectAllowed([
'remote',
'add',
'pr-contributor-orca',
'https://github.com/contributor/orca.git'
])
expectAllowed(['remote', 'add', 'pr-contributor-orca', 'git@github.com:contributor/orca.git'])
expectAllowed(['remote', 'remove', 'pr-contributor-orca'])
})
it.each([
// Extra or missing operands are not a shape main ever sends.
[['remote', 'add', 'fork']],
[['remote', 'add', 'fork', 'https://github.com/contributor/orca.git', '--tags']],
[['remote', 'remove']],
[['remote', 'remove', 'fork', 'extra']],
// Names and URLs must pass the same rules the pushTarget RPCs enforce.
[['remote', 'add', '--mirror=push', 'https://github.com/contributor/orca.git']],
[['remote', 'add', '../escape', 'https://github.com/contributor/orca.git']],
[['remote', 'remove', '-f']],
[['remote', 'add', 'fork', 'ext::sh -c payload']],
[['remote', 'add', 'fork', 'https://evil.test/contributor/orca.git']],
[['remote', 'add', 'fork', '/etc/passwd']]
])('rejects unsafe remote write args %j', (args) => {
expectBlocked(args, 'Destructive git remote operations')
})
})
describe('git symbolic-ref', () => {
it('allows read operations', () => {
expectAllowed(['symbolic-ref', 'HEAD'])
expectAllowed(['symbolic-ref', '--short', 'HEAD'])
expectAllowed(['symbolic-ref', '-q', 'HEAD'])
})
it.each(['-d', '--delete', '-m'])('rejects symbolic-ref %s', (flag) => {
expectBlocked(['symbolic-ref', flag, 'HEAD'], 'git symbolic-ref write operations')
})
it('rejects two positional args (write form)', () => {
expectBlocked(
['symbolic-ref', 'HEAD', 'refs/heads/main'],
'git symbolic-ref write operations'
)
})
it('catches --delete=value compound syntax', () => {
expectBlocked(['symbolic-ref', '--delete=HEAD'], 'git symbolic-ref write operations')
})
})
describe('git diff', () => {
it('rejects unstaged diff reads', () => {
expectBlocked(['diff', '--name-status'], 'restricted to staged changes')
})
it('rejects arbitrary refs and pathspecs', () => {
expectBlocked(['diff', '--cached', 'HEAD~1'], 'git diff flag not allowed')
expectBlocked(['diff', '--cached', '--', 'src/file.ts'], 'git diff flag not allowed')
})
it('rejects no-index reads', () => {
expectBlocked(['diff', '--cached', '--no-index', '/etc/passwd'], 'git diff flag not allowed')
})
})
describe('git clone', () => {
it('allows only the project setup clone shape', () => {
expectAllowed(['clone', '--', 'https://github.com/stablyai/orca.git', 'orca'])
expectAllowed(['clone', '--progress', '--', 'git@github.com:stablyai/orca.git', 'orca'])
})
it.each([
[['clone', 'https://github.com/stablyai/orca.git']],
[['clone', 'https://github.com/stablyai/orca.git', 'orca']],
[['clone', '--depth=1', '--', 'https://github.com/stablyai/orca.git', 'orca']],
[['clone', '--', 'https://github.com/stablyai/orca.git', '.']],
[['clone', '--', 'https://github.com/stablyai/orca.git', '..']],
[['clone', '--', 'https://github.com/stablyai/orca.git', 'nested/orca']],
[['clone', '--', 'https://github.com/stablyai/orca.git', 'nested\\orca']]
])('rejects unsafe clone args %j', (args) => {
expectBlocked(args, 'git clone')
})
})
describe('git init and empty commit', () => {
it('allows only the SSH create-project init and empty commit shapes', () => {
expectAllowed(['init'])
expectAllowed(['commit', '--allow-empty', '-m', 'Initial commit'])
})
it.each([
[['init', '--bare']],
[['init', '/tmp/other']],
[['commit']],
[['commit', '-am', 'message']],
[['commit', '--allow-empty']],
[['commit', '--allow-empty', '-m', '']]
])('rejects unsafe create-project write args %j', (args) => {
expectBlocked(args, 'via exec is restricted')
})
})
})