Task — engineering-spec@1

"Claude Code hook capture — UserPromptSubmit + PostToolUse + Stop hooks emit memory memories with prompt + diff + trace correlation"

doneTASK-MEMORY-109
module memory · class product · priority p0 · created 2026-05-16 · shipped 2026-05-23
depends on TASK-MEMORY-107 · blocks TASK-MEMORY-110

§1 — Description (BCP-14 normative)

The Claude Code hook capture service MUST integrate with Claude Code's documented hook system (UserPromptSubmit, PostToolUse, Stop) to emit memory audit rows for every meaningful Claude Code interaction. The contract:

  1. MUST install via cyberos memory hook claude install <project-path> which writes to <project-path>/.claude/settings.json (project-scope) OR ~/.claude/settings.json (user-scope, with --user flag). The installer is idempotent: re-running with the same args is a no-op.
  2. MUST subscribe to exactly three Claude Code hook events:
  1. MUST NOT subscribe to PreToolUse, Notification, SubagentStop, or SessionStart — those are higher-frequency and offer marginal capture value vs the noise they add.
  2. MUST redact every prompt + assistant message BEFORE hashing or emitting per TASK-MEMORY-111's ruleset:
  1. MUST fail-closed on redaction: if the redactor itself errors (regex panic, OOM), the hook emits a memory.claude_capture_redaction_failed row with {session_id, error} and exits 0 — never blocks Claude Code.
  2. MUST complete in ≤ 50ms p95 for the synchronous part (read stdin + redact + spawn emit). The actual memory write is delegated to a background tokio task via the TASK-MEMORY-107 capture daemon's local Unix socket (/tmp/cyberos-memory-capture.sock) — fire-and-forget. Hook process exits 0 once the message is enqueued.
  3. MUST correlate session-level rows via session_id (UUID; provided by Claude Code in the hook payload) AND a per-session W3C trace_id (32-char hex). The trace_id is generated at first hook invocation for a given session_id and cached in /tmp/cyberos-memory-claude-traces/<session_id> (file persists for 1 hour; pruned by TASK-MEMORY-110 sweeper).
  4. MUST read the hook payload as a single JSON object from stdin per Claude Code's documented schema. Schema validation is strict — unknown top-level keys are logged at WARN and ignored; missing required keys exits 0 with stderr WARN.
  5. MUST emit OTel span memory.claude.hook per invocation with attributes hook_kind, session_id, duration_ms, redaction_match_count.
  6. MUST NOT call cyberos_memory_writer::MemoryWriter::emit directly. The hook is a short-lived process; it MUST post to the long-running TASK-MEMORY-107 daemon via local Unix-domain socket. The daemon does the chain write. This decouples hook latency from chain commit latency.
  7. MUST be opt-in per project: cyberos memory hook claude install <project-path> writes the hook config; absent config = no hook = no capture. There is NO global default that captures every Claude Code project.
  8. MUST support cyberos memory hook claude uninstall <project-path> (reverse of install) and cyberos memory hook claude status <project-path> (prints installed hooks + last capture row for that project).
  9. MUST emit OTel metrics:
  1. SHOULD support cyberos memory hook claude tail — pretty-print the last N memory.claude_* rows for operator debugging.

§2 — Why this design (rationale for humans)

Why only three hook events (§1 #2 + #3)? Claude Code emits many hooks. The three we subscribe to are the load-bearing trio:

Other hooks are operationally noisy (Notification, PreToolUse) or rare (SubagentStop) — capturing them adds 5× chain pressure for ~5% incremental signal.

Why hash tool args, not store (§1 #2)? Tool args sometimes contain secrets — e.g. a Bash tool call like curl -H "Authorization: Bearer sk-abc" .... We capture the fact the tool ran and the hash of its args (so we can dedup identical re-runs) but never the raw args. Operators investigating "what was the call?" can cross-reference the trace_id back to Claude Code's local log (which the user controls).

Why redact in the hook process, not in memory (§1 #4 + #5)? Redaction at write time means raw prompts never leave the user's machine — strongest privacy story. If we redacted server-side, the raw prompt would briefly exist in transit / in temporary buffers. The hook is the right boundary: redact before the bytes leave the local process tree.

Why fail-closed on redaction (§1 #5)? If we fail-OPEN (emit raw prompt on redactor failure), a buggy regex update could leak every prompt. Fail-CLOSED means the worst case is "we lost one row of capture" — recoverable. The memory.claude_capture_redaction_failed row tells the operator their redactor needs fixing, with no data leaked.

Why 50ms latency budget (§1 #6)? Hooks run on Claude Code's critical path. A 500ms hook makes the assistant feel sluggish; users disable it. 50ms is below human-noticeable. The redaction layer is ~5ms p95 (regex on ≤ 4KB prompt); the rest is JSON parse + socket send. Spec budgets 50ms p95 to leave headroom.

Why Unix socket to TASK-MEMORY-107 daemon (§1 #10)? Two reasons:

  1. The hook is a short-lived process (spawned per event); opening a memory connection + writing chain rows + closing would take ~200ms — exceeds budget.
  2. The daemon already has the chain-write machinery, the W3C trace context, the dedup cache. Reusing it via socket is one IPC vs. duplicating the writer stack.

Why per-session trace_id cached in /tmp (§1 #7)? Within a Claude Code session, multiple hook invocations (1 UserPromptSubmit + N PostToolUse + 1 Stop) must share a trace_id so OBS dashboards can correlate them. The session_id is the natural key; /tmp is the right scope (per-machine, per-session, ephemeral). 1-hour TTL covers long sessions; pruned by TASK-MEMORY-110.

Why opt-in per project (§1 #11)? Some Claude Code projects are personal (researching a side project, debugging a friend's code) — operators don't want those captured. Per-project opt-in via .claude/settings.json is the right granularity: explicit consent, project-scoped, version-controllable.

Why tool_args_hash even though redacted (§1 #2)? Dedup. The same Bash invocation with the same args (e.g. cargo test run 10 times) should produce one logical "tool was run" memory, not 10. The hash key is (tool_name, blake3(canonical_json(args))).

Why bullets vs prose in §1? Per the task template's normative structure; readability for compliance review.


§3 — API contract

Cargo.toml

[package]
name        = "cyberos-memory-claude-hook"
version     = "0.1.0"
edition     = "2021"

[[bin]]
name = "cyberos-memory-claude-hook"
path = "src/main.rs"

[dependencies]
clap        = { version = "4", features = ["derive"] }
serde       = { version = "1", features = ["derive"] }
serde_json  = "1"
tokio       = { version = "1.40", features = ["rt-multi-thread", "macros", "net", "io-util", "time"] }
blake3      = "1.5"
regex       = "1.10"
once_cell   = "1.19"
anyhow      = "1"
thiserror   = "1"
tracing     = "0.1"
tracing-opentelemetry = "0.23"
uuid        = { version = "1.10", features = ["v4"] }

Claude Code hook config (template)

// services/memory-claude-hook/install/.claude/settings.json.template
{
  "hooks": {
    "UserPromptSubmit": [
      { "matcher": ".*", "hooks": [{ "type": "command", "command": "cyberos-memory-claude-hook userpromptsubmit" }] }
    ],
    "PostToolUse": [
      { "matcher": ".*", "hooks": [{ "type": "command", "command": "cyberos-memory-claude-hook posttooluse" }] }
    ],
    "Stop": [
      { "matcher": ".*", "hooks": [{ "type": "command", "command": "cyberos-memory-claude-hook stop" }] }
    ]
  }
}

CLI

// services/memory-claude-hook/src/main.rs
use clap::Parser;
use cyberos_cli_exit::ExitCode;

#[derive(Parser)]
#[command(name = "cyberos-memory-claude-hook")]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Parser)]
enum Cmd {
    /// Process UserPromptSubmit JSON on stdin
    Userpromptsubmit,
    /// Process PostToolUse JSON on stdin
    Posttooluse,
    /// Process Stop JSON on stdin
    Stop,
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
    let cli = Cli::parse();
    // Read stdin as JSON
    let mut buf = Vec::new();
    if let Err(e) = tokio::io::AsyncReadExt::read_to_end(&mut tokio::io::stdin(), &mut buf).await {
        eprintln!("WARN cyberos-memory-claude-hook: stdin read failed: {e}");
        return ExitCode::Ok;  // never block Claude Code
    }
    let payload: serde_json::Value = match serde_json::from_slice(&buf) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("WARN cyberos-memory-claude-hook: invalid JSON: {e}");
            return ExitCode::Ok;
        }
    };
    let kind = match cli.cmd {
        Cmd::Userpromptsubmit => HookKind::UserPromptSubmit,
        Cmd::Posttooluse      => HookKind::PostToolUse,
        Cmd::Stop             => HookKind::Stop,
    };
    if let Err(e) = cyberos_memory_claude_hook::dispatch(kind, payload).await {
        eprintln!("WARN cyberos-memory-claude-hook: dispatch failed: {e}");
        // still exit 0; redaction-failed audit row already emitted by dispatch on failure paths
    }
    ExitCode::Ok
}

Dispatch + emit

// services/memory-claude-hook/src/hook.rs
use serde::Deserialize;
use uuid::Uuid;

#[derive(Clone, Copy, Debug)]
pub enum HookKind { UserPromptSubmit, PostToolUse, Stop }

#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
struct UserPromptSubmitPayload {
    session_id:  Uuid,
    prompt:      String,
    cwd:         String,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
struct PostToolUsePayload {
    session_id:        Uuid,
    tool_name:         String,
    tool_args:         serde_json::Value,
    outcome:           String,    // "success" | "error"
    duration_ms:       u64,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
struct StopPayload {
    session_id:           Uuid,
    prompt_count:         u32,
    tool_use_count:       u32,
    duration_ms:          u64,
    last_assistant_message: Option<String>,
}

pub async fn dispatch(kind: HookKind, payload: serde_json::Value) -> anyhow::Result<()> {
    let start = std::time::Instant::now();
    let trace_id = trace_id_for_session(extract_session_id(&payload)?).await?;

    let row = match kind {
        HookKind::UserPromptSubmit => {
            let p: UserPromptSubmitPayload = serde_json::from_value(payload)?;
            let (prompt_redacted, match_count) = crate::redact::redact(&p.prompt);
            serde_json::json!({
                "kind": "memory.claude_prompt",
                "payload": {
                    "session_id":      p.session_id,
                    "prompt_hash":     hex::encode(blake3::hash(p.prompt.as_bytes()).as_bytes()),
                    "prompt_redacted": prompt_redacted,
                    "cwd":             p.cwd,
                    "trace_id":        trace_id,
                    "captured_at_ns":  unix_ns(),
                    "redaction_match_count": match_count,
                }
            })
        }
        HookKind::PostToolUse => {
            let p: PostToolUsePayload = serde_json::from_value(payload)?;
            let args_canonical = serde_json::to_string(&p.tool_args)?;
            let args_hash = hex::encode(blake3::hash(args_canonical.as_bytes()).as_bytes());
            serde_json::json!({
                "kind": "memory.claude_tool_use",
                "payload": {
                    "session_id":     p.session_id,
                    "tool_name":      p.tool_name,
                    "tool_args_hash": args_hash,
                    "outcome":        p.outcome,
                    "duration_ms":    p.duration_ms,
                    "trace_id":       trace_id,
                }
            })
        }
        HookKind::Stop => {
            let p: StopPayload = serde_json::from_value(payload)?;
            let last_msg = p.last_assistant_message.as_deref().unwrap_or_default();
            let (last_redacted, _) = crate::redact::redact(last_msg);
            serde_json::json!({
                "kind": "memory.claude_session_completed",
                "payload": {
                    "session_id":                     p.session_id,
                    "prompt_count":                   p.prompt_count,
                    "tool_use_count":                 p.tool_use_count,
                    "duration_ms":                    p.duration_ms,
                    "last_assistant_message_redacted": last_redacted,
                    "trace_id":                       trace_id,
                }
            })
        }
    };
    crate::emit::post_to_daemon(row).await?;
    metric_record_latency(kind, start.elapsed());
    Ok(())
}

async fn trace_id_for_session(session_id: Uuid) -> anyhow::Result<String> {
    let path = std::path::Path::new("/tmp/cyberos-memory-claude-traces").join(session_id.to_string());
    if let Ok(bytes) = tokio::fs::read(&path).await {
        return Ok(String::from_utf8(bytes)?);
    }
    let new_tid = generate_trace_id();
    let _ = tokio::fs::create_dir_all(path.parent().unwrap()).await;
    tokio::fs::write(&path, &new_tid).await?;
    Ok(new_tid)
}

Redactor

// services/memory-claude-hook/src/redact.rs
use once_cell::sync::Lazy;
use regex::Regex;

struct Rule { pat: Regex, replace: &'static str, name: &'static str }

static RULES: Lazy<Vec<Rule>> = Lazy::new(|| vec![
    Rule { pat: Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),                       replace: "<AWS_KEY>",        name: "aws_access_key" },
    Rule { pat: Regex::new(r"(?i)aws_secret(?:_access)?_key\s*=\s*\S+").unwrap(), replace: "<AWS_SECRET>",   name: "aws_secret" },
    Rule { pat: Regex::new(r"(?i)Bearer\s+[A-Za-z0-9._\-]{16,}").unwrap(),     replace: "Bearer <REDACTED>", name: "bearer_token" },
    Rule { pat: Regex::new(r"gh[oprsu]_[A-Za-z0-9_]{16,}").unwrap(),           replace: "<GH_PAT>",          name: "github_pat" },
    Rule { pat: Regex::new(r"sk-[A-Za-z0-9]{32,}").unwrap(),                   replace: "<OPENAI_KEY>",      name: "openai_key" },
    Rule { pat: Regex::new(r"sk-ant-[A-Za-z0-9_-]{32,}").unwrap(),             replace: "<ANTHROPIC_KEY>",   name: "anthropic_key" },
    Rule { pat: Regex::new(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
                                                                                replace: "<JWT>",            name: "jwt" },
    Rule { pat: Regex::new(r"\b\d{12}\b").unwrap(),                            replace: "<VN_CCCD>",         name: "vn_cccd" },
    Rule { pat: Regex::new(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b").unwrap(),
                                                                                replace: "<EMAIL>",          name: "email" },
    Rule { pat: Regex::new(r"\+?\d{1,3}[\s\-]?\d{2,4}[\s\-]?\d{3,4}[\s\-]?\d{3,4}").unwrap(),
                                                                                replace: "<PHONE>",          name: "phone" },
]);

pub fn redact(input: &str) -> (String, u32) {
    let mut buf = input.to_string();
    let mut match_count = 0u32;
    for rule in RULES.iter() {
        let before_len = buf.len();
        buf = rule.pat.replace_all(&buf, rule.replace).into_owned();
        // crude count: each replacement changes length by (rule.replace.len() - matched.len());
        // accept rough count as match metric
        if buf.len() != before_len {
            match_count += 1;
            metrics::counter!("memory_claude_hook_redactions_total", "pattern" => rule.name).increment(1);
        }
    }
    (buf, match_count)
}

Socket emit

// services/memory-claude-hook/src/emit.rs
use tokio::io::AsyncWriteExt;
use tokio::net::UnixStream;
use std::time::Duration;

const SOCKET: &str = "/tmp/cyberos-memory-capture.sock";

pub async fn post_to_daemon(row: serde_json::Value) -> anyhow::Result<()> {
    let mut stream = tokio::time::timeout(Duration::from_millis(40), UnixStream::connect(SOCKET)).await??;
    let bytes = serde_json::to_vec(&row)?;
    let len = (bytes.len() as u32).to_be_bytes();
    stream.write_all(&len).await?;
    stream.write_all(&bytes).await?;
    stream.flush().await?;
    // Don't wait for ack — fire-and-forget per §1 #6
    Ok(())
}

Installer

#!/usr/bin/env bash
# services/memory-claude-hook/install/install-hooks.sh
# Usage: install-hooks.sh <project-path>  OR  install-hooks.sh --user

set -euo pipefail

if [ "${1:-}" = "--user" ]; then
  SETTINGS="$HOME/.claude/settings.json"
else
  PROJECT="${1:?usage: install-hooks.sh <project-path> | --user}"
  SETTINGS="$PROJECT/.claude/settings.json"
fi
mkdir -p "$(dirname "$SETTINGS")"

TEMPLATE="$(dirname "$0")/.claude/settings.json.template"

# Idempotent merge: if hooks already present with matching command, no-op
if [ -f "$SETTINGS" ] && jq -e '.hooks.UserPromptSubmit[0].hooks[0].command == "cyberos-memory-claude-hook userpromptsubmit"' "$SETTINGS" > /dev/null 2>&1; then
  echo "✓ already installed (no changes): $SETTINGS"
  exit 0
fi

if [ -f "$SETTINGS" ]; then
  # Merge: prepend our hooks; preserve user's existing other hooks
  jq -s '.[0] * .[1]' "$SETTINGS" "$TEMPLATE" > "$SETTINGS.new" && mv "$SETTINGS.new" "$SETTINGS"
else
  cp "$TEMPLATE" "$SETTINGS"
fi
echo "✓ installed hooks → $SETTINGS"

§4 — Acceptance criteria

  1. UserPromptSubmit → memory.claude_prompt — fixture JSON on stdin → 1 row emitted to daemon socket; payload contains session_id, prompt_hash, prompt_redacted, cwd, trace_id, captured_at_ns.
  2. PostToolUse → memory.claude_tool_use — fixture JSON → 1 row; tool_args_hash present; raw tool_args NOT present.
  3. Stop → memory.claude_session_completed — fixture JSON → 1 row; prompt_count + tool_use_count + last_assistant_message_redacted present.
  4. Redaction: AWS access key — prompt containing AKIA1234567890123456 → redacted to <AWS_KEY>; redaction_match_count >= 1.
  5. Redaction: Bearer token — prompt containing Authorization: Bearer abc123def456...Bearer <REDACTED>.
  6. Redaction: OpenAI keysk-abcdefghij1234567890abcdefghij12<OPENAI_KEY>.
  7. Redaction: Anthropic keysk-ant-api03-abc...<ANTHROPIC_KEY>.
  8. Redaction: GitHub PATghp_abc123...<GH_PAT>.
  9. Redaction: JWT — three-segment base64 → <JWT>.
  10. Redaction: VN CCCD — 12-digit number → <VN_CCCD>.
  11. Redaction: emailuser@example.com<EMAIL> (unless @cyberskill.world allowlisted per TASK-MEMORY-111).
  12. Redaction fail-closed — inject regex panic via test seam → memory.claude_capture_redaction_failed row emitted; hook exits 0.
  13. Same session → same trace_id — three hook invocations with same session_id → all three rows carry identical trace_id.
  14. Different sessions → different trace_ids — two sessions → two different trace_ids (32-char hex, no collision).
  15. Latency p95 ≤ 50ms — 100-trial test harness with 4KB prompt; p95 latency < 50ms.
  16. Socket unavailable → graceful — daemon down; hook exits 0 with stderr WARN socket_unavailable; metric memory_claude_hook_total{outcome="socket_unavailable"} increments.
  17. Invalid JSON on stdin → graceful — malformed JSON → exit 0 with stderr WARN; metric outcome="invalid_payload".
  18. Missing required field → graceful — JSON missing session_id → exit 0 with stderr WARN; no row emitted.
  19. Installer idempotent — run install-hooks.sh twice → second run is a no-op; ~/.claude/settings.json byte-identical.
  20. Installer preserves user hooks — pre-existing Stop hook in user settings → after install, both user's hook AND ours are present; user's hook runs first.
  21. Uninstall removes our hookscyberos memory hook claude uninstall <path> → settings.json contains only the user's pre-existing hooks; ours removed.
  22. Status shows last capturecyberos memory hook claude status <path> → prints "installed" + the timestamp + session_id of the last memory.claude_* row.
  23. OTel span per invocation — exporter receives a memory.claude.hook span per invocation with hook_kind + session_id attributes.
  24. Metric: redaction counters — running redactor 5× on a prompt with all 10 patterns → memory_claude_hook_redactions_total{pattern="aws_access_key"} etc. increment.

§5 — Verification

// services/memory/tests/ingest_test.rs

#[tokio::test]
async fn userpromptsubmit_emits_claude_prompt_row() {
    let (daemon_socket, mut rx) = stub_daemon().await;
    let payload = serde_json::json!({
        "session_id": "00000000-0000-0000-0000-000000000001",
        "prompt":     "Fix the auth bug",
        "cwd":        "/Users/x/proj"
    });
    invoke_hook(HookKind::UserPromptSubmit, payload, &daemon_socket).await;
    let row = rx.recv().await.unwrap();
    assert_eq!(row["kind"], "memory.claude_prompt");
    assert_eq!(row["payload"]["prompt_redacted"], "Fix the auth bug");
    assert!(row["payload"]["trace_id"].as_str().unwrap().len() == 32);
}

#[tokio::test]
async fn redacts_aws_key_in_prompt() {
    let (daemon_socket, mut rx) = stub_daemon().await;
    let payload = serde_json::json!({
        "session_id": "00000000-0000-0000-0000-000000000002",
        "prompt":     "use this key AKIA1234567890123456",
        "cwd":        "/Users/x/proj"
    });
    invoke_hook(HookKind::UserPromptSubmit, payload, &daemon_socket).await;
    let row = rx.recv().await.unwrap();
    assert!(row["payload"]["prompt_redacted"].as_str().unwrap().contains("<AWS_KEY>"));
    assert!(!row["payload"]["prompt_redacted"].as_str().unwrap().contains("AKIA1234567890123456"));
    assert!(row["payload"]["redaction_match_count"].as_u64().unwrap() >= 1);
}

#[tokio::test]
async fn same_session_gets_same_trace_id() {
    let (daemon_socket, mut rx) = stub_daemon().await;
    let session = uuid::Uuid::new_v4();
    for kind in [HookKind::UserPromptSubmit, HookKind::PostToolUse, HookKind::Stop] {
        invoke_hook(kind, fixture_for(kind, session), &daemon_socket).await;
    }
    let rows: Vec<_> = (0..3).map(|_| rx.try_recv().unwrap()).collect();
    let trace_ids: std::collections::HashSet<_> = rows.iter().map(|r| r["payload"]["trace_id"].clone()).collect();
    assert_eq!(trace_ids.len(), 1);
}

#[tokio::test]
async fn latency_p95_under_50ms() {
    let (daemon_socket, _rx) = stub_daemon().await;
    let mut latencies = Vec::new();
    for _ in 0..100 {
        let start = std::time::Instant::now();
        invoke_hook(HookKind::UserPromptSubmit, fixture_prompt_4kb(), &daemon_socket).await;
        latencies.push(start.elapsed());
    }
    latencies.sort();
    let p95 = latencies[95];
    assert!(p95 < std::time::Duration::from_millis(50), "p95 = {:?}", p95);
}

#[tokio::test]
async fn socket_unavailable_exits_0() {
    let payload = fixture_prompt(&uuid::Uuid::new_v4());
    let res = run_hook_subprocess(HookKind::UserPromptSubmit, payload, "/tmp/no-such-socket").await;
    assert_eq!(res.exit_code, 0);
    assert!(res.stderr.contains("socket_unavailable") || res.stderr.contains("WARN"));
}
// services/memory-claude-hook/tests/redact_test.rs
#[test] fn redacts_aws_access_key()    { let (out, n) = redact("AKIA1234567890ABCDEF"); assert_eq!(out, "<AWS_KEY>"); assert_eq!(n, 1); }
#[test] fn redacts_openai_key()        { let (out, _) = redact("sk-abcdefghij1234567890abcdefghij12"); assert!(out.contains("<OPENAI_KEY>")); }
#[test] fn redacts_anthropic_key()     { let (out, _) = redact("sk-ant-api03-foobar1234567890abcdefghij"); assert!(out.contains("<ANTHROPIC_KEY>")); }
#[test] fn redacts_jwt()               { let (out, _) = redact("eyJhbGciOiJIUzI1.eyJzdWIiOiIxMjM.SflKxw"); assert!(out.contains("<JWT>")); }
#[test] fn redacts_github_pat()        { let (out, _) = redact("ghp_abcdefghij1234567890abcdefghij"); assert!(out.contains("<GH_PAT>")); }
#[test] fn redacts_bearer()            { let (out, _) = redact("Authorization: Bearer abcdefghij1234"); assert!(out.contains("Bearer <REDACTED>")); }
#[test] fn redacts_vn_cccd()           { let (out, _) = redact("CCCD 079123456789"); assert!(out.contains("<VN_CCCD>")); }
#[test] fn no_false_positive_on_short_numeric() { let (out, n) = redact("issue #12345"); assert_eq!(out, "issue #12345"); assert_eq!(n, 0); }

§6 — Implementation skeleton

(Above §3 is the skeleton.)

// Lib root
pub mod hook;
pub mod redact;
pub mod emit;

pub async fn dispatch(kind: hook::HookKind, payload: serde_json::Value) -> anyhow::Result<()> {
    hook::dispatch(kind, payload).await
}

§7 — Dependencies


§8 — Example payloads

memory.claude_prompt

{
  "kind": "memory.claude_prompt",
  "payload": {
    "session_id":             "0e3b1a2c-4f5d-6789-abcd-ef0123456789",
    "prompt_hash":            "9b0e8c5...",
    "prompt_redacted":        "Help me refactor auth using key <AWS_KEY>",
    "cwd":                    "/Users/stephencheng/Projects/CyberSkill/cyberos",
    "trace_id":               "0af7651916cd43dd8448eb211c80319c",
    "captured_at_ns":         1747407137483000000,
    "redaction_match_count":  1
  }
}

memory.claude_tool_use

{
  "kind": "memory.claude_tool_use",
  "payload": {
    "session_id":     "0e3b1a2c-4f5d-6789-abcd-ef0123456789",
    "tool_name":      "Edit",
    "tool_args_hash": "ab12cd...",
    "outcome":        "success",
    "duration_ms":    142,
    "trace_id":       "0af7651916cd43dd8448eb211c80319c"
  }
}

memory.claude_session_completed

{
  "kind": "memory.claude_session_completed",
  "payload": {
    "session_id":                    "0e3b1a2c-4f5d-6789-abcd-ef0123456789",
    "prompt_count":                  3,
    "tool_use_count":                12,
    "duration_ms":                   847000,
    "last_assistant_message_redacted": "Done. Auth middleware refactored; tests pass.",
    "trace_id":                      "0af7651916cd43dd8448eb211c80319c"
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Hook process spawned but daemon downUnixStream::connect ECONNREFUSEDHook exits 0; stderr WARN socket_unavailable; metric incrementsTASK-MEMORY-110 restarts daemon; next hook succeeds
Daemon socket exists but daemon hungConnect succeeds; write times out at 40msHook exits 0; row lost (single event); metric outcome="emit_timeout"TASK-MEMORY-110 SIGTERM + restart daemon
Malformed JSON on stdinserde_json::from_slice ErrExit 0; stderr WARN; metric outcome="invalid_payload"Operator updates Claude Code OR raises issue
Required field missingserde_json::from_value ErrExit 0; stderr WARNSame as above
Redactor regex panicstd::panic::catch_unwind catchesmemory.claude_capture_redaction_failed row emitted; exit 0Operator updates redactor
/tmp fulltrace_id cache write failsHook still proceeds with generated trace_id (in-mem); next hook for same session may regenerate (correlation broken)Operator frees /tmp
Same session_id reused across daystrace_id cache 1h TTL expiredNew trace_id generated; rows from same logical session split across trace_idsBy design; operators correlate via session_id
Very large prompt (> 1 MB)Read succeeds; redact takes longerLatency may exceed 50ms; row emitted; metric latency_seconds p99 alarmOperator splits; or accept; or raises budget
Claude Code passes extra unknown fieldsserde unknown-field handlingLogged at WARN; field ignored; row still emittedNone
Hook installed but user disabled hooks in Claude CodeHook never invokedNo rows; no errorOperator re-enables OR uninstalls cyberos hook
install-hooks.sh run on missing /.claude dirmkdir -p creates it; settings.json writtenSuccessNone
install-hooks.sh: settings.json malformedjq Err during mergeExit 1; stderr message; user's file unchangedOperator manually fixes
Two hooks for same event from two tools (ours + theirs)Both run in orderBoth succeed independentlyBy design; AC #20
Hook binary missing (uninstalled cyberos)Claude Code logs hook spawn errorNo rows; Claude Code session continues normallyOperator reinstalls
Tracing exporter unavailabletracing-otel buffersHook continues; spans buffered; eventually droppedOperator restarts TASK-OBS-001 collector
Session_id is not a valid UUIDParse errorExit 0; stderr WARN; metric invalid_payloadOperator files Claude Code bug
Redaction yields empty stringAcceptableRow emitted with empty prompt_redacted; redaction_match_count highBy design — operator sees N matches but text is fully redacted

§11 — Implementation notes


End of TASK-MEMORY-109.

As built (2026-07-02)

Shipped as modules/memory/cyberos/claude_code_hook.py (no separate services/memory-claude-hook crate).