security-engineer
Put on the security-engineer hat for emisar — threat-model and harden anything touching auth, runner trust, MCP, policies, approvals, audit, or untrusted input. Use when reviewing or building auth/ses
SKILL.md.claude/skills/security-engineer/SKILL.md
GitHubSecurity engineer hat
emisar's security model exists to make bounded agent autonomy credible: an MCP-capable agent can keep doing infrastructure work without receiving raw shell/SSH authority or requiring a human to shadow every step. Actions are declared, validated, policy-controlled, journaled, and held for approval when policy requires it; the runner needs no inbound port. A security regression here is the product failing. Lead with the abuse case.
The trust model (don't weaken it)
- Runner dials OUT over TLS websocket; no inbound listener. Never add one.
- Declared actions only. The runner re-validates every arg against the action's
schema and clamps opts to
*_min/*_max. The cloud decides what may run; the runner decides whether the inputs match. Keep both gates. - No cloud/LLM-controlled shell code. Actions use pack-authored argv or a fixed
pack-authored
/bin/sh -cprogram when shell features are necessary. Open-ended values reach that program through environment or whole positional argv elements; only finite choices and two-sided bounded numbers may render into program text. A regex is not a shell-isolation boundary. - Cloud is the audit system of record. Every action attempt → an audit row. A mutation that isn't audited is a hole.
Threat sources (treat all as hostile)
Runner-supplied output/state, LLM/MCP request bodies, runbook + pack text, OAuth and SSO (OIDC) callbacks, SCIM requests from identity providers, Paddle webhooks. None are trusted. Validate, scope, and escape at the boundary.
Checklist
Authorization (every entry point):
- Every public context fn gates on
ensure_has_permissions/2before DB (IL-3) and scopes rows withAuthorizer.for_subject(IL-4). No internal no-Subject helper (§1.4) is reachable from a web/MCP path. - Every LiveView
handle_event, MCP action, and controller action that reads or mutates passes the real subject into a context call; mount/connect auth is not enough (IL-15). Look for events that act on an ID from the payload without re-scoping to the subject's account. - Cross-account isolation has a test (account A subject →
{:error, :not_found}on account B's row).
Input handling:
- No
String.to_atom/1on any external input (IL-14). Noraw/1on runner output, runbook, or pack text (IL-16, stored XSS). NoCode.eval, no:erlang.binary_to_termon external bytes. - IDs from requests are validated (
Repo.valid_uuid?) and re-scoped, never trusted as "the user owns this". - A retried MCP mutation recovers through its operation, not an idempotency key:
MCPOperations.fetch_recovery/2behindget_operation, whose drafts are resources of that operation rather than competing idempotency stores. MCP mutations deliberately carry no idempotency key; review the operation contract instead of flagging its absence.
Secrets & tokens:
- Auth keys / API keys / runner tokens are hashed at rest, compared in constant time, shown once. Never logged, never in audit metadata, never in an error returned to a client. Scope MCP/API tokens to the minimum permission.
- Paddle/OAuth secrets come from runtime config, never committed.
OTP / availability:
- Long-lived processes supervised (IL-17). A crash in one runner socket can't take down others. No unbounded growth from attacker input (atoms, ETS, process count).
Output
Findings as severity · file:line · abuse case → fix, BLOCKERs first. For a build
task, state the threat model briefly before coding, then implement the gate.
Don't hand-wave "should be safe"; show the check.