oathbound 0.6.1 → 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 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/bin/cli.cjs CHANGED
File without changes
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.6.1';
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.6.1",
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);
@@ -24,21 +25,32 @@ export async function push(pathArg?: string): Promise<void> {
24
25
 
25
26
  // Parse SKILL.md frontmatter to extract metadata
26
27
  const skillMdFile = rawFiles.find(f => f.path === 'SKILL.md');
27
- if (!skillMdFile) fail('SKILL.md not found in collected files');
28
+ if (!skillMdFile) {
29
+ fail('SKILL.md not found in collected files');
30
+ }
28
31
 
29
32
  const meta = parseFrontmatter(skillMdFile.content.toString('utf-8'));
30
- if (!meta.name) fail('SKILL.md frontmatter missing: name');
31
- if (!meta.description) fail('SKILL.md frontmatter missing: description');
32
- if (!meta.license) fail('SKILL.md frontmatter missing: license');
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'] ?? '');
33
42
 
34
43
  // Build files array with root dir prefix (API expects rootDir/path format)
35
44
  const files = rawFiles.map(f => ({
36
- path: `${meta.name}/${f.path}`,
45
+ path: `${name}/${f.path}`,
37
46
  content: f.content.toString('utf-8'),
38
47
  }));
39
48
 
40
- console.log(`${DIM} name: ${meta.name}${RESET}`);
41
- console.log(`${DIM} license: ${meta.license}${RESET}`);
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
+ }
42
54
  console.log(`${DIM} ${files.length} file(s)${RESET}`);
43
55
 
44
56
  // Authenticate
@@ -54,11 +66,12 @@ export async function push(pathArg?: string): Promise<void> {
54
66
  Authorization: `Bearer ${token}`,
55
67
  },
56
68
  body: JSON.stringify({
57
- name: meta.name,
58
- description: meta.description,
59
- license: meta.license,
60
- compatibility: meta.compatibility || null,
61
- 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,
62
75
  files,
63
76
  }),
64
77
  });
@@ -101,18 +114,10 @@ function resolveSkillDir(pathArg?: string): string {
101
114
  );
102
115
  }
103
116
 
104
- /** Lightweight frontmatter parser (mirrors frontend/lib/skill-validator.ts) */
105
- function parseFrontmatter(content: string): Record<string, string> {
117
+ /** Parse YAML frontmatter into a nested object */
118
+ function parseFrontmatter(content: string): Record<string, unknown> {
106
119
  const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
107
120
  if (!match) return {};
108
-
109
- const meta: Record<string, string> = {};
110
- for (const line of match[1].split('\n')) {
111
- const idx = line.indexOf(':');
112
- if (idx === -1) continue;
113
- const key = line.slice(0, idx).trim();
114
- const value = line.slice(idx + 1).trim();
115
- if (key) meta[key] = value;
116
- }
117
- return meta;
121
+ const parsed = yamlParse(match[1]);
122
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : {};
118
123
  }
package/ui.ts CHANGED
@@ -8,7 +8,13 @@ export const DIM = USE_COLOR ? '\x1b[2m' : '';
8
8
  export const BOLD = USE_COLOR ? '\x1b[1m' : '';
9
9
  export const RESET = USE_COLOR ? '\x1b[0m' : '';
10
10
 
11
- export const BRAND = `${TEAL}${BOLD}🛡️ oathbound${RESET}`;
11
+ const BRAND_MARKS = ['✦', '✧', '✿', '⬡'];
12
+
13
+ export function brand(): string {
14
+ return `${TEAL}${BOLD}⬡ oathbound${RESET}`;
15
+ }
16
+
17
+ export const BRAND = brand();
12
18
 
13
19
  export function usage(exitCode = 1): never {
14
20
  console.log(`
@@ -41,13 +47,13 @@ export function fail(message: string, detail?: string): never {
41
47
  }
42
48
 
43
49
  export function spinner(text: string): { stop: () => void } {
44
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
45
50
  let i = 0;
46
- process.stdout.write(`${TEAL} ${frames[0]} ${text}${RESET}`);
51
+ const render = () => `\r${TEAL}${BOLD} ${BRAND_MARKS[i % BRAND_MARKS.length]} ${RESET}${TEAL}${text}${RESET}`;
52
+ process.stdout.write(render());
47
53
  const interval = setInterval(() => {
48
- i = (i + 1) % frames.length;
49
- process.stdout.write(`\r${TEAL} ${frames[i]} ${text}${RESET}`);
50
- }, 80);
54
+ i++;
55
+ process.stdout.write(render());
56
+ }, 150);
51
57
  return {
52
58
  stop() {
53
59
  clearInterval(interval);
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
- process.stderr.write(`${YELLOW}Oathbound warnings (enforcement: warn):\n${warnLines}${RESET}\n`);
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) => ` - ${r.name}: ${r.reason}`);
246
- process.stderr.write(`Oathbound: skill verification failed! (enforcement: ${enforcement})\n${lines.join('\n')}\nDo NOT use unverified skills.\n`);
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
- denySkill(`Oathbound: skill directory not found for "${baseName}"`);
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
- process.stderr.write(`${RED} ${baseName}: ${currentHash} (not verified at session start)${RESET}\n`);
311
- denySkill(`Oathbound: skill "${baseName}" was not verified at session start`);
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
- process.stderr.write(`${RED} ${baseName}: ${currentHash} ${sessionHash} (tampered)${RESET}\n`);
316
- denySkill(`Oathbound: skill "${baseName}" was modified since session start (tampering detected)`);
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`);