fullcourtdefense-cli 1.22.8 → 1.24.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/commands/approve.d.ts +17 -0
- package/dist/commands/approve.js +40 -0
- package/dist/commands/ciProtect.d.ts +42 -0
- package/dist/commands/ciProtect.js +191 -0
- package/dist/commands/hook.js +98 -4
- package/dist/commands/taintLedger.d.ts +44 -0
- package/dist/commands/taintLedger.js +209 -0
- package/dist/index.js +41 -0
- package/dist/machineIdentity.js +16 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fullcourtdefense approve [id] [--deny]` — resolve taint-guard approve-once
|
|
3
|
+
* requests from the developer's OWN terminal.
|
|
4
|
+
*
|
|
5
|
+
* With no id: lists pending requests (id, age, held action, destinations).
|
|
6
|
+
* With an id: approves the single held action (or denies with --deny).
|
|
7
|
+
*
|
|
8
|
+
* This command is deliberately terminal-only and local: the agent-side hook
|
|
9
|
+
* blocks any AI agent that tries to run it (local-taint-self-approval), so a
|
|
10
|
+
* decision here always comes from the human at the keyboard.
|
|
11
|
+
*/
|
|
12
|
+
export interface ApproveArgs {
|
|
13
|
+
id?: string;
|
|
14
|
+
deny?: boolean;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function approveCommand(args: ApproveArgs): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.approveCommand = approveCommand;
|
|
4
|
+
const taintLedger_1 = require("./taintLedger");
|
|
5
|
+
function ageSeconds(createdAt) {
|
|
6
|
+
const created = Date.parse(createdAt);
|
|
7
|
+
return Number.isFinite(created) ? Math.max(0, Math.round((Date.now() - created) / 1000)) : 0;
|
|
8
|
+
}
|
|
9
|
+
async function approveCommand(args) {
|
|
10
|
+
if (!args.id) {
|
|
11
|
+
const pending = (0, taintLedger_1.listTaintApprovals)();
|
|
12
|
+
if (args.json) {
|
|
13
|
+
console.log(JSON.stringify({ pending }, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (pending.length === 0) {
|
|
17
|
+
console.log('No pending approve-once requests.');
|
|
18
|
+
console.log('(Requests appear here when the taint guard holds an agent action for your decision.)');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
console.log(`\x1b[1m${pending.length} pending approve-once request${pending.length === 1 ? '' : 's'}:\x1b[0m\n`);
|
|
22
|
+
for (const req of pending) {
|
|
23
|
+
console.log(` \x1b[1m${req.id}\x1b[0m (${ageSeconds(req.createdAt)}s ago) ${req.toolName} -> ${req.targets.join(', ') || 'external destination'}`);
|
|
24
|
+
if (req.detail)
|
|
25
|
+
console.log(` ${req.detail}`);
|
|
26
|
+
console.log(` approve: fullcourtdefense approve ${req.id}`);
|
|
27
|
+
console.log(` deny: fullcourtdefense approve ${req.id} --deny\n`);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const result = (0, taintLedger_1.resolveTaintApproval)(args.id, args.deny ? 'denied' : 'approved');
|
|
32
|
+
if (args.json) {
|
|
33
|
+
console.log(JSON.stringify(result, null, 2));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(result.ok ? `\x1b[32m${result.message}\x1b[0m` : `\x1b[31m${result.message}\x1b[0m`);
|
|
37
|
+
}
|
|
38
|
+
if (!result.ok)
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
}
|
|
@@ -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/hook.js
CHANGED
|
@@ -826,6 +826,84 @@ async function hookCommand(args, config) {
|
|
|
826
826
|
// Unknown event → nothing to enforce.
|
|
827
827
|
respond(false);
|
|
828
828
|
}
|
|
829
|
+
/**
|
|
830
|
+
* Approve-once for a taint-guard block: hold the action, alert the human at
|
|
831
|
+
* the keyboard, and poll for their one-time decision from their OWN terminal
|
|
832
|
+
* (`fullcourtdefense approve <ID>`). Returns true only when the developer
|
|
833
|
+
* approved — the caller then continues the normal pipeline for this single
|
|
834
|
+
* action. In every other outcome (denied / timeout / disabled / store error)
|
|
835
|
+
* this function responds with a block and the caller must return.
|
|
836
|
+
*
|
|
837
|
+
* The approval ID is shown ONLY via the native OS alert and the developer's
|
|
838
|
+
* terminal — never in the agent-visible messages — so a compromised agent
|
|
839
|
+
* cannot learn or redeem it (and self-protection blocks it from trying).
|
|
840
|
+
*/
|
|
841
|
+
async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
842
|
+
const { respond } = ctx;
|
|
843
|
+
const hardBlock = () => {
|
|
844
|
+
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: taintFinding.reason, ruleId: taintFinding.ruleId, offlineEnforced: true });
|
|
845
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
846
|
+
respond(true, `Blocked by FullCourtDefense taint guard — ${taintFinding.reason}`, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry.`);
|
|
847
|
+
return false;
|
|
848
|
+
};
|
|
849
|
+
const approveOnceEnabled = process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== 'true'
|
|
850
|
+
&& process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== '1'
|
|
851
|
+
&& ctx.approvalMode !== 'block';
|
|
852
|
+
if (!approveOnceEnabled)
|
|
853
|
+
return hardBlock();
|
|
854
|
+
const approval = (0, taintLedger_1.createTaintApproval)({
|
|
855
|
+
sessionId: sessId,
|
|
856
|
+
event,
|
|
857
|
+
toolName: call.toolName,
|
|
858
|
+
reason: taintFinding.reason,
|
|
859
|
+
detail: taintFinding.sink.detail,
|
|
860
|
+
targets: taintFinding.sink.targets,
|
|
861
|
+
});
|
|
862
|
+
if (!approval)
|
|
863
|
+
return hardBlock();
|
|
864
|
+
const timeoutMs = Number(process.env.FCD_TAINT_APPROVAL_TIMEOUT_MS) > 0
|
|
865
|
+
? Number(process.env.FCD_TAINT_APPROVAL_TIMEOUT_MS)
|
|
866
|
+
: 120000; // local developer decision — short window, they are at the keyboard
|
|
867
|
+
const targetText = taintFinding.sink.targets.join(', ') || 'an external destination';
|
|
868
|
+
// forceWindow: a toast can be swallowed by Focus Assist; this decision must be seen.
|
|
869
|
+
(0, notify_1.notifyOs)({
|
|
870
|
+
forceWindow: true,
|
|
871
|
+
title: 'FullCourtDefense — approve once?',
|
|
872
|
+
message: `Agent action held: ${call.toolName} -> ${targetText}\n`
|
|
873
|
+
+ `${approval.detail}\n\n`
|
|
874
|
+
+ `Allow ONCE: fullcourtdefense approve ${approval.id}\n`
|
|
875
|
+
+ `Deny: fullcourtdefense approve ${approval.id} --deny\n`
|
|
876
|
+
+ `(run "fullcourtdefense approve" to list; expires in ${Math.round(timeoutMs / 60000)} min)`,
|
|
877
|
+
});
|
|
878
|
+
dbg({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs });
|
|
879
|
+
const outcome = await (0, taintLedger_1.waitForTaintApproval)(approval.id, timeoutMs, Math.max(750, Math.min(ctx.approvalPollMs, 2000)));
|
|
880
|
+
dbg({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
|
|
881
|
+
if (outcome === 'approved') {
|
|
882
|
+
(0, telemetry_1.spoolEvent)({
|
|
883
|
+
decision: 'allow',
|
|
884
|
+
toolName: call.toolName,
|
|
885
|
+
reason: `Developer approved once after taint-guard hold (${taintFinding.ruleId}): ${taintFinding.reason}`,
|
|
886
|
+
ruleId: taintFinding.ruleId,
|
|
887
|
+
offlineEnforced: true,
|
|
888
|
+
});
|
|
889
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
890
|
+
(0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved once', message: `${call.toolName} -> ${targetText} will continue.` });
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
(0, telemetry_1.spoolEvent)({
|
|
894
|
+
decision: 'block',
|
|
895
|
+
toolName: call.toolName,
|
|
896
|
+
reason: `${taintFinding.reason} Developer ${outcome === 'denied' ? 'denied the approve-once request' : 'did not respond to the approve-once request'}.`,
|
|
897
|
+
ruleId: taintFinding.ruleId,
|
|
898
|
+
offlineEnforced: true,
|
|
899
|
+
});
|
|
900
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
901
|
+
respond(true, `Blocked by FullCourtDefense taint guard — ${taintFinding.reason} `
|
|
902
|
+
+ (outcome === 'denied'
|
|
903
|
+
? 'You denied the approve-once request.'
|
|
904
|
+
: 'The approve-once request expired with no decision. If you want this action, re-run it and approve from your own terminal (fullcourtdefense approve).'), `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} The developer was asked and did not approve. Do not retry.`);
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
829
907
|
async function enforceActionPolicy(ctx) {
|
|
830
908
|
const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, effectivePolicyHash } = ctx;
|
|
831
909
|
const call = buildToolCall(event, payload);
|
|
@@ -858,6 +936,21 @@ async function enforceActionPolicy(ctx) {
|
|
|
858
936
|
respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
|
|
859
937
|
return;
|
|
860
938
|
}
|
|
939
|
+
// --- Taint-guard self-protection (always enforced before anything taint) ---
|
|
940
|
+
// The agent must never resolve approve-once requests or touch the taint
|
|
941
|
+
// state directory; otherwise a compromised agent could approve itself.
|
|
942
|
+
const selfApproval = (0, taintLedger_1.detectTaintSelfApproval)(event, call.toolName, call.toolArgs);
|
|
943
|
+
if (selfApproval) {
|
|
944
|
+
dbg({ phase: 'taint_self_approval_block', event, tool: call.toolName });
|
|
945
|
+
if (shadow) {
|
|
946
|
+
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${selfApproval.reason}`);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: selfApproval.reason, ruleId: 'local-taint-self-approval', offlineEnforced: true });
|
|
950
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
951
|
+
respond(true, `Blocked by FullCourtDefense self-protection — ${selfApproval.reason}`, `FullCourtDefense blocked this ${event} locally (local-taint-self-approval): ${selfApproval.reason} Do not retry.`);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
861
954
|
// --- Deterministic taint tracking (local, no backend) ---
|
|
862
955
|
// Evaluate the sink against the PRIOR ledger state first, BEFORE this event
|
|
863
956
|
// records its own ingress (so a lone remote pull doesn't self-taint then
|
|
@@ -871,10 +964,11 @@ async function enforceActionPolicy(ctx) {
|
|
|
871
964
|
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${taintFinding.reason}`);
|
|
872
965
|
return;
|
|
873
966
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
967
|
+
const approved = await runTaintApproveOnce(ctx, event, call, sessId, taintFinding);
|
|
968
|
+
if (!approved)
|
|
969
|
+
return; // runTaintApproveOnce already responded (blocked)
|
|
970
|
+
// Developer approved this single action — continue the normal pipeline so
|
|
971
|
+
// Action Policies and server checks still apply to it.
|
|
878
972
|
}
|
|
879
973
|
// Record untrusted ingress for this event (never blocks).
|
|
880
974
|
(0, taintLedger_1.noteIngress)(sessId, event, call.toolName, call.toolArgs);
|
|
@@ -55,4 +55,48 @@ export declare function detectSink(event: EventKind, toolName: string, toolArgs:
|
|
|
55
55
|
export declare function checkTaintedSink(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>): TaintFinding | undefined;
|
|
56
56
|
/** Record ingress for an event if applicable. Safe to call on every event. */
|
|
57
57
|
export declare function noteIngress(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>, workspacePath?: string): TaintSource | undefined;
|
|
58
|
+
export type TaintApprovalStatus = 'pending' | 'approved' | 'denied';
|
|
59
|
+
export interface TaintApprovalRequest {
|
|
60
|
+
id: string;
|
|
61
|
+
sessionId: string;
|
|
62
|
+
status: TaintApprovalStatus;
|
|
63
|
+
event: string;
|
|
64
|
+
toolName: string;
|
|
65
|
+
reason: string;
|
|
66
|
+
/** The exact command/action detail being held (shown to the human, never to the agent). */
|
|
67
|
+
detail: string;
|
|
68
|
+
targets: string[];
|
|
69
|
+
createdAt: string;
|
|
70
|
+
}
|
|
71
|
+
/** Create a pending approve-once request for a taint finding. Returns undefined on disk errors. */
|
|
72
|
+
export declare function createTaintApproval(input: {
|
|
73
|
+
sessionId: string;
|
|
74
|
+
event: string;
|
|
75
|
+
toolName: string;
|
|
76
|
+
reason: string;
|
|
77
|
+
detail: string;
|
|
78
|
+
targets: string[];
|
|
79
|
+
}): TaintApprovalRequest | undefined;
|
|
80
|
+
/** All live pending approve-once requests (expired ones are pruned). */
|
|
81
|
+
export declare function listTaintApprovals(): TaintApprovalRequest[];
|
|
82
|
+
/** Resolve a pending request (developer's terminal). Returns an outcome message. */
|
|
83
|
+
export declare function resolveTaintApproval(id: string, decision: 'approved' | 'denied'): {
|
|
84
|
+
ok: boolean;
|
|
85
|
+
message: string;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Block until the request is approved/denied or the timeout elapses.
|
|
89
|
+
* ALWAYS consumes (deletes) the request file on exit — the decision applies to
|
|
90
|
+
* the single held action only and can never be redeemed later.
|
|
91
|
+
*/
|
|
92
|
+
export declare function waitForTaintApproval(id: string, timeoutMs: number, pollMs: number): Promise<'approved' | 'denied' | 'timeout'>;
|
|
93
|
+
/**
|
|
94
|
+
* Deterministic rule: any agent attempt to run the approve command, or to
|
|
95
|
+
* read/write the taint state directory (pending IDs / ledger files), is
|
|
96
|
+
* blocked regardless of taint state. Only a human in their own terminal may
|
|
97
|
+
* resolve approve-once requests.
|
|
98
|
+
*/
|
|
99
|
+
export declare function detectTaintSelfApproval(event: EventKind, toolName: string, toolArgs: Record<string, unknown>): {
|
|
100
|
+
reason: string;
|
|
101
|
+
} | undefined;
|
|
58
102
|
export {};
|
|
@@ -42,6 +42,12 @@ exports.classifyIngress = classifyIngress;
|
|
|
42
42
|
exports.detectSink = detectSink;
|
|
43
43
|
exports.checkTaintedSink = checkTaintedSink;
|
|
44
44
|
exports.noteIngress = noteIngress;
|
|
45
|
+
exports.createTaintApproval = createTaintApproval;
|
|
46
|
+
exports.listTaintApprovals = listTaintApprovals;
|
|
47
|
+
exports.resolveTaintApproval = resolveTaintApproval;
|
|
48
|
+
exports.waitForTaintApproval = waitForTaintApproval;
|
|
49
|
+
exports.detectTaintSelfApproval = detectTaintSelfApproval;
|
|
50
|
+
const crypto = __importStar(require("crypto"));
|
|
45
51
|
const fs = __importStar(require("fs"));
|
|
46
52
|
const os = __importStar(require("os"));
|
|
47
53
|
const path = __importStar(require("path"));
|
|
@@ -368,3 +374,206 @@ function noteIngress(sessionId, event, toolName, toolArgs, workspacePath) {
|
|
|
368
374
|
markTaint(sessionId, source);
|
|
369
375
|
return source;
|
|
370
376
|
}
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Approve-once — one-shot human approval for a taint-blocked action
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
//
|
|
381
|
+
// When the taint guard blocks a sink, the hook can HOLD the action and ask the
|
|
382
|
+
// human at the keyboard for a one-time decision instead of hard-failing:
|
|
383
|
+
//
|
|
384
|
+
// 1. hook writes a pending request file under ~/.fullcourtdefense/taint/approvals/
|
|
385
|
+
// 2. the developer sees a native OS alert and runs, in their OWN terminal:
|
|
386
|
+
// fullcourtdefense approve <ID> (allow this one action)
|
|
387
|
+
// fullcourtdefense approve <ID> --deny (reject it)
|
|
388
|
+
// 3. the hook polls the file; on approval the HELD action continues, once.
|
|
389
|
+
//
|
|
390
|
+
// One-shot by construction: the waiting hook deletes the request file the
|
|
391
|
+
// moment it resolves (approved, denied, or timed out). A late `approve` after
|
|
392
|
+
// timeout finds nothing to approve — a previously blocked command can never be
|
|
393
|
+
// released retroactively. Approving never whitelists the host or the session;
|
|
394
|
+
// the very next unrequested sink is checked again from scratch.
|
|
395
|
+
//
|
|
396
|
+
// The agent must never approve its own actions. `detectTaintSelfApproval`
|
|
397
|
+
// gives the hook a deterministic rule that blocks any agent attempt to run the
|
|
398
|
+
// approve command or to touch the taint state directory (which would let it
|
|
399
|
+
// read pending IDs or forge an "approved" status).
|
|
400
|
+
const APPROVAL_TTL_MS = 10 * 60 * 1000; // pending requests older than 10 min are dead
|
|
401
|
+
/** Unambiguous alphabet (no 0/O/1/I/L) so IDs are easy to retype from an alert. */
|
|
402
|
+
const APPROVAL_ID_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
403
|
+
function approvalsDir() {
|
|
404
|
+
return path.join(taintDir(), 'approvals');
|
|
405
|
+
}
|
|
406
|
+
function approvalPath(id) {
|
|
407
|
+
return path.join(approvalsDir(), `${id.toUpperCase().replace(/[^A-Z0-9]/g, '')}.json`);
|
|
408
|
+
}
|
|
409
|
+
function newApprovalId() {
|
|
410
|
+
const bytes = crypto.randomBytes(6);
|
|
411
|
+
let id = '';
|
|
412
|
+
for (const b of bytes)
|
|
413
|
+
id += APPROVAL_ID_ALPHABET[b % APPROVAL_ID_ALPHABET.length];
|
|
414
|
+
return id;
|
|
415
|
+
}
|
|
416
|
+
function readApproval(file) {
|
|
417
|
+
try {
|
|
418
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
419
|
+
if (!parsed.id || !parsed.createdAt)
|
|
420
|
+
return undefined;
|
|
421
|
+
return {
|
|
422
|
+
id: parsed.id,
|
|
423
|
+
sessionId: parsed.sessionId || '',
|
|
424
|
+
status: parsed.status === 'approved' || parsed.status === 'denied' ? parsed.status : 'pending',
|
|
425
|
+
event: parsed.event || 'unknown',
|
|
426
|
+
toolName: parsed.toolName || '',
|
|
427
|
+
reason: parsed.reason || '',
|
|
428
|
+
detail: parsed.detail || '',
|
|
429
|
+
targets: Array.isArray(parsed.targets) ? parsed.targets.filter(t => typeof t === 'string') : [],
|
|
430
|
+
createdAt: parsed.createdAt,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function isExpired(req) {
|
|
438
|
+
const created = Date.parse(req.createdAt);
|
|
439
|
+
return !Number.isFinite(created) || Date.now() - created > APPROVAL_TTL_MS;
|
|
440
|
+
}
|
|
441
|
+
/** Best-effort removal of expired approval request files. */
|
|
442
|
+
function pruneApprovals() {
|
|
443
|
+
try {
|
|
444
|
+
const dir = approvalsDir();
|
|
445
|
+
if (!fs.existsSync(dir))
|
|
446
|
+
return;
|
|
447
|
+
for (const name of fs.readdirSync(dir)) {
|
|
448
|
+
const file = path.join(dir, name);
|
|
449
|
+
const req = readApproval(file);
|
|
450
|
+
if (!req || isExpired(req)) {
|
|
451
|
+
try {
|
|
452
|
+
fs.unlinkSync(file);
|
|
453
|
+
}
|
|
454
|
+
catch { /* ignore */ }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch { /* ignore */ }
|
|
459
|
+
}
|
|
460
|
+
/** Create a pending approve-once request for a taint finding. Returns undefined on disk errors. */
|
|
461
|
+
function createTaintApproval(input) {
|
|
462
|
+
try {
|
|
463
|
+
fs.mkdirSync(approvalsDir(), { recursive: true });
|
|
464
|
+
pruneApprovals();
|
|
465
|
+
const req = {
|
|
466
|
+
id: newApprovalId(),
|
|
467
|
+
sessionId: input.sessionId,
|
|
468
|
+
status: 'pending',
|
|
469
|
+
event: input.event,
|
|
470
|
+
toolName: input.toolName,
|
|
471
|
+
reason: input.reason.slice(0, 600),
|
|
472
|
+
detail: input.detail.slice(0, 400),
|
|
473
|
+
targets: input.targets.slice(0, 10),
|
|
474
|
+
createdAt: nowIso(),
|
|
475
|
+
};
|
|
476
|
+
fs.writeFileSync(approvalPath(req.id), JSON.stringify(req, null, 2), 'utf8');
|
|
477
|
+
return req;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** All live pending approve-once requests (expired ones are pruned). */
|
|
484
|
+
function listTaintApprovals() {
|
|
485
|
+
pruneApprovals();
|
|
486
|
+
try {
|
|
487
|
+
const dir = approvalsDir();
|
|
488
|
+
if (!fs.existsSync(dir))
|
|
489
|
+
return [];
|
|
490
|
+
const out = [];
|
|
491
|
+
for (const name of fs.readdirSync(dir)) {
|
|
492
|
+
const req = readApproval(path.join(dir, name));
|
|
493
|
+
if (req && req.status === 'pending')
|
|
494
|
+
out.push(req);
|
|
495
|
+
}
|
|
496
|
+
return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return [];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Resolve a pending request (developer's terminal). Returns an outcome message. */
|
|
503
|
+
function resolveTaintApproval(id, decision) {
|
|
504
|
+
const file = approvalPath(id);
|
|
505
|
+
const req = fs.existsSync(file) ? readApproval(file) : undefined;
|
|
506
|
+
if (!req || isExpired(req)) {
|
|
507
|
+
return { ok: false, message: `No pending approve-once request "${id.toUpperCase()}" — it may have expired or already resolved. Run "fullcourtdefense approve" to list pending requests.` };
|
|
508
|
+
}
|
|
509
|
+
if (req.status !== 'pending') {
|
|
510
|
+
return { ok: false, message: `Request ${req.id} was already ${req.status}.` };
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
fs.writeFileSync(file, JSON.stringify({ ...req, status: decision }, null, 2), 'utf8');
|
|
514
|
+
return { ok: true, message: `${decision === 'approved' ? 'Approved' : 'Denied'} ${req.id}: ${req.toolName} -> ${req.targets.join(', ') || 'external destination'}. ${decision === 'approved' ? 'The held action will continue once.' : 'The held action stays blocked.'}` };
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
return { ok: false, message: `Could not write decision: ${err instanceof Error ? err.message : String(err)}` };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Block until the request is approved/denied or the timeout elapses.
|
|
522
|
+
* ALWAYS consumes (deletes) the request file on exit — the decision applies to
|
|
523
|
+
* the single held action only and can never be redeemed later.
|
|
524
|
+
*/
|
|
525
|
+
async function waitForTaintApproval(id, timeoutMs, pollMs) {
|
|
526
|
+
const file = approvalPath(id);
|
|
527
|
+
const deadline = Date.now() + Math.max(1000, timeoutMs);
|
|
528
|
+
try {
|
|
529
|
+
while (Date.now() < deadline) {
|
|
530
|
+
const req = fs.existsSync(file) ? readApproval(file) : undefined;
|
|
531
|
+
if (!req)
|
|
532
|
+
return 'denied'; // file vanished — treat as not approved
|
|
533
|
+
if (req.status === 'approved')
|
|
534
|
+
return 'approved';
|
|
535
|
+
if (req.status === 'denied')
|
|
536
|
+
return 'denied';
|
|
537
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(250, pollMs)));
|
|
538
|
+
}
|
|
539
|
+
return 'timeout';
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
try {
|
|
543
|
+
fs.unlinkSync(file);
|
|
544
|
+
}
|
|
545
|
+
catch { /* already gone */ }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// --- Self-protection: the agent must never approve its own held actions -----
|
|
549
|
+
const SELF_APPROVE_CMD = /\b(?:fullcourtdefense|fcd|botguard)(?:\.cmd|\.exe|\.ps1|\.js)?["']?\s+(?:approve|deny)\b/i;
|
|
550
|
+
const TAINT_STATE_PATH = /\.fullcourtdefense[\\/]+taint\b/i;
|
|
551
|
+
/**
|
|
552
|
+
* Deterministic rule: any agent attempt to run the approve command, or to
|
|
553
|
+
* read/write the taint state directory (pending IDs / ledger files), is
|
|
554
|
+
* blocked regardless of taint state. Only a human in their own terminal may
|
|
555
|
+
* resolve approve-once requests.
|
|
556
|
+
*/
|
|
557
|
+
function detectTaintSelfApproval(event, toolName, toolArgs) {
|
|
558
|
+
if (!taintEnabled())
|
|
559
|
+
return undefined;
|
|
560
|
+
// For file edits/reads only the TARGET PATH matters — file contents may
|
|
561
|
+
// legitimately mention these strings (e.g. this repo's own source/tests).
|
|
562
|
+
const strings = event === 'file' || event === 'read'
|
|
563
|
+
? [
|
|
564
|
+
typeof toolArgs.path === 'string' ? toolArgs.path : '',
|
|
565
|
+
typeof toolArgs.file_path === 'string' ? toolArgs.file_path : '',
|
|
566
|
+
].filter(Boolean)
|
|
567
|
+
: [...collectStringValues(toolArgs), toolName];
|
|
568
|
+
for (const value of strings) {
|
|
569
|
+
if (event === 'shell' || event === 'mcp') {
|
|
570
|
+
if (SELF_APPROVE_CMD.test(value)) {
|
|
571
|
+
return { reason: 'Agents may not run the FullCourtDefense approve command. Only the developer, from their own terminal, can resolve approve-once requests.' };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (TAINT_STATE_PATH.test(value)) {
|
|
575
|
+
return { reason: 'Agents may not access the FullCourtDefense taint state directory (approval requests and session ledgers are human-only).' };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
37
37
|
const config_1 = require("./config");
|
|
38
38
|
const scan_1 = require("./commands/scan");
|
|
39
39
|
const credits_1 = require("./commands/credits");
|
|
40
|
+
const approve_1 = require("./commands/approve");
|
|
40
41
|
const init_1 = require("./commands/init");
|
|
41
42
|
const doctor_1 = require("./commands/doctor");
|
|
42
43
|
const configure_1 = require("./commands/configure");
|
|
@@ -49,6 +50,7 @@ const installCursorHook_1 = require("./commands/installCursorHook");
|
|
|
49
50
|
const installClaudeHook_1 = require("./commands/installClaudeHook");
|
|
50
51
|
const mcpGateway_1 = require("./commands/mcpGateway");
|
|
51
52
|
const installAll_1 = require("./commands/installAll");
|
|
53
|
+
const ciProtect_1 = require("./commands/ciProtect");
|
|
52
54
|
const onboard_1 = require("./commands/onboard");
|
|
53
55
|
const uninstallAll_1 = require("./commands/uninstallAll");
|
|
54
56
|
const verifyRemoved_1 = require("./commands/verifyRemoved");
|
|
@@ -178,6 +180,17 @@ function printHelp() {
|
|
|
178
180
|
Use --schedule logon to upload inventory/posture at machine login.
|
|
179
181
|
agent-ci CI/CD gate for agents and tools only. Fails builds on risky MCP,
|
|
180
182
|
agent instruction, gateway, policy, or secret drift. Not a bot scan.
|
|
183
|
+
ci-protect One command for CI jobs that run AI agents (Claude Code, Copilot,
|
|
184
|
+
MCP tools): enrolls the PIPELINE as an ephemeral fleet endpoint
|
|
185
|
+
(auto-detected on GitHub Actions / GitLab CI), installs the runtime
|
|
186
|
+
hook + MCP gateway, and streams every tool call to the fleet
|
|
187
|
+
console. Auth: FCD_API_KEY secret (org API key).
|
|
188
|
+
approve Resolve a taint-guard "approve once" request from YOUR terminal.
|
|
189
|
+
When the hook holds an agent action (unrequested network/git
|
|
190
|
+
destination after untrusted web/MCP content), run
|
|
191
|
+
"fullcourtdefense approve <ID>" to allow that single action, or
|
|
192
|
+
add --deny. Bare "approve" lists pending requests. Agents are
|
|
193
|
+
blocked from running this command themselves.
|
|
181
194
|
install-cursor-hook
|
|
182
195
|
Installs a Cursor hook so EVERY agent action on this machine is
|
|
183
196
|
checked against your org's Action Policies — in any repo/folder.
|
|
@@ -563,6 +576,19 @@ async function main() {
|
|
|
563
576
|
await (0, credits_1.creditsCommand)(args, config);
|
|
564
577
|
break;
|
|
565
578
|
}
|
|
579
|
+
case 'approve': {
|
|
580
|
+
// `approve <id>` / `approve <id> --deny` / bare `approve` lists pending.
|
|
581
|
+
// parseArgs quirk: `approve --deny <id>` puts the id into flags.deny.
|
|
582
|
+
const denyValue = flags.deny;
|
|
583
|
+
const idFromDeny = denyValue && denyValue !== 'true' && denyValue !== 'false' ? denyValue : undefined;
|
|
584
|
+
const args = {
|
|
585
|
+
id: positional[0] || idFromDeny,
|
|
586
|
+
deny: denyValue !== undefined && denyValue !== 'false',
|
|
587
|
+
json: flags.json === 'true',
|
|
588
|
+
};
|
|
589
|
+
await (0, approve_1.approveCommand)(args);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
566
592
|
case 'discover': {
|
|
567
593
|
const args = {
|
|
568
594
|
type: flags.type,
|
|
@@ -585,6 +611,21 @@ async function main() {
|
|
|
585
611
|
await (0, discover_1.discoverCommand)(args, config);
|
|
586
612
|
break;
|
|
587
613
|
}
|
|
614
|
+
case 'ci-protect': {
|
|
615
|
+
const args = {
|
|
616
|
+
apiKey: flags['api-key'],
|
|
617
|
+
apiUrl: flags['api-url'],
|
|
618
|
+
provider: flags.provider,
|
|
619
|
+
repo: flags.repo,
|
|
620
|
+
workflow: flags.workflow,
|
|
621
|
+
runId: flags['run-id'],
|
|
622
|
+
runUrl: flags['run-url'],
|
|
623
|
+
hooks: flags.hooks,
|
|
624
|
+
gateway: flags.gateway,
|
|
625
|
+
};
|
|
626
|
+
await (0, ciProtect_1.ciProtectCommand)(args, config);
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
588
629
|
case 'agent-ci': {
|
|
589
630
|
const args = {
|
|
590
631
|
failOn: flags['fail-on'],
|
package/dist/machineIdentity.js
CHANGED
|
@@ -131,6 +131,22 @@ function friendlyOs() {
|
|
|
131
131
|
function getMachineIdentity() {
|
|
132
132
|
if (cached)
|
|
133
133
|
return cached;
|
|
134
|
+
// Ephemeral fleet override: on CI runners the PIPELINE is the fleet identity,
|
|
135
|
+
// not the disposable runner hardware. `ci-protect` enrolls the pipeline and
|
|
136
|
+
// exports these envs (via GITHUB_ENV) so every subsequent hook/gateway
|
|
137
|
+
// process in the job attributes its events to the pipeline machine record.
|
|
138
|
+
const ciMachineId = (process.env.FCD_MACHINE_ID || '').trim();
|
|
139
|
+
if (ciMachineId) {
|
|
140
|
+
cached = {
|
|
141
|
+
machineId: ciMachineId.slice(0, 64),
|
|
142
|
+
user: 'ci',
|
|
143
|
+
hostname: (process.env.FCD_MACHINE_HOSTNAME || 'ci-pipeline').trim().toLowerCase().slice(0, 160) || 'ci-pipeline',
|
|
144
|
+
developerName: (process.env.FCD_DEVELOPER_NAME || 'ci@pipeline').trim().toLowerCase().slice(0, 160) || 'ci@pipeline',
|
|
145
|
+
platform: os.platform(),
|
|
146
|
+
osFriendly: 'CI runner',
|
|
147
|
+
};
|
|
148
|
+
return cached;
|
|
149
|
+
}
|
|
134
150
|
const user = safe(() => os.userInfo().username) || process.env.USER || process.env.USERNAME || 'unknown-user';
|
|
135
151
|
const hostname = normalizeHostname(safe(() => os.hostname()) || 'unknown-host');
|
|
136
152
|
// Seed the hash with the OS-native id when available; fall back to a stable
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"test:honeypot": "npm run build && node scripts/test-honeypot.js",
|
|
24
24
|
"test:browser-credentials-rule": "npm run build && node scripts/test-browser-credentials-rule.js",
|
|
25
25
|
"test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
|
|
26
|
+
"test:taint-approvals": "npm run build && node scripts/test-taint-approvals.js",
|
|
27
|
+
"test:taint-approve-once": "npm run build && node scripts/test-taint-approve-once.js",
|
|
26
28
|
"test:shell-audit": "npm run build && node scripts/test-shell-audit.js",
|
|
27
29
|
"test:audit-restore": "node scripts/test-audit-restore.js",
|
|
28
30
|
"test:shell-guard": "npm run build && node scripts/test-shell-guard.js",
|