klyro 1.0.6 → 1.0.7

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.
Files changed (58) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/orchestrator.d.ts +7 -1
  3. package/dist/agent/orchestrator.js +18 -7
  4. package/dist/agent/runtime.d.ts +10 -0
  5. package/dist/agent/runtime.js +70 -13
  6. package/dist/chat.d.ts +10 -0
  7. package/dist/chat.js +39 -7
  8. package/dist/checkpoints/store.d.ts +2 -0
  9. package/dist/checkpoints/store.js +12 -0
  10. package/dist/cli/auth.d.ts +10 -3
  11. package/dist/cli/auth.js +43 -5
  12. package/dist/cli/doctor.js +0 -1
  13. package/dist/cli/eval.js +22 -16
  14. package/dist/cli/hooks.d.ts +21 -1
  15. package/dist/cli/hooks.js +34 -2
  16. package/dist/cli/keychain.d.ts +10 -0
  17. package/dist/cli/keychain.js +86 -0
  18. package/dist/cli/repl.js +54 -17
  19. package/dist/cli/setup.js +3 -2
  20. package/dist/cli/slash/parser.d.ts +1 -1
  21. package/dist/cli/slash/parser.js +6 -3
  22. package/dist/cli/update.d.ts +3 -1
  23. package/dist/cli/update.js +16 -1
  24. package/dist/context/accounting.d.ts +6 -0
  25. package/dist/context/accounting.js +8 -2
  26. package/dist/context/compaction.d.ts +2 -1
  27. package/dist/context/compaction.js +39 -12
  28. package/dist/context/memory.d.ts +11 -0
  29. package/dist/context/memory.js +47 -9
  30. package/dist/eval/harness.d.ts +19 -2
  31. package/dist/eval/harness.js +72 -7
  32. package/dist/index.js +90 -4
  33. package/dist/mcp/auth.d.ts +85 -0
  34. package/dist/mcp/auth.js +249 -0
  35. package/dist/mcp/config.d.ts +28 -0
  36. package/dist/mcp/config.js +65 -0
  37. package/dist/mcp/registry.d.ts +13 -7
  38. package/dist/mcp/registry.js +90 -14
  39. package/dist/mcp/remote.d.ts +7 -0
  40. package/dist/mcp/remote.js +56 -2
  41. package/dist/mcp/sse.d.ts +42 -0
  42. package/dist/mcp/sse.js +310 -0
  43. package/dist/persistence/audit.d.ts +15 -3
  44. package/dist/persistence/audit.js +84 -13
  45. package/dist/persistence/store.d.ts +9 -0
  46. package/dist/persistence/store.js +17 -0
  47. package/dist/policy/engine.js +9 -0
  48. package/dist/providers.js +4 -4
  49. package/dist/tools/shell/shell-exec.d.ts +13 -0
  50. package/dist/tools/shell/shell-exec.js +64 -2
  51. package/dist/tui/app.js +35 -5
  52. package/dist/tui/app.test.js +3 -2
  53. package/dist/tui/approval.js +3 -1
  54. package/dist/tui/scroll-model.d.ts +2 -2
  55. package/dist/tui/scroll-model.js +9 -3
  56. package/dist/tui/tokens.d.ts +8 -11
  57. package/dist/tui/tokens.js +18 -11
  58. package/package.json +1 -1
@@ -1,6 +1,17 @@
1
1
  import { McpError } from './client.js';
2
+ import { AuthError, mcpTokenCacheKey, resolveAuth } from './auth.js';
2
3
  const PROTOCOL_VERSION = '2024-11-05';
3
4
  const DEFAULT_TIMEOUT_MS = 30_000;
5
+ /** Loopback check shared with the SSE transport guard. */
6
+ export function isLoopback(url) {
7
+ try {
8
+ const h = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, '');
9
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0' || h === '::' || h.startsWith('127.');
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
4
15
  /** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
5
16
  export function parseSseBody(text) {
6
17
  const out = [];
@@ -24,15 +35,58 @@ export class RemoteMcpClient {
24
35
  nextId = 1;
25
36
  headers;
26
37
  timeoutMs;
38
+ authHeader = null;
27
39
  constructor(name, spec) {
28
40
  this.name = name;
29
41
  this.spec = spec;
30
42
  if (!spec.url)
31
43
  throw new McpError(`mcp server "${name}" has no url`, 'INVALID_SPEC');
44
+ // Fail-closed: refuse plaintext remote endpoints even if a spec bypassed
45
+ // schema validation (defense-in-depth; schema already rejects insecure http).
46
+ if (spec.url.startsWith('http:') && !isLoopback(spec.url) && process.env.KLYRO_ALLOW_INSECURE_MCP !== '1') {
47
+ throw new McpError(`insecure MCP server url (plain HTTP) for host ${new URL(spec.url).hostname} — use https:// or a loopback URL, or set KLYRO_ALLOW_INSECURE_MCP=1`, 'INVALID_SPEC');
48
+ }
32
49
  this.headers = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...(spec.headers ?? {}) };
33
50
  this.timeoutMs = spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
34
51
  }
52
+ /** B2: resolve OAuth bearer token; returns true when usable headers exist. */
53
+ async ensureAuth() {
54
+ if (!this.spec.auth || !this.spec.url)
55
+ return false;
56
+ if (this.authHeader)
57
+ return true;
58
+ const url = this.spec.url;
59
+ const cacheKey = mcpTokenCacheKey(url, this.spec.auth.clientId);
60
+ try {
61
+ const result = await resolveAuth(this.spec.auth, cacheKey);
62
+ if (result.headers['Authorization']) {
63
+ this.authHeader = result.headers['Authorization'];
64
+ return true;
65
+ }
66
+ }
67
+ catch (err) {
68
+ if (err instanceof AuthError)
69
+ throw err;
70
+ throw new AuthError(`auth flow failed: ${err instanceof Error ? err.message : String(err)}`);
71
+ }
72
+ return false;
73
+ }
74
+ /** Base headers merged with the OAuth bearer token (never mutates base). */
75
+ headersFor() {
76
+ return this.authHeader ? { ...this.headers, Authorization: this.authHeader } : this.headers;
77
+ }
35
78
  async connect() {
79
+ // B2: resolve OAuth/pkce bearer token (reuses cache, else interactive
80
+ // flow) and merge `Authorization` before the handshake. Non-fatal if no
81
+ // auth is configured.
82
+ if (this.spec.auth) {
83
+ try {
84
+ await this.ensureAuth();
85
+ }
86
+ catch (err) {
87
+ throw new McpError(`mcp server "${this.name}" auth failed: ${err instanceof Error ? err.message : String(err)}`, 'AUTH_ERROR');
88
+ }
89
+ }
36
90
  // Handshake doubles as reachability probe; failures surface typed errors.
37
91
  await this.request('initialize', {
38
92
  protocolVersion: PROTOCOL_VERSION,
@@ -43,7 +97,7 @@ export class RemoteMcpClient {
43
97
  try {
44
98
  await fetch(this.spec.url, {
45
99
  method: 'POST',
46
- headers: this.headers,
100
+ headers: this.headersFor(),
47
101
  body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
48
102
  signal: AbortSignal.timeout(5000),
49
103
  });
@@ -103,7 +157,7 @@ export class RemoteMcpClient {
103
157
  try {
104
158
  const res = await fetch(this.spec.url, {
105
159
  method: 'POST',
106
- headers: this.headers,
160
+ headers: this.headersFor(),
107
161
  body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
108
162
  signal: ctrl.signal,
109
163
  });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Legacy HTTP+SSE MCP transport (MCP 2024-11-05 shape).
3
+ *
4
+ * Distinct from Streamable HTTP (`remote.ts`): the client opens a persistent
5
+ * GET event stream, waits for the `endpoint` event carrying the message POST
6
+ * URI, then POSTs each JSON-RPC request and matches responses by id off the
7
+ * shared stream. Used when `transport: 'sse'` is set on a `url` spec.
8
+ */
9
+ import type { McpServerSpec } from './config.js';
10
+ import { type McpClientLike, type McpToolDef, type McpCallResult, type McpResource, type McpPromptDef } from './client.js';
11
+ /** Loopback check for the SSE constructor guard. */
12
+ export declare function isLoopback(url: string): boolean;
13
+ export declare class SseMcpClient implements McpClientLike {
14
+ readonly name: string;
15
+ private readonly spec;
16
+ private nextId;
17
+ private readonly pending;
18
+ private readonly headers;
19
+ private readonly timeoutMs;
20
+ private endpoint;
21
+ private streamAbort;
22
+ private pumpDone;
23
+ private dead;
24
+ private closed;
25
+ private authHeader;
26
+ constructor(name: string, spec: McpServerSpec);
27
+ /** Base + OAuth authorization header (merged per-request, never mutates base). */
28
+ private authHeadersFor;
29
+ /** B2: resolve OAuth bearer token; true when request headers carry it. */
30
+ private ensureAuth;
31
+ connect(): Promise<void>;
32
+ private pump;
33
+ private dispatch;
34
+ private failAll;
35
+ private request;
36
+ listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
37
+ callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
38
+ listResources(signal?: AbortSignal): Promise<McpResource[]>;
39
+ promptsList(signal?: AbortSignal): Promise<McpPromptDef[]>;
40
+ promptsGet(name: string, args?: Record<string, string>, signal?: AbortSignal): Promise<string>;
41
+ close(): Promise<void>;
42
+ }
@@ -0,0 +1,310 @@
1
+ import { McpError } from './client.js';
2
+ import { AuthError, mcpTokenCacheKey, resolveAuth } from './auth.js';
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+ const CONNECT_TIMEOUT_MS = 15_000;
5
+ /** Loopback check for the SSE constructor guard. */
6
+ export function isLoopback(url) {
7
+ try {
8
+ const h = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, '');
9
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0' || h === '::' || h.startsWith('127.');
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ export class SseMcpClient {
16
+ name;
17
+ spec;
18
+ nextId = 1;
19
+ pending = new Map();
20
+ headers;
21
+ timeoutMs;
22
+ endpoint = null;
23
+ streamAbort = null;
24
+ pumpDone = null;
25
+ dead = null;
26
+ closed = false;
27
+ authHeader = null;
28
+ constructor(name, spec) {
29
+ this.name = name;
30
+ this.spec = spec;
31
+ if (!spec.url)
32
+ throw new McpError(`mcp server "${name}" has no url`, 'INVALID_SPEC');
33
+ // Fail-closed: refuse plaintext remote endpoints (defense-in-depth; the
34
+ // spec schema already rejects insecure remote http URLs at load time).
35
+ if (spec.url.startsWith('http:') && !isLoopback(spec.url) && process.env.KLYRO_ALLOW_INSECURE_MCP !== '1') {
36
+ throw new McpError(`insecure MCP server url (plain HTTP) for host ${new URL(spec.url).hostname} — use https:// or a loopback URL, or set KLYRO_ALLOW_INSECURE_MCP=1`, 'INVALID_SPEC');
37
+ }
38
+ this.headers = { ...(spec.headers ?? {}) };
39
+ this.timeoutMs = spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
40
+ }
41
+ /** Base + OAuth authorization header (merged per-request, never mutates base). */
42
+ authHeadersFor() {
43
+ return this.authHeader ? { ...this.headers, Authorization: this.authHeader } : this.headers;
44
+ }
45
+ /** B2: resolve OAuth bearer token; true when request headers carry it. */
46
+ async ensureAuth() {
47
+ if (!this.spec.auth || !this.spec.url)
48
+ return false;
49
+ if (this.authHeader)
50
+ return true;
51
+ try {
52
+ const result = await resolveAuth(this.spec.auth, mcpTokenCacheKey(this.spec.url, this.spec.auth.clientId));
53
+ if (result.headers['Authorization']) {
54
+ this.authHeader = result.headers['Authorization'];
55
+ return true;
56
+ }
57
+ }
58
+ catch (err) {
59
+ if (err instanceof AuthError)
60
+ throw err;
61
+ throw new AuthError(`auth flow failed: ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+ return false;
64
+ }
65
+ async connect() {
66
+ if (this.endpoint)
67
+ return;
68
+ if (this.closed)
69
+ throw new McpError(`mcp server "${this.name}" is closed`, 'CLOSED');
70
+ // B2: resolve OAuth bearer token before the GET stream handshake.
71
+ if (this.spec.auth && (await this.ensureAuth()) === false) {
72
+ throw new McpError(`mcp server "${this.name}" auth produced no token`, 'AUTH_ERROR');
73
+ }
74
+ this.streamAbort = new AbortController();
75
+ const endpointP = new Promise((resolve, reject) => {
76
+ const timer = setTimeout(() => reject(new McpError(`mcp server "${this.name}" SSE endpoint timeout`, 'TIMEOUT')), CONNECT_TIMEOUT_MS);
77
+ this.pumpDone = this.pump(timer, resolve, reject).catch(() => undefined);
78
+ });
79
+ this.endpoint = await endpointP;
80
+ // Standard initialize handshake over the discovered message endpoint.
81
+ await this.request('initialize', {
82
+ protocolVersion: '2024-11-05',
83
+ capabilities: {},
84
+ clientInfo: { name: 'klyro', version: 'sse' },
85
+ });
86
+ }
87
+ async pump(timer, onEndpoint, onFatal) {
88
+ let buf = '';
89
+ let eventType = '';
90
+ let gotEndpoint = false;
91
+ try {
92
+ const res = await fetch(this.spec.url, {
93
+ headers: { ...this.authHeadersFor(), Accept: 'text/event-stream' },
94
+ signal: this.streamAbort.signal,
95
+ });
96
+ if (!res.ok || !res.body) {
97
+ throw new McpError(`mcp server "${this.name}" SSE GET failed: HTTP ${res.status}`, 'HTTP_ERROR', { status: res.status });
98
+ }
99
+ const reader = res.body.getReader();
100
+ const decoder = new TextDecoder();
101
+ for (;;) {
102
+ const { done, value } = await reader.read();
103
+ if (done)
104
+ break;
105
+ buf += decoder.decode(value, { stream: true });
106
+ let idx;
107
+ while ((idx = buf.indexOf('\n')) !== -1) {
108
+ const line = buf.slice(0, idx).replace(/\r$/, '');
109
+ buf = buf.slice(idx + 1);
110
+ if (line === '') {
111
+ eventType = '';
112
+ continue;
113
+ }
114
+ if (line.startsWith('event:')) {
115
+ eventType = line.slice(6).trim();
116
+ continue;
117
+ }
118
+ if (!line.startsWith('data:'))
119
+ continue;
120
+ const data = line.slice(5).trim();
121
+ if (eventType === 'endpoint' && !gotEndpoint) {
122
+ gotEndpoint = true;
123
+ clearTimeout(timer);
124
+ try {
125
+ onEndpoint(new URL(data, this.spec.url).toString());
126
+ }
127
+ catch {
128
+ onFatal(new McpError(`mcp server "${this.name}" sent a bad endpoint URI`, 'PROTOCOL_ERROR'));
129
+ return;
130
+ }
131
+ eventType = '';
132
+ continue;
133
+ }
134
+ let msg = null;
135
+ try {
136
+ msg = JSON.parse(data);
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ this.dispatch(msg);
142
+ eventType = '';
143
+ }
144
+ }
145
+ }
146
+ catch (err) {
147
+ if (!gotEndpoint) {
148
+ clearTimeout(timer);
149
+ onFatal(err instanceof McpError ? err : new McpError(`mcp server "${this.name}" SSE stream failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR'));
150
+ return;
151
+ }
152
+ this.failAll(new McpError(`mcp server "${this.name}" SSE stream closed`, 'SERVER_EXITED'));
153
+ }
154
+ }
155
+ dispatch(msg) {
156
+ if (!msg || typeof msg !== 'object')
157
+ return;
158
+ const m = msg;
159
+ if (typeof m.id !== 'number')
160
+ return; // notifications are ignored
161
+ const p = this.pending.get(m.id);
162
+ if (!p)
163
+ return;
164
+ this.pending.delete(m.id);
165
+ clearTimeout(p.timer);
166
+ clearTimeout(p.timer);
167
+ if (m.error)
168
+ p.reject(new McpError(`mcp server "${this.name}" error: ${m.error.message ?? 'unknown'}`, 'SERVER_ERROR', m.error));
169
+ else
170
+ p.resolve(m.result ?? {});
171
+ }
172
+ failAll(err) {
173
+ if (!this.dead)
174
+ this.dead = err;
175
+ for (const [id, p] of [...this.pending]) {
176
+ this.pending.delete(id);
177
+ clearTimeout(p.timer);
178
+ p.reject(err);
179
+ }
180
+ }
181
+ async request(method, params, signal) {
182
+ if (this.closed)
183
+ throw new McpError(`mcp server "${this.name}" is closed`, 'CLOSED');
184
+ if (this.dead)
185
+ throw this.dead;
186
+ if (!this.endpoint)
187
+ throw new McpError(`mcp server "${this.name}" not connected`, 'NOT_CONNECTED');
188
+ if (signal?.aborted)
189
+ throw new McpError(`mcp call "${method}" aborted`, 'ABORTED');
190
+ const id = this.nextId++;
191
+ const drop = () => {
192
+ const p = this.pending.get(id);
193
+ if (p) {
194
+ this.pending.delete(id);
195
+ clearTimeout(p.timer);
196
+ }
197
+ };
198
+ const out = new Promise((resolve, reject) => {
199
+ const timer = setTimeout(() => {
200
+ this.pending.delete(id);
201
+ reject(new McpError(`mcp call "${method}" timed out after ${this.timeoutMs}ms`, 'TIMEOUT'));
202
+ }, this.timeoutMs);
203
+ this.pending.set(id, { resolve, reject, timer });
204
+ signal?.addEventListener('abort', () => {
205
+ if (!this.pending.has(id))
206
+ return;
207
+ this.pending.delete(id);
208
+ clearTimeout(timer);
209
+ reject(new McpError(`mcp call "${method}" aborted`, 'ABORTED'));
210
+ }, { once: true });
211
+ });
212
+ try {
213
+ const res = await fetch(this.endpoint, {
214
+ method: 'POST',
215
+ headers: { ...this.authHeadersFor(), 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
216
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
217
+ signal: signal ?? undefined,
218
+ });
219
+ if (!res.ok && res.status !== 202) {
220
+ const text = await res.text().catch(() => '');
221
+ drop();
222
+ throw new McpError(`mcp server "${this.name}" HTTP ${res.status}: ${text.slice(0, 300)}`, 'HTTP_ERROR', { status: res.status });
223
+ }
224
+ // 202 Accepted with no body can precede the response on the stream.
225
+ if (res.status === 202) {
226
+ const text = await res.text().catch(() => '');
227
+ if (text.trim()) {
228
+ try {
229
+ const body = JSON.parse(text);
230
+ if (body && typeof body === 'object' && 'error' in body && body.error) {
231
+ drop();
232
+ throw new McpError(`mcp server "${this.name}" error`, 'SERVER_ERROR', body.error);
233
+ }
234
+ if (body && typeof body === 'object' && 'result' in body) {
235
+ drop();
236
+ return body.result;
237
+ }
238
+ }
239
+ catch (err) {
240
+ if (err instanceof McpError)
241
+ throw err;
242
+ // Not inline JSON — fall through to stream matching.
243
+ }
244
+ }
245
+ }
246
+ }
247
+ catch (err) {
248
+ if (err instanceof McpError && err.code !== 'HTTP_ERROR')
249
+ throw err;
250
+ if (err instanceof McpError)
251
+ throw err;
252
+ drop();
253
+ throw new McpError(`mcp call "${method}" failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR');
254
+ }
255
+ return out;
256
+ }
257
+ async listTools(signal) {
258
+ const res = (await this.request('tools/list', {}, signal));
259
+ return Array.isArray(res.tools) ? res.tools : [];
260
+ }
261
+ async callTool(name, args, signal) {
262
+ const res = (await this.request('tools/call', { name, arguments: args ?? {} }, signal));
263
+ const blocks = Array.isArray(res.content) ? res.content : [];
264
+ const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
265
+ return { text, isError: res.isError === true, raw: res };
266
+ }
267
+ async listResources(signal) {
268
+ const res = (await this.request('resources/list', {}, signal));
269
+ return Array.isArray(res.resources) ? res.resources : [];
270
+ }
271
+ async promptsList(signal) {
272
+ try {
273
+ const res = (await this.request('prompts/list', {}, signal));
274
+ return Array.isArray(res.prompts) ? res.prompts : [];
275
+ }
276
+ catch (err) {
277
+ if (err instanceof McpError && err.code === 'SERVER_ERROR' && err.details?.code === -32601)
278
+ return [];
279
+ throw err;
280
+ }
281
+ }
282
+ async promptsGet(name, args, signal) {
283
+ const res = (await this.request('prompts/get', { name, arguments: args ?? {} }, signal));
284
+ const parts = [];
285
+ if (typeof res.description === 'string' && res.description)
286
+ parts.push(res.description);
287
+ for (const m of Array.isArray(res.messages) ? res.messages : []) {
288
+ const c = m.content;
289
+ if (c && typeof c.text === 'string')
290
+ parts.push(c.text);
291
+ else if (c !== undefined)
292
+ parts.push(JSON.stringify(c));
293
+ }
294
+ return parts.join('\n\n');
295
+ }
296
+ async close() {
297
+ if (this.closed)
298
+ return;
299
+ this.closed = true;
300
+ try {
301
+ this.streamAbort?.abort();
302
+ }
303
+ catch { /* ignore */ }
304
+ this.failAll(new McpError(`mcp server "${this.name}" closed`, 'CLOSED'));
305
+ try {
306
+ await this.pumpDone;
307
+ }
308
+ catch { /* ignore */ }
309
+ }
310
+ }
@@ -84,19 +84,31 @@ export declare function canonicalJson(value: unknown): string;
84
84
  export declare function sha256Hex(s: string): string;
85
85
  /** Hash of a chained record excluding its own `hash` field. */
86
86
  export declare function hashAuditRecord(record: Record<string, unknown>): string;
87
+ /** Default rotation bound: 1 MiB live segment, 5 rotated segments kept. */
88
+ export declare const AUDIT_MAX_BYTES = 1048576;
89
+ export declare const AUDIT_ROTATE_KEEP = 5;
87
90
  export declare class AuditLog {
88
91
  private readonly filePath;
92
+ private readonly maxBytes;
93
+ private readonly keepSegments;
89
94
  /** Serializes chained appends so concurrent writes can't fork the chain. */
90
95
  private chain;
91
- constructor(filePath: string);
96
+ /**
97
+ * Cached last-written hash: avoids re-reading the whole file (O(n) per
98
+ * write) on every append. Null = not loaded yet — first append reads the
99
+ * file (picks up an existing chain), then the cache takes over.
100
+ */
101
+ private lastHash;
102
+ constructor(filePath: string, maxBytes?: number, keepSegments?: number);
92
103
  write(event: AuditEvent): Promise<void>;
93
104
  private appendChained;
94
105
  }
95
106
  /**
96
- * Recompute the hash chain of a session JSONL file.
107
+ * Recompute the hash chain of a session JSONL file, including rotated
108
+ * segments (`.jsonl.1` … `.jsonl.keep` each verified as its own chain).
97
109
  * Sessions live at `<sessionsDir>/<sessionId>.jsonl` (see SessionStore).
98
110
  */
99
- export declare function verifyAuditChain(sessionsDir: string, sessionId: string): Promise<{
111
+ export declare function verifyAuditChain(sessionsDir: string, sessionId: string, keepSegments?: number): Promise<{
100
112
  ok: boolean;
101
113
  events: number;
102
114
  error?: string;
@@ -57,12 +57,56 @@ async function prevHashFor(filePath) {
57
57
  return sha256Hex(last);
58
58
  }
59
59
  }
60
+ /** Default rotation bound: 1 MiB live segment, 5 rotated segments kept. */
61
+ export const AUDIT_MAX_BYTES = 1_048_576;
62
+ export const AUDIT_ROTATE_KEEP = 5;
63
+ /**
64
+ * Rotate an audit log once it exceeds `maxBytes`: shift the newest segment
65
+ * over the oldest (deleted), leave a fresh live file. Each segment carries
66
+ * its own GENESIS-rooted chain, so rotation is tamper-evident per segment.
67
+ * Returns true when a rotation actually happened (the caller must reset its
68
+ * in-memory chain tip to GENESIS — the next live record starts a fresh
69
+ * chain). Best-effort — rotation failures never break appends.
70
+ */
71
+ async function rotateAuditLog(filePath, maxBytes, keep) {
72
+ try {
73
+ const stat = await fs.stat(filePath);
74
+ if (stat.size <= maxBytes)
75
+ return false;
76
+ try {
77
+ await fs.rm(`${filePath}.${keep}`, { force: true });
78
+ }
79
+ catch { /* best-effort */ }
80
+ for (let i = keep - 1; i >= 1; i--) {
81
+ try {
82
+ await fs.rename(`${filePath}.${i}`, `${filePath}.${i + 1}`);
83
+ }
84
+ catch { /* missing segment */ }
85
+ }
86
+ await fs.rename(filePath, `${filePath}.1`);
87
+ return true;
88
+ }
89
+ catch {
90
+ /* stat failure (no file yet) — nothing to rotate */
91
+ return false;
92
+ }
93
+ }
60
94
  export class AuditLog {
61
95
  filePath;
96
+ maxBytes;
97
+ keepSegments;
62
98
  /** Serializes chained appends so concurrent writes can't fork the chain. */
63
99
  chain = Promise.resolve();
64
- constructor(filePath) {
100
+ /**
101
+ * Cached last-written hash: avoids re-reading the whole file (O(n) per
102
+ * write) on every append. Null = not loaded yet — first append reads the
103
+ * file (picks up an existing chain), then the cache takes over.
104
+ */
105
+ lastHash = null;
106
+ constructor(filePath, maxBytes = AUDIT_MAX_BYTES, keepSegments = AUDIT_ROTATE_KEEP) {
65
107
  this.filePath = filePath;
108
+ this.maxBytes = maxBytes;
109
+ this.keepSegments = keepSegments;
66
110
  }
67
111
  async write(event) {
68
112
  const task = this.chain.then(() => this.appendChained(event));
@@ -71,24 +115,28 @@ export class AuditLog {
71
115
  return task;
72
116
  }
73
117
  async appendChained(event) {
74
- const prevHash = await prevHashFor(this.filePath);
118
+ // Rotation leaves a fresh live file and resets the in-memory tip: the
119
+ // next live record restarts at GENESIS (each segment is its own chain).
120
+ const rotated = await rotateAuditLog(this.filePath, this.maxBytes, this.keepSegments);
121
+ if (rotated)
122
+ this.lastHash = AUDIT_GENESIS;
123
+ if (this.lastHash === null)
124
+ this.lastHash = await prevHashFor(this.filePath);
125
+ const prevHash = this.lastHash;
75
126
  const body = { ...event, prevHash };
76
127
  const record = { ...body, prevHash, hash: sha256Hex(canonicalJson(body)) };
128
+ this.lastHash = record.hash;
77
129
  await SessionStore.appendJsonl(this.filePath, record);
78
130
  }
79
131
  }
80
- /**
81
- * Recompute the hash chain of a session JSONL file.
82
- * Sessions live at `<sessionsDir>/<sessionId>.jsonl` (see SessionStore).
83
- */
84
- export async function verifyAuditChain(sessionsDir, sessionId) {
85
- const filePath = path.join(sessionsDir, `${sessionId}.jsonl`);
132
+ /** Recompute one segment's chain; returns verified lines or a numbered error. */
133
+ async function verifySegment(filePath, segmentLabel) {
86
134
  let raw;
87
135
  try {
88
136
  raw = await fs.readFile(filePath, 'utf-8');
89
137
  }
90
138
  catch {
91
- return { ok: false, events: 0, error: `audit log not found: ${filePath}` };
139
+ return { events: 0, error: `${segmentLabel}: not found` };
92
140
  }
93
141
  const lines = raw.split('\n').filter((l) => l.trim());
94
142
  let expectedPrev = AUDIT_GENESIS;
@@ -100,16 +148,39 @@ export async function verifyAuditChain(sessionsDir, sessionId) {
100
148
  parsed = JSON.parse(line);
101
149
  }
102
150
  catch {
103
- return { ok: false, events, error: `line ${i + 1}: unparseable JSON` };
151
+ return { events, error: `${segmentLabel}:${i + 1}: unparseable JSON` };
104
152
  }
105
153
  if (parsed.prevHash !== expectedPrev) {
106
- return { ok: false, events, error: `line ${i + 1}: prevHash mismatch (chain fork or truncation)` };
154
+ return { events, error: `${segmentLabel}:${i + 1}: prevHash mismatch (chain fork or truncation)` };
107
155
  }
108
156
  if (typeof parsed.hash !== 'string' || parsed.hash !== hashAuditRecord(parsed)) {
109
- return { ok: false, events, error: `line ${i + 1}: hash mismatch (tampered record)` };
157
+ return { events, error: `${segmentLabel}:${i + 1}: hash mismatch (tampered record)` };
110
158
  }
111
159
  expectedPrev = parsed.hash;
112
160
  events++;
113
161
  }
114
- return { ok: true, events };
162
+ return { events };
163
+ }
164
+ /**
165
+ * Recompute the hash chain of a session JSONL file, including rotated
166
+ * segments (`.jsonl.1` … `.jsonl.keep` each verified as its own chain).
167
+ * Sessions live at `<sessionsDir>/<sessionId>.jsonl` (see SessionStore).
168
+ */
169
+ export async function verifyAuditChain(sessionsDir, sessionId, keepSegments = AUDIT_ROTATE_KEEP) {
170
+ let totalEvents = 0;
171
+ for (let i = keepSegments; i >= 1; i--) {
172
+ const label = `${sessionId}.jsonl.${i}`;
173
+ const seg = await verifySegment(path.join(sessionsDir, label), label);
174
+ if (seg.error) {
175
+ if (seg.error.endsWith(': not found'))
176
+ continue; // missing rotated segment is fine
177
+ return { ok: false, events: totalEvents, error: seg.error };
178
+ }
179
+ totalEvents += seg.events;
180
+ }
181
+ const live = await verifySegment(path.join(sessionsDir, `${sessionId}.jsonl`), `${sessionId}.jsonl`);
182
+ if (live.error)
183
+ return { ok: false, events: totalEvents, error: live.error };
184
+ totalEvents += live.events;
185
+ return { ok: true, events: totalEvents };
115
186
  }
@@ -74,6 +74,15 @@ export declare class SessionStore {
74
74
  appendObservation(id: string, obs: StoredObservation): Promise<void>;
75
75
  setStatus(id: string, status: SessionStatus, finalText?: string): Promise<void>;
76
76
  loadMessages(id: string): Promise<StoredMessage[]>;
77
+ /**
78
+ * Conversation rewind: drop messages (and observations) at or after
79
+ * `cutoffTs`, keeping everything older. Returns the removed counts.
80
+ * Used by `/rewind <n> full` to restore the conversation alongside code.
81
+ */
82
+ truncateMessages(id: string, cutoffTs: number): Promise<{
83
+ messages: number;
84
+ observations: number;
85
+ }>;
77
86
  loadObservations(id: string): Promise<StoredObservation[]>;
78
87
  list(filter?: {
79
88
  status?: SessionStatus;
@@ -316,6 +316,23 @@ export class SessionStore {
316
316
  const data = await this.readSession(id);
317
317
  return data.messages;
318
318
  }
319
+ /**
320
+ * Conversation rewind: drop messages (and observations) at or after
321
+ * `cutoffTs`, keeping everything older. Returns the removed counts.
322
+ * Used by `/rewind <n> full` to restore the conversation alongside code.
323
+ */
324
+ async truncateMessages(id, cutoffTs) {
325
+ return this.withLock(id, async () => {
326
+ const data = await this.readSession(id);
327
+ const keptMessages = data.messages.filter((m) => (typeof m.ts === 'number' ? m.ts : 0) < cutoffTs);
328
+ const keptObs = data.observations.filter((o) => (typeof o.startedAt === 'number' ? o.startedAt : 0) < cutoffTs);
329
+ const removed = { messages: data.messages.length - keptMessages.length, observations: data.observations.length - keptObs.length };
330
+ data.messages = keptMessages;
331
+ data.observations = keptObs;
332
+ await this.writeSession(id, data);
333
+ return removed;
334
+ });
335
+ }
319
336
  async loadObservations(id) {
320
337
  const data = await this.readSession(id);
321
338
  return data.observations;
@@ -353,6 +353,15 @@ export const shellDenyRule = {
353
353
  if (/\bStart-BitsTransfer\b/i.test(cmd)) {
354
354
  return { action: 'deny', reason: 'exfiltration: Start-BitsTransfer denied' };
355
355
  }
356
+ // Windows has no native sandbox backend, so PowerShell network primitives
357
+ // are the primary upload/exfil vectors there — denied outright (mirrors
358
+ // shell_exec DANGEROUS_PATTERNS).
359
+ if (/\b(Invoke-WebRequest|iwr)\b/i.test(cmd)) {
360
+ return { action: 'deny', reason: 'exfiltration: Invoke-WebRequest/iwr denied (no win32 sandbox backend)' };
361
+ }
362
+ if (/\bSystem\.Net\.WebClient\b/i.test(cmd)) {
363
+ return { action: 'deny', reason: 'exfiltration: System.Net.WebClient denied (no win32 sandbox backend)' };
364
+ }
356
365
  // Protected-branch push deny (mirrors shell_exec DANGEROUS_PATTERNS).
357
366
  // Explicit `git push ... main|master|production` is denied outright;
358
367
  // a bare `git push` (no ref args) is denied when the checkout is on a