Security Defense
Unreleased Enterprise team-history API: live reporting-line checks are independent of ordinary agent access; external transcripts require read-time masking and every disclosure requires a durable chained audit. A disabled setting, uncertain origin, installed-model failure or audit failure cannot return transcript content. Regex-only operation explicitly reports ner: false and can leave names/addresses visible. Direct host/database access remains outside this product boundary.
Four live guards, where each one runs, and what none of them covers.
A note on history
Section titled “A note on history”Until 2026-09 this page described a three-phase shell-script defense: a deterministic blacklist, an obfuscation/exfiltration scanner, and a Haiku AI judgment layer, all living in .claude/hooks/ and orchestrated by a GREEN/YELLOW/RED threat-level state machine.
Those scripts were deleted in commit ba015a48 when .claude/ was taken out of the public repository, and .claude/ is gitignored today. Nothing in the shipped binary reads them. There is no threat-level state machine.
What is actually in the product is smaller and easier to reason about: two PreToolUse hooks — both Rust subcommands — that the gateway installs into every agent directory, one input scanner on the message path, and one field-level freeze over the files that decide who may command whom.
Guard 1 — agent-file-guard (PreToolUse, Rust)
Section titled “Guard 1 — agent-file-guard (PreToolUse, Rust)”duduclaw hook agent-file-guard is a real subcommand rather than a shell script, so it behaves identically on macOS, Linux and Windows. The gateway registers it in <agent_dir>/.claude/settings.json with the matcher Write|Edit|MultiEdit|NotebookEdit|Bash and re-registers it on every boot (agent_hook_installer), merging into whatever else the operator has configured instead of clobbering it.
Grok employees get the same two guards from a different file. Grok spawns turn off Grok’s Claude-compat hook scanning (so the OS user’s own ~/.claude/settings.json hooks never run in an employee’s turn), which also stops Grok reading <agent_dir>/.claude/settings.json. Before every Grok spawn the gateway therefore writes the agent-file-guard and data-file-guard entries, unchanged, to <cwd>/.grok/hooks/duduclaw-guards-<agent>.json, Grok’s project hook directory; Grok maps the Claude tool names in the matcher to its own tools and sends the hook the Claude tool_name / tool_input keys. There is one file per employee, so employees sharing a borrowed working root (team members) never overwrite each other’s identity; each loads every file there and a deny from any of them wins. A spawn with an employee directory whose file cannot be written is refused. In the employee’s own directory .grok/ is a frozen runtime-config directory, so the employee cannot edit it; a borrowed working root outside that directory is not frozen, so an employee with write access there could add its own hook file.
The installed command carries the agent id (--agent) and, since the release after v1.68.1, the DuDuClaw home (--home "<path>"). The installer writes --home for every agent directory of the form <home>/agents/<id> (or <home>/agents/.ephemeral/<id>) and quotes a path that contains shell-special characters. The gateway starts an employee’s CLI with a scrubbed environment that has no DUDUCLAW_HOME, so before this the hook fell back to $HOME/.duduclaw and, on a deployment with a non-default home, judged every path of the real home as “outside home”. Existing installs are rewritten in place at the next spawn or gateway start.
The hook takes its home from, in order: --home (absolute only); for a caller with no employee identity (no --agent in the hook command and no DUDUCLAW_AGENT_ID in the environment), the default home, as before; an explicitly set, absolute DUDUCLAW_HOME in the hook’s environment. The home is not inferred from the working directory. When none of these gives a home, every Write / Edit / MultiEdit / NotebookEdit / Bash call by that employee is refused instead of being judged against a guessed location. NotebookEdit joined the matcher at the same time; its notebook_path is judged like a Write target. The hook subcommand answers on stderr only and no longer writes a log file.
It exits 2 — which Claude Code reads as “block this tool call” — when:
-
an agent writes an agent-structure file (
agent.toml,SOUL.md,CLAUDE.md,.mcp.json, …) outside the canonical<home>/agents/<name>/tree. Scaffolding a new agent has to go through thecreate_agentMCP tool, which carries the delegation-authorization gate; -
an agent writes its own
SOUL.md, even in the right place. Personality is operator-managed. The hook has no opt-in: an agent explicitly opted in withagent.toml [permissions] can_modify_own_soul = truechanges its ownSOUL.mdthrough theagent_update_soulMCP tool, never by writing the file; -
an agent writes its own
CONTRACT.toml, even in the right place (decisionBlockedOwnContractWrite). The contract is the operator’s boundary on the agent, so there is no opt-in flag at all; the block message tells the agent to ask the operator, who changes it from the dashboard (contract.update, the employee’s Owner or an admin with the strict check, which does not pass through this hook); -
an agent touches another agent’s files at all;
-
an agent writes anywhere else under the DuDuClaw home. For an employee-identified caller (or one whose claimed identity fails verification) the only writable places under the home are its own agent directory and the shared
attachments/directory. This is an allow-list, so it covers the audit log (tool_calls.jsonl, which the grounding check, the judge digest and the recent-actions feed read),evals/including held-out sets and other employees’ suites, every SQLite store, breaker state, licence and org files, globalskills/and the shared wiki, and any store added later starts out protected. Their legitimate writers are the gateway and the gated MCP tools, which do not pass through this hook. Only a caller with no employee identity, meaning the hook command carries no--agentand the environment has noDUDUCLAW_AGENT_ID, is unaffected. The installed hook command always carries--agentand the hook prefers it, so an operator who runsclaudeby hand inside an employee’s directory is judged as that employee (and, where identity tokens are required, as an unverified caller). An operator changes these files from the dashboard or with an ordinary editor; -
the write target is a symbolic link that leads somewhere the rules above refuse. Write/Edit paths are judged twice, as written and at their real location (links and any
..after a link followed; a dangling link is always refused); a block from either wins. A relative path is resolved against the working directory in the hook input or, when the input has none, against the employee’s own directory. A path whose real location cannot be worked out is refused with the message “cannot determine where this write actually lands” (無法確認這次寫入實際會落在哪裡); -
an agent writes, moves or deletes anything under
agents/_trash/, where removed employees are kept (Bash by heuristic); -
an agent runs
duduclaw agent create <name>from Bash for a name that is reserved because a removed employee had it (see Delegation isolation); the refusal is audited asagent_name_reservedwithpath_kindcli_bash_agent_create. -
an agent writes its own
INSTRUCTIONS.md(decisionBlockedOwnInstructionsWrite). The file holds operating instructions an operator imported from another platform (agent-config import) and is loaded into every prompt, so like the contract it has no opt-in flag. It is also an agent-structure file, so the location guard andoffice_scripttreat it likeSOUL.md.
For Bash, the own-SOUL.md, own-CONTRACT.toml and own-INSTRUCTIONS.md rules are a heuristic: a write-shaped command that names the file, as agents/<self>/… or as a relative spelling such as CONTRACT.toml or ./CONTRACT.toml, is blocked. A caller whose identity fails verification is refused for a write-shaped command naming anywhere under agents/, the same as on the Write/Edit lane.
Bash and the home allow-list. The Bash lane judges the command text against the same allow-list. A “protected home target” below means anything under the home outside the caller’s own directory and attachments/, another employee’s directory, or the removed-employee area; for a caller whose identity fails verification, its own directory as well.
- The command is first read the way bash reads it: line continuations, backslash escapes and quotes are undone before any rule looks at it. Sending output or errors to
/dev/null(2>/dev/null,>/dev/null) and duplicating one descriptor onto another (2>&1) are not writes; redirecting output and errors together into a file is. - Commands on a short list of known read-only commands (listing, reading, searching, comparing and the like) are not checked on their arguments; their output redirects still are. A command that has an option able to write its output to a file or to run another command is kept off that list, or stops counting as read-only when such an option is used. An employee reading a file under the home with a command outside the list is refused; use a listed command or the matching MCP tool instead.
- Copy-shaped commands (copy, install, download, archive extraction) are judged on their destination only, so copying a home file into the caller’s own directory passes.
- Commands that change what their arguments name (move, delete, link, permission, ownership or timestamp changes, sync, database command-line tools, …) and interpreters and shells are refused when any argument, including inline program text, is a protected home target. An output redirect to a protected home target is refused for every command.
- Any other command, including one behind a prefix option the scan cannot follow, is refused when any of its arguments is a protected home target, whether or not the command writes anything. The known read-only list is the only way for an unlisted command to touch a protected home path.
- Database files under the home outside the agent directories and
attachments/(*.dband its-wal/-shm/ journal files,*.sqlite*) are refused wherever they appear in the command, even for reading. A database inside an employee’s directory orattachments/is not covered by this rule; another employee’s directory is still protected against writes. - Relative paths resolve against the working directory in the hook input (or the employee’s own directory when there is none), and
cd/pushdinside the command are followed. When the working directory cannot be worked out and the command names a protected home location anywhere, relative paths in judged positions are refused; when it names none, they are not judged. - Existing symbolic links in a judged path are followed and the real location is judged too; a judged path that cannot be resolved, a dangling link included, is always refused.
This is a speed bump, not containment. Real isolation is not giving the agent Bash; the limits are listed under “What these guards do not cover”.
Live forking (fork_run) respects the same files from the other side: branches can read the agent’s structure files, but promoting a branch back into the agent directory never copies SOUL.md, CONTRACT.toml, agent.toml, .mcp.json, .claude/ or the other agent-structure files over the parent’s copies.
Guard 2 — data-file-guard (PreToolUse, Rust, RFC-23 §14.4)
Section titled “Guard 2 — data-file-guard (PreToolUse, Rust, RFC-23 §14.4)”Guard 1 protects DuDuClaw’s own structure files. This one protects the customer’s data.
Read and Bash are built-in Claude Code tools, so cat customers.csv never passes the MCP redaction choke point that file_read / csv_read / xlsx_read go through. The installer registers duduclaw hook data-file-guard for the matcher Read|Bash; the decision logic lives in duduclaw_core::data_file_guard, shared by the CLI subcommand and the gateway’s installer tests. Same contract as Guard 1: exit 0 allows, exit 2 plus stderr blocks, and the stderr is shown to the model.
It stays inert unless the gateway sets DUDUCLAW_DATA_FILE_GUARD at spawn time, which it does only when redaction is actually active for that agent. A deployment with redaction off behaves exactly as it did before the guard existed.
Until H10 (2026-09) this was a POSIX shell script at <agent_dir>/.claude/hooks/data-file-guard.sh, and it was inert on a Windows host with no bash on PATH — the hook command failed, and Claude Code reads a non-2 exit (including “command not found”) as allow, so the guard went missing exactly where nobody would notice. The installer now deletes any leftover script on upgrade, so a stale copy cannot be mistaken for the live guard.
Stated limitation. The Bash check matches filenames. A command that builds its path dynamically (python -c "open(chr(99)+…)") walks straight past it. The real protection is the MCP tool surface; this guard lowers the odds of the model taking the ungated route. It is a heuristic, not a sandbox.
Guard 3 — input_guard (prompt-injection scanner, Rust library)
Section titled “Guard 3 — input_guard (prompt-injection scanner, Rust library)”duduclaw_security::input_guard::scan_input scores text 0–100 across eleven rule categories and blocks at or above DEFAULT_BLOCK_THRESHOLD (60):
| Rule | Weight | Instant block |
|---|---|---|
instruction_override |
40 | yes |
role_hijack |
35 | yes |
tool_abuse |
30 | yes |
data_exfiltration |
25 | yes |
system_prompt_extraction |
30 | no |
encoding_bypass |
25 | no |
termination_manipulation |
30 | no |
authority_escalation |
35 per signal, two different signals add up | no |
memory_poisoning |
30 per signal, two different signals add up | no |
role_provenance |
35 per frame, two different frames add up | no |
action_binding |
30 | no |
Patterns cover English and Chinese (Traditional and Simplified). Text is NFKC-normalized first (unicode_normalizer), so homograph and invisible-character tricks cannot slip past a pattern.
Chinese coverage (v1.67.1). Released versions matched Chinese instruction override only as four exact strings, so inserting a word such as 所有, 之前 or 的 got past it; in a live test four such sentences were stored through user_profile_record. Since v1.67.1:
instruction_override: an override verb (忽略/無視/忘記/忘掉/不要理會/不用理會/別管, and Simplified forms) followed, inside the same clause and within 12 characters, by an instruction noun (指示/指令/規則/提示詞/系統提示) with a scope word between them (先前/之前/以上/上面/上述/前面/所有/全部/一切/你的/原本/原來). Whitespace is ignored;。!?;and line breaks end a clause. Same weight and immediate block as the English phrases.system_prompt_extraction: an extraction noun (系統提示詞/系統提示語/你的系統提示/你的指示/你的設定) and an output verb (輸出/顯示/告訴我/給我看/洩漏/列出/重複) within 12 characters of each other, either order. Scored like the English rule: weight 30, not a block on its own. Bare 系統提示 is not an extraction noun, because it also means “system notice”.role_hijack: fixed phrases such as 你現在是管理員模式 / 開發者模式 / 越獄模式, 你現在不受限制, 進入越獄模式 and the bare word 越獄模式. Same as English.- Thresholds and the English lists are unchanged.
Known false positives. The rule matches by shape, so ordinary sentences with an override verb, a scope word and an instruction noun in one short clause are blocked too, for example 「請忽略之前寄的指示,以新版為準」, 「請忽略以上規則中的第三條,已經取消」 and 「忘記之前的規則了,可以再說一次嗎」 (pinned by the test known_benign_shapes_that_do_match). Any mention of 越獄模式 also blocks. The workaround is to say the same thing without the override verb, for example 「之前的指示作廢,以新版為準」; the close sentence 「之前的報價單請作廢,以新的指示為準」 is in the test list of text that must not match. The scanner is a phrase heuristic, not a classifier, and has not been measured against real conversation data.
termination_manipulation (LoopTrap, arXiv:2605.05846) is deliberately not an instant block: weight 30 sits below the threshold, so a single match warns and audits rather than blocking, which keeps ordinary “please continue” requests working.
Sentence-shape families (v1.68.0). The red-team ledger found four agent-specific techniques that scored zero. Each now has an English and a Traditional Chinese set of anchored regexes:
authority_escalation: asking the employee to act with its own authority instead of the user’s (“use your own service account / admin role”, “not my permissions”, “the system trusts you more than me”; 用你自己的服務帳號/管理員身分, 不要用我的權限, 系統比較信任你).memory_poisoning: asking it to store a standing entitlement (“remember this permanently”, “as a fact about me”, “I am always allowed to”; 永久記住, 記在你的記憶裡, 以後每次對話都照辦, 我一律有權限). Plain 「請記住…」, “remember to…” and “remember my name” do not match.role_provenance: a fake system, tool or operator frame inside a user message:[SYSTEM],<tool_result>tags,approval=granted, “policy update from the platform operator”, “restriction lifted for this session”, a note addressed to the AI inside quoted text and the instruction to hide it (平台營運方政策更新, 本次對話已解除…限制, 給 AI 助理的附註, 不要提到這段附註). Frame tokens match only as whole tags, so “our system sends a tool result” does not match; an opening and a closing tag count as one frame.action_binding: reusing one approval for a different action (“use that same approval to…”, “use the approval from step 1”; 用同一個核准, 用第一步的核准去…). 「既然已經核准預算,請安排會議」 does not match.
Weight policy: one signal warns and audits (30–35, below 60). A message is blocked when it carries two different signals of the same family, or one signal plus an existing rule such as instruction_override or system_prompt_extraction. Stacking applies to authority_escalation (“use your service account” plus “the system trusts you more” = 70), memory_poisoning (“remember this permanently” plus “I am always allowed to” = 60) and role_provenance ([SYSTEM] plus approval=granted = 70). action_binding does not stack with itself and blocks only with another rule. Two hits of the same signal count once: a single [SYSTEM], or one <tool_result>…</tool_result> pair, stays at 35. Known cost: an ordinary-looking sentence that happens to carry two signals is blocked too, for example “please use your admin account, not my permissions, to fix the shared folder” (pinned by the test known_benign_shapes_blocked_by_stacking). Say it with one signal, or ask an administrator to do it. Callers that drop text on any match (distillation, profile writes) now also drop text that contains one of these shapes. Positive and look-alike phrasings for each family are pinned by tests in input_guard.rs.
Where a match shows up (verified call sites):
- Inbound chat messages (
channel_reply,scan_input_with_audit): a blocked message gets a warning reply and is not passed to the AI. - MCP tool calls (
mcp_dispatch,scan_input_with_auditover the serialized arguments): a call whose arguments quote a blocked sentence is refused and audited. - Conversation-fact, profile and knowledge-routing distillation (
wiki_ingest,profile_distill,knowledge_route): content is dropped on any rule match, including the non-blocking extraction rule. user_profile_record: the predicate and the value are scanned; a block-level hit is refused.duduclaw migrate fromimports skip blocked items; expert-pack installation refuses a blocked pack.- Agent Mail: an inbound mail that matches is stored but flagged, and a flagged mail never triggers the agent.
- Reminders: a reminder whose prompt is blocked does not run.
Guard 4 — org_field_guard (organizational authority freeze)
Section titled “Guard 4 — org_field_guard (organizational authority freeze)”The A2A delegation predicate (delegation_policy::can_delegate) decides who may command whom by reading [agent] reports_to / department / name from agent.toml, plus [delegation] and [acp] from config.toml. Both are plain files, so an agent holding Edit could rewrite its own reports_to to point at a victim and then claim the “subordinate → ancestor” rule. The judged party owned the evidence.
org_field_guard runs inside the same agent-file-guard hook and compares the reconstructed post-write content field by field against what is on disk. A change to a protected field or section is denied. The [capabilities] table is frozen as a whole table rather than as a key list, so a capability key added in a later release is protected the day it lands instead of the day someone remembers to extend a list.
The employee’s own security settings. For an employee-identified (or unverifiable) caller the rest of its own agent.toml is frozen by allow-list: only the editable sections may change, and any other section, including one added in a later release, is protected by default. The editable sections are [agent], [model], [prompt], [heartbeat], [proactive], [research], [goal_intent], [memory], [skills], [sticker], [cultural_context], [preset] and [planner]. Inside them, [agent] role, [prompt] cli_bare_mode (it makes the Claude CLI skip hooks) and [model] account_pool stay frozen, as do the org fields [agent] reports_to / department / name; [capabilities] is frozen as a whole. The allow-list applies to every caller with an employee identity, which includes an operator running claude by hand inside an employee’s directory (see Guard 1); the older org-field and [capabilities] rows apply to every caller, as before. An operator changes these sections from the dashboard or with an ordinary editor.
The employee’s own .mcp.json and CLI configuration. An employee-identified (or unverifiable) caller may not write any .mcp.json in its directory, at any depth (Write, Edit, MultiEdit, NotebookEdit; the Bash rule already refused the top-level one). The gateway starts the Claude CLI with that file, and the CLI starts every server it lists, so a single added entry whose command is an interpreter would run arbitrary commands as the operator’s OS user at the next spawn, without any Bash grant. This affected every released version: employees used to be allowed to add “unrelated” servers. New MCP servers now come from the dashboard, an approved MCP install request filed in the dashboard (mcp.install_request, installed after an Admin approves), an expert pack, or the operator editing the file; none of these runs through the hook. The same caller may not write the CLI configuration of its directory either: anything under a .claude/ directory (settings, hooks, slash commands, subagents, skills) or named .claude.json, at any depth, and the Codex, Grok and Antigravity configuration directories at the top (.codex/, .gemini/, .grok/, .agents/) and .cursor/ (Grok loads .cursor/mcp.json from its working directory, 2026-10-20). Names are compared case-insensitively. Behaviour change: a project cloned inside an employee directory is a place the CLI starts too, so the employee can read that project’s .claude/ but not write it. An operator keeps the older rules (only the identity keys of .mcp.json are frozen). Before every spawn that hands an employee’s .mcp.json to the Claude CLI (channel reply, dispatch, heartbeat proactive check, live duduclaw eval, and a live fork’s parent directory before its branches are copied) and at start-up the gateway also regenerates the DuDuClaw entry whole (command, arguments, environment), keeps other entries as they are, and refuses the spawn with an audit event (mcp_config_unverified) when the file cannot be confirmed (not a regular file, unreadable, invalid JSON, or the duduclaw binary path is not absolute). That refusal is about the file, not the account: it does not put the account on cooldown, no other account is tried, and a channel reply does not fall back to a local model or the Direct API; the person sees a sentence naming the employee whose settings could not be confirmed. The lock all .mcp.json writers share lives under <home>/locks/, not in the employee directory, so a file or directory the employee creates there cannot block it. Entries added before the upgrade are kept: duduclaw doctor lists every entry DuDuClaw did not write (its name and the file name of its command, never arguments, environment or URL) so the operator can confirm or remove each one. Promoting a live-fork branch into an employee directory never brings back .claude.json, .agents/, .codex/, .gemini/, .grok/, .cursor/, or a .claude/, .claude.json or .mcp.json at any depth. Limits: this is a hook, so an employee with unrestricted Bash can still change the file; the Codex, Grok and Antigravity runtimes do not run the hook, and their MCP configuration files (the four directories above) sit in the employee’s directory too, so the same class of problem is not handled for those runtimes by this fix. Not verified with the real CLI: whether Claude Code reads a .claude.json from the employee directory (frozen anyway).
Fail-closed by construction: unparseable new content, unparseable existing content, and an unreconstructable write intent all deny. An existing agent.toml, config.toml or .mcp.json that cannot be read is denied too (it used to be treated as a new file). A file that does not exist yet is allowed, because creation goes through create_agent and its own gate.
Legitimate changes keep every route they had: the MCP agent_update tool and the dashboard agents.update RPC, neither of which passes through the hook.
Forget by source needs an Admin approval
Section titled “Forget by source needs an Admin approval”duduclaw memory forget-source deletes memories for good, so it has three layers in front of it. The procedure is in Forgetting a conversation, a scheduled run or an imported file; the mechanism is in Memory Intelligence.
Dashboard approval. plan files one approval request (action_kind memory_forget_source) bound to the plan id and the plan hash. It can only be decided in the dashboard and only by an Admin; channel buttons and replies are refused. apply --confirm runs only when that request is approved, still names the same plan hash, and the plan has not expired. The request expires together with the plan (30 minutes by default, at most 24 hours). The card carries counts and source labels, never memory content, and says in fixed text that the request came from a local command line and that nothing proves who typed it. No setting turns this approval off. [memory] forget_source = false only stops new plans and applies.
Bash lane rule. For an employee-identified or unverified caller, the Bash lane of agent-file-guard refuses duduclaw and duduclaw-pro (also by path or as .exe) followed by memory forget-source or memory migrate-namespace, with any subcommand, including the read-only list. The decision is BlockedOperatorMemoryCommand and the message tells the employee to ask the operator. migrate-namespace was not covered by the Bash lane before. This is a speed bump, not a sandbox. It does not catch:
- a global option between
memoryand the subcommand, for exampleduduclaw memory --redact on forget-source …; - a binary named through command substitution, for example
"$(command -v duduclaw)" memory forget-source …; - the words handed to the binary through a pipe, for example
echo memory forget-source … | xargs duduclaw; - other spellings that avoid matching on the command name, and the evasions listed under “What these guards do not cover”.
AI session check. The command itself refuses to run when any variable that the gateway sets on a spawned employee process is present, even empty: the employee identity and token, the turn and session ids, the user-message pair of the turn, the dispatch session and run ids, the delegation sender, origin and depth, the hop depth and the reply channel, the MCP API key, the data-file-guard flag and the goal-round task id (the one list ai_session_guard::AI_SESSION_ENV_VARS, shared since 2026-10-06 by every operator command that files an approval). An operator’s own terminal carries none of them. This check is only a first line. It is not reliable for a command run directly from an employee’s Bash, because an employee with Bash can unset variables. Real isolation is not granting Bash, or running the employee in the task sandbox. An employee that runs as the same operating-system user, can execute arbitrary commands and deliberately evades the file guard can still rewrite the local databases (approvals.db, memory.db) directly.
Operator command-line approvals share one gate
Section titled “Operator command-line approvals share one gate”Four operator commands change state from a terminal: duduclaw ops channel-ingress (LINE inbox), duduclaw responsibility (continuous responsibilities), duduclaw memory forget-source apply and duduclaw ops computer-workspaces. A terminal cannot prove who typed a command, so all four only file a dashboard request that a current Admin decides, and the command then applies once. Since the release after v1.70.0 they share one implementation (approval/operator_cli_gate.rs in the gateway crate) with one registry of kinds: an approval counts only when decided in the dashboard, for the same request and, except for a LINE batch, the same state; a waiting request whose target changed state is withdrawn (state_changed) and a new one filed, never rewritten in place; a used approval is consumed by one conditional update, and a run that loses that race acts on nothing and files nothing; at most 3 different requests wait per action and target and 20 per kind; at most 2 first notifications per target per hour (per AI employee for forget requests). approvals.decide refuses every one of these kinds for anyone but a current Admin through one check. The commands also share one list of AI-session environment variables (ai_session_guard.rs in the CLI crate). Limits are unchanged: an employee with unrestricted Bash can still edit the local databases.
Upgrading. The gate stores its binding in a gate object inside the request payload. Requests filed by v1.70.0 or earlier have no such object, so the new version never matches or counts them. A request filed before the upgrade, even one already approved and not yet applied, is not applied after it: running the command again files a new request that must be approved again. The old waiting card still receives its usual reminder pushes until it expires (responsibility requests send none), but approving it does nothing.
Action rules, explore lane and action review
Section titled “Action rules, explore lane and action review”Added 2026-10 (unreleased). Three layers that only ever narrow what an employee may do; none of them removes an approval or a denial that another gate imposes.
Effect classes. Every DuDuClaw MCP tool has one side-effect class (duduclaw_core::tool_effect::effect_of_builtin): read, draft, send, publish, purchase, delete, modify or admin. read and draft leave nothing behind outside the reply; the other six are side-effecting. A tool whose effect depends on its arguments carries the stricter class (wiki_write is modify for both scopes). mail_send only files a draft a person must approve, but it is send, because sending is what it leads to; gmail_create_draft never sends and is draft. A tool name the table does not know is treated as admin (fail closed), and a test walks every advertised tool so a new one cannot ship unclassified. The built-in tool catalog (tools.catalog) carries the class as effect.
Action rules. agent.toml [capabilities] action_rules sets allow, ask or block per class or per tool:
[capabilities]action_rules = [ { effect = "send", verdict = "ask" }, { tool = "mail_send", verdict = "block" },]A tool rule (same anchored matcher as the other [capabilities] tool lists, so mcp__duduclaw__mail_send and wiki_* work) beats an effect rule; among rules of the same kind the strictest verdict wins. block refuses the call at the MCP dispatch gate next to denied_tools (JSON-RPC -32003, audit error_class action_rule) and hides the tool from tools/list; ask joins the static “always ask” set of the approval gate, so the ApprovalBroker flow runs as it does for approval_required_tools. allow only means “no friction from this layer”: it never lifts denied_tools, an approval list, a capability switch or a scope. The eight computer_* tools and the three computer_workspace_* tools apply the same resolution in the gateway’s computer-use route, which owns their session. The key sits in [capabilities] on purpose: org_field_guard freezes that table against the employee’s own writes. A malformed list or entry (unknown verdict or class, both or neither of effect / tool, an extra key, a non-list value) does not fail the file: it is kept as written and makes every side-effecting call at least ask, while valid block rules still block. An agent.toml that exists but cannot be read or is not valid TOML is treated the same way. The dashboard edits the list on the employee page (rules by kind of action, plus exceptions for single tools) through the admin-only agents.update capabilities path, which validates every entry and audits the change as agent_authority_changed.
Claude Code built-in tools (2026-10-08 close-out). action_rules also reach the Claude CLI’s own tools at spawn. They are classed by exact name (duduclaw_core::effect_of_cli_builtin): Read, Glob, Grep, WebFetch, WebSearch are read; TodoWrite is draft; Write, Edit, MultiEdit, NotebookEdit are modify; Bash, Task, Agent and any other name are admin. A built-in call never passes through the ApprovalBroker, so a rule that says ask for a built-in cannot be honoured and is applied as block (fail closed): the tool is added to --disallowedTools and removed from --tools through CapabilitiesConfig::disallowed_tools(), which every Claude CLI spawn that honours denied_tools already reads (dispatch, cron, goal and heartbeat wake-ups in claude_runner, channel replies in channel_reply/cli_env.rs, the heartbeat proactive check, eval runs and team role members). A malformed rule list therefore keeps every side-effecting built-in away (Write, Edit, Bash, …) while the read tools stay. Behaviour change: an operator who wrote { effect = "modify", verdict = "ask" } for MCP tools now also loses Write / Edit in the Claude CLI.
Removed tool names. A tool rule written for a name removed in v1.69.0 (shared_wiki_write, schedule_task, skill_bank_search, …) keeps gating the call that replaced it (wiki_write with scope = "shared", tasks_create with a cron schedule, skill_search with source = "bank"), at the dispatch block gate and in the approval gate’s ask set, like denied_tools (ActionRules::resolve_for_call, tool_catalog::removed_name_for_call). Only tool rules follow a removed name, and the result is only ever stricter.
Explore lane. An MCP server started with DUDUCLAW_LANE=explore lists and runs only read and draft tools; any other call is refused with -32003 and audit error_class explore_lane. The variable present with any other value, empty included, refuses every call. The gateway sets it on the heartbeat proactive check, whose Claude CLI spawn also gets only the Read, Glob, Grep, WebFetch and WebSearch built-in tools (minus the employee’s own denied_tools). The notification a check decides to send is still delivered by the heartbeat from the reply. The variable reaches the MCP child through the CLI’s inherited environment; the .mcp.json entry does not set it. The proactive check only runs on the Claude CLI, so no other runtime is involved. Since 2026-10-08 two kinds of dispatched work use the lane too: runs started by an MCP Events delivery, unless the subscription was opted into normal mode, and rounds of a responsibility whose contract has lane = "explore". Both set one flag (duduclaw-gateway/src/explore_lane.rs) with the same rules: only Claude CLI, Codex and OpenAI-compatible employees run such work (Codex with -s read-only and approval_policy=never whatever its capability level, the DuDuClaw server registered with the lane variable; Codex rejects every MCP call in that mode); another runtime (Antigravity, Grok, generic CLI) or the task sandbox is refused before the run starts, and a MoA model or local-only inference is refused. Antigravity stays refused because agy --sandbox restricts the terminal only and its shared user-level permissions.allow can widen what print mode allows. Delegations written by a process in the lane (spawn_agent, spawn_ephemeral, send_to_agent) carry lane on their bus_queue.jsonl entry or message_queue row, which the dispatcher honours like the event lane; the lane already refuses those tools, so this is a second line. DUDUCLAW_LANE is in the AI-session variable list that operator-only commands refuse.
Action review. config.toml [action_review] mode = "off" | "shadow" | "enforce", default off, read on every call (system.update_config accepts action_review.mode and audits a change as a protected key). It applies only when every static gate resolved the call to auto-run and the tool is side-effecting. The reviewer is the utility model through the closed-choice decide() helper (duduclaw-gateway/src/decide.rs): the reply must be exactly {"choice":"allow"|"ask"|"block"} under the strict JSON contract, anything else counts as no verdict. Its input is structured only: the tool name, its class, the argument keys (keys that do not look like identifiers are only counted), the closed ActionGuard finding tokens and the employee’s CONTRACT.toml must_not lines. Argument values never enter the prompt. shadow never changes the outcome and appends a tool_calls.jsonl row with action_review set to the verdict or unavailable. enforce refuses on block and asks a person through the ApprovalBroker on ask or when no verdict came back. An unknown mode value, or a config.toml that exists but cannot be read or parsed, is read as enforce. Since the 2026-10-08 close-out the review also runs for the OS action tools (after the situation classifier lets a call run), for skill_hub_install (in front of its own post-scan install approval) and, in the gateway’s computer-use route, for the computer_* and computer_workspace_* tools that no approval list sends to a person; everything else is the same (structured input only, shadow records, enforce refuses on block and asks a person otherwise). A dispatcher test drives enforce end to end with a fixed reviewer answer (block, ask then approve, ask then deny, no verdict) against the real in-process approval store.
Third-party MCP tools (2026-10-08). Tools of the employee’s other MCP servers are classified where DuDuClaw sits in their path: duduclaw mcp-proxy (servers started from .mcp.json) and duduclaw mcp-remote-bridge (remote servers), both through duduclaw-gateway/src/third_party_tools.rs. The class comes from the annotations in the server’s tools/list: destructiveHint: true ⇒ delete; readOnlyHint: true ⇒ read only when the server is listed in [capabilities] trusted_read_hint_servers, otherwise modify; anything else, absent annotations included ⇒ modify. A server can claim anything about its own tools, so a read-only claim is believed only on an administrator’s say-so per server: believed, a lying server’s deleting tool would pass every read rule and the explore lane; not believed, a truly read-only tool of an unlisted server is treated as a change. action_rules apply as for DuDuClaw’s own tools (a tool rule names <server>.<tool>, mcp__<server>__<tool> or <server>.*): block hides the tool from the listing the employee sees and answers a call with -32003 before it reaches the server (audit third_party_tool_refused); ask holds the call for an ApprovalBroker decision and refuses when the store is unavailable, the request fails, or it is denied or expires (audit third_party_tool_approval). In the explore lane only read tools are listed and callable. The spawn-time rewrite puts the proxy in front of stdio servers when redaction is active, when the employee has an action_rules key, or in the explore lane (the heartbeat proactive check and runs started by MCP Events); remote servers always go through the bridge, which then gates them itself. Only the Claude CLI starts .mcp.json servers, so it is the only runtime involved; url / type entries the CLI reaches directly are not covered. The dashboard (mcp.tool_effects) shows the last listing per server with the class and verdict recomputed from the current settings. Guide: docs/guides/mcp-ecosystem.md section 4.
Not covered and not verified. Other MCP servers are classified only through the proxy and the bridge (above). Built-in rules apply only where a Claude CLI is spawned with DuDuClaw’s tool flags; other runtimes have no Claude Code built-ins. Classification is per tool name, so an argument-dependent tool always gets its stricter class. None of the layers has been exercised against a live gateway with a real model yet; the tests are unit and dispatcher-level.
Single-use approvals for employee actions (ActionGrant)
Section titled “Single-use approvals for employee actions (ActionGrant)”Added 2026-10-08 (unreleased). Before this change every tool call that needed a person’s approval already filed its own request and waited for that request’s decision, so an approval could not be reused by a later call. What was missing was a binding: the card did not show the arguments, and nothing tied the decision to the call that ran. Now, for a call whose tool is in the send or purchase class (for example mail_send, odoo_sale_confirm) or that reached human approval because an action_rules rule said ask, the request carries payload.action_grant (duduclaw-gateway/src/approval/action_grant.rs):
- employee, tool, effect class, a SHA-256 digest of employee + tool + canonical arguments (object keys sorted), and a summary of up to eight
key = valuelines built from identifier-shaped argument keys and scalar values. Secret-looking keys and values are masked with the same helperstool_calls.jsonluses, strings are cut at 60 characters, nested values are shown only as counts. The card text and the inbox detail panel (“What this approval covers”) show it. - On approval the stored binding must match the call about to run (same employee, tool and digest); the approval is then consumed once (
approved → invalidated, reasonconsumed:action_grant:<uuid>) before the call proceeds. A mismatch, a lost claim or a second use is a denial.
Since the 2026-10-08 close-out three more human-approval paths are bound the same way, always (not only for send / purchase): the PolicyKernel ask decision at the MCP dispatch gate, the OS action tools’ situation gate (anomaly / sensitive, or an action-review ask), and the approval lists of the computer_* / computer_workspace_* tools in the gateway’s computer-use route. For the computer tools the binding is over a gateway-built snapshot of the operation (typed text and URLs as hashes, so the card shows a character count or the validated host, never the text), and the gateway re-runs the tool gates after consuming the approval. The computer-use high-risk confirmation in the chat was already single-use and bound (an ExecutionBinding over the action hash, the screenshot hash, the policy revision and the decision context, claimed once with claim_operation / begin_execution and re-checked against a fresh screenshot). Workflow runs keep their own binding: a step needing a person is approved only through a bound request whose payload and ExecutionBinding must equal the step’s call and whose operation is claimed under a fence, so it already covers exactly one step. Install approvals are unchanged. Not covered: the digest is computed over the arguments at the approval gate, which runs after PolicyKernel rewrites and egress secret restoration; a person with database access could edit the stored payload and card together. Unit and broker tests only.
Calling an employee by @ follows the sender’s access (v1.76)
Section titled “Calling an employee by @ follows the sender’s access (v1.76)”Before v1.76 the [agent] trigger match was an unanchored, sender-blind substring test: a WebChat account bound only to one employee could reach any other by typing its trigger, and any LINE friend could do the same. Routing now resolves the human sender first (the dashboard account in WebChat; a verified channel_identities row on messaging apps, else “unmapped”) and only matches employees that sender may call: Admins everyone; everyone the conversation’s default employee and employees with [channels] public_trigger = true (admin-only, frozen for employees); dashboard accounts and linked messaging-app senders also their bound employees. A call to anyone else is refused before any model call and written to security_audit.jsonl as trigger_route_refused, with the account or an HMAC of the platform id under a per-installation key (trigger_audit.key). A suspended account’s bindings no longer count. Any lookup failure narrows to the unmapped case. A called employee runs in its own session bucket, so history and secrets do not mix between employees; in a group chat the answer is still visible to every member. Details: Calling AI staff with @.
Dashboard permissions are read live on every call (v1.76)
Section titled “Dashboard permissions are read live on every call (v1.76)”Before v1.76 a dashboard WebSocket kept the role and agent bindings it read at handshake, and an access token lives 30 minutes, so removing a binding, demoting someone or suspending an account reached an open connection only after the next login. Now the RPC dispatch chokepoint rebuilds the caller’s context from users.db on every call; the token only proves who the caller is. Role, direct bindings, the forced-password-change flag and the reporting-line grants below all come from the database. Every write that changes any of them (binding, unbinding, role or password change, status change, manager change, channel identity, email change, offboarding) bumps a counter (auth_meta.acl_generation) in the same transaction, and the per-call cache is keyed by that counter, so a cached answer is never older than the last such write. A missing or non-active account is refused with account is suspended or offboarded; when the database cannot be read the call is refused with authentication service unavailable, nothing from the token is reused, and system.doctor shows how often that happened since start-up. The database reads run on the blocking thread pool, as does the login password check, so a burst of logins does not hold up other connections’ calls. The gateway admin token (system) and lock-screen connections are unchanged.
Accounts can now have one direct manager (users.manager_user_id, Enterprise only, set through users.create / users.update). Every field of one users.create / users.update is written in one transaction, so a refused manager or demotion changes nothing else in that request. Writes refuse a cycle (manager_cycle), a chain longer than 8 people counting the one at the top (manager_too_deep) and a manager below the Manager role or not active (manager_role, manager_inactive); demoting someone who still has direct reports is refused (manager_has_reports); offboarding moves the leaver’s reports to transfer_to or to the leaver’s manager and audits each move as user.reparent; bindings moved to transfer_to keep the higher level when the recipient already had that employee. A manager is computed to reach the employees bound to the people below them at no more than Operator, but in this release nothing uses that yet: routing, agents.list and the Operator-level RPCs (memory, history, settings) still look at direct bindings only.
People editing an employee’s profile files (unreleased)
Section titled “People editing an employee’s profile files (unreleased)”The persona, self-introduction, work rules and boundaries (SOUL.md, IDENTITY.md, INSTRUCTIONS.md, CONTRACT.toml) can be edited by the employee’s Owner and by admins in the dashboard, and every such save is checked before it takes effect (owner decision D7). Who may write is re-read from users.db on every call and once more inside the file lock, so a binding removed while a check runs stops the write. Fixed rules come first (size and control characters, a strict contract parse, hidden or invisible content, injection patterns in the added lines, key-like text, a persona losing most of its text, a contract dropping a boundary). Then the deployment’s utility model reviews the diff; the edited text is wrapped in tags with a random nonce and stripped of hidden content, and the reply must match a fixed schema exactly. The model can only raise the result. A review that does not complete counts as at least a warning, never as a pass, and cannot be turned off. A non-admin facing a block can only send the change to an admin (agent_file_change, decided in the dashboard only; channels get a notice without buttons). The check runs on what the human submits; it does not replace agent-file-guard, which still keeps employees from writing these files themselves. Every write keeps a version outside the employee directory and writes agent_file_changed; refusals write agent_file_change_refused. INSTRUCTIONS.md now has its own approved fingerprint next to SOUL.md’s. The same release widens the admin-only agent.toml fields (bot tokens, [odoo], [runtime] provider / fallback, [prompt] cli_bare_mode, [os_watch], [model] account_pool, role = "main", budget loosening); see Dashboard settings.
Who the work is for (on_behalf_of, unreleased)
Section titled “Who the work is for (on_behalf_of, unreleased)”When a person starts work (a chat turn, a /assign hand-over, a dashboard task), the gateway now carries who that person is through everything the work starts: the employee’s runtime gets DUDUCLAW_ON_BEHALF_OF (every runtime, not only Claude), send_to_agent / spawn_agent write it onto the queue row (message_queue.on_behalf_of, the bus_queue.jsonl field), the dispatcher scopes it onto the receiving run, a goal round or heartbeat wake-up copies it from the task row (tasks.on_behalf_of), tasks_create stamps it on the new task, and an approval raised during the run stores it (approvals.on_behalf_of). The value is user:<account id> for a WebChat account or a verified channel identity, or unmapped:<channel>:<digest> for a sender with no account (the same keyed HMAC the @ routing audit uses, never the raw platform id). It comes only from what the gateway wrote: the environment it sets at spawn and the rows it or the MCP server (from that environment) wrote. Tool arguments are never read. System sender names (dashboard, cron, …), reserved ids and malformed values are refused and audited as on_behalf_of_rejected; the first value set wins and no later hop overwrites it. The gateway’s own environment is never passed on: the spawn allowlist does not carry the variable and each runtime removes an inherited one before setting the scoped value.
What it does in this release:
- Record (default,
[delegation] human_principal = "record"): everytool_calls.jsonlrow from that run, everydelegation_deniedrow and every approval carries it, and channel approval cards show 「由 ○○ 交辦」 (the person’s display name when readable, otherwise a short account label). Nothing is refused because of it. - Enforce (
human_principal = "enforce", opt-in; an unrecognised value or an unreadableconfig.tomlalso means enforce): after the employee tree allows a delegation, the person must be allowed too.send_to_agent/spawn_agentand the dispatcher’s delegation gate need the person to be able to call the target (Admin; a direct binding or a manager’s team grant at any level; or apublic_triggeremployee);tasks_create(also for a task the employee assigns to itself),create_task, schedules, mail access and record changes need Operator or above. An unmapped sender may only reachpublic_triggeremployees and never hands over work. An unknown or inactive account, a malformed value or an unreadableusers.dbis refused (human_principal_not_permitted). Goal rounds, heartbeat wake-ups and cron are not judged again by person at dispatch; their task was authorized when it was created.
In enforce mode the separate MCP process asks the running gateway to check human rights. It sends a host-minted opaque reference bound to its employee and home; the gateway uses its live user directory for rights. Team grants require both a verified Enterprise license and that home’s gateway-resolved Enterprise edition; Personal or unknown in either check denies team grants. It never accepts a principal in the request body or an Enterprise environment override as proof. A genuine background call also needs an explicit host-owned None context: a missing, foreign, expired or dropped reference is refused, as is disagreement between the host record and the MCP identity stamp. Contexts expire after 24 hours or when the runtime returns, with at most 4096 live registrations. Record mode does not need the route. This context is separate from knowledge-base requesters and does not grant access to documents.
[approval] forbid_self_approval = true(default off): a request raised while an employee worked for someone cannot be approved by that same person, in the dashboard or from a channel button (denying still works). Bound requests are unchanged: they are decided only by the person they are bound to.
Computer-use start/action approvals recover the person from the host’s live turn registration, keyed by the authenticated employee and turn_id. Body parameters cannot name a person or borrow another employee’s turn. Missing or unregistered turns carry no principal, so self-approval prevention cannot identify the requester there; workspace, screenshot and stop requests without a turn also carry no principal.
The person check only narrows employee permissions. Record mode still trusts the host metadata and enforces nothing; in enforce mode, changing the principal environment value alone cannot change the person registered in the gateway context. This is not OS isolation: an employee running under the same OS account with unrestricted Bash may read process credentials or alter local databases. Schedules created through tasks_create do not carry the person onto their later runs. spawn_ephemeral and its deferred admission queue do not yet carry the principal. Whether Grok and the deprecated Gemini CLI forward the spawn environment to their MCP child is unverified.
Verified locally: backend unit, integration and doc tests with dashboard features off; the original dispatcher regression at the default stack; and real stdio MCP children against the signed internal route. The native fixture uses an ephemeral signed test-issuer license and covers the Enterprise AND gate, Manager team assignment, Viewer call-only access, live revocation/inactive users, explicit background None and missing/foreign/dropped/mismatched contexts. It does not validate a production issuer, a real model calling send_to_agent, or live channels. Default dashboard features remain a separate CI check.
Supporting layers
Section titled “Supporting layers”MCP authorization gate — every MCP tool is enumerated in a scope table; a tool that is not listed defaults to requiring Admin scope. Scope, per-agent capability grants and denied_tools are each enforced at the dispatcher front door, and every refusal is audited with an error_class.
SOUL.md drift detection — soul_guard fingerprints each SOUL.md with SHA-256 at startup and on every heartbeat tick, keeps up to 10 versioned backups in .soul_history/, and reports drift alongside an Agent Stability Index.
Audit trail — tool_calls.jsonl records every tool call with masked result_text / input_text (three-pass secret masking, mask before truncate), 0600 permissions, hash-chained lines, and rotation at 16 MB. security_audit.jsonl carries security events separately. The same log is the evidence source the grounding precheck and the acceptance judge read, so weakening it weakens verification too.
Per-agent key isolation — MCP API keys and connector credentials are per-agent and resolved through secret_ref, so one agent’s leak is not a platform leak. Channel credentials are split: LINE, WhatsApp, Feishu, Google Chat, Teams, WeCom and DingTalk use the deployment-wide credentials in config.toml [channels]; per-employee bot tokens exist for Telegram, Discord and Slack only (an employee without its own token falls back up the reports_to chain, then to the global token).
Chat commands on channels (v1.68.0) — !STOP, !STOP ALL, !RESUME and /model <name> need an admin. On WhatsApp, Feishu, Teams, WeCom, Google Chat and DingTalk the gateway used to pass is_admin = true for every sender, so anyone who could message the bot could stop or resume it. Those channels now compare the sender id or conversation id, exactly, with the channel’s admin_users setting (global scope; Google Chat and Teams can now have one); with no list, nobody is an admin. On WebChat only an active dashboard account with the Admin role counts, and website widget visitors never do.
Kill-switch triggers (v1.68.0) — the four KILLSWITCH.toml [triggers] thresholds had no reader before. Now a key is enforced only when it is written in the file and in range, and the Security page has a checkbox per trigger (unticking sends null, which removes it). The file is re-read when it changes. cost_limit_usd compares 24-hour spend across all employees and, when reached, sets the global failsafe level to restricted until the failsafe recovers or someone sends !RESUME; max_replies_per_minute counts per conversation and silently drops the excess; max_consecutive_errors and error_rate_threshold (last 20 replies, at least 10) raise that conversation’s failsafe level by one step. Each trip is audited as killswitch_trigger. The [audit] section of KILLSWITCH.toml is no longer read.
Reply circuit breaker and people who write in several messages (B10) — the per-conversation breaker in front of every channel reply (KILLSWITCH.toml [circuit_breaker]) used to drop messages silently in three situations that are ordinary human behaviour: the next message after one reply far longer than the recent ones (token_explosion_multiplier, a property of our own output), three similar messages in a row such as 「在嗎?」 sent while waiting, and more than frequency_max_replies messages within frequency_window_secs. A trip also kept the conversation silent for cooldown_secs and raised its failsafe level, so repeated trips ended in the restricted reply or muting. Now a long reply is only logged; messages shorter than 20 characters never count as repetition; and a message over the frequency limit waits until the window has room and is then answered. A message still waiting after 30 s (a flood) is not answered; it still counts, and the sender is told once per minute. Three things still trip the breaker: an exact echo of our own last reply, three long near-identical messages, and the new hard limit — more than hard_max_messages (default 30) messages within hard_window_secs (default 60) in one conversation, which stops two bots trading short, varied messages. A sender the channel marks as a bot (Telegram is_bot) keeps the old strict trip on the short frequency window. The existing settings keep their names and ranges. Admins edit the two new values on the Security page’s kill-switch card (Advanced: circuit-breaker thresholds); killswitch.update now writes KILLSWITCH.toml through the shared config commit (file lock, refused when the file changed since it was read or does not parse, comments kept).
Redaction source protections (v1.68.0) — the 資料來源保護 switches on the Privacy / Redaction tab now act: user_input redacts a channel message before it reaches the AI, system_prompt redacts the assembled prompt (by default only rules marked apply_to_system_prompt), cron_context redacts the trigger message of a condition script. An error stops that turn instead of sending unredacted text. sub_agent (set on the same tab, or in config.toml [redaction.sources]) covers a delegated agent’s reply that the gateway writes into the delegating agent’s conversation history (the reply to a send_to_agent, spawn_agent or spawn_ephemeral call, including the copy relayed to the agent that started the chain). With on, the reply goes through the receiving agent’s rules before it is stored, and the tokens are restored when that agent answers its user. The default inherit leaves the reply as it is, because the sub-agent already ran under the same [redaction] rules. If the redaction step fails, a fixed notice is stored instead of the reply. Not covered: the copy of the reply sent to the user’s channel (that is the user’s own view), replies an agent fetches itself with check_responses (those are tool results and follow tool_results), hand-offs between Team-as-Agent roles, and Agent Mail. purge_after_expire_days now drives the vault cleanup.
Permission flags (v1.68.0) — agent.toml [permissions] can_create_agents, can_send_cross_agent, can_modify_own_skills and can_schedule_tasks are enforced at the MCP dispatch gate when written as false (refusal audited as permission_denied). A one-time boot migration turns old template false values into true; see dashboard-settings.md.
Rootless container engines (DuDuClaw OS v0.4.0) — from DuDuClaw OS v0.4.0 (embeds platform v1.73.4) the gateway runs as the non-root duduclaw user and its containers run on rootless Podman, reached through DOCKER_HOST / CONTAINER_HOST; v0.3.0 and earlier still run the gateway and Docker as root. Two platform pieces make that safe. Container clients that run with a cleared environment (task sandbox, Discovery attempts and scoring, cleanup) now carry exactly those two names from the gateway’s environment when they are set; they are not on the AI CLI allowlist. The script sandbox runs inside mcp-server, so the gateway also writes the two names into its own duduclaw entry of each employee’s MCP configuration. Before the task sandbox, the script sandbox, computer use or a Discovery attempt or scoring container counts as available on a rootless engine (its info says rootless), a throw-away container with a 32 MiB memory and 32-process limit reads its own memory.max and pids.max; if they read max (an engine with undelegated cgroup controllers accepts the limit and silently ignores it) or anything other than the requested limit, or the check does not finish, those features are unavailable with a reason (task sandbox code resource_limits_unenforced) instead of running without ceilings. Rootful Docker is never probed. A result is reused for ten minutes (a failed check for one) and discarded as soon as the engine comes back from being unavailable.
duduclaw-sysd callers (DuDuClaw OS v0.4.0) — DuDuClaw OS v0.4.0 points the helper at the gateway’s uid, and that uid is also the one the AI employees’ CLIs run as, so a uid check alone lets any agent open the socket and skip every approval the gateway applies. That is not only an availability risk: update_rollback boots the previous system slot, which after the first non-root upgrade is the old image whose gateway (and agents) still ran as root, and ssh_service_start opens a network service. The helper therefore gained --require-main-pid-of <unit>: the caller must be that unit’s systemd main process, proven through a pidfd (SO_PEERPIDFD, Linux 6.5 or later) so a pid reused after a gateway restart cannot pass, and it must sit in the unit’s own cgroup; at most four such checks run at once, each bounded to two seconds, and any failure refuses the call (the flag refuses everything on non-Linux hosts). It is opt-in so older OS images keep working; v0.4.0 turns it on together with the non-root gateway. It relies on yama ptrace_scope being at least 1 (agent processes descend from the gateway and must not be able to ptrace it) and on the gateway never passing a sysd connection to a child (one connection per call, created close-on-exec). Operator terminal commands that change the clock (duduclaw os system timezone-set / ntp-set) also run in a separate process; with the flag on they fail with a message pointing to the dashboard’s system settings. The os_* MCP tools, which run in a separate mcp-server process, therefore no longer open the socket themselves: on an appliance they pass their own checks and then go through a signed loopback route of the gateway (/api/internal/sysd), which accepts only the verbs those tools need (no SSH, network, hostname, timezone or local-model verbs) and calls sysd itself. A process of the gateway’s user that can read the gateway’s key files could still sign requests to that route; it narrows such a caller to those verbs, and the rollback among them is safe only because sysd refuses old targets (next paragraph). The verbs that carry a value were also re-checked for a hostile caller: the hostname is now an RFC 1123 name of at most 64 bytes (until now only its length was checked and a value starting with - reached hostnamectl as an option); timezone and interface names must start with a letter or digit; such values are placed after --. The helper gained a fixed local-model verb (stop or restart of the one model service) so the gateway no longer runs systemctl itself. Factory reset now runs through one verb that also carries the “clear network settings” choice: on images that ship the duduclaw-factory-reset.service oneshot it writes /data/system/factory-reset.pending and reboots, and the oneshot wipes the gateway data, the kiosk home, the gateway’s container store and (if chosen) Wi-Fi before the gateway starts; older images wipe immediately as before, now on the root side. A gateway that meets an older helper refuses the reset with nothing deleted, and so does an appliance whose helper is unreachable. Gateway and helper must ship from the same OS image.
What the non-root gateway does and does not contain (DuDuClaw OS v0.4.0) — on DuDuClaw OS v0.4.0 and later an AI employee that has a shell runs as the gateway’s user. It can read the gateway’s keys and therefore do anything the gateway can, including forging the gateway’s signed internal requests and dashboard sessions. Moving the gateway off root keeps such an agent away from root; it does not keep it away from the gateway’s own powers. The existing rule still holds: real containment means not granting Bash, or running the work in the task sandbox. For that reason the guard against getting root back does not trust any caller: duduclaw-sysd --min-rollback-version <version> (v0.4.0, the first release with a non-root gateway, sets it to its own image version, 1.73.4-y1-bringup) refuses a rollback when any boot entry it could start is older than that, or when the entry’s version cannot be read from the unified kernel image itself (its embedded os-release, cross-checked with the file name). Type #1 entries (loader/entries/*.conf) cannot be checked as strictly, so while any exist the rollback is refused. Installing an update was also a way back to root: the system updater installs whatever sits in its source directory without checking it, and that directory was the gateway’s own download folder, so a same-user process could plant any image (an old signed one renamed to a higher version included). The install now goes through a new helper command: as root it verifies the release’s signed checksum list against the OS signing key built into the helper, copies only the listed files (no links followed, regular files only, hashed while copying) into a root-only staging directory, picks the kernel image bound to the destination slot, and installs only if that image’s embedded version is newer than the running one and at least the rollback floor. The gateway’s own checks stay as an earlier, clearer error. The plain install command is no longer reachable through the gateway’s internal route. Clearing an exhausted entry only deletes it. Remaining outside the helper: the boot loader’s own boot counting falls back to the other entry when a new one fails to start three times before it is marked good, so v0.4.0 deletes entries older than the floor once its first boot has been marked good. That makes the upgrade one-way: after a v0.4.0 image has booted successfully, the device cannot go back to v0.3.0 or earlier, and a bad later release is fixed by installing a newer image or using the rescue entry. Until that first boot is marked good, the old root-gateway entry is still the boot loader’s fallback, and an agent on the new image could reach it by making the trial boots fail; the window is a few boots.
Maintenance SSH with its own account and a temporary key (helper side, unreleased, not usable yet) — the helper gained two commands, ssh_maint_open and ssh_maint_status, for a maintenance login that uses a dedicated account (duduclaw-maint, uid/gid 4800) and one public key per window instead of an open port. The gateway runs as the AI employees’ user, so the helper is the only authority for the key: it accepts exactly one key (Ed25519, ECDSA P-256/P-384/P-521, the two FIDO security-key types, or RSA of 3072–8192 bits with an odd exponent of at most 64 bits), decodes it and checks every field and size, refuses authorized_keys options, certificates, private keys (OpenSSH, PEM, PuTTY), RFC 4716 blocks and more than one key or line, and writes only fixed options (no-port-forwarding,no-agent-forwarding,no-X11-forwarding) plus the canonical encoding of the decoded bytes. The key file lives on tmpfs (/run/duduclaw-maint-ssh/authorized_keys, root:duduclaw-maint 0640); it is written through a directory handle without following symlinks and moved into place with a rename that refuses to replace an existing key, then read back. Before writing anything the helper checks the configuration in effect (systemd’s merged units and the loaded nftables ruleset): an OS readiness marker, the account’s uid and gid, dropbear’s merged command line compared token for token with the expected one, that dropbear’s units are bound to the helper (BindsTo=), the firewall rule that rejects the account’s loopback traffic together with the jump that reaches it, and the LAN/VPN-only source sets for port 22; any miss refuses the window. A window ends at the helper’s own deadline (at most 24 hours, kept on CLOCK_BOOTTIME, so changing the wall clock does not extend it), when the gateway closes it, when the helper restarts and finds it, or, if the helper is down, through a transient systemd timer the helper arms when it opens the window and cancels when it closes it. Every 15 seconds the helper reads logins and logouts from the journal, accepting only fields journald fills in itself (_SYSTEMD_SLICE=system-dropbear.slice, _UID), and appends them to the root-only audit file /data/system/maint-ssh-audit.log; the gateway plays no part, so stopping or compromising the gateway does not stop those records. The helper never waits on the audit file’s lock for more than a few seconds: if it cannot write, a window does not open, and a close reports the problem and keeps the records (and any journal events it could not read yet) queued until they are written. The window state and the journal position live in a root-only directory on persistent storage (/data/system/maint-ssh/), so a reboot during a window does not erase its logins: the next startup replays the journal from the saved position and writes the close record with trigger reboot. While a window is open, every check also re-reads the firewall’s maintenance egress rule and closes the window if it is gone. Opens are rate-limited (at least 5 seconds apart, at most 12 per hour). Strings from those events are cut to 128 characters with control characters removed. The first 50 logins of a window are written one by one and the rest counted, and when the file rotates the window’s open record is written again at the top. On an image that ships the account the old port-only ssh_service_start is refused. Neither command is reachable through the gateway’s internal route for the os_* tools. Nothing here is usable until an OS image ships the account and the gateway and dashboard parts land.
Document knowledge base
Section titled “Document knowledge base”The document knowledge base adds collections of uploaded documents that AI employees can search. Its guards:
- Default-deny permissions per collection, decided in one function and in SQL: a read needs a grant to the employee (or its department) and to the person the turn is for, and group conversations exclude
personalandrestrictedcollections. A grant that cannot be read counts as none; a missing passage and a forbidden one get the samepermission denied. - Parsing out of process: every document is read by
duduclaw knowledge-extractwith a time limit, a CPU limit, an address-space limit on Linux and a sandbox on macOS and Linux that allows no writes, no network and reads only of the file, the extractor and the system libraries; without a sandbox the file is refused unless the operator opts in. Office files pass the zip-bomb checks first,.xlsxcells are streamed with a ceiling. A parser crash only ends that child. - Document text is data: passages are scanned for injection wording when indexed and marked; tool results put them in a marked block where every line starts with
│, so a document cannot forge a result header. A tool call whose turn the gateway did not register reads nothing. Passages put into a reply’s prompt are XML-escaped inside a<knowledge_passages>block, instruction-like ones are never injected, and the system prompt says passage text is data; that last part is a prompt rule the model may still ignore, which is whyanswer_mode = "kb_only"exists. - Who a reply is for is resolved by the gateway, never by the model: the WebChat account, a messaging-app sender only through a verified channel binding, everyone else (including public widget and website Live Chat visitors) as the channel only. Public visitors read only
publiccollections. Cron runs are system turns limited to public and internal collections granted to the employee; a run dispatched from a person’s turn keeps that person’s limits (an unbound sender’s delegation stays channel-only), and a reply turn from an unrecognised session reads nothing. - Group chats disclose by design: a bound sender’s own and department collections may be quoted in a group, and the answer and its source footer are visible to every member. Personal and restricted collections are never used in groups.
- Adding from outside the dashboard always needs a person:
knowledge_add_sourceand the 「加入知識庫」 chat command only file a dashboard-only card, decided by an Admin or the collection’s editor at decision time. The card holds a content hash, a text preview and who asked; a changed file is refused at approval. Only files the channel adapter saved for that sender count (a typed file reference names nothing), visitors and unbound senders cannot propose, and waiting cards are capped per employee and per proposer. - Web pages by URL go through the public-page fetch rules (no internal addresses, no userinfo, no redirects, 5 MB).
- Audit: every read is written to the knowledge access log; collection, permission and deletion changes also go to
security_audit.jsonl. - Vectors respect the same permissions: the vector list is filtered to the allowed collections before any similarity is computed, so a forbidden collection cannot take a top-k slot.
- Embedding models are pinned: each model file and the ONNX Runtime archive are named with size and SHA-256 at one commit; a download or an offline bundle that does not match is refused, and only a fully verified install counts. The extracted ONNX Runtime library is itself pinned and hashed before it is loaded (for the redaction AI detection too), so a library dropped into place by hand is refused. Offline bundles are minisign-signed with the release key and the signature is checked before the bundle is unpacked; the unpack writes only the expected regular files. Custom cloud endpoints must be public
httpsaddresses (re-resolved and pinned on every request) unless an Admin setsallow_private_endpoint, and the disclosure record cannot be added or changed in the raw config editor. Local models keep document text on the machine. - Cloud embeddings are opt-in and fail closed: an Admin must acknowledge which vendor and host receive passages (bound to provider, host and model; any change needs a new acknowledgement). Passages and questions go through the
[redaction]rules first, hits replaced by category markers; with redaction off, rules failing to compile or no rules, nothing is sent and search stays keyword-only.restrictedandlocal_onlycollections are never sent. Endpoints must behttps(plainhttponly to127.0.0.1), redirects are not followed, a provider’s error body is never stored (only its status code) and responses over 32 MB are refused. The force-disable switch of redaction stops sends at once, a running embedding job re-checks the provider, acknowledgement and redaction before every batch, and switching providers resets the previous provider’s endpoint and model. Installing, removing and switching providers are written tosecurity_audit.jsonl.
Not covered yet: Windows has no extraction sandbox (files are refused there by default) or memory limit; heartbeat runs carry no turn or run id, so their tool calls read nothing. Rule-based redaction does not recognise every personal name in passages sent to a cloud embedding provider (the AI detection model does better, not perfectly).
Person credentials in the OS keychain (unreleased)
Section titled “Person credentials in the OS keychain (unreleased)”Letting an AI employee act as one specific person (for example a colleague’s Google account) raises the cost of a stolen credential, so those credentials get their own key. The protected key is a 32-byte AES-256 key that lives only in the operating system’s credential store, and credentials for a specific person are sealed with it (pk:v1:<key id>:…). Home-wide credentials (.keyfile and the *_enc values it decrypts) do not change.
- Off by default. An Admin turns it on from the Security page (the Credential protection card, RPC
security.credential_protection_enable);duduclaw credentials statusprints the same one line. While it is off nothing else changes, and features that act as a specific person refuse to run. - macOS: the item sits in the login keychain, and its decrypt permission trusts only the
duduclawexecutable that created it, identified by code hash. Reading the key with Claude’sReadtool,cat, a backup,securityor a Python script fails; with someone at the Mac, macOS asks first. The gateway’s own reads never show a dialog. Code injected into aduduclawprocess counts asduduclaw: in a test, code injected into an ad-hoc signed creator read the key silently, and hardened runtime stopped it. So the card says “only the DuDuClaw program can read it” only when the running executable has hardened runtime; the released CLI does not yet, and shows “readable by programs under the same user account” with a note about code injection. After an upgrade the new executable has a new code hash, so personal accounts pause until someone at the Mac runsduduclaw credentials authorizeand approves the dialog once. Nothing else stops working. - Windows: the key is in Credential Manager. That keeps it out of files and backups, but any program running as the same Windows user can read it, including an AI employee with a shell. The dashboard says so.
- Linux and Docker: no supported store yet. The card shows “Files (this platform has no supported system keychain)” and person identities stay refused.
- A replaced key is refused, not used. On macOS any program running as the same user can overwrite or delete a keychain item without asking (measured on a throwaway keychain). The gateway therefore checks the key against a key id and an encrypted canary recorded at enable time, pins the first key it loads for the life of the process, and treats a sealed value with a different key id as a changed key. Any of these stops personal accounts and shows “Protection key was replaced”. Replacing the key and the record together and then restarting the gateway passes the first two checks; only the old sealed values give it away. Replace key (also offered when everything looks fine) creates a new key, and everyone reconnects.
- Only the gateway unseals. The key is handed out only inside the process that holds
<home>/locks/gateway.lock. The MCP server and CLI subcommands never receive it. - Audit:
security_audit.jsonlgetscredential_protection_enabled(with the previous state; Critical for a reset),credential_protection_enable_failed,credential_protection_disabled, andcredential_protection_brokenthe first time a gateway sees a missing or replaced key or a bad record. Rows carry the state, level, backend and key id, never the key.
What it does not stop: an employee with a shell can still replace the key (detected, as above), kill the gateway and start its own, or inject code into the duduclaw executable while the released macOS CLI lacks hardened runtime. Real separation is still not granting Bash, the task sandbox, or running the gateway as a different OS user.
Acting as a colleague’s Google account (unreleased)
Section titled “Acting as a colleague’s Google account (unreleased)”Company delegation (B2a) lets an AI employee use Google as one named colleague. The gates, all failing closed:
- Only the proven employee. The identity is chosen from the employee proven by
DUDUCLAW_AGENT_ID+DUDUCLAW_AGENT_TOKEN, never from a tool argument. A process claiming an employee it cannot prove gets only the home default, and nothing when the claimed employee is set to anything else. - Only the gateway mints. The MCP server asks
POST /api/internal/google-token(loopback, signed identity, nonce, rate limit, the same authentication as the knowledge route). The gateway reads the employee’s[google]itself, looks up who the turn is for in its own registry, and unseals the delegation key only afterperson_identity_gate()passes (credential protection at or above the minimum level; Linux and Docker are refused). The token stays in the MCP server’s memory for that turn. - The key is sealed. The service-account key is stored only as a
pk:v1value under the protected key. A sealed key that does not open with the current protected key is treated as a replaced key: delegation stops,google_delegation_key_unreadable(Critical) is written once, and the admin resets protection and imports the key again. The record is not overwritten silently. - Who asked. By default only people bound to the employee, Admins, and system turns may use the colleague’s account; public visitors never, under any setting. A refused requester or a setting error never falls back to the shared account; other failures do so only when the employee’s
fallback = "home"says so, and the reply says it did. - People are re-read. A colleague whose DuDuClaw account is suspended or offboarded is refused on the next call.
- Settings are admin-only.
[google]is frozen for the employee’s own Write/Edit (agent-file guard) and is an authority key: a non-admin Owner may only narrow it.
Limits: gmail.compose lets the token holder send mail; an employee with an unrestricted shell can use the token issued for its own colleague directly against Google, and runs as the same OS user as the gateway. External Google MCP servers (oauth://google) are skipped for a delegated employee in this release. See Per-employee Google accounts.
What these guards do not cover
Section titled “What these guards do not cover”Saying this plainly is part of the defense.
- The hooks see Claude Code’s own tool calls, not MCP tool calls. MCP has its own gate (scopes, grants,
denied_tools); the hooks are the second lock, on the built-inWrite/Edit/Read/Bashsurface. - The Bash lane of
agent-file-guardis a heuristic. It reads command text, so these get past it: a path computed from a variable (other than$DUDUCLAW_HOME, and~/.duduclaw/$HOME/.duduclawwhen the home is in the default place), from command substitution or from any other calculation; an encoded command; a script written to disk and then run; a here-document fed to an interpreter; aliases and functions; an environment variable that makes a shell started later load a file; a write option of a listed read-only command that the list did not account for; an extraction or download command with no explicit destination, which writes into the current directory (the lane does not judge the current directory, so changing into the home first and then running it is not stopped); a link created and then written through within the same command; hard links; and the time gap between the check and the actual execution. - Operator-only memory commands are guarded by a speed bump and an approval, not a sandbox. The Bash lane rule for
memory forget-sourceandmemory migrate-namespacemisses a global option before the subcommand, a binary named by command substitution and a piped command, and the AI-session check can be defeated by unsetting variables. The dashboard approval is the gate; see Forget by source needs an Admin approval. - The Bash lane also refuses some harmless commands. A judged path whose resolution fails is refused even when it lies outside the home, and a dangling symbolic link is refused even when it points outside the home.
agent-file-guarddoes not coverRead. Held-out eval sets and the audit log remain readable to an employee; the hook only stops writes.- The OS keychain does not separate an employee from the gateway. Both run as the same OS user. On macOS the protected key resists reading by other programs but not replacement; on Windows any same-user program can read it. See Person credentials in the OS keychain.
- Only the Claude runtime runs these hooks. Codex, Antigravity and the other runtimes rely on their own sandbox flags.
- State files inside the employee’s own directory are not protected, apart from
SOUL.md,CONTRACT.toml,INSTRUCTIONS.md, the identity files (.mcp.json,.claude/settings.json) and itsagent.toml. The sharedattachments/directory is writable by every employee. data-file-guardis a heuristic. It matches filenames in aBashcommand line; a dynamically-built path defeats it. (It is no longer inert on Windows — H10 made it a Rust subcommand.)- There is no threat-level state machine.
~/.duduclaw/threat_levelsurvives as an operator-controlled kill switch that the computer-use orchestrator polls (REDstops the run,YELLOWpauses it), but nothing inside the workspace writes it. A missing file meansGREEN; a file that exists but cannot be read, or holds anything other thanGREEN/YELLOW/RED, is treated asRED(fail closed) after two re-reads 50 ms apart; a leading UTF-8 BOM and surrounding whitespace are ignored. - (Removed 2026-09.) This section used to note that the PTY session pool sat outside the redaction rewrite. That pool no longer exists — every Claude spawn is a per-call spawn, which is exactly what the rewrite hooks into.
Interaction with other systems
Section titled “Interaction with other systems”- CONTRACT.toml defines what an agent must never do, and
duduclaw testred-teams it. The guards enforce at the tool-call level. - Evolution engine — because
SOUL.mdis read-only to agents, the evolving artifact is the playbook. See 38-aee-playbook-evolution.md. - Redaction and data sources — see 55-data-sources.md for the pipeline
data-file-guardcomplements. - Delegation isolation — see 37-delegation-isolation.md for the predicate
org_field_guardprotects.
The takeaway
Section titled “The takeaway”Four guards with stated failure modes beat a three-layer story with no code behind it. When a defense is removed the documentation has to go with it: a page describing a shell script that does not exist is worse than no page, because it makes an operator stop looking.
Durable channel decisions
Section titled “Durable channel decisions”High-risk Computer Use confirmations use the inbound account and exact conversation/thread. Reply with 確認 <full UUID> or 取消 <full UUID>; questions use 回答 <full UUID> <answer> and never grant tool permission. Bare yes/A/B cannot select a request. Before execution, the host rechecks the live screen, title, policy and cancellation gates. Restart invalidates old GUI approvals instead of replaying coordinates; an execution without a receipt becomes uncertain and requires Admin reconciliation. See the decision guide for the exact supported inbound routes and current limitations.