klyro 0.1.53 → 0.1.55
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/dist/agent/anthropic-adapter.js +22 -2
- package/dist/agent/registry.js +11 -1
- package/dist/agent/retry.d.ts +5 -0
- package/dist/agent/retry.js +28 -1
- package/dist/agent/runtime.d.ts +2 -0
- package/dist/agent/runtime.js +48 -35
- package/dist/chat.js +2 -2
- package/dist/checkpoints/store.js +86 -7
- package/dist/cli/config.js +13 -1
- package/dist/cli/repl.js +200 -36
- package/dist/cli/run.js +88 -69
- package/dist/context/klyro-md.d.ts +5 -0
- package/dist/context/klyro-md.js +28 -6
- package/dist/context/tokenizer.js +8 -1
- package/dist/index.js +49 -14
- package/dist/persistence/session.d.ts +2 -0
- package/dist/persistence/session.js +11 -5
- package/dist/persistence/store.d.ts +7 -0
- package/dist/persistence/store.js +77 -28
- package/dist/policy/engine.d.ts +10 -1
- package/dist/policy/engine.js +33 -2
- package/dist/tools/fs/apply-patch.d.ts +16 -1
- package/dist/tools/fs/apply-patch.js +163 -75
- package/dist/tools/fs/edit-file.d.ts +13 -0
- package/dist/tools/fs/edit-file.js +96 -54
- package/dist/tools/fs/list-dir.d.ts +2 -0
- package/dist/tools/fs/list-dir.js +7 -5
- package/dist/tools/fs/multi-edit.js +12 -1
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/read-history.d.ts +2 -2
- package/dist/tools/fs/read-history.js +39 -7
- package/dist/tools/fs/write-file.js +1 -1
- package/dist/tools/normalize.js +11 -1
- package/dist/tools/shell/background.d.ts +5 -0
- package/dist/tools/shell/background.js +29 -0
- package/dist/tools/shell/shell-exec.d.ts +9 -0
- package/dist/tools/shell/shell-exec.js +54 -8
- package/dist/tools/verify/run-verify.js +2 -2
- package/dist/tui/app.js +10 -5
- package/dist/tui/app.test.js +42 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +20 -5
- package/dist/tui/diff.d.ts +5 -19
- package/dist/tui/diff.js +9 -25
- package/dist/tui/status.d.ts +4 -8
- package/dist/tui/status.js +8 -17
- package/dist/tui/transcript.d.ts +13 -20
- package/dist/tui/transcript.js +9 -74
- package/dist/verification/engine.js +4 -4
- package/package.json +1 -1
package/dist/context/klyro-md.js
CHANGED
|
@@ -1,35 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4.4 — KLYRO.md loader: ~/.klyro/KLYRO.md → root → KLYRO.local.md → subdir files lazily
|
|
3
|
+
*
|
|
4
|
+
* Safety: @import targets are contained to the project cwd (escapes are
|
|
5
|
+
* skipped, not read — a malicious KLYRO.md must not pull /etc/passwd or
|
|
6
|
+
* secret files into context), each file is capped, and total output is
|
|
7
|
+
* capped so a giant monorepo doc can't blow the context budget.
|
|
3
8
|
*/
|
|
4
9
|
import * as fs from 'node:fs/promises';
|
|
5
10
|
import * as path from 'node:path';
|
|
6
11
|
import * as os from 'node:os';
|
|
7
|
-
const
|
|
12
|
+
const MAX_FILE_CHARS = 4000;
|
|
13
|
+
const MAX_TOTAL_CHARS = 8000;
|
|
14
|
+
function cap(s, n) {
|
|
15
|
+
return s.length > n ? s.slice(0, n) + `\n... [truncated ${s.length - n} chars]` : s;
|
|
16
|
+
}
|
|
8
17
|
export async function loadKlyroMd(cwd) {
|
|
9
18
|
const parts = [];
|
|
19
|
+
let total = 0;
|
|
20
|
+
const push = (s) => {
|
|
21
|
+
if (total >= MAX_TOTAL_CHARS)
|
|
22
|
+
return;
|
|
23
|
+
const room = MAX_TOTAL_CHARS - total;
|
|
24
|
+
const chunk = s.length > room ? s.slice(0, room) + '\n... [truncated]' : s;
|
|
25
|
+
parts.push(chunk);
|
|
26
|
+
total += chunk.length;
|
|
27
|
+
};
|
|
10
28
|
// Global
|
|
11
29
|
const home = os.homedir();
|
|
12
30
|
if (home) {
|
|
13
31
|
for (const p of [path.join(home, '.klyro', 'KLYRO.md'), path.join(home, '.klyro', 'KLYRO.local.md')]) {
|
|
14
32
|
try {
|
|
15
33
|
const t = await fs.readFile(p, 'utf-8');
|
|
16
|
-
|
|
34
|
+
push(`# ${p}\n${cap(t, MAX_FILE_CHARS)}`);
|
|
17
35
|
}
|
|
18
36
|
catch { /* ignore */ }
|
|
19
37
|
}
|
|
20
38
|
}
|
|
21
|
-
// Root
|
|
39
|
+
// Root (imports resolved relative to each file, contained to cwd)
|
|
22
40
|
for (const name of ['KLYRO.md', 'KLYRO.local.md', 'AGENTS.md', 'CLAUDE.md', '.cursorrules']) {
|
|
23
41
|
const p = path.join(cwd, name);
|
|
24
42
|
try {
|
|
25
43
|
const t = await fs.readFile(p, 'utf-8');
|
|
26
|
-
|
|
44
|
+
push(`# ${p}\n${cap(await resolveImports(t, path.dirname(p), cwd), MAX_FILE_CHARS)}`);
|
|
27
45
|
}
|
|
28
46
|
catch { /* ignore */ }
|
|
29
47
|
}
|
|
30
48
|
return parts.join('\n\n---\n\n');
|
|
31
49
|
}
|
|
32
|
-
async function resolveImports(text, base, depth = 0) {
|
|
50
|
+
async function resolveImports(text, base, root, depth = 0) {
|
|
33
51
|
if (depth > 5)
|
|
34
52
|
return text;
|
|
35
53
|
const importRe = /^@import\s+(.+)$/gm;
|
|
@@ -38,9 +56,13 @@ async function resolveImports(text, base, depth = 0) {
|
|
|
38
56
|
while ((m = importRe.exec(text))) {
|
|
39
57
|
const rel = m[1].trim().replace(/^["']|["']$/g, '');
|
|
40
58
|
const p = path.resolve(base, rel);
|
|
59
|
+
// Containment: never follow imports outside the project root.
|
|
60
|
+
const relToRoot = path.relative(root, p);
|
|
61
|
+
if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot))
|
|
62
|
+
continue;
|
|
41
63
|
try {
|
|
42
64
|
const t = await fs.readFile(p, 'utf-8');
|
|
43
|
-
const resolved = await resolveImports(t, path.dirname(p), depth + 1);
|
|
65
|
+
const resolved = await resolveImports(cap(t, MAX_FILE_CHARS), path.dirname(p), root, depth + 1);
|
|
44
66
|
out = out.replace(m[0], resolved);
|
|
45
67
|
}
|
|
46
68
|
catch { /* ignore missing */ }
|
|
@@ -93,10 +93,17 @@ export function compressTranscript(system, messages, budget) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
// Phase 3: hard cap — drop oldest messages until we fit, but always
|
|
96
|
-
// preserve the first message (the user's task).
|
|
96
|
+
// preserve the first message (the user's task). Drops keep tool_use /
|
|
97
|
+
// tool_result PAIRS intact: removing an assistant turn also removes the
|
|
98
|
+
// tool messages that answer it, and an orphaned tool message (its turn
|
|
99
|
+
// already gone) is dropped on its own. Split pairs make providers 400.
|
|
97
100
|
while (totalTokens(system, result) > budget.total && result.length > 2) {
|
|
98
101
|
result.splice(1, 1);
|
|
99
102
|
dropped++;
|
|
103
|
+
while (result[1] && result[1].role === 'tool') {
|
|
104
|
+
result.splice(1, 1);
|
|
105
|
+
dropped++;
|
|
106
|
+
}
|
|
100
107
|
}
|
|
101
108
|
return { system, messages: result, dropped };
|
|
102
109
|
}
|
package/dist/index.js
CHANGED
|
@@ -435,11 +435,17 @@ async function main() {
|
|
|
435
435
|
.description('Show session transcript and observations')
|
|
436
436
|
.option('--json', 'Output JSON')
|
|
437
437
|
.action(async (id, opts) => {
|
|
438
|
-
const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
|
|
438
|
+
const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
|
|
439
439
|
const store = getDefaultSessionStore();
|
|
440
440
|
const full = await resolveSessionId(store, id);
|
|
441
441
|
if (!full) {
|
|
442
|
-
|
|
442
|
+
const matches = await matchSessionIds(store, id);
|
|
443
|
+
if (matches.length > 1) {
|
|
444
|
+
process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
process.stderr.write(`session not found: ${id}\n`);
|
|
448
|
+
}
|
|
443
449
|
process.exit(2);
|
|
444
450
|
}
|
|
445
451
|
const rec = await store.get(full);
|
|
@@ -466,11 +472,17 @@ async function main() {
|
|
|
466
472
|
.option('--verify-command <cmd>', 'Override verification command')
|
|
467
473
|
.option('--verify', 'Enable verification (default: enabled)')
|
|
468
474
|
.action(async (id, opts) => {
|
|
469
|
-
const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
|
|
475
|
+
const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
|
|
470
476
|
const store = getDefaultSessionStore();
|
|
471
477
|
const full = await resolveSessionId(store, id);
|
|
472
478
|
if (!full) {
|
|
473
|
-
|
|
479
|
+
const matches = await matchSessionIds(store, id);
|
|
480
|
+
if (matches.length > 1) {
|
|
481
|
+
process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
process.stderr.write(`session not found: ${id}\n`);
|
|
485
|
+
}
|
|
474
486
|
process.exit(2);
|
|
475
487
|
}
|
|
476
488
|
const rec = await store.get(full);
|
|
@@ -501,11 +513,17 @@ async function main() {
|
|
|
501
513
|
.option('-m, --model <id>', 'Model')
|
|
502
514
|
.option('--max-steps <n>', 'Max steps', (v) => parsePositiveInt('--max-steps', v))
|
|
503
515
|
.action(async (id, opts) => {
|
|
504
|
-
const { getDefaultSessionStore, resolveSessionId } = await import('./persistence/session.js');
|
|
516
|
+
const { getDefaultSessionStore, resolveSessionId, matchSessionIds } = await import('./persistence/session.js');
|
|
505
517
|
const store = getDefaultSessionStore();
|
|
506
518
|
const full = await resolveSessionId(store, id);
|
|
507
519
|
if (!full) {
|
|
508
|
-
|
|
520
|
+
const matches = await matchSessionIds(store, id);
|
|
521
|
+
if (matches.length > 1) {
|
|
522
|
+
process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
process.stderr.write(`session not found: ${id}\n`);
|
|
526
|
+
}
|
|
509
527
|
process.exit(2);
|
|
510
528
|
}
|
|
511
529
|
const rec = await store.get(full);
|
|
@@ -555,21 +573,38 @@ async function main() {
|
|
|
555
573
|
const rec = await store.create({ cwd: data.record?.cwd ?? process.cwd(), task: data.record?.task ?? 'imported', config: data.record?.config ?? { model: 'imported', maxSteps: 30 } });
|
|
556
574
|
process.stdout.write(`imported → ${rec.id}\n`);
|
|
557
575
|
});
|
|
558
|
-
sessions.command('fork <id>').description('Fork session (9.4)').action(async (id) => {
|
|
559
|
-
const { getDefaultSessionStore,
|
|
576
|
+
sessions.command('fork <id>').description('Fork session with full context (9.4)').action(async (id) => {
|
|
577
|
+
const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
|
|
560
578
|
const store = getDefaultSessionStore();
|
|
561
|
-
const
|
|
562
|
-
if (
|
|
579
|
+
const matches = await matchSessionIds(store, id);
|
|
580
|
+
if (matches.length === 0) {
|
|
563
581
|
process.stderr.write(`session not found: ${id}\n`);
|
|
564
582
|
process.exit(2);
|
|
565
583
|
}
|
|
566
|
-
|
|
567
|
-
|
|
584
|
+
if (matches.length > 1) {
|
|
585
|
+
process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
|
|
586
|
+
process.exit(2);
|
|
587
|
+
}
|
|
588
|
+
const full = matches[0].id;
|
|
589
|
+
const forked = await store.fork(full);
|
|
590
|
+
const msgs = await store.loadMessages(forked.id);
|
|
591
|
+
process.stdout.write(`forked ${full.slice(0, 8)} → ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)\n`);
|
|
592
|
+
});
|
|
593
|
+
sessions.command('delete <id>').description('Delete a session and its artifacts').action(async (id) => {
|
|
594
|
+
const { getDefaultSessionStore, matchSessionIds } = await import('./persistence/session.js');
|
|
595
|
+
const store = getDefaultSessionStore();
|
|
596
|
+
const matches = await matchSessionIds(store, id);
|
|
597
|
+
if (matches.length === 0) {
|
|
568
598
|
process.stderr.write(`session not found: ${id}\n`);
|
|
569
599
|
process.exit(2);
|
|
570
600
|
}
|
|
571
|
-
|
|
572
|
-
|
|
601
|
+
if (matches.length > 1) {
|
|
602
|
+
process.stderr.write(`ambiguous id "${id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}\n`);
|
|
603
|
+
process.exit(2);
|
|
604
|
+
}
|
|
605
|
+
const full = matches[0].id;
|
|
606
|
+
await store.delete(full);
|
|
607
|
+
process.stdout.write(`deleted ${full.slice(0, 8)}\n`);
|
|
573
608
|
});
|
|
574
609
|
// 10.1 — MCP
|
|
575
610
|
const mcp = program.command('mcp').description('MCP client/server (10.1)');
|
|
@@ -10,3 +10,5 @@ export declare function createSessionStore(dir: string): SessionStore;
|
|
|
10
10
|
export declare function formatSession(rec: SessionRecord): string;
|
|
11
11
|
/** Resolve a short id prefix to full id (like git). */
|
|
12
12
|
export declare function resolveSessionId(store: SessionStore, prefix: string): Promise<string | null>;
|
|
13
|
+
/** All sessions matching an id prefix (lets callers distinguish missing vs ambiguous). */
|
|
14
|
+
export declare function matchSessionIds(store: SessionStore, prefix: string): Promise<SessionRecord[]>;
|
|
@@ -34,12 +34,18 @@ export async function resolveSessionId(store, prefix) {
|
|
|
34
34
|
const rec = await store.get(prefix);
|
|
35
35
|
return rec ? rec.id : null;
|
|
36
36
|
}
|
|
37
|
-
const
|
|
38
|
-
const matches = all.filter((r) => r.id.startsWith(prefix));
|
|
37
|
+
const matches = await matchSessionIds(store, prefix);
|
|
39
38
|
if (matches.length === 1)
|
|
40
39
|
return matches[0].id;
|
|
41
|
-
|
|
42
|
-
return null;
|
|
43
|
-
// ambiguous — return null and let caller report
|
|
40
|
+
// zero or ambiguous — null; callers use matchSessionIds for a good message
|
|
44
41
|
return null;
|
|
45
42
|
}
|
|
43
|
+
/** All sessions matching an id prefix (lets callers distinguish missing vs ambiguous). */
|
|
44
|
+
export async function matchSessionIds(store, prefix) {
|
|
45
|
+
if (prefix.length >= 32) {
|
|
46
|
+
const rec = await store.get(prefix);
|
|
47
|
+
return rec ? [rec] : [];
|
|
48
|
+
}
|
|
49
|
+
const all = await store.list();
|
|
50
|
+
return all.filter((r) => r.id.startsWith(prefix));
|
|
51
|
+
}
|
|
@@ -56,6 +56,13 @@ export declare class SessionStore {
|
|
|
56
56
|
task: string;
|
|
57
57
|
config: SessionConfig;
|
|
58
58
|
}): Promise<SessionRecord>;
|
|
59
|
+
/**
|
|
60
|
+
* Fork a session: new id + copied messages/observations so the fork
|
|
61
|
+
* continues with full context instead of starting blank.
|
|
62
|
+
*/
|
|
63
|
+
fork(id: string, taskSuffix?: string): Promise<SessionRecord>;
|
|
64
|
+
/** Delete a session and all its artifacts (record, transcript, jsonl, indexes). */
|
|
65
|
+
delete(id: string): Promise<boolean>;
|
|
59
66
|
private jsonlPath;
|
|
60
67
|
appendJsonl(id: string, entry: unknown): Promise<void>;
|
|
61
68
|
readJsonl(id: string): Promise<unknown[]>;
|
|
@@ -81,37 +81,86 @@ export class SessionStore {
|
|
|
81
81
|
return w.length > 40 ? w.slice(0, 40) + '…' : w || 'untitled';
|
|
82
82
|
}
|
|
83
83
|
async create(opts) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
let pIdx = {};
|
|
84
|
+
// Locked: the index read-modify-write below races under concurrent creates.
|
|
85
|
+
return this.withLock('index', async () => {
|
|
86
|
+
await this.ensureDir();
|
|
87
|
+
const id = randomUUID();
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const record = {
|
|
90
|
+
id,
|
|
91
|
+
cwd: opts.cwd,
|
|
92
|
+
task: opts.task,
|
|
93
|
+
status: 'open',
|
|
94
|
+
createdAt: now,
|
|
95
|
+
updatedAt: now,
|
|
96
|
+
config: opts.config,
|
|
97
|
+
};
|
|
98
|
+
record.title = this.titleFor(opts.task);
|
|
99
|
+
await fs.writeFile(path.join(this.dir, `${id}.json`), JSON.stringify({ record, messages: [], observations: [] }, null, 2));
|
|
100
|
+
await this.appendJsonl(id, { type: 'session.create', record, ts: now });
|
|
101
|
+
const idx = await this.readIndex();
|
|
102
|
+
idx[id] = record;
|
|
103
|
+
await this.writeIndex(idx);
|
|
104
|
+
// per-project index
|
|
106
105
|
try {
|
|
107
|
-
|
|
106
|
+
const pp = this.perProjectIndexPath(opts.cwd);
|
|
107
|
+
let pIdx = {};
|
|
108
|
+
try {
|
|
109
|
+
pIdx = JSON.parse(await fs.readFile(pp, 'utf-8'));
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
112
|
+
pIdx[id] = record;
|
|
113
|
+
await fs.writeFile(pp, JSON.stringify(pIdx, null, 2), 'utf-8');
|
|
108
114
|
}
|
|
109
115
|
catch { }
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
return record;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Fork a session: new id + copied messages/observations so the fork
|
|
121
|
+
* continues with full context instead of starting blank.
|
|
122
|
+
*/
|
|
123
|
+
async fork(id, taskSuffix = ' (fork)') {
|
|
124
|
+
const data = await this.readSession(id);
|
|
125
|
+
const forked = await this.create({
|
|
126
|
+
cwd: data.record.cwd,
|
|
127
|
+
task: `${data.record.task}${taskSuffix}`,
|
|
128
|
+
config: data.record.config,
|
|
129
|
+
});
|
|
130
|
+
await this.withLock(forked.id, async () => {
|
|
131
|
+
const fresh = await this.readSession(forked.id);
|
|
132
|
+
fresh.messages = data.messages.map((m) => ({ ...m }));
|
|
133
|
+
fresh.observations = data.observations.map((o) => ({ ...o }));
|
|
134
|
+
await this.writeSession(forked.id, fresh);
|
|
135
|
+
});
|
|
136
|
+
await this.appendJsonl(forked.id, { type: 'session.fork', from: id, ts: Date.now() });
|
|
137
|
+
return forked;
|
|
138
|
+
}
|
|
139
|
+
/** Delete a session and all its artifacts (record, transcript, jsonl, indexes). */
|
|
140
|
+
async delete(id) {
|
|
141
|
+
return this.withLock('index', async () => {
|
|
142
|
+
const rec = await this.get(id);
|
|
143
|
+
if (!rec)
|
|
144
|
+
return false;
|
|
145
|
+
for (const f of [`${id}.json`, `${id}.jsonl`]) {
|
|
146
|
+
try {
|
|
147
|
+
await fs.unlink(path.join(this.dir, f));
|
|
148
|
+
}
|
|
149
|
+
catch { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
const idx = await this.readIndex();
|
|
152
|
+
delete idx[id];
|
|
153
|
+
await this.writeIndex(idx);
|
|
154
|
+
try {
|
|
155
|
+
const pp = this.perProjectIndexPath(rec.cwd);
|
|
156
|
+
const raw = await fs.readFile(pp, 'utf-8');
|
|
157
|
+
const pIdx = JSON.parse(raw);
|
|
158
|
+
delete pIdx[id];
|
|
159
|
+
await fs.writeFile(pp, JSON.stringify(pIdx, null, 2), 'utf-8');
|
|
160
|
+
}
|
|
161
|
+
catch { /* ignore */ }
|
|
162
|
+
return true;
|
|
163
|
+
});
|
|
115
164
|
}
|
|
116
165
|
jsonlPath(id) { return path.join(this.dir, `${id}.jsonl`); }
|
|
117
166
|
async appendJsonl(id, entry) {
|
package/dist/policy/engine.d.ts
CHANGED
|
@@ -76,8 +76,17 @@ export declare const shellDenyRule: PolicyRule;
|
|
|
76
76
|
export declare const shellAllowRule: PolicyRule;
|
|
77
77
|
/** Writes are auto-allowed if path resolves inside cwd or --add-dir (path-guard throws otherwise). */
|
|
78
78
|
export declare const writeFileCwdRule: PolicyRule;
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Reads > readSizeAskMiB require confirmation (avoid filling the context).
|
|
81
|
+
* Stats the file directly — the old sizeBytes input never existed in the
|
|
82
|
+
* read_file schema, so this rule never fired (dead code until now).
|
|
83
|
+
*/
|
|
80
84
|
export declare const readFileSizeRule: PolicyRule;
|
|
85
|
+
/**
|
|
86
|
+
* Deep-clone a PolicyConfig so per-session tweaks (/mode, /sandbox) never
|
|
87
|
+
* leak across engines via the shared DEFAULT_POLICY_CONFIG reference.
|
|
88
|
+
*/
|
|
89
|
+
export declare function clonePolicyConfig(base?: PolicyConfig): PolicyConfig;
|
|
81
90
|
/** Convenience: evaluate via safe() so a buggy rule doesn't crash the loop. */
|
|
82
91
|
export declare function evaluatePolicy(engine: PolicyEngine, call: ToolCallLike, ctx: {
|
|
83
92
|
cwd: string;
|
package/dist/policy/engine.js
CHANGED
|
@@ -218,13 +218,27 @@ export const writeFileCwdRule = {
|
|
|
218
218
|
return null;
|
|
219
219
|
},
|
|
220
220
|
};
|
|
221
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Reads > readSizeAskMiB require confirmation (avoid filling the context).
|
|
223
|
+
* Stats the file directly — the old sizeBytes input never existed in the
|
|
224
|
+
* read_file schema, so this rule never fired (dead code until now).
|
|
225
|
+
*/
|
|
222
226
|
export const readFileSizeRule = {
|
|
223
227
|
name: 'read-file-size',
|
|
224
228
|
evaluate(call, ctx) {
|
|
225
229
|
if (call.name !== 'read_file')
|
|
226
230
|
return null;
|
|
227
|
-
const
|
|
231
|
+
const p = asString(call.input.path);
|
|
232
|
+
if (!p)
|
|
233
|
+
return null;
|
|
234
|
+
let size = 0;
|
|
235
|
+
try {
|
|
236
|
+
const { resolved } = resolveWithinCwd(ctx.cwd, p);
|
|
237
|
+
size = fsSync.statSync(resolved).size;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return null; // unreadable → other rules/tools report it
|
|
241
|
+
}
|
|
228
242
|
if (size > ctx.config.readSizeAskMiB * 1024 * 1024) {
|
|
229
243
|
if (ctx.nonInteractive)
|
|
230
244
|
return { action: 'deny', reason: `file > ${ctx.config.readSizeAskMiB} MiB` };
|
|
@@ -233,6 +247,21 @@ export const readFileSizeRule = {
|
|
|
233
247
|
return null;
|
|
234
248
|
},
|
|
235
249
|
};
|
|
250
|
+
/**
|
|
251
|
+
* Deep-clone a PolicyConfig so per-session tweaks (/mode, /sandbox) never
|
|
252
|
+
* leak across engines via the shared DEFAULT_POLICY_CONFIG reference.
|
|
253
|
+
*/
|
|
254
|
+
export function clonePolicyConfig(base = DEFAULT_POLICY_CONFIG) {
|
|
255
|
+
return {
|
|
256
|
+
...base,
|
|
257
|
+
shellAllow: [...base.shellAllow],
|
|
258
|
+
shellDeny: [...base.shellDeny],
|
|
259
|
+
allow: [...(base.allow ?? [])],
|
|
260
|
+
deny: [...(base.deny ?? [])],
|
|
261
|
+
ask: [...(base.ask ?? [])],
|
|
262
|
+
additionalDirs: [...(base.additionalDirs ?? [])],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
236
265
|
/** Convenience: evaluate via safe() so a buggy rule doesn't crash the loop. */
|
|
237
266
|
export async function evaluatePolicy(engine, call, ctx) {
|
|
238
267
|
const r = await safe(() => engine.evaluate(call, ctx));
|
|
@@ -244,3 +273,5 @@ export async function evaluatePolicy(engine, call, ctx) {
|
|
|
244
273
|
// (path-guard is already in policy/, and tools/ depend on it. This keeps
|
|
245
274
|
// policy/ depending only on tools/normalize, no other tool code.)
|
|
246
275
|
import * as path from 'node:path';
|
|
276
|
+
import * as fsSync from 'node:fs';
|
|
277
|
+
import { resolveWithinCwd } from './path-guard.js';
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 4.2 — apply_patch: Codex-style unified patch
|
|
2
|
+
* 4.2 — apply_patch: Codex-style unified patch with REAL hunk application.
|
|
3
|
+
*
|
|
4
|
+
* Format:
|
|
5
|
+
* *** Begin Patch
|
|
6
|
+
* *** Update File: <path> (must contain @@ hunks for existing files)
|
|
7
|
+
* *** Add File: <path> (created from + lines)
|
|
8
|
+
* *** End Patch
|
|
9
|
+
*
|
|
10
|
+
* Hunks are standard unified diff: `@@ -a,b +c,d @@` headers, ' ' context,
|
|
11
|
+
* '-' removals, '+' additions. Context/removals must match the file (exact
|
|
12
|
+
* first, trailing-whitespace-insensitive fallback with ±3 line drift);
|
|
13
|
+
* otherwise INVALID_PATCH names the file+hunk instead of corrupting it.
|
|
14
|
+
*
|
|
15
|
+
* Legacy compat: a file section with NO @@ lines and only + lines is treated
|
|
16
|
+
* as file creation (the old tolerant behavior) — but never as an edit to an
|
|
17
|
+
* existing file, where hunk-less appends used to corrupt content.
|
|
3
18
|
*/
|
|
4
19
|
export declare const applyPatchTool: import("../types.js").Tool<{
|
|
5
20
|
patch: string;
|