fullcourtdefense-cli 1.35.7 → 1.35.8

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.
@@ -69,6 +69,7 @@ const askDialog_1 = require("../askDialog");
69
69
  const hookIo_1 = require("../hookIo");
70
70
  const fileWriteCanon_1 = require("../fileWriteCanon");
71
71
  const discoverProxy_1 = require("./discoverProxy");
72
+ const shellAstLoader_1 = require("../shellAstLoader");
72
73
  function inferEvent(explicit, payload) {
73
74
  const e = (explicit || '').toLowerCase();
74
75
  if (e) {
@@ -552,6 +553,7 @@ function spoolCallEvent(call, rest) {
552
553
  toolName: call.toolName,
553
554
  operation: rest.operation || operation,
554
555
  evidence: rest.evidence || (0, telemetry_1.evidenceFromToolArgs)(call.toolArgs),
556
+ shellAst: rest.shellAst || (0, shellAstLoader_1.collectShellAstFacts)(),
555
557
  });
556
558
  }
557
559
  /**
@@ -964,7 +966,7 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
964
966
  });
965
967
  const declaredOperations = (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName);
966
968
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(policies, call.toolName, operation, context, declaredOperations);
967
- (0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail });
969
+ (0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail, astMode: (0, actionPolicyEngine_1.getShellAstMode)() });
968
970
  // Developer-scoped approvals need no backend at all — the IDE prompt IS the approver.
969
971
  if (tryDeveloperConfirmation(ctx, call, local, ctx.payload, 'offline'))
970
972
  return true;
@@ -1239,6 +1241,9 @@ function processIo(stdin, stdinErr) {
1239
1241
  * an allow verdict, never an error the thin client would surface.
1240
1242
  */
1241
1243
  async function evaluateHookRequest(args, stdinRaw, config) {
1244
+ // Structural shell facts (shadow by default). A missing/slow parser means
1245
+ // "no facts" and the regex floor decides alone — never a wait, never fail-open.
1246
+ await (0, shellAstLoader_1.ensureShellAst)();
1242
1247
  return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false });
1243
1248
  }
1244
1249
  /**
@@ -1319,6 +1324,11 @@ async function hookCommandOuter(args, config, startedAt) {
1319
1324
  // machine that silently stopped using the fast path is diagnosable.
1320
1325
  (0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail });
1321
1326
  }
1327
+ // Structural shell facts for the local evaluation (shadow by default; the
1328
+ // daemon path above initialises its own copy once). Bounded: a slow or
1329
+ // missing parser resolves to "no facts" and the regex floor decides alone.
1330
+ const astReady = await (0, shellAstLoader_1.ensureShellAst)();
1331
+ (0, hookIo_1.dbg)({ phase: 'shell_ast', ready: astReady, status: (0, shellAstLoader_1.shellAstStatus)() });
1322
1332
  // Buffered so an `ask` can be put to the developer in OUR dialog before the
1323
1333
  // IDE reads the verdict; exitsProcess stays true — this process ends right after.
1324
1334
  const result = await evaluateBuffered(args, raw, stdinErr, config, { isTTY: !!process.stdin.isTTY, exitsProcess: true });
@@ -1456,9 +1466,11 @@ async function hookCommandInner(args, config, io) {
1456
1466
  // this is instant when fresh and bounded (~1.5s) only when stale.
1457
1467
  let machineSuspended = false;
1458
1468
  let modeResolved = false;
1469
+ let runtimeBundleFlags = {};
1459
1470
  if (shieldId) {
1460
1471
  try {
1461
1472
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname(), hotPath: true });
1473
+ runtimeBundleFlags = bundle;
1462
1474
  // Kill-switch holds even on a 'default' bundle: with the cache missing
1463
1475
  // but a signed last-known-suspended marker standing, getRuntimeBundle
1464
1476
  // stamps suspended on the default — deleting the cache file must not
@@ -1486,6 +1498,10 @@ async function hookCommandInner(args, config, io) {
1486
1498
  }
1487
1499
  catch { /* keep the local default */ }
1488
1500
  }
1501
+ // Shell AST mode: FCD_SHELL_AST_MODE env, else the bundle's `shellAstMode`
1502
+ // (server-controlled rollout), else shadow — facts recorded, verdicts unchanged.
1503
+ (0, shellAstLoader_1.applyShellAstMode)((0, shellAstLoader_1.resolveShellAstMode)(process.env.FCD_SHELL_AST_MODE, runtimeBundleFlags.shellAstMode));
1504
+ (0, hookIo_1.dbg)({ phase: 'shell_ast_mode', mode: (0, actionPolicyEngine_1.getShellAstMode)(), fromEnv: process.env.FCD_SHELL_AST_MODE, fromBundle: runtimeBundleFlags.shellAstMode });
1489
1505
  // Monitor-first: no authoritative bundle (fresh/uncached/fetch failed) => report-only.
1490
1506
  if (!modeResolved && !forceEnforce) {
1491
1507
  shadow = true;
@@ -74,6 +74,7 @@ const policyGateHealth_1 = require("../policyGateHealth");
74
74
  const actionPolicyEngine_1 = require("../actionPolicyEngine");
75
75
  const machineIdentity_1 = require("../machineIdentity");
76
76
  const backupManifest_1 = require("../backupManifest");
77
+ const shellAstLoader_1 = require("../shellAstLoader");
77
78
  const devConfirm_1 = require("../devConfirm");
78
79
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
79
80
  const MANAGED_SERVER_NAME = 'agentguard-gateway';
@@ -2148,6 +2149,7 @@ class McpGatewayServer {
2148
2149
  }
2149
2150
  }
2150
2151
  async function mcpGatewayCommand(args, config) {
2152
+ await (0, shellAstLoader_1.ensureShellAstReady)(); // resident process: load the parser once, before the first tool call (shadow by default)
2151
2153
  // The gateway is a resident proxy in front of the IDE's MCP servers — if it
2152
2154
  // crashes, EVERY protected tool goes down at once. Any escaped exception
2153
2155
  // (detached flush, timer, transport edge case) logs one line and the proxy
@@ -66,7 +66,7 @@ function assertWorkloadSubstrate(kind, rt, options = {}) {
66
66
  + 'or pass --force true if this really is a disposable workload host.');
67
67
  }
68
68
  /** Connections the console has a section for; anything else is the generic snippet. */
69
- const ENROLLED_VIA_VALUES = new Set(['heroku', 'kubernetes', 'aws', 'gcp', 'snippet']);
69
+ const ENROLLED_VIA_VALUES = new Set(['heroku', 'kubernetes', 'aws', 'gcp', 'azure', 'snippet']);
70
70
  function resolveEnrolledVia(explicit, env, detected) {
71
71
  for (const candidate of [explicit, env.FCD_ENROLLED_VIA, detected]) {
72
72
  const clean = (candidate || '').trim().toLowerCase();
@@ -105,7 +105,11 @@ function detectWorkloadContext(env = process.env, fsProbe = fs) {
105
105
  }
106
106
  if (env.FUNCTIONS_WORKER_RUNTIME && env.WEBSITE_SITE_NAME) {
107
107
  // Azure Functions
108
- return { kind: 'serverless', name: explicitName || env.WEBSITE_SITE_NAME, environment, instanceId: env.WEBSITE_INSTANCE_ID, image };
108
+ return { kind: 'serverless', name: explicitName || env.WEBSITE_SITE_NAME, environment, instanceId: env.WEBSITE_INSTANCE_ID, image, enrolledVia: 'azure' };
109
+ }
110
+ if (env.CONTAINER_APP_NAME && env.CONTAINER_APP_REVISION) {
111
+ // Azure Container Apps: the app name is the stable identity, the replica the instance.
112
+ return { kind: 'container', name: explicitName || env.CONTAINER_APP_NAME, environment, instanceId: env.CONTAINER_APP_REPLICA_NAME || env.HOSTNAME, image, enrolledVia: 'azure' };
109
113
  }
110
114
  if (env.ECS_CONTAINER_METADATA_URI_V4 || env.ECS_CONTAINER_METADATA_URI) {
111
115
  // ECS / Fargate task: the service name comes from the task definition env.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shell AST facts — the structural view of a shell command line that the
3
+ * deterministic guard and the action-policy engine consume as DATA.
4
+ *
5
+ * A tree-sitter-bash parse tree is walked ONCE into a flat, JSON-serializable
6
+ * list of simple commands in execution order. Each command carries its argv
7
+ * with quoting resolved, which words are fully literal, the cwd scope in force
8
+ * when it runs (tracked through `cd`/`pushd`, subshells, loops, functions), the
9
+ * nesting it sits in, its redirections, and — for wrappers such as `sudo`,
10
+ * `bash -c '…'`, `docker run … sh -c '…'` — the inner script parsed recursively.
11
+ *
12
+ * Contract (the part that keeps this safe to put on the decision path):
13
+ *
14
+ * 1. FAIL CLOSED. `complete` is true only when the tree has no ERROR / MISSING
15
+ * node and every node type met is in the explicit allow-list below. Any
16
+ * structure this module does not understand makes the whole result
17
+ * incomplete, and an incomplete result may never RELAX a verdict — the
18
+ * regex floor in the guard and the engine then decides alone. (Same
19
+ * property Codex's `codex-command` and the tree-sitter rewrite of Claude
20
+ * Code's bash safety take: never interpret structure you cannot prove.)
21
+ * 2. Deterministic, no I/O. This file has no imports; the parser is injected
22
+ * as a minimal node interface so the same bytes are vendored into the
23
+ * backend (console simulator) and stay byte-identical with the CLI.
24
+ * 3. Data, not code. The facts cross into the hot-updatable detector worker
25
+ * as JSON. Nothing here executes, expands or resolves anything.
26
+ *
27
+ * Why an AST at all: every false positive of the last months was structural —
28
+ * quoted text mistaken for a command, a heredoc body scanned as shell, a bare
29
+ * `rm -rf *` after `cd scratch/` read as a root wipe, a `$( )` boundary missed.
30
+ * A parser answers those once; a regex answers them one incident at a time.
31
+ */
32
+ export interface ShellSyntaxNode {
33
+ type: string;
34
+ text: string;
35
+ startIndex: number;
36
+ endIndex: number;
37
+ childCount: number;
38
+ isMissing: boolean;
39
+ isNamed: boolean;
40
+ child(index: number): ShellSyntaxNode | null;
41
+ childForFieldName(name: string): ShellSyntaxNode | null;
42
+ }
43
+ export type CwdScope = 'scoped' | 'unscoped' | 'unknown';
44
+ export interface ShellRedirectFact {
45
+ op: string;
46
+ target: string;
47
+ literal: boolean;
48
+ }
49
+ export interface ShellWrapperFact {
50
+ kind: 'privilege' | 'shell' | 'eval' | 'exec' | 'env' | 'container' | 'timing' | 'xargs';
51
+ program: string;
52
+ /** The wrapped script when it is a literal string, parsed with the same walker. */
53
+ inner?: ShellFacts;
54
+ }
55
+ export interface ShellCommandFact {
56
+ /** argv[0] with quotes resolved; '' when it is dynamic (variable, substitution). */
57
+ name: string;
58
+ /** Words with quotes resolved. Dynamic parts keep their source text (`$X`, `$(…)`). */
59
+ argv: string[];
60
+ /** argv[i] is entirely literal: no expansion, substitution, glob-carrying variable. */
61
+ literal: boolean[];
62
+ /** Source text of the command including its redirects, EXCLUDING heredoc bodies. */
63
+ text: string;
64
+ start: number;
65
+ end: number;
66
+ /** cwd scope in force when this command runs. */
67
+ cwdScope: CwdScope;
68
+ depth: {
69
+ subshell: number;
70
+ function: number;
71
+ loop: number;
72
+ conditional: number;
73
+ substitution: number;
74
+ };
75
+ /** Members of one pipeline share an id (0 = not in a pipeline). */
76
+ pipeline: number;
77
+ redirects: ShellRedirectFact[];
78
+ hasHeredoc: boolean;
79
+ wrapper?: ShellWrapperFact;
80
+ }
81
+ export interface ShellFacts {
82
+ parser: 'tree-sitter-bash';
83
+ /** No parse errors and every node type understood. Only a complete result may relax a verdict. */
84
+ complete: boolean;
85
+ /** Why the result is incomplete (first reason). */
86
+ incompleteReason?: string;
87
+ commands: ShellCommandFact[];
88
+ /** Node types met that are not in the allow-list (for diagnostics / corpus growth). */
89
+ unknownNodeTypes: string[];
90
+ /** Any `eval`, `source`/`.`, or dynamic command name — structure the parse cannot see through. */
91
+ dynamicExecution: boolean;
92
+ }
93
+ /** What a `cd`/`pushd` target says about the cwd afterwards. */
94
+ export declare function cdTargetScope(target: string, literal: boolean): CwdScope;
95
+ /** A parsed inner script: its root plus the release of the parser's native tree. */
96
+ export interface ParsedShellTree {
97
+ root: ShellSyntaxNode;
98
+ release(): void;
99
+ }
100
+ export declare const MAX_SHELL_AST_SOURCE = 64000;
101
+ /**
102
+ * Walk a parsed tree into facts. `nesting` guards the recursion through
103
+ * literal `bash -c` scripts (depth 3 is plenty; deeper is treated as unknown).
104
+ */
105
+ export declare function analyzeShellTree(root: ShellSyntaxNode, nesting?: number): ShellFacts;
106
+ /** Register the grammar-backed parser used for inner `bash -c` scripts. */
107
+ export declare function setInnerShellParser(parse: ((source: string) => ParsedShellTree | undefined) | undefined): void;
108
+ /** All commands, including those inside literal wrapper scripts, depth-first in execution order. */
109
+ export declare function flattenShellCommands(facts: ShellFacts): ShellCommandFact[];
110
+ /** True when every part of the analysis (including inner scripts) is complete. */
111
+ export declare function shellFactsFullyComplete(facts: ShellFacts | undefined): facts is ShellFacts;
112
+ /**
113
+ * Text that looks like PowerShell — tree-sitter-bash will often parse it
114
+ * WITHOUT errors into nonsense, so the loader refuses it up front and the
115
+ * PowerShell model in the engine keeps deciding alone.
116
+ */
117
+ export declare function looksLikePowerShell(command: string): boolean;