When Claude starts asking for *read* or *write* permissions on your hard drive, the first thing you should feel is a spike of anxiety. A rogue request can leak secrets, overwrite logs, or even trigger ransomware‑style deletes before you see a warning. In my last production rollout, an un‑sandboxed file‑read tool let the agent crawl the entire `/home` tree, blowing up our storage bill and exposing user‑data snapshots. The fix? Treat the agent as a **tool**, not a user, and wrap every filesystem call in a hardened permission layer that only executes after a real‑time human says “yes”.
- Never give Claude unrestricted filesystem access; enforce a sandbox.
- Define explicit JSON‑schema‑driven tools (read, list, write) that stay inside a base directory.
- Use a PyQt6 GUI to intercept each tool request and ask the user for consent.
- Canonicalize paths, block symlinks, and reject directory‑traversal attempts.
- Keep a per‑session ACL manager and audit log for revocation and forensic analysis.
Before you start: Python 3.11+, Anthropic Python SDK ≥ 1.0, PyQt6 ≥ 6.5, a valid Anthropic API key, and a dedicated sandbox folder (e.g. `~/claude_sandbox`). Install dependencies with pip install anthropic[tool_use] pyqt6 watchdog. You’ll also need asyncio‑compatible networking (Python’s built‑in event loop works fine).
Secure File System Access for Claude Agents
Secure file system access for Claude agents needs a sandboxed architecture. Use the Anthropic SDK’s Tool Use to create a Python permission layer, exposing only safe functions such as `read_file` that stay inside a predefined directory. A PyQt GUI then intercepts every request, prompting real‑time user approval before execution, enforcing least‑privilege security.
Why Sandboxed File Access is Non‑Negotiable for Claude Agents
The Risk of Unrestricted Agent Filesystem Access
Claude can generate perfectly valid shell‑style paths. If those paths resolve outside a controlled area, an attacker could harvest configuration files, API keys, or even delete critical binaries. In 2026, the “AI‑Pwn” report showed that 73 % of breaches involving AI agents originated from sloppy file permissions, not from model exploits.
Core Security Principles: Least Privilege & Explicit Consent
We apply the classic “least privilege” rule: the agent only gets the *exact* operations it needs, and only on the *exact* files the user approved. Consent is always a human‑in‑the‑loop (HITL) step, mirroring the workflow described in my [HITL Approval Workflows for ADK Agents (2026 Guide)](https://nileshblog.tech/hitl-approval-adk-agents/).
Architectural Goal: Agent‑as‑Tool, Not Agent‑as‑User
Think of Claude as a microservice that calls your own API to read or write files. It never touches the OS directly; the sandbox layer mediates every call, validates the path, and returns a structured JSON response or an error you can log.
Architecting the Secure Interface: Python Stack & Anthropic SDK
2026 Tool Stack Breakdown
| Layer | Technology | Reason |
|---|---|---|
| Agent runtime | **Anthropic SDK (v1.0+)** – `client.messages.create` with `tools` | Native tool‑use support, streaming, and JSON schema validation |
| Permission layer | **Python 3.11+** – `pathlib`, `os`, `asyncio` | Simple, async‑first, built‑in OS path handling |
| GUI | **PyQt6** (Qt 6.5) – `QMainWindow`, `QFileDialog`, `QThreadPool` | Native desktop dialogs, non‑blocking event loop |
| Watchdog | **watchdog** – `Observer` | Optional live‑view of sandbox changes for audit |
| Logging | **logging** – JSON formatter | Structured logs for SIEM integration |
Defining the Agent’s “Toolbox”: JSON Schema for File Operations
Claude expects tools to be described in JSON schema. Here’s a minimal yet complete definition:
{
"type": "function",
"name": "read_file",
"description": "Read a plain‑text file from the sandbox.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path inside the sandbox (e.g., \"docs/report.txt\")"
}
},
"required": ["path"],
"additionalProperties": false
}
}
We’ll ship three tools: `read_file`, `list_dir`, `write_file`. Each tool returns a JSON payload so Claude can continue reasoning.
State Management for Session Isolation and Permission Revocation
A singleton `SessionManager` holds a per‑session ACL dictionary:
session_acls: Dict[str, Set[Path]] # session_id → allowed absolute paths
When the user grants a request, the absolute path is stored; future identical requests are auto‑approved, avoiding repetitive dialogs. Revocation clears the set and forces a new prompt.
Step‑by‑Step: Building the File System Permission Layer
Implementing the Base Sandbox Directory & ACL Manager
# sandbox_manager.py – v1.0 (2026)
import os
import logging
from pathlib import Path
from threading import RLock
from typing import Set, Dict
BASE_SANDBOX = Path.home() / "claude_sandbox"
BASE_SANDBOX.mkdir(parents=True, exist_ok=True)
log = logging.getLogger(__name__)
log.setLevel(logging.INFO)
class ACLManager:
"""Thread‑safe manager for per‑session allowed paths."""
def __init__(self):
self._acls: Dict[str, Set[Path]] = {}
self._lock = RLock()
def grant(self, session_id: str, path: Path) -> None:
with self._lock:
self._acls.setdefault(session_id, set()).add(path.resolve())
log.info("Granted %s for session %s", path, session_id)
def revoke(self, session_id: str) -> None:
with self._lock:
self._acls.pop(session_id, None)
log.info("Revoked all permissions for session %s", session_id)
def is_allowed(self, session_id: str, path: Path) -> bool:
"""Check if *absolute* path is within allowed set and inside the sandbox."""
try:
resolved = path.resolve()
except Exception:
return False
if not resolved.is_relative_to(BASE_SANDBOX):
return False
with self._lock:
return resolved in self._acls.get(session_id, set())
Coding the Tool Functions: `read_file`, `list_dir`, `write_file` (with Validation)
# tools.py – v1.0 (2026)
import json
from pathlib import Path
from typing import Any, Dict
from sandbox_manager import BASE_SANDBOX, ACLManager
acl = ACLManager()
def _sanitize(rel_path: str) -> Path:
"""Convert user‑provided relative path into an absolute, safe Path."""
# Reject absolute paths outright
if Path(rel_path).is_absolute():
raise ValueError("Absolute paths are not allowed")
# Resolve symlinks and normalize '..' segments
candidate = (BASE_SANDBOX / rel_path).resolve()
if not candidate.is_relative_to(BASE_SANDBOX):
raise ValueError("Path traversal outside sandbox detected")
return candidate
def read_file(session_id: str, params: Dict[str, Any]) -> Dict[str, Any]:
try:
target = _sanitize(params["path"])
if not acl.is_allowed(session_id, target):
raise PermissionError("Access not approved by user")
content = target.read_text(encoding="utf-8")
return {"status": "ok", "content": content}
except Exception as exc:
return {"status": "error", "message": str(exc)}
def list_dir(session_id: str, params: Dict[str, Any]) -> Dict[str, Any]:
try:
target = _sanitize(params.get("path", "."))
if not acl.is_allowed(session_id, target):
raise PermissionError("Access not approved by user")
entries = [p.name for p in target.iterdir()]
return {"status": "ok", "entries": entries}
except Exception as exc:
return {"status": "error", "message": str(exc)}
def write_file(session_id: str, params: Dict[str, Any]) -> Dict[str, Any]:
try:
target = _sanitize(params["path"])
data = params.get("content", "")
if not acl.is_allowed(session_id, target):
raise PermissionError("Access not approved by user")
target.write_text(data, encoding="utf-8")
return {"status": "ok"}
except Exception as exc:
return {"status": "error", "message": str(exc)}
Injecting the Sandboxed Tool Set into Claude’s Agent Runtime
# agent_runtime.py – v1.0 (2026)
import asyncio
import json
import logging
from anthropic import Anthropic, AsyncMessage
from tools import read_file, list_dir, write_file
from sandbox_manager import ACLManager, BASE_SANDBOX
client = Anthropic(api_key="YOUR_ANTHROPIC_API_KEY")
log = logging.getLogger(__name__)
TOOLS = [
{
"type": "function",
"name": "read_file",
"description": "Read a file from the sandbox.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "list_dir",
"description": "List entries in a sandbox directory.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": [],
"additionalProperties": False,
},
},
{
"type": "function",
"name": "write_file",
"description": "Write a string to a sandbox file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
},
]
async def handle_tool_call(session_id: str, tool_name: str, arguments: str) -> str:
"""Dispatch to the corresponding sandbox tool after user approval."""
args = json.loads(arguments)
# The GUI will push a request onto an asyncio.Queue (see gui.py)
from gui import permission_queue
await permission_queue.put((session_id, tool_name, args))
# Block until GUI sends back a response
response = await permission_queue.get()
# response = {"approved": bool, "message": str}
if not response["approved"]:
return json.dumps({"status": "error", "message": "User denied permission"})
# Grant the path for this session so future calls are auto‑approved
ACLManager().grant(session_id, Path(BASE_SANDBOX / args["path"]).resolve())
# Call the actual implementation
result = globals()[tool_name](session_id, args)
return json.dumps(result)
async def main_loop():
session_id = "session-001"
messages = [
{"role": "user", "content": "Please read the file `notes/todo.txt`."}
]
async with client.messages.stream(
model="claude-3-5-sonnet-202406",
max_tokens=1024,
temperature=0,
system="You are a helpful assistant that can only read/write files via the provided tools.",
messages=messages,
tools=TOOLS,
) as stream:
async for event in stream:
if event.type == "tool_use":
tool_res = await handle_tool_call(session_id, event.name, event.input)
# Push tool result back to the model
await stream.send_tool_result(event.id, tool_res)
elif event.type == "message_done":
log.info("Assistant finished: %s", event.content[0].text)
break
if __name__ == "__main__":
asyncio.run(main_loop())
Building the Guardian GUI: Real‑Time Permission Override & Monitoring
GUI Framework Choice: PyQt for Robust Desktop Control (2026)
PyQt6 gives us native file‑chooser dialogs, non‑blocking `QThreadPool`, and an event‑driven model that meshes nicely with `asyncio` via `QEventLoop`. While Tkinter would also work, PyQt’s signals/slots let us cleanly separate UI from the agent thread.
Designing the Interactive Permission Dialog & File Explorer View
The dialog shows three pieces of information:
- **Tool name** (read, list, write)
- **Requested path** (relative, highlighted)
- **Optional preview** (first 200 bytes for reads)
Two buttons – *Approve* and *Deny* – send a structured response back through an `asyncio.Queue`.
Implementing the Event Loop: Linking GUI Actions to Agent Tool Execution
# gui.py – v1.0 (2026)
import asyncio
import json
import sys
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication,
QDialog,
QLabel,
QPushButton,
QVBoxLayout,
QTextEdit,
QFileDialog,
)
from Py