Securing agents whose actions have consequences
Problem
As soon as an agent does more than write text, such as storing files or preparing and placing orders, a good prompt is not enough. I wanted a pattern where the limits live in code: what the agent may read, what it may write, who approves and how to stop everything. Market data and demo accounts served as the test field, because every action there has a visible effect, without real money.
My role
I defined which role may do what, how approval and stopping work, and signed off the test gates. I also handled specification, review, tests and checking whether the limits really hold in code.
Approach
In cctrader a FastAPI orchestrator starts the coding agent headless via its CLI (-p, stream-json). The allowlist only permits its own MCP tools, skills, subagents, Read and Grep, so no Bash and no Write. Writes only happen through MCP tools that an environment variable binds to exactly one analysis id. From the stream the orchestrator stores tokens, cache hits, cost and every tool call per run.
In the stratlab trading framework the MCP server has three tiers: 0 read, 1 propose and emergency stop, 2 execute. The tier comes from the process environment, and the agent runs at 0. Before every tool except the journal query and the stop itself, a guard checks the kill switch, and only a human can reset it. Proposals go out via Telegram with APPROVE and REJECT and expire after 30 minutes. In the MT5 gateway, tool_choice forces the model into a single tool with a JSON schema, submit_trade_intent. A policy then checks symbol, volume and stop loss, and dry run is the default.
Architecture
Trigger
API call with a token, or an event watcher with 15 minute debounce and at most 4 runs per hour
Headless agent
CLI with -p and stream-json, allowlist: own MCP tools and read tools, no Bash, no Write
Own MCP server
Tier 0/1/2 from the environment, kill switch guard, write tools bound to one analysis id
Deterministic checks
Risk engine or policy: symbol, volume, stop loss, auto-execute off by default
Human approval
Telegram APPROVE or REJECT, open proposals expire after 30 minutes
Execution and log
Risk check again, demo account or fake backend only, journal plus tokens and cost per run
Decision
Permissions live in the process, not in the prompt
A prompt like 'never execute yourself' is a request. So the MCP server reads its tier from the environment at start-up, and the agent's configuration sets it to 0. Only a different process can execute, after a human has approved, and that process checks the risk once more. No tool the agent is allowed to call can change the tier. The cost: more moving parts and an approval that expires after 30 minutes if nobody responds.
Code excerpt
TIER = int(os.environ.get("TRADING_TIER", "0"))
def require_tier(min_tier: int):
def deco(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
if TIER < min_tier:
return json.dumps({"error": "permission_denied", "required_tier": min_tier, "current_tier": TIER})
return fn(*a, **kw)
return wrapper
return deco
def _killswitch_guard() -> str | None:
if killswitch_is_active():
return json.dumps({"error": "killswitch_active"})
return None
# ...
@mcp.tool()
@require_tier(2)
@_safe
def trade_execute(proposal_id: str) -> str:
guard = _killswitch_guard()
if guard:
return guardResults
- 25backend tests in cctrader, green on 18 Oct 2026
- 28tests for tiers, kill switch and approval, green on 18 Oct 2026
- $0.17for a full test run with Haiku in 47 s
- 30 minuntil an open proposal expires
- 4
- conditions before the MT5 gateway really executes
What I learned
Not every limit was enforced. In cctrader the config says one run at a time and twelve per hour, but no code checks it. In the MT5 gateway the docs say policy before auth, while the code checks the token first for executions, and if no token is set it does not check at all. The Telegram callback does not check who pressed the button. Today I write a test for every limit that tries to break it, and missing configuration fails closed. The end-to-end proof against a real demo account is still open; the demo keys were missing.