Files
tiennm99 6943160d7c docs: add user documentation and system overview
Configuration guide, security architecture, Telegram setup, implementation journal, and README describing the personal AI agent gateway with policy engine and Telegram integration.
2026-08-01 18:19:36 +07:00

10 KiB

Security model

Read this before you point MTClaw at a real Telegram bot. It exists so you can make an informed decision about running this at all - it is not softened for comfort.

The threat model, stated plainly

This is the phase 5 design document's Security Model, verbatim:

MTClaw turns a Telegram message into shell execution on the host. Anyone who can message the bot, and anyone who can inject text the model reads (a web page it fetches, a file it opens, a forwarded message), is attempting to run commands as your user account.

The deny-list is the only real enforcement boundary. The LLM classifier in auto mode is a convenience feature, not a security control — it can be talked out of its judgment by the same injection that produced the command.

Concretely:

  1. Deny-list is checked first and cannot be overridden - not by the allow-list, not by the classifier, not by user approval. A deny match is refused outright and the model is told it is refused permanently, so it stops rephrasing.
  2. Injection reaches exec through tool output. web_fetch output is untrusted text that lands in the context. The classifier therefore only ever sees the command string and its cwd - never page content - so a page cannot address the classifier directly. This narrows the channel; it does not close it.
  3. auto mode ships off. Default is approval. onboard does not offer auto. Enabling it is a deliberate config edit, and doctor prints a warning when it is on.
  4. Confinement is not a sandbox. tools.exec.cwd is a starting directory, not a jail; any command can cd. Real isolation means containers/VMs and is explicitly out of scope. Say so rather than implying safety we do not provide.
  5. Denied ≠ error. A refusal returns to the model as a tool result. The model should explain to the user that it was blocked, not retry silently.

The plainest possible statement of the consequence: the Telegram bot token is equivalent to shell access on this machine. Anyone holding the token can message the bot as if they were it; anyone the bot accepts messages from (your allow_from list) can attempt to run commands as your user account, subject only to the deny-list below. Telegram bot usernames are publicly discoverable - do not hand the token, or an allow_from entry, to anyone you would not hand a shell to.

The exec decision pipeline

Every exec tool call goes through this pipeline, in this order, with no shortcuts:

flowchart TD
    CMD[exec tool call] --> PARSE{tokenizes?}
    PARSE -->|no| ASK1[ASK / refuse]
    PARSE -->|yes| DENY{matches tools.exec.deny?}
    DENY -->|yes| REFUSE["REFUSE permanently<br/>audit: denied_rule<br/>never prompts"]
    DENY -->|no| ALLOW{matches tools.exec.allow?}
    ALLOW -->|yes| RUN1["RUN<br/>audit: allowed_rule"]
    ALLOW -->|no| MODE{tools.exec.mode}
    MODE -->|off| NOTREG[tool not registered]
    MODE -->|approval| ASK2[ASK user]
    MODE -->|auto beta| CLS[LLM risk classifier]
    CLS -->|risk none/low<br/>no confirm_on category| RUN2["RUN<br/>audit: auto_allowed"]
    CLS -->|risk high or<br/>confirm_on hit| ASK3[ASK user, include reason]
    CLS -->|error / timeout / unparseable| ASK4[ASK user, fail closed]
    ASK2 & ASK3 & ASK4 --> APPR{Approver verdict}
    APPR -->|approve| RUN3["RUN<br/>audit: approved"]
    APPR -->|deny| REF2["refuse to model<br/>audit: denied_user"]
    APPR -->|timeout / no approver| REF3["refuse to model<br/>audit: expired"]

Every ambiguity fails closed to ASK, never to RUN: a regex compile error, a classifier error/timeout/unparseable response, an unparseable command, an approval timeout, or the absence of an approver at all (cron turns always use DenyAllApprover) - all of these ask a human, or refuse if none exists, never silently execute.

What the deny-list does and does not stop

The default deny-list (written by onboard, OS-appropriate - see internal/tools/deny_defaults.go) catches unconditionally destructive or irreversible commands: recursive/forced rm (short flags, long flags, and path-prefixed forms), find -delete, mkfs, writing to raw disk devices, fork bombs, shutdown/reboot/halt, chmod 777 /, pipe-to-shell downloads, force-pushes, account/password commands, and shell history clearing - and the PowerShell equivalents on Windows.

It stops accidents and naive prompt injection. It does not stop a determined attacker who already has message access. Base64-decode-then-pipe, environment-variable indirection, writing a script to disk and then running it, or simply rephrasing a blocked command in a way the regex does not match, are all realistic bypasses. The honest boundary is: do not give the bot token, or an allow_from entry, to anyone you would not give a shell to. That sentence belongs here and in the README, not just here.

Known accepted false positives, recorded so a confused user knows the fix instead of discovering it by trial and error: docker rm -f <container> and npm rm -f <package> both match the rm deny patterns via the rm word boundary and are refused, even though neither is the dangerous rm -rf they were written to catch. Over-blocking is the correct failure direction here - a false-positive refusal costs you one tools.exec.allow entry; an under-block costs data - but it is worth knowing about before it surprises you mid-conversation.

An empty deny-list with tools.exec.enabled: true is not a load error, but mtclaw doctor warns about it loudly, because it removes the only real enforcement boundary in this design entirely.

Why auto mode is beta

auto mode replaces "ask a human for every unmatched command" with "ask a cheap LLM classifier, and only interrupt for what it flags as risky." This is a genuine usability improvement for benign, repetitive commands - and a real weakening of the security posture, because:

  • The classifier is itself an LLM call, and LLM calls can be steered by adversarial input. The same prompt injection that produced a dangerous command in the first place can plausibly talk the classifier into scoring it as low-risk.
  • The classifier only ever sees the command string, its cwd, and the shell - never the tool output that may have produced the command - which narrows the injection channel but does not close it.
  • Any classifier failure (timeout, malformed response, error) already fails closed to asking a human; the risk is specifically in what the classifier chooses to approve unattended when it is working correctly.

Default is approval. onboard never offers auto as a choice - turning it on is a deliberate, documented config edit, not an onboarding option - and mtclaw doctor always prints a warning when tools.exec.mode: auto is set, regardless of anything else in the config.

Why cron is allow-list-only

A scheduled job runs with no interactive approver at all - there is no human in the loop watching a terminal or a chat at 3am when a cron job fires. internal/tools.DenyAllApprover is what a cron turn's exec tool gets wired to, which means anything that would need a human decision (an unmatched command under approval mode, or something the auto classifier flags) is simply refused, with a message telling the model no interactive approver is available. The only commands a cron job can ever run unattended are ones that match tools.exec.allow outright. This is deliberately conservative: letting a scheduled job silently prompt-and-timeout (or worse, silently run) in the middle of the night is a worse failure mode than a cron job that occasionally has to say "I couldn't run that without approval."

Redaction is best-effort, not a boundary

An approval prompt leaves the host: the command is rendered into a Telegram message that persists on Telegram's own servers indefinitely. A command carrying an inline credential (curl -H "Authorization: Bearer sk-...", PGPASSWORD=... psql, aws --secret-access-key ...) would publish that credential to a third party the moment MTClaw asks about it, so RedactSecrets masks common credential shapes (Bearer tokens, Authorization: headers, --token/--password/--secret* flags, -p<value>, KEY=/TOKEN=/SECRET=/PASSWORD= assignments, and common key shapes like sk-..., ghp_..., AKIA..., and long base64/hex runs) before a command ever reaches an approval prompt or the exec_audit table.

This is pattern matching over plain text, not a security boundary. It will miss credentials in shapes it does not recognize, and it does not change what actually executes - only what is displayed and stored. The real rule is: do not let the agent handle credentials as command arguments. Put them in an env file the command reads instead, or in the shell environment MTClaw's own process inherits, never as literal text the model has to type into a command.

The atomicity/crash exposure

A turn's messages (the assistant's response, its tool calls, and every tool result) are buffered in memory and flushed to the store once, at the end of the turn, in a single transaction. This gives an important correctness property - a crash mid-turn loses the whole turn rather than leaving an orphaned tool_calls row with no matching tool result, which would otherwise poison the session for every future request - but it has a security-relevant exposure: a crash after a tool has already acted leaves no history record that it acted. If exec deletes a directory and the process dies before the flush, the next turn's context contains no evidence the command ran, and the model may attempt to run it again.

exec_audit (deliberately outside that transaction, and outside the session delete cascade) is the recovery path: it is the only durable record that a side effect happened, independent of whether the enclosing turn ever finished. mtclaw approvals list is how you read it. There is no automatic reconciliation between exec_audit and session history in v1 - after a crash, check the audit log yourself before trusting the model's next answer about what it has and has not already done.

What is deliberately not built

No sandbox, no containerization, no seccomp/AppArmor profile, no network namespace. tools.exec.cwd and tools.filesystem.roots are confinement by convention (path checks), not by kernel enforcement. If you need real isolation, run MTClaw itself inside a container or VM you control - that is a deployment choice outside this project's scope, not something the config schema can express.