@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.33 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/adl CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/bin/adl
3
- size: 82920928 bytes
4
- sha256: 3e480a5aff47ee61aec30d26dad9d06dc8f3c4b33ca4b5ed1b8ef9b4a810bb99
3
+ size: 82945504 bytes
4
+ sha256: b6594d01b2ebea8949f80faa8da029598f8be7513fb1e3c7b63ddc01df4c8f02
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amsterdamdatalabs/adl-agent-control-linux-x64",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
4
4
  "description": "adl CLI binary for linux/x64 (agent identity + Azure DevOps front door — see docs/architecture/cli-design.md)",
5
5
  "license": "UNLICENSED",
6
6
  "os": [
@@ -0,0 +1,63 @@
1
+ // PBI 661 (Tarun, 2026-10-09): agents never change branches; both denies say "ask the user for git branch operations". See docs/architecture/agent-git-branch-rules.md
2
+
3
+ import type { Invocation } from '../shell/invocation.ts'
4
+ import type { Finding, MessageContext, RuleContext, RuleSpec } from '../spec.ts'
5
+ import { noParams } from '../spec.ts'
6
+ import { branchMutates, isHelpOnlyInvocation, isSequencerRecoveryOnly, switchCheckoutCreatesBranch } from './git-helpers.ts'
7
+ import { LANDING_SUBCOMMANDS, resolveGitTarget } from './git-target.ts'
8
+ import { gitAgentTail } from './messages.ts'
9
+
10
+ const ASK_USER = 'ask the user for git branch operations'
11
+
12
+ const UNRESOLVED =
13
+ 'The commit target (which repository and branch this would land on) could not be determined statically from this command line. ' +
14
+ 'Run `git commit` as its own command, or with `git -C <path>`, so the target is explicit. '
15
+
16
+ function askUserMessage(ctx: MessageContext, unresolved = false) {
17
+ return { user: ASK_USER, agent: `DENIED ${ASK_USER}. ${unresolved ? UNRESOLVED : ''}${gitAgentTail(ctx.snippet)}` }
18
+ }
19
+
20
+ export const gitBranchMutation: RuleSpec = {
21
+ id: 'git.branch-mutation',
22
+ category: 'git',
23
+ severity: 'deny',
24
+ enabledByDefault: true,
25
+ summary: 'Deny every mutating git branch form and branch creation via git switch -c / git checkout -b.',
26
+ params: noParams,
27
+ match(inv: Invocation): Finding | null {
28
+ if (inv.kind !== 'git') return null
29
+ const hit =
30
+ inv.subcommand === 'branch' ? branchMutates(inv.rest) : inv.subcommand === 'switch' || inv.subcommand === 'checkout' ? switchCheckoutCreatesBranch(inv.subcommand, inv.rest) : false
31
+ return hit ? { ruleId: 'git.branch-mutation', severity: 'deny', detail: {} } : null
32
+ },
33
+ message: (_finding: Finding, ctx: MessageContext) => askUserMessage(ctx),
34
+ corpus: {
35
+ deny: ['git branch foo', 'git branch -D old-feature', 'git branch -aD', 'git branch --delete --force old-feature', 'git branch -m old new', 'git switch -c new-branch', 'git checkout -b new-branch'],
36
+ allow: ['git branch', 'git branch -a', 'git branch -vv', 'git branch --show-current', "git branch --list 'feat/*'", 'git switch main', 'git checkout feature-x'],
37
+ },
38
+ }
39
+
40
+ export const gitCommitProtectedBranch: RuleSpec = {
41
+ id: 'git.commit-protected-branch',
42
+ category: 'git',
43
+ severity: 'deny',
44
+ enabledByDefault: true,
45
+ summary: 'Deny git commit (and merge, cherry-pick, revert, am) while the target repo is on a protected branch, or when the target or its branch cannot be resolved.',
46
+ params: noParams,
47
+ match(inv: Invocation, ctx: RuleContext<Record<string, never>>): Finding | null {
48
+ if (inv.kind !== 'git' || !LANDING_SUBCOMMANDS.has(inv.subcommand)) return null
49
+ if (isHelpOnlyInvocation(inv.rest)) return null // prints text, commits nothing
50
+ if (inv.subcommand !== 'commit' && isSequencerRecoveryOnly(inv.rest)) return null // --abort / --quit only end an operation
51
+ const target = resolveGitTarget(inv, ctx.command)
52
+ // null target or branch = unresolved: fail closed (docs/architecture/agent-git-branch-rules.md)
53
+ const branch = target === null ? null : ctx.index.currentBranch(target)
54
+ if (branch !== null && !ctx.agentConfig.protectedBranches.includes(branch)) return null
55
+ return { ruleId: 'git.commit-protected-branch', severity: 'deny', detail: { unresolved: branch === null ? 'yes' : 'no' } }
56
+ },
57
+ message: (finding: Finding, ctx: MessageContext) => askUserMessage(ctx, finding.detail['unresolved'] === 'yes'),
58
+ // Fixture branch is never protected, so deny entries are the fail-closed ones (docs/architecture/agent-git-branch-rules.md)
59
+ corpus: {
60
+ deny: ['cd "$REPO" && git commit -m x', 'git -C "$REPO" commit -m x', 'GIT_DIR="$D" git merge origin/integration'],
61
+ allow: ["git commit -m 'feat(x): y'", 'git status'],
62
+ },
63
+ }
@@ -61,9 +61,97 @@ export function commitAmends(rest: readonly Token[]): boolean {
61
61
  return hasFlag(rest, '--amend')
62
62
  }
63
63
 
64
- export function branchForceDeletes(rest: readonly Token[]): boolean {
65
- if (hasFlagOrCluster(rest, [], ['D'])) return true
66
- return hasFlagOrCluster(rest, ['--delete'], ['d']) && hasFlagOrCluster(rest, ['--force'], ['f'])
64
+ /**
65
+ * Every long option `git branch -h` lists on git 2.56 (positive forms), plus the
66
+ * legacy `--set-upstream`. Used ONLY to resolve unique prefixes the way git's
67
+ * parse-options does; whether an option is allowed is `BRANCH_READ_ONLY_OPTIONS`.
68
+ */
69
+ const BRANCH_POSITIVE_OPTIONS: readonly string[] = [
70
+ 'verbose', 'quiet', 'track', 'set-upstream-to', 'set-upstream', 'unset-upstream', 'color', 'remotes', 'contains', 'no-contains', 'abbrev', 'all',
71
+ 'delete', 'move', 'omit-empty', 'copy', 'list', 'show-current', 'create-reflog', 'edit-description', 'delete-merged', 'dry-run', 'force', 'merged',
72
+ 'no-merged', 'forked', 'column', 'sort', 'points-at', 'ignore-case', 'recurse-submodules', 'format', 'help',
73
+ ]
74
+ const BRANCH_ALL_OPTIONS: readonly string[] = [...new Set([...BRANCH_POSITIVE_OPTIONS, ...BRANCH_POSITIVE_OPTIONS.filter((name) => !name.startsWith('no-') && name !== 'help' && name !== 'remotes' && name !== 'all').map((name) => `no-${name}`)])]
75
+ /** PBI 661 allowlist: the only `git branch` options that read and never write. Anything else (known or not) is a deny. */
76
+ const BRANCH_READ_ONLY_OPTIONS: ReadonlySet<string> = new Set([
77
+ 'verbose', 'quiet', 'color', 'remotes', 'all', 'list', 'show-current', 'omit-empty', 'ignore-case', 'dry-run', 'abbrev', 'column', 'help',
78
+ 'contains', 'no-contains', 'merged', 'no-merged', 'forked', 'sort', 'points-at', 'format',
79
+ 'no-verbose', 'no-quiet', 'no-color', 'no-abbrev', 'no-column', 'no-sort', 'no-points-at', 'no-format', 'no-omit-empty', 'no-ignore-case', 'no-dry-run',
80
+ ])
81
+ /** Read-only options whose separate next token is their value. */
82
+ const BRANCH_VALUE_OPTIONS: ReadonlySet<string> = new Set(['contains', 'no-contains', 'merged', 'no-merged', 'forked', 'sort', 'points-at', 'format'])
83
+ /** Read-only options that put `git branch` in list mode, where positionals are patterns, not new branch names. */
84
+ const BRANCH_LIST_OPTIONS: ReadonlySet<string> = new Set(['list', 'show-current', 'contains', 'no-contains', 'merged', 'no-merged', 'points-at', 'forked'])
85
+ /** `-v -q -r -a -l -i`, alone or clustered; every other short letter writes or is unknown. */
86
+ const BRANCH_READ_ONLY_LETTERS: readonly string[] = ['v', 'q', 'r', 'a', 'l', 'i']
87
+
88
+ /** git's parse-options rule: an exact name wins, else the unique name that starts with `name`; `null` when unknown or ambiguous. */
89
+ function resolveLongOption(name: string, table: readonly string[]): string | null {
90
+ if (table.includes(name)) return name
91
+ const candidates = table.filter((candidate) => candidate.startsWith(name))
92
+ return candidates.length === 1 ? (candidates[0] as string) : null
93
+ }
94
+
95
+ /**
96
+ * PBI 661: does this `git branch` argv change branches, or might it? Fail
97
+ * closed: only an argv made entirely of known read-only options (long options
98
+ * resolved by unique prefix as git does) is false. A bare positional with no
99
+ * list-mode option is a branch name to CREATE, so it is true.
100
+ */
101
+ export function branchMutates(rest: readonly Token[]): boolean {
102
+ if (isHelpOnlyInvocation(rest)) return false
103
+ let listMode = false
104
+ let positionals = 0
105
+ for (let index = 0; index < rest.length; index++) {
106
+ const value = tokenValue(rest[index] as Token)
107
+ if (value === '--') {
108
+ positionals += rest.length - index - 1
109
+ break
110
+ }
111
+ if (value.startsWith('--')) {
112
+ const name = resolveLongOption(value.slice(2).split('=')[0] as string, BRANCH_ALL_OPTIONS)
113
+ if (name === null || !BRANCH_READ_ONLY_OPTIONS.has(name)) return true
114
+ if (BRANCH_LIST_OPTIONS.has(name)) listMode = true
115
+ if (BRANCH_VALUE_OPTIONS.has(name) && !value.includes('=')) index++
116
+ } else if (value.startsWith('-') && value.length > 1) {
117
+ const letters = [...value.slice(1)]
118
+ if (letters.some((letter) => !BRANCH_READ_ONLY_LETTERS.includes(letter))) return true
119
+ if (letters.includes('l')) listMode = true
120
+ } else {
121
+ positionals++
122
+ }
123
+ }
124
+ return positionals > 0 && !listMode
125
+ }
126
+
127
+ /** Long options that create a branch, per subcommand; `NON_CREATING` are the long options whose exact name must win over a longer creating one. */
128
+ const CREATING_LONG_OPTIONS: Readonly<Record<string, readonly string[]>> = {
129
+ switch: ['create', 'force-create', 'orphan', 'track'],
130
+ checkout: ['orphan', 'track'],
131
+ }
132
+ const NON_CREATING_LONG_OPTIONS: readonly string[] = ['force', 'detach', 'discard-changes', 'merge', 'conflict', 'quiet', 'progress', 'no-progress', 'guess', 'no-guess', 'no-track', 'ignore-other-worktrees', 'recurse-submodules', 'no-recurse-submodules', 'overlay', 'no-overlay', 'ours', 'theirs', 'patch']
133
+
134
+ /**
135
+ * PBI 661: `git switch -c/-C/-t/--create/--force-create/--orphan/--track` and
136
+ * `git checkout -b/-B/-t/--orphan/--track` create a branch, including any
137
+ * unique (or ambiguous-with-a-creating) long-option prefix (`--cr`, `--force-c`,
138
+ * `--orp`, `--tr`). Plain `git switch|checkout <existing>` is not matched.
139
+ */
140
+ export function switchCheckoutCreatesBranch(subcommand: string, rest: readonly Token[]): boolean {
141
+ const longs = CREATING_LONG_OPTIONS[subcommand] ?? []
142
+ const letters = subcommand === 'switch' ? ['c', 'C', 't'] : ['b', 'B', 't']
143
+ for (const token of rest) {
144
+ const value = tokenValue(token)
145
+ if (value === '--') return false
146
+ if (value.startsWith('--')) {
147
+ const name = value.slice(2).split('=')[0] as string
148
+ if (longs.includes(name)) return true
149
+ if (name.length > 0 && !NON_CREATING_LONG_OPTIONS.includes(name) && longs.some((long) => long.startsWith(name))) return true
150
+ } else if (value.startsWith('-') && value.length > 1 && [...value.slice(1)].some((letter) => letters.includes(letter))) {
151
+ return true
152
+ }
153
+ }
154
+ return false
67
155
  }
68
156
 
69
157
  export function tagDeletes(rest: readonly Token[]): boolean {
@@ -1,18 +1,18 @@
1
1
  /**
2
- * `branch -D`, `tag -d`, `switch -f`, `gc --prune[=<date>]`.
2
+ * `tag -d`, `switch -f`, `gc --prune[=<date>]`.
3
3
  * (`update-ref` in every form lives in git-plumbing.ts.)
4
4
  *
5
5
  * Ported from `blockedGitHistoryReason` in blocked-reason.mjs:78-86. None of
6
6
  * these bypass on a bare `--help`/`--version` the way the "core" rules do —
7
7
  * the original's `blockedGitPolicyReason` is called unconditionally, with no
8
- * `rmIsHelpOnly` gate — so neither does this. All four fall through
8
+ * `rmIsHelpOnly` gate — so neither does this. All three fall through
9
9
  * `classifyReason`'s default arm.
10
10
  */
11
11
 
12
12
  import type { Invocation } from '../shell/invocation.ts'
13
13
  import type { Finding, MessageContext, RuleSpec } from '../spec.ts'
14
14
  import { noParams } from '../spec.ts'
15
- import { branchForceDeletes, gcPrunesExplicitly, switchIsForced, tagDeletes } from './git-helpers.ts'
15
+ import { gcPrunesExplicitly, switchIsForced, tagDeletes } from './git-helpers.ts'
16
16
  import { defaultGitMessage, detailString } from './messages.ts'
17
17
 
18
18
  function git(inv: Invocation): (Invocation & { kind: 'git' }) | null {
@@ -40,15 +40,6 @@ function make(id: string, subcommand: string, reasonFor: () => string, hits: (g:
40
40
  }
41
41
  }
42
42
 
43
- export const gitBranchForceDelete = make(
44
- 'git.branch-force-delete',
45
- 'branch',
46
- () => 'git branch -D',
47
- (g) => branchForceDeletes(g.rest),
48
- ['git branch -D old-feature', 'git branch -aD', 'git branch --delete --force old-feature'],
49
- ['git branch -d merged-feature', 'git branch feature-x', 'git branch -a'],
50
- )
51
-
52
43
  export const gitTagDelete = make(
53
44
  'git.tag-delete',
54
45
  'tag',
@@ -64,7 +55,7 @@ export const gitSwitchForce = make(
64
55
  () => 'git switch -f',
65
56
  (g) => switchIsForced(g.rest),
66
57
  ['git switch -f main', 'git switch --force main'],
67
- ['git switch main', 'git switch -c new-branch'],
58
+ ['git switch main', 'git switch feat/existing'],
68
59
  )
69
60
 
70
61
  export const gitGcPrune = make(
@@ -0,0 +1,423 @@
1
+ // Best-effort static resolver of the repository a commit-like git command lands on. See docs/architecture/agent-git-branch-rules.md#resolver
2
+
3
+ import { statSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import type { BranchTarget } from '../ports.ts'
6
+ import type { Invocation } from '../shell/invocation.ts'
7
+ import { withoutRedirections } from '../shell/git-internals.ts'
8
+ import { splitShell } from '../shell/split.ts'
9
+ import { findMatchingBacktick, findMatchingParen } from '../shell/substitution.ts'
10
+ import { type Token, basename, tokenValue, tokenize } from '../shell/token.ts'
11
+
12
+ // Subcommands that add history to the current branch (pull, rebase, reset, amend are excluded: see docs/architecture/agent-git-branch-rules.md)
13
+ export const LANDING_SUBCOMMANDS: ReadonlySet<string> = new Set(['commit', 'merge', 'cherry-pick', 'revert', 'am'])
14
+
15
+ const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/
16
+ const SHELLS: ReadonlySet<string> = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh'])
17
+ /** Commands that re-run another command: a shell `-c` script behind one is followed. */
18
+ const RE_EXECUTORS: ReadonlySet<string> = new Set(['xargs', 'parallel', 'env', 'nice', 'nohup', 'timeout', 'sudo', 'doas'])
19
+
20
+ // Bash builtins and reserved words: `compgen -b` + `compgen -k` on bash 5.3 (docs/architecture/agent-git-branch-rules.md#closed-builtin-sets-and-their-provenance)
21
+ const BASH_BUILTINS_AND_KEYWORDS: readonly string[] = (
22
+ '. : [ alias bg bind break builtin caller cd command compgen complete compopt continue declare dirs disown echo enable eval exec exit export false fc fg getopts hash help history jobs kill let local logout mapfile popd printf pushd pwd read readarray readonly return set shift shopt source suspend test times trap true type typeset ulimit umask unalias unset wait if then else elif fi case esac for select while until do done in function time { } ! [[ ]] coproc').split(' ')
23
+ // zsh builtins and reserved words: `zsh -c 'print ${(k)builtins} ${(k)reswords}'` on zsh 5.9 (same doc section)
24
+ const ZSH_BUILTINS_AND_RESERVED_WORDS: readonly string[] = (
25
+ 'unset rehash popd ulimit local jobs disable [ compfiles printf autoload noglob pushln zle readonly exit false times sched setopt getln builtin let bg which unhash pwd zparseopts logout disown type source eval comptags compdescribe compctl r zmodload zregexparse history return exec compadd emulate chdir ttyctl test comparguments pushd functions float zstyle print declare comptry alias shift - . bindkey typeset true hash compset cd compvalues getopts compgroups export enable limit echotc echo wait dirs unsetopt read : integer bye echoti compquote unfunction fc vared unalias kill compcall where fg zformat suspend unlimit break set continue command zcompile whence umask trap log private if else end do then { select coproc } ! case [[ repeat done for while time esac until fi nocorrect foreach elif function'
26
+ ).split(' ')
27
+ /** The closed set of shell builtins and reserved words (bash 5.3 + zsh 5.9): a head outside it is an external command or a function. */
28
+ const SHELL_BUILTINS_AND_KEYWORDS: ReadonlySet<string> = new Set([...BASH_BUILTINS_AND_KEYWORDS, ...ZSH_BUILTINS_AND_RESERVED_WORDS])
29
+ /** Builtins that only print or test: neutral (`printf -v` / `print -v` assign a variable and are not). */
30
+ const READ_ONLY_BUILTINS: ReadonlySet<string> = new Set(['echo', 'printf', 'print', 'which', 'whence', 'where', 'true', 'false', 'test', '[', '[[', 'pwd', 'type', 'hash', 'help', ':', 'times', 'dirs', 'jobs'])
31
+ /** Variables whose assignment (or export, or unset) changes what a later cd, `~` or shell startup does: unknown. */
32
+ const SENSITIVE_VARIABLES: ReadonlySet<string> = new Set(['BASH_ENV', 'ENV', 'HOME', 'CDPATH', 'ZDOTDIR', 'PATH'])
33
+ /** Variables that, as a prefix on a launched shell, change what that shell runs or means. */
34
+ const SHELL_STARTUP_VARIABLES: ReadonlySet<string> = new Set(['BASH_ENV', 'ENV', 'ZDOTDIR', 'HOME', 'CDPATH'])
35
+ /** git subcommands that move HEAD (or the worktree it is read from): after one, the branch a later landing command hits is unknown. */
36
+ const HEAD_MOVERS: ReadonlySet<string> = new Set(['switch', 'checkout', 'worktree', 'stash', 'bisect', 'rebase', 'reset'])
37
+ const GIT_VALUE_GLOBALS: ReadonlySet<string> = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env'])
38
+
39
+ /** Does this piece run `git <HEAD_MOVER> ...` (behind any wrapper)? */
40
+ function movesHead(rest: readonly Token[]): boolean {
41
+ const gitAt = rest.findIndex((token) => ['git', 'git.exe'].includes(basename(token)))
42
+ if (gitAt < 0) return false
43
+ for (let i = gitAt + 1; i < rest.length; i++) {
44
+ const value = tokenValue(rest[i] as Token)
45
+ if (value.startsWith('-')) {
46
+ if (GIT_VALUE_GLOBALS.has(value)) i++
47
+ continue
48
+ }
49
+ const subcommand = value.toLowerCase()
50
+ if (!HEAD_MOVERS.has(subcommand)) return false
51
+ // Read-only forms of stash/worktree do not move HEAD: `stash list|show`, `worktree list`.
52
+ const action = rest.slice(i + 1).map((token) => tokenValue(token)).find((arg) => !arg.startsWith('-'))
53
+ if (subcommand === 'stash' && (action === 'list' || action === 'show')) return false
54
+ if (subcommand === 'worktree' && action === 'list') return false
55
+ return true
56
+ }
57
+ return false
58
+ }
59
+
60
+ function namesDirectory(word: string): boolean {
61
+ try {
62
+ return statSync(word).isDirectory()
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+
68
+ interface Mutable {
69
+ dirs: string[]
70
+ gitDir?: string
71
+ workTree?: string
72
+ /** `HOME=` was assigned for this command: `~` no longer means the home directory we know. */
73
+ homeUnknown?: boolean
74
+ }
75
+
76
+ /** `~`, `~/x`, `$HOME`, `${HOME}` expand to the home directory; any other `$` or backtick is not static (`null`). */
77
+ function expandPath(value: string, target: Mutable): string | null {
78
+ const home = homedir()
79
+ const tilde = value === '~' || value.startsWith('~/')
80
+ if (tilde) return target.homeUnknown === true ? null : home + value.slice(1)
81
+ for (const prefix of ['${HOME}', '$HOME']) {
82
+ if (value === prefix || value.startsWith(`${prefix}/`)) return target.homeUnknown === true ? null : home + value.slice(prefix.length)
83
+ }
84
+ return /[$`]/.test(value) || value === '' ? null : value
85
+ }
86
+
87
+ /** Apply `NAME=value` env assignments we care about; false when one is not static. */
88
+ function applyEnv(target: Mutable, assignments: readonly string[]): boolean {
89
+ for (const assignment of assignments) {
90
+ const match = ASSIGNMENT_RE.exec(assignment)
91
+ if (!match) continue
92
+ const name = match[1] as string
93
+ if (name !== 'GIT_DIR' && name !== 'GIT_WORK_TREE') continue
94
+ const value = expandPath(match[2] as string, target)
95
+ if (value === null) return false
96
+ if (name === 'GIT_DIR') target.gitDir = value
97
+ else target.workTree = value
98
+ }
99
+ return true
100
+ }
101
+
102
+ function leadingAssignments(tokens: readonly Token[]): { assignments: string[]; rest: Token[] } {
103
+ let index = 0
104
+ const assignments: string[] = []
105
+ while (index < tokens.length && ASSIGNMENT_RE.test(tokenValue(tokens[index] as Token))) assignments.push(tokenValue(tokens[index++] as Token))
106
+ return { assignments, rest: tokens.slice(index) as Token[] }
107
+ }
108
+
109
+ /** `--git-dir`, `--work-tree`, `-C` among git's globals; false when one is not static. */
110
+ function applyGlobals(target: Mutable, globals: readonly Token[]): boolean {
111
+ const values = globals.map((token) => tokenValue(token))
112
+ for (let index = 0; index < values.length; index++) {
113
+ const value = values[index] as string
114
+ const take = (long: string): string | null => {
115
+ if (value === long) return values[++index] ?? ''
116
+ if (value.startsWith(`${long}=`)) return value.slice(long.length + 1)
117
+ return null
118
+ }
119
+ const gitDir = take('--git-dir')
120
+ if (gitDir !== null) {
121
+ const expanded = expandPath(gitDir, target)
122
+ if (expanded === null) return false
123
+ target.gitDir = expanded
124
+ continue
125
+ }
126
+ const workTree = take('--work-tree')
127
+ if (workTree !== null) {
128
+ const expanded = expandPath(workTree, target)
129
+ if (expanded === null) return false
130
+ target.workTree = expanded
131
+ continue
132
+ }
133
+ if (value === '-C' || (value.startsWith('-C') && value.length > 2)) {
134
+ const dir = expandPath(value === '-C' ? (values[++index] ?? '') : value.slice(2), target)
135
+ if (dir === null) return false
136
+ target.dirs.push(dir)
137
+ }
138
+ }
139
+ return true
140
+ }
141
+
142
+ /** Does this piece run our git invocation (possibly behind `env`/`command`/`builtin`/another wrapper)? */
143
+ function runsInvocation(piece: readonly Token[], inv: Invocation & { kind: 'git' }): boolean {
144
+ const tokens = withoutRedirections(piece) // the classifier's `rest` has none either (`git commit -F - <<EOF`)
145
+ const values = tokens.map((token) => tokenValue(token))
146
+ const wanted = inv.rest.map((token) => tokenValue(token))
147
+ const tail = wanted.length === 0 ? values : values.slice(-wanted.length)
148
+ const gitAt = tokens.findIndex((token) => ['git', 'git.exe'].includes(basename(token)))
149
+ return gitAt >= 0 && values.indexOf(inv.subcommand, gitAt) > gitAt && tail.length === wanted.length && tail.every((value, index) => value === wanted[index])
150
+ }
151
+
152
+ /** Assignments reaching git from the piece: leading `NAME=v`, and `env NAME=v ...`. `null` when `env` carries options we do not model. */
153
+ function pieceAssignments(tokens: readonly Token[]): string[] | null {
154
+ const { assignments, rest } = leadingAssignments(tokens)
155
+ let index = 0
156
+ while (index < rest.length && !['git', 'git.exe'].includes(basename(rest[index] as Token))) {
157
+ const name = basename(rest[index] as Token)
158
+ if (name === 'env') {
159
+ index++
160
+ while (index < rest.length && ASSIGNMENT_RE.test(tokenValue(rest[index] as Token))) assignments.push(tokenValue(rest[index++] as Token))
161
+ if (index < rest.length && tokenValue(rest[index] as Token).startsWith('-') && !['git', 'git.exe'].includes(basename(rest[index] as Token))) return null
162
+ } else if (name === 'command' || name === 'builtin') {
163
+ index++
164
+ while (index < rest.length && ['-p', '--'].includes(tokenValue(rest[index] as Token))) index++
165
+ } else {
166
+ index++ // another wrapper (nohup, time, ...): no environment of ours
167
+ }
168
+ }
169
+ return assignments
170
+ }
171
+
172
+ interface Parsed {
173
+ readonly tokens: readonly (readonly Token[] | null)[]
174
+ /** Text between consecutive pieces (operators, group characters, comments); `null` when the pieces could not be located in the source. */
175
+ readonly gaps: readonly string[] | null
176
+ /** Names of functions defined anywhere in the command (`f() {`, `function f`): a piece headed by one is unknown. */
177
+ readonly functions: ReadonlySet<string>
178
+ }
179
+
180
+ /** Heredoc delimiters opened by `<<` / `<<-` at the top level of one piece, or `'unparsable'`. Quotes, `\x` and `$(...)` are skipped. */
181
+ function heredocDelimiters(piece: string): string[] | 'unparsable' {
182
+ const delimiters: string[] = []
183
+ for (let i = 0; i < piece.length; i++) {
184
+ const char = piece[i] as string
185
+ if (char === '\\') i++
186
+ else if (char === "'") {
187
+ const close = piece.indexOf("'", i + 1)
188
+ if (close < 0) return 'unparsable'
189
+ i = close
190
+ } else if (char === '"') {
191
+ let j = i + 1
192
+ while (j < piece.length && piece[j] !== '"') j += piece[j] === '\\' ? 2 : 1
193
+ if (j >= piece.length) return 'unparsable'
194
+ i = j
195
+ } else if (char === '`') {
196
+ const close = findMatchingBacktick(piece, i)
197
+ if (close < 0) return 'unparsable'
198
+ i = close
199
+ } else if (char === '$' && piece[i + 1] === '(') {
200
+ const close = findMatchingParen(piece, i + 1)
201
+ if (close < 0) return 'unparsable'
202
+ i = close
203
+ } else if (char === '<' && piece[i + 1] === '<' && piece[i + 2] !== '<' && piece[i - 1] !== '<') {
204
+ i += 2
205
+ if (piece[i] === '-') i++
206
+ while (piece[i] === ' ' || piece[i] === '\t') i++
207
+ let word = ''
208
+ let sawWord = false
209
+ for (; i < piece.length && !/[\s;|&<>()]/.test(piece[i] as string); i++) {
210
+ sawWord = true
211
+ const c = piece[i] as string
212
+ if (c === '\\') word += piece[++i] ?? ''
213
+ else if (c === "'" || c === '"') {
214
+ const close = piece.indexOf(c, i + 1)
215
+ if (close < 0) return 'unparsable'
216
+ word += piece.slice(i + 1, close)
217
+ i = close
218
+ } else word += c
219
+ }
220
+ if (!sawWord) return 'unparsable'
221
+ delimiters.push(word)
222
+ i--
223
+ }
224
+ }
225
+ return delimiters
226
+ }
227
+
228
+ /** `null`: unparseable (parse.uncertain refuses it). `'closed'`: a heredoc we cannot account for -- fail closed. */
229
+ function parse(command: string): Parsed | null | 'closed' {
230
+ const split = splitShell(command)
231
+ if (split.invalid) return null
232
+ const tokens: (readonly Token[] | null)[] = split.pieces.map((piece) => {
233
+ const tokenized = tokenize(piece)
234
+ return tokenized.invalid ? null : tokenized.tokens
235
+ })
236
+ const functions = new Set<string>()
237
+ for (const match of command.matchAll(/([A-Za-z_][\w.:-]*)\s*\(\s*\)/g)) functions.add(match[1] as string)
238
+ for (const match of command.matchAll(/\bfunction\s+([A-Za-z_][\w.:-]*)/g)) functions.add(match[1] as string)
239
+ const gaps: string[] = []
240
+ let cursor = 0
241
+ for (const piece of split.pieces) {
242
+ const at = command.indexOf(piece, cursor)
243
+ if (at < 0) return { tokens, gaps: null, functions }
244
+ gaps.push(command.slice(cursor, at))
245
+ cursor = at + piece.length
246
+ }
247
+ // Heredoc body lines are not commands: drop them (docs/architecture/agent-git-branch-rules.md#resolver)
248
+ for (let i = 0; i < split.pieces.length; i++) {
249
+ const delimiters = heredocDelimiters(split.pieces[i] as string)
250
+ if (delimiters === 'unparsable') return 'closed'
251
+ if (delimiters.length === 0) continue
252
+ let body = i + 1
253
+ while (body < split.pieces.length && !(gaps[body] as string).includes('\n')) body++ // rest of the marker's own line
254
+ for (const delimiter of delimiters) {
255
+ let closed = false
256
+ for (; body < split.pieces.length && !closed; body++) {
257
+ closed = (split.pieces[body] as string).trim() === delimiter
258
+ tokens[body] = null
259
+ gaps[body] = '\n'
260
+ }
261
+ if (!closed) return 'closed'
262
+ }
263
+ i = body - 1
264
+ }
265
+ return { tokens, gaps, functions }
266
+ }
267
+
268
+ /** The gap as an operator when it is exactly `&&`, `;` or a newline; `null` for anything else (`||`, `|`, `&`, `(`, `{`, ...). */
269
+ function plainGap(gap: string): '&&' | ';' | null {
270
+ const normalized = gap.replace(/#[^\n]*/g, '').replace(/\s*\n\s*/g, ';').replace(/\s+/g, '').replace(/;+/g, ';')
271
+ return normalized === '&&' ? '&&' : normalized === ';' || normalized === '' ? ';' : null
272
+ }
273
+
274
+ type StatePiece =
275
+ | { kind: 'cd'; dir: string }
276
+ | { kind: 'export'; assignments: string[] }
277
+ | { kind: 'unset'; names: string[] }
278
+ | { kind: 'bad' }
279
+ | { kind: 'neutral' }
280
+
281
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
282
+ const SET_CLUSTER_RE = /^[-+][eux]*$/
283
+ const SET_O_VALUES: readonly string[] = ['pipefail', 'errexit', 'nounset']
284
+
285
+ /** `set` with only -e/-u/-x/+e/+u/+x (clusters allowed, `-euo pipefail`) and `-o|+o pipefail|errexit|nounset`. */
286
+ function harmlessSet(args: readonly string[]): boolean {
287
+ if (args.length === 0) return false
288
+ for (let i = 0; i < args.length; i++) {
289
+ const arg = args[i] as string
290
+ const cluster = /^([-+][eux]*)o$/.exec(arg)
291
+ if (cluster !== null || arg === '-o' || arg === '+o') {
292
+ if (!SET_O_VALUES.includes(args[++i] ?? '')) return false
293
+ } else if (!SET_CLUSTER_RE.test(arg) || arg.length < 2) {
294
+ return false
295
+ }
296
+ }
297
+ return true
298
+ }
299
+
300
+ function classify(piece: readonly Token[] | null, functions: ReadonlySet<string>): StatePiece {
301
+ if (piece === null) return { kind: 'neutral' }
302
+ const { assignments, rest } = leadingAssignments(withoutRedirections(piece))
303
+ const names = assignments.map((assignment) => assignment.split('=')[0] as string)
304
+ if (names.some((name) => name.startsWith('GIT_'))) return rest.length === 0 ? { kind: 'bad' } : classifyHead(rest, assignments, functions)
305
+ if (rest.length === 0) return names.some((name) => SENSITIVE_VARIABLES.has(name)) ? { kind: 'bad' } : { kind: 'neutral' }
306
+ return classifyHead(rest, assignments, functions)
307
+ }
308
+
309
+ function classifyHead(rest: readonly Token[], assignments: readonly string[], functions: ReadonlySet<string>): StatePiece {
310
+ const head = tokenValue(rest[0] as Token)
311
+ const args = rest.slice(1).map((token) => tokenValue(token))
312
+ if (/[$`]/.test(head) || functions.has(head)) return { kind: 'bad' }
313
+ if (!SHELL_BUILTINS_AND_KEYWORDS.has(head)) {
314
+ if (movesHead(rest)) return { kind: 'bad' }
315
+ // zsh autocd: a path (or a bare word naming a directory) used as a command changes directory.
316
+ if (head.includes('/') && !['git', 'git.exe'].includes(basename(rest[0] as Token))) return { kind: 'bad' }
317
+ if (head.startsWith('~') || head === '..' || (!head.includes('/') && namesDirectory(head))) return { kind: 'bad' }
318
+ // An external command cannot change this shell, but a shell it launches runs its startup files first.
319
+ const startup = assignments.some((assignment) => SHELL_STARTUP_VARIABLES.has(assignment.split('=')[0] as string))
320
+ return startup && (SHELLS.has(basename(rest[0] as Token)) || RE_EXECUTORS.has(basename(rest[0] as Token))) ? { kind: 'bad' } : { kind: 'neutral' }
321
+ }
322
+ if (READ_ONLY_BUILTINS.has(head)) return (head === 'printf' || head === 'print') && args.includes('-v') ? { kind: 'bad' } : { kind: 'neutral' }
323
+ if (head === 'command' && (args[0] === '-v' || args[0] === '-V')) return { kind: 'neutral' }
324
+ if (head === 'set') return harmlessSet(args) ? { kind: 'neutral' } : { kind: 'bad' }
325
+ if (head === 'cd' || head === 'pushd') return args.length !== 1 || args[0] === '' || (args[0] as string).startsWith('-') ? { kind: 'bad' } : { kind: 'cd', dir: args[0] as string }
326
+ if (head === 'export') {
327
+ if (args.length > 0 && args.every((arg) => /^GIT_(DIR|WORK_TREE)=/.test(arg))) return { kind: 'export', assignments: args }
328
+ return args.every((arg) => NAME_RE.test(arg.split('=')[0] as string) && !arg.startsWith('GIT_') && !SENSITIVE_VARIABLES.has(arg.split('=')[0] as string)) ? { kind: 'neutral' } : { kind: 'bad' }
329
+ }
330
+ if (head === 'unset') return args.every((arg) => NAME_RE.test(arg) && !SENSITIVE_VARIABLES.has(arg)) ? { kind: 'unset', names: args } : { kind: 'bad' }
331
+ return { kind: 'bad' }
332
+ }
333
+
334
+ // Credit or reject the pieces before `until`; fail closed on anything unknown. // See docs/architecture/agent-git-branch-rules.md#resolver
335
+ function applyState(parsed: Parsed, until: number, target: Mutable): boolean {
336
+ for (let i = 0; i < until; i++) {
337
+ const state = classify(parsed.tokens[i] ?? null, parsed.functions)
338
+ if (state.kind === 'neutral') continue
339
+ if (state.kind === 'bad') return false
340
+ if (state.kind === 'unset') {
341
+ if (state.names.includes('GIT_DIR')) delete target.gitDir
342
+ if (state.names.includes('GIT_WORK_TREE')) delete target.workTree
343
+ continue
344
+ }
345
+ if (parsed.gaps === null) return false
346
+ const before = plainGap(parsed.gaps[i] as string)
347
+ if (before === null) return false
348
+ if (before === '&&' && (i === 0 || !['cd', 'export'].includes(classify(parsed.tokens[i - 1] ?? null, parsed.functions).kind))) return false
349
+ for (let j = i + 1; j <= Math.min(until, parsed.gaps.length - 1); j++) if (plainGap(parsed.gaps[j] as string) === null) return false
350
+ if (state.kind === 'cd') {
351
+ const dir = expandPath(state.dir, target)
352
+ if (dir === null) return false
353
+ target.dirs.push(dir)
354
+ } else if (!applyEnv(target, state.assignments)) {
355
+ return false
356
+ }
357
+ }
358
+ return true
359
+ }
360
+
361
+ // The `-c` script of a (re-executor-wrapped) shell piece, or 'unsafe' when a startup-file variable is set. // See docs/architecture/agent-git-branch-rules.md#resolver
362
+ function shellScript(tokens: readonly Token[]): string | null | 'unsafe' {
363
+ const { rest } = leadingAssignments(tokens)
364
+ if (rest[0] === undefined) return null
365
+ let shellAt = 0
366
+ if (!SHELLS.has(basename(rest[0]))) {
367
+ if (!RE_EXECUTORS.has(basename(rest[0]))) return null
368
+ shellAt = rest.findIndex((token, index) => index > 0 && SHELLS.has(basename(token)))
369
+ if (shellAt < 0) return null
370
+ }
371
+ for (let index = shellAt + 1; index < rest.length - 1; index++) {
372
+ if (/^-[a-zA-Z]*c$/.test(tokenValue(rest[index] as Token))) {
373
+ const assignments = pieceAssignments(tokens)
374
+ return assignments === null || assignments.some((assignment) => SHELL_STARTUP_VARIABLES.has(assignment.split('=')[0] as string)) ? 'unsafe' : tokenValue(rest[index + 1] as Token)
375
+ }
376
+ }
377
+ return null
378
+ }
379
+
380
+ /** `undefined`: the invocation is not in `command`. `null`: it is, but its target is not static. */
381
+ function walk(command: string, inv: Invocation & { kind: 'git' }, inherited: Mutable): BranchTarget | null | undefined {
382
+ const parsed = parse(command)
383
+ if (parsed === null) return undefined // unparseable: parse.uncertain's job to refuse
384
+ if (parsed === 'closed') return null
385
+ for (let i = 0; i < parsed.tokens.length; i++) {
386
+ const tokens = parsed.tokens[i]
387
+ if (tokens === null || tokens === undefined) continue
388
+ const target: Mutable = { ...inherited, dirs: [...inherited.dirs] }
389
+ if (runsInvocation(tokens, inv)) {
390
+ const assignments = pieceAssignments(tokens)
391
+ if (assignments === null || !applyState(parsed, i, target)) return null
392
+ if (assignments.some((assignment) => assignment.startsWith('HOME='))) target.homeUnknown = true
393
+ if (!applyEnv(target, assignments) || !applyGlobals(target, inv.globals)) return null
394
+ return target
395
+ }
396
+ const script = shellScript(tokens)
397
+ if (script === null) continue
398
+ if (script === 'unsafe') {
399
+ // The startup file may do anything, but only matters when our git is in there.
400
+ const unsafeInner = tokens.some((token) => tokenValue(token).includes(inv.subcommand))
401
+ if (unsafeInner) return null
402
+ continue
403
+ }
404
+ const probe: Mutable = { ...inherited, dirs: [...inherited.dirs] }
405
+ const inner = walk(script, inv, probe)
406
+ if (inner === undefined) continue
407
+ if (inner === null || !applyState(parsed, i, target)) return null
408
+ const nested = walk(script, inv, target)
409
+ return nested ?? null
410
+ }
411
+ return undefined
412
+ }
413
+
414
+ /** The statically resolved target of `inv` inside `command`, or `null` when it cannot be resolved. */
415
+ export function resolveGitTarget(inv: Invocation & { kind: 'git' }, command: string | undefined): BranchTarget | null {
416
+ const found = command === undefined ? undefined : walk(command, inv, { dirs: [] })
417
+ if (found !== undefined) return found
418
+ // Not found as a piece (xargs, find -exec, ...): its own globals, after any unconditional cd/export before it.
419
+ const target: Mutable = { dirs: [] }
420
+ const parsed = command === undefined ? null : parse(command)
421
+ if (parsed === 'closed' || (parsed !== null && !applyState(parsed, parsed.tokens.length, target))) return null
422
+ return applyGlobals(target, inv.globals) ? target : null
423
+ }
@@ -45,6 +45,14 @@ export interface AgentPort {
45
45
  * `git.protected-tree-commit` can fail closed on "could not read the index"
46
46
  * instead of silently treating an unreadable index as a clean one.
47
47
  */
48
+ /** Where a git invocation points, statically resolved from its command line (see `impl/git-target.ts`). */
49
+ export interface BranchTarget {
50
+ /** `cd <dir>` and `git -C <dir>` values, in the order git applies them. */
51
+ readonly dirs: readonly string[]
52
+ readonly gitDir?: string
53
+ readonly workTree?: string
54
+ }
55
+
48
56
  export interface RepoIndexPort {
49
57
  stagedPaths(): readonly string[] | null
50
58
  trackedModifiedPaths(): readonly string[] | null
@@ -64,6 +72,14 @@ export interface RepoIndexPort {
64
72
  * absent) the same as `null` (present, resolved to nothing).
65
73
  */
66
74
  configuredAlias?(verb: string): string | null
75
+ /**
76
+ * The checked-out branch of the repo a git invocation targets: the hook's
77
+ * root with `target.dirs` applied as `-C <dir>` values in order (relative
78
+ * ones composed), then `--git-dir` / `--work-tree` when given. `''` for a detached HEAD, `null` when it cannot be read (not a
79
+ * repository, git missing, timeout) so `git.commit-protected-branch` can
80
+ * fail closed instead of treating an unreadable HEAD as a safe one.
81
+ */
82
+ currentBranch(target: BranchTarget): string | null
67
83
  }
68
84
 
69
85
  export const liveEnv: EnvPort = {
@@ -159,6 +175,26 @@ export function liveRepoIndex(root: string): RepoIndexPort {
159
175
  }
160
176
  },
161
177
  resolveRemoteUrl: rewriteUrl,
178
+ currentBranch: (target) => {
179
+ try {
180
+ const out = execFileSync('git', [
181
+ '-C',
182
+ root,
183
+ ...target.dirs.flatMap((dir) => ['-C', dir]),
184
+ ...(target.gitDir === undefined ? [] : ['--git-dir', target.gitDir]),
185
+ ...(target.workTree === undefined ? [] : ['--work-tree', target.workTree]),
186
+ 'branch',
187
+ '--show-current',
188
+ ], {
189
+ encoding: 'utf8',
190
+ timeout: 10_000,
191
+ stdio: ['ignore', 'pipe', 'ignore'],
192
+ })
193
+ return out.replace(/\n+$/, '')
194
+ } catch {
195
+ return null
196
+ }
197
+ },
162
198
  configuredAlias: (verb) => {
163
199
  try {
164
200
  const out = execFileSync('git', ['-C', root, 'config', '--get', `alias.${verb}`], {
@@ -29,11 +29,11 @@ import { gitStash, gitClean, gitRebase, gitFilterBranch, gitRestore, gitReset, g
29
29
  import { gitNoVerify, gitAmend } from './impl/git-verify.ts'
30
30
  import { gitProtectedTreeCommit } from './impl/git-protected-tree.ts'
31
31
  import {
32
- gitBranchForceDelete,
33
32
  gitTagDelete,
34
33
  gitSwitchForce,
35
34
  gitGcPrune,
36
35
  } from './impl/git-history.ts'
36
+ import { gitBranchMutation, gitCommitProtectedBranch } from './impl/git-branch.ts'
37
37
  import {
38
38
  gitUpdateRef,
39
39
  gitSymbolicRefWrite,
@@ -95,7 +95,8 @@ export const GIT_RULES: readonly AnyRuleSpec[] = [
95
95
  gitNoVerify,
96
96
  gitAmend,
97
97
  gitProtectedTreeCommit,
98
- gitBranchForceDelete,
98
+ gitBranchMutation,
99
+ gitCommitProtectedBranch,
99
100
  gitTagDelete,
100
101
  gitSwitchForce,
101
102
  gitWorktree,
@@ -6,6 +6,11 @@ repo's own `AGENTS.md`.
6
6
  ## Communication
7
7
  - Address me as **Tarun**.
8
8
  - Short, scannable **bullet points** — not long prose. Lead with the answer; keep reasoning tight.
9
+ - **Every reply has three parts, in this order:**
10
+ - a **TL;DR** of a few lines; information stays brief, and detail is given only when asked;
11
+ - an explicit **Recommendation**: what you would do and why, in one line;
12
+ - **one ask**: the single question or decision you need next.
13
+ - **Keep a numbered list of open points** and show it when it changes. I answer them one at a time, so never bundle several questions into one reply.
9
14
  - If a task is ambiguous, ask exactly one clarifying question.
10
15
 
11
16
  ## Truthfulness (non-negotiable)