klyro 1.0.1 → 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.
Files changed (50) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +13 -5
  2. package/dist/agent/anthropic-adapter.js +19 -2
  3. package/dist/agent/capabilities.js +7 -1
  4. package/dist/agent/orchestrator.d.ts +18 -1
  5. package/dist/agent/orchestrator.js +51 -4
  6. package/dist/agent/retry.js +52 -10
  7. package/dist/agent/runtime.d.ts +34 -0
  8. package/dist/agent/runtime.js +164 -14
  9. package/dist/agent/stream-budget.d.ts +36 -0
  10. package/dist/agent/stream-budget.js +121 -0
  11. package/dist/checkpoints/store.d.ts +9 -0
  12. package/dist/checkpoints/store.js +26 -0
  13. package/dist/cli/commit.d.ts +31 -0
  14. package/dist/cli/commit.js +142 -0
  15. package/dist/cli/config.d.ts +45 -0
  16. package/dist/cli/config.js +82 -0
  17. package/dist/cli/doctor.d.ts +1 -0
  18. package/dist/cli/doctor.js +71 -6
  19. package/dist/cli/hooks.d.ts +47 -0
  20. package/dist/cli/hooks.js +181 -0
  21. package/dist/cli/repl.js +41 -1
  22. package/dist/cli/run.d.ts +6 -0
  23. package/dist/cli/run.js +76 -3
  24. package/dist/events/catalog.d.ts +9 -0
  25. package/dist/events/catalog.js +9 -0
  26. package/dist/index.js +89 -5
  27. package/dist/mcp/client.js +1 -1
  28. package/dist/mcp/registry.d.ts +0 -18
  29. package/dist/mcp/registry.js +49 -2
  30. package/dist/policy/engine.d.ts +16 -0
  31. package/dist/policy/engine.js +74 -1
  32. package/dist/policy/path-guard.d.ts +24 -0
  33. package/dist/policy/path-guard.js +46 -0
  34. package/dist/providers/model-info.d.ts +6 -0
  35. package/dist/providers/model-info.js +8 -0
  36. package/dist/tools/fs/apply-patch.js +6 -1
  37. package/dist/tools/fs/edit-file.js +4 -1
  38. package/dist/tools/fs/multi-edit.js +4 -1
  39. package/dist/tools/fs/write-file.js +16 -6
  40. package/dist/tools/plan/todo-write.js +1 -1
  41. package/dist/tools/shell/shell-exec.d.ts +28 -0
  42. package/dist/tools/shell/shell-exec.js +87 -1
  43. package/dist/trace/writer.d.ts +7 -0
  44. package/dist/trace/writer.js +7 -0
  45. package/dist/verification/classify.js +4 -3
  46. package/dist/verification/engine.d.ts +8 -0
  47. package/dist/verification/engine.js +25 -0
  48. package/dist/verification/registry.js +16 -5
  49. package/dist/verification/scoped.js +36 -5
  50. package/package.json +1 -1
@@ -15,7 +15,18 @@
15
15
  * write-only metadata today), so we pick the most restrictive class,
16
16
  * 'admin' — the same class as `spawn_agent`, since MCP tools execute
17
17
  * arbitrary external side effects (read/write/network) outside our control.
18
+ *
19
+ * Debug capture: when `KLYRO_MCP_DEBUG=1` is set, every MCP tool success
20
+ * AND error ALSO writes the UNREDACTED raw JSON payload (pre-redaction,
21
+ * may contain secrets — handle accordingly) to
22
+ * `<configDir>/tool-output/mcp-<server>-<ts>.json` (mode 0600) and notes
23
+ * the path on stderr. `<configDir>` is `$KLYRO_CONFIG_DIR` when set,
24
+ * otherwise `~/.klyro`. Default off: with the flag unset (or any value
25
+ * other than `1`) no file is written and behaviour is unchanged.
18
26
  */
27
+ import * as fs from 'node:fs';
28
+ import * as os from 'node:os';
29
+ import * as path from 'node:path';
19
30
  import { McpClient, McpError } from './client.js';
20
31
  import { loadMcpServers } from './config.js';
21
32
  import { evaluateMcpPolicy } from './policy.js';
@@ -48,7 +59,39 @@ export function sanitizeMcpName(server, tool) {
48
59
  function errMessage(err) {
49
60
  return err instanceof Error ? err.message : String(err);
50
61
  }
51
- async function executeMcpTool(spec, toolDef, client, input, ctx) {
62
+ /** Debug-capture filename disambiguator when Date.now() collides. */
63
+ let mcpDebugCounter = 0;
64
+ /**
65
+ * `KLYRO_MCP_DEBUG=1` capture: write the UNREDACTED raw payload to
66
+ * `<configDir>/tool-output/mcp-<server>-<ts>.json` (mode 0600) + a stderr
67
+ * note. Best-effort and synchronous — never throws into the tool path.
68
+ */
69
+ function captureMcpDebug(server, tool, payload) {
70
+ if (process.env.KLYRO_MCP_DEBUG !== '1')
71
+ return;
72
+ try {
73
+ const base = process.env.KLYRO_CONFIG_DIR ?? path.join(os.homedir() || process.cwd(), '.klyro');
74
+ const dir = path.join(base, 'tool-output');
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ const safe = server.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 32) || 'server';
77
+ let file = path.join(dir, `mcp-${safe}-${Date.now()}.json`);
78
+ if (fs.existsSync(file)) {
79
+ mcpDebugCounter += 1;
80
+ file = path.join(dir, `mcp-${safe}-${Date.now()}-${mcpDebugCounter}.json`);
81
+ }
82
+ fs.writeFileSync(file, JSON.stringify({ server, tool, payload }, null, 2), { mode: 0o600 });
83
+ try {
84
+ process.stderr.write(`klyro: mcp debug captured ${server}/${tool} -> ${file}\n`);
85
+ }
86
+ catch {
87
+ /* ignore */
88
+ }
89
+ }
90
+ catch {
91
+ /* best-effort only */
92
+ }
93
+ }
94
+ async function executeMcpTool(server, spec, toolDef, client, input, ctx) {
52
95
  try {
53
96
  // (1) Deny-by-default — runs BEFORE any client I/O.
54
97
  const decision = evaluateMcpPolicy(spec.policy, toolDef.name);
@@ -67,16 +110,20 @@ async function executeMcpTool(spec, toolDef, client, input, ctx) {
67
110
  // (3) Typed server failures keep their code; everything else is TOOL_ERROR.
68
111
  // Redact BEFORE the message can reach the model/trace.
69
112
  if (err instanceof McpError) {
113
+ captureMcpDebug(server, toolDef.name, { code: err.code, message: err.message, details: err.details });
70
114
  return { ok: false, error: { code: err.code, message: redact(err.message) } };
71
115
  }
116
+ captureMcpDebug(server, toolDef.name, { message: errMessage(err) });
72
117
  return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
73
118
  }
74
119
  // (4) Server-reported error → TOOL_ERROR, redacted, bounded.
75
120
  if (res.isError) {
121
+ captureMcpDebug(server, toolDef.name, res.raw);
76
122
  return { ok: false, error: { code: 'TOOL_ERROR', message: redact(res.text).slice(0, 2000) } };
77
123
  }
78
124
  // (5) Success — redact BEFORE the value reaches the model/trace, then
79
125
  // truncate to a bounded size with a marker.
126
+ captureMcpDebug(server, toolDef.name, res.raw);
80
127
  const redacted = redact(res.text);
81
128
  if (redacted.length > MCP_SUCCESS_MAX_CHARS) {
82
129
  return {
@@ -177,7 +224,7 @@ export async function registerMcpServers(cfg, opts) {
177
224
  // 'admin': most restrictive class — MCP tools run arbitrary
178
225
  // external side effects; nothing in src reads this field yet.
179
226
  permission: 'admin',
180
- execute: (input, ctx) => executeMcpTool(boundSpec, boundDef, boundClient, input, ctx),
227
+ execute: (input, ctx) => executeMcpTool(server, boundSpec, boundDef, boundClient, input, ctx),
181
228
  });
182
229
  registry.register(tool);
183
230
  registered.push(name);
@@ -84,6 +84,22 @@ export declare class PolicyEngine {
84
84
  /** Builtin set of rules. Order matters: first match wins. */
85
85
  export declare function builtinRules(): PolicyRule[];
86
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;
87
103
  /** Hard-deny for obviously destructive shell patterns. */
88
104
  export declare const shellDenyRule: PolicyRule;
89
105
  /** Allowlist for shell — exact or prefix. Anything not in the list asks. */
@@ -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' || call.name === 'edit_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
  }
@@ -226,6 +230,48 @@ function startsWithAny(haystack, needles) {
226
230
  return h === nl || h.startsWith(nl + ' ') || h.startsWith(nl + '\t');
227
231
  });
228
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
+ }
229
275
  /** Hard-deny for obviously destructive shell patterns. */
230
276
  export const shellDenyRule = {
231
277
  name: 'shell-deny',
@@ -271,6 +317,16 @@ export const shellDenyRule = {
271
317
  if (/\b(Set-Content|Out-File)\b[^\n]*\.env/i.test(cmd)) {
272
318
  return { action: 'deny', reason: 'write to .env via Set-Content/Out-File denied' };
273
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
+ }
274
330
  // Upload-form exfiltration (mirrors shell_exec DANGEROUS_PATTERNS).
275
331
  if (/\bcurl\b.*(?:\s-F\b|\s--form\b)/i.test(cmd)) {
276
332
  return { action: 'deny', reason: 'exfiltration: curl -F/--form denied' };
@@ -284,6 +340,22 @@ export const shellDenyRule = {
284
340
  if (/\bStart-BitsTransfer\b/i.test(cmd)) {
285
341
  return { action: 'deny', reason: 'exfiltration: Start-BitsTransfer denied' };
286
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
+ }
287
359
  return null;
288
360
  },
289
361
  };
@@ -383,4 +455,5 @@ export async function evaluatePolicy(engine, call, ctx) {
383
455
  // policy/ depending only on tools/normalize, no other tool code.)
384
456
  import * as path from 'node:path';
385
457
  import * as fsSync from 'node:fs';
458
+ import { execFileSync } from 'node:child_process';
386
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;
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import * as path from 'node:path';
13
13
  import * as fs from 'node:fs/promises';
14
+ import * as fsSync from 'node:fs';
14
15
  import { TOOL_ERROR_CODES } from '../tools/normalize.js';
15
16
  export class PathGuardError extends Error {
16
17
  code;
@@ -96,3 +97,48 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
96
97
  }
97
98
  return { resolved: real };
98
99
  }
100
+ /**
101
+ * Symlink-swap guard: refuse when the FINAL path itself is a symlink.
102
+ *
103
+ * File tools resolve symlinks up front, but a racing swap between that
104
+ * check and the final write/rename could redirect an open-with-O_TRUNC /
105
+ * O_CREAT (or rename) through a freshly-planted symlink. Calling this
106
+ * immediately before the final write/rename shrinks that window to ~0:
107
+ * `lstat` never follows the final component, so a swapped-in link is
108
+ * caught. (POSIX `rename(2)` onto a symlink replaces the LINK itself, so
109
+ * the vulnerable op is opening/truncating through the link — which this
110
+ * denies first.) Supported on POSIX and Windows (both implement lstat).
111
+ * Missing paths (ENOENT) are fine — a nonexistent path is not a symlink.
112
+ */
113
+ export async function assertNotSymlink(p) {
114
+ let st;
115
+ try {
116
+ st = await fs.lstat(p);
117
+ }
118
+ catch (e) {
119
+ if (e?.code === 'ENOENT')
120
+ return;
121
+ throw e;
122
+ }
123
+ if (st.isSymbolicLink()) {
124
+ throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Refusing to write through symlink: ${p}`);
125
+ }
126
+ }
127
+ /**
128
+ * Open a file for writing without following a trailing symlink (POSIX).
129
+ *
130
+ * Uses O_WRONLY | O_CREAT | O_NOFOLLOW so the open itself fails (ELOOP)
131
+ * when the final component is a symlink, instead of truncating through
132
+ * it. On Windows there is no O_NOFOLLOW, so this falls back to a plain
133
+ * `'w'` open — symlink-swap protection there comes from
134
+ * `assertNotSymlink` (lstat) immediately before the write/rename instead.
135
+ */
136
+ export function openNoFollowForWrite(dir, file) {
137
+ const target = path.resolve(dir, file);
138
+ if (process.platform === 'win32') {
139
+ // Windows fallback: no O_NOFOLLOW — plain open; see comment above.
140
+ return fsSync.openSync(target, 'w');
141
+ }
142
+ const flags = fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_NOFOLLOW;
143
+ return fsSync.openSync(target, flags, 0o666);
144
+ }
@@ -32,4 +32,10 @@ export declare function ratesFor(modelId: string): {
32
32
  input: number;
33
33
  output: number;
34
34
  };
35
+ /**
36
+ * True for Anthropic-family model ids (Claude). Used by the runtime's
37
+ * cache-aware cost math: only Anthropic bills cacheRead/cacheWrite, so
38
+ * other families keep ignoring those counters.
39
+ */
40
+ export declare function isAnthropicModel(modelId: string): boolean;
35
41
  export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number): number;
@@ -43,6 +43,14 @@ export function ratesFor(modelId) {
43
43
  return { input: fam.input, output: fam.output };
44
44
  return { input: 0, output: 0 };
45
45
  }
46
+ /**
47
+ * True for Anthropic-family model ids (Claude). Used by the runtime's
48
+ * cache-aware cost math: only Anthropic bills cacheRead/cacheWrite, so
49
+ * other families keep ignoring those counters.
50
+ */
51
+ export function isAnthropicModel(modelId) {
52
+ return /anthropic|claude/i.test(modelId);
53
+ }
46
54
  export function estimateCost(modelId, inputTokens, outputTokens) {
47
55
  // Single source: delegate to ratesFor so registry, family fallbacks, and
48
56
  // local-$0 rules live in exactly one place (no split-brain with getModelInfo).
@@ -20,7 +20,7 @@ import * as fs from 'node:fs/promises';
20
20
  import * as path from 'node:path';
21
21
  import { z } from 'zod';
22
22
  import { defineTool } from '../types.js';
23
- import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
23
+ import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
24
24
  import { safe } from '../normalize.js';
25
25
  import { wasRead } from './read-history.js';
26
26
  // Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
@@ -242,6 +242,9 @@ export const applyPatchTool = defineTool({
242
242
  await guardWrite(ctx.cwd, sec.path, resolved);
243
243
  await assertSameTarget(ctx.cwd, sec.path, resolved);
244
244
  await fs.mkdir(path.dirname(resolved), { recursive: true });
245
+ // Symlink-swap guard: refuse if the final dest became a symlink
246
+ // since the up-front resolve (lstat never follows the final link).
247
+ await assertNotSymlink(resolved);
245
248
  await fs.writeFile(resolved, content ? content + '\n' : '', 'utf-8');
246
249
  patchedFiles.push(sec.path);
247
250
  continue;
@@ -264,6 +267,8 @@ export const applyPatchTool = defineTool({
264
267
  const next = applyHunks(sec.path, fileLines, hunks);
265
268
  await assertSameTarget(ctx.cwd, sec.path, resolved);
266
269
  await fs.mkdir(path.dirname(resolved), { recursive: true });
270
+ // Symlink-swap guard (mirrors the creation path above).
271
+ await assertNotSymlink(resolved);
267
272
  await fs.writeFile(resolved, next.join('\n') + (hasTrailingNewline ? '\n' : ''), 'utf-8');
268
273
  patchedFiles.push(sec.path);
269
274
  }
@@ -7,7 +7,7 @@ import * as path from 'node:path';
7
7
  import * as crypto from 'node:crypto';
8
8
  import { z } from 'zod';
9
9
  import { defineTool } from '../types.js';
10
- import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
10
+ import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
11
11
  import { safe } from '../normalize.js';
12
12
  import { wasRead } from './read-history.js';
13
13
  // Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
@@ -187,6 +187,9 @@ export const editFileTool = defineTool({
187
187
  finally {
188
188
  await fh.close().catch(() => undefined);
189
189
  }
190
+ // Symlink-swap guard: refuse if the final dest became a symlink
191
+ // since the up-front resolve (lstat never follows the final link).
192
+ await assertNotSymlink(resolved);
190
193
  try {
191
194
  await fs.rename(tmp, resolved);
192
195
  }
@@ -5,7 +5,7 @@ import * as fs from 'node:fs/promises';
5
5
  import * as crypto from 'node:crypto';
6
6
  import { z } from 'zod';
7
7
  import { defineTool } from '../types.js';
8
- import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
8
+ import { resolveAndFollowSymlinks, assertNotSymlink } from '../../policy/path-guard.js';
9
9
  import { safe } from '../normalize.js';
10
10
  import { wasRead } from './read-history.js';
11
11
  import { checkStaleness, recordEditStaleness } from './edit-file.js';
@@ -112,6 +112,9 @@ export const multiEditTool = defineTool({
112
112
  }
113
113
  const tmp = `${resolved}.klyro-multi-${Date.now()}.tmp`;
114
114
  await fs.writeFile(tmp, content, 'utf-8');
115
+ // Symlink-swap guard: refuse if the final dest became a symlink
116
+ // since the up-front resolve (lstat never follows the final link).
117
+ await assertNotSymlink(resolved);
115
118
  try {
116
119
  await fs.rename(tmp, resolved);
117
120
  }
@@ -5,11 +5,12 @@
5
5
  * directories if needed.
6
6
  */
7
7
  import * as fs from 'node:fs/promises';
8
+ import * as fsSyncNode from 'node:fs';
8
9
  import * as path from 'node:path';
9
10
  import * as crypto from 'node:crypto';
10
11
  import { z } from 'zod';
11
12
  import { defineTool } from '../types.js';
12
- import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
13
+ import { resolveAndFollowSymlinks, assertNotSymlink, openNoFollowForWrite } from '../../policy/path-guard.js';
13
14
  import { safe } from '../normalize.js';
14
15
  // Extension-anchored: matches test.ts, foo.test.ts, foo-test.ts, foo.spec.js,
15
16
  // __tests__ segments — but NOT latest.ts / attest.ts / contest-data.
@@ -111,15 +112,24 @@ export const writeFileTool = defineTool({
111
112
  if ((await canon(reResolved)) !== (await canon(resolved))) {
112
113
  throw Object.assign(new Error(`Path target changed between check and write: ${input.path} (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
113
114
  }
114
- // Truncate to 8k tokens ~32k chars for result
115
- const fh = await fs.open(tmp, 'w');
115
+ // Truncate to 8k tokens ~32k chars for result.
116
+ // The tmp file is opened O_NOFOLLOW (POSIX; plain 'w' on Windows —
117
+ // see openNoFollowForWrite) so a planted link at the fresh tmp name
118
+ // can't redirect the content write; atomic tmp+rename semantics stay.
119
+ const fd = openNoFollowForWrite(parent, path.basename(tmp));
116
120
  try {
117
- await fh.writeFile(data);
118
- await fh.sync();
121
+ fsSyncNode.writeFileSync(fd, data);
122
+ fsSyncNode.fsyncSync(fd);
119
123
  }
120
124
  finally {
121
- await fh.close().catch(() => undefined);
125
+ try {
126
+ fsSyncNode.closeSync(fd);
127
+ }
128
+ catch { /* already closed */ }
122
129
  }
130
+ // Symlink-swap guard: lstat the final dest immediately before rename
131
+ // and refuse if it became a symlink since the up-front resolve.
132
+ await assertNotSymlink(resolved);
123
133
  try {
124
134
  await fs.rename(tmp, resolved);
125
135
  }
@@ -14,7 +14,7 @@ export const todoWriteTool = defineTool({
14
14
  name: 'todo_write',
15
15
  description: 'Update the live plan checklist. Persisted and re-injected when stale.',
16
16
  inputSchema: InputSchema,
17
- permission: 'read',
17
+ permission: 'edit',
18
18
  isConcurrencySafe: true,
19
19
  execute: async (input, ctx) => {
20
20
  return safe(async () => {
@@ -18,6 +18,34 @@ declare const InputSchema: z.ZodObject<{
18
18
  /** Reset the sticky cwd (e.g. after /cd changes the session directory). */
19
19
  export declare function resetPersistentCwd(): void;
20
20
  export declare function filteredEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
21
+ /**
22
+ * Protected-branch push helpers (mirror policy engine shellDenyRule).
23
+ * `git push` targeting main/master/production is denied outright; a bare
24
+ * `git push` (no ref args) is denied when the checkout sits on a protected
25
+ * branch. Escape hatch: KLYRO_ALLOW_MAIN_PUSH=1.
26
+ */
27
+ export declare const PROTECTED_BRANCHES: string[];
28
+ export declare const PROTECTED_PUSH_RE: RegExp;
29
+ /**
30
+ * True when KLYRO_ALLOW_MAIN_PUSH=1 opts out of the protected-push deny. */
31
+ export declare function isMainPushEscape(): boolean;
32
+ /**
33
+ * True when the command is a `git push` with NO ref/positional args
34
+ * (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
35
+ * out. `git push origin feature` is explicit, not bare.
36
+ */
37
+ export declare function isBareGitPush(cmd: string): boolean;
38
+ /**
39
+ * Resolve the currently checked-out branch via `git branch --show-current`.
40
+ * Returns null on any failure (not a repo, git missing) — callers treat
41
+ * null as "unknown" and allow the normal flow to continue.
42
+ */
43
+ export declare function currentGitBranch(cwd: string): string | null;
44
+ /**
45
+ * Bare-push check with cwd: deny `git push` with no ref args when the
46
+ * checkout is on a protected branch. Returns the block reason or null.
47
+ */
48
+ export declare function findBareProtectedPushReason(command: string, cwd: string): string | null;
21
49
  /**
22
50
  * Shared dangerous-command check (also used by background shells).
23
51
  * Returns the block reason, or null when the command is not hard-blocked.
@@ -8,7 +8,7 @@
8
8
  * - Non-zero exit is returned as a successful tool call (with exitCode in
9
9
  * the output). The agent reads exitCode to decide what to do next.
10
10
  */
11
- import { spawn, spawnSync } from 'node:child_process';
11
+ import { spawn, spawnSync, execFileSync } from 'node:child_process';
12
12
  import * as os from 'node:os';
13
13
  import * as path from 'node:path';
14
14
  import * as fsp from 'node:fs/promises';
@@ -73,6 +73,15 @@ export function filteredEnv(extra) {
73
73
  }
74
74
  // Interactive command detection (3.3)
75
75
  const INTERACTIVE_PATTERNS = [/^\s*vim\b/, /^\s*nano\b/, /^\s*htop\b/, /^\s*less\b/, /^\s*more\b/, /^\s*ssh\b/, /^\s*tmux\b/];
76
+ /**
77
+ * Protected-branch push helpers (mirror policy engine shellDenyRule).
78
+ * `git push` targeting main/master/production is denied outright; a bare
79
+ * `git push` (no ref args) is denied when the checkout sits on a protected
80
+ * branch. Escape hatch: KLYRO_ALLOW_MAIN_PUSH=1.
81
+ */
82
+ export const PROTECTED_BRANCHES = ['main', 'master', 'production'];
83
+ export const PROTECTED_PUSH_RE = /\bpush\b[^\n]*\b(main|master|production)\b/;
84
+ const GIT_PUSH_RE = /\bgit\b[^\n]*\bpush\b/;
76
85
  // Hard-coded dangerous patterns. These are non-overridable, configurable via --yolo only.
77
86
  const DANGEROUS_PATTERNS = [
78
87
  { pattern: /rm\s+-rf?\s+\//, reason: 'recursive delete at filesystem root' },
@@ -112,12 +121,79 @@ const DANGEROUS_PATTERNS = [
112
121
  // .env writes via tee / PowerShell (mirrors policy engine .env guard).
113
122
  { pattern: /\|\s*tee\b[^\n]*\.env/i, reason: 'write to .env via tee denied' },
114
123
  { pattern: /\b(Set-Content|Out-File)\b[^\n]*\.env/i, reason: 'write to .env via Set-Content/Out-File denied' },
124
+ // Shell-redirection containment (mirrors policy engine shellDenyRule):
125
+ // deny `>` / `>>` into dotfiles under home/abs paths or bare project
126
+ // dotfiles. `(?<![0-9])` excludes the `2>` stderr-redirect prefix.
127
+ { pattern: /(?<![0-9])>+\s*["']?(~|\/)[^"'\s]*\/\.[^"'\s]+/, reason: 'redirect into dotfile under home/abs path denied' },
128
+ { pattern: /(?<![0-9])>+\s*["']?\.[^"'\s\/][^"'\s]*/, reason: 'redirect into project dotfile denied' },
115
129
  // Upload-form exfiltration (mirrors policy engine shellDenyRule).
116
130
  { pattern: /\bcurl\b.*(?:\s-F\b|\s--form\b)/i, reason: 'exfiltration: curl -F/--form denied' },
117
131
  { pattern: /\bwget\b.*(?:\s--method=POST\b|\s--body-data\b)/i, reason: 'exfiltration: wget --method=POST/--body-data denied' },
118
132
  { pattern: /\bInvoke-RestMethod\b/i, reason: 'exfiltration: Invoke-RestMethod denied' },
119
133
  { pattern: /\bStart-BitsTransfer\b/i, reason: 'exfiltration: Start-BitsTransfer denied' },
134
+ // Protected-branch push deny (mirrors policy engine shellDenyRule).
135
+ // Explicit `git push ... main|master|production`. Honored only when
136
+ // KLYRO_ALLOW_MAIN_PUSH=1 is NOT set (see findBlockedReason). Bare
137
+ // `git push` on a protected checkout is handled in execute() with cwd.
138
+ { pattern: PROTECTED_PUSH_RE, reason: 'protected-branch push denied (main/master/production) — set KLYRO_ALLOW_MAIN_PUSH=1 to override' },
120
139
  ];
140
+ /**
141
+ * True when KLYRO_ALLOW_MAIN_PUSH=1 opts out of the protected-push deny. */
142
+ export function isMainPushEscape() {
143
+ return process.env.KLYRO_ALLOW_MAIN_PUSH === '1';
144
+ }
145
+ /**
146
+ * True when the command is a `git push` with NO ref/positional args
147
+ * (e.g. `git push`, `git push -f`) — i.e. it pushes whatever is checked
148
+ * out. `git push origin feature` is explicit, not bare.
149
+ */
150
+ export function isBareGitPush(cmd) {
151
+ if (!GIT_PUSH_RE.test(cmd))
152
+ return false;
153
+ const idx = cmd.search(/\bpush\b/);
154
+ const rest = idx >= 0 ? cmd.slice(idx + 4) : '';
155
+ const segment = (rest.split(/[;&|]/)[0] ?? '').split(/\n/)[0] ?? '';
156
+ const tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
157
+ const positionals = tokens.filter((t) => {
158
+ const unquoted = t.replace(/^["']|["']$/g, '');
159
+ return unquoted.length > 0 && !unquoted.startsWith('-');
160
+ });
161
+ return positionals.length === 0;
162
+ }
163
+ /**
164
+ * Resolve the currently checked-out branch via `git branch --show-current`.
165
+ * Returns null on any failure (not a repo, git missing) — callers treat
166
+ * null as "unknown" and allow the normal flow to continue.
167
+ */
168
+ export function currentGitBranch(cwd) {
169
+ try {
170
+ const out = execFileSync('git', ['-C', cwd, 'branch', '--show-current'], {
171
+ timeout: 3000,
172
+ stdio: ['ignore', 'pipe', 'ignore'],
173
+ encoding: 'utf-8',
174
+ });
175
+ const branch = String(out).trim();
176
+ return branch.length > 0 ? branch : null;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ /**
183
+ * Bare-push check with cwd: deny `git push` with no ref args when the
184
+ * checkout is on a protected branch. Returns the block reason or null.
185
+ */
186
+ export function findBareProtectedPushReason(command, cwd) {
187
+ if (isMainPushEscape())
188
+ return null;
189
+ if (!isBareGitPush(command))
190
+ return null;
191
+ const branch = currentGitBranch(cwd);
192
+ if (branch !== null && PROTECTED_BRANCHES.includes(branch)) {
193
+ return `bare git push on protected branch '${branch}' — set KLYRO_ALLOW_MAIN_PUSH=1 to override`;
194
+ }
195
+ return null;
196
+ }
121
197
  /**
122
198
  * Repair-guard mirror: heredoc/redirect writes to test paths, or test-path
123
199
  * rewrites via sed/python/perl/ruby/node/git (e.g. `> x.test.ts`,
@@ -133,6 +209,9 @@ const REPAIR_GUARD_SHELL_RE = />+\s*['"]?[^'"\s]*test[^'"\s]*|\b(sed|python|perl
133
209
  */
134
210
  export function findBlockedReason(command) {
135
211
  for (const { pattern, reason } of DANGEROUS_PATTERNS) {
212
+ // Protected-push entry honors the KLYRO_ALLOW_MAIN_PUSH=1 escape hatch.
213
+ if (pattern === PROTECTED_PUSH_RE && isMainPushEscape())
214
+ continue;
136
215
  if (pattern.test(command))
137
216
  return reason;
138
217
  }
@@ -204,6 +283,13 @@ export const shellExecTool = defineTool({
204
283
  const timeoutMs = Math.min(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
205
284
  const env = filteredEnv(input.env);
206
285
  const start = Date.now();
286
+ // Protected-branch bare push: `git push` with no ref args while the
287
+ // effective checkout sits on main/master/production. Explicit
288
+ // `push ... main` was already denied by findBlockedReason above.
289
+ const barePushReason = findBareProtectedPushReason(input.command, cwd);
290
+ if (barePushReason) {
291
+ throw Object.assign(new Error(`Command blocked: ${barePushReason}`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
292
+ }
207
293
  // Detached on POSIX so timeout kills the whole process GROUP
208
294
  // (child.kill alone orphans grandchildren holding the pipes).
209
295
  // G1-2 — sandbox is ON BY DEFAULT whenever a backend is available
@@ -6,6 +6,13 @@
6
6
  * the serialized line is passed through redact() before it hits disk so
7
7
  * secrets never land in the trace. readAll skips corrupt lines instead of
8
8
  * discarding the whole trace.
9
+ *
10
+ * Trace-dir rationale (PRD divergence): the PRD places traces at
11
+ * `~/.klyro/projects/<hash>/traces/<session>.jsonl` (global store), but this
12
+ * writer defaults to `<cwd>/.klyro/traces/<session>.jsonl` (project-local)
13
+ * so traces travel with the working tree — inspectable offline, backed up
14
+ * with the repo, and resolvable without a global-project-hash lookup. The
15
+ * `dir` ctor param preserves the PRD path for callers that want it.
9
16
  */
10
17
  import type { KlyroEvent } from '../events/catalog.js';
11
18
  export declare class TraceWriter {
@@ -6,6 +6,13 @@
6
6
  * the serialized line is passed through redact() before it hits disk so
7
7
  * secrets never land in the trace. readAll skips corrupt lines instead of
8
8
  * discarding the whole trace.
9
+ *
10
+ * Trace-dir rationale (PRD divergence): the PRD places traces at
11
+ * `~/.klyro/projects/<hash>/traces/<session>.jsonl` (global store), but this
12
+ * writer defaults to `<cwd>/.klyro/traces/<session>.jsonl` (project-local)
13
+ * so traces travel with the working tree — inspectable offline, backed up
14
+ * with the repo, and resolvable without a global-project-hash lookup. The
15
+ * `dir` ctor param preserves the PRD path for callers that want it.
9
16
  */
10
17
  import * as fs from 'node:fs/promises';
11
18
  import * as path from 'node:path';