@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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LiteLLM gateway's own outage record, written by
|
|
3
|
+
* integrations/litellm/sakk_gateway.py, read here for `sakk status` and the
|
|
4
|
+
* console. Read-only. The file is the gateway's report about itself: calls it
|
|
5
|
+
* let through while Sakk could not answer are in this file, never in the
|
|
6
|
+
* ledger, so nothing here is presented as a ledger record.
|
|
7
|
+
*/
|
|
8
|
+
import { openSync, fstatSync, readSync, closeSync, realpathSync, constants as FS } from 'node:fs';
|
|
9
|
+
import { request as httpRequest } from 'node:http';
|
|
10
|
+
import { request as httpsRequest } from 'node:https';
|
|
11
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const GATEWAY_STATUS_MAX_BYTES = 64 * 1024;
|
|
14
|
+
export const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:4000';
|
|
15
|
+
const SOURCE = 'from the gateway status file';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The same rule as status_path_for in sakk_gateway.py: the ledger's folder
|
|
19
|
+
* with every link resolved, the ledger's file name, then ".gateway.json". It
|
|
20
|
+
* mirrors the folder half of canonicalLedgerPath in ledger.mjs (not exported,
|
|
21
|
+
* and it also refuses a linked ledger file, which is not this reader's job),
|
|
22
|
+
* so a relative path or a path through a linked folder names the one file the
|
|
23
|
+
* gateway writes. A folder that does not exist falls back to the absolute path;
|
|
24
|
+
* no ledger, and so no gateway, can be there either.
|
|
25
|
+
*/
|
|
26
|
+
export function gatewayStatusPathFor(ledgerPath, env = process.env) {
|
|
27
|
+
if (env.SAKK_GATEWAY_STATUS) return env.SAKK_GATEWAY_STATUS;
|
|
28
|
+
const abs = resolve(ledgerPath);
|
|
29
|
+
let dir;
|
|
30
|
+
try {
|
|
31
|
+
dir = realpathSync(dirname(abs));
|
|
32
|
+
} catch {
|
|
33
|
+
dir = dirname(abs);
|
|
34
|
+
}
|
|
35
|
+
return `${join(dir, basename(abs))}.gateway.json`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
39
|
+
const count = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
40
|
+
const amount = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
41
|
+
const text = (v) => typeof v === 'string';
|
|
42
|
+
|
|
43
|
+
function outageOf(v, ended) {
|
|
44
|
+
if (v === null || v === undefined) return null;
|
|
45
|
+
if (
|
|
46
|
+
!isObj(v) ||
|
|
47
|
+
!text(v.since) ||
|
|
48
|
+
!count(v.allowed_calls) ||
|
|
49
|
+
!amount(v.est_max_unrecorded_usd) ||
|
|
50
|
+
!text(v.last_reason ?? '') ||
|
|
51
|
+
!text(v.last_at ?? '') ||
|
|
52
|
+
(ended && !text(v.ended_at))
|
|
53
|
+
)
|
|
54
|
+
throw new TypeError(ended ? 'last_outage' : 'outage');
|
|
55
|
+
const o = {
|
|
56
|
+
since: v.since,
|
|
57
|
+
allowed_calls: v.allowed_calls,
|
|
58
|
+
est_max_unrecorded_usd: v.est_max_unrecorded_usd,
|
|
59
|
+
last_reason: v.last_reason ?? '',
|
|
60
|
+
last_at: v.last_at ?? '',
|
|
61
|
+
};
|
|
62
|
+
if (ended) o.ended_at = v.ended_at;
|
|
63
|
+
return o;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readCapped(path, maxBytes) {
|
|
67
|
+
// O_NOFOLLOW: a link planted at the status path is an error, not a redirect.
|
|
68
|
+
// O_NONBLOCK: a FIFO there cannot hang the reader.
|
|
69
|
+
const fd = openSync(path, FS.O_RDONLY | FS.O_NONBLOCK | FS.O_NOFOLLOW);
|
|
70
|
+
try {
|
|
71
|
+
const st = fstatSync(fd);
|
|
72
|
+
if (!st.isFile()) return { error: 'GATEWAY_STATUS_UNREADABLE', message: 'not a regular file' };
|
|
73
|
+
if (st.size > maxBytes)
|
|
74
|
+
return {
|
|
75
|
+
error: 'GATEWAY_STATUS_TOO_BIG',
|
|
76
|
+
message: `the status file is ${st.size} bytes, over the ${maxBytes}-byte limit — not read`,
|
|
77
|
+
};
|
|
78
|
+
const buf = Buffer.alloc(st.size);
|
|
79
|
+
let n = 0;
|
|
80
|
+
while (n < st.size) {
|
|
81
|
+
const r = readSync(fd, buf, n, st.size - n, n);
|
|
82
|
+
if (r === 0) break;
|
|
83
|
+
n += r;
|
|
84
|
+
}
|
|
85
|
+
return { text: buf.subarray(0, n).toString('utf8') };
|
|
86
|
+
} finally {
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* null when there is no status file (no gateway has written one). Otherwise
|
|
93
|
+
* the file's fields, or { path, error, message } when it cannot be trusted.
|
|
94
|
+
*/
|
|
95
|
+
export function gatewayStatus({
|
|
96
|
+
ledgerPath,
|
|
97
|
+
env = process.env,
|
|
98
|
+
maxBytes = GATEWAY_STATUS_MAX_BYTES,
|
|
99
|
+
} = {}) {
|
|
100
|
+
if (!ledgerPath && !env.SAKK_GATEWAY_STATUS) return null;
|
|
101
|
+
const path = gatewayStatusPathFor(ledgerPath, env);
|
|
102
|
+
let got;
|
|
103
|
+
try {
|
|
104
|
+
got = readCapped(path, maxBytes);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (e.code === 'ENOENT') return null;
|
|
107
|
+
return {
|
|
108
|
+
path,
|
|
109
|
+
source: SOURCE,
|
|
110
|
+
error: 'GATEWAY_STATUS_UNREADABLE',
|
|
111
|
+
message: `${e.code ?? 'error'}: cannot read the gateway status file`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (got.error) return { path, source: SOURCE, ...got };
|
|
115
|
+
try {
|
|
116
|
+
const doc = JSON.parse(got.text);
|
|
117
|
+
if (!isObj(doc) || doc.version !== 1) throw new TypeError('version');
|
|
118
|
+
if (!text(doc.updatedAt)) throw new TypeError('updatedAt');
|
|
119
|
+
if (doc.mode !== undefined && doc.mode !== 'allow' && doc.mode !== 'deny')
|
|
120
|
+
throw new TypeError('mode');
|
|
121
|
+
return {
|
|
122
|
+
path,
|
|
123
|
+
source: SOURCE,
|
|
124
|
+
version: 1,
|
|
125
|
+
updatedAt: doc.updatedAt,
|
|
126
|
+
mode: doc.mode ?? null,
|
|
127
|
+
outage: outageOf(doc.outage, false),
|
|
128
|
+
last_outage: outageOf(doc.last_outage, true),
|
|
129
|
+
};
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return {
|
|
132
|
+
path,
|
|
133
|
+
source: SOURCE,
|
|
134
|
+
error: 'GATEWAY_STATUS_INVALID',
|
|
135
|
+
message: `the gateway status file is not the expected shape (${e instanceof SyntaxError ? 'not JSON' : e.message})`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* GET <url>/health/liveliness, which LiteLLM serves without a key. Resolves,
|
|
142
|
+
* never rejects: { url, ok, status } or { url, ok: false, error }. The timer
|
|
143
|
+
* caps the whole exchange, not just socket idle time.
|
|
144
|
+
*/
|
|
145
|
+
export function probeGateway({ url = DEFAULT_GATEWAY_URL, timeoutMs = 500 } = {}) {
|
|
146
|
+
let target;
|
|
147
|
+
try {
|
|
148
|
+
target = new URL(`${String(url).replace(/\/+$/, '')}/health/liveliness`);
|
|
149
|
+
} catch {
|
|
150
|
+
return Promise.resolve({ url: String(url), ok: false, error: 'GATEWAY_URL_INVALID' });
|
|
151
|
+
}
|
|
152
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:')
|
|
153
|
+
return Promise.resolve({ url: String(url), ok: false, error: 'GATEWAY_URL_INVALID' });
|
|
154
|
+
const send = target.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
let done = false;
|
|
157
|
+
let req = null;
|
|
158
|
+
const finish = (r) => {
|
|
159
|
+
if (done) return;
|
|
160
|
+
done = true;
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
req?.destroy();
|
|
163
|
+
resolve({ url: String(url), ...r });
|
|
164
|
+
};
|
|
165
|
+
const timer = setTimeout(() => finish({ ok: false, error: 'timeout' }), timeoutMs);
|
|
166
|
+
timer.unref?.();
|
|
167
|
+
try {
|
|
168
|
+
req = send(target, { method: 'GET', timeout: timeoutMs }, (res) => {
|
|
169
|
+
res.resume();
|
|
170
|
+
finish({ ok: res.statusCode === 200, status: res.statusCode });
|
|
171
|
+
});
|
|
172
|
+
req.on('error', (e) => finish({ ok: false, error: e.code ?? 'error' }));
|
|
173
|
+
req.on('timeout', () => finish({ ok: false, error: 'timeout' }));
|
|
174
|
+
req.end();
|
|
175
|
+
} catch (e) {
|
|
176
|
+
finish({ ok: false, error: e.code ?? 'error' });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The console's /api/gateway answer. The probe runs only when a status file
|
|
183
|
+
* exists, and one result (or the one in flight) is shared for cacheMs, so
|
|
184
|
+
* page loads add at most one probe per window.
|
|
185
|
+
*/
|
|
186
|
+
export function gatewayView({
|
|
187
|
+
ledgerPath,
|
|
188
|
+
env = process.env,
|
|
189
|
+
now = Date.now,
|
|
190
|
+
probe = probeGateway,
|
|
191
|
+
cacheMs = 5000,
|
|
192
|
+
}) {
|
|
193
|
+
let cached = null;
|
|
194
|
+
return async () => {
|
|
195
|
+
const status = gatewayStatus({ ledgerPath, env });
|
|
196
|
+
if (!status) return { source: SOURCE, status: null, probe: null };
|
|
197
|
+
const url = env.SAKK_GATEWAY_URL || DEFAULT_GATEWAY_URL;
|
|
198
|
+
const t = now();
|
|
199
|
+
if (!cached || cached.url !== url || t - cached.at >= cacheMs)
|
|
200
|
+
cached = { at: t, url, result: probe({ url }) };
|
|
201
|
+
return { source: SOURCE, status, probe: await cached.result };
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// `sakk guard install|uninstall <agent>` and `sakk doctor` (specs/guard/spec.md,
|
|
4
|
+
// "Install" and "Doctor"). Install merges into the agent's own hook config:
|
|
5
|
+
// other tools' hooks are never touched, and the guard's OWN entries converge to
|
|
6
|
+
// the canonical form (F-206) — a reinstall after a host upgrade repairs a stale
|
|
7
|
+
// path, a missing onFailure or an alternate spelling instead of reading the
|
|
8
|
+
// degraded entry as "already protected" and changing nothing. Uninstall removes
|
|
9
|
+
// only the guard's own entries. The doctor checks that the hook would really
|
|
10
|
+
// fire, not only that it is configured (F-132: a hook can be configured and
|
|
11
|
+
// never run).
|
|
12
|
+
|
|
13
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
14
|
+
import {
|
|
15
|
+
accessSync,
|
|
16
|
+
constants,
|
|
17
|
+
copyFileSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
realpathSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { loadPolicy } from './policy.mjs';
|
|
28
|
+
import { AGENTS, DOCTOR_COMMAND, SHIPPED_POLICY, repoRootOf, sakkHome } from './hook.mjs';
|
|
29
|
+
import { AGENT_CONFIG } from './classify.mjs';
|
|
30
|
+
|
|
31
|
+
export const BIN = realpathSync(
|
|
32
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'sakk.mjs'),
|
|
33
|
+
);
|
|
34
|
+
const q = (s) => `"${s.replace(/(["\\$`])/g, '\\$1')}"`;
|
|
35
|
+
/** The installed command line: absolute node and sakk, so PATH at hook time does not matter. */
|
|
36
|
+
export const hookCommand = (agent, node = process.execPath) =>
|
|
37
|
+
`${q(node)} ${q(BIN)} hook --agent ${agent}`;
|
|
38
|
+
/**
|
|
39
|
+
* Ours: the command names this kind of hook, whichever installation wrote it —
|
|
40
|
+
* the canonical quoted absolute path, a stale path from an older location, or
|
|
41
|
+
* the bare `sakk` of a PATH install (F-206: an unrecognised spelling made
|
|
42
|
+
* uninstall leave it and install stack a duplicate or read it as protection).
|
|
43
|
+
* `sakk` must stand as its own word: `mysakk` and a mere directory named
|
|
44
|
+
* `sakk/` are someone else's.
|
|
45
|
+
*/
|
|
46
|
+
const ours = (cmd, agent) =>
|
|
47
|
+
typeof cmd === 'string' &&
|
|
48
|
+
cmd.includes(`hook --agent ${agent}`) &&
|
|
49
|
+
/(^|[\s"'=/\\])sakk(\.mjs)?["']?(\s|$)/.test(cmd);
|
|
50
|
+
|
|
51
|
+
export const CLAUDE_MATCHER =
|
|
52
|
+
'Bash|PowerShell|Monitor|Read|Edit|Write|MultiEdit|NotebookEdit|mcp__.*';
|
|
53
|
+
export const CURSOR_EVENTS = Object.freeze({
|
|
54
|
+
preToolUse: 'Write|Delete', // edits; shell, reads and MCP have their own events below
|
|
55
|
+
beforeShellExecution: null,
|
|
56
|
+
beforeReadFile: null,
|
|
57
|
+
beforeMCPExecution: null,
|
|
58
|
+
});
|
|
59
|
+
export const CODEX_MATCHER = 'Bash|apply_patch|mcp__.*';
|
|
60
|
+
|
|
61
|
+
/** Where each agent reads hooks: the project (repository top) or the user's home. */
|
|
62
|
+
export function configPath(agent, { user = false, cwd = process.cwd(), home = homedir() } = {}) {
|
|
63
|
+
const base = user ? home : (repoRootOf(cwd) ?? cwd);
|
|
64
|
+
if (Object.hasOwn(AGENT_CONFIG, agent)) return join(base, AGENT_CONFIG[agent]);
|
|
65
|
+
throw new Error(`unknown agent ${agent}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Claude Code supports onFailure: "block" from 2.1.295; older versions would not know the key. */
|
|
69
|
+
export function claudeSupportsOnFailure(
|
|
70
|
+
run = () =>
|
|
71
|
+
execFileSync('claude', ['--version'], {
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
74
|
+
}),
|
|
75
|
+
) {
|
|
76
|
+
try {
|
|
77
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(run());
|
|
78
|
+
if (!m) return false;
|
|
79
|
+
const [a, b, c] = m.slice(1).map(Number);
|
|
80
|
+
return a > 2 || (a === 2 && (b > 1 || (b === 1 && c >= 295)));
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const readJson = (p) => {
|
|
87
|
+
if (!existsSync(p)) return {};
|
|
88
|
+
const raw = readFileSync(p, 'utf8');
|
|
89
|
+
if (raw.trim() === '') return {};
|
|
90
|
+
try {
|
|
91
|
+
const v = JSON.parse(raw);
|
|
92
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) throw new Error('not an object');
|
|
93
|
+
return v;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
const err = new Error(
|
|
96
|
+
`GUARD_CONFIG_UNREADABLE: ${p} is not a JSON object (${e.message}) — fix it by hand; nothing was changed`,
|
|
97
|
+
);
|
|
98
|
+
err.code = 'GUARD_CONFIG_UNREADABLE';
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The config with the guard added (pure). Convergence, not presence (F-206):
|
|
105
|
+
* the guard's own entries are stripped and the canonical entry appended, so a
|
|
106
|
+
* reinstall repairs a stale path, a duplicate, an alternate spelling or a
|
|
107
|
+
* missing onFailure instead of reading a degraded entry as already protected.
|
|
108
|
+
* An identical reinstall still changes nothing, and an existing fail-closed
|
|
109
|
+
* entry is never downgraded to fail-open.
|
|
110
|
+
*/
|
|
111
|
+
export function withGuard(agent, cfg, { command, onFailure = false }) {
|
|
112
|
+
if (agent === 'claude-code' || agent === 'codex') {
|
|
113
|
+
const keepClosed = (cfg.hooks?.PreToolUse ?? []).some((g) =>
|
|
114
|
+
(g.hooks ?? []).some((h) => ours(h.command, agent) && h.onFailure === 'block'),
|
|
115
|
+
);
|
|
116
|
+
const c = withoutGuard(agent, cfg);
|
|
117
|
+
c.hooks ??= {};
|
|
118
|
+
c.hooks.PreToolUse ??= [];
|
|
119
|
+
c.hooks.PreToolUse.push({
|
|
120
|
+
matcher: agent === 'codex' ? CODEX_MATCHER : CLAUDE_MATCHER,
|
|
121
|
+
hooks: [
|
|
122
|
+
{
|
|
123
|
+
type: 'command',
|
|
124
|
+
command,
|
|
125
|
+
timeout: 10,
|
|
126
|
+
...(onFailure || keepClosed ? { onFailure: 'block' } : {}),
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
return c;
|
|
131
|
+
}
|
|
132
|
+
const c = withoutGuard(agent, cfg);
|
|
133
|
+
c.version ??= 1;
|
|
134
|
+
c.hooks ??= {};
|
|
135
|
+
for (const [event, matcher] of Object.entries(CURSOR_EVENTS)) {
|
|
136
|
+
c.hooks[event] ??= [];
|
|
137
|
+
c.hooks[event].push({
|
|
138
|
+
command,
|
|
139
|
+
timeout: 10,
|
|
140
|
+
failClosed: true,
|
|
141
|
+
...(matcher ? { matcher } : {}),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return c;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The config with only the guard's own entries removed (pure). */
|
|
148
|
+
export function withoutGuard(agent, cfg) {
|
|
149
|
+
const c = structuredClone(cfg);
|
|
150
|
+
if (!c.hooks) return c;
|
|
151
|
+
if (agent === 'claude-code' || agent === 'codex') {
|
|
152
|
+
c.hooks.PreToolUse = (c.hooks.PreToolUse ?? [])
|
|
153
|
+
.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !ours(h.command, agent)) }))
|
|
154
|
+
.filter((g) => g.hooks.length > 0);
|
|
155
|
+
if (c.hooks.PreToolUse.length === 0) delete c.hooks.PreToolUse;
|
|
156
|
+
} else
|
|
157
|
+
for (const event of Object.keys(CURSOR_EVENTS)) {
|
|
158
|
+
if (!c.hooks[event]) continue;
|
|
159
|
+
c.hooks[event] = c.hooks[event].filter((h) => !ours(h.command, agent));
|
|
160
|
+
if (c.hooks[event].length === 0) delete c.hooks[event];
|
|
161
|
+
}
|
|
162
|
+
if (Object.keys(c.hooks).length === 0) delete c.hooks;
|
|
163
|
+
return c;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function writeConfig(path, before, after) {
|
|
167
|
+
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
168
|
+
if (!changed) return { path, changed: false };
|
|
169
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
170
|
+
let backup = null;
|
|
171
|
+
if (existsSync(path)) {
|
|
172
|
+
backup = `${path}.sakk-backup`;
|
|
173
|
+
copyFileSync(path, backup);
|
|
174
|
+
}
|
|
175
|
+
writeFileSync(path, `${JSON.stringify(after, null, 2)}\n`);
|
|
176
|
+
return { path, changed: true, backup };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function install(agent, opts = {}) {
|
|
180
|
+
if (!AGENTS.includes(agent))
|
|
181
|
+
throw new Error(`GUARD_USAGE: agent must be one of ${AGENTS.join(', ')}`);
|
|
182
|
+
const path = configPath(agent, opts);
|
|
183
|
+
const before = readJson(path);
|
|
184
|
+
const onFailure =
|
|
185
|
+
agent === 'claude-code' && (opts.supportsOnFailure ?? claudeSupportsOnFailure());
|
|
186
|
+
const after = withGuard(agent, before, {
|
|
187
|
+
command: opts.command ?? hookCommand(agent),
|
|
188
|
+
onFailure,
|
|
189
|
+
});
|
|
190
|
+
return { ...writeConfig(path, before, after), onFailure };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function uninstall(agent, opts = {}) {
|
|
194
|
+
if (!AGENTS.includes(agent))
|
|
195
|
+
throw new Error(`GUARD_USAGE: agent must be one of ${AGENTS.join(', ')}`);
|
|
196
|
+
const path = configPath(agent, opts);
|
|
197
|
+
if (!existsSync(path)) return { path, changed: false };
|
|
198
|
+
const before = readJson(path);
|
|
199
|
+
const after = withoutGuard(agent, before);
|
|
200
|
+
// removing the guard loosens it, so the CLI first asks whether it would change
|
|
201
|
+
// anything, and asks a person to confirm only if so (F-171)
|
|
202
|
+
if (opts.dryRun) return { path, changed: JSON.stringify(before) !== JSON.stringify(after) };
|
|
203
|
+
return writeConfig(path, before, after);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The installed guard hook objects of one config file, for one agent (ours only). */
|
|
207
|
+
function installedHooks(agent, path) {
|
|
208
|
+
if (!existsSync(path)) return [];
|
|
209
|
+
let cfg;
|
|
210
|
+
try {
|
|
211
|
+
cfg = readJson(path);
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const all =
|
|
216
|
+
agent === 'cursor'
|
|
217
|
+
? Object.values(cfg.hooks ?? {}).flat()
|
|
218
|
+
: (cfg.hooks?.PreToolUse ?? []).flatMap((g) => g.hooks ?? []);
|
|
219
|
+
return all.filter((h) => ours(h.command, agent));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The installed guard commands of one config file, for one agent. */
|
|
223
|
+
function installedCommands(agent, path) {
|
|
224
|
+
return installedHooks(agent, path).map((h) => h.command);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A synthetic forced push in each agent's shape: the installed hook must deny it. */
|
|
228
|
+
export function firePayload(agent, cwd) {
|
|
229
|
+
const command = DOCTOR_COMMAND;
|
|
230
|
+
if (agent === 'cursor')
|
|
231
|
+
return {
|
|
232
|
+
hook_event_name: 'beforeShellExecution',
|
|
233
|
+
command,
|
|
234
|
+
cwd,
|
|
235
|
+
conversation_id: 'doctor',
|
|
236
|
+
generation_id: 'doctor',
|
|
237
|
+
};
|
|
238
|
+
return {
|
|
239
|
+
hook_event_name: 'PreToolUse',
|
|
240
|
+
tool_name: 'Bash',
|
|
241
|
+
tool_input: { command },
|
|
242
|
+
cwd,
|
|
243
|
+
session_id: 'doctor',
|
|
244
|
+
tool_use_id: 'doctor-fire',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const denied = (agent, out) =>
|
|
248
|
+
agent === 'cursor' ? /"permission":"deny"/.test(out) : /"permissionDecision":"deny"/.test(out);
|
|
249
|
+
|
|
250
|
+
/** Run one installed command line as the agent would: payload on stdin, answer on stdout. */
|
|
251
|
+
export function fire(agent, command, cwd, env = process.env) {
|
|
252
|
+
const r = spawnSync('/bin/sh', ['-c', command], {
|
|
253
|
+
input: JSON.stringify(firePayload(agent, cwd)),
|
|
254
|
+
encoding: 'utf8',
|
|
255
|
+
cwd,
|
|
256
|
+
env: { ...env, SAKK_DOCTOR: '1' },
|
|
257
|
+
timeout: 15_000,
|
|
258
|
+
});
|
|
259
|
+
return {
|
|
260
|
+
ok: r.status === 0 && denied(agent, r.stdout ?? ''),
|
|
261
|
+
stdout: r.stdout ?? '',
|
|
262
|
+
status: r.status,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Every check prints one line; any failed check fails the doctor (exit 1).
|
|
268
|
+
* Returns [{ name, ok, detail, warn? }].
|
|
269
|
+
*/
|
|
270
|
+
export function doctor({
|
|
271
|
+
cwd = process.cwd(),
|
|
272
|
+
home = homedir(),
|
|
273
|
+
env = process.env,
|
|
274
|
+
managed = '/Library/Application Support/ClaudeCode/managed-settings.json',
|
|
275
|
+
// probe:false reports the installed state without firing the hook or creating
|
|
276
|
+
// Sakk's home — a read-only, subprocess-free check for `sakk ui` (M4 review).
|
|
277
|
+
probe = true,
|
|
278
|
+
} = {}) {
|
|
279
|
+
const out = [];
|
|
280
|
+
const add = (name, ok, detail, warn = false) => out.push({ name, ok, detail, warn });
|
|
281
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
282
|
+
add('node', major >= 20, `Node ${process.versions.node} (20 or later required)`);
|
|
283
|
+
const root = repoRootOf(cwd);
|
|
284
|
+
const local = root ? join(root, '.sakk', 'policy.json') : null;
|
|
285
|
+
const policyPath = local && existsSync(local) ? local : SHIPPED_POLICY;
|
|
286
|
+
try {
|
|
287
|
+
const { policy, policyHash } = loadPolicy({ policyPath });
|
|
288
|
+
add(
|
|
289
|
+
'policy',
|
|
290
|
+
true,
|
|
291
|
+
`${policyPath} — ${policy.rules.length} rules, sha256 ${policyHash.slice(0, 12)}…`,
|
|
292
|
+
);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
add('policy', false, `${policyPath}: ${e.message}`);
|
|
295
|
+
}
|
|
296
|
+
const sh = sakkHome(env);
|
|
297
|
+
try {
|
|
298
|
+
if (probe) mkdirSync(sh, { recursive: true }); // read-only check never creates it
|
|
299
|
+
accessSync(sh, constants.W_OK);
|
|
300
|
+
add('decision log', true, `${join(sh, 'decisions.jsonl')} is writable`);
|
|
301
|
+
} catch (e) {
|
|
302
|
+
if (!probe && e.code === 'ENOENT')
|
|
303
|
+
add('decision log', true, `${sh} will be created on the first call`, true);
|
|
304
|
+
else
|
|
305
|
+
add(
|
|
306
|
+
'decision log',
|
|
307
|
+
false,
|
|
308
|
+
`${sh} is not writable (${e.code ?? e.message}) — every call would be denied`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
let anyInstalled = false;
|
|
312
|
+
for (const agent of AGENTS) {
|
|
313
|
+
const places = [
|
|
314
|
+
['project', configPath(agent, { cwd, home })],
|
|
315
|
+
['user', configPath(agent, { user: true, cwd, home })],
|
|
316
|
+
];
|
|
317
|
+
const found = places.flatMap(([where, p]) =>
|
|
318
|
+
installedCommands(agent, p).map((c) => ({ where, p, c })),
|
|
319
|
+
);
|
|
320
|
+
if (agent === 'claude-code' && existsSync(managed))
|
|
321
|
+
for (const c of installedCommands(agent, managed))
|
|
322
|
+
found.push({ where: 'managed', p: managed, c });
|
|
323
|
+
if (found.length === 0) {
|
|
324
|
+
add(`${agent}`, true, 'not installed (sakk guard install ' + agent + ' to add it)', true);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
anyInstalled = true;
|
|
328
|
+
for (const f of found) {
|
|
329
|
+
if (!probe) {
|
|
330
|
+
// read-only: report that the hook is installed, without running it
|
|
331
|
+
add(`${agent} (${f.where})`, true, `${f.p}: installed (run sakk doctor to probe it fires)`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const r = fire(agent, f.c, root ?? cwd, env);
|
|
335
|
+
add(
|
|
336
|
+
`${agent} (${f.where})`,
|
|
337
|
+
r.ok,
|
|
338
|
+
r.ok
|
|
339
|
+
? `${f.p}: a forced push is denied`
|
|
340
|
+
: `${f.p}: the installed hook did not deny a forced push (exit ${r.status}) — reinstall with sakk guard install ${agent}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (agent === 'claude-code' && found.every((f) => f.where === 'project'))
|
|
344
|
+
add(
|
|
345
|
+
'claude-code start folder',
|
|
346
|
+
true,
|
|
347
|
+
`Claude Code loads ${found[0].p} only for sessions started in ${dirname(dirname(found[0].p))}; a session started in a parent folder skips it — add --user, or managed settings, if you start sessions elsewhere`,
|
|
348
|
+
true,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const claudeProject =
|
|
352
|
+
installedCommands('claude-code', configPath('claude-code', { cwd, home })).length > 0;
|
|
353
|
+
const cursorAny =
|
|
354
|
+
installedCommands('cursor', configPath('cursor', { cwd, home })).length > 0 ||
|
|
355
|
+
installedCommands('cursor', configPath('cursor', { user: true, cwd, home })).length > 0;
|
|
356
|
+
if (claudeProject && cursorAny)
|
|
357
|
+
add(
|
|
358
|
+
'cursor double run',
|
|
359
|
+
true,
|
|
360
|
+
'Cursor also runs Claude Code project hooks, so each Cursor call is decided twice; harmless, but logged twice',
|
|
361
|
+
true,
|
|
362
|
+
);
|
|
363
|
+
// A fail-open install is a degraded guard, not a healthy one: a Claude Code
|
|
364
|
+
// hook with no onFailure: block was installed on a binary older than 2.1.295,
|
|
365
|
+
// so if the hook process ever fails to start the tool call proceeds UNGUARDED.
|
|
366
|
+
// Surface it here — the one tool install points the operator at — rather than
|
|
367
|
+
// leave the lost guarantee to be inferred from an absent config key (F-174).
|
|
368
|
+
const claudeHooks = [
|
|
369
|
+
configPath('claude-code', { cwd, home }),
|
|
370
|
+
configPath('claude-code', { user: true, cwd, home }),
|
|
371
|
+
...(existsSync(managed) ? [managed] : []),
|
|
372
|
+
].flatMap((p) => installedHooks('claude-code', p));
|
|
373
|
+
if (claudeHooks.length > 0 && claudeHooks.some((h) => h.onFailure !== 'block'))
|
|
374
|
+
add(
|
|
375
|
+
'claude-code fail-open',
|
|
376
|
+
true,
|
|
377
|
+
'the installed Claude Code hook has no onFailure: block — it was installed on a Claude Code older than 2.1.295, so if the hook ever fails to start the tool call proceeds UNGUARDED; upgrade Claude Code and run sakk guard install claude-code again for a fail-closed guard',
|
|
378
|
+
true,
|
|
379
|
+
);
|
|
380
|
+
if (!anyInstalled)
|
|
381
|
+
add(
|
|
382
|
+
'installed',
|
|
383
|
+
false,
|
|
384
|
+
'no agent has the guard installed — run: sakk guard install claude-code',
|
|
385
|
+
);
|
|
386
|
+
return out;
|
|
387
|
+
}
|