#!/usr/bin/env node /** * Setup: create/update a daily 23:00 cron job that * - runs openclaw security audits * - DMs a chosen recipient (channel+id) * - optionally emails a configured recipient via sendmail/SMTP * * Uses the `openclaw cron` CLI so it can run on a host without direct Gateway RPC access. */ import { spawnSync as runProcessSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import readline from "node:readline"; import { fileURLToPath } from "node:url"; const JOB_NAME = "Daily security audit (Prompt Security)"; const DEFAULT_TZ = "UTC"; const DEFAULT_EXPR = "0 23 * * *"; // 23:00 daily const PERSISTED_ENV_KEYS = [ "PROMPTSEC_EMAIL_TO", "PROMPTSEC_GIT_PULL", "OPENCLAW_AUDIT_CONFIG", "PROMPTSEC_SENDMAIL_BIN", "PROMPTSEC_SMTP_HOST", "PROMPTSEC_SMTP_PORT", "PROMPTSEC_SMTP_HELO", "PROMPTSEC_SMTP_FROM", ]; const SCRIPT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const UNEXPANDED_HOME_TOKEN_PATTERN = /(?:^|[\\/])(?:\\?\$HOME|\\?\$\{HOME\}|\\?\$USERPROFILE|\\?\$\{USERPROFILE\}|%HOME%|%USERPROFILE%|\$env:HOME|\$env:USERPROFILE)(?:$|[\\/])/i; function sh(cmd, args, { input } = {}) { const res = runProcessSync(cmd, args, { encoding: "utf8", input: input ?? undefined, stdio: [input ? "pipe" : "ignore", "pipe", "pipe"], }); if (res.error) throw res.error; if (res.status !== 0) { const msg = (res.stderr || res.stdout || "").trim(); throw new Error(`${cmd} ${args.join(" ")} failed (code ${res.status})${msg ? `: ${msg}` : ""}`); } return res.stdout; } async function prompt(question, { defaultValue = "" } = {}) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const q = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `; const answer = await new Promise((resolve) => rl.question(q, resolve)); rl.close(); const trimmed = String(answer ?? "").trim(); return trimmed || defaultValue; } function envOrEmpty(name) { const v = process.env[name]; return typeof v === "string" ? v.trim() : ""; } function detectHomeDirectory() { const home = envOrEmpty("HOME"); if (home) return home; const userProfile = envOrEmpty("USERPROFILE"); if (userProfile) return userProfile; const homeDrive = envOrEmpty("HOMEDRIVE"); const homePath = envOrEmpty("HOMEPATH"); if (homeDrive && homePath) return `${homeDrive}${homePath}`; return os.homedir(); } function resolveUserPath(inputPath, label) { const raw = String(inputPath ?? "").trim(); if (!raw) return raw; const homeDir = detectHomeDirectory(); let expanded = raw; if (expanded === "~") { expanded = homeDir; } else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) { expanded = path.join(homeDir, expanded.slice(2)); } expanded = expanded .replace(/(? String(value ?? "").trim() !== "") .map(([key, value]) => `${key}="${escapeForShellEnvVar(value)}"`); const exportPrefix = exports.length ? `${exports.join(" ")} ` : ""; return `cd "${escapeForShellEnvVar(installDir || "")}" && ${exportPrefix}./scripts/runner.sh`; } function printPreflightSummary({ dmChannel, dmTo, emailTo, installDir, tz, hostLabel }) { const emailSummary = emailTo || "disabled (set PROMPTSEC_EMAIL_TO to enable)"; const persistedKeys = Array.from(new Set([ "PROMPTSEC_HOST_LABEL", emailTo ? "PROMPTSEC_EMAIL_TO" : null, ...PERSISTED_ENV_KEYS.filter((key) => envOrEmpty(key)), ].filter(Boolean))); const lines = [ "Preflight review:", "- This setup creates or updates an unattended openclaw cron job.", "- Required runtime: openclaw CLI, node, bash.", "- Optional email runtime: local sendmail or PROMPTSEC_SMTP_HOST/PROMPTSEC_SMTP_PORT relay.", `- DM target: ${oneline(dmChannel)}:${oneline(dmTo)}`, `- Email target: ${oneline(emailSummary)}`, `- Schedule: ${DEFAULT_EXPR} (${oneline(tz)})`, `- Install dir: ${oneline(installDir)}`, ]; if (hostLabel) { lines.push(`- Host label: ${oneline(hostLabel)}`); } if (persistedKeys.length) { lines.push(`- Cron payload persists env: ${persistedKeys.join(", ")}`); } process.stdout.write(lines.join("\n") + "\n\n"); } function defaultInstallDir() { const env = envOrEmpty("PROMPTSEC_INSTALL_DIR"); if (env) return resolveUserPath(env, "PROMPTSEC_INSTALL_DIR"); const home = detectHomeDirectory(); if (home) return path.join(home, ".config", "security-checkup"); return resolveUserPath(SCRIPT_ROOT, "script root"); } function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir, emailTo }) { const runnerCommand = buildRunnerCommand({ installDir, hostLabel, emailTo }); const emailLine = emailTo ? `Email: ${oneline(emailTo)} (sendmail first, SMTP fallback if configured)` : "Email: disabled unless PROMPTSEC_EMAIL_TO is set"; return [ "Run daily openclaw security audits and deliver report to the configured recipients.", "", "Dependencies:", "- Required runtime: openclaw CLI, node, bash.", "- Optional email runtime: local sendmail or PROMPTSEC_SMTP_HOST/PROMPTSEC_SMTP_PORT relay.", "", "Configured delivery:", `Delivery DM: ${oneline(dmChannel)}:${oneline(dmTo)}`, emailLine, "", "Execute:", `- Run via exec: ${runnerCommand}`, "", "Output requirements:", "- Print the report to stdout (cron deliver will DM it).", "- If PROMPTSEC_EMAIL_TO is set, email the same report to that address; if email fails, append a NOTE line to stdout.", "- Do not apply fixes automatically.", ].join("\n"); } function buildDescription({ dmChannel, dmTo, emailTo }) { const emailPart = emailTo ? `; email ${emailTo}` : "; email disabled unless configured"; return `Runs openclaw security audit daily and delivers to ${dmChannel}:${dmTo}${emailPart}.`; } function findExistingJobId(listJson) { const jobs = Array.isArray(listJson?.jobs) ? listJson.jobs : []; const match = jobs.find((j) => j?.name === JOB_NAME); return match?.id ?? null; } async function run() { // Non-interactive first (MDM-friendly) const tzEnv = envOrEmpty("PROMPTSEC_TZ"); const dmChannelEnv = envOrEmpty("PROMPTSEC_DM_CHANNEL"); const dmToEnv = envOrEmpty("PROMPTSEC_DM_TO"); const hostLabelEnv = envOrEmpty("PROMPTSEC_HOST_LABEL"); const emailToEnv = envOrEmpty("PROMPTSEC_EMAIL_TO"); const interactive = !(tzEnv && dmChannelEnv && dmToEnv); const tz = interactive ? await prompt("Timezone for daily 11pm run (IANA)", { defaultValue: tzEnv || DEFAULT_TZ }) : tzEnv || DEFAULT_TZ; const dmChannel = interactive ? await prompt("DM channel (e.g. telegram, slack, discord)", { defaultValue: dmChannelEnv }) : dmChannelEnv; const dmTo = interactive ? await prompt("DM recipient id (Telegram numeric chatId/userId preferred)", { defaultValue: dmToEnv }) : dmToEnv; const hostLabel = interactive ? await prompt("Optional host label to include in report", { defaultValue: hostLabelEnv }) : hostLabelEnv; const emailTo = interactive ? await prompt("Optional email recipient (leave empty to disable email)", { defaultValue: emailToEnv }) : emailToEnv; const installDirDefault = defaultInstallDir(); const installDirInput = interactive ? await prompt("Install dir containing scripts/runner.sh", { defaultValue: installDirDefault }) : installDirDefault; const installDir = resolveUserPath(installDirInput, "install dir containing scripts/runner.sh"); if (!dmChannel || !dmTo) { throw new Error("Missing DM target. Set PROMPTSEC_DM_CHANNEL and PROMPTSEC_DM_TO (or run interactively). "); } const runnerPath = path.join(installDir, "scripts", "runner.sh"); if (!fs.existsSync(runnerPath)) { throw new Error(`runner.sh not found at ${runnerPath}; set PROMPTSEC_INSTALL_DIR to the deployed path`); } printPreflightSummary({ dmChannel, dmTo, emailTo, installDir, tz, hostLabel }); const listOut = sh("openclaw", ["cron", "list", "--json"]); const listJson = JSON.parse(listOut); const existingId = findExistingJobId(listJson); const agentMessage = buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir, emailTo }); const description = buildDescription({ dmChannel, dmTo, emailTo }); if (!existingId) { const args = [ "cron", "add", "--name", JOB_NAME, "--description", description, "--session", "isolated", "--wake", "now", "--cron", DEFAULT_EXPR, "--tz", tz, "--message", agentMessage, "--deliver", "--channel", dmChannel, "--to", dmTo, "--best-effort-deliver", "--post-prefix", "[daily security audit]", "--post-mode", "summary", "--json", ]; const out = sh("openclaw", args); const job = JSON.parse(out); process.stdout.write(`Created cron job ${job.id}: ${JOB_NAME}\n`); } else { const args = [ "cron", "edit", existingId, "--name", JOB_NAME, "--description", description, "--enable", "--session", "isolated", "--wake", "now", "--cron", DEFAULT_EXPR, "--tz", tz, "--message", agentMessage, "--deliver", "--channel", dmChannel, "--to", dmTo, "--best-effort-deliver", "--post-prefix", "[daily security audit]", ]; sh("openclaw", args); process.stdout.write(`Updated cron job ${existingId}: ${JOB_NAME}\n`); } } run().catch((err) => { process.stderr.write(String(err?.stack || err) + "\n"); process.exit(1); });