fullcourtdefense-cli 1.21.11 → 1.21.12

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.
@@ -51,6 +51,7 @@ const integrity_1 = require("../integrity");
51
51
  const machineIdentity_1 = require("../machineIdentity");
52
52
  const discoveryMarker_1 = require("../discoveryMarker");
53
53
  const selfUpdate_1 = require("../selfUpdate");
54
+ const machineActionVerify_1 = require("../machineActionVerify");
54
55
  const desktopChatGuard_1 = require("./desktopChatGuard");
55
56
  const COLOR = {
56
57
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -531,6 +532,25 @@ async function runDaemon(args, config) {
531
532
  if (executingActionIds.has(action.id))
532
533
  return;
533
534
  executingActionIds.add(action.id);
535
+ // Cryptographic gate: never execute an action the control plane didn't
536
+ // sign — a Firestore/backend compromise must not become fleet-wide RCE.
537
+ const verdict = (0, machineActionVerify_1.verifyMachineAction)(action);
538
+ if (!verdict.ok) {
539
+ log(`Remote action REJECTED: ${action.type} (${action.id}) — ${verdict.reason}`);
540
+ await reportMachineAction(action.id, 'failed', { error: `Signature verification failed: ${verdict.reason}` });
541
+ executingActionIds.delete(action.id);
542
+ await uploadLogTail();
543
+ return;
544
+ }
545
+ if (verdict.reason)
546
+ log(`Remote action signature notice: ${verdict.reason}`);
547
+ const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
548
+ if (action.machineId && action.machineId !== localIdentity.machineId) {
549
+ // Warn-only for now: enrolled machines can carry legacy fingerprints that
550
+ // differ from the freshly computed identity. Tighten to reject once the
551
+ // fleet is fully on stable machine ids.
552
+ log(`Remote action machine-binding notice: action targets ${action.machineId}, local id is ${localIdentity.machineId}.`);
553
+ }
534
554
  await reportMachineAction(action.id, 'running');
535
555
  log(`Remote action started: ${action.type} (${action.id}).`);
536
556
  await uploadLogTail();
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Ed25519 verification for remote machine actions pulled from the control
3
+ * plane (GET /api/cli/bundle -> machineAction).
4
+ *
5
+ * The daemon must not execute an action just because the backend returned it:
6
+ * a compromised backend/Firestore or stolen admin session could queue actions
7
+ * for the whole fleet. Every action is signed server-side with a private key
8
+ * held in GCP Secret Manager; this module re-builds the canonical payload and
9
+ * verifies the signature against the public key pinned below.
10
+ *
11
+ * Enforcement: when a pinned/override public key is available, actions with a
12
+ * missing or invalid signature are REJECTED. `FCD_MACHINE_ACTION_UNSIGNED_OK=1`
13
+ * is an explicit, loudly-logged local escape hatch for rollout emergencies.
14
+ *
15
+ * Keep the canonical payload construction in sync with
16
+ * backend/src/modules/discovery/machine-action-signing.ts.
17
+ */
18
+ export declare const MACHINE_ACTION_SIGNATURE_VERSION = "fcd-machine-action-v1";
19
+ export interface VerifiableMachineAction {
20
+ id: string;
21
+ type: string;
22
+ reason: string;
23
+ createdAt: string;
24
+ expiresAt: string;
25
+ organizationId?: string;
26
+ machineId?: string;
27
+ signature?: {
28
+ signature: string;
29
+ keyId: string;
30
+ alg: string;
31
+ version: string;
32
+ };
33
+ }
34
+ export interface MachineActionVerdict {
35
+ ok: boolean;
36
+ reason?: string;
37
+ }
38
+ /**
39
+ * Verify a machine action before execution. Rejects when:
40
+ * - the signature is missing (unless the local escape hatch is set),
41
+ * - the signature's keyId is unknown,
42
+ * - the Ed25519 verification fails (any signed field was tampered with),
43
+ * - the action is already expired.
44
+ */
45
+ export declare function verifyMachineAction(action: VerifiableMachineAction): MachineActionVerdict;
@@ -0,0 +1,134 @@
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.MACHINE_ACTION_SIGNATURE_VERSION = void 0;
37
+ exports.verifyMachineAction = verifyMachineAction;
38
+ const crypto = __importStar(require("crypto"));
39
+ /**
40
+ * Ed25519 verification for remote machine actions pulled from the control
41
+ * plane (GET /api/cli/bundle -> machineAction).
42
+ *
43
+ * The daemon must not execute an action just because the backend returned it:
44
+ * a compromised backend/Firestore or stolen admin session could queue actions
45
+ * for the whole fleet. Every action is signed server-side with a private key
46
+ * held in GCP Secret Manager; this module re-builds the canonical payload and
47
+ * verifies the signature against the public key pinned below.
48
+ *
49
+ * Enforcement: when a pinned/override public key is available, actions with a
50
+ * missing or invalid signature are REJECTED. `FCD_MACHINE_ACTION_UNSIGNED_OK=1`
51
+ * is an explicit, loudly-logged local escape hatch for rollout emergencies.
52
+ *
53
+ * Keep the canonical payload construction in sync with
54
+ * backend/src/modules/discovery/machine-action-signing.ts.
55
+ */
56
+ exports.MACHINE_ACTION_SIGNATURE_VERSION = 'fcd-machine-action-v1';
57
+ /**
58
+ * Production control-plane public key (keyId v1). Rotations ship a new CLI
59
+ * release with the additional key appended.
60
+ */
61
+ const EMBEDDED_PUBLIC_KEYS_PEM = {
62
+ v1: `-----BEGIN PUBLIC KEY-----
63
+ MCowBQYDK2VwAyEAbzPhAyATO3n6FL0rfPmlmFa8xz7VABNlU9iOh0GwucI=
64
+ -----END PUBLIC KEY-----`,
65
+ };
66
+ function toIso(value) {
67
+ const parsed = new Date(value);
68
+ return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString();
69
+ }
70
+ function canonicalPayload(action) {
71
+ return Buffer.from(JSON.stringify([
72
+ exports.MACHINE_ACTION_SIGNATURE_VERSION,
73
+ action.id,
74
+ action.organizationId || '',
75
+ action.machineId || '',
76
+ action.type,
77
+ action.reason,
78
+ toIso(action.createdAt),
79
+ toIso(action.expiresAt),
80
+ ]), 'utf8');
81
+ }
82
+ /** Resolve the verification key for a signature keyId (env override wins — staging/tests). */
83
+ function resolvePublicKeyPem(keyId) {
84
+ const override = process.env.FCD_MACHINE_ACTION_PUBLIC_KEY?.trim();
85
+ if (override) {
86
+ return override.includes('-----BEGIN')
87
+ ? override
88
+ : Buffer.from(override, 'base64').toString('utf8');
89
+ }
90
+ return EMBEDDED_PUBLIC_KEYS_PEM[keyId];
91
+ }
92
+ function unsignedAllowed() {
93
+ return process.env.FCD_MACHINE_ACTION_UNSIGNED_OK === '1';
94
+ }
95
+ /**
96
+ * Verify a machine action before execution. Rejects when:
97
+ * - the signature is missing (unless the local escape hatch is set),
98
+ * - the signature's keyId is unknown,
99
+ * - the Ed25519 verification fails (any signed field was tampered with),
100
+ * - the action is already expired.
101
+ */
102
+ function verifyMachineAction(action) {
103
+ const expiresAt = Date.parse(action.expiresAt);
104
+ if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
105
+ return { ok: false, reason: 'Action is expired.' };
106
+ }
107
+ if (!action.signature?.signature) {
108
+ if (unsignedAllowed()) {
109
+ return { ok: true, reason: 'UNSIGNED action accepted via FCD_MACHINE_ACTION_UNSIGNED_OK=1.' };
110
+ }
111
+ return {
112
+ ok: false,
113
+ reason: 'Action is not signed by the control plane. Rejected: this CLI requires Ed25519-signed remote actions (provision MACHINE_ACTION_SIGNING_KEY on the backend).',
114
+ };
115
+ }
116
+ const { signature } = action;
117
+ if (signature.alg !== 'ed25519' || signature.version !== exports.MACHINE_ACTION_SIGNATURE_VERSION) {
118
+ return { ok: false, reason: `Unsupported signature scheme (${signature.alg}/${signature.version}).` };
119
+ }
120
+ const publicKeyPem = resolvePublicKeyPem(signature.keyId);
121
+ if (!publicKeyPem) {
122
+ return { ok: false, reason: `No pinned public key for signing keyId "${signature.keyId}" — upgrade the CLI or check FCD_MACHINE_ACTION_PUBLIC_KEY.` };
123
+ }
124
+ try {
125
+ const key = crypto.createPublicKey(publicKeyPem);
126
+ const valid = crypto.verify(null, canonicalPayload(action), key, Buffer.from(signature.signature, 'base64'));
127
+ return valid
128
+ ? { ok: true }
129
+ : { ok: false, reason: 'Invalid signature — the action payload does not match what the control plane signed.' };
130
+ }
131
+ catch (error) {
132
+ return { ok: false, reason: `Signature verification error: ${error.message}` };
133
+ }
134
+ }
@@ -40,6 +40,16 @@ export interface RuntimeBundle {
40
40
  reason: string;
41
41
  createdAt: string;
42
42
  expiresAt: string;
43
+ /** Signed fields — present so the daemon can re-build the canonical payload. */
44
+ organizationId?: string;
45
+ machineId?: string;
46
+ /** Ed25519 control-plane signature; the daemon rejects unsigned/invalid actions. */
47
+ signature?: {
48
+ signature: string;
49
+ keyId: string;
50
+ alg: string;
51
+ version: string;
52
+ };
43
53
  };
44
54
  }
45
55
  export interface FetchBundleInput {
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.21.11"
2
+ "version": "1.21.12"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.21.11",
3
+ "version": "1.21.12",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -31,6 +31,7 @@
31
31
  "test:posture-blast": "npm run build && node scripts/test-posture-blast-radius.js",
32
32
  "test:ping-e2e": "npm run build && node scripts/test-ping-e2e.js",
33
33
  "test:daemon-e2e": "npm run build && node scripts/test-daemon-e2e.js",
34
+ "test:machine-action-signing": "npm run build && node scripts/test-machine-action-signing.js",
34
35
  "test:integrity": "npm run build && node scripts/test-integrity.js",
35
36
  "test:posture-overhaul": "npm run build && node scripts/test-posture-overhaul.js",
36
37
  "test:dpapi-config": "npm run build && node scripts/test-dpapi-config.js",