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.
This plugin's surface includes:
- Hooks (
hooks/*.mjs) — run as subprocesses of Claude Code onSessionStart,UserPromptSubmit, andPre/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/sdkincluded, are inlined at build time from the lockfile); it talks tohttps://api.heylua.aiover 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-docsis the public remote MCP athttps://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.jsonby/lua-doctorStep 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:
- Bugs in
lua-cliitself (report to https://github.com/lua-ai-global/lua-cli) - Bugs in
lua-api(report to security@heylua.ai with[lua-api]in the subject) - Issues in user-installed third-party MCP servers
| 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.
- 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.mjsresolves the script's real path on every such call and blocks anything that is not this plugin'slib/qa/cli.mjs. It also blocks a call it cannot verify: a relative path, a node flag orNODE_OPTIONSin 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 anaskrule, which takes precedence over the helper allow rule, so the user approves it at a permission prompt.state.jsonstores 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-testandstressrefuse 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.
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.