oathbound 0.7.0 → 0.8.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/auth.ts +1 -1
- package/cli.ts +1 -1
- package/package.json +3 -2
- package/push.ts +27 -30
- package/verify.ts +67 -13
package/auth.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { BRAND, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
|
|
|
10
10
|
|
|
11
11
|
const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
|
|
12
12
|
const SUPABASE_ANON_KEY = 'sb_publishable_T-rk0azNRqAMLLGCyadyhQ_ulk9685n';
|
|
13
|
-
const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://oathbound.ai';
|
|
13
|
+
const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://www.oathbound.ai';
|
|
14
14
|
|
|
15
15
|
const AUTH_DIR = join(homedir(), '.oathbound');
|
|
16
16
|
const AUTH_FILE = join(AUTH_DIR, 'auth.json');
|
package/cli.ts
CHANGED
|
@@ -25,7 +25,7 @@ export { stripJsoncComments, writeOathboundConfig, mergeClaudeSettings, type Mer
|
|
|
25
25
|
export { isNewer } from './update';
|
|
26
26
|
export { installDevDependency, type InstallResult, setup, addPrepareScript, type PrepareResult };
|
|
27
27
|
|
|
28
|
-
const VERSION = '0.
|
|
28
|
+
const VERSION = '0.8.0';
|
|
29
29
|
|
|
30
30
|
// --- Supabase ---
|
|
31
31
|
const SUPABASE_URL = 'https://mjnfqagwuewhgwbtrdgs.supabase.co';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oathbound",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Install verified Claude Code skills from the Oath Bound registry",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Josh Anderson",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@clack/prompts": "^1.1.0",
|
|
55
|
-
"@supabase/supabase-js": "^2"
|
|
55
|
+
"@supabase/supabase-js": "^2",
|
|
56
|
+
"yaml": "^2.8.2"
|
|
56
57
|
}
|
|
57
58
|
}
|
package/push.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { join, basename, resolve } from 'node:path';
|
|
3
3
|
import { intro, outro } from '@clack/prompts';
|
|
4
|
+
import { parse as yamlParse } from 'yaml';
|
|
4
5
|
import { BRAND, GREEN, DIM, BOLD, RESET, fail, spinner } from './ui';
|
|
5
6
|
import { getAccessToken } from './auth';
|
|
6
7
|
import { collectFiles } from './content-hash';
|
|
7
8
|
|
|
8
|
-
const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://oathbound.ai';
|
|
9
|
+
const API_BASE = process.env.OATHBOUND_API_URL ?? 'https://www.oathbound.ai';
|
|
9
10
|
|
|
10
11
|
export async function push(pathArg?: string): Promise<void> {
|
|
11
12
|
intro(BRAND);
|
|
@@ -29,24 +30,27 @@ export async function push(pathArg?: string): Promise<void> {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
const meta = parseFrontmatter(skillMdFile.content.toString('utf-8'));
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
const name = String(meta.name ?? '');
|
|
34
|
+
const description = String(meta.description ?? '');
|
|
35
|
+
const license = String(meta.license ?? '');
|
|
36
|
+
if (!name) fail('SKILL.md frontmatter missing: name');
|
|
37
|
+
if (!description) fail('SKILL.md frontmatter missing: description');
|
|
38
|
+
if (!license) fail('SKILL.md frontmatter missing: license');
|
|
39
|
+
|
|
40
|
+
const oathboundMeta = (meta.meta as Record<string, unknown> | undefined)?.oathbound as Record<string, unknown> | undefined;
|
|
41
|
+
const originalAuthor = String(oathboundMeta?.['original-author'] ?? '');
|
|
41
42
|
|
|
42
43
|
// Build files array with root dir prefix (API expects rootDir/path format)
|
|
43
44
|
const files = rawFiles.map(f => ({
|
|
44
|
-
path: `${
|
|
45
|
+
path: `${name}/${f.path}`,
|
|
45
46
|
content: f.content.toString('utf-8'),
|
|
46
47
|
}));
|
|
47
48
|
|
|
48
|
-
console.log(`${DIM} name: ${
|
|
49
|
-
console.log(`${DIM} license: ${
|
|
49
|
+
console.log(`${DIM} name: ${name}${RESET}`);
|
|
50
|
+
console.log(`${DIM} license: ${license}${RESET}`);
|
|
51
|
+
if (originalAuthor) {
|
|
52
|
+
console.log(`${DIM} original author: ${originalAuthor}${RESET}`);
|
|
53
|
+
}
|
|
50
54
|
console.log(`${DIM} ${files.length} file(s)${RESET}`);
|
|
51
55
|
|
|
52
56
|
// Authenticate
|
|
@@ -62,11 +66,12 @@ export async function push(pathArg?: string): Promise<void> {
|
|
|
62
66
|
Authorization: `Bearer ${token}`,
|
|
63
67
|
},
|
|
64
68
|
body: JSON.stringify({
|
|
65
|
-
name
|
|
66
|
-
description
|
|
67
|
-
license
|
|
68
|
-
compatibility: meta.compatibility || null,
|
|
69
|
-
allowedTools: meta['allowed-tools'] || null,
|
|
69
|
+
name,
|
|
70
|
+
description,
|
|
71
|
+
license,
|
|
72
|
+
compatibility: String(meta.compatibility ?? '') || null,
|
|
73
|
+
allowedTools: String(meta['allowed-tools'] ?? '') || null,
|
|
74
|
+
originalAuthor: originalAuthor || null,
|
|
70
75
|
files,
|
|
71
76
|
}),
|
|
72
77
|
});
|
|
@@ -109,18 +114,10 @@ function resolveSkillDir(pathArg?: string): string {
|
|
|
109
114
|
);
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
/**
|
|
113
|
-
function parseFrontmatter(content: string): Record<string,
|
|
117
|
+
/** Parse YAML frontmatter into a nested object */
|
|
118
|
+
function parseFrontmatter(content: string): Record<string, unknown> {
|
|
114
119
|
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
115
120
|
if (!match) return {};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
for (const line of match[1].split('\n')) {
|
|
119
|
-
const idx = line.indexOf(':');
|
|
120
|
-
if (idx === -1) continue;
|
|
121
|
-
const key = line.slice(0, idx).trim();
|
|
122
|
-
const value = line.slice(idx + 1).trim();
|
|
123
|
-
if (key) meta[key] = value;
|
|
124
|
-
}
|
|
125
|
-
return meta;
|
|
121
|
+
const parsed = yamlParse(match[1]);
|
|
122
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : {};
|
|
126
123
|
}
|
package/verify.ts
CHANGED
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
writeFileSync, readFileSync, existsSync,
|
|
4
4
|
readdirSync, statSync,
|
|
5
5
|
} from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, RESET } from './ui';
|
|
8
|
+
import { BRAND, TEAL, GREEN, RED, YELLOW, DIM, BOLD, RESET } from './ui';
|
|
9
9
|
import { hashSkillDir } from './content-hash';
|
|
10
10
|
import { readOathboundConfig, type EnforcementLevel } from './config';
|
|
11
11
|
|
|
@@ -92,17 +92,51 @@ function skillNameFromCommand(command: string): string | null {
|
|
|
92
92
|
return name || null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
function denySkill(reason: string): never {
|
|
95
|
+
function denySkill(skillName: string, reason: string, enforcement: EnforcementLevel): never {
|
|
96
|
+
process.stderr.write(`\n${TEAL}${BOLD}⬡ oathbound${RESET} ${RED}${BOLD}✗ Blocked${RESET} skill ${BOLD}"${skillName}"${RESET} ${DIM}(${reason})${RESET}\n`);
|
|
97
|
+
process.stderr.write(`${DIM} enforcement: ${enforcement} — switch to "warn" in .oathbound.jsonc for development${RESET}\n\n`);
|
|
96
98
|
console.log(JSON.stringify({
|
|
97
99
|
hookSpecificOutput: {
|
|
98
100
|
hookEventName: 'PreToolUse',
|
|
99
101
|
permissionDecision: 'deny',
|
|
100
|
-
permissionDecisionReason: reason
|
|
102
|
+
permissionDecisionReason: `Oathbound: skill "${skillName}" blocked — ${reason} (enforcement: ${enforcement})`,
|
|
101
103
|
},
|
|
102
104
|
}));
|
|
103
105
|
process.exit(0);
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
function warnSkill(skillName: string, reason: string): never {
|
|
109
|
+
process.stderr.write(`\n${TEAL}${BOLD}⬡ oathbound${RESET} ${YELLOW}⚠ Warning:${RESET} skill ${BOLD}"${skillName}"${RESET} ${DIM}(${reason})${RESET}\n\n`);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Check if a tool operation references a skill in another project, not ours. */
|
|
114
|
+
function isExternalSkillAccess(
|
|
115
|
+
toolName: string,
|
|
116
|
+
toolInput: Record<string, unknown>,
|
|
117
|
+
skillsDir: string,
|
|
118
|
+
baseName: string,
|
|
119
|
+
): boolean {
|
|
120
|
+
const resolvedSkillsDir = resolve(skillsDir);
|
|
121
|
+
|
|
122
|
+
if (toolName === 'Read') {
|
|
123
|
+
const p = String(toolInput.file_path ?? '');
|
|
124
|
+
if (p && !resolve(p).startsWith(resolvedSkillsDir)) return true;
|
|
125
|
+
}
|
|
126
|
+
if (toolName === 'Glob' || toolName === 'Grep') {
|
|
127
|
+
const p = String(toolInput.path ?? '');
|
|
128
|
+
if (p && !resolve(p).startsWith(resolvedSkillsDir)) return true;
|
|
129
|
+
}
|
|
130
|
+
if (toolName === 'Bash') {
|
|
131
|
+
const cmd = String(toolInput.command ?? '');
|
|
132
|
+
// If the command contains an absolute path to .claude/skills/baseName
|
|
133
|
+
// that ISN'T under our project's skills dir, it's external
|
|
134
|
+
if (cmd.includes('/.claude/skills/' + baseName) && !cmd.includes(resolvedSkillsDir)) return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
106
140
|
// --- Verify (SessionStart hook) ---
|
|
107
141
|
export async function verify(supabaseUrl: string, supabaseAnonKey: string): Promise<void> {
|
|
108
142
|
let input: Record<string, unknown>;
|
|
@@ -233,7 +267,8 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
|
|
|
233
267
|
// Warn mode — all skills allowed but with warnings
|
|
234
268
|
const warnLines = warnings.map((w) => ` ⚠ ${w.name}: ${w.reason}`).join('\n');
|
|
235
269
|
const names = Object.keys(verified).join(', ');
|
|
236
|
-
|
|
270
|
+
const warnHeader = `${TEAL}${BOLD}⬡ oathbound${RESET} ${YELLOW}⚠ Unverified skills (enforcement: warn):${RESET}`;
|
|
271
|
+
process.stderr.write(`${warnHeader}\n${warnLines}\n${DIM} Skills allowed but not verified against registry.${RESET}\n`);
|
|
237
272
|
console.log(JSON.stringify({
|
|
238
273
|
hookSpecificOutput: {
|
|
239
274
|
hookEventName: 'SessionStart',
|
|
@@ -242,8 +277,8 @@ export async function verify(supabaseUrl: string, supabaseAnonKey: string): Prom
|
|
|
242
277
|
}));
|
|
243
278
|
process.exit(0);
|
|
244
279
|
} else {
|
|
245
|
-
const lines = rejected.map((r) => `
|
|
246
|
-
process.stderr.write(
|
|
280
|
+
const lines = rejected.map((r) => ` ${RED}✗${RESET} ${r.name}: ${r.reason}`);
|
|
281
|
+
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`);
|
|
247
282
|
process.exit(2);
|
|
248
283
|
}
|
|
249
284
|
}
|
|
@@ -284,6 +319,16 @@ export async function verifyCheck(): Promise<void> {
|
|
|
284
319
|
// Not a skill-related operation — allow through
|
|
285
320
|
if (!baseName) process.exit(0);
|
|
286
321
|
|
|
322
|
+
// Read enforcement config
|
|
323
|
+
const config = readOathboundConfig();
|
|
324
|
+
const enforcement: EnforcementLevel = config?.enforcement ?? 'warn';
|
|
325
|
+
|
|
326
|
+
// Check if the tool is accessing a skill in another project — not our concern
|
|
327
|
+
const skillsDir = findSkillsDir();
|
|
328
|
+
if (isExternalSkillAccess(toolName, toolInput, skillsDir, baseName)) {
|
|
329
|
+
process.exit(0);
|
|
330
|
+
}
|
|
331
|
+
|
|
287
332
|
const stateFile = sessionStatePath(sessionId);
|
|
288
333
|
if (!existsSync(stateFile)) process.exit(0);
|
|
289
334
|
|
|
@@ -296,24 +341,33 @@ export async function verifyCheck(): Promise<void> {
|
|
|
296
341
|
}
|
|
297
342
|
|
|
298
343
|
// Find the skill directory and re-hash
|
|
299
|
-
const skillsDir = findSkillsDir();
|
|
300
344
|
const skillDir = join(skillsDir, baseName);
|
|
301
345
|
|
|
302
346
|
if (!existsSync(skillDir) || !statSync(skillDir).isDirectory()) {
|
|
303
|
-
|
|
347
|
+
if (enforcement === 'warn') {
|
|
348
|
+
warnSkill(baseName, 'not installed locally');
|
|
349
|
+
} else {
|
|
350
|
+
denySkill(baseName, 'not installed locally', enforcement);
|
|
351
|
+
}
|
|
304
352
|
}
|
|
305
353
|
|
|
306
354
|
const currentHash = hashSkillDir(skillDir);
|
|
307
355
|
const sessionHash = state.verified[baseName];
|
|
308
356
|
|
|
309
357
|
if (!sessionHash) {
|
|
310
|
-
|
|
311
|
-
|
|
358
|
+
if (enforcement === 'warn') {
|
|
359
|
+
warnSkill(baseName, 'not verified at session start');
|
|
360
|
+
} else {
|
|
361
|
+
denySkill(baseName, 'not verified at session start', enforcement);
|
|
362
|
+
}
|
|
312
363
|
}
|
|
313
364
|
|
|
314
365
|
if (currentHash !== sessionHash) {
|
|
315
|
-
|
|
316
|
-
|
|
366
|
+
if (enforcement === 'warn') {
|
|
367
|
+
warnSkill(baseName, `modified since session start (${currentHash.slice(0, 8)}… ≠ ${sessionHash.slice(0, 8)}…)`);
|
|
368
|
+
} else {
|
|
369
|
+
denySkill(baseName, `modified since session start — tampering detected (${currentHash.slice(0, 8)}… ≠ ${sessionHash.slice(0, 8)}…)`, enforcement);
|
|
370
|
+
}
|
|
317
371
|
}
|
|
318
372
|
|
|
319
373
|
process.stderr.write(`${GREEN} ${baseName}: ${currentHash} ✓${RESET}\n`);
|