SLOPSHOPPER

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…

newguardcommandtoastprocess
★ 1v0.1.1MITupdated 2026-10-15iuliandita/mods/mods/guard-rules/claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guard-rules
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by guard-rules: guard-rules: protected-branch: git push to protected branch "main". Work on a featu ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard-context ⎿ guard-rules: guard-context: expected a valid name and protected or relaxed; usage: /guard-context <name> protected|relaxed, ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mods

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.

Install

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.

Catalog

  • 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.

Private mods

Put your own mods in local/mods/<name>/ (gitignored) and install them with scripts/mods install <name> --local.

License

MIT

Project and support

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.

Source 16 files
hooks/register.ts 64 lines
1import 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}
64
hooks/context.ts 353 lines
1import { 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}
353
hooks/rules.ts 285 lines
1import { 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}
285
hooks/shell.ts 7 lines
1// 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'
7
hooks/flow.ts 51 lines
1// `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}
51
hooks/paths.ts 34 lines
1export 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}
34
hooks/branch.ts 263 lines
1import { 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}
263
hooks/identity.ts 107 lines
1import { 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}
107
hooks/state.ts 298 lines
1import 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}
298
hooks/words.ts 65 lines
1export 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}
65
hooks/calls.ts 247 lines
1import 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}
247
hooks/extract.ts 321 lines
1import { 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