@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/README.md +83 -53
- package/dist/api.js +44 -3
- package/dist/commands.js +6 -13
- package/dist/main.js +146 -40
- package/dist/paths.js +150 -38
- package/dist/prompt.js +330 -0
- package/dist/statusbar.js +16 -9
- package/dist/theme.js +47 -7
- package/dist/tools.js +125 -44
- package/dist/ui.js +129 -69
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
119
|
-
console.error(`
|
|
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(
|
|
135
|
-
|
|
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 (!
|
|
249
|
+
if (!input.isTTY) return false;
|
|
147
250
|
spinner.stop();
|
|
148
|
-
if (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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
say(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
240
|
-
if (!
|
|
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 {
|
|
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
|
-
//
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 (
|
|
91
|
-
if (
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
return
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
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
|
}
|