machine-bridge-mcp 3.0.0-beta.28 → 3.0.0-beta.29
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 +10 -0
- package/browser-extension/manifest.json +1 -1
- package/docs/AUDIT.md +14 -0
- package/docs/LOGGING.md +2 -2
- package/docs/TESTING.md +1 -1
- package/package.json +1 -1
- package/scripts/coverage-check.mjs +1 -0
- package/src/local/owner-state-lock.mjs +18 -12
- package/src/local/security-audit-log.mjs +5 -2
- package/src/local/security-audit-state.mjs +139 -0
- package/src/local/security-audit-storage.mjs +88 -153
- package/src/local/security-audit-worker.mjs +4 -5
- package/src/worker/index.ts +1 -1
- package/src/worker/observability.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.0.0-beta.29 - 2026-08-01
|
|
4
|
+
|
|
5
|
+
### Bounded security-audit throughput and retention
|
|
6
|
+
|
|
7
|
+
- Reuse one verified security-audit state inside the dedicated audit worker instead of rereading, reparsing, and rehashing the complete retained chain for every batch. The cache is invalidated by file identity, size, modification time, or metadata-change time, so another process or external alteration still forces full verification before a write.
|
|
8
|
+
- Bound retention by both 4,096 events and 4 MiB. Oversized-but-valid event histories now evict the oldest events, advance the chain anchor, and remain verifiable instead of permanently failing before the advertised event limit. Runtime diagnostics expose the byte ceiling explicitly.
|
|
9
|
+
- Fix an owner-state-lock race where a contender observed `EEXIST` just before the holder released the lock and then misclassified the now-missing file as malformed. Missing, invalid, and valid-owner states are now distinct, preserving fail-closed handling for actual corruption while allowing normal retry.
|
|
10
|
+
- Add regressions for cached-state tamper invalidation, byte-driven retention, cross-worker sequence preservation, and the lock release/acquire window. Keep audit state construction in a focused module rather than raising the existing architecture budget.
|
|
11
|
+
- Mark Worker observability counters as current-isolate metrics and state explicitly that durable calls can cross isolate lifetimes, so completed/failed counts are not misread as algebraically closed process-lifetime totals.
|
|
12
|
+
|
|
3
13
|
## 3.0.0-beta.28 - 2026-07-31
|
|
4
14
|
|
|
5
15
|
### Verified service restart semantics
|
|
@@ -30,6 +30,6 @@
|
|
|
30
30
|
"action": {
|
|
31
31
|
"default_title": "Machine Bridge Browser"
|
|
32
32
|
},
|
|
33
|
-
"version_name": "3.0.0-beta.
|
|
33
|
+
"version_name": "3.0.0-beta.29",
|
|
34
34
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxryYkpZhq8+VAQLHcGS9BAHQcyKX8RHGIpIwvtIVRU/rcOcE0bNdnM0aZJ/h6xWQsGDHlhvjT2+1aJaAn/9k8473BRWajzVXld961CdHYVFVHoce2hHiSJ0xydWrHMMZhAm0mN0UzjEpgZ0tMw209efcZHIvSwuxhteZMRy4kyiVjwFlOf5oXFCxRuCJnPj3AK9CmCf4XgEBuPIJ0TZmjGHOOdBvJmbCNnAWXYEo5/mf7MfCGhV4IJ1hNuhpoNQfOFKMUcw9/v/IpT62XpfXdGYTfGYCmCjC+gntK1spbkr2P4/2+sYMQtLpse71mpSNGXfcf3abU55Vpn+gncSxRQIDAQAB"
|
|
35
35
|
}
|
package/docs/AUDIT.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Security and privacy audit notes
|
|
2
2
|
|
|
3
|
+
## 2026-08-01 version 3.0.0-beta.29 security-audit storage review
|
|
4
|
+
|
|
5
|
+
Review of the live 4,096-event audit chain found that moving persistence to a worker thread removed daemon event-loop blocking but did not remove storage amplification. Each sparse batch still read roughly 3 MiB, parsed the complete document, recalculated every SHA-256 link, serialized the complete state, atomically replaced it, and called `fsync`. On the inspected machine, a cold full-chain verification took approximately 1.3 seconds. The previous implementation therefore could accumulate audit work at ordinary interactive call rates even though tool-result delivery itself remained asynchronous.
|
|
6
|
+
|
|
7
|
+
Beta.29 gives each audit worker a verified storage session. The first access performs the complete bounded read and chain verification. Subsequent writes reuse that state only while the on-disk regular-file identity, size, modification time, and metadata-change time remain unchanged. A cross-process writer or external alteration changes that identity and forces a fresh secure read and full hash-chain verification under the owner-state lock. The cache never turns an unverifiable file into an accepted state, and a failed write does not advance the cached state. In the same environment, cached single-record writes fell to roughly 39–264 milliseconds; the remaining cost is full-file serialization, atomic replacement, and `fsync`, not repeated chain verification. A segmented append/checkpoint format remains a possible future optimization if sustained high-rate audit throughput becomes a product requirement.
|
|
8
|
+
|
|
9
|
+
The review also proved that the former retention contract was internally inconsistent. Every event field could be within its legal bound while 4,096 events exceeded the separate 4 MiB file ceiling. The storage layer then rejected the write and left the audit backend unhealthy before reaching its advertised count limit. Retention is now the intersection of the event and byte limits: event encodings are measured exactly, the oldest prefix is removed until both bounds hold, and the anchor advances to the last removed hash so the retained suffix verifies independently. Diagnostics expose `maximum_bytes` alongside the event maximum.
|
|
10
|
+
|
|
11
|
+
Faster writes exposed a pre-existing lock race. After exclusive creation failed with `EEXIST`, a holder could release the lock before the contender read it. The contender treated both `ENOENT` and malformed content as corruption. The lock reader now returns distinct missing, invalid, and valid-owner states; only missing retries, while malformed state continues to fail closed. Cross-worker audit tests exercise this release/acquire window and verify continuous sequence numbers with no lost events.
|
|
12
|
+
|
|
13
|
+
The live review also showed isolate-local event counters that could appear inconsistent with the persistent pending-call snapshot after an isolate restart. That is not durable-call loss: a persisted call may start in one isolate and complete in a later isolate. Beta.29 adds an explicit machine-readable `metric_scope` declaring the current-isolate lifetime and non-closed counter algebra; the persistent pending-call snapshot remains authoritative for active ownership.
|
|
14
|
+
|
|
15
|
+
This source review does not activate beta.29, replace the running beta.28 daemon, deploy the Worker, modify live audit or legacy authorization files, publish npm, create a tag, or record acceptance.
|
|
16
|
+
|
|
3
17
|
## 2026-07-31 version 3.0.0-beta.27 control-plane resilience audit
|
|
4
18
|
|
|
5
19
|
A host-pressure incident exposed a causal chain that ordinary timeout tests had not covered: a metadata-heavy directory deletion exceeded its foreground deadline; subsequent minimal calls were delayed; and the daemon later classified the relay as unresponsive. Review of production diagnostics and the exact installed source showed that host filesystem/endpoint-security load was the trigger, but Machine Bridge amplified it. Timeout/cancellation performed synchronous process-table snapshots on the daemon thread, every remote call synchronously reread, rehashed, rewrote, and `fsync`ed the complete bounded audit file before returning a result, the process could remain alive after the timeout response, and heartbeat policy could not distinguish remote silence from a locally stalled event loop. Existing tests proved that `cancel_call` was sent and the Promise rejected; they did not prove post-timeout control-plane availability or completed process drain.
|
package/docs/LOGGING.md
CHANGED
|
@@ -12,7 +12,7 @@ Logs should answer:
|
|
|
12
12
|
4. Is an infrastructure, protocol, deployment, or local service problem requiring action?
|
|
13
13
|
5. When debug logging is explicitly enabled, which bounded implementation event should be correlated?
|
|
14
14
|
|
|
15
|
-
Logs are not a command history or content transcript. The local security audit provides a
|
|
15
|
+
Logs are not a command history or content transcript. The local security audit provides a SHA-256 hash chain over coarse operation metadata without recording command text, paths, contents, form values, or output. Retention is bounded by both 4,096 events and 4 MiB; byte-driven eviction advances the chain anchor so the retained suffix remains verifiable, and diagnostics expose both ceilings. Audit inputs are projected to an allowlist before crossing to a dedicated Worker thread; batching, atomic replacement, and `fsync` never delay tool-result delivery on the daemon event loop. The audit worker verifies the complete chain on first access and reuses that state only while the regular-file identity and timestamps remain unchanged. Cross-process writes or external alteration force a secure reread and complete verification before another write. Remote `diagnose_runtime.runtime.security_audit` reports worker readiness, queue depth/capacity, dropped records, retained entries, event/byte limits, and chain health; local stdio exposes the same snapshot as `server_info.security_audit`. Before the Worker reports readiness, health is explicitly `audit_initializing`; the daemon thread does not synchronously read the chain. The queue is not a write-ahead log: a process or operating-system crash before persistence can lose queued events, and `dropped_records` counts only failures observed during the current process lifetime. The chain detects local alteration but is not a remote immutable ledger and is not a substitute for OS isolation.
|
|
16
16
|
|
|
17
17
|
## Levels
|
|
18
18
|
|
|
@@ -67,7 +67,7 @@ Brief network interruptions are expected on laptop network changes, Worker deplo
|
|
|
67
67
|
|
|
68
68
|
A WebSocket close code such as `1006` means the transport ended without a normal close handshake. It is useful for debug diagnosis but not useful as the default user message. It is not evidence that the daemon process restarted. Worker `daemon_transport_error` / `daemon_liveness_timeout` messages and their 1012 close frames are likewise retryable connection conditions, not upgrade instructions. Only an unknown/incompatible Worker error, authentication failure, or identity/version mismatch may produce the fatal protocol/configuration log and daemon exit. Default logs therefore describe the affected layer, duration, classification, and recovery behavior rather than printing raw close envelopes.
|
|
69
69
|
|
|
70
|
-
Streamed-call diagnostics are deliberately coarse. Modern request-scoped stream ownership is memory-only; legacy MCP `2025-11-25` may additionally report aggregate persistent active/detached counts, oldest age, tool-name counts, alarm mutations, unmatched-result counts, and whether a legacy call is transient or durable. Logs and `server_info` must not include tool arguments, terminal results, command text, request keys, account identifiers, raw call IDs, raw connection generations, mirrored parameter values, private paths, or subscriber payloads. A stale-generation result is counted as unmatched rather than logged with its envelope.
|
|
70
|
+
Streamed-call diagnostics are deliberately coarse. Modern request-scoped stream ownership is memory-only; legacy MCP `2025-11-25` may additionally report aggregate persistent active/detached counts, oldest age, tool-name counts, alarm mutations, unmatched-result counts, and whether a legacy call is transient or durable. Worker event counters are scoped to the current isolate and say so in `metric_scope`; persisted durable calls can begin in one isolate and complete in another, so `started`, `completed`, and `failed` are not algebraically closed process-lifetime totals. The persistent pending-call snapshot is authoritative for current ownership. Logs and `server_info` must not include tool arguments, terminal results, command text, request keys, account identifiers, raw call IDs, raw connection generations, mirrored parameter values, private paths, or subscriber payloads. A stale-generation result is counted as unmatched rather than logged with its envelope.
|
|
71
71
|
|
|
72
72
|
Examples:
|
|
73
73
|
|
package/docs/TESTING.md
CHANGED
|
@@ -112,7 +112,7 @@ It opens Calculator, activates it through the fixed JXA helper, verifies structu
|
|
|
112
112
|
For deterministic release validation, perform an isolated-profile smoke test with the packaged unpacked extension; a Playwright persistent Chromium context is acceptable only as that isolated harness. When the requirement is specifically to prove control of the user's ordinary browser, the user must load the same unpacked directory into that known daily Chromium profile; status can verify the extension version/protocol and that Machine Bridge did not launch a browser, but cannot infer profile identity. Then use a localhost no-store fixture in a newly created tab. Do not enumerate, read, or mutate unrelated existing tabs. In both modes, inspect and reuse refs, exercise waits/forms/trusted input/open Shadow DOM/screenshots, verify final live DOM, and close the fixture tab.
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
`npm run control-plane-resilience:test` is the focused accident-regression gate. It exercises synchronous/asynchronous audit failures,
|
|
115
|
+
`npm run control-plane-resilience:test` is the focused accident-regression gate. It exercises synchronous/asynchronous audit failures, cached-state invalidation after external alteration, count- and byte-bounded retention anchoring, POSIX/Windows process-tree fallbacks, escalation-supervisor exception isolation, mixed transient/durable Worker capacity, and the shared 30+2 / 14+2 control-plane admission contract. `npm run security-audit:test` additionally runs two independent audit workers against one owner-only state file and requires continuous sequence numbers with no lost events, covering the lock release/acquire race. Both are part of the fast plan rather than coverage-only evidence.
|
|
116
116
|
|
|
117
117
|
- control-plane resilience under host pressure: local event-loop stalls versus genuine relay silence, fresh-heartbeat recovery grace, asynchronous process-group identity capture before `SIGTERM`, bounded post-signal revalidation, draining-process visibility after result settlement, two reserved diagnostic slots at both Worker and local layers under mixed transient/durable ordinary-call saturation, non-blocking audit dispatch, batched Worker persistence, queue/drop health, warning suppression, and privacy-safe audit projection;
|
|
118
118
|
- relay outage diagnostics and recovery: application-proxy versus OS-network scope, timestamped close/outage/recovery fields, fifteen-second maximum reconnect delay, heartbeat timeout, same-instance call continuation, Worker pong/welcome send failure, and `diagnose_runtime` relay history;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.29",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -78,6 +78,7 @@ try {
|
|
|
78
78
|
"src/local/security-audit-dispatch.mjs": [100, 70],
|
|
79
79
|
"src/local/security-audit-warning.mjs": [100, 75],
|
|
80
80
|
"src/local/security-audit-storage.mjs": [85, 60],
|
|
81
|
+
"src/local/security-audit-state.mjs": [90, 65],
|
|
81
82
|
"src/local/delegated-process-sandbox.mjs": [80, 45],
|
|
82
83
|
"src/shared/device-session-auth.mjs": [100, null],
|
|
83
84
|
"src/shared/mcp-protocol.mjs": [90, 70],
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
|
|
5
4
|
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
@@ -32,8 +31,12 @@ export async function withOwnerStateLock(root, callback, options = {}) {
|
|
|
32
31
|
break;
|
|
33
32
|
} catch (error) {
|
|
34
33
|
if (error?.code !== "EEXIST") throw error;
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
34
|
+
const inspected = readLockOwner(lockPath, purpose);
|
|
35
|
+
if (inspected.kind === "missing") continue;
|
|
36
|
+
if (inspected.kind === "invalid") {
|
|
37
|
+
throw new Error(`${label} lock is malformed; inspect the owner-only state directory`);
|
|
38
|
+
}
|
|
39
|
+
const existing = inspected.owner;
|
|
37
40
|
const identity = inspectProcessInstance(existing, { maxAgeMs });
|
|
38
41
|
if (identity.reclaimable) {
|
|
39
42
|
if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose }, { maxBytes: MAX_LOCK_BYTES })) continue;
|
|
@@ -71,16 +74,19 @@ function lockOwner(purpose) {
|
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
function readLockOwner(file, purpose) {
|
|
74
|
-
if (!existsSync(file)) return null;
|
|
75
77
|
let parsed;
|
|
76
|
-
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (!
|
|
82
|
-
if (!Number.
|
|
83
|
-
return
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "owner-state lock").toString("utf8"));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return error?.code === "ENOENT" ? { kind: "missing" } : { kind: "invalid" };
|
|
82
|
+
}
|
|
83
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "invalid" };
|
|
84
|
+
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return { kind: "invalid" };
|
|
85
|
+
if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return { kind: "invalid" };
|
|
86
|
+
if (parsed.purpose !== purpose) return { kind: "invalid" };
|
|
87
|
+
if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return { kind: "invalid" };
|
|
88
|
+
if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return { kind: "invalid" };
|
|
89
|
+
return { kind: "owner", owner: parsed };
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
function boundedIdentifier(value, fallback) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
2
|
import {
|
|
3
|
+
SECURITY_AUDIT_MAX_BYTES,
|
|
4
|
+
SECURITY_AUDIT_MAX_EVENTS,
|
|
3
5
|
auditFilePath,
|
|
4
6
|
unhealthyAuditSnapshot,
|
|
5
7
|
} from "./security-audit-storage.mjs";
|
|
@@ -153,7 +155,7 @@ export class SecurityAuditLog {
|
|
|
153
155
|
|
|
154
156
|
function initializingSnapshot() {
|
|
155
157
|
return {
|
|
156
|
-
enabled: true, healthy: false, retained: 0, maximum:
|
|
158
|
+
enabled: true, healthy: false, retained: 0, maximum: SECURITY_AUDIT_MAX_EVENTS, maximum_bytes: SECURITY_AUDIT_MAX_BYTES,
|
|
157
159
|
last_event_at: null, last_error_class: "audit_initializing",
|
|
158
160
|
content_logged: false, chain_verified: false,
|
|
159
161
|
};
|
|
@@ -164,7 +166,8 @@ function disabledSnapshot() {
|
|
|
164
166
|
enabled: false,
|
|
165
167
|
healthy: true,
|
|
166
168
|
retained: 0,
|
|
167
|
-
maximum:
|
|
169
|
+
maximum: SECURITY_AUDIT_MAX_EVENTS,
|
|
170
|
+
maximum_bytes: SECURITY_AUDIT_MAX_BYTES,
|
|
168
171
|
last_event_at: null,
|
|
169
172
|
last_error_class: null,
|
|
170
173
|
content_logged: false,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
4
|
+
|
|
5
|
+
export function decodeAndVerifyAuditState(buffer, schemaVersion, maximumEvents) {
|
|
6
|
+
let state;
|
|
7
|
+
try { state = JSON.parse(buffer.toString("utf8")); } catch (error) {
|
|
8
|
+
throw new Error("security audit state is not valid JSON", { cause: error });
|
|
9
|
+
}
|
|
10
|
+
validateState(state, schemaVersion, maximumEvents);
|
|
11
|
+
let previous = state.anchor;
|
|
12
|
+
for (const event of state.events) {
|
|
13
|
+
if (event.previous_hash !== previous || event.hash !== eventHash(event)) {
|
|
14
|
+
throw new Error("security audit hash chain verification failed");
|
|
15
|
+
}
|
|
16
|
+
previous = event.hash;
|
|
17
|
+
}
|
|
18
|
+
return state;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function emptyAuditState(schemaVersion) {
|
|
22
|
+
return {
|
|
23
|
+
schemaVersion,
|
|
24
|
+
identity_salt: randomBytes(32).toString("hex"),
|
|
25
|
+
anchor: randomBytes(32).toString("hex"),
|
|
26
|
+
next_sequence: 1,
|
|
27
|
+
events: [],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function copyAuditState(state) {
|
|
32
|
+
return { ...state, events: [...state.events] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function appendAuditRecords(state, records) {
|
|
36
|
+
for (const record of records) state.events.push(buildEvent(record?.input || {}, state, record?.nowMs));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function boundedAuditStateContent(state, maximumEvents, maximumBytes) {
|
|
40
|
+
const events = state.events;
|
|
41
|
+
const eventBytes = events.map((event) => Buffer.byteLength(JSON.stringify(event)));
|
|
42
|
+
let start = Math.max(0, events.length - maximumEvents);
|
|
43
|
+
let retained = events.length - start;
|
|
44
|
+
let bytes = Buffer.byteLength(`${JSON.stringify({ ...state, events: [] })}\n`);
|
|
45
|
+
for (let index = start; index < events.length; index += 1) bytes += eventBytes[index];
|
|
46
|
+
if (retained > 1) bytes += retained - 1;
|
|
47
|
+
|
|
48
|
+
while (bytes > maximumBytes && start < events.length) {
|
|
49
|
+
bytes -= eventBytes[start];
|
|
50
|
+
if (retained > 1) bytes -= 1;
|
|
51
|
+
start += 1;
|
|
52
|
+
retained -= 1;
|
|
53
|
+
}
|
|
54
|
+
if (start > 0) state.anchor = events[start - 1].hash;
|
|
55
|
+
state.events = events.slice(start);
|
|
56
|
+
|
|
57
|
+
const content = `${JSON.stringify(state)}\n`;
|
|
58
|
+
if (Buffer.byteLength(content) > maximumBytes) throw new Error("security audit state exceeds its size limit");
|
|
59
|
+
return content;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildEvent(input, state, nowMs) {
|
|
63
|
+
const timestamp = new Date(Number(nowMs)).toISOString();
|
|
64
|
+
if (!Number.isFinite(Date.parse(timestamp))) throw new Error("security audit timestamp is invalid");
|
|
65
|
+
const principal = input.principal && typeof input.principal === "object" ? input.principal : {};
|
|
66
|
+
const event = {
|
|
67
|
+
sequence: state.next_sequence,
|
|
68
|
+
timestamp,
|
|
69
|
+
outcome: boundedToken(input.outcome, "unknown"),
|
|
70
|
+
tool: boundedToken(input.tool, "unknown"),
|
|
71
|
+
risk_category: boundedText(input.riskCategory, "ordinary operation", 160),
|
|
72
|
+
target_hash: HASH_PATTERN.test(String(input.targetHash || "")) ? String(input.targetHash) : null,
|
|
73
|
+
account_ref: principal.accountId ? privateReference(state.identity_salt, principal.accountId) : null,
|
|
74
|
+
client_ref: principal.clientId ? privateReference(state.identity_salt, principal.clientId) : null,
|
|
75
|
+
family_ref: principal.familyId ? privateReference(state.identity_salt, principal.familyId) : null,
|
|
76
|
+
account_version: Number.isSafeInteger(principal.accountVersion) ? principal.accountVersion : null,
|
|
77
|
+
role: boundedToken(principal.role, principal.kind === "local" ? "local" : "unknown"),
|
|
78
|
+
duration_ms: boundedNumber(input.durationMs),
|
|
79
|
+
input_bytes: boundedNumber(input.inputBytes),
|
|
80
|
+
output_bytes: boundedNumber(input.outputBytes),
|
|
81
|
+
error_code: input.errorCode ? boundedToken(input.errorCode, "unknown") : null,
|
|
82
|
+
previous_hash: state.events.at(-1)?.hash || state.anchor,
|
|
83
|
+
};
|
|
84
|
+
const completed = { ...event, hash: eventHash(event) };
|
|
85
|
+
state.next_sequence += 1;
|
|
86
|
+
return completed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateState(state, schemaVersion, maximumEvents) {
|
|
90
|
+
if (!plainRecord(state) || state.schemaVersion !== schemaVersion) throw new Error("security audit state schema is invalid");
|
|
91
|
+
if (!HASH_PATTERN.test(state.identity_salt) || !HASH_PATTERN.test(state.anchor)) {
|
|
92
|
+
throw new Error("security audit state identity is invalid");
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isSafeInteger(state.next_sequence) || state.next_sequence < 1) {
|
|
95
|
+
throw new Error("security audit sequence is invalid");
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(state.events) || state.events.length > maximumEvents || !state.events.every(validEvent)) {
|
|
98
|
+
throw new Error("security audit events are invalid");
|
|
99
|
+
}
|
|
100
|
+
if (state.events.length && state.next_sequence <= state.events.at(-1).sequence) {
|
|
101
|
+
throw new Error("security audit sequence did not advance");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validEvent(event) {
|
|
106
|
+
return plainRecord(event)
|
|
107
|
+
&& Number.isSafeInteger(event.sequence) && event.sequence > 0
|
|
108
|
+
&& Number.isFinite(Date.parse(String(event.timestamp || "")))
|
|
109
|
+
&& typeof event.outcome === "string" && typeof event.tool === "string" && typeof event.risk_category === "string"
|
|
110
|
+
&& (event.target_hash === null || HASH_PATTERN.test(event.target_hash))
|
|
111
|
+
&& ["account_ref", "client_ref", "family_ref"].every((key) => event[key] === null || HASH_PATTERN.test(event[key]))
|
|
112
|
+
&& (event.account_version === null || Number.isSafeInteger(event.account_version))
|
|
113
|
+
&& typeof event.role === "string"
|
|
114
|
+
&& Number.isFinite(event.duration_ms) && Number.isFinite(event.input_bytes) && Number.isFinite(event.output_bytes)
|
|
115
|
+
&& (event.error_code === null || typeof event.error_code === "string")
|
|
116
|
+
&& HASH_PATTERN.test(event.previous_hash) && HASH_PATTERN.test(event.hash);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function eventHash(event) {
|
|
120
|
+
const value = { ...event };
|
|
121
|
+
delete value.hash;
|
|
122
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function privateReference(salt, value) {
|
|
126
|
+
return createHash("sha256").update(salt).update("\0").update(String(value)).digest("hex");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function boundedToken(value, fallback) {
|
|
130
|
+
return String(value || fallback).replace(/[^A-Za-z0-9._:-]/g, "_").slice(0, 128) || fallback;
|
|
131
|
+
}
|
|
132
|
+
function boundedText(value, fallback, maximum) {
|
|
133
|
+
return String(value || fallback).replace(/[\r\n\t\u0000-\u001f\u007f]/g, " ").trim().slice(0, maximum) || fallback;
|
|
134
|
+
}
|
|
135
|
+
function boundedNumber(value) {
|
|
136
|
+
const number = Number(value);
|
|
137
|
+
return Number.isFinite(number) && number >= 0 ? Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER) : 0;
|
|
138
|
+
}
|
|
139
|
+
function plainRecord(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
|
|
@@ -1,83 +1,80 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { lstatSync } from "node:fs";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
5
|
-
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
6
4
|
import { withOwnerStateLock } from "./owner-state-lock.mjs";
|
|
5
|
+
import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
|
|
6
|
+
import {
|
|
7
|
+
appendAuditRecords,
|
|
8
|
+
boundedAuditStateContent,
|
|
9
|
+
copyAuditState,
|
|
10
|
+
decodeAndVerifyAuditState,
|
|
11
|
+
emptyAuditState,
|
|
12
|
+
} from "./security-audit-state.mjs";
|
|
7
13
|
|
|
8
14
|
export const SECURITY_AUDIT_SCHEMA_VERSION = 1;
|
|
9
15
|
export const SECURITY_AUDIT_MAX_EVENTS = 4096;
|
|
10
16
|
export const SECURITY_AUDIT_MAX_BYTES = 4 * 1024 * 1024;
|
|
11
|
-
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
12
17
|
|
|
13
18
|
export function auditFilePath(root) {
|
|
14
19
|
return path.join(path.resolve(root), "security-audit.json");
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
export function readVerifiedAuditState(root) {
|
|
18
|
-
|
|
19
|
-
if (!existsSync(file)) return emptyState();
|
|
20
|
-
const raw = readBoundedRegularFileSync(file, SECURITY_AUDIT_MAX_BYTES, "security audit state");
|
|
21
|
-
let state;
|
|
22
|
-
try { state = JSON.parse(raw.toString("utf8")); } catch (error) {
|
|
23
|
-
throw new Error("security audit state is not valid JSON", { cause: error });
|
|
24
|
-
}
|
|
25
|
-
validateState(state);
|
|
26
|
-
let previous = state.anchor;
|
|
27
|
-
for (const event of state.events) {
|
|
28
|
-
if (event.previous_hash !== previous || event.hash !== eventHash(event)) {
|
|
29
|
-
throw new Error("security audit hash chain verification failed");
|
|
30
|
-
}
|
|
31
|
-
previous = event.hash;
|
|
32
|
-
}
|
|
33
|
-
return state;
|
|
23
|
+
return readVerifiedAuditStateWithIdentity(root).state;
|
|
34
24
|
}
|
|
35
25
|
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
26
|
+
export function createAuditStorageSession(root) {
|
|
27
|
+
const directory = path.resolve(root);
|
|
28
|
+
const file = auditFilePath(directory);
|
|
29
|
+
let cachedState = null;
|
|
30
|
+
let cachedIdentity;
|
|
31
|
+
|
|
32
|
+
const loadVerifiedState = () => {
|
|
33
|
+
const observedIdentity = inspectAuditFile(file);
|
|
34
|
+
if (cachedState && sameFileIdentity(cachedIdentity, observedIdentity)) return cachedState;
|
|
35
|
+
const loaded = readVerifiedAuditStateWithIdentity(directory);
|
|
36
|
+
cachedState = loaded.state;
|
|
37
|
+
cachedIdentity = loaded.identity;
|
|
38
|
+
return cachedState;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
snapshot: () => auditSnapshotFromState(loadVerifiedState()),
|
|
43
|
+
async recordBatch(records) {
|
|
44
|
+
if (!Array.isArray(records) || records.length === 0) return auditSnapshotFromState(loadVerifiedState());
|
|
45
|
+
return withOwnerStateLock(directory, async () => {
|
|
46
|
+
const state = copyAuditState(loadVerifiedState());
|
|
47
|
+
appendAuditRecords(state, records);
|
|
48
|
+
const content = boundedAuditStateContent(state, SECURITY_AUDIT_MAX_EVENTS, SECURITY_AUDIT_MAX_BYTES);
|
|
49
|
+
cachedIdentity = writeState(file, content);
|
|
50
|
+
cachedState = state;
|
|
51
|
+
return auditSnapshotFromState(state);
|
|
52
|
+
}, {
|
|
53
|
+
purpose: "security-audit", fileName: "security-audit.lock", label: "security audit",
|
|
54
|
+
});
|
|
55
|
+
},
|
|
55
56
|
});
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
export async function recordAuditBatch(root, records) {
|
|
60
|
+
return createAuditStorageSession(root).recordBatch(records);
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
export function auditSnapshotFromState(state) {
|
|
59
64
|
return {
|
|
60
|
-
enabled: true,
|
|
61
|
-
|
|
62
|
-
retained: state.events.length,
|
|
63
|
-
maximum: SECURITY_AUDIT_MAX_EVENTS,
|
|
65
|
+
enabled: true, healthy: true, retained: state.events.length,
|
|
66
|
+
maximum: SECURITY_AUDIT_MAX_EVENTS, maximum_bytes: SECURITY_AUDIT_MAX_BYTES,
|
|
64
67
|
last_event_at: state.events.at(-1)?.timestamp || null,
|
|
65
|
-
last_error_class: null,
|
|
66
|
-
content_logged: false,
|
|
67
|
-
chain_verified: true,
|
|
68
|
+
last_error_class: null, content_logged: false, chain_verified: true,
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export function unhealthyAuditSnapshot(error) {
|
|
72
73
|
return {
|
|
73
|
-
enabled: true,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
last_event_at: null,
|
|
78
|
-
last_error_class: auditErrorClass(error),
|
|
79
|
-
content_logged: false,
|
|
80
|
-
chain_verified: false,
|
|
74
|
+
enabled: true, healthy: false, retained: 0,
|
|
75
|
+
maximum: SECURITY_AUDIT_MAX_EVENTS, maximum_bytes: SECURITY_AUDIT_MAX_BYTES,
|
|
76
|
+
last_event_at: null, last_error_class: auditErrorClass(error),
|
|
77
|
+
content_logged: false, chain_verified: false,
|
|
81
78
|
};
|
|
82
79
|
}
|
|
83
80
|
|
|
@@ -85,113 +82,51 @@ export function auditErrorClass(error) {
|
|
|
85
82
|
return String(error?.code || error?.name || "audit_error").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
|
|
86
83
|
}
|
|
87
84
|
|
|
88
|
-
function
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
client_ref: principal.clientId ? privateReference(state.identity_salt, principal.clientId) : null,
|
|
101
|
-
family_ref: principal.familyId ? privateReference(state.identity_salt, principal.familyId) : null,
|
|
102
|
-
account_version: Number.isSafeInteger(principal.accountVersion) ? principal.accountVersion : null,
|
|
103
|
-
role: boundedToken(principal.role, principal.kind === "local" ? "local" : "unknown"),
|
|
104
|
-
duration_ms: boundedNumber(input.durationMs),
|
|
105
|
-
input_bytes: boundedNumber(input.inputBytes),
|
|
106
|
-
output_bytes: boundedNumber(input.outputBytes),
|
|
107
|
-
error_code: input.errorCode ? boundedToken(input.errorCode, "unknown") : null,
|
|
108
|
-
previous_hash: state.events.at(-1)?.hash || state.anchor,
|
|
109
|
-
};
|
|
110
|
-
const completed = { ...event, hash: eventHash(event) };
|
|
111
|
-
state.next_sequence += 1;
|
|
112
|
-
return completed;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function emptyState() {
|
|
85
|
+
function readVerifiedAuditStateWithIdentity(root) {
|
|
86
|
+
const file = auditFilePath(root);
|
|
87
|
+
let loaded;
|
|
88
|
+
try {
|
|
89
|
+
loaded = readBoundedRegularFileWithInfoSync(file, SECURITY_AUDIT_MAX_BYTES, "security audit state", {
|
|
90
|
+
verifyPathIdentity: true,
|
|
91
|
+
rejectMultipleLinks: true,
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error?.code === "ENOENT") return { state: emptyAuditState(SECURITY_AUDIT_SCHEMA_VERSION), identity: null };
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
116
97
|
return {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
anchor: randomBytes(32).toString("hex"),
|
|
120
|
-
next_sequence: 1,
|
|
121
|
-
events: [],
|
|
98
|
+
state: decodeAndVerifyAuditState(loaded.buffer, SECURITY_AUDIT_SCHEMA_VERSION, SECURITY_AUDIT_MAX_EVENTS),
|
|
99
|
+
identity: fileIdentity(loaded.info),
|
|
122
100
|
};
|
|
123
101
|
}
|
|
124
102
|
|
|
125
|
-
function
|
|
126
|
-
if (!plainRecord(state) || state.schemaVersion !== SECURITY_AUDIT_SCHEMA_VERSION) {
|
|
127
|
-
throw new Error("security audit state schema is invalid");
|
|
128
|
-
}
|
|
129
|
-
if (!HASH_PATTERN.test(state.identity_salt) || !HASH_PATTERN.test(state.anchor)) {
|
|
130
|
-
throw new Error("security audit state identity is invalid");
|
|
131
|
-
}
|
|
132
|
-
if (!Number.isSafeInteger(state.next_sequence) || state.next_sequence < 1) {
|
|
133
|
-
throw new Error("security audit sequence is invalid");
|
|
134
|
-
}
|
|
135
|
-
if (!Array.isArray(state.events)
|
|
136
|
-
|| state.events.length > SECURITY_AUDIT_MAX_EVENTS
|
|
137
|
-
|| !state.events.every(validEvent)) {
|
|
138
|
-
throw new Error("security audit events are invalid");
|
|
139
|
-
}
|
|
140
|
-
if (state.events.length && state.next_sequence <= state.events.at(-1).sequence) {
|
|
141
|
-
throw new Error("security audit sequence did not advance");
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function validEvent(event) {
|
|
146
|
-
return plainRecord(event)
|
|
147
|
-
&& Number.isSafeInteger(event.sequence)
|
|
148
|
-
&& event.sequence > 0
|
|
149
|
-
&& Number.isFinite(Date.parse(String(event.timestamp || "")))
|
|
150
|
-
&& typeof event.outcome === "string"
|
|
151
|
-
&& typeof event.tool === "string"
|
|
152
|
-
&& typeof event.risk_category === "string"
|
|
153
|
-
&& (event.target_hash === null || HASH_PATTERN.test(event.target_hash))
|
|
154
|
-
&& ["account_ref", "client_ref", "family_ref"].every((key) => event[key] === null || HASH_PATTERN.test(event[key]))
|
|
155
|
-
&& (event.account_version === null || Number.isSafeInteger(event.account_version))
|
|
156
|
-
&& typeof event.role === "string"
|
|
157
|
-
&& Number.isFinite(event.duration_ms)
|
|
158
|
-
&& Number.isFinite(event.input_bytes)
|
|
159
|
-
&& Number.isFinite(event.output_bytes)
|
|
160
|
-
&& (event.error_code === null || typeof event.error_code === "string")
|
|
161
|
-
&& HASH_PATTERN.test(event.previous_hash)
|
|
162
|
-
&& HASH_PATTERN.test(event.hash);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function eventHash(event) {
|
|
166
|
-
const value = { ...event };
|
|
167
|
-
delete value.hash;
|
|
168
|
-
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function privateReference(salt, value) {
|
|
172
|
-
return createHash("sha256").update(salt).update("\0").update(String(value)).digest("hex");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function writeState(file, state) {
|
|
103
|
+
function writeState(file, content) {
|
|
176
104
|
ensureOwnerOnlyDirectorySync(path.dirname(file));
|
|
177
|
-
const content = `${JSON.stringify(state)}\n`;
|
|
178
|
-
if (Buffer.byteLength(content) > SECURITY_AUDIT_MAX_BYTES) {
|
|
179
|
-
throw new Error("security audit state exceeds its size limit");
|
|
180
|
-
}
|
|
181
105
|
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
106
|
+
return inspectAuditFile(file);
|
|
182
107
|
}
|
|
183
108
|
|
|
184
|
-
function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
109
|
+
function inspectAuditFile(file) {
|
|
110
|
+
let info;
|
|
111
|
+
try { info = lstatSync(file); } catch (error) {
|
|
112
|
+
if (error?.code === "ENOENT") return null;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("security audit state must be a regular file and not a symbolic link");
|
|
116
|
+
if (Number(info.nlink) > 1) throw new Error("security audit state must not have multiple hard links");
|
|
117
|
+
return fileIdentity(info);
|
|
190
118
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return
|
|
119
|
+
|
|
120
|
+
function fileIdentity(info) {
|
|
121
|
+
return {
|
|
122
|
+
dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size),
|
|
123
|
+
mtime_ms: Number(info.mtimeMs), ctime_ms: Number(info.ctimeMs),
|
|
124
|
+
};
|
|
194
125
|
}
|
|
195
|
-
|
|
196
|
-
|
|
126
|
+
|
|
127
|
+
function sameFileIdentity(left, right) {
|
|
128
|
+
if (left === null || right === null) return left === right;
|
|
129
|
+
return Boolean(left && right)
|
|
130
|
+
&& left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
131
|
+
&& left.mtime_ms === right.mtime_ms && left.ctime_ms === right.ctime_ms;
|
|
197
132
|
}
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import { parentPort, workerData } from "node:worker_threads";
|
|
2
2
|
import {
|
|
3
3
|
auditErrorClass,
|
|
4
|
-
|
|
5
|
-
readVerifiedAuditState,
|
|
6
|
-
recordAuditBatch,
|
|
4
|
+
createAuditStorageSession,
|
|
7
5
|
unhealthyAuditSnapshot,
|
|
8
6
|
} from "./security-audit-storage.mjs";
|
|
9
7
|
|
|
10
8
|
const root = String(workerData?.root || "");
|
|
11
9
|
const MAX_BATCH = 128;
|
|
12
10
|
const BATCH_DELAY_MS = 5;
|
|
11
|
+
const storage = createAuditStorageSession(root);
|
|
13
12
|
const pending = [];
|
|
14
13
|
let timer = null;
|
|
15
14
|
let draining = false;
|
|
16
15
|
let closeRequested = false;
|
|
17
16
|
|
|
18
17
|
try {
|
|
19
|
-
parentPort.postMessage({ type: "ready", snapshot:
|
|
18
|
+
parentPort.postMessage({ type: "ready", snapshot: storage.snapshot() });
|
|
20
19
|
} catch (error) {
|
|
21
20
|
parentPort.postMessage({ type: "ready", snapshot: unhealthyAuditSnapshot(error) });
|
|
22
21
|
}
|
|
@@ -63,7 +62,7 @@ async function drain() {
|
|
|
63
62
|
}
|
|
64
63
|
if (records.length > 0) {
|
|
65
64
|
try {
|
|
66
|
-
const snapshot = await
|
|
65
|
+
const snapshot = await storage.recordBatch(records.map(({ input, nowMs }) => ({ input, nowMs })));
|
|
67
66
|
parentPort.postMessage({
|
|
68
67
|
type: "record_batch_result", ids: records.map((item) => item.id), recorded: true, snapshot,
|
|
69
68
|
});
|
package/src/worker/index.ts
CHANGED
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
sendWebSocketQuietly, trySendWebSocket,
|
|
62
62
|
} from "./websocket-protocol.ts";
|
|
63
63
|
|
|
64
|
-
const SERVER_VERSION = "3.0.0-beta.
|
|
64
|
+
const SERVER_VERSION = "3.0.0-beta.29";
|
|
65
65
|
const MCP_SERVER_INFO = mcpServerInfo(SERVER_VERSION);
|
|
66
66
|
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
67
67
|
const DAEMON_RECONNECT_GRACE_MS = relayContract.reconnectGraceMs;
|
|
@@ -88,6 +88,11 @@ export class WorkerObservability {
|
|
|
88
88
|
snapshot(): Record<string, unknown> {
|
|
89
89
|
return {
|
|
90
90
|
uptime_ms: Math.max(0, performance.now() - this.startedAt),
|
|
91
|
+
metric_scope: {
|
|
92
|
+
lifecycle: "current_worker_isolate",
|
|
93
|
+
durable_calls_may_cross_isolates: true,
|
|
94
|
+
counters_may_not_balance: true,
|
|
95
|
+
},
|
|
91
96
|
requests: { ...this.requests },
|
|
92
97
|
calls: { ...this.calls },
|
|
93
98
|
sockets: { ...this.sockets },
|