klyro 0.1.63 → 1.0.1

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 (117) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +73 -16
  3. package/dist/agent/capabilities.d.ts +145 -0
  4. package/dist/agent/capabilities.js +191 -0
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +232 -0
  8. package/dist/agent/orchestrator.js +589 -0
  9. package/dist/agent/provider-adapter.d.ts +17 -0
  10. package/dist/agent/provider-adapter.js +35 -3
  11. package/dist/agent/registry.d.ts +1 -0
  12. package/dist/agent/registry.js +1 -0
  13. package/dist/agent/retry.d.ts +13 -2
  14. package/dist/agent/retry.js +21 -3
  15. package/dist/agent/runtime.d.ts +70 -8
  16. package/dist/agent/runtime.js +205 -34
  17. package/dist/agent/scoped-registry.d.ts +22 -0
  18. package/dist/agent/scoped-registry.js +42 -0
  19. package/dist/agent/task-manager.d.ts +115 -0
  20. package/dist/agent/task-manager.js +250 -0
  21. package/dist/agent/worker-spawner.d.ts +17 -12
  22. package/dist/agent/worker-spawner.js +26 -20
  23. package/dist/agent/worktree-manager.d.ts +74 -0
  24. package/dist/agent/worktree-manager.js +189 -0
  25. package/dist/checkpoints/store.js +30 -5
  26. package/dist/cli/auth.js +16 -1
  27. package/dist/cli/config.d.ts +9 -3
  28. package/dist/cli/config.js +64 -3
  29. package/dist/cli/dotenv.d.ts +3 -0
  30. package/dist/cli/dotenv.js +57 -0
  31. package/dist/cli/eval.d.ts +6 -1
  32. package/dist/cli/eval.js +9 -0
  33. package/dist/cli/repl.js +183 -17
  34. package/dist/cli/run.d.ts +10 -11
  35. package/dist/cli/run.js +176 -14
  36. package/dist/cli/update.d.ts +5 -0
  37. package/dist/cli/update.js +62 -10
  38. package/dist/context/import-graph.d.ts +2 -0
  39. package/dist/context/import-graph.js +31 -3
  40. package/dist/context/klyro-md.d.ts +6 -0
  41. package/dist/context/klyro-md.js +25 -16
  42. package/dist/context/memory.d.ts +8 -0
  43. package/dist/context/memory.js +50 -2
  44. package/dist/context/project-map.d.ts +6 -0
  45. package/dist/context/project-map.js +50 -2
  46. package/dist/context/repo-map.d.ts +2 -0
  47. package/dist/context/repo-map.js +31 -1
  48. package/dist/context/trust.d.ts +42 -0
  49. package/dist/context/trust.js +111 -0
  50. package/dist/events/catalog.d.ts +99 -0
  51. package/dist/index.js +93 -4
  52. package/dist/mcp/client.d.ts +55 -0
  53. package/dist/mcp/client.js +294 -0
  54. package/dist/mcp/config.d.ts +40 -0
  55. package/dist/mcp/config.js +99 -0
  56. package/dist/mcp/policy.d.ts +13 -0
  57. package/dist/mcp/policy.js +12 -0
  58. package/dist/mcp/registry.d.ts +72 -0
  59. package/dist/mcp/registry.js +244 -0
  60. package/dist/mcp/schema.d.ts +18 -0
  61. package/dist/mcp/schema.js +57 -0
  62. package/dist/mcp/trust.d.ts +20 -0
  63. package/dist/mcp/trust.js +74 -0
  64. package/dist/persistence/audit.d.ts +28 -0
  65. package/dist/persistence/audit.js +101 -1
  66. package/dist/persistence/store.d.ts +26 -2
  67. package/dist/persistence/store.js +140 -13
  68. package/dist/policy/approval.d.ts +14 -0
  69. package/dist/policy/approval.js +44 -2
  70. package/dist/policy/engine.d.ts +1 -0
  71. package/dist/policy/engine.js +91 -10
  72. package/dist/policy/secret-redactor.js +4 -0
  73. package/dist/providers/model-info.d.ts +17 -0
  74. package/dist/providers/model-info.js +35 -2
  75. package/dist/repl.d.ts +6 -0
  76. package/dist/repl.js +12 -7
  77. package/dist/tools/agent/spawn-agent.d.ts +9 -0
  78. package/dist/tools/agent/spawn-agent.js +50 -0
  79. package/dist/tools/agent/task-apply.d.ts +4 -0
  80. package/dist/tools/agent/task-apply.js +44 -0
  81. package/dist/tools/agent/task-get.d.ts +8 -0
  82. package/dist/tools/agent/task-get.js +40 -0
  83. package/dist/tools/agent/task-list.d.ts +4 -0
  84. package/dist/tools/agent/task-list.js +41 -0
  85. package/dist/tools/agent/task-stop.d.ts +6 -0
  86. package/dist/tools/agent/task-stop.js +39 -0
  87. package/dist/tools/agent/task-wait.d.ts +17 -0
  88. package/dist/tools/agent/task-wait.js +79 -0
  89. package/dist/tools/fs/apply-patch.js +71 -0
  90. package/dist/tools/fs/edit-file.js +65 -0
  91. package/dist/tools/fs/multi-edit.d.ts +4 -0
  92. package/dist/tools/fs/multi-edit.js +66 -0
  93. package/dist/tools/fs/write-file.js +67 -0
  94. package/dist/tools/plan/todo-write.d.ts +1 -1
  95. package/dist/tools/registry.js +12 -0
  96. package/dist/tools/shell/background.js +6 -3
  97. package/dist/tools/shell/sandbox.d.ts +51 -0
  98. package/dist/tools/shell/sandbox.js +143 -0
  99. package/dist/tools/shell/shell-exec.d.ts +1 -0
  100. package/dist/tools/shell/shell-exec.js +83 -11
  101. package/dist/tools/shell/worker-entry.d.ts +12 -0
  102. package/dist/tools/shell/worker-entry.js +43 -0
  103. package/dist/tools/types.d.ts +18 -0
  104. package/dist/tools/verify/run-verify.js +3 -1
  105. package/dist/trace/writer.d.ts +13 -0
  106. package/dist/trace/writer.js +55 -4
  107. package/dist/tui/app.js +1 -1
  108. package/dist/tui/approval.js +20 -21
  109. package/dist/util.d.ts +1 -0
  110. package/dist/util.js +1 -0
  111. package/dist/verification/baseline.js +17 -3
  112. package/dist/verification/classify.js +23 -12
  113. package/dist/verification/engine.js +3 -1
  114. package/dist/verification/registry.d.ts +2 -0
  115. package/dist/verification/registry.js +33 -0
  116. package/dist/verification/scoped.js +28 -6
  117. package/package.json +1 -1
@@ -30,7 +30,14 @@ export interface AnthropicAdapterOptions {
30
30
  authHeader?: 'x-api-key' | 'Authorization';
31
31
  /** Beta features (e.g. ['prompt-caching-2024-07-31', 'tools-2024-04-04']). */
32
32
  betas?: string[];
33
+ /**
34
+ * Prompt caching: put a `cache_control: {type:'ephemeral'}` breakpoint on
35
+ * the stable system block and send the `prompt-caching-2024-07-31` beta
36
+ * (merged with user betas). Default true.
37
+ */
38
+ promptCache?: boolean;
33
39
  }
40
+ export declare const PROMPT_CACHING_BETA = "prompt-caching-2024-07-31";
34
41
  interface AnthropicMessage {
35
42
  role: 'user' | 'assistant';
36
43
  content: Array<{
@@ -51,6 +58,25 @@ interface AnthropicMessage {
51
58
  is_error?: boolean;
52
59
  }>;
53
60
  }
61
+ interface AnthropicRequest {
62
+ model: string;
63
+ system?: string | Array<{
64
+ type: 'text';
65
+ text: string;
66
+ cache_control?: {
67
+ type: 'ephemeral';
68
+ };
69
+ }>;
70
+ messages: AnthropicMessage[];
71
+ tools?: Array<{
72
+ name: string;
73
+ description: string;
74
+ input_schema: unknown;
75
+ }>;
76
+ max_tokens: number;
77
+ temperature?: number;
78
+ stream: true;
79
+ }
54
80
  interface AnthropicSseEvent {
55
81
  type: string;
56
82
  [key: string]: unknown;
@@ -61,6 +87,13 @@ export declare class AnthropicApiError extends Error {
61
87
  constructor(status: number, body: string);
62
88
  }
63
89
  export declare function anthropicAdapter(opts: AnthropicAdapterOptions): ProviderAdapter;
90
+ /**
91
+ * Build the Anthropic `system` field. The stable system text gets the
92
+ * cache breakpoint; the volatile telemetry suffix (when present) rides as
93
+ * a second, uncached block so it never poisons the prefix cache.
94
+ * Exported via _internal for testing.
95
+ */
96
+ export declare function buildAnthropicSystem(system: string | undefined, suffix: string | undefined, promptCache: boolean): AnthropicRequest['system'];
64
97
  /**
65
98
  * Mutable per-stream assembly state. Blocks are keyed by content_block
66
99
  * index; the tool id is carried inside the block entry. There is no global
@@ -79,6 +112,8 @@ interface AnthropicStreamState {
79
112
  usage: {
80
113
  input?: number;
81
114
  output?: number;
115
+ cacheRead?: number;
116
+ cacheWrite?: number;
82
117
  };
83
118
  }
84
119
  declare function translateSse(event: string, parsed: AnthropicSseEvent, state: AnthropicStreamState): StreamEvent[];
@@ -92,5 +127,6 @@ export declare const _internal: {
92
127
  toAnthropicMessages: typeof toAnthropicMessages;
93
128
  toAnthropicTool: typeof toAnthropicTool;
94
129
  translateSse: typeof translateSse;
130
+ buildAnthropicSystem: typeof buildAnthropicSystem;
95
131
  };
96
132
  export {};
@@ -16,8 +16,10 @@
16
16
  * Auth can be a Bearer token (for proxies) — the adapter accepts either.
17
17
  */
18
18
  import { assertSafeBaseURL } from '../chat.js';
19
+ import { parseRetryAfterMs } from './provider-adapter.js';
19
20
  const DEFAULT_VERSION = '2023-06-01';
20
21
  const DEFAULT_TIMEOUT_MS = 120_000;
22
+ export const PROMPT_CACHING_BETA = 'prompt-caching-2024-07-31';
21
23
  export class AnthropicApiError extends Error {
22
24
  status;
23
25
  body;
@@ -34,7 +36,10 @@ export function anthropicAdapter(opts) {
34
36
  const baseURL = rawBase.replace(/\/+$/, '');
35
37
  const version = opts.anthropicVersion ?? DEFAULT_VERSION;
36
38
  const authHeader = opts.authHeader ?? 'x-api-key';
37
- const betas = opts.betas ?? [];
39
+ const promptCache = opts.promptCache ?? true;
40
+ const betas = [...(opts.betas ?? [])];
41
+ if (promptCache && !betas.includes(PROMPT_CACHING_BETA))
42
+ betas.push(PROMPT_CACHING_BETA);
38
43
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
39
44
  if (!fetchImpl) {
40
45
  throw new Error('anthropicAdapter: no fetch available — pass opts.fetchImpl or run on Node 18+');
@@ -44,15 +49,36 @@ export function anthropicAdapter(opts) {
44
49
  stream(req) {
45
50
  return streamAnthropic(req, {
46
51
  baseURL, apiKey: opts.apiKey, timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
47
- fetchImpl, version, authHeader, betas,
52
+ fetchImpl, version, authHeader, betas, promptCache,
48
53
  });
49
54
  },
50
55
  };
51
56
  }
57
+ /**
58
+ * Build the Anthropic `system` field. The stable system text gets the
59
+ * cache breakpoint; the volatile telemetry suffix (when present) rides as
60
+ * a second, uncached block so it never poisons the prefix cache.
61
+ * Exported via _internal for testing.
62
+ */
63
+ export function buildAnthropicSystem(system, suffix, promptCache) {
64
+ const hasSuffix = suffix !== undefined && suffix !== '';
65
+ const breakpoint = promptCache ? { cache_control: { type: 'ephemeral' } } : {};
66
+ if (hasSuffix) {
67
+ if (!system)
68
+ return promptCache ? [{ type: 'text', text: suffix, ...breakpoint }] : suffix;
69
+ return [
70
+ { type: 'text', text: system, ...breakpoint },
71
+ { type: 'text', text: suffix },
72
+ ];
73
+ }
74
+ if (!system)
75
+ return undefined;
76
+ return promptCache ? [{ type: 'text', text: system, ...breakpoint }] : system;
77
+ }
52
78
  async function* streamAnthropic(req, opts) {
53
79
  const body = {
54
80
  model: req.model,
55
- system: req.system,
81
+ system: buildAnthropicSystem(req.system, req.systemSuffix, opts.promptCache),
56
82
  messages: toAnthropicMessages(req.messages),
57
83
  tools: req.tools.length > 0 ? req.tools.map(toAnthropicTool) : undefined,
58
84
  max_tokens: req.maxTokens ?? 4096,
@@ -97,11 +123,19 @@ async function* streamAnthropic(req, opts) {
97
123
  clearTimeout(timer);
98
124
  if (!resp.ok || !resp.body) {
99
125
  const text = await resp.text().catch(() => '<unreadable>');
126
+ // 413 / request_too_large (or a too-long-prompt message) is context
127
+ // overflow, not transient: dedicated code, never retryable here — the
128
+ // runtime owns compress-and-retry.
129
+ const isOverflow = resp.status === 413 || /request_too_large|too_large|prompt_too_long|context_length|maximum context length|prompt is too long/i.test(text);
130
+ const retryable = !isOverflow && (resp.status >= 500 || resp.status === 429);
131
+ const retryAfterMs = retryable ? parseRetryAfterMs(resp.headers?.get('retry-after')) : undefined;
100
132
  yield {
101
133
  kind: 'error',
102
- code: `http_${resp.status}`,
134
+ code: isOverflow ? 'REQUEST_TOO_LARGE' : `http_${resp.status}`,
103
135
  message: `Anthropic API returned ${resp.status}: ${text.slice(0, 500)}`,
104
- retryable: resp.status >= 500 || resp.status === 429,
136
+ retryable,
137
+ status: String(resp.status),
138
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
105
139
  };
106
140
  return;
107
141
  }
@@ -203,25 +237,45 @@ async function* streamAnthropic(req, opts) {
203
237
  kind: 'message_end',
204
238
  finishReason: 'stop',
205
239
  ...(state.usage.input !== undefined || state.usage.output !== undefined
206
- ? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
240
+ ? { usage: withCache(state.usage) }
207
241
  : {}),
208
242
  };
209
243
  }
210
244
  }
245
+ /** Copy input/output plus any cache counters into a message_end usage payload. */
246
+ function withCache(u) {
247
+ return {
248
+ input: u.input ?? 0,
249
+ output: u.output ?? 0,
250
+ ...(u.cacheRead !== undefined ? { cacheRead: u.cacheRead } : {}),
251
+ ...(u.cacheWrite !== undefined ? { cacheWrite: u.cacheWrite } : {}),
252
+ };
253
+ }
254
+ /** Fold an Anthropic `usage` object (message_start or message_delta) into stream state. */
255
+ function recordAnthropicUsage(state, u) {
256
+ const rec = u;
257
+ if (typeof rec?.input_tokens === 'number')
258
+ state.usage.input = rec.input_tokens;
259
+ if (typeof rec?.output_tokens === 'number') {
260
+ state.usage.output = (state.usage.output ?? 0) + rec.output_tokens;
261
+ }
262
+ if (typeof rec?.cache_creation_input_tokens === 'number') {
263
+ state.usage.cacheWrite = (state.usage.cacheWrite ?? 0) + rec.cache_creation_input_tokens;
264
+ }
265
+ if (typeof rec?.cache_read_input_tokens === 'number') {
266
+ state.usage.cacheRead = (state.usage.cacheRead ?? 0) + rec.cache_read_input_tokens;
267
+ }
268
+ }
211
269
  function translateSse(event, parsed, state) {
212
270
  const out = [];
213
271
  switch (event) {
214
272
  case 'message_start': {
215
273
  const usage = parsed.message?.usage;
216
- if (typeof usage?.input_tokens === 'number')
217
- state.usage.input = usage.input_tokens;
274
+ recordAnthropicUsage(state, usage);
218
275
  return out;
219
276
  }
220
277
  case 'message_delta': {
221
- const usage = parsed.usage;
222
- if (typeof usage?.output_tokens === 'number') {
223
- state.usage.output = (state.usage.output ?? 0) + usage.output_tokens;
224
- }
278
+ recordAnthropicUsage(state, parsed.usage);
225
279
  return out;
226
280
  }
227
281
  case 'content_block_start': {
@@ -292,17 +346,20 @@ function translateSse(event, parsed, state) {
292
346
  kind: 'message_end',
293
347
  finishReason: 'stop',
294
348
  ...(state.usage.input !== undefined || state.usage.output !== undefined
295
- ? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
349
+ ? { usage: withCache(state.usage) }
296
350
  : {}),
297
351
  });
298
352
  return out;
299
353
  }
300
354
  case 'error': {
301
355
  const err = parsed.error;
356
+ const rawType = err?.type ?? 'anthropic_error';
357
+ const rawMsg = err?.message ?? 'unknown Anthropic error';
358
+ const isOverflow = /too_large|too_long|context_length|prompt_too_long|prompt is too long/i.test(`${rawType} ${rawMsg}`);
302
359
  out.push({
303
360
  kind: 'error',
304
- code: err?.type ?? 'anthropic_error',
305
- message: err?.message ?? 'unknown Anthropic error',
361
+ code: isOverflow ? 'REQUEST_TOO_LARGE' : rawType,
362
+ message: rawMsg,
306
363
  retryable: false,
307
364
  });
308
365
  return out;
@@ -373,4 +430,4 @@ function toAnthropicTool(t) {
373
430
  };
374
431
  }
375
432
  // Re-export for testability.
376
- export const _internal = { toAnthropicMessages, toAnthropicTool, translateSse };
433
+ export const _internal = { toAnthropicMessages, toAnthropicTool, translateSse, buildAnthropicSystem };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Agent capability enforcement (P0.2 from r-6-10.fix.md).
3
+ *
4
+ * Resolves the **effective tool set** for a (child) agent by intersecting:
5
+ *
6
+ * 1. the parent's effective tools — a child can never receive more tools than its parent
7
+ * 2. the agent definition's `allowedTools` (if specified)
8
+ * 3. the runtime policy's allowed tools (e.g. `--allow-write` flags)
9
+ *
10
+ * Then applies safety modifiers:
11
+ *
12
+ * - `readonly: true` strips every tool classified as write or mutation.
13
+ * - `canSpawn: false` (default for children) removes any spawn_agent-like tools.
14
+ * - A deny-list always wins, regardless of what the agent declares.
15
+ *
16
+ * This is a pure function — no I/O, no side effects. It exists so the runtime
17
+ * can decide which tools to expose to the model BEFORE the model picks one.
18
+ *
19
+ * Wiring lives in src/agent/runtime.ts (see the tool-filter hook).
20
+ */
21
+ export interface AgentCapabilities {
22
+ /** Tools the agent definition explicitly allows. `undefined` means "no allow-list". */
23
+ allowedTools?: string[];
24
+ /** If true, write/mutation tools are stripped. */
25
+ readonly?: boolean;
26
+ /** If false (or unset), tools that can spawn new agents are stripped. */
27
+ canSpawn?: boolean;
28
+ /** Model override — if the provider cannot satisfy it, callers must error rather than fall back. */
29
+ model?: string;
30
+ /** Recursion depth cap. Beyond this, spawn attempts are blocked. */
31
+ maxDepth?: number;
32
+ /**
33
+ * Path allow-list for filesystem scope. `undefined` means unconstrained.
34
+ * A child's effective list is the intersection with its parent's.
35
+ */
36
+ allowedPaths?: string[];
37
+ }
38
+ export interface ResolveToolsInput {
39
+ /** Tools the parent is allowed to use (or `null` for the root agent = all registered tools). */
40
+ parentTools: ReadonlySet<string> | null;
41
+ /** The agent's declared capability profile. */
42
+ agent: AgentCapabilities;
43
+ /** Tools currently allowed by runtime policy (e.g. user-permission flags). */
44
+ policyAllowed: ReadonlySet<string>;
45
+ /** All tools known to the registry (used when parentTools is null). */
46
+ registryTools: ReadonlySet<string>;
47
+ /** Tools that mutate state — stripped under `readonly`. */
48
+ writeTools: ReadonlySet<string>;
49
+ /** Tools that spawn other agents — stripped when `canSpawn` is false. */
50
+ spawnTools: ReadonlySet<string>;
51
+ /** Tools that are NEVER allowed, even if explicitly requested. */
52
+ denied: ReadonlySet<string>;
53
+ /** If true, spawn tools are kept even when agent.canSpawn is false/unset. */
54
+ canSpawnOverride?: boolean;
55
+ }
56
+ export interface ResolveToolsResult {
57
+ /** Tools the child agent is permitted to call. */
58
+ allowed: string[];
59
+ /** Tools that were denied (debugging/observability). */
60
+ dropped: {
61
+ tool: string;
62
+ reason: 'denied' | 'readonly' | 'no-spawn' | 'not-in-parent' | 'not-in-policy' | 'unknown';
63
+ }[];
64
+ }
65
+ /**
66
+ * Resolve the effective tool set for an agent.
67
+ *
68
+ * Order of operations:
69
+ * 1. Start from `parentTools ?? registryTools`.
70
+ * 2. Intersect with `policyAllowed`.
71
+ * 3. If `allowedTools` is set, intersect again.
72
+ * 4. Strip `writeTools` if `readonly`.
73
+ * 5. Strip `spawnTools` if `!canSpawn`.
74
+ * 6. Remove `denied` last (always wins).
75
+ */
76
+ export declare function resolveAgentTools(input: ResolveToolsInput): ResolveToolsResult;
77
+ /**
78
+ * Reason a tool was dropped from the resolved capability set. Mirrors
79
+ * `ResolveToolsResult.dropped[].reason`.
80
+ */
81
+ export type DropReason = 'denied' | 'readonly' | 'no-spawn' | 'not-in-parent' | 'not-in-policy' | 'unknown';
82
+ /**
83
+ * Fully-resolved capability profile for a child agent — tool set plus
84
+ * non-tool knobs (model override, recursion cap). Computed once at spawn
85
+ * time and threaded through the runtime as `parentContext`.
86
+ */
87
+ export interface ResolvedCapabilities {
88
+ /** Sorted tool names the child is allowed to invoke. */
89
+ allowed: ReadonlySet<string>;
90
+ /** Per-tool drop reasons — surfaced in ChildSummary for parent visibility. */
91
+ dropped: {
92
+ tool: string;
93
+ reason: DropReason;
94
+ }[];
95
+ /** Model override to send to the provider (or undefined to inherit parent). */
96
+ model?: string;
97
+ /** Effective recursion depth cap. Caller MUST reject spawn when depth + 1 > maxDepth. */
98
+ maxDepth: number;
99
+ readonly: boolean;
100
+ canSpawn: boolean;
101
+ /**
102
+ * Effective filesystem scope: parent's `allowedPaths` intersected with the
103
+ * agent's own. `undefined` means unconstrained (neither side restricts).
104
+ */
105
+ allowedPaths?: string[];
106
+ }
107
+ /** Extra knobs consumed by `resolveCapabilities` on top of `ResolveToolsInput`. */
108
+ export interface ResolveCapabilitiesExtra {
109
+ /** Caller-provided maxDepth (e.g. from CLI `--max-depth` or a parent's maxDepth). Required. */
110
+ maxDepth: number;
111
+ /** If true, the resolved `allowed` set may include tools that themselves spawn agents. */
112
+ canSpawnOverride?: boolean;
113
+ /**
114
+ * The parent's filesystem allow-list (`undefined` = unconstrained parent —
115
+ * the agent's own list, if any, applies as-is).
116
+ */
117
+ parentAllowedPaths?: string[];
118
+ }
119
+ /** Combined input for the one-shot resolver used by the orchestrator. */
120
+ export interface ResolveCapabilitiesInput extends ResolveToolsInput, ResolveCapabilitiesExtra {
121
+ }
122
+ /**
123
+ * Intersect two filesystem allow-lists. `undefined` on either side means
124
+ * "unconstrained" — the other side applies as-is; both `undefined` stays
125
+ * `undefined`. Entries survive when they are equal to, or nested inside, an
126
+ * entry of the other list (so a parent `/repo` and a child `/repo/sub`
127
+ * intersect to `/repo/sub`). Comparison is case-insensitive on Windows.
128
+ */
129
+ export declare function intersectAllowedPaths(parentPaths?: string[], agentPaths?: string[]): string[] | undefined;
130
+ /**
131
+ * One-shot capability resolver: tool intersection + per-agent model/maxDepth.
132
+ *
133
+ * `maxDepth` precedence (most specific wins):
134
+ * 1. `agent.maxDepth` (if set on the definition)
135
+ * 2. `extra.maxDepth` (parent / CLI cap)
136
+ * `model` is propagated only when the agent declares one; we do not silently
137
+ * override a parent's chosen model.
138
+ */
139
+ export declare function resolveCapabilities(input: ResolveCapabilitiesInput): ResolvedCapabilities;
140
+ /** Sensible defaults for "what counts as a write tool" if a caller doesn't override. */
141
+ export declare const DEFAULT_WRITE_TOOLS: ReadonlySet<string>;
142
+ /** Default spawn tools — removed from children by default. */
143
+ export declare const DEFAULT_SPAWN_TOOLS: ReadonlySet<string>;
144
+ /** Default deny-list — these are NEVER allowed, even if explicitly requested. */
145
+ export declare const DEFAULT_DENIED_TOOLS: ReadonlySet<string>;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Agent capability enforcement (P0.2 from r-6-10.fix.md).
3
+ *
4
+ * Resolves the **effective tool set** for a (child) agent by intersecting:
5
+ *
6
+ * 1. the parent's effective tools — a child can never receive more tools than its parent
7
+ * 2. the agent definition's `allowedTools` (if specified)
8
+ * 3. the runtime policy's allowed tools (e.g. `--allow-write` flags)
9
+ *
10
+ * Then applies safety modifiers:
11
+ *
12
+ * - `readonly: true` strips every tool classified as write or mutation.
13
+ * - `canSpawn: false` (default for children) removes any spawn_agent-like tools.
14
+ * - A deny-list always wins, regardless of what the agent declares.
15
+ *
16
+ * This is a pure function — no I/O, no side effects. It exists so the runtime
17
+ * can decide which tools to expose to the model BEFORE the model picks one.
18
+ *
19
+ * Wiring lives in src/agent/runtime.ts (see the tool-filter hook).
20
+ */
21
+ /**
22
+ * Resolve the effective tool set for an agent.
23
+ *
24
+ * Order of operations:
25
+ * 1. Start from `parentTools ?? registryTools`.
26
+ * 2. Intersect with `policyAllowed`.
27
+ * 3. If `allowedTools` is set, intersect again.
28
+ * 4. Strip `writeTools` if `readonly`.
29
+ * 5. Strip `spawnTools` if `!canSpawn`.
30
+ * 6. Remove `denied` last (always wins).
31
+ */
32
+ export function resolveAgentTools(input) {
33
+ const { parentTools, agent, policyAllowed, registryTools, writeTools, spawnTools, denied, } = input;
34
+ const allowed = new Set();
35
+ const dropped = [];
36
+ // 1. Baseline: parent's tools, or all registered tools for the root agent.
37
+ // Record narrowing for observability: registry tools outside the parent's
38
+ // set are reported as 'not-in-parent' drops (child can never exceed parent).
39
+ const baseline = parentTools ?? registryTools;
40
+ if (parentTools !== null) {
41
+ for (const name of registryTools) {
42
+ if (!parentTools.has(name)) {
43
+ dropped.push({ tool: name, reason: 'not-in-parent' });
44
+ }
45
+ }
46
+ }
47
+ for (const name of baseline) {
48
+ if (!registryTools.has(name)) {
49
+ dropped.push({ tool: name, reason: 'unknown' });
50
+ continue;
51
+ }
52
+ if (!policyAllowed.has(name)) {
53
+ dropped.push({ tool: name, reason: 'not-in-policy' });
54
+ continue;
55
+ }
56
+ allowed.add(name);
57
+ }
58
+ // 3. Apply the agent's allow-list (if any). Anything not in the list is dropped.
59
+ if (agent.allowedTools) {
60
+ const explicit = new Set(agent.allowedTools);
61
+ for (const name of [...allowed]) {
62
+ if (!explicit.has(name)) {
63
+ allowed.delete(name);
64
+ dropped.push({ tool: name, reason: 'not-in-parent' });
65
+ }
66
+ }
67
+ // Tools requested in allowedTools but not in the baseline cannot be granted.
68
+ for (const name of agent.allowedTools) {
69
+ if (!allowed.has(name) && registryTools.has(name) && policyAllowed.has(name)) {
70
+ dropped.push({ tool: name, reason: 'not-in-parent' });
71
+ }
72
+ }
73
+ }
74
+ // 4. readonly → strip write tools (plus run_verify: verification can
75
+ // execute arbitrary commands, so it is not read-only).
76
+ if (agent.readonly) {
77
+ for (const name of [...allowed]) {
78
+ if (writeTools.has(name) || name === 'run_verify') {
79
+ allowed.delete(name);
80
+ dropped.push({ tool: name, reason: 'readonly' });
81
+ }
82
+ }
83
+ }
84
+ // 5. canSpawn=false → strip spawn tools. Default: root agent (parentTools
85
+ // === null) may spawn; children strip unless explicitly enabled. An explicit
86
+ // canSpawnOverride=true (via resolveCapabilities) also keeps them.
87
+ const canSpawnEffective = input.canSpawnOverride ?? input.agent.canSpawn ?? (input.parentTools === null);
88
+ if (!canSpawnEffective) {
89
+ for (const name of [...allowed]) {
90
+ if (spawnTools.has(name)) {
91
+ allowed.delete(name);
92
+ dropped.push({ tool: name, reason: 'no-spawn' });
93
+ }
94
+ }
95
+ }
96
+ // 6. Deny-list always wins.
97
+ for (const name of [...allowed]) {
98
+ if (denied.has(name)) {
99
+ allowed.delete(name);
100
+ dropped.push({ tool: name, reason: 'denied' });
101
+ }
102
+ }
103
+ return {
104
+ allowed: [...allowed].sort(),
105
+ dropped: dropped.sort((a, b) => a.tool.localeCompare(b.tool)),
106
+ };
107
+ }
108
+ /**
109
+ * Intersect two filesystem allow-lists. `undefined` on either side means
110
+ * "unconstrained" — the other side applies as-is; both `undefined` stays
111
+ * `undefined`. Entries survive when they are equal to, or nested inside, an
112
+ * entry of the other list (so a parent `/repo` and a child `/repo/sub`
113
+ * intersect to `/repo/sub`). Comparison is case-insensitive on Windows.
114
+ */
115
+ export function intersectAllowedPaths(parentPaths, agentPaths) {
116
+ if (parentPaths === undefined)
117
+ return agentPaths;
118
+ if (agentPaths === undefined)
119
+ return [...parentPaths];
120
+ const out = [];
121
+ const seen = new Set();
122
+ const within = (candidate, scope) => {
123
+ const norm = (p) => {
124
+ const n = p.replace(/\\/g, '/').replace(/\/+$/, '');
125
+ return process.platform === 'win32' ? n.toLowerCase() : n;
126
+ };
127
+ const c = norm(candidate);
128
+ const s = norm(scope);
129
+ return c === s || c.startsWith(s + '/');
130
+ };
131
+ for (const a of agentPaths) {
132
+ for (const p of parentPaths) {
133
+ // The overlap of two nested scopes is the narrower one.
134
+ const narrower = within(a, p) ? a : within(p, a) ? p : undefined;
135
+ if (narrower !== undefined) {
136
+ const key = process.platform === 'win32' ? narrower.toLowerCase() : narrower;
137
+ if (!seen.has(key)) {
138
+ seen.add(key);
139
+ out.push(narrower);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ return out.sort();
145
+ }
146
+ /**
147
+ * One-shot capability resolver: tool intersection + per-agent model/maxDepth.
148
+ *
149
+ * `maxDepth` precedence (most specific wins):
150
+ * 1. `agent.maxDepth` (if set on the definition)
151
+ * 2. `extra.maxDepth` (parent / CLI cap)
152
+ * `model` is propagated only when the agent declares one; we do not silently
153
+ * override a parent's chosen model.
154
+ */
155
+ export function resolveCapabilities(input) {
156
+ const resolved = resolveAgentTools(input);
157
+ const allowed = new Set(resolved.allowed);
158
+ const model = input.agent.model;
159
+ const maxDepth = input.agent.maxDepth ?? input.maxDepth;
160
+ // Default matches resolveAgentTools: the root agent (parentTools === null)
161
+ // may spawn; children strip spawn tools unless explicitly enabled.
162
+ const canSpawn = input.canSpawnOverride ?? input.agent.canSpawn ?? (input.parentTools === null);
163
+ const allowedPaths = intersectAllowedPaths(input.parentAllowedPaths, input.agent.allowedPaths);
164
+ return {
165
+ allowed,
166
+ dropped: resolved.dropped,
167
+ ...(model !== undefined ? { model } : {}),
168
+ maxDepth,
169
+ readonly: input.agent.readonly ?? false,
170
+ canSpawn,
171
+ ...(allowedPaths !== undefined ? { allowedPaths } : {}),
172
+ };
173
+ }
174
+ /** Sensible defaults for "what counts as a write tool" if a caller doesn't override. */
175
+ export const DEFAULT_WRITE_TOOLS = new Set([
176
+ 'write_file',
177
+ 'edit_file',
178
+ 'multi_edit',
179
+ 'apply_patch',
180
+ 'shell_exec',
181
+ 'memory_write',
182
+ 'todo_write',
183
+ ]);
184
+ /** Default spawn tools — removed from children by default. */
185
+ export const DEFAULT_SPAWN_TOOLS = new Set([
186
+ 'spawn_agent',
187
+ ]);
188
+ /** Default deny-list — these are NEVER allowed, even if explicitly requested. */
189
+ export const DEFAULT_DENIED_TOOLS = new Set([
190
+ // Add dangerous tools here. Empty by default — extend as policy matures.
191
+ ]);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * G2 — Process isolation for sub-agents (V1: headless/CLI children).
3
+ *
4
+ * This module is the *child-process* end of spawn_agent. The orchestrator
5
+ * forks this file with `node` (never shares a process), passing a
6
+ * JSON-serializable payload on STDIN (not argv — the system prompt + memory
7
+ * can exceed argv/command-line length limits, which would fail the spawn with
8
+ * E2BIG). The child rebuilds a minimal but self-sufficient `RuntimeDeps`,
9
+ * runs the task through `run()`, emits ONE JSON `ChildResult` line on stdout,
10
+ * and exits 0/1.
11
+ *
12
+ * Isolation contract: a child that OOMs, segfaults, or enters an infinite
13
+ * loop fails ONLY this process — the parent harness survives and reports
14
+ * `CHILD_CRASH` exactly as it does for an in-process throw.
15
+ *
16
+ * V1 scope (documented honestly):
17
+ * - Headless children only. TUI children stay in-process because the Ink
18
+ * approval bridge is inherently tied to the parent terminal.
19
+ * - The child runs a system-prompt STRING (already assembled by the parent,
20
+ * so KLYRO.md + memory + trust decisions match) with a file-system tool
21
+ * subset and DenyAll approval — no MCP server forwarding, no live prompt.
22
+ * - Grandchild spawning is not forwarded: a process-isolated child rebuilds
23
+ * its own agent bridge-less deps, so its tools see NO_ORCHESTRATOR (same
24
+ * as a pre-bridge child). V2 may thread a task handle back via a socket.
25
+ *
26
+ * Secrets never cross the process boundary via argv/env leaks: the child
27
+ * reads its provider key/baseURL from the SAME env the parent CLI uses
28
+ * (KLYRO_API_KEY, KLYRO_BASE_URL), so credentials stay in-process/env,
29
+ * never in the payload.
30
+ */
31
+ import type { RunOptions, RunResult, RuntimeDeps } from './runtime.js';
32
+ export interface ChildWorkerPayload {
33
+ /** Working dir the child operates in (may be a worktree). */
34
+ cwd: string;
35
+ /** The task prompt. */
36
+ task: string;
37
+ /** Model string (drives adapter selection + rate table). */
38
+ model: string;
39
+ /** Pre-assembled system prompt (stable + volatile already spliced). */
40
+ systemPrompt: string;
41
+ /** Agent id, for the system prompt identity. */
42
+ agentId: string;
43
+ /** Cap caps. */
44
+ maxSteps?: number;
45
+ maxCost?: number;
46
+ maxTimeMs?: number;
47
+ maxTokens?: number;
48
+ }
49
+ /** The one-line result the child prints to stdout on success. */
50
+ export type ChildResult = Pick<RunResult, 'status' | 'steps' | 'toolCalls' | 'finalText' | 'hasEdits' | 'usage'>;
51
+ /**
52
+ * Differently-phrased crash, distinct from an in-process throw the parent
53
+ * would catch. The child either exited non-zero without a parseable result
54
+ * line, or produced a malformed result. Carries the raw stdout/stderr tail.
55
+ */
56
+ export declare class ChildCrashError extends Error {
57
+ readonly likelyCause: 'exit' | 'oom' | 'timeout';
58
+ readonly stderrTail?: string | undefined;
59
+ constructor(message: string, likelyCause: 'exit' | 'oom' | 'timeout', stderrTail?: string | undefined);
60
+ }
61
+ /**
62
+ * Resolve the compiled child-worker entry that *this* module's neighboring
63
+ * build (src or dist) produced. Both dev (tsx, .ts) and packaged (dist, .js)
64
+ * layouts live beside this file, so we probe for whichever exists.
65
+ */
66
+ export declare function workerEntryPath(): string;
67
+ export interface ForkChildOptions {
68
+ /** Abort the child process when this fires (wired to the parent signal). */
69
+ signal?: AbortSignal;
70
+ /** Abort+kill the child after this long (defaults to 10 min). */
71
+ timeoutMs?: number;
72
+ /** Capture point for stderr (for diagnostics, already included in error). */
73
+ onStderr?: (chunk: string) => void;
74
+ }
75
+ /**
76
+ * Fork the child-worker as a real OS process and await its single JSON
77
+ * ChildResult line. Secrets never reach argv: the payload rides stdin, the
78
+ * provider key/baseURL come from the inherited env. Non-zero exit / missing
79
+ * line → rejects with ChildCrashError so the orchestrator maps it to
80
+ * CHILD_CRASH exactly as an in-process throw does.
81
+ */
82
+ export declare function forkChild(entry: string, payload: ChildWorkerPayload, opts?: ForkChildOptions): Promise<ChildResult>;
83
+ /**
84
+ * Rebuild a minimal RuntimeDeps from the payload + inherited env. Centralized
85
+ * so tests can call it directly. Errors on missing provider config.
86
+ */
87
+ export declare function buildChildDeps(payload: ChildWorkerPayload): Promise<{
88
+ deps: RuntimeDeps;
89
+ options: RunOptions;
90
+ }>;
91
+ /**
92
+ * Read the JSON payload. Payloads arrive on stdin (the parent writes the JSON
93
+ * then ends stdin), never argv — large system prompts would otherwise fail
94
+ * the spawn with E2BIG on both Windows (32K cmdline) and Linux (MAX_ARG_STRLEN).
95
+ * Callers who must use argv (tests) can route through `runChildFromString`.
96
+ */
97
+ export declare function readPayloadFromStdin(): Promise<string>;
98
+ /**
99
+ * Main entry. Reads payload from stdin, runs, prints ONE JSON ChildResult
100
+ * line, exits 0 on complete, 1 on failure/crash (so the parent knows to mark
101
+ * CHILD_CRASH). Intended to be the sole behavior of this file when invoked as
102
+ * a real process.
103
+ */
104
+ export declare function main(): Promise<void>;