Skip to main content

security

Secure coding practices for agent-native apps: input validation, SQL injection, XSS, secrets, data scoping, and auth. Use when writing any action, route, or component that touches user data or external input.

Source facts

Repository
BuilderIO/agent-native
Last source activity
September 5, 2026 at 01:49
Detected SKILL.md language
English
Stars
7,065
Forks
640

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
security
description
Secure coding practices for agent-native apps: input validation, SQL injection, XSS, secrets, data scoping, and auth. Use when writing any action, route, or component that touches user data or external input.
scope
dev
metadata
{"internal":true}
# Security ## Rule Use the framework's security primitives everywhere. Never bypass them. ## Absolute Secrets Rule Never hardcode secret values or real private data. This applies to source code, docs, tests, fixtures, generated prompts, screenshots, seed data, and extension HTML just as much as production code. Do not paste or invent real-looking API keys, bearer tokens, OAuth refresh tokens, webhook URLs, signing secrets, private Builder/internal data, or customer data into the repo. Examples must use obvious placeholders such as `<OPENAI_API_KEY>`, `${keys.SLACK_WEBHOOK}`, `sk-test-example`, or `example.customer@example.com`. Test literals should be clearly fake and must not match real provider token formats when an `example` token will do. Credential values enter the system only through approved runtime channels: deployment env vars for deploy-level secrets, the encrypted `app_secrets` vault or `saveCredential` / `resolveCredential` for user/org/workspace API keys, and `oauth_tokens` for OAuth. Code and instructions may name the credential key (`OPENAI_API_KEY`), but must never contain the credential value. ## Input Validation Use `defineAction` with a Zod `schema:` for every action. The framework validates input automatically and returns clear 400 errors for HTTP callers and structured error results for agent tool calls. ```ts export default defineAction({ schema: z.object({ email: z.string().email(), role: z.enum(["admin", "member"]), limit: z.coerce.number().int().min(1).max(100).default(25), }), run: async (args) => { /* args is fully typed and validated */ }, }); ``` The legacy `parameters:` field (plain JSON Schema) has no runtime validation — do not use it for new code. ## Large Payloads Do not accept or persist unbounded base64/file blobs through actions, SQL writes, or `application_state`. Route uploads through the file-upload provider and store references. This prevents database bloat, slow hot paths, and accidental secret or customer-data embedding inside binary payloads. ## SQL Injection Never concatenate user input into SQL strings. Use Drizzle ORM's query builder (always safe) or parameterized queries: ```ts // Safe — Drizzle ORM await db.select().from(users).where(eq(users.email, args.email)); // Safe — parameterized raw SQL await client.execute({ sql: "SELECT * FROM users WHERE id = ?", args: [id] }); // NEVER do this await client.execute(`SELECT * FROM users WHERE id = '${id}'`); ``` ## XSS - React auto-escapes JSX content — trust it. - Never use `dangerouslySetInnerHTML`, `innerHTML`, `eval()`, or `document.write()` with user-controlled content. - For rich text editing, use TipTap (framework dependency). - For rendering markdown, use `react-markdown`. ## SSRF Any server-side `fetch` of a user- or agent-controlled URL must go through the framework SSRF guard — a bare `fetch()` can be steered at cloud metadata (`169.254.169.254`), `localhost`, or internal services. ```ts import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety"; // Blocks private/internal targets, re-checks the resolved IP at connect time // (DNS rebinding), and re-validates every redirect hop. const res = await ssrfSafeFetch(userProvidedUrl, {}, { maxRedirects: 3 }); ``` For a pre-flight-only check (e.g. before a streaming or one-shot fetch), use `isBlockedExtensionUrlWithDns(url)` plus `createSsrfSafeDispatcher()` from the same module, and set `redirect: "manual"`. Never let the default `fetch` follow redirects for an untrusted URL — a public URL can 30x into the private network. ## Secrets - OAuth tokens go in the `oauth_tokens` store via `saveOAuthTokens()`. - Per-user / per-org API keys go through `saveCredential` / `resolveCredential` (`@agent-native/core/credentials`) or the `app_secrets` vault. Both encrypt values at rest with AES-256-GCM (keyed by `SECRETS_ENCRYPTION_KEY`, falling back to `BETTER_AUTH_SECRET`; production refuses to start without one). - Never hand-roll secrets into `settings`, `application_state`, source code, or action responses sent to the client. The credential / vault APIs above are the only sanctioned stores. - Never commit real keys, tokens, webhook URLs, signing secrets, or private Builder/customer data in examples or fixtures. Use placeholders that cannot be mistaken for working credentials. ## User Credentials Are Per-User Data — Never `process.env` User credentials (API keys, third-party tokens) are per-user (or per-org) data. They MUST live in SQL, scoped per-user (`u:<email>:credential:KEY`) or per-org (`o:<orgId>:credential:KEY`). Always read with the request context: ```ts import { resolveCredential } from "@agent-native/core/credentials"; const apiKey = await resolveCredential("OPENAI_API_KEY", { userEmail, orgId }); ``` Values are encrypted at rest (AES-256-GCM, shared `secrets/crypto.ts`): `saveCredential` encrypts on write and `resolveCredential` decrypts on read, with a transparent fallback for legacy plaintext rows. The agent's raw `db-query` / `db-exec` tools also cannot read credential rows — they are excluded from the scoped `settings` view. To encrypt pre-existing rows in place, run `pnpm action db-migrate-encrypt-credentials` (idempotent, non-destructive; needs the same `SECRETS_ENCRYPTION_KEY` / `BETTER_AUTH_SECRET` as the app). On 2026-04-29 the previous one-arg `resolveCredential(key)` form fell back to `process.env[key]` and an unscoped global `settings` row, so every signed-in user inherited the deployment's credentials. Two guards now block this in CI (`pnpm prep`): - `scripts/guard-no-env-credentials.mjs` — bans `process.env.<KEY>` reads in `packages/core/src/credentials/`, `secrets/`, `vault/`, and `templates/*/server/{lib,routes/api}/credential*` paths, except for an explicit allowlist of deploy-level vars (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `NETLIFY_*`, etc.). Per-line opt-out: `// guard:allow-env-credential — <reason>`. - `scripts/guard-no-unscoped-credentials.mjs` — bans one-arg calls to `resolveCredential` / `hasCredential` / `saveCredential` / `deleteCredential`. Per-line opt-out: `// guard:allow-unscoped-credential — <reason>`. If a deploy-level value genuinely needs an env var (CI-set token, host secret), it's not a user credential — keep it out of the credentials/ secrets/ vault/ paths and the env-credentials guard won't see it. ## Guards Two more CI guards (also wired into `pnpm prep`) target the 2026-04 cross-tenant leak class — request-state escaping into shared process state, and dev-mode sentinel identities used as production fallbacks. - `scripts/guard-no-env-mutation.mjs` — bans `process.env.<KEY> = …` (and bracket / compound forms) anywhere in production code. On serverless, every warm container handles many concurrent requests in one Node process, so `process.env` mutation leaks across in-flight requests (the "restore" line at the end of a handler races and never helps — most recently the Zoom webhook). Use `runWithRequestContext({ userEmail, orgId, timezone }, fn)` from `@agent-native/core/server` instead — it's AsyncLocalStorage-backed and per-request safe. Allowlisted paths: `scripts/`, `*.spec.ts` / `*.test.ts`, `packages/core/src/dev**`, `templates/*/test/`, anything under `/cli/` or `/scaffold/`. Per-line opt-out: `process.env.X = y // guard:allow-env-mutation — <reason>`. - `scripts/guard-no-localhost-fallback.mjs` — bans the literal `"local@localhost"` / `'local@localhost'` / `` `local@localhost` `` in production code. The bug class: `getRequestUserEmail() ?? "local@localhost"` silently pools every unauthenticated request into a single shared tenant, leaking credentials, tools, and `application_state` rows between accounts. The right behavior is to throw / 401 when there's no session. Allowlisted paths: the dev-mode auth shim (`packages/core/src/server/auth.ts`), `packages/core/src/dev**`, tests, `scripts/`, `seed/` / `seeds/`, plus a few framework helpers that intentionally inspect or migrate the dev identity. SQL DDL `DEFAULT 'local@localhost'` and the Drizzle helper `.default('local@localhost')` are skipped per-line — schema column defaults are intentional dev fixtures, not the dangerous fallback pattern. Per-line opt-out: `email ?? "local@localhost" // guard:allow-localhost-fallback — <reason>`. The same guard also bans the **ambient process identity** as a request-scoped fallback: `?? process.env.AGENT_USER_EMAIL`, `|| process.env.WORKSPACE_OWNER_EMAIL`, and `?? getAmbientUserEmail()`. Those name the identity of the *deployment*, not the caller, so a handler reading one authorizes whoever the deploy env names rather than whoever signed in — it fails open toward more privilege. Exempt paths are the ones with no request by construction: `scripts/` (including `templates/*/scripts/`), `src/cli/`, `src/scripts/`, `script-helpers.ts` / `script-entries.ts`, tests, and seeds. It deliberately matches only the `??` / `||` fallback position, so reading the same env var to build an admin allowlist (`envEmails("WORKSPACE_OWNER_EMAIL").includes(email)`) is untouched. Known gaps, both requiring dataflow analysis the guard does not do: an env read aliased through a local (`const fallback = process.env.AGENT_USER_EMAIL; … ?? fallback`) and a destructured `const { AGENT_USER_EMAIL } = process.env`. ## Auth - All actions are protected by the auth guard automatically. - Prefer actions for normal app data. Do not hand-write `/api/*` routes for CRUD, data queries, or action re-exports just to add auth; action endpoints already get auth and request context. - If you must create custom `/api/` routes, always call `getSession(event)` and reject requests without a session: ```ts import { getSession } from "@agent-native/core/server"; export default defineEventHandler(async (event) => { const session = await getSession(event); if (!session) throw createError({ statusCode: 401 }); // ... }); ``` - Never create unprotected routes that modify data. ## Same-Origin Workspace Apps Path-mounted workspace apps share the Dispatch gateway origin. Because the framework's session cookie is scoped to `/`, a mounted pane receives the ambient Dispatch session and can act as the signed-in user through same-origin requests. This is an intentional trusted-code model, not an isolation boundary; removing a mounted app from SSO fanout does not revoke that ambient access. Only trusted, workspace-owner-authored code belongs on that origin. Treat each of these as a trust-boundary change that requires an explicit origin, sandbox, or capability design before shipping: - non-owners can create or edit mounted app code; - mounted apps render or execute untrusted external content or remote code; - apps are publicly shareable, anonymously reachable, or installable by users outside the owning workspace; - app source, dependencies, or deployment artifacts can be replaced by a third party without the workspace owner's authorization; - a mounted app can be registered across workspaces or can change its mount path/origin without an ownership check; or - cookie scope, proxying, iframe policy, or navigation changes make this ambient-session behavior broader than the owning workspace. Do not describe canonical-only SSO eligibility as origin isolation. Canonical apps and explicitly registered custom origins remain eligible; path-mounted apps are deliberately excluded as SSO targets while retaining their existing same-origin session behavior. **Exception — the SSR HTML/`.data` catch-all is deliberately session-blind.** The rule above is for routes that read or mutate user data. The SSR page render and React Router `.data` route are different: they serve one impersonal, public-cacheable shell to every visitor by design, and loaders on that path render no user data. Do not "fix" this by adding `getSession`, `private`, or `no-store` to it — that regresses the CDN cache contract for the whole site. Data scoping lives in actions and API routes; the client gates private UI after the shell loads. See the `authentication` skill and `guard:ssr-cache-shell` / `ssr-handler.spec.ts`. **Authorization beyond "is there a session" — `authorize`.** The auth guard only proves *someone* is signed in. To restrict an operation to some teammates, set `authorize` on the `defineAction`. It wraps `run`, so it applies at every dispatch site (agent tool, HTTP, frontend, MCP, A2A, CLI) — unlike `needsApproval`, which is honored by the agent loop and MCP 2026 hosts that support elicitation, but is not an authorization boundary for every dispatch site. ```ts import { coachAccess } from "../lib/access.js"; // defineAppRoles(...) export default defineAction({ description: "Archive a client roster.", schema: z.object({ id: z.string() }), authorize: coachAccess.requireAny("coach-admin"), run: async (args) => { /* ... */ }, }); ``` A guard that throws denies with its own message; returning `false` denies generically; anything else (including `undefined`) allows. Guarded actions need a user identity — an unattended CLI/cron caller with no user email is denied. `authorize` is **not** a substitute for `accessFilter` / `assertAccess`. It decides whether this caller may perform the operation at all; `accessFilter` / `assertAccess` scope which rows a permitted caller may see or touch. A restricted write action needs both. See the `authentication` skill for `defineAppRoles` and the `actions` skill for the full surface. ## Human-in-the-Loop Approval for High-Consequence Actions For a small set of outward-facing, hard-to-undo operations — sending an email, charging a card, deleting an account, posting publicly — auth and access control are necessary but not sufficient: you also do not want the **agent** to perform them autonomously. Set `needsApproval` on the `defineAction` so the agent cannot run the action without a human approving the specific call. ```ts export default defineAction({ description: "Send an email via Gmail.", schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }), needsApproval: true, // or (args, ctx) => boolean | Promise<boolean> run: async (args) => { /* ...actually send... */ }, }); ``` When the gate is truthy and the call is not yet approved, the agent loop emits an `approval_required` event and **stops the turn — `run()` never executes**. The human approves via the chat UI's Approve affordance, which re-issues the turn with the call's stable `approvalKey`; only then does the action run. MCP 2026 hosts receive an `input_required` approval request bound to the exact caller, action, and arguments. The signed response state is backed by a durable, single-use grant, so denial, expiry, replay, missing host support, or invalid state fails closed before `run()`. A predicate gates conditionally (e.g. only external recipients) and **fails closed** — a throw is treated as "approval required". Rules: - Reach for `needsApproval` only for genuinely high-consequence operations. The default is off, and the framework intentionally keeps approvals rare — over-gating turns the agent into a click-through wizard. The canonical (and intentionally lone) framework example is Mail's `send-email`. - `needsApproval` is **not** a substitute for `accessFilter` / `assertAccess` or for hiding sensitive operations from the model with `agentTool: false` / `toolCallable: false`. It is the layer for "a human must explicitly bless this specific outward-facing call," not for scoping data. See the `actions` skill for the full surface. ## Custom HTTP Routes Must Apply Access Control Themselves This is the single most-failed rule in the codebase. Auto-mounted action routes (`/_agent-native/actions/...`) get a request context wired up automatically. **Hand-written `/api/*` Nitro routes do not.** If your handler queries an ownable resource (any table with `...ownableColumns()`), you MUST: 1. Read the session: `const session = await getSession(event).catch(() => null)`. 2. Run the work inside `runWithRequestContext({ userEmail: session?.email, orgId: session?.orgId }, fn)` from `@agent-native/core/server`. 3. Inside `fn`, query through one of: - `accessFilter(table, sharesTable)` in the WHERE clause for list/read-many. - `resolveAccess("<type>", id)` for read-by-id (returns null if no access — return 404, not 403, so existence isn't leaked). - `assertAccess("<type>", id, "viewer"|"editor"|"admin")` for write/delete-by-id. ```ts // Bad — Brent's signup leaked every other user's decks because of this exact shape. export default defineEventHandler(async () => { const db = getDb(); return db.select().from(schema.decks); // no access filter! }); // Good import { getSession, runWithRequestContext } from "@agent-native/core/server"; import { accessFilter } from "@agent-native/core/sharing"; export default defineEventHandler(async (event) => {
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub