Holds gcloud and gsutil commands that would create, change or delete cloud resources, shows the account, project, location and the current state of the…

Holds a gcloud or gsutil command that would create, change or delete cloud resources before Claude runs it, shows which account and project it would hit and what the targets look like right now, and opens a pane with Proceed and Cancel. Cancel refuses the command and tells Claude why, so it knows nothing ran. Read-only commands (describe, list, ls, get-iam-policy, …) and local ones (auth login, components update) pass straight through.
The hold-and-ask pattern follows Anthropic's blast-radius mod in this repo; the code is written fresh (MIT) and aimed at Google Cloud.
╭─ ⚠ gcloud-guard · destructive ──────────────────────────────────────────────╮
│ delete compute instances web-1, web-2 │
│ Command gcloud compute instances delete web-1 web-2 --zone us-central1-a │
│ Account dev@example.com │
│ Project demo-proj ← from gcloud config · Config default │
│ Location us-central1-a │
│ │
│ web-1 · RUNNING · us-central1-a · e2-small · created 2026-09-01 · 2 disks │
│ web-2 · TERMINATED · us-central1-a · e2-medium · created 2026-09-03 │
│ web-2: deletion protection is on; a delete fails unless it is turned off │
│ │
│ 1: Proceed 2: Cancel Claude is waiting on your answer │
╰─────────────────────────────────────────────────────────────────────────────╯
The command line is split on &&, ||, ;, | and newlines; VAR=value, sudo, env, nohup, time, nice and ( … ) wrappers are stripped; the first gcloud (or gsutil) segment that would change something decides. The first word after gcloud is the command group, the first table word after it is the verb.
| Severity | gcloud verbs | gsutil |
|---|---|---|
| destructive | delete, remove, destroy, purge, rm, rb, reset, abandon, cancel, revoke, wipe, detach, rollback, unregister, unbind, uninstall, terminate, and the prefixes remove-*, delete-*, detach-*, revoke-*, unregister-*, unbind-*, drop-*, purge-*, destroy-*; remove-iam-policy-binding | rm, rb; notification delete, … clear |
| mutating | update, patch, set, enable, disable, start, stop, suspend, resume, resize, move, rename, promote, failover, restart, reboot, attach, migrate, apply, replace, restore, import, export, upload, rotate, activate, deactivate, deploy, submit, execute, run, trigger, undelete, rsync, cp, mv, and the prefixes update-*, set-*, add-*, attach-*, start-*, stop-*, enable-*, disable-*, restore-*, resize-*, rotate-*, …; add-iam-policy-binding, set-iam-policy; config set / unset / configurations activate (see below) | cp, mv, rsync, setmeta, compose, rewrite; iam ch, acl set, defacl set, lifecycle set, versioning set, cors set, label set, retention set, … |
| create | create, add, insert, clone, snapshot, publish, register, reserve, provision, and create-* | mb; notification create |
Special cases:
gcloud projects delete, organizations delete, folders delete are destructive with a scope badge; the pane shows the project's state, when it was created and how many services are enabled, and reminds you of the 30-day recovery window.deploy (run deploy, app deploy, functions deploy): mutating, labelled as a deploy, since it creates or replaces a revision. builds submit: mutating, labelled as a build.set-iam-policy the number of bindings in the policy file is compared with the current policy.gcloud storage rm/mv/rsync, gsutil rm/rb/mv/rsync): the objects under each source URL are counted with a read-only ls -r (capped at 2000); rsync -d / --delete-unmatched-destination-objects gets a warning.gcloud config set (and unset, configurations activate): held as a local-config change, because switching the project or account silently changes what every later command hits. The pane shows the current value. Turn this off with hold_config_set.gcloud compute instances perform-rollout …): held as mutating with a note, to be safe. An unknown verb under an unknown group is not held.Never held: describe, list, get, get-iam-policy, get-value, get-credentials, ls, cat, stat, du, logs, read, tail, print-access-token, ssh, scp, wait, test-iam-permissions, the list-* / describe-* / get-* / print-* prefixes; the whole auth, components, help, info, version, emulators and init groups; config list / get-value; container clusters get-credentials (it only writes your kubeconfig); compute ssh / scp (a remote shell is not a resource change; see Limits).
--project or the gcloud config) and the active configuration; the location (--zone / --region / --location, or (default)); an alpha/beta badge; a yellow warning when --quiet is given, since gcloud then skips its own confirmation.describe --format=json: name, status, zone/region, machine type, database version and tier, size, node count, creation date, labels, attached disks and how many are auto-delete, IAM bindings. Deletion protection raises a note. A target that is not found raises a note too: the delete would fail, or hit something else than you think.--machine-type, --size, --tier, --num-nodes, --image, --memory, --cpu, …).gcloud is not on PATH or does not answer, the pane says so and the command is still held.At all times, not only while holding, a dim line above the prompt, in its own rounded frame, says which project the session is pointed at, so a gcloud … delete is never a surprise about where:
☁ gcloud · project side-project-staging · account dev@example.com · config default · GKE my-cluster (us-central1)
Where the values come from (files and environment variables only; no gcloud process is started for this line):
| Part | Source |
|---|---|
| config directory | $CLOUDSDK_CONFIG, else ~/.config/gcloud |
| configuration | $CLOUDSDK_ACTIVE_CONFIG_NAME, else the active_config file, else default |
| project, account, zone, region | $CLOUDSDK_CORE_PROJECT, $CLOUDSDK_CORE_ACCOUNT, $CLOUDSDK_COMPUTE_ZONE, $CLOUDSDK_COMPUTE_REGION, else configurations/config_<name> ([core] project = …, [compute] zone = …) |
| GKE cluster | the current-context: line of $KUBECONFIG (colon-separated; the first file with a current context wins), else ~/.kube/config; a gke_<project>_<location>_<cluster> context is shown as GKE <cluster> (<location>) |
A project that comes from CLOUDSDK_CORE_PROJECT is marked ← CLOUDSDK_CORE_PROJECT. Parts that are unknown are left out; when nothing is known (no gcloud configuration at all) the line is not drawn. A kubectl context that is not a GKE cluster (docker-desktop, an EKS ARN) is shown as k8s <name> only with show_other_contexts on.
The line is re-read at session start, every 5 seconds when one of those files' modification times changed (a cheap stat, no read otherwise), after a held gcloud / gsutil command proceeds, and after any Bash command that mentions gcloud config, gcloud auth, gcloud container clusters get-credentials, kubectl config use-context or kubectx. While a command is held, the hold report replaces the line; the hold pane also shows the GKE cluster, in yellow when the cluster's project differs from the project the command would hit.
/gcloud-guard prints the whole snapshot (config directory, configuration, project and its source, account, zone/region, kubeconfig path, current context, the parsed GKE fields) and the hold setting; /gcloud-guard off / on hide and show the line for the session; /gcloud-guard refresh re-reads the files now.
It works as soon as it is installed. The only command is /gcloud-guard (above).
1 for Proceed or 2 for Cancel. Prefer the digits.Backgrounding after the current tool finishes…. To use the arrows, click the pane first, or press ctrl+x then tab to hand it the keyboard.| Option | Default | Meaning |
|---|---|---|
language | auto | Language of the pane, the toast and the refusal: auto (from LC_ALL, then LC_MESSAGES, then LANG: zh* gives Traditional Chinese, ja* Japanese, anything else English), en, zh-TW or ja. |
hold | all | destructive holds delete-class commands only; mutating holds delete- and update-class; all also holds create-class. |
include_gsutil | true | Also watch gsutil. |
hold_config_set | true | Hold gcloud config set / unset / configurations activate. |
describe_timeout_seconds | 15 | How long each read-only lookup may take before it is reported as failed (3 to 60). |
show_context | true | Draw the context line above the prompt. |
show_other_contexts | false | Also show the current kubectl context when it is not a GKE cluster. |
band_style | box | How the context line is framed: box (its own rounded frame, dim normally and yellow when the GKE context belongs to a project other than gcloud's), rule (a thin line beneath it), plain (text only). The hold report always draws its own box. |
Set them with /plugin configure gcloud-guard@cockpit, or --config hold=destructive at install. Command words in the pane (delete, compute instances, …) are never translated. Every refusal ends with the same English line, (gcloud-guard: the user did not approve this command; do not retry unless asked.), so Claude reads a Japanese or Chinese refusal as a refusal, not as a transient error.
band_style switches to a rule or plain text). While it holds a command and the pane cannot be placed, the hold report takes over the band; the other frames return once you answer.gcloud compute instances delete x && rm -rf build is held by both, one after the other.$.process.run), never through a shell: a target name, a URL or a flag value is one argv element, so nothing in it runs as shell. The lookups are config get-value, config configurations list, describe, get-iam-policy, projects describe, services list, storage ls -r / gsutil ls -r.active_config, configurations/config_<name>) and the current-context: line of your kubeconfig, and stats them every 5 seconds. It reads the policy file named on set-iam-policy to count its bindings. It writes no files, sends nothing anywhere and calls no model.get / list permissions; where they fail the pane says so and the command is still held.claude plugin validate ./plugins/gcloud-guard
Result (v0.1.0):
hooks: session.start, command.run{command=gcloud-guard}, tool.call{tool=Bash},
ui.render{component=Pane, requestId=gcloud-guard}, ui.render{component=AbovePrompt}
calls: $.clock.every, $.clock.now, $.command.register, $.env.get, $.fs.read, $.fs.stat,
$.process.run, $.state.get, $.state.set, $.ui.close, $.ui.invalidate, $.ui.open,
$.ui.resolve, $.ui.toast
env reads: CLOUDSDK_ACTIVE_CONFIG_NAME, CLOUDSDK_COMPUTE_REGION, CLOUDSDK_COMPUTE_ZONE,
CLOUDSDK_CONFIG, CLOUDSDK_CORE_ACCOUNT, CLOUDSDK_CORE_PROJECT, HOME, KUBECONFIG,
LANG, LC_ALL, LC_MESSAGES · env writes: nothing
$.process.run runs the read-only lookups above and the 0.25-second sleep that paces the hold; $.fs.read and $.fs.stat read the gcloud and kube config files for the context line and a set-iam-policy file; $.clock.every is the 5-second file check; no network of its own, no model calls.
$(…), aliases, eval, bash -c "…", xargs gcloud, scripts and Makefiles that call gcloud, terraform, kubectl, bq, the Python client libraries and the Console are not covered.compute ssh is not held, although a command run on the VM can change anything there.--flag value is read as a value unless the flag is a known boolean (--quiet, --async, --force, --to-latest, --no-*, --enable-*, …). A boolean flag it does not know, followed by a resource name, swallows that name from the target list; the command is still held.perform-*, trigger-*, …) under a known group; anything else is let through. gcloud grows faster than this table.describe needs permissions and time: up to 5 targets, each with the configured timeout, plus the config lookups. On a slow network the pane can take a few seconds to appear; the command is held from the start regardless.2000+; gsutil ls -r on a huge bucket may hit the timeout, which is reported as a note.--project on a later command, a gcloud config set in another shell before the next 5-second check, or a kubeconfig merged from several files with no current-context: in the first one can make the line lag or differ. /gcloud-guard refresh re-reads at once.gcloud config set was held, the pane showed the account and project, and Proceed ran it.claude --plugin-dir ./plugins/gcloud-guard # load once
claude plugin validate ./plugins/gcloud-guard
claude plugin test ./plugins/gcloud-guard # classifier table, lookups, the hold with a fake gcloud
The classifier and the report text live in hooks/logic.ts (pure), the strings in hooks/i18n.ts, the hooks and the drawing in hooks/register.tsx. If blast-radius is installed while you work on this mod, write test files with the editor rather than a heredoc: a literal rm -rf in a Bash command line is held by it.
hooks/register.tsx 679 lines1// gcloud-guard: holds a gcloud / gsutil command that would create, change or delete
2// cloud resources, shows what it would touch, and waits for Proceed or Cancel.
3//
4// - tool.call (Bash): classify the command line; when it is held, look up the account,
5// project and the targets' current state with read-only gcloud calls, open a pane
6// (or draw in the band when the terminal is too narrow) and hold the call.
7// - Holding: a hook has 10 s of its own time, but time spent inside a `$` call is free,
8// so the hold loop waits on `$.process.run(["sleep", "0.25"])` until a button sets the
9// decision. One hold at a time; a second risky call waits for the first.
10// - Lookups pass every value as an argv element, never as shell source. No files are
11// written, nothing is sent anywhere, no model is called.
12// The hold-and-ask pattern follows Anthropic's blast-radius mod in this repo.
13
14import { atom, read, update } from 'claude-code'
15import type { Register } from 'claude-code'
16
17import type { GcloudContext, HeldView, Report, ReportContext, Risk } from '../types'
18import { DEFAULT_LANG, resolveLang, t } from './i18n'
19import type { Lang } from './i18n'
20import {
21 CONTEXT_TICK_MS,
22 HOLD_LIMIT_MS,
23 MAX_LINES,
24 MAX_TARGETS_DESCRIBED,
25 PANE,
26 POLL_SECONDS,
27 buildContext,
28 classify,
29 configGetArgv,
30 contextLine,
31 contextText,
32 countObjects,
33 currentContextOf,
34 denyText,
35 describeArgv,
36 emptyContext,
37 gcloudConfigDir,
38 getIamPolicyArgv,
39 headline,
40 isHeld,
41 isNotFound,
42 isRsyncDelete,
43 keyFlagsLine,
44 listObjectsArgv,
45 paneRows,
46 projectDescribeArgv,
47 readSettings,
48 servicesListArgv,
49 severityLabel,
50 splitKubeconfigList,
51 storageUrls,
52 kubeProjectMismatch,
53 summarizeDescribe,
54 touchesContext,
55 truncate,
56} from './logic'
57import type { BandStyle, ContextEnv, Settings } from './logic'
58
59const langState = atom({ plugin: 'gcloud-guard', key: 'lang' } as const, DEFAULT_LANG)
60const heldState = atom({ plugin: 'gcloud-guard', key: 'held' } as const, null)
61const contextState = atom({ plugin: 'gcloud-guard', key: 'context' } as const, null)
62const isBandHidden = atom({ plugin: 'gcloud-guard', key: 'isBandHidden' } as const, false)
63
64type Decision = 'proceed' | 'cancel' | 'timeout' | 'interrupted' | 'error'
65
66/** The hold in progress: the view the render hooks draw plus the decision the buttons set. */
67type Hold = { view: HeldView; decision: Decision | null }
68
69// Module state: a hot reload mid-hold loses it, and the old hook then refuses the command.
70let settings: Settings = readSettings(undefined)
71let lang: Lang = DEFAULT_LANG
72let held: Hold | null = null
73let holdSeq = 0
74/** The mtimes of the files the context line was last read from, keyed by path; the timer re-reads when one moved. */
75let watched: Record<string, number> = {}
76let refreshing = false
77
78type Run = { exitCode: number; stdout: string; stderr: string }
79
80function toast($: any, text: string): void {
81 try {
82 $.ui.toast(text)
83 } catch {}
84}
85
86async function resolveLanguage($: any): Promise<Lang> {
87 const env: { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string } = {}
88 try {
89 env.LC_ALL = await $.env.get('LC_ALL')
90 env.LC_MESSAGES = await $.env.get('LC_MESSAGES')
91 env.LANG = await $.env.get('LANG')
92 } catch {}
93 return resolveLang(settings.language, env)
94}
95
96// ── The context line: read from the config files, no process ───────────────
97
98async function contextEnv($: any): Promise<ContextEnv> {
99 const env: ContextEnv = {}
100 try {
101 env.CLOUDSDK_CONFIG = await $.env.get('CLOUDSDK_CONFIG')
102 env.HOME = await $.env.get('HOME')
103 env.CLOUDSDK_CORE_PROJECT = await $.env.get('CLOUDSDK_CORE_PROJECT')
104 env.CLOUDSDK_CORE_ACCOUNT = await $.env.get('CLOUDSDK_CORE_ACCOUNT')
105 env.CLOUDSDK_ACTIVE_CONFIG_NAME = await $.env.get('CLOUDSDK_ACTIVE_CONFIG_NAME')
106 env.CLOUDSDK_COMPUTE_ZONE = await $.env.get('CLOUDSDK_COMPUTE_ZONE')
107 env.CLOUDSDK_COMPUTE_REGION = await $.env.get('CLOUDSDK_COMPUTE_REGION')
108 } catch {}
109 return env
110}
111
112async function kubeconfigList($: any, home: string | null): Promise<string[]> {
113 let value: string | undefined
114 try {
115 value = await $.env.get('KUBECONFIG')
116 } catch {}
117 return splitKubeconfigList(value, home)
118}
119
120/** A file's text, or null when it is missing or unreadable. */
121async function readText($: any, path: string): Promise<string | null> {
122 try {
123 const text = await $.fs.read(path)
124 return typeof text === 'string' ? text : null
125 } catch {
126 return null
127 }
128}
129
130async function mtimeOf($: any, path: string): Promise<number> {
131 try {
132 const st = await $.fs.stat(path)
133 return Number(st?.mtimeMs ?? 0)
134 } catch {
135 return 0
136 }
137}
138
139/** Re-reads the gcloud and kube config files and writes the snapshot; remembers the files' mtimes for the timer. */
140async function refreshContext($: any): Promise<GcloudContext | null> {
141 if (refreshing) return null
142 refreshing = true
143 try {
144 const env = await contextEnv($)
145 const home = (env.HOME ?? '').trim() || null
146 const configDir = gcloudConfigDir(env)
147 const next: Record<string, number> = {}
148 let activeConfigText: string | null = null
149 let configText: string | null = null
150 if (configDir) {
151 const activePath = `${configDir}/active_config`
152 activeConfigText = await readText($, activePath)
153 next[activePath] = await mtimeOf($, activePath)
154 const name = (env.CLOUDSDK_ACTIVE_CONFIG_NAME ?? '').trim() || (activeConfigText ?? '').trim() || 'default'
155 const configPath = `${configDir}/configurations/config_${name}`
156 configText = await readText($, configPath)
157 next[configPath] = await mtimeOf($, configPath)
158 }
159 let kubeconfigPath: string | null = null
160 let kubeconfigText: string | null = null
161 for (const path of await kubeconfigList($, home)) {
162 const text = await readText($, path)
163 next[path] = await mtimeOf($, path)
164 if (text !== null && currentContextOf(text) !== null) {
165 kubeconfigPath = path
166 kubeconfigText = text
167 break
168 }
169 if (kubeconfigPath === null && text !== null) kubeconfigPath = path
170 }
171 watched = next
172 const snapshot = buildContext({ env, configDir, activeConfigText, configText, kubeconfigPath, kubeconfigText, now: await $.clock.now() })
173 const known = snapshot.project || snapshot.account || snapshot.kube
174 const value = known ? snapshot : null
175 await update($, contextState, () => value)
176 return value
177 } catch {
178 return null
179 } finally {
180 refreshing = false
181 }
182}
183
184/** The timer: re-read only when one of the watched files moved. */
185async function onContextTick($: any): Promise<void> {
186 if (refreshing) return
187 for (const [path, mtime] of Object.entries(watched)) {
188 if ((await mtimeOf($, path)) !== mtime) {
189 await refreshContext($)
190 return
191 }
192 }
193 // Nothing watched yet (no config dir found at start): look again for the files
194 if (!Object.keys(watched).length) await refreshContext($)
195}
196
197/** Runs a read-only lookup; never throws. `null` when the process could not start (gcloud missing). */
198async function lookup($: any, argv: string[]): Promise<Run | null> {
199 try {
200 const r = await $.process.run(argv, { timeoutMs: settings.describeTimeoutMs })
201 return { exitCode: Number(r.exitCode), stdout: String(r.stdout ?? ''), stderr: String(r.stderr ?? '') }
202 } catch {
203 return null
204 }
205}
206
207function lastLine(s: string): string {
208 return truncate(s.trim().split('\n').pop() ?? '', 80)
209}
210
211/** Account, project (and where it came from) and the active configuration. */
212async function readContext($: any, risk: Risk): Promise<{ context: ReportContext; gcloudMissing: boolean; failed: boolean }> {
213 const context = emptyContext(risk)
214 let gcloudMissing = false
215 let failed = false
216 if (risk.tool === 'gsutil' && risk.flags.project && risk.flags.account) return { context, gcloudMissing, failed }
217 if (!context.account) {
218 const r = await lookup($, configGetArgv('account', risk.flags))
219 if (r === null) gcloudMissing = true
220 else if (r.exitCode === 0 && r.stdout.trim()) context.account = r.stdout.trim()
221 else failed = true
222 }
223 if (!context.project && !gcloudMissing) {
224 const r = await lookup($, configGetArgv('project', risk.flags))
225 if (r === null) gcloudMissing = true
226 else if (r.exitCode === 0 && r.stdout.trim()) {
227 context.project = r.stdout.trim()
228 context.projectSource = 'config'
229 } else failed = true
230 }
231 if (!context.configuration && !gcloudMissing) {
232 const r = await lookup($, ['gcloud', 'config', 'configurations', 'list', '--filter=is_active=true', '--format=value(name)'])
233 if (r !== null && r.exitCode === 0 && r.stdout.trim()) context.configuration = r.stdout.trim().split('\n')[0] ?? null
234 }
235 return { context, gcloudMissing, failed }
236}
237
238function pushLine(report: Report, line: string): void {
239 if (report.lines.length < MAX_LINES) report.lines.push(truncate(line, 160))
240}
241
242/** Looks up what the command would touch. Never throws: a failed lookup becomes a note. */
243async function measure($: any, risk: Risk): Promise<Report> {
244 const { context, gcloudMissing, failed } = await readContext($, risk)
245 const report: Report = { severity: risk.severity, headline: headline(lang, risk), context, lines: [], notes: [] }
246 if (gcloudMissing) {
247 report.notes.push(t(lang, 'note.noGcloud'))
248 return report
249 }
250 if (failed) report.notes.push(t(lang, 'note.contextFailed'))
251 if (risk.unknownVerb) report.notes.push(t(lang, 'note.unknownVerb'))
252
253 // A whole project, organization or folder
254 if (risk.scope) {
255 const id = risk.targets[0]
256 if (id && risk.scope === 'project') {
257 const d = await lookup($, projectDescribeArgv(id, risk.flags))
258 if (d && d.exitCode === 0) {
259 try {
260 const o = JSON.parse(d.stdout) as Record<string, unknown>
261 pushLine(report, [String(o.name ?? id), String(o.lifecycleState ?? o.state ?? ''), o.createTime ? t(lang, 'line.created', { date: String(o.createTime).slice(0, 10) }) : ''].filter(Boolean).join(' · '))
262 } catch {}
263 } else if (d) report.notes.push(isNotFound(d.stderr) ? t(lang, 'note.notFound', { target: id }) : t(lang, 'note.describeFailed', { target: id, err: lastLine(d.stderr) }))
264 const s = await lookup($, servicesListArgv(id, risk.flags))
265 if (s && s.exitCode === 0) pushLine(report, t(lang, 'line.servicesEnabled', { n: s.stdout.split('\n').filter(l => l.trim()).length }))
266 report.notes.push(t(lang, 'note.projectRecovery'))
267 }
268 return report
269 }
270
271 // gcloud config set: the current value of the property
272 if (risk.kind === 'local-config') {
273 const property = risk.targets[0]
274 if (property && risk.verb !== 'activate') {
275 const cur = await lookup($, configGetArgv(property, risk.flags))
276 const current = cur && cur.exitCode === 0 && cur.stdout.trim() ? cur.stdout.trim() : t(lang, 'value.unknown')
277 report.headline = headline(lang, risk, current)
278 pushLine(report, t(lang, 'line.property', { property, current, value: risk.verb === 'unset' ? '(unset)' : (risk.targets[1] ?? '?') }))
279 }
280 return report
281 }
282
283 // Storage: count the objects under the sources
284 if (risk.kind === 'storage') {
285 if (isRsyncDelete(risk)) report.notes.push(t(lang, 'note.rsyncDelete'))
286 if (risk.verb === 'rm' || risk.verb === 'rb' || risk.verb === 'mv' || risk.verb === 'rsync') {
287 for (const url of storageUrls(risk).slice(0, MAX_TARGETS_DESCRIBED)) {
288 const r = await lookup($, listObjectsArgv(risk, url))
289 if (r && r.exitCode === 0) {
290 const { n, cut } = countObjects(r.stdout)
291 pushLine(report, cut ? t(lang, 'line.objectsMore', { url, n }) : t(lang, 'line.objects', { url, n }))
292 } else if (r) report.notes.push(isNotFound(r.stderr) ? t(lang, 'note.notFound', { target: url }) : t(lang, 'note.listFailed', { url, err: lastLine(r.stderr) }))
293 }
294 }
295 return report
296 }
297
298 // IAM: the member and role; for set-iam-policy the policy file against the current policy
299 if (risk.kind === 'iam' && risk.tool === 'gcloud') {
300 const member = risk.extra.member
301 const role = risk.extra.role
302 if (typeof member === 'string') pushLine(report, t(lang, 'line.member', { member }))
303 if (typeof role === 'string') pushLine(report, t(lang, 'line.role', { role }))
304 const target = risk.targets[0]
305 if (target && risk.verb === 'set-iam-policy') {
306 const file = risk.targets[1]
307 let inFile = '?'
308 if (file) {
309 try {
310 const text = await $.fs.read(file)
311 const o = JSON.parse(String(text)) as Record<string, unknown>
312 inFile = String(Array.isArray(o.bindings) ? (o.bindings as unknown[]).length : 0)
313 } catch {}
314 }
315 const cur = await lookup($, getIamPolicyArgv(risk, target))
316 let current = '?'
317 if (cur && cur.exitCode === 0) {
318 try {
319 const o = JSON.parse(cur.stdout) as Record<string, unknown>
320 current = String(Array.isArray(o.bindings) ? (o.bindings as unknown[]).length : 0)
321 } catch {}
322 }
323 pushLine(report, t(lang, 'line.policyFile', { file: file ?? '?', n: inFile, current }))
324 } else if (target) {
325 await describeTargets($, risk, report, [target])
326 }
327 return report
328 }
329
330 // Create-class: what is being asked for
331 if (risk.severity === 'create') {
332 const flags = keyFlagsLine(risk)
333 if (flags) pushLine(report, `${t(lang, 'label.flags')}: ${flags}`)
334 return report
335 }
336
337 // Delete- and update-class: the current state of each target
338 await describeTargets($, risk, report, risk.targets)
339 return report
340}
341
342async function describeTargets($: any, risk: Risk, report: Report, targets: string[]): Promise<void> {
343 if (!targets.length || !risk.path.length) return
344 for (const target of targets.slice(0, MAX_TARGETS_DESCRIBED)) {
345 const d = await lookup($, describeArgv(risk, target))
346 if (d === null) {
347 report.notes.push(t(lang, 'note.noGcloud'))
348 return
349 }
350 if (d.exitCode !== 0) {
351 report.notes.push(isNotFound(d.stderr) ? t(lang, 'note.notFound', { target }) : t(lang, 'note.describeFailed', { target, err: lastLine(d.stderr) }))
352 continue
353 }
354 try {
355 const { line, notes } = summarizeDescribe(lang, target, JSON.parse(d.stdout))
356 pushLine(report, line)
357 report.notes.push(...notes)
358 } catch {
359 pushLine(report, target)
360 }
361 }
362 if (targets.length > MAX_TARGETS_DESCRIBED) pushLine(report, t(lang, 'more', { n: targets.length - MAX_TARGETS_DESCRIBED }))
363}
364
365// ── Drawing ────────────────────────────────────────────────────────────────
366
367function severityColor(severity: Risk['severity']): string {
368 return severity === 'destructive' ? 'red' : severity === 'mutating' ? 'yellow' : 'green'
369}
370
371function draw($: any, e: any, view: HeldView, columns: number, gke: GcloudContext['kube'] | null) {
372 const { Box, Text, Button } = $.ui.resolve(e)
373 const L = lang
374 const risk = view.risk
375 const report = view.report
376 const color = severityColor(risk.severity)
377 const ctx = report?.context ?? emptyContext(risk)
378 const unknown = t(L, 'value.unknown')
379 const projectSource = ctx.projectSource === 'flag' ? t(L, 'source.flag') : ctx.projectSource === 'config' ? t(L, 'source.config') : ''
380 const location = ctx.location ?? t(L, 'value.default')
381 const decide = (choice: Decision) => () => {
382 if (held && held.view.id === view.id && held.decision === null) held.decision = choice
383 }
384 const rows: any[] = []
385 rows.push(
386 <Text key="title" bold color={color}>
387 {t(L, 'title', { severity: severityLabel(L, risk.severity) })}
388 </Text>,
389 )
390 rows.push(
391 <Text key="head" bold color={color} wrap="truncate-end">
392 {report?.headline ?? headline(L, risk)}
393 </Text>,
394 )
395 rows.push(
396 <Text key="cmd" wrap="truncate-end">
397 <Text dimColor>{`${t(L, 'label.command')} `}</Text>
398 <Text bold>{truncate(view.command, Math.max(20, columns - 12))}</Text>
399 </Text>,
400 )
401 rows.push(
402 <Text key="account" wrap="truncate-end">
403 <Text dimColor>{`${t(L, 'label.account')} `}</Text>
404 <Text>{ctx.account ?? unknown}</Text>
405 {ctx.impersonate ? <Text dimColor>{` (impersonating ${ctx.impersonate})`}</Text> : null}
406 </Text>,
407 )
408 rows.push(
409 <Text key="project" wrap="truncate-end">
410 <Text dimColor>{`${t(L, 'label.project')} `}</Text>
411 <Text bold>{ctx.project ?? unknown}</Text>
412 {projectSource ? <Text dimColor>{` ${projectSource}`}</Text> : null}
413 {ctx.configuration ? <Text dimColor>{` · ${t(L, 'label.configuration')} ${ctx.configuration}`}</Text> : null}
414 </Text>,
415 )
416 rows.push(
417 <Text key="location" wrap="truncate-end">
418 <Text dimColor>{`${t(L, 'label.location')} `}</Text>
419 <Text>{location}</Text>
420 {ctx.track !== 'ga' ? <Text color="magenta">{` · ${t(L, 'label.track')} ${ctx.track}`}</Text> : null}
421 </Text>,
422 )
423 if (gke && gke.kind === 'gke') {
424 rows.push(
425 <Text key="gke" wrap="truncate-end">
426 <Text dimColor>{`${t(L, 'label.gke')} `}</Text>
427 <Text>{`${gke.cluster} (${gke.location})`}</Text>
428 {gke.project && ctx.project && gke.project !== ctx.project ? <Text color="yellow">{` ≠ ${t(L, 'label.project').toLowerCase()} ${gke.project}`}</Text> : null}
429 </Text>,
430 )
431 }
432 if (ctx.quiet) {
433 rows.push(
434 <Text key="quiet" color="yellow">
435 {t(L, 'quiet.warn')}
436 </Text>,
437 )
438 }
439 if (report && report.lines.length) {
440 rows.push(
441 <Box key="lines" flexDirection="column" marginTop={1}>
442 {report.lines.map((line, i) => (
443 <Text key={`l${i}`} wrap="truncate-end">
444 {` ${line}`}
445 </Text>
446 ))}
447 </Box>,
448 )
449 }
450 if (report && report.notes.length) {
451 rows.push(
452 <Box key="notes" flexDirection="column">
453 {report.notes.map((note, i) => (
454 <Text key={`n${i}`} dimColor italic wrap="wrap">
455 {note}
456 </Text>
457 ))}
458 </Box>,
459 )
460 }
461 if (!report) {
462 rows.push(
463 <Text key="measuring" dimColor>
464 …
465 </Text>,
466 )
467 }
468 rows.push(
469 <Box key="buttons" marginTop={1} gap={2}>
470 <Button key="proceed" label={t(L, 'btn.proceed')} hotkey="1" plain onPress={decide('proceed')} />
471 <Button key="cancel" label={t(L, 'btn.cancel')} hotkey="2" plain autoFocus onPress={decide('cancel')} />
472 <Text key="hint" dimColor>
473 {t(L, 'waiting')}
474 </Text>
475 </Box>,
476 )
477 return (
478 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
479 {rows}
480 </Box>
481 )
482}
483
484// ── The hold ───────────────────────────────────────────────────────────────
485
486async function sleepTick($: any): Promise<void> {
487 await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
488}
489
490async function holdCommand($: any, e: any, next: any, risk: Risk): Promise<any> {
491 const command = String(e.command ?? '')
492 // One hold at a time: wait for the one in progress (a subagent's, say). `held` is claimed
493 // with no await between the check and the claim, so two waiting calls never both get through.
494 while (held !== null) {
495 if (next.signal.aborted) return { deny: denyText(lang, 'interrupted', headline(lang, risk), risk.flags.project ?? null) }
496 await sleepTick($)
497 }
498 holdSeq += 1
499 const view: HeldView = { id: holdSeq, command, risk, report: null, where: 'pane' }
500 const mine: Hold = { view, decision: null }
501 held = mine
502 let opened: { isPlaced?: boolean } = { isPlaced: false }
503 let report: Report | null = null
504 try {
505 report = await measure($, risk)
506 mine.view = { ...mine.view, report }
507 await update($, heldState, () => mine.view)
508 try {
509 const snap = (await read($, contextState)) as GcloudContext | null
510 opened = await $.ui.open({ id: PANE, title: `gcloud-guard · ${severityLabel(lang, risk.severity)}`, focus: true, rows: paneRows(report, snap?.kube?.kind === 'gke') })
511 } catch {
512 opened = { isPlaced: false }
513 }
514 if (!opened.isPlaced) {
515 mine.view = { ...mine.view, where: 'band' }
516 await update($, heldState, () => mine.view)
517 }
518 try {
519 $.ui.invalidate('ui.render')
520 } catch {}
521 const startedAt: number = await $.clock.now()
522 while (mine.decision === null) {
523 if (next.signal.aborted) {
524 mine.decision = 'interrupted'
525 break
526 }
527 if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
528 mine.decision = 'timeout'
529 break
530 }
531 await sleepTick($)
532 }
533 } catch {
534 mine.decision = 'error'
535 } finally {
536 // Close this hold's pane before releasing the hold, so the next hold's pane is never the one closed
537 try {
538 if (opened.isPlaced) await $.ui.close({ id: PANE })
539 } catch {}
540 if (held === mine) held = null
541 try {
542 await update($, heldState, () => null)
543 } catch {}
544 try {
545 $.ui.invalidate('ui.render')
546 } catch {}
547 }
548 const decision = mine.decision ?? 'error'
549 if (decision === 'proceed') {
550 toast($, t(lang, 'toast.proceed'))
551 const ran = await next(e)
552 // A proceeded gcloud / gsutil command may have changed the project, account or kube context
553 await refreshContext($)
554 return ran
555 }
556 const why = decision === 'cancel' ? 'cancel' : decision === 'timeout' ? 'timeout' : decision === 'interrupted' ? 'interrupted' : 'error'
557 return { deny: denyText(lang, why, report?.headline ?? headline(lang, risk), report?.context.project ?? risk.flags.project ?? null) }
558}
559
560// ── Hooks ──────────────────────────────────────────────────────────────────
561
562export const register: Register = (on, options) => {
563 settings = readSettings(options as Record<string, unknown> | undefined)
564
565 on('session.start', async ($, e, next) => {
566 const out = await next(e)
567 lang = await resolveLanguage($)
568 await update($, langState, () => lang)
569 // A hot reload mid-hold left a stale view behind: clear it
570 await update($, heldState, () => null)
571 await $.command.register({ name: 'gcloud-guard', description: t(lang, 'cmd.description'), argumentHint: '[off|on|refresh]' })
572 await refreshContext($)
573 // Nobody looks at the band in `claude -p`: no timer there
574 if (e.isInteractive) {
575 $.clock.every(CONTEXT_TICK_MS, () => {
576 void onContextTick($).catch(() => {})
577 })
578 }
579 return out
580 })
581
582 on('command.run', { command: 'gcloud-guard' }, async ($, e) => {
583 const arg = String(e.args ?? '').trim()
584 if (arg === 'off') {
585 await update($, isBandHidden, () => true)
586 return { text: t(lang, 'cmd.off') }
587 }
588 if (arg === 'on') {
589 await update($, isBandHidden, () => false)
590 return { text: t(lang, 'cmd.on') }
591 }
592 if (arg === 'refresh') {
593 await refreshContext($)
594 return { text: `${t(lang, 'cmd.refreshed')}\n${contextText(lang, (await read($, contextState)) as GcloudContext | null, settings)}` }
595 }
596 if (arg !== '') return { text: t(lang, 'cmd.usage') }
597 return { text: contextText(lang, (await read($, contextState)) as GcloudContext | null, settings) }
598 })
599
600 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
601 const command = String(e.command ?? '')
602 const risk = classify(command, settings)
603 if (risk !== null && isHeld(risk, settings)) return holdCommand($, e, next, risk)
604 if (!touchesContext(command)) return next(e)
605 // Not held, but it may change the project, account or kube context: re-read afterwards
606 const ran = await next(e)
607 await refreshContext($)
608 return ran
609 })
610
611 on('ui.render', { component: 'Pane', requestId: 'gcloud-guard' }, async ($, e, next) => {
612 const view = (await read($, heldState)) as HeldView | null
613 if (view === null) return next(e)
614 await read($, langState)
615 const snap = (await read($, contextState)) as GcloudContext | null
616 return draw($, e, view, e.props.bodyColumns ?? 80, snap?.kube ?? null)
617 })
618
619 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
620 const view = (await read($, heldState)) as HeldView | null
621 await read($, langState)
622 if (view !== null) {
623 if (view.where !== 'band') return next(e)
624 // The report takes the whole band while the command is held: the buttons must be on top
625 const snap = (await read($, contextState)) as GcloudContext | null
626 return draw($, e, view, e.props.bodyColumns ?? 80, snap?.kube ?? null)
627 }
628 if (e.props.hasSurvey || !settings.showContext || (await read($, isBandHidden))) return next(e)
629 const ctx = (await read($, contextState)) as GcloudContext | null
630 const text = contextLine(lang, ctx, settings)
631 if (text === null) return next(e)
632 const ui = $.ui.resolve(e)
633 const { Text } = ui
634 // AbovePrompt is a chain: draw our line in its frame, then whatever the plugins beneath drew
635 const below = await next(e)
636 return frameBand(
637 ui,
638 settings.bandStyle,
639 kubeProjectMismatch(ctx),
640 e.props.bodyColumns,
641 <Text wrap="truncate-end" dimColor>
642 {text}
643 </Text>,
644 below,
645 )
646 })
647}
648
649/**
650 * Frames the context line per `band_style` and stacks the plugins beneath under it.
651 * `box`: a rounded frame (yellow when `isWarning`, here when the GKE context's project differs from
652 * gcloud's); `rule`: a dim line beneath, only when another plugin drew something below; `plain`: bare text.
653 */
654function frameBand(ui: { Box: any; Text: any }, style: BandStyle, isWarning: boolean, bodyColumns: number | undefined, content: any, below: any) {
655 const { Box, Text } = ui
656 const hasBelow = below !== null && below !== undefined && (below as { type?: string }).type !== 'engine'
657 const own =
658 style === 'box' ? (
659 <Box key="frame" flexDirection="column" borderStyle="round" borderDimColor={isWarning ? undefined : true} borderColor={isWarning ? 'yellow' : undefined} paddingX={1}>
660 {content}
661 </Box>
662 ) : style === 'rule' ? (
663 <Box key="frame" flexDirection="column">
664 {content}
665 {hasBelow ? <Text key="rule" dimColor>{'─'.repeat(Math.max(8, Math.min(bodyColumns ?? 60, 200)))}</Text> : null}
666 </Box>
667 ) : (
668 <Box key="frame" flexDirection="column">
669 {content}
670 </Box>
671 )
672 return (
673 <Box flexDirection="column">
674 {own}
675 {below}
676 </Box>
677 )
678}
679hooks/i18n.ts 316 lines1// gcloud-guard i18n: the UI language, how it is resolved, and every string a person
2// or the model reads, in English, Traditional Chinese and Japanese.
3// Pure: no `$`. Shared with logic.ts, register.tsx and the tests.
4
5export type Lang = 'en' | 'zh-TW' | 'ja'
6export const LANGS: readonly Lang[] = ['en', 'zh-TW', 'ja']
7export const DEFAULT_LANG: Lang = 'en'
8
9export type LangEnv = { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string }
10
11/**
12 * Picks the language: an explicit option (`en`, `zh-TW`, `ja`) wins; `auto`,
13 * undefined or anything else reads LC_ALL, then LC_MESSAGES, then LANG.
14 * Any `zh*` locale maps to zh-TW (only Traditional is shipped), `ja*` to ja,
15 * everything else (including C, POSIX and empty) to en.
16 */
17export function resolveLang(option: unknown, env: LangEnv): Lang {
18 if (option === 'en' || option === 'zh-TW' || option === 'ja') return option
19 for (const raw of [env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
20 const v = (raw ?? '').trim()
21 if (!v) continue
22 const low = v.toLowerCase()
23 if (low === 'c' || low === 'posix') return 'en'
24 if (low.startsWith('zh')) return 'zh-TW'
25 if (low.startsWith('ja')) return 'ja'
26 return 'en'
27 }
28 return DEFAULT_LANG
29}
30
31export type Params = Record<string, string | number>
32type Message = string | ((p: Params) => string)
33
34/** Every refusal ends with this English line, so a model never reads a translated refusal as a transient error. */
35export const DENY_TAIL = '(gcloud-guard: the user did not approve this command; do not retry unless asked.)'
36
37const en = {
38 // severities
39 'severity.destructive': 'destructive',
40 'severity.mutating': 'mutating',
41 'severity.create': 'create',
42 // pane
43 'title': (p: Params) => `⚠ gcloud-guard · ${p.severity}`,
44 'label.command': 'Command',
45 'label.account': 'Account',
46 'label.project': 'Project',
47 'label.configuration': 'Config',
48 'label.location': 'Location',
49 'label.track': 'Track',
50 'label.flags': 'Flags',
51 'source.flag': '← from --project',
52 'source.config': '← from gcloud config',
53 'value.unknown': 'unknown',
54 'value.default': '(default)',
55 'quiet.warn': '--quiet: gcloud will not ask for its own confirmation',
56 'more': (p: Params) => `+ ${p.n} more`,
57 'btn.proceed': 'Proceed',
58 'btn.cancel': 'Cancel',
59 'waiting': 'Claude is waiting on your answer',
60 'toast.proceed': 'gcloud-guard: running it',
61 // headlines
62 'headline.generic': (p: Params) => `${p.verb} ${p.type}${p.targets ? ` ${p.targets}` : ''}`,
63 'headline.project': (p: Params) => `shut down project ${p.id} (30-day recovery window)`,
64 'headline.org': (p: Params) => `delete ${p.kind} ${p.id}`,
65 'headline.config': (p: Params) => `switch gcloud config ${p.property} → ${p.value} (now: ${p.current})`,
66 'headline.deploy': (p: Params) => `deploy ${p.type}${p.targets ? ` ${p.targets}` : ''} (creates or replaces the revision)`,
67 'headline.build': (p: Params) => `submit a build${p.targets ? ` from ${p.targets}` : ''}`,
68 'headline.iamAdd': (p: Params) => `grant ${p.role} to ${p.member} on ${p.type} ${p.targets}`,
69 'headline.iamRemove': (p: Params) => `revoke ${p.role} from ${p.member} on ${p.type} ${p.targets}`,
70 'headline.iamSet': (p: Params) => `replace the IAM policy of ${p.type} ${p.targets}`,
71 'headline.storageRm': (p: Params) => `delete ${p.targets}`,
72 'headline.storageCopy': (p: Params) => `${p.verb} ${p.targets}`,
73 // describe lines
74 'line.created': (p: Params) => `created ${p.date}`,
75 'line.labels': (p: Params) => `${p.n} labels`,
76 'line.nodes': (p: Params) => `${p.n} nodes`,
77 'line.disks': (p: Params) => `${p.n} disks (${p.autoDelete} auto-delete)`,
78 'line.bindings': (p: Params) => `${p.n} IAM bindings`,
79 'line.objects': (p: Params) => `${p.url}: ${p.n} objects`,
80 'line.objectsMore': (p: Params) => `${p.url}: ${p.n}+ objects (listing cut)`,
81 'line.servicesEnabled': (p: Params) => `${p.n} services enabled`,
82 'line.member': (p: Params) => `member ${p.member}`,
83 'line.role': (p: Params) => `role ${p.role}`,
84 'line.policyFile': (p: Params) => `policy file ${p.file}: ${p.n} bindings (current policy: ${p.current})`,
85 'line.property': (p: Params) => `${p.property}: ${p.current} → ${p.value}`,
86 // notes
87 'note.noGcloud': 'gcloud is not on PATH (or did not answer): nothing could be looked up; holding anyway',
88 'note.notFound': (p: Params) => `${p.target}: not found, so this would fail (or hit something else than you think)`,
89 'note.describeFailed': (p: Params) => `${p.target}: could not describe it (${p.err})`,
90 'note.deletionProtection': (p: Params) => `${p.target}: deletion protection is on; a delete fails unless it is turned off first`,
91 'note.unknownVerb': 'this verb is not in the table; held to be safe',
92 'note.rsyncDelete': 'rsync with delete: objects in the destination that are not in the source are deleted',
93 'note.listFailed': (p: Params) => `${p.url}: could not list it (${p.err})`,
94 'note.projectRecovery': 'a deleted project can be restored within 30 days; its resources stop at once',
95 'note.contextFailed': 'could not read the gcloud config (account / project unknown)',
96 // context line and command
97 'ctx.project': (p: Params) => `project ${p.project}`,
98 'ctx.fromEnv': '← CLOUDSDK_CORE_PROJECT',
99 'ctx.account': (p: Params) => `account ${p.account}`,
100 'ctx.config': (p: Params) => `config ${p.name}`,
101 'ctx.gke': (p: Params) => `GKE ${p.cluster} (${p.location})`,
102 'ctx.k8s': (p: Params) => `k8s ${p.name}`,
103 'label.gke': 'GKE',
104 'cmd.description': 'Show which gcloud project, account and GKE cluster the session is pointed at; off / on hides or shows the line, refresh re-reads the config files',
105 'cmd.usage': 'Usage: /gcloud-guard (context), /gcloud-guard off | on (hide / show the line), /gcloud-guard refresh (re-read the config files)',
106 'cmd.off': 'gcloud-guard: context line hidden for this session. /gcloud-guard on shows it again.',
107 'cmd.on': 'gcloud-guard: context line shown.',
108 'cmd.refreshed': 'gcloud-guard: config files re-read.',
109 'cmd.none': 'gcloud-guard: no gcloud configuration found (no config directory, or no active configuration).',
110 'cmd.configDir': (p: Params) => `config dir ${p.dir}`,
111 'cmd.configuration': (p: Params) => `configuration ${p.name}`,
112 'cmd.project': (p: Params) => `project ${p.project} (${p.source})`,
113 'cmd.account': (p: Params) => `account ${p.account}`,
114 'cmd.zone': (p: Params) => `zone / region ${p.zone} / ${p.region}`,
115 'cmd.kubeconfig': (p: Params) => `kubeconfig ${p.path}`,
116 'cmd.kubeContext': (p: Params) => `kube context ${p.name}`,
117 'cmd.gke': (p: Params) => `GKE project ${p.project} · location ${p.location} · cluster ${p.cluster}`,
118 'cmd.hold': (p: Params) => `hold setting ${p.hold}${p.gsutil ? ' + gsutil' : ''}${p.configSet ? ' + config set' : ''}`,
119 'cmd.source.env': 'from the CLOUDSDK_CORE_PROJECT env var',
120 'cmd.source.file': 'from the configuration file',
121 'value.none': 'none',
122 // deny
123 'deny': (p: Params) => `gcloud-guard held this command and did not run it: ${p.why}. It would have: ${p.headline} in project ${p.project}. ${DENY_TAIL}`,
124 'why.cancel': 'the user pressed Cancel',
125 'why.timeout': 'no answer within 10 minutes',
126 'why.interrupted': 'the turn was interrupted',
127 'why.error': 'gcloud-guard hit an error while holding it',
128 'why.none': 'no answer was recorded',
129} as const
130
131export type MessageKey = keyof typeof en
132
133export type Messages = Record<MessageKey, Message>
134
135const zhTW: Messages = {
136 'severity.destructive': '破壞性',
137 'severity.mutating': '修改',
138 'severity.create': '建立',
139 'title': p => `⚠ gcloud-guard · ${p.severity}`,
140 'label.command': '指令',
141 'label.account': '帳號',
142 'label.project': '專案',
143 'label.configuration': '設定檔',
144 'label.location': '位置',
145 'label.track': '版本',
146 'label.flags': '參數',
147 'source.flag': '← 來自 --project',
148 'source.config': '← 來自 gcloud config',
149 'value.unknown': '不明',
150 'value.default': '(預設)',
151 'quiet.warn': '--quiet:gcloud 不會再自己確認一次',
152 'more': p => `還有 ${p.n} 個`,
153 'btn.proceed': '執行',
154 'btn.cancel': '取消',
155 'waiting': 'Claude 在等你的回答',
156 'toast.proceed': 'gcloud-guard:執行中',
157 'headline.generic': p => `${p.verb} ${p.type}${p.targets ? ` ${p.targets}` : ''}`,
158 'headline.project': p => `關閉專案 ${p.id}(30 天內可復原)`,
159 'headline.org': p => `刪除 ${p.kind} ${p.id}`,
160 'headline.config': p => `切換 gcloud 設定 ${p.property} → ${p.value}(目前:${p.current})`,
161 'headline.deploy': p => `部署 ${p.type}${p.targets ? ` ${p.targets}` : ''}(會建立或取代 revision)`,
162 'headline.build': p => `送出一次 build${p.targets ? `(來源 ${p.targets})` : ''}`,
163 'headline.iamAdd': p => `把 ${p.role} 授予 ${p.member}(${p.type} ${p.targets})`,
164 'headline.iamRemove': p => `收回 ${p.member} 的 ${p.role}(${p.type} ${p.targets})`,
165 'headline.iamSet': p => `整個取代 ${p.type} ${p.targets} 的 IAM policy`,
166 'headline.storageRm': p => `刪除 ${p.targets}`,
167 'headline.storageCopy': p => `${p.verb} ${p.targets}`,
168 'line.created': p => `建立於 ${p.date}`,
169 'line.labels': p => `${p.n} 個 label`,
170 'line.nodes': p => `${p.n} 個節點`,
171 'line.disks': p => `${p.n} 個磁碟(${p.autoDelete} 個會一起刪)`,
172 'line.bindings': p => `${p.n} 條 IAM binding`,
173 'line.objects': p => `${p.url}:${p.n} 個物件`,
174 'line.objectsMore': p => `${p.url}:超過 ${p.n} 個物件(清單截斷)`,
175 'line.servicesEnabled': p => `已啟用 ${p.n} 個服務`,
176 'line.member': p => `成員 ${p.member}`,
177 'line.role': p => `角色 ${p.role}`,
178 'line.policyFile': p => `policy 檔 ${p.file}:${p.n} 條 binding(目前 policy:${p.current})`,
179 'line.property': p => `${p.property}:${p.current} → ${p.value}`,
180 'note.noGcloud': '找不到 gcloud(或它沒有回應),查不到任何資訊;仍然先攔住',
181 'note.notFound': p => `${p.target}:找不到,這個指令會失敗(或打到你以為以外的東西)`,
182 'note.describeFailed': p => `${p.target}:無法 describe(${p.err})`,
183 'note.deletionProtection': p => `${p.target}:已開啟刪除保護,不先關掉的話刪除會失敗`,
184 'note.unknownVerb': '這個動詞不在表裡,保險起見先攔住',
185 'note.rsyncDelete': 'rsync 帶刪除:目的端有、來源端沒有的物件會被刪掉',
186 'note.listFailed': p => `${p.url}:無法列出(${p.err})`,
187 'note.projectRecovery': '刪除的專案 30 天內可復原,但裡面的資源會立刻停止',
188 'note.contextFailed': '讀不到 gcloud 設定(帳號/專案不明)',
189 'ctx.project': p => `專案 ${p.project}`,
190 'ctx.fromEnv': '← CLOUDSDK_CORE_PROJECT',
191 'ctx.account': p => `帳號 ${p.account}`,
192 'ctx.config': p => `設定檔 ${p.name}`,
193 'ctx.gke': p => `GKE ${p.cluster}(${p.location})`,
194 'ctx.k8s': p => `k8s ${p.name}`,
195 'label.gke': 'GKE',
196 'cmd.description': '顯示這個 session 指向哪個 gcloud 專案、帳號與 GKE 叢集;off / on 隱藏或顯示那一行,refresh 重新讀設定檔',
197 'cmd.usage': '用法:/gcloud-guard(目前的 context)、/gcloud-guard off | on(隱藏/顯示那一行)、/gcloud-guard refresh(重新讀設定檔)',
198 'cmd.off': 'gcloud-guard:本 session 已隱藏 context 行。/gcloud-guard on 重新顯示。',
199 'cmd.on': 'gcloud-guard:已顯示 context 行。',
200 'cmd.refreshed': 'gcloud-guard:已重新讀取設定檔。',
201 'cmd.none': 'gcloud-guard:找不到 gcloud 設定(沒有設定目錄,或沒有使用中的 configuration)。',
202 'cmd.configDir': p => `設定目錄 ${p.dir}`,
203 'cmd.configuration': p => `configuration ${p.name}`,
204 'cmd.project': p => `專案 ${p.project}(${p.source})`,
205 'cmd.account': p => `帳號 ${p.account}`,
206 'cmd.zone': p => `zone / region ${p.zone} / ${p.region}`,
207 'cmd.kubeconfig': p => `kubeconfig ${p.path}`,
208 'cmd.kubeContext': p => `kube context ${p.name}`,
209 'cmd.gke': p => `GKE 專案 ${p.project} · 位置 ${p.location} · 叢集 ${p.cluster}`,
210 'cmd.hold': p => `攔截設定 ${p.hold}${p.gsutil ? ' + gsutil' : ''}${p.configSet ? ' + config set' : ''}`,
211 'cmd.source.env': '來自環境變數 CLOUDSDK_CORE_PROJECT',
212 'cmd.source.file': '來自 configuration 檔',
213 'value.none': '無',
214 'deny': p => `gcloud-guard 攔住了這個指令,沒有執行:${p.why}。它原本會:${p.headline},專案 ${p.project}。${DENY_TAIL}`,
215 'why.cancel': '使用者按了取消',
216 'why.timeout': '10 分鐘內沒有回答',
217 'why.interrupted': '這一輪被中斷',
218 'why.error': 'gcloud-guard 在攔住期間發生錯誤',
219 'why.none': '沒有記錄到回答',
220}
221
222const ja: Messages = {
223 'severity.destructive': '破壊的',
224 'severity.mutating': '変更',
225 'severity.create': '作成',
226 'title': p => `⚠ gcloud-guard · ${p.severity}`,
227 'label.command': 'コマンド',
228 'label.account': 'アカウント',
229 'label.project': 'プロジェクト',
230 'label.configuration': '構成',
231 'label.location': 'ロケーション',
232 'label.track': 'トラック',
233 'label.flags': 'フラグ',
234 'source.flag': '← --project から',
235 'source.config': '← gcloud config から',
236 'value.unknown': '不明',
237 'value.default': '(デフォルト)',
238 'quiet.warn': '--quiet:gcloud 自身の確認は出ません',
239 'more': p => `他 ${p.n} 件`,
240 'btn.proceed': '実行',
241 'btn.cancel': 'キャンセル',
242 'waiting': 'Claude はあなたの回答を待っています',
243 'toast.proceed': 'gcloud-guard:実行します',
244 'headline.generic': p => `${p.verb} ${p.type}${p.targets ? ` ${p.targets}` : ''}`,
245 'headline.project': p => `プロジェクト ${p.id} をシャットダウン(30日以内なら復元可)`,
246 'headline.org': p => `${p.kind} ${p.id} を削除`,
247 'headline.config': p => `gcloud 設定 ${p.property} を ${p.value} に切り替え(現在:${p.current})`,
248 'headline.deploy': p => `${p.type}${p.targets ? ` ${p.targets}` : ''} をデプロイ(リビジョンを作成または置換)`,
249 'headline.build': p => `ビルドを送信${p.targets ? `(ソース ${p.targets})` : ''}`,
250 'headline.iamAdd': p => `${p.member} に ${p.role} を付与(${p.type} ${p.targets})`,
251 'headline.iamRemove': p => `${p.member} から ${p.role} を剥奪(${p.type} ${p.targets})`,
252 'headline.iamSet': p => `${p.type} ${p.targets} の IAM ポリシーを丸ごと置換`,
253 'headline.storageRm': p => `${p.targets} を削除`,
254 'headline.storageCopy': p => `${p.verb} ${p.targets}`,
255 'line.created': p => `作成 ${p.date}`,
256 'line.labels': p => `ラベル ${p.n} 件`,
257 'line.nodes': p => `ノード ${p.n} 台`,
258 'line.disks': p => `ディスク ${p.n} 台(${p.autoDelete} 台は同時削除)`,
259 'line.bindings': p => `IAM バインディング ${p.n} 件`,
260 'line.objects': p => `${p.url}:オブジェクト ${p.n} 件`,
261 'line.objectsMore': p => `${p.url}:オブジェクト ${p.n} 件以上(一覧を打ち切り)`,
262 'line.servicesEnabled': p => `有効なサービス ${p.n} 件`,
263 'line.member': p => `メンバー ${p.member}`,
264 'line.role': p => `ロール ${p.role}`,
265 'line.policyFile': p => `ポリシーファイル ${p.file}:バインディング ${p.n} 件(現在のポリシー:${p.current})`,
266 'line.property': p => `${p.property}:${p.current} → ${p.value}`,
267 'note.noGcloud': 'gcloud が PATH にない(または応答なし)ため何も調べられませんでした。保留は続けます',
268 'note.notFound': p => `${p.target}:見つかりません。このコマンドは失敗します(または別の対象に当たります)`,
269 'note.describeFailed': p => `${p.target}:describe できませんでした(${p.err})`,
270 'note.deletionProtection': p => `${p.target}:削除保護が有効です。先に解除しないと削除は失敗します`,
271 'note.unknownVerb': 'この動詞は表にありません。念のため保留します',
272 'note.rsyncDelete': '削除付き rsync:ソースにない宛先オブジェクトは削除されます',
273 'note.listFailed': p => `${p.url}:一覧できませんでした(${p.err})`,
274 'note.projectRecovery': '削除したプロジェクトは30日以内に復元できますが、リソースは直ちに停止します',
275 'note.contextFailed': 'gcloud の設定を読めませんでした(アカウント/プロジェクト不明)',
276 'ctx.project': p => `プロジェクト ${p.project}`,
277 'ctx.fromEnv': '← CLOUDSDK_CORE_PROJECT',
278 'ctx.account': p => `アカウント ${p.account}`,
279 'ctx.config': p => `構成 ${p.name}`,
280 'ctx.gke': p => `GKE ${p.cluster}(${p.location})`,
281 'ctx.k8s': p => `k8s ${p.name}`,
282 'label.gke': 'GKE',
283 'cmd.description': 'このセッションが向いている gcloud プロジェクト・アカウント・GKE クラスタを表示;off / on で行を隠す/表示、refresh で設定ファイルを読み直す',
284 'cmd.usage': '使い方:/gcloud-guard(現在のコンテキスト)、/gcloud-guard off | on(行を隠す/表示)、/gcloud-guard refresh(設定ファイルを読み直す)',
285 'cmd.off': 'gcloud-guard:このセッションではコンテキスト行を隠しました。/gcloud-guard on で再表示。',
286 'cmd.on': 'gcloud-guard:コンテキスト行を表示しました。',
287 'cmd.refreshed': 'gcloud-guard:設定ファイルを読み直しました。',
288 'cmd.none': 'gcloud-guard:gcloud の設定が見つかりません(設定ディレクトリがない、または有効な構成がない)。',
289 'cmd.configDir': p => `設定ディレクトリ ${p.dir}`,
290 'cmd.configuration': p => `構成 ${p.name}`,
291 'cmd.project': p => `プロジェクト ${p.project}(${p.source})`,
292 'cmd.account': p => `アカウント ${p.account}`,
293 'cmd.zone': p => `zone / region ${p.zone} / ${p.region}`,
294 'cmd.kubeconfig': p => `kubeconfig ${p.path}`,
295 'cmd.kubeContext': p => `kube context ${p.name}`,
296 'cmd.gke': p => `GKE プロジェクト ${p.project} · ロケーション ${p.location} · クラスタ ${p.cluster}`,
297 'cmd.hold': p => `保留の設定 ${p.hold}${p.gsutil ? ' + gsutil' : ''}${p.configSet ? ' + config set' : ''}`,
298 'cmd.source.env': '環境変数 CLOUDSDK_CORE_PROJECT から',
299 'cmd.source.file': '構成ファイルから',
300 'value.none': 'なし',
301 'deny': p => `gcloud-guard はこのコマンドを保留し、実行しませんでした:${p.why}。実行すると:${p.headline}(プロジェクト ${p.project})。${DENY_TAIL}`,
302 'why.cancel': 'ユーザーがキャンセルを押しました',
303 'why.timeout': '10分以内に回答がありませんでした',
304 'why.interrupted': 'ターンが中断されました',
305 'why.error': '保留中に gcloud-guard でエラーが起きました',
306 'why.none': '回答が記録されていません',
307}
308
309export const MESSAGES: Record<Lang, Messages> = { en: en as Messages, 'zh-TW': zhTW, ja }
310
311/** The message for `key` in `lang`, falling back to English when a language lacks it. */
312export function t(lang: Lang, key: MessageKey, params: Params = {}): string {
313 const m = MESSAGES[lang]?.[key] ?? MESSAGES.en[key]
314 return typeof m === 'function' ? m(params) : m
315}
316hooks/logic.ts 810 lines1// gcloud-guard pure functions: tokenizer, segment splitting, the gcloud / gsutil
2// classifier, the argv of the read-only lookups, and the report text.
3// No `$` here; shared with register.tsx and the tests.
4
5import type { GcloudContext, GuardTool, KubeContext, Report, ReportContext, Risk, RiskFlags, Severity, Track } from '../types'
6import { t } from './i18n'
7import type { Lang } from './i18n'
8
9export type { GcloudContext, GuardTool, KubeContext, Report, ReportContext, Risk, RiskFlags, Severity, Track }
10
11/** The context line is re-read when one of its files changed; the timer looks every 5 seconds. */
12export const CONTEXT_TICK_MS = 5000
13
14export const PLUGIN = 'gcloud-guard'
15export const PANE = 'gcloud-guard'
16export const HOLD_LIMIT_MS = 10 * 60 * 1000
17export const POLL_SECONDS = '0.25'
18export const MAX_TARGETS_DESCRIBED = 5
19export const MAX_LINES = 12
20export const MAX_OBJECTS_COUNTED = 2000
21export const DEFAULT_DESCRIBE_TIMEOUT_S = 15
22export const MIN_DESCRIBE_TIMEOUT_S = 3
23export const MAX_DESCRIBE_TIMEOUT_S = 60
24
25// ── Settings ───────────────────────────────────────────────────────────────
26
27export type HoldLevel = 'all' | 'mutating' | 'destructive'
28
29export type Settings = {
30 language: unknown
31 hold: HoldLevel
32 includeGsutil: boolean
33 holdConfigSet: boolean
34 describeTimeoutMs: number
35 showContext: boolean
36 showOtherContexts: boolean
37 /** How the context line is framed (`band_style`); the hold report draws its own box. */
38 bandStyle: BandStyle
39}
40
41function num(v: unknown, fallback: number): number {
42 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
43 return Number.isFinite(n) ? n : fallback
44}
45
46function bool(v: unknown, fallback: boolean): boolean {
47 if (typeof v === 'boolean') return v
48 if (v === 'true') return true
49 if (v === 'false') return false
50 return fallback
51}
52
53export function readSettings(options: Readonly<Record<string, unknown>> | undefined): Settings {
54 const o = options ?? {}
55 const hold = o.hold === 'mutating' || o.hold === 'destructive' ? o.hold : 'all'
56 const seconds = Math.min(MAX_DESCRIBE_TIMEOUT_S, Math.max(MIN_DESCRIBE_TIMEOUT_S, num(o.describe_timeout_seconds, DEFAULT_DESCRIBE_TIMEOUT_S)))
57 return {
58 language: o.language,
59 hold,
60 includeGsutil: bool(o.include_gsutil, true),
61 holdConfigSet: bool(o.hold_config_set, true),
62 describeTimeoutMs: Math.round(seconds * 1000),
63 showContext: bool(o.show_context, true),
64 showOtherContexts: bool(o.show_other_contexts, false),
65 bandStyle: parseBandStyle(o.band_style),
66 }
67}
68
69/** True when the current GKE context points at a project other than the one gcloud is set to. */
70export function kubeProjectMismatch(ctx: GcloudContext | null): boolean {
71 if (!ctx || !ctx.project || ctx.kube?.kind !== 'gke') return false
72 return !!ctx.kube.project && ctx.kube.project !== ctx.project
73}
74
75/** Whether a risk of this severity is held under the configured level. */
76export function isHeld(risk: Risk, settings: Settings): boolean {
77 if (risk.kind === 'local-config' && !settings.holdConfigSet) return false
78 if (settings.hold === 'destructive') return risk.severity === 'destructive'
79 if (settings.hold === 'mutating') return risk.severity !== 'create'
80 return true
81}
82
83// ── Text ───────────────────────────────────────────────────────────────────
84
85export function truncate(s: string, max: number): string {
86 const one = s.replace(/\s+/g, ' ').trim()
87 return one.length <= max ? one : `${one.slice(0, Math.max(0, max - 1))}…`
88}
89
90export function baseName(s: string): string {
91 return s.slice(s.lastIndexOf('/') + 1)
92}
93
94/** Splits one segment into words, honouring double and single quotes. Good enough to read flags and names. */
95export function tokenize(text: string): string[] {
96 const words: string[] = []
97 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
98 let m: RegExpExecArray | null
99 while ((m = re.exec(text)) !== null) words.push(m[1] ?? m[2] ?? m[3] ?? '')
100 return words
101}
102
103/** The command line split on &&, ||, ;, | and newlines (quotes are not honoured here, as in the shell's coarse view). */
104export function splitSegments(command: string): string[] {
105 return command
106 .split(/&&|\|\||;|\||\n/)
107 .map(s => s.trim())
108 .filter(Boolean)
109}
110
111// sudo options that take a value, so the value is not read as the command.
112const SUDO_VALUE_OPTIONS = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
113// Words that can come before the real command without changing what it does.
114const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!'])
115
116/** Strips VAR=value, sudo, nice, env/exec/nohup/time and ( { wrappers from the front of a segment's words. */
117export function stripWrappers(input: readonly string[]): string[] {
118 const words = [...input]
119 while (words.length && /^[({]+$/.test(words[0] as string)) words.shift()
120 if (words.length) words[0] = (words[0] as string).replace(/^[({]+/, '')
121 while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] as string)) words.shift()
122 if (words[0] === 'sudo') {
123 words.shift()
124 while (words.length && (words[0] as string).startsWith('-')) {
125 const option = words.shift() as string
126 if (SUDO_VALUE_OPTIONS.has(option)) words.shift()
127 }
128 }
129 while (words.length && (PREFIXES.has(words[0] as string) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] as string))) words.shift()
130 if (words[0] === 'nice') {
131 words.shift()
132 const after: string | undefined = words[0]
133 if (after === '-n') words.splice(0, 2)
134 else if (/^-\d+$/.test(after ?? '')) words.shift()
135 }
136 if (words.length) {
137 const last = words.length - 1
138 words[last] = (words[last] as string).replace(/[)}]+$/, '')
139 if (words[last] === '') words.pop()
140 }
141 return words
142}
143
144// ── gcloud vocabulary ──────────────────────────────────────────────────────
145
146/** Groups whose commands only touch the local machine: credentials, the SDK itself, help. Never held. */
147const LOCAL_GROUPS = new Set(['auth', 'components', 'help', 'info', 'version', 'feedback', 'topic', 'cheat-sheet', 'survey', 'interactive', 'meta', 'emulators', 'init', 'docker'])
148
149/** Verbs that only read. Always win. */
150export const READ_ONLY_VERBS = new Set([
151 'describe', 'list', 'get', 'get-iam-policy', 'get-value', 'get-ancestors', 'get-ancestors-iam-policy', 'get-credentials',
152 'ls', 'cat', 'stat', 'du', 'hash', 'version', 'info', 'help', 'read', 'tail', 'print-access-token', 'print-identity-token',
153 'print-settings', 'check', 'test-iam-permissions', 'ssh', 'scp', 'logs', 'log', 'explain', 'lint', 'search', 'query',
154 'diff', 'which', 'lookup', 'sign-blob', 'sign-jwt', 'generate-id-token', 'wait', 'stream-logs', 'get-serial-port-output',
155 'tail-serial-port-output', 'get-guest-attributes', 'get-shielded-identity', 'get-screenshot', 'get-config', 'sign-url',
156 'exists', 'validate', 'preview', 'simulate', 'dry-run', 'show', 'export-schema',
157])
158const READ_ONLY_PREFIXES = ['list-', 'describe-', 'get-', 'print-', 'show-', 'check-', 'test-', 'verify-', 'search-', 'lookup-', 'explain-', 'tail-', 'fetch-']
159
160export const DESTRUCTIVE_VERBS = new Set([
161 'delete', 'remove', 'destroy', 'purge', 'rm', 'rb', 'reset', 'abandon', 'cancel', 'revoke', 'wipe', 'detach', 'rollback',
162 'erase', 'unregister', 'unbind', 'uninstall', 'terminate', 'kill', 'drop', 'truncate', 'evict', 'teardown', 'expire', 'unlink',
163])
164const DESTRUCTIVE_PREFIXES = ['remove-', 'delete-', 'detach-', 'revoke-', 'unregister-', 'unbind-', 'drop-', 'purge-', 'destroy-']
165
166export const MUTATING_VERBS = new Set([
167 'update', 'patch', 'set', 'enable', 'disable', 'start', 'stop', 'suspend', 'resume', 'resize', 'move', 'rename', 'promote',
168 'failover', 'restart', 'reboot', 'attach', 'migrate', 'apply', 'replace', 'restore', 'import', 'export', 'upload', 'rotate',
169 'activate', 'deactivate', 'deploy', 'submit', 'unset', 'rsync', 'cp', 'mv', 'setmeta', 'undelete', 'execute', 'run', 'trigger', 'edit',
170 'bind', 'grant', 'install', 'upgrade', 'downgrade', 'lock', 'unlock', 'scale', 'drain', 'cordon', 'uncordon', 'approve',
171 'reject', 'abort', 'retry', 'rerun', 'recreate', 'reconcile', 'sync', 'flush', 'refresh', 'repair', 'recover', 'reload',
172 'redeploy', 'override', 'acquire', 'release', 'renew', 'extend', 'pause', 'unpause', 'simulate-maintenance-event',
173 'send', 'perform', 'invalidate', 'modify', 'transfer', 'label', 'tag', 'compose', 'rewrite', 'setup', 'configure', 'mark',
174 'ack', 'seek', 'pull-and-ack', 'modify-ack-deadline', 'modify-message-ack-deadline', 'modify-push-config',
175 'add-iam-policy-binding', 'remove-iam-policy-binding', 'set-iam-policy',
176])
177const MUTATING_PREFIXES = ['update-', 'set-', 'add-', 'attach-', 'start-', 'stop-', 'enable-', 'disable-', 'restore-', 'resize-',
178 'rotate-', 'move-', 'import-', 'export-', 'upload-', 'apply-', 'replace-', 'promote-', 'suspend-', 'resume-', 'migrate-',
179 'modify-', 'reset-', 'simulate-', 'send-', 'trigger-', 'bind-', 'grant-', 'install-', 'upgrade-', 'lock-', 'unlock-', 'scale-',
180 'approve-', 'reject-', 'abort-', 'retry-', 'sync-', 'flush-', 'refresh-', 'repair-', 'recover-', 'reload-', 'override-',
181 'acquire-', 'release-', 'renew-', 'extend-', 'pause-', 'unpause-', 'mark-', 'ack-', 'seek-', 'configure-', 'setup-']
182
183export const CREATE_VERBS = new Set(['create', 'add', 'insert', 'mb', 'clone', 'snapshot', 'publish', 'register', 'copy', 'reserve', 'provision', 'generate', 'issue', 'mint'])
184const CREATE_PREFIXES = ['create-', 'clone-', 'snapshot-', 'register-', 'provision-', 'generate-']
185
186/** Flags that never take a value (so the next word is not swallowed). `--no-*`, `--enable-*` and the like are handled by prefix. */
187const BOOL_FLAGS = new Set([
188 'quiet', 'q', 'async', 'force', 'help', 'h', 'all', 'recursive', 'r', 'R', 'dry-run', 'verbose', 'v', 'to-latest',
189 'delete', 'delete-unmatched-destination-objects', 'continue-on-error', 'no-clobber', 'ignore-existing', 'gzip-local',
190 'm', 'n', 'd', 'a', 'f', 'p', 'u', 'preemptible', 'spot', 'interactive', 'detailed', 'uri', 'log-http', 'user-output-enabled',
191 'allow-unauthenticated', 'ingress-internal', 'cpu-throttling', 'clear-labels', 'clear-env-vars', 'clear-secrets', 'clear-tags',
192 'await', 'keep-disks', 'strict', 'exact', 'readonly', 'public', 'private', 'yes', 'y', 'global', 'default', 'primary',
193])
194const BOOL_PREFIXES = ['no-', 'enable-', 'disable-', 'allow-', 'use-', 'skip-', 'clear-', 'is-', 'include-', 'exclude-', 'with-', 'without-', 'auto-']
195
196/** Flags whose value names the project, account or location: read into `flags`. */
197const GLOBAL_VALUE_FLAGS = new Set(['project', 'account', 'configuration', 'zone', 'region', 'location', 'impersonate-service-account', 'format', 'filter', 'billing-project', 'verbosity', 'access-token-file', 'flags-file', 'limit', 'sort-by', 'page-size'])
198
199function isBoolFlag(name: string): boolean {
200 if (BOOL_FLAGS.has(name)) return true
201 return BOOL_PREFIXES.some(p => name.startsWith(p))
202}
203
204function verbSeverity(verb: string): { severity: Severity; readOnly?: true } | null {
205 if (READ_ONLY_VERBS.has(verb) || READ_ONLY_PREFIXES.some(p => verb.startsWith(p))) return { severity: 'create', readOnly: true }
206 if (verb === 'undelete') return { severity: 'mutating' }
207 if (DESTRUCTIVE_VERBS.has(verb) || DESTRUCTIVE_PREFIXES.some(p => verb.startsWith(p))) return { severity: 'destructive' }
208 if (MUTATING_VERBS.has(verb) || MUTATING_PREFIXES.some(p => verb.startsWith(p))) return { severity: 'mutating' }
209 if (CREATE_VERBS.has(verb) || CREATE_PREFIXES.some(p => verb.startsWith(p))) return { severity: 'create' }
210 return null
211}
212
213/** Verb stems that mean "do something to the resource", for a verb the table does not know. */
214const ACTION_STEMS = new Set(['simulate', 'send', 'trigger', 'run', 'execute', 'perform', 'apply', 'reset', 'invoke', 'modify', 'replace',
215 'attach', 'detach', 'bind', 'unbind', 'grant', 'revoke', 'lock', 'unlock', 'register', 'unregister', 'install', 'uninstall', 'upgrade',
216 'downgrade', 'scale', 'drain', 'cordon', 'uncordon', 'provision', 'deprovision', 'approve', 'reject', 'promote', 'abort', 'retry',
217 'rerun', 'recreate', 'reconcile', 'sync', 'seal', 'unseal', 'rotate', 'flush', 'truncate', 'refresh', 'repair', 'repack', 'vacuum',
218 'convert', 'transform', 'encrypt', 'recover', 'restore', 'reload', 'redeploy', 'override', 'acquire', 'release', 'renew', 'extend',
219 'reduce', 'increase', 'decrease', 'shrink', 'grow', 'expand', 'pause', 'unpause', 'terminate', 'kill', 'drop', 'evict', 'migrate',
220 'patch', 'update', 'set', 'add', 'remove', 'delete', 'create', 'enable', 'disable', 'start', 'stop', 'resume', 'suspend'])
221
222/** Groups whose commands can reach cloud resources; an unknown action verb under one of these is held to be safe. */
223const KNOWN_GROUPS = new Set(['compute', 'container', 'run', 'sql', 'storage', 'iam', 'projects', 'functions', 'pubsub', 'redis', 'memcache',
224 'spanner', 'bigtable', 'firestore', 'datastore', 'dataproc', 'dataflow', 'composer', 'scheduler', 'tasks', 'secrets', 'kms', 'dns',
225 'domains', 'app', 'builds', 'artifacts', 'deploy', 'workflows', 'eventarc', 'logging', 'monitoring', 'services', 'resource-manager',
226 'organizations', 'folders', 'billing', 'filestore', 'netapp', 'apigee', 'ai', 'ai-platform', 'notebooks', 'workstations', 'batch',
227 'vmware', 'bms', 'transfer', 'certificate-manager', 'endpoints', 'api-gateway', 'access-context-manager', 'identity', 'beyondcorp',
228 'essential-contacts', 'asset', 'recommender', 'scc', 'source', 'firebase', 'healthcare', 'lifesciences', 'alloydb', 'datastream',
229 'data-catalog', 'dataplex', 'looker', 'iap', 'privateca', 'network-security', 'network-services', 'network-connectivity',
230 'infra-manager', 'edge-cache', 'bq', 'gke-hub', 'fleet', 'anthos', 'backup-dr', 'parallelstore', 'memorystore', 'developer-connect',
231 'immersive-stream', 'media', 'migration', 'policy-intelligence', 'policy-troubleshoot', 'publicca', 'quotas', 'runtime-config',
232 'service-directory', 'service-extensions', 'telco-automation', 'colab', 'database-migration', 'dataplex', 'edge-container', 'ids',
233 'metastore', 'ml', 'ml-engine', 'oracle-database', 'managed-kafka', 'netapp', 'org-policies', 'recaptcha', 'resource-settings',
234 'storage-insights', 'web-security-scanner', 'workload-certificate', 'workspace-add-ons'])
235
236const STORAGE_VERBS = new Set(['rm', 'rb', 'mb', 'cp', 'mv', 'rsync', 'setmeta', 'compose', 'rewrite', 'ls', 'cat', 'stat', 'du', 'hash', 'sign-url'])
237
238// ── gcloud ─────────────────────────────────────────────────────────────────
239
240type Parsed = {
241 track: Track
242 path: string[]
243 verb: string | null
244 targets: string[]
245 flags: RiskFlags
246 extra: Record<string, string | true>
247}
248
249/** Reads the words after `gcloud`: the release track, the group path, the verb, the targets and the flags. */
250export function parseGcloudArgs(args: readonly string[]): Parsed {
251 const out: Parsed = { track: 'ga', path: [], verb: null, targets: [], flags: { quiet: false }, extra: {} }
252 let i = 0
253 if (args[0] === 'alpha' || args[0] === 'beta') {
254 out.track = args[0]
255 i = 1
256 }
257 const setGlobal = (name: string, value: string | true) => {
258 if (value === true) return
259 if (name === 'project') out.flags.project = value
260 else if (name === 'account') out.flags.account = value
261 else if (name === 'configuration') out.flags.configuration = value
262 else if (name === 'zone') out.flags.zone = value
263 else if (name === 'region') out.flags.region = value
264 else if (name === 'location') out.flags.location = value
265 else if (name === 'impersonate-service-account') out.flags.impersonate = value
266 }
267 for (; i < args.length; i += 1) {
268 const w = args[i] as string
269 if (w === '--') {
270 for (const rest of args.slice(i + 1)) (out.verb === null ? out.path : out.targets).push(rest)
271 break
272 }
273 if (w.startsWith('--')) {
274 const eq = w.indexOf('=')
275 const name = eq === -1 ? w.slice(2) : w.slice(2, eq)
276 let value: string | true = eq === -1 ? true : w.slice(eq + 1)
277 if (eq === -1 && !isBoolFlag(name)) {
278 const next = args[i + 1]
279 if (next !== undefined && !next.startsWith('-')) {
280 value = next
281 i += 1
282 }
283 }
284 if (name === 'quiet') out.flags.quiet = true
285 else if (GLOBAL_VALUE_FLAGS.has(name)) setGlobal(name, value)
286 out.extra[name] = value
287 continue
288 }
289 if (w === '-q') {
290 out.flags.quiet = true
291 out.extra.q = true
292 continue
293 }
294 if (w.startsWith('-') && w.length > 1) {
295 out.extra[w.slice(1)] = true
296 continue
297 }
298 if (out.verb === null) {
299 // The first word is always a group (`run`, `deploy`, `config`), even when it is also a verb elsewhere
300 if (out.path.length > 0 && verbSeverity(w) !== null) out.verb = w
301 else out.path.push(w)
302 } else out.targets.push(w)
303 }
304 return out
305}
306
307function unknownVerbOf(path: readonly string[]): number {
308 for (let k = 1; k < path.length; k += 1) {
309 const w = path[k] as string
310 const stem = w.includes('-') ? (w.split('-')[0] as string) : w
311 if (ACTION_STEMS.has(stem)) return k
312 }
313 return -1
314}
315
316/** One gcloud segment (words after `gcloud`) to a risk, or null when it only reads or stays local. */
317export function classifyGcloud(args: readonly string[], raw: string, settings: Settings): Risk | null {
318 const p = parseGcloudArgs(args)
319 const group = p.path[0] ?? ''
320 if (LOCAL_GROUPS.has(group)) return null
321 const base: Omit<Risk, 'severity' | 'verb'> = { tool: 'gcloud', track: p.track, path: p.path, targets: p.targets, flags: p.flags, extra: p.extra, raw }
322
323 // gcloud config: only set / unset / configurations activate|create|delete|rename change what later commands hit
324 if (group === 'config') {
325 const sub = p.path[1]
326 if (p.verb === 'set' || p.verb === 'unset' || (sub === 'configurations' && (p.verb === 'activate' || p.verb === 'create' || p.verb === 'delete' || p.verb === 'rename'))) {
327 return { ...base, verb: p.verb, severity: 'mutating', kind: 'local-config' }
328 }
329 return null
330 }
331
332 if (p.verb === null) {
333 if (!KNOWN_GROUPS.has(group)) return null
334 const k = unknownVerbOf(p.path)
335 if (k === -1) return null
336 const verb = p.path[k] as string
337 return { ...base, path: p.path.slice(0, k), targets: [...p.path.slice(k + 1), ...p.targets], verb, severity: 'mutating', kind: 'unknown', unknownVerb: true }
338 }
339 const sev = verbSeverity(p.verb)
340 if (sev === null || sev.readOnly) return null
341 const risk: Risk = { ...base, verb: p.verb, severity: sev.severity }
342 void settings
343
344 // Kinds the UI treats specially
345 if (group === 'storage' && STORAGE_VERBS.has(p.verb)) risk.kind = 'storage'
346 else if (p.verb === 'deploy') {
347 risk.kind = 'deploy'
348 risk.severity = 'mutating'
349 } else if (group === 'builds' && p.verb === 'submit') {
350 risk.kind = 'build'
351 risk.severity = 'mutating'
352 } else if (p.verb === 'add-iam-policy-binding' || p.verb === 'remove-iam-policy-binding' || p.verb === 'set-iam-policy') {
353 risk.kind = 'iam'
354 risk.severity = p.verb === 'remove-iam-policy-binding' ? 'destructive' : 'mutating'
355 }
356 // Whole-project / org / folder operations
357 if (risk.severity === 'destructive' && p.path.length === 1) {
358 if (group === 'projects') risk.scope = 'project'
359 else if (group === 'organizations') risk.scope = 'org'
360 else if (group === 'folders') risk.scope = 'folder'
361 }
362 return risk
363}
364
365// ── gsutil ─────────────────────────────────────────────────────────────────
366
367/** gsutil commands whose first positional word is a sub-verb (get / set / ch / ...). */
368const GSUTIL_CONFIG_CMDS = new Set(['acl', 'defacl', 'iam', 'lifecycle', 'versioning', 'web', 'cors', 'label', 'retention', 'logging',
369 'requesterpays', 'ubla', 'pap', 'autoclass', 'kms', 'notification', 'hmac', 'bucketpolicyonly', 'defstorageclass', 'rpo'])
370const GSUTIL_READ_ONLY = new Set(['ls', 'cat', 'stat', 'du', 'hash', 'version', 'help', 'test', 'signurl'])
371const GSUTIL_GLOBAL_VALUE = new Set(['-o', '-h', '-i', '-u'])
372
373/** One gsutil segment (words after `gsutil`) to a risk, or null. */
374export function classifyGsutil(args: readonly string[], raw: string): Risk | null {
375 const flags: RiskFlags = { quiet: false }
376 const extra: Record<string, string | true> = {}
377 let i = 0
378 // Global options come before the command: -m, -q, -D, -o X, -h X, -i SA, -u PROJECT
379 while (i < args.length && (args[i] as string).startsWith('-')) {
380 const w = args[i] as string
381 if (GSUTIL_GLOBAL_VALUE.has(w)) {
382 const v = args[i + 1] ?? ''
383 if (w === '-u') flags.project = v
384 if (w === '-i') flags.impersonate = v
385 extra[w.slice(1)] = v
386 i += 2
387 continue
388 }
389 if (w === '-q') flags.quiet = true
390 extra[w.slice(1)] = true
391 i += 1
392 }
393 const cmd = args[i]
394 if (!cmd) return null
395 i += 1
396 const rest = args.slice(i)
397 const positional: string[] = []
398 for (const w of rest) {
399 if (w.startsWith('-')) extra[w.replace(/^-+/, '')] = true
400 else positional.push(w)
401 }
402 const base = { tool: 'gsutil' as const, track: 'ga' as const, flags, extra, raw }
403 if (GSUTIL_READ_ONLY.has(cmd)) return null
404 if (GSUTIL_CONFIG_CMDS.has(cmd)) {
405 const sub = positional[0] ?? ''
406 const targets = positional.slice(1)
407 if (sub === 'get' || sub === 'list' || sub === '') return null
408 const severity: Severity = sub === 'delete' || sub === 'clear' || sub === 'del' ? 'destructive' : sub === 'create' ? 'create' : 'mutating'
409 return { ...base, path: [cmd], verb: sub, severity, kind: cmd === 'iam' || cmd === 'acl' || cmd === 'defacl' ? 'iam' : 'storage', targets }
410 }
411 if (cmd === 'rm' || cmd === 'rb') return { ...base, path: [], verb: cmd, severity: 'destructive', kind: 'storage', targets: positional }
412 if (cmd === 'mb') return { ...base, path: [], verb: cmd, severity: 'create', kind: 'storage', targets: positional }
413 if (cmd === 'cp' || cmd === 'mv' || cmd === 'rsync' || cmd === 'setmeta' || cmd === 'compose' || cmd === 'rewrite' || cmd === 'perfdiag') {
414 return { ...base, path: [], verb: cmd, severity: 'mutating', kind: 'storage', targets: positional }
415 }
416 return null
417}
418
419// ── The command line ───────────────────────────────────────────────────────
420
421/** The first gcloud / gsutil segment of the command line that would change something, or null. */
422export function classify(command: string, settings: Settings): Risk | null {
423 for (const raw of splitSegments(command)) {
424 const words = stripWrappers(tokenize(raw))
425 const first = words[0]
426 if (!first) continue
427 const cmd = first.replace(/^\\/, '')
428 if (cmd === 'gcloud' || cmd.endsWith('/gcloud')) {
429 const risk = classifyGcloud(words.slice(1), raw, settings)
430 if (risk) return risk
431 continue
432 }
433 if (settings.includeGsutil && (cmd === 'gsutil' || cmd.endsWith('/gsutil'))) {
434 const risk = classifyGsutil(words.slice(1), raw)
435 if (risk) return risk
436 }
437 }
438 return null
439}
440
441// ── Lookups: the argv of the read-only commands ────────────────────────────
442
443/** `--zone=…`, `--region=…`, `--location=…`, `--project=…`, `--account=…`, `--impersonate-service-account=…` as given. */
444export function scopeFlags(flags: RiskFlags): string[] {
445 const out: string[] = []
446 if (flags.zone) out.push(`--zone=${flags.zone}`)
447 if (flags.region) out.push(`--region=${flags.region}`)
448 if (flags.location) out.push(`--location=${flags.location}`)
449 if (flags.project) out.push(`--project=${flags.project}`)
450 if (flags.account) out.push(`--account=${flags.account}`)
451 if (flags.configuration) out.push(`--configuration=${flags.configuration}`)
452 if (flags.impersonate) out.push(`--impersonate-service-account=${flags.impersonate}`)
453 return out
454}
455
456function trackWords(track: Track): string[] {
457 return track === 'ga' ? [] : [track]
458}
459
460/** `gcloud [track] <path> describe <target> --format=json <scope flags>` */
461export function describeArgv(risk: Risk, target: string): string[] {
462 return ['gcloud', ...trackWords(risk.track), ...risk.path, 'describe', target, '--format=json', ...scopeFlags(risk.flags)]
463}
464
465/** `gcloud [track] <path> get-iam-policy <target> --format=json <scope flags>` */
466export function getIamPolicyArgv(risk: Risk, target: string): string[] {
467 return ['gcloud', ...trackWords(risk.track), ...risk.path, 'get-iam-policy', target, '--format=json', ...scopeFlags(risk.flags)]
468}
469
470/** The read-only listing of a storage URL, with the tool the person used. */
471export function listObjectsArgv(risk: Risk, url: string): string[] {
472 if (risk.tool === 'gsutil') return ['gsutil', 'ls', '-r', url]
473 return ['gcloud', 'storage', 'ls', '-r', url, ...scopeFlags(risk.flags)]
474}
475
476export function configGetArgv(property: string, flags: RiskFlags): string[] {
477 const out = ['gcloud', 'config', 'get-value', property]
478 if (flags.configuration) out.push(`--configuration=${flags.configuration}`)
479 return out
480}
481
482export function projectDescribeArgv(id: string, flags: RiskFlags): string[] {
483 const out = ['gcloud', 'projects', 'describe', id, '--format=json']
484 if (flags.account) out.push(`--account=${flags.account}`)
485 if (flags.impersonate) out.push(`--impersonate-service-account=${flags.impersonate}`)
486 return out
487}
488
489export function servicesListArgv(id: string, flags: RiskFlags): string[] {
490 const out = ['gcloud', 'services', 'list', '--enabled', `--project=${id}`, '--format=value(config.name)', '--limit=50']
491 if (flags.account) out.push(`--account=${flags.account}`)
492 if (flags.impersonate) out.push(`--impersonate-service-account=${flags.impersonate}`)
493 return out
494}
495
496/** Whether the storage verb deletes objects at the destination (rsync -d / --delete-unmatched-destination-objects). */
497export function isRsyncDelete(risk: Risk): boolean {
498 return risk.verb === 'rsync' && (risk.extra.d === true || risk.extra['delete-unmatched-destination-objects'] === true)
499}
500
501/** Storage URLs among the targets (sources of rm / mv / rb / rsync, destination of cp excluded). */
502export function storageUrls(risk: Risk): string[] {
503 const urls = risk.targets.filter(x => x.startsWith('gs://'))
504 if (risk.verb === 'cp' || risk.verb === 'mv' || risk.verb === 'rsync') return urls.slice(0, Math.max(0, urls.length - 1))
505 return urls
506}
507
508/** The flags that say how big a thing is being created, for the create-class summary. */
509export const KEY_CREATE_FLAGS = ['machine-type', 'size', 'tier', 'num-nodes', 'image', 'image-family', 'memory', 'cpu', 'min-instances', 'max-instances', 'disk-size', 'database-version', 'storage-size', 'node-count', 'replicas', 'capacity', 'boot-disk-size']
510
511export function keyFlagsLine(risk: Risk): string | null {
512 const parts: string[] = []
513 for (const name of KEY_CREATE_FLAGS) {
514 const v = risk.extra[name]
515 if (v !== undefined && v !== true) parts.push(`--${name}=${v}`)
516 }
517 return parts.length ? parts.join(' ') : null
518}
519
520// ── Describe JSON → one line ───────────────────────────────────────────────
521
522type Json = Record<string, unknown>
523
524function str(v: unknown): string | null {
525 return typeof v === 'string' && v !== '' ? v : null
526}
527
528function numOf(v: unknown): number | null {
529 if (typeof v === 'number') return v
530 if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v)
531 return null
532}
533
534function dateOf(v: unknown): string | null {
535 const s = str(v)
536 if (!s) return null
537 const m = /^(\d{4}-\d{2}-\d{2})/.exec(s)
538 return m ? (m[1] as string) : s
539}
540
541/** A compact description of a resource from its describe JSON, and the notes it raises (deletion protection). */
542export function summarizeDescribe(lang: Lang, target: string, json: unknown): { line: string; notes: string[] } {
543 const o = (json && typeof json === 'object' ? json : {}) as Json
544 const parts: string[] = [str(o.name) ?? target]
545 const status = str(o.status) ?? str(o.state)
546 if (status) parts.push(status)
547 const where = str(o.zone) ?? str(o.region) ?? str(o.location) ?? str(o.locationId)
548 if (where) parts.push(baseName(where))
549 const machine = str(o.machineType)
550 if (machine) parts.push(baseName(machine))
551 const settings = (o.settings && typeof o.settings === 'object' ? o.settings : null) as Json | null
552 const tier = settings ? str(settings.tier) : null
553 const dbv = str(o.databaseVersion)
554 if (dbv) parts.push(tier ? `${dbv} ${tier}` : dbv)
555 const size = numOf(o.sizeGb) ?? numOf(o.diskSizeGb)
556 if (size !== null) parts.push(`${size} GB`)
557 const nodes = numOf(o.currentNodeCount) ?? numOf(o.initialNodeCount)
558 if (nodes !== null) parts.push(t(lang, 'line.nodes', { n: nodes }))
559 const created = dateOf(o.creationTimestamp) ?? dateOf(o.createTime)
560 if (created) parts.push(t(lang, 'line.created', { date: created }))
561 const labels = o.labels && typeof o.labels === 'object' ? Object.keys(o.labels as Json).length : 0
562 if (labels) parts.push(t(lang, 'line.labels', { n: labels }))
563 const disks = Array.isArray(o.disks) ? (o.disks as Json[]) : null
564 if (disks && disks.length) parts.push(t(lang, 'line.disks', { n: disks.length, autoDelete: disks.filter(d => d.autoDelete === true).length }))
565 const bindings = Array.isArray(o.bindings) ? (o.bindings as unknown[]).length : null
566 if (bindings !== null) parts.push(t(lang, 'line.bindings', { n: bindings }))
567 const notes: string[] = []
568 if (o.deletionProtection === true || o.deleteProtection === true || (settings && settings.deletionProtectionEnabled === true)) {
569 notes.push(t(lang, 'note.deletionProtection', { target }))
570 }
571 return { line: parts.join(' · '), notes }
572}
573
574/** Whether a failed describe says the resource does not exist. */
575export function isNotFound(stderr: string): boolean {
576 return /not found|notfound|404|does not exist|could not fetch resource/i.test(stderr)
577}
578
579/** The objects a storage listing holds: lines that are object URLs, not bucket or prefix headers. */
580export function countObjects(stdout: string): { n: number; cut: boolean } {
581 let n = 0
582 for (const line of stdout.split('\n')) {
583 const s = line.trim()
584 if (!s.startsWith('gs://') || s.endsWith('/') || s.endsWith(':')) continue
585 n += 1
586 if (n >= MAX_OBJECTS_COUNTED) return { n, cut: true }
587 }
588 return { n, cut: false }
589}
590
591// ── Report text ────────────────────────────────────────────────────────────
592
593export function severityLabel(lang: Lang, severity: Severity): string {
594 return t(lang, severity === 'destructive' ? 'severity.destructive' : severity === 'mutating' ? 'severity.mutating' : 'severity.create')
595}
596
597export function typeLabel(risk: Risk): string {
598 if (risk.tool === 'gsutil') return risk.path.length ? `gsutil ${risk.path.join(' ')}` : 'gsutil'
599 return risk.path.join(' ') || 'gcloud'
600}
601
602export function targetsLabel(risk: Risk, max = 4): string {
603 const list = risk.targets
604 if (!list.length) return ''
605 const shown = list.slice(0, max).map(x => truncate(x, 48))
606 return list.length > max ? `${shown.join(', ')} (+${list.length - max})` : shown.join(', ')
607}
608
609/** The headline of the pane and of the refusal. `current` is the current config value for a local-config change. */
610export function headline(lang: Lang, risk: Risk, current: string | null = null): string {
611 const type = typeLabel(risk)
612 const targets = targetsLabel(risk)
613 if (risk.scope === 'project') return t(lang, 'headline.project', { id: risk.targets[0] ?? '?' })
614 if (risk.scope === 'org' || risk.scope === 'folder') return t(lang, 'headline.org', { kind: risk.scope === 'org' ? 'organization' : 'folder', id: risk.targets[0] ?? '?' })
615 if (risk.kind === 'local-config') {
616 const property = risk.targets[0] ?? '?'
617 const value = risk.verb === 'unset' ? '(unset)' : (risk.targets[1] ?? '?')
618 return t(lang, 'headline.config', { property, value, current: current ?? t(lang, 'value.unknown') })
619 }
620 if (risk.kind === 'deploy') return t(lang, 'headline.deploy', { type, targets })
621 if (risk.kind === 'build') return t(lang, 'headline.build', { targets })
622 if (risk.kind === 'iam' && risk.tool === 'gcloud') {
623 const member = String(risk.extra.member ?? '?')
624 const role = String(risk.extra.role ?? '?')
625 if (risk.verb === 'add-iam-policy-binding') return t(lang, 'headline.iamAdd', { member, role, type, targets })
626 if (risk.verb === 'remove-iam-policy-binding') return t(lang, 'headline.iamRemove', { member, role, type, targets })
627 if (risk.verb === 'set-iam-policy') return t(lang, 'headline.iamSet', { type, targets: risk.targets[0] ?? '?' })
628 }
629 if (risk.kind === 'storage') {
630 if (risk.verb === 'rm' || risk.verb === 'rb') return t(lang, 'headline.storageRm', { targets: targetsLabel(risk, 3) })
631 if (risk.verb === 'cp' || risk.verb === 'mv' || risk.verb === 'rsync') return t(lang, 'headline.storageCopy', { verb: risk.verb, targets: targetsLabel(risk, 3) })
632 }
633 return t(lang, 'headline.generic', { verb: risk.verb, type, targets })
634}
635
636export function emptyContext(risk: Risk): ReportContext {
637 return {
638 account: risk.flags.account ?? null,
639 project: risk.flags.project ?? null,
640 projectSource: risk.flags.project ? 'flag' : 'unknown',
641 configuration: risk.flags.configuration ?? null,
642 location: risk.flags.zone ?? risk.flags.region ?? risk.flags.location ?? null,
643 track: risk.track,
644 quiet: risk.flags.quiet,
645 impersonate: risk.flags.impersonate ?? null,
646 }
647}
648
649/** The text of the refusal Claude reads. */
650export function denyText(lang: Lang, why: 'cancel' | 'timeout' | 'interrupted' | 'error' | 'none', head: string, project: string | null): string {
651 const whyKey = why === 'cancel' ? 'why.cancel' : why === 'timeout' ? 'why.timeout' : why === 'interrupted' ? 'why.interrupted' : why === 'error' ? 'why.error' : 'why.none'
652 return t(lang, 'deny', { why: t(lang, whyKey), headline: head, project: project ?? t(lang, 'value.unknown') })
653}
654
655/** The rows the pane would need: title and headline, the context block, the lines, the notes, the buttons row. */
656export function paneRows(report: Report | null, hasGke = false): number {
657 if (!report) return 8
658 return Math.min(28, 9 + report.lines.length + report.notes.length + (report.context.quiet ? 1 : 0) + (report.context.track !== 'ga' ? 1 : 0) + (hasGke ? 1 : 0))
659}
660
661// ── The context line: gcloud and kube config files ─────────────────────────
662
663/** A gcloud configuration file (INI): `[section]` headers, `key = value` rows, `#` and `;` comments. Keys are `section/key`. */
664export function parseGcloudIni(text: string): Record<string, string> {
665 const out: Record<string, string> = {}
666 let section = ''
667 for (const raw of text.split('\n')) {
668 const line = raw.trim()
669 if (!line || line.startsWith('#') || line.startsWith(';')) continue
670 const head = /^\[([^\]]+)\]$/.exec(line)
671 if (head) {
672 section = (head[1] as string).trim()
673 continue
674 }
675 const eq = line.indexOf('=')
676 if (eq === -1) continue
677 const key = line.slice(0, eq).trim()
678 const value = line.slice(eq + 1).trim()
679 if (!key) continue
680 out[section ? `${section}/${key}` : key] = value
681 }
682 return out
683}
684
685/** The current kubectl context by name: `gke_<project>_<location>_<cluster>` is a GKE cluster, anything else is `other`. */
686export function parseKubeContext(name: string): KubeContext {
687 const n = name.trim()
688 if (n.startsWith('gke_')) {
689 const parts = n.split('_')
690 // gke, project, location, then the cluster (which may hold no `_`, so the rest is joined back for safety)
691 if (parts.length >= 4 && parts[1] && parts[2] && parts[3]) {
692 return { kind: 'gke', name: n, project: parts[1], location: parts[2], cluster: parts.slice(3).join('_') }
693 }
694 }
695 return { kind: 'other', name: n }
696}
697
698/** The `current-context:` line of a kubeconfig, or null. No YAML parser: one regex. */
699export function currentContextOf(kubeconfigText: string): string | null {
700 const m = /^\s*current-context:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/m.exec(kubeconfigText)
701 const v = m?.[1]?.trim()
702 return v ? v : null
703}
704
705/** KUBECONFIG is a colon-separated list; the first file that names a current context wins. */
706export function splitKubeconfigList(value: string | undefined, home: string | null): string[] {
707 const list = (value ?? '').split(':').map(s => s.trim()).filter(Boolean)
708 if (list.length) return list
709 return home ? [`${home.replace(/\/+$/, '')}/.kube/config`] : []
710}
711
712export type ContextEnv = {
713 CLOUDSDK_CONFIG?: string
714 HOME?: string
715 CLOUDSDK_CORE_PROJECT?: string
716 CLOUDSDK_CORE_ACCOUNT?: string
717 CLOUDSDK_ACTIVE_CONFIG_NAME?: string
718 CLOUDSDK_COMPUTE_ZONE?: string
719 CLOUDSDK_COMPUTE_REGION?: string
720}
721
722export function gcloudConfigDir(env: ContextEnv): string | null {
723 const explicit = (env.CLOUDSDK_CONFIG ?? '').trim()
724 if (explicit) return explicit.replace(/\/+$/, '')
725 const home = (env.HOME ?? '').trim()
726 return home ? `${home.replace(/\/+$/, '')}/.config/gcloud` : null
727}
728
729/** Builds the snapshot from the files' texts and the env; the file reads themselves are the caller's. */
730export function buildContext(input: {
731 env: ContextEnv
732 configDir: string | null
733 activeConfigText: string | null
734 configText: string | null
735 kubeconfigPath: string | null
736 kubeconfigText: string | null
737 now: number
738}): GcloudContext {
739 const { env } = input
740 const configuration = (env.CLOUDSDK_ACTIVE_CONFIG_NAME ?? '').trim() || (input.activeConfigText ?? '').trim() || (input.configDir ? 'default' : '')
741 const ini = input.configText !== null ? parseGcloudIni(input.configText) : {}
742 const envProject = (env.CLOUDSDK_CORE_PROJECT ?? '').trim()
743 const fileProject = (ini['core/project'] ?? '').trim()
744 const project = envProject || fileProject || null
745 const account = (env.CLOUDSDK_CORE_ACCOUNT ?? '').trim() || (ini['core/account'] ?? '').trim() || null
746 const zone = (env.CLOUDSDK_COMPUTE_ZONE ?? '').trim() || (ini['compute/zone'] ?? '').trim() || null
747 const region = (env.CLOUDSDK_COMPUTE_REGION ?? '').trim() || (ini['compute/region'] ?? '').trim() || null
748 const name = input.kubeconfigText !== null ? currentContextOf(input.kubeconfigText) : null
749 return {
750 configDir: input.configDir,
751 configuration: configuration || null,
752 project,
753 projectSource: project ? (envProject ? 'env' : 'file') : null,
754 account,
755 zone,
756 region,
757 kubeconfig: input.kubeconfigPath,
758 kube: name ? parseKubeContext(name) : null,
759 readAt: input.now,
760 }
761}
762
763/** The dim line above the prompt; null when nothing is known. */
764export function contextLine(lang: Lang, ctx: GcloudContext | null, settings: Settings): string | null {
765 if (!ctx) return null
766 const parts: string[] = []
767 if (ctx.project) parts.push(`${t(lang, 'ctx.project', { project: ctx.project })}${ctx.projectSource === 'env' ? ` ${t(lang, 'ctx.fromEnv')}` : ''}`)
768 if (ctx.account) parts.push(t(lang, 'ctx.account', { account: ctx.account }))
769 if (ctx.configuration && (ctx.project || ctx.account)) parts.push(t(lang, 'ctx.config', { name: ctx.configuration }))
770 if (ctx.kube?.kind === 'gke') parts.push(t(lang, 'ctx.gke', { cluster: ctx.kube.cluster, location: ctx.kube.location }))
771 else if (ctx.kube && settings.showOtherContexts) parts.push(t(lang, 'ctx.k8s', { name: ctx.kube.name }))
772 if (!parts.length) return null
773 return `☁ gcloud · ${parts.join(' · ')}`
774}
775
776/** The `/gcloud-guard` output: the whole snapshot and the hold setting. */
777export function contextText(lang: Lang, ctx: GcloudContext | null, settings: Settings): string {
778 const none = t(lang, 'value.none')
779 const lines: string[] = []
780 if (!ctx || (!ctx.configDir && !ctx.project && !ctx.account)) lines.push(t(lang, 'cmd.none'))
781 else {
782 lines.push(t(lang, 'cmd.configDir', { dir: ctx.configDir ?? none }))
783 lines.push(t(lang, 'cmd.configuration', { name: ctx.configuration ?? none }))
784 lines.push(t(lang, 'cmd.project', { project: ctx.project ?? none, source: ctx.projectSource === 'env' ? t(lang, 'cmd.source.env') : t(lang, 'cmd.source.file') }))
785 lines.push(t(lang, 'cmd.account', { account: ctx.account ?? none }))
786 lines.push(t(lang, 'cmd.zone', { zone: ctx.zone ?? none, region: ctx.region ?? none }))
787 }
788 lines.push(t(lang, 'cmd.kubeconfig', { path: ctx?.kubeconfig ?? none }))
789 lines.push(t(lang, 'cmd.kubeContext', { name: ctx?.kube?.name ?? none }))
790 if (ctx?.kube?.kind === 'gke') lines.push(t(lang, 'cmd.gke', { project: ctx.kube.project, location: ctx.kube.location, cluster: ctx.kube.cluster }))
791 lines.push(t(lang, 'cmd.hold', { hold: settings.hold, gsutil: settings.includeGsutil ? 1 : 0, configSet: settings.holdConfigSet ? 1 : 0 }))
792 return lines.join('\n')
793}
794
795/** Bash commands after which the context may have changed: config, credentials, kube context switches. */
796export function touchesContext(command: string): boolean {
797 return /gcloud\s+(?:alpha\s+|beta\s+)?(?:config\b|auth\b|container\s+clusters\s+get-credentials)|kubectl\s+config\s+(?:use-context|set-context|set-cluster|unset)|\bkubectx\b/.test(command)
798}
799
800
801// ── Band framing ───────────────────────────────────────────────────────────
802
803/** How the mod's line above the prompt is framed: a rounded box, a thin rule beneath, or bare text. */
804export type BandStyle = 'box' | 'rule' | 'plain'
805
806/** The `band_style` option; anything but `rule` or `plain` is the default box. */
807export function parseBandStyle(v: unknown): BandStyle {
808 return v === 'rule' || v === 'plain' ? v : 'box'
809}
810types/index.d.ts 107 lines1// gcloud-guard: data types and the $.state contract.
2
3export type GuardLang = 'en' | 'zh-TW' | 'ja'
4
5/** How much a command changes: delete-class, update-class, or create-class. */
6export type Severity = 'destructive' | 'mutating' | 'create'
7
8export type GuardTool = 'gcloud' | 'gsutil'
9
10export type Track = 'ga' | 'alpha' | 'beta'
11
12/** The global flags read off the command line. */
13export type RiskFlags = {
14 project?: string
15 account?: string
16 configuration?: string
17 zone?: string
18 region?: string
19 location?: string
20 quiet: boolean
21 impersonate?: string
22}
23
24/** What the classifier found: the first gcloud / gsutil segment that would change something. */
25export type Risk = {
26 tool: GuardTool
27 track: Track
28 /** The command group words before the verb, e.g. ['compute', 'instances'] */
29 path: string[]
30 verb: string
31 severity: Severity
32 /** A finer label for the UI: 'deploy', 'local-config', 'storage', 'iam', 'build', 'unknown' */
33 kind?: string
34 /** Positional words after the verb: resource names, URLs, properties */
35 targets: string[]
36 flags: RiskFlags
37 /** Every other flag on the segment, by name without the dashes; `true` when it took no value */
38 extra: Record<string, string | true>
39 /** The segment as written */
40 raw: string
41 /** Set when the target is a whole project, organization or folder */
42 scope?: 'project' | 'org' | 'folder'
43 /** The verb was not in the table; held to be safe */
44 unknownVerb?: boolean
45}
46
47export type ReportContext = {
48 account: string | null
49 project: string | null
50 projectSource: 'flag' | 'config' | 'unknown'
51 configuration: string | null
52 location: string | null
53 track: Track
54 quiet: boolean
55 impersonate: string | null
56}
57
58export type Report = {
59 severity: Severity
60 headline: string
61 context: ReportContext
62 /** Facts about the targets, at most 12 */
63 lines: string[]
64 /** Caveats: a describe that failed, a missing gcloud, deletion protection */
65 notes: string[]
66}
67
68/** What the render hooks read: the hold as a frozen value. The decision lives in the module. */
69export type HeldView = {
70 id: number
71 command: string
72 risk: Risk
73 report: Report | null
74 where: 'pane' | 'band'
75}
76
77/** The current kubectl context, parsed: a GKE one carries its project, location and cluster. */
78export type KubeContext = { kind: 'gke'; name: string; project: string; location: string; cluster: string } | { kind: 'other'; name: string }
79
80/** What the session is pointed at, read from the gcloud and kube config files (no process). */
81export type GcloudContext = {
82 configDir: string | null
83 configuration: string | null
84 project: string | null
85 /** Where the project came from: the CLOUDSDK_CORE_PROJECT env var or the configuration file */
86 projectSource: 'env' | 'file' | null
87 account: string | null
88 zone: string | null
89 region: string | null
90 kubeconfig: string | null
91 kube: KubeContext | null
92 /** When the snapshot was taken, in $.clock.now() milliseconds */
93 readAt: number
94}
95
96declare module 'claude-code' {
97 interface PluginState {
98 'gcloud-guard': {
99 lang: GuardLang
100 held: HeldView | null
101 context: GcloudContext | null
102 /** /gcloud-guard off hides the context line for the session */
103 isBandHidden: boolean
104 }
105 }
106}
107