fullcourtdefense-cli 1.23.0 → 1.24.1
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.
- package/dist/actionPolicyEngine.js +43 -3
- package/dist/commands/approve.d.ts +17 -0
- package/dist/commands/approve.js +40 -0
- package/dist/commands/hook.js +98 -4
- package/dist/commands/mcpGateway.js +63 -0
- package/dist/commands/taintLedger.d.ts +44 -0
- package/dist/commands/taintLedger.js +209 -0
- package/dist/index.js +20 -0
- package/dist/runtimeConfig.d.ts +14 -0
- package/dist/runtimeConfig.js +26 -3
- package/dist/sessionLimits.d.ts +76 -0
- package/dist/sessionLimits.js +330 -0
- package/dist/version.json +1 -1
- package/package.json +4 -1
|
@@ -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
|
|
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(
|
|
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;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fullcourtdefense approve [id] [--deny]` — resolve taint-guard approve-once
|
|
3
|
+
* requests from the developer's OWN terminal.
|
|
4
|
+
*
|
|
5
|
+
* With no id: lists pending requests (id, age, held action, destinations).
|
|
6
|
+
* With an id: approves the single held action (or denies with --deny).
|
|
7
|
+
*
|
|
8
|
+
* This command is deliberately terminal-only and local: the agent-side hook
|
|
9
|
+
* blocks any AI agent that tries to run it (local-taint-self-approval), so a
|
|
10
|
+
* decision here always comes from the human at the keyboard.
|
|
11
|
+
*/
|
|
12
|
+
export interface ApproveArgs {
|
|
13
|
+
id?: string;
|
|
14
|
+
deny?: boolean;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function approveCommand(args: ApproveArgs): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.approveCommand = approveCommand;
|
|
4
|
+
const taintLedger_1 = require("./taintLedger");
|
|
5
|
+
function ageSeconds(createdAt) {
|
|
6
|
+
const created = Date.parse(createdAt);
|
|
7
|
+
return Number.isFinite(created) ? Math.max(0, Math.round((Date.now() - created) / 1000)) : 0;
|
|
8
|
+
}
|
|
9
|
+
async function approveCommand(args) {
|
|
10
|
+
if (!args.id) {
|
|
11
|
+
const pending = (0, taintLedger_1.listTaintApprovals)();
|
|
12
|
+
if (args.json) {
|
|
13
|
+
console.log(JSON.stringify({ pending }, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (pending.length === 0) {
|
|
17
|
+
console.log('No pending approve-once requests.');
|
|
18
|
+
console.log('(Requests appear here when the taint guard holds an agent action for your decision.)');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
console.log(`\x1b[1m${pending.length} pending approve-once request${pending.length === 1 ? '' : 's'}:\x1b[0m\n`);
|
|
22
|
+
for (const req of pending) {
|
|
23
|
+
console.log(` \x1b[1m${req.id}\x1b[0m (${ageSeconds(req.createdAt)}s ago) ${req.toolName} -> ${req.targets.join(', ') || 'external destination'}`);
|
|
24
|
+
if (req.detail)
|
|
25
|
+
console.log(` ${req.detail}`);
|
|
26
|
+
console.log(` approve: fullcourtdefense approve ${req.id}`);
|
|
27
|
+
console.log(` deny: fullcourtdefense approve ${req.id} --deny\n`);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const result = (0, taintLedger_1.resolveTaintApproval)(args.id, args.deny ? 'denied' : 'approved');
|
|
32
|
+
if (args.json) {
|
|
33
|
+
console.log(JSON.stringify(result, null, 2));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(result.ok ? `\x1b[32m${result.message}\x1b[0m` : `\x1b[31m${result.message}\x1b[0m`);
|
|
37
|
+
}
|
|
38
|
+
if (!result.ok)
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -826,6 +826,84 @@ async function hookCommand(args, config) {
|
|
|
826
826
|
// Unknown event → nothing to enforce.
|
|
827
827
|
respond(false);
|
|
828
828
|
}
|
|
829
|
+
/**
|
|
830
|
+
* Approve-once for a taint-guard block: hold the action, alert the human at
|
|
831
|
+
* the keyboard, and poll for their one-time decision from their OWN terminal
|
|
832
|
+
* (`fullcourtdefense approve <ID>`). Returns true only when the developer
|
|
833
|
+
* approved — the caller then continues the normal pipeline for this single
|
|
834
|
+
* action. In every other outcome (denied / timeout / disabled / store error)
|
|
835
|
+
* this function responds with a block and the caller must return.
|
|
836
|
+
*
|
|
837
|
+
* The approval ID is shown ONLY via the native OS alert and the developer's
|
|
838
|
+
* terminal — never in the agent-visible messages — so a compromised agent
|
|
839
|
+
* cannot learn or redeem it (and self-protection blocks it from trying).
|
|
840
|
+
*/
|
|
841
|
+
async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
842
|
+
const { respond } = ctx;
|
|
843
|
+
const hardBlock = () => {
|
|
844
|
+
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: taintFinding.reason, ruleId: taintFinding.ruleId, offlineEnforced: true });
|
|
845
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
846
|
+
respond(true, `Blocked by FullCourtDefense taint guard — ${taintFinding.reason}`, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry.`);
|
|
847
|
+
return false;
|
|
848
|
+
};
|
|
849
|
+
const approveOnceEnabled = process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== 'true'
|
|
850
|
+
&& process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== '1'
|
|
851
|
+
&& ctx.approvalMode !== 'block';
|
|
852
|
+
if (!approveOnceEnabled)
|
|
853
|
+
return hardBlock();
|
|
854
|
+
const approval = (0, taintLedger_1.createTaintApproval)({
|
|
855
|
+
sessionId: sessId,
|
|
856
|
+
event,
|
|
857
|
+
toolName: call.toolName,
|
|
858
|
+
reason: taintFinding.reason,
|
|
859
|
+
detail: taintFinding.sink.detail,
|
|
860
|
+
targets: taintFinding.sink.targets,
|
|
861
|
+
});
|
|
862
|
+
if (!approval)
|
|
863
|
+
return hardBlock();
|
|
864
|
+
const timeoutMs = Number(process.env.FCD_TAINT_APPROVAL_TIMEOUT_MS) > 0
|
|
865
|
+
? Number(process.env.FCD_TAINT_APPROVAL_TIMEOUT_MS)
|
|
866
|
+
: 120000; // local developer decision — short window, they are at the keyboard
|
|
867
|
+
const targetText = taintFinding.sink.targets.join(', ') || 'an external destination';
|
|
868
|
+
// forceWindow: a toast can be swallowed by Focus Assist; this decision must be seen.
|
|
869
|
+
(0, notify_1.notifyOs)({
|
|
870
|
+
forceWindow: true,
|
|
871
|
+
title: 'FullCourtDefense — approve once?',
|
|
872
|
+
message: `Agent action held: ${call.toolName} -> ${targetText}\n`
|
|
873
|
+
+ `${approval.detail}\n\n`
|
|
874
|
+
+ `Allow ONCE: fullcourtdefense approve ${approval.id}\n`
|
|
875
|
+
+ `Deny: fullcourtdefense approve ${approval.id} --deny\n`
|
|
876
|
+
+ `(run "fullcourtdefense approve" to list; expires in ${Math.round(timeoutMs / 60000)} min)`,
|
|
877
|
+
});
|
|
878
|
+
dbg({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs });
|
|
879
|
+
const outcome = await (0, taintLedger_1.waitForTaintApproval)(approval.id, timeoutMs, Math.max(750, Math.min(ctx.approvalPollMs, 2000)));
|
|
880
|
+
dbg({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
|
|
881
|
+
if (outcome === 'approved') {
|
|
882
|
+
(0, telemetry_1.spoolEvent)({
|
|
883
|
+
decision: 'allow',
|
|
884
|
+
toolName: call.toolName,
|
|
885
|
+
reason: `Developer approved once after taint-guard hold (${taintFinding.ruleId}): ${taintFinding.reason}`,
|
|
886
|
+
ruleId: taintFinding.ruleId,
|
|
887
|
+
offlineEnforced: true,
|
|
888
|
+
});
|
|
889
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
890
|
+
(0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved once', message: `${call.toolName} -> ${targetText} will continue.` });
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
(0, telemetry_1.spoolEvent)({
|
|
894
|
+
decision: 'block',
|
|
895
|
+
toolName: call.toolName,
|
|
896
|
+
reason: `${taintFinding.reason} Developer ${outcome === 'denied' ? 'denied the approve-once request' : 'did not respond to the approve-once request'}.`,
|
|
897
|
+
ruleId: taintFinding.ruleId,
|
|
898
|
+
offlineEnforced: true,
|
|
899
|
+
});
|
|
900
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
901
|
+
respond(true, `Blocked by FullCourtDefense taint guard — ${taintFinding.reason} `
|
|
902
|
+
+ (outcome === 'denied'
|
|
903
|
+
? 'You denied the approve-once request.'
|
|
904
|
+
: 'The approve-once request expired with no decision. If you want this action, re-run it and approve from your own terminal (fullcourtdefense approve).'), `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} The developer was asked and did not approve. Do not retry.`);
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
829
907
|
async function enforceActionPolicy(ctx) {
|
|
830
908
|
const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, effectivePolicyHash } = ctx;
|
|
831
909
|
const call = buildToolCall(event, payload);
|
|
@@ -858,6 +936,21 @@ async function enforceActionPolicy(ctx) {
|
|
|
858
936
|
respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
|
|
859
937
|
return;
|
|
860
938
|
}
|
|
939
|
+
// --- Taint-guard self-protection (always enforced before anything taint) ---
|
|
940
|
+
// The agent must never resolve approve-once requests or touch the taint
|
|
941
|
+
// state directory; otherwise a compromised agent could approve itself.
|
|
942
|
+
const selfApproval = (0, taintLedger_1.detectTaintSelfApproval)(event, call.toolName, call.toolArgs);
|
|
943
|
+
if (selfApproval) {
|
|
944
|
+
dbg({ phase: 'taint_self_approval_block', event, tool: call.toolName });
|
|
945
|
+
if (shadow) {
|
|
946
|
+
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${selfApproval.reason}`);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: selfApproval.reason, ruleId: 'local-taint-self-approval', offlineEnforced: true });
|
|
950
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
951
|
+
respond(true, `Blocked by FullCourtDefense self-protection — ${selfApproval.reason}`, `FullCourtDefense blocked this ${event} locally (local-taint-self-approval): ${selfApproval.reason} Do not retry.`);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
861
954
|
// --- Deterministic taint tracking (local, no backend) ---
|
|
862
955
|
// Evaluate the sink against the PRIOR ledger state first, BEFORE this event
|
|
863
956
|
// records its own ingress (so a lone remote pull doesn't self-taint then
|
|
@@ -871,10 +964,11 @@ async function enforceActionPolicy(ctx) {
|
|
|
871
964
|
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${taintFinding.reason}`);
|
|
872
965
|
return;
|
|
873
966
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
967
|
+
const approved = await runTaintApproveOnce(ctx, event, call, sessId, taintFinding);
|
|
968
|
+
if (!approved)
|
|
969
|
+
return; // runTaintApproveOnce already responded (blocked)
|
|
970
|
+
// Developer approved this single action — continue the normal pipeline so
|
|
971
|
+
// Action Policies and server checks still apply to it.
|
|
878
972
|
}
|
|
879
973
|
// Record untrusted ingress for this event (never blocks).
|
|
880
974
|
(0, taintLedger_1.noteIngress)(sessId, event, call.toolName, call.toolArgs);
|
|
@@ -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;
|
|
@@ -55,4 +55,48 @@ export declare function detectSink(event: EventKind, toolName: string, toolArgs:
|
|
|
55
55
|
export declare function checkTaintedSink(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>): TaintFinding | undefined;
|
|
56
56
|
/** Record ingress for an event if applicable. Safe to call on every event. */
|
|
57
57
|
export declare function noteIngress(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>, workspacePath?: string): TaintSource | undefined;
|
|
58
|
+
export type TaintApprovalStatus = 'pending' | 'approved' | 'denied';
|
|
59
|
+
export interface TaintApprovalRequest {
|
|
60
|
+
id: string;
|
|
61
|
+
sessionId: string;
|
|
62
|
+
status: TaintApprovalStatus;
|
|
63
|
+
event: string;
|
|
64
|
+
toolName: string;
|
|
65
|
+
reason: string;
|
|
66
|
+
/** The exact command/action detail being held (shown to the human, never to the agent). */
|
|
67
|
+
detail: string;
|
|
68
|
+
targets: string[];
|
|
69
|
+
createdAt: string;
|
|
70
|
+
}
|
|
71
|
+
/** Create a pending approve-once request for a taint finding. Returns undefined on disk errors. */
|
|
72
|
+
export declare function createTaintApproval(input: {
|
|
73
|
+
sessionId: string;
|
|
74
|
+
event: string;
|
|
75
|
+
toolName: string;
|
|
76
|
+
reason: string;
|
|
77
|
+
detail: string;
|
|
78
|
+
targets: string[];
|
|
79
|
+
}): TaintApprovalRequest | undefined;
|
|
80
|
+
/** All live pending approve-once requests (expired ones are pruned). */
|
|
81
|
+
export declare function listTaintApprovals(): TaintApprovalRequest[];
|
|
82
|
+
/** Resolve a pending request (developer's terminal). Returns an outcome message. */
|
|
83
|
+
export declare function resolveTaintApproval(id: string, decision: 'approved' | 'denied'): {
|
|
84
|
+
ok: boolean;
|
|
85
|
+
message: string;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Block until the request is approved/denied or the timeout elapses.
|
|
89
|
+
* ALWAYS consumes (deletes) the request file on exit — the decision applies to
|
|
90
|
+
* the single held action only and can never be redeemed later.
|
|
91
|
+
*/
|
|
92
|
+
export declare function waitForTaintApproval(id: string, timeoutMs: number, pollMs: number): Promise<'approved' | 'denied' | 'timeout'>;
|
|
93
|
+
/**
|
|
94
|
+
* Deterministic rule: any agent attempt to run the approve command, or to
|
|
95
|
+
* read/write the taint state directory (pending IDs / ledger files), is
|
|
96
|
+
* blocked regardless of taint state. Only a human in their own terminal may
|
|
97
|
+
* resolve approve-once requests.
|
|
98
|
+
*/
|
|
99
|
+
export declare function detectTaintSelfApproval(event: EventKind, toolName: string, toolArgs: Record<string, unknown>): {
|
|
100
|
+
reason: string;
|
|
101
|
+
} | undefined;
|
|
58
102
|
export {};
|
|
@@ -42,6 +42,12 @@ exports.classifyIngress = classifyIngress;
|
|
|
42
42
|
exports.detectSink = detectSink;
|
|
43
43
|
exports.checkTaintedSink = checkTaintedSink;
|
|
44
44
|
exports.noteIngress = noteIngress;
|
|
45
|
+
exports.createTaintApproval = createTaintApproval;
|
|
46
|
+
exports.listTaintApprovals = listTaintApprovals;
|
|
47
|
+
exports.resolveTaintApproval = resolveTaintApproval;
|
|
48
|
+
exports.waitForTaintApproval = waitForTaintApproval;
|
|
49
|
+
exports.detectTaintSelfApproval = detectTaintSelfApproval;
|
|
50
|
+
const crypto = __importStar(require("crypto"));
|
|
45
51
|
const fs = __importStar(require("fs"));
|
|
46
52
|
const os = __importStar(require("os"));
|
|
47
53
|
const path = __importStar(require("path"));
|
|
@@ -368,3 +374,206 @@ function noteIngress(sessionId, event, toolName, toolArgs, workspacePath) {
|
|
|
368
374
|
markTaint(sessionId, source);
|
|
369
375
|
return source;
|
|
370
376
|
}
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Approve-once — one-shot human approval for a taint-blocked action
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
//
|
|
381
|
+
// When the taint guard blocks a sink, the hook can HOLD the action and ask the
|
|
382
|
+
// human at the keyboard for a one-time decision instead of hard-failing:
|
|
383
|
+
//
|
|
384
|
+
// 1. hook writes a pending request file under ~/.fullcourtdefense/taint/approvals/
|
|
385
|
+
// 2. the developer sees a native OS alert and runs, in their OWN terminal:
|
|
386
|
+
// fullcourtdefense approve <ID> (allow this one action)
|
|
387
|
+
// fullcourtdefense approve <ID> --deny (reject it)
|
|
388
|
+
// 3. the hook polls the file; on approval the HELD action continues, once.
|
|
389
|
+
//
|
|
390
|
+
// One-shot by construction: the waiting hook deletes the request file the
|
|
391
|
+
// moment it resolves (approved, denied, or timed out). A late `approve` after
|
|
392
|
+
// timeout finds nothing to approve — a previously blocked command can never be
|
|
393
|
+
// released retroactively. Approving never whitelists the host or the session;
|
|
394
|
+
// the very next unrequested sink is checked again from scratch.
|
|
395
|
+
//
|
|
396
|
+
// The agent must never approve its own actions. `detectTaintSelfApproval`
|
|
397
|
+
// gives the hook a deterministic rule that blocks any agent attempt to run the
|
|
398
|
+
// approve command or to touch the taint state directory (which would let it
|
|
399
|
+
// read pending IDs or forge an "approved" status).
|
|
400
|
+
const APPROVAL_TTL_MS = 10 * 60 * 1000; // pending requests older than 10 min are dead
|
|
401
|
+
/** Unambiguous alphabet (no 0/O/1/I/L) so IDs are easy to retype from an alert. */
|
|
402
|
+
const APPROVAL_ID_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
403
|
+
function approvalsDir() {
|
|
404
|
+
return path.join(taintDir(), 'approvals');
|
|
405
|
+
}
|
|
406
|
+
function approvalPath(id) {
|
|
407
|
+
return path.join(approvalsDir(), `${id.toUpperCase().replace(/[^A-Z0-9]/g, '')}.json`);
|
|
408
|
+
}
|
|
409
|
+
function newApprovalId() {
|
|
410
|
+
const bytes = crypto.randomBytes(6);
|
|
411
|
+
let id = '';
|
|
412
|
+
for (const b of bytes)
|
|
413
|
+
id += APPROVAL_ID_ALPHABET[b % APPROVAL_ID_ALPHABET.length];
|
|
414
|
+
return id;
|
|
415
|
+
}
|
|
416
|
+
function readApproval(file) {
|
|
417
|
+
try {
|
|
418
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
419
|
+
if (!parsed.id || !parsed.createdAt)
|
|
420
|
+
return undefined;
|
|
421
|
+
return {
|
|
422
|
+
id: parsed.id,
|
|
423
|
+
sessionId: parsed.sessionId || '',
|
|
424
|
+
status: parsed.status === 'approved' || parsed.status === 'denied' ? parsed.status : 'pending',
|
|
425
|
+
event: parsed.event || 'unknown',
|
|
426
|
+
toolName: parsed.toolName || '',
|
|
427
|
+
reason: parsed.reason || '',
|
|
428
|
+
detail: parsed.detail || '',
|
|
429
|
+
targets: Array.isArray(parsed.targets) ? parsed.targets.filter(t => typeof t === 'string') : [],
|
|
430
|
+
createdAt: parsed.createdAt,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function isExpired(req) {
|
|
438
|
+
const created = Date.parse(req.createdAt);
|
|
439
|
+
return !Number.isFinite(created) || Date.now() - created > APPROVAL_TTL_MS;
|
|
440
|
+
}
|
|
441
|
+
/** Best-effort removal of expired approval request files. */
|
|
442
|
+
function pruneApprovals() {
|
|
443
|
+
try {
|
|
444
|
+
const dir = approvalsDir();
|
|
445
|
+
if (!fs.existsSync(dir))
|
|
446
|
+
return;
|
|
447
|
+
for (const name of fs.readdirSync(dir)) {
|
|
448
|
+
const file = path.join(dir, name);
|
|
449
|
+
const req = readApproval(file);
|
|
450
|
+
if (!req || isExpired(req)) {
|
|
451
|
+
try {
|
|
452
|
+
fs.unlinkSync(file);
|
|
453
|
+
}
|
|
454
|
+
catch { /* ignore */ }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch { /* ignore */ }
|
|
459
|
+
}
|
|
460
|
+
/** Create a pending approve-once request for a taint finding. Returns undefined on disk errors. */
|
|
461
|
+
function createTaintApproval(input) {
|
|
462
|
+
try {
|
|
463
|
+
fs.mkdirSync(approvalsDir(), { recursive: true });
|
|
464
|
+
pruneApprovals();
|
|
465
|
+
const req = {
|
|
466
|
+
id: newApprovalId(),
|
|
467
|
+
sessionId: input.sessionId,
|
|
468
|
+
status: 'pending',
|
|
469
|
+
event: input.event,
|
|
470
|
+
toolName: input.toolName,
|
|
471
|
+
reason: input.reason.slice(0, 600),
|
|
472
|
+
detail: input.detail.slice(0, 400),
|
|
473
|
+
targets: input.targets.slice(0, 10),
|
|
474
|
+
createdAt: nowIso(),
|
|
475
|
+
};
|
|
476
|
+
fs.writeFileSync(approvalPath(req.id), JSON.stringify(req, null, 2), 'utf8');
|
|
477
|
+
return req;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** All live pending approve-once requests (expired ones are pruned). */
|
|
484
|
+
function listTaintApprovals() {
|
|
485
|
+
pruneApprovals();
|
|
486
|
+
try {
|
|
487
|
+
const dir = approvalsDir();
|
|
488
|
+
if (!fs.existsSync(dir))
|
|
489
|
+
return [];
|
|
490
|
+
const out = [];
|
|
491
|
+
for (const name of fs.readdirSync(dir)) {
|
|
492
|
+
const req = readApproval(path.join(dir, name));
|
|
493
|
+
if (req && req.status === 'pending')
|
|
494
|
+
out.push(req);
|
|
495
|
+
}
|
|
496
|
+
return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return [];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Resolve a pending request (developer's terminal). Returns an outcome message. */
|
|
503
|
+
function resolveTaintApproval(id, decision) {
|
|
504
|
+
const file = approvalPath(id);
|
|
505
|
+
const req = fs.existsSync(file) ? readApproval(file) : undefined;
|
|
506
|
+
if (!req || isExpired(req)) {
|
|
507
|
+
return { ok: false, message: `No pending approve-once request "${id.toUpperCase()}" — it may have expired or already resolved. Run "fullcourtdefense approve" to list pending requests.` };
|
|
508
|
+
}
|
|
509
|
+
if (req.status !== 'pending') {
|
|
510
|
+
return { ok: false, message: `Request ${req.id} was already ${req.status}.` };
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
fs.writeFileSync(file, JSON.stringify({ ...req, status: decision }, null, 2), 'utf8');
|
|
514
|
+
return { ok: true, message: `${decision === 'approved' ? 'Approved' : 'Denied'} ${req.id}: ${req.toolName} -> ${req.targets.join(', ') || 'external destination'}. ${decision === 'approved' ? 'The held action will continue once.' : 'The held action stays blocked.'}` };
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
return { ok: false, message: `Could not write decision: ${err instanceof Error ? err.message : String(err)}` };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Block until the request is approved/denied or the timeout elapses.
|
|
522
|
+
* ALWAYS consumes (deletes) the request file on exit — the decision applies to
|
|
523
|
+
* the single held action only and can never be redeemed later.
|
|
524
|
+
*/
|
|
525
|
+
async function waitForTaintApproval(id, timeoutMs, pollMs) {
|
|
526
|
+
const file = approvalPath(id);
|
|
527
|
+
const deadline = Date.now() + Math.max(1000, timeoutMs);
|
|
528
|
+
try {
|
|
529
|
+
while (Date.now() < deadline) {
|
|
530
|
+
const req = fs.existsSync(file) ? readApproval(file) : undefined;
|
|
531
|
+
if (!req)
|
|
532
|
+
return 'denied'; // file vanished — treat as not approved
|
|
533
|
+
if (req.status === 'approved')
|
|
534
|
+
return 'approved';
|
|
535
|
+
if (req.status === 'denied')
|
|
536
|
+
return 'denied';
|
|
537
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(250, pollMs)));
|
|
538
|
+
}
|
|
539
|
+
return 'timeout';
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
try {
|
|
543
|
+
fs.unlinkSync(file);
|
|
544
|
+
}
|
|
545
|
+
catch { /* already gone */ }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// --- Self-protection: the agent must never approve its own held actions -----
|
|
549
|
+
const SELF_APPROVE_CMD = /\b(?:fullcourtdefense|fcd|botguard)(?:\.cmd|\.exe|\.ps1|\.js)?["']?\s+(?:approve|deny)\b/i;
|
|
550
|
+
const TAINT_STATE_PATH = /\.fullcourtdefense[\\/]+taint\b/i;
|
|
551
|
+
/**
|
|
552
|
+
* Deterministic rule: any agent attempt to run the approve command, or to
|
|
553
|
+
* read/write the taint state directory (pending IDs / ledger files), is
|
|
554
|
+
* blocked regardless of taint state. Only a human in their own terminal may
|
|
555
|
+
* resolve approve-once requests.
|
|
556
|
+
*/
|
|
557
|
+
function detectTaintSelfApproval(event, toolName, toolArgs) {
|
|
558
|
+
if (!taintEnabled())
|
|
559
|
+
return undefined;
|
|
560
|
+
// For file edits/reads only the TARGET PATH matters — file contents may
|
|
561
|
+
// legitimately mention these strings (e.g. this repo's own source/tests).
|
|
562
|
+
const strings = event === 'file' || event === 'read'
|
|
563
|
+
? [
|
|
564
|
+
typeof toolArgs.path === 'string' ? toolArgs.path : '',
|
|
565
|
+
typeof toolArgs.file_path === 'string' ? toolArgs.file_path : '',
|
|
566
|
+
].filter(Boolean)
|
|
567
|
+
: [...collectStringValues(toolArgs), toolName];
|
|
568
|
+
for (const value of strings) {
|
|
569
|
+
if (event === 'shell' || event === 'mcp') {
|
|
570
|
+
if (SELF_APPROVE_CMD.test(value)) {
|
|
571
|
+
return { reason: 'Agents may not run the FullCourtDefense approve command. Only the developer, from their own terminal, can resolve approve-once requests.' };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (TAINT_STATE_PATH.test(value)) {
|
|
575
|
+
return { reason: 'Agents may not access the FullCourtDefense taint state directory (approval requests and session ledgers are human-only).' };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
37
37
|
const config_1 = require("./config");
|
|
38
38
|
const scan_1 = require("./commands/scan");
|
|
39
39
|
const credits_1 = require("./commands/credits");
|
|
40
|
+
const approve_1 = require("./commands/approve");
|
|
40
41
|
const init_1 = require("./commands/init");
|
|
41
42
|
const doctor_1 = require("./commands/doctor");
|
|
42
43
|
const configure_1 = require("./commands/configure");
|
|
@@ -184,6 +185,12 @@ function printHelp() {
|
|
|
184
185
|
(auto-detected on GitHub Actions / GitLab CI), installs the runtime
|
|
185
186
|
hook + MCP gateway, and streams every tool call to the fleet
|
|
186
187
|
console. Auth: FCD_API_KEY secret (org API key).
|
|
188
|
+
approve Resolve a taint-guard "approve once" request from YOUR terminal.
|
|
189
|
+
When the hook holds an agent action (unrequested network/git
|
|
190
|
+
destination after untrusted web/MCP content), run
|
|
191
|
+
"fullcourtdefense approve <ID>" to allow that single action, or
|
|
192
|
+
add --deny. Bare "approve" lists pending requests. Agents are
|
|
193
|
+
blocked from running this command themselves.
|
|
187
194
|
install-cursor-hook
|
|
188
195
|
Installs a Cursor hook so EVERY agent action on this machine is
|
|
189
196
|
checked against your org's Action Policies — in any repo/folder.
|
|
@@ -569,6 +576,19 @@ async function main() {
|
|
|
569
576
|
await (0, credits_1.creditsCommand)(args, config);
|
|
570
577
|
break;
|
|
571
578
|
}
|
|
579
|
+
case 'approve': {
|
|
580
|
+
// `approve <id>` / `approve <id> --deny` / bare `approve` lists pending.
|
|
581
|
+
// parseArgs quirk: `approve --deny <id>` puts the id into flags.deny.
|
|
582
|
+
const denyValue = flags.deny;
|
|
583
|
+
const idFromDeny = denyValue && denyValue !== 'true' && denyValue !== 'false' ? denyValue : undefined;
|
|
584
|
+
const args = {
|
|
585
|
+
id: positional[0] || idFromDeny,
|
|
586
|
+
deny: denyValue !== undefined && denyValue !== 'false',
|
|
587
|
+
json: flags.json === 'true',
|
|
588
|
+
};
|
|
589
|
+
await (0, approve_1.approveCommand)(args);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
572
592
|
case 'discover': {
|
|
573
593
|
const args = {
|
|
574
594
|
type: flags.type,
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.1",
|
|
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,7 +22,10 @@
|
|
|
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",
|
|
27
|
+
"test:taint-approvals": "npm run build && node scripts/test-taint-approvals.js",
|
|
28
|
+
"test:taint-approve-once": "npm run build && node scripts/test-taint-approve-once.js",
|
|
26
29
|
"test:shell-audit": "npm run build && node scripts/test-shell-audit.js",
|
|
27
30
|
"test:audit-restore": "node scripts/test-audit-restore.js",
|
|
28
31
|
"test:shell-guard": "npm run build && node scripts/test-shell-guard.js",
|