@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/src/match.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // The one path-matching implementation shared by the eval scorer and the
4
+ // policy kernel (spec: specs/policy-engine/ — "one implementation, already
5
+ // mutation-drilled"). Entries are PREFIX RULES, not wildcard globs:
6
+ // 'docs' or 'docs/' → the docs/ subtree (and the literal path 'docs')
7
+ // 'a/b.mjs' → that path and anything under it ('a/b.mjs/x' too)
8
+ // Bounded, deterministic, no regex — no ReDoS surface by construction.
9
+
10
+ /** The prefix comparison alone, for a path already known to be canonical. */
11
+ function prefixMatch(p, x) {
12
+ if (x.endsWith('/')) return p === x.slice(0, -1) || p.startsWith(x);
13
+ return p === x || p.startsWith(`${x}/`);
14
+ }
15
+
16
+ export function matchPath(p, x) {
17
+ // Pathological paths fail closed: non-strings, empty strings, absolute
18
+ // paths, '.'/'..' segments, and double slashes match NO rule. Matching
19
+ // nothing always tightens: in the scorer it yields UNAUTHORIZED_WRITE, in
20
+ // the policy kernel it falls through to the mandatory default verdict.
21
+ return isCanonicalRelPath(p) && prefixMatch(p, x);
22
+ }
23
+
24
+ // Validate once, compare many (policy review S6): re-splitting a 2048-segment
25
+ // agent-chosen path for every rule entry made each decision cost
26
+ // segments × entries (~0.7 s with a 20k-entry allow-list).
27
+ export const matchesAny = (p, prefixes) =>
28
+ isCanonicalRelPath(p) && prefixes.some((x) => prefixMatch(p, x));
29
+
30
+ /** True iff `p` is a canonical relative path — the only shape matchPath can
31
+ * ever match. Review finding F2: in a deny-first policy, a path alias that
32
+ * silently matches NOTHING ('prod//x', './prod/x', '/prod/x') slides past
33
+ * an explicit deny into a later allow — so the policy kernel REFUSES
34
+ * non-canonical context scopes outright instead of not-matching them.
35
+ * (Unicode homoglyphs are indistinguishable from honest names here;
36
+ * normalizing those is the enforcer's documented duty.) */
37
+ export function isCanonicalRelPath(p) {
38
+ return (
39
+ typeof p === 'string' &&
40
+ p !== '' &&
41
+ !p.startsWith('/') &&
42
+ !p.split('/').some((s) => s === '' || s === '.' || s === '..')
43
+ );
44
+ }
45
+
46
+ /** Decidable prefix-subset check: does rule `a` cover every path rule `b`
47
+ * covers? Used by shadow detection; anything undecidable returns false
48
+ * (the checker refuses provable dead rules, it does not promise more). */
49
+ export function prefixCovers(a, b) {
50
+ const base = (x) => (x.endsWith('/') ? x.slice(0, -1) : x);
51
+ return base(b) === base(a) || matchPath(base(b), a);
52
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,299 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // MCP enforcer (specs/mcp-server/): the thinnest possible surface putting
4
+ // the ledger — and, when configured, the policy kernel — in front of any
5
+ // MCP-speaking agent. This module owns NO semantics: every refusal is a
6
+ // LedgerError/PolicyError passed through verbatim (plus the two policy
7
+ // verdict codes below); deleting the server and calling the library
8
+ // yields byte-identical books. Transport is hand-rolled newline-delimited
9
+ // JSON-RPC 2.0 over stdio — zero dependencies, like everything here.
10
+
11
+ import { reserve, settle, readLedger, totalsOf, maxReservableUsd, LedgerError } from './ledger.mjs';
12
+ import { decide, logDecision, PolicyError } from './policy.mjs';
13
+
14
+ const MAX_FRAME = 1 << 20; // 1 MiB per line (contract adversary #8)
15
+
16
+ const TOOLS = [
17
+ {
18
+ name: 'sakk_reserve',
19
+ description:
20
+ 'Reserve budget BEFORE a paid action. Refuses when the cap would be reached, the callId repeats, the ledger is halted, or the configured policy says deny/require_approval. scope must be a CANONICAL relative path — normalize paths and unicode BEFORE calling; with a policy configured, aliases like prod//x are refused (without one, agent/action/scope are not evaluated and the response says so).',
21
+ inputSchema: {
22
+ type: 'object',
23
+ required: ['callId', 'estimatedMaxUsd'],
24
+ properties: {
25
+ callId: { type: 'string', description: 'unique id for this paid call' },
26
+ estimatedMaxUsd: { type: 'number', description: 'worst-case cost of this one call' },
27
+ agent: { type: 'string' },
28
+ action: { type: 'string' },
29
+ scope: { type: 'string', description: 'canonical relative path this action touches' },
30
+ },
31
+ },
32
+ },
33
+ {
34
+ name: 'sakk_settle',
35
+ description:
36
+ 'Settle a reservation with the real cost afterwards. Pass failedUnknownCost: true when the cost is unknowable — the ledger then halts all further reservations until a named human reconciles.',
37
+ inputSchema: {
38
+ type: 'object',
39
+ required: ['callId'],
40
+ properties: {
41
+ callId: { type: 'string' },
42
+ actualUsd: { type: 'number' },
43
+ failedUnknownCost: { type: 'boolean' },
44
+ },
45
+ },
46
+ },
47
+ {
48
+ name: 'sakk_status',
49
+ description:
50
+ 'Ledger totals, entry counts, reconciliation state, max reservable amount, and the active policy hash when a policy is configured.',
51
+ inputSchema: { type: 'object', properties: {} },
52
+ },
53
+ ];
54
+
55
+ const ok = (obj) => ({ content: [{ type: 'text', text: JSON.stringify(obj) }] });
56
+
57
+ // F-82: caller values crossed into template literals and JSON.stringify,
58
+ // which recurse per nesting level — a 10 KB nested array overflowed the
59
+ // stack and killed the gate. Echoed caller values are bounded strings;
60
+ // structures are named, never serialized.
61
+ const MAX_ECHO = 200;
62
+ const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
63
+ const echo = (v) =>
64
+ typeof v === 'string' ? (v.length > MAX_ECHO ? `${v.slice(0, MAX_ECHO)}…` : v) : `<${kind(v)}>`;
65
+ const safeDetails = (d, depth = 0) => {
66
+ if (typeof d === 'string') return echo(d);
67
+ if (d === null || typeof d !== 'object') return d;
68
+ if (depth >= 2) return `<${kind(d)}>`;
69
+ if (Array.isArray(d)) return d.slice(0, 20).map((x) => safeDetails(x, depth + 1));
70
+ return Object.fromEntries(
71
+ Object.entries(d)
72
+ .slice(0, 50)
73
+ .map(([k, x]) => [k, safeDetails(x, depth + 1)]),
74
+ );
75
+ };
76
+ const refusal = (obj) => ({
77
+ content: [{ type: 'text', text: JSON.stringify(obj) }],
78
+ isError: true,
79
+ });
80
+
81
+ const MAX_ARG_STRING = 4096;
82
+
83
+ function callTool({ name, args, ledgerPath, loaded, decisionLogPath }) {
84
+ try {
85
+ // Review F4: the ledger has no string caps of its own, so the enforcer
86
+ // bounds its inputs (contract adversary #8; seam rule 2 — refusing MORE
87
+ // than the library is always allowed, spending more never is).
88
+ for (const f of ['callId', 'agent', 'action', 'scope'])
89
+ if (typeof args?.[f] === 'string' && args[f].length > MAX_ARG_STRING)
90
+ return refusal({
91
+ code: 'MCP_ARG_TOO_LONG',
92
+ message: `${f} exceeds ${MAX_ARG_STRING} chars — bounded inputs, bounded books`,
93
+ details: { field: f },
94
+ });
95
+ if (name === 'sakk_reserve') {
96
+ if (loaded) {
97
+ const context = {
98
+ agent: args.agent,
99
+ action: args.action,
100
+ scope: args.scope,
101
+ estimatedUsd: args.estimatedMaxUsd,
102
+ callId: args.callId,
103
+ };
104
+ const decision = decide({ ...loaded, context });
105
+ // Every decision is logged BEFORE anything acts on it — an
106
+ // unauditable allow is a deny (kernel contract, surfaced here).
107
+ logDecision({ decisionLogPath, decision, by: 'sakk-mcp' });
108
+ if (decision.verdict !== 'allow')
109
+ return refusal({
110
+ code: decision.verdict === 'deny' ? 'POLICY_DENIED' : 'POLICY_APPROVAL_REQUIRED',
111
+ message:
112
+ decision.verdict === 'deny'
113
+ ? `policy rule ${decision.ruleId ?? '(default)'} denies this action`
114
+ : `policy requires ${decision.approversMin} distinct approver(s) — approval collection is not available over MCP`,
115
+ decision,
116
+ });
117
+ }
118
+ const r = reserve({
119
+ ledgerPath,
120
+ callId: args.callId,
121
+ estimatedMaxUsd: args.estimatedMaxUsd,
122
+ agentId: args.agent,
123
+ });
124
+ return ok({
125
+ reserved: true,
126
+ callId: r.callId,
127
+ estimatedMaxUsd: args.estimatedMaxUsd,
128
+ ...(loaded ? {} : { policy: 'not configured — agent/action/scope were not evaluated' }),
129
+ });
130
+ }
131
+ if (name === 'sakk_settle') {
132
+ // Review F1/F2: arguments pass VERBATIM — the LIBRARY judges
133
+ // truthiness (thinness is the contract), and the library's return
134
+ // rides the response: a settle that just halted the whole ledger
135
+ // must say so (R-15: a verdict carries everything its enforcement
136
+ // needs).
137
+ const r = settle({
138
+ ledgerPath,
139
+ callId: args.callId,
140
+ actualUsd: args.actualUsd,
141
+ failedUnknownCost: args.failedUnknownCost,
142
+ allowReconciling: false, // a person resolves reconciliation, never the agent (N8)
143
+ });
144
+ return ok({
145
+ settled: true,
146
+ callId: r.callId,
147
+ ...(r.duplicate ? { duplicate: true } : {}),
148
+ ...(r.reconciliationRequired
149
+ ? {
150
+ reconciliationRequired: true,
151
+ warning:
152
+ 'the ledger is now HALTED: all further reservations refuse until a named human reconciles',
153
+ }
154
+ : {}),
155
+ });
156
+ }
157
+ if (name === 'sakk_status') {
158
+ const doc = readLedger({ ledgerPath, maxAgeMs: Number.POSITIVE_INFINITY });
159
+ const t = totalsOf(doc);
160
+ return ok({
161
+ cap_usd: t.capUsd,
162
+ settled_usd: t.settledUsd,
163
+ outstanding_usd: t.outstandingUsd,
164
+ committed_usd: t.committedUsd,
165
+ max_reservable_usd: maxReservableUsd(t),
166
+ entries: doc.entries.length,
167
+ reconciliation_required: doc.flags?.reconciliationRequired === true,
168
+ halted: doc.halt?.global ?? null,
169
+ halted_agents: doc.halt?.agents ?? {},
170
+ policy_hash: loaded?.policyHash ?? null,
171
+ });
172
+ }
173
+ return null; // unknown tool → protocol error upstream
174
+ } catch (e) {
175
+ if (e instanceof LedgerError || e instanceof PolicyError)
176
+ return refusal({ code: e.code, message: echo(e.message), details: safeDetails(e.details) });
177
+ throw e;
178
+ }
179
+ }
180
+
181
+ /** Start the server on the given streams. Exported for the scripted-client
182
+ * tests; bin/sakk.mjs wires process stdio. Startup validation (ledger path
183
+ * required, policy loadable) happens in the CALLER — by the time this runs,
184
+ * the configuration is known-good. */
185
+ export function startMcpServer({
186
+ ledgerPath,
187
+ loaded = null,
188
+ decisionLogPath,
189
+ input,
190
+ output,
191
+ version,
192
+ }) {
193
+ const send = (o) => output.write(`${JSON.stringify(o)}\n`);
194
+ const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
195
+ const rpcError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
196
+
197
+ let buf = '';
198
+ let dropping = false;
199
+ const handleLine = (line) => {
200
+ let replyId = null;
201
+ try {
202
+ replyId = handleFrame(line, (id) => (replyId = id));
203
+ } catch {
204
+ // one frame's failure never takes the gate down (F-82, RC-09)
205
+ rpcError(
206
+ replyId,
207
+ -32603,
208
+ 'internal error: this request was not handled; the server keeps serving',
209
+ );
210
+ }
211
+ };
212
+ const handleFrame = (line, setReplyId) => {
213
+ if (line.trim() === '') return;
214
+ let msg;
215
+ try {
216
+ msg = JSON.parse(line);
217
+ } catch {
218
+ rpcError(null, -32700, 'parse error: frames are one JSON-RPC object per line');
219
+ return;
220
+ }
221
+ // Review F5: a batch array or a bare JSON value is a valid parse but
222
+ // not a request object — "never a silent drop" (contract #5).
223
+ if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) {
224
+ rpcError(null, -32600, 'invalid request: one JSON-RPC object per line (batches unsupported)');
225
+ return;
226
+ }
227
+ const { id, method, params } = msg;
228
+ if (id === undefined) return; // notification — nothing to answer, nothing executed
229
+ // the envelope is checked before any value is used (F-82)
230
+ if (!(id === null || typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id))))
231
+ return rpcError(
232
+ null,
233
+ -32600,
234
+ 'invalid request: id must be a string, a finite number or null',
235
+ );
236
+ setReplyId(id);
237
+ if (typeof method !== 'string')
238
+ return rpcError(id, -32600, 'invalid request: method must be a string');
239
+ if (method === 'initialize')
240
+ return reply(id, {
241
+ protocolVersion:
242
+ typeof params?.protocolVersion === 'string' ? echo(params.protocolVersion) : '2024-11-05',
243
+ capabilities: { tools: {} },
244
+ serverInfo: { name: 'sakk', version },
245
+ });
246
+ if (method === 'ping') return reply(id, {});
247
+ if (method === 'tools/list') return reply(id, { tools: TOOLS });
248
+ if (method === 'tools/call') {
249
+ if (typeof params?.name !== 'string')
250
+ return rpcError(id, -32602, 'invalid params: the tool name must be a string');
251
+ const result = callTool({
252
+ name: params?.name,
253
+ args: params?.arguments ?? {},
254
+ ledgerPath,
255
+ loaded,
256
+ decisionLogPath,
257
+ });
258
+ if (result === null) return rpcError(id, -32602, `unknown tool: ${echo(params.name)}`);
259
+ return reply(id, result);
260
+ }
261
+ return rpcError(id, -32601, `unknown method: ${echo(method)}`);
262
+ };
263
+
264
+ input.setEncoding('utf8');
265
+ // a final line without a newline is still a request (MCP review N5)
266
+ input.on('end', () => {
267
+ if (!dropping && buf.trim() !== '') handleLine(buf);
268
+ buf = '';
269
+ });
270
+ input.on('data', (chunk) => {
271
+ buf += chunk;
272
+ let i;
273
+ while ((i = buf.indexOf('\n')) >= 0) {
274
+ const line = buf.slice(0, i);
275
+ buf = buf.slice(i + 1);
276
+ if (dropping) {
277
+ dropping = false; // the oversized frame ended at this newline
278
+ continue;
279
+ }
280
+ if (line.length > MAX_FRAME) {
281
+ // the whole giant line arrived within one drain — same refusal as
282
+ // the accumulating case below, never fed to the parser
283
+ rpcError(null, -32600, `frame exceeds ${MAX_FRAME} bytes`);
284
+ continue;
285
+ }
286
+ handleLine(line);
287
+ }
288
+ if (buf.length > MAX_FRAME) {
289
+ // Oversized frame: ONE protocol error on entering the drop state
290
+ // (review F11 — a 1GB flood is one refusal, not a thousand), discard
291
+ // until the next newline, keep serving.
292
+ if (!dropping) rpcError(null, -32600, `frame exceeds ${MAX_FRAME} bytes`);
293
+ buf = '';
294
+ dropping = true;
295
+ }
296
+ });
297
+ }
298
+
299
+ export { TOOLS };