fullcourtdefense-cli 1.7.22 → 1.7.23

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.
@@ -1,11 +1,30 @@
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';
2
3
  export interface DeterministicFinding {
3
4
  blocked: true;
4
5
  ruleId: string;
5
- category: 'sensitive_file' | 'metadata_ssrf' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration';
6
+ category: DeterministicCategory;
7
+ categoryId: string;
8
+ itemId: string;
9
+ source: 'builtin' | 'custom';
6
10
  reason: string;
7
11
  evidence: string;
12
+ explanation?: string;
13
+ policyHash?: string;
8
14
  }
9
- export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>): DeterministicFinding | undefined;
10
- export declare function scanDeterministicTextResponse(text: string): DeterministicFinding | undefined;
11
- export declare function scanDeterministicPrompt(text: string): DeterministicFinding | undefined;
15
+ export interface LocalSafetyCustomBlock {
16
+ id: string;
17
+ categoryId: string;
18
+ pattern: string;
19
+ explanation?: string;
20
+ }
21
+ export interface LocalSafetyScanOptions {
22
+ disabledBuiltInItemIds?: string[];
23
+ customBlocks?: LocalSafetyCustomBlock[];
24
+ policyHash?: string;
25
+ cwd?: string;
26
+ inspectScripts?: boolean;
27
+ }
28
+ export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
29
+ export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
30
+ export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
@@ -1,27 +1,62 @@
1
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
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
4
37
  exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
5
38
  exports.scanDeterministicPrompt = scanDeterministicPrompt;
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
6
41
  const SECRET_PATTERNS = [
7
- { label: 'private key material', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i },
8
- { label: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
9
- { label: 'GitHub token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/ },
10
- { label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
11
- { label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
12
- { label: 'OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/ },
13
- { label: 'OpenAI API key', re: /\bsk-[A-Za-z0-9_-]{32,}\b/ },
14
- { label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/ },
15
- { label: 'Stripe live key', re: /\b[rs]k_live_[0-9a-zA-Z]{24,}\b/ },
16
- { label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
17
- { label: 'HuggingFace token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
18
- { label: 'database URL with password', re: /\b(?:postgres(?:ql)?|mongodb(?:\+srv)?|mysql):\/\/[^/\s'":]+:[^@\s'"]+@/i },
42
+ { itemId: 'private_key_material', label: 'private key material', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i },
43
+ { itemId: 'aws_access_key', label: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
44
+ { itemId: 'github_token', label: 'GitHub token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/ },
45
+ { itemId: 'github_token', label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
46
+ { itemId: 'anthropic_key', label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
47
+ { itemId: 'openai_key', label: 'OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/ },
48
+ { itemId: 'openai_key', label: 'OpenAI API key', re: /\bsk-[A-Za-z0-9_-]{32,}\b/ },
49
+ { itemId: 'slack_token', label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/ },
50
+ { itemId: 'stripe_live_key', label: 'Stripe live key', re: /\b[rs]k_live_[0-9a-zA-Z]{24,}\b/ },
51
+ { itemId: 'google_api_key', label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
52
+ { itemId: 'huggingface_token', label: 'HuggingFace token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
53
+ { itemId: 'database_url_password', label: 'database URL with password', re: /\b(?:postgres(?:ql)?|mongodb(?:\+srv)?|mysql):\/\/[^/\s'":]+:[^@\s'"]+@/i },
19
54
  ];
20
55
  const METADATA_ENDPOINTS = [
21
- '169.254.169.254',
22
- 'metadata.google.internal',
23
- 'metadata.azure.com',
24
- '100.100.100.200',
56
+ { itemId: 'aws_gcp_metadata_ip', value: '169.254.169.254', label: 'cloud metadata endpoint' },
57
+ { itemId: 'gcp_metadata_host', value: 'metadata.google.internal', label: 'GCP metadata endpoint' },
58
+ { itemId: 'azure_metadata_host', value: 'metadata.azure.com', label: 'Azure metadata endpoint' },
59
+ { itemId: 'alibaba_metadata_ip', value: '100.100.100.200', label: 'Alibaba cloud metadata endpoint' },
25
60
  ];
26
61
  const OUTBOUND_TOOL_HINT = /(?:http|fetch|request|curl|wget|webhook|upload|send|email|mail|slack|discord|teams|post|put|publish|notify)/i;
27
62
  const COMMAND_TOOL_HINT = /(?:shell|bash|command|cmd|powershell|terminal|exec|subprocess|run|script)/i;
@@ -46,6 +81,11 @@ const SQL_KEYS = new Set([
46
81
  'toolArgs.query',
47
82
  'toolArgs.sql',
48
83
  ]);
84
+ const DEFAULT_DISABLED_BUILT_INS = new Set([
85
+ 'git_clean_force',
86
+ 'generic_kubectl_delete',
87
+ 'truncate_table',
88
+ ]);
49
89
  function evidence(value) {
50
90
  const compact = value.replace(/\s+/g, ' ').trim();
51
91
  return compact.length > 180 ? `${compact.slice(0, 180)}...` : compact;
@@ -96,41 +136,84 @@ function collectStrings(value, keyPath = 'args', out = [], depth = 0) {
96
136
  }
97
137
  return out;
98
138
  }
139
+ function disabled(options) {
140
+ return new Set(options?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS));
141
+ }
142
+ function isEnabled(itemId, options) {
143
+ return !disabled(options).has(itemId);
144
+ }
145
+ function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence, explanation, options) {
146
+ if (!isEnabled(itemId, options))
147
+ return undefined;
148
+ return {
149
+ blocked: true,
150
+ ruleId,
151
+ category,
152
+ categoryId,
153
+ itemId,
154
+ source: 'builtin',
155
+ reason,
156
+ evidence: evidence(findingEvidence),
157
+ explanation,
158
+ policyHash: options?.policyHash,
159
+ };
160
+ }
161
+ function customBlock(value, options) {
162
+ const lower = value.toLowerCase();
163
+ for (const block of options?.customBlocks || []) {
164
+ const pattern = block.pattern.trim();
165
+ if (!pattern || !lower.includes(pattern.toLowerCase()))
166
+ continue;
167
+ return {
168
+ blocked: true,
169
+ ruleId: 'local-custom-block',
170
+ category: 'destructive_command',
171
+ categoryId: block.categoryId || 'custom',
172
+ itemId: block.id,
173
+ source: 'custom',
174
+ reason: block.explanation || `Blocked custom local safety pattern: ${pattern}.`,
175
+ evidence: evidence(value),
176
+ explanation: block.explanation,
177
+ policyHash: options?.policyHash,
178
+ };
179
+ }
180
+ return undefined;
181
+ }
99
182
  function findSecret(value) {
100
183
  for (const pattern of SECRET_PATTERNS) {
101
184
  const match = value.match(pattern.re);
102
185
  if (match?.[0])
103
- return { label: pattern.label, value: match[0] };
186
+ return { itemId: pattern.itemId, label: pattern.label, value: match[0] };
104
187
  }
105
188
  return undefined;
106
189
  }
107
190
  function containsMetadataEndpoint(value) {
108
191
  const normalized = decodeLoosely(value).toLowerCase();
109
- return METADATA_ENDPOINTS.some(endpoint => normalized.includes(endpoint));
192
+ return METADATA_ENDPOINTS.find(endpoint => normalized.includes(endpoint.value));
110
193
  }
111
194
  function containsSensitiveCredentialPath(value) {
112
195
  const text = normalizeForPath(value);
113
196
  const traversal = /(?:^|\/)\.\.(?:\/|$)/.test(text) || /%2e/i.test(value);
114
197
  const checks = [
115
- { label: 'SSH private key', re: /(?:^|\/)\.ssh\/id_(?:rsa|ed25519|ecdsa|dsa)(?:$|[/?#\s'"])/ },
116
- { label: 'SSH config', re: /(?:^|\/)\.ssh\/config(?:$|[/?#\s'"])/ },
117
- { label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"])/ },
118
- { label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"])/ },
119
- { label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"])/ },
120
- { label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"])/ },
121
- { label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"])/ },
122
- { label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"])/ },
123
- { label: 'GCP ADC credentials', re: /application_default_credentials\.json(?:$|[/?#\s'"])/ },
124
- { label: 'Linux shadow file', re: /(?:^|\/)etc\/(?:shadow|sudoers)(?:$|[/?#\s'"])/ },
125
- { label: 'PowerShell history', re: /powershell\/psreadline\/consolehost_history\.txt(?:$|[/?#\s'"])/ },
126
- { label: 'browser credential store', re: /(?:login data|cookies|key4\.db|logins\.json)(?:$|[/?#\s'"])/ },
198
+ { itemId: 'ssh_private_key', label: 'SSH private key', re: /(?:^|\/)\.ssh\/id_(?:rsa|ed25519|ecdsa|dsa)(?:$|[/?#\s'"])/ },
199
+ { itemId: 'ssh_config', label: 'SSH config', re: /(?:^|\/)\.ssh\/config(?:$|[/?#\s'"])/ },
200
+ { itemId: 'aws_credentials', label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"])/ },
201
+ { itemId: 'kube_config', label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"])/ },
202
+ { itemId: 'docker_auth', label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"])/ },
203
+ { itemId: 'npmrc', label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"])/ },
204
+ { itemId: 'pypirc', label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"])/ },
205
+ { itemId: 'netrc', label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"])/ },
206
+ { itemId: 'gcp_adc', label: 'GCP ADC credentials', re: /application_default_credentials\.json(?:$|[/?#\s'"])/ },
207
+ { itemId: 'linux_shadow', label: 'Linux shadow file', re: /(?:^|\/)etc\/(?:shadow|sudoers)(?:$|[/?#\s'"])/ },
208
+ { itemId: 'powershell_history', label: 'PowerShell history', re: /powershell\/psreadline\/consolehost_history\.txt(?:$|[/?#\s'"])/ },
209
+ { itemId: 'browser_credentials', label: 'browser credential store', re: /(?:login data|cookies|key4\.db|logins\.json)(?:$|[/?#\s'"])/ },
127
210
  ];
128
211
  for (const check of checks) {
129
212
  if (check.re.test(text))
130
- return check.label;
213
+ return { itemId: check.itemId, label: check.label };
131
214
  }
132
215
  if (traversal && /(?:^|\/)\.env(?:\.[a-z0-9_-]+)?(?:$|[/?#\s'"])/.test(text)) {
133
- return 'environment file via path traversal';
216
+ return { itemId: 'env_traversal', label: 'environment file via path traversal' };
134
217
  }
135
218
  return undefined;
136
219
  }
@@ -138,69 +221,75 @@ function destructiveCommandReason(value) {
138
221
  const text = normalizeForCommand(value);
139
222
  const lower = text.toLowerCase();
140
223
  if (/\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+(?:["']?\/["']?|\*)(?:\s|$|[;&|])/.test(lower))
141
- return 'recursive force delete of filesystem root';
224
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
142
225
  if (/\bsudo\s+rm\s+-[a-z]*r[a-z]*f[a-z]*\s+(?:["']?\/["']?|\*)(?:\s|$|[;&|])/.test(lower))
143
- return 'recursive force delete of filesystem root';
226
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
144
227
  if (/\b(?:del|erase)\s+\/[a-z]*s[a-z]*\s+\/[a-z]*q[a-z]*\s+[a-z]:\\(?:\s|$)/i.test(text))
145
- return 'recursive Windows drive delete';
228
+ return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
146
229
  if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force') && /[a-z]:\\?(?:\s|$)/i.test(text))
147
- return 'recursive Windows drive delete';
230
+ return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
148
231
  if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
149
- return 'fork bomb';
232
+ return { itemId: 'fork_bomb', reason: 'fork bomb' };
150
233
  if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
151
- return 'filesystem format command';
234
+ return { itemId: 'mkfs_device', reason: 'filesystem format command' };
152
235
  if (/\bdd\b(?=.*\bof=\/dev\/(?:sd|xvd|hd|nvme|disk))/i.test(text))
153
- return 'raw disk overwrite command';
236
+ return { itemId: 'dd_disk_overwrite', reason: 'raw disk overwrite command' };
154
237
  if (/\bchmod\s+-r\s+777\s+\/(?:\s|$)/i.test(text))
155
- return 'recursive permission change on root';
238
+ return { itemId: 'chmod_root_777', reason: 'recursive permission change on root' };
239
+ if (/\bgit\s+(?:reset\s+--hard|clean\s+-f(?:d|x|dx)?\b)/i.test(text))
240
+ return { itemId: 'git_clean_force', reason: 'destructive git clean/reset' };
156
241
  return undefined;
157
242
  }
158
243
  function reverseShellReason(value) {
159
244
  const text = normalizeForCommand(value);
160
245
  if (/\bbash\s+-i\b.*\/dev\/tcp\//i.test(text))
161
- return 'bash reverse shell';
246
+ return { itemId: 'bash_dev_tcp', reason: 'bash reverse shell' };
162
247
  if (/\b(?:nc|ncat|netcat)\b.*\s-e\s+(?:\/bin\/)?(?:sh|bash)\b/i.test(text))
163
- return 'netcat reverse shell';
248
+ return { itemId: 'netcat_exec', reason: 'netcat reverse shell' };
164
249
  if (/\bsocat\b.*\bexec:(?:\/bin\/)?(?:sh|bash)\b/i.test(text))
165
- return 'socat reverse shell';
250
+ return { itemId: 'socat_exec', reason: 'socat reverse shell' };
166
251
  if (/\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash)\b/i.test(text))
167
- return 'remote script piped to shell';
252
+ return { itemId: 'curl_pipe_shell', reason: 'remote script piped to shell' };
168
253
  if (/\bpython(?:3)?\s+-c\b(?=.*socket)(?=.*subprocess)/i.test(text))
169
- return 'python reverse shell';
254
+ return { itemId: 'python_socket_subprocess', reason: 'python reverse shell' };
170
255
  return undefined;
171
256
  }
172
257
  function destructiveSqlReason(value) {
173
258
  const text = normalizeForCommand(value);
174
259
  if (/\bdrop\s+database\b/i.test(text))
175
- return 'DROP DATABASE';
260
+ return { itemId: 'drop_database', reason: 'DROP DATABASE' };
176
261
  if (/\bdrop\s+schema\b/i.test(text))
177
- return 'DROP SCHEMA';
262
+ return { itemId: 'drop_schema', reason: 'DROP SCHEMA' };
263
+ if (/\btruncate\s+table\b/i.test(text))
264
+ return { itemId: 'truncate_table', reason: 'TRUNCATE TABLE' };
178
265
  return undefined;
179
266
  }
180
267
  function infraDestroyReason(value) {
181
268
  const text = normalizeForCommand(value);
182
269
  if (/\bterraform\s+destroy\b(?=.*--auto-approve)/i.test(text))
183
- return 'terraform destroy with auto-approve';
270
+ return { itemId: 'terraform_destroy_auto', reason: 'terraform destroy with auto-approve' };
184
271
  if (/\bpulumi\s+destroy\b(?=.*(?:--yes|-y))/i.test(text))
185
- return 'pulumi destroy with auto-approve';
186
- if (/\bkubectl\s+delete\s+(?:namespace|clusterrole|secret)\b/i.test(text))
187
- return 'destructive kubectl delete';
272
+ return { itemId: 'pulumi_destroy_yes', reason: 'pulumi destroy with auto-approve' };
273
+ if (/\bkubectl\s+delete\s+namespace\b/i.test(text))
274
+ return { itemId: 'kubectl_delete_namespace', reason: 'destructive kubectl namespace delete' };
275
+ if (/\bkubectl\s+delete\s+(?:clusterrole|secret)\b/i.test(text))
276
+ return { itemId: 'generic_kubectl_delete', reason: 'destructive kubectl delete' };
188
277
  if (/\bkubectl\s+delete\s+all\b(?=.*--all)/i.test(text))
189
- return 'broad kubectl delete all';
278
+ return { itemId: 'kubectl_delete_all_all', reason: 'broad kubectl delete all' };
190
279
  if (/\bgcloud\s+projects\s+delete\b/i.test(text))
191
- return 'GCP project deletion';
280
+ return { itemId: 'gcloud_project_delete', reason: 'GCP project deletion' };
192
281
  if (/\bgcloud\s+sql\s+instances\s+delete\b/i.test(text))
193
- return 'GCP SQL instance deletion';
282
+ return { itemId: 'gcloud_sql_delete', reason: 'GCP SQL instance deletion' };
194
283
  if (/\baws\s+s3\s+rm\s+s3:\/\/\S+\s+--recursive\b/i.test(text))
195
- return 'recursive S3 deletion';
284
+ return { itemId: 'aws_s3_recursive_rm', reason: 'recursive S3 deletion' };
196
285
  if (/\baws\s+cloudformation\s+delete-stack\b/i.test(text))
197
- return 'CloudFormation stack deletion';
286
+ return { itemId: 'aws_cloudformation_delete_stack', reason: 'CloudFormation stack deletion' };
198
287
  if (/\baws\s+iam\s+attach-user-policy\b(?=.*AdministratorAccess)/i.test(text))
199
- return 'IAM administrator privilege grant';
288
+ return { itemId: 'aws_iam_admin_policy', reason: 'IAM administrator privilege grant' };
200
289
  if (/\baws\s+iam\s+create-access-key\b/i.test(text))
201
- return 'IAM access key creation';
290
+ return { itemId: 'aws_iam_access_key', reason: 'IAM access key creation' };
202
291
  if (/\baz\s+group\s+delete\b(?=.*(?:--yes|-y))/i.test(text))
203
- return 'Azure resource group deletion';
292
+ return { itemId: 'az_group_delete_yes', reason: 'Azure resource group deletion' };
204
293
  return undefined;
205
294
  }
206
295
  function isOutboundContext(toolName, candidates) {
@@ -216,107 +305,77 @@ function isSqlContext(toolName, candidate) {
216
305
  const lastKey = candidate.keyPath.split('.').pop() || '';
217
306
  return SQL_TOOL_HINT.test(toolName) || SQL_KEYS.has(lastKey) || SQL_KEYS.has(candidate.keyPath);
218
307
  }
219
- function scanDeterministicToolCall(toolName, toolArgs) {
308
+ function scanTextValue(toolName, value, options) {
309
+ const custom = customBlock(value, options);
310
+ if (custom)
311
+ return custom;
312
+ const sensitivePath = containsSensitiveCredentialPath(value);
313
+ if (sensitivePath) {
314
+ return builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path', `Blocked local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
315
+ }
316
+ const metadata = containsMetadataEndpoint(value);
317
+ if (metadata) {
318
+ return builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`, value, metadata.value, options);
319
+ }
320
+ const reverseShell = reverseShellReason(value);
321
+ if (reverseShell) {
322
+ return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-reverse-shell', `Blocked ${reverseShell.reason}.`, value, reverseShell.reason, options);
323
+ }
324
+ const destructive = destructiveCommandReason(value);
325
+ if (destructive) {
326
+ return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
327
+ }
328
+ const infraDestroy = infraDestroyReason(value);
329
+ if (infraDestroy) {
330
+ return builtIn(infraDestroy.itemId, 'infra_destroy', 'infra_destroy', 'local-infra-destroy', `Blocked ${infraDestroy.reason}.`, value, infraDestroy.reason, options);
331
+ }
332
+ const destructiveSql = destructiveSqlReason(value);
333
+ if (destructiveSql) {
334
+ return builtIn(destructiveSql.itemId, 'destructive_sql', 'destructive_sql', 'local-destructive-sql', `Blocked destructive SQL command: ${destructiveSql.reason}.`, value, destructiveSql.reason, options);
335
+ }
336
+ return undefined;
337
+ }
338
+ function scanDeterministicToolCall(toolName, toolArgs, options) {
220
339
  const candidates = collectStrings(toolArgs);
221
340
  const outbound = isOutboundContext(toolName, candidates);
222
341
  for (const candidate of candidates) {
223
- const sensitivePath = containsSensitiveCredentialPath(candidate.value);
224
- if (sensitivePath) {
225
- return {
226
- blocked: true,
227
- ruleId: 'local-sensitive-credential-path',
228
- category: 'sensitive_file',
229
- reason: `Blocked local agent access to ${sensitivePath}.`,
230
- evidence: evidence(candidate.value),
231
- };
232
- }
342
+ const custom = customBlock(candidate.value, options);
343
+ if (custom)
344
+ return custom;
233
345
  }
234
346
  for (const candidate of candidates) {
235
- if (containsMetadataEndpoint(candidate.value)) {
236
- return {
237
- blocked: true,
238
- ruleId: 'local-cloud-metadata-ssrf',
239
- category: 'metadata_ssrf',
240
- reason: 'Blocked request to cloud metadata endpoint.',
241
- evidence: evidence(candidate.value),
242
- };
243
- }
244
- }
245
- for (const candidate of candidates) {
246
- if (!isCommandContext(toolName, candidate))
247
- continue;
248
- const reverseShell = reverseShellReason(candidate.value);
249
- if (reverseShell) {
250
- return {
251
- blocked: true,
252
- ruleId: 'local-reverse-shell',
253
- category: 'reverse_shell',
254
- reason: `Blocked ${reverseShell}.`,
255
- evidence: evidence(candidate.value),
256
- };
257
- }
258
- const destructive = destructiveCommandReason(candidate.value);
259
- if (destructive) {
260
- return {
261
- blocked: true,
262
- ruleId: 'local-destructive-command',
263
- category: 'destructive_command',
264
- reason: `Blocked ${destructive}.`,
265
- evidence: evidence(candidate.value),
266
- };
267
- }
268
- const infraDestroy = infraDestroyReason(candidate.value);
269
- if (infraDestroy) {
270
- return {
271
- blocked: true,
272
- ruleId: 'local-infra-destroy',
273
- category: 'infra_destroy',
274
- reason: `Blocked ${infraDestroy}.`,
275
- evidence: evidence(candidate.value),
276
- };
277
- }
347
+ const finding = scanTextValue(toolName, candidate.value, options);
348
+ if (finding && (isCommandContext(toolName, candidate) || finding.category === 'sensitive_file' || finding.category === 'metadata_ssrf'))
349
+ return finding;
278
350
  }
279
351
  for (const candidate of candidates) {
280
352
  if (!isSqlContext(toolName, candidate))
281
353
  continue;
282
354
  const destructiveSql = destructiveSqlReason(candidate.value);
283
355
  if (destructiveSql) {
284
- return {
285
- blocked: true,
286
- ruleId: 'local-destructive-sql',
287
- category: 'destructive_sql',
288
- reason: `Blocked destructive SQL command: ${destructiveSql}.`,
289
- evidence: evidence(candidate.value),
290
- };
356
+ return builtIn(destructiveSql.itemId, 'destructive_sql', 'destructive_sql', 'local-destructive-sql', `Blocked destructive SQL command: ${destructiveSql.reason}.`, candidate.value, destructiveSql.reason, options);
291
357
  }
292
358
  }
293
359
  if (outbound) {
294
360
  for (const candidate of candidates) {
295
361
  const secret = findSecret(candidate.value);
296
362
  if (secret) {
297
- return {
298
- blocked: true,
299
- ruleId: 'local-secret-exfiltration',
300
- category: 'secret_exfiltration',
301
- reason: `Blocked outbound data containing ${secret.label}.`,
302
- evidence: evidence(secret.value),
303
- };
363
+ return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-exfiltration', `Blocked outbound data containing ${secret.label}.`, secret.value, secret.label, options);
304
364
  }
305
365
  }
306
366
  }
367
+ if (options?.inspectScripts && toolArgs.command && typeof toolArgs.command === 'string') {
368
+ const scriptFinding = scanReferencedScript(toolArgs.command, typeof toolArgs.cwd === 'string' ? toolArgs.cwd : options.cwd, options);
369
+ if (scriptFinding)
370
+ return scriptFinding;
371
+ }
307
372
  return undefined;
308
373
  }
309
- function scanDeterministicTextResponse(text) {
374
+ function scanDeterministicTextResponse(text, options) {
310
375
  const secret = findSecret(text);
311
376
  if (!secret)
312
377
  return undefined;
313
- return {
314
- blocked: true,
315
- ruleId: 'local-secret-in-tool-response',
316
- category: 'secret_exfiltration',
317
- reason: `Blocked tool response containing ${secret.label}.`,
318
- evidence: evidence(secret.value),
319
- };
378
+ return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-in-tool-response', `Blocked tool response containing ${secret.label}.`, secret.value, secret.label, options);
320
379
  }
321
380
  function looksLikeQuestion(value) {
322
381
  return /^(?:what|why|how|explain|describe|is|are|can you explain)\b/i.test(value.trim());
@@ -327,79 +386,121 @@ function bareOrActionIntent(value, action) {
327
386
  return false;
328
387
  return action.test(trimmed) || /^[^\n]{1,240}$/.test(trimmed);
329
388
  }
330
- function scanDeterministicPrompt(text) {
389
+ function scanDeterministicPrompt(text, options) {
331
390
  const trimmed = text.trim();
332
391
  if (!trimmed || looksLikeQuestion(trimmed))
333
392
  return undefined;
393
+ const custom = customBlock(trimmed, options);
394
+ if (custom)
395
+ return custom;
334
396
  const secret = findSecret(trimmed);
335
397
  if (secret && OUTBOUND_TOOL_HINT.test(trimmed)) {
336
- return {
337
- blocked: true,
338
- ruleId: 'local-prompt-secret-exfiltration',
339
- category: 'secret_exfiltration',
340
- reason: `Blocked prompt asking to send ${secret.label}.`,
341
- evidence: evidence(secret.value),
342
- };
398
+ return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-prompt-secret-exfiltration', `Blocked prompt asking to send ${secret.label}.`, secret.value, secret.label, options);
343
399
  }
344
400
  const sensitivePath = containsSensitiveCredentialPath(trimmed);
345
401
  if (sensitivePath && bareOrActionIntent(trimmed, /\b(?:read|open|cat|show|print|copy|upload|send|exfiltrate)\b/i)) {
346
- return {
347
- blocked: true,
348
- ruleId: 'local-prompt-sensitive-credential-path',
349
- category: 'sensitive_file',
350
- reason: `Blocked prompt targeting ${sensitivePath}.`,
351
- evidence: evidence(trimmed),
352
- };
402
+ return builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-prompt-sensitive-credential-path', `Blocked prompt targeting ${sensitivePath.label}.`, trimmed, sensitivePath.label, options);
353
403
  }
354
404
  if (bareOrActionIntent(trimmed, /\b(?:run|execute|launch|start)\b/i)) {
355
405
  const reverseShell = reverseShellReason(trimmed);
356
406
  if (reverseShell) {
357
- return {
358
- blocked: true,
359
- ruleId: 'local-prompt-reverse-shell',
360
- category: 'reverse_shell',
361
- reason: `Blocked prompt containing ${reverseShell}.`,
362
- evidence: evidence(trimmed),
363
- };
407
+ return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-prompt-reverse-shell', `Blocked prompt containing ${reverseShell.reason}.`, trimmed, reverseShell.reason, options);
364
408
  }
365
409
  const destructive = destructiveCommandReason(trimmed);
366
410
  if (destructive) {
367
- return {
368
- blocked: true,
369
- ruleId: 'local-prompt-destructive-command',
370
- category: 'destructive_command',
371
- reason: `Blocked prompt containing ${destructive}.`,
372
- evidence: evidence(trimmed),
373
- };
411
+ return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-prompt-destructive-command', `Blocked prompt containing ${destructive.reason}.`, trimmed, destructive.reason, options);
374
412
  }
375
413
  const infraDestroy = infraDestroyReason(trimmed);
376
414
  if (infraDestroy) {
377
- return {
378
- blocked: true,
379
- ruleId: 'local-prompt-infra-destroy',
380
- category: 'infra_destroy',
381
- reason: `Blocked prompt containing ${infraDestroy}.`,
382
- evidence: evidence(trimmed),
383
- };
415
+ return builtIn(infraDestroy.itemId, 'infra_destroy', 'infra_destroy', 'local-prompt-infra-destroy', `Blocked prompt containing ${infraDestroy.reason}.`, trimmed, infraDestroy.reason, options);
384
416
  }
385
417
  }
386
418
  const destructiveSql = destructiveSqlReason(trimmed);
387
419
  if (destructiveSql && bareOrActionIntent(trimmed, /\b(?:run|execute|query|apply)\b/i)) {
388
- return {
389
- blocked: true,
390
- ruleId: 'local-prompt-destructive-sql',
391
- category: 'destructive_sql',
392
- reason: `Blocked prompt containing destructive SQL command: ${destructiveSql}.`,
393
- evidence: evidence(trimmed),
394
- };
420
+ return builtIn(destructiveSql.itemId, 'destructive_sql', 'destructive_sql', 'local-prompt-destructive-sql', `Blocked prompt containing destructive SQL command: ${destructiveSql.reason}.`, trimmed, destructiveSql.reason, options);
421
+ }
422
+ const metadata = containsMetadataEndpoint(trimmed);
423
+ if (metadata && /\b(?:fetch|request|curl|open|connect|call)\b/i.test(trimmed)) {
424
+ return builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-prompt-cloud-metadata-ssrf', `Blocked prompt targeting ${metadata.label}.`, trimmed, metadata.value, options);
425
+ }
426
+ return undefined;
427
+ }
428
+ function splitCommand(command) {
429
+ return command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map(part => part.replace(/^["']|["']$/g, '')) || [];
430
+ }
431
+ function safeResolveScriptPath(candidate, cwd) {
432
+ if (!candidate || /^https?:\/\//i.test(candidate))
433
+ return undefined;
434
+ const base = cwd || process.cwd();
435
+ const resolved = path.resolve(base, candidate);
436
+ const root = path.resolve(base);
437
+ if (!resolved.toLowerCase().startsWith(root.toLowerCase()))
438
+ return undefined;
439
+ const ext = path.extname(resolved).toLowerCase();
440
+ if (!['.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd', '.py', '.js', '.mjs', '.cjs'].includes(ext))
441
+ return undefined;
442
+ return resolved;
443
+ }
444
+ function referencedScripts(command, cwd) {
445
+ const tokens = splitCommand(command);
446
+ const scripts = [];
447
+ const runners = new Set(['bash', 'sh', 'zsh', 'python', 'python3', 'node', 'pwsh']);
448
+ for (let i = 0; i < tokens.length; i++) {
449
+ const token = path.basename(tokens[i]).toLowerCase();
450
+ if (runners.has(token)) {
451
+ const next = tokens.slice(i + 1).find(part => part && !part.startsWith('-'));
452
+ const resolved = next ? safeResolveScriptPath(next, cwd) : undefined;
453
+ if (resolved)
454
+ scripts.push(resolved);
455
+ }
456
+ if ((token === 'powershell' || token === 'powershell.exe') && tokens[i + 1]?.toLowerCase() === '-file') {
457
+ const resolved = safeResolveScriptPath(tokens[i + 2], cwd);
458
+ if (resolved)
459
+ scripts.push(resolved);
460
+ }
461
+ const direct = safeResolveScriptPath(tokens[i], cwd);
462
+ if (direct)
463
+ scripts.push(direct);
464
+ }
465
+ if (tokens[0]?.toLowerCase() === 'npm' && tokens[1]?.toLowerCase() === 'run' && tokens[2]) {
466
+ const pkg = path.resolve(cwd || process.cwd(), 'package.json');
467
+ try {
468
+ const stat = fs.statSync(pkg);
469
+ if (stat.size <= 200_000) {
470
+ const parsed = JSON.parse(fs.readFileSync(pkg, 'utf8'));
471
+ const script = parsed?.scripts?.[tokens[2]];
472
+ if (typeof script === 'string')
473
+ scripts.push(`npm:${tokens[2]}:${script}`);
474
+ }
475
+ }
476
+ catch { /* ignore */ }
395
477
  }
396
- if (containsMetadataEndpoint(trimmed) && /\b(?:fetch|request|curl|open|connect|call)\b/i.test(trimmed)) {
478
+ return Array.from(new Set(scripts));
479
+ }
480
+ function scanReferencedScript(command, cwd, options) {
481
+ for (const script of referencedScripts(command, cwd)) {
482
+ let content = '';
483
+ if (script.startsWith('npm:')) {
484
+ content = script;
485
+ }
486
+ else {
487
+ try {
488
+ const stat = fs.statSync(script);
489
+ if (!stat.isFile() || stat.size > 250_000)
490
+ continue;
491
+ content = fs.readFileSync(script, 'utf8');
492
+ }
493
+ catch {
494
+ continue;
495
+ }
496
+ }
497
+ const finding = scanTextValue('script_file', content, options);
498
+ if (!finding)
499
+ continue;
397
500
  return {
398
- blocked: true,
399
- ruleId: 'local-prompt-cloud-metadata-ssrf',
400
- category: 'metadata_ssrf',
401
- reason: 'Blocked prompt targeting cloud metadata endpoint.',
402
- evidence: evidence(trimmed),
501
+ ...finding,
502
+ evidence: evidence(`${script}: ${finding.evidence}`),
503
+ reason: `${finding.reason} Found inside referenced script ${script.startsWith('npm:') ? script.split(':').slice(0, 2).join(':') : path.basename(script)}.`,
403
504
  };
404
505
  }
405
506
  return undefined;
@@ -41,6 +41,7 @@ const config_1 = require("../config");
41
41
  const runtimeConfig_1 = require("../runtimeConfig");
42
42
  const telemetry_1 = require("../telemetry");
43
43
  const deterministicGuard_1 = require("./deterministicGuard");
44
+ const localSafetySnapshot_1 = require("../localSafetySnapshot");
44
45
  const taintLedger_1 = require("./taintLedger");
45
46
  const DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
46
47
  /** Append a diagnostic line so we can see exactly what Cursor invoked + the verdict. */
@@ -324,6 +325,24 @@ function machineMetadata() {
324
325
  agentClient: 'cursor',
325
326
  };
326
327
  }
328
+ function spoolLocalFinding(input) {
329
+ (0, telemetry_1.spoolEvent)({
330
+ decision: 'block',
331
+ toolName: input.toolName,
332
+ operation: input.operation,
333
+ reason: input.finding.reason,
334
+ ruleId: input.finding.ruleId,
335
+ category: input.finding.category,
336
+ categoryId: input.finding.categoryId,
337
+ itemId: input.finding.itemId,
338
+ source: input.finding.source,
339
+ evidence: input.finding.evidence,
340
+ explanation: input.finding.explanation,
341
+ policyHash: input.finding.policyHash,
342
+ offlineEnforced: true,
343
+ });
344
+ (0, telemetry_1.triggerFlush)(true);
345
+ }
327
346
  async function hookCommand(args, config) {
328
347
  // Mode is server-authoritative (pulled from the cached runtime bundle), with
329
348
  // local flags as explicit overrides. Resolved after creds below.
@@ -343,14 +362,16 @@ async function hookCommand(args, config) {
343
362
  const shieldId = creds.shieldId;
344
363
  const shieldKey = creds.shieldKey;
345
364
  const apiUrl = creds.apiUrl;
365
+ let effectivePolicyHash;
346
366
  // Server-authoritative enforcement mode: the console flips shield.mode and the
347
367
  // machine applies it on the next poll. Cached locally (stale-while-error), so
348
368
  // this is instant when fresh and bounded (~1.5s) only when stale.
349
369
  if (!forceMonitor && !forceEnforce && shieldId) {
350
370
  try {
351
- const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey });
371
+ const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname() });
352
372
  if (bundle.source !== 'default') {
353
373
  shadow = bundle.mode !== 'block'; // 'monitor'/'shadow' => report-only
374
+ effectivePolicyHash = bundle.policyHash || bundle.version;
354
375
  dbg({ phase: 'mode_resolved', mode: bundle.mode, source: bundle.source, shadow });
355
376
  }
356
377
  }
@@ -416,6 +437,7 @@ async function hookCommand(args, config) {
416
437
  await enforceActionPolicy({
417
438
  event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
418
439
  shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond,
440
+ effectivePolicyHash,
419
441
  });
420
442
  return;
421
443
  }
@@ -428,13 +450,23 @@ async function hookCommand(args, config) {
428
450
  // Record the developer's prompt so taint-tracking can later tell whether a
429
451
  // sensitive action (network/git) was actually requested by the human.
430
452
  (0, taintLedger_1.recordUserPrompt)(sessionId(payload), text);
431
- const localBlock = (0, deterministicGuard_1.scanDeterministicPrompt)(text);
453
+ const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
454
+ apiUrl: apiUrl,
455
+ shieldId: shieldId,
456
+ shieldKey,
457
+ developerName: developerId(),
458
+ machineName: os.hostname(),
459
+ expectedPolicyHash: effectivePolicyHash,
460
+ });
461
+ const localBlock = (0, deterministicGuard_1.scanDeterministicPrompt)(text, (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
432
462
  if (localBlock) {
433
463
  dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
434
464
  if (shadow) {
465
+ spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
435
466
  respond(false, undefined, `[FullCourtDefense shadow] would block prompt: ${localBlock.reason}`);
436
467
  return;
437
468
  }
469
+ spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
438
470
  respond(true, `Blocked by FullCourtDefense local guard — ${localBlock.reason}`, `FullCourtDefense blocked this prompt locally (${localBlock.ruleId}). Do not retry.`);
439
471
  return;
440
472
  }
@@ -448,21 +480,32 @@ async function hookCommand(args, config) {
448
480
  respond(false);
449
481
  }
450
482
  async function enforceActionPolicy(ctx) {
451
- const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond } = ctx;
483
+ const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, effectivePolicyHash } = ctx;
452
484
  const call = buildToolCall(event, payload);
453
485
  if (!call) {
454
486
  respond(false);
455
487
  return;
456
488
  }
457
- const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(call.toolName, call.toolArgs);
489
+ const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
490
+ apiUrl,
491
+ shieldId,
492
+ shieldKey,
493
+ developerName: developerId(),
494
+ machineName: os.hostname(),
495
+ expectedPolicyHash: effectivePolicyHash,
496
+ });
497
+ const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(call.toolName, call.toolArgs, (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot, {
498
+ cwd: typeof call.toolArgs.cwd === 'string' ? call.toolArgs.cwd : process.cwd(),
499
+ inspectScripts: event === 'shell',
500
+ }));
458
501
  if (localBlock) {
459
502
  dbg({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
460
503
  if (shadow) {
504
+ spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
461
505
  respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${localBlock.reason}`);
462
506
  return;
463
507
  }
464
- (0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: localBlock.reason, ruleId: localBlock.ruleId, offlineEnforced: true });
465
- (0, telemetry_1.triggerFlush)(true);
508
+ spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
466
509
  respond(true, `Blocked by FullCourtDefense local guard — ${localBlock.reason}`, `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
467
510
  return;
468
511
  }
@@ -54,6 +54,7 @@ const discoverPaths_1 = require("./discoverPaths");
54
54
  const discover_1 = require("./discover");
55
55
  const deterministicGuard_1 = require("./deterministicGuard");
56
56
  const restartNotice_1 = require("./restartNotice");
57
+ const localSafetySnapshot_1 = require("../localSafetySnapshot");
57
58
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
58
59
  const MANAGED_SERVER_NAME = 'agentguard-gateway';
59
60
  const INSTALL_GATEWAY_ARGS = { includeShieldKey: false };
@@ -591,7 +592,14 @@ class McpGatewayServer {
591
592
  let approvalActionId;
592
593
  let operation = typeof params.operation === 'string' && params.operation.trim() ? params.operation.trim() : undefined;
593
594
  try {
594
- const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs);
595
+ const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
596
+ apiUrl: this.gatewayConfig.apiUrl,
597
+ shieldId: this.gatewayConfig.shieldId,
598
+ shieldKey: this.gatewayConfig.shieldKey,
599
+ developerName: this.gatewayConfig.developerName,
600
+ machineName: os.hostname(),
601
+ });
602
+ const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
595
603
  if (localBlock)
596
604
  throw new Error(`${localBlock.reason} (${localBlock.ruleId}: ${localBlock.evidence})`);
597
605
  const preflight = await this.api.checkToolCall({ toolName, operation, toolArgs });
@@ -610,7 +618,7 @@ class McpGatewayServer {
610
618
  }
611
619
  }
612
620
  const rawResult = await this.downstream.callTool(toolName, toolArgs);
613
- const localResponseBlock = (0, deterministicGuard_1.scanDeterministicTextResponse)(contentToText(rawResult));
621
+ const localResponseBlock = (0, deterministicGuard_1.scanDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
614
622
  if (localResponseBlock)
615
623
  throw new Error(`${localResponseBlock.reason} (${localResponseBlock.ruleId}: ${localResponseBlock.evidence})`);
616
624
  const finalResult = this.gatewayConfig.scanResponse
@@ -0,0 +1,18 @@
1
+ import { LocalSafetyCustomBlock, LocalSafetyScanOptions } from './commands/deterministicGuard';
2
+ export interface LocalSafetySnapshot {
3
+ policyHash: string;
4
+ disabledBuiltInItemIds: string[];
5
+ customBlocks: LocalSafetyCustomBlock[];
6
+ updatedAt?: string;
7
+ }
8
+ export interface LocalSafetySnapshotInput {
9
+ apiUrl: string;
10
+ shieldId: string;
11
+ shieldKey?: string;
12
+ developerName: string;
13
+ machineName: string;
14
+ expectedPolicyHash?: string;
15
+ timeoutMs?: number;
16
+ }
17
+ export declare function loadLocalSafetySnapshot(input: LocalSafetySnapshotInput): Promise<LocalSafetySnapshot | undefined>;
18
+ export declare function snapshotToScanOptions(snapshot: LocalSafetySnapshot | undefined, extra?: Pick<LocalSafetyScanOptions, 'cwd' | 'inspectScripts'>): LocalSafetyScanOptions;
@@ -0,0 +1,130 @@
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.loadLocalSafetySnapshot = loadLocalSafetySnapshot;
37
+ exports.snapshotToScanOptions = snapshotToScanOptions;
38
+ const crypto = __importStar(require("crypto"));
39
+ const fs = __importStar(require("fs"));
40
+ const os = __importStar(require("os"));
41
+ const path = __importStar(require("path"));
42
+ const CACHE_TTL_MS = 60_000;
43
+ const CACHE_DIR = path.join(os.homedir(), '.fullcourtdefense');
44
+ function cachePath(input) {
45
+ const key = crypto.createHash('sha1')
46
+ .update([input.apiUrl, input.shieldId, input.developerName, input.machineName].join('|'))
47
+ .digest('hex');
48
+ return path.join(CACHE_DIR, `local-safety-${key}.json`);
49
+ }
50
+ function readCache(file) {
51
+ try {
52
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ if (!parsed?.snapshot || typeof parsed.loadedAt !== 'number')
54
+ return undefined;
55
+ return parsed;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ function writeCache(file, snapshot) {
62
+ try {
63
+ fs.mkdirSync(path.dirname(file), { recursive: true });
64
+ fs.writeFileSync(file, JSON.stringify({ loadedAt: Date.now(), snapshot }, null, 2), { encoding: 'utf8', mode: 0o600 });
65
+ }
66
+ catch { /* best-effort */ }
67
+ }
68
+ function toSnapshot(data) {
69
+ if (!data || typeof data !== 'object')
70
+ return undefined;
71
+ return {
72
+ policyHash: typeof data.policyHash === 'string' ? data.policyHash : '',
73
+ disabledBuiltInItemIds: Array.isArray(data.disabledBuiltInItemIds)
74
+ ? data.disabledBuiltInItemIds.filter((item) => typeof item === 'string')
75
+ : [],
76
+ customBlocks: Array.isArray(data.customBlocks)
77
+ ? data.customBlocks
78
+ .filter((item) => item && typeof item.pattern === 'string')
79
+ .map((item) => ({
80
+ id: String(item.id || item.pattern),
81
+ categoryId: String(item.categoryId || 'custom'),
82
+ pattern: String(item.pattern),
83
+ explanation: typeof item.explanation === 'string' ? item.explanation : undefined,
84
+ }))
85
+ : [],
86
+ updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
87
+ };
88
+ }
89
+ async function loadLocalSafetySnapshot(input) {
90
+ const file = cachePath(input);
91
+ const cached = readCache(file);
92
+ const hashMatches = !input.expectedPolicyHash || cached?.snapshot.policyHash === input.expectedPolicyHash;
93
+ if (cached && hashMatches && Date.now() - cached.loadedAt < CACHE_TTL_MS)
94
+ return cached.snapshot;
95
+ const headers = { 'Content-Type': 'application/json' };
96
+ if (input.shieldKey)
97
+ headers['x-shield-key'] = input.shieldKey;
98
+ try {
99
+ const resp = await fetch(`${input.apiUrl}/api/agent-security/runtime/local-safety-snapshot`, {
100
+ method: 'POST',
101
+ headers,
102
+ body: JSON.stringify({
103
+ shieldId: input.shieldId,
104
+ developerName: input.developerName,
105
+ machineName: input.machineName,
106
+ }),
107
+ signal: AbortSignal.timeout(input.timeoutMs ?? 1200),
108
+ });
109
+ if (resp.ok) {
110
+ const body = await resp.json().catch(() => ({}));
111
+ if (body.success) {
112
+ const snapshot = toSnapshot(body.data);
113
+ if (snapshot) {
114
+ writeCache(file, snapshot);
115
+ return snapshot;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ catch { /* offline-safe fallback below */ }
121
+ return cached?.snapshot;
122
+ }
123
+ function snapshotToScanOptions(snapshot, extra = {}) {
124
+ return {
125
+ disabledBuiltInItemIds: snapshot?.disabledBuiltInItemIds,
126
+ customBlocks: snapshot?.customBlocks,
127
+ policyHash: snapshot?.policyHash,
128
+ ...extra,
129
+ };
130
+ }
@@ -17,6 +17,7 @@ export type ShieldMode = 'block' | 'monitor' | 'shadow';
17
17
  export interface RuntimeBundle {
18
18
  mode: ShieldMode;
19
19
  version: string;
20
+ policyHash?: string;
20
21
  policyCount?: number;
21
22
  pollIntervalMs?: number;
22
23
  }
@@ -24,6 +25,8 @@ export interface FetchBundleInput {
24
25
  apiUrl: string;
25
26
  shieldId: string;
26
27
  shieldKey?: string;
28
+ developerName?: string;
29
+ machineName?: string;
27
30
  /** Override cache TTL (ms). */
28
31
  ttlMs?: number;
29
32
  /** Force a network refresh regardless of cache freshness. */
@@ -70,7 +70,7 @@ async function getRuntimeBundle(input) {
70
70
  const cached = cache[input.shieldId];
71
71
  const fresh = cached && Date.now() - cached.fetchedAt < ttl;
72
72
  if (cached && fresh && !input.force) {
73
- return { mode: cached.mode, version: cached.version, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
73
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
74
74
  }
75
75
  try {
76
76
  const headers = { 'Content-Type': 'application/json' };
@@ -78,12 +78,17 @@ async function getRuntimeBundle(input) {
78
78
  headers['x-shield-key'] = input.shieldKey;
79
79
  if (cached?.version)
80
80
  headers['If-None-Match'] = cached.version;
81
- const resp = await fetch(`${input.apiUrl}/api/cli/bundle?shieldId=${encodeURIComponent(input.shieldId)}`, { method: 'GET', headers, signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) });
81
+ const params = new URLSearchParams({ shieldId: input.shieldId });
82
+ if (input.developerName)
83
+ params.set('developerName', input.developerName);
84
+ if (input.machineName)
85
+ params.set('machineName', input.machineName);
86
+ const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, { method: 'GET', headers, signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) });
82
87
  // 304 Not Modified — cache is still valid; refresh its timestamp.
83
88
  if (resp.status === 304 && cached) {
84
89
  cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
85
90
  writeCacheFile(cache);
86
- return { mode: cached.mode, version: cached.version, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
91
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
87
92
  }
88
93
  if (resp.ok) {
89
94
  const body = await resp.json().catch(() => ({}));
@@ -91,6 +96,7 @@ async function getRuntimeBundle(input) {
91
96
  const entry = {
92
97
  mode: body.data.mode,
93
98
  version: body.data.version,
99
+ policyHash: body.data.policyHash,
94
100
  policyCount: body.data.policyCount,
95
101
  pollIntervalMs: body.data.pollIntervalMs,
96
102
  fetchedAt: Date.now(),
@@ -103,7 +109,7 @@ async function getRuntimeBundle(input) {
103
109
  }
104
110
  catch { /* fall through to cache / default */ }
105
111
  if (cached) {
106
- return { mode: cached.mode, version: cached.version, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
112
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
107
113
  }
108
114
  return { mode: 'block', version: '', source: 'default' };
109
115
  }
@@ -6,6 +6,13 @@ export interface SpoolEvent {
6
6
  operation?: string;
7
7
  reason?: string;
8
8
  ruleId?: string;
9
+ category?: string;
10
+ categoryId?: string;
11
+ itemId?: string;
12
+ source?: 'builtin' | 'custom' | 'taint' | 'backend';
13
+ evidence?: string;
14
+ explanation?: string;
15
+ policyHash?: string;
9
16
  /** True when this decision was enforced locally while the backend was unreachable. */
10
17
  offlineEnforced?: boolean;
11
18
  occurredAt: string;
package/dist/telemetry.js CHANGED
@@ -66,6 +66,13 @@ function spoolEvent(event) {
66
66
  operation: event.operation,
67
67
  reason: event.reason,
68
68
  ruleId: event.ruleId,
69
+ category: event.category,
70
+ categoryId: event.categoryId,
71
+ itemId: event.itemId,
72
+ source: event.source,
73
+ evidence: event.evidence,
74
+ explanation: event.explanation,
75
+ policyHash: event.policyHash,
69
76
  offlineEnforced: event.offlineEnforced,
70
77
  };
71
78
  fs.appendFileSync(SPOOL_PATH, JSON.stringify(full) + '\n', { encoding: 'utf-8', mode: 0o600 });
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.7.22"
2
+ "version": "1.7.23"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.7.22",
3
+ "version": "1.7.23",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {