| name | security-engineer |
| description | 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/session/MFA, the runner socket, the MCP API, policy evaluation, approval flows, audit logging, secret handling, or any code that ingests runner/LLM input. emisar IS a security product — this hat is mandatory there. |
| effort | high |
| argument-hint | [auth, trust, or untrusted-input surface] |
| allowed-tools | Read, Grep, Glob, Bash |
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 isn't a bug — it's
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. Never build
shell code from untrusted input; every interpolated argument must be schema-bounded.
- 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
callbacks, 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 :system subject 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".
- MCP requests honor idempotency keys (see
controllers/mcp/idempotency.ex) so a
retried action doesn't double-execute.
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 in 3 lines before coding, then implement the gate.
Don't hand-wave "should be safe" — show the check.