Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "lua-agent-builder",
"displayName": "Lua Agent Builder",
"description": "Build, test, and deploy Lua AI agents (heylua.ai) from inside Cursor — 14 skills, 5 specialised subagents, MCP-first integrations to 250+ third-party services via Unified.to, sandbox-then-prod deploys with safety gates.",
"version": "1.0.0",
"version": "1.1.0",
"author": {
"name": "Lua AI",
"email": "support@heylua.ai"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ node scripts/install.mjs
# 4. Fully quit Cursor (Cmd+Q on macOS, NOT just close-window) and reopen.
```

Then in Composer or Chat: `/lua-auth` to authenticate (email + OTP, or paste an existing API key from [admin.heylua.ai](https://admin.heylua.ai)), and `/lua-doctor` to verify the full environment.
Then run `/lua-auth` in Composer or Chat. A new login uses `lua auth configure` in a private terminal and requires lua-cli 3.28.0 or newer. Run `/lua-doctor` to verify the full environment.

To uninstall: `node scripts/install.mjs --uninstall`.

Expand Down Expand Up @@ -101,7 +101,7 @@ The plugin enforces the same gates as the Claude Code version, translated to Cur

- **§3.3 deploy gate** — bare `lua deploy` is denied by `hooks/before-shell-execution.mjs` unless prefixed with `LUA_DEPLOY_CONFIRMED=1` (the `/lua-deploy` skill sets this after walking the user through the gated 5-step ship sequence).
- **`--auto-deploy` block** — denied for any command containing `--auto-deploy`.
- **Credential isolation** — `lua auth key*` is denied to prevent the API key from being printed into the chat transcript. The user can read it themselves in a private terminal.
- **Credential isolation** — model-run `lua auth configure` and `lua auth key*` commands are denied. Account details, OTPs, and credentials stay in a private terminal.
- **Single-permission contract** — the same §3.7 contract from the Claude Code plugin (one user prompt per skill) is preserved in the skill bodies.

See [`SECURITY.md`](./SECURITY.md) for disclosure path.
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The plugin enforces several safety contracts. Bypasses count as security issues.
|---|---|
| **§3.3 deploy gate**: bare `lua deploy` is denied | `hooks/before-shell-execution.mjs` (umbrella) + `hooks/confirm-deploy.mjs` (dedicated) |
| **§3.3 auto-deploy block**: `--auto-deploy` is denied | `hooks/before-shell-execution.mjs` + `hooks/block-auto-deploy.mjs` |
| **Credential isolation**: API key never enters chat transcript | `hooks/before-shell-execution.mjs` denies `lua auth key*` invocations + `commands/lua-doctor.md` Step 4 uses an authenticated metadata probe (`lua agents --json --ci`), not a key-printing command |
| **Credential isolation**: account details, OTPs, and credentials never enter the Cursor conversation | `skills/lua-auth/SKILL.md` sends new login to a private terminal; `hooks/before-shell-execution.mjs` denies model-run `lua auth configure` and `lua auth key*` commands |
| **§3.7 single-permission contract**: each skill asks at most one prompt per invocation | Convention enforced in skill bodies; not yet machine-checked in the Cursor port (was lint-checked in the Claude Code plugin via `scripts/lint-single-permission.mjs`) |

If you find a way to bypass any of these without an explicit user prompt, please report.
Expand Down
2 changes: 1 addition & 1 deletion docs/TESTERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ If any of those four checks fails, see [Common gotchas](#5-common-gotchas) below
Once the sanity check passes, walk a full agent build to verify the integration works in your own environment:

```
/lua-auth # email+OTP, takes ~30s
/lua-auth # private typed login through lua-cli 3.28.0+
/lua-doctor # 5-step env diagnostic
/lua-architect Build me an agent that summarises my Stripe refund history
# produces a structured plan
Expand Down
9 changes: 6 additions & 3 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,13 @@ If any of this fails, see the [Troubleshooting](#7-troubleshooting) section belo
/lua-auth
```

The skill asks how you want to authenticate:
The skill first runs `lua agents --json --ci`. If a credential from `LUA_API_KEY`, `~/.lua-cli/credentials`, or the project's `.env` file works, the plugin leaves it unchanged. Existing non-dotted legacy keys remain supported.

- **Email + OTP** (recommended for first-time users) — enter your email; you'll receive a 6-digit code; enter it back. The CLI generates and stores an API key for you.
- **Existing API key** — paste it. (The plugin's `before-shell-execution.mjs` hook denies `lua auth key*` invocations specifically to prevent your stored key from being printed back into the chat transcript.)
For a new login, install lua-cli 3.28.0 or newer. Open a terminal outside Cursor and run `lua auth configure`, then choose the email option. The CLI handles your email and OTP, then requires an organization, one or more exact agents, and an assignable role. Builder is the default role. The CLI writes the typed personal credential to `~/.lua-cli/credentials` with mode `0600`.

If you already have a credential that is not configured, choose the existing-key option in the private terminal. Existing automation can keep using `LUA_API_KEY` or `.env`.

Never paste an email, an OTP, or a credential into the Cursor conversation. The safety hook denies model-run `lua auth configure` and `lua auth key*` commands.

Verify with:

Expand Down
20 changes: 16 additions & 4 deletions hooks/before-shell-execution.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
//
// Coverage:
// 1. `lua auth key*` — would print API key to stdout (transcript leak)
// 2. `--auto-deploy` — bypasses the §3.3 confirmation contract
// 3. bare `lua deploy` — must be prefixed with LUA_DEPLOY_CONFIRMED=1
// 2. `lua auth configure` — account and credential input belongs in a private terminal
// 3. `--auto-deploy` — bypasses the §3.3 confirmation contract
// 4. bare `lua deploy` — must be prefixed with LUA_DEPLOY_CONFIRMED=1
// (set by confirm-deploy.mjs after the user OKs the
// 5-step gated ship via /lua-deploy)
//
Expand Down Expand Up @@ -34,7 +35,18 @@ export function decide(input) {
};
}

// 2. --auto-deploy — never. Bypasses the §3.3 confirmation contract.
// 2. Interactive auth belongs in a private terminal. Even the flagless
// command prompts for account details and an OTP.
if (/\blua\s+auth\s+configure\b/.test(command)) {
return {
block: true,
reason:
'AUTH_INPUT_DENIED: Run `lua auth configure` yourself in a private terminal. ' +
'Do not enter your email, OTP, or credential in the Cursor conversation.',
};
}

// 3. --auto-deploy — never. Bypasses the §3.3 confirmation contract.
if (/--auto-deploy\b/.test(command)) {
return {
block: true,
Expand All @@ -45,7 +57,7 @@ export function decide(input) {
};
}

// 3. Bare `lua deploy` — only allowed when LUA_DEPLOY_CONFIRMED=1 is set
// 4. Bare `lua deploy` — only allowed when LUA_DEPLOY_CONFIRMED=1 is set
// inline (which the confirm-deploy.mjs hook does after the user OKs the
// /lua-deploy gated flow).
if (/\blua\s+deploy\b/.test(command) && !/\bLUA_DEPLOY_CONFIRMED=1\b/.test(command)) {
Expand Down
4 changes: 2 additions & 2 deletions hooks/check-lua-auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ export function decide(versionResult, authResult) {

return {
warn:
'🔐 Lua plugin loaded but you\'re not authenticated. Run `/lua-auth` to set up ' +
'pick `Email + OTP` (we\'ll send a 6-digit code to your inbox) or paste an existing API key. ' +
'🔐 Lua plugin loaded but you\'re not authenticated. Run `/lua-auth` to set up a typed credential. ' +
'The setup keeps your email, OTP, and credential in a private terminal. ' +
'Until then, every `/lua-*` slash that needs the platform will fail.',
};
}
Expand Down
4 changes: 1 addition & 3 deletions lib/permissions-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@
"Bash(lua agents *)",
"Bash(lua skills view --ci*)",
"Bash(lua skills versions --ci*)",
"Bash(lua auth configure --api-key * --ci)",
"Bash(lua auth configure --email * --ci)",
"Bash(lua auth configure --email * --otp * --ci)",
"Bash(lua push * --ci --force*)",
"Bash(LUA_DEPLOY_CONFIRMED=1 lua deploy*)",
"Bash(env LUA_DEPLOY_CONFIRMED=1 lua deploy*)",
Expand All @@ -44,6 +41,7 @@
"Bash(lua deploy*)",
"Bash(lua * --auto-deploy*)",
"Bash(lua push * --auto-deploy*)",
"Bash(lua auth configure*)",
"Bash(lua auth key*)"
]
}
Expand Down
5 changes: 1 addition & 4 deletions mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@
"mcpServers": {
"lua-platform": {
"command": "node",
"args": ["./mcp/lua-platform/dist/server.js"],
"env": {
"LUA_API_KEY": "${env:LUA_API_KEY}"
}
"args": ["./mcp/lua-platform/dist/server.js"]
}
}
}
8 changes: 4 additions & 4 deletions mcp/lua-platform/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @lua/claude-plugin-mcp

Read-only MCP server for the lua-agent-builder Claude Code plugin.
Read-only MCP server bundled with the Lua Agent Builder plugin for Cursor.

Per tech spec §3.4 / §6.3: this server exposes 6 read-only tools that let
Claude Code query lua-platform state mid-conversation without using slash
Expand Down Expand Up @@ -28,11 +28,11 @@ plugin assets repo at `mcp/lua-platform/dist/server.js` per §3.2.

## Running standalone

The server speaks MCP over stdio. Normally invoked by Claude Code via
`.mcp.json`; for manual testing:
The server speaks MCP over stdio. Cursor normally invokes it through
`mcp.json`; for manual testing:

```bash
LUA_API_KEY=lk_... node dist/server.js
LUA_API_KEY='<existing-credential>' node dist/server.js
```

## Architecture
Expand Down
4 changes: 2 additions & 2 deletions mcp/lua-platform/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion mcp/lua-platform/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lua/claude-plugin-mcp",
"version": "1.0.0",
"version": "1.1.0",
"description": "Read-only MCP server for the lua-agent-builder Claude Code plugin",
"type": "module",
"main": "dist/server.js",
Expand Down
1 change: 1 addition & 0 deletions mcp/lua-platform/src/api-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export async function apiRequest(path, {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-Lua-Client': 'cursor-plugin/1.1.0',
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
Expand Down
6 changes: 3 additions & 3 deletions mcp/lua-platform/src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as tools from './tools/index.mjs';
const TOOL_REGISTRY = Object.values(tools);

const server = new Server(
{ name: 'lua-platform', version: '1.0.0' },
{ name: 'lua-platform', version: '1.1.0' },
{ capabilities: { tools: {} } }
);

Expand Down Expand Up @@ -46,7 +46,7 @@ process.on('uncaughtException', (err) => {
kind: 'uncaughtException',
message: err?.message,
stack: err?.stack,
plugin_version: '1.0.0',
plugin_version: '1.1.0',
lua_cli_version: process.env.LUA_CLI_VERSION ?? null,
platform: process.platform,
ts: new Date().toISOString(),
Expand All @@ -59,7 +59,7 @@ process.on('unhandledRejection', (reason) => {
kind: 'unhandledRejection',
reason: String(reason),
stack: reason?.stack,
plugin_version: '1.0.0',
plugin_version: '1.1.0',
lua_cli_version: process.env.LUA_CLI_VERSION ?? null,
platform: process.platform,
ts: new Date().toISOString(),
Expand Down
29 changes: 29 additions & 0 deletions mcp/lua-platform/tests/api-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,21 @@
// 401 / 403 / generic-error paths, and query-string handling.

import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import { readFileSync, readdirSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { apiRequest } from '../src/api-client.mjs';

const SOURCE_DIRECTORY = fileURLToPath(new URL('../src/', import.meta.url));
const PLUGIN_PACKAGE = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'));

function sourceFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? sourceFiles(path) : [path];
});
}

function mockFetch(scripted) {
const calls = [];
const fn = async (url, init) => {
Expand Down Expand Up @@ -51,6 +64,22 @@ describe('apiRequest', () => {
expect(fetchFn.calls[0].init.headers['Content-Type']).toBe('application/json');
});

test('identifies direct requests as the versioned Cursor plugin', async () => {
const fetchFn = mockFetch(jsonResponse({ ok: true }));
await apiRequest('/agents', { fetchFn });
expect(fetchFn.calls[0].init.headers['X-Lua-Client']).toBe(`cursor-plugin/${PLUGIN_PACKAGE.version}`);
});

test('keeps every direct Lua API call behind the identified wrapper', () => {
const directCallers = sourceFiles(SOURCE_DIRECTORY)
.filter((path) => path.endsWith('.mjs'))
.filter((path) => /\b(?:fetch|fetchFn)\s*\(/.test(readFileSync(path, 'utf8')))
.map((path) => relative(SOURCE_DIRECTORY, path))
.sort();

expect(directCallers).toEqual(['api-client.mjs']);
});

test('uses LUA_API_URL env override when set', async () => {
process.env.LUA_API_URL = 'https://api-staging.heylua.ai';
const fetchFn = mockFetch(jsonResponse({ ok: true }));
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"name": "cursor-lua-agent-builder",
"version": "1.0.0",
"version": "1.1.0",
"description": "Cursor plugin for building, testing, and deploying Lua AI agents (heylua.ai)",
"private": true,
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"lint": "eslint . && node scripts/lint-paths.mjs && node scripts/lint-mcp-refs.mjs && node scripts/lint-pinned-version.mjs && node scripts/lint-knowledge-commands.mjs && node scripts/lint-monorepo-paths.mjs && node scripts/lint-log-field-names.mjs && node scripts/lint-chat-thread-flag.mjs && node scripts/lint-cli-flags.mjs && node scripts/lint-cursor-manifest.mjs && node scripts/lint-cursor-mcp-config.mjs && node scripts/lint-cursor-no-claude-root.mjs",
"lint": "eslint . && node scripts/lint-paths.mjs && node scripts/lint-mcp-refs.mjs && node scripts/lint-pinned-version.mjs && node scripts/lint-knowledge-commands.mjs && node scripts/lint-monorepo-paths.mjs && node scripts/lint-log-field-names.mjs && node scripts/lint-chat-thread-flag.mjs && node scripts/lint-cli-flags.mjs && node scripts/lint-cursor-manifest.mjs && node scripts/lint-cursor-mcp-config.mjs && node scripts/lint-cursor-no-claude-root.mjs && node scripts/lint-release-version.mjs",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage && node scripts/check-coverage.mjs",
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
Expand Down
1 change: 0 additions & 1 deletion scripts/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ async function installMcp() {
cfg.mcpServers['lua-platform'] = {
command: 'node',
args: [join(PLUGIN_ROOT, 'mcp/lua-platform/dist/server.js')],
env: { LUA_API_KEY: '${env:LUA_API_KEY}' },
};
await writeFile(MCP_PATH, JSON.stringify(cfg, null, 2) + '\n');
ok(`Added "lua-platform" to ${MCP_PATH} (preserved ${Object.keys(cfg.mcpServers).length - 1} existing server(s))`);
Expand Down
36 changes: 24 additions & 12 deletions scripts/lint-cli-flags.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Denylist of known-wrong lua-cli flag combinations that have shipped to the
// plugin in the past. Standalone-repo friendly: doesn't need lua-cli source
// Denylist of known-wrong or unsafe lua-cli command references that have shipped
// in the plugin. Standalone-repo friendly: doesn't need lua-cli source
// (unlike lint-knowledge-commands.mjs, which is skipped without it).
//
// History:
Expand All @@ -20,9 +20,13 @@ const DENY = [
// Pattern → reason
{ pattern: 'lua sync --pull', reason: 'real flag is `lua sync --accept` (server → local)' },
{ pattern: 'sync --pull', reason: 'permission rule must allow `--accept`, not `--pull`' },
{ pattern: 'lua auth configure --email', reason: 'email and OTP input must stay in a private terminal', authFlow: true },
{ pattern: 'lua auth configure --api-key', reason: 'credentials must stay out of the model conversation', authFlow: true },
];

const SCAN_DIRS = ['commands', 'agents', 'hooks', 'lib', 'scripts', 'mcp'];
const SCAN_DIRS = ['skills', 'agents', 'hooks', 'lib', 'scripts', 'mcp'];
const AUTH_DOC_DIRS = ['docs'];
const AUTH_DOC_FILES = ['README.md', 'SECURITY.md'];
const SCAN_EXT = new Set(['.md', '.json', '.mjs', '.js', '.ts']);

let failed = false;
Expand All @@ -40,23 +44,31 @@ async function* walk(dir) {
}

let scanned = 0;
async function scan(path, { authOnly = false } = {}) {
const content = await readFile(path, 'utf8');
for (const { pattern, reason, authFlow } of DENY) {
if (authOnly && !authFlow) continue;
if (content.includes(pattern)) {
fail(`${path}: contains denylisted CLI reference \`${pattern}\` — ${reason}`);
}
}
scanned++;
}

for (const dir of SCAN_DIRS) {
for await (const path of walk(dir)) {
// Don't lint this script itself — it has to mention the deny patterns.
if (path.endsWith('lint-cli-flags.mjs')) continue;
const content = await readFile(path, 'utf8');
for (const { pattern, reason } of DENY) {
if (content.includes(pattern)) {
fail(`${path}: contains denylisted CLI reference \`${pattern}\` — ${reason}`);
}
}
scanned++;
await scan(path);
}
}
for (const dir of AUTH_DOC_DIRS) {
for await (const path of walk(dir)) await scan(path, { authOnly: true });
}
for (const path of AUTH_DOC_FILES) await scan(path, { authOnly: true });

if (failed) {
console.error(`\nFix the references above. These flags do not exist in lua-cli; shipping them ` +
`breaks the user's first attempt to use the slash/agent that referenced them.`);
console.error('\nFix the references above. These commands are wrong or unsafe in a model-run plugin flow.');
process.exit(1);
}
console.log(`✓ CLI flag denylist: ${scanned} file(s) scanned, no known-wrong flags found.`);
8 changes: 8 additions & 0 deletions scripts/lint-cursor-mcp-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ try {
process.exit(1);
}

const installer = await readFile('scripts/install.mjs', 'utf8');
if (/LUA_API_KEY\s*:\s*['"]\$\{env:LUA_API_KEY\}['"]/.test(installer)) {
fail('scripts/install.mjs must not write the unsupported ${env:LUA_API_KEY} literal into ~/.cursor/mcp.json. Let the MCP process inherit the environment and use its credentials-file fallback.');
}

const servers = mcpDoc?.mcpServers ?? {};
const serverNames = Object.keys(servers);
if (serverNames.length === 0) {
Expand All @@ -41,6 +46,9 @@ for (const [name, server] of Object.entries(servers)) {
fail(`${CONFIG}: server "${name}" missing \`command\``);
continue;
}
if (Object.prototype.hasOwnProperty.call(server.env ?? {}, 'LUA_API_KEY')) {
fail(`${CONFIG}: server "${name}" must inherit LUA_API_KEY instead of writing a literal interpolation string. Omitting this field also lets the MCP resolver fall back to ~/.lua-cli/credentials and .env.`);
}
if (!Array.isArray(server.args) || server.args.length === 0) {
// Allow servers with no args (e.g. an HTTP-based MCP referenced by URL).
continue;
Expand Down
Loading
Loading