fullcourtdefense-cli 1.7.21 → 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.
- package/dist/commands/agentCi.d.ts +2 -0
- package/dist/commands/agentCi.js +76 -4
- package/dist/commands/deterministicGuard.d.ts +23 -4
- package/dist/commands/deterministicGuard.js +285 -184
- package/dist/commands/hook.js +49 -6
- package/dist/commands/mcpGateway.js +10 -2
- package/dist/index.js +4 -0
- package/dist/localSafetySnapshot.d.ts +18 -0
- package/dist/localSafetySnapshot.js +130 -0
- package/dist/runtimeConfig.d.ts +3 -0
- package/dist/runtimeConfig.js +10 -4
- package/dist/telemetry.d.ts +7 -0
- package/dist/telemetry.js +7 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/agentCi.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.agentCiCommand = agentCiCommand;
|
|
37
37
|
const crypto = __importStar(require("crypto"));
|
|
38
|
+
const child_process_1 = require("child_process");
|
|
38
39
|
const fs = __importStar(require("fs"));
|
|
39
40
|
const path = __importStar(require("path"));
|
|
40
41
|
const config_1 = require("../config");
|
|
@@ -159,11 +160,82 @@ function parseRequiredControls(value) {
|
|
|
159
160
|
function finding(input) {
|
|
160
161
|
return { ...input, id: stableId([input.category, input.title, input.filePath || '', input.detail]) };
|
|
161
162
|
}
|
|
163
|
+
function normalizeRelativePath(cwd, filePath) {
|
|
164
|
+
if (!filePath)
|
|
165
|
+
return '';
|
|
166
|
+
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
167
|
+
return path.relative(cwd, absolute).replace(/\\/g, '/').toLowerCase();
|
|
168
|
+
}
|
|
169
|
+
function parseChangedFilesInput(raw) {
|
|
170
|
+
return (raw || '')
|
|
171
|
+
.split(/\r?\n|,/)
|
|
172
|
+
.map(item => item.trim())
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
}
|
|
175
|
+
function resolveChangedFiles(cwd, args) {
|
|
176
|
+
if (args.changedOnly !== 'true')
|
|
177
|
+
return null;
|
|
178
|
+
let files = [];
|
|
179
|
+
if (args.changedFiles) {
|
|
180
|
+
const changedFilePath = path.resolve(cwd, args.changedFiles);
|
|
181
|
+
if (fs.existsSync(changedFilePath)) {
|
|
182
|
+
files = parseChangedFilesInput(fs.readFileSync(changedFilePath, 'utf8'));
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
files = parseChangedFilesInput(args.changedFiles);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (files.length === 0)
|
|
189
|
+
files = parseChangedFilesInput(process.env.AGENTGUARD_CHANGED_FILES || process.env.FCD_CHANGED_FILES);
|
|
190
|
+
if (files.length === 0 && process.env.GITHUB_BASE_REF) {
|
|
191
|
+
try {
|
|
192
|
+
files = (0, child_process_1.execFileSync)('git', ['diff', '--name-only', `origin/${process.env.GITHUB_BASE_REF}...HEAD`], { cwd, encoding: 'utf8' })
|
|
193
|
+
.split(/\r?\n/)
|
|
194
|
+
.filter(Boolean);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// The workflow should pass --changed-files; full scan is safer if diff context is unavailable.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (files.length === 0)
|
|
201
|
+
return null;
|
|
202
|
+
return new Set(files.map(file => normalizeRelativePath(cwd, file)));
|
|
203
|
+
}
|
|
204
|
+
function isChangedFile(cwd, changedFiles, filePath) {
|
|
205
|
+
if (!changedFiles)
|
|
206
|
+
return true;
|
|
207
|
+
return changedFiles.has(normalizeRelativePath(cwd, filePath));
|
|
208
|
+
}
|
|
209
|
+
function agentFileRemediation(tags) {
|
|
210
|
+
const fixes = [];
|
|
211
|
+
if (tags.includes('allows_shell'))
|
|
212
|
+
fixes.push('remove blanket shell/terminal permission or require an explicit command allowlist plus human approval');
|
|
213
|
+
if (tags.includes('broad_filesystem'))
|
|
214
|
+
fixes.push('limit file access to approved project paths and deny secrets/home/system paths');
|
|
215
|
+
if (tags.includes('destructive_actions'))
|
|
216
|
+
fixes.push('require an AgentGuard Action Policy for delete, payment, transfer, email, Slack, or other side-effecting actions');
|
|
217
|
+
if (tags.includes('weak_guardrails'))
|
|
218
|
+
fixes.push('delete bypass language such as always allow, never block, disable security, or skip validation');
|
|
219
|
+
if (tags.includes('embedded_secret_ref'))
|
|
220
|
+
fixes.push('remove inline secret/password/token references and use a managed secret store');
|
|
221
|
+
if (tags.includes('jailbreak_hint'))
|
|
222
|
+
fixes.push('remove jailbreak/bypass instructions and add hierarchy language that system and security policies win');
|
|
223
|
+
if (tags.includes('no_runtime_hook'))
|
|
224
|
+
fixes.push('install the Full Court Defense hook/gateway so risky tool calls are checked before execution');
|
|
225
|
+
if (fixes.length === 0)
|
|
226
|
+
return 'Review the instruction/rule and add explicit least-privilege boundaries.';
|
|
227
|
+
return `Fix this file before merge: ${fixes.join('; ')}.`;
|
|
228
|
+
}
|
|
162
229
|
function evaluateAgentGate(args) {
|
|
163
230
|
const cwd = process.cwd();
|
|
164
|
-
const
|
|
165
|
-
const
|
|
231
|
+
const changedFiles = resolveChangedFiles(cwd, args);
|
|
232
|
+
const mcpServers = discoverMcpServers(cwd, args.extraPath)
|
|
233
|
+
.filter(server => isChangedFile(cwd, changedFiles, server.filePath));
|
|
234
|
+
const agentFiles = (0, discoverAgentFiles_1.scanAgentFiles)({ cwd })
|
|
235
|
+
.filter(file => isChangedFile(cwd, changedFiles, file.filePath));
|
|
166
236
|
const secrets = (0, discoverSecrets_1.scanSecrets)({ cwd, maxEnvDepth: 3 });
|
|
237
|
+
const secretFindings = secrets.findings
|
|
238
|
+
.filter(item => isChangedFile(cwd, changedFiles, item.filePath));
|
|
167
239
|
const requireGateway = args.requireGateway !== 'false';
|
|
168
240
|
const requiredControls = parseRequiredControls(args.requireApprovalFor);
|
|
169
241
|
const findings = [];
|
|
@@ -197,10 +269,10 @@ function evaluateAgentGate(args) {
|
|
|
197
269
|
title: `Risky agent instruction: ${file.title}`,
|
|
198
270
|
detail: `${file.filePath} contains agent risk tags: ${file.riskTags.join(', ')}.`,
|
|
199
271
|
filePath: file.filePath,
|
|
200
|
-
remediation:
|
|
272
|
+
remediation: agentFileRemediation(file.riskTags),
|
|
201
273
|
}));
|
|
202
274
|
}
|
|
203
|
-
for (const secret of
|
|
275
|
+
for (const secret of secretFindings.filter(item => item.severity === 'critical' || item.severity === 'high')) {
|
|
204
276
|
findings.push(finding({
|
|
205
277
|
severity: secret.severity === 'critical' ? 'critical' : 'high',
|
|
206
278
|
category: 'secret',
|
|
@@ -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:
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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+
|
|
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
|
|
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
|
|
224
|
-
if (
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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;
|
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
(
|
|
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
|
|
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
|
package/dist/index.js
CHANGED
|
@@ -265,6 +265,8 @@ function printHelp() {
|
|
|
265
265
|
--require-gateway agent-ci requires risky MCP servers to use FCD gateway
|
|
266
266
|
--require-approval-for agent-ci risky controls list, e.g. payment,delete,shell,secrets
|
|
267
267
|
--require-upload agent-ci fails if evidence cannot upload to AgentGuard UI
|
|
268
|
+
--changed-only agent-ci limits findings to changed files
|
|
269
|
+
--changed-files <path> file or comma/newline list of changed files for agent-ci
|
|
268
270
|
--method <GET|POST> Local endpoint method
|
|
269
271
|
--request-format <fmt> Local endpoint request format: custom or openai
|
|
270
272
|
--auth-type <type> none, bearer, basic, api-key
|
|
@@ -506,6 +508,8 @@ async function main() {
|
|
|
506
508
|
requireUpload: flags['require-upload'],
|
|
507
509
|
requireGateway: flags['require-gateway'],
|
|
508
510
|
requireApprovalFor: flags['require-approval-for'],
|
|
511
|
+
changedOnly: flags['changed-only'],
|
|
512
|
+
changedFiles: flags['changed-files'],
|
|
509
513
|
extraPath: flags.path,
|
|
510
514
|
apiKey: flags['api-key'],
|
|
511
515
|
apiUrl: flags['api-url'],
|
|
@@ -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
|
+
}
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -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. */
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -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