fullcourtdefense-cli 1.26.14 → 1.26.16
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 +58 -1
- package/dist/actionPolicyEngine.js +66 -0
- package/dist/commands/ciProtect.d.ts +2 -0
- package/dist/commands/ciProtect.js +16 -84
- package/dist/commands/daemon.js +95 -9
- package/dist/commands/demoActions.d.ts +117 -0
- package/dist/commands/demoActions.js +450 -0
- package/dist/commands/discover.js +3 -9
- package/dist/commands/ephemeralStack.d.ts +82 -0
- package/dist/commands/ephemeralStack.js +185 -0
- package/dist/commands/hook.d.ts +52 -1
- package/dist/commands/hook.js +263 -38
- package/dist/commands/installClaudeHook.js +21 -1
- package/dist/commands/installCursorHook.js +38 -0
- package/dist/commands/taintLedger.d.ts +1 -1
- package/dist/commands/workloadProtect.d.ts +61 -0
- package/dist/commands/workloadProtect.js +173 -0
- package/dist/describeError.d.ts +17 -0
- package/dist/describeError.js +74 -0
- package/dist/devConfirm.d.ts +78 -0
- package/dist/devConfirm.js +241 -0
- package/dist/distress.d.ts +7 -0
- package/dist/distress.js +7 -0
- package/dist/index.js +40 -0
- package/dist/localSafetySnapshot.js +11 -0
- package/dist/pollLoop.d.ts +6 -0
- package/dist/pollLoop.js +52 -8
- package/dist/telemetry.d.ts +6 -0
- package/dist/telemetry.js +3 -0
- package/dist/version.json +1 -1
- package/package.json +5 -1
|
@@ -17,15 +17,47 @@
|
|
|
17
17
|
* they diverge. Change this file? Copy it to the other location verbatim.
|
|
18
18
|
*/
|
|
19
19
|
export type ActionPolicyVerdict = 'allow' | 'block' | 'require_approval' | 'log';
|
|
20
|
+
/**
|
|
21
|
+
* Constraint operators. `not_matches` is the glob complement of `matches`: it is what lets an
|
|
22
|
+
* allow-list be expressed under "most restrictive verdict wins" — `to matches *@company.com ->
|
|
23
|
+
* allow` needs a `to not_matches *@company.com -> block` partner, because an unconstrained block
|
|
24
|
+
* rule would match every call and swallow the allow.
|
|
25
|
+
*/
|
|
26
|
+
export type ActionPolicyConstraintOperator = 'contains' | 'not_contains' | 'equals' | 'not_equals' | 'gt' | 'lt' | 'matches' | 'not_matches';
|
|
20
27
|
export interface ActionPolicyConstraint {
|
|
21
28
|
field: string;
|
|
22
|
-
operator:
|
|
29
|
+
operator: ActionPolicyConstraintOperator;
|
|
23
30
|
value: string;
|
|
24
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Who resolves a `require_approval` verdict.
|
|
34
|
+
* - `org` — an admin in the console / Slack (default; the only scope with no human at the keyboard).
|
|
35
|
+
* - `developer` — the developer running the agent confirms in their IDE's own permission prompt
|
|
36
|
+
* (Cursor `permission: ask`, Claude `permissionDecision: ask`) or a terminal y/N.
|
|
37
|
+
* Only meaningful where a human is present — see `effectiveApprovalScope`.
|
|
38
|
+
*/
|
|
39
|
+
export type ActionPolicyApprovalScope = 'developer' | 'org';
|
|
40
|
+
/** How long a developer's confirmation is remembered for the same tool + operation. */
|
|
41
|
+
export type ActionPolicyApprovalRemember = 'none' | 'session' | '1h';
|
|
42
|
+
/** What happens when nobody answers within the approval window. */
|
|
43
|
+
export type ActionPolicyApprovalOnTimeout = 'block' | 'org';
|
|
44
|
+
export interface ActionPolicyApproval {
|
|
45
|
+
scope?: ActionPolicyApprovalScope;
|
|
46
|
+
remember?: ActionPolicyApprovalRemember;
|
|
47
|
+
onTimeout?: ActionPolicyApprovalOnTimeout;
|
|
48
|
+
}
|
|
49
|
+
/** Fully-resolved approval options (every field present) attached to a `require_approval` result. */
|
|
50
|
+
export interface ResolvedActionPolicyApproval {
|
|
51
|
+
scope: ActionPolicyApprovalScope;
|
|
52
|
+
remember: ActionPolicyApprovalRemember;
|
|
53
|
+
onTimeout: ActionPolicyApprovalOnTimeout;
|
|
54
|
+
}
|
|
25
55
|
export interface ActionPolicyRule {
|
|
26
56
|
operations: string[];
|
|
27
57
|
verdict: ActionPolicyVerdict;
|
|
28
58
|
constraints?: ActionPolicyConstraint[];
|
|
59
|
+
/** Approval routing for `require_approval` rules. Absent = org admin approval, no memory, block on timeout. */
|
|
60
|
+
approval?: ActionPolicyApproval;
|
|
29
61
|
}
|
|
30
62
|
export interface ActionPolicyTargeting {
|
|
31
63
|
developerNames?: string[];
|
|
@@ -59,11 +91,36 @@ export interface EngineCheckResult {
|
|
|
59
91
|
resourceType?: string;
|
|
60
92
|
matchedRule?: string;
|
|
61
93
|
reason?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Resolved approval routing — present only when `verdict === 'require_approval'`. When several
|
|
96
|
+
* approval rules match, the most restrictive option wins per field (org > developer, none >
|
|
97
|
+
* session > 1h, block > org) so one permissive rule can never loosen another.
|
|
98
|
+
*/
|
|
99
|
+
approval?: ResolvedActionPolicyApproval;
|
|
62
100
|
/** Monitor-stage policies that matched (would-block / would-approve) without enforcing. */
|
|
63
101
|
monitorMatches?: ActionPolicyMonitorMatch[];
|
|
64
102
|
/** Org-scoped policies skipped because the call carried no developerName (for caller-side logging). */
|
|
65
103
|
skippedOrgPolicies?: string[];
|
|
66
104
|
}
|
|
105
|
+
export declare const DEFAULT_ACTION_POLICY_APPROVAL: ResolvedActionPolicyApproval;
|
|
106
|
+
/**
|
|
107
|
+
* Normalize a rule's approval options: unknown / missing values fall back to the DEFAULT
|
|
108
|
+
* (org, none, block) — the strictest — so a typo in a policy document can never widen who
|
|
109
|
+
* may approve.
|
|
110
|
+
*/
|
|
111
|
+
export declare function resolveApprovalOptions(approval?: ActionPolicyApproval): ResolvedActionPolicyApproval;
|
|
112
|
+
/**
|
|
113
|
+
* Where an approval actually goes given who is present at the enforcement point.
|
|
114
|
+
*
|
|
115
|
+
* `developer` scope means "the human running the agent confirms in their IDE". That human only
|
|
116
|
+
* exists on an end machine. Ephemeral machines (CI runners, containers, cloud coding agents) and
|
|
117
|
+
* SDK-embedded online services have no keyboard, so a developer-scoped rule there degrades to the
|
|
118
|
+
* org admin queue — never to a silent self-approval, and never to allow. Pure and shared so the
|
|
119
|
+
* backend, the CLI hook and the SDK agree on this fallback without a network round-trip.
|
|
120
|
+
*/
|
|
121
|
+
export declare function effectiveApprovalScope(approval: ActionPolicyApproval | ResolvedActionPolicyApproval | undefined, presence: {
|
|
122
|
+
humanPresent: boolean;
|
|
123
|
+
}): ActionPolicyApprovalScope;
|
|
67
124
|
/**
|
|
68
125
|
* Derive the set of canonical operations a tool represents from its NAME and declared inventory
|
|
69
126
|
* actions. This is the "declared operations" model: the tool's identity/declaration defines what
|
|
@@ -18,11 +18,60 @@
|
|
|
18
18
|
* they diverge. Change this file? Copy it to the other location verbatim.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.DEFAULT_ACTION_POLICY_APPROVAL = void 0;
|
|
22
|
+
exports.resolveApprovalOptions = resolveApprovalOptions;
|
|
23
|
+
exports.effectiveApprovalScope = effectiveApprovalScope;
|
|
21
24
|
exports.deriveToolOperations = deriveToolOperations;
|
|
22
25
|
exports.toolCapabilities = toolCapabilities;
|
|
23
26
|
exports.evaluateActionPolicies = evaluateActionPolicies;
|
|
24
27
|
exports.inferToolContext = inferToolContext;
|
|
25
28
|
exports.inventoryToolMatchesActionPolicy = inventoryToolMatchesActionPolicy;
|
|
29
|
+
exports.DEFAULT_ACTION_POLICY_APPROVAL = {
|
|
30
|
+
scope: 'org',
|
|
31
|
+
remember: 'none',
|
|
32
|
+
onTimeout: 'block',
|
|
33
|
+
};
|
|
34
|
+
const APPROVAL_SCOPE_RANK = { developer: 0, org: 1 };
|
|
35
|
+
const APPROVAL_REMEMBER_RANK = { '1h': 0, session: 1, none: 2 };
|
|
36
|
+
const APPROVAL_ON_TIMEOUT_RANK = { org: 0, block: 1 };
|
|
37
|
+
/**
|
|
38
|
+
* Normalize a rule's approval options: unknown / missing values fall back to the DEFAULT
|
|
39
|
+
* (org, none, block) — the strictest — so a typo in a policy document can never widen who
|
|
40
|
+
* may approve.
|
|
41
|
+
*/
|
|
42
|
+
function resolveApprovalOptions(approval) {
|
|
43
|
+
const scope = approval?.scope;
|
|
44
|
+
const remember = approval?.remember;
|
|
45
|
+
const onTimeout = approval?.onTimeout;
|
|
46
|
+
return {
|
|
47
|
+
scope: scope === 'developer' || scope === 'org' ? scope : exports.DEFAULT_ACTION_POLICY_APPROVAL.scope,
|
|
48
|
+
remember: remember === 'session' || remember === '1h' || remember === 'none' ? remember : exports.DEFAULT_ACTION_POLICY_APPROVAL.remember,
|
|
49
|
+
onTimeout: onTimeout === 'org' || onTimeout === 'block' ? onTimeout : exports.DEFAULT_ACTION_POLICY_APPROVAL.onTimeout,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Field-wise most-restrictive merge of two resolved approval options. */
|
|
53
|
+
function mergeApprovalOptions(a, b) {
|
|
54
|
+
return {
|
|
55
|
+
scope: APPROVAL_SCOPE_RANK[b.scope] > APPROVAL_SCOPE_RANK[a.scope] ? b.scope : a.scope,
|
|
56
|
+
remember: APPROVAL_REMEMBER_RANK[b.remember] > APPROVAL_REMEMBER_RANK[a.remember] ? b.remember : a.remember,
|
|
57
|
+
onTimeout: APPROVAL_ON_TIMEOUT_RANK[b.onTimeout] > APPROVAL_ON_TIMEOUT_RANK[a.onTimeout] ? b.onTimeout : a.onTimeout,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Where an approval actually goes given who is present at the enforcement point.
|
|
62
|
+
*
|
|
63
|
+
* `developer` scope means "the human running the agent confirms in their IDE". That human only
|
|
64
|
+
* exists on an end machine. Ephemeral machines (CI runners, containers, cloud coding agents) and
|
|
65
|
+
* SDK-embedded online services have no keyboard, so a developer-scoped rule there degrades to the
|
|
66
|
+
* org admin queue — never to a silent self-approval, and never to allow. Pure and shared so the
|
|
67
|
+
* backend, the CLI hook and the SDK agree on this fallback without a network round-trip.
|
|
68
|
+
*/
|
|
69
|
+
function effectiveApprovalScope(approval, presence) {
|
|
70
|
+
const resolved = resolveApprovalOptions(approval);
|
|
71
|
+
if (resolved.scope === 'developer' && presence.humanPresent)
|
|
72
|
+
return 'developer';
|
|
73
|
+
return 'org';
|
|
74
|
+
}
|
|
26
75
|
function matchesGlob(value, pattern) {
|
|
27
76
|
const escaped = pattern
|
|
28
77
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
@@ -174,6 +223,8 @@ function evaluateConstraint(constraint, context) {
|
|
|
174
223
|
return !actualNorm.includes(expectedNorm);
|
|
175
224
|
case 'matches':
|
|
176
225
|
return matchesGlob(actualNorm, expectedNorm);
|
|
226
|
+
case 'not_matches':
|
|
227
|
+
return !matchesGlob(actualNorm, expectedNorm);
|
|
177
228
|
default:
|
|
178
229
|
return false;
|
|
179
230
|
}
|
|
@@ -190,6 +241,8 @@ function evaluateConstraint(constraint, context) {
|
|
|
190
241
|
return !actual.toLowerCase().includes(expected.toLowerCase());
|
|
191
242
|
case 'matches':
|
|
192
243
|
return matchesGlob(actual, expected);
|
|
244
|
+
case 'not_matches':
|
|
245
|
+
return !matchesGlob(actual, expected);
|
|
193
246
|
default:
|
|
194
247
|
return false;
|
|
195
248
|
}
|
|
@@ -207,6 +260,7 @@ function evaluateConstraint(constraint, context) {
|
|
|
207
260
|
return capabilityMatch;
|
|
208
261
|
case 'not_equals':
|
|
209
262
|
case 'not_contains':
|
|
263
|
+
case 'not_matches':
|
|
210
264
|
return !capabilityMatch;
|
|
211
265
|
default:
|
|
212
266
|
return false;
|
|
@@ -227,6 +281,8 @@ function evaluateConstraint(constraint, context) {
|
|
|
227
281
|
return parseFloat(actual) < parseFloat(expected);
|
|
228
282
|
case 'matches':
|
|
229
283
|
return matchesGlob(actual, expected);
|
|
284
|
+
case 'not_matches':
|
|
285
|
+
return !matchesGlob(actual, expected);
|
|
230
286
|
default:
|
|
231
287
|
return false;
|
|
232
288
|
}
|
|
@@ -528,6 +584,16 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
528
584
|
resourceType: policy.resourceType,
|
|
529
585
|
matchedRule,
|
|
530
586
|
reason: `Action policy "${policy.name}": ${rule.operations.join('/')} ${rule.verdict === 'block' ? 'blocked' : rule.verdict === 'require_approval' ? 'requires approval' : rule.verdict}`,
|
|
587
|
+
...(rule.verdict === 'require_approval' ? { approval: resolveApprovalOptions(rule.approval) } : {}),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
else if (rule.verdict === 'require_approval' && worstResult.verdict === 'require_approval') {
|
|
591
|
+
// A second approval rule matched: keep the first rule's attribution but tighten the
|
|
592
|
+
// approval routing to the most restrictive of both (a developer-scoped rule can never
|
|
593
|
+
// relax an org-scoped one that also fires).
|
|
594
|
+
worstResult = {
|
|
595
|
+
...worstResult,
|
|
596
|
+
approval: mergeApprovalOptions(worstResult.approval || exports.DEFAULT_ACTION_POLICY_APPROVAL, resolveApprovalOptions(rule.approval)),
|
|
531
597
|
};
|
|
532
598
|
}
|
|
533
599
|
}
|
|
@@ -28,6 +28,8 @@ export interface CiProtectArgs {
|
|
|
28
28
|
hooks?: string;
|
|
29
29
|
/** 'false' => skip wrapping MCP client configs with the gateway. */
|
|
30
30
|
gateway?: string;
|
|
31
|
+
/** 'true' => allow replacing an existing developer-machine enrollment (see assertNotEnrolledLaptop). */
|
|
32
|
+
force?: string;
|
|
31
33
|
}
|
|
32
34
|
interface CiContext {
|
|
33
35
|
provider: string;
|
|
@@ -1,44 +1,9 @@
|
|
|
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
|
-
})();
|
|
35
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
3
|
exports.detectCiContext = detectCiContext;
|
|
37
4
|
exports.ciProtectCommand = ciProtectCommand;
|
|
38
|
-
const fs = __importStar(require("fs"));
|
|
39
5
|
const config_1 = require("../config");
|
|
40
|
-
const
|
|
41
|
-
const mcpGateway_1 = require("./mcpGateway");
|
|
6
|
+
const ephemeralStack_1 = require("./ephemeralStack");
|
|
42
7
|
/** Detect the pipeline from standard CI env vars (GitHub Actions, GitLab CI). */
|
|
43
8
|
function detectCiContext(env = process.env) {
|
|
44
9
|
if (env.GITHUB_ACTIONS === 'true') {
|
|
@@ -89,6 +54,9 @@ async function ciProtectCommand(args, config) {
|
|
|
89
54
|
throw new Error('An organization API key is required. Add FCD_API_KEY to the repo/pipeline secrets '
|
|
90
55
|
+ '(create one in the dashboard: Workspace → API keys) or pass --api-key.');
|
|
91
56
|
}
|
|
57
|
+
// Before any network call or file write: never overwrite a developer's enrollment
|
|
58
|
+
// (self-hosted runner that is also someone's workstation).
|
|
59
|
+
(0, ephemeralStack_1.assertNotEnrolledLaptop)('pipeline', { force: args.force === 'true' });
|
|
92
60
|
const detected = detectCiContext();
|
|
93
61
|
const provider = (args.provider || detected?.provider || 'generic-ci').trim();
|
|
94
62
|
const repo = (args.repo || detected?.repo || '').trim();
|
|
@@ -129,62 +97,26 @@ async function ciProtectCommand(args, config) {
|
|
|
129
97
|
shieldKey: result.shieldKey,
|
|
130
98
|
apiUrl,
|
|
131
99
|
});
|
|
100
|
+
(0, ephemeralStack_1.writeEphemeralIdentityMarker)('pipeline', result.machineId);
|
|
132
101
|
// Attribute every subsequent hook/gateway event in this job to the PIPELINE
|
|
133
102
|
// machine record: current process + GITHUB_ENV for the steps that follow
|
|
134
103
|
// (where the AI agent actually runs).
|
|
135
|
-
|
|
104
|
+
(0, ephemeralStack_1.exportIdentityEnv)({
|
|
136
105
|
FCD_MACHINE_ID: result.machineId,
|
|
137
106
|
FCD_DEVELOPER_NAME: `ci@${repo.toLowerCase()}`,
|
|
138
107
|
FCD_MACHINE_HOSTNAME: result.pipeline.key,
|
|
139
|
-
};
|
|
140
|
-
for (const [key, value] of Object.entries(identityEnv))
|
|
141
|
-
process.env[key] = value;
|
|
142
|
-
if (process.env.GITHUB_ENV) {
|
|
143
|
-
try {
|
|
144
|
-
fs.appendFileSync(process.env.GITHUB_ENV, Object.entries(identityEnv).map(([k, v]) => `${k}=${v}\n`).join(''));
|
|
145
|
-
}
|
|
146
|
-
catch { /* non-GitHub runner with a stray env var — identity still set for this process */ }
|
|
147
|
-
}
|
|
108
|
+
}, { githubEnvPath: process.env.GITHUB_ENV });
|
|
148
109
|
console.log(`\x1b[32m✓ ${result.reused ? 'Pipeline re-enrolled' : 'Pipeline enrolled'}\x1b[0m ${result.shieldName}`);
|
|
149
110
|
console.log(` Mode: ${result.enforcementMode} · Config: ${savedPath}`);
|
|
150
|
-
// Same protection stack as laptops
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
};
|
|
160
|
-
try {
|
|
161
|
-
await (0, installClaudeHook_1.installClaudeHookCommand)(hookArgs, config);
|
|
162
|
-
await sendLog([{ level: 'success', message: 'Runtime hook installed (Claude Code / VS Code / Copilot CLI)' }]);
|
|
163
|
-
}
|
|
164
|
-
catch (error) {
|
|
165
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
166
|
-
console.log(`Hook install skipped: ${reason}`);
|
|
167
|
-
await sendLog([{ level: 'warn', message: `Runtime hook install skipped: ${reason}` }]);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (args.gateway !== 'false') {
|
|
171
|
-
console.log('\n\x1b[1mWrapping MCP client configs with the gateway…\x1b[0m');
|
|
172
|
-
const gatewayArgs = {
|
|
173
|
-
shieldId: result.shieldId,
|
|
174
|
-
shieldKey: result.shieldKey,
|
|
175
|
-
apiUrl,
|
|
176
|
-
clients: 'all',
|
|
177
|
-
};
|
|
178
|
-
try {
|
|
179
|
-
await (0, mcpGateway_1.protectAllCommand)(gatewayArgs, config);
|
|
180
|
-
await sendLog([{ level: 'success', message: 'MCP client configs wrapped with the FullCourtDefense gateway' }]);
|
|
181
|
-
}
|
|
182
|
-
catch (error) {
|
|
183
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
184
|
-
console.log(`MCP gateway wrap skipped: ${reason}`);
|
|
185
|
-
await sendLog([{ level: 'warn', message: `MCP gateway wrap skipped: ${reason}` }]);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
111
|
+
// Same protection stack as laptops (shared with workload-protect).
|
|
112
|
+
await (0, ephemeralStack_1.installEphemeralProtectionStack)({
|
|
113
|
+
shieldId: result.shieldId,
|
|
114
|
+
shieldKey: result.shieldKey,
|
|
115
|
+
apiUrl,
|
|
116
|
+
hooks: args.hooks,
|
|
117
|
+
gateway: args.gateway,
|
|
118
|
+
sendLog,
|
|
119
|
+
}, config);
|
|
188
120
|
console.log('\n\x1b[32mDone.\x1b[0m This job\'s AI tool calls are now policy-checked and streamed to the fleet console.');
|
|
189
121
|
console.log(`Pipeline appears in AI Fleet → Machines → CI pipelines as \x1b[1m${repo} · ${workflow}\x1b[0m.`);
|
|
190
122
|
await sendLog([{ level: 'success', message: `Protection active (${result.enforcementMode} mode) — this job's AI tool calls are policy-checked and recorded` }], 'succeeded');
|
package/dist/commands/daemon.js
CHANGED
|
@@ -145,6 +145,14 @@ const HEARTBEAT_DEADLINE_MS = envMs('FCD_DAEMON_HEARTBEAT_DEADLINE_MS', 120_000)
|
|
|
145
145
|
*/
|
|
146
146
|
const RESUME_BEAT_MS = envMs('FCD_DAEMON_RESUME_BEAT_MS', 30_000);
|
|
147
147
|
const RESUME_JUMP_MS = envMs('FCD_DAEMON_RESUME_JUMP_MS', 120_000);
|
|
148
|
+
/**
|
|
149
|
+
* Report-in attempts after a resume, and the base delay between them (linear
|
|
150
|
+
* backoff: 10s, 20s, 30s, 40s). Sized to outlast a normal Wi-Fi reassociation
|
|
151
|
+
* plus DHCP/DNS, so waking on a slow network costs seconds of staleness rather
|
|
152
|
+
* than a whole reporting window.
|
|
153
|
+
*/
|
|
154
|
+
const RESUME_REPORT_ATTEMPTS = envMs('FCD_DAEMON_RESUME_ATTEMPTS', 5);
|
|
155
|
+
const RESUME_RETRY_BASE_MS = envMs('FCD_DAEMON_RESUME_RETRY_MS', 10_000);
|
|
148
156
|
/** Delay before the one-time initial discovery sweep on a fresh machine. */
|
|
149
157
|
const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
|
|
150
158
|
/** A discovery upload older than this is stale — the daemon catches up itself. */
|
|
@@ -582,30 +590,49 @@ async function runDaemon(args, config) {
|
|
|
582
590
|
* back within one heartbeat instead of up to an hour later (the 8/12
|
|
583
591
|
* incident: re-enroll fixed the hooks instantly while the daemon kept
|
|
584
592
|
* 401-ing for 16 more minutes).
|
|
593
|
+
*
|
|
594
|
+
* The same applies when the daemon already HOLDS credentials: a re-enroll
|
|
595
|
+
* rotates the shield key, so the key in memory is dead the moment
|
|
596
|
+
* ~/.fullcourtdefense.yml is rewritten. Until 9/3 this function returned
|
|
597
|
+
* early whenever creds were present, and a re-enrolled laptop's daemon kept
|
|
598
|
+
* 401-ing (bundle, heartbeat, telemetry) until someone killed the process.
|
|
599
|
+
* Now a changed enrollment file reloads credentials whether or not the
|
|
600
|
+
* daemon had some — one heartbeat later it is on the new key.
|
|
585
601
|
*/
|
|
586
602
|
const recoverCredentialsIfMissing = () => {
|
|
587
|
-
if (creds.shieldId && creds.shieldKey)
|
|
588
|
-
return;
|
|
589
603
|
let enrollmentChanged = false;
|
|
590
604
|
try {
|
|
591
605
|
const mtime = fs.statSync((0, config_1.getHomeConfigPath)()).mtimeMs;
|
|
592
|
-
|
|
606
|
+
// First observation only records the baseline; a later different mtime is a re-enroll.
|
|
607
|
+
enrollmentChanged = lastEnrollmentMtimeMs !== 0 && mtime !== lastEnrollmentMtimeMs;
|
|
593
608
|
lastEnrollmentMtimeMs = mtime;
|
|
594
609
|
}
|
|
595
610
|
catch { /* no config file — the tick cadence below applies */ }
|
|
596
|
-
|
|
597
|
-
if (!
|
|
611
|
+
const missing = !(creds.shieldId && creds.shieldKey);
|
|
612
|
+
if (!missing && !enrollmentChanged)
|
|
598
613
|
return;
|
|
614
|
+
if (missing) {
|
|
615
|
+
credRecoveryTicks += 1;
|
|
616
|
+
if (!enrollmentChanged && credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
599
619
|
try {
|
|
600
620
|
const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
|
|
601
621
|
shieldId: args.shieldId,
|
|
602
622
|
shieldKey: args.shieldKey,
|
|
603
623
|
apiUrl: args.apiUrl,
|
|
604
624
|
});
|
|
605
|
-
if (fresh.shieldId
|
|
625
|
+
if (!fresh.shieldId || !fresh.shieldKey)
|
|
626
|
+
return;
|
|
627
|
+
const rotated = fresh.shieldId !== creds.shieldId || fresh.shieldKey !== creds.shieldKey || fresh.apiUrl !== creds.apiUrl;
|
|
628
|
+
if (missing) {
|
|
606
629
|
Object.assign(creds, fresh);
|
|
607
630
|
log('Credentials recovered — telemetry and control-plane sync restored.');
|
|
608
631
|
}
|
|
632
|
+
else if (rotated) {
|
|
633
|
+
Object.assign(creds, fresh);
|
|
634
|
+
log(`Enrollment changed on disk — reloaded credentials (shield ${fresh.shieldId}); telemetry and control-plane sync continue on the new key.`);
|
|
635
|
+
}
|
|
609
636
|
}
|
|
610
637
|
catch { /* next tick */ }
|
|
611
638
|
};
|
|
@@ -1298,6 +1325,14 @@ async function runDaemon(args, config) {
|
|
|
1298
1325
|
recoverCredentialsIfMissing();
|
|
1299
1326
|
if (!creds.shieldId)
|
|
1300
1327
|
return;
|
|
1328
|
+
// Did THIS beat actually record daemon liveness upstream? `flushSpool`
|
|
1329
|
+
// reports every failure by returning null and never throws, so without
|
|
1330
|
+
// this flag a heartbeat that never left the machine was indistinguishable
|
|
1331
|
+
// from a delivered one — and the loop happily waited a full cadence before
|
|
1332
|
+
// trying again. `null` also covers the case where a hook flusher held the
|
|
1333
|
+
// flush lock, which drops the daemon marker specifically (hook flushes do
|
|
1334
|
+
// not set `daemon: true`), so retrying soon is right in that case too.
|
|
1335
|
+
let reported = false;
|
|
1301
1336
|
try {
|
|
1302
1337
|
let integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
1303
1338
|
// A failing verdict used to be REPORTED and nothing more, so a machine
|
|
@@ -1348,6 +1383,7 @@ async function runDaemon(args, config) {
|
|
|
1348
1383
|
}
|
|
1349
1384
|
: undefined,
|
|
1350
1385
|
});
|
|
1386
|
+
reported = result !== null;
|
|
1351
1387
|
if (result && unreportedCrash)
|
|
1352
1388
|
(0, daemonForensics_1.markPostmortemReported)();
|
|
1353
1389
|
if (result && result.accepted > 0)
|
|
@@ -1358,8 +1394,13 @@ async function runDaemon(args, config) {
|
|
|
1358
1394
|
}
|
|
1359
1395
|
catch { /* spool stays on disk for the next tick */ }
|
|
1360
1396
|
// Settle any in-flight upgrade_cli action (success once the new build is
|
|
1361
|
-
// live, failure when the target never arrived).
|
|
1397
|
+
// live, failure when the target never arrived). Runs BEFORE the failure
|
|
1398
|
+
// signal below so a lost heartbeat can never strand a pending upgrade.
|
|
1362
1399
|
await verifyPendingUpgrade();
|
|
1400
|
+
// Surface the miss to the caller (poll loop / resume path) so it retries in
|
|
1401
|
+
// seconds. Nothing else can tell them: liveness has exactly one writer.
|
|
1402
|
+
if (!reported)
|
|
1403
|
+
throw new Error('heartbeat did not reach the backend — daemon liveness was not recorded');
|
|
1363
1404
|
};
|
|
1364
1405
|
// --- boot ---------------------------------------------------------------
|
|
1365
1406
|
// Self-heal autostart + watchdog tasks: machines installed by older versions
|
|
@@ -1385,7 +1426,19 @@ async function runDaemon(args, config) {
|
|
|
1385
1426
|
// would report a false "damaged" state (and fire admin integrity alerts)
|
|
1386
1427
|
// for a condition the very next line repairs.
|
|
1387
1428
|
await reprotect(['startup pass']);
|
|
1388
|
-
|
|
1429
|
+
// The FIRST beat must not be able to abort the rest of this function.
|
|
1430
|
+
// `heartbeat` throws when the report did not reach the backend (that is how
|
|
1431
|
+
// the poll loop knows to retry in seconds), and an exception here propagates
|
|
1432
|
+
// out of `runDaemon` and skips every loop registered below it — the heartbeat
|
|
1433
|
+
// loop, the resume timer and the bundle poll. The process itself survives on
|
|
1434
|
+
// the `unhandledRejection` handler, which is worse than dying: it holds the
|
|
1435
|
+
// pid lock, so the scheduled task declines to start a replacement, and it
|
|
1436
|
+
// never polls the bundle, so no remote action can reach it. Booting before
|
|
1437
|
+
// the network is up (captive portal, VPN, resumed in a lift) is routine, and
|
|
1438
|
+
// the heartbeat loop below retries within seconds.
|
|
1439
|
+
await heartbeat().catch(error => {
|
|
1440
|
+
log(`First heartbeat did not reach the backend (${error?.message || String(error)}) — the heartbeat loop will retry shortly.`);
|
|
1441
|
+
});
|
|
1389
1442
|
// Claude Desktop chat guard (Windows, advisory): Claude Desktop's regular
|
|
1390
1443
|
// chat has no hook and never hits an MCP server, so it is the one machine
|
|
1391
1444
|
// surface neither hooks nor the gateway can see. Supervise the advisory guard
|
|
@@ -1535,6 +1588,39 @@ async function runDaemon(args, config) {
|
|
|
1535
1588
|
// closes the reporting gap and removes the evidence of a hang that never
|
|
1536
1589
|
// happened. Both calls go through runWithDeadline, so a resume that lands on
|
|
1537
1590
|
// sockets which died during sleep cannot leak an unhandled rejection.
|
|
1591
|
+
// The resume beat is RETRIED, because it fires the instant the wall-clock
|
|
1592
|
+
// jump is noticed — which on a laptop is reliably before Wi-Fi has
|
|
1593
|
+
// reassociated. As a single shot, the one beat whose whole job is to prove
|
|
1594
|
+
// the daemon survived the sleep was also the likeliest to be lost, and modern
|
|
1595
|
+
// standby routinely re-suspends the machine before the next cadence tick, so
|
|
1596
|
+
// liveness could stay stale indefinitely while IDE-hook flushes kept the
|
|
1597
|
+
// machine "online" — a live daemon displayed as down.
|
|
1598
|
+
let resumeReportInFlight = false;
|
|
1599
|
+
const reportInAfterResume = async () => {
|
|
1600
|
+
if (resumeReportInFlight)
|
|
1601
|
+
return; // a previous resume is still catching up
|
|
1602
|
+
resumeReportInFlight = true;
|
|
1603
|
+
try {
|
|
1604
|
+
for (let attempt = 1; attempt <= RESUME_REPORT_ATTEMPTS; attempt++) {
|
|
1605
|
+
let failed = false;
|
|
1606
|
+
const outcome = await (0, pollLoop_1.runWithDeadline)({
|
|
1607
|
+
run: heartbeat,
|
|
1608
|
+
deadlineMs: HEARTBEAT_DEADLINE_MS,
|
|
1609
|
+
onError: () => { failed = true; },
|
|
1610
|
+
});
|
|
1611
|
+
if (outcome === 'settled' && !failed)
|
|
1612
|
+
return; // liveness recorded
|
|
1613
|
+
if (attempt === RESUME_REPORT_ATTEMPTS) {
|
|
1614
|
+
log(`Resume report-in did not land after ${attempt} attempt(s) — the scheduled heartbeat keeps retrying.`);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
await new Promise(resolve => setTimeout(resolve, RESUME_RETRY_BASE_MS * attempt));
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
finally {
|
|
1621
|
+
resumeReportInFlight = false;
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1538
1624
|
let lastResumeBeatMs = Date.now();
|
|
1539
1625
|
const resumeTimer = setInterval(() => {
|
|
1540
1626
|
const now = Date.now();
|
|
@@ -1544,7 +1630,7 @@ async function runDaemon(args, config) {
|
|
|
1544
1630
|
return;
|
|
1545
1631
|
log(`Resume detected: ${Math.round(drift / 1000)}s of wall time elapsed while suspended — refreshing liveness and reporting in now.`);
|
|
1546
1632
|
(0, daemonForensics_1.touchDaemonAlive)();
|
|
1547
|
-
void (
|
|
1633
|
+
void reportInAfterResume();
|
|
1548
1634
|
void (0, pollLoop_1.runWithDeadline)({ run: pollBundle, deadlineMs: BUNDLE_POLL_DEADLINE_MS });
|
|
1549
1635
|
}, RESUME_BEAT_MS);
|
|
1550
1636
|
// PowerShell transcript retention (Windows): the Transcription policy FCD
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { BotGuardConfig } from '../config';
|
|
2
|
+
import { type LocalSafetySnapshot } from '../localSafetySnapshot';
|
|
3
|
+
export interface DemoActionsArgs {
|
|
4
|
+
live?: string;
|
|
5
|
+
json?: string;
|
|
6
|
+
demoPolicies?: string;
|
|
7
|
+
timeout?: string;
|
|
8
|
+
}
|
|
9
|
+
export type DemoEvent = 'shell' | 'mcp' | 'read' | 'file';
|
|
10
|
+
export interface DemoScenario {
|
|
11
|
+
id: string;
|
|
12
|
+
/** One line a security lead understands. */
|
|
13
|
+
title: string;
|
|
14
|
+
event: DemoEvent;
|
|
15
|
+
/** Payload in the exact shape the IDE hook receives. */
|
|
16
|
+
payload: Record<string, unknown>;
|
|
17
|
+
/** What the scenario is meant to show. */
|
|
18
|
+
why: string;
|
|
19
|
+
}
|
|
20
|
+
export interface DemoScenarioResult {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
event: DemoEvent;
|
|
24
|
+
action: string;
|
|
25
|
+
permission: 'allow' | 'deny' | 'ask' | 'unknown';
|
|
26
|
+
reason?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Catalog rules that matched at WARN level (recorded as findings, action allowed). Shown so
|
|
29
|
+
* an allowed credential read is not mistaken for "not noticed": the org can flip these
|
|
30
|
+
* rules to block in the console. Offline replay only.
|
|
31
|
+
*/
|
|
32
|
+
flagged?: string[];
|
|
33
|
+
/** Monitor/shadow machines allow and report what WOULD have been blocked. */
|
|
34
|
+
wouldBlock?: boolean;
|
|
35
|
+
latencyMs: number;
|
|
36
|
+
raw?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
export interface DemoActionsReport {
|
|
39
|
+
mode: 'offline' | 'live';
|
|
40
|
+
policySource: 'machine-cache' | 'demo-policies';
|
|
41
|
+
machineMode: 'block' | 'monitor' | 'shadow' | 'unknown';
|
|
42
|
+
policyCount: number;
|
|
43
|
+
shieldId: string;
|
|
44
|
+
scenarios: DemoScenarioResult[];
|
|
45
|
+
summary: {
|
|
46
|
+
allow: number;
|
|
47
|
+
deny: number;
|
|
48
|
+
ask: number;
|
|
49
|
+
wouldBlock: number;
|
|
50
|
+
unknown: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Policies seeded when the machine has no cached bundle (not enrolled). Plain
|
|
55
|
+
* action policies in the shape the engine evaluates — labelled so the output
|
|
56
|
+
* cannot be mistaken for the org's real configuration.
|
|
57
|
+
*/
|
|
58
|
+
export declare const DEMO_POLICIES: ({
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
resourceType: string;
|
|
62
|
+
rules: {
|
|
63
|
+
operations: string[];
|
|
64
|
+
verdict: string;
|
|
65
|
+
}[];
|
|
66
|
+
} | {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
resourceType: string;
|
|
70
|
+
rules: {
|
|
71
|
+
operations: string[];
|
|
72
|
+
verdict: string;
|
|
73
|
+
constraints: {
|
|
74
|
+
field: string;
|
|
75
|
+
operator: string;
|
|
76
|
+
value: string;
|
|
77
|
+
}[];
|
|
78
|
+
}[];
|
|
79
|
+
})[];
|
|
80
|
+
export declare const DEMO_SCENARIOS: DemoScenario[];
|
|
81
|
+
/**
|
|
82
|
+
* Build the throw-away HOME for an offline replay. Copies this machine's cached
|
|
83
|
+
* policy bundle when there is one (refreshed so the hook does not try to
|
|
84
|
+
* revalidate against the dead API), otherwise seeds the demo policy set.
|
|
85
|
+
*/
|
|
86
|
+
export interface OfflineHome {
|
|
87
|
+
home: string;
|
|
88
|
+
policySource: 'machine-cache' | 'demo-policies';
|
|
89
|
+
shieldId: string;
|
|
90
|
+
/** Machine mode carried over from the cache (`monitor` machines record would-block instead of denying). */
|
|
91
|
+
mode: 'block' | 'monitor' | 'shadow';
|
|
92
|
+
/** Number of org action policies in the replayed cache (0 = enrolled org without policies yet). */
|
|
93
|
+
policyCount: number;
|
|
94
|
+
}
|
|
95
|
+
export declare function prepareOfflineHome(realHome: string, shieldId: string, forceDemoPolicies?: boolean): OfflineHome;
|
|
96
|
+
interface RunOptions {
|
|
97
|
+
cliPath: string;
|
|
98
|
+
home: string;
|
|
99
|
+
/** Offline replay: explicit sandbox credentials + dead API. Live: absent — the hook loads the machine's real config (incl. DPAPI keys). */
|
|
100
|
+
sandbox?: {
|
|
101
|
+
apiUrl: string;
|
|
102
|
+
shieldId: string;
|
|
103
|
+
shieldKey: string;
|
|
104
|
+
};
|
|
105
|
+
timeoutMs: number;
|
|
106
|
+
developerId: string;
|
|
107
|
+
}
|
|
108
|
+
export declare function runScenario(scenario: DemoScenario, opts: RunOptions): DemoScenarioResult;
|
|
109
|
+
/**
|
|
110
|
+
* Which catalog rules matched at WARN level for a scenario — the same scan the hook ran
|
|
111
|
+
* (same guard, same snapshot) — so the report can show "allowed, but flagged". The hook
|
|
112
|
+
* spools these as findings; it does not put them in the verdict it hands the IDE.
|
|
113
|
+
*/
|
|
114
|
+
export declare function flaggedRules(scenario: DemoScenario, snapshot: LocalSafetySnapshot | undefined): string[];
|
|
115
|
+
export declare function renderReport(report: DemoActionsReport): string;
|
|
116
|
+
export declare function demoActionsCommand(args: DemoActionsArgs, config: BotGuardConfig): Promise<void>;
|
|
117
|
+
export {};
|