@panchr/tyr 0.1.1 → 0.2.2
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 +58 -179
- package/package.json +1 -1
- package/src/cache.ts +1 -0
- package/src/commands/config.ts +48 -0
- package/src/commands/db.ts +58 -0
- package/src/commands/debug.ts +2 -1
- package/src/commands/judge.ts +63 -6
- package/src/commands/log.ts +2 -1
- package/src/commands/suggest.ts +99 -84
- package/src/config.ts +1 -0
- package/src/index.ts +0 -0
- package/src/install.ts +2 -1
- package/src/prompts.ts +46 -8
- package/src/providers/claude.ts +14 -1
- package/src/providers/openrouter.ts +14 -1
- package/src/repo.ts +24 -0
- package/src/transcript.ts +97 -0
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -1,90 +1,62 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
27
|
-
| **Shadow** (`tyr install --shadow`) | Runs the full allow/deny pipeline but always abstains to Claude Code | Validate your rules against real
|
|
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
|
-
##
|
|
25
|
+
## Quickstart
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
- Claude Code (for integration — tyr can be tested standalone)
|
|
36
|
-
|
|
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
|
-
#
|
|
41
|
-
|
|
42
|
-
bun install
|
|
43
|
-
|
|
44
|
-
# Build and install the binary to /usr/local/bin
|
|
45
|
-
bun run build
|
|
30
|
+
# Install tyr
|
|
31
|
+
bun install -g @panchr/tyr
|
|
46
32
|
|
|
47
|
-
# Register the hook
|
|
33
|
+
# Register the hook (run this inside your project directory)
|
|
48
34
|
tyr install
|
|
49
35
|
|
|
50
|
-
#
|
|
51
|
-
|
|
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
|
|
52
40
|
```
|
|
53
41
|
|
|
54
|
-
|
|
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):
|
|
55
45
|
|
|
56
46
|
```bash
|
|
57
|
-
tyr
|
|
58
|
-
tyr uninstall --global
|
|
47
|
+
tyr install --global
|
|
59
48
|
```
|
|
60
49
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
## Usage
|
|
64
|
-
|
|
65
|
-
Once installed, tyr runs automatically as a Claude Code hook. No manual invocation needed.
|
|
66
|
-
|
|
67
|
-
### Commands
|
|
50
|
+
To remove:
|
|
68
51
|
|
|
69
|
-
```
|
|
70
|
-
tyr
|
|
71
|
-
tyr uninstall
|
|
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
|
|
52
|
+
```bash
|
|
53
|
+
tyr uninstall # project
|
|
54
|
+
tyr uninstall --global # global
|
|
85
55
|
```
|
|
86
56
|
|
|
87
|
-
|
|
57
|
+
Use `--dry-run` with either command to preview changes without modifying anything. Run `tyr --help` for the full command reference.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
88
60
|
|
|
89
61
|
Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_CONFIG_FILE`). The config file supports JSON with comments (JSONC).
|
|
90
62
|
|
|
@@ -99,173 +71,80 @@ Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_
|
|
|
99
71
|
| `openrouter.endpoint` | string | `"https://openrouter.ai/api/v1"` | OpenRouter API endpoint |
|
|
100
72
|
| `openrouter.timeout` | number | `10` | OpenRouter request timeout in seconds |
|
|
101
73
|
| `openrouter.canDeny` | boolean | `false` | Whether OpenRouter can deny requests |
|
|
74
|
+
| `conversationContext` | boolean | `false` | Give LLM providers recent conversation context to judge intent (can allow commands beyond configured patterns) |
|
|
102
75
|
| `verboseLog` | boolean | `false` | Include LLM prompt/params in log entries |
|
|
103
76
|
| `logRetention` | string | `"30d"` | Auto-prune logs older than this (`"0"` to disable) |
|
|
104
77
|
|
|
105
|
-
|
|
78
|
+
Use `tyr config show` to view the current config, `tyr config set <key> <value>` to update a value, `tyr config example` to print a recommended starter config, and `tyr config schema` to print the JSON Schema.
|
|
106
79
|
|
|
107
80
|
### Environment variables
|
|
108
81
|
|
|
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
|
|
82
|
+
Tyr loads environment variables from `~/.config/tyr/.env` (next to the config file). This is the recommended place to store API keys (e.g., `OPENROUTER_API_KEY`). Use `tyr config env set <key> <value>` to manage them. Existing process environment variables take precedence.
|
|
125
83
|
|
|
126
|
-
|
|
84
|
+
## Providers
|
|
127
85
|
|
|
128
|
-
|
|
86
|
+
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.
|
|
129
87
|
|
|
130
|
-
|
|
88
|
+
Configure the pipeline via the `providers` array. **Order matters** -- providers run in order.
|
|
131
89
|
|
|
132
|
-
|
|
90
|
+
### `cache`
|
|
133
91
|
|
|
134
92
|
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
93
|
|
|
136
94
|
**Best practice:** Place first in the pipeline to skip expensive downstream evaluations.
|
|
137
95
|
|
|
138
|
-
|
|
96
|
+
### `chained-commands`
|
|
139
97
|
|
|
140
|
-
Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns
|
|
98
|
+
Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns.
|
|
141
99
|
|
|
142
100
|
- **Allow:** All sub-commands match an allow pattern
|
|
143
|
-
- **Deny:**
|
|
101
|
+
- **Deny:** _Any_ sub-command matches a deny pattern
|
|
144
102
|
- **Abstain:** Any sub-command has no matching pattern
|
|
145
103
|
|
|
146
|
-
|
|
104
|
+
### `claude`
|
|
147
105
|
|
|
148
|
-
|
|
106
|
+
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.
|
|
149
107
|
|
|
150
|
-
|
|
108
|
+
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.
|
|
151
109
|
|
|
152
|
-
When `
|
|
110
|
+
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.
|
|
153
111
|
|
|
154
|
-
|
|
112
|
+
Note: this provider adds ~5 seconds of latency per evaluation due to the subprocess overhead, but this is still faster than a human reviewing and approving a command. It also reuses whatever authentication `claude` is already configured with.
|
|
155
113
|
|
|
156
|
-
|
|
114
|
+
### `openrouter`
|
|
157
115
|
|
|
158
|
-
|
|
116
|
+
Same semantics as the `claude` provider but uses the OpenRouter HTTP API instead of the local CLI. Supports `conversationContext` in the same way. Requires `OPENROUTER_API_KEY`.
|
|
159
117
|
|
|
160
|
-
|
|
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
|
|
118
|
+
### Pipeline examples
|
|
167
119
|
|
|
168
120
|
```jsonc
|
|
169
|
-
// Safe & fast (default)
|
|
121
|
+
// Safe & fast (default) -- pattern matching only
|
|
170
122
|
{ "providers": ["chained-commands"] }
|
|
171
123
|
|
|
172
|
-
// With caching
|
|
124
|
+
// With caching -- faster repeated evaluations
|
|
173
125
|
{ "providers": ["cache", "chained-commands"] }
|
|
174
126
|
|
|
175
|
-
// Full pipeline
|
|
127
|
+
// Full pipeline -- patterns first, then Claude for ambiguous commands
|
|
176
128
|
{ "providers": ["cache", "chained-commands", "claude"] }
|
|
177
129
|
|
|
178
130
|
// Using OpenRouter instead of local Claude
|
|
179
131
|
{ "providers": ["cache", "chained-commands", "openrouter"] }
|
|
180
132
|
```
|
|
181
133
|
|
|
182
|
-
###
|
|
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
|
|
134
|
+
### Permission prompt delay
|
|
189
135
|
|
|
190
|
-
|
|
191
|
-
tyr log --last 50
|
|
136
|
+
When tyr is installed as a hook, Claude Code shows the permission prompt and calls the hook concurrently. You'll see the prompt appear immediately while tyr evaluates the command in the background. If the hook decides to allow or deny, the prompt is automatically resolved; if the hook abstains, the prompt remains for you to decide manually.
|
|
192
137
|
|
|
193
|
-
|
|
194
|
-
tyr log --decision allow
|
|
195
|
-
tyr log --decision deny
|
|
138
|
+
## Database management
|
|
196
139
|
|
|
197
|
-
|
|
198
|
-
tyr log --since 1h
|
|
199
|
-
tyr log --since 2025-01-01 --until 2025-01-31
|
|
140
|
+
Tyr stores all decision logs and cached results in a SQLite database at `~/.local/share/tyr/tyr.db` (overridable via `TYR_DB_PATH`).
|
|
200
141
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
tyr
|
|
142
|
+
| Command | Description |
|
|
143
|
+
|---------|-------------|
|
|
144
|
+
| `tyr db migrate` | Run pending schema migrations after upgrading tyr |
|
|
145
|
+
| `tyr db rename <old> <new>` | Update stored project paths after moving a directory |
|
|
204
146
|
|
|
205
|
-
|
|
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
|
-
```
|
|
147
|
+
`tyr db rename` is useful when you relocate a project on disk. It rewrites the `cwd` column in both the logs and cache tables (including subpaths) so that `tyr log`, `tyr stats`, and cache lookups continue to work for the moved project.
|
|
269
148
|
|
|
270
149
|
## Development
|
|
271
150
|
|
package/package.json
CHANGED
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
|
}
|
package/src/commands/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
+
import { z } from "zod/v4";
|
|
2
3
|
import {
|
|
3
4
|
getConfigPath,
|
|
4
5
|
getEnvPath,
|
|
@@ -167,6 +168,51 @@ const env = defineCommand({
|
|
|
167
168
|
},
|
|
168
169
|
});
|
|
169
170
|
|
|
171
|
+
const EXAMPLE_CONFIG = `{
|
|
172
|
+
// Pattern matching with caching for fast repeated evaluations.
|
|
173
|
+
// Add "claude" or "openrouter" after "chained-commands" to use an
|
|
174
|
+
// LLM for commands that don't match any allow/deny pattern.
|
|
175
|
+
"providers": ["cache", "chained-commands"],
|
|
176
|
+
|
|
177
|
+
// Fail closed: deny on error rather than auto-approving.
|
|
178
|
+
"failOpen": false,
|
|
179
|
+
|
|
180
|
+
// Auto-prune log entries older than 30 days.
|
|
181
|
+
"logRetention": "30d"
|
|
182
|
+
}
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
const example = defineCommand({
|
|
186
|
+
meta: {
|
|
187
|
+
name: "example",
|
|
188
|
+
description: "Print a recommended example config",
|
|
189
|
+
},
|
|
190
|
+
run() {
|
|
191
|
+
process.stdout.write(EXAMPLE_CONFIG);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const schema = defineCommand({
|
|
196
|
+
meta: {
|
|
197
|
+
name: "schema",
|
|
198
|
+
description: "Print the config JSON Schema",
|
|
199
|
+
},
|
|
200
|
+
run() {
|
|
201
|
+
const jsonSchema = z.toJSONSchema(TyrConfigSchema, {
|
|
202
|
+
target: "draft-2020-12",
|
|
203
|
+
});
|
|
204
|
+
// z.toJSONSchema cannot represent .refine() constraints;
|
|
205
|
+
// manually add the pattern for logRetention.
|
|
206
|
+
const props = (jsonSchema as { properties?: Record<string, object> })
|
|
207
|
+
.properties;
|
|
208
|
+
if (props?.logRetention) {
|
|
209
|
+
(props.logRetention as Record<string, unknown>).pattern =
|
|
210
|
+
"^(0|\\d+[smhd])$";
|
|
211
|
+
}
|
|
212
|
+
console.log(JSON.stringify(jsonSchema, null, 2));
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
170
216
|
export default defineCommand({
|
|
171
217
|
meta: {
|
|
172
218
|
name: "config",
|
|
@@ -176,6 +222,8 @@ export default defineCommand({
|
|
|
176
222
|
show,
|
|
177
223
|
set,
|
|
178
224
|
path,
|
|
225
|
+
example,
|
|
226
|
+
schema,
|
|
179
227
|
env,
|
|
180
228
|
},
|
|
181
229
|
});
|
package/src/commands/db.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { defineCommand } from "citty";
|
|
2
3
|
import {
|
|
3
4
|
CURRENT_SCHEMA_VERSION,
|
|
@@ -55,6 +56,62 @@ const migrate = defineCommand({
|
|
|
55
56
|
},
|
|
56
57
|
});
|
|
57
58
|
|
|
59
|
+
const rename = defineCommand({
|
|
60
|
+
meta: {
|
|
61
|
+
name: "rename",
|
|
62
|
+
description:
|
|
63
|
+
"Rename a project directory in the database (e.g. after moving a project)",
|
|
64
|
+
},
|
|
65
|
+
args: {
|
|
66
|
+
oldPath: {
|
|
67
|
+
type: "positional",
|
|
68
|
+
description: "Current project directory path",
|
|
69
|
+
required: true,
|
|
70
|
+
},
|
|
71
|
+
newPath: {
|
|
72
|
+
type: "positional",
|
|
73
|
+
description: "New project directory path",
|
|
74
|
+
required: true,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
async run({ args }) {
|
|
78
|
+
const oldPath = resolve(args.oldPath as string);
|
|
79
|
+
const newPath = resolve(args.newPath as string);
|
|
80
|
+
|
|
81
|
+
if (oldPath === newPath) {
|
|
82
|
+
console.error("Old and new paths are the same.");
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const db = openRawDb();
|
|
87
|
+
|
|
88
|
+
if (!hasMetaTable(db)) {
|
|
89
|
+
console.error("Database is uninitialized. Nothing to rename.");
|
|
90
|
+
db.close();
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const escapedOld = oldPath.replace(/[\\%_]/g, "\\$&");
|
|
95
|
+
|
|
96
|
+
const updated = db.transaction(() => {
|
|
97
|
+
let total = 0;
|
|
98
|
+
for (const table of ["logs", "cache"] as const) {
|
|
99
|
+
const result = db
|
|
100
|
+
.query(
|
|
101
|
+
`UPDATE ${table} SET cwd = ? || substr(cwd, length(?) + 1)
|
|
102
|
+
WHERE cwd = ? OR cwd LIKE ? || '/%' ESCAPE '\\'`,
|
|
103
|
+
)
|
|
104
|
+
.run(newPath, oldPath, oldPath, escapedOld);
|
|
105
|
+
total += result.changes;
|
|
106
|
+
}
|
|
107
|
+
return total;
|
|
108
|
+
})();
|
|
109
|
+
|
|
110
|
+
console.log(`Renamed ${oldPath} → ${newPath} (${updated} row(s) updated)`);
|
|
111
|
+
db.close();
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
58
115
|
export default defineCommand({
|
|
59
116
|
meta: {
|
|
60
117
|
name: "db",
|
|
@@ -62,5 +119,6 @@ export default defineCommand({
|
|
|
62
119
|
},
|
|
63
120
|
subCommands: {
|
|
64
121
|
migrate,
|
|
122
|
+
rename,
|
|
65
123
|
},
|
|
66
124
|
});
|
package/src/commands/debug.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { ClaudeAgent } from "../agents/claude.ts";
|
|
3
|
+
import { getRepoRoot } from "../repo.ts";
|
|
3
4
|
|
|
4
5
|
const claudeConfig = defineCommand({
|
|
5
6
|
meta: {
|
|
@@ -13,7 +14,7 @@ const claudeConfig = defineCommand({
|
|
|
13
14
|
},
|
|
14
15
|
},
|
|
15
16
|
async run({ args }) {
|
|
16
|
-
const cwd = (args.cwd as string | undefined) ??
|
|
17
|
+
const cwd = (args.cwd as string | undefined) ?? getRepoRoot();
|
|
17
18
|
const agent = new ClaudeAgent();
|
|
18
19
|
await agent.init(cwd);
|
|
19
20
|
const info = agent.getDebugInfo();
|
package/src/commands/judge.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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;
|
package/src/commands/log.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
readLogEntries,
|
|
11
11
|
truncateOldLogs,
|
|
12
12
|
} from "../log.ts";
|
|
13
|
+
import { getRepoRoot } from "../repo.ts";
|
|
13
14
|
|
|
14
15
|
function formatTime(ts: number): string {
|
|
15
16
|
const d = new Date(ts);
|
|
@@ -145,7 +146,7 @@ export default defineCommand({
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
// Default to current directory unless --all or --cwd is provided
|
|
148
|
-
const cwdFilter = args.all ? undefined : (args.cwd ??
|
|
149
|
+
const cwdFilter = args.all ? undefined : (args.cwd ?? getRepoRoot());
|
|
149
150
|
|
|
150
151
|
const entries = readLogEntries({
|
|
151
152
|
last: last > 0 ? last : undefined,
|
package/src/commands/suggest.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
@@ -8,13 +9,10 @@ import {
|
|
|
8
9
|
} from "../agents/claude.ts";
|
|
9
10
|
import { rejectUnknownArgs } from "../args.ts";
|
|
10
11
|
import { closeDb, getDb } from "../db.ts";
|
|
11
|
-
import { readSettings
|
|
12
|
+
import { readSettings } from "../install.ts";
|
|
13
|
+
import { getRepoRoot } from "../repo.ts";
|
|
12
14
|
|
|
13
15
|
const suggestArgs = {
|
|
14
|
-
apply: {
|
|
15
|
-
type: "boolean" as const,
|
|
16
|
-
description: "Write suggestions into Claude's settings.json",
|
|
17
|
-
},
|
|
18
16
|
global: {
|
|
19
17
|
type: "boolean" as const,
|
|
20
18
|
description: "Target global (~/.claude/settings.json)",
|
|
@@ -27,9 +25,10 @@ const suggestArgs = {
|
|
|
27
25
|
type: "string" as const,
|
|
28
26
|
description: "Minimum approval count to suggest (default: 5)",
|
|
29
27
|
},
|
|
30
|
-
|
|
28
|
+
all: {
|
|
31
29
|
type: "boolean" as const,
|
|
32
|
-
description:
|
|
30
|
+
description:
|
|
31
|
+
"Include commands from all projects (default: current directory)",
|
|
33
32
|
},
|
|
34
33
|
};
|
|
35
34
|
|
|
@@ -44,23 +43,39 @@ export interface Suggestion {
|
|
|
44
43
|
rule: string;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
/** Query frequently-allowed commands and filter out those already in allow lists.
|
|
46
|
+
/** Query frequently-allowed commands and filter out those already in allow lists.
|
|
47
|
+
* When `cwd` is provided, only includes commands from that directory (or subdirs). */
|
|
48
48
|
export function getSuggestions(
|
|
49
49
|
minCount: number,
|
|
50
50
|
allowPatterns: string[],
|
|
51
|
+
cwd?: string,
|
|
51
52
|
): Suggestion[] {
|
|
52
53
|
const db = getDb();
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
let query: string;
|
|
56
|
+
let params: (number | string)[];
|
|
57
|
+
|
|
58
|
+
if (cwd) {
|
|
59
|
+
const escapedCwd = cwd.replace(/[%_]/g, "\\$&");
|
|
60
|
+
query = `SELECT tool_input, COUNT(*) as count
|
|
61
|
+
FROM logs
|
|
62
|
+
WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
|
|
63
|
+
AND (cwd = ? OR cwd LIKE ? || '/%' ESCAPE '\\')
|
|
64
|
+
GROUP BY tool_input
|
|
65
|
+
HAVING COUNT(*) >= ?
|
|
66
|
+
ORDER BY COUNT(*) DESC`;
|
|
67
|
+
params = [cwd, escapedCwd, minCount];
|
|
68
|
+
} else {
|
|
69
|
+
query = `SELECT tool_input, COUNT(*) as count
|
|
57
70
|
FROM logs
|
|
58
71
|
WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
|
|
59
72
|
GROUP BY tool_input
|
|
60
73
|
HAVING COUNT(*) >= ?
|
|
61
|
-
ORDER BY COUNT(*) DESC
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
ORDER BY COUNT(*) DESC`;
|
|
75
|
+
params = [minCount];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rows = db.query(query).all(...params) as CommandFrequency[];
|
|
64
79
|
|
|
65
80
|
const suggestions: Suggestion[] = [];
|
|
66
81
|
for (const row of rows) {
|
|
@@ -79,20 +94,43 @@ export function getSuggestions(
|
|
|
79
94
|
return suggestions;
|
|
80
95
|
}
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
):
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
export function buildSuggestPrompt(
|
|
98
|
+
suggestions: Suggestion[],
|
|
99
|
+
targetPath: string,
|
|
100
|
+
allPaths: string[],
|
|
101
|
+
): string {
|
|
102
|
+
const commandList = suggestions
|
|
103
|
+
.map((s) => `- \`${s.command}\` (approved ${s.count} times)`)
|
|
104
|
+
.join("\n");
|
|
105
|
+
|
|
106
|
+
const pathList = allPaths.map((p) => `- \`${p}\``).join("\n");
|
|
107
|
+
|
|
108
|
+
return `I've been manually approving shell commands while using Claude Code. Tyr has identified frequently-approved commands that could be added as permanent allow rules.
|
|
109
|
+
|
|
110
|
+
## Frequently Approved Commands (not yet in allow rules)
|
|
111
|
+
|
|
112
|
+
${commandList}
|
|
90
113
|
|
|
91
|
-
|
|
92
|
-
const merged = [...existing, ...rules.filter((r) => !existingSet.has(r))];
|
|
114
|
+
## Settings Files
|
|
93
115
|
|
|
94
|
-
|
|
95
|
-
|
|
116
|
+
Claude Code reads permissions from multiple settings files:
|
|
117
|
+
|
|
118
|
+
${pathList}
|
|
119
|
+
|
|
120
|
+
## Target Settings File
|
|
121
|
+
- Write new rules to: \`${targetPath}\`
|
|
122
|
+
- Format: JSON with a \`permissions.allow\` array of strings
|
|
123
|
+
- Each rule MUST use the exact format \`Bash(pattern)\` where \`pattern\` can use \`*\` as a glob wildcard
|
|
124
|
+
- Example: \`Bash(bun *)\` allows any command starting with \`bun \`
|
|
125
|
+
- IMPORTANT: Before writing rules, read the settings files listed above (those that exist) to understand existing permissions, then merge new rules into the target file's \`permissions.allow\` array
|
|
126
|
+
|
|
127
|
+
## Instructions
|
|
128
|
+
Help me decide which commands to add as allow rules:
|
|
129
|
+
1. Suggest generalized glob patterns that group similar commands (e.g., "bun test" and "bun lint" → "Bash(bun *)")
|
|
130
|
+
2. Explain what each pattern would match
|
|
131
|
+
3. When I'm ready, write the rules to the target settings file
|
|
132
|
+
|
|
133
|
+
Be concise. Start by presenting your suggested rules and ask if I want to adjust them.`;
|
|
96
134
|
}
|
|
97
135
|
|
|
98
136
|
export default defineCommand({
|
|
@@ -118,67 +156,44 @@ export default defineCommand({
|
|
|
118
156
|
return;
|
|
119
157
|
}
|
|
120
158
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
| undefined;
|
|
130
|
-
if (perms && Array.isArray(perms.allow)) {
|
|
131
|
-
allowPatterns.push(...extractBashPatterns(perms.allow));
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const suggestions = getSuggestions(minCount, allowPatterns);
|
|
136
|
-
|
|
137
|
-
if (args.json) {
|
|
138
|
-
console.log(JSON.stringify(suggestions));
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (suggestions.length === 0) {
|
|
143
|
-
console.log("No new suggestions found.");
|
|
144
|
-
return;
|
|
159
|
+
const repoRoot = getRepoRoot();
|
|
160
|
+
const allPaths = settingsPaths(repoRoot);
|
|
161
|
+
const allowPatterns: string[] = [];
|
|
162
|
+
for (const path of allPaths) {
|
|
163
|
+
const settings = await readSettings(path);
|
|
164
|
+
const perms = settings.permissions as Record<string, unknown> | undefined;
|
|
165
|
+
if (perms && Array.isArray(perms.allow)) {
|
|
166
|
+
allowPatterns.push(...extractBashPatterns(perms.allow));
|
|
145
167
|
}
|
|
168
|
+
}
|
|
146
169
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
}
|
|
170
|
+
const cwdFilter = args.all ? undefined : repoRoot;
|
|
171
|
+
const suggestions = getSuggestions(minCount, allowPatterns, cwdFilter);
|
|
172
|
+
closeDb();
|
|
159
173
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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();
|
|
174
|
+
if (suggestions.length === 0) {
|
|
175
|
+
console.log("No new suggestions found.");
|
|
176
|
+
return;
|
|
182
177
|
}
|
|
178
|
+
|
|
179
|
+
const scope: "global" | "project" = args.global ? "global" : "project";
|
|
180
|
+
const configDir =
|
|
181
|
+
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
182
|
+
const targetPath =
|
|
183
|
+
scope === "global"
|
|
184
|
+
? join(configDir, "settings.json")
|
|
185
|
+
: join(repoRoot, ".claude", "settings.json");
|
|
186
|
+
|
|
187
|
+
const existingPaths = allPaths.filter((p) => existsSync(p));
|
|
188
|
+
const prompt = buildSuggestPrompt(suggestions, targetPath, existingPaths);
|
|
189
|
+
|
|
190
|
+
const proc = Bun.spawn(["claude", prompt], {
|
|
191
|
+
stdin: "inherit",
|
|
192
|
+
stdout: "inherit",
|
|
193
|
+
stderr: "inherit",
|
|
194
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
process.exitCode = await proc.exited;
|
|
183
198
|
},
|
|
184
199
|
});
|
package/src/config.ts
CHANGED
package/src/index.ts
CHANGED
|
File without changes
|
package/src/install.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { getRepoRoot } from "./repo.ts";
|
|
4
5
|
|
|
5
6
|
export type JudgeMode = "shadow" | "audit" | undefined;
|
|
6
7
|
|
|
@@ -20,7 +21,7 @@ export function getSettingsPath(scope: "global" | "project"): string {
|
|
|
20
21
|
if (scope === "global") {
|
|
21
22
|
return join(homedir(), ".claude", "settings.json");
|
|
22
23
|
}
|
|
23
|
-
return join(
|
|
24
|
+
return join(getRepoRoot(), ".claude", "settings.json");
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Read and parse a settings.json, returning {} if it doesn't exist. */
|
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
|
|
21
|
-
|
|
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
|
-
|
|
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" ||
|
|
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 };
|
package/src/providers/claude.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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 };
|
package/src/repo.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walk up from `startDir` looking for a `.git` directory.
|
|
6
|
+
* Returns the repo root or `null` if not inside a git repository.
|
|
7
|
+
*/
|
|
8
|
+
export function findRepoRoot(startDir: string): string | null {
|
|
9
|
+
let dir = resolve(startDir);
|
|
10
|
+
for (;;) {
|
|
11
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
12
|
+
const parent = dirname(dir);
|
|
13
|
+
if (parent === dir) return null;
|
|
14
|
+
dir = parent;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns the repo root for the current directory, falling back to
|
|
20
|
+
* `process.cwd()` when not inside a git repository.
|
|
21
|
+
*/
|
|
22
|
+
export function getRepoRoot(): string {
|
|
23
|
+
return findRepoRoot(process.cwd()) ?? process.cwd();
|
|
24
|
+
}
|
|
@@ -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.
|