You have a LangChain agent with tool access. It can run shell commands, call APIs, modify files. It works great in development.
Then you give it production credentials and it does something you didn't expect.
The fix is two lines.
The Problem With Tool Access Today
When you define a LangChain tool, there's nothing between the model's decision and the execution:
from langchain.tools import tool
@tool
def run_bash(command: str) -> str:
"""Execute a bash command and return the output."""
import subprocess
return subprocess.check_output(command, shell=True).decode()
The model decides to call run_bash. It runs. No questions asked.
If the model decides rm -rf /tmp/important_data is the right move, that's what happens. No log. No gate. No way to know it happened until something is broken.
The Fix: @safe_tool
Canopy 0.2.1 ships a decorator that wraps any function with a policy check before execution:
from langchain.tools import tool
from canopy import safe_tool
@tool
@safe_tool(
action_type="execute_shell",
agent_ctx={"env": "production", "role": "deploy_bot"}
)
def run_bash(command: str) -> str:
"""Execute a bash command and return the output."""
import subprocess
return subprocess.check_output(command, shell=True).decode()
That's it. Two lines added, zero changes to the agent.
LangChain sees a normal tool. Canopy intercepts every call before execution and makes a policy decision: ALLOW, DENY, or REQUIRE_APPROVAL.
What Happens on Each Decision
ALLOW — function executes normally, decision written to audit log.
DENY — raises PermissionError with the reason and an avid (audit ID). LangChain catches it as a tool error and the agent can decide what to do next.
REQUIRE_APPROVAL — by default also raises PermissionError. You can override this with a callback.
def notify_slack_and_wait(canopy_result, func, *args, **kwargs):
# Send a Slack message, wait for approval, then execute
approved = send_slack_approval_request(
reason=canopy_result["reason"],
avid=canopy_result["avid"],
command=kwargs.get("command"),
)
if approved:
return func(*args, **kwargs)
return "Action was not approved by operator."
@tool
@safe_tool(
action_type="execute_shell",
agent_ctx={"env": "production"},
on_require_approval="callback",
approval_callback=notify_slack_and_wait,
)
def run_bash(command: str) -> str:
"""Execute a bash command."""
import subprocess
return subprocess.check_output(command, shell=True).decode()
Now your agent pauses on dangerous commands and waits for a human. Not because the model decided to — because the runtime enforces it regardless of what the model decided.
Dynamic Context at Runtime
In real multi-agent systems, the context changes. Different agents have different roles. The same tool gets called by a deploy bot in one flow and a research agent in another.
@safe_tool accepts agent_ctx as a callable:
from contextvars import ContextVar
from canopy import safe_tool
current_agent_ctx: ContextVar[dict] = ContextVar("agent_ctx", default={})
@tool
@safe_tool(
action_type="execute_shell",
agent_ctx=lambda: current_agent_ctx.get()
)
def run_bash(command: str) -> str:
"""Execute a bash command."""
import subprocess
return subprocess.check_output(command, shell=True).decode()
Before running each agent, set the context:
token = current_agent_ctx.set({
"env": "production",
"role": "research_agent"
})
# run agent...
current_agent_ctx.reset(token)
The decorator evaluates agent_ctx at call time, not at definition time. Same tool, different policy behavior depending on which agent is calling it.
The Policy Behind It
@safe_tool uses the same YAML policy engine as authorize_action. The default policy for execute_shell already covers the most dangerous patterns out of the box:
execute_shell:
rules:
- decision: DENY
when_any:
- 'payload contains "rm -rf"'
- 'payload contains "mkfs"'
- 'payload contains "dd if="'
- 'payload contains "> /dev/sd"'
reason: "Destructive shell pattern blocked"
- decision: REQUIRE_APPROVAL
when_any:
- 'payload contains "pip install"'
- 'payload contains "curl"'
- 'payload contains "wget"'
- 'payload contains "npm install"'
reason: "Network/install command requires approval"
You can override with your own policy file:
CANOPY_POLICY_FILE=/path/to/my-policy.yaml python my_agent.py
The Audit Log
Every @safe_tool call writes to the hash-chain audit log, including the function name, serialized arguments, decision, reason, and avid. The chain is tamper-evident — each entry is cryptographically linked to the previous one.
After your agent runs:
canopy-verify audit.log
# exit 0: chain valid
# exit 1: tampered or broken
You get a complete, verifiable record of every action your agent attempted and what Canopy decided.
Install
pip install --upgrade canopy-runtime
from canopy import safe_tool, authorize_action
Full changelog: CHANGELOG.md
GitHub: https://github.com/Mavericksantander/Canopy
If you're stacking @safe_tool with CrewAI or AutoGen instead of LangChain, the pattern is the same — wrap the function before it gets registered as a tool. Drop your setup in the comments if you run into anything unexpected.
Top comments (0)