@synqa/cli 0.0.0-stage → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js ADDED
@@ -0,0 +1,413 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { Worker } from "node:worker_threads";
3
+ import { closeSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
5
+ import { MAX_DIFF_LINES, renderDiff, unifiedDiff } from "./diff.js";
6
+ import { snapshot } from "./journal.js";
7
+
8
+ import { commandTouchesSecrets, confine, fold, isHomeDir, isSecretStemSource, isSensitivePath, writeConfined } from "./paths.js";
9
+ import { visibleEscapes } from "./sanitize.js";
10
+
11
+
12
+ const MAX_FILE_BYTES = 1_000_000;
13
+ const MAX_PATTERN = 200;
14
+
15
+ // Reads at most MAX_FILE_BYTES of a regular file; stats first so a huge file is never loaded.
16
+ function readCapped(p ) {
17
+ const st = statSync(p);
18
+ if (!st.isFile()) throw new Error("not a regular file");
19
+ const len = Math.min(st.size, MAX_FILE_BYTES);
20
+ const buf = Buffer.alloc(len);
21
+ const fd = openSync(p, "r");
22
+ try {
23
+ readSync(fd, buf, 0, len, 0);
24
+ } finally {
25
+ closeSync(fd);
26
+ }
27
+ const text = buf.toString("utf8");
28
+ return st.size > MAX_FILE_BYTES ? text + `\n[file truncated at ${MAX_FILE_BYTES} bytes]` : text;
29
+ }
30
+
31
+ // Environment variables that commonly hold credentials, by name: the API key (SYNQA_*), tokens,
32
+ // secrets, keys and passwords (TOKENIZERS_PARALLELISM, AWS_REGION, AWS_DEFAULT_REGION and
33
+ // AWS_PROFILE stay), connection strings, the cloud, code host and package registry families
34
+ // (NPM_* includes npm_config_*), git credential helpers and the SSH agent. Model provider keys are
35
+ // caught by the KEY and TOKEN rules. Narrowing this list must keep the regression list in
36
+ // test/hardening.test.ts passing.
37
+ const SECRET_ENV = [
38
+ /^SYNQA_/, /TOKEN(?!IZER)/, /AUTHTOKEN/, /SECRET/, /(^|_)KEYS?(_|$)/, /APIKEY/, /PASSWORD/, /PASSWD/, /(^|_)PASS$/, /_PWD$/,
39
+ /CREDENTIAL/, /CONNECTION_?STRING/, /(^|_)AUTH$/, /^AUTH_/, /ASKPASS/, /^VSCODE_GIT_/, /^SSH_AUTH_SOCK$/,
40
+ /^SLACK_WEBHOOK_URL$/, /^SENTRY_DSN$/, /^DATABASE_URL$/,
41
+ /^AWS_(?!REGION$|DEFAULT_REGION$|PROFILE$)/, /^GH_/, /^GITHUB_/, /^GITLAB_/, /^NPM_/, /^HF_/, /^HUGGING_?FACE/, /^AZURE_/,
42
+ /^GOOGLE_APPLICATION_CREDENTIALS$/,
43
+ ];
44
+ // A URL, URI or DSN value with a user and password in it (postgres://user:pass@host) is a secret;
45
+ // a plain URL is kept.
46
+ const URL_NAME = /(^|_)(URL|URI|DSN)$/;
47
+ const USERINFO = /^[a-z][a-z0-9+.-]*:\/\/[^/\s@:]*:[^/\s@]*@/i;
48
+
49
+ // Child processes get the parent environment minus every variable that looks like a credential.
50
+ // SSH_AUTH_SOCK is one of them, so git over SSH that needs the agent runs in the user's own terminal.
51
+ export function bashEnv(env ) {
52
+ const out = {};
53
+ for (const [k, v] of Object.entries(env)) {
54
+ const up = k.toUpperCase();
55
+ if (v === undefined || SECRET_ENV.some((re) => re.test(up))) continue;
56
+ if (URL_NAME.test(up) && USERINFO.test(v.trim())) continue;
57
+ out[k] = v;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // `detail` is text this module built (a coloured diff); the caller prints it as is, while the
63
+ // question is model-influenced and must be sanitized by the caller.
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+ const MAX_OUT = 20_000;
79
+ const MAX_RESULTS = 500;
80
+ const SKIP_DIRS = new Set([".git", "node_modules", ".ssh", ".aws", ".gnupg", ".kube", ".trash"]);
81
+
82
+ export const TOOL_DEFS = [
83
+ def("read_file", "Read a text file inside the working directory.", {
84
+ path: { type: "string", description: "File path relative to the working directory" },
85
+ }, ["path"]),
86
+ def("write_file", "Create or overwrite a file (asks the user first).", {
87
+ path: { type: "string" },
88
+ content: { type: "string" },
89
+ }, ["path", "content"]),
90
+ def("edit_file", "Replace one exact occurrence of old_string with new_string (asks the user first).", {
91
+ path: { type: "string" },
92
+ old_string: { type: "string" },
93
+ new_string: { type: "string" },
94
+ }, ["path", "old_string", "new_string"]),
95
+ def("glob", "List files matching a glob pattern such as src/**/*.ts.", {
96
+ pattern: { type: "string" },
97
+ }, ["pattern"]),
98
+ def("grep", "Search file contents with a regular expression.", {
99
+ pattern: { type: "string" },
100
+ path: { type: "string", description: "Optional directory or file to search" },
101
+ }, ["pattern"]),
102
+ def("bash", "Run a shell command in the working directory (asks the user first).", {
103
+ command: { type: "string" },
104
+ }, ["command"]),
105
+ ];
106
+
107
+ function def(name , description , properties , required ) {
108
+ return { type: "function", function: { name, description, parameters: { type: "object", properties, required } } };
109
+ }
110
+
111
+ function truncate(s ) {
112
+ return s.length > MAX_OUT ? s.slice(0, MAX_OUT) + `\n[truncated ${s.length - MAX_OUT} chars]` : s;
113
+ }
114
+
115
+ export function globToRegExp(pattern ) {
116
+ let re = "";
117
+ for (let i = 0; i < pattern.length; i++) {
118
+ const c = pattern[i] ;
119
+ if (c === "*") {
120
+ if (pattern[i + 1] === "*") {
121
+ i++;
122
+ if (pattern[i + 1] === "/") {
123
+ i++;
124
+ re += "(?:.*/)?";
125
+ } else re += ".*";
126
+ } else re += "[^/]*";
127
+ } else if (c === "?") re += "[^/]";
128
+ else if (c === "{") {
129
+ const end = pattern.indexOf("}", i);
130
+ if (end > i) {
131
+ re += "(?:" + pattern.slice(i + 1, end).split(",").map(escapeRe).join("|") + ")";
132
+ i = end;
133
+ } else re += "\\{";
134
+ } else re += escapeRe(c);
135
+ }
136
+ return new RegExp("^" + re + "$");
137
+ }
138
+
139
+ function escapeRe(s ) {
140
+ return s.replace(/[.+^${}()|[\]\\]/g, "\\$&");
141
+ }
142
+
143
+ function walk(root , dir , out , secret ) {
144
+ if (out.length >= MAX_RESULTS * 20) return;
145
+ let entries;
146
+ try {
147
+ entries = readdirSync(dir, { withFileTypes: true });
148
+ } catch {
149
+ return;
150
+ }
151
+ for (const e of entries) {
152
+ if (SKIP_DIRS.has(fold(e.name))) continue;
153
+ const full = join(dir, e.name);
154
+ // A folder of credentials or a browser profile is never walked into (it can be huge, too).
155
+ if (e.isDirectory()) { if (!secret(full)) walk(root, full, out, secret); }
156
+ else if (e.isFile()) out.push(relative(root, full).split(sep).join("/"));
157
+ }
158
+ }
159
+
160
+ const RC_FILES = new Set([".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile", ".zshenv", ".bash_login", ".zlogin", ".zlogout", ".bash_logout", ".kshrc", ".cshrc", ".tcshrc", ".login", "config.fish"]);
161
+
162
+ // Why an approved write to this target can run code later (hooks, build files, shell startup files,
163
+ // CI, editor tasks, executables). Compared folded, so ".GIT/hooks" counts as ".git/hooks".
164
+ export function riskReasons(root , abs ) {
165
+ const rel = fold(relative(root, abs).split(sep).join("/"));
166
+ const parts = rel.split("/");
167
+ const base = parts[parts.length - 1] ?? "";
168
+ const dirs = parts.slice(0, -1);
169
+ const reasons = [];
170
+ const full = "/" + fold(abs.split(sep).join("/")).replace(/^\/+/, "");
171
+ if (rel === ".." || rel.startsWith("../") || isAbsolute(rel)) reasons.push("outside the workspace");
172
+ if (/\/library\/(launchagents|launchdaemons)\//.test(full)) reasons.push("a launchd job (runs at login or boot)");
173
+ if (full.includes("/.config/autostart/")) reasons.push("an autostart entry (runs at login)");
174
+ if (/\/(\.config|\.local\/share)\/systemd\//.test(full)) reasons.push("a systemd unit (runs as a service)");
175
+ if (full.includes("/.config/fish/")) reasons.push("a fish startup file (runs in every new shell)");
176
+ if (full.includes("/.local/bin/")) reasons.push("a program on your PATH");
177
+ if (rel.startsWith(".git/hooks/") || rel.includes("/.git/hooks/")) reasons.push("a git hook (runs on git actions)");
178
+ else if (dirs.includes(".git")) reasons.push("inside .git");
179
+ if (base === "package.json") reasons.push("package.json (scripts can run on install or test)");
180
+ if (base === "makefile" || base === "gnumakefile") reasons.push("a Makefile (make runs it)");
181
+ if (base === ".envrc") reasons.push(".envrc (direnv runs it when you enter the folder)");
182
+ if (dirs.includes(".vscode") && base.endsWith(".json")) reasons.push("VS Code settings (can define tasks that run)");
183
+ if (rel.startsWith(".github/workflows/") || rel.includes("/.github/workflows/")) reasons.push("a CI workflow");
184
+ if (dirs.includes(".devcontainer") && base.endsWith(".json")) reasons.push("a dev container config (runs commands when the container is built)");
185
+ if (base === "conftest.py") reasons.push("conftest.py (pytest runs it)");
186
+ if (base === "setup.py") reasons.push("setup.py (runs on install)");
187
+ if (base === "pyproject.toml") reasons.push("pyproject.toml (build backends and scripts run from it)");
188
+ if (dirs.includes(".cargo") && base.startsWith("config")) reasons.push("a Cargo config (can set the compiler or a runner)");
189
+ if (base === "build.rs") reasons.push("build.rs (cargo runs it)");
190
+ if (base === "justfile" || base === ".justfile") reasons.push("a justfile (just runs it)");
191
+ if (base === "rakefile" || base === "rakefile.rb") reasons.push("a Rakefile (rake runs it)");
192
+ if (dirs.includes(".husky")) reasons.push("a husky hook (runs on git actions)");
193
+ if (RC_FILES.has(base)) reasons.push("a shell startup file (runs in every new shell)");
194
+ else if (base.startsWith(".")) reasons.push("a dotfile");
195
+ try {
196
+ const st = statSync(abs);
197
+ if (st.isFile() && (st.mode & 0o111) !== 0) reasons.push("an executable file");
198
+ } catch {
199
+ // The file does not exist yet: nothing to check.
200
+ }
201
+ return reasons;
202
+ }
203
+
204
+ export function riskNote(root , abs ) {
205
+ const reasons = riskReasons(root, abs);
206
+ return reasons.length ? `\n WARNING: this is ${reasons.join(", ")}; an approved write can run code later.` : "";
207
+ }
208
+
209
+ const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*}](?:[^()\\]|\\.)*\)[+*{]/;
210
+
211
+ // Heuristic ReDoS guard: refuse a quantified group that itself contains a quantifier, e.g. (a+)+.
212
+ // It does not catch every pathological pattern (see SPEC); lines are also cut to 2000 characters.
213
+ export function hasNestedQuantifier(pattern ) {
214
+ return NESTED_QUANTIFIER.test(pattern);
215
+ }
216
+
217
+ // A file that holds secrets (see isSensitivePath) is never opened, written or edited by a tool:
218
+ // not with --yes, not with a yes to a question. The answer names no content, so it is not an
219
+ // oracle either. The person can open the file themselves; the model never gets its text.
220
+ export const SECRET_REFUSAL = "error: refused: this file may hold secrets (keys, tokens or credentials), so Synqa never reads, writes or edits it. Open it yourself if you need to.";
221
+
222
+ const secretIn = (ctx ) => (p ) => isSensitivePath(p, undefined, ctx.home);
223
+
224
+ function sensitiveGate(ctx , p ) {
225
+ return secretIn(ctx)(p) ? SECRET_REFUSAL : undefined;
226
+ }
227
+
228
+ // grep and glob also skip source files named like a secret store (secrets.py): see isSecretStemSource.
229
+ const skipIn = (ctx ) => (p ) => secretIn(ctx)(p) || isSecretStemSource(p);
230
+
231
+ // secrets.py, credentials.js and the like are opened only after an explicit yes, --yes or not;
232
+ // with nobody to ask the answer is no.
233
+ async function stemGate(ctx , p , verb ) {
234
+ if (!isSecretStemSource(p)) return undefined;
235
+ const ok = await ctx.confirm(`${verb} ${visibleEscapes(p)}? Its name says it may hold secrets, and its text would be sent to the model.`);
236
+ return ok ? undefined : "denied by user";
237
+ }
238
+
239
+ async function toolRead(ctx , a ) {
240
+ const p = confine(ctx.cwd, str(a, "path"));
241
+ const refused = sensitiveGate(ctx, p) ?? (await stemGate(ctx, p, "Read"));
242
+ if (refused) return refused;
243
+ return truncate(readCapped(p));
244
+ }
245
+
246
+
247
+
248
+ // Shows the change (the question gets the diff as `detail`; with --yes the diff is only shown).
249
+ async function approve(ctx , question , path , before , after ) {
250
+ const rel = relative(ctx.cwd, path) || path;
251
+ // A file that can run code later (hook, package.json, shell startup file, dotfile) is shown in
252
+ // full: a payload on line 60 must not hide behind a cut.
253
+ // A write to a secrets-named source file (secrets.py) asks too, like reading it does.
254
+ const risky = riskReasons(ctx.cwd, path).length > 0 || isSecretStemSource(path);
255
+ const diff = (maxLines ) => before === "too-large"
256
+ ? " (the existing file is too large to preview)"
257
+ : renderDiff(unifiedDiff(rel, before === null ? null : before.toString("utf8"), after), ctx.style ?? { color: ctx.color ?? false, depth: 8 }, maxLines);
258
+ // --yes skips the question only for an ordinary file with no AGENTS.md loaded. A write that can
259
+ // run code later, a secrets file, or any write while repository text steers the model always asks
260
+ // (and is denied when nobody can answer): a shell prompt is no boundary if another tool can plant
261
+ // a hook, a Makefile or a workflow that runs without one.
262
+ if (ctx.yes && !risky && !ctx.notesLoaded) {
263
+ // Already approved by --yes: the shown diff may be cut for length.
264
+ ctx.show?.(diff(MAX_DIFF_LINES));
265
+ return true;
266
+ }
267
+ // A question always shows the whole change: nothing can hide below a cut while you decide.
268
+ return ctx.confirm(question, diff(Infinity));
269
+ }
270
+
271
+ function journalWrite(ctx , written , before , after ) {
272
+ if (before === "too-large") return " (not undoable: the old file was too large to keep)";
273
+ ctx.journal?.record({ path: written, before, after: Buffer.from(after) });
274
+ return "";
275
+ }
276
+
277
+ async function toolWrite(ctx , a ) {
278
+ const p = confine(ctx.cwd, str(a, "path"));
279
+ const content = str(a, "content");
280
+ const refused = sensitiveGate(ctx, p);
281
+ if (refused) return refused;
282
+ const before = snapshot(p);
283
+ const q = `Write ${content.length} chars to ${visibleEscapes(p)}?${riskNote(ctx.cwd, p)}`;
284
+ if (!(await approve(ctx, q, p, before, content))) return "denied by user";
285
+ mkdirSync(dirname(confine(ctx.cwd, p)), { recursive: true });
286
+ const written = writeConfined(ctx.cwd, p, content);
287
+ return `wrote ${relative(ctx.cwd, written)}${journalWrite(ctx, written, before, content)}`;
288
+ }
289
+
290
+ async function toolEdit(ctx , a ) {
291
+ const p = confine(ctx.cwd, str(a, "path"));
292
+ const oldS = str(a, "old_string");
293
+ const newS = str(a, "new_string");
294
+ const refused = sensitiveGate(ctx, p) ?? (await stemGate(ctx, p, "Edit"));
295
+ if (refused) return refused;
296
+ const text = readCapped(p);
297
+ if (text.endsWith(`[file truncated at ${MAX_FILE_BYTES} bytes]`)) return "error: file too large to edit";
298
+ if (oldS === "") return "error: old_string is empty";
299
+ const first = text.indexOf(oldS);
300
+ if (first < 0) return "error: old_string not found";
301
+ if (text.indexOf(oldS, first + oldS.length) >= 0) return "error: old_string matches more than once; add context";
302
+ const next = text.slice(0, first) + newS + text.slice(first + oldS.length);
303
+ const before = snapshot(p);
304
+ const q = `Edit ${visibleEscapes(p)}?${riskNote(ctx.cwd, p)}`;
305
+ if (!(await approve(ctx, q, p, before, next))) return "denied by user";
306
+ const written = writeConfined(ctx.cwd, p, next);
307
+ return `edited ${relative(ctx.cwd, p)}${journalWrite(ctx, written, before, next)}`;
308
+ }
309
+
310
+ function toolGlob(ctx , a ) {
311
+ const re = globToRegExp(str(a, "pattern"));
312
+ const all = [];
313
+ walk(ctx.cwd, ctx.cwd, all, secretIn(ctx));
314
+ const hits = all.filter((f) => re.test(f) && !skipIn(ctx)(join(ctx.cwd, f))).sort().slice(0, MAX_RESULTS);
315
+ return hits.length ? hits.join("\n") : "no matches";
316
+ }
317
+
318
+ // The matching loop runs in a worker thread, so a pattern that backtracks badly ((a|a)*$ and the
319
+ // like, which no static check catches) is stopped after GREP_BUDGET_MS instead of hanging the session.
320
+ const GREP_BUDGET_MS = 10_000;
321
+ const GREP_WORKER = `
322
+ const { parentPort, workerData } = require("node:worker_threads");
323
+ const re = new RegExp(workerData.pattern);
324
+ const out = [];
325
+ for (const f of workerData.files) {
326
+ if (out.length >= workerData.max) break;
327
+ f.text.split("\\n").forEach((line, i) => {
328
+ if (out.length < workerData.max && re.test(line.slice(0, 2000))) out.push(f.rel + ":" + (i + 1) + ":" + line.slice(0, 300));
329
+ });
330
+ }
331
+ parentPort.postMessage(out);
332
+ `;
333
+
334
+ function grepInWorker(pattern , files , budgetMs ) {
335
+ return new Promise((resolve, reject) => {
336
+ const w = new Worker(GREP_WORKER, { eval: true, workerData: { pattern, files, max: MAX_RESULTS } });
337
+ const timer = setTimeout(() => { void w.terminate(); resolve("timeout"); }, budgetMs);
338
+ w.once("message", (m ) => { clearTimeout(timer); void w.terminate(); resolve(m); });
339
+ w.once("error", (e) => { clearTimeout(timer); reject(e); });
340
+ });
341
+ }
342
+
343
+ async function toolGrep(ctx , a ) {
344
+ const pat = str(a, "pattern");
345
+ if (pat.length > MAX_PATTERN) return `error: pattern longer than ${MAX_PATTERN} characters`;
346
+ if (hasNestedQuantifier(pat)) return "error: pattern has a quantified group containing a quantifier (nested quantifier); rewrite it";
347
+ new RegExp(pat); // a syntax error is reported here, before any file is read
348
+ const base = confine(ctx.cwd, typeof a.path === "string" && a.path ? a.path : ".");
349
+ const paths = [];
350
+ if (statSync(base).isDirectory()) walk(base, base, paths, secretIn(ctx));
351
+ else paths.push("");
352
+ const files = [];
353
+ let bytes = 0;
354
+ for (const f of paths) {
355
+ const full = f ? join(base, f) : base;
356
+ if (skipIn(ctx)(full)) continue;
357
+ let text ;
358
+ try {
359
+ if (statSync(full).size > MAX_FILE_BYTES) continue;
360
+ text = readCapped(full);
361
+ } catch {
362
+ continue;
363
+ }
364
+ if (text.includes("\u0000")) continue;
365
+ files.push({ rel: relative(ctx.cwd, full), text });
366
+ bytes += text.length;
367
+ if (bytes > 64 * MAX_FILE_BYTES) break;
368
+ }
369
+ const out = await grepInWorker(pat, files, ctx.grepBudgetMs ?? GREP_BUDGET_MS);
370
+ if (out === "timeout") return `error: the pattern took longer than ${Math.round((ctx.grepBudgetMs ?? GREP_BUDGET_MS) / 1000)}s (it may backtrack heavily); use a simpler pattern`;
371
+ return out.length ? out.join("\n") : "no matches";
372
+ }
373
+
374
+ async function toolBash(ctx , a ) {
375
+ const command = str(a, "command");
376
+ // Every shell command asks, also under --yes: any file the model read (a README, a code comment)
377
+ // can carry an instruction such as "printenv | curl ...", so --yes covers file writes inside the
378
+ // workspace only. With nobody to ask the answer is no. A command that names a secret or dumps
379
+ // the environment gets a warning in the question.
380
+ const secret = commandTouchesSecrets(command);
381
+ if (!(await ctx.confirm(`Run: ${visibleEscapes(command)}${secret ? "\n WARNING: this command refers to files that may hold secrets." : ""}`))) return "denied by user";
382
+ const r = spawnSync("/bin/sh", ["-c", command], {
383
+ cwd: ctx.cwd, encoding: "utf8", timeout: 120_000, maxBuffer: 4 * 1024 * 1024, env: bashEnv(process.env),
384
+ });
385
+ const body = `${r.stdout ?? ""}${r.stderr ?? ""}`;
386
+ const code = r.status === null ? `signal ${r.signal ?? "timeout"}` : `exit ${r.status}`;
387
+ return truncate(`${body}\n[${code}]`);
388
+ }
389
+
390
+ function str(a , k ) {
391
+ const v = a[k];
392
+ if (typeof v !== "string") throw new Error(`missing string argument: ${k}`);
393
+ return v;
394
+ }
395
+
396
+ // Never throws: failures come back as text so the model can react.
397
+ export async function runTool(ctx0 , name , args ) {
398
+ // --yes never applies when the workspace is the home folder: too much there can run code later.
399
+ const ctx = ctx0.yes && isHomeDir(ctx0.cwd, ctx0.home) ? { ...ctx0, yes: false } : ctx0;
400
+ try {
401
+ switch (name) {
402
+ case "read_file": return await toolRead(ctx, args);
403
+ case "write_file": return await toolWrite(ctx, args);
404
+ case "edit_file": return await toolEdit(ctx, args);
405
+ case "glob": return toolGlob(ctx, args);
406
+ case "grep": return await toolGrep(ctx, args);
407
+ case "bash": return await toolBash(ctx, args);
408
+ default: return `error: unknown tool ${name}`;
409
+ }
410
+ } catch (err) {
411
+ return `error: ${err instanceof Error ? err.message : String(err)}`;
412
+ }
413
+ }