@sunaiva/gate 1.0.0 → 1.1.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/BUSINESS_LICENSE.md +70 -0
- package/CHANGELOG.md +148 -0
- package/LICENSE +0 -0
- package/README.md +411 -27
- package/dist/config/defaults.d.ts +22 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +56 -8
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/loader.d.ts +0 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +24 -6
- package/dist/config/loader.js.map +1 -1
- package/dist/engine/backend-client.d.ts +58 -0
- package/dist/engine/backend-client.d.ts.map +1 -0
- package/dist/engine/backend-client.js +287 -0
- package/dist/engine/backend-client.js.map +1 -0
- package/dist/engine/hmac-verifier.d.ts +33 -0
- package/dist/engine/hmac-verifier.d.ts.map +1 -0
- package/dist/engine/hmac-verifier.js +161 -0
- package/dist/engine/hmac-verifier.js.map +1 -0
- package/dist/engine/immutability.d.ts +59 -0
- package/dist/engine/immutability.d.ts.map +1 -0
- package/dist/engine/immutability.js +129 -0
- package/dist/engine/immutability.js.map +1 -0
- package/dist/engine/pattern-matcher.d.ts +13 -0
- package/dist/engine/pattern-matcher.d.ts.map +1 -1
- package/dist/engine/pattern-matcher.js +85 -17
- package/dist/engine/pattern-matcher.js.map +1 -1
- package/dist/engine/rule-engine.d.ts +62 -1
- package/dist/engine/rule-engine.d.ts.map +1 -1
- package/dist/engine/rule-engine.js +222 -12
- package/dist/engine/rule-engine.js.map +1 -1
- package/dist/engine/session-state.d.ts +0 -0
- package/dist/engine/session-state.d.ts.map +1 -1
- package/dist/engine/session-state.js +8 -2
- package/dist/engine/session-state.js.map +1 -1
- package/dist/engine/ship-confidence-gate.d.ts +184 -0
- package/dist/engine/ship-confidence-gate.d.ts.map +1 -0
- package/dist/engine/ship-confidence-gate.js +768 -0
- package/dist/engine/ship-confidence-gate.js.map +1 -0
- package/dist/index.d.ts +0 -0
- package/dist/index.d.ts.map +0 -0
- package/dist/index.js +289 -2
- package/dist/index.js.map +1 -1
- package/dist/rules/categories.json +0 -0
- package/dist/rules/presets.json +0 -0
- package/dist/rules/rules.json +200 -100
- package/dist/tools/audit.d.ts +6 -0
- package/dist/tools/audit.d.ts.map +1 -1
- package/dist/tools/audit.js +43 -6
- package/dist/tools/audit.js.map +1 -1
- package/dist/tools/bypass.d.ts +0 -0
- package/dist/tools/bypass.d.ts.map +1 -1
- package/dist/tools/bypass.js +50 -6
- package/dist/tools/bypass.js.map +1 -1
- package/dist/tools/rules.d.ts +0 -0
- package/dist/tools/rules.d.ts.map +0 -0
- package/dist/tools/rules.js +0 -0
- package/dist/tools/rules.js.map +0 -0
- package/dist/tools/ship-confidence.d.ts +11 -0
- package/dist/tools/ship-confidence.d.ts.map +1 -0
- package/dist/tools/ship-confidence.js +42 -0
- package/dist/tools/ship-confidence.js.map +1 -0
- package/dist/tools/update.d.ts +0 -0
- package/dist/tools/update.d.ts.map +1 -1
- package/dist/tools/update.js +45 -9
- package/dist/tools/update.js.map +1 -1
- package/dist/tools/validate.d.ts +0 -0
- package/dist/tools/validate.d.ts.map +1 -1
- package/dist/tools/validate.js +56 -4
- package/dist/tools/validate.js.map +1 -1
- package/dist/types/backend.d.ts +69 -0
- package/dist/types/backend.d.ts.map +1 -0
- package/dist/types/backend.js +18 -0
- package/dist/types/backend.js.map +1 -0
- package/package.json +11 -3
|
@@ -0,0 +1,58 @@
|
|
|
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 { BackendClientOptions, BackendEvalResult } from "../types/backend.js";
|
|
29
|
+
/**
|
|
30
|
+
* Reset the once-per-process notice flag. Test-only — wired so that test
|
|
31
|
+
* fixtures can assert that the notice fires on the first call but not on
|
|
32
|
+
* subsequent calls within the same process.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resetPremiumSkippedNotice(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Returns true iff the once-per-process notice was emitted. Test-only.
|
|
37
|
+
*/
|
|
38
|
+
export declare function wasPremiumSkippedNoticeShown(): boolean;
|
|
39
|
+
export declare class BackendClient {
|
|
40
|
+
private readonly url;
|
|
41
|
+
private readonly apiToken;
|
|
42
|
+
private readonly timeoutMs;
|
|
43
|
+
private readonly fetchImpl;
|
|
44
|
+
constructor(options?: BackendClientOptions);
|
|
45
|
+
/** True iff the client has an API token configured. */
|
|
46
|
+
isConfigured(): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Evaluate a list of premium rules against `inputText`. Returns one
|
|
49
|
+
* BackendRuleResult per requested rule. Never throws; backend errors
|
|
50
|
+
* become `skipped_*` statuses so the caller can fail-OPEN gracefully.
|
|
51
|
+
*/
|
|
52
|
+
evaluate(ruleIds: string[], inputText: string, context?: Record<string, unknown>): Promise<BackendEvalResult>;
|
|
53
|
+
/** Single-rule call with retry + timeout. */
|
|
54
|
+
private evaluateOne;
|
|
55
|
+
/** Single HTTP call. Returns a tagged outcome — never throws on HTTP. */
|
|
56
|
+
private callOnce;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=backend-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backend-client.d.ts","sourceRoot":"","sources":["../../src/engine/backend-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EAMlB,MAAM,qBAAqB,CAAC;AAM7B;;;;GAIG;AACH,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD;AAED;;GAEG;AACH,wBAAgB,4BAA4B,IAAI,OAAO,CAEtD;AAuBD,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAE7B,OAAO,GAAE,oBAAyB;IAqB9C,uDAAuD;IACvD,YAAY,IAAI,OAAO;IAIvB;;;;OAIG;IACG,QAAQ,CACZ,OAAO,EAAE,MAAM,EAAE,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,iBAAiB,CAAC;IA8C7B,6CAA6C;YAC/B,WAAW;IA4DzB,yEAAyE;YAC3D,QAAQ;CAmFvB"}
|
|
@@ -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://sunaivadigital.com/pricing"
|
|
54
|
+
console.error("[sunaiva-gate] premium rules skipped — set SUNAIVA_GATE_API_TOKEN to enable. " +
|
|
55
|
+
"See https://sunaivadigital.com/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,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON encoding — recursive sorted keys, tight separators.
|
|
3
|
+
*
|
|
4
|
+
* Output bytes are IDENTICAL to Python's
|
|
5
|
+
* json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
|
|
6
|
+
*
|
|
7
|
+
* Restrictions / assumptions (matching Python json defaults):
|
|
8
|
+
* - Object keys are strings.
|
|
9
|
+
* - Numbers are serialized via JSON.stringify (matches Python for integers
|
|
10
|
+
* and finite floats with no scientific notation under 1e21).
|
|
11
|
+
* - Strings use Python's ensure_ascii=True semantics (default in json.dumps):
|
|
12
|
+
* non-ASCII chars are escaped as \uXXXX. UTF-8 byte equality is preserved
|
|
13
|
+
* because the escaped form is pure ASCII.
|
|
14
|
+
* - Functions, undefined, and Symbols are not allowed (would not round-trip
|
|
15
|
+
* to/from Python).
|
|
16
|
+
*/
|
|
17
|
+
export declare function canonicalJson(value: unknown): Buffer;
|
|
18
|
+
/**
|
|
19
|
+
* HMAC-SHA256 verification with constant-time comparison.
|
|
20
|
+
*
|
|
21
|
+
* @param payload Canonical-JSON bytes of the verdict (minus signature field).
|
|
22
|
+
* @param signatureHex The signature provided in the verdict, hex-encoded.
|
|
23
|
+
* @param key The raw signing key bytes (UTF-8 encoded from the env var).
|
|
24
|
+
* @returns true iff the signature is valid; false otherwise (including any
|
|
25
|
+
* length-mismatch error from timingSafeEqual which would throw — caught and
|
|
26
|
+
* returned as false to maintain a uniform predicate contract).
|
|
27
|
+
*/
|
|
28
|
+
export declare function verifyHmac(payload: Buffer, signatureHex: string, key: Buffer): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Convenience: sign a payload buffer with a key. Used in tests.
|
|
31
|
+
*/
|
|
32
|
+
export declare function signPayload(payload: Buffer, key: Buffer): string;
|
|
33
|
+
//# 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":"AAuBA;;;;;;;;;;;;;;;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,161 @@
|
|
|
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
|
|
17
|
+
/
|
|
18
|
+
by default; we don't need that for HMAC
|
|
19
|
+
* payloads in practice because the verdict objects are ASCII-only, but the
|
|
20
|
+
* canonicalizer matches Python's escape rules for the common case.
|
|
21
|
+
*/
|
|
22
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
23
|
+
/**
|
|
24
|
+
* Canonical JSON encoding — recursive sorted keys, tight separators.
|
|
25
|
+
*
|
|
26
|
+
* Output bytes are IDENTICAL to Python's
|
|
27
|
+
* json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
|
|
28
|
+
*
|
|
29
|
+
* Restrictions / assumptions (matching Python json defaults):
|
|
30
|
+
* - Object keys are strings.
|
|
31
|
+
* - Numbers are serialized via JSON.stringify (matches Python for integers
|
|
32
|
+
* and finite floats with no scientific notation under 1e21).
|
|
33
|
+
* - Strings use Python's ensure_ascii=True semantics (default in json.dumps):
|
|
34
|
+
* non-ASCII chars are escaped as \uXXXX. UTF-8 byte equality is preserved
|
|
35
|
+
* because the escaped form is pure ASCII.
|
|
36
|
+
* - Functions, undefined, and Symbols are not allowed (would not round-trip
|
|
37
|
+
* to/from Python).
|
|
38
|
+
*/
|
|
39
|
+
export function canonicalJson(value) {
|
|
40
|
+
return Buffer.from(canonicalize(value), "utf-8");
|
|
41
|
+
}
|
|
42
|
+
function canonicalize(value) {
|
|
43
|
+
if (value === null)
|
|
44
|
+
return "null";
|
|
45
|
+
if (typeof value === "boolean")
|
|
46
|
+
return value ? "true" : "false";
|
|
47
|
+
if (typeof value === "number") {
|
|
48
|
+
if (!Number.isFinite(value)) {
|
|
49
|
+
// Python emits NaN/Infinity but it's not valid JSON; refuse.
|
|
50
|
+
throw new Error("Cannot canonicalize non-finite number");
|
|
51
|
+
}
|
|
52
|
+
// For integers, JSON.stringify matches Python json.dumps exactly.
|
|
53
|
+
// For floats Python may emit differently than JS in edge cases; verdict
|
|
54
|
+
// payloads do not use floats for signed fields, so this is acceptable.
|
|
55
|
+
return JSON.stringify(value);
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === "string") {
|
|
58
|
+
return encodeString(value);
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
const parts = value.map((v) => canonicalize(v));
|
|
62
|
+
return "[" + parts.join(",") + "]";
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === "object") {
|
|
65
|
+
const obj = value;
|
|
66
|
+
const keys = Object.keys(obj).sort();
|
|
67
|
+
const parts = [];
|
|
68
|
+
for (const k of keys) {
|
|
69
|
+
// Match Python: skip undefined values to align with the typical pattern
|
|
70
|
+
// of "exclude unset" in Pydantic model_dump. NB: Python json.dumps would
|
|
71
|
+
// RAISE on a None value missing in a dict — it never sees undefined.
|
|
72
|
+
// We treat undefined as "omit" to be ergonomic; null is emitted as "null".
|
|
73
|
+
if (obj[k] === undefined)
|
|
74
|
+
continue;
|
|
75
|
+
parts.push(encodeString(k) + ":" + canonicalize(obj[k]));
|
|
76
|
+
}
|
|
77
|
+
return "{" + parts.join(",") + "}";
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Cannot canonicalize value of type ${typeof value}`);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* String encoding matching Python json.dumps default behaviour
|
|
83
|
+
* (ensure_ascii=True). Non-ASCII characters are escaped as \uXXXX so the
|
|
84
|
+
* output is pure ASCII — byte-equal under any UTF-8 round-trip.
|
|
85
|
+
*
|
|
86
|
+
* Standard ASCII escapes: \b \t \n \f \r \" \\ — same as JS JSON.stringify.
|
|
87
|
+
* Control chars < 0x20 → \u00XX.
|
|
88
|
+
* Non-ASCII (≥ 0x80) → \uXXXX (or surrogate pair for code points > 0xFFFF).
|
|
89
|
+
*/
|
|
90
|
+
function encodeString(s) {
|
|
91
|
+
let out = '"';
|
|
92
|
+
for (let i = 0; i < s.length; i++) {
|
|
93
|
+
const c = s.charCodeAt(i);
|
|
94
|
+
if (c === 0x22) {
|
|
95
|
+
out += '\\"';
|
|
96
|
+
}
|
|
97
|
+
else if (c === 0x5c) {
|
|
98
|
+
out += "\\\\";
|
|
99
|
+
}
|
|
100
|
+
else if (c === 0x08) {
|
|
101
|
+
out += "\\b";
|
|
102
|
+
}
|
|
103
|
+
else if (c === 0x0c) {
|
|
104
|
+
out += "\\f";
|
|
105
|
+
}
|
|
106
|
+
else if (c === 0x0a) {
|
|
107
|
+
out += "\\n";
|
|
108
|
+
}
|
|
109
|
+
else if (c === 0x0d) {
|
|
110
|
+
out += "\\r";
|
|
111
|
+
}
|
|
112
|
+
else if (c === 0x09) {
|
|
113
|
+
out += "\\t";
|
|
114
|
+
}
|
|
115
|
+
else if (c < 0x20) {
|
|
116
|
+
out += "\\u" + c.toString(16).padStart(4, "0");
|
|
117
|
+
}
|
|
118
|
+
else if (c < 0x7f) {
|
|
119
|
+
// ASCII printable — emit as-is.
|
|
120
|
+
out += s[i];
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Non-ASCII — emit as \uXXXX. For chars in the BMP this is the code unit
|
|
124
|
+
// directly; surrogate pairs are emitted as two \uXXXX sequences, which
|
|
125
|
+
// matches Python json.dumps with ensure_ascii=True.
|
|
126
|
+
out += "\\u" + c.toString(16).padStart(4, "0");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
out += '"';
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* HMAC-SHA256 verification with constant-time comparison.
|
|
134
|
+
*
|
|
135
|
+
* @param payload Canonical-JSON bytes of the verdict (minus signature field).
|
|
136
|
+
* @param signatureHex The signature provided in the verdict, hex-encoded.
|
|
137
|
+
* @param key The raw signing key bytes (UTF-8 encoded from the env var).
|
|
138
|
+
* @returns true iff the signature is valid; false otherwise (including any
|
|
139
|
+
* length-mismatch error from timingSafeEqual which would throw — caught and
|
|
140
|
+
* returned as false to maintain a uniform predicate contract).
|
|
141
|
+
*/
|
|
142
|
+
export function verifyHmac(payload, signatureHex, key) {
|
|
143
|
+
try {
|
|
144
|
+
const expectedHex = createHmac("sha256", key).update(payload).digest("hex");
|
|
145
|
+
if (expectedHex.length !== signatureHex.length)
|
|
146
|
+
return false;
|
|
147
|
+
const a = Buffer.from(expectedHex, "utf-8");
|
|
148
|
+
const b = Buffer.from(signatureHex, "utf-8");
|
|
149
|
+
return timingSafeEqual(a, b);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Convenience: sign a payload buffer with a key. Used in tests.
|
|
157
|
+
*/
|
|
158
|
+
export function signPayload(payload, key) {
|
|
159
|
+
return createHmac("sha256", key).update(payload).digest("hex");
|
|
160
|
+
}
|
|
161
|
+
//# 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,SAAgB,OAAO,EAAE,MAAM,CAAC;IAChC,SAAgB,EAAE,EAAE,SAAS,GAAG,QAAQ,CAAC;gBAE7B,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,SAAS,GAAG,QAAQ;CASrD;AAQD;;;;;;GAMG;AACH,wBAAgB,wBAAwB,IAAI,WAAW,CAAC,MAAM,CAAC,CAc9D;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,CAe1E"}
|