fullcourtdefense-cli 1.26.15 → 1.26.17
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/cliVersion.d.ts +11 -0
- package/dist/cliVersion.js +65 -0
- package/dist/commands/ciProtect.d.ts +2 -0
- package/dist/commands/ciProtect.js +18 -85
- package/dist/commands/daemon.js +25 -6
- 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 +176 -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/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
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installed CLI version.
|
|
3
|
+
*
|
|
4
|
+
* `process.env.npm_package_version` is only populated when npm itself runs the
|
|
5
|
+
* script (`npm run …`). A container entrypoint, an MSI install, or a CI step
|
|
6
|
+
* calling the `fullcourtdefense` binary directly gets `undefined` — which is how
|
|
7
|
+
* enrolled workloads ended up with a blank CLI version in the fleet console.
|
|
8
|
+
* Resolve it from disk instead: the build stamp `dist/version.json` first
|
|
9
|
+
* (same source `--version` prints), then the package manifest.
|
|
10
|
+
*/
|
|
11
|
+
export declare function cliVersion(): string | undefined;
|
|
@@ -0,0 +1,65 @@
|
|
|
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.cliVersion = cliVersion;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
/**
|
|
40
|
+
* The installed CLI version.
|
|
41
|
+
*
|
|
42
|
+
* `process.env.npm_package_version` is only populated when npm itself runs the
|
|
43
|
+
* script (`npm run …`). A container entrypoint, an MSI install, or a CI step
|
|
44
|
+
* calling the `fullcourtdefense` binary directly gets `undefined` — which is how
|
|
45
|
+
* enrolled workloads ended up with a blank CLI version in the fleet console.
|
|
46
|
+
* Resolve it from disk instead: the build stamp `dist/version.json` first
|
|
47
|
+
* (same source `--version` prints), then the package manifest.
|
|
48
|
+
*/
|
|
49
|
+
function cliVersion() {
|
|
50
|
+
const candidates = [
|
|
51
|
+
path.join(__dirname, 'version.json'),
|
|
52
|
+
path.resolve(__dirname, '..', 'package.json'),
|
|
53
|
+
];
|
|
54
|
+
for (const file of candidates) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
57
|
+
if (typeof parsed.version === 'string' && parsed.version)
|
|
58
|
+
return parsed.version;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// try the next location
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
@@ -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,10 @@
|
|
|
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
|
|
6
|
+
const cliVersion_1 = require("../cliVersion");
|
|
7
|
+
const ephemeralStack_1 = require("./ephemeralStack");
|
|
42
8
|
/** Detect the pipeline from standard CI env vars (GitHub Actions, GitLab CI). */
|
|
43
9
|
function detectCiContext(env = process.env) {
|
|
44
10
|
if (env.GITHUB_ACTIONS === 'true') {
|
|
@@ -89,6 +55,9 @@ async function ciProtectCommand(args, config) {
|
|
|
89
55
|
throw new Error('An organization API key is required. Add FCD_API_KEY to the repo/pipeline secrets '
|
|
90
56
|
+ '(create one in the dashboard: Workspace → API keys) or pass --api-key.');
|
|
91
57
|
}
|
|
58
|
+
// Before any network call or file write: never overwrite a developer's enrollment
|
|
59
|
+
// (self-hosted runner that is also someone's workstation).
|
|
60
|
+
(0, ephemeralStack_1.assertNotEnrolledLaptop)('pipeline', { force: args.force === 'true' });
|
|
92
61
|
const detected = detectCiContext();
|
|
93
62
|
const provider = (args.provider || detected?.provider || 'generic-ci').trim();
|
|
94
63
|
const repo = (args.repo || detected?.repo || '').trim();
|
|
@@ -113,7 +82,7 @@ async function ciProtectCommand(args, config) {
|
|
|
113
82
|
workflow,
|
|
114
83
|
runId,
|
|
115
84
|
runUrl,
|
|
116
|
-
cliVersion:
|
|
85
|
+
cliVersion: (0, cliVersion_1.cliVersion)(),
|
|
117
86
|
}),
|
|
118
87
|
});
|
|
119
88
|
const data = (await resp.json().catch(() => ({})));
|
|
@@ -129,62 +98,26 @@ async function ciProtectCommand(args, config) {
|
|
|
129
98
|
shieldKey: result.shieldKey,
|
|
130
99
|
apiUrl,
|
|
131
100
|
});
|
|
101
|
+
(0, ephemeralStack_1.writeEphemeralIdentityMarker)('pipeline', result.machineId);
|
|
132
102
|
// Attribute every subsequent hook/gateway event in this job to the PIPELINE
|
|
133
103
|
// machine record: current process + GITHUB_ENV for the steps that follow
|
|
134
104
|
// (where the AI agent actually runs).
|
|
135
|
-
|
|
105
|
+
(0, ephemeralStack_1.exportIdentityEnv)({
|
|
136
106
|
FCD_MACHINE_ID: result.machineId,
|
|
137
107
|
FCD_DEVELOPER_NAME: `ci@${repo.toLowerCase()}`,
|
|
138
108
|
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
|
-
}
|
|
109
|
+
}, { githubEnvPath: process.env.GITHUB_ENV });
|
|
148
110
|
console.log(`\x1b[32m✓ ${result.reused ? 'Pipeline re-enrolled' : 'Pipeline enrolled'}\x1b[0m ${result.shieldName}`);
|
|
149
111
|
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
|
-
}
|
|
112
|
+
// Same protection stack as laptops (shared with workload-protect).
|
|
113
|
+
await (0, ephemeralStack_1.installEphemeralProtectionStack)({
|
|
114
|
+
shieldId: result.shieldId,
|
|
115
|
+
shieldKey: result.shieldKey,
|
|
116
|
+
apiUrl,
|
|
117
|
+
hooks: args.hooks,
|
|
118
|
+
gateway: args.gateway,
|
|
119
|
+
sendLog,
|
|
120
|
+
}, config);
|
|
188
121
|
console.log('\n\x1b[32mDone.\x1b[0m This job\'s AI tool calls are now policy-checked and streamed to the fleet console.');
|
|
189
122
|
console.log(`Pipeline appears in AI Fleet → Machines → CI pipelines as \x1b[1m${repo} · ${workflow}\x1b[0m.`);
|
|
190
123
|
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
|
@@ -590,30 +590,49 @@ async function runDaemon(args, config) {
|
|
|
590
590
|
* back within one heartbeat instead of up to an hour later (the 8/12
|
|
591
591
|
* incident: re-enroll fixed the hooks instantly while the daemon kept
|
|
592
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.
|
|
593
601
|
*/
|
|
594
602
|
const recoverCredentialsIfMissing = () => {
|
|
595
|
-
if (creds.shieldId && creds.shieldKey)
|
|
596
|
-
return;
|
|
597
603
|
let enrollmentChanged = false;
|
|
598
604
|
try {
|
|
599
605
|
const mtime = fs.statSync((0, config_1.getHomeConfigPath)()).mtimeMs;
|
|
600
|
-
|
|
606
|
+
// First observation only records the baseline; a later different mtime is a re-enroll.
|
|
607
|
+
enrollmentChanged = lastEnrollmentMtimeMs !== 0 && mtime !== lastEnrollmentMtimeMs;
|
|
601
608
|
lastEnrollmentMtimeMs = mtime;
|
|
602
609
|
}
|
|
603
610
|
catch { /* no config file — the tick cadence below applies */ }
|
|
604
|
-
|
|
605
|
-
if (!
|
|
611
|
+
const missing = !(creds.shieldId && creds.shieldKey);
|
|
612
|
+
if (!missing && !enrollmentChanged)
|
|
606
613
|
return;
|
|
614
|
+
if (missing) {
|
|
615
|
+
credRecoveryTicks += 1;
|
|
616
|
+
if (!enrollmentChanged && credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
607
619
|
try {
|
|
608
620
|
const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
|
|
609
621
|
shieldId: args.shieldId,
|
|
610
622
|
shieldKey: args.shieldKey,
|
|
611
623
|
apiUrl: args.apiUrl,
|
|
612
624
|
});
|
|
613
|
-
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) {
|
|
614
629
|
Object.assign(creds, fresh);
|
|
615
630
|
log('Credentials recovered — telemetry and control-plane sync restored.');
|
|
616
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
|
+
}
|
|
617
636
|
}
|
|
618
637
|
catch { /* next tick */ }
|
|
619
638
|
};
|
|
@@ -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 {};
|