@panchr/tyr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -0
- package/package.json +32 -0
- package/src/agents/claude.ts +143 -0
- package/src/args.ts +55 -0
- package/src/cache.ts +84 -0
- package/src/commands/config.ts +181 -0
- package/src/commands/db.ts +66 -0
- package/src/commands/debug.ts +34 -0
- package/src/commands/install.ts +77 -0
- package/src/commands/judge.ts +399 -0
- package/src/commands/log.ts +189 -0
- package/src/commands/stats.ts +154 -0
- package/src/commands/suggest.ts +184 -0
- package/src/commands/uninstall.ts +54 -0
- package/src/commands/version.ts +14 -0
- package/src/config.ts +222 -0
- package/src/db.ts +229 -0
- package/src/index.ts +36 -0
- package/src/install.ts +116 -0
- package/src/judge.ts +19 -0
- package/src/log.ts +193 -0
- package/src/pipeline.ts +32 -0
- package/src/prompts.ts +83 -0
- package/src/providers/cache.ts +34 -0
- package/src/providers/chained-commands.ts +45 -0
- package/src/providers/claude.ts +120 -0
- package/src/providers/openrouter.ts +112 -0
- package/src/providers/shell-parser.ts +76 -0
- package/src/types/mvdan-sh.d.ts +23 -0
- package/src/types.ts +109 -0
- package/src/version.ts +9 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Current schema version. Bump when schema changes require migration. */
|
|
7
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
const DEFAULT_DB_DIR = join(homedir(), ".local", "share", "tyr");
|
|
10
|
+
const DEFAULT_DB_FILE = join(DEFAULT_DB_DIR, "tyr.db");
|
|
11
|
+
|
|
12
|
+
export function getDbPath(): string {
|
|
13
|
+
return process.env.TYR_DB_PATH ?? DEFAULT_DB_FILE;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let instance: Database | null = null;
|
|
17
|
+
|
|
18
|
+
const SCHEMA_STATEMENTS = [
|
|
19
|
+
`CREATE TABLE IF NOT EXISTS _meta (
|
|
20
|
+
key TEXT PRIMARY KEY,
|
|
21
|
+
value TEXT NOT NULL
|
|
22
|
+
)`,
|
|
23
|
+
`CREATE TABLE IF NOT EXISTS logs (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
timestamp INTEGER NOT NULL,
|
|
26
|
+
session_id TEXT NOT NULL,
|
|
27
|
+
cwd TEXT NOT NULL,
|
|
28
|
+
tool_name TEXT NOT NULL,
|
|
29
|
+
tool_input TEXT NOT NULL,
|
|
30
|
+
input TEXT NOT NULL,
|
|
31
|
+
decision TEXT NOT NULL CHECK (decision IN ('allow','deny','abstain','error')),
|
|
32
|
+
provider TEXT,
|
|
33
|
+
reason TEXT,
|
|
34
|
+
duration_ms INTEGER NOT NULL,
|
|
35
|
+
cached INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
mode TEXT CHECK (mode IN ('shadow','audit') OR mode IS NULL)
|
|
37
|
+
)`,
|
|
38
|
+
"CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs (timestamp)",
|
|
39
|
+
"CREATE INDEX IF NOT EXISTS idx_logs_session ON logs (session_id)",
|
|
40
|
+
"CREATE INDEX IF NOT EXISTS idx_logs_suggest ON logs (decision, mode, tool_input)",
|
|
41
|
+
`CREATE TABLE IF NOT EXISTS llm_logs (
|
|
42
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
43
|
+
log_id INTEGER NOT NULL REFERENCES logs(id),
|
|
44
|
+
prompt TEXT NOT NULL,
|
|
45
|
+
model TEXT NOT NULL
|
|
46
|
+
)`,
|
|
47
|
+
"CREATE INDEX IF NOT EXISTS idx_llm_logs_log_id ON llm_logs (log_id)",
|
|
48
|
+
`CREATE TABLE IF NOT EXISTS cache (
|
|
49
|
+
tool_name TEXT NOT NULL,
|
|
50
|
+
tool_input TEXT NOT NULL,
|
|
51
|
+
cwd TEXT NOT NULL,
|
|
52
|
+
decision TEXT NOT NULL CHECK (decision IN ('allow','deny')),
|
|
53
|
+
provider TEXT NOT NULL,
|
|
54
|
+
reason TEXT,
|
|
55
|
+
config_hash TEXT NOT NULL,
|
|
56
|
+
created_at INTEGER NOT NULL,
|
|
57
|
+
PRIMARY KEY (tool_name, tool_input, cwd, config_hash)
|
|
58
|
+
)`,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export function getSchemaVersion(db: Database): number | null {
|
|
62
|
+
const row = db
|
|
63
|
+
.query("SELECT value FROM _meta WHERE key = 'schema_version'")
|
|
64
|
+
.get() as { value: string } | null;
|
|
65
|
+
return row ? Number(row.value) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkVersion(version: number): void {
|
|
69
|
+
if (version < CURRENT_SCHEMA_VERSION) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`[tyr] database schema is v${version} but tyr requires v${CURRENT_SCHEMA_VERSION}. Run 'tyr db migrate' to upgrade.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (version > CURRENT_SCHEMA_VERSION) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[tyr] database schema is v${version} but this tyr only supports up to v${CURRENT_SCHEMA_VERSION}. Upgrade tyr.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Sequential migration functions. Each entry migrates from version N to N+1.
|
|
83
|
+
* migrations[0] migrates v1 → v2, migrations[1] migrates v2 → v3, etc.
|
|
84
|
+
*
|
|
85
|
+
* Rules for writing migrations:
|
|
86
|
+
* - cache table is ephemeral: DROP + CREATE is fine
|
|
87
|
+
* - logs/llm_logs are historical: only ADD COLUMN, never drop data
|
|
88
|
+
* - For complex changes, use rename-copy-drop pattern
|
|
89
|
+
*/
|
|
90
|
+
export const migrations: ReadonlyArray<(db: Database) => void> = [
|
|
91
|
+
// Add new migration functions here when CURRENT_SCHEMA_VERSION is bumped.
|
|
92
|
+
// Example: (db) => { db.run("ALTER TABLE logs ADD COLUMN new_col TEXT"); }
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
export interface MigrationResult {
|
|
96
|
+
fromVersion: number;
|
|
97
|
+
toVersion: number;
|
|
98
|
+
applied: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Run pending migrations from current version up to CURRENT_SCHEMA_VERSION. */
|
|
102
|
+
export function runMigrations(db: Database): MigrationResult {
|
|
103
|
+
if (!hasMetaTable(db)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
"[tyr] database has no _meta table. Cannot migrate an uninitialized database.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const version = getSchemaVersion(db);
|
|
110
|
+
if (version === null) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"[tyr] database is missing schema_version. Cannot determine migration starting point.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (version > CURRENT_SCHEMA_VERSION) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`[tyr] database schema is v${version} but this tyr only supports up to v${CURRENT_SCHEMA_VERSION}. Upgrade tyr.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (version === CURRENT_SCHEMA_VERSION) {
|
|
123
|
+
return { fromVersion: version, toVersion: version, applied: 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fromVersion = version;
|
|
127
|
+
db.transaction(() => {
|
|
128
|
+
for (let v = version; v < CURRENT_SCHEMA_VERSION; v++) {
|
|
129
|
+
const migrate = migrations[v - 1];
|
|
130
|
+
if (!migrate) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`[tyr] missing migration function for v${v} → v${v + 1}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
migrate(db);
|
|
136
|
+
}
|
|
137
|
+
db.query("UPDATE _meta SET value = ? WHERE key = 'schema_version'").run(
|
|
138
|
+
String(CURRENT_SCHEMA_VERSION),
|
|
139
|
+
);
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
fromVersion,
|
|
144
|
+
toVersion: CURRENT_SCHEMA_VERSION,
|
|
145
|
+
applied: CURRENT_SCHEMA_VERSION - fromVersion,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function hasMetaTable(db: Database): boolean {
|
|
150
|
+
const row = db
|
|
151
|
+
.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='_meta'")
|
|
152
|
+
.get();
|
|
153
|
+
return row !== null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setPragmas(db: Database): void {
|
|
157
|
+
const walResult = db.query("PRAGMA journal_mode = WAL").get() as {
|
|
158
|
+
journal_mode: string;
|
|
159
|
+
};
|
|
160
|
+
if (walResult.journal_mode !== "wal") {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`[tyr] failed to enable WAL mode (got ${walResult.journal_mode})`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
db.run("PRAGMA busy_timeout = 5000");
|
|
166
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Open a raw Database connection with PRAGMAs set, bypassing version checks. */
|
|
170
|
+
export function openRawDb(dbPath?: string): Database {
|
|
171
|
+
const p = dbPath ?? getDbPath();
|
|
172
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
173
|
+
const db = new Database(p);
|
|
174
|
+
setPragmas(db);
|
|
175
|
+
return db;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function initDb(db: Database): void {
|
|
179
|
+
setPragmas(db);
|
|
180
|
+
|
|
181
|
+
// If _meta exists, this is an existing DB — check version before touching anything
|
|
182
|
+
if (hasMetaTable(db)) {
|
|
183
|
+
const version = getSchemaVersion(db);
|
|
184
|
+
if (version === null) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
"[tyr] database is missing schema_version. Delete the DB or run 'tyr db migrate'.",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
checkVersion(version);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// First-time initialization — create all tables in a transaction
|
|
194
|
+
db.transaction(() => {
|
|
195
|
+
for (const stmt of SCHEMA_STATEMENTS) {
|
|
196
|
+
db.run(stmt);
|
|
197
|
+
}
|
|
198
|
+
db.query("INSERT INTO _meta (key, value) VALUES ('schema_version', ?)").run(
|
|
199
|
+
String(CURRENT_SCHEMA_VERSION),
|
|
200
|
+
);
|
|
201
|
+
})();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Get (or create) the singleton SQLite database connection. */
|
|
205
|
+
export function getDb(): Database {
|
|
206
|
+
if (instance) return instance;
|
|
207
|
+
|
|
208
|
+
const dbPath = getDbPath();
|
|
209
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
210
|
+
|
|
211
|
+
const db = new Database(dbPath);
|
|
212
|
+
initDb(db);
|
|
213
|
+
|
|
214
|
+
instance = db;
|
|
215
|
+
return db;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Close the singleton connection. Safe to call multiple times. */
|
|
219
|
+
export function closeDb(): void {
|
|
220
|
+
if (instance) {
|
|
221
|
+
instance.close();
|
|
222
|
+
instance = null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Reset the singleton (for tests). Closes the connection first. */
|
|
227
|
+
export function resetDbInstance(): void {
|
|
228
|
+
closeDb();
|
|
229
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { defineCommand, runMain } from "citty";
|
|
4
|
+
import config from "./commands/config.ts";
|
|
5
|
+
import db from "./commands/db.ts";
|
|
6
|
+
import debug from "./commands/debug.ts";
|
|
7
|
+
import install from "./commands/install.ts";
|
|
8
|
+
import judge from "./commands/judge.ts";
|
|
9
|
+
import log from "./commands/log.ts";
|
|
10
|
+
import stats from "./commands/stats.ts";
|
|
11
|
+
import suggest from "./commands/suggest.ts";
|
|
12
|
+
import uninstall from "./commands/uninstall.ts";
|
|
13
|
+
import version from "./commands/version.ts";
|
|
14
|
+
import { VERSION } from "./version.ts";
|
|
15
|
+
|
|
16
|
+
const main = defineCommand({
|
|
17
|
+
meta: {
|
|
18
|
+
name: "tyr",
|
|
19
|
+
version: VERSION,
|
|
20
|
+
description: "Intelligent permission management for Claude Code hooks",
|
|
21
|
+
},
|
|
22
|
+
subCommands: {
|
|
23
|
+
config,
|
|
24
|
+
db,
|
|
25
|
+
debug,
|
|
26
|
+
install,
|
|
27
|
+
judge,
|
|
28
|
+
log,
|
|
29
|
+
stats,
|
|
30
|
+
suggest,
|
|
31
|
+
uninstall,
|
|
32
|
+
version,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
runMain(main);
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type JudgeMode = "shadow" | "audit" | undefined;
|
|
6
|
+
|
|
7
|
+
function tyrCommand(mode: JudgeMode): string {
|
|
8
|
+
if (mode) return `tyr judge --${mode}`;
|
|
9
|
+
return "tyr judge";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tyrHookEntry(mode: JudgeMode) {
|
|
13
|
+
return {
|
|
14
|
+
matcher: "Bash",
|
|
15
|
+
hooks: [{ type: "command", command: tyrCommand(mode) }],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getSettingsPath(scope: "global" | "project"): string {
|
|
20
|
+
if (scope === "global") {
|
|
21
|
+
return join(homedir(), ".claude", "settings.json");
|
|
22
|
+
}
|
|
23
|
+
return join(process.cwd(), ".claude", "settings.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read and parse a settings.json, returning {} if it doesn't exist. */
|
|
27
|
+
export async function readSettings(
|
|
28
|
+
path: string,
|
|
29
|
+
): Promise<Record<string, unknown>> {
|
|
30
|
+
try {
|
|
31
|
+
const text = await readFile(path, "utf-8");
|
|
32
|
+
return JSON.parse(text) as Record<string, unknown>;
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Check if tyr is already installed in the given settings. */
|
|
39
|
+
export function isInstalled(settings: Record<string, unknown>): boolean {
|
|
40
|
+
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
|
41
|
+
if (!hooks) return false;
|
|
42
|
+
|
|
43
|
+
const permReqs = hooks.PermissionRequest;
|
|
44
|
+
if (!Array.isArray(permReqs)) return false;
|
|
45
|
+
|
|
46
|
+
return permReqs.some((entry: Record<string, unknown>) => isTyrEntry(entry));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Merge the tyr hook into settings without clobbering existing hooks.
|
|
50
|
+
* If a tyr entry already exists it is replaced so install is idempotent. */
|
|
51
|
+
export function mergeHook(
|
|
52
|
+
settings: Record<string, unknown>,
|
|
53
|
+
mode?: JudgeMode,
|
|
54
|
+
): Record<string, unknown> {
|
|
55
|
+
const result = { ...settings };
|
|
56
|
+
const hooks = (result.hooks ?? {}) as Record<string, unknown>;
|
|
57
|
+
const permReqs = Array.isArray(hooks.PermissionRequest)
|
|
58
|
+
? (hooks.PermissionRequest as Record<string, unknown>[]).filter(
|
|
59
|
+
(entry) => !isTyrEntry(entry),
|
|
60
|
+
)
|
|
61
|
+
: [];
|
|
62
|
+
|
|
63
|
+
permReqs.push(tyrHookEntry(mode));
|
|
64
|
+
|
|
65
|
+
result.hooks = { ...hooks, PermissionRequest: permReqs };
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Check if a PermissionRequest entry belongs to tyr. */
|
|
70
|
+
function isTyrEntry(entry: Record<string, unknown>): boolean {
|
|
71
|
+
const entryHooks = entry.hooks;
|
|
72
|
+
if (!Array.isArray(entryHooks)) return false;
|
|
73
|
+
return entryHooks.some(
|
|
74
|
+
(h: Record<string, unknown>) =>
|
|
75
|
+
h.type === "command" &&
|
|
76
|
+
typeof h.command === "string" &&
|
|
77
|
+
h.command.startsWith("tyr "),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Remove the tyr hook from settings, returning the cleaned settings.
|
|
82
|
+
* Returns null if tyr was not installed. */
|
|
83
|
+
export function removeHook(
|
|
84
|
+
settings: Record<string, unknown>,
|
|
85
|
+
): Record<string, unknown> | null {
|
|
86
|
+
if (!isInstalled(settings)) return null;
|
|
87
|
+
|
|
88
|
+
const result = { ...settings };
|
|
89
|
+
const hooks = { ...(result.hooks as Record<string, unknown>) };
|
|
90
|
+
const permReqs = hooks.PermissionRequest as Record<string, unknown>[];
|
|
91
|
+
const filtered = permReqs.filter((entry) => !isTyrEntry(entry));
|
|
92
|
+
|
|
93
|
+
if (filtered.length > 0) {
|
|
94
|
+
hooks.PermissionRequest = filtered;
|
|
95
|
+
} else {
|
|
96
|
+
delete hooks.PermissionRequest;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Clean up empty hooks object
|
|
100
|
+
if (Object.keys(hooks).length === 0) {
|
|
101
|
+
delete result.hooks;
|
|
102
|
+
} else {
|
|
103
|
+
result.hooks = hooks;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Write settings to disk, creating parent directories as needed. */
|
|
110
|
+
export async function writeSettings(
|
|
111
|
+
path: string,
|
|
112
|
+
settings: Record<string, unknown>,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
await mkdir(dirname(path), { recursive: true });
|
|
115
|
+
await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
|
|
116
|
+
}
|
package/src/judge.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type PermissionRequest, PermissionRequestSchema } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Parse and validate a PermissionRequest from unknown input. */
|
|
4
|
+
export function parsePermissionRequest(
|
|
5
|
+
data: unknown,
|
|
6
|
+
): PermissionRequest | null {
|
|
7
|
+
const result = PermissionRequestSchema.safeParse(data);
|
|
8
|
+
if (!result.success) return null;
|
|
9
|
+
return result.data;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Read all of stdin as a string. */
|
|
13
|
+
export async function readStdin(): Promise<string> {
|
|
14
|
+
const chunks: Uint8Array[] = [];
|
|
15
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
16
|
+
chunks.push(chunk);
|
|
17
|
+
}
|
|
18
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
19
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { getDb } from "./db.ts";
|
|
2
|
+
|
|
3
|
+
export interface LogEntry {
|
|
4
|
+
timestamp: number;
|
|
5
|
+
session_id: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
tool_name: string;
|
|
8
|
+
tool_input: string;
|
|
9
|
+
input: string;
|
|
10
|
+
decision: "allow" | "deny" | "abstain" | "error";
|
|
11
|
+
provider: string | null;
|
|
12
|
+
reason?: string | null;
|
|
13
|
+
duration_ms: number;
|
|
14
|
+
cached?: number;
|
|
15
|
+
mode?: "shadow" | "audit" | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LlmLogEntry {
|
|
19
|
+
prompt: string;
|
|
20
|
+
model: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Extract a human-readable tool_input string from the raw PermissionRequest tool_input. */
|
|
24
|
+
export function extractToolInput(
|
|
25
|
+
toolName: string,
|
|
26
|
+
toolInput: Record<string, unknown>,
|
|
27
|
+
): string {
|
|
28
|
+
if (toolName === "Bash" && typeof toolInput.command === "string") {
|
|
29
|
+
return toolInput.command;
|
|
30
|
+
}
|
|
31
|
+
return JSON.stringify(toolInput);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function appendLogEntry(entry: LogEntry, llm?: LlmLogEntry): void {
|
|
35
|
+
const db = getDb();
|
|
36
|
+
const result = db
|
|
37
|
+
.query(
|
|
38
|
+
`INSERT INTO logs (timestamp, session_id, cwd, tool_name, tool_input, input, decision, provider, reason, duration_ms, cached, mode)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
40
|
+
)
|
|
41
|
+
.run(
|
|
42
|
+
entry.timestamp,
|
|
43
|
+
entry.session_id,
|
|
44
|
+
entry.cwd,
|
|
45
|
+
entry.tool_name,
|
|
46
|
+
entry.tool_input,
|
|
47
|
+
entry.input,
|
|
48
|
+
entry.decision,
|
|
49
|
+
entry.provider,
|
|
50
|
+
entry.reason ?? null,
|
|
51
|
+
entry.duration_ms,
|
|
52
|
+
entry.cached ?? 0,
|
|
53
|
+
entry.mode ?? null,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (llm) {
|
|
57
|
+
const logId = Number(result.lastInsertRowid);
|
|
58
|
+
db.query(
|
|
59
|
+
"INSERT INTO llm_logs (log_id, prompt, model) VALUES (?, ?, ?)",
|
|
60
|
+
).run(logId, llm.prompt, llm.model);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LogRow {
|
|
65
|
+
id: number;
|
|
66
|
+
timestamp: number;
|
|
67
|
+
session_id: string;
|
|
68
|
+
cwd: string;
|
|
69
|
+
tool_name: string;
|
|
70
|
+
tool_input: string;
|
|
71
|
+
input: string;
|
|
72
|
+
decision: string;
|
|
73
|
+
provider: string | null;
|
|
74
|
+
reason: string | null;
|
|
75
|
+
duration_ms: number;
|
|
76
|
+
cached: number;
|
|
77
|
+
mode: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface LlmLogRow {
|
|
81
|
+
log_id: number;
|
|
82
|
+
prompt: string;
|
|
83
|
+
model: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Fetch LLM verbose logs for the given log entry IDs. */
|
|
87
|
+
export function readLlmLogs(logIds: number[]): Map<number, LlmLogRow> {
|
|
88
|
+
if (logIds.length === 0) return new Map();
|
|
89
|
+
const db = getDb();
|
|
90
|
+
const placeholders = logIds.map(() => "?").join(",");
|
|
91
|
+
const rows = db
|
|
92
|
+
.query(
|
|
93
|
+
`SELECT log_id, prompt, model FROM llm_logs WHERE log_id IN (${placeholders})`,
|
|
94
|
+
)
|
|
95
|
+
.all(...logIds) as LlmLogRow[];
|
|
96
|
+
const map = new Map<number, LlmLogRow>();
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
map.set(row.log_id, row);
|
|
99
|
+
}
|
|
100
|
+
return map;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ReadLogOptions {
|
|
104
|
+
last?: number;
|
|
105
|
+
since?: number;
|
|
106
|
+
until?: number;
|
|
107
|
+
decision?: string;
|
|
108
|
+
provider?: string;
|
|
109
|
+
cwd?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Delete all log entries and associated LLM logs. Returns the number of rows deleted. */
|
|
113
|
+
export function clearLogs(): number {
|
|
114
|
+
const db = getDb();
|
|
115
|
+
db.run("DELETE FROM llm_logs");
|
|
116
|
+
const result = db.query("DELETE FROM logs").run();
|
|
117
|
+
return result.changes;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Parse a retention duration string (e.g. "30d", "12h") into milliseconds.
|
|
121
|
+
* Returns null for "0" (disabled) or invalid values. */
|
|
122
|
+
export function parseRetention(value: string): number | null {
|
|
123
|
+
if (value === "0") return null;
|
|
124
|
+
const match = value.match(/^(\d+)([smhd])$/);
|
|
125
|
+
if (!match) return null;
|
|
126
|
+
const amount = Number(match[1]);
|
|
127
|
+
if (amount === 0) return null;
|
|
128
|
+
const unit = match[2];
|
|
129
|
+
const multipliers: Record<string, number> = {
|
|
130
|
+
s: 1000,
|
|
131
|
+
m: 60_000,
|
|
132
|
+
h: 3_600_000,
|
|
133
|
+
d: 86_400_000,
|
|
134
|
+
};
|
|
135
|
+
const ms = multipliers[unit as string];
|
|
136
|
+
if (ms === undefined) return null;
|
|
137
|
+
return amount * ms;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Delete log entries (and associated LLM logs) older than the given retention period.
|
|
141
|
+
* Returns the number of log rows deleted. No-op if retention is "0" (disabled). */
|
|
142
|
+
export function truncateOldLogs(retention: string): number {
|
|
143
|
+
const ms = parseRetention(retention);
|
|
144
|
+
if (ms === null) return 0;
|
|
145
|
+
const cutoff = Date.now() - ms;
|
|
146
|
+
const db = getDb();
|
|
147
|
+
db.run(
|
|
148
|
+
"DELETE FROM llm_logs WHERE log_id IN (SELECT id FROM logs WHERE timestamp < ?)",
|
|
149
|
+
[cutoff],
|
|
150
|
+
);
|
|
151
|
+
const result = db.query("DELETE FROM logs WHERE timestamp < ?").run(cutoff);
|
|
152
|
+
return result.changes;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function readLogEntries(opts: ReadLogOptions = {}): LogRow[] {
|
|
156
|
+
const db = getDb();
|
|
157
|
+
const conditions: string[] = [];
|
|
158
|
+
const params: (string | number)[] = [];
|
|
159
|
+
|
|
160
|
+
if (opts.since !== undefined) {
|
|
161
|
+
conditions.push("timestamp >= ?");
|
|
162
|
+
params.push(opts.since);
|
|
163
|
+
}
|
|
164
|
+
if (opts.until !== undefined) {
|
|
165
|
+
conditions.push("timestamp <= ?");
|
|
166
|
+
params.push(opts.until);
|
|
167
|
+
}
|
|
168
|
+
if (opts.decision !== undefined) {
|
|
169
|
+
conditions.push("decision = ?");
|
|
170
|
+
params.push(opts.decision);
|
|
171
|
+
}
|
|
172
|
+
if (opts.provider !== undefined) {
|
|
173
|
+
conditions.push("provider = ?");
|
|
174
|
+
params.push(opts.provider);
|
|
175
|
+
}
|
|
176
|
+
if (opts.cwd !== undefined) {
|
|
177
|
+
const escapedCwd = opts.cwd.replace(/[%_]/g, "\\$&");
|
|
178
|
+
conditions.push("(cwd = ? OR cwd LIKE ? || '/%' ESCAPE '\\')");
|
|
179
|
+
params.push(opts.cwd, escapedCwd);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const where =
|
|
183
|
+
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
184
|
+
const limit =
|
|
185
|
+
opts.last !== undefined && opts.last > 0 ? `LIMIT ${opts.last}` : "";
|
|
186
|
+
|
|
187
|
+
// Use a subquery to get the last N rows (ordered by id DESC), then re-order ASC
|
|
188
|
+
const sql = limit
|
|
189
|
+
? `SELECT * FROM (SELECT * FROM logs ${where} ORDER BY id DESC ${limit}) ORDER BY id ASC`
|
|
190
|
+
: `SELECT * FROM logs ${where} ORDER BY id ASC`;
|
|
191
|
+
|
|
192
|
+
return db.query(sql).all(...params) as LogRow[];
|
|
193
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PermissionRequest, PermissionResult, Provider } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Result from running the provider pipeline. */
|
|
4
|
+
export interface PipelineResult {
|
|
5
|
+
decision: PermissionResult;
|
|
6
|
+
provider: string | null;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Run providers in order until one returns a definitive result.
|
|
11
|
+
* First `allow` or `deny` wins. If all abstain, returns `abstain`. */
|
|
12
|
+
export async function runPipeline(
|
|
13
|
+
providers: Provider[],
|
|
14
|
+
req: PermissionRequest,
|
|
15
|
+
): Promise<PipelineResult> {
|
|
16
|
+
for (const provider of providers) {
|
|
17
|
+
try {
|
|
18
|
+
const result = await provider.checkPermission(req);
|
|
19
|
+
if (result.decision === "allow" || result.decision === "deny") {
|
|
20
|
+
return {
|
|
21
|
+
decision: result.decision,
|
|
22
|
+
provider: provider.name,
|
|
23
|
+
reason: result.reason,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// Provider errors are treated as abstain — fail-through
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { decision: "abstain", provider: null };
|
|
32
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ClaudeAgent } from "./agents/claude.ts";
|
|
2
|
+
import type { PermissionRequest } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Expected shape of the LLM's JSON response. */
|
|
5
|
+
export interface LlmDecision {
|
|
6
|
+
decision: "allow" | "deny";
|
|
7
|
+
reason: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Build the prompt that asks the LLM to evaluate a permission request. */
|
|
11
|
+
export function buildPrompt(
|
|
12
|
+
req: PermissionRequest,
|
|
13
|
+
agent: ClaudeAgent,
|
|
14
|
+
canDeny: boolean,
|
|
15
|
+
): string {
|
|
16
|
+
const info = agent.getDebugInfo();
|
|
17
|
+
const command =
|
|
18
|
+
typeof req.tool_input.command === "string" ? req.tool_input.command : "";
|
|
19
|
+
|
|
20
|
+
const rules = canDeny
|
|
21
|
+
? `- If the command is a variation of one of the ALLOWED patterns → allow.
|
|
22
|
+
- If the command is a variation of one of the DENIED patterns → deny.
|
|
23
|
+
- 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.
|
|
26
|
+
- If the command is NOT clearly similar to an allowed pattern → abstain.
|
|
27
|
+
- Only allow commands that are clearly within the spirit of an existing allowed pattern.
|
|
28
|
+
- You CANNOT deny commands. Your only options are allow or abstain.`;
|
|
29
|
+
|
|
30
|
+
const responseFormat = canDeny
|
|
31
|
+
? `{"decision": "allow", "reason": "brief explanation"}
|
|
32
|
+
or
|
|
33
|
+
{"decision": "deny", "reason": "brief explanation"}`
|
|
34
|
+
: `{"decision": "allow", "reason": "brief explanation"}
|
|
35
|
+
or
|
|
36
|
+
{"decision": "abstain", "reason": "brief explanation"}`;
|
|
37
|
+
|
|
38
|
+
return `You are a pattern-matching permission checker.
|
|
39
|
+
|
|
40
|
+
A coding assistant is requesting permission to run a shell command. Your job is to decide whether this command is similar to an already-allowed pattern.
|
|
41
|
+
|
|
42
|
+
## Context
|
|
43
|
+
- Working directory: ${req.cwd}
|
|
44
|
+
- Tool: ${req.tool_name}
|
|
45
|
+
- Command: ${command}
|
|
46
|
+
|
|
47
|
+
## Configured permission patterns
|
|
48
|
+
- Allowed patterns: ${JSON.stringify(info.allow)}
|
|
49
|
+
- Denied patterns: ${JSON.stringify(info.deny)}
|
|
50
|
+
|
|
51
|
+
The command did not exactly match any pattern, so you must judge by similarity.
|
|
52
|
+
|
|
53
|
+
## Rules
|
|
54
|
+
${rules}
|
|
55
|
+
|
|
56
|
+
Respond with ONLY a JSON object in this exact format, no other text:
|
|
57
|
+
${responseFormat}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse the LLM's stdout into a decision. Returns null on invalid output. */
|
|
61
|
+
export function parseLlmResponse(stdout: string): LlmDecision | null {
|
|
62
|
+
const trimmed = stdout.trim();
|
|
63
|
+
if (!trimmed) return null;
|
|
64
|
+
|
|
65
|
+
// The LLM might wrap JSON in markdown code fences
|
|
66
|
+
const jsonStr = trimmed
|
|
67
|
+
.replace(/^```(?:json)?\s*/i, "")
|
|
68
|
+
.replace(/\s*```$/i, "")
|
|
69
|
+
.trim();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
|
|
73
|
+
if (
|
|
74
|
+
(parsed.decision === "allow" || parsed.decision === "deny") &&
|
|
75
|
+
typeof parsed.reason === "string"
|
|
76
|
+
) {
|
|
77
|
+
return { decision: parsed.decision, reason: parsed.reason };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|