jev-agent-tools 0.1.3 → 0.2.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.
- package/CHANGELOG.md +88 -3
- package/CONTRIBUTING.md +40 -0
- package/README.md +64 -9
- package/SECURITY.md +27 -0
- package/dist/adapters/analysis-context.js +75 -0
- package/dist/adapters/ask-files.js +189 -0
- package/dist/adapters/ask-proof.js +144 -0
- package/dist/adapters/ask-syntax.js +385 -0
- package/dist/adapters/canonical-path.js +17 -0
- package/dist/adapters/command.js +181 -0
- package/dist/adapters/docs.js +172 -0
- package/dist/adapters/exec.js +207 -0
- package/dist/adapters/files.js +293 -0
- package/dist/adapters/find.js +122 -0
- package/dist/adapters/git-base.js +26 -0
- package/dist/adapters/git-inventory.js +71 -0
- package/dist/adapters/git.js +439 -0
- package/dist/adapters/locate-file.js +159 -0
- package/dist/adapters/output-lines.js +46 -0
- package/dist/adapters/private-storage.js +98 -0
- package/dist/adapters/risk-callers.js +426 -0
- package/dist/adapters/runner-version.js +78 -0
- package/dist/adapters/shell.js +76 -0
- package/dist/adapters/syntax.js +187 -0
- package/dist/adapters/test-inventory.js +131 -0
- package/dist/adapters/usage.js +20 -0
- package/dist/adapters/utf8.js +47 -0
- package/dist/configuration.js +257 -0
- package/dist/constants.js +119 -0
- package/dist/core/ask-closure.js +282 -0
- package/dist/core/ask-proof.js +1 -0
- package/dist/core/ask-references.js +194 -0
- package/dist/core/asks.js +436 -0
- package/dist/core/batches.js +65 -0
- package/dist/core/command-output.js +224 -0
- package/dist/core/diff.js +178 -0
- package/dist/core/docs.js +302 -0
- package/dist/core/find.js +108 -0
- package/dist/core/git.js +1 -0
- package/dist/core/imports.js +550 -0
- package/dist/core/integrity.js +45 -0
- package/dist/core/lexical.js +132 -0
- package/dist/core/locate.js +169 -0
- package/dist/core/output.js +120 -0
- package/dist/core/pointer.js +29 -0
- package/dist/core/risk-callers.js +851 -0
- package/dist/core/runner-version.js +45 -0
- package/dist/core/sections.js +230 -0
- package/dist/core/state.js +44 -0
- package/dist/core/syntax.js +1 -0
- package/dist/core/test-commands.js +334 -0
- package/dist/core/test-coverage.js +74 -0
- package/dist/core/test-discovery.js +1382 -0
- package/dist/core/test-evidence.js +527 -0
- package/dist/core/test-state.js +81 -0
- package/dist/core/truncate.js +12 -0
- package/dist/core/units.js +349 -0
- package/dist/describe.js +23 -0
- package/dist/guide.js +33 -0
- package/dist/host.js +24 -0
- package/dist/jev/client.js +434 -0
- package/dist/jev/pool.js +54 -0
- package/dist/jev/types.js +1 -0
- package/dist/mcp/main.js +124 -0
- package/dist/mcp/protocol.js +187 -0
- package/dist/mcp/tools.js +116 -0
- package/dist/presets/docs.js +62 -0
- package/dist/presets/risk.js +179 -0
- package/dist/presets/spec.js +81 -0
- package/dist/presets/witnesses.js +249 -0
- package/dist/render.js +42 -0
- package/dist/result.js +3 -0
- package/dist/runtime.js +1 -0
- package/dist/session.js +147 -0
- package/dist/texts/ask-files.js +1 -0
- package/dist/texts/ask.js +2 -0
- package/dist/texts/check-diff.js +17 -0
- package/dist/texts/configuration.js +1 -0
- package/dist/texts/find.js +14 -0
- package/dist/texts/guide.js +16 -0
- package/dist/texts/locate.js +10 -0
- package/dist/texts/select-tests.js +2 -0
- package/dist/tools/ask-files.js +217 -0
- package/dist/tools/ask-schema.js +70 -0
- package/dist/tools/ask.js +686 -0
- package/dist/tools/check-diff.js +402 -0
- package/dist/tools/docs-check.js +299 -0
- package/dist/tools/find.js +389 -0
- package/dist/tools/locate.js +303 -0
- package/dist/tools/select-tests.js +567 -0
- package/dist/tools/spec-check.js +166 -0
- package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
- package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
- package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
- package/docs/adr/0004-compiled-typed-intents.md +19 -0
- package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
- package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
- package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
- package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
- package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
- package/docs/adr/0010-mcp-server-thin-host.md +23 -0
- package/docs/agent-instructions.md +91 -0
- package/docs/design.md +3 -3
- package/docs/mcp.md +231 -0
- package/package.json +19 -4
- package/server.json +57 -0
- package/src/adapters/canonical-path.ts +18 -0
- package/src/adapters/command.ts +7 -4
- package/src/adapters/exec.ts +226 -0
- package/src/adapters/private-storage.ts +143 -0
- package/src/adapters/risk-callers.ts +4 -2
- package/src/adapters/shell.ts +97 -0
- package/src/configuration.ts +294 -0
- package/src/constants.ts +11 -0
- package/src/core/command-output.ts +17 -1
- package/src/host-tui.d.ts +14 -0
- package/src/host.ts +11 -0
- package/src/index.ts +13 -5
- package/src/jev/client.ts +12 -0
- package/src/jev/types.ts +6 -0
- package/src/mcp/main.ts +135 -0
- package/src/mcp/protocol.ts +282 -0
- package/src/mcp/tools.ts +166 -0
- package/src/secret-input.ts +222 -0
- package/src/session.ts +59 -0
- package/src/setup.ts +170 -0
- package/src/texts/configuration.ts +1 -1
- package/src/tools/ask-files.ts +8 -13
- package/src/tools/ask.ts +29 -28
- package/src/tools/check-diff.ts +11 -11
- package/src/tools/docs-check.ts +1 -0
- package/src/tools/find.ts +8 -8
- package/src/tools/locate.ts +8 -9
- package/src/tools/select-tests.ts +10 -10
- package/src/tools/spec-check.ts +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { PRIVATE_STORAGE_TIMEOUT_MS } from "../constants.js";
|
|
4
|
+
const SYSTEM = "S-1-5-18";
|
|
5
|
+
const ADMINISTRATORS = "S-1-5-32-544";
|
|
6
|
+
const CREATOR_OWNER = "S-1-3-0";
|
|
7
|
+
export const posixStorage = {
|
|
8
|
+
async isPrivate(entries) {
|
|
9
|
+
return entries.every(({ stat }) => (stat.mode & 0o077) === 0 &&
|
|
10
|
+
(!process.getuid || stat.uid === process.getuid()));
|
|
11
|
+
},
|
|
12
|
+
async restrictDirectory() {
|
|
13
|
+
// mkdir(..., { mode: 0o700 }) already creates POSIX directories privately.
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
// Paths arrive as JSON in an environment variable, never in the command text.
|
|
17
|
+
const CHECK = `$ErrorActionPreference = 'Stop'
|
|
18
|
+
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
19
|
+
$trusted = @($me, '${SYSTEM}', '${ADMINISTRATORS}')
|
|
20
|
+
$sid = [System.Security.Principal.SecurityIdentifier]
|
|
21
|
+
$results = foreach ($path in (ConvertFrom-Json $env:JEV_PRIVATE_PATHS)) {
|
|
22
|
+
try {
|
|
23
|
+
$acl = Get-Acl -LiteralPath $path
|
|
24
|
+
$owner = $acl.GetOwner($sid).Value
|
|
25
|
+
$private = ($owner -eq $me) -or ($owner -eq '${ADMINISTRATORS}')
|
|
26
|
+
foreach ($rule in $acl.GetAccessRules($true, $true, $sid)) {
|
|
27
|
+
if ($rule.AccessControlType -ne 'Allow') { continue }
|
|
28
|
+
$id = $rule.IdentityReference.Value
|
|
29
|
+
$inheritOnly = ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0
|
|
30
|
+
if ($id -eq '${CREATOR_OWNER}' -and $inheritOnly) { continue }
|
|
31
|
+
if ($trusted -notcontains $id) { $private = $false }
|
|
32
|
+
}
|
|
33
|
+
$private
|
|
34
|
+
} catch { $false }
|
|
35
|
+
}
|
|
36
|
+
ConvertTo-Json -Compress @($results)`;
|
|
37
|
+
const RESTRICT = `$ErrorActionPreference = 'Stop'
|
|
38
|
+
$path = $env:JEV_PRIVATE_PATH
|
|
39
|
+
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
40
|
+
$acl = Get-Acl -LiteralPath $path
|
|
41
|
+
if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'not owner' }
|
|
42
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
43
|
+
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleAll($rule) }
|
|
44
|
+
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
45
|
+
foreach ($id in @($me.Value, '${SYSTEM}', '${ADMINISTRATORS}')) {
|
|
46
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
47
|
+
(New-Object System.Security.Principal.SecurityIdentifier($id)),
|
|
48
|
+
'FullControl', $inherit, 'None', 'Allow')
|
|
49
|
+
$acl.AddAccessRule($rule)
|
|
50
|
+
}
|
|
51
|
+
Set-Acl -LiteralPath $path -AclObject $acl`;
|
|
52
|
+
function powershell(script, env) {
|
|
53
|
+
const root = process.env.SystemRoot ?? "C:\\Windows";
|
|
54
|
+
// A fixed system path, so a powershell.exe earlier on PATH is never used.
|
|
55
|
+
const executable = join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
56
|
+
// PowerShell 7 parents export PSModulePath, which stops Windows PowerShell
|
|
57
|
+
// 5.1 from loading its own Get-Acl module; 5.1 rebuilds it when unset.
|
|
58
|
+
const childEnv = { ...process.env, ...env };
|
|
59
|
+
for (const key of Object.keys(childEnv))
|
|
60
|
+
if (key.toLowerCase() === "psmodulepath")
|
|
61
|
+
delete childEnv[key];
|
|
62
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
63
|
+
execFile(executable, [
|
|
64
|
+
"-NoProfile",
|
|
65
|
+
"-NonInteractive",
|
|
66
|
+
"-ExecutionPolicy",
|
|
67
|
+
"Bypass",
|
|
68
|
+
// UTF-16LE base64 runs the script as one unit; stdin runs line by line.
|
|
69
|
+
"-EncodedCommand",
|
|
70
|
+
Buffer.from(script, "utf16le").toString("base64"),
|
|
71
|
+
], { env: childEnv, windowsHide: true, timeout: PRIVATE_STORAGE_TIMEOUT_MS }, (error, stdout) => (error ? reject(error) : resolve(stdout)));
|
|
72
|
+
return promise;
|
|
73
|
+
}
|
|
74
|
+
export const windowsStorage = {
|
|
75
|
+
async isPrivate(entries) {
|
|
76
|
+
if (!entries.length)
|
|
77
|
+
return true;
|
|
78
|
+
try {
|
|
79
|
+
const output = await powershell(CHECK, {
|
|
80
|
+
JEV_PRIVATE_PATHS: JSON.stringify(entries.map(({ path }) => path)),
|
|
81
|
+
});
|
|
82
|
+
const results = JSON.parse(output.trim());
|
|
83
|
+
return (Array.isArray(results) &&
|
|
84
|
+
results.length === entries.length &&
|
|
85
|
+
results.every((result) => result === true));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// An ACL that cannot be read is never treated as private.
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async restrictDirectory(path) {
|
|
93
|
+
await powershell(RESTRICT, { JEV_PRIVATE_PATH: path });
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
export function privateStorage(platform = process.platform) {
|
|
97
|
+
return platform === "win32" ? windowsStorage : posixStorage;
|
|
98
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import { CONCURRENCY, STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.js";
|
|
4
|
+
import { prepareRiskCallers, } from "../core/risk-callers.js";
|
|
5
|
+
import { admitGitPath, checkFileAdmission, openRepoFile, resolveInsideRepo, } from "./files.js";
|
|
6
|
+
import { loadSyntaxRootParser } from "./syntax.js";
|
|
7
|
+
import { decodeUtf8, verifyGitUtf8 } from "./utf8.js";
|
|
8
|
+
export async function loadCallerParser(sourceParser) {
|
|
9
|
+
const parser = sourceParser ?? (await loadSyntaxRootParser());
|
|
10
|
+
if (!parser)
|
|
11
|
+
return undefined;
|
|
12
|
+
const versions = new Map();
|
|
13
|
+
return {
|
|
14
|
+
supports: parser.supports,
|
|
15
|
+
parse(path, text) {
|
|
16
|
+
let entries = versions.get(path);
|
|
17
|
+
if (!entries) {
|
|
18
|
+
entries = new Map();
|
|
19
|
+
versions.set(path, entries);
|
|
20
|
+
}
|
|
21
|
+
if (entries.has(text))
|
|
22
|
+
return entries.get(text) ?? null;
|
|
23
|
+
const result = parser.parseRaw(path, text);
|
|
24
|
+
const root = result.ok ? result.root : null;
|
|
25
|
+
entries.set(text, root);
|
|
26
|
+
return root;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export async function collectRiskCallers(exec, options, units, changedFiles = [], sourceParser) {
|
|
31
|
+
if (!units.length)
|
|
32
|
+
return { proofs: [], limits: [] };
|
|
33
|
+
const parser = await loadCallerParser(sourceParser);
|
|
34
|
+
const local = prepareRiskCallers(units, changedFiles.map((file) => ({
|
|
35
|
+
path: file.path,
|
|
36
|
+
beforePath: file.oldPath,
|
|
37
|
+
before: file.before,
|
|
38
|
+
after: file.after,
|
|
39
|
+
limitation: file.limitation,
|
|
40
|
+
})), parser);
|
|
41
|
+
const eligibleIds = new Set([
|
|
42
|
+
...local.proofs.map((proof) => proof.unitId),
|
|
43
|
+
...local.limits.map((limit) => limit.unitId),
|
|
44
|
+
]);
|
|
45
|
+
const nameFor = (unit) => {
|
|
46
|
+
const name = unit.name.split(".").at(-1) ?? unit.name;
|
|
47
|
+
if (!parser?.supports(unit.file))
|
|
48
|
+
return;
|
|
49
|
+
const probe = parser.parse(unit.file, unit.file.endsWith(".py")
|
|
50
|
+
? `def ${name}():\n pass`
|
|
51
|
+
: `function ${name}() {}`);
|
|
52
|
+
const declaration = probe
|
|
53
|
+
?.children()
|
|
54
|
+
.find((node) => ["function_definition", "function_declaration"].includes(String(node.kind())));
|
|
55
|
+
return declaration?.field("name")?.text() === name ? name : undefined;
|
|
56
|
+
};
|
|
57
|
+
const eligible = units.filter((unit) => eligibleIds.has(unit.id) &&
|
|
58
|
+
["function", "declaration", "slice"].includes(unit.kind) &&
|
|
59
|
+
nameFor(unit) !== undefined);
|
|
60
|
+
const retainedLimits = local.limits.filter((limit) => !eligible.some((unit) => unit.id === limit.unitId));
|
|
61
|
+
if (!eligible.length)
|
|
62
|
+
return local;
|
|
63
|
+
let cwd = options.cwd;
|
|
64
|
+
const git = async (args) => {
|
|
65
|
+
options.signal?.throwIfAborted();
|
|
66
|
+
const result = await exec("git", args, {
|
|
67
|
+
cwd,
|
|
68
|
+
timeout: TIMEOUT_MS,
|
|
69
|
+
signal: options.signal,
|
|
70
|
+
});
|
|
71
|
+
if (result.code !== 0 || result.killed)
|
|
72
|
+
throw new Error(result.stderr || "git interrupted");
|
|
73
|
+
return result.stdout;
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
// The tool supplies the already resolved comparison reference, as for collectUnits.
|
|
77
|
+
cwd = (await git(["rev-parse", "--show-toplevel"])).trim();
|
|
78
|
+
const ref = (options.base ?? "HEAD").trim();
|
|
79
|
+
const sourceExtension = {
|
|
80
|
+
".js": ".ts",
|
|
81
|
+
".jsx": ".tsx",
|
|
82
|
+
".mjs": ".mts",
|
|
83
|
+
".cjs": ".cts",
|
|
84
|
+
};
|
|
85
|
+
const extensions = [
|
|
86
|
+
"ts",
|
|
87
|
+
"tsx",
|
|
88
|
+
"mts",
|
|
89
|
+
"cts",
|
|
90
|
+
"js",
|
|
91
|
+
"jsx",
|
|
92
|
+
"mjs",
|
|
93
|
+
"cjs",
|
|
94
|
+
"py",
|
|
95
|
+
];
|
|
96
|
+
const tracked = await git([
|
|
97
|
+
"ls-files",
|
|
98
|
+
"-z",
|
|
99
|
+
`--with-tree=${ref}`,
|
|
100
|
+
"--",
|
|
101
|
+
...extensions.map((extension) => `*.${extension}`),
|
|
102
|
+
]);
|
|
103
|
+
const beforePaths = new Set(tracked.split("\0"));
|
|
104
|
+
const baseInventory = new Map();
|
|
105
|
+
for (const entry of (await git([
|
|
106
|
+
"ls-tree",
|
|
107
|
+
"-r",
|
|
108
|
+
"--long",
|
|
109
|
+
"-z",
|
|
110
|
+
"--full-tree",
|
|
111
|
+
"--end-of-options",
|
|
112
|
+
ref,
|
|
113
|
+
])).split("\0")) {
|
|
114
|
+
const separator = entry.indexOf("\t");
|
|
115
|
+
if (separator !== -1)
|
|
116
|
+
baseInventory.set(entry.slice(separator + 1), entry);
|
|
117
|
+
}
|
|
118
|
+
const modifiedPaths = new Set((await git([
|
|
119
|
+
"diff",
|
|
120
|
+
"--name-only",
|
|
121
|
+
"-z",
|
|
122
|
+
"--no-renames",
|
|
123
|
+
"--no-ext-diff",
|
|
124
|
+
"--no-textconv",
|
|
125
|
+
ref,
|
|
126
|
+
])).split("\0"));
|
|
127
|
+
const currentPaths = new Set((await git([
|
|
128
|
+
"ls-files",
|
|
129
|
+
"-z",
|
|
130
|
+
"--cached",
|
|
131
|
+
"--others",
|
|
132
|
+
"--exclude-standard",
|
|
133
|
+
]))
|
|
134
|
+
.split("\0")
|
|
135
|
+
.filter(Boolean));
|
|
136
|
+
const names = [
|
|
137
|
+
...new Set(eligible
|
|
138
|
+
.map((unit) => nameFor(unit))
|
|
139
|
+
.filter((name) => name !== undefined)),
|
|
140
|
+
];
|
|
141
|
+
const matched = new Set();
|
|
142
|
+
const inventory = [...beforePaths].filter((path) => /\.(?:[cm]?[jt]sx?|py)$/.test(path));
|
|
143
|
+
const patternArgs = names.flatMap((name) => ["-e", name]);
|
|
144
|
+
const priorSearch = exec("git", [
|
|
145
|
+
"grep",
|
|
146
|
+
"-l",
|
|
147
|
+
"-z",
|
|
148
|
+
"-F",
|
|
149
|
+
"-w",
|
|
150
|
+
...patternArgs,
|
|
151
|
+
ref,
|
|
152
|
+
"--",
|
|
153
|
+
...extensions.map((extension) => `*.${extension}`),
|
|
154
|
+
], { cwd, timeout: TIMEOUT_MS, signal: options.signal }).catch((error) => ({
|
|
155
|
+
stdout: "",
|
|
156
|
+
stderr: String(error),
|
|
157
|
+
code: 2,
|
|
158
|
+
killed: false,
|
|
159
|
+
}));
|
|
160
|
+
// Explicit tracked paths prevent rg from collecting ignored or untracked callers.
|
|
161
|
+
// Bound argument bytes as well as process concurrency, including long paths.
|
|
162
|
+
const batches = [];
|
|
163
|
+
let batch = [];
|
|
164
|
+
let bytes = 0;
|
|
165
|
+
for (const path of inventory) {
|
|
166
|
+
const size = Buffer.byteLength(path) + 1;
|
|
167
|
+
if (batch.length && bytes + size > 96_000) {
|
|
168
|
+
batches.push(batch);
|
|
169
|
+
batch = [];
|
|
170
|
+
bytes = 0;
|
|
171
|
+
}
|
|
172
|
+
batch.push(path);
|
|
173
|
+
bytes += size;
|
|
174
|
+
}
|
|
175
|
+
if (batch.length)
|
|
176
|
+
batches.push(batch);
|
|
177
|
+
for (let offset = 0; offset < batches.length; offset += CONCURRENCY) {
|
|
178
|
+
await Promise.all(batches.slice(offset, offset + CONCURRENCY).map(async (paths) => {
|
|
179
|
+
options.signal?.throwIfAborted();
|
|
180
|
+
const metadata = await Promise.all(paths.map(async (path) => {
|
|
181
|
+
try {
|
|
182
|
+
const admission = await checkFileAdmission(cwd, path, undefined, options.signal, currentPaths);
|
|
183
|
+
if (!admission.ok)
|
|
184
|
+
return undefined;
|
|
185
|
+
const location = await resolveInsideRepo(cwd, path);
|
|
186
|
+
if (!location.ok)
|
|
187
|
+
return undefined;
|
|
188
|
+
return (await lstat(location.abs)).isFile() ? path : undefined;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error.code === "ENOENT")
|
|
192
|
+
return undefined;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}));
|
|
196
|
+
const existing = metadata.filter((path) => path !== undefined);
|
|
197
|
+
if (!existing.length)
|
|
198
|
+
return;
|
|
199
|
+
let current;
|
|
200
|
+
try {
|
|
201
|
+
current = await exec("rg", ["-l", "-0", "-F", "-w", ...patternArgs, "--", ...existing], { cwd, timeout: TIMEOUT_MS, signal: options.signal });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
options.signal?.throwIfAborted();
|
|
205
|
+
}
|
|
206
|
+
if (!current || current.code > 1 || current.killed)
|
|
207
|
+
current = await exec("git", [
|
|
208
|
+
"grep",
|
|
209
|
+
"-l",
|
|
210
|
+
"-z",
|
|
211
|
+
"-F",
|
|
212
|
+
"-w",
|
|
213
|
+
...patternArgs,
|
|
214
|
+
"--",
|
|
215
|
+
...existing,
|
|
216
|
+
], { cwd, timeout: TIMEOUT_MS, signal: options.signal });
|
|
217
|
+
if (current.code > 1 || current.killed)
|
|
218
|
+
throw new Error(current.stderr || "tracked caller name search failed");
|
|
219
|
+
for (const path of current.stdout.split("\0"))
|
|
220
|
+
if (path && beforePaths.has(path))
|
|
221
|
+
matched.add(path);
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
const prior = await priorSearch;
|
|
225
|
+
if (prior.code > 1 || prior.killed)
|
|
226
|
+
throw new Error(prior.stderr || "before caller name search failed");
|
|
227
|
+
for (const path of prior.stdout.split("\0"))
|
|
228
|
+
if (path.startsWith(`${ref}:`))
|
|
229
|
+
matched.add(path.slice(ref.length + 1));
|
|
230
|
+
for (const file of changedFiles)
|
|
231
|
+
matched.add(file.path);
|
|
232
|
+
const paths = [...matched];
|
|
233
|
+
const sources = [];
|
|
234
|
+
const readBefore = async (path) => {
|
|
235
|
+
if (!beforePaths.has(path))
|
|
236
|
+
return { text: null };
|
|
237
|
+
if (!(await admitGitPath(cwd, path, ref, exec, options.signal, baseInventory)).ok)
|
|
238
|
+
return { text: null, limitation: "before file not admitted" };
|
|
239
|
+
const entry = baseInventory.get(path);
|
|
240
|
+
const metadata = entry &&
|
|
241
|
+
/^(?:100644|100755|120000) blob ([0-9a-f]+)\s+(\d+)\t/.exec(entry);
|
|
242
|
+
if (!metadata)
|
|
243
|
+
return { text: null };
|
|
244
|
+
const object = metadata[1] ?? "";
|
|
245
|
+
const size = Number(metadata[2]);
|
|
246
|
+
if (!Number.isFinite(size) || size > STATE_MAX_CHARS)
|
|
247
|
+
return { text: null, limitation: "before file exceeds read budget" };
|
|
248
|
+
const text = await git([
|
|
249
|
+
"show",
|
|
250
|
+
"--no-ext-diff",
|
|
251
|
+
"--no-textconv",
|
|
252
|
+
"--end-of-options",
|
|
253
|
+
object,
|
|
254
|
+
]);
|
|
255
|
+
const decoded = verifyGitUtf8(text, object, size);
|
|
256
|
+
if (!decoded.ok)
|
|
257
|
+
return { text: null, limitation: `${path}: ${decoded.error}` };
|
|
258
|
+
return text.length <= STATE_MAX_CHARS && !text.includes("\0")
|
|
259
|
+
? { text }
|
|
260
|
+
: { text: null, limitation: "before binary or oversized" };
|
|
261
|
+
};
|
|
262
|
+
const readAfter = async (path) => {
|
|
263
|
+
let handle;
|
|
264
|
+
try {
|
|
265
|
+
options.signal?.throwIfAborted();
|
|
266
|
+
const location = await resolveInsideRepo(cwd, path);
|
|
267
|
+
if (!location.ok) {
|
|
268
|
+
const metadata = await admitGitPath(cwd, path, ref, exec, options.signal, baseInventory);
|
|
269
|
+
if (metadata.ok && location.error.includes("ENOENT"))
|
|
270
|
+
return { text: null };
|
|
271
|
+
return { text: null, limitation: "after file not admitted" };
|
|
272
|
+
}
|
|
273
|
+
const opened = await openRepoFile(cwd, path, {
|
|
274
|
+
exec,
|
|
275
|
+
signal: options.signal,
|
|
276
|
+
inventory: currentPaths,
|
|
277
|
+
});
|
|
278
|
+
if (!opened.ok)
|
|
279
|
+
return { text: null, limitation: "after file not admitted" };
|
|
280
|
+
handle = opened.handle;
|
|
281
|
+
const stat = { size: opened.size };
|
|
282
|
+
if (stat.size > STATE_MAX_CHARS)
|
|
283
|
+
return { text: null, limitation: "after file exceeds read budget" };
|
|
284
|
+
const buffer = Buffer.alloc(STATE_MAX_CHARS + 1);
|
|
285
|
+
let offset = 0;
|
|
286
|
+
while (offset < buffer.length) {
|
|
287
|
+
const read = await handle.read(buffer, offset, buffer.length - offset, null);
|
|
288
|
+
if (!read.bytesRead)
|
|
289
|
+
break;
|
|
290
|
+
offset += read.bytesRead;
|
|
291
|
+
}
|
|
292
|
+
if (offset > STATE_MAX_CHARS || buffer.subarray(0, offset).includes(0))
|
|
293
|
+
return { text: null, limitation: "after binary or oversized" };
|
|
294
|
+
const decoded = decodeUtf8(buffer.subarray(0, offset));
|
|
295
|
+
return decoded.ok
|
|
296
|
+
? { text: decoded.text }
|
|
297
|
+
: { text: null, limitation: `${path}: ${decoded.error}` };
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (error.code === "ENOENT")
|
|
301
|
+
return { text: null };
|
|
302
|
+
return { text: null, limitation: "after unreadable" };
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
await handle?.close();
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
for (let offset = 0; offset < paths.length;) {
|
|
309
|
+
const selected = paths.slice(offset, offset + CONCURRENCY);
|
|
310
|
+
offset += selected.length;
|
|
311
|
+
const batch = await Promise.all(selected.map(async (path) => {
|
|
312
|
+
const changed = changedFiles.find((f) => f.path === path);
|
|
313
|
+
if (changed)
|
|
314
|
+
return {
|
|
315
|
+
path,
|
|
316
|
+
beforePath: changed.oldPath,
|
|
317
|
+
before: changed.before,
|
|
318
|
+
after: changed.after,
|
|
319
|
+
limitation: changed.limitation,
|
|
320
|
+
};
|
|
321
|
+
if (!modifiedPaths.has(path)) {
|
|
322
|
+
const after = await readAfter(path);
|
|
323
|
+
if (after.text !== null)
|
|
324
|
+
return {
|
|
325
|
+
path,
|
|
326
|
+
before: after.text,
|
|
327
|
+
after: after.text,
|
|
328
|
+
limitation: after.limitation,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const [before, after] = await Promise.all([
|
|
332
|
+
readBefore(path),
|
|
333
|
+
readAfter(path),
|
|
334
|
+
]);
|
|
335
|
+
return {
|
|
336
|
+
path,
|
|
337
|
+
before: before.text,
|
|
338
|
+
after: after.text,
|
|
339
|
+
limitation: before.limitation ?? after.limitation,
|
|
340
|
+
};
|
|
341
|
+
}));
|
|
342
|
+
sources.push(...batch);
|
|
343
|
+
// Follow static import AST nodes from candidate files so imported concrete providers remain visible.
|
|
344
|
+
for (const source of batch)
|
|
345
|
+
for (const side of ["before", "after"]) {
|
|
346
|
+
if (side === "after" && source.before === source.after)
|
|
347
|
+
continue;
|
|
348
|
+
const text = source[side];
|
|
349
|
+
if (!text || !parser?.supports(source.path))
|
|
350
|
+
continue;
|
|
351
|
+
const ast = parser.parse(source.path, text);
|
|
352
|
+
if (!ast)
|
|
353
|
+
continue;
|
|
354
|
+
const visit = (node) => {
|
|
355
|
+
if ([
|
|
356
|
+
"import_statement",
|
|
357
|
+
"import_from_statement",
|
|
358
|
+
"export_statement",
|
|
359
|
+
].includes(String(node.kind()))) {
|
|
360
|
+
const literal = node.field("source") ?? node.field("module_name");
|
|
361
|
+
if (literal) {
|
|
362
|
+
const specifier = literal.kind() === "string"
|
|
363
|
+
? literal.text().slice(1, -1)
|
|
364
|
+
: literal.text();
|
|
365
|
+
const sourcePath = side === "before" &&
|
|
366
|
+
"beforePath" in source &&
|
|
367
|
+
typeof source.beforePath === "string"
|
|
368
|
+
? source.beforePath
|
|
369
|
+
: source.path;
|
|
370
|
+
// Repository paths are always "/"-separated; the platform
|
|
371
|
+
// resolve would yield "C:\\src\\..." on Windows.
|
|
372
|
+
const basePath = specifier.startsWith(".")
|
|
373
|
+
? posix.resolve("/", sourcePath, "..", specifier).slice(1)
|
|
374
|
+
: specifier.replaceAll(".", "/");
|
|
375
|
+
const runtimeSource = basePath.replace(/\.(js|jsx|mjs|cjs)$/, (extension) => sourceExtension[extension] ?? extension);
|
|
376
|
+
const candidates = new Set([
|
|
377
|
+
basePath,
|
|
378
|
+
runtimeSource,
|
|
379
|
+
...extensions.map((extension) => `${basePath}.${extension}`),
|
|
380
|
+
`${basePath}/index.ts`,
|
|
381
|
+
`${basePath}/__init__.py`,
|
|
382
|
+
]);
|
|
383
|
+
const matches = [...candidates].filter((path) => beforePaths.has(path));
|
|
384
|
+
if (matches.length === 1 && !matched.has(matches[0] ?? "")) {
|
|
385
|
+
matched.add(matches[0] ?? "");
|
|
386
|
+
paths.push(matches[0] ?? "");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const child of node.children())
|
|
391
|
+
visit(child);
|
|
392
|
+
};
|
|
393
|
+
if (source.path.endsWith(".py"))
|
|
394
|
+
visit(ast);
|
|
395
|
+
else
|
|
396
|
+
for (const statement of ast.children()) {
|
|
397
|
+
if (["import_statement", "export_statement"].includes(String(statement.kind())) &&
|
|
398
|
+
statement.field("source"))
|
|
399
|
+
visit(statement);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const prepared = prepareRiskCallers(eligible, sources, parser);
|
|
404
|
+
prepared.limits.push(...retainedLimits);
|
|
405
|
+
prepared.limits.push(...eligible.map((unit) => ({
|
|
406
|
+
unitId: unit.id,
|
|
407
|
+
kind: "dynamic_access_uncovered",
|
|
408
|
+
reason: "callers found by name; dynamic access not covered",
|
|
409
|
+
paths: [unit.file],
|
|
410
|
+
missing: "dynamically named callers",
|
|
411
|
+
})));
|
|
412
|
+
return prepared;
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
return {
|
|
416
|
+
proofs: [],
|
|
417
|
+
limits: eligible.map((unit) => ({
|
|
418
|
+
unitId: unit.id,
|
|
419
|
+
kind: "unreadable",
|
|
420
|
+
reason: `caller collection failed: ${String(error)}`,
|
|
421
|
+
paths: [unit.file],
|
|
422
|
+
missing: "bounded tracked caller/provider source",
|
|
423
|
+
})),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, realpath } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { RUNNER_PACKAGE_MAX_BYTES } from "../constants.js";
|
|
5
|
+
import { lockedRunnerVersion, runnerVersion, } from "../core/runner-version.js";
|
|
6
|
+
/** Local-only ignored metadata exception: no contents enter Jev state. Closed runner names,
|
|
7
|
+
* bounded reads and realpath confinement allow pnpm links only inside the repository. */
|
|
8
|
+
export async function installedRunnerVersion(root, cwd, runner) {
|
|
9
|
+
if (runner !== "vitest" && runner !== "jest" && runner !== "mocha")
|
|
10
|
+
return undefined;
|
|
11
|
+
if (isAbsolute(cwd) || cwd.split(/[\\/]/).includes(".."))
|
|
12
|
+
return undefined;
|
|
13
|
+
let handle;
|
|
14
|
+
try {
|
|
15
|
+
const base = await realpath(root);
|
|
16
|
+
const path = await realpath(resolve(base, cwd, "node_modules", runner, "package.json"));
|
|
17
|
+
const rel = relative(base, path);
|
|
18
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`))
|
|
19
|
+
return undefined;
|
|
20
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
21
|
+
const stat = await handle.stat();
|
|
22
|
+
if (!stat.isFile() || stat.size > RUNNER_PACKAGE_MAX_BYTES)
|
|
23
|
+
return undefined;
|
|
24
|
+
const bytes = Buffer.alloc(RUNNER_PACKAGE_MAX_BYTES + 1);
|
|
25
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
26
|
+
if (bytesRead > RUNNER_PACKAGE_MAX_BYTES)
|
|
27
|
+
return undefined;
|
|
28
|
+
const value = JSON.parse(bytes.subarray(0, bytesRead).toString("utf8"));
|
|
29
|
+
return runnerVersion(value.version);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await handle?.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function attachRunnerVersions(root, entries, read, known) {
|
|
39
|
+
const cache = new Map();
|
|
40
|
+
return Promise.all(entries.map(async (entry) => {
|
|
41
|
+
if (entry.framework !== "vitest" &&
|
|
42
|
+
entry.framework !== "jest" &&
|
|
43
|
+
entry.framework !== "mocha")
|
|
44
|
+
return entry;
|
|
45
|
+
const runner = entry.framework;
|
|
46
|
+
const key = `${entry.cwd}:${runner}`;
|
|
47
|
+
let pending = cache.get(key);
|
|
48
|
+
if (!pending) {
|
|
49
|
+
pending = (async () => {
|
|
50
|
+
const installed = await installedRunnerVersion(root, entry.cwd, runner);
|
|
51
|
+
if (installed)
|
|
52
|
+
return installed;
|
|
53
|
+
const dirs = [entry.cwd];
|
|
54
|
+
for (const dir of dirs)
|
|
55
|
+
for (const name of [
|
|
56
|
+
"package-lock.json",
|
|
57
|
+
"pnpm-lock.yaml",
|
|
58
|
+
"yarn.lock",
|
|
59
|
+
"bun.lock",
|
|
60
|
+
]) {
|
|
61
|
+
const path = dir === "." ? name : `${dir}/${name}`;
|
|
62
|
+
if (!known.has(path))
|
|
63
|
+
continue;
|
|
64
|
+
const source = await read(path);
|
|
65
|
+
if (source) {
|
|
66
|
+
const version = lockedRunnerVersion(runner, path, source.text);
|
|
67
|
+
if (version)
|
|
68
|
+
return version;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
})();
|
|
73
|
+
cache.set(key, pending);
|
|
74
|
+
}
|
|
75
|
+
const version = await pending;
|
|
76
|
+
return { ...entry, ...(version ? { runnerVersion: version } : {}) };
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
function executable(path) {
|
|
6
|
+
try {
|
|
7
|
+
accessSync(path, constants.X_OK);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Windows ships `bash.exe` launchers for WSL in System32 (and SysWOW64,
|
|
16
|
+
* Sysnative) and in WindowsApps. They run in a Linux VM that cannot see Windows
|
|
17
|
+
* temporary paths, so they are never used for command evidence, including
|
|
18
|
+
* when named explicitly in JEV_TOOLS_BASH.
|
|
19
|
+
*/
|
|
20
|
+
export function isWslLauncher(path) {
|
|
21
|
+
const lower = win32.normalize(path).toLowerCase();
|
|
22
|
+
return (/\\windows\\(system32|syswow64|sysnative)\\/.test(lower) ||
|
|
23
|
+
lower.includes("\\microsoft\\windowsapps\\"));
|
|
24
|
+
}
|
|
25
|
+
/** Permitted Git for Windows bash candidates, most specific first. */
|
|
26
|
+
export function windowsBashCandidates(env) {
|
|
27
|
+
const candidates = [];
|
|
28
|
+
if (env.JEV_TOOLS_BASH?.trim())
|
|
29
|
+
candidates.push(env.JEV_TOOLS_BASH.trim());
|
|
30
|
+
const pathEntries = (env.PATH ?? env.Path ?? "")
|
|
31
|
+
.split(win32.delimiter)
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
for (const entry of pathEntries) {
|
|
34
|
+
candidates.push(win32.join(entry, "bash.exe"));
|
|
35
|
+
// Git\cmd\git.exe or Git\bin\git.exe on PATH implies Git\bin\bash.exe.
|
|
36
|
+
if (/[\\/](cmd|bin)$/i.test(entry))
|
|
37
|
+
candidates.push(win32.join(win32.dirname(entry), "bin", "bash.exe"));
|
|
38
|
+
}
|
|
39
|
+
for (const root of [
|
|
40
|
+
env.ProgramFiles,
|
|
41
|
+
env["ProgramFiles(x86)"],
|
|
42
|
+
env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs"),
|
|
43
|
+
])
|
|
44
|
+
if (root)
|
|
45
|
+
candidates.push(win32.join(root, "Git", "bin", "bash.exe"));
|
|
46
|
+
// Filter every source, so no candidate can reintroduce a WSL launcher.
|
|
47
|
+
return [...new Set(candidates)].filter((candidate) => win32.isAbsolute(candidate) && !isWslLauncher(candidate));
|
|
48
|
+
}
|
|
49
|
+
const UNAVAILABLE = "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.";
|
|
50
|
+
let cached;
|
|
51
|
+
/**
|
|
52
|
+
* POSIX hosts keep `env CI=1 bash -c`. Windows has no `env` and its default
|
|
53
|
+
* `bash` is the WSL launcher, so resolve Git for Windows bash (or
|
|
54
|
+
* JEV_TOOLS_BASH) to an absolute, permitted path and export CI inside the
|
|
55
|
+
* script. When none is found this fails closed: no bare name is returned,
|
|
56
|
+
* because spawning one would search PATH again without the WSL exclusion.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveShell(platform = process.platform, env = process.env, isExecutable = executable) {
|
|
59
|
+
if (platform !== "win32")
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
executable: "env",
|
|
63
|
+
prefix: ["CI=1", "bash"],
|
|
64
|
+
scriptPrefix: "",
|
|
65
|
+
};
|
|
66
|
+
const useCache = isExecutable === executable && env === process.env;
|
|
67
|
+
if (useCache && cached)
|
|
68
|
+
return cached;
|
|
69
|
+
const found = windowsBashCandidates(env).find((path) => isExecutable(path));
|
|
70
|
+
const resolved = found
|
|
71
|
+
? { ok: true, executable: found, prefix: [], scriptPrefix: "export CI=1; " }
|
|
72
|
+
: { ok: false, error: UNAVAILABLE };
|
|
73
|
+
if (useCache && found)
|
|
74
|
+
cached = resolved;
|
|
75
|
+
return resolved;
|
|
76
|
+
}
|