jev-agent-tools 0.1.4 → 0.3.0

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 (180) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/CONTRIBUTING.md +43 -0
  3. package/README.md +58 -17
  4. package/SECURITY.md +43 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +198 -0
  7. package/dist/adapters/ask-proof.js +200 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +234 -0
  11. package/dist/adapters/docs.js +192 -0
  12. package/dist/adapters/evidence-context.js +119 -0
  13. package/dist/adapters/exec.js +207 -0
  14. package/dist/adapters/files.js +418 -0
  15. package/dist/adapters/find.js +150 -0
  16. package/dist/adapters/git-base.js +32 -0
  17. package/dist/adapters/git-inventory.js +71 -0
  18. package/dist/adapters/git.js +483 -0
  19. package/dist/adapters/locate-file.js +197 -0
  20. package/dist/adapters/output-lines.js +46 -0
  21. package/dist/adapters/private-storage.js +106 -0
  22. package/dist/adapters/risk-callers.js +429 -0
  23. package/dist/adapters/runner-version.js +78 -0
  24. package/dist/adapters/shell.js +92 -0
  25. package/dist/adapters/syntax.js +187 -0
  26. package/dist/adapters/test-inventory.js +139 -0
  27. package/dist/adapters/usage.js +20 -0
  28. package/dist/adapters/utf8.js +47 -0
  29. package/dist/configuration.js +267 -0
  30. package/dist/constants.js +140 -0
  31. package/dist/core/ask-closure.js +282 -0
  32. package/dist/core/ask-proof.js +1 -0
  33. package/dist/core/ask-references.js +278 -0
  34. package/dist/core/asks.js +507 -0
  35. package/dist/core/batches.js +65 -0
  36. package/dist/core/command-output.js +224 -0
  37. package/dist/core/diff.js +178 -0
  38. package/dist/core/docs.js +302 -0
  39. package/dist/core/find.js +108 -0
  40. package/dist/core/git.js +1 -0
  41. package/dist/core/imports.js +550 -0
  42. package/dist/core/integrity.js +45 -0
  43. package/dist/core/lexical.js +132 -0
  44. package/dist/core/locate.js +169 -0
  45. package/dist/core/output.js +137 -0
  46. package/dist/core/pointer.js +29 -0
  47. package/dist/core/result-report.js +302 -0
  48. package/dist/core/risk-callers.js +851 -0
  49. package/dist/core/runner-version.js +45 -0
  50. package/dist/core/secret-path.js +34 -0
  51. package/dist/core/sections.js +230 -0
  52. package/dist/core/state.js +51 -0
  53. package/dist/core/syntax.js +1 -0
  54. package/dist/core/test-commands.js +334 -0
  55. package/dist/core/test-coverage.js +74 -0
  56. package/dist/core/test-discovery.js +1382 -0
  57. package/dist/core/test-evidence.js +527 -0
  58. package/dist/core/test-state.js +81 -0
  59. package/dist/core/truncate.js +12 -0
  60. package/dist/core/units.js +349 -0
  61. package/dist/describe.js +23 -0
  62. package/dist/guide.js +33 -0
  63. package/dist/host.js +24 -0
  64. package/dist/jev/client.js +456 -0
  65. package/dist/jev/pool.js +54 -0
  66. package/dist/jev/types.js +1 -0
  67. package/dist/mcp/main.js +124 -0
  68. package/dist/mcp/protocol.js +210 -0
  69. package/dist/mcp/tools.js +129 -0
  70. package/dist/presets/docs.js +62 -0
  71. package/dist/presets/risk.js +179 -0
  72. package/dist/presets/spec.js +81 -0
  73. package/dist/presets/witnesses.js +249 -0
  74. package/dist/render.js +114 -0
  75. package/dist/report-schema.js +1356 -0
  76. package/dist/result-types.js +1 -0
  77. package/dist/result.js +3 -0
  78. package/dist/runtime.js +1 -0
  79. package/dist/session.js +147 -0
  80. package/dist/texts/ask-files.js +3 -0
  81. package/dist/texts/ask.js +4 -0
  82. package/dist/texts/check-diff.js +20 -0
  83. package/dist/texts/configuration.js +1 -0
  84. package/dist/texts/find.js +19 -0
  85. package/dist/texts/guide.js +3 -0
  86. package/dist/texts/instructions.js +72 -0
  87. package/dist/texts/locate.js +15 -0
  88. package/dist/texts/select-tests.js +4 -0
  89. package/dist/tools/ask-files.js +450 -0
  90. package/dist/tools/ask-schema.js +70 -0
  91. package/dist/tools/ask.js +1147 -0
  92. package/dist/tools/check-diff.js +594 -0
  93. package/dist/tools/docs-check.js +408 -0
  94. package/dist/tools/find.js +682 -0
  95. package/dist/tools/locate.js +602 -0
  96. package/dist/tools/review-report.js +230 -0
  97. package/dist/tools/select-tests.js +821 -0
  98. package/dist/tools/spec-check.js +263 -0
  99. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  100. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  101. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  102. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  103. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  104. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  105. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  106. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  107. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  108. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  109. package/docs/agent-instructions.md +120 -0
  110. package/docs/design.md +16 -4
  111. package/docs/mcp.md +233 -0
  112. package/docs/tools/jev_ask.md +8 -5
  113. package/docs/tools/jev_ask_files.md +2 -1
  114. package/docs/tools/jev_check_diff.md +4 -1
  115. package/docs/tools/jev_find_files.md +2 -1
  116. package/docs/tools/jev_locate_in_file.md +5 -0
  117. package/docs/tools/jev_select_tests.md +4 -1
  118. package/package.json +19 -4
  119. package/rules/jev-ask.md +22 -1
  120. package/server.json +57 -0
  121. package/src/adapters/ask-files.ts +11 -3
  122. package/src/adapters/ask-proof.ts +69 -11
  123. package/src/adapters/canonical-path.ts +18 -0
  124. package/src/adapters/command.ts +102 -36
  125. package/src/adapters/docs.ts +33 -14
  126. package/src/adapters/evidence-context.ts +169 -0
  127. package/src/adapters/exec.ts +226 -0
  128. package/src/adapters/files.ts +146 -16
  129. package/src/adapters/find.ts +37 -7
  130. package/src/adapters/git-base.ts +7 -1
  131. package/src/adapters/git.ts +61 -8
  132. package/src/adapters/locate-file.ts +51 -9
  133. package/src/adapters/private-storage.ts +155 -0
  134. package/src/adapters/risk-callers.ts +7 -2
  135. package/src/adapters/shell.ts +113 -0
  136. package/src/adapters/test-inventory.ts +12 -4
  137. package/src/configuration.ts +55 -14
  138. package/src/constants.ts +37 -5
  139. package/src/core/ask-references.ts +262 -146
  140. package/src/core/asks.ts +79 -7
  141. package/src/core/command-output.ts +17 -1
  142. package/src/core/import-boundaries.ts +8 -3
  143. package/src/core/locate.ts +8 -5
  144. package/src/core/output.ts +34 -0
  145. package/src/core/result-report.ts +410 -0
  146. package/src/core/secret-path.ts +37 -0
  147. package/src/core/state.ts +8 -1
  148. package/src/core/units.ts +3 -2
  149. package/src/host.ts +11 -0
  150. package/src/index.ts +3 -0
  151. package/src/jev/client.ts +66 -16
  152. package/src/jev/types.ts +24 -3
  153. package/src/mcp/main.ts +135 -0
  154. package/src/mcp/protocol.ts +332 -0
  155. package/src/mcp/tools.ts +179 -0
  156. package/src/render.ts +109 -0
  157. package/src/report-schema.ts +1380 -0
  158. package/src/result-types.ts +234 -0
  159. package/src/result.ts +4 -1
  160. package/src/runtime.ts +6 -0
  161. package/src/session.ts +59 -0
  162. package/src/setup.ts +13 -5
  163. package/src/texts/ask-files.ts +4 -1
  164. package/src/texts/ask.ts +8 -1
  165. package/src/texts/check-diff.ts +7 -4
  166. package/src/texts/find.ts +8 -2
  167. package/src/texts/guide.ts +8 -16
  168. package/src/texts/instructions.ts +98 -0
  169. package/src/texts/locate.ts +8 -2
  170. package/src/texts/run-end.ts +2 -2
  171. package/src/texts/select-tests.ts +4 -1
  172. package/src/tools/ask-files.ts +311 -18
  173. package/src/tools/ask.ts +722 -95
  174. package/src/tools/check-diff.ts +337 -31
  175. package/src/tools/docs-check.ts +241 -38
  176. package/src/tools/find.ts +389 -29
  177. package/src/tools/locate.ts +387 -25
  178. package/src/tools/review-report.ts +308 -0
  179. package/src/tools/select-tests.ts +484 -23
  180. package/src/tools/spec-check.ts +194 -19
@@ -34,6 +34,7 @@ export async function readLocateFile(
34
34
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
35
35
  return {
36
36
  ok: false,
37
+ cause: "forbidden_path",
37
38
  error: `Internal URLs are not files; use read for ${path}.`,
38
39
  };
39
40
  const opened = await openRepoFile(cwd, path, { exec, signal });
@@ -41,7 +42,12 @@ export async function readLocateFile(
41
42
  const handle = opened.handle;
42
43
  try {
43
44
  const stat = await handle.stat();
44
- if (!stat.isFile()) return { ok: false, error: `Not a file: ${path}.` };
45
+ if (!stat.isFile())
46
+ return {
47
+ ok: false,
48
+ cause: "file_unavailable",
49
+ error: `Not a file: ${path}.`,
50
+ };
45
51
  const buffer = Buffer.alloc(
46
52
  Math.min(stat.size + 1, LOCATE_WHOLE_MAX_BYTES + 1),
47
53
  );
@@ -58,10 +64,19 @@ export async function readLocateFile(
58
64
  count += read.bytesRead;
59
65
  }
60
66
  if (buffer.subarray(0, count).includes(0))
61
- return { ok: false, error: `Not a text file: ${path}.` };
67
+ return {
68
+ ok: false,
69
+ cause: "binary_or_non_utf8",
70
+ error: `Not a text file: ${path}.`,
71
+ };
62
72
  if (count < buffer.length) {
63
73
  const decoded = decodeUtf8(buffer.subarray(0, count));
64
- if (!decoded.ok) return { ok: false, error: `${path}: ${decoded.error}` };
74
+ if (!decoded.ok)
75
+ return {
76
+ ok: false,
77
+ cause: "binary_or_non_utf8",
78
+ error: `${path}: ${decoded.error}`,
79
+ };
65
80
  const text = decoded.text;
66
81
  if (text.length <= LOCATE_WHOLE_MAX_CHARS)
67
82
  return {
@@ -85,7 +100,11 @@ export async function readLocateFile(
85
100
  }
86
101
  : scan;
87
102
  } catch (error) {
88
- return { ok: false, error: `Cannot read ${path}: ${String(error)}` };
103
+ return {
104
+ ok: false,
105
+ cause: signal?.aborted ? "cancelled" : "file_unavailable",
106
+ error: `Cannot read ${path}: ${String(error)}`,
107
+ };
89
108
  } finally {
90
109
  await handle.close();
91
110
  }
@@ -171,26 +190,49 @@ export async function scanRange(
171
190
  break;
172
191
  }
173
192
  if (buffer.subarray(0, read.bytesRead).includes(0))
174
- return { ok: false, error: `Not a text file: ${path}.` };
193
+ return {
194
+ ok: false,
195
+ cause: "binary_or_non_utf8",
196
+ error: `Not a text file: ${path}.`,
197
+ };
175
198
  bytes += read.bytesRead;
176
199
  const decoded = decode(buffer.subarray(0, read.bytesRead));
177
- if (!decoded.ok) return { ok: false, error: `${path}: ${decoded.error}` };
200
+ if (!decoded.ok)
201
+ return {
202
+ ok: false,
203
+ cause: "binary_or_non_utf8",
204
+ error: `${path}: ${decoded.error}`,
205
+ };
178
206
  if (!consume(decoded.text))
179
207
  return {
180
208
  ok: false,
209
+ cause: "evidence_too_large",
181
210
  error: `Selected range exceeds ${STATE_MAX_CHARS} chars; read a narrower range directly.`,
182
211
  };
183
212
  if (range && line > range.end) break;
184
213
  }
185
214
  const end = reachedEof ? decode() : { ok: true as const, text: "" };
186
- if (!end.ok) return { ok: false, error: `${path}: ${end.error}` };
215
+ if (!end.ok)
216
+ return {
217
+ ok: false,
218
+ cause: "binary_or_non_utf8",
219
+ error: `${path}: ${end.error}`,
220
+ };
187
221
  if (!consume(end.text))
188
- return { ok: false, error: "Selected range too large." };
222
+ return {
223
+ ok: false,
224
+ cause: "evidence_too_large",
225
+ error: "Selected range too large.",
226
+ };
189
227
  const lines = line - Number(!pending);
190
228
  if (lines > 0 && (windows.at(-1)?.end ?? 0) < lines) finishWindow(lines);
191
229
  return { ok: true, bytes, lines, windows, text };
192
230
  } catch (error) {
193
- return { ok: false, error: `Cannot read ${path}: ${String(error)}` };
231
+ return {
232
+ ok: false,
233
+ cause: signal?.aborted ? "cancelled" : "file_unavailable",
234
+ error: `Cannot read ${path}: ${String(error)}`,
235
+ };
194
236
  } finally {
195
237
  await handle.close();
196
238
  }
@@ -0,0 +1,155 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { Stats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { PRIVATE_STORAGE_TIMEOUT_MS } from "../constants.ts";
5
+ import { withoutApiKey } from "./shell.ts";
6
+
7
+ /**
8
+ * Owner-only storage, checked with each operating system's own model.
9
+ *
10
+ * POSIX: no group/other mode bits and owned by the current user.
11
+ * Windows: mode bits are synthesized (always 0o666), so the real ACL is read.
12
+ * Storage is private when every allow entry belongs to the current user,
13
+ * SYSTEM or the Administrators group (the Windows equivalent of root), and
14
+ * the owner is the current user or Administrators. Entries are compared as
15
+ * SIDs, so localized account names do not matter.
16
+ */
17
+ export interface PrivateStorage {
18
+ /** True when every path is private. One check for all paths. */
19
+ isPrivate(
20
+ entries: readonly { path: string; stat: Stats }[],
21
+ ): Promise<boolean>;
22
+ /** Restrict a directory the current user owns so new files inherit privacy. */
23
+ restrictDirectory(path: string): Promise<void>;
24
+ }
25
+
26
+ const SYSTEM = "S-1-5-18";
27
+ const ADMINISTRATORS = "S-1-5-32-544";
28
+ const CREATOR_OWNER = "S-1-3-0";
29
+
30
+ export const posixStorage: PrivateStorage = {
31
+ async isPrivate(entries) {
32
+ return entries.every(
33
+ ({ stat }) =>
34
+ (stat.mode & 0o077) === 0 &&
35
+ (!process.getuid || stat.uid === process.getuid()),
36
+ );
37
+ },
38
+ async restrictDirectory() {
39
+ // mkdir(..., { mode: 0o700 }) already creates POSIX directories privately.
40
+ },
41
+ };
42
+
43
+ // Paths arrive as JSON in an environment variable, never in the command text.
44
+ const CHECK = `$ErrorActionPreference = 'Stop'
45
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
46
+ $trusted = @($me, '${SYSTEM}', '${ADMINISTRATORS}')
47
+ $sid = [System.Security.Principal.SecurityIdentifier]
48
+ $results = foreach ($path in (ConvertFrom-Json $env:JEV_PRIVATE_PATHS)) {
49
+ try {
50
+ $acl = Get-Acl -LiteralPath $path
51
+ $owner = $acl.GetOwner($sid).Value
52
+ $private = ($owner -eq $me) -or ($owner -eq '${ADMINISTRATORS}')
53
+ foreach ($rule in $acl.GetAccessRules($true, $true, $sid)) {
54
+ if ($rule.AccessControlType -ne 'Allow') { continue }
55
+ $id = $rule.IdentityReference.Value
56
+ $inheritOnly = ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0
57
+ if ($id -eq '${CREATOR_OWNER}' -and $inheritOnly) { continue }
58
+ if ($trusted -notcontains $id) { $private = $false }
59
+ }
60
+ $private
61
+ } catch { $false }
62
+ }
63
+ ConvertTo-Json -Compress @($results)`;
64
+
65
+ const RESTRICT = `$ErrorActionPreference = 'Stop'
66
+ $path = $env:JEV_PRIVATE_PATH
67
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
68
+ $acl = Get-Acl -LiteralPath $path
69
+ if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'not owner' }
70
+ $acl.SetAccessRuleProtection($true, $false)
71
+ foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleAll($rule) }
72
+ $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
73
+ foreach ($id in @($me.Value, '${SYSTEM}', '${ADMINISTRATORS}')) {
74
+ $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
75
+ (New-Object System.Security.Principal.SecurityIdentifier($id)),
76
+ 'FullControl', $inherit, 'None', 'Allow')
77
+ $acl.AddAccessRule($rule)
78
+ }
79
+ Set-Acl -LiteralPath $path -AclObject $acl`;
80
+
81
+ /**
82
+ * Host environment for the ACL helper, without the Jev API key. PowerShell 7
83
+ * parents export PSModulePath, which stops Windows PowerShell 5.1 from loading
84
+ * its own Get-Acl module; 5.1 rebuilds it when unset.
85
+ */
86
+ export function powershellEnv(
87
+ host: NodeJS.ProcessEnv,
88
+ env: Record<string, string>,
89
+ ): NodeJS.ProcessEnv {
90
+ const childEnv = withoutApiKey({ ...host, ...env });
91
+ for (const key of Object.keys(childEnv))
92
+ if (key.toLowerCase() === "psmodulepath") delete childEnv[key];
93
+ return childEnv;
94
+ }
95
+
96
+ function powershell(
97
+ script: string,
98
+ env: Record<string, string>,
99
+ ): Promise<string> {
100
+ const root = process.env.SystemRoot ?? "C:\\Windows";
101
+ // A fixed system path, so a powershell.exe earlier on PATH is never used.
102
+ const executable = join(
103
+ root,
104
+ "System32",
105
+ "WindowsPowerShell",
106
+ "v1.0",
107
+ "powershell.exe",
108
+ );
109
+ const childEnv = powershellEnv(process.env, env);
110
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
111
+ execFile(
112
+ executable,
113
+ [
114
+ "-NoProfile",
115
+ "-NonInteractive",
116
+ "-ExecutionPolicy",
117
+ "Bypass",
118
+ // UTF-16LE base64 runs the script as one unit; stdin runs line by line.
119
+ "-EncodedCommand",
120
+ Buffer.from(script, "utf16le").toString("base64"),
121
+ ],
122
+ { env: childEnv, windowsHide: true, timeout: PRIVATE_STORAGE_TIMEOUT_MS },
123
+ (error, stdout) => (error ? reject(error) : resolve(stdout)),
124
+ );
125
+ return promise;
126
+ }
127
+
128
+ export const windowsStorage: PrivateStorage = {
129
+ async isPrivate(entries) {
130
+ if (!entries.length) return true;
131
+ try {
132
+ const output = await powershell(CHECK, {
133
+ JEV_PRIVATE_PATHS: JSON.stringify(entries.map(({ path }) => path)),
134
+ });
135
+ const results: unknown = JSON.parse(output.trim());
136
+ return (
137
+ Array.isArray(results) &&
138
+ results.length === entries.length &&
139
+ results.every((result) => result === true)
140
+ );
141
+ } catch {
142
+ // An ACL that cannot be read is never treated as private.
143
+ return false;
144
+ }
145
+ },
146
+ async restrictDirectory(path) {
147
+ await powershell(RESTRICT, { JEV_PRIVATE_PATH: path });
148
+ },
149
+ };
150
+
151
+ export function privateStorage(
152
+ platform: NodeJS.Platform = process.platform,
153
+ ): PrivateStorage {
154
+ return platform === "win32" ? windowsStorage : posixStorage;
155
+ }
@@ -1,5 +1,5 @@
1
1
  import { type FileHandle, lstat } from "node:fs/promises";
2
- import { resolve } from "node:path";
2
+ import { posix } from "node:path";
3
3
  import type { SgNode } from "@ast-grep/napi";
4
4
  import { CONCURRENCY, STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.ts";
5
5
  import type { GitExec } from "../core/git.ts";
@@ -9,6 +9,7 @@ import {
9
9
  type CallerSource,
10
10
  prepareRiskCallers,
11
11
  } from "../core/risk-callers.ts";
12
+ import { SECRET_NAME_GLOBS } from "../core/secret-path.ts";
12
13
  import type { SyntaxRootParser } from "../core/syntax.ts";
13
14
  import type { EvidenceUnit, SourceFile } from "../core/units.ts";
14
15
  import {
@@ -201,6 +202,8 @@ export async function collectRiskCallers(
201
202
  ref,
202
203
  "--",
203
204
  ...extensions.map((extension) => `*.${extension}`),
205
+ // Base-tree secret-named files are never searched or read.
206
+ ...SECRET_NAME_GLOBS.map((glob) => `:(exclude,icase,glob)**/${glob}`),
204
207
  ],
205
208
  { cwd, timeout: TIMEOUT_MS, signal: options.signal },
206
209
  ).catch((error: unknown) => ({
@@ -460,8 +463,10 @@ export async function collectRiskCallers(
460
463
  typeof source.beforePath === "string"
461
464
  ? source.beforePath
462
465
  : source.path;
466
+ // Repository paths are always "/"-separated; the platform
467
+ // resolve would yield "C:\\src\\..." on Windows.
463
468
  const basePath = specifier.startsWith(".")
464
- ? resolve("/", sourcePath, "..", specifier).slice(1)
469
+ ? posix.resolve("/", sourcePath, "..", specifier).slice(1)
465
470
  : specifier.replaceAll(".", "/");
466
471
  const runtimeSource = basePath.replace(
467
472
  /\.(js|jsx|mjs|cjs)$/,
@@ -0,0 +1,113 @@
1
+ import { accessSync, constants } from "node:fs";
2
+ // Candidates are Windows paths whatever the host OS, so the helpers use win32
3
+ // semantics explicitly; the host-native `node:path` splits PATH on ":" on POSIX.
4
+ import { win32 } from "node:path";
5
+
6
+ export interface ShellInvocation {
7
+ executable: string;
8
+ /** Arguments placed before the `-c` script. */
9
+ prefix: string[];
10
+ /** Exports prepended to the script when the launcher cannot set them. */
11
+ scriptPrefix: string;
12
+ }
13
+
14
+ export type ShellResolution =
15
+ | ({ ok: true } & ShellInvocation)
16
+ | { ok: false; error: string };
17
+
18
+ function executable(path: string): boolean {
19
+ try {
20
+ accessSync(path, constants.X_OK);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Windows ships `bash.exe` launchers for WSL in System32 (and SysWOW64,
29
+ * Sysnative) and in WindowsApps. They run in a Linux VM that cannot see Windows
30
+ * temporary paths, so they are never used for command evidence, including
31
+ * when named explicitly in JEV_TOOLS_BASH.
32
+ */
33
+ export function isWslLauncher(path: string): boolean {
34
+ const lower = win32.normalize(path).toLowerCase();
35
+ return (
36
+ /\\windows\\(system32|syswow64|sysnative)\\/.test(lower) ||
37
+ lower.includes("\\microsoft\\windowsapps\\")
38
+ );
39
+ }
40
+
41
+ /** Permitted Git for Windows bash candidates, most specific first. */
42
+ export function windowsBashCandidates(env: NodeJS.ProcessEnv): string[] {
43
+ const candidates: string[] = [];
44
+ if (env.JEV_TOOLS_BASH?.trim()) candidates.push(env.JEV_TOOLS_BASH.trim());
45
+ const pathEntries = (env.PATH ?? env.Path ?? "")
46
+ .split(win32.delimiter)
47
+ .filter(Boolean);
48
+ for (const entry of pathEntries) {
49
+ candidates.push(win32.join(entry, "bash.exe"));
50
+ // Git\cmd\git.exe or Git\bin\git.exe on PATH implies Git\bin\bash.exe.
51
+ if (/[\\/](cmd|bin)$/i.test(entry))
52
+ candidates.push(win32.join(win32.dirname(entry), "bin", "bash.exe"));
53
+ }
54
+ for (const root of [
55
+ env.ProgramFiles,
56
+ env["ProgramFiles(x86)"],
57
+ env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs"),
58
+ ])
59
+ if (root) candidates.push(win32.join(root, "Git", "bin", "bash.exe"));
60
+ // Filter every source, so no candidate can reintroduce a WSL launcher.
61
+ return [...new Set(candidates)].filter(
62
+ (candidate) => win32.isAbsolute(candidate) && !isWslLauncher(candidate),
63
+ );
64
+ }
65
+
66
+ const UNAVAILABLE =
67
+ "No permitted bash found: install Git for Windows or set JEV_TOOLS_BASH to the full path of a bash that is not the WSL launcher.";
68
+ /** Never handed to command children: the Jev API key stays with the extension. */
69
+ export const API_KEY_VARIABLE = "JEV_TOOLS_API_KEY";
70
+
71
+ /** Copy of `env` without the Jev API key (any case, for Windows). */
72
+ export function withoutApiKey(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
73
+ const copy = { ...env };
74
+ for (const key of Object.keys(copy))
75
+ if (key.toUpperCase() === API_KEY_VARIABLE) delete copy[key];
76
+ return copy;
77
+ }
78
+ let cached: ShellResolution | undefined;
79
+
80
+ /**
81
+ * POSIX hosts run `env -u JEV_TOOLS_API_KEY CI=1 bash -c`. Windows has no
82
+ * `env` and its default `bash` is the WSL launcher, so resolve Git for Windows
83
+ * bash (or JEV_TOOLS_BASH) to an absolute, permitted path, and unset the key
84
+ * and export CI inside the script. When none is found this fails closed: no
85
+ * bare name is returned, because spawning one would search PATH again without
86
+ * the WSL exclusion.
87
+ */
88
+ export function resolveShell(
89
+ platform: NodeJS.Platform = process.platform,
90
+ env: NodeJS.ProcessEnv = process.env,
91
+ isExecutable: (path: string) => boolean = executable,
92
+ ): ShellResolution {
93
+ if (platform !== "win32")
94
+ return {
95
+ ok: true,
96
+ executable: "env",
97
+ prefix: ["-u", API_KEY_VARIABLE, "CI=1", "bash"],
98
+ scriptPrefix: "",
99
+ };
100
+ const useCache = isExecutable === executable && env === process.env;
101
+ if (useCache && cached) return cached;
102
+ const found = windowsBashCandidates(env).find((path) => isExecutable(path));
103
+ const resolved: ShellResolution = found
104
+ ? {
105
+ ok: true,
106
+ executable: found,
107
+ prefix: [],
108
+ scriptPrefix: `unset ${API_KEY_VARIABLE}; export CI=1; `,
109
+ }
110
+ : { ok: false, error: UNAVAILABLE };
111
+ if (useCache && found) cached = resolved;
112
+ return resolved;
113
+ }
@@ -2,6 +2,7 @@ import { type FileHandle, lstat } from "node:fs/promises";
2
2
  import { TEST_SOURCE_MAX_BYTES, TIMEOUT_MS } from "../constants.ts";
3
3
  import type { GitExec } from "../core/git.ts";
4
4
  import type { ImportSource } from "../core/imports.ts";
5
+ import { isSecretPath } from "../core/secret-path.ts";
5
6
  import type { Result } from "../result.ts";
6
7
  import {
7
8
  checkFileAdmission,
@@ -21,7 +22,7 @@ export async function collectTestInventory(
21
22
  read(path: string): Promise<ImportSource | undefined>;
22
23
  limits: readonly {
23
24
  path: string;
24
- kind: "unreadable" | "too_large" | "not UTF-8 text";
25
+ kind: "unreadable" | "too_large" | "not UTF-8 text" | "secret_pattern";
25
26
  }[];
26
27
  }>
27
28
  > {
@@ -33,6 +34,7 @@ export async function collectTestInventory(
33
34
  if (root.code !== 0 || root.killed)
34
35
  return {
35
36
  ok: false,
37
+ cause: signal?.aborted ? "cancelled" : "git_failure",
36
38
  error: root.stderr || "Cannot identify repository root.",
37
39
  };
38
40
  cwd = root.stdout.replace(/\n$/, "");
@@ -51,6 +53,7 @@ export async function collectTestInventory(
51
53
  if (listed.code !== 0 || listed.killed)
52
54
  return {
53
55
  ok: false,
56
+ cause: signal?.aborted ? "cancelled" : "git_failure",
54
57
  error: listed.stderr || "Cannot inventory repository files.",
55
58
  };
56
59
  const ignored = await exec(
@@ -61,10 +64,11 @@ export async function collectTestInventory(
61
64
  if (ignored.code !== 0 || ignored.killed)
62
65
  return {
63
66
  ok: false,
67
+ cause: signal?.aborted ? "cancelled" : "git_failure",
64
68
  error: ignored.stderr || "Cannot identify ignored tracked files.",
65
69
  };
66
70
  const excluded = new Set(ignored.stdout.split("\0").filter(Boolean));
67
- const paths = listed.stdout
71
+ const listedPaths = listed.stdout
68
72
  .split("\0")
69
73
  .filter(
70
74
  (path) =>
@@ -72,11 +76,15 @@ export async function collectTestInventory(
72
76
  (!excluded.has(path) ||
73
77
  /(?:^|\/)(?:package|tsconfig[^/]*)\.json$/.test(path)),
74
78
  );
79
+ // Secret-named files are named exclusions, never inventory members to read.
80
+ const paths = listedPaths.filter((path) => !isSecretPath(path));
75
81
  const admitted = new Set(paths);
76
82
  const limits: {
77
83
  path: string;
78
- kind: "unreadable" | "too_large" | "not UTF-8 text";
79
- }[] = [];
84
+ kind: "unreadable" | "too_large" | "not UTF-8 text" | "secret_pattern";
85
+ }[] = listedPaths
86
+ .filter(isSecretPath)
87
+ .map((path) => ({ path, kind: "secret_pattern" as const }));
80
88
  const cache = new Map<string, Promise<ImportSource | undefined>>();
81
89
  for (let offset = 0; offset < paths.length; offset += 16) {
82
90
  const inspected = await Promise.all(
@@ -3,6 +3,10 @@ import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import {
7
+ type PrivateStorage,
8
+ privateStorage,
9
+ } from "./adapters/private-storage.ts";
6
10
  import { createJevClient } from "./jev/client.ts";
7
11
  import type { JevClient } from "./jev/types.ts";
8
12
 
@@ -26,17 +30,31 @@ function nonempty(value: string | undefined): value is string {
26
30
 
27
31
  function validate(values: ConfigurationValues): void {
28
32
  let validUrl = false;
33
+ let insecureTransport = false;
29
34
  try {
30
35
  const url = new URL(values.url);
31
- validUrl =
36
+ const host = url.hostname.toLowerCase();
37
+ const loopback =
38
+ host === "127.0.0.1" ||
39
+ host === "localhost" ||
40
+ host === "::1" ||
41
+ host === "[::1]";
42
+ const wellFormed =
32
43
  /^https?:\/\//i.test(values.url) &&
33
44
  (url.protocol === "http:" || url.protocol === "https:") &&
34
45
  url.hostname !== "" &&
35
46
  url.username === "" &&
36
47
  url.password === "";
48
+ insecureTransport = wellFormed && url.protocol === "http:" && !loopback;
49
+ validUrl = wellFormed && (url.protocol === "https:" || loopback);
37
50
  } catch {
38
51
  // Keep user-controlled URL and credentials out of error messages.
39
52
  }
53
+ if (insecureTransport) {
54
+ throw new Error(
55
+ "Jev configuration refuses plain http: to a non-loopback host; use https: or an http: loopback URL (127.0.0.1, ::1 or localhost).",
56
+ );
57
+ }
40
58
  if (
41
59
  !validUrl ||
42
60
  !nonempty(values.apiKey) ||
@@ -44,7 +62,7 @@ function validate(values: ConfigurationValues): void {
44
62
  !nonempty(values.model)
45
63
  ) {
46
64
  throw new Error(
47
- "Jev configuration requires a full HTTP(S) URL without embedded credentials, a nonempty HTTP-header-compatible API key, and a nonempty model.",
65
+ "Jev configuration requires a full https: URL (or http: to 127.0.0.1, ::1 or localhost) without embedded credentials, a nonempty HTTP-header-compatible API key, and a nonempty model.",
48
66
  );
49
67
  }
50
68
  }
@@ -56,11 +74,19 @@ export class ConfigController {
56
74
  private saved: SavedConfiguration = {};
57
75
  private session: SavedConfiguration = {};
58
76
  private readonly directory: string;
77
+ private readonly storage: PrivateStorage;
59
78
  private initialization: Promise<void> | undefined;
60
79
  private currentClient: JevClient | undefined;
61
80
 
62
- constructor(options: { env?: NodeJS.ProcessEnv; directory?: string } = {}) {
81
+ constructor(
82
+ options: {
83
+ env?: NodeJS.ProcessEnv;
84
+ directory?: string;
85
+ storage?: PrivateStorage;
86
+ } = {},
87
+ ) {
63
88
  const env = options.env ?? process.env;
89
+ this.storage = options.storage ?? privateStorage();
64
90
  for (const [field, name] of [
65
91
  ["url", "JEV_TOOLS_URL"],
66
92
  ["apiKey", "JEV_TOOLS_API_KEY"],
@@ -163,19 +189,14 @@ export class ConfigController {
163
189
 
164
190
  private async checkDirectory(): Promise<boolean> {
165
191
  // Check ancestors too: recursive mkdir and path-based reads must not follow symlinks.
192
+ // Privacy of the directory itself is checked with its file in readSaved,
193
+ // using the operating system's own permission model.
166
194
  let path = this.directory;
167
195
  let exists = true;
168
196
  while (true) {
169
197
  try {
170
198
  const stat = await lstat(path);
171
199
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw storageError();
172
- if (
173
- path === this.directory &&
174
- ((stat.mode & 0o077) !== 0 ||
175
- (process.getuid && stat.uid !== process.getuid()))
176
- ) {
177
- throw storageError();
178
- }
179
200
  } catch (error) {
180
201
  if ((error as NodeJS.ErrnoException)?.code !== "ENOENT")
181
202
  throw storageError();
@@ -191,12 +212,20 @@ export class ConfigController {
191
212
  private async readSaved(): Promise<SavedConfiguration> {
192
213
  try {
193
214
  if (!(await this.checkDirectory())) return {};
215
+ const directory = {
216
+ path: this.directory,
217
+ stat: await lstat(this.directory),
218
+ };
194
219
  const path = join(this.directory, "config.json");
195
220
  try {
196
221
  const stat = await lstat(path);
197
222
  if (!stat.isFile() || stat.isSymbolicLink()) throw storageError();
198
223
  } catch (error) {
199
- if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return {};
224
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
225
+ if (!(await this.storage.isPrivate([directory])))
226
+ throw storageError();
227
+ return {};
228
+ }
200
229
  throw error;
201
230
  }
202
231
  const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -205,8 +234,7 @@ export class ConfigController {
205
234
  if (
206
235
  !stat.isFile() ||
207
236
  stat.nlink !== 1 ||
208
- (stat.mode & 0o077) !== 0 ||
209
- (process.getuid && stat.uid !== process.getuid())
237
+ !(await this.storage.isPrivate([directory, { path, stat }]))
210
238
  ) {
211
239
  throw storageError();
212
240
  }
@@ -235,9 +263,22 @@ export class ConfigController {
235
263
  private async save(values: SavedConfiguration): Promise<void> {
236
264
  let temporary: string | undefined;
237
265
  try {
238
- if (!(await this.checkDirectory())) {
266
+ const existed = await this.checkDirectory();
267
+ if (!existed) {
239
268
  await mkdir(this.directory, { recursive: true, mode: 0o700 });
240
269
  }
270
+ // Windows ignores mkdir's mode, so restrict the ACL before any secret is
271
+ // written: always for a new directory, and for an existing one only while
272
+ // it holds no configuration (e.g. left behind by an earlier failed save).
273
+ // Existing configuration is never re-permissioned; readSaved refuses it.
274
+ if (
275
+ !existed ||
276
+ !(await lstat(join(this.directory, "config.json")).then(
277
+ () => true,
278
+ () => false,
279
+ ))
280
+ )
281
+ await this.storage.restrictDirectory(this.directory);
241
282
  // Refuse to replace malformed, insecure or newly introduced storage.
242
283
  await this.readSaved();
243
284
  temporary = join(this.directory, `.config-${randomUUID()}.tmp`);
package/src/constants.ts CHANGED
@@ -5,6 +5,17 @@ export const ASK_NOTE_MAX_CHARS = 8_000;
5
5
  export const ASK_MAX_FILES = 20;
6
6
  export const ASK_TIMEOUT_S = 60;
7
7
  export const ASK_TIMEOUT_MAX_S = 300;
8
+ export const PROCESS_KILL_GRACE_MS = 2_000;
9
+ export const PROCESS_PIPE_GRACE_MS = 1_000;
10
+ export const PROCESS_TREE_TIMEOUT_MS = 5_000;
11
+ export const MCP_SHUTDOWN_FLUSH_TIMEOUT_MS = 1_000;
12
+ export const PRIVATE_STORAGE_TIMEOUT_MS = 20_000;
13
+ export const MCP_VALIDATION_MAX_ERRORS = 5;
14
+ export const MCP_PACKAGE_REPLY_TIMEOUT_MS = 20_000;
15
+ export const MCP_PACKAGE_EXIT_TIMEOUT_MS = 10_000;
16
+ export const MCP_PACKAGE_KILL_TIMEOUT_MS = 2_000;
17
+ export const MCP_PACKAGE_INSTALL_TIMEOUT_MS = 300_000;
18
+ export const MCP_PACKAGE_STDERR_MAX_CHARS = 2_000;
8
19
  export const OUTPUT_REPEAT_MIN = 5;
9
20
  export const OUTPUT_CHUNK_CHARS = 2_500;
10
21
  export const OUTPUT_FIND_MAX_CALLS = 40;
@@ -99,11 +110,32 @@ export const RUNNER_PACKAGE_MAX_BYTES = 64 * 1024;
99
110
 
100
111
  // ADR 0001: allowed local dependencies, including erased type imports.
101
112
  export const IMPORT_LAYERS = {
102
- core: ["core", "constants.ts", "result.ts", "jev/types.ts"],
103
- presets: ["presets", "core", "constants.ts", "result.ts", "jev/types.ts"],
104
- adapters: ["adapters", "core", "constants.ts", "result.ts", "jev/types.ts"],
105
- jev: ["jev", "core", "constants.ts", "result.ts"],
113
+ core: [
114
+ "core",
115
+ "constants.ts",
116
+ "result.ts",
117
+ "result-types.ts",
118
+ "jev/types.ts",
119
+ ],
120
+ presets: [
121
+ "presets",
122
+ "core",
123
+ "constants.ts",
124
+ "result.ts",
125
+ "result-types.ts",
126
+ "jev/types.ts",
127
+ ],
128
+ adapters: [
129
+ "adapters",
130
+ "core",
131
+ "constants.ts",
132
+ "result.ts",
133
+ "result-types.ts",
134
+ "jev/types.ts",
135
+ ],
136
+ jev: ["jev", "core", "constants.ts", "result.ts", "result-types.ts"],
106
137
  texts: ["texts", "constants.ts", "presets"],
107
138
  "constants.ts": [],
108
- "result.ts": [],
139
+ "result.ts": ["result-types.ts"],
140
+ "result-types.ts": [],
109
141
  } as const;