oathbound 0.4.0 → 0.5.1
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/cli.ts +124 -624
- package/config.ts +128 -0
- package/content-hash.ts +39 -0
- package/package.json +6 -1
- package/ui.ts +53 -0
- package/update.ts +111 -0
- package/verify.ts +323 -0
package/config.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
writeFileSync, readFileSync, existsSync, mkdirSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export type EnforcementLevel = 'warn' | 'registered' | 'audited';
|
|
7
|
+
|
|
8
|
+
/** Strip // line comments from JSONC, preserving // inside strings. */
|
|
9
|
+
export function stripJsoncComments(text: string): string {
|
|
10
|
+
let result = '';
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < text.length) {
|
|
13
|
+
// String literal — copy through, respecting escapes
|
|
14
|
+
if (text[i] === '"') {
|
|
15
|
+
result += '"';
|
|
16
|
+
i++;
|
|
17
|
+
while (i < text.length && text[i] !== '"') {
|
|
18
|
+
if (text[i] === '\\') { result += text[i++]; } // escape char
|
|
19
|
+
if (i < text.length) { result += text[i++]; }
|
|
20
|
+
}
|
|
21
|
+
if (i < text.length) { result += text[i++]; } // closing "
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
// Line comment
|
|
25
|
+
if (text[i] === '/' && text[i + 1] === '/') {
|
|
26
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
result += text[i++];
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readOathboundConfig(): { enforcement: EnforcementLevel } | null {
|
|
35
|
+
const configPath = join(process.cwd(), '.oathbound.jsonc');
|
|
36
|
+
if (!existsSync(configPath)) return null;
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
39
|
+
const parsed = JSON.parse(stripJsoncComments(raw));
|
|
40
|
+
const level = parsed.enforcement;
|
|
41
|
+
if (level === 'warn' || level === 'registered' || level === 'audited') {
|
|
42
|
+
return { enforcement: level };
|
|
43
|
+
}
|
|
44
|
+
return { enforcement: 'warn' };
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeOathboundConfig(enforcement: EnforcementLevel): boolean {
|
|
51
|
+
const configPath = join(process.cwd(), '.oathbound.jsonc');
|
|
52
|
+
if (existsSync(configPath)) return false;
|
|
53
|
+
const content = `// Oathbound project configuration
|
|
54
|
+
// Docs: https://oathbound.ai/docs/config
|
|
55
|
+
{
|
|
56
|
+
"$schema": "https://oathbound.ai/schemas/config-v1.json",
|
|
57
|
+
"version": 1,
|
|
58
|
+
"enforcement": "${enforcement}",
|
|
59
|
+
"org": null
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
writeFileSync(configPath, content);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SKILL_CHECK = { type: 'command', command: 'npx oathbound verify --check' };
|
|
67
|
+
|
|
68
|
+
const OATHBOUND_HOOKS = {
|
|
69
|
+
SessionStart: [
|
|
70
|
+
{ matcher: '', hooks: [{ type: 'command', command: 'npx oathbound verify' }] },
|
|
71
|
+
],
|
|
72
|
+
PreToolUse: [
|
|
73
|
+
{ matcher: 'Skill', hooks: [SKILL_CHECK] },
|
|
74
|
+
{ matcher: 'Bash', hooks: [SKILL_CHECK] },
|
|
75
|
+
{ matcher: 'Read', hooks: [SKILL_CHECK] },
|
|
76
|
+
{ matcher: 'Glob', hooks: [SKILL_CHECK] },
|
|
77
|
+
{ matcher: 'Grep', hooks: [SKILL_CHECK] },
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function hasOathboundHooks(settings: Record<string, unknown>): boolean {
|
|
82
|
+
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
|
83
|
+
if (!hooks) return false;
|
|
84
|
+
for (const entries of Object.values(hooks)) {
|
|
85
|
+
if (!Array.isArray(entries)) continue;
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const e = entry as Record<string, unknown>;
|
|
88
|
+
const innerHooks = e.hooks as Array<Record<string, unknown>> | undefined;
|
|
89
|
+
if (!innerHooks) continue;
|
|
90
|
+
for (const h of innerHooks) {
|
|
91
|
+
if (typeof h.command === 'string' && h.command.startsWith('npx oathbound')) return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type MergeResult = 'created' | 'merged' | 'skipped' | 'malformed';
|
|
99
|
+
|
|
100
|
+
export function mergeClaudeSettings(): MergeResult {
|
|
101
|
+
const claudeDir = join(process.cwd(), '.claude');
|
|
102
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
103
|
+
|
|
104
|
+
if (!existsSync(settingsPath)) {
|
|
105
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
106
|
+
writeFileSync(settingsPath, JSON.stringify({ hooks: OATHBOUND_HOOKS }, null, 2) + '\n');
|
|
107
|
+
return 'created';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let settings: Record<string, unknown>;
|
|
111
|
+
try {
|
|
112
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
113
|
+
} catch {
|
|
114
|
+
return 'malformed';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (hasOathboundHooks(settings)) return 'skipped';
|
|
118
|
+
|
|
119
|
+
// Merge hooks into existing settings
|
|
120
|
+
const hooks = (settings.hooks ?? {}) as Record<string, unknown[]>;
|
|
121
|
+
for (const [event, entries] of Object.entries(OATHBOUND_HOOKS)) {
|
|
122
|
+
const existing = hooks[event] as unknown[] | undefined;
|
|
123
|
+
hooks[event] = existing ? [...existing, ...entries] : [...entries];
|
|
124
|
+
}
|
|
125
|
+
settings.hooks = hooks;
|
|
126
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
127
|
+
return 'merged';
|
|
128
|
+
}
|
package/content-hash.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const HASH_EXCLUDED = new Set([
|
|
6
|
+
'node_modules',
|
|
7
|
+
'bun.lock',
|
|
8
|
+
'package-lock.json',
|
|
9
|
+
'yarn.lock',
|
|
10
|
+
'.DS_Store',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function collectFiles(dir: string, base: string = dir): { path: string; content: Buffer }[] {
|
|
14
|
+
const results: { path: string; content: Buffer }[] = [];
|
|
15
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
16
|
+
if (HASH_EXCLUDED.has(entry.name)) continue;
|
|
17
|
+
const full = join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
results.push(...collectFiles(full, base));
|
|
20
|
+
} else if (entry.isFile()) {
|
|
21
|
+
results.push({ path: relative(base, full), content: readFileSync(full) });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function contentHash(files: { path: string; content: Buffer }[]): string {
|
|
28
|
+
const sorted = files.toSorted((a, b) => a.path.localeCompare(b.path));
|
|
29
|
+
const lines = sorted.map((f) => {
|
|
30
|
+
const h = createHash('sha256').update(f.content).digest('hex');
|
|
31
|
+
return `${f.path}\0${h}`;
|
|
32
|
+
});
|
|
33
|
+
return createHash('sha256').update(lines.join('\n')).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hashSkillDir(skillDir: string): string {
|
|
37
|
+
const files = collectFiles(skillDir);
|
|
38
|
+
return contentHash(files);
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oathbound",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Install verified Claude Code skills from the Oath Bound registry",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Josh Anderson",
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"cli.ts",
|
|
27
|
+
"ui.ts",
|
|
28
|
+
"config.ts",
|
|
29
|
+
"update.ts",
|
|
30
|
+
"verify.ts",
|
|
31
|
+
"content-hash.ts",
|
|
27
32
|
"bin/cli.cjs",
|
|
28
33
|
"install.cjs"
|
|
29
34
|
],
|
package/ui.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// --- ANSI (respect NO_COLOR standard: https://no-color.org) ---
|
|
2
|
+
export const USE_COLOR = process.env.NO_COLOR === undefined && process.stderr.isTTY;
|
|
3
|
+
export const TEAL = USE_COLOR ? '\x1b[38;2;63;168;164m' : ''; // brand teal #3fa8a4
|
|
4
|
+
export const GREEN = USE_COLOR ? '\x1b[32m' : '';
|
|
5
|
+
export const RED = USE_COLOR ? '\x1b[31m' : '';
|
|
6
|
+
export const YELLOW = USE_COLOR ? '\x1b[33m' : '';
|
|
7
|
+
export const DIM = USE_COLOR ? '\x1b[2m' : '';
|
|
8
|
+
export const BOLD = USE_COLOR ? '\x1b[1m' : '';
|
|
9
|
+
export const RESET = USE_COLOR ? '\x1b[0m' : '';
|
|
10
|
+
|
|
11
|
+
export const BRAND = `${TEAL}${BOLD}🛡️ oathbound${RESET}`;
|
|
12
|
+
|
|
13
|
+
export function usage(exitCode = 1): never {
|
|
14
|
+
console.log(`
|
|
15
|
+
${BOLD}oathbound${RESET} — install and verify skills
|
|
16
|
+
|
|
17
|
+
${DIM}Usage:${RESET}
|
|
18
|
+
oathbound init ${DIM}Setup wizard — configure project${RESET}
|
|
19
|
+
oathbound pull <namespace/skill-name>
|
|
20
|
+
oathbound install <namespace/skill-name>
|
|
21
|
+
oathbound verify ${DIM}SessionStart hook — verify all skills${RESET}
|
|
22
|
+
oathbound verify --check ${DIM}PreToolUse hook — check skill integrity${RESET}
|
|
23
|
+
|
|
24
|
+
${DIM}Options:${RESET}
|
|
25
|
+
--help, -h Show this help message
|
|
26
|
+
--version, -v Show version
|
|
27
|
+
`);
|
|
28
|
+
process.exit(exitCode);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fail(message: string, detail?: string): never {
|
|
32
|
+
console.log(`\n${BOLD}${RED} ✗ ${message}${RESET}`);
|
|
33
|
+
if (detail) {
|
|
34
|
+
console.log(`${RED} ${detail}${RESET}`);
|
|
35
|
+
}
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function spinner(text: string): { stop: () => void } {
|
|
40
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
41
|
+
let i = 0;
|
|
42
|
+
process.stdout.write(`${TEAL} ${frames[0]} ${text}${RESET}`);
|
|
43
|
+
const interval = setInterval(() => {
|
|
44
|
+
i = (i + 1) % frames.length;
|
|
45
|
+
process.stdout.write(`\r${TEAL} ${frames[i]} ${text}${RESET}`);
|
|
46
|
+
}, 80);
|
|
47
|
+
return {
|
|
48
|
+
stop() {
|
|
49
|
+
clearInterval(interval);
|
|
50
|
+
process.stdout.write(USE_COLOR ? '\r\x1b[2K' : '\n');
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/update.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import {
|
|
3
|
+
writeFileSync, readFileSync, existsSync,
|
|
4
|
+
readdirSync, statSync,
|
|
5
|
+
} from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, RESET } from './ui';
|
|
9
|
+
import { hashSkillDir } from './content-hash';
|
|
10
|
+
import { readOathboundConfig, type EnforcementLevel } from './config';
|
|
11
|
+
|
|
12
|
+
// --- Session state file ---
|
|
13
|
+
interface SessionState {
|
|
14
|
+
verified: Record<string, string>; // skill name → content_hash
|
|
15
|
+
rejected: { name: string; reason: string }[];
|
|
16
|
+
ok: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sessionStatePath(sessionId: string): string {
|
|
20
|
+
return join(tmpdir(), `oathbound-${sessionId}.json`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readStdin(): Promise<string> {
|
|
24
|
+
const chunks: Buffer[] = [];
|
|
25
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
26
|
+
chunks.push(Buffer.from(chunk));
|
|
27
|
+
}
|
|
28
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function findSkillsDir(): string {
|
|
32
|
+
const cwd = process.cwd();
|
|
33
|
+
const normalized = cwd.replace(/\/+$/, '');
|
|
34
|
+
|
|
35
|
+
// Already inside .claude/skills
|
|
36
|
+
if (normalized.endsWith('.claude/skills')) return cwd;
|
|
37
|
+
|
|
38
|
+
// Inside .claude — check for skills/ subdir
|
|
39
|
+
if (normalized.endsWith('.claude')) {
|
|
40
|
+
const skills = join(cwd, 'skills');
|
|
41
|
+
if (existsSync(skills)) return skills;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check cwd/.claude/skills directly
|
|
45
|
+
const direct = join(cwd, '.claude', 'skills');
|
|
46
|
+
if (existsSync(direct)) return direct;
|
|
47
|
+
|
|
48
|
+
// Recurse downward (skip noise, limited depth)
|
|
49
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next']);
|
|
50
|
+
function search(dir: string, depth: number): string | null {
|
|
51
|
+
if (depth <= 0) return null;
|
|
52
|
+
try {
|
|
53
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (!entry.isDirectory() || SKIP.has(entry.name)) continue;
|
|
56
|
+
if (entry.name === '.claude') {
|
|
57
|
+
const skills = join(dir, '.claude', 'skills');
|
|
58
|
+
if (existsSync(skills)) return skills;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isDirectory() || SKIP.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
63
|
+
const result = search(join(dir, entry.name), depth - 1);
|
|
64
|
+
if (result) return result;
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// permission denied, etc.
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return search(cwd, 5) ?? cwd;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extract skill name from a file path if it references .claude/skills/<name>/... */
|
|
76
|
+
function skillNameFromPath(filePath: string): string | null {
|
|
77
|
+
const marker = '.claude/skills/';
|
|
78
|
+
const idx = filePath.indexOf(marker);
|
|
79
|
+
if (idx === -1) return null;
|
|
80
|
+
const rest = filePath.slice(idx + marker.length);
|
|
81
|
+
const name = rest.split('/')[0];
|
|
82
|
+
return name || null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Extract skill name from a bash command if it references .claude/skills/<name>/... */
|
|
86
|
+
function skillNameFromCommand(command: string): string | null {
|
|
87
|
+
const marker = '.claude/skills/';
|
|
88
|
+
const idx = command.indexOf(marker);
|
|
89
|
+
if (idx === -1) return null;
|
|
90
|
+
const rest = command.slice(idx + marker.length);
|
|
91
|
+
const name = rest.split(/[\/\s'"]/)[0];
|
|
92
|
+
return name || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function denySkill(reason: string): never {
|
|
96
|
+
console.log(JSON.stringify({
|
|
97
|
+
hookSpecificOutput: {
|
|
98
|
+
hookEventName: 'PreToolUse',
|
|
99
|
+
permissionDecision: 'deny',
|
|
100
|
+
permissionDecisionReason: reason,
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Verify (SessionStart hook) ---
|
|
107
|
+
export async function verify(supabaseUrl: string, supabaseAnonKey: string): Promise<void> {
|
|
108
|
+
let input: Record<string, unknown>;
|
|
109
|
+
try {
|
|
110
|
+
input = JSON.parse(await readStdin());
|
|
111
|
+
} catch {
|
|
112
|
+
process.stderr.write('oathbound verify: invalid JSON on stdin\n');
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const sessionId: string = input.session_id as string;
|
|
116
|
+
if (!sessionId) {
|
|
117
|
+
process.stderr.write('oathbound verify: no session_id in stdin\n');
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const skillsDir = findSkillsDir();
|
|
122
|
+
|
|
123
|
+
// Guard: findSkillsDir() falls back to cwd if no .claude/skills found.
|
|
124
|
+
// In verify mode, we must NOT hash the entire project — only .claude/skills.
|
|
125
|
+
if (!skillsDir.endsWith('.claude/skills') && !skillsDir.includes('.claude/skills')) {
|
|
126
|
+
const state: SessionState = { verified: {}, rejected: [], ok: true };
|
|
127
|
+
writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
|
|
128
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no .claude/skills/ directory found — nothing to verify.' } }));
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// List skill subdirectories
|
|
133
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
134
|
+
const skillDirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.'));
|
|
135
|
+
|
|
136
|
+
if (skillDirs.length === 0) {
|
|
137
|
+
const state: SessionState = { verified: {}, rejected: [], ok: true };
|
|
138
|
+
writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
|
|
139
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'Oathbound: no skills installed — nothing to verify.' } }));
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Hash each local skill
|
|
144
|
+
const localHashes: Record<string, string> = {};
|
|
145
|
+
for (const dir of skillDirs) {
|
|
146
|
+
const fullPath = join(skillsDir, dir.name);
|
|
147
|
+
localHashes[dir.name] = hashSkillDir(fullPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Read enforcement config
|
|
151
|
+
const config = readOathboundConfig();
|
|
152
|
+
const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
|
|
153
|
+
|
|
154
|
+
// Fetch registry hashes from Supabase (latest version per skill name)
|
|
155
|
+
// If enforcement=audited, also fetch audit status
|
|
156
|
+
const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
|
157
|
+
const selectFields = enforcement === 'audited'
|
|
158
|
+
? 'name, namespace, content_hash, version, audits(passed)'
|
|
159
|
+
: 'name, namespace, content_hash, version';
|
|
160
|
+
const { data: skills, error } = await supabase
|
|
161
|
+
.from('skills')
|
|
162
|
+
.select(selectFields)
|
|
163
|
+
.order('version', { ascending: false });
|
|
164
|
+
|
|
165
|
+
if (error) {
|
|
166
|
+
process.stderr.write(`oathbound verify: failed to query registry: ${error.message}\n`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Build lookup: skill name → latest content_hash (dedupe by taking first per name)
|
|
171
|
+
const registryHashes = new Map<string, string>();
|
|
172
|
+
const auditedSkills = new Set<string>(); // skills with at least one passed audit
|
|
173
|
+
for (const skill of skills ?? []) {
|
|
174
|
+
if (!skill.content_hash) continue;
|
|
175
|
+
if (!registryHashes.has(skill.name)) {
|
|
176
|
+
registryHashes.set(skill.name, skill.content_hash);
|
|
177
|
+
}
|
|
178
|
+
if (enforcement === 'audited') {
|
|
179
|
+
const audits = (skill as Record<string, unknown>).audits as Array<{ passed: boolean }> | null;
|
|
180
|
+
if (audits?.some((a) => a.passed)) {
|
|
181
|
+
auditedSkills.add(skill.name);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const verified: Record<string, string> = {};
|
|
187
|
+
const rejected: { name: string; reason: string }[] = [];
|
|
188
|
+
const warnings: { name: string; reason: string }[] = [];
|
|
189
|
+
|
|
190
|
+
process.stderr.write(`${BRAND} ${TEAL}verifying skills...${RESET}\n`);
|
|
191
|
+
|
|
192
|
+
for (const [name, localHash] of Object.entries(localHashes)) {
|
|
193
|
+
const registryHash = registryHashes.get(name);
|
|
194
|
+
if (!registryHash) {
|
|
195
|
+
process.stderr.write(`${DIM} ${name}: ${localHash} (not in registry)${RESET}\n`);
|
|
196
|
+
if (enforcement === 'warn') {
|
|
197
|
+
warnings.push({ name, reason: 'not in registry' });
|
|
198
|
+
verified[name] = localHash; // allow in warn mode
|
|
199
|
+
} else {
|
|
200
|
+
rejected.push({ name, reason: 'not in registry' });
|
|
201
|
+
}
|
|
202
|
+
} else if (localHash !== registryHash) {
|
|
203
|
+
process.stderr.write(`${RED} ${name}: ${localHash} ≠ ${registryHash}${RESET}\n`);
|
|
204
|
+
if (enforcement === 'warn') {
|
|
205
|
+
warnings.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
|
|
206
|
+
verified[name] = localHash;
|
|
207
|
+
} else {
|
|
208
|
+
rejected.push({ name, reason: `content hash mismatch (local: ${localHash.slice(0, 8)}…, registry: ${registryHash.slice(0, 8)}…)` });
|
|
209
|
+
}
|
|
210
|
+
} else if (enforcement === 'audited' && !auditedSkills.has(name)) {
|
|
211
|
+
process.stderr.write(`${YELLOW} ${name}: ${localHash} (registered but not audited)${RESET}\n`);
|
|
212
|
+
rejected.push({ name, reason: 'no passed audit' });
|
|
213
|
+
} else {
|
|
214
|
+
process.stderr.write(`${GREEN} ${name}: ${localHash} ✓${RESET}\n`);
|
|
215
|
+
verified[name] = localHash;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ok = rejected.length === 0;
|
|
220
|
+
const state: SessionState = { verified, rejected, ok };
|
|
221
|
+
writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
|
|
222
|
+
|
|
223
|
+
if (ok && warnings.length === 0) {
|
|
224
|
+
const names = Object.keys(verified).join(', ');
|
|
225
|
+
console.log(JSON.stringify({
|
|
226
|
+
hookSpecificOutput: {
|
|
227
|
+
hookEventName: 'SessionStart',
|
|
228
|
+
additionalContext: `Oathbound: all ${Object.keys(verified).length} skill(s) verified against registry [${names}]. Skills are safe to use.`,
|
|
229
|
+
},
|
|
230
|
+
}));
|
|
231
|
+
process.exit(0);
|
|
232
|
+
} else if (ok && warnings.length > 0) {
|
|
233
|
+
// Warn mode — all skills allowed but with warnings
|
|
234
|
+
const warnLines = warnings.map((w) => ` ⚠ ${w.name}: ${w.reason}`).join('\n');
|
|
235
|
+
const names = Object.keys(verified).join(', ');
|
|
236
|
+
process.stderr.write(`${YELLOW}Oathbound warnings (enforcement: warn):\n${warnLines}${RESET}\n`);
|
|
237
|
+
console.log(JSON.stringify({
|
|
238
|
+
hookSpecificOutput: {
|
|
239
|
+
hookEventName: 'SessionStart',
|
|
240
|
+
additionalContext: `Oathbound (warn mode): ${Object.keys(verified).length} skill(s) allowed [${names}]. Warnings:\n${warnLines}`,
|
|
241
|
+
},
|
|
242
|
+
}));
|
|
243
|
+
process.exit(0);
|
|
244
|
+
} else {
|
|
245
|
+
const lines = rejected.map((r) => ` - ${r.name}: ${r.reason}`);
|
|
246
|
+
process.stderr.write(`Oathbound: skill verification failed! (enforcement: ${enforcement})\n${lines.join('\n')}\nDo NOT use unverified skills.\n`);
|
|
247
|
+
process.exit(2);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Verify --check (PreToolUse hook) ---
|
|
252
|
+
export async function verifyCheck(): Promise<void> {
|
|
253
|
+
let input: Record<string, unknown>;
|
|
254
|
+
try {
|
|
255
|
+
input = JSON.parse(await readStdin());
|
|
256
|
+
} catch {
|
|
257
|
+
process.stderr.write('oathbound verify --check: invalid JSON on stdin\n');
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
const sessionId: string = input.session_id as string;
|
|
261
|
+
const toolName: string = (input.tool_name as string) ?? '';
|
|
262
|
+
const toolInput = (input.tool_input as Record<string, unknown>) ?? {};
|
|
263
|
+
|
|
264
|
+
if (!sessionId) process.exit(0);
|
|
265
|
+
|
|
266
|
+
// Extract skill name based on which tool triggered the hook
|
|
267
|
+
let baseName: string | null = null;
|
|
268
|
+
|
|
269
|
+
if (toolName === 'Skill') {
|
|
270
|
+
const skill = toolInput.skill as string | undefined;
|
|
271
|
+
if (!skill) process.exit(0);
|
|
272
|
+
baseName = skill.includes(':') ? skill.split(':').pop()! : skill;
|
|
273
|
+
} else if (toolName === 'Bash') {
|
|
274
|
+
baseName = skillNameFromCommand((toolInput.command as string) ?? '');
|
|
275
|
+
} else if (toolName === 'Read') {
|
|
276
|
+
baseName = skillNameFromPath((toolInput.file_path as string) ?? '');
|
|
277
|
+
} else if (toolName === 'Glob' || toolName === 'Grep') {
|
|
278
|
+
baseName = skillNameFromPath((toolInput.path as string) ?? '');
|
|
279
|
+
// Also check pattern/glob fields for skill path references
|
|
280
|
+
if (!baseName) baseName = skillNameFromPath((toolInput.pattern as string) ?? '');
|
|
281
|
+
if (!baseName) baseName = skillNameFromPath((toolInput.glob as string) ?? '');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Not a skill-related operation — allow through
|
|
285
|
+
if (!baseName) process.exit(0);
|
|
286
|
+
|
|
287
|
+
const stateFile = sessionStatePath(sessionId);
|
|
288
|
+
if (!existsSync(stateFile)) process.exit(0);
|
|
289
|
+
|
|
290
|
+
let state: SessionState;
|
|
291
|
+
try {
|
|
292
|
+
state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
293
|
+
} catch {
|
|
294
|
+
process.stderr.write('oathbound verify --check: corrupt session state file\n');
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Find the skill directory and re-hash
|
|
299
|
+
const skillsDir = findSkillsDir();
|
|
300
|
+
const skillDir = join(skillsDir, baseName);
|
|
301
|
+
|
|
302
|
+
if (!existsSync(skillDir) || !statSync(skillDir).isDirectory()) {
|
|
303
|
+
denySkill(`Oathbound: skill directory not found for "${baseName}"`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const currentHash = hashSkillDir(skillDir);
|
|
307
|
+
const sessionHash = state.verified[baseName];
|
|
308
|
+
|
|
309
|
+
if (!sessionHash) {
|
|
310
|
+
process.stderr.write(`${RED} ${baseName}: ${currentHash} (not verified at session start)${RESET}\n`);
|
|
311
|
+
denySkill(`Oathbound: skill "${baseName}" was not verified at session start`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (currentHash !== sessionHash) {
|
|
315
|
+
process.stderr.write(`${RED} ${baseName}: ${currentHash} ≠ ${sessionHash} (tampered)${RESET}\n`);
|
|
316
|
+
denySkill(`Oathbound: skill "${baseName}" was modified since session start (tampering detected)`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
process.stderr.write(`${GREEN} ${baseName}: ${currentHash} ✓${RESET}\n`);
|
|
320
|
+
|
|
321
|
+
// Hash matches — allow
|
|
322
|
+
process.exit(0);
|
|
323
|
+
}
|