Agent actions: ALLOW, REDACT, REVIEW, BLOCK¶
Every Unplug scan returns an Action. Agent hosts and integration adapters must handle each action differently — especially REVIEW, which is not the same as BLOCK.
Examples below use from unplug import Guard for the host and from unplug.api.* only
where wire types or enums are needed. Do not import from unplug.core.*.
Decision table¶
result.action |
result.safe |
HookDecision.allowed |
Agent host should |
|---|---|---|---|
allow |
True |
True |
Proceed with original text |
redact |
varies | usually True |
Use result.redacted_text (or decision.redacted_text) |
review |
False |
False |
Pause — request human approval before side-effect tools; do not treat as silent allow |
block |
False |
False |
Stop — do not call the LLM or execute the tool |
abstain |
False |
False |
Escalate (judge, secondary model, or operator) |
HookDecision.allowed is False for both review and block. Check decision.result.action (or decision.needs_review) to tell them apart.
Typical triggers¶
| Situation | Action |
|---|---|
| Direct prompt injection in user input | block |
| Secret leak in model output | block or redact |
| Destructive shell/SQL tool | block |
| Side-effect tool after untrusted fetch (tainted session) | review |
| Large wire transfer | block or review |
After a web fetch or RAG ingest, call guard.notify_taint_source("web_fetch") so the next side-effect tool (email, upload, payment) returns review instead of allow.
REVIEW + human approval¶
Guard accepts an ApprovalProvider. When a tool call returns review, Unplug builds an ApprovalRequest and calls your provider. If it returns True, the tool call is re-evaluated and may proceed.
from unplug import Guard
from unplug.api.types import ApprovalRequest
from unplug.integrations.hooks import AgentHooks
class CliApprovalProvider:
"""Example: prompt an operator in the terminal."""
def request_approval(self, request: ApprovalRequest) -> bool:
print(f"\n[UNPLUG REVIEW] Tool: {request.tool_name}")
print(f" Reason: {request.reason}")
print(f" Args: {request.arguments}")
answer = input("Approve this tool call? [y/N] ").strip().lower()
return answer in {"y", "yes"}
guard = Guard(approval=CliApprovalProvider())
hooks = AgentHooks(guard)
# Simulate tainted session (e.g. after fetching untrusted web content)
guard.notify_taint_source("web_fetch")
decision = hooks.before_tool_call("send_email", {"to": "user@example.com", "body": "summary"})
if decision.needs_review:
# ApprovalProvider already ran inside check_tool_call; if still not allowed, hold.
print("Held for review:", decision.message)
elif not decision.allowed:
raise RuntimeError(decision.message) # hard block
else:
execute_send_email(...)
For batch/cron agents, never auto-approve: use a provider that returns False unless an operator explicitly signed off (see HERMES_AGENT_SECURITY.md).
How tools are classified¶
The taint gate needs to know which of your tools can cause an effect in the world and
which only read. Names are normalized before matching, so WebFetch, web_fetch,
mcp__browser__webFetch and browser.web-fetch all resolve to the same thing. Both
camelCase and the MCP server__tool convention are handled, and vendor prefixes are
matched through, so slack_post_message is recognised as a post.
A name that matches nothing is treated as a side effect while the session is tainted, and is allowed on a clean session. That is deliberate: an unrecognised name is not evidence a call is safe, and the failure it guards against is silent.
Four knobs, all under [tools] in unplug.toml:
[tools]
# Names you want classified explicitly. These win over the patterns.
side_effect_tools = ["deploy_to_prod", "mcp__acme__charge_card"]
taint_source_tools = ["mcp__acme__fetch_ticket"]
read_only_tools = ["mcp__acme__frobnicate"]
# Or add to the regexes, which match against the normalized name and each of its
# underscore-delimited suffixes.
side_effect_patterns = ["^deploy", "^charge"]
# Set false to allow tool names that match nothing, even on a tainted session.
unknown_tool_is_side_effect = false
If you see session_taint_unknown_tool findings for a tool that genuinely only reads,
add it to read_only_tools rather than turning the gate off.
Integration adapter pattern¶
Replace bare raise RuntimeError on every not decision.allowed with action-aware handling:
from unplug.api.enums import Action
decision = hooks.before_tool_call(tool_name, tool_args)
if decision.result.action == Action.REVIEW:
return hold_for_operator(decision) # pause workflow
if not decision.allowed:
raise RuntimeError(decision.message) # block
# allowed or redacted — proceed
LangChain note: on_tool_start callbacks are observer-only for input/output. Use unplug_input_runnable / unplug_output_runnable for enforcement, plus the callback (or direct before_tool_call) for tools.
Context files (AGENTS.md)¶
scan_context_file returns (text_for_prompt, scan_result) — not a single result:
text_for_prompt, result = guard.scan_context_file(raw_agents_md, filename="AGENTS.md")
if not result.safe:
# text_for_prompt is already a blocked placeholder — do not load raw content
load_system_prompt(text_for_prompt)
else:
load_system_prompt(text_for_prompt) # same as raw when clean
Agent host checklist (full flow)¶
scan_context_fileon AGENTS.md / rules before system promptscan_user_inputon each user turnwrap_retrieved_contenton RAG / tool results entering contextnotify_taint_sourceafter fetch/read toolsbefore_tool_callbefore every side-effect tool (handle review)scan_agent_outputbefore returning to userreset_session_taintbetween users / tenants
See also: README.md, integrations/custom-loop/README.md.