Blocks AI attribution in commits and PRs, direct commits to protected branches, commits under the wrong git identity, and mutating kubectl, helm and terraform…

Claude Code mods you can install one at a time. guard-rules, secret-box, prompt-gate and skill-pick also run on Codex and OpenCode, and statusline and delegation-board on OpenCode; what works on which host, and why.
Mods are plugins built on the hooks-module API. They run with your permissions and are not sandboxed: read a mod before installing it. Each mod lists what leaves your machine (egress) in its README and in mods.json.
claude plugin marketplace add iuliandita/mods claude plugin install <mod>@iuliandita-mods
Run it in a terminal. It works for Claude Code and loads in Claude Desktop sessions too.
These calls install from the GitHub marketplace. From a clone, scripts/mods install <mod> registers that clone as the marketplace instead (or --source <owner/repo>).
For Codex and OpenCode, see the mod's README and INSTALL.md. OpenCode hook plugins use opencode.json; its terminal UI plugins use the global cli.json instead. Prompt advisers make model calls even in their default dry mode; check the host's costs and privacy limits before installing them.
guard-rules: blocks AI attribution in commits and PRs, direct commits to protected branches, commits under the wrong git identity, and mutating kubectl, helm and terraform calls on protected or unclassified environments.secret-box: keeps secret values out of the chat; a secret placeholder in a Bash command is filled in at execution time from the OS keyring.prompt-gate: before the main model spends a turn on a vague prompt, a small model says so and asks one short question. With a TypeSafe API key in the environment the prompt goes to a third party (Jev); jevPaths restricts where.skill-pick: names the 1 to 5 installed skills that fit each prompt. With a TypeSafe API key the prompt and skill descriptions go to a third party (Jev); jevPaths restricts where.delegation-board: a live pane of the session's subagents and teammates: type, model, effort, state, elapsed time and tokens; in Claude Desktop, a compact band above the prompt.statusline: a two-row status line above the prompt in the terminal and Claude Desktop: model and effort, git state, context against your auto-compact window, 5h and 7d limits with pace, cache, cost and time. In a git repo with a GitHub or GitLab remote it runs gh or glab for PR and issue counts; forge=off turns that off.mods.json has the full catalog: egress, modes, requirements, and the install command per mod. All six mods have recorded Claude terminal checks; the compatibility page lists the tested behavior and versions. Other Claude surfaces remain unverified.
Put your own mods in local/mods/<name>/ (gitignored) and install them with scripts/mods install <name> --local.
MIT
Made by iuliandita. Other projects.
See installation and recovery, contributing, documentation, and private security reporting.
agents provides global prompts and role definitions; skills provides individually installed task guidance. Installing one collection does not install the others.
hooks/register.ts 64 lines1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3import { applyGuardCommand, type Classes } from './context'
4import { evaluate, formatDeny, parseConfig, type Env } from './rules'
5import { extractCalls } from './shell'
6
7async function stored($: Engine): Promise<Classes> {
8 const raw = await $.store.get('contexts')
9 if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {}
10 return Object.fromEntries(Object.entries(raw).filter((entry): entry is [string, 'protected' | 'relaxed'] => entry[1] === 'protected' || entry[1] === 'relaxed'))
11}
12
13export const register: Register = (on, options) => {
14 const cfg = parseConfig(options)
15
16 on('session.start', async ($, e, next) => {
17 await $.command.register({
18 name: 'guard-context',
19 description: 'Classify a kubectl/helm/terraform environment on this machine.',
20 argumentHint: '<name> protected|relaxed | forget <name> | list',
21 })
22 return next(e)
23 })
24
25 on('command.run', { command: 'guard-context' }, async ($, e) => {
26 const result = applyGuardCommand(e.args, e.origin, await stored($), cfg)
27 if (result.stored) await $.store.set('contexts', result.stored)
28 return { text: result.text }
29 }).catch(() => ({ text: 'guard-context: the command failed, nothing was changed. Check the debug log.' }))
30
31 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
32 const probe = extractCalls(e.command, '/', '/')
33 if (probe.ok && probe.calls.length === 0) return next(e)
34 const run = async (dir: string, argv: string[], vars?: Record<string, string>) => {
35 try {
36 const r = await $.process.run(argv, { cwd: dir, timeoutMs: 10_000, ...(vars && Object.keys(vars).length ? { env: vars } : {}) })
37 return { ok: r.exitCode === 0, out: r.stdout.trim() }
38 } catch {
39 return { ok: false, out: '', failed: true }
40 }
41 }
42 const env: Env = {
43 cwd: await $.session.cwd(),
44 home: (await $.env.get('HOME')) ?? '/',
45 git: (dir, args, vars) => run(dir, ['git', '-C', dir, ...args], vars),
46 run,
47 contexts: () => stored($),
48 read: async (path) => {
49 try {
50 return await $.fs.read(path)
51 } catch {
52 return null
53 }
54 },
55 }
56 const verdict = await evaluate(e.command, cfg, env)
57 if (verdict.dry.length) $.ui.toast(`guard-rules (dry run): ${verdict.dry.map((v) => v.message).join(' | ')}`)
58 if (verdict.enforced.length) return { deny: formatDeny(verdict.enforced) }
59 return next(e)
60 }).catch(($, e, next) =>
61 next.called ? next(e) : { deny: 'guard-rules: the guard failed, so the command was blocked (fail closed). Check the plugin options and the debug log. If this keeps happening, set modeAttribution, modeProtectedBranch and modeIdentity to off in the plugin options (modeContext for kubectl, helm and terraform), or disable the plugin.' },
62 )
63}
64hooks/context.ts 353 lines1import { createTracker, REAL, UNKNOWN, type Tracker } from './flow'
2import type { Config, Env, Violation } from './rules'
3import { hasVariable } from './paths'
4import { positionals, resolvePath, takesValue, type InfraCall } from './shell'
5
6export type Class = 'protected' | 'relaxed'
7export type Classes = Record<string, Class>
8export type Origin = { kind: string } | undefined
9
10// Anything not listed is mutating: an unknown verb or plugin fails closed.
11const KUBECTL_READ = new Set([
12 'get', 'describe', 'logs', 'top', 'explain', 'api-resources', 'api-versions', 'version', 'diff', 'cluster-info', 'wait',
13 'events', 'completion', 'kustomize', 'port-forward', 'plugin', 'config',
14])
15const KUBECTL_ROLLOUT_READ = new Set(['status', 'history'])
16const KUBECTL_AUTH_READ = new Set(['can-i', 'whoami'])
17const HELM_READ = new Set([
18 'list', 'ls', 'status', 'get', 'show', 'template', 'lint', 'diff', 'history', 'search', 'repo', 'env', 'version', 'verify',
19 'dependency', 'plugin', 'completion', 'pull', 'package',
20])
21const TERRAFORM_READ = new Set([
22 'plan', 'validate', 'fmt', 'init', 'show', 'output', 'providers', 'version', 'graph', 'console', 'get', 'login', 'logout', 'metadata',
23])
24const TERRAFORM_WORKSPACE_READ = new Set(['show', 'list', 'select'])
25// `init` flags that move or drop backend state.
26const TERRAFORM_INIT_WRITE = /^--?(?:migrate-state|force-copy|reconfigure)(?:=(?!false$)|$)/
27const OR_CREATE = /^--?or-create(?:=(?!false$)|$)/
28const TERRAFORM_STATE_READ = new Set(['list', 'show', 'pull'])
29// Verbs that take --dry-run; it never turns any other verb into a read.
30const KUBECTL_DRY = new Set([
31 'apply', 'create', 'delete', 'replace', 'patch', 'edit', 'scale', 'autoscale', 'drain', 'cordon', 'uncordon', 'taint', 'label',
32 'annotate', 'set', 'expose', 'run', 'rollout',
33])
34const HELM_DRY = new Set(['install', 'upgrade', 'uninstall', 'delete', 'rollback'])
35const DRY_VALUES = new Set(['client', 'server', 'true'])
36
37const CUSTOM = '(custom target)'
38// Helm variables that point at a cluster no context name describes.
39const HELM_TARGET_ENV = ['HELM_KUBEAPISERVER', 'HELM_KUBETOKEN', 'HELM_KUBECAFILE']
40const NAME_LIMIT = 200
41const CONTROL = /[\u0000-\u001f\u007f]/
42const CONTROL_ALL = new RegExp(CONTROL.source, 'g')
43const USAGE = 'usage: /guard-context <name> protected|relaxed, /guard-context forget <name>, /guard-context list'
44
45// The last --dry-run flag wins, as in the tools' own flag parsing; a word that may be an option's value is skipped.
46function isDryRun(call: InfraCall): boolean {
47 let dry = false
48 const args = call.args
49 for (let i = 0; i < args.length; i++) {
50 const a = args[i]!
51 if (a === '--') break
52 if (a === '--dry-run') dry = true
53 else if (a.startsWith('--dry-run=')) dry = DRY_VALUES.has(a.slice('--dry-run='.length))
54 else if (takesValue(call.tool, a)) i++
55 }
56 return dry
57}
58
59export function isMutating(call: InfraCall): boolean {
60 if (call.unsure) return true
61 const next = positionals(call)[0] ?? ''
62 if (call.tool === 'kubectl') {
63 if (KUBECTL_READ.has(call.sub)) return false
64 if (call.sub === 'rollout' && KUBECTL_ROLLOUT_READ.has(next)) return false
65 if (call.sub === 'auth' && KUBECTL_AUTH_READ.has(next)) return false
66 return !(KUBECTL_DRY.has(call.sub) && isDryRun(call))
67 }
68 if (call.tool === 'helm') return !HELM_READ.has(call.sub) && !(HELM_DRY.has(call.sub) && isDryRun(call))
69 if (call.sub === 'init') return call.args.some((a) => TERRAFORM_INIT_WRITE.test(a))
70 if (TERRAFORM_READ.has(call.sub)) return false
71 if (call.sub === 'workspace') return !TERRAFORM_WORKSPACE_READ.has(next) || call.args.some((a) => OR_CREATE.test(a))
72 if (call.sub === 'state') return !TERRAFORM_STATE_READ.has(next)
73 return true
74}
75
76const BOUNDARY = '[-_./:@]'
77
78// `bounded`: a `*` next to a literal must meet it at a token boundary (`-_./:@`), so the relaxed `*dev*` matches
79// `dev` and `my-dev-x` but not `devops-eu`. Protected patterns stay plain substring globs.
80function matches(patterns: string[], name: string, bounded = false): boolean {
81 return patterns.some((p) => {
82 const parts = p.toLowerCase().split('*')
83 let body = esc(parts[0]!)
84 for (let i = 1; i < parts.length; i++) {
85 const left = parts[i - 1]!
86 const right = parts[i]!
87 // A literal edge that is itself a boundary needs none from the star.
88 const needLeft = bounded && left !== '' && !new RegExp(`${BOUNDARY}$`).test(left)
89 const needRight = bounded && right !== '' && !new RegExp(`^${BOUNDARY}`).test(right)
90 const star = needLeft && needRight ? `(?:${BOUNDARY}(?:.*${BOUNDARY})?)?` : needLeft ? `(?:${BOUNDARY}.*)?` : needRight ? `(?:.*${BOUNDARY})?` : '.*'
91 body += star + esc(right)
92 }
93 return new RegExp(`^${body}$`).test(name)
94 })
95}
96
97function esc(s: string): string {
98 return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
99}
100
101export function classify(name: string, stored: Classes, cfg: Pick<Config, 'protectedContexts' | 'relaxedContexts'>): Class | 'unknown' {
102 const n = name.toLowerCase()
103 const own = Object.prototype.hasOwnProperty.call(stored, n) ? stored[n] : undefined
104 if (own === 'protected' || own === 'relaxed') return own
105 if (matches(cfg.protectedContexts, n)) return 'protected'
106 if (matches(cfg.relaxedContexts, n, true)) return 'relaxed'
107 return 'unknown'
108}
109
110function shown(name: string): string {
111 return name.replace(CONTROL_ALL, '?').slice(0, 100)
112}
113
114// What earlier calls of one command may have switched, as possibility sets: the kube context per kubeconfig, and the
115// terraform workspace per tool and directory (REAL: not switched). `shown` memoizes `workspace show` per tool and directory.
116export type ContextState = { contexts: Tracker<string>; workspaces: Tracker<string>; shown: Map<string, string | null> }
117
118export function newContextState(): ContextState {
119 return { contexts: createTracker<string>((v) => v), workspaces: createTracker<string>((v) => v), shown: new Map() }
120}
121
122export function stepContext(state: ContextState, chain: number): void {
123 state.contexts.step(chain)
124 state.workspaces.step(chain)
125}
126
127function kubeconfigOf(call: InfraCall): string {
128 return call.kubeconfig || call.assigns.KUBECONFIG || ''
129}
130
131// The switch-state key of a call's kubeconfig: each entry resolved against the call's directory, so `./c` and `c`
132// share state and `./c` in two directories does not; no kubeconfig is the default file.
133function kubeKey(call: InfraCall, home: string): string {
134 const entries = kubeconfigOf(call).split(':').filter(Boolean)
135 if (!entries.length) return resolvePath('/', '~/.kube/config', home)
136 return entries.map((p) => (/[$`]/.test(p) ? p : resolvePath(call.dir, p, home))).join(':')
137}
138
139// `kubectl config` verbs that only read; any other verb (`set-cluster`, `set-credentials`, `delete-*`, a plugin or an
140// unknown one) may write the kubeconfig in ways the guard does not follow.
141const CONFIG_READ = new Set(['view', 'get-contexts', 'current-context', 'get-clusters', 'get-users'])
142
143// A name built from a variable or substitution is whatever it expands to, which the guard cannot tell.
144function known(name: string): string | typeof UNKNOWN {
145 return hasVariable(name) ? UNKNOWN : name
146}
147
148const CTX_PLUGINS = new Set(['ctx', 'kubectx', 'switch', 'konfig'])
149const CTX_READ = new Set(['-c', '--current', '-h', '--help'])
150
151export function followSwitch(call: InfraCall, state: ContextState, home: string): void {
152 const [verb, target, value] = positionals(call)
153 const key = kubeKey(call, home)
154 if (call.tool === 'kubectl' && call.sub === 'config') {
155 // `use` is an alias of `use-context`.
156 if ((verb === 'use-context' || verb === 'use') && target) state.contexts.set(key, [known(target)], call.flow)
157 // `config set current-context X` is a switch; other writes leave the current context unknown.
158 else if (verb === 'set' && target === 'current-context' && value) state.contexts.set(key, [known(value)], call.flow)
159 else if (verb !== undefined && !CONFIG_READ.has(verb)) state.contexts.set(key, [UNKNOWN], call.flow)
160 }
161 // A krew plugin that switches context (`kubectl ctx NAME`); with no argument or only a read flag it just lists.
162 if (call.tool === 'kubectl' && CTX_PLUGINS.has(call.sub) && call.args.some((a) => !CTX_READ.has(a))) {
163 state.contexts.set(key, [UNKNOWN], call.flow)
164 }
165 if ((call.tool === 'terraform' || call.tool === 'tofu') && call.sub === 'workspace' && (verb === 'select' || verb === 'new') && target) {
166 state.workspaces.set(`${call.tool}\0${call.dir}`, [known(target)], call.flow)
167 }
168}
169
170// A `weak` candidate can make a call protected but never relaxed.
171type Candidate = { name: string; kind: string; weak?: boolean }
172
173function lastPart(path: string): string {
174 const parts = path.split('/').filter(Boolean)
175 return parts[parts.length - 1] ?? ''
176}
177
178// The root `current-context` of one kubeconfig, read only where its text is the value kubectl resolves: a root-level
179// key (nested ones belong to other fields) with a plain scalar or a quoted one without escapes. '' when none is set,
180// null when the guard cannot tell (an escape, anchor, tag, block or flow value, a continuation, a duplicate key).
181export function parseCurrentContext(text: string): string | null {
182 const lines = text.replace(/^/, '').split(/\r?\n/)
183 const first = lines.find((l) => l.trim() !== '' && !l.trimStart().startsWith('#'))
184 if (first !== undefined && /^\s*[{[]/.test(first)) return null
185 if (lines.slice(1).some((l) => /^(?:---|\.\.\.)(?:\s|$)/.test(l))) return null
186 const key = /^(?:current-context|"current-context"|'current-context')[ \t]*:(?:[ \t]+(.*))?$/
187 const at = lines.flatMap((l, i) => (key.test(l) ? [i] : []))
188 if (at.length === 0) return ''
189 if (at.length > 1) return null
190 const i = at[0]!
191 // The value may not continue on an indented line below, or in a sequence at the key's column.
192 for (const l of lines.slice(i + 1)) {
193 if (l.trim() === '') continue
194 if (/^[ \t]+#/.test(l)) break
195 if (/^[ \t]/.test(l) || /^-(?:\s|$)/.test(l)) return null
196 break
197 }
198 const rest = (key.exec(lines[i]!)?.[1] ?? '').trim()
199 if (rest === '' || rest.startsWith('#')) return ''
200 const quoted = /^(?:"([^"\\]*)"|'([^'\\]*)')[ \t]*(?:#.*)?$/.exec(rest)
201 if (quoted) return quoted[1] ?? quoted[2] ?? ''
202 if (/^["'&*!|>{[%@`?,]|^-(?:\s|$)/.test(rest)) return null
203 const plain = rest.replace(/[ \t]+#.*$/, '').trim()
204 if (/: |:$/.test(plain) || /^(?:~|null|Null|NULL)$/.test(plain)) return null
205 return plain
206}
207
208// The current-context the kubeconfig files set (the first file that sets one wins, as kubectl merges them);
209// '' when none sets one, null when a file cannot be read or its value cannot be told.
210async function fileContext(config: string, dir: string, env: Env): Promise<string | null> {
211 for (const path of config.split(':').filter(Boolean)) {
212 if (/[$`]/.test(path)) return null
213 const text = await env.read(resolvePath(dir, path, env.home))
214 if (text === null) return null
215 const name = parseCurrentContext(text)
216 if (name === null) return null
217 if (name) return name
218 }
219 return ''
220}
221
222// One candidate list per environment the call could act on; null when a lookup could not run (the caller reports
223// it as a lookup failure). An UNKNOWN switch is a custom target.
224async function candidates(call: InfraCall, env: Env, state: ContextState): Promise<Candidate[][] | null> {
225 const kube = call.tool === 'kubectl' || call.tool === 'helm'
226 const switched = kube ? state.contexts.get(kubeKey(call, env.home)) : state.workspaces.get(`${call.tool}\0${call.dir}`)
227 const out: Candidate[][] = []
228 for (const p of switched) {
229 const list = p === UNKNOWN ? [{ name: CUSTOM, kind: 'target' }] : await candidatesAt(call, env, state, p)
230 if (list === null) return null
231 out.push(list)
232 }
233 return out
234}
235
236async function candidatesAt(call: InfraCall, env: Env, state: ContextState, p: string | typeof REAL): Promise<Candidate[] | null> {
237 const first = (r: { ok: boolean; out: string }) => (r.ok ? r.out.split('\n')[0]?.trim() ?? '' : '')
238 if (call.tool === 'kubectl' || call.tool === 'helm') {
239 const named = call.context || (call.tool === 'helm' ? call.assigns.HELM_KUBECONTEXT : '')
240 if (named && hasVariable(named)) return [{ name: CUSTOM, kind: 'target' }]
241 const config = kubeconfigOf(call)
242 const switched = p === REAL ? undefined : p
243 const current = !named && switched === undefined && config ? await fileContext(config, call.dir, env) : undefined
244 const file = lastPart(config.split(':')[0] ?? '')
245 const base: Candidate | undefined = named
246 ? { name: named, kind: 'context' }
247 : switched !== undefined
248 ? { name: switched, kind: 'context' }
249 : typeof current === 'string'
250 ? { name: current || '(none)', kind: 'context' }
251 : current === null && file
252 ? { name: file, kind: 'kubeconfig' }
253 : undefined
254 // A server or cluster override, or a kubeconfig the guard cannot read, points somewhere no name describes, so only the target can relax it.
255 if (call.custom || current === null || (call.tool === 'helm' && HELM_TARGET_ENV.some((k) => call.assigns[k]))) return base ? [{ name: CUSTOM, kind: 'target' }, { ...base, weak: true }] : [{ name: CUSTOM, kind: 'target' }]
256 if (base) return [base]
257 const r = await env.run(call.dir, ['kubectl', 'config', 'current-context'])
258 return r.failed ? null : [{ name: first(r) || '(none)', kind: 'context' }]
259 }
260 if (call.custom || call.assigns.TF_DATA_DIR) return [{ name: CUSTOM, kind: 'target' }]
261 const [verb, target] = positionals(call)
262 const targeted = call.sub === 'workspace' && verb !== undefined && ['new', 'delete', 'select'].includes(verb) ? target : undefined
263 let workspace = targeted || call.assigns.TF_WORKSPACE || (p === REAL ? '' : p)
264 // A workspace built from a variable or substitution is unknown; no directory name may stand in for it.
265 if (hasVariable(workspace)) return [{ name: CUSTOM, kind: 'target' }]
266 if (!workspace) {
267 const key = `${call.tool}\0${call.dir}`
268 let shown = state.shown.get(key)
269 if (shown === undefined) {
270 const r = await env.run(call.dir, [call.tool, 'workspace', 'show'])
271 shown = r.failed ? null : first(r)
272 state.shown.set(key, shown)
273 }
274 if (shown === null) return null
275 workspace = shown
276 }
277 const parts = call.dir.split('/').filter(Boolean)
278 const out: Candidate[] = []
279 if (workspace && workspace !== 'default') out.push({ name: workspace, kind: 'workspace' })
280 if (parts.length) out.push({ name: parts[parts.length - 1]!, kind: 'directory' })
281 if (parts.length > 1) out.push({ name: parts[parts.length - 2]!, kind: 'directory', weak: true })
282 return out.length ? out : [{ name: '(none)', kind: 'workspace' }]
283}
284
285export async function contextViolations(call: InfraCall, cfg: Config, env: Env, state: ContextState = newContextState()): Promise<Violation[]> {
286 if (cfg.context === 'off' || !isMutating(call)) return []
287 const lists = await candidates(call, env, state)
288 if (lists === null) return []
289 const stored = await env.contexts()
290 // Every environment the call could act on must pass; a protected one is reported before an unclassified one.
291 let worst: Violation | undefined
292 for (const found of lists) {
293 const classes = found.map((c) => classify(c.name, stored, cfg))
294 const protectedAt = classes.indexOf('protected')
295 if (protectedAt === -1 && classes.some((c, i) => c === 'relaxed' && !found[i]!.weak)) continue
296 const at = protectedAt === -1 ? 0 : protectedAt
297 const { name, kind } = found[at]!
298 const message =
299 protectedAt !== -1
300 ? `context: ${call.tool} ${call.sub} on protected ${kind} "${shown(name)}". Preview with --dry-run, diff or plan instead, or ask the user to run it themselves with the ! prefix.`
301 : `context: ${kind} "${shown(name)}" is not classified on this machine. Ask the user whether it is protected or relaxed; they record the answer by typing /guard-context ${shown(name)} protected|relaxed (or run this command themselves with the ! prefix).`
302 if (protectedAt !== -1) return [{ rule: 'context', mode: cfg.context, message }]
303 worst ??= { rule: 'context', mode: cfg.context, message }
304 }
305 return worst ? [worst] : []
306}
307
308export function parseGuardCommand(args: string): { action: 'set' | 'forget' | 'list' | 'error'; name?: string; cls?: Class; message?: string } {
309 const words = args.trim().match(/\(custom target\)|\S+/gi) ?? []
310 const error = (message: string) => ({ action: 'error' as const, message: `${message}; ${USAGE}` })
311 const valid = (word: string | undefined): string | null => (word !== undefined && word.length <= NAME_LIMIT && !CONTROL.test(word) ? word.toLowerCase() : null)
312 const first = words[0]?.toLowerCase()
313 if (first === 'list' && words.length === 1) return { action: 'list' }
314 if (first === 'forget') {
315 const name = words.length === 2 ? valid(words[1]) : null
316 return name === null ? error('forget takes one valid name') : { action: 'forget', name }
317 }
318 const cls = words[1]?.toLowerCase()
319 const name = words.length === 2 ? valid(words[0]) : null
320 if (name === null || (cls !== 'protected' && cls !== 'relaxed')) return error('expected a valid name and protected or relaxed')
321 return { action: 'set', name, cls }
322}
323
324export function isUserOrigin(origin: Origin): boolean {
325 return origin?.kind === 'composer' || origin?.kind === 'bridge'
326}
327
328// `stored` is set only when the classifications changed and must be written back.
329export function applyGuardCommand(args: string, origin: Origin, stored: Classes, cfg: Config): { text: string; stored?: Classes } {
330 if (!isUserOrigin(origin)) {
331 return { text: 'guard-context: only the user can change classifications, and this run did not come from the user. Type /guard-context yourself.' }
332 }
333 const cmd = parseGuardCommand(args)
334 if (cmd.action === 'error') return { text: `guard-context: ${cmd.message}` }
335 if (cmd.action === 'list') {
336 const rows = Object.entries(stored).map(([n, c]) => ` ${n}: ${c}`)
337 return {
338 text: [
339 rows.length ? 'Classified on this machine:' : 'Nothing is classified on this machine.',
340 ...rows,
341 `Protected patterns: ${cfg.protectedContexts.join(', ') || '(none)'}`,
342 `Relaxed patterns: ${cfg.relaxedContexts.join(', ') || '(none)'}`,
343 ].join('\n'),
344 }
345 }
346 const name = cmd.name as string
347 if (cmd.action === 'forget') {
348 const { [name]: gone, ...rest } = stored
349 return gone === undefined ? { text: `guard-context: "${name}" was not classified.` } : { text: `guard-context: forgot "${name}".`, stored: rest }
350 }
351 return { text: `guard-context: "${name}" is now ${cmd.cls}.`, stored: { ...stored, [name]: cmd.cls as Class } }
352}
353hooks/rules.ts 285 lines1import { COMMIT_SUBS, createFollower, protectedBranchViolations } from './branch'
2import { contextViolations, followSwitch, isMutating, newContextState, stepContext, type Classes } from './context'
3import { annotates, identityViolations } from './identity'
4import { extractCalls, resolvePath, type Call, type GhCall, type GitCall, type Parsed } from './shell'
5import { taintConfig, type ConfigTaint } from './state'
6import { SHELL_NAMES, toolWords } from './words'
7
8export type Mode = 'off' | 'dry' | 'on'
9export type RuleId = 'attribution' | 'protected-branch' | 'identity' | 'context' | 'unparsed' | 'config' | 'lookup'
10export type Violation = { rule: RuleId; mode: Mode; message: string }
11export type IdentityRule = { prefix: string; email: string }
12
13export type Config = {
14 attribution: Mode
15 protectedBranch: Mode
16 identity: Mode
17 context: Mode
18 protectedBranches: string[]
19 unprotectedRepos: string[]
20 identityRules: IdentityRule[]
21 protectedContexts: string[]
22 relaxedContexts: string[]
23 error?: string
24}
25
26// `failed` means git could not be run at all (thrown or timed out), never a non-zero exit.
27export type GitResult = { ok: boolean; out: string; failed?: boolean }
28
29export interface Env {
30 cwd: string
31 home: string
32 // `env` is set over the guard's own environment for the child.
33 git(dir: string, args: string[], env?: Record<string, string>): Promise<GitResult>
34 run(dir: string, argv: string[], env?: Record<string, string>): Promise<GitResult>
35 contexts(): Promise<Classes>
36 // Null when the file cannot be read.
37 read(path: string): Promise<string | null>
38}
39
40export type Verdict = { enforced: Violation[]; dry: Violation[] }
41
42const GH_MESSAGE: Record<string, Set<string>> = {
43 pr: new Set(['create', 'edit', 'comment', 'review', 'merge']),
44 issue: new Set(['create', 'edit', 'comment']),
45 release: new Set(['create', 'edit']),
46}
47const GLAB_VERBS = new Set(['create', 'update', 'note', 'edit', 'merge'])
48const TEA_VERBS = new Set(['create', 'edit', 'comment'])
49const FJ_VERBS = new Set(['create', 'edit', 'comment', 'merge'])
50const FORGE_MESSAGE: Record<GhCall['cli'], Record<string, Set<string>>> = {
51 gh: GH_MESSAGE,
52 glab: { mr: GLAB_VERBS, issue: GLAB_VERBS, release: GLAB_VERBS },
53 tea: Object.fromEntries(['pulls', 'pr', 'issues', 'issue', 'releases', 'release'].map((a) => [a, TEA_VERBS])),
54 fj: { pr: FJ_VERBS, issue: FJ_VERBS, release: FJ_VERBS },
55}
56const DEFAULT_PROTECTED_CONTEXTS = '*prod*,*production*,*live*,*qa*,*staging*,*stage*,*uat*,*preprod*,*pre-prod*'
57const DEFAULT_RELAXED_CONTEXTS = '*dev*,*development*,*test*,*local*,*sandbox*,minikube,kind-*,k3d-*,docker-desktop,rancher-desktop'
58const BODY_FILE_OPTS = new Set(['-F', '--body-file', '--notes-file', '--description-file'])
59
60const ATTRIBUTION: { name: string; re: RegExp }[] = [
61 { name: 'a Co-Authored-By trailer', re: /(?:^|["']|\\n)[ \t]*co-authored-by:/im },
62 { name: 'a "Generated with ... Claude" line', re: /(?:^|["']|\\n)[ \t]*(?:\u{1F916}[ \t]*)?generated with[^\n]*claude/imu },
63 { name: 'a Claude-Session trailer', re: /(?:^|["']|\\n)[ \t]*claude-session:/im },
64 { name: 'a Co-Authored-By trailer', re: /--trailer[= ]\s*["']?(?:co-authored-by|claude-session)\b/i },
65 { name: 'a claude.ai session link', re: /claude\.ai\/code\/session_/i },
66 { name: 'a Claude Code link', re: /claude\.com\/claude-code/i },
67 { name: 'a robot-emoji line', re: /(?:^|["']|\\n)[ \t]*\u{1F916}/imu },
68]
69const INFRA_TOOLS = new Set(['kubectl', 'helm', 'terraform', 'tofu'])
70const ATTRIBUTION_HINT = ' If this text is not part of a commit or PR message, run the git or gh command separately from the command that contains it.'
71
72function parseList(value: unknown, fallback: string): string[] {
73 return String(value ?? fallback)
74 .split(',')
75 .map((v) => v.trim())
76 .filter(Boolean)
77}
78
79function parseMode(value: unknown, name: string, errors: string[]): Mode {
80 const v = value === undefined || value === null ? '' : String(value).trim().toLowerCase()
81 if (v === '') return 'on'
82 if (v === 'off' || v === 'dry' || v === 'on') return v
83 errors.push(`${name} must be off, dry or on`)
84 return 'on'
85}
86
87export function parseConfig(options: Readonly<Record<string, unknown>>): Config {
88 const errors: string[] = []
89 const branches = String(options.protectedBranches ?? 'develop,main')
90 .split(',')
91 .map((b) => b.trim())
92 .filter(Boolean)
93 if (!branches.length) errors.push('protectedBranches must name at least one branch (set modeProtectedBranch to off to turn the rule off)')
94 let identityRules: IdentityRule[] = []
95 const raw = String(options.identityRules ?? '').trim()
96 if (raw) {
97 try {
98 const parsed: unknown = JSON.parse(raw)
99 const valid =
100 Array.isArray(parsed) &&
101 parsed.every(
102 (r) =>
103 r !== null &&
104 typeof r === 'object' &&
105 typeof (r as IdentityRule).prefix === 'string' &&
106 typeof (r as IdentityRule).email === 'string' &&
107 (r as IdentityRule).prefix.length > 0 &&
108 (r as IdentityRule).email.trim().length > 0,
109 )
110 if (valid) identityRules = parsed as IdentityRule[]
111 else errors.push('identityRules must be a JSON array of {"prefix", "email"} objects')
112 } catch {
113 errors.push('identityRules is not valid JSON')
114 }
115 }
116 return {
117 attribution: parseMode(options.modeAttribution, 'modeAttribution', errors),
118 protectedBranch: parseMode(options.modeProtectedBranch, 'modeProtectedBranch', errors),
119 identity: parseMode(options.modeIdentity, 'modeIdentity', errors),
120 context: parseMode(options.modeContext, 'modeContext', errors),
121 protectedBranches: branches,
122 unprotectedRepos: parseList(options.unprotectedRepos, ''),
123 identityRules,
124 protectedContexts: parseList(options.protectedContexts, DEFAULT_PROTECTED_CONTEXTS),
125 relaxedContexts: parseList(options.relaxedContexts, DEFAULT_RELAXED_CONTEXTS),
126 error: errors.length ? errors.join('; ') : undefined,
127 }
128}
129
130function isMessageCall(call: Call): boolean {
131 if (call.kind === 'gh') {
132 const areas = FORGE_MESSAGE[call.cli]
133 return (Object.hasOwn(areas, call.area) && areas[call.area]?.has(call.verb)) || false
134 }
135 if (call.kind === 'infra') return false
136 if (call.sub === 'tag') return annotates(call)
137 return call.sub === 'commit' || call.sub === 'merge' || call.sub === 'revert'
138}
139
140function isGuarded(call: Call, cfg: Config): boolean {
141 if (call.kind === 'gh') return isMessageCall(call)
142 if (call.kind === 'infra') return cfg.context !== 'off' && isMutating(call)
143 if (call.sub === 'tag') return annotates(call)
144 return COMMIT_SUBS.has(call.sub) || call.sub === 'push'
145}
146
147function attributionViolations(text: string, cfg: Config, where = 'the command'): Violation[] {
148 return ATTRIBUTION.filter((p) => p.re.test(text)).map((p) => ({
149 rule: 'attribution',
150 mode: cfg.attribution,
151 message: `attribution: ${where} contains ${p.name}. AI attribution does not belong in commits, tags, PRs or issues; remove it and retry.${where === 'the command' ? ATTRIBUTION_HINT : ''}`,
152 }))
153}
154
155// Literal gh and glab body files (`-F`, `--body-file`, `--notes-file`, `--description-file`); stdin and variables are not followed.
156function bodyFiles(call: GhCall, home: string): string[] {
157 if (call.cli !== 'gh' && call.cli !== 'glab') return []
158 const out: string[] = []
159 for (let i = 0; i < call.args.length && call.args[i] !== '--'; i++) {
160 const a = call.args[i]!
161 const eq = a.startsWith('--') ? a.indexOf('=') : -1
162 const key = eq !== -1 ? a.slice(0, eq) : a.startsWith('-F') && !a.startsWith('--') ? '-F' : a
163 const value = eq !== -1 ? a.slice(eq + 1) : key === '-F' && a.length > 2 ? a.slice(2) : BODY_FILE_OPTS.has(key) ? call.args[++i] : undefined
164 if (BODY_FILE_OPTS.has(key) && value && value !== '-' && !/[$`]/.test(value)) out.push(resolvePath(call.dir, value, home))
165 }
166 return out
167}
168
169function strictest(modes: Mode[]): Mode {
170 return modes.includes('on') ? 'on' : modes.includes('dry') ? 'dry' : 'off'
171}
172
173// A construct the guard does not model (`opaque`) is unparsed when the command names a tool anywhere; a command it
174// does not model that names a tool among its own words (`local`), or a guarded git call in a repository the guard
175// cannot follow, is unparsed by itself. Git and forge tools take the strictest git-side mode, infra tools the
176// context mode, and a shell name both.
177function unparsedViolations(command: string, parsed: Parsed, guarded: Call[], cfg: Config): Violation[] {
178 const tools = new Map<string, string>()
179 // `${G:-git}` names git through a default-value operator.
180 const plain = command.replace(/["'\\]/g, '').replace(/\$\{\w*:?[-=+?]/g, ' ')
181 if (parsed.opaque.length) for (const t of toolWords(plain, { anyCase: true })) tools.set(t, parsed.opaque[0]!)
182 for (const l of parsed.local) for (const t of l.tools) if (!tools.has(t)) tools.set(t, l.why)
183 const call = guarded.find((c): c is GitCall => c.kind === 'git' && c.opaque !== undefined)
184 if (call && !tools.has('git')) tools.set('git', call.opaque!)
185 const named = [...tools.keys()]
186 const gitWhy = named.filter((t) => !INFRA_TOOLS.has(t)).map((t) => tools.get(t)!)[0]
187 const infraWhy = named.filter((t) => INFRA_TOOLS.has(t) || SHELL_NAMES.includes(t)).map((t) => tools.get(t)!)[0]
188 const out: Violation[] = []
189 const hint = 'Use literal command words and simple quoting, without nested or piped scripts, and retry.'
190 if (gitWhy !== undefined) {
191 out.push({
192 rule: 'unparsed',
193 mode: strictest(cfg.identityRules.length ? [cfg.attribution, cfg.protectedBranch, cfg.identity] : [cfg.attribution, cfg.protectedBranch]),
194 message: `unparsed: this command uses ${gitWhy}, which the guard cannot follow, and it names git or a forge CLI. ${hint}`,
195 })
196 }
197 if (infraWhy !== undefined) {
198 out.push({ rule: 'unparsed', mode: cfg.context, message: `unparsed: this command uses ${infraWhy}, which the guard cannot follow, and it names kubectl, helm or terraform. ${hint}` })
199 }
200 return out
201}
202
203export async function evaluate(command: string, cfg: Config, rawEnv: Env): Promise<Verdict> {
204 const all: Violation[] = []
205 let gitLookupFailed = false
206 let runLookupFailed = false
207 const env: Env = {
208 ...rawEnv,
209 git: async (dir, args, vars) => {
210 const r = await rawEnv.git(dir, args, vars)
211 if (r.failed) gitLookupFailed = true
212 return r
213 },
214 run: async (dir, argv, vars) => {
215 const r = await rawEnv.run(dir, argv, vars)
216 if (r.failed) runLookupFailed = true
217 return r
218 },
219 }
220 const parsed = extractCalls(command, env.cwd, env.home)
221 const guarded = parsed.calls.filter((c) => isGuarded(c, cfg))
222
223 all.push(...unparsedViolations(command, parsed, guarded, cfg))
224 if (guarded.length && cfg.error) {
225 all.push({ rule: 'config', mode: 'on', message: `config: guard-rules settings are invalid (${cfg.error}). Fix them in the plugin options.` })
226 }
227 if (cfg.attribution !== 'off' && parsed.calls.some(isMessageCall)) {
228 all.push(...attributionViolations(command, cfg))
229 for (const call of parsed.calls) {
230 if (call.kind !== 'gh' || !isMessageCall(call)) continue
231 for (const path of bodyFiles(call, env.home)) {
232 const text = await env.read(path)
233 if (text !== null) all.push(...attributionViolations(text, cfg, `the body file ${path}`))
234 }
235 }
236 }
237 const follow = createFollower(env)
238 let created = false
239 let taint: ConfigTaint = { config: false, upstream: false }
240 const switched = newContextState()
241 for (const call of parsed.calls) {
242 if (call.kind === 'infra') {
243 stepContext(switched, call.flow.chain)
244 all.push(...(await contextViolations(call, cfg, env, switched)))
245 followSwitch(call, switched, env.home)
246 continue
247 }
248 if (call.kind !== 'git') continue
249 follow.step(call.flow.chain)
250 if (cfg.protectedBranch !== 'off') all.push(...(await protectedBranchViolations(call, cfg, env, follow, taint)))
251 if (cfg.identity !== 'off') all.push(...(await identityViolations(call, cfg, env, created, taint.config)))
252 if (cfg.protectedBranch !== 'off') await follow.track(call)
253 taint = taintConfig(taint, call)
254 if (call.sub === 'init' || call.sub === 'clone') created = true
255 }
256 const lookupMode = strictest(cfg.identityRules.length ? [cfg.protectedBranch, cfg.identity] : [cfg.protectedBranch])
257 if (gitLookupFailed && lookupMode !== 'off') {
258 all.push({
259 rule: 'lookup',
260 mode: lookupMode,
261 message:
262 'lookup: git could not be run to check the branch or identity (it failed or timed out), so the command was blocked. Retry it; if this keeps happening, set the rule modes to off in the plugin options.',
263 })
264 }
265 if (runLookupFailed && cfg.context !== 'off') {
266 all.push({
267 rule: 'lookup',
268 mode: cfg.context,
269 message:
270 'lookup: the current kubectl context or terraform workspace could not be read (the command failed or timed out), so the command was blocked. Retry it, or name the environment in the command (--context, --kube-context, TF_WORKSPACE); if this keeps happening, set modeContext to off in the plugin options.',
271 })
272 }
273
274 const seen = new Set<string>()
275 const unique = all.filter((v) => !seen.has(v.message) && seen.add(v.message))
276 return {
277 enforced: unique.filter((v) => v.mode === 'on'),
278 dry: unique.filter((v) => v.mode === 'dry'),
279 }
280}
281
282export function formatDeny(violations: Violation[]): string {
283 return violations.map((v) => `guard-rules: ${v.message}`).join('\n')
284}
285hooks/shell.ts 7 lines1// Public API of the shell parser; the parts live in lex, words, paths, wrappers, calls and extract.
2export { positionals, takesValue, type Call, type GhCall, type GitCall, type InfraCall, type InfraTool } from './calls'
3export { extractCalls, type Parsed } from './extract'
4export { tokenize, type Simple } from './lex'
5export { basename, joinPath, resolvePath } from './paths'
6export { IDENTITY_ENV } from './words'
7hooks/flow.ts 51 lines1// `chain` changes at every connector other than `&&` (and `then`); `certain` means the call runs exactly when
2// the earlier calls of its chain succeeded, so a state change it makes holds for the rest of that chain.
3// `loop` means the call is inside a loop body, where a relative change compounds.
4export type Flow = { chain: number; certain: boolean; loop?: boolean }
5
6// REAL: whatever the lookup reports; UNKNOWN: a value the guard cannot tell.
7export const REAL: unique symbol = Symbol('real')
8export const UNKNOWN: unique symbol = Symbol('unknown')
9export type Possible<V> = V | typeof REAL | typeof UNKNOWN
10export const MAX_POSSIBLE = 8
11
12export type Tracker<V> = {
13 get(k: string): Possible<V>[]
14 set(k: string, next: Possible<V>[], flow: Flow): void
15 step(chain: number): void
16 size(): number
17}
18
19// The set of values each key could hold. A certain change replaces the set and holds the old one until a call
20// outside its chain makes it possible again; any other change only adds possibilities.
21export function createTracker<V>(keyOf: (v: V) => string, initial: (k: string) => Possible<V>[] = () => [REAL]): Tracker<V> {
22 const now = new Map<string, Possible<V>[]>()
23 const held = new Map<string, { chain: number; before: Possible<V>[] }>()
24 const id = (v: Possible<V>) => (v === REAL ? '\0real' : v === UNKNOWN ? '\0unknown' : keyOf(v))
25 const union = (a: Possible<V>[], b: Possible<V>[]): Possible<V>[] => {
26 const seen = new Set<string>()
27 const out = [...a, ...b].filter((v) => !seen.has(id(v)) && seen.add(id(v)))
28 return out.length > MAX_POSSIBLE ? [UNKNOWN] : out
29 }
30 const get = (k: string) => now.get(k) ?? initial(k)
31 return {
32 get,
33 size: () => now.size,
34 set(k, next, flow) {
35 if (flow.certain) {
36 held.set(k, { chain: flow.chain, before: union(held.get(k)?.before ?? [], get(k)) })
37 now.set(k, union([], next))
38 } else {
39 now.set(k, union(get(k), next))
40 }
41 },
42 step(chain) {
43 for (const [k, h] of held) {
44 if (h.chain === chain) continue
45 now.set(k, union(h.before, get(k)))
46 held.delete(k)
47 }
48 },
49 }
50}
51hooks/paths.ts 34 lines1export function basename(p: string): string {
2 const cut = p.lastIndexOf('/')
3 return cut === -1 ? p : p.slice(cut + 1)
4}
5
6// Joins without collapsing `..`: the OS resolves it after any symlink before it, so `git -C link/..`, `env -C`,
7// `sudo -D`, `terraform -chdir` and `cd -P` reach the link target's parent, which no lexical form describes.
8export function joinPath(base: string, p: string, home: string): string {
9 let q = p
10 if (q === '~') q = home
11 else if (q.startsWith('~/')) q = home + q.slice(1)
12 const full = q.startsWith('/') ? q : base.replace(/\/+$/, '') + '/' + q
13 return '/' + full.split('/').filter((seg) => seg && seg !== '.').join('/')
14}
15
16// Collapses `..` lexically, as a logical `cd` (the shell default) does.
17export function resolvePath(base: string, p: string, home: string): string {
18 let q = p
19 if (q === '~') q = home
20 else if (q.startsWith('~/')) q = home + q.slice(1)
21 const full = q.startsWith('/') ? q : base.replace(/\/+$/, '') + '/' + q
22 const out: string[] = []
23 for (const seg of full.split('/')) {
24 if (!seg || seg === '.') continue
25 if (seg === '..') out.pop()
26 else out.push(seg)
27 }
28 return '/' + out.join('/')
29}
30
31export function hasVariable(p: string): boolean {
32 return p.includes('$') || p.includes('`')
33}
34hooks/branch.ts 263 lines1import { createTracker, REAL, UNKNOWN, type Possible } from './flow'
2import { pushMessages } from './push'
3import type { Config, Env, Violation } from './rules'
4import { resolvePath, type GitCall } from './shell'
5import type { ConfigTaint } from './state'
6
7export const COMMIT_SUBS = new Set(['commit', 'merge', 'cherry-pick', 'revert', 'am', 'rebase'])
8
9const STOP_SUBS = new Set(['merge', 'cherry-pick', 'revert', 'am', 'rebase'])
10
11const FOLLOWED_SUBS = new Set(['checkout', 'switch', 'rebase', 'symbolic-ref', 'update-ref'])
12
13function globToRegExp(glob: string): RegExp {
14 const body = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
15 return new RegExp(`^${body}$`)
16}
17
18export function isProtected(branch: string, cfg: Config): boolean {
19 return cfg.protectedBranches.some((p) => globToRegExp(p).test(branch))
20}
21
22// Only a bare `--abort` or `--quit` of a sequencer command stops it; the same word as a message does not.
23export function stops(call: GitCall): boolean {
24 return STOP_SUBS.has(call.sub) && call.args.length === 1 && (call.args[0] === '--abort' || call.args[0] === '--quit')
25}
26
27async function currentBranch(env: Env, dir: string): Promise<{ name: string | null; born: boolean }> {
28 const head = await env.git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
29 if (!head.ok || !head.out) return { name: null, born: true }
30 const commit = await env.git(dir, ['rev-parse', '--verify', '--quiet', 'HEAD'])
31 return { name: head.out, born: commit.ok }
32}
33
34// REAL is the branch git reports; UNKNOWN is a branch the guard cannot tell (`git switch -`).
35type Followed = Possible<{ cur: string; prev?: string } | { detached: true }>
36
37export type Branch = { name: string | null; born: boolean; unknown?: boolean }
38
39export type Follower = { branchOf(dir: string): Promise<Branch[]>; track(call: GitCall): Promise<void>; step(chain: number): void }
40
41// Follows branch switches inside one command, per repository root, as the set of branches each repository could be on.
42export function createFollower(env: Env): Follower {
43 const repos = createTracker<Exclude<Followed, typeof REAL | typeof UNKNOWN>>((f) => ('detached' in f ? '\0detached' : `${f.cur}\0${f.prev ?? '\0'}`))
44 const roots = new Map<string, string>()
45 const real = new Map<string, Promise<Branch>>()
46 const currentOf = (dir: string): Promise<Branch> => {
47 let b = real.get(dir)
48 if (b === undefined) real.set(dir, (b = currentBranch(env, dir)))
49 return b
50 }
51
52 const rootOf = async (dir: string): Promise<string> => {
53 let root = roots.get(dir)
54 if (root === undefined) {
55 const top = await env.git(dir, ['rev-parse', '--show-toplevel'])
56 root = top.ok && top.out ? top.out : dir
57 roots.set(dir, root)
58 }
59 return root
60 }
61
62 const to = async (dir: string, before: Followed[], name: string): Promise<Followed[]> => {
63 let real: string | undefined
64 if (before.includes(REAL)) real = (await currentOf(dir)).name ?? undefined
65 return before.map((f) => ({ cur: name, prev: f === REAL ? real : f !== UNKNOWN && 'cur' in f ? f.cur : undefined }))
66 }
67
68 // The states after a switch, or undefined when the call does not switch.
69 const after = async (call: GitCall, before: Followed[]): Promise<Followed[] | undefined> => {
70 const { dir } = call
71 if (call.sub === 'rebase') {
72 // `git rebase <upstream> <branch>` checks out <branch> first, and stays on it.
73 const branch = stops(call) ? undefined : rebaseBranch(call)
74 return branch === undefined ? undefined : branch === UNKNOWN ? [UNKNOWN] : to(dir, before, branch)
75 }
76 if (call.sub === 'symbolic-ref' || call.sub === 'update-ref') {
77 const head = headWrite(call)
78 if (head === undefined) return undefined
79 if (head === 'detach') return [{ detached: true }]
80 return head === UNKNOWN ? [UNKNOWN] : to(dir, before, head)
81 }
82 const target = switchTarget(call)
83 if (target === undefined) return undefined
84 if (target === 'detach') return [{ detached: true }]
85 if (target === 'unknown') return [UNKNOWN]
86 if (target === 'prev') {
87 // In a loop the previous branch depends on how many times the body ran.
88 if (call.flow.loop) return [UNKNOWN]
89 return before.map((f) => (f !== REAL && f !== UNKNOWN && 'cur' in f && f.prev !== undefined ? { cur: f.prev, prev: f.cur } : UNKNOWN))
90 }
91 if (target.sure) return to(dir, before, target.name)
92 // A name with no local branch may still be created from a remote branch of that name (DWIM), so it is possible.
93 const exists = await env.git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${target.name}`])
94 const next = await to(dir, before, target.name)
95 return exists.ok ? next : [...before, ...next]
96 }
97
98 return {
99 async branchOf(dir) {
100 if (!repos.size()) return [await currentOf(dir)]
101 return Promise.all(
102 repos.get(await rootOf(dir)).map(async (f): Promise<Branch> => {
103 if (f === REAL) return currentOf(dir)
104 if (f === UNKNOWN) return { name: null, born: true, unknown: true }
105 if ('detached' in f) return { name: null, born: true }
106 return { name: f.cur, born: (await env.git(dir, ['rev-parse', '--verify', '--quiet', 'HEAD'])).ok }
107 }),
108 )
109 },
110
111 step: repos.step,
112
113 async track(call) {
114 if (!FOLLOWED_SUBS.has(call.sub)) return
115 const root = await rootOf(call.dir)
116 const next = await after(call, repos.get(root))
117 if (next) repos.set(root, next, call.flow)
118 },
119 }
120}
121
122const SWITCH_BOOL = new Set([
123 '-q', '--quiet', '-f', '--force', '--discard-changes', '-m', '--merge', '--no-track', '--guess', '--no-guess', '--progress', '--no-progress',
124 '--recurse-submodules', '--no-recurse-submodules', '--ignore-other-worktrees', '--overwrite-ignore', '--no-overwrite-ignore', '-l',
125 '--ignore-skip-worktree-bits',
126])
127const REBASE_VALUE = new Set(['--onto', '-s', '--strategy', '-X', '--strategy-option', '-x', '--exec', '-C', '--whitespace', '-S', '--gpg-sign'])
128
129// Where a checkout or switch goes, getopt style (`-bmain`, `-qb main`, `--create=x`, `--orphan x`); undefined when
130// it does not switch (`checkout -- file`, `checkout main file`). An option the guard does not know is `unknown`.
131// `sure` means the branch is created or named after `--`, so it is the branch even when no local one exists yet.
132type SwitchTarget = undefined | 'detach' | 'prev' | 'unknown' | { name: string; sure: boolean }
133
134export function switchTarget(call: GitCall): SwitchTarget {
135 const { sub, args } = call
136 if (sub !== 'checkout' && sub !== 'switch') return undefined
137 // A word that is not literal may be the branch, or split into more words; after a checkout `--` it is only a path.
138 const dashAt = args.indexOf('--')
139 if (call.dynArgs?.some((d, k) => d && (sub === 'switch' || dashAt === -1 || k < dashAt))) return 'unknown'
140 const create = sub === 'checkout' ? 'bB' : 'cC'
141 const createLong = sub === 'checkout' ? ['--orphan'] : ['--create', '--force-create', '--orphan']
142 const positional: string[] = []
143 let dashes = -1
144 let track = false
145 let detach = false
146 for (let i = 0; i < args.length; i++) {
147 const a = args[i]!
148 if (dashes !== -1 || a === '-' || a === '--' || !a.startsWith('-')) {
149 if (a === '--' && dashes === -1) dashes = positional.length
150 else positional.push(a)
151 continue
152 }
153 const eq = a.startsWith('--') ? a.indexOf('=') : -1
154 const key = eq === -1 ? a : a.slice(0, eq)
155 if (createLong.includes(key)) return { name: eq === -1 ? (args[++i] ?? '') : a.slice(eq + 1), sure: true }
156 if (key === '--detach') detach = true
157 else if (key === '--track') track = true
158 else if (key === '--conflict' && eq !== -1) continue
159 else if (SWITCH_BOOL.has(a)) continue
160 else if (a.startsWith('--')) return 'unknown'
161 else {
162 for (let j = 1; j < a.length; j++) {
163 const c = a[j]!
164 if (create.includes(c)) {
165 const name = j + 1 < a.length ? a.slice(j + 1) : args[++i]
166 return name === undefined ? 'unknown' : { name, sure: true }
167 }
168 if (c === 't') track = true
169 else if (c === 'd' && sub === 'switch') detach = true
170 else if (!'qfml'.includes(c)) return 'unknown'
171 }
172 }
173 }
174 if (detach) return 'detach'
175 // `checkout -- x` and `checkout main -- file` restore files; `checkout main --` and `switch -- main` switch.
176 if (dashes !== -1 && sub === 'checkout' && dashes !== positional.length) return undefined
177 if (positional.length !== 1) return undefined
178 const name = positional[0]!
179 if (name === '-' && dashes === -1) return 'prev'
180 if (track) return { name: name.slice(name.indexOf('/') + 1), sure: true }
181 return { name, sure: false }
182}
183
184// The branch `git rebase <upstream> <branch>` (or `--root <branch>`) checks out and rewrites, or undefined; UNKNOWN
185// when a word is not literal, since it may name or split into that branch.
186function rebaseBranch(call: GitCall): string | typeof UNKNOWN | undefined {
187 const { args } = call
188 if (call.dynArgs?.some(Boolean)) return UNKNOWN
189 const positional: string[] = []
190 for (let i = 0; i < args.length; i++) {
191 const a = args[i]!
192 if (REBASE_VALUE.has(a)) i++
193 else if (!a.startsWith('-')) positional.push(a)
194 }
195 return args.includes('--root') ? positional[0] : positional[1]
196}
197
198// Where `git symbolic-ref HEAD <ref>` or `git update-ref --no-deref HEAD <rev>` points HEAD: a branch, 'detach',
199// UNKNOWN (a ref outside refs/heads/, a deleted HEAD, or a word that is not literal), or undefined when HEAD stays.
200function headWrite(call: GitCall): string | 'detach' | typeof UNKNOWN | undefined {
201 const positional: string[] = []
202 let del = false
203 let noDeref = false
204 for (let i = 0; i < call.args.length; i++) {
205 const a = call.args[i]!
206 if (a === '--') {
207 positional.push(...call.args.slice(i + 1))
208 break
209 }
210 if (a === '-m') i++
211 else if (a === '-d' || a === '--delete') del = true
212 else if (a === '--no-deref') noDeref = true
213 else if (!a.startsWith('-')) positional.push(a)
214 }
215 const dynamic = call.dynArgs?.some(Boolean) ?? false
216 if (call.sub === 'update-ref') {
217 if (!noDeref || del || (positional[0] !== 'HEAD' && !dynamic)) return undefined
218 return dynamic ? UNKNOWN : 'detach'
219 }
220 if (dynamic && positional.length) return UNKNOWN
221 if (positional[0] !== 'HEAD') return undefined
222 if (del) return UNKNOWN
223 const ref = positional[1]
224 if (ref === undefined) return undefined
225 return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : UNKNOWN
226}
227
228export async function protectedBranchViolations(call: GitCall, cfg: Config, env: Env, follow: Follower, taint: ConfigTaint = { config: false, upstream: false }): Promise<Violation[]> {
229 const out: Violation[] = []
230 const hint = 'Work on a feature branch and land it by pull request; for an intentional direct change, ask the user to run it themselves with the ! prefix.'
231 const add = (message: string) => out.push({ rule: 'protected-branch', mode: cfg.protectedBranch, message })
232 const ffFlags = call.args.filter((a) => a === '--ff' || a === '--no-ff' || a === '--ff-only')
233 const fastForward = call.sub === 'merge' && ffFlags[ffFlags.length - 1] === '--ff-only'
234 const unknown = `protected-branch: an earlier "git switch", "git checkout", "git rebase" or "git symbolic-ref" in this command (such as "git switch -", a branch built from a variable, or an option the guard does not know) goes to a branch the guard cannot tell, so git ${call.sub} counts as on a protected branch. Run the switch on its own first. ${hint}`
235 const rewritten = call.sub === 'rebase' && !stops(call) ? rebaseBranch(call) : undefined
236 if (rewritten === UNKNOWN) {
237 add(`protected-branch: git rebase names a branch built from a variable, substitution or secret placeholder, so it counts as rewriting a protected branch. ${hint}`)
238 } else if (rewritten !== undefined && isProtected(rewritten, cfg)) {
239 add(`protected-branch: git rebase rewrites protected branch "${rewritten}". ${hint}`)
240 } else if (COMMIT_SUBS.has(call.sub) && !stops(call) && !fastForward) {
241 const branches = await follow.branchOf(call.dir)
242 const b = branches.find((x) => x.name && x.born && isProtected(x.name, cfg))
243 if (b) add(`protected-branch: git ${call.sub} on protected branch "${b.name}". ${hint}`)
244 else if (branches.some((x) => x.unknown)) add(unknown)
245 } else if (call.sub === 'push') {
246 for (const message of await pushMessages(call, env, follow, (n) => isProtected(n, cfg), hint, unknown, taint)) add(message)
247 }
248 // Looked up only once the rule would deny, so a clean call costs no extra git run.
249 if (out.length && cfg.unprotectedRepos.length && (await exempt(call.dir, cfg, env))) return []
250 return out
251}
252
253// True only when git names the toplevel and it is, or is under, a listed prefix; a failed or empty lookup never exempts.
254async function exempt(dir: string, cfg: Config, env: Env): Promise<boolean> {
255 const top = await env.git(dir, ['rev-parse', '--show-toplevel'])
256 if (!top.ok || !top.out) return false
257 const toplevel = resolvePath('/', top.out, env.home)
258 return cfg.unprotectedRepos.some((prefix) => {
259 const root = resolvePath('/', prefix, env.home)
260 return toplevel === root || toplevel.startsWith(root + '/')
261 })
262}
263hooks/identity.ts 107 lines1import { stops } from './branch'
2import { SECRET_PLACEHOLDER } from './calls'
3import type { Config, Env, IdentityRule, Violation } from './rules'
4import { IDENTITY_ENV, resolvePath, type GitCall } from './shell'
5
6const IDENTITY_SUBS = new Set(['commit', 'merge', 'cherry-pick', 'revert', 'am'])
7
8const TAG_LONG = /^--(?:annotate|sign|message|file)(?:=|$)/
9
10const IDENTITY_KEYS = new Set(['user.name', 'user.email', 'user.useconfigonly', 'author.name', 'author.email', 'committer.name', 'committer.email'])
11
12export function annotates(call: GitCall): boolean {
13 return call.args.some((a) => TAG_LONG.test(a) || /^-[A-Za-z]*[asmF]/.test(a))
14}
15
16function usesIdentity(call: GitCall): boolean {
17 if (stops(call)) return false
18 if (call.sub === 'tag') return annotates(call)
19 return IDENTITY_SUBS.has(call.sub)
20}
21
22const INIT_THEN_COMMIT =
23 'identity: this command creates a repository and commits in it in one step, so its identity cannot be checked first. Run git init on its own, then commit.'
24
25export async function identityViolations(call: GitCall, cfg: Config, env: Env, created: boolean, configWritten = false): Promise<Violation[]> {
26 if (!cfg.identityRules.length || !usesIdentity(call)) return []
27 const add = (message: string): Violation[] => [{ rule: 'identity', mode: cfg.identity, message }]
28 if (created) return add(INIT_THEN_COMMIT)
29 const top = await env.git(call.dir, ['rev-parse', '--show-toplevel'])
30 if (!top.ok) return []
31 if (configWritten) {
32 return add('identity: an earlier call in this command may change git config, so the identity this commit would use cannot be checked. Run the git config on its own first, then commit.')
33 }
34 const toplevel = resolvePath('/', top.out, env.home)
35 let best: { rule: IdentityRule; length: number } | null = null
36 for (const rule of cfg.identityRules) {
37 const root = resolvePath('/', rule.prefix, env.home)
38 const hit = root === '/' || toplevel === root || toplevel.startsWith(root + '/')
39 if (hit && (!best || root.length > best.length)) best = { rule, length: root.length }
40 }
41 // Identity keys and includes, in their order on the command line, so the last value wins as it does for git.
42 const dashC = call.config
43 .filter(([k]) => IDENTITY_KEYS.has(k.toLowerCase()) || k.toLowerCase() === 'include.path' || /^includeif\..+\.path$/i.test(k))
44 .flatMap(([k, v]) => ['-c', `${k}=${v}`])
45 // The guard can set variables for its probe but not remove them, so a cleared or unset identity variable cannot be checked.
46 if (call.scrubbed) {
47 return add('identity: this command clears environment variables git reads its identity from (env -i or env -u), so the identity cannot be checked. Run the commit without them.')
48 }
49 const probe = probeEnv(call.assigns)
50 if (probe === null) {
51 return [{ rule: 'unparsed', mode: cfg.identity, message: 'unparsed: GIT_CONFIG_COUNT is not a literal number up to 32, so the identity git would use cannot be checked. Use -c user.email=... instead.' }]
52 }
53 if (probe === 'other') {
54 return add('identity: this command passes git config through GIT_CONFIG_PARAMETERS or an include.path in GIT_CONFIG_KEY_n, so the identity cannot be checked. Use -c user.email=... instead.')
55 }
56 const emailOf = async (role: 'AUTHOR' | 'COMMITTER'): Promise<string> => {
57 const r = await env.git(call.dir, [...dashC, 'var', `GIT_${role}_IDENT`], probe)
58 return r.ok ? (/<([^<>]*)>[^<>]*$/.exec(r.out.split('\n')[0] ?? '')?.[1]?.trim() ?? '') : ''
59 }
60 const author = await emailOf('AUTHOR')
61 const committer = await emailOf('COMMITTER')
62 if (!best) {
63 return add(
64 `identity: ${toplevel} is outside the configured identity roots, so a commit here would use ${author || 'an unset identity'}. Create the repository under a configured root or add a rule.`,
65 )
66 }
67 const want = best.rule.email.toLowerCase()
68 const roles: [string, string][] = [['this commit would use', author], ["this commit's committer would be", committer]]
69 for (const [who, email] of roles) {
70 if (email.toLowerCase() !== want) {
71 return add(
72 `identity: repositories under ${best.rule.prefix} commit as ${best.rule.email}, but ${who} ${email || 'an unset identity'}. Set user.email for this repository (or pass -c user.email=...) and retry.`,
73 )
74 }
75 }
76 return []
77}
78
79const MAX_CONFIG_COUNT = 32
80const INCLUDE_KEY = /^include\.path$|^includeif\..+\.path$/i
81
82// The environment of the identity probe: an exact allowlist of identity inputs, with GIT_CONFIG_COUNT/KEY_n/VALUE_n
83// rebuilt and renumbered from identity keys only, so the command cannot make the probe trace, exec or read other
84// config. Null when the count is not a literal integer up to 32; 'other' when the command passes config that may
85// set the identity and the probe does not replay (GIT_CONFIG_PARAMETERS, or a counted include.path/includeIf key).
86function probeEnv(assigns: Record<string, string>): Record<string, string> | null | 'other' {
87 if (assigns.GIT_CONFIG_PARAMETERS !== undefined) return 'other'
88 const out = Object.fromEntries(Object.entries(assigns).filter(([k]) => IDENTITY_ENV.test(k) && !/^GIT_CONFIG_(?:COUNT|KEY_|VALUE_)/.test(k)))
89 const raw = assigns.GIT_CONFIG_COUNT
90 if (raw === undefined) return out
91 if (!/^\d+$/.test(raw) || Number(raw) > MAX_CONFIG_COUNT) return null
92 let n = 0
93 for (let i = 0; i < Number(raw); i++) {
94 const key = assigns[`GIT_CONFIG_KEY_${i}`]
95 const value = assigns[`GIT_CONFIG_VALUE_${i}`]
96 if (key === undefined || value === undefined) continue
97 // An include may set the identity from a file, and replaying it would let the command make the probe read any config.
98 if (INCLUDE_KEY.test(key) || /[$`]/.test(key) || SECRET_PLACEHOLDER.test(key)) return 'other'
99 if (!IDENTITY_KEYS.has(key.toLowerCase())) continue
100 out[`GIT_CONFIG_KEY_${n}`] = key
101 out[`GIT_CONFIG_VALUE_${n}`] = value
102 n++
103 }
104 if (n) out.GIT_CONFIG_COUNT = String(n)
105 return out
106}
107hooks/state.ts 298 lines1import type { GitCall } from './calls'
2import { createTracker, REAL, UNKNOWN, type Flow, type Possible, type Tracker } from './flow'
3import { hasVariable, joinPath, resolvePath } from './paths'
4
5// Process state as possibility sets: `dir` (UNKNOWN after a `cd` or `env -C` to a path built from a variable or
6// substitution), `prev` for `cd -`, the pushd `stack` (JSON), and per variable: `env:NAME` what child processes see
7// (REAL: not set by the command; a call's own prefix wins), `var:NAME` the shell value, and `x:NAME` the export
8// attribute (EXPORTED or NOT_EXPORTED; REAL: as inherited, which the guard cannot tell). `allexport` is `set -a`.
9// `env:NAME` holds UNSET after a shell `unset` or `export -n`: the variable is removed, not restored to the inherited value.
10export const UNSET = '\0unset'
11const EXPORTED = '1'
12const NOT_EXPORTED = '0'
13const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
14
15export type ProcState = { proc: Tracker<string>; names: Set<string>; startDir: string; home: string }
16
17export function newProcState(startDir: string, home: string): ProcState {
18 const proc = createTracker<string>((v) => v, (k) => (k === 'dir' || k === 'prev' ? [startDir] : k === 'stack' ? ['[]'] : [REAL]))
19 return { proc, names: new Set<string>(), startDir, home }
20}
21
22export function parseStack(v: Possible<string>): (string | null)[] | null {
23 return v === UNKNOWN ? null : v === REAL ? [] : (JSON.parse(v) as (string | null)[])
24}
25
26export function dirOf(st: ProcState, v: Possible<string>): string | null {
27 return v === REAL ? st.startDir : v === UNKNOWN ? null : v
28}
29
30// `physical`: the OS resolves the path (`env -C`, `sudo -D`, `cd -P`), so `..` stays as written.
31export function move(st: ProcState, from: string | null, target: string, physical = false): string | null {
32 if (hasVariable(target)) return null
33 const join = physical ? joinPath : resolvePath
34 if (target.startsWith('/') || target === '~' || target.startsWith('~/')) return join('/', target, st.home)
35 return from === null ? null : join(from, target, st.home)
36}
37
38export function setEnv(st: ProcState, k: string, v: Possible<string>, effect: Flow): void {
39 st.names.add(k)
40 st.proc.set(`env:${k}`, [v], effect)
41}
42
43type How = 'export' | 'unexport' | 'plain'
44
45// One `NAME=V` or `NAME` word of an assignment, `export` or `declare`. `export` (and `declare -x`) exports the value;
46// `export -n` (and `declare +x`) keeps it in the shell only, so children no longer see the name. A plain assignment
47// reaches children only when the name is exported (an earlier export, `set -a`); whether an inherited name is
48// exported cannot be told, so then both the new value and the environment before it stay possible.
49function declareWord(st: ProcState, word: string, how: How, effect: Flow): void {
50 const eq = word.indexOf('=')
51 const k = eq === -1 ? word : word.slice(0, eq)
52 if (!NAME.test(k)) return
53 st.names.add(k)
54 if (eq !== -1) st.proc.set(`var:${k}`, [word.slice(eq + 1)], effect)
55 if (how === 'unexport') {
56 st.proc.set(`x:${k}`, [NOT_EXPORTED], effect)
57 st.proc.set(`env:${k}`, [UNSET], effect)
58 return
59 }
60 if (how === 'export') {
61 st.proc.set(`x:${k}`, [EXPORTED], effect)
62 // `export NAME` exports the shell value the command set (REAL: whatever it inherited).
63 st.proc.set(`env:${k}`, eq === -1 ? st.proc.get(`var:${k}`) : [word.slice(eq + 1)], effect)
64 return
65 }
66 if (eq === -1) return
67 const v = word.slice(eq + 1)
68 const all = st.proc.get('allexport')
69 const mayAll = all.some((s) => s === EXPORTED || s === UNKNOWN)
70 const states = all.every((s) => s === EXPORTED) ? [EXPORTED] : [...st.proc.get(`x:${k}`), ...(mayAll ? [EXPORTED] : [])]
71 const env = st.proc.get(`env:${k}`)
72 st.proc.set(`env:${k}`, states.flatMap((s) => (s === EXPORTED ? [v] : s === NOT_EXPORTED ? env : [v, ...env])), effect)
73 if (mayAll) st.proc.set(`x:${k}`, states, effect)
74}
75
76// A statement of only assignments (`A=v`, `A=v B=w`) sets shell variables.
77export function applyAssign(st: ProcState, assigns: Record<string, string>, effect: Flow): void {
78 for (const [k, v] of Object.entries(assigns)) declareWord(st, `${k}=${v}`, 'plain', effect)
79}
80
81// `export [-n] [-f] [-p] [--] WORDS`; `-f` names functions, which hold no state the guard follows.
82export function applyExport(st: ProcState, argv: string[], effect: Flow): void {
83 let how: How = 'export'
84 let k = 1
85 for (; k < argv.length && /^-[nfp]+$/.test(argv[k]!); k++) {
86 if (argv[k]!.includes('f')) return
87 if (argv[k]!.includes('n')) how = 'unexport'
88 }
89 if (argv[k] === '--') k++
90 for (const a of argv.slice(k)) declareWord(st, a, how, effect)
91}
92
93// `set -a` (`-o allexport`) exports every later assignment; `set +a` stops it.
94export function applySet(st: ProcState, argv: string[], effect: Flow): void {
95 for (let k = 1; k < argv.length && /^[-+][A-Za-z]+$/.test(argv[k]!); k++) {
96 const a = argv[k]!
97 const on = a[0] === '-' ? EXPORTED : NOT_EXPORTED
98 if (a.includes('a')) st.proc.set('allexport', [on], effect)
99 if (a.includes('o') && argv[++k] === 'allexport') st.proc.set('allexport', [on], effect)
100 }
101}
102
103// `unset -f` removes functions, which hold no state the guard follows; `-v`, `-n` and no option remove variables.
104export function applyUnset(st: ProcState, argv: string[], effect: Flow): void {
105 let functions = false
106 let k = 1
107 for (; k < argv.length && /^-[vfn]+$/.test(argv[k]!); k++) functions ||= argv[k]!.includes('f')
108 if (argv[k] === '--') k++
109 if (functions) return
110 for (const a of argv.slice(k)) {
111 setEnv(st, a, UNSET, effect)
112 st.proc.set(`var:${a}`, [UNSET], effect)
113 st.proc.set(`x:${a}`, [NOT_EXPORTED], effect)
114 }
115}
116
117// `declare`, `typeset`, `local` and `readonly` with only -x -r -g -l -u set variables: exported with `-x`, unexported
118// with `+x`, otherwise shell variables only. Returns why the call cannot be followed (another flag, or a word built
119// from a variable).
120export function applyDeclare(st: ProcState, argv: string[], dyn: boolean[], effect: Flow): string | undefined {
121 let how: How = 'plain'
122 let k = 1
123 for (; k < argv.length && /^[-+]/.test(argv[k]!); k++) {
124 if (argv[k] === '--') {
125 k++
126 break
127 }
128 if (!/^[-+][xrglu]+$/.test(argv[k]!)) return `${argv[0]} ${argv[k]}`
129 if (argv[k]!.includes('x')) how = argv[k]![0] === '-' ? 'export' : 'unexport'
130 }
131 if (dyn.slice(1).some(Boolean)) return `${argv[0]} with a word built from a variable or substitution`
132 for (const a of argv.slice(k)) declareWord(st, a, how, effect)
133 return undefined
134}
135
136// `here` maps a possible directory to the one the command runs in (`env -C` moves it).
137export function applyCd(st: ProcState, argv: string[], here: (d: Possible<string>) => string | null, loop: boolean, effect: Flow): void {
138 const dirs = st.proc.get('dir')
139 const target = argv.slice(1).find((a) => a === '-' || (a !== '--' && !a.startsWith('-')))
140 const physical = argv.slice(1).some((a) => /^-[LPe@]*P[e@]*$/.test(a))
141 const relative = target !== undefined && target !== '-' && !/^(?:\/|~$|~\/)/.test(target)
142 const to: Possible<string>[] =
143 target === undefined
144 ? [st.home]
145 : target === '-'
146 ? st.proc.get('prev')
147 : dirs.map((d) => (loop && relative ? UNKNOWN : (move(st, here(d), target, physical) ?? UNKNOWN)))
148 st.proc.set('prev', dirs, effect)
149 st.proc.set('dir', to, effect)
150}
151
152// `pushd DIR` stacks the current directory and moves; bare `pushd` swaps the top two; `-n DIR` only stacks DIR;
153// `+N`/`-N` rotate the stack, which is not followed (the directory becomes unknown).
154export function applyPushd(st: ProcState, argv: string[], here: (d: Possible<string>) => string | null, loop: boolean, effect: Flow): void {
155 const words = argv.slice(1).filter((a) => a !== '--')
156 const noCd = words.includes('-n')
157 const target = words.find((a) => a !== '-n')
158 const dirs = st.proc.get('dir')
159 if (target !== undefined && /^[+-]\d+$/.test(target)) {
160 st.proc.set('prev', dirs, effect)
161 st.proc.set('dir', [UNKNOWN], effect)
162 st.proc.set('stack', [UNKNOWN], effect)
163 return
164 }
165 if (target === undefined) {
166 if (noCd) return
167 const to: Possible<string>[] = []
168 const after: Possible<string>[] = []
169 for (const v of st.proc.get('stack')) {
170 const s = parseStack(v)
171 for (const d of dirs) {
172 if (s === null) {
173 to.push(UNKNOWN)
174 after.push(UNKNOWN)
175 } else if (!s.length) {
176 // Bare pushd fails on an empty stack and nothing moves.
177 to.push(d)
178 after.push(v)
179 } else {
180 to.push(s[s.length - 1] ?? UNKNOWN)
181 after.push(JSON.stringify([...s.slice(0, -1), dirOf(st, d)]))
182 }
183 }
184 }
185 st.proc.set('prev', dirs, effect)
186 st.proc.set('dir', to, effect)
187 st.proc.set('stack', after, effect)
188 return
189 }
190 if (noCd) {
191 const added = st.proc.get('stack').flatMap((v): Possible<string>[] => {
192 const s = parseStack(v)
193 return s === null ? [UNKNOWN] : dirs.map((d) => JSON.stringify([...s, move(st, here(d), target)]))
194 })
195 st.proc.set('stack', added, effect)
196 return
197 }
198 const stacks = st.proc.get('stack').flatMap((v): Possible<string>[] => {
199 const s = parseStack(v)
200 return s === null ? [UNKNOWN] : dirs.map((d) => JSON.stringify([...s, dirOf(st, d)]))
201 })
202 st.proc.set('stack', stacks, effect)
203 applyCd(st, ['pushd', target], here, loop, effect)
204}
205
206// Git config an earlier call of the command may have written, which the guard's lookups (taken before the command
207// runs) do not see. `config`: any key, so the identity and push configuration; `upstream`: a branch's upstream.
208// Set whether or not the writing call surely runs, and kept to the end of the command, since config is on disk.
209export type ConfigTaint = { config: boolean; upstream: boolean }
210
211const CONFIG_READ_OPTS = new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l', '--get-color', '--get-colorbool'])
212const CONFIG_BOOL_OPTS = new Set([
213 '--global', '--system', '--local', '--worktree', '--show-origin', '--show-scope', '--null', '-z', '--name-only', '--includes', '--no-includes',
214 '--bool', '--int', '--bool-or-int', '--path', '--expiry-date', '--all', '--regexp', '--fixed-value',
215])
216const CONFIG_VALUE_OPTS = new Set(['-f', '--file', '--blob', '--type', '--default', '--url'])
217
218// Only a recognised read (`git config k`, `--get`, `--list`, `get`, `list`) leaves config alone; any other form may write.
219function configWrites(args: string[]): boolean {
220 const positional: string[] = []
221 let read = false
222 for (let i = 0; i < args.length; i++) {
223 const a = args[i]!
224 const key = a.startsWith('--') && a.includes('=') ? a.slice(0, a.indexOf('=')) : a
225 if (CONFIG_READ_OPTS.has(key)) read = true
226 else if (CONFIG_VALUE_OPTS.has(key)) i += key === a ? 1 : 0
227 else if (CONFIG_BOOL_OPTS.has(key)) continue
228 else if (a.startsWith('-')) return true
229 else positional.push(a)
230 }
231 if (positional[0] === 'get' || positional[0] === 'list') return false
232 // A key has a dot; a single word without one is a subcommand (`git config edit`).
233 return !(read || (positional.length === 1 && positional[0]!.includes('.')))
234}
235
236// `remote add` and `set-url` change where a remote points, not which branch a push updates, so they do not taint.
237export function taintConfig(t: ConfigTaint, call: GitCall): ConfigTaint {
238 const { sub, args } = call
239 const opts = args.slice(0, args.includes('--') ? args.indexOf('--') : undefined)
240 if (sub === 'config' && configWrites(args)) return { config: true, upstream: true }
241 // A checkout or switch with a start point (`-c x origin/main`) may set the upstream through branch.autoSetupMerge.
242 const upstream =
243 (sub === 'branch' && opts.some((a) => /^(?:--set-upstream-to(?:=|$)|--unset-upstream$|--track(?:=|$)|-[vqfrali]*[ut])/.test(a))) ||
244 (sub === 'push' && opts.some((a) => a === '--set-upstream' || /^-[fnqvd4-6]*u/.test(a))) ||
245 ((sub === 'checkout' || sub === 'switch') && (opts.some((a) => /^(?:--track(?:=|$)|-[qfmld]*t)/.test(a)) || opts.filter((a) => !a.startsWith('-')).length > 1))
246 return upstream ? { ...t, upstream: true } : t
247}
248
249// `popd [-n] [+N|-N]` removes entry N of `dirs` (0 is the current directory, `-N` counts from the right) and moves
250// only when that entry is 0; `-n` never moves and removes entry 1 for 0. Out of range, it fails and nothing changes.
251export function applyPopd(st: ProcState, argv: string[], dyn: boolean[], effect: Flow): void {
252 const dirs = st.proc.get('dir')
253 const prev = st.proc.get('prev')
254 let noCd = false
255 let index: string | undefined
256 for (let k = 1; k < argv.length; k++) {
257 const a = argv[k]!
258 if (dyn[k]) index = '?'
259 else if (a === '-n') noCd = true
260 else if (/^[+-]\d+$/.test(a)) index = a
261 else if (a !== '--') return
262 }
263 // A known `+N` above 0 or `-n` keeps the directory; any other index the guard cannot tell may move it.
264 const stays = noCd || (index !== undefined && index !== '?' && index[0] === '+' && Number(index.slice(1)) > 0)
265 const to: Possible<string>[] = []
266 const after: Possible<string>[] = []
267 const prevs: Possible<string>[] = []
268 for (const v of st.proc.get('stack')) {
269 const s = parseStack(v)
270 if (s === null || index === '?') {
271 if (stays) to.push(...dirs)
272 else to.push(UNKNOWN)
273 after.push(UNKNOWN)
274 prevs.push(...prev, ...(stays ? [] : dirs))
275 continue
276 }
277 const size = s.length + 1
278 let at = index === undefined ? 0 : index[0] === '+' ? Number(index.slice(1)) : size - 1 - Number(index.slice(1))
279 if (noCd && at === 0) at = 1
280 if (at < 0 || at >= size || size === 1) {
281 to.push(...dirs)
282 after.push(v)
283 prevs.push(...prev)
284 } else if (at === 0) {
285 to.push(s[s.length - 1] ?? UNKNOWN)
286 after.push(JSON.stringify(s.slice(0, -1)))
287 prevs.push(...dirs)
288 } else {
289 to.push(...dirs)
290 after.push(JSON.stringify(s.filter((_, i) => i !== size - 1 - at)))
291 prevs.push(...prev)
292 }
293 }
294 st.proc.set('prev', prevs, effect)
295 st.proc.set('dir', to, effect)
296 st.proc.set('stack', after, effect)
297}
298hooks/words.ts 65 lines1export const ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/
2// Variables that change the identity `git var` reports; only these reach the guard's identity probe.
3export const IDENTITY_ENV =
4 /^(?:GIT_(?:AUTHOR|COMMITTER)_(?:NAME|EMAIL)|EMAIL|HOME|XDG_CONFIG_HOME|GIT_CONFIG_(?:GLOBAL|SYSTEM|NOSYSTEM|COUNT|KEY_\d+|VALUE_\d+))$/
5
6// The other variables the guard reads for a call: kube and terraform targets, the git repository and config.
7export const READ_ENV = /^(?:KUBECONFIG|TF_WORKSPACE|TF_DATA_DIR|HELM_KUBE(?:CONTEXT|APISERVER|TOKEN|CAFILE)|GIT_(?:DIR|WORK_TREE|NAMESPACE|CONFIG_PARAMETERS))$/
8
9export function liftAssigns(words: string[]): { assigns: Record<string, string>; rest: string[] } {
10 const assigns: Record<string, string> = {}
11 let k = 0
12 while (k < words.length && ASSIGN.test(words[k]!)) {
13 const eq = words[k]!.indexOf('=')
14 assigns[words[k]!.slice(0, eq)] = words[k]!.slice(eq + 1)
15 k++
16 }
17 return { assigns, rest: words.slice(k) }
18}
19
20export const TOOLS = ['git', 'gh', 'glab', 'tea', 'fj', 'kubectl', 'helm', 'terraform', 'tofu']
21export const SHELL_NAMES = ['bash', 'sh', 'zsh', 'dash', 'ksh']
22const TOOL_WORD = new RegExp(`(?<![\\w.-])(${TOOLS.join('|')})(?![\\w-])`, 'gi')
23const RUN_WORD = new RegExp(`(?<![\\w.-])(${[...TOOLS, ...SHELL_NAMES].join('|')})(?![\\w-])`, 'g')
24
25// The tool names that appear as words of their own in `text`, matched on word boundaries rather than whitespace.
26// `anyCase` is for the whole command text, where `$GIT` names git too; `shells` adds the shell names.
27export function toolWords(text: string, opts: { anyCase?: boolean; shells?: boolean } = {}): string[] {
28 const re = opts.shells ? RUN_WORD : TOOL_WORD
29 const flags = opts.anyCase ? 'gi' : 'g'
30 // A path segment with a dotted name (`/gh.example/`, `/terraform.tfstate`) is a name, not the tool; elsewhere
31 // `git.cmd` (`import git.cmd`) still names it.
32 const found = [...text.matchAll(new RegExp(re.source, flags))].filter(
33 (m) => !(text[m.index! - 1] === '/' && /^\.\w/.test(text.slice(m.index! + m[0].length))),
34 )
35 return [...new Set(found.map((m) => m[1]!.toLowerCase()))]
36}
37
38const esc = (s: string) => s.replace(/[.+^${}()|[\]\\*?]/g, '\\$&')
39
40// The names in `names` a glob or brace command word (`gi[t]`, `/usr/bin/gi?`, `{git,true}`) could expand to;
41// empty for a word with a variable or substitution, whose value cannot be told.
42export function mayName(word: string, names: readonly string[]): string[] {
43 if (/[$`]/.test(word)) return []
44 const last = word.slice(word.lastIndexOf('/') + 1)
45 let re = ''
46 for (let i = 0; i < last.length; i++) {
47 const c = last[i]!
48 const close = c === '[' ? last.indexOf(']', i + 2) : c === '{' ? last.indexOf('}', i) : -1
49 if (c === '*') re += '.*'
50 else if (c === '?') re += '.'
51 else if (c === '[' && close !== -1) re += `[${last.slice(i + 1, close).replace(/^!/, '^').replace(/\\/g, '\\\\')}]`
52 else if (c === '{' && close !== -1) {
53 const body = last.slice(i + 1, close)
54 re += body.includes('..') ? '.*' : `(?:${body.split(',').map(esc).join('|')})`
55 } else re += esc(c)
56 if (close !== -1 && (c === '[' || c === '{')) i = close
57 }
58 try {
59 const pattern = new RegExp(`^${re}$`)
60 return names.filter((n) => pattern.test(n))
61 } catch {
62 return [...names]
63 }
64}
65hooks/calls.ts 247 lines1import type { Flow } from './flow'
2import type { Simple } from './lex'
3import { hasVariable, joinPath } from './paths'
4import { IDENTITY_ENV } from './words'
5
6export type GitCall = {
7 kind: 'git'
8 sub: string
9 args: string[]
10 dir: string
11 overrides: Record<string, string>
12 // Every `-c` in order, key as written: multivalued keys (`remote.*.push`) and includes keep all their values.
13 config: [key: string, value: string][]
14 assigns: Record<string, string>
15 op: string
16 flow: Flow
17 // `env -i`, or `env -u` of a variable git reads its identity from.
18 scrubbed?: boolean
19 // Why the repository or directory the call acts on cannot be followed; a write is then unparsed.
20 opaque?: string
21 // Follows `args`: set when some word is not literal (a variable, substitution, glob or secret placeholder).
22 dynArgs?: boolean[]
23}
24
25// secret-box resolves a placeholder before the command runs, so the guard cannot know the value.
26export const SECRET_PLACEHOLDER = /\{\{secret:[^}]*\}\}/
27
28export type GhCall = { kind: 'gh'; cli: 'gh' | 'glab' | 'tea' | 'fj'; area: string; verb: string; args: string[]; dir: string; flow: Flow }
29
30export type InfraTool = 'kubectl' | 'helm' | 'terraform' | 'tofu'
31
32export type InfraCall = {
33 kind: 'infra'
34 tool: InfraTool
35 sub: string
36 args: string[]
37 dir: string
38 assigns: Record<string, string>
39 context?: string
40 kubeconfig?: string
41 custom?: boolean
42 // An option the guard does not know came before the verb, so the verb may be its value.
43 unsure?: boolean
44 flow: Flow
45}
46
47export type Call = GitCall | GhCall | InfraCall
48
49const FJ_VALUE = new Set(['-H', '--host', '-C', '--cwd', '--style'])
50export const INFRA_TOOLS = new Set<string>(['kubectl', 'helm', 'terraform', 'tofu'])
51const INFRA_VALUE: Record<InfraTool, Set<string>> = {
52 kubectl: new Set([
53 '--context', '-n', '--namespace', '--cluster', '--user', '--kubeconfig', '-s', '--server', '--as', '--as-group', '--as-uid', '--token',
54 '--username', '--password', '--certificate-authority', '--client-certificate', '--client-key', '--tls-server-name',
55 '--request-timeout', '--cache-dir', '-v', '--vmodule', '--log-flush-frequency', '--profile', '--profile-output',
56 ]),
57 helm: new Set([
58 '--kube-context', '-n', '--namespace', '--kubeconfig', '--kube-as-user', '--kube-as-group', '--kube-token', '--kube-apiserver',
59 '--kube-ca-file', '--kube-tls-server-name', '--registry-config', '--repository-cache', '--repository-config', '--burst-limit', '--qps',
60 '--content-cache', '--color', '--colour',
61 ]),
62 terraform: new Set(),
63 tofu: new Set(),
64}
65// Flags known not to take a separate value; any other option without "=" may take the next word.
66const INFRA_BOOL: Record<InfraTool, Set<string>> = {
67 kubectl: new Set([
68 '--dry-run', '--all', '--all-namespaces', '-A', '--force', '--wait', '--overwrite', '--recursive', '-R', '--server-side', '--force-conflicts',
69 '--prune', '--record', '--local', '--ignore-not-found', '--now', '--disable-compression', '--insecure-skip-tls-verify',
70 '--match-server-version', '--warnings-as-errors', '-i', '-t', '--stdin', '--tty', '--rm', '--quiet', '-q',
71 ]),
72 helm: new Set([
73 '--dry-run', '--install', '-i', '--atomic', '--wait', '--wait-for-jobs', '--create-namespace', '--force', '--reuse-values', '--reset-values',
74 '--reset-then-reuse-values', '--debug', '--devel', '--skip-crds', '--no-hooks', '--cleanup-on-fail', '--dependency-update',
75 '--disable-openapi-validation', '--render-subchart-notes', '--keep-history', '--rollback-on-failure', '--take-ownership', '--hide-notes',
76 '--skip-schema-validation', '--insecure-skip-tls-verify', '--plain-http', '--verify', '--enable-dns', '--hide-secret',
77 '--kube-insecure-skip-tls-verify',
78 ]),
79 terraform: new Set(),
80 tofu: new Set(),
81}
82const INFRA_CONTEXT_OPT: Partial<Record<InfraTool, string>> = { kubectl: '--context', helm: '--kube-context' }
83const INFRA_TARGET_OPTS: Partial<Record<InfraTool, Set<string>>> = {
84 kubectl: new Set(['--server', '-s', '--cluster']),
85 helm: new Set(['--kube-apiserver']),
86}
87
88const GIT_FLAGS = new Set([
89 '-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--no-lazy-fetch', '--no-optional-locks', '--no-advice', '--literal-pathspecs',
90 '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs', '-v', '--version', '-h', '--help', '--html-path', '--man-path', '--info-path',
91 '--exec-path', '--list-cmds',
92])
93// Options that move the repository or its refs somewhere the guard's own lookups do not follow.
94const GIT_OPAQUE = new Set(['--git-dir', '--work-tree', '--namespace', '--config-env', '--bare'])
95const GIT_OPAQUE_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_NAMESPACE']
96const PUSH_OPTION_VALUE = new Set(['-o', '--push-option'])
97
98// Reports a word the shell expands in a slot that decides what a call does.
99export type Mark = (why: string) => void
100
101// `ok` is false for an option form git's own parser may read differently, or a target the guard cannot follow.
102export function parseGit(s: Simple, argv: string[], dyn0: boolean[], dir0: string, home: string, flow: Flow, mark: Mark): { call: GitCall | null; ok: boolean } {
103 const dyn = argv.map((a, k) => (dyn0[k] ?? false) || SECRET_PLACEHOLDER.test(a))
104 let dir = dir0
105 let ok = !GIT_OPAQUE_ENV.some((k) => k in s.assigns)
106 const overrides: Record<string, string> = {}
107 const config: [string, string][] = []
108 let i = 1
109 while (i < argv.length) {
110 const a = argv[i]!
111 const key = a.startsWith('--') && a.includes('=') ? a.slice(0, a.indexOf('=')) : a
112 if ((a === '-C' || a === '-c') && dyn[i + 1]) mark(`a git ${a} value built from a variable, substitution or glob`)
113 if (a === '-C') {
114 const target = argv[i + 1] ?? ''
115 if (hasVariable(target) || dyn[i + 1]) ok = false
116 else dir = joinPath(dir, target, home)
117 i += 2
118 } else if (a === '-c') {
119 const kv = argv[i + 1] ?? ''
120 const eq = kv.indexOf('=')
121 if (eq > 0) {
122 overrides[kv.slice(0, eq).toLowerCase()] = kv.slice(eq + 1)
123 config.push([kv.slice(0, eq), kv.slice(eq + 1)])
124 }
125 i += 2
126 } else if (GIT_OPAQUE.has(key)) {
127 ok = false
128 i += key === a && a !== '--bare' ? 2 : 1
129 } else if (key === '--attr-source') {
130 i += key === a ? 2 : 1
131 } else if (GIT_FLAGS.has(key)) {
132 i++
133 } else if (a.startsWith('-')) {
134 ok = false
135 i++
136 } else {
137 break
138 }
139 }
140 const sub = argv[i]
141 if (sub === undefined) return { call: null, ok }
142 if (dyn[i]) mark('a git subcommand built from a variable, substitution or glob')
143 // The remote and refspecs decide what a push updates. A secret placeholder as the remote is a URL with a token,
144 // which names no branch; one in a refspec hides the destination.
145 for (let j = i + 1, positional = 0; sub === 'push' && j < argv.length; j++) {
146 const a = argv[j]!
147 if (PUSH_OPTION_VALUE.has(a)) {
148 j++
149 continue
150 }
151 if (dyn0[j] || (dyn[j] && positional > 0)) mark('a git push remote or refspec built from a variable, substitution, glob or secret placeholder')
152 if (!a.startsWith('-')) positional++
153 }
154 const scrubbed = s.clear || s.unset?.some((k) => IDENTITY_ENV.test(k))
155 const dynArgs = dyn.slice(i + 1)
156 return {
157 call: {
158 kind: 'git', sub, args: argv.slice(i + 1), dir, overrides, config, assigns: s.assigns, op: s.op, flow,
159 ...(scrubbed ? { scrubbed } : {}), ...(dynArgs.some(Boolean) ? { dynArgs } : {}),
160 },
161 ok,
162 }
163}
164
165export function parseGh(argv: string[], dyn: boolean[], cli: GhCall['cli'], dir: string, flow: Flow, mark: Mark): GhCall | null {
166 const rest: string[] = []
167 const restDyn: boolean[] = []
168 for (let i = 1; i < argv.length; i++) {
169 if (argv[i] === '-R' || argv[i] === '--repo' || (cli === 'tea' && argv[i] === '-r') || (cli === 'fj' && FJ_VALUE.has(argv[i]!))) i++
170 else if (argv[i]!.startsWith('-')) continue
171 else {
172 rest.push(argv[i]!)
173 restDyn.push(dyn[i] ?? false)
174 }
175 }
176 if (restDyn[0] || restDyn[1]) mark(`a ${cli} area or verb built from a variable, substitution or glob`)
177 if (rest.length === 0) return null
178 return { kind: 'gh', cli, area: rest[0]!, verb: rest[1] ?? '', args: argv.slice(1), dir, flow }
179}
180
181// True when a kubectl or helm option may take the next word as its value: a known value option, or any
182// option the guard does not know, without an attached "=" value.
183export function takesValue(tool: InfraTool, a: string): boolean {
184 if (tool === 'terraform' || tool === 'tofu' || !a.startsWith('-') || a === '--' || a.includes('=')) return false
185 if (!a.startsWith('--') && a.length !== 2) return false
186 return INFRA_VALUE[tool].has(a) || !INFRA_BOOL[tool].has(a)
187}
188
189// Index of the first token after the options, or -1; `-chdir=DIR` moves `dir`.
190function infraVerb(argv: string[], tool: InfraTool, onChdir: (dir: string) => void): { at: number; unsure: boolean } {
191 const value = INFRA_VALUE[tool]
192 let unsure = false
193 for (let i = 1; i < argv.length; i++) {
194 const a = argv[i]!
195 if (a.startsWith('-chdir=') && (tool === 'terraform' || tool === 'tofu')) onChdir(a.slice(7))
196 else if (value.has(a)) i++
197 else if (!a.startsWith('-')) return { at: i, unsure }
198 else if (takesValue(tool, a)) unsure = true
199 }
200 return { at: -1, unsure }
201}
202
203export function parseInfra(s: Simple, argv: string[], dyn: boolean[], tool: InfraTool, dir0: string, home: string, flow: Flow, mark: Mark): InfraCall | null {
204 let dir = dir0
205 let custom = false
206 const { at, unsure } = infraVerb(argv, tool, (target) => {
207 if (hasVariable(target)) custom = true
208 else dir = joinPath(dir, target, home)
209 })
210 const verb = argv[at]
211 if (at === -1 || verb === undefined) return null
212 if (dyn[at]) mark(`a ${tool} verb built from a variable, substitution or glob`)
213 const opt = INFRA_CONTEXT_OPT[tool]
214 const targets = INFRA_TARGET_OPTS[tool]
215 let context: string | undefined
216 let kubeconfig: string | undefined
217 // The previous word was an unknown option, so this word may be its value rather than an option.
218 let loose = false
219 for (let i = 1; opt !== undefined && i < argv.length && argv[i] !== '--'; i++) {
220 const a = argv[i]!
221 const eq = a.startsWith('--') ? a.indexOf('=') : -1
222 const key = eq === -1 ? a : a.slice(0, eq)
223 const val = eq === -1 ? argv[i + 1] : a.slice(eq + 1)
224 const target = targets?.has(key) || (!a.startsWith('--') && targets?.has(a.slice(0, 2)))
225 if (loose && (key === opt || key === '--kubeconfig' || target)) custom = true
226 loose = false
227 if ((key === opt || key === '--kubeconfig') && dyn[eq === -1 ? i + 1 : i]) mark(`a ${tool} ${key} value built from a variable, substitution or glob`)
228 if (key === opt && val) context = val
229 else if (key === '--kubeconfig' && val) kubeconfig = val
230 else if (target) custom = true
231 if (eq === -1 && INFRA_VALUE[tool].has(a)) i++
232 else if (takesValue(tool, a)) loose = true
233 }
234 return { kind: 'infra', tool, sub: verb, args: argv.slice(at + 1), dir, assigns: s.assigns, context, kubeconfig, custom, unsure, flow }
235}
236
237// The words after the verb that are not options or option values (`rollout restart`, `state rm`).
238export function positionals(call: InfraCall): string[] {
239 const value = INFRA_VALUE[call.tool]
240 const out: string[] = []
241 for (let i = 0; i < call.args.length && call.args[i] !== '--'; i++) {
242 if (value.has(call.args[i]!)) i++
243 else if (!call.args[i]!.startsWith('-')) out.push(call.args[i]!)
244 }
245 return out
246}
247hooks/extract.ts 321 lines1import { REAL, UNKNOWN, type Flow, type Possible } from './flow'
2import { INFRA_TOOLS, parseGh, parseGit, parseInfra, type Call, type InfraTool } from './calls'
3import { tokenize, type Simple } from './lex'
4import { basename } from './paths'
5import { applyAssign, applyCd, applyDeclare, applyExport, applyPopd, applyPushd, applySet, applyUnset, dirOf, move, newProcState, UNSET, type ProcState } from './state'
6import { IDENTITY_ENV, mayName, READ_ENV, toolWords } from './words'
7import { SHELLS, shellScript, unwrap } from './wrappers'
8
9// `opaque` lists constructs the guard does not model anywhere in the command (unparsed when the text names a
10// tool); `local` lists commands it does not model that name a tool among their own words (unparsed by themselves).
11export type Local = { why: string; tools: string[] }
12export type Parsed = { calls: Call[]; ok: boolean; opaque: string[]; local: Local[] }
13
14// A command the guard does not look through that names one of these later runs it in a way the guard cannot follow
15// (xargs, flock, `find -exec`, `direnv exec`, `su -c '...'`), unless the head only prints or searches text.
16const OPAQUE_TOOLS = new Set(['git', 'gh', 'glab', 'tea', 'fj', 'kubectl', 'helm', 'terraform', 'tofu', ...SHELLS])
17// `cat` only copies its input, so a commit message heredoc (`"$(cat <<'EOF' ...)"`) that names a tool stays data.
18const TEXT_HEADS = new Set(['echo', 'printf', 'man', 'which', 'type', 'grep', 'rg', 'cat'])
19// Shell constructs the guard does not follow: code defined here and run later, or a branch it does not model.
20const STRUCTURES: Record<string, string> = {
21 case: 'a case statement',
22 select: 'a select loop',
23 coproc: 'a coprocess',
24 trap: 'a trap',
25 alias: 'an alias',
26 function: 'a function definition',
27}
28// Commands the guard models that only test or iterate: their words name tools as data.
29const FLOW_HEADS = new Set(['[', '[[', 'test', 'for', 'true', 'false', ':'])
30const SINGLE_PIPE = /(?:^|[^|])\|(?:[^|]|$)/
31const SINGLE_AMP = /(?:^|[^&])&(?:[^&]|$)/
32const MAX_FANOUT = 16
33const MAX_QUEUE = 4000
34const DECLARES = new Set(['declare', 'typeset', 'local', 'readonly'])
35const SUBST_TEXT = /[$<>]\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)|`[^`]*`/g
36// Variables that change how the shell or git reads every later word.
37const SHELL_ENV = new Set(['IFS', 'BASH_ENV', 'ENV', 'GIT_CONFIG_PARAMETERS'])
38const DYNAMIC = 'built from a variable, substitution or glob'
39const PLAIN_WORD = /^[\w./+@%:,=~-]+$/
40// Commands that switch the kube context, or rewrite what a context points at, in a kubeconfig the guard read before
41// the command ran: each row is the words that must all appear after the command name (empty: any arguments).
42const KUBE_SWITCHERS: Record<string, string[][]> = {
43 kubectx: [[]],
44 'kubectl-ctx': [[]],
45 kubens: [[]],
46 aws: [['eks', 'update-kubeconfig']],
47 gcloud: [['get-credentials']],
48 az: [['aks', 'get-credentials']],
49 doctl: [['kubeconfig', 'save'], ['cluster', 'create']],
50 oc: [['login'], ['project'], ['config']],
51 kind: [['export', 'kubeconfig'], ['create', 'cluster']],
52 minikube: [['update-context'], ['start'], ['profile']],
53 k3d: [['kubeconfig', 'merge'], ['cluster', 'create']],
54}
55const KUBECTX_READ = new Set(['-c', '--current', '-h', '--help'])
56const OC_CONFIG_READ = new Set(['view', 'get-contexts', 'current-context', 'get-clusters', 'get-users'])
57// Commands that write the files named among their words (`sed` and `perl` only with -i).
58const FILE_WRITERS = new Set(['cp', 'mv', 'ln', 'install', 'rsync', 'tee', 'truncate', 'dd', 'sed', 'perl'])
59const KUBE_FILE = /(?:^|\/)\.kube(?:\/|$)|kubeconfig/i
60const WORKSPACE_FILE = /(?:^|\/)\.terraform(?:\/environment)?\/?$/
61
62// The walk over the queued commands: `chain` and `depth` follow the connectors, `loops` the open `do` bodies.
63// `kube` and `workspace` are set once an earlier command may have switched the kube context or terraform workspace
64// out of the guard's sight; `written` holds the base names of files earlier commands wrote.
65type Walk = {
66 queue: Simple[]
67 calls: Call[]
68 st: ProcState
69 opaque: string[]
70 local: Local[]
71 chain: number
72 joinNext: boolean
73 depth: number
74 loops: number[]
75 kube: boolean
76 workspace: boolean
77 written: Set<string>
78}
79
80// One command after unwrapping, with how surely it runs.
81type Step = {
82 cmd: Simple
83 idx: number
84 u: ReturnType<typeof unwrap>
85 argv: string[]
86 dyn: boolean[]
87 name: string
88 sure: boolean
89 loop: boolean
90 effect: Flow
91 here: (d: Possible<string>) => string | null
92}
93
94function mark(w: Walk, why: string): void {
95 if (!w.opaque.includes(why)) w.opaque.push(why)
96}
97
98// Follows the connector before `cmd`: a new chain at anything but `&&` (and `then`), and the subshell depth.
99function advance(w: Walk, cmd: Simple): void {
100 const raw = cmd.argv[0]
101 // `then` runs only when the condition succeeded, so it continues the condition's chain like `&&`.
102 if (cmd.op !== '&&' && cmd.op !== '$(' && raw !== 'then' && !w.joinNext) w.chain++
103 w.joinNext = raw === 'then' && cmd.argv.length === 1
104 if (cmd.op !== '$(') for (const ch of cmd.op) w.depth = Math.max(0, w.depth + (ch === '(' ? 1 : ch === ')' ? -1 : 0))
105}
106
107// At `done`, queues the loop body once more for the back edge: it runs again after any state it changed.
108function expandLoop(w: Walk, cmd: Simple, idx: number): void {
109 const raw = cmd.argv[0]
110 if (raw === 'do') w.loops.push(idx)
111 if (raw !== 'done' || !w.loops.length) return
112 const start = w.loops.pop()!
113 if (!cmd.again && w.queue.length < MAX_QUEUE) w.queue.splice(idx + 1, 0, ...w.queue.slice(start, idx + 1).map((c, i) => ({ ...c, op: i === 0 ? ';' : c.op, again: true })))
114 else if (!cmd.again) mark(w, 'a loop too large to follow')
115}
116
117function flowOf(w: Walk, cmd: Simple, idx: number): Step {
118 const u = unwrap(cmd)
119 let nextOp = ''
120 for (let j = idx + 1; j < w.queue.length; j++) {
121 if (w.queue[j]!.op === '$(') continue
122 nextOp = w.queue[j]!.op
123 break
124 }
125 const loop = w.loops.length > 0
126 // A call after `||`, inside a substitution, negated, in a pipeline, in the background or in a loop may not
127 // run, or may run while its failure lets the chain go on.
128 const sure = !cmd.op.includes('||') && cmd.op !== '$(' && !u.negated && !SINGLE_PIPE.test(cmd.op) && !SINGLE_PIPE.test(nextOp) && !SINGLE_AMP.test(nextOp) && !loop
129 // Process state does not leak out of a `( ... )` subshell.
130 const effect: Flow = { chain: w.chain, certain: sure && w.depth === 0, loop }
131 // The directory this command runs in (`env -C` moves it); a call in an unknown one is judged at the start directory and fails closed.
132 const here = (d: Possible<string>) => u.chdir.reduce<string | null>((from, t) => move(w.st, from, t, true), dirOf(w.st, d))
133 return { cmd, idx, u, argv: u.argv, dyn: u.dyn, name: u.argv.length ? basename(u.argv[0]!) : '', sure, loop, effect, here }
134}
135
136// Shell builtins that change the process state; false when `s` is not one.
137function applyBuiltin(w: Walk, s: Step): boolean {
138 const { argv, name, effect } = s
139 if (name === 'export') applyExport(w.st, argv, effect)
140 else if (name === 'unset') applyUnset(w.st, argv, effect)
141 else if (DECLARES.has(name)) {
142 const why = applyDeclare(w.st, argv, s.dyn, effect)
143 if (why) mark(w, why)
144 }
145 else if (name === 'cd') applyCd(w.st, argv, s.here, s.loop, effect)
146 else if (name === 'pushd') applyPushd(w.st, argv, s.here, s.loop, effect)
147 else if (name === 'popd') applyPopd(w.st, argv, s.dyn, effect)
148 else if (name === 'set') applySet(w.st, argv, effect)
149 // A sourced script is not read; one the shell makes up or reads from stdin is not even a file.
150 else if ((name === 'source' || name === '.') && (argv.length === 1 || s.dyn[1] || argv[1] === '/dev/stdin' || argv[1] === '-')) mark(w, 'a sourced script the guard cannot read')
151 else return false
152 return true
153}
154
155// One call per possible directory and value of each multi-valued variable; fanned-out calls are never certain.
156function emitToolCalls(w: Walk, s: Step): void {
157 const { argv, name, u } = s
158 const dirs = w.st.proc.get('dir')
159 let combos = dirs.map((d) => ({ here: s.here(d), env: {} as Record<string, string>, unknown: false, gone: [] as string[] }))
160 for (const n of w.st.names) {
161 // Only the variables the guard reads can change a verdict, so the others do not multiply the calls.
162 if (!READ_ENV.test(n) && !IDENTITY_ENV.test(n)) continue
163 if (u.clear || u.unset.has(n) || Object.hasOwn(u.assigns, n)) continue
164 const values = w.st.proc.get(`env:${n}`)
165 combos = combos.flatMap((c) =>
166 values.map((v) => (v === REAL ? c : v === UNKNOWN ? { ...c, unknown: true } : v === UNSET ? { ...c, gone: [...c.gone, n] } : { ...c, env: { ...c.env, [n]: v } })),
167 )
168 if (combos.length > MAX_FANOUT) {
169 mark(w, 'more than 16 combinations of directory and variables')
170 combos = combos.slice(0, MAX_FANOUT)
171 }
172 }
173 const flow: Flow = { chain: w.chain, certain: s.sure && combos.length === 1, loop: s.loop }
174 const slot = (why: string) => mark(w, why)
175 for (const { here, env, unknown, gone } of combos) {
176 const simple: Simple = { argv, assigns: { ...env, ...u.assigns }, op: s.cmd.op, unset: [...u.unset, ...gone], clear: u.clear }
177 const at = here ?? w.st.startDir
178 if (name === 'git') {
179 const r = parseGit(simple, argv, s.dyn, at, w.st.home, flow, slot)
180 const why = !r.ok ? 'a git option or repository the guard cannot follow' : here === null ? 'a directory the guard cannot tell' : unknown ? 'a variable the guard cannot tell' : undefined
181 if (r.call && why) r.call.opaque = why
182 if (r.call) w.calls.push(r.call)
183 } else if (name === 'gh' || name === 'glab' || name === 'tea' || name === 'fj') {
184 const call = parseGh(argv, s.dyn, name, at, flow, slot)
185 if (call) w.calls.push(call)
186 } else {
187 const call = parseInfra(simple, argv, s.dyn, name as InfraTool, at, w.st.home, flow, slot)
188 // Terraform is classified by its directory, and a relative kubeconfig path depends on it.
189 const located = name === 'terraform' || name === 'tofu' || Boolean(call?.kubeconfig || simple.assigns.KUBECONFIG)
190 // A `..` left for the OS to resolve hides the directory names terraform is classified by.
191 const opaque = call !== null && call.dir.split('/').includes('..')
192 // An earlier command may have switched the context or workspace, or written the kubeconfig the guard reads.
193 const files = (call?.kubeconfig || simple.assigns.KUBECONFIG || '').split(':').filter(Boolean)
194 const switched = name === 'kubectl' || name === 'helm' ? w.kube || files.some((f) => w.written.has(basename(f))) : w.workspace
195 if (call && ((here === null && located) || unknown || (opaque && located) || switched)) call.custom = true
196 if (call) w.calls.push(call)
197 }
198 }
199}
200
201// `bash -c`, `eval` and `secret-box exec --` scripts are queued in the wrapper's place in the chain.
202function inlineScript(w: Walk, s: Step): void {
203 const { argv, name, u } = s
204 let script: string | undefined
205 let from = argv.length
206 if (name === 'secret-box') {
207 // `secret-box exec -- SCRIPT` runs SCRIPT through a shell; an exec form without a script cannot be read.
208 if (argv[1] === 'exec') {
209 if (argv[2] === '--' && argv[3] !== undefined) script = argv.slice((from = 3)).join(' ')
210 else mark(w, 'a secret-box exec without a script')
211 }
212 } else if (name === 'eval') {
213 script = argv.slice((from = 1)).join(' ')
214 } else {
215 // A script read from stdin (pipe, here-string, heredoc) is not followed: the call is unparsed.
216 const r = shellScript(argv)
217 if (!r.ok) mark(w, 'a shell option the guard does not know')
218 else if (r.stdin) mark(w, 'a shell reading its script from stdin')
219 script = r.script
220 if (r.at !== undefined && script !== undefined) from = r.at
221 // A shell running a script it builds or reads from a process substitution is a local marker under its own name.
222 if (r.at !== undefined && s.dyn[r.at]) {
223 w.local.push({ why: `a ${name} script ${DYNAMIC}`, tools: [name] })
224 return
225 }
226 }
227 if (script === undefined) return
228 if (s.dyn.slice(from).some(Boolean)) {
229 mark(w, `a ${name} script ${DYNAMIC}`)
230 return
231 }
232 if (w.queue.length >= MAX_QUEUE) {
233 mark(w, 'too many commands to follow')
234 return
235 }
236 const inner = tokenize(script, 1)
237 if (!inner.ok) mark(w, 'a quote or substitution that does not close, or nesting deeper than 4')
238 for (const why of inner.opaque) mark(w, why)
239 // The wrapper's own assignments and `env -u`/`-i` apply to the inner script, which runs in the wrapper's place in the chain.
240 const outer = u.assigns
241 const drop = { unset: [...u.unset], clear: u.clear, chdir: u.chdir }
242 w.queue.splice(s.idx + 1, 0, ...inner.cmds.map((c) => ({ ...c, op: c.op === '' ? s.cmd.op : c.op, ...(c.argv.length ? { assigns: { ...outer, ...c.assigns }, ...drop } : {}) })))
243}
244
245// A command the guard does not model that names a tool among its arguments runs it out of sight.
246// `rg --pre` runs a program on every file, so it is not a text head.
247// Follows the commands that change the kube context or terraform workspace on disk, and the files a command writes;
248// once either may have happened, the lookups (taken before the command runs) are stale for every later call,
249// whether or not the command surely ran.
250function markSwitch(w: Walk, s: Step): void {
251 const words = s.argv.slice(1)
252 const inPlace = words.some((a) => /^-[a-zA-Z]*i|^--in-place/.test(a))
253 const writes = FILE_WRITERS.has(s.name) && (inPlace || (s.name !== 'sed' && s.name !== 'perl'))
254 for (const f of [...(s.cmd.outputs ?? []), ...(writes ? words.filter((a) => !a.startsWith('-')) : [])]) {
255 w.written.add(basename(f))
256 if (KUBE_FILE.test(f)) w.kube = true
257 if (WORKSPACE_FILE.test(f)) w.workspace = true
258 }
259 const rows = Object.hasOwn(KUBE_SWITCHERS, s.name) ? KUBE_SWITCHERS[s.name]! : []
260 if (!rows.some((row) => row.every((r) => words.includes(r)))) return
261 if ((s.name === 'kubectx' || s.name === 'kubens' || s.name === 'kubectl-ctx') && words.every((a) => KUBECTX_READ.has(a))) return
262 if (s.name === 'oc' && words[0] === 'config' && words.slice(1).some((a) => OC_CONFIG_READ.has(a))) return
263 w.kube = true
264}
265
266function markOpaque(w: Walk, s: Step): void {
267 if (FLOW_HEADS.has(s.name) || (TEXT_HEADS.has(s.name) && !(s.name === 'rg' && s.argv.some((a) => a === '--pre' || a.startsWith('--pre='))))) return
268 // Substitutions are followed as calls of their own, so their text is not this command's words. A command word
269 // that is not a plain name or path (`"git push" origin main`) counts among them.
270 const words = s.argv.slice(1).map((a, i) => (s.dyn[i + 1] ? a.replace(SUBST_TEXT, ' ') : a))
271 if (!PLAIN_WORD.test(s.argv[0]!)) words.push(s.argv[0]!)
272 const tools = toolWords([...words, ...(s.cmd.input ?? [])].join(' '), { shells: true })
273 if (tools.length) w.local.push({ why: `${s.name} with ${tools.join(', ')} among its words`, tools })
274}
275
276export function extractCalls(src: string, startDir: string, home: string): Parsed {
277 const first = tokenize(src)
278 const w: Walk = {
279 queue: [...first.cmds], calls: [], st: newProcState(startDir, home), opaque: [], local: [], chain: 0, joinNext: false, depth: 0, loops: [],
280 kube: false, workspace: false, written: new Set(),
281 }
282 if (!first.ok) mark(w, 'a quote or substitution that does not close, or nesting deeper than 4')
283 for (const why of first.opaque) mark(w, why)
284 let budget = 2000
285 for (let idx = 0; idx < w.queue.length; idx++) {
286 const cmd = w.queue[idx]!
287 if (budget-- <= 0) {
288 mark(w, 'too many commands to follow')
289 break
290 }
291 advance(w, cmd)
292 expandLoop(w, cmd, idx)
293 w.st.proc.step(w.chain)
294 const s = flowOf(w, cmd, idx)
295 for (const why of s.u.opaque) mark(w, why)
296 const assigned = [...Object.keys(s.u.assigns), ...(s.name === 'export' || DECLARES.has(s.name) ? s.argv.slice(1).map((a) => a.split('=')[0]!) : [])]
297 for (const k of assigned) if (SHELL_ENV.has(k)) mark(w, `an assignment to ${k}`)
298 if (s.dyn[0]) {
299 const tools = mayName(s.argv[0]!, [...OPAQUE_TOOLS])
300 if (tools.length) w.local.push({ why: `a command word that may expand to ${tools.join(', ')}`, tools })
301 else mark(w, `a command word ${DYNAMIC}`)
302 continue
303 }
304 // The shell opens a command's redirections before it runs.
305 markSwitch(w, s)
306 if (!s.argv.length) {
307 if (s.u.bare) applyAssign(w.st, s.u.assigns, s.effect)
308 continue
309 }
310 if (Object.hasOwn(STRUCTURES, s.name)) {
311 mark(w, STRUCTURES[s.name]!)
312 continue
313 }
314 if (applyBuiltin(w, s)) continue
315 if (s.name === 'git' || s.name === 'gh' || s.name === 'glab' || s.name === 'tea' || s.name === 'fj' || INFRA_TOOLS.has(s.name)) emitToolCalls(w, s)
316 else if (SHELLS.has(s.name) || s.name === 'eval' || s.name === 'secret-box') inlineScript(w, s)
317 else markOpaque(w, s)
318 }
319 return { calls: w.calls, ok: !w.opaque.length && !w.local.length, opaque: w.opaque, local: w.local }
320}
321