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
@@ -35,6 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
37
37
  exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
38
+ exports.setShellAstProvider = setShellAstProvider;
39
+ exports.setShellAstMode = setShellAstMode;
40
+ exports.getShellAstMode = getShellAstMode;
41
+ exports.takeShellAstComparison = takeShellAstComparison;
38
42
  exports.unquotedCode = unquotedCode;
39
43
  exports.stripInertDataSegments = stripInertDataSegments;
40
44
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
@@ -722,25 +726,211 @@ function credentialCommandReason(value, options) {
722
726
  // stall. Root targets (`/`, `/*`) stay blocked even inside containers (the
723
727
  // Patria case: an agent nuking a running container's filesystem).
724
728
  const CONTAINER_EXEC_BEFORE = /\b(?:docker|podman|nerdctl|kubectl)(?:\.exe)?\s+(?:compose\s+)?(?:exec|run)\b[^;&|]*$/i;
725
- function destructiveCommandReason(value) {
729
+ let shellAstProvider;
730
+ let shellAstMode = 'shadow';
731
+ let lastShellAstComparison;
732
+ function setShellAstProvider(provider) {
733
+ shellAstProvider = provider;
734
+ }
735
+ function setShellAstMode(mode) { shellAstMode = mode; }
736
+ function getShellAstMode() { return shellAstMode; }
737
+ /** Shadow telemetry: the comparison recorded by the most recent shell scan (consumed once). */
738
+ function takeShellAstComparison() {
739
+ const value = lastShellAstComparison;
740
+ lastShellAstComparison = undefined;
741
+ return value;
742
+ }
743
+ function shellAstFactsFullyComplete(facts) {
744
+ if (!facts || !facts.complete)
745
+ return false;
746
+ for (const command of facts.commands)
747
+ if (command.wrapper?.inner && !shellAstFactsFullyComplete(command.wrapper.inner))
748
+ return false;
749
+ return true;
750
+ }
751
+ const AST_TRANSPARENT_WRAPPERS = new Set(['sudo', 'doas', 'pkexec', 'run0', 'gosu', 'runuser', 'env', 'nice', 'nohup', 'ionice', 'chrt', 'stdbuf', 'timeout', 'command', 'builtin', 'time', 'exec']);
752
+ /**
753
+ * The destructive-rm decision from structure: which program really runs
754
+ * (through `sudo` / `env` / `time` / `exec`), which words are its flags after
755
+ * quote resolution (`rm "-rf" /` ≡ `rm -rf /`, `-r -f` ≡ `-rf`, `--recursive
756
+ * --force`), and what each target is once the cwd scope is known. Inner scripts
757
+ * of `bash -c` / `docker run … sh -c` are included; inside a container a bare
758
+ * `*` is the container workdir (cleanup), the root `/` is still the root.
759
+ */
760
+ function astDestructiveRm(facts, inContainer = false) {
761
+ for (const command of facts.commands) {
762
+ if (command.wrapper?.inner) {
763
+ const hit = astDestructiveRm(command.wrapper.inner, inContainer || command.wrapper.kind === 'container');
764
+ if (hit)
765
+ return hit;
766
+ }
767
+ let argv = command.argv;
768
+ let literal = command.literal;
769
+ let name = command.name;
770
+ // Unwrap transparent wrappers: `sudo -u root rm -rf /`, `env X=1 rm -rf /`.
771
+ for (let guard = 0; guard < 4 && AST_TRANSPARENT_WRAPPERS.has(name); guard++) {
772
+ let i = 1;
773
+ while (i < argv.length && ((argv[i].startsWith('-') && argv[i] !== '-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i]))) {
774
+ // `sudo -u root`: the option value follows on the next word.
775
+ if (/^-(?:u|g|h|C|p|r|t|U|D)$/.test(argv[i]))
776
+ i++;
777
+ i++;
778
+ }
779
+ if (i >= argv.length || !literal[i]) {
780
+ name = '';
781
+ break;
782
+ }
783
+ argv = argv.slice(i);
784
+ literal = literal.slice(i);
785
+ name = argv[0].replace(/^.*\//, '').replace(/\.exe$/i, '').toLowerCase();
786
+ }
787
+ if (name !== 'rm')
788
+ continue;
789
+ let recursive = false;
790
+ let force = false;
791
+ const targets = [];
792
+ let flagsDone = false;
793
+ for (let i = 1; i < argv.length; i++) {
794
+ const word = argv[i];
795
+ if (!flagsDone && word === '--') {
796
+ flagsDone = true;
797
+ continue;
798
+ }
799
+ if (!flagsDone && /^--/.test(word)) {
800
+ if (/^--recursive$/i.test(word))
801
+ recursive = true;
802
+ if (/^--force$/i.test(word))
803
+ force = true;
804
+ continue;
805
+ }
806
+ if (!flagsDone && /^-[A-Za-z]+$/.test(word)) {
807
+ if (/[rR]/.test(word))
808
+ recursive = true;
809
+ if (/f/.test(word))
810
+ force = true;
811
+ continue;
812
+ }
813
+ targets.push({ value: word, literal: literal[i] });
814
+ }
815
+ if (!recursive || !force)
816
+ continue;
817
+ for (const target of targets) {
818
+ const t = target.value.trim().replace(/\\/g, '/');
819
+ const lower = t.toLowerCase();
820
+ if (/^\/+\*?$/.test(t))
821
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
822
+ if (/^(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/+\*?)?$/.test(lower))
823
+ return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
824
+ if (/^(?:\*|\.\/+\*)$/.test(t)) {
825
+ if (inContainer || command.cwdScope === 'scoped')
826
+ continue;
827
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of the current directory tree (rm -rf *)' };
828
+ }
829
+ }
830
+ }
831
+ return undefined;
832
+ }
833
+ const RM_SCOPE_ITEMS = new Set(['rm_rf_root', 'rm_rf_home']);
834
+ /**
835
+ * Destructive-command decision with the AST in the loop. Returns the regex
836
+ * verdict in 'off'/'shadow' (recording the comparison in shadow), and in
837
+ * 'primary' lets a fully complete AST decide the two rm-scope items — clearing
838
+ * a regex false positive or adding a structurally proven hit the regex missed —
839
+ * while every other destructive rule keeps its regex verdict.
840
+ */
841
+ function destructiveCommandDecision(value) {
842
+ const regexHit = destructiveCommandReason(value);
843
+ if (shellAstMode === 'off' || !shellAstProvider)
844
+ return regexHit;
845
+ let facts;
846
+ try {
847
+ facts = shellAstProvider(value);
848
+ }
849
+ catch {
850
+ facts = undefined;
851
+ }
852
+ const complete = shellAstFactsFullyComplete(facts);
853
+ const astHit = complete && facts ? astDestructiveRm(facts) : undefined;
854
+ const regexRm = regexHit && RM_SCOPE_ITEMS.has(regexHit.itemId) ? regexHit.itemId : undefined;
855
+ lastShellAstComparison = { mode: shellAstMode, complete, regex: regexRm, ast: astHit?.itemId, agree: !complete || regexRm === astHit?.itemId };
856
+ if (shellAstMode !== 'primary' || !complete)
857
+ return regexHit;
858
+ if (astHit)
859
+ return astHit;
860
+ if (regexRm) {
861
+ // The AST cleared the rm-scope hit; other destructive rules still apply.
862
+ return destructiveCommandReason(value, { skipRmScope: true });
863
+ }
864
+ return regexHit;
865
+ }
866
+ // Every `cd <target>` statement start (cwd persists across `&&`, `;`, `|`,
867
+ // newlines and `then`/`do`); the caller keeps the LAST one before the `rm`.
868
+ const CD_STATEMENT = /(?:^|[;&|\n(]|\bthen\b|\bdo\b)\s*cd\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/gi;
869
+ /**
870
+ * Bare `rm -rf *` wipes whatever the CURRENT directory is. When the same
871
+ * script has already `cd`'d into a scoped directory — a literal subpath such
872
+ * as `x`, `./build`, `"$SP/chk"`, `/tmp/work` — the star is that directory's
873
+ * contents: everyday scratch cleanup (`mkdir -p x && cd x && rm -rf * && unzip …`
874
+ * on a bank workstation, 2026-09-24). It stays destructive when the cd target
875
+ * is a filesystem root, a home root, a drive root, `..`/`-`/`.`, or a BARE
876
+ * variable (`cd "$X"` with X unset lands in $HOME). No cd at all keeps the
877
+ * conservative reading: the agent's cwd is usually the project.
878
+ */
879
+ function cdScopedBeforeBareStar(before) {
880
+ let last = null;
881
+ CD_STATEMENT.lastIndex = 0;
882
+ for (let m = CD_STATEMENT.exec(before); m; m = CD_STATEMENT.exec(before))
883
+ last = m;
884
+ if (!last)
885
+ return false;
886
+ // A `)` after the cd closes a subshell: `(cd x && …); rm -rf *` runs the rm
887
+ // back in the ORIGINAL directory, which the cd never changed.
888
+ if (before.slice(last.index + last[0].length).includes(')'))
889
+ return false;
890
+ const target = last[1].replace(/^["']|["']$/g, '').trim().replace(/\\/g, '/');
891
+ if (!target)
892
+ return false;
893
+ const lower = target.toLowerCase();
894
+ if (/^(?:\/+\*?|~\/*\*?|\.{1,2}\/?|-|\$home\/?|\$\{home\}\/?|%userprofile%\/?|\$env:userprofile\/?|[a-z]:\/*\*?)$/.test(lower))
895
+ return false;
896
+ if (/^(?:\$\{?[a-z_][a-z0-9_]*\}?|%[a-z_][a-z0-9_]*%|\$env:[a-z_][a-z0-9_]*)\/?$/.test(lower))
897
+ return false;
898
+ // At least one real path component that is not `.`/`..`.
899
+ return lower.split('/').some(part => part && part !== '.' && part !== '..');
900
+ }
901
+ function destructiveCommandReason(value, opts = {}) {
726
902
  return matchCommand(value, (text, lower) => {
727
- // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`). Terminators
728
- // include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
729
- // outer command — is caught too. Path-aware: only the root `/`, the root
730
- // wildcard `/*`, and a bare `*` are treated as destructive — scoped paths
731
- // (`/tmp/...`, `/var/cache/...`, `./build`) are everyday cleanup.
732
- if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?\/\*?["']?(?:\s|$|[;&|)])/.test(lower))
733
- return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
734
- // Home-ROOT only (`~`, `$HOME`, `%USERPROFILE%`, optional `/` or `/*`).
735
- // A subdirectory (`~/.cache`, `~/node_modules`) is everyday cleanup.
736
- if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/\*?)?["']?(?:\s|$|[;&|)])/.test(lower)) {
737
- return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
903
+ if (!opts.skipRmScope) {
904
+ // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`). Terminators
905
+ // include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
906
+ // outer command — is caught too. Path-aware: only the root `/`, the root
907
+ // wildcard `/*`, and a bare `*` are treated as destructive — scoped paths
908
+ // (`/tmp/...`, `/var/cache/...`, `./build`) are everyday cleanup.
909
+ if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?\/\*?["']?(?:\s|$|[;&|)])/.test(lower))
910
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
911
+ // Split or long flags: `rm -r -f /`, `rm -f -r /`, `rm --recursive --force /`
912
+ // (the structural parser saw these first — 2026-09-24; the floor must too).
913
+ const splitFlags = /\b(?:sudo\s+)?rm\s+((?:-(?:[a-z]+|-recursive|-force|-no-preserve-root)\s+){2,})["']?\/\*?["']?(?:\s|$|[;&|)])/.exec(lower);
914
+ if (splitFlags && /(?:^|\s)-(?:[a-z]*r|-recursive)/.test(splitFlags[1]) && /(?:^|\s)-(?:[a-z]*f|-force)/.test(splitFlags[1]))
915
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
916
+ // Home-ROOT only (`~`, `$HOME`, `%USERPROFILE%`, optional `/` or `/*`).
917
+ // A subdirectory (`~/.cache`, `~/node_modules`) is everyday cleanup.
918
+ if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/\*?)?["']?(?:\s|$|[;&|)])/.test(lower)) {
919
+ return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
920
+ }
921
+ // Bare `*` deletes the current directory tree — destructive on the HOST,
922
+ // but inside a container exec/run it is the container workdir (cleanup),
923
+ // and after an explicit `cd <subdir>` it is that scratch folder's contents.
924
+ // EVERY bare-star rm on the line is judged: `(cd x && rm -rf *); rm -rf *`
925
+ // has one scoped and one unscoped removal, and the unscoped one wipes.
926
+ const bareStars = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/g;
927
+ for (let bareStar = bareStars.exec(lower); bareStar; bareStar = bareStars.exec(lower)) {
928
+ const before = lower.slice(0, bareStar.index);
929
+ if (!CONTAINER_EXEC_BEFORE.test(before) && !cdScopedBeforeBareStar(before)) {
930
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of the current directory tree (rm -rf *)' };
931
+ }
932
+ }
738
933
  }
739
- // Bare `*` deletes the current directory tree — destructive on the HOST,
740
- // but inside a container exec/run it is the container workdir (cleanup).
741
- const bareStar = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/.exec(lower);
742
- if (bareStar && !CONTAINER_EXEC_BEFORE.test(lower.slice(0, bareStar.index)))
743
- return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
744
934
  // Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
745
935
  // is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
746
936
  // handles `"C:\"`. rd/rmdir are the same wipe under a different verb — the
@@ -1029,6 +1219,11 @@ function interpreterCodeSpawnsProcess(before, code) {
1029
1219
  return true;
1030
1220
  return BACKTICK_SHELL_INTERPRETER.test(before) && /`[^`]*`|%x[\[{(]/.test(code);
1031
1221
  }
1222
+ // An HTTP client in the same code makes a metadata endpoint literal a request target, not prose.
1223
+ const CODE_OPENS_REQUEST = /\b(?:urlopen|Request|requests|httpx|urllib3|aiohttp|http\.client|HTTPS?Connection|fetch|axios|got|XMLHttpRequest|https?\.(?:get|request)|Net::HTTP|LWP|HTTP::Tiny|URI\.open|curl_exec|file_get_contents|fsockopen|socket)\b/;
1224
+ function interpreterCodeRequestsMetadata(code) {
1225
+ return Boolean(containsMetadataEndpoint(code)) && CODE_OPENS_REQUEST.test(code);
1226
+ }
1032
1227
  /** First path argument of direct file APIs in inline language code, not printed examples. */
1033
1228
  function interpreterFileArguments(text) {
1034
1229
  if (text.length > 32_000)
@@ -1116,7 +1311,7 @@ function stripInertDataSegments(text) {
1116
1311
  const before = src.slice(Math.max(0, offset - 80), offset);
1117
1312
  // Language-interpreter code: data unless it spawns a process (see above).
1118
1313
  if (LANG_INTERPRETER_CODE_BEFORE.test(before))
1119
- return interpreterCodeSpawnsProcess(before, body) ? whole : INERT_DATA;
1314
+ return interpreterCodeSpawnsProcess(before, body) || interpreterCodeRequestsMetadata(body) ? whole : INERT_DATA;
1120
1315
  if (CODE_CONTEXT_BEFORE.test(before))
1121
1316
  return whole; // code position: executes
1122
1317
  if (dq !== undefined && INTERPOLATES.test(body))
@@ -1353,7 +1548,7 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
1353
1548
  if (finding)
1354
1549
  return finding;
1355
1550
  }
1356
- const destructive = destructiveCommandReason(value);
1551
+ const destructive = destructiveCommandDecision(value);
1357
1552
  if (destructive) {
1358
1553
  return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
1359
1554
  }
@@ -193,7 +193,27 @@ export interface HookIo {
193
193
  * mode, where `exit` only unwinds the evaluation and the process lives on.
194
194
  */
195
195
  exitsProcess: boolean;
196
+ /** Evaluated inside the resident daemon for a thin client (see DaemonDeclined). */
197
+ viaDaemon?: boolean;
198
+ /** Set when this evaluation's verdict is a stop still being settled (approval wait). */
199
+ pendingStop?: string;
196
200
  }
201
+ /**
202
+ * The daemon declines a request whose verdict is a human wait (org approval,
203
+ * taint approve-once). The thin client stops listening after ~2.5s and
204
+ * re-evaluates locally, so a daemon-side wait only opened a second approval
205
+ * request nobody would ever read. Declining sends the client to its local
206
+ * evaluation at once, where the one wait runs in the developer's own process.
207
+ */
208
+ export declare class DaemonDeclined extends Error {
209
+ }
210
+ /** Absolute time a human wait must end by: its own cap, and never past the IDE's timeout. */
211
+ export declare function humanWaitDeadline(waitMs: number, now?: number, startedAt?: number, ideTimeoutMs?: number): number;
212
+ /** Verdict for an unexpected error that escaped the evaluation (process-level handlers). */
213
+ export declare function crashVerdictForProcess(event: string | undefined, message: string): {
214
+ stdout: string;
215
+ exitCode: number;
216
+ };
197
217
  /**
198
218
  * Daemon-side entry: evaluate one hook request in-process and return the
199
219
  * verdict instead of writing/exiting. Everything the per-event hook process
@@ -202,7 +222,9 @@ export interface HookIo {
202
222
  * Mirrors hookCommand's fail-open contract: any unexpected exception yields
203
223
  * an allow verdict, never an error the thin client would surface.
204
224
  */
205
- export declare function evaluateHookRequest(args: HookArgs, stdinRaw: string, config: BotGuardConfig): Promise<{
225
+ export declare function evaluateHookRequest(args: HookArgs, stdinRaw: string, config: BotGuardConfig, opts?: {
226
+ viaDaemon?: boolean;
227
+ }): Promise<{
206
228
  stdout: string;
207
229
  stderr: string;
208
230
  exitCode: number;
@@ -217,4 +239,15 @@ export declare function evaluateHookRequest(args: HookArgs, stdinRaw: string, co
217
239
  * through respond() inside and never reach this catch.
218
240
  */
219
241
  export declare function hookCommand(args: HookArgs, config: BotGuardConfig): Promise<void>;
242
+ /**
243
+ * Errors that escape every await (an async 'error' event from a child process,
244
+ * a stray rejection) never reach the try/catch around the evaluation. Without
245
+ * these handlers Node prints a stack and exits 1 with NO verdict, which every
246
+ * client reads as a non-blocking hook error — the action runs. Route them
247
+ * through the same contract as a caught crash instead.
248
+ */
249
+ export declare function installHookCrashHandlers(verdict: (message: string) => {
250
+ stdout: string;
251
+ exitCode: number;
252
+ }): void;
220
253
  export {};