fullcourtdefense-cli 1.36.0 → 1.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/actionPolicyEngine.d.ts +23 -0
  2. package/dist/actionPolicyEngine.js +58 -19
  3. package/dist/approvalPrompt.d.ts +15 -0
  4. package/dist/approvalPrompt.js +157 -20
  5. package/dist/askDialog.d.ts +2 -0
  6. package/dist/askDialog.js +15 -1
  7. package/dist/askHeader.d.ts +81 -0
  8. package/dist/askHeader.js +573 -0
  9. package/dist/blockExplanation.d.ts +9 -0
  10. package/dist/blockExplanation.js +14 -0
  11. package/dist/callerEnv.d.ts +1 -1
  12. package/dist/callerEnv.js +14 -0
  13. package/dist/cloudGrant.d.ts +46 -0
  14. package/dist/cloudGrant.js +420 -0
  15. package/dist/cloudGrantTools.d.ts +97 -0
  16. package/dist/cloudGrantTools.js +1112 -0
  17. package/dist/commandNarrative.d.ts +8 -0
  18. package/dist/commandNarrative.js +222 -49
  19. package/dist/commands/cmdGuard.js +1 -1
  20. package/dist/commands/daemon.js +14 -0
  21. package/dist/commands/deterministicGuard.d.ts +8 -2
  22. package/dist/commands/deterministicGuard.js +219 -55
  23. package/dist/commands/discover.js +37 -14
  24. package/dist/commands/discoverAgentFiles.d.ts +2 -0
  25. package/dist/commands/discoverAgentFiles.js +1 -0
  26. package/dist/commands/discoverPaths.d.ts +4 -0
  27. package/dist/commands/discoverPaths.js +19 -1
  28. package/dist/commands/discoverProjectRoots.d.ts +72 -0
  29. package/dist/commands/discoverProjectRoots.js +327 -0
  30. package/dist/commands/discoverProxy.d.ts +1 -1
  31. package/dist/commands/discoverProxy.js +1 -1
  32. package/dist/commands/discoverSecrets.d.ts +44 -0
  33. package/dist/commands/discoverSecrets.js +158 -14
  34. package/dist/commands/hook.js +10 -7
  35. package/dist/commands/mcpGateway.js +9 -7
  36. package/dist/commands/posixShellGuard.js +1 -1
  37. package/dist/commands/shellGuard.js +70 -25
  38. package/dist/commands/uninstallAll.d.ts +1 -7
  39. package/dist/commands/uninstallAll.js +6 -45
  40. package/dist/config.d.ts +1 -1
  41. package/dist/config.js +3 -3
  42. package/dist/credentialGrant.d.ts +48 -0
  43. package/dist/credentialGrant.js +29 -18
  44. package/dist/credentialStore.js +1 -1
  45. package/dist/devConfirm.d.ts +44 -3
  46. package/dist/devConfirm.js +166 -30
  47. package/dist/distress.d.ts +2 -0
  48. package/dist/distress.js +2 -0
  49. package/dist/grantTargets.d.ts +21 -0
  50. package/dist/grantTargets.js +235 -0
  51. package/dist/localSafetySnapshot.js +2 -0
  52. package/dist/msiProductCode.d.ts +10 -0
  53. package/dist/msiProductCode.js +124 -0
  54. package/dist/notify.js +1 -0
  55. package/dist/selfTest.d.ts +1 -1
  56. package/dist/selfTest.js +1 -1
  57. package/dist/selfUpdate.d.ts +51 -2
  58. package/dist/selfUpdate.js +217 -19
  59. package/dist/shellAst.d.ts +2 -0
  60. package/dist/shellAst.js +7 -1
  61. package/dist/shellAstLoader.js +12 -1
  62. package/dist/version.json +1 -1
  63. package/package.json +6 -1
@@ -0,0 +1,81 @@
1
+ export type EnvironmentTag = 'production' | 'staging' | 'dev';
2
+ /** Facts the ask envelope carries besides the reason text (all optional: the MCP gateway has none). */
3
+ export interface AskSubject {
4
+ /** Hook event: shell | mcp | file | read. */
5
+ event?: string;
6
+ /** Engine operation of the action (read / write / DELETE / SHELL …). */
7
+ operation?: string;
8
+ /** Grant scope cloud entries, `gcloud|<how>|<where>`. */
9
+ cloud?: string[];
10
+ /** Host the action reaches (engine `destination.domain`). */
11
+ destinationDomain?: string;
12
+ /** Hosts a credential line sends to (credential binding). */
13
+ credentialHosts?: string[];
14
+ /** Rule id: `local-safety:<item>` or the action policy id. */
15
+ ruleId?: string;
16
+ /** Why no "Always allow" when the scope itself did not refuse it. */
17
+ alwaysOff?: 'rule' | 'org' | 'no_rule';
18
+ /** The client the hook records as `agentClient` on the runtime event (cursor, claude-code, …). */
19
+ agentClient?: string;
20
+ }
21
+ export interface AskHeaderInput {
22
+ /** The full action text: the script, else the "What" line. */
23
+ command?: string;
24
+ /** "shell command", "file edit", "MCP tool call" … */
25
+ kind?: string;
26
+ /** Tool line (MCP gateway). */
27
+ tool?: string;
28
+ /** The rule by name. */
29
+ policy?: string;
30
+ /** Local Safety rule detail from the Rule line: "id x, credential_commands". */
31
+ ruleDetail?: string;
32
+ /** The `IDE:` line of the reason (MCP gateway, older hook builds). */
33
+ ide?: string;
34
+ subject?: AskSubject;
35
+ /** "Always allow" is offered; `covers` says what it would cover. */
36
+ alwaysOffered: boolean;
37
+ alwaysCovers?: string;
38
+ /** The scope's own reason for not offering it (`grantNotOfferedReason`). */
39
+ alwaysNotOffered?: string;
40
+ }
41
+ export interface AskHeader {
42
+ /** Who is asking: "Your AI agent (Claude Code) wants to run this command." */
43
+ who: string;
44
+ /** One plain sentence. */
45
+ why: string;
46
+ /** What the action would change or remove, when the line names it; absent when unreadable. */
47
+ affects?: string;
48
+ /** Removes the home folder, a whole drive or the filesystem root: drawn like production (the words say it too). */
49
+ danger?: boolean;
50
+ /** Credential rules: where the token / secret is sent ("firestore.googleapis.com" or "unknown"). */
51
+ secretSentTo?: {
52
+ label: 'Token sent to' | 'Secret sent to';
53
+ value: string;
54
+ };
55
+ /** "Command" for shell lines, "Tool" for tool calls, "Target" otherwise. */
56
+ commandLabel: string;
57
+ command: string;
58
+ action: string;
59
+ /** Where the action lands, without the tag. */
60
+ where: string;
61
+ tag?: EnvironmentTag;
62
+ /** `where` with the tag spelled out: "acme-prod · production (by name)". */
63
+ whereText: string;
64
+ always: string;
65
+ /** Which fact the sentence came from; the dialog skips repeating it under "Why it triggered". */
66
+ source: 'cloud' | 'credential' | 'operation' | 'rule';
67
+ }
68
+ /** The client id as the console labels it; undefined for a missing or placeholder id. */
69
+ export declare function agentClientLabel(raw?: string): string | undefined;
70
+ /** Name tokens of a project id → environment. Deterministic, and only a hint: the dialog says "(by name)". */
71
+ export declare function environmentTag(projectId: string): EnvironmentTag | undefined;
72
+ interface ProjectRead {
73
+ /** Literal project ids, in order of appearance, unique. */
74
+ ids: string[];
75
+ /** A project reference exists but is computed (`$p`, `$(cat p.txt)`). */
76
+ computed: boolean;
77
+ }
78
+ /** Literal project ids named on the command: `--project x` / `--project=x` and Google API `projects/<id>` paths. */
79
+ export declare function projectsInCommand(command: string): ProjectRead;
80
+ export declare function buildAskHeader(input: AskHeaderInput): AskHeader;
81
+ export {};
@@ -0,0 +1,573 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.agentClientLabel = agentClientLabel;
4
+ exports.environmentTag = environmentTag;
5
+ exports.projectsInCommand = projectsInCommand;
6
+ exports.buildAskHeader = buildAskHeader;
7
+ /**
8
+ * The header at the top of every "Ask the developer" dialog: WHO is asking, WHY
9
+ * it popped up in one sentence, and the facts behind it (Command, Action,
10
+ * Affects, Where, Token / Secret sent to, Always allow).
11
+ *
12
+ * Deterministic wording of facts the hook already has — the grant scope's cloud
13
+ * entries (`cloudGrant.ts`), the destination the engine derived, the rule that
14
+ * asked — plus literal project ids read off the command (`--project x`,
15
+ * `…googleapis.com/…/projects/<id>/…`). Nothing here decides anything; a field
16
+ * that cannot be read says "unknown". Pure: no I/O, no engine, no Node built-ins — the
17
+ * browser extension bundles `approvalPrompt.ts` (and so this module) for the browser.
18
+ */
19
+ const commandNarrative_1 = require("./commandNarrative");
20
+ const MAX_COMMAND = 90;
21
+ /** The console's client names (`frontend/src/lib/activityClient.ts`): the dialog and the console name the same agent. */
22
+ const CLIENT_LABELS = {
23
+ cursor: 'Cursor', codex: 'Codex', 'claude-code': 'Claude Code', 'claude-desktop': 'Claude Desktop', vscode: 'VS Code',
24
+ copilot: 'Copilot CLI', 'gemini-cli': 'Gemini CLI', windsurf: 'Windsurf', kiro: 'Kiro',
25
+ };
26
+ /** `canonicalAgentClient` aliases plus the console's. */
27
+ const CLIENT_ALIASES = { gemini: 'gemini-cli', 'vs-code': 'vscode', 'copilot-cli': 'copilot' };
28
+ /** The client id as the console labels it; undefined for a missing or placeholder id. */
29
+ function agentClientLabel(raw) {
30
+ const id = (raw || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
31
+ const client = CLIENT_ALIASES[id] || id;
32
+ if (!client || /^(?:unknown|other|custom|none)$/.test(client))
33
+ return undefined;
34
+ return CLIENT_LABELS[client] || (/^[a-z0-9][a-z0-9.-]{0,30}$/.test(client) ? client : undefined);
35
+ }
36
+ /** Credential rules whose secret is a token (the row says "Token sent to"). */
37
+ const TOKEN_RULES = new Set(['gcloud_access_token_cmd', 'gh_auth_token_cmd', 'az_access_token_cmd']);
38
+ const REMOVAL_PROGRAM = /^(?:sudo\s+)?(rm|remove-item|ri|del|erase|rmdir|rd)(?:\.exe)?\s/i;
39
+ /**
40
+ * What a local removal touches, from its literal targets and the engine's own
41
+ * scope decision (`operation`: a workspace-scoped removal is `write`, anything
42
+ * wider `DELETE` — `isWorkspaceScopedRemoval`). Undefined when no target is readable.
43
+ */
44
+ function localRemovalAffects(command, operation) {
45
+ const removals = segments(command).filter(s => REMOVAL_PROGRAM.test(s));
46
+ if (!removals.length)
47
+ return undefined;
48
+ const scoped = String(operation || '').toLowerCase() === 'write';
49
+ const parts = [];
50
+ let unreadable = false;
51
+ for (const segment of removals) {
52
+ for (const raw of segment.trim().split(/\s+/).slice(1)) {
53
+ if (/^(?:sudo|rm|remove-item|ri|del|erase|rmdir|rd)$/i.test(raw) || /^-/.test(raw) || /^\/[a-z]$/i.test(raw))
54
+ continue;
55
+ const t = raw.replace(/^["']|["']$/g, '').replace(/\\/g, '/');
56
+ if (/^(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/+\*?)?$/i.test(t))
57
+ parts.push('your home folder');
58
+ else if (/^\/+\*?$/.test(t))
59
+ parts.push('the whole drive (/)');
60
+ else if (/^[a-z]:(?:\/+\*?)?$/i.test(t))
61
+ parts.push(`the whole drive (${t.slice(0, 2).toUpperCase()})`);
62
+ else if (/^(?:\*|\.\/+\*)$/.test(t))
63
+ parts.push(scoped ? 'everything in the folder the script changed into' : 'everything in the current folder');
64
+ else if (/[$%`(]/.test(t))
65
+ unreadable = true;
66
+ else if (!/^(?:\/|[a-z]:\/|~)/i.test(t) && !/(?:^|\/)\.\.(?:\/|$)/.test(t))
67
+ parts.push(scoped ? `your workspace folder ${t}` : `folder ${t}`);
68
+ else
69
+ parts.push(t);
70
+ }
71
+ }
72
+ const named = unique(parts).slice(0, 3);
73
+ if (!named.length)
74
+ return undefined;
75
+ return `${named.join(', ')}${unreadable ? ' and a path it cannot read' : ''} — can't be undone`;
76
+ }
77
+ const GCLOUD_VALUED = /^--(?:project|region|zone|format|configuration|account|filter|limit|image|platform|source|port|memory|cpu|service-account|location|database|instance)$/;
78
+ const GCLOUD_SERVICE = {
79
+ run: 'Cloud Run', sql: 'Cloud SQL', functions: 'Cloud Function', compute: 'Compute Engine', firestore: 'Firestore',
80
+ secrets: 'secret', pubsub: 'Pub/Sub', container: 'GKE', storage: 'Cloud Storage', projects: 'project', artifacts: 'Artifact Registry',
81
+ };
82
+ function words(segment) {
83
+ return (segment.match(/"[^"]*"|'[^']*'|\S+/g) || []).map(w => w.replace(/^["']|["']$/g, ''));
84
+ }
85
+ function flag(ws, names) {
86
+ for (let i = 0; i < ws.length; i++) {
87
+ for (const name of names) {
88
+ if (ws[i] === name && ws[i + 1] && !ws[i + 1].startsWith('-'))
89
+ return ws[i + 1];
90
+ if (ws[i].startsWith(`${name}=`))
91
+ return ws[i].slice(name.length + 1);
92
+ }
93
+ }
94
+ return undefined;
95
+ }
96
+ /** A literal name, or undefined when it is computed. */
97
+ function literalName(value) {
98
+ return value && !/[$`(){}%]/.test(value) ? value : undefined;
99
+ }
100
+ /** The resource a cloud line names: "Cloud Run service api", "bucket acme-prod-site", "deployment api in namespace payments". */
101
+ function cloudAffects(segment, tool) {
102
+ const ws = words(segment);
103
+ const at = ws.findIndex(w => w.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.(?:exe|cmd)$/, '') === tool);
104
+ if (at < 0)
105
+ return undefined;
106
+ const argv = ws.slice(at + 1);
107
+ const bucket = argv.map(w => /^(?:gs|s3):\/\/([^/\s]+)/i.exec(w)?.[1]).find(Boolean);
108
+ if (bucket && literalName(bucket))
109
+ return `bucket ${bucket}`;
110
+ const ns = literalName(flag(argv, ['-n', '--namespace']));
111
+ const inNs = ns ? ` in namespace ${ns}` : '';
112
+ const operands = (valued) => {
113
+ const out = [];
114
+ for (let i = 0; i < argv.length; i++) {
115
+ if (argv[i].startsWith('-')) {
116
+ if (!argv[i].includes('=') && valued.test(argv[i]))
117
+ i++;
118
+ continue;
119
+ }
120
+ out.push(argv[i]);
121
+ }
122
+ return out;
123
+ };
124
+ if (tool === 'gcloud') {
125
+ const path = operands(GCLOUD_VALUED).filter(w => !/^(?:alpha|beta|preview)$/.test(w));
126
+ const service = GCLOUD_SERVICE[path[0] || ''];
127
+ const verbAt = path.findIndex(w => /^(?:delete|deploy|create|update|describe|list|set-iam-policy|add-iam-policy-binding|remove-iam-policy-binding|replace|patch|start|stop|restart|resize|rm)$/.test(w));
128
+ const name = verbAt >= 0 ? literalName(path[verbAt + 1]) : undefined;
129
+ if (!service || !name)
130
+ return undefined;
131
+ const kind = verbAt > 1 ? path[verbAt - 1].replace(/ies$/, 'y').replace(/s$/, '') : path[0] === 'run' ? 'service' : '';
132
+ return `${service}${kind && service !== kind ? ` ${kind}` : ''} ${name}`;
133
+ }
134
+ if (tool === 'kubectl' || tool === 'oc') {
135
+ const path = operands(/^(?:--context|-n|--namespace|--kubeconfig|--cluster|--user|-s|--server|--as|-f|--filename|-l|--selector|-o|--output)$/);
136
+ const file = literalName(flag(argv, ['-f', '--filename']));
137
+ if (file)
138
+ return `the manifests in ${file}${inNs}`;
139
+ const kind = literalName(path[1]);
140
+ const name = literalName(path[2]);
141
+ return kind && name ? `${kind} ${name}${inNs}` : kind ? `${kind}${inNs}` : undefined;
142
+ }
143
+ if (tool === 'helm') {
144
+ const path = operands(/^(?:--kube-context|-n|--namespace|--kubeconfig|-f|--values|--set|--version|--repo)$/);
145
+ const release = literalName(path[1]);
146
+ return release ? `release ${release}${inNs}` : undefined;
147
+ }
148
+ if (tool === 'az') {
149
+ const group = literalName(flag(argv, ['--resource-group', '-g']));
150
+ const name = literalName(flag(argv, ['--name', '-n']));
151
+ const path = operands(/^(?:--subscription|-o|--output|--query|--name|-n|--resource-group|-g)$/);
152
+ if (path[0] === 'group' && name)
153
+ return `resource group ${name}`;
154
+ return name ? `${path.slice(0, -1).join(' ') || 'resource'} ${name}${group ? ` in resource group ${group}` : ''}` : group ? `resource group ${group}` : undefined;
155
+ }
156
+ if (tool === 'terraform' || tool === 'tofu') {
157
+ const dir = literalName(argv.find(w => w.startsWith('-chdir='))?.slice(7));
158
+ return dir ? `the ${tool} resources in ${dir}` : undefined;
159
+ }
160
+ return undefined;
161
+ }
162
+ /** `cloudGrant.ts` PROJECT_ID_RE; not imported, that module reads files and the caller's env. */
163
+ const PROJECT_ID_RE = /^(?:[a-z][a-z0-9.-]*:)?[a-z0-9][a-z0-9-]{3,62}$/;
164
+ /** Credential-command rules (deterministicGuard `CREDENTIAL_COMMAND_RULES`) in plain words. */
165
+ const CREDENTIAL_ACTIONS = {
166
+ gcloud_access_token_cmd: 'Reads a cloud access token',
167
+ gcloud_secret_access_cmd: 'Reads a secret value',
168
+ gh_auth_token_cmd: 'Reads the GitHub token',
169
+ git_credential_fill: 'Reads stored git credentials',
170
+ macos_keychain_dump: 'Reads a keychain password',
171
+ kubectl_config_raw: 'Reads raw Kubernetes credentials',
172
+ aws_export_credentials: 'Reads AWS secret keys',
173
+ aws_secret_fetch_cmd: 'Reads a secret value',
174
+ az_access_token_cmd: 'Reads an Azure access token',
175
+ vault_read_cmd: 'Reads a Vault secret',
176
+ };
177
+ /** The program a credential rule is about, to find its segment when no token printer matched. */
178
+ const CREDENTIAL_PROGRAM = {
179
+ gcloud_access_token_cmd: 'gcloud', gcloud_secret_access_cmd: 'gcloud', gh_auth_token_cmd: 'gh', git_credential_fill: 'git',
180
+ macos_keychain_dump: 'security', kubectl_config_raw: 'kubectl', aws_export_credentials: 'aws', aws_secret_fetch_cmd: 'aws',
181
+ az_access_token_cmd: 'az', vault_read_cmd: 'vault',
182
+ };
183
+ /** Token printers found anywhere in a segment (`$t = (gcloud auth … print-access-token)`), for rules without an id. */
184
+ const TOKEN_PRINTER_ACTIONS = [
185
+ { re: /\bgcloud(?:\.cmd|\.exe)?\s+(?:[\w-]+\s+)*?auth\s+(?:application-default\s+)?print-access-token\b/i, action: 'Reads a cloud access token' },
186
+ { re: /\bgcloud(?:\.cmd|\.exe)?\s+(?:[\w-]+\s+)*?auth\s+print-identity-token\b/i, action: 'Reads a cloud identity token' },
187
+ { re: /\bgh(?:\.exe)?\s+auth\s+token\b/i, action: 'Reads the GitHub token' },
188
+ ];
189
+ const HOW_RANK = ['delete', 'permissions', 'protected', 'change', 'read'];
190
+ /** Name tokens of a project id → environment. Deterministic, and only a hint: the dialog says "(by name)". */
191
+ function environmentTag(projectId) {
192
+ const words = projectId.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
193
+ if (words.some(w => /^(?:prod|production|prd)$/.test(w)))
194
+ return 'production';
195
+ if (words.some(w => /^(?:staging|stage|stg)$/.test(w)))
196
+ return 'staging';
197
+ if (words.some(w => /^(?:dev|development|test|sandbox)$/.test(w)))
198
+ return 'dev';
199
+ return undefined;
200
+ }
201
+ function strongestTag(ids) {
202
+ const tags = ids.map(environmentTag);
203
+ return ['production', 'staging', 'dev'].find(tag => tags.includes(tag));
204
+ }
205
+ /**
206
+ * Statements, quote- and bracket-aware. A script block's braces separate statements
207
+ * (`try { $v = gcloud … }` yields `$v = gcloud …`); `@{ … }` / `${ … }` are data and stay whole.
208
+ */
209
+ function segments(command) {
210
+ const out = [];
211
+ let current = '';
212
+ let quote = '';
213
+ let depth = 0;
214
+ const blocks = [];
215
+ const cut = () => { out.push(current); current = ''; };
216
+ for (let i = 0; i < command.length; i++) {
217
+ const ch = command[i];
218
+ if (quote) {
219
+ current += ch;
220
+ if (ch === quote)
221
+ quote = '';
222
+ continue;
223
+ }
224
+ if (ch === '"' || ch === "'") {
225
+ quote = ch;
226
+ current += ch;
227
+ continue;
228
+ }
229
+ if (ch === '{') {
230
+ const block = depth === 0 && !/[@$]/.test(command[i - 1] || '');
231
+ blocks.push(block);
232
+ if (block) {
233
+ cut();
234
+ continue;
235
+ }
236
+ depth++;
237
+ }
238
+ else if (ch === '}') {
239
+ if (blocks.pop()) {
240
+ cut();
241
+ continue;
242
+ }
243
+ depth = Math.max(0, depth - 1);
244
+ }
245
+ else if (ch === '(' || ch === '[')
246
+ depth++;
247
+ else if (ch === ')' || ch === ']')
248
+ depth = Math.max(0, depth - 1);
249
+ if (depth === 0 && (ch === '\n' || ch === ';' || ((ch === '&' || ch === '|') && command[i + 1] === ch))) {
250
+ if (ch !== '\n' && ch !== ';')
251
+ i++;
252
+ cut();
253
+ continue;
254
+ }
255
+ current += ch;
256
+ }
257
+ cut();
258
+ return out.map(s => s.trim()).filter(Boolean);
259
+ }
260
+ /** One line; a long one keeps its head and its tail (a destination or target often comes last), cut between words. */
261
+ function oneLine(text, max = MAX_COMMAND) {
262
+ const flat = text.replace(/\s+/g, ' ').trim();
263
+ if (flat.length <= max)
264
+ return flat;
265
+ let head = flat.slice(0, Math.ceil((max - 3) * 0.6));
266
+ let tail = flat.slice(-(max - head.length - 3));
267
+ const headSpace = head.lastIndexOf(' ');
268
+ if (headSpace > head.length * 0.6)
269
+ head = head.slice(0, headSpace);
270
+ const tailSpace = tail.indexOf(' ');
271
+ if (tailSpace >= 0 && tailSpace < tail.length * 0.4 && flat[flat.length - tail.length - 1] !== ' ')
272
+ tail = tail.slice(tailSpace + 1);
273
+ return `${head.trimEnd()} … ${tail.trimStart()}`;
274
+ }
275
+ /** Literal project ids named on the command: `--project x` / `--project=x` and Google API `projects/<id>` paths. */
276
+ function projectsInCommand(command) {
277
+ const ids = [];
278
+ let computed = false;
279
+ const add = (raw) => {
280
+ const value = raw.replace(/^["']|["']$/g, '');
281
+ if (/[$`(){}%<>]/.test(value) || !PROJECT_ID_RE.test(value.toLowerCase())) {
282
+ computed = true;
283
+ return;
284
+ }
285
+ if (!ids.includes(value.toLowerCase()))
286
+ ids.push(value.toLowerCase());
287
+ };
288
+ for (const m of command.matchAll(/(?:^|\s)--project(?:=|\s+)("[^"]*"|'[^']*'|[^\s;|&]+)/g))
289
+ add(m[1]);
290
+ for (const m of command.matchAll(/https?:\/\/[a-z0-9.-]*googleapis\.com\/[^\s"'`]*?\bprojects\/([^/\s"'`?#]+)/gi))
291
+ add(m[1]);
292
+ return { ids, computed };
293
+ }
294
+ function cloudEntries(entries) {
295
+ return (entries || []).map(entry => entry.split('|')).filter(p => p.length >= 3 && p[0])
296
+ .map(([tool, how, ...rest]) => ({ tool, how, where: rest.join('|') }));
297
+ }
298
+ /** Nothing about where this use acts is known. */
299
+ function whereUnknown(entry) {
300
+ return entry.how === '?' || !entry.where || entry.where === '?';
301
+ }
302
+ /** The environment names the tag reads: gcloud project, aws profile, kube context (as `cloudEntryEnvironmentNames`). */
303
+ function environmentNames(entry) {
304
+ return entry.where.split(',').map(part => [part.slice(0, part.indexOf('=')), part.slice(part.indexOf('=') + 1)])
305
+ .filter(([key, value]) => /^(?:project|profile|context)$/.test(key) && value && value !== '?').map(([, value]) => value);
306
+ }
307
+ /** Where one use acts, in the header's words (as `cloudGrant.ts` whereWords): `acme-prod` / `profile dev, region eu-west-1` / `unknown`. */
308
+ function entryWhere(entry) {
309
+ if (whereUnknown(entry))
310
+ return 'unknown';
311
+ if (entry.tool === 'gcloud' && entry.where.startsWith('project='))
312
+ return entry.where.slice(8);
313
+ const parts = entry.where.split(',');
314
+ const words = parts.filter(part => part !== 'profile-from=default').map(part => {
315
+ const key = part.slice(0, part.indexOf('='));
316
+ const value = part.slice(part.indexOf('=') + 1);
317
+ if (value === '?')
318
+ return `unknown ${key}`;
319
+ if (value === '-')
320
+ return `no ${key === 'group' ? 'resource group' : key}`;
321
+ if (key === 'namespace' && value === '*')
322
+ return 'all namespaces';
323
+ return `${key} ${key === 'bucket' ? value.split('+').join(' + ') : value}`;
324
+ }).join(', ');
325
+ return parts.includes('profile-from=default') ? `${words} (profile from the default config)` : words;
326
+ }
327
+ /** The target in the sentence: `project acme-prod` / `aws profile dev, region eu-west-1`. */
328
+ function entryTarget(entry) {
329
+ if (whereUnknown(entry))
330
+ return undefined;
331
+ return entry.tool === 'gcloud' && entry.where.startsWith('project=') ? `project ${entry.where.slice(8)}` : `${entry.tool} ${entryWhere(entry)}`;
332
+ }
333
+ /** How a `git push` on the line is protected: forced (`--force`, `-f`, `--force-with-lease`, `+refspec`), tags / all refs, or a plain push to a protected branch. */
334
+ function gitPushKind(command) {
335
+ const pushes = segments(command).filter(s => /\bgit(?:\.exe)?\b(?:\s+-C\s+\S+|\s+-c\s+\S+|\s+--?[\w-]+(?:=\S+)?)*\s+push\b/i.test(s));
336
+ if (pushes.some(s => /\spush\b.*(?:\s(?:--force(?:-with-lease|-if-includes)?(?:=\S*)?|-[a-z]*f[a-z]*)(?=\s|$)|\s\+[^\s+]+)/i.test(s)))
337
+ return 'force';
338
+ if (pushes.some(s => /\spush\b.*\s--(?:tags|follow-tags|all|mirror)\b/i.test(s)))
339
+ return 'tags';
340
+ return 'branch';
341
+ }
342
+ function strongestHow(entries) {
343
+ const hows = entries.map(entry => entry.how);
344
+ return HOW_RANK.find(how => hows.includes(how)) || hows.find(how => how.startsWith('cmd:'))
345
+ || hows.find(how => how && how !== '?') || (hows.includes('?') ? '?' : undefined);
346
+ }
347
+ function howAction(how, tools) {
348
+ if (how === 'delete')
349
+ return 'Delete';
350
+ if (how === 'permissions')
351
+ return 'Permissions change';
352
+ if (how === 'change')
353
+ return 'Change';
354
+ if (how === 'read')
355
+ return 'Read';
356
+ if (how === 'protected')
357
+ return 'Force push or push to a protected branch';
358
+ if (how === '?')
359
+ return `Runs ${tools}`;
360
+ if (how === 'exec')
361
+ return 'Runs a remote command';
362
+ if (how.startsWith('cmd:'))
363
+ return `Runs ${tools} ${how.slice(4)}`;
364
+ return `Runs ${tools} (${how})`;
365
+ }
366
+ /** The statement the decisive fact came from: the delete, the token read, the rule's program. */
367
+ function matchedSegment(command, pick) {
368
+ return segments(command).find(pick);
369
+ }
370
+ function toolSegment(tools, how) {
371
+ const program = `\\b(?:${tools.map(t => t.replace(/[^\w-]/g, '')).join('|')})(?:\\.exe|\\.cmd)?\\b`;
372
+ if (how === 'delete')
373
+ return new RegExp(`${program}.*\\b(?:delete|rm|purge|destroy|uninstall|terminate-[\\w-]+|delete-[\\w-]+)\\b`, 'i');
374
+ if (how === 'permissions')
375
+ return new RegExp(`${program}.*(?:iam|policy|role|rbac|auth\\s+can-i)`, 'i');
376
+ return new RegExp(program, 'i');
377
+ }
378
+ function ruleItem(subject, detail, policy) {
379
+ const fromId = subject?.ruleId?.startsWith('local-safety:') ? subject.ruleId.slice('local-safety:'.length) : undefined;
380
+ const fromDetail = detail ? /\bid ([a-z][a-z0-9_]*)/.exec(detail)?.[1] : undefined;
381
+ const items = (fromId || fromDetail || policy || '').split('+').map(s => s.trim());
382
+ return items.find(item => CREDENTIAL_ACTIONS[item]) || items[0] || undefined;
383
+ }
384
+ function operationAction(operation) {
385
+ const op = String(operation || '').toLowerCase();
386
+ if (/^(?:delete|destroy|drop|remove)$/.test(op))
387
+ return 'Delete';
388
+ if (/^(?:write|update|create|send|execute_write|modify|restart)$/.test(op))
389
+ return 'Change';
390
+ if (/^(?:read|list|get|search)$/.test(op))
391
+ return 'Read';
392
+ return undefined;
393
+ }
394
+ function nounOf(kind, event) {
395
+ if (kind === 'shell command' || kind === 'terminal command' || event === 'shell')
396
+ return 'command';
397
+ if (kind)
398
+ return kind;
399
+ if (event === 'mcp')
400
+ return 'MCP tool call';
401
+ return 'action';
402
+ }
403
+ /** `grantNotOfferedReason` ("Always allow is not offered for gcloud deletes.") → the row's own words. */
404
+ function alwaysNotOfferedRow(reason) {
405
+ if (/^Always allow is not offered for .+ deletes\.$/.test(reason))
406
+ return 'Not offered for deletes — each delete is confirmed.';
407
+ if (/^Always allow is not offered for .+ permission changes\.$/.test(reason))
408
+ return 'Not offered for permission changes — each one is confirmed.';
409
+ const needs = /^Always allow needs the (.+) on the command: (add .+)\.$/.exec(reason);
410
+ if (needs)
411
+ return `Not offered: the ${needs[1]} ${/ and /.test(needs[1]) ? 'are' : 'is'} unknown — ${needs[2]}.`;
412
+ return reason.replace(/^Always allow is not offered:?\s*/i, 'Not offered: ').replace(/^Not offered: for /, 'Not offered for ')
413
+ .replace(/^Always allow needs /, 'Not offered: needs ');
414
+ }
415
+ /** The `add --project` / `git push <remote> <branch>` hint the scope's refusal already names. */
416
+ function addHint(reason, tools) {
417
+ const named = reason && /^Always allow needs /.test(reason) ? /: ([^:]+)\.$/.exec(reason)?.[1] : undefined;
418
+ return named || (tools.length === 1 && tools[0] === 'gcloud' ? 'add --project' : undefined);
419
+ }
420
+ function alwaysRow(input) {
421
+ if (input.alwaysOffered)
422
+ return input.alwaysCovers || 'This kind of action, on this machine.';
423
+ if (input.alwaysNotOffered)
424
+ return alwaysNotOfferedRow(input.alwaysNotOffered);
425
+ const off = input.subject?.alwaysOff;
426
+ if (off === 'org')
427
+ return 'Not offered: your organization turned Always allow off.';
428
+ if (off === 'rule')
429
+ return 'Not offered: this rule asks every time.';
430
+ return 'Not offered for this rule.';
431
+ }
432
+ function projectPhrase(ids, tag) {
433
+ const named = ids.length === 1 ? `project ${ids[0]}` : `projects ${ids.join(', ')}`;
434
+ return tag === 'production' ? `${named} (production)` : named;
435
+ }
436
+ function unique(values) {
437
+ return [...new Set(values)];
438
+ }
439
+ function buildAskHeader(input) {
440
+ const subject = input.subject || {};
441
+ const command = (input.command || '').trim();
442
+ const shell = subject.event ? subject.event === 'shell' : /command$/.test(input.kind || '') || (!input.tool && Boolean(command));
443
+ const noun = nounOf(input.kind, subject.event);
444
+ const item = ruleItem(subject, input.ruleDetail, input.policy);
445
+ const credentialAction = item ? CREDENTIAL_ACTIONS[item] : undefined;
446
+ const cloud = cloudEntries(subject.cloud);
447
+ const how = cloud.length ? strongestHow(cloud) : undefined;
448
+ const decisiveEntries = how ? cloud.filter(entry => entry.how === how) : [];
449
+ const decisiveTools = unique(decisiveEntries.map(entry => entry.tool));
450
+ const printer = shell ? TOKEN_PRINTER_ACTIONS.find(p => p.re.test(command)) : undefined;
451
+ // A token read is about the token, even when the line also reaches a target not read yet (`curl` POST).
452
+ const credential = Boolean(credentialAction || (printer && (!how || how === '?')));
453
+ // Where: a credential line names the project its literal Google API URL / --project acts on;
454
+ // otherwise what the cloud entries name (each unknown part stays "unknown"), else a literal
455
+ // project on the command, else the host the action reaches.
456
+ const literal = shell && (credential || !cloud.length) ? projectsInCommand(command) : { ids: [], computed: false };
457
+ const useCloud = cloud.length > 0 && !(credential && literal.ids.length);
458
+ const cloudWheres = unique(cloud.map(entryWhere));
459
+ const names = useCloud ? unique(cloud.flatMap(environmentNames)) : literal.ids;
460
+ const host = subject.destinationDomain || (subject.credentialHosts || [])[0]
461
+ || (shell ? /https?:\/\/([a-z0-9.-]+\.[a-z]{2,})(?=[/:\s"'`]|$)/i.exec(command)?.[1]?.toLowerCase() : undefined);
462
+ const tag = names.length ? strongestTag(names) : undefined;
463
+ const hint = addHint(input.alwaysNotOffered, unique(cloud.map(entry => entry.tool)));
464
+ let where;
465
+ if (useCloud) {
466
+ const known = cloudWheres.filter(w => w !== 'unknown').slice(0, 3);
467
+ const unknown = cloudWheres.length > known.length ? `unknown${hint ? ` — ${hint}` : ''}` : '';
468
+ where = [known.join(known.some(w => w.includes(', ')) ? '; ' : ', '), unknown].filter(Boolean).join(' + ');
469
+ }
470
+ else if (literal.ids.length)
471
+ where = literal.ids.slice(0, 3).join(', ');
472
+ else if (literal.computed)
473
+ where = 'unknown — add --project';
474
+ else
475
+ where = host || (subject.event === 'mcp' || input.tool ? 'unknown' : 'this machine');
476
+ const whereText = tag ? `${where} · ${tag} (by name)` : where;
477
+ const removal = shell && !credential && !how ? localRemovalAffects(command, subject.operation) : undefined;
478
+ let source;
479
+ let action;
480
+ let why;
481
+ if (credential) {
482
+ source = 'credential';
483
+ action = credentialAction || printer.action;
484
+ why = `This ${noun} ${action.charAt(0).toLowerCase()}${action.slice(1)}.`;
485
+ }
486
+ else if (how) {
487
+ source = 'cloud';
488
+ const tools = decisiveTools.join(', ');
489
+ action = howAction(how, tools);
490
+ const targets = unique(decisiveEntries.map(entryTarget).filter((t) => Boolean(t))).slice(0, 3);
491
+ const anyUnknown = decisiveEntries.some(whereUnknown);
492
+ const named = targets.length ? `${targets.join('; ')}${anyUnknown ? ' and one it does not name' : ''}${tag === 'production' ? ' (production)' : ''}`
493
+ : decisiveTools.length === 1 && decisiveTools[0] === 'gcloud' ? 'a project it does not name' : 'an environment it does not name';
494
+ // A protected git push: FORCE only when the command forces (the entry cannot tell a force push from a push to main).
495
+ const push = how === 'protected' ? gitPushKind(command) : undefined;
496
+ if (push)
497
+ action = push === 'force' ? 'Force push' : push === 'tags' ? 'Push tags or every branch' : 'Push to a protected branch';
498
+ const branch = decisiveEntries.map(e => /(?:^|,)branch=([^,]+)/.exec(e.where)?.[1]).find(b => b && b !== '?');
499
+ const remote = decisiveEntries.map(e => /(?:^|,)remote=([^,]+)/.exec(e.where)?.[1]).find(r => r && r !== '?');
500
+ const verbs = {
501
+ '?': `runs ${tools}; where it acts could not be read`,
502
+ delete: `DELETES in ${named}`,
503
+ permissions: `CHANGES PERMISSIONS in ${named}`,
504
+ protected: push === 'force' ? `FORCE-PUSHES to ${named}`
505
+ : push === 'tags' ? `pushes tags or every branch to ${named}`
506
+ : `pushes to ${branch || 'a protected branch'}${remote ? ` in git remote ${remote}` : ''}`,
507
+ change: `changes ${named}`,
508
+ read: `reads ${named}`,
509
+ exec: `runs a command on ${named}`,
510
+ };
511
+ const verb = verbs[how] || (how.startsWith('cmd:') ? `runs \`${tools} ${how.slice(4)}\` in ${named}` : `runs ${tools} (${how}) in ${named}`);
512
+ why = `This ${noun} ${verb}.`;
513
+ }
514
+ else if (removal) {
515
+ // A local removal: no environment (Where stays "this machine"); what it removes is the fact.
516
+ source = 'operation';
517
+ action = 'Delete';
518
+ why = `This ${noun} DELETES ${removal.replace(/ — can't be undone$/, '')}.`;
519
+ }
520
+ else if (operationAction(subject.operation)) {
521
+ source = 'operation';
522
+ action = operationAction(subject.operation);
523
+ const target = literal.ids.length ? ` ${projectPhrase(literal.ids, tag)}` : host ? ` ${host}` : '';
524
+ why = action === 'Delete' ? `This ${noun} DELETES${target ? ` in${target}` : ''}.`
525
+ : action === 'Change' ? `This ${noun} changes or sends data${target ? ` to${target}` : ''}.`
526
+ : `This ${noun} reads${target || ' data'}.`;
527
+ }
528
+ else {
529
+ source = 'rule';
530
+ action = /credential/i.test(input.ruleDetail || '') ? 'Credential command'
531
+ : input.kind ? `${input.kind.charAt(0).toUpperCase()}${input.kind.slice(1)}`
532
+ : input.tool ? 'Tool call' : 'Action';
533
+ why = input.policy ? `Rule "${oneLine(input.policy, 80)}" asks before this ${noun} runs.` : `A policy asks before this ${noun} runs.`;
534
+ }
535
+ const decisive = !shell ? undefined
536
+ : source === 'credential' ? matchedSegment(command, s => TOKEN_PRINTER_ACTIONS.some(p => p.re.test(s)) || (item ? new RegExp(`\\b${(CREDENTIAL_PROGRAM[item] || '\\S')}`, 'i').test(s) : false))
537
+ : source === 'cloud' ? (matchedSegment(command, s => toolSegment(decisiveTools, how).test(s) && !/\bgcloud\s+(?:auth|config)\b/i.test(s))
538
+ || matchedSegment(command, s => toolSegment(decisiveTools, 'any').test(s)))
539
+ : undefined;
540
+ const shown = decisive || (shell ? segments(command)[0] : input.tool || command) || 'unknown';
541
+ const affects = removal || (source === 'cloud' && decisive ? cloudAffects(decisive, decisiveTools[0]) : undefined);
542
+ // Where the secret goes: the credential binding's hosts (what Always allow binds), else the
543
+ // hosts the step list follows the token to through literal variables (`tokenDestinations`).
544
+ let secretSentTo;
545
+ let always = alwaysRow(input);
546
+ if (credential) {
547
+ const bound = subject.credentialHosts || [];
548
+ const followed = bound.length ? [] : (0, commandNarrative_1.tokenDestinations)(command);
549
+ const label = (item && TOKEN_RULES.has(item)) || (!credentialAction && printer) ? 'Token sent to' : 'Secret sent to';
550
+ secretSentTo = { label, value: [...bound, ...followed].slice(0, 3).join(', ') || 'unknown' };
551
+ // A destination IS readable, but another step on the line is not: say that, not "could not be read".
552
+ if ((bound.length || followed.length) && /where the credential goes could not be read/.test(input.alwaysNotOffered || '')) {
553
+ always = `Not offered: another step on this line could send the ${label === 'Token sent to' ? 'token' : 'secret'} somewhere the check cannot read.`;
554
+ }
555
+ }
556
+ const agent = agentClientLabel(subject.agentClient || input.ide);
557
+ const act = shell ? 'run this command' : input.tool || subject.event === 'mcp' ? 'call this tool' : /file/.test(input.kind || subject.event || '') ? 'change this file' : 'take this action';
558
+ return {
559
+ who: agent ? `Your AI agent (${agent}) wants to ${act}.` : `An unknown agent wants to ${act}.`,
560
+ why,
561
+ commandLabel: shell ? 'Command' : input.tool ? 'Tool' : 'Target',
562
+ command: oneLine(shown),
563
+ action,
564
+ ...(affects ? { affects } : {}),
565
+ ...(removal && /your home folder|the whole drive/.test(removal) ? { danger: true } : {}),
566
+ where,
567
+ ...(tag ? { tag } : {}),
568
+ whereText,
569
+ ...(secretSentTo ? { secretSentTo } : {}),
570
+ always,
571
+ source,
572
+ };
573
+ }