aztrx-cli 0.1.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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * F10 gate #2 — AST safety gates. A model-generated patch is never applied
3
+ * blindly: the patched file is re-parsed and rejected if it smuggles in a new
4
+ * import, dynamic code execution, child-process access, or an empty catch that
5
+ * would swallow the very error we're trying to surface. JavaScript/TypeScript
6
+ * use the TypeScript compiler's AST; HTML/Vue/Svelte are audited by extracting
7
+ * inline <script> blocks and running the same checks on each.
8
+ */
9
+ import ts from "typescript";
10
+ const JS_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
11
+ const MARKUP_EXT = new Set([".html", ".htm", ".vue", ".svelte", ".astro"]);
12
+ function scriptKindFor(ext) {
13
+ switch (ext) {
14
+ case ".tsx": return ts.ScriptKind.TSX;
15
+ case ".jsx": return ts.ScriptKind.JSX;
16
+ case ".ts":
17
+ case ".mts":
18
+ case ".cts": return ts.ScriptKind.TS;
19
+ default: return ts.ScriptKind.JS;
20
+ }
21
+ }
22
+ const FORBIDDEN_CALLS = new Set([
23
+ "exec",
24
+ "execSync",
25
+ "execFile",
26
+ "execFileSync",
27
+ "spawn",
28
+ "spawnSync",
29
+ "fork",
30
+ ]);
31
+ function lineOf(source, node) {
32
+ return source.getLineAndCharacterOfPosition(node.getStart()).line + 1;
33
+ }
34
+ function collectImports(source) {
35
+ const out = new Set();
36
+ const visit = (node) => {
37
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
38
+ out.add(node.moduleSpecifier.text);
39
+ }
40
+ else if (ts.isImportEqualsDeclaration(node)) {
41
+ if (ts.isExternalModuleReference(node.moduleReference) &&
42
+ node.moduleReference.expression &&
43
+ ts.isStringLiteral(node.moduleReference.expression)) {
44
+ out.add(node.moduleReference.expression.text);
45
+ }
46
+ }
47
+ else if (ts.isCallExpression(node)) {
48
+ const expr = node.expression;
49
+ const isRequire = ts.isIdentifier(expr) && expr.text === "require";
50
+ const isDynamicImport = expr.kind === ts.SyntaxKind.ImportKeyword;
51
+ if ((isRequire || isDynamicImport) && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) {
52
+ out.add(node.arguments[0].text);
53
+ }
54
+ }
55
+ ts.forEachChild(node, visit);
56
+ };
57
+ ts.forEachChild(source, visit);
58
+ return out;
59
+ }
60
+ function scanForbidden(source, violations) {
61
+ const visit = (node) => {
62
+ if (ts.isCallExpression(node)) {
63
+ const expr = node.expression;
64
+ const name = ts.isIdentifier(expr) ? expr.text : null;
65
+ const line = lineOf(source, node);
66
+ if (name === "eval") {
67
+ violations.push({ rule: "no-eval", detail: `eval() at line ${line}` });
68
+ }
69
+ else if (name === "require" && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) {
70
+ const spec = node.arguments[0].text;
71
+ if (spec === "child_process" || spec.startsWith("node:")) {
72
+ violations.push({ rule: "no-child-process", detail: `require(${JSON.stringify(spec)}) at line ${line}` });
73
+ }
74
+ }
75
+ else if (name === "setTimeout" || name === "setInterval") {
76
+ if (node.arguments[0] && ts.isStringLiteral(node.arguments[0])) {
77
+ violations.push({ rule: "no-eval", detail: `${name}(string) at line ${line}` });
78
+ }
79
+ }
80
+ else if (name && FORBIDDEN_CALLS.has(name)) {
81
+ violations.push({ rule: "no-child-process", detail: `${name}() at line ${line}` });
82
+ }
83
+ }
84
+ if (ts.isNewExpression(node)) {
85
+ const expr = node.expression;
86
+ if (ts.isIdentifier(expr) && expr.text === "Function") {
87
+ violations.push({ rule: "no-eval", detail: `new Function() at line ${lineOf(source, node)}` });
88
+ }
89
+ }
90
+ if (ts.isCatchClause(node) && node.block.statements.length === 0) {
91
+ violations.push({ rule: "no-empty-catch", detail: `empty catch at line ${lineOf(source, node)}` });
92
+ }
93
+ if (ts.isExpressionStatement(node)) {
94
+ const text = node.getText(source).trim();
95
+ if (/\bprocess\.(exit|kill)\s*\(/.test(text)) {
96
+ violations.push({ rule: "no-child-process", detail: `${text.slice(0, 60)}` });
97
+ }
98
+ }
99
+ ts.forEachChild(node, visit);
100
+ };
101
+ ts.forEachChild(source, visit);
102
+ }
103
+ function auditScript(original, patched, label, kind) {
104
+ const violations = [];
105
+ const fileName = `${label}.js`;
106
+ const origSrc = ts.createSourceFile(fileName, original, ts.ScriptTarget.Latest, true, kind);
107
+ const patchSrc = ts.createSourceFile(fileName, patched, ts.ScriptTarget.Latest, true, kind);
108
+ // Gate 0: the patched file must still parse — the compile fast-fail that runs
109
+ // before any Playwright verification (cheap, dependency-free).
110
+ const parseDiags = patchSrc.parseDiagnostics ?? [];
111
+ for (const d of parseDiags) {
112
+ violations.push({ rule: "syntax-error", detail: ts.flattenDiagnosticMessageText(d.messageText, " ") });
113
+ }
114
+ // Gate 1: no new imports / dependencies.
115
+ const before = collectImports(origSrc);
116
+ const after = collectImports(patchSrc);
117
+ for (const spec of after) {
118
+ if (!before.has(spec)) {
119
+ violations.push({ rule: "no-new-imports", detail: `new import ${JSON.stringify(spec)}` });
120
+ }
121
+ }
122
+ // Gates 2–4: no eval, no child_process, no empty catch.
123
+ scanForbidden(patchSrc, violations);
124
+ return { ok: violations.length === 0, violations };
125
+ }
126
+ function scriptBlocks(markup) {
127
+ const out = [];
128
+ const re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
129
+ let m;
130
+ while ((m = re.exec(markup)) !== null)
131
+ out.push(m[1]);
132
+ return out;
133
+ }
134
+ /** Audit a patch by parsing the resulting file. `filePath` selects the parser. */
135
+ export function auditPatch(original, patched, filePath) {
136
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
137
+ if (JS_EXT.has(ext)) {
138
+ return auditScript(original, patched, "file", scriptKindFor(ext));
139
+ }
140
+ if (MARKUP_EXT.has(ext)) {
141
+ const origBlocks = scriptBlocks(original);
142
+ const patchBlocks = scriptBlocks(patched);
143
+ const violations = [];
144
+ const n = Math.max(origBlocks.length, patchBlocks.length);
145
+ for (let i = 0; i < n; i++) {
146
+ const r = auditScript(origBlocks[i] ?? "", patchBlocks[i] ?? "", `script${i + 1}`, ts.ScriptKind.JS);
147
+ violations.push(...r.violations);
148
+ }
149
+ // If the patch introduced a <script> that wasn't there, patchBlocks grew;
150
+ // the per-block audit already covers the new content. Report combined.
151
+ return { ok: violations.length === 0, violations };
152
+ }
153
+ // Unknown extension: conservative lexical scan, clearly not an AST audit.
154
+ const violations = [];
155
+ if (/\beval\s*\(/.test(patched) || /\bnew\s+Function\b/.test(patched)) {
156
+ violations.push({ rule: "no-eval", detail: "lexical eval/Function in non-JS file" });
157
+ }
158
+ if (/child_process|\bexec(Sync|File|FileSync)?\s*\(|\bspawn(Sync)?\s*\(|\bfork\s*\(/.test(patched)) {
159
+ violations.push({ rule: "no-child-process", detail: "lexical child_process/exec in non-JS file" });
160
+ }
161
+ if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(patched)) {
162
+ violations.push({ rule: "no-empty-catch", detail: "lexical empty catch in non-JS file" });
163
+ }
164
+ return { ok: violations.length === 0, violations };
165
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * F10 — closed-loop healing. Orchestrates the full loop, in order:
3
+ *
4
+ * 1. redact — strip secrets from anything that will leave the machine
5
+ * 2. generate — ask an LLM for a Search & Replace diff (against the redacted
6
+ * file, unredacted before it touches the raw bytes)
7
+ * 3. gate — re-parse the patched file; reject new imports / eval /
8
+ * child_process / empty catch
9
+ * 4. sandbox — apply the patch in a detached git worktree, never the tree
10
+ * 5. verify — replay the repro against the patched app; the bug must be gone
11
+ * 6. hand off — write a unified-diff `.patch` for a human to review and commit
12
+ *
13
+ * Aztrx never commits. A patch that fails any gate, does not apply exactly, or
14
+ * still reproduces the bug is rejected and reported, not silently kept.
15
+ */
16
+ import { createServer } from "http";
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import { redact, unredact } from "./redact.js";
20
+ import { auditPatch } from "./gates.js";
21
+ import { generatePatch, modelTiers } from "./llm.js";
22
+ import { applyHunks, createWorktree, diffWorktree, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
23
+ import { verifyFix } from "./verify.js";
24
+ const MIME = {
25
+ ".html": "text/html; charset=utf-8",
26
+ ".htm": "text/html; charset=utf-8",
27
+ ".js": "text/javascript",
28
+ ".mjs": "text/javascript",
29
+ ".json": "application/json",
30
+ ".css": "text/css",
31
+ ".svg": "image/svg+xml",
32
+ ".png": "image/png",
33
+ ".jpg": "image/jpeg",
34
+ ".ico": "image/x-icon",
35
+ };
36
+ /** Default verification server: serve the worktree's repo root statically and
37
+ * address the mapped file directly. Works for static fixtures; real apps inject
38
+ * their own dev-server `serve` fn. */
39
+ function staticServe(worktreeDir, filePath) {
40
+ const entry = filePath.split(path.sep).join("/");
41
+ return new Promise((resolve, reject) => {
42
+ const server = createServer((req, res) => {
43
+ const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
44
+ let p = path.normalize(path.join(worktreeDir, pathname));
45
+ const root = path.resolve(worktreeDir);
46
+ if (p !== root && !p.startsWith(root + path.sep)) {
47
+ res.statusCode = 403;
48
+ res.end("forbidden");
49
+ return;
50
+ }
51
+ if (fs.existsSync(p) && fs.statSync(p).isDirectory())
52
+ p = path.join(p, "index.html");
53
+ try {
54
+ const body = fs.readFileSync(p);
55
+ res.setHeader("Content-Type", MIME[path.extname(p).toLowerCase()] ?? "text/plain");
56
+ res.end(body);
57
+ }
58
+ catch {
59
+ res.statusCode = 404;
60
+ res.end("not found");
61
+ }
62
+ });
63
+ server.on("error", reject);
64
+ server.listen(0, "127.0.0.1", () => {
65
+ const addr = server.address();
66
+ resolve({
67
+ url: `http://127.0.0.1:${addr.port}/${entry}`,
68
+ close: () => new Promise((r) => server.close(() => r())),
69
+ });
70
+ });
71
+ });
72
+ }
73
+ async function saveArtifact(repoRoot, finding, patch, gateOk, verification, worktreeDir, filePath) {
74
+ const dir = path.join(repoRoot, ".aztrx", "heal");
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ const diff = await diffWorktree(worktreeDir, filePath);
77
+ const diffPath = path.join(dir, `${finding.id}.patch`);
78
+ fs.writeFileSync(diffPath, diff || `# (no unified diff produced)\n`, "utf-8");
79
+ fs.writeFileSync(path.join(dir, `${finding.id}.json`), JSON.stringify({ explanation: patch.explanation, hunks: patch.hunks, gateOk, verification }, null, 2), "utf-8");
80
+ return diffPath;
81
+ }
82
+ export async function heal(finding, opts) {
83
+ const base = {
84
+ status: "skipped",
85
+ findingId: finding.id,
86
+ filePath: finding.mappedLocation?.filePath ?? "",
87
+ hunks: [],
88
+ violations: [],
89
+ };
90
+ const loc = finding.mappedLocation;
91
+ if (!loc || !loc.isOwnCode) {
92
+ return { ...base, error: "no own-code source location to heal" };
93
+ }
94
+ if (!finding.repro || finding.repro.verdict === "unreliable") {
95
+ return { ...base, error: "no deterministic repro to verify against" };
96
+ }
97
+ const filePath = loc.filePath;
98
+ const absPath = path.resolve(opts.repoRoot, filePath);
99
+ let original;
100
+ try {
101
+ original = fs.readFileSync(absPath, "utf-8");
102
+ }
103
+ catch (e) {
104
+ return { ...base, error: `cannot read ${filePath}: ${e.message}` };
105
+ }
106
+ // 1. Redact — only the redacted copy is shown to the model.
107
+ const red = redact(original);
108
+ const ctx = { finding, filePath, fileContent: original, redactedContent: red.text };
109
+ // No transport configured (and no injected generator) → nothing to try. A
110
+ // higher model tier can't fix a missing key, so bail before paying anything.
111
+ if (!opts.patchFn && !process.env.ANTHROPIC_API_KEY) {
112
+ return {
113
+ ...base,
114
+ status: "no-llm",
115
+ error: "heal: ANTHROPIC_API_KEY is not set (and no patchFn was injected)",
116
+ };
117
+ }
118
+ // The Smart Cloud Router tier plan: fast/cheap first, Sonnet as the fallback.
119
+ // An injected patchFn collapses to a single tier (there is no model to route).
120
+ const tiers = opts.patchFn
121
+ ? [{ model: opts.model ?? "claude-sonnet-5", label: "sonnet" }]
122
+ : modelTiers(opts.model);
123
+ const wt = await createWorktree(opts.repoRoot, finding.id);
124
+ // The winning (or last) patch + verification, held back for the final save.
125
+ let savedPatch = null;
126
+ let savedVerification = null;
127
+ let savedGateOk = false;
128
+ let last = base;
129
+ try {
130
+ for (const tier of tiers) {
131
+ // 2. Generate (this tier).
132
+ let patch;
133
+ try {
134
+ patch = await generatePatch(ctx, { model: tier.model, patchFn: opts.patchFn });
135
+ }
136
+ catch (e) {
137
+ // A transport/config failure isn't a model-quality failure — a pricier
138
+ // tier won't fix a dead endpoint or a missing key, so stop here.
139
+ last = { ...base, status: "no-llm", error: e.message, model: tier.model };
140
+ break;
141
+ }
142
+ if (patch.hunks.length === 0) {
143
+ last = {
144
+ ...base,
145
+ status: "rejected",
146
+ explanation: patch.explanation,
147
+ error: "model produced no edits",
148
+ model: tier.model,
149
+ };
150
+ continue; // a higher tier may still produce a real edit
151
+ }
152
+ // Unredact the diff back onto the raw bytes before anything is applied.
153
+ const hunks = patch.hunks.map((h) => ({
154
+ search: unredact(h.search, red.map),
155
+ replace: unredact(h.replace, red.map),
156
+ }));
157
+ // Apply in memory (exact-match), then gate the resulting file.
158
+ const applied = applyHunks(original, hunks);
159
+ if (!applied.ok) {
160
+ last = {
161
+ ...base,
162
+ status: "apply-failed",
163
+ hunks,
164
+ explanation: patch.explanation,
165
+ error: applied.errors.join("; "),
166
+ model: tier.model,
167
+ };
168
+ continue;
169
+ }
170
+ const gate = auditPatch(original, applied.patched, filePath);
171
+ if (!gate.ok) {
172
+ last = {
173
+ ...base,
174
+ status: "rejected",
175
+ hunks,
176
+ explanation: patch.explanation,
177
+ violations: gate.violations,
178
+ model: tier.model,
179
+ };
180
+ continue;
181
+ }
182
+ // 3. Sandbox — apply in a detached worktree.
183
+ const writeErr = writeWorktreeFile(wt.dir, filePath, applied.patched);
184
+ if (writeErr) {
185
+ last = {
186
+ ...base,
187
+ status: "apply-failed",
188
+ hunks,
189
+ explanation: patch.explanation,
190
+ error: writeErr,
191
+ model: tier.model,
192
+ };
193
+ continue;
194
+ }
195
+ // 3b. Compile fast-fail — reject a patch that doesn't typecheck before
196
+ // paying for the Playwright verification loop.
197
+ const compile = await typecheckWorktree(wt.dir, opts.repoRoot);
198
+ if (!compile.ok) {
199
+ last = {
200
+ ...base,
201
+ status: "compile-failed",
202
+ hunks,
203
+ explanation: patch.explanation,
204
+ error: compile.output.slice(0, 400) || "tsc --noEmit failed",
205
+ model: tier.model,
206
+ };
207
+ continue;
208
+ }
209
+ // 4. Verify — the bug must stop reproducing.
210
+ const serve = opts.serve ?? ((dir, fp) => staticServe(dir, fp));
211
+ const v = await verifyFix({
212
+ url: opts.url,
213
+ actions: opts.actions,
214
+ fingerprint: opts.fingerprint,
215
+ runs: opts.verifyRuns ?? 3,
216
+ serve: () => serve(wt.dir, filePath),
217
+ });
218
+ savedPatch = patch;
219
+ savedVerification = v;
220
+ savedGateOk = gate.ok;
221
+ last = {
222
+ ...base,
223
+ status: v.fixed ? "healed" : "unfixed",
224
+ explanation: patch.explanation,
225
+ hunks,
226
+ violations: gate.violations,
227
+ verification: v,
228
+ model: tier.model,
229
+ };
230
+ if (v.fixed)
231
+ break;
232
+ }
233
+ // 5. Hand off a reviewable patch (the winning — or last — attempt only).
234
+ if (savedPatch && savedVerification) {
235
+ last.patchPath = await saveArtifact(opts.repoRoot, finding, savedPatch, savedGateOk, savedVerification, wt.dir, filePath);
236
+ }
237
+ return { ...last, tiers: tiers.map((t) => t.model) };
238
+ }
239
+ finally {
240
+ await wt.cleanup();
241
+ }
242
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * F10 — LLM patch generator. Turns a redacted bug report into a minimal
3
+ * Search & Replace diff. The transport is Anthropic's Messages API (key from
4
+ * `ANTHROPIC_API_KEY`, model from `AZTRX_MODEL` or a sensible default); a
5
+ * `patchFn` can be injected instead, which is how the loop is unit-tested and
6
+ * how a future provider (local model, proxy) plugs in without touching this
7
+ * module's callers.
8
+ */
9
+ import { redact } from "./redact.js";
10
+ const DEFAULT_MODEL = process.env.AZTRX_MODEL || "claude-sonnet-5";
11
+ /** Cheap/fast first tier for the Smart Cloud Router. The idea: most one-line
12
+ * fixes are trivial, so try the small model before paying for the big one. */
13
+ const FAST_MODEL = process.env.AZTRX_FAST_MODEL || "claude-haiku-4-5-20251001";
14
+ const API_URL = "https://api.anthropic.com/v1/messages";
15
+ /**
16
+ * The Smart Cloud Router's tier plan: fast/cheap first, then the capable model
17
+ * as the fallback. Collapses to a single tier when the two resolve to the same
18
+ * model (e.g. `AZTRX_FAST_MODEL=claude-sonnet-5`). Consumers loop over this in
19
+ * order and stop at the first `healed` result.
20
+ */
21
+ export function modelTiers(fallbackModel) {
22
+ const fast = process.env.AZTRX_FAST_MODEL || FAST_MODEL;
23
+ const sonnet = fallbackModel || DEFAULT_MODEL;
24
+ if (fast === sonnet)
25
+ return [{ model: sonnet, label: "sonnet" }];
26
+ return [
27
+ { model: fast, label: "fast" },
28
+ { model: sonnet, label: "sonnet" },
29
+ ];
30
+ }
31
+ const SYSTEM = `You are a meticulous bug-fixing engineer. You are given a single source file and a runtime error that occurs in it. Produce a MINIMAL fix as a Search & Replace diff.
32
+
33
+ Return ONLY a JSON object, no markdown fences, no prose. Shape:
34
+ { "explanation": "one sentence", "edits": [ { "search": "<exact substring from the file>", "replace": "<the fixed version>" } ] }
35
+
36
+ Hard rules:
37
+ - "search" must be an EXACT, unique substring of the file you were shown (include enough surrounding lines to be unique).
38
+ - "replace" is the corrected version of exactly that substring.
39
+ - Change as little as possible. Do not reformat unrelated code.
40
+ - Do NOT add any new import/require/import(). Do NOT use eval or new Function. Do NOT write an empty catch block (catch {}). Do NOT touch child_process, exec, spawn, fork, process.exit.
41
+ - If you see __AZTRX_REDACTED_N__ placeholders, treat them as opaque tokens and carry them through unchanged — do not invent values for them.
42
+ - If you cannot fix the bug, return { "explanation": "cannot fix", "edits": [] }.`;
43
+ function buildPrompt(ctx) {
44
+ const loc = ctx.finding.mappedLocation;
45
+ const msg = redact(ctx.finding.rawMessage).text;
46
+ const stack = redact(ctx.finding.rawStack).text;
47
+ const parts = [];
48
+ parts.push(`File: ${ctx.filePath}`);
49
+ if (loc)
50
+ parts.push(`Bug location: line ${loc.line}, column ${loc.column}`);
51
+ parts.push(`Error: ${msg.split("\n")[0].slice(0, 200)}`);
52
+ if (stack)
53
+ parts.push(`Stack (truncated):\n${stack.split("\n").slice(0, 12).join("\n")}`);
54
+ parts.push(`--- file: ${ctx.filePath} ---`);
55
+ parts.push(ctx.redactedContent);
56
+ parts.push("--- end file ---");
57
+ parts.push("Return the JSON Search & Replace diff that fixes this error.");
58
+ return parts.join("\n");
59
+ }
60
+ /** Parse a model reply into a Patch. Tolerates markdown fences and leading text. */
61
+ export function parsePatch(raw) {
62
+ let text = raw.trim();
63
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
64
+ if (fence)
65
+ text = fence[1].trim();
66
+ const start = text.indexOf("{");
67
+ const end = text.lastIndexOf("}");
68
+ if (start >= 0 && end > start)
69
+ text = text.slice(start, end + 1);
70
+ const data = JSON.parse(text);
71
+ const hunks = (data.edits ?? [])
72
+ .filter((e) => typeof e?.search === "string" && e.search.length > 0 && typeof e?.replace === "string")
73
+ .map((e) => ({ search: e.search, replace: e.replace }));
74
+ return { explanation: typeof data.explanation === "string" ? data.explanation : "", hunks };
75
+ }
76
+ export async function generatePatch(ctx, opts = {}) {
77
+ if (opts.patchFn)
78
+ return opts.patchFn(ctx);
79
+ const key = process.env.ANTHROPIC_API_KEY;
80
+ if (!key) {
81
+ throw new Error("heal: ANTHROPIC_API_KEY is not set (and no patchFn was injected)");
82
+ }
83
+ const res = await fetch(API_URL, {
84
+ method: "POST",
85
+ headers: {
86
+ "content-type": "application/json",
87
+ "x-api-key": key,
88
+ "anthropic-version": "2023-06-01",
89
+ },
90
+ body: JSON.stringify({
91
+ model: opts.model ?? DEFAULT_MODEL,
92
+ max_tokens: 2048,
93
+ temperature: 0,
94
+ system: SYSTEM,
95
+ messages: [{ role: "user", content: buildPrompt(ctx) }],
96
+ }),
97
+ });
98
+ if (!res.ok) {
99
+ const body = await res.text().catch(() => "");
100
+ throw new Error(`heal: LLM request failed (${res.status}): ${body.slice(0, 300)}`);
101
+ }
102
+ const data = (await res.json());
103
+ const text = (data.content ?? [])
104
+ .filter((c) => c.type === "text")
105
+ .map((c) => c.text ?? "")
106
+ .join("\n");
107
+ return parsePatch(text);
108
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Secret redaction layer — F10 gate #1. Before any byte leaves the machine
3
+ * (sent to an LLM, written to a log, streamed to a dashboard) it passes through
4
+ * here. Redaction is *reversible*: the caller keeps the placeholder→secret map
5
+ * so a generated Search & Replace diff (which references placeholders) can be
6
+ * unredacted back onto the raw source before it is applied.
7
+ */
8
+ const PLACEHOLDER = (n) => `__AZTRX_REDACTED_${n}__`;
9
+ // Whole-match secrets: the entire match is replaced by a placeholder.
10
+ const WHOLE_MATCH = [
11
+ { re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, label: "private_key" },
12
+ { re: /sk-(?:ant-)?[A-Za-z0-9_-]{20,}/g, label: "api_key" },
13
+ { re: /\bAKIA[0-9A-Z]{16}\b/g, label: "aws_key" },
14
+ { re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, label: "github_token" },
15
+ { re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, label: "github_token" },
16
+ { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, label: "slack_token" },
17
+ { re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, label: "jwt" },
18
+ { re: /\bBearer [A-Za-z0-9._-]{20,}/g, label: "bearer_token" },
19
+ ];
20
+ // Prefix-preserving: group 1 stays in place (so the code structure — the key
21
+ // name, the scheme+user of a URL — remains visible to the model), group 2 is
22
+ // the secret value that is replaced.
23
+ const VALUE_MATCH = [
24
+ {
25
+ re: /(["']?(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token|client[_-]?secret|private[_-]?key|auth[_-]?token)["']?\s*[:=]\s*)(["']?[^"'\s;,&}{=]{8,}["']?)/gi,
26
+ label: "secret_value",
27
+ },
28
+ {
29
+ re: /((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|amqps):\/\/[^:/\s]+:)[^@\s]+(@)/gi,
30
+ label: "url_password",
31
+ },
32
+ ];
33
+ export function redact(input) {
34
+ const map = new Map();
35
+ let counter = 0;
36
+ let text = input;
37
+ for (const { re, label } of WHOLE_MATCH) {
38
+ re.lastIndex = 0;
39
+ text = text.replace(re, (match) => {
40
+ const ph = PLACEHOLDER(counter++);
41
+ map.set(ph, match);
42
+ void label;
43
+ return ph;
44
+ });
45
+ }
46
+ for (const { re, label } of VALUE_MATCH) {
47
+ re.lastIndex = 0;
48
+ text = text.replace(re, (_match, prefix, secret) => {
49
+ const ph = PLACEHOLDER(counter++);
50
+ map.set(ph, secret);
51
+ void label;
52
+ return prefix + ph;
53
+ });
54
+ }
55
+ return { text, map };
56
+ }
57
+ /** Reverse a redaction: swap every placeholder back to its original secret. */
58
+ export function unredact(input, map) {
59
+ let out = input;
60
+ for (const [ph, secret] of map)
61
+ out = out.split(ph).join(secret);
62
+ return out;
63
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * F10 gate #3 — isolated Git worktree sandbox. A patch is never applied to the
3
+ * user's working tree: it lands in a detached `git worktree`, is verified there,
4
+ * and the only artifact that escapes is a `.patch` file for a human to review
5
+ * and apply. Aztrx never commits — humans do.
6
+ */
7
+ import { execFile } from "child_process";
8
+ import { promisify } from "util";
9
+ import * as fs from "fs";
10
+ import * as os from "os";
11
+ import * as path from "path";
12
+ const execFileP = promisify(execFile);
13
+ const preview = (s) => JSON.stringify(s.length > 60 ? s.slice(0, 57) + "…" : s);
14
+ /** Create a detached worktree at HEAD in a temp dir (outside the repo). */
15
+ export async function createWorktree(repoRoot, label) {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `aztrx-heal-${label}-`));
17
+ await execFileP("git", ["-C", repoRoot, "worktree", "add", "--detach", dir, "HEAD"]);
18
+ return {
19
+ dir,
20
+ cleanup: async () => {
21
+ await execFileP("git", ["-C", repoRoot, "worktree", "remove", "--force", dir]).catch(() => { });
22
+ fs.rmSync(dir, { recursive: true, force: true });
23
+ },
24
+ };
25
+ }
26
+ /** Apply Search & Replace hunks to an in-memory file. Each `search` must match
27
+ * exactly once; ambiguous or missing matches fail the whole apply (no partial
28
+ * writes). Pure — the caller decides where the result lands. */
29
+ export function applyHunks(content, hunks) {
30
+ const errors = [];
31
+ for (const h of hunks) {
32
+ const first = content.indexOf(h.search);
33
+ if (first === -1) {
34
+ errors.push(`search not found: ${preview(h.search)}`);
35
+ continue;
36
+ }
37
+ if (content.indexOf(h.search, first + h.search.length) !== -1) {
38
+ errors.push(`search ambiguous (matches multiple times): ${preview(h.search)}`);
39
+ }
40
+ }
41
+ if (errors.length)
42
+ return { ok: false, patched: content, applied: 0, errors };
43
+ let patched = content;
44
+ for (const h of hunks) {
45
+ patched = patched.replace(h.search, h.replace);
46
+ }
47
+ return { ok: true, patched, applied: hunks.length, errors: [] };
48
+ }
49
+ /** Write the patched file into the worktree, refusing to escape it. */
50
+ export function writeWorktreeFile(worktreeDir, repoRelativePath, content) {
51
+ const root = path.resolve(worktreeDir);
52
+ const target = path.resolve(root, repoRelativePath);
53
+ if (target !== root && !target.startsWith(root + path.sep)) {
54
+ return `refusing to write outside worktree: ${repoRelativePath}`;
55
+ }
56
+ fs.mkdirSync(path.dirname(target), { recursive: true });
57
+ fs.writeFileSync(target, content, "utf-8");
58
+ return null;
59
+ }
60
+ /** Produce a unified diff of the patched file against HEAD in the worktree. */
61
+ export async function diffWorktree(worktreeDir, repoRelativePath) {
62
+ try {
63
+ const { stdout } = await execFileP("git", ["-C", worktreeDir, "diff", "--", repoRelativePath]);
64
+ return stdout;
65
+ }
66
+ catch {
67
+ return "";
68
+ }
69
+ }
70
+ /** Run `tsc --noEmit` against the patched worktree — the full type check that
71
+ * follows the AST syntax gate. Best-effort: passes (skips) when the repo has no
72
+ * TypeScript or the worktree has no tsconfig, so non-TS projects aren't blocked.
73
+ * The worktree has no node_modules; a symlink to the root's is created first and
74
+ * removed with the worktree on cleanup. */
75
+ export async function typecheckWorktree(worktreeDir, repoRoot) {
76
+ const tscBin = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc");
77
+ const hasTsconfig = fs.existsSync(path.join(worktreeDir, "tsconfig.json"));
78
+ if (!fs.existsSync(tscBin) || !hasTsconfig) {
79
+ return { ok: true, ran: false, output: "" };
80
+ }
81
+ const rootNodeModules = path.join(repoRoot, "node_modules");
82
+ const wtNodeModules = path.join(worktreeDir, "node_modules");
83
+ if (!fs.existsSync(wtNodeModules)) {
84
+ try {
85
+ fs.symlinkSync(rootNodeModules, wtNodeModules, process.platform === "win32" ? "junction" : "dir");
86
+ }
87
+ catch {
88
+ /* symlink failed — tsc reports its own resolution errors below */
89
+ }
90
+ }
91
+ try {
92
+ const { stdout } = await execFileP(process.execPath, [tscBin, "--noEmit", "-p", worktreeDir], { cwd: worktreeDir, maxBuffer: 10 * 1024 * 1024 });
93
+ return { ok: true, ran: true, output: stdout.trim() };
94
+ }
95
+ catch (e) {
96
+ const err = e;
97
+ return { ok: false, ran: true, output: ((err.stdout ?? "") + (err.stderr ?? "")).trim() };
98
+ }
99
+ }
@@ -0,0 +1 @@
1
+ export {};