klyro 0.1.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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/agent/anthropic-adapter.d.ts +75 -0
  4. package/dist/agent/anthropic-adapter.js +297 -0
  5. package/dist/agent/message.d.ts +40 -0
  6. package/dist/agent/message.js +21 -0
  7. package/dist/agent/observation.d.ts +47 -0
  8. package/dist/agent/observation.js +53 -0
  9. package/dist/agent/provider-adapter.d.ts +101 -0
  10. package/dist/agent/provider-adapter.js +254 -0
  11. package/dist/agent/registry.d.ts +42 -0
  12. package/dist/agent/registry.js +86 -0
  13. package/dist/agent/retry.d.ts +34 -0
  14. package/dist/agent/retry.js +91 -0
  15. package/dist/agent/runtime.d.ts +130 -0
  16. package/dist/agent/runtime.js +221 -0
  17. package/dist/agent/worker-spawner.d.ts +41 -0
  18. package/dist/agent/worker-spawner.js +76 -0
  19. package/dist/chat.d.ts +39 -0
  20. package/dist/chat.js +235 -0
  21. package/dist/chat.js.map +1 -0
  22. package/dist/cli/eval.d.ts +75 -0
  23. package/dist/cli/eval.js +190 -0
  24. package/dist/cli/repl.d.ts +15 -0
  25. package/dist/cli/repl.js +194 -0
  26. package/dist/cli/run.d.ts +67 -0
  27. package/dist/cli/run.js +198 -0
  28. package/dist/cli/slash/parser.d.ts +39 -0
  29. package/dist/cli/slash/parser.js +47 -0
  30. package/dist/context/level6.d.ts +45 -0
  31. package/dist/context/level6.js +231 -0
  32. package/dist/context/level7.d.ts +84 -0
  33. package/dist/context/level7.js +156 -0
  34. package/dist/context/project-map.d.ts +48 -0
  35. package/dist/context/project-map.js +438 -0
  36. package/dist/context/repo-map.d.ts +30 -0
  37. package/dist/context/repo-map.js +168 -0
  38. package/dist/context/selector.d.ts +22 -0
  39. package/dist/context/selector.js +37 -0
  40. package/dist/context/snippets.d.ts +17 -0
  41. package/dist/context/snippets.js +15 -0
  42. package/dist/context/tokenizer.d.ts +49 -0
  43. package/dist/context/tokenizer.js +97 -0
  44. package/dist/eval/harness.d.ts +48 -0
  45. package/dist/eval/harness.js +149 -0
  46. package/dist/eval/tasks.d.ts +6 -0
  47. package/dist/eval/tasks.js +98 -0
  48. package/dist/index.d.ts +14 -0
  49. package/dist/index.js +128 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/persistence/audit.d.ts +75 -0
  52. package/dist/persistence/audit.js +15 -0
  53. package/dist/persistence/store.d.ts +67 -0
  54. package/dist/persistence/store.js +106 -0
  55. package/dist/policy/approval.d.ts +35 -0
  56. package/dist/policy/approval.js +54 -0
  57. package/dist/policy/engine.d.ts +76 -0
  58. package/dist/policy/engine.js +163 -0
  59. package/dist/policy/path-guard.d.ts +30 -0
  60. package/dist/policy/path-guard.js +100 -0
  61. package/dist/policy/secret-redactor.d.ts +19 -0
  62. package/dist/policy/secret-redactor.js +52 -0
  63. package/dist/providers.d.ts +23 -0
  64. package/dist/providers.js +83 -0
  65. package/dist/repl.d.ts +9 -0
  66. package/dist/repl.js +170 -0
  67. package/dist/repl.js.map +1 -0
  68. package/dist/tools/fs/edit-file.d.ts +48 -0
  69. package/dist/tools/fs/edit-file.js +84 -0
  70. package/dist/tools/fs/list-dir.d.ts +40 -0
  71. package/dist/tools/fs/list-dir.js +72 -0
  72. package/dist/tools/fs/read-file.d.ts +66 -0
  73. package/dist/tools/fs/read-file.js +75 -0
  74. package/dist/tools/fs/write-file.d.ts +24 -0
  75. package/dist/tools/fs/write-file.js +48 -0
  76. package/dist/tools/git/git-diff.d.ts +35 -0
  77. package/dist/tools/git/git-diff.js +68 -0
  78. package/dist/tools/git/git-status.d.ts +29 -0
  79. package/dist/tools/git/git-status.js +57 -0
  80. package/dist/tools/normalize.d.ts +31 -0
  81. package/dist/tools/normalize.js +88 -0
  82. package/dist/tools/registry.d.ts +23 -0
  83. package/dist/tools/registry.js +87 -0
  84. package/dist/tools/schema.d.ts +29 -0
  85. package/dist/tools/schema.js +183 -0
  86. package/dist/tools/search/dependencies.d.ts +44 -0
  87. package/dist/tools/search/dependencies.js +188 -0
  88. package/dist/tools/search/glob.d.ts +26 -0
  89. package/dist/tools/search/glob.js +88 -0
  90. package/dist/tools/search/grep.d.ts +52 -0
  91. package/dist/tools/search/grep.js +125 -0
  92. package/dist/tools/search/recent-files.d.ts +33 -0
  93. package/dist/tools/search/recent-files.js +100 -0
  94. package/dist/tools/search/search-files.d.ts +39 -0
  95. package/dist/tools/search/search-files.js +110 -0
  96. package/dist/tools/shell/shell-exec.d.ts +60 -0
  97. package/dist/tools/shell/shell-exec.js +133 -0
  98. package/dist/tools/types.d.ts +54 -0
  99. package/dist/tools/types.js +15 -0
  100. package/dist/tools/verify/run-verify.d.ts +60 -0
  101. package/dist/tools/verify/run-verify.js +115 -0
  102. package/dist/tui/app.d.ts +27 -0
  103. package/dist/tui/app.js +79 -0
  104. package/dist/tui/app.test.d.ts +1 -0
  105. package/dist/tui/app.test.js +77 -0
  106. package/dist/tui/status.d.ts +21 -0
  107. package/dist/tui/status.js +13 -0
  108. package/dist/tui/status.test.d.ts +1 -0
  109. package/dist/tui/status.test.js +39 -0
  110. package/dist/tui/transcript.d.ts +40 -0
  111. package/dist/tui/transcript.js +28 -0
  112. package/dist/tui/transcript.test.d.ts +1 -0
  113. package/dist/tui/transcript.test.js +71 -0
  114. package/dist/verification/detect.d.ts +29 -0
  115. package/dist/verification/detect.js +85 -0
  116. package/dist/verification/engine.d.ts +25 -0
  117. package/dist/verification/engine.js +57 -0
  118. package/package.json +58 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * grep — regex search over file contents. Bounded results.
3
+ */
4
+ import { z } from 'zod';
5
+ declare const InputSchema: z.ZodObject<{
6
+ pattern: z.ZodString;
7
+ cwd: z.ZodOptional<z.ZodString>;
8
+ include: z.ZodOptional<z.ZodString>;
9
+ maxResults: z.ZodOptional<z.ZodNumber>;
10
+ contextLines: z.ZodOptional<z.ZodNumber>;
11
+ }, z.core.$strip>;
12
+ export interface GrepHit {
13
+ file: string;
14
+ line: number;
15
+ text: string;
16
+ context?: {
17
+ before: string[];
18
+ after: string[];
19
+ };
20
+ }
21
+ export interface GrepOutput {
22
+ pattern: string;
23
+ hits: GrepHit[];
24
+ truncated: boolean;
25
+ searchedFiles: number;
26
+ }
27
+ export declare const grepTool: import("../types.js").Tool<{
28
+ pattern: string;
29
+ cwd?: string | undefined;
30
+ include?: string | undefined;
31
+ maxResults?: number | undefined;
32
+ contextLines?: number | undefined;
33
+ }, {
34
+ readonly ok: false;
35
+ readonly error: {
36
+ readonly code: "INVALID_INPUT";
37
+ readonly message: `Invalid regex: ${string}`;
38
+ };
39
+ pattern?: undefined;
40
+ hits?: undefined;
41
+ truncated?: undefined;
42
+ searchedFiles?: undefined;
43
+ } | {
44
+ pattern: string;
45
+ hits: GrepHit[];
46
+ truncated: false;
47
+ searchedFiles: number;
48
+ readonly ok?: undefined;
49
+ readonly error?: undefined;
50
+ }>;
51
+ export type GrepInput = z.infer<typeof InputSchema>;
52
+ export {};
@@ -0,0 +1,125 @@
1
+ /**
2
+ * grep — regex search over file contents. Bounded results.
3
+ */
4
+ import * as path from 'node:path';
5
+ import * as fs from 'node:fs/promises';
6
+ import { z } from 'zod';
7
+ import { defineTool } from '../types.js';
8
+ import { resolveWithinCwd } from '../../policy/path-guard.js';
9
+ import { safe, TOOL_ERROR_CODES } from '../normalize.js';
10
+ const InputSchema = z.object({
11
+ pattern: z.string().min(1).describe('JavaScript regular expression (not ripgrep syntax)'),
12
+ cwd: z.string().optional(),
13
+ include: z.string().optional().describe('Glob filter for included files (e.g. "*.ts")'),
14
+ maxResults: z.number().int().min(1).max(5_000).optional().describe('Cap hits (default 500)'),
15
+ contextLines: z.number().int().min(0).max(5).optional().describe('Lines of context around each hit (default 0)'),
16
+ });
17
+ const DEFAULT_MAX = 500;
18
+ const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
19
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'coverage', '.klyro']);
20
+ const SKIP_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.woff', '.woff2', '.ttf', '.otf', '.exe', '.dll', '.bin', '.zip', '.tar', '.gz']);
21
+ export const grepTool = defineTool({
22
+ name: 'grep',
23
+ description: 'Search file contents with a JavaScript regex. Skips binary files and common build dirs. Returns hits with optional context lines.',
24
+ inputSchema: InputSchema,
25
+ execute: async (input, ctx) => {
26
+ return safe(async () => {
27
+ const base = input.cwd ? resolveWithinCwd(ctx.cwd, input.cwd).resolved : ctx.cwd;
28
+ let re;
29
+ try {
30
+ re = new RegExp(input.pattern, 'g');
31
+ }
32
+ catch (e) {
33
+ return {
34
+ ok: false,
35
+ error: { code: TOOL_ERROR_CODES.INVALID_INPUT, message: `Invalid regex: ${e instanceof Error ? e.message : String(e)}` },
36
+ };
37
+ }
38
+ const incRe = input.include ? globToRegex(input.include) : null;
39
+ const max = input.maxResults ?? DEFAULT_MAX;
40
+ const ctxLines = input.contextLines ?? 0;
41
+ const hits = [];
42
+ let searched = 0;
43
+ let truncated = false;
44
+ await walk(base, async (file) => {
45
+ if (hits.length >= max) {
46
+ truncated = true;
47
+ return;
48
+ }
49
+ if (incRe && !incRe.test(path.relative(base, file).split(path.sep).join('/')))
50
+ return;
51
+ const ext = path.extname(file).toLowerCase();
52
+ if (SKIP_EXT.has(ext))
53
+ return;
54
+ const stat = await fs.stat(file).catch(() => null);
55
+ if (!stat || stat.size > DEFAULT_MAX_FILE_BYTES)
56
+ return;
57
+ searched++;
58
+ const text = await fs.readFile(file, 'utf-8').catch(() => null);
59
+ if (text === null)
60
+ return;
61
+ if (text.includes('\0'))
62
+ return; // binary
63
+ const lines = text.split(/\r\n|\r|\n/);
64
+ for (let i = 0; i < lines.length && hits.length < max; i++) {
65
+ const line = lines[i];
66
+ if (line === undefined)
67
+ continue;
68
+ re.lastIndex = 0;
69
+ if (re.test(line)) {
70
+ const hit = {
71
+ file: path.relative(ctx.cwd, file),
72
+ line: i + 1,
73
+ text: line,
74
+ };
75
+ if (ctxLines > 0) {
76
+ hit.context = {
77
+ before: lines.slice(Math.max(0, i - ctxLines), i),
78
+ after: lines.slice(i + 1, i + 1 + ctxLines),
79
+ };
80
+ }
81
+ hits.push(hit);
82
+ }
83
+ }
84
+ });
85
+ return { pattern: input.pattern, hits, truncated, searchedFiles: searched };
86
+ });
87
+ },
88
+ });
89
+ function globToRegex(g) {
90
+ const escaped = g
91
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
92
+ .replace(/\*\*/g, '::DS::')
93
+ .replace(/\*/g, '[^/]*')
94
+ .replace(/\?/g, '[^/]')
95
+ .replace(/::DS::/g, '.*');
96
+ return new RegExp(`^${escaped}$`);
97
+ }
98
+ async function walk(dir, visit) {
99
+ let names;
100
+ try {
101
+ names = await fs.readdir(dir);
102
+ }
103
+ catch {
104
+ return;
105
+ }
106
+ for (const name of names) {
107
+ if (SKIP_DIRS.has(name))
108
+ continue;
109
+ const full = path.join(dir, name);
110
+ let isDir = false;
111
+ try {
112
+ const s = await fs.lstat(full);
113
+ if (s.isSymbolicLink())
114
+ continue;
115
+ isDir = s.isDirectory();
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ if (isDir)
121
+ await walk(full, visit);
122
+ else
123
+ await visit(full);
124
+ }
125
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * recent_files — list files modified recently, newest first.
3
+ *
4
+ * Answers "what changed lately?" without forcing the model to walk the tree.
5
+ * Skips the same dirs as glob/grep.
6
+ */
7
+ import { z } from 'zod';
8
+ declare const InputSchema: z.ZodObject<{
9
+ cwd: z.ZodOptional<z.ZodString>;
10
+ sinceHours: z.ZodOptional<z.ZodNumber>;
11
+ maxResults: z.ZodOptional<z.ZodNumber>;
12
+ glob: z.ZodOptional<z.ZodString>;
13
+ }, z.core.$strip>;
14
+ export interface RecentFile {
15
+ path: string;
16
+ mtimeMs: number;
17
+ ageHours: number;
18
+ }
19
+ export interface RecentFilesOutput {
20
+ files: RecentFile[];
21
+ truncated: boolean;
22
+ }
23
+ export declare const recentFilesTool: import("../types.js").Tool<{
24
+ cwd?: string | undefined;
25
+ sinceHours?: number | undefined;
26
+ maxResults?: number | undefined;
27
+ glob?: string | undefined;
28
+ }, {
29
+ files: RecentFile[];
30
+ truncated: boolean;
31
+ }>;
32
+ export type RecentFilesInput = z.infer<typeof InputSchema>;
33
+ export {};
@@ -0,0 +1,100 @@
1
+ /**
2
+ * recent_files — list files modified recently, newest first.
3
+ *
4
+ * Answers "what changed lately?" without forcing the model to walk the tree.
5
+ * Skips the same dirs as glob/grep.
6
+ */
7
+ import * as fs from 'node:fs/promises';
8
+ import * as path from 'node:path';
9
+ import { z } from 'zod';
10
+ import { defineTool } from '../types.js';
11
+ import { resolveWithinCwd } from '../../policy/path-guard.js';
12
+ import { safe } from '../normalize.js';
13
+ const InputSchema = z.object({
14
+ cwd: z.string().optional(),
15
+ sinceHours: z.number().int().min(0).max(24 * 365).optional().describe('Only files modified within this many hours (default 24)'),
16
+ maxResults: z.number().int().min(1).max(500).optional().describe('Cap results (default 50)'),
17
+ glob: z.string().optional().describe('Glob pattern to constrain (e.g. "src/**")'),
18
+ });
19
+ const DEFAULT_MAX = 50;
20
+ const DEFAULT_SINCE_HOURS = 24;
21
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'coverage', '.klyro']);
22
+ const SKIP_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.woff', '.woff2', '.ttf', '.otf', '.exe', '.dll', '.bin', '.zip', '.tar', '.gz', '.map']);
23
+ async function globToRegex(pattern) {
24
+ let p = pattern;
25
+ const anchor = p.startsWith('**/') ? '^(?:.*/)?' : p === '**' ? '^.*' : '^';
26
+ if (p.startsWith('**/'))
27
+ p = p.slice(3);
28
+ const escaped = p
29
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
30
+ .replace(/\*\*/g, '::DOUBLESTAR::')
31
+ .replace(/\*/g, '[^/]*')
32
+ .replace(/\?/g, '[^/]')
33
+ .replace(/::DOUBLESTAR::/g, '.*');
34
+ return new RegExp(`${anchor}${escaped}$`);
35
+ }
36
+ async function walk(root, dir, onFile) {
37
+ let names;
38
+ try {
39
+ names = await fs.readdir(dir);
40
+ }
41
+ catch {
42
+ return;
43
+ }
44
+ for (const name of names) {
45
+ if (SKIP_DIRS.has(name))
46
+ continue;
47
+ const full = path.join(dir, name);
48
+ let isDir = false;
49
+ try {
50
+ const s = await fs.lstat(full);
51
+ if (s.isSymbolicLink())
52
+ continue;
53
+ isDir = s.isDirectory();
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ if (isDir)
59
+ await walk(root, full, onFile);
60
+ else
61
+ await onFile(full);
62
+ }
63
+ }
64
+ export const recentFilesTool = defineTool({
65
+ name: 'recent_files',
66
+ description: 'List files in the repo modified within the last N hours, newest first. Use to answer "what changed recently?" efficiently.',
67
+ inputSchema: InputSchema,
68
+ execute: async (input, ctx) => {
69
+ return safe(async () => {
70
+ const base = input.cwd ? resolveWithinCwd(ctx.cwd, input.cwd).resolved : ctx.cwd;
71
+ const max = input.maxResults ?? DEFAULT_MAX;
72
+ const sinceHours = input.sinceHours ?? DEFAULT_SINCE_HOURS;
73
+ const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000;
74
+ const globRe = input.glob ? await globToRegex(input.glob) : null;
75
+ const results = [];
76
+ await walk(base, base, async (full) => {
77
+ const ext = path.extname(full).toLowerCase();
78
+ if (SKIP_EXT.has(ext))
79
+ return;
80
+ const rel = path.relative(ctx.cwd, full).split(path.sep).join('/');
81
+ if (globRe && !globRe.test(rel))
82
+ return;
83
+ let mtimeMs = 0;
84
+ try {
85
+ mtimeMs = (await fs.stat(full)).mtimeMs;
86
+ }
87
+ catch {
88
+ return;
89
+ }
90
+ if (mtimeMs < sinceMs)
91
+ return;
92
+ const ageHours = (Date.now() - mtimeMs) / (1000 * 60 * 60);
93
+ results.push({ path: rel, mtimeMs, ageHours });
94
+ });
95
+ results.sort((a, b) => b.mtimeMs - a.mtimeMs);
96
+ const truncated = results.length > max;
97
+ return { files: results.slice(0, max), truncated };
98
+ });
99
+ },
100
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * search_files — repo-aware file lookup with ranking.
3
+ *
4
+ * Returns files that match an optional glob AND/OR a regex against the file
5
+ * contents, ranked by:
6
+ * - path/regex match strength
7
+ * - recent modification time (newer = higher)
8
+ * - whether the file is a first-party source file (src/ etc.) vs vendored
9
+ *
10
+ * The agent uses this to answer "where is X?" without doing a full grep.
11
+ */
12
+ import { z } from 'zod';
13
+ declare const InputSchema: z.ZodObject<{
14
+ query: z.ZodOptional<z.ZodString>;
15
+ glob: z.ZodOptional<z.ZodString>;
16
+ cwd: z.ZodOptional<z.ZodString>;
17
+ maxResults: z.ZodOptional<z.ZodNumber>;
18
+ }, z.core.$strip>;
19
+ export interface RankedFile {
20
+ path: string;
21
+ score: number;
22
+ mtimeMs: number;
23
+ firstParty: boolean;
24
+ }
25
+ export interface SearchFilesOutput {
26
+ matches: RankedFile[];
27
+ truncated: boolean;
28
+ }
29
+ export declare const searchFilesTool: import("../types.js").Tool<{
30
+ query?: string | undefined;
31
+ glob?: string | undefined;
32
+ cwd?: string | undefined;
33
+ maxResults?: number | undefined;
34
+ }, {
35
+ matches: RankedFile[];
36
+ truncated: boolean;
37
+ }>;
38
+ export type SearchFilesInput = z.infer<typeof InputSchema>;
39
+ export {};
@@ -0,0 +1,110 @@
1
+ /**
2
+ * search_files — repo-aware file lookup with ranking.
3
+ *
4
+ * Returns files that match an optional glob AND/OR a regex against the file
5
+ * contents, ranked by:
6
+ * - path/regex match strength
7
+ * - recent modification time (newer = higher)
8
+ * - whether the file is a first-party source file (src/ etc.) vs vendored
9
+ *
10
+ * The agent uses this to answer "where is X?" without doing a full grep.
11
+ */
12
+ import * as fs from 'node:fs/promises';
13
+ import * as path from 'node:path';
14
+ import { z } from 'zod';
15
+ import { defineTool } from '../types.js';
16
+ import { resolveWithinCwd } from '../../policy/path-guard.js';
17
+ import { safe } from '../normalize.js';
18
+ import { globToRegex } from './glob.js';
19
+ const InputSchema = z.object({
20
+ query: z.string().optional().describe('Substring or regex to match against file paths (case-insensitive)'),
21
+ glob: z.string().optional().describe('Glob pattern to constrain candidates, e.g. "src/**/*.ts"'),
22
+ cwd: z.string().optional(),
23
+ maxResults: z.number().int().min(1).max(500).optional().describe('Cap results (default 50)'),
24
+ });
25
+ const DEFAULT_MAX = 50;
26
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo', 'coverage', '.klyro']);
27
+ const FIRST_PARTY_HINTS = ['src/', 'lib/', 'app/', 'pkg/'];
28
+ function queryToRegex(query) {
29
+ const escaped = query.replace(/[.+^${}()|[\]\\]/g, '\\$&');
30
+ return new RegExp(escaped, 'i');
31
+ }
32
+ async function walk(root, dir, onFile) {
33
+ let names;
34
+ try {
35
+ names = await fs.readdir(dir);
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ for (const name of names) {
41
+ if (SKIP_DIRS.has(name))
42
+ continue;
43
+ const full = path.join(dir, name);
44
+ let isDir = false;
45
+ try {
46
+ const s = await fs.lstat(full);
47
+ if (s.isSymbolicLink())
48
+ continue;
49
+ isDir = s.isDirectory();
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ if (isDir)
55
+ await walk(root, full, onFile);
56
+ else
57
+ await onFile(full);
58
+ }
59
+ }
60
+ export const searchFilesTool = defineTool({
61
+ name: 'search_files',
62
+ description: 'Find files in the repo, ranked by name/path match and recency. Use to answer "where is X?" questions efficiently. Pass `query` (substring) and/or `glob` to constrain candidates.',
63
+ inputSchema: InputSchema,
64
+ execute: async (input, ctx) => {
65
+ return safe(async () => {
66
+ const base = input.cwd ? resolveWithinCwd(ctx.cwd, input.cwd).resolved : ctx.cwd;
67
+ const max = input.maxResults ?? DEFAULT_MAX;
68
+ const matcher = input.query ? queryToRegex(input.query) : null;
69
+ const globRe = input.glob ? globToRegex(input.glob) : null;
70
+ const candidates = [];
71
+ const now = Date.now();
72
+ await walk(base, base, async (full) => {
73
+ const rel = path.relative(ctx.cwd, full).split(path.sep).join('/');
74
+ if (globRe && !globRe.test(rel))
75
+ return;
76
+ if (matcher && !matcher.test(rel))
77
+ return;
78
+ let mtimeMs = 0;
79
+ try {
80
+ const s = await fs.stat(full);
81
+ mtimeMs = s.mtimeMs;
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ const ageDays = Math.max(0, (now - mtimeMs) / (1000 * 60 * 60 * 24));
87
+ const firstParty = FIRST_PARTY_HINTS.some((h) => rel.startsWith(h));
88
+ let score = 0;
89
+ if (matcher) {
90
+ // Substring match weight depends on how much of the path matches.
91
+ const nameOnly = rel.split('/').pop() ?? rel;
92
+ if (nameOnly.toLowerCase() === input.query.toLowerCase())
93
+ score += 200;
94
+ else if (nameOnly.toLowerCase().includes(input.query.toLowerCase()))
95
+ score += 80;
96
+ else if (rel.toLowerCase().includes(input.query.toLowerCase()))
97
+ score += 40;
98
+ }
99
+ if (firstParty)
100
+ score += 20;
101
+ // Recency: 0..30 points, 0 days = 30, 30+ days = 0
102
+ score += Math.max(0, 30 - ageDays);
103
+ candidates.push({ path: rel, score, mtimeMs, firstParty });
104
+ });
105
+ candidates.sort((a, b) => b.score - a.score);
106
+ const truncated = candidates.length > max;
107
+ return { matches: candidates.slice(0, max), truncated };
108
+ });
109
+ },
110
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * shell_exec — run a shell command.
3
+ *
4
+ * Safety:
5
+ * - Hard-coded dangerous patterns are always blocked (fork bomb, rm -rf /, etc.)
6
+ * - Per-call timeout (default 30s)
7
+ * - stdout/stderr are truncated at 128 KiB so a runaway command can't OOM us
8
+ * - Non-zero exit is returned as a successful tool call (with exitCode in
9
+ * the output). The agent reads exitCode to decide what to do next.
10
+ */
11
+ import { z } from 'zod';
12
+ declare const InputSchema: z.ZodObject<{
13
+ command: z.ZodString;
14
+ cwd: z.ZodOptional<z.ZodString>;
15
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
16
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
17
+ }, z.core.$strip>;
18
+ export interface ShellOutput {
19
+ command: string;
20
+ exitCode: number | null;
21
+ signal: NodeJS.Signals | null;
22
+ stdout: string;
23
+ stderr: string;
24
+ durationMs: number;
25
+ timedOut: boolean;
26
+ truncated: boolean;
27
+ }
28
+ export declare const shellExecTool: import("../types.js").Tool<{
29
+ command: string;
30
+ cwd?: string | undefined;
31
+ timeoutMs?: number | undefined;
32
+ env?: Record<string, string> | undefined;
33
+ }, {
34
+ readonly ok: false;
35
+ readonly error: {
36
+ readonly code: "COMMAND_DENIED";
37
+ readonly message: `Command blocked: ${string}`;
38
+ };
39
+ command?: undefined;
40
+ exitCode?: undefined;
41
+ signal?: undefined;
42
+ stdout?: undefined;
43
+ stderr?: undefined;
44
+ durationMs?: undefined;
45
+ timedOut?: undefined;
46
+ truncated?: undefined;
47
+ } | {
48
+ command: string;
49
+ exitCode: number | null;
50
+ signal: NodeJS.Signals | null;
51
+ stdout: string;
52
+ stderr: string;
53
+ durationMs: number;
54
+ timedOut: boolean;
55
+ truncated: false;
56
+ readonly ok?: undefined;
57
+ readonly error?: undefined;
58
+ }>;
59
+ export type ShellInput = z.infer<typeof InputSchema>;
60
+ export {};
@@ -0,0 +1,133 @@
1
+ /**
2
+ * shell_exec — run a shell command.
3
+ *
4
+ * Safety:
5
+ * - Hard-coded dangerous patterns are always blocked (fork bomb, rm -rf /, etc.)
6
+ * - Per-call timeout (default 30s)
7
+ * - stdout/stderr are truncated at 128 KiB so a runaway command can't OOM us
8
+ * - Non-zero exit is returned as a successful tool call (with exitCode in
9
+ * the output). The agent reads exitCode to decide what to do next.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { z } from 'zod';
13
+ import { defineTool } from '../types.js';
14
+ import { resolveWithinCwd } from '../../policy/path-guard.js';
15
+ import { safe, TOOL_ERROR_CODES } from '../normalize.js';
16
+ const InputSchema = z.object({
17
+ command: z.string().min(1).describe('Shell command to execute (interpreted by /bin/sh -c on Unix, cmd.exe /c on Windows)'),
18
+ cwd: z.string().optional().describe('Working directory; defaults to the workspace cwd'),
19
+ timeoutMs: z.number().int().min(1).max(600_000).optional().describe('Timeout in milliseconds (default 30000, max 600000)'),
20
+ env: z.record(z.string(), z.string()).optional().describe('Extra env vars merged onto process.env'),
21
+ });
22
+ const DEFAULT_TIMEOUT_MS = 30_000;
23
+ const MAX_OUTPUT_BYTES = 128 * 1024;
24
+ // Hard-coded dangerous patterns. These are non-overridable in MVP.
25
+ const DANGEROUS_PATTERNS = [
26
+ { pattern: /rm\s+-rf?\s+\//, reason: 'recursive delete at filesystem root' },
27
+ { pattern: /del\s+\/s\s+\/q\s+[a-z]:\\/i, reason: 'recursive delete on Windows drive root' },
28
+ { pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, reason: 'fork bomb' },
29
+ { pattern: />\s*\/dev\/sd[a-z]/, reason: 'overwrite raw block device' },
30
+ { pattern: /mkfs(\.|\s)/, reason: 'format filesystem' },
31
+ { pattern: /dd\s+.*of=\/dev\//, reason: 'dd write to device' },
32
+ ];
33
+ export const shellExecTool = defineTool({
34
+ name: 'shell_exec',
35
+ description: 'Execute a shell command. Default timeout 30s. Output is truncated at 128 KiB. Returns exitCode (or null if killed by signal). Non-zero exit code is NOT a tool error — the tool succeeds and the agent sees the exit code in the output.',
36
+ inputSchema: InputSchema,
37
+ execute: async (input, ctx) => {
38
+ return safe(async () => {
39
+ for (const { pattern, reason } of DANGEROUS_PATTERNS) {
40
+ if (pattern.test(input.command)) {
41
+ return {
42
+ ok: false,
43
+ error: { code: TOOL_ERROR_CODES.COMMAND_DENIED, message: `Command blocked: ${reason}` },
44
+ };
45
+ }
46
+ }
47
+ let cwd = ctx.cwd;
48
+ if (input.cwd) {
49
+ cwd = resolveWithinCwd(ctx.cwd, input.cwd).resolved;
50
+ }
51
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
52
+ const env = { ...process.env, ...(input.env ?? {}) };
53
+ const start = Date.now();
54
+ const child = spawn(input.command, {
55
+ cwd,
56
+ env: env,
57
+ shell: true,
58
+ stdio: ['ignore', 'pipe', 'pipe'],
59
+ windowsHide: true,
60
+ signal: ctx.signal,
61
+ });
62
+ const stdoutChunks = [];
63
+ const stderrChunks = [];
64
+ let totalOut = 0;
65
+ let totalErr = 0;
66
+ let truncated = false;
67
+ child.stdout?.on('data', (chunk) => {
68
+ if (totalOut + chunk.length > MAX_OUTPUT_BYTES) {
69
+ truncated = true;
70
+ return;
71
+ }
72
+ stdoutChunks.push(chunk);
73
+ totalOut += chunk.length;
74
+ });
75
+ child.stderr?.on('data', (chunk) => {
76
+ if (totalErr + chunk.length > MAX_OUTPUT_BYTES) {
77
+ truncated = true;
78
+ return;
79
+ }
80
+ stderrChunks.push(chunk);
81
+ totalErr += chunk.length;
82
+ });
83
+ // Resolve on the FIRST of: process exit, or the timeout timer. Awaiting
84
+ // `close` alone would deadlock on Windows, where a grandchild (e.g. ping)
85
+ // inherits the stdio pipes and keeps them open after the shell wrapper
86
+ // exits. We listen to `exit` for the code and let the timer force-kill.
87
+ const outcome = await new Promise((resolve) => {
88
+ let done = false;
89
+ const finish = (o) => {
90
+ if (done)
91
+ return;
92
+ done = true;
93
+ clearTimeout(timer);
94
+ resolve(o);
95
+ };
96
+ const timer = setTimeout(() => {
97
+ // On Windows, `child.kill()` only kills the shell wrapper, leaving
98
+ // grandchildren (e.g. ping) holding the stdio pipes open. Use
99
+ // `taskkill /F /T` to nuke the entire process tree; on POSIX,
100
+ // -SIGKILL signals the group so children go too.
101
+ if (process.platform === 'win32') {
102
+ try {
103
+ require('node:child_process').spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
104
+ }
105
+ catch { /* already gone */ }
106
+ }
107
+ else {
108
+ try {
109
+ child.kill('SIGKILL');
110
+ }
111
+ catch { /* already dead */ }
112
+ }
113
+ finish({ code: null, signal: 'SIGKILL', timedOut: true });
114
+ }, timeoutMs);
115
+ child.on('exit', (code, signal) => finish({ code, signal, timedOut: false }));
116
+ child.on('error', () => finish({ code: null, signal: null, timedOut: false }));
117
+ });
118
+ const { code: exitCode, signal, timedOut } = outcome;
119
+ const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
120
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8');
121
+ return {
122
+ command: input.command,
123
+ exitCode,
124
+ signal,
125
+ stdout,
126
+ stderr,
127
+ durationMs: Date.now() - start,
128
+ timedOut,
129
+ truncated,
130
+ };
131
+ });
132
+ },
133
+ });