fullcourtdefense-cli 1.35.7 → 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 +38 -0
- package/dist/actionPolicyEngine.js +383 -24
- 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.d.ts +32 -0
- package/dist/commands/deterministicGuard.js +214 -19
- package/dist/commands/hook.d.ts +34 -1
- package/dist/commands/hook.js +188 -31
- package/dist/commands/mcpGateway.js +2 -0
- package/dist/commands/taintLedger.js +3 -1
- package/dist/commands/workloadProtect.js +6 -2
- 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/shellAst.d.ts +117 -0
- package/dist/shellAst.js +590 -0
- package/dist/shellAstLoader.d.ts +32 -0
- package/dist/shellAstLoader.js +271 -0
- package/dist/telemetry.d.ts +20 -0
- package/dist/telemetry.js +7 -0
- package/dist/vendor/tree-sitter/LICENSE.tree-sitter-bash +21 -0
- package/dist/vendor/tree-sitter/LICENSE.web-tree-sitter +21 -0
- package/dist/vendor/tree-sitter/VERSIONS.json +4 -0
- package/dist/vendor/tree-sitter/tree-sitter-bash.wasm +0 -0
- package/dist/vendor/tree-sitter/tree-sitter.cjs +4031 -0
- package/dist/vendor/tree-sitter/tree-sitter.wasm +0 -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 +9 -6
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");
|
|
@@ -69,6 +74,7 @@ const askDialog_1 = require("../askDialog");
|
|
|
69
74
|
const hookIo_1 = require("../hookIo");
|
|
70
75
|
const fileWriteCanon_1 = require("../fileWriteCanon");
|
|
71
76
|
const discoverProxy_1 = require("./discoverProxy");
|
|
77
|
+
const shellAstLoader_1 = require("../shellAstLoader");
|
|
72
78
|
function inferEvent(explicit, payload) {
|
|
73
79
|
const e = (explicit || '').toLowerCase();
|
|
74
80
|
if (e) {
|
|
@@ -176,10 +182,17 @@ function mapClaudeToolPayload(payload) {
|
|
|
176
182
|
payload: { ...payload, tool_name: shortTool, mcp_server_name: server, tool_input: input },
|
|
177
183
|
};
|
|
178
184
|
}
|
|
179
|
-
//
|
|
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
|
|
180
192
|
// permission flow handle them.
|
|
181
193
|
return mapBuiltinToolPayload(toolName, input, payload) ?? { event: 'ignore', payload };
|
|
182
194
|
}
|
|
195
|
+
const BUILTIN_WEB_READ_TOOLS = new Set(['webfetch', 'fetchwebpage', 'webextract']);
|
|
183
196
|
// ---------------------------------------------------------------------------
|
|
184
197
|
// Built-in IDE tools — ONE taxonomy for every client.
|
|
185
198
|
//
|
|
@@ -402,9 +415,10 @@ function isGatewayWrappedMcpServer(payload, client, cwd = process.cwd()) {
|
|
|
402
415
|
* VS Code Copilot runs hooks inside the VS Code process environment.
|
|
403
416
|
*/
|
|
404
417
|
function claudeFormatClient() {
|
|
405
|
-
|
|
418
|
+
const env = (0, callerEnv_1.callerEnv)();
|
|
419
|
+
if (env.CLAUDECODE || env.CLAUDE_PROJECT_DIR)
|
|
406
420
|
return 'claude-code';
|
|
407
|
-
if (
|
|
421
|
+
if (env.VSCODE_PID || env.TERM_PROGRAM === 'vscode')
|
|
408
422
|
return 'vscode';
|
|
409
423
|
return 'claude-code';
|
|
410
424
|
}
|
|
@@ -552,6 +566,7 @@ function spoolCallEvent(call, rest) {
|
|
|
552
566
|
toolName: call.toolName,
|
|
553
567
|
operation: rest.operation || operation,
|
|
554
568
|
evidence: rest.evidence || (0, telemetry_1.evidenceFromToolArgs)(call.toolArgs),
|
|
569
|
+
shellAst: rest.shellAst || (0, shellAstLoader_1.collectShellAstFacts)(),
|
|
555
570
|
});
|
|
556
571
|
}
|
|
557
572
|
/**
|
|
@@ -826,7 +841,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
826
841
|
const grantLimits = (0, devConfirm_1.resolveGrantLimits)(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)());
|
|
827
842
|
const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
|
|
828
843
|
if (grant) {
|
|
829
|
-
const covers = (0, devConfirm_1.describeGrantScope)(grant.scope);
|
|
844
|
+
const covers = (0, devConfirm_1.describeGrantScope)(grant.scope, grant);
|
|
830
845
|
(0, hookIo_1.dbg)({ phase: 'dev_confirm_granted', event, tool: call.toolName, operation, grant: grant.key, where: source.where });
|
|
831
846
|
spoolCallEvent(call, {
|
|
832
847
|
decision: 'allow',
|
|
@@ -838,7 +853,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
838
853
|
offlineEnforced,
|
|
839
854
|
});
|
|
840
855
|
(0, telemetry_1.triggerFlush)(false);
|
|
841
|
-
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}.` };
|
|
842
857
|
}
|
|
843
858
|
const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
|
|
844
859
|
event,
|
|
@@ -964,18 +979,25 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
964
979
|
});
|
|
965
980
|
const declaredOperations = (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName);
|
|
966
981
|
const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(policies, call.toolName, operation, context, declaredOperations);
|
|
967
|
-
(0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail });
|
|
982
|
+
(0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail, astMode: (0, actionPolicyEngine_1.getShellAstMode)() });
|
|
968
983
|
// Developer-scoped approvals need no backend at all — the IDE prompt IS the approver.
|
|
969
984
|
if (tryDeveloperConfirmation(ctx, call, local, ctx.payload, 'offline'))
|
|
970
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;
|
|
971
990
|
if (local.verdict === 'block' || local.verdict === 'require_approval') {
|
|
972
991
|
const reason = local.reason || `${call.toolName}: local policy ${local.verdict}`;
|
|
973
992
|
if (ctx.shadow) {
|
|
974
993
|
// Truth-in-reporting: the action RAN — 'warn' (advisory), never 'allow'
|
|
975
994
|
// (which hides the would-block from the console) and never 'block'.
|
|
976
|
-
|
|
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 });
|
|
977
997
|
(0, telemetry_1.triggerFlush)(false);
|
|
978
|
-
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}`);
|
|
979
1001
|
}
|
|
980
1002
|
const approvalNote = local.verdict === 'require_approval'
|
|
981
1003
|
? ' This action requires human approval, which is not possible while the policy service is unreachable.'
|
|
@@ -985,10 +1007,6 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
985
1007
|
const explained = explainPolicyStop('block', ctx.event, call, local, policies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`);
|
|
986
1008
|
ctx.respond(true, explained.userMessage, `${explained.agentMessage} Do not retry until the connection is restored or the policy allows it.`);
|
|
987
1009
|
}
|
|
988
|
-
// Monitor machines land here by design (zero synchronous network calls), not
|
|
989
|
-
// because anything is down — never tell the developer the service is
|
|
990
|
-
// unreachable when it is simply not consulted.
|
|
991
|
-
const monitorPath = opts.countAsGateFailure === false;
|
|
992
1010
|
if (local.verdict === 'log') {
|
|
993
1011
|
// Warn-only rule, offline: still a real warning + Warned in the fleet.
|
|
994
1012
|
const warning = warnByPolicy(call, local, ctx.event, ctx.exitsProcess, 'local-first');
|
|
@@ -1094,7 +1112,7 @@ function machineMetadata(client = 'cursor') {
|
|
|
1094
1112
|
os: `${os.platform()} ${os.release()}`,
|
|
1095
1113
|
username,
|
|
1096
1114
|
cwd: process.cwd(),
|
|
1097
|
-
workspacePath:
|
|
1115
|
+
workspacePath: (0, callerEnv_1.callerEnv)().WORKSPACE_PATH || (0, callerEnv_1.callerEnv)().CLAUDE_PROJECT_DIR || process.cwd(),
|
|
1098
1116
|
agentClient: client,
|
|
1099
1117
|
};
|
|
1100
1118
|
}
|
|
@@ -1219,6 +1237,40 @@ class HookExitSignal {
|
|
|
1219
1237
|
this.code = code;
|
|
1220
1238
|
}
|
|
1221
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
|
+
}
|
|
1222
1274
|
function processIo(stdin, stdinErr) {
|
|
1223
1275
|
return {
|
|
1224
1276
|
stdin,
|
|
@@ -1238,8 +1290,11 @@ function processIo(stdin, stdinErr) {
|
|
|
1238
1290
|
* Mirrors hookCommand's fail-open contract: any unexpected exception yields
|
|
1239
1291
|
* an allow verdict, never an error the thin client would surface.
|
|
1240
1292
|
*/
|
|
1241
|
-
async function evaluateHookRequest(args, stdinRaw, config) {
|
|
1242
|
-
|
|
1293
|
+
async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
|
|
1294
|
+
// Structural shell facts (shadow by default). A missing/slow parser means
|
|
1295
|
+
// "no facts" and the regex floor decides alone — never a wait, never fail-open.
|
|
1296
|
+
await (0, shellAstLoader_1.ensureShellAst)();
|
|
1297
|
+
return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false, viaDaemon: opts.viaDaemon });
|
|
1243
1298
|
}
|
|
1244
1299
|
/**
|
|
1245
1300
|
* Run the hook against a buffered io and return the verdict instead of writing
|
|
@@ -1258,6 +1313,7 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
|
|
|
1258
1313
|
writeErr: (text) => { errOut += text; },
|
|
1259
1314
|
exit: (code) => { throw new HookExitSignal(code); },
|
|
1260
1315
|
exitsProcess: mode.exitsProcess,
|
|
1316
|
+
viaDaemon: mode.viaDaemon,
|
|
1261
1317
|
};
|
|
1262
1318
|
try {
|
|
1263
1319
|
await hookCommandInner(args, config, io);
|
|
@@ -1269,11 +1325,13 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
|
|
|
1269
1325
|
if (err instanceof HookExitSignal) {
|
|
1270
1326
|
return { stdout: out, stderr: errOut, exitCode: err.code };
|
|
1271
1327
|
}
|
|
1328
|
+
if (err instanceof DaemonDeclined)
|
|
1329
|
+
throw err;
|
|
1272
1330
|
const message = err instanceof Error ? err.message : String(err);
|
|
1273
1331
|
// Same contract as the process-level wrapper, including the kill-switch
|
|
1274
1332
|
// exception: a crash during daemon-side evaluation must not unlock a
|
|
1275
1333
|
// suspended machine.
|
|
1276
|
-
const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config);
|
|
1334
|
+
const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config, io.pendingStop);
|
|
1277
1335
|
return { stdout: outcome.stdout, stderr: '', exitCode: outcome.exitCode };
|
|
1278
1336
|
}
|
|
1279
1337
|
}
|
|
@@ -1293,7 +1351,27 @@ async function hookCommand(args, config) {
|
|
|
1293
1351
|
const startedAt = Date.now();
|
|
1294
1352
|
return (0, telemetry_1.markVerdictTiming)({ startedAt, path: 'local' }, () => hookCommandOuter(args, config, startedAt));
|
|
1295
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
|
+
}
|
|
1296
1373
|
async function hookCommandOuter(args, config, startedAt) {
|
|
1374
|
+
installHookCrashHandlers((message) => crashVerdictForProcess(args.event, message));
|
|
1297
1375
|
try {
|
|
1298
1376
|
let raw = '';
|
|
1299
1377
|
let stdinErr = '';
|
|
@@ -1319,6 +1397,11 @@ async function hookCommandOuter(args, config, startedAt) {
|
|
|
1319
1397
|
// machine that silently stopped using the fast path is diagnosable.
|
|
1320
1398
|
(0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail });
|
|
1321
1399
|
}
|
|
1400
|
+
// Structural shell facts for the local evaluation (shadow by default; the
|
|
1401
|
+
// daemon path above initialises its own copy once). Bounded: a slow or
|
|
1402
|
+
// missing parser resolves to "no facts" and the regex floor decides alone.
|
|
1403
|
+
const astReady = await (0, shellAstLoader_1.ensureShellAst)();
|
|
1404
|
+
(0, hookIo_1.dbg)({ phase: 'shell_ast', ready: astReady, status: (0, shellAstLoader_1.shellAstStatus)() });
|
|
1322
1405
|
// Buffered so an `ask` can be put to the developer in OUR dialog before the
|
|
1323
1406
|
// IDE reads the verdict; exitsProcess stays true — this process ends right after.
|
|
1324
1407
|
const result = await evaluateBuffered(args, raw, stdinErr, config, { isTTY: !!process.stdin.isTTY, exitsProcess: true });
|
|
@@ -1326,7 +1409,7 @@ async function hookCommandOuter(args, config, startedAt) {
|
|
|
1326
1409
|
}
|
|
1327
1410
|
catch (err) {
|
|
1328
1411
|
const message = err instanceof Error ? err.message : String(err);
|
|
1329
|
-
const outcome = failOpenOutcome(args.event, message, args, config);
|
|
1412
|
+
const outcome = failOpenOutcome(args.event, message, args, config, processPendingStop);
|
|
1330
1413
|
try {
|
|
1331
1414
|
process.stdout.write(outcome.stdout);
|
|
1332
1415
|
}
|
|
@@ -1344,8 +1427,29 @@ async function hookCommandOuter(args, config, startedAt) {
|
|
|
1344
1427
|
* machines still deny. An enforcing Shield with fail-closed also denies
|
|
1345
1428
|
* evaluation failures; Monitor and fail-open configurations continue.
|
|
1346
1429
|
*/
|
|
1347
|
-
function failOpenOutcome(event, message, args, config) {
|
|
1430
|
+
function failOpenOutcome(event, message, args, config, pendingStop) {
|
|
1348
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
|
+
}
|
|
1349
1453
|
// Label the spooled event below as a crash-allow: a rising fail_open rate is
|
|
1350
1454
|
// the fleet's earliest signal that the guard itself is broken on a machine.
|
|
1351
1455
|
try {
|
|
@@ -1456,9 +1560,11 @@ async function hookCommandInner(args, config, io) {
|
|
|
1456
1560
|
// this is instant when fresh and bounded (~1.5s) only when stale.
|
|
1457
1561
|
let machineSuspended = false;
|
|
1458
1562
|
let modeResolved = false;
|
|
1563
|
+
let runtimeBundleFlags = {};
|
|
1459
1564
|
if (shieldId) {
|
|
1460
1565
|
try {
|
|
1461
1566
|
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname(), hotPath: true });
|
|
1567
|
+
runtimeBundleFlags = bundle;
|
|
1462
1568
|
// Kill-switch holds even on a 'default' bundle: with the cache missing
|
|
1463
1569
|
// but a signed last-known-suspended marker standing, getRuntimeBundle
|
|
1464
1570
|
// stamps suspended on the default — deleting the cache file must not
|
|
@@ -1486,6 +1592,10 @@ async function hookCommandInner(args, config, io) {
|
|
|
1486
1592
|
}
|
|
1487
1593
|
catch { /* keep the local default */ }
|
|
1488
1594
|
}
|
|
1595
|
+
// Shell AST mode: FCD_SHELL_AST_MODE env, else the bundle's `shellAstMode`
|
|
1596
|
+
// (server-controlled rollout), else shadow — facts recorded, verdicts unchanged.
|
|
1597
|
+
(0, shellAstLoader_1.applyShellAstMode)((0, shellAstLoader_1.resolveShellAstMode)(process.env.FCD_SHELL_AST_MODE, runtimeBundleFlags.shellAstMode));
|
|
1598
|
+
(0, hookIo_1.dbg)({ phase: 'shell_ast_mode', mode: (0, actionPolicyEngine_1.getShellAstMode)(), fromEnv: process.env.FCD_SHELL_AST_MODE, fromBundle: runtimeBundleFlags.shellAstMode });
|
|
1489
1599
|
// Monitor-first: no authoritative bundle (fresh/uncached/fetch failed) => report-only.
|
|
1490
1600
|
if (!modeResolved && !forceEnforce) {
|
|
1491
1601
|
shadow = true;
|
|
@@ -1539,7 +1649,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
1539
1649
|
payload = cursor.payload;
|
|
1540
1650
|
ignoreEvent = cursor.ignore;
|
|
1541
1651
|
}
|
|
1542
|
-
(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),
|
|
1543
1653
|
isTTY, rawLen: raw.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
|
|
1544
1654
|
argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
|
|
1545
1655
|
// Emit the verdict in the shape THIS client expects, then exit.
|
|
@@ -1577,7 +1687,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
1577
1687
|
const hasPendingLocalAsk = () => Boolean(pendingLocalAsk);
|
|
1578
1688
|
const respond = (blocked, userMsg, agentMsg, askEnvelope) => {
|
|
1579
1689
|
let ask = blocked === 'ask' && event !== 'prompt';
|
|
1580
|
-
|
|
1690
|
+
let denied = blocked === true;
|
|
1581
1691
|
if (!denied && !ask && pendingLocalAsk && event !== 'prompt') {
|
|
1582
1692
|
const { finding, call, ruleHash } = pendingLocalAsk;
|
|
1583
1693
|
pendingLocalAsk = undefined;
|
|
@@ -1598,6 +1708,22 @@ async function hookCommandInner(args, config, io) {
|
|
|
1598
1708
|
}
|
|
1599
1709
|
agentMsg = [prepared.agentMsg, agentMsg].filter(Boolean).join(' ');
|
|
1600
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
|
+
}
|
|
1601
1727
|
if (!denied && pendingLocalWarnings.length > 0) {
|
|
1602
1728
|
spoolLocalWarnings(pendingLocalWarnings.map(w => w.finding), pendingLocalWarnings[0].toolName, pendingLocalWarnings[0].operation);
|
|
1603
1729
|
}
|
|
@@ -1691,7 +1817,12 @@ async function hookCommandInner(args, config, io) {
|
|
|
1691
1817
|
await enforceActionPolicy({
|
|
1692
1818
|
event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
|
|
1693
1819
|
shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk,
|
|
1694
|
-
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
|
+
},
|
|
1695
1826
|
});
|
|
1696
1827
|
return;
|
|
1697
1828
|
}
|
|
@@ -1749,6 +1880,13 @@ async function hookCommandInner(args, config, io) {
|
|
|
1749
1880
|
// Unknown event → nothing to enforce.
|
|
1750
1881
|
respond(false);
|
|
1751
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
|
+
}
|
|
1752
1890
|
/**
|
|
1753
1891
|
* Approve-once for a taint-guard block: hold the action, alert the human at
|
|
1754
1892
|
* the keyboard, and poll for their one-time decision from their OWN terminal
|
|
@@ -1770,11 +1908,17 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1770
1908
|
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry. ${explained.agentMessage}`);
|
|
1771
1909
|
return false;
|
|
1772
1910
|
};
|
|
1773
|
-
const
|
|
1774
|
-
|
|
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'
|
|
1775
1914
|
&& ctx.approvalMode !== 'block';
|
|
1776
1915
|
if (!approveOnceEnabled)
|
|
1777
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');
|
|
1778
1922
|
const approval = (0, taintLedger_1.createTaintApproval)({
|
|
1779
1923
|
sessionId: sessId,
|
|
1780
1924
|
event,
|
|
@@ -1785,8 +1929,8 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1785
1929
|
});
|
|
1786
1930
|
if (!approval)
|
|
1787
1931
|
return hardBlock();
|
|
1788
|
-
const timeoutMs = Number(
|
|
1789
|
-
? Number(
|
|
1932
|
+
const timeoutMs = Number(env.FCD_TAINT_APPROVAL_TIMEOUT_MS) > 0
|
|
1933
|
+
? Number(env.FCD_TAINT_APPROVAL_TIMEOUT_MS)
|
|
1790
1934
|
: 120000; // local developer decision — short window, they are at the keyboard
|
|
1791
1935
|
const targetText = taintFinding.sink.targets.join(', ') || 'an external destination';
|
|
1792
1936
|
// forceWindow: a toast can be swallowed by Focus Assist; this decision must be seen.
|
|
@@ -1799,10 +1943,13 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1799
1943
|
+ `Deny: fullcourtdefense approve ${approval.id} --deny\n`
|
|
1800
1944
|
+ `(run "fullcourtdefense approve" to list; expires in ${Math.round(timeoutMs / 60000)} min)`,
|
|
1801
1945
|
});
|
|
1802
|
-
(0,
|
|
1803
|
-
|
|
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)));
|
|
1804
1950
|
(0, hookIo_1.dbg)({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
|
|
1805
1951
|
if (outcome === 'approved') {
|
|
1952
|
+
ctx.notePendingStop('');
|
|
1806
1953
|
spoolCallEvent(call, {
|
|
1807
1954
|
decision: 'allow',
|
|
1808
1955
|
reason: `Developer approved once after taint-guard hold (${taintFinding.ruleId}): ${taintFinding.reason}`,
|
|
@@ -2050,6 +2197,9 @@ async function enforceActionPolicy(ctx) {
|
|
|
2050
2197
|
if (tryDeveloperConfirmation({ event, respond, shadow, effectivePolicyHash, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
|
|
2051
2198
|
return;
|
|
2052
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
|
+
}
|
|
2053
2203
|
}
|
|
2054
2204
|
// PROXY RESILIENCE (fast probe): when the gate failed within the last 60s
|
|
2055
2205
|
// (bank proxy without the api hostname allowlisted, backend outage), skip
|
|
@@ -2160,6 +2310,11 @@ async function enforceActionPolicy(ctx) {
|
|
|
2160
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}`);
|
|
2161
2311
|
return;
|
|
2162
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');
|
|
2163
2318
|
// Make the wait visible on the machine: the hook process has no UI and its
|
|
2164
2319
|
// stderr is invisible inside IDEs, so a native toast is the only reliable
|
|
2165
2320
|
// signal that a human needs to act NOW (the approval window is minutes).
|
|
@@ -2170,9 +2325,10 @@ async function enforceActionPolicy(ctx) {
|
|
|
2170
2325
|
});
|
|
2171
2326
|
const outcome = await waitForApproval({
|
|
2172
2327
|
apiUrl, shieldId, shieldKey, actionId: result.approvalActionId,
|
|
2173
|
-
|
|
2328
|
+
deadline: humanWaitDeadline(ctx.approvalTimeoutMs), pollMs: ctx.approvalPollMs,
|
|
2174
2329
|
});
|
|
2175
2330
|
if (outcome.status === 'approved') {
|
|
2331
|
+
ctx.notePendingStop('');
|
|
2176
2332
|
(0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved', message: `${call.toolName} was approved and will continue.` });
|
|
2177
2333
|
respond(false, undefined, `FullCourtDefense: human approved this ${event}.`);
|
|
2178
2334
|
return;
|
|
@@ -2202,11 +2358,12 @@ async function waitForApproval(input) {
|
|
|
2202
2358
|
if (input.shieldKey)
|
|
2203
2359
|
headers['x-shield-key'] = input.shieldKey;
|
|
2204
2360
|
const url = `${input.apiUrl}/api/agent-security/runtime/approvals/${encodeURIComponent(input.actionId)}?shieldId=${encodeURIComponent(input.shieldId)}`;
|
|
2205
|
-
const deadline =
|
|
2206
|
-
(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()) });
|
|
2207
2363
|
while (Date.now() <= deadline) {
|
|
2208
2364
|
try {
|
|
2209
|
-
|
|
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()))) });
|
|
2210
2367
|
if (resp.ok) {
|
|
2211
2368
|
const body = await resp.json().catch(() => ({}));
|
|
2212
2369
|
const approval = body.data?.approval;
|
|
@@ -74,6 +74,7 @@ const policyGateHealth_1 = require("../policyGateHealth");
|
|
|
74
74
|
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
75
75
|
const machineIdentity_1 = require("../machineIdentity");
|
|
76
76
|
const backupManifest_1 = require("../backupManifest");
|
|
77
|
+
const shellAstLoader_1 = require("../shellAstLoader");
|
|
77
78
|
const devConfirm_1 = require("../devConfirm");
|
|
78
79
|
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
79
80
|
const MANAGED_SERVER_NAME = 'agentguard-gateway';
|
|
@@ -2148,6 +2149,7 @@ class McpGatewayServer {
|
|
|
2148
2149
|
}
|
|
2149
2150
|
}
|
|
2150
2151
|
async function mcpGatewayCommand(args, config) {
|
|
2152
|
+
await (0, shellAstLoader_1.ensureShellAstReady)(); // resident process: load the parser once, before the first tool call (shadow by default)
|
|
2151
2153
|
// The gateway is a resident proxy in front of the IDE's MCP servers — if it
|
|
2152
2154
|
// crashes, EVERY protected tool goes down at once. Any escaped exception
|
|
2153
2155
|
// (detached flush, timer, transport edge case) logs one line and the proxy
|
|
@@ -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 {
|
|
@@ -66,7 +66,7 @@ function assertWorkloadSubstrate(kind, rt, options = {}) {
|
|
|
66
66
|
+ 'or pass --force true if this really is a disposable workload host.');
|
|
67
67
|
}
|
|
68
68
|
/** Connections the console has a section for; anything else is the generic snippet. */
|
|
69
|
-
const ENROLLED_VIA_VALUES = new Set(['heroku', 'kubernetes', 'aws', 'gcp', 'snippet']);
|
|
69
|
+
const ENROLLED_VIA_VALUES = new Set(['heroku', 'kubernetes', 'aws', 'gcp', 'azure', 'snippet']);
|
|
70
70
|
function resolveEnrolledVia(explicit, env, detected) {
|
|
71
71
|
for (const candidate of [explicit, env.FCD_ENROLLED_VIA, detected]) {
|
|
72
72
|
const clean = (candidate || '').trim().toLowerCase();
|
|
@@ -105,7 +105,11 @@ function detectWorkloadContext(env = process.env, fsProbe = fs) {
|
|
|
105
105
|
}
|
|
106
106
|
if (env.FUNCTIONS_WORKER_RUNTIME && env.WEBSITE_SITE_NAME) {
|
|
107
107
|
// Azure Functions
|
|
108
|
-
return { kind: 'serverless', name: explicitName || env.WEBSITE_SITE_NAME, environment, instanceId: env.WEBSITE_INSTANCE_ID, image };
|
|
108
|
+
return { kind: 'serverless', name: explicitName || env.WEBSITE_SITE_NAME, environment, instanceId: env.WEBSITE_INSTANCE_ID, image, enrolledVia: 'azure' };
|
|
109
|
+
}
|
|
110
|
+
if (env.CONTAINER_APP_NAME && env.CONTAINER_APP_REVISION) {
|
|
111
|
+
// Azure Container Apps: the app name is the stable identity, the replica the instance.
|
|
112
|
+
return { kind: 'container', name: explicitName || env.CONTAINER_APP_NAME, environment, instanceId: env.CONTAINER_APP_REPLICA_NAME || env.HOSTNAME, image, enrolledVia: 'azure' };
|
|
109
113
|
}
|
|
110
114
|
if (env.ECS_CONTAINER_METADATA_URI_V4 || env.ECS_CONTAINER_METADATA_URI) {
|
|
111
115
|
// ECS / Fargate task: the service name comes from the task definition env.
|
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<{
|