@sakk-dev/sakk 0.0.0-stage → 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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +1421 -0
- package/LICENSE +202 -0
- package/NOTICE +82 -0
- package/README.md +620 -2
- package/README.zh-CN.md +555 -0
- package/bin/sakk.mjs +1460 -0
- package/integrations/litellm/LICENSE +202 -0
- package/integrations/litellm/NOTICE +82 -0
- package/integrations/litellm/README.md +366 -0
- package/integrations/litellm/config.example.yaml +52 -0
- package/integrations/litellm/sakk_gateway.py +823 -0
- package/package.json +88 -4
- package/policies/guard-default.json +161 -0
- package/policies/permissive-dev.json +22 -0
- package/prices/snapshot.json +1404 -0
- package/src/append.mjs +81 -0
- package/src/archive.mjs +489 -0
- package/src/classify.mjs +914 -0
- package/src/console-login.mjs +374 -0
- package/src/console.mjs +1628 -0
- package/src/gateway-status.mjs +203 -0
- package/src/guard-install.mjs +387 -0
- package/src/hook.mjs +730 -0
- package/src/ledger.mjs +1229 -0
- package/src/match.mjs +52 -0
- package/src/mcp.mjs +299 -0
- package/src/policy.mjs +423 -0
- package/src/prices.mjs +152 -0
- package/src/secret-rules.generated.mjs +225 -0
- package/src/secret-scan.mjs +489 -0
- package/src/spend.mjs +443 -0
package/src/policy.mjs
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// Policy kernel (sakk-policy.v1) — the deterministic, auditable answer to
|
|
4
|
+
// "may this agent do this action at this cost?", decided BEFORE any
|
|
5
|
+
// reservation exists. Design signed 2026-10-15 (specs/policy-engine/):
|
|
6
|
+
// - decide() is PURE: same policy bytes + same context → same verdict.
|
|
7
|
+
// - This module never imports the ledger and never performs the action;
|
|
8
|
+
// enforcement belongs to the enforcer (MCP server / hook / CI gate),
|
|
9
|
+
// which calls reserve() only on an ALLOW.
|
|
10
|
+
// - Every refusal carries a stable machine code (public API).
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { appendNoFollow } from './append.mjs';
|
|
15
|
+
import { matchPath, matchesAny, prefixCovers, isCanonicalRelPath } from './match.mjs';
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line no-control-regex -- control characters are what it finds
|
|
18
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
19
|
+
|
|
20
|
+
export class PolicyError extends Error {
|
|
21
|
+
constructor(code, message, details = {}) {
|
|
22
|
+
super(`${code}: ${message}`);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.details = details;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const POLICY_SCHEMA = 'sakk-policy.v1';
|
|
29
|
+
export const MAX_POLICY_BYTES = 4 * 1024 * 1024; // above the work-budget attacks (25k rules ≈ 1.6 MB), which refuse by budget
|
|
30
|
+
const VERDICTS = ['allow', 'deny', 'require_approval'];
|
|
31
|
+
const RULE_KEYS = ['id', 'match', 'verdict', 'approvers_min'];
|
|
32
|
+
const MATCH_KEYS = ['agent', 'action', 'scope', 'max_estimated_usd'];
|
|
33
|
+
|
|
34
|
+
const isPlainObject = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
35
|
+
const isFiniteNonNeg = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0;
|
|
36
|
+
|
|
37
|
+
function bad(code, message, details) {
|
|
38
|
+
throw new PolicyError(code, message, details);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validatePolicyDoc(doc, policyPath) {
|
|
42
|
+
const inv = (why, details = {}) =>
|
|
43
|
+
bad('POLICY_SCHEMA_INVALID', `${why} (${policyPath})`, { policyPath, ...details });
|
|
44
|
+
if (!isPlainObject(doc)) inv('policy root must be an object');
|
|
45
|
+
for (const k of Object.keys(doc))
|
|
46
|
+
if (!['schema', 'default_verdict', 'rules'].includes(k)) inv(`unknown top-level key "${k}"`);
|
|
47
|
+
if (doc.schema !== POLICY_SCHEMA) inv(`schema must be "${POLICY_SCHEMA}"`, { got: doc.schema });
|
|
48
|
+
if (!VERDICTS.includes(doc.default_verdict))
|
|
49
|
+
inv(
|
|
50
|
+
'default_verdict is mandatory: allow | deny | require_approval — there is no implicit default',
|
|
51
|
+
);
|
|
52
|
+
if (!Array.isArray(doc.rules)) inv('rules must be an array');
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
doc.rules.forEach((r, i) => {
|
|
55
|
+
if (!isPlainObject(r)) inv(`rules[${i}] must be an object`);
|
|
56
|
+
for (const k of Object.keys(r))
|
|
57
|
+
if (!RULE_KEYS.includes(k)) inv(`rules[${i}]: unknown key "${k}"`);
|
|
58
|
+
if (typeof r.id !== 'string' || r.id === '') inv(`rules[${i}]: id must be a non-empty string`);
|
|
59
|
+
// ids and names reach refusal messages and terminals (review N4)
|
|
60
|
+
if (CONTROL.test(r.id)) inv(`rules[${i}]: id contains control characters`);
|
|
61
|
+
if (seen.has(r.id)) inv(`duplicate rule id "${r.id}"`);
|
|
62
|
+
seen.add(r.id);
|
|
63
|
+
if (!VERDICTS.includes(r.verdict)) inv(`rule "${r.id}": unknown verdict`, { got: r.verdict });
|
|
64
|
+
if (!isPlainObject(r.match)) inv(`rule "${r.id}": match must be an object`);
|
|
65
|
+
for (const k of Object.keys(r.match))
|
|
66
|
+
if (!MATCH_KEYS.includes(k)) inv(`rule "${r.id}": unknown match key "${k}"`);
|
|
67
|
+
for (const f of ['agent', 'action']) {
|
|
68
|
+
if (f in r.match && (typeof r.match[f] !== 'string' || r.match[f] === ''))
|
|
69
|
+
inv(`rule "${r.id}": match.${f} must be a non-empty string`);
|
|
70
|
+
// review S3: F10's no-globs rule held for scope only — a 'ci-*' deny
|
|
71
|
+
// was a literal that never fired. Only the exact '*' is a wildcard.
|
|
72
|
+
if (f in r.match && r.match[f] !== '*' && /[*?[\]]/.test(r.match[f]))
|
|
73
|
+
inv(
|
|
74
|
+
`rule "${r.id}": match.${f} "${r.match[f]}" is not a glob — only the exact "*" is a wildcard, so this rule would never match`,
|
|
75
|
+
{ got: r.match[f] },
|
|
76
|
+
);
|
|
77
|
+
if (f in r.match && CONTROL.test(r.match[f]))
|
|
78
|
+
inv(`rule "${r.id}": match.${f} contains control characters`);
|
|
79
|
+
}
|
|
80
|
+
if ('scope' in r.match) {
|
|
81
|
+
if (!Array.isArray(r.match.scope) || r.match.scope.length === 0)
|
|
82
|
+
inv(`rule "${r.id}": match.scope must be a non-empty array of path prefix rules`);
|
|
83
|
+
for (const s of r.match.scope) {
|
|
84
|
+
if (typeof s !== 'string' || s === '' || s.startsWith('/'))
|
|
85
|
+
inv(`rule "${r.id}": scope entries are relative path prefix rules`, { got: s });
|
|
86
|
+
// Review finding N2 (verify pass): ANY entry the matcher can never
|
|
87
|
+
// satisfy is a silent dead rule — 'docs//x', './docs', 'a/../b'
|
|
88
|
+
// fail-closed on the context side (F2), so as RULE entries they
|
|
89
|
+
// could only ever disable a deny. Refuse the whole family.
|
|
90
|
+
const dirBase = s.endsWith('/') ? s.slice(0, -1) : s;
|
|
91
|
+
if (!isCanonicalRelPath(dirBase))
|
|
92
|
+
inv(
|
|
93
|
+
`rule "${r.id}": scope entry "${s}" is non-canonical and can never match any valid context`,
|
|
94
|
+
{ got: s },
|
|
95
|
+
);
|
|
96
|
+
// Review finding F10: prefix semantics have no wildcards — a 'prod/**'
|
|
97
|
+
// entry would be a dead rule that silently never fires.
|
|
98
|
+
if (s.includes('*'))
|
|
99
|
+
inv(
|
|
100
|
+
`rule "${r.id}": scope entries are prefix rules, not globs — "${s}" would never match anything`,
|
|
101
|
+
{ got: s },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
'max_estimated_usd' in r.match &&
|
|
107
|
+
!(isFiniteNonNeg(r.match.max_estimated_usd) && r.match.max_estimated_usd > 0)
|
|
108
|
+
)
|
|
109
|
+
inv(`rule "${r.id}": max_estimated_usd must be a finite number > 0`);
|
|
110
|
+
if ('approvers_min' in r) {
|
|
111
|
+
if (r.verdict !== 'require_approval')
|
|
112
|
+
inv(`rule "${r.id}": approvers_min only belongs on require_approval rules`);
|
|
113
|
+
if (!Number.isInteger(r.approvers_min) || r.approvers_min < 1)
|
|
114
|
+
inv(`rule "${r.id}": approvers_min must be an integer >= 1`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
// R-04: a gate that can only say yes is not a gate — empty or not
|
|
118
|
+
// (review S4: rules that are all allow, or a first rule that allows
|
|
119
|
+
// everything, loaded although the README promised this refusal).
|
|
120
|
+
const unconditional = (r) =>
|
|
121
|
+
Object.entries(r.match).every(([k, v]) => (k === 'agent' || k === 'action') && v === '*');
|
|
122
|
+
if (
|
|
123
|
+
(doc.default_verdict === 'allow' && doc.rules.every((r) => r.verdict === 'allow')) ||
|
|
124
|
+
(doc.rules.length > 0 && doc.rules[0].verdict === 'allow' && unconditional(doc.rules[0]))
|
|
125
|
+
)
|
|
126
|
+
bad(
|
|
127
|
+
'POLICY_VACUOUS_ALLOW',
|
|
128
|
+
`this policy can only say yes — it can never refuse anything (${policyPath})`,
|
|
129
|
+
{ policyPath },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Field-level coverage: does constraint of `a` accept every context that
|
|
134
|
+
* `b`'s accepts? Only the decidable cases return true. */
|
|
135
|
+
function fieldCovers(a, b, field) {
|
|
136
|
+
if (!(field in a.match)) return true; // absent = matches anything
|
|
137
|
+
if ((field === 'agent' || field === 'action') && a.match[field] === '*') return true; // '*' too
|
|
138
|
+
if (!(field in b.match)) return false; // a constrains, b does not
|
|
139
|
+
if (field === 'agent' || field === 'action') return a.match[field] === b.match[field];
|
|
140
|
+
if (field === 'scope')
|
|
141
|
+
return b.match.scope.every((sb) => a.match.scope.some((sa) => prefixCovers(sa, sb)));
|
|
142
|
+
if (field === 'max_estimated_usd') return a.match.max_estimated_usd >= b.match.max_estimated_usd;
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Review finding F4: the pairwise scan is quadratic and the per-pair scope
|
|
147
|
+
// subset check is quadratic again — a schema-legal file could park loadPolicy
|
|
148
|
+
// for minutes. Two defenses: a cheap compatibility pre-filter so benign
|
|
149
|
+
// many-rule policies stay near-linear, and a hard work budget — exceeding it
|
|
150
|
+
// REFUSES (POLICY_TOO_COMPLEX): bounded time by refusal, never by hanging.
|
|
151
|
+
// Weighted units: 1 per pair ITERATION (review N1: the pre-filtered fast
|
|
152
|
+
// path is charged too — a guard's own bypass is an attack surface), 1 per
|
|
153
|
+
// simple field check, 10 per scope-entry comparison.
|
|
154
|
+
const SHADOW_SCAN_BUDGET = 200_000_000;
|
|
155
|
+
|
|
156
|
+
function findShadowedRule(rules, policyPath) {
|
|
157
|
+
let ops = 0;
|
|
158
|
+
const overBudget = () =>
|
|
159
|
+
bad(
|
|
160
|
+
'POLICY_TOO_COMPLEX',
|
|
161
|
+
`shadow analysis exceeded its work budget — split the policy or reduce overlapping rules (${policyPath})`,
|
|
162
|
+
{ policyPath, budget: SHADOW_SCAN_BUDGET },
|
|
163
|
+
);
|
|
164
|
+
const coversWithBudget = (a, b, field) => {
|
|
165
|
+
if (field === 'scope' && field in a.match && field in b.match)
|
|
166
|
+
ops += 10 * a.match.scope.length * b.match.scope.length;
|
|
167
|
+
else ops += 1;
|
|
168
|
+
if (ops > SHADOW_SCAN_BUDGET) overBudget();
|
|
169
|
+
return fieldCovers(a, b, field);
|
|
170
|
+
};
|
|
171
|
+
for (let j = 1; j < rules.length; j++) {
|
|
172
|
+
const mj = rules[j].match;
|
|
173
|
+
for (let i = 0; i < j; i++) {
|
|
174
|
+
ops += 1; // N1: EVERY pair costs — including ones the pre-filter skips
|
|
175
|
+
if (ops > SHADOW_SCAN_BUDGET) overBudget();
|
|
176
|
+
const mi = rules[i].match;
|
|
177
|
+
// pre-filter: i can only cover j if its agent/action constraint is
|
|
178
|
+
// absent, '*', or literally equal — O(1), no allocation
|
|
179
|
+
if ('agent' in mi && mi.agent !== '*' && !('agent' in mj && mi.agent === mj.agent)) continue;
|
|
180
|
+
if ('action' in mi && mi.action !== '*' && !('action' in mj && mi.action === mj.action))
|
|
181
|
+
continue;
|
|
182
|
+
if (MATCH_KEYS.every((f) => coversWithBudget(rules[i], rules[j], f)))
|
|
183
|
+
return { by: rules[i].id, dead: rules[j].id };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Review finding F7: JSON duplicate keys are parser-dependent ("deny" to a
|
|
190
|
+
// human reader, "allow" to last-wins JSON.parse) — a hashed file must have
|
|
191
|
+
// ONE meaning. This scanner walks the already-parse-validated text and
|
|
192
|
+
// refuses any object with a repeated key, at any depth.
|
|
193
|
+
function scanDuplicateKeys(text, policyPath) {
|
|
194
|
+
const frames = [];
|
|
195
|
+
let i = 0;
|
|
196
|
+
const n = text.length;
|
|
197
|
+
let pendingKey = null;
|
|
198
|
+
while (i < n) {
|
|
199
|
+
const c = text[i];
|
|
200
|
+
if (c === '"') {
|
|
201
|
+
const start = ++i;
|
|
202
|
+
while (i < n && text[i] !== '"') i += text[i] === '\\' ? 2 : 1;
|
|
203
|
+
const spanEnd = i;
|
|
204
|
+
i += 1; // closing quote
|
|
205
|
+
const top = frames[frames.length - 1];
|
|
206
|
+
if (top && top.type === 'obj' && top.expectKey) {
|
|
207
|
+
// normalize escapes the way JSON.parse does, so "a" === "a"
|
|
208
|
+
pendingKey = JSON.parse(`"${text.slice(start, spanEnd)}"`);
|
|
209
|
+
top.expectKey = false;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (c === '{') {
|
|
214
|
+
frames.push({ type: 'obj', keys: new Set(), expectKey: true });
|
|
215
|
+
pendingKey = null;
|
|
216
|
+
} else if (c === '[') frames.push({ type: 'arr' });
|
|
217
|
+
else if (c === '}' || c === ']') frames.pop();
|
|
218
|
+
else if (c === ':') {
|
|
219
|
+
const top = frames[frames.length - 1];
|
|
220
|
+
if (top && top.type === 'obj' && pendingKey !== null) {
|
|
221
|
+
if (top.keys.has(pendingKey))
|
|
222
|
+
bad(
|
|
223
|
+
'POLICY_SCHEMA_INVALID',
|
|
224
|
+
`duplicate JSON key "${pendingKey}" — a hashed policy must have exactly one meaning (${policyPath})`,
|
|
225
|
+
{ policyPath, key: pendingKey },
|
|
226
|
+
);
|
|
227
|
+
top.keys.add(pendingKey);
|
|
228
|
+
pendingKey = null;
|
|
229
|
+
}
|
|
230
|
+
} else if (c === ',') {
|
|
231
|
+
const top = frames[frames.length - 1];
|
|
232
|
+
if (top && top.type === 'obj') top.expectKey = true;
|
|
233
|
+
}
|
|
234
|
+
i += 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Load, validate and hash a policy file. The hash is SHA-256 over the RAW
|
|
240
|
+
* file bytes — what the auditor hashes is what decided; duplicate-key
|
|
241
|
+
* documents (whose meaning depends on the parser) refuse outright.
|
|
242
|
+
* Throws: POLICY_UNREADABLE | POLICY_TOO_LARGE | POLICY_SCHEMA_INVALID | POLICY_VACUOUS_ALLOW |
|
|
243
|
+
* POLICY_RULE_SHADOWED | POLICY_TOO_COMPLEX
|
|
244
|
+
*/
|
|
245
|
+
export function loadPolicy({ policyPath }) {
|
|
246
|
+
if (typeof policyPath !== 'string' || policyPath === '')
|
|
247
|
+
bad('POLICY_UNREADABLE', 'policyPath is required — policies are never implicit', {});
|
|
248
|
+
let raw;
|
|
249
|
+
try {
|
|
250
|
+
// a fifo or device would block the read forever (review N1): files only
|
|
251
|
+
const st = statSync(policyPath);
|
|
252
|
+
if (!st.isFile())
|
|
253
|
+
bad('POLICY_UNREADABLE', `the policy path is not a regular file (${policyPath})`, {
|
|
254
|
+
policyPath,
|
|
255
|
+
});
|
|
256
|
+
// C-04: a policy is read whole on every hook call, so its size is bounded
|
|
257
|
+
if (st.size > MAX_POLICY_BYTES)
|
|
258
|
+
bad(
|
|
259
|
+
'POLICY_TOO_LARGE',
|
|
260
|
+
`the policy file is larger than ${MAX_POLICY_BYTES} bytes (${policyPath})`,
|
|
261
|
+
{
|
|
262
|
+
policyPath,
|
|
263
|
+
bytes: st.size,
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
raw = readFileSync(policyPath);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
if (e instanceof PolicyError) throw e;
|
|
269
|
+
bad('POLICY_UNREADABLE', `cannot read policy file: ${e.code ?? e.message}`, { policyPath });
|
|
270
|
+
}
|
|
271
|
+
let doc;
|
|
272
|
+
try {
|
|
273
|
+
doc = JSON.parse(raw.toString('utf8'));
|
|
274
|
+
} catch {
|
|
275
|
+
bad('POLICY_UNREADABLE', `policy file is not valid JSON (${policyPath})`, { policyPath });
|
|
276
|
+
}
|
|
277
|
+
scanDuplicateKeys(raw.toString('utf8'), policyPath);
|
|
278
|
+
validatePolicyDoc(doc, policyPath);
|
|
279
|
+
const shadowed = findShadowedRule(doc.rules, policyPath);
|
|
280
|
+
if (shadowed)
|
|
281
|
+
bad(
|
|
282
|
+
'POLICY_RULE_SHADOWED',
|
|
283
|
+
`rule "${shadowed.dead}" can never fire — every context it matches is taken first by "${shadowed.by}"`,
|
|
284
|
+
{ policyPath, ...shadowed },
|
|
285
|
+
);
|
|
286
|
+
const policyHash = createHash('sha256').update(raw).digest('hex');
|
|
287
|
+
return { policy: doc, policyHash };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const MAX_CONTEXT_STRING = 4096;
|
|
291
|
+
|
|
292
|
+
function validateContext(context) {
|
|
293
|
+
const inv = (why, details = {}) => bad('CONTEXT_INVALID', why, details);
|
|
294
|
+
if (!isPlainObject(context)) inv('context must be an object');
|
|
295
|
+
if (!isFiniteNonNeg(context.estimatedUsd))
|
|
296
|
+
inv('context.estimatedUsd must be a finite number >= 0 — no coercion, ever', {
|
|
297
|
+
got: context.estimatedUsd,
|
|
298
|
+
});
|
|
299
|
+
if (typeof context.callId !== 'string' || context.callId === '')
|
|
300
|
+
inv('context.callId is required — an unattributable decision is unauditable');
|
|
301
|
+
for (const f of ['agent', 'action', 'scope', 'callId'])
|
|
302
|
+
if (f in context && context[f] !== undefined) {
|
|
303
|
+
if (typeof context[f] !== 'string')
|
|
304
|
+
inv(`context.${f} must be a string when present`, { field: f });
|
|
305
|
+
// Review finding F11: unbounded strings flow into the audit log verbatim.
|
|
306
|
+
if (context[f].length > MAX_CONTEXT_STRING)
|
|
307
|
+
inv(`context.${f} exceeds ${MAX_CONTEXT_STRING} chars`, { field: f });
|
|
308
|
+
// review N3: '' is no agent and no action — the ledger refuses it later,
|
|
309
|
+
// after the decision was already logged as an allow
|
|
310
|
+
if (context[f] === '') inv(`context.${f} must not be empty when present`, { field: f });
|
|
311
|
+
}
|
|
312
|
+
// Review finding F2: a non-canonical scope ('prod//x', './prod/x',
|
|
313
|
+
// '/prod/x', 'a/../b') can match NO rule — in a deny-first policy that
|
|
314
|
+
// slides past an explicit deny into a later allow or a permissive
|
|
315
|
+
// default. Refuse the alias outright; canonicalizing (and unicode
|
|
316
|
+
// normalization) is the enforcer's documented duty.
|
|
317
|
+
if (typeof context.scope === 'string' && !isCanonicalRelPath(context.scope))
|
|
318
|
+
inv('context.scope must be a canonical relative path — aliases are refused, not ignored', {
|
|
319
|
+
got: context.scope.slice(0, 80),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function ruleMatches(rule, context) {
|
|
324
|
+
const m = rule.match;
|
|
325
|
+
if ('agent' in m && m.agent !== '*' && m.agent !== context.agent) return false;
|
|
326
|
+
if ('action' in m && m.action !== '*' && m.action !== context.action) return false;
|
|
327
|
+
if ('scope' in m) {
|
|
328
|
+
// a context without a scope cannot satisfy a scope-constrained rule
|
|
329
|
+
if (typeof context.scope !== 'string') return false;
|
|
330
|
+
if (!matchesAny(context.scope, m.scope)) return false;
|
|
331
|
+
}
|
|
332
|
+
// AT the limit matches (plain <=); the ledger's epsilon wall is the
|
|
333
|
+
// ledger's own, independent concern.
|
|
334
|
+
if ('max_estimated_usd' in m && !(context.estimatedUsd <= m.max_estimated_usd)) return false;
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* PURE decision: no I/O, no clock, no randomness. First match wins, top to
|
|
340
|
+
* bottom; no rule matched → the mandatory default verdict with ruleId null.
|
|
341
|
+
* Throws: CONTEXT_INVALID
|
|
342
|
+
*/
|
|
343
|
+
export function decide({ policy, policyHash, context }) {
|
|
344
|
+
// COPY FIRST, validate the copy, judge the copy, log the copy. Each caller
|
|
345
|
+
// property is read exactly ONCE: a getter that answers the validator one
|
|
346
|
+
// value and the matcher another (review finding F5 — the lock-protocol
|
|
347
|
+
// TOCTOU, reborn in miniature) gets no second read, and mutating the
|
|
348
|
+
// caller's object after decide() cannot rewrite the audit log.
|
|
349
|
+
const judged = isPlainObject(context)
|
|
350
|
+
? {
|
|
351
|
+
agent: context.agent,
|
|
352
|
+
action: context.action,
|
|
353
|
+
scope: context.scope,
|
|
354
|
+
estimatedUsd: context.estimatedUsd,
|
|
355
|
+
callId: context.callId,
|
|
356
|
+
}
|
|
357
|
+
: context;
|
|
358
|
+
validateContext(judged);
|
|
359
|
+
const finish = (verdict, ruleId, approversMinRaw) => {
|
|
360
|
+
const d = { verdict, ruleId, policyHash, context: judged };
|
|
361
|
+
// Review finding F1: the default path MUST carry approversMin too —
|
|
362
|
+
// "unmatched means a human signs" fell open as `0 < undefined === false`.
|
|
363
|
+
if (verdict === 'require_approval') d.approversMin = approversMinRaw ?? 1;
|
|
364
|
+
return d;
|
|
365
|
+
};
|
|
366
|
+
for (const rule of policy.rules)
|
|
367
|
+
if (ruleMatches(rule, judged)) return finish(rule.verdict, rule.id, rule.approvers_min);
|
|
368
|
+
return finish(policy.default_verdict, null, undefined);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Append one decision to the JSONL log. An unauditable allow is a deny:
|
|
373
|
+
* any failure here throws DECISION_UNLOGGABLE and the enforcer must refuse
|
|
374
|
+
* the action.
|
|
375
|
+
*/
|
|
376
|
+
export function logDecision({ decisionLogPath, decision, by }) {
|
|
377
|
+
// Review finding F6: the log line format is public API, so the gate is
|
|
378
|
+
// strict — only something shaped exactly like a decide() result may enter
|
|
379
|
+
// the audit trail, and ANY failure (including serialization) is the one
|
|
380
|
+
// typed refusal. Accepts only loadPolicy/decide outputs by construction.
|
|
381
|
+
if (
|
|
382
|
+
typeof decisionLogPath !== 'string' ||
|
|
383
|
+
decisionLogPath === '' ||
|
|
384
|
+
!isPlainObject(decision) ||
|
|
385
|
+
!VERDICTS.includes(decision.verdict) ||
|
|
386
|
+
typeof decision.policyHash !== 'string' ||
|
|
387
|
+
!/^[0-9a-f]{64}$/.test(decision.policyHash) ||
|
|
388
|
+
!(decision.ruleId === null || typeof decision.ruleId === 'string') ||
|
|
389
|
+
!(
|
|
390
|
+
decision.context === undefined ||
|
|
391
|
+
decision.context === null ||
|
|
392
|
+
isPlainObject(decision.context)
|
|
393
|
+
) ||
|
|
394
|
+
// N3: approversMin iff require_approval — exactly what decide() emits
|
|
395
|
+
!(decision.verdict === 'require_approval'
|
|
396
|
+
? Number.isInteger(decision.approversMin) && decision.approversMin >= 1
|
|
397
|
+
: decision.approversMin === undefined) ||
|
|
398
|
+
// N4: the enforcer-supplied signer name obeys the same string cap
|
|
399
|
+
(typeof by === 'string' && by.length > MAX_CONTEXT_STRING)
|
|
400
|
+
)
|
|
401
|
+
bad('DECISION_UNLOGGABLE', 'only a decide()-shaped decision can enter the audit trail', {
|
|
402
|
+
decisionLogPath,
|
|
403
|
+
});
|
|
404
|
+
let line;
|
|
405
|
+
try {
|
|
406
|
+
line = `${JSON.stringify({
|
|
407
|
+
at: new Date().toISOString(),
|
|
408
|
+
by: typeof by === 'string' && by !== '' ? by : null,
|
|
409
|
+
verdict: decision.verdict,
|
|
410
|
+
ruleId: decision.ruleId ?? null,
|
|
411
|
+
...(decision.approversMin !== undefined ? { approversMin: decision.approversMin } : {}),
|
|
412
|
+
policyHash: decision.policyHash,
|
|
413
|
+
context: decision.context ?? null,
|
|
414
|
+
})}\n`;
|
|
415
|
+
appendNoFollow(decisionLogPath, line); // never written through a planted symlink (review S4)
|
|
416
|
+
} catch (e) {
|
|
417
|
+
bad('DECISION_UNLOGGABLE', `decision log write failed: ${e.code ?? e.message}`, {
|
|
418
|
+
decisionLogPath,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export { matchPath };
|
package/src/prices.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// Prices (specs/spend/spec.md, "Price"). The table is data, never code: a
|
|
4
|
+
// trimmed snapshot of LiteLLM's model list ships with the package
|
|
5
|
+
// (maintainers regenerate it with scripts/prices-trim.mjs), and an operator
|
|
6
|
+
// can override or extend it with a sakk-prices.v1 table at SAKK_HOME/prices.json.
|
|
7
|
+
// A model missing from the table is unpriced — never guessed.
|
|
8
|
+
//
|
|
9
|
+
// Table shape (sakk-prices.v1), prices in USD per token:
|
|
10
|
+
// { schema, source: { url, commit, sha256, fetchedAt, license },
|
|
11
|
+
// models: { "<id>": { input, output, cacheWrite5m, cacheWrite1h, cacheRead,
|
|
12
|
+
// aliases?: [..],
|
|
13
|
+
// longContext?: { thresholdTokens, input, output, cacheWrite5m, cacheWrite1h, cacheRead },
|
|
14
|
+
// multipliers?: { "<usage field>": { "<value>": factor } } } } }
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
export const PRICES_SCHEMA = 'sakk-prices.v1';
|
|
21
|
+
export const MAX_PRICES_BYTES = 256 * 1024; // the plugin directory's per-file limit
|
|
22
|
+
export const SHIPPED_PRICES = join(
|
|
23
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
24
|
+
'..',
|
|
25
|
+
'prices',
|
|
26
|
+
'snapshot.json',
|
|
27
|
+
);
|
|
28
|
+
const KINDS = ['input', 'output', 'cacheWrite5m', 'cacheWrite1h', 'cacheRead'];
|
|
29
|
+
|
|
30
|
+
export class PriceError extends Error {
|
|
31
|
+
constructor(code, message) {
|
|
32
|
+
super(`${code}: ${message}`);
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const finiteNonNeg = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0;
|
|
38
|
+
|
|
39
|
+
/** A table is refused whole if any entry is not a price: a half-read table would under-count. */
|
|
40
|
+
export function validateTable(t, where) {
|
|
41
|
+
const bad = (why) => {
|
|
42
|
+
throw new PriceError('PRICES_INVALID', `${where}: ${why}`);
|
|
43
|
+
};
|
|
44
|
+
if (t === null || typeof t !== 'object' || t.schema !== PRICES_SCHEMA)
|
|
45
|
+
bad(`schema must be ${PRICES_SCHEMA}`);
|
|
46
|
+
if (t.models === null || typeof t.models !== 'object' || Array.isArray(t.models))
|
|
47
|
+
bad('models must be an object');
|
|
48
|
+
const names = new Map();
|
|
49
|
+
for (const [id, m] of Object.entries(t.models)) {
|
|
50
|
+
for (const k of KINDS)
|
|
51
|
+
if (!finiteNonNeg(m?.[k])) bad(`${id}.${k} is not a non-negative number`);
|
|
52
|
+
if (m.longContext !== undefined) {
|
|
53
|
+
if (!(Number.isInteger(m.longContext.thresholdTokens) && m.longContext.thresholdTokens > 0))
|
|
54
|
+
bad(`${id}.longContext.thresholdTokens must be a positive integer`);
|
|
55
|
+
for (const k of KINDS)
|
|
56
|
+
if (!finiteNonNeg(m.longContext[k])) bad(`${id}.longContext.${k} is not a price`);
|
|
57
|
+
}
|
|
58
|
+
for (const [field, values] of Object.entries(m.multipliers ?? {}))
|
|
59
|
+
for (const [v, f] of Object.entries(values ?? {}))
|
|
60
|
+
if (!finiteNonNeg(f) || f === 0)
|
|
61
|
+
bad(`${id}.multipliers.${field}.${v} must be a positive number`);
|
|
62
|
+
for (const name of [id, ...(m.aliases ?? [])]) {
|
|
63
|
+
if (typeof name !== 'string' || name === '') bad(`${id}: an alias is not a name`);
|
|
64
|
+
if (names.has(name) && names.get(name) !== id)
|
|
65
|
+
bad(`${name} names two models (${names.get(name)}, ${id})`);
|
|
66
|
+
names.set(name, id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return t;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The table in use: SAKK_HOME/prices.json when present (a malformed one is
|
|
74
|
+
* refused — never a silent fall back to the snapshot), else the shipped one.
|
|
75
|
+
*/
|
|
76
|
+
export function loadPrices({ home, shipped = SHIPPED_PRICES } = {}) {
|
|
77
|
+
const user = home ? join(home, 'prices.json') : null;
|
|
78
|
+
const path = user && existsSync(user) ? user : shipped;
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
const st = statSync(path);
|
|
82
|
+
if (!st.isFile()) throw new PriceError('PRICES_UNREADABLE', `${path} is not a file`);
|
|
83
|
+
if (st.size > MAX_PRICES_BYTES)
|
|
84
|
+
throw new PriceError('PRICES_UNREADABLE', `${path} is larger than ${MAX_PRICES_BYTES} bytes`);
|
|
85
|
+
raw = readFileSync(path, 'utf8');
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (e instanceof PriceError) throw e;
|
|
88
|
+
throw new PriceError('PRICES_UNREADABLE', `${path}: ${e.code ?? e.message}`);
|
|
89
|
+
}
|
|
90
|
+
let t;
|
|
91
|
+
try {
|
|
92
|
+
t = JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new PriceError('PRICES_INVALID', `${path} is not JSON`);
|
|
95
|
+
}
|
|
96
|
+
return { table: validateTable(t, path), path };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The entry for a model id: the id itself or a listed alias. Nothing else is guessed. */
|
|
100
|
+
export function priceOf(table, model) {
|
|
101
|
+
if (typeof model !== 'string' || model === '') return null;
|
|
102
|
+
if (Object.hasOwn(table.models, model)) return table.models[model];
|
|
103
|
+
for (const m of Object.values(table.models)) if ((m.aliases ?? []).includes(model)) return m;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const count = (u, k) => {
|
|
108
|
+
const v = u?.[k];
|
|
109
|
+
if (v === undefined || v === null) return 0;
|
|
110
|
+
if (!finiteNonNeg(v) || !Number.isInteger(v))
|
|
111
|
+
throw new PriceError('USAGE_INVALID', `${k} is not a token count`);
|
|
112
|
+
return v;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The cost of one call from its usage (specs/spend/spec.md, "Price"):
|
|
117
|
+
* each token kind at its own price; a 1-hour cache write at its own price;
|
|
118
|
+
* past the long-context threshold the whole request at the higher rates;
|
|
119
|
+
* multipliers matched on usage fields (e.g. speed, inference_geo).
|
|
120
|
+
*/
|
|
121
|
+
export function costOf(entry, usage) {
|
|
122
|
+
const input = count(usage, 'input_tokens');
|
|
123
|
+
const output = count(usage, 'output_tokens');
|
|
124
|
+
const cacheRead = count(usage, 'cache_read_input_tokens');
|
|
125
|
+
const cacheWrite = count(usage, 'cache_creation_input_tokens');
|
|
126
|
+
const split = usage?.cache_creation;
|
|
127
|
+
let w5 = cacheWrite;
|
|
128
|
+
let w1h = 0;
|
|
129
|
+
if (split && typeof split === 'object') {
|
|
130
|
+
const s5 = count(split, 'ephemeral_5m_input_tokens');
|
|
131
|
+
const s1h = count(split, 'ephemeral_1h_input_tokens');
|
|
132
|
+
// The split refines the price of the aggregate, never shrinks it: an
|
|
133
|
+
// empty cache_creation:{} (F-168) or one naming only part of the writes
|
|
134
|
+
// (F-179) leaves the rest at the 5-minute price, as a row with no split.
|
|
135
|
+
w1h = s1h;
|
|
136
|
+
w5 = Math.max(s5, cacheWrite - s1h);
|
|
137
|
+
}
|
|
138
|
+
const prompt = input + cacheRead + w5 + w1h;
|
|
139
|
+
const rate =
|
|
140
|
+
entry.longContext && prompt > entry.longContext.thresholdTokens ? entry.longContext : entry;
|
|
141
|
+
let usd =
|
|
142
|
+
input * rate.input +
|
|
143
|
+
output * rate.output +
|
|
144
|
+
w5 * rate.cacheWrite5m +
|
|
145
|
+
w1h * rate.cacheWrite1h +
|
|
146
|
+
cacheRead * rate.cacheRead;
|
|
147
|
+
for (const [field, values] of Object.entries(entry.multipliers ?? {})) {
|
|
148
|
+
const v = usage?.[field];
|
|
149
|
+
if (typeof v === 'string' && Object.hasOwn(values, v)) usd *= values[v];
|
|
150
|
+
}
|
|
151
|
+
return usd;
|
|
152
|
+
}
|