@panchr/tyr 0.1.1 → 0.2.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 CHANGED
@@ -1,69 +1,63 @@
1
1
  # tyr
2
2
 
3
- > **Experimental** — tyr is under active development. The API, configuration schema, and CLI interface may change without notice.
3
+ > **Experimental** — tyr is under active development. The API, configuration schema, and CLI interface may change without notice. If upgrading between minor versions, it's highly possible that a previous configuration needs some manual update.
4
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.
5
+ Tyr is a CLI for intelligently managing permissions for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). It is added as a hook on `PermissionRequest` events and evaluates them against configured allow/deny patterns. In the standard mode, this evaluation is done by Claude (or another LLM) by comparing the requested Bash command to the user's configuration. The hook will auto-approve commands that fuzzily match the configuration, without manual intervention.
6
6
 
7
- Named after the [Norse god of justice](https://en.wikipedia.org/wiki/T%C3%BDr).
7
+ The goal is to reduce the number of permission prompts sent to the user. As of now, Tyr only evaluates `Bash` tool requests; it abstains on all other tools.
8
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
9
+ It is named after the [Norse god of justice](https://en.wikipedia.org/wiki/T%C3%BDr).
17
10
 
18
11
  ## Why tyr?
19
12
 
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.
13
+ Claude Code's `--dangerously-skip-permissions` flag gives the agent full autonomy -- it can run any command without asking. That's risky: a single bad tool call can delete files, leak secrets, or break your environment.
21
14
 
22
- Tyr gives you the same automation benefits with granular control and full observability. You choose how much autonomy to grant:
15
+ Tyr gives a configurable degree of autonomy to Claude:
23
16
 
24
17
  | Mode | What happens | Use case |
25
18
  |------|-------------|----------|
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 |
19
+ | **Audit** (`tyr install --audit`) | Logs every permission request without evaluating it | Understand what Claude Code is doing without performing any of tyr's logic on the request |
20
+ | **Shadow** (`tyr install --shadow`) | Runs the full allow/deny pipeline but always abstains to Claude Code | Validate your rules against real requests, before an impact |
28
21
  | **Active** (`tyr install`) | Evaluates requests and enforces allow/deny decisions | Full automation with pattern-based guardrails |
29
22
 
30
23
  Every decision is logged to a SQLite database, so you can review what was allowed, denied, or abstained — and why.
31
24
 
32
- ## Prerequisites
33
-
34
- - [Bun](https://bun.sh) runtime
35
- - Claude Code (for integration — tyr can be tested standalone)
25
+ ## Quickstart
36
26
 
37
- ## Install
27
+ Requires [Bun](https://bun.sh) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
38
28
 
39
29
  ```bash
40
- # Clone and install dependencies
41
- git clone git@github.com:panchr/tyr.git && cd tyr
42
- bun install
30
+ # Install tyr
31
+ bun install -g @panchr/tyr
43
32
 
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)
33
+ # Register the hook (run this inside your project directory)
48
34
  tyr install
49
35
 
50
- # Or install globally (writes to ~/.claude/settings.json)
36
+ # Start a Claude Code session and work as usual — tyr runs automatically.
37
+ # When you're done, review what happened:
38
+ tyr log
39
+ tyr stats
40
+ ```
41
+
42
+ That's it. Tyr evaluates every permission request against your Claude Code allow/deny patterns and logs the result. Commands that match an allowed pattern are auto-approved; everything else falls through to Claude Code's normal prompt.
43
+
44
+ To install globally (applies to all projects):
45
+
46
+ ```bash
51
47
  tyr install --global
52
48
  ```
53
49
 
54
50
  To remove:
55
51
 
56
52
  ```bash
57
- tyr uninstall
58
- tyr uninstall --global
53
+ tyr uninstall # project
54
+ tyr uninstall --global # global
59
55
  ```
60
56
 
61
57
  Use `--dry-run` with either command to preview changes without modifying anything.
62
58
 
63
59
  ## Usage
64
60
 
65
- Once installed, tyr runs automatically as a Claude Code hook. No manual invocation needed.
66
-
67
61
  ### Commands
68
62
 
69
63
  ```
@@ -79,7 +73,7 @@ tyr log [--last N] [--json] [--since T] [--until T] [--decision D] [--provider P
79
73
  tyr log clear
80
74
  tyr db migrate
81
75
  tyr stats [--since T] [--json]
82
- tyr suggest [--apply] [--global|--project] [--min-count N] [--json]
76
+ tyr suggest [--global|--project] [--min-count N] [--all]
83
77
  tyr debug claude-config [--cwd C]
84
78
  tyr version
85
79
  ```
@@ -90,7 +84,7 @@ Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_
90
84
 
91
85
  | Key | Type | Default | Description |
92
86
  |-----|------|---------|-------------|
93
- | `providers` | string[] | `["chained-commands"]` | Ordered list of providers to run |
87
+ | `providers` | string[] | `["chained-commands", "claude"]` | Ordered list of providers to run |
94
88
  | `failOpen` | boolean | `false` | Approve on error instead of failing closed |
95
89
  | `claude.model` | string | `"haiku"` | Model identifier for the Claude CLI |
96
90
  | `claude.timeout` | number | `10` | Claude request timeout in seconds |
@@ -99,6 +93,7 @@ Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_
99
93
  | `openrouter.endpoint` | string | `"https://openrouter.ai/api/v1"` | OpenRouter API endpoint |
100
94
  | `openrouter.timeout` | number | `10` | OpenRouter request timeout in seconds |
101
95
  | `openrouter.canDeny` | boolean | `false` | Whether OpenRouter can deny requests |
96
+ | `conversationContext` | boolean | `false` | Give LLM providers recent conversation context to judge intent (can allow commands beyond configured patterns) |
102
97
  | `verboseLog` | boolean | `false` | Include LLM prompt/params in log entries |
103
98
  | `logRetention` | string | `"30d"` | Auto-prune logs older than this (`"0"` to disable) |
104
99
 
@@ -123,11 +118,11 @@ Existing process environment variables take precedence over `.env` values.
123
118
 
124
119
  ### Providers
125
120
 
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.
121
+ 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
122
 
128
- Configure the pipeline via the `providers` array. **Order matters** providers run left to right.
123
+ Configure the pipeline via the `providers` array. **Order matters** -- providers run in order.
129
124
 
130
- Valid provider names: `cache`, `chained-commands`, `claude`, `openrouter`.
125
+ Valid providers are listed below.
131
126
 
132
127
  #### `cache`
133
128
 
@@ -137,27 +132,27 @@ Caches prior decisions in SQLite. If the same command was previously allowed or
137
132
 
138
133
  #### `chained-commands`
139
134
 
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).
135
+ Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns.
141
136
 
142
137
  - **Allow:** All sub-commands match an allow pattern
143
- - **Deny:** Any sub-command matches a deny pattern
138
+ - **Deny:** _Any_ sub-command matches a deny pattern
144
139
  - **Abstain:** Any sub-command has no matching pattern
145
140
 
146
- Only evaluates `Bash` tool requests; abstains on all other tools.
147
-
148
141
  #### `claude`
149
142
 
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.
143
+ Sends ambiguous commands to the 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
144
 
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.
145
+ 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
146
 
154
- Requires a local `claude` CLI binary (installed with Claude Code). Timeouts and errors are treated as abstain.
147
+ When `conversationContext` is enabled, the LLM also sees recent conversation messages from the Claude Code session. This lets it allow commands that don't match any configured pattern if the user clearly requested the action and it's a typical, safe development command. The deny list is always checked first -- no amount of context overrides a denied pattern.
155
148
 
156
- Only evaluates `Bash` tool requests; abstains on all other tools.
149
+ Requires a local `claude` CLI binary. While this is somewhat slow due to the subprocess required to run `claude` (generally a decision is made in about 5 seconds), this slowness is acceptable given that it will still be faster than a human understanding and approving a command.
150
+
151
+ The main benefit of this provider is that it reuses the authentication that `claude` is already configured with, whether that's an Anthropic API key or a subscription.
157
152
 
158
153
  #### `openrouter`
159
154
 
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.
155
+ 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. Supports `conversationContext` in the same way.
161
156
 
162
157
  Requires `OPENROUTER_API_KEY` set in your environment or `.env` file.
163
158
 
@@ -166,13 +161,13 @@ Only evaluates `Bash` tool requests; abstains on all other tools.
166
161
  #### Pipeline examples
167
162
 
168
163
  ```jsonc
169
- // Safe & fast (default) pattern matching only
164
+ // Safe & fast (default) -- pattern matching only
170
165
  { "providers": ["chained-commands"] }
171
166
 
172
- // With caching faster repeated evaluations
167
+ // With caching -- faster repeated evaluations
173
168
  { "providers": ["cache", "chained-commands"] }
174
169
 
175
- // Full pipeline patterns first, then Claude for ambiguous commands
170
+ // Full pipeline -- patterns first, then Claude for ambiguous commands
176
171
  { "providers": ["cache", "chained-commands", "claude"] }
177
172
 
178
173
  // Using OpenRouter instead of local Claude
@@ -231,37 +226,26 @@ Shows: total checks, decision breakdown (allow/deny/abstain/error), cache hit ra
231
226
 
232
227
  ### Suggestions
233
228
 
234
- Tyr can analyze your decision history and recommend new allow rules to add to Claude Code's settings:
229
+ Tyr can analyze your decision history and start an interactive Claude session to help you refine and apply allow rules:
235
230
 
236
231
  ```bash
237
- # View suggestions (commands approved >= 5 times by default)
232
+ # Start an interactive session with suggested rules (commands approved >= 5 times)
238
233
  tyr suggest
239
234
 
240
- # Lower the threshold
235
+ # Lower the threshold for which commands are surfaced
241
236
  tyr suggest --min-count 3
242
237
 
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
238
+ # Target project settings instead of global
239
+ tyr suggest --project
258
240
 
259
- # Print tyr version and runtime info
260
- tyr version
241
+ # Include commands from all projects, not just the current directory
242
+ tyr suggest --all
261
243
  ```
262
244
 
263
245
  ### Database migrations
264
246
 
247
+ When upgrading from one `tyr` version to another, run
248
+
265
249
  ```bash
266
250
  # Run pending schema migrations
267
251
  tyr db migrate
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Intelligent permission management for Claude Code hooks",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
- "version": "0.1.1",
6
+ "version": "0.2.0",
7
7
  "bin": {
8
8
  "tyr": "src/index.ts"
9
9
  },
package/src/cache.ts CHANGED
@@ -26,6 +26,7 @@ export function computeConfigHash(
26
26
  "claude.canDeny": config.claude.canDeny,
27
27
  "openrouter.model": config.openrouter.model,
28
28
  "openrouter.canDeny": config.openrouter.canDeny,
29
+ conversationContext: config.conversationContext,
29
30
  });
30
31
  return createHash("sha256").update(data).digest("hex");
31
32
  }
@@ -18,9 +18,12 @@ import { CacheProvider } from "../providers/cache.ts";
18
18
  import { ChainedCommandsProvider } from "../providers/chained-commands.ts";
19
19
  import { ClaudeProvider } from "../providers/claude.ts";
20
20
  import { OpenRouterProvider } from "../providers/openrouter.ts";
21
+ import { formatTranscriptForPrompt, readTranscript } from "../transcript.ts";
21
22
  import type { HookResponse, Provider, TyrConfig } from "../types.ts";
22
23
  import { resolveProviders } from "../types.ts";
23
24
 
25
+ const MAX_TRANSCRIPT_MESSAGES = 10;
26
+
24
27
  const judgeArgs = {
25
28
  verbose: {
26
29
  type: "boolean" as const,
@@ -77,6 +80,11 @@ const judgeArgs = {
77
80
  type: "boolean" as const,
78
81
  description: "Include LLM prompt and parameters in log entries",
79
82
  },
83
+ "conversation-context": {
84
+ type: "boolean" as const,
85
+ description:
86
+ "Override conversationContext config (include transcript in LLM prompts)",
87
+ },
80
88
  };
81
89
 
82
90
  export default defineCommand({
@@ -224,6 +232,8 @@ export default defineCommand({
224
232
  config.openrouter.canDeny = args["openrouter-can-deny"];
225
233
  if (args["verbose-log"] !== undefined)
226
234
  config.verboseLog = args["verbose-log"];
235
+ if (args["conversation-context"] !== undefined)
236
+ config.conversationContext = args["conversation-context"];
227
237
 
228
238
  const agent = new ClaudeAgent();
229
239
  try {
@@ -232,6 +242,21 @@ export default defineCommand({
232
242
  if (verbose) console.error("[tyr] failed to init agent config:", err);
233
243
  }
234
244
 
245
+ // Read conversation context from transcript if enabled
246
+ let transcriptContext: string | undefined;
247
+ if (config.conversationContext) {
248
+ const messages = await readTranscript(
249
+ req.transcript_path,
250
+ MAX_TRANSCRIPT_MESSAGES,
251
+ );
252
+ transcriptContext = formatTranscriptForPrompt(messages) || undefined;
253
+ if (verbose && transcriptContext) {
254
+ console.error(
255
+ `[tyr] conversation context (${messages.length} messages):\n${transcriptContext}`,
256
+ );
257
+ }
258
+ }
259
+
235
260
  // Build provider pipeline from config
236
261
  const providers: Provider[] = [];
237
262
  let cacheProvider: CacheProvider | null = null;
@@ -248,11 +273,23 @@ export default defineCommand({
248
273
  providers.push(new ChainedCommandsProvider(agent, verbose));
249
274
  break;
250
275
  case "claude":
251
- providers.push(new ClaudeProvider(agent, config.claude, verbose));
276
+ providers.push(
277
+ new ClaudeProvider(
278
+ agent,
279
+ config.claude,
280
+ verbose,
281
+ transcriptContext,
282
+ ),
283
+ );
252
284
  break;
253
285
  case "openrouter":
254
286
  providers.push(
255
- new OpenRouterProvider(agent, config.openrouter, verbose),
287
+ new OpenRouterProvider(
288
+ agent,
289
+ config.openrouter,
290
+ verbose,
291
+ transcriptContext,
292
+ ),
256
293
  );
257
294
  break;
258
295
  }
@@ -319,12 +356,22 @@ export default defineCommand({
319
356
  if (config.verboseLog) {
320
357
  if (result.provider === "claude") {
321
358
  llm = {
322
- prompt: buildPrompt(req, agent, config.claude.canDeny),
359
+ prompt: buildPrompt(
360
+ req,
361
+ agent,
362
+ config.claude.canDeny,
363
+ transcriptContext,
364
+ ),
323
365
  model: config.claude.model,
324
366
  };
325
367
  } else if (result.provider === "openrouter") {
326
368
  llm = {
327
- prompt: buildPrompt(req, agent, config.openrouter.canDeny),
369
+ prompt: buildPrompt(
370
+ req,
371
+ agent,
372
+ config.openrouter.canDeny,
373
+ transcriptContext,
374
+ ),
328
375
  model: config.openrouter.model,
329
376
  };
330
377
  } else {
@@ -332,14 +379,24 @@ export default defineCommand({
332
379
  for (const name of resolveProviders(config)) {
333
380
  if (name === "claude") {
334
381
  llm = {
335
- prompt: buildPrompt(req, agent, config.claude.canDeny),
382
+ prompt: buildPrompt(
383
+ req,
384
+ agent,
385
+ config.claude.canDeny,
386
+ transcriptContext,
387
+ ),
336
388
  model: config.claude.model,
337
389
  };
338
390
  break;
339
391
  }
340
392
  if (name === "openrouter") {
341
393
  llm = {
342
- prompt: buildPrompt(req, agent, config.openrouter.canDeny),
394
+ prompt: buildPrompt(
395
+ req,
396
+ agent,
397
+ config.openrouter.canDeny,
398
+ transcriptContext,
399
+ ),
343
400
  model: config.openrouter.model,
344
401
  };
345
402
  break;
@@ -8,13 +8,9 @@ import {
8
8
  } from "../agents/claude.ts";
9
9
  import { rejectUnknownArgs } from "../args.ts";
10
10
  import { closeDb, getDb } from "../db.ts";
11
- import { readSettings, writeSettings } from "../install.ts";
11
+ import { readSettings } from "../install.ts";
12
12
 
13
13
  const suggestArgs = {
14
- apply: {
15
- type: "boolean" as const,
16
- description: "Write suggestions into Claude's settings.json",
17
- },
18
14
  global: {
19
15
  type: "boolean" as const,
20
16
  description: "Target global (~/.claude/settings.json)",
@@ -27,9 +23,10 @@ const suggestArgs = {
27
23
  type: "string" as const,
28
24
  description: "Minimum approval count to suggest (default: 5)",
29
25
  },
30
- json: {
26
+ all: {
31
27
  type: "boolean" as const,
32
- description: "Output raw JSON",
28
+ description:
29
+ "Include commands from all projects (default: current directory)",
33
30
  },
34
31
  };
35
32
 
@@ -44,23 +41,39 @@ export interface Suggestion {
44
41
  rule: string;
45
42
  }
46
43
 
47
- /** Query frequently-allowed commands and filter out those already in allow lists. */
44
+ /** Query frequently-allowed commands and filter out those already in allow lists.
45
+ * When `cwd` is provided, only includes commands from that directory (or subdirs). */
48
46
  export function getSuggestions(
49
47
  minCount: number,
50
48
  allowPatterns: string[],
49
+ cwd?: string,
51
50
  ): Suggestion[] {
52
51
  const db = getDb();
53
52
 
54
- const rows = db
55
- .query(
56
- `SELECT tool_input, COUNT(*) as count
53
+ let query: string;
54
+ let params: (number | string)[];
55
+
56
+ if (cwd) {
57
+ const escapedCwd = cwd.replace(/[%_]/g, "\\$&");
58
+ query = `SELECT tool_input, COUNT(*) as count
59
+ FROM logs
60
+ WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
61
+ AND (cwd = ? OR cwd LIKE ? || '/%' ESCAPE '\\')
62
+ GROUP BY tool_input
63
+ HAVING COUNT(*) >= ?
64
+ ORDER BY COUNT(*) DESC`;
65
+ params = [cwd, escapedCwd, minCount];
66
+ } else {
67
+ query = `SELECT tool_input, COUNT(*) as count
57
68
  FROM logs
58
69
  WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
59
70
  GROUP BY tool_input
60
71
  HAVING COUNT(*) >= ?
61
- ORDER BY COUNT(*) DESC`,
62
- )
63
- .all(minCount) as CommandFrequency[];
72
+ ORDER BY COUNT(*) DESC`;
73
+ params = [minCount];
74
+ }
75
+
76
+ const rows = db.query(query).all(...params) as CommandFrequency[];
64
77
 
65
78
  const suggestions: Suggestion[] = [];
66
79
  for (const row of rows) {
@@ -79,20 +92,35 @@ export function getSuggestions(
79
92
  return suggestions;
80
93
  }
81
94
 
82
- /** Merge new allow rules into existing settings without clobbering. */
83
- export function mergeAllowRules(
84
- settings: Record<string, unknown>,
85
- rules: string[],
86
- ): Record<string, unknown> {
87
- const result = { ...settings };
88
- const perms = (result.permissions ?? {}) as Record<string, unknown>;
89
- const existing = Array.isArray(perms.allow) ? (perms.allow as string[]) : [];
95
+ function buildSuggestSystemPrompt(
96
+ suggestions: Suggestion[],
97
+ settingsPath: string,
98
+ ): string {
99
+ const commandList = suggestions
100
+ .map((s) => `- \`${s.command}\` (approved ${s.count} times)`)
101
+ .join("\n");
102
+
103
+ return `You are helping configure permission rules for Claude Code.
104
+
105
+ The user has been manually approving shell commands while using Claude Code. Tyr has identified frequently-approved commands that could be added as permanent allow rules.
90
106
 
91
- const existingSet = new Set(existing);
92
- const merged = [...existing, ...rules.filter((r) => !existingSet.has(r))];
107
+ ## Frequently Approved Commands (not yet in allow rules)
93
108
 
94
- result.permissions = { ...perms, allow: merged };
95
- return result;
109
+ ${commandList}
110
+
111
+ ## Settings File
112
+ - Path: ${settingsPath}
113
+ - Format: JSON with a \`permissions.allow\` array of strings
114
+ - Each rule is a string like \`Bash(pattern)\` where \`pattern\` can use \`*\` as a glob wildcard
115
+ - Example: \`Bash(bun *)\` allows any command starting with \`bun \`
116
+
117
+ ## Your Task
118
+ Help the user decide which commands to add as allow rules:
119
+ 1. Suggest generalized glob patterns that group similar commands (e.g., "bun test" and "bun lint" → "Bash(bun *)")
120
+ 2. Explain what each pattern would match
121
+ 3. When the user is ready, write the rules to the settings file at the path above
122
+
123
+ Be concise. Start by presenting your suggested rules and ask if the user wants to adjust them.`;
96
124
  }
97
125
 
98
126
  export default defineCommand({
@@ -118,67 +146,42 @@ export default defineCommand({
118
146
  return;
119
147
  }
120
148
 
121
- try {
122
- // Load all allow patterns from Claude settings to filter suggestions
123
- const allPaths = settingsPaths(process.cwd());
124
- const allowPatterns: string[] = [];
125
- for (const path of allPaths) {
126
- const settings = await readSettings(path);
127
- const perms = settings.permissions as
128
- | Record<string, unknown>
129
- | undefined;
130
- if (perms && Array.isArray(perms.allow)) {
131
- allowPatterns.push(...extractBashPatterns(perms.allow));
132
- }
149
+ const allPaths = settingsPaths(process.cwd());
150
+ const allowPatterns: string[] = [];
151
+ for (const path of allPaths) {
152
+ const settings = await readSettings(path);
153
+ const perms = settings.permissions as Record<string, unknown> | undefined;
154
+ if (perms && Array.isArray(perms.allow)) {
155
+ allowPatterns.push(...extractBashPatterns(perms.allow));
133
156
  }
157
+ }
134
158
 
135
- const suggestions = getSuggestions(minCount, allowPatterns);
159
+ const cwdFilter = args.all ? undefined : process.cwd();
160
+ const suggestions = getSuggestions(minCount, allowPatterns, cwdFilter);
161
+ closeDb();
136
162
 
137
- if (args.json) {
138
- console.log(JSON.stringify(suggestions));
139
- return;
140
- }
163
+ if (suggestions.length === 0) {
164
+ console.log("No new suggestions found.");
165
+ return;
166
+ }
141
167
 
142
- if (suggestions.length === 0) {
143
- console.log("No new suggestions found.");
144
- return;
145
- }
168
+ const scope: "global" | "project" = args.project ? "project" : "global";
169
+ const configDir =
170
+ process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
171
+ const settingsPath =
172
+ scope === "global"
173
+ ? join(configDir, "settings.json")
174
+ : join(process.cwd(), ".claude", "settings.json");
146
175
 
147
- if (!args.apply) {
148
- console.log(
149
- `Suggested allow rules (commands approved >= ${minCount} times):`,
150
- );
151
- console.log();
152
- for (const s of suggestions) {
153
- console.log(` ${s.rule} (${s.count} approvals)`);
154
- }
155
- console.log();
156
- console.log("Run with --apply to add these rules to Claude settings.");
157
- return;
158
- }
176
+ const systemPrompt = buildSuggestSystemPrompt(suggestions, settingsPath);
159
177
 
160
- // Apply mode: write rules to settings
161
- const scope: "global" | "project" = args.project ? "project" : "global";
162
- const configDir =
163
- process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
164
- const settingsPath =
165
- scope === "global"
166
- ? join(configDir, "settings.json")
167
- : join(process.cwd(), ".claude", "settings.json");
168
- const settings = await readSettings(settingsPath);
169
-
170
- const newRules = suggestions.map((s) => s.rule);
171
- const merged = mergeAllowRules(settings, newRules);
172
- await writeSettings(settingsPath, merged);
173
-
174
- console.log(
175
- `Added ${newRules.length} allow rule(s) to ${scope} settings (${settingsPath}):`,
176
- );
177
- for (const rule of newRules) {
178
- console.log(` ${rule}`);
179
- }
180
- } finally {
181
- closeDb();
182
- }
178
+ const proc = Bun.spawn(["claude", "--append-system-prompt", systemPrompt], {
179
+ stdin: "inherit",
180
+ stdout: "inherit",
181
+ stderr: "inherit",
182
+ env: { ...process.env, CLAUDECODE: undefined },
183
+ });
184
+
185
+ process.exitCode = await proc.exited;
183
186
  },
184
187
  });
package/src/config.ts CHANGED
@@ -101,6 +101,7 @@ const VALID_KEY_TYPES: Record<
101
101
  > = {
102
102
  providers: "providers",
103
103
  failOpen: "boolean",
104
+ conversationContext: "boolean",
104
105
  verboseLog: "boolean",
105
106
  logRetention: "string",
106
107
  "claude.model": "string",
package/src/prompts.ts CHANGED
@@ -3,7 +3,7 @@ import type { PermissionRequest } from "./types.ts";
3
3
 
4
4
  /** Expected shape of the LLM's JSON response. */
5
5
  export interface LlmDecision {
6
- decision: "allow" | "deny";
6
+ decision: "allow" | "deny" | "abstain";
7
7
  reason: string;
8
8
  }
9
9
 
@@ -12,20 +12,56 @@ export function buildPrompt(
12
12
  req: PermissionRequest,
13
13
  agent: ClaudeAgent,
14
14
  canDeny: boolean,
15
+ conversationContext?: string,
15
16
  ): string {
16
17
  const info = agent.getDebugInfo();
17
18
  const command =
18
19
  typeof req.tool_input.command === "string" ? req.tool_input.command : "";
19
20
 
20
- const rules = canDeny
21
- ? `- If the command is a variation of one of the ALLOWED patterns → allow.
21
+ const fallthrough = canDeny ? "deny" : "abstain";
22
+
23
+ const conversationSection = conversationContext
24
+ ? `\n## Recent conversation\n${conversationContext}\n`
25
+ : "";
26
+
27
+ let rules: string;
28
+ if (conversationContext) {
29
+ rules = `1. If the command matches a DENIED pattern → deny. No exceptions, regardless of context.
30
+ 2. If the command is a variation of an ALLOWED pattern → allow.
31
+ 3. If the command matches neither pattern, allow ONLY if ALL of these are true:
32
+ - The user clearly requested or implied this action in the conversation
33
+ - It is a typical development command (build, test, lint, search, read, install, etc.)
34
+ - It does not access sensitive resources (credentials, .env, auth tokens)
35
+ - It does not make irreversible system-wide changes
36
+ 4. Otherwise → ${fallthrough}.
37
+ 5. Only allow commands clearly within the spirit of an existing allowed pattern OR clearly supported by conversation context.`;
38
+ } else if (canDeny) {
39
+ rules = `- If the command is a variation of one of the ALLOWED patterns → allow.
22
40
  - If the command is a variation of one of the DENIED patterns → deny.
23
41
  - If the command is not clearly similar to either set of patterns → deny (fail-closed).
24
- - Only allow commands that are clearly within the spirit of an existing allowed pattern.`
25
- : `- If the command is a variation of one of the ALLOWED patterns → allow.
42
+ - Only allow commands that are clearly within the spirit of an existing allowed pattern.`;
43
+ } else {
44
+ rules = `- If the command is a variation of one of the ALLOWED patterns → allow.
26
45
  - If the command is NOT clearly similar to an allowed pattern → abstain.
27
46
  - Only allow commands that are clearly within the spirit of an existing allowed pattern.
28
47
  - You CANNOT deny commands. Your only options are allow or abstain.`;
48
+ }
49
+
50
+ const examples = conversationContext
51
+ ? `
52
+
53
+ ## Examples
54
+ - User: "run the tests" → agent runs \`pytest\` → allow (clear intent, common dev command)
55
+ - User: "check the bundle size" → agent runs \`du -sh dist/\` → allow (clear intent, read-only)
56
+ - User: "install the dependencies" → agent runs \`npm install\` → allow (clear intent, standard workflow)
57
+ - User: "format the code" → agent runs \`prettier --write src/\` → allow (clear intent, common dev tool)
58
+ - User: "check what's listening on port 3000" → agent runs \`lsof -i :3000\` → allow (clear intent, read-only)
59
+ - User: "fix the bug" → agent runs \`curl https://example.com\` → ${fallthrough} (user didn't ask for network requests)
60
+ - User: "deploy this" → agent runs \`rm -rf /tmp/*\` → ${fallthrough} (destructive, not clearly related)
61
+ - Agent runs \`cat .env\` with no relevant user message → ${fallthrough} (no clear intent, sensitive file)
62
+ - Agent runs \`ssh remote-host\` with no relevant context → ${fallthrough} (network access, no clear intent)
63
+ - User: "clean up the build" → agent runs \`rm -rf node_modules/ dist/\` → allow (clear intent, scoped to project)`
64
+ : "";
29
65
 
30
66
  const responseFormat = canDeny
31
67
  ? `{"decision": "allow", "reason": "brief explanation"}
@@ -43,7 +79,7 @@ A coding assistant is requesting permission to run a shell command. Your job is
43
79
  - Working directory: ${req.cwd}
44
80
  - Tool: ${req.tool_name}
45
81
  - Command: ${command}
46
-
82
+ ${conversationSection}
47
83
  ## Configured permission patterns
48
84
  - Allowed patterns: ${JSON.stringify(info.allow)}
49
85
  - Denied patterns: ${JSON.stringify(info.deny)}
@@ -51,7 +87,7 @@ A coding assistant is requesting permission to run a shell command. Your job is
51
87
  The command did not exactly match any pattern, so you must judge by similarity.
52
88
 
53
89
  ## Rules
54
- ${rules}
90
+ ${rules}${examples}
55
91
 
56
92
  Respond with ONLY a JSON object in this exact format, no other text:
57
93
  ${responseFormat}`;
@@ -71,7 +107,9 @@ export function parseLlmResponse(stdout: string): LlmDecision | null {
71
107
  try {
72
108
  const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
73
109
  if (
74
- (parsed.decision === "allow" || parsed.decision === "deny") &&
110
+ (parsed.decision === "allow" ||
111
+ parsed.decision === "deny" ||
112
+ parsed.decision === "abstain") &&
75
113
  typeof parsed.reason === "string"
76
114
  ) {
77
115
  return { decision: parsed.decision, reason: parsed.reason };
@@ -20,14 +20,18 @@ export class ClaudeProvider implements Provider {
20
20
  private model: string;
21
21
  private canDeny: boolean;
22
22
 
23
+ private conversationContext?: string;
24
+
23
25
  constructor(
24
26
  private agent: ClaudeAgent,
25
27
  config: ClaudeConfig,
26
28
  private verbose: boolean = false,
29
+ conversationContext?: string,
27
30
  ) {
28
31
  this.model = config.model;
29
32
  this.timeoutMs = config.timeout * S_TO_MS;
30
33
  this.canDeny = config.canDeny;
34
+ this.conversationContext = conversationContext;
31
35
  }
32
36
 
33
37
  async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
@@ -37,7 +41,12 @@ export class ClaudeProvider implements Provider {
37
41
  if (typeof command !== "string" || command.trim() === "")
38
42
  return { decision: "abstain" };
39
43
 
40
- const prompt = buildPrompt(req, this.agent, this.canDeny);
44
+ const prompt = buildPrompt(
45
+ req,
46
+ this.agent,
47
+ this.canDeny,
48
+ this.conversationContext,
49
+ );
41
50
 
42
51
  // Clear CLAUDECODE env var so claude -p doesn't refuse to run
43
52
  // inside a Claude Code session (tyr is invoked as a hook).
@@ -110,6 +119,10 @@ export class ClaudeProvider implements Provider {
110
119
  const llmDecision = parseLlmResponse(result.stdout);
111
120
  if (!llmDecision) return { decision: "abstain" };
112
121
 
122
+ if (llmDecision.decision === "abstain") {
123
+ return { decision: "abstain", reason: llmDecision.reason };
124
+ }
125
+
113
126
  // When canDeny is false, convert deny→abstain so the user gets prompted
114
127
  if (!this.canDeny && llmDecision.decision === "deny") {
115
128
  return { decision: "abstain", reason: llmDecision.reason };
@@ -19,15 +19,19 @@ export class OpenRouterProvider implements Provider {
19
19
  private endpoint: string;
20
20
  private canDeny: boolean;
21
21
 
22
+ private conversationContext?: string;
23
+
22
24
  constructor(
23
25
  private agent: ClaudeAgent,
24
26
  config: OpenRouterConfig,
25
27
  private verbose: boolean = false,
28
+ conversationContext?: string,
26
29
  ) {
27
30
  this.model = config.model;
28
31
  this.timeoutMs = config.timeout * S_TO_MS;
29
32
  this.canDeny = config.canDeny;
30
33
  this.endpoint = config.endpoint;
34
+ this.conversationContext = conversationContext;
31
35
  }
32
36
 
33
37
  async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
@@ -45,7 +49,12 @@ export class OpenRouterProvider implements Provider {
45
49
  return { decision: "abstain" };
46
50
  }
47
51
 
48
- const prompt = buildPrompt(req, this.agent, this.canDeny);
52
+ const prompt = buildPrompt(
53
+ req,
54
+ this.agent,
55
+ this.canDeny,
56
+ this.conversationContext,
57
+ );
49
58
  const url = `${this.endpoint}/chat/completions`;
50
59
 
51
60
  const controller = new AbortController();
@@ -102,6 +111,10 @@ export class OpenRouterProvider implements Provider {
102
111
  const llmDecision = parseLlmResponse(responseText);
103
112
  if (!llmDecision) return { decision: "abstain" };
104
113
 
114
+ if (llmDecision.decision === "abstain") {
115
+ return { decision: "abstain", reason: llmDecision.reason };
116
+ }
117
+
105
118
  // When canDeny is false, convert deny→abstain so the user gets prompted
106
119
  if (!this.canDeny && llmDecision.decision === "deny") {
107
120
  return { decision: "abstain", reason: llmDecision.reason };
@@ -0,0 +1,97 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ export interface TranscriptMessage {
4
+ role: "user" | "assistant";
5
+ content: string;
6
+ }
7
+
8
+ /** Content block within an assistant message. */
9
+ interface ContentBlock {
10
+ type: string;
11
+ text?: string;
12
+ }
13
+
14
+ /** Shape of a single JSONL line in the transcript file. */
15
+ interface TranscriptLine {
16
+ type: string;
17
+ isSidechain?: boolean;
18
+ isMeta?: boolean;
19
+ message?: {
20
+ role?: string;
21
+ content?: string | ContentBlock[];
22
+ };
23
+ }
24
+
25
+ /** Read the last N conversation messages from a Claude transcript JSONL file.
26
+ * Returns an empty array on any error (graceful degradation). */
27
+ export async function readTranscript(
28
+ path: string,
29
+ maxMessages: number,
30
+ ): Promise<TranscriptMessage[]> {
31
+ let text: string;
32
+ try {
33
+ text = await readFile(path, "utf-8");
34
+ } catch {
35
+ return [];
36
+ }
37
+
38
+ const messages: TranscriptMessage[] = [];
39
+
40
+ for (const line of text.split("\n")) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed) continue;
43
+
44
+ let entry: TranscriptLine;
45
+ try {
46
+ entry = JSON.parse(trimmed) as TranscriptLine;
47
+ } catch {
48
+ continue;
49
+ }
50
+
51
+ // Skip non-conversation entries
52
+ if (entry.type !== "user" && entry.type !== "assistant") continue;
53
+ if (entry.isSidechain || entry.isMeta) continue;
54
+ if (!entry.message) continue;
55
+
56
+ if (entry.type === "user") {
57
+ if (typeof entry.message.content !== "string") continue;
58
+ messages.push({ role: "user", content: entry.message.content });
59
+ } else {
60
+ // Assistant: extract text content blocks
61
+ const content = entry.message.content;
62
+ if (!Array.isArray(content)) continue;
63
+ const textParts = content
64
+ .filter(
65
+ (block): block is ContentBlock & { text: string } =>
66
+ block.type === "text" && typeof block.text === "string",
67
+ )
68
+ .map((block) => block.text);
69
+ if (textParts.length === 0) continue;
70
+ messages.push({ role: "assistant", content: textParts.join("\n") });
71
+ }
72
+ }
73
+
74
+ return messages.slice(-maxMessages);
75
+ }
76
+
77
+ const DEFAULT_MAX_CHARS = 500;
78
+
79
+ /** Format transcript messages for inclusion in an LLM prompt.
80
+ * Each message is truncated to maxCharsPerMessage characters.
81
+ * Returns an empty string if there are no messages. */
82
+ export function formatTranscriptForPrompt(
83
+ messages: TranscriptMessage[],
84
+ maxCharsPerMessage: number = DEFAULT_MAX_CHARS,
85
+ ): string {
86
+ if (messages.length === 0) return "";
87
+
88
+ const lines = messages.map((msg) => {
89
+ const truncated =
90
+ msg.content.length > maxCharsPerMessage
91
+ ? `${msg.content.slice(0, maxCharsPerMessage)}...`
92
+ : msg.content;
93
+ return `[${msg.role}]: ${truncated}`;
94
+ });
95
+
96
+ return lines.join("\n");
97
+ }
package/src/types.ts CHANGED
@@ -87,6 +87,8 @@ export const TyrConfigSchema = z.object({
87
87
  claude: ClaudeConfigSchema.default(ClaudeConfigSchema.parse({})),
88
88
  /** OpenRouter API provider configuration. */
89
89
  openrouter: OpenRouterConfigSchema.default(OpenRouterConfigSchema.parse({})),
90
+ /** Include recent conversation messages in LLM judge prompts for better context. */
91
+ conversationContext: z.boolean().default(false),
90
92
  /** Include LLM prompt and parameters in log entries for debugging. */
91
93
  verboseLog: z.boolean().default(false),
92
94
  /** Maximum age of log entries. Entries older than this are pruned on the next tyr invocation.