@panchr/tyr 0.1.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.
@@ -0,0 +1,34 @@
1
+ import { checkCache, writeCache } from "../cache.ts";
2
+ import type { PermissionRequest, Provider, ProviderResult } from "../types.ts";
3
+
4
+ /** Provider that checks the decision cache. Sits at the front of the pipeline
5
+ * so cached results short-circuit more expensive providers.
6
+ *
7
+ * After the pipeline runs, call `cacheResult()` to store a new result from
8
+ * a downstream provider. */
9
+ export class CacheProvider implements Provider {
10
+ readonly name = "cache";
11
+
12
+ constructor(private configHash: string) {}
13
+
14
+ async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
15
+ const hit = checkCache(req, this.configHash);
16
+ if (hit) {
17
+ return {
18
+ decision: hit.decision,
19
+ reason: hit.reason ?? undefined,
20
+ };
21
+ }
22
+ return { decision: "abstain" };
23
+ }
24
+
25
+ /** Store a definitive result from a downstream provider. */
26
+ cacheResult(
27
+ req: PermissionRequest,
28
+ decision: "allow" | "deny",
29
+ provider: string,
30
+ reason: string | undefined,
31
+ ): void {
32
+ writeCache(req, decision, provider, reason, this.configHash);
33
+ }
34
+ }
@@ -0,0 +1,45 @@
1
+ import type { ClaudeAgent } from "../agents/claude.ts";
2
+ import type { PermissionRequest, Provider, ProviderResult } from "../types.ts";
3
+ import { parseCommands } from "./shell-parser.ts";
4
+
5
+ /** Provider that splits chained shell commands and checks each sub-command
6
+ * against the Claude agent's configured permissions.
7
+ *
8
+ * - If every sub-command is individually allowed → allow.
9
+ * - If any sub-command is denied → deny.
10
+ * - Otherwise (any unknown) → abstain. */
11
+ export class ChainedCommandsProvider implements Provider {
12
+ readonly name = "chained-commands";
13
+
14
+ constructor(
15
+ private agent: ClaudeAgent,
16
+ private verbose = false,
17
+ ) {}
18
+
19
+ async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
20
+ if (req.tool_name !== "Bash") return { decision: "abstain" };
21
+
22
+ const command = req.tool_input.command;
23
+ if (typeof command !== "string" || command.trim() === "")
24
+ return { decision: "abstain" };
25
+
26
+ const subCommands = parseCommands(command);
27
+ if (subCommands.length === 0) return { decision: "abstain" };
28
+
29
+ let allAllowed = true;
30
+ for (const sub of subCommands) {
31
+ const result = this.agent.isCommandAllowed(sub.command);
32
+ if (this.verbose) {
33
+ console.error(`[tyr] chained-commands: "${sub.command}" → ${result}`);
34
+ }
35
+ if (result === "deny") return { decision: "deny" };
36
+ if (result !== "allow") allAllowed = false;
37
+ }
38
+
39
+ const decision = allAllowed ? "allow" : "abstain";
40
+ if (this.verbose) {
41
+ console.error(`[tyr] chained-commands: overall → ${decision}`);
42
+ }
43
+ return { decision };
44
+ }
45
+ }
@@ -0,0 +1,120 @@
1
+ import type { ClaudeAgent } from "../agents/claude.ts";
2
+ import { buildPrompt, parseLlmResponse } from "../prompts.ts";
3
+ import type {
4
+ ClaudeConfig,
5
+ PermissionRequest,
6
+ Provider,
7
+ ProviderResult,
8
+ } from "../types.ts";
9
+
10
+ export { buildPrompt, parseLlmResponse };
11
+
12
+ const S_TO_MS = 1000;
13
+
14
+ /** Provider that asks an LLM (via `claude -p`) to evaluate permission requests.
15
+ * Only handles Bash tool requests. Abstains for everything else. */
16
+ export class ClaudeProvider implements Provider {
17
+ readonly name = "claude";
18
+
19
+ private timeoutMs: number;
20
+ private model: string;
21
+ private canDeny: boolean;
22
+
23
+ constructor(
24
+ private agent: ClaudeAgent,
25
+ config: ClaudeConfig,
26
+ private verbose: boolean = false,
27
+ ) {
28
+ this.model = config.model;
29
+ this.timeoutMs = config.timeout * S_TO_MS;
30
+ this.canDeny = config.canDeny;
31
+ }
32
+
33
+ async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
34
+ if (req.tool_name !== "Bash") return { decision: "abstain" };
35
+
36
+ const command = req.tool_input.command;
37
+ if (typeof command !== "string" || command.trim() === "")
38
+ return { decision: "abstain" };
39
+
40
+ const prompt = buildPrompt(req, this.agent, this.canDeny);
41
+
42
+ // Clear CLAUDECODE env var so claude -p doesn't refuse to run
43
+ // inside a Claude Code session (tyr is invoked as a hook).
44
+ const env: Record<string, string | undefined> = {
45
+ ...process.env,
46
+ CLAUDECODE: undefined,
47
+ };
48
+
49
+ const proc = Bun.spawn(
50
+ [
51
+ "claude",
52
+ "-p",
53
+ "--output-format",
54
+ "text",
55
+ "--no-session-persistence",
56
+ "--model",
57
+ this.model,
58
+ ],
59
+ {
60
+ stdin: new Response(prompt).body,
61
+ stdout: "pipe",
62
+ stderr: "pipe",
63
+ env,
64
+ },
65
+ );
66
+
67
+ let timer: Timer | undefined;
68
+ const result = await Promise.race([
69
+ (async () => {
70
+ const [stdout, stderr] = await Promise.all([
71
+ new Response(proc.stdout).text(),
72
+ new Response(proc.stderr).text(),
73
+ ]);
74
+ const exitCode = await proc.exited;
75
+ return { stdout, stderr, exitCode, timedOut: false };
76
+ })(),
77
+ new Promise<{
78
+ stdout: string;
79
+ stderr: string;
80
+ exitCode: number;
81
+ timedOut: boolean;
82
+ }>((resolve) => {
83
+ timer = setTimeout(() => {
84
+ proc.kill();
85
+ resolve({
86
+ stdout: "",
87
+ stderr: "timeout",
88
+ exitCode: -1,
89
+ timedOut: true,
90
+ });
91
+ }, this.timeoutMs);
92
+ }),
93
+ ]);
94
+ clearTimeout(timer);
95
+
96
+ if (this.verbose) {
97
+ console.error(
98
+ `[tyr] claude: exitCode=${result.exitCode} timedOut=${result.timedOut}`,
99
+ );
100
+ if (result.stdout)
101
+ console.error(`[tyr] claude stdout: ${result.stdout.trim()}`);
102
+ if (result.stderr)
103
+ console.error(`[tyr] claude stderr: ${result.stderr.trim()}`);
104
+ }
105
+
106
+ if (result.timedOut || result.exitCode !== 0) {
107
+ return { decision: "abstain" };
108
+ }
109
+
110
+ const llmDecision = parseLlmResponse(result.stdout);
111
+ if (!llmDecision) return { decision: "abstain" };
112
+
113
+ // When canDeny is false, convert deny→abstain so the user gets prompted
114
+ if (!this.canDeny && llmDecision.decision === "deny") {
115
+ return { decision: "abstain", reason: llmDecision.reason };
116
+ }
117
+
118
+ return { decision: llmDecision.decision, reason: llmDecision.reason };
119
+ }
120
+ }
@@ -0,0 +1,112 @@
1
+ import type { ClaudeAgent } from "../agents/claude.ts";
2
+ import { buildPrompt, parseLlmResponse } from "../prompts.ts";
3
+ import type {
4
+ OpenRouterConfig,
5
+ PermissionRequest,
6
+ Provider,
7
+ ProviderResult,
8
+ } from "../types.ts";
9
+
10
+ const S_TO_MS = 1000;
11
+
12
+ /** Provider that calls OpenRouter's chat completions API to evaluate permission requests.
13
+ * Only handles Bash tool requests. Abstains for everything else. */
14
+ export class OpenRouterProvider implements Provider {
15
+ readonly name = "openrouter";
16
+
17
+ private timeoutMs: number;
18
+ private model: string;
19
+ private endpoint: string;
20
+ private canDeny: boolean;
21
+
22
+ constructor(
23
+ private agent: ClaudeAgent,
24
+ config: OpenRouterConfig,
25
+ private verbose: boolean = false,
26
+ ) {
27
+ this.model = config.model;
28
+ this.timeoutMs = config.timeout * S_TO_MS;
29
+ this.canDeny = config.canDeny;
30
+ this.endpoint = config.endpoint;
31
+ }
32
+
33
+ async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
34
+ if (req.tool_name !== "Bash") return { decision: "abstain" };
35
+
36
+ const command = req.tool_input.command;
37
+ if (typeof command !== "string" || command.trim() === "")
38
+ return { decision: "abstain" };
39
+
40
+ const apiKey = process.env.OPENROUTER_API_KEY;
41
+ if (!apiKey) {
42
+ console.error(
43
+ "[tyr] openrouter: OPENROUTER_API_KEY not set, skipping LLM check",
44
+ );
45
+ return { decision: "abstain" };
46
+ }
47
+
48
+ const prompt = buildPrompt(req, this.agent, this.canDeny);
49
+ const url = `${this.endpoint}/chat/completions`;
50
+
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
53
+
54
+ let responseText: string;
55
+ try {
56
+ const res = await fetch(url, {
57
+ method: "POST",
58
+ headers: {
59
+ Authorization: `Bearer ${apiKey}`,
60
+ "Content-Type": "application/json",
61
+ },
62
+ body: JSON.stringify({
63
+ model: this.model,
64
+ messages: [{ role: "user", content: prompt }],
65
+ temperature: 0,
66
+ max_tokens: 256,
67
+ }),
68
+ signal: controller.signal,
69
+ });
70
+
71
+ if (!res.ok) {
72
+ if (this.verbose) {
73
+ const body = await res.text().catch(() => "<unreadable>");
74
+ console.error(
75
+ `[tyr] openrouter: HTTP ${res.status}: ${body.slice(0, 200)}`,
76
+ );
77
+ }
78
+ return { decision: "abstain" };
79
+ }
80
+
81
+ const json = (await res.json()) as {
82
+ choices?: { message?: { content?: string } }[];
83
+ };
84
+ responseText = json.choices?.[0]?.message?.content ?? "";
85
+ } catch (err) {
86
+ if (this.verbose) {
87
+ const label =
88
+ err instanceof DOMException && err.name === "AbortError"
89
+ ? "timeout"
90
+ : String(err);
91
+ console.error(`[tyr] openrouter: ${label}`);
92
+ }
93
+ return { decision: "abstain" };
94
+ } finally {
95
+ clearTimeout(timer);
96
+ }
97
+
98
+ if (this.verbose) {
99
+ console.error(`[tyr] openrouter response: ${responseText.trim()}`);
100
+ }
101
+
102
+ const llmDecision = parseLlmResponse(responseText);
103
+ if (!llmDecision) return { decision: "abstain" };
104
+
105
+ // When canDeny is false, convert deny→abstain so the user gets prompted
106
+ if (!this.canDeny && llmDecision.decision === "deny") {
107
+ return { decision: "abstain", reason: llmDecision.reason };
108
+ }
109
+
110
+ return { decision: llmDecision.decision, reason: llmDecision.reason };
111
+ }
112
+ }
@@ -0,0 +1,76 @@
1
+ import { syntax } from "mvdan-sh";
2
+
3
+ /** A simple command extracted from a shell string. */
4
+ export interface SimpleCommand {
5
+ /** The full command text reconstructed from the AST (e.g. "git commit -m test"). */
6
+ command: string;
7
+ /** Individual arguments including the command name. */
8
+ args: string[];
9
+ }
10
+
11
+ /** Extract the string value of a Word AST node. */
12
+ function wordToString(word: unknown): string {
13
+ const w = word as { Parts: unknown[] };
14
+ let result = "";
15
+ for (let i = 0; i < w.Parts.length; i++) {
16
+ const part = w.Parts[i] as Record<string, unknown>;
17
+ const partType = syntax.NodeType(part) as string;
18
+ if (partType === "Lit") {
19
+ result += part.Value;
20
+ } else if (partType === "SglQuoted") {
21
+ result += part.Value;
22
+ } else if (partType === "DblQuoted") {
23
+ const inner = part.Parts as unknown[];
24
+ for (let j = 0; j < inner.length; j++) {
25
+ const ip = inner[j] as Record<string, unknown>;
26
+ if (syntax.NodeType(ip) === "Lit") {
27
+ result += ip.Value;
28
+ }
29
+ // For parameter expansions, command substitutions inside
30
+ // double quotes we skip — they're dynamic and can't be
31
+ // statically resolved.
32
+ }
33
+ }
34
+ // CmdSubst, ParamExp, etc. are dynamic — we skip them.
35
+ }
36
+ return result;
37
+ }
38
+
39
+ /** Parse a shell command string and extract all simple commands.
40
+ *
41
+ * Handles pipes (`|`), logical operators (`&&`, `||`), semicolons (`;`),
42
+ * subshells (`(cmd)`), and command substitution (`$(cmd)`).
43
+ * Returns an empty array if parsing fails. */
44
+ export function parseCommands(input: string): SimpleCommand[] {
45
+ const parser = syntax.NewParser();
46
+ let file: import("mvdan-sh").ShellNode;
47
+ try {
48
+ file = parser.Parse(input, "");
49
+ } catch {
50
+ return [];
51
+ }
52
+
53
+ const commands: SimpleCommand[] = [];
54
+
55
+ syntax.Walk(file, (node) => {
56
+ if (!node) return true;
57
+ if (syntax.NodeType(node) !== "CallExpr") return true;
58
+
59
+ const call = node as { Args: unknown[] };
60
+ if (!call.Args || call.Args.length === 0) return true;
61
+
62
+ const args: string[] = [];
63
+ for (let i = 0; i < call.Args.length; i++) {
64
+ args.push(wordToString(call.Args[i]));
65
+ }
66
+
67
+ commands.push({
68
+ command: args.join(" "),
69
+ args,
70
+ });
71
+
72
+ return true;
73
+ });
74
+
75
+ return commands;
76
+ }
@@ -0,0 +1,23 @@
1
+ /** Minimal type declarations for mvdan-sh (GopherJS shell parser). */
2
+ declare module "mvdan-sh" {
3
+ interface ShellNode {
4
+ [key: string]: unknown;
5
+ }
6
+
7
+ interface Parser {
8
+ /** Parse a shell string into an AST. Throws on syntax errors. */
9
+ Parse(input: string, name: string): ShellNode;
10
+ }
11
+
12
+ interface Syntax {
13
+ /** Return the AST node type name (e.g. "CallExpr", "Lit", "DblQuoted"). */
14
+ NodeType(node: unknown): string;
15
+ /** Create a new shell parser instance. */
16
+ NewParser(): Parser;
17
+ /** Walk an AST tree, calling the visitor for each node. Return true to continue. */
18
+ Walk(node: ShellNode, visitor: (node: ShellNode | null) => boolean): void;
19
+ }
20
+
21
+ const syntax: Syntax;
22
+ export { syntax, type ShellNode };
23
+ }
package/src/types.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { z } from "zod/v4";
2
+
3
+ // -- Hook interface types (Claude Code PermissionRequest) --
4
+
5
+ /** Schema for the JSON payload Claude Code sends to hooks on PermissionRequest events. */
6
+ export const PermissionRequestSchema = z.object({
7
+ session_id: z.string(),
8
+ transcript_path: z.string(),
9
+ cwd: z.string(),
10
+ permission_mode: z.string(),
11
+ hook_event_name: z.literal("PermissionRequest"),
12
+ tool_name: z.string(),
13
+ tool_input: z.record(z.string(), z.unknown()),
14
+ });
15
+
16
+ export type PermissionRequest = z.infer<typeof PermissionRequestSchema>;
17
+
18
+ /** A provider's verdict on a permission request. */
19
+ export type PermissionResult = "allow" | "deny" | "abstain";
20
+
21
+ /** Extended result from a provider, carrying an optional reason. */
22
+ export interface ProviderResult {
23
+ decision: PermissionResult;
24
+ reason?: string;
25
+ }
26
+
27
+ /** The JSON structure tyr writes to stdout to communicate a decision back to Claude. */
28
+ export interface HookResponse {
29
+ hookSpecificOutput: {
30
+ hookEventName: "PermissionRequest";
31
+ decision: {
32
+ behavior: "allow" | "deny";
33
+ message?: string;
34
+ };
35
+ };
36
+ }
37
+
38
+ // -- Provider interface --
39
+
40
+ /** A strategy for evaluating permission requests. */
41
+ export interface Provider {
42
+ readonly name: string;
43
+ checkPermission(req: PermissionRequest): Promise<ProviderResult>;
44
+ }
45
+
46
+ // -- Tyr's own config --
47
+
48
+ export const ClaudeConfigSchema = z.object({
49
+ /** Model identifier passed to the Claude CLI. */
50
+ model: z.string().default("haiku"),
51
+ /** Request timeout in seconds. */
52
+ timeout: z.number().default(10),
53
+ /** Whether the provider can deny requests. When false, it can only allow or abstain. */
54
+ canDeny: z.boolean().default(false),
55
+ });
56
+
57
+ export type ClaudeConfig = z.infer<typeof ClaudeConfigSchema>;
58
+
59
+ export const OpenRouterConfigSchema = z.object({
60
+ /** Model identifier passed to the OpenRouter API. */
61
+ model: z.string().default("anthropic/claude-3.5-haiku"),
62
+ /** OpenRouter API endpoint. */
63
+ endpoint: z.string().default("https://openrouter.ai/api/v1"),
64
+ /** Request timeout in seconds. */
65
+ timeout: z.number().default(10),
66
+ /** Whether the provider can deny requests. When false, it can only allow or abstain. */
67
+ canDeny: z.boolean().default(false),
68
+ });
69
+
70
+ export type OpenRouterConfig = z.infer<typeof OpenRouterConfigSchema>;
71
+
72
+ /** Valid provider names for the pipeline. */
73
+ export const PROVIDER_NAMES = [
74
+ "cache",
75
+ "chained-commands",
76
+ "claude",
77
+ "openrouter",
78
+ ] as const;
79
+ export type ProviderName = (typeof PROVIDER_NAMES)[number];
80
+
81
+ export const TyrConfigSchema = z.object({
82
+ /** Ordered list of providers to run in the pipeline. */
83
+ providers: z.array(z.enum(PROVIDER_NAMES)).default(["chained-commands"]),
84
+ /** If true, approve requests when tyr encounters an error. Default: false (fail-closed). */
85
+ failOpen: z.boolean().default(false),
86
+ /** Claude CLI provider configuration. */
87
+ claude: ClaudeConfigSchema.default(ClaudeConfigSchema.parse({})),
88
+ /** OpenRouter API provider configuration. */
89
+ openrouter: OpenRouterConfigSchema.default(OpenRouterConfigSchema.parse({})),
90
+ /** Include LLM prompt and parameters in log entries for debugging. */
91
+ verboseLog: z.boolean().default(false),
92
+ /** Maximum age of log entries. Entries older than this are pruned on the next tyr invocation.
93
+ * Use relative duration syntax: "30d", "12h", "0" to disable. Default: "30d". */
94
+ logRetention: z
95
+ .string()
96
+ .default("30d")
97
+ .refine((v) => v === "0" || /^\d+[smhd]$/.test(v), {
98
+ message: "Must be '0' or a duration like '30d', '12h', '45m', '60s'",
99
+ }),
100
+ });
101
+
102
+ export type TyrConfig = z.infer<typeof TyrConfigSchema>;
103
+
104
+ export const DEFAULT_TYR_CONFIG: TyrConfig = TyrConfigSchema.parse({});
105
+
106
+ /** Return the ordered provider list from config. */
107
+ export function resolveProviders(config: TyrConfig): ProviderName[] {
108
+ return config.providers;
109
+ }
package/src/version.ts ADDED
@@ -0,0 +1,9 @@
1
+ import pkg from "../package.json";
2
+
3
+ /**
4
+ * For compiled binaries, TYR_VERSION is injected via --define at build time.
5
+ * When running from source, reads from package.json.
6
+ */
7
+ declare const TYR_VERSION: string | undefined;
8
+ export const VERSION: string =
9
+ typeof TYR_VERSION === "string" ? TYR_VERSION : pkg.version;