Skip to content

EM-WS8: headless hardening + standalone MCP server - #15

Merged
lua-stefan-kruger merged 3 commits into
mainfrom
em/ws8-headless-hardening
Sep 26, 2026
Merged

lua-stefan-kruger merged 3 commits into
mainfrom
em/ws8-headless-hardening

Conversation

@lua-stefan-kruger

@lua-stefan-kruger lua-stefan-kruger commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

EM-WS8 of the EM v1 program. This fixes three findings from the Lua Job-tier audit, where the plugin runs unattended in claude -p:

  • finding 2: the MCP server has no node_modules;
  • finding 3 and row 16: the confirm-deploy parser can be bypassed;
  • §1.5 and §9: hooks steer an unattended model into interactive flows.

The plugin moves 1.5.0 → 1.6.0. The lua-cli pin is unchanged (3.38.0).

1. Standalone lua-platform MCP server

  • scripts/bundle.mjs no longer leaves @modelcontextprotocol/sdk external. dist/server.js inlines every npm dependency from the lockfile (106 KB → 529 KB; the budget is 5 MB). A createRequire banner handles CommonJS dependencies. The build is deterministic: rebuilding gives an identical sha.
  • New tests/standalone-bundle.test.mjs:
    • copies package.json and dist/server.js into an empty temp dir and asserts there is no node_modules above it;
    • spawns the server with NODE_PATH removed and runs initialize then tools/list;
    • asserts the 5 tools, and that serverInfo.version matches package.json.
  • Verified on Node 18, 20 and 26. As a negative control, the 1.5.0 bundle fails this test with ERR_MODULE_NOT_FOUND.

2. confirm-deploy parser hardening (lib/tokenizer.mjs)

The old parser was a single regex anchored at the start of the command. It is replaced by a POSIX-ish shell lexer, and every simple command is inspected.

  • Chains and groups: verbs are found anywhere in &&, ;, ||, |, |&, & and newline chains, and in ( ) / { } groups.
  • Substitutions: $( ), backticks and <( ) are parsed recursively, including inside double quotes.
  • Strings run by another shell: bash -c, eval, ssh host "…", … | sh, heredocs and here-strings fed to a shell, and alias / trap.
  • Inline code: node -e, python -c and perl -e bodies are searched as text.
  • Prefixes and wrappers: env-assignment prefixes, and sudo, env, timeout, command and exec.
  • Launchers: npx lua, npx lua-cli[@v], pnpm exec, pnpm dlx, yarn, npm exec --, bunx, node [opts] …/lua-cli/dist/index.js, and any binary path.
  • Options between binary and verb: lua --ci deploy, lua workflows -v 3 deploy x.
  • Shell variables in the binary or verb position are blocked ($L deploy, lua $VERB, | xargs lua).
  • Unclosable input falls back to a textual search that fails closed.
  • Alias gaps closed: marketplace templates / agent-template(s), and upper-case action words (lua-cli lower-cases them).
  • LUA_DEPLOY_CONFIRMED=1 counts only as the first word of the same simple command, with the binary spelled bare, outside pipes, groups, substitutions and wrapper strings. Each verb in a chain needs its own prefix.
    • Now blocked: export LUA_DEPLOY_CONFIRMED=1; lua deploy all, LUA_DEPLOY_CONFIRMED=1 true && lua deploy all, LUA_DEPLOY_CONFIRMED=1 npx lua ….
    • Every form the deploy pilot and /lua-template emit is still allowed.
  • Fails closed on an internal error. A draft had exponential regex backtracking (~200 s, past the 10 s hook timeout, where the hook fails open). The final version has one parse per option (<1 ms), plus timing-bound tests and a 3,000-case fuzz.

The five audit bypasses (cd x && lua deploy …, true; lua version promote 3, FOO=1 lua deploy all, /usr/local/bin/lua deploy all, npx lua deploy all) are each tested. They are in the unit tests and the spawned integration test.

New intentional false positives, which fail closed:

  • an unquoted echo lua deploy all;
  • quoted text in a segment that also runs a shell, ssh or node / python / perl (for example node x.js "lua deploy all").

Quoted mentions in git commit -m, grep, gh pr create --body, lua chat -m and heredocs written to files stay allowed.

This is still a belt. Commands assembled at runtime (base64 -d | sh, script files, npm scripts, raw HTTP) are out of reach. SECURITY.md now says so.

3. Headless mode: LUA_PLUGIN_HEADLESS=1 (lib/headless.mjs)

  • check-lua-auth: every failure path emits a neutral note ("could not be confirmed; behind a proxy this may just be the probe route"). It never says "run /lua-auth".
  • check-lua-version and detect-project: the same findings, with no /lua-doctor, /lua-update or npm i -g.
  • confirm-deploy: the prefix is void. Every production verb is blocked with DEPLOY_DENIED_HEADLESS, because the model would be confirming to itself. block-auto-deploy has no slash pointer.
  • post-deploy-smoke: never sends the production lua chat ping.
  • Interactive behaviour and text are unchanged.
  • New docs page docs/JOB_TIER.md, "Running in the Lua Job tier". It covers:
    • the playbooks run inline, since the Agent tool is denied;
    • --strict-mcp-config plus --mcp-config;
    • which slashes work;
    • the proxy as the boundary, with suggested inline deny rows;
    • allowing GET /agents/self-serve/models;
    • env hygiene.

Review fixes (commit 8c14fee)

The independent review returned BLOCK. Every finding is addressed, and the review's probe sets are now tests.

  1. CRITICAL: hook timeout fails open.
    • The classifier is linear. The textual fallback is a token scan, not regexes.
    • Commands over 32 KB, a spent 1.5 s budget, and internal errors block when the command mentions lua (DEPLOY_DENIED_UNCLASSIFIABLE).
    • test/hooks/confirm-deploy.timeout.test.mjs spawns the real hook on adversarial inputs up to 100 KB, including the review's reproductions and inputs just under the cap, and asserts a decision in under 2 s. Observed times are about 0.2–0.4 s, including node startup.
  2. HIGH: bypasses closed.
    • A computed binary with a computed verb is blocked when the command mentions lua.
    • lua${IFS}deploy is split at its expansions.
    • Globs and brace expansion count as computed words.
    • Unicode spaces count as separators.
  3. MEDIUM: more strings that run as code. env -S, awk system() and pipes, osascript -e, editor -c '!…', sed e, git -c alias '!…', tmux, docker exec … sh -c, bash < <(…) / source <(…), $(…) in unquoted heredocs, and heredoc-to-script-then-run.
  4. MEDIUM: false positives fixed.
    • Heredoc bodies inside $(…) are treated as text, so Claude Code's gh pr create --body "$(cat <<'EOF' … EOF)" passes.
    • The binary is matched only in command position, so cp -r lua deploy, ls lua deploy and echo lua deploy all pass.
  5. LOW: licences. dist/THIRD_PARTY_NOTICES.txt is generated from the esbuild metafile (SDK and zod, both MIT, full texts), with legalComments: 'eof'. dist/server.js is byte-identical.

Three review probes are not bypasses, and they are kept as asserted tests:

  • … # ; lua version promote 3 is a comment;
  • lua workflow and lua skill are not lua-cli commands.

By design, a pipe or a launcher still voids the prefix.

Tests: 808 plugin tests pass, with hooks at 100% coverage and the tokenizer at 99.6%. 149 MCP tests pass. All lints pass.

Notes for WS3 / WS7

  • In headless mode no deploy or promote can run from the plugin session, so EM's go-live must happen outside it.
  • The Lua-API proxy should allow GET /agents/self-serve/models. Otherwise the headless auth note appears; it is harmless noise. Do not use disableAllHooks, because that also removes confirm-deploy.
  • A stdio lua-platform server via --mcp-config needs a harness change, because claudeMcpConfig only emits {type:'http'} servers today.
  • Merging to main triggers release-prod.yml (tag v1.6.0). That is the maintainers' call; no release was cut from this branch.

Tests

  • Plugin: 808 tests pass with --coverage. Hooks are at 100% on every file, lib/tokenizer.mjs is at 99.8% statements, and check-coverage passes. All 17 lints pass.
  • MCP: 149 tests pass, the MCP lint is clean, and the bundle is 0.50 MB, under the 5 MB budget.

Rollback: revert this PR. No server-side state is involved.

🤖 Generated with Claude Code

lua-stefan-kruger and others added 3 commits September 26, 2026 10:24
- mcp/lua-platform: bundle @modelcontextprotocol/sdk and every other npm
  dependency into dist/server.js (createRequire banner for CJS deps), so
  the server runs with no node_modules (marketplace install, Job-tier
  image). tests/standalone-bundle.test.mjs copies the bundle + package.json
  into an empty temp dir and lists the five tools over stdio.
- lib/tokenizer.mjs: replace the start-anchored regex with a shell lexer.
  Production verbs are found in any simple command of a &&/;/||/pipe chain,
  groups, $()/backticks/<(), bash -c/eval/ssh/| sh strings and heredocs,
  behind env-assignment prefixes and wrappers, via npx lua / npx lua-cli /
  pnpm exec / yarn / node .../lua-cli, and at any binary path; shell
  variables in the binary/verb position are blocked; unclosable input
  falls back to a textual search. LUA_DEPLOY_CONFIRMED=1 counts only as
  the first word of the same simple command, binary spelled bare, outside
  pipes/groups/substitutions. Closes the five Job-tier audit bypasses and
  the marketplace `templates`/`agent-template` and upper-case alias gaps.
- LUA_PLUGIN_HEADLESS=1 (lib/headless.mjs): hooks name no slash command;
  check-lua-auth emits a neutral note instead of "run /lua-auth";
  confirm-deploy treats the prefix as void and blocks every production
  verb; post-deploy-smoke sends no production ping.
- docs/JOB_TIER.md "Running in the Lua Job tier"; SECURITY.md, READMEs,
  CHANGELOG; version 1.5.0 -> 1.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…king in the textual fallback

The textual-fallback option group could split --a two ways and backtracked
exponentially on a failing match (28 options ~200 s), past the 10 s hook
timeout, where the hook fails open. One parse per option now (<1 ms); an
internal classifier error blocks any command that mentions a lua binary.
Adds timing bounds, a 3000-case shell fuzz, and the fail-closed seam test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…close bypasses, cut false positives

1. CRITICAL (hook times out → fails open): the textual fallback is now a
   linear token scan reusing matchArgs (the RAW regexes were quadratic on
   `lua/` runs); findClose/heredoc skipping, matchArgs and command-position
   walking are linear. Commands over 32 KB, a spent 1.5 s budget, or an
   internal error block the command when it mentions lua
   (DEPLOY_DENIED_UNCLASSIFIABLE). New test/hooks/confirm-deploy.timeout
   spawns the real hook on adversarial inputs up to 100 KB and asserts a
   decision in < 2 s.
2. HIGH bypasses: computed binary + computed verb when the command mentions
   lua; `lua${IFS}deploy` split at expansions; unquoted globs and brace
   expansion are computed words; unicode spaces separate.
3. MEDIUM: env -S / --split-string, awk system()/pipes, osascript -e,
   editor -c '!…', sed e, git -c alias '!…', tmux/screen, docker exec sh -c,
   bash < <(…) / source <(…), $(…) in unquoted heredocs, heredoc written to
   a script that is then run.
4. MEDIUM false positives: findClose skips heredoc bodies and comments
   (Claude Code's own `gh pr create --body "$(cat <<'EOF' … EOF)"` passes);
   the binary is matched only in command position (`cp -r lua deploy`,
   `ls lua deploy`, `echo lua deploy all` pass); runners count only in
   command position (`rg x .` is not `source`).
5. LOW: dist/THIRD_PARTY_NOTICES.txt generated from the esbuild metafile
   (SDK + zod, MIT, full texts); legalComments: 'eof'. dist/server.js is
   byte-identical.

The review's probe sets (bypass.json, fp.json, perf.mjs, e2e.mjs) are unit
and spawned tests. 808 plugin tests + 149 MCP tests pass; coverage gates
and all lints green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lua-stefan-kruger
lua-stefan-kruger merged commit b9e86ee into main Sep 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant