SLOPSHOPPER

infra-guard

Guards infra work from Claude Code: shows the ambient kube/gcloud/gh/aws targets, requires named contexts and approved terraform plans, reads scripts before…

panebandguardcommandtoasttool
v0.5.0Apache-2.0updated 2026-10-18dfgh012316/infra-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · infra-guard
│ ┃ secret-paste ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing is waiting to be pasted. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /timeline │ ⎿ infra-guard: No infrastructure changes recorded yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · secret-paste
Nothing is waiting to be pasted.
README

infra-guard

Guardrails for infrastructure work in Claude Code. It shows which cluster, project and account Claude is pointed at, makes cluster changes name their target, reads scripts before they run, gates terraform apply on a plan a person approved, and lets Claude rotate Kubernetes Secrets without ever seeing the values.

It is a Claude Code mod: a plugin with a hooks module that runs inside Claude Code, so the checks run on every tool call instead of depending on Claude remembering a rule in CLAUDE.md.

Status: early (0.x). Tested on the Linux CLI (WSL2) with Claude Code 2.1.301–2.1.302, and on the macOS CLI with 2.1.302 against GKE (all but secret_rotate and secret_set). The VS Code extension is untested.

Why

A deploy script took upgrade.sh <chart> <env>. The <env> argument only picked values-<env>.yaml; the cluster was whatever kube-context happened to be current, and the script neither printed nor accepted one. Run while the context pointed at another environment, it applied one environment's values to the other's release.

Reading that script before running it was not enough: it was read to see how it built the helm command, not to see what it silently inherited. infra-guard reads it for that, every time.

What it does

GuardBehavior
Ambient targetsA line above the prompt shows the current kube context, gcloud project (and configuration), gh account and AWS profile. Names matching prodPattern show in red with PROD. Claude is told the targets with the first infra command and again whenever they change.
Named targetsA kubectl or helm command that changes a cluster must pass --context / --kube-context (or HELM_KUBECONTEXT); otherwise it is refused, with the ambient context in the reason.
Script checkBefore ./x.sh, bash x.sh or source x.sh runs, the mod reads the script (and scripts it runs, two levels down) and lists every line that would act on the ambient target, switch it, or run terraform. The person decides; on a production target the button reads "Run against production". A script it cannot read is asked about too.
Terraform plan gateterraform apply runs only from a saved plan. The mod reads terraform show -json itself, counts what the plan adds, changes, replaces and destroys, lists the destroyed addresses, and asks. terraform destroy asks with the number of resources in state. The plan contents are not passed to Claude.
ApprovalA script or plan the person approved in one of these dialogs runs on that answer: the mod allows that one call at the permission check, so the permission mode (in auto mode, its classifier) does not ask about it again. Only an ask becomes an allow; a settings rule that denies the command still refuses it. Each such allow is said in a dim transcript line and in the call's audit note.
AuditEvery infra change, refusal and script run is logged with the targets at the time and its outcome. /timeline [count] prints them newest first. Values after --from-literal=, --password, *_TOKEN= and similar are masked before they are stored.
Watcheswatch_start hands a Deployment rollout or a GitHub Actions run to the mod, which polls it read-only every 5 seconds and sends Claude a message when it ends, so Claude does not sleep-loop. The mod builds each watch command itself; Claude cannot pass an arbitrary one.
Secret valuessecret_rotate mints a value; secret_set takes one the person pastes into the mod's own field, or reads one from a file they wrote; either writes one key of an existing Secret after the person confirms, the value goes to kubectl on stdin and Claude gets key names, sizes and fingerprints. Commands that would print Secret data (get secret -o yaml/json/jsonpath/…, --raw …/secrets, edit secret, exec … env, helm get values/manifest/all) are refused. Any tool output carrying a value the mod minted or read, plain or base64, is withheld, and stored conversation rows carrying one are scrubbed.

Tools Claude gets

ToolDoes
mcp__infra-guard__secret_rotateSets a key to a new random value (16–128 characters) after confirmation, then reads it back to verify.
mcp__infra-guard__secret_setSame, with a value the person supplies (a token a vendor issued). By default the mod opens a field to paste it into: a pane that takes the keys at once, or, where no pane is placed, a field above the prompt that ctrl+x tab reaches. It returns at once, and Claude gets a message when the value is set or cancelled (Cancel, or Esc in the pane). With file, it reads a file the person wrote instead (an absolute path; one trailing newline is dropped) and asks to confirm with its length and fingerprint.
mcp__infra-guard__secret_keysLists keys with byte sizes and session-scoped fingerprints; warns when last-applied-configuration holds a plaintext copy.
mcp__infra-guard__leak_checkSearches files (default ~/.claude) for every value the mod has seen this session and answers file names only.
mcp__infra-guard__watch_start / watch_stopStart or stop a rollout or GitHub Actions watch.

Install

claude plugin marketplace add dfgh012316/infra-guard
claude plugin install infra-guard@jerrytech

Or from inside a session: /plugin install infra-guard --marketplace dfgh012316/infra-guard.

Requires a Claude Code release with mods. Auto-update is off by default for third-party marketplaces: run claude plugin update infra-guard@jerrytech, or turn on auto-update for the jerrytech marketplace under /plugin → Marketplaces.

To try it for one session without installing: claude --plugin-dir /path/to/infra-guard.

Configuration

OptionDefaultMeaning
prodPattern`prod\prd\production`Case-insensitive regular expression. A target name that matches is shown and confirmed as production.

Set it in /config, or under pluginConfigs in settings.json.

What it is not

infra-guard catches mistakes. It does not stop someone, or a model, determined to get around it.

  • It reads command lines; it is not a shell. No variable expansion, aliases or functions. Makefile targets, paths built from $(dirname "$0") or variables, and deploy tools written in Python or Go are invisible to the script check. Commands outside its lists (for example kubectl plugins) are not treated as writes.
  • The real wall is permissions. Give the kube identity Claude uses no get secrets and no production write access, and keep a privileged kubeconfig for the secret tools (they take an absolute kubeconfig path). The command-line guards only protect against slips.
  • Known Secret values live in memory, for the current load only. A reload or restart forgets them, and a value the mod never minted or read is not caught. Values shorter than 8 characters are not tracked. Multi-line values (PEM), wrapped or URL-safe base64, and partial prints can slip through.
  • kubectl apply leaves old values behind. A Secret created with kubectl apply keeps a plaintext copy in the kubectl.kubernetes.io/last-applied-configuration annotation. secret_keys warns about it; the mod does not remove it.
  • Dialog answers can leave the machine. In proxy captures of Claude Code 2.1.302 signed in with claude.ai, a value typed into a dialog (under "Other") never reached the model or the local transcript. With Remote Control connected (as remoteControlAtStartup: true does for every session), it was sent to Anthropic in the session's event stream (POST /v1/code/sessions/<id>/worker/events). With Remote Control off, the session sent no /v1/code/sessions requests, and the value was in none of its 44 requests. Remote Control is one setting away for every session, so secret_set never takes a value through a dialog, and the mod's dialogs carry only key names, lengths and fingerprints. In the same kind of capture, with Remote Control connected, a value pasted into the mod's own field (0.5.0) appeared in none of the 208 requests the session sent, nor in any of Claude Code's files on disk.
  • The paste field shows what you paste. It has no masked mode; it closes on Enter. A pane the mod opens on its own is placed from 144 terminal columns; narrower, the field sits above the prompt instead.
  • The value file is yours to guard. Claude is told only its path. The mod knows the value once it has read the file, so output carrying it is withheld from then on; a read of the file before that is not caught. Write it in your own terminal and delete it when the Secret is set.
  • The audit log is local. Plaintext JSON under ~/.claude/plugins/store/, last 1000 entries, shared by sessions on the machine, not atomic and not tamper-proof. It records only commands Claude ran.
  • Failures. If the Bash check throws, the command is refused; if the output check throws, the output is withheld. Claude Code skips a hook whose own error handler fails, so this is not a guarantee.
  • Mods are not sandboxed. This one runs kubectl, terraform, gh and grep as you, and reads gcloud, gh and AWS config files. It makes no network requests of its own. Run claude plugin validate . to list every hook and API it uses.

Desktop app WSL sessions do not load plugins, so the guards do not run there.

Development

claude plugin validate --strict .   # manifest, hooks, and the APIs the mod calls
claude plugin test .                 # unit tests, run by Claude Code's engine

TypeScript types for the mod API are generated into .claude-plugin/types/ when Claude Code loads the mod (for example claude --plugin-dir .); after that, tsc -p . type-checks the hooks and tests. Load it again after adding a tool so the generated tool names include it.

Engine behavior worth knowing:

  • A $.ui.ask answer is not always local: while Remote Control is connected, Claude Code sends it to Anthropic in the session's event stream, though it never enters the model's context. Collect secrets some other way.
  • $.ui.ask reaches the person in every permission mode (it is the AskUserQuestion tool), but the answer is not a permission approval: the call's tool.check still puts an ask to the mode's decider, in auto mode its classifier. The mod answers allow at tool.check for the one call it asked about, by tool_use_id.
  • $.process.run hands the child its stdin as a socket, so --patch-file /dev/stdin and grep -f /dev/stdin fail with ENXIO. Use forms that stream fd 0 (-f -).
  • Claude Code stores an allowed tool result's raw stdout separately in the transcript (toolUseResult), out of reach of session.append. Output has to be withheld in tool.call, before it is stored.
  • The runtime has crypto.subtle.digest but no HMAC; fingerprints use a random per-load salt with SHA-256, so they compare values within one session only.

License

Apache-2.0

Source 11 files
hooks/register.tsx 593 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { AuditEntry, PendingPaste, Targets, Watch } from '../types'
4import { settle } from './approval'
5import { mask, targetLine, timeline } from './audit'
6import { CONTEXT, DNS_NAME, REPO } from './names'
7import { describeFindings, scanScript, type Finding } from './script'
8import {
9  carriesKnown,
10  finishPaste,
11  hasKnown,
12  KEYS,
13  leakCheck,
14  listKeys,
15  pastePending,
16  readsSecret,
17  REDACTED,
18  rotate,
19  ROTATE,
20  scrub,
21  SET,
22  setFromFile,
23  startPaste,
24} from './secrets'
25import { calls, isWrite, namesTarget, scripts, switchesTarget, type Call } from './shell'
26import { prodTargets, readTargets } from './targets'
27import { describePlan, summarizePlan, terraformRuns } from './terraform'
28import { ghRunProgress, rolloutProgress } from './watch'
29
30// Guards for infra work from Claude Code:
31// 1. the ambient targets in a band above the prompt, told to Claude when they
32//    change; direct kubectl/helm changes must name their context, and
33//    scripts are read before they run for commands that take the ambient one;
34// 2. terraform apply only from a saved plan the person approved;
35// 3. every infra change logged with the targets of the moment (/timeline);
36// 4. read-only watches the mod polls itself, waking Claude when one ends;
37// 5. Secret values the model never sees (secrets.ts): tools that write them,
38//    commands that would print them refused, output carrying them withheld;
39// 6. a call the person approved in one of these dialogs runs on that answer,
40//    the permission mode not asking again (approval.ts).
41
42type Engine = EngineInterface
43type Where = { cwd: string; home: string }
44type Gate = { deny?: string; confirmed?: string }
45
46const PLUGIN = 'infra-guard'
47const AUDIT_KEY = 'audit'
48const AUDIT_LIMIT = 1000
49const TICK_MS = 5000
50const FINISHED_SHOWN = 3
51// Direct changes to a cluster must say which one; a script is asked about instead.
52const CONTEXT_FLAG: Partial<Record<Call['tool'], string>> = { kubectl: 'kubectl --context', helm: 'helm --kube-context' }
53
54const targets = atom({ plugin: 'infra-guard', key: 'targets' } as const, null as Targets | null)
55const watches = atom({ plugin: 'infra-guard', key: 'watches' } as const, [] as Watch[])
56const pending = atom({ plugin: 'infra-guard', key: 'pending' } as const, null as PendingPaste | null)
57// The pane secret_set opens for the person to paste a value into.
58const PASTE = 'secret-paste'
59
60// What Claude was last told of the targets: it hears again only on a change.
61let announced = ''
62const polling = new Set<string>()
63// Bash calls the person approved in a dialog here, by tool_use_id, while they run;
64// of those, the ones the permission check asked about and this mod allowed.
65const approved = new Set<string>()
66const overruled = new Set<string>()
67
68async function where($: Engine): Promise<Where> {
69  return { cwd: await $.session.cwd(), home: (await $.env.get('HOME')) ?? '' }
70}
71
72function short(path: string, home: string): string {
73  return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path
74}
75
76async function refresh($: Engine): Promise<Targets> {
77  const t = await readTargets($)
78  await update($, targets, () => t)
79  return t
80}
81
82async function ask($: Engine, question: string, yes: string): Promise<boolean> {
83  try {
84    return (await $.ui.ask(question, { header: PLUGIN, options: [yes, 'Cancel'] })) === yes
85  } catch {
86    return false
87  }
88}
89
90function list(items: string[]): string {
91  return `${items.slice(0, 8).join(', ')}${items.length > 8 ? ', …' : ''}`
92}
93
94async function terraformGate($: Engine, command: string, w: Where): Promise<Gate> {
95  const confirmed: string[] = []
96  for (const run of terraformRuns(command, w.cwd, w.home)) {
97    if (run.verb === 'apply' && run.plan === undefined) {
98      return {
99        deny:
100          `${PLUGIN}: refused terraform apply without a saved plan. Run \`terraform plan -out=tfplan\`, ` +
101          'show the person what it changes, then `terraform apply tfplan`.',
102      }
103    }
104    const dir = short(run.dir, w.home)
105    if (run.verb === 'apply' && run.plan !== undefined) {
106      const shown = await $.process.run(['terraform', 'show', '-json', run.plan], { cwd: run.dir, timeoutMs: 60_000 })
107      if (shown.exitCode !== 0) return { deny: `${PLUGIN}: could not read the plan ${run.plan}: ${shown.stderr.trim().slice(0, 300)}` }
108      const s = summarizePlan(JSON.parse(shown.stdout))
109      const losses = [...s.replace, ...s.delete]
110      const question = [
111        `terraform apply ${run.plan} in ${dir}: ${describePlan(s)}.`,
112        ...(losses.length > 0 ? [`Destroys: ${list(losses)}`] : []),
113        'Apply it?',
114      ].join('\n')
115      if (!(await ask($, question, losses.length > 0 ? `Apply, destroying ${losses.length}` : 'Apply'))) {
116        return { deny: `${PLUGIN}: the person did not approve the plan (${describePlan(s)})` }
117      }
118      confirmed.push(describePlan(s))
119      continue
120    }
121    const state = await $.process.run(['terraform', 'state', 'list'], { cwd: run.dir, timeoutMs: 60_000 })
122    const items = state.stdout.split('\n').filter(Boolean)
123    const question = `terraform destroy in ${dir} deletes all ${items.length} resources in its state${items.length > 0 ? `: ${list(items)}` : ''}. Destroy?`
124    if (!(await ask($, question, `Destroy ${items.length}`))) return { deny: `${PLUGIN}: the person did not approve terraform destroy` }
125    confirmed.push(`destroy of ${items.length}`)
126  }
127  return confirmed.length > 0 ? { confirmed: confirmed.join('; ') } : {}
128}
129
130// Reads each script the line runs (and those it runs, two levels down).
131async function scriptGate($: Engine, command: string, w: Where, t: Targets | null, prod: RegExp): Promise<Gate> {
132  const queue = scripts(command, w.cwd, w.home).map(path => ({ path, depth: 0 }))
133  const seen = new Set<string>()
134  const findings: Finding[] = []
135  const unread: string[] = []
136  while (queue.length > 0) {
137    const { path, depth } = queue.shift()!
138    if (seen.has(path)) continue
139    seen.add(path)
140    let text: string
141    try {
142      text = await $.fs.read(path)
143    } catch {
144      unread.push(path)
145      continue
146    }
147    const scan = scanScript(path, text, w.home)
148    findings.push(...scan.findings)
149    if (depth < 2) queue.push(...scan.nested.map(p => ({ path: p, depth: depth + 1 })))
150  }
151  if (findings.length === 0 && unread.length === 0) return {}
152  const onProd = prodTargets(t, prod)
153  const question = [
154    `${seen.size} script(s) would run ${findings.length} command(s) that take the ambient target:`,
155    ...describeFindings(findings, w.home),
156    ...unread.map(p => `could not read ${short(p, w.home)} to check it`),
157    `Ambient now: ${targetLine(t)}${onProd.length > 0 ? `  (PRODUCTION: ${onProd.join(', ')})` : ''}`,
158    'Run it?',
159  ].join('\n')
160  if (!(await ask($, question, onProd.length > 0 ? 'Run against production' : 'Run it'))) {
161    return {
162      deny:
163        `${PLUGIN}: the person did not run it. ${describeFindings(findings, w.home).join('; ')}. ` +
164        'Pass the target explicitly (kubectl --context, helm --kube-context, gcloud --project, aws --profile).',
165    }
166  }
167  return { confirmed: `${findings.length} ambient command(s) in scripts, run as asked` }
168}
169
170async function record($: Engine, entry: AuditEntry) {
171  const kept = ((await $.store.get(AUDIT_KEY)) as AuditEntry[] | undefined) ?? []
172  await $.store.set(AUDIT_KEY, [...kept, entry].slice(-AUDIT_LIMIT))
173}
174
175async function setWatch($: Engine, id: string, change: Partial<Watch>) {
176  await update($, watches, all => all.map(x => (x.id === id && x.state === 'running' ? { ...x, ...change } : x)))
177}
178
179async function announce($: Engine, w: Watch, now: number) {
180  const text = `watch ${w.id} (${w.label}) ${w.state} after ${Math.round((now - w.startedAt) / 1000)}s: ${w.status}`
181  $.ui.toast(text)
182  void $.ui.notify(text, { title: PLUGIN })
183  await $.prompt.submit({ text: `${PLUGIN}: ${text}` })
184}
185
186async function poll($: Engine, w: Watch, now: number) {
187  polling.add(w.id)
188  try {
189    const r = await $.process.run(w.argv, { timeoutMs: 20_000 })
190    const p = w.kind === 'rollout' ? rolloutProgress(r) : ghRunProgress(r)
191    const state = p.state === 'running' && now > w.deadline ? 'timeout' : p.state
192    await setWatch($, w.id, { status: p.status, state, ...(state === 'running' ? {} : { endedAt: now }) })
193    if (state !== 'running') await announce($, { ...w, status: p.status, state }, now)
194  } catch (error) {
195    await setWatch($, w.id, { status: `poll failed: ${error instanceof Error ? error.message : String(error)}` })
196  } finally {
197    polling.delete(w.id)
198  }
199}
200
201async function tick($: Engine, withTargets: boolean) {
202  if (withTargets) await refresh($)
203  const now = await $.clock.now()
204  for (const w of await read($, watches)) if (w.state === 'running' && !polling.has(w.id)) void poll($, w, now)
205}
206
207function directGate(found: Call[], t: Targets | null): Gate {
208  for (const call of found) {
209    const why = readsSecret(call)
210    if (why !== undefined) return { deny: `${PLUGIN}: refused, ${why}. List keys with ${KEYS}; change a value with ${ROTATE} or ${SET}.` }
211  }
212  for (const call of found) {
213    const flag = CONTEXT_FLAG[call.tool]
214    if (flag !== undefined && isWrite(call) && !namesTarget(call)) {
215      return { deny: `${PLUGIN}: refused, a command that changes a cluster must name its target with ${flag} (ambient kube-context: ${t?.kube ?? 'unset'}).` }
216    }
217  }
218  return {}
219}
220
221type Ran = { deny?: string; text?: string; result?: unknown }
222
223// Output carrying a Secret value this mod minted or read never reaches the model.
224function withheld<R extends Ran>(ran: R): R | { deny: string } {
225  if (ran.deny !== undefined || !hasKnown()) return ran
226  if (!carriesKnown(`${ran.text ?? ''}\n${JSON.stringify(ran.result ?? null)}`)) return ran
227  return { deny: `${PLUGIN}: the output carried a Secret value this guard manages, so it was withheld` }
228}
229
230async function startWatch($: Engine, e: Record<string, unknown>) {
231  const minutes = typeof e.timeoutMinutes === 'number' ? Math.min(Math.max(Math.round(e.timeoutMinutes), 1), 120) : 15
232  let argv: string[]
233  let label: string
234  if (e.kind === 'rollout') {
235    const { context, namespace, deployment, kubeconfig } = e
236    if (typeof context !== 'string' || !CONTEXT.test(context)) return { deny: 'context must name a kubeconfig context' }
237    if (typeof namespace !== 'string' || !DNS_NAME.test(namespace)) return { deny: 'namespace is not a valid name' }
238    if (typeof deployment !== 'string' || !DNS_NAME.test(deployment)) return { deny: 'deployment is not a valid name' }
239    if (kubeconfig !== undefined && (typeof kubeconfig !== 'string' || !kubeconfig.startsWith('/'))) return { deny: 'kubeconfig must be an absolute path' }
240    const config = typeof kubeconfig === 'string' ? ['--kubeconfig', kubeconfig] : []
241    argv = ['kubectl', ...config, '--context', context, '--namespace', namespace, 'rollout', 'status', `deployment/${deployment}`, '--watch=false']
242    label = `rollout ${namespace}/${deployment} on ${context}`
243  } else if (e.kind === 'gh-run') {
244    const { repo, run } = e
245    if (typeof repo !== 'string' || !REPO.test(repo)) return { deny: 'repo must be owner/name' }
246    if (typeof run !== 'number' || !Number.isInteger(run) || run <= 0) return { deny: 'run must be a workflow run id' }
247    argv = ['gh', 'run', 'view', String(run), '--repo', repo, '--json', 'status,conclusion']
248    label = `gh run ${run} in ${repo}`
249  } else return { deny: 'kind must be rollout or gh-run' }
250  const now = await $.clock.now()
251  const watch: Watch = {
252    id: `w-${crypto.randomUUID().slice(0, 4)}`,
253    kind: e.kind,
254    label,
255    argv,
256    startedAt: now,
257    deadline: now + minutes * 60_000,
258    status: 'starting',
259    state: 'running',
260  }
261  await update($, watches, all => {
262    const finished = all.filter(x => x.state !== 'running').slice(-FINISHED_SHOWN)
263    return [...all.filter(x => x.state === 'running'), ...finished, watch]
264  })
265  return {
266    result:
267      `Watching ${label} as ${watch.id}: the harness polls it every ${TICK_MS / 1000}s and gives up after ${minutes} min. ` +
268      'A message arrives when it ends, so do not poll or sleep for it yourself.',
269  }
270}
271
272// secret_set with no file: a field of the mod's own takes the value, so no
273// dialog answer (which Claude Code sends to Anthropic while Remote Control is
274// connected) carries it. The pane takes the keys at once where it is placed;
275// else the band holds the field.
276async function openPaste($: Engine, e: Record<string, unknown>) {
277  const started = await startPaste($, e)
278  if ('deny' in started) return started
279  await update($, pending, () => ({ label: started.label, inPane: true }))
280  const opened = await $.ui
281    .open({ id: PASTE, title: `${PLUGIN}: paste a Secret value`, focus: true, closeOnEscape: true, holdToasts: true, rows: 5 })
282    .catch(() => undefined)
283  // No surface at all (a -p run): nobody could paste, so nothing is left waiting.
284  if (opened === undefined || (!opened.isPlaced && opened.reason.startsWith('no-surface'))) {
285    await update($, pending, () => null)
286    await finishPaste($, undefined)
287    return { deny: 'there is no screen to paste the value into here; ask the person to write it to a file and pass `file`' }
288  }
289  if (!opened.isPlaced) await update($, pending, p => p && { ...p, inPane: false })
290  return {
291    result:
292      `Opened a field ${opened.isPlaced ? 'in a pane' : 'above the prompt (ctrl+x tab reaches it)'} where the person pastes the value for ` +
293      `${started.label}. The value goes from that field to kubectl and is never shown to you. A message arrives when it is ` +
294      'set or cancelled: do not ask for the value in chat, and do not poll or wait for it.',
295  }
296}
297
298// Ends the paste: writes a pasted value or cancels, then tells Claude, never the value.
299async function endPaste($: Engine, pasted: string | undefined, closePane: boolean) {
300  if (!pastePending() || (pasted !== undefined && pasted.replace(/\r?\n$/, '') === '')) return
301  await update($, pending, () => null)
302  if (closePane) await $.ui.close({ id: PASTE })
303  const text = await finishPaste($, pasted)
304  if (text !== undefined) await $.prompt.submit({ text: `${PLUGIN}: ${text}` })
305}
306
307// The field itself, in the pane or the band. No `value` prop: the text stays in
308// the field until Enter hands it to onSubmit, and is never drawn back.
309function pasteField($: Engine, e: Parameters<Engine['ui']['resolve']>[0], p: PendingPaste, hint: string) {
310  const { Box, Text, Input, Button } = $.ui.resolve(e)
311  if (Input === undefined) return <Text color="warning">{`${PLUGIN}: paste the value for ${p.label} from a terminal session`}</Text>
312  return (
313    <Box flexDirection="column">
314      <Text>{`Paste the new value for ${p.label}, then Enter. It goes to kubectl only; Claude never sees it.${hint}`}</Text>
315      <Input key="secret-value" label="value" placeholder="paste here" autoFocus onSubmit={value => void endPaste($, value, true)} />
316      <Button key="secret-cancel" label="Cancel" onPress={() => endPaste($, undefined, true)} />
317    </Box>
318  )
319}
320
321export const register: Register = (on, options) => {
322  let prod = /prod|prd|production/i
323  try {
324    prod = new RegExp(String(options.prodPattern ?? prod.source), 'i')
325  } catch {
326    // An unreadable pattern keeps the default.
327  }
328
329  on('session.start', async ($, e, next) => {
330    await $.tool.register({
331      name: 'watch_start',
332      description:
333        'Hands a long-running operation to the harness to watch: a Deployment rollout or a GitHub Actions run. ' +
334        'The harness polls it read-only, shows it to the person, and sends you a message when it ends; ' +
335        'use this instead of sleep loops or repeated status commands.',
336      inputSchema: {
337        type: 'object',
338        properties: {
339          kind: { type: 'string', enum: ['rollout', 'gh-run'] },
340          context: { type: 'string', description: 'rollout: kubeconfig context name' },
341          kubeconfig: { type: 'string', description: 'rollout: absolute kubeconfig path; default kubectl\'s own' },
342          namespace: { type: 'string', description: 'rollout' },
343          deployment: { type: 'string', description: 'rollout: Deployment name' },
344          repo: { type: 'string', description: 'gh-run: owner/name' },
345          run: { type: 'integer', description: 'gh-run: workflow run id' },
346          timeoutMinutes: { type: 'integer', minimum: 1, maximum: 120, description: 'default 15' },
347        },
348        required: ['kind'],
349      },
350      isDeferred: false,
351    })
352    await $.tool.register({
353      name: 'watch_stop',
354      description: 'Stops a watch started with watch_start.',
355      inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
356      isDeferred: false,
357    })
358    const secretTarget = {
359      kubeconfig: { type: 'string', description: 'absolute path of the kubeconfig file; default: kubectl\'s own' },
360      context: { type: 'string', description: 'kubeconfig context name' },
361      namespace: { type: 'string' },
362      secret: { type: 'string', description: 'name of an existing Secret' },
363    }
364    await $.tool.register({
365      name: 'secret_rotate',
366      description:
367        'Sets one key of an existing Kubernetes Secret to a new random value after the person confirms. ' +
368        'The value is generated and written by the harness and is never shown to you; you get key names, sizes, ' +
369        'fingerprints and whether the write was verified. Name the kube context explicitly.',
370      inputSchema: {
371        type: 'object',
372        properties: {
373          ...secretTarget,
374          key: { type: 'string', description: 'key inside the Secret to set' },
375          length: { type: 'integer', minimum: 16, maximum: 128, description: 'characters, default 32' },
376        },
377        required: ['context', 'namespace', 'secret', 'key'],
378      },
379      isDeferred: false,
380    })
381    await $.tool.register({
382      name: 'secret_set',
383      description:
384        'Sets one key of an existing Kubernetes Secret to a value the person supplies (for a token a vendor issued). ' +
385        'Without `file`, the harness opens a field where the person pastes the value, and returns at once; a message ' +
386        'arrives when it is set or cancelled. With `file`, it reads a file the person wrote and asks them to confirm. ' +
387        'The value goes to kubectl and is never shown to you: never ask for it in chat, and never read the file yourself.',
388      inputSchema: {
389        type: 'object',
390        properties: {
391          ...secretTarget,
392          key: { type: 'string', description: 'key inside the Secret to set' },
393          file: {
394            type: 'string',
395            description: 'only when the person wrote the value to a file: its absolute path; one trailing newline is dropped',
396          },
397        },
398        required: ['context', 'namespace', 'secret', 'key'],
399      },
400      isDeferred: false,
401    })
402    await $.tool.register({
403      name: 'secret_keys',
404      description:
405        'Lists the keys of a Kubernetes Secret with byte sizes and session-scoped fingerprints, never values. ' +
406        'Two keys with the same fingerprint hold the same value.',
407      inputSchema: { type: 'object', properties: secretTarget, required: ['context', 'namespace', 'secret'] },
408      isDeferred: false,
409    })
410    await $.tool.register({
411      name: 'leak_check',
412      description:
413        'Searches files for every Secret value this guard has minted or read in this session and answers only the names ' +
414        'of files that hold one. Default folder: ~/.claude (transcripts, history, debug logs).',
415      inputSchema: {
416        type: 'object',
417        properties: { paths: { type: 'array', items: { type: 'string' }, description: 'absolute folders or files' } },
418      },
419      isDeferred: false,
420    })
421    await $.command.register({ name: 'timeline', description: 'Infrastructure changes run from Claude Code, newest first', argumentHint: '[count]' })
422    await refresh($)
423    // A reload forgets the paste it was waiting for; its field goes with it.
424    if ((await read($, pending)) !== null) {
425      await update($, pending, () => null)
426      await $.ui.close({ id: PASTE })
427    }
428    let ticks = 0
429    $.clock.every(TICK_MS, () => void tick($, ++ticks % 3 === 0))
430    return next(e)
431  })
432
433  on('tool.call', { tool: 'mcp__infra-guard__watch_start' }, ($, e) => startWatch($, e as Record<string, unknown>)).catch(() => ({
434    deny: `${PLUGIN}: starting the watch failed`,
435  }))
436
437  on('tool.call', { tool: 'mcp__infra-guard__watch_stop' }, async ($, e) => {
438    const id = (e as Record<string, unknown>).id
439    const all = await read($, watches)
440    if (!all.some(w => w.id === id && w.state === 'running')) return { deny: `no running watch ${String(id)}` }
441    await setWatch($, String(id), { state: 'stopped', status: 'stopped', endedAt: await $.clock.now() })
442    return { result: `stopped ${String(id)}` }
443  }).catch(() => ({ deny: `${PLUGIN}: stopping the watch failed` }))
444
445  on('tool.call', { tool: 'mcp__infra-guard__secret_rotate' }, ($, e) => rotate($, e as Record<string, unknown>)).catch(() => ({
446    deny: `${PLUGIN}: the rotation failed before it reported`,
447  }))
448  on('tool.call', { tool: 'mcp__infra-guard__secret_set' }, ($, e) => {
449    const input = e as Record<string, unknown>
450    return input.file === undefined ? openPaste($, input) : setFromFile($, input)
451  }).catch(() => ({
452    deny: `${PLUGIN}: setting the value failed before it reported`,
453  }))
454  on('tool.call', { tool: 'mcp__infra-guard__secret_keys' }, ($, e) => listKeys($, e as Record<string, unknown>)).catch(() => ({
455    deny: `${PLUGIN}: listing the keys failed`,
456  }))
457  on('tool.call', { tool: 'mcp__infra-guard__leak_check' }, ($, e) => leakCheck($, e as Record<string, unknown>)).catch(() => ({
458    deny: `${PLUGIN}: the leak check failed`,
459  }))
460
461  on('command.run', { command: 'timeline' }, async ($, e) => {
462    const n = Number.parseInt(e.args.trim() || '20', 10)
463    const kept = ((await $.store.get(AUDIT_KEY)) as AuditEntry[] | undefined) ?? []
464    return { text: timeline(kept, Number.isFinite(n) && n > 0 ? Math.min(n, 200) : 20) }
465  })
466
467  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
468    const w = await where($)
469    const found = calls(e.command, w.cwd, w.home)
470    const runs = scripts(e.command, w.cwd, w.home)
471    if (found.length === 0 && runs.length === 0) return withheld(await next(e))
472
473    const started = await $.clock.now()
474    const before = (await read($, targets)) ?? (await refresh($))
475    let gate = directGate(found, before)
476    if (gate.deny === undefined) gate = await terraformGate($, e.command, w)
477    if (gate.deny === undefined && runs.length > 0) {
478      const scripted = await scriptGate($, e.command, w, before, prod)
479      gate = { deny: scripted.deny, confirmed: [gate.confirmed, scripted.confirmed].filter(Boolean).join('; ') || undefined }
480    }
481    // Approved in a dialog here, the call is not asked about again (tool.check below).
482    const id = gate.deny === undefined && gate.confirmed !== undefined ? e.tool_use_id : undefined
483    if (id !== undefined) approved.add(id)
484    const raw = gate.deny === undefined ? await next(e).finally(() => id !== undefined && approved.delete(id)) : { deny: gate.deny }
485    const confirmed = id !== undefined && overruled.delete(id) ? `${gate.confirmed}; the permission check asked, ${PLUGIN} allowed it` : gate.confirmed
486    const ran = withheld(raw)
487    const after = await refresh($)
488
489    if (gate.deny !== undefined || found.some(c => isWrite(c) || switchesTarget(c)) || runs.length > 0) {
490      await record($, {
491        at: new Date(started).toISOString(),
492        session: await $.session.id(),
493        cwd: w.cwd,
494        command: mask(e.command),
495        targets: before,
496        outcome: raw.deny !== undefined ? 'refused' : raw.isError ? 'error' : gate.confirmed ? 'confirmed' : 'ok',
497        note: raw.deny !== undefined ? raw.deny.slice(0, 160) : ran.deny !== undefined ? 'output withheld' : confirmed,
498        ms: (await $.clock.now()) - started,
499      })
500    }
501
502    if (ran.deny !== undefined) return ran
503    const line = targetLine(after)
504    if (line === announced) return ran
505    announced = line
506    return { ...ran, context: [...(ran.context ?? []), `${PLUGIN}: ambient targets now: ${line}`] }
507  }).catch(async ($, e, next) => (next.called ? withheld(await next(e)) : { deny: `${PLUGIN}: its check failed, so the command was refused` }))
508
509  // A Bash call the person approved in a dialog here runs on that answer: an ask
510  // would go to the permission mode, in auto mode its classifier. A failure here
511  // is skipped, leaving the verdict beneath, which asks as it did before. Each
512  // allow is said in the transcript (not to Claude) and in the audit note.
513  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
514    const verdict = await next(e)
515    const settled = settle(verdict, e.tool_use_id, approved)
516    if (settled !== verdict && e.tool_use_id !== undefined) {
517      overruled.add(e.tool_use_id)
518      $.ui.log(`${PLUGIN}: allowed this command at the permission check, on your answer in its dialog`)
519    }
520    return settled
521  })
522
523  // Every other tool: output carrying a known value never reaches the model.
524  on('tool.call', async ($, e, next) => (e.tool === 'Bash' ? next(e) : withheld(await next(e)))).catch(() => ({
525    deny: `${PLUGIN}: its output check failed, so the output was withheld`,
526  }))
527
528  // Every stored row: a known value is replaced before it is kept or sent.
529  on('session.append', ($, e, next) => {
530    if (!hasKnown() || !carriesKnown(JSON.stringify(e.message.content))) return next(e)
531    const content = e.message.content.map(block => {
532      if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: scrub(block.text) }
533      if (block.type === 'tool_result') return JSON.parse(scrub(JSON.stringify(block)))
534      return block
535    })
536    return next({ ...e, message: { ...e.message, content } })
537  }).catch(($, e, next) => {
538    // The scrub failed: keep the row's shape, none of its text.
539    const content = e.message.content.map(block => {
540      if (block.type === 'text') return { ...block, text: REDACTED }
541      if (block.type === 'tool_result') return { ...block, content: REDACTED }
542      return block
543    })
544    return next({ ...e, message: { ...e.message, content } })
545  })
546
547  // The person closing the paste pane (Esc, its close mark) cancels the paste.
548  on('ui.close', async ($, e, next) => {
549    if (e.id === PASTE && e.origin.kind === 'person') await endPaste($, undefined, false)
550    return next(e)
551  })
552
553  on('ui.render', { component: 'Pane', requestId: PASTE }, async ($, e) => {
554    const p = await read($, pending)
555    const { Text } = $.ui.resolve(e)
556    return p === null ? <Text dimColor>Nothing is waiting to be pasted.</Text> : pasteField($, e, p, ' Esc cancels.')
557  })
558
559  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
560    if (e.props.hasSurvey) return next(e)
561    const t = await read($, targets)
562    const all = await read($, watches)
563    const paste = await read($, pending)
564    const shown = [...all.filter(w => w.state === 'running'), ...all.filter(w => w.state !== 'running').slice(-FINISHED_SHOWN)]
565    const { Box, Text } = $.ui.resolve(e)
566    const part = (key: string, label: string, value: string | undefined) => {
567      if (value === undefined) return null
568      const isProd = prod.test(value)
569      return (
570        <Text key={key} color={isProd ? 'error' : 'subtle'} bold={isProd}>
571          {`${label} ${value}${isProd ? ' PROD' : ''}   `}
572        </Text>
573      )
574    }
575    return (
576      <Box flexDirection="column">
577        <Box flexDirection="row">
578          {part('kube', 'kube', t?.kube)}
579          {part('gcloud', 'gcloud', t?.gcloud)}
580          {part('gh', 'gh', t?.gh)}
581          {part('aws', 'aws', t?.aws)}
582        </Box>
583        {shown.map(w => (
584          <Text key={w.id} color={w.state === 'running' ? 'suggestion' : w.state === 'done' ? 'success' : 'warning'}>
585            {`${w.state === 'running' ? 'watching' : w.state} ${w.label}: ${w.status}`}
586          </Text>
587        ))}
588        {paste !== null && !paste.inPane && pasteField($, e, paste, ' ctrl+x tab reaches the field.')}
589      </Box>
590    )
591  })
592}
593
hooks/approval.ts 14 lines
1// A call the person approved in this mod's own dialog (the script check, the
2// terraform plan gate) runs on that answer. The dialog is not a permission
3// approval: the call's tool.check still puts an `ask` to the permission mode,
4// and in auto mode its classifier could refuse what the person just approved.
5
6type Verdict = { decision: 'allow' | 'ask' | 'deny'; reason?: string }
7
8// Only an ask becomes an allow, and only for a call approved while it runs
9// (by tool_use_id): a deny from a settings rule still stands.
10export function settle<V extends Verdict>(verdict: V, id: string | undefined, approved: ReadonlySet<string>): V | Verdict {
11  if (verdict.decision !== 'ask' || id === undefined || !approved.has(id)) return verdict
12  return { decision: 'allow', reason: 'infra-guard: the person approved this call in its dialog' }
13}
14
hooks/audit.ts 38 lines
1import type { AuditEntry, Targets } from '../types'
2
3// Values after these never reach the log: a best effort by spelling.
4const SECRET_FLAG = /(--from-literal=[^=\s]+=|--(?:password|token|secret|client-secret)[= ])(\S+)/gi
5const SECRET_ASSIGNMENT = /\b([A-Za-z0-9_]*(?:PASSWORD|TOKEN|SECRET|APIKEY|API_KEY)[A-Za-z0-9_]*=)(\S+)/gi
6
7export function mask(command: string): string {
8  return command.replace(SECRET_FLAG, '$1***').replace(SECRET_ASSIGNMENT, '$1***').slice(0, 400)
9}
10
11export function targetLine(t: Targets | null): string {
12  if (t === null) return 'unknown'
13  const parts = [
14    t.kube && `kube ${t.kube}`,
15    t.gcloud && `gcloud ${t.gcloud}`,
16    t.gh && `gh ${t.gh}`,
17    t.aws && `aws ${t.aws}`,
18  ].filter(Boolean)
19  return parts.length > 0 ? parts.join(' · ') : 'none set'
20}
21
22function cell(text: string): string {
23  return text.replace(/\|/g, '\\|').replace(/\n/g, ' ')
24}
25
26// Newest first, as a markdown table.
27export function timeline(entries: readonly AuditEntry[], limit: number): string {
28  if (entries.length === 0) return 'No infrastructure changes recorded yet.'
29  const rows = entries
30    .slice(-limit)
31    .reverse()
32    .map(
33      e =>
34        `| ${e.at.replace('T', ' ').slice(0, 19)} | ${e.outcome}${e.note ? `: ${cell(e.note)}` : ''} | ${cell(targetLine(e.targets))} | \`${cell(e.command)}\` |`,
35    )
36  return ['| time (UTC) | outcome | targets at the time | command |', '| --- | --- | --- | --- |', ...rows].join('\n')
37}
38
hooks/names.ts 6 lines
1// What a tool's input may name, checked before it reaches a command line.
2export const CONTEXT = /^[-._:@/a-zA-Z0-9]{1,253}$/
3export const DNS_NAME = /^[a-z0-9]([-a-z0-9.]{0,251}[a-z0-9])?$/
4export const SECRET_KEY = /^[-._a-zA-Z0-9]{1,253}$/
5export const REPO = /^[\w.-]+\/[\w.-]+$/
6
hooks/script.ts 43 lines
1import { calls, isWrite, namesTarget, scripts, switchesTarget, withoutHeredocs } from './shell'
2
3// What a script would run against the ambient target, line by line, and the
4// scripts it runs in turn (resolved from its own folder: a best guess, as a
5// script may `cd` or build the path from $0).
6
7export type Finding = { file: string; line: number; text: string; why: 'ambient' | 'switches' | 'terraform' }
8
9export function scanScript(file: string, text: string, home: string): { findings: Finding[]; nested: string[] } {
10  const dir = file.slice(0, file.lastIndexOf('/')) || '/'
11  const findings: Finding[] = []
12  const nested: string[] = []
13  const lines = withoutHeredocs(text).split('\n')
14  for (let i = 0; i < lines.length; i++) {
15    const start = i
16    let line = lines[i]!
17    while (line.endsWith('\\') && i + 1 < lines.length) line = `${line.slice(0, -1)} ${lines[++i]!}`
18    for (const call of calls(line, dir, home)) {
19      const why =
20        call.tool === 'terraform' && isWrite(call)
21          ? 'terraform'
22          : switchesTarget(call)
23            ? 'switches'
24            : isWrite(call) && !namesTarget(call)
25              ? 'ambient'
26              : undefined
27      if (why !== undefined) findings.push({ file, line: start + 1, text: call.text.slice(0, 160), why })
28    }
29    nested.push(...scripts(line, dir, home))
30  }
31  return { findings, nested }
32}
33
34const WHY = {
35  ambient: 'no explicit target',
36  switches: 'switches the ambient target',
37  terraform: 'terraform change the plan gate cannot see inside a script',
38}
39
40export function describeFindings(findings: Finding[], home: string): string[] {
41  return findings.slice(0, 6).map(f => `${f.file.replace(home, '~')}:${f.line}  ${f.text}  (${WHY[f.why]})`)
42}
43
hooks/secrets.ts 295 lines
1import type { EngineInterface } from 'claude-code'
2import { CONTEXT, DNS_NAME, SECRET_KEY } from './names'
3import { positionals, type Call } from './shell'
4
5// Kubernetes Secret values the model never sees.
6//
7// - secret_rotate mints a random value and writes it to a key of an existing
8//   Secret once the person confirms; secret_set takes one the person pastes
9//   into the mod's own field, or reads one from a file they wrote. Either way
10//   the value reaches kubectl through stdin and the model gets key names,
11//   sizes and fingerprints only.
12// - Every value minted or read here is kept in module memory, so output that
13//   carries one is withheld and stored rows carrying one are scrubbed.
14
15type Engine = EngineInterface
16type Target = { kubeconfig?: string; context: string; namespace: string; secret: string }
17type Source = { kind: 'random'; length: number } | { kind: 'file'; path: string }
18type SecretObject = {
19  type?: string
20  data?: Record<string, string>
21  metadata?: { resourceVersion?: string; annotations?: Record<string, string> }
22}
23
24export const ROTATE = 'mcp__infra-guard__secret_rotate'
25export const SET = 'mcp__infra-guard__secret_set'
26export const KEYS = 'mcp__infra-guard__secret_keys'
27export const REDACTED = '[infra-guard: redacted]'
28
29const LAST_APPLIED = 'kubectl.kubernetes.io/last-applied-configuration'
30const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
31// Shorter values ("true", "5432") would withhold ordinary output.
32const MIN_TRACKED = 8
33
34// Every value this load minted or read, plain and base64. Module memory only.
35const known = new Set<string>()
36// Per-load salt, never shown: fingerprints compare values within a session,
37// and a short password cannot be recovered from one offline.
38const salt = crypto.getRandomValues(new Uint8Array(32))
39
40function track(value: string) {
41  if (value.length < MIN_TRACKED) return
42  known.add(value)
43  known.add(btoa(value))
44}
45
46export function hasKnown(): boolean {
47  return known.size > 0
48}
49
50export function carriesKnown(text: string): boolean {
51  for (const value of known) if (text.includes(value)) return true
52  return false
53}
54
55export function scrub(text: string): string {
56  let out = text
57  for (const value of known) out = out.split(value).join(REDACTED)
58  return out
59}
60
61function mint(length: number): string {
62  let out = ''
63  const bytes = new Uint8Array(length * 2)
64  while (out.length < length) {
65    crypto.getRandomValues(bytes)
66    // 248 = 4 * 62: rejecting the rest keeps every character equally likely.
67    for (const b of bytes) if (b < 248 && out.length < length) out += ALPHABET[b % 62]
68  }
69  return out
70}
71
72async function fingerprint(binary: string): Promise<string> {
73  const data = new Uint8Array(salt.length + binary.length)
74  data.set(salt)
75  data.set(Uint8Array.from(binary, c => c.charCodeAt(0)), salt.length)
76  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', data))
77  return [...digest.slice(0, 6)].map(b => b.toString(16).padStart(2, '0')).join('')
78}
79
80function target(e: Record<string, unknown>): Target | string {
81  const { kubeconfig, context, namespace, secret } = e
82  if (kubeconfig !== undefined && (typeof kubeconfig !== 'string' || !kubeconfig.startsWith('/'))) {
83    return 'kubeconfig must be an absolute path'
84  }
85  if (typeof context !== 'string' || !CONTEXT.test(context)) return 'context must name a kubeconfig context'
86  if (typeof namespace !== 'string' || !DNS_NAME.test(namespace)) return 'namespace is not a valid name'
87  if (typeof secret !== 'string' || !DNS_NAME.test(secret)) return 'secret is not a valid name'
88  return { ...(kubeconfig === undefined ? {} : { kubeconfig }), context, namespace, secret }
89}
90
91function where(t: Target): string {
92  return `${t.namespace}/${t.secret} on context ${t.context}${t.kubeconfig ? ` (${t.kubeconfig})` : ''}`
93}
94
95function kubectl($: Engine, t: Target, args: string[], stdin?: string) {
96  const config = t.kubeconfig ? ['--kubeconfig', t.kubeconfig] : []
97  const argv = ['kubectl', ...config, '--context', t.context, '--namespace', t.namespace, ...args]
98  return $.process.run(argv, stdin === undefined ? undefined : { stdin })
99}
100
101async function readSecret($: Engine, t: Target): Promise<SecretObject | string> {
102  const got = await kubectl($, t, ['get', 'secret', t.secret, '--output', 'json'])
103  if (got.exitCode !== 0) return `kubectl get failed: ${scrub(got.stderr).trim().slice(0, 500)}`
104  const secret = JSON.parse(got.stdout) as SecretObject
105  for (const value of Object.values(secret.data ?? {})) track(atob(value))
106  return secret
107}
108
109async function summary(secret: SecretObject) {
110  const keys = await Promise.all(
111    Object.entries(secret.data ?? {}).map(async ([key, value]) => {
112      const plain = atob(value)
113      return { key, bytes: plain.length, fingerprint: await fingerprint(plain) }
114    }),
115  )
116  const warnings =
117    secret.metadata?.annotations?.[LAST_APPLIED] === undefined
118      ? []
119      : [`the ${LAST_APPLIED} annotation holds a plaintext copy of the values as of the last kubectl apply`]
120  return { type: secret.type, resourceVersion: secret.metadata?.resourceVersion, keys, warnings }
121}
122
123export async function listKeys($: Engine, e: Record<string, unknown>) {
124  const t = target(e)
125  if (typeof t === 'string') return { deny: t }
126  const secret = await readSecret($, t)
127  if (typeof secret === 'string') return { deny: secret }
128  return { result: JSON.stringify({ ...t, ...(await summary(secret)) }, null, 2) }
129}
130
131// The value comes from the mod (random) or from a file the person wrote; either
132// way it reaches kubectl through stdin alone. Never from a dialog answer:
133// while Remote Control is connected, Claude Code sends answers to Anthropic in
134// the session's event stream, though they never reach the model.
135async function obtain($: Engine, t: Target, key: string, source: Source): Promise<string | { deny: string }> {
136  let fromFile = ''
137  if (source.kind === 'file') {
138    try {
139      // One trailing newline is the file's, not the value's.
140      fromFile = (await $.fs.read(source.path)).replace(/\r?\n$/, '')
141    } catch {
142      return { deny: `could not read ${source.path}` }
143    }
144    if (fromFile === '') return { deny: `${source.path} holds no value` }
145    // Known from here on, even if the person cancels.
146    track(fromFile)
147  }
148  try {
149    if (source.kind === 'random') {
150      const answer = await $.ui.ask(`Set ${key} in Secret ${where(t)} to a new random ${source.length}-character value?`, {
151        header: 'Secret',
152        options: ['Rotate', 'Cancel'],
153      })
154      return answer === 'Rotate' ? mint(source.length) : { deny: 'the person did not confirm the change' }
155    }
156    const size = `${fromFile.length} characters, fingerprint ${await fingerprint(fromFile)}`
157    const answer = await $.ui.ask(`Set ${key} in Secret ${where(t)} to the value in ${source.path} (${size})?`, {
158      header: 'Secret',
159      options: ['Set', 'Cancel'],
160    })
161    return answer === 'Set' ? fromFile : { deny: 'the person did not confirm the change' }
162  } catch {
163    return { deny: 'changing a Secret needs a person in an interactive session' }
164  }
165}
166
167// The Secret and key a call names, and the Secret as it is now.
168async function prepare($: Engine, e: Record<string, unknown>): Promise<{ t: Target; key: string; before: SecretObject } | { deny: string }> {
169  const t = target(e)
170  if (typeof t === 'string') return { deny: t }
171  const key = e.key
172  if (typeof key !== 'string' || !SECRET_KEY.test(key)) return { deny: 'key is not a valid Secret key' }
173  const before = await readSecret($, t)
174  if (typeof before === 'string') return { deny: before }
175  return { t, key, before }
176}
177
178export async function writeKey($: Engine, e: Record<string, unknown>, source: Source) {
179  const p = await prepare($, e)
180  if ('deny' in p) return p
181  const value = await obtain($, p.t, p.key, source)
182  if (typeof value !== 'string') return value
183  return apply($, p.t, p.key, p.before, value)
184}
185
186// Writes `value` to `key` over the Secret as read, then reads it back.
187async function apply($: Engine, t: Target, key: string, before: SecretObject, value: string) {
188  track(value)
189  // The host hands stdin over a socket, which kubectl cannot open as
190  // /dev/stdin; `-f -` reads it as a stream. `replace` also refuses the write
191  // when the Secret changed since it was read (resourceVersion).
192  const updated = { ...before, data: { ...before.data, [key]: btoa(value) } }
193  const replaced = await kubectl($, t, ['replace', '--filename', '-'], JSON.stringify(updated))
194  if (replaced.exitCode !== 0) return { deny: `kubectl replace failed: ${scrub(replaced.stderr).trim().slice(0, 500)}` }
195
196  const after = await readSecret($, t)
197  if (typeof after === 'string') return { deny: `written, but reading it back failed: ${after}` }
198  const verified = after.data?.[key] === btoa(value)
199  const report = { ...t, key, written: true, verified, ...(await summary(after)) }
200  return { result: JSON.stringify(report, null, 2) }
201}
202
203export function rotate($: Engine, e: Record<string, unknown>) {
204  const length = e.length ?? 32
205  if (typeof length !== 'number' || !Number.isInteger(length) || length < 16 || length > 128) {
206    return { deny: 'length must be an integer from 16 to 128' }
207  }
208  return writeKey($, e, { kind: 'random', length })
209}
210
211export function setFromFile($: Engine, e: Record<string, unknown>) {
212  const file = e.file
213  if (typeof file !== 'string' || !file.startsWith('/')) return { deny: 'file must be an absolute path' }
214  return writeKey($, e, { kind: 'file', path: file })
215}
216
217// secret_set with no file: the person pastes the value into the mod's own
218// field, which no dialog answer carries. One at a time; the target waits here.
219let pasting: { t: Target; key: string } | null = null
220
221export function pastePending(): boolean {
222  return pasting !== null
223}
224
225// Checks the call and the Secret now, so a bad target fails before the field opens.
226export async function startPaste($: Engine, e: Record<string, unknown>): Promise<{ label: string } | { deny: string }> {
227  if (pasting !== null) return { deny: `a value for ${pasting.key} is already waiting to be pasted` }
228  const p = await prepare($, e)
229  if ('deny' in p) return p
230  pasting = { t: p.t, key: p.key }
231  return { label: `${p.key} in Secret ${where(p.t)}` }
232}
233
234// The pasted value (one trailing newline dropped), or undefined for a cancel:
235// what to tell Claude, which never carries the value.
236export async function finishPaste($: Engine, pasted: string | undefined): Promise<string | undefined> {
237  const p = pasting
238  if (p === null) return undefined
239  pasting = null
240  if (pasted === undefined) return `the person cancelled secret_set for ${p.key}`
241  const value = pasted.replace(/\r?\n$/, '')
242  track(value)
243  const before = await readSecret($, p.t)
244  if (typeof before === 'string') return `secret_set for ${p.key} failed: ${before}`
245  const r = await apply($, p.t, p.key, before, value)
246  return 'deny' in r ? `secret_set for ${p.key} failed: ${r.deny}` : `secret_set wrote ${p.key}: ${r.result}`
247}
248
249// Answers file names only: the values travel to grep on stdin.
250export async function leakCheck($: Engine, e: Record<string, unknown>) {
251  if (known.size === 0) return { result: 'nothing to look for: this load has not minted or read a value yet' }
252  const given = e.paths
253  const paths =
254    Array.isArray(given) && given.length > 0 && given.every(p => typeof p === 'string' && p.startsWith('/'))
255      ? (given as string[])
256      : [`${await $.env.get('HOME')}/.claude`]
257  const ran = await $.process.run(['grep', '-rlsF', '-f', '-', '--', ...paths], {
258    stdin: `${[...known].join('\n')}\n`,
259    timeoutMs: 120_000,
260  })
261  if (ran.exitCode > 1 && ran.stdout === '') return { deny: `grep failed with exit code ${ran.exitCode}` }
262  const files = ran.stdout.split('\n').filter(Boolean)
263  return { result: JSON.stringify({ searched: paths, valuesLookedFor: known.size, filesHoldingOne: files, partial: ran.exitCode > 1 }, null, 2) }
264}
265
266const DATA_OUTPUT = /^(yaml|json|jsonpath|go-template|template|custom-columns)/
267
268function outputFormat(call: Call): string | undefined {
269  for (let i = 0; i < call.args.length; i++) {
270    const a = call.args[i]!
271    if (a === '-o' || a === '--output') return call.args[i + 1]
272    if (a.startsWith('--output=')) return a.slice('--output='.length)
273    if (a.startsWith('-o')) return a.slice(2).replace(/^=/, '')
274  }
275  return undefined
276}
277
278// Why a call would print Secret data, if it would. Best effort: the real
279// wall is a kube identity without `get secrets`.
280export function readsSecret(call: Call): string | undefined {
281  const p = positionals(call)
282  if (call.tool === 'helm') {
283    return p[0] === 'get' && ['all', 'manifest', 'values'].includes(p[1] ?? '') ? 'it would print rendered Secrets or chart values' : undefined
284  }
285  if (call.tool !== 'kubectl') return undefined
286  const namesSecret = (p[1] ?? '').split(',').some(r => /^secrets?(\/.*)?$/.test(r))
287  if (p[0] === 'get' && namesSecret && DATA_OUTPUT.test(outputFormat(call) ?? '')) return 'it would print Secret data'
288  if (call.args.includes('--raw') && call.args.some(a => a.includes('/secrets'))) return 'it would print Secret data through the raw API'
289  if (p[0] === 'edit' && namesSecret) return 'it would open Secret data in an editor'
290  if (p[0] === 'exec' && /(^|\s)(env|printenv)(\s|$)|\/environ\b/.test(call.text)) {
291    return "it would print a container's environment, which Secrets feed"
292  }
293  return undefined
294}
295
hooks/shell.ts 243 lines
1// Just enough shell reading to find which infra CLIs a command line runs and
2// whether each names its target. Not a shell: no expansion, no aliases.
3
4export type Tool = 'kubectl' | 'helm' | 'gcloud' | 'aws' | 'terraform' | 'gh'
5export type Step = { words: string[]; cwd: string }
6export type Call = { tool: Tool; args: string[]; env: string[]; cwd: string; text: string }
7
8const TOOLS = new Set<string>(['kubectl', 'helm', 'gcloud', 'aws', 'terraform', 'gh'])
9const WRAPPERS = new Set(['sudo', 'env', 'time', 'nice', 'nohup', 'exec', 'command', 'k3s'])
10
11// Words of each simple command, split on unquoted ; & | ( ) and newlines.
12export function simpleCommands(line: string): string[][] {
13  const out: string[][] = []
14  let words: string[] = []
15  let word = ''
16  let inWord = false
17  let quote: string | null = null
18  const endWord = () => {
19    if (inWord) words.push(word)
20    word = ''
21    inWord = false
22  }
23  const endCommand = () => {
24    endWord()
25    if (words.length > 0) out.push(words)
26    words = []
27  }
28  for (let i = 0; i < line.length; i++) {
29    const c = line[i]!
30    if (quote !== null) {
31      if (c === quote) quote = null
32      else if (c === '\\' && quote === '"' && i + 1 < line.length) word += line[++i]
33      else word += c
34      continue
35    }
36    if (c === "'" || c === '"') {
37      quote = c
38      inWord = true
39    } else if (c === '\\' && i + 1 < line.length) {
40      if (line[i + 1] === '\n') i++
41      else {
42        word += line[++i]
43        inWord = true
44      }
45    } else if (';&|()\n'.includes(c)) endCommand()
46    else if (c === ' ' || c === '\t') endWord()
47    else if (c === '#' && !inWord) {
48      while (i < line.length && line[i] !== '\n') i++
49      endCommand()
50    } else {
51      word += c
52      inWord = true
53    }
54  }
55  endCommand()
56  return out
57}
58
59export function join(base: string, path: string): string {
60  if (path.startsWith('/')) return normalize(path)
61  return normalize(`${base}/${path}`)
62}
63
64function normalize(path: string): string {
65  const parts: string[] = []
66  for (const p of path.split('/')) {
67    if (p === '' || p === '.') continue
68    if (p === '..') parts.pop()
69    else parts.push(p)
70  }
71  return `/${parts.join('/')}`
72}
73
74function isQuoted(line: string, index: number): boolean {
75  let quote: string | null = null
76  for (let i = 0; i < index; i++) {
77    const c = line[i]!
78    if (quote !== null) {
79      if (c === quote) quote = null
80      else if (c === '\\' && quote === '"') i++
81    } else if (c === "'" || c === '"') quote = c
82    else if (c === '\\') i++
83  }
84  return quote !== null
85}
86
87// Heredoc bodies are data, not commands (a script written with `cat <<EOF`
88// is not run): each body line becomes empty, so line numbers stay put.
89export function withoutHeredocs(text: string): string {
90  const out: string[] = []
91  const pending: { word: string; tabs: boolean }[] = []
92  for (const line of text.split('\n')) {
93    const body = pending[0]
94    if (body !== undefined) {
95      if ((body.tabs ? line.replace(/^\t+/, '') : line) === body.word) pending.shift()
96      out.push('')
97      continue
98    }
99    out.push(line)
100    for (const m of line.matchAll(/<<(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/g)) {
101      if (line[m.index - 1] === '<' || isQuoted(line, m.index)) continue
102      pending.push({ word: m[3]!, tabs: m[1] === '-' })
103    }
104  }
105  return out.join('\n')
106}
107
108// Simple commands with the directory each runs in, following `cd`.
109export function steps(line: string, cwd: string, home: string): Step[] {
110  let dir = cwd
111  return simpleCommands(withoutHeredocs(line)).map(words => {
112    if (words[0] === 'cd' && words[1] !== undefined) {
113      dir = words[1].startsWith('~/') ? join(home, words[1].slice(2)) : join(dir, words[1])
114    }
115    return { words, cwd: dir }
116  })
117}
118
119// The infra CLI calls in a line, also inside quoted words (`ssh host '...'`,
120// `bash -c '...'`), two levels deep.
121export function calls(line: string, cwd: string, home: string, depth = 0): Call[] {
122  const found: Call[] = []
123  for (const step of steps(line, cwd, home)) {
124    const { words } = step
125    const env: string[] = []
126    let i = 0
127    while (i < words.length) {
128      const w = words[i]!
129      if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) env.push(w)
130      else if (!WRAPPERS.has(w) && !(w.startsWith('-') && i > 0 && WRAPPERS.has(words[i - 1]!))) break
131      i++
132    }
133    const name = words[i]?.split('/').at(-1)
134    if (name !== undefined && TOOLS.has(name)) {
135      found.push({ tool: name as Tool, args: words.slice(i + 1), env, cwd: step.cwd, text: words.slice(i).join(' ') })
136    }
137    if (depth < 2) for (const w of words) if (/\s/.test(w)) found.push(...calls(w, step.cwd, home, depth + 1))
138  }
139  return found
140}
141
142const VALUE_FLAGS: Record<Tool, Set<string>> = {
143  kubectl: new Set(['-n', '--namespace', '--context', '--kubeconfig', '--cluster', '--user', '-s', '--server', '-l', '--selector', '-o', '--output', '-f', '--filename', '-c', '--container']),
144  helm: new Set(['-n', '--namespace', '--kube-context', '--kubeconfig', '-f', '--values', '--set', '--version', '-o', '--output']),
145  gcloud: new Set(['--project', '--account', '--configuration', '--format', '--region', '--zone']),
146  aws: new Set(['--profile', '--region', '--output', '--query']),
147  terraform: new Set(['-var', '-var-file', '-target', '-state', '-state-out', '-backup', '-lock-timeout', '-parallelism']),
148  gh: new Set(['-R', '--repo', '-b', '--body', '-t', '--title']),
149}
150
151// Positional words, flags and their values left out.
152export function positionals(call: Call): string[] {
153  const out: string[] = []
154  const takes = VALUE_FLAGS[call.tool]
155  for (let i = 0; i < call.args.length; i++) {
156    const a = call.args[i]!
157    if (a.startsWith('-')) {
158      if (!a.includes('=') && takes.has(a)) i++
159      continue
160    }
161    out.push(a)
162  }
163  return out
164}
165
166function hasFlag(call: Call, ...flags: string[]): boolean {
167  return call.args.some(a => flags.some(f => a === f || a.startsWith(`${f}=`)))
168}
169
170function hasEnv(call: Call, ...names: string[]): boolean {
171  return call.env.some(e => names.some(n => e.startsWith(`${n}=`)))
172}
173
174const KUBECTL_WRITES = new Set(['apply', 'create', 'delete', 'patch', 'replace', 'scale', 'set', 'label', 'annotate', 'edit', 'drain', 'cordon', 'uncordon', 'taint', 'expose', 'autoscale', 'run'])
175const HELM_WRITES = new Set(['install', 'upgrade', 'uninstall', 'rollback', 'delete'])
176const GCLOUD_WRITES = new Set(['create', 'delete', 'update', 'deploy', 'resize', 'patch', 'import', 'apply', 'set-iam-policy', 'add-iam-policy-binding', 'remove-iam-policy-binding'])
177const AWS_WRITE = /^(create|delete|put|update|modify|terminate|run|start|stop|reboot|attach|detach|associate|disassociate|register|deregister|remove|add|set|import|restore|invoke)-/
178const TERRAFORM_WRITES = new Set(['apply', 'destroy', 'import', 'taint', 'untaint', 'force-unlock'])
179
180// Whether the call changes something outside this machine.
181export function isWrite(call: Call): boolean {
182  const p = positionals(call)
183  switch (call.tool) {
184    case 'kubectl':
185      if (p[0] === 'rollout') return p[1] !== 'status' && p[1] !== 'history'
186      return KUBECTL_WRITES.has(p[0] ?? '')
187    case 'helm':
188      return HELM_WRITES.has(p[0] ?? '')
189    case 'gcloud':
190      return p[0] !== 'config' && p[0] !== 'auth' && p.some(w => GCLOUD_WRITES.has(w))
191    case 'aws':
192      return AWS_WRITE.test(p[1] ?? '') || (p[0] === 's3' && ['rm', 'cp', 'mv', 'sync', 'rb', 'mb'].includes(p[1] ?? ''))
193    case 'terraform':
194      return TERRAFORM_WRITES.has(p[0] ?? '') || (p[0] === 'state' && ['rm', 'mv', 'push'].includes(p[1] ?? ''))
195    case 'gh':
196      return (
197        (p[0] === 'workflow' && p[1] === 'run') ||
198        (p[0] === 'release' && ['create', 'delete'].includes(p[1] ?? '')) ||
199        (p[0] === 'pr' && p[1] === 'merge') ||
200        (p[0] === 'secret' && ['set', 'delete'].includes(p[1] ?? ''))
201      )
202  }
203}
204
205// Whether the call names where it acts, instead of taking the ambient one.
206// A kubeconfig file alone does not: its current-context is still ambient.
207export function namesTarget(call: Call): boolean {
208  switch (call.tool) {
209    case 'kubectl':
210      return hasFlag(call, '--context')
211    case 'helm':
212      return hasFlag(call, '--kube-context') || hasEnv(call, 'HELM_KUBECONTEXT')
213    case 'gcloud':
214      return hasFlag(call, '--project') || hasEnv(call, 'CLOUDSDK_CORE_PROJECT')
215    case 'aws':
216      return hasFlag(call, '--profile') || hasEnv(call, 'AWS_PROFILE')
217    case 'gh':
218      return hasFlag(call, '-R', '--repo') || hasEnv(call, 'GH_REPO')
219    case 'terraform':
220      return true
221  }
222}
223
224// Calls that change which target later commands take.
225export function switchesTarget(call: Call): boolean {
226  const p = positionals(call)
227  if (call.tool === 'kubectl') return p[0] === 'config' && (p[1] === 'use-context' || (p[1] === 'set-context' && hasFlag(call, '--current')))
228  if (call.tool === 'gcloud') return p[0] === 'config' && ((p[1] === 'set' && p[2] === 'project') || (p[1] === 'configurations' && p[2] === 'activate'))
229  if (call.tool === 'gh') return p[0] === 'auth' && p[1] === 'switch'
230  return false
231}
232
233// Scripts a line runs: `./x.sh`, `bash x.sh`, `source x.sh`, with their directory.
234export function scripts(line: string, cwd: string, home: string): string[] {
235  const found: string[] = []
236  for (const { words, cwd: dir } of steps(line, cwd, home)) {
237    const first = words[0] ?? ''
238    const path = /\.(sh|bash)$/.test(first) && first.includes('/') ? first : ['bash', 'sh', 'zsh', 'source', '.'].includes(first) && /\.(sh|bash)$/.test(words[1] ?? '') ? words[1]! : undefined
239    if (path !== undefined) found.push(path.startsWith('~/') ? join(home, path.slice(2)) : join(dir, path))
240  }
241  return found
242}
243
hooks/targets.ts 82 lines
1import type { EngineInterface } from 'claude-code'
2import type { Targets } from '../types'
3
4// The ambient targets a command takes when it names none. Read from the
5// tools' own config files where that is cheap; kubectl itself for kube, as
6// KUBECONFIG may merge several files.
7
8type Engine = EngineInterface
9
10async function readText($: Engine, path: string): Promise<string | undefined> {
11  try {
12    return await $.fs.read(path)
13  } catch {
14    return undefined
15  }
16}
17
18export function iniValue(text: string, section: string, key: string): string | undefined {
19  let current = ''
20  for (const raw of text.split('\n')) {
21    const line = raw.trim()
22    const head = /^\[(.+)\]$/.exec(line)
23    if (head) current = head[1]!
24    else if (current === section) {
25      const kv = /^([^=]+?)\s*=\s*(.*)$/.exec(line)
26      if (kv?.[1] === key) return kv[2]!.trim()
27    }
28  }
29  return undefined
30}
31
32// gh keeps the active account of each host as `user:` under the host.
33export function ghActiveUser(hostsYaml: string, host = 'github.com'): string | undefined {
34  const block = new RegExp(`^${host.replace(/\./g, '\\.')}:\\s*\\n((?:[ \\t].*\\n?)*)`, 'm').exec(hostsYaml)
35  return block ? /^ {4}user:\s*(\S+)/m.exec(block[1]!)?.[1] : undefined
36}
37
38async function kube($: Engine): Promise<string | undefined> {
39  try {
40    const r = await $.process.run(['kubectl', 'config', 'current-context'], { timeoutMs: 5000 })
41    return r.exitCode === 0 ? r.stdout.trim() || undefined : undefined
42  } catch {
43    return undefined
44  }
45}
46
47async function gcloud($: Engine, home: string): Promise<string | undefined> {
48  const dir = (await $.env.get('CLOUDSDK_CONFIG')) ?? `${home}/.config/gcloud`
49  const name = (await $.env.get('CLOUDSDK_ACTIVE_CONFIG_NAME')) ?? (await readText($, `${dir}/active_config`))?.trim()
50  const fromEnv = await $.env.get('CLOUDSDK_CORE_PROJECT')
51  const config = name ? await readText($, `${dir}/configurations/config_${name}`) : undefined
52  const project = fromEnv ?? (config ? iniValue(config, 'core', 'project') : undefined)
53  if (project === undefined) return undefined
54  return name ? `${project} (${name})` : project
55}
56
57async function gh($: Engine, home: string): Promise<string | undefined> {
58  if ((await $.env.get('GH_TOKEN')) ?? (await $.env.get('GITHUB_TOKEN'))) return 'token from env'
59  const dir = (await $.env.get('GH_CONFIG_DIR')) ?? `${home}/.config/gh`
60  const hosts = await readText($, `${dir}/hosts.yml`)
61  return hosts ? ghActiveUser(hosts) : undefined
62}
63
64async function aws($: Engine, home: string): Promise<string | undefined> {
65  const profile = await $.env.get('AWS_PROFILE')
66  if (profile) return profile
67  if (await $.env.get('AWS_ACCESS_KEY_ID')) return 'keys from env'
68  const config = (await readText($, `${home}/.aws/config`)) ?? (await readText($, `${home}/.aws/credentials`))
69  return config ? 'default' : undefined
70}
71
72export async function readTargets($: Engine): Promise<Targets> {
73  const home = (await $.env.get('HOME')) ?? ''
74  const [k, g, h, a] = await Promise.all([kube($), gcloud($, home), gh($, home), aws($, home)])
75  return { kube: k, gcloud: g, gh: h, aws: a }
76}
77
78export function prodTargets(t: Targets | null, pattern: RegExp): string[] {
79  if (t === null) return []
80  return Object.values(t).filter((v): v is string => typeof v === 'string' && pattern.test(v))
81}
82
hooks/terraform.ts 38 lines
1import { calls, join, positionals } from './shell'
2
3export type TerraformRun = { verb: 'apply' | 'destroy'; dir: string; plan?: string; isDestroy: boolean }
4export type PlanSummary = { create: string[]; update: string[]; replace: string[]; delete: string[] }
5
6// `terraform [-chdir=d] apply [plan]` and `terraform destroy`, with the folder
7// terraform runs in (the plan path is read from there, as terraform does).
8export function terraformRuns(line: string, cwd: string, home: string): TerraformRun[] {
9  return calls(line, cwd, home)
10    .filter(c => c.tool === 'terraform')
11    .flatMap((c): TerraformRun[] => {
12      const p = positionals(c)
13      const chdir = c.args.find(a => a.startsWith('-chdir='))?.slice('-chdir='.length)
14      const dir = chdir === undefined ? c.cwd : join(c.cwd, chdir)
15      if (p[0] === 'apply') return [{ verb: 'apply', dir, plan: p[1], isDestroy: c.args.includes('-destroy') }]
16      if (p[0] === 'destroy') return [{ verb: 'destroy', dir, isDestroy: true }]
17      return []
18    })
19}
20
21type PlanJson = { resource_changes?: { address: string; change: { actions: string[] } }[] }
22
23export function summarizePlan(plan: unknown): PlanSummary {
24  const s: PlanSummary = { create: [], update: [], replace: [], delete: [] }
25  for (const rc of (plan as PlanJson).resource_changes ?? []) {
26    const a = rc.change.actions.join(',')
27    if (a === 'create') s.create.push(rc.address)
28    else if (a === 'update') s.update.push(rc.address)
29    else if (a === 'delete') s.delete.push(rc.address)
30    else if (a === 'delete,create' || a === 'create,delete') s.replace.push(rc.address)
31  }
32  return s
33}
34
35export function describePlan(s: PlanSummary): string {
36  return `${s.create.length} to add, ${s.update.length} to change, ${s.replace.length} to replace, ${s.delete.length} to destroy`
37}
38
hooks/watch.ts 20 lines
1// Reading one poll of a watched operation. Each watch is a read-only command
2// the mod builds itself, so a watch cannot run anything the model chose.
3
4export type Progress = { state: 'running' | 'done' | 'failed'; status: string }
5type Ran = { exitCode: number; stdout: string; stderr: string }
6
7export function rolloutProgress(r: Ran): Progress {
8  const last = `${r.stdout}\n${r.stderr}`.trim().split('\n').at(-1) ?? ''
9  if (r.exitCode !== 0) return { state: 'failed', status: last || `exit code ${r.exitCode}` }
10  if (r.stdout.includes('successfully rolled out')) return { state: 'done', status: 'successfully rolled out' }
11  return { state: 'running', status: last.replace(/^Waiting for deployment "[^"]*" rollout to finish: /, '') || 'waiting' }
12}
13
14export function ghRunProgress(r: Ran): Progress {
15  if (r.exitCode !== 0) return { state: 'failed', status: r.stderr.trim().split('\n').at(-1) ?? `exit code ${r.exitCode}` }
16  const run = JSON.parse(r.stdout) as { status?: string; conclusion?: string }
17  if (run.status !== 'completed') return { state: 'running', status: run.status ?? 'unknown' }
18  return run.conclusion === 'success' ? { state: 'done', status: 'success' } : { state: 'failed', status: run.conclusion ?? 'unknown' }
19}
20
types/index.d.ts 35 lines
1export type Targets = { kube?: string; gcloud?: string; gh?: string; aws?: string }
2
3export type AuditEntry = {
4  at: string
5  session: string
6  cwd: string
7  command: string
8  targets: Targets | null
9  outcome: 'ok' | 'error' | 'refused' | 'confirmed'
10  note?: string
11  ms: number
12}
13
14export type Watch = {
15  id: string
16  kind: 'rollout' | 'gh-run'
17  label: string
18  argv: string[]
19  startedAt: number
20  deadline: number
21  status: string
22  state: 'running' | 'done' | 'failed' | 'timeout' | 'stopped'
23  endedAt?: number
24}
25
26// A Secret value waiting to be pasted: what the field says, never the value.
27// `inPane`: the pane holds the field; otherwise the band above the prompt does.
28export type PendingPaste = { label: string; inPane: boolean }
29
30declare module 'claude-code' {
31  interface PluginState {
32    'infra-guard': { targets: Targets | null; watches: Watch[]; pending: PendingPaste | null }
33  }
34}
35