fullcourtdefense-cli 1.21.40 → 1.22.0
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/cmdGuard.js +3 -2
- package/dist/commands/daemon.js +9 -10
- package/dist/commands/deterministicGuard.d.ts +55 -1
- package/dist/commands/deterministicGuard.js +182 -5
- package/dist/commands/hook.js +17 -3
- package/dist/commands/mcpGateway.js +69 -6
- package/dist/commands/posixShellGuard.js +3 -2
- package/dist/commands/shellGuard.d.ts +7 -0
- package/dist/commands/shellGuard.js +66 -6
- package/dist/localSafetySnapshot.d.ts +3 -1
- package/dist/localSafetySnapshot.js +17 -0
- package/dist/machineActionVerify.d.ts +15 -2
- package/dist/machineActionVerify.js +30 -4
- package/dist/telemetry.d.ts +1 -1
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -117,14 +117,15 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
117
117
|
`const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
|
|
118
118
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
119
119
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
120
|
-
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
121
|
-
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();for(const r of rules){try{if(r.re.test(n)||r.re.test(line))return r;}catch{}}return
|
|
120
|
+
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
121
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
122
122
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){spawnSync(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{stdio:'ignore',windowsHide:true});}}catch{}}`,
|
|
123
123
|
`function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
|
|
124
124
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
125
125
|
`const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
126
126
|
`if(!hit)delegate(args);`,
|
|
127
127
|
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');delegate(args);}`,
|
|
128
|
+
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
128
129
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
129
130
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
130
131
|
`spoolEvent(hit,line,'block');process.exit(1);`,
|
package/dist/commands/daemon.js
CHANGED
|
@@ -718,23 +718,22 @@ async function runDaemon(args, config) {
|
|
|
718
718
|
executingActionIds.add(action.id);
|
|
719
719
|
// Cryptographic gate: never execute an action the control plane didn't
|
|
720
720
|
// sign — a Firestore/backend compromise must not become fleet-wide RCE.
|
|
721
|
-
|
|
721
|
+
// Machine binding is enforced too: a correctly signed action targeting a
|
|
722
|
+
// DIFFERENT machine is rejected (bundle fetches send this machine's id, so
|
|
723
|
+
// a mismatch means misrouting/replay, not normal operation). Legacy fleets
|
|
724
|
+
// whose enrolled fingerprint drifted can set FCD_MACHINE_ACTION_ANY_MACHINE_OK=1
|
|
725
|
+
// locally while they re-enroll.
|
|
726
|
+
const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
|
|
727
|
+
const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId });
|
|
722
728
|
if (!verdict.ok) {
|
|
723
729
|
log(`Remote action REJECTED: ${action.type} (${action.id}) — ${verdict.reason}`);
|
|
724
|
-
await reportMachineAction(action.id, 'failed', { error: `
|
|
730
|
+
await reportMachineAction(action.id, 'failed', { error: `Action verification failed: ${verdict.reason}` });
|
|
725
731
|
executingActionIds.delete(action.id);
|
|
726
732
|
await uploadLogTail();
|
|
727
733
|
return;
|
|
728
734
|
}
|
|
729
735
|
if (verdict.reason)
|
|
730
|
-
log(`Remote action
|
|
731
|
-
const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
|
|
732
|
-
if (action.machineId && action.machineId !== localIdentity.machineId) {
|
|
733
|
-
// Warn-only for now: enrolled machines can carry legacy fingerprints that
|
|
734
|
-
// differ from the freshly computed identity. Tighten to reject once the
|
|
735
|
-
// fleet is fully on stable machine ids.
|
|
736
|
-
log(`Remote action machine-binding notice: action targets ${action.machineId}, local id is ${localIdentity.machineId}.`);
|
|
737
|
-
}
|
|
736
|
+
log(`Remote action verification notice: ${verdict.reason}`);
|
|
738
737
|
await reportMachineAction(action.id, 'running');
|
|
739
738
|
log(`Remote action started: ${action.type} (${action.id}).`);
|
|
740
739
|
await uploadLogTail();
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export type DeterministicDirection = 'request' | 'response';
|
|
2
|
-
export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'honeypot';
|
|
2
|
+
export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'credential_command' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'honeypot';
|
|
3
|
+
/**
|
|
4
|
+
* Per-rule enforcement action (org-configured, shipped in the Local Safety
|
|
5
|
+
* snapshot). Absent everywhere = 'block' — the only behavior older CLIs and
|
|
6
|
+
* older snapshots know, so mixed fleets stay fail-strict.
|
|
7
|
+
* - 'block' — stop the action (today's behavior).
|
|
8
|
+
* - 'warn' — let the action proceed; surface + report the finding as a warning.
|
|
9
|
+
* - 'mask' — redact the matched secret where the surface can rewrite content
|
|
10
|
+
* (MCP tool responses); surfaces that cannot rewrite treat it as block.
|
|
11
|
+
*/
|
|
12
|
+
export type LocalSafetyRuleAction = 'block' | 'warn' | 'mask';
|
|
3
13
|
export interface DeterministicFinding {
|
|
4
14
|
blocked: true;
|
|
5
15
|
ruleId: string;
|
|
@@ -11,15 +21,26 @@ export interface DeterministicFinding {
|
|
|
11
21
|
evidence: string;
|
|
12
22
|
explanation?: string;
|
|
13
23
|
policyHash?: string;
|
|
24
|
+
/** Org-configured enforcement action for the matched rule. Absent = 'block'. */
|
|
25
|
+
action?: LocalSafetyRuleAction;
|
|
26
|
+
/**
|
|
27
|
+
* The exact matched secret string, present only for secret_exfiltration
|
|
28
|
+
* findings — the seam 'mask' uses to redact tool responses. Never spooled.
|
|
29
|
+
*/
|
|
30
|
+
matchedSecret?: string;
|
|
14
31
|
}
|
|
15
32
|
export interface LocalSafetyCustomBlock {
|
|
16
33
|
id: string;
|
|
17
34
|
categoryId: string;
|
|
18
35
|
pattern: string;
|
|
19
36
|
explanation?: string;
|
|
37
|
+
/** Absent = 'block'. */
|
|
38
|
+
action?: LocalSafetyRuleAction;
|
|
20
39
|
}
|
|
21
40
|
export interface LocalSafetyScanOptions {
|
|
22
41
|
disabledBuiltInItemIds?: string[];
|
|
42
|
+
/** Built-in items with a NON-default action (absent item = 'block'). */
|
|
43
|
+
itemActions?: Record<string, LocalSafetyRuleAction>;
|
|
23
44
|
customBlocks?: LocalSafetyCustomBlock[];
|
|
24
45
|
policyHash?: string;
|
|
25
46
|
cwd?: string;
|
|
@@ -37,3 +58,36 @@ export interface LocalSafetyScanOptions {
|
|
|
37
58
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
38
59
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
39
60
|
export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
61
|
+
export interface DeterministicScanOutcome {
|
|
62
|
+
/** The finding that stops the action, if any. */
|
|
63
|
+
blockingFinding?: DeterministicFinding;
|
|
64
|
+
/** warn-action findings that matched — the action proceeds; report these. */
|
|
65
|
+
warnings: DeterministicFinding[];
|
|
66
|
+
}
|
|
67
|
+
export interface DeterministicTextResponseOutcome extends DeterministicScanOutcome {
|
|
68
|
+
/** mask-action secret findings that WERE redacted from the text. */
|
|
69
|
+
maskFindings: DeterministicFinding[];
|
|
70
|
+
/** The response text with every masked secret redacted. */
|
|
71
|
+
maskedText: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a scan under per-rule actions WITHOUT letting a warn rule shadow a
|
|
75
|
+
* block rule. Scans return the FIRST match, so a single pass would let one
|
|
76
|
+
* warn-mode rule hide a second, block-mode rule matching the same value. This
|
|
77
|
+
* re-scans with each warn rule suppressed until a blocking finding surfaces or
|
|
78
|
+
* nothing matches. Default (no itemActions configured) is a single pass
|
|
79
|
+
* returning today's behavior exactly.
|
|
80
|
+
*
|
|
81
|
+
* 'mask' degrades to block here: these surfaces (tool calls, prompts, shell
|
|
82
|
+
* commands) cannot rewrite content, so mask can only fail strict.
|
|
83
|
+
*/
|
|
84
|
+
export declare function resolveDeterministicOutcome(scan: (options?: LocalSafetyScanOptions) => DeterministicFinding | undefined, options?: LocalSafetyScanOptions): DeterministicScanOutcome;
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a TOOL RESPONSE under per-rule actions. The response is the one
|
|
87
|
+
* surface that can be rewritten, so 'mask' redacts here: each masked secret is
|
|
88
|
+
* replaced in the text and the REDACTED text is rescanned — a second secret of
|
|
89
|
+
* the same type is still found (rule suppression would hide it). Warn rules
|
|
90
|
+
* are suppressed as in resolveDeterministicOutcome. Any 'block' rule match
|
|
91
|
+
* blocks regardless of what was masked before it.
|
|
92
|
+
*/
|
|
93
|
+
export declare function resolveDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicTextResponseOutcome;
|
|
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
37
37
|
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
38
38
|
exports.scanDeterministicPrompt = scanDeterministicPrompt;
|
|
39
|
+
exports.resolveDeterministicOutcome = resolveDeterministicOutcome;
|
|
40
|
+
exports.resolveDeterministicTextResponse = resolveDeterministicTextResponse;
|
|
39
41
|
const fs = __importStar(require("fs"));
|
|
40
42
|
const os = __importStar(require("os"));
|
|
41
43
|
const path = __importStar(require("path"));
|
|
@@ -190,6 +192,15 @@ const DEFAULT_DISABLED_BUILT_INS = new Set([
|
|
|
190
192
|
// IDE). Off by default — orgs opt in via the Local Safety console toggle,
|
|
191
193
|
// which the runtime snapshot then applies on every machine.
|
|
192
194
|
'contextual_secret',
|
|
195
|
+
// Cloud token printers / secret fetchers used constantly by LEGITIMATE
|
|
196
|
+
// deploy tooling and dev loops. Off by default; orgs with stricter posture
|
|
197
|
+
// opt in via the console. (The rarely-legitimate forms — gh auth token,
|
|
198
|
+
// git credential fill, keychain dumps — are ON by default.)
|
|
199
|
+
'gcloud_access_token_cmd',
|
|
200
|
+
'gcloud_secret_access_cmd',
|
|
201
|
+
'az_access_token_cmd',
|
|
202
|
+
'aws_secret_fetch_cmd',
|
|
203
|
+
'vault_read_cmd',
|
|
193
204
|
]);
|
|
194
205
|
function evidence(value) {
|
|
195
206
|
const compact = value.replace(/\s+/g, ' ').trim();
|
|
@@ -247,6 +258,10 @@ function disabled(options) {
|
|
|
247
258
|
function isEnabled(itemId, options) {
|
|
248
259
|
return !disabled(options).has(itemId);
|
|
249
260
|
}
|
|
261
|
+
function actionFor(itemId, options) {
|
|
262
|
+
const action = options?.itemActions?.[itemId];
|
|
263
|
+
return action === 'warn' || action === 'mask' ? action : 'block';
|
|
264
|
+
}
|
|
250
265
|
function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence, explanation, options) {
|
|
251
266
|
if (!isEnabled(itemId, options))
|
|
252
267
|
return undefined;
|
|
@@ -261,6 +276,7 @@ function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence,
|
|
|
261
276
|
evidence: evidence(findingEvidence),
|
|
262
277
|
explanation,
|
|
263
278
|
policyHash: options?.policyHash,
|
|
279
|
+
action: actionFor(itemId, options),
|
|
264
280
|
};
|
|
265
281
|
}
|
|
266
282
|
/**
|
|
@@ -301,6 +317,7 @@ function honeypotTouch(value, options) {
|
|
|
301
317
|
reason: 'Blocked access to a decoy credential file (honeypot). No legitimate tool uses this file — this indicates credential-hunting behavior.',
|
|
302
318
|
evidence: evidence(value),
|
|
303
319
|
policyHash: options?.policyHash,
|
|
320
|
+
action: actionFor('honeypot_decoy_access', options),
|
|
304
321
|
};
|
|
305
322
|
}
|
|
306
323
|
}
|
|
@@ -323,6 +340,7 @@ function customBlock(value, options) {
|
|
|
323
340
|
evidence: evidence(value),
|
|
324
341
|
explanation: block.explanation,
|
|
325
342
|
policyHash: options?.policyHash,
|
|
343
|
+
action: block.action === 'warn' || block.action === 'mask' ? block.action : 'block',
|
|
326
344
|
};
|
|
327
345
|
}
|
|
328
346
|
return undefined;
|
|
@@ -348,6 +366,20 @@ function containsSensitiveCredentialPath(value) {
|
|
|
348
366
|
{ itemId: 'aws_credentials', label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"])/ },
|
|
349
367
|
{ itemId: 'kube_config', label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"])/ },
|
|
350
368
|
{ itemId: 'docker_auth', label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"])/ },
|
|
369
|
+
// GitHub CLI stores its OAuth token in hosts.yml — ~/.config/gh/ on Unix,
|
|
370
|
+
// AppData/Roaming/GitHub CLI/ on Windows (normalized+lowercased upstream).
|
|
371
|
+
{ itemId: 'github_cli_token', label: 'GitHub CLI token', re: /(?:^|\/)(?:gh|github cli)\/hosts\.yml(?:$|[/?#\s'"])/ },
|
|
372
|
+
{ itemId: 'git_credentials', label: 'Git plaintext credentials', re: /(?:^|\/)\.git-credentials(?:$|[/?#\s'"])/ },
|
|
373
|
+
{ itemId: 'gcloud_credentials', label: 'gcloud credential store', re: /(?:^|\/)gcloud\/(?:credentials\.db|access_tokens\.db|legacy_credentials)(?:$|[/?#\s'"])/ },
|
|
374
|
+
{ itemId: 'azure_tokens', label: 'Azure token cache', re: /(?:^|\/)\.azure\/(?:msal_token_cache|accesstokens\.json)/ },
|
|
375
|
+
{ itemId: 'gnupg_private_keys', label: 'GnuPG private keys', re: /(?:^|\/)\.gnupg\/(?:private-keys-v1\.d|secring\.gpg)(?:$|[/?#\s'"])/ },
|
|
376
|
+
{ itemId: 'pgpass', label: 'PostgreSQL password file', re: /(?:^|\/)(?:\.pgpass|pgpass\.conf)(?:$|[/?#\s'"])/ },
|
|
377
|
+
{ itemId: 'mysql_cnf', label: 'MySQL credentials file', re: /(?:^|\/)\.my\.cnf(?:$|[/?#\s'"])/ },
|
|
378
|
+
{ itemId: 'terraform_credentials', label: 'Terraform credentials', re: /credentials\.tfrc\.json(?:$|[/?#\s'"])/ },
|
|
379
|
+
{ itemId: 'huggingface_token', label: 'Hugging Face token', re: /(?:^|\/)\.?huggingface\/token(?:$|[/?#\s'"])/ },
|
|
380
|
+
{ itemId: 'cargo_credentials', label: 'Cargo registry credentials', re: /(?:^|\/)\.cargo\/credentials(?:\.toml)?(?:$|[/?#\s'"])/ },
|
|
381
|
+
{ itemId: 'gem_credentials', label: 'RubyGems credentials', re: /(?:^|\/)\.gem\/credentials(?:$|[/?#\s'"])/ },
|
|
382
|
+
{ itemId: 'composer_auth', label: 'Composer auth tokens', re: /(?:^|\/)\.composer\/auth\.json(?:$|[/?#\s'"])/ },
|
|
351
383
|
{ itemId: 'npmrc', label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"])/ },
|
|
352
384
|
{ itemId: 'pypirc', label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"])/ },
|
|
353
385
|
{ itemId: 'netrc', label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"])/ },
|
|
@@ -369,6 +401,47 @@ function containsSensitiveCredentialPath(value) {
|
|
|
369
401
|
}
|
|
370
402
|
return undefined;
|
|
371
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Commands that PRINT stored credentials to stdout — the file-path rules are
|
|
406
|
+
* useless against them because the secret never comes from a watched file
|
|
407
|
+
* ("gh auth token" emits the same token that lives in hosts.yml). Obvious,
|
|
408
|
+
* rarely-legitimate-for-agents forms are on by default; cloud token printers
|
|
409
|
+
* that legitimate deploy tooling uses constantly are strict (off by default).
|
|
410
|
+
*/
|
|
411
|
+
function credentialCommandReason(value) {
|
|
412
|
+
const lower = normalizeForCommand(value).toLowerCase();
|
|
413
|
+
if (/\bgh\s+auth\s+token\b/.test(lower) || (/\bgh\s+auth\s+status\b/.test(lower) && /(?:--show-token|\s-t\b)/.test(lower))) {
|
|
414
|
+
return { itemId: 'gh_auth_token_cmd', reason: 'GitHub CLI token print (gh auth token)' };
|
|
415
|
+
}
|
|
416
|
+
if (/\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b/.test(lower)) {
|
|
417
|
+
return { itemId: 'git_credential_fill', reason: 'git credential helper dump' };
|
|
418
|
+
}
|
|
419
|
+
if (/\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b/.test(lower) || /\bsecurity\s+dump-keychain\b/.test(lower)) {
|
|
420
|
+
return { itemId: 'macos_keychain_dump', reason: 'macOS keychain password dump' };
|
|
421
|
+
}
|
|
422
|
+
if (/\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b/.test(lower)) {
|
|
423
|
+
return { itemId: 'kubectl_config_raw', reason: 'raw kubeconfig dump (kubectl config view --raw)' };
|
|
424
|
+
}
|
|
425
|
+
if (/\baws\s+configure\s+export-credentials\b/.test(lower) || /\baws\s+configure\s+get\b[^\n;|&]*secret/.test(lower)) {
|
|
426
|
+
return { itemId: 'aws_export_credentials', reason: 'AWS secret access key export' };
|
|
427
|
+
}
|
|
428
|
+
if (/\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b/.test(lower)) {
|
|
429
|
+
return { itemId: 'gcloud_access_token_cmd', reason: 'gcloud access-token print' };
|
|
430
|
+
}
|
|
431
|
+
if (/\bgcloud\s+secrets\s+versions\s+access\b/.test(lower)) {
|
|
432
|
+
return { itemId: 'gcloud_secret_access_cmd', reason: 'GCP Secret Manager read' };
|
|
433
|
+
}
|
|
434
|
+
if (/\baz\s+account\s+get-access-token\b/.test(lower)) {
|
|
435
|
+
return { itemId: 'az_access_token_cmd', reason: 'Azure access-token print' };
|
|
436
|
+
}
|
|
437
|
+
if (/\baws\s+secretsmanager\s+get-secret-value\b/.test(lower) || /\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b/.test(lower)) {
|
|
438
|
+
return { itemId: 'aws_secret_fetch_cmd', reason: 'AWS secret fetch' };
|
|
439
|
+
}
|
|
440
|
+
if (/\bvault\s+(?:kv\s+get|read)\b/.test(lower)) {
|
|
441
|
+
return { itemId: 'vault_read_cmd', reason: 'Vault secret read' };
|
|
442
|
+
}
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
372
445
|
function destructiveCommandReason(value) {
|
|
373
446
|
const text = normalizeForCommand(value);
|
|
374
447
|
const lower = text.toLowerCase();
|
|
@@ -536,6 +609,12 @@ function scanTextValue(toolName, value, options) {
|
|
|
536
609
|
if (reverseShell) {
|
|
537
610
|
return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-reverse-shell', `Blocked ${reverseShell.reason}.`, value, reverseShell.reason, options);
|
|
538
611
|
}
|
|
612
|
+
const credentialCmd = credentialCommandReason(value);
|
|
613
|
+
if (credentialCmd) {
|
|
614
|
+
const finding = builtIn(credentialCmd.itemId, 'credential_commands', 'credential_command', 'local-credential-command', `Blocked credential-revealing command: ${credentialCmd.reason}.`, value, credentialCmd.reason, options);
|
|
615
|
+
if (finding)
|
|
616
|
+
return finding;
|
|
617
|
+
}
|
|
539
618
|
const destructive = destructiveCommandReason(value);
|
|
540
619
|
if (destructive) {
|
|
541
620
|
return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
|
|
@@ -608,7 +687,8 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
|
|
|
608
687
|
|| contextualSecretFromKeyPath(candidate.keyPath, candidate.value)
|
|
609
688
|
|| findContextualSecret(candidate.value);
|
|
610
689
|
if (secret) {
|
|
611
|
-
|
|
690
|
+
const finding = builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-exfiltration', `Blocked outbound data containing ${secret.label}.`, secret.value, secret.label, options);
|
|
691
|
+
return finding ? { ...finding, matchedSecret: secret.value } : undefined;
|
|
612
692
|
}
|
|
613
693
|
}
|
|
614
694
|
}
|
|
@@ -623,7 +703,8 @@ function scanDeterministicTextResponse(text, options) {
|
|
|
623
703
|
const secret = findSecret(text) || findContextualSecret(text);
|
|
624
704
|
if (!secret)
|
|
625
705
|
return undefined;
|
|
626
|
-
|
|
706
|
+
const finding = builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-in-tool-response', `Blocked tool response containing ${secret.label}.`, secret.value, secret.label, options);
|
|
707
|
+
return finding ? { ...finding, matchedSecret: secret.value } : undefined;
|
|
627
708
|
}
|
|
628
709
|
function looksLikeQuestion(value) {
|
|
629
710
|
return /^(?:what|why|how|explain|describe|is|are|can you explain)\b/i.test(value.trim());
|
|
@@ -667,6 +748,12 @@ function scanDeterministicPrompt(text, options) {
|
|
|
667
748
|
if (reverseShell) {
|
|
668
749
|
return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-prompt-reverse-shell', `Blocked prompt containing ${reverseShell.reason}.`, trimmed, reverseShell.reason, options);
|
|
669
750
|
}
|
|
751
|
+
const credentialCmd = credentialCommandReason(trimmed);
|
|
752
|
+
if (credentialCmd) {
|
|
753
|
+
const finding = builtIn(credentialCmd.itemId, 'credential_commands', 'credential_command', 'local-prompt-credential-command', `Blocked prompt containing ${credentialCmd.reason}.`, trimmed, credentialCmd.reason, options);
|
|
754
|
+
if (finding)
|
|
755
|
+
return finding;
|
|
756
|
+
}
|
|
670
757
|
const destructive = destructiveCommandReason(trimmed);
|
|
671
758
|
if (destructive) {
|
|
672
759
|
return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-prompt-destructive-command', `Blocked prompt containing ${destructive.reason}.`, trimmed, destructive.reason, options);
|
|
@@ -706,6 +793,9 @@ function referencedScripts(command, cwd) {
|
|
|
706
793
|
const tokens = splitCommand(command);
|
|
707
794
|
const scripts = [];
|
|
708
795
|
const runners = new Set(['bash', 'sh', 'zsh', 'python', 'python3', 'node', 'pwsh']);
|
|
796
|
+
// Wrappers that still EXECUTE the script that follows them ("sudo ./x.sh").
|
|
797
|
+
const execPrefixes = new Set(['sudo', 'time', 'nohup', 'exec', 'call', 'source', '.']);
|
|
798
|
+
const startsNewCommand = (prev) => prev === undefined || /[;&|]$/.test(prev) || execPrefixes.has(path.basename(prev).toLowerCase());
|
|
709
799
|
for (let i = 0; i < tokens.length; i++) {
|
|
710
800
|
const token = path.basename(tokens[i]).toLowerCase();
|
|
711
801
|
if (runners.has(token)) {
|
|
@@ -719,9 +809,16 @@ function referencedScripts(command, cwd) {
|
|
|
719
809
|
if (resolved)
|
|
720
810
|
scripts.push(resolved);
|
|
721
811
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
812
|
+
// A bare script path only EXECUTES when it is the command word itself
|
|
813
|
+
// ("./deploy.sh --prod", "sudo ./x.sh", "a.sh && b.sh"). A script path that
|
|
814
|
+
// is an ARGUMENT of another command ("git add guard.test.js",
|
|
815
|
+
// "code scripts/x.py") is inert — content-scanning those blocked ordinary
|
|
816
|
+
// version-control and editor commands on our own test fixtures.
|
|
817
|
+
if (startsNewCommand(tokens[i - 1])) {
|
|
818
|
+
const direct = safeResolveScriptPath(tokens[i], cwd);
|
|
819
|
+
if (direct)
|
|
820
|
+
scripts.push(direct);
|
|
821
|
+
}
|
|
725
822
|
}
|
|
726
823
|
if (tokens[0]?.toLowerCase() === 'npm' && tokens[1]?.toLowerCase() === 'run' && tokens[2]) {
|
|
727
824
|
const pkg = path.resolve(cwd || process.cwd(), 'package.json');
|
|
@@ -817,3 +914,83 @@ function scanReferencedScript(command, cwd, options) {
|
|
|
817
914
|
}
|
|
818
915
|
return undefined;
|
|
819
916
|
}
|
|
917
|
+
const MAX_ACTION_PASSES = 10;
|
|
918
|
+
function withRuleSuppressed(options, finding) {
|
|
919
|
+
if (finding.source === 'custom') {
|
|
920
|
+
return {
|
|
921
|
+
...(options || {}),
|
|
922
|
+
customBlocks: (options?.customBlocks || []).filter(block => block.id !== finding.itemId),
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
const baseDisabled = options?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
|
|
926
|
+
return { ...(options || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
|
|
927
|
+
}
|
|
928
|
+
function findingAction(finding) {
|
|
929
|
+
return finding.action === 'warn' || finding.action === 'mask' ? finding.action : 'block';
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Resolve a scan under per-rule actions WITHOUT letting a warn rule shadow a
|
|
933
|
+
* block rule. Scans return the FIRST match, so a single pass would let one
|
|
934
|
+
* warn-mode rule hide a second, block-mode rule matching the same value. This
|
|
935
|
+
* re-scans with each warn rule suppressed until a blocking finding surfaces or
|
|
936
|
+
* nothing matches. Default (no itemActions configured) is a single pass
|
|
937
|
+
* returning today's behavior exactly.
|
|
938
|
+
*
|
|
939
|
+
* 'mask' degrades to block here: these surfaces (tool calls, prompts, shell
|
|
940
|
+
* commands) cannot rewrite content, so mask can only fail strict.
|
|
941
|
+
*/
|
|
942
|
+
function resolveDeterministicOutcome(scan, options) {
|
|
943
|
+
const warnings = [];
|
|
944
|
+
let current = options;
|
|
945
|
+
for (let pass = 0; pass < MAX_ACTION_PASSES; pass++) {
|
|
946
|
+
const finding = scan(current);
|
|
947
|
+
if (!finding)
|
|
948
|
+
return { warnings };
|
|
949
|
+
if (findingAction(finding) !== 'warn')
|
|
950
|
+
return { blockingFinding: finding, warnings };
|
|
951
|
+
warnings.push(finding);
|
|
952
|
+
current = withRuleSuppressed(current, finding);
|
|
953
|
+
}
|
|
954
|
+
// Pass budget exhausted with matches still appearing — fail strict.
|
|
955
|
+
const last = scan(current);
|
|
956
|
+
return last ? { blockingFinding: last, warnings } : { warnings };
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Resolve a TOOL RESPONSE under per-rule actions. The response is the one
|
|
960
|
+
* surface that can be rewritten, so 'mask' redacts here: each masked secret is
|
|
961
|
+
* replaced in the text and the REDACTED text is rescanned — a second secret of
|
|
962
|
+
* the same type is still found (rule suppression would hide it). Warn rules
|
|
963
|
+
* are suppressed as in resolveDeterministicOutcome. Any 'block' rule match
|
|
964
|
+
* blocks regardless of what was masked before it.
|
|
965
|
+
*/
|
|
966
|
+
function resolveDeterministicTextResponse(text, options) {
|
|
967
|
+
const warnings = [];
|
|
968
|
+
const maskFindings = [];
|
|
969
|
+
let currentOptions = options;
|
|
970
|
+
let currentText = text;
|
|
971
|
+
for (let pass = 0; pass < MAX_ACTION_PASSES; pass++) {
|
|
972
|
+
const finding = scanDeterministicTextResponse(currentText, currentOptions);
|
|
973
|
+
if (!finding)
|
|
974
|
+
return { warnings, maskFindings, maskedText: currentText };
|
|
975
|
+
const action = findingAction(finding);
|
|
976
|
+
if (action === 'block')
|
|
977
|
+
return { blockingFinding: finding, warnings, maskFindings, maskedText: currentText };
|
|
978
|
+
if (action === 'mask') {
|
|
979
|
+
if (!finding.matchedSecret || !currentText.includes(finding.matchedSecret)) {
|
|
980
|
+
// Nothing concrete to redact — mask degrades to block, never to allow.
|
|
981
|
+
return { blockingFinding: finding, warnings, maskFindings, maskedText: currentText };
|
|
982
|
+
}
|
|
983
|
+
maskFindings.push(finding);
|
|
984
|
+
currentText = currentText.split(finding.matchedSecret).join(`[REDACTED:${finding.itemId}]`);
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
warnings.push(finding);
|
|
988
|
+
currentOptions = withRuleSuppressed(currentOptions, finding);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// Pass budget exhausted with matches still appearing — fail strict.
|
|
992
|
+
const last = scanDeterministicTextResponse(currentText, currentOptions);
|
|
993
|
+
return last
|
|
994
|
+
? { blockingFinding: last, warnings, maskFindings, maskedText: currentText }
|
|
995
|
+
: { warnings, maskFindings, maskedText: currentText };
|
|
996
|
+
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -545,7 +545,7 @@ function localBlockUserMessage(finding) {
|
|
|
545
545
|
}
|
|
546
546
|
function spoolLocalFinding(input) {
|
|
547
547
|
(0, telemetry_1.spoolEvent)({
|
|
548
|
-
decision: 'block',
|
|
548
|
+
decision: input.decision || 'block',
|
|
549
549
|
toolName: input.toolName,
|
|
550
550
|
operation: input.operation,
|
|
551
551
|
reason: input.finding.reason,
|
|
@@ -561,6 +561,16 @@ function spoolLocalFinding(input) {
|
|
|
561
561
|
});
|
|
562
562
|
(0, telemetry_1.triggerFlush)(true);
|
|
563
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* warn-action rule matches: the action PROCEEDS — spool each finding as a
|
|
566
|
+
* 'warn' event so it lands in the org console/timeline without stopping work.
|
|
567
|
+
*/
|
|
568
|
+
function spoolLocalWarnings(warnings, toolName, operation) {
|
|
569
|
+
for (const finding of warnings) {
|
|
570
|
+
dbg({ phase: 'local_deterministic_warn', tool: toolName, ruleId: finding.ruleId, itemId: finding.itemId });
|
|
571
|
+
spoolLocalFinding({ finding, toolName, operation, decision: 'warn' });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
564
574
|
async function hookCommand(args, config) {
|
|
565
575
|
// Mode is server-authoritative (pulled from the cached runtime bundle), with
|
|
566
576
|
// local flags as explicit overrides. Resolved after creds below.
|
|
@@ -768,7 +778,9 @@ async function hookCommand(args, config) {
|
|
|
768
778
|
machineName: os.hostname(),
|
|
769
779
|
expectedPolicyHash: effectivePolicyHash,
|
|
770
780
|
});
|
|
771
|
-
const
|
|
781
|
+
const promptOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicPrompt)(text, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
782
|
+
spoolLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
|
|
783
|
+
const localBlock = promptOutcome.blockingFinding;
|
|
772
784
|
if (localBlock) {
|
|
773
785
|
dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
|
|
774
786
|
if (shadow) {
|
|
@@ -809,10 +821,12 @@ async function enforceActionPolicy(ctx) {
|
|
|
809
821
|
machineName: os.hostname(),
|
|
810
822
|
expectedPolicyHash: effectivePolicyHash,
|
|
811
823
|
});
|
|
812
|
-
const
|
|
824
|
+
const toolOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(call.toolName, call.toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot, {
|
|
813
825
|
cwd: typeof call.toolArgs.cwd === 'string' ? call.toolArgs.cwd : process.cwd(),
|
|
814
826
|
inspectScripts: event === 'shell',
|
|
815
827
|
}));
|
|
828
|
+
spoolLocalWarnings(toolOutcome.warnings, call.toolName, event);
|
|
829
|
+
const localBlock = toolOutcome.blockingFinding;
|
|
816
830
|
if (localBlock) {
|
|
817
831
|
dbg({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
|
|
818
832
|
if (shadow) {
|
|
@@ -310,6 +310,43 @@ function contentToText(result) {
|
|
|
310
310
|
return String(result);
|
|
311
311
|
}
|
|
312
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Redact mask-action secrets from an MCP tool result IN PLACE of blocking it.
|
|
315
|
+
* Only the shapes contentToText() reads are rewritable (plain string, text
|
|
316
|
+
* content items); anything else reports fullyMasked=false and the caller
|
|
317
|
+
* blocks — mask must never leak the secret it exists to hide.
|
|
318
|
+
*/
|
|
319
|
+
function maskToolResult(result, maskFindings) {
|
|
320
|
+
const secrets = maskFindings
|
|
321
|
+
.map(finding => ({ secret: finding.matchedSecret, replacement: `[REDACTED:${finding.itemId}]` }))
|
|
322
|
+
.filter((entry) => Boolean(entry.secret));
|
|
323
|
+
const redact = (text) => {
|
|
324
|
+
let out = text;
|
|
325
|
+
for (const { secret, replacement } of secrets)
|
|
326
|
+
out = out.split(secret).join(replacement);
|
|
327
|
+
return out;
|
|
328
|
+
};
|
|
329
|
+
let masked = result;
|
|
330
|
+
if (typeof result === 'string') {
|
|
331
|
+
masked = redact(result);
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
const content = result?.content;
|
|
335
|
+
if (Array.isArray(content)) {
|
|
336
|
+
masked = {
|
|
337
|
+
...result,
|
|
338
|
+
content: content.map(item => (item?.type === 'text' && typeof item.text === 'string')
|
|
339
|
+
? { ...item, text: redact(item.text) }
|
|
340
|
+
: item),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Fail-strict verification: every masked secret must be gone from what a
|
|
345
|
+
// client would actually read out of this result.
|
|
346
|
+
const remaining = contentToText(masked);
|
|
347
|
+
const fullyMasked = secrets.every(({ secret }) => !remaining.includes(secret));
|
|
348
|
+
return { masked, fullyMasked };
|
|
349
|
+
}
|
|
313
350
|
class StdioMcpClient {
|
|
314
351
|
child;
|
|
315
352
|
buffer = '';
|
|
@@ -748,9 +785,9 @@ class McpGatewayServer {
|
|
|
748
785
|
await this.downstreamReady;
|
|
749
786
|
}
|
|
750
787
|
/** Spool a locally-enforced deterministic block so it reaches Live Enforcement even offline. */
|
|
751
|
-
spoolLocalFinding(finding, toolName, operation) {
|
|
788
|
+
spoolLocalFinding(finding, toolName, operation, decision = 'block') {
|
|
752
789
|
(0, telemetry_1.spoolEvent)({
|
|
753
|
-
decision
|
|
790
|
+
decision,
|
|
754
791
|
toolName,
|
|
755
792
|
operation,
|
|
756
793
|
reason: finding.reason,
|
|
@@ -764,7 +801,7 @@ class McpGatewayServer {
|
|
|
764
801
|
policyHash: finding.policyHash,
|
|
765
802
|
offlineEnforced: true,
|
|
766
803
|
});
|
|
767
|
-
(0, telemetry_1.triggerFlush)(
|
|
804
|
+
(0, telemetry_1.triggerFlush)(decision === 'block');
|
|
768
805
|
}
|
|
769
806
|
async handleToolCall(params) {
|
|
770
807
|
await this.ensureDownstream();
|
|
@@ -833,7 +870,12 @@ class McpGatewayServer {
|
|
|
833
870
|
machineName: os.hostname(),
|
|
834
871
|
expectedPolicyHash,
|
|
835
872
|
});
|
|
836
|
-
const
|
|
873
|
+
const requestOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
874
|
+
for (const warning of requestOutcome.warnings) {
|
|
875
|
+
this.spoolLocalFinding(warning, toolName, operation, 'warn');
|
|
876
|
+
process.stderr.write(`AgentGuard warning (${warning.itemId}): ${warning.reason}\n`);
|
|
877
|
+
}
|
|
878
|
+
const localBlock = requestOutcome.blockingFinding;
|
|
837
879
|
if (localBlock) {
|
|
838
880
|
this.spoolLocalFinding(localBlock, toolName, operation);
|
|
839
881
|
const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
@@ -933,13 +975,34 @@ class McpGatewayServer {
|
|
|
933
975
|
throw new Error(parts.join(' | '));
|
|
934
976
|
}
|
|
935
977
|
}
|
|
936
|
-
|
|
937
|
-
const
|
|
978
|
+
let rawResult = await this.downstream.callTool(toolName, toolArgs);
|
|
979
|
+
const responseOutcome = (0, deterministicGuard_1.resolveDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
980
|
+
for (const warning of responseOutcome.warnings) {
|
|
981
|
+
this.spoolLocalFinding(warning, toolName, operation, 'warn');
|
|
982
|
+
process.stderr.write(`AgentGuard warning (${warning.itemId}): ${warning.reason}\n`);
|
|
983
|
+
}
|
|
984
|
+
const localResponseBlock = responseOutcome.blockingFinding;
|
|
938
985
|
if (localResponseBlock) {
|
|
939
986
|
this.spoolLocalFinding(localResponseBlock, toolName, operation);
|
|
940
987
|
const responseOrigin = localResponseBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
941
988
|
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.`);
|
|
942
989
|
}
|
|
990
|
+
if (responseOutcome.maskFindings.length > 0) {
|
|
991
|
+
// mask-action rules: redact the matched secrets in place. If the result
|
|
992
|
+
// shape cannot be rewritten (secret hidden in a non-text payload), fail
|
|
993
|
+
// strict and block — a mask rule must never leak what it was set to hide.
|
|
994
|
+
const { masked, fullyMasked } = maskToolResult(rawResult, responseOutcome.maskFindings);
|
|
995
|
+
if (!fullyMasked) {
|
|
996
|
+
const finding = responseOutcome.maskFindings[0];
|
|
997
|
+
this.spoolLocalFinding(finding, toolName, operation);
|
|
998
|
+
throw new Error(`${finding.reason} (rule "${finding.itemId}" is set to mask, but this tool response could not be rewritten — blocked instead.)`);
|
|
999
|
+
}
|
|
1000
|
+
rawResult = masked;
|
|
1001
|
+
for (const finding of responseOutcome.maskFindings) {
|
|
1002
|
+
this.spoolLocalFinding(finding, toolName, operation, 'mask');
|
|
1003
|
+
process.stderr.write(`AgentGuard masked a ${finding.itemId} in the ${toolName} response.\n`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
943
1006
|
const finalResult = this.gatewayConfig.scanResponse
|
|
944
1007
|
? await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult })
|
|
945
1008
|
: rawResult;
|
|
@@ -103,8 +103,8 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
103
103
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
104
104
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
105
105
|
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
106
|
-
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
107
|
-
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();for(const r of rules){try{if(r.re.test(n)||r.re.test(line))return r;}catch{}}return
|
|
106
|
+
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
107
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
108
108
|
`function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
|
|
109
109
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
|
|
110
110
|
`let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
|
|
@@ -113,6 +113,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
113
113
|
`const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
114
114
|
`if(!hit)process.exit(0);`,
|
|
115
115
|
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');process.exit(0);}`,
|
|
116
|
+
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');process.exit(0);}`,
|
|
116
117
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
117
118
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
118
119
|
`spoolEvent(hit,line,'block');process.exit(BLOCK_CODE);`,
|
|
@@ -8,6 +8,13 @@ export interface ShellGuardRule {
|
|
|
8
8
|
pattern: string;
|
|
9
9
|
reason: string;
|
|
10
10
|
source: 'builtin' | 'custom';
|
|
11
|
+
/**
|
|
12
|
+
* Per-rule enforcement action from the console (Local Safety). Absent =
|
|
13
|
+
* 'block'. 'warn' → yellow notice, the command still runs (like monitor
|
|
14
|
+
* mode, but for this one rule). 'mask' is meaningless for a typed command
|
|
15
|
+
* and degrades to block.
|
|
16
|
+
*/
|
|
17
|
+
action?: 'block' | 'warn';
|
|
11
18
|
}
|
|
12
19
|
interface ShellGuardRulesFile {
|
|
13
20
|
updatedAt: string;
|
|
@@ -287,6 +287,44 @@ function cachedDisabledItemIds() {
|
|
|
287
287
|
catch { /* no snapshot cache yet */ }
|
|
288
288
|
return disabled;
|
|
289
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Per-item enforcement actions from the cached local-safety snapshots. Only
|
|
292
|
+
* 'warn' survives here: 'mask' cannot rewrite a typed command (degrades to
|
|
293
|
+
* block by omission), and cross-snapshot conflicts resolve fail-strict — an
|
|
294
|
+
* explicit 'block' anywhere wins over a 'warn' elsewhere.
|
|
295
|
+
*/
|
|
296
|
+
function cachedItemActions() {
|
|
297
|
+
const warn = new Set();
|
|
298
|
+
const blocked = new Set();
|
|
299
|
+
try {
|
|
300
|
+
for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
|
|
301
|
+
if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
|
|
302
|
+
continue;
|
|
303
|
+
try {
|
|
304
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(SNAPSHOT_CACHE_DIR, file), 'utf8'));
|
|
305
|
+
const actions = parsed?.snapshot?.itemActions;
|
|
306
|
+
if (!actions || typeof actions !== 'object')
|
|
307
|
+
continue;
|
|
308
|
+
for (const [itemId, action] of Object.entries(actions)) {
|
|
309
|
+
if (typeof itemId !== 'string' || !itemId)
|
|
310
|
+
continue;
|
|
311
|
+
if (action === 'warn')
|
|
312
|
+
warn.add(itemId);
|
|
313
|
+
else
|
|
314
|
+
blocked.add(itemId);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch { /* skip malformed snapshot */ }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch { /* no snapshot cache yet */ }
|
|
321
|
+
const result = new Map();
|
|
322
|
+
for (const itemId of warn) {
|
|
323
|
+
if (!blocked.has(itemId))
|
|
324
|
+
result.set(itemId, 'warn');
|
|
325
|
+
}
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
290
328
|
/** Org custom block patterns from the cached local-safety snapshots (offline read). */
|
|
291
329
|
function cachedCustomRules() {
|
|
292
330
|
const rules = [];
|
|
@@ -311,6 +349,8 @@ function cachedCustomRules() {
|
|
|
311
349
|
pattern: escapeDotNetRegex(block.pattern.trim()),
|
|
312
350
|
reason: typeof block.explanation === 'string' && block.explanation ? block.explanation : `matched custom safety pattern "${block.pattern.trim()}"`,
|
|
313
351
|
source: 'custom',
|
|
352
|
+
// 'mask' cannot rewrite a typed command — only 'warn' relaxes here.
|
|
353
|
+
...(block.action === 'warn' ? { action: 'warn' } : {}),
|
|
314
354
|
});
|
|
315
355
|
}
|
|
316
356
|
}
|
|
@@ -328,21 +368,27 @@ function cachedCustomRules() {
|
|
|
328
368
|
return true;
|
|
329
369
|
});
|
|
330
370
|
}
|
|
371
|
+
/** Builtins minus dashboard-disabled, with per-rule console actions applied. */
|
|
372
|
+
function effectiveBuiltinRules() {
|
|
373
|
+
const disabled = cachedDisabledItemIds();
|
|
374
|
+
const itemActions = cachedItemActions();
|
|
375
|
+
return BUILTIN_RULES
|
|
376
|
+
.filter(rule => !disabled.has(rule.id))
|
|
377
|
+
.map(rule => itemActions.get(rule.id) === 'warn' ? { ...rule, action: 'warn' } : rule);
|
|
378
|
+
}
|
|
331
379
|
/** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
|
|
332
380
|
function writeShellGuardRules() {
|
|
333
|
-
const disabled = cachedDisabledItemIds();
|
|
334
381
|
const data = {
|
|
335
382
|
updatedAt: new Date().toISOString(),
|
|
336
383
|
mode: cachedMode(),
|
|
337
|
-
rules: [...
|
|
384
|
+
rules: [...effectiveBuiltinRules(), ...cachedCustomRules()],
|
|
338
385
|
};
|
|
339
386
|
fs.writeFileSync(GUARD_RULES_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
340
387
|
return data;
|
|
341
388
|
}
|
|
342
389
|
/** The active ruleset in memory (builtins minus dashboard-disabled + org custom). */
|
|
343
390
|
function activeShellGuardRules() {
|
|
344
|
-
|
|
345
|
-
return [...BUILTIN_RULES.filter(rule => !disabled.has(rule.id)), ...cachedCustomRules()];
|
|
391
|
+
return [...effectiveBuiltinRules(), ...cachedCustomRules()];
|
|
346
392
|
}
|
|
347
393
|
/**
|
|
348
394
|
* Evaluate a typed command line WITHOUT executing it — the dry-run classifier
|
|
@@ -428,6 +474,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
428
474
|
` Severity = [string]$_.severity`,
|
|
429
475
|
` Reason = [string]$_.reason`,
|
|
430
476
|
` Source = [string]$_.source`,
|
|
477
|
+
` Action = $(if ([string]$_.action -eq 'warn') { 'warn' } else { 'block' })`,
|
|
431
478
|
` Regex = [regex]::new([string]$_.pattern, 'IgnoreCase')`,
|
|
432
479
|
` }`,
|
|
433
480
|
` } catch { $null }`,
|
|
@@ -439,12 +486,17 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
439
486
|
` param([string]$CommandLine)`,
|
|
440
487
|
` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
|
|
441
488
|
` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
|
|
489
|
+
` $warnHit = $null`,
|
|
442
490
|
` foreach ($rule in $global:FcdGuardRules) {`,
|
|
443
491
|
` try {`,
|
|
444
|
-
` if ($rule.Regex.IsMatch($normalized) -or $rule.Regex.IsMatch($CommandLine)) {
|
|
492
|
+
` if ($rule.Regex.IsMatch($normalized) -or $rule.Regex.IsMatch($CommandLine)) {`,
|
|
493
|
+
` # A block rule always outranks a warn rule matching the same line.`,
|
|
494
|
+
` if ($rule.Action -ne 'warn') { return $rule }`,
|
|
495
|
+
` if ($null -eq $warnHit) { $warnHit = $rule }`,
|
|
496
|
+
` }`,
|
|
445
497
|
` } catch { }`,
|
|
446
498
|
` }`,
|
|
447
|
-
` return $
|
|
499
|
+
` return $warnHit`,
|
|
448
500
|
`}`,
|
|
449
501
|
``,
|
|
450
502
|
`function global:Write-FcdGuardEvent {`,
|
|
@@ -495,6 +547,14 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
495
547
|
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
496
548
|
` return`,
|
|
497
549
|
` }`,
|
|
550
|
+
` # Per-rule 'warn' action from the console: notice + the command runs.`,
|
|
551
|
+
` if ($hit.Action -eq 'warn') {`,
|
|
552
|
+
` Write-Host ''`,
|
|
553
|
+
` Write-Host ('[FullCourtDefense] warning [' + $hit.Severity + ']: ' + $hit.Reason + ' (rule ' + $hit.Id + ') — allowed by org policy, reported to your security dashboard.') -ForegroundColor Yellow`,
|
|
554
|
+
` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
|
|
555
|
+
` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
|
|
556
|
+
` return`,
|
|
557
|
+
` }`,
|
|
498
558
|
` [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()`,
|
|
499
559
|
` Write-Host ''`,
|
|
500
560
|
` Write-Host ('[FullCourtDefense] BLOCKED [' + $hit.Severity + ']: ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Red`,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { LocalSafetyCustomBlock, LocalSafetyScanOptions } from './commands/deterministicGuard';
|
|
1
|
+
import { LocalSafetyCustomBlock, LocalSafetyRuleAction, LocalSafetyScanOptions } from './commands/deterministicGuard';
|
|
2
2
|
export interface LocalSafetySnapshot {
|
|
3
3
|
policyHash: string;
|
|
4
4
|
disabledBuiltInItemIds: string[];
|
|
5
|
+
/** Built-in items with a NON-default enforcement action (absent = 'block'). */
|
|
6
|
+
itemActions?: Record<string, LocalSafetyRuleAction>;
|
|
5
7
|
customBlocks: LocalSafetyCustomBlock[];
|
|
6
8
|
/** Org-managed trusted script paths — content scanning skipped under these. */
|
|
7
9
|
trustedScriptPaths?: string[];
|
|
@@ -67,14 +67,29 @@ function writeCache(file, snapshot) {
|
|
|
67
67
|
}
|
|
68
68
|
catch { /* best-effort */ }
|
|
69
69
|
}
|
|
70
|
+
function toRuleAction(value) {
|
|
71
|
+
return value === 'warn' || value === 'mask' ? value : undefined;
|
|
72
|
+
}
|
|
70
73
|
function toSnapshot(data) {
|
|
71
74
|
if (!data || typeof data !== 'object')
|
|
72
75
|
return undefined;
|
|
76
|
+
// Only non-default ('warn'/'mask') entries are kept — anything unrecognized
|
|
77
|
+
// falls back to 'block', so a malformed value can only fail strict.
|
|
78
|
+
let itemActions;
|
|
79
|
+
if (data.itemActions && typeof data.itemActions === 'object' && !Array.isArray(data.itemActions)) {
|
|
80
|
+
for (const [itemId, raw] of Object.entries(data.itemActions)) {
|
|
81
|
+
const action = toRuleAction(raw);
|
|
82
|
+
if (!action)
|
|
83
|
+
continue;
|
|
84
|
+
(itemActions ||= {})[itemId] = action;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
73
87
|
return {
|
|
74
88
|
policyHash: typeof data.policyHash === 'string' ? data.policyHash : '',
|
|
75
89
|
disabledBuiltInItemIds: Array.isArray(data.disabledBuiltInItemIds)
|
|
76
90
|
? data.disabledBuiltInItemIds.filter((item) => typeof item === 'string')
|
|
77
91
|
: [],
|
|
92
|
+
itemActions,
|
|
78
93
|
customBlocks: Array.isArray(data.customBlocks)
|
|
79
94
|
? data.customBlocks
|
|
80
95
|
.filter((item) => item && typeof item.pattern === 'string')
|
|
@@ -83,6 +98,7 @@ function toSnapshot(data) {
|
|
|
83
98
|
categoryId: String(item.categoryId || 'custom'),
|
|
84
99
|
pattern: String(item.pattern),
|
|
85
100
|
explanation: typeof item.explanation === 'string' ? item.explanation : undefined,
|
|
101
|
+
action: toRuleAction(item.action),
|
|
86
102
|
}))
|
|
87
103
|
: [],
|
|
88
104
|
trustedScriptPaths: Array.isArray(data.trustedScriptPaths)
|
|
@@ -134,6 +150,7 @@ async function loadLocalSafetySnapshot(input) {
|
|
|
134
150
|
function snapshotToScanOptions(snapshot, extra = {}) {
|
|
135
151
|
return {
|
|
136
152
|
disabledBuiltInItemIds: snapshot?.disabledBuiltInItemIds,
|
|
153
|
+
itemActions: snapshot?.itemActions,
|
|
137
154
|
customBlocks: snapshot?.customBlocks,
|
|
138
155
|
trustedScriptPaths: snapshot?.trustedScriptPaths,
|
|
139
156
|
policyHash: snapshot?.policyHash,
|
|
@@ -35,11 +35,24 @@ export interface MachineActionVerdict {
|
|
|
35
35
|
ok: boolean;
|
|
36
36
|
reason?: string;
|
|
37
37
|
}
|
|
38
|
+
export interface MachineActionVerifyContext {
|
|
39
|
+
/**
|
|
40
|
+
* The local machine's computed identity. When provided, a signed action bound
|
|
41
|
+
* to a DIFFERENT machineId is rejected — a valid control-plane signature for
|
|
42
|
+
* machine A must never execute on machine B (misrouted bundle, replay, or a
|
|
43
|
+
* compromised delivery path). Actions without a machineId binding (legacy)
|
|
44
|
+
* are unaffected.
|
|
45
|
+
*/
|
|
46
|
+
localMachineId?: string;
|
|
47
|
+
}
|
|
38
48
|
/**
|
|
39
49
|
* Verify a machine action before execution. Rejects when:
|
|
40
50
|
* - the signature is missing (unless the local escape hatch is set),
|
|
41
51
|
* - the signature's keyId is unknown,
|
|
42
52
|
* - the Ed25519 verification fails (any signed field was tampered with),
|
|
43
|
-
* - the action is already expired
|
|
53
|
+
* - the action is already expired,
|
|
54
|
+
* - the action is bound to a different machineId than this machine
|
|
55
|
+
* (when `context.localMachineId` is provided; escape hatch:
|
|
56
|
+
* FCD_MACHINE_ACTION_ANY_MACHINE_OK=1 for legacy-fingerprint fleets).
|
|
44
57
|
*/
|
|
45
|
-
export declare function verifyMachineAction(action: VerifiableMachineAction): MachineActionVerdict;
|
|
58
|
+
export declare function verifyMachineAction(action: VerifiableMachineAction, context?: MachineActionVerifyContext): MachineActionVerdict;
|
|
@@ -92,21 +92,44 @@ function resolvePublicKeyPem(keyId) {
|
|
|
92
92
|
function unsignedAllowed() {
|
|
93
93
|
return process.env.FCD_MACHINE_ACTION_UNSIGNED_OK === '1';
|
|
94
94
|
}
|
|
95
|
+
function anyMachineAllowed() {
|
|
96
|
+
return process.env.FCD_MACHINE_ACTION_ANY_MACHINE_OK === '1';
|
|
97
|
+
}
|
|
95
98
|
/**
|
|
96
99
|
* Verify a machine action before execution. Rejects when:
|
|
97
100
|
* - the signature is missing (unless the local escape hatch is set),
|
|
98
101
|
* - the signature's keyId is unknown,
|
|
99
102
|
* - the Ed25519 verification fails (any signed field was tampered with),
|
|
100
|
-
* - the action is already expired
|
|
103
|
+
* - the action is already expired,
|
|
104
|
+
* - the action is bound to a different machineId than this machine
|
|
105
|
+
* (when `context.localMachineId` is provided; escape hatch:
|
|
106
|
+
* FCD_MACHINE_ACTION_ANY_MACHINE_OK=1 for legacy-fingerprint fleets).
|
|
101
107
|
*/
|
|
102
|
-
function verifyMachineAction(action) {
|
|
108
|
+
function verifyMachineAction(action, context) {
|
|
103
109
|
const expiresAt = Date.parse(action.expiresAt);
|
|
104
110
|
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
|
|
105
111
|
return { ok: false, reason: 'Action is expired.' };
|
|
106
112
|
}
|
|
113
|
+
// Machine binding is enforced BEFORE signature checks so the rejection reason
|
|
114
|
+
// is precise: the machineId is part of the signed canonical payload, so a
|
|
115
|
+
// mismatch here means a correctly signed action was delivered to the WRONG
|
|
116
|
+
// machine — not a forgery, but never something this machine may run.
|
|
117
|
+
if (context?.localMachineId && action.machineId && action.machineId !== context.localMachineId) {
|
|
118
|
+
if (anyMachineAllowed()) {
|
|
119
|
+
return verifySignatureOnly(action, `MACHINE-ID MISMATCH accepted via FCD_MACHINE_ACTION_ANY_MACHINE_OK=1 (action targets ${action.machineId}, local id is ${context.localMachineId}).`);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: `Action is bound to machine ${action.machineId}, but this machine's id is ${context.localMachineId}. Rejected: signed remote actions only run on the machine they target. If this machine was re-imaged or its fingerprint changed, re-enroll it (\`fullcourtdefense onboard\`) or set FCD_MACHINE_ACTION_ANY_MACHINE_OK=1 as a temporary, local escape hatch.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return verifySignatureOnly(action);
|
|
127
|
+
}
|
|
128
|
+
/** Signature/expiry portion of verification (machine binding handled by the caller above). */
|
|
129
|
+
function verifySignatureOnly(action, notice) {
|
|
107
130
|
if (!action.signature?.signature) {
|
|
108
131
|
if (unsignedAllowed()) {
|
|
109
|
-
return { ok: true, reason: 'UNSIGNED action accepted via FCD_MACHINE_ACTION_UNSIGNED_OK=1.' };
|
|
132
|
+
return { ok: true, reason: withNotice('UNSIGNED action accepted via FCD_MACHINE_ACTION_UNSIGNED_OK=1.', notice) };
|
|
110
133
|
}
|
|
111
134
|
return {
|
|
112
135
|
ok: false,
|
|
@@ -125,10 +148,13 @@ function verifyMachineAction(action) {
|
|
|
125
148
|
const key = crypto.createPublicKey(publicKeyPem);
|
|
126
149
|
const valid = crypto.verify(null, canonicalPayload(action), key, Buffer.from(signature.signature, 'base64'));
|
|
127
150
|
return valid
|
|
128
|
-
? { ok: true }
|
|
151
|
+
? { ok: true, ...(notice ? { reason: notice } : {}) }
|
|
129
152
|
: { ok: false, reason: 'Invalid signature — the action payload does not match what the control plane signed.' };
|
|
130
153
|
}
|
|
131
154
|
catch (error) {
|
|
132
155
|
return { ok: false, reason: `Signature verification error: ${error.message}` };
|
|
133
156
|
}
|
|
134
157
|
}
|
|
158
|
+
function withNotice(base, notice) {
|
|
159
|
+
return notice ? `${notice} ${base}` : base;
|
|
160
|
+
}
|
package/dist/telemetry.d.ts
CHANGED
package/dist/version.json
CHANGED