fullcourtdefense-cli 1.24.0 → 1.24.2

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.
@@ -294,7 +294,13 @@ function operationMatchesRule(operation, ruleOperations, matchText = '', toolCap
294
294
  const ruleAliases = operationAliases(upper);
295
295
  if ([...ruleAliases].some(alias => opAliases.has(alias)))
296
296
  return true;
297
- if (op === upper || op.includes(upper) || upper.includes(op))
297
+ if (op === upper)
298
+ return true;
299
+ // Token-boundary containment, both directions — never raw substring. Raw substring
300
+ // matching let operation GET satisfy a "nuget push" rule ("nuGET") and would let
301
+ // SELECT match inside SELECTED. With boundaries, DELETE still matches DELETE_FILE,
302
+ // and operation "deploy" still satisfies a "gcloud run deploy" bank entry.
303
+ if (keywordAppearsAsWord(upper, op) || keywordAppearsAsWord(op, upper))
298
304
  return true;
299
305
  // READ is a category-level rule. It should cover read-only DB verbs observed from shell/SQL
300
306
  // activity, while a narrow SELECT/SHOW rule stays precise and does not match every READ.
@@ -347,6 +353,20 @@ const OPERATION_MATCH_SKIP_FIELDS = new Set([
347
353
  // Identity / metadata — NOT action signals. Excluded so the tool/agent name can never
348
354
  // pollute operation matching.
349
355
  'toolName', 'agentName', 'developerName', 'machineName', 'ipAddress', 'agentClient', 'gateway', 'mcpServer', 'environment',
356
+ // Document / file CONTENT — data being written or displayed, not the action itself.
357
+ // A file whose text mentions "delete" or "npm publish" is not a delete or a deploy;
358
+ // matching rule verbs against document bodies pauses ordinary file edits (real incident:
359
+ // writing a comment containing "safe to delete" tripped the Standard role's delete rule).
360
+ // The action signal for file ops is the operation (read/write/delete) + path; content
361
+ // safety (secrets, dangerous text) is Local Safety's job, not verb matching.
362
+ 'content', 'contents', 'text', 'body', 'data', 'diff', 'patch', 'newText', 'oldText',
363
+ // Search PATTERNS — text being looked for, not an action (see search-intent handling
364
+ // in inferToolContext). Searching for "DROP TABLE" is a read.
365
+ 'search.pattern',
366
+ // File PATHS — nouns, not verbs. Reading a file named "how-to-delete-accounts.md"
367
+ // is a read; the verb signal for file ops is the OPERATION (read/write/delete).
368
+ // Paths stay in context for path-based rule CONSTRAINTS, which are unaffected.
369
+ 'path', 'filepath', 'file', 'filename', 'dir', 'directory', 'cwd',
350
370
  ]);
351
371
  function buildOperationMatchText(_toolName, context) {
352
372
  const parts = [];
@@ -546,10 +566,30 @@ function inferToolContext(toolName, args) {
546
566
  context[key] = String(val);
547
567
  }
548
568
  }
569
+ // Search-intent tools: their `query` is a text PATTERN to look for, not an action to run.
570
+ // Searching a codebase for "DROP TABLE" is a read — classifying the pattern as SQL would
571
+ // pause every code/document search that mentions a governed verb. The destructive-verb
572
+ // exclusion keeps hybrid tools (e.g. search_and_delete) out of this shortcut.
573
+ const nameWords = toolNameLower.replace(/[_\-.]/g, ' ');
574
+ const isSearchTool = /\b(search|grep|find|lookup|locate)\b/.test(nameWords)
575
+ && !/\b(delete|remove|drop|write|update|insert|replace|create)\b/.test(nameWords);
576
+ if (isSearchTool) {
577
+ operation = 'read';
578
+ if (typeof context.query === 'string') {
579
+ // Keep the pattern reachable for constraints, but OUT of `query` — `query` feeds
580
+ // verb match text and database session budgets, which a search pattern must not.
581
+ context['search.pattern'] = context.query;
582
+ delete context.query;
583
+ }
584
+ }
549
585
  // SQL detection: extract the SQL verb from common query/code/command payloads.
586
+ // Hyphen-glued matches are NOT SQL: PowerShell cmdlets (`Select-Object`, `Select-String`)
587
+ // and CLI flags (`--delete-branch`) must not classify as SELECT/DELETE — real SQL never
588
+ // hyphenates these verbs. Misclassifying also poisons context.query, which would count
589
+ // ordinary shell pipes against database session budgets.
550
590
  const sqlArg = args.query || args.sql || args.statement || args.command || args.code || args.script || args.input || '';
551
- if (typeof sqlArg === 'string' && sqlArg.length > 0) {
552
- const sqlMatch = sqlArg.match(/\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b/i);
591
+ if (!isSearchTool && typeof sqlArg === 'string' && sqlArg.length > 0) {
592
+ const sqlMatch = sqlArg.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
553
593
  if (sqlMatch) {
554
594
  operation = sqlMatch[1].toUpperCase();
555
595
  context.query = sqlArg;
@@ -32,6 +32,7 @@ export declare function discoverSweepCredentialEnv(credentials?: {
32
32
  shieldKey?: string;
33
33
  apiUrl?: string;
34
34
  }): Record<string, string>;
35
+ export declare function cliVersion(): string | undefined;
35
36
  export type TaskLogonType = 'S4U' | 'InteractiveToken';
36
37
  /** Fully-qualified current user (DOMAIN\\user) for the task principal. */
37
38
  export declare function taskUserId(): string;
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.daemonDiscoverSweepArgs = daemonDiscoverSweepArgs;
37
37
  exports.summarizeDiscoverStderr = summarizeDiscoverStderr;
38
38
  exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
39
+ exports.cliVersion = cliVersion;
39
40
  exports.taskUserId = taskUserId;
40
41
  exports.taskLocalTimestamp = taskLocalTimestamp;
41
42
  exports.buildTaskXml = buildTaskXml;
@@ -383,7 +384,15 @@ async function runDaemon(args, config) {
383
384
  // Crash forensics: was the previous daemon killed without a clean shutdown?
384
385
  const postmortem = (0, daemonForensics_1.recordDaemonStart)({ version: cliVersion(), isPidAlive });
385
386
  if (postmortem) {
386
- log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
387
+ const current = cliVersion();
388
+ if (postmortem.version && current && postmortem.version !== current) {
389
+ // The silent updater stops the old daemon forcefully — an unclean death
390
+ // of an OLDER version is the upgrade itself, not something to alarm on.
391
+ log(`Previous daemon (v${postmortem.version}) was replaced by this upgrade to v${current} — the updater's forced stop is expected, not a crash. Post-mortem recorded for the timeline.`);
392
+ }
393
+ else {
394
+ log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
395
+ }
387
396
  }
388
397
  // A fresh daemon runs right after every successful MSI update: if a
389
398
  // previously-reported update loop has been resolved by this version, clear
@@ -59,6 +59,7 @@ const deterministicGuard_1 = require("./deterministicGuard");
59
59
  const restartNotice_1 = require("./restartNotice");
60
60
  const localSafetySnapshot_1 = require("../localSafetySnapshot");
61
61
  const runtimeConfig_1 = require("../runtimeConfig");
62
+ const sessionLimits_1 = require("../sessionLimits");
62
63
  const telemetry_1 = require("../telemetry");
63
64
  const notify_1 = require("../notify");
64
65
  const distress_1 = require("../distress");
@@ -864,6 +865,7 @@ class McpGatewayServer {
864
865
  // locally with the same engine the server runs (actionPolicyEngine.ts).
865
866
  let cachedPolicies;
866
867
  let reportOnlyMode = false;
868
+ let machineRole;
867
869
  try {
868
870
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
869
871
  apiUrl: this.gatewayConfig.apiUrl,
@@ -875,6 +877,7 @@ class McpGatewayServer {
875
877
  if (bundle.source !== 'default') {
876
878
  expectedPolicyHash = bundle.policyHash || bundle.version;
877
879
  cachedPolicies = bundle.actionPolicies;
880
+ machineRole = bundle.machineRole;
878
881
  reportOnlyMode = bundle.mode !== 'block'; // monitor/shadow machines must never block
879
882
  // Machine-level offline stance (same rule as the IDE hooks): the
880
883
  // bundle governs unless the gateway was installed with an explicit
@@ -921,6 +924,37 @@ class McpGatewayServer {
921
924
  const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
922
925
  throw new Error(`${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId}: ${localBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety.`);
923
926
  }
927
+ // --- Machine-role session amount limits (pre-call: operation counts) ---
928
+ // The role's verb rules ride actionPolicies; this enforces the AMOUNTS.
929
+ // Count the classified operation, then stop the session once it exceeds
930
+ // the role's per-session budget — the exfiltration circuit breaker: each
931
+ // SELECT looks benign, the volume is the signal.
932
+ const limitSessionId = runtimeSessionId() || `gateway-${this.gatewayConfig.agentName || 'default'}`;
933
+ // Classified once here; the response-volume accounting below reuses the
934
+ // same target so DB rows land in the DB budget and file bytes in the file budget.
935
+ const limitInferred = (0, actionPolicyEngine_1.inferToolContext)(toolName, toolArgs);
936
+ const limitClassification = (0, sessionLimits_1.classifyOpForLimits)(operation || limitInferred.operation, limitInferred.context);
937
+ if (machineRole) {
938
+ const inferredOp = operation || limitInferred.operation;
939
+ const usage = limitClassification
940
+ ? (0, sessionLimits_1.recordSessionOp)(limitSessionId, limitClassification.target, limitClassification.opClass)
941
+ : (0, sessionLimits_1.getSessionUsage)(limitSessionId);
942
+ const violation = (0, sessionLimits_1.checkSessionLimits)(usage, machineRole.limits);
943
+ if (violation) {
944
+ const reason = `Machine role "${machineRole.name}": ${violation.description}.`;
945
+ if (machineRole.stage === 'monitor' || reportOnlyMode) {
946
+ (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation: inferredOp, reason: `[monitor] ${reason}`, ruleId: `machine-role-limit:${violation.limit}`, offlineEnforced: true });
947
+ (0, telemetry_1.triggerFlush)(false);
948
+ process.stderr.write(`AgentGuard (monitor): ${reason}\n`);
949
+ }
950
+ else {
951
+ (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation: inferredOp, reason, ruleId: `machine-role-limit:${violation.limit}`, offlineEnforced: true });
952
+ (0, telemetry_1.triggerFlush)(true);
953
+ (0, notify_1.notifyOs)({ title: 'FullCourtDefense — session limit reached', message: `${toolName} blocked: ${violation.description}. Adjust the machine's role in the console if this is expected.` });
954
+ throw new Error(`FullCourtDefense blocked ${toolName} — ${reason} An admin can raise the limit or change this machine's role in the console → AI Fleet → Roles.`);
955
+ }
956
+ }
957
+ }
924
958
  let preflight;
925
959
  try {
926
960
  preflight = await this.api.checkToolCall({ toolName, operation, toolArgs });
@@ -1044,6 +1078,35 @@ class McpGatewayServer {
1044
1078
  process.stderr.write(`AgentGuard masked a ${finding.itemId} in the ${toolName} response.\n`);
1045
1079
  }
1046
1080
  }
1081
+ // --- Machine-role session amount limits (post-call: response volume) ---
1082
+ // Measure what the tool actually returned (after masking) and stop the
1083
+ // response from reaching the agent once the session's cumulative MB/rows
1084
+ // budget is exhausted. The data stays on the MCP server — nothing above
1085
+ // the limit is ever handed to the model.
1086
+ if (machineRole) {
1087
+ const responseText = contentToText(rawResult);
1088
+ const responseBytes = Buffer.byteLength(typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult ?? ''), 'utf8');
1089
+ const usage = (0, sessionLimits_1.recordSessionResponse)(limitSessionId, {
1090
+ bytes: responseBytes,
1091
+ rows: (0, sessionLimits_1.countResponseRows)(responseText),
1092
+ target: limitClassification?.target ?? 'other',
1093
+ });
1094
+ const violation = (0, sessionLimits_1.checkSessionLimits)(usage, machineRole.limits);
1095
+ if (violation && sessionLimits_1.RESPONSE_VOLUME_LIMITS.has(violation.limit)) {
1096
+ const reason = `Machine role "${machineRole.name}": ${violation.description}.`;
1097
+ if (machineRole.stage === 'monitor' || reportOnlyMode) {
1098
+ (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation, reason: `[monitor] ${reason}`, ruleId: `machine-role-limit:${violation.limit}`, offlineEnforced: true });
1099
+ (0, telemetry_1.triggerFlush)(false);
1100
+ process.stderr.write(`AgentGuard (monitor): ${reason}\n`);
1101
+ }
1102
+ else {
1103
+ (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason, ruleId: `machine-role-limit:${violation.limit}`, offlineEnforced: true });
1104
+ (0, telemetry_1.triggerFlush)(true);
1105
+ (0, notify_1.notifyOs)({ title: 'FullCourtDefense — session data limit reached', message: `${toolName} response withheld: ${violation.description}.` });
1106
+ throw new Error(`FullCourtDefense withheld the ${toolName} response — ${reason} The session already pulled its data budget; an admin can raise the limit or change this machine's role in the console → AI Fleet → Roles.`);
1107
+ }
1108
+ }
1109
+ }
1047
1110
  const finalResult = this.gatewayConfig.scanResponse
1048
1111
  ? await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult })
1049
1112
  : rawResult;
@@ -37,6 +37,13 @@ export declare function collectEnvironmentDiagnostics(options?: {
37
37
  }): Promise<EnvironmentDiagnostics>;
38
38
  /** One-line human summary for the onboarding journal / MSI screen / daemon log. */
39
39
  export declare function summarizeEnvironmentDiagnostics(diag: EnvironmentDiagnostics): string;
40
+ /**
41
+ * The silent updater stops the old daemon FORCEFULLY, so an upgrade always
42
+ * leaves an "unclean death" post-mortem — of the OLD version. When the dead
43
+ * daemon's version differs from the CLI running now, that death was the
44
+ * upgrade replacing it: routine, expected, and not a health problem.
45
+ */
46
+ export declare function crashLooksLikeUpgradeRestart(crash: DaemonPostmortem): boolean;
40
47
  /**
41
48
  * Verdict for the onboarding journal: healthy unless something on this
42
49
  * machine is actively interfering (daemon killed, PowerShell blocked, or a
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.collectEnvironmentDiagnostics = collectEnvironmentDiagnostics;
4
4
  exports.summarizeEnvironmentDiagnostics = summarizeEnvironmentDiagnostics;
5
+ exports.crashLooksLikeUpgradeRestart = crashLooksLikeUpgradeRestart;
5
6
  exports.environmentLooksHealthy = environmentLooksHealthy;
6
7
  const config_1 = require("./config");
7
8
  const daemon_1 = require("./commands/daemon");
@@ -77,10 +78,23 @@ function summarizeEnvironmentDiagnostics(diag) {
77
78
  parts.push(diag.daemonAlive ? 'daemon alive' : 'daemon not running');
78
79
  }
79
80
  parts.push(diag.watchdogTaskInstalled ? 'watchdog installed' : 'watchdog task missing');
80
- if (diag.lastCrash)
81
- parts.push(`previous daemon died uncleanly at ${diag.lastCrash.lastAliveAt || 'unknown time'}`);
81
+ if (diag.lastCrash) {
82
+ parts.push(crashLooksLikeUpgradeRestart(diag.lastCrash)
83
+ ? `previous daemon (v${diag.lastCrash.version}) was replaced by a CLI upgrade — routine, not a crash`
84
+ : `previous daemon died uncleanly at ${diag.lastCrash.lastAliveAt || 'unknown time'}`);
85
+ }
82
86
  return parts.join('; ');
83
87
  }
88
+ /**
89
+ * The silent updater stops the old daemon FORCEFULLY, so an upgrade always
90
+ * leaves an "unclean death" post-mortem — of the OLD version. When the dead
91
+ * daemon's version differs from the CLI running now, that death was the
92
+ * upgrade replacing it: routine, expected, and not a health problem.
93
+ */
94
+ function crashLooksLikeUpgradeRestart(crash) {
95
+ const current = (0, daemon_1.cliVersion)();
96
+ return Boolean(crash.version && current && crash.version !== current);
97
+ }
84
98
  /**
85
99
  * Verdict for the onboarding journal: healthy unless something on this
86
100
  * machine is actively interfering (daemon killed, PowerShell blocked, or a
@@ -92,7 +106,9 @@ function environmentLooksHealthy(diag) {
92
106
  return false;
93
107
  if (diag.powershellSpawnOk === false)
94
108
  return false;
95
- if (diag.lastCrash)
109
+ // Upgrade restarts are not crashes — only a same-version unclean death
110
+ // (something actually killed the daemon) marks the environment unhealthy.
111
+ if (diag.lastCrash && !crashLooksLikeUpgradeRestart(diag.lastCrash))
96
112
  return false;
97
113
  return true;
98
114
  }
@@ -45,6 +45,20 @@ export interface RuntimeBundle {
45
45
  * covers policy edits, so console changes refresh this within one poll.
46
46
  */
47
47
  actionPolicies?: EngineActionPolicy[];
48
+ /**
49
+ * This machine's role profile (least-privilege preset assigned in the console).
50
+ * Verb rules already ride `actionPolicies` as compiled policies; this block
51
+ * carries the per-session AMOUNT limits enforced by local session counters
52
+ * (hooks + MCP gateway). `null` limit = unlimited.
53
+ */
54
+ machineRole?: {
55
+ roleId: string;
56
+ name: string;
57
+ summary?: string;
58
+ stage?: 'monitor' | 'enforce';
59
+ /** Split by target (db / file / overall); legacy pre-split keys are normalized on read. */
60
+ limits: import('./sessionLimits').SessionRoleLimits;
61
+ };
48
62
  /** One constrained, auditable action queued for the resident daemon. */
49
63
  machineAction?: {
50
64
  id: string;
@@ -41,6 +41,7 @@ const fs = __importStar(require("fs"));
41
41
  const os = __importStar(require("os"));
42
42
  const path = __importStar(require("path"));
43
43
  const distress_1 = require("./distress");
44
+ const sessionLimits_1 = require("./sessionLimits");
44
45
  const CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.json');
45
46
  const DEFAULT_TTL_MS = 60_000;
46
47
  const REFRESH_TIMEOUT_MS = 1_500; // tight: the hook must stay fast
@@ -63,6 +64,27 @@ function writeCacheFile(data) {
63
64
  function isMode(value) {
64
65
  return value === 'block' || value === 'monitor' || value === 'shadow';
65
66
  }
67
+ /** A cached entry as an EffectiveBundle — single place that decides which fields survive the cache. */
68
+ function cachedToEffective(cached) {
69
+ const { fetchedAt: _fetchedAt, ...bundle } = cached;
70
+ return { ...bundle, source: 'cache' };
71
+ }
72
+ /** Keep only a well-formed machine-role block (limits must be numbers or null; legacy keys normalized). */
73
+ function sanitizeMachineRole(value) {
74
+ if (!value || typeof value !== 'object')
75
+ return undefined;
76
+ const role = value;
77
+ if (typeof role.roleId !== 'string' || typeof role.name !== 'string')
78
+ return undefined;
79
+ const rawLimits = (role.limits && typeof role.limits === 'object' ? role.limits : {});
80
+ return {
81
+ roleId: role.roleId,
82
+ name: role.name,
83
+ summary: typeof role.summary === 'string' ? role.summary : undefined,
84
+ stage: role.stage === 'monitor' ? 'monitor' : 'enforce',
85
+ limits: (0, sessionLimits_1.normalizeSessionLimits)(rawLimits),
86
+ };
87
+ }
66
88
  /** Keep only well-formed policies — a malformed server payload must never poison the offline cache. */
67
89
  function sanitizeBundlePolicies(value) {
68
90
  if (!Array.isArray(value))
@@ -85,7 +107,7 @@ async function getRuntimeBundle(input) {
85
107
  const cached = cache[input.shieldId];
86
108
  const fresh = cached && Date.now() - cached.fetchedAt < ttl;
87
109
  if (cached && fresh && !input.force) {
88
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, honeypot: cached.honeypot, machineAction: cached.machineAction, actionPolicies: cached.actionPolicies, source: 'cache' };
110
+ return cachedToEffective(cached);
89
111
  }
90
112
  try {
91
113
  const headers = { 'Content-Type': 'application/json' };
@@ -105,7 +127,7 @@ async function getRuntimeBundle(input) {
105
127
  if (resp.status === 304 && cached) {
106
128
  cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
107
129
  writeCacheFile(cache);
108
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, honeypot: cached.honeypot, machineAction: cached.machineAction, actionPolicies: cached.actionPolicies, source: 'cache' };
130
+ return cachedToEffective(cached);
109
131
  }
110
132
  if (resp.status === 401 || resp.status === 403) {
111
133
  // The backend REACHED us and rejected the key — a credential problem,
@@ -140,6 +162,7 @@ async function getRuntimeBundle(input) {
140
162
  : undefined,
141
163
  machineAction: body.data.machineAction,
142
164
  actionPolicies: sanitizeBundlePolicies(body.data.actionPolicies),
165
+ machineRole: sanitizeMachineRole(body.data.machineRole),
143
166
  fetchedAt: Date.now(),
144
167
  };
145
168
  cache[input.shieldId] = entry;
@@ -154,7 +177,7 @@ async function getRuntimeBundle(input) {
154
177
  /* fall through to cache / default */
155
178
  }
156
179
  if (cached) {
157
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, honeypot: cached.honeypot, machineAction: cached.machineAction, actionPolicies: cached.actionPolicies, source: 'cache' };
180
+ return cachedToEffective(cached);
158
181
  }
159
182
  return { mode: 'block', version: '', source: 'default' };
160
183
  }
@@ -0,0 +1,76 @@
1
+ export interface SessionRoleLimits {
2
+ maxDbReadsPerSession: number | null;
3
+ maxDbWritesPerSession: number | null;
4
+ maxDbDeletesPerSession: number | null;
5
+ maxDbRowsPerSession: number | null;
6
+ maxFileReadsPerSession: number | null;
7
+ maxFileWritesPerSession: number | null;
8
+ maxFileDeletesPerSession: number | null;
9
+ maxFileMbPerSession: number | null;
10
+ maxResponseMbPerSession: number | null;
11
+ }
12
+ /** Normalize a raw bundle limits object (new or legacy keys) into the split shape. */
13
+ export declare function normalizeSessionLimits(raw: Record<string, unknown> | undefined | null): SessionRoleLimits;
14
+ export interface SessionUsage {
15
+ sessionId: string;
16
+ dbReads: number;
17
+ dbWrites: number;
18
+ dbDeletes: number;
19
+ /** Cumulative rows returned by database tools. */
20
+ dbRows: number;
21
+ fileReads: number;
22
+ fileWrites: number;
23
+ fileDeletes: number;
24
+ /** Cumulative bytes moved by file tools. */
25
+ fileBytes: number;
26
+ /** Cumulative bytes of ALL tool responses, any target. */
27
+ responseBytes: number;
28
+ updatedAt: string;
29
+ }
30
+ export interface SessionLimitViolation {
31
+ limit: keyof SessionRoleLimits;
32
+ configured: number;
33
+ actual: number;
34
+ /** Human sentence for block messages and console events. */
35
+ description: string;
36
+ }
37
+ export type SessionOpClass = 'read' | 'write' | 'delete';
38
+ export type SessionOpTarget = 'db' | 'file' | 'other';
39
+ export declare function getSessionUsage(sessionId: string): SessionUsage;
40
+ /** Record one classified operation against its target; returns the updated running totals. */
41
+ export declare function recordSessionOp(sessionId: string, target: SessionOpTarget, opClass: SessionOpClass): SessionUsage;
42
+ /**
43
+ * Record tool-response volume (MCP gateway). Bytes always count toward the
44
+ * overall cap; rows count toward the DB budget only for DB-target calls, and
45
+ * bytes toward the file budget only for file-target calls.
46
+ */
47
+ export declare function recordSessionResponse(sessionId: string, input: {
48
+ bytes: number;
49
+ rows: number;
50
+ target: SessionOpTarget;
51
+ }): SessionUsage;
52
+ /**
53
+ * Classify an engine-inferred operation into a counter class AND target.
54
+ * The engine's context is the discriminator: SQL detection sets `query`,
55
+ * file-path detection sets `path`. Without context, provenance decides
56
+ * (SQL-only verbs are uppercase, file/shell verbs lowercase); HTTP methods
57
+ * land in 'other' (covered only by the overall response-volume cap).
58
+ * MUST mirror backend/src/modules/policies/roles.ts `classifyOpForLimits` —
59
+ * the two sides count the same ops or the console's numbers lie.
60
+ */
61
+ export declare function classifyOpForLimits(operation: string, context?: Record<string, string>): {
62
+ target: SessionOpTarget;
63
+ opClass: SessionOpClass;
64
+ } | undefined;
65
+ /** Limits that meter RESPONSE volume (checked after the tool ran, response withheld). */
66
+ export declare const RESPONSE_VOLUME_LIMITS: ReadonlySet<keyof SessionRoleLimits>;
67
+ /** First exceeded limit for the given usage, or undefined when within bounds. */
68
+ export declare function checkSessionLimits(usage: SessionUsage, limits: SessionRoleLimits): SessionLimitViolation | undefined;
69
+ /**
70
+ * Estimate how many rows/records a tool response text carries.
71
+ * JSON: total element count across arrays (a page of DB rows = its length).
72
+ * Non-JSON: non-empty line count (CSV / table / psql output ≈ one row per line).
73
+ * Deterministic heuristic — used only for cumulative volume limits, never
74
+ * for per-item decisions, so approximate is fine.
75
+ */
76
+ export declare function countResponseRows(text: string): number;
@@ -0,0 +1,330 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.RESPONSE_VOLUME_LIMITS = void 0;
37
+ exports.normalizeSessionLimits = normalizeSessionLimits;
38
+ exports.getSessionUsage = getSessionUsage;
39
+ exports.recordSessionOp = recordSessionOp;
40
+ exports.recordSessionResponse = recordSessionResponse;
41
+ exports.classifyOpForLimits = classifyOpForLimits;
42
+ exports.checkSessionLimits = checkSessionLimits;
43
+ exports.countResponseRows = countResponseRows;
44
+ /**
45
+ * Session amount limits — local counters enforcing a machine role's
46
+ * per-session data limits (from the runtime bundle's `machineRole.limits`).
47
+ *
48
+ * The role's VERB rules (read/write/delete/...) ride the bundle as compiled
49
+ * Action Policies and are enforced by the shared engine. This module enforces
50
+ * the AMOUNTS, split by TARGET: database operations (queries + rows returned)
51
+ * and file operations (ops + MB moved) are metered separately, plus one
52
+ * overall response-volume cap covering every tool regardless of target.
53
+ * "1,000 file reads is a normal build; 1,000 SELECTs is an exfiltration" —
54
+ * different judgments need different budgets.
55
+ *
56
+ * Counters are per agent session, persisted as small JSON files under
57
+ * ~/.fullcourtdefense/sessions/ so every short-lived hook invocation and the
58
+ * long-running MCP gateway see the same running totals. Purely local — works
59
+ * offline, no backend needed. Files older than 48h are pruned (an agent
60
+ * session never legitimately spans days).
61
+ *
62
+ * The Bits-of-Gold-style scenario this exists for: a compromised agent that
63
+ * starts bulk-reading customer data. Each individual SELECT looks benign; the
64
+ * VOLUME is the signal. A role limit of e.g. 10,000 DB rows/session stops the
65
+ * bleed mid-exfiltration, deterministically, before any AI judgment.
66
+ */
67
+ const crypto = __importStar(require("crypto"));
68
+ const fs = __importStar(require("fs"));
69
+ const os = __importStar(require("os"));
70
+ const path = __importStar(require("path"));
71
+ /**
72
+ * Legacy (pre-split) bundle keys → the new keys they govern. An old backend
73
+ * may still send them; they apply to every successor not explicitly set.
74
+ * MUST mirror backend/src/modules/policies/roles.ts `LEGACY_LIMIT_KEY_MAP`.
75
+ */
76
+ const LEGACY_LIMIT_KEY_MAP = {
77
+ maxReadOpsPerSession: ['maxDbReadsPerSession', 'maxFileReadsPerSession'],
78
+ maxWriteOpsPerSession: ['maxDbWritesPerSession', 'maxFileWritesPerSession'],
79
+ maxDeleteOpsPerSession: ['maxDbDeletesPerSession', 'maxFileDeletesPerSession'],
80
+ maxResponseRowsPerSession: ['maxDbRowsPerSession'],
81
+ };
82
+ const LIMIT_KEYS = [
83
+ 'maxDbReadsPerSession', 'maxDbWritesPerSession', 'maxDbDeletesPerSession', 'maxDbRowsPerSession',
84
+ 'maxFileReadsPerSession', 'maxFileWritesPerSession', 'maxFileDeletesPerSession', 'maxFileMbPerSession',
85
+ 'maxResponseMbPerSession',
86
+ ];
87
+ /** Normalize a raw bundle limits object (new or legacy keys) into the split shape. */
88
+ function normalizeSessionLimits(raw) {
89
+ const valid = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
90
+ const limits = {
91
+ maxDbReadsPerSession: null,
92
+ maxDbWritesPerSession: null,
93
+ maxDbDeletesPerSession: null,
94
+ maxDbRowsPerSession: null,
95
+ maxFileReadsPerSession: null,
96
+ maxFileWritesPerSession: null,
97
+ maxFileDeletesPerSession: null,
98
+ maxFileMbPerSession: null,
99
+ maxResponseMbPerSession: null,
100
+ };
101
+ if (!raw || typeof raw !== 'object')
102
+ return limits;
103
+ for (const key of LIMIT_KEYS) {
104
+ const value = raw[key];
105
+ if (valid(value))
106
+ limits[key] = value;
107
+ }
108
+ for (const [legacyKey, successors] of Object.entries(LEGACY_LIMIT_KEY_MAP)) {
109
+ const value = raw[legacyKey];
110
+ if (!valid(value))
111
+ continue;
112
+ for (const successor of successors) {
113
+ if (limits[successor] === null && !(successor in raw && raw[successor] !== undefined && raw[successor] !== null)) {
114
+ limits[successor] = value;
115
+ }
116
+ }
117
+ }
118
+ return limits;
119
+ }
120
+ const SESSION_FILE_TTL_MS = 48 * 60 * 60 * 1000;
121
+ function sessionsDir() {
122
+ return path.join(os.homedir(), '.fullcourtdefense', 'sessions');
123
+ }
124
+ function usagePath(sessionId) {
125
+ const safe = crypto.createHash('sha1').update(sessionId || 'default').digest('hex').slice(0, 24);
126
+ return path.join(sessionsDir(), `${safe}.json`);
127
+ }
128
+ function emptyUsage(sessionId) {
129
+ return {
130
+ sessionId,
131
+ dbReads: 0, dbWrites: 0, dbDeletes: 0, dbRows: 0,
132
+ fileReads: 0, fileWrites: 0, fileDeletes: 0, fileBytes: 0,
133
+ responseBytes: 0,
134
+ updatedAt: new Date().toISOString(),
135
+ };
136
+ }
137
+ /** Best-effort removal of stale session counter files. */
138
+ function pruneSessions() {
139
+ try {
140
+ const dir = sessionsDir();
141
+ if (!fs.existsSync(dir))
142
+ return;
143
+ const cutoff = Date.now() - SESSION_FILE_TTL_MS;
144
+ for (const name of fs.readdirSync(dir)) {
145
+ const file = path.join(dir, name);
146
+ try {
147
+ if (fs.statSync(file).mtimeMs < cutoff)
148
+ fs.unlinkSync(file);
149
+ }
150
+ catch { /* ignore */ }
151
+ }
152
+ }
153
+ catch { /* ignore */ }
154
+ }
155
+ function getSessionUsage(sessionId) {
156
+ try {
157
+ const raw = JSON.parse(fs.readFileSync(usagePath(sessionId), 'utf8'));
158
+ return {
159
+ sessionId,
160
+ // Pre-split usage files carried readOps/writeOps/deleteOps/responseRows —
161
+ // those counters are simply restarted under the split model (a session
162
+ // straddling a CLI upgrade loses at most one session's history).
163
+ dbReads: Number(raw.dbReads) || 0,
164
+ dbWrites: Number(raw.dbWrites) || 0,
165
+ dbDeletes: Number(raw.dbDeletes) || 0,
166
+ dbRows: Number(raw.dbRows) || 0,
167
+ fileReads: Number(raw.fileReads) || 0,
168
+ fileWrites: Number(raw.fileWrites) || 0,
169
+ fileDeletes: Number(raw.fileDeletes) || 0,
170
+ fileBytes: Number(raw.fileBytes) || 0,
171
+ responseBytes: Number(raw.responseBytes) || 0,
172
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : new Date().toISOString(),
173
+ };
174
+ }
175
+ catch {
176
+ return emptyUsage(sessionId);
177
+ }
178
+ }
179
+ function saveUsage(usage) {
180
+ try {
181
+ fs.mkdirSync(sessionsDir(), { recursive: true });
182
+ fs.writeFileSync(usagePath(usage.sessionId), JSON.stringify({ ...usage, updatedAt: new Date().toISOString() }), 'utf8');
183
+ }
184
+ catch { /* counters are best-effort — never break the hook */ }
185
+ }
186
+ /** Record one classified operation against its target; returns the updated running totals. */
187
+ function recordSessionOp(sessionId, target, opClass) {
188
+ pruneSessions();
189
+ const usage = getSessionUsage(sessionId);
190
+ if (target === 'db') {
191
+ if (opClass === 'read')
192
+ usage.dbReads += 1;
193
+ else if (opClass === 'write')
194
+ usage.dbWrites += 1;
195
+ else
196
+ usage.dbDeletes += 1;
197
+ }
198
+ else if (target === 'file') {
199
+ if (opClass === 'read')
200
+ usage.fileReads += 1;
201
+ else if (opClass === 'write')
202
+ usage.fileWrites += 1;
203
+ else
204
+ usage.fileDeletes += 1;
205
+ }
206
+ // target 'other' (HTTP/messaging) has no per-op budget — it is covered by
207
+ // the overall response-volume cap recorded in recordSessionResponse.
208
+ saveUsage(usage);
209
+ return usage;
210
+ }
211
+ /**
212
+ * Record tool-response volume (MCP gateway). Bytes always count toward the
213
+ * overall cap; rows count toward the DB budget only for DB-target calls, and
214
+ * bytes toward the file budget only for file-target calls.
215
+ */
216
+ function recordSessionResponse(sessionId, input) {
217
+ const usage = getSessionUsage(sessionId);
218
+ const bytes = Math.max(0, Math.floor(input.bytes) || 0);
219
+ const rows = Math.max(0, Math.floor(input.rows) || 0);
220
+ usage.responseBytes += bytes;
221
+ if (input.target === 'db')
222
+ usage.dbRows += rows;
223
+ if (input.target === 'file')
224
+ usage.fileBytes += bytes;
225
+ saveUsage(usage);
226
+ return usage;
227
+ }
228
+ /** Operations that only ever come from SQL detection (context.query). */
229
+ const DB_ONLY_OPS = new Set(['SELECT', 'INSERT', 'UPDATE', 'TRUNCATE', 'SHOW', 'DESCRIBE', 'EXPLAIN', 'ALTER']);
230
+ /** Lowercase engine ops produced by file-tool / shell-command classification. */
231
+ const FILE_STYLE_OPS = new Set(['read', 'write', 'create', 'delete', 'remove']);
232
+ /**
233
+ * Classify an engine-inferred operation into a counter class AND target.
234
+ * The engine's context is the discriminator: SQL detection sets `query`,
235
+ * file-path detection sets `path`. Without context, provenance decides
236
+ * (SQL-only verbs are uppercase, file/shell verbs lowercase); HTTP methods
237
+ * land in 'other' (covered only by the overall response-volume cap).
238
+ * MUST mirror backend/src/modules/policies/roles.ts `classifyOpForLimits` —
239
+ * the two sides count the same ops or the console's numbers lie.
240
+ */
241
+ function classifyOpForLimits(operation, context) {
242
+ const raw = operation || '';
243
+ const op = raw.toUpperCase();
244
+ let opClass;
245
+ if (['READ', 'SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN', 'GET', 'LIST'].includes(op))
246
+ opClass = 'read';
247
+ else if (['WRITE', 'CREATE', 'INSERT', 'UPDATE', 'PUT', 'PATCH', 'POST', 'UPLOAD'].includes(op))
248
+ opClass = 'write';
249
+ else if (['DELETE', 'DROP', 'TRUNCATE', 'REMOVE'].includes(op))
250
+ opClass = 'delete';
251
+ else
252
+ return undefined;
253
+ let target = 'other';
254
+ if (context?.query)
255
+ target = 'db';
256
+ else if (context?.path)
257
+ target = 'file';
258
+ else if (DB_ONLY_OPS.has(op) || (op === 'DROP' && raw === op))
259
+ target = 'db';
260
+ else if (FILE_STYLE_OPS.has(raw) && !context?.url)
261
+ target = 'file';
262
+ return { target, opClass };
263
+ }
264
+ const OP_LIMIT_CHECKS = [
265
+ { limit: 'maxDbReadsPerSession', counter: 'dbReads', noun: 'database read queries' },
266
+ { limit: 'maxDbWritesPerSession', counter: 'dbWrites', noun: 'database write statements' },
267
+ { limit: 'maxDbDeletesPerSession', counter: 'dbDeletes', noun: 'database delete statements' },
268
+ { limit: 'maxDbRowsPerSession', counter: 'dbRows', noun: 'database rows returned' },
269
+ { limit: 'maxFileReadsPerSession', counter: 'fileReads', noun: 'file reads' },
270
+ { limit: 'maxFileWritesPerSession', counter: 'fileWrites', noun: 'file writes' },
271
+ { limit: 'maxFileDeletesPerSession', counter: 'fileDeletes', noun: 'file deletes' },
272
+ ];
273
+ /** Limits that meter RESPONSE volume (checked after the tool ran, response withheld). */
274
+ exports.RESPONSE_VOLUME_LIMITS = new Set([
275
+ 'maxDbRowsPerSession', 'maxFileMbPerSession', 'maxResponseMbPerSession',
276
+ ]);
277
+ /** First exceeded limit for the given usage, or undefined when within bounds. */
278
+ function checkSessionLimits(usage, limits) {
279
+ for (const check of OP_LIMIT_CHECKS) {
280
+ const configured = limits[check.limit];
281
+ const actual = usage[check.counter];
282
+ if (configured !== null && actual > configured) {
283
+ return { limit: check.limit, configured, actual, description: `${check.noun} this session (${actual}) exceeded the machine role limit of ${configured}` };
284
+ }
285
+ }
286
+ const fileMb = usage.fileBytes / (1024 * 1024);
287
+ if (limits.maxFileMbPerSession !== null && fileMb > limits.maxFileMbPerSession) {
288
+ return { limit: 'maxFileMbPerSession', configured: limits.maxFileMbPerSession, actual: Math.round(fileMb * 100) / 100, description: `file data volume this session (${fileMb.toFixed(1)} MB) exceeded the machine role limit of ${limits.maxFileMbPerSession} MB` };
289
+ }
290
+ const mb = usage.responseBytes / (1024 * 1024);
291
+ if (limits.maxResponseMbPerSession !== null && mb > limits.maxResponseMbPerSession) {
292
+ return { limit: 'maxResponseMbPerSession', configured: limits.maxResponseMbPerSession, actual: Math.round(mb * 100) / 100, description: `tool response volume this session (${mb.toFixed(1)} MB) exceeded the machine role limit of ${limits.maxResponseMbPerSession} MB` };
293
+ }
294
+ return undefined;
295
+ }
296
+ /**
297
+ * Estimate how many rows/records a tool response text carries.
298
+ * JSON: total element count across arrays (a page of DB rows = its length).
299
+ * Non-JSON: non-empty line count (CSV / table / psql output ≈ one row per line).
300
+ * Deterministic heuristic — used only for cumulative volume limits, never
301
+ * for per-item decisions, so approximate is fine.
302
+ */
303
+ function countResponseRows(text) {
304
+ const trimmed = (text || '').trim();
305
+ if (!trimmed)
306
+ return 0;
307
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
308
+ try {
309
+ const parsed = JSON.parse(trimmed);
310
+ let count = 0;
311
+ const walk = (value, depth) => {
312
+ if (depth > 4 || count > 1_000_000)
313
+ return;
314
+ if (Array.isArray(value)) {
315
+ count += value.length;
316
+ for (const item of value)
317
+ walk(item, depth + 1);
318
+ }
319
+ else if (value && typeof value === 'object') {
320
+ for (const item of Object.values(value))
321
+ walk(item, depth + 1);
322
+ }
323
+ };
324
+ walk(parsed, 0);
325
+ return count;
326
+ }
327
+ catch { /* fall through to line counting */ }
328
+ }
329
+ return trimmed.split('\n').filter(line => line.trim().length > 0).length;
330
+ }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.24.0"
2
+ "version": "1.24.2"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.24.0",
3
+ "version": "1.24.2",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "test:catalog-toggles": "npm run build && node scripts/test-catalog-toggles.js",
23
23
  "test:honeypot": "npm run build && node scripts/test-honeypot.js",
24
24
  "test:browser-credentials-rule": "npm run build && node scripts/test-browser-credentials-rule.js",
25
+ "test:session-limits": "npm run build && node scripts/test-session-limits.js",
25
26
  "test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
26
27
  "test:taint-approvals": "npm run build && node scripts/test-taint-approvals.js",
27
28
  "test:taint-approve-once": "npm run build && node scripts/test-taint-approve-once.js",