This document covers the system design, request lifecycle, security model, database schema, and API reference.
┌─────────────────────────────────────────────────────────┐
│ VPS │
│ │
│ ┌──────────────┐ │
│ │ AI Agent / │ │
│ │ User shell │ │
│ │ │ │
│ │ rm -rf /var │ │
│ └──────┬───────┘ │
│ │ execv │
│ ▼ │
│ ┌──────────────┐ │
│ │ safe-rm │ Python script at /usr/local/bin/rm │
│ │ │ │
│ │ 1. Parse args │
│ │ 2. Check session │
│ │ 3. Assess risk │
│ │ 4. POST to API (if risky) │
│ │ 5. Poll for status │
│ │ 6. exec real rm (if approved) │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────┐
│ Approval Server │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ Express.js │ │ SQLite DBs │ │
│ │ │ │ │ │
│ │ /api/delete-│────►│ delete_requests│ │
│ │ requests │ │ │ │
│ │ │ │ claude_events │ │
│ │ /api/claude-│────►│ │ │
│ │ events │ │ claude_auto_ │ │
│ │ │ │ approve │ │
│ └──────┬───────┘ └────────────────┘ │
│ │ POST (fire-and-forget) │
└─────────┼───────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ n8n │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ Webhook │────►│ Send Email │ │
│ │ Trigger │ │ (SMTP/Gmail) │ │
│ └──────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
User's Inbox
[Approve] [Deny]
│
│ Click (GET request with token)
▼
Approval Server
updates status
When rm is invoked, the shell resolves /usr/local/bin/rm (the safe-rm symlink) before /bin/rm because /usr/local/bin is first in $PATH.
safe-rm separates its own flags (--dry-run, --config, etc.) from the arguments intended for rm, then:
- If no arguments are given, it passes through to the real
rmimmediately. - If the session is not guarded (based on IP filtering), it passes through.
- It assesses risk on the remaining arguments.
The assess_risk function checks:
- Hard-blocked paths:
/,~,.,..-- immediately blocked with exit code 1, no API call. - Flags:
-r/-R/--recursiveand-f/--forceare risk factors. - Glob expansion: Patterns like
*.txtare expanded and each result is checked. - Protected paths: Each resolved path is checked against the configured protected path list.
- Safe pattern exemption: If all resolved paths match a safe pattern (e.g.,
/tmp/build-*), the command is allowed through. - File count: For recursive deletes, the total file count is tallied (capped at 1000). If it exceeds
FILE_THRESHOLD, it is a risk factor.
If no risk factors remain after safe pattern filtering, the command passes through to the real rm.
The client constructs a JSON payload:
{
"request_id": "del_1700000000_12345",
"command": "rm -rf /var/www/data",
"risk_reasons": ["recursive deletion (-r/-R)", "protected path: /var/www"],
"affected_paths": ["/var/www/data"],
"file_count": 47,
"user": "deploy",
"hostname": "prod-01",
"cwd": "/home/deploy",
"timestamp": 1700000000
}The payload is signed with HMAC-SHA256 using the shared secret. The signature is sent in the X-SafeRM-Signature header. The server verifies the signature before processing.
The server:
- Verifies the HMAC signature (timing-safe comparison).
- Generates two random 32-byte tokens: one for approve, one for deny.
- Stores the request in SQLite with a TTL (default: 10 minutes).
- Constructs approve/deny URLs containing the request ID and token.
- Fires a webhook POST to n8n with the request details and URLs.
- Returns
201 Createdwith the request ID.
n8n receives the webhook, formats an HTML email with the command details and action buttons (Approve, Deny, Stop Session), and sends it to the configured admin address.
The user clicks a link in the email. The server:
- Looks up the request by ID.
- Verifies the token (timing-safe comparison against the stored token).
- Checks that the token has not been used and the request has not expired.
- Updates the status to
approvedordeniedand marks the token as used. - Returns an HTML confirmation page.
The client polls GET /api/delete-requests/{id}/status every POLL_INTERVAL seconds. When the status changes from pending:
- approved: The client calls
os.execvto execute the real/bin/rmwith the original arguments. - denied: The client exits with code 1.
- expired: The client exits with code 1.
- stopped: The client prints a stop message and exits with code 130 (simulating SIGINT).
If POLL_TIMEOUT is reached with no response, the client exits with code 1.
Every request from the client to the server includes an HMAC-SHA256 signature computed over the JSON-serialized payload (keys sorted, no extra whitespace). The server recomputes the signature and compares using crypto.timingSafeEqual. This ensures:
- Requests cannot be forged without the shared secret.
- Payloads cannot be tampered with in transit (integrity).
Each approval request generates two cryptographically random tokens (32 bytes each). The approve token and deny token are stored in the database and embedded in the email URLs. Once a token is used, the used_token flag is set and the link cannot be reused.
All token comparisons use crypto.timingSafeEqual to prevent timing side-channel attacks. If the token lengths differ or decoding fails, the comparison returns false.
Pending requests expire after APPROVAL_TIMEOUT_SECONDS (default: 600 seconds). The server checks expiry on every status poll and on approve/deny link access. Expired pending requests are cleaned up from the database periodically (every 60 seconds, with a 1-hour grace period).
The paths /, ~, ., and .. are unconditionally blocked on the client side. No API request is made. The client exits with code 1 immediately.
All user-supplied data rendered in HTML response pages is escaped via a dedicated escapeHtml function that replaces &, <, >, ", and '.
The server applies a per-IP rate limit (default: 100 requests/minute) using express-rate-limit.
The server should be deployed behind a TLS-terminating reverse proxy (nginx, Caddy). The example nginx config enforces HTTPS with TLS 1.2+.
The server uses two SQLite databases stored in DATA_DIR.
Table: delete_requests
| Column | Type | Description |
|---|---|---|
id |
TEXT PK | Request ID (e.g., del_1700000000_12345) |
command |
TEXT | Full rm command string |
risk_reasons |
TEXT | JSON array of risk reason strings |
affected_paths |
TEXT | JSON array of resolved absolute paths (max 50) |
file_count |
INTEGER | Number of affected files |
user |
TEXT | Unix username |
hostname |
TEXT | Server hostname |
cwd |
TEXT | Working directory at time of command |
status |
TEXT | pending, approved, denied |
created_at |
INTEGER | Unix timestamp |
expires_at |
INTEGER | Unix timestamp |
approved_by |
TEXT | Who approved/denied (currently always admin) |
approved_at |
INTEGER | Unix timestamp of resolution |
approve_token |
TEXT | 64-char hex token for approval |
deny_token |
TEXT | 64-char hex token for denial |
used_token |
INTEGER | 0 or 1 -- whether any token has been used |
Table: claude_events
| Column | Type | Description |
|---|---|---|
id |
TEXT PK | Event ID |
event_type |
TEXT | Event type (e.g., tool_approval) |
session_id |
TEXT | Claude Code session identifier |
tool_name |
TEXT | Name of the tool being called |
tool_input |
TEXT | Tool input (JSON string) |
status |
TEXT | pending, approved, denied, responded, expired |
approve_token |
TEXT | 64-char hex token |
deny_token |
TEXT | 64-char hex token |
response_token |
TEXT | 64-char hex token for custom text responses |
response_text |
TEXT | Custom response text (if responded) |
auto_approve_until |
INTEGER | Unix timestamp if auto-approve was used |
created_at |
INTEGER | Unix timestamp |
expires_at |
INTEGER | Unix timestamp |
resolved_at |
INTEGER | Unix timestamp of resolution |
resolved_by |
TEXT | email or auto_approve |
Table: claude_auto_approve
| Column | Type | Description |
|---|---|---|
session_id |
TEXT PK | Claude Code session identifier |
until |
INTEGER | Unix timestamp when auto-approve expires |
created_at |
INTEGER | Unix timestamp |
Both databases use WAL journal mode for concurrent read/write performance.
GET /health
Response 200:
{ "status": "ok", "uptime": 12345.67 }POST /api/delete-requests
Headers:
X-SafeRM-Signature: HMAC-SHA256 signature of the JSON body
Body:
{
"request_id": "del_1700000000_12345",
"command": "rm -rf /var/www/data",
"risk_reasons": ["recursive deletion (-r/-R)"],
"affected_paths": ["/var/www/data"],
"file_count": 47,
"user": "deploy",
"hostname": "prod-01",
"cwd": "/home/deploy",
"timestamp": 1700000000
}Response 201:
{
"success": true,
"request_id": "del_1700000000_12345",
"approval_url": "https://safe-rm.example.com/api/delete-requests/approve/del_1700000000_12345/abc123...",
"expires_at": 1700000600
}GET /api/delete-requests/:id/status
Response 200:
{ "status": "pending", "approved_by": null }Status values: pending, approved, denied, expired.
GET /api/delete-requests/approve/:id/:token
Returns an HTML confirmation page. The token must match the stored approve token. One-time use.
GET /api/delete-requests/deny/:id/:token
Returns an HTML confirmation page. The token must match the stored deny token. One-time use.
GET /api/delete-requests?status=pending&limit=50
Response 200:
{
"success": true,
"requests": [{ "id": "...", "command": "...", "status": "pending", ... }]
}POST /api/claude-events
Headers:
X-Claude-Signature: HMAC-SHA256 signature of the JSON body
Body:
{
"event_id": "evt_abc123",
"event_type": "tool_approval",
"session_id": "session-xyz",
"tool_name": "Bash",
"tool_input": "rm -rf /tmp/build",
"timestamp": "2024-01-01T00:00:00Z"
}Response 201:
{ "success": true, "event_id": "evt_abc123", "expires_at": 1700000600 }If the session has an active auto-approve window:
Response 200:
{ "success": true, "event_id": "evt_abc123", "status": "auto_approved", "auto_approved": true }GET /api/claude-events/:id/status
Response 200:
{ "status": "pending", "response_text": null, "resolved_by": null }Status values: pending, approved, denied, responded, expired.
GET /api/claude-events/approve/:id/:token
GET /api/claude-events/deny/:id/:token
GET /api/claude-events/respond/:id/:token
Renders an HTML form. Submitting the form sends a POST:
POST /api/claude-events/respond/:id/:token
Content-Type: application/x-www-form-urlencoded
response=Your+message+here
Sets status to responded with the custom text in response_text.
GET /api/claude-events/auto-approve/:id/:token/:minutes
- Approves the current event.
- Sets auto-approve for the session for
:minutesminutes (1--60, clamped). - Subsequent events from the same session are auto-approved without email.
GET /api/claude-events?status=pending&session_id=xyz&limit=50
Response 200:
{
"success": true,
"events": [{ "id": "...", "tool_name": "...", "status": "pending", ... }]
}