klyro 0.1.1
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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/agent/anthropic-adapter.d.ts +75 -0
- package/dist/agent/anthropic-adapter.js +297 -0
- package/dist/agent/message.d.ts +40 -0
- package/dist/agent/message.js +21 -0
- package/dist/agent/observation.d.ts +47 -0
- package/dist/agent/observation.js +53 -0
- package/dist/agent/provider-adapter.d.ts +101 -0
- package/dist/agent/provider-adapter.js +254 -0
- package/dist/agent/registry.d.ts +42 -0
- package/dist/agent/registry.js +86 -0
- package/dist/agent/retry.d.ts +34 -0
- package/dist/agent/retry.js +91 -0
- package/dist/agent/runtime.d.ts +130 -0
- package/dist/agent/runtime.js +221 -0
- package/dist/agent/worker-spawner.d.ts +41 -0
- package/dist/agent/worker-spawner.js +76 -0
- package/dist/chat.d.ts +39 -0
- package/dist/chat.js +235 -0
- package/dist/chat.js.map +1 -0
- package/dist/cli/eval.d.ts +75 -0
- package/dist/cli/eval.js +190 -0
- package/dist/cli/repl.d.ts +15 -0
- package/dist/cli/repl.js +194 -0
- package/dist/cli/run.d.ts +67 -0
- package/dist/cli/run.js +198 -0
- package/dist/cli/slash/parser.d.ts +39 -0
- package/dist/cli/slash/parser.js +47 -0
- package/dist/context/level6.d.ts +45 -0
- package/dist/context/level6.js +231 -0
- package/dist/context/level7.d.ts +84 -0
- package/dist/context/level7.js +156 -0
- package/dist/context/project-map.d.ts +48 -0
- package/dist/context/project-map.js +438 -0
- package/dist/context/repo-map.d.ts +30 -0
- package/dist/context/repo-map.js +168 -0
- package/dist/context/selector.d.ts +22 -0
- package/dist/context/selector.js +37 -0
- package/dist/context/snippets.d.ts +17 -0
- package/dist/context/snippets.js +15 -0
- package/dist/context/tokenizer.d.ts +49 -0
- package/dist/context/tokenizer.js +97 -0
- package/dist/eval/harness.d.ts +48 -0
- package/dist/eval/harness.js +149 -0
- package/dist/eval/tasks.d.ts +6 -0
- package/dist/eval/tasks.js +98 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence/audit.d.ts +75 -0
- package/dist/persistence/audit.js +15 -0
- package/dist/persistence/store.d.ts +67 -0
- package/dist/persistence/store.js +106 -0
- package/dist/policy/approval.d.ts +35 -0
- package/dist/policy/approval.js +54 -0
- package/dist/policy/engine.d.ts +76 -0
- package/dist/policy/engine.js +163 -0
- package/dist/policy/path-guard.d.ts +30 -0
- package/dist/policy/path-guard.js +100 -0
- package/dist/policy/secret-redactor.d.ts +19 -0
- package/dist/policy/secret-redactor.js +52 -0
- package/dist/providers.d.ts +23 -0
- package/dist/providers.js +83 -0
- package/dist/repl.d.ts +9 -0
- package/dist/repl.js +170 -0
- package/dist/repl.js.map +1 -0
- package/dist/tools/fs/edit-file.d.ts +48 -0
- package/dist/tools/fs/edit-file.js +84 -0
- package/dist/tools/fs/list-dir.d.ts +40 -0
- package/dist/tools/fs/list-dir.js +72 -0
- package/dist/tools/fs/read-file.d.ts +66 -0
- package/dist/tools/fs/read-file.js +75 -0
- package/dist/tools/fs/write-file.d.ts +24 -0
- package/dist/tools/fs/write-file.js +48 -0
- package/dist/tools/git/git-diff.d.ts +35 -0
- package/dist/tools/git/git-diff.js +68 -0
- package/dist/tools/git/git-status.d.ts +29 -0
- package/dist/tools/git/git-status.js +57 -0
- package/dist/tools/normalize.d.ts +31 -0
- package/dist/tools/normalize.js +88 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.js +87 -0
- package/dist/tools/schema.d.ts +29 -0
- package/dist/tools/schema.js +183 -0
- package/dist/tools/search/dependencies.d.ts +44 -0
- package/dist/tools/search/dependencies.js +188 -0
- package/dist/tools/search/glob.d.ts +26 -0
- package/dist/tools/search/glob.js +88 -0
- package/dist/tools/search/grep.d.ts +52 -0
- package/dist/tools/search/grep.js +125 -0
- package/dist/tools/search/recent-files.d.ts +33 -0
- package/dist/tools/search/recent-files.js +100 -0
- package/dist/tools/search/search-files.d.ts +39 -0
- package/dist/tools/search/search-files.js +110 -0
- package/dist/tools/shell/shell-exec.d.ts +60 -0
- package/dist/tools/shell/shell-exec.js +133 -0
- package/dist/tools/types.d.ts +54 -0
- package/dist/tools/types.js +15 -0
- package/dist/tools/verify/run-verify.d.ts +60 -0
- package/dist/tools/verify/run-verify.js +115 -0
- package/dist/tui/app.d.ts +27 -0
- package/dist/tui/app.js +79 -0
- package/dist/tui/app.test.d.ts +1 -0
- package/dist/tui/app.test.js +77 -0
- package/dist/tui/status.d.ts +21 -0
- package/dist/tui/status.js +13 -0
- package/dist/tui/status.test.d.ts +1 -0
- package/dist/tui/status.test.js +39 -0
- package/dist/tui/transcript.d.ts +40 -0
- package/dist/tui/transcript.js +28 -0
- package/dist/tui/transcript.test.d.ts +1 -0
- package/dist/tui/transcript.test.js +71 -0
- package/dist/verification/detect.d.ts +29 -0
- package/dist/verification/detect.js +85 -0
- package/dist/verification/engine.d.ts +25 -0
- package/dist/verification/engine.js +57 -0
- package/package.json +58 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit log — append-only JSONL of runtime events. Same shape as the
|
|
3
|
+
* future durable-task scheduler will read, so the data layout is
|
|
4
|
+
* forward-compatible.
|
|
5
|
+
*/
|
|
6
|
+
export type AuditEvent = {
|
|
7
|
+
kind: 'session_created';
|
|
8
|
+
sessionId: string;
|
|
9
|
+
task: string;
|
|
10
|
+
cwd: string;
|
|
11
|
+
ts: number;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'session_resumed';
|
|
14
|
+
sessionId: string;
|
|
15
|
+
ts: number;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'session_completed';
|
|
18
|
+
sessionId: string;
|
|
19
|
+
status: string;
|
|
20
|
+
ts: number;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'step_started';
|
|
23
|
+
sessionId: string;
|
|
24
|
+
step: number;
|
|
25
|
+
ts: number;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'step_completed';
|
|
28
|
+
sessionId: string;
|
|
29
|
+
step: number;
|
|
30
|
+
ts: number;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'tool_call_started';
|
|
33
|
+
sessionId: string;
|
|
34
|
+
callId: string;
|
|
35
|
+
name: string;
|
|
36
|
+
ts: number;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'tool_call_completed';
|
|
39
|
+
sessionId: string;
|
|
40
|
+
callId: string;
|
|
41
|
+
isError: boolean;
|
|
42
|
+
latencyMs: number;
|
|
43
|
+
ts: number;
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'policy_decision';
|
|
46
|
+
sessionId: string;
|
|
47
|
+
callId: string;
|
|
48
|
+
action: string;
|
|
49
|
+
ts: number;
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'verification_attempted';
|
|
52
|
+
sessionId: string;
|
|
53
|
+
command: string;
|
|
54
|
+
ts: number;
|
|
55
|
+
} | {
|
|
56
|
+
kind: 'verification_succeeded';
|
|
57
|
+
sessionId: string;
|
|
58
|
+
ts: number;
|
|
59
|
+
} | {
|
|
60
|
+
kind: 'verification_failed';
|
|
61
|
+
sessionId: string;
|
|
62
|
+
exitCode: number;
|
|
63
|
+
type: string;
|
|
64
|
+
ts: number;
|
|
65
|
+
} | {
|
|
66
|
+
kind: 'repair_attempted';
|
|
67
|
+
sessionId: string;
|
|
68
|
+
attempt: number;
|
|
69
|
+
ts: number;
|
|
70
|
+
};
|
|
71
|
+
export declare class AuditLog {
|
|
72
|
+
private readonly filePath;
|
|
73
|
+
constructor(filePath: string);
|
|
74
|
+
write(event: AuditEvent): Promise<void>;
|
|
75
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit log — append-only JSONL of runtime events. Same shape as the
|
|
3
|
+
* future durable-task scheduler will read, so the data layout is
|
|
4
|
+
* forward-compatible.
|
|
5
|
+
*/
|
|
6
|
+
import { SessionStore } from './store.js';
|
|
7
|
+
export class AuditLog {
|
|
8
|
+
filePath;
|
|
9
|
+
constructor(filePath) {
|
|
10
|
+
this.filePath = filePath;
|
|
11
|
+
}
|
|
12
|
+
async write(event) {
|
|
13
|
+
await SessionStore.appendJsonl(this.filePath, event);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session store — JSON-on-disk persistence for sessions, transcripts,
|
|
3
|
+
* and observations. Single file per session, indexed by a `sessions.json`
|
|
4
|
+
* directory index. No native deps.
|
|
5
|
+
*
|
|
6
|
+
* MVP rationale: better-sqlite3 needs a native build step that fails on
|
|
7
|
+
* some Windows installs. A JSON file per session + a flat index is
|
|
8
|
+
* trivial to back up, easy to inspect, and good enough for the MVP's
|
|
9
|
+
* ~50-task eval suite. v1.0 can swap in SQLite behind the same
|
|
10
|
+
* SessionStore interface.
|
|
11
|
+
*/
|
|
12
|
+
export type SessionStatus = 'open' | 'complete' | 'verify_failed' | 'aborted' | 'max_steps';
|
|
13
|
+
export interface SessionConfig {
|
|
14
|
+
model: string;
|
|
15
|
+
maxSteps: number;
|
|
16
|
+
policyAllow?: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface SessionRecord {
|
|
19
|
+
id: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
task: string;
|
|
22
|
+
status: SessionStatus;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
config: SessionConfig;
|
|
26
|
+
finalText?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface StoredMessage {
|
|
29
|
+
role: 'user' | 'assistant' | 'tool' | 'system';
|
|
30
|
+
content: unknown;
|
|
31
|
+
ts: number;
|
|
32
|
+
}
|
|
33
|
+
export interface StoredObservation {
|
|
34
|
+
toolCallId: string;
|
|
35
|
+
toolName: string;
|
|
36
|
+
input: unknown;
|
|
37
|
+
output: unknown;
|
|
38
|
+
isError: boolean;
|
|
39
|
+
startedAt: number;
|
|
40
|
+
finishedAt: number;
|
|
41
|
+
}
|
|
42
|
+
export declare class SessionStore {
|
|
43
|
+
private readonly dir;
|
|
44
|
+
private readonly indexPath;
|
|
45
|
+
constructor(dir: string);
|
|
46
|
+
private ensureDir;
|
|
47
|
+
private readIndex;
|
|
48
|
+
private writeIndex;
|
|
49
|
+
create(opts: {
|
|
50
|
+
cwd: string;
|
|
51
|
+
task: string;
|
|
52
|
+
config: SessionConfig;
|
|
53
|
+
}): Promise<SessionRecord>;
|
|
54
|
+
private readSession;
|
|
55
|
+
private writeSession;
|
|
56
|
+
appendMessage(id: string, message: StoredMessage): Promise<void>;
|
|
57
|
+
appendObservation(id: string, obs: StoredObservation): Promise<void>;
|
|
58
|
+
setStatus(id: string, status: SessionStatus, finalText?: string): Promise<void>;
|
|
59
|
+
loadMessages(id: string): Promise<StoredMessage[]>;
|
|
60
|
+
loadObservations(id: string): Promise<StoredObservation[]>;
|
|
61
|
+
list(filter?: {
|
|
62
|
+
status?: SessionStatus;
|
|
63
|
+
}): Promise<SessionRecord[]>;
|
|
64
|
+
get(id: string): Promise<SessionRecord | null>;
|
|
65
|
+
/** Atomic append — survives crashes; suitable for audit log. */
|
|
66
|
+
static appendJsonl(filePath: string, entry: unknown): Promise<void>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session store — JSON-on-disk persistence for sessions, transcripts,
|
|
3
|
+
* and observations. Single file per session, indexed by a `sessions.json`
|
|
4
|
+
* directory index. No native deps.
|
|
5
|
+
*
|
|
6
|
+
* MVP rationale: better-sqlite3 needs a native build step that fails on
|
|
7
|
+
* some Windows installs. A JSON file per session + a flat index is
|
|
8
|
+
* trivial to back up, easy to inspect, and good enough for the MVP's
|
|
9
|
+
* ~50-task eval suite. v1.0 can swap in SQLite behind the same
|
|
10
|
+
* SessionStore interface.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'node:fs/promises';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
export class SessionStore {
|
|
16
|
+
dir;
|
|
17
|
+
indexPath;
|
|
18
|
+
constructor(dir) {
|
|
19
|
+
this.dir = dir;
|
|
20
|
+
this.indexPath = path.join(dir, 'sessions.json');
|
|
21
|
+
}
|
|
22
|
+
async ensureDir() {
|
|
23
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
async readIndex() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await fs.readFile(this.indexPath, 'utf-8');
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async writeIndex(idx) {
|
|
35
|
+
await fs.writeFile(this.indexPath, JSON.stringify(idx, null, 2));
|
|
36
|
+
}
|
|
37
|
+
async create(opts) {
|
|
38
|
+
await this.ensureDir();
|
|
39
|
+
const id = randomUUID();
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const record = {
|
|
42
|
+
id,
|
|
43
|
+
cwd: opts.cwd,
|
|
44
|
+
task: opts.task,
|
|
45
|
+
status: 'open',
|
|
46
|
+
createdAt: now,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
config: opts.config,
|
|
49
|
+
};
|
|
50
|
+
await fs.writeFile(path.join(this.dir, `${id}.json`), JSON.stringify({ record, messages: [], observations: [] }, null, 2));
|
|
51
|
+
const idx = await this.readIndex();
|
|
52
|
+
idx[id] = record;
|
|
53
|
+
await this.writeIndex(idx);
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
async readSession(id) {
|
|
57
|
+
const raw = await fs.readFile(path.join(this.dir, `${id}.json`), 'utf-8');
|
|
58
|
+
return JSON.parse(raw);
|
|
59
|
+
}
|
|
60
|
+
async writeSession(id, data) {
|
|
61
|
+
data.record.updatedAt = Date.now();
|
|
62
|
+
await fs.writeFile(path.join(this.dir, `${id}.json`), JSON.stringify(data, null, 2));
|
|
63
|
+
const idx = await this.readIndex();
|
|
64
|
+
idx[id] = data.record;
|
|
65
|
+
await this.writeIndex(idx);
|
|
66
|
+
}
|
|
67
|
+
async appendMessage(id, message) {
|
|
68
|
+
const data = await this.readSession(id);
|
|
69
|
+
data.messages.push(message);
|
|
70
|
+
await this.writeSession(id, data);
|
|
71
|
+
}
|
|
72
|
+
async appendObservation(id, obs) {
|
|
73
|
+
const data = await this.readSession(id);
|
|
74
|
+
data.observations.push(obs);
|
|
75
|
+
await this.writeSession(id, data);
|
|
76
|
+
}
|
|
77
|
+
async setStatus(id, status, finalText) {
|
|
78
|
+
const data = await this.readSession(id);
|
|
79
|
+
data.record.status = status;
|
|
80
|
+
if (finalText !== undefined)
|
|
81
|
+
data.record.finalText = finalText;
|
|
82
|
+
await this.writeSession(id, data);
|
|
83
|
+
}
|
|
84
|
+
async loadMessages(id) {
|
|
85
|
+
const data = await this.readSession(id);
|
|
86
|
+
return data.messages;
|
|
87
|
+
}
|
|
88
|
+
async loadObservations(id) {
|
|
89
|
+
const data = await this.readSession(id);
|
|
90
|
+
return data.observations;
|
|
91
|
+
}
|
|
92
|
+
async list(filter) {
|
|
93
|
+
const idx = await this.readIndex();
|
|
94
|
+
const all = Object.values(idx);
|
|
95
|
+
return filter?.status ? all.filter((s) => s.status === filter.status) : all;
|
|
96
|
+
}
|
|
97
|
+
async get(id) {
|
|
98
|
+
const idx = await this.readIndex();
|
|
99
|
+
return idx[id] ?? null;
|
|
100
|
+
}
|
|
101
|
+
/** Atomic append — survives crashes; suitable for audit log. */
|
|
102
|
+
static async appendJsonl(filePath, entry) {
|
|
103
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
104
|
+
await fs.appendFile(filePath, JSON.stringify(entry) + '\n', 'utf-8');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive approval — prompt the user to allow/deny a tool call.
|
|
3
|
+
*
|
|
4
|
+
* TTY mode: shows the call, accepts y/n/a (yes/no/always-allow-this-command).
|
|
5
|
+
* Non-TTY: returns deny. Callers should treat non-TTY as the default
|
|
6
|
+
* (the user opted out of prompts with `--yes` or pipe input).
|
|
7
|
+
*/
|
|
8
|
+
export type ApprovalChoice = 'allow' | 'deny' | 'always';
|
|
9
|
+
export interface ApprovalRequest {
|
|
10
|
+
toolName: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
/** Best-effort summary of the call (command or path). */
|
|
13
|
+
summary: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ApprovalPrompt {
|
|
16
|
+
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
17
|
+
}
|
|
18
|
+
/** Default prompt backed by readline on stdin/stdout. */
|
|
19
|
+
export declare class StdinApprovalPrompt implements ApprovalPrompt {
|
|
20
|
+
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
21
|
+
}
|
|
22
|
+
/** Auto-deny — used in CI and `--no-approve` mode. */
|
|
23
|
+
export declare class DenyAllApprovalPrompt implements ApprovalPrompt {
|
|
24
|
+
ask(_req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* In-memory allowlist: tracks "always" choices by command prefix so a
|
|
28
|
+
* single prompt per command covers repeat invocations in the same session.
|
|
29
|
+
*/
|
|
30
|
+
export declare class InMemoryAllowlist implements ApprovalPrompt {
|
|
31
|
+
private readonly inner;
|
|
32
|
+
private readonly allow;
|
|
33
|
+
constructor(inner?: ApprovalPrompt);
|
|
34
|
+
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive approval — prompt the user to allow/deny a tool call.
|
|
3
|
+
*
|
|
4
|
+
* TTY mode: shows the call, accepts y/n/a (yes/no/always-allow-this-command).
|
|
5
|
+
* Non-TTY: returns deny. Callers should treat non-TTY as the default
|
|
6
|
+
* (the user opted out of prompts with `--yes` or pipe input).
|
|
7
|
+
*/
|
|
8
|
+
import * as readline from 'node:readline/promises';
|
|
9
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
10
|
+
/** Default prompt backed by readline on stdin/stdout. */
|
|
11
|
+
export class StdinApprovalPrompt {
|
|
12
|
+
async ask(req) {
|
|
13
|
+
if (!input.isTTY)
|
|
14
|
+
return 'deny';
|
|
15
|
+
const rl = readline.createInterface({ input, output });
|
|
16
|
+
try {
|
|
17
|
+
process.stdout.write(`\n[approval needed] ${req.toolName}: ${req.summary}\n reason: ${req.reason}\n allow? [y/n/a(llow)] `);
|
|
18
|
+
const ans = (await rl.question('')).trim().toLowerCase();
|
|
19
|
+
if (ans === 'y' || ans === 'yes')
|
|
20
|
+
return 'allow';
|
|
21
|
+
if (ans === 'a' || ans === 'allow')
|
|
22
|
+
return 'always';
|
|
23
|
+
return 'deny';
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
rl.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Auto-deny — used in CI and `--no-approve` mode. */
|
|
31
|
+
export class DenyAllApprovalPrompt {
|
|
32
|
+
async ask(_req) {
|
|
33
|
+
return 'deny';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* In-memory allowlist: tracks "always" choices by command prefix so a
|
|
38
|
+
* single prompt per command covers repeat invocations in the same session.
|
|
39
|
+
*/
|
|
40
|
+
export class InMemoryAllowlist {
|
|
41
|
+
inner;
|
|
42
|
+
allow = new Set();
|
|
43
|
+
constructor(inner = new DenyAllApprovalPrompt()) {
|
|
44
|
+
this.inner = inner;
|
|
45
|
+
}
|
|
46
|
+
async ask(req) {
|
|
47
|
+
if (this.allow.has(req.summary))
|
|
48
|
+
return 'allow';
|
|
49
|
+
const choice = await this.inner.ask(req);
|
|
50
|
+
if (choice === 'always')
|
|
51
|
+
this.allow.add(req.summary);
|
|
52
|
+
return choice;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy engine — gates every tool call.
|
|
3
|
+
*
|
|
4
|
+
* Returns a Decision (allow/ask/deny) per tool call based on a list of rules
|
|
5
|
+
* plus per-tool path/shell/secret checks. The runtime loop is the only
|
|
6
|
+
* caller; it then either executes the tool, prompts the user, or skips
|
|
7
|
+
* with a denial observation.
|
|
8
|
+
*/
|
|
9
|
+
export type Decision = {
|
|
10
|
+
action: 'allow';
|
|
11
|
+
} | {
|
|
12
|
+
action: 'ask';
|
|
13
|
+
reason: string;
|
|
14
|
+
} | {
|
|
15
|
+
action: 'deny';
|
|
16
|
+
reason: string;
|
|
17
|
+
};
|
|
18
|
+
export interface ToolCallLike {
|
|
19
|
+
/** Tool name, e.g. "write_file", "shell_exec". */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Parsed tool input. */
|
|
22
|
+
input: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
export interface PolicyContext {
|
|
25
|
+
cwd: string;
|
|
26
|
+
/** Optional transcript (for future rule extensions). */
|
|
27
|
+
transcript?: unknown;
|
|
28
|
+
/** True when running in a non-interactive environment (CI, pipe). */
|
|
29
|
+
nonInteractive: boolean;
|
|
30
|
+
/** Per-tool config. */
|
|
31
|
+
config: PolicyConfig;
|
|
32
|
+
}
|
|
33
|
+
export interface PolicyConfig {
|
|
34
|
+
/** Shell commands that are pre-approved (prefix match). */
|
|
35
|
+
shellAllow: string[];
|
|
36
|
+
/** Hard-deny shell commands (prefix or full match). */
|
|
37
|
+
shellDeny: string[];
|
|
38
|
+
/** File size limit in MiB for reads without confirmation. */
|
|
39
|
+
readSizeAskMiB: number;
|
|
40
|
+
}
|
|
41
|
+
export interface PolicyRule {
|
|
42
|
+
name: string;
|
|
43
|
+
/** Return a Decision to act on, or null to fall through to the next rule. */
|
|
44
|
+
evaluate(call: ToolCallLike, ctx: PolicyContext): Decision | null;
|
|
45
|
+
}
|
|
46
|
+
export declare const DEFAULT_POLICY_CONFIG: PolicyConfig;
|
|
47
|
+
/**
|
|
48
|
+
* Compose multiple rules. The first rule to return a Decision wins.
|
|
49
|
+
* If none return a Decision, the default is `allow`.
|
|
50
|
+
*/
|
|
51
|
+
export declare class PolicyEngine {
|
|
52
|
+
private readonly rules;
|
|
53
|
+
private readonly config;
|
|
54
|
+
constructor(rules: PolicyRule[], config?: PolicyConfig);
|
|
55
|
+
evaluate(call: ToolCallLike, base: {
|
|
56
|
+
cwd: string;
|
|
57
|
+
transcript?: unknown;
|
|
58
|
+
nonInteractive: boolean;
|
|
59
|
+
}): Promise<Decision>;
|
|
60
|
+
}
|
|
61
|
+
/** Builtin set of rules. Order matters: first match wins. */
|
|
62
|
+
export declare function builtinRules(): PolicyRule[];
|
|
63
|
+
/** Hard-deny for obviously destructive shell patterns. */
|
|
64
|
+
export declare const shellDenyRule: PolicyRule;
|
|
65
|
+
/** Allowlist for shell — exact or prefix. Anything not in the list asks. */
|
|
66
|
+
export declare const shellAllowRule: PolicyRule;
|
|
67
|
+
/** Writes are auto-allowed if path resolves inside cwd (path-guard throws otherwise). */
|
|
68
|
+
export declare const writeFileCwdRule: PolicyRule;
|
|
69
|
+
/** Reads > 5 MiB require confirmation (avoid filling the context window). */
|
|
70
|
+
export declare const readFileSizeRule: PolicyRule;
|
|
71
|
+
/** Convenience: evaluate via safe() so a buggy rule doesn't crash the loop. */
|
|
72
|
+
export declare function evaluatePolicy(engine: PolicyEngine, call: ToolCallLike, ctx: {
|
|
73
|
+
cwd: string;
|
|
74
|
+
transcript?: unknown;
|
|
75
|
+
nonInteractive: boolean;
|
|
76
|
+
}): Promise<Decision>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy engine — gates every tool call.
|
|
3
|
+
*
|
|
4
|
+
* Returns a Decision (allow/ask/deny) per tool call based on a list of rules
|
|
5
|
+
* plus per-tool path/shell/secret checks. The runtime loop is the only
|
|
6
|
+
* caller; it then either executes the tool, prompts the user, or skips
|
|
7
|
+
* with a denial observation.
|
|
8
|
+
*/
|
|
9
|
+
import { safe } from '../tools/normalize.js';
|
|
10
|
+
export const DEFAULT_POLICY_CONFIG = {
|
|
11
|
+
shellAllow: [
|
|
12
|
+
'git',
|
|
13
|
+
'ls',
|
|
14
|
+
'cat',
|
|
15
|
+
'head',
|
|
16
|
+
'tail',
|
|
17
|
+
'echo',
|
|
18
|
+
'pwd',
|
|
19
|
+
'npm test',
|
|
20
|
+
'npm run test',
|
|
21
|
+
'npx vitest',
|
|
22
|
+
'npx tsc',
|
|
23
|
+
'node -e',
|
|
24
|
+
],
|
|
25
|
+
shellDeny: [
|
|
26
|
+
'rm -rf /',
|
|
27
|
+
'rm -rf ~',
|
|
28
|
+
'rm -rf .',
|
|
29
|
+
'del /f /s /q C:\\',
|
|
30
|
+
'format ',
|
|
31
|
+
'mkfs',
|
|
32
|
+
'dd if=',
|
|
33
|
+
':(){:|:&};:',
|
|
34
|
+
'shutdown',
|
|
35
|
+
'reboot',
|
|
36
|
+
'bcdedit',
|
|
37
|
+
],
|
|
38
|
+
readSizeAskMiB: 5,
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Compose multiple rules. The first rule to return a Decision wins.
|
|
42
|
+
* If none return a Decision, the default is `allow`.
|
|
43
|
+
*/
|
|
44
|
+
export class PolicyEngine {
|
|
45
|
+
rules;
|
|
46
|
+
config;
|
|
47
|
+
constructor(rules, config = DEFAULT_POLICY_CONFIG) {
|
|
48
|
+
this.rules = rules;
|
|
49
|
+
this.config = config;
|
|
50
|
+
}
|
|
51
|
+
async evaluate(call, base) {
|
|
52
|
+
const ctx = {
|
|
53
|
+
cwd: base.cwd,
|
|
54
|
+
transcript: base.transcript,
|
|
55
|
+
nonInteractive: base.nonInteractive,
|
|
56
|
+
config: this.config,
|
|
57
|
+
};
|
|
58
|
+
for (const rule of this.rules) {
|
|
59
|
+
const d = rule.evaluate(call, ctx);
|
|
60
|
+
if (d)
|
|
61
|
+
return d;
|
|
62
|
+
}
|
|
63
|
+
return { action: 'allow' };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Builtin set of rules. Order matters: first match wins. */
|
|
67
|
+
export function builtinRules() {
|
|
68
|
+
return [
|
|
69
|
+
shellDenyRule,
|
|
70
|
+
shellAllowRule,
|
|
71
|
+
writeFileCwdRule,
|
|
72
|
+
readFileSizeRule,
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
function asString(v) {
|
|
76
|
+
return typeof v === 'string' ? v : '';
|
|
77
|
+
}
|
|
78
|
+
function startsWithAny(haystack, needles) {
|
|
79
|
+
const h = haystack.trim().toLowerCase();
|
|
80
|
+
return needles.some((n) => {
|
|
81
|
+
const nl = n.toLowerCase().replace(/\s+$/, ''); // strip trailing ws from needle
|
|
82
|
+
return h === nl || h.startsWith(nl + ' ') || h.startsWith(nl + '\t');
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Hard-deny for obviously destructive shell patterns. */
|
|
86
|
+
export const shellDenyRule = {
|
|
87
|
+
name: 'shell-deny',
|
|
88
|
+
evaluate(call, ctx) {
|
|
89
|
+
if (call.name !== 'shell_exec' && call.name !== 'run_verify')
|
|
90
|
+
return null;
|
|
91
|
+
const cmd = asString(call.input.command);
|
|
92
|
+
if (!cmd)
|
|
93
|
+
return null;
|
|
94
|
+
if (cmd.includes('..') && /rm|del/i.test(cmd) && /(\/|\b)([a-z]:)?[\\\/]\s*$|~|\/etc|\/usr|\/var|\/tmp/i.test(cmd)) {
|
|
95
|
+
return { action: 'deny', reason: 'destructive command targeting system path' };
|
|
96
|
+
}
|
|
97
|
+
if (startsWithAny(cmd, ctx.config.shellDeny)) {
|
|
98
|
+
return { action: 'deny', reason: `command matches deny list: ${cmd.slice(0, 60)}` };
|
|
99
|
+
}
|
|
100
|
+
// Pipe-to-shell: curl/wget/fetch piped into sh/bash/cmd.exe/PowerShell
|
|
101
|
+
if (/\b(curl|wget|fetch|iwr|Invoke-WebRequest)\b.*\|\s*(sh|bash|zsh|cmd|powershell|pwsh|node|node\s+-e)\b/i.test(cmd)) {
|
|
102
|
+
return { action: 'deny', reason: 'pipe-to-shell: remote download piped into an interpreter' };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
/** Allowlist for shell — exact or prefix. Anything not in the list asks. */
|
|
108
|
+
export const shellAllowRule = {
|
|
109
|
+
name: 'shell-allow',
|
|
110
|
+
evaluate(call, ctx) {
|
|
111
|
+
if (call.name !== 'shell_exec' && call.name !== 'run_verify')
|
|
112
|
+
return null;
|
|
113
|
+
const cmd = asString(call.input.command);
|
|
114
|
+
if (!cmd)
|
|
115
|
+
return null;
|
|
116
|
+
if (startsWithAny(cmd, ctx.config.shellAllow))
|
|
117
|
+
return { action: 'allow' };
|
|
118
|
+
if (ctx.nonInteractive)
|
|
119
|
+
return { action: 'deny', reason: `non-interactive: '${cmd.slice(0, 40)}' not in allowlist` };
|
|
120
|
+
return { action: 'ask', reason: `shell command not in allowlist: ${cmd.slice(0, 60)}` };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
/** Writes are auto-allowed if path resolves inside cwd (path-guard throws otherwise). */
|
|
124
|
+
export const writeFileCwdRule = {
|
|
125
|
+
name: 'write-file-cwd',
|
|
126
|
+
evaluate(call) {
|
|
127
|
+
if (call.name !== 'write_file' && call.name !== 'edit_file')
|
|
128
|
+
return null;
|
|
129
|
+
const p = asString(call.input.path);
|
|
130
|
+
if (!p)
|
|
131
|
+
return null;
|
|
132
|
+
if (p.includes('..') || path.isAbsolute(p)) {
|
|
133
|
+
return { action: 'deny', reason: 'path traversal or absolute path not allowed' };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
/** Reads > 5 MiB require confirmation (avoid filling the context window). */
|
|
139
|
+
export const readFileSizeRule = {
|
|
140
|
+
name: 'read-file-size',
|
|
141
|
+
evaluate(call, ctx) {
|
|
142
|
+
if (call.name !== 'read_file')
|
|
143
|
+
return null;
|
|
144
|
+
const size = typeof call.input.sizeBytes === 'number' ? call.input.sizeBytes : 0;
|
|
145
|
+
if (size > ctx.config.readSizeAskMiB * 1024 * 1024) {
|
|
146
|
+
if (ctx.nonInteractive)
|
|
147
|
+
return { action: 'deny', reason: `file > ${ctx.config.readSizeAskMiB} MiB` };
|
|
148
|
+
return { action: 'ask', reason: `file is ${(size / 1024 / 1024).toFixed(1)} MiB` };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
/** Convenience: evaluate via safe() so a buggy rule doesn't crash the loop. */
|
|
154
|
+
export async function evaluatePolicy(engine, call, ctx) {
|
|
155
|
+
const r = await safe(() => engine.evaluate(call, ctx));
|
|
156
|
+
if (r.ok)
|
|
157
|
+
return r.value;
|
|
158
|
+
return { action: 'deny', reason: `policy error: ${r.error.message}` };
|
|
159
|
+
}
|
|
160
|
+
// Late import to avoid a circular dep with policy -> tools via path-guard.
|
|
161
|
+
// (path-guard is already in policy/, and tools/ depend on it. This keeps
|
|
162
|
+
// policy/ depending only on tools/normalize, no other tool code.)
|
|
163
|
+
import * as path from 'node:path';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path safety: ensure every file path the agent touches stays inside `cwd`.
|
|
3
|
+
*
|
|
4
|
+
* Refuses:
|
|
5
|
+
* - absolute paths outside cwd
|
|
6
|
+
* - parent-directory traversal (`..`)
|
|
7
|
+
* - symlinks that resolve outside cwd
|
|
8
|
+
*
|
|
9
|
+
* Uses `path.resolve` then `path.relative` to check. On Windows, the
|
|
10
|
+
* comparison is case-insensitive.
|
|
11
|
+
*/
|
|
12
|
+
import { type ToolErrorCode } from '../tools/normalize.js';
|
|
13
|
+
export interface PathGuardResult {
|
|
14
|
+
/** Absolute, normalized path that IS inside cwd. */
|
|
15
|
+
resolved: string;
|
|
16
|
+
}
|
|
17
|
+
export declare class PathGuardError extends Error {
|
|
18
|
+
readonly code: ToolErrorCode;
|
|
19
|
+
constructor(code: ToolErrorCode, message: string);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a (possibly relative) path against cwd and assert it stays inside.
|
|
23
|
+
* Does NOT follow symlinks — call `resolveAndFollowSymlinks` for that.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveWithinCwd(cwd: string, requested: string): PathGuardResult;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve, follow symlinks, and assert the target stays inside cwd.
|
|
28
|
+
* Use this for read_file / write_file to defeat symlink-based escapes.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveAndFollowSymlinks(cwd: string, requested: string): Promise<PathGuardResult>;
|