@synqa/cli 1.1.0 → 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/main.js CHANGED
@@ -1,18 +1,106 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { runAgent } from "./agent.js";
3
- import { checkKey } from "./api.js";
3
+ import { ApiError, checkKey } from "./api.js";
4
4
  import { announceNotes, freshHistory, runCommand, statusParts } from "./commands.js";
5
5
 
6
6
  import { looksLikeKey, readConfig, resolveApiKey, writeConfig, configPath } from "./config.js";
7
7
  import { EditJournal } from "./journal.js";
8
- import { unsafeStartDir } from "./paths.js";
8
+ import { isHomeDir, isSensitivePath, unsafeStartDir } from "./paths.js";
9
+
10
+
11
+ import { join } from "node:path";
12
+
13
+ // A workspace inside a protected area (~/.dotfiles, ~/.config/nvim, ~/Library/...): every file is
14
+ // refused there, so say why once at start-up instead of leaving the user with refusals.
15
+ export function protectedWorkspaceWarning(cwd , home ) {
16
+ if (!isSensitivePath(join(cwd, "probe.txt"), undefined, home)) return undefined;
17
+ return "this folder is inside a protected area (a dot folder or Library in your home folder, or a credentials folder), where tools keep keys and tokens. The agent cannot read, write or search files here. Copy the project to a normal folder to work on it.";
18
+ }
19
+
20
+ // The start-up notes both the session banner and a one-shot run print.
21
+ export function startWarnings(env , cwd , home ) {
22
+ const out = [];
23
+ if (env.SYNQA_API_KEY?.trim()) out.push("using the key from SYNQA_API_KEY (not the one saved by synqa login)");
24
+ const inProtected = protectedWorkspaceWarning(cwd, home);
25
+ if (inProtected) out.push(inProtected);
26
+ return out;
27
+ }
28
+
29
+
30
+
31
+ // Keys from a terminal always go through a PromptFrame, framed or not, so pasted text can never
32
+ // answer a y/N question; the frame (rules, status, / menu) is drawn only when `framed`. Questions
33
+ // and the bracketed paste switch go to the terminal: stdout when it is one, else stderr, so
34
+ // `synqa "task" | tee log` still asks on the screen and still tells pasted text from typing.
35
+ export function openSessionInput(o
36
+
37
+
38
+ ) {
39
+ const tty = Boolean(o.stdin.isTTY);
40
+ const term = o.stdout.isTTY || !o.stderr.isTTY ? o.stdout : o.stderr;
41
+ const keys = tty ? new PromptFrame(o.stdin, term, o.ui, o.below, o.promptWidth, { paste: Boolean(term.isTTY) }) : undefined;
42
+ const rl = createInterface({ input: keys ? keys.input : (o.stdin ), output: term, terminal: tty });
43
+ keys?.start(rl);
44
+ return { rl, keys, term };
45
+ }
46
+
47
+
48
+
49
+ // Raw mode and bracketed paste are switched off on every way out: a normal exit, an uncaught
50
+ // exception and an unhandled rejection (then the error is printed and the exit code is 1).
51
+ export function installTerminalRestore(
52
+ restore , proc = process,
53
+ exit = (c) => process.exit(c), report = (s) => console.error(s),
54
+ ) {
55
+ proc.on("exit", restore);
56
+ const crash = (err ) => {
57
+ restore();
58
+ report(`\nerror: ${sanitize(err instanceof Error ? err.message : String(err))}`);
59
+ exit(1);
60
+ };
61
+ proc.on("uncaughtException", crash);
62
+ proc.on("unhandledRejection", crash);
63
+ }
64
+
65
+ // The session's terminal wiring, in one place so it is tested as main uses it: restore stops the
66
+ // spinner and gives the keys back, on every way out. In a one-shot run on a terminal, a Ctrl+C the
67
+ // key router saw (it closes readline) ends the process with 130, the shell's code for an
68
+ // interrupt. Readline closing for any other reason (end of piped input, /dev/null) is not an
69
+ // interrupt: without a terminal there is no key router and no handler at all.
70
+ export function wireSession(o
71
+
72
+
73
+ ) {
74
+ const exit = o.exit ?? ((c ) => process.exit(c));
75
+ const report = o.report ?? ((s ) => console.error(s));
76
+ const restore = () => { o.spinner.stop(); o.keys?.stop(); };
77
+ installTerminalRestore(restore, o.proc ?? process, exit, report);
78
+ let finished = false;
79
+ const keys = o.keys;
80
+ if (o.oneShot && keys) {
81
+ o.rl.on("close", () => {
82
+ if (finished || !keys.interrupted) return;
83
+ restore();
84
+ report("\ninterrupted");
85
+ exit(130);
86
+ });
87
+ }
88
+ return { restore, done: () => { finished = true; } };
89
+ }
90
+
91
+ // --yes in the home folder would let the agent write shell startup files, launch agents and the
92
+ // like without a question, so it is refused there (the tools also ignore it, as a second guard).
93
+ export function homeYesRefusal(cwd , yes , home ) {
94
+ return yes && isHomeDir(cwd, home) ? "--yes is not allowed in your home folder. Run without --yes, or cd into a project folder." : undefined;
95
+ }
9
96
  import { sanitize } from "./sanitize.js";
10
- import { formatStatus, StatusBar } from "./statusbar.js";
11
- import { DEFAULT_TIER, findEffort, findTier, routingLabel } from "./tiers.js";
97
+ import { formatStatus } from "./statusbar.js";
98
+ import { CONTEXT_TOKENS, DEFAULT_TIER, findEffort, findTier, routingLabel } from "./tiers.js";
12
99
  import { accent, dim, stone } from "./theme.js";
13
100
 
14
101
  import { fetchAccount } from "./account.js";
15
- import { balanceFrom, formatFooter, makeUi, readVersion, renderBanner, Spinner, styleOf, TurnRenderer } from "./ui.js";
102
+ import { balanceFrom, formatFooter, makeUi, promptText, readVersion, renderBanner, renderStatusLines, Spinner, styleOf, textWidth, TurnRenderer } from "./ui.js";
103
+ import { PromptFrame } from "./prompt.js";
16
104
 
17
105
 
18
106
  const VERSION = readVersion();
@@ -25,12 +113,16 @@ const HELP = `Usage:
25
113
  synqa "do something" one-shot task
26
114
  synqa login save your API key
27
115
  Options:
28
- -y, --yes do not ask before write, edit and bash
116
+ -y, --yes do not ask before ordinary file writes and edits
117
+ (shell commands always ask; not allowed in your home folder)
29
118
  -m, --model <tier> blink | sonata | aeon (default blink)
30
119
  -e, --effort <level> default | low | medium (default default)
31
- --allow-home allow starting in your home folder or the filesystem root
32
120
  -h, --help -v, --version
33
- In a session: /model, /effort, /status, /init, /compact, /undo, /clear, /help, /exit`;
121
+ In a session, type / for the command menu (/model, /effort, /status, /init, /compact, /undo,
122
+ /clear, /help, /exit).`;
123
+
124
+ // Said when SYNQA_API_KEY is set: it wins over the key that synqa login saved.
125
+ export const ENV_KEY_NOTE = "SYNQA_API_KEY is set in this shell, so it is used instead of the key saved by synqa login. Remove it (unset SYNQA_API_KEY, and delete the line from ~/.zshrc or ~/.bash_profile) to use the saved key.";
34
126
 
35
127
  // Masked single-line input: nothing typed is echoed.
36
128
  export function readSecret(promptText ) {
@@ -88,6 +180,8 @@ export async function login() {
88
180
  }
89
181
  const file = writeConfig({ ...readConfig(), apiKey: key });
90
182
  console.log(`Key verified and saved to ${file}`);
183
+ const fromEnv = process.env.SYNQA_API_KEY?.trim();
184
+ if (fromEnv && fromEnv !== key) console.error(`\nWarning: ${ENV_KEY_NOTE}`);
91
185
  return 0;
92
186
  }
93
187
 
@@ -98,7 +192,7 @@ export function parseArgs(argv ) {
98
192
  for (let i = 0; i < argv.length; i++) {
99
193
  const t = argv[i] ;
100
194
  if (t === "-y" || t === "--yes") a.yes = true;
101
- else if (t === "--allow-home") a.allowHome = true;
195
+ else if (t === "--allow-home") a.allowHome = true; // accepted and ignored since 1.1.1
102
196
  else if (t === "-h" || t === "--help") a.help = true;
103
197
  else if (t === "-v" || t === "--version") a.version = true;
104
198
  else if (t === "-m" || t === "--model") a.model = argv[++i] ?? "";
@@ -108,17 +202,20 @@ export function parseArgs(argv ) {
108
202
  return a;
109
203
  }
110
204
 
111
- export async function main(argv ) {
205
+ // `input` is the key source; tests pass a stream that ends at once, like `< /dev/null`.
206
+ export async function main(argv , input = process.stdin) {
112
207
  const args = parseArgs(argv);
113
208
  if (args.help) { console.log(HELP); return 0; }
114
209
  if (args.version) { console.log(VERSION); return 0; }
115
210
  if (args.rest[0] === "login") return login();
116
211
 
117
212
  const unsafe = unsafeStartDir(process.cwd());
118
- if (unsafe && !args.allowHome) {
119
- console.error(`Refusing to start in ${unsafe}: the agent could read your keys and settings there. cd into a project folder, or pass --allow-home if you really mean it.`);
213
+ if (unsafe) {
214
+ console.error(`Synqa does not start in ${unsafe} (the whole disk would be its workspace). cd into a project folder first.`);
120
215
  return 1;
121
216
  }
217
+ const yesRefused = homeYesRefusal(process.cwd(), args.yes);
218
+ if (yesRefused) { console.error(yesRefused); return 1; }
122
219
  const apiKey = resolveApiKey();
123
220
  if (!apiKey) {
124
221
  console.error(`No API key. Run "synqa login" or set SYNQA_API_KEY. (config: ${configPath()})`);
@@ -131,8 +228,14 @@ export async function main(argv ) {
131
228
  const effort = args.effort ? findEffort(args.effort) : "default";
132
229
  if (!effort) { console.error(`Unknown effort "${args.effort}". Use default, low or medium.`); return 1; }
133
230
 
134
- const interactive = Boolean(process.stdin.isTTY) && args.rest.length === 0;
135
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
231
+ const interactive = Boolean(input.isTTY) && args.rest.length === 0;
232
+ // On a terminal the prompt frame reads the keys (for the / menu) and hands them to readline.
233
+ const framed = interactive && ui.tty && ui.unicode;
234
+ let statusLines = () => [];
235
+ const { rl, keys, term } = openSessionInput({
236
+ stdin: input, stdout: process.stdout, stderr: process.stderr, ui, framed, below: () => statusLines(), promptWidth: textWidth(ui.unicode ? "❯ " : "> "),
237
+ });
238
+ const frame = framed ? keys : undefined;
136
239
  const say = (s ) => console.log(s);
137
240
  // Chrome (spinner, cards, footer) is for interactive terminals only. Pipes and one-shot runs get
138
241
  // the plain text output they had before.
@@ -143,9 +246,9 @@ export async function main(argv ) {
143
246
  // `detail` is the coloured diff the tools built; the question is sanitized, the diff is not
144
247
  // (the tools already escaped every file character in it).
145
248
  const confirm = async (q , detail ) => {
146
- if (!process.stdin.isTTY) return false;
249
+ if (!input.isTTY) return false;
147
250
  spinner.stop();
148
- if (detail) say(inCard(detail));
251
+ if (detail) term.write(inCard(detail) + "\n"); // with the question, on the terminal
149
252
  const ans = await rl.question(`${accent(style, "?")} ${sanitize(q)} [y/N] `);
150
253
  return /^y(es)?$/i.test(ans.trim());
151
254
  };
@@ -158,21 +261,17 @@ export async function main(argv ) {
158
261
  st.history = freshHistory(st);
159
262
  const ctx = { cwd, confirm, yes: args.yes, color: style.color, journal: st.journal, show: (t ) => say(inCard(t)), get notesLoaded() { return st.notesChars > 0; } };
160
263
 
161
- // The pinned bar needs a terminal on both ends; otherwise the status is an ordinary line.
162
- const bar = new StatusBar(
163
- process.stdout,
164
- interactive && Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb",
165
- (s) => dim(style, s),
166
- );
167
- const refresh = () => {
168
- if (bar.enabled) bar.update(formatStatus(statusParts(st), (process.stdout.columns ?? 80) - 1));
169
- };
170
- const restore = () => { spinner.stop(); bar.uninstall(); };
171
- process.on("exit", restore);
172
- process.stdout.on("resize", () => { bar.resize(); refresh(); });
173
-
174
- // Known balance for the footer; refreshed in the background after each turn, never awaited.
264
+ // Known balance for the footer and status line; refreshed in the background after each turn.
175
265
  let balance = { kind: "unavailable" };
266
+ // The status line under the input: only known values (tier, workspace, context, tokens, credit).
267
+ statusLines = () => renderStatusLines({
268
+ tier: st.tier, effort: st.effort, cwd, tokens: st.total.prompt + st.total.completion,
269
+ contextTokens: st.last.prompt || undefined, contextMax: CONTEXT_TOKENS, routing: st.routing, balance,
270
+ }, ui);
271
+ const refresh = () => { frame?.redraw(); };
272
+ // Raw mode and bracketed paste are always switched off again, also after a crash.
273
+ const { restore, done } = wireSession({ spinner, keys, rl, oneShot: !interactive });
274
+ process.stdout.on("resize", () => refresh());
176
275
  const loadBalance = async () => balanceFrom(await fetchAccount(apiKey, undefined, BALANCE_TIMEOUT_MS));
177
276
  const refreshBalance = () => { void loadBalance().then((b) => { balance = b; }, () => {}); };
178
277
 
@@ -213,7 +312,9 @@ export async function main(argv ) {
213
312
  }
214
313
  } catch (err) {
215
314
  spinner.stop();
216
- console.error(`\nerror: ${sanitize(err instanceof Error ? err.message : String(err))}`);
315
+ const msg = sanitize(err instanceof Error ? err.message : String(err));
316
+ const envNote = err instanceof ApiError && err.status === 401 && process.env.SYNQA_API_KEY?.trim() ? `\n${ENV_KEY_NOTE}` : "";
317
+ console.error(`\n${chrome ? accent(style, "error") : "error"}: ${msg}${envNote}`);
217
318
  }
218
319
  refresh();
219
320
  };
@@ -223,6 +324,7 @@ export async function main(argv ) {
223
324
  const prompt = args.rest.join(" ");
224
325
  if (!prompt) { console.error("Nothing to do. Pass a task or run in a terminal."); return 1; }
225
326
  announceNotes(st, say);
327
+ for (const w of startWarnings(process.env, cwd)) console.error(`Warning: ${w}`);
226
328
  await turn(prompt);
227
329
  say(dim(style, formatStatus(statusParts(st))));
228
330
  return 0;
@@ -230,24 +332,28 @@ export async function main(argv ) {
230
332
 
231
333
  // The balance may take a moment; the banner waits at most BALANCE_TIMEOUT_MS for it.
232
334
  balance = await loadBalance();
233
- for (const line of renderBanner({ version: VERSION, tier: st.tier, cwd, notesChars: st.notesChars, balance }, ui)) say(line);
234
- say(dim(style, "Type /help for commands."));
235
- announceNotes(st, say);
236
- bar.install();
237
- refresh();
335
+ const warnings = startWarnings(process.env, cwd);
336
+ say("");
337
+ for (const line of renderBanner({ version: VERSION, tier: st.tier, effort: st.effort, cwd, notesChars: st.notesChars, balance, warnings }, ui)) say(line);
338
+ say("");
339
+ if (!frame) say(dim(style, "Type /help for commands."));
340
+ announceNotes(st, (t) => say(dim(style, t)));
238
341
  for (;;) {
239
- // Without a pinned bar the status is printed above each prompt instead.
240
- if (!bar.enabled) say(dim(style, formatStatus(statusParts(st))));
342
+ // Without the frame the status is printed above each prompt instead.
343
+ if (!frame) say(dim(style, formatStatus(statusParts(st))));
241
344
  let line ;
242
- try { line = (await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim(); } catch { break; }
345
+ try {
346
+ line = (frame ? await frame.ask(promptText(ui)) : keys ? await keys.askPlain(`${accent(style, ui.unicode ? "❯" : ">")} `) : await rl.question(`${accent(style, ui.unicode ? "❯" : ">")} `)).trim();
347
+ } catch { break; }
243
348
  if (!line) continue;
244
- const r = await runCommand(line, st, { print: say, confirm, style });
349
+ const r = await runCommand(line, st, { print: say, confirm, style, ui });
245
350
  refresh();
246
351
  if (r.exit) break;
247
352
  if (!r.handled) await turn(line);
248
353
  }
249
354
  return 0;
250
355
  } finally {
356
+ done(); // a normal end: closing readline below is not an interrupt
251
357
  restore();
252
358
  rl.close();
253
359
  }
package/dist/paths.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { closeSync, constants, lstatSync, openSync, realpathSync, writeSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
2
3
  import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
4
  import { configDir } from "./config.js";
5
5
 
6
6
  export class ConfineError extends Error {}
@@ -62,47 +62,164 @@ export function fold(s ) {
62
62
  return s.normalize("NFKC").toLowerCase();
63
63
  }
64
64
 
65
- // Folders whose contents are credentials.
66
- const SENSITIVE_DIRS = new Set([".ssh", ".aws", ".gnupg"]);
65
+ // The protected tables. isSensitivePath (file tools) and commandTouchesSecrets (shell) are both
66
+ // built from these, so a name added here is protected everywhere at once.
67
+ // Folders whose contents are credentials, matched as any one path segment.
68
+ export const SENSITIVE_DIRS = [".ssh", ".aws", ".gnupg", ".kube", ".password-store", ".mozilla", ".azure", "keychains"];
69
+ // Runs of path segments that hold credentials, browser profiles or tokens, matched anywhere in the
70
+ // path (folded). Inside the home folder every dot entry and Library is protected anyway (see
71
+ // homeHidden); these runs also protect copies of such folders elsewhere.
72
+ export const SENSITIVE_RUNS = [
73
+ [".config", "synqa"], [".config", "gh"], [".config", "hub"], [".config", "gcloud"], [".config", "op"], [".config", "heroku"],
74
+ [".config", "google-chrome"], [".config", "chromium"], [".config", "bravesoftware"], [".config", "microsoft-edge"],
75
+ [".docker", "config.json"], [".local", "share", "keyrings"], [".cargo", "credentials.toml"], [".gem", "credentials"],
76
+ [".terraform.d", "credentials.tfrc.json"], [".m2", "settings.xml"], [".oci", "config"],
77
+ ["library", "cookies"], ["library", "safari"], ["library", "mail"], ["library", "messages"],
78
+ ["library", "application support", "google", "chrome"], ["library", "application support", "chromium"],
79
+ ["library", "application support", "bravesoftware"], ["library", "application support", "microsoft edge"],
80
+ ["library", "application support", "arc"], ["library", "application support", "firefox"],
81
+ ["library", "application support", "1password"], ["library", "group containers"],
82
+ ];
67
83
  // Exact file names that hold secrets (compared folded).
68
- const SENSITIVE_NAMES = new Set([
84
+ export const SENSITIVE_NAMES = [
69
85
  ".env", ".envrc", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".dev.vars", "keys.json", "credentials.json", ".yarnrc.yml", ".pgpass", ".my.cnf",
70
- ]);
86
+ "_netrc", ".boto", ".s3cfg", ".vault-token", ".terraformrc", "login data", "cookies", "cookies.binarycookies", "key4.db", "logins.json",
87
+ ".zsh_history", ".bash_history", ".python_history", ".node_repl_history", ".psql_history", ".mysql_history", ".lesshst",
88
+ ".yarnrc",
89
+ ];
90
+ // File extensions of private keys, certificates with keys, key stores and infrastructure state.
91
+ export const SENSITIVE_EXTENSIONS = [
92
+ "pem", "key", "p12", "p8", "pfx", "jks", "keystore", "ppk", "tfvars", "tfstate", "keychain", "keychain-db", "kdbx",
93
+ ];
94
+ // Name patterns, each with the looser form a shell command is checked with. One table for both.
95
+ // `word` rules guess from a word in the name, so they skip source code (src/auth/credentials.ts is
96
+ // code about credentials, not credentials).
97
+ export const SENSITIVE_PATTERNS = [
98
+ { name: /^\.env\.|\.env$/, shell: /\.env/ }, // .env.local, prod.env, config.env
99
+ { name: /\.tfstate(\.|$)/, shell: /\.tfstate/ }, // terraform.tfstate, terraform.tfstate.backup
100
+ { name: /service[-_]?account.*\.json$/, shell: /service[-_]?account/ },
101
+ // credential(s) or secret(s) as a word ("client_secret.json", "aws-secrets.yaml"; not "secretary.ts")
102
+ { name: /(^|[^a-z])(credential|secret)s?([^a-z]|$)/, shell: /credential|secret/, word: true },
103
+ // shell and REPL histories (.zsh_history, .python_history, history): the name ends in "history"
104
+ { name: /history$/, shell: /history/ }, // only a name that ends in "history": history.md and chat_history.ts never match
105
+ { name: /^id_[a-z0-9_]+$/, shell: /\bid_[a-z0-9]/ }, // id_rsa, id_ed25519; id_rsa.pub is not matched
106
+ ];
107
+ // Source files: a word rule never applies to these (named secret files still do).
108
+ export const SOURCE_EXTENSIONS = [
109
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "rb", "java", "kt", "swift", "c", "cc", "cpp", "h", "cs", "php", "vue", "svelte",
110
+ ];
111
+ const SOURCE_RE = new RegExp(`\\.(${SOURCE_EXTENSIONS.join("|")})$`);
112
+ // Source files whose whole stem says secret (secrets.py, credentials.js, client_secret.ts) often
113
+ // hold real keys: the word rules let them through, so read_file asks first and grep and glob skip
114
+ // them. A longer name (secret_manager.go, credentials_form.ts) is ordinary code.
115
+ export const SECRET_STEMS = ["secret", "secrets", "credential", "credentials", "client_secret", "client_secrets"];
116
+
117
+ export function isSecretStemSource(abs ) {
118
+ const name = fold(abs.split(sep).join("/")).split("/").pop() ?? "";
119
+ if (!SOURCE_RE.test(name)) return false;
120
+ return SECRET_STEMS.includes(name.slice(0, name.lastIndexOf(".")));
121
+ }
122
+ // First segments of SENSITIVE_RUNS that are credential folders in their own right, so a shell
123
+ // command that names the folder (ls .docker, cat ~/.docker/*) asks too. .config and .local hold
124
+ // much else and are covered by the home rule.
125
+ const RUN_FOLDERS = [...new Set(SENSITIVE_RUNS.map((r) => r[0] ))].filter((f) => f.startsWith(".") && f !== ".config" && f !== ".local");
126
+ const NAME_SET = new Set(SENSITIVE_NAMES);
127
+ const DIR_SET = new Set(SENSITIVE_DIRS);
71
128
  const EXAMPLE_SUFFIX = /\.(example|sample|template|dist)$/;
129
+ const EXT_RE = new RegExp(`\\.(${SENSITIVE_EXTENSIONS.map((e) => e.replace(/[.-]/g, "\\$&")).join("|")})$`);
72
130
 
73
- // Files and folders that hold secrets: .env files, private keys, cloud, package manager and
74
- // container credentials, and this CLI's own config (which holds the API key). Names only, no file
75
- // is opened. Every segment is folded first (see fold).
76
- export function isSensitivePath(abs , cfg = configDir()) {
77
- const norm = fold(abs.split(sep).join("/"));
78
- const parts = norm.split("/").filter(Boolean);
79
- if (parts.some((d) => SENSITIVE_DIRS.has(d))) return true;
80
- for (let i = 0; i + 1 < parts.length; i++) {
81
- if (parts[i] === ".config" && parts[i + 1] === "synqa") return true;
82
- if (parts[i] === ".kube" && parts[i + 1] === "config") return true;
83
- if (parts[i] === ".docker" && parts[i + 1] === "config.json") return true;
131
+ function hasRun(parts , run ) {
132
+ for (let i = 0; i + run.length <= parts.length; i++) {
133
+ if (run.every((seg, j) => parts[i + j] === seg)) return true;
84
134
  }
85
- const c = fold(cfg.split(sep).join("/")).replace(/\/+$/, "");
135
+ return false;
136
+ }
137
+
138
+ const slashed = (p ) => fold(p.split(sep).join("/")).replace(/\/+$/, "");
139
+
140
+ // The home folder, as given and with symlinks resolved, folded.
141
+ function homeForms(home ) {
142
+ const out = [slashed(home)];
143
+ try {
144
+ out.push(slashed(realpathSync(home)));
145
+ } catch {
146
+ // a home that does not exist is compared as given
147
+ }
148
+ return out.filter((h) => h !== "" && h !== "/");
149
+ }
150
+
151
+ // Inside the home folder, everything under a dot entry at its top (~/.zsh_history, ~/.config/**,
152
+ // ~/.cargo/**, any tool's credential store, known or not) and everything under
153
+ // ~/Library is protected. This is the structural rule: it does not depend on naming each store.
154
+ // A project's own dot files (~/proj/.github, ~/proj/.eslintrc) are below a normal folder and stay usable.
155
+ export function homeHidden(abs , home = homedir()) {
156
+ const norm = slashed(abs);
157
+ for (const h of homeForms(home)) {
158
+ if (!norm.startsWith(h + "/")) continue;
159
+ const first = norm.slice(h.length + 1).split("/")[0] ?? "";
160
+ if (first.startsWith(".") || first === "library") return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ // True when `cwd` is the home folder itself.
166
+ export function isHomeDir(cwd , home = homedir()) {
167
+ let real = slashed(cwd);
168
+ try {
169
+ real = slashed(realpathSync(cwd));
170
+ } catch {
171
+ // compared as given
172
+ }
173
+ return homeForms(home).includes(real) || homeForms(home).includes(slashed(cwd));
174
+ }
175
+
176
+ // Files and folders that hold secrets: the home folder's dot entries and Library (homeHidden),
177
+ // env files, private keys, cloud, package manager and container credentials, keychains, browser
178
+ // profiles, histories, and this CLI's own config (which holds the API key). Names only, no file is
179
+ // opened. Every segment is folded first (see fold). read_file, write_file and edit_file refuse
180
+ // these outright; grep and glob skip them.
181
+ export function isSensitivePath(abs , cfg = configDir(), home = homedir()) {
182
+ if (homeHidden(abs, home)) return true;
183
+ const norm = slashed(abs);
184
+ const parts = norm.split("/").filter(Boolean);
185
+ if (parts.some((d) => DIR_SET.has(d))) return true;
186
+ if (SENSITIVE_RUNS.some((run) => hasRun(parts, run))) return true;
187
+ const c = slashed(cfg);
86
188
  if (c && (norm === c || norm.startsWith(c + "/"))) return true;
87
189
  const name = parts[parts.length - 1] ?? "";
88
190
  // Remotes can hold tokens: .git/config, .git/config.worktree, .git/worktrees/<name>/config, at any depth.
89
191
  if (parts.slice(0, -1).includes(".git") && (name === "config" || name.startsWith("config."))) return true;
90
- if (SENSITIVE_NAMES.has(name)) return true;
91
- if (name.startsWith(".env.") && !EXAMPLE_SUFFIX.test(name)) return true;
92
- if (/\.(pem|key|p12|pfx|tfvars|tfstate)$/.test(name)) return true;
93
- if (/^secrets\./.test(name) && !EXAMPLE_SUFFIX.test(name)) return true;
94
- return /^id_[a-z0-9_]+$/.test(name); // id_rsa, id_ed25519; id_rsa.pub is not matched
192
+ if (NAME_SET.has(name)) return true;
193
+ if (EXAMPLE_SUFFIX.test(name)) return false;
194
+ if (EXT_RE.test(name)) return true;
195
+ const source = SOURCE_RE.test(name);
196
+ return SENSITIVE_PATTERNS.some((x) => x.name.test(name) && !(x.word && source));
95
197
  }
96
198
 
97
- // Names a shell command can use to reach a secret, including globs that expand to them. Heuristic:
98
- // it makes an unattended (--yes) command ask when it looks at secrets, it does not make bash safe.
99
- const SECRET_WORDS = [
100
- /\.env/, /\.ssh/, /\.aws/, /\.gnupg/, /\.kube/, /\.docker/, /\.netrc/, /\.npmrc/, /\.pypirc/, /\.git-credentials/,
101
- /\.dev\.vars/, /keys\.json/, /credentials/, /\.tfvars/, /\bsecrets?\b/, /\.(pem|key|p12|pfx)\b/, /\bid_[a-z0-9]/,
102
- /\.config\/synqa/, /synqa\/config/, /(^|[\s/])\.[\w.-]*[*?[]/, /~\/?\./,
199
+ const escRe = (s ) => s.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
200
+
201
+ // Names a shell command can use to reach a secret, including globs that expand to them, built from
202
+ // the same tables as isSensitivePath. Heuristic: it makes an unattended (--yes) command ask when it
203
+ // looks at secrets, it does not make bash safe.
204
+ const SECRET_WORDS = [
205
+ ...SENSITIVE_DIRS.map((d) => new RegExp(escRe(d))),
206
+ ...SENSITIVE_RUNS.map((r) => new RegExp(r.map(escRe).join("/"))),
207
+ ...SENSITIVE_NAMES.map((n) => new RegExp(escRe(n))),
208
+ ...RUN_FOLDERS.map((f) => new RegExp(`${escRe(f)}\\b`)),
209
+ new RegExp(`\\.(${SENSITIVE_EXTENSIONS.map(escRe).join("|")})\\b`),
210
+ ...SENSITIVE_PATTERNS.map((x) => x.shell),
211
+ /\.git\/config/,
212
+ // Dumping the environment (where tokens live): printenv, env or export -p or set with no arguments.
213
+ /\bprintenv\b/, /(^|[;&|(]\s*)env\s*($|[;&|)>])/, /\bexport\s+-p\b/, /(^|[;&|(]\s*)(set|declare\s+-x)\s*($|[;&|)>])/,
214
+ /\.config\/synqa/, /synqa\/config/,
215
+ /(^|[\s/])\.[\w.-]*[*?[]/, // a dot glob such as .e* or .*
216
+ /~\/?\./, /\$\{?home\}?\/?\./, /~\/library/, /\$\{?home\}?\/library/, // the home folder's dot entries and Library
217
+ /\bsecurity\s+(find|dump|export)-/, /\.binarycookies/,
103
218
  ];
104
219
 
105
220
  export function commandTouchesSecrets(command , cfg = configDir()) {
221
+ // Escapes that can spell any name ($'\x2eenv', \056, printf '\x2e'): always ask.
222
+ if (/\$'|\\x[0-9a-f]{2}|\\[0-7]{3}|\\u[0-9a-f]{4}/i.test(command)) return true;
106
223
  // Quotes and backslashes are dropped first so .e""nv and .\env read as .env.
107
224
  const c = fold(command).replace(/["'\\`]/g, "");
108
225
  if (SECRET_WORDS.some((re) => re.test(c))) return true;
@@ -110,22 +227,17 @@ export function commandTouchesSecrets(command , cfg = configDir()
110
227
  return dir !== "" && c.includes(dir);
111
228
  }
112
229
 
113
- // A start folder this CLI refuses without --allow-home: the home folder or the filesystem root.
114
- // Returns the reason, or undefined when the folder is fine.
115
- export function unsafeStartDir(cwd , home = homedir()) {
230
+ // The one start folder this CLI refuses: the filesystem root, where the working directory would be
231
+ // the whole disk. Anywhere else, the home folder included, it starts like any other coding CLI;
232
+ // secrets there are protected by isSensitivePath, not by refusing to start. Returns the reason,
233
+ // or undefined when the folder is fine.
234
+ export function unsafeStartDir(cwd ) {
116
235
  let real ;
117
- let realHome ;
118
236
  try {
119
237
  real = realpathSync(cwd);
120
238
  } catch {
121
239
  return undefined;
122
240
  }
123
- try {
124
- realHome = realpathSync(home);
125
- } catch {
126
- realHome = home;
127
- }
128
241
  if (real === parse(real).root) return "the filesystem root";
129
- if (real === realHome) return "your home folder";
130
242
  return undefined;
131
243
  }