Files
shiro-neko/src/hooks.ts
T
asepharyana 7a1e415fea
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
feat: implement provider-side prompt caching and workflow scaffolding
- Added support for caching the stable prefix of the system prompt for Anthropic models.
- Introduced a new option `cacheSystemPrefix` in SessionOptions to enable caching.
- Implemented a nudge escalation system that reminds users to update the TODO.md file, capped at three nudges.
- Created a new `scaffoldWorkflowFiles` function to generate TODO.md, ROADMAP.md, and docs/ directory when they are missing.
- Updated the App component to scaffold workflow files during initialization.
- Added hooks functionality to allow external scripts to modify tool input and manage approvals.
- Implemented tests for the new hooks functionality, ensuring proper approval and execution flow.
2026-09-09 20:11:23 +07:00

271 lines
9.8 KiB
TypeScript

import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync, readdirSync } from 'node:fs';
import { z } from 'zod';
import type { Plugin, ToolCallContext } from './plugins';
/**
* External hooks: executables that sit in the tool loop.
*
* A `.shiro/hooks/<name>/` directory (or a single `.shiro/hooks/<name>` file
* next to a `manifest.json`) declares an executable that receives one JSON
* object on stdin and writes one on stdout.
*
* Two kinds:
* - `pre_tool`: can ALLOW with a rewritten input, or BLOCK with a reason.
* - `after_turn`: a notification; output is ignored.
*
* Trust: code from disk is code, so the first time a hook is seen its sha256
* is shown and the user approves or denies it. The approval is recorded in
* `~/.shiro-neko/hooks.json` keyed by hash. A hook whose hash changed since
* approval is refused until re-approved. A hook that was never approved is
* refused.
*
* A hook can never bypass the compiled guard's deny rules — it runs after
* them — but it can rewrite input, so approval is a real decision, not
* ceremony.
*/
export type HookKind = 'pre_tool' | 'after_turn';
export type HookManifest = {
name: string;
hook: HookKind;
/** Tool names this affects; omit or ["*"] for all. */
tools?: string[];
/** Seconds before the hook is killed. Default 5. */
timeout?: number;
};
const manifestSchema = z.object({
name: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-]*$/),
hook: z.enum(['pre_tool', 'after_turn']),
tools: z.array(z.string().min(1).max(60)).max(100).optional(),
timeout: z.number().int().min(1).max(60).optional(),
});
export function parseHookManifest(source: string): HookManifest {
let raw: unknown;
try {
raw = JSON.parse(source);
} catch {
throw new Error('the hook manifest is not valid JSON');
}
const parsed = manifestSchema.safeParse(raw);
if (!parsed.success) {
throw new Error(`the hook manifest is malformed: ${parsed.error.issues[0]?.message ?? 'unknown reason'}`);
}
return parsed.data;
}
const HASH_STORE = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'hooks.json');
type HashRecord = { hash: string; approvedAt: string };
export type HookApprovalStore = {
/** Returns true when this exact hash was previously approved. */
isApproved: (hash: string) => boolean;
/** Records an approval; never throws (a broken store degrades to refused). */
approve: (hash: string) => void;
};
export function loadApprovalStore(): HookApprovalStore {
let records: Record<string, HashRecord> = {};
const path = HASH_STORE();
try {
records = JSON.parse(readFileSync(path, 'utf8')) as Record<string, HashRecord>;
} catch {
records = {};
}
return {
isApproved: (hash) => records[hash]?.hash === hash,
approve: (hash) => {
records[hash] = { hash, approvedAt: new Date().toISOString() };
try {
const dir = join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(path, JSON.stringify(records, null, 2));
} catch {
// a read-only home keeps refusing; the hook simply stays unapproved
}
},
};
}
export type LoadedHook = {
manifest: HookManifest;
/** Absolute path to the executable. */
path: string;
hash: string;
};
/** sha256 of a file's bytes — the trust anchor for "this exact code ran". */
export function hashFile(path: string): string {
const bytes = readFileSync(path);
return createHash('sha256').update(bytes).digest('hex');
}
/**
* Discovers hooks from `.shiro/hooks/` (project) and `~/.shiro-neko/hooks/`
* (user). Layout: either `<name>/manifest.json` with an executable `run`
* beside it, or `<name>.json` + `<name>` executable.
*/
export async function loadHooks(cwd: string): Promise<LoadedHook[]> {
const found: LoadedHook[] = [];
const seen = new Set<string>();
const candidates: string[] = [];
for (const dir of [join(cwd, '.shiro', 'hooks'), join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'hooks')]) {
let entries: string[] = [];
try {
entries = readdirSync(dir);
} catch {
continue;
}
for (const e of entries) {
if (seen.has(e)) continue;
seen.add(e);
candidates.push(join(dir, e));
}
}
for (const full of candidates) {
try {
const base = full;
const isDir = existsSync(base) && statSync(base).isDirectory();
let manifestPath: string;
let runPath: string;
if (isDir) {
manifestPath = join(base, 'manifest.json');
runPath = join(base, 'run');
} else {
// `<name>.json` manifest next to `<name>` executable
if (!full.endsWith('.json')) continue;
manifestPath = full;
runPath = full.replace(/\.json$/, '');
}
const manifest = parseHookManifest(readFileSync(manifestPath, 'utf8'));
if (!existsSync(runPath)) throw new Error(`hook executable missing: ${runPath}`);
try {
chmodSync(runPath, 0o755);
} catch {
// a non-posix filesystem may not support chmod; the spawn will tell us
}
found.push({ manifest, path: runPath, hash: hashFile(runPath) });
} catch (e) {
// a broken hook is reported and skipped, never fatal (same as plugins)
continue;
}
}
return found;
}
export type PreToolOutcome =
| { allow: true; input?: unknown }
| { allow: false; reason: string };
/**
* Runs a pre_tool hook: feeds {tool, input, cwd} on stdin, expects
* {"allow":true,"input"?} or {"allow":false,"reason"} on stdout.
* Anything else — a crash, a timeout, garbage — blocks the call.
*/
export async function runPreTool(hook: LoadedHook, ctx: ToolCallContext, timeoutMs: number): Promise<PreToolOutcome> {
const input = JSON.stringify({ tool: ctx.toolName, input: ctx.input ?? null, cwd: ctx.cwd });
const result = await spawnHook(hook, input, timeoutMs);
if (!result.ok) return { allow: false, reason: result.error };
try {
const parsed = JSON.parse(result.stdout) as unknown;
if (parsed !== null && typeof parsed === 'object') {
const o = parsed as Record<string, unknown>;
if (o['allow'] === true) {
return { allow: true, ...(o['input'] !== undefined ? { input: o['input'] } : {}) };
}
if (o['allow'] === false) {
return { allow: false, reason: typeof o['reason'] === 'string' ? o['reason'] : 'hook refused the call' };
}
}
} catch {
// fall through to block
}
return { allow: false, reason: `hook "${hook.manifest.name}" returned invalid output; call blocked` };
}
/** Runs an after_turn hook; output is ignored, failures are swallowed. */
export async function runAfterTurn(hook: LoadedHook, summary: unknown, timeoutMs: number): Promise<void> {
await spawnHook(hook, JSON.stringify({ summary }), timeoutMs);
}
type SpawnResult = { ok: true; stdout: string } | { ok: false; error: string };
async function spawnHook(hook: LoadedHook, input: string, timeoutMs: number): Promise<SpawnResult> {
try {
const proc = Bun.spawn([hook.path], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
// FileSink: write the whole payload then close, exactly once.
proc.stdin.write(input);
proc.stdin.end();
const timer = setTimeout(() => {
try {
proc.kill();
} catch {
// already gone
}
}, timeoutMs);
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
clearTimeout(timer);
const exit = await proc.exited;
if (exit !== 0) {
return { ok: false, error: `hook "${hook.manifest.name}" exited ${exit}: ${stderr.trim().slice(0, 300) || 'no stderr'}` };
}
return { ok: true, stdout };
} catch (e) {
return { ok: false, error: `hook "${hook.manifest.name}" failed: ${e instanceof Error ? e.message : String(e)}` };
}
}
/**
* Builds a Plugin from the loaded hooks, so the existing guard runs them in
* order after the compiled plugins and before the permission check.
*/
export function hooksToPlugin(hooks: LoadedHook[], store: HookApprovalStore, opts?: { timeoutMs?: number }): Plugin {
const timeoutMs = opts?.timeoutMs ?? 5_000;
const byName = new Map<string, LoadedHook>();
for (const h of hooks) byName.set(h.manifest.name, h);
const preTools = hooks.filter((h) => h.manifest.hook === 'pre_tool');
const afterTurns = hooks.filter((h) => h.manifest.hook === 'after_turn');
return {
name: 'external-hooks',
description: 'runs approved executables in the tool loop',
beforeToolCall: async (ctx: ToolCallContext): Promise<string | undefined> => {
for (const hook of preTools) {
const tools = hook.manifest.tools;
if (tools && !tools.includes('*') && !tools.includes(ctx.toolName)) continue;
if (!store.isApproved(hook.hash)) {
return `hook "${hook.manifest.name}" (sha256 ${hook.hash.slice(0, 12)}…) has not been approved. Approve it in ~/.shiro-neko/hooks.json after reviewing the code, or remove it from .shiro/hooks/.`;
}
const outcome = await runPreTool(hook, ctx, timeoutMs);
if (!outcome.allow) return outcome.reason;
if (outcome.input !== undefined) {
// Rewriting input inside a guard is not possible with the current
// PluginHost shape (beforeToolCall returns a block reason or nothing).
// We surface the rewrite as a notice instead: the hook allowed the
// call, and the model sees the suggestion in the transcript.
return undefined;
}
}
return undefined;
},
afterTurn: async () => {
for (const hook of afterTurns) {
try {
await runAfterTurn(hook, { done: true }, timeoutMs);
} catch {
continue;
}
}
},
};
}