fullcourtdefense-cli 1.35.8 → 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.
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.GRANT_MAX_USES_LIMIT = exports.GRANT_TTL_MS = exports.CONFIRMED_ACTION_TTL_MS = exports.CONFIRM_PENDING_TTL_MS = exports.DIALOG_SCRIPT_MARKER = exports.DIALOG_BUTTON_DENY = exports.DIALOG_BUTTON_ONCE = exports.DIALOG_BUTTON_ALWAYS = exports.MIN_HUMAN_ANSWER_MS = void 0;
36
+ exports.GRANT_MAX_USES_LIMIT = exports.GRANT_TTL_MS = exports.CONFIRMED_ACTION_TTL_MS = exports.CONFIRM_PENDING_TTL_MS = exports.DIALOG_SCRIPT_MARKER = exports.DIALOG_BUTTON_DENY = exports.DIALOG_BUTTON_ONCE = exports.DIALOG_BUTTON_ALWAYS = exports.MIN_HUMAN_ANSWER_MS = exports.isHumanPresent = exports.isCiEnvironment = void 0;
37
37
  exports.looksLikeClientAutoAnswer = looksLikeClientAutoAnswer;
38
38
  exports.confirmNativeAction = confirmNativeAction;
39
39
  exports.nativeDialogAvailable = nativeDialogAvailable;
@@ -41,8 +41,6 @@ exports.confirmNativeActionDetailed = confirmNativeActionDetailed;
41
41
  exports.resolveGrantLimits = resolveGrantLimits;
42
42
  exports.describeGrantLimits = describeGrantLimits;
43
43
  exports.grantRemainingUses = grantRemainingUses;
44
- exports.isCiEnvironment = isCiEnvironment;
45
- exports.isHumanPresent = isHumanPresent;
46
44
  exports.actionDigest = actionDigest;
47
45
  exports.actionPolicyRuleHash = actionPolicyRuleHash;
48
46
  exports.localSafetyRuleHash = localSafetyRuleHash;
@@ -55,6 +53,7 @@ exports.settleDeveloperConfirmation = settleDeveloperConfirmation;
55
53
  exports.rejectDeveloperConfirmation = rejectDeveloperConfirmation;
56
54
  exports.sweepExpiredConfirmations = sweepExpiredConfirmations;
57
55
  exports.shellPrograms = shellPrograms;
56
+ exports.shellProgramsForRule = shellProgramsForRule;
58
57
  exports.splitLocalSafetyRuleIds = splitLocalSafetyRuleIds;
59
58
  exports.findDeveloperGrant = findDeveloperGrant;
60
59
  exports.recordDeveloperGrant = recordDeveloperGrant;
@@ -94,6 +93,10 @@ const os = __importStar(require("os"));
94
93
  const path = __importStar(require("path"));
95
94
  const approvalPrompt_1 = require("./approvalPrompt");
96
95
  const deterministicGuard_1 = require("./commands/deterministicGuard");
96
+ const callerEnv_1 = require("./callerEnv");
97
+ var callerEnv_2 = require("./callerEnv");
98
+ Object.defineProperty(exports, "isCiEnvironment", { enumerable: true, get: function () { return callerEnv_2.isCiEnvironment; } });
99
+ Object.defineProperty(exports, "isHumanPresent", { enumerable: true, get: function () { return callerEnv_2.isHumanPresent; } });
97
100
  /**
98
101
  * Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
99
102
  * the dialog, read at least the tool name and press a button; MCP clients that
@@ -126,11 +129,12 @@ exports.DIALOG_BUTTON_DENY = 'Deny';
126
129
  exports.DIALOG_SCRIPT_MARKER = 'FullCourtDefense.Dialog';
127
130
  /** True when this process can put a dialog in front of a person on this machine. */
128
131
  function nativeDialogAvailable() {
129
- if (!isHumanPresent())
132
+ if (!(0, callerEnv_1.isHumanPresent)())
130
133
  return false;
131
134
  if (process.platform === 'win32' || process.platform === 'darwin')
132
135
  return true;
133
- return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
136
+ const env = (0, callerEnv_1.callerEnv)();
137
+ return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
134
138
  }
135
139
  function b64(value) {
136
140
  return Buffer.from(value, 'utf8').toString('base64');
@@ -430,28 +434,6 @@ function grantExhausted(grant, limits) {
430
434
  const cap = [grant.maxUses, limits?.maxUses].filter((c) => typeof c === 'number' && c >= 1);
431
435
  return cap.length > 0 && (Number(grant.uses) || 0) >= Math.min(...cap);
432
436
  }
433
- /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
434
- function isCiEnvironment(env = process.env) {
435
- return env.GITHUB_ACTIONS === 'true'
436
- || env.GITLAB_CI === 'true'
437
- || env.TF_BUILD === 'True' // Azure Pipelines
438
- || !!env.JENKINS_URL
439
- || !!env.BUILDKITE
440
- || !!env.CIRCLECI
441
- || env.CI === 'true';
442
- }
443
- /**
444
- * Whether a developer can answer an IDE permission prompt on this machine. False on CI runners
445
- * and on ephemeral workloads (`FCD_EPHEMERAL=1`, exported by `fcd workload-protect`) — there a
446
- * developer-scoped approval must go to the org queue or block, never self-resolve.
447
- */
448
- function isHumanPresent(env = process.env) {
449
- if (env.FCD_EPHEMERAL === '1' || env.FCD_EPHEMERAL === 'true' || env.FULLCOURTDEFENSE_EPHEMERAL === '1')
450
- return false;
451
- if (env.FCD_HUMAN_PRESENT === '0' || env.FCD_HUMAN_PRESENT === 'false')
452
- return false;
453
- return !isCiEnvironment(env);
454
- }
455
437
  function ledgerFile() {
456
438
  return path.join(os.homedir(), '.fullcourtdefense', 'dev-confirm.json');
457
439
  }
@@ -832,6 +814,12 @@ const SHELL_TEXT_FILTERS = new Set([
832
814
  'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'sls', 'awk', 'gawk', 'sed',
833
815
  'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'jq', 'yq', 'column', 'nl', 'more', 'less', 'cat', 'tee',
834
816
  ]);
817
+ /**
818
+ * PowerShell aliases of the pipeline / formatting cmdlets above (`| Select -Last 25`,
819
+ * `| ft`, `| measure`). Output shaping in pipeline position, like their full names.
820
+ * Piped-only: leading a statement `sort` / `where` are real programs.
821
+ */
822
+ const SHELL_PIPELINE_ALIASES = new Set(['select', 'where', 'sort', 'group', 'measure', 'compare', 'ft', 'fl', 'fw', 'tee']);
835
823
  /**
836
824
  * Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not
837
825
  * semantics. Separators inside `{ … }` do not split: a control statement's
@@ -944,6 +932,37 @@ function braceBodies(statement) {
944
932
  bodies.push(statement.slice(start));
945
933
  return bodies.map(body => body.trim()).filter(Boolean);
946
934
  }
935
+ /**
936
+ * Programs that change only local file-system structure — they cannot send
937
+ * data off the machine or run other code. Dropped from a grant only when the
938
+ * rule declares its own programs (`gcloud_secret_access_cmd` is about `gcloud`,
939
+ * not the `Remove-Item $tmp` that cleans up after it). Writers that take
940
+ * content (`New-Item -Value`, `Set-Content`, `Copy-Item` to a share) stay.
941
+ */
942
+ const SHELL_LOCAL_FILE_OPS = new Set(['remove-item', 'ri', 'rm', 'del', 'erase', 'rmdir', 'rd', 'mkdir', 'md', 'new-temporaryfile', 'mktemp']);
943
+ /** `git` subcommands that read or change the local repository only (no remote, no pager/diff driver override). */
944
+ const GIT_LOCAL_SUBCOMMANDS = new Set([
945
+ 'status', 'diff', 'log', 'show', 'add', 'commit', 'rev-parse', 'ls-files', 'branch', 'stash', 'restore', 'switch', 'checkout', 'reset',
946
+ ]);
947
+ /** git flags that run a configured command or write output elsewhere: never local-only. */
948
+ const GIT_NONLOCAL_FLAGS = /^(?:-c|--config-env\b.*|--exec-path\b.*|--output\b.*|--ext-diff|--upload-pack\b.*|--receive-pack\b.*)$/i;
949
+ function localOnlyUse(name, args) {
950
+ // A UNC path or URL operand reaches another host (`mkdir \\host\share\$v` carries $v in the name).
951
+ if (args.some(arg => /^["'(]*(?:\\\\|\/\/)|:\/\//.test(arg)))
952
+ return false;
953
+ if (SHELL_LOCAL_FILE_OPS.has(name))
954
+ return true;
955
+ if (name !== 'git' || args.some(arg => GIT_NONLOCAL_FLAGS.test(arg)))
956
+ return false;
957
+ let k = 0;
958
+ while (k < args.length && args[k].startsWith('-'))
959
+ k += args[k] === '-C' ? 2 : 1;
960
+ return GIT_LOCAL_SUBCOMMANDS.has((args[k] || '').toLowerCase());
961
+ }
962
+ /**
963
+ * `out`: program name -> true when EVERY use of it on the line is local-only
964
+ * (`localOnlyUse`). `git status; git push` is `git -> false`.
965
+ */
947
966
  function collectPrograms(command, out, depth) {
948
967
  if (depth > 6 || !command.trim())
949
968
  return;
@@ -973,7 +992,18 @@ function collectPrograms(command, out, depth) {
973
992
  i += 2;
974
993
  continue;
975
994
  }
976
- if (/^\$[\w:.]+[+-]?=/.test(w) || /^[A-Za-z_][\w]*=/.test(w)) {
995
+ // PowerShell `$v=((gcloud …` glued in one token: the right-hand side can be a
996
+ // command, so keep reading the same token after the `=`.
997
+ const glued = w.match(/^\$[\w:.]+[+-]?=/);
998
+ if (glued) {
999
+ const rhs = w.slice(glued[0].length);
1000
+ if (rhs)
1001
+ words[i] = rhs;
1002
+ else
1003
+ i++;
1004
+ continue;
1005
+ }
1006
+ if (/^[A-Za-z_][\w]*=/.test(w)) {
977
1007
  i++;
978
1008
  continue;
979
1009
  }
@@ -998,7 +1028,7 @@ function collectPrograms(command, out, depth) {
998
1028
  const name = programToken(lead);
999
1029
  if (!name || SHELL_PIPELINE_NOISE.has(name) || SHELL_INCIDENTAL.has(name))
1000
1030
  continue;
1001
- if (piped && SHELL_TEXT_FILTERS.has(name))
1031
+ if (piped && (SHELL_TEXT_FILTERS.has(name) || SHELL_PIPELINE_ALIASES.has(name)))
1002
1032
  continue;
1003
1033
  if (GENERIC_SHELLS.has(name)) {
1004
1034
  const nested = nestedShellBody(raw);
@@ -1006,7 +1036,8 @@ function collectPrograms(command, out, depth) {
1006
1036
  collectPrograms(nested, out, depth + 1);
1007
1037
  continue;
1008
1038
  }
1009
- out.add(name);
1039
+ const localOnly = localOnlyUse(name, words.slice(i + 1));
1040
+ out.set(name, (out.get(name) ?? true) && localOnly);
1010
1041
  }
1011
1042
  }
1012
1043
  /**
@@ -1021,9 +1052,24 @@ function collectPrograms(command, out, depth) {
1021
1052
  * (`out-string).trim()`) is stripped. Sorted and unique.
1022
1053
  */
1023
1054
  function shellPrograms(command) {
1024
- const out = new Set();
1055
+ const out = new Map();
1025
1056
  collectPrograms(command, out, 0);
1026
- return [...out].sort();
1057
+ return [...out.keys()].sort();
1058
+ }
1059
+ /**
1060
+ * `shellPrograms` narrowed to what a rule that declares its programs is about:
1061
+ * the rule's programs plus every other program that could move data off the
1062
+ * machine or run code (`curl`, `node`, `Invoke-RestMethod`, anything unknown).
1063
+ * Only local file-structure changes and local `git` reads/commits drop out.
1064
+ * Without declared programs, or when none of them is on the line, the full set.
1065
+ */
1066
+ function shellProgramsForRule(command, ruleCommands) {
1067
+ const out = new Map();
1068
+ collectPrograms(command, out, 0);
1069
+ const all = [...out.keys()].sort();
1070
+ if (!ruleCommands || !ruleCommands.length || !all.some(p => ruleCommands.includes(p)))
1071
+ return all;
1072
+ return all.filter(p => ruleCommands.includes(p) || !out.get(p));
1027
1073
  }
1028
1074
  /** Local Safety item ids joined as `a+b` when two ask-rules fire in one dialog. */
1029
1075
  function splitLocalSafetyRuleIds(matchedRule) {
@@ -1057,11 +1103,9 @@ function grantRuleApplies(grant, rule) {
1057
1103
  return true;
1058
1104
  if (!namesOverlap)
1059
1105
  return false;
1060
- // Per-item grant (new) vs a combined live finding (joined hash).
1061
- if (grantItems.length === 1 && liveItems.length > 1) {
1062
- return grant.policyHash === localSafetyRuleHash({ itemId: grantItems[0], source: 'builtin', action: 'ask' });
1063
- }
1064
1106
  // Combined leftover grant (joined id + joined hash) vs a later single-rule ask.
1107
+ // A per-item grant never covers a combined live finding on its own: the
1108
+ // combined ask is split per item in `findDeveloperGrant`, each item needs its own.
1065
1109
  if (grantItems.length > 1 && liveItems.length === 1)
1066
1110
  return true;
1067
1111
  return false;
@@ -1082,8 +1126,9 @@ function grantKey(scope, policyId, matchedRule, policyHash) {
1082
1126
  return sha256(JSON.stringify([scope.event, scope.toolName, scope.programs || null, scope.scripts || null, scope.operation, scope.destinationDomain || null, policyId || null, matchedRule || null, policyHash]));
1083
1127
  }
1084
1128
  /**
1085
- * Does a live grant cover this action under this rule? Combined Local Safety
1086
- * asks (`a+b`) match a grant for `a` or `b`. Shell programs match by INCLUSION:
1129
+ * Does a live grant cover this action under this rule? A combined Local Safety
1130
+ * ask (`a+b`) needs a grant for `a` AND one for `b` (or one for `a+b`); a grant
1131
+ * for `a` alone never silences `b`. Shell programs match by INCLUSION:
1087
1132
  * a grant for `{gh, git}` covers a line that only runs `gh`, never a line that
1088
1133
  * also runs `curl`. Generic shells and pipeline cmdlets are not in the set.
1089
1134
  * Touch counts a use.
@@ -1101,7 +1146,7 @@ function findDeveloperGrant(scope, rule, limits = { ttlMs: exports.GRANT_TTL_MS
1101
1146
  const ttlCap = Math.min(exports.GRANT_TTL_MS, limits.ttlMs);
1102
1147
  return mutateLedger(undefined, () => {
1103
1148
  const ledger = readLedger();
1104
- const hit = ledger.grants.find(g => grantRuleApplies(g, rule)
1149
+ const covers = (g, r) => grantRuleApplies(g, r)
1105
1150
  && g.scope.event === scope.event
1106
1151
  && g.scope.toolName === scope.toolName
1107
1152
  && g.scope.operation === scope.operation
@@ -1115,13 +1160,23 @@ function findDeveloperGrant(scope, rule, limits = { ttlMs: exports.GRANT_TTL_MS
1115
1160
  && (scope.event !== 'shell'
1116
1161
  || (Array.isArray(g.scope.programs) && (scope.programs || []).length > 0 && (scope.programs || []).every(p => g.scope.programs.includes(p))))
1117
1162
  && (!(g.scope.scripts && g.scope.scripts.length)
1118
- || ((scope.scripts || []).length > 0 && (scope.scripts || []).every(s => g.scope.scripts.includes(s)))));
1119
- if (!hit)
1163
+ || ((scope.scripts || []).length > 0 && (scope.scripts || []).every(s => g.scope.scripts.includes(s))));
1164
+ // A combined ask (`a+b`) is covered by a grant for exactly that combination,
1165
+ // or by one grant per item — never by a grant for `a` alone.
1166
+ const whole = ledger.grants.find(g => covers(g, rule));
1167
+ const hits = whole
1168
+ ? [whole]
1169
+ : localSafetyItemIds(rule.matchedRule).length > 1
1170
+ ? expandGrantRuleIdentities({ ...rule, policyHash: rule.policyHash }).map(item => ledger.grants.find(g => covers(g, item)))
1171
+ : [];
1172
+ if (!hits.length || hits.some(hit => !hit))
1120
1173
  return undefined;
1121
- hit.lastUsedAt = now.toISOString();
1122
- hit.uses = (Number(hit.uses) || 0) + 1;
1174
+ for (const hit of hits) {
1175
+ hit.lastUsedAt = now.toISOString();
1176
+ hit.uses = (Number(hit.uses) || 0) + 1;
1177
+ }
1123
1178
  writeLedger(ledger);
1124
- return hit;
1179
+ return hits[0];
1125
1180
  });
1126
1181
  }
1127
1182
  /**
@@ -1175,8 +1230,17 @@ function revokeDeveloperGrants(key) {
1175
1230
  return before - ledger.grants.length;
1176
1231
  });
1177
1232
  }
1178
- /** One line a person can read on the button and in the grant list: what "Always allow" covers. */
1179
- function describeGrantScope(scope) {
1233
+ /**
1234
+ * One line a person can read on the button and in the grant list: what "Always allow" covers.
1235
+ * With `rule`, names the rule the grant is under (`… under rule gcloud_secret_access_cmd`).
1236
+ */
1237
+ function describeGrantScope(scope, rule) {
1238
+ const base = describeGrantTarget(scope);
1239
+ const localItem = rule?.policyId?.startsWith('local-safety:') ? rule.policyId.slice('local-safety:'.length) : undefined;
1240
+ const ruleName = localItem || (rule?.policyName ? `"${rule.policyName}"` : undefined);
1241
+ return ruleName ? `${base} under rule ${ruleName}` : base;
1242
+ }
1243
+ function describeGrantTarget(scope) {
1180
1244
  const what = scope.event === 'shell'
1181
1245
  ? (scope.scripts && scope.scripts.length
1182
1246
  ? `running ${scope.scripts.map(s => `\`${s}\``).join(', ')}`
@@ -1209,7 +1273,11 @@ const DESTINATION_BOUND_CATEGORIES = new Set(['metadata_ssrf', 'secret_exfiltrat
1209
1273
  */
1210
1274
  function grantRuleFactsFor(source) {
1211
1275
  if (source.localFinding) {
1212
- return { bindsDestination: DESTINATION_BOUND_CATEGORIES.has(String(source.localFinding.category || '')) };
1276
+ const facts = { bindsDestination: DESTINATION_BOUND_CATEGORIES.has(String(source.localFinding.category || '')) };
1277
+ const ruleCommands = localSafetyRuleCommands(source.localFinding);
1278
+ if (ruleCommands)
1279
+ facts.ruleCommands = ruleCommands;
1280
+ return facts;
1213
1281
  }
1214
1282
  const policy = source.policyId ? source.policies?.find(p => p.id === source.policyId) : undefined;
1215
1283
  if (!policy)
@@ -1217,13 +1285,31 @@ function grantRuleFactsFor(source) {
1217
1285
  const bindsDestination = (policy.rules || []).some(rule => (rule.constraints || []).some(c => typeof c.field === 'string' && c.field.startsWith('destination.')));
1218
1286
  return { bindsDestination };
1219
1287
  }
1288
+ /**
1289
+ * The programs a built-in Local Safety item declares (`commands`). A combined
1290
+ * finding (`a+b`) is the union, and only when every item declares programs;
1291
+ * custom rules and items without `commands` return undefined.
1292
+ */
1293
+ function localSafetyRuleCommands(finding) {
1294
+ if (!finding.itemId || finding.source === 'custom')
1295
+ return undefined;
1296
+ const commands = new Set();
1297
+ for (const itemId of splitLocalSafetyRuleIds(finding.itemId) || [finding.itemId]) {
1298
+ const rule = deterministicGuard_1.CREDENTIAL_COMMAND_RULES.find(r => r.itemId === itemId);
1299
+ if (!rule || !rule.commands.length)
1300
+ return undefined;
1301
+ for (const command of rule.commands)
1302
+ commands.add(command.toLowerCase());
1303
+ }
1304
+ return [...commands].sort();
1305
+ }
1220
1306
  /** Grants made without rule facts (MCP gateway, older callers) keep the narrow, host-bound shape. */
1221
1307
  const DEFAULT_GRANT_RULE_FACTS = { bindsDestination: true };
1222
1308
  function grantScopeFor(event, toolName, toolArgs, operation, context, facts = DEFAULT_GRANT_RULE_FACTS) {
1223
1309
  const scope = { event, toolName, operation: String(operation || '').trim() || 'unknown' };
1224
1310
  if (event === 'shell') {
1225
1311
  const command = typeof toolArgs?.command === 'string' ? toolArgs.command : typeof toolArgs?.cmd === 'string' ? toolArgs.cmd : '';
1226
- scope.programs = shellPrograms(command);
1312
+ scope.programs = shellProgramsForRule(command, facts.ruleCommands);
1227
1313
  const cwd = typeof toolArgs?.cwd === 'string' ? toolArgs.cwd : undefined;
1228
1314
  const scripts = scriptGrantKeys(command, cwd);
1229
1315
  if (scripts.length)
@@ -10,7 +10,7 @@
10
10
  * hour. This entry exists to make the common path (daemon up, IPC verdict)
11
11
  * load almost nothing:
12
12
  *
13
- * node boot -> hookSlim.js -> hookIo.js + verdictIpcClient.js (3 small files,
13
+ * node boot -> hookSlim.js -> hookIo.js + verdictIpcClient.js + callerEnv.js (4 small files,
14
14
  * Node built-ins only) -> named-pipe verdict -> print -> exit.
15
15
  *
16
16
  * FALLBACK PARITY: when the daemon is unreachable, this entry lazily requires
package/dist/hookSlim.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * hour. This entry exists to make the common path (daemon up, IPC verdict)
12
12
  * load almost nothing:
13
13
  *
14
- * node boot -> hookSlim.js -> hookIo.js + verdictIpcClient.js (3 small files,
14
+ * node boot -> hookSlim.js -> hookIo.js + verdictIpcClient.js + callerEnv.js (4 small files,
15
15
  * Node built-ins only) -> named-pipe verdict -> print -> exit.
16
16
  *
17
17
  * FALLBACK PARITY: when the daemon is unreachable, this entry lazily requires
@@ -82,6 +82,25 @@ async function main() {
82
82
  const startedAt = Date.now();
83
83
  const flags = (0, hookIo_1.parseHookFlags)(process.argv.slice(2));
84
84
  const args = (0, hookIo_1.mapHookFlags)(flags);
85
+ // An error that escapes every await (async child-process 'error', stray
86
+ // rejection) would otherwise exit 1 with no verdict — an allow to the IDE,
87
+ // even mid-approval. Once the full hook is loaded it owns the verdict.
88
+ let hook;
89
+ const onCrash = (err) => {
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ if (!hook) {
92
+ emitFailOpen(args.event, message);
93
+ return;
94
+ }
95
+ const outcome = hook.crashVerdictForProcess(args.event, message);
96
+ try {
97
+ process.stdout.write(outcome.stdout);
98
+ }
99
+ catch { /* stdout gone */ }
100
+ process.exit(outcome.exitCode);
101
+ };
102
+ process.on('uncaughtException', onCrash);
103
+ process.on('unhandledRejection', onCrash);
85
104
  try {
86
105
  const raw = await (0, hookIo_1.readStdin)();
87
106
  // FAST PATH: the resident daemon computes the verdict with everything warm.
@@ -121,7 +140,8 @@ async function main() {
121
140
  // kill-switch handling), so daemon-down behavior is identical to the
122
141
  // pre-slim hook. evaluateHookRequest never rejects by contract.
123
142
  // eslint-disable-next-line @typescript-eslint/no-var-requires
124
- const { evaluateHookRequest } = require('./commands/hook');
143
+ hook = require('./commands/hook');
144
+ const { evaluateHookRequest } = hook;
125
145
  // eslint-disable-next-line @typescript-eslint/no-var-requires
126
146
  const { loadConfig } = require('./config');
127
147
  // eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -1,4 +1,3 @@
1
- /** Native hook adapters. Never emit an unsupported `ask` to an IDE. */
2
1
  import { HookArgs } from './commands/hook';
3
2
  import { BotGuardConfig } from './config';
4
3
  import { confirmNativeAction } from './devConfirm';
@@ -6,6 +5,8 @@ export type NativeClient = 'codex' | 'gemini_cli' | 'windsurf' | 'kiro';
6
5
  type Payload = Record<string, any>;
7
6
  export declare const NATIVE_CLIENTS: NativeClient[];
8
7
  export declare function patchToolCalls(raw: Payload): Payload[];
8
+ /** Gemini `read_many_files` reads every `include` path or glob: one read per target, like a Codex patch. */
9
+ export declare function geminiReadManyCalls(raw: Payload): Payload[] | null;
9
10
  export declare function normalizeNativeInput(client: NativeClient, raw: Payload, codexServers?: string[]): Payload;
10
11
  export declare function nativeVerdict(client: NativeClient, denied: boolean, reason?: string): {
11
12
  stdout: string;
@@ -1,12 +1,48 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.confirmNativeAction = exports.NATIVE_CLIENTS = void 0;
4
37
  exports.patchToolCalls = patchToolCalls;
38
+ exports.geminiReadManyCalls = geminiReadManyCalls;
5
39
  exports.normalizeNativeInput = normalizeNativeInput;
6
40
  exports.nativeVerdict = nativeVerdict;
7
41
  exports.nativeErrorVerdict = nativeErrorVerdict;
8
42
  exports.evaluateNativeHook = evaluateNativeHook;
9
43
  /** Native hook adapters. Never emit an unsupported `ask` to an IDE. */
44
+ const os = __importStar(require("os"));
45
+ const path = __importStar(require("path"));
10
46
  const hook_1 = require("./commands/hook");
11
47
  const config_1 = require("./config");
12
48
  const runtimeConfig_1 = require("./runtimeConfig");
@@ -16,6 +52,7 @@ Object.defineProperty(exports, "confirmNativeAction", { enumerable: true, get: f
16
52
  const askDialog_1 = require("./askDialog");
17
53
  const telemetry_1 = require("./telemetry");
18
54
  const codexMcpIdentity_1 = require("./codexMcpIdentity");
55
+ const actionPolicyEngine_1 = require("./actionPolicyEngine");
19
56
  /** Inside the 110 s process deadline below, with room for the verdict itself. */
20
57
  const NATIVE_DIALOG_TIMEOUT_MS = 90_000;
21
58
  exports.NATIVE_CLIENTS = ['codex', 'gemini_cli', 'windsurf', 'kiro'];
@@ -42,6 +79,41 @@ function patchToolCalls(raw) {
42
79
  throw new Error('Patch contains no recognized file operations');
43
80
  return calls;
44
81
  }
82
+ /**
83
+ * Gemini CLI built-ins that never run a command, touch a workspace file, reach a
84
+ * server or the network (packages/core/src/tools/tool-names.ts, v0.61). Without
85
+ * this list the unknown-tool fallback below judged them as MCP calls.
86
+ */
87
+ const GEMINI_PASSIVE_TOOLS = new Set([
88
+ 'list_directory', 'glob', 'grep_search', 'search_file_content', 'update_topic', 'write_todos', 'complete_task',
89
+ 'ask_user', 'enter_plan_mode', 'exit_plan_mode', 'activate_skill', 'get_internal_docs',
90
+ 'tracker_create_task', 'tracker_update_task', 'tracker_get_task', 'tracker_list_tasks', 'tracker_add_dependency', 'tracker_visualize',
91
+ ]);
92
+ function isAbsolutePathLike(value) {
93
+ return /^(?:[\\/]|[A-Za-z]:[\\/]|~)/.test(value);
94
+ }
95
+ /** Gemini `read_many_files` reads every `include` path or glob: one read per target, like a Codex patch. */
96
+ function geminiReadManyCalls(raw) {
97
+ if (raw.tool_name !== 'read_many_files' || raw.mcp_context)
98
+ return null;
99
+ const include = Array.isArray(raw.tool_input?.include) ? raw.tool_input.include.map((p) => String(p || '').trim()).filter(Boolean) : [];
100
+ if (!include.length)
101
+ return null;
102
+ return include.map((file_path) => ({ ...raw, tool_name: 'read_file', tool_input: { file_path } }));
103
+ }
104
+ function geminiBuiltinInput(tool, raw, input) {
105
+ if (tool === 'run_shell_command' && typeof input.dir_path === 'string' && input.dir_path && !input.cwd) {
106
+ const base = String(raw.cwd || '');
107
+ const dir = input.dir_path;
108
+ const cwd = isAbsolutePathLike(dir) || !base ? dir : `${base.replace(/[\\/]+$/, '')}${base.includes('/') ? '/' : '\\'}${dir}`;
109
+ return { tool, input: { ...input, cwd } };
110
+ }
111
+ if (tool === 'save_memory') {
112
+ // Pre-0.61 memory tool: appends `fact` to the global GEMINI.md context file.
113
+ return { tool: 'write_file', input: { file_path: path.join(os.homedir(), '.gemini', 'GEMINI.md'), content: String(input.fact || '') } };
114
+ }
115
+ return { tool, input };
116
+ }
45
117
  function normalizeNativeInput(client, raw, codexServers = []) {
46
118
  let name = String(raw.hook_event_name || '');
47
119
  let tool = String(raw.tool_name || '');
@@ -84,6 +156,8 @@ function normalizeNativeInput(client, raw, codexServers = []) {
84
156
  tool = `mcp__${raw.mcp_context.server_name}__${raw.mcp_context.tool_name}`;
85
157
  else if (tool.startsWith('mcp_'))
86
158
  tool = `mcp__gemini__${tool}`; // retain full name when the server split is ambiguous
159
+ else
160
+ ({ tool, input } = geminiBuiltinInput(tool, raw, input));
87
161
  }
88
162
  else if (client === 'kiro') {
89
163
  name = { preToolUse: 'PreToolUse', postToolUse: 'PostToolUse', userPromptSubmit: 'UserPromptSubmit' }[name] || name;
@@ -96,7 +170,8 @@ function normalizeNativeInput(client, raw, codexServers = []) {
96
170
  throw new Error('Missing tool name');
97
171
  // Unknown function tools still need Action Policy evaluation, not silent ignore.
98
172
  let normalized = { ...raw, hook_event_name: name, tool_name: tool, tool_input: input };
99
- if (name !== 'UserPromptSubmit' && (0, hook_1.normalizeClaudePayload)(normalized)?.event === 'ignore') {
173
+ const passiveBuiltin = client === 'gemini_cli' && GEMINI_PASSIVE_TOOLS.has(tool);
174
+ if (name !== 'UserPromptSubmit' && !passiveBuiltin && (0, hook_1.normalizeClaudePayload)(normalized)?.event === 'ignore') {
100
175
  normalized = { ...normalized, tool_name: `mcp__${client}-builtin__${tool}` };
101
176
  }
102
177
  return normalized;
@@ -135,6 +210,15 @@ async function evaluateNativeHook(client, raw, args, config, confirm) {
135
210
  return nativeErrorVerdict(client, `Patch validation failed: ${String(error)}`);
136
211
  }
137
212
  }
213
+ const readManyCalls = client === 'gemini_cli' ? geminiReadManyCalls(raw) : null;
214
+ if (readManyCalls) {
215
+ for (const call of readManyCalls) {
216
+ const result = await evaluateNativeHook(client, call, args, config, confirm);
217
+ if (result.exitCode !== 0 || JSON.parse(result.stdout || '{}').decision === 'deny')
218
+ return result;
219
+ }
220
+ return nativeVerdict(client, false);
221
+ }
138
222
  let payload;
139
223
  const resolveCodexIdentity = client === 'codex' && String(raw.tool_name || '').startsWith('mcp__');
140
224
  if (resolveCodexIdentity && !(0, codexMcpIdentity_1.loadTomlParser)()) {
@@ -147,8 +231,10 @@ async function evaluateNativeHook(client, raw, args, config, confirm) {
147
231
  catch (error) {
148
232
  return nativeErrorVerdict(client, `FullCourtDefense could not parse the hook: ${String(error)}`);
149
233
  }
150
- return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'local', agentClient: client }, async () => {
151
- const evaluated = await (0, hook_1.evaluateHookRequest)({ ...args, agentClient: client }, JSON.stringify(payload), config);
234
+ // `client` names the vendor output shape; policies, telemetry and the console use the product id.
235
+ const agentClient = (0, actionPolicyEngine_1.canonicalAgentClient)(client);
236
+ return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'local', agentClient }, async () => {
237
+ const evaluated = await (0, hook_1.evaluateHookRequest)({ ...args, agentClient }, JSON.stringify(payload), config);
152
238
  // The three-button FullCourtDefense dialog (askDialog.ts) — the same one Cursor
153
239
  // and Claude Code get. A test-injected `confirm` keeps the legacy yes/no shape.
154
240
  const result = await (0, askDialog_1.resolveAskWithDialog)(evaluated, {
@@ -201,9 +287,28 @@ async function main() {
201
287
  throw new Error('Unsupported native hook client');
202
288
  const config = (0, config_1.loadConfig)();
203
289
  const shieldId = (0, config_1.resolveCliCredentials)(config, { shieldId: flags['shield-id'] }, { skipDpapi: true }).shieldId;
290
+ // Errors that escape every await (an async child-process 'error') would exit
291
+ // 1 with no verdict, which these clients read as "hook failed, continue".
292
+ // Same stance as the deadline below. After the verdict is out, nothing is left to decide.
293
+ let verdictWritten = false;
294
+ const onCrash = (err) => {
295
+ if (verdictWritten)
296
+ return;
297
+ verdictWritten = true;
298
+ const deny = (0, runtimeConfig_1.isSuspendedByCache)() || (0, runtimeConfig_1.cachedEnforcementState)(shieldId).mode === 'block';
299
+ const result = deny ? nativeErrorVerdict(client, `FullCourtDefense hook failed: ${String(err)}; action not approved.`) : nativeVerdict(client, false);
300
+ if (result.stdout)
301
+ process.stdout.write(result.stdout + '\n');
302
+ if (result.stderr)
303
+ process.stderr.write(result.stderr + '\n');
304
+ process.exit(result.exitCode);
305
+ };
306
+ process.on('uncaughtException', onCrash);
307
+ process.on('unhandledRejection', onCrash);
204
308
  // Return before the vendor's 130s timeout: some clients fail open on timeout.
205
309
  // Bound the entire hook, including multiple patch files and human dialogs.
206
310
  const deadline = setTimeout(() => {
311
+ verdictWritten = true;
207
312
  const deny = (0, runtimeConfig_1.isSuspendedByCache)() || (0, runtimeConfig_1.cachedEnforcementState)(shieldId).mode === 'block';
208
313
  const result = deny ? nativeErrorVerdict(client, 'FullCourtDefense evaluation deadline exceeded; action not approved.') : nativeVerdict(client, false);
209
314
  if (result.stdout)
@@ -216,6 +321,7 @@ async function main() {
216
321
  const raw = JSON.parse((await (0, hookIo_1.readStdin)()).replace(/^\uFEFF/, ''));
217
322
  const result = await evaluateNativeHook(client, raw, (0, hookIo_1.mapHookFlags)(flags), config);
218
323
  clearTimeout(deadline);
324
+ verdictWritten = true;
219
325
  if (result.stdout)
220
326
  process.stdout.write(result.stdout + '\n');
221
327
  if (result.stderr)
package/dist/notify.js CHANGED
@@ -22,6 +22,10 @@ function spawnDetached(command, args, opts) {
22
22
  windowsHide: opts?.showWindow ? false : true,
23
23
  env: opts?.env ? { ...process.env, ...opts.env } : process.env,
24
24
  });
25
+ // A missing binary (no notify-send on a headless box) surfaces as an
26
+ // ASYNC 'error' event, not a throw. Unhandled, it kills the calling hook
27
+ // mid-verdict — and a hook that dies without output is an allow to the IDE.
28
+ child.on('error', () => { });
25
29
  child.unref();
26
30
  }
27
31
  catch { /* best-effort */ }
@@ -109,6 +109,8 @@ interface VerdictTiming {
109
109
  }
110
110
  /** Run one hook evaluation with timing attached to everything it spools. */
111
111
  export declare function markVerdictTiming<T>(timing: VerdictTiming, fn: () => T): T;
112
+ /** When the developer's wait for the current evaluation began (undefined outside one). */
113
+ export declare function verdictStartedAt(): number | undefined;
112
114
  /**
113
115
  * Re-label the current evaluation (e.g. it ended in the fail-open handler).
114
116
  * Keeps the original start time so latency still covers the full wait.
package/dist/telemetry.js CHANGED
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.EVIDENCE_MAX_CHARS = void 0;
37
37
  exports.evidenceFromToolArgs = evidenceFromToolArgs;
38
38
  exports.markVerdictTiming = markVerdictTiming;
39
+ exports.verdictStartedAt = verdictStartedAt;
39
40
  exports.setVerdictPath = setVerdictPath;
40
41
  exports.setVerdictClient = setVerdictClient;
41
42
  exports.setVerdictIdentity = setVerdictIdentity;
@@ -178,6 +179,10 @@ const verdictTiming = new async_hooks_1.AsyncLocalStorage();
178
179
  function markVerdictTiming(timing, fn) {
179
180
  return verdictTiming.run(timing, fn);
180
181
  }
182
+ /** When the developer's wait for the current evaluation began (undefined outside one). */
183
+ function verdictStartedAt() {
184
+ return verdictTiming.getStore()?.startedAt;
185
+ }
181
186
  /**
182
187
  * Re-label the current evaluation (e.g. it ended in the fail-open handler).
183
188
  * Keeps the original start time so latency still covers the full wait.
@@ -486,6 +491,7 @@ function triggerFlush(immediate = false) {
486
491
  stdio: 'ignore',
487
492
  windowsHide: true,
488
493
  });
494
+ child.on('error', () => { });
489
495
  child.unref();
490
496
  }
491
497
  catch { /* best-effort */ }
@@ -39,6 +39,7 @@ const crypto = __importStar(require("crypto"));
39
39
  const fs = __importStar(require("fs"));
40
40
  const net = __importStar(require("net"));
41
41
  const telemetry_1 = require("./telemetry");
42
+ const callerEnv_1 = require("./callerEnv");
42
43
  const verdictIpcClient_1 = require("./verdictIpcClient");
43
44
  /**
44
45
  * Daemon verdict IPC — SERVER side (runs inside the resident daemon).
@@ -139,8 +140,10 @@ function startVerdictServer(evaluate, log) {
139
140
  // evaluation spools, so fleet telemetry reports the wall time the
140
141
  // developer waited. Per-request (AsyncLocalStorage), so concurrent
141
142
  // hooks in this one daemon never borrow each other's timings.
142
- const result = await (0, telemetry_1.markVerdictTiming)({ startedAt: typeof request.t0 === 'number' ? request.t0 : Date.now(), path: 'ipc' }, () => evaluate(request.args || {}, String(request.stdin ?? '')));
143
- reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, ...result });
143
+ // Decision facts read from the environment are the CALLER's (#1466):
144
+ // this process is a scheduled task, not the IDE session that asked.
145
+ const result = await (0, callerEnv_1.withCallerEnv)(request.env, () => (0, telemetry_1.markVerdictTiming)({ startedAt: typeof request.t0 === 'number' ? request.t0 : Date.now(), path: 'ipc' }, () => evaluate(request.args || {}, String(request.stdin ?? ''))));
146
+ reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, ...result, ...(request.env ? { envApplied: true } : {}) });
144
147
  }
145
148
  catch (err) {
146
149
  // Evaluator bug — tell the client to fall back locally (which will