diff --git a/run.json b/run.json index 1d752b7e5..cfa02e5e0 100644 --- a/run.json +++ b/run.json @@ -226,7 +226,7 @@ "kind": "running" }, "status_updated_at": "2026-07-07T16:39:11.209488148Z", - "last_event_at": "2026-07-07T17:46:11.333387605Z", + "last_event_at": "2026-07-07T17:46:15.493209059Z", "pending_control": null, "checkpoints": [ { @@ -267,9 +267,9 @@ "diff": {} }, { - "seq": 0, + "seq": 837, "checkpoint": { - "timestamp": "2026-07-07T17:46:11.358211435Z", + "timestamp": "2026-07-07T17:46:15.492860013Z", "current_node": "simplify", "completed_nodes": [ "start", @@ -277,30 +277,26 @@ ], "node_retries": {}, "context_values": { - "internal.node_visit_count": 1, - "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.", "failure_signature": "", - "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` 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`.", - "internal.thread_id": "start", - "internal.work_dir": "/home/daytona/workspace/fabro", "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", - "internal.run_id": "01KWYQ40STEB2KNREK7EVNJEEK", - "internal.retry_count.start": 0, "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", - "internal.fidelity": "compact", + "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` 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", - "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", - "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.fidelity": "compact" }, "node_outcomes": { - "start": { - "status": "succeeded", - "usage": null - }, "simplify": { "status": "succeeded", "context_updates": { @@ -360,18 +356,76 @@ "tool_time_ms": 2595211, "active_time_ms": 4017038 } + }, + "start": { + "status": "succeeded", + "usage": null } }, "next_node_id": "exit", + "git_commit_sha": "baa013d649993c74840f47b229e623cf25d15a0a", "node_visits": { - "start": 1, - "simplify": 1 + "simplify": 1, + "start": 1 } }, - "diff": {} + "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 for SandboxAcpTransport {\n }\n \n fn redacted_stderr_tail(stderr: &str) -> Option {\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,\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,\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(value: &InterpString, env: &E) -> Result\n+fn resolve_interp(\n+ value: &InterpString,\n+ env: &E,\n+ secrets: &ResolvedHookSecrets,\n+) -> Result\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(\n value: &InterpString,\n allowed_env_vars: &[String],\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n ) -> Result\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(\n prompt: &InterpString,\n model: Option<&InterpString>,\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n ) -> Result<(String, Option), 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,\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,\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,\n env: &E,\n+ secrets: &ResolvedHookSecrets,\n llm_source: &dyn CredentialSource,\n catalog: Arc,\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,\n+ secrets: &ResolvedHookSecrets,\n ) -> HookResult {\n use std::sync::OnceLock;\n static HTTP_CLIENTS: OnceLock = 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,\n llm_source: Arc,\n catalog: Arc,\n+ secrets: HookSecretResolver,\n /// Pre-compiled regexes keyed by matcher pattern string.\n compiled_matchers: HashMap,\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,\n catalog: Arc,\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,\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,\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> + 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>,\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(redactor: SecretRedactor, lookup: F) -> Self\n+ where\n+ F: Fn(String) -> Fut + Send + Sync + 'static,\n+ Fut: Future> + 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(&self, names: I) -> ResolvedHookSecrets\n+ where\n+ I: IntoIterator,\n+ S: AsRef,\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,\n+ redactor: SecretRedactor,\n+}\n+\n+impl ResolvedHookSecrets {\n+ #[must_use]\n+ pub fn new(values: HashMap, 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 {\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 {\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 {\n- default_redacted_output_tail(self)\n+ pub fn default_redacted_output_tail(\n+ &self,\n+ redactor: &SecretRedactor,\n+ ) -> Option {\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) -> String {\n \n pub type Result = std::result::Result;\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 {\n let mut current = Some(err);\n while let Some(err) = current {\n if let Some(Error::Exec { result, .. }) = err.downcast_ref::() {\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,\n github_app: Option,\n run_id: Option,\n clone_origin_url: Option,\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 {\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 {\n- self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)\n+ pub fn default_redacted_output_tail(\n+ &self,\n+ redactor: &SecretRedactor,\n+ ) -> Option {\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 {\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, bool) {\n+fn redacted_tail(\n+ text: &str,\n+ max_bytes: usize,\n+ redactor: &SecretRedactor,\n+) -> (Option, 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,\n github_app: Option,\n run_id: Option,\n clone_origin_url: Option,\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 {\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,\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 {\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 {\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 {\n serde_json::to_string(&redacted_event_value(event)?).map_err(anyhow::Error::from)\n }\n \n-fn normalized_event_value(event: &RunEvent) -> Result {\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 {\n+ Ok(redact_json_value(normalize_json_value(event.to_value()?)))\n }\n \n-fn redacted_event_value(event: &RunEvent) -> Result {\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 {\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,\n inner: Box,\n },\n+ RedactSecrets {\n+ redactor: SecretRedactor,\n+ inner: Box,\n+ },\n Composite(Vec),\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,\n pub metadata_runtime: Arc,\n pub metadata_writer: Option,\n+ pub secret_redactor: SecretRedactor,\n pub start_node_id: Option,\n // Cross-lifecycle data (shared with EventLifecycle)\n pub checkpoint_git_result: Arc>>,\n@@ -311,10 +313,16 @@ impl RunLifecycle 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 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 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 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,\n metadata_runtime: Arc,\n metadata_writer: Option,\n+ secret_redactor: fabro_redact::SecretRedactor,\n is_resume: bool,\n on_node: crate::OnNodeCallback,\n run_control: Option>,\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>,\n run_control: Option>,\n vault: Option>>,\n+ secret_redactor: SecretRedactor,\n+ hook_secrets: fabro_hooks::HookSecretResolver,\n catalog: Arc,\n fabro_run_tools: Option,\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 {\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 {\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>>,\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,\n pub workflow_bundle: Option>,\n pub hooks: fabro_hooks::HookSettings,\n+ pub hook_secrets: fabro_hooks::HookSecretResolver,\n pub sandbox_env: SandboxEnvSpec,\n pub vault: Option>>,\n+ pub secret_redactor: SecretRedactor,\n pub git: Option,\n pub registry_override: Option>,\n pub artifact_sink: Option,\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,\n pub catalog: Arc,\n+ pub secret_redactor: SecretRedactor,\n pub(crate) sandbox_git: Arc,\n pub(crate) metadata_runtime: Arc,\n pub(crate) metadata_writer: Option,\n@@ -122,6 +124,7 @@ impl RunServices {\n model: String,\n llm_source: Arc,\n catalog: Arc,\n+ secret_redactor: SecretRedactor,\n sandbox_git: Arc,\n metadata_runtime: Arc,\n metadata_writer: Option,\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) -> Arc exec_output_tail.clone(), + Self::ProcessExited(exit) => exit.exec_output_tail.clone(), +- Self::Sandbox(source) => source.default_redacted_output_tail(), ++ Self::Sandbox(source) => { ++ source.default_redacted_output_tail(&fabro_sandbox::SecretRedactor::default()) ++ } + _ => None, + } + } +diff --git a/lib/crates/fabro-acp/src/transport.rs b/lib/crates/fabro-acp/src/transport.rs +index f599c5a27..79ec20db4 100644 +--- a/lib/crates/fabro-acp/src/transport.rs ++++ b/lib/crates/fabro-acp/src/transport.rs +@@ -184,7 +184,12 @@ impl ConnectTo for SandboxAcpTransport { + } + + fn redacted_stderr_tail(stderr: &str) -> Option { +- fabro_sandbox::redacted_output_tail("", stderr, DEFAULT_EXEC_OUTPUT_TAIL_BYTES) ++ fabro_sandbox::redacted_output_tail( ++ "", ++ stderr, ++ DEFAULT_EXEC_OUTPUT_TAIL_BYTES, ++ &fabro_sandbox::SecretRedactor::default(), ++ ) + } + + fn process_exited_before_protocol_completed() -> ProtocolError { +diff --git a/lib/crates/fabro-hooks/Cargo.toml b/lib/crates/fabro-hooks/Cargo.toml +index 559a7499a..84bb443c7 100644 +--- a/lib/crates/fabro-hooks/Cargo.toml ++++ b/lib/crates/fabro-hooks/Cargo.toml +@@ -23,6 +23,7 @@ fabro-util = { path = "../fabro-util" } + fabro-http.workspace = true + serde.workspace = true + serde_json.workspace = true ++strum.workspace = true + tokio.workspace = true + async-trait.workspace = true + regex.workspace = true +diff --git a/lib/crates/fabro-hooks/src/bridge.rs b/lib/crates/fabro-hooks/src/bridge.rs +index 6aa7e60f6..a7bfa05ed 100644 +--- a/lib/crates/fabro-hooks/src/bridge.rs ++++ b/lib/crates/fabro-hooks/src/bridge.rs +@@ -102,6 +102,7 @@ mod tests { + execution_context: &HookExecutionContext, + _llm_source: &dyn fabro_auth::CredentialSource, + _catalog: Arc, ++ _secrets: &crate::ResolvedHookSecrets, + ) -> HookResult { + self.captured_contexts.lock().unwrap().push(context.clone()); + self.captured_execution_contexts +diff --git a/lib/crates/fabro-hooks/src/executor.rs b/lib/crates/fabro-hooks/src/executor.rs +index 9fda710d5..c2e6d2ab0 100644 +--- a/lib/crates/fabro-hooks/src/executor.rs ++++ b/lib/crates/fabro-hooks/src/executor.rs +@@ -13,7 +13,7 @@ use fabro_llm::generate::{GenerateParams, generate_object}; + use fabro_llm::types::{Message, Request, ToolResult}; + use fabro_model::Catalog; + use fabro_redact::redacted_url_for_log; +-use fabro_types::settings::interp::Namespace; ++use fabro_types::settings::interp::{Namespace, ResolveCtx}; + use fabro_types::settings::{InterpString, ResolveError}; + use fabro_util::env::{Env, SystemEnv}; + use tokio::process::Command as TokioCommand; +@@ -21,6 +21,7 @@ use tokio::time::timeout as tokio_timeout; + use tokio_util::sync::CancellationToken; + + use crate::config::{HookDefinition, HookType, TlsMode}; ++use crate::secrets::{ResolvedHookSecrets, first_secret_name}; + use crate::types::{ + HookContext, HookDecision, HookExecutionContext, HookResult, PromptHookResponse, + }; +@@ -54,16 +55,18 @@ pub trait HookExecutor: Send + Sync { + execution_context: &HookExecutionContext, + llm_source: &dyn CredentialSource, + catalog: Arc, ++ secrets: &ResolvedHookSecrets, + ) -> HookResult; + } + + /// Resolve a typed [`InterpString`] hook segment at fire time, looking up +-/// `{{ env.* }}` tokens against `env`. ++/// `{{ env.* }}` tokens against `env` and `{{ secrets.* }}` tokens against the ++/// run-scoped secret resolver. + /// +-/// Only the `env` namespace is wired here; `{{ secrets.* }}`, `{{ vars.* }}`, +-/// and `{{ inputs.* }}` tokens have no lookup in this context and resolve as +-/// `Unavailable`, which is a hard error — so a hook that references one fails +-/// closed rather than firing with a half-resolved value. ++/// Only `env` and `secrets` are wired here; `{{ vars.* }}` and `{{ inputs.* }}` ++/// tokens have no lookup in this context and resolve as `Unavailable`, which is ++/// a hard error — so a hook that references one fails closed rather than firing ++/// with a half-resolved value. + /// + /// The value stays typed end-to-end: it is carried as an `InterpString` + /// through the config resolve layer and resolved here from its segments — +@@ -73,13 +76,18 @@ pub trait HookExecutor: Send + Sync { + /// + /// Returns the typed [`ResolveError`] so callers keep the source until the + /// decision boundary renders it; do not flatten it to a `String` here. +-fn resolve_interp(value: &InterpString, env: &E) -> Result ++fn resolve_interp( ++ value: &InterpString, ++ env: &E, ++ secrets: &ResolvedHookSecrets, ++) -> Result + where + E: Env + ?Sized, + { +- value +- .resolve(|name| env.var(name).ok()) +- .map(|resolved| resolved.value) ++ let mut ctx = ResolveCtx::new() ++ .with_env(|name| env.var(name).ok()) ++ .with_secrets(|name| secrets.lookup(name)); ++ value.resolve_with(&mut ctx).map(|resolved| resolved.value) + } + + #[expect( +@@ -95,6 +103,7 @@ fn safe_url_source_for_log(url: &InterpString) -> String { + #[derive(Debug, Clone, PartialEq, Eq)] + enum HeaderResolveError { + NotAllowed { name: String }, ++ SecretNotAllowed { name: String }, + Resolve(ResolveError), + } + +@@ -106,6 +115,11 @@ impl fmt::Display for HeaderResolveError { + "environment variable {name:?} referenced by an HTTP hook header is not listed in \ + allowed_env_vars" + ), ++ Self::SecretNotAllowed { name } => write!( ++ f, ++ "secret {name:?} referenced by an HTTP hook header is not allowed; use secret \ ++ interpolation in a hook command, prompt, or url instead" ++ ), + Self::Resolve(error) => error.fmt(f), + } + } +@@ -114,7 +128,7 @@ impl fmt::Display for HeaderResolveError { + impl std::error::Error for HeaderResolveError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { +- Self::NotAllowed { .. } => None, ++ Self::NotAllowed { .. } | Self::SecretNotAllowed { .. } => None, + Self::Resolve(error) => Some(error), + } + } +@@ -135,10 +149,17 @@ fn resolve_header( + value: &InterpString, + allowed_env_vars: &[String], + env: &E, ++ secrets: &ResolvedHookSecrets, + ) -> Result + where + E: Env + ?Sized, + { ++ if let Some(name) = first_secret_name(value) { ++ return Err(HeaderResolveError::SecretNotAllowed { ++ name: name.to_string(), ++ }); ++ } ++ + if let Some(name) = value.names(Namespace::Env).into_iter().find(|name| { + !allowed_env_vars + .iter() +@@ -149,7 +170,7 @@ where + }); + } + +- resolve_interp(value, env).map_err(HeaderResolveError::Resolve) ++ resolve_interp(value, env, secrets).map_err(HeaderResolveError::Resolve) + } + + /// Executes hooks via shell commands or HTTP POST. +@@ -181,20 +202,24 @@ impl HookExecutorImpl { + + /// Resolve the prompt and optional model segments at fire time. + /// +- /// Fail-closed: only `{{ env.* }}` is wired here; a missing env token (or a +- /// token in any other, unavailable namespace) is a hard error so the hook +- /// never fires with a half-resolved value. The caller turns the error into +- /// a `Block` decision, matching the command-hook behavior. ++ /// Fail-closed: only `{{ env.* }}` and `{{ secrets.* }}` are wired here; a ++ /// missing token (or a token in any other, unavailable namespace) is a hard ++ /// error so the hook never fires with a half-resolved value. The caller ++ /// turns the error into a `Block` decision, matching the command-hook ++ /// behavior. + fn resolve_prompt_and_model( + prompt: &InterpString, + model: Option<&InterpString>, + env: &E, ++ secrets: &ResolvedHookSecrets, + ) -> Result<(String, Option), ResolveError> + where + E: Env + ?Sized, + { +- let prompt = resolve_interp(prompt, env)?; +- let model = model.map(|model| resolve_interp(model, env)).transpose()?; ++ let prompt = resolve_interp(prompt, env, secrets)?; ++ let model = model ++ .map(|model| resolve_interp(model, env, secrets)) ++ .transpose()?; + Ok((prompt, model)) + } + +@@ -206,11 +231,12 @@ impl HookExecutorImpl { + sandbox: &Arc, + execution_context: &HookExecutionContext, + env: &E, ++ secrets: &ResolvedHookSecrets, + ) -> HookDecision + where + E: Env + ?Sized, + { +- let command = match resolve_interp(command, env) { ++ let command = match resolve_interp(command, env, secrets) { + Ok(command) => command, + Err(error) => { + return HookDecision::Block { +@@ -363,13 +389,14 @@ impl HookExecutorImpl { + model: Option<&InterpString>, + context: &HookContext, + env: &E, ++ secrets: &ResolvedHookSecrets, + llm_source: &dyn CredentialSource, + catalog: Arc, + ) -> HookDecision + where + E: Env + ?Sized, + { +- let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env) { ++ let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env, secrets) { + Ok(resolved) => resolved, + Err(error) => { + tracing::error!(error = %error, "prompt hook env resolution failed, not firing"); +@@ -432,13 +459,14 @@ impl HookExecutorImpl { + context: &HookContext, + sandbox: Arc, + env: &E, ++ secrets: &ResolvedHookSecrets, + llm_source: &dyn CredentialSource, + catalog: Arc, + ) -> HookDecision + where + E: Env + ?Sized, + { +- let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env) { ++ let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env, secrets) { + Ok(resolved) => resolved, + Err(error) => { + tracing::error!(error = %error, "agent hook env resolution failed, not firing"); +@@ -578,11 +606,12 @@ impl HookExecutorImpl { + context: &HookContext, + timeout: std::time::Duration, + env: &E, ++ secrets: &ResolvedHookSecrets, + ) -> HookDecision + where + E: Env + ?Sized, + { +- let resolved_url = match resolve_interp(url, env) { ++ let resolved_url = match resolve_interp(url, env, secrets) { + Ok(url) => url, + Err(error) => { + tracing::error!( +@@ -618,7 +647,7 @@ impl HookExecutorImpl { + // `{{ env.NAME }}` not in `allowed_env_vars` blocks before any + // lookup, while an allowlisted-but-unset name still fails as + // missing. +- let interpolated = match resolve_header(value, allowed_env_vars, env) { ++ let interpolated = match resolve_header(value, allowed_env_vars, env, secrets) { + Ok(rendered) => rendered, + Err(error) => { + tracing::error!( +@@ -639,9 +668,11 @@ impl HookExecutorImpl { + let response = match request.send().await { + Ok(resp) => resp, + Err(e) => { ++ // `without_url` drops the resolved URL, which may embed a ++ // secret; the redacted source form is logged separately. + tracing::warn!( + url_source = %safe_url_source_for_log(url), +- error = %e, ++ error = %e.without_url(), + "HTTP hook request failed, proceeding" + ); + return HookDecision::Proceed; +@@ -662,7 +693,7 @@ impl HookExecutorImpl { + Err(e) => { + tracing::warn!( + url_source = %safe_url_source_for_log(url), +- error = %e, ++ error = %e.without_url(), + "HTTP hook body read failed, proceeding" + ); + return HookDecision::Proceed; +@@ -728,6 +759,7 @@ impl HookExecutor for HookExecutorImpl { + execution_context: &HookExecutionContext, + llm_source: &dyn CredentialSource, + catalog: Arc, ++ secrets: &ResolvedHookSecrets, + ) -> HookResult { + use std::sync::OnceLock; + static HTTP_CLIENTS: OnceLock = OnceLock::new(); +@@ -747,6 +779,7 @@ impl HookExecutor for HookExecutorImpl { + &sandbox, + execution_context, + &env, ++ secrets, + ) + .await + } +@@ -774,6 +807,7 @@ impl HookExecutor for HookExecutorImpl { + context, + definition.timeout(), + &env, ++ secrets, + ) + .await + } +@@ -793,6 +827,7 @@ impl HookExecutor for HookExecutorImpl { + model.as_ref(), + context, + &env, ++ secrets, + llm_source, + Arc::clone(&catalog), + ) +@@ -818,6 +853,7 @@ impl HookExecutor for HookExecutorImpl { + context, + sandbox, + &env, ++ secrets, + llm_source, + Arc::clone(&catalog), + ) +@@ -869,6 +905,19 @@ mod tests { + HookExecutorImpl::build_http_client(TlsMode::Off) + } + ++ fn empty_secrets() -> ResolvedHookSecrets { ++ ResolvedHookSecrets::default() ++ } ++ ++ fn test_secrets(vars: &[(&str, &str)]) -> ResolvedHookSecrets { ++ ResolvedHookSecrets::new( ++ vars.iter() ++ .map(|(name, value)| ((*name).to_string(), (*value).to_string())) ++ .collect(), ++ fabro_redact::SecretRedactor::default(), ++ ) ++ } ++ + fn make_definition(command: &str) -> HookDefinition { + HookDefinition { + name: Some("test-hook".into()), +@@ -946,6 +995,7 @@ mod tests { + let ctx = make_context(); + let sandbox = make_sandbox(); + let source = test_llm_source(); ++ let secrets = empty_secrets(); + let result = executor + .execute( + &def, +@@ -954,6 +1004,7 @@ mod tests { + &HookExecutionContext::default(), + source.as_ref(), + test_catalog(), ++ &secrets, + ) + .await; + assert_eq!(result.decision, HookDecision::Proceed); +@@ -967,6 +1018,7 @@ mod tests { + let ctx = make_context(); + let sandbox = make_sandbox(); + let source = test_llm_source(); ++ let secrets = empty_secrets(); + let result = executor + .execute( + &def, +@@ -975,6 +1027,7 @@ mod tests { + &HookExecutionContext::default(), + source.as_ref(), + test_catalog(), ++ &secrets, + ) + .await; + assert!(matches!(result.decision, HookDecision::Block { .. })); +@@ -987,6 +1040,7 @@ mod tests { + let ctx = make_context(); + let sandbox = make_sandbox(); + let source = test_llm_source(); ++ let secrets = empty_secrets(); + let result = executor + .execute( + &def, +@@ -995,6 +1049,7 @@ mod tests { + &HookExecutionContext::default(), + source.as_ref(), + test_catalog(), ++ &secrets, + ) + .await; + assert!(matches!(result.decision, HookDecision::Block { .. })); +@@ -1007,6 +1062,7 @@ mod tests { + let ctx = make_context(); + let sandbox = make_sandbox(); + let source = test_llm_source(); ++ let secrets = empty_secrets(); + let result = executor + .execute( + &def, +@@ -1015,6 +1071,7 @@ mod tests { + &HookExecutionContext::default(), + source.as_ref(), + test_catalog(), ++ &secrets, + ) + .await; + assert_eq!(result.decision, HookDecision::Skip { +@@ -1031,6 +1088,7 @@ mod tests { + ctx.node_id = Some("plan".into()); + let sandbox = make_sandbox(); + let source = test_llm_source(); ++ let secrets = empty_secrets(); + let result = executor + .execute( + &def, +@@ -1039,6 +1097,7 @@ mod tests { + &HookExecutionContext::default(), + source.as_ref(), + test_catalog(), ++ &secrets, + ) + .await; + assert_eq!(result.decision, HookDecision::Proceed); +@@ -1060,6 +1119,7 @@ mod tests { + let ctx = make_context(); + let sandbox = make_sandbox(); + let source = test_llm_source(); ++ let secrets = empty_secrets(); + let result = executor + .execute( + &def, +@@ -1068,6 +1128,7 @@ mod tests { + &HookExecutionContext::default(), + source.as_ref(), + test_catalog(), ++ &secrets, + ) + .await; + assert!(matches!(result.decision, HookDecision::Block { .. })); +@@ -1188,6 +1249,7 @@ mod tests { + &interp("Bearer {{ env.FABRO_TEST_KEY_1 }}"), + &["FABRO_TEST_KEY_1".to_string()], + &env, ++ &empty_secrets(), + ) + .unwrap(); + assert_eq!(result, "Bearer secret123"); +@@ -1203,6 +1265,7 @@ mod tests { + &interp("prefix-{{ env.FABRO_TEST_KEY_3 }}-suffix"), + &[], + &env, ++ &empty_secrets(), + ) + .unwrap_err(); + assert_eq!(err, HeaderResolveError::NotAllowed { +@@ -1217,11 +1280,12 @@ mod tests { + &interp("prefix-{{ env.FABRO_TEST_KEY_3 }}-suffix"), + &["FABRO_TEST_KEY_3".to_string()], + &env, ++ &empty_secrets(), + ) + .unwrap_err(); + match err { + HeaderResolveError::Resolve(error) => assert_eq!(error.name, "FABRO_TEST_KEY_3"), +- HeaderResolveError::NotAllowed { .. } => { ++ HeaderResolveError::NotAllowed { .. } | HeaderResolveError::SecretNotAllowed { .. } => { + panic!("expected missing token resolve error, got {err:?}") + } + } +@@ -1233,14 +1297,19 @@ mod tests { + fn resolve_interp_resolves_embedded_token_from_typed_value() { + let env = test_env(&[("FABRO_TEST_KEY_2", "val")]); + let value = interp("x{{ env.FABRO_TEST_KEY_2 }}y"); +- let result = resolve_interp(&value, &env).unwrap(); ++ let result = resolve_interp(&value, &env, &empty_secrets()).unwrap(); + assert_eq!(result, "xvaly"); + } + + #[test] + fn resolve_interp_errors_on_missing_var() { + let env = test_env(&[]); +- let err = resolve_interp(&interp("a{{ env.FABRO_TEST_NOEXIST }}-b"), &env).unwrap_err(); ++ let err = resolve_interp( ++ &interp("a{{ env.FABRO_TEST_NOEXIST }}-b"), ++ &env, ++ &empty_secrets(), ++ ) ++ .unwrap_err(); + assert_eq!(err.name, "FABRO_TEST_NOEXIST"); + } + +@@ -1248,11 +1317,63 @@ mod tests { + fn resolve_interp_without_tokens_passes_through() { + let env = test_env(&[]); + assert_eq!( +- resolve_interp(&interp("plain text"), &env).unwrap(), ++ resolve_interp(&interp("plain text"), &env, &empty_secrets()).unwrap(), + "plain text" + ); + } + ++ #[test] ++ fn resolve_interp_resolves_secret_and_registers_value() { ++ let env = test_env(&[]); ++ let redactor = fabro_redact::SecretRedactor::default(); ++ let secrets = ResolvedHookSecrets::new( ++ HashMap::from([("HOOK_TOKEN".to_string(), "staging".to_string())]), ++ redactor.clone(), ++ ); ++ ++ let resolved = ++ resolve_interp(&interp("deploy {{ secrets.HOOK_TOKEN }}"), &env, &secrets).unwrap(); ++ ++ assert_eq!(resolved, "deploy staging"); ++ assert_eq!(redactor.redact_into("deploy staging"), "deploy REDACTED"); ++ } ++ ++ #[test] ++ fn resolve_prompt_and_model_resolves_secret_tokens() { ++ let env = test_env(&[]); ++ let secrets = test_secrets(&[("PROMPT_TOKEN", "staging")]); ++ ++ let (prompt, model) = HookExecutorImpl::resolve_prompt_and_model( ++ &interp("check {{ secrets.PROMPT_TOKEN }}"), ++ Some(&interp("haiku")), ++ &env, ++ &secrets, ++ ) ++ .unwrap(); ++ ++ assert_eq!(prompt, "check staging"); ++ assert_eq!(model.as_deref(), Some("haiku")); ++ } ++ ++ #[test] ++ fn header_rejects_secret_token_with_guidance() { ++ let env = test_env(&[]); ++ let err = resolve_header( ++ &interp("Bearer {{ secrets.HOOK_TOKEN }}"), ++ &[], ++ &env, ++ &test_secrets(&[("HOOK_TOKEN", "staging")]), ++ ) ++ .unwrap_err(); ++ ++ assert_eq!(err, HeaderResolveError::SecretNotAllowed { ++ name: "HOOK_TOKEN".to_string(), ++ }); ++ let message = err.to_string(); ++ assert!(message.contains("HTTP hook header")); ++ assert!(message.contains("command, prompt, or url")); ++ } ++ + // --- HTTP hook execution tests --- + + #[tokio::test] +@@ -1278,6 +1399,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1307,6 +1429,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1334,6 +1457,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1353,6 +1477,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(1), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1388,6 +1513,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &env, ++ &empty_secrets(), + ) + .await; + +@@ -1425,6 +1551,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &env, ++ &empty_secrets(), + ) + .await; + +@@ -1442,6 +1569,46 @@ mod tests { + } + } + ++ #[tokio::test] ++ async fn http_hook_secret_header_blocks_without_firing() { ++ let server = httpmock::MockServer::start_async().await; ++ let mock = server ++ .mock_async(|when, then| { ++ when.method("POST").path("/hook"); ++ then.status(200).body(""); ++ }) ++ .await; ++ ++ let headers = HashMap::from([( ++ "Authorization".to_string(), ++ interp("Bearer {{ secrets.FABRO_TEST_TOKEN }}"), ++ )]); ++ ++ let client = test_http_client(); ++ let decision = HookExecutorImpl::execute_http( ++ &client, ++ &interp(&server.url("/hook")), ++ Some(&headers), ++ &[], ++ &TlsMode::Off, ++ &make_context(), ++ std::time::Duration::from_secs(5), ++ &test_env(&[]), ++ &test_secrets(&[("FABRO_TEST_TOKEN", "staging")]), ++ ) ++ .await; ++ ++ assert_eq!(mock.calls_async().await, 0); ++ match decision { ++ HookDecision::Block { reason } => { ++ let reason = reason.unwrap_or_default(); ++ assert!(reason.contains("HTTP hook header")); ++ assert!(reason.contains("command, prompt, or url")); ++ } ++ other => panic!("expected Block on secret header token, got {other:?}"), ++ } ++ } ++ + #[tokio::test] + async fn http_hook_resolves_url_before_dispatch() { + let server = httpmock::MockServer::start_async().await; +@@ -1463,6 +1630,36 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &env, ++ &empty_secrets(), ++ ) ++ .await; ++ ++ mock.assert_async().await; ++ assert_eq!(decision, HookDecision::Proceed); ++ } ++ ++ #[tokio::test] ++ async fn http_hook_resolves_secret_url_before_dispatch() { ++ let server = httpmock::MockServer::start_async().await; ++ let mock = server ++ .mock_async(|when, then| { ++ when.method("POST").path("/hook"); ++ then.status(200).body(""); ++ }) ++ .await; ++ ++ let client = test_http_client(); ++ let secrets = test_secrets(&[("FABRO_TEST_URL", &server.url("/hook"))]); ++ let decision = HookExecutorImpl::execute_http( ++ &client, ++ &interp("{{ secrets.FABRO_TEST_URL }}"), ++ None, ++ &[], ++ &TlsMode::Off, ++ &make_context(), ++ std::time::Duration::from_secs(5), ++ &test_env(&[]), ++ &secrets, + ) + .await; + +@@ -1490,6 +1687,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1534,6 +1732,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1557,6 +1756,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1575,6 +1775,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1601,6 +1802,7 @@ mod tests { + &make_context(), + std::time::Duration::from_secs(5), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + +@@ -1637,6 +1839,7 @@ mod tests { + let ctx = make_context(); + let sandbox = make_sandbox(); + let source = test_llm_source(); ++ let secrets = empty_secrets(); + let result = executor + .execute( + &def, +@@ -1645,6 +1848,7 @@ mod tests { + &HookExecutionContext::default(), + source.as_ref(), + test_catalog(), ++ &secrets, + ) + .await; + +@@ -1663,12 +1867,57 @@ mod tests { + &sandbox, + &HookExecutionContext::default(), + &test_env(&[]), ++ &empty_secrets(), + ) + .await; + + assert!(matches!(decision, HookDecision::Block { .. })); + } + ++ #[tokio::test] ++ async fn command_hook_resolves_secret_token() { ++ let sandbox = make_sandbox(); ++ let decision = HookExecutorImpl::execute_command( ++ &make_definition(r#"test "{{ secrets.HOOK_TOKEN }}" = "staging""#), ++ &interp(r#"test "{{ secrets.HOOK_TOKEN }}" = "staging""#), ++ &make_context(), ++ &sandbox, ++ &HookExecutionContext::default(), ++ &test_env(&[]), ++ &test_secrets(&[("HOOK_TOKEN", "staging")]), ++ ) ++ .await; ++ ++ assert_eq!(decision, HookDecision::Proceed); ++ } ++ ++ #[tokio::test] ++ async fn command_hook_missing_secret_blocks() { ++ let sandbox = make_sandbox(); ++ let decision = HookExecutorImpl::execute_command( ++ &make_definition("echo {{ secrets.MISSING_HOOK_SECRET }}"), ++ &interp("echo {{ secrets.MISSING_HOOK_SECRET }}"), ++ &make_context(), ++ &sandbox, ++ &HookExecutionContext::default(), ++ &test_env(&[]), ++ &empty_secrets(), ++ ) ++ .await; ++ ++ match decision { ++ HookDecision::Block { reason } => { ++ assert!( ++ reason ++ .as_deref() ++ .is_some_and(|reason| reason.contains("MISSING_HOOK_SECRET")), ++ "block reason should name the missing secret, got: {reason:?}" ++ ); ++ } ++ other => panic!("expected Block on missing command secret, got {other:?}"), ++ } ++ } ++ + // Fail-closed: a prompt hook with a missing token does not fire the LLM + // call; it blocks with the resolution error, matching command hooks. + #[tokio::test] +@@ -1679,6 +1928,7 @@ mod tests { + None, + &make_context(), + &test_env(&[]), ++ &empty_secrets(), + test_llm_source().as_ref(), + test_catalog(), + ) +@@ -1708,6 +1958,7 @@ mod tests { + &make_context(), + make_sandbox(), + &test_env(&[]), ++ &empty_secrets(), + test_llm_source().as_ref(), + test_catalog(), + ) +diff --git a/lib/crates/fabro-hooks/src/lib.rs b/lib/crates/fabro-hooks/src/lib.rs +index 79a77bb98..d0d800f73 100644 +--- a/lib/crates/fabro-hooks/src/lib.rs ++++ b/lib/crates/fabro-hooks/src/lib.rs +@@ -2,6 +2,7 @@ pub mod bridge; + pub mod config; + pub mod executor; + pub mod runner; ++mod secrets; + pub mod types; + + pub use bridge::WorkflowToolHookCallback; +@@ -10,4 +11,5 @@ pub use config::{HookDefinition, HookSettings, HookType, TlsMode}; + // `InterpString`; constructing a hook definition requires it. + pub use fabro_types::settings::InterpString; + pub use runner::HookRunner; ++pub use secrets::{HookSecretResolver, ResolvedHookSecrets}; + pub use types::{HookContext, HookDecision, HookEvent, HookExecutionContext}; +diff --git a/lib/crates/fabro-hooks/src/runner.rs b/lib/crates/fabro-hooks/src/runner.rs +index 9435dba9a..006570730 100644 +--- a/lib/crates/fabro-hooks/src/runner.rs ++++ b/lib/crates/fabro-hooks/src/runner.rs +@@ -6,10 +6,12 @@ use fabro_auth::CredentialSource; + #[cfg(test)] + use fabro_auth::EnvCredentialSource; + use fabro_model::Catalog; ++use fabro_redact::SecretRedactor; + + use crate::config::{HookDefinition, HookSettings}; + use crate::executor::{HookExecutor, HookExecutorImpl}; +-use crate::types::{HookContext, HookDecision, HookExecutionContext}; ++use crate::secrets::HookSecretResolver; ++use crate::types::{HookContext, HookDecision, HookExecutionContext, HookResult}; + + /// Central orchestrator: filters matching hooks, executes them, merges + /// decisions. +@@ -18,16 +20,37 @@ pub struct HookRunner { + executor: Arc, + llm_source: Arc, + catalog: Arc, ++ secrets: HookSecretResolver, + /// Pre-compiled regexes keyed by matcher pattern string. + compiled_matchers: HashMap, + } + ++fn redact_hook_result(mut result: HookResult, redactor: &SecretRedactor) -> HookResult { ++ result.decision = redact_hook_decision(result.decision, redactor); ++ result ++} ++ ++fn redact_hook_decision(decision: HookDecision, redactor: &SecretRedactor) -> HookDecision { ++ match decision { ++ HookDecision::Skip { reason } => HookDecision::Skip { ++ reason: reason.map(|reason| redactor.redact_into(&reason)), ++ }, ++ HookDecision::Block { reason } => HookDecision::Block { ++ reason: reason.map(|reason| redactor.redact_into(&reason)), ++ }, ++ // `Override.edge_to` is a structural graph edge id, not free-form text, ++ // so it is intentionally left unredacted. ++ HookDecision::Proceed | HookDecision::Override { .. } => decision, ++ } ++} ++ + impl HookRunner { + #[must_use] + pub fn new( + config: HookSettings, + llm_source: Arc, + catalog: Arc, ++ secrets: HookSecretResolver, + ) -> Self { + let compiled_matchers = Self::compile_matchers(&config); + Self { +@@ -35,6 +58,7 @@ impl HookRunner { + executor: Arc::new(HookExecutorImpl), + llm_source, + catalog, ++ secrets, + compiled_matchers, + } + } +@@ -48,6 +72,7 @@ impl HookRunner { + executor, + llm_source: Arc::new(EnvCredentialSource::new()), + catalog: Arc::new(Catalog::from_builtin().expect("default catalog should build")), ++ secrets: HookSecretResolver::default(), + compiled_matchers, + } + } +@@ -100,7 +125,7 @@ impl HookRunner { + + tracing::info!( + event = %context.event, +- decision = ?decision, ++ decision = decision.as_str(), + "Hooks complete" + ); + +@@ -137,6 +162,43 @@ impl HookRunner { + .any(|field| field.is_some_and(|v| re.is_match(v))) + } + ++ /// Resolve secrets, run a single hook through the executor, and redact its ++ /// result. Shared by the blocking and non-blocking loops. ++ async fn execute_one( ++ &self, ++ hook: &HookDefinition, ++ context: &HookContext, ++ sandbox: Arc, ++ execution_context: &HookExecutionContext, ++ ) -> HookResult { ++ tracing::debug!( ++ hook = %hook.effective_name(), ++ event = %context.event, ++ "Executing hook" ++ ); ++ let secrets = self.secrets.resolve_for_definition(hook).await; ++ let result = self ++ .executor ++ .execute( ++ hook, ++ context, ++ sandbox, ++ execution_context, ++ self.llm_source.as_ref(), ++ Arc::clone(&self.catalog), ++ &secrets, ++ ) ++ .await; ++ let result = redact_hook_result(result, secrets.redactor()); ++ tracing::debug!( ++ hook = %hook.effective_name(), ++ duration_ms = result.duration_ms, ++ decision = result.decision.as_str(), ++ "Hook complete" ++ ); ++ result ++ } ++ + async fn run_sequential( + &self, + hooks: &[&HookDefinition], +@@ -146,28 +208,9 @@ impl HookRunner { + ) -> HookDecision { + let mut merged = HookDecision::Proceed; + for hook in hooks { +- tracing::debug!( +- hook = %hook.effective_name(), +- event = %context.event, +- "Executing hook" +- ); + let result = self +- .executor +- .execute( +- hook, +- context, +- sandbox.clone(), +- execution_context, +- self.llm_source.as_ref(), +- Arc::clone(&self.catalog), +- ) ++ .execute_one(hook, context, sandbox.clone(), execution_context) + .await; +- tracing::debug!( +- hook = %hook.effective_name(), +- duration_ms = result.duration_ms, +- decision = ?result.decision, +- "Hook complete" +- ); + + if hook.is_blocking() { + merged = merged.merge(result.decision); +@@ -176,7 +219,7 @@ impl HookRunner { + tracing::error!( + hook = %hook.effective_name(), + event = %context.event, +- decision = ?merged, ++ decision = merged.as_str(), + "Hook blocked execution" + ); + return merged; +@@ -185,7 +228,7 @@ impl HookRunner { + tracing::warn!( + hook = %hook.effective_name(), + event = %context.event, +- decision = ?result.decision, ++ decision = result.decision.as_str(), + "Non-blocking hook returned non-proceed, ignoring" + ); + } +@@ -201,33 +244,14 @@ impl HookRunner { + execution_context: &HookExecutionContext, + ) -> HookDecision { + for hook in hooks { +- tracing::debug!( +- hook = %hook.effective_name(), +- event = %context.event, +- "Executing hook" +- ); + let result = self +- .executor +- .execute( +- hook, +- context, +- sandbox.clone(), +- execution_context, +- self.llm_source.as_ref(), +- Arc::clone(&self.catalog), +- ) ++ .execute_one(hook, context, sandbox.clone(), execution_context) + .await; +- tracing::debug!( +- hook = %hook.effective_name(), +- duration_ms = result.duration_ms, +- decision = ?result.decision, +- "Hook complete" +- ); + if !result.decision.is_proceed() { + tracing::warn!( + hook = %hook.effective_name(), + event = %context.event, +- decision = ?result.decision, ++ decision = result.decision.as_str(), + "Non-blocking hook failed, continuing" + ); + } +@@ -259,6 +283,7 @@ mod tests { + _execution_context: &HookExecutionContext, + _llm_source: &dyn CredentialSource, + _catalog: Arc, ++ _secrets: &crate::ResolvedHookSecrets, + ) -> HookResult { + HookResult { + hook_name: definition.name.clone(), +@@ -301,7 +326,12 @@ mod tests { + + #[tokio::test] + async fn no_hooks_returns_proceed() { +- let runner = HookRunner::new(HookSettings::default(), test_llm_source(), test_catalog()); ++ let runner = HookRunner::new( ++ HookSettings::default(), ++ test_llm_source(), ++ test_catalog(), ++ HookSecretResolver::default(), ++ ); + let ctx = make_context(HookEvent::RunStart); + let sandbox = make_sandbox(); + let decision = runner +@@ -474,7 +504,12 @@ mod tests { + h + }], + }; +- let runner = HookRunner::new(config, test_llm_source(), test_catalog()); ++ let runner = HookRunner::new( ++ config, ++ test_llm_source(), ++ test_catalog(), ++ HookSecretResolver::default(), ++ ); + let ctx = make_context(HookEvent::RunStart); + let sandbox = make_sandbox(); + let decision = runner +@@ -492,7 +527,12 @@ mod tests { + h + }], + }; +- let runner = HookRunner::new(config, test_llm_source(), test_catalog()); ++ let runner = HookRunner::new( ++ config, ++ test_llm_source(), ++ test_catalog(), ++ HookSecretResolver::default(), ++ ); + let ctx = make_context(HookEvent::RunStart); + let sandbox = make_sandbox(); + let decision = runner +diff --git a/lib/crates/fabro-hooks/src/secrets.rs b/lib/crates/fabro-hooks/src/secrets.rs +new file mode 100644 +index 000000000..f78ccc518 +--- /dev/null ++++ b/lib/crates/fabro-hooks/src/secrets.rs +@@ -0,0 +1,134 @@ ++use std::collections::HashMap; ++use std::future::Future; ++use std::pin::Pin; ++use std::sync::Arc; ++ ++use fabro_redact::SecretRedactor; ++use fabro_types::settings::InterpString; ++use fabro_types::settings::interp::Namespace; ++ ++use crate::config::HookDefinition; ++ ++type SecretLookupFuture = Pin> + Send + 'static>>; ++type SecretLookup = dyn Fn(String) -> SecretLookupFuture + Send + Sync + 'static; ++ ++/// Per-run hook secret resolver. ++/// ++/// The resolver is cheap to clone and must be constructed per run. It returns ++/// only token-shaped vault secrets supplied by the worker and shares the run's ++/// [`SecretRedactor`] so values resolved by hooks join the same redaction ++/// registry as run-boundary environment and prepare-step secrets. ++#[derive(Clone)] ++pub struct HookSecretResolver { ++ lookup: Option>, ++ redactor: SecretRedactor, ++} ++ ++impl HookSecretResolver { ++ #[must_use] ++ pub fn new(redactor: SecretRedactor) -> Self { ++ Self { ++ lookup: None, ++ redactor, ++ } ++ } ++ ++ #[must_use] ++ pub fn with_lookup(redactor: SecretRedactor, lookup: F) -> Self ++ where ++ F: Fn(String) -> Fut + Send + Sync + 'static, ++ Fut: Future> + Send + 'static, ++ { ++ Self { ++ lookup: Some(Arc::new(move |name| Box::pin(lookup(name)))), ++ redactor, ++ } ++ } ++ ++ pub async fn resolve_for_definition(&self, definition: &HookDefinition) -> ResolvedHookSecrets { ++ self.resolve_names(secret_names_for_definition(definition)) ++ .await ++ } ++ ++ async fn resolve_names(&self, names: I) -> ResolvedHookSecrets ++ where ++ I: IntoIterator, ++ S: AsRef, ++ { ++ let mut values = HashMap::new(); ++ let Some(lookup) = self.lookup.as_ref() else { ++ return ResolvedHookSecrets::new(values, self.redactor.clone()); ++ }; ++ ++ for name in names { ++ let name = name.as_ref(); ++ if values.contains_key(name) { ++ continue; ++ } ++ if let Some(value) = lookup(name.to_string()).await { ++ values.insert(name.to_string(), value); ++ } ++ } ++ ++ ResolvedHookSecrets::new(values, self.redactor.clone()) ++ } ++} ++ ++impl Default for HookSecretResolver { ++ fn default() -> Self { ++ Self::new(SecretRedactor::default()) ++ } ++} ++ ++/// Secrets resolved for one hook firing. ++#[derive(Clone, Default)] ++pub struct ResolvedHookSecrets { ++ values: HashMap, ++ redactor: SecretRedactor, ++} ++ ++impl ResolvedHookSecrets { ++ #[must_use] ++ pub fn new(values: HashMap, redactor: SecretRedactor) -> Self { ++ for value in values.values() { ++ redactor.register(value); ++ } ++ Self { values, redactor } ++ } ++ ++ #[must_use] ++ pub fn lookup(&self, name: &str) -> Option { ++ // Values were registered with the redactor at construction time in ++ // `new`, so this is a pure read. ++ self.values.get(name).cloned() ++ } ++ ++ #[must_use] ++ pub fn redactor(&self) -> &SecretRedactor { ++ &self.redactor ++ } ++} ++ ++pub(crate) fn secret_names(value: &InterpString) -> Vec<&str> { ++ value.names(Namespace::Secrets) ++} ++ ++pub(crate) fn first_secret_name(value: &InterpString) -> Option<&str> { ++ // Cheap containment check first: the common no-secret case avoids ++ // collecting the name list. ++ value ++ .references(Namespace::Secrets) ++ .then(|| secret_names(value).into_iter().next()) ++ .flatten() ++} ++ ++fn secret_names_for_definition(definition: &HookDefinition) -> Vec { ++ let Some(hook_type) = definition.resolved_hook_type() else { ++ return Vec::new(); ++ }; ++ hook_type ++ .interp_strings() ++ .flat_map(secret_names) ++ .map(str::to_string) ++ .collect() ++} +diff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/crates/fabro-hooks/src/types.rs +index 8a1cba192..da45268a8 100644 +--- a/lib/crates/fabro-hooks/src/types.rs ++++ b/lib/crates/fabro-hooks/src/types.rs +@@ -82,8 +82,9 @@ pub struct PromptHookResponse { + } + + /// Decision returned by blocking hooks. +-#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] ++#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, strum::IntoStaticStr)] + #[serde(tag = "decision", rename_all = "snake_case")] ++#[strum(serialize_all = "snake_case")] + pub enum HookDecision { + #[default] + Proceed, +@@ -117,6 +118,12 @@ impl HookDecision { + pub fn is_proceed(&self) -> bool { + matches!(self, Self::Proceed) + } ++ ++ /// Variant label matching the serde `decision` tag, for logging. ++ #[must_use] ++ pub fn as_str(&self) -> &'static str { ++ self.into() ++ } + } + + /// Realm-specific locations available to hook execution. +diff --git a/lib/crates/fabro-hooks/tests/host_command_hooks.rs b/lib/crates/fabro-hooks/tests/host_command_hooks.rs +index 4bbe0c5a3..2ff53ff54 100644 +--- a/lib/crates/fabro-hooks/tests/host_command_hooks.rs ++++ b/lib/crates/fabro-hooks/tests/host_command_hooks.rs +@@ -5,7 +5,7 @@ use fabro_agent::{LocalSandbox, Sandbox}; + use fabro_auth::{CredentialSource, EnvCredentialSource}; + use fabro_hooks::{ + HookContext, HookDecision, HookDefinition, HookEvent, HookExecutionContext, HookRunner, +- HookSettings, InterpString, ++ HookSecretResolver, HookSettings, InterpString, + }; + use fabro_model::Catalog; + use fabro_types::RunId; +@@ -54,6 +54,7 @@ async fn host_command_hook_uses_host_workdir_not_sandbox_workdir() { + }, + test_llm_source(), + test_catalog(), ++ HookSecretResolver::default(), + ); + let context = HookContext::new( + HookEvent::RunStart, +diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs +index 3c1568fc0..bc402e0ae 100644 +--- a/lib/crates/fabro-sandbox/src/daytona/mod.rs ++++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs +@@ -1106,7 +1106,10 @@ impl Sandbox for DaytonaSandbox { + }, + ); + tracing::warn!( +- error = %crate::display_for_log(&err), ++ error = %crate::display_for_log( ++ &err, ++ &fabro_redact::SecretRedactor::default(), ++ ), + "Failed to set Daytona sandbox push credentials \ + on origin — subsequent git push from this \ + sandbox will fail" +diff --git a/lib/crates/fabro-sandbox/src/error.rs b/lib/crates/fabro-sandbox/src/error.rs +index 7b2377660..47164e21f 100644 +--- a/lib/crates/fabro-sandbox/src/error.rs ++++ b/lib/crates/fabro-sandbox/src/error.rs +@@ -2,6 +2,7 @@ use std::fmt::Write as _; + + #[cfg(feature = "docker")] + use bollard::errors::Error as BollardError; ++use fabro_redact::SecretRedactor; + use fabro_util::error::{collect_causes, render_with_causes}; + + use crate::ExecResult; +@@ -75,8 +76,11 @@ impl Error { + } + } + +- pub fn default_redacted_output_tail(&self) -> Option { +- default_redacted_output_tail(self) ++ pub fn default_redacted_output_tail( ++ &self, ++ redactor: &SecretRedactor, ++ ) -> Option { ++ default_redacted_output_tail(self, redactor) + } + + #[cfg(feature = "docker")] +@@ -161,22 +165,29 @@ fn format_exit_code(exit_code: Option) -> String { + + pub type Result = std::result::Result; + ++/// Walk the error chain and build the redacted output tail of the first ++/// sandbox exec failure. The run-scoped exact-match `redactor` is applied ++/// after content-based redaction; an empty redactor is an identity. + pub fn default_redacted_output_tail( + err: &(dyn std::error::Error + 'static), ++ redactor: &SecretRedactor, + ) -> Option { + let mut current = Some(err); + while let Some(err) = current { + if let Some(Error::Exec { result, .. }) = err.downcast_ref::() { +- return result.default_redacted_output_tail(); ++ return result.default_redacted_output_tail(redactor); + } + current = err.source(); + } + None + } + +-pub fn display_for_log(err: &(dyn std::error::Error + 'static)) -> String { ++pub fn display_for_log( ++ err: &(dyn std::error::Error + 'static), ++ redactor: &SecretRedactor, ++) -> String { + let mut rendered = render_with_causes(&err.to_string(), &collect_causes(err)); +- if let Some(tail) = default_redacted_output_tail(err) { ++ if let Some(tail) = default_redacted_output_tail(err, redactor) { + append_tail_for_log( + &mut rendered, + "stderr", +@@ -264,7 +275,7 @@ mod tests { + }); + let error = Error::context("metadata push failed", exec_error); + +- let rendered = display_for_log(&error); ++ let rendered = display_for_log(&error, &SecretRedactor::default()); + + assert!(rendered.contains("metadata push failed")); + assert!(rendered.contains("git push origin refs/heads/run")); +@@ -284,7 +295,7 @@ mod tests { + duration_ms: 210, + }); + +- let rendered = display_for_log(&error); ++ let rendered = display_for_log(&error, &SecretRedactor::default()); + + assert!( + !rendered.contains("ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA"), +@@ -297,7 +308,7 @@ mod tests { + fn display_for_log_for_non_exec_error_returns_chain_only() { + let error = Error::context("outer failure", std::io::Error::other("leaf failure")); + +- let rendered = display_for_log(&error); ++ let rendered = display_for_log(&error, &SecretRedactor::default()); + + assert_eq!(rendered, "outer failure\n caused by: leaf failure"); + assert!(!rendered.contains("--- stderr")); +@@ -331,7 +342,9 @@ mod tests { + duration_ms: 210, + }); + +- let tail = error.default_redacted_output_tail().expect("tail present"); ++ let tail = error ++ .default_redacted_output_tail(&SecretRedactor::default()) ++ .expect("tail present"); + assert_eq!(tail.stdout.as_deref(), Some("last stdout line")); + assert!( + tail.stderr +@@ -352,7 +365,8 @@ mod tests { + }); + let error = Error::context("metadata push failed", exec_error); + +- let tail = default_redacted_output_tail(&error).expect("tail present"); ++ let tail = ++ default_redacted_output_tail(&error, &SecretRedactor::default()).expect("tail present"); + + assert_eq!(tail.stdout.as_deref(), Some("last stdout line")); + assert_eq!(tail.stderr.as_deref(), Some("last stderr line")); +diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs +index b7e4751e6..9ffec14ec 100644 +--- a/lib/crates/fabro-sandbox/src/lib.rs ++++ b/lib/crates/fabro-sandbox/src/lib.rs +@@ -41,6 +41,7 @@ pub use details::sandbox_details; + #[cfg(feature = "docker")] + pub use docker::{DockerSandbox, DockerSandboxOptions}; + pub use error::{Error, Result, default_redacted_output_tail, display_for_log}; ++pub use fabro_redact::SecretRedactor; + pub use fabro_types::{RunSandboxInstance, SandboxProviderKind}; + pub use local::LocalSandbox; + #[cfg(feature = "daytona")] +diff --git a/lib/crates/fabro-sandbox/src/provider.rs b/lib/crates/fabro-sandbox/src/provider.rs +index b72d64efc..360e8d8a6 100644 +--- a/lib/crates/fabro-sandbox/src/provider.rs ++++ b/lib/crates/fabro-sandbox/src/provider.rs +@@ -26,7 +26,7 @@ pub enum SandboxCreateSpec { + Local, + #[cfg(feature = "docker")] + Docker { +- config: DockerSandboxOptions, ++ config: Box, + github_app: Option, + run_id: Option, + clone_origin_url: Option, +diff --git a/lib/crates/fabro-sandbox/src/provider/docker.rs b/lib/crates/fabro-sandbox/src/provider/docker.rs +index f1f8bb725..3e81e5f28 100644 +--- a/lib/crates/fabro-sandbox/src/provider/docker.rs ++++ b/lib/crates/fabro-sandbox/src/provider/docker.rs +@@ -99,7 +99,7 @@ impl SandboxProvider for DockerSandboxProvider { + }; + + let sandbox = +- DockerSandbox::new(config, github_app, run_id, clone_origin_url, clone_branch)?; ++ DockerSandbox::new(*config, github_app, run_id, clone_origin_url, clone_branch)?; + sandbox.initialize().await?; + let container_id = sandbox.container_identifier()?.to_string(); + self.get(&container_id).await?.ok_or_else(|| { +diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs +index 68801f8bb..f9ac2f1a9 100644 +--- a/lib/crates/fabro-sandbox/src/sandbox.rs ++++ b/lib/crates/fabro-sandbox/src/sandbox.rs +@@ -7,6 +7,7 @@ use std::sync::Arc; + use std::time::Duration; + + use async_trait::async_trait; ++use fabro_redact::SecretRedactor; + use fabro_types::{CommandOutputStream, CommandTermination}; + use fabro_util::shell; + use serde::{Deserialize, Serialize}; +@@ -568,12 +569,16 @@ impl ExecResult { + pub fn redacted_output_tail( + &self, + max_bytes_per_stream: usize, ++ redactor: &SecretRedactor, + ) -> Option { +- redacted_output_tail(&self.stdout, &self.stderr, max_bytes_per_stream) ++ redacted_output_tail(&self.stdout, &self.stderr, max_bytes_per_stream, redactor) + } + +- pub fn default_redacted_output_tail(&self) -> Option { +- self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) ++ pub fn default_redacted_output_tail( ++ &self, ++ redactor: &SecretRedactor, ++ ) -> Option { ++ self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redactor) + } + + /// Converts host process output into the canonical full exec result. +@@ -600,15 +605,18 @@ impl ExecResult { + + /// Build a redacted `ExecOutputTail` from raw stdout/stderr without + /// fabricating a synthetic `ExecResult`. Pass `""` for either stream that +-/// isn't relevant. Returns `None` when both streams are empty. ++/// isn't relevant. Returns `None` when both streams are empty. The run-scoped ++/// exact-match `redactor` is applied after the content-based redaction ++/// baseline; an empty redactor is an identity. + #[must_use] + pub fn redacted_output_tail( + stdout: &str, + stderr: &str, + max_bytes_per_stream: usize, ++ redactor: &SecretRedactor, + ) -> Option { +- let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream); +- let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream); ++ let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream, redactor); ++ let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream, redactor); + let tail = fabro_types::ExecOutputTail { + stdout, + stderr, +@@ -618,12 +626,19 @@ pub fn redacted_output_tail( + (!tail.is_empty()).then_some(tail) + } + +-fn redacted_tail(text: &str, max_bytes: usize) -> (Option, bool) { ++fn redacted_tail( ++ text: &str, ++ max_bytes: usize, ++ redactor: &SecretRedactor, ++) -> (Option, bool) { + if text.is_empty() || max_bytes == 0 { + return (None, !text.is_empty()); + } + +- let redacted = fabro_redact::redact_string(text); ++ let mut redacted = fabro_redact::redact_string(text); ++ if !redactor.is_empty() { ++ redacted = redactor.redact_into(&redacted); ++ } + let sanitized = sanitize_exec_output(&redacted); + let truncated = sanitized.len() > max_bytes; + let start = if truncated { +@@ -1172,7 +1187,7 @@ pub async fn git_push_via_exec(sandbox: &dyn Sandbox, refspec: &str) -> crate::R + if let Err(e) = sandbox.refresh_push_credentials().await { + tracing::warn!( + refspec = %refspec, +- error = %crate::display_for_log(&e), ++ error = %crate::display_for_log(&e, &SecretRedactor::default()), + "Failed to refresh push credentials before git push" + ); + } +@@ -1274,7 +1289,7 @@ mod tests { + }; + + let tail = result +- .redacted_output_tail(32) ++ .redacted_output_tail(32, &SecretRedactor::default()) + .expect("redacted output tail"); + let stdout = tail.stdout.expect("stdout tail"); + assert!(stdout.contains("REDACTED"), "{stdout}"); +@@ -1282,6 +1297,27 @@ mod tests { + assert!(tail.stdout_truncated); + } + ++ #[test] ++ fn exec_result_redacts_registered_low_entropy_secret_before_taking_tail() { ++ let redactor = SecretRedactor::default(); ++ redactor.register("staging"); ++ let result = ExecResult { ++ stdout: format!("{} staging done", "context ".repeat(20)), ++ stderr: String::new(), ++ exit_code: Some(1), ++ termination: CommandTermination::Exited, ++ duration_ms: 1, ++ }; ++ ++ let tail = result ++ .redacted_output_tail(32, &redactor) ++ .expect("redacted output tail"); ++ let stdout = tail.stdout.expect("stdout tail"); ++ assert!(stdout.contains("REDACTED"), "{stdout}"); ++ assert!(!stdout.contains("staging"), "{stdout}"); ++ assert!(tail.stdout_truncated); ++ } ++ + #[test] + fn exec_result_tail_sanitizes_terminal_control_sequences() { + let result = ExecResult { +@@ -1295,7 +1331,7 @@ mod tests { + }; + + let tail = result +- .redacted_output_tail(1024) ++ .redacted_output_tail(1024, &SecretRedactor::default()) + .expect("redacted output tail"); + let stdout = tail.stdout.expect("stdout tail"); + assert_eq!(stdout, "red shown set two-byte backspace"); +@@ -1338,7 +1374,7 @@ mod tests { + + let result = ExecResult::from_process_output(output, 3); + let tail = result +- .redacted_output_tail(16) ++ .redacted_output_tail(16, &SecretRedactor::default()) + .expect("redacted output tail"); + + assert!(tail.stdout.expect("stdout tail").len() <= 16); +@@ -1355,7 +1391,9 @@ mod tests { + duration_ms: 1, + }; + +- let tail = result.default_redacted_output_tail().expect("tail present"); ++ let tail = result ++ .default_redacted_output_tail(&SecretRedactor::default()) ++ .expect("tail present"); + assert_eq!( + tail.stdout.as_deref().map(str::len), + Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES) +diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs +index b6a56bd40..5b8ead6d0 100644 +--- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs ++++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs +@@ -27,7 +27,7 @@ pub enum SandboxSpec { + }, + #[cfg(feature = "docker")] + Docker { +- config: DockerSandboxOptions, ++ config: Box, + github_app: Option, + run_id: Option, + clone_origin_url: Option, +@@ -204,7 +204,7 @@ impl SandboxSpec { + clone_branch, + } => { + let mut sandbox = DockerSandbox::new( +- config.clone(), ++ config.as_ref().clone(), + github_app.clone(), + *run_id, + clone_origin_url.clone(), +@@ -271,7 +271,7 @@ mod tests { + #[test] + fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() { + let spec = SandboxSpec::Docker { +- config: DockerSandboxOptions::default(), ++ config: Box::new(DockerSandboxOptions::default()), + github_app: None, + run_id: None, + clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()), +@@ -306,10 +306,10 @@ mod tests { + #[test] + fn docker_run_sandbox_omits_primary_repo_metadata_for_empty_workspace() { + let spec = SandboxSpec::Docker { +- config: DockerSandboxOptions { ++ config: Box::new(DockerSandboxOptions { + skip_clone: true, + ..DockerSandboxOptions::default() +- }, ++ }), + github_app: None, + run_id: None, + clone_origin_url: Some("https://gitlab.com/acme/widgets".to_string()), +diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs +index d7794be48..fe328c7d3 100644 +--- a/lib/crates/fabro-server/src/run_manifest.rs ++++ b/lib/crates/fabro-server/src/run_manifest.rs +@@ -890,7 +890,7 @@ fn preflight_sandbox_spec( + let mut config = resolve_docker_config(resolved_run); + config.skip_clone = true; + SandboxSpec::Docker { +- config, ++ config: Box::new(config), + github_app, + run_id: None, + clone_origin_url, +diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs +index 95b3a2282..97fd950b0 100644 +--- a/lib/crates/fabro-types/src/settings/run.rs ++++ b/lib/crates/fabro-types/src/settings/run.rs +@@ -2169,6 +2169,24 @@ pub enum HookType { + }, + } + ++impl HookType { ++ /// The fields of this hook type that support `{{ env.* }}` / ++ /// `{{ secrets.* }}` interpolation at fire time. ++ /// ++ /// HTTP `headers` are intentionally excluded: headers only accept ++ /// allowlisted `{{ env.* }}` tokens and reject secret tokens outright. ++ pub fn interp_strings(&self) -> impl Iterator { ++ let (first, second) = match self { ++ Self::Command { command } => (command, None), ++ Self::Http { url, .. } => (url, None), ++ Self::Prompt { prompt, model } | Self::Agent { prompt, model, .. } => { ++ (prompt, model.as_ref()) ++ } ++ }; ++ std::iter::once(first).chain(second) ++ } ++} ++ + #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] + pub struct HookDefinition { + pub name: Option, +diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/crates/fabro-workflow/src/error.rs +index 241787868..5a70e85a7 100644 +--- a/lib/crates/fabro-workflow/src/error.rs ++++ b/lib/crates/fabro-workflow/src/error.rs +@@ -509,8 +509,12 @@ impl Error { + category: self.failure_category(), + system_actor: None, + signature: self.failure_signature_hint(), +- exec_output_tail: explicit_exec_output_tail +- .or_else(|| fabro_sandbox::default_redacted_output_tail(self)), ++ exec_output_tail: explicit_exec_output_tail.or_else(|| { ++ fabro_sandbox::default_redacted_output_tail( ++ self, ++ &fabro_redact::SecretRedactor::default(), ++ ) ++ }), + } + } + +diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs +index e64b2ae30..84f01f752 100644 +--- a/lib/crates/fabro-workflow/src/event.rs ++++ b/lib/crates/fabro-workflow/src/event.rs +@@ -16,6 +16,7 @@ pub use self::events::Event; + pub use self::names::event_name; + pub use self::redaction::{ + build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json, ++ redacted_run_event, + }; + pub use self::sink::{ + RunEventLogger, RunEventSink, StoreProgressLogger, append_event, append_event_to_sink, +diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs +index 0b5afb1c7..85707d0a2 100644 +--- a/lib/crates/fabro-workflow/src/event/events.rs ++++ b/lib/crates/fabro-workflow/src/event/events.rs +@@ -1330,7 +1330,7 @@ impl Event { + info!(command_count, "Setup started"); + } + Self::SetupCommandStarted { command, index } => { +- debug!(command, index, "Setup command started"); ++ debug!(command_len = command.len(), index, "Setup command started"); + } + Self::SetupCommandCompleted { + command, +@@ -1339,7 +1339,7 @@ impl Event { + duration_ms, + } => { + debug!( +- command, ++ command_len = command.len(), + index, exit_code, duration_ms, "Setup command completed" + ); + } +@@ -1355,7 +1355,7 @@ impl Event { + } => { + let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref()); + error!( +- command, ++ command_len = command.len(), + index, + exit_code, + exec_output_tail_present = tail.present, +diff --git a/lib/crates/fabro-workflow/src/event/redaction.rs b/lib/crates/fabro-workflow/src/event/redaction.rs +index 5ae44d111..7565e64c8 100644 +--- a/lib/crates/fabro-workflow/src/event/redaction.rs ++++ b/lib/crates/fabro-workflow/src/event/redaction.rs +@@ -1,26 +1,118 @@ + use ::fabro_types::{RunEvent, RunId}; + use anyhow::{Context, Result}; +-use fabro_redact::redact_json_value; ++use fabro_redact::{SecretRedactor, redact_json_value}; + use fabro_store::EventPayload; + use fabro_util::json::normalize_json_value; + use serde_json::Value; + + pub fn build_redacted_event_payload(event: &RunEvent, run_id: &RunId) -> Result { +- let value = redacted_event_value(event)?; +- EventPayload::new(value, run_id).map_err(anyhow::Error::from) ++ EventPayload::new(redacted_event_value(event)?, run_id).map_err(anyhow::Error::from) ++} ++ ++/// Redact an event and reconstruct it as a `RunEvent`. ++/// ++/// This runs the event through the content-based pass plus the per-run ++/// exact-match [`SecretRedactor`], then reparses the redacted payload back ++/// into a `RunEvent` so downstream sinks that require a typed event never see ++/// the raw value. An empty redactor is an identity for the exact-match pass. ++pub fn redacted_run_event(event: &RunEvent, redactor: &SecretRedactor) -> Result { ++ let mut value = redacted_event_value(event)?; ++ redact_event_payload_secrets(&mut value, redactor); ++ let payload = EventPayload::new(value, &event.run_id)?; ++ RunEvent::try_from(&payload).map_err(anyhow::Error::from) + } + + pub fn redacted_event_json(event: &RunEvent) -> Result { + serde_json::to_string(&redacted_event_value(event)?).map_err(anyhow::Error::from) + } + +-fn normalized_event_value(event: &RunEvent) -> Result { +- let value = event.to_value()?; +- Ok(normalize_json_value(value)) ++/// Content-based redaction over the normalized event payload. Exact-match ++/// per-run secret redaction is layered on top by [`redacted_run_event`]. ++fn redacted_event_value(event: &RunEvent) -> Result { ++ Ok(redact_json_value(normalize_json_value(event.to_value()?))) + } + +-fn redacted_event_value(event: &RunEvent) -> Result { +- Ok(redact_json_value(normalized_event_value(event)?)) ++fn redact_event_payload_secrets(value: &mut Value, redactor: &SecretRedactor) { ++ // No declared secrets (the common case): skip the recursive property walk ++ // entirely. Content-based redaction already ran in `redacted_event_value`. ++ if redactor.is_empty() { ++ return; ++ } ++ if let Some(properties) = value.get_mut("properties") { ++ redact_redactable_event_properties(properties, redactor); ++ } ++ if let Some(Value::String(label)) = value.get_mut("node_label") { ++ let redacted = redactor.redact_into(label); ++ if redacted != *label { ++ *label = redacted; ++ } ++ } ++} ++ ++fn redact_redactable_event_properties(value: &mut Value, redactor: &SecretRedactor) { ++ match value { ++ Value::Object(obj) => { ++ for (key, child) in obj { ++ if is_secret_redactable_event_property(key) { ++ *child = redactor.redact_json(std::mem::take(child)); ++ } else { ++ redact_redactable_event_properties(child, redactor); ++ } ++ } ++ } ++ Value::Array(items) => { ++ for item in items { ++ redact_redactable_event_properties(item, redactor); ++ } ++ } ++ Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} ++ } ++} ++ ++// Exact-match secret values may be intentionally low entropy ("staging", ++// "pause", "running"). Redacting every string in an event can therefore corrupt ++// structural fields that are validated enum values or IDs. Keep this list to ++// free-form text/blob fields where replacing a matched substring preserves the ++// event schema and projection semantics. ++fn is_secret_redactable_event_property(key: &str) -> bool { ++ matches!( ++ key, ++ "active_form" ++ | "answer" ++ | "arguments" ++ | "causes" ++ | "command" ++ | "context_display" ++ | "delta" ++ | "description" ++ | "details" ++ | "diff" ++ | "error" ++ | "error_message" ++ | "exec_output_tail" ++ | "failure" ++ | "final_patch" ++ | "goal" ++ | "input" ++ | "message" ++ | "notes" ++ | "output" ++ | "preview" ++ | "prompt" ++ | "question" ++ | "reason" ++ | "response" ++ | "script" ++ | "stderr" ++ | "stdout" ++ | "subject" ++ | "text" ++ | "title" ++ | "tool_input" ++ | "tool_output" ++ | "workflow_config" ++ | "workflow_source" ++ ) + } + + pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result { +@@ -72,4 +164,90 @@ mod tests { + "plain stderr" + ); + } ++ ++ #[test] ++ fn redacted_run_event_redacts_registered_low_entropy_secret() { ++ let redactor = fabro_redact::SecretRedactor::default(); ++ redactor.register("staging"); ++ let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed { ++ command: "deploy staging".to_string(), ++ index: 0, ++ exit_code: 1, ++ stderr: "failed in staging".to_string(), ++ exec_output_tail: None, ++ }); ++ ++ let redacted = redacted_run_event(&stored, &redactor).unwrap(); ++ let redacted_text = serde_json::to_string(&redacted.to_value().unwrap()).unwrap(); ++ ++ assert!(!redacted_text.contains("staging")); ++ assert!(redacted_text.contains("REDACTED")); ++ } ++ ++ #[test] ++ fn redacted_run_event_redactors_are_isolated_per_run() { ++ let first = fabro_redact::SecretRedactor::default(); ++ first.register("alpha"); ++ let second = fabro_redact::SecretRedactor::default(); ++ second.register("bravo"); ++ let stored = to_run_event(&fixtures::RUN_8, &Event::SetupCommandStarted { ++ command: "echo alpha bravo".to_string(), ++ index: 0, ++ }); ++ ++ let first_text = serde_json::to_string( ++ &redacted_run_event(&stored, &first) ++ .unwrap() ++ .to_value() ++ .unwrap(), ++ ) ++ .unwrap(); ++ let second_text = serde_json::to_string( ++ &redacted_run_event(&stored, &second) ++ .unwrap() ++ .to_value() ++ .unwrap(), ++ ) ++ .unwrap(); ++ ++ assert!(!first_text.contains("alpha")); ++ assert!(first_text.contains("bravo")); ++ assert!(second_text.contains("alpha")); ++ assert!(!second_text.contains("bravo")); ++ } ++ ++ #[test] ++ fn redacted_run_event_preserves_structural_event_fields() { ++ let redactor = fabro_redact::SecretRedactor::default(); ++ redactor.register("setup.failed"); ++ let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed { ++ command: "echo setup.failed".to_string(), ++ index: 0, ++ exit_code: 1, ++ stderr: "setup.failed".to_string(), ++ exec_output_tail: None, ++ }); ++ ++ let redacted = redacted_run_event(&stored, &redactor).unwrap(); ++ ++ assert_eq!(redacted.event_name(), "setup.failed"); ++ let value = redacted.to_value().unwrap(); ++ assert_eq!(value["properties"]["command"], "echo REDACTED"); ++ assert_eq!(value["properties"]["stderr"], "REDACTED"); ++ } ++ ++ #[test] ++ fn redacted_run_event_preserves_structural_property_values() { ++ let redactor = fabro_redact::SecretRedactor::default(); ++ redactor.register("pause"); ++ let stored = to_run_event(&fixtures::RUN_8, &Event::RunPauseRequested { actor: None }); ++ ++ let redacted = redacted_run_event(&stored, &redactor).unwrap(); ++ ++ assert_eq!(redacted.event_name(), "run.pause.requested"); ++ assert_eq!( ++ redacted.to_value().unwrap()["properties"]["action"], ++ "pause" ++ ); ++ } + } +diff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs +index 1017b2b29..0281a4165 100644 +--- a/lib/crates/fabro-workflow/src/event/sink.rs ++++ b/lib/crates/fabro-workflow/src/event/sink.rs +@@ -4,12 +4,13 @@ use std::sync::Arc; + + use ::fabro_types::{RunEvent, RunId}; + use anyhow::Result; ++use fabro_redact::SecretRedactor; + use fabro_store::RunDatabase; + use tokio::io::{AsyncWrite, AsyncWriteExt}; + use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot}; + + use super::emitter::Emitter; +-use super::redaction::{build_redacted_event_payload, redacted_event_json}; ++use super::redaction::{build_redacted_event_payload, redacted_event_json, redacted_run_event}; + use super::{Event, to_run_event}; + use crate::runtime_store::RunStoreHandle; + +@@ -41,6 +42,10 @@ pub enum RunEventSink { + transform: Arc, + inner: Box, + }, ++ RedactSecrets { ++ redactor: SecretRedactor, ++ inner: Box, ++ }, + Composite(Vec), + } + +@@ -99,6 +104,14 @@ impl RunEventSink { + } + } + ++ #[must_use] ++ pub fn with_secret_redactor(self, redactor: SecretRedactor) -> Self { ++ Self::RedactSecrets { ++ redactor, ++ inner: Box::new(self), ++ } ++ } ++ + pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> { + let mut pending = vec![(self, event.clone())]; + while let Some((sink, event)) = pending.pop() { +@@ -113,10 +126,24 @@ impl RunEventSink { + writer.write_all(b"\n").await?; + writer.flush().await?; + } +- Self::Callback(callback) => callback(event).await?, ++ Self::Callback(callback) => { ++ callback(event).await?; ++ } + Self::Map { transform, inner } => { + pending.push((inner.as_ref(), transform(event))); + } ++ Self::RedactSecrets { redactor, inner } => { ++ // Redact once at the wrapper so every inner sink sees the ++ // same already-redacted event. With no declared secrets ++ // (the common case) the event passes through untouched and ++ // leaves apply their own content-based redaction as usual. ++ let event = if redactor.is_empty() { ++ event ++ } else { ++ redacted_run_event(&event, redactor)? ++ }; ++ pending.push((inner.as_ref(), event)); ++ } + Self::Composite(sinks) => { + for sink in sinks.iter().rev() { + pending.push((sink, event.clone())); +@@ -334,6 +361,34 @@ mod tests { + assert_eq!(second[0].actor, Some(user_principal("alice"))); + } + ++ #[tokio::test] ++ async fn run_event_sink_redacts_callback_events_with_run_secret_redactor() { ++ let captured = Arc::new(AsyncMutex::new(Vec::new())); ++ let captured_events = Arc::clone(&captured); ++ let redactor = fabro_redact::SecretRedactor::default(); ++ redactor.register("staging"); ++ let sink = RunEventSink::callback(move |event| { ++ let captured_events = Arc::clone(&captured_events); ++ async move { ++ captured_events.lock().await.push(event); ++ Ok(()) ++ } ++ }) ++ .with_secret_redactor(redactor); ++ let event = to_run_event(&fixtures::RUN_7, &Event::SetupCommandStarted { ++ command: "deploy staging".to_string(), ++ index: 0, ++ }); ++ ++ sink.write_run_event(&event).await.unwrap(); ++ ++ let captured = captured.lock().await; ++ assert_eq!(captured.len(), 1); ++ let captured_text = serde_json::to_string(&captured[0].to_value().unwrap()).unwrap(); ++ assert!(!captured_text.contains("staging")); ++ assert!(captured_text.contains("REDACTED")); ++ } ++ + #[tokio::test] + async fn run_event_logger_registers_emitter_events_to_json_lines() { + use tokio::io::{AsyncBufReadExt, BufReader}; +diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs +index 108a72369..ff80de2c0 100644 +--- a/lib/crates/fabro-workflow/src/handler/parallel.rs ++++ b/lib/crates/fabro-workflow/src/handler/parallel.rs +@@ -205,14 +205,20 @@ impl Handler for ParallelHandler { + } + Err(e) => { + tracing::warn!( +- error = %fabro_sandbox::display_for_log(&e), ++ error = %fabro_sandbox::display_for_log( ++ &e, ++ &services.run.secret_redactor, ++ ), + "parallel base checkpoint failed" + ); + services.run.emitter.notice_with_tail( + RunNoticeLevel::Warn, + RunNoticeCode::ParallelBaseCheckpointFailed, + format!("Could not checkpoint base state before parallel branches: {e}"), +- fabro_sandbox::default_redacted_output_tail(&e), ++ fabro_sandbox::default_redacted_output_tail( ++ &e, ++ &services.run.secret_redactor, ++ ), + ); + None + } +diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs +index 5133fa602..82825e5d3 100644 +--- a/lib/crates/fabro-workflow/src/lifecycle/git.rs ++++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs +@@ -8,6 +8,7 @@ use fabro_core::lifecycle::RunLifecycle; + use fabro_core::outcome::NodeResult; + use fabro_core::state::ExecutionState; + use fabro_dump::RunDump; ++use fabro_redact::SecretRedactor; + use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase}; + use fabro_types::{CheckpointRecord, DiffSummary, RunDiff, RunId}; + use fabro_util::error::collect_causes; +@@ -84,6 +85,7 @@ pub(crate) struct GitLifecycle { + pub sandbox_git: Arc, + pub metadata_runtime: Arc, + pub metadata_writer: Option, ++ pub secret_redactor: SecretRedactor, + pub start_node_id: Option, + // Cross-lifecycle data (shared with EventLifecycle) + pub checkpoint_git_result: Arc>>, +@@ -311,10 +313,16 @@ impl RunLifecycle for GitLifecycle { + Ok(()) => (true, None), + Err(err) => { + let exec_output_tail = +- fabro_sandbox::default_redacted_output_tail(&err); ++ fabro_sandbox::default_redacted_output_tail( ++ &err, ++ &self.secret_redactor, ++ ); + tracing::warn!( + refspec = %refspec, +- error = %fabro_sandbox::display_for_log(&err), ++ error = %fabro_sandbox::display_for_log( ++ &err, ++ &self.secret_redactor, ++ ), + "git push from run lifecycle failed" + ); + self.emitter.notice_with_tail( +@@ -364,8 +372,10 @@ impl RunLifecycle for GitLifecycle { + } + Ok(_) => {} + Err(err) => { +- let exec_output_tail = +- fabro_sandbox::default_redacted_output_tail(&err); ++ let exec_output_tail = fabro_sandbox::default_redacted_output_tail( ++ &err, ++ &self.secret_redactor, ++ ); + self.emitter.notice_with_tail( + RunNoticeLevel::Warn, + RunNoticeCode::GitDiffFailed, +@@ -379,8 +389,10 @@ impl RunLifecycle for GitLifecycle { + git_result.diff_summary = Some(summarize_diff_numstat(&numstat)); + } + Some(Err(err)) => { +- let exec_output_tail = +- fabro_sandbox::default_redacted_output_tail(&err); ++ let exec_output_tail = fabro_sandbox::default_redacted_output_tail( ++ &err, ++ &self.secret_redactor, ++ ); + self.emitter.notice_with_tail( + RunNoticeLevel::Warn, + RunNoticeCode::GitDiffFailed, +@@ -399,7 +411,8 @@ impl RunLifecycle for GitLifecycle { + .expect("git lifecycle mutex should not be poisoned: no code panics while holding this lock") = Some(git_result); + } + Err(e) => { +- let exec_output_tail = fabro_sandbox::default_redacted_output_tail(&e); ++ let exec_output_tail = ++ fabro_sandbox::default_redacted_output_tail(&e, &self.secret_redactor); + let error = e.to_string(); + // Emit CheckpointFailed and return error + let scope = stage_scope_for(state, node_id); +@@ -797,6 +810,7 @@ mod tests { + sandbox_git: Arc::new(SandboxGitRuntime::new()), + metadata_runtime, + metadata_writer, ++ secret_redactor: SecretRedactor::default(), + start_node_id: Some("start".to_string()), + checkpoint_git_result: Arc::new(Mutex::new(None)), + last_git_sha: Arc::new(Mutex::new(None)), +@@ -1260,6 +1274,7 @@ mod tests { + "claude-sonnet-4-6".to_string(), + Arc::new(fabro_auth::EnvCredentialSource::new()), + Arc::new(Catalog::from_builtin().expect("default catalog should build")), ++ fabro_redact::SecretRedactor::default(), + Arc::new(SandboxGitRuntime::new()), + Arc::clone(&lifecycle.metadata_runtime), + lifecycle.metadata_writer.clone(), +diff --git a/lib/crates/fabro-workflow/src/lifecycle/mod.rs b/lib/crates/fabro-workflow/src/lifecycle/mod.rs +index 2db1e8d75..97b46a5a8 100644 +--- a/lib/crates/fabro-workflow/src/lifecycle/mod.rs ++++ b/lib/crates/fabro-workflow/src/lifecycle/mod.rs +@@ -94,6 +94,7 @@ impl WorkflowLifecycle { + sandbox_git: Arc, + metadata_runtime: Arc, + metadata_writer: Option, ++ secret_redactor: fabro_redact::SecretRedactor, + is_resume: bool, + on_node: crate::OnNodeCallback, + run_control: Option>, +@@ -161,6 +162,7 @@ impl WorkflowLifecycle { + sandbox_git, + metadata_runtime, + metadata_writer, ++ secret_redactor, + start_node_id, + checkpoint_git_result: Arc::clone(&checkpoint_git_result), + last_git_sha, +diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs +index 7fa47e938..463a55609 100644 +--- a/lib/crates/fabro-workflow/src/operations/start.rs ++++ b/lib/crates/fabro-workflow/src/operations/start.rs +@@ -8,6 +8,7 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer}; + use fabro_llm::client::Client as LlmClient; + use fabro_mcp::config::McpServerSettings; + use fabro_model::{Catalog, FallbackTarget, ProviderId}; ++use fabro_redact::SecretRedactor; + use fabro_sandbox::daytona::DaytonaConfig; + use fabro_sandbox::from_environment::{ + daytona_config_from_environment, docker_config_from_environment_with_secrets, +@@ -79,6 +80,8 @@ struct RunSession { + workflow_bundle: Option>, + run_control: Option>, + vault: Option>>, ++ secret_redactor: SecretRedactor, ++ hook_secrets: fabro_hooks::HookSecretResolver, + catalog: Arc, + fabro_run_tools: Option, + } +@@ -252,10 +255,13 @@ pub(super) async fn execute_persisted_run( + }; + + bootstrap_guard.defuse(); ++ let terminal_event_sink = event_sink ++ .clone() ++ .with_secret_redactor(session.secret_redactor.clone()); + let mut completion_guard = DetachedRunCompletionGuard::arm( + run_id, + run_store.clone(), +- event_sink.clone(), ++ terminal_event_sink.clone(), + cancel_token, + ); + let run_start = Instant::now(); +@@ -270,7 +276,7 @@ pub(super) async fn execute_persisted_run( + persist_terminal_engine_failure( + run_id, + &run_store, +- &event_sink, ++ &terminal_event_sink, + run_dir, + &err, + run_start.elapsed(), +@@ -377,10 +383,14 @@ impl RunSession { + Some(vault) => Some(vault.read().await), + None => None, + }; ++ let secret_redactor = SecretRedactor::default(); ++ let hook_secrets = hook_secret_resolver(services.vault.clone(), secret_redactor.clone()); + // Token-only secrets lookup over the vault read guard, shared across + // every run-boundary resolver. A missing or non-Token secret becomes + // `None`, so resolution fails closed with a secret error. +- let secret_lookup = |name: &str| vault_token_lookup(vault_guard.as_deref(), name); ++ let secret_lookup = |name: &str| { ++ registered_vault_token_lookup(vault_guard.as_deref(), &secret_redactor, name) ++ }; + let mcp_servers = resolved + .agent + .mcps +@@ -419,7 +429,7 @@ impl RunSession { + SandboxSpec::Local { working_directory } + } + SandboxProviderKind::Docker => SandboxSpec::Docker { +- config: resolve_docker_config(resolved, secret_lookup)?, ++ config: Box::new(resolve_docker_config(resolved, secret_lookup)?), + github_app: services.github_app.clone(), + run_id: Some(record.run_id), + clone_origin_url: record.repo_origin_url().map(str::to_string), +@@ -505,6 +515,8 @@ impl RunSession { + workflow_path, + workflow_bundle, + vault: services.vault, ++ secret_redactor, ++ hook_secrets, + catalog, + fabro_run_tools: services.fabro_run_tools, + }) +@@ -566,6 +578,34 @@ fn vault_token_lookup(vault: Option<&Vault>, name: &str) -> Option { + vault.and_then(|vault| fabro_auth::vault_get_token(vault, name).ok().flatten()) + } + ++fn registered_vault_token_lookup( ++ vault: Option<&Vault>, ++ redactor: &SecretRedactor, ++ name: &str, ++) -> Option { ++ let value = vault_token_lookup(vault, name); ++ if let Some(value) = value.as_deref() { ++ redactor.register(value); ++ } ++ value ++} ++ ++fn hook_secret_resolver( ++ vault: Option>>, ++ redactor: SecretRedactor, ++) -> fabro_hooks::HookSecretResolver { ++ match vault { ++ Some(vault) => fabro_hooks::HookSecretResolver::with_lookup(redactor, move |name| { ++ let vault = Arc::clone(&vault); ++ async move { ++ let guard = vault.read().await; ++ vault_token_lookup(Some(&guard), &name) ++ } ++ }), ++ None => fabro_hooks::HookSecretResolver::new(redactor), ++ } ++} ++ + async fn load_accepted_run_definition( + run_store: &RunStoreHandle, + blob_id: fabro_types::RunBlobId, +@@ -827,7 +867,11 @@ impl RunSession { + }); + } + +- let store_progress_logger = RunEventLogger::new(self.event_sink.clone()); ++ let store_progress_logger = RunEventLogger::new( ++ self.event_sink ++ .clone() ++ .with_secret_redactor(self.secret_redactor.clone()), ++ ); + store_progress_logger.register(self.emitter.as_ref()); + + let init_options = InitOptions { +@@ -845,8 +889,10 @@ impl RunSession { + workflow_path: self.workflow_path, + workflow_bundle: self.workflow_bundle, + hooks: self.hooks, ++ hook_secrets: self.hook_secrets, + sandbox_env: self.sandbox_env, + vault: self.vault, ++ secret_redactor: self.secret_redactor, + git: self.git, + registry_override: self.registry_override, + artifact_sink: self.artifact_sink, +@@ -855,7 +901,13 @@ impl RunSession { + seed_context: self.seed_context, + fabro_run_tools: self.fabro_run_tools, + }; +- let mut initialized = Box::pin(pipeline::initialize(persisted, init_options)).await?; ++ let mut initialized = match Box::pin(pipeline::initialize(persisted, init_options)).await { ++ Ok(initialized) => initialized, ++ Err(err) => { ++ store_progress_logger.flush().await; ++ return Err(err); ++ } ++ }; + initialized.on_node = on_node; + + let sandbox_for_cleanup = Arc::clone(&initialized.engine.run.sandbox); +@@ -1125,8 +1177,8 @@ mod tests { + }; + use fabro_store::Database; + use fabro_types::settings::run::{ +- McpTransport as ResolvedMcpTransport, PreparedStep, PreparedStepRun, RunMode, +- RunPrepareSettings, ++ HookDefinition, HookEvent, HookType, McpTransport as ResolvedMcpTransport, PreparedStep, ++ PreparedStepRun, RunMode, RunPrepareSettings, TlsMode, + }; + use fabro_types::settings::{InterpString, ModelRef}; + use fabro_types::{ +@@ -1568,6 +1620,49 @@ reasoning = false + ); + } + ++ #[tokio::test] ++ async fn run_session_secret_redactors_are_isolated_between_runs() { ++ async fn session_with_secret(secret_value: &str) -> RunSession { ++ let temp = tempfile::tempdir().unwrap(); ++ let (storage_root, _run_dir) = storage_root_and_run_dir(&temp); ++ let mut settings = settings_from_run_layer(RunLayer { ++ execution: Some(RunExecutionLayer { ++ mode: Some(RunMode::DryRun), ++ ..RunExecutionLayer::default() ++ }), ++ ..RunLayer::default() ++ }); ++ settings.run.environment.env.insert( ++ "DEPLOY_ENV".to_string(), ++ InterpString::parse("{{ secrets.DEPLOY_ENV }}"), ++ ); ++ let (persisted, store) = ++ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await; ++ let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); ++ let registry = Arc::new(test_registry()); ++ let vault = Arc::new(AsyncRwLock::new(token_vault("DEPLOY_ENV", secret_value))); ++ ++ RunSession::new(&persisted, StartServices { ++ vault: Some(vault), ++ ..test_start_services(&store, &storage_root, emitter, registry).await ++ }) ++ .await ++ .unwrap() ++ } ++ ++ let first = session_with_secret("alpha").await; ++ let second = session_with_secret("bravo").await; ++ ++ assert_eq!( ++ first.secret_redactor.redact_into("alpha bravo"), ++ "REDACTED bravo" ++ ); ++ assert_eq!( ++ second.secret_redactor.redact_into("alpha bravo"), ++ "alpha REDACTED" ++ ); ++ } ++ + #[tokio::test] + async fn run_session_new_missing_secret_fails_startup() { + let temp = tempfile::tempdir().unwrap(); +@@ -1605,6 +1700,218 @@ reasoning = false + assert!(err.causes()[0].contains("DEPLOY_TOKEN")); + } + ++ #[tokio::test] ++ async fn setup_failure_redacts_low_entropy_secret_in_event_and_error() { ++ let temp = tempfile::tempdir().unwrap(); ++ let (storage_root, run_dir) = storage_root_and_run_dir(&temp); ++ let mut settings = settings_from_run_layer(RunLayer { ++ execution: Some(RunExecutionLayer { ++ mode: Some(RunMode::DryRun), ++ ..RunExecutionLayer::default() ++ }), ++ ..RunLayer::default() ++ }); ++ settings.run.prepare = prepare_with_step(script_step( ++ "echo {{ secrets.DEPLOY_ENV }} >&2; exit 7", ++ HashMap::new(), ++ )); ++ let (_persisted, store) = ++ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await; ++ let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); ++ let registry = Arc::new(test_registry()); ++ let vault = Arc::new(AsyncRwLock::new(token_vault("DEPLOY_ENV", "staging"))); ++ ++ let Err(err) = start(&run_dir, StartServices { ++ vault: Some(vault), ++ ..test_start_services(&store, &storage_root, emitter, registry).await ++ }) ++ .await ++ else { ++ panic!("setup failure should fail the run"); ++ }; ++ ++ let error_text = err.to_string(); ++ assert!(!error_text.contains("staging")); ++ assert!(error_text.contains("REDACTED")); ++ ++ let run_store = store.open_run(&fixtures::RUN_1).await.unwrap(); ++ let events = run_store.list_events().await.unwrap(); ++ let events_text = serde_json::to_string(&events).unwrap(); ++ assert!(!events_text.contains("staging")); ++ assert!(events_text.contains("REDACTED")); ++ assert!( ++ events ++ .iter() ++ .any(|event| event.event.event_name() == "setup.failed") ++ ); ++ } ++ ++ #[tokio::test] ++ async fn hook_command_resolves_secret_from_vault() { ++ let temp = tempfile::tempdir().unwrap(); ++ let (storage_root, run_dir) = storage_root_and_run_dir(&temp); ++ let mut settings = settings_from_run_layer(RunLayer { ++ execution: Some(RunExecutionLayer { ++ mode: Some(RunMode::DryRun), ++ ..RunExecutionLayer::default() ++ }), ++ ..RunLayer::default() ++ }); ++ settings.run.hooks.push(sandbox_ready_command_hook( ++ "test \"{{ secrets.HOOK_TOKEN }}\" = staging", ++ )); ++ let (_persisted, store) = ++ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await; ++ let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); ++ let registry = Arc::new(test_registry()); ++ let vault = Arc::new(AsyncRwLock::new(token_vault("HOOK_TOKEN", "staging"))); ++ ++ start(&run_dir, StartServices { ++ vault: Some(vault), ++ ..test_start_services(&store, &storage_root, emitter, registry).await ++ }) ++ .await ++ .expect("hook command should resolve secret and proceed"); ++ } ++ ++ #[tokio::test] ++ async fn hook_missing_secret_fails_closed() { ++ let temp = tempfile::tempdir().unwrap(); ++ let (storage_root, run_dir) = storage_root_and_run_dir(&temp); ++ let mut settings = settings_from_run_layer(RunLayer { ++ execution: Some(RunExecutionLayer { ++ mode: Some(RunMode::DryRun), ++ ..RunExecutionLayer::default() ++ }), ++ ..RunLayer::default() ++ }); ++ settings ++ .run ++ .hooks ++ .push(sandbox_ready_command_hook("echo {{ secrets.HOOK_TOKEN }}")); ++ let (_persisted, store) = ++ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await; ++ let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); ++ let registry = Arc::new(test_registry()); ++ let vault = Arc::new(AsyncRwLock::new(temp_vault(&[]))); ++ ++ let Err(err) = start(&run_dir, StartServices { ++ vault: Some(vault), ++ ..test_start_services(&store, &storage_root, emitter, registry).await ++ }) ++ .await ++ else { ++ panic!("missing hook secret should fail the run"); ++ }; ++ ++ assert!(err.to_string().contains("HOOK_TOKEN")); ++ } ++ ++ #[tokio::test] ++ async fn hook_http_url_resolves_secret_from_vault() { ++ let server = httpmock::MockServer::start_async().await; ++ let mock = server ++ .mock_async(|when, then| { ++ when.method("POST").path("/hook"); ++ then.status(200).body(""); ++ }) ++ .await; ++ let temp = tempfile::tempdir().unwrap(); ++ let (storage_root, run_dir) = storage_root_and_run_dir(&temp); ++ let mut settings = settings_from_run_layer(RunLayer { ++ execution: Some(RunExecutionLayer { ++ mode: Some(RunMode::DryRun), ++ ..RunExecutionLayer::default() ++ }), ++ ..RunLayer::default() ++ }); ++ settings ++ .run ++ .hooks ++ .push(sandbox_ready_http_hook("{{ secrets.HOOK_URL }}")); ++ let (_persisted, store) = ++ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await; ++ let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); ++ let registry = Arc::new(test_registry()); ++ let vault = Arc::new(AsyncRwLock::new(token_vault( ++ "HOOK_URL", ++ &server.url("/hook"), ++ ))); ++ ++ start(&run_dir, StartServices { ++ vault: Some(vault), ++ ..test_start_services(&store, &storage_root, emitter, registry).await ++ }) ++ .await ++ .expect("HTTP hook URL should resolve secret and proceed"); ++ ++ mock.assert_async().await; ++ } ++ ++ #[tokio::test] ++ async fn hook_prompt_secret_resolver_resolves_from_vault_and_registers() { ++ let redactor = SecretRedactor::default(); ++ let vault = Arc::new(AsyncRwLock::new(token_vault("PROMPT_TOKEN", "staging"))); ++ let resolver = hook_secret_resolver(Some(vault), redactor.clone()); ++ let hook = HookDefinition { ++ name: Some("prompt-secret".to_string()), ++ event: HookEvent::StageStart, ++ command: None, ++ hook_type: Some(HookType::Prompt { ++ prompt: InterpString::parse("check {{ secrets.PROMPT_TOKEN }}"), ++ model: None, ++ }), ++ matcher: None, ++ blocking: Some(true), ++ timeout_ms: None, ++ sandbox: None, ++ }; ++ ++ let secrets = resolver.resolve_for_definition(&hook).await; ++ ++ assert_eq!(secrets.lookup("PROMPT_TOKEN").as_deref(), Some("staging")); ++ assert_eq!(redactor.redact_into("deploy staging"), "deploy REDACTED"); ++ } ++ ++ #[tokio::test] ++ async fn hook_block_reason_redacts_resolved_secret_in_error_and_events() { ++ let temp = tempfile::tempdir().unwrap(); ++ let (storage_root, run_dir) = storage_root_and_run_dir(&temp); ++ let mut settings = settings_from_run_layer(RunLayer { ++ execution: Some(RunExecutionLayer { ++ mode: Some(RunMode::DryRun), ++ ..RunExecutionLayer::default() ++ }), ++ ..RunLayer::default() ++ }); ++ settings.run.hooks.push(sandbox_ready_command_hook( ++ r#"printf '%s' '{"decision":"block","reason":"{{ secrets.HOOK_TOKEN }}"}'"#, ++ )); ++ let (_persisted, store) = ++ persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await; ++ let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); ++ let registry = Arc::new(test_registry()); ++ let vault = Arc::new(AsyncRwLock::new(token_vault("HOOK_TOKEN", "staging"))); ++ ++ let Err(err) = start(&run_dir, StartServices { ++ vault: Some(vault), ++ ..test_start_services(&store, &storage_root, emitter, registry).await ++ }) ++ .await ++ else { ++ panic!("blocking hook should fail the run"); ++ }; ++ ++ let error_text = err.to_string(); ++ assert!(!error_text.contains("staging")); ++ assert!(error_text.contains("REDACTED")); ++ ++ let run_store = store.open_run(&fixtures::RUN_1).await.unwrap(); ++ let events_text = serde_json::to_string(&run_store.list_events().await.unwrap()).unwrap(); ++ assert!(!events_text.contains("staging")); ++ assert!(events_text.contains("REDACTED")); ++ } ++ + #[test] + fn runtime_docker_config_maps_environment_hints() { + let settings = settings_from_run_layer(RunLayer { +@@ -1806,6 +2113,37 @@ reasoning = false + } + } + ++ fn sandbox_ready_command_hook(command: &str) -> HookDefinition { ++ HookDefinition { ++ name: Some("sandbox-ready".to_string()), ++ event: HookEvent::SandboxReady, ++ command: Some(InterpString::parse(command)), ++ hook_type: None, ++ matcher: None, ++ blocking: Some(true), ++ timeout_ms: Some(5_000), ++ sandbox: Some(false), ++ } ++ } ++ ++ fn sandbox_ready_http_hook(url: &str) -> HookDefinition { ++ HookDefinition { ++ name: Some("sandbox-ready-http".to_string()), ++ event: HookEvent::SandboxReady, ++ command: None, ++ hook_type: Some(HookType::Http { ++ url: InterpString::parse(url), ++ headers: None, ++ allowed_env_vars: Vec::new(), ++ tls: TlsMode::Off, ++ }), ++ matcher: None, ++ blocking: Some(true), ++ timeout_ms: Some(5_000), ++ sandbox: Some(false), ++ } ++ } ++ + use crate::test_support::{mark_run_running, test_usage}; + + async fn append_completed_stage( +diff --git a/lib/crates/fabro-workflow/src/pipeline/execute.rs b/lib/crates/fabro-workflow/src/pipeline/execute.rs +index 4a82a509c..f84b03e7c 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/execute.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/execute.rs +@@ -89,6 +89,7 @@ pub async fn execute(init: Initialized) -> Executed { + Arc::clone(&engine.run.sandbox_git), + Arc::clone(&engine.run.metadata_runtime), + engine.run.metadata_writer.clone(), ++ engine.run.secret_redactor.clone(), + checkpoint.is_some(), + on_node, + run_control, +diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +index e2b96ed37..09b9a4f6d 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +@@ -282,12 +282,14 @@ async fn execute_test_run_with_options( + workflow_path: None, + workflow_bundle: None, + hooks: HookSettings { hooks: vec![] }, ++ hook_secrets: fabro_hooks::HookSecretResolver::default(), + sandbox_env: SandboxEnvSpec { + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, ++ secret_redactor: fabro_redact::SecretRedactor::default(), + git: git_options, + run_control: None, + registry_override, +@@ -345,12 +347,14 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { + workflow_path: None, + workflow_bundle: None, + hooks: HookSettings { hooks: vec![] }, ++ hook_secrets: fabro_hooks::HookSecretResolver::default(), + sandbox_env: SandboxEnvSpec { + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, ++ secret_redactor: fabro_redact::SecretRedactor::default(), + git: None, + run_control: None, + registry_override: None, +@@ -416,12 +420,14 @@ async fn run_with_lifecycle( + workflow_path: None, + workflow_bundle: None, + hooks: HookSettings { hooks: vec![] }, ++ hook_secrets: fabro_hooks::HookSecretResolver::default(), + sandbox_env: SandboxEnvSpec { + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + vault: None, ++ secret_redactor: fabro_redact::SecretRedactor::default(), + git: None, + run_control: None, + registry_override: Some(Arc::new(registry)), +diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs +index 6e90991dc..805e8e0e4 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs +@@ -615,8 +615,15 @@ pub async fn finalize(executed: Executed, options: &FinalizeOptions) -> Result, + pub workflow_bundle: Option>, + pub hooks: fabro_hooks::HookSettings, ++ pub hook_secrets: fabro_hooks::HookSecretResolver, + pub sandbox_env: SandboxEnvSpec, + pub vault: Option>>, ++ pub secret_redactor: SecretRedactor, + pub git: Option, + pub registry_override: Option>, + pub artifact_sink: Option, +diff --git a/lib/crates/fabro-workflow/src/sandbox_git.rs b/lib/crates/fabro-workflow/src/sandbox_git.rs +index b746e2f89..f50142132 100644 +--- a/lib/crates/fabro-workflow/src/sandbox_git.rs ++++ b/lib/crates/fabro-workflow/src/sandbox_git.rs +@@ -1075,7 +1075,11 @@ mod tests { + + assert_eq!(err.to_string(), "git add timed out after 77ms"); + assert!( +- fabro_sandbox::default_redacted_output_tail(&err).is_none(), ++ fabro_sandbox::default_redacted_output_tail( ++ &err, ++ &fabro_redact::SecretRedactor::default() ++ ) ++ .is_none(), + "empty exec streams should not produce a tail" + ); + } +@@ -1109,7 +1113,11 @@ mod tests { + "expected sandbox git context, got {chain:#?}" + ); + assert!( +- fabro_sandbox::default_redacted_output_tail(&err).is_some(), ++ fabro_sandbox::default_redacted_output_tail( ++ &err, ++ &fabro_redact::SecretRedactor::default() ++ ) ++ .is_some(), + "expected probe exec output tail to survive SharedError wrapping" + ); + } +@@ -1245,7 +1253,11 @@ mod tests { + assert_eq!(err.to_string(), "git diff failed (exit 128)"); + assert!(!err.to_string().contains("fatal: bad revision")); + +- let tail = fabro_sandbox::default_redacted_output_tail(&err).expect("tail present"); ++ let tail = fabro_sandbox::default_redacted_output_tail( ++ &err, ++ &fabro_redact::SecretRedactor::default(), ++ ) ++ .expect("tail present"); + assert_eq!(tail.stderr.as_deref(), Some("fatal: bad revision\n")); + } + +diff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs +index 44e0eca21..c4dbd6735 100644 +--- a/lib/crates/fabro-workflow/src/services.rs ++++ b/lib/crates/fabro-workflow/src/services.rs +@@ -11,6 +11,7 @@ use fabro_auth::ResolvedCredentials; + use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner}; + use fabro_interview::Interviewer; + use fabro_model::{Catalog, ProviderId}; ++use fabro_redact::SecretRedactor; + use fabro_types::{ManifestPath, RunId}; + use tokio_util::sync::CancellationToken; + +@@ -103,6 +104,7 @@ pub struct RunServices { + pub model: String, + pub llm_source: Arc, + pub catalog: Arc, ++ pub secret_redactor: SecretRedactor, + pub(crate) sandbox_git: Arc, + pub(crate) metadata_runtime: Arc, + pub(crate) metadata_writer: Option, +@@ -122,6 +124,7 @@ impl RunServices { + model: String, + llm_source: Arc, + catalog: Arc, ++ secret_redactor: SecretRedactor, + sandbox_git: Arc, + metadata_runtime: Arc, + metadata_writer: Option, +@@ -137,6 +140,7 @@ impl RunServices { + model, + llm_source, + catalog, ++ secret_redactor, + sandbox_git, + metadata_runtime, + metadata_writer, +@@ -337,6 +341,7 @@ impl EngineServices { + "claude-sonnet-4-6".to_string(), + Arc::new(StubCredentialSource), + Arc::new(Catalog::from_builtin().expect("default catalog should build")), ++ SecretRedactor::default(), + Arc::new(SandboxGitRuntime::new()), + Arc::new(RunMetadataRuntime::new()), + None, +diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs +index 1acf410a4..c890667e6 100644 +--- a/lib/crates/fabro-workflow/src/test_support.rs ++++ b/lib/crates/fabro-workflow/src/test_support.rs +@@ -9,6 +9,7 @@ use fabro_auth::{CredentialSource, EnvCredentialSource}; + use fabro_graphviz::graph::Graph as GvGraph; + use fabro_interview::AutoApproveInterviewer; + use fabro_model::Catalog; ++use fabro_redact::SecretRedactor; + use fabro_store::{ArtifactStore, Database, RunProjection}; + use object_store::local::LocalFileSystem; + +@@ -235,6 +236,7 @@ async fn initialized( + .llm_source + .unwrap_or_else(|| Arc::new(EnvCredentialSource::new())), + Arc::new(Catalog::from_builtin().expect("default catalog should build")), ++ SecretRedactor::default(), + Arc::new(SandboxGitRuntime::new()), + Arc::new(RunMetadataRuntime::new()), + None, +diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs +index 3ceeced5d..ab7354ba5 100644 +--- a/lib/crates/fabro-workflow/tests/it/integration.rs ++++ b/lib/crates/fabro-workflow/tests/it/integration.rs +@@ -8179,6 +8179,7 @@ fn hook_runner_from_defs(hooks: Vec) -> Arc