fullcourtdefense-cli 1.21.38 → 1.21.40
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/commands/deterministicGuard.d.ts +7 -0
- package/dist/commands/deterministicGuard.js +94 -4
- package/dist/commands/hook.js +12 -2
- package/dist/commands/mcpGateway.js +4 -2
- package/dist/localSafetySnapshot.d.ts +2 -0
- package/dist/localSafetySnapshot.js +4 -0
- package/dist/version.json +1 -1
- package/package.json +6 -1
|
@@ -26,6 +26,13 @@ export interface LocalSafetyScanOptions {
|
|
|
26
26
|
inspectScripts?: boolean;
|
|
27
27
|
/** Absolute paths of planted honeypot decoy files — ANY reference blocks. */
|
|
28
28
|
honeypotPaths?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Org-managed trusted script paths (substring match on the normalized path).
|
|
31
|
+
* Content scanning is skipped for referenced scripts under these paths — the
|
|
32
|
+
* admin-controlled exception for repos whose test suites legitimately contain
|
|
33
|
+
* attack fixtures (security tooling, guard regression tests).
|
|
34
|
+
*/
|
|
35
|
+
trustedScriptPaths?: string[];
|
|
29
36
|
}
|
|
30
37
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
31
38
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -272,6 +272,12 @@ function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence,
|
|
|
272
272
|
* references these files at all.
|
|
273
273
|
*/
|
|
274
274
|
function honeypotTouch(value, options) {
|
|
275
|
+
// Honeypot is a catalog item (id 'honeypot_decoy_access') so admins can see it
|
|
276
|
+
// in the console and, if they must, turn it off — nothing enforces invisibly.
|
|
277
|
+
// It is zero-FP by design (daemon-planted decoy paths only), so it defaults ON
|
|
278
|
+
// and disabling is discouraged, but the toggle is honored like any other rule.
|
|
279
|
+
if (!isEnabled('honeypot_decoy_access', options))
|
|
280
|
+
return undefined;
|
|
275
281
|
const paths = options?.honeypotPaths;
|
|
276
282
|
if (!paths || paths.length === 0)
|
|
277
283
|
return undefined;
|
|
@@ -372,8 +378,16 @@ function destructiveCommandReason(value) {
|
|
|
372
378
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
373
379
|
if (/\b(?:del|erase)\s+\/[a-z]*s[a-z]*\s+\/[a-z]*q[a-z]*\s+[a-z]:\\(?:\s|$)/i.test(text))
|
|
374
380
|
return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
|
|
375
|
-
|
|
381
|
+
// Drive-ROOT Remove-Item only: the bare drive (C:\) must be an argument of
|
|
382
|
+
// the SAME Remove-Item command (no ;|& between them). The previous loose
|
|
383
|
+
// drive-letter match flagged ANY text containing Remove-Item -Recurse
|
|
384
|
+
// -Force plus an unrelated "x: " pattern — e.g. our own installer's
|
|
385
|
+
// legitimate cleanup script, blocking a customer's git command that merely
|
|
386
|
+
// referenced it.
|
|
387
|
+
if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force')
|
|
388
|
+
&& /remove-item\b[^;|&]{0,80}?["']?\b[a-z]:[\\/]?["']?(?=\s|$|[;&|])/i.test(text)) {
|
|
376
389
|
return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
|
|
390
|
+
}
|
|
377
391
|
if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
|
|
378
392
|
return { itemId: 'fork_bomb', reason: 'fork bomb' };
|
|
379
393
|
if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
|
|
@@ -438,9 +452,27 @@ function infraDestroyReason(value) {
|
|
|
438
452
|
return { itemId: 'az_group_delete_yes', reason: 'Azure resource group deletion' };
|
|
439
453
|
return undefined;
|
|
440
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Local file editors (Write / StrReplace / edit_file / EditNotebook / apply_patch ...)
|
|
457
|
+
* write to DISK — that is not egress. Their contents/new_string arguments legitimately
|
|
458
|
+
* carry secret-shaped fixtures (test suites, docs, security tooling source), and the
|
|
459
|
+
* `content(s)` key name otherwise matches the outbound-payload heuristic, which blocked
|
|
460
|
+
* editing our own guard test files. Real exfiltration is still caught at the actual
|
|
461
|
+
* egress point (outbound/HTTP tools, shell commands), where secret rules apply unchanged.
|
|
462
|
+
*/
|
|
463
|
+
function isLocalFileEditor(toolName, candidates) {
|
|
464
|
+
if (!/(?:write|edit|replace|patch|notebook|create)/i.test(toolName))
|
|
465
|
+
return false;
|
|
466
|
+
return candidates.some(candidate => {
|
|
467
|
+
const lastKey = (candidate.keyPath.split('.').pop() || '').toLowerCase();
|
|
468
|
+
return ['path', 'file', 'filename', 'file_path', 'filepath', 'target_file', 'target_notebook'].includes(lastKey);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
441
471
|
function isOutboundContext(toolName, candidates) {
|
|
442
472
|
if (OUTBOUND_TOOL_HINT.test(toolName))
|
|
443
473
|
return true;
|
|
474
|
+
if (isLocalFileEditor(toolName, candidates))
|
|
475
|
+
return false;
|
|
444
476
|
return candidates.some(candidate => /(?:url|uri|webhook|endpoint|recipient|channel|email|to|body|payload|message|content)/i.test(candidate.keyPath));
|
|
445
477
|
}
|
|
446
478
|
function isCommandContext(toolName, candidate) {
|
|
@@ -485,13 +517,20 @@ function scanTextValue(toolName, value, options) {
|
|
|
485
517
|
const custom = customBlock(value, options);
|
|
486
518
|
if (custom)
|
|
487
519
|
return custom;
|
|
520
|
+
// NOTE: a disabled item must FALL THROUGH to the later rules, not end the
|
|
521
|
+
// scan — otherwise turning off (or gating) a path rule would mask a real
|
|
522
|
+
// reverse shell / destructive command in the same value.
|
|
488
523
|
const sensitivePath = containsSensitiveCredentialPath(value);
|
|
489
524
|
if (sensitivePath) {
|
|
490
|
-
|
|
525
|
+
const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path', `Blocked local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
|
|
526
|
+
if (finding)
|
|
527
|
+
return finding;
|
|
491
528
|
}
|
|
492
529
|
const metadata = containsMetadataEndpoint(value);
|
|
493
530
|
if (metadata) {
|
|
494
|
-
|
|
531
|
+
const finding = builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`, value, metadata.value, options);
|
|
532
|
+
if (finding)
|
|
533
|
+
return finding;
|
|
495
534
|
}
|
|
496
535
|
const reverseShell = reverseShellReason(value);
|
|
497
536
|
if (reverseShell) {
|
|
@@ -699,8 +738,48 @@ function referencedScripts(command, cwd) {
|
|
|
699
738
|
}
|
|
700
739
|
return Array.from(new Set(scripts));
|
|
701
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* Verbs that make a sensitive path / metadata endpoint on a script line ACTIONABLE:
|
|
743
|
+
* reading, copying, uploading, or requesting it. A mere string mention (test fixture,
|
|
744
|
+
* detector regex, doc comment) has none of these on the same line.
|
|
745
|
+
*/
|
|
746
|
+
// Collision-hardened forms: `ssh` must be a command start (NOT the `.ssh` of the
|
|
747
|
+
// flagged path itself), `type` the cmd.exe read form (`type file`, not `type:` in
|
|
748
|
+
// JSON/TS fixtures), `open` a call (`open(`), `requests.*` a python HTTP call.
|
|
749
|
+
const SCRIPT_ACCESS_VERB = /\b(?:cat|gc|get-content|get-item|copy(?:-item)?|cp|xcopy|robocopy|move(?:-item)?|mv|scp|sftp|rsync|curl|wget|invoke-webrequest|invoke-restmethod|iwr|irm|openssl|tar|zip|7z|certutil|base64|readfile(?:sync)?|createreadstream|read_text|read_bytes|fopen|urlopen|fetch|upload|download)\b|\btype\s+\S|(?:^|[^.\w])ssh(?:-add|-keygen|\.exe)?\s|\bopen\s*\(|\brequests\.\w+\s*\(/i;
|
|
750
|
+
/**
|
|
751
|
+
* A path/metadata finding inside a referenced script only counts when some LINE both
|
|
752
|
+
* contains the flagged pattern AND acts on it. Scripts (and their tests, and security
|
|
753
|
+
* tooling) routinely mention credential paths as inert strings — "found inside
|
|
754
|
+
* referenced script" used to block running our own guard test suite over fixtures.
|
|
755
|
+
*/
|
|
756
|
+
function scriptActsOnFinding(content, category) {
|
|
757
|
+
const matcher = category === 'sensitive_file'
|
|
758
|
+
? (line) => Boolean(containsSensitiveCredentialPath(line))
|
|
759
|
+
: (line) => Boolean(containsMetadataEndpoint(line));
|
|
760
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
761
|
+
if (!rawLine || rawLine.length > 4000)
|
|
762
|
+
continue;
|
|
763
|
+
if (matcher(rawLine) && SCRIPT_ACCESS_VERB.test(rawLine))
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
/** Org-trusted script paths (from the Local Safety snapshot): skip content scanning. */
|
|
769
|
+
function isTrustedScriptPath(script, options) {
|
|
770
|
+
const trusted = options?.trustedScriptPaths;
|
|
771
|
+
if (!trusted || trusted.length === 0)
|
|
772
|
+
return false;
|
|
773
|
+
const normalized = normalizeForPath(script);
|
|
774
|
+
return trusted.some(entry => {
|
|
775
|
+
const pattern = normalizeForPath(String(entry || '')).replace(/\/+$/, '');
|
|
776
|
+
return pattern.length >= 3 && normalized.includes(pattern);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
702
779
|
function scanReferencedScript(command, cwd, options) {
|
|
703
780
|
for (const script of referencedScripts(command, cwd)) {
|
|
781
|
+
if (isTrustedScriptPath(script, options))
|
|
782
|
+
continue;
|
|
704
783
|
let content = '';
|
|
705
784
|
if (script.startsWith('npm:')) {
|
|
706
785
|
content = script;
|
|
@@ -716,7 +795,18 @@ function scanReferencedScript(command, cwd, options) {
|
|
|
716
795
|
continue;
|
|
717
796
|
}
|
|
718
797
|
}
|
|
719
|
-
|
|
798
|
+
// Path/metadata findings must be ACTIONABLE inside the script; suppress inert
|
|
799
|
+
// string mentions and rescan so command-shaped rules (reverse shell, destructive
|
|
800
|
+
// delete...) in the SAME file still surface.
|
|
801
|
+
let scanOptions = options;
|
|
802
|
+
let finding = scanTextValue('script_file', content, scanOptions);
|
|
803
|
+
while (finding
|
|
804
|
+
&& (finding.category === 'sensitive_file' || finding.category === 'metadata_ssrf')
|
|
805
|
+
&& !scriptActsOnFinding(content, finding.category)) {
|
|
806
|
+
const baseDisabled = scanOptions?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
|
|
807
|
+
scanOptions = { ...(scanOptions || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
|
|
808
|
+
finding = scanTextValue('script_file', content, scanOptions);
|
|
809
|
+
}
|
|
720
810
|
if (!finding)
|
|
721
811
|
continue;
|
|
722
812
|
return {
|
package/dist/commands/hook.js
CHANGED
|
@@ -533,6 +533,16 @@ function machineMetadata(client = 'cursor') {
|
|
|
533
533
|
agentClient: client,
|
|
534
534
|
};
|
|
535
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* The developer-facing block line must answer "WHO blocked me and where do I
|
|
538
|
+
* see/manage it" in one glance — a bare reason string left developers unable
|
|
539
|
+
* to tell a built-in rule from an org policy or to find the console entry.
|
|
540
|
+
*/
|
|
541
|
+
function localBlockUserMessage(finding) {
|
|
542
|
+
const origin = finding.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
543
|
+
return `Blocked by FullCourtDefense local guard — ${finding.reason}`
|
|
544
|
+
+ ` [${origin} "${finding.itemId}" — logged to your org's console (machine timeline); admins manage rules under Shield → Local Safety]`;
|
|
545
|
+
}
|
|
536
546
|
function spoolLocalFinding(input) {
|
|
537
547
|
(0, telemetry_1.spoolEvent)({
|
|
538
548
|
decision: 'block',
|
|
@@ -767,7 +777,7 @@ async function hookCommand(args, config) {
|
|
|
767
777
|
return;
|
|
768
778
|
}
|
|
769
779
|
spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
|
|
770
|
-
respond(true,
|
|
780
|
+
respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this prompt locally (${localBlock.ruleId}). Do not retry.`);
|
|
771
781
|
return;
|
|
772
782
|
}
|
|
773
783
|
if (localOnly) {
|
|
@@ -811,7 +821,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
811
821
|
return;
|
|
812
822
|
}
|
|
813
823
|
spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
|
|
814
|
-
respond(true,
|
|
824
|
+
respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
|
|
815
825
|
return;
|
|
816
826
|
}
|
|
817
827
|
// --- Deterministic taint tracking (local, no backend) ---
|
|
@@ -836,7 +836,8 @@ class McpGatewayServer {
|
|
|
836
836
|
const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
837
837
|
if (localBlock) {
|
|
838
838
|
this.spoolLocalFinding(localBlock, toolName, operation);
|
|
839
|
-
|
|
839
|
+
const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
840
|
+
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.`);
|
|
840
841
|
}
|
|
841
842
|
let preflight;
|
|
842
843
|
try {
|
|
@@ -936,7 +937,8 @@ class McpGatewayServer {
|
|
|
936
937
|
const localResponseBlock = (0, deterministicGuard_1.scanDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
937
938
|
if (localResponseBlock) {
|
|
938
939
|
this.spoolLocalFinding(localResponseBlock, toolName, operation);
|
|
939
|
-
|
|
940
|
+
const responseOrigin = localResponseBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
941
|
+
throw new Error(`${localResponseBlock.reason} (${responseOrigin} "${localResponseBlock.itemId}", ${localResponseBlock.ruleId}: ${localResponseBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety.`);
|
|
940
942
|
}
|
|
941
943
|
const finalResult = this.gatewayConfig.scanResponse
|
|
942
944
|
? await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult })
|
|
@@ -3,6 +3,8 @@ export interface LocalSafetySnapshot {
|
|
|
3
3
|
policyHash: string;
|
|
4
4
|
disabledBuiltInItemIds: string[];
|
|
5
5
|
customBlocks: LocalSafetyCustomBlock[];
|
|
6
|
+
/** Org-managed trusted script paths — content scanning skipped under these. */
|
|
7
|
+
trustedScriptPaths?: string[];
|
|
6
8
|
updatedAt?: string;
|
|
7
9
|
}
|
|
8
10
|
export interface LocalSafetySnapshotInput {
|
|
@@ -85,6 +85,9 @@ function toSnapshot(data) {
|
|
|
85
85
|
explanation: typeof item.explanation === 'string' ? item.explanation : undefined,
|
|
86
86
|
}))
|
|
87
87
|
: [],
|
|
88
|
+
trustedScriptPaths: Array.isArray(data.trustedScriptPaths)
|
|
89
|
+
? data.trustedScriptPaths.filter((item) => typeof item === 'string' && item.trim().length >= 3)
|
|
90
|
+
: undefined,
|
|
88
91
|
updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
|
|
89
92
|
};
|
|
90
93
|
}
|
|
@@ -132,6 +135,7 @@ function snapshotToScanOptions(snapshot, extra = {}) {
|
|
|
132
135
|
return {
|
|
133
136
|
disabledBuiltInItemIds: snapshot?.disabledBuiltInItemIds,
|
|
134
137
|
customBlocks: snapshot?.customBlocks,
|
|
138
|
+
trustedScriptPaths: snapshot?.trustedScriptPaths,
|
|
135
139
|
policyHash: snapshot?.policyHash,
|
|
136
140
|
// Machine-local decoy paths planted by the daemon — every enforcement
|
|
137
141
|
// surface (hooks, MCP gateway, desktop chat guard) gets honeypot
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.40",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc && node scripts/copy-attack-corpus.js",
|
|
17
17
|
"test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
|
|
18
|
+
"test:drive-delete-guard": "npm run build && node scripts/test-drive-delete-guard.js",
|
|
19
|
+
"test:msi-stop-filter": "node scripts/test-msi-stop-filter.js",
|
|
18
20
|
"test:guard-content-context": "npm run build && node scripts/test-guard-content-context.js",
|
|
21
|
+
"test:guard-fp-fixes": "npm run build && node scripts/test-guard-fp-fixes.js",
|
|
22
|
+
"test:catalog-toggles": "npm run build && node scripts/test-catalog-toggles.js",
|
|
19
23
|
"test:honeypot": "npm run build && node scripts/test-honeypot.js",
|
|
20
24
|
"test:browser-credentials-rule": "npm run build && node scripts/test-browser-credentials-rule.js",
|
|
21
25
|
"test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
|
|
@@ -54,6 +58,7 @@
|
|
|
54
58
|
"test:policy-gate-health": "npm run build && node scripts/test-policy-gate-health.js",
|
|
55
59
|
"test:offline-policy": "npm run build && node scripts/test-offline-policy-enforcement.js",
|
|
56
60
|
"test:real-scenario": "npm run build && node scripts/test-real-scenario-drill.js",
|
|
61
|
+
"test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",
|
|
57
62
|
"test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
|
|
58
63
|
"build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
|
|
59
64
|
"prepublishOnly": "npm run build"
|