mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
839 lines
No EOL
176 KiB
JSON
839 lines
No EOL
176 KiB
JSON
{
|
||
"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"
|
||
}
|
||
}
|
||
} |