#!/usr/bin/env node // Check Streamliner config before anyone sees it: an account folder (tenant.json, agents/, private/…) or one personal // *.streamline.json file. Node 20+, no dependencies. // // node check.mjs [--json] [--dev] [--checker ] // // Runs the full Streamliner checker (configcheck: JSON Schemas + every cross-file rule) when it can find it: // --checker , $STREAMLINE_CHECK, ./streamline-check.mjs or ./vendor/scripts/configcheck.mjs beside this script (bundled in the skill zip), or // scripts/configcheck.mjs in a Streamline checkout above the current directory. // Otherwise it runs the SAFETY SUBSET below and says so (not a substitute for the full check before a publish): // JSON parses; no secrets (provider keys, private keys, JWT-shaped strings); no card fields outside demo accounts; // no http:// except localhost with --dev; every write tool confirm-gated or deliberately not; overview and preview // tools read-only; renderers/templates/forms referenced exist; instructions not in device-visible files. // Exit 0 clean (warnings allowed), 1 errors, 2 usage. import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { join, dirname, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; const HERE = dirname(fileURLToPath(import.meta.url)); const argv = process.argv.slice(2); const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; }; const target = argv.find((a, i) => !a.startsWith("--") && argv[i - 1] !== "--checker"); if (!target || !existsSync(target)) { console.error("usage: node check.mjs [--json] [--dev] [--checker path]"); process.exit(2); } const asJSON = argv.includes("--json"), dev = argv.includes("--dev"); // ── 1. the full checker, if present ── function findChecker() { const c = [opt("checker"), process.env.STREAMLINE_CHECK, join(HERE, "streamline-check.mjs"), join(HERE, "vendor/scripts/configcheck.mjs")]; for (let d = resolve(process.cwd()); ; d = dirname(d)) { c.push(join(d, "scripts/configcheck.mjs")); if (dirname(d) === d) break; } return c.find((p) => p && existsSync(p)); } const checker = findChecker(); if (checker) { const args = [checker, target, ...(asJSON ? ["--json"] : [])]; // configcheck reports http://localhost as a warning itself if (!asJSON) console.error(`check: running ${relative(process.cwd(), checker) || checker}`); const r = spawnSync(process.execPath, args, { stdio: "inherit" }); process.exit(r.status ?? 1); } // ── 2. the safety subset ── const errors = [], warnings = []; const err = (file, path, code, message, fix) => errors.push({ file, path, code, message, ...(fix ? { fix } : {}) }); const warn = (file, path, code, message, fix) => warnings.push({ file, path, code, message, ...(fix ? { fix } : {}) }); const SECRET = [[/sk-ant-[A-Za-z0-9_-]{8,}/, "an Anthropic API key"], [/\bsk-[A-Za-z0-9]{20,}/, "an OpenAI-style API key"], [/\bgsk_[A-Za-z0-9]{20,}/, "a Groq API key"], [/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key"], [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"], [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, "a JWT (a bearer token)"], [/\bxox[abpr]-[A-Za-z0-9-]{10,}/, "a Slack token"], [/\bgh[pousr]_[A-Za-z0-9]{30,}/, "a GitHub token"]]; const CARD_TYPES = new Set(["cardNumber", "expiry", "cvc"]); const ptr = (parts) => "/" + parts.map((p) => String(p).replace(/~/g, "~0").replace(/\//g, "~1")).join("/"); function scan(file, v, parts = []) { if (typeof v === "string") { for (const [re, what] of SECRET) if (re.test(v)) err(file, ptr(parts), "secret", `looks like ${what}; config must never hold a key or token`, "remove it; name provider keys with credentialRef and let the owner set the key in the account"); if (/^http:\/\//.test(v) && !(dev && /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(v))) err(file, ptr(parts), "http", "plain http:// URL", "use https:// (http://localhost only with --dev, for local testing)"); if (/^d$/.test(String(parts.at(-1))) && /^[A-Za-z0-9_-]{40,}$/.test(v)) err(file, ptr(parts), "secret", "looks like the private half of a JWK", "remove it; only public keys belong in config"); } else if (Array.isArray(v)) v.forEach((x, i) => scan(file, x, [...parts, i])); else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) scan(file, x, [...parts, k]); } const readJSON = (root, p) => { try { return JSON.parse(readFileSync(join(root, p), "utf8")); } catch (e) { err(p, "", "bad_json", e.message); return null; } }; const glob = (g) => new RegExp("^" + g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"); function checkTemplate(file, node, parts = ["root"], depth = 1) { if (!node || typeof node !== "object") return; if (depth > 12) err(file, ptr(parts), "template_depth", "template deeper than 12"); const TYPES = ["stack", "row", "text", "badge", "money", "image", "list", "keyValue", "divider", "spacer", "button"]; if (!TYPES.includes(node.type)) err(file, ptr([...parts, "type"]), "template_node", `unknown node type ${node.type}`, `one of ${TYPES.join(", ")}`); const FORMATTERS = ["money", "number", "percent", "date", "time", "datetime", "phone", "mask", "upper", "lower", "title", "default", "prefix", "suffix", "count", "plural"]; for (const k of ["text", "label"]) if (typeof node[k] === "string") for (const m of node[k].matchAll(/\{\{([^}]*)\}\}/g)) for (const f of m[1].split("|").slice(1).map((s) => s.trim().split(":")[0])) if (!FORMATTERS.includes(f)) err(file, ptr([...parts, k]), "formatter", `unknown formatter ${f}`, FORMATTERS.join(", ")); for (const k of ["children"]) (node[k] || []).forEach((c, i) => checkTemplate(file, c, [...parts, k, i], depth + 1)); if (node.item) checkTemplate(file, node.item, [...parts, "item"], depth + 1); } function policyFor(name, tools) { for (const k of ["hidden", "confirm", "confirmWhenArgs", "readOnly"]) if ((tools?.[k] || []).some((g) => glob(g).test(name))) return k; return null; } let stat = statSync(target); if (stat.isFile()) { // a personal *.streamline.json const file = target.split(sep).pop(); const s = readJSON(dirname(target), file); if (s) { scan(file, s); if (!s.name) err(file, "/name", "required", "a streamline needs a name"); if (s.schema && !/^streamline\.personal\/1$/.test(s.schema)) err(file, "/schema", "schema", `unknown schema ${s.schema}`, "streamline.personal/1"); if (s.id && !/^[a-z0-9_]{2,32}$/.test(s.id)) err(file, "/id", "pattern", "id must match [a-z0-9_]{2,32}"); if (s.model?.credentialRef && !/^vault:/.test(s.model.credentialRef)) err(file, "/model/credentialRef", "credential", "a personal streamline names only vault:default or vault:"); (s.mcp || []).forEach((m, i) => { if (!m.url) err(file, ptr(["mcp", i, "url"]), "required", "server url missing"); if (!m.tools) warn(file, ptr(["mcp", i, "tools"]), "no_policy", "no tool policy: every tool the server does not annotate read-only will ask for a tap", "add tools.readOnly / confirm / confirmWhenArgs / hidden after inspecting the server"); for (const [t, tid] of Object.entries(m.renderers || {})) if (!(s.templates || {})[tid]) err(file, ptr(["mcp", i, "renderers", t]), "missing_template", `template ${tid} is not in templates`); for (const d of m.tools?.direct || []) if (policyFor(d, m.tools) === "confirm" || policyFor(d, m.tools) === "hidden") warn(file, ptr(["mcp", i, "tools", "direct"]), "direct_gated", `${d} is direct but also ${policyFor(d, m.tools)}: the button will still wait for a tap or be refused`); }); for (const [id, t] of Object.entries(s.templates || {})) checkTemplate(file, t.root || t, ["templates", id, "root"]); } } else { const root = target; const walk = (d) => readdirSync(d).flatMap((n) => { const p = join(d, n); return statSync(p).isDirectory() ? walk(p) : n.startsWith(".") ? [] : [p]; }); const paths = walk(root).map((f) => relative(root, f).split(sep).join("/")); const J = {}; for (const p of paths.filter((p) => p.endsWith(".json"))) { const j = readJSON(root, p); if (j) { J[p] = j; scan(p, j); } } const tenant = J["tenant.json"]; if (!tenant) err("tenant.json", "", "required", "an account folder needs tenant.json"); else { const demo = tenant.mode === "demo"; const roles = new Set((tenant.roles || []).map((r) => r.id)); for (const kind of tenant.kinds || []) { const pub = J[`agents/${kind}.json`], priv = J[`private/agents/${kind}.json`]; if (!pub) { err("tenant.json", "/kinds", "missing_file", `agents/${kind}.json is missing`); continue; } if (pub.id !== kind) err(`agents/${kind}.json`, "/id", "id", `id must be ${kind}`); if (pub.instructions) err(`agents/${kind}.json`, "/instructions", "instructions_public", "instructions belong in private/agents/, never in a file devices download"); for (const r of pub.roles || []) if (!roles.has(r)) err(`agents/${kind}.json`, "/roles", "unknown_role", `role ${r} is not in tenant.json roles`); if (!priv) { if (!demo) err(`private/agents/${kind}.json`, "", "missing_file", "every streamline needs its private file (instructions, model, MCP allowlist)"); continue; } if (priv.model?.credentialRef && /^(sk-|gsk_)/.test(priv.model.credentialRef)) err(`private/agents/${kind}.json`, "/model/credentialRef", "secret", "credentialRef must NAME a key (e.g. anthropic-main), not be one"); const tools = new Map(); (priv.mcp || []).forEach((m, i) => (m.tools || []).forEach((t, j) => tools.set(t.name, { ...t, at: ["mcp", i, "tools", j] }))); const tmpl = (id, file, path) => { if (id && !J[`templates/${id}.json`]) err(file, path, "missing_template", `templates/${id}.json does not exist`); }; const ov = pub.overview; if (ov) { const t = tools.get(ov.source?.tool); if (!t) err(`agents/${kind}.json`, "/overview/source/tool", "not_allowlisted", `${ov.source?.tool} is not in the private allowlist`); else if (!t.readOnly) err(`agents/${kind}.json`, "/overview/source/tool", "overview_not_readonly", `${ov.source.tool} must be readOnly: the app calls it with no model`); tmpl(ov.template, `agents/${kind}.json`, "/overview/template"); tmpl(ov.compactTemplate, `agents/${kind}.json`, "/overview/compactTemplate"); for (const g of ov.refreshOn || []) if (![...tools.keys()].some((n) => glob(g).test(n))) warn(`agents/${kind}.json`, "/overview/refreshOn", "refresh_unmatched", `${g} matches no allowlisted tool`); } for (const [tool, id] of Object.entries(pub.renderers || {})) { if (!tools.has(tool)) err(`agents/${kind}.json`, ptr(["renderers", tool]), "not_allowlisted", `${tool} is not in the private allowlist`); tmpl(id, `agents/${kind}.json`, ptr(["renderers", tool])); } for (const t of pub.completion?.tools || []) if (!tools.has(t)) err(`agents/${kind}.json`, "/completion/tools", "not_allowlisted", `${t} is not in the private allowlist`); for (const id of [...(pub.forms || []), ...(pub.views || [])]) if (!J[`forms/${id}.json`]) err(`agents/${kind}.json`, "/forms", "missing_form", `forms/${id}.json does not exist`); for (const [name, t] of tools) { const file = `private/agents/${kind}.json`; if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) err(file, ptr(t.at), "tool_name", `bad tool name ${name}`); if (t.confirm) { tmpl(t.confirm.template, file, ptr([...t.at, "confirm", "template"])); if (t.confirm.preview && !tools.get(t.confirm.preview)?.readOnly) err(file, ptr([...t.at, "confirm", "preview"]), "preview_not_readonly", `preview ${t.confirm.preview} must be an allowlisted readOnly tool`); } for (const r of t.roles || []) if (!roles.has(r)) err(file, ptr([...t.at, "roles"]), "unknown_role", `role ${r} is not in tenant.json roles`); if (!t.readOnly && !t.confirm && !t.direct) warn(file, ptr(t.at), "write_not_confirmed", `${name} is neither readOnly nor confirm-gated: if it changes money or records, add confirm (law 5); if it only reads, mark readOnly`); } if (pub.network?.restricted && !priv.network) warn(`agents/${kind}.json`, "/network/restricted", "network_mismatch", "public says restricted but the private file has no network rule (the account default applies)"); } for (const p of paths.filter((p) => p.startsWith("forms/"))) { const f = J[p]; if (!f) continue; (f.fields || []).forEach((x, i) => { if (CARD_TYPES.has(x.type) && !demo) err(p, ptr(["fields", i, "type"]), "card_field", `${x.type} fields exist only for demo accounts`, "use a hosted form (the processor's page) or the store's terminal"); }); if (f.kind === "form" && !f.receipt?.keep) err(p, "/receipt/keep", "required", "a sealed form needs receipt.keep: only those keys reach the model"); for (const [k, re] of Object.entries(f.receipt?.keep || {})) { try { new RegExp(re); } catch { err(p, ptr(["receipt", "keep", k]), "regex", `bad regex for ${k}`); } } } for (const p of paths.filter((p) => p.startsWith("templates/"))) if (J[p]) checkTemplate(p, J[p].root, ["root"]); const pt = J["private/tenant.json"]; for (const k of pt?.serverKeys || []) if (k.d || k.privateKey) err("private/tenant.json", "/serverKeys", "secret", `server key ${k.id} carries a private half`, "only the public half (publicKey) belongs here"); } } const ok = errors.length === 0; if (asJSON) console.log(JSON.stringify({ ok, checker: "safety-subset", errors, warnings }, null, 2)); else { console.log(`check: SAFETY SUBSET only (the full configcheck was not found; run it before publishing)`); for (const e of errors) console.log(`error ${e.file} ${e.path}: ${e.message}${e.fix ? ` — fix: ${e.fix}` : ""}`); for (const w of warnings) console.log(`warning ${w.file} ${w.path}: ${w.message}${w.fix ? ` — fix: ${w.fix}` : ""}`); console.log(ok ? `ok (${warnings.length} warning${warnings.length === 1 ? "" : "s"})` : `${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`); } process.exit(ok ? 0 : 1);