oathbound 0.14.0 → 0.15.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/update.ts DELETED
@@ -1,111 +0,0 @@
1
- import {
2
- writeFileSync, readFileSync, existsSync, mkdirSync,
3
- renameSync, chmodSync,
4
- } from 'node:fs';
5
- import { join } from 'node:path';
6
- import { homedir, platform } from 'node:os';
7
- import { TEAL, GREEN, RESET } from './ui';
8
-
9
- export function isNewer(remote: string, local: string): boolean {
10
- const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
11
- const [rMaj, rMin, rPat] = parse(remote);
12
- const [lMaj, lMin, lPat] = parse(local);
13
- if (rMaj !== lMaj) return rMaj > lMaj;
14
- if (rMin !== lMin) return rMin > lMin;
15
- return rPat > lPat;
16
- }
17
-
18
- function getCacheDir(): string {
19
- if (platform() === 'darwin') {
20
- return join(homedir(), 'Library', 'Caches', 'oathbound');
21
- }
22
- return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'oathbound');
23
- }
24
-
25
- function getPlatformBinaryName(): string {
26
- const p = platform();
27
- const os = p === 'win32' ? 'windows' : p === 'darwin' ? 'darwin' : 'linux';
28
- const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
29
- const ext = p === 'win32' ? '.exe' : '';
30
- return `oathbound-${os}-${arch}${ext}`;
31
- }
32
-
33
- function printUpdateBox(current: string, latest: string): void {
34
- const line = `Update available: ${current} → ${latest}`;
35
- const install = 'Run: npm install -g oathbound';
36
- const width = Math.max(line.length, install.length) + 2;
37
- const pad = (s: string) => s + ' '.repeat(width - s.length);
38
- process.stderr.write(`\n${TEAL}┌${'─'.repeat(width)}┐${RESET}\n`);
39
- process.stderr.write(`${TEAL}│${RESET} ${pad(line)}${TEAL}│${RESET}\n`);
40
- process.stderr.write(`${TEAL}│${RESET} ${pad(install)}${TEAL}│${RESET}\n`);
41
- process.stderr.write(`${TEAL}└${'─'.repeat(width)}┘${RESET}\n`);
42
- }
43
-
44
- export async function checkForUpdate(version: string): Promise<void> {
45
- const cacheDir = getCacheDir();
46
- const cacheFile = join(cacheDir, 'update-check.json');
47
-
48
- // Check cache freshness (24h) — invalidate if local version changed
49
- if (existsSync(cacheFile)) {
50
- try {
51
- const cache = JSON.parse(readFileSync(cacheFile, 'utf-8'));
52
- if (cache.localVersion === version && Date.now() - cache.checkedAt < 86_400_000) {
53
- if (cache.latestVersion && isNewer(cache.latestVersion, version)) {
54
- printUpdateBox(version, cache.latestVersion);
55
- }
56
- return;
57
- }
58
- } catch { /* stale cache, re-check */ }
59
- }
60
-
61
- // Fetch latest version from npm
62
- const controller = new AbortController();
63
- const timeout = setTimeout(() => controller.abort(), 5_000);
64
- try {
65
- const resp = await fetch(
66
- 'https://registry.npmjs.org/oathbound?fields=dist-tags',
67
- { signal: controller.signal },
68
- );
69
- clearTimeout(timeout);
70
- if (!resp.ok) return;
71
- const data = await resp.json() as { 'dist-tags'?: { latest?: string } };
72
- const latest = data['dist-tags']?.latest;
73
- if (!latest) return;
74
-
75
- // Write cache
76
- mkdirSync(cacheDir, { recursive: true });
77
- writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latestVersion: latest, localVersion: version }));
78
-
79
- if (!isNewer(latest, version)) return;
80
-
81
- // Try auto-update the binary
82
- const binaryPath = process.argv[0];
83
- if (!binaryPath || binaryPath.includes('bun') || binaryPath.includes('node')) {
84
- // Running via bun/node, not compiled binary — just print box
85
- printUpdateBox(version, latest);
86
- return;
87
- }
88
-
89
- const binaryName = getPlatformBinaryName();
90
- const url = `https://github.com/Joshuatanderson/oath-bound/releases/download/v${latest}/${binaryName}`;
91
- const dlController = new AbortController();
92
- const dlTimeout = setTimeout(() => dlController.abort(), 30_000);
93
- const dlResp = await fetch(url, { signal: dlController.signal, redirect: 'follow' });
94
- clearTimeout(dlTimeout);
95
-
96
- if (!dlResp.ok || !dlResp.body) {
97
- printUpdateBox(version, latest);
98
- return;
99
- }
100
-
101
- const bytes = Buffer.from(await dlResp.arrayBuffer());
102
- const tmpPath = `${binaryPath}.update-${Date.now()}`;
103
- writeFileSync(tmpPath, bytes);
104
- chmodSync(tmpPath, 0o755);
105
- renameSync(tmpPath, binaryPath);
106
- process.stderr.write(`${TEAL} ✓ Updated oathbound ${version} → ${latest}${RESET}\n`);
107
- } catch (err) {
108
- const msg = err instanceof Error ? err.message : String(err);
109
- process.stderr.write(`${TEAL}Update check failed: ${msg}${RESET}\n`);
110
- }
111
- }
package/verify.ts DELETED
@@ -1,400 +0,0 @@
1
- import { createClient } from '@supabase/supabase-js';
2
- import {
3
- writeFileSync, readFileSync, existsSync,
4
- readdirSync, statSync,
5
- } from 'node:fs';
6
- import { join, resolve } from 'node:path';
7
- import { tmpdir } from 'node:os';
8
- import { parse as yamlParse } from 'yaml';
9
- import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET } from './ui';
10
- import { hashSkillDir } from './content-hash';
11
- import { readOathboundConfig, type EnforcementLevel } from './config';
12
- import { isValidSemver } from './semver';
13
-
14
- // --- Session state file ---
15
- interface SessionState {
16
- verified: Record<string, string>; // skill name → content_hash
17
- rejected: { name: string; reason: string }[];
18
- ok: boolean;
19
- }
20
-
21
- function sessionStatePath(sessionId: string): string {
22
- return join(tmpdir(), `oathbound-${sessionId}.json`);
23
- }
24
-
25
- async function readStdin(): Promise<string> {
26
- const chunks: Buffer[] = [];
27
- for await (const chunk of Bun.stdin.stream()) {
28
- chunks.push(Buffer.from(chunk));
29
- }
30
- return Buffer.concat(chunks).toString('utf-8');
31
- }
32
-
33
- export function findSkillsDir(): string {
34
- const cwd = process.cwd();
35
- const normalized = cwd.replace(/\/+$/, '');
36
-
37
- // Already inside .claude/skills
38
- if (normalized.endsWith('.claude/skills')) return cwd;
39
-
40
- // Inside .claude — check for skills/ subdir
41
- if (normalized.endsWith('.claude')) {
42
- const skills = join(cwd, 'skills');
43
- if (existsSync(skills)) return skills;
44
- }
45
-
46
- // Check cwd/.claude/skills directly
47
- const direct = join(cwd, '.claude', 'skills');
48
- if (existsSync(direct)) return direct;
49
-
50
- // Recurse downward (skip noise, limited depth)
51
- const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next']);
52
- function search(dir: string, depth: number): string | null {
53
- if (depth <= 0) return null;
54
- try {
55
- const entries = readdirSync(dir, { withFileTypes: true });
56
- for (const entry of entries) {
57
- if (!entry.isDirectory() || SKIP.has(entry.name)) continue;
58
- if (entry.name === '.claude') {
59
- const skills = join(dir, '.claude', 'skills');
60
- if (existsSync(skills)) return skills;
61
- }
62
- }
63
- for (const entry of entries) {
64
- if (!entry.isDirectory() || SKIP.has(entry.name) || entry.name.startsWith('.')) continue;
65
- const result = search(join(dir, entry.name), depth - 1);
66
- if (result) return result;
67
- }
68
- } catch {
69
- // permission denied, etc.
70
- }
71
- return null;
72
- }
73
-
74
- return search(cwd, 5) ?? cwd;
75
- }
76
-
77
- /** Extract skill name from a file path if it references .claude/skills/<name>/... */
78
- function skillNameFromPath(filePath: string): string | null {
79
- const marker = '.claude/skills/';
80
- const idx = filePath.indexOf(marker);
81
- if (idx === -1) return null;
82
- const rest = filePath.slice(idx + marker.length);
83
- const name = rest.split('/')[0];
84
- return name || null;
85
- }
86
-
87
- /** Extract skill name from a bash command if it references .claude/skills/<name>/... */
88
- function skillNameFromCommand(command: string): string | null {
89
- const marker = '.claude/skills/';
90
- const idx = command.indexOf(marker);
91
- if (idx === -1) return null;
92
- const rest = command.slice(idx + marker.length);
93
- const name = rest.split(/[\/\s'"]/)[0];
94
- return name || null;
95
- }
96
-
97
- function denySkill(skillName: string, reason: string, enforcement: EnforcementLevel): never {
98
- process.stderr.write(`\n${TEAL}${BOLD}⬡ oathbound${RESET} ${RED}${BOLD}✗ Blocked${RESET} skill ${BOLD}"${skillName}"${RESET} ${DIM}(${reason})${RESET}\n`);
99
- process.stderr.write(`${DIM} enforcement: ${enforcement} — switch to "warn" in .oathbound.jsonc for development${RESET}\n\n`);
100
- console.log(JSON.stringify({
101
- hookSpecificOutput: {
102
- hookEventName: 'PreToolUse',
103
- permissionDecision: 'deny',
104
- permissionDecisionReason: `Oathbound: skill "${skillName}" blocked — ${reason} (enforcement: ${enforcement})`,
105
- },
106
- }));
107
- process.exit(0);
108
- }
109
-
110
- function warnSkill(skillName: string, reason: string): never {
111
- process.stderr.write(`\n${TEAL}${BOLD}⬡ oathbound${RESET} ${YELLOW}⚠ Warning:${RESET} skill ${BOLD}"${skillName}"${RESET} ${DIM}(${reason})${RESET}\n\n`);
112
- process.exit(0);
113
- }
114
-
115
- /** Check if a tool operation references a skill in another project, not ours. */
116
- function isExternalSkillAccess(
117
- toolName: string,
118
- toolInput: Record<string, unknown>,
119
- skillsDir: string,
120
- baseName: string,
121
- ): boolean {
122
- const resolvedSkillsDir = resolve(skillsDir);
123
-
124
- if (toolName === 'Read') {
125
- const p = String(toolInput.file_path ?? '');
126
- if (p && !resolve(p).startsWith(resolvedSkillsDir)) return true;
127
- }
128
- if (toolName === 'Glob' || toolName === 'Grep') {
129
- const p = String(toolInput.path ?? '');
130
- if (p && !resolve(p).startsWith(resolvedSkillsDir)) return true;
131
- }
132
- if (toolName === 'Bash') {
133
- const cmd = String(toolInput.command ?? '');
134
- // If the command contains an absolute path to .claude/skills/baseName
135
- // that ISN'T under our project's skills dir, it's external
136
- if (cmd.includes('/.claude/skills/' + baseName) && !cmd.includes(resolvedSkillsDir)) return true;
137
- }
138
-
139
- return false;
140
- }
141
-
142
- function parseSkillVersion(skillDir: string): string | null {
143
- const skillMdPath = join(skillDir, 'SKILL.md');
144
- if (!existsSync(skillMdPath)) return null;
145
- const content = readFileSync(skillMdPath, 'utf-8');
146
- const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
147
- if (!match) return null;
148
- try {
149
- const parsed = yamlParse(match[1]);
150
- const v = parsed?.version;
151
- if (v == null) return null;
152
- const vStr = String(v);
153
- return isValidSemver(vStr) ? vStr : null;
154
- } catch {
155
- return null;
156
- }
157
- }
158
-
159
- // --- Verify (SessionStart hook) ---
160
- export async function verify(supabaseUrl: string, supabaseAnonKey: string): Promise<void> {
161
- let input: Record<string, unknown>;
162
- try {
163
- input = JSON.parse(await readStdin());
164
- } catch {
165
- process.stderr.write('oathbound verify: invalid JSON on stdin\n');
166
- process.exit(1);
167
- }
168
- const sessionId: string = input.session_id as string;
169
- if (!sessionId) {
170
- process.stderr.write('oathbound verify: no session_id in stdin\n');
171
- process.exit(1);
172
- }
173
-
174
- const skillsDir = findSkillsDir();
175
-
176
- // Guard: findSkillsDir() falls back to cwd if no .claude/skills found.
177
- // In verify mode, we must NOT hash the entire project — only .claude/skills.
178
- if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
179
- const state: SessionState = { verified: {}, rejected: [], ok: true };
180
- writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
181
- console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no .claude/skills/ directory found — nothing to verify.' } }));
182
- process.exit(0);
183
- }
184
-
185
- // List skill subdirectories
186
- const entries = readdirSync(skillsDir, { withFileTypes: true });
187
- const skillDirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
188
-
189
- if (skillDirs.length === 0) {
190
- const state: SessionState = { verified: {}, rejected: [], ok: true };
191
- writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
192
- console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no skills installed — nothing to verify.' } }));
193
- process.exit(0);
194
- }
195
-
196
- // Hash each local skill and parse version from SKILL.md
197
- const localSkills: Record<string, { hash: string; version: string }> = {};
198
- for (const dir of skillDirs) {
199
- const fullPath = join(skillsDir, dir.name);
200
- const hash = hashSkillDir(fullPath);
201
- const version = parseSkillVersion(fullPath) ?? "1.0.0"; // fallback for pre-semver installs
202
- localSkills[dir.name] = { hash, version };
203
- }
204
-
205
- // Read enforcement config
206
- const config = readOathboundConfig();
207
- const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
208
-
209
- // Fetch registry data from Supabase (all versions)
210
- // If enforcement=audited, also fetch audit status
211
- const supabase = createClient(supabaseUrl, supabaseAnonKey);
212
- const selectFields = enforcement === 'audited'
213
- ? 'name, namespace, content_hash, version, audits(passed)'
214
- : 'name, namespace, content_hash, version';
215
- const { data: skills, error } = await supabase
216
- .from('skills')
217
- .select(selectFields)
218
- .order('version', { ascending: false });
219
-
220
- if (error) {
221
- process.stderr.write(`oathbound verify: failed to query registry: ${error.message}\n`);
222
- process.exit(1);
223
- }
224
-
225
- // Build lookup: name → version → { hash, audited }
226
- const registryMap = new Map<string, Map<string, { hash: string; audited: boolean }>>();
227
- for (const skill of skills ?? []) {
228
- if (!skill.content_hash) continue;
229
- if (!registryMap.has(skill.name)) {
230
- registryMap.set(skill.name, new Map());
231
- }
232
- const versionMap = registryMap.get(skill.name)!;
233
- if (!versionMap.has(skill.version)) {
234
- const audited = enforcement === 'audited'
235
- ? ((skill as Record<string, unknown>).audits as Array<{ passed: boolean }> | null)?.some(a => a.passed) ?? false
236
- : false;
237
- versionMap.set(skill.version, { hash: skill.content_hash, audited });
238
- }
239
- }
240
-
241
- const verified: Record<string, string> = {};
242
- const rejected: { name: string; reason: string }[] = [];
243
- const warnings: { name: string; reason: string }[] = [];
244
-
245
- process.stderr.write(`${BRAND} ${TEAL}verifying skills...${RESET}\n`);
246
-
247
- for (const [name, { hash: localHash, version }] of Object.entries(localSkills)) {
248
- const versionMap = registryMap.get(name);
249
- const entry = versionMap?.get(version);
250
-
251
- if (!entry) {
252
- process.stderr.write(`${DIM} ${name}@${version}: ${localHash} (not in registry)${RESET}\n`);
253
- if (enforcement === 'warn') {
254
- warnings.push({ name, reason: 'not in registry' });
255
- verified[name] = localHash;
256
- } else {
257
- rejected.push({ name, reason: 'not in registry' });
258
- }
259
- } else if (localHash !== entry.hash) {
260
- process.stderr.write(`${RED} ${name}@${version}: ${localHash} ≠ ${entry.hash}${RESET}\n`);
261
- if (enforcement === 'warn') {
262
- warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${entry.hash.slice(0, 8)}…)` });
263
- verified[name] = localHash;
264
- } else {
265
- rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${entry.hash.slice(0, 8)}…)` });
266
- }
267
- } else if (enforcement === 'audited' && !entry.audited) {
268
- process.stderr.write(`${YELLOW} ${name}@${version}: ${localHash} (registered but not audited)${RESET}\n`);
269
- rejected.push({ name, reason: 'no passed audit' });
270
- } else {
271
- process.stderr.write(`${GREEN} ${name}@${version}: ${localHash} ✓${RESET}\n`);
272
- verified[name] = localHash;
273
- }
274
- }
275
-
276
- const ok = rejected.length === 0;
277
- const state: SessionState = { verified, rejected, ok };
278
- writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
279
-
280
- if (ok && warnings.length === 0) {
281
- const names = Object.keys(verified).join(', ');
282
- console.log(JSON.stringify({
283
- hookSpecificOutput: {
284
- hookEventName: 'SessionStart',
285
- additionalContext: `Oathbound: all ${Object.keys(verified).length} skill(s) verified against registry [${names}]. Skills are safe to use.`,
286
- },
287
- }));
288
- process.exit(0);
289
- } else if (ok && warnings.length > 0) {
290
- // Warn mode — all skills allowed but with warnings
291
- const warnLines = warnings.map((w) => ` ⚠ ${w.name}: ${w.reason}`).join('\n');
292
- const names = Object.keys(verified).join(', ');
293
- const warnHeader = `${TEAL}${BOLD}⬡ oathbound${RESET} ${YELLOW}⚠ Unverified skills (enforcement: warn):${RESET}`;
294
- process.stderr.write(`${warnHeader}\n${warnLines}\n${DIM} Skills allowed but not verified against registry.${RESET}\n`);
295
- console.log(JSON.stringify({
296
- hookSpecificOutput: {
297
- hookEventName: 'SessionStart',
298
- additionalContext: `Oathbound (warn mode): ${Object.keys(verified).length} skill(s) allowed [${names}]. Warnings:\n${warnLines}`,
299
- },
300
- }));
301
- process.exit(0);
302
- } else {
303
- const lines = rejected.map((r) => ` ${RED}✗${RESET} ${r.name}: ${r.reason}`);
304
- process.stderr.write(`\n${TEAL}${BOLD}⬡ oathbound${RESET} ${RED}${BOLD}✗ Skill verification failed${RESET} ${DIM}(enforcement: ${enforcement})${RESET}\n${lines.join('\n')}\n${DIM} Do NOT use unverified skills.${RESET}\n\n`);
305
- process.exit(2);
306
- }
307
- }
308
-
309
- // --- Verify --check (PreToolUse hook) ---
310
- export async function verifyCheck(): Promise<void> {
311
- let input: Record<string, unknown>;
312
- try {
313
- input = JSON.parse(await readStdin());
314
- } catch {
315
- process.stderr.write('oathbound verify --check: invalid JSON on stdin\n');
316
- process.exit(1);
317
- }
318
- const sessionId: string = input.session_id as string;
319
- const toolName: string = (input.tool_name as string) ?? '';
320
- const toolInput = (input.tool_input as Record<string, unknown>) ?? {};
321
-
322
- if (!sessionId) process.exit(0);
323
-
324
- // Extract skill name based on which tool triggered the hook
325
- let baseName: string | null = null;
326
-
327
- if (toolName === 'Skill') {
328
- const skill = toolInput.skill as string | undefined;
329
- if (!skill) process.exit(0);
330
- baseName = skill.includes(':') ? skill.split(':').pop()! : skill;
331
- } else if (toolName === 'Bash') {
332
- baseName = skillNameFromCommand((toolInput.command as string) ?? '');
333
- } else if (toolName === 'Read') {
334
- baseName = skillNameFromPath((toolInput.file_path as string) ?? '');
335
- } else if (toolName === 'Glob' || toolName === 'Grep') {
336
- baseName = skillNameFromPath((toolInput.path as string) ?? '');
337
- // Also check pattern/glob fields for skill path references
338
- if (!baseName) baseName = skillNameFromPath((toolInput.pattern as string) ?? '');
339
- if (!baseName) baseName = skillNameFromPath((toolInput.glob as string) ?? '');
340
- }
341
-
342
- // Not a skill-related operation — allow through
343
- if (!baseName) process.exit(0);
344
-
345
- // Read enforcement config
346
- const config = readOathboundConfig();
347
- const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
348
-
349
- // Check if the tool is accessing a skill in another project — not our concern
350
- const skillsDir = findSkillsDir();
351
- if (isExternalSkillAccess(toolName, toolInput, skillsDir, baseName)) {
352
- process.exit(0);
353
- }
354
-
355
- const stateFile = sessionStatePath(sessionId);
356
- if (!existsSync(stateFile)) process.exit(0);
357
-
358
- let state: SessionState;
359
- try {
360
- state = JSON.parse(readFileSync(stateFile, 'utf-8'));
361
- } catch {
362
- process.stderr.write('oathbound verify --check: corrupt session state file\n');
363
- process.exit(1);
364
- }
365
-
366
- // Find the skill directory and re-hash
367
- const skillDir = join(skillsDir, baseName);
368
-
369
- if (!existsSync(skillDir) || !statSync(skillDir).isDirectory()) {
370
- if (enforcement === 'warn') {
371
- warnSkill(baseName, 'not installed locally');
372
- } else {
373
- denySkill(baseName, 'not installed locally', enforcement);
374
- }
375
- }
376
-
377
- const currentHash = hashSkillDir(skillDir);
378
- const sessionHash = state.verified[baseName];
379
-
380
- if (!sessionHash) {
381
- if (enforcement === 'warn') {
382
- warnSkill(baseName, 'not verified at session start');
383
- } else {
384
- denySkill(baseName, 'not verified at session start', enforcement);
385
- }
386
- }
387
-
388
- if (currentHash !== sessionHash) {
389
- if (enforcement === 'warn') {
390
- warnSkill(baseName, `modified since session start (${currentHash.slice(0, 8)}… ≠ ${sessionHash.slice(0, 8)}…)`);
391
- } else {
392
- denySkill(baseName, `modified since session start — tampering detected (${currentHash.slice(0, 8)}… ≠ ${sessionHash.slice(0, 8)}…)`, enforcement);
393
- }
394
- }
395
-
396
- process.stderr.write(`${GREEN} ${baseName}: ${currentHash} ✓${RESET}\n`);
397
-
398
- // Hash matches — allow
399
- process.exit(0);
400
- }