@sakk-dev/sakk 0.0.0 → 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 +617 -10
- 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 +79 -5
- 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
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// Basic username + password sign-in for `sakk ui` (the maintainer, 2026-11-01: the most
|
|
4
|
+
// basic login, account and password; anything more is V2). Opt-in: with no
|
|
5
|
+
// credential file the console keeps its token-only behaviour.
|
|
6
|
+
//
|
|
7
|
+
// Storage follows the OWASP Password Storage cheat sheet: scrypt N=2^17, r=8,
|
|
8
|
+
// p=1 with a 16-byte random salt, kept in an owner-only file. Sessions follow
|
|
9
|
+
// the OWASP Session Management cheat sheet: a fresh 128-bit id per sign-in,
|
|
10
|
+
// held in memory only, with an idle and an absolute limit.
|
|
11
|
+
|
|
12
|
+
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
13
|
+
import {
|
|
14
|
+
closeSync,
|
|
15
|
+
constants as FS,
|
|
16
|
+
fstatSync,
|
|
17
|
+
lstatSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
openSync,
|
|
20
|
+
readSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { createInterface } from 'node:readline';
|
|
25
|
+
import { writeFileAtomic } from './append.mjs';
|
|
26
|
+
|
|
27
|
+
export const LOGIN_FILE = 'console-login.json';
|
|
28
|
+
/** The only parameters a credential file may carry (no silent downgrade). */
|
|
29
|
+
export const SCRYPT_PARAMS = Object.freeze({ N: 2 ** 17, r: 8, p: 1, keylen: 64 });
|
|
30
|
+
// scrypt needs 128 * N * r bytes (128 MiB here); Node's default cap is 32 MiB
|
|
31
|
+
const MAXMEM = 256 * 1024 * 1024;
|
|
32
|
+
const SALT_BYTES = 16;
|
|
33
|
+
const FILE_CAP = 4096;
|
|
34
|
+
export const USERNAME_MAX = 64;
|
|
35
|
+
// NIST SP 800-63B-4: a password used on its own is at least 15 characters
|
|
36
|
+
export const PASSWORD_MIN = 15;
|
|
37
|
+
export const PASSWORD_MAX = 1024;
|
|
38
|
+
export const SESSION_IDLE_MS = 30 * 60 * 1000;
|
|
39
|
+
export const SESSION_MAX_MS = 8 * 60 * 60 * 1000;
|
|
40
|
+
export const LOGIN_MAX_FAILURES = 5;
|
|
41
|
+
export const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
|
42
|
+
const MAX_SESSIONS = 16;
|
|
43
|
+
|
|
44
|
+
export class ConsoleLoginError extends Error {
|
|
45
|
+
constructor(code, message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.code = code;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const loginPath = (home) => join(home, LOGIN_FILE);
|
|
52
|
+
|
|
53
|
+
/** A username is 1–64 printable characters, no spaces at either end. */
|
|
54
|
+
export function checkUsername(u) {
|
|
55
|
+
if (
|
|
56
|
+
typeof u !== 'string' ||
|
|
57
|
+
u.length < 1 ||
|
|
58
|
+
u.length > USERNAME_MAX ||
|
|
59
|
+
u.trim() !== u ||
|
|
60
|
+
/\p{Cc}/u.test(u)
|
|
61
|
+
)
|
|
62
|
+
throw new ConsoleLoginError(
|
|
63
|
+
'UI_USERNAME_INVALID',
|
|
64
|
+
`a username is 1-${USERNAME_MAX} characters, no control characters or edge spaces`,
|
|
65
|
+
);
|
|
66
|
+
return u;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A password is 15–1024 characters after NFKC normalisation. */
|
|
70
|
+
export function checkPassword(p) {
|
|
71
|
+
if (typeof p !== 'string')
|
|
72
|
+
throw new ConsoleLoginError('UI_PASSWORD_INVALID', 'a password is text');
|
|
73
|
+
const n = p.normalize('NFKC');
|
|
74
|
+
if (n.length < PASSWORD_MIN || n.length > PASSWORD_MAX)
|
|
75
|
+
throw new ConsoleLoginError(
|
|
76
|
+
'UI_PASSWORD_INVALID',
|
|
77
|
+
`a password is ${PASSWORD_MIN}-${PASSWORD_MAX} characters`,
|
|
78
|
+
);
|
|
79
|
+
return n;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const derive = (password, salt, params) =>
|
|
83
|
+
new Promise((resolve, reject) =>
|
|
84
|
+
scrypt(
|
|
85
|
+
Buffer.from(password.normalize('NFKC')),
|
|
86
|
+
salt,
|
|
87
|
+
params.keylen,
|
|
88
|
+
{ N: params.N, r: params.r, p: params.p, maxmem: MAXMEM },
|
|
89
|
+
(e, key) => (e ? reject(e) : resolve(key)),
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/** Hash and store the credential in an owner-only file under the Sakk home. */
|
|
94
|
+
export async function setConsoleLogin({ home, username, password }) {
|
|
95
|
+
checkUsername(username);
|
|
96
|
+
const pw = checkPassword(password);
|
|
97
|
+
const salt = randomBytes(SALT_BYTES);
|
|
98
|
+
const hash = await derive(pw, salt, SCRYPT_PARAMS);
|
|
99
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
100
|
+
const path = loginPath(home);
|
|
101
|
+
const record = {
|
|
102
|
+
v: 1,
|
|
103
|
+
username,
|
|
104
|
+
salt: salt.toString('hex'),
|
|
105
|
+
hash: hash.toString('hex'),
|
|
106
|
+
params: { ...SCRYPT_PARAMS },
|
|
107
|
+
};
|
|
108
|
+
writeFileAtomic(path, JSON.stringify(record, null, 1) + '\n', 0o600);
|
|
109
|
+
return { path };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Remove the credential; the console is token-only again from its next start. */
|
|
113
|
+
export function clearConsoleLogin({ home }) {
|
|
114
|
+
const path = loginPath(home);
|
|
115
|
+
let existed = true;
|
|
116
|
+
try {
|
|
117
|
+
lstatSync(path);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (e.code !== 'ENOENT') throw e;
|
|
120
|
+
existed = false;
|
|
121
|
+
}
|
|
122
|
+
rmSync(path, { force: true });
|
|
123
|
+
return { path, removed: existed };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const unsafe = (path, why) =>
|
|
127
|
+
new ConsoleLoginError(
|
|
128
|
+
'CONSOLE_LOGIN_UNSAFE',
|
|
129
|
+
`${path} ${why} — fix it or run sakk ui --clear-password, then start again`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The stored credential, or null when sign-in is off (no file). A file that
|
|
134
|
+
* exists but cannot be trusted (a link, shared, someone else's, malformed)
|
|
135
|
+
* refuses the start rather than falling back to token-only silently.
|
|
136
|
+
*/
|
|
137
|
+
export function loadConsoleLogin({ home }) {
|
|
138
|
+
const path = loginPath(home);
|
|
139
|
+
let st;
|
|
140
|
+
try {
|
|
141
|
+
st = lstatSync(path);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (e.code === 'ENOENT') return null;
|
|
144
|
+
throw new ConsoleLoginError('CONSOLE_LOGIN_UNREADABLE', `${path}: ${e.message}`);
|
|
145
|
+
}
|
|
146
|
+
if (st.isSymbolicLink()) throw unsafe(path, 'is a symlink');
|
|
147
|
+
let fd;
|
|
148
|
+
try {
|
|
149
|
+
fd = openSync(path, FS.O_RDONLY | (FS.O_NOFOLLOW ?? 0) | (FS.O_NONBLOCK ?? 0));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
throw new ConsoleLoginError('CONSOLE_LOGIN_UNREADABLE', `${path}: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
const f = fstatSync(fd);
|
|
156
|
+
if (!f.isFile()) throw unsafe(path, 'is not a regular file');
|
|
157
|
+
if (f.nlink !== 1) throw unsafe(path, 'has more than one name (a hard link)');
|
|
158
|
+
if ((f.mode & 0o077) !== 0) throw unsafe(path, 'is readable or writable by others (want 0600)');
|
|
159
|
+
if (typeof process.getuid === 'function' && f.uid !== process.getuid())
|
|
160
|
+
throw unsafe(path, 'belongs to another user');
|
|
161
|
+
if (f.size > FILE_CAP) throw unsafe(path, `is over ${FILE_CAP} bytes`);
|
|
162
|
+
const buf = Buffer.alloc(FILE_CAP + 1);
|
|
163
|
+
let n = 0;
|
|
164
|
+
for (;;) {
|
|
165
|
+
const r = readSync(fd, buf, n, buf.length - n, null);
|
|
166
|
+
if (r <= 0) break;
|
|
167
|
+
n += r;
|
|
168
|
+
if (n > FILE_CAP) throw unsafe(path, `is over ${FILE_CAP} bytes`);
|
|
169
|
+
}
|
|
170
|
+
text = buf.subarray(0, n).toString('utf8');
|
|
171
|
+
} finally {
|
|
172
|
+
closeSync(fd);
|
|
173
|
+
}
|
|
174
|
+
const bad = () =>
|
|
175
|
+
new ConsoleLoginError(
|
|
176
|
+
'CONSOLE_LOGIN_INVALID',
|
|
177
|
+
`${path} is not a credential sakk ui --set-password wrote — run it again`,
|
|
178
|
+
);
|
|
179
|
+
let rec;
|
|
180
|
+
try {
|
|
181
|
+
rec = JSON.parse(text);
|
|
182
|
+
} catch {
|
|
183
|
+
throw bad();
|
|
184
|
+
}
|
|
185
|
+
const p = rec?.params;
|
|
186
|
+
if (
|
|
187
|
+
rec?.v !== 1 ||
|
|
188
|
+
typeof rec.salt !== 'string' ||
|
|
189
|
+
!/^[0-9a-f]{32}$/.test(rec.salt) ||
|
|
190
|
+
typeof rec.hash !== 'string' ||
|
|
191
|
+
!/^[0-9a-f]{128}$/.test(rec.hash) ||
|
|
192
|
+
!p ||
|
|
193
|
+
Object.keys(SCRYPT_PARAMS).some((k) => p[k] !== SCRYPT_PARAMS[k])
|
|
194
|
+
)
|
|
195
|
+
throw bad();
|
|
196
|
+
try {
|
|
197
|
+
checkUsername(rec.username);
|
|
198
|
+
} catch {
|
|
199
|
+
throw bad();
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
username: rec.username,
|
|
203
|
+
salt: Buffer.from(rec.salt, 'hex'),
|
|
204
|
+
hash: Buffer.from(rec.hash, 'hex'),
|
|
205
|
+
params: { ...SCRYPT_PARAMS },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const digest = (s) => createHash('sha256').update(String(s), 'utf8').digest();
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Constant-time check of a username and password: the scrypt runs for a wrong
|
|
213
|
+
* username too, and both comparisons are fixed-length, so a wrong user and a
|
|
214
|
+
* wrong password take the same path and get the same answer.
|
|
215
|
+
*/
|
|
216
|
+
export async function verifyConsoleLogin(rec, username, password) {
|
|
217
|
+
const userOk = timingSafeEqual(digest(username), digest(rec.username));
|
|
218
|
+
const key = await derive(String(password), rec.salt, rec.params);
|
|
219
|
+
const passOk = timingSafeEqual(key, rec.hash);
|
|
220
|
+
return userOk && passOk;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Sessions and the failed-sign-in limit for one console run, all in memory.
|
|
225
|
+
* `now` and `verify` are injectable so the limits are tested by counting,
|
|
226
|
+
* not by waiting on scrypt or the clock.
|
|
227
|
+
*/
|
|
228
|
+
export function createLoginGate({
|
|
229
|
+
rec,
|
|
230
|
+
now = Date.now,
|
|
231
|
+
log = () => {},
|
|
232
|
+
verify = verifyConsoleLogin,
|
|
233
|
+
}) {
|
|
234
|
+
const sessions = new Map(); // sha256(id) → { created, seen }
|
|
235
|
+
let failures = []; // times of failed (or in-flight) attempts in the window
|
|
236
|
+
let busy = false;
|
|
237
|
+
const key = (id) => digest(id).toString('hex');
|
|
238
|
+
const prune = (t) => {
|
|
239
|
+
failures = failures.filter((f) => t - f < LOGIN_WINDOW_MS);
|
|
240
|
+
};
|
|
241
|
+
return {
|
|
242
|
+
/** True for a live session id this run issued; refreshes its idle clock. */
|
|
243
|
+
sessionOk(id) {
|
|
244
|
+
if (typeof id !== 'string' || !/^[0-9a-f]{32}$/.test(id)) return false;
|
|
245
|
+
const k = key(id);
|
|
246
|
+
const s = sessions.get(k);
|
|
247
|
+
if (!s) return false;
|
|
248
|
+
const t = now();
|
|
249
|
+
if (t - s.created >= SESSION_MAX_MS || t - s.seen >= SESSION_IDLE_MS) {
|
|
250
|
+
sessions.delete(k);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
s.seen = t;
|
|
254
|
+
return true;
|
|
255
|
+
},
|
|
256
|
+
end(id) {
|
|
257
|
+
if (typeof id === 'string') sessions.delete(key(id));
|
|
258
|
+
},
|
|
259
|
+
get sessions() {
|
|
260
|
+
return sessions.size;
|
|
261
|
+
},
|
|
262
|
+
get failures() {
|
|
263
|
+
prune(now());
|
|
264
|
+
return failures.length;
|
|
265
|
+
},
|
|
266
|
+
async signIn(username, password) {
|
|
267
|
+
const t = now();
|
|
268
|
+
prune(t);
|
|
269
|
+
if (failures.length >= LOGIN_MAX_FAILURES) {
|
|
270
|
+
const retryAfter = Math.max(1, Math.ceil((failures[0] + LOGIN_WINDOW_MS - t) / 1000));
|
|
271
|
+
log(`sign-in refused: locked for ${retryAfter}s after ${LOGIN_MAX_FAILURES} failures`);
|
|
272
|
+
return { locked: true, retryAfter };
|
|
273
|
+
}
|
|
274
|
+
if (busy) return { busy: true };
|
|
275
|
+
busy = true;
|
|
276
|
+
failures.push(t); // the slot is taken before the check, so a burst cannot overrun
|
|
277
|
+
let ok = false;
|
|
278
|
+
try {
|
|
279
|
+
ok = await verify(rec, username, password);
|
|
280
|
+
} finally {
|
|
281
|
+
busy = false;
|
|
282
|
+
}
|
|
283
|
+
if (!ok) {
|
|
284
|
+
prune(now());
|
|
285
|
+
log(
|
|
286
|
+
`sign-in failed (${failures.length} of ${LOGIN_MAX_FAILURES} in ${LOGIN_WINDOW_MS / 60000} min)`,
|
|
287
|
+
);
|
|
288
|
+
return { ok: false };
|
|
289
|
+
}
|
|
290
|
+
failures.splice(failures.indexOf(t), 1);
|
|
291
|
+
const id = randomBytes(16).toString('hex'); // 128-bit, fresh per sign-in
|
|
292
|
+
const at = now();
|
|
293
|
+
sessions.set(key(id), { created: at, seen: at });
|
|
294
|
+
while (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value);
|
|
295
|
+
return { ok: true, id };
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const needsTty = () =>
|
|
301
|
+
new ConsoleLoginError(
|
|
302
|
+
'UI_PASSWORD_NEEDS_TTY',
|
|
303
|
+
'set the password at a terminal: sakk ui --set-password asks for it and does not echo it',
|
|
304
|
+
);
|
|
305
|
+
const isTerminal = (input) => Boolean(input.isTTY) && typeof input.setRawMode === 'function';
|
|
306
|
+
|
|
307
|
+
/** One visible line (the username), echoed by the terminal itself. */
|
|
308
|
+
const askLine = ({ input, output, prompt }) =>
|
|
309
|
+
new Promise((resolve) => {
|
|
310
|
+
const rl = createInterface({ input, output, terminal: false });
|
|
311
|
+
rl.question(prompt, (a) => {
|
|
312
|
+
rl.close();
|
|
313
|
+
resolve(a.slice(0, USERNAME_MAX + 1));
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* `sakk ui --set-password`: a person at a terminal types a username, then the
|
|
319
|
+
* password twice without echo. Nothing is saved unless the two match.
|
|
320
|
+
*/
|
|
321
|
+
export async function askConsoleLogin({ home, input = process.stdin, output = process.stderr }) {
|
|
322
|
+
if (!isTerminal(input)) throw needsTty();
|
|
323
|
+
const username = checkUsername(await askLine({ input, output, prompt: 'Console username: ' }));
|
|
324
|
+
const password = checkPassword(
|
|
325
|
+
await promptHidden({
|
|
326
|
+
input,
|
|
327
|
+
output,
|
|
328
|
+
prompt: `Password (${PASSWORD_MIN} or more characters, not shown): `,
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
const again = await promptHidden({ input, output, prompt: 'The same password again: ' });
|
|
332
|
+
if (again.normalize('NFKC') !== password)
|
|
333
|
+
throw new ConsoleLoginError(
|
|
334
|
+
'UI_PASSWORD_MISMATCH',
|
|
335
|
+
'the two passwords differ; nothing was saved',
|
|
336
|
+
);
|
|
337
|
+
return { ...(await setConsoleLogin({ home, username, password })), username };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Read one line from a terminal without echoing it. Refuses anything that is
|
|
342
|
+
* not a TTY, so a password is never piped in, logged or put on argv.
|
|
343
|
+
* Echo goes off before the prompt is shown, as Python's getpass does: someone
|
|
344
|
+
* (or a driver such as expect) who answers the moment the prompt appears must
|
|
345
|
+
* not land a key while echo is still on (F-240).
|
|
346
|
+
*/
|
|
347
|
+
export function promptHidden({ input = process.stdin, output = process.stderr, prompt }) {
|
|
348
|
+
if (!isTerminal(input)) return Promise.reject(needsTty());
|
|
349
|
+
return new Promise((resolve, reject) => {
|
|
350
|
+
let buf = '';
|
|
351
|
+
input.setRawMode(true);
|
|
352
|
+
output.write(prompt);
|
|
353
|
+
input.resume();
|
|
354
|
+
input.setEncoding('utf8');
|
|
355
|
+
const done = (err, value) => {
|
|
356
|
+
input.setRawMode(false);
|
|
357
|
+
input.pause();
|
|
358
|
+
input.removeListener('data', onData);
|
|
359
|
+
output.write('\n');
|
|
360
|
+
if (err) reject(err);
|
|
361
|
+
else resolve(value);
|
|
362
|
+
};
|
|
363
|
+
const onData = (chunk) => {
|
|
364
|
+
for (const ch of chunk) {
|
|
365
|
+
if (ch === '\r' || ch === '\n') return done(null, buf);
|
|
366
|
+
if (ch === '\u0003' || ch === '\u0004')
|
|
367
|
+
return done(new ConsoleLoginError('UI_PASSWORD_CANCELLED', 'cancelled'));
|
|
368
|
+
if (ch === '\u007f' || ch === '\b') buf = buf.slice(0, -1);
|
|
369
|
+
else if (buf.length <= PASSWORD_MAX) buf += ch;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
input.on('data', onData);
|
|
373
|
+
});
|
|
374
|
+
}
|