@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 +276 -0
- package/package.json +32 -0
- package/src/agents/claude.ts +143 -0
- package/src/args.ts +55 -0
- package/src/cache.ts +84 -0
- package/src/commands/config.ts +181 -0
- package/src/commands/db.ts +66 -0
- package/src/commands/debug.ts +34 -0
- package/src/commands/install.ts +77 -0
- package/src/commands/judge.ts +399 -0
- package/src/commands/log.ts +189 -0
- package/src/commands/stats.ts +154 -0
- package/src/commands/suggest.ts +184 -0
- package/src/commands/uninstall.ts +54 -0
- package/src/commands/version.ts +14 -0
- package/src/config.ts +222 -0
- package/src/db.ts +229 -0
- package/src/index.ts +36 -0
- package/src/install.ts +116 -0
- package/src/judge.ts +19 -0
- package/src/log.ts +193 -0
- package/src/pipeline.ts +32 -0
- package/src/prompts.ts +83 -0
- package/src/providers/cache.ts +34 -0
- package/src/providers/chained-commands.ts +45 -0
- package/src/providers/claude.ts +120 -0
- package/src/providers/openrouter.ts +112 -0
- package/src/providers/shell-parser.ts +76 -0
- package/src/types/mvdan-sh.d.ts +23 -0
- package/src/types.ts +109 -0
- package/src/version.ts +9 -0
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
|
+
}
|