@bookedsolid/rea 0.2.1 → 0.4.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.
Files changed (65) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/THREAT_MODEL.md +100 -29
  4. package/dist/audit/append.d.ts +21 -8
  5. package/dist/audit/append.js +48 -83
  6. package/dist/audit/fs.d.ts +68 -0
  7. package/dist/audit/fs.js +171 -0
  8. package/dist/cli/audit.d.ts +40 -0
  9. package/dist/cli/audit.js +205 -0
  10. package/dist/cli/doctor.d.ts +19 -4
  11. package/dist/cli/doctor.js +172 -5
  12. package/dist/cli/index.js +26 -1
  13. package/dist/cli/init.js +93 -7
  14. package/dist/cli/install/pre-push.d.ts +335 -0
  15. package/dist/cli/install/pre-push.js +2818 -0
  16. package/dist/cli/serve.d.ts +64 -0
  17. package/dist/cli/serve.js +270 -2
  18. package/dist/cli/status.d.ts +90 -0
  19. package/dist/cli/status.js +399 -0
  20. package/dist/cli/utils.d.ts +4 -0
  21. package/dist/cli/utils.js +4 -0
  22. package/dist/gateway/audit/rotator.d.ts +116 -0
  23. package/dist/gateway/audit/rotator.js +289 -0
  24. package/dist/gateway/circuit-breaker.d.ts +17 -0
  25. package/dist/gateway/circuit-breaker.js +32 -3
  26. package/dist/gateway/downstream-pool.d.ts +2 -1
  27. package/dist/gateway/downstream-pool.js +2 -2
  28. package/dist/gateway/downstream.d.ts +39 -3
  29. package/dist/gateway/downstream.js +73 -14
  30. package/dist/gateway/log.d.ts +122 -0
  31. package/dist/gateway/log.js +334 -0
  32. package/dist/gateway/middleware/audit.d.ts +24 -1
  33. package/dist/gateway/middleware/audit.js +103 -58
  34. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  35. package/dist/gateway/middleware/blocked-paths.js +439 -67
  36. package/dist/gateway/middleware/injection.d.ts +218 -13
  37. package/dist/gateway/middleware/injection.js +433 -51
  38. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  39. package/dist/gateway/middleware/kill-switch.js +20 -1
  40. package/dist/gateway/observability/metrics.d.ts +125 -0
  41. package/dist/gateway/observability/metrics.js +321 -0
  42. package/dist/gateway/server.d.ts +19 -0
  43. package/dist/gateway/server.js +99 -15
  44. package/dist/policy/loader.d.ts +47 -0
  45. package/dist/policy/loader.js +47 -0
  46. package/dist/policy/profiles.d.ts +13 -0
  47. package/dist/policy/profiles.js +12 -0
  48. package/dist/policy/types.d.ts +52 -0
  49. package/dist/registry/fingerprint.d.ts +73 -0
  50. package/dist/registry/fingerprint.js +81 -0
  51. package/dist/registry/fingerprints-store.d.ts +62 -0
  52. package/dist/registry/fingerprints-store.js +111 -0
  53. package/dist/registry/interpolate.d.ts +58 -0
  54. package/dist/registry/interpolate.js +121 -0
  55. package/dist/registry/loader.d.ts +2 -2
  56. package/dist/registry/loader.js +22 -1
  57. package/dist/registry/tofu-gate.d.ts +41 -0
  58. package/dist/registry/tofu-gate.js +189 -0
  59. package/dist/registry/tofu.d.ts +111 -0
  60. package/dist/registry/tofu.js +173 -0
  61. package/dist/registry/types.d.ts +9 -1
  62. package/package.json +3 -1
  63. package/profiles/bst-internal-no-codex.yaml +5 -0
  64. package/profiles/bst-internal.yaml +7 -0
  65. package/scripts/tarball-smoke.sh +197 -0
@@ -1,11 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import crypto from 'node:crypto';
4
3
  import { Tier, InvocationStatus } from '../../policy/types.js';
5
- function computeHash(record) {
6
- const payload = JSON.stringify(record);
7
- return crypto.createHash('sha256').update(payload).digest('hex');
8
- }
4
+ import { computeHash, fsyncFile, readLastRecord, withAuditLock, } from '../../audit/fs.js';
5
+ import { maybeRotate } from '../audit/rotator.js';
9
6
  /**
10
7
  * Post-execution middleware: appends a hash-chained JSONL audit record.
11
8
  *
@@ -14,15 +11,42 @@ function computeHash(record) {
14
11
  * SECURITY: Wraps next() in try/finally to ensure audit runs even on middleware exceptions.
15
12
  * SECURITY: Placed as outermost middleware so audit records ALL invocations, including denials.
16
13
  * PERFORMANCE: All fs operations are async to avoid blocking the event loop.
14
+ *
15
+ * CONCURRENCY (G1):
16
+ * - Per-process: the writeQueue below serializes writes within the Node process.
17
+ * - Cross-process: each write acquires a `proper-lockfile` lock on `.rea/`.
18
+ * Stale locks are reclaimed after 10s. Lock-acquisition failure falls back
19
+ * to the current best-effort behavior — the tool call proceeds and the
20
+ * failure is logged. Breaking the invocation because the auditor failed
21
+ * would let an audit outage take down the gateway.
22
+ *
23
+ * ROTATION (G1):
24
+ * - `maybeRotate` runs before each write's lock acquisition. Rotation writes
25
+ * a marker record whose `prev_hash` preserves hash-chain continuity across
26
+ * the rotation boundary. When no `audit.rotation` block is set in policy,
27
+ * rotation is a no-op — 0.2.x behavior is preserved.
17
28
  */
18
- export function createAuditMiddleware(baseDir, policy) {
29
+ export function createAuditMiddleware(baseDir, policy,
30
+ /**
31
+ * Optional metrics registry. When supplied, the
32
+ * `rea_audit_lines_appended_total` counter is incremented on every
33
+ * successful append (post-fsync). When omitted, no metrics are emitted —
34
+ * keeps the middleware usable in unit tests that don't exercise the
35
+ * observability surface.
36
+ */
37
+ metrics) {
19
38
  // REA writes to a single .rea/audit.jsonl file (not dated per-day files).
20
39
  const reaDir = path.join(baseDir, '.rea');
21
40
  const auditFile = path.join(reaDir, 'audit.jsonl');
22
- let prevHash = '0000000000000000000000000000000000000000000000000000000000000000';
23
41
  let dirEnsured = false;
24
42
  // SECURITY: Use a write queue to serialize audit writes, ensuring the hash chain is linear.
25
43
  let writeQueue = Promise.resolve();
44
+ async function ensureDir() {
45
+ if (!dirEnsured) {
46
+ await fs.mkdir(reaDir, { recursive: true });
47
+ dirEnsured = true;
48
+ }
49
+ }
26
50
  return async (ctx, next) => {
27
51
  let nextError;
28
52
  try {
@@ -40,64 +64,85 @@ export function createAuditMiddleware(baseDir, policy) {
40
64
  // kill-switch denied before policy middleware ran).
41
65
  const duration_ms = Date.now() - ctx.start_time;
42
66
  const autonomyLevel = ctx.metadata.autonomy_level ?? policy?.autonomy_level ?? 'unknown';
67
+ // Cap ctx.error before writing the audit record. A downstream MCP server
68
+ // can produce arbitrarily long error strings; if the audit record grows
69
+ // beyond ~64 KiB, `rea status` misreports it as corrupt because the tail
70
+ // window in summarizeAudit cannot contain the full record. 4096 bytes is
71
+ // generous for any legitimate error description.
72
+ const MAX_AUDIT_ERROR_BYTES = 4096;
73
+ if (ctx.error && ctx.error.length > MAX_AUDIT_ERROR_BYTES) {
74
+ ctx.error = ctx.error.slice(0, MAX_AUDIT_ERROR_BYTES) + '\u2026[truncated]';
75
+ }
43
76
  // Serialize audit writes via a queue to maintain hash chain linearity under concurrency.
44
- // Each write awaits the previous one before computing its hash, ensuring a linear chain.
77
+ // Each write awaits the previous one before running its lock-scoped append.
45
78
  const writePromise = writeQueue.then(async () => {
46
79
  try {
47
- const now = new Date().toISOString();
48
- const recordBase = {
49
- timestamp: now,
50
- session_id: ctx.session_id,
51
- tool_name: ctx.tool_name,
52
- server_name: ctx.server_name,
53
- tier: ctx.tier ?? Tier.Write,
54
- status: ctx.status,
55
- autonomy_level: autonomyLevel,
56
- duration_ms,
57
- prev_hash: prevHash,
58
- };
59
- if (ctx.error) {
60
- recordBase.error = ctx.error;
61
- }
62
- if (ctx.redacted_fields?.length) {
63
- recordBase.redacted_fields = ctx.redacted_fields;
64
- }
65
- // Attach caller-supplied metadata when the middleware context carries any.
66
- // The `autonomy_level` key is reserved for internal bookkeeping (see above)
67
- // and is excluded from the exported metadata payload.
68
- if (ctx.metadata !== undefined) {
69
- const exported = {};
70
- for (const [k, v] of Object.entries(ctx.metadata)) {
71
- if (k === 'autonomy_level')
72
- continue;
73
- exported[k] = v;
80
+ await ensureDir();
81
+ // G1: Attempt rotation BEFORE acquiring the append lock. No-op when the
82
+ // policy's audit.rotation block is absent. Errors are swallowed inside
83
+ // maybeRotate so rotation can never take down the gateway.
84
+ await maybeRotate(auditFile, policy);
85
+ await withAuditLock(auditFile, async () => {
86
+ const { hash: prevHash } = await readLastRecord(auditFile);
87
+ const now = new Date().toISOString();
88
+ const recordBase = {
89
+ timestamp: now,
90
+ session_id: ctx.session_id,
91
+ tool_name: ctx.tool_name,
92
+ server_name: ctx.server_name,
93
+ tier: ctx.tier ?? Tier.Write,
94
+ status: ctx.status,
95
+ autonomy_level: autonomyLevel,
96
+ duration_ms,
97
+ prev_hash: prevHash,
98
+ };
99
+ if (ctx.error) {
100
+ recordBase.error = ctx.error;
101
+ }
102
+ if (ctx.redacted_fields?.length) {
103
+ recordBase.redacted_fields = ctx.redacted_fields;
104
+ }
105
+ // Attach caller-supplied metadata when the middleware context carries any.
106
+ // The `autonomy_level` key is reserved for internal bookkeeping (see above)
107
+ // and is excluded from the exported metadata payload.
108
+ if (ctx.metadata !== undefined) {
109
+ const exported = {};
110
+ for (const [k, v] of Object.entries(ctx.metadata)) {
111
+ if (k === 'autonomy_level')
112
+ continue;
113
+ exported[k] = v;
114
+ }
115
+ if (Object.keys(exported).length > 0) {
116
+ recordBase.metadata = exported;
117
+ }
118
+ }
119
+ const hash = computeHash(recordBase);
120
+ const record = { ...recordBase, hash };
121
+ const line = JSON.stringify(record) + '\n';
122
+ try {
123
+ await fs.appendFile(auditFile, line);
124
+ }
125
+ catch {
126
+ // Directory may have been deleted externally — retry once with mkdir
127
+ dirEnsured = false;
128
+ await ensureDir();
129
+ await fs.appendFile(auditFile, line);
130
+ }
131
+ await fsyncFile(auditFile);
132
+ // Only increment after fsync — a counter advance for a line that
133
+ // was never durable on disk would be a lie.
134
+ try {
135
+ metrics?.incAuditLines(1);
74
136
  }
75
- if (Object.keys(exported).length > 0) {
76
- recordBase.metadata = exported;
137
+ catch {
138
+ // Metrics failures must never crash the gateway.
77
139
  }
78
- }
79
- const hash = computeHash(recordBase);
80
- const record = { ...recordBase, hash };
81
- prevHash = hash;
82
- const line = JSON.stringify(record) + '\n';
83
- // Ensure .rea dir exists (cached, with retry on failure)
84
- if (!dirEnsured) {
85
- await fs.mkdir(reaDir, { recursive: true });
86
- dirEnsured = true;
87
- }
88
- try {
89
- await fs.appendFile(auditFile, line);
90
- }
91
- catch {
92
- // Directory may have been deleted externally — retry once with mkdir
93
- dirEnsured = false;
94
- await fs.mkdir(reaDir, { recursive: true });
95
- dirEnsured = true;
96
- await fs.appendFile(auditFile, line);
97
- }
140
+ });
98
141
  }
99
142
  catch (auditErr) {
100
- // SECURITY: Never crash the gateway on audit failure — log to stderr
143
+ // SECURITY: Never crash the gateway on audit failure — log to stderr.
144
+ // This catches lock-acquisition failures, EEXIST-without-stale, and
145
+ // any other I/O failure. The tool call itself continues.
101
146
  dirEnsured = false;
102
147
  console.error('[rea] AUDIT WRITE FAILED:', auditErr instanceof Error ? auditErr.message : auditErr);
103
148
  }
@@ -1,12 +1,3 @@
1
1
  import type { Policy } from '../../policy/types.js';
2
2
  import type { Middleware } from './chain.js';
3
- /**
4
- * Pre-execution middleware: denies tool invocations whose arguments
5
- * reference paths that are in the policy's blocked_paths list.
6
- *
7
- * SECURITY: Inspects all string values in arguments (including nested objects/arrays).
8
- * SECURITY: Always blocks .rea/ regardless of policy configuration.
9
- * SECURITY: Normalizes URL-encoded characters, path separators, and case before comparison.
10
- * SECURITY: Re-reads blocked_paths from policy.yaml when baseDir is provided (hot-reload).
11
- */
12
3
  export declare function createBlockedPathsMiddleware(initialPolicy: Policy, baseDir?: string): Middleware;