Skip to content

Latest commit

 

History

History
57 lines (40 loc) · 14 KB

SECURITY.md

File metadata and controls

57 lines (40 loc) · 14 KB

Security Policy

Reporting a vulnerability

Email security@heylua.ai with the details. Please do NOT open a public GitHub issue for security reports.

We aim to acknowledge within 2 business days and ship a fix within 30 days for high-severity issues.

Scope

This plugin's surface includes:

  • Hooks (hooks/*.mjs) — run as subprocesses of Claude Code on SessionStart, UserPromptSubmit, and Pre/PostToolUse(Bash) events.
  • MCP servers — mcp/lua-platform/dist/server.js, a local stdio server exposing 5 read-only tools (a single self-contained bundle since 1.6.0 — its npm dependencies, @modelcontextprotocol/sdk included, are inlined at build time from the lockfile); it talks to https://api.heylua.ai over HTTPS with the user's lua-cli credential (and, for a session login, refreshes the stored refresh token at Google's securetoken endpoint exactly like lua-cli does). lua-docs is the public remote MCP at https://docs.heylua.ai/mcp (read-only docs search).
  • Slash commands and subagents (commands/*.md, agents/*.md) — Markdown prompts that Claude reads and executes.
  • Permission rules (lib/permissions-template.json) — merged into the user's .claude/settings.json by /lua-doctor Step 5 with consent.

In scope for security reports:

  • Credential exposure (API keys, session tokens or one-time codes leaking into transcripts, logs, or external systems)
  • Production-gate bypasses (any of the gated verbs below succeeding without the documented confirmation)
  • Hook payload injection (malicious bash commands triggering unintended hook behaviour)
  • MCP server auth bypass

Out of scope:

Safety-critical contracts

Contract Where enforced
Production gate: lua deploy, lua skills|webhooks|jobs|preprocessors|postprocessors deploy, lua skills production|prod|prd|live deploy|publish (a non-interactive skill deploy since lua-cli 3.44.0; gated since 1.7.0), lua persona production deploy, lua workflows deploy|activate, lua version promote, lua mcp activate, lua marketplace template publish|apply are blocked in bare form; only the LUA_DEPLOY_CONFIRMED=1-prefixed form emitted after the user's confirmation runs. The gate covers every spelling lua-cli accepts: the action aliases from its aliases.ts (publish→deploy, on/enable→activate, submit/publish_version→template publish, deploy/fleet-apply/rollout→template apply, prod/prd/live→production) and all three installed binaries (lua, heylua, lua-ai) The hook is the gate. hooks/confirm-deploy.mjs runs on every Bash call (no if glob to drift), classifies with lib/tokenizer.mjs (the single source of truth) and exits 2 on a bare verb — a hook block takes precedence over any allow rule, including a broad Bash(lua *) in the user's own settings. Since 1.6.0 the classifier lexes the whole command. It recognises the binary in command position: after assignments, and as the command of a wrapper, launcher or keyword (sudo, env, timeout, xargs, npx lua, npx lua-cli, pnpm exec lua, node …/lua-cli/…, find -exec, if/then), at any path and in any case. That applies to every simple command of a &&/;/`
--auto-deploy is never allowed deny list (Bash(lua * --auto-deploy*) matches prefixed or not) + hooks/block-auto-deploy.mjs + hooks/confirm-deploy.mjs
Single-permission contract: each slash asks at most one prompt. For workflow run-control verbs (lua workflows start|approve|signal|resume|retry-step|resolve-step|raise-budget|cancel|deactivate|export|schedules|goals) that single prompt is the Bash permission prompt from the ask tier, which shows the exact command; /lua-workflow does not add an AskUserQuestion on top. /lua-env (lua env *; listings are masked by the CLI, set values are never echoed by the slash) and /lua-integrations (connect/update/disconnect/convert, webhook and MCP toggles) rely on the same ask-tier prompt scripts/lint-single-permission.mjs; ask tier of lib/permissions-template.json
Post-deploy smoke check fires after every verb that makes something live (SMOKE_LABELS in lib/tokenizer.mjs: deploy spellings, persona production deploy, workflows deploy, version promote, mcp activate); never in headless mode, which sends no production ping hooks/post-deploy-smoke.mjs (PostToolUse on every Bash call)
Headless mode (LUA_PLUGIN_HEADLESS=1): no hook message points at a slash command (they need AskUserQuestion or the Agent tool), and nothing blocked interactively is allowed lib/headless.mjs; test/hooks/headless.test.mjs
Credential isolation: account details, one-time codes and credentials never enter the conversation commands/lua-auth.md sends login to a private terminal; hooks/block-auth-configure.mjs; lua auth configure|key|logout denied in lib/permissions-template.json
Device credentials never enter the conversation: lua-cli 3.45.0 lua devices credential (aliases credentials, key) prints the secret to stdout unless --out <file> is given, so the plugin only ever runs the --out form, into a file it makes sure is git-ignored and never reads back hooks/block-device-secret.mjs (PreToolUse, if: Bash(*devices*)) blocks the command — any binary spelling, any action case, --help excepted — when it carries no --out <file>; scripts/lint-cli-flags.mjs fails on any shipped lua devices credential … shape without --out; the ask tier is the confirmation for the --out form; commands/lua-devices.md forbids cat/source/Read of the file. A belt, like the deploy gate: a command assembled at runtime is out of its reach
Device go-live: a pushed defineDevice is live on the agent's next turn with no publish, and devices are not part of agent versions lua push device … / lua push device-trigger … (every push-type alias) sit in the ask tier above the generic lua push * --ci --force* allow; publishing a device-trigger needs --auto-deploy, which stays denied — /lua-devices prints that line for the user's own terminal
Web-app commands: only lua apps new <name> and four exact npm commands run unprompted — npm --prefix src/apps/<name>/web install --ignore-scripts [@lua-ai-global/app-client], run typecheck, run build (no other package, script, flag, path or chain); no dependency install script runs unprompted hooks/approve-web-app.mjs approves them with anchored regexes (<name> = ^[a-z][a-z0-9-]{0,62}$) through the PreToolUse permissionDecision: "allow" envelope; anything else gets no decision and falls to Claude Code's prompt. Deliberately not a permission glob: * matches spaces, so Bash(npm --prefix src/apps/*/web install) admitted extra packages, a second --prefix and ../, and Bash(lua apps new *) any trailing text (both found by reviews before 1.8.0 merged). run typecheck / run build execute the project's own scripts and Vite config — the same trust as lua test --ci*. test/hooks/approve-web-app.test.mjs, test/lib/web-apps-permissions.test.mjs
Web-app routes write live Data, locally too /lua-test runs GET at once and a write route only after its one AskUserQuestion; lua-skill-builder tests GET only; lua apps dev is in no permission list and never started by the plugin; /lua-deploy webapp diffs the new agent version and stops before promoting if anything other than web apps would go live
Read-only MCP: no tool mutates platform state mcp/lua-platform/src/tools/* (GET routes and lua agents --json only)

If you find a way to bypass any of these without an explicit user prompt, please report it.

/lua-qa full: helper path and production consent (1.9.0)

  • Only the plugin's own helper runs unprompted. The permission template allows node *lua-agent-builder*/lib/qa/cli.mjs *. Because a glob cannot tell that file from another file with the same name, hooks/guard-qa-helper.mjs resolves the script's real path on every such call and blocks anything that is not this plugin's lib/qa/cli.mjs. It also blocks a call it cannot verify: a relative path, a node flag or NODE_OPTIONS in front of the script (either could load other code first), command substitution, or an unexpanded variable.
  • Production consent is confirmed by Claude Code, not by the model. The gate accepts only the exact option I consent to running this against production. The command that stamps it (--production-consent-text) matches an ask rule, which takes precedence over the helper allow rule, so the user approves it at a permission prompt. state.json stores only a SHA-256 of the run's consent token, and the helpers that touch production check the token against that hash.
  • The approved plan is sealed. The plan gate stores a hash of the persona cards and the three test plans. start-run, record, tool-test, flow-test and stress refuse to run if any of those files changed after the stamp. Before each spawn, tool and flow tests re-check their inputs for real-looking emails and URLs. A tool test of a tool with a side effect writes a ledger row.

Known platform-side exposure the plugin cannot close

lua chat -e sandbox uploads the invoking shell's entire environment. lua-cli 3.33.0 attaches loadEnvironmentVariables() — all of process.env merged with .env — as the env of every sandbox skill version it pushes (src/utils/sandbox.ts ~149-172, src/services/sandbox.service.ts ~232-238). lua-api and lua-agents accept the field unfiltered (ValidationPipe whitelist:false) and lua-agents caches it with the sandbox version in Redis for about 24 h; the runtime never reads it (a sandbox turn's env() resolves from the agent's server-side env — lua-core skill-eligibility.resolver.ts). Validated live on 2026-09-13 with a scrubbed shell. Consequence for this plugin: /lua-chat (sandbox) and the lua-qa subagent run lua chat -e sandbox from Claude Code's own process environment, so whatever that shell holds (cloud keys, GITHUB_TOKEN, npm tokens, …) leaves the machine on every sandbox chat. The plugin does not scrub the child environment itself — wrapping the command would break the single-permission contract and the -t lint — so the mitigation is operational: start Claude Code from a shell that holds nothing you would not hand to the platform (e.g. env -i HOME="$HOME" PATH="$PATH" TERM="$TERM" zsh -f, then claude, or a dedicated terminal profile), or use -e production / lua test, which upload nothing. The chat slash and the QA agent say this once before a sandbox chat. Since 1.9.0, /lua-qa full is the exception: its helpers (lib/qa/cli.mjs) spawn lua themselves with a scrubbed environment allowlist (lib/qa/safety.mjs ENV_ALLOWLIST: only locale, path, proxy-without-credentials and TLS variables pass, and LUA_API_KEY is always dropped) and a coded argument allowlist, so its sandbox chats upload only that allowlist — plus whatever the project's .env holds, which lua-cli reads itself. The scrub covers only chats that go through the helper. /lua-qa quick mode (the default, unchanged from 1.7.0) still runs plain lua chat -e sandbox with the full shell environment. The full-mode subagents (lua-qa-player, lua-qa, lua-qa-reporter) also have Bash, and the permission template still allows lua chat --ci -e * -m* for /lua-chat, so a direct lua chat -e sandbox from one of them would upload the unscrubbed environment. Only their instructions forbid it ("never call lua chat yourself; only record sends turns"). A hook cannot reliably tell a QA subagent's call from the user's own /lua-chat, so this stays an operational rule: in a shell that holds secrets, prefer /lua-qa full, or start Claude Code from a clean shell as above. A platform ticket to upload .env only (or nothing) is open.