@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.
package/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # tyr
2
+
3
+ > **Experimental** — tyr is under active development. The API, configuration schema, and CLI interface may change without notice.
4
+
5
+ Intelligent permission management for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hooks. Tyr intercepts `PermissionRequest` events from Claude Code and evaluates them against your configured allow/deny patterns, so you can auto-approve safe commands and block dangerous ones without manual intervention.
6
+
7
+ Named after the [Norse god of justice](https://en.wikipedia.org/wiki/T%C3%BDr).
8
+
9
+ ## How it works
10
+
11
+ Tyr registers itself as a Claude Code [hook](https://docs.anthropic.com/en/docs/claude-code/hooks) on the `PermissionRequest` event. When Claude Code asks to run a shell command, tyr:
12
+
13
+ 1. Reads your Claude Code allow/deny permission patterns
14
+ 2. Parses compound commands (e.g. `git add . && git commit`) and checks each component
15
+ 3. Optionally asks an LLM to evaluate ambiguous commands against your patterns
16
+ 4. Returns allow/deny/abstain back to Claude Code
17
+
18
+ ## Why tyr?
19
+
20
+ Claude Code's `--dangerously-skip-permissions` flag gives the agent full autonomy — it can run any command without asking. That's fast, but risky: a single bad tool call can delete files, leak secrets, or break your environment with no audit trail.
21
+
22
+ Tyr gives you the same automation benefits with granular control and full observability. You choose how much autonomy to grant:
23
+
24
+ | Mode | What happens | Use case |
25
+ |------|-------------|----------|
26
+ | **Audit** (`tyr install --audit`) | Logs every permission request without evaluating it | Understand what Claude Code is doing before changing anything |
27
+ | **Shadow** (`tyr install --shadow`) | Runs the full allow/deny pipeline but always abstains to Claude Code | Validate your rules against real traffic before going live |
28
+ | **Active** (`tyr install`) | Evaluates requests and enforces allow/deny decisions | Full automation with pattern-based guardrails |
29
+
30
+ Every decision is logged to a SQLite database, so you can review what was allowed, denied, or abstained — and why.
31
+
32
+ ## Prerequisites
33
+
34
+ - [Bun](https://bun.sh) runtime
35
+ - Claude Code (for integration — tyr can be tested standalone)
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ # Clone and install dependencies
41
+ git clone git@github.com:panchr/tyr.git && cd tyr
42
+ bun install
43
+
44
+ # Build and install the binary to /usr/local/bin
45
+ bun run build
46
+
47
+ # Register the hook in your project (writes to .claude/settings.json)
48
+ tyr install
49
+
50
+ # Or install globally (writes to ~/.claude/settings.json)
51
+ tyr install --global
52
+ ```
53
+
54
+ To remove:
55
+
56
+ ```bash
57
+ tyr uninstall
58
+ tyr uninstall --global
59
+ ```
60
+
61
+ Use `--dry-run` with either command to preview changes without modifying anything.
62
+
63
+ ## Usage
64
+
65
+ Once installed, tyr runs automatically as a Claude Code hook. No manual invocation needed.
66
+
67
+ ### Commands
68
+
69
+ ```
70
+ tyr install [--global] [--project] [--dry-run] [--shadow|--audit]
71
+ tyr uninstall [--global] [--project] [--dry-run]
72
+ tyr config show
73
+ tyr config set <key> <value>
74
+ tyr config path
75
+ tyr config env set <key> <value>
76
+ tyr config env show
77
+ tyr config env path
78
+ tyr log [--last N] [--json] [--since T] [--until T] [--decision D] [--provider P] [--cwd C] [--verbose]
79
+ tyr log clear
80
+ tyr db migrate
81
+ tyr stats [--since T] [--json]
82
+ tyr suggest [--apply] [--global|--project] [--min-count N] [--json]
83
+ tyr debug claude-config [--cwd C]
84
+ tyr version
85
+ ```
86
+
87
+ ### Configuration
88
+
89
+ Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_CONFIG_FILE`). The config file supports JSON with comments (JSONC).
90
+
91
+ | Key | Type | Default | Description |
92
+ |-----|------|---------|-------------|
93
+ | `providers` | string[] | `["chained-commands"]` | Ordered list of providers to run |
94
+ | `failOpen` | boolean | `false` | Approve on error instead of failing closed |
95
+ | `claude.model` | string | `"haiku"` | Model identifier for the Claude CLI |
96
+ | `claude.timeout` | number | `10` | Claude request timeout in seconds |
97
+ | `claude.canDeny` | boolean | `false` | Whether Claude can deny requests |
98
+ | `openrouter.model` | string | `"anthropic/claude-3.5-haiku"` | Model for OpenRouter API |
99
+ | `openrouter.endpoint` | string | `"https://openrouter.ai/api/v1"` | OpenRouter API endpoint |
100
+ | `openrouter.timeout` | number | `10` | OpenRouter request timeout in seconds |
101
+ | `openrouter.canDeny` | boolean | `false` | Whether OpenRouter can deny requests |
102
+ | `verboseLog` | boolean | `false` | Include LLM prompt/params in log entries |
103
+ | `logRetention` | string | `"30d"` | Auto-prune logs older than this (`"0"` to disable) |
104
+
105
+ All config values can be overridden per-invocation via CLI flags on the `judge` command (e.g. `--fail-open`, `--claude-model`). These flags are passed through the hook configuration in `.claude/settings.json`.
106
+
107
+ ### Environment variables
108
+
109
+ Tyr loads environment variables from `~/.config/tyr/.env` (next to the config file). This is the recommended place to store API keys.
110
+
111
+ ```bash
112
+ # Store your OpenRouter API key
113
+ tyr config env set OPENROUTER_API_KEY sk-or-...
114
+
115
+ # View stored variables (values masked)
116
+ tyr config env show
117
+
118
+ # Print .env file path
119
+ tyr config env path
120
+ ```
121
+
122
+ Existing process environment variables take precedence over `.env` values.
123
+
124
+ ### Providers
125
+
126
+ Tyr uses a **pipeline architecture** where providers are evaluated in sequence. The first provider to return a definitive `allow` or `deny` wins — remaining providers are skipped. If all providers `abstain`, the request falls through to Claude Code's default behavior (prompting the user), unless `failOpen` is `true`, in which case the request is approved.
127
+
128
+ Configure the pipeline via the `providers` array. **Order matters** — providers run left to right.
129
+
130
+ Valid provider names: `cache`, `chained-commands`, `claude`, `openrouter`.
131
+
132
+ #### `cache`
133
+
134
+ Caches prior decisions in SQLite. If the same command was previously allowed or denied (with the same config and permission rules), returns the cached result immediately. The cache auto-invalidates when your config or Claude Code permission rules change.
135
+
136
+ **Best practice:** Place first in the pipeline to skip expensive downstream evaluations.
137
+
138
+ #### `chained-commands`
139
+
140
+ Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns (merged from all settings files).
141
+
142
+ - **Allow:** All sub-commands match an allow pattern
143
+ - **Deny:** Any sub-command matches a deny pattern
144
+ - **Abstain:** Any sub-command has no matching pattern
145
+
146
+ Only evaluates `Bash` tool requests; abstains on all other tools.
147
+
148
+ #### `claude`
149
+
150
+ Sends ambiguous commands to a local Claude CLI for semantic evaluation. The LLM sees your permission rules, the command, and the working directory, then reasons about whether the command is safe.
151
+
152
+ When `claude.canDeny` is `false` (the default), the LLM can only approve commands — deny decisions are converted to abstain, forcing the user to decide. Set `canDeny: true` for stricter enforcement.
153
+
154
+ Requires a local `claude` CLI binary (installed with Claude Code). Timeouts and errors are treated as abstain.
155
+
156
+ Only evaluates `Bash` tool requests; abstains on all other tools.
157
+
158
+ #### `openrouter`
159
+
160
+ Sends ambiguous commands to the OpenRouter API for evaluation. Same semantics as the `claude` provider but uses an HTTP API instead of the local CLI.
161
+
162
+ Requires `OPENROUTER_API_KEY` set in your environment or `.env` file.
163
+
164
+ Only evaluates `Bash` tool requests; abstains on all other tools.
165
+
166
+ #### Pipeline examples
167
+
168
+ ```jsonc
169
+ // Safe & fast (default) — pattern matching only
170
+ { "providers": ["chained-commands"] }
171
+
172
+ // With caching — faster repeated evaluations
173
+ { "providers": ["cache", "chained-commands"] }
174
+
175
+ // Full pipeline — patterns first, then Claude for ambiguous commands
176
+ { "providers": ["cache", "chained-commands", "claude"] }
177
+
178
+ // Using OpenRouter instead of local Claude
179
+ { "providers": ["cache", "chained-commands", "openrouter"] }
180
+ ```
181
+
182
+ ### Viewing logs
183
+
184
+ Every permission decision is logged to a SQLite database at `~/.local/share/tyr/tyr.db` (overridable via `TYR_DB_PATH`).
185
+
186
+ ```bash
187
+ # View recent decisions (default: last 20)
188
+ tyr log
189
+
190
+ # Show more entries
191
+ tyr log --last 50
192
+
193
+ # Filter by decision type
194
+ tyr log --decision allow
195
+ tyr log --decision deny
196
+
197
+ # Filter by time range (ISO or relative: 1h, 30m, 7d)
198
+ tyr log --since 1h
199
+ tyr log --since 2025-01-01 --until 2025-01-31
200
+
201
+ # Filter by provider or working directory
202
+ tyr log --provider chained-commands
203
+ tyr log --cwd /path/to/project
204
+
205
+ # JSON output
206
+ tyr log --json
207
+
208
+ # Show LLM prompts for verbose-logged entries
209
+ tyr log --verbose
210
+
211
+ # Clear all logs
212
+ tyr log clear
213
+ ```
214
+
215
+ Log entries are automatically pruned based on the `logRetention` config setting (default: 30 days).
216
+
217
+ ### Statistics
218
+
219
+ ```bash
220
+ # View overall stats
221
+ tyr stats
222
+
223
+ # Stats for the last 7 days
224
+ tyr stats --since 7d
225
+
226
+ # Machine-readable JSON
227
+ tyr stats --json
228
+ ```
229
+
230
+ Shows: total checks, decision breakdown (allow/deny/abstain/error), cache hit rate, provider distribution, and auto-approval count.
231
+
232
+ ### Suggestions
233
+
234
+ Tyr can analyze your decision history and recommend new allow rules to add to Claude Code's settings:
235
+
236
+ ```bash
237
+ # View suggestions (commands approved >= 5 times by default)
238
+ tyr suggest
239
+
240
+ # Lower the threshold
241
+ tyr suggest --min-count 3
242
+
243
+ # Apply suggestions to global settings
244
+ tyr suggest --apply --global
245
+
246
+ # Apply to project settings
247
+ tyr suggest --apply --project
248
+ ```
249
+
250
+ ### Debugging
251
+
252
+ ```bash
253
+ # Print the merged Claude Code permission config for the current project
254
+ tyr debug claude-config
255
+
256
+ # Print for a different project directory
257
+ tyr debug claude-config --cwd /path/to/project
258
+
259
+ # Print tyr version and runtime info
260
+ tyr version
261
+ ```
262
+
263
+ ### Database migrations
264
+
265
+ ```bash
266
+ # Run pending schema migrations
267
+ tyr db migrate
268
+ ```
269
+
270
+ ## Development
271
+
272
+ ```bash
273
+ bun test # Run all tests
274
+ bun run typecheck # Type-check without emitting
275
+ bun run lint # Lint with Biome
276
+ ```
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@panchr/tyr",
3
+ "description": "Intelligent permission management for Claude Code hooks",
4
+ "module": "src/index.ts",
5
+ "type": "module",
6
+ "version": "0.1.0",
7
+ "bin": {
8
+ "tyr": "src/index.ts"
9
+ },
10
+ "files": ["src/", "!src/__tests__/"],
11
+ "scripts": {
12
+ "start": "bun run src/index.ts",
13
+ "build": "bun run scripts/build.ts",
14
+ "lint": "biome check src/",
15
+ "lint:fix": "biome check --write src/",
16
+ "test": "bun test",
17
+ "test:smoke": "bun test ./src/__tests__/smoke.ts",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "devDependencies": {
21
+ "@biomejs/biome": "^2.3.15",
22
+ "@types/bun": "latest"
23
+ },
24
+ "peerDependencies": {
25
+ "typescript": "^5"
26
+ },
27
+ "dependencies": {
28
+ "citty": "^0.2.1",
29
+ "mvdan-sh": "^0.10.1",
30
+ "zod": "^4.3.6"
31
+ }
32
+ }
@@ -0,0 +1,143 @@
1
+ import { type FSWatcher, watch } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { readSettings } from "../install.ts";
5
+
6
+ /** Parse Bash permission rules into command patterns.
7
+ * "Bash(npm run *)" → "npm run *", bare "Bash" → "*".
8
+ * Non-Bash rules are ignored. */
9
+ export function extractBashPatterns(rules: unknown[]): string[] {
10
+ const patterns: string[] = [];
11
+ for (const rule of rules) {
12
+ if (typeof rule !== "string") continue;
13
+ if (rule === "Bash") {
14
+ patterns.push("*");
15
+ continue;
16
+ }
17
+ const match = rule.match(/^Bash\((.+)\)$/);
18
+ if (match?.[1]) {
19
+ patterns.push(match[1]);
20
+ }
21
+ }
22
+ return patterns;
23
+ }
24
+
25
+ /** Check if a command matches a glob-style pattern.
26
+ * `*` in the pattern matches any sequence of characters. */
27
+ export function matchPattern(pattern: string, command: string): boolean {
28
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
29
+ const regex = new RegExp(`^${escaped.replace(/\*+/g, ".*")}$`);
30
+ return regex.test(command);
31
+ }
32
+
33
+ /** Return settings file paths in precedence order (highest first). */
34
+ export function settingsPaths(cwd: string): string[] {
35
+ const configDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
36
+ const paths: string[] = [];
37
+
38
+ // 1. Managed (macOS)
39
+ if (process.platform === "darwin") {
40
+ paths.push("/Library/Application Support/ClaudeCode/managed-settings.json");
41
+ }
42
+
43
+ // 2. Local project
44
+ paths.push(join(cwd, ".claude", "settings.local.json"));
45
+
46
+ // 3. Project shared
47
+ paths.push(join(cwd, ".claude", "settings.json"));
48
+
49
+ // 4. User global
50
+ paths.push(join(configDir, "settings.json"));
51
+
52
+ return paths;
53
+ }
54
+
55
+ interface MergedPermissions {
56
+ allow: string[];
57
+ deny: string[];
58
+ }
59
+
60
+ /** Merge permissions from all settings files in precedence order. */
61
+ async function loadPermissions(paths: string[]): Promise<MergedPermissions> {
62
+ const allow: string[] = [];
63
+ const deny: string[] = [];
64
+
65
+ for (const path of paths) {
66
+ const settings = await readSettings(path);
67
+ const perms = settings.permissions as Record<string, unknown> | undefined;
68
+ if (!perms) continue;
69
+
70
+ if (Array.isArray(perms.allow)) {
71
+ allow.push(...extractBashPatterns(perms.allow));
72
+ }
73
+ if (Array.isArray(perms.deny)) {
74
+ deny.push(...extractBashPatterns(perms.deny));
75
+ }
76
+ }
77
+
78
+ return { allow, deny };
79
+ }
80
+
81
+ export class ClaudeAgent {
82
+ private allow: string[] = [];
83
+ private deny: string[] = [];
84
+ private watchers: FSWatcher[] = [];
85
+ private paths: string[] = [];
86
+
87
+ /** Read all settings files and start watching for changes.
88
+ * If `paths` is provided, use those instead of auto-detected paths. */
89
+ async init(cwd?: string, paths?: string[]): Promise<void> {
90
+ this.paths = paths ?? settingsPaths(cwd ?? process.cwd());
91
+ await this.reload();
92
+
93
+ for (const path of this.paths) {
94
+ try {
95
+ const watcher = watch(path, () => {
96
+ void this.reload();
97
+ });
98
+ this.watchers.push(watcher);
99
+ } catch {
100
+ // File doesn't exist yet — skip watching
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Return the resolved settings file paths and merged permissions. */
106
+ getDebugInfo(): {
107
+ paths: string[];
108
+ allow: string[];
109
+ deny: string[];
110
+ } {
111
+ return {
112
+ paths: [...this.paths],
113
+ allow: [...this.allow],
114
+ deny: [...this.deny],
115
+ };
116
+ }
117
+
118
+ /** Check if a command is allowed/denied/unknown per configured permissions. */
119
+ isCommandAllowed(cmd: string): "allow" | "deny" | "unknown" {
120
+ // Deny rules are evaluated first; first match wins.
121
+ for (const pattern of this.deny) {
122
+ if (matchPattern(pattern, cmd)) return "deny";
123
+ }
124
+ for (const pattern of this.allow) {
125
+ if (matchPattern(pattern, cmd)) return "allow";
126
+ }
127
+ return "unknown";
128
+ }
129
+
130
+ /** Stop watching settings files. */
131
+ close(): void {
132
+ for (const watcher of this.watchers) {
133
+ watcher.close();
134
+ }
135
+ this.watchers = [];
136
+ }
137
+
138
+ private async reload(): Promise<void> {
139
+ const perms = await loadPermissions(this.paths);
140
+ this.allow = perms.allow;
141
+ this.deny = perms.deny;
142
+ }
143
+ }
package/src/args.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { ArgsDef } from "citty";
2
+
3
+ /** Parse a relative time string like '1h', '30m', '2d' into a Date, or parse ISO. */
4
+ export function parseTime(value: string): Date | null {
5
+ const relativeMatch = value.match(/^(\d+)([smhd])$/);
6
+ if (relativeMatch) {
7
+ const amount = Number(relativeMatch[1]);
8
+ const unit = relativeMatch[2];
9
+ const multipliers: Record<string, number> = {
10
+ s: 1000,
11
+ m: 60_000,
12
+ h: 3_600_000,
13
+ d: 86_400_000,
14
+ };
15
+ const ms = multipliers[unit as string];
16
+ if (ms === undefined) return null;
17
+ return new Date(Date.now() - amount * ms);
18
+ }
19
+ const d = new Date(value);
20
+ return Number.isNaN(d.getTime()) ? null : d;
21
+ }
22
+
23
+ /**
24
+ * Reject unknown flags in rawArgs that aren't defined in argsDef.
25
+ * Citty parses with strict:false, so we validate manually.
26
+ */
27
+ export function rejectUnknownArgs(rawArgs: string[], argsDef: ArgsDef): void {
28
+ const known = new Set<string>();
29
+ for (const [name, def] of Object.entries(argsDef)) {
30
+ known.add(`--${name}`);
31
+ if ("type" in def && def.type === "boolean") {
32
+ known.add(`--no-${name}`);
33
+ }
34
+ if ("alias" in def) {
35
+ const aliases = Array.isArray(def.alias) ? def.alias : [def.alias];
36
+ for (const a of aliases) {
37
+ if (a) known.add(a.length === 1 ? `-${a}` : `--${a}`);
38
+ }
39
+ }
40
+ }
41
+ // Always allow --help and --version
42
+ known.add("--help");
43
+ known.add("-h");
44
+ known.add("--version");
45
+
46
+ for (const arg of rawArgs) {
47
+ if (!arg.startsWith("-")) continue;
48
+ // Handle --flag=value
49
+ const flag = arg.split("=")[0] ?? arg;
50
+ if (!known.has(flag)) {
51
+ console.error(`Unknown option: ${flag}`);
52
+ process.exit(1);
53
+ }
54
+ }
55
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ClaudeAgent } from "./agents/claude.ts";
3
+ import { getDb } from "./db.ts";
4
+ import { extractToolInput } from "./log.ts";
5
+ import type { PermissionRequest, TyrConfig } from "./types.ts";
6
+ import { resolveProviders } from "./types.ts";
7
+
8
+ interface CacheHit {
9
+ decision: "allow" | "deny";
10
+ provider: string;
11
+ reason: string | null;
12
+ }
13
+
14
+ /** Compute a config fingerprint covering both Claude's rules and tyr's config. */
15
+ export function computeConfigHash(
16
+ agent: ClaudeAgent,
17
+ config: TyrConfig,
18
+ ): string {
19
+ const info = agent.getDebugInfo();
20
+ const data = JSON.stringify({
21
+ allow: [...info.allow].sort(),
22
+ deny: [...info.deny].sort(),
23
+ providers: resolveProviders(config),
24
+ failOpen: config.failOpen,
25
+ "claude.model": config.claude.model,
26
+ "claude.canDeny": config.claude.canDeny,
27
+ "openrouter.model": config.openrouter.model,
28
+ "openrouter.canDeny": config.openrouter.canDeny,
29
+ });
30
+ return createHash("sha256").update(data).digest("hex");
31
+ }
32
+
33
+ /** Look up a cached decision. Returns null on miss. */
34
+ export function checkCache(
35
+ req: PermissionRequest,
36
+ configHash: string,
37
+ ): CacheHit | null {
38
+ const db = getDb();
39
+ const toolInput = extractToolInput(req.tool_name, req.tool_input);
40
+
41
+ const row = db
42
+ .query(
43
+ "SELECT decision, provider, reason FROM cache WHERE tool_name = ? AND tool_input = ? AND cwd = ? AND config_hash = ?",
44
+ )
45
+ .get(req.tool_name, toolInput, req.cwd, configHash) as {
46
+ decision: string;
47
+ provider: string;
48
+ reason: string | null;
49
+ } | null;
50
+
51
+ if (!row) return null;
52
+
53
+ return {
54
+ decision: row.decision as "allow" | "deny",
55
+ provider: row.provider,
56
+ reason: row.reason,
57
+ };
58
+ }
59
+
60
+ /** Store a definitive decision in the cache. Only allow/deny are cached. */
61
+ export function writeCache(
62
+ req: PermissionRequest,
63
+ decision: "allow" | "deny",
64
+ provider: string,
65
+ reason: string | undefined,
66
+ configHash: string,
67
+ ): void {
68
+ const db = getDb();
69
+ const toolInput = extractToolInput(req.tool_name, req.tool_input);
70
+
71
+ db.query(
72
+ `INSERT OR REPLACE INTO cache (tool_name, tool_input, cwd, decision, provider, reason, config_hash, created_at)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
74
+ ).run(
75
+ req.tool_name,
76
+ toolInput,
77
+ req.cwd,
78
+ decision,
79
+ provider,
80
+ reason ?? null,
81
+ configHash,
82
+ Date.now(),
83
+ );
84
+ }