Skip to content

Security

kadyapam edited this page Oct 17, 2026 · 6 revisions

Security

What a kind: agent step can reach, and the guards that keep it there.

Threat model in one paragraph

The model is untrusted in the same way its inputs are: anything it reads (an email, a web page, a tool result) can contain instructions. The design goal is that the playbook author, not the model, decides what can be touched. NoETL enforces that with declared tools, explicit credentials, and treating model output as data.

Guards

Guard Where Prevents
auth: required for framework: claude; no env-var or default-key fallback AgentSession::start a playbook spending credits it never declared
Unset / blank credential stops the step before any request start_agent_session sending an empty key; a missing key file looking like a silent no-op
API errors report status + message, never the key AgentSession::next_turn keys in logs and --json output
Tools are only the tools: list; the playbook: binding is never sent to the model AgentSession::request_body, runner the model choosing what code runs
Model tool input is passed verbatim, never rendered, and {{ → { { run_agent_tool_call / defang_template_markers text in an email reading playbook variables through the tool playbook's templates
Failing tools return is_error to the model instead of crashing run_agent_tool_call partial failures aborting a run halfway
max_turns (claude) / timeout (claude-code) session runaway loops and runaway cost
ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN stripped from claude -p run_claude_code a subscription run silently billing API credits
claude-code gets no tools unless allowed_tools lists them; MCP is --strict-mcp-config claude_code_args Claude Code using your shell, files, or every MCP server in your user config
Credential files must be mode 600; aliases are validated ([A-Za-z0-9._-], no leading .) local_credentials::resolve a shared-readable key file; a templated alias such as ../../x reading arbitrary files
A google_oauth refresh token is never sent as a bearer / api_key value resolve_http_auth_header a model-chosen alias shipping a refresh token to the URL a step names
Gmail tools use format=metadata and the gmail.readonly scope playbooks/gmail-triage/tools the model reading message bodies, or anything that can change the mailbox

Fixed in noetl 5.3.1 and 5.3.2: template re-expansion

Up to noetl 5.3.0, the CLI's render_template substituted variables in successive passes. A value that itself contained {{ var }} was expanded by a later pass, so untrusted text templated into a step (e.g. {{ fetch.result }} holding fetched email) could read other variables.

noetl 5.3.1 (noetl/cli#98, executor 0.13.1) makes the CLI renderer single-pass, so it no longer re-expands substituted values. Use 5.3.1 or newer.

A second layer was fixed in noetl 5.3.2 (noetl/cli#100, noetl/ai-meta#477). In --runtime local, shell and http steps used to be rendered by the CLI and then again by noetl-tools (minijinja), so {{ … }} inside data was evaluated twice, and usually erased. Since 5.3.2 the CLI-rendered config is escaped before it reaches the tool, so data arrives unchanged. Verified with a probe: 30/30 runs print the literal {{ workload.secret }}. Use noetl 5.3.2 or newer.

The agent runner still rewrites {{ to { { in model tool input as defence in depth; since 5.3.2 it is no longer load-bearing.

Operating the key

  • Store it with noetl credential set anthropic, which writes ~/.config/noetl/credentials/anthropic (mode 600, directory 700). With noetl 5.1.0, keep it in any 600 file and export NOETL_CREDENTIAL_ANTHROPIC from it. Never in a playbook, a unit file, --set, or shell history.
  • Set a monthly limit in the Console (Settings → Limits).
  • Use a separate key per machine or automation so one can be revoked alone.

Mailbox access

The Connect Gmail setup follows these rules: read-only scope, one google_oauth credential per mailbox (gmail-<account>), and metadata only.

Templated aliases. gmail-{{ workload.account }} lets the model choose the account, so it can only pick among credentials you created. A model-chosen account that names a non-Google credential (e.g. anthropic) fails, because google_oauth accepts only refresh-token documents. Never template the alias of a bearer or api_key step from model input.

  • Grant read-only scopes / read-only MCP tools for triage. Drafting replies is a separate, explicit tool; sending should stay a human step.
  • One credential alias or MCP server per mailbox, named for the mailbox, so logs say which account was touched.
  • Keep tool output to sender, subject and snippet. Full bodies cost tokens and widen what injected text can do.

Clone this wiki locally