klyro 1.0.0 → 1.0.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/dist/agent/anthropic-adapter.d.ts +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -19
- package/dist/mcp/registry.js +127 -8
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +20 -0
- package/dist/trace/writer.js +62 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- package/package.json +1 -1
|
@@ -50,6 +50,8 @@ export declare class SessionStore {
|
|
|
50
50
|
private writeIndex;
|
|
51
51
|
private projectHash;
|
|
52
52
|
private perProjectIndexPath;
|
|
53
|
+
/** Atomic per-project index write (tmp + fsync + rename). */
|
|
54
|
+
private writePerProjectIndex;
|
|
53
55
|
private titleFor;
|
|
54
56
|
create(opts: {
|
|
55
57
|
cwd: string;
|
|
@@ -61,7 +63,7 @@ export declare class SessionStore {
|
|
|
61
63
|
* continues with full context instead of starting blank.
|
|
62
64
|
*/
|
|
63
65
|
fork(id: string, taskSuffix?: string): Promise<SessionRecord>;
|
|
64
|
-
/** Delete a session and all its artifacts (record, transcript, jsonl, indexes). */
|
|
66
|
+
/** Delete a session and all its artifacts (record, transcript, jsonl, lock, indexes). */
|
|
65
67
|
delete(id: string): Promise<boolean>;
|
|
66
68
|
private jsonlPath;
|
|
67
69
|
appendJsonl(id: string, entry: unknown): Promise<void>;
|
|
@@ -77,6 +79,28 @@ export declare class SessionStore {
|
|
|
77
79
|
status?: SessionStatus;
|
|
78
80
|
}): Promise<SessionRecord[]>;
|
|
79
81
|
get(id: string): Promise<SessionRecord | null>;
|
|
80
|
-
/** Atomic append — survives crashes; suitable for audit log. */
|
|
82
|
+
/** Atomic append + fsync — survives crashes; suitable for audit log. */
|
|
81
83
|
static appendJsonl(filePath: string, entry: unknown): Promise<void>;
|
|
82
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Redact text blocks of a stored message at the persist boundary.
|
|
87
|
+
* Handles string content, `{ text }` blocks, arrays of blocks, and plain
|
|
88
|
+
* objects (observation input/output) via a deep walk over string leaves.
|
|
89
|
+
* Only secret-shaped strings (key/token/password patterns, [:=-] shapes)
|
|
90
|
+
* are touched — normal prose passes through unchanged.
|
|
91
|
+
*/
|
|
92
|
+
export declare function redactStoredContent(content: unknown): unknown;
|
|
93
|
+
export interface SessionLockInfo {
|
|
94
|
+
held: boolean;
|
|
95
|
+
pid?: number;
|
|
96
|
+
/** True when the lock holder process is (probably) still running. */
|
|
97
|
+
alive?: boolean;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Cross-process session lock probe. The lock file `<sessionsDir>/<id>.lock`
|
|
101
|
+
* contains the holder's PID. `alive` is resolved via `process.kill(pid, 0)`:
|
|
102
|
+
* EPERM means alive-but-unpermitted (→ true); ESRCH / other errors → false.
|
|
103
|
+
*/
|
|
104
|
+
export declare function readSessionLock(sessionsDir: string, id: string): SessionLockInfo;
|
|
105
|
+
/** Best-effort release of the cross-process session lock. Never throws. */
|
|
106
|
+
export declare function releaseSessionLock(sessionsDir: string, id: string): Promise<void>;
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* SessionStore interface.
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from 'node:fs/promises';
|
|
13
|
+
import * as fsSync from 'node:fs';
|
|
13
14
|
import * as path from 'node:path';
|
|
14
15
|
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
15
17
|
export class SessionStore {
|
|
16
18
|
dir;
|
|
17
19
|
indexPath;
|
|
@@ -75,6 +77,28 @@ export class SessionStore {
|
|
|
75
77
|
return Math.abs(h).toString(36);
|
|
76
78
|
}
|
|
77
79
|
perProjectIndexPath(cwd) { return path.join(this.dir, `index-${this.projectHash(cwd)}.json`); }
|
|
80
|
+
/** Atomic per-project index write (tmp + fsync + rename). */
|
|
81
|
+
async writePerProjectIndex(pp, pIdx) {
|
|
82
|
+
const tmp = `${pp}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
83
|
+
await fs.writeFile(tmp, JSON.stringify(pIdx, null, 2), 'utf-8');
|
|
84
|
+
try {
|
|
85
|
+
const fh = await fs.open(tmp, 'r+');
|
|
86
|
+
try {
|
|
87
|
+
await fh.sync();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await fh.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { /* ignore on Windows */ }
|
|
94
|
+
try {
|
|
95
|
+
await fs.rename(tmp, pp);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
99
|
+
throw new Error('Failed to write per-project sessions index');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
78
102
|
titleFor(task) {
|
|
79
103
|
// heuristic title via first 6 words, or model.small would be used if available
|
|
80
104
|
const w = task.trim().split(/\s+/).slice(0, 6).join(' ');
|
|
@@ -84,24 +108,39 @@ export class SessionStore {
|
|
|
84
108
|
// Locked: the index read-modify-write below races under concurrent creates.
|
|
85
109
|
return this.withLock('index', async () => {
|
|
86
110
|
await this.ensureDir();
|
|
87
|
-
|
|
111
|
+
// Redact at the persist boundary — tasks routinely paste keys/tokens.
|
|
112
|
+
const task = redact(opts.task);
|
|
113
|
+
let id = randomUUID();
|
|
114
|
+
// Cross-process session lock: O_EXCL so a concurrent creator (another
|
|
115
|
+
// process resuming/creating the same id path) can detect contention.
|
|
116
|
+
// On collision fall back to a unique id suffix — never crash.
|
|
117
|
+
try {
|
|
118
|
+
await fs.writeFile(path.join(this.dir, `${id}.lock`), String(process.pid), { flag: 'wx' });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
id = `${id}-${process.pid}-${Date.now().toString(36)}`;
|
|
122
|
+
try {
|
|
123
|
+
await fs.writeFile(path.join(this.dir, `${id}.lock`), String(process.pid), { flag: 'wx' });
|
|
124
|
+
}
|
|
125
|
+
catch { /* best-effort only */ }
|
|
126
|
+
}
|
|
88
127
|
const now = Date.now();
|
|
89
128
|
const record = {
|
|
90
129
|
id,
|
|
91
130
|
cwd: opts.cwd,
|
|
92
|
-
task
|
|
131
|
+
task,
|
|
93
132
|
status: 'open',
|
|
94
133
|
createdAt: now,
|
|
95
134
|
updatedAt: now,
|
|
96
135
|
config: opts.config,
|
|
97
136
|
};
|
|
98
|
-
record.title = this.titleFor(
|
|
137
|
+
record.title = this.titleFor(task);
|
|
99
138
|
await fs.writeFile(path.join(this.dir, `${id}.json`), JSON.stringify({ record, messages: [], observations: [] }, null, 2));
|
|
100
139
|
await this.appendJsonl(id, { type: 'session.create', record, ts: now });
|
|
101
140
|
const idx = await this.readIndex();
|
|
102
141
|
idx[id] = record;
|
|
103
142
|
await this.writeIndex(idx);
|
|
104
|
-
// per-project index
|
|
143
|
+
// per-project index (atomic)
|
|
105
144
|
try {
|
|
106
145
|
const pp = this.perProjectIndexPath(opts.cwd);
|
|
107
146
|
let pIdx = {};
|
|
@@ -110,7 +149,7 @@ export class SessionStore {
|
|
|
110
149
|
}
|
|
111
150
|
catch { }
|
|
112
151
|
pIdx[id] = record;
|
|
113
|
-
await
|
|
152
|
+
await this.writePerProjectIndex(pp, pIdx);
|
|
114
153
|
}
|
|
115
154
|
catch { }
|
|
116
155
|
return record;
|
|
@@ -136,13 +175,13 @@ export class SessionStore {
|
|
|
136
175
|
await this.appendJsonl(forked.id, { type: 'session.fork', from: id, ts: Date.now() });
|
|
137
176
|
return forked;
|
|
138
177
|
}
|
|
139
|
-
/** Delete a session and all its artifacts (record, transcript, jsonl, indexes). */
|
|
178
|
+
/** Delete a session and all its artifacts (record, transcript, jsonl, lock, indexes). */
|
|
140
179
|
async delete(id) {
|
|
141
180
|
return this.withLock('index', async () => {
|
|
142
181
|
const rec = await this.get(id);
|
|
143
182
|
if (!rec)
|
|
144
183
|
return false;
|
|
145
|
-
for (const f of [`${id}.json`, `${id}.jsonl`]) {
|
|
184
|
+
for (const f of [`${id}.json`, `${id}.jsonl`, `${id}.lock`]) {
|
|
146
185
|
try {
|
|
147
186
|
await fs.unlink(path.join(this.dir, f));
|
|
148
187
|
}
|
|
@@ -156,7 +195,7 @@ export class SessionStore {
|
|
|
156
195
|
const raw = await fs.readFile(pp, 'utf-8');
|
|
157
196
|
const pIdx = JSON.parse(raw);
|
|
158
197
|
delete pIdx[id];
|
|
159
|
-
await
|
|
198
|
+
await this.writePerProjectIndex(pp, pIdx);
|
|
160
199
|
}
|
|
161
200
|
catch { /* ignore */ }
|
|
162
201
|
return true;
|
|
@@ -241,14 +280,19 @@ export class SessionStore {
|
|
|
241
280
|
async appendMessage(id, message) {
|
|
242
281
|
return this.withLock(id, async () => {
|
|
243
282
|
const data = await this.readSession(id);
|
|
244
|
-
data.messages.push(message);
|
|
283
|
+
data.messages.push({ ...message, content: redactStoredContent(message.content) });
|
|
245
284
|
await this.writeSession(id, data);
|
|
246
285
|
});
|
|
247
286
|
}
|
|
248
287
|
async appendObservation(id, obs) {
|
|
249
288
|
return this.withLock(id, async () => {
|
|
250
289
|
const data = await this.readSession(id);
|
|
251
|
-
|
|
290
|
+
// S4-at-rest: redact observation input/output at the persist boundary.
|
|
291
|
+
data.observations.push({
|
|
292
|
+
...obs,
|
|
293
|
+
input: redactStoredContent(obs.input),
|
|
294
|
+
output: redactStoredContent(obs.output),
|
|
295
|
+
});
|
|
252
296
|
await this.writeSession(id, data);
|
|
253
297
|
});
|
|
254
298
|
}
|
|
@@ -256,9 +300,16 @@ export class SessionStore {
|
|
|
256
300
|
return this.withLock(id, async () => {
|
|
257
301
|
const data = await this.readSession(id);
|
|
258
302
|
data.record.status = status;
|
|
303
|
+
// S4-at-rest: redact finalText before persisting.
|
|
259
304
|
if (finalText !== undefined)
|
|
260
|
-
data.record.finalText = finalText;
|
|
305
|
+
data.record.finalText = redact(finalText);
|
|
261
306
|
await this.writeSession(id, data);
|
|
307
|
+
// Terminal states release the cross-process lock (no destructor exists
|
|
308
|
+
// to do it — the session is done, so the lock is stale by definition).
|
|
309
|
+
if (status === 'complete' || status === 'aborted' || status === 'max_steps' ||
|
|
310
|
+
status === 'verify_failed' || status === 'stuck') {
|
|
311
|
+
await releaseSessionLock(this.dir, id);
|
|
312
|
+
}
|
|
262
313
|
});
|
|
263
314
|
}
|
|
264
315
|
async loadMessages(id) {
|
|
@@ -278,9 +329,85 @@ export class SessionStore {
|
|
|
278
329
|
const idx = await this.readIndex();
|
|
279
330
|
return idx[id] ?? null;
|
|
280
331
|
}
|
|
281
|
-
/** Atomic append — survives crashes; suitable for audit log. */
|
|
332
|
+
/** Atomic append + fsync — survives crashes; suitable for audit log. */
|
|
282
333
|
static async appendJsonl(filePath, entry) {
|
|
283
334
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
284
|
-
await fs.
|
|
335
|
+
const fh = await fs.open(filePath, 'a');
|
|
336
|
+
try {
|
|
337
|
+
await fh.write(JSON.stringify(entry) + '\n');
|
|
338
|
+
await fh.sync();
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
await fh.close();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Redact text blocks of a stored message at the persist boundary.
|
|
347
|
+
* Handles string content, `{ text }` blocks, arrays of blocks, and plain
|
|
348
|
+
* objects (observation input/output) via a deep walk over string leaves.
|
|
349
|
+
* Only secret-shaped strings (key/token/password patterns, [:=-] shapes)
|
|
350
|
+
* are touched — normal prose passes through unchanged.
|
|
351
|
+
*/
|
|
352
|
+
export function redactStoredContent(content) {
|
|
353
|
+
if (typeof content === 'string')
|
|
354
|
+
return redact(content);
|
|
355
|
+
if (Array.isArray(content)) {
|
|
356
|
+
return content.map((b) => {
|
|
357
|
+
if (b && typeof b === 'object' && typeof b.text === 'string') {
|
|
358
|
+
return { ...b, text: redact(b.text) };
|
|
359
|
+
}
|
|
360
|
+
if (typeof b === 'string')
|
|
361
|
+
return redact(b);
|
|
362
|
+
if (b && typeof b === 'object')
|
|
363
|
+
return redactStoredContent(b);
|
|
364
|
+
return b;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (content && typeof content === 'object') {
|
|
368
|
+
const rec = content;
|
|
369
|
+
if (typeof rec.text === 'string') {
|
|
370
|
+
return { ...rec, text: redact(rec.text) };
|
|
371
|
+
}
|
|
372
|
+
const out = {};
|
|
373
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
374
|
+
out[k] = typeof v === 'string' ? redact(v) : (v && typeof v === 'object' ? redactStoredContent(v) : v);
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
return content;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Cross-process session lock probe. The lock file `<sessionsDir>/<id>.lock`
|
|
382
|
+
* contains the holder's PID. `alive` is resolved via `process.kill(pid, 0)`:
|
|
383
|
+
* EPERM means alive-but-unpermitted (→ true); ESRCH / other errors → false.
|
|
384
|
+
*/
|
|
385
|
+
export function readSessionLock(sessionsDir, id) {
|
|
386
|
+
let raw;
|
|
387
|
+
try {
|
|
388
|
+
raw = fsSync.readFileSync(path.join(sessionsDir, `${id}.lock`), 'utf-8');
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return { held: false };
|
|
392
|
+
}
|
|
393
|
+
const pid = Number(raw.trim());
|
|
394
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
395
|
+
return { held: true };
|
|
396
|
+
let alive;
|
|
397
|
+
try {
|
|
398
|
+
process.kill(pid, 0);
|
|
399
|
+
alive = true;
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
const code = err?.code;
|
|
403
|
+
alive = code === 'EPERM';
|
|
404
|
+
}
|
|
405
|
+
return { held: true, pid, alive };
|
|
406
|
+
}
|
|
407
|
+
/** Best-effort release of the cross-process session lock. Never throws. */
|
|
408
|
+
export async function releaseSessionLock(sessionsDir, id) {
|
|
409
|
+
try {
|
|
410
|
+
await fs.unlink(path.join(sessionsDir, `${id}.lock`));
|
|
285
411
|
}
|
|
412
|
+
catch { /* ignore */ }
|
|
286
413
|
}
|
|
@@ -25,6 +25,20 @@ export interface ApprovalRequest {
|
|
|
25
25
|
export interface ApprovalPrompt {
|
|
26
26
|
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Sanitize untrusted tool text before showing it in an approval prompt.
|
|
30
|
+
* Strips ANSI escapes (CSI `ESC[...letter` and OSC `ESC]...BEL`), control
|
|
31
|
+
* chars except `\n`/`\t`, and truncates to 500 chars with a hidden-count
|
|
32
|
+
* marker so prompt-injection / terminal-escape payloads can't ride along.
|
|
33
|
+
*/
|
|
34
|
+
export declare function sanitizeForPrompt(s: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Single-key approval mapping shared by the TUI modal (and documented here
|
|
37
|
+
* so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
|
|
38
|
+
* persist to settings. Case MUST be checked before lowercasing — the old TUI
|
|
39
|
+
* code lowercased first, so `A` silently became session-only `always`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function approvalChoiceForKey(inputStr: string): ApprovalChoice | 'expand' | 'explain' | null;
|
|
28
42
|
/** Default prompt backed by readline on stdin/stdout. */
|
|
29
43
|
export declare class StdinApprovalPrompt implements ApprovalPrompt {
|
|
30
44
|
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
package/dist/policy/approval.js
CHANGED
|
@@ -8,6 +8,45 @@
|
|
|
8
8
|
import * as readline from 'node:readline/promises';
|
|
9
9
|
import { stdin as input, stdout as output } from 'node:process';
|
|
10
10
|
import { patternForCall } from './patterns.js';
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize untrusted tool text before showing it in an approval prompt.
|
|
13
|
+
* Strips ANSI escapes (CSI `ESC[...letter` and OSC `ESC]...BEL`), control
|
|
14
|
+
* chars except `\n`/`\t`, and truncates to 500 chars with a hidden-count
|
|
15
|
+
* marker so prompt-injection / terminal-escape payloads can't ride along.
|
|
16
|
+
*/
|
|
17
|
+
export function sanitizeForPrompt(s) {
|
|
18
|
+
const noAnsi = s
|
|
19
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
20
|
+
.replace(/\x1b\][^\x07]*\x07/g, '');
|
|
21
|
+
const clean = noAnsi.replace(/[\x00-\x08\x0b-\x0d\x0e-\x1f\x7f]/g, '');
|
|
22
|
+
const LIMIT = 500;
|
|
23
|
+
if (clean.length > LIMIT) {
|
|
24
|
+
return clean.slice(0, LIMIT) + `…[+${clean.length - LIMIT} hidden]`;
|
|
25
|
+
}
|
|
26
|
+
return clean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Single-key approval mapping shared by the TUI modal (and documented here
|
|
30
|
+
* so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
|
|
31
|
+
* persist to settings. Case MUST be checked before lowercasing — the old TUI
|
|
32
|
+
* code lowercased first, so `A` silently became session-only `always`.
|
|
33
|
+
*/
|
|
34
|
+
export function approvalChoiceForKey(inputStr) {
|
|
35
|
+
if (inputStr === 'A')
|
|
36
|
+
return 'always-persist';
|
|
37
|
+
const c = inputStr.toLowerCase();
|
|
38
|
+
if (c === 'y')
|
|
39
|
+
return 'allow';
|
|
40
|
+
if (c === 'a')
|
|
41
|
+
return 'always';
|
|
42
|
+
if (c === 'd' || c === 'n' || c === 'e')
|
|
43
|
+
return 'deny';
|
|
44
|
+
if (c === 'f')
|
|
45
|
+
return 'expand';
|
|
46
|
+
if (c === '?')
|
|
47
|
+
return 'explain';
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
11
50
|
/** Default prompt backed by readline on stdin/stdout. */
|
|
12
51
|
export class StdinApprovalPrompt {
|
|
13
52
|
async ask(req) {
|
|
@@ -15,10 +54,13 @@ export class StdinApprovalPrompt {
|
|
|
15
54
|
return 'deny';
|
|
16
55
|
const rl = readline.createInterface({ input, output });
|
|
17
56
|
try {
|
|
18
|
-
process.stdout.write(`\n[approval needed] ${req.toolName}: ${req.summary}\n reason: ${req.reason}\n allow? [y/n/a(llow)] `);
|
|
19
|
-
const
|
|
57
|
+
process.stdout.write(`\n[approval needed] ${req.toolName}: ${sanitizeForPrompt(req.summary)}\n reason: ${sanitizeForPrompt(req.reason)}\n allow? [y/n/a/A(llow always → settings)] (y=once, n=deny, a=session, A=persist) `);
|
|
58
|
+
const raw = (await rl.question('')).trim();
|
|
59
|
+
const ans = raw.toLowerCase();
|
|
20
60
|
if (ans === 'y' || ans === 'yes')
|
|
21
61
|
return 'allow';
|
|
62
|
+
if (raw === 'A')
|
|
63
|
+
return 'always-persist';
|
|
22
64
|
if (ans === 'a' || ans === 'allow')
|
|
23
65
|
return 'always';
|
|
24
66
|
return 'deny';
|
package/dist/policy/engine.d.ts
CHANGED
|
@@ -83,6 +83,23 @@ export declare class PolicyEngine {
|
|
|
83
83
|
}
|
|
84
84
|
/** Builtin set of rules. Order matters: first match wins. */
|
|
85
85
|
export declare function builtinRules(): PolicyRule[];
|
|
86
|
+
export declare function matchesGlobRule(call: ToolCallLike, rule: string): boolean;
|
|
87
|
+
/** Branches that `git push` must never target without an explicit opt-out. */
|
|
88
|
+
export declare const PROTECTED_BRANCHES: string[];
|
|
89
|
+
/** Matches `push` with a protected branch name later on the same line. */
|
|
90
|
+
export declare const PROTECTED_PUSH_RE: RegExp;
|
|
91
|
+
/**
|
|
92
|
+
* True when the command is a `git push` with NO ref/positional args
|
|
93
|
+
* (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
|
|
94
|
+
* out. `git push origin feature` is explicit, not bare.
|
|
95
|
+
*/
|
|
96
|
+
export declare function isBareGitPush(cmd: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the currently checked-out branch via `git branch --show-current`.
|
|
99
|
+
* Returns null on any failure (not a repo, git missing) — callers treat
|
|
100
|
+
* null as "unknown" and allow the normal policy flow to continue.
|
|
101
|
+
*/
|
|
102
|
+
export declare function currentGitBranch(cwd: string): string | null;
|
|
86
103
|
/** Hard-deny for obviously destructive shell patterns. */
|
|
87
104
|
export declare const shellDenyRule: PolicyRule;
|
|
88
105
|
/** Allowlist for shell — exact or prefix. Anything not in the list asks. */
|
package/dist/policy/engine.js
CHANGED
|
@@ -66,7 +66,11 @@ export class PolicyEngine {
|
|
|
66
66
|
}
|
|
67
67
|
else if (ctx.config.mode === 'plan') {
|
|
68
68
|
// plan mode: block all writes (edit)
|
|
69
|
-
if (call.name === 'write_file' ||
|
|
69
|
+
if (call.name === 'write_file' ||
|
|
70
|
+
call.name === 'edit_file' ||
|
|
71
|
+
call.name === 'multi_edit' ||
|
|
72
|
+
call.name === 'apply_patch' ||
|
|
73
|
+
call.name === 'memory_write') {
|
|
70
74
|
return { action: 'deny', reason: 'plan mode: writes blocked — use /permissions to allow or switch mode' };
|
|
71
75
|
}
|
|
72
76
|
}
|
|
@@ -80,17 +84,33 @@ export class PolicyEngine {
|
|
|
80
84
|
const globDecision = this.evaluateGlobRules(call);
|
|
81
85
|
if (globDecision)
|
|
82
86
|
return globDecision;
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// Centralized .env guard: any tool with a string `path` input pointing
|
|
88
|
+
// at an env file is denied unless an allow rule explicitly names that
|
|
89
|
+
// tool+path (exact/glob match — substring `.includes('.env')` is NOT enough).
|
|
90
|
+
const maybePath = typeof call.input.path === 'string' ? String(call.input.path) : null;
|
|
91
|
+
if (maybePath !== null && isEnvPath(maybePath)) {
|
|
92
|
+
const allowed = isExplicitlyAllowedForPath(call.name, maybePath, ctx.config.allow);
|
|
93
|
+
if (!allowed) {
|
|
94
|
+
return { action: 'deny', reason: 'write to .env denied by policy — add to allow list or use --yolo' };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// apply_patch carries paths inside the patch text, not `input.path`.
|
|
98
|
+
if (call.name === 'apply_patch' && typeof call.input.patch === 'string') {
|
|
99
|
+
const targets = extractPatchPaths(String(call.input.patch)).filter(isEnvPath);
|
|
100
|
+
if (targets.length > 0) {
|
|
101
|
+
const allAllowed = targets.every((t) => isExplicitlyAllowedForPath(call.name, t, ctx.config.allow));
|
|
102
|
+
if (!allAllowed) {
|
|
90
103
|
return { action: 'deny', reason: 'write to .env denied by policy — add to allow list or use --yolo' };
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
106
|
}
|
|
107
|
+
// Shell redirection into .env (e.g. `echo x > .env`, `cmd >> .env.local`).
|
|
108
|
+
if (call.name === 'shell_exec' && typeof call.input.command === 'string') {
|
|
109
|
+
const cmd = String(call.input.command);
|
|
110
|
+
if (/>+\s*['"]?[^'"\s]*\.env/i.test(cmd)) {
|
|
111
|
+
return { action: 'deny', reason: 'write to .env via shell redirection denied' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
94
114
|
for (const rule of this.rules) {
|
|
95
115
|
const d = rule.evaluate(call, ctx);
|
|
96
116
|
if (d)
|
|
@@ -157,7 +177,32 @@ export function builtinRules() {
|
|
|
157
177
|
function asString(v) {
|
|
158
178
|
return typeof v === 'string' ? v : '';
|
|
159
179
|
}
|
|
160
|
-
function
|
|
180
|
+
function isEnvPath(p) {
|
|
181
|
+
const lower = p.toLowerCase();
|
|
182
|
+
// Sample/template carve-out: `.env.example`-style files are documentation,
|
|
183
|
+
// not secrets — reads AND writes are allowed.
|
|
184
|
+
if (/[.](example|sample|template|dist)$/.test(lower))
|
|
185
|
+
return false;
|
|
186
|
+
return /(^|\/)\.env(\.|$)/.test(lower) || lower.endsWith('.env');
|
|
187
|
+
}
|
|
188
|
+
function extractPatchPaths(patch) {
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const line of patch.split('\n')) {
|
|
191
|
+
if (line.startsWith('*** Update File:'))
|
|
192
|
+
out.push(line.replace('*** Update File:', '').trim());
|
|
193
|
+
else if (line.startsWith('*** Add File:'))
|
|
194
|
+
out.push(line.replace('*** Add File:', '').trim());
|
|
195
|
+
}
|
|
196
|
+
return out.filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
/** True when an allow rule explicitly names this tool+path (exact/glob match, not substring). */
|
|
199
|
+
function isExplicitlyAllowedForPath(callName, envPath, allow) {
|
|
200
|
+
if (!allow || allow.length === 0)
|
|
201
|
+
return false;
|
|
202
|
+
const synthetic = { name: callName, input: { path: envPath } };
|
|
203
|
+
return allow.some((r) => matchesGlobRule(synthetic, r));
|
|
204
|
+
}
|
|
205
|
+
export function matchesGlobRule(call, rule) {
|
|
161
206
|
// Rule grammar: tool or tool(glob). e.g. "write_file", "write_file(.env)", "shell_exec(npm *)".
|
|
162
207
|
// Digits are allowed so generated names (e.g. mcp__server__tool2) can be targeted.
|
|
163
208
|
const m = /^([a-z0-9_]+)(?:\((.*)\))?$/.exec(rule.trim());
|
|
@@ -185,6 +230,48 @@ function startsWithAny(haystack, needles) {
|
|
|
185
230
|
return h === nl || h.startsWith(nl + ' ') || h.startsWith(nl + '\t');
|
|
186
231
|
});
|
|
187
232
|
}
|
|
233
|
+
/** Branches that `git push` must never target without an explicit opt-out. */
|
|
234
|
+
export const PROTECTED_BRANCHES = ['main', 'master', 'production'];
|
|
235
|
+
/** Matches `push` with a protected branch name later on the same line. */
|
|
236
|
+
export const PROTECTED_PUSH_RE = /\bpush\b[^\n]*\b(main|master|production)\b/;
|
|
237
|
+
const GIT_PUSH_RE = /\bgit\b[^\n]*\bpush\b/;
|
|
238
|
+
/**
|
|
239
|
+
* True when the command is a `git push` with NO ref/positional args
|
|
240
|
+
* (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
|
|
241
|
+
* out. `git push origin feature` is explicit, not bare.
|
|
242
|
+
*/
|
|
243
|
+
export function isBareGitPush(cmd) {
|
|
244
|
+
if (!GIT_PUSH_RE.test(cmd))
|
|
245
|
+
return false;
|
|
246
|
+
const idx = cmd.search(/\bpush\b/);
|
|
247
|
+
const rest = idx >= 0 ? cmd.slice(idx + 4) : '';
|
|
248
|
+
const segment = (rest.split(/[;&|]/)[0] ?? '').split(/\n/)[0] ?? '';
|
|
249
|
+
const tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
250
|
+
const positionals = tokens.filter((t) => {
|
|
251
|
+
const unquoted = t.replace(/^["']|["']$/g, '');
|
|
252
|
+
return unquoted.length > 0 && !unquoted.startsWith('-');
|
|
253
|
+
});
|
|
254
|
+
return positionals.length === 0;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Resolve the currently checked-out branch via `git branch --show-current`.
|
|
258
|
+
* Returns null on any failure (not a repo, git missing) — callers treat
|
|
259
|
+
* null as "unknown" and allow the normal policy flow to continue.
|
|
260
|
+
*/
|
|
261
|
+
export function currentGitBranch(cwd) {
|
|
262
|
+
try {
|
|
263
|
+
const out = execFileSync('git', ['-C', cwd, 'branch', '--show-current'], {
|
|
264
|
+
timeout: 3000,
|
|
265
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
266
|
+
encoding: 'utf-8',
|
|
267
|
+
});
|
|
268
|
+
const branch = String(out).trim();
|
|
269
|
+
return branch.length > 0 ? branch : null;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
188
275
|
/** Hard-deny for obviously destructive shell patterns. */
|
|
189
276
|
export const shellDenyRule = {
|
|
190
277
|
name: 'shell-deny',
|
|
@@ -204,6 +291,71 @@ export const shellDenyRule = {
|
|
|
204
291
|
if (/\b(curl|wget|fetch|iwr|Invoke-WebRequest)\b.*\|\s*(sh|bash|zsh|cmd|powershell|pwsh|node|node\s+-e)\b/i.test(cmd)) {
|
|
205
292
|
return { action: 'deny', reason: 'pipe-to-shell: remote download piped into an interpreter' };
|
|
206
293
|
}
|
|
294
|
+
// Exfiltration denylist (mirrors shell_exec DANGEROUS_PATTERNS).
|
|
295
|
+
if (/\bcurl\b.*(?:\s-d\b|\s--data(?:-binary|-urlencode)?\b|\s--upload-file\b)/i.test(cmd)) {
|
|
296
|
+
return { action: 'deny', reason: 'exfiltration: curl with --data/-d/--upload-file denied' };
|
|
297
|
+
}
|
|
298
|
+
if (/\bwget\b.*--post-(data|file)\b/i.test(cmd)) {
|
|
299
|
+
return { action: 'deny', reason: 'exfiltration: wget --post-data/--post-file denied' };
|
|
300
|
+
}
|
|
301
|
+
if (/\b(powershell|pwsh)\b.*-e(nc|ncodedcommand)\b/i.test(cmd)) {
|
|
302
|
+
return { action: 'deny', reason: 'exfiltration: powershell -enc denied' };
|
|
303
|
+
}
|
|
304
|
+
if (/\bInvoke-Expression\b/i.test(cmd) || /\biex\s*\(/i.test(cmd)) {
|
|
305
|
+
return { action: 'deny', reason: 'exfiltration: Invoke-Expression/iex denied' };
|
|
306
|
+
}
|
|
307
|
+
if (/\bcertutil\b.*-urlcache\b/i.test(cmd)) {
|
|
308
|
+
return { action: 'deny', reason: 'exfiltration: certutil -urlcache denied' };
|
|
309
|
+
}
|
|
310
|
+
if (/\bbitsadmin\b.*\/transfer\b/i.test(cmd)) {
|
|
311
|
+
return { action: 'deny', reason: 'exfiltration: bitsadmin /transfer denied' };
|
|
312
|
+
}
|
|
313
|
+
// .env writes via tee / PowerShell (mirrors shell_exec DANGEROUS_PATTERNS).
|
|
314
|
+
if (/\|\s*tee\b[^\n]*\.env/i.test(cmd)) {
|
|
315
|
+
return { action: 'deny', reason: 'write to .env via tee denied' };
|
|
316
|
+
}
|
|
317
|
+
if (/\b(Set-Content|Out-File)\b[^\n]*\.env/i.test(cmd)) {
|
|
318
|
+
return { action: 'deny', reason: 'write to .env via Set-Content/Out-File denied' };
|
|
319
|
+
}
|
|
320
|
+
// Shell-redirection containment: deny `>` / `>>` into dotfiles.
|
|
321
|
+
// Home/abs dotfile target (e.g. `> ~/.klyro/mcp.json`, `> /home/u/.config/x`).
|
|
322
|
+
// The `(?<![0-9])` guard excludes the `2>` stderr-redirect prefix.
|
|
323
|
+
if (/(?<![0-9])>+\s*["']?(~|\/)[^"'\s]*\/\.[^"'\s]+/.test(cmd)) {
|
|
324
|
+
return { action: 'deny', reason: 'redirect into dotfile under home/abs path denied' };
|
|
325
|
+
}
|
|
326
|
+
// Bare project dotfile target (e.g. `> .mcp.json`, `>> .env.local`).
|
|
327
|
+
if (/(?<![0-9])>+\s*["']?\.[^"'\s\/][^"'\s]*/.test(cmd)) {
|
|
328
|
+
return { action: 'deny', reason: 'redirect into project dotfile denied' };
|
|
329
|
+
}
|
|
330
|
+
// Upload-form exfiltration (mirrors shell_exec DANGEROUS_PATTERNS).
|
|
331
|
+
if (/\bcurl\b.*(?:\s-F\b|\s--form\b)/i.test(cmd)) {
|
|
332
|
+
return { action: 'deny', reason: 'exfiltration: curl -F/--form denied' };
|
|
333
|
+
}
|
|
334
|
+
if (/\bwget\b.*(?:\s--method=POST\b|\s--body-data\b)/i.test(cmd)) {
|
|
335
|
+
return { action: 'deny', reason: 'exfiltration: wget --method=POST/--body-data denied' };
|
|
336
|
+
}
|
|
337
|
+
if (/\bInvoke-RestMethod\b/i.test(cmd)) {
|
|
338
|
+
return { action: 'deny', reason: 'exfiltration: Invoke-RestMethod denied' };
|
|
339
|
+
}
|
|
340
|
+
if (/\bStart-BitsTransfer\b/i.test(cmd)) {
|
|
341
|
+
return { action: 'deny', reason: 'exfiltration: Start-BitsTransfer denied' };
|
|
342
|
+
}
|
|
343
|
+
// Protected-branch push deny (mirrors shell_exec DANGEROUS_PATTERNS).
|
|
344
|
+
// Explicit `git push ... main|master|production` is denied outright;
|
|
345
|
+
// a bare `git push` (no ref args) is denied when the checkout is on a
|
|
346
|
+
// protected branch. Escape hatch: KLYRO_ALLOW_MAIN_PUSH=1.
|
|
347
|
+
if (process.env.KLYRO_ALLOW_MAIN_PUSH !== '1') {
|
|
348
|
+
if (PROTECTED_PUSH_RE.test(cmd)) {
|
|
349
|
+
return { action: 'deny', reason: 'protected-branch push denied (main/master/production) — set KLYRO_ALLOW_MAIN_PUSH=1 to override' };
|
|
350
|
+
}
|
|
351
|
+
if (isBareGitPush(cmd)) {
|
|
352
|
+
const branch = currentGitBranch(ctx.cwd);
|
|
353
|
+
if (branch !== null && PROTECTED_BRANCHES.includes(branch)) {
|
|
354
|
+
return { action: 'deny', reason: `bare git push on protected branch '${branch}' denied — set KLYRO_ALLOW_MAIN_PUSH=1 to override` };
|
|
355
|
+
}
|
|
356
|
+
// currentGitBranch null (not a repo / git missing) → allow normal flow.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
207
359
|
return null;
|
|
208
360
|
},
|
|
209
361
|
};
|
|
@@ -303,4 +455,5 @@ export async function evaluatePolicy(engine, call, ctx) {
|
|
|
303
455
|
// policy/ depending only on tools/normalize, no other tool code.)
|
|
304
456
|
import * as path from 'node:path';
|
|
305
457
|
import * as fsSync from 'node:fs';
|
|
458
|
+
import { execFileSync } from 'node:child_process';
|
|
306
459
|
import { resolveWithinCwd } from './path-guard.js';
|
|
@@ -28,3 +28,27 @@ export declare function resolveWithinCwd(cwd: string, requested: string): PathGu
|
|
|
28
28
|
* Use this for read_file / write_file to defeat symlink-based escapes.
|
|
29
29
|
*/
|
|
30
30
|
export declare function resolveAndFollowSymlinks(cwd: string, requested: string): Promise<PathGuardResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Symlink-swap guard: refuse when the FINAL path itself is a symlink.
|
|
33
|
+
*
|
|
34
|
+
* File tools resolve symlinks up front, but a racing swap between that
|
|
35
|
+
* check and the final write/rename could redirect an open-with-O_TRUNC /
|
|
36
|
+
* O_CREAT (or rename) through a freshly-planted symlink. Calling this
|
|
37
|
+
* immediately before the final write/rename shrinks that window to ~0:
|
|
38
|
+
* `lstat` never follows the final component, so a swapped-in link is
|
|
39
|
+
* caught. (POSIX `rename(2)` onto a symlink replaces the LINK itself, so
|
|
40
|
+
* the vulnerable op is opening/truncating through the link — which this
|
|
41
|
+
* denies first.) Supported on POSIX and Windows (both implement lstat).
|
|
42
|
+
* Missing paths (ENOENT) are fine — a nonexistent path is not a symlink.
|
|
43
|
+
*/
|
|
44
|
+
export declare function assertNotSymlink(p: string): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Open a file for writing without following a trailing symlink (POSIX).
|
|
47
|
+
*
|
|
48
|
+
* Uses O_WRONLY | O_CREAT | O_NOFOLLOW so the open itself fails (ELOOP)
|
|
49
|
+
* when the final component is a symlink, instead of truncating through
|
|
50
|
+
* it. On Windows there is no O_NOFOLLOW, so this falls back to a plain
|
|
51
|
+
* `'w'` open — symlink-swap protection there comes from
|
|
52
|
+
* `assertNotSymlink` (lstat) immediately before the write/rename instead.
|
|
53
|
+
*/
|
|
54
|
+
export declare function openNoFollowForWrite(dir: string, file: string): number;
|