fullcourtdefense-cli 1.36.4 → 1.37.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/askDialog.d.ts +1 -0
- package/dist/askDialog.js +20 -3
- package/dist/callerEnv.d.ts +29 -2
- package/dist/callerEnv.js +48 -2
- package/dist/commands/daemon.d.ts +10 -0
- package/dist/commands/daemon.js +348 -170
- package/dist/commands/desktopChatGuard.js +2 -0
- package/dist/commands/discoverPaths.d.ts +12 -9
- package/dist/commands/discoverPaths.js +30 -0
- package/dist/commands/ephemeralStack.js +7 -0
- package/dist/commands/flushSpool.d.ts +2 -0
- package/dist/commands/flushSpool.js +9 -2
- package/dist/commands/hook.d.ts +14 -0
- package/dist/commands/hook.js +302 -101
- package/dist/commands/httpGatewayCommand.d.ts +1 -0
- package/dist/commands/httpGatewayCommand.js +7 -1
- package/dist/commands/installClaudeHook.js +32 -10
- package/dist/commands/installCursorHook.js +20 -9
- package/dist/commands/login.js +8 -1
- package/dist/commands/mcpGateway.d.ts +21 -0
- package/dist/commands/mcpGateway.js +90 -32
- package/dist/commands/refreshPolicyCache.d.ts +28 -0
- package/dist/commands/refreshPolicyCache.js +59 -0
- package/dist/commands/watchdog.d.ts +2 -0
- package/dist/commands/watchdog.js +30 -5
- package/dist/config.d.ts +36 -0
- package/dist/config.js +78 -12
- package/dist/daemonDetectorBoot.d.ts +1 -0
- package/dist/daemonDetectorBoot.js +12 -0
- package/dist/daemonForensics.d.ts +14 -1
- package/dist/daemonForensics.js +32 -0
- package/dist/detectorInProcess.d.ts +18 -0
- package/dist/detectorInProcess.js +234 -0
- package/dist/detectorInThread.d.ts +15 -0
- package/dist/detectorInThread.js +129 -0
- package/dist/distress.d.ts +2 -0
- package/dist/distress.js +2 -0
- package/dist/fileWriteCanon.d.ts +17 -0
- package/dist/fileWriteCanon.js +141 -0
- package/dist/gateReports.d.ts +144 -0
- package/dist/gateReports.js +488 -0
- package/dist/hookIo.d.ts +59 -0
- package/dist/hookIo.js +319 -9
- package/dist/hookSlim.d.ts +15 -8
- package/dist/hookSlim.js +187 -25
- package/dist/housekeeping.d.ts +64 -0
- package/dist/housekeeping.js +384 -0
- package/dist/housekeepingOps.d.ts +150 -0
- package/dist/housekeepingOps.js +218 -0
- package/dist/housekeepingWorker.d.ts +1 -0
- package/dist/housekeepingWorker.js +113 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +19 -0
- package/dist/localDetectionData.d.ts +10 -0
- package/dist/localDetectionData.js +48 -0
- package/dist/localDetectionUpdates.d.ts +2 -2
- package/dist/localDetectionUpdates.js +4 -4
- package/dist/localSafetySnapshot.d.ts +48 -2
- package/dist/localSafetySnapshot.js +241 -25
- package/dist/machineIdentity.d.ts +7 -0
- package/dist/machineIdentity.js +20 -2
- package/dist/monitorFastPath.d.ts +191 -0
- package/dist/monitorFastPath.js +1198 -0
- package/dist/nativeHook.js +9 -2
- package/dist/pendingDrain.d.ts +82 -0
- package/dist/pendingDrain.js +215 -0
- package/dist/pendingDrainer.d.ts +30 -0
- package/dist/pendingDrainer.js +217 -0
- package/dist/perfSnapshot.d.ts +1 -1
- package/dist/perfSnapshot.js +28 -4
- package/dist/policyCacheFiles.d.ts +73 -0
- package/dist/policyCacheFiles.js +320 -0
- package/dist/policyGateHealth.d.ts +8 -0
- package/dist/policyGateHealth.js +17 -0
- package/dist/policyRefresh.d.ts +28 -0
- package/dist/policyRefresh.js +69 -0
- package/dist/protectionMaintenance.d.ts +15 -0
- package/dist/protectionMaintenance.js +43 -1
- package/dist/runtimeConfig.d.ts +77 -20
- package/dist/runtimeConfig.js +278 -57
- package/dist/selfTest.d.ts +6 -1
- package/dist/selfTest.js +11 -4
- package/dist/telemetry.d.ts +136 -4
- package/dist/telemetry.js +493 -96
- package/dist/verdictIpc.d.ts +60 -0
- package/dist/verdictIpc.js +504 -19
- package/dist/verdictIpcClient.d.ts +175 -18
- package/dist/verdictIpcClient.js +277 -59
- package/dist/version.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DetectorArtifact } from './detectorArtifact';
|
|
2
|
+
import { type DetectorFacts } from './detectorRuntime';
|
|
3
|
+
/** Same contract as detectorModule.compileDetector: a callable detector with dispose(). */
|
|
4
|
+
export declare function compileDetectorInThread(artifact: DetectorArtifact): ((tool: string, args: Record<string, any>) => DetectorFacts) & {
|
|
5
|
+
dispose(): void;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Resident daemon only: every detector this process compiles from now on
|
|
9
|
+
* (the bundled baseline, cached and downloaded updates) is compiled in-thread.
|
|
10
|
+
* Must run before the first detector is compiled — detectorUpdates activates
|
|
11
|
+
* the baseline when it loads — so index.ts runs it first (daemonDetectorBoot).
|
|
12
|
+
*/
|
|
13
|
+
export declare function useInThreadDetector(): boolean;
|
|
14
|
+
/** For the heartbeat: whether this process's detector runs in-thread. */
|
|
15
|
+
export declare function inThreadDetectorActive(): boolean;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.compileDetectorInThread = compileDetectorInThread;
|
|
4
|
+
exports.useInThreadDetector = useInThreadDetector;
|
|
5
|
+
exports.inThreadDetectorActive = inThreadDetectorActive;
|
|
6
|
+
const vm_1 = require("vm");
|
|
7
|
+
const detectorRuntime_1 = require("./detectorRuntime");
|
|
8
|
+
const detectionData_1 = require("./detectionData");
|
|
9
|
+
/**
|
|
10
|
+
* The signed detector compiled on the CALLING thread, for the resident daemon.
|
|
11
|
+
*
|
|
12
|
+
* detectorModule.compileDetector runs the artifact in a Worker and answers
|
|
13
|
+
* each inference through Atomics.wait: up to 4 s at compile and 500 ms per
|
|
14
|
+
* inference with the daemon's only event loop blocked, and a single missed
|
|
15
|
+
* deadline retired the detector for the rest of the daemon's life (LPTX1110 ran
|
|
16
|
+
* 2.6 days without it while every fallback process had it — RC7). Inference
|
|
17
|
+
* itself takes well under a millisecond, so the daemon compiles the same
|
|
18
|
+
* artifact here instead: same VM context options, same URL bridge, same input
|
|
19
|
+
* JSON (`[tool, args, detectionData]`), same output JSON, so the facts are
|
|
20
|
+
* identical to the Worker's (drilled over the whole IDE corpus).
|
|
21
|
+
*
|
|
22
|
+
* detectorModule, detectorRuntime and detectorUpdates are byte-identical twins
|
|
23
|
+
* of backend modules and stay untouched: the daemon swaps the compile factory
|
|
24
|
+
* those modules call (useInThreadDetector), before the first detector loads.
|
|
25
|
+
* Revision selection, activation, persistence and status reporting are theirs.
|
|
26
|
+
*/
|
|
27
|
+
/** Largest detector result accepted, in bytes (the Worker's shared output buffer). */
|
|
28
|
+
const MAX_OUTPUT_BYTES = 512 * 1024;
|
|
29
|
+
const MAX_INPUT_BYTES = 256 * 1024;
|
|
30
|
+
/** VM deadline per inference: the Worker's own in-VM bound. Wall time, so it also covers time descheduled by Windows. */
|
|
31
|
+
const INFERENCE_TIMEOUT_MS = 250;
|
|
32
|
+
/** Same contract as detectorModule.compileDetector: a callable detector with dispose(). */
|
|
33
|
+
function compileDetectorInThread(artifact) {
|
|
34
|
+
const context = (0, vm_1.createContext)(Object.create(null), { codeGeneration: { strings: false, wasm: false } });
|
|
35
|
+
let evaluate;
|
|
36
|
+
try {
|
|
37
|
+
// Expose parsed scalar fields, not Node's host URL constructor or native
|
|
38
|
+
// exceptions. The wrapper and all values visible to code belong to the VM.
|
|
39
|
+
const makeUrl = new vm_1.Script('(parse => class URL { constructor(input, base) { const fields=JSON.parse(parse(String(input), base===undefined?undefined:String(base))); if(fields.error)throw new TypeError("Invalid URL"); Object.assign(this,fields); } toString(){return this.href;} })').runInContext(context, { timeout: 1000 });
|
|
40
|
+
context.URL = makeUrl((input, base) => {
|
|
41
|
+
try {
|
|
42
|
+
if (typeof input !== 'string' || input.length > 256 * 1024 || (base !== undefined && (typeof base !== 'string' || base.length > 256 * 1024)))
|
|
43
|
+
return '{"error":true}';
|
|
44
|
+
const value = new URL(input, base);
|
|
45
|
+
const fields = {};
|
|
46
|
+
for (const key of ['href', 'origin', 'protocol', 'username', 'password', 'host', 'hostname', 'port', 'pathname', 'search', 'hash'])
|
|
47
|
+
fields[key] = value[key];
|
|
48
|
+
return JSON.stringify(fields);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return '{"error":true}';
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
new vm_1.Script(artifact.code).runInContext(context, { timeout: 1000 });
|
|
55
|
+
new vm_1.Script('if(typeof FcdDetector?.inferToolContext!=="function")throw new Error("Missing export")').runInContext(context, { timeout: 1000 });
|
|
56
|
+
evaluate = new vm_1.Script('JSON.stringify(FcdDetector.inferToolContext(...JSON.parse(inputJson)))');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error('Detector initialization failed');
|
|
60
|
+
}
|
|
61
|
+
let stopped = false;
|
|
62
|
+
const dispose = () => { stopped = true; };
|
|
63
|
+
const invoke = (tool, args) => {
|
|
64
|
+
if (stopped)
|
|
65
|
+
throw new detectorRuntime_1.DetectorWorkerUnavailable('Detector worker unavailable');
|
|
66
|
+
const input = JSON.stringify([tool, args, (0, detectionData_1.getDetectionData)()]);
|
|
67
|
+
if (Buffer.byteLength(input) > MAX_INPUT_BYTES)
|
|
68
|
+
throw new Error('Detector input exceeds bound');
|
|
69
|
+
let text;
|
|
70
|
+
for (let attempt = 1;; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
context.inputJson = input;
|
|
73
|
+
text = evaluate.runInContext(context, { timeout: INFERENCE_TIMEOUT_MS });
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// A missed deadline leaves this context intact (a Worker had to be
|
|
78
|
+
// terminated). The deadline is wall time, so a thread descheduled by a
|
|
79
|
+
// busy machine misses it on a sub-millisecond inference: run it once
|
|
80
|
+
// more. Twice is one failure, and the next action may use the detector
|
|
81
|
+
// again. (detectorRuntime never forgets failures and retires a
|
|
82
|
+
// detector at its third, for the daemon's whole life: a scheduling
|
|
83
|
+
// blip must not count.) Any other fault is a detector bug: retire it,
|
|
84
|
+
// as the Worker host does.
|
|
85
|
+
if (error?.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
|
|
86
|
+
if (attempt < 2)
|
|
87
|
+
continue;
|
|
88
|
+
throw new Error('Detector timed out');
|
|
89
|
+
}
|
|
90
|
+
dispose();
|
|
91
|
+
throw new detectorRuntime_1.DetectorWorkerUnavailable('Detector failed');
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
delete context.inputJson;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_OUTPUT_BYTES) {
|
|
98
|
+
dispose();
|
|
99
|
+
throw new detectorRuntime_1.DetectorWorkerUnavailable('Detector output invalid or too large');
|
|
100
|
+
}
|
|
101
|
+
return JSON.parse(text);
|
|
102
|
+
};
|
|
103
|
+
return Object.assign(invoke, { dispose });
|
|
104
|
+
}
|
|
105
|
+
let swapped = false;
|
|
106
|
+
/**
|
|
107
|
+
* Resident daemon only: every detector this process compiles from now on
|
|
108
|
+
* (the bundled baseline, cached and downloaded updates) is compiled in-thread.
|
|
109
|
+
* Must run before the first detector is compiled — detectorUpdates activates
|
|
110
|
+
* the baseline when it loads — so index.ts runs it first (daemonDetectorBoot).
|
|
111
|
+
*/
|
|
112
|
+
function useInThreadDetector() {
|
|
113
|
+
if (swapped)
|
|
114
|
+
return true;
|
|
115
|
+
try {
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
117
|
+
const host = require('./detectorModule');
|
|
118
|
+
host.compileDetector = compileDetectorInThread;
|
|
119
|
+
swapped = host.compileDetector === compileDetectorInThread;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
swapped = false;
|
|
123
|
+
}
|
|
124
|
+
return swapped;
|
|
125
|
+
}
|
|
126
|
+
/** For the heartbeat: whether this process's detector runs in-thread. */
|
|
127
|
+
function inThreadDetectorActive() {
|
|
128
|
+
return swapped;
|
|
129
|
+
}
|
package/dist/distress.d.ts
CHANGED
|
@@ -70,6 +70,8 @@ export declare const DISTRESS: {
|
|
|
70
70
|
* stale past the bound and the verdict pipe silent — and recycled it.
|
|
71
71
|
*/
|
|
72
72
|
readonly DAEMON_HUNG: "daemon_hung";
|
|
73
|
+
/** Daemon alive and answering, but rejecting this profile's hooks: its token file was replaced. */
|
|
74
|
+
readonly IPC_NOT_SERVING: "ipc_not_serving";
|
|
73
75
|
/**
|
|
74
76
|
* The daemon exited itself after repeated uncaught exceptions in a short
|
|
75
77
|
* window (process state suspect); the supervisor restarts it clean.
|
package/dist/distress.js
CHANGED
|
@@ -115,6 +115,8 @@ exports.DISTRESS = {
|
|
|
115
115
|
* stale past the bound and the verdict pipe silent — and recycled it.
|
|
116
116
|
*/
|
|
117
117
|
DAEMON_HUNG: 'daemon_hung',
|
|
118
|
+
/** Daemon alive and answering, but rejecting this profile's hooks: its token file was replaced. */
|
|
119
|
+
IPC_NOT_SERVING: 'ipc_not_serving',
|
|
118
120
|
/**
|
|
119
121
|
* The daemon exited itself after repeated uncaught exceptions in a short
|
|
120
122
|
* window (process state suspect); the supervisor restarts it clean.
|
package/dist/fileWriteCanon.d.ts
CHANGED
|
@@ -37,6 +37,23 @@ export declare function addedLinesOnly(existing: string, content: string): strin
|
|
|
37
37
|
* go through this, so every client yields the same canonical `content`.
|
|
38
38
|
*/
|
|
39
39
|
export declare function newContentOnly(filePath: string | undefined, content: string): string;
|
|
40
|
+
/** One target file as newContentOnly saw it when the hook ran (see above). */
|
|
41
|
+
export type RecordedFileState = {
|
|
42
|
+
h: string;
|
|
43
|
+
l: string[];
|
|
44
|
+
w: string[];
|
|
45
|
+
} | null;
|
|
46
|
+
/** By path, exactly as the payload names it. */
|
|
47
|
+
export type RecordedFileStates = Record<string, RecordedFileState>;
|
|
48
|
+
/**
|
|
49
|
+
* Record, before the hook answers, every file a write in this payload targets
|
|
50
|
+
* (see above). undefined when the payload writes no file; null when it could
|
|
51
|
+
* not be recorded (the caller then evaluates the call itself). Reads each
|
|
52
|
+
* target once (as the full evaluation would); never throws.
|
|
53
|
+
*/
|
|
54
|
+
export declare function recordFileStates(payload: unknown): RecordedFileStates | undefined | null;
|
|
55
|
+
/** Run a replay with the files as its hook recorded them (recordFileStates); without a record, as before. */
|
|
56
|
+
export declare function withRecordedFileStates<T>(states: unknown, fn: () => T): T;
|
|
40
57
|
/**
|
|
41
58
|
* For a whole-file write tool (MCP `write_file`, …) with `{ path, content }`
|
|
42
59
|
* arguments, replace `content` with only the added lines. Any other tool or
|
package/dist/fileWriteCanon.js
CHANGED
|
@@ -38,7 +38,11 @@ exports.canonicalToolName = canonicalToolName;
|
|
|
38
38
|
exports.maskTokensAlreadyPresent = maskTokensAlreadyPresent;
|
|
39
39
|
exports.addedLinesOnly = addedLinesOnly;
|
|
40
40
|
exports.newContentOnly = newContentOnly;
|
|
41
|
+
exports.recordFileStates = recordFileStates;
|
|
42
|
+
exports.withRecordedFileStates = withRecordedFileStates;
|
|
41
43
|
exports.canonicalizeFileWriteArgs = canonicalizeFileWriteArgs;
|
|
44
|
+
const async_hooks_1 = require("async_hooks");
|
|
45
|
+
const crypto = __importStar(require("crypto"));
|
|
42
46
|
const fs = __importStar(require("fs"));
|
|
43
47
|
/**
|
|
44
48
|
* Canonicalization of FILE WRITES before any policy decision.
|
|
@@ -118,11 +122,148 @@ function addedLinesOnly(existing, content) {
|
|
|
118
122
|
function newContentOnly(filePath, content) {
|
|
119
123
|
if (!filePath || !content)
|
|
120
124
|
return content;
|
|
125
|
+
// A replay of a queued hook payload: the file as it was when the hook ran.
|
|
126
|
+
const recorded = recordedFileStates.getStore();
|
|
127
|
+
if (recorded && recorded.has(filePath)) {
|
|
128
|
+
const state = recorded.get(filePath);
|
|
129
|
+
return state ? newContentFromRecord(state, content) : content;
|
|
130
|
+
}
|
|
121
131
|
const existing = readExisting(filePath);
|
|
122
132
|
if (existing === undefined)
|
|
123
133
|
return content;
|
|
124
134
|
return maskTokensAlreadyPresent(existing, addedLinesOnly(existing, content));
|
|
125
135
|
}
|
|
136
|
+
const recordedFileStates = new async_hooks_1.AsyncLocalStorage();
|
|
137
|
+
/** Keys a file write names its target by (mapBuiltinToolPayload, buildToolCall 'file', canonicalizeFileWriteArgs). */
|
|
138
|
+
const FILE_PATH_KEYS = new Set(['file_path', 'notebook_path', 'path', 'filePath', 'target_file']);
|
|
139
|
+
/** Keys a file write carries its text in, next to the path (same readers). */
|
|
140
|
+
const FILE_CONTENT_KEYS = new Set(['content', 'contents', 'new_string', 'newString', 'newText', 'new_str', 'new_text', 'edits', 'new_source', 'code', 'text']);
|
|
141
|
+
const MAX_RECORDED_FILES = 16;
|
|
142
|
+
const MAX_PAYLOAD_DEPTH = 10;
|
|
143
|
+
function digest(text) {
|
|
144
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 32);
|
|
145
|
+
}
|
|
146
|
+
/** newContentOnly against a recorded file: the same reduction, asked of the record. */
|
|
147
|
+
function newContentFromRecord(state, content) {
|
|
148
|
+
if (digest(content) === state.h)
|
|
149
|
+
return '';
|
|
150
|
+
const known = new Set(state.l);
|
|
151
|
+
const added = content.split(/\r?\n/).filter(line => {
|
|
152
|
+
const t = line.trim();
|
|
153
|
+
return t && !known.has(digest(t));
|
|
154
|
+
}).join('\n');
|
|
155
|
+
if (!added)
|
|
156
|
+
return added;
|
|
157
|
+
const present = new Set(state.w);
|
|
158
|
+
const credentialWord = new RegExp(`${CREDENTIAL_WORD_CLASS}{${CREDENTIAL_WORD_MIN},}`, 'g');
|
|
159
|
+
return added.replace(credentialWord, word => (present.has(digest(word)) ? exports.UNCHANGED_TOKEN_MARK : word));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Record, before the hook answers, every file a write in this payload targets
|
|
163
|
+
* (see above). undefined when the payload writes no file; null when it could
|
|
164
|
+
* not be recorded (the caller then evaluates the call itself). Reads each
|
|
165
|
+
* target once (as the full evaluation would); never throws.
|
|
166
|
+
*/
|
|
167
|
+
function recordFileStates(payload) {
|
|
168
|
+
try {
|
|
169
|
+
const targets = new Set();
|
|
170
|
+
const lines = new Set();
|
|
171
|
+
const visit = (value, depth) => {
|
|
172
|
+
if (depth > MAX_PAYLOAD_DEPTH)
|
|
173
|
+
return;
|
|
174
|
+
if (typeof value === 'string') {
|
|
175
|
+
for (const line of value.split(/\r?\n/))
|
|
176
|
+
lines.add(line);
|
|
177
|
+
// A JSON-encoded argument object (Cursor's tool_input), read as the hook reads it.
|
|
178
|
+
const trimmed = value.trim();
|
|
179
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
180
|
+
try {
|
|
181
|
+
visit(JSON.parse(trimmed), depth + 1);
|
|
182
|
+
}
|
|
183
|
+
catch { /* plain text */ }
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (Array.isArray(value)) {
|
|
188
|
+
for (const item of value)
|
|
189
|
+
visit(item, depth + 1);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!value || typeof value !== 'object')
|
|
193
|
+
return;
|
|
194
|
+
const record = value;
|
|
195
|
+
if (Object.keys(record).some(key => FILE_CONTENT_KEYS.has(key))) {
|
|
196
|
+
for (const key of FILE_PATH_KEYS) {
|
|
197
|
+
if (typeof record[key] === 'string' && record[key])
|
|
198
|
+
targets.add(record[key]);
|
|
199
|
+
}
|
|
200
|
+
if (Array.isArray(record.files)) {
|
|
201
|
+
const joined = record.files.map(f => (typeof f === 'string' ? f : '')).filter(Boolean).join(', ');
|
|
202
|
+
if (joined)
|
|
203
|
+
targets.add(joined);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const item of Object.values(record))
|
|
207
|
+
visit(item, depth + 1);
|
|
208
|
+
};
|
|
209
|
+
visit(payload, 0);
|
|
210
|
+
if (targets.size === 0)
|
|
211
|
+
return undefined;
|
|
212
|
+
const credentialWord = new RegExp(`${CREDENTIAL_WORD_CLASS}{${CREDENTIAL_WORD_MIN},}`, 'g');
|
|
213
|
+
const words = new Set();
|
|
214
|
+
for (const line of lines)
|
|
215
|
+
for (const word of line.match(credentialWord) || [])
|
|
216
|
+
words.add(word);
|
|
217
|
+
const states = new Map();
|
|
218
|
+
for (const target of Array.from(targets).slice(0, MAX_RECORDED_FILES)) {
|
|
219
|
+
const existing = readExisting(target);
|
|
220
|
+
if (existing === undefined) {
|
|
221
|
+
states.set(target, null);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const known = new Set(existing.split(/\r?\n/).map(line => line.trim()).filter(Boolean));
|
|
225
|
+
const present = new Set(Array.from(words).filter(word => existing.includes(word)));
|
|
226
|
+
const l = new Set();
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
const t = line.trim();
|
|
229
|
+
if (t && known.has(t))
|
|
230
|
+
l.add(digest(t));
|
|
231
|
+
// maskTokensAlreadyPresent, per line (a word never spans a line).
|
|
232
|
+
const masked = existing ? line.replace(credentialWord, word => (present.has(word) ? exports.UNCHANGED_TOKEN_MARK : word)) : line;
|
|
233
|
+
const mt = masked.trim();
|
|
234
|
+
if (masked !== line && mt && known.has(mt))
|
|
235
|
+
l.add(digest(mt));
|
|
236
|
+
}
|
|
237
|
+
states.set(target, { h: digest(existing), l: Array.from(l), w: Array.from(present, digest) });
|
|
238
|
+
}
|
|
239
|
+
return Object.fromEntries(states);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** A queued record read back: only well-formed entries (anything else is read from the disk, as before). */
|
|
246
|
+
function recordedStatesMap(states) {
|
|
247
|
+
if (!states || typeof states !== 'object' || Array.isArray(states))
|
|
248
|
+
return undefined;
|
|
249
|
+
const map = new Map();
|
|
250
|
+
const strings = (v) => Array.isArray(v) && v.every(s => typeof s === 'string');
|
|
251
|
+
for (const [target, state] of Object.entries(states)) {
|
|
252
|
+
if (state === null) {
|
|
253
|
+
map.set(target, null);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const s = state;
|
|
257
|
+
if (s && typeof s === 'object' && typeof s.h === 'string' && strings(s.l) && strings(s.w))
|
|
258
|
+
map.set(target, { h: s.h, l: s.l, w: s.w });
|
|
259
|
+
}
|
|
260
|
+
return map.size ? map : undefined;
|
|
261
|
+
}
|
|
262
|
+
/** Run a replay with the files as its hook recorded them (recordFileStates); without a record, as before. */
|
|
263
|
+
function withRecordedFileStates(states, fn) {
|
|
264
|
+
const map = recordedStatesMap(states);
|
|
265
|
+
return map ? recordedFileStates.run(map, fn) : fn();
|
|
266
|
+
}
|
|
126
267
|
/**
|
|
127
268
|
* For a whole-file write tool (MCP `write_file`, …) with `{ path, content }`
|
|
128
269
|
* arguments, replace `content` with only the added lines. Any other tool or
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred gate reports — the audit of a native hook's block-mode local-first
|
|
3
|
+
* ALLOW (Codex, Gemini CLI, Windsurf, Kiro).
|
|
4
|
+
*
|
|
5
|
+
* That allow is reported to check-tool-call. The native process used to send
|
|
6
|
+
* the report fire-and-forget and stay alive until it settled: up to 4 s after
|
|
7
|
+
* its verdict on a stalled API, with the IDE waiting for the process to exit.
|
|
8
|
+
* The report also fed later decisions:
|
|
9
|
+
* - any HTTP answer ended the gate-failure streak (policyGateHealth.ts),
|
|
10
|
+
* which decides the 60 s fast-probe skip (inside it an org approval is
|
|
11
|
+
* denied offline) and the fail-closed count;
|
|
12
|
+
* - the server recorded the action in the agent's behavior baseline, whose
|
|
13
|
+
* facts (tool.status, agent.baseline, toolArgs.mb1h) its later gate
|
|
14
|
+
* verdicts read.
|
|
15
|
+
* The native process now exits with its verdict and queues the SAME request
|
|
16
|
+
* here, one file each (0600, under ~/.fullcourtdefense). It is sent off the
|
|
17
|
+
* verdict path, with the old semantics:
|
|
18
|
+
* - by the detached flusher (drainSpool), and
|
|
19
|
+
* - by the next evaluation that reads gate health or calls the gate, BEFORE
|
|
20
|
+
* it does (settleGateReports below — the hook, the MCP gateway and the
|
|
21
|
+
* HTTP gateway): the old report had landed before its process exited, so
|
|
22
|
+
* before any later evaluation on this machine.
|
|
23
|
+
* Any HTTP answer → the streak ends (recordGateReportAnswered). A network
|
|
24
|
+
* failure → the allow is spooled ("gate report failed — spooled"), as before.
|
|
25
|
+
* A report is sent at most once: a deliverer claims it by an atomic rename.
|
|
26
|
+
*
|
|
27
|
+
* The queue is a user-writable directory: a file in it is DATA, never a
|
|
28
|
+
* destination or a credential. Where a report goes and with which key comes
|
|
29
|
+
* only from the configuration of the process that sends it (its sender: the
|
|
30
|
+
* hook's, the flusher's, the gateway's own enrollment — and for a resident
|
|
31
|
+
* gateway, the machine's enrollment its hooks use), each key only to the API
|
|
32
|
+
* URL configured with it. The file names one thing, the body's shield, and a
|
|
33
|
+
* report is sent only by a sender OF that shield: never with another shield's
|
|
34
|
+
* key, never to a URL the file names (an `apiUrl`, `shieldKey` or any other
|
|
35
|
+
* field in it is ignored). A report no sender of this process may send stays
|
|
36
|
+
* queued for its own shield's process; the flusher, the last deliverer,
|
|
37
|
+
* spools it (the spool uploads to its own configured endpoint). A 401/403
|
|
38
|
+
* answer (the server refused the report) also spools it: the allow is not
|
|
39
|
+
* lost. An entry that is not a well-formed report (schema, size, a decision
|
|
40
|
+
* time that disagrees with its name) is dropped unsent.
|
|
41
|
+
*/
|
|
42
|
+
/** The fire-and-forget report's timeout. */
|
|
43
|
+
export declare const GATE_REPORT_TIMEOUT_MS = 4000;
|
|
44
|
+
/**
|
|
45
|
+
* One queued report on disk, at most. A larger allow is spooled at once by the
|
|
46
|
+
* queueing hook; a larger file is not a report and is dropped unread.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MAX_GATE_REPORT_BYTES: number;
|
|
49
|
+
export interface QueuedGateReport<E = unknown> {
|
|
50
|
+
/** When the verdict was decided — the moment the report used to be sent. Equals the file name's prefix. */
|
|
51
|
+
decidedAt: number;
|
|
52
|
+
/**
|
|
53
|
+
* The exact check-tool-call body: a JSON object naming its shield
|
|
54
|
+
* (`shieldId`). Never the shield key: the sender adds its own.
|
|
55
|
+
*/
|
|
56
|
+
body: string;
|
|
57
|
+
/** The spool record written when the report cannot be delivered (a `verdict` allow). */
|
|
58
|
+
fallback: E;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Configured credentials a process sends reports with: only reports of
|
|
62
|
+
* `shieldId`, only to `apiUrl`, only with `shieldKey`. Never built from the queue.
|
|
63
|
+
*/
|
|
64
|
+
export interface GateReportSender {
|
|
65
|
+
shieldId?: string;
|
|
66
|
+
apiUrl: string;
|
|
67
|
+
shieldKey?: string;
|
|
68
|
+
}
|
|
69
|
+
/** Queue one report. False when it could not be queued: the caller spools the allow instead. */
|
|
70
|
+
export declare function queueGateReport(report: QueuedGateReport): boolean;
|
|
71
|
+
/** Decision times of the reports not yet delivered (being sent included). */
|
|
72
|
+
export declare function queuedGateReports(): number[];
|
|
73
|
+
/** A report a flusher could send now: queued, or claimed by a deliverer that died. */
|
|
74
|
+
export declare function hasQueuedGateReports(): boolean;
|
|
75
|
+
/** Same check without blocking the caller's event loop (resident daemon timer). */
|
|
76
|
+
export declare function hasQueuedGateReportsAsync(): Promise<boolean>;
|
|
77
|
+
export interface DeliverGateReportsInput<E> {
|
|
78
|
+
/** This process's configured enrollment: the destination and key for reports of its shield. */
|
|
79
|
+
apiUrl: string;
|
|
80
|
+
shieldId?: string;
|
|
81
|
+
shieldKey?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Resident gateways: the machine's enrollment (~/.fullcourtdefense.yml and
|
|
84
|
+
* the environment — what the hooks that queue reports resolve), for reports
|
|
85
|
+
* of that shield when the gateway runs for another one. Resolved from this
|
|
86
|
+
* process's configuration, never from the queue; called only when such a
|
|
87
|
+
* report is waiting.
|
|
88
|
+
*/
|
|
89
|
+
machineSender?: () => GateReportSender | undefined;
|
|
90
|
+
/** Only reports decided at or after this time (default: all). */
|
|
91
|
+
decidedFrom?: number;
|
|
92
|
+
/** Also wait, bounded, for matching reports another live process is sending. */
|
|
93
|
+
waitForOthers?: boolean;
|
|
94
|
+
/** Network failure: the allow goes to the spool. */
|
|
95
|
+
spool: (fallback: E) => void;
|
|
96
|
+
/** The machine id for a body queued without one (the queueing process never reads it natively). */
|
|
97
|
+
machineId?: () => string | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* A report of a shield no sender of this process serves: left queued for
|
|
100
|
+
* its own shield's process (default), or spooled — the flusher, the last
|
|
101
|
+
* deliverer.
|
|
102
|
+
*/
|
|
103
|
+
unmatched?: 'leave' | 'spool';
|
|
104
|
+
}
|
|
105
|
+
export interface DeliverGateReportsResult {
|
|
106
|
+
answered: number;
|
|
107
|
+
failed: number;
|
|
108
|
+
/** Entries that were not well-formed reports: removed, never sent. */
|
|
109
|
+
dropped: number;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Send the queued reports (claimed by this call) in parallel, each with the
|
|
113
|
+
* old 4 s timeout. With `waitForOthers`, also wait until the matching reports
|
|
114
|
+
* other processes claimed are gone; the whole call stays within ~4.5 s.
|
|
115
|
+
*/
|
|
116
|
+
export declare function deliverGateReports<E>(input: DeliverGateReportsInput<E>): Promise<DeliverGateReportsResult>;
|
|
117
|
+
export interface SettleGateReportsInput<E> {
|
|
118
|
+
/** The evaluation's configured enrollment: reports of its shield go here with its key. */
|
|
119
|
+
apiUrl: string;
|
|
120
|
+
shieldId?: string;
|
|
121
|
+
/** Resolved only when a report is queued. */
|
|
122
|
+
shieldKey?: string | (() => string | undefined);
|
|
123
|
+
/** Resident gateways: see DeliverGateReportsInput.machineSender. */
|
|
124
|
+
machineSender?: () => GateReportSender | undefined;
|
|
125
|
+
/** The caller calls the gate next unless it is recently down (fast-probe skip). */
|
|
126
|
+
forGateCall: boolean;
|
|
127
|
+
/** Network failure: the allow goes to the spool. */
|
|
128
|
+
spool: (fallback: E) => void;
|
|
129
|
+
machineId?: () => string | undefined;
|
|
130
|
+
/** Diagnostics, once per delivery round. */
|
|
131
|
+
onSettled?: (scope: 'streak' | 'baseline', outcome: DeliverGateReportsResult) => void;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Queued native allow reports used to be sent before those processes exited,
|
|
135
|
+
* i.e. before any later evaluation on this machine. An evaluation about to
|
|
136
|
+
* read gate health or call the gate lets them land first:
|
|
137
|
+
* - reports decided after the streak's last failure: their answer may end
|
|
138
|
+
* the streak (fast-probe skip, fail-closed count);
|
|
139
|
+
* - `forGateCall` and the gate will be called: the rest too, since the
|
|
140
|
+
* server's verdict reads the behavior baseline they feed.
|
|
141
|
+
* Bounded by the reports' own 4 s timeout; one readdir when none is queued.
|
|
142
|
+
* Never throws: the evaluation continues as it would have.
|
|
143
|
+
*/
|
|
144
|
+
export declare function settleGateReports<E>(input: SettleGateReportsInput<E>): Promise<void>;
|