dante-watch 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # Dante
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Dante watches a server you own — and never lies to itself.**
4
+
5
+ ```
6
+ npx dante-watch # check this machine
7
+ npx dante-watch --ssh root@host --key ~/.ssh/id # check a server you own
8
+ ```
9
+
10
+ Every verdict carries the real line it came from. If Dante cannot read the proof, it says
11
+ `UNBEKANNT` (unknown) — never `safe`. Safe is a thing it proves, not a thing it assumes.
12
+
13
+ ## What Dante sends: nothing
14
+
15
+ Read the source — it is 19 kB. Dante makes **no network connections of its own**: there is no
16
+ `fetch`, no HTTP, no telemetry, nothing is uploaded anywhere. It imports exactly one Node builtin,
17
+ `node:child_process`, to run local commands. It connects out **only** if you pass `--ssh`, and then
18
+ only to the host you named, with the key you named.
19
+
20
+ - **No install hooks.** `npm install` runs no scripts. No `postinstall`, nothing executes on install.
21
+ - **No dependencies.** Nothing is pulled in that you did not read.
22
+ - **Every command is shown.** Each verdict prints the exact command it ran and the line it got
23
+ back, so you can audit every move Dante makes.
24
+
25
+ If you review packages for a living: this one phones home to no one, and it says so here on purpose.
26
+ Questions: info@nm-streetwear.space.
27
+
28
+ ## Why
29
+
30
+ In October 2026 the PHP2Shell flaw (CVE-2026-109618 / -109619) put most of the web on the clock,
31
+ and AI agents started hunting unpatched servers within the hour. The honest defence is not a
32
+ scary report — it is a fast, grounded check you can run on your own box and believe.
33
+
34
+ ## How it stays honest
35
+
36
+ - **Channel first.** Dante proves it can see the target (it must get its own nonce back) before it
37
+ concludes anything. No channel → everything is unknown.
38
+ - **Every claim shows its line.** A claim without a real command output is printed as a gap, never
39
+ as proof.
40
+ - **Backports are respected.** A distro patch keeps the old version number but fixes the bug, so a
41
+ version number alone can never decide. The changelog is the authority; if it can't be read, the
42
+ answer is unknown.
43
+ - **Read-only (v0.1).** Dante looks and reports. It changes nothing. Fixes come behind a yes/no
44
+ gate.
45
+ - **Check me against another AI.** If Dante tells you something is open, verify it against an
46
+ independent model. If that check disagrees, write us: info@nm-streetwear.space.
47
+
48
+ ## Zero dependencies
49
+
50
+ A security tool with a fat dependency tree is itself a supply-chain risk. Dante ships with none —
51
+ only Node (≥18). You can read all of it.
52
+
53
+ ## Credits
54
+
55
+ Dante is the pilgrim who walked through Hell and came back with the map — companion to
56
+ [Virgil](https://www.npmjs.com/package/virgil-cli). Built by nm. More coming.
57
+
58
+ MIT License
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "dante-watch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Dante watches your own server and never lies to itself: every verdict carries the real line it came from.",
5
+ "type": "module",
6
+ "bin": { "dante-watch": "src/cli.mjs" },
7
+ "files": ["src", "README.md"],
8
+ "engines": { "node": ">=18" },
9
+ "license": "MIT",
10
+ "dependencies": {},
11
+ "keywords": ["security", "server", "cve", "php", "self-check", "zero-dependencies"]
12
+ }
@@ -0,0 +1,24 @@
1
+ // Demo check (only with --demo): a harmless "open door" = a temp file. Lets you watch the whole
2
+ // loop — human-listen → plan → ja/nein → fix → re-prove — without touching anything real.
3
+ import { run, firstLine } from "../core/sh.mjs";
4
+ import { result, evidence, SAFE, RISK } from "../core/check.mjs";
5
+
6
+ const HOLE = "/tmp/dante-demo-hole";
7
+
8
+ export const demo = {
9
+ id: "demo",
10
+ title: "Demo — eine harmlose offene Tür (Übung)",
11
+ run() {
12
+ const ls = run("ls -l " + HOLE + " 2>/dev/null");
13
+ const open = ls.ran && ls.ok && ls.out.trim().length > 0;
14
+ if (!open) {
15
+ return result({ id: this.id, title: this.title, verdict: SAFE,
16
+ was: "Die Übungs-Tür ist zu.", wie: "Die Datei " + HOLE + " existiert nicht.",
17
+ wo: "Dieser Rechner.", evidence: [evidence("Keine Übungs-Datei da", { cmd: "ls " + HOLE, line: "(nicht vorhanden)" })] });
18
+ }
19
+ return result({ id: this.id, title: this.title, verdict: RISK,
20
+ was: "Die Übungs-Tür steht offen.", wie: "Die Datei " + HOLE + " ist da.",
21
+ wo: "Dieser Rechner.", evidence: [evidence("Übungs-Datei existiert", { cmd: "ls -l " + HOLE, line: firstLine(ls.out) })],
22
+ fix: { why: "Die Übungs-Datei entfernen.", steps: [{ desc: "Datei löschen", script: "rm -f " + HOLE }] } });
23
+ },
24
+ };
@@ -0,0 +1,103 @@
1
+ // Check: PHP2Shell (CVE-2026-109618 / CVE-2026-109619), the RCE flaw in the PHP engine.
2
+ // Read-only. The rule that keeps Dante honest here: a distro BACKPORT keeps the old version
3
+ // number but fixes the bug, so the version number alone can never prove "safe" or "risk".
4
+ // The changelog is the authority. If Dante cannot read the changelog, it says UNKNOWN — not RISK.
5
+ import { run, firstLine } from "../core/sh.mjs";
6
+ import { result, evidence, SAFE, WARN, RISK, UNKNOWN } from "../core/check.mjs";
7
+
8
+ const CVE_RE = "CVE-2026-109618|CVE-2026-109619";
9
+ const FIXED = { 8: { 2: 35, 3: 36, 4: 27, 5: 12 } }; // upstream fixed versions (2026-10-21)
10
+
11
+ function parseVer(s) {
12
+ const m = String(s || "").match(/\b(\d+)\.(\d+)\.(\d+)/);
13
+ return m ? [+m[1], +m[2], +m[3]] : null;
14
+ }
15
+ function upstreamFixed(v) {
16
+ if (!v) return false;
17
+ const min = FIXED[v[0]] && FIXED[v[0]][v[1]];
18
+ if (min == null) return v[0] > 8;
19
+ return v[2] >= min;
20
+ }
21
+
22
+ export const php2shell = {
23
+ id: "php2shell",
24
+ title: "PHP2Shell — RCE im PHP-Kern (CVE-2026-109618/-619)",
25
+
26
+ run() {
27
+ const ev = [];
28
+
29
+ // 1) Is PHP present at all?
30
+ const cli = run("php -v");
31
+ const fpm = run("systemctl list-units --type=service --all --no-legend 2>/dev/null | grep -iE 'php[0-9.]*-fpm' | awk '{print $1, $4}'");
32
+ const anyFpm = fpm.ok && fpm.out.trim().length > 0;
33
+ const anyCli = cli.ok && /PHP\s+\d/.test(cli.out);
34
+ if (!anyCli && !anyFpm) {
35
+ // Only call it SAFE if the "is php here" commands actually RAN. Otherwise we don't know.
36
+ if (!cli.ran && !fpm.ran) {
37
+ return result({ id: this.id, title: this.title, verdict: UNKNOWN,
38
+ was: "Konnte den Server nicht abfragen.", wie: cli.errorMsg || fpm.errorMsg || "Befehle liefen nicht.",
39
+ wo: "Ziel nicht erreichbar.", evidence: [] });
40
+ }
41
+ ev.push(evidence("Kein PHP-CLI", { cmd: "php -v", line: firstLine(cli.err || cli.out) || "command not found" }));
42
+ ev.push(evidence("Kein PHP-FPM-Dienst", { cmd: "systemctl … grep php-fpm", line: "(keine Treffer)" }));
43
+ return result({ id: this.id, title: this.title, verdict: SAFE,
44
+ was: "PHP2Shell betrifft nur Server mit PHP.", wie: "Kein PHP-CLI und kein PHP-FPM-Dienst installiert.",
45
+ wo: "Dieser Server.", evidence: ev });
46
+ }
47
+
48
+ if (anyCli) {
49
+ const verLine = firstLine(cli.out);
50
+ const v = parseVer(verLine);
51
+ ev.push(evidence("PHP-Version: " + (v ? v.join(".") : verLine), { cmd: "php -v", line: verLine }));
52
+ if (upstreamFixed(v)) {
53
+ return result({ id: this.id, title: this.title, verdict: SAFE,
54
+ was: "PHP ist auf einer upstream-gepatchten Version.", wie: "Version ≥ Fix-Grenze (8.2.35/8.3.36/8.4.27/8.5.12).",
55
+ wo: "Dieser Server.", evidence: ev });
56
+ }
57
+ }
58
+
59
+ // 2) The authority: can we read the distro changelog, and does it name the CVE patch?
60
+ const docs = run("ls /usr/share/doc/php*-common/changelog.* 2>/dev/null");
61
+ const canReadChangelog = docs.ran && docs.ok && docs.out.trim().length > 0;
62
+ const chg = run("zcat /usr/share/doc/php*-common/changelog.* 2>/dev/null | grep -iE '" + CVE_RE + "' | head -1");
63
+
64
+ if (chg.ran && chg.ok && chg.out.trim()) {
65
+ ev.push(evidence("Distro-Changelog nennt den CVE-Patch (Backport)", { cmd: "zcat changelog | grep CVE-2026-109618", line: firstLine(chg.out) }));
66
+ return result({ id: this.id, title: this.title, verdict: SAFE,
67
+ was: "Der PHP2Shell-Patch ist auf diesem Server belegt.", wie: "Der Distro-Changelog weist den CVE-Patch aus.",
68
+ wo: "Dieser Server.", evidence: ev });
69
+ }
70
+
71
+ // Not upstream-fixed and no CVE line found. Could we even READ the changelog?
72
+ if (!canReadChangelog) {
73
+ ev.push(evidence("Changelog nicht lesbar — Patch-Stand nicht belegbar", { cmd: "ls /usr/share/doc/php*-common/changelog.*", line: firstLine(docs.err || docs.out) || "(keine Datei)" }));
74
+ return result({ id: this.id, title: this.title, verdict: UNKNOWN,
75
+ was: "PHP ist da, aber der Patch-Stand war nicht belegbar.",
76
+ wie: "Versionsnummer allein reicht nicht (Distro-Backports), und der Changelog ließ sich nicht lesen.",
77
+ wo: "Dieser Server.", evidence: ev,
78
+ fix: "Patch-Stand von Hand prüfen (Changelog/Advisory) oder PHP aktualisieren und PHP-FPM neu starten." });
79
+ }
80
+
81
+ // Changelog readable, CVE absent, version below fix line → genuinely unpatched. Exposure decides urgency.
82
+ ev.push(evidence("Changelog gelesen, kein CVE-Patch darin", { cmd: "zcat changelog | grep CVE", line: "(keine Treffer, Datei lesbar)" }));
83
+ const expose = run("grep -rslE 'fastcgi_pass|\\\\.php' /etc/nginx/sites-enabled/ /etc/apache2/sites-enabled/ 2>/dev/null | head -3");
84
+ const exposed = expose.ran && expose.ok && expose.out.trim().length > 0;
85
+ ev.push(evidence(exposed ? "Webserver leitet .php/FastCGI weiter (prüfen: Honeypot oder echt)" : "Keine PHP-Weiterleitung im Webserver gefunden",
86
+ { cmd: "grep -rl fastcgi_pass sites-enabled", line: exposed ? firstLine(expose.out) : "(keine Treffer)" }));
87
+
88
+ const major = (parseVer(firstLine(cli.out)) || [8, 3]).slice(0, 2).join(".");
89
+ const fpmSvc = anyFpm ? firstLine(fpm.out).split(" ")[0] : ("php" + major + "-fpm");
90
+ return result({ id: this.id, title: this.title, verdict: exposed ? RISK : WARN,
91
+ was: exposed ? "PHP ist ungepatcht UND vom Web erreichbar." : "PHP ist ungepatcht, aber nicht offen erreichbar.",
92
+ wie: "Version unter der Fix-Grenze und kein CVE-Patch im lesbaren Changelog.",
93
+ wo: "Dieser Server.", evidence: ev,
94
+ fix: {
95
+ why: "PHP auf den gepatchten Stand bringen und den laufenden Dienst darauf neu starten.",
96
+ steps: [
97
+ { desc: "Paketliste aktualisieren", script: "apt-get update" },
98
+ { desc: "PHP-Pakete patchen", script: "apt-get install --only-upgrade -y php" + major + "-fpm php" + major + "-cli php" + major + "-common" },
99
+ { desc: "PHP-FPM neu starten (lädt die gepatchte Version)", script: "systemctl restart " + fpmSvc },
100
+ ],
101
+ } });
102
+ },
103
+ };
package/src/cli.mjs ADDED
@@ -0,0 +1,112 @@
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.
5
+ import { configureTarget, targetLabel, probe, run, firstLine } from "./core/sh.mjs";
6
+ import { renderResult, renderSummary } from "./core/render.mjs";
7
+ import { RISK, WARN, SAFE, UNKNOWN } from "./core/check.mjs";
8
+ import { ask, recordYes } from "./core/consent.mjs";
9
+ import { banner } from "./mascot.mjs";
10
+ import { php2shell } from "./checks/php2shell.mjs";
11
+ import { demo } from "./checks/demo.mjs";
12
+
13
+ function parseArgs(argv) {
14
+ const a = { ssh: null, key: null, json: false, yes: false, fix: false, demo: false };
15
+ for (let i = 0; i < argv.length; i++) {
16
+ const t = argv[i];
17
+ if (t === "--ssh") a.ssh = argv[++i];
18
+ else if (t === "--key") a.key = argv[++i];
19
+ else if (t === "--json") a.json = true;
20
+ else if (t === "--yes" || t === "-y") a.yes = true;
21
+ else if (t === "--fix") a.fix = true;
22
+ else if (t === "--demo") a.demo = true;
23
+ else if (t === "--help" || t === "-h") a.help = true;
24
+ }
25
+ return a;
26
+ }
27
+
28
+ const HELP = `Dante — wächter
29
+
30
+ npx dante-watch prüft diesen Rechner (nur schauen)
31
+ npx dante-watch --ssh user@host [--key /pfad] prüft einen Server, der dir gehört
32
+ --fix bei einem Fund: Plan zeigen und nach deinem Ja schließen
33
+ --demo harmlose Übung: zeigt die ganze Schleife (human → plan → ja/nein → fix → Beweis)
34
+ --json Maschinen-Ausgabe
35
+ -y, --yes nicht nachfragen (für Automode/Cron)
36
+
37
+ Dante ändert nichts ohne dein Ja. Jedes Urteil trägt die echte Zeile; fehlt sie, sagt er „unbekannt“, nie „sicher“.`;
38
+
39
+ const C = (code, s) => (process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
40
+
41
+ async function handleFinding(check, r, a) {
42
+ // "Human, hör zu" — foreground, plan in words + exact commands, then ja/nein, then re-prove.
43
+ console.log("\n" + C("41;37", " HUMAN, HÖR ZU ") + " " + C("1", r.title));
44
+ console.log(" " + C("2", "was:") + " " + r.was);
45
+ console.log(" " + C("2", "wie:") + " " + r.wie);
46
+ console.log(" " + C("36", "mein plan:") + " " + r.fix.why);
47
+ r.fix.steps.forEach((s, i) => {
48
+ console.log(" " + (i + 1) + ") " + s.desc);
49
+ console.log(" " + C("2", "$ " + s.script));
50
+ });
51
+ // On a real risk, Dante tells you to distrust it (SPEC): check against another AI.
52
+ console.log(" " + C("33", "prüf das ruhig gegen eine andere KI. Sagt die was anderes — schreib uns: info@nm-streetwear.space"));
53
+
54
+ const answer = await ask(check.id, " Soll ich das ausführen? [ja / nein / (a)utomode] ", { yes: a.yes });
55
+ if (answer === "no") { console.log(" " + C("2", "nichts geändert.")); return r.verdict; }
56
+ if (answer === "always") { await recordYes(check.id, { yes: true }); const { grantAutomode } = await import("./core/consent.mjs"); grantAutomode(check.id); }
57
+
58
+ for (const s of r.fix.steps) {
59
+ const res = run(s.script, { timeout: 120000 });
60
+ const line = firstLine(res.out || res.err) || (res.ok ? "(ok)" : "(fehler)");
61
+ console.log(" " + (res.ok ? C("32", "✓") : C("31", "✗")) + " " + s.desc + " " + C("2", "→ " + line));
62
+ if (!res.ok) { console.log(" " + C("31", "Schritt fehlgeschlagen — ich halte an. Nichts weiter geändert.")); return r.verdict; }
63
+ }
64
+ if (answer === "yes") await recordYes(check.id, { yes: a.yes });
65
+
66
+ // Re-prove: run the same check again and show the new, real verdict.
67
+ const after = check.run();
68
+ console.log(" " + C("2", "nochmal geprüft:"));
69
+ console.log(renderResult(after).split("\n").map((l) => " " + l).join("\n"));
70
+ if (after.verdict === SAFE) console.log(" " + C("42;30", " GESCHLOSSEN ") + " " + C("2", "— mit neuer echter Zeile belegt."));
71
+ else console.log(" " + C("33", "Noch nicht sicher. Dante behauptet nichts, was die Zeilen nicht hergeben."));
72
+ return after.verdict;
73
+ }
74
+
75
+ async function main() {
76
+ const a = parseArgs(process.argv.slice(2));
77
+ if (a.help) { console.log(HELP); return; }
78
+ configureTarget(a.ssh ? { host: a.ssh, key: a.key } : null);
79
+ const target = targetLabel();
80
+
81
+ const ch = probe();
82
+ if (!ch.reached) {
83
+ const r = { id: "channel", title: "Kanal zum Ziel", verdict: UNKNOWN, was: "Dante konnte " + target + " nicht sehen.", wie: ch.line, wo: target, evidence: [] };
84
+ if (a.json) console.log(JSON.stringify({ target, results: [r] }, null, 2));
85
+ else { console.log(banner()); console.log(renderResult(r)); console.log(renderSummary([r], target) + "\n" + C("2", "Kein Urteil über einen Server, den Dante nicht erreicht.")); }
86
+ process.exit(1);
87
+ }
88
+
89
+ const checks = [php2shell, ...(a.demo ? [demo] : [])];
90
+ const results = checks.map((c) => { try { return c.run(); } catch (e) { return { id: c.id, title: c.title, verdict: UNKNOWN, was: "Check brach ab.", wie: String((e && e.message) || e), wo: target, evidence: [] }; } });
91
+
92
+ if (a.json) { console.log(JSON.stringify({ target, results }, null, 2)); process.exit(results.some((r) => r.verdict === RISK) ? 2 : 0); }
93
+
94
+ console.log(banner());
95
+ for (const r of results) console.log(renderResult(r));
96
+ console.log(renderSummary(results, target));
97
+
98
+ // Findings with a plan → the consent flow (only with --fix or --demo; otherwise just report).
99
+ if (a.fix || a.demo) {
100
+ for (const c of checks) {
101
+ const r = results.find((x) => x.id === c.id);
102
+ if (r && (r.verdict === RISK || r.verdict === WARN) && r.fix && r.fix.steps) {
103
+ await handleFinding(c, r, a);
104
+ }
105
+ }
106
+ } else if (results.some((r) => (r.verdict === RISK || r.verdict === WARN) && r.fix)) {
107
+ console.log("\n" + C("2", "Dante kann das nach deinem Ja schließen: noch einmal mit ") + "--fix" + C("2", " starten."));
108
+ }
109
+ process.exit(results.some((r) => r.verdict === RISK) ? 2 : 0);
110
+ }
111
+
112
+ main();
@@ -0,0 +1,26 @@
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.
7
+
8
+ export const SAFE = "safe"; // proven not vulnerable, with evidence
9
+ export const WARN = "warn"; // partially mitigated / needs a step (e.g. restart)
10
+ export const RISK = "risk"; // proven exposed / vulnerable, with evidence
11
+ export const UNKNOWN = "unknown"; // could not gather real evidence — never claim safety
12
+
13
+ export function evidence(claim, source) {
14
+ // source = { cmd, line } — cmd is the real command, line is the real output it produced.
15
+ const line = (source && source.line != null) ? String(source.line).trim() : "";
16
+ const cmd = (source && source.cmd) ? String(source.cmd) : "";
17
+ return { claim: String(claim), cmd, line, grounded: line.length > 0 };
18
+ }
19
+
20
+ export function result({ id, title, verdict, was, wie, wo, evidence: ev = [], fix = null }) {
21
+ // Guard: a verdict of SAFE/RISK/WARN must stand on at least one grounded evidence line.
22
+ const grounded = ev.filter((e) => e.grounded);
23
+ let v = verdict;
24
+ if ((v === SAFE || v === RISK || v === WARN) && grounded.length === 0) v = UNKNOWN;
25
+ return { id, title, verdict: v, was: was || "", wie: wie || "", wo: wo || "", evidence: ev, fix };
26
+ }
@@ -0,0 +1,47 @@
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.
4
+ import { createInterface } from "node:readline/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
+
9
+ const DIR = join(homedir(), ".dante");
10
+ const FILE = join(DIR, "trust.json");
11
+
12
+ function load() { try { return JSON.parse(readFileSync(FILE, "utf8")); } catch { return {}; } }
13
+ function save(d) { try { mkdirSync(DIR, { recursive: true }); writeFileSync(FILE, JSON.stringify(d, null, 1)); } catch { /* best effort */ } }
14
+
15
+ export function automodeFor(id) {
16
+ const d = load();
17
+ return d[id] && d[id].automode === true;
18
+ }
19
+
20
+ /** Returns "yes" | "no" | "always". In --yes mode: auto-yes. Honours earned automode. */
21
+ export async function ask(id, question, { yes = false } = {}) {
22
+ if (yes || automodeFor(id)) return "yes";
23
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
24
+ let answer = "no";
25
+ try {
26
+ const raw = (await rl.question(question)).trim().toLowerCase();
27
+ if (/^(a|immer|always)/.test(raw)) answer = "always";
28
+ else if (/^(j|y)/.test(raw)) answer = "yes";
29
+ else answer = "no";
30
+ } finally { rl.close(); }
31
+ return answer;
32
+ }
33
+
34
+ /** Record a yes; after 5, offer automode. Returns true if automode was just granted. */
35
+ export async function recordYes(id, { yes = false } = {}) {
36
+ const d = load();
37
+ const e = d[id] || (d[id] = { yes: 0, automode: false });
38
+ e.yes = (e.yes || 0) + 1;
39
+ save(d);
40
+ if (!e.automode && e.yes >= 5 && !yes && !automodeFor(id)) {
41
+ const a = await ask(id + ":automode-offer", " Du hast für „" + id + "“ schon " + e.yes + "× ja gesagt. Künftig ohne Nachfrage (Automode)? [ja/nein] ", {});
42
+ if (a === "yes" || a === "always") { e.automode = true; save(d); return true; }
43
+ }
44
+ return false;
45
+ }
46
+
47
+ export function grantAutomode(id) { const d = load(); d[id] = Object.assign(d[id] || { yes: 0 }, { automode: true }); save(d); }
@@ -0,0 +1,55 @@
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.
3
+ import { SAFE, WARN, RISK, UNKNOWN } from "./check.mjs";
4
+
5
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
6
+ const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
7
+ const dim = (s) => c("2", s), bold = (s) => c("1", s);
8
+
9
+ const BADGE = {
10
+ [SAFE]: () => c("42;30", " SICHER "),
11
+ [WARN]: () => c("43;30", " SCHRITT NÖTIG "),
12
+ [RISK]: () => c("41;37", " OFFEN "),
13
+ [UNKNOWN]: () => c("100;37", " UNBEKANNT "),
14
+ };
15
+
16
+ export function renderResult(r) {
17
+ const lines = [];
18
+ lines.push("");
19
+ lines.push((BADGE[r.verdict] || BADGE[UNKNOWN])() + " " + bold(r.title));
20
+ if (r.was) lines.push(" " + dim("was:") + " " + r.was);
21
+ if (r.wie) lines.push(" " + dim("wie:") + " " + r.wie);
22
+ if (r.wo) lines.push(" " + dim("wo: ") + " " + r.wo);
23
+ if (r.evidence && r.evidence.length) {
24
+ lines.push(" " + dim("belege:"));
25
+ for (const e of r.evidence) {
26
+ if (e.grounded) {
27
+ lines.push(" • " + e.claim);
28
+ lines.push(" " + dim("↳ " + e.cmd + " → " + truncate(e.line, 96)));
29
+ } else {
30
+ lines.push(" • " + e.claim + " " + c("33", "(ohne Beleg — nicht als Beweis gewertet)"));
31
+ }
32
+ }
33
+ }
34
+ if ((r.verdict === RISK || r.verdict === WARN) && r.fix) {
35
+ const why = typeof r.fix === "string" ? r.fix : (r.fix.why || "Dante kann das schließen.");
36
+ lines.push(" " + c("36", "vorschlag:") + " " + why + dim(" (Plan + ja/nein mit --fix)"));
37
+ }
38
+ return lines.join("\n");
39
+ }
40
+
41
+ export function renderSummary(results, target) {
42
+ const n = (v) => results.filter((r) => r.verdict === v).length;
43
+ const parts = [];
44
+ if (n(RISK)) parts.push(c("41;37", ` ${n(RISK)} offen `));
45
+ if (n(WARN)) parts.push(c("43;30", ` ${n(WARN)} Schritt nötig `));
46
+ if (n(UNKNOWN)) parts.push(c("100;37", ` ${n(UNKNOWN)} unbekannt `));
47
+ if (n(SAFE)) parts.push(c("42;30", ` ${n(SAFE)} sicher `));
48
+ return "\n" + bold("Dante · Wache über ") + bold(target) + "\n" + (parts.join(" ") || "keine Checks") +
49
+ "\n" + dim("Jedes Urteil steht auf einer echten Zeile. Fehlt die Zeile, sagt Dante „unbekannt“, nie „sicher“.");
50
+ }
51
+
52
+ function truncate(s, n) {
53
+ s = String(s).replace(/\s+/g, " ");
54
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
55
+ }
@@ -0,0 +1,61 @@
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.
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ let PREFIX = null; // e.g. ["ssh","-i","/key","root@host"] — set by configureTarget()
7
+
8
+ export function configureTarget(ssh) {
9
+ // ssh: null (local) | {host, key?} — we build an argv prefix, never a shell string.
10
+ if (!ssh) { PREFIX = null; return; }
11
+ const p = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
12
+ if (ssh.key) p.push("-i", ssh.key);
13
+ p.push(ssh.host);
14
+ PREFIX = p;
15
+ }
16
+
17
+ export function targetLabel() {
18
+ return PREFIX ? PREFIX[PREFIX.length - 1] : "localhost";
19
+ }
20
+
21
+ /**
22
+ * Run a shell SCRIPT (one string). Locally via `bash -lc <script>`, remotely via `ssh host <script>`
23
+ * where the remote login shell parses the exact same string — no fragile argv joining.
24
+ * Returns { cmd, out, err, code, ok, ran }. `ran` = the process actually executed (vs ssh/spawn error).
25
+ * ok=true only means it exited 0 — the caller decides what the lines mean.
26
+ */
27
+ export function run(script, { timeout = 15000, input } = {}) {
28
+ const s = Array.isArray(script) ? script.join(" ") : String(script);
29
+ const argv = PREFIX ? [...PREFIX, s] : ["bash", "-lc", s];
30
+ const bin = argv[0];
31
+ const args = argv.slice(1);
32
+ const r = spawnSync(bin, args, { encoding: "utf8", timeout, input, maxBuffer: 8 * 1024 * 1024 });
33
+ const out = (r.stdout || "").trimEnd();
34
+ const err = (r.stderr || "").trimEnd();
35
+ return {
36
+ cmd: s,
37
+ out,
38
+ err,
39
+ code: typeof r.status === "number" ? r.status : (r.error ? -1 : null),
40
+ ok: r.status === 0,
41
+ ran: typeof r.status === "number", // process ran and returned an exit code (not an ssh/spawn failure)
42
+ failed: !!r.error,
43
+ errorMsg: r.error ? String(r.error.message) : "",
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Prove the channel before concluding anything. Dante must get its own nonce back from the target.
49
+ * No nonce → the target was never reached, and "nothing found" means nothing. Returns {reached, line}.
50
+ */
51
+ export function probe() {
52
+ const nonce = "dnt-" + Math.random().toString(36).slice(2, 10);
53
+ const r = run("echo " + nonce, { timeout: 12000 });
54
+ const reached = r.ran && r.ok && r.out.includes(nonce);
55
+ return { reached, line: reached ? "Kanal bestätigt (" + targetLabel() + ")" : firstLine(r.err || r.errorMsg || r.out) || "keine Antwort" };
56
+ }
57
+
58
+ /** First non-empty line of a command's output, for a compact Beleg. */
59
+ export function firstLine(s) {
60
+ return String(s || "").split("\n").map((x) => x.trim()).find(Boolean) || "";
61
+ }
package/src/mascot.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // Dante — the pilgrim who walked through Hell and came back with the map.
2
+ // Hand-pixeled sprite, 256-colour half-blocks (two pixel rows per text line). No library.
3
+ // Companion to Virgil's lantern-bearer; Dante wears the red robe and the laurel.
4
+ const PAL = {
5
+ ".": null, // transparent
6
+ L: 100, // laurel (olive green)
7
+ s: 180, // skin / face
8
+ R: 124, // red robe
9
+ r: 160, // robe highlight
10
+ m: 230, // the map/scroll he carries back
11
+ e: 16, // eyes / shadow
12
+ };
13
+ // 14 rows × 14 cols → 7 text rows.
14
+ const ART = [
15
+ "...LL....LL...",
16
+ "..LLLsssLLL...",
17
+ "...LsssssL....",
18
+ "...sseesess...",
19
+ "...ssssssss...",
20
+ "....RRRRRR....",
21
+ "...RRrrrrRR.mm",
22
+ "..RRrrrrrrRmm.",
23
+ "..RRrrrrrrRR..",
24
+ "..RRrrrrrrRR..",
25
+ "..RRRrrrrRRR..",
26
+ "..RRRRRRRRRR..",
27
+ "..RRRR..RRRR..",
28
+ ".RRRR....RRRR.",
29
+ ];
30
+
31
+ export function danteSprite() {
32
+ const out = [];
33
+ const px = (ch) => PAL[ch];
34
+ for (let y = 0; y < ART.length; y += 2) {
35
+ let line = "";
36
+ const top = ART[y], bot = ART[y + 1] || ".".repeat(top.length);
37
+ for (let x = 0; x < top.length; x++) {
38
+ const t = px(top[x]), b = px(bot[x]);
39
+ if (t == null && b == null) line += " ";
40
+ else if (t != null && b != null) line += `\x1b[38;5;${t};48;5;${b}m▀\x1b[0m`;
41
+ else if (t != null) line += `\x1b[38;5;${t}m▀\x1b[0m`;
42
+ else line += `\x1b[38;5;${b}m▄\x1b[0m`;
43
+ }
44
+ out.push(line);
45
+ }
46
+ return out.join("\n");
47
+ }
48
+
49
+ export function banner() {
50
+ const tty = process.stdout.isTTY && !process.env.NO_COLOR;
51
+ const name = tty ? "\x1b[1mDante\x1b[0m \x1b[2m· wächter · er geht durch die Hölle und bringt die Karte\x1b[0m" : "Dante · wächter";
52
+ if (!tty) return name;
53
+ return danteSprite() + "\n" + name;
54
+ }