fullcourtdefense-cli 1.35.6 → 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
  /**
@@ -820,7 +822,9 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
820
822
  // this exact rule (same rule hash). Rules that forbid memory never reach here.
821
823
  // The caps in force come from the rule and the org ceiling in the cached bundle;
822
824
  // 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);
825
+ // The grant binds the facts THIS rule fired on: a credential-command rule is
826
+ // about the program, an egress / SSRF / exfil rule is about the host too.
827
+ const grantScope = (0, devConfirm_1.grantScopeFor)(event, call.toolName, call.toolArgs, operation, context, (0, devConfirm_1.grantRuleFactsFor)(source));
824
828
  const grantLimits = (0, devConfirm_1.resolveGrantLimits)(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)());
825
829
  const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
826
830
  if (grant) {
@@ -962,7 +966,7 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
962
966
  });
963
967
  const declaredOperations = (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName);
964
968
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(policies, call.toolName, operation, context, declaredOperations);
965
- (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)() });
966
970
  // Developer-scoped approvals need no backend at all — the IDE prompt IS the approver.
967
971
  if (tryDeveloperConfirmation(ctx, call, local, ctx.payload, 'offline'))
968
972
  return true;
@@ -1237,6 +1241,9 @@ function processIo(stdin, stdinErr) {
1237
1241
  * an allow verdict, never an error the thin client would surface.
1238
1242
  */
1239
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)();
1240
1247
  return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false });
1241
1248
  }
1242
1249
  /**
@@ -1317,6 +1324,11 @@ async function hookCommandOuter(args, config, startedAt) {
1317
1324
  // machine that silently stopped using the fast path is diagnosable.
1318
1325
  (0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail });
1319
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)() });
1320
1332
  // Buffered so an `ask` can be put to the developer in OUR dialog before the
1321
1333
  // IDE reads the verdict; exitsProcess stays true — this process ends right after.
1322
1334
  const result = await evaluateBuffered(args, raw, stdinErr, config, { isTTY: !!process.stdin.isTTY, exitsProcess: true });
@@ -1454,9 +1466,11 @@ async function hookCommandInner(args, config, io) {
1454
1466
  // this is instant when fresh and bounded (~1.5s) only when stale.
1455
1467
  let machineSuspended = false;
1456
1468
  let modeResolved = false;
1469
+ let runtimeBundleFlags = {};
1457
1470
  if (shieldId) {
1458
1471
  try {
1459
1472
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname(), hotPath: true });
1473
+ runtimeBundleFlags = bundle;
1460
1474
  // Kill-switch holds even on a 'default' bundle: with the cache missing
1461
1475
  // but a signed last-known-suspended marker standing, getRuntimeBundle
1462
1476
  // stamps suspended on the default — deleting the cache file must not
@@ -1484,6 +1498,10 @@ async function hookCommandInner(args, config, io) {
1484
1498
  }
1485
1499
  catch { /* keep the local default */ }
1486
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 });
1487
1505
  // Monitor-first: no authoritative bundle (fresh/uncached/fetch failed) => report-only.
1488
1506
  if (!modeResolved && !forceEnforce) {
1489
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.
@@ -334,7 +334,38 @@ export declare function listDeveloperGrants(now?: Date): DeveloperGrant[];
334
334
  export declare function revokeDeveloperGrants(key?: string): number;
335
335
  /** One line a person can read on the button and in the grant list: what "Always allow" covers. */
336
336
  export declare function describeGrantScope(scope: GrantScope): string;
337
- 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;
338
369
  /**
339
370
  * Is this exact action covered in the same session and policy context?
340
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
  /**
@@ -1200,7 +1201,25 @@ function scriptGrantKeys(command, cwd) {
1200
1201
  });
1201
1202
  return [...new Set(keys)].sort();
1202
1203
  }
1203
- 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) {
1204
1223
  const scope = { event, toolName, operation: String(operation || '').trim() || 'unknown' };
1205
1224
  if (event === 'shell') {
1206
1225
  const command = typeof toolArgs?.command === 'string' ? toolArgs.command : typeof toolArgs?.cmd === 'string' ? toolArgs.cmd : '';
@@ -1209,9 +1228,14 @@ function grantScopeFor(event, toolName, toolArgs, operation, context) {
1209
1228
  const scripts = scriptGrantKeys(command, cwd);
1210
1229
  if (scripts.length)
1211
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';
1212
1236
  }
1213
1237
  const domain = context?.['destination.domain'];
1214
- if (typeof domain === 'string' && domain.trim())
1238
+ if (facts.bindsDestination && typeof domain === 'string' && domain.trim())
1215
1239
  scope.destinationDomain = domain.trim().toLowerCase();
1216
1240
  return scope;
1217
1241
  }
@@ -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;