SLOPSHOPPER

gcloud-guard

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gcloud-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gcloud-guard ⎿ gcloud-guard: gcloud-guard: no gcloud configuration found (no config directory, or no active configuration). ⎿ gcloud-guard: kubeconfig none ⎿ gcloud-guard: kube context none ⎿ gcloud-guard: hold setting all + gsutil + config set ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

gcloud-guard

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                   │
╰─────────────────────────────────────────────────────────────────────────────╯

What it holds

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.

Severitygcloud verbsgsutil
destructivedelete, 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-bindingrm, rb; notification delete, … clear
mutatingupdate, 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, …
createcreate, add, insert, clone, snapshot, publish, register, reserve, provision, and create-*mb; notification create

Special cases:

  • Whole projects, organizations, folders: 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.
  • IAM bindings: the member and role are shown; for set-iam-policy the number of bindings in the policy file is compared with the current policy.
  • Storage (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.
  • An unknown action verb under a known group (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).

What the pane shows

  • The headline in red (destructive), yellow (mutating) or green (create).
  • The command on one line; the account and project it would hit, with where the project came from (--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.
  • For delete- and update-class commands, one line per target (up to 5) from a read-only 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.
  • For create-class commands, the sizing flags (--machine-type, --size, --tier, --num-nodes, --image, --memory, --cpu, …).
  • When gcloud is not on PATH or does not answer, the pane says so and the command is still held.

Context line

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):

PartSource
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 clusterthe 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.

Usage

It works as soon as it is installed. The only command is /gcloud-guard (above).

  • When the pane appears, press 1 for Proceed or 2 for Cancel. Prefer the digits.
  • Arrow keys, Tab and Enter only work while the pane actually holds the keyboard. The pane requests focus when it opens, and Claude Code grants it only over an empty composer. If the composer has text, or the terminal is too narrow and the report is drawn in the band above the prompt instead, the keys stay with the composer. An Enter there is read by Claude Code as "background this turn once the current tool finishes": the session is continued under a new session id and the transcript shows 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.
  • Cancel has the focus when the pane does hold the keyboard, so Enter there refuses the command.
  • No answer within 10 minutes refuses the command. Interrupting the turn (Esc) refuses it too.
  • One command is held at a time. A second risky call (from a subagent, say) waits until the first is answered.

Settings

OptionDefaultMeaning
languageautoLanguage 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.
holdalldestructive holds delete-class commands only; mutating holds delete- and update-class; all also holds create-class.
include_gsutiltrueAlso watch gsutil.
hold_config_settrueHold gcloud config set / unset / configurations activate.
describe_timeout_seconds15How long each read-only lookup may take before it is reported as failed (3 to 60).
show_contexttrueDraw the context line above the prompt.
show_other_contextsfalseAlso show the current kubectl context when it is not a GKE cluster.
band_styleboxHow 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.

Beside the other mods in this repo

  • Its context line sits in its own rounded frame, stacked above the frames of opsx-board, auto-handover, cache-keeper and secret-guard (each mod frames its own line; 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.
  • blast-radius and gcloud-guard can both be installed: each watches its own commands. A line such as gcloud compute instances delete x && rm -rf build is held by both, one after the other.

Safety boundary

  • Every lookup is a read-only gcloud / gsutil command run by argv ($.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.
  • For the context line it reads, read-only, the gcloud configuration files (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.
  • The lookups run with your credentials and need the matching get / list permissions; where they fail the pane says so and the command is still held.
  • It is a safety net, not IAM. After Proceed the command runs as written, with no sandbox. For a hard block use permission rules or the project's IAM.

What it does, before you install it

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.

Limits

  • It reads the command text; it is not a shell parser. $(…), 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 parsing is heuristic: --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.
  • The verb tables cover the common groups. A verb that is not in them is held only when it looks like an action (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.
  • Object counting for storage lists up to 2000 objects per URL and then says 2000+; gsutil ls -r on a huge bucket may hit the timeout, which is reported as a note.
  • One command is held at a time; a second waits.
  • The context line reads the files gcloud and kubectl read, not what they would resolve: a --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.
  • Verified in the test kit against a stand-in for gcloud and a fake file system; in a live session a gcloud config set was held, the pane showed the account and project, and Proceed ran it.

Development

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.

Source 4 files
hooks/register.tsx 679 lines
1// 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}
679
hooks/i18n.ts 316 lines
1// 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}
316
hooks/logic.ts 810 lines
1// 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}
810
types/index.d.ts 107 lines
1// 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