thumbgate 1.34.3 → 1.37.1
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/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
- package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
- package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
- package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
- package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +1 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/CONTRIBUTING.md +95 -0
- package/README.md +195 -632
- package/THIRD_PARTY_NOTICES.md +89 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/future-agi/.mcp.json +8 -0
- package/adapters/future-agi/FUTURE_AGI.md +23 -0
- package/adapters/future-agi/config.toml +3 -0
- package/adapters/future-agi/future-agi-bridge.js +9 -0
- package/adapters/future-agi/opencode.json +8 -0
- package/adapters/herdr/herdr-plugin.toml +18 -0
- package/adapters/mcp/server-stdio.js +238 -25
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/workos/WORKOS.md +52 -0
- package/bin/cli.js +373 -5
- package/bin/futureagi-bridge +9 -0
- package/config/gate-templates.json +653 -4
- package/config/gates/actor-critic-audit.json +34 -0
- package/config/gates/default.json +21 -2
- package/config/gates/five-walls-governance.json +34 -0
- package/config/gates/future-agi-guardrails.json +34 -0
- package/config/gates/radware-threat-defense-2026.json +61 -0
- package/config/gates/simatree-data-governance.json +33 -0
- package/config/mcp-allowlists.json +4 -0
- package/config/merge-quality-checks.json +10 -1
- package/config/model-candidates.json +382 -24
- package/config/model-tiers.json +18 -0
- package/config/post-deploy-marketing-pages.json +10 -0
- package/config/progressive/01-wire-only.json +11 -0
- package/config/progressive/02-dashboard-empty-ok.json +10 -0
- package/config/progressive/03-one-lesson.json +10 -0
- package/config/progressive/04-warn-fires.json +11 -0
- package/config/progressive/05-strict-optional.json +11 -0
- package/config/progressive/README.md +15 -0
- package/config/schemas/broker-execution-receipt.schema.json +139 -0
- package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
- package/conformance/provider-attestation/vectors.json +320 -0
- package/docs/specs/provider-execution-attestation-v1.md +69 -0
- package/openapi/openapi.yaml +15 -0
- package/package.json +401 -147
- package/public/about.html +2 -2
- package/public/ai-malpractice-prevention.html +7 -7
- package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
- package/public/blog/a-receipt-is-not-world-state.html +388 -0
- package/public/blog/git-at-agent-scale.html +374 -0
- package/public/blog/no-llm-in-the-gate.html +133 -0
- package/public/blog.html +80 -0
- package/public/case-studies.html +16 -1
- package/public/compare.html +28 -0
- package/public/diagnostic.html +216 -7
- package/public/docs/connectors.html +39 -0
- package/public/federal.html +2 -2
- package/public/founders.html +639 -0
- package/public/index.html +87 -9
- package/public/install.html +8 -8
- package/public/learn.html +39 -0
- package/public/numbers.html +2 -2
- package/public/peter.html +310 -0
- package/public/platform-partners.html +119 -0
- package/public/pricing.html +24 -3
- package/public/privacy.html +117 -0
- package/public/pro.html +17 -0
- package/public/support.html +62 -0
- package/public/terms.html +130 -0
- package/public/third-party-notices.html +95 -0
- package/public/yt.html +351 -0
- package/scripts/action-receipts.js +133 -3
- package/scripts/adaptive-governance-arena.js +349 -0
- package/scripts/admin-override.js +205 -0
- package/scripts/agent-action-inventory.js +869 -0
- package/scripts/agent-audit-trace.js +42 -2
- package/scripts/agent-egress-policy.js +1117 -0
- package/scripts/agent-memory-lifecycle.js +141 -2
- package/scripts/agent-operations-planner.js +441 -1
- package/scripts/agent-readiness.js +68 -0
- package/scripts/agent-security-central.js +647 -0
- package/scripts/allowlist-bridge-honesty.js +417 -0
- package/scripts/async-job-runner.js +102 -11
- package/scripts/audit-trail.js +212 -0
- package/scripts/auto-promote-gates.js +178 -27
- package/scripts/billing.js +1 -1
- package/scripts/broker-execution-receipts.js +719 -0
- package/scripts/budget-aware-gates-proof.js +423 -0
- package/scripts/claude-feedback-sync.js +29 -3
- package/scripts/claw-harness-production.js +237 -0
- package/scripts/cli-progress.js +111 -0
- package/scripts/cli-schema.js +163 -1
- package/scripts/codex-runbook-flywheel.js +318 -0
- package/scripts/context-footprint.js +186 -0
- package/scripts/contextfs.js +143 -61
- package/scripts/dashboard-limits.js +27 -0
- package/scripts/dashboard.js +279 -9
- package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
- package/scripts/docker-sandbox-planner.js +18 -0
- package/scripts/double-blind-eval-protocol.js +252 -0
- package/scripts/edotenv-rl-gateway.js +259 -0
- package/scripts/ensure-production-search-corpus.js +162 -0
- package/scripts/eval-holdout.js +311 -0
- package/scripts/feedback-aggregate.js +21 -2
- package/scripts/feedback-loop.js +87 -5
- package/scripts/feedback-quality.js +9 -0
- package/scripts/file-ledger-lock.js +4 -1
- package/scripts/financial-control-plane.js +41 -1
- package/scripts/find-dormant-requires.js +118 -0
- package/scripts/fs-utils.js +84 -8
- package/scripts/gate-stats.js +2 -2
- package/scripts/gates-engine.js +859 -58
- package/scripts/generate-case-study-outreach.js +24 -15
- package/scripts/git-at-scale.js +628 -0
- package/scripts/governance-conflict-audit.js +1650 -0
- package/scripts/governance-difficulty-curriculum.js +328 -0
- package/scripts/graphrag-retrieval.js +275 -0
- package/scripts/gurobi-optimizer.js +324 -0
- package/scripts/gurobi_optimizer.py +485 -0
- package/scripts/harness-selector.js +82 -1
- package/scripts/hidden-entry-points.js +284 -0
- package/scripts/human-escalation.js +199 -1
- package/scripts/hybrid-feedback-context.js +152 -19
- package/scripts/intent-governed-execution.js +602 -0
- package/scripts/intervention-policy.js +123 -20
- package/scripts/jit-harness-compose.js +628 -0
- package/scripts/jsonl-watcher.js +10 -0
- package/scripts/lesson-embedding-index.js +95 -12
- package/scripts/lesson-retrieval.js +105 -19
- package/scripts/local-model-profile.js +19 -2
- package/scripts/mailer/resend-mailer.js +1 -1
- package/scripts/matryoshka-embedding.js +235 -0
- package/scripts/mcp-oauth.js +42 -4
- package/scripts/mcp-session-handles.js +1016 -0
- package/scripts/mcp-wiring-doctor.js +314 -0
- package/scripts/memory-firewall.js +115 -2
- package/scripts/memory-scope-readiness.js +299 -0
- package/scripts/memory-vs-rag-route.js +161 -0
- package/scripts/model-tier-router.js +148 -21
- package/scripts/nvidia-specdecode-al-doctor.js +536 -0
- package/scripts/openui-catalog-compose-honesty.js +593 -0
- package/scripts/operational-integrity.js +19 -1
- package/scripts/override-audit.js +213 -0
- package/scripts/package-manager-honesty-doctor.js +458 -0
- package/scripts/pr-manager.js +63 -1
- package/scripts/prove-herdr-adapter.js +52 -0
- package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
- package/scripts/prove-workos.js +73 -0
- package/scripts/provider-attestation-conformance.js +192 -0
- package/scripts/provider-receipt-contract.js +136 -0
- package/scripts/qwen38-max-cost-optimizer.js +401 -0
- package/scripts/radware-threat-defense.js +280 -0
- package/scripts/rag-embedding-identity.js +221 -0
- package/scripts/rag-precision-guardrails.js +112 -2
- package/scripts/remote-feedback-capture.js +159 -0
- package/scripts/research-agent-harness.js +256 -0
- package/scripts/rsi-safety-hillclimb.js +200 -0
- package/scripts/rule-sprawl.js +188 -0
- package/scripts/schedule-manager.js +147 -0
- package/scripts/self-heal.js +8 -0
- package/scripts/session-lease.js +415 -0
- package/scripts/simatree-data-governance.js +347 -0
- package/scripts/slo-alert-engine.js +172 -7
- package/scripts/solver-parity.js +539 -0
- package/scripts/stealth-memory-injection-gate.js +333 -0
- package/scripts/switchyard-router.js +366 -0
- package/scripts/telemetry-analytics.js +84 -27
- package/scripts/temporal-decay-weighting.js +138 -0
- package/scripts/test-all.js +165 -0
- package/scripts/token-savings.js +42 -0
- package/scripts/tool-kpi-tracker.js +108 -5
- package/scripts/tool-registry.js +193 -5
- package/scripts/universal-claim-evaluator.js +14 -2
- package/scripts/vector-store.js +279 -9
- package/scripts/workflow-notebook.js +391 -0
- package/scripts/workflow-sentinel.js +111 -12
- package/scripts/workos-production-guard.js +260 -0
- package/scripts/workspace-search-route.js +515 -0
- package/server.json +2 -2
- package/src/agent-identity-boundary.js +76 -0
- package/src/agent-retrieval-cache.js +155 -0
- package/src/alert-noise-ledger.js +502 -0
- package/src/api/server.js +802 -185
- package/src/git-fast-cache.js +220 -0
- package/src/git-wal-sync.js +156 -0
- package/src/hash-anchored-edit.js +82 -0
- package/src/hermes-platform-protocol.js +475 -0
- package/src/hermes-sync-plane.js +241 -0
- package/src/index.js +30 -1
- package/src/iso42001-compliance-guard.js +97 -0
- package/src/latency-budget.js +244 -0
- package/src/mcp-writeguard.js +316 -0
- package/src/miminions-adapter.js +106 -0
- package/src/pipeline-compass.js +104 -0
- package/src/ppl-alert-pipeline.js +284 -0
- package/src/rendezvous-router.js +90 -0
- package/src/security-questionnaire.js +195 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* agent-action-inventory.js — "what did the agents actually do here?"
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS
|
|
8
|
+
* ---------------
|
|
9
|
+
* ThumbGate already records gate decisions (`audit-trail.jsonl`), gate firings
|
|
10
|
+
* (`gate-events-log.jsonl`) and per-tool KPIs (`tool-kpi.jsonl`). What it did
|
|
11
|
+
* not have is one read-only rollup an operator can point at a repo and get:
|
|
12
|
+
* which agents ran, which tools they reached for, how much was allowed, how
|
|
13
|
+
* much was blocked, by which gate, on which day — and how often a block was
|
|
14
|
+
* later reversed (the false-deny signal).
|
|
15
|
+
*
|
|
16
|
+
* TWO HONESTY RULES THIS FILE ENFORCES
|
|
17
|
+
* ------------------------------------
|
|
18
|
+
* 1. A missing source and an empty source are DIFFERENT facts and must look
|
|
19
|
+
* different in the output. `sources.auditTrail === 'missing'` means the file
|
|
20
|
+
* is not there; `'empty'` means it is there and holds nothing. Reporting
|
|
21
|
+
* both as `0` would let "we never instrumented this" masquerade as "the
|
|
22
|
+
* agents did nothing".
|
|
23
|
+
* 2. `falseDenyRate` is null unless a real numerator AND a real denominator
|
|
24
|
+
* were observed. A rate over zero denies is an invented denominator, so it
|
|
25
|
+
* stays null and `falseDenyReason` says why. `falseDenyNumerator` and
|
|
26
|
+
* `falseDenyDenominator` are always present so a reader can redo the math.
|
|
27
|
+
*
|
|
28
|
+
* All functions here are read-only. Nothing in this module writes to the store.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
// Filenames come from the modules that own the writes, so a rename there can
|
|
35
|
+
// never silently turn this report into a "source missing" false negative.
|
|
36
|
+
const { AUDIT_LOG_FILENAME, GATE_EVENTS_LOG_FILENAME } = require('./audit-trail');
|
|
37
|
+
|
|
38
|
+
// scripts/tool-kpi-tracker.js writes this; it is the only store in the feedback
|
|
39
|
+
// dir that carries an agentId, which is why agent identity comes from here.
|
|
40
|
+
const KPI_LOG_FILENAME = 'tool-kpi.jsonl';
|
|
41
|
+
|
|
42
|
+
const MIN_WINDOW_DAYS = 1;
|
|
43
|
+
const MAX_WINDOW_DAYS = 365;
|
|
44
|
+
const DEFAULT_WINDOW_DAYS = 30;
|
|
45
|
+
|
|
46
|
+
const DENY_DECISION = 'deny';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* WHAT COUNTS AS A REVERSAL — and the trap that makes this subtle.
|
|
50
|
+
*
|
|
51
|
+
* Only two record shapes in audit-trail.jsonl mean "a block was actually cleared":
|
|
52
|
+
*
|
|
53
|
+
* 1. decision === 'override'
|
|
54
|
+
* Written by scripts/override-audit.js for satisfy_gate, the CLI
|
|
55
|
+
* satisfyCondition path, and break-glass. Always carries the cleared gateId.
|
|
56
|
+
*
|
|
57
|
+
* 2. decision === 'allow' AND source === 'gates-engine-admin-override'
|
|
58
|
+
* Written by gates-engine.js when a single-use admin override is CONSUMED
|
|
59
|
+
* against a blocking gate. The decision field reads 'allow' — the reversal
|
|
60
|
+
* is identifiable only by the source.
|
|
61
|
+
*
|
|
62
|
+
* `decision: 'approve'` is NOT a reversal, despite the name. gates-engine.js
|
|
63
|
+
* emits it when a gate's action is 'approve' and the action needs human
|
|
64
|
+
* sign-off: it ships alongside `requiresApproval: true`, formatOutput renders it
|
|
65
|
+
* to the harness as `permissionDecision: 'deny'` with "APPROVAL REQUIRED",
|
|
66
|
+
* isBlockingDecision() groups it with 'deny', and recordStat files it under
|
|
67
|
+
* `pendingApproval`. It is a HELD action — another kind of block.
|
|
68
|
+
*
|
|
69
|
+
* Counting 'approve' as a clearance inflates the false-deny numerator with
|
|
70
|
+
* records that are themselves blocks. It is reported separately as
|
|
71
|
+
* `approvalRequiredCount` and never enters the numerator.
|
|
72
|
+
*/
|
|
73
|
+
const CLEARING_DECISION = 'override';
|
|
74
|
+
const ADMIN_OVERRIDE_SOURCE = 'gates-engine-admin-override';
|
|
75
|
+
const APPROVAL_REQUIRED_DECISION = 'approve';
|
|
76
|
+
|
|
77
|
+
/** True only for records that genuinely reversed a block. */
|
|
78
|
+
function isClearingRecord(record) {
|
|
79
|
+
if (!record) return false;
|
|
80
|
+
if (record.decision === CLEARING_DECISION) return true;
|
|
81
|
+
return record.decision === 'allow' && record.source === ADMIN_OVERRIDE_SOURCE;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const SOURCE_STATUS = Object.freeze({
|
|
85
|
+
OK: 'ok',
|
|
86
|
+
EMPTY: 'empty',
|
|
87
|
+
MISSING: 'missing',
|
|
88
|
+
UNREADABLE: 'unreadable',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Input normalization
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** Mirrors scripts/session-report.js normalizeWindowHours: clamp, never throw. */
|
|
96
|
+
function normalizeWindowDays(input) {
|
|
97
|
+
if (input === null || input === undefined || input === '') return DEFAULT_WINDOW_DAYS;
|
|
98
|
+
const n = Number(input);
|
|
99
|
+
if (!Number.isFinite(n)) return DEFAULT_WINDOW_DAYS;
|
|
100
|
+
if (n < MIN_WINDOW_DAYS) return MIN_WINDOW_DAYS;
|
|
101
|
+
if (n > MAX_WINDOW_DAYS) return MAX_WINDOW_DAYS;
|
|
102
|
+
return Math.floor(n);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Source reading
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default tail window per source. Matches scripts/override-audit.js, which
|
|
111
|
+
* reads the tail rather than the whole file because audit logs reach hundreds
|
|
112
|
+
* of MB and a report must not allocate the entire history to summarise it.
|
|
113
|
+
*/
|
|
114
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read one JSONL store and report WHY it produced the records it did.
|
|
118
|
+
*
|
|
119
|
+
* Reads at most `maxBytes` from the END of the file, following the
|
|
120
|
+
* scripts/override-audit.js convention: allocate `Math.min(size, maxBytes)`,
|
|
121
|
+
* seek to `size - readBytes`, then discard the partial first line, since a
|
|
122
|
+
* byte-offset read almost always lands mid-record.
|
|
123
|
+
*
|
|
124
|
+
* When the file was larger than the window, `truncated` is true and
|
|
125
|
+
* `records`/`lines` describe only the tail that was read. Callers MUST surface
|
|
126
|
+
* that — a truncated `recordsTotal` presented as a whole-file count is exactly
|
|
127
|
+
* the kind of silently-partial number this module exists to avoid.
|
|
128
|
+
*
|
|
129
|
+
* @returns {{ status: string, path: string, records: object[], lines: number,
|
|
130
|
+
* malformed: number, truncated: boolean, bytesRead: number, fileBytes: number }}
|
|
131
|
+
*/
|
|
132
|
+
function readJsonlSource(filePath, options = {}) {
|
|
133
|
+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
134
|
+
? options.maxBytes
|
|
135
|
+
: DEFAULT_MAX_BYTES;
|
|
136
|
+
const base = {
|
|
137
|
+
path: filePath,
|
|
138
|
+
records: [],
|
|
139
|
+
lines: 0,
|
|
140
|
+
malformed: 0,
|
|
141
|
+
truncated: false,
|
|
142
|
+
bytesRead: 0,
|
|
143
|
+
fileBytes: 0,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let size;
|
|
147
|
+
try {
|
|
148
|
+
({ size } = fs.statSync(filePath));
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// ENOENT is "never written". Anything else (EACCES, EISDIR) is a real
|
|
151
|
+
// read failure and must not be flattened into "missing" — an operator
|
|
152
|
+
// fixes those two problems differently.
|
|
153
|
+
if (err && err.code === 'ENOENT') return { ...base, status: SOURCE_STATUS.MISSING };
|
|
154
|
+
return {
|
|
155
|
+
...base,
|
|
156
|
+
status: SOURCE_STATUS.UNREADABLE,
|
|
157
|
+
error: String(err && err.message ? err.message : err),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (size === 0) return { ...base, status: SOURCE_STATUS.EMPTY };
|
|
162
|
+
|
|
163
|
+
const readBytes = Math.min(size, maxBytes);
|
|
164
|
+
const truncated = readBytes < size;
|
|
165
|
+
let text = '';
|
|
166
|
+
let fd;
|
|
167
|
+
try {
|
|
168
|
+
const buf = Buffer.alloc(readBytes);
|
|
169
|
+
fd = fs.openSync(filePath, 'r');
|
|
170
|
+
fs.readSync(fd, buf, 0, readBytes, size - readBytes);
|
|
171
|
+
text = buf.toString('utf8');
|
|
172
|
+
if (truncated) {
|
|
173
|
+
// Drop the partial first record left by the byte-offset seek.
|
|
174
|
+
const nl = text.indexOf('\n');
|
|
175
|
+
text = nl === -1 ? '' : text.slice(nl + 1);
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
return {
|
|
179
|
+
...base,
|
|
180
|
+
fileBytes: size,
|
|
181
|
+
status: SOURCE_STATUS.UNREADABLE,
|
|
182
|
+
error: String(err && err.message ? err.message : err),
|
|
183
|
+
};
|
|
184
|
+
} finally {
|
|
185
|
+
if (fd !== undefined) {
|
|
186
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const trimmed = text.trim();
|
|
191
|
+
if (!trimmed) {
|
|
192
|
+
return { ...base, status: SOURCE_STATUS.EMPTY, truncated, bytesRead: readBytes, fileBytes: size };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const lines = trimmed.split('\n');
|
|
196
|
+
const records = [];
|
|
197
|
+
let malformed = 0;
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
if (!line.trim()) continue;
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(line);
|
|
202
|
+
if (parsed && typeof parsed === 'object') records.push(parsed);
|
|
203
|
+
else malformed += 1;
|
|
204
|
+
} catch {
|
|
205
|
+
malformed += 1; // a corrupt line must not abort the scan
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
path: filePath,
|
|
211
|
+
// A file of nothing but unparsable lines is empty of DATA, not missing.
|
|
212
|
+
status: records.length > 0 ? SOURCE_STATUS.OK : SOURCE_STATUS.EMPTY,
|
|
213
|
+
records,
|
|
214
|
+
lines: lines.length,
|
|
215
|
+
malformed,
|
|
216
|
+
truncated,
|
|
217
|
+
bytesRead: readBytes,
|
|
218
|
+
fileBytes: size,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Did the tail window actually cover the whole requested window?
|
|
224
|
+
*
|
|
225
|
+
* If the file was truncated AND its oldest surviving record is newer than
|
|
226
|
+
* `since`, then records inside the window were dropped and every count derived
|
|
227
|
+
* from this source is a partial view. That must be reported, not assumed away.
|
|
228
|
+
*/
|
|
229
|
+
function coversWindow(source, sinceMs) {
|
|
230
|
+
if (source.status !== SOURCE_STATUS.OK) return null;
|
|
231
|
+
if (!source.truncated) return true;
|
|
232
|
+
let oldest = null;
|
|
233
|
+
for (const r of source.records) {
|
|
234
|
+
const ts = timestampMs(r);
|
|
235
|
+
if (ts === null) continue;
|
|
236
|
+
if (oldest === null || ts < oldest) oldest = ts;
|
|
237
|
+
}
|
|
238
|
+
if (oldest === null) return false;
|
|
239
|
+
return oldest <= sinceMs;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function timestampMs(record, field = 'timestamp') {
|
|
243
|
+
const value = record && record[field];
|
|
244
|
+
if (!value) return null;
|
|
245
|
+
const ms = Date.parse(value);
|
|
246
|
+
return Number.isFinite(ms) ? ms : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function withinWindow(record, sinceMs, field = 'timestamp') {
|
|
250
|
+
const ms = timestampMs(record, field);
|
|
251
|
+
if (ms === null) return false;
|
|
252
|
+
return ms >= sinceMs;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function dayKey(record, field = 'timestamp') {
|
|
256
|
+
const ms = timestampMs(record, field);
|
|
257
|
+
if (ms === null) return null;
|
|
258
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// False-deny pairing
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Pair each clearing event (override/approve) with the most recent UNPAIRED
|
|
267
|
+
* preceding deny of the same gate.
|
|
268
|
+
*
|
|
269
|
+
* Why 1:1 and not "any later clear marks every earlier deny of that gate":
|
|
270
|
+
* one break-glass override would then retroactively brand hundreds of
|
|
271
|
+
* force-push denies as false, which is a fabricated numerator. One receipt
|
|
272
|
+
* clears at most one block. The pairing is therefore a LOWER BOUND on
|
|
273
|
+
* reversals, and the method string in the output says so.
|
|
274
|
+
*
|
|
275
|
+
* @returns {{ numerator: number, denominator: number, clearEvents: number, pairs: object[] }}
|
|
276
|
+
*/
|
|
277
|
+
function pairDeniesWithClears(records) {
|
|
278
|
+
const denyStack = new Map(); // gateId -> ascending [{ ts, record }]
|
|
279
|
+
const clearEvents = [];
|
|
280
|
+
let denominator = 0;
|
|
281
|
+
|
|
282
|
+
const sorted = records
|
|
283
|
+
.map((r) => ({ r, ts: timestampMs(r) }))
|
|
284
|
+
.filter((x) => x.ts !== null)
|
|
285
|
+
.sort((a, b) => a.ts - b.ts);
|
|
286
|
+
|
|
287
|
+
for (const { r, ts } of sorted) {
|
|
288
|
+
const gateId = r.gateId || null;
|
|
289
|
+
if (r.decision === DENY_DECISION) {
|
|
290
|
+
denominator += 1;
|
|
291
|
+
if (!gateId) continue; // an unattributed deny can never be paired
|
|
292
|
+
if (!denyStack.has(gateId)) denyStack.set(gateId, []);
|
|
293
|
+
denyStack.get(gateId).push({ ts, record: r });
|
|
294
|
+
} else if (isClearingRecord(r) && gateId) {
|
|
295
|
+
clearEvents.push({ ts, gateId, decision: r.decision, source: r.source || null });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const pairs = [];
|
|
300
|
+
for (const { ts, gateId, decision } of clearEvents) {
|
|
301
|
+
const stack = denyStack.get(gateId);
|
|
302
|
+
if (!stack || stack.length === 0) continue;
|
|
303
|
+
// Newest unpaired deny that happened BEFORE this clear.
|
|
304
|
+
let idx = -1;
|
|
305
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
306
|
+
if (stack[i].ts < ts) { idx = i; break; }
|
|
307
|
+
}
|
|
308
|
+
if (idx === -1) continue;
|
|
309
|
+
const [matched] = stack.splice(idx, 1);
|
|
310
|
+
pairs.push({
|
|
311
|
+
gate: gateId,
|
|
312
|
+
deniedAt: new Date(matched.ts).toISOString(),
|
|
313
|
+
clearedAt: new Date(ts).toISOString(),
|
|
314
|
+
clearedBy: decision,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { numerator: pairs.length, denominator, clearEvents: clearEvents.length, pairs };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Aggregation helpers
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function buildToolCalls(records) {
|
|
326
|
+
const byTool = {};
|
|
327
|
+
for (const r of records) {
|
|
328
|
+
const tool = (typeof r.toolName === 'string' && r.toolName) || 'unknown';
|
|
329
|
+
if (!byTool[tool]) {
|
|
330
|
+
byTool[tool] = { total: 0, allow: 0, deny: 0, warn: 0, approvalRequired: 0, other: 0 };
|
|
331
|
+
}
|
|
332
|
+
const bucket = byTool[tool];
|
|
333
|
+
bucket.total += 1;
|
|
334
|
+
if (r.decision === APPROVAL_REQUIRED_DECISION) bucket.approvalRequired += 1;
|
|
335
|
+
else if (r.decision === DENY_DECISION) bucket.deny += 1;
|
|
336
|
+
else if (r.decision === 'warn') bucket.warn += 1;
|
|
337
|
+
else if (r.decision === 'allow') bucket.allow += 1;
|
|
338
|
+
else bucket.other += 1;
|
|
339
|
+
}
|
|
340
|
+
return byTool;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function buildDenyReasonsByGate(records) {
|
|
344
|
+
const byGate = {};
|
|
345
|
+
for (const r of records) {
|
|
346
|
+
if (r.decision !== DENY_DECISION) continue;
|
|
347
|
+
const gate = r.gateId || '(unattributed)';
|
|
348
|
+
if (!byGate[gate]) byGate[gate] = { denies: 0, reasons: {} };
|
|
349
|
+
byGate[gate].denies += 1;
|
|
350
|
+
const reason = (typeof r.message === 'string' && r.message.trim())
|
|
351
|
+
? r.message.trim()
|
|
352
|
+
: '(no message recorded)';
|
|
353
|
+
byGate[gate].reasons[reason] = (byGate[gate].reasons[reason] || 0) + 1;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Reasons as a sorted array so the shape is stable and rankable.
|
|
357
|
+
for (const gate of Object.keys(byGate)) {
|
|
358
|
+
byGate[gate].reasons = Object.entries(byGate[gate].reasons)
|
|
359
|
+
.map(([message, count]) => ({ message, count }))
|
|
360
|
+
.sort((a, b) => b.count - a.count || a.message.localeCompare(b.message));
|
|
361
|
+
}
|
|
362
|
+
return byGate;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Mirrors scripts/session-report.js topGates: rank by blocks, then warns. */
|
|
366
|
+
function buildTopGates(records, limit = 5) {
|
|
367
|
+
const byGate = {};
|
|
368
|
+
for (const r of records) {
|
|
369
|
+
if (!r.gateId) continue;
|
|
370
|
+
if (!byGate[r.gateId]) {
|
|
371
|
+
byGate[r.gateId] = {
|
|
372
|
+
gate: r.gateId,
|
|
373
|
+
denies: 0,
|
|
374
|
+
warns: 0,
|
|
375
|
+
approvalRequired: 0,
|
|
376
|
+
overrides: 0,
|
|
377
|
+
other: 0,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
const bucket = byGate[r.gateId];
|
|
381
|
+
if (r.decision === DENY_DECISION) bucket.denies += 1;
|
|
382
|
+
else if (r.decision === 'warn') bucket.warns += 1;
|
|
383
|
+
else if (r.decision === APPROVAL_REQUIRED_DECISION) bucket.approvalRequired += 1;
|
|
384
|
+
else if (isClearingRecord(r)) bucket.overrides += 1;
|
|
385
|
+
else bucket.other += 1;
|
|
386
|
+
}
|
|
387
|
+
return Object.values(byGate)
|
|
388
|
+
.sort((a, b) => b.denies - a.denies || b.warns - a.warns || a.gate.localeCompare(b.gate))
|
|
389
|
+
.slice(0, limit);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Per-day calls/denies. Only days that ACTUALLY APPEAR in the log are listed —
|
|
394
|
+
* a zero-filled calendar would print "0 calls" for days the store may simply
|
|
395
|
+
* predate, which is exactly the measured-vs-absent confusion this report exists
|
|
396
|
+
* to avoid. `undated` counts rows whose timestamp could not be parsed.
|
|
397
|
+
*/
|
|
398
|
+
function buildDaily(records) {
|
|
399
|
+
const byDay = new Map();
|
|
400
|
+
let undated = 0;
|
|
401
|
+
for (const r of records) {
|
|
402
|
+
const day = dayKey(r);
|
|
403
|
+
if (!day) { undated += 1; continue; }
|
|
404
|
+
if (!byDay.has(day)) byDay.set(day, { date: day, calls: 0, denies: 0, warns: 0 });
|
|
405
|
+
const bucket = byDay.get(day);
|
|
406
|
+
bucket.calls += 1;
|
|
407
|
+
if (r.decision === DENY_DECISION) bucket.denies += 1;
|
|
408
|
+
else if (r.decision === 'warn') bucket.warns += 1;
|
|
409
|
+
}
|
|
410
|
+
const daily = [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
411
|
+
return { daily, undated };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Agents come from tool-kpi.jsonl — the ONLY store here that records an agentId.
|
|
416
|
+
* audit-trail.jsonl rows carry no agent identity, which is why gate decisions
|
|
417
|
+
* are reported repo-wide and never split per agent.
|
|
418
|
+
*/
|
|
419
|
+
function buildAgents(kpiRecords) {
|
|
420
|
+
const byAgent = new Map();
|
|
421
|
+
for (const r of kpiRecords) {
|
|
422
|
+
const agentId = (typeof r.agentId === 'string' && r.agentId.trim())
|
|
423
|
+
? r.agentId.trim()
|
|
424
|
+
: '(no agentId recorded)';
|
|
425
|
+
if (!byAgent.has(agentId)) {
|
|
426
|
+
byAgent.set(agentId, {
|
|
427
|
+
agentId,
|
|
428
|
+
calls: 0,
|
|
429
|
+
successes: 0,
|
|
430
|
+
failures: 0,
|
|
431
|
+
tools: {},
|
|
432
|
+
firstSeen: null,
|
|
433
|
+
lastSeen: null,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const bucket = byAgent.get(agentId);
|
|
437
|
+
bucket.calls += 1;
|
|
438
|
+
if (r.success === true) bucket.successes += 1;
|
|
439
|
+
else if (r.success === false) bucket.failures += 1;
|
|
440
|
+
const tool = (typeof r.toolName === 'string' && r.toolName) || 'unknown';
|
|
441
|
+
bucket.tools[tool] = (bucket.tools[tool] || 0) + 1;
|
|
442
|
+
const ts = timestampMs(r);
|
|
443
|
+
if (ts !== null) {
|
|
444
|
+
const iso = new Date(ts).toISOString();
|
|
445
|
+
if (!bucket.firstSeen || iso < bucket.firstSeen) bucket.firstSeen = iso;
|
|
446
|
+
if (!bucket.lastSeen || iso > bucket.lastSeen) bucket.lastSeen = iso;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return [...byAgent.values()]
|
|
451
|
+
.map((a) => ({
|
|
452
|
+
...a,
|
|
453
|
+
tools: Object.entries(a.tools)
|
|
454
|
+
.map(([tool, calls]) => ({ tool, calls }))
|
|
455
|
+
.sort((x, y) => y.calls - x.calls || x.tool.localeCompare(y.tool)),
|
|
456
|
+
}))
|
|
457
|
+
.sort((a, b) => b.calls - a.calls || a.agentId.localeCompare(b.agentId));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Report assembly
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
function sourceDetail(source, inWindow, sinceMs) {
|
|
465
|
+
const detail = {
|
|
466
|
+
status: source.status,
|
|
467
|
+
path: source.path,
|
|
468
|
+
// NOTE: when `truncated` is true this counts only the records inside the
|
|
469
|
+
// tail window that was read, never the whole file.
|
|
470
|
+
recordsTotal: source.records.length,
|
|
471
|
+
recordsInWindow: source.status === SOURCE_STATUS.OK ? inWindow : null,
|
|
472
|
+
malformedLines: source.malformed,
|
|
473
|
+
truncated: Boolean(source.truncated),
|
|
474
|
+
bytesRead: source.bytesRead || 0,
|
|
475
|
+
fileBytes: source.fileBytes || 0,
|
|
476
|
+
coversWindow: coversWindow(source, sinceMs),
|
|
477
|
+
};
|
|
478
|
+
if (source.error) detail.error = source.error;
|
|
479
|
+
return detail;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function buildAgentAttribution(kpiSource, agents) {
|
|
483
|
+
const base = {
|
|
484
|
+
identitySource: KPI_LOG_FILENAME,
|
|
485
|
+
// Stated explicitly because it is the single biggest limitation of this
|
|
486
|
+
// report: allow/deny counts are repo-wide, NOT per-agent.
|
|
487
|
+
gateDecisionsPerAgent: 'unavailable',
|
|
488
|
+
reason: `${AUDIT_LOG_FILENAME} records carry no agentId, so allow/deny/warn counts cannot be split per agent. Agent rows come from ${KPI_LOG_FILENAME} and cover tool calls only.`,
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
if (kpiSource.status === SOURCE_STATUS.MISSING) {
|
|
492
|
+
return {
|
|
493
|
+
...base,
|
|
494
|
+
status: 'missing-source',
|
|
495
|
+
reason: `${KPI_LOG_FILENAME} is not present at ${kpiSource.path}; no agent identity is recorded anywhere in this store.`,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (kpiSource.status === SOURCE_STATUS.UNREADABLE) {
|
|
499
|
+
return {
|
|
500
|
+
...base,
|
|
501
|
+
status: 'unreadable-source',
|
|
502
|
+
reason: `${KPI_LOG_FILENAME} could not be read: ${kpiSource.error}`,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
if (kpiSource.status === SOURCE_STATUS.EMPTY) {
|
|
506
|
+
return {
|
|
507
|
+
...base,
|
|
508
|
+
status: 'empty-source',
|
|
509
|
+
reason: `${KPI_LOG_FILENAME} exists at ${kpiSource.path} but holds no parsable records.`,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (agents.length === 0) {
|
|
513
|
+
return { ...base, status: 'no-agents-in-window' };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const unknown = agents.find(
|
|
517
|
+
(a) => a.agentId === 'unknown' || a.agentId === '(no agentId recorded)'
|
|
518
|
+
);
|
|
519
|
+
return {
|
|
520
|
+
...base,
|
|
521
|
+
status: 'partial',
|
|
522
|
+
distinctAgents: agents.length,
|
|
523
|
+
unattributedCalls: unknown ? unknown.calls : 0,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* falseDenyRate = paired reversals / denies observed.
|
|
529
|
+
*
|
|
530
|
+
* Returns null (with a reason) whenever a real denominator was not observed.
|
|
531
|
+
* The numerator and denominator are ALWAYS returned raw so the caller can see
|
|
532
|
+
* exactly what the rate was — or was not — computed from.
|
|
533
|
+
*/
|
|
534
|
+
function computeFalseDeny(auditSource, auditInWindow, windowDays) {
|
|
535
|
+
const unmeasurable = (reason) => ({
|
|
536
|
+
falseDenyRate: null,
|
|
537
|
+
falseDenyReason: reason,
|
|
538
|
+
falseDenyNumerator: null,
|
|
539
|
+
falseDenyDenominator: null,
|
|
540
|
+
falseDenyClearEvents: null,
|
|
541
|
+
falseDenyMethod: null,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (auditSource.status === SOURCE_STATUS.MISSING) {
|
|
545
|
+
return unmeasurable(`${AUDIT_LOG_FILENAME} is missing at ${auditSource.path}. Deny and reversal records both live there, so no numerator and no denominator exist.`);
|
|
546
|
+
}
|
|
547
|
+
if (auditSource.status === SOURCE_STATUS.UNREADABLE) {
|
|
548
|
+
return unmeasurable(`${AUDIT_LOG_FILENAME} at ${auditSource.path} could not be read (${auditSource.error}). Nothing was measured.`);
|
|
549
|
+
}
|
|
550
|
+
if (auditSource.status === SOURCE_STATUS.EMPTY) {
|
|
551
|
+
return unmeasurable(`${AUDIT_LOG_FILENAME} exists at ${auditSource.path} but holds no parsable records. There were no denies to measure — this is an absent measurement, not a rate of zero.`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const { numerator, denominator, clearEvents } = pairDeniesWithClears(auditInWindow);
|
|
555
|
+
const method = `1:1 pairing — each reversal receipt in ${AUDIT_LOG_FILENAME} is matched to the most recent unpaired preceding deny of the SAME gateId. A reversal receipt is decision "${CLEARING_DECISION}", or decision "allow" with source "${ADMIN_OVERRIDE_SOURCE}" (a consumed single-use admin override). Decision "${APPROVAL_REQUIRED_DECISION}" is deliberately NOT counted: gates-engine emits it with requiresApproval:true and renders it to the harness as permissionDecision "deny", so it is itself a block, not a clearance. One receipt clears at most one block, so the numerator is a lower bound on reversals, never an inflated one.`;
|
|
556
|
+
|
|
557
|
+
if (denominator === 0) {
|
|
558
|
+
return {
|
|
559
|
+
falseDenyRate: null,
|
|
560
|
+
falseDenyReason: `No deny records in the last ${windowDays} day(s) (${auditSource.records.length} audit record(s) on file overall). A rate over zero denies would require an invented denominator.`,
|
|
561
|
+
falseDenyNumerator: numerator,
|
|
562
|
+
falseDenyDenominator: 0,
|
|
563
|
+
falseDenyClearEvents: clearEvents,
|
|
564
|
+
falseDenyMethod: method,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
falseDenyRate: Math.round((numerator / denominator) * 10000) / 10000,
|
|
570
|
+
falseDenyReason: null,
|
|
571
|
+
falseDenyNumerator: numerator,
|
|
572
|
+
falseDenyDenominator: denominator,
|
|
573
|
+
falseDenyClearEvents: clearEvents,
|
|
574
|
+
falseDenyMethod: method,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Build the agent action inventory for one ThumbGate data directory.
|
|
580
|
+
*
|
|
581
|
+
* Read-only: opens files, writes nothing.
|
|
582
|
+
*
|
|
583
|
+
* @param {object} [opts]
|
|
584
|
+
* @param {string} [opts.dataDir] — a ThumbGate store dir (e.g. `<repo>/.thumbgate`).
|
|
585
|
+
* Defaults to the resolved feedback dir.
|
|
586
|
+
* @param {number|string} [opts.windowDays] — lookback in days (clamped 1..365, default 30).
|
|
587
|
+
* @param {number} [opts.topGateLimit=5]
|
|
588
|
+
* @returns {object} the inventory
|
|
589
|
+
*/
|
|
590
|
+
function buildInventory(opts = {}) {
|
|
591
|
+
const windowDays = normalizeWindowDays(opts.windowDays);
|
|
592
|
+
const topGateLimit = Number.isFinite(opts.topGateLimit) && opts.topGateLimit > 0
|
|
593
|
+
? Math.floor(opts.topGateLimit)
|
|
594
|
+
: 5;
|
|
595
|
+
|
|
596
|
+
let dataDir = opts.dataDir;
|
|
597
|
+
if (!dataDir) {
|
|
598
|
+
// Only reached by callers that did not scope themselves; the CLI always passes one.
|
|
599
|
+
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
600
|
+
dataDir = resolveFeedbackDir();
|
|
601
|
+
}
|
|
602
|
+
dataDir = path.resolve(String(dataDir));
|
|
603
|
+
|
|
604
|
+
const nowMs = Date.now();
|
|
605
|
+
const sinceMs = nowMs - windowDays * 24 * 60 * 60 * 1000;
|
|
606
|
+
|
|
607
|
+
const readOpts = { maxBytes: opts.maxBytes };
|
|
608
|
+
const auditSource = readJsonlSource(path.join(dataDir, AUDIT_LOG_FILENAME), readOpts);
|
|
609
|
+
const gateSource = readJsonlSource(path.join(dataDir, GATE_EVENTS_LOG_FILENAME), readOpts);
|
|
610
|
+
const kpiSource = readJsonlSource(path.join(dataDir, KPI_LOG_FILENAME), readOpts);
|
|
611
|
+
|
|
612
|
+
const auditInWindow = auditSource.records.filter((r) => withinWindow(r, sinceMs));
|
|
613
|
+
const gateInWindow = gateSource.records.filter((r) => withinWindow(r, sinceMs));
|
|
614
|
+
const kpiInWindow = kpiSource.records.filter((r) => withinWindow(r, sinceMs));
|
|
615
|
+
|
|
616
|
+
const { daily, undated } = buildDaily(auditInWindow);
|
|
617
|
+
|
|
618
|
+
let allowCount = 0;
|
|
619
|
+
let denyCount = 0;
|
|
620
|
+
let warnCount = 0;
|
|
621
|
+
let approvalRequiredCount = 0;
|
|
622
|
+
let overrideCount = 0;
|
|
623
|
+
let otherDecisionCount = 0;
|
|
624
|
+
for (const r of auditInWindow) {
|
|
625
|
+
if (r.decision === APPROVAL_REQUIRED_DECISION) approvalRequiredCount += 1;
|
|
626
|
+
else if (r.decision === DENY_DECISION) denyCount += 1;
|
|
627
|
+
else if (r.decision === 'warn') warnCount += 1;
|
|
628
|
+
else if (r.decision === 'allow') allowCount += 1;
|
|
629
|
+
else otherDecisionCount += 1;
|
|
630
|
+
// Counted independently of the buckets above: a consumed admin override is
|
|
631
|
+
// recorded as decision 'allow', so it is both an allow and a reversal.
|
|
632
|
+
if (isClearingRecord(r)) overrideCount += 1;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const agents = buildAgents(kpiInWindow);
|
|
636
|
+
|
|
637
|
+
const inventory = {
|
|
638
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
639
|
+
dataDir,
|
|
640
|
+
windowDays,
|
|
641
|
+
since: new Date(sinceMs).toISOString(),
|
|
642
|
+
|
|
643
|
+
sources: {
|
|
644
|
+
auditTrail: auditSource.status,
|
|
645
|
+
gateEvents: gateSource.status,
|
|
646
|
+
toolKpi: kpiSource.status,
|
|
647
|
+
},
|
|
648
|
+
// Per-source detail so "0 calls" is always traceable to a file that exists,
|
|
649
|
+
// parsed, and simply had nothing inside the window.
|
|
650
|
+
sourceDetail: {
|
|
651
|
+
auditTrail: sourceDetail(auditSource, auditInWindow.length, sinceMs),
|
|
652
|
+
gateEvents: sourceDetail(gateSource, gateInWindow.length, sinceMs),
|
|
653
|
+
toolKpi: sourceDetail(kpiSource, kpiInWindow.length, sinceMs),
|
|
654
|
+
},
|
|
655
|
+
// false when a source's tail window did not reach back to `since`, i.e.
|
|
656
|
+
// records inside the requested window were dropped and every count below is
|
|
657
|
+
// a partial view. null when the audit trail was not readable at all.
|
|
658
|
+
windowFullyCovered: coversWindow(auditSource, sinceMs),
|
|
659
|
+
|
|
660
|
+
agents,
|
|
661
|
+
agentAttribution: buildAgentAttribution(kpiSource, agents),
|
|
662
|
+
|
|
663
|
+
toolCalls: buildToolCalls(auditInWindow),
|
|
664
|
+
allowCount,
|
|
665
|
+
denyCount,
|
|
666
|
+
warnCount,
|
|
667
|
+
// Also a block: gates-engine renders 'approve' to the harness as
|
|
668
|
+
// permissionDecision 'deny' with "APPROVAL REQUIRED". Reported on its own
|
|
669
|
+
// rather than folded into denyCount, because the false-deny denominator is
|
|
670
|
+
// defined strictly as decision === 'deny' and must not silently widen.
|
|
671
|
+
approvalRequiredCount,
|
|
672
|
+
overrideCount,
|
|
673
|
+
otherDecisionCount,
|
|
674
|
+
|
|
675
|
+
denyReasonsByGate: buildDenyReasonsByGate(auditInWindow),
|
|
676
|
+
topGates: buildTopGates(auditInWindow, topGateLimit),
|
|
677
|
+
|
|
678
|
+
daily,
|
|
679
|
+
undatedAuditRecords: undated,
|
|
680
|
+
|
|
681
|
+
// Independent second view of blocks. gate-events-log.jsonl is written by
|
|
682
|
+
// auditToFeedback() for every non-allow decision; if these two disagree,
|
|
683
|
+
// one of the two writers is broken and the operator should know.
|
|
684
|
+
gateEventDenies: gateSource.status === SOURCE_STATUS.OK
|
|
685
|
+
? gateInWindow.filter((r) => r.decision === DENY_DECISION).length
|
|
686
|
+
: null,
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
Object.assign(inventory, computeFalseDeny(auditSource, auditInWindow, windowDays));
|
|
690
|
+
|
|
691
|
+
return inventory;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// Text rendering
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
|
|
698
|
+
const STATUS_LABEL = {
|
|
699
|
+
[SOURCE_STATUS.OK]: 'ok',
|
|
700
|
+
[SOURCE_STATUS.EMPTY]: 'EMPTY (file present, no records)',
|
|
701
|
+
[SOURCE_STATUS.MISSING]: 'MISSING (file not found)',
|
|
702
|
+
[SOURCE_STATUS.UNREADABLE]: 'UNREADABLE',
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
function renderInventoryText(inv) {
|
|
706
|
+
const lines = [];
|
|
707
|
+
lines.push('ThumbGate agent action inventory');
|
|
708
|
+
lines.push(` data dir : ${inv.dataDir}`);
|
|
709
|
+
lines.push(` window : last ${inv.windowDays} day(s), since ${inv.since}`);
|
|
710
|
+
lines.push('');
|
|
711
|
+
|
|
712
|
+
lines.push('Sources');
|
|
713
|
+
for (const [name, status] of Object.entries(inv.sources)) {
|
|
714
|
+
const detail = inv.sourceDetail[name];
|
|
715
|
+
const suffix = status === SOURCE_STATUS.OK
|
|
716
|
+
? `${detail.recordsInWindow} of ${detail.recordsTotal} record(s) in window`
|
|
717
|
+
: detail.path;
|
|
718
|
+
lines.push(` ${name.padEnd(11)} ${STATUS_LABEL[status] || status} — ${suffix}`);
|
|
719
|
+
if (detail.truncated) {
|
|
720
|
+
lines.push(` TRUNCATED: read the last ${detail.bytesRead} of ${detail.fileBytes} bytes; counts above cover only that tail`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (inv.windowFullyCovered === false) {
|
|
724
|
+
lines.push('');
|
|
725
|
+
lines.push(' WARNING: the audit-trail tail window did not reach back to the start of the');
|
|
726
|
+
lines.push(' requested window. Every count below is a PARTIAL view. Raise --days scope or');
|
|
727
|
+
lines.push(' pass a larger maxBytes to cover it fully.');
|
|
728
|
+
}
|
|
729
|
+
lines.push('');
|
|
730
|
+
|
|
731
|
+
lines.push('Decisions in window');
|
|
732
|
+
if (inv.sources.auditTrail !== SOURCE_STATUS.OK) {
|
|
733
|
+
lines.push(` not measured — audit trail is ${inv.sources.auditTrail}`);
|
|
734
|
+
} else {
|
|
735
|
+
lines.push(` allow ${inv.allowCount} deny ${inv.denyCount} warn ${inv.warnCount} other ${inv.otherDecisionCount}`);
|
|
736
|
+
lines.push(` approval-required ${inv.approvalRequiredCount} (also blocks; excluded from the false-deny denominator)`);
|
|
737
|
+
lines.push(` reversals (override / consumed admin override): ${inv.overrideCount}`);
|
|
738
|
+
const corroborating = inv.gateEventDenies === null
|
|
739
|
+
? `not measured (gate-events source: ${inv.sources.gateEvents})`
|
|
740
|
+
: inv.gateEventDenies;
|
|
741
|
+
lines.push(` gate-events-log corroborating denies: ${corroborating}`);
|
|
742
|
+
}
|
|
743
|
+
lines.push('');
|
|
744
|
+
|
|
745
|
+
lines.push('Agents');
|
|
746
|
+
if (inv.agents.length === 0) {
|
|
747
|
+
lines.push(` none — ${inv.agentAttribution.status}: ${inv.agentAttribution.reason}`);
|
|
748
|
+
} else {
|
|
749
|
+
for (const a of inv.agents.slice(0, 10)) {
|
|
750
|
+
const top = a.tools.slice(0, 3).map((t) => `${t.tool}x${t.calls}`).join(', ');
|
|
751
|
+
lines.push(` ${a.agentId} ${a.calls} call(s)${top ? ` [${top}]` : ''}`);
|
|
752
|
+
}
|
|
753
|
+
if (inv.agents.length > 10) lines.push(` ... and ${inv.agents.length - 10} more`);
|
|
754
|
+
lines.push(` note: ${inv.agentAttribution.reason}`);
|
|
755
|
+
}
|
|
756
|
+
lines.push('');
|
|
757
|
+
|
|
758
|
+
lines.push('Tool calls (from audit trail)');
|
|
759
|
+
const tools = Object.entries(inv.toolCalls)
|
|
760
|
+
.sort((a, b) => b[1].total - a[1].total || a[0].localeCompare(b[0]));
|
|
761
|
+
if (tools.length === 0) {
|
|
762
|
+
lines.push(` none in window (audit trail: ${inv.sources.auditTrail})`);
|
|
763
|
+
} else {
|
|
764
|
+
for (const [tool, c] of tools.slice(0, 10)) {
|
|
765
|
+
lines.push(` ${tool.padEnd(22)} total ${c.total} allow ${c.allow} deny ${c.deny} warn ${c.warn}`);
|
|
766
|
+
}
|
|
767
|
+
if (tools.length > 10) lines.push(` ... and ${tools.length - 10} more tool(s)`);
|
|
768
|
+
}
|
|
769
|
+
lines.push('');
|
|
770
|
+
|
|
771
|
+
lines.push('Top gates by denies');
|
|
772
|
+
if (inv.topGates.length === 0) {
|
|
773
|
+
lines.push(` none in window (audit trail: ${inv.sources.auditTrail})`);
|
|
774
|
+
} else {
|
|
775
|
+
for (const g of inv.topGates) {
|
|
776
|
+
lines.push(` ${g.gate.padEnd(34)} deny ${g.denies} warn ${g.warns} approval ${g.approvalRequired} reversed ${g.overrides}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
lines.push('');
|
|
780
|
+
|
|
781
|
+
lines.push('Daily');
|
|
782
|
+
if (inv.daily.length === 0) {
|
|
783
|
+
lines.push(` no dated records in window (audit trail: ${inv.sources.auditTrail})`);
|
|
784
|
+
} else {
|
|
785
|
+
for (const d of inv.daily) {
|
|
786
|
+
lines.push(` ${d.date} calls ${d.calls} denies ${d.denies} warns ${d.warns}`);
|
|
787
|
+
}
|
|
788
|
+
if (inv.undatedAuditRecords > 0) {
|
|
789
|
+
lines.push(` (${inv.undatedAuditRecords} record(s) had an unparsable timestamp and are excluded)`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
lines.push('');
|
|
793
|
+
|
|
794
|
+
lines.push('False-deny rate');
|
|
795
|
+
if (inv.falseDenyRate === null) {
|
|
796
|
+
lines.push(' null — NOT MEASURED');
|
|
797
|
+
lines.push(` reason: ${inv.falseDenyReason}`);
|
|
798
|
+
} else {
|
|
799
|
+
lines.push(` ${(inv.falseDenyRate * 100).toFixed(2)}%`);
|
|
800
|
+
}
|
|
801
|
+
lines.push(` numerator : ${inv.falseDenyNumerator === null ? 'n/a' : inv.falseDenyNumerator}`);
|
|
802
|
+
lines.push(` denominator : ${inv.falseDenyDenominator === null ? 'n/a' : inv.falseDenyDenominator}`);
|
|
803
|
+
lines.push(` clear events: ${inv.falseDenyClearEvents === null ? 'n/a' : inv.falseDenyClearEvents}`);
|
|
804
|
+
if (inv.falseDenyMethod) lines.push(` method : ${inv.falseDenyMethod}`);
|
|
805
|
+
|
|
806
|
+
return lines.join('\n') + '\n';
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
// Exports
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
module.exports = {
|
|
814
|
+
buildInventory,
|
|
815
|
+
renderInventoryText,
|
|
816
|
+
normalizeWindowDays,
|
|
817
|
+
readJsonlSource,
|
|
818
|
+
pairDeniesWithClears,
|
|
819
|
+
buildAgents,
|
|
820
|
+
buildToolCalls,
|
|
821
|
+
buildDenyReasonsByGate,
|
|
822
|
+
buildTopGates,
|
|
823
|
+
buildDaily,
|
|
824
|
+
coversWindow,
|
|
825
|
+
isClearingRecord,
|
|
826
|
+
SOURCE_STATUS,
|
|
827
|
+
CLEARING_DECISION,
|
|
828
|
+
ADMIN_OVERRIDE_SOURCE,
|
|
829
|
+
APPROVAL_REQUIRED_DECISION,
|
|
830
|
+
DEFAULT_MAX_BYTES,
|
|
831
|
+
KPI_LOG_FILENAME,
|
|
832
|
+
MIN_WINDOW_DAYS,
|
|
833
|
+
MAX_WINDOW_DAYS,
|
|
834
|
+
DEFAULT_WINDOW_DAYS,
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// CLI
|
|
839
|
+
// ---------------------------------------------------------------------------
|
|
840
|
+
|
|
841
|
+
function runCli(argv) {
|
|
842
|
+
const args = argv.slice(2);
|
|
843
|
+
const flag = (name) => {
|
|
844
|
+
const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
845
|
+
if (!hit) return undefined;
|
|
846
|
+
if (hit === `--${name}`) return true;
|
|
847
|
+
return hit.slice(`--${name}=`.length);
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const json = flag('json') !== undefined;
|
|
851
|
+
const days = flag('days');
|
|
852
|
+
const dataDir = flag('data-dir');
|
|
853
|
+
const maxBytes = flag('max-bytes');
|
|
854
|
+
|
|
855
|
+
const inventory = buildInventory({
|
|
856
|
+
dataDir: typeof dataDir === 'string' ? dataDir : undefined,
|
|
857
|
+
windowDays: typeof days === 'string' ? days : undefined,
|
|
858
|
+
maxBytes: typeof maxBytes === 'string' ? Number(maxBytes) : undefined,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
if (json) console.log(JSON.stringify(inventory, null, 2));
|
|
862
|
+
else process.stdout.write(renderInventoryText(inventory));
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// SonarCloud S3403 flags `require.main === module` as an always-false strict
|
|
866
|
+
// equality; the path-resolve form is the portable equivalent.
|
|
867
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
|
|
868
|
+
runCli(process.argv);
|
|
869
|
+
}
|