fullcourtdefense-cli 1.22.7 → 1.23.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/backupManifest.d.ts +7 -0
- package/dist/backupManifest.js +108 -0
- package/dist/commands/ciProtect.d.ts +42 -0
- package/dist/commands/ciProtect.js +191 -0
- package/dist/commands/daemon.js +2 -0
- package/dist/commands/deterministicGuard.js +18 -3
- package/dist/commands/discover.js +5 -0
- package/dist/commands/hook.js +10 -0
- package/dist/commands/installAll.js +10 -4
- package/dist/commands/installCursorHook.js +6 -2
- package/dist/commands/mcpGateway.js +13 -0
- package/dist/commands/onboard.d.ts +2 -0
- package/dist/commands/onboard.js +53 -2
- package/dist/commands/uninstallAll.d.ts +58 -9
- package/dist/commands/uninstallAll.js +359 -37
- package/dist/commands/verifyRemoved.d.ts +24 -0
- package/dist/commands/verifyRemoved.js +293 -0
- package/dist/commands/windowsAudit.d.ts +10 -5
- package/dist/commands/windowsAudit.js +47 -8
- package/dist/index.js +60 -7
- package/dist/localSafetySnapshot.js +6 -3
- package/dist/machineIdentity.js +16 -0
- package/dist/selfTest.js +31 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function backupManifestPath(): string;
|
|
2
|
+
/** Every recorded backup path (absent/corrupt manifest = empty list). */
|
|
3
|
+
export declare function readBackupManifest(): string[];
|
|
4
|
+
/** Record one backup file path. Deduped, capped, never throws. */
|
|
5
|
+
export declare function recordConfigBackup(backupPath: string): void;
|
|
6
|
+
/** Drop entries whose backup file no longer exists (post-cleanup compaction). */
|
|
7
|
+
export declare function pruneBackupManifest(): void;
|
|
@@ -0,0 +1,108 @@
|
|
|
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.backupManifestPath = backupManifestPath;
|
|
37
|
+
exports.readBackupManifest = readBackupManifest;
|
|
38
|
+
exports.recordConfigBackup = recordConfigBackup;
|
|
39
|
+
exports.pruneBackupManifest = pruneBackupManifest;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
/**
|
|
44
|
+
* Ledger of every `*.fcd-backup-*` config safety copy the CLI creates.
|
|
45
|
+
*
|
|
46
|
+
* MCP-config edits back the original file up next to it — including
|
|
47
|
+
* PROJECT-LOCAL configs (a repo's .cursor/mcp.json), which live in places no
|
|
48
|
+
* directory scan can enumerate. Those backups can embed live credentials, so
|
|
49
|
+
* `uninstall` / `verify-removed` must be able to find every one of them: each
|
|
50
|
+
* backup path is recorded here at creation time and the manifest is the
|
|
51
|
+
* authoritative list at cleanup time (union-ed with the known-dir scan for
|
|
52
|
+
* backups created by older CLI versions).
|
|
53
|
+
*
|
|
54
|
+
* Best-effort by design: a manifest write failure must never break a protect
|
|
55
|
+
* operation, and cleanup falls back to the directory scan.
|
|
56
|
+
*/
|
|
57
|
+
const MANIFEST_NAME = 'backup-manifest.json';
|
|
58
|
+
const MAX_ENTRIES = 2000;
|
|
59
|
+
function backupManifestPath() {
|
|
60
|
+
return path.join(os.homedir(), '.fullcourtdefense', MANIFEST_NAME);
|
|
61
|
+
}
|
|
62
|
+
/** Every recorded backup path (absent/corrupt manifest = empty list). */
|
|
63
|
+
function readBackupManifest() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = JSON.parse(fs.readFileSync(backupManifestPath(), 'utf8'));
|
|
66
|
+
const entries = Array.isArray(raw?.backups) ? raw.backups : [];
|
|
67
|
+
return entries.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Record one backup file path. Deduped, capped, never throws. */
|
|
74
|
+
function recordConfigBackup(backupPath) {
|
|
75
|
+
try {
|
|
76
|
+
const existing = readBackupManifest();
|
|
77
|
+
if (existing.includes(backupPath))
|
|
78
|
+
return;
|
|
79
|
+
const entries = [...existing, backupPath].slice(-MAX_ENTRIES);
|
|
80
|
+
const file = backupManifestPath();
|
|
81
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
82
|
+
fs.writeFileSync(file, `${JSON.stringify({ backups: entries }, null, 2)}\n`, 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch { /* best effort — cleanup still has the directory scan */ }
|
|
85
|
+
}
|
|
86
|
+
/** Drop entries whose backup file no longer exists (post-cleanup compaction). */
|
|
87
|
+
function pruneBackupManifest() {
|
|
88
|
+
try {
|
|
89
|
+
const existing = readBackupManifest();
|
|
90
|
+
const remaining = existing.filter(entry => {
|
|
91
|
+
try {
|
|
92
|
+
return fs.existsSync(entry);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
if (remaining.length === existing.length)
|
|
99
|
+
return;
|
|
100
|
+
const file = backupManifestPath();
|
|
101
|
+
if (remaining.length === 0) {
|
|
102
|
+
fs.rmSync(file, { force: true });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
fs.writeFileSync(file, `${JSON.stringify({ backups: remaining }, null, 2)}\n`, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
catch { /* best effort */ }
|
|
108
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { BotGuardConfig } from '../config';
|
|
2
|
+
/**
|
|
3
|
+
* `fullcourtdefense ci-protect` — one-command runtime protection for CI jobs.
|
|
4
|
+
*
|
|
5
|
+
* A CI runner is born, runs one job with an AI agent inside, and dies — no
|
|
6
|
+
* human to click a consent dialog, no durable disk for a fleet token. So the
|
|
7
|
+
* flow differs from laptop onboarding in exactly two ways:
|
|
8
|
+
*
|
|
9
|
+
* 1. Auth is the org API key (FCD_API_KEY repo secret) — the admin who put
|
|
10
|
+
* it there consented for the pipeline.
|
|
11
|
+
* 2. Fleet identity is the PIPELINE (provider/repo/workflow), not the
|
|
12
|
+
* runner: the backend converges every run onto one machine record, and
|
|
13
|
+
* we export FCD_MACHINE_ID so the hooks installed here attribute all
|
|
14
|
+
* events to that pipeline record.
|
|
15
|
+
*
|
|
16
|
+
* Everything else is the exact laptop stack: same Claude-format hook, same
|
|
17
|
+
* MCP gateway, same vendored policy engine enforcing offline.
|
|
18
|
+
*/
|
|
19
|
+
export interface CiProtectArgs {
|
|
20
|
+
apiKey?: string;
|
|
21
|
+
apiUrl?: string;
|
|
22
|
+
provider?: string;
|
|
23
|
+
repo?: string;
|
|
24
|
+
workflow?: string;
|
|
25
|
+
runId?: string;
|
|
26
|
+
runUrl?: string;
|
|
27
|
+
/** 'false' => skip installing the Claude-format runtime hook. */
|
|
28
|
+
hooks?: string;
|
|
29
|
+
/** 'false' => skip wrapping MCP client configs with the gateway. */
|
|
30
|
+
gateway?: string;
|
|
31
|
+
}
|
|
32
|
+
interface CiContext {
|
|
33
|
+
provider: string;
|
|
34
|
+
repo?: string;
|
|
35
|
+
workflow?: string;
|
|
36
|
+
runId?: string;
|
|
37
|
+
runUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Detect the pipeline from standard CI env vars (GitHub Actions, GitLab CI). */
|
|
40
|
+
export declare function detectCiContext(env?: NodeJS.ProcessEnv): CiContext | undefined;
|
|
41
|
+
export declare function ciProtectCommand(args: CiProtectArgs, config: BotGuardConfig): Promise<void>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
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.detectCiContext = detectCiContext;
|
|
37
|
+
exports.ciProtectCommand = ciProtectCommand;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const config_1 = require("../config");
|
|
40
|
+
const installClaudeHook_1 = require("./installClaudeHook");
|
|
41
|
+
const mcpGateway_1 = require("./mcpGateway");
|
|
42
|
+
/** Detect the pipeline from standard CI env vars (GitHub Actions, GitLab CI). */
|
|
43
|
+
function detectCiContext(env = process.env) {
|
|
44
|
+
if (env.GITHUB_ACTIONS === 'true') {
|
|
45
|
+
const repo = env.GITHUB_REPOSITORY || undefined;
|
|
46
|
+
const runId = env.GITHUB_RUN_ID || undefined;
|
|
47
|
+
return {
|
|
48
|
+
provider: 'github-actions',
|
|
49
|
+
repo,
|
|
50
|
+
workflow: env.GITHUB_WORKFLOW || undefined,
|
|
51
|
+
runId,
|
|
52
|
+
runUrl: repo && runId ? `${env.GITHUB_SERVER_URL || 'https://github.com'}/${repo}/actions/runs/${runId}` : undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (env.GITLAB_CI === 'true') {
|
|
56
|
+
return {
|
|
57
|
+
provider: 'gitlab-ci',
|
|
58
|
+
repo: env.CI_PROJECT_PATH || undefined,
|
|
59
|
+
workflow: env.CI_JOB_NAME || env.CI_PIPELINE_NAME || undefined,
|
|
60
|
+
runId: env.CI_PIPELINE_ID || undefined,
|
|
61
|
+
runUrl: env.CI_PIPELINE_URL || undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Best-effort log streaming to the dashboard's CI Pipelines page — the admin
|
|
68
|
+
* watches this job's protection steps live without opening GitHub. Never
|
|
69
|
+
* throws and never blocks the job for more than a few seconds.
|
|
70
|
+
*/
|
|
71
|
+
function ciLogSender(apiUrl, apiKey, ctx) {
|
|
72
|
+
return async (lines, status) => {
|
|
73
|
+
try {
|
|
74
|
+
await fetch(`${apiUrl}/api/cli/ci-log`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
77
|
+
body: JSON.stringify({ ...ctx, lines, status }),
|
|
78
|
+
signal: AbortSignal.timeout(4000),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch { /* log delivery is best-effort */ }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function ciProtectCommand(args, config) {
|
|
85
|
+
const creds = (0, config_1.resolveCliCredentials)(config, { apiUrl: args.apiUrl });
|
|
86
|
+
const apiUrl = creds.apiUrl;
|
|
87
|
+
const apiKey = (args.apiKey || process.env.FCD_API_KEY || '').trim();
|
|
88
|
+
if (!apiKey) {
|
|
89
|
+
throw new Error('An organization API key is required. Add FCD_API_KEY to the repo/pipeline secrets '
|
|
90
|
+
+ '(create one in the dashboard: Workspace → API keys) or pass --api-key.');
|
|
91
|
+
}
|
|
92
|
+
const detected = detectCiContext();
|
|
93
|
+
const provider = (args.provider || detected?.provider || 'generic-ci').trim();
|
|
94
|
+
const repo = (args.repo || detected?.repo || '').trim();
|
|
95
|
+
const workflow = (args.workflow || detected?.workflow || '').trim();
|
|
96
|
+
if (!repo || !workflow) {
|
|
97
|
+
throw new Error('Could not detect the pipeline identity. On GitHub Actions / GitLab CI it is auto-detected; '
|
|
98
|
+
+ 'elsewhere pass --repo <org/repo> --workflow <name>.');
|
|
99
|
+
}
|
|
100
|
+
const runId = (args.runId || detected?.runId || '').trim() || undefined;
|
|
101
|
+
const runUrl = (args.runUrl || detected?.runUrl || '').trim() || undefined;
|
|
102
|
+
console.log('\x1b[1m\x1b[36mFullCourtDefense — CI runtime protection\x1b[0m');
|
|
103
|
+
console.log(` Pipeline: ${provider}/${repo}/${workflow}`);
|
|
104
|
+
if (runId)
|
|
105
|
+
console.log(` Run: ${runId}`);
|
|
106
|
+
const sendLog = ciLogSender(apiUrl, apiKey, { provider, repo, workflow, runId, runUrl });
|
|
107
|
+
const resp = await fetch(`${apiUrl}/api/cli/enroll/ci`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
provider,
|
|
112
|
+
repo,
|
|
113
|
+
workflow,
|
|
114
|
+
runId,
|
|
115
|
+
runUrl,
|
|
116
|
+
cliVersion: process.env.npm_package_version,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
const data = (await resp.json().catch(() => ({})));
|
|
120
|
+
if (!resp.ok || data.success === false || !data.data) {
|
|
121
|
+
const reason = data.error || `CI enrollment failed (HTTP ${resp.status})`;
|
|
122
|
+
await sendLog([{ level: 'error', message: `Enrollment failed: ${reason}` }], 'failed');
|
|
123
|
+
throw new Error(reason);
|
|
124
|
+
}
|
|
125
|
+
const result = data.data;
|
|
126
|
+
const savedPath = (0, config_1.saveSetupConfig)({
|
|
127
|
+
organizationId: result.organizationId,
|
|
128
|
+
shieldId: result.shieldId,
|
|
129
|
+
shieldKey: result.shieldKey,
|
|
130
|
+
apiUrl,
|
|
131
|
+
});
|
|
132
|
+
// Attribute every subsequent hook/gateway event in this job to the PIPELINE
|
|
133
|
+
// machine record: current process + GITHUB_ENV for the steps that follow
|
|
134
|
+
// (where the AI agent actually runs).
|
|
135
|
+
const identityEnv = {
|
|
136
|
+
FCD_MACHINE_ID: result.machineId,
|
|
137
|
+
FCD_DEVELOPER_NAME: `ci@${repo.toLowerCase()}`,
|
|
138
|
+
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
|
+
}
|
|
148
|
+
console.log(`\x1b[32m✓ ${result.reused ? 'Pipeline re-enrolled' : 'Pipeline enrolled'}\x1b[0m ${result.shieldName}`);
|
|
149
|
+
console.log(` Mode: ${result.enforcementMode} · Config: ${savedPath}`);
|
|
150
|
+
// Same protection stack as laptops. The Claude-format hook covers Claude
|
|
151
|
+
// Code, VS Code agent mode, and Copilot CLI — the common CI agents.
|
|
152
|
+
if (args.hooks !== 'false') {
|
|
153
|
+
console.log('\n\x1b[1mInstalling runtime hook (Claude Code / VS Code / Copilot CLI)…\x1b[0m');
|
|
154
|
+
const hookArgs = {
|
|
155
|
+
shieldId: result.shieldId,
|
|
156
|
+
shieldKey: result.shieldKey,
|
|
157
|
+
apiUrl,
|
|
158
|
+
events: 'tools,prompt',
|
|
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
|
+
}
|
|
188
|
+
console.log('\n\x1b[32mDone.\x1b[0m This job\'s AI tool calls are now policy-checked and streamed to the fleet console.');
|
|
189
|
+
console.log(`Pipeline appears in AI Fleet → Machines → CI pipelines as \x1b[1m${repo} · ${workflow}\x1b[0m.`);
|
|
190
|
+
await sendLog([{ level: 'success', message: `Protection active (${result.enforcementMode} mode) — this job's AI tool calls are policy-checked and recorded` }], 'succeeded');
|
|
191
|
+
}
|
package/dist/commands/daemon.js
CHANGED
|
@@ -1187,6 +1187,8 @@ async function runDaemon(args, config) {
|
|
|
1187
1187
|
const result = (0, windowsAudit_1.pruneTranscripts)();
|
|
1188
1188
|
if (result.pruned > 0)
|
|
1189
1189
|
log(`Transcript retention: pruned ${result.pruned} transcript file(s) older than ${(0, windowsAudit_1.transcriptRetentionDays)()} day(s).`);
|
|
1190
|
+
if ((result.prunedBySize || 0) > 0)
|
|
1191
|
+
log(`Transcript retention: pruned ${result.prunedBySize} more transcript file(s) (oldest first) to stay under the ${(0, windowsAudit_1.transcriptMaxMb)()}MB size budget.`);
|
|
1190
1192
|
}
|
|
1191
1193
|
catch { /* never let housekeeping hurt the daemon */ }
|
|
1192
1194
|
};
|
|
@@ -258,9 +258,18 @@ function disabled(options) {
|
|
|
258
258
|
function isEnabled(itemId, options) {
|
|
259
259
|
return !disabled(options).has(itemId);
|
|
260
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Items that WARN by default (catalog-level default action). Applies only when
|
|
263
|
+
* the snapshot carries no explicit action for the item — an org override to
|
|
264
|
+
* 'block' ships explicitly in the snapshot and wins. Keeps offline machines
|
|
265
|
+
* and stale snapshots on the warn-first rollout instead of hard-blocking.
|
|
266
|
+
*/
|
|
267
|
+
const DEFAULT_WARN_BUILT_INS = new Set(['env_workspace']);
|
|
261
268
|
function actionFor(itemId, options) {
|
|
262
269
|
const action = options?.itemActions?.[itemId];
|
|
263
|
-
|
|
270
|
+
if (action === 'warn' || action === 'mask' || action === 'block')
|
|
271
|
+
return action;
|
|
272
|
+
return DEFAULT_WARN_BUILT_INS.has(itemId) ? 'warn' : 'block';
|
|
264
273
|
}
|
|
265
274
|
function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence, explanation, options) {
|
|
266
275
|
if (!isEnabled(itemId, options))
|
|
@@ -396,8 +405,14 @@ function containsSensitiveCredentialPath(value) {
|
|
|
396
405
|
if (check.re.test(text))
|
|
397
406
|
return { itemId: check.itemId, label: check.label };
|
|
398
407
|
}
|
|
399
|
-
if (
|
|
400
|
-
|
|
408
|
+
if (/(?:^|\/)\.env(?:\.[a-z0-9_-]+)?(?:$|[/?#\s'"])/.test(text)) {
|
|
409
|
+
// Traversal to a .env outside the workspace stays a hard block
|
|
410
|
+
// (env_traversal); a plain workspace .env is a separate warn-by-default
|
|
411
|
+
// item (env_workspace) — agents legitimately read it during development,
|
|
412
|
+
// but the fleet should still see every touch.
|
|
413
|
+
return traversal
|
|
414
|
+
? { itemId: 'env_traversal', label: 'environment file via path traversal' }
|
|
415
|
+
: { itemId: 'env_workspace', label: 'workspace environment file' };
|
|
401
416
|
}
|
|
402
417
|
return undefined;
|
|
403
418
|
}
|
|
@@ -1097,6 +1097,11 @@ async function discoverCommand(args, config) {
|
|
|
1097
1097
|
printAgentFilesReport(agentFiles, silent);
|
|
1098
1098
|
if (posture)
|
|
1099
1099
|
printPostureReport(posture, silent);
|
|
1100
|
+
// Explicit scope boundary: a broad security/credential report during an
|
|
1101
|
+
// active incident can reasonably be over-read as endpoint protection.
|
|
1102
|
+
if (!silent) {
|
|
1103
|
+
console.log(`${COLOR.gray}Scope: this discovery inventories AI agents, MCP servers, credentials, and machine posture. It is NOT antivirus/EDR — binaries, running processes, and quarantined files are not scanned. Keep your endpoint protection (e.g. Microsoft Defender) active alongside it.${COLOR.reset}`);
|
|
1104
|
+
}
|
|
1100
1105
|
if (uploadRequested) {
|
|
1101
1106
|
console.log('');
|
|
1102
1107
|
await maybeUpload();
|
package/dist/commands/hook.js
CHANGED
|
@@ -47,6 +47,7 @@ const taintLedger_1 = require("./taintLedger");
|
|
|
47
47
|
const notify_1 = require("../notify");
|
|
48
48
|
const distress_1 = require("../distress");
|
|
49
49
|
const policyGateHealth_1 = require("../policyGateHealth");
|
|
50
|
+
const machineIdentity_1 = require("../machineIdentity");
|
|
50
51
|
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
51
52
|
const DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
|
|
52
53
|
/**
|
|
@@ -533,7 +534,16 @@ function machineMetadata(client = 'cursor') {
|
|
|
533
534
|
username = os.userInfo().username;
|
|
534
535
|
}
|
|
535
536
|
catch { /* ignore */ }
|
|
537
|
+
// Stable fleet machine identity: lets the backend attribute this decision to
|
|
538
|
+
// THIS machine exactly (timeline + behavior baseline), instead of matching
|
|
539
|
+
// by hostname. Best-effort — identity must never break the hook.
|
|
540
|
+
let machineId;
|
|
541
|
+
try {
|
|
542
|
+
machineId = (0, machineIdentity_1.getMachineIdentity)().machineId;
|
|
543
|
+
}
|
|
544
|
+
catch { /* ignore */ }
|
|
536
545
|
return {
|
|
546
|
+
...(machineId ? { machineId } : {}),
|
|
537
547
|
developerName: developerId(),
|
|
538
548
|
machineName: os.hostname(),
|
|
539
549
|
os: `${os.platform()} ${os.release()}`,
|
|
@@ -44,7 +44,8 @@ async function installAllCommand(args, config) {
|
|
|
44
44
|
// Prompt secrets are checked locally; prompt text never leaves the machine.
|
|
45
45
|
// No failClosed flag: the offline stance is server-authoritative (from
|
|
46
46
|
// the runtime bundle), unified with the Claude/VS Code hook below.
|
|
47
|
-
|
|
47
|
+
// file + read close the "agent reads ~/.aws/credentials silently" hole.
|
|
48
|
+
events: 'prompt,shell,mcp,file,read',
|
|
48
49
|
};
|
|
49
50
|
try {
|
|
50
51
|
await (0, installCursorHook_1.installCursorHookCommand)(hookArgs, config);
|
|
@@ -72,9 +73,11 @@ async function installAllCommand(args, config) {
|
|
|
72
73
|
}
|
|
73
74
|
// Windows-wide command auditing — PowerShell ScriptBlock Logging + Transcription.
|
|
74
75
|
// Covers commands typed in ANY PowerShell (inside or outside the IDE), not just
|
|
75
|
-
// agent-driven actions.
|
|
76
|
-
//
|
|
77
|
-
|
|
76
|
+
// agent-driven actions. OFF BY DEFAULT: it flips machine-wide HKLM policy and
|
|
77
|
+
// transcription writes sizeable plaintext logs to ProgramData — that footprint
|
|
78
|
+
// is an explicit opt-in (--windows-audit true, or org policy), not a side
|
|
79
|
+
// effect of installing protection. Requires ONE admin (UAC) approval.
|
|
80
|
+
if (process.platform === 'win32' && args.windowsAudit === 'true') {
|
|
78
81
|
console.log('\n\x1b[1mEnabling Windows PowerShell audit logging (ScriptBlock Logging + Transcription)…\x1b[0m');
|
|
79
82
|
try {
|
|
80
83
|
const current = (0, windowsAudit_1.getWindowsAuditStatus)();
|
|
@@ -97,6 +100,9 @@ async function installAllCommand(args, config) {
|
|
|
97
100
|
console.log(`\x1b[33m⚠ Warning: PowerShell audit logging step failed (${error instanceof Error ? error.message : String(error)}). Installation continues.\x1b[0m`);
|
|
98
101
|
}
|
|
99
102
|
}
|
|
103
|
+
else if (process.platform === 'win32' && args.windowsAudit !== 'false') {
|
|
104
|
+
console.log('\n\x1b[2mPowerShell audit logging (ScriptBlock Logging + Transcription) is OFF by default — it changes machine-wide policy and writes command transcripts to disk. Opt in with --windows-audit true, or later via: fullcourtdefense windows-audit --enable true\x1b[0m');
|
|
105
|
+
}
|
|
100
106
|
// Interactive PowerShell guard — blocks dangerous commands TYPED in a
|
|
101
107
|
// terminal (inside or outside the IDE) using the same rule families the IDE
|
|
102
108
|
// hooks enforce, from the local cache (no admin needed, no network hot path).
|
|
@@ -147,7 +147,7 @@ function repairCursorManagedHooks() {
|
|
|
147
147
|
const json = readHooksJson(before.file);
|
|
148
148
|
let changed = false;
|
|
149
149
|
if (before.managedEntries === 0) {
|
|
150
|
-
for (const event of ['prompt', 'shell', 'mcp']) {
|
|
150
|
+
for (const event of ['prompt', 'shell', 'mcp', 'file', 'read']) {
|
|
151
151
|
const { hookKey, flag, timeoutSec } = EVENT_MAP[event];
|
|
152
152
|
const list = Array.isArray(json.hooks[hookKey]) ? json.hooks[hookKey] : [];
|
|
153
153
|
list.push({
|
|
@@ -218,7 +218,11 @@ async function installCursorHookCommand(args, config) {
|
|
|
218
218
|
const projectScope = args.project === 'true';
|
|
219
219
|
const shadow = args.shadow === 'true';
|
|
220
220
|
const file = hooksJsonPath(projectScope);
|
|
221
|
-
|
|
221
|
+
// file + read are in the default set: without them a Cursor agent can read
|
|
222
|
+
// ~/.aws/credentials or write a poisoned config with no hook ever firing —
|
|
223
|
+
// the exact coverage hole customers assume is closed. Opt out with
|
|
224
|
+
// --events prompt,shell,mcp.
|
|
225
|
+
const requested = (args.events || 'prompt,shell,mcp,file,read')
|
|
222
226
|
.split(/[\s,]+/).map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
223
227
|
const events = requested.filter((e) => EVENT_MAP[e]);
|
|
224
228
|
if (events.length === 0) {
|
|
@@ -63,6 +63,8 @@ const telemetry_1 = require("../telemetry");
|
|
|
63
63
|
const notify_1 = require("../notify");
|
|
64
64
|
const distress_1 = require("../distress");
|
|
65
65
|
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
66
|
+
const machineIdentity_1 = require("../machineIdentity");
|
|
67
|
+
const backupManifest_1 = require("../backupManifest");
|
|
66
68
|
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
67
69
|
const MANAGED_SERVER_NAME = 'agentguard-gateway';
|
|
68
70
|
const INSTALL_GATEWAY_ARGS = { includeShieldKey: false };
|
|
@@ -230,7 +232,15 @@ function resolveGatewayConfig(args, config, defaults = {}) {
|
|
|
230
232
|
}
|
|
231
233
|
function machineMetadata(developerName, agentClient = 'cursor') {
|
|
232
234
|
const sessionId = runtimeSessionId();
|
|
235
|
+
// Exact fleet machine attribution for the backend's runtime decision mirror
|
|
236
|
+
// (timeline + behavior baseline). Best-effort — never break the gateway.
|
|
237
|
+
let machineId;
|
|
238
|
+
try {
|
|
239
|
+
machineId = (0, machineIdentity_1.getMachineIdentity)().machineId;
|
|
240
|
+
}
|
|
241
|
+
catch { /* ignore */ }
|
|
233
242
|
return {
|
|
243
|
+
...(machineId ? { machineId } : {}),
|
|
234
244
|
developerName: developerName || machineScopedUser(),
|
|
235
245
|
machineName: os.hostname(),
|
|
236
246
|
os: `${os.platform()} ${os.release()}`,
|
|
@@ -1621,6 +1631,9 @@ function backupFile(file) {
|
|
|
1621
1631
|
try {
|
|
1622
1632
|
const bak = `${file}.fcd-backup-${Date.now()}`;
|
|
1623
1633
|
fs.copyFileSync(file, bak);
|
|
1634
|
+
// Backups can embed live credentials and land in project-local dirs no
|
|
1635
|
+
// scan can enumerate — record each one so uninstall can delete them all.
|
|
1636
|
+
(0, backupManifest_1.recordConfigBackup)(bak);
|
|
1624
1637
|
}
|
|
1625
1638
|
catch { /* best effort */ }
|
|
1626
1639
|
}
|
|
@@ -15,6 +15,8 @@ export interface OnboardArgs extends InstallAllArgs {
|
|
|
15
15
|
json?: string;
|
|
16
16
|
/** Do not enable optional self-healing protection. */
|
|
17
17
|
noDaemon?: string;
|
|
18
|
+
/** 'true' => skip the interactive trust disclosure (automation/MDM; the MSI consents via its own dialog). */
|
|
19
|
+
acceptDisclosure?: string;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* `fullcourtdefense onboard` — the one-command path from a fresh machine to a
|
package/dist/commands/onboard.js
CHANGED
|
@@ -63,6 +63,50 @@ const YELLOW = '\x1b[33m';
|
|
|
63
63
|
const BOLD = '\x1b[1m';
|
|
64
64
|
const DIM = '\x1b[2m';
|
|
65
65
|
const RESET = '\x1b[0m';
|
|
66
|
+
/**
|
|
67
|
+
* The trust disclosure — the npm/CLI twin of the MSI EnrollmentDlg text
|
|
68
|
+
* (FullCourtDefense.wxs). A user installing from npm must see the same trust
|
|
69
|
+
* boundary the Windows installer shows BEFORE anything touches the machine:
|
|
70
|
+
* what gets installed, what runs in the background, and what data leaves the
|
|
71
|
+
* device. Keep both texts in sync when either changes.
|
|
72
|
+
*
|
|
73
|
+
* Consent model: interactive terminals must confirm; `--accept-disclosure
|
|
74
|
+
* true` / FCD_ACCEPT_DISCLOSURE=1 skip the prompt for automation and MDM;
|
|
75
|
+
* non-interactive runs (MSI custom action — consent was the Install click)
|
|
76
|
+
* print the text and continue, because a hidden prompt would hang the install.
|
|
77
|
+
* Returns false when the user explicitly declined.
|
|
78
|
+
*/
|
|
79
|
+
async function confirmTrustDisclosure(args) {
|
|
80
|
+
console.log(`${BOLD}Before continuing, understand what FullCourtDefense sets up on this machine:${RESET}`);
|
|
81
|
+
console.log(`${DIM} - background protection tasks (resident daemon + watchdog) that start with your session`);
|
|
82
|
+
console.log(' - IDE/terminal hooks, and MCP tool traffic routed through a local security gateway');
|
|
83
|
+
console.log(" - agent/tool activity (commands, tool-call argument summaries) sent to your organization's");
|
|
84
|
+
console.log(' FullCourtDefense console for policy decisions and audit');
|
|
85
|
+
console.log(' - intended for organization-managed devices');
|
|
86
|
+
console.log(` - removed with \`fullcourtdefense uninstall\`${process.platform === 'win32' ? ' (MSI installs: Windows Settings > Apps)' : ''}${RESET}`);
|
|
87
|
+
console.log('');
|
|
88
|
+
const accepted = args.acceptDisclosure === 'true'
|
|
89
|
+
|| process.env.FCD_ACCEPT_DISCLOSURE === '1'
|
|
90
|
+
|| process.env.FCD_ACCEPT_DISCLOSURE === 'true';
|
|
91
|
+
if (accepted) {
|
|
92
|
+
console.log(`${DIM}Disclosure accepted via --accept-disclosure / FCD_ACCEPT_DISCLOSURE.${RESET}\n`);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
96
|
+
// Non-interactive (MSI custom action, CI, MDM) — consent happened upstream
|
|
97
|
+
// (installer dialog / management policy); prompting here would hang.
|
|
98
|
+
console.log(`${DIM}Non-interactive run — continuing (pass --accept-disclosure true to make consent explicit in automation).${RESET}\n`);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const readline = await Promise.resolve().then(() => __importStar(require('readline')));
|
|
102
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
103
|
+
const answer = await new Promise(resolve => {
|
|
104
|
+
rl.question(`${BOLD}Continue? [y/N] ${RESET}`, resolve);
|
|
105
|
+
});
|
|
106
|
+
rl.close();
|
|
107
|
+
console.log('');
|
|
108
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
109
|
+
}
|
|
66
110
|
function printCheck(check) {
|
|
67
111
|
const mark = check.ok ? `${GREEN}✓${RESET}` : check.optional ? `${YELLOW}○${RESET}` : `${RED}✗${RESET}`;
|
|
68
112
|
const detail = check.detail ? ` ${DIM}${check.detail}${RESET}` : '';
|
|
@@ -193,10 +237,10 @@ function checkTerminalGuards() {
|
|
|
193
237
|
const audit = (0, windowsAudit_1.getWindowsAuditStatus)();
|
|
194
238
|
const auditOk = audit.scriptBlockLogging && audit.transcription;
|
|
195
239
|
checks.push({
|
|
196
|
-
label: 'PowerShell audit logging',
|
|
240
|
+
label: 'PowerShell audit logging (opt-in)',
|
|
197
241
|
ok: auditOk,
|
|
198
242
|
optional: true,
|
|
199
|
-
detail: auditOk ? undefined : '
|
|
243
|
+
detail: auditOk ? undefined : 'off by default — enable with windows-audit --enable true (one UAC approval; writes transcripts to ProgramData)',
|
|
200
244
|
});
|
|
201
245
|
}
|
|
202
246
|
else {
|
|
@@ -234,6 +278,13 @@ async function onboardCommand(args, config) {
|
|
|
234
278
|
};
|
|
235
279
|
console.log(`\n${BOLD}\x1b[36mFullCourtDefense — machine onboarding${RESET}`);
|
|
236
280
|
console.log(`${DIM}Connectivity -> enrollment -> protection -> verification. One command.${RESET}\n`);
|
|
281
|
+
// Trust disclosure first — nothing touches the machine before consent.
|
|
282
|
+
// Dry runs report the plan without changing anything, so no prompt there.
|
|
283
|
+
if (!dryRun && !(await confirmTrustDisclosure(args))) {
|
|
284
|
+
console.log(`${YELLOW}Onboarding cancelled — nothing was installed or changed on this machine.${RESET}`);
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
237
288
|
// Onboarding is a persisted transaction: completed steps are reused on any
|
|
238
289
|
// re-run, so `--resume` is an explicit alias that also surfaces the journal.
|
|
239
290
|
const resume = args.resume === 'true' || args.resume === '';
|