fullcourtdefense-cli 1.35.5 → 1.35.7

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.
@@ -820,7 +820,9 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
820
820
  // this exact rule (same rule hash). Rules that forbid memory never reach here.
821
821
  // The caps in force come from the rule and the org ceiling in the cached bundle;
822
822
  // an org that switched "Always allow" off (`null`) honours no grant at all.
823
- const grantScope = (0, devConfirm_1.grantScopeFor)(event, call.toolName, call.toolArgs, operation, context);
823
+ // The grant binds the facts THIS rule fired on: a credential-command rule is
824
+ // about the program, an egress / SSRF / exfil rule is about the host too.
825
+ const grantScope = (0, devConfirm_1.grantScopeFor)(event, call.toolName, call.toolArgs, operation, context, (0, devConfirm_1.grantRuleFactsFor)(source));
824
826
  const grantLimits = (0, devConfirm_1.resolveGrantLimits)(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)());
825
827
  const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
826
828
  if (grant) {
@@ -293,8 +293,10 @@ export declare function sweepExpiredConfirmations(now?: Date, ttlMs?: number): P
293
293
  * programs: `gcloud … | ForEach-Object` is `{gcloud}`, `cmd /c gcloud` is
294
294
  * `{gcloud}`. Inspect cmdlets (`Get-ChildItem`, `Test-Path`) are dropped so
295
295
  * Always allow is the real app (`gcloud` / `gh` / `aws` / …), not that
296
- * wrapper mix. `exit` is control flow. Trailing junk (`out-string).trim()`)
297
- * is stripped. Sorted and unique.
296
+ * wrapper mix. Text filters after a pipe (`| Select-String`, `| grep | head`)
297
+ * are output shaping, not programs; the same names leading a statement
298
+ * (`head .env`) stay. `exit` is control flow. Trailing junk
299
+ * (`out-string).trim()`) is stripped. Sorted and unique.
298
300
  */
299
301
  export declare function shellPrograms(command: string): string[];
300
302
  /** Local Safety item ids joined as `a+b` when two ask-rules fire in one dialog. */
@@ -332,7 +334,38 @@ export declare function listDeveloperGrants(now?: Date): DeveloperGrant[];
332
334
  export declare function revokeDeveloperGrants(key?: string): number;
333
335
  /** One line a person can read on the button and in the grant list: what "Always allow" covers. */
334
336
  export declare function describeGrantScope(scope: GrantScope): string;
335
- export declare function grantScopeFor(event: string, toolName: string, toolArgs: Record<string, unknown> | undefined, operation: string, context: Record<string, unknown> | undefined): GrantScope;
337
+ /**
338
+ * Which line-level facts the RULE that asked actually depends on. A grant binds
339
+ * the facts the rule fired on and nothing else: `gcloud_access_token_cmd` fired
340
+ * on `gcloud auth print-access-token`, so where a later `Invoke-RestMethod` in
341
+ * the same line posts (`logging.` vs `monitoring.googleapis.com`) and whether
342
+ * the engine classed the line `write` or `SHELL` must not partition the grant —
343
+ * that is what made every Always allow ask again (this machine, 2026-09-14..20).
344
+ * A rule that IS about the host (egress policy, SSRF, exfiltration, reverse
345
+ * shell) keeps the host in the grant so `evil.example` never rides on it.
346
+ */
347
+ export interface GrantRuleFacts {
348
+ bindsDestination: boolean;
349
+ }
350
+ /**
351
+ * Derive `GrantRuleFacts` from the source of the ask. Unknown = bound (the
352
+ * narrower grant), so a policy the cache no longer holds never widens anything.
353
+ */
354
+ export declare function grantRuleFactsFor(source: {
355
+ localFinding?: {
356
+ category?: string;
357
+ };
358
+ policyId?: string;
359
+ policies?: Array<{
360
+ id: string;
361
+ rules?: Array<{
362
+ constraints?: Array<{
363
+ field: string;
364
+ }>;
365
+ }>;
366
+ }>;
367
+ }): GrantRuleFacts;
368
+ export declare function grantScopeFor(event: string, toolName: string, toolArgs: Record<string, unknown> | undefined, operation: string, context: Record<string, unknown> | undefined, facts?: GrantRuleFacts): GrantScope;
336
369
  /**
337
370
  * Is this exact action covered in the same session and policy context?
338
371
  * The 1h choice is a shorter expiry, never authorization for another session.
@@ -61,6 +61,7 @@ exports.recordDeveloperGrant = recordDeveloperGrant;
61
61
  exports.listDeveloperGrants = listDeveloperGrants;
62
62
  exports.revokeDeveloperGrants = revokeDeveloperGrants;
63
63
  exports.describeGrantScope = describeGrantScope;
64
+ exports.grantRuleFactsFor = grantRuleFactsFor;
64
65
  exports.grantScopeFor = grantScopeFor;
65
66
  exports.isConfirmationRemembered = isConfirmationRemembered;
66
67
  /**
@@ -819,11 +820,31 @@ const SHELL_INCIDENTAL = new Set([
819
820
  const SHELL_CONTROL_WORDS = /^(if|then|else|elif|elseif|fi|do|done|for|while|until|case|esac|try|catch|finally|foreach|function|return|exit|switch|break|continue)$/i;
820
821
  /** Words a body follows directly (`then rm x`, `do echo`, PowerShell `else { … }`). */
821
822
  const SHELL_BODY_LEADS = /^(then|do|else)$/i;
822
- /** Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not semantics. */
823
+ /**
824
+ * Text filters. In PIPELINE position (`gcloud … | Select-String x`, `gh … | grep
825
+ * y | head -1`) they shape the previous program's output and are not the app the
826
+ * rule asked about — binding Always allow to `select-string` made the next
827
+ * `gcloud … | Select-Object` line ask again (this machine, 2026-09-20). As a
828
+ * statement LEAD they read the file they are given (`head .env`, `grep x
829
+ * ~/.aws/credentials`) and stay the program.
830
+ */
831
+ const SHELL_TEXT_FILTERS = new Set([
832
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'sls', 'awk', 'gawk', 'sed',
833
+ 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'jq', 'yq', 'column', 'nl', 'more', 'less', 'cat', 'tee',
834
+ ]);
835
+ /**
836
+ * Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not
837
+ * semantics. Separators inside `{ … }` do not split: a control statement's
838
+ * bodies (`if (…) { a; b } else { c }`) stay with their statement so every
839
+ * body is read (`braceBodies`). `piped` = this segment is a pipeline stage
840
+ * (`|`, not `||`).
841
+ */
823
842
  function splitShellSegments(command) {
824
843
  const segments = [];
825
844
  let current = '';
845
+ let piped = false;
826
846
  let quote = null;
847
+ let braces = 0;
827
848
  for (let i = 0; i < command.length; i++) {
828
849
  const ch = command[i];
829
850
  if (quote) {
@@ -839,18 +860,28 @@ function splitShellSegments(command) {
839
860
  current += ch;
840
861
  continue;
841
862
  }
842
- if (ch === '\n' || ch === ';' || ch === '|' || (ch === '&' && command[i + 1] === '&')) {
863
+ if (ch === '{')
864
+ braces++;
865
+ else if (ch === '}' && braces > 0)
866
+ braces--;
867
+ if (braces === 0 && (ch === '\n' || ch === ';' || ch === '|' || (ch === '&' && command[i + 1] === '&'))) {
868
+ let nextPiped = false;
843
869
  if (ch === '&')
844
870
  i++;
845
- if (ch === '|' && command[i + 1] === '|')
846
- i++;
847
- segments.push(current);
871
+ if (ch === '|') {
872
+ if (command[i + 1] === '|')
873
+ i++;
874
+ else
875
+ nextPiped = true;
876
+ }
877
+ segments.push({ text: current, piped });
848
878
  current = '';
879
+ piped = nextPiped;
849
880
  continue;
850
881
  }
851
882
  current += ch;
852
883
  }
853
- segments.push(current);
884
+ segments.push({ text: current, piped });
854
885
  return segments;
855
886
  }
856
887
  /** `out-string).trim()` is not a program — take the leading identifier only. */
@@ -872,10 +903,58 @@ function nestedShellBody(segment) {
872
903
  rest = rest.slice(1, -1);
873
904
  return rest.trim() || undefined;
874
905
  }
906
+ /**
907
+ * Every top-level `{ … }` body of a control statement, quote-aware. An
908
+ * unterminated body (a segment cut before its `}`) yields what follows the
909
+ * `{` — under-reading a body is what let a branch hide a program.
910
+ */
911
+ function braceBodies(statement) {
912
+ const bodies = [];
913
+ let quote = null;
914
+ let depth = 0;
915
+ let start = -1;
916
+ let data = false; // `${VAR}` / `@{ k = v }` — an expansion or a hashtable, not a script block
917
+ for (let i = 0; i < statement.length; i++) {
918
+ const ch = statement[i];
919
+ if (quote) {
920
+ if (ch === quote)
921
+ quote = null;
922
+ else if (ch === '\\' && quote === '"')
923
+ i++;
924
+ continue;
925
+ }
926
+ if (ch === '"' || ch === "'" || ch === '`') {
927
+ quote = ch;
928
+ continue;
929
+ }
930
+ if (ch === '{') {
931
+ if (depth++ === 0) {
932
+ data = /[$@]/.test(statement[i - 1] || '');
933
+ start = i + 1;
934
+ }
935
+ continue;
936
+ }
937
+ if (ch === '}' && depth > 0 && --depth === 0) {
938
+ if (!data)
939
+ bodies.push(statement.slice(start, i));
940
+ start = -1;
941
+ }
942
+ }
943
+ if (start >= 0 && !data)
944
+ bodies.push(statement.slice(start));
945
+ return bodies.map(body => body.trim()).filter(Boolean);
946
+ }
875
947
  function collectPrograms(command, out, depth) {
876
- if (depth > 3 || !command.trim())
948
+ if (depth > 6 || !command.trim())
877
949
  return;
878
- for (const raw of splitShellSegments(command)) {
950
+ for (const { text: raw, piped } of splitShellSegments(command)) {
951
+ // Script blocks anywhere in the statement run programs of their own —
952
+ // `if (c) { a } else { b }`, `… | ForEach-Object { curl … }`, `try { … } catch { … }`,
953
+ // `{ git status }`. EVERY body is read: reading only the first let a `{gh}`
954
+ // grant cover `if ($x) { gh auth token } else { curl https://evil… }`.
955
+ if (raw.includes('{'))
956
+ for (const body of braceBodies(raw))
957
+ collectPrograms(body, out, depth + 1);
879
958
  const words = raw.trim().split(/\s+/).filter(Boolean);
880
959
  let i = 0;
881
960
  // `$r = Invoke-WebRequest …` / `$r=Invoke-WebRequest …` / `FOO=bar cmd …` / `$t = (gcloud …)`
@@ -906,23 +985,10 @@ function collectPrograms(command, out, depth) {
906
985
  i++;
907
986
  continue;
908
987
  }
988
+ // Control statement: its bodies were read above; the statement itself is not a program.
909
989
  if (SHELL_CONTROL_WORDS.test(w)) {
910
- // `if (cond) { body }` / `foreach ($x in $y) { body }` / `else { body }`: the program
911
- // runs in the body, so skip to the first `{` and read on from there.
912
- const open = words.findIndex((word, index) => index > i && word.includes('{'));
913
- if (open < 0) {
914
- i = words.length;
915
- break;
916
- }
917
- const rest = words[open].slice(words[open].indexOf('{') + 1);
918
- if (rest) {
919
- words[open] = rest;
920
- i = open;
921
- }
922
- else {
923
- i = open + 1;
924
- }
925
- continue;
990
+ i = words.length;
991
+ break;
926
992
  }
927
993
  break;
928
994
  }
@@ -932,6 +998,8 @@ function collectPrograms(command, out, depth) {
932
998
  const name = programToken(lead);
933
999
  if (!name || SHELL_PIPELINE_NOISE.has(name) || SHELL_INCIDENTAL.has(name))
934
1000
  continue;
1001
+ if (piped && SHELL_TEXT_FILTERS.has(name))
1002
+ continue;
935
1003
  if (GENERIC_SHELLS.has(name)) {
936
1004
  const nested = nestedShellBody(raw);
937
1005
  if (nested)
@@ -947,8 +1015,10 @@ function collectPrograms(command, out, depth) {
947
1015
  * programs: `gcloud … | ForEach-Object` is `{gcloud}`, `cmd /c gcloud` is
948
1016
  * `{gcloud}`. Inspect cmdlets (`Get-ChildItem`, `Test-Path`) are dropped so
949
1017
  * Always allow is the real app (`gcloud` / `gh` / `aws` / …), not that
950
- * wrapper mix. `exit` is control flow. Trailing junk (`out-string).trim()`)
951
- * is stripped. Sorted and unique.
1018
+ * wrapper mix. Text filters after a pipe (`| Select-String`, `| grep | head`)
1019
+ * are output shaping, not programs; the same names leading a statement
1020
+ * (`head .env`) stay. `exit` is control flow. Trailing junk
1021
+ * (`out-string).trim()`) is stripped. Sorted and unique.
952
1022
  */
953
1023
  function shellPrograms(command) {
954
1024
  const out = new Set();
@@ -1131,7 +1201,25 @@ function scriptGrantKeys(command, cwd) {
1131
1201
  });
1132
1202
  return [...new Set(keys)].sort();
1133
1203
  }
1134
- function grantScopeFor(event, toolName, toolArgs, operation, context) {
1204
+ /** Local Safety categories whose finding is the destination host itself. */
1205
+ const DESTINATION_BOUND_CATEGORIES = new Set(['metadata_ssrf', 'secret_exfiltration', 'reverse_shell']);
1206
+ /**
1207
+ * Derive `GrantRuleFacts` from the source of the ask. Unknown = bound (the
1208
+ * narrower grant), so a policy the cache no longer holds never widens anything.
1209
+ */
1210
+ function grantRuleFactsFor(source) {
1211
+ if (source.localFinding) {
1212
+ return { bindsDestination: DESTINATION_BOUND_CATEGORIES.has(String(source.localFinding.category || '')) };
1213
+ }
1214
+ const policy = source.policyId ? source.policies?.find(p => p.id === source.policyId) : undefined;
1215
+ if (!policy)
1216
+ return { bindsDestination: true };
1217
+ const bindsDestination = (policy.rules || []).some(rule => (rule.constraints || []).some(c => typeof c.field === 'string' && c.field.startsWith('destination.')));
1218
+ return { bindsDestination };
1219
+ }
1220
+ /** Grants made without rule facts (MCP gateway, older callers) keep the narrow, host-bound shape. */
1221
+ const DEFAULT_GRANT_RULE_FACTS = { bindsDestination: true };
1222
+ function grantScopeFor(event, toolName, toolArgs, operation, context, facts = DEFAULT_GRANT_RULE_FACTS) {
1135
1223
  const scope = { event, toolName, operation: String(operation || '').trim() || 'unknown' };
1136
1224
  if (event === 'shell') {
1137
1225
  const command = typeof toolArgs?.command === 'string' ? toolArgs.command : typeof toolArgs?.cmd === 'string' ? toolArgs.cmd : '';
@@ -1140,9 +1228,14 @@ function grantScopeFor(event, toolName, toolArgs, operation, context) {
1140
1228
  const scripts = scriptGrantKeys(command, cwd);
1141
1229
  if (scripts.length)
1142
1230
  scope.scripts = scripts;
1231
+ // A shell line's identity is its programs. The engine verb (`write` for a
1232
+ // POST, `SHELL` otherwise) describes the line, not the app the rule asked
1233
+ // about, and it changed the key between two `gcloud … ; Invoke-RestMethod`
1234
+ // lines that fired the very same rule. One verb for every shell grant.
1235
+ scope.operation = 'SHELL';
1143
1236
  }
1144
1237
  const domain = context?.['destination.domain'];
1145
- if (typeof domain === 'string' && domain.trim())
1238
+ if (facts.bindsDestination && typeof domain === 'string' && domain.trim())
1146
1239
  scope.destinationDomain = domain.trim().toLowerCase();
1147
1240
  return scope;
1148
1241
  }
package/dist/telemetry.js CHANGED
@@ -84,6 +84,26 @@ const ACTION_EVIDENCE_KEYS = [
84
84
  ];
85
85
  /** Noise / secrets — never stamp these as activity evidence. */
86
86
  const EVIDENCE_SKIP_KEYS = /^(cwd|pwd|env|environment|headers|cookie|cookies|authorization|password|passwd|secret|token|api[_-]?key|private[_-]?key|session|retryCount|timeoutMs|timeout)$/i;
87
+ /**
88
+ * Payload keys — a file body, a patch, message text — are business content,
89
+ * never evidence. The record carries the ACTION (tool, path, command, verdict,
90
+ * rule); what was written is the customer's. Only the size leaves the machine:
91
+ * a `.env` edit on a fleet machine shipped its whole body, signing secret
92
+ * included, before this (2026-09-18). The suffix is the backend's idempotency
93
+ * marker (`modules/runtime/tool-args.ts`), so re-sanitizing leaves it alone.
94
+ */
95
+ const EVIDENCE_CONTENT_KEYS = /^(content|contents|body|file_?contents?|stdin|payload|diff|patch|new_?str(ing)?|old_?str(ing)?)$/i;
96
+ /** Text a human or agent TYPED into a field or a page script — count only, the same rule the backend applies on read. */
97
+ const EVIDENCE_TYPED_TEXT_KEYS = /^(text|value|input|keys|type_?text)$/i;
98
+ function minimizedEvidenceValue(key, value) {
99
+ if (typeof value !== 'string')
100
+ return undefined;
101
+ if (EVIDENCE_CONTENT_KEYS.test(key))
102
+ return `[… ${value.length} chars total — content minimized]`;
103
+ if (EVIDENCE_TYPED_TEXT_KEYS.test(key))
104
+ return `[${value.length} character${value.length === 1 ? '' : 's'} typed]`;
105
+ return undefined;
106
+ }
87
107
  function stringifyEvidenceValue(value) {
88
108
  // One value (the command line) may use most of the envelope; keys and quoting take the rest.
89
109
  if (typeof value === 'string' && value.trim())
@@ -128,7 +148,7 @@ function evidenceFromToolArgs(toolArgs) {
128
148
  continue;
129
149
  if (Object.keys(picked).length >= 8)
130
150
  break;
131
- const text = stringifyEvidenceValue(value);
151
+ const text = minimizedEvidenceValue(key, value) || stringifyEvidenceValue(value);
132
152
  if (text)
133
153
  picked[key] = text;
134
154
  }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.35.5"
2
+ "version": "1.35.7"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.35.5",
3
+ "version": "1.35.7",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {