Skip to content

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)

  1. scan_context_file on AGENTS.md / rules before system prompt
  2. scan_user_input on each user turn
  3. wrap_retrieved_content on RAG / tool results entering context
  4. notify_taint_source after fetch/read tools
  5. before_tool_call before every side-effect tool (handle review)
  6. scan_agent_output before returning to user
  7. reset_session_taint between users / tenants

See also: README.md, integrations/custom-loop/README.md.