dante-watch 0.1.2 → 0.1.3

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.
Files changed (63) hide show
  1. package/README.md +56 -23
  2. package/package.json +1 -1
  3. package/src/bib/answers.mjs +2 -2
  4. package/src/bib/bib.json +1 -1
  5. package/src/bib/ear.mjs +19 -18
  6. package/src/bib/net.json +1 -1
  7. package/src/bib/net.mjs +7 -9
  8. package/src/bib/probe.mjs +7 -8
  9. package/src/bib/pruef.json +1 -0
  10. package/src/bib/router.mjs +16 -19
  11. package/src/bib/system.mjs +14 -15
  12. package/src/bib/wash.mjs +5 -13
  13. package/src/checks/battery.mjs +15 -10
  14. package/src/checks/common.mjs +5 -5
  15. package/src/checks/demo.mjs +2 -2
  16. package/src/checks/formats.mjs +221 -0
  17. package/src/checks/hf.mjs +252 -0
  18. package/src/checks/loc.mjs +2 -4
  19. package/src/checks/php2shell.mjs +8 -9
  20. package/src/checks/rechte.mjs +2 -3
  21. package/src/checks/secrets.mjs +2 -4
  22. package/src/checks/sshd.mjs +3 -5
  23. package/src/checks/webshell.mjs +3 -5
  24. package/src/cli.mjs +15 -10
  25. package/src/core/check.mjs +4 -8
  26. package/src/core/consent.mjs +7 -7
  27. package/src/core/render.mjs +2 -2
  28. package/src/core/sh.mjs +12 -7
  29. package/src/i18n-checks.mjs +2 -3
  30. package/src/i18n.mjs +54 -14
  31. package/src/mascot.mjs +3 -5
  32. package/src/me/deep.mjs +37 -0
  33. package/src/me/diary.mjs +51 -0
  34. package/src/me/home.mjs +10 -0
  35. package/src/me/lock.mjs +41 -0
  36. package/src/me/nudge.mjs +52 -0
  37. package/src/me/onboard.mjs +102 -0
  38. package/src/me/overview.mjs +10 -0
  39. package/src/me/patrol.mjs +43 -0
  40. package/src/me/pick.mjs +64 -0
  41. package/src/me/pool.mjs +15 -0
  42. package/src/me/recon.mjs +94 -0
  43. package/src/me/refresh.mjs +70 -0
  44. package/src/me/schedule-deep.mjs +86 -0
  45. package/src/me/schedule.mjs +120 -0
  46. package/src/me/texts.mjs +291 -0
  47. package/src/me/user.mjs +67 -0
  48. package/src/pack/builtin.mjs +67 -4
  49. package/src/pack/ere.mjs +87 -0
  50. package/src/pack/install.mjs +6 -8
  51. package/src/pack/key.mjs +2 -3
  52. package/src/pack/know.mjs +4 -5
  53. package/src/pack/pack.mjs +14 -10
  54. package/src/pack/rules.mjs +109 -1
  55. package/src/pack/texts.mjs +8 -7
  56. package/src/session.mjs +199 -37
  57. package/src/ticker.mjs +10 -9
  58. package/src/ui/footer.mjs +3 -5
  59. package/src/ui/job-worker.mjs +3 -3
  60. package/src/ui/job.mjs +7 -6
  61. package/src/ui/quips.mjs +1 -2
  62. package/src/ui/wait.mjs +3 -4
  63. package/src/version.mjs +11 -0
@@ -0,0 +1,252 @@
1
+ // Checks for AI models and agent configs on the local machine: model_card (origin, license),
2
+ // model_files (formats that execute code on load, extra Python files) and agent_config (MCP servers and
3
+ // their reach). Rules come from the Hugging Face pack. Only names, sizes, card fields and JSON keys are
4
+ // read; weights are never opened and secret values are never printed.
5
+ import { homedir } from "node:os";
6
+ import { readdirSync, readFileSync, statSync, existsSync, realpathSync } from "node:fs";
7
+ import { join, basename, extname } from "node:path";
8
+ import { targetLabel } from "../core/sh.mjs";
9
+ import { result, evidence, SAFE, WARN, RISK, UNKNOWN } from "../core/check.mjs";
10
+ import { toJs } from "../pack/ere.mjs";
11
+ import { lang } from "../i18n.mjs";
12
+ import { read as readUser } from "../me/user.mjs";
13
+ import { loc } from "./loc.mjs";
14
+
15
+ const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", ".venv", "venv", "Pods", "target"]);
16
+ const BUDGET_MS = 60000, MAX_EVIDENCE = 14;
17
+ const expand = (p) => String(p || "").replace(/^~(?=\/)/, homedir());
18
+ const name3 = (o, fb) => (o && o.i18n && o.i18n[lang()]) || fb;
19
+ // Many checks can describe the same finding; a person needs the worst few, once each.
20
+ const RANK = { risk: 0, warn: 1, info: 2 };
21
+ const topHits = (hits, n = 3) => { const sorted = [...hits].sort((a, b) => RANK[(a.c || a).level] - RANK[(b.c || b).level]); const seen = new Set(), out = []; for (const h of sorted) { const t = name3(h.c || h, (h.c || h).name).toLowerCase().replace(/[^a-zäöüß ]/g, "").slice(0, 28); if (seen.has(t)) continue; seen.add(t); out.push(h); if (out.length === n) break; } return { shown: out, more: hits.length - out.length }; };
22
+
23
+ // ---- where models live on this machine
24
+ export function modelRoots(env = process.env) {
25
+ const h = homedir(), out = [];
26
+ const seen = new Set();
27
+ const add = (p) => { if (!p || !existsSync(p)) return; let real = p; try { real = realpathSync(p).toLowerCase(); } catch { /* keep */ } if (seen.has(real)) return; seen.add(real); out.push(p); };
28
+ add(env.HUGGINGFACE_HUB_CACHE); if (env.HF_HOME) add(join(env.HF_HOME, "hub")); add(env.TRANSFORMERS_CACHE);
29
+ add(join(h, ".cache", "huggingface", "hub")); add(join(h, ".cache", "torch", "hub")); add(join(h, "models")); add(join(h, "Models"));
30
+ const u = readUser();
31
+ for (const p of (u && u.projects) || []) add(expand(p));
32
+ return out;
33
+ }
34
+
35
+ // Walk the roots (files behind a symlink count - a Hugging Face cache is made of them - folders behind one do not).
36
+ // onDir(dir, entries[{name, size}]) is called for every folder. Stops at the time budget and says so.
37
+ export function walk(roots, onDir, { maxDepth = 9, budgetMs = BUDGET_MS } = {}) {
38
+ const start = Date.now();
39
+ let incomplete = false, dirs = 0;
40
+ const stack = roots.map((r) => [r, 0]);
41
+ while (stack.length) {
42
+ if (Date.now() - start > budgetMs) { incomplete = true; break; }
43
+ const [dir, depth] = stack.pop();
44
+ let ents; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
45
+ const files = [];
46
+ for (const e of ents) {
47
+ const p = join(dir, e.name);
48
+ if (e.isDirectory()) { if (depth < maxDepth && !SKIP_DIRS.has(e.name)) stack.push([p, depth + 1]); continue; }
49
+ let st; try { st = statSync(p); } catch { continue; }
50
+ if (st.isFile()) files.push({ name: e.name, size: st.size });
51
+ }
52
+ dirs++;
53
+ onDir(dir, files);
54
+ }
55
+ return { incomplete, dirs };
56
+ }
57
+
58
+ // "models--org--name/snapshots/<rev>/..." -> "org/name"; any other folder -> its name
59
+ export function modelName(dir) {
60
+ const m = dir.match(/models--([^/]+?)--([^/]+)\/snapshots\//);
61
+ return m ? `${m[1]}/${m[2]}` : basename(dir);
62
+ }
63
+ const rev = (dir) => { const m = dir.match(/snapshots\/([0-9a-f]{7})/); return m ? m[1] : ""; };
64
+
65
+ // ---- the model card: README front matter (a small YAML subset) and config.json
66
+ export function parseFrontMatter(text) {
67
+ const m = String(text).match(/^---\s*\n([\s\S]*?)\n---\s*(\n|$)/);
68
+ if (!m) return null;
69
+ const out = {}; let key = null;
70
+ for (const raw of m[1].split("\n")) {
71
+ const line = raw.replace(/\s+$/, "");
72
+ if (!line.trim() || line.trim().startsWith("#")) continue;
73
+ const item = line.match(/^\s+-\s+(.*)$/);
74
+ if (item && key) { (out[key] = Array.isArray(out[key]) ? out[key] : []).push(item[1].replace(/^["']|["']$/g, "")); continue; }
75
+ const kv = line.match(/^([A-Za-z0-9_.-]+):\s*(.*)$/);
76
+ if (!kv) continue;
77
+ key = kv[1]; const v = kv[2].trim();
78
+ if (v === "") { out[key] = out[key] ?? ""; continue; }
79
+ if (v.startsWith("[") && v.endsWith("]")) out[key] = v.slice(1, -1).split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
80
+ else out[key] = v.replace(/^["']|["']$/g, "");
81
+ }
82
+ return out;
83
+ }
84
+ export function cardFacts(dir, files) {
85
+ const names = new Set(files.map((f) => f.name));
86
+ let card = null, cfg = null;
87
+ if (names.has("README.md")) { try { card = parseFrontMatter(readFileSync(join(dir, "README.md"), "utf8").slice(0, 60000)); } catch { /* unreadable */ } }
88
+ if (names.has("config.json")) { try { cfg = JSON.parse(readFileSync(join(dir, "config.json"), "utf8").slice(0, 400000)); } catch { /* unreadable */ } }
89
+ const list = (v) => (Array.isArray(v) ? v : v === undefined || v === "" ? [] : [String(v)]);
90
+ const facts = {
91
+ license: card && card.license !== undefined ? String(card.license).toLowerCase() : "",
92
+ base_model: card ? list(card.base_model) : [],
93
+ pipeline_tag: card && card.pipeline_tag ? String(card.pipeline_tag) : "",
94
+ library_name: card && card.library_name ? String(card.library_name) : "",
95
+ tags: card ? list(card.tags) : [], language: card ? list(card.language) : [], datasets: card ? list(card.datasets) : [],
96
+ gated: !!(card && (card.extra_gated_prompt !== undefined || card.extra_gated_fields !== undefined || String(card.gated) === "true" || card.gated === "manual" || card.gated === "auto")),
97
+ auto_map: !!(cfg && cfg.auto_map && typeof cfg.auto_map === "object"),
98
+ architectures: cfg && Array.isArray(cfg.architectures) ? cfg.architectures.map(String) : [],
99
+ model_type: cfg && cfg.model_type ? String(cfg.model_type) : "",
100
+ quantization: !!(cfg && cfg.quantization_config),
101
+ };
102
+ return { facts, hasCard: !!card, hasCfg: !!cfg, cardFile: names.has("README.md") ? join(dir, "README.md") : "", cfgFile: names.has("config.json") ? join(dir, "config.json") : "" };
103
+ }
104
+ // one declarative test on one field
105
+ export function testCheck(c, facts) {
106
+ const v = facts[c.field];
107
+ const empty = Array.isArray(v) ? v.length === 0 : v === "" || v === false || v === undefined;
108
+ const vals = Array.isArray(v) ? v.map(String) : [String(v)];
109
+ switch (c.op) {
110
+ case "missing": return empty;
111
+ case "present": return !empty;
112
+ case "equals": return !empty && vals.some((x) => x.toLowerCase() === String(c.value).toLowerCase());
113
+ case "in": return !empty && vals.some((x) => c.values.map((y) => y.toLowerCase()).includes(x.toLowerCase()));
114
+ case "matches": { const re = toJs(c.value); return !!re && !empty && vals.some((x) => re.test(x)); }
115
+ default: return false;
116
+ }
117
+ }
118
+
119
+ const localOnly = (rule, id, title) => targetLabel() !== "localhost"
120
+ ? result({ id, title, verdict: UNKNOWN, was: "Diese Prüfung liest Modelle und Agent-Dateien auf dem eigenen Rechner.", wie: "Auf einem Server über SSH schaut Dante hier nicht nach.", wo: "Nur dieser Rechner.", evidence: [evidence("Ziel ist ein Server", { cmd: "targetLabel()", line: targetLabel() })] })
121
+ : null;
122
+
123
+ const find = (id, rule) => ({ id, know: rule.know || rule.id, title: rule.title, aliases: rule.aliases || [], cve: [], deep: !!rule.deep });
124
+
125
+ // ---- CHARACTER: where does the model come from?
126
+ export function modelCardCheck(rule) {
127
+ return { ...find(rule.id, rule), run() {
128
+ const m = loc(rule), id = rule.id, title = m.title;
129
+ const off = localOnly(rule, id, title); if (off) return off;
130
+ const roots = modelRoots();
131
+ if (!roots.length) return result({ id, title, verdict: SAFE, was: "Keine Modell-Ordner gefunden.", wie: "Kein Hugging-Face-Cache und keine Modell-Ordner auf diesem Rechner.", wo: "Dieser Rechner.", evidence: [evidence("Modell-Ordner gesucht", { cmd: "~/.cache/huggingface/hub, ~/models, Projektordner", line: "(keiner dieser Ordner existiert)" })] });
132
+ const lic = new Map((rule.licenses || []).map((l) => [l.id, l.class]));
133
+ const models = [];
134
+ const w = walk(roots, (dir, files) => {
135
+ const names = new Set(files.map((f) => f.name));
136
+ if (!names.has("config.json") && !names.has("README.md")) return;
137
+ if (!files.some((f) => /\.(safetensors|bin|pt|pth|ckpt|gguf|onnx|h5|pkl|msgpack)$/i.test(f.name)) && !names.has("config.json")) return;
138
+ const c = cardFacts(dir, files);
139
+ if (c.hasCfg || c.hasCard) models.push({ dir, ...c });
140
+ });
141
+ if (w.incomplete) return result({ id, title, verdict: UNKNOWN, was: "Konnte nicht alle Modell-Ordner lesen.", wie: "Das Zeitlimit war erreicht — ohne vollständige Suche sage ich nichts.", wo: "Dieser Rechner.", evidence: [evidence("Zeitlimit", { cmd: "walk", line: `${models.length} Modelle bis dahin` })] });
142
+ const ev = [];
143
+ let warn = 0;
144
+ for (const md of models.slice(0, MAX_EVIDENCE)) {
145
+ const f = md.facts, cls = lic.get(f.license) || (f.license ? "unknown" : "");
146
+ const hits = (rule.checks || []).filter((c) => testCheck(c, f));
147
+ const warns = hits.filter((c) => c.level === "warn");
148
+ if (warns.length) warn++;
149
+ const bits = [`Lizenz ${f.license || "—"}${cls ? " (" + cls + ")" : ""}`, f.base_model.length ? "Basis " + f.base_model.slice(0, 2).join(", ") : "", f.pipeline_tag, f.library_name, f.auto_map ? "eigener Code" : "", f.gated ? "gated" : ""].filter(Boolean).join(" · ");
150
+ ev.push(evidence(`${modelName(md.dir)}${rev(md.dir) ? "@" + rev(md.dir) : ""}: ${bits}${hits.length ? " — " + (() => { const t = topHits(hits); return t.shown.map((c) => (c.level === "info" ? "" : "⚠ ") + name3(c, c.name)).join("; ") + (t.more > 0 ? ` (+${t.more})` : ""); })() : ""}`, { cmd: "README.md front matter + config.json", line: md.cardFile || md.cfgFile }));
151
+ }
152
+ if (models.length > MAX_EVIDENCE) ev.push(evidence(`… und ${models.length - MAX_EVIDENCE} weitere`, { cmd: "walk", line: `${models.length} Modelle insgesamt` }));
153
+ if (!models.length) return result({ id, title, verdict: SAFE, was: "Keine Modelle mit Karte oder Konfiguration gefunden.", wie: `${roots.length} Ordner durchsucht.`, wo: "Dieser Rechner.", evidence: [evidence("Ordner durchsucht", { cmd: "walk", line: `(keine Modelle in: ${roots.join(", ")})` })] });
154
+ const bad = models.filter((md) => (rule.checks || []).some((c) => c.level === "warn" && testCheck(c, md.facts))).length;
155
+ return result({ id, title, verdict: bad ? WARN : SAFE, was: m.bad && bad ? m.bad : (m.ok || "Herkunft der Modelle ist angegeben."), wie: bad ? (m.bad_how || "Die Zeilen nennen Modell, Lizenz, Basis und was auffällt.") : (m.ok_how || "Karte und Konfiguration sagen, woher die Modelle kommen."), wo: "Dieser Rechner.", evidence: ev });
156
+ } };
157
+ }
158
+
159
+ // ---- BEHAVIOUR: what does loading it do?
160
+ export function modelFilesCheck(rule) {
161
+ const kinds = new Map((rule.formats || []).map((f) => [f.ext.toLowerCase(), f]));
162
+ const codeRes = (rule.code || []).map((c) => ({ c, re: toJs(c.re) })).filter((x) => x.re);
163
+ return { ...find(rule.id, rule), run() {
164
+ const m = loc(rule), id = rule.id, title = m.title;
165
+ const off = localOnly(rule, id, title); if (off) return off;
166
+ const roots = modelRoots();
167
+ if (!roots.length) return result({ id, title, verdict: SAFE, was: "Keine Modell-Ordner gefunden.", wie: "Kein Hugging-Face-Cache und keine Modell-Ordner auf diesem Rechner.", wo: "Dieser Rechner.", evidence: [evidence("Modell-Ordner gesucht", { cmd: "~/.cache/huggingface/hub, ~/models, Projektordner", line: "(keiner dieser Ordner existiert)" })] });
168
+ const dirs = [];
169
+ const w = walk(roots, (dir, files) => {
170
+ const weights = files.filter((f) => kinds.has(extname(f.name).slice(1).toLowerCase()));
171
+ const names = new Set(files.map((f) => f.name));
172
+ if (!weights.length || (!names.has("config.json") && !weights.some((f) => kinds.get(extname(f.name).slice(1).toLowerCase()).kind !== "unknown"))) return;
173
+ const cfg = (() => { try { return JSON.parse(readFileSync(join(dir, "config.json"), "utf8").slice(0, 400000)); } catch { return null; } })();
174
+ const hasCustomCode = !!(cfg && cfg.auto_map) || files.some((f) => /^(modeling|configuration|tokenization)_.*\.py$/.test(f.name));
175
+ dirs.push({ dir, weights, files, hasCustomCode });
176
+ });
177
+ if (w.incomplete) return result({ id, title, verdict: UNKNOWN, was: "Konnte nicht alle Modell-Ordner lesen.", wie: "Das Zeitlimit war erreicht — ohne vollständige Suche sage ich nichts.", wo: "Dieser Rechner.", evidence: [evidence("Zeitlimit", { cmd: "walk", line: `${dirs.length} Modell-Ordner bis dahin` })] });
178
+ const ev = [], steps = [];
179
+ let warn = 0, safeOnly = 0;
180
+ for (const d of dirs) {
181
+ const byKind = { code: [], safe: [], unknown: [] };
182
+ for (const f of d.weights) byKind[(kinds.get(extname(f.name).slice(1).toLowerCase()) || { kind: "unknown" }).kind].push(f);
183
+ const hits = [];
184
+ if (d.hasCustomCode) for (const f of d.files.filter((x) => /\.py$/.test(x.name) && x.size < 400 * 1024)) {
185
+ let text; try { text = readFileSync(join(d.dir, f.name), "latin1"); } catch { continue; }
186
+ const lines = text.split("\n");
187
+ for (const { c, re } of codeRes) for (let i = 0; i < lines.length; i++) if (lines[i].length < 2000 && re.test(lines[i])) { hits.push({ c, file: join(d.dir, f.name), n: i + 1 }); break; }
188
+ }
189
+ const label = `${modelName(d.dir)}${rev(d.dir) ? "@" + rev(d.dir) : ""}`;
190
+ const redundant = byKind.code.length && byKind.safe.length; // a pickle next to a safetensors copy is loaded by the safe one
191
+ if (byKind.code.length && !redundant) {
192
+ warn++;
193
+ const exts = [...new Set(byKind.code.map((f) => extname(f.name).slice(1).toLowerCase()))];
194
+ if (ev.length < MAX_EVIDENCE) ev.push(evidence(`${label}: lädt per ${exts.join("/")} — dabei kann Code laufen (${name3(kinds.get(exts[0]), kinds.get(exts[0]).name)})`, { cmd: "Dateiendungen im Ordner", line: `${join(d.dir, byKind.code[0].name)} · ${byKind.code[0].size} Bytes` }));
195
+ steps.push({ desc: `Ordner ansehen (nur lesen): ${label}`, script: `ls -l '${d.dir.replace(/'/g, "'\\''")}'` });
196
+ } else if (byKind.safe.length && !byKind.code.length) {
197
+ safeOnly++;
198
+ if (ev.length < MAX_EVIDENCE) ev.push(evidence(`${label}: nur ${[...new Set(byKind.safe.map((f) => extname(f.name).slice(1)))].join("/")} — enthält keinen ausführbaren Code`, { cmd: "Dateiendungen im Ordner", line: join(d.dir, byKind.safe[0].name) }));
199
+ } else if (redundant && ev.length < MAX_EVIDENCE) ev.push(evidence(`${label}: Pickle liegt neben einer safetensors-Kopie`, { cmd: "Dateiendungen im Ordner", line: join(d.dir, byKind.code[0].name) }));
200
+ for (const h of hits.slice(0, 4)) { warn++; if (ev.length < MAX_EVIDENCE + 6) ev.push(evidence(`${label}: eigener Code — Muster „${name3(h.c, h.c.name)}“`, { cmd: "Muster in den .py-Dateien des Modells", line: `${h.file}:${h.n}` })); }
201
+ }
202
+ if (!dirs.length) return result({ id, title, verdict: SAFE, was: "Keine Modell-Dateien gefunden.", wie: `${roots.length} Ordner durchsucht.`, wo: "Dieser Rechner.", evidence: [evidence("Ordner durchsucht", { cmd: "walk", line: `(keine Modell-Dateien in: ${roots.join(", ")})` })] });
203
+ if (!warn) return result({ id, title, verdict: SAFE, was: m.ok || "Beim Laden läuft kein fremder Code.", wie: m.ok_how || "Die Modelle liegen in Formaten ohne ausführbaren Code, und ihr Zusatzcode zeigt keine Auffälligkeit.", wo: "Dieser Rechner.", evidence: ev });
204
+ return result({ id, title, verdict: WARN, was: m.bad || "Beim Laden mancher Modelle kann fremder Code laufen.", wie: m.bad_how || "Pickle-Formate führen beim Laden Code aus; die Zeilen nennen Modell, Datei und Muster. Von Hand ansehen, nicht blind löschen.", wo: "Dieser Rechner.", evidence: ev,
205
+ fix: steps.length ? { why: "Besser die safetensors-Variante laden (kein Code darin). Bis dahin: nur Modelle aus Quellen laden, denen du traust.", steps: steps.slice(0, 5) } : null });
206
+ } };
207
+ }
208
+
209
+ // ---- CAPABILITIES: what may an agent do?
210
+ function serversOf(json) {
211
+ const out = [];
212
+ const take = (obj) => { if (obj && typeof obj === "object" && !Array.isArray(obj)) for (const [name, s] of Object.entries(obj)) if (s && typeof s === "object" && (s.command || s.url || s.args)) out.push({ name, s }); };
213
+ if (!json || typeof json !== "object") return out;
214
+ take(json.mcpServers); take(json.servers); take(json.context_servers); take(json.mcp && json.mcp.servers);
215
+ if (json.projects && typeof json.projects === "object") for (const p of Object.values(json.projects)) if (p && p.mcpServers) take(p.mcpServers);
216
+ return out;
217
+ }
218
+ const where = (name, s) => ({
219
+ command: String(s.command || ""), args: Array.isArray(s.args) ? s.args.map(String).join(" ") : "", url: String(s.url || ""), name,
220
+ env: s.env && typeof s.env === "object" ? Object.entries(s.env).map(([k, v]) => `${k}=${typeof v === "string" ? v : ""}`).join("\n") : "",
221
+ });
222
+ export function agentConfigCheck(rule) {
223
+ const checks = (rule.checks || []).map((c) => ({ c, re: toJs(c.re) })).filter((x) => x.re);
224
+ const caps = (rule.caps || []).map((c) => ({ c, re: toJs(c.re) })).filter((x) => x.re);
225
+ return { ...find(rule.id, rule), run() {
226
+ const m = loc(rule), id = rule.id, title = m.title;
227
+ const off = localOnly(rule, id, title); if (off) return off;
228
+ const tried = [], found = [];
229
+ const proj = []; const u = readUser(); for (const p of (u && u.projects) || []) proj.push(expand(p));
230
+ for (const f of rule.files) {
231
+ if (f.startsWith("~/") || f.includes("/")) { const p = expand(f); tried.push(p); if (existsSync(p)) found.push(p); }
232
+ else for (const root of proj) for (const p of [join(root, f), ...(() => { try { return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")).slice(0, 60).map((e) => join(root, e.name, f)); } catch { return []; } })()]) { if (existsSync(p) && !found.includes(p)) found.push(p); }
233
+ }
234
+ if (!found.length) return result({ id, title, verdict: SAFE, was: "Keine Agent- oder MCP-Konfiguration gefunden.", wie: `${tried.length} bekannte Orte geprüft.`, wo: "Dieser Rechner.", evidence: [evidence("Bekannte Konfig-Dateien gesucht", { cmd: "existsSync auf bekannte Pfade", line: `(keine von ${tried.length} gefunden, z. B. ${tried[0] || "?"})` })] });
235
+ const ev = [], steps = [];
236
+ let worst = SAFE, servers = 0, unreadable = 0;
237
+ for (const file of found) {
238
+ let json; try { json = JSON.parse(readFileSync(file, "utf8").slice(0, 2 * 1024 * 1024)); } catch { unreadable++; ev.push(evidence(`${basename(file)} ist kein lesbares JSON`, { cmd: "JSON.parse", line: file })); if (worst === SAFE) worst = WARN; continue; }
239
+ for (const { name, s } of serversOf(json)) {
240
+ servers++;
241
+ const w = where(name, s);
242
+ const have = [...new Set(caps.filter((x) => x.re.test(`${w.command} ${w.args} ${name}`)).map((x) => x.c.cap))];
243
+ const hits = checks.filter((x) => x.re.test(w[x.c.where] || ""));
244
+ for (const lvl of ["risk", "warn"]) if (hits.some((x) => x.c.level === lvl) && (worst === SAFE || (lvl === "risk" && worst !== RISK))) worst = lvl === "risk" ? RISK : (worst === RISK ? RISK : WARN);
245
+ if (ev.length < MAX_EVIDENCE + 8) ev.push(evidence(`${name}${have.length ? " kann: " + have.join(", ") : ""}${hits.length ? " — " + (() => { const t = topHits(hits); return t.shown.map((x) => (x.c.level === "info" ? "" : "⚠ ") + name3(x.c, x.c.name)).join("; ") + (t.more > 0 ? ` (+${t.more})` : ""); })() : ""}`, { cmd: "JSON-Eintrag des Servers (Werte werden nie gezeigt)", line: `${file} · Server „${name}“` }));
246
+ }
247
+ }
248
+ if (!servers && !unreadable) return result({ id, title, verdict: SAFE, was: "Die Konfiguration nennt keine Server.", wie: `${found.length} Datei(en) gelesen, kein Server eingetragen.`, wo: "Dieser Rechner.", evidence: [evidence("Konfig-Dateien gelesen", { cmd: "JSON.parse", line: found[0] })] });
249
+ return result({ id, title, verdict: worst, was: worst === SAFE ? (m.ok || "Keine auffälligen Rechte in den Agent-Konfigurationen.") : (m.bad || "Ein Agent hat mehr Rechte, als er braucht."), wie: worst === SAFE ? (m.ok_how || `${servers} Server geprüft; die Zeilen sagen, was jeder kann.`) : (m.bad_how || "Die Zeilen nennen Server, Fähigkeit und was auffällt — Werte zeigt Dante nie."), wo: "Dieser Rechner.", evidence: ev,
250
+ fix: worst === SAFE ? null : { why: "Nur die Rechte lassen, die der Agent wirklich braucht; Schlüssel nicht in die Datei schreiben, sondern als Umgebungsvariable setzen.", steps: [{ desc: "Konfig-Datei ansehen (nur lesen)", script: `ls -l '${found[0].replace(/'/g, "'\\''")}'` }] } });
251
+ } };
252
+ }
@@ -1,7 +1,5 @@
1
- // The words of a rule in the language of the person. A rule carries German as its base text and
2
- // i18n.en / i18n.es next to it; the pack factory refuses a rule that lacks either, so a pack can
3
- // never put a German sentence into an English session. An older pack without them falls back to the
4
- // German base (the phrasebook in i18n-checks.mjs still translates what it knows).
1
+ // Picks the wording of a rule for the current language. Rules carry German as base text plus
2
+ // i18n.en / i18n.es; the pack factory rejects rules without both. Older packs fall back to German.
5
3
  import { lang } from "../i18n.mjs";
6
4
 
7
5
  export function loc(rule) {
@@ -1,7 +1,6 @@
1
- // Check: a version-bound CVE (PHP2Shell, CVE-2026-109618 / CVE-2026-109619, is the first), built from
2
- // a rule of type version_cve. Read-only. The rule that keeps Dante honest here: a distro BACKPORT
3
- // keeps the old version number but fixes the bug, so the version number alone can never prove "safe"
4
- // or "risk". The changelog is the authority. If Dante cannot read the changelog, it says UNKNOWN.
1
+ // Version-bound CVE check (PHP2Shell, CVE-2026-109618/-619 first), built from a version_cve rule.
2
+ // Distro backports keep the old version number, so a version alone never proves safe or vulnerable;
3
+ // the package changelog decides, and without it the verdict is unknown.
5
4
  import { run, firstLine } from "../core/sh.mjs";
6
5
  import { result, evidence, SAFE, WARN, RISK, UNKNOWN } from "../core/check.mjs";
7
6
  import { FAMILY_OF, fixSteps } from "../bib/system.mjs";
@@ -10,7 +9,7 @@ import { BUILTIN } from "../pack/builtin.mjs";
10
9
  import { loc } from "./loc.mjs";
11
10
 
12
11
  // How to ask a program for its version, which service belongs to it, where its changelog lives.
13
- // This table is CODE: a pack only picks a name from it (rules.mjs PROBES), it cannot add a command.
12
+ // This table is code: a pack only picks a name from it (rules.mjs PROBES), it cannot add a command.
14
13
  const PROBES = {
15
14
  php: { ver: "php -v", doc: "/usr/share/doc/php*-common/changelog.*", svc: "php[0-9.]*-fpm" },
16
15
  nginx: { ver: "nginx -v 2>&1", doc: "/usr/share/doc/nginx*/changelog.*", svc: "nginx" },
@@ -44,7 +43,7 @@ export function versionCheck(rule) {
44
43
 
45
44
  // 1) Is the program present at all?
46
45
  const cli = run(P.ver);
47
- const fpm = P.svc ? run("systemctl list-units --type=service --all --no-legend 2>/dev/null | grep -iE " + sq(P.svc) + " | awk '{print $1, $4}'") : { ran: false, ok: false, out: "" }; // no service to ask → it must not count as "I looked"
46
+ const fpm = P.svc ? run("systemctl list-units --type=service --all --no-legend 2>/dev/null | grep -iE " + sq(P.svc) + " | awk '{print $1, $4}'") : { ran: false, ok: false, out: "" }; // no service to ask -> it must not count as "I looked"
48
47
  const anyFpm = fpm.ok && fpm.out.trim().length > 0;
49
48
  const anyCli = cli.ok && parseVer(cli.out) != null;
50
49
  if (!anyCli && !anyFpm) {
@@ -84,7 +83,7 @@ export function versionCheck(rule) {
84
83
  wo, evidence: ev });
85
84
  }
86
85
 
87
- // Not upstream-fixed and no CVE line found. Could we even READ the changelog?
86
+ // Not upstream-fixed and no CVE line found. Could we even read the changelog?
88
87
  if (!canReadChangelog) {
89
88
  ev.push(evidence("Changelog nicht lesbar — Patch-Stand nicht belegbar", { cmd: "ls " + P.doc, line: firstLine(docs.err || docs.out) || "(keine Datei)" }));
90
89
  return result({ id, title, verdict: UNKNOWN,
@@ -94,7 +93,7 @@ export function versionCheck(rule) {
94
93
  fix: "Patch-Stand von Hand prüfen (Changelog/Advisory) oder " + L + " aktualisieren und " + (rule.probe === "php" ? "PHP-FPM" : "den Dienst") + " neu starten." });
95
94
  }
96
95
 
97
- // Changelog readable, CVE absent, version below fix line → genuinely unpatched. Exposure decides urgency.
96
+ // Changelog readable, CVE absent, version below fix line -> genuinely unpatched. Exposure decides urgency.
98
97
  ev.push(evidence("Changelog gelesen, kein CVE-Patch darin", { cmd: "zcat changelog | grep CVE", line: "(keine Treffer, Datei lesbar)" }));
99
98
  let exposed = null; // only PHP has an exposure test (a web server that hands .php to FastCGI)
100
99
  if (rule.probe === "php") {
@@ -111,7 +110,7 @@ export function versionCheck(rule) {
111
110
  const major = (parseVer(firstLine(cli.out)) || [8, 3]).slice(0, 2).join(".");
112
111
  const fpmSvc = anyFpm ? firstLine(fpm.out).split(" ")[0] : ("php" + major + "-fpm");
113
112
  // The fix must speak the system's own package manager. Read the family off the target itself
114
- // (os-release), not from anything the user claimed — unreadable → debian (the most common).
113
+ // (os-release), not from anything the user claimed - unreadable -> debian (the most common).
115
114
  const osId = firstLine(run(". /etc/os-release 2>/dev/null; echo \"$ID\"").out).toLowerCase();
116
115
  const family = FAMILY_OF[osId] || "debian";
117
116
  ev.push(evidence("System-Familie (für den Fix-Weg): " + family, { cmd: ". /etc/os-release; echo $ID", line: osId || "(nicht lesbar → debian angenommen)" }));
@@ -1,6 +1,5 @@
1
- // Check: world-writable files in sensitive places (/etc and the web roots). A file anyone can
2
- // rewrite is a file anyone can turn into a foothold (a cron, a config, a PHP page). Read-only:
3
- // it only lists paths with the dangerous mode bit. Can't search → UNKNOWN, never "safe".
1
+ // World-writable files under /etc and the web roots. Anyone can turn such a file into a foothold
2
+ // (cron, config, PHP page). Lists paths only. If the search cannot run the verdict is unknown.
4
3
  import { run } from "../core/sh.mjs";
5
4
  import { result, evidence, SAFE, WARN, UNKNOWN } from "../core/check.mjs";
6
5
  import { webRoots, lines } from "./common.mjs";
@@ -1,7 +1,5 @@
1
- // Check: secrets left world-readable inside a web root (.env, *.sql, backups, .git/config), built
2
- // from a rule of type path_exposed. These are the classic "one GET request and your DB password is
3
- // public" holes. Read-only. Honest rule: no standard web root found → UNKNOWN (we don't know where
4
- // the site is), never "safe".
1
+ // Readable secrets inside a web root (.env, *.sql, backups, .git/config), from a path_exposed rule.
2
+ // Without a standard web root the verdict is unknown, because the site could be anywhere.
5
3
  import { run } from "../core/sh.mjs";
6
4
  import { result, evidence, SAFE, RISK, UNKNOWN } from "../core/check.mjs";
7
5
  import { webRoots, lines, sq } from "./common.mjs";
@@ -1,8 +1,6 @@
1
- // Check: SSH exposure, built from a rule of type sshd_setting — every listed setting must hold a bad
2
- // value for the verdict to be WARN (the built-in rule: root login AND password login together are
3
- // the brute-force surface most own-server break-ins ride in on). Reads the EFFECTIVE sshd config
4
- // (`sshd -T`) so an Include or a drop-in cannot hide it; falls back to the config file if sshd -T
5
- // is not available. Read-only. Can't read either → UNKNOWN, never "safe".
1
+ // SSH exposure from an sshd_setting rule: the verdict is a warning only if every listed setting is bad
2
+ // (by default root login and password login together). Reads the effective config via `sshd -T` so an
3
+ // Include cannot hide a value, and falls back to the config file.
6
4
  import { run } from "../core/sh.mjs";
7
5
  import { result, evidence, SAFE, WARN, UNKNOWN } from "../core/check.mjs";
8
6
  import { BUILTIN } from "../pack/builtin.mjs";
@@ -1,8 +1,6 @@
1
- // Check: webshell markers in the web root — files that call a sink from user input, the shape of a
2
- // planted backdoor ($_GET['x'](...), eval(base64_decode(...)), system($_POST...)). Built from a
3
- // rule of type file_pattern. Read-only: it reports the PATH and which marker matched, never the
4
- // file's contents (that could be attacker code). A match is a heuristic → verdict WARN ("look here
5
- // by hand"), never a bare RISK. No web root → UNKNOWN, never "safe".
1
+ // Looks for webshell markers in a web root (input passed to a call, eval of decoded data, system()
2
+ // with request data), from a file_pattern rule. Reports the path and the marker, not the file content.
3
+ // A match is a warning, since the patterns are heuristics.
6
4
  import { run } from "../core/sh.mjs";
7
5
  import { result, evidence, SAFE, WARN, UNKNOWN } from "../core/check.mjs";
8
6
  import { webRoots, lines, sq } from "./common.mjs";
package/src/cli.mjs CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Dante — watches a server you own, never lies to itself, and changes nothing without a yes.
3
- // It looks and reports with real lines. On a real finding it comes to the foreground, shows its
4
- // plan (plain words + the exact commands) and asks ja/nein. On yes it acts, then re-proves.
2
+ // Command line entry: flags, one-shot checks and the interactive session.
5
3
  import { configureTarget, targetLabel, probe, run, firstLine } from "./core/sh.mjs";
6
4
  import { renderResult, renderSummary } from "./core/render.mjs";
7
5
  import { RISK, WARN, SAFE, UNKNOWN } from "./core/check.mjs";
@@ -10,7 +8,7 @@ import { banner } from "./mascot.mjs";
10
8
  import { activeBattery } from "./checks/battery.mjs";
11
9
  import { demo } from "./checks/demo.mjs";
12
10
  import { staleLine, VERSION, BIB_DATE } from "./ticker.mjs";
13
- import { lang, t } from "./i18n.mjs";
11
+ import { lang, t, initLang } from "./i18n.mjs";
14
12
  import { tx } from "./i18n-checks.mjs";
15
13
 
16
14
  function parseArgs(argv) {
@@ -25,6 +23,8 @@ function parseArgs(argv) {
25
23
  else if (t === "--demo") a.demo = true;
26
24
  else if (t === "--help" || t === "-h") a.help = true;
27
25
  else if (t === "--version" || t === "-v") a.version = true;
26
+ else if (t === "--patrol") a.patrol = true;
27
+ else if (t === "--deep") a.deep = true;
28
28
  }
29
29
  return a;
30
30
  }
@@ -35,6 +35,8 @@ const HELP = `Dante — wächter
35
35
  npx dante-watch --ssh user@host [--key /pfad] prüft einen Server, der dir gehört
36
36
  --fix bei einem Fund: Plan zeigen und nach deinem Ja schließen
37
37
  --demo harmlose Übung: zeigt die ganze Schleife (human → plan → ja/nein → fix → Beweis)
38
+ --patrol eine Patrouille (das startet der Zeitplan): prüft, schreibt das Tagebuch, ändert nichts
39
+ --deep ein Tiefenlauf (wöchentlich, startet der Zeitplan): liest Projektordner nach Schlüsseln, bohrt bei Treffern nach
38
40
  --version welche Version läuft
39
41
  --json Maschinen-Ausgabe
40
42
  -y, --yes nicht nachfragen (für Automode/Cron)
@@ -44,7 +46,7 @@ Dante ändert nichts ohne dein Ja. Jedes Urteil trägt die echte Zeile; fehlt si
44
46
  const C = (code, s) => (process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
45
47
 
46
48
  async function handleFinding(check, r, a) {
47
- // "Human, hör zu" — foreground, plan in words + exact commands, then ja/nein, then re-prove.
49
+ // "Human, hör zu" - foreground, plan in words + exact commands, then ja/nein, then re-prove.
48
50
  console.log("\n" + C("41;37", t("listen")) + " " + C("1", tx(r.title)));
49
51
  console.log(" " + C("2", t("what")) + " " + tx(r.was));
50
52
  console.log(" " + C("2", t("how")) + " " + tx(r.wie));
@@ -53,7 +55,7 @@ async function handleFinding(check, r, a) {
53
55
  console.log(" " + (i + 1) + ") " + tx(s.desc));
54
56
  console.log(" " + C("2", "$ " + s.script));
55
57
  });
56
- // On a real risk, Dante tells you to distrust it (SPEC): check against another AI.
58
+ // On a real risk, Dante tells you to distrust it : check against another AI.
57
59
  console.log(" " + C("33", t("distrust")));
58
60
 
59
61
  const answer = await ask(check.id, t("run"), { yes: a.yes, plan: r.fix });
@@ -78,12 +80,15 @@ async function handleFinding(check, r, a) {
78
80
  }
79
81
 
80
82
  async function main() {
83
+ initLang();
81
84
  const a = parseArgs(process.argv.slice(2));
82
85
  if (a.help) { console.log(HELP); return; }
86
+ if (a.deep) { const { deep } = await import("./me/deep.mjs"); process.exit(await deep()); }
87
+ if (a.patrol) { const { patrol } = await import("./me/patrol.mjs"); process.exit(await patrol()); } // what the schedule starts: no screen, only the diary and the log
83
88
  if (a.version) { console.log("dante-watch " + VERSION + " · bib " + BIB_DATE); return; }
84
89
 
85
- // No flags + a real terminal → the face: summon, greet, arm, talk, stay open.
86
- // Flags or a pipe → one-shot (for cron / --json / automode).
90
+ // No flags + a real terminal -> the face: summon, greet, arm, talk, stay open.
91
+ // Flags or a pipe -> one-shot (for cron / --json / automode).
87
92
  const bare = process.argv.slice(2).length === 0;
88
93
  if (bare && process.stdin.isTTY && process.stdout.isTTY) {
89
94
  const { session } = await import("./session.mjs");
@@ -112,7 +117,7 @@ async function main() {
112
117
  for (const r of results) console.log(renderResult(r));
113
118
  console.log(renderSummary(results, target));
114
119
 
115
- // Findings with a plan → the consent flow (only with --fix or --demo; otherwise just report).
120
+ // Findings with a plan -> the consent flow (only with --fix or --demo; otherwise just report).
116
121
  if (a.fix || a.demo) {
117
122
  for (const c of checks) {
118
123
  const r = results.find((x) => x.id === c.id);
@@ -123,7 +128,7 @@ async function main() {
123
128
  } else if (results.some((r) => (r.verdict === RISK || r.verdict === WARN) && r.fix)) {
124
129
  console.log("\n" + C("2", "Dante kann das nach deinem Ja schließen: noch einmal mit ") + "--fix" + C("2", " starten."));
125
130
  }
126
- const old = staleLine(lang()); // the inner clock — local arithmetic, no update check over the net
131
+ const old = staleLine(lang()); // the inner clock - local arithmetic, no update check over the net
127
132
  if (old) console.log("\n" + C("33", old));
128
133
  process.exit(results.some((r) => r.verdict === RISK) ? 2 : 0);
129
134
  }
@@ -1,17 +1,13 @@
1
- // The contract that makes Dante unable to lie to itself.
2
- //
3
- // A check returns a verdict and a list of EVIDENCE. Every evidence item ties a plain-language
4
- // claim to the exact command and the raw line it came from. The renderer refuses to print a
5
- // claim that has no raw line. A check that cannot gather real evidence must return "unknown" —
6
- // never "safe". Safe is a thing you prove, not a thing you assume.
1
+ // Result contract for checks. Every piece of evidence ties a claim to the command that ran and the
2
+ // line it printed. A safe, risk or warn verdict without a grounded line is downgraded to unknown.
7
3
 
8
4
  export const SAFE = "safe"; // proven not vulnerable, with evidence
9
5
  export const WARN = "warn"; // partially mitigated / needs a step (e.g. restart)
10
6
  export const RISK = "risk"; // proven exposed / vulnerable, with evidence
11
- export const UNKNOWN = "unknown"; // could not gather real evidence — never claim safety
7
+ export const UNKNOWN = "unknown"; // could not gather real evidence - never claim safety
12
8
 
13
9
  export function evidence(claim, source) {
14
- // source = { cmd, line } — cmd is the real command, line is the real output it produced.
10
+ // source = { cmd, line } - cmd is the real command, line is the real output it produced.
15
11
  const line = (source && source.line != null) ? String(source.line).trim() : "";
16
12
  const cmd = (source && source.cmd) ? String(source.cmd) : "";
17
13
  return { claim: String(claim), cmd, line, grounded: line.length > 0 };
@@ -1,6 +1,6 @@
1
- // Consent: Dante never changes anything without a yes. It comes to the foreground, shows its plan
2
- // in plain words AND the exact commands, and waits. A trust count per check-id earns an automode
3
- // offer after 5 yeses (SPEC). State lives in ~/.dante/trust.json, local to the user.
1
+ // Confirmation before any change: show the plan and the exact commands, then wait for a yes.
2
+ // A per-check trust counter offers automatic mode after five confirmations; it lives in
3
+ // ~/.dante/trust.json.
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
@@ -19,7 +19,7 @@ export function automodeFor(id) {
19
19
  return d[id] && d[id].automode === true;
20
20
  }
21
21
 
22
- // Only a clean yes is a yes. "ja aber erst morgen", "jein", "ja?" are not — when in doubt, nothing
22
+ // Only a clean yes is a yes. "ja aber erst morgen", "jein", "ja?" are not - when in doubt, nothing
23
23
  // changes. (Before: anything starting with "j" counted, so "ja aber nicht jetzt" ran the fix.)
24
24
  const YES = /^(ja|j|jo|jap|jawohl|ja bitte|ja mach|yes|y|yep|yes please|yeah|sure|si|sí|claro|dale|ok|okay|mach|mach es|los|go)[.!]*$/;
25
25
  const ALWAYS = /^(a|automode|immer|always|siempre)[.!]*$/;
@@ -42,16 +42,16 @@ export async function ask(id, question, { yes = false, plan = null, rl: shared =
42
42
  const dim = (t) => console.log(" \x1b[2m" + t + "\x1b[0m");
43
43
  let answer = "no";
44
44
  try {
45
- // The consent ear may explain, delay or refuse — it can never say yes. Yes is typed, cleanly.
45
+ // The consent ear may explain, delay or refuse - it can never say yes. Yes is typed, cleanly.
46
46
  for (let round = 0; round < 4; round++) {
47
47
  answer = hear(await rl.question(question));
48
48
  if (answer !== "no" || NO.test(lastRaw)) break;
49
49
  const { hearConsent } = await import("../bib/ear.mjs");
50
50
  const meant = hearConsent(lastRaw).intent;
51
- // A long yes ("ja ohne wenn und aber", "Sí. apt upgrade …") is not run — it is asked back.
51
+ // A long yes ("ja ohne wenn und aber", "Sí. apt upgrade ...") is not run - it is asked back.
52
52
  if (YES_START.test(lastRaw) && meant !== "nein") { dim(t("sayClean")); continue; }
53
53
  if (meant === "spaeter") { dim(t("later")); break; }
54
- // "what does it do" and "is it dangerous" overlap (measured: most yes/no mix-ups) — both get both.
54
+ // "what does it do" and "is it dangerous" overlap (measured: most yes/no mix-ups) - both get both.
55
55
  if ((meant === "was_macht" || meant === "gefahr") && plan) {
56
56
  plan.steps.forEach((s, i) => dim(`${i + 1}) ${tx(s.desc)} — ${t("stepIs")} ${s.script}`));
57
57
  dim(plan.risk || t("danger")(plan.steps.length));
@@ -1,5 +1,5 @@
1
- // Render a check result as a Formular: was / wie / wo, a verdict badge, and the Belege.
2
- // A claim without a real line is never shown as proof — it is marked as a gap.
1
+ // Formats a check result as what / how / where plus evidence lines. A claim without a real
2
+ // line is printed as a gap, not as proof.
3
3
  import { SAFE, WARN, RISK, UNKNOWN } from "./check.mjs";
4
4
  import { t } from "../i18n.mjs";
5
5
  import { tx } from "../i18n-checks.mjs";
package/src/core/sh.mjs CHANGED
@@ -1,21 +1,26 @@
1
- // sh: the only place a real line enters Dante. Runs a command, captures stdout/stderr/exit.
2
- // Optionally prefixes with ssh so Dante can look at a remote server the user owns.
3
- // Nothing here is a model's opinion — it is the machine's own output.
1
+ // Runs commands, locally or over ssh, and returns stdout, stderr and the exit status. All evidence in
2
+ // the program comes through here.
4
3
  import { spawn, spawnSync } from "node:child_process";
5
4
 
6
- let PREFIX = null; // e.g. ["ssh","-i","/key","root@host"] — set by configureTarget()
5
+ let PREFIX = null; // e.g. ["ssh","-i","/key","root@host"] - set by configureTarget()
6
+
7
+ // user@host, host, host:port-less names and addresses (letters, digits, . _ - @ : and [] for IPv6); never a leading -
8
+ export const validHost = (h) => typeof h === "string" && h.length > 0 && h.length < 256 && /^[A-Za-z0-9_][A-Za-z0-9._@:\[\]%-]*$/.test(h);
7
9
 
8
10
  export function configureTarget(ssh) {
9
- // ssh: null (local) | {host, key?} — we build an argv prefix, never a shell string.
11
+ // ssh: null (local) | {host, key?} - we build an argv prefix, never a shell string.
10
12
  if (!ssh) { PREFIX = null; return; }
13
+ // A target is a name or address, never an option: a "host" that starts with "-" (-oProxyCommand=...) would be run by ssh
14
+ // itself as an option. Anything that is not a plain host is refused here and answers "no channel", never a command.
15
+ if (!validHost(ssh.host)) { PREFIX = ["sh", "-c", "echo 'ungültiges Ziel' >&2; exit 255", "dante"]; return; }
11
16
  const p = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
12
17
  if (ssh.key) p.push("-i", ssh.key);
13
- p.push(ssh.host);
18
+ p.push("--", ssh.host);
14
19
  PREFIX = p;
15
20
  }
16
21
 
17
22
  export function targetLabel() {
18
- return PREFIX ? PREFIX[PREFIX.length - 1] : "localhost";
23
+ return PREFIX ? (PREFIX[0] === "ssh" ? PREFIX[PREFIX.length - 1] : "ungültig") : "localhost";
19
24
  }
20
25
 
21
26
  /**
@@ -1,6 +1,5 @@
1
- // Phrasebook for what the checks say. Checks stay written in German (their logic is the part that
2
- // must not lie, so it is not touched for language); the renderer looks each sentence up here.
3
- // Unknown text — above all every raw line from a real command — passes through unchanged.
1
+ // Translations for the fixed sentences of the checks. The checks themselves are written in German;
2
+ // the renderer looks sentences up here and passes unknown text (such as command output) through.
4
3
  import { lang } from "./i18n.mjs";
5
4
 
6
5
  const BOOK = [