klyro 1.0.1 → 1.0.3

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 (63) 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 +44 -4
  5. package/dist/agent/orchestrator.js +102 -8
  6. package/dist/agent/retry.js +52 -10
  7. package/dist/agent/runtime.d.ts +34 -0
  8. package/dist/agent/runtime.js +169 -15
  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/markdown.js +29 -1
  22. package/dist/cli/repl.js +41 -1
  23. package/dist/cli/run.d.ts +6 -0
  24. package/dist/cli/run.js +76 -3
  25. package/dist/context/tokenizer.d.ts +18 -0
  26. package/dist/context/tokenizer.js +50 -1
  27. package/dist/events/bus.d.ts +5 -0
  28. package/dist/events/bus.js +10 -0
  29. package/dist/events/catalog.d.ts +9 -0
  30. package/dist/events/catalog.js +9 -0
  31. package/dist/index.js +89 -5
  32. package/dist/mcp/client.js +1 -1
  33. package/dist/mcp/registry.d.ts +0 -18
  34. package/dist/mcp/registry.js +49 -2
  35. package/dist/policy/engine.d.ts +16 -0
  36. package/dist/policy/engine.js +74 -1
  37. package/dist/policy/path-guard.d.ts +24 -0
  38. package/dist/policy/path-guard.js +46 -0
  39. package/dist/providers/model-info.d.ts +6 -0
  40. package/dist/providers/model-info.js +8 -0
  41. package/dist/tools/fs/apply-patch.js +6 -1
  42. package/dist/tools/fs/edit-file.js +4 -1
  43. package/dist/tools/fs/multi-edit.js +4 -1
  44. package/dist/tools/fs/write-file.js +16 -6
  45. package/dist/tools/plan/todo-write.js +1 -1
  46. package/dist/tools/shell/sandbox.d.ts +4 -3
  47. package/dist/tools/shell/sandbox.js +23 -3
  48. package/dist/tools/shell/shell-exec.d.ts +28 -0
  49. package/dist/tools/shell/shell-exec.js +87 -1
  50. package/dist/trace/writer.d.ts +7 -0
  51. package/dist/trace/writer.js +7 -0
  52. package/dist/tui/app.js +20 -1
  53. package/dist/tui/app.test.js +18 -12
  54. package/dist/tui/approval.test.js +20 -3
  55. package/dist/tui/markdown.d.ts +13 -0
  56. package/dist/tui/markdown.js +169 -2
  57. package/dist/tui/scroll-flow.test.js +3 -1
  58. package/dist/verification/classify.js +4 -3
  59. package/dist/verification/engine.d.ts +8 -0
  60. package/dist/verification/engine.js +25 -0
  61. package/dist/verification/registry.js +16 -5
  62. package/dist/verification/scoped.js +36 -5
  63. package/package.json +1 -1
@@ -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 () => {
@@ -8,14 +8,15 @@
8
8
  * Backend strategy (defense-in-depth, cheapest-first):
9
9
  * - `bwrap` (bubblewrap) — rootless, cross-distro, the same primitive Claude
10
10
  * Code's sandbox docs reference. Primary backend when present.
11
- * - landlock — kernel LSM, no helper binary needed, but requires a native
12
- * syscall helper Node can't invoke directly. Tracked for the next step.
11
+ * - landlock — kernel LSM (Linux 5.13+), no namespaces needed. Node can't
12
+ * issue the syscalls directly, so we drive it through the `llkr` helper
13
+ * (from the ll_start project). Fallback backend when bwrap is absent.
13
14
  *
14
15
  * Detection is done once at startup and cached. When no backend is present we
15
16
  * *degrade cleanly* to `undefined` — an empty sandbox command and a clearly
16
17
  * reported status, never a pretend "sandboxed" that isn't.
17
18
  */
18
- export type SandboxBackend = 'bwrap' | 'none';
19
+ export type SandboxBackend = 'bwrap' | 'landlock' | 'none';
19
20
  export interface SandboxStatus {
20
21
  backend: SandboxBackend;
21
22
  /** True when a real kernel/namespace boundary will be applied. */
@@ -8,8 +8,9 @@
8
8
  * Backend strategy (defense-in-depth, cheapest-first):
9
9
  * - `bwrap` (bubblewrap) — rootless, cross-distro, the same primitive Claude
10
10
  * Code's sandbox docs reference. Primary backend when present.
11
- * - landlock — kernel LSM, no helper binary needed, but requires a native
12
- * syscall helper Node can't invoke directly. Tracked for the next step.
11
+ * - landlock — kernel LSM (Linux 5.13+), no namespaces needed. Node can't
12
+ * issue the syscalls directly, so we drive it through the `llkr` helper
13
+ * (from the ll_start project). Fallback backend when bwrap is absent.
13
14
  *
14
15
  * Detection is done once at startup and cached. When no backend is present we
15
16
  * *degrade cleanly* to `undefined` — an empty sandbox command and a clearly
@@ -66,10 +67,17 @@ export function detectSandbox() {
66
67
  cachedStatus = { backend: 'bwrap', active: true, reason: `bubblewrap at ${bwrap}` };
67
68
  return cachedStatus;
68
69
  }
70
+ // Landlock: the helper binary (`llkr` from the ll_start project) exposes the
71
+ // kernel LSM without a subprocess namespace. Requires Linux 5.13+.
72
+ const llkr = findOnPath('llkr');
73
+ if (process.platform === 'linux' && llkr && bwrapUsable(llkr)) {
74
+ cachedStatus = { backend: 'landlock', active: true, reason: `landlock via llkr at ${llkr}` };
75
+ return cachedStatus;
76
+ }
69
77
  cachedStatus = {
70
78
  backend: 'none',
71
79
  active: false,
72
- reason: 'bwrap not found on $PATH — install bubblewrap (e.g. `apt install bubblewrap`) or run unsandboxed; policy+path guards remain active',
80
+ reason: 'no sandbox backend (bwrap or llkr) on $PATH — install bubblewrap (apt install bubblewrap) or run unsandboxed; policy+path guards remain active',
73
81
  };
74
82
  return cachedStatus;
75
83
  }
@@ -88,6 +96,18 @@ export function sandboxCommand(cmd, args, opts) {
88
96
  const status = detectSandbox();
89
97
  if (!status.active)
90
98
  return undefined;
99
+ // Landlock backend: the helper confines filesystem access to the read-write
100
+ // set and then execs the command. Kernel-enforced (LSM), no namespaces.
101
+ if (status.backend === 'landlock') {
102
+ const rw = [opts.cwd, ...(opts.readWriteDirs ?? [])];
103
+ const llArgs = [];
104
+ for (const d of rw)
105
+ llArgs.push('--rw', d);
106
+ if (!opts.allowNetwork)
107
+ llArgs.push('--no-net');
108
+ llArgs.push('--', cmd, ...args);
109
+ return { cmd: 'llkr', args: llArgs };
110
+ }
91
111
  const roDirs = ['/usr', '/bin', '/etc', '/lib', '/lib64', '/opt'];
92
112
  const bwrapArgs = [
93
113
  '--unshare-all',
@@ -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';
package/dist/tui/app.js CHANGED
@@ -96,7 +96,26 @@ function MarkdownText({ text, dim, width }) {
96
96
  const lines = useMemo(() => renderMarkdownLines(text), [text]);
97
97
  const dimColor = tokens.colors.dim;
98
98
  const softColor = tokens.colors.soft;
99
- return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) => (_jsx(Text, { bold: p.bold || undefined, color: p.bold ? (dim ? undefined : softColor) : p.dim ? dimColor : p.code ? softColor : undefined, children: p.text }, j)))] }, i))) }));
99
+ return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) => {
100
+ // R4: syntax-highlight color wins; otherwise dim for code/comment,
101
+ // soft for bold, plain otherwise. file:line links surface as a
102
+ // distinct accent (href is carried for tooling/terminal emit).
103
+ // Inside dimmed (thinking) blocks the dim tone always wins.
104
+ let color;
105
+ if (dim)
106
+ color = p.dim ? dimColor : undefined;
107
+ else if (p.color)
108
+ color = p.color;
109
+ else if (p.href)
110
+ color = 'blue';
111
+ else if (p.dim)
112
+ color = dimColor;
113
+ else if (p.code)
114
+ color = softColor;
115
+ else if (p.bold)
116
+ color = softColor;
117
+ return (_jsx(Text, { bold: p.bold || undefined, color: color, children: p.text }, j));
118
+ })] }, i))) }));
100
119
  }
101
120
  // Chat scroll — scroll.md §5 anchor model adapted to Ink.
102
121
  //