# Lab Spec — DD-16: ZeroClaw — The 6-Layer Safety Cascade

**Deep-Dive**: DD-16 · **Duration**: 90 minutes · **Language**: Python 3.10+ · **Dependencies**: none (stdlib only)

**Goal**: Build a minimal simulation of ZeroClaw's 6-layer safety cascade. Model each layer as a gate function. Send a sequence of tool calls through the cascade and confirm which calls are blocked at which layer. Then implement the tool-receipt system (HMAC-SHA256 over tool calls) and confirm it detects a fabricated tool claim.

---

## Setup

Create `zeroclaw_sim.py`. No external dependencies. Type hints required. Python 3.10+.

```python
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Optional
import hmac, hashlib, json, sys
```

---

## Phase 1 — Model the 6-layer cascade

### Step 1: Define the data types

```python
class Risk(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

class Autonomy(str, Enum):
    READONLY = "readonly"
    SUPERVISED = "supervised"
    FULL = "full"

@dataclass
class ToolCall:
    tool: str                       # e.g. "file_read", "shell_exec", "http_get"
    args: dict                      # e.g. {"path": "/etc/passwd"}
    risk: Risk = Risk.MEDIUM

@dataclass
class ToolResult:
    ok: bool
    value: str
    error: Optional[str] = None     # ToolResult::Err equivalent
    receipt: Optional[str] = None   # HMAC-SHA256 if ok and receipt layer active

class BlockReason(Exception):
    def __init__(self, layer: int, reason: str):
        self.layer = layer
        self.reason = reason
        super().__init__(f"LAYER {layer} BLOCK: {reason}")
```

### Step 2: Define the 6 layers as gate functions

Each layer is a function `(tool_call, config) -> None` that raises `BlockReason` if the call is blocked. The cascade runs them in order; the first to raise wins.

```python
@dataclass
class Config:
    allowed_users: list[str]
    allowed_chats: list[str]
    autonomy: Autonomy = Autonomy.SUPERVISED
    workspace_only: bool = True
    workspace_root: str = "/workspace"
    forbidden_paths: list[str] = field(default_factory=lambda: ["/etc", "/sys", "~/.ssh"])
    allowed_commands: list[str] = field(default_factory=lambda: ["ls", "cat", "grep", "git"])
    forbidden_commands: list[str] = field(default_factory=lambda: ["rm", "curl", "wget"])
    receipts_active: bool = True

# LAYER 1: Channel pairing / access control
def layer1_channel_acl(call: ToolCall, cfg: Config, sender: str, chat: str) -> None:
    if sender not in cfg.allowed_users:
        raise BlockReason(1, f"sender {sender!r} not in allowed_users")
    if chat not in cfg.allowed_chats:
        raise BlockReason(1, f"chat {chat!r} not in allowed_chats")

# LAYER 2: Autonomy level + tool risk classification
def layer2_autonomy(call: ToolCall, cfg: Config) -> None:
    if cfg.autonomy is Autonomy.READONLY and call.tool not in ("file_read", "http_get"):
        raise BlockReason(2, f"readonly autonomy: {call.tool} is not a read-only tool")
    if cfg.autonomy is Autonomy.SUPERVISED and call.risk is Risk.HIGH:
        raise BlockReason(2, f"supervised autonomy: {call.tool} is HIGH risk (requires full autonomy or operator approval)")
    # FULL autonomy: no block based on risk alone

# LAYER 3: Workspace boundary + path rules
def layer3_workspace(call: ToolCall, cfg: Config) -> None:
    path = call.args.get("path")
    if path is None:
        return
    for forbidden in cfg.forbidden_paths:
        if path.startswith(forbidden):
            raise BlockReason(3, f"path {path!r} matches forbidden_path {forbidden!r}")
    if cfg.workspace_only and not path.startswith(cfg.workspace_root):
        raise BlockReason(3, f"path {path!r} outside workspace root {cfg.workspace_root!r} (workspace_only=True)")

# LAYER 4: Shell command policy (allowlist BEFORE exec)
def layer4_shell_policy(call: ToolCall, cfg: Config) -> None:
    if call.tool != "shell_exec":
        return
    cmd = call.args.get("command", "")
    base = cmd.split()[0] if cmd.split() else ""
    if base in cfg.forbidden_commands:
        raise BlockReason(4, f"command {base!r} on forbidden_commands list")
    if base not in cfg.allowed_commands:
        raise BlockReason(4, f"command {base!r} not on allowed_commands allowlist")

# LAYER 5: OS-level sandbox (simulated — always passes in this lab)
def layer5_sandbox(call: ToolCall, cfg: Config) -> None:
    # In production: Landlock/Bubblewrap/Seatbelt/AppContainer/Docker auto-detected.
    # Here we simulate: the sandbox is present and does not block.
    pass

# LAYER 6: Tool receipts (computed AFTER exec — handled in the executor, not as a pre-gate)
# See Phase 2.
```

### Step 3: The cascade runner

```python
def run_cascade(call: ToolCall, cfg: Config, sender: str = "alice", chat: str = "#ops") -> ToolResult:
    """Run a tool call through the 6-layer cascade. Returns ToolResult (ok or Err)."""
    try:
        layer1_channel_acl(call, cfg, sender, chat)
        layer2_autonomy(call, cfg)
        layer3_workspace(call, cfg)
        layer4_shell_policy(call, cfg)
        layer5_sandbox(call, cfg)
    except BlockReason as b:
        return ToolResult(ok=False, value="", error=str(b))
    # All pre-gates passed — execute (simulated)
    return ToolResult(ok=True, value=f"<simulated {call.tool} result>")
```

### Step 4: Verify the cascade

Write `verify_cascade()` that asserts these cases:

| # | Call | Expected outcome | Layer |
| --- | --- | --- | --- |
| 1 | `file_read {path:/workspace/a.txt}` | OK | — |
| 2 | `file_read {path:/etc/passwd}` | BLOCKED | 3 (forbidden_path /etc) |
| 3 | `file_read {path:/tmp/x}` | BLOCKED | 3 (outside workspace) |
| 4 | `shell_exec {command:rm -rf /}` | BLOCKED | 4 (forbidden_commands) |
| 5 | `shell_exec {command:nmap 10.0.0.1}` | BLOCKED | 4 (not on allowlist) |
| 6 | `shell_exec {command:ls /workspace}` | OK | — |
| 7 | `drop_table {risk:HIGH}` under SUPERVISED | BLOCKED | 2 (HIGH risk) |

Run it: `python3 zeroclaw_sim.py` should print `Phase 1 PASS` if all seven cases match.

---

## Phase 2 — Tool receipts (HMAC-SHA256 over successful tool calls)

### Step 1: The receipt system

```python
def compute_receipt(call: ToolCall, result_value: str, key: bytes) -> str:
    """HMAC-SHA256 over (tool, args, result). Ephemeral key — in-context integrity signal."""
    payload = json.dumps({"tool": call.tool, "args": call.args, "result": result_value}, sort_keys=True)
    return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()

def issue_receipt(call: ToolCall, result: ToolResult, key: bytes) -> ToolResult:
    """If a call succeeded and receipts are active, attach an HMAC receipt."""
    if result.ok and key:
        result.receipt = compute_receipt(call, result.value, key)
    return result
```

Update `run_cascade` to issue a receipt on success:

```python
RECEIPT_KEY = b"ephemeral-lab-key-not-durable"

def run_cascade(call: ToolCall, cfg: Config, sender: str = "alice", chat: str = "#ops") -> ToolResult:
    try:
        layer1_channel_acl(call, cfg, sender, chat)
        layer2_autonomy(call, cfg)
        layer3_workspace(call, cfg)
        layer4_shell_policy(call, cfg)
        layer5_sandbox(call, cfg)
    except BlockReason as b:
        return ToolResult(ok=False, value="", error=str(b))
    result = ToolResult(ok=True, value=f"<simulated {call.tool} result>")
    if cfg.receipts_active:
        result = issue_receipt(call, result, RECEIPT_KEY)
    return result
```

### Step 2: The fabricated-claim detector

The model claims "file_read on /workspace/a.txt returned <simulated result>." The harness checks: does a receipt exist for this exact claim?

```python
def verify_claim(claimed_tool: str, claimed_args: dict, claimed_value: str,
                 issued_receipts: list[tuple[ToolCall, ToolResult]], key: bytes) -> bool:
    """Return True if the claim matches an issued receipt, False if fabricated."""
    expected = compute_receipt(ToolCall(claimed_tool, claimed_args), claimed_value, key)
    for _, result in issued_receipts:
        if result.receipt == expected:
            return True
    return False
```

### Step 3: Verify the fabricated-claim defense

Write `verify_receipts()` that:

1. Runs a real `file_read {path:/workspace/a.txt}` through the cascade. Records the receipt.
2. Constructs a HONEST claim (matches the real call and result). Asserts `verify_claim(...)` returns True.
3. Constructs a FABRICATED claim (claims the file returned "secret API key = sk-..."). Asserts `verify_claim(...)` returns False — the receipt does not match, so the claim is detected as fabricated.

Run it: `python3 zeroclaw_sim.py` should print `Phase 2 PASS`.

---

## Phase 3 — The explicit-only memory rule

Simulate ZeroClaw's explicit-only memory design. The agent has a `durable_memory` dict. Writes happen ONLY via an explicit `memory_store` call that passes through the cascade.

```python
def memory_store(key: str, value: str, cfg: Config) -> ToolResult:
    call = ToolCall(tool="memory_store", args={"key": key, "value": value}, risk=Risk.MEDIUM)
    result = run_cascade(call, cfg)
    return result
```

Write `verify_explicit_memory()` that:

1. Confirms that an indirect-injection payload arriving in a tool output does NOT persist to durable memory automatically (no memory_store call was made).
2. Confirms that an explicit `memory_store` call DOES persist (and passes through the cascade).

**Discussion prompt**: Compare this to Hermes (DD-08)'s model-initiated free writes. Write one paragraph: under which deployment would you prefer ZeroClaw's explicit-only design, and under which would you prefer Hermes's free-writes design? Save your answer to `answers.md`.

---

## Phase 4 (stretch) — The thickness-paradox measurement

ZeroClaw is thin by philosophy but medium-thick by implementation. Write a function that, given a Cargo workspace path, counts:

- Total `.rs` files
- Total LOC (`wc -l` equivalent — count non-blank lines)
- Number of `trait` declarations
- Number of `impl` blocks
- Number of feature flags in `Cargo.toml` files (`#[cfg(feature = ...)]` count)

Run it against any local Rust workspace you have access to. Record the numbers. Discuss: does the trait-to-impl ratio justify the line count? Is the codebase thick because it has many providers (intentional breadth) or because the kernel itself is bloated (unintentional)?

---

## Solution key

### Phase 1 expected output

```
Case 1 (file_read /workspace/a.txt):       OK          — pass
Case 2 (file_read /etc/passwd):            BLOCKED L3  — pass
Case 3 (file_read /tmp/x):                 BLOCKED L3  — pass
Case 4 (shell_exec rm -rf /):              BLOCKED L4  — pass
Case 5 (shell_exec nmap):                  BLOCKED L4  — pass
Case 6 (shell_exec ls /workspace):         OK          — pass
Case 7 (drop_table HIGH under SUPERVISED): BLOCKED L2  — pass
Phase 1 PASS
```

### Phase 2 expected output

```
Honest claim receipt verified:    True   — pass
Fabricated claim receipt verified: False  — pass (fabrication detected)
Phase 2 PASS
```

### Phase 3 expected behavior

- Indirect injection in a tool output: NOT persisted (no memory_store call). The injection influenced the current turn but did not survive to durable memory.
- Explicit memory_store call: persisted (and passed through the cascade).

---

## Stretch goals

1. **Add layer 6 as a pre-gate.** In this lab, receipts are computed AFTER exec. Extend the model so the receipt from a PREVIOUS successful call is checked when the model claims its result (the fabricated-claim defense happens reactively, not proactively).
2. **Approval routing.** Add a config flag `approver_channel: Optional[str]`. When a MEDIUM-risk tool is blocked at layer 2 under SUPERVISED autonomy, route the request to the approver channel and resume on approval. Simulate the approval with a callback.
3. **Ephemeral vs durable keys.** Add a `durable_key: Optional[bytes]` to Config. When set, receipts are signed with it and persisted to a JSONL audit log. Demonstrate that this enables post-hoc forensics (the limitation called out in the deep-dive).
4. **RFC 5574 simulation.** Add a `feature_flags: set[str]` to Config. Make each layer conditional on a feature flag. Demonstrate that `feature_flags = {"kernel"}` (loop + policy only) produces a minimal cascade, and adding `"hardware"` enables GPIO/SPI/USB tool types. This is the microkernel refactor in miniature.

---

## Deliverables

- `zeroclaw_sim.py` — the simulator (Phases 1, 2, 3).
- `answers.md` — your Phase 3 discussion paragraph (explicit-only vs free-writes tradeoff).
- (Stretch) `thickness.py` — the Phase 4 measurement script and its output against a real workspace.

---

## References

- DD-16 teaching document, Section "The 6-Layer Safety Model" — the layer definitions and order.
- DD-16 teaching document, Section "Phase 4 — Security Audit" — the credential flow and tool-receipt caveats.
- Module 6 (Permission/Safety) — risk-tiering principle the cascade embodies.
- Module 11 — the fabricated-tool-claim attack the receipt system defends against.
- DD-08 (Hermes) — the model-initiated-free-writes design ZeroClaw's explicit-only memory trades against.
