fullcourtdefense-cli 1.29.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actionPolicyEngine.d.ts +22 -0
- package/dist/actionPolicyEngine.js +446 -22
- package/dist/blockExplanation.d.ts +95 -0
- package/dist/blockExplanation.js +240 -0
- package/dist/commands/cmdGuard.js +6 -0
- package/dist/commands/hook.js +116 -21
- package/dist/commands/mcpGateway.js +53 -14
- package/dist/commands/posixShellGuard.d.ts +12 -0
- package/dist/commands/posixShellGuard.js +8 -0
- package/dist/commands/shellGuard.d.ts +2 -0
- package/dist/commands/shellGuard.js +34 -6
- package/dist/notify.d.ts +2 -0
- package/dist/notify.js +5 -0
- package/dist/version.json +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE developer-facing explanation for a FullCourtDefense stop.
|
|
3
|
+
*
|
|
4
|
+
* Every enforcement surface (IDE hooks, MCP gateway, terminal guards) MUST go
|
|
5
|
+
* through here when it blocks, masks, or pauses an action. A developer who is
|
|
6
|
+
* stopped has to learn, in one glance and without opening anything:
|
|
7
|
+
*
|
|
8
|
+
* 1. WHAT was stopped — the exact command / path / tool call
|
|
9
|
+
* 2. WHICH RULE, BY NAME — the policy or Local Safety rule, and where it came from
|
|
10
|
+
* 3. WHY it matched — the deterministic fact that fired (operation, secret kind, host…)
|
|
11
|
+
* 4. HOW TO REMOVE THE BLOCK — the exact console page, and what to click
|
|
12
|
+
*
|
|
13
|
+
* Fixed templates only: nothing here is generated text, and raw argument
|
|
14
|
+
* values never leak beyond the short "what" line. Pure — no I/O, so it is unit
|
|
15
|
+
* tested byte-for-byte by `scripts/test-block-explanation.js`.
|
|
16
|
+
*/
|
|
17
|
+
export type ExplanationOutcome = 'block' | 'ask' | 'approval' | 'mask';
|
|
18
|
+
export interface PolicyExplanationSource {
|
|
19
|
+
kind: 'policy';
|
|
20
|
+
policyName?: string;
|
|
21
|
+
policyId?: string;
|
|
22
|
+
/** Engine `matchedRule`, e.g. `DELETE → block (toolArgs.containsSecret equals true)`. */
|
|
23
|
+
matchedRule?: string;
|
|
24
|
+
/** Policy scope: `organization` (company default) or a machine/developer-scoped exception. */
|
|
25
|
+
scope?: string;
|
|
26
|
+
/** Engine facts that explain the match. Only well-known keys are rendered. */
|
|
27
|
+
facts?: Record<string, string | undefined>;
|
|
28
|
+
operation?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Verdict reason from the engine or the policy service. Rendered only when it
|
|
31
|
+
* says more than the rule line already does (the engine's own
|
|
32
|
+
* `Action policy "<name>": … blocked` boilerplate is dropped; a server-side
|
|
33
|
+
* or admin-written reason such as "Destructive business operation blocked" is kept).
|
|
34
|
+
*/
|
|
35
|
+
reason?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface LocalSafetyExplanationSource {
|
|
38
|
+
kind: 'local_safety';
|
|
39
|
+
itemId: string;
|
|
40
|
+
/** Console label when known (e.g. "GitHub CLI token print"); falls back to the id. */
|
|
41
|
+
label?: string;
|
|
42
|
+
category?: string;
|
|
43
|
+
source: 'builtin' | 'custom';
|
|
44
|
+
explanation?: string;
|
|
45
|
+
/** Machine-stated reason from the guard (`reason` on the finding). */
|
|
46
|
+
reason: string;
|
|
47
|
+
}
|
|
48
|
+
export interface GuardExplanationSource {
|
|
49
|
+
kind: 'taint' | 'self_protection' | 'fail_closed' | 'suspended' | 'offline';
|
|
50
|
+
ruleId?: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
}
|
|
53
|
+
export type ExplanationSource = PolicyExplanationSource | LocalSafetyExplanationSource | GuardExplanationSource;
|
|
54
|
+
export interface BlockExplanationInput {
|
|
55
|
+
outcome: ExplanationOutcome;
|
|
56
|
+
/** Hook event / surface: shell, file, mcp, prompt, terminal, gateway. */
|
|
57
|
+
event: string;
|
|
58
|
+
toolName: string;
|
|
59
|
+
toolArgs?: Record<string, unknown>;
|
|
60
|
+
source: ExplanationSource;
|
|
61
|
+
/** Console base URL (no trailing slash). */
|
|
62
|
+
consoleUrl: string;
|
|
63
|
+
/** Fleet machine id — used to deep-link the machine page. */
|
|
64
|
+
machineId?: string;
|
|
65
|
+
/** Developer display name, when the block is machine-scoped. */
|
|
66
|
+
developerName?: string;
|
|
67
|
+
/** Extra sentence appended to the "why" (e.g. no human present on CI). */
|
|
68
|
+
note?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface BlockExplanation {
|
|
71
|
+
/** One line, first thing the developer reads. */
|
|
72
|
+
headline: string;
|
|
73
|
+
/** The stopped action, shortened, never more than ~160 chars. */
|
|
74
|
+
what: string;
|
|
75
|
+
/** Rule name + origin, exactly as it appears in the console. */
|
|
76
|
+
rule: string;
|
|
77
|
+
/** Deterministic reason the rule matched. */
|
|
78
|
+
why: string;
|
|
79
|
+
/** Numbered, concrete steps to lift the block. */
|
|
80
|
+
unblock: string[];
|
|
81
|
+
/** Deep link to the console entry that controls this rule. */
|
|
82
|
+
consoleLink: string;
|
|
83
|
+
/** Full multi-line text shown to the developer (IDE toast / terminal stderr). */
|
|
84
|
+
userMessage: string;
|
|
85
|
+
/** Single paragraph for the agent: stop, do not retry, relay the message. */
|
|
86
|
+
agentMessage: string;
|
|
87
|
+
}
|
|
88
|
+
/** The stopped action in the developer's own terms — command, path, or tool call. */
|
|
89
|
+
export declare function summarizeAction(event: string, toolName: string, args?: Record<string, unknown>): string;
|
|
90
|
+
export declare function buildBlockExplanation(input: BlockExplanationInput): BlockExplanation;
|
|
91
|
+
/**
|
|
92
|
+
* Compact single-line form for surfaces with no room for a block (terminal
|
|
93
|
+
* `[FullCourtDefense]` line, Claude `permissionDecisionReason`).
|
|
94
|
+
*/
|
|
95
|
+
export declare function oneLineExplanation(explanation: BlockExplanation): string;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The ONE developer-facing explanation for a FullCourtDefense stop.
|
|
4
|
+
*
|
|
5
|
+
* Every enforcement surface (IDE hooks, MCP gateway, terminal guards) MUST go
|
|
6
|
+
* through here when it blocks, masks, or pauses an action. A developer who is
|
|
7
|
+
* stopped has to learn, in one glance and without opening anything:
|
|
8
|
+
*
|
|
9
|
+
* 1. WHAT was stopped — the exact command / path / tool call
|
|
10
|
+
* 2. WHICH RULE, BY NAME — the policy or Local Safety rule, and where it came from
|
|
11
|
+
* 3. WHY it matched — the deterministic fact that fired (operation, secret kind, host…)
|
|
12
|
+
* 4. HOW TO REMOVE THE BLOCK — the exact console page, and what to click
|
|
13
|
+
*
|
|
14
|
+
* Fixed templates only: nothing here is generated text, and raw argument
|
|
15
|
+
* values never leak beyond the short "what" line. Pure — no I/O, so it is unit
|
|
16
|
+
* tested byte-for-byte by `scripts/test-block-explanation.js`.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.summarizeAction = summarizeAction;
|
|
20
|
+
exports.buildBlockExplanation = buildBlockExplanation;
|
|
21
|
+
exports.oneLineExplanation = oneLineExplanation;
|
|
22
|
+
const MAX_WHAT = 160;
|
|
23
|
+
function str(v) {
|
|
24
|
+
return typeof v === 'string' && v.trim() ? v : undefined;
|
|
25
|
+
}
|
|
26
|
+
function shorten(text, max = MAX_WHAT) {
|
|
27
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
28
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
29
|
+
}
|
|
30
|
+
/** The stopped action in the developer's own terms — command, path, or tool call. */
|
|
31
|
+
function summarizeAction(event, toolName, args = {}) {
|
|
32
|
+
const command = str(args.command) || str(args.cmd) || str(args.commandLine) || str(args.script);
|
|
33
|
+
if (command)
|
|
34
|
+
return `${shorten(command)} (${event === 'terminal' ? 'terminal command' : 'shell command'})`;
|
|
35
|
+
const path = str(args.path) || str(args.file_path) || str(args.filePath) || str(args.target_file) || str(args.notebook_path);
|
|
36
|
+
if (path) {
|
|
37
|
+
const verb = /read|open|view|cat|list|search|grep/i.test(toolName) ? 'read' : 'edit';
|
|
38
|
+
return `${shorten(path)} (file ${verb})`;
|
|
39
|
+
}
|
|
40
|
+
const url = str(args.url) || str(args.uri) || str(args.endpoint);
|
|
41
|
+
if (url)
|
|
42
|
+
return `${toolName} → ${shorten(url, 120)} (network call)`;
|
|
43
|
+
if (event === 'prompt')
|
|
44
|
+
return 'the prompt you submitted';
|
|
45
|
+
const server = str(args.mcp_server_name) || str(args.server_name) || str(args.serverName);
|
|
46
|
+
return `${server ? `${server} / ` : ''}${toolName} (${event === 'mcp' ? 'MCP tool call' : `${event} action`})`;
|
|
47
|
+
}
|
|
48
|
+
/** Headline per outcome — `${noun}` is the action kind ("shell command", "MCP tool call"…). */
|
|
49
|
+
function headlineFor(outcome, noun) {
|
|
50
|
+
switch (outcome) {
|
|
51
|
+
case 'ask': return `FullCourtDefense paused this ${noun} — your confirmation is needed.`;
|
|
52
|
+
case 'approval': return `FullCourtDefense paused this ${noun} — Approval required from your security team.`;
|
|
53
|
+
case 'mask': return `FullCourtDefense masked secrets in this ${noun}.`;
|
|
54
|
+
default: return `FullCourtDefense blocked this ${noun}.`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The step list title — a pause is lifted by an answer, a block by a rule change. */
|
|
58
|
+
function stepsTitle(outcome) {
|
|
59
|
+
return outcome === 'block' ? 'To remove this block:' : outcome === 'mask' ? 'To change this rule:' : 'To remove this block (or to stop being asked):';
|
|
60
|
+
}
|
|
61
|
+
function eventNoun(event) {
|
|
62
|
+
switch (event) {
|
|
63
|
+
case 'shell': return 'shell command';
|
|
64
|
+
case 'terminal': return 'terminal command';
|
|
65
|
+
case 'file': return 'file operation';
|
|
66
|
+
case 'read': return 'file read';
|
|
67
|
+
case 'mcp': return 'MCP tool call';
|
|
68
|
+
case 'prompt': return 'prompt';
|
|
69
|
+
case 'gateway': return 'tool call';
|
|
70
|
+
default: return 'action';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Company default vs local exception — the same words the console uses. */
|
|
74
|
+
function scopeWords(scope, developerName) {
|
|
75
|
+
if (!scope || scope === 'organization' || scope === 'org')
|
|
76
|
+
return '🌐 company default — applies to every machine';
|
|
77
|
+
if (scope === 'machine' || scope === 'developer' || scope === 'local')
|
|
78
|
+
return `📍 local rule — only ${developerName || 'this machine'}`;
|
|
79
|
+
return `${scope} policy`;
|
|
80
|
+
}
|
|
81
|
+
const FACT_WORDS = [
|
|
82
|
+
['operation', v => v && !/^(shell|execute|unknown)$/i.test(v) ? `the action was classified as ${v.toUpperCase()}` : undefined],
|
|
83
|
+
['toolArgs.containsSecret', (v, f) => v === 'true'
|
|
84
|
+
? `a secret-shaped value was found in the arguments${f['toolArgs.secretKind'] && f['toolArgs.secretKind'] !== 'none' ? ` (${f['toolArgs.secretKind'].replace(/_/g, ' ')})` : ''}`
|
|
85
|
+
: undefined],
|
|
86
|
+
['toolArgs.containsPii', v => v === 'true' ? 'personal data (PII) was found in the arguments' : undefined],
|
|
87
|
+
['destination.type', (v, f) => v === 'external' ? `the destination ${f['destination.domain'] ? `"${f['destination.domain']}" ` : ''}is outside your organisation` : undefined],
|
|
88
|
+
['destination.domain', (v, f) => f['destination.type'] === 'external' ? undefined : `destination host: ${v}`],
|
|
89
|
+
['url.risk', v => v && v !== 'none' && v !== 'low' ? `the URL was rated ${v} risk` : undefined],
|
|
90
|
+
['path.sensitive', v => v === 'true' ? 'the path is a sensitive location' : undefined],
|
|
91
|
+
];
|
|
92
|
+
/** Which facts the rule's constraints actually referenced — only those are shown. */
|
|
93
|
+
function relevantFacts(matchedRule, facts, operation) {
|
|
94
|
+
const out = [];
|
|
95
|
+
const constraintText = (matchedRule || '').toLowerCase();
|
|
96
|
+
for (const [key, words] of FACT_WORDS) {
|
|
97
|
+
const value = key === 'operation' ? (facts.operation || operation) : facts[key];
|
|
98
|
+
if (!value)
|
|
99
|
+
continue;
|
|
100
|
+
// Operation is always the reason a per-operation rule fired; other facts
|
|
101
|
+
// are shown only when the constraint text mentions them.
|
|
102
|
+
if (key !== 'operation' && !constraintText.includes(key.toLowerCase()))
|
|
103
|
+
continue;
|
|
104
|
+
const line = words(value, facts);
|
|
105
|
+
if (line)
|
|
106
|
+
out.push(line);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** A verdict reason worth showing: not empty, not the engine's own restatement of the rule line. */
|
|
111
|
+
function informativeReason(reason, policyName) {
|
|
112
|
+
const text = (reason || '').replace(/\s+/g, ' ').trim();
|
|
113
|
+
if (!text)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (/^Action policy "/i.test(text))
|
|
116
|
+
return undefined; // engine boilerplate: `Action policy "<name>": DELETE blocked`
|
|
117
|
+
if (text.toLowerCase() === policyName.toLowerCase())
|
|
118
|
+
return undefined;
|
|
119
|
+
return shorten(text.replace(/[.\s]+$/, ''), 200);
|
|
120
|
+
}
|
|
121
|
+
function policiesLink(consoleUrl, policyId) {
|
|
122
|
+
return `${consoleUrl}/agent-security/users?view=policies&rules=mcp${policyId ? `&policy=${encodeURIComponent(policyId)}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
function shellRulesLink(consoleUrl, itemId) {
|
|
125
|
+
return `${consoleUrl}/agent-security/users?view=policies&rules=shell${itemId ? `&rule=${encodeURIComponent(itemId)}` : ''}`;
|
|
126
|
+
}
|
|
127
|
+
function machineLink(consoleUrl, machineId) {
|
|
128
|
+
return machineId ? `${consoleUrl}/agent-security/machines/${encodeURIComponent(machineId)}` : undefined;
|
|
129
|
+
}
|
|
130
|
+
function buildBlockExplanation(input) {
|
|
131
|
+
const noun = eventNoun(input.event);
|
|
132
|
+
const what = summarizeAction(input.event, input.toolName, input.toolArgs || {});
|
|
133
|
+
const consoleUrl = input.consoleUrl.replace(/\/$/, '');
|
|
134
|
+
const machine = machineLink(consoleUrl, input.machineId);
|
|
135
|
+
let rule;
|
|
136
|
+
let why;
|
|
137
|
+
let unblock;
|
|
138
|
+
let consoleLink;
|
|
139
|
+
const src = input.source;
|
|
140
|
+
if (src.kind === 'policy') {
|
|
141
|
+
const name = src.policyName || 'an Action Policy';
|
|
142
|
+
consoleLink = policiesLink(consoleUrl, src.policyId);
|
|
143
|
+
rule = `Action Policy "${name}" (${scopeWords(src.scope, input.developerName)})${src.matchedRule ? ` — rule: ${src.matchedRule}` : ''}`;
|
|
144
|
+
const facts = relevantFacts(src.matchedRule, src.facts || {}, src.operation);
|
|
145
|
+
const extraReason = informativeReason(src.reason, name);
|
|
146
|
+
if (extraReason)
|
|
147
|
+
facts.push(extraReason);
|
|
148
|
+
why = facts.length ? facts.join('; ') : `the ${noun} matched this rule`;
|
|
149
|
+
unblock = [
|
|
150
|
+
`Open the rule in the console: ${consoleLink}`,
|
|
151
|
+
src.scope === 'organization' || !src.scope
|
|
152
|
+
? `An admin can switch "${name}" to Ask the developer / Warn only, add an exception for ${input.developerName || 'this machine'} (Override), or Disable it.`
|
|
153
|
+
: `An admin can change or remove the local rule "${name}" for ${input.developerName || 'this machine'}.`,
|
|
154
|
+
...(machine ? [`See this decision, with the rule name, on your machine timeline: ${machine}`] : []),
|
|
155
|
+
'Wrong block? Tell your admin the rule name above — every decision is recorded, nothing was sent anywhere.',
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
else if (src.kind === 'local_safety') {
|
|
159
|
+
const label = src.label || src.itemId;
|
|
160
|
+
consoleLink = shellRulesLink(consoleUrl, src.itemId);
|
|
161
|
+
// Same words as the console: a built-in catalog rule vs a rule an admin wrote.
|
|
162
|
+
const detail = [label !== src.itemId ? `id ${src.itemId}` : '', src.category || ''].filter(Boolean).join(', ');
|
|
163
|
+
rule = src.source === 'custom'
|
|
164
|
+
? `org custom rule "${label}" in Local Safety${detail ? ` (${detail})` : ''}`
|
|
165
|
+
: `built-in Local Safety rule "${label}"${detail ? ` (${detail})` : ''}`;
|
|
166
|
+
why = src.explanation ? `${src.reason} — ${src.explanation}` : src.reason;
|
|
167
|
+
unblock = [
|
|
168
|
+
`Open the rule in the console: ${consoleLink}`,
|
|
169
|
+
`An admin can set "${label}" to Ask the developer, Warn only, or Off — for everyone or only for ${input.developerName || 'this machine'}.`,
|
|
170
|
+
...(machine ? [`See this decision, with the rule name, on your machine timeline: ${machine}`] : []),
|
|
171
|
+
'Wrong block? Tell your admin the rule name above — every decision is recorded, nothing was sent anywhere.',
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
consoleLink = machine || `${consoleUrl}/agent-security/users?view=machines`;
|
|
176
|
+
switch (src.kind) {
|
|
177
|
+
case 'taint':
|
|
178
|
+
rule = `FullCourtDefense taint guard${src.ruleId ? ` (${src.ruleId})` : ''}`;
|
|
179
|
+
why = src.reason;
|
|
180
|
+
unblock = [
|
|
181
|
+
'The agent tried to act on content it received from an untrusted source (tool output, web page, file).',
|
|
182
|
+
'If you want this action, run it yourself from your own terminal — the guard only stops the agent, never you.',
|
|
183
|
+
`Machine timeline: ${consoleLink}`,
|
|
184
|
+
];
|
|
185
|
+
break;
|
|
186
|
+
case 'self_protection':
|
|
187
|
+
rule = `FullCourtDefense self-protection${src.ruleId ? ` (${src.ruleId})` : ''}`;
|
|
188
|
+
why = src.reason;
|
|
189
|
+
unblock = [
|
|
190
|
+
'Agents may not approve their own actions or edit FullCourtDefense configuration.',
|
|
191
|
+
'Do it yourself from your own terminal (fullcourtdefense approve / fullcourtdefense doctor).',
|
|
192
|
+
`Machine timeline: ${consoleLink}`,
|
|
193
|
+
];
|
|
194
|
+
break;
|
|
195
|
+
case 'fail_closed':
|
|
196
|
+
rule = 'Fail-closed mode (machine setting)';
|
|
197
|
+
why = src.reason;
|
|
198
|
+
unblock = [
|
|
199
|
+
'Restore the connection to the policy service, or run: fullcourtdefense doctor',
|
|
200
|
+
`An admin can switch this machine to fail-open under Machines: ${consoleLink}`,
|
|
201
|
+
];
|
|
202
|
+
break;
|
|
203
|
+
case 'suspended':
|
|
204
|
+
rule = 'Machine suspended by your security team';
|
|
205
|
+
why = src.reason;
|
|
206
|
+
unblock = [`Ask your admin to resume this machine: ${consoleLink}`];
|
|
207
|
+
break;
|
|
208
|
+
default:
|
|
209
|
+
rule = 'Cached org policy (policy service unreachable)';
|
|
210
|
+
why = src.reason;
|
|
211
|
+
unblock = [
|
|
212
|
+
'Restore the connection to the policy service, or run: fullcourtdefense doctor',
|
|
213
|
+
`Machine page: ${consoleLink}`,
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (input.note)
|
|
218
|
+
why = `${why} ${input.note}`.trim();
|
|
219
|
+
const headline = headlineFor(input.outcome, noun);
|
|
220
|
+
const userMessage = [
|
|
221
|
+
headline,
|
|
222
|
+
`What: ${what}`,
|
|
223
|
+
`Rule: ${rule}`,
|
|
224
|
+
`Why: ${why}`,
|
|
225
|
+
stepsTitle(input.outcome),
|
|
226
|
+
...unblock.map((step, i) => ` ${i + 1}. ${step}`),
|
|
227
|
+
].join('\n');
|
|
228
|
+
const agentVerb = input.outcome === 'block' || input.outcome === 'mask'
|
|
229
|
+
? 'This is a policy decision, not an error — do not retry, rephrase, or work around it.'
|
|
230
|
+
: 'Wait for the developer\'s answer — do not retry or work around it.';
|
|
231
|
+
const agentMessage = `${headline} Rule: ${rule}. Why: ${why}. ${agentVerb} Tell the developer exactly this: the block came from ${rule}; to remove it: ${unblock[0]}`;
|
|
232
|
+
return { headline, what, rule, why, unblock, consoleLink, userMessage, agentMessage };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Compact single-line form for surfaces with no room for a block (terminal
|
|
236
|
+
* `[FullCourtDefense]` line, Claude `permissionDecisionReason`).
|
|
237
|
+
*/
|
|
238
|
+
function oneLineExplanation(explanation) {
|
|
239
|
+
return `${explanation.headline} ${explanation.rule}. Why: ${explanation.why}. Remove it: ${explanation.consoleLink}`;
|
|
240
|
+
}
|
|
@@ -46,6 +46,7 @@ const fs = __importStar(require("fs"));
|
|
|
46
46
|
const os = __importStar(require("os"));
|
|
47
47
|
const path = __importStar(require("path"));
|
|
48
48
|
const shellGuard_1 = require("./shellGuard");
|
|
49
|
+
const notify_1 = require("../notify");
|
|
49
50
|
/**
|
|
50
51
|
* Interactive cmd.exe guard — blocks dangerous commands TYPED in Command Prompt.
|
|
51
52
|
*
|
|
@@ -147,6 +148,9 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
147
148
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
148
149
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
149
150
|
`const ASK_TIMEOUT_S=${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS};const ASK_REMEMBER_MS=${shellGuard_1.SHELL_ASK_REMEMBER_HOURS}*3600*1000;`,
|
|
151
|
+
`const RULES_URL=${JSON.stringify((0, notify_1.shellRulesConsoleUrl)())};`,
|
|
152
|
+
`// Which rule (by id + origin) and where an admin changes it — printed on every block/ask so the developer is never left guessing.`,
|
|
153
|
+
`function unblockHint(rule){return 'Rule: "'+rule.reason+'" ('+(rule.source==='custom'?'org custom rule':'built-in Local Safety rule')+', id '+rule.id+'). To remove this block an admin can set it to Ask / Warn only / Off here: '+RULES_URL+'&rule='+encodeURIComponent(rule.id);}`,
|
|
150
154
|
`// "s = remember for this cmd window": keyed by the parent cmd.exe pid (doskey runs us directly under it); dies with the window, capped in time.`,
|
|
151
155
|
`const ASK_MEMORY_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense'))}+'/cmd-ask-'+process.ppid+'.json';`,
|
|
152
156
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
@@ -177,12 +181,14 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
177
181
|
`if(hit.action==='ask'){`,
|
|
178
182
|
` if(askRemembered(hit.id)){console.error('[FullCourtDefense] allowed — you confirmed rule '+hit.id+' earlier in this cmd window.');spoolEvent(hit,line,'allow','remembered');delegate(args);}`,
|
|
179
183
|
` console.error('[FullCourtDefense] ASK ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
184
|
+
` console.error(unblockHint(hit));`,
|
|
180
185
|
` const a=askDeveloper(hit);`,
|
|
181
186
|
` if(a==='y'||a==='s'){if(a==='s')rememberAsk(hit.id);console.error('[FullCourtDefense] yes'+(a==='s'?' — remembered for this cmd window.':'')+' Reported to your security dashboard as allowed by you.');spoolEvent(hit,line,'allow','confirmed');delegate(args);}`,
|
|
182
187
|
` const outcome=a==='t'?'timeout':'declined';console.error('[FullCourtDefense] '+(outcome==='timeout'?'no answer':'no')+' — this command was not executed ('+outcome+'). Reported to your security dashboard.');spoolEvent(hit,line,'block',outcome);process.exit(1);`,
|
|
183
188
|
`}`,
|
|
184
189
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
185
190
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
191
|
+
`console.error(unblockHint(hit));`,
|
|
186
192
|
`spoolEvent(hit,line,'block');process.exit(1);`,
|
|
187
193
|
``,
|
|
188
194
|
].join('\n');
|
package/dist/commands/hook.js
CHANGED
|
@@ -48,6 +48,7 @@ const deterministicGuard_1 = require("./deterministicGuard");
|
|
|
48
48
|
const localSafetySnapshot_1 = require("../localSafetySnapshot");
|
|
49
49
|
const taintLedger_1 = require("./taintLedger");
|
|
50
50
|
const notify_1 = require("../notify");
|
|
51
|
+
const blockExplanation_1 = require("../blockExplanation");
|
|
51
52
|
const distress_1 = require("../distress");
|
|
52
53
|
const describeError_1 = require("../describeError");
|
|
53
54
|
const policyGateHealth_1 = require("../policyGateHealth");
|
|
@@ -486,7 +487,9 @@ function respondDegraded(ctx, detail, toolName, kind = 'unreachable', opts = {})
|
|
|
486
487
|
(0, distress_1.reportDistress)('hook', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : failure === 'rejected' ? distress_1.DISTRESS.BACKEND_REJECTED : distress_1.DISTRESS.HOOK_FAIL_CLOSED, `Blocking ${ctx.event} actions: ${cause} ${health.consecutiveFailures} times in a row since ${health.firstFailureAt || 'now'} (${detail})`);
|
|
487
488
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, reason: `fail-closed: ${detail}`, offlineEnforced: true });
|
|
488
489
|
(0, telemetry_1.triggerFlush)(true);
|
|
489
|
-
|
|
490
|
+
const failClosedWhy = `${cause} (${health.consecutiveFailures}x in a row) and this machine is set to fail-closed. ${detail} ${advice}`;
|
|
491
|
+
const explained = explainGuardStop('fail_closed', ctx.event, { toolName: toolName || 'unknown', toolArgs: {} }, failClosedWhy);
|
|
492
|
+
ctx.respond(true, explained.userMessage, `FullCourtDefense could not verify this ${ctx.event} against your org policies (${detail}) and the failure is persistent, so fail-closed mode is blocking. ${advice} ${explained.agentMessage}`);
|
|
490
493
|
}
|
|
491
494
|
// Grace window: transient failure on a fail-closed machine — allow this
|
|
492
495
|
// action, but record distress so the episode is visible in the console.
|
|
@@ -531,6 +534,7 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
|
|
|
531
534
|
remember: verdict.approval?.remember ?? 'none',
|
|
532
535
|
reason: verdict.reason,
|
|
533
536
|
where,
|
|
537
|
+
policies: ctx.localPolicies,
|
|
534
538
|
});
|
|
535
539
|
if (prepared.outcome === 'allow')
|
|
536
540
|
ctx.respond(false, undefined, prepared.agentMsg);
|
|
@@ -593,10 +597,15 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
593
597
|
});
|
|
594
598
|
(0, telemetry_1.triggerFlush)(false);
|
|
595
599
|
const reason = source.reason || `${call.toolName} needs your confirmation`;
|
|
600
|
+
// Name the rule in the prompt itself: a developer deciding y/n must see WHICH
|
|
601
|
+
// rule is asking and where to change it — same builder as every block.
|
|
602
|
+
const explained = source.where === 'local-safety' && source.localFinding
|
|
603
|
+
? explainLocalSafetyStop('ask', event, call, source.localFinding)
|
|
604
|
+
: explainPolicyStop('ask', event, call, source, source.policies);
|
|
596
605
|
return {
|
|
597
606
|
outcome: 'ask',
|
|
598
|
-
userMsg:
|
|
599
|
-
agentMsg: `FullCourtDefense paused this ${event} for the developer's confirmation${policyLabel}: ${reason}. Wait for the developer's answer in the IDE prompt — do not retry or work around it
|
|
607
|
+
userMsg: `${explained.userMessage}\nAllow this ${event}?`,
|
|
608
|
+
agentMsg: `FullCourtDefense paused this ${event} for the developer's confirmation${policyLabel}: ${reason}. Wait for the developer's answer in the IDE prompt — do not retry or work around it. ${explained.agentMessage}`,
|
|
600
609
|
};
|
|
601
610
|
}
|
|
602
611
|
/**
|
|
@@ -680,7 +689,8 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
680
689
|
: '';
|
|
681
690
|
spoolCallEvent(call, { decision: 'block', reason: `local policy (offline): ${reason}`, offlineEnforced: true });
|
|
682
691
|
(0, telemetry_1.triggerFlush)(true);
|
|
683
|
-
ctx.
|
|
692
|
+
const explained = explainPolicyStop('block', ctx.event, call, local, policies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`);
|
|
693
|
+
ctx.respond(true, explained.userMessage, `${explained.agentMessage} Do not retry until the connection is restored or the policy allows it.`);
|
|
684
694
|
}
|
|
685
695
|
// Monitor machines land here by design (zero synchronous network calls), not
|
|
686
696
|
// because anything is down — never tell the developer the service is
|
|
@@ -800,10 +810,85 @@ function machineMetadata(client = 'cursor') {
|
|
|
800
810
|
* see/manage it" in one glance — a bare reason string left developers unable
|
|
801
811
|
* to tell a built-in rule from an org policy or to find the console entry.
|
|
802
812
|
*/
|
|
803
|
-
function localBlockUserMessage(finding) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
813
|
+
function localBlockUserMessage(finding, event = 'shell', call, note) {
|
|
814
|
+
return explainLocalSafetyStop('block', event, call || { toolName: 'prompt', toolArgs: {} }, finding, note).userMessage;
|
|
815
|
+
}
|
|
816
|
+
/** Fleet machine id for deep links — best-effort, never throws. */
|
|
817
|
+
function explanationMachineId() {
|
|
818
|
+
try {
|
|
819
|
+
return (0, machineIdentity_1.getMachineIdentity)().machineId;
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Every stop the hook emits is explained through ONE builder (blockExplanation.ts):
|
|
827
|
+
* what was stopped, which rule BY NAME and where it came from, why it matched,
|
|
828
|
+
* and the exact console page that removes the block. Fixed templates, no prose.
|
|
829
|
+
*/
|
|
830
|
+
function explainPolicyStop(outcome, event, call, verdict, policies, note) {
|
|
831
|
+
const policy = verdict.policyId ? policies?.find(p => p.id === verdict.policyId) : undefined;
|
|
832
|
+
let facts = {};
|
|
833
|
+
let operation;
|
|
834
|
+
try {
|
|
835
|
+
const inferred = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
|
|
836
|
+
facts = inferred.context;
|
|
837
|
+
operation = inferred.operation;
|
|
838
|
+
}
|
|
839
|
+
catch { /* explanation must never fail the hook */ }
|
|
840
|
+
return (0, blockExplanation_1.buildBlockExplanation)({
|
|
841
|
+
outcome,
|
|
842
|
+
event,
|
|
843
|
+
toolName: call.toolName,
|
|
844
|
+
toolArgs: call.toolArgs,
|
|
845
|
+
source: {
|
|
846
|
+
kind: 'policy',
|
|
847
|
+
policyName: verdict.policyName,
|
|
848
|
+
policyId: verdict.policyId,
|
|
849
|
+
matchedRule: verdict.matchedRule,
|
|
850
|
+
scope: policy?.scope,
|
|
851
|
+
facts,
|
|
852
|
+
operation,
|
|
853
|
+
reason: verdict.reason,
|
|
854
|
+
},
|
|
855
|
+
consoleUrl: (0, notify_1.consoleUrl)(),
|
|
856
|
+
machineId: explanationMachineId(),
|
|
857
|
+
developerName: developerId(),
|
|
858
|
+
note,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
function explainLocalSafetyStop(outcome, event, call, finding, note) {
|
|
862
|
+
return (0, blockExplanation_1.buildBlockExplanation)({
|
|
863
|
+
outcome,
|
|
864
|
+
event,
|
|
865
|
+
toolName: call.toolName,
|
|
866
|
+
toolArgs: call.toolArgs,
|
|
867
|
+
source: {
|
|
868
|
+
kind: 'local_safety',
|
|
869
|
+
itemId: finding.itemId,
|
|
870
|
+
category: finding.category,
|
|
871
|
+
source: finding.source,
|
|
872
|
+
explanation: finding.explanation,
|
|
873
|
+
reason: finding.reason,
|
|
874
|
+
},
|
|
875
|
+
consoleUrl: (0, notify_1.consoleUrl)(),
|
|
876
|
+
machineId: explanationMachineId(),
|
|
877
|
+
developerName: developerId(),
|
|
878
|
+
note,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
function explainGuardStop(kind, event, call, reason, ruleId) {
|
|
882
|
+
return (0, blockExplanation_1.buildBlockExplanation)({
|
|
883
|
+
outcome: 'block',
|
|
884
|
+
event,
|
|
885
|
+
toolName: call.toolName,
|
|
886
|
+
toolArgs: call.toolArgs,
|
|
887
|
+
source: { kind, ruleId, reason },
|
|
888
|
+
consoleUrl: (0, notify_1.consoleUrl)(),
|
|
889
|
+
machineId: explanationMachineId(),
|
|
890
|
+
developerName: developerId(),
|
|
891
|
+
});
|
|
807
892
|
}
|
|
808
893
|
function spoolLocalFinding(input) {
|
|
809
894
|
(0, telemetry_1.spoolEvent)({
|
|
@@ -1195,6 +1280,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
1195
1280
|
remember: 'session',
|
|
1196
1281
|
reason: finding.reason.replace(/[.\s]+$/, ''),
|
|
1197
1282
|
where: 'local-safety',
|
|
1283
|
+
localFinding: finding,
|
|
1198
1284
|
});
|
|
1199
1285
|
if (prepared.outcome === 'ask') {
|
|
1200
1286
|
ask = true;
|
|
@@ -1323,7 +1409,8 @@ async function hookCommandInner(args, config, io) {
|
|
|
1323
1409
|
return;
|
|
1324
1410
|
}
|
|
1325
1411
|
spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
|
|
1326
|
-
|
|
1412
|
+
const explained = explainLocalSafetyStop('block', 'prompt', { toolName: 'prompt', toolArgs: {} }, localBlock);
|
|
1413
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this prompt locally (${localBlock.ruleId}). Do not retry. ${explained.agentMessage}`);
|
|
1327
1414
|
return;
|
|
1328
1415
|
}
|
|
1329
1416
|
if (localOnly) {
|
|
@@ -1357,7 +1444,8 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1357
1444
|
const hardBlock = () => {
|
|
1358
1445
|
spoolCallEvent(call, { decision: 'block', reason: taintFinding.reason, ruleId: taintFinding.ruleId, offlineEnforced: true });
|
|
1359
1446
|
(0, telemetry_1.triggerFlush)(true);
|
|
1360
|
-
|
|
1447
|
+
const explained = explainGuardStop('taint', event, call, taintFinding.reason, taintFinding.ruleId);
|
|
1448
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry. ${explained.agentMessage}`);
|
|
1361
1449
|
return false;
|
|
1362
1450
|
};
|
|
1363
1451
|
const approveOnceEnabled = process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== 'true'
|
|
@@ -1410,10 +1498,11 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1410
1498
|
offlineEnforced: true,
|
|
1411
1499
|
});
|
|
1412
1500
|
(0, telemetry_1.triggerFlush)(true);
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1501
|
+
const outcomeNote = outcome === 'denied'
|
|
1502
|
+
? 'You denied the approve-once request.'
|
|
1503
|
+
: 'The approve-once request expired with no decision. If you want this action, re-run it and approve from your own terminal (fullcourtdefense approve).';
|
|
1504
|
+
const explained = explainGuardStop('taint', event, call, `${taintFinding.reason} ${outcomeNote}`, taintFinding.ruleId);
|
|
1505
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} The developer was asked and did not approve. Do not retry. ${explained.agentMessage}`);
|
|
1417
1506
|
return false;
|
|
1418
1507
|
}
|
|
1419
1508
|
async function enforceActionPolicy(ctx) {
|
|
@@ -1448,7 +1537,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1448
1537
|
return;
|
|
1449
1538
|
}
|
|
1450
1539
|
spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
|
|
1451
|
-
|
|
1540
|
+
const explained = explainLocalSafetyStop('block', event, call, localBlock);
|
|
1541
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry. ${explained.agentMessage}`);
|
|
1452
1542
|
return;
|
|
1453
1543
|
}
|
|
1454
1544
|
// Local Safety rule set to "Ask the developer" (no block rule matched).
|
|
@@ -1464,7 +1554,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1464
1554
|
}
|
|
1465
1555
|
else if (!(0, devConfirm_1.isHumanPresent)()) {
|
|
1466
1556
|
spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event });
|
|
1467
|
-
|
|
1557
|
+
const explained = explainLocalSafetyStop('block', event, call, localAsk, 'This rule asks the developer to confirm, but no developer is present on this machine (CI / ephemeral), so it is blocked.');
|
|
1558
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${localAsk.ruleId}): the rule requires the developer's confirmation and no human is present here. Do not retry. ${explained.agentMessage}`);
|
|
1468
1559
|
return;
|
|
1469
1560
|
}
|
|
1470
1561
|
else {
|
|
@@ -1483,7 +1574,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1483
1574
|
}
|
|
1484
1575
|
spoolCallEvent(call, { decision: 'block', reason: selfApproval.reason, ruleId: 'local-taint-self-approval', offlineEnforced: true });
|
|
1485
1576
|
(0, telemetry_1.triggerFlush)(true);
|
|
1486
|
-
|
|
1577
|
+
const explained = explainGuardStop('self_protection', event, call, selfApproval.reason, 'local-taint-self-approval');
|
|
1578
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (local-taint-self-approval): ${selfApproval.reason} Do not retry. ${explained.agentMessage}`);
|
|
1487
1579
|
return;
|
|
1488
1580
|
}
|
|
1489
1581
|
// --- Deterministic taint tracking (local, no backend) ---
|
|
@@ -1605,7 +1697,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1605
1697
|
const reason = localVerdict.reason || `${call.toolName}: blocked by org Action Policy`;
|
|
1606
1698
|
spoolCallEvent(call, { decision: 'block', reason: `local policy (block mode, local-first): ${reason}`, offlineEnforced: true });
|
|
1607
1699
|
(0, telemetry_1.triggerFlush)(true);
|
|
1608
|
-
|
|
1700
|
+
const explained = explainPolicyStop('block', event, call, localVerdict, ctx.localPolicies);
|
|
1701
|
+
respond(true, explained.userMessage, explained.agentMessage);
|
|
1609
1702
|
return;
|
|
1610
1703
|
}
|
|
1611
1704
|
if (localVerdict.verdict === 'log') {
|
|
@@ -1616,7 +1709,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
1616
1709
|
return;
|
|
1617
1710
|
}
|
|
1618
1711
|
// require_approval, developer scope + human present → IDE-native ask (no org queue).
|
|
1619
|
-
if (tryDeveloperConfirmation({ event, respond, shadow }, call, localVerdict, payload, 'local-first'))
|
|
1712
|
+
if (tryDeveloperConfirmation({ event, respond, shadow, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
|
|
1620
1713
|
return;
|
|
1621
1714
|
// require_approval (org scope) → synchronous gate below creates the approval action.
|
|
1622
1715
|
}
|
|
@@ -1719,7 +1812,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1719
1812
|
message: `${call.toolName} needs human approval${policyName ? ` (${policyName})` : ''}.`,
|
|
1720
1813
|
url: queueUrl,
|
|
1721
1814
|
});
|
|
1722
|
-
|
|
1815
|
+
const explained = explainPolicyStop('approval', event, call, { policyName, policyId: result.actionPolicy?.policyId, matchedRule: result.actionPolicy?.matchedRule, reason }, ctx.localPolicies, `Approve at ${queueUrl}`);
|
|
1816
|
+
respond(true, explained.userMessage, `This ${event} requires human approval per Action Policy. Stop and ask the developer to approve it in the FullCourtDefense console: ${queueUrl}. ${explained.agentMessage}`);
|
|
1723
1817
|
return;
|
|
1724
1818
|
}
|
|
1725
1819
|
// Make the wait visible on the machine: the hook process has no UI and its
|
|
@@ -1752,7 +1846,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1752
1846
|
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}${policyName ? ` (${policyName})` : ''}: ${reason}`);
|
|
1753
1847
|
return;
|
|
1754
1848
|
}
|
|
1755
|
-
|
|
1849
|
+
const explained = explainPolicyStop(result.decision === 'mask' ? 'mask' : 'block', event, call, { policyName, policyId: result.actionPolicy?.policyId, matchedRule: result.actionPolicy?.matchedRule, reason }, ctx.localPolicies);
|
|
1850
|
+
respond(true, explained.userMessage, explained.agentMessage);
|
|
1756
1851
|
}
|
|
1757
1852
|
async function waitForApproval(input) {
|
|
1758
1853
|
const headers = { 'Content-Type': 'application/json' };
|