fullcourtdefense-cli 1.35.10 → 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.
- package/dist/actionPolicyEngine.d.ts +24 -0
- package/dist/actionPolicyEngine.js +79 -19
- package/dist/approvalPrompt.d.ts +15 -0
- package/dist/approvalPrompt.js +157 -20
- package/dist/askDialog.d.ts +2 -0
- package/dist/askDialog.js +15 -1
- package/dist/askHeader.d.ts +81 -0
- package/dist/askHeader.js +573 -0
- package/dist/blockExplanation.d.ts +9 -0
- package/dist/blockExplanation.js +14 -0
- package/dist/callerEnv.d.ts +1 -1
- package/dist/callerEnv.js +14 -0
- package/dist/cloudGrant.d.ts +46 -0
- package/dist/cloudGrant.js +420 -0
- package/dist/cloudGrantTools.d.ts +97 -0
- package/dist/cloudGrantTools.js +1112 -0
- package/dist/commandNarrative.d.ts +8 -0
- package/dist/commandNarrative.js +222 -49
- package/dist/commands/cmdGuard.js +3 -1
- package/dist/commands/daemon.js +56 -2
- package/dist/commands/deterministicGuard.d.ts +70 -4
- package/dist/commands/deterministicGuard.js +494 -63
- package/dist/commands/discover.js +50 -16
- package/dist/commands/discoverAgentFiles.d.ts +2 -0
- package/dist/commands/discoverAgentFiles.js +1 -0
- package/dist/commands/discoverPaths.d.ts +4 -0
- package/dist/commands/discoverPaths.js +19 -1
- package/dist/commands/discoverProjectRoots.d.ts +72 -0
- package/dist/commands/discoverProjectRoots.js +327 -0
- package/dist/commands/discoverProxy.d.ts +1 -1
- package/dist/commands/discoverProxy.js +1 -1
- package/dist/commands/discoverSecrets.d.ts +44 -0
- package/dist/commands/discoverSecrets.js +158 -14
- package/dist/commands/hook.js +25 -10
- package/dist/commands/knownMcpServers.d.ts +8 -2
- package/dist/commands/knownMcpServers.js +33 -2
- package/dist/commands/mcpGateway.js +9 -7
- package/dist/commands/posixShellGuard.js +1 -1
- package/dist/commands/shellGuard.js +86 -26
- package/dist/commands/uninstallAll.d.ts +1 -7
- package/dist/commands/uninstallAll.js +6 -45
- package/dist/commands/watchdog.js +17 -3
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -3
- package/dist/credentialGrant.d.ts +48 -0
- package/dist/credentialGrant.js +29 -18
- package/dist/credentialStore.js +1 -1
- package/dist/devConfirm.d.ts +51 -3
- package/dist/devConfirm.js +226 -33
- package/dist/distress.d.ts +2 -0
- package/dist/distress.js +2 -0
- package/dist/grantTargets.d.ts +21 -0
- package/dist/grantTargets.js +235 -0
- package/dist/localSafetySnapshot.js +2 -0
- package/dist/msiProductCode.d.ts +10 -0
- package/dist/msiProductCode.js +124 -0
- package/dist/notify.js +1 -0
- package/dist/selfTest.d.ts +1 -1
- package/dist/selfTest.js +1 -1
- package/dist/selfUpdate.d.ts +108 -0
- package/dist/selfUpdate.js +353 -18
- package/dist/shellAst.d.ts +2 -0
- package/dist/shellAst.js +7 -1
- package/dist/shellAstLoader.js +14 -1
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.js +1 -0
- package/dist/version.json +1 -1
- package/installer/macos/guardian/ai.fullcourtdefense.guardian.plist +19 -0
- package/installer/macos/guardian/fcd-guardian.sh +590 -0
- package/installer/macos/guardian/install-guardian.sh +32 -0
- package/installer/macos/guardian/uninstall-guardian.sh +12 -0
- package/installer/windows/README.md +88 -0
- package/installer/windows/rescue/FullCourtDefense-Rescue.ps1 +229 -0
- package/package.json +11 -1
|
@@ -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
|
+
}
|
|
@@ -85,6 +85,15 @@ export interface BlockExplanation {
|
|
|
85
85
|
/** Single paragraph for the agent: stop, do not retry, relay the message. */
|
|
86
86
|
agentMessage: string;
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The reason recorded for an action that RAN (monitor / shadow, or a Warn-only rule).
|
|
90
|
+
* Guard findings are phrased as enforced ("Blocked recursive force delete …"); a
|
|
91
|
+
* record of something that was not stopped must never say "Blocked".
|
|
92
|
+
* would_block -> "Would block: recursive force delete …" (monitor / shadow)
|
|
93
|
+
* would_ask -> "Would ask: …" (monitor / shadow, Ask rule)
|
|
94
|
+
* warned -> "Recorded: …" (rule set to Warn only)
|
|
95
|
+
*/
|
|
96
|
+
export declare function recordedReason(reason: string, kind: 'would_block' | 'would_ask' | 'warned'): string;
|
|
88
97
|
/** The stopped action in the developer's own terms — command, path, or tool call. */
|
|
89
98
|
export declare function summarizeAction(event: string, toolName: string, args?: Record<string, unknown>): string;
|
|
90
99
|
export declare function buildBlockExplanation(input: BlockExplanationInput): BlockExplanation;
|
package/dist/blockExplanation.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* tested byte-for-byte by `scripts/test-block-explanation.js`.
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.recordedReason = recordedReason;
|
|
19
20
|
exports.summarizeAction = summarizeAction;
|
|
20
21
|
exports.buildBlockExplanation = buildBlockExplanation;
|
|
21
22
|
exports.oneLineExplanation = oneLineExplanation;
|
|
@@ -35,6 +36,19 @@ function shorten(text, max = MAX_WHAT) {
|
|
|
35
36
|
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
36
37
|
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The reason recorded for an action that RAN (monitor / shadow, or a Warn-only rule).
|
|
41
|
+
* Guard findings are phrased as enforced ("Blocked recursive force delete …"); a
|
|
42
|
+
* record of something that was not stopped must never say "Blocked".
|
|
43
|
+
* would_block -> "Would block: recursive force delete …" (monitor / shadow)
|
|
44
|
+
* would_ask -> "Would ask: …" (monitor / shadow, Ask rule)
|
|
45
|
+
* warned -> "Recorded: …" (rule set to Warn only)
|
|
46
|
+
*/
|
|
47
|
+
function recordedReason(reason, kind) {
|
|
48
|
+
const fact = reason.trim().replace(/^(?:Blocked|Matched)\s+/, '');
|
|
49
|
+
const lead = kind === 'would_block' ? 'Would block' : kind === 'would_ask' ? 'Would ask' : 'Recorded';
|
|
50
|
+
return `${lead}: ${fact}`;
|
|
51
|
+
}
|
|
38
52
|
/** The stopped action in the developer's own terms — command, path, or tool call. */
|
|
39
53
|
function summarizeAction(event, toolName, args = {}) {
|
|
40
54
|
const command = str(args.command) || str(args.cmd) || str(args.commandLine) || str(args.script);
|
package/dist/callerEnv.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Node built-ins only: this leaf is on the slim hook's warm-path module graph.
|
|
13
13
|
*/
|
|
14
|
-
export declare const CALLER_ENV_KEYS: readonly ["FCD_HUMAN_PRESENT", "FCD_EPHEMERAL", "FULLCOURTDEFENSE_EPHEMERAL", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "JENKINS_URL", "BUILDKITE", "CIRCLECI", "DISPLAY", "WAYLAND_DISPLAY", "FCD_ASK_CHANNEL", "FCD_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "FCD_RUN_ID", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "FCD_WORKLOAD_INSTANCE_ID", "CLAUDECODE", "CLAUDE_PROJECT_DIR", "VSCODE_PID", "TERM_PROGRAM", "WORKSPACE_PATH", "FCD_HOOK_IDE_TIMEOUT_MS", "FCD_TAINT_APPROVE_ONCE_DISABLED", "FCD_TAINT_APPROVAL_TIMEOUT_MS"];
|
|
14
|
+
export declare const CALLER_ENV_KEYS: readonly ["FCD_HUMAN_PRESENT", "FCD_EPHEMERAL", "FULLCOURTDEFENSE_EPHEMERAL", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "JENKINS_URL", "BUILDKITE", "CIRCLECI", "DISPLAY", "WAYLAND_DISPLAY", "FCD_ASK_CHANNEL", "FCD_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "FCD_RUN_ID", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "FCD_WORKLOAD_INSTANCE_ID", "CLAUDECODE", "CLAUDE_PROJECT_DIR", "VSCODE_PID", "TERM_PROGRAM", "WORKSPACE_PATH", "FCD_HOOK_IDE_TIMEOUT_MS", "FCD_TAINT_APPROVE_ONCE_DISABLED", "FCD_TAINT_APPROVAL_TIMEOUT_MS", "CLOUDSDK_CORE_PROJECT", "CLOUDSDK_ACTIVE_CONFIG_NAME", "CLOUDSDK_CONFIG", "APPDATA", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_ACCESS_KEY_ID", "KUBECONFIG", "HELM_KUBECONTEXT", "HELM_NAMESPACE", "HELM_KUBEAPISERVER", "HELM_KUBETOKEN", "HELM_KUBEASUSER", "DOCKER_HOST", "DOCKER_CONTEXT", "AZURE_CONFIG_DIR", "AZURE_DEFAULTS_GROUP", "TF_WORKSPACE", "TF_DATA_DIR", "PGHOST", "PGDATABASE", "PGSERVICE", "MYSQL_HOST"];
|
|
15
15
|
export type CallerEnvSnapshot = Partial<Record<(typeof CALLER_ENV_KEYS)[number], string>>;
|
|
16
16
|
/** The keys above that are set in `env` — what the thin client sends. */
|
|
17
17
|
export declare function snapshotCallerEnv(env?: NodeJS.ProcessEnv): CallerEnvSnapshot;
|
package/dist/callerEnv.js
CHANGED
|
@@ -34,6 +34,20 @@ exports.CALLER_ENV_KEYS = [
|
|
|
34
34
|
'CLAUDECODE', 'CLAUDE_PROJECT_DIR', 'VSCODE_PID', 'TERM_PROGRAM', 'WORKSPACE_PATH',
|
|
35
35
|
// Human-wait budget and approve-once switches.
|
|
36
36
|
'FCD_HOOK_IDE_TIMEOUT_MS', 'FCD_TAINT_APPROVE_ONCE_DISABLED', 'FCD_TAINT_APPROVAL_TIMEOUT_MS',
|
|
37
|
+
// Which GCP project a gcloud line targets when it names none (cloudGrant.activeGcloudProject).
|
|
38
|
+
'CLOUDSDK_CORE_PROJECT', 'CLOUDSDK_ACTIVE_CONFIG_NAME', 'CLOUDSDK_CONFIG', 'APPDATA',
|
|
39
|
+
// Which aws profile / region and which kube context an aws / kubectl / helm line targets
|
|
40
|
+
// (cloudGrantTools). AWS_ACCESS_KEY_ID only says static keys override every profile.
|
|
41
|
+
'AWS_PROFILE', 'AWS_DEFAULT_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_ACCESS_KEY_ID',
|
|
42
|
+
'KUBECONFIG', 'HELM_KUBECONTEXT', 'HELM_NAMESPACE', 'HELM_KUBEAPISERVER', 'HELM_KUBETOKEN', 'HELM_KUBEASUSER',
|
|
43
|
+
// A docker line talks to a remote daemon (grantTargets).
|
|
44
|
+
'DOCKER_HOST', 'DOCKER_CONTEXT',
|
|
45
|
+
// Which az subscription / resource group an az line falls back to (cloudGrantTools).
|
|
46
|
+
'AZURE_CONFIG_DIR', 'AZURE_DEFAULTS_GROUP',
|
|
47
|
+
// Which terraform workspace a terraform / tofu line runs in (cloudGrantTools).
|
|
48
|
+
'TF_WORKSPACE', 'TF_DATA_DIR',
|
|
49
|
+
// Which database host / name a psql / mysql line falls back to (cloudGrantTools).
|
|
50
|
+
'PGHOST', 'PGDATABASE', 'PGSERVICE', 'MYSQL_HOST',
|
|
37
51
|
];
|
|
38
52
|
/** The keys above that are set in `env` — what the thin client sends. */
|
|
39
53
|
function snapshotCallerEnv(env = process.env) {
|