fullcourtdefense-cli 1.35.8 → 1.35.9
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/actionIdentity.js +2 -1
- package/dist/actionPolicyEngine.d.ts +1 -0
- package/dist/actionPolicyEngine.js +203 -14
- package/dist/approvalPrompt.js +2 -2
- package/dist/askDialog.js +5 -4
- package/dist/blockExplanation.js +2 -0
- package/dist/callerEnv.d.ts +33 -0
- package/dist/callerEnv.js +90 -0
- package/dist/commandNarrative.js +111 -4
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/deterministicGuard.js +6 -1
- package/dist/commands/hook.d.ts +34 -1
- package/dist/commands/hook.js +171 -30
- package/dist/commands/taintLedger.js +3 -1
- package/dist/devConfirm.d.ts +28 -12
- package/dist/devConfirm.js +134 -48
- package/dist/hookSlim.d.ts +1 -1
- package/dist/hookSlim.js +22 -2
- package/dist/nativeHook.d.ts +2 -1
- package/dist/nativeHook.js +109 -3
- package/dist/notify.js +4 -0
- package/dist/telemetry.d.ts +2 -0
- package/dist/telemetry.js +6 -0
- package/dist/verdictIpc.js +5 -2
- package/dist/verdictIpcClient.d.ts +11 -1
- package/dist/verdictIpcClient.js +12 -2
- package/dist/version.json +1 -1
- package/package.json +3 -3
package/dist/commands/hook.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DaemonDeclined = void 0;
|
|
36
37
|
exports.normalizeClaudePayload = normalizeClaudePayload;
|
|
37
38
|
exports.canonicalBuiltinToolName = canonicalBuiltinToolName;
|
|
38
39
|
exports.mapBuiltinToolPayload = mapBuiltinToolPayload;
|
|
@@ -44,13 +45,17 @@ exports.buildToolCall = buildToolCall;
|
|
|
44
45
|
exports.classifyGateHttpFailure = classifyGateHttpFailure;
|
|
45
46
|
exports.gateHttpFailureDetail = gateHttpFailureDetail;
|
|
46
47
|
exports.readBackendError = readBackendError;
|
|
48
|
+
exports.humanWaitDeadline = humanWaitDeadline;
|
|
49
|
+
exports.crashVerdictForProcess = crashVerdictForProcess;
|
|
47
50
|
exports.evaluateHookRequest = evaluateHookRequest;
|
|
48
51
|
exports.hookCommand = hookCommand;
|
|
52
|
+
exports.installHookCrashHandlers = installHookCrashHandlers;
|
|
49
53
|
const os = __importStar(require("os"));
|
|
50
54
|
const config_1 = require("../config");
|
|
51
55
|
const runtimeConfig_1 = require("../runtimeConfig");
|
|
52
56
|
const telemetry_1 = require("../telemetry");
|
|
53
57
|
const actionIdentity_1 = require("../actionIdentity");
|
|
58
|
+
const callerEnv_1 = require("../callerEnv");
|
|
54
59
|
const deterministicGuard_1 = require("./deterministicGuard");
|
|
55
60
|
const localSafetySnapshot_1 = require("../localSafetySnapshot");
|
|
56
61
|
const taintLedger_1 = require("./taintLedger");
|
|
@@ -177,10 +182,17 @@ function mapClaudeToolPayload(payload) {
|
|
|
177
182
|
payload: { ...payload, tool_name: shortTool, mcp_server_name: server, tool_input: input },
|
|
178
183
|
};
|
|
179
184
|
}
|
|
180
|
-
//
|
|
185
|
+
// Built-in web readers (Claude `WebFetch`, Copilot `fetch_webpage` / `web_fetch`,
|
|
186
|
+
// Hermes `web_extract`) are an HTTP GET judged by host — the same MCP-shaped call
|
|
187
|
+
// Cursor already sends, so destination rules and Local Safety network rules apply.
|
|
188
|
+
if (BUILTIN_WEB_READ_TOOLS.has(canonicalBuiltinToolName(toolName))) {
|
|
189
|
+
return { event: 'mcp', payload: { ...payload, tool_name: toolName, tool_input: input } };
|
|
190
|
+
}
|
|
191
|
+
// Glob/Grep/Task/WebSearch/etc. — no enforcement surface; let the client's own
|
|
181
192
|
// permission flow handle them.
|
|
182
193
|
return mapBuiltinToolPayload(toolName, input, payload) ?? { event: 'ignore', payload };
|
|
183
194
|
}
|
|
195
|
+
const BUILTIN_WEB_READ_TOOLS = new Set(['webfetch', 'fetchwebpage', 'webextract']);
|
|
184
196
|
// ---------------------------------------------------------------------------
|
|
185
197
|
// Built-in IDE tools — ONE taxonomy for every client.
|
|
186
198
|
//
|
|
@@ -403,9 +415,10 @@ function isGatewayWrappedMcpServer(payload, client, cwd = process.cwd()) {
|
|
|
403
415
|
* VS Code Copilot runs hooks inside the VS Code process environment.
|
|
404
416
|
*/
|
|
405
417
|
function claudeFormatClient() {
|
|
406
|
-
|
|
418
|
+
const env = (0, callerEnv_1.callerEnv)();
|
|
419
|
+
if (env.CLAUDECODE || env.CLAUDE_PROJECT_DIR)
|
|
407
420
|
return 'claude-code';
|
|
408
|
-
if (
|
|
421
|
+
if (env.VSCODE_PID || env.TERM_PROGRAM === 'vscode')
|
|
409
422
|
return 'vscode';
|
|
410
423
|
return 'claude-code';
|
|
411
424
|
}
|
|
@@ -828,7 +841,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
828
841
|
const grantLimits = (0, devConfirm_1.resolveGrantLimits)(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)());
|
|
829
842
|
const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
|
|
830
843
|
if (grant) {
|
|
831
|
-
const covers = (0, devConfirm_1.describeGrantScope)(grant.scope);
|
|
844
|
+
const covers = (0, devConfirm_1.describeGrantScope)(grant.scope, grant);
|
|
832
845
|
(0, hookIo_1.dbg)({ phase: 'dev_confirm_granted', event, tool: call.toolName, operation, grant: grant.key, where: source.where });
|
|
833
846
|
spoolCallEvent(call, {
|
|
834
847
|
decision: 'allow',
|
|
@@ -840,7 +853,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
840
853
|
offlineEnforced,
|
|
841
854
|
});
|
|
842
855
|
(0, telemetry_1.triggerFlush)(false);
|
|
843
|
-
return { outcome: 'allow', agentMsg: `FullCourtDefense: the developer has a standing "Always allow" for ${covers}
|
|
856
|
+
return { outcome: 'allow', agentMsg: `FullCourtDefense: the developer has a standing "Always allow" for ${covers}.` };
|
|
844
857
|
}
|
|
845
858
|
const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
|
|
846
859
|
event,
|
|
@@ -970,14 +983,21 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
970
983
|
// Developer-scoped approvals need no backend at all — the IDE prompt IS the approver.
|
|
971
984
|
if (tryDeveloperConfirmation(ctx, call, local, ctx.payload, 'offline'))
|
|
972
985
|
return true;
|
|
986
|
+
// Monitor machines land here by design (zero synchronous network calls), not
|
|
987
|
+
// because anything is down — never tell the developer the service is
|
|
988
|
+
// unreachable when it is simply not consulted.
|
|
989
|
+
const monitorPath = opts.countAsGateFailure === false;
|
|
973
990
|
if (local.verdict === 'block' || local.verdict === 'require_approval') {
|
|
974
991
|
const reason = local.reason || `${call.toolName}: local policy ${local.verdict}`;
|
|
975
992
|
if (ctx.shadow) {
|
|
976
993
|
// Truth-in-reporting: the action RAN — 'warn' (advisory), never 'allow'
|
|
977
994
|
// (which hides the would-block from the console) and never 'block'.
|
|
978
|
-
|
|
995
|
+
const would = local.verdict === 'block' ? 'block' : 'require approval for';
|
|
996
|
+
spoolCallEvent(call, { decision: 'warn', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `[monitor] local policy would ${local.verdict}${monitorPath ? '' : ' (offline)'}: ${reason}`, offlineEnforced: true });
|
|
979
997
|
(0, telemetry_1.triggerFlush)(false);
|
|
980
|
-
ctx.respond(false, undefined,
|
|
998
|
+
ctx.respond(false, undefined, monitorPath
|
|
999
|
+
? `[FullCourtDefense shadow] would ${would} ${call.toolName}${local.policyName ? ` (${local.policyName})` : ''}: ${reason} — this machine is in Monitor mode, so nothing was blocked.`
|
|
1000
|
+
: `[FullCourtDefense shadow] would ${would} ${call.toolName} (offline, locally cached policy): ${reason}`);
|
|
981
1001
|
}
|
|
982
1002
|
const approvalNote = local.verdict === 'require_approval'
|
|
983
1003
|
? ' This action requires human approval, which is not possible while the policy service is unreachable.'
|
|
@@ -987,10 +1007,6 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
987
1007
|
const explained = explainPolicyStop('block', ctx.event, call, local, policies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`);
|
|
988
1008
|
ctx.respond(true, explained.userMessage, `${explained.agentMessage} Do not retry until the connection is restored or the policy allows it.`);
|
|
989
1009
|
}
|
|
990
|
-
// Monitor machines land here by design (zero synchronous network calls), not
|
|
991
|
-
// because anything is down — never tell the developer the service is
|
|
992
|
-
// unreachable when it is simply not consulted.
|
|
993
|
-
const monitorPath = opts.countAsGateFailure === false;
|
|
994
1010
|
if (local.verdict === 'log') {
|
|
995
1011
|
// Warn-only rule, offline: still a real warning + Warned in the fleet.
|
|
996
1012
|
const warning = warnByPolicy(call, local, ctx.event, ctx.exitsProcess, 'local-first');
|
|
@@ -1096,7 +1112,7 @@ function machineMetadata(client = 'cursor') {
|
|
|
1096
1112
|
os: `${os.platform()} ${os.release()}`,
|
|
1097
1113
|
username,
|
|
1098
1114
|
cwd: process.cwd(),
|
|
1099
|
-
workspacePath:
|
|
1115
|
+
workspacePath: (0, callerEnv_1.callerEnv)().WORKSPACE_PATH || (0, callerEnv_1.callerEnv)().CLAUDE_PROJECT_DIR || process.cwd(),
|
|
1100
1116
|
agentClient: client,
|
|
1101
1117
|
};
|
|
1102
1118
|
}
|
|
@@ -1221,6 +1237,40 @@ class HookExitSignal {
|
|
|
1221
1237
|
this.code = code;
|
|
1222
1238
|
}
|
|
1223
1239
|
}
|
|
1240
|
+
/**
|
|
1241
|
+
* The daemon declines a request whose verdict is a human wait (org approval,
|
|
1242
|
+
* taint approve-once). The thin client stops listening after ~2.5s and
|
|
1243
|
+
* re-evaluates locally, so a daemon-side wait only opened a second approval
|
|
1244
|
+
* request nobody would ever read. Declining sends the client to its local
|
|
1245
|
+
* evaluation at once, where the one wait runs in the developer's own process.
|
|
1246
|
+
*/
|
|
1247
|
+
class DaemonDeclined extends Error {
|
|
1248
|
+
}
|
|
1249
|
+
exports.DaemonDeclined = DaemonDeclined;
|
|
1250
|
+
/**
|
|
1251
|
+
* The shortest pre-tool hook timeout any installer writes for an
|
|
1252
|
+
* approval-capable event (Claude Code / Copilot / VS Code and Cursor: 90s; the
|
|
1253
|
+
* native adapter's own process deadline is 110s). When a client kills a hook on
|
|
1254
|
+
* timeout it runs the action — so every human wait must END, with a deny,
|
|
1255
|
+
* before this. FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
|
|
1256
|
+
*/
|
|
1257
|
+
const IDE_HOOK_TIMEOUT_MS = 90_000;
|
|
1258
|
+
const IDE_TIMEOUT_MARGIN_MS = 15_000;
|
|
1259
|
+
/** Absolute time a human wait must end by: its own cap, and never past the IDE's timeout. */
|
|
1260
|
+
function humanWaitDeadline(waitMs, now = Date.now(), startedAt = (0, telemetry_1.verdictStartedAt)() ?? now, ideTimeoutMs = Number((0, callerEnv_1.callerEnv)().FCD_HOOK_IDE_TIMEOUT_MS) > 0 ? Number((0, callerEnv_1.callerEnv)().FCD_HOOK_IDE_TIMEOUT_MS) : IDE_HOOK_TIMEOUT_MS) {
|
|
1261
|
+
return Math.min(now + waitMs, startedAt + ideTimeoutMs - Math.min(IDE_TIMEOUT_MARGIN_MS, ideTimeoutMs / 4));
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Per-event hook process only (full, slim and native entries): set once the
|
|
1265
|
+
* verdict is a stop (approval pending). A crash after this point must deny —
|
|
1266
|
+
* the fail-open wrapper would otherwise turn a pending approval into an allow.
|
|
1267
|
+
* Never set in the daemon, which evaluates many requests in one process.
|
|
1268
|
+
*/
|
|
1269
|
+
let processPendingStop;
|
|
1270
|
+
/** Verdict for an unexpected error that escaped the evaluation (process-level handlers). */
|
|
1271
|
+
function crashVerdictForProcess(event, message) {
|
|
1272
|
+
return failOpenOutcome(event, message, undefined, undefined, processPendingStop);
|
|
1273
|
+
}
|
|
1224
1274
|
function processIo(stdin, stdinErr) {
|
|
1225
1275
|
return {
|
|
1226
1276
|
stdin,
|
|
@@ -1240,11 +1290,11 @@ function processIo(stdin, stdinErr) {
|
|
|
1240
1290
|
* Mirrors hookCommand's fail-open contract: any unexpected exception yields
|
|
1241
1291
|
* an allow verdict, never an error the thin client would surface.
|
|
1242
1292
|
*/
|
|
1243
|
-
async function evaluateHookRequest(args, stdinRaw, config) {
|
|
1293
|
+
async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
|
|
1244
1294
|
// Structural shell facts (shadow by default). A missing/slow parser means
|
|
1245
1295
|
// "no facts" and the regex floor decides alone — never a wait, never fail-open.
|
|
1246
1296
|
await (0, shellAstLoader_1.ensureShellAst)();
|
|
1247
|
-
return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false });
|
|
1297
|
+
return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false, viaDaemon: opts.viaDaemon });
|
|
1248
1298
|
}
|
|
1249
1299
|
/**
|
|
1250
1300
|
* Run the hook against a buffered io and return the verdict instead of writing
|
|
@@ -1263,6 +1313,7 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
|
|
|
1263
1313
|
writeErr: (text) => { errOut += text; },
|
|
1264
1314
|
exit: (code) => { throw new HookExitSignal(code); },
|
|
1265
1315
|
exitsProcess: mode.exitsProcess,
|
|
1316
|
+
viaDaemon: mode.viaDaemon,
|
|
1266
1317
|
};
|
|
1267
1318
|
try {
|
|
1268
1319
|
await hookCommandInner(args, config, io);
|
|
@@ -1274,11 +1325,13 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
|
|
|
1274
1325
|
if (err instanceof HookExitSignal) {
|
|
1275
1326
|
return { stdout: out, stderr: errOut, exitCode: err.code };
|
|
1276
1327
|
}
|
|
1328
|
+
if (err instanceof DaemonDeclined)
|
|
1329
|
+
throw err;
|
|
1277
1330
|
const message = err instanceof Error ? err.message : String(err);
|
|
1278
1331
|
// Same contract as the process-level wrapper, including the kill-switch
|
|
1279
1332
|
// exception: a crash during daemon-side evaluation must not unlock a
|
|
1280
1333
|
// suspended machine.
|
|
1281
|
-
const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config);
|
|
1334
|
+
const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config, io.pendingStop);
|
|
1282
1335
|
return { stdout: outcome.stdout, stderr: '', exitCode: outcome.exitCode };
|
|
1283
1336
|
}
|
|
1284
1337
|
}
|
|
@@ -1298,7 +1351,27 @@ async function hookCommand(args, config) {
|
|
|
1298
1351
|
const startedAt = Date.now();
|
|
1299
1352
|
return (0, telemetry_1.markVerdictTiming)({ startedAt, path: 'local' }, () => hookCommandOuter(args, config, startedAt));
|
|
1300
1353
|
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Errors that escape every await (an async 'error' event from a child process,
|
|
1356
|
+
* a stray rejection) never reach the try/catch around the evaluation. Without
|
|
1357
|
+
* these handlers Node prints a stack and exits 1 with NO verdict, which every
|
|
1358
|
+
* client reads as a non-blocking hook error — the action runs. Route them
|
|
1359
|
+
* through the same contract as a caught crash instead.
|
|
1360
|
+
*/
|
|
1361
|
+
function installHookCrashHandlers(verdict) {
|
|
1362
|
+
const onCrash = (err) => {
|
|
1363
|
+
const outcome = verdict(err instanceof Error ? err.message : String(err));
|
|
1364
|
+
try {
|
|
1365
|
+
process.stdout.write(outcome.stdout);
|
|
1366
|
+
}
|
|
1367
|
+
catch { /* stdout gone */ }
|
|
1368
|
+
process.exit(outcome.exitCode);
|
|
1369
|
+
};
|
|
1370
|
+
process.on('uncaughtException', onCrash);
|
|
1371
|
+
process.on('unhandledRejection', onCrash);
|
|
1372
|
+
}
|
|
1301
1373
|
async function hookCommandOuter(args, config, startedAt) {
|
|
1374
|
+
installHookCrashHandlers((message) => crashVerdictForProcess(args.event, message));
|
|
1302
1375
|
try {
|
|
1303
1376
|
let raw = '';
|
|
1304
1377
|
let stdinErr = '';
|
|
@@ -1336,7 +1409,7 @@ async function hookCommandOuter(args, config, startedAt) {
|
|
|
1336
1409
|
}
|
|
1337
1410
|
catch (err) {
|
|
1338
1411
|
const message = err instanceof Error ? err.message : String(err);
|
|
1339
|
-
const outcome = failOpenOutcome(args.event, message, args, config);
|
|
1412
|
+
const outcome = failOpenOutcome(args.event, message, args, config, processPendingStop);
|
|
1340
1413
|
try {
|
|
1341
1414
|
process.stdout.write(outcome.stdout);
|
|
1342
1415
|
}
|
|
@@ -1354,8 +1427,29 @@ async function hookCommandOuter(args, config, startedAt) {
|
|
|
1354
1427
|
* machines still deny. An enforcing Shield with fail-closed also denies
|
|
1355
1428
|
* evaluation failures; Monitor and fail-open configurations continue.
|
|
1356
1429
|
*/
|
|
1357
|
-
function failOpenOutcome(event, message, args, config) {
|
|
1430
|
+
function failOpenOutcome(event, message, args, config, pendingStop) {
|
|
1358
1431
|
const toolName = event || 'unknown';
|
|
1432
|
+
if (pendingStop) {
|
|
1433
|
+
// The verdict was already a stop (approval requested, not granted) when the
|
|
1434
|
+
// error hit — it stays a stop. The request remains pending in the console.
|
|
1435
|
+
try {
|
|
1436
|
+
(0, telemetry_1.setVerdictPath)('fail_open');
|
|
1437
|
+
}
|
|
1438
|
+
catch { /* never throw */ }
|
|
1439
|
+
try {
|
|
1440
|
+
(0, hookIo_1.dbg)({ phase: 'hook_error_pending_stop', event, error: message });
|
|
1441
|
+
}
|
|
1442
|
+
catch { /* never throw */ }
|
|
1443
|
+
try {
|
|
1444
|
+
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation: `${toolName}:hook_error`, reason: `Hook error while ${pendingStop} — denied: ${message.slice(0, 200)}`, offlineEnforced: true });
|
|
1445
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
1446
|
+
}
|
|
1447
|
+
catch { /* never throw */ }
|
|
1448
|
+
const reason = `FullCourtDefense stopped this action: ${pendingStop}, and it was not approved before the check ended. The request stays pending in the console; re-run the action after it is approved.`;
|
|
1449
|
+
return { stdout: JSON.stringify({ continue: false, permission: 'deny', user_message: reason, agent_message: `${reason} Do not retry until a human approves it.`,
|
|
1450
|
+
permissionDecision: 'deny', permissionDecisionReason: reason,
|
|
1451
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }) + '\n', exitCode: 0 };
|
|
1452
|
+
}
|
|
1359
1453
|
// Label the spooled event below as a crash-allow: a rising fail_open rate is
|
|
1360
1454
|
// the fleet's earliest signal that the guard itself is broken on a machine.
|
|
1361
1455
|
try {
|
|
@@ -1555,7 +1649,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
1555
1649
|
payload = cursor.payload;
|
|
1556
1650
|
ignoreEvent = cursor.ignore;
|
|
1557
1651
|
}
|
|
1558
|
-
(0, hookIo_1.dbg)({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
|
|
1652
|
+
(0, hookIo_1.dbg)({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid, viaDaemon: Boolean(io.viaDaemon),
|
|
1559
1653
|
isTTY, rawLen: raw.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
|
|
1560
1654
|
argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
|
|
1561
1655
|
// Emit the verdict in the shape THIS client expects, then exit.
|
|
@@ -1593,7 +1687,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
1593
1687
|
const hasPendingLocalAsk = () => Boolean(pendingLocalAsk);
|
|
1594
1688
|
const respond = (blocked, userMsg, agentMsg, askEnvelope) => {
|
|
1595
1689
|
let ask = blocked === 'ask' && event !== 'prompt';
|
|
1596
|
-
|
|
1690
|
+
let denied = blocked === true;
|
|
1597
1691
|
if (!denied && !ask && pendingLocalAsk && event !== 'prompt') {
|
|
1598
1692
|
const { finding, call, ruleHash } = pendingLocalAsk;
|
|
1599
1693
|
pendingLocalAsk = undefined;
|
|
@@ -1614,6 +1708,22 @@ async function hookCommandInner(args, config, io) {
|
|
|
1614
1708
|
}
|
|
1615
1709
|
agentMsg = [prepared.agentMsg, agentMsg].filter(Boolean).join(' ');
|
|
1616
1710
|
}
|
|
1711
|
+
if (ask && !(0, devConfirm_1.isHumanPresent)()) {
|
|
1712
|
+
// Nobody can answer an IDE ask here, and an agent in an auto-approve mode
|
|
1713
|
+
// (Claude Code bypass, Gemini CLI yolo) runs the action on an ask.
|
|
1714
|
+
(0, hookIo_1.dbg)({ phase: 'ask_without_human_denied', event, policyId: askEnvelope?.policyId });
|
|
1715
|
+
(0, telemetry_1.spoolEvent)({
|
|
1716
|
+
decision: 'block', toolName: askEnvelope?.toolName || event, operation: askEnvelope?.operation || event,
|
|
1717
|
+
reason: `Confirmation required and no human is present — denied${askEnvelope?.policyName ? ` (${askEnvelope.policyName})` : ''}`,
|
|
1718
|
+
ruleId: askEnvelope?.policyId, policyHash: askEnvelope?.policyHash, offlineEnforced: true,
|
|
1719
|
+
});
|
|
1720
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
1721
|
+
ask = false;
|
|
1722
|
+
denied = true;
|
|
1723
|
+
askEnvelope = undefined;
|
|
1724
|
+
userMsg = `FullCourtDefense stopped this ${event}: a rule asks a person to confirm it, and no one is present on this machine (CI, ephemeral workload or FCD_HUMAN_PRESENT=0), so it was not run.`;
|
|
1725
|
+
agentMsg = `${userMsg} Do not retry or work around it.`;
|
|
1726
|
+
}
|
|
1617
1727
|
if (!denied && pendingLocalWarnings.length > 0) {
|
|
1618
1728
|
spoolLocalWarnings(pendingLocalWarnings.map(w => w.finding), pendingLocalWarnings[0].toolName, pendingLocalWarnings[0].operation);
|
|
1619
1729
|
}
|
|
@@ -1707,7 +1817,12 @@ async function hookCommandInner(args, config, io) {
|
|
|
1707
1817
|
await enforceActionPolicy({
|
|
1708
1818
|
event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
|
|
1709
1819
|
shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk,
|
|
1710
|
-
effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess,
|
|
1820
|
+
effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess, viaDaemon: Boolean(io.viaDaemon),
|
|
1821
|
+
notePendingStop: (what) => {
|
|
1822
|
+
io.pendingStop = what;
|
|
1823
|
+
if (!io.viaDaemon)
|
|
1824
|
+
processPendingStop = what;
|
|
1825
|
+
},
|
|
1711
1826
|
});
|
|
1712
1827
|
return;
|
|
1713
1828
|
}
|
|
@@ -1765,6 +1880,13 @@ async function hookCommandInner(args, config, io) {
|
|
|
1765
1880
|
// Unknown event → nothing to enforce.
|
|
1766
1881
|
respond(false);
|
|
1767
1882
|
}
|
|
1883
|
+
/** A human wait belongs in the developer's hook process, never in the daemon. */
|
|
1884
|
+
function declineInDaemon(ctx, what) {
|
|
1885
|
+
if (!ctx.viaDaemon)
|
|
1886
|
+
return;
|
|
1887
|
+
(0, hookIo_1.dbg)({ phase: 'daemon_declined', what });
|
|
1888
|
+
throw new DaemonDeclined(`${what}: runs in the hook process`);
|
|
1889
|
+
}
|
|
1768
1890
|
/**
|
|
1769
1891
|
* Approve-once for a taint-guard block: hold the action, alert the human at
|
|
1770
1892
|
* the keyboard, and poll for their one-time decision from their OWN terminal
|
|
@@ -1786,11 +1908,17 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1786
1908
|
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry. ${explained.agentMessage}`);
|
|
1787
1909
|
return false;
|
|
1788
1910
|
};
|
|
1789
|
-
const
|
|
1790
|
-
|
|
1911
|
+
const env = (0, callerEnv_1.callerEnv)();
|
|
1912
|
+
const approveOnceEnabled = env.FCD_TAINT_APPROVE_ONCE_DISABLED !== 'true'
|
|
1913
|
+
&& env.FCD_TAINT_APPROVE_ONCE_DISABLED !== '1'
|
|
1791
1914
|
&& ctx.approvalMode !== 'block';
|
|
1792
1915
|
if (!approveOnceEnabled)
|
|
1793
1916
|
return hardBlock();
|
|
1917
|
+
// Approve-once is answered from the developer's own terminal: with nobody
|
|
1918
|
+
// there, the wait could only end in the IDE's timeout.
|
|
1919
|
+
if (!(0, devConfirm_1.isHumanPresent)())
|
|
1920
|
+
return hardBlock();
|
|
1921
|
+
declineInDaemon(ctx, 'taint approve-once');
|
|
1794
1922
|
const approval = (0, taintLedger_1.createTaintApproval)({
|
|
1795
1923
|
sessionId: sessId,
|
|
1796
1924
|
event,
|
|
@@ -1801,8 +1929,8 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1801
1929
|
});
|
|
1802
1930
|
if (!approval)
|
|
1803
1931
|
return hardBlock();
|
|
1804
|
-
const timeoutMs = Number(
|
|
1805
|
-
? Number(
|
|
1932
|
+
const timeoutMs = Number(env.FCD_TAINT_APPROVAL_TIMEOUT_MS) > 0
|
|
1933
|
+
? Number(env.FCD_TAINT_APPROVAL_TIMEOUT_MS)
|
|
1806
1934
|
: 120000; // local developer decision — short window, they are at the keyboard
|
|
1807
1935
|
const targetText = taintFinding.sink.targets.join(', ') || 'an external destination';
|
|
1808
1936
|
// forceWindow: a toast can be swallowed by Focus Assist; this decision must be seen.
|
|
@@ -1815,10 +1943,13 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1815
1943
|
+ `Deny: fullcourtdefense approve ${approval.id} --deny\n`
|
|
1816
1944
|
+ `(run "fullcourtdefense approve" to list; expires in ${Math.round(timeoutMs / 60000)} min)`,
|
|
1817
1945
|
});
|
|
1818
|
-
(0,
|
|
1819
|
-
|
|
1946
|
+
const waitMs = Math.max(0, humanWaitDeadline(timeoutMs) - Date.now());
|
|
1947
|
+
(0, hookIo_1.dbg)({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs, waitMs });
|
|
1948
|
+
ctx.notePendingStop('the action is held for a one-time developer approval');
|
|
1949
|
+
const outcome = await (0, taintLedger_1.waitForTaintApproval)(approval.id, waitMs, Math.max(750, Math.min(ctx.approvalPollMs, 2000)));
|
|
1820
1950
|
(0, hookIo_1.dbg)({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
|
|
1821
1951
|
if (outcome === 'approved') {
|
|
1952
|
+
ctx.notePendingStop('');
|
|
1822
1953
|
spoolCallEvent(call, {
|
|
1823
1954
|
decision: 'allow',
|
|
1824
1955
|
reason: `Developer approved once after taint-guard hold (${taintFinding.ruleId}): ${taintFinding.reason}`,
|
|
@@ -2066,6 +2197,9 @@ async function enforceActionPolicy(ctx) {
|
|
|
2066
2197
|
if (tryDeveloperConfirmation({ event, respond, shadow, effectivePolicyHash, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
|
|
2067
2198
|
return;
|
|
2068
2199
|
// require_approval (org scope) → synchronous gate below creates the approval action.
|
|
2200
|
+
if (localVerdict.verdict === 'require_approval' && ctx.approvalMode === 'wait' && !shadow) {
|
|
2201
|
+
declineInDaemon(ctx, 'org approval');
|
|
2202
|
+
}
|
|
2069
2203
|
}
|
|
2070
2204
|
// PROXY RESILIENCE (fast probe): when the gate failed within the last 60s
|
|
2071
2205
|
// (bank proxy without the api hostname allowlisted, backend outage), skip
|
|
@@ -2176,6 +2310,11 @@ async function enforceActionPolicy(ctx) {
|
|
|
2176
2310
|
respond(true, explained.userMessage, `This ${event} requires human approval per Action Policy. Stop and ask the developer to approve it in the FullCourtDefense console: ${queueUrl}. ${explained.agentMessage}`);
|
|
2177
2311
|
return;
|
|
2178
2312
|
}
|
|
2313
|
+
// No cached policies, so the daemon only learned it from the gate: the
|
|
2314
|
+
// client re-asks the gate, which answers with this same pending request
|
|
2315
|
+
// (one request per action — see approvalRequestKey), and waits on it.
|
|
2316
|
+
declineInDaemon(ctx, 'org approval');
|
|
2317
|
+
ctx.notePendingStop('it needs human approval');
|
|
2179
2318
|
// Make the wait visible on the machine: the hook process has no UI and its
|
|
2180
2319
|
// stderr is invisible inside IDEs, so a native toast is the only reliable
|
|
2181
2320
|
// signal that a human needs to act NOW (the approval window is minutes).
|
|
@@ -2186,9 +2325,10 @@ async function enforceActionPolicy(ctx) {
|
|
|
2186
2325
|
});
|
|
2187
2326
|
const outcome = await waitForApproval({
|
|
2188
2327
|
apiUrl, shieldId, shieldKey, actionId: result.approvalActionId,
|
|
2189
|
-
|
|
2328
|
+
deadline: humanWaitDeadline(ctx.approvalTimeoutMs), pollMs: ctx.approvalPollMs,
|
|
2190
2329
|
});
|
|
2191
2330
|
if (outcome.status === 'approved') {
|
|
2331
|
+
ctx.notePendingStop('');
|
|
2192
2332
|
(0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved', message: `${call.toolName} was approved and will continue.` });
|
|
2193
2333
|
respond(false, undefined, `FullCourtDefense: human approved this ${event}.`);
|
|
2194
2334
|
return;
|
|
@@ -2218,11 +2358,12 @@ async function waitForApproval(input) {
|
|
|
2218
2358
|
if (input.shieldKey)
|
|
2219
2359
|
headers['x-shield-key'] = input.shieldKey;
|
|
2220
2360
|
const url = `${input.apiUrl}/api/agent-security/runtime/approvals/${encodeURIComponent(input.actionId)}?shieldId=${encodeURIComponent(input.shieldId)}`;
|
|
2221
|
-
const deadline =
|
|
2222
|
-
(0, hookIo_1.dbg)({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs:
|
|
2361
|
+
const { deadline } = input;
|
|
2362
|
+
(0, hookIo_1.dbg)({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: Math.max(0, deadline - Date.now()) });
|
|
2223
2363
|
while (Date.now() <= deadline) {
|
|
2224
2364
|
try {
|
|
2225
|
-
|
|
2365
|
+
// A poll never outlives the deadline — the deadline is what keeps the verdict inside the IDE's timeout.
|
|
2366
|
+
const resp = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(Math.max(1000, Math.min(15000, deadline - Date.now()))) });
|
|
2226
2367
|
if (resp.ok) {
|
|
2227
2368
|
const body = await resp.json().catch(() => ({}));
|
|
2228
2369
|
const approval = body.data?.approval;
|
|
@@ -53,6 +53,7 @@ const os = __importStar(require("os"));
|
|
|
53
53
|
const path = __importStar(require("path"));
|
|
54
54
|
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
55
55
|
const deterministicGuard_1 = require("./deterministicGuard");
|
|
56
|
+
const callerEnv_1 = require("../callerEnv");
|
|
56
57
|
/**
|
|
57
58
|
* Deterministic taint tracking for local IDE coding agents.
|
|
58
59
|
*
|
|
@@ -240,7 +241,8 @@ function isLocalHost(host) {
|
|
|
240
241
|
|| host.endsWith('.local');
|
|
241
242
|
}
|
|
242
243
|
function workspaceRoot(workspacePath) {
|
|
243
|
-
|
|
244
|
+
const env = (0, callerEnv_1.callerEnv)();
|
|
245
|
+
return workspacePath || env.WORKSPACE_PATH || env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
244
246
|
}
|
|
245
247
|
function isExternalFile(filePath, workspacePath) {
|
|
246
248
|
try {
|
package/dist/devConfirm.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { isCiEnvironment, isHumanPresent } from './callerEnv';
|
|
1
2
|
export type ConfirmRemember = 'none' | 'session' | '1h';
|
|
2
3
|
/**
|
|
3
4
|
* Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
|
|
@@ -191,14 +192,6 @@ export declare function resolveGrantLimits(rule?: RuleGrantLimits | null, org?:
|
|
|
191
192
|
export declare function describeGrantLimits(limits: GrantLimits): string;
|
|
192
193
|
/** Uses left on a grant, or undefined when it is only time-limited. */
|
|
193
194
|
export declare function grantRemainingUses(grant: Pick<DeveloperGrant, 'uses' | 'maxUses'>): number | undefined;
|
|
194
|
-
/** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
|
|
195
|
-
export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
|
|
196
|
-
/**
|
|
197
|
-
* Whether a developer can answer an IDE permission prompt on this machine. False on CI runners
|
|
198
|
-
* and on ephemeral workloads (`FCD_EPHEMERAL=1`, exported by `fcd workload-protect`) — there a
|
|
199
|
-
* developer-scoped approval must go to the org queue or block, never self-resolve.
|
|
200
|
-
*/
|
|
201
|
-
export declare function isHumanPresent(env?: NodeJS.ProcessEnv): boolean;
|
|
202
195
|
/** Canonical JSON action, including cwd and every argument. Invalid inputs cannot reuse consent. */
|
|
203
196
|
export declare function actionDigest(toolArgs: Record<string, unknown>): string;
|
|
204
197
|
/**
|
|
@@ -299,11 +292,20 @@ export declare function sweepExpiredConfirmations(now?: Date, ttlMs?: number): P
|
|
|
299
292
|
* (`out-string).trim()`) is stripped. Sorted and unique.
|
|
300
293
|
*/
|
|
301
294
|
export declare function shellPrograms(command: string): string[];
|
|
295
|
+
/**
|
|
296
|
+
* `shellPrograms` narrowed to what a rule that declares its programs is about:
|
|
297
|
+
* the rule's programs plus every other program that could move data off the
|
|
298
|
+
* machine or run code (`curl`, `node`, `Invoke-RestMethod`, anything unknown).
|
|
299
|
+
* Only local file-structure changes and local `git` reads/commits drop out.
|
|
300
|
+
* Without declared programs, or when none of them is on the line, the full set.
|
|
301
|
+
*/
|
|
302
|
+
export declare function shellProgramsForRule(command: string, ruleCommands?: readonly string[]): string[];
|
|
302
303
|
/** Local Safety item ids joined as `a+b` when two ask-rules fire in one dialog. */
|
|
303
304
|
export declare function splitLocalSafetyRuleIds(matchedRule?: string): string[] | undefined;
|
|
304
305
|
/**
|
|
305
|
-
* Does a live grant cover this action under this rule?
|
|
306
|
-
*
|
|
306
|
+
* Does a live grant cover this action under this rule? A combined Local Safety
|
|
307
|
+
* ask (`a+b`) needs a grant for `a` AND one for `b` (or one for `a+b`); a grant
|
|
308
|
+
* for `a` alone never silences `b`. Shell programs match by INCLUSION:
|
|
307
309
|
* a grant for `{gh, git}` covers a line that only runs `gh`, never a line that
|
|
308
310
|
* also runs `curl`. Generic shells and pipeline cmdlets are not in the set.
|
|
309
311
|
* Touch counts a use.
|
|
@@ -332,8 +334,14 @@ export declare function recordDeveloperGrant(scope: GrantScope, rule: {
|
|
|
332
334
|
export declare function listDeveloperGrants(now?: Date): DeveloperGrant[];
|
|
333
335
|
/** Remove one grant by key, or every grant when `key` is omitted. Returns how many were removed. */
|
|
334
336
|
export declare function revokeDeveloperGrants(key?: string): number;
|
|
335
|
-
/**
|
|
336
|
-
|
|
337
|
+
/**
|
|
338
|
+
* One line a person can read on the button and in the grant list: what "Always allow" covers.
|
|
339
|
+
* With `rule`, names the rule the grant is under (`… under rule gcloud_secret_access_cmd`).
|
|
340
|
+
*/
|
|
341
|
+
export declare function describeGrantScope(scope: GrantScope, rule?: {
|
|
342
|
+
policyId?: string;
|
|
343
|
+
policyName?: string;
|
|
344
|
+
}): string;
|
|
337
345
|
/**
|
|
338
346
|
* Which line-level facts the RULE that asked actually depends on. A grant binds
|
|
339
347
|
* the facts the rule fired on and nothing else: `gcloud_access_token_cmd` fired
|
|
@@ -346,6 +354,12 @@ export declare function describeGrantScope(scope: GrantScope): string;
|
|
|
346
354
|
*/
|
|
347
355
|
export interface GrantRuleFacts {
|
|
348
356
|
bindsDestination: boolean;
|
|
357
|
+
/**
|
|
358
|
+
* Programs the rule itself is about (a Local Safety item's `commands`). When
|
|
359
|
+
* set, local-only helpers on the line do not partition the grant
|
|
360
|
+
* (`shellProgramsForRule`). Absent = every program on the line.
|
|
361
|
+
*/
|
|
362
|
+
ruleCommands?: string[];
|
|
349
363
|
}
|
|
350
364
|
/**
|
|
351
365
|
* Derive `GrantRuleFacts` from the source of the ask. Unknown = bound (the
|
|
@@ -354,6 +368,8 @@ export interface GrantRuleFacts {
|
|
|
354
368
|
export declare function grantRuleFactsFor(source: {
|
|
355
369
|
localFinding?: {
|
|
356
370
|
category?: string;
|
|
371
|
+
itemId?: string;
|
|
372
|
+
source?: string;
|
|
357
373
|
};
|
|
358
374
|
policyId?: string;
|
|
359
375
|
policies?: Array<{
|