feat: spawn headless claude ingest with filename validation
This commit is contained in:
102
.obsidian/plugins/webinar-dash/main.js
vendored
102
.obsidian/plugins/webinar-dash/main.js
vendored
@@ -156,6 +156,19 @@ function reconcileConcepts(rows, conceptNames) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `shell: true` is required on Windows to resolve `claude.cmd`, which puts the
|
||||||
|
// filename into a shell string. Reject anything cmd.exe or a POSIX shell would
|
||||||
|
// interpret. Apostrophes and cyrillic are safe inside double quotes and are
|
||||||
|
// present in real filenames, so they stay allowed.
|
||||||
|
const UNSAFE_CHARS = /["`$&|;<>%\r\n]/;
|
||||||
|
|
||||||
|
function isSafeFilename(name) {
|
||||||
|
if (typeof name !== "string" || name.length === 0) return false;
|
||||||
|
if (UNSAFE_CHARS.test(name)) return false;
|
||||||
|
if (name.includes("..")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function parseConfig(source) {
|
function parseConfig(source) {
|
||||||
const cfg = Object.assign({}, DEFAULTS);
|
const cfg = Object.assign({}, DEFAULTS);
|
||||||
for (const line of String(source).split(/\r?\n/)) {
|
for (const line of String(source).split(/\r?\n/)) {
|
||||||
@@ -341,7 +354,7 @@ class WebinarDashPlugin extends PluginBase {
|
|||||||
const root = el.createDiv({ cls: "webinar-dash" });
|
const root = el.createDiv({ cls: "webinar-dash" });
|
||||||
try {
|
try {
|
||||||
const pipeline = await this.readPipeline(cfg);
|
const pipeline = await this.readPipeline(cfg);
|
||||||
renderLeftPane(root, pipeline, () => {});
|
renderLeftPane(root, pipeline, (file, rowEl) => this.runIngest(file, rowEl));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
root.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
|
root.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
|
||||||
}
|
}
|
||||||
@@ -390,6 +403,91 @@ class WebinarDashPlugin extends PluginBase {
|
|||||||
|
|
||||||
return derivePipeline({ rawFiles, sourcePages });
|
return derivePipeline({ rawFiles, sourcePages });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vaultPath() {
|
||||||
|
const adapter = this.app.vault.adapter;
|
||||||
|
if (typeof adapter.getBasePath === "function") return adapter.getBasePath();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
runIngest(file, rowEl) {
|
||||||
|
if (rowEl.dataset.wdRunning === "1") return;
|
||||||
|
|
||||||
|
const Notice = OB ? OB.Notice : null;
|
||||||
|
const notify = (msg) => { if (Notice) new Notice(msg); };
|
||||||
|
|
||||||
|
if (!isSafeFilename(file.name)) {
|
||||||
|
rowEl.createDiv({
|
||||||
|
cls: "wd-output",
|
||||||
|
text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = this.vaultPath();
|
||||||
|
if (!base) {
|
||||||
|
notify("Ingest needs desktop Obsidian.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let spawn;
|
||||||
|
try {
|
||||||
|
({ spawn } = require("child_process"));
|
||||||
|
} catch (_) {
|
||||||
|
notify("child_process unavailable — ingest needs desktop Obsidian.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowEl.dataset.wdRunning = "1";
|
||||||
|
const button = rowEl.querySelector("button");
|
||||||
|
if (button) button.disabled = true;
|
||||||
|
|
||||||
|
const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
|
||||||
|
const started = Date.now();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const output = rowEl.createDiv({ cls: "wd-output", text: "" });
|
||||||
|
let buffered = "";
|
||||||
|
const append = (chunk) => {
|
||||||
|
buffered += chunk.toString();
|
||||||
|
output.setText(buffered.slice(-4000));
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const child = spawn("claude", ["-p", `ingest "${file.name}"`], {
|
||||||
|
cwd: base,
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on("data", append);
|
||||||
|
child.stderr.on("data", append);
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
status.className = "wd-status wd-status-failed";
|
||||||
|
status.setText("failed");
|
||||||
|
append(`\nCould not start claude: ${err.message}\nIs it on PATH?`);
|
||||||
|
rowEl.dataset.wdRunning = "0";
|
||||||
|
if (button) button.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
const secs = Math.round((Date.now() - started) / 1000);
|
||||||
|
if (code === 0) {
|
||||||
|
status.className = "wd-status wd-status-done";
|
||||||
|
status.setText(`done in ${secs}s`);
|
||||||
|
notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`);
|
||||||
|
} else {
|
||||||
|
status.className = "wd-status wd-status-failed";
|
||||||
|
status.setText(`failed - exit ${code}`);
|
||||||
|
}
|
||||||
|
rowEl.dataset.wdRunning = "0";
|
||||||
|
if (button) button.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = WebinarDashPlugin;
|
module.exports = WebinarDashPlugin;
|
||||||
@@ -397,5 +495,5 @@ module.exports.default = WebinarDashPlugin;
|
|||||||
module.exports.__test__ = {
|
module.exports.__test__ = {
|
||||||
parseConfig, extractRawPath, derivePipeline,
|
parseConfig, extractRawPath, derivePipeline,
|
||||||
parseCoverageTable, groupByStation, reconcileConcepts,
|
parseCoverageTable, groupByStation, reconcileConcepts,
|
||||||
STATIONS, DEFAULTS,
|
STATIONS, DEFAULTS, isSafeFilename,
|
||||||
};
|
};
|
||||||
|
|||||||
11
.obsidian/plugins/webinar-dash/styles.css
vendored
11
.obsidian/plugins/webinar-dash/styles.css
vendored
@@ -160,3 +160,14 @@
|
|||||||
.wd-partial { color: var(--wd-warn); }
|
.wd-partial { color: var(--wd-warn); }
|
||||||
.wd-absent { color: var(--wd-danger); }
|
.wd-absent { color: var(--wd-danger); }
|
||||||
.wd-pin { font-family: var(--wd-mono); font-size: 10px; color: var(--wd-fg-4); }
|
.wd-pin { font-family: var(--wd-mono); font-size: 10px; color: var(--wd-fg-4); }
|
||||||
|
|
||||||
|
.wd-status { font-family: var(--wd-mono); font-size: 11px; flex: none; }
|
||||||
|
.wd-status-running { color: var(--wd-warn); }
|
||||||
|
.wd-status-done { color: var(--wd-ok); }
|
||||||
|
.wd-status-failed { color: var(--wd-danger); }
|
||||||
|
.wd-output {
|
||||||
|
font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap;
|
||||||
|
max-height: 220px; overflow: auto; margin-top: 8px;
|
||||||
|
border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px;
|
||||||
|
color: var(--wd-fg-2);
|
||||||
|
}
|
||||||
|
|||||||
39
.obsidian/plugins/webinar-dash/test/safety.test.js
vendored
Normal file
39
.obsidian/plugins/webinar-dash/test/safety.test.js
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use strict";
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { isSafeFilename } = require("../main.js").__test__;
|
||||||
|
|
||||||
|
test("isSafeFilename accepts every filename currently in the vault", () => {
|
||||||
|
const real = [
|
||||||
|
"Agentic Engineering, explained by a 10x developer.md",
|
||||||
|
"Webinar Plan - From Chat Box to Your Own OS.md",
|
||||||
|
"Webinar script.md",
|
||||||
|
"You're reading way too much code.md",
|
||||||
|
"ИИ глупый!.md",
|
||||||
|
"Скиллы на базе git — новая память AI-агентов.md",
|
||||||
|
"sebastian interview - conclusions and insights.md",
|
||||||
|
"In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md",
|
||||||
|
];
|
||||||
|
for (const name of real) {
|
||||||
|
assert.equal(isSafeFilename(name), true, `should accept: ${name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isSafeFilename rejects shell metacharacters", () => {
|
||||||
|
for (const bad of ['a".md', "a`b.md", "a$b.md", "a&b.md", "a|b.md", "a;b.md",
|
||||||
|
"a<b.md", "a>b.md", "a%b.md", "a\nb.md", "a\rb.md"]) {
|
||||||
|
assert.equal(isSafeFilename(bad), false, `should reject: ${JSON.stringify(bad)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isSafeFilename rejects path traversal", () => {
|
||||||
|
assert.equal(isSafeFilename("../secrets.md"), false);
|
||||||
|
assert.equal(isSafeFilename("a/../../b.md"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isSafeFilename rejects empty and non-string input", () => {
|
||||||
|
assert.equal(isSafeFilename(""), false);
|
||||||
|
assert.equal(isSafeFilename(null), false);
|
||||||
|
assert.equal(isSafeFilename(undefined), false);
|
||||||
|
assert.equal(isSafeFilename(42), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user