mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
fabro(01KWFGXZ5P42QRWBYAPVEAXMX6): implement (succeeded)
Fabro-Run: 01KWFGXZ5P42QRWBYAPVEAXMX6 Fabro-Completed: 5 ⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
c1ec9ae813
commit
f8992b2f09
28 changed files with 1415 additions and 98 deletions
|
|
@ -36,8 +36,8 @@ Authorization = "Bearer {{ env.API_KEY }}"
|
|||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `url` | The endpoint to POST to. Must use `https://` unless `tls = "off"`. Supports `{{ env.NAME }}` interpolation. |
|
||||
| `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). |
|
||||
| `url` | The endpoint to POST to. Must use `https://` unless `tls = "off"`. Supports `{{ env.NAME }}` and `{{ secrets.NAME }}` interpolation. |
|
||||
| `headers` | Optional HTTP headers. Values support `{{ env.NAME }}` interpolation, scoped to the names in `allowed_env_vars`. A token for any other env var fails to resolve and the hook blocks (fail-closed). `{{ secrets.NAME }}` is not allowed in headers. |
|
||||
| `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. |
|
||||
| `tls` | TLS mode: `"verify"` (default), `"no_verify"`, or `"off"`. |
|
||||
|
||||
|
|
@ -134,6 +134,33 @@ sandbox = false
|
|||
| `timeout_ms` | Hook timeout in milliseconds. Default: `60000` (60s) for most types, `30000` (30s) for prompt hooks. |
|
||||
| `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). |
|
||||
|
||||
## Interpolation and secrets
|
||||
|
||||
Hook `command`, HTTP `url`, prompt `prompt`, and agent `prompt` fields can reference server-vault token secrets with `{{ secrets.NAME }}`. Add these values with `fabro secret set NAME ...`. Missing secrets, empty/non-token vault entries, and unsupported namespaces fail closed so the hook does not run with a partially resolved value.
|
||||
|
||||
```toml
|
||||
[[hooks]]
|
||||
event = "sandbox_ready"
|
||||
command = "test \"{{ secrets.DEPLOY_ENV }}\" = staging"
|
||||
blocking = true
|
||||
|
||||
[[hooks]]
|
||||
event = "run_failed"
|
||||
type = "http"
|
||||
url = "https://hooks.example.com/{{ secrets.WEBHOOK_PATH }}"
|
||||
tls = "verify"
|
||||
|
||||
[[hooks]]
|
||||
event = "stage_start"
|
||||
type = "prompt"
|
||||
prompt = "Block deployments to {{ secrets.RESTRICTED_ENV }}?"
|
||||
blocking = true
|
||||
```
|
||||
|
||||
HTTP hook headers intentionally support only allowlisted `{{ env.NAME }}` interpolation. A `{{ secrets.NAME }}` token in a header blocks the hook with guidance to use secret interpolation in a hook command, prompt, or URL instead; Fabro does not provide a separate outbound-header secret allowlist.
|
||||
|
||||
Resolved hook secrets are registered with the run's secret redactor. Fabro redacts those exact values from worker-side structured run surfaces such as events, `progress.jsonl`, and setup-error messages. Command output still crosses the sandbox boundary as process output, so treat sandbox-reemitted plaintext as best-effort: content-based redaction always runs, and worker-side event surfaces apply exact-match redaction after the output is captured.
|
||||
|
||||
## Blocking vs. non-blocking
|
||||
|
||||
Blocking hooks can affect workflow execution. Non-blocking hooks run for side effects only — their decisions are ignored.
|
||||
|
|
|
|||
|
|
@ -288,12 +288,14 @@ When `provider = "local"`, Fabro runs directly in the resolved working
|
|||
directory. If you want local isolation, create or enter a separate clone or Git
|
||||
worktree yourself.
|
||||
|
||||
Environment variable values can be literal strings or host environment
|
||||
references using `{{ env.VARNAME }}` syntax:
|
||||
Environment variable values can be literal strings, host environment references
|
||||
using `{{ env.VARNAME }}` syntax, or server-vault token secrets using
|
||||
`{{ secrets.NAME }}` syntax:
|
||||
|
||||
```toml title="run.toml"
|
||||
[environments.ci.env]
|
||||
API_KEY = "{{ env.MY_API_KEY }}"
|
||||
DEPLOY_TOKEN = "{{ secrets.DEPLOY_TOKEN }}"
|
||||
NODE_ENV = "production"
|
||||
SERVICE_URL = "https://api.{{ env.REGION }}.example.com"
|
||||
```
|
||||
|
|
@ -302,9 +304,14 @@ SERVICE_URL = "https://api.{{ env.REGION }}.example.com"
|
|||
|---|---|
|
||||
| `"literal"` | Static value passed as-is |
|
||||
| `"{{ env.VARNAME }}"` | Whole-value reference resolved from the host environment at consumption time |
|
||||
| `"prefix-{{ env.X }}-suffix"` | Substring interpolation; multiple tokens per string are supported |
|
||||
| `"{{ secrets.NAME }}"` | Reference resolved from the server vault as a token secret at the run boundary |
|
||||
| `"prefix-{{ env.X }}-{{ secrets.Y }}-suffix"` | Substring interpolation; multiple env and secret tokens per string are supported |
|
||||
|
||||
Missing host variables produce a hard error pointing at the specific field and unresolved token.
|
||||
Missing host variables or missing/non-token secrets produce a hard error pointing at the specific field and unresolved token.
|
||||
|
||||
Resolved declared secrets are registered with a per-run redactor. On worker-side structured surfaces — run events, `progress.jsonl`, the run store, SSE payloads derived from stored events, and setup-command errors — Fabro applies content-based redaction and then exact-match redaction for those declared secret values, even when the value is low entropy and does not look like a credential.
|
||||
|
||||
The boundary is the worker/sandbox handoff. If you put a secret into sandbox process environment or a command line, software inside the sandbox can still print it as plain text. Fabro applies content-based redaction to command output and exact-match redaction when captured output is embedded back into worker-side structured events, but avoid intentionally echoing secrets from sandbox commands.
|
||||
|
||||
### `[run.integrations.github.permissions]`
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Goal and prompt templates can reference:
|
|||
| `{{ goal }}` | The workflow goal |
|
||||
| `{{ inputs.name }}` | A value from `[run.inputs]`, optionally overridden by CLI input flags |
|
||||
|
||||
Environment variables are **not** available in goal or prompt templates. Use `{{ env.NAME }}` only in config strings and HTTP hook headers.
|
||||
Environment variables and secrets are **not** available in workflow graph goal or prompt templates. Use `{{ env.NAME }}` and `{{ secrets.NAME }}` only in supported run configuration and hook fields.
|
||||
|
||||
## Run config inputs
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use fabro_interview::{
|
|||
WorkerControlMessage,
|
||||
};
|
||||
use fabro_model::Catalog;
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_server::run_tool_manifest;
|
||||
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
||||
use fabro_tool::fabro_client::ClientBackend;
|
||||
|
|
@ -26,7 +27,7 @@ use fabro_types::{
|
|||
};
|
||||
use fabro_vault::Vault;
|
||||
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
|
||||
use fabro_workflow::event::{Emitter, RunEventSink};
|
||||
use fabro_workflow::event::{Emitter, RunEventSink, build_redacted_event_payload_with_redactor};
|
||||
use fabro_workflow::operations::{self, StartServices};
|
||||
use fabro_workflow::run_control::RunControlState;
|
||||
use fabro_workflow::runtime_store::{RunStoreBackend, RunStoreHandle};
|
||||
|
|
@ -1006,6 +1007,22 @@ impl RunStoreBackend for HttpRunStore {
|
|||
}
|
||||
|
||||
async fn append_run_event(&self, event: &RunEvent) -> Result<()> {
|
||||
self.append_run_event_with_redactor(event, None).await
|
||||
}
|
||||
|
||||
async fn append_run_event_with_redactor(
|
||||
&self,
|
||||
event: &RunEvent,
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> Result<()> {
|
||||
let event = if let Some(redactor) = redactor {
|
||||
let payload =
|
||||
build_redacted_event_payload_with_redactor(event, &self.run_id, Some(redactor))
|
||||
.context("failed to build redacted run event payload")?;
|
||||
RunEvent::try_from(&payload).context("redacted run event payload is invalid")?
|
||||
} else {
|
||||
event.clone()
|
||||
};
|
||||
let seq = Box::pin(self.with_retries("append run event", || {
|
||||
let client = self.client.clone_for_reuse();
|
||||
let run_id = self.run_id;
|
||||
|
|
@ -1013,7 +1030,7 @@ impl RunStoreBackend for HttpRunStore {
|
|||
async move { client.append_run_event(&run_id, &event).await }
|
||||
}))
|
||||
.await?;
|
||||
self.apply_acknowledged_event(seq, event).await
|
||||
self.apply_acknowledged_event(seq, &event).await
|
||||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ mod tests {
|
|||
execution_context: &HookExecutionContext,
|
||||
_llm_source: &dyn fabro_auth::CredentialSource,
|
||||
_catalog: Arc<Catalog>,
|
||||
_secrets: &crate::ResolvedHookSecrets,
|
||||
) -> HookResult {
|
||||
self.captured_contexts.lock().unwrap().push(context.clone());
|
||||
self.captured_execution_contexts
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use fabro_llm::generate::{GenerateParams, generate_object};
|
|||
use fabro_llm::types::{Message, Request, ToolResult};
|
||||
use fabro_model::Catalog;
|
||||
use fabro_redact::redacted_url_for_log;
|
||||
use fabro_types::settings::interp::Namespace;
|
||||
use fabro_types::settings::interp::{Namespace, ResolveCtx};
|
||||
use fabro_types::settings::{InterpString, ResolveError};
|
||||
use fabro_util::env::{Env, SystemEnv};
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
|
@ -21,6 +21,7 @@ use tokio::time::timeout as tokio_timeout;
|
|||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::{HookDefinition, HookType, TlsMode};
|
||||
use crate::secrets::ResolvedHookSecrets;
|
||||
use crate::types::{
|
||||
HookContext, HookDecision, HookExecutionContext, HookResult, PromptHookResponse,
|
||||
};
|
||||
|
|
@ -54,16 +55,18 @@ pub trait HookExecutor: Send + Sync {
|
|||
execution_context: &HookExecutionContext,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
) -> HookResult;
|
||||
}
|
||||
|
||||
/// Resolve a typed [`InterpString`] hook segment at fire time, looking up
|
||||
/// `{{ env.* }}` tokens against `env`.
|
||||
/// `{{ env.* }}` tokens against `env` and `{{ secrets.* }}` tokens against the
|
||||
/// run-scoped secret resolver.
|
||||
///
|
||||
/// Only the `env` namespace is wired here; `{{ secrets.* }}`, `{{ vars.* }}`,
|
||||
/// and `{{ inputs.* }}` tokens have no lookup in this context and resolve as
|
||||
/// `Unavailable`, which is a hard error — so a hook that references one fails
|
||||
/// closed rather than firing with a half-resolved value.
|
||||
/// Only `env` and `secrets` are wired here; `{{ vars.* }}` and `{{ inputs.* }}`
|
||||
/// tokens have no lookup in this context and resolve as `Unavailable`, which is
|
||||
/// a hard error — so a hook that references one fails closed rather than firing
|
||||
/// with a half-resolved value.
|
||||
///
|
||||
/// The value stays typed end-to-end: it is carried as an `InterpString`
|
||||
/// through the config resolve layer and resolved here from its segments —
|
||||
|
|
@ -73,13 +76,18 @@ pub trait HookExecutor: Send + Sync {
|
|||
///
|
||||
/// Returns the typed [`ResolveError`] so callers keep the source until the
|
||||
/// decision boundary renders it; do not flatten it to a `String` here.
|
||||
fn resolve_interp<E>(value: &InterpString, env: &E) -> Result<String, ResolveError>
|
||||
fn resolve_interp<E>(
|
||||
value: &InterpString,
|
||||
env: &E,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
) -> Result<String, ResolveError>
|
||||
where
|
||||
E: Env + ?Sized,
|
||||
{
|
||||
value
|
||||
.resolve(|name| env.var(name).ok())
|
||||
.map(|resolved| resolved.value)
|
||||
let mut ctx = ResolveCtx::new()
|
||||
.with_env(|name| env.var(name).ok())
|
||||
.with_secrets(|name| secrets.lookup(name));
|
||||
value.resolve_with(&mut ctx).map(|resolved| resolved.value)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
|
|
@ -95,6 +103,7 @@ fn safe_url_source_for_log(url: &InterpString) -> String {
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum HeaderResolveError {
|
||||
NotAllowed { name: String },
|
||||
SecretNotAllowed { name: String },
|
||||
Resolve(ResolveError),
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +115,11 @@ impl fmt::Display for HeaderResolveError {
|
|||
"environment variable {name:?} referenced by an HTTP hook header is not listed in \
|
||||
allowed_env_vars"
|
||||
),
|
||||
Self::SecretNotAllowed { name } => write!(
|
||||
f,
|
||||
"secret {name:?} referenced by an HTTP hook header is not allowed; use secret \
|
||||
interpolation in a hook command, prompt, or url instead"
|
||||
),
|
||||
Self::Resolve(error) => error.fmt(f),
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +128,7 @@ impl fmt::Display for HeaderResolveError {
|
|||
impl std::error::Error for HeaderResolveError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::NotAllowed { .. } => None,
|
||||
Self::NotAllowed { .. } | Self::SecretNotAllowed { .. } => None,
|
||||
Self::Resolve(error) => Some(error),
|
||||
}
|
||||
}
|
||||
|
|
@ -135,10 +149,17 @@ fn resolve_header<E>(
|
|||
value: &InterpString,
|
||||
allowed_env_vars: &[String],
|
||||
env: &E,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
) -> Result<String, HeaderResolveError>
|
||||
where
|
||||
E: Env + ?Sized,
|
||||
{
|
||||
if let Some(name) = value.names(Namespace::Secrets).into_iter().next() {
|
||||
return Err(HeaderResolveError::SecretNotAllowed {
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(name) = value.names(Namespace::Env).into_iter().find(|name| {
|
||||
!allowed_env_vars
|
||||
.iter()
|
||||
|
|
@ -149,7 +170,7 @@ where
|
|||
});
|
||||
}
|
||||
|
||||
resolve_interp(value, env).map_err(HeaderResolveError::Resolve)
|
||||
resolve_interp(value, env, secrets).map_err(HeaderResolveError::Resolve)
|
||||
}
|
||||
|
||||
/// Executes hooks via shell commands or HTTP POST.
|
||||
|
|
@ -181,20 +202,24 @@ impl HookExecutorImpl {
|
|||
|
||||
/// Resolve the prompt and optional model segments at fire time.
|
||||
///
|
||||
/// Fail-closed: only `{{ env.* }}` is wired here; a missing env token (or a
|
||||
/// token in any other, unavailable namespace) is a hard error so the hook
|
||||
/// never fires with a half-resolved value. The caller turns the error into
|
||||
/// a `Block` decision, matching the command-hook behavior.
|
||||
/// Fail-closed: only `{{ env.* }}` and `{{ secrets.* }}` are wired here; a
|
||||
/// missing token (or a token in any other, unavailable namespace) is a hard
|
||||
/// error so the hook never fires with a half-resolved value. The caller
|
||||
/// turns the error into a `Block` decision, matching the command-hook
|
||||
/// behavior.
|
||||
fn resolve_prompt_and_model<E>(
|
||||
prompt: &InterpString,
|
||||
model: Option<&InterpString>,
|
||||
env: &E,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
) -> Result<(String, Option<String>), ResolveError>
|
||||
where
|
||||
E: Env + ?Sized,
|
||||
{
|
||||
let prompt = resolve_interp(prompt, env)?;
|
||||
let model = model.map(|model| resolve_interp(model, env)).transpose()?;
|
||||
let prompt = resolve_interp(prompt, env, secrets)?;
|
||||
let model = model
|
||||
.map(|model| resolve_interp(model, env, secrets))
|
||||
.transpose()?;
|
||||
Ok((prompt, model))
|
||||
}
|
||||
|
||||
|
|
@ -206,11 +231,12 @@ impl HookExecutorImpl {
|
|||
sandbox: &Arc<dyn Sandbox>,
|
||||
execution_context: &HookExecutionContext,
|
||||
env: &E,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
) -> HookDecision
|
||||
where
|
||||
E: Env + ?Sized,
|
||||
{
|
||||
let command = match resolve_interp(command, env) {
|
||||
let command = match resolve_interp(command, env, secrets) {
|
||||
Ok(command) => command,
|
||||
Err(error) => {
|
||||
return HookDecision::Block {
|
||||
|
|
@ -363,13 +389,14 @@ impl HookExecutorImpl {
|
|||
model: Option<&InterpString>,
|
||||
context: &HookContext,
|
||||
env: &E,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> HookDecision
|
||||
where
|
||||
E: Env + ?Sized,
|
||||
{
|
||||
let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env) {
|
||||
let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env, secrets) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => {
|
||||
tracing::error!(error = %error, "prompt hook env resolution failed, not firing");
|
||||
|
|
@ -432,13 +459,14 @@ impl HookExecutorImpl {
|
|||
context: &HookContext,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
env: &E,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> HookDecision
|
||||
where
|
||||
E: Env + ?Sized,
|
||||
{
|
||||
let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env) {
|
||||
let (prompt, model) = match Self::resolve_prompt_and_model(prompt, model, env, secrets) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => {
|
||||
tracing::error!(error = %error, "agent hook env resolution failed, not firing");
|
||||
|
|
@ -578,11 +606,12 @@ impl HookExecutorImpl {
|
|||
context: &HookContext,
|
||||
timeout: std::time::Duration,
|
||||
env: &E,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
) -> HookDecision
|
||||
where
|
||||
E: Env + ?Sized,
|
||||
{
|
||||
let resolved_url = match resolve_interp(url, env) {
|
||||
let resolved_url = match resolve_interp(url, env, secrets) {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
|
|
@ -618,7 +647,7 @@ impl HookExecutorImpl {
|
|||
// `{{ env.NAME }}` not in `allowed_env_vars` blocks before any
|
||||
// lookup, while an allowlisted-but-unset name still fails as
|
||||
// missing.
|
||||
let interpolated = match resolve_header(value, allowed_env_vars, env) {
|
||||
let interpolated = match resolve_header(value, allowed_env_vars, env, secrets) {
|
||||
Ok(rendered) => rendered,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
|
|
@ -641,7 +670,11 @@ impl HookExecutorImpl {
|
|||
Err(e) => {
|
||||
tracing::warn!(
|
||||
url_source = %safe_url_source_for_log(url),
|
||||
error = %e,
|
||||
error_timeout = e.is_timeout(),
|
||||
error_connect = e.is_connect(),
|
||||
error_request = e.is_request(),
|
||||
error_body = e.is_body(),
|
||||
error_decode = e.is_decode(),
|
||||
"HTTP hook request failed, proceeding"
|
||||
);
|
||||
return HookDecision::Proceed;
|
||||
|
|
@ -662,7 +695,11 @@ impl HookExecutorImpl {
|
|||
Err(e) => {
|
||||
tracing::warn!(
|
||||
url_source = %safe_url_source_for_log(url),
|
||||
error = %e,
|
||||
error_timeout = e.is_timeout(),
|
||||
error_connect = e.is_connect(),
|
||||
error_request = e.is_request(),
|
||||
error_body = e.is_body(),
|
||||
error_decode = e.is_decode(),
|
||||
"HTTP hook body read failed, proceeding"
|
||||
);
|
||||
return HookDecision::Proceed;
|
||||
|
|
@ -728,6 +765,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
execution_context: &HookExecutionContext,
|
||||
llm_source: &dyn CredentialSource,
|
||||
catalog: Arc<Catalog>,
|
||||
secrets: &ResolvedHookSecrets,
|
||||
) -> HookResult {
|
||||
use std::sync::OnceLock;
|
||||
static HTTP_CLIENTS: OnceLock<HttpClientCache> = OnceLock::new();
|
||||
|
|
@ -747,6 +785,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
&sandbox,
|
||||
execution_context,
|
||||
&env,
|
||||
secrets,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -774,6 +813,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
context,
|
||||
definition.timeout(),
|
||||
&env,
|
||||
secrets,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -793,6 +833,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
model.as_ref(),
|
||||
context,
|
||||
&env,
|
||||
secrets,
|
||||
llm_source,
|
||||
Arc::clone(&catalog),
|
||||
)
|
||||
|
|
@ -818,6 +859,7 @@ impl HookExecutor for HookExecutorImpl {
|
|||
context,
|
||||
sandbox,
|
||||
&env,
|
||||
secrets,
|
||||
llm_source,
|
||||
Arc::clone(&catalog),
|
||||
)
|
||||
|
|
@ -869,6 +911,19 @@ mod tests {
|
|||
HookExecutorImpl::build_http_client(TlsMode::Off)
|
||||
}
|
||||
|
||||
fn empty_secrets() -> ResolvedHookSecrets {
|
||||
ResolvedHookSecrets::default()
|
||||
}
|
||||
|
||||
fn test_secrets(vars: &[(&str, &str)]) -> ResolvedHookSecrets {
|
||||
ResolvedHookSecrets::new(
|
||||
vars.iter()
|
||||
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
|
||||
.collect(),
|
||||
fabro_redact::SecretRedactor::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_definition(command: &str) -> HookDefinition {
|
||||
HookDefinition {
|
||||
name: Some("test-hook".into()),
|
||||
|
|
@ -946,6 +1001,7 @@ mod tests {
|
|||
let ctx = make_context();
|
||||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let secrets = empty_secrets();
|
||||
let result = executor
|
||||
.execute(
|
||||
&def,
|
||||
|
|
@ -954,6 +1010,7 @@ mod tests {
|
|||
&HookExecutionContext::default(),
|
||||
source.as_ref(),
|
||||
test_catalog(),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.decision, HookDecision::Proceed);
|
||||
|
|
@ -967,6 +1024,7 @@ mod tests {
|
|||
let ctx = make_context();
|
||||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let secrets = empty_secrets();
|
||||
let result = executor
|
||||
.execute(
|
||||
&def,
|
||||
|
|
@ -975,6 +1033,7 @@ mod tests {
|
|||
&HookExecutionContext::default(),
|
||||
source.as_ref(),
|
||||
test_catalog(),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result.decision, HookDecision::Block { .. }));
|
||||
|
|
@ -987,6 +1046,7 @@ mod tests {
|
|||
let ctx = make_context();
|
||||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let secrets = empty_secrets();
|
||||
let result = executor
|
||||
.execute(
|
||||
&def,
|
||||
|
|
@ -995,6 +1055,7 @@ mod tests {
|
|||
&HookExecutionContext::default(),
|
||||
source.as_ref(),
|
||||
test_catalog(),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result.decision, HookDecision::Block { .. }));
|
||||
|
|
@ -1007,6 +1068,7 @@ mod tests {
|
|||
let ctx = make_context();
|
||||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let secrets = empty_secrets();
|
||||
let result = executor
|
||||
.execute(
|
||||
&def,
|
||||
|
|
@ -1015,6 +1077,7 @@ mod tests {
|
|||
&HookExecutionContext::default(),
|
||||
source.as_ref(),
|
||||
test_catalog(),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.decision, HookDecision::Skip {
|
||||
|
|
@ -1031,6 +1094,7 @@ mod tests {
|
|||
ctx.node_id = Some("plan".into());
|
||||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let secrets = empty_secrets();
|
||||
let result = executor
|
||||
.execute(
|
||||
&def,
|
||||
|
|
@ -1039,6 +1103,7 @@ mod tests {
|
|||
&HookExecutionContext::default(),
|
||||
source.as_ref(),
|
||||
test_catalog(),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.decision, HookDecision::Proceed);
|
||||
|
|
@ -1060,6 +1125,7 @@ mod tests {
|
|||
let ctx = make_context();
|
||||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let secrets = empty_secrets();
|
||||
let result = executor
|
||||
.execute(
|
||||
&def,
|
||||
|
|
@ -1068,6 +1134,7 @@ mod tests {
|
|||
&HookExecutionContext::default(),
|
||||
source.as_ref(),
|
||||
test_catalog(),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result.decision, HookDecision::Block { .. }));
|
||||
|
|
@ -1188,6 +1255,7 @@ mod tests {
|
|||
&interp("Bearer {{ env.FABRO_TEST_KEY_1 }}"),
|
||||
&["FABRO_TEST_KEY_1".to_string()],
|
||||
&env,
|
||||
&empty_secrets(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, "Bearer secret123");
|
||||
|
|
@ -1203,6 +1271,7 @@ mod tests {
|
|||
&interp("prefix-{{ env.FABRO_TEST_KEY_3 }}-suffix"),
|
||||
&[],
|
||||
&env,
|
||||
&empty_secrets(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, HeaderResolveError::NotAllowed {
|
||||
|
|
@ -1217,11 +1286,12 @@ mod tests {
|
|||
&interp("prefix-{{ env.FABRO_TEST_KEY_3 }}-suffix"),
|
||||
&["FABRO_TEST_KEY_3".to_string()],
|
||||
&env,
|
||||
&empty_secrets(),
|
||||
)
|
||||
.unwrap_err();
|
||||
match err {
|
||||
HeaderResolveError::Resolve(error) => assert_eq!(error.name, "FABRO_TEST_KEY_3"),
|
||||
HeaderResolveError::NotAllowed { .. } => {
|
||||
HeaderResolveError::NotAllowed { .. } | HeaderResolveError::SecretNotAllowed { .. } => {
|
||||
panic!("expected missing token resolve error, got {err:?}")
|
||||
}
|
||||
}
|
||||
|
|
@ -1233,14 +1303,19 @@ mod tests {
|
|||
fn resolve_interp_resolves_embedded_token_from_typed_value() {
|
||||
let env = test_env(&[("FABRO_TEST_KEY_2", "val")]);
|
||||
let value = interp("x{{ env.FABRO_TEST_KEY_2 }}y");
|
||||
let result = resolve_interp(&value, &env).unwrap();
|
||||
let result = resolve_interp(&value, &env, &empty_secrets()).unwrap();
|
||||
assert_eq!(result, "xvaly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_interp_errors_on_missing_var() {
|
||||
let env = test_env(&[]);
|
||||
let err = resolve_interp(&interp("a{{ env.FABRO_TEST_NOEXIST }}-b"), &env).unwrap_err();
|
||||
let err = resolve_interp(
|
||||
&interp("a{{ env.FABRO_TEST_NOEXIST }}-b"),
|
||||
&env,
|
||||
&empty_secrets(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.name, "FABRO_TEST_NOEXIST");
|
||||
}
|
||||
|
||||
|
|
@ -1248,11 +1323,63 @@ mod tests {
|
|||
fn resolve_interp_without_tokens_passes_through() {
|
||||
let env = test_env(&[]);
|
||||
assert_eq!(
|
||||
resolve_interp(&interp("plain text"), &env).unwrap(),
|
||||
resolve_interp(&interp("plain text"), &env, &empty_secrets()).unwrap(),
|
||||
"plain text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_interp_resolves_secret_and_registers_value() {
|
||||
let env = test_env(&[]);
|
||||
let redactor = fabro_redact::SecretRedactor::default();
|
||||
let secrets = ResolvedHookSecrets::new(
|
||||
HashMap::from([("HOOK_TOKEN".to_string(), "staging".to_string())]),
|
||||
redactor.clone(),
|
||||
);
|
||||
|
||||
let resolved =
|
||||
resolve_interp(&interp("deploy {{ secrets.HOOK_TOKEN }}"), &env, &secrets).unwrap();
|
||||
|
||||
assert_eq!(resolved, "deploy staging");
|
||||
assert_eq!(redactor.redact_into("deploy staging"), "deploy REDACTED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prompt_and_model_resolves_secret_tokens() {
|
||||
let env = test_env(&[]);
|
||||
let secrets = test_secrets(&[("PROMPT_TOKEN", "staging")]);
|
||||
|
||||
let (prompt, model) = HookExecutorImpl::resolve_prompt_and_model(
|
||||
&interp("check {{ secrets.PROMPT_TOKEN }}"),
|
||||
Some(&interp("haiku")),
|
||||
&env,
|
||||
&secrets,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(prompt, "check staging");
|
||||
assert_eq!(model.as_deref(), Some("haiku"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rejects_secret_token_with_guidance() {
|
||||
let env = test_env(&[]);
|
||||
let err = resolve_header(
|
||||
&interp("Bearer {{ secrets.HOOK_TOKEN }}"),
|
||||
&[],
|
||||
&env,
|
||||
&test_secrets(&[("HOOK_TOKEN", "staging")]),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, HeaderResolveError::SecretNotAllowed {
|
||||
name: "HOOK_TOKEN".to_string(),
|
||||
});
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("HTTP hook header"));
|
||||
assert!(message.contains("command, prompt, or url"));
|
||||
}
|
||||
|
||||
// --- HTTP hook execution tests ---
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1278,6 +1405,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1307,6 +1435,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1334,6 +1463,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1353,6 +1483,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(1),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1388,6 +1519,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&env,
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1425,6 +1557,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&env,
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1442,6 +1575,46 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_hook_secret_header_blocks_without_firing() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let mock = server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST").path("/hook");
|
||||
then.status(200).body("");
|
||||
})
|
||||
.await;
|
||||
|
||||
let headers = HashMap::from([(
|
||||
"Authorization".to_string(),
|
||||
interp("Bearer {{ secrets.FABRO_TEST_TOKEN }}"),
|
||||
)]);
|
||||
|
||||
let client = test_http_client();
|
||||
let decision = HookExecutorImpl::execute_http(
|
||||
&client,
|
||||
&interp(&server.url("/hook")),
|
||||
Some(&headers),
|
||||
&[],
|
||||
&TlsMode::Off,
|
||||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&test_secrets(&[("FABRO_TEST_TOKEN", "staging")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(mock.calls_async().await, 0);
|
||||
match decision {
|
||||
HookDecision::Block { reason } => {
|
||||
let reason = reason.unwrap_or_default();
|
||||
assert!(reason.contains("HTTP hook header"));
|
||||
assert!(reason.contains("command, prompt, or url"));
|
||||
}
|
||||
other => panic!("expected Block on secret header token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_hook_resolves_url_before_dispatch() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
|
|
@ -1463,6 +1636,36 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&env,
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
mock.assert_async().await;
|
||||
assert_eq!(decision, HookDecision::Proceed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_hook_resolves_secret_url_before_dispatch() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let mock = server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST").path("/hook");
|
||||
then.status(200).body("");
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = test_http_client();
|
||||
let secrets = test_secrets(&[("FABRO_TEST_URL", &server.url("/hook"))]);
|
||||
let decision = HookExecutorImpl::execute_http(
|
||||
&client,
|
||||
&interp("{{ secrets.FABRO_TEST_URL }}"),
|
||||
None,
|
||||
&[],
|
||||
&TlsMode::Off,
|
||||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1490,6 +1693,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1534,6 +1738,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1557,6 +1762,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1575,6 +1781,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1601,6 +1808,7 @@ mod tests {
|
|||
&make_context(),
|
||||
std::time::Duration::from_secs(5),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1637,6 +1845,7 @@ mod tests {
|
|||
let ctx = make_context();
|
||||
let sandbox = make_sandbox();
|
||||
let source = test_llm_source();
|
||||
let secrets = empty_secrets();
|
||||
let result = executor
|
||||
.execute(
|
||||
&def,
|
||||
|
|
@ -1645,6 +1854,7 @@ mod tests {
|
|||
&HookExecutionContext::default(),
|
||||
source.as_ref(),
|
||||
test_catalog(),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -1663,12 +1873,57 @@ mod tests {
|
|||
&sandbox,
|
||||
&HookExecutionContext::default(),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(decision, HookDecision::Block { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_hook_resolves_secret_token() {
|
||||
let sandbox = make_sandbox();
|
||||
let decision = HookExecutorImpl::execute_command(
|
||||
&make_definition(r#"test "{{ secrets.HOOK_TOKEN }}" = "staging""#),
|
||||
&interp(r#"test "{{ secrets.HOOK_TOKEN }}" = "staging""#),
|
||||
&make_context(),
|
||||
&sandbox,
|
||||
&HookExecutionContext::default(),
|
||||
&test_env(&[]),
|
||||
&test_secrets(&[("HOOK_TOKEN", "staging")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(decision, HookDecision::Proceed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_hook_missing_secret_blocks() {
|
||||
let sandbox = make_sandbox();
|
||||
let decision = HookExecutorImpl::execute_command(
|
||||
&make_definition("echo {{ secrets.MISSING_HOOK_SECRET }}"),
|
||||
&interp("echo {{ secrets.MISSING_HOOK_SECRET }}"),
|
||||
&make_context(),
|
||||
&sandbox,
|
||||
&HookExecutionContext::default(),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match decision {
|
||||
HookDecision::Block { reason } => {
|
||||
assert!(
|
||||
reason
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("MISSING_HOOK_SECRET")),
|
||||
"block reason should name the missing secret, got: {reason:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Block on missing command secret, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-closed: a prompt hook with a missing token does not fire the LLM
|
||||
// call; it blocks with the resolution error, matching command hooks.
|
||||
#[tokio::test]
|
||||
|
|
@ -1679,6 +1934,7 @@ mod tests {
|
|||
None,
|
||||
&make_context(),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
test_llm_source().as_ref(),
|
||||
test_catalog(),
|
||||
)
|
||||
|
|
@ -1708,6 +1964,7 @@ mod tests {
|
|||
&make_context(),
|
||||
make_sandbox(),
|
||||
&test_env(&[]),
|
||||
&empty_secrets(),
|
||||
test_llm_source().as_ref(),
|
||||
test_catalog(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pub mod bridge;
|
|||
pub mod config;
|
||||
pub mod executor;
|
||||
pub mod runner;
|
||||
mod secrets;
|
||||
pub mod types;
|
||||
|
||||
pub use bridge::WorkflowToolHookCallback;
|
||||
|
|
@ -10,4 +11,5 @@ pub use config::{HookDefinition, HookSettings, HookType, TlsMode};
|
|||
// `InterpString`; constructing a hook definition requires it.
|
||||
pub use fabro_types::settings::InterpString;
|
||||
pub use runner::HookRunner;
|
||||
pub use secrets::{HookSecretResolver, ResolvedHookSecrets};
|
||||
pub use types::{HookContext, HookDecision, HookEvent, HookExecutionContext};
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ use fabro_auth::CredentialSource;
|
|||
#[cfg(test)]
|
||||
use fabro_auth::EnvCredentialSource;
|
||||
use fabro_model::Catalog;
|
||||
use fabro_redact::SecretRedactor;
|
||||
|
||||
use crate::config::{HookDefinition, HookSettings};
|
||||
use crate::executor::{HookExecutor, HookExecutorImpl};
|
||||
use crate::types::{HookContext, HookDecision, HookExecutionContext};
|
||||
use crate::secrets::HookSecretResolver;
|
||||
use crate::types::{HookContext, HookDecision, HookExecutionContext, HookResult};
|
||||
|
||||
/// Central orchestrator: filters matching hooks, executes them, merges
|
||||
/// decisions.
|
||||
|
|
@ -18,16 +20,53 @@ pub struct HookRunner {
|
|||
executor: Arc<dyn HookExecutor>,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
catalog: Arc<Catalog>,
|
||||
secrets: HookSecretResolver,
|
||||
/// Pre-compiled regexes keyed by matcher pattern string.
|
||||
compiled_matchers: HashMap<String, regex::Regex>,
|
||||
}
|
||||
|
||||
fn decision_label(decision: &HookDecision) -> &'static str {
|
||||
match decision {
|
||||
HookDecision::Proceed => "proceed",
|
||||
HookDecision::Skip { .. } => "skip",
|
||||
HookDecision::Block { .. } => "block",
|
||||
HookDecision::Override { .. } => "override",
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_hook_result(mut result: HookResult, redactor: &SecretRedactor) -> HookResult {
|
||||
result.decision = redact_hook_decision(result.decision, redactor);
|
||||
result
|
||||
}
|
||||
|
||||
fn redact_hook_decision(decision: HookDecision, redactor: &SecretRedactor) -> HookDecision {
|
||||
match decision {
|
||||
HookDecision::Skip { reason } => HookDecision::Skip {
|
||||
reason: reason.map(|reason| redactor.redact_into(&reason)),
|
||||
},
|
||||
HookDecision::Block { reason } => HookDecision::Block {
|
||||
reason: reason.map(|reason| redactor.redact_into(&reason)),
|
||||
},
|
||||
HookDecision::Proceed | HookDecision::Override { .. } => decision,
|
||||
}
|
||||
}
|
||||
|
||||
impl HookRunner {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
config: HookSettings,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
catalog: Arc<Catalog>,
|
||||
) -> Self {
|
||||
Self::new_with_secrets(config, llm_source, catalog, HookSecretResolver::default())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_secrets(
|
||||
config: HookSettings,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
catalog: Arc<Catalog>,
|
||||
secrets: HookSecretResolver,
|
||||
) -> Self {
|
||||
let compiled_matchers = Self::compile_matchers(&config);
|
||||
Self {
|
||||
|
|
@ -35,6 +74,7 @@ impl HookRunner {
|
|||
executor: Arc::new(HookExecutorImpl),
|
||||
llm_source,
|
||||
catalog,
|
||||
secrets,
|
||||
compiled_matchers,
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +88,7 @@ impl HookRunner {
|
|||
executor,
|
||||
llm_source: Arc::new(EnvCredentialSource::new()),
|
||||
catalog: Arc::new(Catalog::from_builtin().expect("default catalog should build")),
|
||||
secrets: HookSecretResolver::default(),
|
||||
compiled_matchers,
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +141,7 @@ impl HookRunner {
|
|||
|
||||
tracing::info!(
|
||||
event = %context.event,
|
||||
decision = ?decision,
|
||||
decision = decision_label(&decision),
|
||||
"Hooks complete"
|
||||
);
|
||||
|
||||
|
|
@ -151,6 +192,7 @@ impl HookRunner {
|
|||
event = %context.event,
|
||||
"Executing hook"
|
||||
);
|
||||
let secrets = self.secrets.resolve_for_definition(hook).await;
|
||||
let result = self
|
||||
.executor
|
||||
.execute(
|
||||
|
|
@ -160,12 +202,14 @@ impl HookRunner {
|
|||
execution_context,
|
||||
self.llm_source.as_ref(),
|
||||
Arc::clone(&self.catalog),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
let result = redact_hook_result(result, secrets.redactor());
|
||||
tracing::debug!(
|
||||
hook = %hook.effective_name(),
|
||||
duration_ms = result.duration_ms,
|
||||
decision = ?result.decision,
|
||||
decision = decision_label(&result.decision),
|
||||
"Hook complete"
|
||||
);
|
||||
|
||||
|
|
@ -176,7 +220,7 @@ impl HookRunner {
|
|||
tracing::error!(
|
||||
hook = %hook.effective_name(),
|
||||
event = %context.event,
|
||||
decision = ?merged,
|
||||
decision = decision_label(&merged),
|
||||
"Hook blocked execution"
|
||||
);
|
||||
return merged;
|
||||
|
|
@ -185,7 +229,7 @@ impl HookRunner {
|
|||
tracing::warn!(
|
||||
hook = %hook.effective_name(),
|
||||
event = %context.event,
|
||||
decision = ?result.decision,
|
||||
decision = decision_label(&result.decision),
|
||||
"Non-blocking hook returned non-proceed, ignoring"
|
||||
);
|
||||
}
|
||||
|
|
@ -206,6 +250,7 @@ impl HookRunner {
|
|||
event = %context.event,
|
||||
"Executing hook"
|
||||
);
|
||||
let secrets = self.secrets.resolve_for_definition(hook).await;
|
||||
let result = self
|
||||
.executor
|
||||
.execute(
|
||||
|
|
@ -215,19 +260,21 @@ impl HookRunner {
|
|||
execution_context,
|
||||
self.llm_source.as_ref(),
|
||||
Arc::clone(&self.catalog),
|
||||
&secrets,
|
||||
)
|
||||
.await;
|
||||
let result = redact_hook_result(result, secrets.redactor());
|
||||
tracing::debug!(
|
||||
hook = %hook.effective_name(),
|
||||
duration_ms = result.duration_ms,
|
||||
decision = ?result.decision,
|
||||
decision = decision_label(&result.decision),
|
||||
"Hook complete"
|
||||
);
|
||||
if !result.decision.is_proceed() {
|
||||
tracing::warn!(
|
||||
hook = %hook.effective_name(),
|
||||
event = %context.event,
|
||||
decision = ?result.decision,
|
||||
decision = decision_label(&result.decision),
|
||||
"Non-blocking hook failed, continuing"
|
||||
);
|
||||
}
|
||||
|
|
@ -259,6 +306,7 @@ mod tests {
|
|||
_execution_context: &HookExecutionContext,
|
||||
_llm_source: &dyn CredentialSource,
|
||||
_catalog: Arc<Catalog>,
|
||||
_secrets: &crate::ResolvedHookSecrets,
|
||||
) -> HookResult {
|
||||
HookResult {
|
||||
hook_name: definition.name.clone(),
|
||||
|
|
|
|||
155
lib/crates/fabro-hooks/src/secrets.rs
Normal file
155
lib/crates/fabro-hooks/src/secrets.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_types::settings::interp::Namespace;
|
||||
|
||||
use crate::config::{HookDefinition, HookType};
|
||||
|
||||
type SecretLookupFuture = Pin<Box<dyn Future<Output = Option<String>> + Send + 'static>>;
|
||||
type SecretLookup = dyn Fn(String) -> SecretLookupFuture + Send + Sync + 'static;
|
||||
|
||||
/// Per-run hook secret resolver.
|
||||
///
|
||||
/// The resolver is cheap to clone and must be constructed per run. It returns
|
||||
/// only token-shaped vault secrets supplied by the worker and shares the run's
|
||||
/// [`SecretRedactor`] so values resolved by hooks join the same redaction
|
||||
/// registry as run-boundary environment and prepare-step secrets.
|
||||
#[derive(Clone)]
|
||||
pub struct HookSecretResolver {
|
||||
lookup: Option<Arc<SecretLookup>>,
|
||||
redactor: SecretRedactor,
|
||||
}
|
||||
|
||||
impl HookSecretResolver {
|
||||
#[must_use]
|
||||
pub fn new(redactor: SecretRedactor) -> Self {
|
||||
Self {
|
||||
lookup: None,
|
||||
redactor,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_lookup<F, Fut>(redactor: SecretRedactor, lookup: F) -> Self
|
||||
where
|
||||
F: Fn(String) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Option<String>> + Send + 'static,
|
||||
{
|
||||
Self {
|
||||
lookup: Some(Arc::new(move |name| Box::pin(lookup(name)))),
|
||||
redactor,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn redactor(&self) -> &SecretRedactor {
|
||||
&self.redactor
|
||||
}
|
||||
|
||||
pub async fn resolve_for_definition(&self, definition: &HookDefinition) -> ResolvedHookSecrets {
|
||||
self.resolve_names(secret_names_for_definition(definition))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_names<I, S>(&self, names: I) -> ResolvedHookSecrets
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut values = HashMap::new();
|
||||
let Some(lookup) = self.lookup.as_ref() else {
|
||||
return ResolvedHookSecrets::new(values, self.redactor.clone());
|
||||
};
|
||||
|
||||
for name in names {
|
||||
let name = name.as_ref();
|
||||
if values.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = lookup(name.to_string()).await {
|
||||
values.insert(name.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedHookSecrets::new(values, self.redactor.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HookSecretResolver {
|
||||
fn default() -> Self {
|
||||
Self::new(SecretRedactor::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Secrets resolved for one hook firing.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ResolvedHookSecrets {
|
||||
values: HashMap<String, String>,
|
||||
redactor: SecretRedactor,
|
||||
}
|
||||
|
||||
impl ResolvedHookSecrets {
|
||||
#[must_use]
|
||||
pub fn new(values: HashMap<String, String>, redactor: SecretRedactor) -> Self {
|
||||
for value in values.values() {
|
||||
redactor.register(value);
|
||||
}
|
||||
Self { values, redactor }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn empty_with_redactor(redactor: SecretRedactor) -> Self {
|
||||
Self::new(HashMap::new(), redactor)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn lookup(&self, name: &str) -> Option<String> {
|
||||
let value = self.values.get(name).cloned();
|
||||
if let Some(value) = value.as_deref() {
|
||||
self.redactor.register(value);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn redactor(&self) -> &SecretRedactor {
|
||||
&self.redactor
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_names_for_definition(definition: &HookDefinition) -> Vec<String> {
|
||||
let Some(hook_type) = definition.resolved_hook_type() else {
|
||||
return Vec::new();
|
||||
};
|
||||
match hook_type.as_ref() {
|
||||
HookType::Command { command } => command
|
||||
.names(Namespace::Secrets)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
HookType::Http { url, .. } => url
|
||||
.names(Namespace::Secrets)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
HookType::Prompt { prompt, model } | HookType::Agent { prompt, model, .. } => {
|
||||
let mut names: Vec<String> = prompt
|
||||
.names(Namespace::Secrets)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
if let Some(model) = model {
|
||||
names.extend(
|
||||
model
|
||||
.names(Namespace::Secrets)
|
||||
.into_iter()
|
||||
.map(str::to_string),
|
||||
);
|
||||
}
|
||||
names
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ use std::fmt::Write as _;
|
|||
|
||||
#[cfg(feature = "docker")]
|
||||
use bollard::errors::Error as BollardError;
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_util::error::{collect_causes, render_with_causes};
|
||||
|
||||
use crate::ExecResult;
|
||||
|
|
@ -79,6 +80,13 @@ impl Error {
|
|||
default_redacted_output_tail(self)
|
||||
}
|
||||
|
||||
pub fn default_redacted_output_tail_with_redactor(
|
||||
&self,
|
||||
redactor: &SecretRedactor,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
default_redacted_output_tail_with_redactor(self, redactor)
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
pub fn docker_connect(source: BollardError) -> Self {
|
||||
Self::DockerConnect { source }
|
||||
|
|
@ -163,11 +171,28 @@ pub type Result<T> = std::result::Result<T, Error>;
|
|||
|
||||
pub fn default_redacted_output_tail(
|
||||
err: &(dyn std::error::Error + 'static),
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
default_redacted_output_tail_inner(err, None)
|
||||
}
|
||||
|
||||
pub fn default_redacted_output_tail_with_redactor(
|
||||
err: &(dyn std::error::Error + 'static),
|
||||
redactor: &SecretRedactor,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
default_redacted_output_tail_inner(err, Some(redactor))
|
||||
}
|
||||
|
||||
fn default_redacted_output_tail_inner(
|
||||
err: &(dyn std::error::Error + 'static),
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
let mut current = Some(err);
|
||||
while let Some(err) = current {
|
||||
if let Some(Error::Exec { result, .. }) = err.downcast_ref::<Error>() {
|
||||
return result.default_redacted_output_tail();
|
||||
return match redactor {
|
||||
Some(redactor) => result.default_redacted_output_tail_with_redactor(redactor),
|
||||
None => result.default_redacted_output_tail(),
|
||||
};
|
||||
}
|
||||
current = err.source();
|
||||
}
|
||||
|
|
@ -175,8 +200,26 @@ pub fn default_redacted_output_tail(
|
|||
}
|
||||
|
||||
pub fn display_for_log(err: &(dyn std::error::Error + 'static)) -> String {
|
||||
display_for_log_inner(err, None)
|
||||
}
|
||||
|
||||
pub fn display_for_log_with_redactor(
|
||||
err: &(dyn std::error::Error + 'static),
|
||||
redactor: &SecretRedactor,
|
||||
) -> String {
|
||||
display_for_log_inner(err, Some(redactor))
|
||||
}
|
||||
|
||||
fn display_for_log_inner(
|
||||
err: &(dyn std::error::Error + 'static),
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> String {
|
||||
let mut rendered = render_with_causes(&err.to_string(), &collect_causes(err));
|
||||
if let Some(tail) = default_redacted_output_tail(err) {
|
||||
let tail = match redactor {
|
||||
Some(redactor) => default_redacted_output_tail_with_redactor(err, redactor),
|
||||
None => default_redacted_output_tail(err),
|
||||
};
|
||||
if let Some(tail) = tail {
|
||||
append_tail_for_log(
|
||||
&mut rendered,
|
||||
"stderr",
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ pub mod test_support;
|
|||
pub use details::sandbox_details;
|
||||
#[cfg(feature = "docker")]
|
||||
pub use docker::{DockerSandbox, DockerSandboxOptions};
|
||||
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
|
||||
pub use error::{
|
||||
Error, Result, default_redacted_output_tail, default_redacted_output_tail_with_redactor,
|
||||
display_for_log, display_for_log_with_redactor,
|
||||
};
|
||||
pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};
|
||||
pub use local::LocalSandbox;
|
||||
#[cfg(feature = "daytona")]
|
||||
|
|
@ -57,7 +60,7 @@ pub use sandbox::{
|
|||
ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions, Sandbox, SandboxEvent,
|
||||
SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle,
|
||||
StdioProcessTermination, format_lines_numbered, git_push_via_exec, redacted_output_tail,
|
||||
setup_git_via_exec, shell_quote,
|
||||
redacted_output_tail_with_redactor, setup_git_via_exec, shell_quote,
|
||||
};
|
||||
pub use sandbox_spec::SandboxSpec;
|
||||
pub use terminal::{TerminalSession, TerminalSize, open_terminal_for_run};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use std::sync::Arc;
|
|||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_types::{CommandOutputStream, CommandTermination};
|
||||
use fabro_util::shell;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -572,10 +573,30 @@ impl ExecResult {
|
|||
redacted_output_tail(&self.stdout, &self.stderr, max_bytes_per_stream)
|
||||
}
|
||||
|
||||
pub fn redacted_output_tail_with_redactor(
|
||||
&self,
|
||||
max_bytes_per_stream: usize,
|
||||
redactor: &SecretRedactor,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
redacted_output_tail_with_redactor(
|
||||
&self.stdout,
|
||||
&self.stderr,
|
||||
max_bytes_per_stream,
|
||||
redactor,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn default_redacted_output_tail(&self) -> Option<fabro_types::ExecOutputTail> {
|
||||
self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)
|
||||
}
|
||||
|
||||
pub fn default_redacted_output_tail_with_redactor(
|
||||
&self,
|
||||
redactor: &SecretRedactor,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
self.redacted_output_tail_with_redactor(DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redactor)
|
||||
}
|
||||
|
||||
/// Converts host process output into the canonical full exec result.
|
||||
///
|
||||
/// This stores raw stdout/stderr. Callers must not log these fields
|
||||
|
|
@ -607,8 +628,29 @@ pub fn redacted_output_tail(
|
|||
stderr: &str,
|
||||
max_bytes_per_stream: usize,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream);
|
||||
let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream);
|
||||
redacted_output_tail_inner(stdout, stderr, max_bytes_per_stream, None)
|
||||
}
|
||||
|
||||
/// Build a redacted `ExecOutputTail` with a run-scoped exact-match secret
|
||||
/// redactor applied after the content-based redaction baseline.
|
||||
#[must_use]
|
||||
pub fn redacted_output_tail_with_redactor(
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
max_bytes_per_stream: usize,
|
||||
redactor: &SecretRedactor,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
redacted_output_tail_inner(stdout, stderr, max_bytes_per_stream, Some(redactor))
|
||||
}
|
||||
|
||||
fn redacted_output_tail_inner(
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
max_bytes_per_stream: usize,
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream, redactor);
|
||||
let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream, redactor);
|
||||
let tail = fabro_types::ExecOutputTail {
|
||||
stdout,
|
||||
stderr,
|
||||
|
|
@ -618,12 +660,19 @@ pub fn redacted_output_tail(
|
|||
(!tail.is_empty()).then_some(tail)
|
||||
}
|
||||
|
||||
fn redacted_tail(text: &str, max_bytes: usize) -> (Option<String>, bool) {
|
||||
fn redacted_tail(
|
||||
text: &str,
|
||||
max_bytes: usize,
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> (Option<String>, bool) {
|
||||
if text.is_empty() || max_bytes == 0 {
|
||||
return (None, !text.is_empty());
|
||||
}
|
||||
|
||||
let redacted = fabro_redact::redact_string(text);
|
||||
let mut redacted = fabro_redact::redact_string(text);
|
||||
if let Some(redactor) = redactor {
|
||||
redacted = redactor.redact_into(&redacted);
|
||||
}
|
||||
let sanitized = sanitize_exec_output(&redacted);
|
||||
let truncated = sanitized.len() > max_bytes;
|
||||
let start = if truncated {
|
||||
|
|
@ -1282,6 +1331,27 @@ mod tests {
|
|||
assert!(tail.stdout_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_result_redacts_registered_low_entropy_secret_before_taking_tail() {
|
||||
let redactor = SecretRedactor::default();
|
||||
redactor.register("staging");
|
||||
let result = ExecResult {
|
||||
stdout: format!("{} staging done", "context ".repeat(20)),
|
||||
stderr: String::new(),
|
||||
exit_code: Some(1),
|
||||
termination: CommandTermination::Exited,
|
||||
duration_ms: 1,
|
||||
};
|
||||
|
||||
let tail = result
|
||||
.redacted_output_tail_with_redactor(32, &redactor)
|
||||
.expect("redacted output tail");
|
||||
let stdout = tail.stdout.expect("stdout tail");
|
||||
assert!(stdout.contains("REDACTED"), "{stdout}");
|
||||
assert!(!stdout.contains("staging"), "{stdout}");
|
||||
assert!(tail.stdout_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_result_tail_sanitizes_terminal_control_sequences() {
|
||||
let result = ExecResult {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ pub use self::emitter::Emitter;
|
|||
pub use self::events::Event;
|
||||
pub use self::names::event_name;
|
||||
pub use self::redaction::{
|
||||
build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json,
|
||||
build_redacted_event_payload, build_redacted_event_payload_with_redactor,
|
||||
event_payload_from_redacted_json, redacted_event_json, redacted_event_json_with_redactor,
|
||||
};
|
||||
pub use self::sink::{
|
||||
RunEventLogger, RunEventSink, StoreProgressLogger, append_event, append_event_to_sink,
|
||||
|
|
|
|||
|
|
@ -1330,7 +1330,7 @@ impl Event {
|
|||
info!(command_count, "Setup started");
|
||||
}
|
||||
Self::SetupCommandStarted { command, index } => {
|
||||
debug!(command, index, "Setup command started");
|
||||
debug!(command_len = command.len(), index, "Setup command started");
|
||||
}
|
||||
Self::SetupCommandCompleted {
|
||||
command,
|
||||
|
|
@ -1339,7 +1339,7 @@ impl Event {
|
|||
duration_ms,
|
||||
} => {
|
||||
debug!(
|
||||
command,
|
||||
command_len = command.len(),
|
||||
index, exit_code, duration_ms, "Setup command completed"
|
||||
);
|
||||
}
|
||||
|
|
@ -1355,7 +1355,7 @@ impl Event {
|
|||
} => {
|
||||
let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref());
|
||||
error!(
|
||||
command,
|
||||
command_len = command.len(),
|
||||
index,
|
||||
exit_code,
|
||||
exec_output_tail_present = tail.present,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,32 @@
|
|||
use ::fabro_types::{RunEvent, RunId};
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_redact::redact_json_value;
|
||||
use fabro_redact::{SecretRedactor, redact_json_value};
|
||||
use fabro_store::EventPayload;
|
||||
use fabro_util::json::normalize_json_value;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn build_redacted_event_payload(event: &RunEvent, run_id: &RunId) -> Result<EventPayload> {
|
||||
let value = redacted_event_value(event)?;
|
||||
build_redacted_event_payload_with_redactor(event, run_id, None)
|
||||
}
|
||||
|
||||
pub fn build_redacted_event_payload_with_redactor(
|
||||
event: &RunEvent,
|
||||
run_id: &RunId,
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> Result<EventPayload> {
|
||||
let value = redacted_event_value(event, redactor)?;
|
||||
EventPayload::new(value, run_id).map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
pub fn redacted_event_json(event: &RunEvent) -> Result<String> {
|
||||
serde_json::to_string(&redacted_event_value(event)?).map_err(anyhow::Error::from)
|
||||
redacted_event_json_with_redactor(event, None)
|
||||
}
|
||||
|
||||
pub fn redacted_event_json_with_redactor(
|
||||
event: &RunEvent,
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> Result<String> {
|
||||
serde_json::to_string(&redacted_event_value(event, redactor)?).map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
fn normalized_event_value(event: &RunEvent) -> Result<Value> {
|
||||
|
|
@ -19,8 +34,90 @@ fn normalized_event_value(event: &RunEvent) -> Result<Value> {
|
|||
Ok(normalize_json_value(value))
|
||||
}
|
||||
|
||||
fn redacted_event_value(event: &RunEvent) -> Result<Value> {
|
||||
Ok(redact_json_value(normalized_event_value(event)?))
|
||||
fn redacted_event_value(event: &RunEvent, redactor: Option<&SecretRedactor>) -> Result<Value> {
|
||||
let mut value = redact_json_value(normalized_event_value(event)?);
|
||||
if let Some(redactor) = redactor {
|
||||
redact_event_payload_secrets(&mut value, redactor);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn redact_event_payload_secrets(value: &mut Value, redactor: &SecretRedactor) {
|
||||
if let Some(properties) = value.get_mut("properties") {
|
||||
redact_redactable_event_properties(properties, redactor);
|
||||
}
|
||||
if let Some(Value::String(label)) = value.get_mut("node_label") {
|
||||
let redacted = redactor.redact_into(label);
|
||||
if redacted != *label {
|
||||
*label = redacted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_redactable_event_properties(value: &mut Value, redactor: &SecretRedactor) {
|
||||
match value {
|
||||
Value::Object(obj) => {
|
||||
for (key, child) in obj {
|
||||
if is_secret_redactable_event_property(key) {
|
||||
*child = redactor.redact_json(std::mem::take(child));
|
||||
} else {
|
||||
redact_redactable_event_properties(child, redactor);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
redact_redactable_event_properties(item, redactor);
|
||||
}
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact-match secret values may be intentionally low entropy ("staging",
|
||||
// "pause", "running"). Redacting every string in an event can therefore corrupt
|
||||
// structural fields that are validated enum values or IDs. Keep this list to
|
||||
// free-form text/blob fields where replacing a matched substring preserves the
|
||||
// event schema and projection semantics.
|
||||
fn is_secret_redactable_event_property(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"active_form"
|
||||
| "answer"
|
||||
| "arguments"
|
||||
| "causes"
|
||||
| "command"
|
||||
| "context_display"
|
||||
| "delta"
|
||||
| "description"
|
||||
| "details"
|
||||
| "diff"
|
||||
| "error"
|
||||
| "error_message"
|
||||
| "exec_output_tail"
|
||||
| "failure"
|
||||
| "final_patch"
|
||||
| "goal"
|
||||
| "input"
|
||||
| "message"
|
||||
| "notes"
|
||||
| "output"
|
||||
| "preview"
|
||||
| "prompt"
|
||||
| "question"
|
||||
| "reason"
|
||||
| "response"
|
||||
| "script"
|
||||
| "stderr"
|
||||
| "stdout"
|
||||
| "subject"
|
||||
| "text"
|
||||
| "title"
|
||||
| "tool_input"
|
||||
| "tool_output"
|
||||
| "workflow_config"
|
||||
| "workflow_source"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result<EventPayload> {
|
||||
|
|
@ -30,7 +127,7 @@ pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result<Ev
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ::fabro_types::{fixtures, run_event as fabro_types};
|
||||
use ::fabro_types::{RunEvent, fixtures, run_event as fabro_types};
|
||||
|
||||
use super::*;
|
||||
use crate::event::{Event, to_run_event};
|
||||
|
|
@ -72,4 +169,89 @@ mod tests {
|
|||
"plain stderr"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_redacted_event_payload_redacts_registered_low_entropy_secret() {
|
||||
let redactor = fabro_redact::SecretRedactor::default();
|
||||
redactor.register("staging");
|
||||
let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed {
|
||||
command: "deploy staging".to_string(),
|
||||
index: 0,
|
||||
exit_code: 1,
|
||||
stderr: "failed in staging".to_string(),
|
||||
exec_output_tail: None,
|
||||
});
|
||||
|
||||
let payload =
|
||||
build_redacted_event_payload_with_redactor(&stored, &fixtures::RUN_8, Some(&redactor))
|
||||
.unwrap();
|
||||
let payload_text = serde_json::to_string(payload.as_value()).unwrap();
|
||||
|
||||
assert!(!payload_text.contains("staging"));
|
||||
assert!(payload_text.contains("REDACTED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_redacted_event_payload_redactors_are_isolated_per_run() {
|
||||
let first = fabro_redact::SecretRedactor::default();
|
||||
first.register("alpha");
|
||||
let second = fabro_redact::SecretRedactor::default();
|
||||
second.register("bravo");
|
||||
let stored = to_run_event(&fixtures::RUN_8, &Event::SetupCommandStarted {
|
||||
command: "echo alpha bravo".to_string(),
|
||||
index: 0,
|
||||
});
|
||||
|
||||
let first_payload =
|
||||
build_redacted_event_payload_with_redactor(&stored, &fixtures::RUN_8, Some(&first))
|
||||
.unwrap();
|
||||
let second_payload =
|
||||
build_redacted_event_payload_with_redactor(&stored, &fixtures::RUN_8, Some(&second))
|
||||
.unwrap();
|
||||
let first_text = serde_json::to_string(first_payload.as_value()).unwrap();
|
||||
let second_text = serde_json::to_string(second_payload.as_value()).unwrap();
|
||||
|
||||
assert!(!first_text.contains("alpha"));
|
||||
assert!(first_text.contains("bravo"));
|
||||
assert!(second_text.contains("alpha"));
|
||||
assert!(!second_text.contains("bravo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_redacted_event_payload_preserves_structural_event_fields() {
|
||||
let redactor = fabro_redact::SecretRedactor::default();
|
||||
redactor.register("setup.failed");
|
||||
let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed {
|
||||
command: "echo setup.failed".to_string(),
|
||||
index: 0,
|
||||
exit_code: 1,
|
||||
stderr: "setup.failed".to_string(),
|
||||
exec_output_tail: None,
|
||||
});
|
||||
|
||||
let payload =
|
||||
build_redacted_event_payload_with_redactor(&stored, &fixtures::RUN_8, Some(&redactor))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(payload.as_value()["event"], "setup.failed");
|
||||
assert_eq!(payload.as_value()["properties"]["command"], "echo REDACTED");
|
||||
assert_eq!(payload.as_value()["properties"]["stderr"], "REDACTED");
|
||||
let parsed = RunEvent::try_from(&payload).expect("redacted event remains parseable");
|
||||
assert_eq!(parsed.event_name(), "setup.failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_redacted_event_payload_preserves_structural_property_values() {
|
||||
let redactor = fabro_redact::SecretRedactor::default();
|
||||
redactor.register("pause");
|
||||
let stored = to_run_event(&fixtures::RUN_8, &Event::RunPauseRequested { actor: None });
|
||||
|
||||
let payload =
|
||||
build_redacted_event_payload_with_redactor(&stored, &fixtures::RUN_8, Some(&redactor))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(payload.as_value()["properties"]["action"], "pause");
|
||||
let parsed = RunEvent::try_from(&payload).expect("redacted event remains parseable");
|
||||
assert_eq!(parsed.event_name(), "run.pause.requested");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ use std::sync::Arc;
|
|||
|
||||
use ::fabro_types::{RunEvent, RunId};
|
||||
use anyhow::Result;
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_store::RunDatabase;
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot};
|
||||
|
||||
use super::emitter::Emitter;
|
||||
use super::redaction::{build_redacted_event_payload, redacted_event_json};
|
||||
use super::redaction::{
|
||||
build_redacted_event_payload, build_redacted_event_payload_with_redactor, redacted_event_json,
|
||||
redacted_event_json_with_redactor,
|
||||
};
|
||||
use super::{Event, to_run_event};
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
|
||||
|
|
@ -41,6 +45,10 @@ pub enum RunEventSink {
|
|||
transform: Arc<RunEventTransform>,
|
||||
inner: Box<Self>,
|
||||
},
|
||||
RedactSecrets {
|
||||
redactor: SecretRedactor,
|
||||
inner: Box<Self>,
|
||||
},
|
||||
Composite(Vec<Self>),
|
||||
}
|
||||
|
||||
|
|
@ -99,27 +107,60 @@ impl RunEventSink {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_secret_redactor(self, redactor: SecretRedactor) -> Self {
|
||||
Self::RedactSecrets {
|
||||
redactor,
|
||||
inner: Box::new(self),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> {
|
||||
let mut pending = vec![(self, event.clone())];
|
||||
while let Some((sink, event)) = pending.pop() {
|
||||
let mut pending = vec![(self, event.clone(), None::<SecretRedactor>)];
|
||||
while let Some((sink, event, redactor)) = pending.pop() {
|
||||
match sink {
|
||||
Self::Store(run_store) => {
|
||||
run_store.append_run_event(&event).await?;
|
||||
run_store
|
||||
.append_run_event_with_redactor(&event, redactor.as_ref())
|
||||
.await?;
|
||||
}
|
||||
Self::JsonLines(writer) => {
|
||||
let line = redacted_event_json(&event)?;
|
||||
let line = match redactor.as_ref() {
|
||||
Some(redactor) => {
|
||||
redacted_event_json_with_redactor(&event, Some(redactor))?
|
||||
}
|
||||
None => redacted_event_json(&event)?,
|
||||
};
|
||||
let mut writer = writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Self::Callback(callback) => callback(event).await?,
|
||||
Self::Callback(callback) => {
|
||||
let event = if let Some(redactor) = redactor.as_ref() {
|
||||
let payload = build_redacted_event_payload_with_redactor(
|
||||
&event,
|
||||
&event.run_id,
|
||||
Some(redactor),
|
||||
)?;
|
||||
RunEvent::try_from(&payload)?
|
||||
} else {
|
||||
event
|
||||
};
|
||||
callback(event).await?;
|
||||
}
|
||||
Self::Map { transform, inner } => {
|
||||
pending.push((inner.as_ref(), transform(event)));
|
||||
pending.push((inner.as_ref(), transform(event), redactor));
|
||||
}
|
||||
Self::RedactSecrets {
|
||||
redactor: sink_redactor,
|
||||
inner,
|
||||
} => {
|
||||
pending.push((inner.as_ref(), event, Some(sink_redactor.clone())));
|
||||
}
|
||||
Self::Composite(sinks) => {
|
||||
for sink in sinks.iter().rev() {
|
||||
pending.push((sink, event.clone()));
|
||||
pending.push((sink, event.clone(), redactor.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -334,6 +375,34 @@ mod tests {
|
|||
assert_eq!(second[0].actor, Some(user_principal("alice")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_event_sink_redacts_callback_events_with_run_secret_redactor() {
|
||||
let captured = Arc::new(AsyncMutex::new(Vec::new()));
|
||||
let captured_events = Arc::clone(&captured);
|
||||
let redactor = fabro_redact::SecretRedactor::default();
|
||||
redactor.register("staging");
|
||||
let sink = RunEventSink::callback(move |event| {
|
||||
let captured_events = Arc::clone(&captured_events);
|
||||
async move {
|
||||
captured_events.lock().await.push(event);
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.with_secret_redactor(redactor);
|
||||
let event = to_run_event(&fixtures::RUN_7, &Event::SetupCommandStarted {
|
||||
command: "deploy staging".to_string(),
|
||||
index: 0,
|
||||
});
|
||||
|
||||
sink.write_run_event(&event).await.unwrap();
|
||||
|
||||
let captured = captured.lock().await;
|
||||
assert_eq!(captured.len(), 1);
|
||||
let captured_text = serde_json::to_string(&captured[0].to_value().unwrap()).unwrap();
|
||||
assert!(!captured_text.contains("staging"));
|
||||
assert!(captured_text.contains("REDACTED"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_event_logger_registers_emitter_events_to_json_lines() {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
|
|
|||
|
|
@ -205,14 +205,20 @@ impl Handler for ParallelHandler {
|
|||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %fabro_sandbox::display_for_log(&e),
|
||||
error = %fabro_sandbox::display_for_log_with_redactor(
|
||||
&e,
|
||||
&services.run.secret_redactor,
|
||||
),
|
||||
"parallel base checkpoint failed"
|
||||
);
|
||||
services.run.emitter.notice_with_tail(
|
||||
RunNoticeLevel::Warn,
|
||||
RunNoticeCode::ParallelBaseCheckpointFailed,
|
||||
format!("Could not checkpoint base state before parallel branches: {e}"),
|
||||
fabro_sandbox::default_redacted_output_tail(&e),
|
||||
fabro_sandbox::default_redacted_output_tail_with_redactor(
|
||||
&e,
|
||||
&services.run.secret_redactor,
|
||||
),
|
||||
);
|
||||
None
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use fabro_core::lifecycle::RunLifecycle;
|
|||
use fabro_core::outcome::NodeResult;
|
||||
use fabro_core::state::ExecutionState;
|
||||
use fabro_dump::RunDump;
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase};
|
||||
use fabro_types::{CheckpointRecord, DiffSummary, RunDiff, RunId};
|
||||
use fabro_util::error::collect_causes;
|
||||
|
|
@ -84,6 +85,7 @@ pub(crate) struct GitLifecycle {
|
|||
pub sandbox_git: Arc<SandboxGitRuntime>,
|
||||
pub metadata_runtime: Arc<RunMetadataRuntime>,
|
||||
pub metadata_writer: Option<RunMetadataWriterHandle>,
|
||||
pub secret_redactor: SecretRedactor,
|
||||
pub start_node_id: Option<String>,
|
||||
// Cross-lifecycle data (shared with EventLifecycle)
|
||||
pub checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>>,
|
||||
|
|
@ -311,10 +313,16 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
Ok(()) => (true, None),
|
||||
Err(err) => {
|
||||
let exec_output_tail =
|
||||
fabro_sandbox::default_redacted_output_tail(&err);
|
||||
fabro_sandbox::default_redacted_output_tail_with_redactor(
|
||||
&err,
|
||||
&self.secret_redactor,
|
||||
);
|
||||
tracing::warn!(
|
||||
refspec = %refspec,
|
||||
error = %fabro_sandbox::display_for_log(&err),
|
||||
error = %fabro_sandbox::display_for_log_with_redactor(
|
||||
&err,
|
||||
&self.secret_redactor,
|
||||
),
|
||||
"git push from run lifecycle failed"
|
||||
);
|
||||
self.emitter.notice_with_tail(
|
||||
|
|
@ -365,7 +373,10 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
let exec_output_tail =
|
||||
fabro_sandbox::default_redacted_output_tail(&err);
|
||||
fabro_sandbox::default_redacted_output_tail_with_redactor(
|
||||
&err,
|
||||
&self.secret_redactor,
|
||||
);
|
||||
self.emitter.notice_with_tail(
|
||||
RunNoticeLevel::Warn,
|
||||
RunNoticeCode::GitDiffFailed,
|
||||
|
|
@ -380,7 +391,10 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
}
|
||||
Some(Err(err)) => {
|
||||
let exec_output_tail =
|
||||
fabro_sandbox::default_redacted_output_tail(&err);
|
||||
fabro_sandbox::default_redacted_output_tail_with_redactor(
|
||||
&err,
|
||||
&self.secret_redactor,
|
||||
);
|
||||
self.emitter.notice_with_tail(
|
||||
RunNoticeLevel::Warn,
|
||||
RunNoticeCode::GitDiffFailed,
|
||||
|
|
@ -399,7 +413,10 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
.expect("git lifecycle mutex should not be poisoned: no code panics while holding this lock") = Some(git_result);
|
||||
}
|
||||
Err(e) => {
|
||||
let exec_output_tail = fabro_sandbox::default_redacted_output_tail(&e);
|
||||
let exec_output_tail = fabro_sandbox::default_redacted_output_tail_with_redactor(
|
||||
&e,
|
||||
&self.secret_redactor,
|
||||
);
|
||||
let error = e.to_string();
|
||||
// Emit CheckpointFailed and return error
|
||||
let scope = stage_scope_for(state, node_id);
|
||||
|
|
@ -797,6 +814,7 @@ mod tests {
|
|||
sandbox_git: Arc::new(SandboxGitRuntime::new()),
|
||||
metadata_runtime,
|
||||
metadata_writer,
|
||||
secret_redactor: SecretRedactor::default(),
|
||||
start_node_id: Some("start".to_string()),
|
||||
checkpoint_git_result: Arc::new(Mutex::new(None)),
|
||||
last_git_sha: Arc::new(Mutex::new(None)),
|
||||
|
|
@ -1260,6 +1278,7 @@ mod tests {
|
|||
"claude-sonnet-4-6".to_string(),
|
||||
Arc::new(fabro_auth::EnvCredentialSource::new()),
|
||||
Arc::new(Catalog::from_builtin().expect("default catalog should build")),
|
||||
fabro_redact::SecretRedactor::default(),
|
||||
Arc::new(SandboxGitRuntime::new()),
|
||||
Arc::clone(&lifecycle.metadata_runtime),
|
||||
lifecycle.metadata_writer.clone(),
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ impl WorkflowLifecycle {
|
|||
sandbox_git: Arc<SandboxGitRuntime>,
|
||||
metadata_runtime: Arc<RunMetadataRuntime>,
|
||||
metadata_writer: Option<RunMetadataWriterHandle>,
|
||||
secret_redactor: fabro_redact::SecretRedactor,
|
||||
is_resume: bool,
|
||||
on_node: crate::OnNodeCallback,
|
||||
run_control: Option<Arc<RunControlState>>,
|
||||
|
|
@ -161,6 +162,7 @@ impl WorkflowLifecycle {
|
|||
sandbox_git,
|
||||
metadata_runtime,
|
||||
metadata_writer,
|
||||
secret_redactor,
|
||||
start_node_id,
|
||||
checkpoint_git_result: Arc::clone(&checkpoint_git_result),
|
||||
last_git_sha,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer};
|
|||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
use fabro_model::{Catalog, FallbackTarget, ProviderId};
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_sandbox::daytona::DaytonaConfig;
|
||||
use fabro_sandbox::from_environment::{
|
||||
daytona_config_from_environment, docker_config_from_environment_with_secrets,
|
||||
|
|
@ -79,6 +80,8 @@ struct RunSession {
|
|||
workflow_bundle: Option<Arc<WorkflowBundle>>,
|
||||
run_control: Option<Arc<RunControlState>>,
|
||||
vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
secret_redactor: SecretRedactor,
|
||||
hook_secrets: fabro_hooks::HookSecretResolver,
|
||||
catalog: Arc<Catalog>,
|
||||
fabro_run_tools: Option<FabroRunToolServices>,
|
||||
}
|
||||
|
|
@ -252,10 +255,13 @@ pub(super) async fn execute_persisted_run(
|
|||
};
|
||||
|
||||
bootstrap_guard.defuse();
|
||||
let terminal_event_sink = event_sink
|
||||
.clone()
|
||||
.with_secret_redactor(session.secret_redactor.clone());
|
||||
let mut completion_guard = DetachedRunCompletionGuard::arm(
|
||||
run_id,
|
||||
run_store.clone(),
|
||||
event_sink.clone(),
|
||||
terminal_event_sink.clone(),
|
||||
cancel_token,
|
||||
);
|
||||
let run_start = Instant::now();
|
||||
|
|
@ -270,7 +276,7 @@ pub(super) async fn execute_persisted_run(
|
|||
persist_terminal_engine_failure(
|
||||
run_id,
|
||||
&run_store,
|
||||
&event_sink,
|
||||
&terminal_event_sink,
|
||||
run_dir,
|
||||
&err,
|
||||
run_start.elapsed(),
|
||||
|
|
@ -377,17 +383,21 @@ impl RunSession {
|
|||
Some(vault) => Some(vault.read().await),
|
||||
None => None,
|
||||
};
|
||||
let secret_redactor = SecretRedactor::default();
|
||||
let hook_secrets = hook_secret_resolver(services.vault.clone(), secret_redactor.clone());
|
||||
// Token-only secrets lookup over the vault read guard, shared across
|
||||
// every run-boundary resolver. A missing or non-Token secret becomes
|
||||
// `None`, so resolution fails closed with a secret error.
|
||||
let secret_lookup = |name: &str| vault_token_lookup(vault_guard.as_deref(), name);
|
||||
let secret_lookup = |name: &str| {
|
||||
registered_vault_token_lookup(vault_guard.as_deref(), &secret_redactor, name)
|
||||
};
|
||||
let mcp_servers = resolved
|
||||
.agent
|
||||
.mcps
|
||||
.iter()
|
||||
.map(|(key, entry)| match entry {
|
||||
ResolvedMcpEntry::Resolved(server) => {
|
||||
runtime_mcp_server(server, process_env_var, secret_lookup)
|
||||
runtime_mcp_server(server, process_env_var, |name| secret_lookup(name))
|
||||
}
|
||||
// References must be resolved to concrete servers before the run
|
||||
// spec is persisted (server-side run-preparation pass). Reaching
|
||||
|
|
@ -419,7 +429,7 @@ impl RunSession {
|
|||
SandboxSpec::Local { working_directory }
|
||||
}
|
||||
SandboxProviderKind::Docker => SandboxSpec::Docker {
|
||||
config: resolve_docker_config(resolved, secret_lookup)?,
|
||||
config: resolve_docker_config(resolved, |name| secret_lookup(name))?,
|
||||
github_app: services.github_app.clone(),
|
||||
run_id: Some(record.run_id),
|
||||
clone_origin_url: record.repo_origin_url().map(str::to_string),
|
||||
|
|
@ -443,7 +453,7 @@ impl RunSession {
|
|||
|
||||
let toml_env = resolved
|
||||
.environment
|
||||
.resolve_env(process_env_var, secret_lookup)
|
||||
.resolve_env(process_env_var, |name| secret_lookup(name))
|
||||
.map_err(|err| Error::engine_with_source("failed to resolve run environment", err))?;
|
||||
let github_permissions: Option<HashMap<String, String>> =
|
||||
(!services.github_permissions.is_empty()).then(|| services.github_permissions.clone());
|
||||
|
|
@ -461,8 +471,9 @@ impl RunSession {
|
|||
};
|
||||
|
||||
let pr_config = resolved.pull_request.clone();
|
||||
let setup_commands =
|
||||
runtime_setup_commands(&resolved.prepare, process_env_var, secret_lookup)?;
|
||||
let setup_commands = runtime_setup_commands(&resolved.prepare, process_env_var, |name| {
|
||||
secret_lookup(name)
|
||||
})?;
|
||||
drop(vault_guard);
|
||||
|
||||
Ok(Self {
|
||||
|
|
@ -505,6 +516,8 @@ impl RunSession {
|
|||
workflow_path,
|
||||
workflow_bundle,
|
||||
vault: services.vault,
|
||||
secret_redactor,
|
||||
hook_secrets,
|
||||
catalog,
|
||||
fabro_run_tools: services.fabro_run_tools,
|
||||
})
|
||||
|
|
@ -566,6 +579,34 @@ fn vault_token_lookup(vault: Option<&Vault>, name: &str) -> Option<String> {
|
|||
vault.and_then(|vault| fabro_auth::vault_get_token(vault, name).ok().flatten())
|
||||
}
|
||||
|
||||
fn registered_vault_token_lookup(
|
||||
vault: Option<&Vault>,
|
||||
redactor: &SecretRedactor,
|
||||
name: &str,
|
||||
) -> Option<String> {
|
||||
let value = vault_token_lookup(vault, name);
|
||||
if let Some(value) = value.as_deref() {
|
||||
redactor.register(value);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn hook_secret_resolver(
|
||||
vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
redactor: SecretRedactor,
|
||||
) -> fabro_hooks::HookSecretResolver {
|
||||
match vault {
|
||||
Some(vault) => fabro_hooks::HookSecretResolver::with_lookup(redactor, move |name| {
|
||||
let vault = Arc::clone(&vault);
|
||||
async move {
|
||||
let guard = vault.read().await;
|
||||
vault_token_lookup(Some(&guard), &name)
|
||||
}
|
||||
}),
|
||||
None => fabro_hooks::HookSecretResolver::new(redactor),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_accepted_run_definition(
|
||||
run_store: &RunStoreHandle,
|
||||
blob_id: fabro_types::RunBlobId,
|
||||
|
|
@ -827,7 +868,11 @@ impl RunSession {
|
|||
});
|
||||
}
|
||||
|
||||
let store_progress_logger = RunEventLogger::new(self.event_sink.clone());
|
||||
let store_progress_logger = RunEventLogger::new(
|
||||
self.event_sink
|
||||
.clone()
|
||||
.with_secret_redactor(self.secret_redactor.clone()),
|
||||
);
|
||||
store_progress_logger.register(self.emitter.as_ref());
|
||||
|
||||
let init_options = InitOptions {
|
||||
|
|
@ -845,8 +890,10 @@ impl RunSession {
|
|||
workflow_path: self.workflow_path,
|
||||
workflow_bundle: self.workflow_bundle,
|
||||
hooks: self.hooks,
|
||||
hook_secrets: self.hook_secrets,
|
||||
sandbox_env: self.sandbox_env,
|
||||
vault: self.vault,
|
||||
secret_redactor: self.secret_redactor,
|
||||
git: self.git,
|
||||
registry_override: self.registry_override,
|
||||
artifact_sink: self.artifact_sink,
|
||||
|
|
@ -855,7 +902,13 @@ impl RunSession {
|
|||
seed_context: self.seed_context,
|
||||
fabro_run_tools: self.fabro_run_tools,
|
||||
};
|
||||
let mut initialized = Box::pin(pipeline::initialize(persisted, init_options)).await?;
|
||||
let mut initialized = match Box::pin(pipeline::initialize(persisted, init_options)).await {
|
||||
Ok(initialized) => initialized,
|
||||
Err(err) => {
|
||||
store_progress_logger.flush().await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
initialized.on_node = on_node;
|
||||
|
||||
let sandbox_for_cleanup = Arc::clone(&initialized.engine.run.sandbox);
|
||||
|
|
@ -1125,8 +1178,8 @@ mod tests {
|
|||
};
|
||||
use fabro_store::Database;
|
||||
use fabro_types::settings::run::{
|
||||
McpTransport as ResolvedMcpTransport, PreparedStep, PreparedStepRun, RunMode,
|
||||
RunPrepareSettings,
|
||||
HookDefinition, HookEvent, HookType, McpTransport as ResolvedMcpTransport, PreparedStep,
|
||||
PreparedStepRun, RunMode, RunPrepareSettings, TlsMode,
|
||||
};
|
||||
use fabro_types::settings::{InterpString, ModelRef};
|
||||
use fabro_types::{
|
||||
|
|
@ -1568,6 +1621,49 @@ reasoning = false
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_session_secret_redactors_are_isolated_between_runs() {
|
||||
async fn session_with_secret(secret_value: &str) -> RunSession {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
|
||||
let mut settings = settings_from_run_layer(RunLayer {
|
||||
execution: Some(RunExecutionLayer {
|
||||
mode: Some(RunMode::DryRun),
|
||||
..RunExecutionLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
});
|
||||
settings.run.environment.env.insert(
|
||||
"DEPLOY_ENV".to_string(),
|
||||
InterpString::parse("{{ secrets.DEPLOY_ENV }}"),
|
||||
);
|
||||
let (persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
let vault = Arc::new(AsyncRwLock::new(token_vault("DEPLOY_ENV", secret_value)));
|
||||
|
||||
RunSession::new(&persisted, StartServices {
|
||||
vault: Some(vault),
|
||||
..test_start_services(&store, &storage_root, emitter, registry).await
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
let first = session_with_secret("alpha").await;
|
||||
let second = session_with_secret("bravo").await;
|
||||
|
||||
assert_eq!(
|
||||
first.secret_redactor.redact_into("alpha bravo"),
|
||||
"REDACTED bravo"
|
||||
);
|
||||
assert_eq!(
|
||||
second.secret_redactor.redact_into("alpha bravo"),
|
||||
"alpha REDACTED"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_session_new_missing_secret_fails_startup() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
|
@ -1605,6 +1701,218 @@ reasoning = false
|
|||
assert!(err.causes()[0].contains("DEPLOY_TOKEN"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn setup_failure_redacts_low_entropy_secret_in_event_and_error() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, run_dir) = storage_root_and_run_dir(&temp);
|
||||
let mut settings = settings_from_run_layer(RunLayer {
|
||||
execution: Some(RunExecutionLayer {
|
||||
mode: Some(RunMode::DryRun),
|
||||
..RunExecutionLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
});
|
||||
settings.run.prepare = prepare_with_step(script_step(
|
||||
"echo {{ secrets.DEPLOY_ENV }} >&2; exit 7",
|
||||
HashMap::new(),
|
||||
));
|
||||
let (_persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
let vault = Arc::new(AsyncRwLock::new(token_vault("DEPLOY_ENV", "staging")));
|
||||
|
||||
let Err(err) = start(&run_dir, StartServices {
|
||||
vault: Some(vault),
|
||||
..test_start_services(&store, &storage_root, emitter, registry).await
|
||||
})
|
||||
.await
|
||||
else {
|
||||
panic!("setup failure should fail the run");
|
||||
};
|
||||
|
||||
let error_text = err.to_string();
|
||||
assert!(!error_text.contains("staging"));
|
||||
assert!(error_text.contains("REDACTED"));
|
||||
|
||||
let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();
|
||||
let events = run_store.list_events().await.unwrap();
|
||||
let events_text = serde_json::to_string(&events).unwrap();
|
||||
assert!(!events_text.contains("staging"));
|
||||
assert!(events_text.contains("REDACTED"));
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| event.event.event_name() == "setup.failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_command_resolves_secret_from_vault() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, run_dir) = storage_root_and_run_dir(&temp);
|
||||
let mut settings = settings_from_run_layer(RunLayer {
|
||||
execution: Some(RunExecutionLayer {
|
||||
mode: Some(RunMode::DryRun),
|
||||
..RunExecutionLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
});
|
||||
settings.run.hooks.push(sandbox_ready_command_hook(
|
||||
"test \"{{ secrets.HOOK_TOKEN }}\" = staging",
|
||||
));
|
||||
let (_persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
let vault = Arc::new(AsyncRwLock::new(token_vault("HOOK_TOKEN", "staging")));
|
||||
|
||||
start(&run_dir, StartServices {
|
||||
vault: Some(vault),
|
||||
..test_start_services(&store, &storage_root, emitter, registry).await
|
||||
})
|
||||
.await
|
||||
.expect("hook command should resolve secret and proceed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_missing_secret_fails_closed() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, run_dir) = storage_root_and_run_dir(&temp);
|
||||
let mut settings = settings_from_run_layer(RunLayer {
|
||||
execution: Some(RunExecutionLayer {
|
||||
mode: Some(RunMode::DryRun),
|
||||
..RunExecutionLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
});
|
||||
settings
|
||||
.run
|
||||
.hooks
|
||||
.push(sandbox_ready_command_hook("echo {{ secrets.HOOK_TOKEN }}"));
|
||||
let (_persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
let vault = Arc::new(AsyncRwLock::new(temp_vault(&[])));
|
||||
|
||||
let Err(err) = start(&run_dir, StartServices {
|
||||
vault: Some(vault),
|
||||
..test_start_services(&store, &storage_root, emitter, registry).await
|
||||
})
|
||||
.await
|
||||
else {
|
||||
panic!("missing hook secret should fail the run");
|
||||
};
|
||||
|
||||
assert!(err.to_string().contains("HOOK_TOKEN"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_http_url_resolves_secret_from_vault() {
|
||||
let server = httpmock::MockServer::start_async().await;
|
||||
let mock = server
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST").path("/hook");
|
||||
then.status(200).body("");
|
||||
})
|
||||
.await;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, run_dir) = storage_root_and_run_dir(&temp);
|
||||
let mut settings = settings_from_run_layer(RunLayer {
|
||||
execution: Some(RunExecutionLayer {
|
||||
mode: Some(RunMode::DryRun),
|
||||
..RunExecutionLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
});
|
||||
settings
|
||||
.run
|
||||
.hooks
|
||||
.push(sandbox_ready_http_hook("{{ secrets.HOOK_URL }}"));
|
||||
let (_persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
let vault = Arc::new(AsyncRwLock::new(token_vault(
|
||||
"HOOK_URL",
|
||||
&server.url("/hook"),
|
||||
)));
|
||||
|
||||
start(&run_dir, StartServices {
|
||||
vault: Some(vault),
|
||||
..test_start_services(&store, &storage_root, emitter, registry).await
|
||||
})
|
||||
.await
|
||||
.expect("HTTP hook URL should resolve secret and proceed");
|
||||
|
||||
mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_prompt_secret_resolver_resolves_from_vault_and_registers() {
|
||||
let redactor = SecretRedactor::default();
|
||||
let vault = Arc::new(AsyncRwLock::new(token_vault("PROMPT_TOKEN", "staging")));
|
||||
let resolver = hook_secret_resolver(Some(vault), redactor.clone());
|
||||
let hook = HookDefinition {
|
||||
name: Some("prompt-secret".to_string()),
|
||||
event: HookEvent::StageStart,
|
||||
command: None,
|
||||
hook_type: Some(HookType::Prompt {
|
||||
prompt: InterpString::parse("check {{ secrets.PROMPT_TOKEN }}"),
|
||||
model: None,
|
||||
}),
|
||||
matcher: None,
|
||||
blocking: Some(true),
|
||||
timeout_ms: None,
|
||||
sandbox: None,
|
||||
};
|
||||
|
||||
let secrets = resolver.resolve_for_definition(&hook).await;
|
||||
|
||||
assert_eq!(secrets.lookup("PROMPT_TOKEN").as_deref(), Some("staging"));
|
||||
assert_eq!(redactor.redact_into("deploy staging"), "deploy REDACTED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_block_reason_redacts_resolved_secret_in_error_and_events() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, run_dir) = storage_root_and_run_dir(&temp);
|
||||
let mut settings = settings_from_run_layer(RunLayer {
|
||||
execution: Some(RunExecutionLayer {
|
||||
mode: Some(RunMode::DryRun),
|
||||
..RunExecutionLayer::default()
|
||||
}),
|
||||
..RunLayer::default()
|
||||
});
|
||||
settings.run.hooks.push(sandbox_ready_command_hook(
|
||||
r#"printf '%s' '{"decision":"block","reason":"{{ secrets.HOOK_TOKEN }}"}'"#,
|
||||
));
|
||||
let (_persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
|
||||
let registry = Arc::new(test_registry());
|
||||
let vault = Arc::new(AsyncRwLock::new(token_vault("HOOK_TOKEN", "staging")));
|
||||
|
||||
let Err(err) = start(&run_dir, StartServices {
|
||||
vault: Some(vault),
|
||||
..test_start_services(&store, &storage_root, emitter, registry).await
|
||||
})
|
||||
.await
|
||||
else {
|
||||
panic!("blocking hook should fail the run");
|
||||
};
|
||||
|
||||
let error_text = err.to_string();
|
||||
assert!(!error_text.contains("staging"));
|
||||
assert!(error_text.contains("REDACTED"));
|
||||
|
||||
let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();
|
||||
let events_text = serde_json::to_string(&run_store.list_events().await.unwrap()).unwrap();
|
||||
assert!(!events_text.contains("staging"));
|
||||
assert!(events_text.contains("REDACTED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_docker_config_maps_environment_hints() {
|
||||
let settings = settings_from_run_layer(RunLayer {
|
||||
|
|
@ -1806,6 +2114,37 @@ reasoning = false
|
|||
}
|
||||
}
|
||||
|
||||
fn sandbox_ready_command_hook(command: &str) -> HookDefinition {
|
||||
HookDefinition {
|
||||
name: Some("sandbox-ready".to_string()),
|
||||
event: HookEvent::SandboxReady,
|
||||
command: Some(InterpString::parse(command)),
|
||||
hook_type: None,
|
||||
matcher: None,
|
||||
blocking: Some(true),
|
||||
timeout_ms: Some(5_000),
|
||||
sandbox: Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox_ready_http_hook(url: &str) -> HookDefinition {
|
||||
HookDefinition {
|
||||
name: Some("sandbox-ready-http".to_string()),
|
||||
event: HookEvent::SandboxReady,
|
||||
command: None,
|
||||
hook_type: Some(HookType::Http {
|
||||
url: InterpString::parse(url),
|
||||
headers: None,
|
||||
allowed_env_vars: Vec::new(),
|
||||
tls: TlsMode::Off,
|
||||
}),
|
||||
matcher: None,
|
||||
blocking: Some(true),
|
||||
timeout_ms: Some(5_000),
|
||||
sandbox: Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
use crate::test_support::{mark_run_running, test_usage};
|
||||
|
||||
async fn append_completed_stage(
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ pub async fn execute(init: Initialized) -> Executed {
|
|||
Arc::clone(&engine.run.sandbox_git),
|
||||
Arc::clone(&engine.run.metadata_runtime),
|
||||
engine.run.metadata_writer.clone(),
|
||||
engine.run.secret_redactor.clone(),
|
||||
checkpoint.is_some(),
|
||||
on_node,
|
||||
run_control,
|
||||
|
|
|
|||
|
|
@ -282,12 +282,14 @@ async fn execute_test_run_with_options(
|
|||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: HookSettings { hooks: vec![] },
|
||||
hook_secrets: fabro_hooks::HookSecretResolver::default(),
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
secret_redactor: fabro_redact::SecretRedactor::default(),
|
||||
git: git_options,
|
||||
run_control: None,
|
||||
registry_override,
|
||||
|
|
@ -345,12 +347,14 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
|
|||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: HookSettings { hooks: vec![] },
|
||||
hook_secrets: fabro_hooks::HookSecretResolver::default(),
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
secret_redactor: fabro_redact::SecretRedactor::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -416,12 +420,14 @@ async fn run_with_lifecycle(
|
|||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: HookSettings { hooks: vec![] },
|
||||
hook_secrets: fabro_hooks::HookSecretResolver::default(),
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
secret_redactor: fabro_redact::SecretRedactor::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: Some(Arc::new(registry)),
|
||||
|
|
|
|||
|
|
@ -615,8 +615,17 @@ pub async fn finalize(executed: Executed, options: &FinalizeOptions) -> Result<C
|
|||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %fabro_sandbox::display_for_log(&e), "Sandbox stop failed");
|
||||
let exec_output_tail = fabro_sandbox::default_redacted_output_tail(&e);
|
||||
tracing::warn!(
|
||||
error = %fabro_sandbox::display_for_log_with_redactor(
|
||||
&e,
|
||||
&services.secret_redactor,
|
||||
),
|
||||
"Sandbox stop failed"
|
||||
);
|
||||
let exec_output_tail = fabro_sandbox::default_redacted_output_tail_with_redactor(
|
||||
&e,
|
||||
&services.secret_redactor,
|
||||
);
|
||||
services.emitter.notice_with_tail(
|
||||
RunNoticeLevel::Warn,
|
||||
RunNoticeCode::SandboxCleanupFailed,
|
||||
|
|
@ -1021,6 +1030,7 @@ mod tests {
|
|||
"claude-sonnet-4-6".to_string(),
|
||||
Arc::new(fabro_auth::EnvCredentialSource::new()),
|
||||
Arc::new(Catalog::from_builtin().expect("default catalog should build")),
|
||||
fabro_redact::SecretRedactor::default(),
|
||||
Arc::new(SandboxGitRuntime::new()),
|
||||
metadata_runtime,
|
||||
metadata_writer,
|
||||
|
|
@ -1053,6 +1063,7 @@ mod tests {
|
|||
"claude-sonnet-4-6".to_string(),
|
||||
Arc::new(fabro_auth::EnvCredentialSource::new()),
|
||||
Arc::new(Catalog::from_builtin().expect("default catalog should build")),
|
||||
fabro_redact::SecretRedactor::default(),
|
||||
Arc::new(SandboxGitRuntime::new()),
|
||||
Arc::new(RunMetadataRuntime::new()),
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -286,10 +286,11 @@ pub async fn initialize(
|
|||
let hook_runner = if options.hooks.hooks.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Arc::new(HookRunner::new(
|
||||
Some(Arc::new(HookRunner::new_with_secrets(
|
||||
options.hooks.clone(),
|
||||
Arc::clone(&llm_source),
|
||||
Arc::clone(&catalog),
|
||||
options.hook_secrets.clone(),
|
||||
)))
|
||||
};
|
||||
|
||||
|
|
@ -551,7 +552,8 @@ pub async fn initialize(
|
|||
let duration_ms = crate::millis_u64(cmd_start.elapsed());
|
||||
if !result.is_success() {
|
||||
let exit_code = result.display_exit_code();
|
||||
let exec_output_tail = result.default_redacted_output_tail();
|
||||
let exec_output_tail =
|
||||
result.default_redacted_output_tail_with_redactor(&options.secret_redactor);
|
||||
options.emitter.emit(&Event::SetupFailed {
|
||||
command: command.clone(),
|
||||
index,
|
||||
|
|
@ -559,10 +561,11 @@ pub async fn initialize(
|
|||
stderr: result.stderr.clone(),
|
||||
exec_output_tail,
|
||||
});
|
||||
return Err(Error::engine(format!(
|
||||
let message = format!(
|
||||
"Setup command failed (exit code {}): {command}\n{}",
|
||||
exit_code, result.stderr,
|
||||
)));
|
||||
);
|
||||
return Err(Error::engine(options.secret_redactor.redact_into(&message)));
|
||||
}
|
||||
let exit_code = result.exit_code.unwrap_or(0);
|
||||
options.emitter.emit(&Event::SetupCommandCompleted {
|
||||
|
|
@ -603,6 +606,7 @@ pub async fn initialize(
|
|||
options.llm.model.clone(),
|
||||
Arc::clone(&llm_source),
|
||||
catalog,
|
||||
options.secret_redactor.clone(),
|
||||
sandbox_git,
|
||||
metadata_runtime,
|
||||
metadata_writer,
|
||||
|
|
@ -842,12 +846,14 @@ mod tests {
|
|||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
hook_secrets: fabro_hooks::HookSecretResolver::default(),
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
secret_redactor: fabro_redact::SecretRedactor::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -924,12 +930,14 @@ mod tests {
|
|||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
hook_secrets: fabro_hooks::HookSecretResolver::default(),
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
toml_env: HashMap::from([("TEST_KEY".to_string(), "value".to_string())]),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
secret_redactor: fabro_redact::SecretRedactor::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1122,12 +1130,14 @@ mod tests {
|
|||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
hook_secrets: fabro_hooks::HookSecretResolver::default(),
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: Some(vault),
|
||||
secret_redactor: fabro_redact::SecretRedactor::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1218,12 +1228,14 @@ mod tests {
|
|||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
hook_secrets: fabro_hooks::HookSecretResolver::default(),
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
secret_redactor: fabro_redact::SecretRedactor::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1361,12 +1373,14 @@ mod tests {
|
|||
workflow_path: None,
|
||||
workflow_bundle: None,
|
||||
hooks: fabro_hooks::HookSettings { hooks: vec![] },
|
||||
hook_secrets: fabro_hooks::HookSecretResolver::default(),
|
||||
sandbox_env: SandboxEnvSpec {
|
||||
toml_env: HashMap::new(),
|
||||
github_permissions: None,
|
||||
origin_url: None,
|
||||
},
|
||||
vault: None,
|
||||
secret_redactor: fabro_redact::SecretRedactor::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use fabro_graphviz::graph::Graph;
|
|||
use fabro_interview::Interviewer;
|
||||
use fabro_mcp::config::McpServerSettings;
|
||||
use fabro_model::{Catalog, FallbackTarget, ProviderId};
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_sandbox::SandboxSpec;
|
||||
use fabro_template::TemplateContext;
|
||||
use fabro_types::settings::run::{PullRequestSettings, RunModelControls};
|
||||
|
|
@ -263,8 +264,10 @@ pub struct InitOptions {
|
|||
pub workflow_path: Option<ManifestPath>,
|
||||
pub workflow_bundle: Option<Arc<WorkflowBundle>>,
|
||||
pub hooks: fabro_hooks::HookSettings,
|
||||
pub hook_secrets: fabro_hooks::HookSecretResolver,
|
||||
pub sandbox_env: SandboxEnvSpec,
|
||||
pub vault: Option<Arc<AsyncRwLock<Vault>>>,
|
||||
pub secret_redactor: SecretRedactor,
|
||||
pub git: Option<GitCheckpointOptions>,
|
||||
pub registry_override: Option<Arc<HandlerRegistry>>,
|
||||
pub artifact_sink: Option<ArtifactSink>,
|
||||
|
|
|
|||
|
|
@ -3,16 +3,25 @@ use std::sync::Arc;
|
|||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_store::{EventEnvelope, RunDatabase, RunProjection};
|
||||
use fabro_types::{RunBlobId, RunEvent};
|
||||
|
||||
use crate::event::build_redacted_event_payload;
|
||||
use crate::event::build_redacted_event_payload_with_redactor;
|
||||
|
||||
#[async_trait]
|
||||
pub trait RunStoreBackend: Send + Sync {
|
||||
async fn load_state(&self) -> Result<RunProjection>;
|
||||
async fn list_events(&self) -> Result<Vec<EventEnvelope>>;
|
||||
async fn append_run_event(&self, event: &RunEvent) -> Result<()>;
|
||||
async fn append_run_event_with_redactor(
|
||||
&self,
|
||||
event: &RunEvent,
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> Result<()> {
|
||||
let _ = redactor;
|
||||
self.append_run_event(event).await
|
||||
}
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId>;
|
||||
async fn read_blob(&self, id: &RunBlobId) -> Result<Option<Bytes>>;
|
||||
async fn read_run_log(&self) -> Result<Option<Vec<u8>>>;
|
||||
|
|
@ -46,6 +55,16 @@ impl RunStoreHandle {
|
|||
self.backend.append_run_event(event).await
|
||||
}
|
||||
|
||||
pub async fn append_run_event_with_redactor(
|
||||
&self,
|
||||
event: &RunEvent,
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> Result<()> {
|
||||
self.backend
|
||||
.append_run_event_with_redactor(event, redactor)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn write_blob(&self, data: &[u8]) -> Result<RunBlobId> {
|
||||
self.backend.write_blob(data).await
|
||||
}
|
||||
|
|
@ -83,7 +102,15 @@ impl RunStoreBackend for LocalRunStoreBackend {
|
|||
}
|
||||
|
||||
async fn append_run_event(&self, event: &RunEvent) -> Result<()> {
|
||||
let payload = build_redacted_event_payload(event, &event.run_id)?;
|
||||
self.append_run_event_with_redactor(event, None).await
|
||||
}
|
||||
|
||||
async fn append_run_event_with_redactor(
|
||||
&self,
|
||||
event: &RunEvent,
|
||||
redactor: Option<&SecretRedactor>,
|
||||
) -> Result<()> {
|
||||
let payload = build_redacted_event_payload_with_redactor(event, &event.run_id, redactor)?;
|
||||
self.run_store
|
||||
.append_event(&payload)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use fabro_auth::ResolvedCredentials;
|
|||
use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner};
|
||||
use fabro_interview::Interviewer;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_types::{ManifestPath, RunId};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
|
|
@ -103,6 +104,7 @@ pub struct RunServices {
|
|||
pub model: String,
|
||||
pub llm_source: Arc<dyn CredentialSource>,
|
||||
pub catalog: Arc<Catalog>,
|
||||
pub secret_redactor: SecretRedactor,
|
||||
pub(crate) sandbox_git: Arc<SandboxGitRuntime>,
|
||||
pub(crate) metadata_runtime: Arc<RunMetadataRuntime>,
|
||||
pub(crate) metadata_writer: Option<RunMetadataWriterHandle>,
|
||||
|
|
@ -122,6 +124,7 @@ impl RunServices {
|
|||
model: String,
|
||||
llm_source: Arc<dyn CredentialSource>,
|
||||
catalog: Arc<Catalog>,
|
||||
secret_redactor: SecretRedactor,
|
||||
sandbox_git: Arc<SandboxGitRuntime>,
|
||||
metadata_runtime: Arc<RunMetadataRuntime>,
|
||||
metadata_writer: Option<RunMetadataWriterHandle>,
|
||||
|
|
@ -137,6 +140,7 @@ impl RunServices {
|
|||
model,
|
||||
llm_source,
|
||||
catalog,
|
||||
secret_redactor,
|
||||
sandbox_git,
|
||||
metadata_runtime,
|
||||
metadata_writer,
|
||||
|
|
@ -337,6 +341,7 @@ impl EngineServices {
|
|||
"claude-sonnet-4-6".to_string(),
|
||||
Arc::new(StubCredentialSource),
|
||||
Arc::new(Catalog::from_builtin().expect("default catalog should build")),
|
||||
SecretRedactor::default(),
|
||||
Arc::new(SandboxGitRuntime::new()),
|
||||
Arc::new(RunMetadataRuntime::new()),
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use fabro_auth::{CredentialSource, EnvCredentialSource};
|
|||
use fabro_graphviz::graph::Graph as GvGraph;
|
||||
use fabro_interview::AutoApproveInterviewer;
|
||||
use fabro_model::Catalog;
|
||||
use fabro_redact::SecretRedactor;
|
||||
use fabro_store::{ArtifactStore, Database, RunProjection};
|
||||
use object_store::local::LocalFileSystem;
|
||||
|
||||
|
|
@ -235,6 +236,7 @@ async fn initialized(
|
|||
.llm_source
|
||||
.unwrap_or_else(|| Arc::new(EnvCredentialSource::new())),
|
||||
Arc::new(Catalog::from_builtin().expect("default catalog should build")),
|
||||
SecretRedactor::default(),
|
||||
Arc::new(SandboxGitRuntime::new()),
|
||||
Arc::new(RunMetadataRuntime::new()),
|
||||
None,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue