Skip to content

Commit f368598

Browse files
SecAI-Hubclaude
andcommitted
Harden agent IPC, reduce audit sensitivity, expand test docs
- Tighten UI→Agent IPC from loopback TCP to Unix domain socket (/run/secure-ai/agent.sock), eliminating TCP attack surface - Change log_file_paths default to false to reduce audit sensitivity - Document dev-mode auth bypass as non-production (SECAI_DEV_MODE=1 required; never set on appliance image) - Expand test-matrix.md with per-class agent test breakdown (11 classes, 93 tests with exact counts and categories) - Update security-status.md M31 entry to reflect current truth Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 452dadd commit f368598

9 files changed

Lines changed: 132 additions & 61 deletions

File tree

docs/components/agent.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ User Intent
5555

5656
| Property | Value |
5757
|----------|-------|
58-
| Port | 8476 |
58+
| Socket | `/run/secure-ai/agent.sock` (Unix domain socket) |
5959
| Language | Python (Flask) |
60-
| Bind | 127.0.0.1 (loopback only) |
60+
| Bind | Unix socket in production; TCP 127.0.0.1:8476 in dev mode |
6161
| Systemd unit | `secure-ai-agent.service` |
6262
| Policy file | `/etc/secure-ai/policy/agent.yaml` |
6363
| Audit log | `/var/lib/secure-ai/logs/agent-audit.jsonl` |
@@ -125,8 +125,9 @@ The agent systemd service uses the same defense-in-depth as other services, with
125125

126126
The agent communicates with other services (registry, tool firewall, airlock, inference) over loopback HTTP. Authentication and access control:
127127

128-
- **Loopback-only binding**: All services bind to `127.0.0.1`, never `0.0.0.0`. Only processes on the local machine can reach service endpoints.
129-
- **Service tokens**: The agent reads a shared service token from `/run/secure-ai/service-token` (mounted read-only). This Bearer token authenticates requests to peer services with mutating endpoints. If the token file is absent (dev mode), auth is bypassed.
128+
- **Unix socket IPC (UI→Agent)**: The UI communicates with the agent over a Unix domain socket at `/run/secure-ai/agent.sock`, eliminating TCP attack surface for this channel. The agent still uses loopback TCP for outbound calls to Go services (registry, tool firewall, airlock) which do not support Unix sockets.
129+
- **Loopback-only binding**: Go services bind to `127.0.0.1`, never `0.0.0.0`. Only processes on the local machine can reach their endpoints.
130+
- **Service tokens**: The agent reads a shared service token from `/run/secure-ai/service-token` (mounted read-only). This Bearer token authenticates requests to peer services with mutating endpoints. **Production (appliance):** The token file MUST exist; if absent, the agent refuses to start. **Development only:** When `SECAI_DEV_MODE=1` is set explicitly, auth is bypassed to allow local testing without the full service stack. Dev-mode bypass is never enabled on the appliance image — the systemd unit does not set this variable, and the token file is provisioned at boot by `secure-ai-init.service`.
130131
- **UI→Agent auth**: The UI proxies agent requests through `/api/agent/*` endpoints. These are protected by session-based authentication (scrypt passphrase) and are not in the public endpoint list. All state-changing endpoints (approve, deny, cancel) require an authenticated session.
131132
- **CSRF protection**: The UI applies CSRF token validation on all POST requests, including agent proxy endpoints. Direct agent-to-agent calls are backend-only (no browser origin).
132133
- **Fail-closed**: If any peer service is unreachable, the agent returns an error rather than bypassing the service (e.g., tool firewall unreachable → tool invocation fails, airlock unreachable → outbound request fails).

docs/policy-schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ Minimal-logging policy for agent audit records.
396396
| `log_step_actions` | boolean | `true` | Log which actions were executed |
397397
| `log_raw_prompts` | boolean | `false` | Log raw LLM prompts (privacy risk — keep false) |
398398
| `log_raw_content` | boolean | `false` | Log raw file content (privacy risk — keep false) |
399-
| `log_file_paths` | boolean | `true` | Log which files were accessed (not their content) |
399+
| `log_file_paths` | boolean | `false` | Log which files were accessed (not their content) — off by default to reduce audit sensitivity; enable explicitly if needed |
400400

401401
### audit retention
402402

docs/security-status.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Last updated: 2026-03-10
3939
| Weight distribution fingerprinting | Implemented | M28 | Statistical fingerprinting of model weight distributions |
4040
| Garak LLM vulnerability scanner | Implemented | M29 | Garak integration for LLM vulnerability scanning |
4141
| gguf-guard deep GGUF integrity scanner | Implemented | M30 | Deep GGUF file format integrity and safety scanning |
42-
| Agent Mode (Phase 1: safe local autopilot) | Implemented | M31 | Policy-bound agent on :8476 with deny-by-default policy engine, capability tokens, hard budgets, storage gateway, 82 tests |
42+
| Agent Mode (Phase 1: safe local autopilot) | Implemented | M31 | Policy-bound agent with deny-by-default policy engine, capability tokens, hard budgets, storage gateway, workspace ID abstraction, Unix socket IPC (UI→Agent), 93 tests across 11 classes |
4343

4444
## Planned Features
4545

docs/test-matrix.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,23 @@ Last updated: 2026-03-10
3535
| test_canary_tripwire.py | tests/ | ~49 | Canary token placement, tripwire monitoring, alerts |
3636
| test_emergency_wipe.py | tests/ | ~65 | 3-level panic wipe, secure deletion, escalation |
3737
| test_update_rollback.py | tests/ | ~74 | Signed update verification, rollback triggers, recovery |
38-
| test_agent.py | tests/ | ~93 | Agent policy engine, capability tokens, storage gateway, budgets, planner, executor, API, workspace validation, security invariants |
38+
| test_agent.py | tests/ | 93 | Agent policy engine, capability tokens, storage gateway, budgets, planner, executor, API, workspace validation, security invariants |
39+
40+
### Agent test breakdown (test_agent.py)
41+
42+
| Class | Tests | Category | Description |
43+
|-------|-------|----------|-------------|
44+
| TestClassifyRisk | 3 | Unit | Risk-level classification for agent actions |
45+
| TestPolicyEngine | 15 | Unit / Security | Deny-by-default evaluation, always-deny invariants, hard-approval gates |
46+
| TestCapabilityTokens | 8 | Unit | Token creation, workspace scoping, mode-specific capabilities |
47+
| TestBudgets | 7 | Unit | Budget enforcement, limit checking, sensitive-mode tighter limits |
48+
| TestStorageGateway | 14 | Unit / Security | Path scope validation, sensitive file blocking, sensitivity ceiling, file size limits |
49+
| TestPlannerHeuristic | 8 | Unit | Heuristic plan decomposition, keyword-to-action mapping |
50+
| TestPlannerLLMParsing | 4 | Unit | LLM response parsing, malformed plan rejection |
51+
| TestExecutor | 6 | Integration | Step execution dispatch, tool firewall calls, budget tracking |
52+
| TestAgentAPI | 17 | Integration | HTTP endpoint contracts, input validation, task CRUD lifecycle, workspace ID resolution |
53+
| TestSecurityInvariants | 7 | Security | Fail-closed behavior, airlock/firewall bypass prevention, service-down handling |
54+
| TestDataModels | 4 | Unit | Task/step serialisation, status enum coverage |
3955

4056
## Shell Checks
4157

files/system/etc/secure-ai/policy/agent.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,4 @@ logging:
9393
log_step_actions: true
9494
log_raw_prompts: false
9595
log_raw_content: false
96-
log_file_paths: true # log which files were accessed (not content)
96+
log_file_paths: false # log which files were accessed (not content) — off by default to reduce audit sensitivity

files/system/usr/lib/systemd/system/secure-ai-agent.service

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Requires=secure-ai-registry.service secure-ai-tool-firewall.service
66
[Service]
77
Type=simple
88
ExecStart=/usr/libexec/secure-ai/agent
9-
Environment=BIND_ADDR=127.0.0.1:8476
9+
Environment=BIND_ADDR=unix:/run/secure-ai/agent.sock
1010
Environment=INFERENCE_URL=http://127.0.0.1:8465
1111
Environment=REGISTRY_URL=http://127.0.0.1:8470
1212
Environment=TOOL_FIREWALL_URL=http://127.0.0.1:8475
@@ -20,7 +20,8 @@ Environment=SERVICE_TOKEN_PATH=/run/secure-ai/service-token
2020
# Filesystem isolation
2121
DynamicUser=yes
2222
ReadOnlyPaths=/etc/secure-ai
23-
ReadOnlyPaths=/run/secure-ai
23+
ReadOnlyPaths=/run/secure-ai/service-token
24+
ReadWritePaths=/run/secure-ai/agent.sock
2425
ReadOnlyPaths=/var/lib/secure-ai/vault/user_docs
2526
ReadWritePaths=/var/lib/secure-ai/vault/outputs
2627
ReadWritePaths=/var/lib/secure-ai/logs

files/system/usr/lib/systemd/system/secure-ai-ui.service

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Environment=INFERENCE_URL=http://127.0.0.1:8465
1111
Environment=REGISTRY_URL=http://127.0.0.1:8470
1212
Environment=TOOL_FIREWALL_URL=http://127.0.0.1:8475
1313
Environment=AIRLOCK_URL=http://127.0.0.1:8490
14-
Environment=AGENT_URL=http://127.0.0.1:8476
14+
Environment=AGENT_SOCKET=/run/secure-ai/agent.sock
1515
Environment=SEARCH_MEDIATOR_URL=http://127.0.0.1:8485
1616
Environment=DIFFUSION_URL=http://127.0.0.1:8455
1717
Environment=APPLIANCE_CONFIG=/etc/secure-ai/config/appliance.yaml

services/agent/agent/app.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -471,16 +471,44 @@ def main():
471471
format="%(asctime)s %(name)s %(levelname)s %(message)s",
472472
)
473473

474-
host, port_str = _BIND_ADDR.rsplit(":", 1)
475-
port = int(port_str)
476-
477-
log.info("agent service starting on %s:%d", host, port)
478474
log.info("policy: %s", _POLICY_PATH)
479475
log.info("vault: %s", _VAULT_ROOT)
480476

481-
_audit_log("service_started", {"bind": _BIND_ADDR})
482-
483-
app.run(host=host, port=port, debug=False, threaded=True)
477+
if _BIND_ADDR.startswith("unix:"):
478+
# Production: listen on a Unix domain socket (no TCP attack surface).
479+
import socket as _socket
480+
from wsgiref.simple_server import WSGIServer, make_server
481+
482+
sock_path = _BIND_ADDR[len("unix:"):]
483+
484+
# Remove stale socket file if present (e.g. after unclean shutdown).
485+
try:
486+
os.unlink(sock_path)
487+
except FileNotFoundError:
488+
pass
489+
490+
class _UnixWSGIServer(WSGIServer):
491+
address_family = _socket.AF_UNIX
492+
493+
srv = make_server("", 0, app, server_class=_UnixWSGIServer)
494+
# Replace the TCP socket with a Unix one bound to sock_path.
495+
srv.socket.close()
496+
sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
497+
sock.bind(sock_path)
498+
os.chmod(sock_path, 0o660)
499+
sock.listen(128)
500+
srv.socket = sock
501+
502+
log.info("agent service starting on unix:%s", sock_path)
503+
_audit_log("service_started", {"bind": _BIND_ADDR})
504+
srv.serve_forever()
505+
else:
506+
# Dev / fallback: plain TCP on loopback.
507+
host, port_str = _BIND_ADDR.rsplit(":", 1)
508+
port = int(port_str)
509+
log.info("agent service starting on %s:%d (TCP — dev mode)", host, port)
510+
_audit_log("service_started", {"bind": _BIND_ADDR})
511+
app.run(host=host, port=port, debug=False, threaded=True)
484512

485513

486514
if __name__ == "__main__":

services/ui/ui/app.py

Lines changed: 68 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ def add_security_headers(response):
164164
REGISTRY_URL = os.getenv("REGISTRY_URL", "http://127.0.0.1:8470")
165165
TOOL_FIREWALL_URL = os.getenv("TOOL_FIREWALL_URL", "http://127.0.0.1:8475")
166166
AIRLOCK_URL = os.getenv("AIRLOCK_URL", "http://127.0.0.1:8490")
167-
AGENT_URL = os.getenv("AGENT_URL", "http://127.0.0.1:8476")
167+
AGENT_SOCKET = os.getenv("AGENT_SOCKET", "") # Unix socket path (production)
168+
AGENT_URL = os.getenv("AGENT_URL", "http://127.0.0.1:8476") # TCP fallback (dev)
168169
SEARCH_MEDIATOR_URL = os.getenv("SEARCH_MEDIATOR_URL", "http://127.0.0.1:8485")
169170
APPLIANCE_CONFIG = os.getenv("APPLIANCE_CONFIG", "/etc/secure-ai/config/appliance.yaml")
170171
QUARANTINE_DIR = Path(os.getenv("QUARANTINE_DIR", "/var/lib/secure-ai/quarantine"))
@@ -1695,35 +1696,75 @@ def update_health():
16951696

16961697

16971698
# ---------------------------------------------------------------------------
1698-
# Agent mode endpoints (proxy to agent service at :8476)
1699+
# Agent IPC helper (Unix socket in production, TCP fallback for dev)
1700+
# ---------------------------------------------------------------------------
1701+
1702+
def _agent_request(method: str, path: str, *, json_body=None, params=None, timeout=10):
1703+
"""Send an HTTP request to the agent service.
1704+
1705+
Uses a Unix domain socket when AGENT_SOCKET is set (production),
1706+
falls back to TCP via AGENT_URL for local development.
1707+
"""
1708+
if AGENT_SOCKET:
1709+
import http.client
1710+
import json as _json
1711+
import socket as _socket
1712+
1713+
conn = http.client.HTTPConnection("localhost")
1714+
sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
1715+
sock.settimeout(timeout)
1716+
sock.connect(AGENT_SOCKET)
1717+
conn.sock = sock
1718+
1719+
headers = {"Host": "localhost"}
1720+
body = None
1721+
if json_body is not None:
1722+
body = _json.dumps(json_body).encode()
1723+
headers["Content-Type"] = "application/json"
1724+
if params:
1725+
from urllib.parse import urlencode
1726+
path = f"{path}?{urlencode(params)}"
1727+
1728+
conn.request(method, path, body=body, headers=headers)
1729+
resp = conn.getresponse()
1730+
data = resp.read()
1731+
conn.close()
1732+
return _json.loads(data), resp.status
1733+
else:
1734+
url = f"{AGENT_URL}{path}"
1735+
if method == "GET":
1736+
resp = requests.get(url, params=params, timeout=timeout)
1737+
else:
1738+
resp = requests.post(url, json=json_body, timeout=timeout)
1739+
return resp.json(), resp.status_code
1740+
1741+
1742+
# ---------------------------------------------------------------------------
1743+
# Agent mode endpoints (proxy to agent service)
16991744
# ---------------------------------------------------------------------------
17001745

17011746
@app.route("/api/agent/task", methods=["POST"])
17021747
def agent_submit_task():
17031748
"""Submit a task to the agent service."""
17041749
body = request.get_json(silent=True) or {}
17051750
try:
1706-
resp = requests.post(
1707-
f"{AGENT_URL}/v1/task",
1708-
json=body,
1709-
timeout=30,
1710-
)
1751+
data, status = _agent_request("POST", "/v1/task", json_body=body, timeout=30)
17111752
_ui_audit.append("agent_task_submitted", {
17121753
"intent_length": len(body.get("intent", "")),
17131754
"mode": body.get("mode", "standard"),
17141755
})
1715-
return jsonify(resp.json()), resp.status_code
1716-
except requests.RequestException as e:
1756+
return jsonify(data), status
1757+
except Exception as e:
17171758
return jsonify({"error": f"agent service unavailable: {e}"}), 503
17181759

17191760

17201761
@app.route("/api/agent/task/<task_id>")
17211762
def agent_get_task(task_id):
17221763
"""Get task status from agent service."""
17231764
try:
1724-
resp = requests.get(f"{AGENT_URL}/v1/task/{task_id}", timeout=10)
1725-
return jsonify(resp.json()), resp.status_code
1726-
except requests.RequestException as e:
1765+
data, status = _agent_request("GET", f"/v1/task/{task_id}")
1766+
return jsonify(data), status
1767+
except Exception as e:
17271768
return jsonify({"error": f"agent service unavailable: {e}"}), 503
17281769

17291770

@@ -1732,14 +1773,10 @@ def agent_approve_steps(task_id):
17321773
"""Approve pending steps in an agent task."""
17331774
body = request.get_json(silent=True) or {}
17341775
try:
1735-
resp = requests.post(
1736-
f"{AGENT_URL}/v1/task/{task_id}/approve",
1737-
json=body,
1738-
timeout=10,
1739-
)
1776+
data, status = _agent_request("POST", f"/v1/task/{task_id}/approve", json_body=body)
17401777
_ui_audit.append("agent_steps_approved", {"task_id": task_id})
1741-
return jsonify(resp.json()), resp.status_code
1742-
except requests.RequestException as e:
1778+
return jsonify(data), status
1779+
except Exception as e:
17431780
return jsonify({"error": f"agent service unavailable: {e}"}), 503
17441781

17451782

@@ -1748,29 +1785,21 @@ def agent_deny_steps(task_id):
17481785
"""Deny pending steps in an agent task."""
17491786
body = request.get_json(silent=True) or {}
17501787
try:
1751-
resp = requests.post(
1752-
f"{AGENT_URL}/v1/task/{task_id}/deny",
1753-
json=body,
1754-
timeout=10,
1755-
)
1788+
data, status = _agent_request("POST", f"/v1/task/{task_id}/deny", json_body=body)
17561789
_ui_audit.append("agent_steps_denied", {"task_id": task_id})
1757-
return jsonify(resp.json()), resp.status_code
1758-
except requests.RequestException as e:
1790+
return jsonify(data), status
1791+
except Exception as e:
17591792
return jsonify({"error": f"agent service unavailable: {e}"}), 503
17601793

17611794

17621795
@app.route("/api/agent/task/<task_id>/cancel", methods=["POST"])
17631796
def agent_cancel_task(task_id):
17641797
"""Cancel an agent task."""
17651798
try:
1766-
resp = requests.post(
1767-
f"{AGENT_URL}/v1/task/{task_id}/cancel",
1768-
json={},
1769-
timeout=10,
1770-
)
1799+
data, status = _agent_request("POST", f"/v1/task/{task_id}/cancel", json_body={})
17711800
_ui_audit.append("agent_task_cancelled", {"task_id": task_id})
1772-
return jsonify(resp.json()), resp.status_code
1773-
except requests.RequestException as e:
1801+
return jsonify(data), status
1802+
except Exception as e:
17741803
return jsonify({"error": f"agent service unavailable: {e}"}), 503
17751804

17761805

@@ -1779,23 +1808,19 @@ def agent_list_tasks():
17791808
"""List agent tasks."""
17801809
limit = request.args.get("limit", 50)
17811810
try:
1782-
resp = requests.get(
1783-
f"{AGENT_URL}/v1/tasks",
1784-
params={"limit": limit},
1785-
timeout=10,
1786-
)
1787-
return jsonify(resp.json()), resp.status_code
1788-
except requests.RequestException as e:
1811+
data, status = _agent_request("GET", "/v1/tasks", params={"limit": limit})
1812+
return jsonify(data), status
1813+
except Exception as e:
17891814
return jsonify({"error": f"agent service unavailable: {e}"}), 503
17901815

17911816

17921817
@app.route("/api/agent/modes")
17931818
def agent_list_modes():
17941819
"""List available agent operating modes."""
17951820
try:
1796-
resp = requests.get(f"{AGENT_URL}/v1/modes", timeout=5)
1797-
return jsonify(resp.json()), resp.status_code
1798-
except requests.RequestException as e:
1821+
data, status = _agent_request("GET", "/v1/modes", timeout=5)
1822+
return jsonify(data), status
1823+
except Exception as e:
17991824
return jsonify({"error": f"agent service unavailable: {e}"}), 503
18001825

18011826

0 commit comments

Comments
 (0)