@compr/opscontext-mcp 2.10.0 → 2.12.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/CHANGELOG.md +81 -1
- package/dist/audit.d.ts +80 -3
- package/dist/audit.js +735 -96
- package/dist/cli.js +67 -5
- package/dist/firewall.js +17 -7
- package/dist/fleet-health.d.ts +23 -0
- package/dist/fleet-health.js +56 -0
- package/dist/http-server.js +12 -2
- package/dist/index.js +85 -4
- package/dist/learnings.d.ts +3 -0
- package/dist/learnings.js +100 -6
- package/dist/secret-shapes.js +7 -4
- package/dist/server-registry.d.ts +3 -0
- package/dist/server-registry.js +67 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,87 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
|
|
|
4
4
|
|
|
5
5
|
> Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [2.12.0] 2026-09-27: phase B, second half (liveness, the learnings store, two decisions)
|
|
8
|
+
|
|
9
|
+
### Servers and locks
|
|
10
|
+
|
|
11
|
+
- **A crashed process no longer holds a lock for seconds or minutes.** The audit log, rotation,
|
|
12
|
+
daily-check and learnings-store locks name their holder; a lock whose holder is gone, or an empty lock
|
|
13
|
+
older than a second, is broken at once. A writer killed inside the audit lock used to cost every
|
|
14
|
+
other writer about four entries (it happened on the author's machine on 2026-09-27); a saver killed
|
|
15
|
+
inside the store lock made saves fail for 20 s. Temp copies left by dead writers are removed.
|
|
16
|
+
LOCKs `[A-DEAD-HOLDER-LOSES-THE-LOCK-AT-ONCE]`, `[A-DEAD-STORE-HOLDER-LOSES-THE-LOCK-AT-ONCE]`. (B1-2, B4-2)
|
|
17
|
+
- **A registry record belongs to its own process.** A crashed server's record whose process number was
|
|
18
|
+
reused by another program froze the indexer election and silenced the event port. A record is now
|
|
19
|
+
alive only if its process started no later than the record says. LOCK `[A-RECORD-BELONGS-TO-ITS-OWN-PROCESS]`. (B5-1)
|
|
20
|
+
- **A chat server ends with its chat.** A server whose client died used to live on, still indexing and
|
|
21
|
+
holding the event port. It now stops when its input closes (the launchd agent excepted). A server with
|
|
22
|
+
the embedding model loaded stops without the model runtime's native abort, which used to leave a macOS
|
|
23
|
+
crash report on every stop. LOCK `[A-CHAT-SERVER-ENDS-WITH-ITS-CHAT]`. (B5-2)
|
|
24
|
+
|
|
25
|
+
### Learnings
|
|
26
|
+
|
|
27
|
+
- **A read never writes the store without the lock.** A read that found a bundled default missing used
|
|
28
|
+
to rewrite the whole store unlocked (a concurrent save could be lost), and a deleted default came back at
|
|
29
|
+
the next read. Now the read takes the lock or only shows the default; a deleted default stays deleted,
|
|
30
|
+
and a re-add is recorded. LOCK `[A-READ-NEVER-WRITES-THE-STORE]`. (B4-1)
|
|
31
|
+
|
|
32
|
+
### Firewall and compliance
|
|
33
|
+
|
|
34
|
+
- **A failed `git status` reads "unknown", never "clean"** in the firewall's git check (a repository git
|
|
35
|
+
could not read was reported clean). LOCK `[GIT-FAILURE-IS-UNKNOWN-NOT-CLEAN]`. (B6-2)
|
|
36
|
+
- **`audit-verify` shows every redaction acknowledgement it relied on** (time, actor, reason), and the
|
|
37
|
+
compliance pages say what an acknowledgement cannot prove. Four claims on those pages that had stopped
|
|
38
|
+
being true since June are corrected (rotation, verification, file permissions, restore). (B3-2)
|
|
39
|
+
|
|
40
|
+
## [2.11.0] 2026-09-27: the end-to-end review, phase B (the evidence stays true when things go wrong)
|
|
41
|
+
|
|
42
|
+
Fixes from the end-to-end review, phase B (`docs/audits/E2E_REVIEW_2026-09.md`, section "Phase B").
|
|
43
|
+
Every finding was replayed in real processes killed at the exact write, and on real full disks.
|
|
44
|
+
|
|
45
|
+
### Audit log
|
|
46
|
+
|
|
47
|
+
- **A log trim no longer keeps entries twice.** It took its size from one read of the file and its
|
|
48
|
+
records from another; entries that arrived in between were archived and kept again (29 copies in
|
|
49
|
+
the test on the old code; 20 real ones on 2026-09-25). The snapshot is now the bytes read.
|
|
50
|
+
LOCK `[ROTATION-SNAPSHOT-IS-THE-BYTES-READ]`. (B2-1)
|
|
51
|
+
- **The verifier names a copy a copy.** A record whose hash already appeared is counted once and
|
|
52
|
+
reported as a duplicate, not as a concurrent-append fork. (B2-1, B2-2)
|
|
53
|
+
- **A record cut short by a full disk no longer stops the log.** Every later append used to be
|
|
54
|
+
refused for good, only on stderr, while the receiver answered "written" and `emit-event` printed
|
|
55
|
+
"Appended". The fragment is now kept in `audit.torn-<time>.partial`, the log continues from its last
|
|
56
|
+
complete record, and an `audit.torn_tail` record says so. LOCK `[TORN-TAIL-IS-KEPT-AND-CHAINED]`. (B1-1)
|
|
57
|
+
- **The verifier reads past a line that is not a record**, names its file and line, and checks every
|
|
58
|
+
other record (it used to say "0 record(s) checked"). LOCK `[VERIFY-READS-PAST-AN-UNREADABLE-LINE]`.
|
|
59
|
+
- **A refused entry is counted and put on the chain.** `safeAppend()` returns whether it wrote; the
|
|
60
|
+
next good append chains an `audit.append_failed` record. The receiver answers 503 with the real
|
|
61
|
+
counts when the log refused entries; `emit-event` exits 1. LOCKs
|
|
62
|
+
`[A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED]`, `[RECEIVER-SAYS-WHAT-WAS-WRITTEN]`. (B1-1, B1-2)
|
|
63
|
+
- **An interrupted trim, restore or scrub is finished, never repeated.** A trim killed after writing its
|
|
64
|
+
archive file used to be archived again by the next one (70,000 copies), a trim or restore killed
|
|
65
|
+
before its record was never recorded, and a leftover temp file kept secrets through a scrub. Each
|
|
66
|
+
move now writes a small note first; the next holder of the lock finishes it and chains the missing
|
|
67
|
+
record (a restore keeps its reason). LOCK `[AN-INTERRUPTED-MOVE-IS-FINISHED]`. (B2-2, B2-3, B3-3)
|
|
68
|
+
- **The scrub acknowledges before it rewrites.** Killed in between, 100 scrubbed records used to read
|
|
69
|
+
as tampering for good. `audit-verify` now lists every altered index in the command it suggests.
|
|
70
|
+
LOCK `[SCRUB-ACKNOWLEDGES-BEFORE-IT-REWRITES]`. (B3-1)
|
|
71
|
+
|
|
72
|
+
### Fleet health
|
|
73
|
+
|
|
74
|
+
- **Health sees the audit chain.** It was green on three broken logs. Every `audit-verify` records its
|
|
75
|
+
result in `~/.contextengine/audit-verify.json`; the indexing server runs a full check once a day in a
|
|
76
|
+
separate low-priority process (a full check costs about 26 s and 3.5 GB on 4.9 million records;
|
|
77
|
+
`CONTEXTENGINE_CHAIN_CHECK=0` turns it off). A failed check, a check older than 48 h, refused entries
|
|
78
|
+
and records cut short are warnings. LOCK `[HEALTH-SEES-THE-CHAIN]`. (B6-1)
|
|
79
|
+
|
|
80
|
+
### Other
|
|
81
|
+
|
|
82
|
+
- Appends cost about 0.03 ms more (one extra read of the log's last byte); measured at 8 processes x 3,000.
|
|
83
|
+
- The bare `pass` name added in 2.10.0 now counts only before an equals sign or before a quoted value
|
|
84
|
+
after a colon (nodemailer's auth block): it also
|
|
85
|
+
took prose (a README's "PII pass" list) and code (a count of passing checks), which the public-release
|
|
86
|
+
scan refused. `SMTP_PASS`, `DB_PASS`, `db.pass` are unchanged. Replayed on 250,539 real strings:
|
|
87
|
+
the one false hit is gone, the real mail password is still caught.
|
|
8
88
|
|
|
9
89
|
## [2.10.0] 2026-09-26: the end-to-end review, phase A (security of what ships)
|
|
10
90
|
|
package/dist/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuditEvent = "learning.save" | "learning.delete" | "learning.store_unreadable" | "learning.store_shrink_refused" | "learning.store_growth_refused" | "server.start" | "server.role" | "index.write" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "ingest.rate_limited" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error" | "audit.rotate" | "audit.redact" | "audit.restore";
|
|
1
|
+
export type AuditEvent = "learning.save" | "learning.delete" | "learning.store_unreadable" | "learning.store_shrink_refused" | "learning.store_growth_refused" | "server.start" | "server.role" | "index.write" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "ingest.rate_limited" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error" | "audit.rotate" | "audit.redact" | "audit.restore" | "audit.torn_tail" | "audit.append_failed";
|
|
2
2
|
export interface AuditRecord {
|
|
3
3
|
ts: string;
|
|
4
4
|
event: AuditEvent;
|
|
@@ -10,6 +10,12 @@ export interface AuditRecord {
|
|
|
10
10
|
export declare function appendAudit(event: AuditEvent, payload: Record<string, unknown>, actor?: string): AuditRecord;
|
|
11
11
|
/** Archived segment filenames in chain order (oldest first). */
|
|
12
12
|
export declare function listSegments(): string[];
|
|
13
|
+
/** A line of the history that is not a record: where it is, and the history index it sits before. */
|
|
14
|
+
export interface UnreadableLine {
|
|
15
|
+
file: string;
|
|
16
|
+
line: number;
|
|
17
|
+
beforeIndex: number;
|
|
18
|
+
}
|
|
13
19
|
export interface ReadOptions {
|
|
14
20
|
/** Include archived segments. Default true — callers asking for "the audit log" mean
|
|
15
21
|
* the whole history. Hot paths that only care about a recent window pass false. */
|
|
@@ -105,10 +111,26 @@ export declare function rotateAuditLog(opts?: RotateOptions): RotationResult;
|
|
|
105
111
|
* command bypassed it. [LOCK] [ROTATION-HOLDS-THE-LOCK-BEFORE-IT-PLANS]
|
|
106
112
|
*/
|
|
107
113
|
export declare const AUTO_ROTATE_TRIGGER: number;
|
|
114
|
+
/** Cheap: is there anything for finishInterruptedMoves() to do? */
|
|
115
|
+
export declare function interruptedMovePending(): boolean;
|
|
116
|
+
export interface FinishReport {
|
|
117
|
+
tempsRemoved: string[];
|
|
118
|
+
rotation: {
|
|
119
|
+
segment: string;
|
|
120
|
+
duplicatesDropped: number;
|
|
121
|
+
recorded: boolean;
|
|
122
|
+
} | null;
|
|
123
|
+
restore: {
|
|
124
|
+
segment: string;
|
|
125
|
+
recorded: boolean;
|
|
126
|
+
} | null;
|
|
127
|
+
}
|
|
128
|
+
/** Run with the rotate lock held. [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED] */
|
|
129
|
+
export declare function finishInterruptedMoves(): FinishReport;
|
|
108
130
|
/** Count newline-terminated lines without parsing. The live log is small by construction. */
|
|
109
131
|
export declare function countLiveRecords(): number;
|
|
110
132
|
export interface AutoRotateOutcome {
|
|
111
|
-
action: "disabled" | "below_trigger" | "in_progress" | "rotated" | "refused" | "error";
|
|
133
|
+
action: "disabled" | "below_trigger" | "in_progress" | "rotated" | "refused" | "error" | "finished";
|
|
112
134
|
liveRecords: number;
|
|
113
135
|
detail: string;
|
|
114
136
|
result?: RotationResult;
|
|
@@ -130,6 +152,22 @@ export interface IntegrityReport {
|
|
|
130
152
|
/** Records whose prev_hash names a KNOWN earlier head — a concurrent-append fork.
|
|
131
153
|
* Content is provably intact; only the linkage is non-linear. Not tampering. */
|
|
132
154
|
forkIndices?: number[];
|
|
155
|
+
/** Records whose hash already appeared earlier in the history: a second copy of a record,
|
|
156
|
+
* counted once and never relinked. Not tampering, not a fork. [LOCK] [VERIFY-FORK-IS-NOT-TAMPER] */
|
|
157
|
+
duplicateIndices?: number[];
|
|
158
|
+
/** The acknowledgements that turned altered records into redacted ones: who said so, when, why.
|
|
159
|
+
* An acknowledgement is a statement by whoever ran it, so the verifier shows every one it used.
|
|
160
|
+
* [LOCK] [REDACTION-IS-A-CHAINED-RECORD] */
|
|
161
|
+
acknowledgements?: Array<{
|
|
162
|
+
index: number;
|
|
163
|
+
ts: string;
|
|
164
|
+
actor: string;
|
|
165
|
+
reason: string;
|
|
166
|
+
records: number;
|
|
167
|
+
}>;
|
|
168
|
+
/** Lines that are not records: file, line number, and the history index they sit before.
|
|
169
|
+
* Non-empty makes `ok` false; every other record is still checked. [LOCK] [VERIFY-READS-PAST-AN-UNREADABLE-LINE] */
|
|
170
|
+
unreadable?: UnreadableLine[];
|
|
133
171
|
/** Records whose content was altered AND whose alteration is acknowledged by a later, intact
|
|
134
172
|
* `audit.redact` record binding the original hash to the current content. Not counted as
|
|
135
173
|
* tampering. */
|
|
@@ -157,8 +195,45 @@ export interface IntegrityReport {
|
|
|
157
195
|
* `ok` is true when there are no tampered and no orphan records. Forks are surfaced
|
|
158
196
|
* with counts and indices so the report stays honest in both directions — it must
|
|
159
197
|
* never claim a forked log is pristine either.
|
|
198
|
+
* 2026-09-27: a fourth class. A record whose hash was already seen is a DUPLICATE (a second copy
|
|
199
|
+
* of the same record), counted once and skipped for linkage, so the record after a copied
|
|
200
|
+
* block links to the original. Before, the first copy read as a "fork" and the total counted
|
|
201
|
+
* every copy: 190,011 records "verified" for 120,011 real ones after an interrupted rotation
|
|
202
|
+
* (E2E_REVIEW_2026-09 B2-1, B2-2). A copy's content is still checked against its own hash.
|
|
160
203
|
*/
|
|
161
204
|
export declare function verifyChain(): IntegrityReport;
|
|
205
|
+
/**
|
|
206
|
+
* The result of the last full check, kept for fleet health: the check costs seconds and gigabytes
|
|
207
|
+
* (measured 2026-09-27: 26 s and 3.5 GB for 4,927,803 records), so health reads its result and
|
|
208
|
+
* never runs it. [LOCK] [HEALTH-SEES-THE-CHAIN] (src/fleet-health.ts)
|
|
209
|
+
*/
|
|
210
|
+
export interface VerifyState {
|
|
211
|
+
checkedAt: string;
|
|
212
|
+
ms: number;
|
|
213
|
+
by: "cli" | "scheduled";
|
|
214
|
+
ok: boolean;
|
|
215
|
+
total: number;
|
|
216
|
+
unique: number;
|
|
217
|
+
altered: number;
|
|
218
|
+
orphans: number;
|
|
219
|
+
unreadable: number;
|
|
220
|
+
duplicates: number;
|
|
221
|
+
forks: number;
|
|
222
|
+
redacted: number;
|
|
223
|
+
reason: string | null;
|
|
224
|
+
}
|
|
225
|
+
export declare function verifyStatePath(): string;
|
|
226
|
+
/** Keep the result of a full check. Best effort: a check that cannot record still printed its verdict. */
|
|
227
|
+
export declare function recordVerifyState(report: IntegrityReport, ms: number, by: VerifyState["by"]): VerifyState;
|
|
228
|
+
export declare function readVerifyState(): VerifyState | null;
|
|
229
|
+
/** Refusals written by safeAppend() and not chained yet: count, and the newest one. */
|
|
230
|
+
export declare function pendingRefusals(): {
|
|
231
|
+
count: number;
|
|
232
|
+
last: string | null;
|
|
233
|
+
error: string | null;
|
|
234
|
+
};
|
|
235
|
+
/** One scheduled full check at a time across processes: O_EXCL, stale after two hours. */
|
|
236
|
+
export declare function acquireVerifyLock(): (() => void) | null;
|
|
162
237
|
/**
|
|
163
238
|
* Acknowledge that records were deliberately redacted (a secret removed from their content).
|
|
164
239
|
*
|
|
@@ -262,6 +337,8 @@ type Redactor = (payload: Record<string, unknown>) => {
|
|
|
262
337
|
* the live log under the append lock (appends wait, none is lost), then one audit.redact
|
|
263
338
|
* record per 100 rewrites names each original hash and its new content hash
|
|
264
339
|
* ([REDACTION-IS-A-CHAINED-RECORD]). Running it again changes nothing.
|
|
340
|
+
* 2026-09-27 (Yan's GO): the order is now acknowledgement first, rewrite second, for every file.
|
|
341
|
+
* [LOCK] [SCRUB-ACKNOWLEDGES-BEFORE-IT-REWRITES]
|
|
265
342
|
*/
|
|
266
343
|
export declare function scrubAuditLog(opts: {
|
|
267
344
|
apply?: boolean;
|
|
@@ -272,6 +349,6 @@ export declare function scrubAuditLog(opts: {
|
|
|
272
349
|
export declare function filterByRange(records: AuditRecord[], since?: string, until?: string): AuditRecord[];
|
|
273
350
|
export declare function toCsv(records: AuditRecord[]): string;
|
|
274
351
|
export declare function resetCacheForTest(): void;
|
|
275
|
-
export declare function safeAppend(event: AuditEvent, payload: Record<string, unknown>, actor?: string):
|
|
352
|
+
export declare function safeAppend(event: AuditEvent, payload: Record<string, unknown>, actor?: string): boolean;
|
|
276
353
|
export {};
|
|
277
354
|
//# sourceMappingURL=audit.d.ts.map
|