mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
2444 lines
No EOL
374 KiB
JSON
2444 lines
No EOL
374 KiB
JSON
{
|
|
"title": "PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)",
|
|
"spec": {
|
|
"run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"settings": {
|
|
"project": {
|
|
"name": null,
|
|
"description": null,
|
|
"metadata": {}
|
|
},
|
|
"workflow": {
|
|
"name": null,
|
|
"description": null,
|
|
"graph": "workflow.fabro",
|
|
"metadata": {}
|
|
},
|
|
"run": {
|
|
"goal": {
|
|
"type": "inline",
|
|
"value": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n"
|
|
},
|
|
"working_dir": null,
|
|
"metadata": {
|
|
"series": "redaction-v2"
|
|
},
|
|
"inputs": {},
|
|
"model": {
|
|
"provider": "anthropic",
|
|
"name": "claude-sonnet-4-6",
|
|
"fallbacks": [],
|
|
"controls": {
|
|
"reasoning_effort": null,
|
|
"speed": null
|
|
}
|
|
},
|
|
"git": {
|
|
"author": null
|
|
},
|
|
"prepare": {
|
|
"steps": [],
|
|
"timeout_ms": 300000
|
|
},
|
|
"execution": {
|
|
"mode": "normal",
|
|
"approval": "prompt"
|
|
},
|
|
"checkpoint": {
|
|
"exclude_globs": [],
|
|
"skip_git_hooks": false
|
|
},
|
|
"clone": {
|
|
"enabled": true
|
|
},
|
|
"run_branch": {
|
|
"enabled": true,
|
|
"push": true
|
|
},
|
|
"meta_branch": {
|
|
"enabled": true,
|
|
"push": true
|
|
},
|
|
"environment": {
|
|
"id": "fabro-dev",
|
|
"provider": "daytona",
|
|
"image": {
|
|
"docker": null,
|
|
"dockerfile": {
|
|
"type": "inline",
|
|
"value": "FROM ubuntu:24.04\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n curl git ripgrep ca-certificates build-essential pkg-config libssl-dev unzip python3 \\\n xvfb xfce4 xfce4-terminal x11vnc novnc dbus-x11 \\\n libx11-6 libxrandr2 libxext6 libxrender1 libxfixes3 libxss1 libxtst6 libxi6 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Install real Chromium (not the snap stub) via xtradeb PPA\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n software-properties-common curl gnupg \\\n && add-apt-repository -y ppa:xtradeb/apps \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends chromium \\\n && rm -rf /var/lib/apt/lists/*\n\n# Wrapper: Chromium needs --no-sandbox when running as root in a container,\n# and --disable-dev-shm-usage avoids crashes from small /dev/shm\nRUN printf '#!/bin/bash\\nexec /usr/bin/chromium --no-sandbox --disable-dev-shm-usage \"$@\"\\n' \\\n > /usr/local/bin/chromium-wrapper \\\n && chmod +x /usr/local/bin/chromium-wrapper\n\n# Make the wrapper the default in the system .desktop file and via alternatives\nRUN sed -i 's|^Exec=.*|Exec=/usr/local/bin/chromium-wrapper %U|' \\\n /usr/share/applications/chromium.desktop \\\n && update-alternatives --install /usr/bin/x-www-browser x-www-browser \\\n /usr/local/bin/chromium-wrapper 100\n\n# Tell XFCE's exo-open that Chromium is the WebBrowser helper (system-wide)\nRUN mkdir -p /etc/xdg/xfce4 /usr/share/xfce4/helpers \\\n && printf 'WebBrowser=custom-WebBrowser\\n' > /etc/xdg/xfce4/helpers.rc \\\n && printf '[Desktop Entry]\\n\\\nVersion=1.0\\n\\\nType=X-XFCE-Helper\\n\\\nName=Chromium\\n\\\nIcon=chromium\\n\\\nX-XFCE-Category=WebBrowser\\n\\\nX-XFCE-CommandsWithParameter=/usr/local/bin/chromium-wrapper \"%%s\"\\n\\\nX-XFCE-Commands=/usr/local/bin/chromium-wrapper\\n' \\\n > /usr/share/xfce4/helpers/custom-WebBrowser.desktop\n\n# GitHub CLI\nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \\\n | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \\\n && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" \\\n | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \\\n && apt-get update && apt-get install -y --no-install-recommends gh \\\n && rm -rf /var/lib/apt/lists/*\n\n# Rust\nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\nENV PATH=\"/root/.cargo/bin:${PATH}\"\nRUN rustup toolchain install nightly-2026-04-14 --profile minimal --component clippy,rustfmt\nRUN cargo install cargo-nextest --locked\nENV CARGO_INCREMENTAL=0\n\n# Bun\nRUN curl -fsSL https://bun.sh/install | bash\nENV PATH=\"/root/.bun/bin:${PATH}\"\n\nWORKDIR /root\n"
|
|
}
|
|
},
|
|
"resources": {
|
|
"cpu": 8,
|
|
"memory": "16GB",
|
|
"disk": "20GB"
|
|
},
|
|
"network": {
|
|
"mode": "allow_all",
|
|
"allow": []
|
|
},
|
|
"lifecycle": {
|
|
"preserve": false,
|
|
"stop_on_terminal": true,
|
|
"auto_stop": "30m"
|
|
},
|
|
"labels": {
|
|
"repo": "fabro-sh/fabro"
|
|
},
|
|
"env": {}
|
|
},
|
|
"notifications": {},
|
|
"interviews": {
|
|
"provider": null,
|
|
"slack": null
|
|
},
|
|
"agent": {
|
|
"fabro_tools": false,
|
|
"permissions": null,
|
|
"mcps": {}
|
|
},
|
|
"hooks": [],
|
|
"scm": {
|
|
"provider": null,
|
|
"owner": null,
|
|
"repository": null,
|
|
"github": null
|
|
},
|
|
"pull_request": {
|
|
"enabled": true,
|
|
"draft": false,
|
|
"auto_merge": false,
|
|
"merge_strategy": "squash"
|
|
},
|
|
"artifacts": {
|
|
"include": []
|
|
},
|
|
"integrations": {
|
|
"github": {
|
|
"permissions": {}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"graph": {
|
|
"name": "ImplementPlan",
|
|
"nodes": {
|
|
"preflight_lint": {
|
|
"id": "preflight_lint",
|
|
"attrs": {
|
|
"script": {
|
|
"String": "cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1"
|
|
},
|
|
"shape": {
|
|
"String": "parallelogram"
|
|
},
|
|
"model": {
|
|
"String": "claude-opus-4-8"
|
|
},
|
|
"max_retries": {
|
|
"Integer": 0
|
|
},
|
|
"provider": {
|
|
"String": "anthropic"
|
|
},
|
|
"label": {
|
|
"String": "Preflight Lint"
|
|
}
|
|
}
|
|
},
|
|
"exit": {
|
|
"id": "exit",
|
|
"attrs": {
|
|
"label": {
|
|
"String": "Exit"
|
|
},
|
|
"provider": {
|
|
"String": "anthropic"
|
|
},
|
|
"model": {
|
|
"String": "claude-opus-4-8"
|
|
},
|
|
"shape": {
|
|
"String": "Msquare"
|
|
}
|
|
}
|
|
},
|
|
"toolchain": {
|
|
"id": "toolchain",
|
|
"attrs": {
|
|
"provider": {
|
|
"String": "anthropic"
|
|
},
|
|
"script": {
|
|
"String": "command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1"
|
|
},
|
|
"label": {
|
|
"String": "Toolchain"
|
|
},
|
|
"max_retries": {
|
|
"Integer": 0
|
|
},
|
|
"model": {
|
|
"String": "claude-opus-4-8"
|
|
},
|
|
"shape": {
|
|
"String": "parallelogram"
|
|
}
|
|
}
|
|
},
|
|
"implement": {
|
|
"id": "implement",
|
|
"attrs": {
|
|
"prompt": {
|
|
"String": "Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions."
|
|
},
|
|
"reasoning_effort": {
|
|
"String": "xhigh"
|
|
},
|
|
"label": {
|
|
"String": "Implement"
|
|
},
|
|
"provider": {
|
|
"String": "openai"
|
|
},
|
|
"model": {
|
|
"String": "gpt-5.5"
|
|
}
|
|
}
|
|
},
|
|
"simplify_gpt": {
|
|
"id": "simplify_gpt",
|
|
"attrs": {
|
|
"prompt": {
|
|
"String": "# Simplify: Code Review and Cleanup\n\nReview all changes for reuse, quality, and efficiency. Fix any issues found. Feel free to use any sub agents you need.\n\n## Phase 1: Identify Changes\n\nRun git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. (You may already have the changes in context, if so, feel free to skip this part)\n\n## Phase 2: Launch Three Review Agents in Parallel\n\nUse the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.\n\n### Agent 1: Code Reuse Review\n\nFor each change:\n\n1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.\n2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.\n3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.\n\nNote: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.\n\n### Agent 2: Code Quality Review\n\nReview the same changes for hacky patterns:\n\n1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls\n2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones\n3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction\n4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries\n5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase\n\nNote: This is a greenfield app, so be aggressive in optimizing quality.\n\n### Agent 3: Efficiency Review\n\nReview the same changes for efficiency:\n\n1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns\n2. Missed concurrency: independent operations run sequentially when they could run in parallel\n3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths\n4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error\n5. Memory: unbounded data structures, missing cleanup, event listener leaks\n6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one\n\n## Phase 3: Fix Issues\n\nWait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.\n\nWhen done, briefly summarize what was fixed (or confirm the code was already clean).\n"
|
|
},
|
|
"provider": {
|
|
"String": "openai"
|
|
},
|
|
"label": {
|
|
"String": "Simplify (GPT-55)"
|
|
},
|
|
"model": {
|
|
"String": "gpt-5.5"
|
|
}
|
|
}
|
|
},
|
|
"verify": {
|
|
"id": "verify",
|
|
"attrs": {
|
|
"retry_target": {
|
|
"String": "fixup"
|
|
},
|
|
"shape": {
|
|
"String": "parallelogram"
|
|
},
|
|
"model": {
|
|
"String": "claude-opus-4-8"
|
|
},
|
|
"label": {
|
|
"String": "Verify"
|
|
},
|
|
"provider": {
|
|
"String": "anthropic"
|
|
},
|
|
"script": {
|
|
"String": "git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1"
|
|
},
|
|
"timeout": {
|
|
"Duration": {
|
|
"secs": 1800,
|
|
"nanos": 0
|
|
}
|
|
},
|
|
"goal_gate": {
|
|
"Boolean": true
|
|
}
|
|
}
|
|
},
|
|
"preflight_compile": {
|
|
"id": "preflight_compile",
|
|
"attrs": {
|
|
"provider": {
|
|
"String": "anthropic"
|
|
},
|
|
"shape": {
|
|
"String": "parallelogram"
|
|
},
|
|
"max_retries": {
|
|
"Integer": 0
|
|
},
|
|
"model": {
|
|
"String": "claude-opus-4-8"
|
|
},
|
|
"script": {
|
|
"String": "cargo check -q --workspace 2>&1"
|
|
},
|
|
"label": {
|
|
"String": "Preflight Compile"
|
|
}
|
|
}
|
|
},
|
|
"fix_lints": {
|
|
"id": "fix_lints",
|
|
"attrs": {
|
|
"label": {
|
|
"String": "Fix Lints"
|
|
},
|
|
"prompt": {
|
|
"String": "The preflight lint step failed. Read the build output from context and fix all clippy lint warnings."
|
|
},
|
|
"provider": {
|
|
"String": "anthropic"
|
|
},
|
|
"model": {
|
|
"String": "claude-opus-4-8"
|
|
},
|
|
"max_visits": {
|
|
"Integer": 3
|
|
}
|
|
}
|
|
},
|
|
"start": {
|
|
"id": "start",
|
|
"attrs": {
|
|
"model": {
|
|
"String": "claude-opus-4-8"
|
|
},
|
|
"label": {
|
|
"String": "Start"
|
|
},
|
|
"shape": {
|
|
"String": "Mdiamond"
|
|
},
|
|
"provider": {
|
|
"String": "anthropic"
|
|
}
|
|
}
|
|
},
|
|
"simplify_fable": {
|
|
"id": "simplify_fable",
|
|
"attrs": {
|
|
"prompt": {
|
|
"String": "# Simplify: Code Review and Cleanup\n\nReview all changes for reuse, quality, and efficiency. Fix any issues found. Feel free to use any sub agents you need.\n\n## Phase 1: Identify Changes\n\nRun git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. (You may already have the changes in context, if so, feel free to skip this part)\n\n## Phase 2: Launch Three Review Agents in Parallel\n\nUse the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.\n\n### Agent 1: Code Reuse Review\n\nFor each change:\n\n1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.\n2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.\n3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.\n\nNote: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.\n\n### Agent 2: Code Quality Review\n\nReview the same changes for hacky patterns:\n\n1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls\n2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones\n3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction\n4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries\n5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase\n\nNote: This is a greenfield app, so be aggressive in optimizing quality.\n\n### Agent 3: Efficiency Review\n\nReview the same changes for efficiency:\n\n1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns\n2. Missed concurrency: independent operations run sequentially when they could run in parallel\n3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths\n4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error\n5. Memory: unbounded data structures, missing cleanup, event listener leaks\n6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one\n\n## Phase 3: Fix Issues\n\nWait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.\n\nWhen done, briefly summarize what was fixed (or confirm the code was already clean).\n"
|
|
},
|
|
"label": {
|
|
"String": "Simplify (Fable)"
|
|
},
|
|
"model": {
|
|
"String": "claude-fable-5"
|
|
},
|
|
"reasoning_effort": {
|
|
"String": "xhigh"
|
|
},
|
|
"provider": {
|
|
"String": "anthropic"
|
|
}
|
|
}
|
|
},
|
|
"fixup": {
|
|
"id": "fixup",
|
|
"attrs": {
|
|
"label": {
|
|
"String": "Fixup"
|
|
},
|
|
"provider": {
|
|
"String": "anthropic"
|
|
},
|
|
"prompt": {
|
|
"String": "The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures."
|
|
},
|
|
"max_visits": {
|
|
"Integer": 3
|
|
},
|
|
"model": {
|
|
"String": "claude-opus-4-8"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"edges": [
|
|
{
|
|
"from": "start",
|
|
"to": "toolchain",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "toolchain",
|
|
"to": "preflight_compile",
|
|
"attrs": {
|
|
"condition": {
|
|
"String": "outcome=succeeded"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"from": "toolchain",
|
|
"to": "exit",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "preflight_compile",
|
|
"to": "preflight_lint",
|
|
"attrs": {
|
|
"condition": {
|
|
"String": "outcome=succeeded"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"from": "preflight_compile",
|
|
"to": "exit",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "preflight_lint",
|
|
"to": "implement",
|
|
"attrs": {
|
|
"condition": {
|
|
"String": "outcome=succeeded"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"from": "preflight_lint",
|
|
"to": "fix_lints",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "fix_lints",
|
|
"to": "preflight_lint",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "implement",
|
|
"to": "simplify_fable",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "simplify_fable",
|
|
"to": "simplify_gpt",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "simplify_gpt",
|
|
"to": "verify",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "verify",
|
|
"to": "exit",
|
|
"attrs": {
|
|
"condition": {
|
|
"String": "outcome=succeeded"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"from": "verify",
|
|
"to": "fixup",
|
|
"attrs": {}
|
|
},
|
|
{
|
|
"from": "fixup",
|
|
"to": "verify",
|
|
"attrs": {}
|
|
}
|
|
],
|
|
"attrs": {
|
|
"goal": {
|
|
"String": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n"
|
|
},
|
|
"model_stylesheet": {
|
|
"String": "\n * { model: claude-opus-4-8; }\n "
|
|
},
|
|
"rankdir": {
|
|
"String": "LR"
|
|
}
|
|
}
|
|
},
|
|
"graph_source": "digraph ImplementPlan {\n graph [\n goal=\"Implement and simplify\",\n model_stylesheet=\"\n * { model: claude-opus-4-8; }\n \"\n ]\n rankdir=LR\n\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n\n toolchain [label=\"Toolchain\", shape=parallelogram, script=\"command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1\", max_retries=0]\n preflight_compile [label=\"Preflight Compile\", shape=parallelogram, script=\"cargo check -q --workspace 2>&1\", max_retries=0]\n preflight_lint [label=\"Preflight Lint\", shape=parallelogram, script=\"cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1\", max_retries=0]\n fix_lints [label=\"Fix Lints\", prompt=\"The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.\", max_visits=3]\n implement [label=\"Implement\", prompt=\"Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions.\", model=\"gpt-55\", reasoning_effort=\"xhigh\"]\n simplify_fable [label=\"Simplify (Fable)\", prompt=\"@prompts/simplify.md\", model=\"claude-fable-5\", reasoning_effort=\"xhigh\"]\n simplify_gpt [label=\"Simplify (GPT-55)\", prompt=\"@prompts/simplify.md\", model=\"gpt-55\"]\n verify [label=\"Verify\", shape=parallelogram, timeout=\"1800s\", script=\"git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\\\"disabled\\\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1\", goal_gate=true, retry_target=\"fixup\"]\n fixup [label=\"Fixup\", prompt=\"The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.\", max_visits=3]\n\n start -> toolchain\n toolchain -> preflight_compile [condition=\"outcome=succeeded\"]\n toolchain -> exit\n preflight_compile -> preflight_lint [condition=\"outcome=succeeded\"]\n preflight_compile -> exit\n preflight_lint -> implement [condition=\"outcome=succeeded\"]\n preflight_lint -> fix_lints\n fix_lints -> preflight_lint\n implement -> simplify_fable -> simplify_gpt -> verify\n verify -> exit [condition=\"outcome=succeeded\"]\n verify -> fixup\n fixup -> verify\n}\n",
|
|
"workflow_slug": "implement-plan",
|
|
"source_directory": "/Users/swerner/Development/os/fabro-main/fabro",
|
|
"labels": {
|
|
"series": "redaction-v2"
|
|
},
|
|
"provenance": {
|
|
"server": {
|
|
"version": "0.287.0-nightly.0"
|
|
},
|
|
"client": {
|
|
"user_agent": "fabro-cli/0.267.0-nightly.0",
|
|
"name": "fabro-cli",
|
|
"version": "0.267.0-nightly.0"
|
|
},
|
|
"subject": {
|
|
"kind": "user",
|
|
"identity": {
|
|
"issuer": "https://github.com",
|
|
"subject": "138379"
|
|
},
|
|
"login": "swerner",
|
|
"auth_method": "github",
|
|
"avatar_url": "https://avatars.githubusercontent.com/u/138379?v=4"
|
|
}
|
|
},
|
|
"manifest_blob": "edcfd63676c35cdd2bd5b868dcca4f62435924681b451cf5b77fceaf5abc0340",
|
|
"definition_blob": "210e7b4f61ae68212431bf516dbfc8081799145ff09b4c6615cd587c9a30769d",
|
|
"git": {
|
|
"origin_url": "https://github.com/fabro-sh/fabro",
|
|
"branch": "main",
|
|
"sha": "790762fb8ddb7c517e66adfaa8da02311280f2ac",
|
|
"dirty": "dirty",
|
|
"push_outcome": {
|
|
"type": "not_attempted"
|
|
}
|
|
}
|
|
},
|
|
"web_url": "https://fabro-testing.walleye-rainbow.ts.net/runs/01KX9EM9QF65A43PB064TF7TWY",
|
|
"start": {
|
|
"start_time": "2026-07-11T20:41:55.540822475Z",
|
|
"run_branch": "fabro/run/01KX9EM9QF65A43PB064TF7TWY",
|
|
"base_sha": "12529cba2f0b5dfce9990a5735eee2fe6b1b411b"
|
|
},
|
|
"status": {
|
|
"kind": "running"
|
|
},
|
|
"status_updated_at": "2026-07-11T20:41:55.540863608Z",
|
|
"last_event_at": "2026-07-11T21:49:12.708627309Z",
|
|
"pending_control": null,
|
|
"checkpoints": [
|
|
{
|
|
"seq": 21,
|
|
"checkpoint": {
|
|
"timestamp": "2026-07-11T20:41:57.021212968Z",
|
|
"current_node": "start",
|
|
"completed_nodes": [
|
|
"start"
|
|
],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"internal.thread_id": null,
|
|
"internal.run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"internal.work_dir": "/home/daytona/workspace/fabro",
|
|
"graph.goal": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n",
|
|
"current_node": "start",
|
|
"graph.rankdir": "LR",
|
|
"graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ",
|
|
"failure_signature": "",
|
|
"failure_class": "",
|
|
"internal.fidelity": "compact",
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.start": 0,
|
|
"outcome": "succeeded"
|
|
},
|
|
"node_outcomes": {
|
|
"start": {
|
|
"status": "succeeded",
|
|
"usage": null
|
|
}
|
|
},
|
|
"next_node_id": "toolchain",
|
|
"node_visits": {
|
|
"start": 1
|
|
}
|
|
},
|
|
"diff": {}
|
|
},
|
|
{
|
|
"seq": 29,
|
|
"checkpoint": {
|
|
"timestamp": "2026-07-11T20:42:01.882538141Z",
|
|
"current_node": "toolchain",
|
|
"completed_nodes": [
|
|
"start",
|
|
"toolchain"
|
|
],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"internal.node_visit_count": 1,
|
|
"thread.start.current_node": "toolchain",
|
|
"internal.retry_count.start": 0,
|
|
"internal.run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"current_node": "toolchain",
|
|
"internal.retry_count.toolchain": 0,
|
|
"graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ",
|
|
"graph.rankdir": "LR",
|
|
"internal.thread_id": "start",
|
|
"internal.fidelity": "compact",
|
|
"internal.work_dir": "/home/daytona/workspace/fabro",
|
|
"failure_signature": "",
|
|
"command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1",
|
|
"failure_class": "",
|
|
"graph.goal": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n",
|
|
"outcome": "succeeded"
|
|
},
|
|
"node_outcomes": {
|
|
"start": {
|
|
"status": "succeeded",
|
|
"usage": null
|
|
},
|
|
"toolchain": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1"
|
|
},
|
|
"notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 1299,
|
|
"active_time_ms": 1299
|
|
}
|
|
}
|
|
},
|
|
"next_node_id": "preflight_compile",
|
|
"git_commit_sha": "f8e00c305d9183d1ae4b87463d5815ceda689898",
|
|
"node_visits": {
|
|
"toolchain": 1,
|
|
"start": 1
|
|
}
|
|
},
|
|
"diff": {
|
|
"summary": {
|
|
"files_changed": 0,
|
|
"additions": 0,
|
|
"deletions": 0
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"seq": 39,
|
|
"checkpoint": {
|
|
"timestamp": "2026-07-11T20:43:50.601660312Z",
|
|
"current_node": "preflight_compile",
|
|
"completed_nodes": [
|
|
"start",
|
|
"toolchain",
|
|
"preflight_compile"
|
|
],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"internal.work_dir": "/home/daytona/workspace/fabro",
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
|
|
"current_node": "preflight_compile",
|
|
"graph.goal": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n",
|
|
"graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ",
|
|
"internal.retry_count.preflight_compile": 0,
|
|
"outcome": "succeeded",
|
|
"failure_signature": "",
|
|
"failure_class": "",
|
|
"internal.retry_count.start": 0,
|
|
"internal.run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"thread.start.current_node": "toolchain",
|
|
"thread.toolchain.current_node": "preflight_compile",
|
|
"internal.node_visit_count": 1,
|
|
"internal.thread_id": "toolchain",
|
|
"graph.rankdir": "LR",
|
|
"internal.retry_count.toolchain": 0,
|
|
"internal.fidelity": "compact"
|
|
},
|
|
"node_outcomes": {
|
|
"start": {
|
|
"status": "succeeded",
|
|
"usage": null
|
|
},
|
|
"toolchain": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1"
|
|
},
|
|
"notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 1299,
|
|
"active_time_ms": 1299
|
|
}
|
|
},
|
|
"preflight_compile": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo check -q --workspace 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 105518,
|
|
"active_time_ms": 105518
|
|
}
|
|
}
|
|
},
|
|
"next_node_id": "preflight_lint",
|
|
"git_commit_sha": "c5e72501edf03a22144a6af538bc71e10cb66015",
|
|
"node_visits": {
|
|
"preflight_compile": 1,
|
|
"toolchain": 1,
|
|
"start": 1
|
|
}
|
|
},
|
|
"diff": {
|
|
"summary": {
|
|
"files_changed": 0,
|
|
"additions": 0,
|
|
"deletions": 0
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"seq": 49,
|
|
"checkpoint": {
|
|
"timestamp": "2026-07-11T20:45:45.297541987Z",
|
|
"current_node": "preflight_lint",
|
|
"completed_nodes": [
|
|
"start",
|
|
"toolchain",
|
|
"preflight_compile",
|
|
"preflight_lint"
|
|
],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"internal.fidelity": "compact",
|
|
"internal.retry_count.toolchain": 0,
|
|
"graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ",
|
|
"graph.rankdir": "LR",
|
|
"internal.retry_count.preflight_lint": 0,
|
|
"thread.preflight_compile.current_node": "preflight_lint",
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
|
|
"graph.goal": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n",
|
|
"internal.retry_count.preflight_compile": 0,
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.start": 0,
|
|
"internal.work_dir": "/home/daytona/workspace/fabro",
|
|
"outcome": "succeeded",
|
|
"failure_class": "",
|
|
"failure_signature": "",
|
|
"thread.toolchain.current_node": "preflight_compile",
|
|
"internal.run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"thread.start.current_node": "toolchain",
|
|
"current_node": "preflight_lint",
|
|
"internal.thread_id": "preflight_compile"
|
|
},
|
|
"node_outcomes": {
|
|
"preflight_lint": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 111530,
|
|
"active_time_ms": 111530
|
|
}
|
|
},
|
|
"start": {
|
|
"status": "succeeded",
|
|
"usage": null
|
|
},
|
|
"preflight_compile": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo check -q --workspace 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 105518,
|
|
"active_time_ms": 105518
|
|
}
|
|
},
|
|
"toolchain": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1"
|
|
},
|
|
"notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 1299,
|
|
"active_time_ms": 1299
|
|
}
|
|
}
|
|
},
|
|
"next_node_id": "implement",
|
|
"git_commit_sha": "047a3472f1ae96d171f23c1ae4ce498b4d64002e",
|
|
"node_visits": {
|
|
"start": 1,
|
|
"preflight_compile": 1,
|
|
"preflight_lint": 1,
|
|
"toolchain": 1
|
|
}
|
|
},
|
|
"diff": {
|
|
"summary": {
|
|
"files_changed": 0,
|
|
"additions": 0,
|
|
"deletions": 0
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"seq": 66,
|
|
"checkpoint": {
|
|
"timestamp": "2026-07-11T20:45:48.828982655Z",
|
|
"current_node": "implement",
|
|
"completed_nodes": [
|
|
"start",
|
|
"toolchain",
|
|
"preflight_compile",
|
|
"preflight_lint",
|
|
"implement"
|
|
],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"thread.preflight_compile.current_node": "preflight_lint",
|
|
"thread.preflight_lint.current_node": "implement",
|
|
"outcome": "failed",
|
|
"internal.retry_count.start": 0,
|
|
"internal.run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"thread.toolchain.current_node": "preflight_compile",
|
|
"internal.fidelity": "compact",
|
|
"graph.goal": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n",
|
|
"failure_class": "deterministic",
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.preflight_compile": 0,
|
|
"internal.retry_count.toolchain": 0,
|
|
"thread.start.current_node": "toolchain",
|
|
"current_node": "implement",
|
|
"graph.rankdir": "LR",
|
|
"internal.thread_id": "preflight_lint",
|
|
"failure_signature": "implement|deterministic|api_deterministic|openai|authentication",
|
|
"internal.retry_count.implement": 0,
|
|
"internal.retry_count.preflight_lint": 0,
|
|
"graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ",
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
|
|
"internal.work_dir": "/home/daytona/workspace/fabro"
|
|
},
|
|
"node_outcomes": {
|
|
"start": {
|
|
"status": "succeeded",
|
|
"usage": null
|
|
},
|
|
"implement": {
|
|
"status": "failed",
|
|
"failure": {
|
|
"message": "LLM error: Authentication error for openai: Your authentication token has been invalidated. Please try signing in again.",
|
|
"category": "deterministic",
|
|
"signature": "api_deterministic|openai|authentication"
|
|
},
|
|
"usage": null
|
|
},
|
|
"preflight_compile": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo check -q --workspace 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 105518,
|
|
"active_time_ms": 105518
|
|
}
|
|
},
|
|
"toolchain": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1"
|
|
},
|
|
"notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 1299,
|
|
"active_time_ms": 1299
|
|
}
|
|
},
|
|
"preflight_lint": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 111530,
|
|
"active_time_ms": 111530
|
|
}
|
|
}
|
|
},
|
|
"next_node_id": "simplify_fable",
|
|
"git_commit_sha": "a79034cc231e5751afafadcabb3350139026eeff",
|
|
"loop_failure_signatures": {
|
|
"implement|deterministic|api_deterministic|openai|authentication": 1
|
|
},
|
|
"node_visits": {
|
|
"implement": 1,
|
|
"preflight_compile": 1,
|
|
"preflight_lint": 1,
|
|
"toolchain": 1,
|
|
"start": 1
|
|
}
|
|
},
|
|
"diff": {
|
|
"summary": {
|
|
"files_changed": 0,
|
|
"additions": 0,
|
|
"deletions": 0
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"seq": 1168,
|
|
"checkpoint": {
|
|
"timestamp": "2026-07-11T21:49:08.662260598Z",
|
|
"current_node": "simplify_fable",
|
|
"completed_nodes": [
|
|
"start",
|
|
"toolchain",
|
|
"preflight_compile",
|
|
"preflight_lint",
|
|
"implement",
|
|
"simplify_fable"
|
|
],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"internal.node_visit_count": 1,
|
|
"internal.thread_id": "implement",
|
|
"graph.rankdir": "LR",
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
|
|
"internal.fidelity": "compact",
|
|
"graph.goal": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n",
|
|
"failure_class": "",
|
|
"graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ",
|
|
"internal.retry_count.implement": 0,
|
|
"internal.retry_count.preflight_compile": 0,
|
|
"failure_signature": "",
|
|
"internal.retry_count.preflight_lint": 0,
|
|
"internal.retry_count.start": 0,
|
|
"internal.retry_count.toolchain": 0,
|
|
"internal.work_dir": "/home/daytona/workspace/fabro",
|
|
"last_response": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-ag",
|
|
"last_stage": "simplify_fable",
|
|
"thread.preflight_compile.current_node": "preflight_lint",
|
|
"thread.start.current_node": "toolchain",
|
|
"current_node": "simplify_fable",
|
|
"thread.implement.current_node": "simplify_fable",
|
|
"internal.run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"outcome": "succeeded",
|
|
"response.simplify_fable": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-agent review pass over my own diff.\n\n## What was implemented\n\n**Boundary resolution (the PR's core):**\n- `fabro-types/src/settings/run.rs`: new `RuntimeHookDefinition`, `RuntimeHookType`, `RuntimeHttpHook`, and `HookResolveError`, plus `HookDefinition::resolve_env(env_lookup, secrets_lookup)` — resolves `command`/`url`/headers/`prompt`/`model` once, with the header policy enforced before any lookup (secrets in headers rejected with the guidance message; header env gated by `allowed_env_vars`). Http hooks carry the unresolved `url_source` for safe logging. Wire/config types are untouched.\n- `fabro-workflow/operations/start.rs`: `runtime_hooks(...)` next to `runtime_mcp_server`/`runtime_setup_commands`, wired into `RunSession::new` through the shared `process_env_var`/`secret_lookup` closures; any resolution error fails the run at startup naming the hook.\n- `fabro-hooks`: executor/runner/bridge now consume resolved strings only — deleted `resolve_interp`, `resolve_header`, `HeaderResolveError`, `resolve_prompt_and_model`, the `Env` type params, and the crate's `fabro-util` dep. Matchers, decision merging, `FABRO_HOOK_CONTEXT`, timeouts, and TLS enforcement are untouched. Also deleted the production-dead `HookSettings::merge` (config layering owns merge semantics).\n\n**Tests:** resolution tests moved to fabro-types (13 new) and start.rs boundary tests (secret resolves / missing secret fails startup / header-secret guidance, incl. a panicking-lookup test proving rejection happens before any vault read); new worker-level tests with hermetic temp-dir vaults: command-hook secret proceeds, http hook with secret URL fires (httpmock asserts), blocking hook echoing a credential-shaped secret gets **redacted in stored events** by the existing content-based pass, and env-only hooks work on both host and sandbox dispatch.\n\n**Docs:** `hooks.mdx` gained an Interpolation section (secrets in command/url/prompt, header exclusion, startup-fail semantics); the OpenAPI `headers` description was updated (description-only, no wire change).\n\n## Review-agent findings fixed\n- **Quality:** swappable `url`/`url_source` `&str` params → grouped into `RuntimeHttpHook`; stale fire-time wording in `fabro-config/resolve/run.rs` and `fabro-api.yaml`; missing allowlist-violation outcome in hooks.mdx; `Debug`-leak warning added to runtime type docs.\n- **Reuse:** extracted shared `resolve_env_secrets` (now backing both `resolve_env_string` and hook resolution); test vault seeding reuses `fabro_auth::vault_set_token`.\n- **Efficiency:** `initialize.rs` now **moves** the resolved hooks into `HookRunner` instead of deep-cloning them, so a single copy of resolved secrets lives per run. (Reviewers also confirmed the per-fire path got strictly lighter.)\n- **Skipped (noted deliberately):** pre-redacting `url_source` at the boundary (plan mandates carrying the unresolved source; would add a fabro-redact dep to fabro-types), exporting `vault_token_lookup` just for a test one-liner, and rebuilding all test fixtures through `resolve_env`.\n\n## Verification\n`cargo +nightly-2026-04-14 fmt --check --all` ✓ · `clippy --workspace --all-targets -D warnings` ✓ · `cargo nextest run --workspace` → **6964 passed** ✓ · `cargo dev docs check` ✓\n\nFor the PR description, the two intended behavior changes: **fail timing moves to startup** (missing hook env/secret fails the run immediately, even for hooks that never fire) and **eager resolution** (hook secrets are resolved and held in worker memory for the run, like every other boundary-resolved secret).",
|
|
"thread.preflight_lint.current_node": "implement",
|
|
"thread.toolchain.current_node": "preflight_compile",
|
|
"internal.retry_count.simplify_fable": 0
|
|
},
|
|
"node_outcomes": {
|
|
"toolchain": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1"
|
|
},
|
|
"notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 1299,
|
|
"active_time_ms": 1299
|
|
}
|
|
},
|
|
"implement": {
|
|
"status": "failed",
|
|
"failure": {
|
|
"message": "LLM error: Authentication error for openai: Your authentication token has been invalidated. Please try signing in again.",
|
|
"category": "deterministic",
|
|
"signature": "api_deterministic|openai|authentication"
|
|
},
|
|
"usage": null
|
|
},
|
|
"simplify_fable": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"last_response": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-ag",
|
|
"response.simplify_fable": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-agent review pass over my own diff.\n\n## What was implemented\n\n**Boundary resolution (the PR's core):**\n- `fabro-types/src/settings/run.rs`: new `RuntimeHookDefinition`, `RuntimeHookType`, `RuntimeHttpHook`, and `HookResolveError`, plus `HookDefinition::resolve_env(env_lookup, secrets_lookup)` — resolves `command`/`url`/headers/`prompt`/`model` once, with the header policy enforced before any lookup (secrets in headers rejected with the guidance message; header env gated by `allowed_env_vars`). Http hooks carry the unresolved `url_source` for safe logging. Wire/config types are untouched.\n- `fabro-workflow/operations/start.rs`: `runtime_hooks(...)` next to `runtime_mcp_server`/`runtime_setup_commands`, wired into `RunSession::new` through the shared `process_env_var`/`secret_lookup` closures; any resolution error fails the run at startup naming the hook.\n- `fabro-hooks`: executor/runner/bridge now consume resolved strings only — deleted `resolve_interp`, `resolve_header`, `HeaderResolveError`, `resolve_prompt_and_model`, the `Env` type params, and the crate's `fabro-util` dep. Matchers, decision merging, `FABRO_HOOK_CONTEXT`, timeouts, and TLS enforcement are untouched. Also deleted the production-dead `HookSettings::merge` (config layering owns merge semantics).\n\n**Tests:** resolution tests moved to fabro-types (13 new) and start.rs boundary tests (secret resolves / missing secret fails startup / header-secret guidance, incl. a panicking-lookup test proving rejection happens before any vault read); new worker-level tests with hermetic temp-dir vaults: command-hook secret proceeds, http hook with secret URL fires (httpmock asserts), blocking hook echoing a credential-shaped secret gets **redacted in stored events** by the existing content-based pass, and env-only hooks work on both host and sandbox dispatch.\n\n**Docs:** `hooks.mdx` gained an Interpolation section (secrets in command/url/prompt, header exclusion, startup-fail semantics); the OpenAPI `headers` description was updated (description-only, no wire change).\n\n## Review-agent findings fixed\n- **Quality:** swappable `url`/`url_source` `&str` params → grouped into `RuntimeHttpHook`; stale fire-time wording in `fabro-config/resolve/run.rs` and `fabro-api.yaml`; missing allowlist-violation outcome in hooks.mdx; `Debug`-leak warning added to runtime type docs.\n- **Reuse:** extracted shared `resolve_env_secrets` (now backing both `resolve_env_string` and hook resolution); test vault seeding reuses `fabro_auth::vault_set_token`.\n- **Efficiency:** `initialize.rs` now **moves** the resolved hooks into `HookRunner` instead of deep-cloning them, so a single copy of resolved secrets lives per run. (Reviewers also confirmed the per-fire path got strictly lighter.)\n- **Skipped (noted deliberately):** pre-redacting `url_source` at the boundary (plan mandates carrying the unresolved source; would add a fabro-redact dep to fabro-types), exporting `vault_token_lookup` just for a test one-liner, and rebuilding all test fixtures through `resolve_env`.\n\n## Verification\n`cargo +nightly-2026-04-14 fmt --check --all` ✓ · `clippy --workspace --all-targets -D warnings` ✓ · `cargo nextest run --workspace` → **6964 passed** ✓ · `cargo dev docs check` ✓\n\nFor the PR description, the two intended behavior changes: **fail timing moves to startup** (missing hook env/secret fails the run immediately, even for hooks that never fire) and **eager resolution** (hook secrets are resolved and held in worker memory for the run, like every other boundary-resolved secret).",
|
|
"last_stage": "simplify_fable"
|
|
},
|
|
"notes": "Stage completed: simplify_fable",
|
|
"usage": {
|
|
"input": {
|
|
"usage": {
|
|
"model": {
|
|
"provider": "anthropic",
|
|
"model_id": "claude-fable-5"
|
|
},
|
|
"tokens": {
|
|
"input_tokens": 374846,
|
|
"output_tokens": 148952,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 46751554,
|
|
"cache_write_tokens": 5095953
|
|
}
|
|
},
|
|
"facts": {
|
|
"algorithm": "anthropic",
|
|
"cache_write_5m_tokens": 5095953,
|
|
"cache_write_1h_tokens": 0
|
|
}
|
|
},
|
|
"total_usd_micros": 121647026
|
|
},
|
|
"files_touched": [
|
|
"/home/daytona/workspace/fabro/docs/public/agents/hooks.mdx",
|
|
"/home/daytona/workspace/fabro/docs/public/api-reference/fabro-api.yaml",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-config/src/resolve/run.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/Cargo.toml",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/bridge.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/config.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/executor.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/lib.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/runner.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/types.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/tests/host_command_hooks.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-types/src/settings/run.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/operations/start.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/pipeline/initialize.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/integration.rs"
|
|
],
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 2358244,
|
|
"tool_time_ms": 1436685,
|
|
"active_time_ms": 3794929
|
|
}
|
|
},
|
|
"preflight_compile": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo check -q --workspace 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 105518,
|
|
"active_time_ms": 105518
|
|
}
|
|
},
|
|
"start": {
|
|
"status": "succeeded",
|
|
"usage": null
|
|
},
|
|
"preflight_lint": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 111530,
|
|
"active_time_ms": 111530
|
|
}
|
|
}
|
|
},
|
|
"next_node_id": "simplify_gpt",
|
|
"git_commit_sha": "3bb3ee5fb81635d834a5b79a56d0346efc7da24a",
|
|
"loop_failure_signatures": {
|
|
"implement|deterministic|api_deterministic|openai|authentication": 1
|
|
},
|
|
"node_visits": {
|
|
"implement": 1,
|
|
"start": 1,
|
|
"preflight_lint": 1,
|
|
"simplify_fable": 1,
|
|
"preflight_compile": 1,
|
|
"toolchain": 1
|
|
}
|
|
},
|
|
"diff": {
|
|
"patch": "diff --git a/Cargo.lock b/Cargo.lock\nindex 3d6b02edf..c8bce19d9 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -2668,14 +2668,12 @@ dependencies = [\n \"fabro-model\",\n \"fabro-redact\",\n \"fabro-types\",\n- \"fabro-util\",\n \"httpmock\",\n \"regex\",\n \"serde\",\n \"serde_json\",\n \"tokio\",\n \"tokio-util\",\n- \"toml 0.8.23\",\n \"tracing\",\n ]\n \ndiff --git a/docs/public/agents/hooks.mdx b/docs/public/agents/hooks.mdx\nindex aafdd356f..a4dfa9314 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` — referencing an env var outside the allowlist fails the run at startup. `{{ secrets.NAME }}` tokens are not allowed in headers — use secret interpolation in a hook `command`, `prompt`, or `url` instead. |\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,24 @@ 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\n+\n+The configurable string fields of a hook — `command`, `url`, header values, `prompt`, and `model` — support interpolation tokens:\n+\n+```toml title=\"run.toml\"\n+[[hooks]]\n+name = \"deploy-gate\"\n+event = \"run_start\"\n+command = \"./scripts/deploy-gate.sh --token {{ secrets.DEPLOY_TOKEN }} --region {{ env.AWS_REGION }}\"\n+sandbox = false\n+```\n+\n+- `{{ env.NAME }}` — an environment variable of the worker process.\n+- `{{ secrets.NAME }}` — a secret from the vault. Allowed everywhere **except HTTP hook header values**; put secret tokens in a hook `command`, `prompt`, or `url` instead.\n+- `{{ vars.NAME }}` — a run variable, substituted when the run is created.\n+\n+`env` and `secrets` tokens resolve **once, when the run starts** — the same boundary where MCP server and prepare-step tokens resolve. A referenced env var or secret that is not set fails the run at startup with an error naming the missing value, even if the hook would never have fired. Hooks never re-resolve tokens at fire time; per-firing data (event, node ID, tool name, …) arrives through the [hook context](#hook-context) instead.\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/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex d6e384405..89253d6a3 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -14273,8 +14273,10 @@ components:\n description: >-\n Optional HTTP headers for an http hook. Values support\n `{{ env.NAME }}` interpolation, scoped to the names listed in\n- `allowed_env_vars`; a token for any other env var fails to resolve\n- and the hook blocks (fail-closed).\n+ `allowed_env_vars`; a token for any other env var, or any\n+ `{{ secrets.NAME }}` token, fails hook resolution and the run\n+ fails at startup. Use secret interpolation in a hook command,\n+ prompt, or url instead of a header.\n allowed_env_vars:\n type: array\n items:\ndiff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs\nindex 46287d85d..15f2b7bef 100644\n--- a/lib/crates/fabro-config/src/resolve/run.rs\n+++ b/lib/crates/fabro-config/src/resolve/run.rs\n@@ -526,12 +526,13 @@ fn resolve_hook(hook: &HookEntry, index: usize, errors: &mut Vec<ResolveError>)\n }\n \n /// Join an argv-style `command` into a single space-separated [`InterpString`],\n-/// preserving every `{{ ... }}` token so the executor resolves it at hook fire\n-/// time. The join reconstructs the source form once, in one audited place.\n+/// preserving every `{{ ... }}` token for its one-time resolution at the run\n+/// boundary (`HookDefinition::resolve_env`). The join reconstructs the source\n+/// form once, in one audited place.\n #[expect(\n clippy::disallowed_methods,\n reason = \"deliberate source reconstruction: argv parts are reassembled into one InterpString \\\n- whose tokens stay typed for resolution at hook fire time\"\n+ whose tokens stay typed for their one-time resolution at the run boundary\"\n )]\n fn join_command(command: &[InterpString]) -> InterpString {\n InterpString::parse(\ndiff --git a/lib/crates/fabro-hooks/Cargo.toml b/lib/crates/fabro-hooks/Cargo.toml\nindex 559a7499a..8b4e11b5a 100644\n--- a/lib/crates/fabro-hooks/Cargo.toml\n+++ b/lib/crates/fabro-hooks/Cargo.toml\n@@ -19,7 +19,6 @@ fabro-llm = { path = \"../fabro-llm\" }\n fabro-model = { path = \"../fabro-model\" }\n fabro-redact.workspace = true\n fabro-types = { path = \"../fabro-types\" }\n-fabro-util = { path = \"../fabro-util\" }\n fabro-http.workspace = true\n serde.workspace = true\n serde_json.workspace = true\n@@ -32,4 +31,3 @@ tokio-util.workspace = true\n [dev-dependencies]\n httpmock = \"0.8\"\n tokio = { workspace = true, features = [\"test-util\", \"macros\"] }\n-toml.workspace = true\ndiff --git a/lib/crates/fabro-hooks/src/bridge.rs b/lib/crates/fabro-hooks/src/bridge.rs\nindex 6aa7e60f6..3fbfd2652 100644\n--- a/lib/crates/fabro-hooks/src/bridge.rs\n+++ b/lib/crates/fabro-hooks/src/bridge.rs\n@@ -82,7 +82,7 @@ mod tests {\n use fabro_types::fixtures;\n \n use super::*;\n- use crate::config::{HookDefinition, HookSettings};\n+ use crate::config::{HookSettings, RuntimeHookDefinition, RuntimeHookType};\n use crate::executor::HookExecutor;\n use crate::types::{HookContext, HookResult};\n \n@@ -96,7 +96,7 @@ mod tests {\n impl HookExecutor for CapturingExecutor {\n async fn execute(\n &self,\n- _definition: &HookDefinition,\n+ _definition: &RuntimeHookDefinition,\n context: &HookContext,\n _sandbox: Arc<dyn Sandbox>,\n execution_context: &HookExecutionContext,\n@@ -116,16 +116,18 @@ mod tests {\n }\n }\n \n- fn make_hook(event: HookEvent) -> HookDefinition {\n- HookDefinition {\n+ fn make_hook(event: HookEvent) -> RuntimeHookDefinition {\n+ RuntimeHookDefinition {\n name: Some(\"test-hook\".into()),\n event,\n- command: Some(\"echo test\".into()),\n- hook_type: None,\n+ hook_type: Some(RuntimeHookType::Command {\n+ command: \"echo test\".into(),\n+ }),\n matcher: None,\n blocking: None,\n timeout_ms: None,\n sandbox: Some(false),\n+ effective_name: \"test-hook\".into(),\n }\n }\n \ndiff --git a/lib/crates/fabro-hooks/src/config.rs b/lib/crates/fabro-hooks/src/config.rs\nindex 72ac52f2a..88d35cdb1 100644\n--- a/lib/crates/fabro-hooks/src/config.rs\n+++ b/lib/crates/fabro-hooks/src/config.rs\n@@ -1,44 +1,16 @@\n //! Hook configuration runtime settings.\n \n-pub use fabro_types::settings::run::{HookDefinition, HookEvent, HookType, TlsMode};\n-use serde::{Deserialize, Serialize};\n+pub use fabro_types::settings::run::{\n+ HookEvent, RuntimeHookDefinition, RuntimeHookType, RuntimeHttpHook, TlsMode,\n+};\n \n-/// Top-level hook configuration: a list of hook definitions.\n-#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]\n+/// Top-level hook configuration: the boundary-resolved hooks for one run.\n+///\n+/// Every interpolatable hook field is resolved to a plain string at the run\n+/// boundary before it reaches this crate (see\n+/// `fabro_types::settings::run::HookDefinition::resolve_env`), so the runner\n+/// and executor never resolve tokens themselves.\n+#[derive(Debug, Clone, Default, PartialEq)]\n pub struct HookSettings {\n- #[serde(default)]\n- pub hooks: Vec<HookDefinition>,\n-}\n-\n-impl HookSettings {\n- /// Merge with another config. Concatenates lists; on name collisions,\n- /// `other` wins.\n- #[must_use]\n- pub fn merge(self, other: Self) -> Self {\n- let mut by_name: std::collections::HashMap<String, HookDefinition> =\n- std::collections::HashMap::new();\n- let mut order: Vec<String> = Vec::new();\n-\n- for hook in self.hooks {\n- let name = hook.effective_name();\n- if !by_name.contains_key(&name) {\n- order.push(name.clone());\n- }\n- by_name.insert(name, hook);\n- }\n- for hook in other.hooks {\n- let name = hook.effective_name();\n- if !by_name.contains_key(&name) {\n- order.push(name.clone());\n- }\n- by_name.insert(name, hook);\n- }\n-\n- let hooks = order\n- .into_iter()\n- .filter_map(|name| by_name.remove(&name))\n- .collect();\n-\n- Self { hooks }\n- }\n+ pub hooks: Vec<RuntimeHookDefinition>,\n }\ndiff --git a/lib/crates/fabro-hooks/src/executor.rs b/lib/crates/fabro-hooks/src/executor.rs\nindex a409a49b7..40467d32c 100644\n--- a/lib/crates/fabro-hooks/src/executor.rs\n+++ b/lib/crates/fabro-hooks/src/executor.rs\n@@ -1,6 +1,4 @@\n-use std::borrow::Cow;\n use std::collections::HashMap;\n-use std::fmt;\n use std::sync::{Arc, LazyLock};\n use std::time::Instant;\n \n@@ -13,14 +11,11 @@ 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::{InterpString, ResolveError};\n-use fabro_util::env::{Env, SystemEnv};\n use tokio::process::Command as TokioCommand;\n use tokio::time::timeout as tokio_timeout;\n use tokio_util::sync::CancellationToken;\n \n-use crate::config::{HookDefinition, HookType, TlsMode};\n+use crate::config::{RuntimeHookDefinition, RuntimeHookType, RuntimeHttpHook, TlsMode};\n use crate::types::{\n HookContext, HookDecision, HookExecutionContext, HookResult, PromptHookResponse,\n };\n@@ -44,11 +39,15 @@ fn duration_ms(duration: std::time::Duration) -> u64 {\n }\n \n /// Trait for executing hooks via different transports.\n+///\n+/// Definitions arrive fully resolved from the run boundary\n+/// ([`RuntimeHookDefinition`]): the executor formats, dispatches, and merges\n+/// decisions — it resolves nothing.\n #[async_trait]\n pub trait HookExecutor: Send + Sync {\n async fn execute(\n &self,\n- definition: &HookDefinition,\n+ definition: &RuntimeHookDefinition,\n context: &HookContext,\n sandbox: Arc<dyn Sandbox>,\n execution_context: &HookExecutionContext,\n@@ -57,99 +56,6 @@ pub trait HookExecutor: Send + Sync {\n ) -> HookResult;\n }\n \n-/// Resolve a typed [`InterpString`] hook segment at fire time, looking up\n-/// `{{ env.* }}` tokens against `env`.\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-///\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-/// there is no `InterpString -> String -> InterpString` re-parse. A missing or\n-/// out-of-scope token is a hard error (fail-closed); there is no fallback to\n-/// the unresolved source.\n-///\n-/// Returns the typed [`ResolveError`] so callers keep the source until the\n-/// decision boundary renders it; do not flatten it to a `String` here.\n-fn resolve_interp<E>(value: &InterpString, env: &E) -> Result<String, ResolveError>\n-where\n- E: Env + ?Sized,\n-{\n- value.resolve(|name| env.var(name).ok())\n-}\n-\n-#[expect(\n- clippy::disallowed_methods,\n- reason = \"hook HTTP logs use the unresolved token source, not the resolved URL, so env-sourced \\\n- URL material is not logged; redacted_url_for_log masks literal credentials in \\\n- parseable source URLs and replaces unparseable sources with a placeholder\"\n-)]\n-fn safe_url_source_for_log(url: &InterpString) -> String {\n- redacted_url_for_log(&url.as_source())\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Eq)]\n-enum HeaderResolveError {\n- NotAllowed { name: String },\n- Resolve(ResolveError),\n-}\n-\n-impl fmt::Display for HeaderResolveError {\n- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n- match self {\n- Self::NotAllowed { name } => write!(\n- f,\n- \"environment variable {name:?} referenced by an HTTP hook header is not listed in \\\n- allowed_env_vars\"\n- ),\n- Self::Resolve(error) => error.fmt(f),\n- }\n- }\n-}\n-\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::Resolve(error) => Some(error),\n- }\n- }\n-}\n-\n-/// Resolve an HTTP-hook **header** value at fire time, scoping its\n-/// `{{ env.* }}` lookups to `allowed_env_vars`.\n-///\n-/// Headers carry credentials, so unlike every other hook field they read env\n-/// through an allowlist: a `{{ env.NAME }}` token resolves only when `NAME` is\n-/// listed in the hook's `allowed_env_vars`. A name outside the allowlist fails\n-/// with a distinct error before any lookup, while an allowlisted-but-unset name\n-/// still surfaces as the normal `Missing` error. An empty `allowed_env_vars`\n-/// therefore permits no env vars in headers at all. This mirrors the previous\n-/// template-based `with_env_lookup_allowed` behavior without reviving any\n-/// template engine.\n-fn resolve_header<E>(\n- value: &InterpString,\n- allowed_env_vars: &[String],\n- env: &E,\n-) -> Result<String, HeaderResolveError>\n-where\n- E: Env + ?Sized,\n-{\n- if let Some(name) = value.names(Namespace::Env).into_iter().find(|name| {\n- !allowed_env_vars\n- .iter()\n- .any(|allowed| allowed.as_str() == *name)\n- }) {\n- return Err(HeaderResolveError::NotAllowed {\n- name: name.to_string(),\n- });\n- }\n-\n- resolve_interp(value, env).map_err(HeaderResolveError::Resolve)\n-}\n-\n /// Executes hooks via shell commands or HTTP POST.\n pub struct HookExecutorImpl;\n \n@@ -177,45 +83,14 @@ impl HookExecutorImpl {\n }\n }\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- fn resolve_prompt_and_model<E>(\n- prompt: &InterpString,\n- model: Option<&InterpString>,\n- env: &E,\n- ) -> Result<(String, Option<String>), ResolveError>\n- where\n- E: Env + ?Sized,\n- {\n- let prompt = resolve_interp(prompt, env)?;\n- let model = model.map(|model| resolve_interp(model, env)).transpose()?;\n- Ok((prompt, model))\n- }\n-\n /// Execute a command hook (sandbox or host).\n- async fn execute_command<E>(\n- definition: &HookDefinition,\n- command: &InterpString,\n+ async fn execute_command(\n+ definition: &RuntimeHookDefinition,\n+ command: &str,\n context: &HookContext,\n sandbox: &Arc<dyn Sandbox>,\n execution_context: &HookExecutionContext,\n- env: &E,\n- ) -> HookDecision\n- where\n- E: Env + ?Sized,\n- {\n- let command = match resolve_interp(command, env) {\n- Ok(command) => command,\n- Err(error) => {\n- return HookDecision::Block {\n- reason: Some(error.to_string()),\n- };\n- }\n- };\n+ ) -> HookDecision {\n let context_json = serde_json::to_string(context).unwrap_or_default();\n let timeout_ms = duration_ms(definition.timeout());\n \n@@ -243,7 +118,7 @@ impl HookExecutorImpl {\n .map(|path| path.to_string_lossy().to_string());\n match sandbox\n .exec_command(\n- &command,\n+ command,\n timeout_ms,\n sandbox_work_dir.as_deref(),\n Some(&env_vars),\n@@ -258,7 +133,7 @@ impl HookExecutorImpl {\n }\n } else {\n let mut cmd = TokioCommand::new(\"sh\");\n- cmd.arg(\"-c\").arg(&command);\n+ cmd.arg(\"-c\").arg(command);\n if let Some(wd) = execution_context.command_cwd_for(definition) {\n cmd.current_dir(wd);\n }\n@@ -355,30 +230,16 @@ impl HookExecutorImpl {\n }\n \n /// Execute a prompt hook: single-turn LLM call returning ok/block.\n- async fn execute_prompt<E>(\n- definition: &HookDefinition,\n- prompt: &InterpString,\n- model: Option<&InterpString>,\n+ async fn execute_prompt(\n+ definition: &RuntimeHookDefinition,\n+ prompt: &str,\n+ model: Option<&str>,\n context: &HookContext,\n- env: &E,\n llm_source: &dyn CredentialSource,\n catalog: Arc<Catalog>,\n- ) -> HookDecision\n- where\n- E: Env + ?Sized,\n- {\n- let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env) {\n- Ok(resolved) => resolved,\n- Err(error) => {\n- tracing::error!(error = %error, \"prompt hook env resolution failed, not firing\");\n- return HookDecision::Block {\n- reason: Some(error.to_string()),\n- };\n- }\n- };\n-\n- let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref());\n- let user_msg = Self::build_hook_user_message(&prompt, context);\n+ ) -> HookDecision {\n+ let resolved_model = Self::resolve_model(model, catalog.as_ref());\n+ let user_msg = Self::build_hook_user_message(prompt, context);\n \n Self::execute_llm_with_timeout(definition.timeout(), \"prompt\", || async move {\n let client = match LlmClient::from_source(llm_source, catalog).await {\n@@ -422,32 +283,18 @@ impl HookExecutorImpl {\n /// Reuses the core `ToolRegistry` from `fabro_agent` so the agent hook has\n /// the same tools (read_file, write_file, shell, grep, glob, etc.) as\n /// a normal agent session.\n- async fn execute_agent<E>(\n- definition: &HookDefinition,\n- prompt: &InterpString,\n- model: Option<&InterpString>,\n+ async fn execute_agent(\n+ definition: &RuntimeHookDefinition,\n+ prompt: &str,\n+ model: Option<&str>,\n max_tool_rounds: Option<u32>,\n context: &HookContext,\n sandbox: Arc<dyn Sandbox>,\n- env: &E,\n llm_source: &dyn CredentialSource,\n catalog: Arc<Catalog>,\n- ) -> HookDecision\n- where\n- E: Env + ?Sized,\n- {\n- let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env) {\n- Ok(resolved) => resolved,\n- Err(error) => {\n- tracing::error!(error = %error, \"agent hook env resolution failed, not firing\");\n- return HookDecision::Block {\n- reason: Some(error.to_string()),\n- };\n- }\n- };\n-\n- let resolved_model = Self::resolve_model(model.as_deref(), catalog.as_ref());\n- let user_msg = Self::build_hook_user_message(&prompt, context);\n+ ) -> HookDecision {\n+ let resolved_model = Self::resolve_model(model, catalog.as_ref());\n+ let user_msg = Self::build_hook_user_message(prompt, context);\n \n Self::execute_llm_with_timeout(definition.timeout(), \"agent\", || async move {\n let client = match LlmClient::from_source(llm_source, catalog).await {\n@@ -562,45 +409,24 @@ impl HookExecutorImpl {\n \n /// Execute an HTTP hook: POST context JSON and parse the response.\n ///\n- /// Token resolution is fail-closed: a missing or out-of-scope token in the\n- /// URL or a header is a hard `Block`, so the hook never fires with a\n- /// half-resolved URL or an empty credential header. Transport outcomes\n- /// (non-2xx, connection errors, unparseable body) stay fail-open and\n- /// return `Proceed`.\n- async fn execute_http<E>(\n+ /// Transport outcomes (non-2xx, connection errors, unparseable body) are\n+ /// fail-open and return `Proceed`. Logging uses `http.url_source` — the\n+ /// unresolved config source carried on the runtime type — so the resolved\n+ /// URL, which may embed secret material, is never logged.\n+ async fn execute_http(\n client: &fabro_http::HttpClient,\n- url: &InterpString,\n- headers: Option<&HashMap<String, InterpString>>,\n- allowed_env_vars: &[String],\n- tls: &TlsMode,\n+ http: &RuntimeHttpHook,\n context: &HookContext,\n timeout: std::time::Duration,\n- env: &E,\n- ) -> HookDecision\n- where\n- E: Env + ?Sized,\n- {\n- let resolved_url = match resolve_interp(url, env) {\n- Ok(url) => url,\n- Err(error) => {\n- tracing::error!(\n- url_source = %safe_url_source_for_log(url),\n- error = %error,\n- \"HTTP hook URL env resolution failed, not firing\"\n- );\n- return HookDecision::Block {\n- reason: Some(error.to_string()),\n- };\n- }\n- };\n-\n+ ) -> HookDecision {\n // Enforce URL scheme based on TLS mode\n- match tls {\n+ match http.tls {\n TlsMode::Verify | TlsMode::NoVerify => {\n- if !resolved_url.starts_with(\"https://\") {\n+ if !http.url.starts_with(\"https://\") {\n return HookDecision::Block {\n reason: Some(format!(\n- \"HTTP hook URL must use https:// (tls mode is {tls:?})\"\n+ \"HTTP hook URL must use https:// (tls mode is {:?})\",\n+ http.tls\n )),\n };\n }\n@@ -608,29 +434,11 @@ impl HookExecutorImpl {\n TlsMode::Off => {}\n }\n \n- let mut request = client.post(&resolved_url).timeout(timeout).json(context);\n+ let mut request = client.post(&http.url).timeout(timeout).json(context);\n \n- if let Some(hdrs) = headers {\n+ if let Some(hdrs) = &http.headers {\n for (key, value) in hdrs {\n- // Headers resolve through the per-hook env allowlist: a\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- Ok(rendered) => rendered,\n- Err(error) => {\n- tracing::error!(\n- url_source = %safe_url_source_for_log(url),\n- header = %key,\n- error = %error,\n- \"HTTP hook header env resolution failed, not firing\"\n- );\n- return HookDecision::Block {\n- reason: Some(error.to_string()),\n- };\n- }\n- };\n- request = request.header(key, interpolated);\n+ request = request.header(key, value);\n }\n }\n \n@@ -638,7 +446,7 @@ impl HookExecutorImpl {\n Ok(resp) => resp,\n Err(e) => {\n tracing::warn!(\n- url_source = %safe_url_source_for_log(url),\n+ url_source = %redacted_url_for_log(&http.url_source),\n error = %e,\n \"HTTP hook request failed, proceeding\"\n );\n@@ -648,7 +456,7 @@ impl HookExecutorImpl {\n \n if !response.status().is_success() {\n tracing::warn!(\n- url_source = %safe_url_source_for_log(url),\n+ url_source = %redacted_url_for_log(&http.url_source),\n status = response.status().as_u16(),\n \"HTTP hook returned non-2xx, proceeding\"\n );\n@@ -659,7 +467,7 @@ impl HookExecutorImpl {\n Ok(text) => text,\n Err(e) => {\n tracing::warn!(\n- url_source = %safe_url_source_for_log(url),\n+ url_source = %redacted_url_for_log(&http.url_source),\n error = %e,\n \"HTTP hook body read failed, proceeding\"\n );\n@@ -675,7 +483,7 @@ impl HookExecutorImpl {\n Ok(decision) => decision,\n Err(e) => {\n tracing::warn!(\n- url_source = %safe_url_source_for_log(url),\n+ url_source = %redacted_url_for_log(&http.url_source),\n error = %e,\n \"HTTP hook response parse failed, proceeding\"\n );\n@@ -720,7 +528,7 @@ impl Default for HttpClientCache {\n impl HookExecutor for HookExecutorImpl {\n async fn execute(\n &self,\n- definition: &HookDefinition,\n+ definition: &RuntimeHookDefinition,\n context: &HookContext,\n sandbox: Arc<dyn Sandbox>,\n execution_context: &HookExecutionContext,\n@@ -731,91 +539,39 @@ impl HookExecutor for HookExecutorImpl {\n static HTTP_CLIENTS: OnceLock<HttpClientCache> = OnceLock::new();\n \n let start = Instant::now();\n- let env = SystemEnv;\n-\n- let decision = match definition.resolved_hook_type() {\n- Some(\n- Cow::Borrowed(HookType::Command { ref command })\n- | Cow::Owned(HookType::Command { ref command }),\n- ) => {\n- Self::execute_command(\n- definition,\n- command,\n- context,\n- &sandbox,\n- execution_context,\n- &env,\n- )\n- .await\n+\n+ let decision = match &definition.hook_type {\n+ Some(RuntimeHookType::Command { command }) => {\n+ Self::execute_command(definition, command, context, &sandbox, execution_context)\n+ .await\n }\n- Some(\n- Cow::Borrowed(HookType::Http {\n- ref url,\n- ref headers,\n- ref allowed_env_vars,\n- ref tls,\n- })\n- | Cow::Owned(HookType::Http {\n- ref url,\n- ref headers,\n- ref allowed_env_vars,\n- ref tls,\n- }),\n- ) => {\n+ Some(RuntimeHookType::Http(http)) => {\n let clients = HTTP_CLIENTS.get_or_init(HttpClientCache::new);\n- Self::execute_http(\n- clients.get(*tls),\n- url,\n- headers.as_ref(),\n- allowed_env_vars,\n- tls,\n- context,\n- definition.timeout(),\n- &env,\n- )\n- .await\n+ Self::execute_http(clients.get(http.tls), http, context, definition.timeout()).await\n }\n- Some(\n- Cow::Borrowed(HookType::Prompt {\n- ref prompt,\n- ref model,\n- })\n- | Cow::Owned(HookType::Prompt {\n- ref prompt,\n- ref model,\n- }),\n- ) => {\n+ Some(RuntimeHookType::Prompt { prompt, model }) => {\n Self::execute_prompt(\n definition,\n prompt,\n- model.as_ref(),\n+ model.as_deref(),\n context,\n- &env,\n llm_source,\n Arc::clone(&catalog),\n )\n .await\n }\n- Some(\n- Cow::Borrowed(HookType::Agent {\n- ref prompt,\n- ref model,\n- ref max_tool_rounds,\n- })\n- | Cow::Owned(HookType::Agent {\n- ref prompt,\n- ref model,\n- ref max_tool_rounds,\n- }),\n- ) => {\n+ Some(RuntimeHookType::Agent {\n+ prompt,\n+ model,\n+ max_tool_rounds,\n+ }) => {\n Self::execute_agent(\n definition,\n prompt,\n- model.as_ref(),\n+ model.as_deref(),\n *max_tool_rounds,\n context,\n sandbox,\n- &env,\n llm_source,\n Arc::clone(&catalog),\n )\n@@ -839,10 +595,8 @@ impl HookExecutor for HookExecutorImpl {\n mod tests {\n use fabro_auth::{CredentialSource, EnvCredentialSource};\n use fabro_types::fixtures;\n- use fabro_util::env::TestEnv;\n \n use super::*;\n- use crate::config::HookType;\n use crate::types::HookEvent;\n \n fn make_context() -> HookContext {\n@@ -867,19 +621,25 @@ mod tests {\n HookExecutorImpl::build_http_client(TlsMode::Off)\n }\n \n- fn make_definition(command: &str) -> HookDefinition {\n- HookDefinition {\n- name: Some(\"test-hook\".into()),\n- event: HookEvent::StageStart,\n- command: Some(command.into()),\n- hook_type: None,\n- matcher: None,\n- blocking: None,\n+ fn make_typed_definition(hook_type: Option<RuntimeHookType>) -> RuntimeHookDefinition {\n+ RuntimeHookDefinition {\n+ name: Some(\"test-hook\".into()),\n+ event: HookEvent::StageStart,\n+ hook_type,\n+ matcher: None,\n+ blocking: None,\n timeout_ms: Some(5000),\n- sandbox: Some(false), // host execution for tests\n+ sandbox: Some(false), // host execution for tests\n+ effective_name: \"test-hook\".into(),\n }\n }\n \n+ fn make_definition(command: &str) -> RuntimeHookDefinition {\n+ make_typed_definition(Some(RuntimeHookType::Command {\n+ command: command.to_string(),\n+ }))\n+ }\n+\n #[test]\n fn parse_decision_exit_0_proceed() {\n assert_eq!(\n@@ -1045,16 +805,7 @@ mod tests {\n #[tokio::test]\n async fn no_hook_type_blocks() {\n let executor = HookExecutorImpl;\n- let def = HookDefinition {\n- name: None,\n- event: HookEvent::StageStart,\n- command: None,\n- hook_type: None,\n- matcher: None,\n- blocking: None,\n- timeout_ms: None,\n- sandbox: Some(false),\n- };\n+ let def = make_typed_definition(None);\n let ctx = make_context();\n let sandbox = make_sandbox();\n let source = test_llm_source();\n@@ -1143,116 +894,31 @@ mod tests {\n );\n }\n \n- // --- hook segment resolution helpers ---\n-\n- fn test_env(vars: &[(&str, &str)]) -> TestEnv {\n- TestEnv(\n- vars.iter()\n- .map(|(k, v)| (k.to_string(), v.to_string()))\n- .collect(),\n- )\n- }\n-\n- fn interp(value: &str) -> InterpString {\n- InterpString::parse(value)\n- }\n-\n- #[test]\n- fn safe_url_source_for_log_redacts_parseable_url_source() {\n- let safe = safe_url_source_for_log(&interp(\n- \"https://user:secret@example.com/hook?token=literal&keep=value\",\n- ));\n-\n- assert_eq!(\n- safe,\n- \"https://user:****@example.com/hook?token=****&keep=value\"\n- );\n- }\n-\n- #[test]\n- fn safe_url_source_for_log_hides_unparseable_url_source() {\n- let safe = safe_url_source_for_log(&interp(\"{{ env.FABRO_TEST_HOOK_URL }}\"));\n-\n- assert_eq!(safe, \"<invalid url>\");\n- }\n-\n- // Headers resolve `{{ env.NAME }}` tokens through the per-hook\n- // `allowed_env_vars` allowlist: an allowlisted name resolves, anything else\n- // fails closed before lookup.\n- #[test]\n- fn header_resolves_allowlisted_var() {\n- let env = test_env(&[(\"FABRO_TEST_KEY_1\", \"secret123\")]);\n- let result = resolve_header(\n- &interp(\"Bearer {{ env.FABRO_TEST_KEY_1 }}\"),\n- &[\"FABRO_TEST_KEY_1\".to_string()],\n- &env,\n- )\n- .unwrap();\n- assert_eq!(result, \"Bearer secret123\");\n- }\n+ // Token resolution tests live with the boundary resolver\n+ // (`HookDefinition::resolve_env` in fabro-types); the executor only ever\n+ // sees resolved strings.\n \n- // Fail-closed: a header may not read an env var that is set in the process\n- // but missing from `allowed_env_vars`. This is distinct from an unset\n- // allowlisted variable, so the block reason points at the allowlist.\n- #[test]\n- fn header_rejects_unlisted_var() {\n- let env = test_env(&[(\"FABRO_TEST_KEY_3\", \"should_not_appear\")]);\n- let err = resolve_header(\n- &interp(\"prefix-{{ env.FABRO_TEST_KEY_3 }}-suffix\"),\n- &[],\n- &env,\n- )\n- .unwrap_err();\n- assert_eq!(err, HeaderResolveError::NotAllowed {\n- name: \"FABRO_TEST_KEY_3\".to_string(),\n- });\n- }\n-\n- #[test]\n- fn header_missing_token_is_hard_error() {\n- let env = test_env(&[]);\n- let err = resolve_header(\n- &interp(\"prefix-{{ env.FABRO_TEST_KEY_3 }}-suffix\"),\n- &[\"FABRO_TEST_KEY_3\".to_string()],\n- &env,\n- )\n- .unwrap_err();\n- match err {\n- HeaderResolveError::Resolve(error) => assert_eq!(error.name, \"FABRO_TEST_KEY_3\"),\n- HeaderResolveError::NotAllowed { .. } => {\n- panic!(\"expected missing token resolve error, got {err:?}\")\n- }\n- }\n- }\n-\n- // The value stays a typed `InterpString`: it resolves at fire time from its\n- // segments, never via a String -> InterpString re-parse.\n- #[test]\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- 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- assert_eq!(err.name, \"FABRO_TEST_NOEXIST\");\n- }\n+ // --- HTTP hook execution tests ---\n \n- #[test]\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- \"plain text\"\n- );\n+ /// Call `execute_http` with the URL doubling as its own (already literal)\n+ /// source, which is exactly what the boundary produces for token-free\n+ /// config URLs.\n+ async fn execute_http_for_test(\n+ url: &str,\n+ headers: Option<HashMap<String, String>>,\n+ tls: TlsMode,\n+ timeout: std::time::Duration,\n+ ) -> HookDecision {\n+ let client = test_http_client();\n+ let http = RuntimeHttpHook {\n+ url: url.to_string(),\n+ url_source: url.to_string(),\n+ headers,\n+ tls,\n+ };\n+ HookExecutorImpl::execute_http(&client, &http, &make_context(), timeout).await\n }\n \n- // --- HTTP hook execution tests ---\n-\n #[tokio::test]\n async fn http_hook_posts_json_and_parses_decision() {\n let server = httpmock::MockServer::start_async().await;\n@@ -1266,16 +932,11 @@ mod tests {\n })\n .await;\n \n- let client = test_http_client();\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(&server.url(\"/hook\")),\n+ let decision = execute_http_for_test(\n+ &server.url(\"/hook\"),\n None,\n- &[],\n- &TlsMode::Off,\n- &make_context(),\n+ TlsMode::Off,\n std::time::Duration::from_secs(5),\n- &test_env(&[]),\n )\n .await;\n \n@@ -1295,16 +956,11 @@ mod tests {\n })\n .await;\n \n- let client = test_http_client();\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(&server.url(\"/hook\")),\n+ let decision = execute_http_for_test(\n+ &server.url(\"/hook\"),\n None,\n- &[],\n- &TlsMode::Off,\n- &make_context(),\n+ TlsMode::Off,\n std::time::Duration::from_secs(5),\n- &test_env(&[]),\n )\n .await;\n \n@@ -1322,16 +978,11 @@ mod tests {\n })\n .await;\n \n- let client = test_http_client();\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(&server.url(\"/hook\")),\n+ let decision = execute_http_for_test(\n+ &server.url(\"/hook\"),\n None,\n- &[],\n- &TlsMode::Off,\n- &make_context(),\n+ TlsMode::Off,\n std::time::Duration::from_secs(5),\n- &test_env(&[]),\n )\n .await;\n \n@@ -1341,16 +992,11 @@ mod tests {\n \n #[tokio::test]\n async fn http_hook_connection_failure_returns_proceed() {\n- let client = test_http_client();\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(\"http://127.0.0.1:1\"),\n+ let decision = execute_http_for_test(\n+ \"http://127.0.0.1:1\",\n None,\n- &[],\n- &TlsMode::Off,\n- &make_context(),\n+ TlsMode::Off,\n std::time::Duration::from_secs(1),\n- &test_env(&[]),\n )\n .await;\n \n@@ -1358,9 +1004,7 @@ mod tests {\n }\n \n #[tokio::test]\n- async fn http_hook_sends_interpolated_headers() {\n- let env = test_env(&[(\"FABRO_TEST_TOKEN\", \"my-secret\")]);\n-\n+ async fn http_hook_sends_configured_headers() {\n let server = httpmock::MockServer::start_async().await;\n let mock = server\n .mock_async(|when, then| {\n@@ -1371,96 +1015,14 @@ mod tests {\n })\n .await;\n \n- let headers = HashMap::from([(\n- \"Authorization\".to_string(),\n- interp(\"Bearer {{ env.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- &[\"FABRO_TEST_TOKEN\".to_string()],\n- &TlsMode::Off,\n- &make_context(),\n- std::time::Duration::from_secs(5),\n- &env,\n- )\n- .await;\n-\n- mock.assert_async().await;\n- assert_eq!(decision, HookDecision::Proceed);\n- }\n-\n- // Fail-closed: a header that references an env var set in the process but\n- // absent from `allowed_env_vars` must block and never fire the request.\n- #[tokio::test]\n- async fn http_hook_unlisted_header_var_blocks_without_firing() {\n- let env = test_env(&[(\"FABRO_TEST_TOKEN\", \"my-secret\")]);\n-\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 {{ env.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- // Empty allowlist: the env var is set, but headers may read nothing.\n- &[],\n- &TlsMode::Off,\n- &make_context(),\n- std::time::Duration::from_secs(5),\n- &env,\n- )\n- .await;\n-\n- assert_eq!(mock.calls_async().await, 0);\n- match decision {\n- HookDecision::Block { reason } => {\n- assert!(\n- reason\n- .as_deref()\n- .is_some_and(|reason| reason.contains(\"FABRO_TEST_TOKEN\")),\n- \"block reason should name the unlisted token, got: {reason:?}\"\n- );\n- }\n- other => panic!(\"expected Block on unlisted header var, got {other:?}\"),\n- }\n- }\n+ let headers =\n+ HashMap::from([(\"Authorization\".to_string(), \"Bearer my-secret\".to_string())]);\n \n- #[tokio::test]\n- async fn http_hook_resolves_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 env = test_env(&[(\"FABRO_TEST_URL\", &server.url(\"/hook\"))]);\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(\"{{ env.FABRO_TEST_URL }}\"),\n- None,\n- &[],\n- &TlsMode::Off,\n- &make_context(),\n+ let decision = execute_http_for_test(\n+ &server.url(\"/hook\"),\n+ Some(headers),\n+ TlsMode::Off,\n std::time::Duration::from_secs(5),\n- &env,\n )\n .await;\n \n@@ -1468,93 +1030,15 @@ mod tests {\n assert_eq!(decision, HookDecision::Proceed);\n }\n \n- #[tokio::test]\n- async fn http_hook_missing_url_token_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 client = test_http_client();\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(\"{{ env.FABRO_TEST_MISSING_URL }}/hook\"),\n- None,\n- &[],\n- &TlsMode::Off,\n- &make_context(),\n- std::time::Duration::from_secs(5),\n- &test_env(&[]),\n- )\n- .await;\n-\n- // Fail-closed: the missing token must not fire the hook at all.\n- assert_eq!(mock.calls_async().await, 0);\n- match decision {\n- HookDecision::Block { reason } => {\n- assert!(\n- reason\n- .as_deref()\n- .is_some_and(|reason| reason.contains(\"FABRO_TEST_MISSING_URL\")),\n- \"block reason should name the missing token, got: {reason:?}\"\n- );\n- }\n- other => panic!(\"expected Block on missing url token, got {other:?}\"),\n- }\n- }\n-\n- #[tokio::test]\n- async fn http_hook_missing_header_token_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 {{ env.FABRO_TEST_MISSING_HEADER }}\"),\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- // Allowlisted but unset: still blocks on the Missing lookup.\n- &[\"FABRO_TEST_MISSING_HEADER\".to_string()],\n- &TlsMode::Off,\n- &make_context(),\n- std::time::Duration::from_secs(5),\n- &test_env(&[]),\n- )\n- .await;\n-\n- // Fail-closed: a missing header token must not fire the hook with an\n- // empty credential header.\n- assert_eq!(mock.calls_async().await, 0);\n- assert!(matches!(decision, HookDecision::Block { .. }));\n- }\n-\n // --- TLS mode enforcement tests ---\n \n #[tokio::test]\n async fn http_hook_rejects_http_url_when_tls_verify() {\n- let client = test_http_client();\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(\"http://example.com/hook\"),\n+ let decision = execute_http_for_test(\n+ \"http://example.com/hook\",\n None,\n- &[],\n- &TlsMode::Verify,\n- &make_context(),\n+ TlsMode::Verify,\n std::time::Duration::from_secs(5),\n- &test_env(&[]),\n )\n .await;\n \n@@ -1563,16 +1047,11 @@ mod tests {\n \n #[tokio::test]\n async fn http_hook_rejects_http_url_when_tls_no_verify() {\n- let client = test_http_client();\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(\"http://example.com/hook\"),\n+ let decision = execute_http_for_test(\n+ \"http://example.com/hook\",\n None,\n- &[],\n- &TlsMode::NoVerify,\n- &make_context(),\n+ TlsMode::NoVerify,\n std::time::Duration::from_secs(5),\n- &test_env(&[]),\n )\n .await;\n \n@@ -1589,16 +1068,11 @@ mod tests {\n })\n .await;\n \n- let client = test_http_client();\n- let decision = HookExecutorImpl::execute_http(\n- &client,\n- &interp(&server.url(\"/hook\")),\n+ let decision = execute_http_for_test(\n+ &server.url(\"/hook\"),\n None,\n- &[],\n- &TlsMode::Off,\n- &make_context(),\n+ TlsMode::Off,\n std::time::Duration::from_secs(5),\n- &test_env(&[]),\n )\n .await;\n \n@@ -1617,20 +1091,20 @@ mod tests {\n .await;\n \n let executor = HookExecutorImpl;\n- let def = HookDefinition {\n- name: Some(\"http-test\".into()),\n- event: HookEvent::StageStart,\n- command: None,\n- hook_type: Some(HookType::Http {\n- url: interp(&server.url(\"/hook\")),\n- headers: None,\n- allowed_env_vars: vec![],\n- tls: TlsMode::Off,\n- }),\n- matcher: None,\n- blocking: None,\n- timeout_ms: Some(5000),\n- sandbox: Some(false),\n+ let def = RuntimeHookDefinition {\n+ name: Some(\"http-test\".into()),\n+ event: HookEvent::StageStart,\n+ hook_type: Some(RuntimeHookType::Http(RuntimeHttpHook {\n+ url: server.url(\"/hook\"),\n+ url_source: server.url(\"/hook\"),\n+ headers: None,\n+ tls: TlsMode::Off,\n+ })),\n+ matcher: None,\n+ blocking: None,\n+ timeout_ms: Some(5000),\n+ sandbox: Some(false),\n+ effective_name: \"http-test\".into(),\n };\n let ctx = make_context();\n let sandbox = make_sandbox();\n@@ -1650,67 +1124,4 @@ mod tests {\n assert_eq!(result.decision, HookDecision::Proceed);\n assert_eq!(result.hook_name.as_deref(), Some(\"http-test\"));\n }\n-\n- #[tokio::test]\n- async fn command_hook_missing_env_blocks() {\n- let sandbox = make_sandbox();\n- let decision = HookExecutorImpl::execute_command(\n- &make_definition(\"echo {{ env.MISSING_HOOK_VALUE }}\"),\n- &interp(\"echo {{ env.MISSING_HOOK_VALUE }}\"),\n- &make_context(),\n- &sandbox,\n- &HookExecutionContext::default(),\n- &test_env(&[]),\n- )\n- .await;\n-\n- assert!(matches!(decision, HookDecision::Block { .. }));\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- async fn prompt_hook_missing_env_blocks() {\n- let decision = HookExecutorImpl::execute_prompt(\n- &make_definition(\"unused\"),\n- &interp(\"{{ env.MISSING_HOOK_VALUE }}\"),\n- None,\n- &make_context(),\n- &test_env(&[]),\n- test_llm_source().as_ref(),\n- test_catalog(),\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_VALUE\")),\n- \"block reason should name the missing token, got: {reason:?}\"\n- );\n- }\n- other => panic!(\"expected Block on missing prompt token, got {other:?}\"),\n- }\n- }\n-\n- // Fail-closed: an agent hook with a missing token blocks instead of firing.\n- #[tokio::test]\n- async fn agent_hook_missing_env_blocks() {\n- let decision = HookExecutorImpl::execute_agent(\n- &make_definition(\"unused\"),\n- &interp(\"{{ env.MISSING_HOOK_VALUE }}\"),\n- None,\n- Some(1),\n- &make_context(),\n- make_sandbox(),\n- &test_env(&[]),\n- test_llm_source().as_ref(),\n- test_catalog(),\n- )\n- .await;\n-\n- assert!(matches!(decision, HookDecision::Block { .. }));\n- }\n }\ndiff --git a/lib/crates/fabro-hooks/src/lib.rs b/lib/crates/fabro-hooks/src/lib.rs\nindex 79a77bb98..ed737eac6 100644\n--- a/lib/crates/fabro-hooks/src/lib.rs\n+++ b/lib/crates/fabro-hooks/src/lib.rs\n@@ -5,9 +5,6 @@ pub mod runner;\n pub mod types;\n \n pub use bridge::WorkflowToolHookCallback;\n-pub use config::{HookDefinition, HookSettings, HookType, TlsMode};\n-// Re-exported because the interpolatable fields of `HookType` are typed as\n-// `InterpString`; constructing a hook definition requires it.\n-pub use fabro_types::settings::InterpString;\n+pub use config::{HookSettings, RuntimeHookDefinition, RuntimeHookType, RuntimeHttpHook, TlsMode};\n pub use runner::HookRunner;\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..ed2a52ce5 100644\n--- a/lib/crates/fabro-hooks/src/runner.rs\n+++ b/lib/crates/fabro-hooks/src/runner.rs\n@@ -7,7 +7,7 @@ use fabro_auth::CredentialSource;\n use fabro_auth::EnvCredentialSource;\n use fabro_model::Catalog;\n \n-use crate::config::{HookDefinition, HookSettings};\n+use crate::config::{HookSettings, RuntimeHookDefinition};\n use crate::executor::{HookExecutor, HookExecutorImpl};\n use crate::types::{HookContext, HookDecision, HookExecutionContext};\n \n@@ -108,7 +108,7 @@ impl HookRunner {\n }\n \n /// Filter hooks that match the given event and context.\n- fn filter_hooks(&self, context: &HookContext) -> Vec<&HookDefinition> {\n+ fn filter_hooks(&self, context: &HookContext) -> Vec<&RuntimeHookDefinition> {\n self.config\n .hooks\n .iter()\n@@ -118,7 +118,7 @@ impl HookRunner {\n }\n \n /// Check if a hook's matcher applies to this context.\n- fn matches(&self, hook: &HookDefinition, context: &HookContext) -> bool {\n+ fn matches(&self, hook: &RuntimeHookDefinition, context: &HookContext) -> bool {\n let Some(ref pattern) = hook.matcher else {\n return true;\n };\n@@ -139,7 +139,7 @@ impl HookRunner {\n \n async fn run_sequential(\n &self,\n- hooks: &[&HookDefinition],\n+ hooks: &[&RuntimeHookDefinition],\n context: &HookContext,\n sandbox: Arc<dyn Sandbox>,\n execution_context: &HookExecutionContext,\n@@ -147,7 +147,7 @@ impl HookRunner {\n let mut merged = HookDecision::Proceed;\n for hook in hooks {\n tracing::debug!(\n- hook = %hook.effective_name(),\n+ hook = %hook.effective_name,\n event = %context.event,\n \"Executing hook\"\n );\n@@ -163,7 +163,7 @@ impl HookRunner {\n )\n .await;\n tracing::debug!(\n- hook = %hook.effective_name(),\n+ hook = %hook.effective_name,\n duration_ms = result.duration_ms,\n decision = ?result.decision,\n \"Hook complete\"\n@@ -174,7 +174,7 @@ impl HookRunner {\n // Short-circuit on Block\n if matches!(merged, HookDecision::Block { .. }) {\n tracing::error!(\n- hook = %hook.effective_name(),\n+ hook = %hook.effective_name,\n event = %context.event,\n decision = ?merged,\n \"Hook blocked execution\"\n@@ -183,7 +183,7 @@ impl HookRunner {\n }\n } else if !result.decision.is_proceed() {\n tracing::warn!(\n- hook = %hook.effective_name(),\n+ hook = %hook.effective_name,\n event = %context.event,\n decision = ?result.decision,\n \"Non-blocking hook returned non-proceed, ignoring\"\n@@ -195,14 +195,14 @@ impl HookRunner {\n \n async fn run_non_blocking(\n &self,\n- hooks: &[&HookDefinition],\n+ hooks: &[&RuntimeHookDefinition],\n context: &HookContext,\n sandbox: Arc<dyn Sandbox>,\n execution_context: &HookExecutionContext,\n ) -> HookDecision {\n for hook in hooks {\n tracing::debug!(\n- hook = %hook.effective_name(),\n+ hook = %hook.effective_name,\n event = %context.event,\n \"Executing hook\"\n );\n@@ -218,14 +218,14 @@ impl HookRunner {\n )\n .await;\n tracing::debug!(\n- hook = %hook.effective_name(),\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+ hook = %hook.effective_name,\n event = %context.event,\n decision = ?result.decision,\n \"Non-blocking hook failed, continuing\"\n@@ -242,7 +242,7 @@ mod tests {\n use fabro_types::fixtures;\n \n use super::*;\n- use crate::config::HookSettings;\n+ use crate::config::{HookSettings, RuntimeHookType};\n use crate::types::{HookContext, HookEvent, HookResult};\n \n struct MockExecutor {\n@@ -253,7 +253,7 @@ mod tests {\n impl HookExecutor for MockExecutor {\n async fn execute(\n &self,\n- definition: &HookDefinition,\n+ definition: &RuntimeHookDefinition,\n _context: &HookContext,\n _sandbox: Arc<dyn Sandbox>,\n _execution_context: &HookExecutionContext,\n@@ -286,16 +286,18 @@ mod tests {\n Arc::new(Catalog::from_builtin().expect(\"default catalog should build\"))\n }\n \n- fn make_hook(event: HookEvent, name: &str) -> HookDefinition {\n- HookDefinition {\n+ fn make_hook(event: HookEvent, name: &str) -> RuntimeHookDefinition {\n+ RuntimeHookDefinition {\n name: Some(name.into()),\n event,\n- command: Some(\"echo test\".into()),\n- hook_type: None,\n+ hook_type: Some(RuntimeHookType::Command {\n+ command: \"echo test\".into(),\n+ }),\n matcher: None,\n blocking: None,\n timeout_ms: None,\n sandbox: Some(false),\n+ effective_name: name.into(),\n }\n }\n \n@@ -470,7 +472,9 @@ mod tests {\n let config = HookSettings {\n hooks: vec![{\n let mut h = make_hook(HookEvent::RunStart, \"echo-hook\");\n- h.command = Some(\"exit 0\".into());\n+ h.hook_type = Some(RuntimeHookType::Command {\n+ command: \"exit 0\".into(),\n+ });\n h\n }],\n };\n@@ -488,7 +492,9 @@ mod tests {\n let config = HookSettings {\n hooks: vec![{\n let mut h = make_hook(HookEvent::RunStart, \"fail-hook\");\n- h.command = Some(\"exit 1\".into());\n+ h.hook_type = Some(RuntimeHookType::Command {\n+ command: \"exit 1\".into(),\n+ });\n h\n }],\n };\ndiff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/crates/fabro-hooks/src/types.rs\nindex 8a1cba192..7f2d523ec 100644\n--- a/lib/crates/fabro-hooks/src/types.rs\n+++ b/lib/crates/fabro-hooks/src/types.rs\n@@ -3,8 +3,8 @@ use std::path::{Path, PathBuf};\n use fabro_types::RunId;\n use serde::{Deserialize, Serialize};\n \n-use crate::config::HookDefinition;\n pub use crate::config::HookEvent;\n+use crate::config::RuntimeHookDefinition;\n \n /// Rich JSON payload sent to hooks.\n #[derive(Debug, Clone, Serialize, Deserialize)]\n@@ -128,7 +128,7 @@ pub struct HookExecutionContext {\n \n impl HookExecutionContext {\n #[must_use]\n- pub fn command_cwd_for(&self, definition: &HookDefinition) -> Option<&Path> {\n+ pub fn command_cwd_for(&self, definition: &RuntimeHookDefinition) -> Option<&Path> {\n if definition.runs_in_sandbox() {\n self.sandbox_work_dir.as_deref()\n } else {\n@@ -152,17 +152,20 @@ mod tests {\n use fabro_types::fixtures;\n \n use super::*;\n-\n- fn command_hook(sandbox: bool) -> HookDefinition {\n- HookDefinition {\n- name: Some(\"cwd-test\".into()),\n- event: HookEvent::RunStart,\n- command: Some(\"pwd\".into()),\n- hook_type: None,\n- matcher: None,\n- blocking: None,\n- timeout_ms: None,\n- sandbox: Some(sandbox),\n+ use crate::config::RuntimeHookType;\n+\n+ fn command_hook(sandbox: bool) -> RuntimeHookDefinition {\n+ RuntimeHookDefinition {\n+ name: Some(\"cwd-test\".into()),\n+ event: HookEvent::RunStart,\n+ hook_type: Some(RuntimeHookType::Command {\n+ command: \"pwd\".into(),\n+ }),\n+ matcher: None,\n+ blocking: None,\n+ timeout_ms: None,\n+ sandbox: Some(sandbox),\n+ effective_name: \"cwd-test\".into(),\n }\n }\n \ndiff --git a/lib/crates/fabro-hooks/tests/host_command_hooks.rs b/lib/crates/fabro-hooks/tests/host_command_hooks.rs\nindex 4bbe0c5a3..b608a86b0 100644\n--- a/lib/crates/fabro-hooks/tests/host_command_hooks.rs\n+++ b/lib/crates/fabro-hooks/tests/host_command_hooks.rs\n@@ -4,8 +4,8 @@ use std::sync::Arc;\n 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+ HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner, HookSettings,\n+ RuntimeHookDefinition, RuntimeHookType,\n };\n use fabro_model::Catalog;\n use fabro_types::RunId;\n@@ -41,15 +41,17 @@ async fn host_command_hook_uses_host_workdir_not_sandbox_workdir() {\n \n let runner = HookRunner::new(\n HookSettings {\n- hooks: vec![HookDefinition {\n- name: Some(\"host-marker\".to_string()),\n- event: HookEvent::RunStart,\n- command: Some(InterpString::parse(\"printf ran > marker.txt\")),\n- hook_type: None,\n- matcher: None,\n- blocking: Some(true),\n- timeout_ms: Some(5000),\n- sandbox: Some(false),\n+ hooks: vec![RuntimeHookDefinition {\n+ name: Some(\"host-marker\".to_string()),\n+ event: HookEvent::RunStart,\n+ hook_type: Some(RuntimeHookType::Command {\n+ command: \"printf ran > marker.txt\".to_string(),\n+ }),\n+ matcher: None,\n+ blocking: Some(true),\n+ timeout_ms: Some(5000),\n+ sandbox: Some(false),\n+ effective_name: \"host-marker\".to_string(),\n }],\n },\n test_llm_source(),\ndiff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs\nindex 51410c44d..10c6769dc 100644\n--- a/lib/crates/fabro-types/src/settings/run.rs\n+++ b/lib/crates/fabro-types/src/settings/run.rs\n@@ -7,6 +7,7 @@\n //! behavior, and artifact collection.\n \n use std::collections::HashMap;\n+use std::fmt;\n use std::path::PathBuf;\n use std::time::Duration as StdDuration;\n \n@@ -1651,11 +1652,24 @@ fn resolve_env_string(\n if !value.contains(\"{{\") {\n return Ok(());\n }\n+ *value = resolve_env_secrets(&InterpString::parse(value), env_lookup, secrets_lookup)?;\n+ Ok(())\n+}\n+\n+/// Resolve a typed [`InterpString`] against env + secrets lookups — the one\n+/// shared run-boundary resolution context. Tokens in any other namespace fail\n+/// loudly (`vars` should already be substituted server-side; `inputs` never\n+/// resolves in config fields), and a lookup miss is a hard error with no\n+/// fallback to the unresolved source.\n+fn resolve_env_secrets(\n+ value: &InterpString,\n+ env_lookup: &mut impl FnMut(&str) -> Option<String>,\n+ secrets_lookup: &mut impl FnMut(&str) -> Option<String>,\n+) -> Result<String, ResolveError> {\n let mut ctx = ResolveCtx::new()\n .with_env(&mut *env_lookup)\n .with_secrets(&mut *secrets_lookup);\n- *value = InterpString::parse(value).resolve_with(&mut ctx)?;\n- Ok(())\n+ value.resolve_with(&mut ctx)\n }\n \n #[cfg(test)]\n@@ -2256,33 +2270,10 @@ impl HookDefinition {\n })\n }\n \n- #[must_use]\n- pub fn is_blocking(&self) -> bool {\n- self.blocking\n- .unwrap_or_else(|| self.event.is_blocking_by_default())\n- }\n-\n- #[must_use]\n- pub fn timeout(&self) -> StdDuration {\n- if let Some(ms) = self.timeout_ms {\n- return StdDuration::from_millis(ms);\n- }\n- let default_ms = match self.resolved_hook_type().as_deref() {\n- Some(HookType::Prompt { .. }) => 30_000,\n- _ => 60_000,\n- };\n- StdDuration::from_millis(default_ms)\n- }\n-\n- #[must_use]\n- pub fn runs_in_sandbox(&self) -> bool {\n- self.sandbox.unwrap_or(true)\n- }\n-\n #[must_use]\n #[expect(\n clippy::disallowed_methods,\n- reason = \"effective_name builds a human/merge-identity label from the hook's unresolved \\\n+ reason = \"effective_name builds a human-readable label from the hook's unresolved \\\n template source; the source text is the intended display value here\"\n )]\n pub fn effective_name(&self) -> String {\n@@ -2305,6 +2296,569 @@ impl HookDefinition {\n None => event,\n }\n }\n+\n+ /// Resolve `{{ env.* }}` and `{{ secrets.* }}` tokens in this hook's\n+ /// interpolatable fields (`command`, `url`, header values, `prompt`,\n+ /// `model`) against the supplied lookups, producing the fully resolved\n+ /// [`RuntimeHookDefinition`] the hook executor consumes.\n+ ///\n+ /// This is the late, use-time half of hook interpolation, the counterpart\n+ /// to the server-side `{{ vars.* }}` substitution in\n+ /// [`RunNamespace::substitute_variables`]: `{{ vars.* }}` are substituted\n+ /// earlier, server-side, while `{{ env.* }}` and `{{ secrets.* }}`\n+ /// resolve here — once, at the run boundary, in the worker process that\n+ /// fires the hooks. Carrying the source form out of the config resolve\n+ /// layer keeps `fabro validate` portable (it never requires env to be\n+ /// set), and a referenced env var or secret that is unset is a hard error\n+ /// — no fallback to the unresolved source and no fire-time resolution.\n+ ///\n+ /// HTTP hook headers keep their own, stricter policy, enforced here\n+ /// before any lookup: a `{{ secrets.* }}` token is rejected outright\n+ /// ([`HookResolveError::SecretsInHeader`]), and an `{{ env.* }}` token\n+ /// must name a variable listed in the hook's `allowed_env_vars`\n+ /// ([`HookResolveError::HeaderEnvNotAllowed`]).\n+ pub fn resolve_env(\n+ &self,\n+ mut env_lookup: impl FnMut(&str) -> Option<String>,\n+ mut secrets_lookup: impl FnMut(&str) -> Option<String>,\n+ ) -> Result<RuntimeHookDefinition, HookResolveError> {\n+ let hook_type = match self.resolved_hook_type().as_deref() {\n+ Some(HookType::Command { command }) => Some(RuntimeHookType::Command {\n+ command: resolve_hook_value(command, &mut env_lookup, &mut secrets_lookup)?,\n+ }),\n+ Some(HookType::Http {\n+ url,\n+ headers,\n+ allowed_env_vars,\n+ tls,\n+ }) => {\n+ let headers = headers\n+ .as_ref()\n+ .map(|headers| {\n+ headers\n+ .iter()\n+ .map(|(name, value)| {\n+ let value =\n+ resolve_hook_header(value, allowed_env_vars, &mut env_lookup)?;\n+ Ok((name.clone(), value))\n+ })\n+ .collect::<Result<HashMap<_, _>, HookResolveError>>()\n+ })\n+ .transpose()?;\n+ #[expect(\n+ clippy::disallowed_methods,\n+ reason = \"url_source deliberately carries the unresolved source so hook HTTP \\\n+ logging never includes the resolved URL\"\n+ )]\n+ let url_source = url.as_source();\n+ Some(RuntimeHookType::Http(RuntimeHttpHook {\n+ url: resolve_hook_value(url, &mut env_lookup, &mut secrets_lookup)?,\n+ url_source,\n+ headers,\n+ tls: *tls,\n+ }))\n+ }\n+ Some(HookType::Prompt { prompt, model }) => Some(RuntimeHookType::Prompt {\n+ prompt: resolve_hook_value(prompt, &mut env_lookup, &mut secrets_lookup)?,\n+ model: model\n+ .as_ref()\n+ .map(|model| resolve_hook_value(model, &mut env_lookup, &mut secrets_lookup))\n+ .transpose()?,\n+ }),\n+ Some(HookType::Agent {\n+ prompt,\n+ model,\n+ max_tool_rounds,\n+ }) => Some(RuntimeHookType::Agent {\n+ prompt: resolve_hook_value(prompt, &mut env_lookup, &mut secrets_lookup)?,\n+ model: model\n+ .as_ref()\n+ .map(|model| resolve_hook_value(model, &mut env_lookup, &mut secrets_lookup))\n+ .transpose()?,\n+ max_tool_rounds: *max_tool_rounds,\n+ }),\n+ None => None,\n+ };\n+\n+ Ok(RuntimeHookDefinition {\n+ name: self.name.clone(),\n+ event: self.event,\n+ hook_type,\n+ matcher: self.matcher.clone(),\n+ blocking: self.blocking,\n+ timeout_ms: self.timeout_ms,\n+ sandbox: self.sandbox,\n+ effective_name: self.effective_name(),\n+ })\n+ }\n+}\n+\n+/// Resolve `{{ env.* }}` and `{{ secrets.* }}` tokens in one hook field via\n+/// the shared run-boundary resolution context ([`resolve_env_secrets`]).\n+fn resolve_hook_value(\n+ value: &InterpString,\n+ env_lookup: &mut impl FnMut(&str) -> Option<String>,\n+ secrets_lookup: &mut impl FnMut(&str) -> Option<String>,\n+) -> Result<String, HookResolveError> {\n+ resolve_env_secrets(value, env_lookup, secrets_lookup).map_err(HookResolveError::Resolve)\n+}\n+\n+/// Resolve an HTTP hook **header** value, enforcing the header credential\n+/// policy before any lookup: `{{ secrets.* }}` tokens are rejected outright,\n+/// and `{{ env.* }}` lookups are scoped to `allowed_env_vars`. A name outside\n+/// the allowlist fails with a distinct error before any lookup, while an\n+/// allowlisted-but-unset name still surfaces as the normal missing-variable\n+/// error. An empty `allowed_env_vars` therefore permits no env vars in\n+/// headers at all.\n+fn resolve_hook_header(\n+ value: &InterpString,\n+ allowed_env_vars: &[String],\n+ env_lookup: &mut impl FnMut(&str) -> Option<String>,\n+) -> Result<String, HookResolveError> {\n+ if let Some(name) = value.names(Namespace::Secrets).first() {\n+ return Err(HookResolveError::SecretsInHeader {\n+ name: (*name).to_string(),\n+ });\n+ }\n+ if let Some(name) = value.names(Namespace::Env).into_iter().find(|name| {\n+ !allowed_env_vars\n+ .iter()\n+ .any(|allowed| allowed.as_str() == *name)\n+ }) {\n+ return Err(HookResolveError::HeaderEnvNotAllowed {\n+ name: name.to_string(),\n+ });\n+ }\n+ value\n+ .resolve(&mut *env_lookup)\n+ .map_err(HookResolveError::Resolve)\n+}\n+\n+/// An error from resolving a hook's interpolation tokens at the run boundary\n+/// (see [`HookDefinition::resolve_env`]).\n+#[derive(Debug, Clone, PartialEq, Eq)]\n+pub enum HookResolveError {\n+ /// A `{{ secrets.* }}` token in an HTTP hook header value, rejected\n+ /// before any vault lookup: header values travel to third-party\n+ /// endpoints, so secrets stay out of them by policy.\n+ SecretsInHeader { name: String },\n+ /// An `{{ env.* }}` token in an HTTP hook header naming a variable that\n+ /// is not listed in the hook's `allowed_env_vars`.\n+ HeaderEnvNotAllowed { name: String },\n+ /// A missing or out-of-scope token in any hook field.\n+ Resolve(ResolveError),\n+}\n+\n+impl fmt::Display for HookResolveError {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ match self {\n+ Self::SecretsInHeader { name } => write!(\n+ f,\n+ \"secret {name:?} is not allowed in HTTP hook headers; use secret interpolation \\\n+ in a hook command, prompt, or url instead\"\n+ ),\n+ Self::HeaderEnvNotAllowed { name } => write!(\n+ f,\n+ \"environment variable {name:?} referenced by an HTTP hook header is not listed \\\n+ in allowed_env_vars\"\n+ ),\n+ Self::Resolve(error) => error.fmt(f),\n+ }\n+ }\n+}\n+\n+impl std::error::Error for HookResolveError {\n+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {\n+ match self {\n+ Self::Resolve(error) => Some(error),\n+ Self::SecretsInHeader { .. } | Self::HeaderEnvNotAllowed { .. } => None,\n+ }\n+ }\n+}\n+\n+/// A hook definition with every interpolatable field resolved to a plain\n+/// string, produced at the run boundary by [`HookDefinition::resolve_env`].\n+///\n+/// This is the form the hook executor consumes: it formats, dispatches, and\n+/// merges decisions, but never resolves tokens — the hooks subsystem never\n+/// learns about process env or vault secrets. The type is deliberately not\n+/// serializable: resolved values may carry secrets and must not round-trip\n+/// through manifests or events. For the same reason, never log or\n+/// `Debug`-format a whole definition — log `effective_name` instead.\n+#[derive(Debug, Clone, PartialEq)]\n+pub struct RuntimeHookDefinition {\n+ pub name: Option<String>,\n+ pub event: HookEvent,\n+ pub hook_type: Option<RuntimeHookType>,\n+ pub matcher: Option<String>,\n+ pub blocking: Option<bool>,\n+ pub timeout_ms: Option<u64>,\n+ pub sandbox: Option<bool>,\n+ /// Human-readable label for logs, built at the boundary from the hook's\n+ /// *unresolved* config sources so resolved secret material never reaches\n+ /// log output.\n+ pub effective_name: String,\n+}\n+\n+impl RuntimeHookDefinition {\n+ #[must_use]\n+ pub fn is_blocking(&self) -> bool {\n+ self.blocking\n+ .unwrap_or_else(|| self.event.is_blocking_by_default())\n+ }\n+\n+ #[must_use]\n+ pub fn timeout(&self) -> StdDuration {\n+ if let Some(ms) = self.timeout_ms {\n+ return StdDuration::from_millis(ms);\n+ }\n+ let default_ms = match self.hook_type {\n+ Some(RuntimeHookType::Prompt { .. }) => 30_000,\n+ _ => 60_000,\n+ };\n+ StdDuration::from_millis(default_ms)\n+ }\n+\n+ #[must_use]\n+ pub fn runs_in_sandbox(&self) -> bool {\n+ self.sandbox.unwrap_or(true)\n+ }\n+}\n+\n+/// The resolved counterpart of [`HookType`], carried by\n+/// [`RuntimeHookDefinition`].\n+///\n+/// Like [`RuntimeHookDefinition`], the resolved values may carry secrets:\n+/// never log or `Debug`-format them — log the definition's `effective_name`\n+/// (and, for HTTP hooks, the `url_source`) instead.\n+#[derive(Debug, Clone, PartialEq)]\n+pub enum RuntimeHookType {\n+ Command {\n+ command: String,\n+ },\n+ Http(RuntimeHttpHook),\n+ Prompt {\n+ prompt: String,\n+ model: Option<String>,\n+ },\n+ Agent {\n+ prompt: String,\n+ model: Option<String>,\n+ max_tool_rounds: Option<u32>,\n+ },\n+}\n+\n+/// The resolved payload of an HTTP hook, carried by [`RuntimeHookType::Http`].\n+///\n+/// A dedicated struct (rather than inline variant fields) so `url` and\n+/// `url_source` — two same-typed strings with very different logging rules —\n+/// can never be swapped positionally at a call site.\n+#[derive(Debug, Clone, PartialEq)]\n+pub struct RuntimeHttpHook {\n+ pub url: String,\n+ /// The unresolved source of `url`, carried so hook HTTP logging can keep\n+ /// pointing at the config source instead of the resolved URL (which may\n+ /// embed secret material).\n+ pub url_source: String,\n+ pub headers: Option<HashMap<String, String>>,\n+ pub tls: TlsMode,\n+}\n+\n+#[cfg(test)]\n+mod hook_resolve_env_tests {\n+ use std::collections::HashMap;\n+\n+ use super::super::interp::{Namespace, ResolveErrorKind};\n+ use super::{\n+ HookDefinition, HookEvent, HookResolveError, HookType, InterpString, RuntimeHookType,\n+ TlsMode, pair_lookup as env_lookup, pair_lookup as secret_lookup,\n+ };\n+\n+ fn hook(hook_type: HookType) -> HookDefinition {\n+ HookDefinition {\n+ name: Some(\"test-hook\".to_string()),\n+ event: HookEvent::RunStart,\n+ command: None,\n+ hook_type: Some(hook_type),\n+ matcher: None,\n+ blocking: None,\n+ timeout_ms: None,\n+ sandbox: Some(false),\n+ }\n+ }\n+\n+ fn http_hook(url: &str, headers: &[(&str, &str)], allowed_env_vars: &[&str]) -> HookDefinition {\n+ hook(HookType::Http {\n+ url: InterpString::parse(url),\n+ headers: (!headers.is_empty()).then(|| {\n+ headers\n+ .iter()\n+ .map(|(name, value)| ((*name).to_string(), InterpString::parse(value)))\n+ .collect()\n+ }),\n+ allowed_env_vars: allowed_env_vars\n+ .iter()\n+ .map(|name| (*name).to_string())\n+ .collect(),\n+ tls: TlsMode::Off,\n+ })\n+ }\n+\n+ #[test]\n+ fn command_resolves_env_and_secret_tokens() {\n+ let definition = hook(HookType::Command {\n+ command: InterpString::parse(\n+ \"deploy --token {{ secrets.TOKEN }} --region {{ env.REGION }}\",\n+ ),\n+ });\n+\n+ let resolved = definition\n+ .resolve_env(\n+ env_lookup(&[(\"REGION\", \"us-east-1\")]),\n+ secret_lookup(&[(\"TOKEN\", \"vault-value\")]),\n+ )\n+ .unwrap();\n+\n+ assert_eq!(\n+ resolved.hook_type,\n+ Some(RuntimeHookType::Command {\n+ command: \"deploy --token vault-value --region us-east-1\".to_string(),\n+ })\n+ );\n+ assert_eq!(resolved.effective_name, \"test-hook\");\n+ }\n+\n+ #[test]\n+ fn legacy_command_field_resolves() {\n+ let definition = HookDefinition {\n+ hook_type: None,\n+ command: Some(InterpString::parse(\"echo {{ env.MARKER }}\")),\n+ ..hook(HookType::Command {\n+ command: InterpString::parse(\"unused\"),\n+ })\n+ };\n+\n+ let resolved = definition\n+ .resolve_env(env_lookup(&[(\"MARKER\", \"ok\")]), secret_lookup(&[]))\n+ .unwrap();\n+\n+ assert_eq!(\n+ resolved.hook_type,\n+ Some(RuntimeHookType::Command {\n+ command: \"echo ok\".to_string(),\n+ })\n+ );\n+ }\n+\n+ #[test]\n+ fn missing_secret_is_hard_error_naming_the_secret() {\n+ let definition = hook(HookType::Command {\n+ command: InterpString::parse(\"deploy {{ secrets.MISSING_TOKEN }}\"),\n+ });\n+\n+ let err = definition\n+ .resolve_env(env_lookup(&[]), secret_lookup(&[]))\n+ .unwrap_err();\n+\n+ let HookResolveError::Resolve(error) = err else {\n+ panic!(\"expected resolve error, got {err:?}\");\n+ };\n+ assert_eq!(error.namespace, Namespace::Secrets);\n+ assert_eq!(error.name, \"MISSING_TOKEN\");\n+ assert_eq!(error.kind, ResolveErrorKind::Missing);\n+ }\n+\n+ // `inputs` stays template-only: a hook field referencing it fails loudly\n+ // as an unavailable namespace, exactly as fire-time resolution did.\n+ #[test]\n+ fn inputs_token_is_unavailable_namespace_error() {\n+ let definition = hook(HookType::Command {\n+ command: InterpString::parse(\"echo {{ inputs.ticket }}\"),\n+ });\n+\n+ let err = definition\n+ .resolve_env(env_lookup(&[]), secret_lookup(&[]))\n+ .unwrap_err();\n+\n+ let HookResolveError::Resolve(error) = err else {\n+ panic!(\"expected resolve error, got {err:?}\");\n+ };\n+ assert_eq!(error.kind, ResolveErrorKind::Unavailable);\n+ }\n+\n+ #[test]\n+ fn http_url_resolves_secret_and_carries_unresolved_source() {\n+ let definition = http_hook(\"{{ secrets.HOOK_URL }}/notify\", &[], &[]);\n+\n+ let resolved = definition\n+ .resolve_env(\n+ env_lookup(&[]),\n+ secret_lookup(&[(\"HOOK_URL\", \"https://user:pw@hooks.example.com\")]),\n+ )\n+ .unwrap();\n+\n+ let Some(RuntimeHookType::Http(http)) = resolved.hook_type else {\n+ panic!(\"expected http hook type\");\n+ };\n+ assert_eq!(http.url, \"https://user:pw@hooks.example.com/notify\");\n+ assert_eq!(http.url_source, \"{{ secrets.HOOK_URL }}/notify\");\n+ }\n+\n+ #[test]\n+ fn header_resolves_allowlisted_env_var() {\n+ let definition = http_hook(\n+ \"https://hooks.example.com\",\n+ &[(\"Authorization\", \"Bearer {{ env.HOOK_KEY }}\")],\n+ &[\"HOOK_KEY\"],\n+ );\n+\n+ let resolved = definition\n+ .resolve_env(env_lookup(&[(\"HOOK_KEY\", \"key-123\")]), secret_lookup(&[]))\n+ .unwrap();\n+\n+ let Some(RuntimeHookType::Http(http)) = resolved.hook_type else {\n+ panic!(\"expected http hook type\");\n+ };\n+ assert_eq!(\n+ http.headers,\n+ Some(HashMap::from([(\n+ \"Authorization\".to_string(),\n+ \"Bearer key-123\".to_string(),\n+ )]))\n+ );\n+ }\n+\n+ // A secret token in a header is rejected before any vault lookup — the\n+ // panicking secrets lookup proves the value is never fetched.\n+ #[test]\n+ fn header_secret_token_is_rejected_before_lookup() {\n+ let definition = http_hook(\n+ \"https://hooks.example.com\",\n+ &[(\"Authorization\", \"Bearer {{ secrets.HOOK_KEY }}\")],\n+ &[],\n+ );\n+\n+ let err = definition\n+ .resolve_env(env_lookup(&[]), |_: &str| -> Option<String> {\n+ panic!(\"secrets lookup must not run for header values\")\n+ })\n+ .unwrap_err();\n+\n+ assert_eq!(err, HookResolveError::SecretsInHeader {\n+ name: \"HOOK_KEY\".to_string(),\n+ });\n+ assert_eq!(\n+ err.to_string(),\n+ \"secret \\\"HOOK_KEY\\\" is not allowed in HTTP hook headers; use secret interpolation \\\n+ in a hook command, prompt, or url instead\"\n+ );\n+ }\n+\n+ // Fail-closed: a header may not read an env var that is set in the\n+ // process but missing from `allowed_env_vars`. This is distinct from an\n+ // unset allowlisted variable, so the error points at the allowlist.\n+ #[test]\n+ fn header_rejects_env_var_outside_allowlist() {\n+ let definition = http_hook(\n+ \"https://hooks.example.com\",\n+ &[(\"Authorization\", \"Bearer {{ env.HOOK_KEY }}\")],\n+ &[],\n+ );\n+\n+ let err = definition\n+ .resolve_env(\n+ env_lookup(&[(\"HOOK_KEY\", \"should_not_appear\")]),\n+ secret_lookup(&[]),\n+ )\n+ .unwrap_err();\n+\n+ assert_eq!(err, HookResolveError::HeaderEnvNotAllowed {\n+ name: \"HOOK_KEY\".to_string(),\n+ });\n+ assert_eq!(\n+ err.to_string(),\n+ \"environment variable \\\"HOOK_KEY\\\" referenced by an HTTP hook header is not listed \\\n+ in allowed_env_vars\"\n+ );\n+ }\n+\n+ #[test]\n+ fn header_allowlisted_but_unset_env_var_is_missing_error() {\n+ let definition = http_hook(\n+ \"https://hooks.example.com\",\n+ &[(\"Authorization\", \"Bearer {{ env.HOOK_KEY }}\")],\n+ &[\"HOOK_KEY\"],\n+ );\n+\n+ let err = definition\n+ .resolve_env(env_lookup(&[]), secret_lookup(&[]))\n+ .unwrap_err();\n+\n+ let HookResolveError::Resolve(error) = err else {\n+ panic!(\"expected resolve error, got {err:?}\");\n+ };\n+ assert_eq!(error.name, \"HOOK_KEY\");\n+ assert_eq!(error.kind, ResolveErrorKind::Missing);\n+ }\n+\n+ #[test]\n+ fn prompt_and_model_resolve_tokens() {\n+ let definition = hook(HookType::Prompt {\n+ prompt: InterpString::parse(\"check {{ secrets.POLICY }}\"),\n+ model: Some(InterpString::parse(\"{{ env.HOOK_MODEL }}\")),\n+ });\n+\n+ let resolved = definition\n+ .resolve_env(\n+ env_lookup(&[(\"HOOK_MODEL\", \"haiku\")]),\n+ secret_lookup(&[(\"POLICY\", \"no-force-push\")]),\n+ )\n+ .unwrap();\n+\n+ assert_eq!(\n+ resolved.hook_type,\n+ Some(RuntimeHookType::Prompt {\n+ prompt: \"check no-force-push\".to_string(),\n+ model: Some(\"haiku\".to_string()),\n+ })\n+ );\n+ }\n+\n+ #[test]\n+ fn definition_without_hook_type_resolves_to_none() {\n+ let definition = HookDefinition {\n+ name: None,\n+ event: HookEvent::StageStart,\n+ command: None,\n+ hook_type: None,\n+ matcher: None,\n+ blocking: None,\n+ timeout_ms: None,\n+ sandbox: None,\n+ };\n+\n+ let resolved = definition\n+ .resolve_env(env_lookup(&[]), secret_lookup(&[]))\n+ .unwrap();\n+\n+ assert_eq!(resolved.hook_type, None);\n+ assert_eq!(resolved.effective_name, \"stage_start\");\n+ }\n+\n+ #[test]\n+ fn runtime_defaults_match_config_semantics() {\n+ let resolved = hook(HookType::Prompt {\n+ prompt: InterpString::parse(\"ok?\"),\n+ model: None,\n+ })\n+ .resolve_env(env_lookup(&[]), secret_lookup(&[]))\n+ .unwrap();\n+\n+ // RunStart is blocking by default; prompt hooks default to 30s.\n+ assert!(resolved.is_blocking());\n+ assert_eq!(resolved.timeout(), std::time::Duration::from_secs(30));\n+ assert!(!resolved.runs_in_sandbox());\n+ }\n }\n \n #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]\ndiff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs\nindex 7fa47e938..bd9b46fba 100644\n--- a/lib/crates/fabro-workflow/src/operations/start.rs\n+++ b/lib/crates/fabro-workflow/src/operations/start.rs\n@@ -16,9 +16,10 @@ use fabro_sandbox::from_environment::{\n use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};\n use fabro_static::EnvVars;\n use fabro_types::settings::run::{\n- ApprovalMode, McpServerSettings as ResolvedMcpServerSettings, PullRequestSettings,\n- ResolvedMcpEntry, RunMode, RunModelSettings as ResolvedRunModelSettings,\n+ ApprovalMode, HookDefinition, McpServerSettings as ResolvedMcpServerSettings,\n+ PullRequestSettings, ResolvedMcpEntry, RunMode, RunModelSettings as ResolvedRunModelSettings,\n RunNamespace as ResolvedRunSettings, RunPrepareSettings as ResolvedRunPrepareSettings,\n+ RuntimeHookDefinition,\n };\n use fabro_types::settings::{ModelRegistry, ResolvedModelRef};\n use fabro_types::{ManifestPath, RunId, RunRunnableSource, SandboxProviderKind};\n@@ -463,6 +464,7 @@ impl RunSession {\n let pr_config = resolved.pull_request.clone();\n let setup_commands =\n runtime_setup_commands(&resolved.prepare, process_env_var, secret_lookup)?;\n+ let hooks = runtime_hooks(&resolved.hooks, process_env_var, secret_lookup)?;\n drop(vault_guard);\n \n Ok(Self {\n@@ -486,9 +488,7 @@ impl RunSession {\n setup_commands,\n setup_command_timeout_ms: resolved.prepare.timeout_ms,\n },\n- hooks: fabro_hooks::HookSettings {\n- hooks: resolved.hooks.clone(),\n- },\n+ hooks: fabro_hooks::HookSettings { hooks },\n sandbox_env,\n seed_context: None,\n run_store: services.run_store,\n@@ -764,6 +764,39 @@ fn runtime_setup_commands(\n .collect())\n }\n \n+/// Build the launch-time hook definitions from resolved settings, resolving\n+/// any `{{ env.* }}` and `{{ secrets.* }}` tokens in each hook's\n+/// `command`/`url`/`headers`/`prompt`/`model` against the worker process\n+/// environment and vault — the run boundary, so the hook executor only ever\n+/// sees fully resolved strings and fire time involves no resolution at all.\n+///\n+/// The resolution itself lives on the type ([`HookDefinition::resolve_env`])\n+/// together with the HTTP-header policy (secret tokens are rejected in\n+/// headers; header env reads stay scoped to `allowed_env_vars`); this wrapper\n+/// just adds the hook's name to the error. Hook strings are carried in source\n+/// form out of the config resolve layer so `fabro validate` stays portable\n+/// (it never requires env to be set), and a referenced env var or secret that\n+/// is unset is a hard error that fails the run at startup — even for hooks\n+/// that would never have fired.\n+fn runtime_hooks(\n+ hooks: &[HookDefinition],\n+ mut env_lookup: impl FnMut(&str) -> Option<String>,\n+ mut secrets_lookup: impl FnMut(&str) -> Option<String>,\n+) -> Result<Vec<RuntimeHookDefinition>, Error> {\n+ hooks\n+ .iter()\n+ .map(|hook| {\n+ hook.resolve_env(&mut env_lookup, &mut secrets_lookup)\n+ .map_err(|err| {\n+ Error::engine_with_source(\n+ format!(\"failed to resolve hook {:?}\", hook.effective_name()),\n+ err,\n+ )\n+ })\n+ })\n+ .collect()\n+}\n+\n impl RunSession {\n /// Shared engine: initialize, execute, finalize, pull_request.\n async fn run(\n@@ -1125,8 +1158,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+ HookEvent, HookType, McpTransport as ResolvedMcpTransport, PreparedStep, PreparedStepRun,\n+ RunMode, RunPrepareSettings, RuntimeHookType,\n };\n use fabro_types::settings::{InterpString, ModelRef};\n use fabro_types::{\n@@ -1489,6 +1522,85 @@ reasoning = false\n assert!(err.causes()[0].contains(\"GITHUB_APP_PRIVATE_KEY\"));\n }\n \n+ fn command_hook(name: &str, command: &str) -> HookDefinition {\n+ HookDefinition {\n+ name: Some(name.to_string()),\n+ event: HookEvent::RunStart,\n+ command: Some(InterpString::parse(command)),\n+ hook_type: None,\n+ matcher: None,\n+ blocking: None,\n+ timeout_ms: None,\n+ sandbox: Some(false),\n+ }\n+ }\n+\n+ #[test]\n+ fn runtime_hooks_resolve_secret_tokens_from_vault() {\n+ let vault = token_vault(\"HOOK_TOKEN\", \"vault-token\");\n+ let hooks = [command_hook(\"guard\", \"deploy {{ secrets.HOOK_TOKEN }}\")];\n+\n+ let resolved = runtime_hooks(&hooks, |_| None, vault_secret_lookup(&vault)).unwrap();\n+\n+ assert_eq!(\n+ resolved[0].hook_type,\n+ Some(RuntimeHookType::Command {\n+ command: \"deploy vault-token\".to_string(),\n+ })\n+ );\n+ }\n+\n+ // Fail timing note: a missing hook secret used to surface as a fire-time\n+ // Block; boundary resolution turns it into a startup failure, including\n+ // for hooks that would never have fired.\n+ #[test]\n+ fn runtime_hooks_missing_secret_fails_naming_hook_and_secret() {\n+ let vault = temp_vault(&[]);\n+ let hooks = [command_hook(\"guard\", \"deploy {{ secrets.HOOK_TOKEN }}\")];\n+\n+ let err = runtime_hooks(&hooks, |_| None, vault_secret_lookup(&vault)).unwrap_err();\n+\n+ assert_eq!(\n+ err.to_string(),\n+ \"Engine error: failed to resolve hook \\\"guard\\\"\"\n+ );\n+ assert!(err.causes()[0].contains(\"HOOK_TOKEN\"));\n+ }\n+\n+ #[test]\n+ fn runtime_hooks_reject_secret_in_http_header_with_guidance() {\n+ let vault = token_vault(\"HOOK_TOKEN\", \"vault-token\");\n+ let hooks = [HookDefinition {\n+ name: Some(\"notify\".to_string()),\n+ event: HookEvent::RunComplete,\n+ command: None,\n+ hook_type: Some(HookType::Http {\n+ url: InterpString::parse(\"https://hooks.example.com\"),\n+ headers: Some(HashMap::from([(\n+ \"Authorization\".to_string(),\n+ InterpString::parse(\"Bearer {{ secrets.HOOK_TOKEN }}\"),\n+ )])),\n+ allowed_env_vars: Vec::new(),\n+ tls: fabro_types::settings::run::TlsMode::Verify,\n+ }),\n+ matcher: None,\n+ blocking: None,\n+ timeout_ms: None,\n+ sandbox: None,\n+ }];\n+\n+ let err = runtime_hooks(&hooks, |_| None, vault_secret_lookup(&vault)).unwrap_err();\n+\n+ assert_eq!(\n+ err.to_string(),\n+ \"Engine error: failed to resolve hook \\\"notify\\\"\"\n+ );\n+ assert!(err.causes()[0].contains(\n+ \"not allowed in HTTP hook headers; use secret interpolation in a hook command, \\\n+ prompt, or url instead\"\n+ ));\n+ }\n+\n #[tokio::test]\n async fn run_session_new_resolves_secret_tokens_from_vault_at_boundary() {\n let temp = tempfile::tempdir().unwrap();\n@@ -1525,6 +1637,7 @@ reasoning = false\n ..ResolvedMcpServerSettings::default()\n }),\n );\n+ settings.run.hooks = vec![command_hook(\"guard\", \"deploy {{ secrets.DEPLOY_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@@ -1546,6 +1659,12 @@ reasoning = false\n .map(String::as_str),\n Some(\"vault-token\")\n );\n+ assert_eq!(\n+ session.hooks.hooks[0].hook_type,\n+ Some(RuntimeHookType::Command {\n+ command: \"deploy vault-token\".to_string(),\n+ })\n+ );\n assert_eq!(\n session.lifecycle.setup_commands[0]\n .env\n@@ -1605,6 +1724,40 @@ reasoning = false\n assert!(err.causes()[0].contains(\"DEPLOY_TOKEN\"));\n }\n \n+ #[tokio::test]\n+ async fn run_session_new_missing_hook_secret_fails_startup() {\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 = vec![command_hook(\"guard\", \"deploy {{ 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) = RunSession::new(&persisted, 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 run startup\");\n+ };\n+\n+ assert_eq!(\n+ err.to_string(),\n+ \"Engine error: failed to resolve hook \\\"guard\\\"\"\n+ );\n+ assert!(err.causes()[0].contains(\"HOOK_TOKEN\"));\n+ }\n+\n #[test]\n fn runtime_docker_config_maps_environment_hints() {\n let settings = settings_from_run_layer(RunLayer {\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\nindex 54a6f1f45..d6c40f7f4 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n@@ -283,11 +283,13 @@ pub async fn initialize(\n let sandbox_git = Arc::new(SandboxGitRuntime::new());\n let metadata_runtime = Arc::new(RunMetadataRuntime::new());\n \n+ // Move (not clone) the hooks into the runner: boundary-resolved hooks\n+ // carry resolved secret values, so keep a single copy alive for the run.\n let hook_runner = if options.hooks.hooks.is_empty() {\n None\n } else {\n Some(Arc::new(HookRunner::new(\n- options.hooks.clone(),\n+ std::mem::take(&mut options.hooks),\n Arc::clone(&llm_source),\n Arc::clone(&catalog),\n )))\ndiff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs\nindex 3ceeced5d..6de6238af 100644\n--- a/lib/crates/fabro-workflow/tests/it/integration.rs\n+++ b/lib/crates/fabro-workflow/tests/it/integration.rs\n@@ -8174,7 +8174,9 @@ fn subgraph_without_label_no_class_derived() {\n // Hook System E2E Tests\n // ---------------------------------------------------------------------------\n \n-fn hook_runner_from_defs(hooks: Vec<fabro_hooks::HookDefinition>) -> Arc<fabro_hooks::HookRunner> {\n+fn hook_runner_from_defs(\n+ hooks: Vec<fabro_hooks::RuntimeHookDefinition>,\n+) -> Arc<fabro_hooks::HookRunner> {\n Arc::new(fabro_hooks::HookRunner::new(\n fabro_hooks::HookSettings { hooks },\n Arc::new(fabro_auth::EnvCredentialSource::new()),\n@@ -8227,7 +8229,7 @@ fn emitter_with_events() -> (Arc<Emitter>, Arc<std::sync::Mutex<Vec<RunEvent>>>)\n (Arc::new(emitter), events)\n }\n \n-fn engine_with_hooks(hooks: Vec<fabro_hooks::HookDefinition>) -> HookTestRunner {\n+fn engine_with_hooks(hooks: Vec<fabro_hooks::RuntimeHookDefinition>) -> HookTestRunner {\n HookTestRunner {\n emitter: Arc::new(Emitter::default()),\n hook_runner: hook_runner_from_defs(hooks),\n@@ -8235,7 +8237,7 @@ fn engine_with_hooks(hooks: Vec<fabro_hooks::HookDefinition>) -> HookTestRunner\n }\n \n fn engine_with_hooks_and_events(\n- hooks: Vec<fabro_hooks::HookDefinition>,\n+ hooks: Vec<fabro_hooks::RuntimeHookDefinition>,\n ) -> (HookTestRunner, Arc<std::sync::Mutex<Vec<RunEvent>>>) {\n let (emitter, events) = emitter_with_events();\n (\n@@ -8264,16 +8266,18 @@ fn make_run_options(dir: &std::path::Path) -> RunOptions {\n }\n }\n \n-fn make_hook(event: fabro_hooks::HookEvent, command: &str) -> fabro_hooks::HookDefinition {\n- fabro_hooks::HookDefinition {\n+fn make_hook(event: fabro_hooks::HookEvent, command: &str) -> fabro_hooks::RuntimeHookDefinition {\n+ fabro_hooks::RuntimeHookDefinition {\n name: None,\n event,\n- command: Some(command.into()),\n- hook_type: None,\n+ hook_type: Some(fabro_hooks::RuntimeHookType::Command {\n+ command: command.to_string(),\n+ }),\n matcher: None,\n blocking: None,\n timeout_ms: Some(5000),\n sandbox: Some(false), // run on host for test reliability\n+ effective_name: format!(\"{event}:{command}\"),\n }\n }\n \n@@ -8837,97 +8841,12 @@ async fn hook_stage_start_exit_2_blocks() {\n assert!(result.is_err(), \"exit 2 should block\");\n }\n \n-// --- Config merge tests (server + run) ---\n-\n-#[tokio::test]\n-async fn hook_config_merge_concatenates() {\n- use fabro_hooks::{HookDefinition, HookEvent, HookSettings};\n-\n- let server_hooks = HookSettings {\n- hooks: vec![HookDefinition {\n- name: Some(\"server-hook\".into()),\n- event: HookEvent::RunStart,\n- command: Some(\"exit 0\".into()),\n- hook_type: None,\n- matcher: None,\n- blocking: None,\n- timeout_ms: None,\n- sandbox: Some(false),\n- }],\n- };\n- let run_hooks = HookSettings {\n- hooks: vec![HookDefinition {\n- name: Some(\"run-hook\".into()),\n- event: HookEvent::StageComplete,\n- command: Some(\"exit 0\".into()),\n- hook_type: None,\n- matcher: None,\n- blocking: None,\n- timeout_ms: None,\n- sandbox: Some(false),\n- }],\n- };\n-\n- let merged = server_hooks.merge(run_hooks);\n- assert_eq!(merged.hooks.len(), 2);\n- assert_eq!(merged.hooks[0].name.as_deref(), Some(\"server-hook\"));\n- assert_eq!(merged.hooks[1].name.as_deref(), Some(\"run-hook\"));\n-}\n-\n-#[tokio::test]\n-async fn hook_config_merge_run_overrides_by_name() {\n- use fabro_hooks::{HookDefinition, HookEvent, HookSettings};\n-\n- let server_hooks = HookSettings {\n- hooks: vec![HookDefinition {\n- name: Some(\"shared\".into()),\n- event: HookEvent::RunStart,\n- command: Some(\"exit 1\".into()), // would block\n- hook_type: None,\n- matcher: None,\n- blocking: None,\n- timeout_ms: None,\n- sandbox: Some(false),\n- }],\n- };\n- let run_hooks = HookSettings {\n- hooks: vec![HookDefinition {\n- name: Some(\"shared\".into()),\n- event: HookEvent::RunStart,\n- command: Some(\"exit 0\".into()), // allows\n- hook_type: None,\n- matcher: None,\n- blocking: None,\n- timeout_ms: None,\n- sandbox: Some(false),\n- }],\n- };\n-\n- let merged = server_hooks.merge(run_hooks);\n- assert_eq!(merged.hooks.len(), 1);\n- // Run config wins — command should be \"exit 0\"\n- assert_eq!(\n- merged.hooks[0]\n- .command\n- .as_ref()\n- .map(fabro_hooks::InterpString::as_source),\n- Some(\"exit 0\".to_string())\n- );\n-\n- // Verify it actually works end-to-end\n- let engine = engine_with_hooks(merged.hooks);\n- let graph = parse(simple_linear_dot()).unwrap();\n- let dir = tempfile::tempdir().unwrap();\n- let run_options = make_run_options(dir.path());\n-\n- let outcome = engine.run(&graph, &run_options).await.unwrap();\n- assert_eq!(outcome.status, StageOutcome::Succeeded);\n-}\n-\n // The legacy `Settings`-based TOML parsing tests were deleted in Stage\n // 6.3b. Hook TOML parsing now flows through the v2 config parser path,\n // with coverage in fabro-config unit tests and the fabro-cli integration\n-// tests under `cmd::config`.\n+// tests under `cmd::config`. Server/run hook config merging likewise lives\n+// in the config layer resolution; the resolved list arrives here already\n+// merged, so the deleted `HookSettings::merge` had no production callers.\n \n // --- Blocking vs non-blocking behavior ---\n \n@@ -9122,6 +9041,233 @@ async fn hooks_do_not_duplicate_workflow_events() {\n assert_eq!(run_failed, 0, \"Should have 0 WorkflowRunFailed\");\n }\n \n+// --- Boundary-resolved interpolation (env + vault secrets) ---\n+//\n+// Hook InterpStrings resolve once, at the run boundary\n+// (`operations::start::runtime_hooks`); these tests drive the same resolver\n+// (`HookDefinition::resolve_env`) against a hermetic temp-dir vault and then\n+// run the resolved hooks through the engine, proving the executor fires\n+// correctly on fully resolved strings.\n+\n+fn boundary_resolved_hooks(\n+ hooks: &[fabro_types::settings::run::HookDefinition],\n+ env: &'static [(&'static str, &'static str)],\n+ vault: &fabro_vault::Vault,\n+) -> Vec<fabro_hooks::RuntimeHookDefinition> {\n+ hooks\n+ .iter()\n+ .map(|hook| {\n+ hook.resolve_env(\n+ |name| {\n+ env.iter()\n+ .find_map(|(key, value)| (*key == name).then(|| (*value).to_string()))\n+ },\n+ |name| fabro_auth::vault_get_token(vault, name).ok().flatten(),\n+ )\n+ .expect(\"hook should resolve at the run boundary\")\n+ })\n+ .collect()\n+}\n+\n+fn config_command_hook(\n+ event: fabro_hooks::HookEvent,\n+ command: &str,\n+ sandbox: bool,\n+) -> fabro_types::settings::run::HookDefinition {\n+ fabro_types::settings::run::HookDefinition {\n+ name: None,\n+ event,\n+ command: Some(fabro_types::settings::InterpString::parse(command)),\n+ hook_type: None,\n+ matcher: None,\n+ blocking: None,\n+ timeout_ms: Some(5000),\n+ sandbox: Some(sandbox),\n+ }\n+}\n+\n+fn hook_test_vault(dir: &std::path::Path, entries: &[(&str, &str)]) -> fabro_vault::Vault {\n+ let mut vault = fabro_vault::Vault::load(dir.join(\"secrets.json\"))\n+ .expect(\"temp-dir vault should load for hook tests\");\n+ for (name, value) in entries {\n+ fabro_auth::vault_set_token(&mut vault, name, value)\n+ .expect(\"test secret should store in temp vault\");\n+ }\n+ vault\n+}\n+\n+#[tokio::test]\n+async fn hook_command_secret_resolves_from_vault_and_proceeds() {\n+ let dir = tempfile::tempdir().unwrap();\n+ let vault = hook_test_vault(dir.path(), &[(\"HOOK_TOKEN\", \"hook-secret-value\")]);\n+ let hooks = boundary_resolved_hooks(\n+ &[config_command_hook(\n+ fabro_hooks::HookEvent::RunStart,\n+ r#\"test \"{{ secrets.HOOK_TOKEN }}\" = \"hook-secret-value\"\"#,\n+ false,\n+ )],\n+ &[],\n+ &vault,\n+ );\n+\n+ let engine = engine_with_hooks(hooks);\n+ let graph = parse(simple_linear_dot()).unwrap();\n+ let run_options = make_run_options(dir.path());\n+\n+ let outcome = engine.run(&graph, &run_options).await.unwrap();\n+ assert_eq!(outcome.status, StageOutcome::Succeeded);\n+}\n+\n+#[tokio::test]\n+async fn hook_http_secret_url_resolves_from_vault_and_fires() {\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 dir = tempfile::tempdir().unwrap();\n+ let vault = hook_test_vault(dir.path(), &[(\"HOOK_URL\", &server.url(\"/hook\"))]);\n+ let hooks = boundary_resolved_hooks(\n+ &[fabro_types::settings::run::HookDefinition {\n+ name: Some(\"notify\".into()),\n+ event: fabro_hooks::HookEvent::RunStart,\n+ command: None,\n+ hook_type: Some(fabro_types::settings::run::HookType::Http {\n+ url: fabro_types::settings::InterpString::parse(\n+ \"{{ secrets.HOOK_URL }}\",\n+ ),\n+ headers: None,\n+ allowed_env_vars: Vec::new(),\n+ tls: fabro_hooks::TlsMode::Off,\n+ }),\n+ matcher: None,\n+ blocking: None,\n+ timeout_ms: Some(5000),\n+ sandbox: Some(false),\n+ }],\n+ &[],\n+ &vault,\n+ );\n+\n+ let engine = engine_with_hooks(hooks);\n+ let graph = parse(simple_linear_dot()).unwrap();\n+ let run_options = make_run_options(dir.path());\n+\n+ let outcome = engine.run(&graph, &run_options).await.unwrap();\n+ assert_eq!(outcome.status, StageOutcome::Succeeded);\n+ mock.assert_async().await;\n+}\n+\n+// Hook secrets get the standard content-based redaction coverage: a block\n+// reason that echoes a resolved credential-shaped secret value is redacted\n+// where events are serialized into the run store. Low-entropy values are out\n+// of coverage by design, so this asserts only on a credential-shaped marker.\n+#[tokio::test]\n+async fn hook_block_reason_echoing_secret_is_redacted_in_stored_events() {\n+ // Same distinctive credential-shaped test marker as the fabro-redact and\n+ // event-redaction tests; never a real credential.\n+ const CREDENTIAL_SHAPED_SECRET: &str = \"sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA\";\n+\n+ let dir = tempfile::tempdir().unwrap();\n+ let vault = hook_test_vault(dir.path(), &[(\"HOOK_TOKEN\", CREDENTIAL_SHAPED_SECRET)]);\n+ let hooks = boundary_resolved_hooks(\n+ &[config_command_hook(\n+ fabro_hooks::HookEvent::RunStart,\n+ r#\"echo '{\"decision\": \"block\", \"reason\": \"denied by {{ secrets.HOOK_TOKEN }}\"}'\"#,\n+ false,\n+ )],\n+ &[],\n+ &vault,\n+ );\n+\n+ let engine = engine_with_hooks(hooks);\n+ let graph = parse(simple_linear_dot()).unwrap();\n+ let run_options = make_run_options(dir.path());\n+\n+ let result = engine.run(&graph, &run_options).await;\n+ assert!(result.is_err(), \"blocking hook should fail the run\");\n+\n+ // Reopen the run store and inspect the stored (redacted) event payloads.\n+ let store_dir = test_store_dir(&run_options.run_dir);\n+ let store = Database::new(\n+ Arc::new(LocalFileSystem::new_with_prefix(&store_dir).unwrap()),\n+ \"\",\n+ Duration::from_millis(1),\n+ None,\n+ );\n+ let run_store = store.open_run_reader(&run_options.run_id).await.unwrap();\n+ let events = run_store.list_events().await.unwrap();\n+ let stored_text = events\n+ .iter()\n+ .map(|event| event.event.to_value().unwrap().to_string())\n+ .collect::<Vec<_>>()\n+ .join(\"\\n\");\n+\n+ assert!(\n+ stored_text.contains(\"denied by\"),\n+ \"block reason should reach stored events: {stored_text}\"\n+ );\n+ assert!(\n+ !stored_text.contains(CREDENTIAL_SHAPED_SECRET),\n+ \"credential-shaped secret must not appear in stored events\"\n+ );\n+ assert!(\n+ stored_text.contains(\"REDACTED\"),\n+ \"redaction marker should replace the secret value: {stored_text}\"\n+ );\n+}\n+\n+// Env-only hooks keep working through boundary resolution, on both dispatch\n+// paths: host-side (`sandbox = false`) and sandbox-side (`sandbox = true`).\n+#[tokio::test]\n+async fn hook_env_tokens_resolve_at_boundary_for_host_and_sandbox_dispatch() {\n+ let dir = tempfile::tempdir().unwrap();\n+ let vault = hook_test_vault(dir.path(), &[]);\n+ let host_marker = dir.path().join(\"host_marker.txt\");\n+ let sandbox_marker = dir.path().join(\"sandbox_marker.txt\");\n+ let hooks = boundary_resolved_hooks(\n+ &[\n+ config_command_hook(\n+ fabro_hooks::HookEvent::RunStart,\n+ &format!(\n+ r#\"printf %s \"{{{{ env.HOOK_MARKER }}}}\" > {}\"#,\n+ host_marker.display()\n+ ),\n+ false,\n+ ),\n+ config_command_hook(\n+ fabro_hooks::HookEvent::RunStart,\n+ &format!(\n+ r#\"printf %s \"{{{{ env.HOOK_MARKER }}}}\" > {}\"#,\n+ sandbox_marker.display()\n+ ),\n+ true,\n+ ),\n+ ],\n+ &[(\"HOOK_MARKER\", \"marker-value\")],\n+ &vault,\n+ );\n+\n+ let engine = engine_with_hooks(hooks);\n+ let graph = parse(simple_linear_dot()).unwrap();\n+ let run_options = make_run_options(dir.path());\n+\n+ let outcome = engine.run(&graph, &run_options).await.unwrap();\n+ assert_eq!(outcome.status, StageOutcome::Succeeded);\n+\n+ assert_eq!(\n+ std::fs::read_to_string(&host_marker).unwrap(),\n+ \"marker-value\"\n+ );\n+ assert_eq!(\n+ std::fs::read_to_string(&sandbox_marker).unwrap(),\n+ \"marker-value\"\n+ );\n+}\n+\n // ---------------------------------------------------------------------------\n // Fidelity preamble injection: verify prompt.md contains preamble + prompt\n // for each fidelity mode, using script → codergen pipeline with no live LLM.\n",
|
|
"summary": {
|
|
"files_changed": 16,
|
|
"additions": 1226,
|
|
"deletions": 961
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"seq": 1185,
|
|
"checkpoint": {
|
|
"timestamp": "2026-07-11T21:49:12.705304218Z",
|
|
"current_node": "simplify_gpt",
|
|
"completed_nodes": [
|
|
"start",
|
|
"toolchain",
|
|
"preflight_compile",
|
|
"preflight_lint",
|
|
"implement",
|
|
"simplify_fable",
|
|
"simplify_gpt"
|
|
],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ",
|
|
"internal.fidelity": "compact",
|
|
"internal.retry_count.implement": 0,
|
|
"internal.retry_count.simplify_gpt": 0,
|
|
"internal.thread_id": "simplify_fable",
|
|
"thread.simplify_fable.current_node": "simplify_gpt",
|
|
"internal.run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"internal.work_dir": "/home/daytona/workspace/fabro",
|
|
"outcome": "failed",
|
|
"thread.implement.current_node": "simplify_fable",
|
|
"current_node": "simplify_gpt",
|
|
"graph.goal": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n",
|
|
"internal.retry_count.toolchain": 0,
|
|
"failure_signature": "simplify_gpt|deterministic|api_deterministic|openai|authentication",
|
|
"thread.toolchain.current_node": "preflight_compile",
|
|
"thread.preflight_compile.current_node": "preflight_lint",
|
|
"failure_class": "deterministic",
|
|
"internal.retry_count.start": 0,
|
|
"thread.preflight_lint.current_node": "implement",
|
|
"internal.retry_count.preflight_lint": 0,
|
|
"internal.retry_count.preflight_compile": 0,
|
|
"last_response": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-ag",
|
|
"response.simplify_fable": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-agent review pass over my own diff.\n\n## What was implemented\n\n**Boundary resolution (the PR's core):**\n- `fabro-types/src/settings/run.rs`: new `RuntimeHookDefinition`, `RuntimeHookType`, `RuntimeHttpHook`, and `HookResolveError`, plus `HookDefinition::resolve_env(env_lookup, secrets_lookup)` — resolves `command`/`url`/headers/`prompt`/`model` once, with the header policy enforced before any lookup (secrets in headers rejected with the guidance message; header env gated by `allowed_env_vars`). Http hooks carry the unresolved `url_source` for safe logging. Wire/config types are untouched.\n- `fabro-workflow/operations/start.rs`: `runtime_hooks(...)` next to `runtime_mcp_server`/`runtime_setup_commands`, wired into `RunSession::new` through the shared `process_env_var`/`secret_lookup` closures; any resolution error fails the run at startup naming the hook.\n- `fabro-hooks`: executor/runner/bridge now consume resolved strings only — deleted `resolve_interp`, `resolve_header`, `HeaderResolveError`, `resolve_prompt_and_model`, the `Env` type params, and the crate's `fabro-util` dep. Matchers, decision merging, `FABRO_HOOK_CONTEXT`, timeouts, and TLS enforcement are untouched. Also deleted the production-dead `HookSettings::merge` (config layering owns merge semantics).\n\n**Tests:** resolution tests moved to fabro-types (13 new) and start.rs boundary tests (secret resolves / missing secret fails startup / header-secret guidance, incl. a panicking-lookup test proving rejection happens before any vault read); new worker-level tests with hermetic temp-dir vaults: command-hook secret proceeds, http hook with secret URL fires (httpmock asserts), blocking hook echoing a credential-shaped secret gets **redacted in stored events** by the existing content-based pass, and env-only hooks work on both host and sandbox dispatch.\n\n**Docs:** `hooks.mdx` gained an Interpolation section (secrets in command/url/prompt, header exclusion, startup-fail semantics); the OpenAPI `headers` description was updated (description-only, no wire change).\n\n## Review-agent findings fixed\n- **Quality:** swappable `url`/`url_source` `&str` params → grouped into `RuntimeHttpHook`; stale fire-time wording in `fabro-config/resolve/run.rs` and `fabro-api.yaml`; missing allowlist-violation outcome in hooks.mdx; `Debug`-leak warning added to runtime type docs.\n- **Reuse:** extracted shared `resolve_env_secrets` (now backing both `resolve_env_string` and hook resolution); test vault seeding reuses `fabro_auth::vault_set_token`.\n- **Efficiency:** `initialize.rs` now **moves** the resolved hooks into `HookRunner` instead of deep-cloning them, so a single copy of resolved secrets lives per run. (Reviewers also confirmed the per-fire path got strictly lighter.)\n- **Skipped (noted deliberately):** pre-redacting `url_source` at the boundary (plan mandates carrying the unresolved source; would add a fabro-redact dep to fabro-types), exporting `vault_token_lookup` just for a test one-liner, and rebuilding all test fixtures through `resolve_env`.\n\n## Verification\n`cargo +nightly-2026-04-14 fmt --check --all` ✓ · `clippy --workspace --all-targets -D warnings` ✓ · `cargo nextest run --workspace` → **6964 passed** ✓ · `cargo dev docs check` ✓\n\nFor the PR description, the two intended behavior changes: **fail timing moves to startup** (missing hook env/secret fails the run immediately, even for hooks that never fire) and **eager resolution** (hook secrets are resolved and held in worker memory for the run, like every other boundary-resolved secret).",
|
|
"last_stage": "simplify_fable",
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
|
|
"graph.rankdir": "LR",
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.simplify_fable": 0,
|
|
"thread.start.current_node": "toolchain"
|
|
},
|
|
"node_outcomes": {
|
|
"simplify_fable": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"last_response": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-ag",
|
|
"response.simplify_fable": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-agent review pass over my own diff.\n\n## What was implemented\n\n**Boundary resolution (the PR's core):**\n- `fabro-types/src/settings/run.rs`: new `RuntimeHookDefinition`, `RuntimeHookType`, `RuntimeHttpHook`, and `HookResolveError`, plus `HookDefinition::resolve_env(env_lookup, secrets_lookup)` — resolves `command`/`url`/headers/`prompt`/`model` once, with the header policy enforced before any lookup (secrets in headers rejected with the guidance message; header env gated by `allowed_env_vars`). Http hooks carry the unresolved `url_source` for safe logging. Wire/config types are untouched.\n- `fabro-workflow/operations/start.rs`: `runtime_hooks(...)` next to `runtime_mcp_server`/`runtime_setup_commands`, wired into `RunSession::new` through the shared `process_env_var`/`secret_lookup` closures; any resolution error fails the run at startup naming the hook.\n- `fabro-hooks`: executor/runner/bridge now consume resolved strings only — deleted `resolve_interp`, `resolve_header`, `HeaderResolveError`, `resolve_prompt_and_model`, the `Env` type params, and the crate's `fabro-util` dep. Matchers, decision merging, `FABRO_HOOK_CONTEXT`, timeouts, and TLS enforcement are untouched. Also deleted the production-dead `HookSettings::merge` (config layering owns merge semantics).\n\n**Tests:** resolution tests moved to fabro-types (13 new) and start.rs boundary tests (secret resolves / missing secret fails startup / header-secret guidance, incl. a panicking-lookup test proving rejection happens before any vault read); new worker-level tests with hermetic temp-dir vaults: command-hook secret proceeds, http hook with secret URL fires (httpmock asserts), blocking hook echoing a credential-shaped secret gets **redacted in stored events** by the existing content-based pass, and env-only hooks work on both host and sandbox dispatch.\n\n**Docs:** `hooks.mdx` gained an Interpolation section (secrets in command/url/prompt, header exclusion, startup-fail semantics); the OpenAPI `headers` description was updated (description-only, no wire change).\n\n## Review-agent findings fixed\n- **Quality:** swappable `url`/`url_source` `&str` params → grouped into `RuntimeHttpHook`; stale fire-time wording in `fabro-config/resolve/run.rs` and `fabro-api.yaml`; missing allowlist-violation outcome in hooks.mdx; `Debug`-leak warning added to runtime type docs.\n- **Reuse:** extracted shared `resolve_env_secrets` (now backing both `resolve_env_string` and hook resolution); test vault seeding reuses `fabro_auth::vault_set_token`.\n- **Efficiency:** `initialize.rs` now **moves** the resolved hooks into `HookRunner` instead of deep-cloning them, so a single copy of resolved secrets lives per run. (Reviewers also confirmed the per-fire path got strictly lighter.)\n- **Skipped (noted deliberately):** pre-redacting `url_source` at the boundary (plan mandates carrying the unresolved source; would add a fabro-redact dep to fabro-types), exporting `vault_token_lookup` just for a test one-liner, and rebuilding all test fixtures through `resolve_env`.\n\n## Verification\n`cargo +nightly-2026-04-14 fmt --check --all` ✓ · `clippy --workspace --all-targets -D warnings` ✓ · `cargo nextest run --workspace` → **6964 passed** ✓ · `cargo dev docs check` ✓\n\nFor the PR description, the two intended behavior changes: **fail timing moves to startup** (missing hook env/secret fails the run immediately, even for hooks that never fire) and **eager resolution** (hook secrets are resolved and held in worker memory for the run, like every other boundary-resolved secret).",
|
|
"last_stage": "simplify_fable"
|
|
},
|
|
"notes": "Stage completed: simplify_fable",
|
|
"usage": {
|
|
"input": {
|
|
"usage": {
|
|
"model": {
|
|
"provider": "anthropic",
|
|
"model_id": "claude-fable-5"
|
|
},
|
|
"tokens": {
|
|
"input_tokens": 374846,
|
|
"output_tokens": 148952,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 46751554,
|
|
"cache_write_tokens": 5095953
|
|
}
|
|
},
|
|
"facts": {
|
|
"algorithm": "anthropic",
|
|
"cache_write_5m_tokens": 5095953,
|
|
"cache_write_1h_tokens": 0
|
|
}
|
|
},
|
|
"total_usd_micros": 121647026
|
|
},
|
|
"files_touched": [
|
|
"/home/daytona/workspace/fabro/docs/public/agents/hooks.mdx",
|
|
"/home/daytona/workspace/fabro/docs/public/api-reference/fabro-api.yaml",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-config/src/resolve/run.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/Cargo.toml",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/bridge.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/config.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/executor.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/lib.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/runner.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/types.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/tests/host_command_hooks.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-types/src/settings/run.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/operations/start.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/pipeline/initialize.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/integration.rs"
|
|
],
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 2358244,
|
|
"tool_time_ms": 1436685,
|
|
"active_time_ms": 3794929
|
|
}
|
|
},
|
|
"toolchain": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1"
|
|
},
|
|
"notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 1299,
|
|
"active_time_ms": 1299
|
|
}
|
|
},
|
|
"preflight_lint": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 111530,
|
|
"active_time_ms": 111530
|
|
}
|
|
},
|
|
"implement": {
|
|
"status": "failed",
|
|
"failure": {
|
|
"message": "LLM error: Authentication error for openai: Your authentication token has been invalidated. Please try signing in again.",
|
|
"category": "deterministic",
|
|
"signature": "api_deterministic|openai|authentication"
|
|
},
|
|
"usage": null
|
|
},
|
|
"simplify_gpt": {
|
|
"status": "failed",
|
|
"failure": {
|
|
"message": "LLM error: Authentication error for openai: Your authentication token has been invalidated. Please try signing in again.",
|
|
"category": "deterministic",
|
|
"signature": "api_deterministic|openai|authentication"
|
|
},
|
|
"usage": null
|
|
},
|
|
"start": {
|
|
"status": "succeeded",
|
|
"usage": null
|
|
},
|
|
"preflight_compile": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo check -q --workspace 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 105518,
|
|
"active_time_ms": 105518
|
|
}
|
|
}
|
|
},
|
|
"next_node_id": "verify",
|
|
"git_commit_sha": "e577d465bb21d1b933300828890a85cec38cfd65",
|
|
"loop_failure_signatures": {
|
|
"implement|deterministic|api_deterministic|openai|authentication": 1,
|
|
"simplify_gpt|deterministic|api_deterministic|openai|authentication": 1
|
|
},
|
|
"node_visits": {
|
|
"start": 1,
|
|
"simplify_fable": 1,
|
|
"preflight_lint": 1,
|
|
"simplify_gpt": 1,
|
|
"toolchain": 1,
|
|
"implement": 1,
|
|
"preflight_compile": 1
|
|
}
|
|
},
|
|
"diff": {
|
|
"summary": {
|
|
"files_changed": 16,
|
|
"additions": 1226,
|
|
"deletions": 961
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"seq": 0,
|
|
"checkpoint": {
|
|
"timestamp": "2026-07-11T21:55:00.094096153Z",
|
|
"current_node": "verify",
|
|
"completed_nodes": [
|
|
"start",
|
|
"toolchain",
|
|
"preflight_compile",
|
|
"preflight_lint",
|
|
"implement",
|
|
"simplify_fable",
|
|
"simplify_gpt",
|
|
"verify"
|
|
],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"internal.run_id": "01KX9EM9QF65A43PB064TF7TWY",
|
|
"failure_class": "",
|
|
"internal.retry_count.toolchain": 0,
|
|
"thread.toolchain.current_node": "preflight_compile",
|
|
"thread.simplify_gpt.current_node": "verify",
|
|
"internal.retry_count.simplify_fable": 0,
|
|
"graph.goal": "# PR 4 — Resolve hook interpolation at the run boundary (and enable secrets in hooks)\n\n**Self-contained implementation plan.** Everything needed to implement this is\nin this file plus the repository. Independent — no preconditions; can land\nanytime.\n\n**Redaction context (fixed; do not build on it):** run-output redaction in\nthis codebase is **content-based only** — entropy + credential-pattern\ndetection (`fabro_redact::redact_string` / `redact_json_value`), applied\nwhere events are serialized and where exec-output tails are captured. There\nis no per-run exact-value secret registry (a registration approach was\nconsidered and rejected). Secrets resolved for hooks by this PR get exactly\nthe coverage every other boundary-resolved secret (MCP env, run env, prepare\nsteps) already has: credential-shaped values are redacted from event and\ntail surfaces if echoed; a low-entropy secret value is not — an accepted,\ndocumented trade. Do not add any registration or exact-match machinery.\n\n> **Token notation.** Interpolation tokens are written in this file without\n> their enclosing double curly braces, so the file is safe to pass directly as\n> a workflow goal (the goal templater would otherwise try to expand them).\n> Read `secrets.NAME`, `env.NAME` as the double-curly-brace token form used in\n> the codebase, and write the real double-brace syntax in the code, tests, and\n> docs you produce.\n\n## Context and goal\n\nHooks are user-defined callbacks on workflow lifecycle events (`run_start`,\n`stage_start`, `pre_tool_use`, `sandbox_ready`, …) that can observe or gate a\nrun (decisions: proceed / block / skip / override). Four types: **command**\n(shell, runs in the sandbox by default or host-side with `sandbox = false`),\n**http** (POST from the worker), **prompt** and **agent** (LLM evaluation in\nthe worker). Their configurable string fields — `command`, `url`, header\nvalues, `prompt`, `model` — are typed `InterpString` and may carry `env.NAME`\ntokens.\n\nEvery other secret/env-consuming subsystem (MCP transport env, run-environment\nenv, prepare steps, docker config) resolves its InterpStrings **once, at the\nrun boundary** in `RunSession::new`, through one shared lookup closure. Hooks\nare the single exception: the hook **executor** resolves tokens **at fire\ntime**, and only the `env` namespace is wired there — a `secrets.NAME` token\nin a hook currently fails closed with \"unavailable namespace\". This fire-time\nresolution is a fossil, not a decision: it dates from the original hooks\nimplementation, before the boundary-resolution pattern existed, and was\ncarried forward unexamined. A previous attempt to add secrets support built a\nparallel fire-time secrets-resolution and redaction-registration subsystem\ninside the hooks crate to accommodate it; that PR was closed, and the accepted\ndirection is to remove the root special case instead.\n\n**Goal:** resolve all hook InterpStrings once at the run boundary through the\nshared closures, hand the hooks subsystem fully-resolved strings, and delete\nthe executor's resolution layer. Consequences, all intended:\n\n- `secrets.NAME` becomes usable in hook `command`, `url`, and `prompt`/`model`\n — the user-facing capability — with the same content-based redaction\n coverage every other boundary-resolved secret already has (see the\n redaction context above).\n- The invariant \"all env/secrets resolution happens at the run boundary\" holds\n with **zero exceptions**, so no future secrets/redaction work needs a hooks\n special case.\n- The hooks crate never learns about vaults or secrets at all.\n\nExplicitly out of scope / preserved:\n\n- The fire-time **context** mechanism is untouched: hooks receive per-firing\n data (event, node id, tool name, …) out-of-band via the `FABRO_HOOK_CONTEXT`\n env var, not via interpolation. There is no context namespace in\n InterpString; nothing interpolates per-firing data today, so boundary\n resolution loses no capability that exists.\n- Matcher semantics, blocking/decision merging, sandbox-vs-host dispatch,\n timeouts: unchanged.\n\n## Verified current state (as of main `9daca83b3`, 2026-07-09 — re-verify before starting; line numbers are anchors, not gospel)\n\n`lib/crates/fabro-hooks/src/executor.rs`:\n\n- `resolve_interp(value, env)` (~`:76`) — resolves an `InterpString` against\n process env at fire time; doc comment says only `env` is wired and other\n namespaces fail closed. Used for `command` and `url`.\n- `resolve_prompt_and_model` (~`:186`) — same, for prompt/agent hooks.\n- `resolve_header(value, allowed_env_vars, env)` (~`:132`) — header\n values additionally gate `env.NAME` behind the hook's `allowed_env_vars`\n list (`HeaderResolveError::NotAllowed`); resolution errors block the hook.\n- `safe_url_source_for_log` — logs the **unresolved** URL source (never the\n resolved URL) so env-sourced URL material is not logged.\n- Fail-closed dispositions today: a resolution error in `command` blocks; in\n `url`/headers/prompt/model the hook logs an error and does not fire (http\n headers produce a block); transport-level failures stay fail-open.\n\n`lib/crates/fabro-types/src/settings/run.rs`:\n\n- `HookDefinition { name, event, command: Option<InterpString>, hook_type,\n matcher, blocking, timeout_ms, sandbox }` (~`:2173`); `HookType::{Command,\n Http { url, headers, allowed_env_vars, tls }, Prompt { prompt, model },\n Agent { prompt, model } }` (~`:2149`). `vars.NAME` tokens in all these\n fields are already substituted at run creation (`substitute_variables`\n walks hooks), so only `env`/`secrets` tokens remain by boundary time.\n\n`lib/crates/fabro-workflow/src/operations/start.rs`:\n\n- The boundary pattern to mirror: `runtime_mcp_server(server, process_env_var,\n secret_lookup)` (~`:718`) and `runtime_setup_commands(...)` (~`:745`) —\n config type in, resolved runtime type out, hard error on missing names.\n- Hooks are currently passed through to the runner **unresolved** (find the\n hook wiring where `resolved.hooks` reaches `HookSettings`).\n\nEnvironment-timing note (verified): no `env::set_var` in production worker\npaths, so worker process env is identical at boundary time and fire time —\nresolving earlier does not change resolved values. Command hooks with\n`sandbox = true` already resolve against **worker** env and ship the resolved\nstring into the sandbox; that stays true, just earlier.\n\n## Design\n\n1. **Runtime hook type.** Add a resolved runtime form (e.g.\n `RuntimeHookDefinition`, plain `String` fields, mirroring\n `HookDefinition`/`HookType` shape) plus a boundary constructor\n `runtime_hooks(hooks, process_env_var, secret_lookup) -> Result<Vec<...>>`\n in `operations/start.rs` alongside `runtime_mcp_server` /\n `runtime_setup_commands`. The config/wire type `HookDefinition` is\n unchanged — no API or manifest change. For http hooks, carry the\n **unresolved url source string** on the runtime type as well, for safe\n logging (preserves the `safe_url_source_for_log` guarantee).\n2. **Header policy enforced at the boundary, unchanged in substance:**\n - a `secrets.NAME` token in a header value is rejected **before any vault\n lookup**, with the existing guidance shape: secrets are not allowed in\n HTTP hook headers; use secret interpolation in a hook command, prompt,\n or url instead;\n - `env.NAME` in header values stays gated by `allowed_env_vars`\n (non-allowlisted name → error naming the variable; allowlisted-but-unset\n → missing-variable error);\n - `command`/`url`/`prompt`/`model` resolve `env` + `secrets` with hard\n errors on missing names.\n Any resolution error **fails the run at startup** (consistent with how\n missing secrets in MCP/prepare config behave).\n3. **Slim the executor.** `HookRunner`/`HookExecutor` take the runtime type;\n delete `resolve_interp`, `resolve_header`, `resolve_prompt_and_model`,\n `HeaderResolveError`, and the `Env` type parameters from execution paths.\n The executor formats, dispatches, and merges decisions — it resolves\n nothing.\n4. **No hook-side redaction work needed.** Hook output and block/skip reasons\n flow into events, and event serialization already applies the content-based\n redaction pass (`event/redaction.rs`, `redact_json_value`). That is the\n full extent of coverage by design — do not add redaction machinery for\n hook values (see the redaction context at the top).\n\n### Behavior changes (state these plainly in the PR description)\n\n- **Fail timing moves earlier.** A hook referencing a missing env var or\n secret today fails when (and only if) the hook fires; after this PR the run\n fails at startup, including for hooks that would never have fired. Both are\n fail-closed; startup surfacing is stricter and reports config errors\n immediately instead of mid-run.\n- **Eager resolution.** Hook secrets resolve even if the hook never fires;\n values are held in worker memory for the run, like every other\n boundary-resolved secret.\n- Env snapshot timing is theoretically observable but a practical no-op (see\n the environment-timing note above).\n\n## Implementation\n\n1. Boundary: `RuntimeHookDefinition` + `runtime_hooks(...)` with header\n policy; wire into `RunSession::new` next to the other `runtime_*`\n resolvers; hard-fail the run on any resolve error.\n2. `fabro-hooks`: switch `HookSettings`/runner/executor to the runtime type;\n delete the resolution layer; keep matcher/blocking/dispatch/\n `FABRO_HOOK_CONTEXT`/timeout code untouched.\n3. Migrate tests:\n - executor tests asserting resolution behavior (missing env var blocks at\n fire time; header allowlist gating; unavailable-namespace errors) become\n boundary tests asserting startup failure / rejection with the same error\n content;\n - executor execution tests (dispatch, decisions, timeouts, sandbox-vs-host)\n switch to literal strings.\n4. New end-to-end tests (worker level, hermetic temp-dir vaults):\n - command hook with a `secrets.NAME` token resolves from the vault and\n proceeds;\n - missing hook secret fails the run at startup, error names the secret;\n - `secrets.NAME` in an http-hook header fails at startup with the guidance\n message, and the endpoint is never called;\n - http hook with a secret-valued URL resolves and fires (mock server\n asserts the call);\n - a blocking command hook whose block reason echoes a resolved\n **credential-shaped** secret value (use a distinctive high-entropy test\n marker, never a realistic credential) has that value redacted in stored\n events by the existing content-based pass — proving hook secrets get the\n standard coverage. Do not assert redaction of low-entropy values; that\n is out of coverage by design;\n - a hook that references only env still works host-side and sandbox-side.\n5. Docs (`docs/public/` hooks page): secrets usable in hook command / url /\n prompt; headers reject secret tokens with the guidance; missing names fail\n at run start.\n\n## Scope boundaries — deliberately NOT in this PR\n\n- **No redaction machinery inside `fabro-hooks`** — restated as a boundary:\n hook output flows into events, and event serialization already applies the\n content-based pass. If you find yourself adding a redactor, a registry, or\n a secrets type to the hooks crate, you have left this PR's design.\n- **The event-serialization redaction pass in `event/redaction.rs`** — leave\n as-is; do not extend, scope, or restructure it for hook fields.\n- **Exec-output tails and any `fabro-sandbox` redaction signatures** — leave\n as-is; they already apply content-based redaction.\n- **Read-side server handlers and the event-detail `redacted` flag** — leave\n as-is; a separate change owns read paths.\n- **Typed wrapper types for secret values** — separate planned work. The\n runtime hook type carries plain resolved `String`s in this PR.\n\nIf work outside these boundaries seems genuinely required for this PR to\ncompile or pass its tests, stop and state that in the PR description rather\nthan expanding scope.\n\n## Acceptance / verification\n\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n- `cargo nextest run --workspace` (touched: `fabro-hooks`, `fabro-workflow`,\n `fabro-types` if the runtime type lands there)\n- `cargo dev docs check`\n- No OpenAPI/wire change (config types untouched).\n\n## Conventions\n\n- Never print or log a resolved secret value, including from tests; preserve\n unresolved-source-only URL logging.\n- Plain-English commit messages, PR text, and comments; no internal planning\n identifiers or plan-file names in anything that ships.\n- PR description must include the two behavior changes above, framed as\n intended semantics (fail-fast config errors), and state the capability\n added (vault secrets in hooks) with the header exclusion.\n- If implementation uncovers a genuine need for per-firing interpolation in\n hook strings (none is known), stop and surface it rather than re-adding a\n fire-time resolver.\n",
|
|
"thread.simplify_fable.current_node": "simplify_gpt",
|
|
"internal.retry_count.simplify_gpt": 0,
|
|
"internal.retry_count.start": 0,
|
|
"internal.retry_count.verify": 0,
|
|
"internal.fidelity": "compact",
|
|
"thread.implement.current_node": "simplify_fable",
|
|
"internal.retry_count.implement": 0,
|
|
"internal.node_visit_count": 1,
|
|
"graph.model_stylesheet": "\n * { model: claude-opus-4-8; }\n ",
|
|
"outcome": "succeeded",
|
|
"failure_signature": "",
|
|
"command.output": "blob://sha256/57cd04e0ed3f7b7efe6736c2777ef103692de337dbc78192bdddbea9938f393d",
|
|
"graph.rankdir": "LR",
|
|
"internal.work_dir": "/home/daytona/workspace/fabro",
|
|
"internal.thread_id": "simplify_gpt",
|
|
"internal.retry_count.preflight_compile": 0,
|
|
"thread.preflight_compile.current_node": "preflight_lint",
|
|
"internal.retry_count.preflight_lint": 0,
|
|
"last_response": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-ag",
|
|
"last_stage": "simplify_fable",
|
|
"current_node": "verify",
|
|
"thread.start.current_node": "toolchain",
|
|
"thread.preflight_lint.current_node": "implement",
|
|
"response.simplify_fable": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-agent review pass over my own diff.\n\n## What was implemented\n\n**Boundary resolution (the PR's core):**\n- `fabro-types/src/settings/run.rs`: new `RuntimeHookDefinition`, `RuntimeHookType`, `RuntimeHttpHook`, and `HookResolveError`, plus `HookDefinition::resolve_env(env_lookup, secrets_lookup)` — resolves `command`/`url`/headers/`prompt`/`model` once, with the header policy enforced before any lookup (secrets in headers rejected with the guidance message; header env gated by `allowed_env_vars`). Http hooks carry the unresolved `url_source` for safe logging. Wire/config types are untouched.\n- `fabro-workflow/operations/start.rs`: `runtime_hooks(...)` next to `runtime_mcp_server`/`runtime_setup_commands`, wired into `RunSession::new` through the shared `process_env_var`/`secret_lookup` closures; any resolution error fails the run at startup naming the hook.\n- `fabro-hooks`: executor/runner/bridge now consume resolved strings only — deleted `resolve_interp`, `resolve_header`, `HeaderResolveError`, `resolve_prompt_and_model`, the `Env` type params, and the crate's `fabro-util` dep. Matchers, decision merging, `FABRO_HOOK_CONTEXT`, timeouts, and TLS enforcement are untouched. Also deleted the production-dead `HookSettings::merge` (config layering owns merge semantics).\n\n**Tests:** resolution tests moved to fabro-types (13 new) and start.rs boundary tests (secret resolves / missing secret fails startup / header-secret guidance, incl. a panicking-lookup test proving rejection happens before any vault read); new worker-level tests with hermetic temp-dir vaults: command-hook secret proceeds, http hook with secret URL fires (httpmock asserts), blocking hook echoing a credential-shaped secret gets **redacted in stored events** by the existing content-based pass, and env-only hooks work on both host and sandbox dispatch.\n\n**Docs:** `hooks.mdx` gained an Interpolation section (secrets in command/url/prompt, header exclusion, startup-fail semantics); the OpenAPI `headers` description was updated (description-only, no wire change).\n\n## Review-agent findings fixed\n- **Quality:** swappable `url`/`url_source` `&str` params → grouped into `RuntimeHttpHook`; stale fire-time wording in `fabro-config/resolve/run.rs` and `fabro-api.yaml`; missing allowlist-violation outcome in hooks.mdx; `Debug`-leak warning added to runtime type docs.\n- **Reuse:** extracted shared `resolve_env_secrets` (now backing both `resolve_env_string` and hook resolution); test vault seeding reuses `fabro_auth::vault_set_token`.\n- **Efficiency:** `initialize.rs` now **moves** the resolved hooks into `HookRunner` instead of deep-cloning them, so a single copy of resolved secrets lives per run. (Reviewers also confirmed the per-fire path got strictly lighter.)\n- **Skipped (noted deliberately):** pre-redacting `url_source` at the boundary (plan mandates carrying the unresolved source; would add a fabro-redact dep to fabro-types), exporting `vault_token_lookup` just for a test one-liner, and rebuilding all test fixtures through `resolve_env`.\n\n## Verification\n`cargo +nightly-2026-04-14 fmt --check --all` ✓ · `clippy --workspace --all-targets -D warnings` ✓ · `cargo nextest run --workspace` → **6964 passed** ✓ · `cargo dev docs check` ✓\n\nFor the PR description, the two intended behavior changes: **fail timing moves to startup** (missing hook env/secret fails the run immediately, even for hooks that never fire) and **eager resolution** (hook secrets are resolved and held in worker memory for the run, like every other boundary-resolved secret)."
|
|
},
|
|
"node_outcomes": {
|
|
"simplify_fable": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"last_response": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-ag",
|
|
"response.simplify_fable": "Done. A note up front: the failed `implement` stage had produced **zero code changes** (API auth failure — all stage commits were empty), so I implemented PR 4 from scratch and then ran the three-agent review pass over my own diff.\n\n## What was implemented\n\n**Boundary resolution (the PR's core):**\n- `fabro-types/src/settings/run.rs`: new `RuntimeHookDefinition`, `RuntimeHookType`, `RuntimeHttpHook`, and `HookResolveError`, plus `HookDefinition::resolve_env(env_lookup, secrets_lookup)` — resolves `command`/`url`/headers/`prompt`/`model` once, with the header policy enforced before any lookup (secrets in headers rejected with the guidance message; header env gated by `allowed_env_vars`). Http hooks carry the unresolved `url_source` for safe logging. Wire/config types are untouched.\n- `fabro-workflow/operations/start.rs`: `runtime_hooks(...)` next to `runtime_mcp_server`/`runtime_setup_commands`, wired into `RunSession::new` through the shared `process_env_var`/`secret_lookup` closures; any resolution error fails the run at startup naming the hook.\n- `fabro-hooks`: executor/runner/bridge now consume resolved strings only — deleted `resolve_interp`, `resolve_header`, `HeaderResolveError`, `resolve_prompt_and_model`, the `Env` type params, and the crate's `fabro-util` dep. Matchers, decision merging, `FABRO_HOOK_CONTEXT`, timeouts, and TLS enforcement are untouched. Also deleted the production-dead `HookSettings::merge` (config layering owns merge semantics).\n\n**Tests:** resolution tests moved to fabro-types (13 new) and start.rs boundary tests (secret resolves / missing secret fails startup / header-secret guidance, incl. a panicking-lookup test proving rejection happens before any vault read); new worker-level tests with hermetic temp-dir vaults: command-hook secret proceeds, http hook with secret URL fires (httpmock asserts), blocking hook echoing a credential-shaped secret gets **redacted in stored events** by the existing content-based pass, and env-only hooks work on both host and sandbox dispatch.\n\n**Docs:** `hooks.mdx` gained an Interpolation section (secrets in command/url/prompt, header exclusion, startup-fail semantics); the OpenAPI `headers` description was updated (description-only, no wire change).\n\n## Review-agent findings fixed\n- **Quality:** swappable `url`/`url_source` `&str` params → grouped into `RuntimeHttpHook`; stale fire-time wording in `fabro-config/resolve/run.rs` and `fabro-api.yaml`; missing allowlist-violation outcome in hooks.mdx; `Debug`-leak warning added to runtime type docs.\n- **Reuse:** extracted shared `resolve_env_secrets` (now backing both `resolve_env_string` and hook resolution); test vault seeding reuses `fabro_auth::vault_set_token`.\n- **Efficiency:** `initialize.rs` now **moves** the resolved hooks into `HookRunner` instead of deep-cloning them, so a single copy of resolved secrets lives per run. (Reviewers also confirmed the per-fire path got strictly lighter.)\n- **Skipped (noted deliberately):** pre-redacting `url_source` at the boundary (plan mandates carrying the unresolved source; would add a fabro-redact dep to fabro-types), exporting `vault_token_lookup` just for a test one-liner, and rebuilding all test fixtures through `resolve_env`.\n\n## Verification\n`cargo +nightly-2026-04-14 fmt --check --all` ✓ · `clippy --workspace --all-targets -D warnings` ✓ · `cargo nextest run --workspace` → **6964 passed** ✓ · `cargo dev docs check` ✓\n\nFor the PR description, the two intended behavior changes: **fail timing moves to startup** (missing hook env/secret fails the run immediately, even for hooks that never fire) and **eager resolution** (hook secrets are resolved and held in worker memory for the run, like every other boundary-resolved secret).",
|
|
"last_stage": "simplify_fable"
|
|
},
|
|
"notes": "Stage completed: simplify_fable",
|
|
"usage": {
|
|
"input": {
|
|
"usage": {
|
|
"model": {
|
|
"provider": "anthropic",
|
|
"model_id": "claude-fable-5"
|
|
},
|
|
"tokens": {
|
|
"input_tokens": 374846,
|
|
"output_tokens": 148952,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 46751554,
|
|
"cache_write_tokens": 5095953
|
|
}
|
|
},
|
|
"facts": {
|
|
"algorithm": "anthropic",
|
|
"cache_write_5m_tokens": 5095953,
|
|
"cache_write_1h_tokens": 0
|
|
}
|
|
},
|
|
"total_usd_micros": 121647026
|
|
},
|
|
"files_touched": [
|
|
"/home/daytona/workspace/fabro/docs/public/agents/hooks.mdx",
|
|
"/home/daytona/workspace/fabro/docs/public/api-reference/fabro-api.yaml",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-config/src/resolve/run.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/Cargo.toml",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/bridge.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/config.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/executor.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/lib.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/runner.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/src/types.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-hooks/tests/host_command_hooks.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-types/src/settings/run.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/operations/start.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/pipeline/initialize.rs",
|
|
"/home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/integration.rs"
|
|
],
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 2358244,
|
|
"tool_time_ms": 1436685,
|
|
"active_time_ms": 3794929
|
|
}
|
|
},
|
|
"preflight_compile": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo check -q --workspace 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 105518,
|
|
"active_time_ms": 105518
|
|
}
|
|
},
|
|
"preflight_lint": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
|
|
},
|
|
"notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 111530,
|
|
"active_time_ms": 111530
|
|
}
|
|
},
|
|
"implement": {
|
|
"status": "failed",
|
|
"failure": {
|
|
"message": "LLM error: Authentication error for openai: Your authentication token has been invalidated. Please try signing in again.",
|
|
"category": "deterministic",
|
|
"signature": "api_deterministic|openai|authentication"
|
|
},
|
|
"usage": null
|
|
},
|
|
"verify": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/57cd04e0ed3f7b7efe6736c2777ef103692de337dbc78192bdddbea9938f393d"
|
|
},
|
|
"notes": "Script completed: git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 347374,
|
|
"active_time_ms": 347374
|
|
}
|
|
},
|
|
"start": {
|
|
"status": "succeeded",
|
|
"usage": null
|
|
},
|
|
"simplify_gpt": {
|
|
"status": "failed",
|
|
"failure": {
|
|
"message": "LLM error: Authentication error for openai: Your authentication token has been invalidated. Please try signing in again.",
|
|
"category": "deterministic",
|
|
"signature": "api_deterministic|openai|authentication"
|
|
},
|
|
"usage": null
|
|
},
|
|
"toolchain": {
|
|
"status": "succeeded",
|
|
"context_updates": {
|
|
"command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1"
|
|
},
|
|
"notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"usage": null,
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 1299,
|
|
"active_time_ms": 1299
|
|
}
|
|
}
|
|
},
|
|
"next_node_id": "exit",
|
|
"node_visits": {
|
|
"start": 1,
|
|
"preflight_lint": 1,
|
|
"simplify_fable": 1,
|
|
"implement": 1,
|
|
"preflight_compile": 1,
|
|
"toolchain": 1,
|
|
"simplify_gpt": 1,
|
|
"verify": 1
|
|
}
|
|
},
|
|
"diff": {}
|
|
}
|
|
],
|
|
"conclusion": null,
|
|
"sandbox": {
|
|
"kind": "ready",
|
|
"plan": {
|
|
"provider": "daytona"
|
|
},
|
|
"instance": {
|
|
"provider": "daytona",
|
|
"snapshot": "fabro-fdb28dec-1233-892c-b9d7-9f88f8353e7a",
|
|
"runtime": {
|
|
"id": "fabro-01KX9EM9QF65A43PB064TF7TWY",
|
|
"working_directory": "/home/daytona/workspace/fabro",
|
|
"repo_cloned": true,
|
|
"clone_origin_url": "https://github.com/fabro-sh/fabro",
|
|
"clone_branch": "main",
|
|
"workspace_root": "/home/daytona/workspace",
|
|
"repos_root": "/home/daytona/repos",
|
|
"primary_repo_path": "/home/daytona/repos/fabro-sh/fabro",
|
|
"primary_repo_link": "/home/daytona/workspace/fabro"
|
|
}
|
|
}
|
|
},
|
|
"pull_request": null,
|
|
"superseded_by": null,
|
|
"pending_interviews": {},
|
|
"stages": {
|
|
"toolchain@1": {
|
|
"first_event_seq": 22,
|
|
"prompt": null,
|
|
"response": null,
|
|
"completion": {
|
|
"outcome": "succeeded",
|
|
"notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"failure_reason": null,
|
|
"timestamp": "2026-07-11T20:41:58.324723Z"
|
|
},
|
|
"provider_used": null,
|
|
"diff": null,
|
|
"script_invocation": {
|
|
"script": "command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"command": "exec 2>&1\ncommand -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
|
|
"language": "shell"
|
|
},
|
|
"script_timing": {
|
|
"output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1",
|
|
"exit_code": 0,
|
|
"duration_ms": 1299,
|
|
"termination": "exited",
|
|
"output_bytes": 36,
|
|
"live_streaming": true
|
|
},
|
|
"parallel_results": null,
|
|
"output": null,
|
|
"output_bytes": 36,
|
|
"live_streaming": true,
|
|
"termination": "exited",
|
|
"started_at": "2026-07-11T20:41:57.021333330Z",
|
|
"handler": "command",
|
|
"timing": {
|
|
"wall_time_ms": 1303,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 1299,
|
|
"active_time_ms": 1299
|
|
},
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"cache_write_tokens": 0
|
|
},
|
|
"state": "succeeded"
|
|
},
|
|
"verify@1": {
|
|
"first_event_seq": 1188,
|
|
"prompt": null,
|
|
"response": null,
|
|
"completion": null,
|
|
"provider_used": null,
|
|
"diff": null,
|
|
"script_invocation": {
|
|
"script": "git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1",
|
|
"command": "exec 2>&1\ngit fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1",
|
|
"language": "shell",
|
|
"timeout_ms": 1800000
|
|
},
|
|
"script_timing": null,
|
|
"parallel_results": null,
|
|
"output": null,
|
|
"started_at": "2026-07-11T21:49:12.708132052Z",
|
|
"handler": "command",
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"cache_write_tokens": 0
|
|
},
|
|
"state": "running"
|
|
},
|
|
"preflight_compile@1": {
|
|
"first_event_seq": 32,
|
|
"prompt": null,
|
|
"response": null,
|
|
"completion": {
|
|
"outcome": "succeeded",
|
|
"notes": "Script completed: cargo check -q --workspace 2>&1",
|
|
"failure_reason": null,
|
|
"timestamp": "2026-07-11T20:43:47.405509387Z"
|
|
},
|
|
"provider_used": null,
|
|
"diff": null,
|
|
"script_invocation": {
|
|
"script": "cargo check -q --workspace 2>&1",
|
|
"command": "exec 2>&1\ncargo check -q --workspace 2>&1",
|
|
"language": "shell"
|
|
},
|
|
"script_timing": {
|
|
"output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
|
|
"exit_code": 0,
|
|
"duration_ms": 105518,
|
|
"termination": "exited",
|
|
"output_bytes": 0,
|
|
"live_streaming": false
|
|
},
|
|
"parallel_results": null,
|
|
"output": null,
|
|
"output_bytes": 0,
|
|
"live_streaming": false,
|
|
"termination": "exited",
|
|
"started_at": "2026-07-11T20:42:01.884049052Z",
|
|
"handler": "command",
|
|
"timing": {
|
|
"wall_time_ms": 105521,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 105518,
|
|
"active_time_ms": 105518
|
|
},
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"cache_write_tokens": 0
|
|
},
|
|
"state": "succeeded"
|
|
},
|
|
"preflight_lint@1": {
|
|
"first_event_seq": 42,
|
|
"prompt": null,
|
|
"response": null,
|
|
"completion": {
|
|
"outcome": "succeeded",
|
|
"notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
|
|
"failure_reason": null,
|
|
"timestamp": "2026-07-11T20:45:42.138415261Z"
|
|
},
|
|
"provider_used": null,
|
|
"diff": null,
|
|
"script_invocation": {
|
|
"script": "cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
|
|
"command": "exec 2>&1\ncargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
|
|
"language": "shell"
|
|
},
|
|
"script_timing": {
|
|
"output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
|
|
"exit_code": 0,
|
|
"duration_ms": 111530,
|
|
"termination": "exited",
|
|
"output_bytes": 0,
|
|
"live_streaming": false
|
|
},
|
|
"parallel_results": null,
|
|
"output": null,
|
|
"output_bytes": 0,
|
|
"live_streaming": false,
|
|
"termination": "exited",
|
|
"started_at": "2026-07-11T20:43:50.603822193Z",
|
|
"handler": "command",
|
|
"timing": {
|
|
"wall_time_ms": 111534,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 111530,
|
|
"active_time_ms": 111530
|
|
},
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"cache_write_tokens": 0
|
|
},
|
|
"state": "succeeded"
|
|
},
|
|
"implement@1": {
|
|
"first_event_seq": 52,
|
|
"prompt": null,
|
|
"response": null,
|
|
"completion": {
|
|
"outcome": "failed",
|
|
"notes": null,
|
|
"failure_reason": "LLM error: Authentication error for openai: Your authentication token has been invalidated. Please try signing in again.",
|
|
"timestamp": "2026-07-11T20:45:45.780976787Z"
|
|
},
|
|
"provider_used": {
|
|
"mode": "agent",
|
|
"provider": "openai",
|
|
"model": "gpt-5.5",
|
|
"reasoning_effort": "xhigh"
|
|
},
|
|
"diff": null,
|
|
"script_invocation": null,
|
|
"script_timing": null,
|
|
"parallel_results": null,
|
|
"output": null,
|
|
"started_at": "2026-07-11T20:45:45.299664832Z",
|
|
"handler": "agent",
|
|
"timing": {
|
|
"wall_time_ms": 480,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 0,
|
|
"active_time_ms": 0
|
|
},
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"cache_write_tokens": 0
|
|
},
|
|
"skills": {
|
|
"available": [
|
|
{
|
|
"name": "rust-style-guide",
|
|
"description": "Apply this Rust style guide when writing, reviewing, refactoring, or configuring Rust code for this project. Covers Rust 2024/MSRV, library vs application conventions, public API design, errors, panics, ownership and cloning, async/Tokio/concurrency, tracing, rustfmt/Clippy, testing with nextest, and unsafe/macro policy. Also use when setting up new Rust projects, investigating Rust performance, verifying library releases, or reviewing Rust code changes."
|
|
}
|
|
],
|
|
"activated": []
|
|
},
|
|
"permission_level": "full",
|
|
"agent_tools": [
|
|
{
|
|
"name": "apply_patch",
|
|
"description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "write",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "close_agent",
|
|
"description": "Close a running subagent that is no longer needed.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "glob",
|
|
"description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "grep",
|
|
"description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "read_file",
|
|
"description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "request_user_input",
|
|
"description": "Ask the human one or more questions and wait for their answers before continuing this stage.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "send_input",
|
|
"description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "shell",
|
|
"description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "shell",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "spawn_agent",
|
|
"description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "update_plan",
|
|
"description": "Update the multi-step plan for the current task. Submit the entire plan; existing steps are reconciled by exact step text.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "use_skill",
|
|
"description": "Load a skill's instructions by name. Call this when the user's request matches an available skill.",
|
|
"source": {
|
|
"kind": "skill"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "wait",
|
|
"description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "web_fetch",
|
|
"description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "web_search",
|
|
"description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "write_file",
|
|
"description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "write",
|
|
"invoked": false
|
|
}
|
|
],
|
|
"state": "failed"
|
|
},
|
|
"start@1": {
|
|
"first_event_seq": 18,
|
|
"prompt": null,
|
|
"response": null,
|
|
"completion": {
|
|
"outcome": "succeeded",
|
|
"notes": null,
|
|
"failure_reason": null,
|
|
"timestamp": "2026-07-11T20:41:57.020984248Z"
|
|
},
|
|
"provider_used": null,
|
|
"diff": null,
|
|
"script_invocation": null,
|
|
"script_timing": null,
|
|
"parallel_results": null,
|
|
"output": null,
|
|
"started_at": "2026-07-11T20:41:57.020879631Z",
|
|
"handler": "start",
|
|
"timing": {
|
|
"wall_time_ms": 0,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 0,
|
|
"active_time_ms": 0
|
|
},
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"cache_write_tokens": 0
|
|
},
|
|
"state": "succeeded"
|
|
},
|
|
"simplify_fable@1": {
|
|
"first_event_seq": 69,
|
|
"prompt": null,
|
|
"response": null,
|
|
"completion": {
|
|
"outcome": "succeeded",
|
|
"notes": "Stage completed: simplify_fable",
|
|
"failure_reason": null,
|
|
"timestamp": "2026-07-11T21:49:04.931525161Z"
|
|
},
|
|
"provider_used": {
|
|
"mode": "agent",
|
|
"provider": "anthropic",
|
|
"model": "claude-fable-5",
|
|
"reasoning_effort": "xhigh"
|
|
},
|
|
"diff": null,
|
|
"script_invocation": null,
|
|
"script_timing": null,
|
|
"parallel_results": null,
|
|
"output": null,
|
|
"started_at": "2026-07-11T20:45:48.831195717Z",
|
|
"handler": "agent",
|
|
"timing": {
|
|
"wall_time_ms": 3796100,
|
|
"inference_time_ms": 2358244,
|
|
"tool_time_ms": 1436685,
|
|
"active_time_ms": 3794929
|
|
},
|
|
"usage": {
|
|
"input_tokens": 374846,
|
|
"output_tokens": 148952,
|
|
"total_tokens": 52371305,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 46751554,
|
|
"cache_write_tokens": 5095953,
|
|
"total_usd_micros": 121647026
|
|
},
|
|
"model": {
|
|
"provider": "anthropic",
|
|
"model_id": "claude-fable-5"
|
|
},
|
|
"todos": {
|
|
"kind": "anthropic_tasks",
|
|
"list_id": "anthropic_tasks:47290e2f-4c2c-41b0-af3f-219a0395aede",
|
|
"items": [
|
|
{
|
|
"id": "1",
|
|
"status": "completed",
|
|
"order": 0,
|
|
"subject": "Report hook-related tests in fabro-workflow/tests/",
|
|
"description": "Find all test functions constructing HookDefinition/HookSettings or exercising hooks, esp. integration.rs 8700-9100",
|
|
"active_form": "Surveying hook tests"
|
|
},
|
|
{
|
|
"id": "2",
|
|
"status": "completed",
|
|
"order": 1,
|
|
"subject": "Report vault-based secrets tests and helpers",
|
|
"description": "Find hermetic temp-dir vault tests (MCP env secrets, prepare-step secrets), helper functions like vault_secret_lookup, and worker-level run test setup pattern",
|
|
"active_form": "Surveying vault test helpers"
|
|
},
|
|
{
|
|
"id": "3",
|
|
"status": "completed",
|
|
"order": 2,
|
|
"subject": "Report event redaction and stored-event assertions",
|
|
"description": "Find event/redaction.rs application point, tests asserting credential-shaped values redacted in stored events, and exact high-entropy marker strings used",
|
|
"active_form": "Surveying event redaction tests"
|
|
},
|
|
{
|
|
"id": "4",
|
|
"status": "completed",
|
|
"order": 3,
|
|
"subject": "Report hooks docs page structure",
|
|
"description": "Find docs/public hooks page, report headings and quote interpolation/env vars/allowed_env_vars/secrets sections",
|
|
"active_form": "Reading hooks docs"
|
|
},
|
|
{
|
|
"id": "5",
|
|
"status": "completed",
|
|
"order": 4,
|
|
"subject": "Check fabro-server/fabro-cli usage of fabro-hooks",
|
|
"description": "Search src/ and tests/ of both crates for fabro_hooks and HookRunner",
|
|
"active_form": "Checking fabro-hooks dependents"
|
|
},
|
|
{
|
|
"id": "6",
|
|
"status": "completed",
|
|
"order": 5,
|
|
"subject": "Report InitOptions.hooks type and construction sites",
|
|
"description": "Find InitOptions definition, hooks field type, and all src/ (non-test) construction sites of InitOptions and HookSettings",
|
|
"active_form": "Tracing InitOptions constructions"
|
|
},
|
|
{
|
|
"id": "7",
|
|
"status": "completed",
|
|
"order": 6,
|
|
"subject": "Review agents: reuse, quality, efficiency",
|
|
"description": "Launch three parallel review agents over the full diff (/tmp/pr4-diff.txt); aggregate findings and fix.",
|
|
"active_form": "Running review agents"
|
|
}
|
|
]
|
|
},
|
|
"subagents": [
|
|
{
|
|
"agent_id": "5198ff9b",
|
|
"depth": 1,
|
|
"task": "Explore the repository at /home/daytona/workspace/fabro (read-only exploration; do NOT modify any files). I need a report to support a refactor where workflow hook InterpString fields (command, url, headers, prompt, model) stop being resolved at fire time inside lib/crates/fabro-hooks and instead are resolved once at the run boundary in lib/crates/fabro-workflow/src/operations/start.rs (mirroring runtime_mcp_server/runtime_setup_commands), gaining secrets support from the vault.\n\nReport the following, with file paths and line numbers:\n\n1. All test functions in lib/crates/fabro-workflow/tests/ that construct HookDefinition, HookSettings, or exercise hooks (search for \"hook\" case-insensitive in tests/). For each: test name, what it asserts, and which hook fields it uses. Especially the ones around lib/crates/fabro-workflow/tests/it/integration.rs lines 8700-9100.\n\n2. Existing worker-level tests that use a hermetic temp-dir vault to test secrets resolution (e.g., tests for MCP env secrets or prepare-step secrets resolved at run boundary). Search for \"vault\" in lib/crates/fabro-workflow/tests/ and in lib/crates/fabro-workflow/src/operations/start.rs tests (module at bottom of start.rs, around lines 1200-1500). Describe the helper functions used to create test vaults (e.g., vault_secret_lookup) and how a full worker-level run test with a vault is set up (which harness: fabro_test? scenario tests? give an example test name and its setup pattern).\n\n3. How stored events are asserted in worker-level tests (for a test that a hook block reason echoing a high-entropy secret gets redacted in stored events by fabro_redact content-based redaction). Find where event redaction is applied (lib/crates/fabro-workflow/src/event/redaction.rs) and an existing test that asserts a credential-shaped value is redacted in stored events (search for \"redact\" in fabro-workflow tests and src). Report what a \"credential-shaped high-entropy\" test marker looks like in existing tests (exact example strings used).\n\n4. The docs page for hooks under docs/public/ (find it, give the path and its current structure/headings, and quote the section(s) about interpolation/env vars in hooks, including anything about allowed_env_vars and secrets).\n\n5. Whether fabro-server or fabro-cli use anything from the fabro-hooks crate (they list it in Cargo.toml). Search their src/ and tests/ for \"fabro_hooks\" and \"HookRunner\" and report usages, or confirm none (in which case the dependency may be vestigial — just report, don't change).\n\n6. In lib/crates/fabro-workflow/src/run_options.rs or wherever InitOptions is defined, report the type of the `hooks` field and every construction site of InitOptions and HookSettings in src/ (not tests) so I know every place the type change ripples.\n\n7. Report how `cargo dev docs check` works (find the dev command implementation) and what it validates, briefly.\n\nBe precise and complete; include exact line numbers and short code quotes. Do not make any edits.",
|
|
"status": {
|
|
"kind": "completed",
|
|
"success": true,
|
|
"turns_used": 66
|
|
}
|
|
},
|
|
{
|
|
"agent_id": "39dc7828",
|
|
"depth": 1,
|
|
"task": "You are a CODE REUSE reviewer for a Rust workspace at /home/daytona/workspace/fabro. Read the full uncommitted diff at /tmp/pr4-diff.txt (use the shell command `cat /tmp/pr4-diff.txt` or read the file). The change moves workflow hook interpolation (env + vault secrets) from fire-time resolution inside lib/crates/fabro-hooks to a one-time run-boundary resolution in lib/crates/fabro-workflow/src/operations/start.rs, adding RuntimeHookDefinition/RuntimeHookType/HookResolveError and HookDefinition::resolve_env to lib/crates/fabro-types/src/settings/run.rs.\n\nFor each change in the diff:\n1. Search for existing utilities and helpers in the repository that could replace newly written code (grep for similar patterns — e.g., existing resolve helpers like resolve_env_string, pair_lookup, existing test vault helpers like temp_vault/token_vault, existing error-wrapping helpers).\n2. Flag any new function that duplicates existing functionality; suggest the existing function to use instead.\n3. Flag inline logic that could use an existing utility (string manipulation, lookup closures, test fixtures).\n\nDo NOT modify any files. Produce a concise report of concrete findings with file:line references, each with a suggested fix, and mark severity (high/medium/low). If a finding would change behavior or is not worth it, say so. End with a short list titled \"Actionable\" of only the findings you'd actually fix.",
|
|
"status": {
|
|
"kind": "completed",
|
|
"success": true,
|
|
"turns_used": 46
|
|
}
|
|
},
|
|
{
|
|
"agent_id": "dd1e91a3",
|
|
"depth": 1,
|
|
"task": "You are a CODE QUALITY reviewer for a Rust workspace at /home/daytona/workspace/fabro. Read the full uncommitted diff at /tmp/pr4-diff.txt (e.g. `cat /tmp/pr4-diff.txt`). The change moves workflow hook interpolation (env + vault secrets) from fire-time resolution inside lib/crates/fabro-hooks to a one-time run-boundary resolution in lib/crates/fabro-workflow/src/operations/start.rs, adding RuntimeHookDefinition/RuntimeHookType/HookResolveError and HookDefinition::resolve_env to lib/crates/fabro-types/src/settings/run.rs. Read the surrounding code of changed files as needed for context.\n\nReview the diff for hacky patterns:\n1. Redundant state: fields that duplicate other state or could be derived (e.g., is RuntimeHookDefinition.effective_name justified? note: it exists so logs never derive labels from resolved secret values — evaluate whether that justification holds and is documented).\n2. Parameter sprawl: new parameters added where restructuring would be cleaner (e.g., execute_http taking url + url_source).\n3. Copy-paste with slight variation: near-duplicate code blocks that should be unified (check the four RuntimeHookType resolution arms, the test fixtures across bridge.rs/runner.rs/types.rs/executor.rs, and the repeated `resolve_hook_value(..., &mut env_lookup, &mut secrets_lookup)` calls).\n4. Leaky abstractions: does anything expose internals that should be encapsulated? Does the hooks crate still leak resolution concerns anywhere?\n5. Stringly-typed code: raw strings where enums/constants exist.\nAlso check doc comments for accuracy against the new behavior (stale references to fire-time resolution anywhere in fabro-hooks, fabro-types interp.rs module docs, or fabro-workflow).\n\nDo NOT modify any files. Produce a concise report of concrete findings with file:line references, each with a suggested fix and severity. End with a short list titled \"Actionable\" of only the findings you'd actually fix.",
|
|
"status": {
|
|
"kind": "completed",
|
|
"success": true,
|
|
"turns_used": 39
|
|
}
|
|
},
|
|
{
|
|
"agent_id": "2ac6b556",
|
|
"depth": 1,
|
|
"task": "You are an EFFICIENCY reviewer for a Rust workspace at /home/daytona/workspace/fabro. Read the full uncommitted diff at /tmp/pr4-diff.txt (e.g. `cat /tmp/pr4-diff.txt`). The change moves workflow hook interpolation (env + vault secrets) from fire-time resolution inside lib/crates/fabro-hooks to a one-time run-boundary resolution in lib/crates/fabro-workflow/src/operations/start.rs, adding RuntimeHookDefinition/RuntimeHookType/HookResolveError and HookDefinition::resolve_env to lib/crates/fabro-types/src/settings/run.rs. Read surrounding code of changed files as needed.\n\nReview the diff for efficiency issues:\n1. Unnecessary work: redundant computations, repeated allocations (e.g., redacted_url_for_log called per log site in execute_http — is that fine since they're error paths?), repeated clones in resolve_env.\n2. Missed concurrency: independent operations run sequentially that could be parallel (probably N/A, but check).\n3. Hot-path bloat: is anything new added to per-hook-fire or startup hot paths that is heavier than before? (Compare to previous fire-time resolution.)\n4. Unnecessary existence checks / TOCTOU: pre-checking before operating.\n5. Memory: unbounded structures, leaks, needless long-lived clones (e.g., does RuntimeHookDefinition hold anything unnecessarily?).\n6. Overly broad operations: cloning whole structures when only parts are needed (e.g., resolved_hook_type() returning Cow::Owned clones of InterpString for legacy command hooks during resolve_env — is that acceptable one-time boundary cost?).\n\nDo NOT modify any files. Produce a concise report of concrete findings with file:line references, each with a suggested fix and severity. Be pragmatic: run-boundary code runs once per run; per-fire code runs often. End with a short list titled \"Actionable\" of only the findings you'd actually fix.",
|
|
"status": {
|
|
"kind": "completed",
|
|
"success": true,
|
|
"turns_used": 31
|
|
}
|
|
}
|
|
],
|
|
"skills": {
|
|
"available": [
|
|
{
|
|
"name": "rust-style-guide",
|
|
"description": "Apply this Rust style guide when writing, reviewing, refactoring, or configuring Rust code for this project. Covers Rust 2024/MSRV, library vs application conventions, public API design, errors, panics, ownership and cloning, async/Tokio/concurrency, tracing, rustfmt/Clippy, testing with nextest, and unsafe/macro policy. Also use when setting up new Rust projects, investigating Rust performance, verifying library releases, or reviewing Rust code changes."
|
|
}
|
|
],
|
|
"activated": []
|
|
},
|
|
"permission_level": "full",
|
|
"agent_tools": [
|
|
{
|
|
"name": "AskUserQuestion",
|
|
"description": "Ask the human one or more questions and wait for their answers before continuing this stage.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "TaskCreate",
|
|
"description": "Create pending tasks in the current session. Use concise subjects, descriptions, optional activeForm text, and metadata. Check TaskList first to avoid duplicate tasks.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "TaskGet",
|
|
"description": "Get one task by taskId, including subject, status, description, owner, blockedBy, and blocks.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "TaskList",
|
|
"description": "List tasks for the current session, including status, owner, and blocking dependencies. Use TaskGet with a taskId for full description and dependency details.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "TaskUpdate",
|
|
"description": "Update an existing task's status, text, owner, metadata, or dependencies. Valid statuses are pending, in_progress, completed, and deleted. After completing a task, call TaskList to find newly unblocked work.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "close_agent",
|
|
"description": "Close a running subagent that is no longer needed.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "edit_file",
|
|
"description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "write",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "glob",
|
|
"description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "grep",
|
|
"description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "read_file",
|
|
"description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "send_input",
|
|
"description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "shell",
|
|
"description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "shell",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "spawn_agent",
|
|
"description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "use_skill",
|
|
"description": "Load a skill's instructions by name. Call this when the user's request matches an available skill.",
|
|
"source": {
|
|
"kind": "skill"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "wait",
|
|
"description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": true
|
|
},
|
|
{
|
|
"name": "web_fetch",
|
|
"description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "web_search",
|
|
"description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "write_file",
|
|
"description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "write",
|
|
"invoked": true
|
|
}
|
|
],
|
|
"context_window": {
|
|
"provider": "anthropic",
|
|
"model": "claude-fable-5",
|
|
"context_window_tokens": 1000000,
|
|
"input_tokens": 388125,
|
|
"usage_percent": 38.8125,
|
|
"count_method": "response_usage_scaled_breakdown",
|
|
"staleness": "live",
|
|
"generated_at": "2026-07-11T21:49:04.863253607Z",
|
|
"event_seq": 1161,
|
|
"breakdown": [
|
|
{
|
|
"category": "system_prompt",
|
|
"tokens": 2116,
|
|
"usage_percent": 0.2116
|
|
},
|
|
{
|
|
"category": "tools",
|
|
"tokens": 2437,
|
|
"usage_percent": 0.2437
|
|
},
|
|
{
|
|
"category": "skills",
|
|
"tokens": 282,
|
|
"usage_percent": 0.0282
|
|
},
|
|
{
|
|
"category": "memory",
|
|
"tokens": 5180,
|
|
"usage_percent": 0.518
|
|
},
|
|
{
|
|
"category": "conversation",
|
|
"tokens": 378101,
|
|
"usage_percent": 37.8101
|
|
},
|
|
{
|
|
"category": "other",
|
|
"tokens": 9,
|
|
"usage_percent": 0.0009
|
|
}
|
|
],
|
|
"warnings": []
|
|
},
|
|
"state": "succeeded"
|
|
},
|
|
"simplify_gpt@1": {
|
|
"first_event_seq": 1171,
|
|
"prompt": null,
|
|
"response": null,
|
|
"completion": {
|
|
"outcome": "failed",
|
|
"notes": null,
|
|
"failure_reason": "LLM error: Authentication error for openai: Your authentication token has been invalidated. Please try signing in again.",
|
|
"timestamp": "2026-07-11T21:49:09.227779517Z"
|
|
},
|
|
"provider_used": {
|
|
"mode": "agent",
|
|
"provider": "openai",
|
|
"model": "gpt-5.5"
|
|
},
|
|
"diff": null,
|
|
"script_invocation": null,
|
|
"script_timing": null,
|
|
"parallel_results": null,
|
|
"output": null,
|
|
"started_at": "2026-07-11T21:49:08.664679772Z",
|
|
"handler": "agent",
|
|
"timing": {
|
|
"wall_time_ms": 563,
|
|
"inference_time_ms": 0,
|
|
"tool_time_ms": 0,
|
|
"active_time_ms": 0
|
|
},
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"cache_write_tokens": 0
|
|
},
|
|
"skills": {
|
|
"available": [
|
|
{
|
|
"name": "rust-style-guide",
|
|
"description": "Apply this Rust style guide when writing, reviewing, refactoring, or configuring Rust code for this project. Covers Rust 2024/MSRV, library vs application conventions, public API design, errors, panics, ownership and cloning, async/Tokio/concurrency, tracing, rustfmt/Clippy, testing with nextest, and unsafe/macro policy. Also use when setting up new Rust projects, investigating Rust performance, verifying library releases, or reviewing Rust code changes."
|
|
}
|
|
],
|
|
"activated": []
|
|
},
|
|
"permission_level": "full",
|
|
"agent_tools": [
|
|
{
|
|
"name": "apply_patch",
|
|
"description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "write",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "close_agent",
|
|
"description": "Close a running subagent that is no longer needed.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "glob",
|
|
"description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "grep",
|
|
"description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "read_file",
|
|
"description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "read",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "request_user_input",
|
|
"description": "Ask the human one or more questions and wait for their answers before continuing this stage.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "send_input",
|
|
"description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "shell",
|
|
"description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "shell",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "spawn_agent",
|
|
"description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "update_plan",
|
|
"description": "Update the multi-step plan for the current task. Submit the entire plan; existing steps are reconciled by exact step text.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "use_skill",
|
|
"description": "Load a skill's instructions by name. Call this when the user's request matches an available skill.",
|
|
"source": {
|
|
"kind": "skill"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "wait",
|
|
"description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "subagent",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "web_fetch",
|
|
"description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "web_search",
|
|
"description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "other",
|
|
"invoked": false
|
|
},
|
|
{
|
|
"name": "write_file",
|
|
"description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.",
|
|
"source": {
|
|
"kind": "native"
|
|
},
|
|
"category": "write",
|
|
"invoked": false
|
|
}
|
|
],
|
|
"state": "failed"
|
|
}
|
|
}
|
|
} |