@ashlr/hub 2.2.0 → 3.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 (177) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +95 -8
  3. package/dist/api/plugin.d.ts +1 -0
  4. package/dist/cli/backlog.js +2 -0
  5. package/dist/cli/backlog.js.map +1 -1
  6. package/dist/cli/digest.d.ts +6 -0
  7. package/dist/cli/digest.js +127 -2
  8. package/dist/cli/digest.js.map +1 -1
  9. package/dist/cli/eval-fixtures.d.ts +18 -0
  10. package/dist/cli/eval-fixtures.js +38 -0
  11. package/dist/cli/eval-fixtures.js.map +1 -0
  12. package/dist/cli/eval.d.ts +56 -0
  13. package/dist/cli/eval.js +283 -0
  14. package/dist/cli/eval.js.map +1 -0
  15. package/dist/cli/fleet.d.ts +27 -0
  16. package/dist/cli/fleet.js +222 -0
  17. package/dist/cli/fleet.js.map +1 -0
  18. package/dist/cli/goal.d.ts +15 -0
  19. package/dist/cli/goal.js +91 -0
  20. package/dist/cli/goal.js.map +1 -0
  21. package/dist/cli/help.js +10 -3
  22. package/dist/cli/help.js.map +1 -1
  23. package/dist/cli/inbox.d.ts +6 -0
  24. package/dist/cli/inbox.js +183 -8
  25. package/dist/cli/inbox.js.map +1 -1
  26. package/dist/cli/index.js +50 -0
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/cli/loop.d.ts +16 -0
  29. package/dist/cli/loop.js +66 -0
  30. package/dist/cli/loop.js.map +1 -0
  31. package/dist/cli/mcp.d.ts +12 -0
  32. package/dist/cli/mcp.js +148 -0
  33. package/dist/cli/mcp.js.map +1 -1
  34. package/dist/cli/onboard.d.ts +16 -0
  35. package/dist/cli/onboard.js +71 -0
  36. package/dist/cli/onboard.js.map +1 -1
  37. package/dist/cli/plugins.js +9 -8
  38. package/dist/cli/plugins.js.map +1 -1
  39. package/dist/cli/pulse.d.ts +10 -0
  40. package/dist/cli/pulse.js +261 -0
  41. package/dist/cli/pulse.js.map +1 -1
  42. package/dist/cli/run.js +21 -3
  43. package/dist/cli/run.js.map +1 -1
  44. package/dist/cli/stack.d.ts +34 -0
  45. package/dist/cli/stack.js +221 -0
  46. package/dist/cli/stack.js.map +1 -0
  47. package/dist/cli/update.js +12 -5
  48. package/dist/cli/update.js.map +1 -1
  49. package/dist/core/daemon/loop.d.ts +10 -5
  50. package/dist/core/daemon/loop.js +167 -37
  51. package/dist/core/daemon/loop.js.map +1 -1
  52. package/dist/core/env-bridge.js +3 -2
  53. package/dist/core/env-bridge.js.map +1 -1
  54. package/dist/core/fleet/automerge-pass.d.ts +33 -0
  55. package/dist/core/fleet/automerge-pass.js +61 -0
  56. package/dist/core/fleet/automerge-pass.js.map +1 -0
  57. package/dist/core/fleet/quota.d.ts +70 -0
  58. package/dist/core/fleet/quota.js +198 -0
  59. package/dist/core/fleet/quota.js.map +1 -0
  60. package/dist/core/fleet/router.d.ts +46 -0
  61. package/dist/core/fleet/router.js +121 -0
  62. package/dist/core/fleet/router.js.map +1 -0
  63. package/dist/core/fleet/self.d.ts +58 -0
  64. package/dist/core/fleet/self.js +187 -0
  65. package/dist/core/fleet/self.js.map +1 -0
  66. package/dist/core/fleet/status.d.ts +54 -0
  67. package/dist/core/fleet/status.js +157 -0
  68. package/dist/core/fleet/status.js.map +1 -0
  69. package/dist/core/foundry/provenance.d.ts +78 -0
  70. package/dist/core/foundry/provenance.js +172 -0
  71. package/dist/core/foundry/provenance.js.map +1 -0
  72. package/dist/core/git.d.ts +13 -0
  73. package/dist/core/git.js +26 -0
  74. package/dist/core/git.js.map +1 -1
  75. package/dist/core/inbox/merge.d.ts +151 -0
  76. package/dist/core/inbox/merge.js +858 -0
  77. package/dist/core/inbox/merge.js.map +1 -0
  78. package/dist/core/inbox/store.js +10 -1
  79. package/dist/core/inbox/store.js.map +1 -1
  80. package/dist/core/integrations/markdown.d.ts +66 -0
  81. package/dist/core/integrations/markdown.js +132 -0
  82. package/dist/core/integrations/markdown.js.map +1 -0
  83. package/dist/core/integrations/secrets.d.ts +37 -0
  84. package/dist/core/integrations/secrets.js +67 -0
  85. package/dist/core/integrations/secrets.js.map +1 -0
  86. package/dist/core/integrations/stack.d.ts +47 -0
  87. package/dist/core/integrations/stack.js +107 -0
  88. package/dist/core/integrations/stack.js.map +1 -0
  89. package/dist/core/mcp-native-engineer.d.ts +123 -0
  90. package/dist/core/mcp-native-engineer.js +936 -0
  91. package/dist/core/mcp-native-engineer.js.map +1 -0
  92. package/dist/core/mcp-native.js +20 -42
  93. package/dist/core/mcp-native.js.map +1 -1
  94. package/dist/core/mcp-registry.js +2 -0
  95. package/dist/core/mcp-registry.js.map +1 -1
  96. package/dist/core/observability/codex-source.d.ts +53 -0
  97. package/dist/core/observability/codex-source.js +390 -0
  98. package/dist/core/observability/codex-source.js.map +1 -0
  99. package/dist/core/observability/estimate.d.ts +12 -0
  100. package/dist/core/observability/estimate.js +26 -0
  101. package/dist/core/observability/estimate.js.map +1 -1
  102. package/dist/core/observability/limits.d.ts +52 -0
  103. package/dist/core/observability/limits.js +316 -0
  104. package/dist/core/observability/limits.js.map +1 -0
  105. package/dist/core/observability/usage-source.js +3 -1
  106. package/dist/core/observability/usage-source.js.map +1 -1
  107. package/dist/core/portfolio/scanners.d.ts +1 -0
  108. package/dist/core/portfolio/scanners.js +40 -1
  109. package/dist/core/portfolio/scanners.js.map +1 -1
  110. package/dist/core/providers.js +4 -0
  111. package/dist/core/providers.js.map +1 -1
  112. package/dist/core/quality/health.js +1 -0
  113. package/dist/core/quality/health.js.map +1 -1
  114. package/dist/core/run/agent-loop.d.ts +8 -0
  115. package/dist/core/run/agent-loop.js +24 -14
  116. package/dist/core/run/agent-loop.js.map +1 -1
  117. package/dist/core/run/engine-registry.d.ts +48 -0
  118. package/dist/core/run/engine-registry.js +190 -0
  119. package/dist/core/run/engine-registry.js.map +1 -0
  120. package/dist/core/run/engines.d.ts +17 -3
  121. package/dist/core/run/engines.js +63 -44
  122. package/dist/core/run/engines.js.map +1 -1
  123. package/dist/core/run/learned-router.d.ts +121 -0
  124. package/dist/core/run/learned-router.js +374 -0
  125. package/dist/core/run/learned-router.js.map +1 -0
  126. package/dist/core/run/model-manager.js +3 -3
  127. package/dist/core/run/model-manager.js.map +1 -1
  128. package/dist/core/run/model-profile.d.ts +67 -0
  129. package/dist/core/run/model-profile.js +132 -0
  130. package/dist/core/run/model-profile.js.map +1 -0
  131. package/dist/core/run/orchestrator.d.ts +1 -1
  132. package/dist/core/run/orchestrator.js +642 -510
  133. package/dist/core/run/orchestrator.js.map +1 -1
  134. package/dist/core/run/prompts/budget.d.ts +25 -0
  135. package/dist/core/run/prompts/budget.js +51 -0
  136. package/dist/core/run/prompts/budget.js.map +1 -0
  137. package/dist/core/run/prompts/index.d.ts +14 -0
  138. package/dist/core/run/prompts/index.js +59 -0
  139. package/dist/core/run/prompts/index.js.map +1 -0
  140. package/dist/core/run/prompts/layers.d.ts +26 -0
  141. package/dist/core/run/prompts/layers.js +73 -0
  142. package/dist/core/run/prompts/layers.js.map +1 -0
  143. package/dist/core/run/prompts/roles.d.ts +23 -0
  144. package/dist/core/run/prompts/roles.js +48 -0
  145. package/dist/core/run/prompts/roles.js.map +1 -0
  146. package/dist/core/run/prompts/types.d.ts +38 -0
  147. package/dist/core/run/prompts/types.js +5 -0
  148. package/dist/core/run/prompts/types.js.map +1 -0
  149. package/dist/core/run/provider-client.d.ts +6 -24
  150. package/dist/core/run/provider-client.js +125 -61
  151. package/dist/core/run/provider-client.js.map +1 -1
  152. package/dist/core/run/router.js +8 -0
  153. package/dist/core/run/router.js.map +1 -1
  154. package/dist/core/run/sandboxed-engine.d.ts +66 -0
  155. package/dist/core/run/sandboxed-engine.js +262 -0
  156. package/dist/core/run/sandboxed-engine.js.map +1 -0
  157. package/dist/core/run/verify-commands.d.ts +83 -0
  158. package/dist/core/run/verify-commands.js +234 -0
  159. package/dist/core/run/verify-commands.js.map +1 -0
  160. package/dist/core/run/verify.d.ts +31 -1
  161. package/dist/core/run/verify.js +48 -0
  162. package/dist/core/run/verify.js.map +1 -1
  163. package/dist/core/sandbox/audit.d.ts +27 -0
  164. package/dist/core/sandbox/audit.js +34 -0
  165. package/dist/core/sandbox/audit.js.map +1 -1
  166. package/dist/core/sandbox/confine.d.ts +134 -0
  167. package/dist/core/sandbox/confine.js +370 -0
  168. package/dist/core/sandbox/confine.js.map +1 -0
  169. package/dist/core/types.d.ts +227 -5
  170. package/dist/core/web/api.js +77 -28
  171. package/dist/core/web/api.js.map +1 -1
  172. package/dist/core/web/control.d.ts +116 -0
  173. package/dist/core/web/control.js +307 -0
  174. package/dist/core/web/control.js.map +1 -0
  175. package/dist/core/web/public/app.js +512 -6
  176. package/dist/core/web/public/styles.css +468 -0
  177. package/package.json +3 -2
@@ -0,0 +1,936 @@
1
+ /**
2
+ * M42: Native engineering tool surface — REAL, sandboxed tools for the hub's
3
+ * local-model agent loop.
4
+ *
5
+ * The hub loop (src/core/run/agent-loop.ts) only EXECUTES a tool spec that
6
+ * carries a callable `fn` (it builds toolExecutors from `spec.fn`). The native
7
+ * gateway tools from `listNativeTools()` ship NO `fn`, so they were dead in the
8
+ * loop. This module fixes that two ways:
9
+ * 1. `buildEngineerToolSpecs(eng)` — NEW read/write/exec tools (read_file,
10
+ * glob, grep, write_file, edit_file, bash), each wrapped with a `fn` that
11
+ * runs through `callEngineerTool` (the gated pipeline below).
12
+ * 2. `buildNativeToolSpecsWithFn()` — wraps the existing 11 native tools so
13
+ * they are executable in-process via `callNativeTool`.
14
+ *
15
+ * SECURITY MODEL — defense in depth, in order of authority:
16
+ * 1. WORKSPACE BOUNDARY (the primary spine): every path is resolved with
17
+ * `resolveInside(root, p)`, which realpath-resolves both the root and the
18
+ * target and refuses anything that is not the root itself or a descendant
19
+ * of `root + sep`. `workspaceRoot` is an absolute sandbox worktree path —
20
+ * NEVER `/` or `~`. A symlink that points outside the root is rejected
21
+ * because realpath resolves it before the prefix check.
22
+ * 2. KILL SWITCH + ENROLLMENT (reused, NOT reinvented): every mutating tool
23
+ * calls `assertMayMutate(eng.sourceRepo, { allowAnyRepo: true })`, which
24
+ * ALWAYS throws when ~/.ashlr/KILL is present and (unless the test env seam
25
+ * ASHLR_TEST_ALLOW_ANY_REPO=1 is set) when the repo is not enrolled.
26
+ * `callEngineerTool` also refuses non-read tools when the kill switch is on
27
+ * BEFORE loading config or running a handler.
28
+ * 3. COMMAND DENY-LIST (`assertCommandAllowed`): a best-effort guard against
29
+ * egress / destructive shell verbs. This is DEFENSE IN DEPTH only — the
30
+ * worktree boundary + kill-switch + minimal env are the real containment.
31
+ * 4. MINIMAL ENV (`minimalEnv`): `bash` spawns with a hand-built env (PATH +
32
+ * a scratch HOME under os.tmpdir() + LANG) merged with ashlr's NON-SECRET
33
+ * keys via `withToolEnv`. The full `process.env` (and thus any API keys it
34
+ * carries) is NEVER handed to the shell.
35
+ *
36
+ * Every tool result is rendered through `renderToolText` (serialize + scrub
37
+ * secrets + 32KB cap), and every call (ok / refused / error) is audited as
38
+ * 'mcp:engineer-call'. `callEngineerTool` NEVER throws — failures surface as
39
+ * error text the model can read.
40
+ */
41
+ import { spawn } from 'node:child_process';
42
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync, realpathSync, } from 'node:fs';
43
+ import { tmpdir } from 'node:os';
44
+ import { execFileSync } from 'node:child_process';
45
+ import { resolve, sep, join, relative, basename, dirname } from 'node:path';
46
+ import { loadConfig } from './config.js';
47
+ import { renderToolText, listNativeTools, callNativeTool, } from './mcp-native.js';
48
+ import { assertMayMutate, killSwitchOn } from './sandbox/policy.js';
49
+ import { audit } from './sandbox/audit.js';
50
+ import { withToolEnv } from './env-bridge.js';
51
+ // ---------------------------------------------------------------------------
52
+ // Constants — bounds keep a tool reply from blowing agent context.
53
+ // ---------------------------------------------------------------------------
54
+ /** Max bytes read by read_file in a single call (renderToolText caps again). */
55
+ const MAX_READ_BYTES = 256 * 1024;
56
+ /**
57
+ * Hard ceiling on the on-disk size of a file read_file will load into memory.
58
+ * Checked via statSync BEFORE readFileSync so a multi-GB file is refused rather
59
+ * than OOMing the process (the line-window cap above only applies AFTER the
60
+ * whole file is already in memory).
61
+ */
62
+ const MAX_READ_FILE_BYTES = 1024 * 1024;
63
+ /**
64
+ * Basenames that look like secrets material — read_file refuses them and the JS
65
+ * grep fallback silently skips them. The knowledge indexer
66
+ * (src/core/knowledge/index.ts) defines its own SECRET_FILENAME_RE / SECRET_FILES
67
+ * but does NOT export them, so this is a deliberate local equivalent kept in sync
68
+ * with that intent: .env*, *.pem, *.key, id_rsa, *.p12, *.pfx, credentials, secret.
69
+ */
70
+ const SECRET_FILE_RE = /(^\.env)|(\.pem$)|(\.key$)|(id_rsa)|(\.p12$)|(\.pfx$)|credentials|secret/i;
71
+ /** Max matched paths returned by glob. */
72
+ const MAX_GLOB_RESULTS = 500;
73
+ /** Max files the JS grep fallback will scan. */
74
+ const MAX_GREP_FILES = 2000;
75
+ /** Max bytes of a single file the JS grep fallback will read. */
76
+ const MAX_GREP_FILE_BYTES = 512 * 1024;
77
+ /** Max matched lines grep returns. */
78
+ const MAX_GREP_LINES = 1000;
79
+ /** Hard byte cap on combined bash stdout+stderr capture (raw). */
80
+ const MAX_BASH_OUTPUT_BYTES = 256 * 1024;
81
+ /** Default bash timeout (ms) and clamp bounds. */
82
+ const DEFAULT_BASH_TIMEOUT_MS = 120_000;
83
+ const MIN_BASH_TIMEOUT_MS = 1;
84
+ const MAX_BASH_TIMEOUT_MS = 600_000;
85
+ /** Directory names the file walkers never descend into. */
86
+ const SKIP_DIRS = new Set([
87
+ 'node_modules', '.git', 'dist', 'build', '.next', '.turbo',
88
+ 'coverage', '__pycache__', '.cache', 'vendor', 'out', '.output',
89
+ '.vercel', '.serverless', 'target', '.yarn',
90
+ ]);
91
+ // ---------------------------------------------------------------------------
92
+ // Small helpers
93
+ // ---------------------------------------------------------------------------
94
+ /** Clamp `n` into [min, max]. NaN/non-finite collapses to `min`. */
95
+ export function clamp(n, min, max) {
96
+ if (!Number.isFinite(n))
97
+ return min;
98
+ return Math.max(min, Math.min(max, n));
99
+ }
100
+ /**
101
+ * The security spine: resolve `p` relative to `root` and refuse anything that
102
+ * escapes the workspace boundary. Both the root and the target are realpath-
103
+ * resolved so a symlink can never lead outside the worktree.
104
+ *
105
+ * For a NOT-YET-EXISTING target (e.g. a new file for write_file) a purely
106
+ * lexical resolve is NOT enough: a symlinked intermediate directory (the model
107
+ * does `ln -s /victim/.ssh evil` then `write_file('evil/authorized_keys', …)`)
108
+ * would escape because `resolve` never touches the filesystem. So we walk up to
109
+ * the NEAREST EXISTING ANCESTOR and canonicalize THAT (resolving any symlinked
110
+ * parent), then re-append the non-existing tail to the canonical base before the
111
+ * boundary check.
112
+ */
113
+ function resolveInside(root, p) {
114
+ // Canonicalize the root FIRST so the lexical join below is already in the
115
+ // realpath namespace (on macOS os.tmpdir() lives under /var -> /private/var,
116
+ // so resolving against the raw root and a canonical root never match for a
117
+ // not-yet-existing target).
118
+ const realRoot = realpathSync(root);
119
+ const abs = resolve(realRoot, p);
120
+ // Walk up to the nearest existing ancestor and canonicalize THAT (resolves any
121
+ // symlinked parent), then re-append the non-existing tail to the canonical base.
122
+ let probe = abs;
123
+ while (!existsSync(probe) && probe !== dirname(probe))
124
+ probe = dirname(probe);
125
+ const realProbe = realpathSync(probe);
126
+ const tail = relative(probe, abs);
127
+ const realAbs = tail ? join(realProbe, tail) : realProbe;
128
+ if (realAbs !== realRoot && !realAbs.startsWith(realRoot + sep)) {
129
+ throw new Error(`path escapes workspace boundary: ${p}`);
130
+ }
131
+ return realAbs;
132
+ }
133
+ /** True when `dir` is (or sits within) a git repo we can `git grep` in. */
134
+ function isGitRepo(dir) {
135
+ try {
136
+ execFileSync('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'], {
137
+ stdio: 'ignore',
138
+ timeout: 5_000,
139
+ });
140
+ return true;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ /**
147
+ * A scratch HOME under os.tmpdir() for spawned shells — so a tool can never
148
+ * read or clobber the real user home, and never inherit dotfile-sourced env.
149
+ * Created once per process; best-effort (falls back to tmpdir root). The dir is
150
+ * guaranteed to exist (mkdirSync recursive) so Windows tools that demand a real
151
+ * USERPROFILE/TEMP do not fail.
152
+ */
153
+ let scratchHomeCache = null;
154
+ function scratchHome() {
155
+ if (scratchHomeCache && existsSync(scratchHomeCache))
156
+ return scratchHomeCache;
157
+ try {
158
+ scratchHomeCache = mkdtempSync(join(tmpdir(), 'ashlr-engineer-home-'));
159
+ }
160
+ catch {
161
+ scratchHomeCache = tmpdir();
162
+ }
163
+ // Defensive: ensure the scratch home really exists on disk.
164
+ try {
165
+ mkdirSync(scratchHomeCache, { recursive: true });
166
+ }
167
+ catch {
168
+ /* best-effort; tmpdir() itself always exists */
169
+ }
170
+ return scratchHomeCache;
171
+ }
172
+ /**
173
+ * Build the MINIMAL, cross-platform env for a spawned shell. Deliberately does
174
+ * NOT pass the full process.env — that would leak ANTHROPIC_API_KEY and friends
175
+ * to the shell. The full process.env (and any API keys it carries) is NEVER
176
+ * handed to the shell; ashlr's non-secret config keys are layered on by the
177
+ * caller via withToolEnv.
178
+ *
179
+ * Always carried: PATH and a scratch home dir under os.tmpdir().
180
+ * - posix: HOME = scratch, LANG = process.env.LANG ?? 'C'.
181
+ * - win32: USERPROFILE = scratch, plus the handful of system vars Windows
182
+ * tools genuinely NEED to function (SystemRoot/windir/COMSPEC/PATHEXT/…)
183
+ * and TEMP/TMP pointed AT the scratch dir. No secrets are passed through.
184
+ */
185
+ export function minimalEnv() {
186
+ // Windows uses `Path`; POSIX uses `PATH`. Tolerate either source key.
187
+ const path = process.env.PATH ?? process.env.Path ?? '';
188
+ const scratch = scratchHome();
189
+ if (process.platform === 'win32') {
190
+ const env = {
191
+ PATH: path,
192
+ USERPROFILE: scratch,
193
+ TEMP: scratch,
194
+ TMP: scratch,
195
+ };
196
+ // Pass through ONLY the non-secret system vars Windows tools need to run.
197
+ // (cmd.exe, PowerShell, and most native tools fail without SystemRoot.)
198
+ const passthrough = [
199
+ 'SYSTEMROOT',
200
+ 'SystemRoot',
201
+ 'windir',
202
+ 'PATHEXT',
203
+ 'COMSPEC',
204
+ 'NUMBER_OF_PROCESSORS',
205
+ 'PROCESSOR_ARCHITECTURE',
206
+ ];
207
+ for (const key of passthrough) {
208
+ const val = process.env[key];
209
+ if (val !== undefined)
210
+ env[key] = val;
211
+ }
212
+ return env;
213
+ }
214
+ return {
215
+ PATH: path,
216
+ HOME: scratch,
217
+ LANG: process.env.LANG ?? 'C',
218
+ };
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Command deny-list (DEFENSE IN DEPTH — not the primary boundary)
222
+ // ---------------------------------------------------------------------------
223
+ /**
224
+ * Refuse a bash command that matches an egress / destructive / privilege verb.
225
+ *
226
+ * This is intentionally a coarse, defense-in-depth guard layered ON TOP of the
227
+ * real containment (workspace boundary + kill-switch + minimal env). It is NOT
228
+ * meant to be a complete shell parser — a determined adversary can obfuscate
229
+ * around any deny-list, which is exactly why the boundary + minimal env (no
230
+ * secrets, scratch HOME, cwd pinned to the worktree) are the load-bearing
231
+ * controls. Throws with a clear message on a match.
232
+ *
233
+ * NOTE: `bash` grants local code execution WITH network access; it must only be
234
+ * enabled (`--bash` / eng.allowExec) for goals you trust. This deny-list reduces
235
+ * the blast radius of an obviously-hostile command but is NOT the security
236
+ * boundary and must never be relied on as one.
237
+ */
238
+ export function assertCommandAllowed(command) {
239
+ const cmd = command.trim();
240
+ const lower = cmd.toLowerCase();
241
+ // Network egress: curl/wget (incl. the Windows curl.exe / wget.exe) are
242
+ // allowed ONLY when the target URL's host is exactly localhost / 127.0.0.1 /
243
+ // ::1. A naive substring check is bypassable (`http://localhost@evil.com`,
244
+ // `http://127.0.0.1.evil.com`), so parse the URL and compare the real
245
+ // hostname. `\bcurl\b` / `\bwget\b` also match `curl.exe` / `wget.exe`
246
+ // (the `.` is a word boundary), so the Windows variants route here too.
247
+ if (/\b(curl|wget)\b/.test(lower)) {
248
+ if (!curlWgetTargetsLocalhostOnly(cmd)) {
249
+ throw new Error('command refused: network egress (curl/wget) is only allowed to localhost');
250
+ }
251
+ }
252
+ const denials = [
253
+ { re: /\bgit\s+push\b/, why: 'git push (outward publish)' },
254
+ { re: /\bgit\s+remote\b/, why: 'git remote (mutates remotes)' },
255
+ { re: /\bgh\s/, why: 'gh CLI (GitHub outward actions)' },
256
+ { re: /\bnpm\s+publish\b/, why: 'npm publish' },
257
+ { re: /\byarn\s+publish\b/, why: 'yarn publish' },
258
+ { re: /\bpnpm\s+publish\b/, why: 'pnpm publish' },
259
+ { re: /\bssh\b/, why: 'ssh' },
260
+ { re: /\bscp\b/, why: 'scp' },
261
+ { re: /\brsync\b/, why: 'rsync' },
262
+ { re: /\bnc\s/, why: 'nc / netcat (network egress)' },
263
+ { re: /\bncat\b/, why: 'ncat (network egress)' },
264
+ { re: /\btelnet\b/, why: 'telnet (network egress)' },
265
+ { re: /\/dev\/tcp\//, why: '/dev/tcp/ (bash network socket)' },
266
+ { re: /\bnode\s+-e\b/, why: 'node -e (inline code execution)' },
267
+ { re: /\bpython3?\s+-c\b/, why: 'python -c (inline code execution)' },
268
+ { re: /\bruby\s+-e\b/, why: 'ruby -e (inline code execution)' },
269
+ { re: /\bperl\s+-e\b/, why: 'perl -e (inline code execution)' },
270
+ { re: /\bbase64\s+-d\b/, why: 'base64 -d (decode-to-execute primitive)' },
271
+ { re: /\bln\s+-s\b/, why: 'ln -s (symlink-escape primitive)' },
272
+ { re: /\bsudo\b/, why: 'sudo (privilege escalation)' },
273
+ { re: /\brm\s+-rf\s+\//, why: 'rm -rf / (catastrophic delete)' },
274
+ { re: /\brm\s+-rf\s+~/, why: 'rm -rf ~ (home delete)' },
275
+ { re: /:\(\)\s*\{/, why: 'fork bomb' },
276
+ { re: /\bmkfs\b/, why: 'mkfs (filesystem format)' },
277
+ { re: /\bdd\s+if=/, why: 'dd if= (raw device write)' },
278
+ { re: />\s*\/dev\//, why: 'redirect to /dev/ device' },
279
+ { re: /\bshutdown\b/, why: 'shutdown' },
280
+ { re: /\breboot\b/, why: 'reboot' },
281
+ // Windows destructive / egress verbs (defense in depth; matched lowercased).
282
+ { re: /\bdel\s+\//, why: 'del / (cmd recursive delete)' },
283
+ { re: /\brd\s+\/s\b/, why: 'rd /s (cmd recursive dir delete)' },
284
+ { re: /\brmdir\s+\/s\b/, why: 'rmdir /s (cmd recursive dir delete)' },
285
+ { re: /\bformat\s/, why: 'format (disk format)' },
286
+ { re: /\bremove-item\b[\s\S]*(-recurse|-force)\b/, why: 'Remove-Item -Recurse/-Force (PowerShell recursive/forced delete)' },
287
+ { re: /\binvoke-webrequest\b/, why: 'Invoke-WebRequest (PowerShell network egress)' },
288
+ { re: /\biwr\s/, why: 'iwr (Invoke-WebRequest alias, network egress)' },
289
+ { re: /\binvoke-restmethod\b/, why: 'Invoke-RestMethod (PowerShell network egress)' },
290
+ { re: /\bcertutil\b[\s\S]*-urlcache\b/, why: 'certutil -urlcache (download primitive)' },
291
+ { re: /\bbitsadmin\b/, why: 'bitsadmin (background download/egress)' },
292
+ { re: /\bpowershell\b[\s\S]*(-enc\b|-encodedcommand\b)/, why: 'powershell -EncodedCommand (obfuscated execution)' },
293
+ ];
294
+ for (const d of denials) {
295
+ if (d.re.test(lower)) {
296
+ throw new Error(`command refused by deny-list: ${d.why}`);
297
+ }
298
+ }
299
+ }
300
+ /** Hostnames that count as "localhost" for the curl/wget egress allow. */
301
+ const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
302
+ /**
303
+ * True iff EVERY http(s) URL in a curl/wget command resolves to a localhost
304
+ * host. Extracts URL-shaped tokens, parses each with `new URL()`, and compares
305
+ * the parsed hostname against the allow-set — so `http://localhost@evil.com`
306
+ * (host = evil.com) and `http://127.0.0.1.evil.com` (host = 127.0.0.1.evil.com)
307
+ * are correctly DENIED. If no URL token can be parsed, deny (fail closed).
308
+ */
309
+ function curlWgetTargetsLocalhostOnly(command) {
310
+ // Match http/https URL tokens (stop at whitespace, quotes, or shell metachars).
311
+ const urlTokens = command.match(/https?:\/\/[^\s'"`;|&<>]+/gi) ?? [];
312
+ if (urlTokens.length === 0)
313
+ return false; // fail closed: no parseable target
314
+ for (const token of urlTokens) {
315
+ let host;
316
+ try {
317
+ host = new URL(token).hostname.toLowerCase();
318
+ }
319
+ catch {
320
+ return false; // unparseable URL — fail closed
321
+ }
322
+ // URL.hostname wraps IPv6 in brackets; strip them for the set compare.
323
+ const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
324
+ if (!LOCALHOST_HOSTS.has(bare))
325
+ return false;
326
+ }
327
+ return true;
328
+ }
329
+ /**
330
+ * Resolve the shell + argv for the current platform.
331
+ * - win32: COMSPEC (or cmd.exe) with ['/d', '/s', '/c', command].
332
+ * - posix: /bin/bash when present, else /bin/sh, with ['-c', command].
333
+ */
334
+ function resolveShell(command) {
335
+ if (process.platform === 'win32') {
336
+ const shell = process.env.COMSPEC || 'cmd.exe';
337
+ return { shell, shellArgs: ['/d', '/s', '/c', command] };
338
+ }
339
+ const shell = existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh';
340
+ return { shell, shellArgs: ['-c', command] };
341
+ }
342
+ /**
343
+ * Execute `command` inside the workspace, contained in order:
344
+ * 1. assertMayMutate(sourceRepo) — kill-switch + enrollment gate.
345
+ * 2. assertCommandAllowed(command) — deny-list (defense in depth).
346
+ * 3. spawn the platform shell (posix: /bin/bash || /bin/sh -c; win32:
347
+ * cmd.exe /d /s /c) with cwd pinned to the worktree, minimal env (no
348
+ * secrets), a clamped timeout, SIGKILL on timeout, and a hard output cap.
349
+ *
350
+ * NOTE: on Windows the `bash` tool runs the command via cmd.exe — shell
351
+ * semantics differ from POSIX, but it is still confined to the sandbox cwd
352
+ * (eng.workspaceRoot) and the sanitized, secret-free minimal env.
353
+ */
354
+ async function runBash(args, cfg, eng) {
355
+ const command = typeof args['command'] === 'string' ? args['command'] : '';
356
+ if (command.trim() === '') {
357
+ throw new Error('bash: "command" is required and must be a non-empty string');
358
+ }
359
+ // 1. Kill-switch + enrollment gate (throws on KILL or non-enrolled repo).
360
+ assertMayMutate(eng.sourceRepo, { allowAnyRepo: true });
361
+ // 2. Deny-list (defense in depth).
362
+ assertCommandAllowed(command);
363
+ const rawTimeout = typeof args['timeout_ms'] === 'number' ? args['timeout_ms'] : DEFAULT_BASH_TIMEOUT_MS;
364
+ const timeout = clamp(Math.floor(rawTimeout), MIN_BASH_TIMEOUT_MS, MAX_BASH_TIMEOUT_MS);
365
+ // 3. Spawn with minimal env + ashlr non-secret keys layered on top.
366
+ const env = withToolEnv(cfg, minimalEnv());
367
+ return await new Promise((resolvePromise) => {
368
+ let stdout = '';
369
+ let stderr = '';
370
+ let stdoutBytes = 0;
371
+ let stderrBytes = 0;
372
+ let timedOut = false;
373
+ const { shell, shellArgs } = resolveShell(command);
374
+ const child = spawn(shell, shellArgs, {
375
+ cwd: eng.workspaceRoot,
376
+ env,
377
+ timeout,
378
+ killSignal: 'SIGKILL',
379
+ stdio: ['ignore', 'pipe', 'pipe'],
380
+ });
381
+ child.stdout?.on('data', (chunk) => {
382
+ if (stdoutBytes < MAX_BASH_OUTPUT_BYTES) {
383
+ const remaining = MAX_BASH_OUTPUT_BYTES - stdoutBytes;
384
+ stdout += chunk.toString('utf8', 0, Math.min(chunk.length, remaining));
385
+ stdoutBytes += chunk.length;
386
+ }
387
+ });
388
+ child.stderr?.on('data', (chunk) => {
389
+ if (stderrBytes < MAX_BASH_OUTPUT_BYTES) {
390
+ const remaining = MAX_BASH_OUTPUT_BYTES - stderrBytes;
391
+ stderr += chunk.toString('utf8', 0, Math.min(chunk.length, remaining));
392
+ stderrBytes += chunk.length;
393
+ }
394
+ });
395
+ child.on('error', (err) => {
396
+ // ETIMEDOUT surfaces here on some platforms; treat as timeout.
397
+ if (err.code === 'ETIMEDOUT')
398
+ timedOut = true;
399
+ resolvePromise({
400
+ exitCode: null,
401
+ stdout,
402
+ stderr: stderr || String(err),
403
+ timedOut,
404
+ });
405
+ });
406
+ child.on('close', (code, signal) => {
407
+ // node sets signal 'SIGKILL' when the timeout killed the child.
408
+ if (signal === 'SIGKILL')
409
+ timedOut = true;
410
+ resolvePromise({ exitCode: code, stdout, stderr, timedOut });
411
+ });
412
+ });
413
+ }
414
+ // ---------------------------------------------------------------------------
415
+ // Tool handlers
416
+ // ---------------------------------------------------------------------------
417
+ /** read_file — read a file inside the boundary, optional line window. */
418
+ async function handleReadFile(args, _cfg, eng) {
419
+ const p = typeof args['path'] === 'string' ? args['path'] : '';
420
+ if (p === '')
421
+ throw new Error('read_file: "path" is required');
422
+ const abs = resolveInside(eng.workspaceRoot, p);
423
+ // Refuse files that look like secrets material (keys, .env, credentials).
424
+ if (SECRET_FILE_RE.test(basename(abs))) {
425
+ throw new Error(`refused: "${p}" looks like a secrets file`);
426
+ }
427
+ const st = statSync(abs);
428
+ if (st.isDirectory())
429
+ throw new Error(`read_file: "${p}" is a directory`);
430
+ // Size guard BEFORE loading: refuse oversized files rather than OOMing.
431
+ if (st.size > MAX_READ_FILE_BYTES) {
432
+ throw new Error(`read_file: "${p}" is ${st.size} bytes, over the ${MAX_READ_FILE_BYTES}-byte ` +
433
+ 'read cap; use grep or offset/limit on a smaller window');
434
+ }
435
+ const raw = readFileSync(abs, 'utf8');
436
+ const all = raw.split('\n');
437
+ const offset = typeof args['offset'] === 'number' ? Math.max(0, Math.floor(args['offset'])) : 0;
438
+ const limit = typeof args['limit'] === 'number' ? Math.max(1, Math.floor(args['limit'])) : all.length;
439
+ const slice = all.slice(offset, offset + limit);
440
+ // Line-numbered, 1-based, capped by total bytes.
441
+ let out = '';
442
+ let bytes = 0;
443
+ let truncated = false;
444
+ for (let i = 0; i < slice.length; i++) {
445
+ const lineNo = offset + i + 1;
446
+ const line = `${String(lineNo).padStart(6, ' ')}\t${slice[i]}\n`;
447
+ if (bytes + line.length > MAX_READ_BYTES) {
448
+ truncated = true;
449
+ break;
450
+ }
451
+ out += line;
452
+ bytes += line.length;
453
+ }
454
+ return {
455
+ path: relative(eng.workspaceRoot, abs) || basename(abs),
456
+ totalLines: all.length,
457
+ offset,
458
+ returnedLines: out === '' ? 0 : out.split('\n').length - 1,
459
+ truncated,
460
+ content: out,
461
+ };
462
+ }
463
+ /** Recursively collect files under `dir` matching a simple glob `pattern`. */
464
+ function walkGlob(root, pattern) {
465
+ const re = globToRegExp(pattern);
466
+ const results = [];
467
+ function walk(dir) {
468
+ if (results.length >= MAX_GLOB_RESULTS)
469
+ return;
470
+ let entries;
471
+ try {
472
+ entries = readdirSync(dir, { withFileTypes: true });
473
+ }
474
+ catch {
475
+ return;
476
+ }
477
+ for (const entry of entries) {
478
+ if (results.length >= MAX_GLOB_RESULTS)
479
+ break;
480
+ if (entry.isSymbolicLink())
481
+ continue; // never follow symlinks out of the boundary
482
+ const full = join(dir, entry.name);
483
+ if (entry.isDirectory()) {
484
+ if (SKIP_DIRS.has(entry.name))
485
+ continue;
486
+ walk(full);
487
+ continue;
488
+ }
489
+ if (!entry.isFile())
490
+ continue;
491
+ const rel = relative(root, full);
492
+ if (re.test(rel))
493
+ results.push(rel);
494
+ }
495
+ }
496
+ walk(root);
497
+ return results.sort();
498
+ }
499
+ /**
500
+ * Translate a glob pattern into a RegExp matched against a repo-relative path.
501
+ * Supports `**` (any depth, including none), `*` (no path sep), and `?`.
502
+ */
503
+ function globToRegExp(pattern) {
504
+ let re = '';
505
+ for (let i = 0; i < pattern.length; i++) {
506
+ const c = pattern[i];
507
+ if (c === '*') {
508
+ if (pattern[i + 1] === '*') {
509
+ // ** — match across path separators (and an optional trailing slash).
510
+ re += '.*';
511
+ i++;
512
+ if (pattern[i + 1] === '/')
513
+ i++;
514
+ }
515
+ else {
516
+ re += '[^/]*';
517
+ }
518
+ }
519
+ else if (c === '?') {
520
+ re += '[^/]';
521
+ }
522
+ else if ('.+^${}()|[]\\'.includes(c)) {
523
+ re += '\\' + c;
524
+ }
525
+ else {
526
+ re += c;
527
+ }
528
+ }
529
+ return new RegExp(`^${re}$`);
530
+ }
531
+ /** glob — list files matching `pattern`, scoped to the boundary. */
532
+ async function handleGlob(args, _cfg, eng) {
533
+ const pattern = typeof args['pattern'] === 'string' ? args['pattern'] : '';
534
+ if (pattern === '')
535
+ throw new Error('glob: "pattern" is required');
536
+ const cwdArg = typeof args['cwd'] === 'string' && args['cwd'] !== '' ? args['cwd'] : '.';
537
+ const base = resolveInside(eng.workspaceRoot, cwdArg);
538
+ const matches = walkGlob(base, pattern);
539
+ return {
540
+ cwd: relative(eng.workspaceRoot, base) || '.',
541
+ pattern,
542
+ count: matches.length,
543
+ truncated: matches.length >= MAX_GLOB_RESULTS,
544
+ files: matches,
545
+ };
546
+ }
547
+ /** grep — `git grep` when the workspace is a git repo, else a bounded JS scan. */
548
+ async function handleGrep(args, _cfg, eng) {
549
+ const pattern = typeof args['pattern'] === 'string' ? args['pattern'] : '';
550
+ if (pattern === '')
551
+ throw new Error('grep: "pattern" is required');
552
+ const pathArg = typeof args['path'] === 'string' && args['path'] !== '' ? args['path'] : '.';
553
+ const base = resolveInside(eng.workspaceRoot, pathArg);
554
+ const globFilter = typeof args['glob'] === 'string' ? args['glob'] : undefined;
555
+ // --- git grep (preferred; arg arrays, NO shell) ---
556
+ if (isGitRepo(eng.workspaceRoot)) {
557
+ try {
558
+ const gitArgs = [
559
+ '-C', base,
560
+ 'grep', '-n', '-I',
561
+ '--no-color',
562
+ '--untracked', // also search new files not yet committed (still skips .gitignored)
563
+ '-e', pattern,
564
+ ];
565
+ if (globFilter) {
566
+ gitArgs.push('--', globFilter);
567
+ }
568
+ const out = execFileSync('git', gitArgs, {
569
+ timeout: 30_000,
570
+ encoding: 'utf8',
571
+ maxBuffer: 8 * 1024 * 1024,
572
+ });
573
+ const lines = out.split('\n').filter(Boolean).slice(0, MAX_GREP_LINES);
574
+ return {
575
+ engine: 'git-grep',
576
+ pattern,
577
+ count: lines.length,
578
+ truncated: lines.length >= MAX_GREP_LINES,
579
+ matches: lines,
580
+ };
581
+ }
582
+ catch (err) {
583
+ // git grep exits 1 when there are zero matches — that is NOT an error.
584
+ const code = err.status;
585
+ if (code === 1) {
586
+ return { engine: 'git-grep', pattern, count: 0, truncated: false, matches: [] };
587
+ }
588
+ // Any other failure: fall through to the JS scan.
589
+ }
590
+ }
591
+ // --- bounded JS scan fallback ---
592
+ let re;
593
+ try {
594
+ re = new RegExp(pattern);
595
+ }
596
+ catch {
597
+ // Treat an invalid regex as a literal substring search.
598
+ re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
599
+ }
600
+ const globRe = globFilter ? globToRegExp(globFilter) : null;
601
+ const matches = [];
602
+ let filesScanned = 0;
603
+ function walk(dir) {
604
+ if (matches.length >= MAX_GREP_LINES || filesScanned >= MAX_GREP_FILES)
605
+ return;
606
+ let entries;
607
+ try {
608
+ entries = readdirSync(dir, { withFileTypes: true });
609
+ }
610
+ catch {
611
+ return;
612
+ }
613
+ for (const entry of entries) {
614
+ if (matches.length >= MAX_GREP_LINES || filesScanned >= MAX_GREP_FILES)
615
+ break;
616
+ if (entry.isSymbolicLink())
617
+ continue;
618
+ const full = join(dir, entry.name);
619
+ if (entry.isDirectory()) {
620
+ if (SKIP_DIRS.has(entry.name))
621
+ continue;
622
+ walk(full);
623
+ continue;
624
+ }
625
+ if (!entry.isFile())
626
+ continue;
627
+ // Never read secrets material into a grep result — silently skip.
628
+ if (SECRET_FILE_RE.test(entry.name))
629
+ continue;
630
+ const rel = relative(eng.workspaceRoot, full);
631
+ if (globRe && !globRe.test(rel))
632
+ continue;
633
+ let st;
634
+ try {
635
+ st = statSync(full);
636
+ }
637
+ catch {
638
+ continue;
639
+ }
640
+ if (st.size > MAX_GREP_FILE_BYTES)
641
+ continue;
642
+ filesScanned++;
643
+ let text;
644
+ try {
645
+ text = readFileSync(full, 'utf8');
646
+ }
647
+ catch {
648
+ continue;
649
+ }
650
+ const fileLines = text.split('\n');
651
+ for (let i = 0; i < fileLines.length; i++) {
652
+ if (matches.length >= MAX_GREP_LINES)
653
+ break;
654
+ if (re.test(fileLines[i])) {
655
+ matches.push(`${rel}:${i + 1}:${fileLines[i]}`);
656
+ }
657
+ }
658
+ }
659
+ }
660
+ walk(base);
661
+ return {
662
+ engine: 'js-scan',
663
+ pattern,
664
+ count: matches.length,
665
+ truncated: matches.length >= MAX_GREP_LINES,
666
+ matches,
667
+ };
668
+ }
669
+ /** write_file — write content to a file inside the boundary (gated). */
670
+ async function handleWriteFile(args, _cfg, eng) {
671
+ const p = typeof args['path'] === 'string' ? args['path'] : '';
672
+ if (p === '')
673
+ throw new Error('write_file: "path" is required');
674
+ const content = typeof args['content'] === 'string' ? args['content'] : '';
675
+ // Kill-switch + enrollment gate (allowAnyRepo honored only under the env seam).
676
+ assertMayMutate(eng.sourceRepo, { allowAnyRepo: true });
677
+ const abs = resolveInside(eng.workspaceRoot, p);
678
+ mkdirSync(resolve(abs, '..'), { recursive: true });
679
+ writeFileSync(abs, content, 'utf8');
680
+ return {
681
+ written: true,
682
+ path: relative(eng.workspaceRoot, abs) || basename(abs),
683
+ bytes: Buffer.byteLength(content, 'utf8'),
684
+ };
685
+ }
686
+ /** edit_file — exact-match string replace (Claude Code edit semantics). */
687
+ async function handleEditFile(args, _cfg, eng) {
688
+ const p = typeof args['path'] === 'string' ? args['path'] : '';
689
+ if (p === '')
690
+ throw new Error('edit_file: "path" is required');
691
+ const oldString = typeof args['old_string'] === 'string' ? args['old_string'] : '';
692
+ const newString = typeof args['new_string'] === 'string' ? args['new_string'] : '';
693
+ const replaceAll = args['replace_all'] === true;
694
+ if (oldString === '')
695
+ throw new Error('edit_file: "old_string" is required and must be non-empty');
696
+ if (oldString === newString)
697
+ throw new Error('edit_file: "old_string" and "new_string" are identical');
698
+ // Kill-switch + enrollment gate.
699
+ assertMayMutate(eng.sourceRepo, { allowAnyRepo: true });
700
+ const abs = resolveInside(eng.workspaceRoot, p);
701
+ const original = readFileSync(abs, 'utf8');
702
+ // Count exact occurrences.
703
+ let count = 0;
704
+ let idx = original.indexOf(oldString);
705
+ while (idx !== -1) {
706
+ count++;
707
+ idx = original.indexOf(oldString, idx + oldString.length);
708
+ }
709
+ if (count === 0) {
710
+ throw new Error(`edit_file: old_string not found in "${p}" (0 matches)`);
711
+ }
712
+ if (count > 1 && !replaceAll) {
713
+ throw new Error(`edit_file: old_string is ambiguous in "${p}" (${count} matches); ` +
714
+ 'pass replace_all:true or provide a more specific old_string');
715
+ }
716
+ const updated = replaceAll
717
+ ? original.split(oldString).join(newString)
718
+ : original.replace(oldString, newString);
719
+ writeFileSync(abs, updated, 'utf8');
720
+ return {
721
+ edited: true,
722
+ path: relative(eng.workspaceRoot, abs) || basename(abs),
723
+ replacements: replaceAll ? count : 1,
724
+ };
725
+ }
726
+ /** bash — execute a command inside the workspace (gated + deny-listed). */
727
+ async function handleBash(args, cfg, eng) {
728
+ const r = await runBash(args, cfg, eng);
729
+ return {
730
+ exitCode: r.exitCode,
731
+ stdout: r.stdout,
732
+ stderr: r.stderr,
733
+ timedOut: r.timedOut,
734
+ };
735
+ }
736
+ const ENGINEER_TOOLS = [
737
+ {
738
+ name: 'read_file',
739
+ description: 'Read a UTF-8 text file inside the workspace. Returns line-numbered content; ' +
740
+ 'use offset/limit to window large files. Read-only.',
741
+ inputSchema: {
742
+ type: 'object',
743
+ properties: {
744
+ path: { type: 'string', description: 'Workspace-relative file path.' },
745
+ offset: { type: 'number', description: '0-based start line (default 0).' },
746
+ limit: { type: 'number', description: 'Max lines to return (default all).' },
747
+ },
748
+ required: ['path'],
749
+ },
750
+ safety: 'read',
751
+ handler: handleReadFile,
752
+ },
753
+ {
754
+ name: 'glob',
755
+ description: 'List files matching a glob pattern (supports **, *, ?) scoped to the workspace. ' +
756
+ `Skips node_modules/.git/build dirs; caps at ${MAX_GLOB_RESULTS} results. Read-only.`,
757
+ inputSchema: {
758
+ type: 'object',
759
+ properties: {
760
+ pattern: { type: 'string', description: 'Glob, e.g. "src/**/*.ts".' },
761
+ cwd: { type: 'string', description: 'Workspace-relative base dir (default ".").' },
762
+ },
763
+ required: ['pattern'],
764
+ },
765
+ safety: 'read',
766
+ handler: handleGlob,
767
+ },
768
+ {
769
+ name: 'grep',
770
+ description: 'Search file contents for a regex inside the workspace (uses git grep when ' +
771
+ 'available, else a bounded scan). Returns file:line:text matches. Read-only.',
772
+ inputSchema: {
773
+ type: 'object',
774
+ properties: {
775
+ pattern: { type: 'string', description: 'Regex (or literal) to search for.' },
776
+ path: { type: 'string', description: 'Workspace-relative dir to scope to (default ".").' },
777
+ glob: { type: 'string', description: 'Optional pathspec/glob filter, e.g. "*.ts".' },
778
+ },
779
+ required: ['pattern'],
780
+ },
781
+ safety: 'read',
782
+ handler: handleGrep,
783
+ },
784
+ {
785
+ name: 'write_file',
786
+ description: 'Write (create or overwrite) a UTF-8 file inside the workspace. Gated by the ' +
787
+ 'kill switch + repo enrollment and the workspace boundary.',
788
+ inputSchema: {
789
+ type: 'object',
790
+ properties: {
791
+ path: { type: 'string', description: 'Workspace-relative file path.' },
792
+ content: { type: 'string', description: 'Full file content to write.' },
793
+ },
794
+ required: ['path', 'content'],
795
+ },
796
+ safety: 'write',
797
+ handler: handleWriteFile,
798
+ },
799
+ {
800
+ name: 'edit_file',
801
+ description: 'Replace an exact string in a file inside the workspace. Errors if old_string ' +
802
+ 'matches 0 times, or >1 time without replace_all. Gated like write_file.',
803
+ inputSchema: {
804
+ type: 'object',
805
+ properties: {
806
+ path: { type: 'string', description: 'Workspace-relative file path.' },
807
+ old_string: { type: 'string', description: 'Exact text to find.' },
808
+ new_string: { type: 'string', description: 'Replacement text.' },
809
+ replace_all: { type: 'boolean', description: 'Replace every occurrence (default false).' },
810
+ },
811
+ required: ['path', 'old_string', 'new_string'],
812
+ },
813
+ safety: 'write',
814
+ handler: handleEditFile,
815
+ },
816
+ {
817
+ name: 'bash',
818
+ description: 'Run a shell command inside the workspace (cwd = worktree root, minimal env ' +
819
+ 'with NO secrets, clamped timeout). Gated by the kill switch + enrollment and ' +
820
+ 'a destructive/egress deny-list. Returns {exitCode, stdout, stderr, timedOut}.',
821
+ inputSchema: {
822
+ type: 'object',
823
+ properties: {
824
+ command: { type: 'string', description: 'Shell command to run via the platform shell (posix: /bin/bash || /bin/sh -c; win32: cmd.exe /c).' },
825
+ timeout_ms: { type: 'number', description: 'Timeout in ms (1..600000, default 120000).' },
826
+ },
827
+ required: ['command'],
828
+ },
829
+ safety: 'exec',
830
+ handler: handleBash,
831
+ },
832
+ ];
833
+ // ---------------------------------------------------------------------------
834
+ // callEngineerTool — the gated pipeline (mirrors callNativeTool)
835
+ // ---------------------------------------------------------------------------
836
+ /** Audit one engineer-tool outcome under the shared 'mcp:engineer-call' action. */
837
+ function auditEngineerCall(eng, summary, result) {
838
+ audit({
839
+ action: 'mcp:engineer-call',
840
+ repo: eng.sourceRepo ?? null,
841
+ sandboxId: null,
842
+ summary,
843
+ result,
844
+ });
845
+ }
846
+ /**
847
+ * Execute an engineer tool through the full safety pipeline. NEVER throws:
848
+ * unknown tools, capability/kill-switch refusals, and handler failures all
849
+ * surface as rendered error text. Every outcome is audited. The returned
850
+ * string is always rendered through `renderToolText` (scrub + 32KB cap).
851
+ */
852
+ export async function callEngineerTool(name, rawArgs, eng) {
853
+ const args = rawArgs !== null && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
854
+ ? rawArgs
855
+ : {};
856
+ const argKeys = Object.keys(args).sort().join(',') || '(none)';
857
+ const tool = ENGINEER_TOOLS.find((t) => t.name === name);
858
+ if (!tool) {
859
+ auditEngineerCall(eng, `${name} keys=${argKeys} — unknown engineer tool`, 'error');
860
+ return renderToolText(`Unknown engineer tool "${name}".`);
861
+ }
862
+ // ── Kill-switch gate: refuse all non-read tools when KILL is engaged ──────
863
+ if (tool.safety !== 'read' && killSwitchOn()) {
864
+ auditEngineerCall(eng, `${tool.name} keys=${argKeys} — refused: kill switch on`, 'refused');
865
+ return renderToolText(`${tool.name} refused: the ashlr kill switch is engaged (~/.ashlr/KILL). ` +
866
+ 'Read-only tools still work; writes/exec are disabled until `ashlr enroll kill off`.');
867
+ }
868
+ // ── Capability gates ──────────────────────────────────────────────────────
869
+ if (tool.safety === 'write' && !eng.allowWrite) {
870
+ auditEngineerCall(eng, `${tool.name} keys=${argKeys} — refused: write not allowed`, 'refused');
871
+ return renderToolText(`${tool.name} refused: write tools are not enabled for this session.`);
872
+ }
873
+ if (tool.safety === 'exec' && !eng.allowExec) {
874
+ auditEngineerCall(eng, `${tool.name} keys=${argKeys} — refused: exec not allowed`, 'refused');
875
+ return renderToolText(`${tool.name} refused: exec (bash) is not enabled for this session.`);
876
+ }
877
+ // ── Load config ───────────────────────────────────────────────────────────
878
+ let cfg;
879
+ try {
880
+ cfg = loadConfig();
881
+ }
882
+ catch (err) {
883
+ auditEngineerCall(eng, `${tool.name} keys=${argKeys} — config load failed`, 'error');
884
+ return renderToolText(`${tool.name} failed: could not load ~/.ashlr/config.json ` +
885
+ `(${err instanceof Error ? err.message : String(err)})`);
886
+ }
887
+ // ── Execute ───────────────────────────────────────────────────────────────
888
+ try {
889
+ const payload = await tool.handler(args, cfg, eng);
890
+ auditEngineerCall(eng, `${tool.name} keys=${argKeys} — ok`, 'ok');
891
+ return renderToolText(payload);
892
+ }
893
+ catch (err) {
894
+ const msg = err instanceof Error ? err.message : String(err);
895
+ auditEngineerCall(eng, `${tool.name} keys=${argKeys} — error: ${msg}`, 'error');
896
+ return renderToolText(`${tool.name} failed: ${msg}`);
897
+ }
898
+ }
899
+ /**
900
+ * Build executable specs for the engineer tools, filtered by capability:
901
+ * read tools always; write tools only when eng.allowWrite; exec tools only when
902
+ * eng.allowExec. Each spec's `fn` routes through `callEngineerTool` so the full
903
+ * gated pipeline (boundary + kill-switch + audit + render) always runs.
904
+ */
905
+ export function buildEngineerToolSpecs(eng) {
906
+ return ENGINEER_TOOLS.filter((t) => {
907
+ if (t.safety === 'write')
908
+ return eng.allowWrite;
909
+ if (t.safety === 'exec')
910
+ return eng.allowExec;
911
+ return true; // read
912
+ }).map((t) => ({
913
+ type: 'function',
914
+ function: { name: t.name, description: t.description, parameters: t.inputSchema },
915
+ name: t.name,
916
+ fn: (args) => callEngineerTool(t.name, args, eng),
917
+ }));
918
+ }
919
+ /**
920
+ * Wrap the existing native tools (from listNativeTools) so they are EXECUTABLE
921
+ * in-process via `callNativeTool`. This fixes the pre-existing dead-tool bug:
922
+ * the native specs the gateway exposes carry no `fn`, so the agent loop's
923
+ * toolExecutors map never picked them up.
924
+ */
925
+ export function buildNativeToolSpecsWithFn() {
926
+ return listNativeTools().map((t) => ({
927
+ type: 'function',
928
+ function: { name: t.name, description: t.description, parameters: t.inputSchema },
929
+ name: t.name,
930
+ fn: async (args) => {
931
+ const r = await callNativeTool(t.name, args);
932
+ return r.content[0]?.text ?? '';
933
+ },
934
+ }));
935
+ }
936
+ //# sourceMappingURL=mcp-native-engineer.js.map