fabro/run.json
Fabro 1a755902b7 finalize run
⚒️ Generated with [Fabro](https://fabro.sh)
2026-07-07 17:46:15 +00:00

839 lines
No EOL
176 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"title": "Run the simplify code-review pass on PR #547: review the changes for reuse, quality, and efficien...",
"spec": {
"run_id": "01KWYQ40STEB2KNREK7EVNJEEK",
"settings": {
"project": {
"name": null,
"description": null,
"metadata": {}
},
"workflow": {
"name": null,
"description": null,
"graph": "workflow.fabro",
"metadata": {}
},
"run": {
"goal": {
"type": "inline",
"value": "Run the simplify code-review pass on PR #547: review the changes for reuse, quality, and efficiency, apply the fixes, and update the existing PR in place."
},
"working_dir": null,
"metadata": {},
"inputs": {
"pr": 547
},
"model": {
"provider": "anthropic",
"name": "claude-fable-5",
"fallbacks": [],
"controls": {
"reasoning_effort": null,
"speed": null
}
},
"git": {
"author": null
},
"prepare": {
"commands": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"
},
"checkpoint": {
"exclude_globs": [],
"skip_git_hooks": false
},
"clone": {
"enabled": true
},
"run_branch": {
"enabled": true,
"push": true
},
"meta_branch": {
"enabled": true,
"push": true
},
"environment": {
"id": "fabro-dev",
"provider": "daytona",
"image": {
"docker": null,
"dockerfile": {
"type": "inline",
"value": "FROM ubuntu:24.04\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n curl git ripgrep ca-certificates build-essential pkg-config libssl-dev unzip python3 \\\n xvfb xfce4 xfce4-terminal x11vnc novnc dbus-x11 \\\n libx11-6 libxrandr2 libxext6 libxrender1 libxfixes3 libxss1 libxtst6 libxi6 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Install real Chromium (not the snap stub) via xtradeb PPA\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n software-properties-common curl gnupg \\\n && add-apt-repository -y ppa:xtradeb/apps \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends chromium \\\n && rm -rf /var/lib/apt/lists/*\n\n# Wrapper: Chromium needs --no-sandbox when running as root in a container,\n# and --disable-dev-shm-usage avoids crashes from small /dev/shm\nRUN printf '#!/bin/bash\\nexec /usr/bin/chromium --no-sandbox --disable-dev-shm-usage \"$@\"\\n' \\\n > /usr/local/bin/chromium-wrapper \\\n && chmod +x /usr/local/bin/chromium-wrapper\n\n# Make the wrapper the default in the system .desktop file and via alternatives\nRUN sed -i 's|^Exec=.*|Exec=/usr/local/bin/chromium-wrapper %U|' \\\n /usr/share/applications/chromium.desktop \\\n && update-alternatives --install /usr/bin/x-www-browser x-www-browser \\\n /usr/local/bin/chromium-wrapper 100\n\n# Tell XFCE's exo-open that Chromium is the WebBrowser helper (system-wide)\nRUN mkdir -p /etc/xdg/xfce4 /usr/share/xfce4/helpers \\\n && printf 'WebBrowser=custom-WebBrowser\\n' > /etc/xdg/xfce4/helpers.rc \\\n && printf '[Desktop Entry]\\n\\\nVersion=1.0\\n\\\nType=X-XFCE-Helper\\n\\\nName=Chromium\\n\\\nIcon=chromium\\n\\\nX-XFCE-Category=WebBrowser\\n\\\nX-XFCE-CommandsWithParameter=/usr/local/bin/chromium-wrapper \"%%s\"\\n\\\nX-XFCE-Commands=/usr/local/bin/chromium-wrapper\\n' \\\n > /usr/share/xfce4/helpers/custom-WebBrowser.desktop\n\n# GitHub CLI\nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \\\n | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \\\n && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" \\\n | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \\\n && apt-get update && apt-get install -y --no-install-recommends gh \\\n && rm -rf /var/lib/apt/lists/*\n\n# Rust\nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\nENV PATH=\"/root/.cargo/bin:${PATH}\"\nRUN rustup toolchain install nightly-2026-04-14 --profile minimal --component clippy,rustfmt\nRUN cargo install cargo-nextest --locked\nENV CARGO_INCREMENTAL=0\n\n# Bun\nRUN curl -fsSL https://bun.sh/install | bash\nENV PATH=\"/root/.bun/bin:${PATH}\"\n\nWORKDIR /root\n"
}
},
"resources": {
"cpu": 8,
"memory": "16GB",
"disk": "20GB"
},
"network": {
"mode": "allow_all",
"allow": []
},
"lifecycle": {
"preserve": false,
"stop_on_terminal": true,
"auto_stop": "30m"
},
"labels": {
"repo": "fabro-sh/fabro"
},
"env": {}
},
"notifications": {},
"interviews": {
"provider": null,
"slack": null
},
"agent": {
"fabro_tools": false,
"permissions": null,
"mcps": {}
},
"hooks": [],
"scm": {
"provider": null,
"owner": null,
"repository": null,
"github": null
},
"pull_request": null,
"artifacts": {
"include": []
},
"integrations": {
"github": {
"permissions": {
"pull_requests": "write",
"issues": "write",
"contents": "write"
}
}
}
}
},
"graph": {
"name": "PrSimplify",
"nodes": {
"exit": {
"id": "exit",
"attrs": {
"shape": {
"String": "Msquare"
},
"label": {
"String": "Exit"
}
}
},
"start": {
"id": "start",
"attrs": {
"label": {
"String": "Start"
},
"shape": {
"String": "Mdiamond"
}
}
},
"simplify": {
"id": "simplify",
"attrs": {
"label": {
"String": "Simplify Pass"
},
"prompt": {
"String": "# Simplify: Code Review and Cleanup\n\nRun the simplify code-review pass on **PR #547**: review the changes for reuse, quality, and efficiency, fix what's worth fixing, and update the existing PR in place.\n\n## Phase 1: Identify the changes\n\nRun `gh pr diff 547` to see what changed. (Fall back to `git diff origin/main...HEAD` if that returns nothing.) This diff is the shared context for the reviews below.\n\n## Phase 2: Launch three review sub-agents in parallel\n\nUse the `spawn_agent` tool to launch all three reviewers below. Spawn all three first so they run concurrently, then `wait` for their results and aggregate them. Give each sub-agent its full brief, and tell it to run `gh pr diff 547` itself to see the changes. Each reviewer **only reports findings — it does not edit code.**\n\n### Reviewer 1 — Code Reuse\n\nFor each change:\n\n1. Search for existing utilities and helpers that could replace newly written code. Use grep to find similar patterns elsewhere — utility directories, shared modules, and files adjacent to the changed ones.\n2. Flag any new function that duplicates existing functionality; name the existing function to use instead.\n3. Flag inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar.\n\nThis is a greenfield app — focus on maximizing simplicity; don't worry about backward compatibility.\n\n### Reviewer 2 — Code Quality\n\nReview the same changes for hacky patterns:\n\n1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls.\n2. Parameter sprawl: new parameters bolted onto a function instead of generalizing or restructuring existing ones.\n3. Copy-paste with slight variation: near-duplicate blocks that should be unified with a shared abstraction.\n4. Leaky abstractions: exposing internals that should be encapsulated, or breaking existing boundaries.\n5. Stringly-typed code: raw strings where constants, enums, or branded types already exist.\n\nThis is a greenfield app — be aggressive in optimizing quality.\n\n### Reviewer 3 — Efficiency\n\nReview the same changes for efficiency:\n\n1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns.\n2. Missed concurrency: independent operations run sequentially when they could run in parallel.\n3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths.\n4. Unnecessary existence checks: pre-checking a file/resource before operating (TOCTOU) — operate directly and handle the error.\n5. Memory: unbounded data structures, missing cleanup, listener leaks.\n6. Overly broad operations: reading whole files when a portion suffices, loading all items when filtering for one.\n\n## Phase 3: Apply fixes\n\nWait for all three reviewers, aggregate their findings, and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — don't argue with it, just skip it.\n\n## Phase 4: Update the existing PR\n\n1. **Check out the PR branch:** run `gh pr checkout 547` so your edits land on the PR's own branch. Do not create a new branch or a new PR.\n2. **Commit and push:** commit with a clear message describing the simplify pass, then push to update PR #547. Push to the **existing** branch — do **not** open a new pull request. If you made no changes (the code was already clean), skip the commit and say so in the comment below.\n3. **Comment:** post a summary on PR #547 noting this was an automated simplify run, what was fixed, and anything you skipped and why:\n\n gh pr comment 547 --body \"<your summary>\"\n\n4. **Label:** record which model ran this pass. Read the model you are running as from your environment block (the `Model:` line) and build the label `simplify:<model-id>` — e.g. if your model is `claude-opus-4-8`, the label is `simplify:claude-opus-4-8`. Apply it via the GitHub **issues** API; do **not** use `gh pr edit --add-label` (it walks a deprecated Projects-classic GraphQL path and fails here). Ensure the label exists, then add it (gh fills `{owner}/{repo}` from the current repo):\n\n gh label create \"simplify:<model-id>\" --color BFD4F2 --description \"Simplify pass by <model-id>\" 2>/dev/null || true\n gh api repos/{owner}/{repo}/issues/547/labels -X POST -f \"labels[]=simplify:<model-id>\"\n\n## Done\n\nBriefly summarize what was fixed (or confirm the code was already clean), and confirm the PR was updated, commented, and labeled."
}
}
}
},
"edges": [
{
"from": "start",
"to": "simplify",
"attrs": {}
},
{
"from": "simplify",
"to": "exit",
"attrs": {}
}
],
"attrs": {
"rankdir": {
"String": "LR"
},
"default_model": {
"String": "claude-fable-5"
},
"goal": {
"String": "Run the simplify code-review pass on PR #547: review the changes for reuse, quality, and efficiency, apply the fixes, and update the existing PR in place."
}
}
},
"graph_source": "digraph PrSimplify {\n graph [\n goal=\"Run the simplify code-review pass on PR #{{ inputs.pr }}: review the changes for reuse, quality, and efficiency, apply the fixes, and update the existing PR in place.\",\n // Default the whole pass (orchestrator + review subagents) to Fable.\n // `default_model` is beaten by a run-config model / `--model`, so it is\n // a floor, not a pin — override per run with `--model <id>`.\n default_model=\"claude-fable-5\",\n rankdir=LR\n ]\n\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n\n // One agent node runs the whole simplify pass. It uses the spawn_agent tool\n // to fan out the three reviews in parallel and wait for their results —\n // sub-agent results return directly to the orchestrator, which is the clean\n // way to AGGREGATE several perspectives. (A fork + tripleoctagon fan-in is\n // the wrong tool here: fan-in selects one \"best\" branch and merges only its\n // worktree; it does not merge several distinct review outputs.)\n simplify [label=\"Simplify Pass\", prompt=\"@prompts/simplify.md\"]\n\n start -> simplify -> exit\n}\n",
"workflow_slug": "pr-simplify",
"source_directory": "/Users/swerner/Development/os/fabro-main/fabro",
"provenance": {
"server": {
"version": "0.278.0-nightly.0"
},
"client": {
"user_agent": "fabro-cli/0.267.0-nightly.0",
"name": "fabro-cli",
"version": "0.267.0-nightly.0"
},
"subject": {
"kind": "user",
"identity": {
"issuer": "https://github.com",
"subject": "138379"
},
"login": "swerner",
"auth_method": "github",
"avatar_url": "https://avatars.githubusercontent.com/u/138379?v=4"
}
},
"manifest_blob": "696bc65e4e9b74f739803e91f065cfa40666bd43c30f1301da182ff516fe6c05",
"definition_blob": "c905f6412800e5aa489dbc9bab1dbe2fa36a89ee57e6a410d3ab03b1bf60a2e5",
"git": {
"origin_url": "https://github.com/fabro-sh/fabro",
"branch": "main",
"sha": "8c7d5dc7d0375fb8e7d1d3f47e7e6639979d8517",
"dirty": "dirty",
"push_outcome": {
"type": "not_attempted"
}
}
},
"web_url": "https://fabro-testing.walleye-rainbow.ts.net/runs/01KWYQ40STEB2KNREK7EVNJEEK",
"start": {
"start_time": "2026-07-07T16:39:11.209454471Z",
"run_branch": "fabro/run/01KWYQ40STEB2KNREK7EVNJEEK",
"base_sha": "8c7d5dc7d0375fb8e7d1d3f47e7e6639979d8517"
},
"status": {
"kind": "running"
},
"status_updated_at": "2026-07-07T16:39:11.209488148Z",
"last_event_at": "2026-07-07T17:46:15.493209059Z",
"pending_control": null,
"checkpoints": [
{
"seq": 21,
"checkpoint": {
"timestamp": "2026-07-07T16:39:13.209743381Z",
"current_node": "start",
"completed_nodes": [
"start"
],
"node_retries": {},
"context_values": {
"failure_class": "",
"graph.rankdir": "LR",
"current_node": "start",
"internal.fidelity": "compact",
"graph.default_model": "claude-fable-5",
"graph.goal": "Run the simplify code-review pass on PR #547: review the changes for reuse, quality, and efficiency, apply the fixes, and update the existing PR in place.",
"internal.node_visit_count": 1,
"internal.run_id": "01KWYQ40STEB2KNREK7EVNJEEK",
"internal.work_dir": "/home/daytona/workspace/fabro",
"internal.retry_count.start": 0,
"outcome": "succeeded",
"internal.thread_id": null,
"failure_signature": ""
},
"node_outcomes": {
"start": {
"status": "succeeded",
"usage": null
}
},
"next_node_id": "simplify",
"node_visits": {
"start": 1
}
},
"diff": {}
},
{
"seq": 837,
"checkpoint": {
"timestamp": "2026-07-07T17:46:15.492860013Z",
"current_node": "simplify",
"completed_nodes": [
"start",
"simplify"
],
"node_retries": {},
"context_values": {
"graph.goal": "Run the simplify code-review pass on PR #547: review the changes for reuse, quality, and efficiency, apply the fixes, and update the existing PR in place.",
"failure_signature": "",
"outcome": "succeeded",
"last_response": "Done. Simplify pass on PR #547 is complete — reviewed by three parallel sub-agents, fixes applied, and the existing PR updated in place (no new branch/PR).\n\n## What was fixed\n\n**High-impact (flagged",
"last_stage": "simplify",
"internal.node_visit_count": 1,
"internal.run_id": "01KWYQ40STEB2KNREK7EVNJEEK",
"internal.retry_count.simplify": 0,
"graph.rankdir": "LR",
"failure_class": "",
"graph.default_model": "claude-fable-5",
"internal.thread_id": "start",
"internal.retry_count.start": 0,
"internal.work_dir": "/home/daytona/workspace/fabro",
"current_node": "simplify",
"response.simplify": "Done. Simplify pass on PR #547 is complete — reviewed by three parallel sub-agents, fixes applied, and the existing PR updated in place (no new branch/PR).\n\n## What was fixed\n\n**High-impact (flagged by all three reviewers):**\n- **Redact once at the sink boundary** — `RunEventSink` now redacts at the `RedactSecrets` node (skipped entirely when no secrets are registered) instead of threading `Option<SecretRedactor>` into every leaf. This removes a full serialize→content-scan→reparse pass that ran **per event per fanout leaf** on the streaming hot path, even for runs with no secrets, and deletes the fail-open `append_run_event_with_redactor` trait method whose default silently dropped the redactor.\n- **Collapsed ~13 `foo`/`foo_with_redactor`/`foo_inner` twin functions** across fabro-sandbox, fabro-workflow, and fabro-cli into single signatures taking `&SecretRedactor` (an empty redactor is an identity).\n\n**Smaller cleanups:** dropped the redundant `run_id` param from `redacted_run_event`; folded `HookRunner::new_with_secrets` into `new`; replaced the hand-written `decision_label` map with `strum::IntoStaticStr` per repo policy; replaced two copy-pasted 5-boolean reqwest log blocks with `e.without_url()`; added `HookType::interp_strings()` as the single source of truth for interpolatable hook fields; avoided a per-header `Vec` allocation in `first_secret_name`.\n\n**Also:** merged `origin/main` into the branch (resolving the `start.rs` overlap with #545), so the PR diff now shows only its real delta. Net: **107 lines** on top of that merge.\n\n**Skipped** (noted in the PR comment): the event-property allowlist colocation, the env+secrets resolver-context bundle, the `RunSecrets` ownership bundle, the fabro-redact snapshot micro-opt (main-side code), `BoxFuture` (new dep for a type alias), and per-definition secret-name precompute.\n\n**Verified:** workspace build, nextest on all touched crates (1200+ workflow tests, 1300+ cli/acp/types tests — all pass; one unrelated flaky socket test passes standalone), nightly fmt and clippy `-D warnings` clean.\n\n**PR updated:** pushed `c5cf4b7f9` (merge) + `6d6a1d56a` (simplify) to the existing branch, [commented](https://github.com/fabro-sh/fabro/pull/547#issuecomment-4906844207), and labeled `simplify:claude-fable-5`.",
"thread.start.current_node": "simplify",
"internal.fidelity": "compact"
},
"node_outcomes": {
"simplify": {
"status": "succeeded",
"context_updates": {
"last_stage": "simplify",
"last_response": "Done. Simplify pass on PR #547 is complete — reviewed by three parallel sub-agents, fixes applied, and the existing PR updated in place (no new branch/PR).\n\n## What was fixed\n\n**High-impact (flagged",
"response.simplify": "Done. Simplify pass on PR #547 is complete — reviewed by three parallel sub-agents, fixes applied, and the existing PR updated in place (no new branch/PR).\n\n## What was fixed\n\n**High-impact (flagged by all three reviewers):**\n- **Redact once at the sink boundary** — `RunEventSink` now redacts at the `RedactSecrets` node (skipped entirely when no secrets are registered) instead of threading `Option<SecretRedactor>` into every leaf. This removes a full serialize→content-scan→reparse pass that ran **per event per fanout leaf** on the streaming hot path, even for runs with no secrets, and deletes the fail-open `append_run_event_with_redactor` trait method whose default silently dropped the redactor.\n- **Collapsed ~13 `foo`/`foo_with_redactor`/`foo_inner` twin functions** across fabro-sandbox, fabro-workflow, and fabro-cli into single signatures taking `&SecretRedactor` (an empty redactor is an identity).\n\n**Smaller cleanups:** dropped the redundant `run_id` param from `redacted_run_event`; folded `HookRunner::new_with_secrets` into `new`; replaced the hand-written `decision_label` map with `strum::IntoStaticStr` per repo policy; replaced two copy-pasted 5-boolean reqwest log blocks with `e.without_url()`; added `HookType::interp_strings()` as the single source of truth for interpolatable hook fields; avoided a per-header `Vec` allocation in `first_secret_name`.\n\n**Also:** merged `origin/main` into the branch (resolving the `start.rs` overlap with #545), so the PR diff now shows only its real delta. Net: **107 lines** on top of that merge.\n\n**Skipped** (noted in the PR comment): the event-property allowlist colocation, the env+secrets resolver-context bundle, the `RunSecrets` ownership bundle, the fabro-redact snapshot micro-opt (main-side code), `BoxFuture` (new dep for a type alias), and per-definition secret-name precompute.\n\n**Verified:** workspace build, nextest on all touched crates (1200+ workflow tests, 1300+ cli/acp/types tests — all pass; one unrelated flaky socket test passes standalone), nightly fmt and clippy `-D warnings` clean.\n\n**PR updated:** pushed `c5cf4b7f9` (merge) + `6d6a1d56a` (simplify) to the existing branch, [commented](https://github.com/fabro-sh/fabro/pull/547#issuecomment-4906844207), and labeled `simplify:claude-fable-5`."
},
"notes": "Stage completed: simplify",
"usage": {
"input": {
"usage": {
"model": {
"provider": "anthropic",
"model_id": "claude-fable-5"
},
"tokens": {
"input_tokens": 191037,
"output_tokens": 70152,
"reasoning_tokens": 0,
"cache_read_tokens": 19621920,
"cache_write_tokens": 2092068
}
},
"facts": {
"algorithm": "anthropic",
"cache_write_5m_tokens": 2092068,
"cache_write_1h_tokens": 0
}
},
"total_usd_micros": 51190740
},
"files_touched": [
"/home/daytona/workspace/fabro/lib/crates/fabro-acp/src/error.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-acp/src/transport.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/runner.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/Cargo.toml",
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/executor.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/runner.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/secrets.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/types.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/tests/host_command_hooks.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-sandbox/src/daytona/mod.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-sandbox/src/error.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-sandbox/src/lib.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-sandbox/src/sandbox.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-types/src/settings/run.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/redaction.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/sink.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/operations/start.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/pipeline/initialize.rs",
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/runtime_store.rs"
],
"timing": {
"wall_time_ms": 0,
"inference_time_ms": 1421827,
"tool_time_ms": 2595211,
"active_time_ms": 4017038
}
},
"start": {
"status": "succeeded",
"usage": null
}
},
"next_node_id": "exit",
"git_commit_sha": "baa013d649993c74840f47b229e623cf25d15a0a",
"node_visits": {
"simplify": 1,
"start": 1
}
},
"diff": {
"patch": "diff --git a/Cargo.lock b/Cargo.lock\nindex 30f0bd0e2..53ce08b42 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -2673,6 +2673,7 @@ dependencies = [\n \"regex\",\n \"serde\",\n \"serde_json\",\n+ \"strum 0.28.0\",\n \"tokio\",\n \"tokio-util\",\n \"toml 0.8.23\",\ndiff --git a/docs/public/agents/hooks.mdx b/docs/public/agents/hooks.mdx\nindex aafdd356f..77874ce27 100644\n--- a/docs/public/agents/hooks.mdx\n+++ b/docs/public/agents/hooks.mdx\n@@ -36,8 +36,8 @@ Authorization = \"Bearer {{ env.API_KEY }}\"\n \n | Field | Description |\n |---|---|\n-| `url` | The endpoint to POST to. Must use `https://` unless `tls = \"off\"`. Supports `{{ env.NAME }}` interpolation. |\n-| `headers` | Optional HTTP headers. Values support `{{ env.NAME }}` interpolation, scoped to the names in `allowed_env_vars`. A token for any other env var fails to resolve and the hook blocks (fail-closed). |\n+| `url` | The endpoint to POST to. Must use `https://` unless `tls = \"off\"`. Supports `{{ env.NAME }}` and `{{ secrets.NAME }}` interpolation. |\n+| `headers` | Optional HTTP headers. Values support `{{ env.NAME }}` interpolation, scoped to the names in `allowed_env_vars`. A token for any other env var fails to resolve and the hook blocks (fail-closed). `{{ secrets.NAME }}` is not allowed in headers. |\n | `allowed_env_vars` | Allowlist of environment variable names a header may read via `{{ env.NAME }}`. Empty (the default) means no env vars may be interpolated into headers. |\n | `tls` | TLS mode: `\"verify\"` (default), `\"no_verify\"`, or `\"off\"`. |\n \n@@ -134,6 +134,33 @@ sandbox = false\n | `timeout_ms` | Hook timeout in milliseconds. Default: `60000` (60s) for most types, `30000` (30s) for prompt hooks. |\n | `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). |\n \n+## Interpolation and secrets\n+\n+Hook `command`, HTTP `url`, prompt `prompt`, and agent `prompt` fields can reference server-vault token secrets with `{{ secrets.NAME }}`. Add these values with `fabro secret set NAME ...`. Missing secrets, empty/non-token vault entries, and unsupported namespaces fail closed so the hook does not run with a partially resolved value.\n+\n+```toml\n+[[hooks]]\n+event = \"sandbox_ready\"\n+command = \"test \\\"{{ secrets.DEPLOY_ENV }}\\\" = staging\"\n+blocking = true\n+\n+[[hooks]]\n+event = \"run_failed\"\n+type = \"http\"\n+url = \"https://hooks.example.com/{{ secrets.WEBHOOK_PATH }}\"\n+tls = \"verify\"\n+\n+[[hooks]]\n+event = \"stage_start\"\n+type = \"prompt\"\n+prompt = \"Block deployments to {{ secrets.RESTRICTED_ENV }}?\"\n+blocking = true\n+```\n+\n+HTTP hook headers intentionally support only allowlisted `{{ env.NAME }}` interpolation. A `{{ secrets.NAME }}` token in a header blocks the hook with guidance to use secret interpolation in a hook command, prompt, or URL instead; Fabro does not provide a separate outbound-header secret allowlist.\n+\n+Resolved hook secrets are registered with the run's secret redactor. Fabro redacts those exact values from worker-side structured run surfaces such as events, `progress.jsonl`, and setup-error messages. Command output still crosses the sandbox boundary as process output, so treat sandbox-reemitted plaintext as best-effort: content-based redaction always runs, and worker-side event surfaces apply exact-match redaction after the output is captured.\n+\n ## Blocking vs. non-blocking\n \n Blocking hooks can affect workflow execution. Non-blocking hooks run for side effects only — their decisions are ignored.\ndiff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx\nindex 9c3cd8487..5e6c8b97a 100644\n--- a/docs/public/execution/run-configuration.mdx\n+++ b/docs/public/execution/run-configuration.mdx\n@@ -288,12 +288,14 @@ When `provider = \"local\"`, Fabro runs directly in the resolved working\n directory. If you want local isolation, create or enter a separate clone or Git\n worktree yourself.\n \n-Environment variable values can be literal strings or host environment\n-references using `{{ env.VARNAME }}` syntax:\n+Environment variable values can be literal strings, host environment references\n+using `{{ env.VARNAME }}` syntax, or server-vault token secrets using\n+`{{ secrets.NAME }}` syntax:\n \n ```toml title=\"run.toml\"\n [environments.ci.env]\n API_KEY = \"{{ env.MY_API_KEY }}\"\n+DEPLOY_TOKEN = \"{{ secrets.DEPLOY_TOKEN }}\"\n NODE_ENV = \"production\"\n SERVICE_URL = \"https://api.{{ env.REGION }}.example.com\"\n ```\n@@ -302,9 +304,14 @@ SERVICE_URL = \"https://api.{{ env.REGION }}.example.com\"\n |---|---|\n | `\"literal\"` | Static value passed as-is |\n | `\"{{ env.VARNAME }}\"` | Whole-value reference resolved from the host environment at consumption time |\n-| `\"prefix-{{ env.X }}-suffix\"` | Substring interpolation; multiple tokens per string are supported |\n+| `\"{{ secrets.NAME }}\"` | Reference resolved from the server vault as a token secret at the run boundary |\n+| `\"prefix-{{ env.X }}-{{ secrets.Y }}-suffix\"` | Substring interpolation; multiple env and secret tokens per string are supported |\n \n-Missing host variables produce a hard error pointing at the specific field and unresolved token.\n+Missing host variables or missing/non-token secrets produce a hard error pointing at the specific field and unresolved token.\n+\n+Resolved declared secrets are registered with a per-run redactor. On worker-side structured surfaces — run events, `progress.jsonl`, the run store, SSE payloads derived from stored events, and setup-command errors — Fabro applies content-based redaction and then exact-match redaction for those declared secret values, even when the value is low entropy and does not look like a credential.\n+\n+The boundary is the worker/sandbox handoff. If you put a secret into sandbox process environment or a command line, software inside the sandbox can still print it as plain text. Fabro applies content-based redaction to command output and exact-match redaction when captured output is embedded back into worker-side structured events, but avoid intentionally echoing secrets from sandbox commands.\n \n ### `[run.integrations.github.permissions]`\n \ndiff --git a/docs/public/workflows/variables.mdx b/docs/public/workflows/variables.mdx\nindex 18b0c00b1..3344e1ab0 100644\n--- a/docs/public/workflows/variables.mdx\n+++ b/docs/public/workflows/variables.mdx\n@@ -14,7 +14,7 @@ Goal and prompt templates can reference:\n | `{{ goal }}` | The workflow goal |\n | `{{ inputs.name }}` | A value from `[run.inputs]`, optionally overridden by CLI input flags |\n \n-Environment variables are **not** available in goal or prompt templates. Use `{{ env.NAME }}` only in config strings and HTTP hook headers.\n+Environment variables and secrets are **not** available in workflow graph goal or prompt templates. Use `{{ env.NAME }}` and `{{ secrets.NAME }}` only in supported run configuration and hook fields.\n \n ## Run config inputs\n \ndiff --git a/lib/crates/fabro-acp/src/error.rs b/lib/crates/fabro-acp/src/error.rs\nindex 9e84978a2..6af43cd69 100644\n--- a/lib/crates/fabro-acp/src/error.rs\n+++ b/lib/crates/fabro-acp/src/error.rs\n@@ -57,7 +57,9 @@ impl AcpError {\n match self {\n Self::TimedOut { exec_output_tail } => exec_output_tail.clone(),\n Self::ProcessExited(exit) => exit.exec_output_tail.clone(),\n- Self::Sandbox(source) => source.default_redacted_output_tail(),\n+ Self::Sandbox(source) => {\n+ source.default_redacted_output_tail(&fabro_sandbox::SecretRedactor::default())\n+ }\n _ => None,\n }\n }\ndiff --git a/lib/crates/fabro-acp/src/transport.rs b/lib/crates/fabro-acp/src/transport.rs\nindex f599c5a27..79ec20db4 100644\n--- a/lib/crates/fabro-acp/src/transport.rs\n+++ b/lib/crates/fabro-acp/src/transport.rs\n@@ -184,7 +184,12 @@ impl ConnectTo<Client> for SandboxAcpTransport {\n }\n \n fn redacted_stderr_tail(stderr: &str) -> Option<ExecOutputTail> {\n- fabro_sandbox::redacted_output_tail(\"\", stderr, DEFAULT_EXEC_OUTPUT_TAIL_BYTES)\n+ fabro_sandbox::redacted_output_tail(\n+ \"\",\n+ stderr,\n+ DEFAULT_EXEC_OUTPUT_TAIL_BYTES,\n+ &fabro_sandbox::SecretRedactor::default(),\n+ )\n }\n \n fn process_exited_before_protocol_completed() -> ProtocolError {\ndiff --git a/lib/crates/fabro-hooks/Cargo.toml b/lib/crates/fabro-hooks/Cargo.toml\nindex 559a7499a..84bb443c7 100644\n--- a/lib/crates/fabro-hooks/Cargo.toml\n+++ b/lib/crates/fabro-hooks/Cargo.toml\n@@ -23,6 +23,7 @@ fabro-util = { path = \"../fabro-util\" }\n fabro-http.workspace = true\n serde.workspace = true\n serde_json.workspace = true\n+strum.workspace = true\n tokio.workspace = true\n async-trait.workspace = true\n regex.workspace = true\ndiff --git a/lib/crates/fabro-hooks/src/bridge.rs b/lib/crates/fabro-hooks/src/bridge.rs\nindex 6aa7e60f6..a7bfa05ed 100644\n--- a/lib/crates/fabro-hooks/src/bridge.rs\n+++ b/lib/crates/fabro-hooks/src/bridge.rs\n@@ -102,6 +102,7 @@ mod tests {\n execution_context: &HookExecutionContext,\n _llm_source: &dyn fabro_auth::CredentialSource,\n _catalog: Arc<Catalog>,\n+ _secrets: &crate::ResolvedHookSecrets,\n ) -> HookResult {\n self.captured_contexts.lock().unwrap().push(context.clone());\n self.captured_execution_contexts\ndiff --git a/lib/crates/fabro-hooks/src/executor.rs b/lib/crates/fabro-hooks/src/executor.rs\nindex 9fda710d5..c2e6d2ab0 100644\n--- a/lib/crates/fabro-hooks/src/executor.rs\n+++ b/lib/crates/fabro-hooks/src/executor.rs\n@@ -13,7 +13,7 @@ use fabro_llm::generate::{GenerateParams, generate_object};\n use fabro_llm::types::{Message, Request, ToolResult};\n use fabro_model::Catalog;\n use fabro_redact::redacted_url_for_log;\n-use fabro_types::settings::interp::Namespace;\n+use fabro_types::settings::interp::{Namespace, ResolveCtx};\n use fabro_types::settings::{InterpString, ResolveError};\n use fabro_util::env::{Env, SystemEnv};\n use tokio::process::Command as TokioCommand;\n@@ -21,6 +21,7 @@ use tokio::time::timeout as tokio_timeout;\n use tokio_util::sync::CancellationToken;\n \n use crate::config::{HookDefinition, HookType, TlsMode};\n+use crate::secrets::{ResolvedHookSecrets, first_secret_name};\n use crate::types::{\n HookContext, HookDecision, HookExecutionContext, HookResult, PromptHookResponse,\n };\n@@ -54,16 +55,18 @@ pub trait HookExecutor: Send + Sync {\n execution_context: &HookExecutionContext,\n llm_source: &dyn CredentialSource,\n catalog: Arc<Catalog>,\n+ secrets: &ResolvedHookSecrets,\n ) -> HookResult;\n }\n \n /// Resolve a typed [`InterpString`] hook segment at fire time, looking up\n-/// `{{ env.* }}` tokens against `env`.\n+/// `{{ env.* }}` tokens against `env` and `{{ secrets.* }}` tokens against the\n+/// run-scoped secret resolver.\n ///\n-/// Only the `env` namespace is wired here; `{{ secrets.* }}`, `{{ vars.* }}`,\n-/// and `{{ inputs.* }}` tokens have no lookup in this context and resolve as\n-/// `Unavailable`, which is a hard error — so a hook that references one fails\n-/// closed rather than firing with a half-resolved value.\n+/// Only `env` and `secrets` are wired here; `{{ vars.* }}` and `{{ inputs.* }}`\n+/// tokens have no lookup in this context and resolve as `Unavailable`, which is\n+/// a hard error — so a hook that references one fails closed rather than firing\n+/// with a half-resolved value.\n ///\n /// The value stays typed end-to-end: it is carried as an `InterpString`\n /// through the config resolve layer and resolved here from its segments —\n@@ -73,13 +76,18 @@ pub trait HookExecutor: Send + Sync {\n ///\n /// Returns the typed [`ResolveError`] so callers keep the source until the\n /// decision boundary renders it; do not flatten it to a `String` here.\n-fn resolve_interp<E>(value: &InterpString, env: &E) -> Result<String, ResolveError>\n+fn resolve_interp<E>(\n+ value: &InterpString,\n+ env: &E,\n+ secrets: &ResolvedHookSecrets,\n+) -> Result<String, ResolveError>\n where\n E: Env + ?Sized,\n {\n- value\n- .resolve(|name| env.var(name).ok())\n- .map(|resolved| resolved.value)\n+ let mut ctx = ResolveCtx::new()\n+ .with_env(|name| env.var(name).ok())\n+ .with_secrets(|name| secrets.lookup(name));\n+ value.resolve_with(&mut ctx).map(|resolved| resolved.value)\n }\n \n #[expect(\n@@ -95,6 +103,7 @@ fn safe_url_source_for_log(url: &InterpString) -> String {\n #[derive(Debug, Clone, PartialEq, Eq)]\n enum HeaderResolveError {\n NotAllowed { name: String },\n+ SecretNotAllowed { name: String },\n Resolve(ResolveError),\n }\n \n@@ -106,6 +115,11 @@ impl fmt::Display for HeaderResolveError {\n \"environment variable {name:?} referenced by an HTTP hook header is not listed in \\\n allowed_env_vars\"\n ),\n+ Self::SecretNotAllowed { name } => write!(\n+ f,\n+ \"secret {name:?} referenced by an HTTP hook header is not allowed; use secret \\\n+ interpolation in a hook command, prompt, or url instead\"\n+ ),\n Self::Resolve(error) => error.fmt(f),\n }\n }\n@@ -114,7 +128,7 @@ impl fmt::Display for HeaderResolveError {\n impl std::error::Error for HeaderResolveError {\n fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {\n match self {\n- Self::NotAllowed { .. } => None,\n+ Self::NotAllowed { .. } | Self::SecretNotAllowed { .. } => None,\n Self::Resolve(error) => Some(error),\n }\n }\n@@ -135,10 +149,17 @@ fn resolve_header<E>(\n value: &InterpString,\n allowed_env_vars: &[String],\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n ) -> Result<String, HeaderResolveError>\n where\n E: Env + ?Sized,\n {\n+ if let Some(name) = first_secret_name(value) {\n+ return Err(HeaderResolveError::SecretNotAllowed {\n+ name: name.to_string(),\n+ });\n+ }\n+\n if let Some(name) = value.names(Namespace::Env).into_iter().find(|name| {\n !allowed_env_vars\n .iter()\n@@ -149,7 +170,7 @@ where\n });\n }\n \n- resolve_interp(value, env).map_err(HeaderResolveError::Resolve)\n+ resolve_interp(value, env, secrets).map_err(HeaderResolveError::Resolve)\n }\n \n /// Executes hooks via shell commands or HTTP POST.\n@@ -181,20 +202,24 @@ impl HookExecutorImpl {\n \n /// Resolve the prompt and optional model segments at fire time.\n ///\n- /// Fail-closed: only `{{ env.* }}` is wired here; a missing env token (or a\n- /// token in any other, unavailable namespace) is a hard error so the hook\n- /// never fires with a half-resolved value. The caller turns the error into\n- /// a `Block` decision, matching the command-hook behavior.\n+ /// Fail-closed: only `{{ env.* }}` and `{{ secrets.* }}` are wired here; a\n+ /// missing token (or a token in any other, unavailable namespace) is a hard\n+ /// error so the hook never fires with a half-resolved value. The caller\n+ /// turns the error into a `Block` decision, matching the command-hook\n+ /// behavior.\n fn resolve_prompt_and_model<E>(\n prompt: &InterpString,\n model: Option<&InterpString>,\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n ) -> Result<(String, Option<String>), ResolveError>\n where\n E: Env + ?Sized,\n {\n- let prompt = resolve_interp(prompt, env)?;\n- let model = model.map(|model| resolve_interp(model, env)).transpose()?;\n+ let prompt = resolve_interp(prompt, env, secrets)?;\n+ let model = model\n+ .map(|model| resolve_interp(model, env, secrets))\n+ .transpose()?;\n Ok((prompt, model))\n }\n \n@@ -206,11 +231,12 @@ impl HookExecutorImpl {\n sandbox: &Arc<dyn Sandbox>,\n execution_context: &HookExecutionContext,\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n ) -> HookDecision\n where\n E: Env + ?Sized,\n {\n- let command = match resolve_interp(command, env) {\n+ let command = match resolve_interp(command, env, secrets) {\n Ok(command) => command,\n Err(error) => {\n return HookDecision::Block {\n@@ -363,13 +389,14 @@ impl HookExecutorImpl {\n model: Option<&InterpString>,\n context: &HookContext,\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n llm_source: &dyn CredentialSource,\n catalog: Arc<Catalog>,\n ) -> HookDecision\n where\n E: Env + ?Sized,\n {\n- let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env) {\n+ let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env, secrets) {\n Ok(resolved) => resolved,\n Err(error) => {\n tracing::error!(error = %error, \"prompt hook env resolution failed, not firing\");\n@@ -432,13 +459,14 @@ impl HookExecutorImpl {\n context: &HookContext,\n sandbox: Arc<dyn Sandbox>,\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n llm_source: &dyn CredentialSource,\n catalog: Arc<Catalog>,\n ) -> HookDecision\n where\n E: Env + ?Sized,\n {\n- let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env) {\n+ let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env, secrets) {\n Ok(resolved) => resolved,\n Err(error) => {\n tracing::error!(error = %error, \"agent hook env resolution failed, not firing\");\n@@ -578,11 +606,12 @@ impl HookExecutorImpl {\n context: &HookContext,\n timeout: std::time::Duration,\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n ) -> HookDecision\n where\n E: Env + ?Sized,\n {\n- let resolved_url = match resolve_interp(url, env) {\n+ let resolved_url = match resolve_interp(url, env, secrets) {\n Ok(url) => url,\n Err(error) => {\n tracing::error!(\n@@ -618,7 +647,7 @@ impl HookExecutorImpl {\n // `{{ env.NAME }}` not in `allowed_env_vars` blocks before any\n // lookup, while an allowlisted-but-unset name still fails as\n // missing.\n- let interpolated = match resolve_header(value, allowed_env_vars, env) {\n+ let interpolated = match resolve_header(value, allowed_env_vars, env, secrets) {\n Ok(rendered) => rendered,\n Err(error) => {\n tracing::error!(\n@@ -639,9 +668,11 @@ impl HookExecutorImpl {\n let response = match request.send().await {\n Ok(resp) => resp,\n Err(e) => {\n+ // `without_url` drops the resolved URL, which may embed a\n+ // secret; the redacted source form is logged separately.\n tracing::warn!(\n url_source = %safe_url_source_for_log(url),\n- error = %e,\n+ error = %e.without_url(),\n \"HTTP hook request failed, proceeding\"\n );\n return HookDecision::Proceed;\n@@ -662,7 +693,7 @@ impl HookExecutorImpl {\n Err(e) => {\n tracing::warn!(\n url_source = %safe_url_source_for_log(url),\n- error = %e,\n+ error = %e.without_url(),\n \"HTTP hook body read failed, proceeding\"\n );\n return HookDecision::Proceed;\n@@ -728,6 +759,7 @@ impl HookExecutor for HookExecutorImpl {\n execution_context: &HookExecutionContext,\n llm_source: &dyn CredentialSource,\n catalog: Arc<Catalog>,\n+ secrets: &ResolvedHookSecrets,\n ) -> HookResult {\n use std::sync::OnceLock;\n static HTTP_CLIENTS: OnceLock<HttpClientCache> = OnceLock::new();\n@@ -747,6 +779,7 @@ impl HookExecutor for HookExecutorImpl {\n &sandbox,\n execution_context,\n &env,\n+ secrets,\n )\n .await\n }\n@@ -774,6 +807,7 @@ impl HookExecutor for HookExecutorImpl {\n context,\n definition.timeout(),\n &env,\n+ secrets,\n )\n .await\n }\n@@ -793,6 +827,7 @@ impl HookExecutor for HookExecutorImpl {\n model.as_ref(),\n context,\n &env,\n+ secrets,\n llm_source,\n Arc::clone(&catalog),\n )\n@@ -818,6 +853,7 @@ impl HookExecutor for HookExecutorImpl {\n context,\n sandbox,\n &env,\n+ secrets,\n llm_source,\n Arc::clone(&catalog),\n )\n@@ -869,6 +905,19 @@ mod tests {\n HookExecutorImpl::build_http_client(TlsMode::Off)\n }\n \n+ fn empty_secrets() -> ResolvedHookSecrets {\n+ ResolvedHookSecrets::default()\n+ }\n+\n+ fn test_secrets(vars: &[(&str, &str)]) -> ResolvedHookSecrets {\n+ ResolvedHookSecrets::new(\n+ vars.iter()\n+ .map(|(name, value)| ((*name).to_string(), (*value).to_string()))\n+ .collect(),\n+ fabro_redact::SecretRedactor::default(),\n+ )\n+ }\n+\n fn make_definition(command: &str) -> HookDefinition {\n HookDefinition {\n name: Some(\"test-hook\".into()),\n@@ -946,6 +995,7 @@ mod tests {\n let ctx = make_context();\n let sandbox = make_sandbox();\n let source = test_llm_source();\n+ let secrets = empty_secrets();\n let result = executor\n .execute(\n &def,\n@@ -954,6 +1004,7 @@ mod tests {\n &HookExecutionContext::default(),\n source.as_ref(),\n test_catalog(),\n+ &secrets,\n )\n .await;\n assert_eq!(result.decision, HookDecision::Proceed);\n@@ -967,6 +1018,7 @@ mod tests {\n let ctx = make_context();\n let sandbox = make_sandbox();\n let source = test_llm_source();\n+ let secrets = empty_secrets();\n let result = executor\n .execute(\n &def,\n@@ -975,6 +1027,7 @@ mod tests {\n &HookExecutionContext::default(),\n source.as_ref(),\n test_catalog(),\n+ &secrets,\n )\n .await;\n assert!(matches!(result.decision, HookDecision::Block { .. }));\n@@ -987,6 +1040,7 @@ mod tests {\n let ctx = make_context();\n let sandbox = make_sandbox();\n let source = test_llm_source();\n+ let secrets = empty_secrets();\n let result = executor\n .execute(\n &def,\n@@ -995,6 +1049,7 @@ mod tests {\n &HookExecutionContext::default(),\n source.as_ref(),\n test_catalog(),\n+ &secrets,\n )\n .await;\n assert!(matches!(result.decision, HookDecision::Block { .. }));\n@@ -1007,6 +1062,7 @@ mod tests {\n let ctx = make_context();\n let sandbox = make_sandbox();\n let source = test_llm_source();\n+ let secrets = empty_secrets();\n let result = executor\n .execute(\n &def,\n@@ -1015,6 +1071,7 @@ mod tests {\n &HookExecutionContext::default(),\n source.as_ref(),\n test_catalog(),\n+ &secrets,\n )\n .await;\n assert_eq!(result.decision, HookDecision::Skip {\n@@ -1031,6 +1088,7 @@ mod tests {\n ctx.node_id = Some(\"plan\".into());\n let sandbox = make_sandbox();\n let source = test_llm_source();\n+ let secrets = empty_secrets();\n let result = executor\n .execute(\n &def,\n@@ -1039,6 +1097,7 @@ mod tests {\n &HookExecutionContext::default(),\n source.as_ref(),\n test_catalog(),\n+ &secrets,\n )\n .await;\n assert_eq!(result.decision, HookDecision::Proceed);\n@@ -1060,6 +1119,7 @@ mod tests {\n let ctx = make_context();\n let sandbox = make_sandbox();\n let source = test_llm_source();\n+ let secrets = empty_secrets();\n let result = executor\n .execute(\n &def,\n@@ -1068,6 +1128,7 @@ mod tests {\n &HookExecutionContext::default(),\n source.as_ref(),\n test_catalog(),\n+ &secrets,\n )\n .await;\n assert!(matches!(result.decision, HookDecision::Block { .. }));\n@@ -1188,6 +1249,7 @@ mod tests {\n &interp(\"Bearer {{ env.FABRO_TEST_KEY_1 }}\"),\n &[\"FABRO_TEST_KEY_1\".to_string()],\n &env,\n+ &empty_secrets(),\n )\n .unwrap();\n assert_eq!(result, \"Bearer secret123\");\n@@ -1203,6 +1265,7 @@ mod tests {\n &interp(\"prefix-{{ env.FABRO_TEST_KEY_3 }}-suffix\"),\n &[],\n &env,\n+ &empty_secrets(),\n )\n .unwrap_err();\n assert_eq!(err, HeaderResolveError::NotAllowed {\n@@ -1217,11 +1280,12 @@ mod tests {\n &interp(\"prefix-{{ env.FABRO_TEST_KEY_3 }}-suffix\"),\n &[\"FABRO_TEST_KEY_3\".to_string()],\n &env,\n+ &empty_secrets(),\n )\n .unwrap_err();\n match err {\n HeaderResolveError::Resolve(error) => assert_eq!(error.name, \"FABRO_TEST_KEY_3\"),\n- HeaderResolveError::NotAllowed { .. } => {\n+ HeaderResolveError::NotAllowed { .. } | HeaderResolveError::SecretNotAllowed { .. } => {\n panic!(\"expected missing token resolve error, got {err:?}\")\n }\n }\n@@ -1233,14 +1297,19 @@ mod tests {\n fn resolve_interp_resolves_embedded_token_from_typed_value() {\n let env = test_env(&[(\"FABRO_TEST_KEY_2\", \"val\")]);\n let value = interp(\"x{{ env.FABRO_TEST_KEY_2 }}y\");\n- let result = resolve_interp(&value, &env).unwrap();\n+ let result = resolve_interp(&value, &env, &empty_secrets()).unwrap();\n assert_eq!(result, \"xvaly\");\n }\n \n #[test]\n fn resolve_interp_errors_on_missing_var() {\n let env = test_env(&[]);\n- let err = resolve_interp(&interp(\"a{{ env.FABRO_TEST_NOEXIST }}-b\"), &env).unwrap_err();\n+ let err = resolve_interp(\n+ &interp(\"a{{ env.FABRO_TEST_NOEXIST }}-b\"),\n+ &env,\n+ &empty_secrets(),\n+ )\n+ .unwrap_err();\n assert_eq!(err.name, \"FABRO_TEST_NOEXIST\");\n }\n \n@@ -1248,11 +1317,63 @@ mod tests {\n fn resolve_interp_without_tokens_passes_through() {\n let env = test_env(&[]);\n assert_eq!(\n- resolve_interp(&interp(\"plain text\"), &env).unwrap(),\n+ resolve_interp(&interp(\"plain text\"), &env, &empty_secrets()).unwrap(),\n \"plain text\"\n );\n }\n \n+ #[test]\n+ fn resolve_interp_resolves_secret_and_registers_value() {\n+ let env = test_env(&[]);\n+ let redactor = fabro_redact::SecretRedactor::default();\n+ let secrets = ResolvedHookSecrets::new(\n+ HashMap::from([(\"HOOK_TOKEN\".to_string(), \"staging\".to_string())]),\n+ redactor.clone(),\n+ );\n+\n+ let resolved =\n+ resolve_interp(&interp(\"deploy {{ secrets.HOOK_TOKEN }}\"), &env, &secrets).unwrap();\n+\n+ assert_eq!(resolved, \"deploy staging\");\n+ assert_eq!(redactor.redact_into(\"deploy staging\"), \"deploy REDACTED\");\n+ }\n+\n+ #[test]\n+ fn resolve_prompt_and_model_resolves_secret_tokens() {\n+ let env = test_env(&[]);\n+ let secrets = test_secrets(&[(\"PROMPT_TOKEN\", \"staging\")]);\n+\n+ let (prompt, model) = HookExecutorImpl::resolve_prompt_and_model(\n+ &interp(\"check {{ secrets.PROMPT_TOKEN }}\"),\n+ Some(&interp(\"haiku\")),\n+ &env,\n+ &secrets,\n+ )\n+ .unwrap();\n+\n+ assert_eq!(prompt, \"check staging\");\n+ assert_eq!(model.as_deref(), Some(\"haiku\"));\n+ }\n+\n+ #[test]\n+ fn header_rejects_secret_token_with_guidance() {\n+ let env = test_env(&[]);\n+ let err = resolve_header(\n+ &interp(\"Bearer {{ secrets.HOOK_TOKEN }}\"),\n+ &[],\n+ &env,\n+ &test_secrets(&[(\"HOOK_TOKEN\", \"staging\")]),\n+ )\n+ .unwrap_err();\n+\n+ assert_eq!(err, HeaderResolveError::SecretNotAllowed {\n+ name: \"HOOK_TOKEN\".to_string(),\n+ });\n+ let message = err.to_string();\n+ assert!(message.contains(\"HTTP hook header\"));\n+ assert!(message.contains(\"command, prompt, or url\"));\n+ }\n+\n // --- HTTP hook execution tests ---\n \n #[tokio::test]\n@@ -1278,6 +1399,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1307,6 +1429,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1334,6 +1457,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1353,6 +1477,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(1),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1388,6 +1513,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &env,\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1425,6 +1551,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &env,\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1442,6 +1569,46 @@ mod tests {\n }\n }\n \n+ #[tokio::test]\n+ async fn http_hook_secret_header_blocks_without_firing() {\n+ let server = httpmock::MockServer::start_async().await;\n+ let mock = server\n+ .mock_async(|when, then| {\n+ when.method(\"POST\").path(\"/hook\");\n+ then.status(200).body(\"\");\n+ })\n+ .await;\n+\n+ let headers = HashMap::from([(\n+ \"Authorization\".to_string(),\n+ interp(\"Bearer {{ secrets.FABRO_TEST_TOKEN }}\"),\n+ )]);\n+\n+ let client = test_http_client();\n+ let decision = HookExecutorImpl::execute_http(\n+ &client,\n+ &interp(&server.url(\"/hook\")),\n+ Some(&headers),\n+ &[],\n+ &TlsMode::Off,\n+ &make_context(),\n+ std::time::Duration::from_secs(5),\n+ &test_env(&[]),\n+ &test_secrets(&[(\"FABRO_TEST_TOKEN\", \"staging\")]),\n+ )\n+ .await;\n+\n+ assert_eq!(mock.calls_async().await, 0);\n+ match decision {\n+ HookDecision::Block { reason } => {\n+ let reason = reason.unwrap_or_default();\n+ assert!(reason.contains(\"HTTP hook header\"));\n+ assert!(reason.contains(\"command, prompt, or url\"));\n+ }\n+ other => panic!(\"expected Block on secret header token, got {other:?}\"),\n+ }\n+ }\n+\n #[tokio::test]\n async fn http_hook_resolves_url_before_dispatch() {\n let server = httpmock::MockServer::start_async().await;\n@@ -1463,6 +1630,36 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &env,\n+ &empty_secrets(),\n+ )\n+ .await;\n+\n+ mock.assert_async().await;\n+ assert_eq!(decision, HookDecision::Proceed);\n+ }\n+\n+ #[tokio::test]\n+ async fn http_hook_resolves_secret_url_before_dispatch() {\n+ let server = httpmock::MockServer::start_async().await;\n+ let mock = server\n+ .mock_async(|when, then| {\n+ when.method(\"POST\").path(\"/hook\");\n+ then.status(200).body(\"\");\n+ })\n+ .await;\n+\n+ let client = test_http_client();\n+ let secrets = test_secrets(&[(\"FABRO_TEST_URL\", &server.url(\"/hook\"))]);\n+ let decision = HookExecutorImpl::execute_http(\n+ &client,\n+ &interp(\"{{ secrets.FABRO_TEST_URL }}\"),\n+ None,\n+ &[],\n+ &TlsMode::Off,\n+ &make_context(),\n+ std::time::Duration::from_secs(5),\n+ &test_env(&[]),\n+ &secrets,\n )\n .await;\n \n@@ -1490,6 +1687,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1534,6 +1732,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1557,6 +1756,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1575,6 +1775,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1601,6 +1802,7 @@ mod tests {\n &make_context(),\n std::time::Duration::from_secs(5),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n@@ -1637,6 +1839,7 @@ mod tests {\n let ctx = make_context();\n let sandbox = make_sandbox();\n let source = test_llm_source();\n+ let secrets = empty_secrets();\n let result = executor\n .execute(\n &def,\n@@ -1645,6 +1848,7 @@ mod tests {\n &HookExecutionContext::default(),\n source.as_ref(),\n test_catalog(),\n+ &secrets,\n )\n .await;\n \n@@ -1663,12 +1867,57 @@ mod tests {\n &sandbox,\n &HookExecutionContext::default(),\n &test_env(&[]),\n+ &empty_secrets(),\n )\n .await;\n \n assert!(matches!(decision, HookDecision::Block { .. }));\n }\n \n+ #[tokio::test]\n+ async fn command_hook_resolves_secret_token() {\n+ let sandbox = make_sandbox();\n+ let decision = HookExecutorImpl::execute_command(\n+ &make_definition(r#\"test \"{{ secrets.HOOK_TOKEN }}\" = \"staging\"\"#),\n+ &interp(r#\"test \"{{ secrets.HOOK_TOKEN }}\" = \"staging\"\"#),\n+ &make_context(),\n+ &sandbox,\n+ &HookExecutionContext::default(),\n+ &test_env(&[]),\n+ &test_secrets(&[(\"HOOK_TOKEN\", \"staging\")]),\n+ )\n+ .await;\n+\n+ assert_eq!(decision, HookDecision::Proceed);\n+ }\n+\n+ #[tokio::test]\n+ async fn command_hook_missing_secret_blocks() {\n+ let sandbox = make_sandbox();\n+ let decision = HookExecutorImpl::execute_command(\n+ &make_definition(\"echo {{ secrets.MISSING_HOOK_SECRET }}\"),\n+ &interp(\"echo {{ secrets.MISSING_HOOK_SECRET }}\"),\n+ &make_context(),\n+ &sandbox,\n+ &HookExecutionContext::default(),\n+ &test_env(&[]),\n+ &empty_secrets(),\n+ )\n+ .await;\n+\n+ match decision {\n+ HookDecision::Block { reason } => {\n+ assert!(\n+ reason\n+ .as_deref()\n+ .is_some_and(|reason| reason.contains(\"MISSING_HOOK_SECRET\")),\n+ \"block reason should name the missing secret, got: {reason:?}\"\n+ );\n+ }\n+ other => panic!(\"expected Block on missing command secret, got {other:?}\"),\n+ }\n+ }\n+\n // Fail-closed: a prompt hook with a missing token does not fire the LLM\n // call; it blocks with the resolution error, matching command hooks.\n #[tokio::test]\n@@ -1679,6 +1928,7 @@ mod tests {\n None,\n &make_context(),\n &test_env(&[]),\n+ &empty_secrets(),\n test_llm_source().as_ref(),\n test_catalog(),\n )\n@@ -1708,6 +1958,7 @@ mod tests {\n &make_context(),\n make_sandbox(),\n &test_env(&[]),\n+ &empty_secrets(),\n test_llm_source().as_ref(),\n test_catalog(),\n )\ndiff --git a/lib/crates/fabro-hooks/src/lib.rs b/lib/crates/fabro-hooks/src/lib.rs\nindex 79a77bb98..d0d800f73 100644\n--- a/lib/crates/fabro-hooks/src/lib.rs\n+++ b/lib/crates/fabro-hooks/src/lib.rs\n@@ -2,6 +2,7 @@ pub mod bridge;\n pub mod config;\n pub mod executor;\n pub mod runner;\n+mod secrets;\n pub mod types;\n \n pub use bridge::WorkflowToolHookCallback;\n@@ -10,4 +11,5 @@ pub use config::{HookDefinition, HookSettings, HookType, TlsMode};\n // `InterpString`; constructing a hook definition requires it.\n pub use fabro_types::settings::InterpString;\n pub use runner::HookRunner;\n+pub use secrets::{HookSecretResolver, ResolvedHookSecrets};\n pub use types::{HookContext, HookDecision, HookEvent, HookExecutionContext};\ndiff --git a/lib/crates/fabro-hooks/src/runner.rs b/lib/crates/fabro-hooks/src/runner.rs\nindex 9435dba9a..006570730 100644\n--- a/lib/crates/fabro-hooks/src/runner.rs\n+++ b/lib/crates/fabro-hooks/src/runner.rs\n@@ -6,10 +6,12 @@ use fabro_auth::CredentialSource;\n #[cfg(test)]\n use fabro_auth::EnvCredentialSource;\n use fabro_model::Catalog;\n+use fabro_redact::SecretRedactor;\n \n use crate::config::{HookDefinition, HookSettings};\n use crate::executor::{HookExecutor, HookExecutorImpl};\n-use crate::types::{HookContext, HookDecision, HookExecutionContext};\n+use crate::secrets::HookSecretResolver;\n+use crate::types::{HookContext, HookDecision, HookExecutionContext, HookResult};\n \n /// Central orchestrator: filters matching hooks, executes them, merges\n /// decisions.\n@@ -18,16 +20,37 @@ pub struct HookRunner {\n executor: Arc<dyn HookExecutor>,\n llm_source: Arc<dyn CredentialSource>,\n catalog: Arc<Catalog>,\n+ secrets: HookSecretResolver,\n /// Pre-compiled regexes keyed by matcher pattern string.\n compiled_matchers: HashMap<String, regex::Regex>,\n }\n \n+fn redact_hook_result(mut result: HookResult, redactor: &SecretRedactor) -> HookResult {\n+ result.decision = redact_hook_decision(result.decision, redactor);\n+ result\n+}\n+\n+fn redact_hook_decision(decision: HookDecision, redactor: &SecretRedactor) -> HookDecision {\n+ match decision {\n+ HookDecision::Skip { reason } => HookDecision::Skip {\n+ reason: reason.map(|reason| redactor.redact_into(&reason)),\n+ },\n+ HookDecision::Block { reason } => HookDecision::Block {\n+ reason: reason.map(|reason| redactor.redact_into(&reason)),\n+ },\n+ // `Override.edge_to` is a structural graph edge id, not free-form text,\n+ // so it is intentionally left unredacted.\n+ HookDecision::Proceed | HookDecision::Override { .. } => decision,\n+ }\n+}\n+\n impl HookRunner {\n #[must_use]\n pub fn new(\n config: HookSettings,\n llm_source: Arc<dyn CredentialSource>,\n catalog: Arc<Catalog>,\n+ secrets: HookSecretResolver,\n ) -> Self {\n let compiled_matchers = Self::compile_matchers(&config);\n Self {\n@@ -35,6 +58,7 @@ impl HookRunner {\n executor: Arc::new(HookExecutorImpl),\n llm_source,\n catalog,\n+ secrets,\n compiled_matchers,\n }\n }\n@@ -48,6 +72,7 @@ impl HookRunner {\n executor,\n llm_source: Arc::new(EnvCredentialSource::new()),\n catalog: Arc::new(Catalog::from_builtin().expect(\"default catalog should build\")),\n+ secrets: HookSecretResolver::default(),\n compiled_matchers,\n }\n }\n@@ -100,7 +125,7 @@ impl HookRunner {\n \n tracing::info!(\n event = %context.event,\n- decision = ?decision,\n+ decision = decision.as_str(),\n \"Hooks complete\"\n );\n \n@@ -137,6 +162,43 @@ impl HookRunner {\n .any(|field| field.is_some_and(|v| re.is_match(v)))\n }\n \n+ /// Resolve secrets, run a single hook through the executor, and redact its\n+ /// result. Shared by the blocking and non-blocking loops.\n+ async fn execute_one(\n+ &self,\n+ hook: &HookDefinition,\n+ context: &HookContext,\n+ sandbox: Arc<dyn Sandbox>,\n+ execution_context: &HookExecutionContext,\n+ ) -> HookResult {\n+ tracing::debug!(\n+ hook = %hook.effective_name(),\n+ event = %context.event,\n+ \"Executing hook\"\n+ );\n+ let secrets = self.secrets.resolve_for_definition(hook).await;\n+ let result = self\n+ .executor\n+ .execute(\n+ hook,\n+ context,\n+ sandbox,\n+ execution_context,\n+ self.llm_source.as_ref(),\n+ Arc::clone(&self.catalog),\n+ &secrets,\n+ )\n+ .await;\n+ let result = redact_hook_result(result, secrets.redactor());\n+ tracing::debug!(\n+ hook = %hook.effective_name(),\n+ duration_ms = result.duration_ms,\n+ decision = result.decision.as_str(),\n+ \"Hook complete\"\n+ );\n+ result\n+ }\n+\n async fn run_sequential(\n &self,\n hooks: &[&HookDefinition],\n@@ -146,28 +208,9 @@ impl HookRunner {\n ) -> HookDecision {\n let mut merged = HookDecision::Proceed;\n for hook in hooks {\n- tracing::debug!(\n- hook = %hook.effective_name(),\n- event = %context.event,\n- \"Executing hook\"\n- );\n let result = self\n- .executor\n- .execute(\n- hook,\n- context,\n- sandbox.clone(),\n- execution_context,\n- self.llm_source.as_ref(),\n- Arc::clone(&self.catalog),\n- )\n+ .execute_one(hook, context, sandbox.clone(), execution_context)\n .await;\n- tracing::debug!(\n- hook = %hook.effective_name(),\n- duration_ms = result.duration_ms,\n- decision = ?result.decision,\n- \"Hook complete\"\n- );\n \n if hook.is_blocking() {\n merged = merged.merge(result.decision);\n@@ -176,7 +219,7 @@ impl HookRunner {\n tracing::error!(\n hook = %hook.effective_name(),\n event = %context.event,\n- decision = ?merged,\n+ decision = merged.as_str(),\n \"Hook blocked execution\"\n );\n return merged;\n@@ -185,7 +228,7 @@ impl HookRunner {\n tracing::warn!(\n hook = %hook.effective_name(),\n event = %context.event,\n- decision = ?result.decision,\n+ decision = result.decision.as_str(),\n \"Non-blocking hook returned non-proceed, ignoring\"\n );\n }\n@@ -201,33 +244,14 @@ impl HookRunner {\n execution_context: &HookExecutionContext,\n ) -> HookDecision {\n for hook in hooks {\n- tracing::debug!(\n- hook = %hook.effective_name(),\n- event = %context.event,\n- \"Executing hook\"\n- );\n let result = self\n- .executor\n- .execute(\n- hook,\n- context,\n- sandbox.clone(),\n- execution_context,\n- self.llm_source.as_ref(),\n- Arc::clone(&self.catalog),\n- )\n+ .execute_one(hook, context, sandbox.clone(), execution_context)\n .await;\n- tracing::debug!(\n- hook = %hook.effective_name(),\n- duration_ms = result.duration_ms,\n- decision = ?result.decision,\n- \"Hook complete\"\n- );\n if !result.decision.is_proceed() {\n tracing::warn!(\n hook = %hook.effective_name(),\n event = %context.event,\n- decision = ?result.decision,\n+ decision = result.decision.as_str(),\n \"Non-blocking hook failed, continuing\"\n );\n }\n@@ -259,6 +283,7 @@ mod tests {\n _execution_context: &HookExecutionContext,\n _llm_source: &dyn CredentialSource,\n _catalog: Arc<Catalog>,\n+ _secrets: &crate::ResolvedHookSecrets,\n ) -> HookResult {\n HookResult {\n hook_name: definition.name.clone(),\n@@ -301,7 +326,12 @@ mod tests {\n \n #[tokio::test]\n async fn no_hooks_returns_proceed() {\n- let runner = HookRunner::new(HookSettings::default(), test_llm_source(), test_catalog());\n+ let runner = HookRunner::new(\n+ HookSettings::default(),\n+ test_llm_source(),\n+ test_catalog(),\n+ HookSecretResolver::default(),\n+ );\n let ctx = make_context(HookEvent::RunStart);\n let sandbox = make_sandbox();\n let decision = runner\n@@ -474,7 +504,12 @@ mod tests {\n h\n }],\n };\n- let runner = HookRunner::new(config, test_llm_source(), test_catalog());\n+ let runner = HookRunner::new(\n+ config,\n+ test_llm_source(),\n+ test_catalog(),\n+ HookSecretResolver::default(),\n+ );\n let ctx = make_context(HookEvent::RunStart);\n let sandbox = make_sandbox();\n let decision = runner\n@@ -492,7 +527,12 @@ mod tests {\n h\n }],\n };\n- let runner = HookRunner::new(config, test_llm_source(), test_catalog());\n+ let runner = HookRunner::new(\n+ config,\n+ test_llm_source(),\n+ test_catalog(),\n+ HookSecretResolver::default(),\n+ );\n let ctx = make_context(HookEvent::RunStart);\n let sandbox = make_sandbox();\n let decision = runner\ndiff --git a/lib/crates/fabro-hooks/src/secrets.rs b/lib/crates/fabro-hooks/src/secrets.rs\nnew file mode 100644\nindex 000000000..f78ccc518\n--- /dev/null\n+++ b/lib/crates/fabro-hooks/src/secrets.rs\n@@ -0,0 +1,134 @@\n+use std::collections::HashMap;\n+use std::future::Future;\n+use std::pin::Pin;\n+use std::sync::Arc;\n+\n+use fabro_redact::SecretRedactor;\n+use fabro_types::settings::InterpString;\n+use fabro_types::settings::interp::Namespace;\n+\n+use crate::config::HookDefinition;\n+\n+type SecretLookupFuture = Pin<Box<dyn Future<Output = Option<String>> + Send + 'static>>;\n+type SecretLookup = dyn Fn(String) -> SecretLookupFuture + Send + Sync + 'static;\n+\n+/// Per-run hook secret resolver.\n+///\n+/// The resolver is cheap to clone and must be constructed per run. It returns\n+/// only token-shaped vault secrets supplied by the worker and shares the run's\n+/// [`SecretRedactor`] so values resolved by hooks join the same redaction\n+/// registry as run-boundary environment and prepare-step secrets.\n+#[derive(Clone)]\n+pub struct HookSecretResolver {\n+ lookup: Option<Arc<SecretLookup>>,\n+ redactor: SecretRedactor,\n+}\n+\n+impl HookSecretResolver {\n+ #[must_use]\n+ pub fn new(redactor: SecretRedactor) -> Self {\n+ Self {\n+ lookup: None,\n+ redactor,\n+ }\n+ }\n+\n+ #[must_use]\n+ pub fn with_lookup<F, Fut>(redactor: SecretRedactor, lookup: F) -> Self\n+ where\n+ F: Fn(String) -> Fut + Send + Sync + 'static,\n+ Fut: Future<Output = Option<String>> + Send + 'static,\n+ {\n+ Self {\n+ lookup: Some(Arc::new(move |name| Box::pin(lookup(name)))),\n+ redactor,\n+ }\n+ }\n+\n+ pub async fn resolve_for_definition(&self, definition: &HookDefinition) -> ResolvedHookSecrets {\n+ self.resolve_names(secret_names_for_definition(definition))\n+ .await\n+ }\n+\n+ async fn resolve_names<I, S>(&self, names: I) -> ResolvedHookSecrets\n+ where\n+ I: IntoIterator<Item = S>,\n+ S: AsRef<str>,\n+ {\n+ let mut values = HashMap::new();\n+ let Some(lookup) = self.lookup.as_ref() else {\n+ return ResolvedHookSecrets::new(values, self.redactor.clone());\n+ };\n+\n+ for name in names {\n+ let name = name.as_ref();\n+ if values.contains_key(name) {\n+ continue;\n+ }\n+ if let Some(value) = lookup(name.to_string()).await {\n+ values.insert(name.to_string(), value);\n+ }\n+ }\n+\n+ ResolvedHookSecrets::new(values, self.redactor.clone())\n+ }\n+}\n+\n+impl Default for HookSecretResolver {\n+ fn default() -> Self {\n+ Self::new(SecretRedactor::default())\n+ }\n+}\n+\n+/// Secrets resolved for one hook firing.\n+#[derive(Clone, Default)]\n+pub struct ResolvedHookSecrets {\n+ values: HashMap<String, String>,\n+ redactor: SecretRedactor,\n+}\n+\n+impl ResolvedHookSecrets {\n+ #[must_use]\n+ pub fn new(values: HashMap<String, String>, redactor: SecretRedactor) -> Self {\n+ for value in values.values() {\n+ redactor.register(value);\n+ }\n+ Self { values, redactor }\n+ }\n+\n+ #[must_use]\n+ pub fn lookup(&self, name: &str) -> Option<String> {\n+ // Values were registered with the redactor at construction time in\n+ // `new`, so this is a pure read.\n+ self.values.get(name).cloned()\n+ }\n+\n+ #[must_use]\n+ pub fn redactor(&self) -> &SecretRedactor {\n+ &self.redactor\n+ }\n+}\n+\n+pub(crate) fn secret_names(value: &InterpString) -> Vec<&str> {\n+ value.names(Namespace::Secrets)\n+}\n+\n+pub(crate) fn first_secret_name(value: &InterpString) -> Option<&str> {\n+ // Cheap containment check first: the common no-secret case avoids\n+ // collecting the name list.\n+ value\n+ .references(Namespace::Secrets)\n+ .then(|| secret_names(value).into_iter().next())\n+ .flatten()\n+}\n+\n+fn secret_names_for_definition(definition: &HookDefinition) -> Vec<String> {\n+ let Some(hook_type) = definition.resolved_hook_type() else {\n+ return Vec::new();\n+ };\n+ hook_type\n+ .interp_strings()\n+ .flat_map(secret_names)\n+ .map(str::to_string)\n+ .collect()\n+}\ndiff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/crates/fabro-hooks/src/types.rs\nindex 8a1cba192..da45268a8 100644\n--- a/lib/crates/fabro-hooks/src/types.rs\n+++ b/lib/crates/fabro-hooks/src/types.rs\n@@ -82,8 +82,9 @@ pub struct PromptHookResponse {\n }\n \n /// Decision returned by blocking hooks.\n-#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]\n+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, strum::IntoStaticStr)]\n #[serde(tag = \"decision\", rename_all = \"snake_case\")]\n+#[strum(serialize_all = \"snake_case\")]\n pub enum HookDecision {\n #[default]\n Proceed,\n@@ -117,6 +118,12 @@ impl HookDecision {\n pub fn is_proceed(&self) -> bool {\n matches!(self, Self::Proceed)\n }\n+\n+ /// Variant label matching the serde `decision` tag, for logging.\n+ #[must_use]\n+ pub fn as_str(&self) -> &'static str {\n+ self.into()\n+ }\n }\n \n /// Realm-specific locations available to hook execution.\ndiff --git a/lib/crates/fabro-hooks/tests/host_command_hooks.rs b/lib/crates/fabro-hooks/tests/host_command_hooks.rs\nindex 4bbe0c5a3..2ff53ff54 100644\n--- a/lib/crates/fabro-hooks/tests/host_command_hooks.rs\n+++ b/lib/crates/fabro-hooks/tests/host_command_hooks.rs\n@@ -5,7 +5,7 @@ use fabro_agent::{LocalSandbox, Sandbox};\n use fabro_auth::{CredentialSource, EnvCredentialSource};\n use fabro_hooks::{\n HookContext, HookDecision, HookDefinition, HookEvent, HookExecutionContext, HookRunner,\n- HookSettings, InterpString,\n+ HookSecretResolver, HookSettings, InterpString,\n };\n use fabro_model::Catalog;\n use fabro_types::RunId;\n@@ -54,6 +54,7 @@ async fn host_command_hook_uses_host_workdir_not_sandbox_workdir() {\n },\n test_llm_source(),\n test_catalog(),\n+ HookSecretResolver::default(),\n );\n let context = HookContext::new(\n HookEvent::RunStart,\ndiff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs\nindex 3c1568fc0..bc402e0ae 100644\n--- a/lib/crates/fabro-sandbox/src/daytona/mod.rs\n+++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs\n@@ -1106,7 +1106,10 @@ impl Sandbox for DaytonaSandbox {\n },\n );\n tracing::warn!(\n- error = %crate::display_for_log(&err),\n+ error = %crate::display_for_log(\n+ &err,\n+ &fabro_redact::SecretRedactor::default(),\n+ ),\n \"Failed to set Daytona sandbox push credentials \\\n on origin — subsequent git push from this \\\n sandbox will fail\"\ndiff --git a/lib/crates/fabro-sandbox/src/error.rs b/lib/crates/fabro-sandbox/src/error.rs\nindex 7b2377660..47164e21f 100644\n--- a/lib/crates/fabro-sandbox/src/error.rs\n+++ b/lib/crates/fabro-sandbox/src/error.rs\n@@ -2,6 +2,7 @@ use std::fmt::Write as _;\n \n #[cfg(feature = \"docker\")]\n use bollard::errors::Error as BollardError;\n+use fabro_redact::SecretRedactor;\n use fabro_util::error::{collect_causes, render_with_causes};\n \n use crate::ExecResult;\n@@ -75,8 +76,11 @@ impl Error {\n }\n }\n \n- pub fn default_redacted_output_tail(&self) -> Option<fabro_types::ExecOutputTail> {\n- default_redacted_output_tail(self)\n+ pub fn default_redacted_output_tail(\n+ &self,\n+ redactor: &SecretRedactor,\n+ ) -> Option<fabro_types::ExecOutputTail> {\n+ default_redacted_output_tail(self, redactor)\n }\n \n #[cfg(feature = \"docker\")]\n@@ -161,22 +165,29 @@ fn format_exit_code(exit_code: Option<i32>) -> String {\n \n pub type Result<T> = std::result::Result<T, Error>;\n \n+/// Walk the error chain and build the redacted output tail of the first\n+/// sandbox exec failure. The run-scoped exact-match `redactor` is applied\n+/// after content-based redaction; an empty redactor is an identity.\n pub fn default_redacted_output_tail(\n err: &(dyn std::error::Error + 'static),\n+ redactor: &SecretRedactor,\n ) -> Option<fabro_types::ExecOutputTail> {\n let mut current = Some(err);\n while let Some(err) = current {\n if let Some(Error::Exec { result, .. }) = err.downcast_ref::<Error>() {\n- return result.default_redacted_output_tail();\n+ return result.default_redacted_output_tail(redactor);\n }\n current = err.source();\n }\n None\n }\n \n-pub fn display_for_log(err: &(dyn std::error::Error + 'static)) -> String {\n+pub fn display_for_log(\n+ err: &(dyn std::error::Error + 'static),\n+ redactor: &SecretRedactor,\n+) -> String {\n let mut rendered = render_with_causes(&err.to_string(), &collect_causes(err));\n- if let Some(tail) = default_redacted_output_tail(err) {\n+ if let Some(tail) = default_redacted_output_tail(err, redactor) {\n append_tail_for_log(\n &mut rendered,\n \"stderr\",\n@@ -264,7 +275,7 @@ mod tests {\n });\n let error = Error::context(\"metadata push failed\", exec_error);\n \n- let rendered = display_for_log(&error);\n+ let rendered = display_for_log(&error, &SecretRedactor::default());\n \n assert!(rendered.contains(\"metadata push failed\"));\n assert!(rendered.contains(\"git push origin refs/heads/run\"));\n@@ -284,7 +295,7 @@ mod tests {\n duration_ms: 210,\n });\n \n- let rendered = display_for_log(&error);\n+ let rendered = display_for_log(&error, &SecretRedactor::default());\n \n assert!(\n !rendered.contains(\"ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA\"),\n@@ -297,7 +308,7 @@ mod tests {\n fn display_for_log_for_non_exec_error_returns_chain_only() {\n let error = Error::context(\"outer failure\", std::io::Error::other(\"leaf failure\"));\n \n- let rendered = display_for_log(&error);\n+ let rendered = display_for_log(&error, &SecretRedactor::default());\n \n assert_eq!(rendered, \"outer failure\\n caused by: leaf failure\");\n assert!(!rendered.contains(\"--- stderr\"));\n@@ -331,7 +342,9 @@ mod tests {\n duration_ms: 210,\n });\n \n- let tail = error.default_redacted_output_tail().expect(\"tail present\");\n+ let tail = error\n+ .default_redacted_output_tail(&SecretRedactor::default())\n+ .expect(\"tail present\");\n assert_eq!(tail.stdout.as_deref(), Some(\"last stdout line\"));\n assert!(\n tail.stderr\n@@ -352,7 +365,8 @@ mod tests {\n });\n let error = Error::context(\"metadata push failed\", exec_error);\n \n- let tail = default_redacted_output_tail(&error).expect(\"tail present\");\n+ let tail =\n+ default_redacted_output_tail(&error, &SecretRedactor::default()).expect(\"tail present\");\n \n assert_eq!(tail.stdout.as_deref(), Some(\"last stdout line\"));\n assert_eq!(tail.stderr.as_deref(), Some(\"last stderr line\"));\ndiff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs\nindex b7e4751e6..9ffec14ec 100644\n--- a/lib/crates/fabro-sandbox/src/lib.rs\n+++ b/lib/crates/fabro-sandbox/src/lib.rs\n@@ -41,6 +41,7 @@ pub use details::sandbox_details;\n #[cfg(feature = \"docker\")]\n pub use docker::{DockerSandbox, DockerSandboxOptions};\n pub use error::{Error, Result, default_redacted_output_tail, display_for_log};\n+pub use fabro_redact::SecretRedactor;\n pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};\n pub use local::LocalSandbox;\n #[cfg(feature = \"daytona\")]\ndiff --git a/lib/crates/fabro-sandbox/src/provider.rs b/lib/crates/fabro-sandbox/src/provider.rs\nindex b72d64efc..360e8d8a6 100644\n--- a/lib/crates/fabro-sandbox/src/provider.rs\n+++ b/lib/crates/fabro-sandbox/src/provider.rs\n@@ -26,7 +26,7 @@ pub enum SandboxCreateSpec {\n Local,\n #[cfg(feature = \"docker\")]\n Docker {\n- config: DockerSandboxOptions,\n+ config: Box<DockerSandboxOptions>,\n github_app: Option<GitHubCredentials>,\n run_id: Option<RunId>,\n clone_origin_url: Option<String>,\ndiff --git a/lib/crates/fabro-sandbox/src/provider/docker.rs b/lib/crates/fabro-sandbox/src/provider/docker.rs\nindex f1f8bb725..3e81e5f28 100644\n--- a/lib/crates/fabro-sandbox/src/provider/docker.rs\n+++ b/lib/crates/fabro-sandbox/src/provider/docker.rs\n@@ -99,7 +99,7 @@ impl SandboxProvider for DockerSandboxProvider {\n };\n \n let sandbox =\n- DockerSandbox::new(config, github_app, run_id, clone_origin_url, clone_branch)?;\n+ DockerSandbox::new(*config, github_app, run_id, clone_origin_url, clone_branch)?;\n sandbox.initialize().await?;\n let container_id = sandbox.container_identifier()?.to_string();\n self.get(&container_id).await?.ok_or_else(|| {\ndiff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs\nindex 68801f8bb..f9ac2f1a9 100644\n--- a/lib/crates/fabro-sandbox/src/sandbox.rs\n+++ b/lib/crates/fabro-sandbox/src/sandbox.rs\n@@ -7,6 +7,7 @@ use std::sync::Arc;\n use std::time::Duration;\n \n use async_trait::async_trait;\n+use fabro_redact::SecretRedactor;\n use fabro_types::{CommandOutputStream, CommandTermination};\n use fabro_util::shell;\n use serde::{Deserialize, Serialize};\n@@ -568,12 +569,16 @@ impl ExecResult {\n pub fn redacted_output_tail(\n &self,\n max_bytes_per_stream: usize,\n+ redactor: &SecretRedactor,\n ) -> Option<fabro_types::ExecOutputTail> {\n- redacted_output_tail(&self.stdout, &self.stderr, max_bytes_per_stream)\n+ redacted_output_tail(&self.stdout, &self.stderr, max_bytes_per_stream, redactor)\n }\n \n- pub fn default_redacted_output_tail(&self) -> Option<fabro_types::ExecOutputTail> {\n- self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)\n+ pub fn default_redacted_output_tail(\n+ &self,\n+ redactor: &SecretRedactor,\n+ ) -> Option<fabro_types::ExecOutputTail> {\n+ self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redactor)\n }\n \n /// Converts host process output into the canonical full exec result.\n@@ -600,15 +605,18 @@ impl ExecResult {\n \n /// Build a redacted `ExecOutputTail` from raw stdout/stderr without\n /// fabricating a synthetic `ExecResult`. Pass `\"\"` for either stream that\n-/// isn't relevant. Returns `None` when both streams are empty.\n+/// isn't relevant. Returns `None` when both streams are empty. The run-scoped\n+/// exact-match `redactor` is applied after the content-based redaction\n+/// baseline; an empty redactor is an identity.\n #[must_use]\n pub fn redacted_output_tail(\n stdout: &str,\n stderr: &str,\n max_bytes_per_stream: usize,\n+ redactor: &SecretRedactor,\n ) -> Option<fabro_types::ExecOutputTail> {\n- let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream);\n- let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream);\n+ let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream, redactor);\n+ let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream, redactor);\n let tail = fabro_types::ExecOutputTail {\n stdout,\n stderr,\n@@ -618,12 +626,19 @@ pub fn redacted_output_tail(\n (!tail.is_empty()).then_some(tail)\n }\n \n-fn redacted_tail(text: &str, max_bytes: usize) -> (Option<String>, bool) {\n+fn redacted_tail(\n+ text: &str,\n+ max_bytes: usize,\n+ redactor: &SecretRedactor,\n+) -> (Option<String>, bool) {\n if text.is_empty() || max_bytes == 0 {\n return (None, !text.is_empty());\n }\n \n- let redacted = fabro_redact::redact_string(text);\n+ let mut redacted = fabro_redact::redact_string(text);\n+ if !redactor.is_empty() {\n+ redacted = redactor.redact_into(&redacted);\n+ }\n let sanitized = sanitize_exec_output(&redacted);\n let truncated = sanitized.len() > max_bytes;\n let start = if truncated {\n@@ -1172,7 +1187,7 @@ pub async fn git_push_via_exec(sandbox: &dyn Sandbox, refspec: &str) -> crate::R\n if let Err(e) = sandbox.refresh_push_credentials().await {\n tracing::warn!(\n refspec = %refspec,\n- error = %crate::display_for_log(&e),\n+ error = %crate::display_for_log(&e, &SecretRedactor::default()),\n \"Failed to refresh push credentials before git push\"\n );\n }\n@@ -1274,7 +1289,7 @@ mod tests {\n };\n \n let tail = result\n- .redacted_output_tail(32)\n+ .redacted_output_tail(32, &SecretRedactor::default())\n .expect(\"redacted output tail\");\n let stdout = tail.stdout.expect(\"stdout tail\");\n assert!(stdout.contains(\"REDACTED\"), \"{stdout}\");\n@@ -1282,6 +1297,27 @@ mod tests {\n assert!(tail.stdout_truncated);\n }\n \n+ #[test]\n+ fn exec_result_redacts_registered_low_entropy_secret_before_taking_tail() {\n+ let redactor = SecretRedactor::default();\n+ redactor.register(\"staging\");\n+ let result = ExecResult {\n+ stdout: format!(\"{} staging done\", \"context \".repeat(20)),\n+ stderr: String::new(),\n+ exit_code: Some(1),\n+ termination: CommandTermination::Exited,\n+ duration_ms: 1,\n+ };\n+\n+ let tail = result\n+ .redacted_output_tail(32, &redactor)\n+ .expect(\"redacted output tail\");\n+ let stdout = tail.stdout.expect(\"stdout tail\");\n+ assert!(stdout.contains(\"REDACTED\"), \"{stdout}\");\n+ assert!(!stdout.contains(\"staging\"), \"{stdout}\");\n+ assert!(tail.stdout_truncated);\n+ }\n+\n #[test]\n fn exec_result_tail_sanitizes_terminal_control_sequences() {\n let result = ExecResult {\n@@ -1295,7 +1331,7 @@ mod tests {\n };\n \n let tail = result\n- .redacted_output_tail(1024)\n+ .redacted_output_tail(1024, &SecretRedactor::default())\n .expect(\"redacted output tail\");\n let stdout = tail.stdout.expect(\"stdout tail\");\n assert_eq!(stdout, \"red shown set two-byte backspace\");\n@@ -1338,7 +1374,7 @@ mod tests {\n \n let result = ExecResult::from_process_output(output, 3);\n let tail = result\n- .redacted_output_tail(16)\n+ .redacted_output_tail(16, &SecretRedactor::default())\n .expect(\"redacted output tail\");\n \n assert!(tail.stdout.expect(\"stdout tail\").len() <= 16);\n@@ -1355,7 +1391,9 @@ mod tests {\n duration_ms: 1,\n };\n \n- let tail = result.default_redacted_output_tail().expect(\"tail present\");\n+ let tail = result\n+ .default_redacted_output_tail(&SecretRedactor::default())\n+ .expect(\"tail present\");\n assert_eq!(\n tail.stdout.as_deref().map(str::len),\n Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)\ndiff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs\nindex b6a56bd40..5b8ead6d0 100644\n--- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs\n+++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs\n@@ -27,7 +27,7 @@ pub enum SandboxSpec {\n },\n #[cfg(feature = \"docker\")]\n Docker {\n- config: DockerSandboxOptions,\n+ config: Box<DockerSandboxOptions>,\n github_app: Option<GitHubCredentials>,\n run_id: Option<RunId>,\n clone_origin_url: Option<String>,\n@@ -204,7 +204,7 @@ impl SandboxSpec {\n clone_branch,\n } => {\n let mut sandbox = DockerSandbox::new(\n- config.clone(),\n+ config.as_ref().clone(),\n github_app.clone(),\n *run_id,\n clone_origin_url.clone(),\n@@ -271,7 +271,7 @@ mod tests {\n #[test]\n fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() {\n let spec = SandboxSpec::Docker {\n- config: DockerSandboxOptions::default(),\n+ config: Box::new(DockerSandboxOptions::default()),\n github_app: None,\n run_id: None,\n clone_origin_url: Some(\"git@github.com:brynary/rack-test.git\".to_string()),\n@@ -306,10 +306,10 @@ mod tests {\n #[test]\n fn docker_run_sandbox_omits_primary_repo_metadata_for_empty_workspace() {\n let spec = SandboxSpec::Docker {\n- config: DockerSandboxOptions {\n+ config: Box::new(DockerSandboxOptions {\n skip_clone: true,\n ..DockerSandboxOptions::default()\n- },\n+ }),\n github_app: None,\n run_id: None,\n clone_origin_url: Some(\"https://gitlab.com/acme/widgets\".to_string()),\ndiff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs\nindex d7794be48..fe328c7d3 100644\n--- a/lib/crates/fabro-server/src/run_manifest.rs\n+++ b/lib/crates/fabro-server/src/run_manifest.rs\n@@ -890,7 +890,7 @@ fn preflight_sandbox_spec(\n let mut config = resolve_docker_config(resolved_run);\n config.skip_clone = true;\n SandboxSpec::Docker {\n- config,\n+ config: Box::new(config),\n github_app,\n run_id: None,\n clone_origin_url,\ndiff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs\nindex 95b3a2282..97fd950b0 100644\n--- a/lib/crates/fabro-types/src/settings/run.rs\n+++ b/lib/crates/fabro-types/src/settings/run.rs\n@@ -2169,6 +2169,24 @@ pub enum HookType {\n },\n }\n \n+impl HookType {\n+ /// The fields of this hook type that support `{{ env.* }}` /\n+ /// `{{ secrets.* }}` interpolation at fire time.\n+ ///\n+ /// HTTP `headers` are intentionally excluded: headers only accept\n+ /// allowlisted `{{ env.* }}` tokens and reject secret tokens outright.\n+ pub fn interp_strings(&self) -> impl Iterator<Item = &InterpString> {\n+ let (first, second) = match self {\n+ Self::Command { command } => (command, None),\n+ Self::Http { url, .. } => (url, None),\n+ Self::Prompt { prompt, model } | Self::Agent { prompt, model, .. } => {\n+ (prompt, model.as_ref())\n+ }\n+ };\n+ std::iter::once(first).chain(second)\n+ }\n+}\n+\n #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]\n pub struct HookDefinition {\n pub name: Option<String>,\ndiff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/crates/fabro-workflow/src/error.rs\nindex 241787868..5a70e85a7 100644\n--- a/lib/crates/fabro-workflow/src/error.rs\n+++ b/lib/crates/fabro-workflow/src/error.rs\n@@ -509,8 +509,12 @@ impl Error {\n category: self.failure_category(),\n system_actor: None,\n signature: self.failure_signature_hint(),\n- exec_output_tail: explicit_exec_output_tail\n- .or_else(|| fabro_sandbox::default_redacted_output_tail(self)),\n+ exec_output_tail: explicit_exec_output_tail.or_else(|| {\n+ fabro_sandbox::default_redacted_output_tail(\n+ self,\n+ &fabro_redact::SecretRedactor::default(),\n+ )\n+ }),\n }\n }\n \ndiff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs\nindex e64b2ae30..84f01f752 100644\n--- a/lib/crates/fabro-workflow/src/event.rs\n+++ b/lib/crates/fabro-workflow/src/event.rs\n@@ -16,6 +16,7 @@ pub use self::events::Event;\n pub use self::names::event_name;\n pub use self::redaction::{\n build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json,\n+ redacted_run_event,\n };\n pub use self::sink::{\n RunEventLogger, RunEventSink, StoreProgressLogger, append_event, append_event_to_sink,\ndiff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs\nindex 0b5afb1c7..85707d0a2 100644\n--- a/lib/crates/fabro-workflow/src/event/events.rs\n+++ b/lib/crates/fabro-workflow/src/event/events.rs\n@@ -1330,7 +1330,7 @@ impl Event {\n info!(command_count, \"Setup started\");\n }\n Self::SetupCommandStarted { command, index } => {\n- debug!(command, index, \"Setup command started\");\n+ debug!(command_len = command.len(), index, \"Setup command started\");\n }\n Self::SetupCommandCompleted {\n command,\n@@ -1339,7 +1339,7 @@ impl Event {\n duration_ms,\n } => {\n debug!(\n- command,\n+ command_len = command.len(),\n index, exit_code, duration_ms, \"Setup command completed\"\n );\n }\n@@ -1355,7 +1355,7 @@ impl Event {\n } => {\n let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref());\n error!(\n- command,\n+ command_len = command.len(),\n index,\n exit_code,\n exec_output_tail_present = tail.present,\ndiff --git a/lib/crates/fabro-workflow/src/event/redaction.rs b/lib/crates/fabro-workflow/src/event/redaction.rs\nindex 5ae44d111..7565e64c8 100644\n--- a/lib/crates/fabro-workflow/src/event/redaction.rs\n+++ b/lib/crates/fabro-workflow/src/event/redaction.rs\n@@ -1,26 +1,118 @@\n use ::fabro_types::{RunEvent, RunId};\n use anyhow::{Context, Result};\n-use fabro_redact::redact_json_value;\n+use fabro_redact::{SecretRedactor, redact_json_value};\n use fabro_store::EventPayload;\n use fabro_util::json::normalize_json_value;\n use serde_json::Value;\n \n pub fn build_redacted_event_payload(event: &RunEvent, run_id: &RunId) -> Result<EventPayload> {\n- let value = redacted_event_value(event)?;\n- EventPayload::new(value, run_id).map_err(anyhow::Error::from)\n+ EventPayload::new(redacted_event_value(event)?, run_id).map_err(anyhow::Error::from)\n+}\n+\n+/// Redact an event and reconstruct it as a `RunEvent`.\n+///\n+/// This runs the event through the content-based pass plus the per-run\n+/// exact-match [`SecretRedactor`], then reparses the redacted payload back\n+/// into a `RunEvent` so downstream sinks that require a typed event never see\n+/// the raw value. An empty redactor is an identity for the exact-match pass.\n+pub fn redacted_run_event(event: &RunEvent, redactor: &SecretRedactor) -> Result<RunEvent> {\n+ let mut value = redacted_event_value(event)?;\n+ redact_event_payload_secrets(&mut value, redactor);\n+ let payload = EventPayload::new(value, &event.run_id)?;\n+ RunEvent::try_from(&payload).map_err(anyhow::Error::from)\n }\n \n pub fn redacted_event_json(event: &RunEvent) -> Result<String> {\n serde_json::to_string(&redacted_event_value(event)?).map_err(anyhow::Error::from)\n }\n \n-fn normalized_event_value(event: &RunEvent) -> Result<Value> {\n- let value = event.to_value()?;\n- Ok(normalize_json_value(value))\n+/// Content-based redaction over the normalized event payload. Exact-match\n+/// per-run secret redaction is layered on top by [`redacted_run_event`].\n+fn redacted_event_value(event: &RunEvent) -> Result<Value> {\n+ Ok(redact_json_value(normalize_json_value(event.to_value()?)))\n }\n \n-fn redacted_event_value(event: &RunEvent) -> Result<Value> {\n- Ok(redact_json_value(normalized_event_value(event)?))\n+fn redact_event_payload_secrets(value: &mut Value, redactor: &SecretRedactor) {\n+ // No declared secrets (the common case): skip the recursive property walk\n+ // entirely. Content-based redaction already ran in `redacted_event_value`.\n+ if redactor.is_empty() {\n+ return;\n+ }\n+ if let Some(properties) = value.get_mut(\"properties\") {\n+ redact_redactable_event_properties(properties, redactor);\n+ }\n+ if let Some(Value::String(label)) = value.get_mut(\"node_label\") {\n+ let redacted = redactor.redact_into(label);\n+ if redacted != *label {\n+ *label = redacted;\n+ }\n+ }\n+}\n+\n+fn redact_redactable_event_properties(value: &mut Value, redactor: &SecretRedactor) {\n+ match value {\n+ Value::Object(obj) => {\n+ for (key, child) in obj {\n+ if is_secret_redactable_event_property(key) {\n+ *child = redactor.redact_json(std::mem::take(child));\n+ } else {\n+ redact_redactable_event_properties(child, redactor);\n+ }\n+ }\n+ }\n+ Value::Array(items) => {\n+ for item in items {\n+ redact_redactable_event_properties(item, redactor);\n+ }\n+ }\n+ Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}\n+ }\n+}\n+\n+// Exact-match secret values may be intentionally low entropy (\"staging\",\n+// \"pause\", \"running\"). Redacting every string in an event can therefore corrupt\n+// structural fields that are validated enum values or IDs. Keep this list to\n+// free-form text/blob fields where replacing a matched substring preserves the\n+// event schema and projection semantics.\n+fn is_secret_redactable_event_property(key: &str) -> bool {\n+ matches!(\n+ key,\n+ \"active_form\"\n+ | \"answer\"\n+ | \"arguments\"\n+ | \"causes\"\n+ | \"command\"\n+ | \"context_display\"\n+ | \"delta\"\n+ | \"description\"\n+ | \"details\"\n+ | \"diff\"\n+ | \"error\"\n+ | \"error_message\"\n+ | \"exec_output_tail\"\n+ | \"failure\"\n+ | \"final_patch\"\n+ | \"goal\"\n+ | \"input\"\n+ | \"message\"\n+ | \"notes\"\n+ | \"output\"\n+ | \"preview\"\n+ | \"prompt\"\n+ | \"question\"\n+ | \"reason\"\n+ | \"response\"\n+ | \"script\"\n+ | \"stderr\"\n+ | \"stdout\"\n+ | \"subject\"\n+ | \"text\"\n+ | \"title\"\n+ | \"tool_input\"\n+ | \"tool_output\"\n+ | \"workflow_config\"\n+ | \"workflow_source\"\n+ )\n }\n \n pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result<EventPayload> {\n@@ -72,4 +164,90 @@ mod tests {\n \"plain stderr\"\n );\n }\n+\n+ #[test]\n+ fn redacted_run_event_redacts_registered_low_entropy_secret() {\n+ let redactor = fabro_redact::SecretRedactor::default();\n+ redactor.register(\"staging\");\n+ let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed {\n+ command: \"deploy staging\".to_string(),\n+ index: 0,\n+ exit_code: 1,\n+ stderr: \"failed in staging\".to_string(),\n+ exec_output_tail: None,\n+ });\n+\n+ let redacted = redacted_run_event(&stored, &redactor).unwrap();\n+ let redacted_text = serde_json::to_string(&redacted.to_value().unwrap()).unwrap();\n+\n+ assert!(!redacted_text.contains(\"staging\"));\n+ assert!(redacted_text.contains(\"REDACTED\"));\n+ }\n+\n+ #[test]\n+ fn redacted_run_event_redactors_are_isolated_per_run() {\n+ let first = fabro_redact::SecretRedactor::default();\n+ first.register(\"alpha\");\n+ let second = fabro_redact::SecretRedactor::default();\n+ second.register(\"bravo\");\n+ let stored = to_run_event(&fixtures::RUN_8, &Event::SetupCommandStarted {\n+ command: \"echo alpha bravo\".to_string(),\n+ index: 0,\n+ });\n+\n+ let first_text = serde_json::to_string(\n+ &redacted_run_event(&stored, &first)\n+ .unwrap()\n+ .to_value()\n+ .unwrap(),\n+ )\n+ .unwrap();\n+ let second_text = serde_json::to_string(\n+ &redacted_run_event(&stored, &second)\n+ .unwrap()\n+ .to_value()\n+ .unwrap(),\n+ )\n+ .unwrap();\n+\n+ assert!(!first_text.contains(\"alpha\"));\n+ assert!(first_text.contains(\"bravo\"));\n+ assert!(second_text.contains(\"alpha\"));\n+ assert!(!second_text.contains(\"bravo\"));\n+ }\n+\n+ #[test]\n+ fn redacted_run_event_preserves_structural_event_fields() {\n+ let redactor = fabro_redact::SecretRedactor::default();\n+ redactor.register(\"setup.failed\");\n+ let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed {\n+ command: \"echo setup.failed\".to_string(),\n+ index: 0,\n+ exit_code: 1,\n+ stderr: \"setup.failed\".to_string(),\n+ exec_output_tail: None,\n+ });\n+\n+ let redacted = redacted_run_event(&stored, &redactor).unwrap();\n+\n+ assert_eq!(redacted.event_name(), \"setup.failed\");\n+ let value = redacted.to_value().unwrap();\n+ assert_eq!(value[\"properties\"][\"command\"], \"echo REDACTED\");\n+ assert_eq!(value[\"properties\"][\"stderr\"], \"REDACTED\");\n+ }\n+\n+ #[test]\n+ fn redacted_run_event_preserves_structural_property_values() {\n+ let redactor = fabro_redact::SecretRedactor::default();\n+ redactor.register(\"pause\");\n+ let stored = to_run_event(&fixtures::RUN_8, &Event::RunPauseRequested { actor: None });\n+\n+ let redacted = redacted_run_event(&stored, &redactor).unwrap();\n+\n+ assert_eq!(redacted.event_name(), \"run.pause.requested\");\n+ assert_eq!(\n+ redacted.to_value().unwrap()[\"properties\"][\"action\"],\n+ \"pause\"\n+ );\n+ }\n }\ndiff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs\nindex 1017b2b29..0281a4165 100644\n--- a/lib/crates/fabro-workflow/src/event/sink.rs\n+++ b/lib/crates/fabro-workflow/src/event/sink.rs\n@@ -4,12 +4,13 @@ use std::sync::Arc;\n \n use ::fabro_types::{RunEvent, RunId};\n use anyhow::Result;\n+use fabro_redact::SecretRedactor;\n use fabro_store::RunDatabase;\n use tokio::io::{AsyncWrite, AsyncWriteExt};\n use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot};\n \n use super::emitter::Emitter;\n-use super::redaction::{build_redacted_event_payload, redacted_event_json};\n+use super::redaction::{build_redacted_event_payload, redacted_event_json, redacted_run_event};\n use super::{Event, to_run_event};\n use crate::runtime_store::RunStoreHandle;\n \n@@ -41,6 +42,10 @@ pub enum RunEventSink {\n transform: Arc<RunEventTransform>,\n inner: Box<Self>,\n },\n+ RedactSecrets {\n+ redactor: SecretRedactor,\n+ inner: Box<Self>,\n+ },\n Composite(Vec<Self>),\n }\n \n@@ -99,6 +104,14 @@ impl RunEventSink {\n }\n }\n \n+ #[must_use]\n+ pub fn with_secret_redactor(self, redactor: SecretRedactor) -> Self {\n+ Self::RedactSecrets {\n+ redactor,\n+ inner: Box::new(self),\n+ }\n+ }\n+\n pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> {\n let mut pending = vec![(self, event.clone())];\n while let Some((sink, event)) = pending.pop() {\n@@ -113,10 +126,24 @@ impl RunEventSink {\n writer.write_all(b\"\\n\").await?;\n writer.flush().await?;\n }\n- Self::Callback(callback) => callback(event).await?,\n+ Self::Callback(callback) => {\n+ callback(event).await?;\n+ }\n Self::Map { transform, inner } => {\n pending.push((inner.as_ref(), transform(event)));\n }\n+ Self::RedactSecrets { redactor, inner } => {\n+ // Redact once at the wrapper so every inner sink sees the\n+ // same already-redacted event. With no declared secrets\n+ // (the common case) the event passes through untouched and\n+ // leaves apply their own content-based redaction as usual.\n+ let event = if redactor.is_empty() {\n+ event\n+ } else {\n+ redacted_run_event(&event, redactor)?\n+ };\n+ pending.push((inner.as_ref(), event));\n+ }\n Self::Composite(sinks) => {\n for sink in sinks.iter().rev() {\n pending.push((sink, event.clone()));\n@@ -334,6 +361,34 @@ mod tests {\n assert_eq!(second[0].actor, Some(user_principal(\"alice\")));\n }\n \n+ #[tokio::test]\n+ async fn run_event_sink_redacts_callback_events_with_run_secret_redactor() {\n+ let captured = Arc::new(AsyncMutex::new(Vec::new()));\n+ let captured_events = Arc::clone(&captured);\n+ let redactor = fabro_redact::SecretRedactor::default();\n+ redactor.register(\"staging\");\n+ let sink = RunEventSink::callback(move |event| {\n+ let captured_events = Arc::clone(&captured_events);\n+ async move {\n+ captured_events.lock().await.push(event);\n+ Ok(())\n+ }\n+ })\n+ .with_secret_redactor(redactor);\n+ let event = to_run_event(&fixtures::RUN_7, &Event::SetupCommandStarted {\n+ command: \"deploy staging\".to_string(),\n+ index: 0,\n+ });\n+\n+ sink.write_run_event(&event).await.unwrap();\n+\n+ let captured = captured.lock().await;\n+ assert_eq!(captured.len(), 1);\n+ let captured_text = serde_json::to_string(&captured[0].to_value().unwrap()).unwrap();\n+ assert!(!captured_text.contains(\"staging\"));\n+ assert!(captured_text.contains(\"REDACTED\"));\n+ }\n+\n #[tokio::test]\n async fn run_event_logger_registers_emitter_events_to_json_lines() {\n use tokio::io::{AsyncBufReadExt, BufReader};\ndiff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs\nindex 108a72369..ff80de2c0 100644\n--- a/lib/crates/fabro-workflow/src/handler/parallel.rs\n+++ b/lib/crates/fabro-workflow/src/handler/parallel.rs\n@@ -205,14 +205,20 @@ impl Handler for ParallelHandler {\n }\n Err(e) => {\n tracing::warn!(\n- error = %fabro_sandbox::display_for_log(&e),\n+ error = %fabro_sandbox::display_for_log(\n+ &e,\n+ &services.run.secret_redactor,\n+ ),\n \"parallel base checkpoint failed\"\n );\n services.run.emitter.notice_with_tail(\n RunNoticeLevel::Warn,\n RunNoticeCode::ParallelBaseCheckpointFailed,\n format!(\"Could not checkpoint base state before parallel branches: {e}\"),\n- fabro_sandbox::default_redacted_output_tail(&e),\n+ fabro_sandbox::default_redacted_output_tail(\n+ &e,\n+ &services.run.secret_redactor,\n+ ),\n );\n None\n }\ndiff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs\nindex 5133fa602..82825e5d3 100644\n--- a/lib/crates/fabro-workflow/src/lifecycle/git.rs\n+++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs\n@@ -8,6 +8,7 @@ use fabro_core::lifecycle::RunLifecycle;\n use fabro_core::outcome::NodeResult;\n use fabro_core::state::ExecutionState;\n use fabro_dump::RunDump;\n+use fabro_redact::SecretRedactor;\n use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};\n use fabro_types::{CheckpointRecord, DiffSummary, RunDiff, RunId};\n use fabro_util::error::collect_causes;\n@@ -84,6 +85,7 @@ pub(crate) struct GitLifecycle {\n pub sandbox_git: Arc<SandboxGitRuntime>,\n pub metadata_runtime: Arc<RunMetadataRuntime>,\n pub metadata_writer: Option<RunMetadataWriterHandle>,\n+ pub secret_redactor: SecretRedactor,\n pub start_node_id: Option<String>,\n // Cross-lifecycle data (shared with EventLifecycle)\n pub checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>>,\n@@ -311,10 +313,16 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {\n Ok(()) => (true, None),\n Err(err) => {\n let exec_output_tail =\n- fabro_sandbox::default_redacted_output_tail(&err);\n+ fabro_sandbox::default_redacted_output_tail(\n+ &err,\n+ &self.secret_redactor,\n+ );\n tracing::warn!(\n refspec = %refspec,\n- error = %fabro_sandbox::display_for_log(&err),\n+ error = %fabro_sandbox::display_for_log(\n+ &err,\n+ &self.secret_redactor,\n+ ),\n \"git push from run lifecycle failed\"\n );\n self.emitter.notice_with_tail(\n@@ -364,8 +372,10 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {\n }\n Ok(_) => {}\n Err(err) => {\n- let exec_output_tail =\n- fabro_sandbox::default_redacted_output_tail(&err);\n+ let exec_output_tail = fabro_sandbox::default_redacted_output_tail(\n+ &err,\n+ &self.secret_redactor,\n+ );\n self.emitter.notice_with_tail(\n RunNoticeLevel::Warn,\n RunNoticeCode::GitDiffFailed,\n@@ -379,8 +389,10 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {\n git_result.diff_summary = Some(summarize_diff_numstat(&numstat));\n }\n Some(Err(err)) => {\n- let exec_output_tail =\n- fabro_sandbox::default_redacted_output_tail(&err);\n+ let exec_output_tail = fabro_sandbox::default_redacted_output_tail(\n+ &err,\n+ &self.secret_redactor,\n+ );\n self.emitter.notice_with_tail(\n RunNoticeLevel::Warn,\n RunNoticeCode::GitDiffFailed,\n@@ -399,7 +411,8 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {\n .expect(\"git lifecycle mutex should not be poisoned: no code panics while holding this lock\") = Some(git_result);\n }\n Err(e) => {\n- let exec_output_tail = fabro_sandbox::default_redacted_output_tail(&e);\n+ let exec_output_tail =\n+ fabro_sandbox::default_redacted_output_tail(&e, &self.secret_redactor);\n let error = e.to_string();\n // Emit CheckpointFailed and return error\n let scope = stage_scope_for(state, node_id);\n@@ -797,6 +810,7 @@ mod tests {\n sandbox_git: Arc::new(SandboxGitRuntime::new()),\n metadata_runtime,\n metadata_writer,\n+ secret_redactor: SecretRedactor::default(),\n start_node_id: Some(\"start\".to_string()),\n checkpoint_git_result: Arc::new(Mutex::new(None)),\n last_git_sha: Arc::new(Mutex::new(None)),\n@@ -1260,6 +1274,7 @@ mod tests {\n \"claude-sonnet-4-6\".to_string(),\n Arc::new(fabro_auth::EnvCredentialSource::new()),\n Arc::new(Catalog::from_builtin().expect(\"default catalog should build\")),\n+ fabro_redact::SecretRedactor::default(),\n Arc::new(SandboxGitRuntime::new()),\n Arc::clone(&lifecycle.metadata_runtime),\n lifecycle.metadata_writer.clone(),\ndiff --git a/lib/crates/fabro-workflow/src/lifecycle/mod.rs b/lib/crates/fabro-workflow/src/lifecycle/mod.rs\nindex 2db1e8d75..97b46a5a8 100644\n--- a/lib/crates/fabro-workflow/src/lifecycle/mod.rs\n+++ b/lib/crates/fabro-workflow/src/lifecycle/mod.rs\n@@ -94,6 +94,7 @@ impl WorkflowLifecycle {\n sandbox_git: Arc<SandboxGitRuntime>,\n metadata_runtime: Arc<RunMetadataRuntime>,\n metadata_writer: Option<RunMetadataWriterHandle>,\n+ secret_redactor: fabro_redact::SecretRedactor,\n is_resume: bool,\n on_node: crate::OnNodeCallback,\n run_control: Option<Arc<RunControlState>>,\n@@ -161,6 +162,7 @@ impl WorkflowLifecycle {\n sandbox_git,\n metadata_runtime,\n metadata_writer,\n+ secret_redactor,\n start_node_id,\n checkpoint_git_result: Arc::clone(&checkpoint_git_result),\n last_git_sha,\ndiff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs\nindex 7fa47e938..463a55609 100644\n--- a/lib/crates/fabro-workflow/src/operations/start.rs\n+++ b/lib/crates/fabro-workflow/src/operations/start.rs\n@@ -8,6 +8,7 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer};\n use fabro_llm::client::Client as LlmClient;\n use fabro_mcp::config::McpServerSettings;\n use fabro_model::{Catalog, FallbackTarget, ProviderId};\n+use fabro_redact::SecretRedactor;\n use fabro_sandbox::daytona::DaytonaConfig;\n use fabro_sandbox::from_environment::{\n daytona_config_from_environment, docker_config_from_environment_with_secrets,\n@@ -79,6 +80,8 @@ struct RunSession {\n workflow_bundle: Option<Arc<WorkflowBundle>>,\n run_control: Option<Arc<RunControlState>>,\n vault: Option<Arc<AsyncRwLock<Vault>>>,\n+ secret_redactor: SecretRedactor,\n+ hook_secrets: fabro_hooks::HookSecretResolver,\n catalog: Arc<Catalog>,\n fabro_run_tools: Option<FabroRunToolServices>,\n }\n@@ -252,10 +255,13 @@ pub(super) async fn execute_persisted_run(\n };\n \n bootstrap_guard.defuse();\n+ let terminal_event_sink = event_sink\n+ .clone()\n+ .with_secret_redactor(session.secret_redactor.clone());\n let mut completion_guard = DetachedRunCompletionGuard::arm(\n run_id,\n run_store.clone(),\n- event_sink.clone(),\n+ terminal_event_sink.clone(),\n cancel_token,\n );\n let run_start = Instant::now();\n@@ -270,7 +276,7 @@ pub(super) async fn execute_persisted_run(\n persist_terminal_engine_failure(\n run_id,\n &run_store,\n- &event_sink,\n+ &terminal_event_sink,\n run_dir,\n &err,\n run_start.elapsed(),\n@@ -377,10 +383,14 @@ impl RunSession {\n Some(vault) => Some(vault.read().await),\n None => None,\n };\n+ let secret_redactor = SecretRedactor::default();\n+ let hook_secrets = hook_secret_resolver(services.vault.clone(), secret_redactor.clone());\n // Token-only secrets lookup over the vault read guard, shared across\n // every run-boundary resolver. A missing or non-Token secret becomes\n // `None`, so resolution fails closed with a secret error.\n- let secret_lookup = |name: &str| vault_token_lookup(vault_guard.as_deref(), name);\n+ let secret_lookup = |name: &str| {\n+ registered_vault_token_lookup(vault_guard.as_deref(), &secret_redactor, name)\n+ };\n let mcp_servers = resolved\n .agent\n .mcps\n@@ -419,7 +429,7 @@ impl RunSession {\n SandboxSpec::Local { working_directory }\n }\n SandboxProviderKind::Docker => SandboxSpec::Docker {\n- config: resolve_docker_config(resolved, secret_lookup)?,\n+ config: Box::new(resolve_docker_config(resolved, secret_lookup)?),\n github_app: services.github_app.clone(),\n run_id: Some(record.run_id),\n clone_origin_url: record.repo_origin_url().map(str::to_string),\n@@ -505,6 +515,8 @@ impl RunSession {\n workflow_path,\n workflow_bundle,\n vault: services.vault,\n+ secret_redactor,\n+ hook_secrets,\n catalog,\n fabro_run_tools: services.fabro_run_tools,\n })\n@@ -566,6 +578,34 @@ fn vault_token_lookup(vault: Option<&Vault>, name: &str) -> Option<String> {\n vault.and_then(|vault| fabro_auth::vault_get_token(vault, name).ok().flatten())\n }\n \n+fn registered_vault_token_lookup(\n+ vault: Option<&Vault>,\n+ redactor: &SecretRedactor,\n+ name: &str,\n+) -> Option<String> {\n+ let value = vault_token_lookup(vault, name);\n+ if let Some(value) = value.as_deref() {\n+ redactor.register(value);\n+ }\n+ value\n+}\n+\n+fn hook_secret_resolver(\n+ vault: Option<Arc<AsyncRwLock<Vault>>>,\n+ redactor: SecretRedactor,\n+) -> fabro_hooks::HookSecretResolver {\n+ match vault {\n+ Some(vault) => fabro_hooks::HookSecretResolver::with_lookup(redactor, move |name| {\n+ let vault = Arc::clone(&vault);\n+ async move {\n+ let guard = vault.read().await;\n+ vault_token_lookup(Some(&guard), &name)\n+ }\n+ }),\n+ None => fabro_hooks::HookSecretResolver::new(redactor),\n+ }\n+}\n+\n async fn load_accepted_run_definition(\n run_store: &RunStoreHandle,\n blob_id: fabro_types::RunBlobId,\n@@ -827,7 +867,11 @@ impl RunSession {\n });\n }\n \n- let store_progress_logger = RunEventLogger::new(self.event_sink.clone());\n+ let store_progress_logger = RunEventLogger::new(\n+ self.event_sink\n+ .clone()\n+ .with_secret_redactor(self.secret_redactor.clone()),\n+ );\n store_progress_logger.register(self.emitter.as_ref());\n \n let init_options = InitOptions {\n@@ -845,8 +889,10 @@ impl RunSession {\n workflow_path: self.workflow_path,\n workflow_bundle: self.workflow_bundle,\n hooks: self.hooks,\n+ hook_secrets: self.hook_secrets,\n sandbox_env: self.sandbox_env,\n vault: self.vault,\n+ secret_redactor: self.secret_redactor,\n git: self.git,\n registry_override: self.registry_override,\n artifact_sink: self.artifact_sink,\n@@ -855,7 +901,13 @@ impl RunSession {\n seed_context: self.seed_context,\n fabro_run_tools: self.fabro_run_tools,\n };\n- let mut initialized = Box::pin(pipeline::initialize(persisted, init_options)).await?;\n+ let mut initialized = match Box::pin(pipeline::initialize(persisted, init_options)).await {\n+ Ok(initialized) => initialized,\n+ Err(err) => {\n+ store_progress_logger.flush().await;\n+ return Err(err);\n+ }\n+ };\n initialized.on_node = on_node;\n \n let sandbox_for_cleanup = Arc::clone(&initialized.engine.run.sandbox);\n@@ -1125,8 +1177,8 @@ mod tests {\n };\n use fabro_store::Database;\n use fabro_types::settings::run::{\n- McpTransport as ResolvedMcpTransport, PreparedStep, PreparedStepRun, RunMode,\n- RunPrepareSettings,\n+ HookDefinition, HookEvent, HookType, McpTransport as ResolvedMcpTransport, PreparedStep,\n+ PreparedStepRun, RunMode, RunPrepareSettings, TlsMode,\n };\n use fabro_types::settings::{InterpString, ModelRef};\n use fabro_types::{\n@@ -1568,6 +1620,49 @@ reasoning = false\n );\n }\n \n+ #[tokio::test]\n+ async fn run_session_secret_redactors_are_isolated_between_runs() {\n+ async fn session_with_secret(secret_value: &str) -> RunSession {\n+ let temp = tempfile::tempdir().unwrap();\n+ let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);\n+ let mut settings = settings_from_run_layer(RunLayer {\n+ execution: Some(RunExecutionLayer {\n+ mode: Some(RunMode::DryRun),\n+ ..RunExecutionLayer::default()\n+ }),\n+ ..RunLayer::default()\n+ });\n+ settings.run.environment.env.insert(\n+ \"DEPLOY_ENV\".to_string(),\n+ InterpString::parse(\"{{ secrets.DEPLOY_ENV }}\"),\n+ );\n+ let (persisted, store) =\n+ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;\n+ let emitter = Arc::new(Emitter::new(fixtures::RUN_1));\n+ let registry = Arc::new(test_registry());\n+ let vault = Arc::new(AsyncRwLock::new(token_vault(\"DEPLOY_ENV\", secret_value)));\n+\n+ RunSession::new(&persisted, StartServices {\n+ vault: Some(vault),\n+ ..test_start_services(&store, &storage_root, emitter, registry).await\n+ })\n+ .await\n+ .unwrap()\n+ }\n+\n+ let first = session_with_secret(\"alpha\").await;\n+ let second = session_with_secret(\"bravo\").await;\n+\n+ assert_eq!(\n+ first.secret_redactor.redact_into(\"alpha bravo\"),\n+ \"REDACTED bravo\"\n+ );\n+ assert_eq!(\n+ second.secret_redactor.redact_into(\"alpha bravo\"),\n+ \"alpha REDACTED\"\n+ );\n+ }\n+\n #[tokio::test]\n async fn run_session_new_missing_secret_fails_startup() {\n let temp = tempfile::tempdir().unwrap();\n@@ -1605,6 +1700,218 @@ reasoning = false\n assert!(err.causes()[0].contains(\"DEPLOY_TOKEN\"));\n }\n \n+ #[tokio::test]\n+ async fn setup_failure_redacts_low_entropy_secret_in_event_and_error() {\n+ let temp = tempfile::tempdir().unwrap();\n+ let (storage_root, run_dir) = storage_root_and_run_dir(&temp);\n+ let mut settings = settings_from_run_layer(RunLayer {\n+ execution: Some(RunExecutionLayer {\n+ mode: Some(RunMode::DryRun),\n+ ..RunExecutionLayer::default()\n+ }),\n+ ..RunLayer::default()\n+ });\n+ settings.run.prepare = prepare_with_step(script_step(\n+ \"echo {{ secrets.DEPLOY_ENV }} >&2; exit 7\",\n+ HashMap::new(),\n+ ));\n+ let (_persisted, store) =\n+ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;\n+ let emitter = Arc::new(Emitter::new(fixtures::RUN_1));\n+ let registry = Arc::new(test_registry());\n+ let vault = Arc::new(AsyncRwLock::new(token_vault(\"DEPLOY_ENV\", \"staging\")));\n+\n+ let Err(err) = start(&run_dir, StartServices {\n+ vault: Some(vault),\n+ ..test_start_services(&store, &storage_root, emitter, registry).await\n+ })\n+ .await\n+ else {\n+ panic!(\"setup failure should fail the run\");\n+ };\n+\n+ let error_text = err.to_string();\n+ assert!(!error_text.contains(\"staging\"));\n+ assert!(error_text.contains(\"REDACTED\"));\n+\n+ let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();\n+ let events = run_store.list_events().await.unwrap();\n+ let events_text = serde_json::to_string(&events).unwrap();\n+ assert!(!events_text.contains(\"staging\"));\n+ assert!(events_text.contains(\"REDACTED\"));\n+ assert!(\n+ events\n+ .iter()\n+ .any(|event| event.event.event_name() == \"setup.failed\")\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn hook_command_resolves_secret_from_vault() {\n+ let temp = tempfile::tempdir().unwrap();\n+ let (storage_root, run_dir) = storage_root_and_run_dir(&temp);\n+ let mut settings = settings_from_run_layer(RunLayer {\n+ execution: Some(RunExecutionLayer {\n+ mode: Some(RunMode::DryRun),\n+ ..RunExecutionLayer::default()\n+ }),\n+ ..RunLayer::default()\n+ });\n+ settings.run.hooks.push(sandbox_ready_command_hook(\n+ \"test \\\"{{ secrets.HOOK_TOKEN }}\\\" = staging\",\n+ ));\n+ let (_persisted, store) =\n+ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;\n+ let emitter = Arc::new(Emitter::new(fixtures::RUN_1));\n+ let registry = Arc::new(test_registry());\n+ let vault = Arc::new(AsyncRwLock::new(token_vault(\"HOOK_TOKEN\", \"staging\")));\n+\n+ start(&run_dir, StartServices {\n+ vault: Some(vault),\n+ ..test_start_services(&store, &storage_root, emitter, registry).await\n+ })\n+ .await\n+ .expect(\"hook command should resolve secret and proceed\");\n+ }\n+\n+ #[tokio::test]\n+ async fn hook_missing_secret_fails_closed() {\n+ let temp = tempfile::tempdir().unwrap();\n+ let (storage_root, run_dir) = storage_root_and_run_dir(&temp);\n+ let mut settings = settings_from_run_layer(RunLayer {\n+ execution: Some(RunExecutionLayer {\n+ mode: Some(RunMode::DryRun),\n+ ..RunExecutionLayer::default()\n+ }),\n+ ..RunLayer::default()\n+ });\n+ settings\n+ .run\n+ .hooks\n+ .push(sandbox_ready_command_hook(\"echo {{ secrets.HOOK_TOKEN }}\"));\n+ let (_persisted, store) =\n+ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;\n+ let emitter = Arc::new(Emitter::new(fixtures::RUN_1));\n+ let registry = Arc::new(test_registry());\n+ let vault = Arc::new(AsyncRwLock::new(temp_vault(&[])));\n+\n+ let Err(err) = start(&run_dir, StartServices {\n+ vault: Some(vault),\n+ ..test_start_services(&store, &storage_root, emitter, registry).await\n+ })\n+ .await\n+ else {\n+ panic!(\"missing hook secret should fail the run\");\n+ };\n+\n+ assert!(err.to_string().contains(\"HOOK_TOKEN\"));\n+ }\n+\n+ #[tokio::test]\n+ async fn hook_http_url_resolves_secret_from_vault() {\n+ let server = httpmock::MockServer::start_async().await;\n+ let mock = server\n+ .mock_async(|when, then| {\n+ when.method(\"POST\").path(\"/hook\");\n+ then.status(200).body(\"\");\n+ })\n+ .await;\n+ let temp = tempfile::tempdir().unwrap();\n+ let (storage_root, run_dir) = storage_root_and_run_dir(&temp);\n+ let mut settings = settings_from_run_layer(RunLayer {\n+ execution: Some(RunExecutionLayer {\n+ mode: Some(RunMode::DryRun),\n+ ..RunExecutionLayer::default()\n+ }),\n+ ..RunLayer::default()\n+ });\n+ settings\n+ .run\n+ .hooks\n+ .push(sandbox_ready_http_hook(\"{{ secrets.HOOK_URL }}\"));\n+ let (_persisted, store) =\n+ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;\n+ let emitter = Arc::new(Emitter::new(fixtures::RUN_1));\n+ let registry = Arc::new(test_registry());\n+ let vault = Arc::new(AsyncRwLock::new(token_vault(\n+ \"HOOK_URL\",\n+ &server.url(\"/hook\"),\n+ )));\n+\n+ start(&run_dir, StartServices {\n+ vault: Some(vault),\n+ ..test_start_services(&store, &storage_root, emitter, registry).await\n+ })\n+ .await\n+ .expect(\"HTTP hook URL should resolve secret and proceed\");\n+\n+ mock.assert_async().await;\n+ }\n+\n+ #[tokio::test]\n+ async fn hook_prompt_secret_resolver_resolves_from_vault_and_registers() {\n+ let redactor = SecretRedactor::default();\n+ let vault = Arc::new(AsyncRwLock::new(token_vault(\"PROMPT_TOKEN\", \"staging\")));\n+ let resolver = hook_secret_resolver(Some(vault), redactor.clone());\n+ let hook = HookDefinition {\n+ name: Some(\"prompt-secret\".to_string()),\n+ event: HookEvent::StageStart,\n+ command: None,\n+ hook_type: Some(HookType::Prompt {\n+ prompt: InterpString::parse(\"check {{ secrets.PROMPT_TOKEN }}\"),\n+ model: None,\n+ }),\n+ matcher: None,\n+ blocking: Some(true),\n+ timeout_ms: None,\n+ sandbox: None,\n+ };\n+\n+ let secrets = resolver.resolve_for_definition(&hook).await;\n+\n+ assert_eq!(secrets.lookup(\"PROMPT_TOKEN\").as_deref(), Some(\"staging\"));\n+ assert_eq!(redactor.redact_into(\"deploy staging\"), \"deploy REDACTED\");\n+ }\n+\n+ #[tokio::test]\n+ async fn hook_block_reason_redacts_resolved_secret_in_error_and_events() {\n+ let temp = tempfile::tempdir().unwrap();\n+ let (storage_root, run_dir) = storage_root_and_run_dir(&temp);\n+ let mut settings = settings_from_run_layer(RunLayer {\n+ execution: Some(RunExecutionLayer {\n+ mode: Some(RunMode::DryRun),\n+ ..RunExecutionLayer::default()\n+ }),\n+ ..RunLayer::default()\n+ });\n+ settings.run.hooks.push(sandbox_ready_command_hook(\n+ r#\"printf '%s' '{\"decision\":\"block\",\"reason\":\"{{ secrets.HOOK_TOKEN }}\"}'\"#,\n+ ));\n+ let (_persisted, store) =\n+ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;\n+ let emitter = Arc::new(Emitter::new(fixtures::RUN_1));\n+ let registry = Arc::new(test_registry());\n+ let vault = Arc::new(AsyncRwLock::new(token_vault(\"HOOK_TOKEN\", \"staging\")));\n+\n+ let Err(err) = start(&run_dir, StartServices {\n+ vault: Some(vault),\n+ ..test_start_services(&store, &storage_root, emitter, registry).await\n+ })\n+ .await\n+ else {\n+ panic!(\"blocking hook should fail the run\");\n+ };\n+\n+ let error_text = err.to_string();\n+ assert!(!error_text.contains(\"staging\"));\n+ assert!(error_text.contains(\"REDACTED\"));\n+\n+ let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();\n+ let events_text = serde_json::to_string(&run_store.list_events().await.unwrap()).unwrap();\n+ assert!(!events_text.contains(\"staging\"));\n+ assert!(events_text.contains(\"REDACTED\"));\n+ }\n+\n #[test]\n fn runtime_docker_config_maps_environment_hints() {\n let settings = settings_from_run_layer(RunLayer {\n@@ -1806,6 +2113,37 @@ reasoning = false\n }\n }\n \n+ fn sandbox_ready_command_hook(command: &str) -> HookDefinition {\n+ HookDefinition {\n+ name: Some(\"sandbox-ready\".to_string()),\n+ event: HookEvent::SandboxReady,\n+ command: Some(InterpString::parse(command)),\n+ hook_type: None,\n+ matcher: None,\n+ blocking: Some(true),\n+ timeout_ms: Some(5_000),\n+ sandbox: Some(false),\n+ }\n+ }\n+\n+ fn sandbox_ready_http_hook(url: &str) -> HookDefinition {\n+ HookDefinition {\n+ name: Some(\"sandbox-ready-http\".to_string()),\n+ event: HookEvent::SandboxReady,\n+ command: None,\n+ hook_type: Some(HookType::Http {\n+ url: InterpString::parse(url),\n+ headers: None,\n+ allowed_env_vars: Vec::new(),\n+ tls: TlsMode::Off,\n+ }),\n+ matcher: None,\n+ blocking: Some(true),\n+ timeout_ms: Some(5_000),\n+ sandbox: Some(false),\n+ }\n+ }\n+\n use crate::test_support::{mark_run_running, test_usage};\n \n async fn append_completed_stage(\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/execute.rs b/lib/crates/fabro-workflow/src/pipeline/execute.rs\nindex 4a82a509c..f84b03e7c 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/execute.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/execute.rs\n@@ -89,6 +89,7 @@ pub async fn execute(init: Initialized) -> Executed {\n Arc::clone(&engine.run.sandbox_git),\n Arc::clone(&engine.run.metadata_runtime),\n engine.run.metadata_writer.clone(),\n+ engine.run.secret_redactor.clone(),\n checkpoint.is_some(),\n on_node,\n run_control,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\nindex e2b96ed37..09b9a4f6d 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\n@@ -282,12 +282,14 @@ async fn execute_test_run_with_options(\n workflow_path: None,\n workflow_bundle: None,\n hooks: HookSettings { hooks: vec![] },\n+ hook_secrets: fabro_hooks::HookSecretResolver::default(),\n sandbox_env: SandboxEnvSpec {\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n+ secret_redactor: fabro_redact::SecretRedactor::default(),\n git: git_options,\n run_control: None,\n registry_override,\n@@ -345,12 +347,14 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {\n workflow_path: None,\n workflow_bundle: None,\n hooks: HookSettings { hooks: vec![] },\n+ hook_secrets: fabro_hooks::HookSecretResolver::default(),\n sandbox_env: SandboxEnvSpec {\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n+ secret_redactor: fabro_redact::SecretRedactor::default(),\n git: None,\n run_control: None,\n registry_override: None,\n@@ -416,12 +420,14 @@ async fn run_with_lifecycle(\n workflow_path: None,\n workflow_bundle: None,\n hooks: HookSettings { hooks: vec![] },\n+ hook_secrets: fabro_hooks::HookSecretResolver::default(),\n sandbox_env: SandboxEnvSpec {\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n+ secret_redactor: fabro_redact::SecretRedactor::default(),\n git: None,\n run_control: None,\n registry_override: Some(Arc::new(registry)),\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs\nindex 6e90991dc..805e8e0e4 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs\n@@ -615,8 +615,15 @@ pub async fn finalize(executed: Executed, options: &FinalizeOptions) -> Result<C\n )\n .await\n {\n- tracing::warn!(error = %fabro_sandbox::display_for_log(&e), \"Sandbox stop failed\");\n- let exec_output_tail = fabro_sandbox::default_redacted_output_tail(&e);\n+ tracing::warn!(\n+ error = %fabro_sandbox::display_for_log(\n+ &e,\n+ &services.secret_redactor,\n+ ),\n+ \"Sandbox stop failed\"\n+ );\n+ let exec_output_tail =\n+ fabro_sandbox::default_redacted_output_tail(&e, &services.secret_redactor);\n services.emitter.notice_with_tail(\n RunNoticeLevel::Warn,\n RunNoticeCode::SandboxCleanupFailed,\n@@ -1021,6 +1028,7 @@ mod tests {\n \"claude-sonnet-4-6\".to_string(),\n Arc::new(fabro_auth::EnvCredentialSource::new()),\n Arc::new(Catalog::from_builtin().expect(\"default catalog should build\")),\n+ fabro_redact::SecretRedactor::default(),\n Arc::new(SandboxGitRuntime::new()),\n metadata_runtime,\n metadata_writer,\n@@ -1053,6 +1061,7 @@ mod tests {\n \"claude-sonnet-4-6\".to_string(),\n Arc::new(fabro_auth::EnvCredentialSource::new()),\n Arc::new(Catalog::from_builtin().expect(\"default catalog should build\")),\n+ fabro_redact::SecretRedactor::default(),\n Arc::new(SandboxGitRuntime::new()),\n Arc::new(RunMetadataRuntime::new()),\n None,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\nindex 54a6f1f45..8608f9a51 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n@@ -290,6 +290,7 @@ pub async fn initialize(\n options.hooks.clone(),\n Arc::clone(&llm_source),\n Arc::clone(&catalog),\n+ options.hook_secrets.clone(),\n )))\n };\n \n@@ -551,7 +552,8 @@ pub async fn initialize(\n let duration_ms = crate::millis_u64(cmd_start.elapsed());\n if !result.is_success() {\n let exit_code = result.display_exit_code();\n- let exec_output_tail = result.default_redacted_output_tail();\n+ let exec_output_tail =\n+ result.default_redacted_output_tail(&options.secret_redactor);\n options.emitter.emit(&Event::SetupFailed {\n command: command.clone(),\n index,\n@@ -559,10 +561,11 @@ pub async fn initialize(\n stderr: result.stderr.clone(),\n exec_output_tail,\n });\n- return Err(Error::engine(format!(\n+ let message = format!(\n \"Setup command failed (exit code {}): {command}\\n{}\",\n exit_code, result.stderr,\n- )));\n+ );\n+ return Err(Error::engine(options.secret_redactor.redact_into(&message)));\n }\n let exit_code = result.exit_code.unwrap_or(0);\n options.emitter.emit(&Event::SetupCommandCompleted {\n@@ -603,6 +606,7 @@ pub async fn initialize(\n options.llm.model.clone(),\n Arc::clone(&llm_source),\n catalog,\n+ options.secret_redactor.clone(),\n sandbox_git,\n metadata_runtime,\n metadata_writer,\n@@ -842,12 +846,14 @@ mod tests {\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n+ hook_secrets: fabro_hooks::HookSecretResolver::default(),\n sandbox_env: SandboxEnvSpec {\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n+ secret_redactor: fabro_redact::SecretRedactor::default(),\n git: None,\n run_control: None,\n registry_override: None,\n@@ -924,12 +930,14 @@ mod tests {\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n+ hook_secrets: fabro_hooks::HookSecretResolver::default(),\n sandbox_env: SandboxEnvSpec {\n toml_env: HashMap::from([(\"TEST_KEY\".to_string(), \"value\".to_string())]),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n+ secret_redactor: fabro_redact::SecretRedactor::default(),\n git: None,\n run_control: None,\n registry_override: None,\n@@ -1122,12 +1130,14 @@ mod tests {\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n+ hook_secrets: fabro_hooks::HookSecretResolver::default(),\n sandbox_env: SandboxEnvSpec {\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: Some(vault),\n+ secret_redactor: fabro_redact::SecretRedactor::default(),\n git: None,\n run_control: None,\n registry_override: None,\n@@ -1218,12 +1228,14 @@ mod tests {\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n+ hook_secrets: fabro_hooks::HookSecretResolver::default(),\n sandbox_env: SandboxEnvSpec {\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n+ secret_redactor: fabro_redact::SecretRedactor::default(),\n git: None,\n run_control: None,\n registry_override: None,\n@@ -1361,12 +1373,14 @@ mod tests {\n workflow_path: None,\n workflow_bundle: None,\n hooks: fabro_hooks::HookSettings { hooks: vec![] },\n+ hook_secrets: fabro_hooks::HookSecretResolver::default(),\n sandbox_env: SandboxEnvSpec {\n toml_env: HashMap::new(),\n github_permissions: None,\n origin_url: None,\n },\n vault: None,\n+ secret_redactor: fabro_redact::SecretRedactor::default(),\n git: None,\n run_control: None,\n registry_override: None,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs\nindex 8449453a8..112897c75 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/types.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/types.rs\n@@ -6,6 +6,7 @@ use fabro_graphviz::graph::Graph;\n use fabro_interview::Interviewer;\n use fabro_mcp::config::McpServerSettings;\n use fabro_model::{Catalog, FallbackTarget, ProviderId};\n+use fabro_redact::SecretRedactor;\n use fabro_sandbox::SandboxSpec;\n use fabro_template::TemplateContext;\n use fabro_types::settings::run::{PullRequestSettings, RunModelControls};\n@@ -263,8 +264,10 @@ pub struct InitOptions {\n pub workflow_path: Option<ManifestPath>,\n pub workflow_bundle: Option<Arc<WorkflowBundle>>,\n pub hooks: fabro_hooks::HookSettings,\n+ pub hook_secrets: fabro_hooks::HookSecretResolver,\n pub sandbox_env: SandboxEnvSpec,\n pub vault: Option<Arc<AsyncRwLock<Vault>>>,\n+ pub secret_redactor: SecretRedactor,\n pub git: Option<GitCheckpointOptions>,\n pub registry_override: Option<Arc<HandlerRegistry>>,\n pub artifact_sink: Option<ArtifactSink>,\ndiff --git a/lib/crates/fabro-workflow/src/sandbox_git.rs b/lib/crates/fabro-workflow/src/sandbox_git.rs\nindex b746e2f89..f50142132 100644\n--- a/lib/crates/fabro-workflow/src/sandbox_git.rs\n+++ b/lib/crates/fabro-workflow/src/sandbox_git.rs\n@@ -1075,7 +1075,11 @@ mod tests {\n \n assert_eq!(err.to_string(), \"git add timed out after 77ms\");\n assert!(\n- fabro_sandbox::default_redacted_output_tail(&err).is_none(),\n+ fabro_sandbox::default_redacted_output_tail(\n+ &err,\n+ &fabro_redact::SecretRedactor::default()\n+ )\n+ .is_none(),\n \"empty exec streams should not produce a tail\"\n );\n }\n@@ -1109,7 +1113,11 @@ mod tests {\n \"expected sandbox git context, got {chain:#?}\"\n );\n assert!(\n- fabro_sandbox::default_redacted_output_tail(&err).is_some(),\n+ fabro_sandbox::default_redacted_output_tail(\n+ &err,\n+ &fabro_redact::SecretRedactor::default()\n+ )\n+ .is_some(),\n \"expected probe exec output tail to survive SharedError wrapping\"\n );\n }\n@@ -1245,7 +1253,11 @@ mod tests {\n assert_eq!(err.to_string(), \"git diff failed (exit 128)\");\n assert!(!err.to_string().contains(\"fatal: bad revision\"));\n \n- let tail = fabro_sandbox::default_redacted_output_tail(&err).expect(\"tail present\");\n+ let tail = fabro_sandbox::default_redacted_output_tail(\n+ &err,\n+ &fabro_redact::SecretRedactor::default(),\n+ )\n+ .expect(\"tail present\");\n assert_eq!(tail.stderr.as_deref(), Some(\"fatal: bad revision\\n\"));\n }\n \ndiff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs\nindex 44e0eca21..c4dbd6735 100644\n--- a/lib/crates/fabro-workflow/src/services.rs\n+++ b/lib/crates/fabro-workflow/src/services.rs\n@@ -11,6 +11,7 @@ use fabro_auth::ResolvedCredentials;\n use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner};\n use fabro_interview::Interviewer;\n use fabro_model::{Catalog, ProviderId};\n+use fabro_redact::SecretRedactor;\n use fabro_types::{ManifestPath, RunId};\n use tokio_util::sync::CancellationToken;\n \n@@ -103,6 +104,7 @@ pub struct RunServices {\n pub model: String,\n pub llm_source: Arc<dyn CredentialSource>,\n pub catalog: Arc<Catalog>,\n+ pub secret_redactor: SecretRedactor,\n pub(crate) sandbox_git: Arc<SandboxGitRuntime>,\n pub(crate) metadata_runtime: Arc<RunMetadataRuntime>,\n pub(crate) metadata_writer: Option<RunMetadataWriterHandle>,\n@@ -122,6 +124,7 @@ impl RunServices {\n model: String,\n llm_source: Arc<dyn CredentialSource>,\n catalog: Arc<Catalog>,\n+ secret_redactor: SecretRedactor,\n sandbox_git: Arc<SandboxGitRuntime>,\n metadata_runtime: Arc<RunMetadataRuntime>,\n metadata_writer: Option<RunMetadataWriterHandle>,\n@@ -137,6 +140,7 @@ impl RunServices {\n model,\n llm_source,\n catalog,\n+ secret_redactor,\n sandbox_git,\n metadata_runtime,\n metadata_writer,\n@@ -337,6 +341,7 @@ impl EngineServices {\n \"claude-sonnet-4-6\".to_string(),\n Arc::new(StubCredentialSource),\n Arc::new(Catalog::from_builtin().expect(\"default catalog should build\")),\n+ SecretRedactor::default(),\n Arc::new(SandboxGitRuntime::new()),\n Arc::new(RunMetadataRuntime::new()),\n None,\ndiff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs\nindex 1acf410a4..c890667e6 100644\n--- a/lib/crates/fabro-workflow/src/test_support.rs\n+++ b/lib/crates/fabro-workflow/src/test_support.rs\n@@ -9,6 +9,7 @@ use fabro_auth::{CredentialSource, EnvCredentialSource};\n use fabro_graphviz::graph::Graph as GvGraph;\n use fabro_interview::AutoApproveInterviewer;\n use fabro_model::Catalog;\n+use fabro_redact::SecretRedactor;\n use fabro_store::{ArtifactStore, Database, RunProjection};\n use object_store::local::LocalFileSystem;\n \n@@ -235,6 +236,7 @@ async fn initialized(\n .llm_source\n .unwrap_or_else(|| Arc::new(EnvCredentialSource::new())),\n Arc::new(Catalog::from_builtin().expect(\"default catalog should build\")),\n+ SecretRedactor::default(),\n Arc::new(SandboxGitRuntime::new()),\n Arc::new(RunMetadataRuntime::new()),\n None,\ndiff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs\nindex 3ceeced5d..ab7354ba5 100644\n--- a/lib/crates/fabro-workflow/tests/it/integration.rs\n+++ b/lib/crates/fabro-workflow/tests/it/integration.rs\n@@ -8179,6 +8179,7 @@ fn hook_runner_from_defs(hooks: Vec<fabro_hooks::HookDefinition>) -> Arc<fabro_h\n fabro_hooks::HookSettings { hooks },\n Arc::new(fabro_auth::EnvCredentialSource::new()),\n default_catalog(),\n+ fabro_hooks::HookSecretResolver::default(),\n ))\n }\n \n",
"summary": {
"files_changed": 41,
"additions": 1365,
"deletions": 160
}
}
}
],
"conclusion": {
"timestamp": "2026-07-07T17:46:15.536441942Z",
"status": "succeeded",
"timing": {
"wall_time_ms": 4024286,
"inference_time_ms": 1421827,
"tool_time_ms": 2595211,
"active_time_ms": 4017038
},
"final_git_commit_sha": "baa013d649993c74840f47b229e623cf25d15a0a",
"stages": [
{
"stage_id": "start",
"stage_label": "start",
"timing": {
"wall_time_ms": 0,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"retries": 0
},
{
"stage_id": "simplify",
"stage_label": "simplify",
"timing": {
"wall_time_ms": 4018148,
"inference_time_ms": 1421827,
"tool_time_ms": 2595211,
"active_time_ms": 4017038
},
"billing_usd_micros": 51190740,
"retries": 0
}
],
"billing": {
"input_tokens": 191037,
"output_tokens": 70152,
"total_tokens": 21975177,
"reasoning_tokens": 0,
"cache_read_tokens": 19621920,
"cache_write_tokens": 2092068,
"total_usd_micros": 51190740
},
"total_retries": 0,
"diff": {}
},
"sandbox": {
"kind": "ready",
"plan": {
"provider": "daytona"
},
"instance": {
"provider": "daytona",
"snapshot": "fabro-fdb28dec-1233-892c-b9d7-9f88f8353e7a",
"runtime": {
"id": "fabro-01KWYQ40STEB2KNREK7EVNJEEK",
"working_directory": "/home/daytona/workspace/fabro",
"repo_cloned": true,
"clone_origin_url": "https://github.com/fabro-sh/fabro",
"clone_branch": "main",
"workspace_root": "/home/daytona/workspace",
"repos_root": "/home/daytona/repos",
"primary_repo_path": "/home/daytona/repos/fabro-sh/fabro",
"primary_repo_link": "/home/daytona/workspace/fabro"
}
}
},
"pull_request": null,
"superseded_by": null,
"pending_interviews": {},
"stages": {
"exit@1": {
"first_event_seq": 840,
"prompt": null,
"response": null,
"completion": {
"outcome": "succeeded",
"notes": null,
"failure_reason": null,
"timestamp": "2026-07-07T17:46:15.493209059Z"
},
"provider_used": null,
"diff": null,
"script_invocation": null,
"script_timing": null,
"parallel_results": null,
"output": null,
"started_at": "2026-07-07T17:46:15.493169138Z",
"handler": "exit",
"timing": {
"wall_time_ms": 0,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"reasoning_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"state": "succeeded"
},
"simplify@1": {
"first_event_seq": 22,
"prompt": null,
"response": null,
"completion": {
"outcome": "succeeded",
"notes": "Stage completed: simplify",
"failure_reason": null,
"timestamp": "2026-07-07T17:46:11.358025863Z"
},
"provider_used": {
"mode": "agent",
"provider": "anthropic",
"model": "claude-fable-5"
},
"diff": null,
"script_invocation": null,
"script_timing": null,
"parallel_results": null,
"output": null,
"started_at": "2026-07-07T16:39:13.209853506Z",
"handler": "agent",
"timing": {
"wall_time_ms": 4018148,
"inference_time_ms": 1421827,
"tool_time_ms": 2595211,
"active_time_ms": 4017038
},
"usage": {
"input_tokens": 191037,
"output_tokens": 70152,
"total_tokens": 21975177,
"reasoning_tokens": 0,
"cache_read_tokens": 19621920,
"cache_write_tokens": 2092068,
"total_usd_micros": 51190740
},
"model": {
"provider": "anthropic",
"model_id": "claude-fable-5"
},
"todos": {
"kind": "anthropic_tasks",
"list_id": "anthropic_tasks:cdf4ed3a-dd0d-4c75-a243-466329545774",
"items": [
{
"id": "1",
"status": "completed",
"order": 0,
"subject": "Checkout PR #547 branch and establish real delta vs main",
"description": "gh pr checkout 547; verify merge-base diff since gh pr diff used an older base.",
"active_form": "Checking out PR branch"
},
{
"id": "2",
"status": "completed",
"order": 1,
"subject": "Restructure sink redaction: redact once at RedactSecrets node, delete append_run_event_with_redactor",
"description": "In event/sink.rs redact once at the RedactSecrets arm (skip when redactor.is_empty()), push redacted event down with no redactor; revert leaf arms; delete append_run_event_with_redactor from RunStoreBackend trait, RunStoreHandle, LocalRunStoreBackend, HttpRunStore.",
"active_form": "Restructuring sink redaction"
},
{
"id": "3",
"status": "completed",
"order": 2,
"subject": "Collapse *_with_redactor twins in fabro-sandbox and event/redaction.rs",
"description": "Single signatures taking &SecretRedactor (empty = identity); delete _inner helpers and twins; migrate call sites.",
"active_form": "Collapsing redactor twin functions"
},
{
"id": "4",
"status": "completed",
"order": 3,
"subject": "Hook crate cleanups: decision_label→strum, reqwest error logs, new_with_secrets fold, first_secret_name",
"description": "Derive strum IntoStaticStr on HookDecision; replace 5-boolean reqwest log blocks with e.without_url(); fold HookRunner::new_with_secrets into new; use references(Namespace::Secrets) guard in first_secret_name.",
"active_form": "Applying hook crate cleanups"
},
{
"id": "5",
"status": "completed",
"order": 4,
"subject": "Build, test, push, comment, label PR #547",
"description": "cargo build/nextest on touched crates, fmt, clippy; commit and push to existing branch; gh pr comment; add simplify:<model> label via issues API.",
"active_form": "Verifying and updating PR"
}
]
},
"subagents": [
{
"agent_id": "665278d2",
"depth": 1,
"task": "You are a code-review sub-agent (Reviewer 1 — Code Reuse) for the repository at /home/daytona/workspace/fabro (a Rust workspace + TypeScript apps; see CLAUDE.md for conventions). You ONLY report findings — do NOT edit any code.\n\nContext: PR #547 \"Wire secret redaction and vault secrets into hooks\" adds `{{ secrets.NAME }}` interpolation for hook commands/URLs/prompts and run env, resolves them from a server vault at the run boundary, and registers resolved values with a per-run SecretRedactor. Touched files include lib/crates/fabro-hooks/ (bridge.rs, executor.rs, runner.rs, secrets.rs, lib.rs), lib/crates/fabro-redact/, lib/crates/fabro-sandbox/, lib/crates/fabro-workflow/ (event/, pipeline/, operations/, lifecycle/, services.rs, runtime_store.rs, test_support.rs), lib/crates/fabro-server/src/run_manifest.rs, lib/crates/fabro-types/src/settings/run.rs, fabro-cli, and docs.\n\nFirst run `gh pr diff 547` to see the full diff (it's ~4700 lines; page through it all). Then review each change for CODE REUSE issues:\n\n1. Search for existing utilities and helpers that could replace newly written code. Use grep to find similar patterns elsewhere — utility directories, shared modules (e.g. fabro-util, fabro-redact, fabro-types interp/token parsing), and files adjacent to the changed ones.\n2. Flag any new function that duplicates existing functionality; name the existing function to use instead.\n3. Flag inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar. Pay attention to token/interpolation parsing (is there an existing InterpString/Namespace machinery being bypassed anywhere?), shell quoting (the repo mandates shell_quote()/shlex), redaction helpers, and error formatting.\n\nThis is a greenfield app — focus on maximizing simplicity; don't worry about backward compatibility.\n\nRead the actual current files (not just the diff) when needed to confirm a finding. For each finding, report: file path + line, what the issue is, the existing utility/function to use instead, and severity (high/medium/low). If something looks fine, don't pad the report. End with a concise numbered list of actionable findings.",
"status": {
"kind": "completed",
"success": true,
"turns_used": 50
}
},
{
"agent_id": "7db02d5d",
"depth": 1,
"task": "You are a code-review sub-agent (Reviewer 2 — Code Quality) for the repository at /home/daytona/workspace/fabro (a Rust workspace + TypeScript apps; see CLAUDE.md for conventions). You ONLY report findings — do NOT edit any code.\n\nContext: PR #547 \"Wire secret redaction and vault secrets into hooks\" adds `{{ secrets.NAME }}` interpolation for hook commands/URLs/prompts and run env, resolves them from a server vault at the run boundary, and registers resolved values with a per-run SecretRedactor. Touched files include lib/crates/fabro-hooks/ (bridge.rs, executor.rs, runner.rs, secrets.rs, lib.rs), lib/crates/fabro-redact/, lib/crates/fabro-sandbox/, lib/crates/fabro-workflow/ (event/, pipeline/, operations/, lifecycle/, services.rs, runtime_store.rs, test_support.rs), lib/crates/fabro-server/src/run_manifest.rs, lib/crates/fabro-types/src/settings/run.rs, fabro-cli, and docs.\n\nFirst run `gh pr diff 547` to see the full diff (it's ~4700 lines; page through it all). Then review the changes for QUALITY / hacky patterns:\n\n1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls. (E.g. is the same secrets map/redactor threaded or stored in multiple places when one canonical owner would do?)\n2. Parameter sprawl: new parameters bolted onto functions instead of generalizing or restructuring existing ones (e.g. `env: &E, secrets: &ResolvedHookSecrets` threaded through many function signatures — could these be bundled into a resolver context struct?). Only flag this if consolidation genuinely simplifies; note the tradeoff.\n3. Copy-paste with slight variation: near-duplicate blocks that should be unified with a shared abstraction (e.g. repeated resolve-then-Block error handling, repeated redaction wiring in multiple pipeline stages).\n4. Leaky abstractions: exposing internals that should be encapsulated, or breaking existing boundaries (e.g. test-only helpers exposed from production modules — the repo has a strict test_support policy in CLAUDE.md).\n5. Stringly-typed code: raw strings where constants, enums, or branded types already exist (e.g. namespace names like \"secrets\"/\"env\" as raw strings when a Namespace enum exists).\n\nThis is a greenfield app — be aggressive in optimizing quality.\n\nRead the actual current files (not just the diff) when needed to confirm a finding. For each finding, report: file path + line, what the issue is, the concrete suggested fix, and severity (high/medium/low). Don't pad the report with non-issues. End with a concise numbered list of actionable findings.",
"status": {
"kind": "completed",
"success": true,
"turns_used": 50
}
},
{
"agent_id": "a5d57b5f",
"depth": 1,
"task": "You are a code-review sub-agent (Reviewer 3 — Efficiency) for the repository at /home/daytona/workspace/fabro (a Rust workspace + TypeScript apps; see CLAUDE.md for conventions). You ONLY report findings — do NOT edit any code.\n\nContext: PR #547 \"Wire secret redaction and vault secrets into hooks\" adds `{{ secrets.NAME }}` interpolation for hook commands/URLs/prompts and run env, resolves them from a server vault at the run boundary, and registers resolved values with a per-run SecretRedactor. Touched files include lib/crates/fabro-hooks/ (bridge.rs, executor.rs, runner.rs, secrets.rs, lib.rs), lib/crates/fabro-redact/, lib/crates/fabro-sandbox/, lib/crates/fabro-workflow/ (event/, pipeline/, operations/, lifecycle/, services.rs, runtime_store.rs, test_support.rs), lib/crates/fabro-server/src/run_manifest.rs, lib/crates/fabro-types/src/settings/run.rs, fabro-cli, and docs.\n\nFirst run `gh pr diff 547` to see the full diff (it's ~4700 lines; page through it all). Then review the changes for EFFICIENCY issues:\n\n1. Unnecessary work: redundant computations, repeated vault reads, duplicate network/API calls, N+1 patterns (e.g. resolving the same secret once per hook fire instead of once per run; rebuilding regex/redactor state per event).\n2. Missed concurrency: independent operations run sequentially when they could run in parallel.\n3. Hot-path bloat: new blocking work added to startup or per-request/per-event/per-hook hot paths (e.g. locking, cloning large maps per event in the redaction path, per-event allocation that could be avoided).\n4. Unnecessary existence checks: pre-checking a file/resource before operating (TOCTOU) — operate directly and handle the error.\n5. Memory: unbounded data structures, missing cleanup, listener leaks, secrets kept alive longer than needed.\n6. Overly broad operations: reading whole files when a portion suffices, loading all items when filtering for one, redacting surfaces that can't contain secrets.\n\nRead the actual current files (not just the diff) when needed to confirm a finding — pay attention to how SecretRedactor is invoked per event in lib/crates/fabro-workflow/src/event/redaction.rs and sink.rs, and how hook secrets are resolved in fabro-hooks/src/secrets.rs and runner.rs. For each finding, report: file path + line, what the issue is, the concrete suggested fix, and severity (high/medium/low). Don't pad the report with non-issues. End with a concise numbered list of actionable findings.",
"status": {
"kind": "completed",
"success": true,
"turns_used": 54
}
}
],
"permission_level": "full",
"agent_tools": [
{
"name": "AskUserQuestion",
"description": "Ask the human one or more questions and wait for their answers before continuing this stage.",
"source": {
"kind": "native"
},
"category": "other",
"invoked": false
},
{
"name": "TaskCreate",
"description": "Create pending tasks in the current session. Use concise subjects, descriptions, optional activeForm text, and metadata. Check TaskList first to avoid duplicate tasks.",
"source": {
"kind": "native"
},
"category": "other",
"invoked": true
},
{
"name": "TaskGet",
"description": "Get one task by taskId, including subject, status, description, owner, blockedBy, and blocks.",
"source": {
"kind": "native"
},
"category": "other",
"invoked": false
},
{
"name": "TaskList",
"description": "List tasks for the current session, including status, owner, and blocking dependencies. Use TaskGet with a taskId for full description and dependency details.",
"source": {
"kind": "native"
},
"category": "other",
"invoked": false
},
{
"name": "TaskUpdate",
"description": "Update an existing task's status, text, owner, metadata, or dependencies. Valid statuses are pending, in_progress, completed, and deleted. After completing a task, call TaskList to find newly unblocked work.",
"source": {
"kind": "native"
},
"category": "other",
"invoked": true
},
{
"name": "close_agent",
"description": "Close a running subagent that is no longer needed.",
"source": {
"kind": "native"
},
"category": "subagent",
"invoked": false
},
{
"name": "edit_file",
"description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.",
"source": {
"kind": "native"
},
"category": "write",
"invoked": true
},
{
"name": "glob",
"description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.",
"source": {
"kind": "native"
},
"category": "read",
"invoked": true
},
{
"name": "grep",
"description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.",
"source": {
"kind": "native"
},
"category": "read",
"invoked": true
},
{
"name": "read_file",
"description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.",
"source": {
"kind": "native"
},
"category": "read",
"invoked": true
},
{
"name": "send_input",
"description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.",
"source": {
"kind": "native"
},
"category": "subagent",
"invoked": false
},
{
"name": "shell",
"description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.",
"source": {
"kind": "native"
},
"category": "shell",
"invoked": true
},
{
"name": "spawn_agent",
"description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.",
"source": {
"kind": "native"
},
"category": "subagent",
"invoked": true
},
{
"name": "wait",
"description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.",
"source": {
"kind": "native"
},
"category": "subagent",
"invoked": true
},
{
"name": "web_fetch",
"description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.",
"source": {
"kind": "native"
},
"category": "other",
"invoked": false
},
{
"name": "web_search",
"description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.",
"source": {
"kind": "native"
},
"category": "other",
"invoked": false
},
{
"name": "write_file",
"description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.",
"source": {
"kind": "native"
},
"category": "write",
"invoked": false
}
],
"context_window": {
"provider": "anthropic",
"model": "claude-fable-5",
"context_window_tokens": 1000000,
"input_tokens": 203966,
"usage_percent": 20.3966,
"count_method": "response_usage_scaled_breakdown",
"staleness": "live",
"generated_at": "2026-07-07T17:46:11.332171929Z",
"event_seq": 830,
"breakdown": [
{
"category": "system_prompt",
"tokens": 2239,
"usage_percent": 0.2239
},
{
"category": "tools",
"tokens": 2524,
"usage_percent": 0.2524
},
{
"category": "memory",
"tokens": 5367,
"usage_percent": 0.5367
},
{
"category": "conversation",
"tokens": 193829,
"usage_percent": 19.3829
},
{
"category": "other",
"tokens": 7,
"usage_percent": 0.0007
}
],
"warnings": []
},
"state": "succeeded"
},
"start@1": {
"first_event_seq": 18,
"prompt": null,
"response": null,
"completion": {
"outcome": "succeeded",
"notes": null,
"failure_reason": null,
"timestamp": "2026-07-07T16:39:13.209638043Z"
},
"provider_used": null,
"diff": null,
"script_invocation": null,
"script_timing": null,
"parallel_results": null,
"output": null,
"started_at": "2026-07-07T16:39:13.209563224Z",
"handler": "start",
"timing": {
"wall_time_ms": 0,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"reasoning_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"state": "succeeded"
}
}
}