Skip to content

Security: gregmeyer/some-useful-agents

Security

docs/SECURITY.md

Security Model

This document is the source of truth for what some-useful-agents defends against, what it does not, and what you are on the hook for as an operator.

Keep it honest. If the code diverges from this document, treat that as a bug in the code or the document — not a tolerable drift.

Intended use

sua is a local-first tool for a single user on a machine they control. Think "the user's laptop" or "a developer's dev box." It is not designed for:

  • Multi-tenant hosting (one process serving agents for many users).
  • Public-internet exposure of the MCP server or any other sua surface.
  • Storage of regulated data (PCI, HIPAA, etc.).
  • Untrusted code execution as a security boundary. Shell agents run as the invoking user.

If your use case sounds like any of the above, stop and use a tool built for it.

Trust model

Three concentric rings, in order of increasing trust:

Ring Source Default treatment
community Agents in agents/community/, installed from third parties Hostile-by-assumption. Minimal env allowlist; shell downstream blocked; claude-code output wrapped.
local Agents you authored in agents/local/ Trusted — yours. Full env allowlist minus secrets.
examples Agents bundled with sua in agents/examples/ Trusted. Same treatment as local.

Trust flows with the agent's source through the chain executor. A local agent that reads the output of a community agent does not become community, but the value flowing in is tagged as untrusted and handled accordingly (see "Chain trust propagation" below).

What sua defends against

MCP server (v0.4.0)

The MCP HTTP server on localhost:3003 has four layered defenses:

  1. Loopback bind. httpServer.listen(port, '127.0.0.1') by default. A LAN attacker on the same Wi-Fi cannot reach the port. Override with sua mcp start --host <host> only when you genuinely need LAN exposure (a warning is printed).
  2. Bearer token auth. Every /mcp POST/GET/DELETE must carry Authorization: Bearer <token>. /health stays unauthenticated. The token is a 32-byte random value in ~/.sua/mcp-token (chmod 0600). crypto.timingSafeEqual avoids timing leaks on compare.
  3. Host and Origin allowlists. The server rejects requests whose Host header is not in a loopback allowlist (belt-and-suspenders for the --host case), and requests whose Origin header is present and not loopback (the actual DNS-rebinding defense — a browser tab pointed at evil.com that rebinds DNS to 127.0.0.1 still sends its real Origin).
  4. Session-to-token binding. Each mcp-session-id is pinned to the sha256 of the bearer token that created it. After sua mcp rotate-token, in-flight sessions created under the previous token are refused. No hijack window.

The bearer token's only job is to gate any process that can hit localhost from the user's intended MCP clients. It is not a credential against a remote attacker — for that we have the loopback bind. Both layers must hold for the system to be safe.

MCP agent scope (v0.5.0)

Only agents with mcp: true in their YAML are exposed via the MCP list-agents and run-agent tools. Agents without the flag respond as "not found" — not "forbidden" — so a compromised MCP client cannot enumerate the user's full catalog. Use sua agent list from the CLI to see everything.

Shell agent gate (v0.6.0)

Shell-type agents sourced from community/ refuse to run unless the caller has explicitly opted in. The CLI surfaces this as --allow-untrusted-shell <name> on sua agent run and sua schedule start (repeatable, per-agent, not global). Library consumers pass LocalProvider({ allowUntrustedShell: Set<string> }) or the equivalent on TemporalProvider. The executor throws UntrustedCommunityShellError before spawn is called, and the provider records a failed run in the store so the refusal shows up in history.

This is a forcing function, not a sandbox. Once you opt in, the shell runs with your full ambient authority. The point is to make you read the command: field first. Use sua agent audit <name> to print the resolved YAML before opting in.

Run-store hygiene (v0.6.0)

  • data/runs.db is chmod 0o600 at create time. Agent stdout can contain secrets that were echoed by an agent; the DB is unencrypted plaintext, so POSIX perms are the only at-rest protection.
  • Retention sweep: the store deletes rows older than runRetentionDays (default 30) on startup. Configure via sua.config.json. Long-running agent history is an ambient leak surface — we cap it unless you opt out.
  • Opt-in redaction: set redactSecrets: true on an agent's YAML and its captured stdout/stderr runs through a known-prefix scrubber (AWS access keys, GitHub PATs, OpenAI / Anthropic keys, Slack tokens) before it lands in the store. Intentionally narrow patterns — we do not try to scrub "anything long and unusual" because that kind of generic regex produces too many false positives.

Chain trust propagation (v0.5.0)

When a downstream agent reads {{outputs.X.result}} and X is a community agent, two defenses kick in:

  • Claude-code downstream: the substituted value is wrapped in BEGIN/END UNTRUSTED INPUT FROM <agent> (source=community) delimiters, and a [SECURITY NOTE] is prepended to the prompt telling the LLM to treat wrapped blocks as data rather than instructions.
  • Shell downstream: refused outright with UntrustedShellChainError unless the downstream agent name is in the caller's allowUntrustedShell set. When allowed, the shell receives SUA_CHAIN_INPUT_TRUST=untrusted so well-written agents can branch; all-local chains see SUA_CHAIN_INPUT_TRUST=trusted.

The allow-list is per-agent, not global. One careless invocation cannot trust everything.

Env-builder trust levels are not downgraded through the chain. A local downstream keeps its full env allowlist even when consuming community output. The reason: a strict min(source) rule would push users to silently relabel agents as local in their YAML and bypass the MINIMAL_ALLOWLIST that actually protects secrets. Template wrapping at the value level plus the shell hard-block is where the real teeth sit.

Env filtering by trust level (v0.3)

The env-builder injects a different set of process environment variables based on the agent's source:

  • community: PATH, HOME, LANG, TERM, TMPDIR only. AWS_*, *_TOKEN, *_KEY, and any secret-shaped variables never reach the subprocess.
  • local / examples: the community allowlist plus USER, SHELL, NODE_ENV, TZ, and LC_*.
  • Any agent can declare explicit additions via envAllowlist: in its YAML.
  • Secrets declared via secrets: are resolved from the encrypted store and injected regardless of trust level — but only when the agent explicitly asks for them by name.

See ADR-0006.

Cron frequency cap (v0.4.0)

node-cron accepts 6-field "with-seconds" expressions silently. That would have let a malicious or typo'd YAML fire an agent every second, melting an Anthropic bill. We reject 6-field expressions by default. 5-field expressions (minimum interval 60s) pass unchanged. Sub-minute scheduling requires allowHighFrequency: true in the YAML and logs a [high-frequency] warning on every fire.

Secrets store encryption (v0.10.0)

data/secrets.enc encrypts under a key derived from a user-chosen passphrase via scrypt(passphrase, random-salt, { N: 2^17, r: 8, p: 1 }). Each store generates its own 16-byte salt on first write; the KDF parameters are stored alongside the ciphertext so we can tune them upward without breaking old stores. AES-256-GCM remains the cipher; the payload is a v2 JSON envelope:

{ "version": 2, "salt": "...", "iv": "...", "tag": "...",
  "data": "...", "kdfParams": {"algorithm":"scrypt","N":131072,"r":8,"p":1,"keyLength":32} }

The passphrase lives only in process memory — never on disk. The CLI prompts for it on sua secrets set against a cold store and reads SUA_SECRETS_PASSPHRASE for CI and other non-TTY contexts. A v2-protected store that nobody can supply a passphrase for is unreadable by design, which is the whole point — a stolen secrets.enc is no longer decryptable just by knowing the victim's hostname and username.

Empty-passphrase fallback. If you want the pre-v0.10 zero-friction behavior (e.g. for a demo npx init), sua secrets set accepts an empty passphrase. The store is still written in v2 format but flagged with obfuscatedFallback: true, the key is re-derived from the legacy hostname+username seed, and every read/write emits a warning telling you to run sua secrets migrate. sua doctor --security flags the store as hostname-obfuscated in red. This is obfuscation, not encryption — exactly what v1 was, except now it's explicitly labeled as such on disk and in every CLI surface.

Legacy v1 stores. Payloads written by v0.9.x and earlier still decrypt (warning on every load). The next sua secrets set / delete auto-migrates to v2 under the caller's passphrase; sua secrets migrate does the same without requiring a value change.

Public-repo secret hygiene (v0.21+)

The project source lives in a public GitHub repo. Secrets must never land in commits — .gitignore excludes the runtime homes that hold them (data/, ~/.sua/, .env*, agents/local/) and the encrypted secrets store sits at ~/.sua/secrets.enc, outside the working tree entirely.

In addition to the gitignore:

  • Gitleaks runs in CI on every push + pull request via .github/workflows/secret-scan.yml. A finding fails the job; the PR can't merge until cleared. The scan reads .gitleaks.toml, which extends the upstream default ruleset and allowlists test fixtures that intentionally contain secret-shaped strings (the redactor self-tests, fake ghp_… defaults in dag-executor tests, etc.).
  • Opt-in local hook: ./scripts/install-hooks.sh installs a .git/hooks/pre-commit that runs gitleaks protect --staged so leaks die at commit time rather than after a force-push. Not auto-installed by npm install — explicit by design.
  • Test fixtures convention: any value that could be mistaken for a real secret must either be generated at test runtime or live in one of the allowlisted paths in .gitleaks.toml. Don't add new hard-coded fake tokens outside that list.
  • Bypassing: git commit --no-verify skips the local hook. CI is the ground truth; don't rely on --no-verify to silence a real finding.

If gitleaks fires on a file you believe is a true positive, rotate the credential first, then remove from history via git filter-repo or BFG. Treat any committed real-looking secret as compromised even if quickly reverted — git push is public.

Supply chain (v0.4.0)

  • Third-party GitHub Actions are pinned to full SHAs with version comments (actions/checkout, actions/setup-node, changesets/action). A compromise of those orgs cannot ship malicious code through a moving tag. Dependabot refreshes the SHAs weekly in its own PRs for review.
  • npm publish runs through OIDC trusted publishing via the npm-publish GitHub environment. No long-lived NPM_TOKEN exists to leak.
  • .github/CODEOWNERS requires owner review on any change under .github/workflows/ once the matching branch ruleset is enabled.

HTML sanitizer (v0.18)

The ai-template output widget type stores LLM-generated HTML and re-renders it on every run. Two places the sanitizer guards:

  1. At save time, on the output of claude --print (or any registered TemplateGenerator). Anything outside the allowlist is stripped before the template reaches the DB.
  2. At render time, on the substituted string (template + interpolated values). Defense-in-depth — if a run output somehow smuggled a tag through the per-value HTML escape, the second pass catches it.

Allowlisted tags: standard text + layout (div, span, p, h1-h6, ul/ol/li, dl/dt/dd, table/tr/td/th, section, article, header, footer, figure, figcaption, blockquote, q, details, summary, a, img, pre, code, strong, em, b, i, u, br, hr, small) plus SVG (svg, g, path, circle, ellipse, rect, line, polyline, polygon, text, tspan, defs, use, symbol, linearGradient, radialGradient, stop, clipPath, mask).

Allowlisted attrs: class, style, id, role, title, lang, dir, tabindex, plus any aria-* / data-*, plus per-tag attrs (href/src/alt/width/height/viewBox/d/fill/stroke/etc.). Full per-tag list: packages/core/src/html-sanitizer.ts lines 17–85.

Always stripped: <script>, <iframe>, <object>, <embed>, <link>, <meta>, <form>, <input>, <button>, <textarea>, <style>, any on* attribute, javascript: / vbscript: URLs in href/src/style, HTML comments.

URL rules: http(s)://, mailto:, data:image/*, anchors (#...), and relative paths are allowed. data:text/html, data:application/*, and every other scheme are rejected.

Values from run output are always HTML-escaped before substitution, so even without the sanitizer a hostile run could not inject tags through {{outputs.X}}. The sanitizer is the second layer.

MCP server trust

sua opens an MCP client against every server imported at /tools/mcp/import. Threats + mitigations:

  • stdio servers run in host env. The paste-config env: block is merged on top of process.env for the child process — anything you list overrides host env only for that subprocess. The parent sua process is unchanged. Malicious server = malicious subprocess with your ambient authority; treat the paste-config textarea the same as --allow-untrusted-shell.
  • HTTP servers are not loopback-enforced. The import page accepts any URL. Don't point it at servers you haven't authenticated. No certificate pinning yet. Run loopback-only unless you trust the network path.
  • Server config is stored verbatim in the mcp_servers table. Command, args, env, url. Treat the DB file (data/runs.db, chmod 0o600) as sensitive — anyone who can read it can see the full stdio command and any env values baked into the server config.
  • The disabled-server gate is cooperative, not mandatory. It prevents accidental invocation of a flakey server; it is not a security boundary against a malicious agent author who can edit tool rows directly.

Output widget templates

Templates (both hand-authored and AI-generated) are trusted at save time. Anyone who can save an agent can save a template. The sanitizer is a correctness + defense-in-depth layer, not access control.

The dashboard's auth model (cookie + Host + Origin allowlist) is what gates who can save.

What sua does NOT defend against

Being explicit so you can evaluate whether sua is the right tool for your threat model:

  • Shell agent sandboxing. Once you opt in past the community-shell gate, the agent runs as the invoking user with full ambient authority: filesystem, network, processes. A malicious shell agent can rm -rf $HOME, exfiltrate ~/.ssh, read browser cookies, or run any other command the user could run. The env filter reduces secret-leak blast radius but does not create a sandbox. Treat every --allow-untrusted-shell invocation like running the shell script yourself — because you effectively are. Real sandboxing (nsjail on Linux, sandbox-exec on macOS) is on the long-term roadmap.
  • Secrets-store passphrase loss. The v2 store is only decryptable with the passphrase you set. There is no recovery path — forget it, and the secrets are gone. Write it down, put it in a password manager, or use SUA_SECRETS_PASSPHRASE as the single source of truth; sua deliberately does not stash the passphrase on disk as any stash defeats the purpose. If you explicitly accepted the empty-passphrase fallback at setup, the store is obfuscatedFallback: true — secrets-store encryption degrades to obfuscation until you sua secrets migrate to a real passphrase. sua doctor --security calls this out on every run.
  • Prompt injection from claude-code upstream agents. We only wrap values from community agents. If you write a local claude-code agent that can be manipulated by a user into producing prompt-injection payloads, and a second local agent reads its output, that output flows through unwrapped. Don't chain agents whose inputs you don't control.
  • Run output secrets (by default). Agent stdout lands verbatim in data/runs.db unless the agent opts into redactSecrets: true (v0.6.0). The default behavior is still "store what the agent printed." If an agent you author calls a third-party API that might return a token, set redactSecrets: true to catch the AWS / GitHub / LLM / Slack prefixes. Do not rely on the scrubber for unknown secret formats — it's narrow on purpose. As of v0.6.0, runs.db is chmod 0o600 at create and rows older than 30 days are swept on startup; neither of those replaces redaction for live secrets.
  • Temporal workflow history. When running under the Temporal provider, agent inputs and outputs are persisted in Temporal's own history store. That is a second plaintext sink outside sua's control. Same advice: don't echo secrets.
  • Remote MCP access. MCP is localhost-only by design. If you need remote access, stand up a reverse proxy with its own auth in front of --host 127.0.0.1 and understand that you are now maintaining that remote surface.
  • Denial of service. sua does not rate-limit anything. An attacker with the bearer token (i.e., a local user) can hammer run-agent until disk or CPU runs out. The local-first threat model considers this a non-goal.

Operator responsibilities

You are on the hook for:

  1. Guard the bearer token. Anyone with ~/.sua/mcp-token can invoke every mcp: true agent. Rotate with sua mcp rotate-token if you suspect compromise and update every MCP client config.
  2. Audit community agents before installing. Run sua agent audit <name> to print the resolved YAML, read the command:, prompt:, and envAllowlist: fields, then opt in with --allow-untrusted-shell <name> per invocation. Prefer type: claude-code over type: shell when possible.
  3. Run sua doctor --security periodically. Checks file perms on the MCP token / secrets / run store, flags any community shell agents that would refuse to run, reports which agents are MCP-exposed.
  4. Keep sua up to date. Security fixes ship in minor versions in the 0.x range. npm outdated -g @some-useful-agents/cli.
  5. Lock down the repo. Enable "Require review from Code Owners" on your main branch ruleset if you are running a multi-contributor fork. .github/CODEOWNERS is inert until you do.
  6. Keep secrets out of agent output. Don't echo $SECRET. Set redactSecrets: true on agents that call third-party APIs whose responses might contain tokens.

Future security work

Items below are known gaps with explicit threat-model framing. Each says what it would defend against and why it's not in scope today. Tracked here so security planning is honest about what we have vs what we'd want.

Subprocess filesystem sandbox

What it would defend against: a community-shell agent (or an LLM-generated agent saved without close inspection) reading or writing arbitrary paths outside its declared inputs. Today, shell nodes inherit the working directory and can cat /etc/passwd, rm -rf $HOME/anything, etc. — anything the OS user running sua can do.

Why not now: requires platform-specific subprocess sandboxing — chroot (Linux, root needed) or firejail / bubblewrap (Linux, package install) or sandbox-exec (macOS, deprecated by Apple, no Windows equivalent). Each has different failure modes and capability surfaces. Multi-day cross-platform effort. Stays on the long list until we either (a) have a concrete attack we're trying to prevent or (b) ship a v1 hosted offering where the trust boundary changes.

State directory encryption at rest

What it would defend against: an attacker with read access to data/agent-state/<id>/ (e.g. via a separate compromise that landed disk-read-only) recovering plaintext intermediate artifacts an agent persisted across runs (cached API responses, diff snapshots, last-fired timestamps). Today, state is plain files chmod 0o700.

Why not now: the existing secrets KEK (PR v0.10) is the obvious candidate to reuse — wrap state writes through the same data/secrets.enc-style envelope. The complication is that agents read state via raw filesystem APIs (cat, jq, read -r), not through a sua API. Encrypting state means either (a) injecting a decrypt shim into every shell node — fragile — or (b) FUSE-mounting an encrypted volume — heavyweight, platform-specific. Neither lands in a single PR. Today's mitigation: docs warn against putting secrets in state.

Sibling state-dir prevention

What it would defend against: an agent reading another agent's state via $STATE_DIR/../other-agent/. Today the regex on agent ids prevents .. injection, but $STATE_DIR/../ is a literal valid path the agent can compose itself.

Why not now: cheap defense-in-depth (symlink $STATE_DIR to a randomized location inside data/agent-state/, breaking .. traversal) but it breaks any agent that hardcodes the path elsewhere or shares state intentionally between two agents owned by the same operator. Trade-off worth thinking about before shipping. Also: the same OS user can already read everything under data/ directly, so this only buys defense against accidental cross-agent reads, not malicious ones.

Read-only state for community agents

What it would defend against: an installed community agent filling your disk by appending to a state file forever, or persisting telemetry / fingerprints across runs. Today community agents get full read-write state access, same as local agents.

Why not now: requires a per-agent capability flag in the executor and a clean error when the cap is hit. Useful but the simpler mitigation (the per-agent size cap planned in PR D.1) covers the disk-fill case. Telemetry persistence is a real privacy concern with no good answer except "don't install community agents you don't trust" — same posture as the existing community-shell gate.

Tracking

These items live on ROADMAP.md under "Security audit follow-through" but the threat-model framing here is the source of truth. Promote out as concrete PRs are scheduled.

Reporting vulnerabilities

Do not open a public GitHub issue for a security report.

We respond within a week. There is no bug bounty.

Version history

  • v0.4.0 — MCP transport lockdown (loopback bind, bearer token, Host/Origin allowlists, session-to-token binding), cron frequency cap, CI action SHA-pinning, CODEOWNERS.
  • v0.5.0 — Chain trust propagation (wrap community values, block community→shell), mcp: true agent opt-in, this document.
  • v0.6.0 — Community shell agent gate (UntrustedCommunityShellError + --allow-untrusted-shell), run-store chmod 0600, 30-day retention sweep, opt-in known-prefix secret redaction, sua agent audit, sua doctor --security.
  • v0.6.1 — Community agents are runnable from sua agent run / sua schedule start directly; the shell gate enforces per-invocation opt-in. Previously the gate lived in the executor but was unreachable from the CLI because community agents weren't in the runnable load set.
  • v0.10.0 — Passphrase-based KEK for the secrets store (scrypt N=2^17, per-store random salt, AES-256-GCM). v2 payload format with explicit obfuscatedFallback flag for users who accept the empty-passphrase legacy path. sua secrets migrate command; sua doctor --security reports the mode. Closes the last finding from the original /cso audit.
  • v0.17.0 — SSRF protection on http-get / http-post (DNS-resolved IP validation blocks private, loopback, link-local, cloud metadata). Auth token moved from URL query to fragment (never sent to server, never logged, never leaked via Referer). CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy on every dashboard response.
  • v0.18.0 — HTML allowlist sanitizer for ai-template output widgets (see above). MCP server trust surface called out explicitly: stdio = ambient authority, HTTP = no pinning. Disabled-server gate for imported MCP tools. Executor aborts Claude template generation on client cancel.

There aren't any published security advisories