@sunaiva/gate 1.0.0 → 1.1.2

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 (83) hide show
  1. package/BUSINESS_LICENSE.md +70 -0
  2. package/CHANGELOG.md +254 -0
  3. package/LICENSE +0 -0
  4. package/README.md +451 -67
  5. package/README.md.bak-v1.0.0-stale-MIT +59 -0
  6. package/SUPPORT.md +75 -0
  7. package/TIER_DEFINITIONS.md +161 -0
  8. package/dist/config/defaults.d.ts +22 -1
  9. package/dist/config/defaults.d.ts.map +1 -1
  10. package/dist/config/defaults.js +56 -8
  11. package/dist/config/defaults.js.map +1 -1
  12. package/dist/config/loader.d.ts +0 -0
  13. package/dist/config/loader.d.ts.map +1 -1
  14. package/dist/config/loader.js +23 -5
  15. package/dist/config/loader.js.map +1 -1
  16. package/dist/engine/backend-client.d.ts +58 -0
  17. package/dist/engine/backend-client.d.ts.map +1 -0
  18. package/dist/engine/backend-client.js +287 -0
  19. package/dist/engine/backend-client.js.map +1 -0
  20. package/dist/engine/hmac-verifier.d.ts +52 -0
  21. package/dist/engine/hmac-verifier.d.ts.map +1 -0
  22. package/dist/engine/hmac-verifier.js +159 -0
  23. package/dist/engine/hmac-verifier.js.map +1 -0
  24. package/dist/engine/immutability.d.ts +59 -0
  25. package/dist/engine/immutability.d.ts.map +1 -0
  26. package/dist/engine/immutability.js +129 -0
  27. package/dist/engine/immutability.js.map +1 -0
  28. package/dist/engine/pattern-matcher.d.ts +13 -0
  29. package/dist/engine/pattern-matcher.d.ts.map +1 -1
  30. package/dist/engine/pattern-matcher.js +85 -17
  31. package/dist/engine/pattern-matcher.js.map +1 -1
  32. package/dist/engine/rule-engine.d.ts +62 -1
  33. package/dist/engine/rule-engine.d.ts.map +1 -1
  34. package/dist/engine/rule-engine.js +224 -12
  35. package/dist/engine/rule-engine.js.map +1 -1
  36. package/dist/engine/session-state.d.ts +0 -0
  37. package/dist/engine/session-state.d.ts.map +1 -1
  38. package/dist/engine/session-state.js +8 -2
  39. package/dist/engine/session-state.js.map +1 -1
  40. package/dist/engine/ship-confidence-gate.d.ts +232 -0
  41. package/dist/engine/ship-confidence-gate.d.ts.map +1 -0
  42. package/dist/engine/ship-confidence-gate.js +768 -0
  43. package/dist/engine/ship-confidence-gate.js.map +1 -0
  44. package/dist/index.d.ts +0 -0
  45. package/dist/index.js +293 -2
  46. package/dist/rules/categories.json +0 -0
  47. package/dist/rules/presets.json +0 -0
  48. package/dist/rules/rules.json +132 -64
  49. package/dist/tools/audit.d.ts +6 -0
  50. package/dist/tools/audit.d.ts.map +1 -1
  51. package/dist/tools/audit.js +43 -6
  52. package/dist/tools/audit.js.map +1 -1
  53. package/dist/tools/bypass.d.ts +0 -0
  54. package/dist/tools/bypass.d.ts.map +1 -1
  55. package/dist/tools/bypass.js +50 -6
  56. package/dist/tools/bypass.js.map +1 -1
  57. package/dist/tools/export-attestation.d.ts +45 -0
  58. package/dist/tools/export-attestation.d.ts.map +1 -0
  59. package/dist/tools/export-attestation.js +152 -0
  60. package/dist/tools/export-attestation.js.map +1 -0
  61. package/dist/tools/rules.d.ts +0 -0
  62. package/dist/tools/rules.d.ts.map +0 -0
  63. package/dist/tools/rules.js +0 -0
  64. package/dist/tools/rules.js.map +0 -0
  65. package/dist/tools/ship-confidence.d.ts +17 -0
  66. package/dist/tools/ship-confidence.d.ts.map +1 -0
  67. package/dist/tools/ship-confidence.js +42 -0
  68. package/dist/tools/ship-confidence.js.map +1 -0
  69. package/dist/tools/update.d.ts +0 -0
  70. package/dist/tools/update.d.ts.map +1 -1
  71. package/dist/tools/update.js +45 -9
  72. package/dist/tools/update.js.map +1 -1
  73. package/dist/tools/validate.d.ts +0 -0
  74. package/dist/tools/validate.d.ts.map +1 -1
  75. package/dist/tools/validate.js +56 -4
  76. package/dist/tools/validate.js.map +1 -1
  77. package/dist/types/backend.d.ts +69 -0
  78. package/dist/types/backend.d.ts.map +1 -0
  79. package/dist/types/backend.js +18 -0
  80. package/dist/types/backend.js.map +1 -0
  81. package/package.json +83 -65
  82. package/dist/index.d.ts.map +0 -1
  83. package/dist/index.js.map +0 -1
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Backend client for the Sunaiva Gate premium tier.
3
+ *
4
+ * Responsibilities:
5
+ * - POST to `<backend_url>` for each premium (`[server-side]`) rule.
6
+ * - Fail-OPEN for the *customer*: any backend failure (network, timeout,
7
+ * 5xx, 401, missing token) results in the rule being SKIPPED, never
8
+ * a customer-side block. A Sunaiva outage MUST NOT take down a
9
+ * customer's agent pipeline.
10
+ * - Emit a once-per-process stderr notice when premium rules are skipped
11
+ * because no API token is configured. Subsequent skips in the same
12
+ * process are silent.
13
+ * - One retry with 500ms backoff on 5xx / network error (per spec).
14
+ * - Request timeout default 3000ms, env-overridable.
15
+ * - Track audit counters: every skipped rule appears in
16
+ * `BackendEvalResult.skipped_rule_ids` with a status code in
17
+ * `BackendRuleResult.status` (e.g. `skipped_premium`, `skipped_auth`)
18
+ * so the audit ledger can record exactly why a rule was deferred.
19
+ *
20
+ * What this module does NOT do:
21
+ * - It does NOT decide whether to call the backend at all. That is the
22
+ * caller's job (rule-engine.ts checks rule.backend_required + URL).
23
+ * - It does NOT block. The returned result is informational; the caller
24
+ * turns matched=true with severity=block into a customer-side block.
25
+ * - It does NOT mutate global state apart from the once-per-process
26
+ * stderr flag.
27
+ */
28
+ import { DEFAULT_BACKEND_URL, DEFAULT_TIMEOUT_MS, MAX_RETRIES, RETRY_BACKOFF_MS, } from "../types/backend.js";
29
+ // Once-per-process flag for the "no API token" stderr notice. Module-level
30
+ // so that multiple instances of BackendClient in the same process share it.
31
+ let _premiumSkippedNoticeShown = false;
32
+ /**
33
+ * Reset the once-per-process notice flag. Test-only — wired so that test
34
+ * fixtures can assert that the notice fires on the first call but not on
35
+ * subsequent calls within the same process.
36
+ */
37
+ export function resetPremiumSkippedNotice() {
38
+ _premiumSkippedNoticeShown = false;
39
+ }
40
+ /**
41
+ * Returns true iff the once-per-process notice was emitted. Test-only.
42
+ */
43
+ export function wasPremiumSkippedNoticeShown() {
44
+ return _premiumSkippedNoticeShown;
45
+ }
46
+ /** Stderr notice format — exact string per the brief. */
47
+ function emitOnceSessionNoticeNoToken() {
48
+ if (_premiumSkippedNoticeShown)
49
+ return;
50
+ _premiumSkippedNoticeShown = true;
51
+ // Brief specifies the exact substring:
52
+ // "[sunaiva-gate] premium rules skipped — set SUNAIVA_GATE_API_TOKEN to enable.
53
+ // See https://sunaivacore.io/pricing"
54
+ console.error("[sunaiva-gate] premium rules skipped — set SUNAIVA_GATE_API_TOKEN to enable. " +
55
+ "See https://sunaivacore.io/pricing");
56
+ }
57
+ /**
58
+ * Sleep helper for retry backoff. Pulled out so tests can mock it via the
59
+ * fetchImpl path (the test fixture controls the call count, not the clock).
60
+ */
61
+ function sleep(ms) {
62
+ return new Promise((resolve) => setTimeout(resolve, ms));
63
+ }
64
+ export class BackendClient {
65
+ url;
66
+ apiToken;
67
+ timeoutMs;
68
+ fetchImpl;
69
+ constructor(options = {}) {
70
+ this.url =
71
+ options.url ??
72
+ process.env.SUNAIVA_GATE_BACKEND_URL ??
73
+ DEFAULT_BACKEND_URL;
74
+ this.apiToken =
75
+ options.apiToken ?? process.env.SUNAIVA_GATE_API_TOKEN ?? null;
76
+ const envTimeout = process.env.SUNAIVA_GATE_BACKEND_TIMEOUT_MS;
77
+ const envTimeoutParsed = envTimeout ? Number.parseInt(envTimeout, 10) : NaN;
78
+ this.timeoutMs =
79
+ options.timeoutMs ??
80
+ (Number.isFinite(envTimeoutParsed) && envTimeoutParsed > 0
81
+ ? envTimeoutParsed
82
+ : DEFAULT_TIMEOUT_MS);
83
+ // Default to the platform fetch. Node 18+ ships native fetch.
84
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
85
+ }
86
+ /** True iff the client has an API token configured. */
87
+ isConfigured() {
88
+ return !!this.apiToken;
89
+ }
90
+ /**
91
+ * Evaluate a list of premium rules against `inputText`. Returns one
92
+ * BackendRuleResult per requested rule. Never throws; backend errors
93
+ * become `skipped_*` statuses so the caller can fail-OPEN gracefully.
94
+ */
95
+ async evaluate(ruleIds, inputText, context) {
96
+ const t0 = Date.now();
97
+ // Fast-path: no token configured → all rules skipped, once-per-process notice.
98
+ if (!this.apiToken) {
99
+ emitOnceSessionNoticeNoToken();
100
+ const results = ruleIds.map((id) => ({
101
+ rule_id: id,
102
+ status: "skipped_no_token",
103
+ matched: false,
104
+ latency_ms: 0,
105
+ error: "no API token (set SUNAIVA_GATE_API_TOKEN)",
106
+ }));
107
+ return {
108
+ evaluated_rules: ruleIds,
109
+ results,
110
+ matched_rule_ids: [],
111
+ skipped_rule_ids: [...ruleIds],
112
+ latency_ms: Date.now() - t0,
113
+ premium_skipped_due_to_no_token: true,
114
+ };
115
+ }
116
+ // Sequential calls — the backend is per-rule. Could be parallelised in
117
+ // a future version; sequential is fine for v1.1.0 because premium rule
118
+ // counts per validate() call are bounded by the active_rules set.
119
+ const results = [];
120
+ for (const ruleId of ruleIds) {
121
+ results.push(await this.evaluateOne(ruleId, inputText, context));
122
+ }
123
+ const matched_rule_ids = results.filter((r) => r.matched).map((r) => r.rule_id);
124
+ const skipped_rule_ids = results
125
+ .filter((r) => r.status.startsWith("skipped_"))
126
+ .map((r) => r.rule_id);
127
+ return {
128
+ evaluated_rules: ruleIds,
129
+ results,
130
+ matched_rule_ids,
131
+ skipped_rule_ids,
132
+ latency_ms: Date.now() - t0,
133
+ premium_skipped_due_to_no_token: false,
134
+ };
135
+ }
136
+ /** Single-rule call with retry + timeout. */
137
+ async evaluateOne(ruleId, inputText, context) {
138
+ const t0 = Date.now();
139
+ let lastError;
140
+ let lastStatus;
141
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
142
+ try {
143
+ const outcome = await this.callOnce(ruleId, inputText, context);
144
+ // If callOnce returned a non-retriable result (2xx or 4xx-except-5xx),
145
+ // return immediately. Retry only on 5xx / network failure.
146
+ if (outcome.kind === "ok") {
147
+ return {
148
+ rule_id: ruleId,
149
+ status: outcome.body.matched ? "matched" : "no_match",
150
+ matched: outcome.body.matched,
151
+ severity: outcome.body.severity,
152
+ decision: outcome.body.decision,
153
+ reason: outcome.body.reason,
154
+ http_status: outcome.status,
155
+ latency_ms: Date.now() - t0,
156
+ };
157
+ }
158
+ if (outcome.kind === "client_error") {
159
+ // 4xx (other than 5xx) — do NOT retry. Map to a skipped status.
160
+ return {
161
+ rule_id: ruleId,
162
+ status: clientErrorToStatus(outcome.status),
163
+ matched: false,
164
+ http_status: outcome.status,
165
+ error: outcome.errorBody?.error ?? `HTTP ${outcome.status}`,
166
+ latency_ms: Date.now() - t0,
167
+ };
168
+ }
169
+ // 5xx or network: capture and fall through to retry / final return.
170
+ lastError = outcome.errorMessage;
171
+ lastStatus = outcome.status;
172
+ }
173
+ catch (e) {
174
+ // Unexpected exception in callOnce itself. Treat as retriable network failure.
175
+ lastError = e instanceof Error ? e.message : String(e);
176
+ }
177
+ if (attempt < MAX_RETRIES) {
178
+ await sleep(RETRY_BACKOFF_MS);
179
+ }
180
+ }
181
+ // Exhausted retries — fail-OPEN (skipped).
182
+ return {
183
+ rule_id: ruleId,
184
+ status: "skipped_error",
185
+ matched: false,
186
+ http_status: lastStatus,
187
+ error: lastError ?? "backend unreachable after retries",
188
+ latency_ms: Date.now() - t0,
189
+ };
190
+ }
191
+ /** Single HTTP call. Returns a tagged outcome — never throws on HTTP. */
192
+ async callOnce(ruleId, inputText, context) {
193
+ const controller = new AbortController();
194
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
195
+ try {
196
+ const resp = await this.fetchImpl(this.url, {
197
+ method: "POST",
198
+ headers: {
199
+ "Content-Type": "application/json",
200
+ Authorization: `Bearer ${this.apiToken}`,
201
+ "User-Agent": "sunaiva-gate-client/1.1.0",
202
+ },
203
+ body: JSON.stringify({
204
+ rule_id: ruleId,
205
+ input_text: inputText,
206
+ context: context ?? {},
207
+ }),
208
+ signal: controller.signal,
209
+ });
210
+ // 2xx → parse body, even if response is something we don't expect.
211
+ // Backend contract: { matched, severity, decision, reason? }
212
+ if (resp.status >= 200 && resp.status < 300) {
213
+ let body;
214
+ try {
215
+ body = (await resp.json());
216
+ }
217
+ catch (e) {
218
+ // Malformed 2xx — treat as a server fault, retry.
219
+ return {
220
+ kind: "server_error",
221
+ status: resp.status,
222
+ errorMessage: `malformed JSON body: ${e instanceof Error ? e.message : String(e)}`,
223
+ };
224
+ }
225
+ if (typeof body?.matched !== "boolean") {
226
+ return {
227
+ kind: "server_error",
228
+ status: resp.status,
229
+ errorMessage: "missing 'matched' field in backend response",
230
+ };
231
+ }
232
+ return { kind: "ok", status: resp.status, body };
233
+ }
234
+ if (resp.status >= 500 && resp.status < 600) {
235
+ let errorBody;
236
+ try {
237
+ errorBody = (await resp.json());
238
+ }
239
+ catch {
240
+ // Ignore — body may be empty/text.
241
+ }
242
+ return {
243
+ kind: "server_error",
244
+ status: resp.status,
245
+ errorMessage: errorBody?.error ?? `HTTP ${resp.status}`,
246
+ };
247
+ }
248
+ // 4xx — client error, not retriable.
249
+ let errorBody;
250
+ try {
251
+ errorBody = (await resp.json());
252
+ }
253
+ catch {
254
+ // Ignore.
255
+ }
256
+ return { kind: "client_error", status: resp.status, errorBody };
257
+ }
258
+ catch (e) {
259
+ // AbortError (timeout) or network failure. Both retriable.
260
+ const msg = e instanceof Error ? e.message : String(e);
261
+ return {
262
+ kind: "server_error",
263
+ status: undefined,
264
+ errorMessage: msg.includes("aborted") ? "request timeout" : msg,
265
+ };
266
+ }
267
+ finally {
268
+ clearTimeout(timer);
269
+ }
270
+ }
271
+ }
272
+ /** Map a 4xx status to a BackendEvalStatus. */
273
+ function clientErrorToStatus(status) {
274
+ switch (status) {
275
+ case 401:
276
+ return "skipped_auth";
277
+ case 402:
278
+ return "skipped_tier";
279
+ case 404:
280
+ return "skipped_unknown";
281
+ case 429:
282
+ return "skipped_quota";
283
+ default:
284
+ return "skipped_error";
285
+ }
286
+ }
287
+ //# sourceMappingURL=backend-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend-client.js","sourceRoot":"","sources":["../../src/engine/backend-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * HMAC-SHA256 verifier + canonical JSON helpers.
3
+ *
4
+ * BYTE-FOR-BYTE compatible with Python's:
5
+ * json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
6
+ * hmac.new(key, payload, hashlib.sha256).hexdigest()
7
+ * hmac.compare_digest(expected, given)
8
+ *
9
+ * Reference: sunaiva-ship-confidence/utils/signing.py
10
+ * .claude/hooks/ship_confidence_gate.py (_canonical_json, _verify_hmac)
11
+ *
12
+ * Why a custom canonicalizer (not JSON.stringify-with-replacer):
13
+ * - Python's json.dumps sorts keys at EVERY level (recursive).
14
+ * - Python uses NO spaces between separators with (",", ":").
15
+ * - JavaScript's default JSON.stringify preserves insertion order, not sorted.
16
+ * - Python escapes / by default; we don't need that for HMAC
17
+ * payloads in practice because the verdict objects are ASCII-only, but the
18
+ * canonicalizer matches Python's escape rules for the common case.
19
+ */
20
+ /**
21
+ * Canonical JSON encoding — recursive sorted keys, tight separators.
22
+ *
23
+ * Output bytes are IDENTICAL to Python's
24
+ * json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
25
+ *
26
+ * Restrictions / assumptions (matching Python json defaults):
27
+ * - Object keys are strings.
28
+ * - Numbers are serialized via JSON.stringify (matches Python for integers
29
+ * and finite floats with no scientific notation under 1e21).
30
+ * - Strings use Python's ensure_ascii=True semantics (default in json.dumps):
31
+ * non-ASCII chars are escaped as \uXXXX. UTF-8 byte equality is preserved
32
+ * because the escaped form is pure ASCII.
33
+ * - Functions, undefined, and Symbols are not allowed (would not round-trip
34
+ * to/from Python).
35
+ */
36
+ export declare function canonicalJson(value: unknown): Buffer;
37
+ /**
38
+ * HMAC-SHA256 verification with constant-time comparison.
39
+ *
40
+ * @param payload Canonical-JSON bytes of the verdict (minus signature field).
41
+ * @param signatureHex The signature provided in the verdict, hex-encoded.
42
+ * @param key The raw signing key bytes (UTF-8 encoded from the env var).
43
+ * @returns true iff the signature is valid; false otherwise (including any
44
+ * length-mismatch error from timingSafeEqual which would throw — caught and
45
+ * returned as false to maintain a uniform predicate contract).
46
+ */
47
+ export declare function verifyHmac(payload: Buffer, signatureHex: string, key: Buffer): boolean;
48
+ /**
49
+ * Convenience: sign a payload buffer with a key. Used in tests.
50
+ */
51
+ export declare function signPayload(payload: Buffer, key: Buffer): string;
52
+ //# sourceMappingURL=hmac-verifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hmac-verifier.d.ts","sourceRoot":"","sources":["../../src/engine/hmac-verifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEpD;AAkFD;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAUtF;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhE"}
@@ -0,0 +1,159 @@
1
+ /**
2
+ * HMAC-SHA256 verifier + canonical JSON helpers.
3
+ *
4
+ * BYTE-FOR-BYTE compatible with Python's:
5
+ * json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
6
+ * hmac.new(key, payload, hashlib.sha256).hexdigest()
7
+ * hmac.compare_digest(expected, given)
8
+ *
9
+ * Reference: sunaiva-ship-confidence/utils/signing.py
10
+ * .claude/hooks/ship_confidence_gate.py (_canonical_json, _verify_hmac)
11
+ *
12
+ * Why a custom canonicalizer (not JSON.stringify-with-replacer):
13
+ * - Python's json.dumps sorts keys at EVERY level (recursive).
14
+ * - Python uses NO spaces between separators with (",", ":").
15
+ * - JavaScript's default JSON.stringify preserves insertion order, not sorted.
16
+ * - Python escapes / by default; we don't need that for HMAC
17
+ * payloads in practice because the verdict objects are ASCII-only, but the
18
+ * canonicalizer matches Python's escape rules for the common case.
19
+ */
20
+ import { createHmac, timingSafeEqual } from "node:crypto";
21
+ /**
22
+ * Canonical JSON encoding — recursive sorted keys, tight separators.
23
+ *
24
+ * Output bytes are IDENTICAL to Python's
25
+ * json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
26
+ *
27
+ * Restrictions / assumptions (matching Python json defaults):
28
+ * - Object keys are strings.
29
+ * - Numbers are serialized via JSON.stringify (matches Python for integers
30
+ * and finite floats with no scientific notation under 1e21).
31
+ * - Strings use Python's ensure_ascii=True semantics (default in json.dumps):
32
+ * non-ASCII chars are escaped as \uXXXX. UTF-8 byte equality is preserved
33
+ * because the escaped form is pure ASCII.
34
+ * - Functions, undefined, and Symbols are not allowed (would not round-trip
35
+ * to/from Python).
36
+ */
37
+ export function canonicalJson(value) {
38
+ return Buffer.from(canonicalize(value), "utf-8");
39
+ }
40
+ function canonicalize(value) {
41
+ if (value === null)
42
+ return "null";
43
+ if (typeof value === "boolean")
44
+ return value ? "true" : "false";
45
+ if (typeof value === "number") {
46
+ if (!Number.isFinite(value)) {
47
+ // Python emits NaN/Infinity but it's not valid JSON; refuse.
48
+ throw new Error("Cannot canonicalize non-finite number");
49
+ }
50
+ // For integers, JSON.stringify matches Python json.dumps exactly.
51
+ // For floats Python may emit differently than JS in edge cases; verdict
52
+ // payloads do not use floats for signed fields, so this is acceptable.
53
+ return JSON.stringify(value);
54
+ }
55
+ if (typeof value === "string") {
56
+ return encodeString(value);
57
+ }
58
+ if (Array.isArray(value)) {
59
+ const parts = value.map((v) => canonicalize(v));
60
+ return "[" + parts.join(",") + "]";
61
+ }
62
+ if (typeof value === "object") {
63
+ const obj = value;
64
+ const keys = Object.keys(obj).sort();
65
+ const parts = [];
66
+ for (const k of keys) {
67
+ // Match Python: skip undefined values to align with the typical pattern
68
+ // of "exclude unset" in Pydantic model_dump. NB: Python json.dumps would
69
+ // RAISE on a None value missing in a dict — it never sees undefined.
70
+ // We treat undefined as "omit" to be ergonomic; null is emitted as "null".
71
+ if (obj[k] === undefined)
72
+ continue;
73
+ parts.push(encodeString(k) + ":" + canonicalize(obj[k]));
74
+ }
75
+ return "{" + parts.join(",") + "}";
76
+ }
77
+ throw new Error(`Cannot canonicalize value of type ${typeof value}`);
78
+ }
79
+ /**
80
+ * String encoding matching Python json.dumps default behaviour
81
+ * (ensure_ascii=True). Non-ASCII characters are escaped as \uXXXX so the
82
+ * output is pure ASCII — byte-equal under any UTF-8 round-trip.
83
+ *
84
+ * Standard ASCII escapes: \b \t \n \f \r \" \\ — same as JS JSON.stringify.
85
+ * Control chars < 0x20 → \u00XX.
86
+ * Non-ASCII (≥ 0x80) → \uXXXX (or surrogate pair for code points > 0xFFFF).
87
+ */
88
+ function encodeString(s) {
89
+ let out = '"';
90
+ for (let i = 0; i < s.length; i++) {
91
+ const c = s.charCodeAt(i);
92
+ if (c === 0x22) {
93
+ out += '\\"';
94
+ }
95
+ else if (c === 0x5c) {
96
+ out += "\\\\";
97
+ }
98
+ else if (c === 0x08) {
99
+ out += "\\b";
100
+ }
101
+ else if (c === 0x0c) {
102
+ out += "\\f";
103
+ }
104
+ else if (c === 0x0a) {
105
+ out += "\\n";
106
+ }
107
+ else if (c === 0x0d) {
108
+ out += "\\r";
109
+ }
110
+ else if (c === 0x09) {
111
+ out += "\\t";
112
+ }
113
+ else if (c < 0x20) {
114
+ out += "\\u" + c.toString(16).padStart(4, "0");
115
+ }
116
+ else if (c < 0x7f) {
117
+ // ASCII printable — emit as-is.
118
+ out += s[i];
119
+ }
120
+ else {
121
+ // Non-ASCII — emit as \uXXXX. For chars in the BMP this is the code unit
122
+ // directly; surrogate pairs are emitted as two \uXXXX sequences, which
123
+ // matches Python json.dumps with ensure_ascii=True.
124
+ out += "\\u" + c.toString(16).padStart(4, "0");
125
+ }
126
+ }
127
+ out += '"';
128
+ return out;
129
+ }
130
+ /**
131
+ * HMAC-SHA256 verification with constant-time comparison.
132
+ *
133
+ * @param payload Canonical-JSON bytes of the verdict (minus signature field).
134
+ * @param signatureHex The signature provided in the verdict, hex-encoded.
135
+ * @param key The raw signing key bytes (UTF-8 encoded from the env var).
136
+ * @returns true iff the signature is valid; false otherwise (including any
137
+ * length-mismatch error from timingSafeEqual which would throw — caught and
138
+ * returned as false to maintain a uniform predicate contract).
139
+ */
140
+ export function verifyHmac(payload, signatureHex, key) {
141
+ try {
142
+ const expectedHex = createHmac("sha256", key).update(payload).digest("hex");
143
+ if (expectedHex.length !== signatureHex.length)
144
+ return false;
145
+ const a = Buffer.from(expectedHex, "utf-8");
146
+ const b = Buffer.from(signatureHex, "utf-8");
147
+ return timingSafeEqual(a, b);
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * Convenience: sign a payload buffer with a key. Used in tests.
155
+ */
156
+ export function signPayload(payload, key) {
157
+ return createHmac("sha256", key).update(payload).digest("hex");
158
+ }
159
+ //# sourceMappingURL=hmac-verifier.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hmac-verifier.js","sourceRoot":"","sources":["../../src/engine/hmac-verifier.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Immutability guard — constitutional rules cannot be disabled or bypassed.
3
+ *
4
+ * Builder B4. Resolves CRITICAL findings:
5
+ * - C2: `update_rules({disable: ['fin-001']})` must reject (cannot disable constitutional).
6
+ * - C3: `log_bypass({rule_id: 'fin-001'})` must reject (cannot bypass constitutional).
7
+ *
8
+ * Design:
9
+ * - Single canonical source of truth for "what is constitutional":
10
+ * 1. CONSTITUTIONAL_RULE_IDS (frozen array in src/config/defaults.ts) — primary.
11
+ * 2. Cross-check against loadConstitutionalRulesOnly() from rule-engine — defense in depth.
12
+ * Union of both => guard set. This means a future rule with `enforcement === "constitutional"`
13
+ * in rules.json is automatically protected even if CONSTITUTIONAL_RULE_IDS isn't updated.
14
+ * - assertCanDisable() and assertCanBypass() throw ConstitutionalImmutableError.
15
+ * - enforceConstitutionalActive() returns a config with every constitutional ID guaranteed
16
+ * in active_rules — called on every config load so user tampering of ~/.sunaiva/gate-config.json
17
+ * cannot disable a constitutional rule.
18
+ *
19
+ * Why both sources?
20
+ * - CONSTITUTIONAL_RULE_IDS in defaults.ts is the frozen, code-level source — even if
21
+ * the bundled rules.json is missing or corrupt, we still know which IDs are protected.
22
+ * - loadConstitutionalRulesOnly() from rule-engine reads from the package-bundled rules,
23
+ * never user config — adds new rules without code changes if rules.json is updated.
24
+ * - Union ensures additive protection: a rule listed EITHER place is constitutional.
25
+ */
26
+ import { type GateConfig } from "../config/defaults.js";
27
+ export declare class ConstitutionalImmutableError extends Error {
28
+ readonly rule_id: string;
29
+ readonly op: "disable" | "bypass";
30
+ constructor(ruleId: string, op: "disable" | "bypass");
31
+ }
32
+ /**
33
+ * Returns the canonical set of constitutional rule IDs. Union of:
34
+ * 1. CONSTITUTIONAL_RULE_IDS (frozen array — code-level guarantee)
35
+ * 2. loadConstitutionalRulesOnly() (rules.json — runtime additive)
36
+ *
37
+ * Cached after first call. Tests can force a refresh with refreshConstitutionalCache().
38
+ */
39
+ export declare function getConstitutionalRuleIds(): ReadonlySet<string>;
40
+ /**
41
+ * Test-only cache reset. Used by tests that mutate environment / config files
42
+ * and need a fresh read.
43
+ */
44
+ export declare function refreshConstitutionalCache(): void;
45
+ export declare function isConstitutional(ruleId: string): boolean;
46
+ export declare function assertCanDisable(ruleId: string): void;
47
+ export declare function assertCanBypass(ruleId: string): void;
48
+ /**
49
+ * Returns a new GateConfig with every constitutional rule ID guaranteed
50
+ * in active_rules. The on-disk config file is NOT mutated; this is an
51
+ * in-memory enforcement layer so a user can hand-edit `~/.sunaiva/gate-config.json`
52
+ * to remove constitutional rules and the next runtime load will simply
53
+ * re-add them — silently and unbypassably.
54
+ *
55
+ * Idempotent: calling on a config that already has all constitutional IDs
56
+ * is a no-op (returns a structurally equal config).
57
+ */
58
+ export declare function enforceConstitutionalActive(config: GateConfig): GateConfig;
59
+ //# sourceMappingURL=immutability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"immutability.d.ts","sourceRoot":"","sources":["../../src/engine/immutability.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAA2B,KAAK,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAOjF,qBAAa,4BAA6B,SAAQ,KAAK;IACrD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,EAAE,SAAS,GAAG,QAAQ,CAAC;gBAEtB,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,SAAS,GAAG,QAAQ;CASrD;AAQD;;;;;;GAMG;AACH,wBAAgB,wBAAwB,IAAI,WAAW,CAAC,MAAM,CAAC,CAiB9D;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,IAAI,IAAI,CAEjD;AAMD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAExD;AAMD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAIrD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAIpD;AAMD;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,UAAU,GAAG,UAAU,CAkB1E"}