fullcourtdefense-cli 1.35.7 → 1.35.9

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 (41) hide show
  1. package/dist/actionIdentity.js +2 -1
  2. package/dist/actionPolicyEngine.d.ts +38 -0
  3. package/dist/actionPolicyEngine.js +383 -24
  4. package/dist/approvalPrompt.js +2 -2
  5. package/dist/askDialog.js +5 -4
  6. package/dist/blockExplanation.js +2 -0
  7. package/dist/callerEnv.d.ts +33 -0
  8. package/dist/callerEnv.js +90 -0
  9. package/dist/commandNarrative.js +111 -4
  10. package/dist/commands/daemon.js +1 -1
  11. package/dist/commands/deterministicGuard.d.ts +32 -0
  12. package/dist/commands/deterministicGuard.js +214 -19
  13. package/dist/commands/hook.d.ts +34 -1
  14. package/dist/commands/hook.js +188 -31
  15. package/dist/commands/mcpGateway.js +2 -0
  16. package/dist/commands/taintLedger.js +3 -1
  17. package/dist/commands/workloadProtect.js +6 -2
  18. package/dist/devConfirm.d.ts +28 -12
  19. package/dist/devConfirm.js +134 -48
  20. package/dist/hookSlim.d.ts +1 -1
  21. package/dist/hookSlim.js +22 -2
  22. package/dist/nativeHook.d.ts +2 -1
  23. package/dist/nativeHook.js +109 -3
  24. package/dist/notify.js +4 -0
  25. package/dist/shellAst.d.ts +117 -0
  26. package/dist/shellAst.js +590 -0
  27. package/dist/shellAstLoader.d.ts +32 -0
  28. package/dist/shellAstLoader.js +271 -0
  29. package/dist/telemetry.d.ts +20 -0
  30. package/dist/telemetry.js +7 -0
  31. package/dist/vendor/tree-sitter/LICENSE.tree-sitter-bash +21 -0
  32. package/dist/vendor/tree-sitter/LICENSE.web-tree-sitter +21 -0
  33. package/dist/vendor/tree-sitter/VERSIONS.json +4 -0
  34. package/dist/vendor/tree-sitter/tree-sitter-bash.wasm +0 -0
  35. package/dist/vendor/tree-sitter/tree-sitter.cjs +4031 -0
  36. package/dist/vendor/tree-sitter/tree-sitter.wasm +0 -0
  37. package/dist/verdictIpc.js +5 -2
  38. package/dist/verdictIpcClient.d.ts +11 -1
  39. package/dist/verdictIpcClient.js +12 -2
  40. package/dist/version.json +1 -1
  41. package/package.json +9 -6
package/dist/askDialog.js CHANGED
@@ -31,13 +31,14 @@ exports.resolveAskWithDialog = resolveAskWithDialog;
31
31
  const devConfirm_1 = require("./devConfirm");
32
32
  const telemetry_1 = require("./telemetry");
33
33
  const hookIo_1 = require("./hookIo");
34
+ const callerEnv_1 = require("./callerEnv");
34
35
  /** Field on the verdict JSON that carries the envelope. Stripped before the IDE sees the verdict. */
35
36
  exports.ASK_ENVELOPE_FIELD = 'fcdAsk';
36
37
  /** Default wait for the person. Cursor's hook window is 90 s, Claude's 60 s by default; stay inside both. */
37
38
  exports.ASK_DIALOG_TIMEOUT_MS = 55_000;
38
39
  /** Which channel asks the developer. `dialog` (default) = our OS dialog, `ide` = the IDE's own card. */
39
40
  function askChannel() {
40
- return (process.env.FCD_ASK_CHANNEL || '').trim().toLowerCase() === 'ide' ? 'ide' : 'dialog';
41
+ return ((0, callerEnv_1.callerEnv)().FCD_ASK_CHANNEL || '').trim().toLowerCase() === 'ide' ? 'ide' : 'dialog';
41
42
  }
42
43
  /** Attach the envelope to a decision object (core side). */
43
44
  function attachAskEnvelope(decision, envelope) {
@@ -142,7 +143,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
142
143
  const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
143
144
  const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits)
144
145
  && (envelope.scope.event !== 'shell' || (envelope.scope.programs || []).length > 0);
145
- const scopeLabel = allowAlways ? (0, devConfirm_1.describeGrantScope)(envelope.scope) : undefined;
146
+ const scopeLabel = allowAlways ? (0, devConfirm_1.describeGrantScope)(envelope.scope, { policyId: envelope.policyId, policyName: envelope.policyName }) : undefined;
146
147
  const limitsLabel = allowAlways && grantLimits ? (0, devConfirm_1.describeGrantLimits)(grantLimits) : undefined;
147
148
  const dialog = options.dialog || devConfirm_1.confirmNativeActionDetailed;
148
149
  const reason = options.clientLabel ? `IDE: ${options.clientLabel}\n${envelope.reason}` : envelope.reason;
@@ -172,7 +173,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
172
173
  toolName: envelope.toolName,
173
174
  operation: envelope.operation,
174
175
  reason: granted
175
- ? `developer chose Always allow in the FullCourtDefense dialog — ${scopeLabel} (${limitsLabel}, this rule)${policyLabel}`
176
+ ? `developer chose Always allow in the FullCourtDefense dialog — ${scopeLabel} (${limitsLabel})${policyLabel}`
176
177
  : `developer confirmed in the FullCourtDefense dialog${policyLabel}`,
177
178
  ruleId: envelope.policyId,
178
179
  policyHash: envelope.policyHash,
@@ -182,7 +183,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
182
183
  });
183
184
  (0, telemetry_1.triggerFlush)(false);
184
185
  const agentMsg = granted
185
- ? `FullCourtDefense: the developer allowed this ${envelope.event} and chose "Always allow" for ${scopeLabel} (${limitsLabel}) under this rule.`
186
+ ? `FullCourtDefense: the developer allowed this ${envelope.event} and chose "Always allow" for ${scopeLabel} (${limitsLabel}).`
186
187
  : `FullCourtDefense: the developer allowed this ${envelope.event} once in the FullCourtDefense dialog.`;
187
188
  return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, true, '', agentMsg)), exitCode: 0, resolution: outcome };
188
189
  }
@@ -84,6 +84,8 @@ function scopeWords(scope, developerName) {
84
84
  return '🌐 company default — applies to every machine';
85
85
  if (scope === 'machine' || scope === 'developer' || scope === 'local')
86
86
  return `📍 local rule — only ${developerName || 'this machine'}`;
87
+ if (scope === 'shield')
88
+ return '📍 shield rule — only the machines on the shield it was saved on, or the ones it names';
87
89
  return `${scope} policy`;
88
90
  }
89
91
  const FACT_WORDS = [
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The environment of the process that ASKED for a verdict (#1466).
3
+ *
4
+ * A hook answered by the resident daemon is evaluated inside the daemon's
5
+ * process, whose own environment belongs to a scheduled task — not to the IDE
6
+ * session that spawned the hook. Every fact the decision reads from the
7
+ * environment (is a human present, which CI / ephemeral markers are set, the
8
+ * session identity, the IDE's hook budget) must be the CALLER's. The thin
9
+ * client sends a snapshot of exactly these keys; the daemon evaluates the
10
+ * request with them in place of its own values, per request.
11
+ *
12
+ * Node built-ins only: this leaf is on the slim hook's warm-path module graph.
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"];
15
+ export type CallerEnvSnapshot = Partial<Record<(typeof CALLER_ENV_KEYS)[number], string>>;
16
+ /** The keys above that are set in `env` — what the thin client sends. */
17
+ export declare function snapshotCallerEnv(env?: NodeJS.ProcessEnv): CallerEnvSnapshot;
18
+ /** The environment decision facts are read from: the requesting caller's inside a daemon request, else this process's. */
19
+ export declare function callerEnv(): NodeJS.ProcessEnv;
20
+ /**
21
+ * Run `fn` with the caller's snapshot in place of this process's values for
22
+ * CALLER_ENV_KEYS — a key the caller did not send is UNSET, never inherited
23
+ * from the daemon. No snapshot (older client) = this process's env, as before.
24
+ */
25
+ export declare function withCallerEnv<T>(snapshot: unknown, fn: () => T): T;
26
+ /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
27
+ export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
28
+ /**
29
+ * Whether a developer can answer an IDE permission prompt on this machine. False on CI runners
30
+ * and on ephemeral workloads (`FCD_EPHEMERAL=1`, exported by `fcd workload-protect`) — there a
31
+ * developer-scoped approval must go to the org queue or block, never self-resolve.
32
+ */
33
+ export declare function isHumanPresent(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CALLER_ENV_KEYS = void 0;
4
+ exports.snapshotCallerEnv = snapshotCallerEnv;
5
+ exports.callerEnv = callerEnv;
6
+ exports.withCallerEnv = withCallerEnv;
7
+ exports.isCiEnvironment = isCiEnvironment;
8
+ exports.isHumanPresent = isHumanPresent;
9
+ const async_hooks_1 = require("async_hooks");
10
+ /**
11
+ * The environment of the process that ASKED for a verdict (#1466).
12
+ *
13
+ * A hook answered by the resident daemon is evaluated inside the daemon's
14
+ * process, whose own environment belongs to a scheduled task — not to the IDE
15
+ * session that spawned the hook. Every fact the decision reads from the
16
+ * environment (is a human present, which CI / ephemeral markers are set, the
17
+ * session identity, the IDE's hook budget) must be the CALLER's. The thin
18
+ * client sends a snapshot of exactly these keys; the daemon evaluates the
19
+ * request with them in place of its own values, per request.
20
+ *
21
+ * Node built-ins only: this leaf is on the slim hook's warm-path module graph.
22
+ */
23
+ exports.CALLER_ENV_KEYS = [
24
+ // Human presence (isHumanPresent / isCiEnvironment).
25
+ 'FCD_HUMAN_PRESENT', 'FCD_EPHEMERAL', 'FULLCOURTDEFENSE_EPHEMERAL',
26
+ 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'TF_BUILD', 'JENKINS_URL', 'BUILDKITE', 'CIRCLECI',
27
+ // Whether a dialog can be shown in the caller's session.
28
+ 'DISPLAY', 'WAYLAND_DISPLAY', 'FCD_ASK_CHANNEL',
29
+ // Action identity (captureActionIdentity).
30
+ 'FCD_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID', 'FCD_RUN_ID', 'GITHUB_RUN_ID', 'CI_PIPELINE_ID', 'FCD_WORKLOAD_INSTANCE_ID',
31
+ // Which client / workspace the verdict is written for.
32
+ 'CLAUDECODE', 'CLAUDE_PROJECT_DIR', 'VSCODE_PID', 'TERM_PROGRAM', 'WORKSPACE_PATH',
33
+ // Human-wait budget and approve-once switches.
34
+ 'FCD_HOOK_IDE_TIMEOUT_MS', 'FCD_TAINT_APPROVE_ONCE_DISABLED', 'FCD_TAINT_APPROVAL_TIMEOUT_MS',
35
+ ];
36
+ /** The keys above that are set in `env` — what the thin client sends. */
37
+ function snapshotCallerEnv(env = process.env) {
38
+ const snapshot = {};
39
+ for (const key of exports.CALLER_ENV_KEYS) {
40
+ const value = env[key];
41
+ if (typeof value === 'string')
42
+ snapshot[key] = value;
43
+ }
44
+ return snapshot;
45
+ }
46
+ const requestEnv = new async_hooks_1.AsyncLocalStorage();
47
+ /** The environment decision facts are read from: the requesting caller's inside a daemon request, else this process's. */
48
+ function callerEnv() {
49
+ return requestEnv.getStore() ?? process.env;
50
+ }
51
+ /**
52
+ * Run `fn` with the caller's snapshot in place of this process's values for
53
+ * CALLER_ENV_KEYS — a key the caller did not send is UNSET, never inherited
54
+ * from the daemon. No snapshot (older client) = this process's env, as before.
55
+ */
56
+ function withCallerEnv(snapshot, fn) {
57
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
58
+ return fn();
59
+ const env = { ...process.env };
60
+ for (const key of exports.CALLER_ENV_KEYS) {
61
+ const value = snapshot[key];
62
+ if (typeof value === 'string')
63
+ env[key] = value;
64
+ else
65
+ delete env[key];
66
+ }
67
+ return requestEnv.run(env, fn);
68
+ }
69
+ /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
70
+ function isCiEnvironment(env = callerEnv()) {
71
+ return env.GITHUB_ACTIONS === 'true'
72
+ || env.GITLAB_CI === 'true'
73
+ || env.TF_BUILD === 'True' // Azure Pipelines
74
+ || !!env.JENKINS_URL
75
+ || !!env.BUILDKITE
76
+ || !!env.CIRCLECI
77
+ || env.CI === 'true';
78
+ }
79
+ /**
80
+ * Whether a developer can answer an IDE permission prompt on this machine. False on CI runners
81
+ * and on ephemeral workloads (`FCD_EPHEMERAL=1`, exported by `fcd workload-protect`) — there a
82
+ * developer-scoped approval must go to the org queue or block, never self-resolve.
83
+ */
84
+ function isHumanPresent(env = callerEnv()) {
85
+ if (env.FCD_EPHEMERAL === '1' || env.FCD_EPHEMERAL === 'true' || env.FULLCOURTDEFENSE_EPHEMERAL === '1')
86
+ return false;
87
+ if (env.FCD_HUMAN_PRESENT === '0' || env.FCD_HUMAN_PRESENT === 'false')
88
+ return false;
89
+ return !isCiEnvironment(env);
90
+ }
@@ -110,12 +110,12 @@ const GCLOUD_SERVICE_NAMES = {
110
110
  secrets: 'Secret Manager', iam: 'IAM', projects: 'project', functions: 'Cloud Functions', container: 'GKE', logging: 'Cloud Logging',
111
111
  artifacts: 'Artifact Registry', scheduler: 'Cloud Scheduler', tasks: 'Cloud Tasks', kms: 'Cloud KMS', builds: 'Cloud Build', auth: 'auth', config: 'config',
112
112
  };
113
- const GCLOUD_READ = /^(?:describe|list|ls|get-iam-policy|get-value|get-ancestors|read|check|versions)$/;
113
+ const GCLOUD_READ = /^(?:describe|list|ls|get-iam-policy|get-value|get-ancestors|read|check)$/;
114
114
  const GCLOUD_WRITE = /^(?:create|update|patch|replace|enable|disable|deploy|apply|start|stop|restart|resume|suspend|scale|promote|migrate|rollback|add-[a-z-]+|remove-[a-z-]+|set-[a-z-]+|unset|clear|activate|revoke|copy)$/;
115
115
  function humanVerb(verb) {
116
116
  const map = {
117
117
  describe: 'Reads details of', list: 'Lists', ls: 'Lists', 'get-iam-policy': 'Reads the access policy of', 'get-value': 'Reads the setting',
118
- read: 'Reads', check: 'Checks', versions: 'Lists versions of',
118
+ read: 'Reads', check: 'Checks',
119
119
  create: 'Creates', update: 'Updates', patch: 'Updates', replace: 'Replaces', enable: 'Enables', disable: 'Disables', deploy: 'Deploys',
120
120
  apply: 'Applies', start: 'Starts', stop: 'Stops', restart: 'Restarts', resume: 'Resumes', suspend: 'Suspends', scale: 'Scales',
121
121
  promote: 'Promotes', migrate: 'Migrates', rollback: 'Rolls back', unset: 'Unsets', clear: 'Clears', activate: 'Activates', revoke: 'Revokes', copy: 'Copies',
@@ -135,12 +135,39 @@ function humanVerb(verb) {
135
135
  return `Sets ${verb.slice(4).replace(/-/g, ' ')} on`;
136
136
  return `Runs \`${verb}\` on`;
137
137
  }
138
+ /** `gcloud secrets versions <verb>`: `access` reads the secret VALUE, `list` lists versions, `add` stores a new value. */
139
+ function describeSecretVersions(toks, verbAt, where) {
140
+ const verb = toks[verbAt] || '';
141
+ const ops = operandsAfter(toks, verbAt + 1);
142
+ const flagSecret = flagValue(toks, '--secret');
143
+ const secret = (name) => (name ? `secret ${shortValue(name, 60)}` : 'a secret');
144
+ if (verb === 'access') {
145
+ return `Reads the value of ${secret(flagSecret)} (version ${shortValue(ops[0] || 'latest', 20)}) from Secret Manager${where}`;
146
+ }
147
+ if (verb === 'list')
148
+ return `Lists the versions of ${secret(ops[0])} in Secret Manager${where}`;
149
+ if (verb === 'add') {
150
+ const dataFile = flagValue(toks, '--data-file');
151
+ return `Adds a new version to ${secret(ops[0])} in Secret Manager${dataFile ? `, value from ${dataFile === '-' ? 'standard input' : shortValue(dataFile, 60)}` : ''}${where}`;
152
+ }
153
+ const version = ops[0] ? `version ${shortValue(ops[0], 20)} of ` : '';
154
+ if (verb === 'describe')
155
+ return `Reads details of ${version}${secret(flagSecret)} in Secret Manager${where}`;
156
+ const state = { destroy: 'Destroys', disable: 'Disables', enable: 'Enables' };
157
+ if (state[verb])
158
+ return `${state[verb]} ${version}${secret(flagSecret)} in Secret Manager${where}`;
159
+ return `Runs \`gcloud secrets versions ${verb}\`${where}`;
160
+ }
138
161
  function describeGcloud(toks) {
139
162
  let i = 1;
140
163
  while (i < toks.length && toks[i].startsWith('-'))
141
164
  i++;
142
165
  if (/^(?:alpha|beta)$/.test(toks[i] || ''))
143
166
  i++;
167
+ if (toks[i] === 'secrets' && toks[i + 1] === 'versions') {
168
+ const project = flagValue(toks, '--project');
169
+ return describeSecretVersions(toks, i + 2, project ? ` in project ${project}` : '');
170
+ }
144
171
  const groups = [];
145
172
  let verb;
146
173
  for (; i < toks.length; i++) {
@@ -234,6 +261,79 @@ function describePipeTail(stages) {
234
261
  return ', and writes the output to a file';
235
262
  return '';
236
263
  }
264
+ /**
265
+ * The whole statement is one quoted string (`"len=$($t.Length)"`, `'done'`): its text
266
+ * with each `$( … )` sub-expression shown as `…`. Undefined when it is anything else.
267
+ */
268
+ function bareStringText(head) {
269
+ const value = head.trim();
270
+ const q = value[0];
271
+ if ((q !== '"' && q !== "'") || value.length < 2 || value[value.length - 1] !== q)
272
+ return undefined;
273
+ let depth = 0;
274
+ let text = '';
275
+ for (let k = 1; k < value.length - 1; k++) {
276
+ const ch = value[k];
277
+ if (q === '"' && ch === '`') {
278
+ text += value[++k] ?? '';
279
+ continue;
280
+ }
281
+ if (q === '"' && ch === '$' && value[k + 1] === '(') {
282
+ if (depth++ === 0)
283
+ text += '…';
284
+ k++;
285
+ continue;
286
+ }
287
+ if (depth > 0) {
288
+ if (ch === '(')
289
+ depth++;
290
+ else if (ch === ')')
291
+ depth--;
292
+ continue;
293
+ }
294
+ if (ch === q) {
295
+ if (q === "'" && value[k + 1] === "'") {
296
+ text += "'";
297
+ k++;
298
+ continue;
299
+ }
300
+ return undefined;
301
+ }
302
+ text += ch;
303
+ }
304
+ return depth === 0 ? text.trim() : undefined;
305
+ }
306
+ /** `((gcloud … $p) -join '').Trim()` -> `gcloud … $p`: the command inside nested groups (quote-aware). */
307
+ function peelGroups(text) {
308
+ let value = text.trim();
309
+ for (let guard = 0; guard < 8 && value.startsWith('('); guard++) {
310
+ let depth = 0;
311
+ let end = -1;
312
+ let quote = '';
313
+ for (let k = 0; k < value.length; k++) {
314
+ const ch = value[k];
315
+ if (quote) {
316
+ if (ch === quote)
317
+ quote = '';
318
+ continue;
319
+ }
320
+ if (ch === '"' || ch === "'") {
321
+ quote = ch;
322
+ continue;
323
+ }
324
+ if (ch === '(')
325
+ depth++;
326
+ else if (ch === ')' && --depth === 0) {
327
+ end = k;
328
+ break;
329
+ }
330
+ }
331
+ if (end < 0)
332
+ break;
333
+ value = value.slice(1, end).trim();
334
+ }
335
+ return value;
336
+ }
237
337
  function describeOne(segmentIn) {
238
338
  const segment = segmentIn.trim();
239
339
  const [head, ...pipe] = splitPipe(segment);
@@ -270,10 +370,14 @@ function describeOne(segmentIn) {
270
370
  }
271
371
  return step(`Sets variable ${name}${interpolates ? ' from other values' : ` to ${shortValue(bare, 60)}`}`);
272
372
  }
273
- const inner = rhs.replace(/^\(\s*/, '').replace(/\)\s*(?:\.\w+\(\))*$/, '');
373
+ const inner = rhs.startsWith('(') ? peelGroups(rhs) : rhs.replace(/\)\s*(?:\.\w+\(\))*$/, '');
274
374
  const innerStep = describeOne(inner);
275
375
  return { segment, text: shortValue(`Stores in ${name}: ${innerStep.text.charAt(0).toLowerCase()}${innerStep.text.slice(1)}${tail}`, MAX_LINE), known: innerStep.known };
276
376
  }
377
+ // A bare string statement: PowerShell writes it to the output (`"len=$($t.Length)"`).
378
+ const bare = bareStringText(head);
379
+ if (bare !== undefined)
380
+ return step(bare ? `Prints text: ${shortValue(bare, 80)}` : 'Prints an empty line');
277
381
  const toks = tokens(head);
278
382
  if (toks.length === 0)
279
383
  return step('(empty)', false);
@@ -531,7 +635,10 @@ function describeOne(segmentIn) {
531
635
  default:
532
636
  if (/^\$/.test(programToken))
533
637
  return step(pipe.length ? `Uses ${shortValue(programToken, 40)}${tail}` : `Reads variable ${shortValue(programToken, 40)}`, false);
534
- return step(`Runs \`${shortValue([program, rest[1]].filter(t => t && !t.startsWith('-')).join(' '), 50)}\``, false);
638
+ {
639
+ const shown = shortValue([program, rest[1]].filter(t => t && !t.startsWith('-')).join(' '), 50);
640
+ return step(shown ? `Runs \`${shown}\`` : 'Runs a command this dialog cannot describe', false);
641
+ }
535
642
  }
536
643
  }
537
644
  /** Split one step on top-level `|` (quote-aware). */
@@ -1593,7 +1593,7 @@ async function runDaemon(args, config) {
1593
1593
  // to full local evaluation whenever this server is down — never a blocker.
1594
1594
  let verdictServer;
1595
1595
  try {
1596
- verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)()), log);
1596
+ verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)(), { viaDaemon: true }), log);
1597
1597
  }
1598
1598
  catch (error) {
1599
1599
  log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
@@ -106,6 +106,38 @@ export interface SharedCommandRule {
106
106
  * see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
107
107
  */
108
108
  export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
109
+ export type ShellAstMode = 'off' | 'shadow' | 'primary';
110
+ /** Structural subset of shellAst.ts ShellFacts that the guard reads. */
111
+ export interface ShellAstFactsLike {
112
+ complete: boolean;
113
+ incompleteReason?: string;
114
+ dynamicExecution?: boolean;
115
+ commands: Array<{
116
+ name: string;
117
+ argv: string[];
118
+ literal: boolean[];
119
+ cwdScope: 'scoped' | 'unscoped' | 'unknown';
120
+ wrapper?: {
121
+ kind: string;
122
+ program: string;
123
+ inner?: ShellAstFactsLike;
124
+ };
125
+ }>;
126
+ }
127
+ export interface ShellAstComparison {
128
+ mode: ShellAstMode;
129
+ complete: boolean;
130
+ /** Destructive-rm item id the regex floor produced (undefined = clean). */
131
+ regex?: string;
132
+ /** Destructive-rm item id the AST produced (undefined = clean). */
133
+ ast?: string;
134
+ agree: boolean;
135
+ }
136
+ export declare function setShellAstProvider(provider: ((command: string) => ShellAstFactsLike | undefined) | undefined): void;
137
+ export declare function setShellAstMode(mode: ShellAstMode): void;
138
+ export declare function getShellAstMode(): ShellAstMode;
139
+ /** Shadow telemetry: the comparison recorded by the most recent shell scan (consumed once). */
140
+ export declare function takeShellAstComparison(): ShellAstComparison | undefined;
109
141
  /**
110
142
  * Code-only view of a line: each `'…'` / `"…"` / `` `…` `` span becomes a
111
143
  * space. Detectors that ask "does this EXECUTE" must look here; the raw line