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

GitHub

Security 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 -c program 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/2 before DB (IL-3) and scopes rows with Authorizer.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/1 on any external input (IL-14). No raw/1 on runner output, runbook, or pack text (IL-16, stored XSS). No Code.eval, no :erlang.binary_to_term on 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/2 behind get_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.