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

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_rotateandsecret_set). The VS Code extension is untested.
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.
| Guard | Behavior |
|---|---|
| Ambient targets | A 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 targets | A 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 check | Before ./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 gate | terraform 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. |
| Approval | A 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. |
| Audit | Every 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. |
| Watches | watch_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 values | secret_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. |
| Tool | Does |
|---|---|
mcp__infra-guard__secret_rotate | Sets a key to a new random value (16–128 characters) after confirmation, then reads it back to verify. |
mcp__infra-guard__secret_set | Same, 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_keys | Lists keys with byte sizes and session-scoped fingerprints; warns when last-applied-configuration holds a plaintext copy. |
mcp__infra-guard__leak_check | Searches files (default ~/.claude) for every value the mod has seen this session and answers file names only. |
mcp__infra-guard__watch_start / watch_stop | Start or stop a rollout or GitHub Actions watch. |
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.
| Option | Default | Meaning | ||
|---|---|---|---|---|
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.
infra-guard catches mistakes. It does not stop someone, or a model, determined to get around it.
$(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.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.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.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.~/.claude/plugins/store/, last 1000 entries, shared by sessions on the machine, not atomic and not tamper-proof. It records only commands Claude ran.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.
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:
$.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 -).toolUseResult), out of reach of session.append. Output has to be withheld in tool.call, before it is stored.crypto.subtle.digest but no HMAC; fingerprints use a random per-load salt with SHA-256, so they compare values within one session only.hooks/register.tsx 593 lines1import { 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}
593hooks/approval.ts 14 lines1// 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}
14hooks/audit.ts 38 lines1import 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}
38hooks/names.ts 6 lines1// 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.-]+$/
6hooks/script.ts 43 lines1import { 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}
43hooks/secrets.ts 295 lines1import 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}
295hooks/shell.ts 243 lines1// 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}
243hooks/targets.ts 82 lines1import 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}
82hooks/terraform.ts 38 lines1import { 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}
38hooks/watch.ts 20 lines1// 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}
20types/index.d.ts 35 lines1export 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