Checking the arguments, not just the tool
Lesson 5 decided on the tool's name alone. But lookup_order is safe and read_file is safe, right up until the path is /etc/passwd. This lesson goes one level deeper, and finds a trap.
The engine has built-in checks that read the arguments. Allow a file tool, then try to use it on somewhere it should not go.
from agent_control_plane import PolicyEngine
policy = PolicyEngine()
policy.add_constraint("support", ["read_file"])
print(policy.check_violation("support", "read_file", {"path": "notes.txt"}))
print(policy.check_violation("support", "read_file", {"path": "/etc/passwd"}))The tool was allowed and the call was still refused. The engine keeps a list of protected directories and compares the path against it, after resolving .. so that a path walking upwards out of a safe directory cannot sneak past.
The same idea for queries and commands
print(policy.check_violation("support", "database_query", {"query": "SELECT 1"}))
print(policy.check_violation("support", "database_query", {"query": "DROP TABLE orders"}))A destructive statement is caught by a pattern, and the refusal names the pattern that fired so you can see which rule you hit rather than guessing.
The trap
Now the same dangerous query, through a tool with a different name. This is the bug worth meeting on purpose.
policy.add_constraint("support", ["database_query", "run_sql"])
dangerous = {"query": "DROP TABLE orders"}
print("database_query:", policy.check_violation("support", "database_query", dangerous))
print("run_sql: ", policy.check_violation("support", "run_sql", dangerous))Identical arguments, opposite answers. The second call is allowed, and nothing warned you.
Every one of these built-in checks is keyed on a hardcoded tool name. The path check runs for read_file, write_file and delete_file. The SQL check runs for database_query and database_write. The command check runs for execute_code and run_command. Call your tool anything else and none of them apply.
run_sql, query or sql gets no SQL protection at all.The fix
There are two honest ways out. Name your tools the names the engine already knows, or write the rule yourself. The second is lesson 8; the first costs nothing.
NAMES = {"run_sql": "database_query"}
def check(policy, role, tool, args):
return policy.check_violation(role, NAMES.get(tool, tool), args)
policy.add_constraint("support", ["database_query"])
print(check(policy, "support", "run_sql", {"query": "DROP TABLE orders"}))Mapping your own tool name onto the one the engine recognises gets the built-in checks back. It is a small adapter, and it belongs next to the gate rather than scattered through the tools.
- Try
read_filewith a path ofsafe/../../etc/passwdand confirm the normalisation catches it. - Try an
api_callwith an endpoint startinginternal://. - Add
\nto a path and read the different refusal you get.
Little by little, you're building something great.