mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
parent
cad1fcc115
commit
d34cb7e587
6 changed files with 951 additions and 20 deletions
500
run.json
500
run.json
File diff suppressed because one or more lines are too long
257
stages/005-implement@1/diff.patch
Normal file
257
stages/005-implement@1/diff.patch
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
diff --git a/lib/crates/fabro-redact/src/lib.rs b/lib/crates/fabro-redact/src/lib.rs
|
||||
index 170cf7a81..d55f45f43 100644
|
||||
--- a/lib/crates/fabro-redact/src/lib.rs
|
||||
+++ b/lib/crates/fabro-redact/src/lib.rs
|
||||
@@ -8,9 +8,13 @@ mod entropy;
|
||||
mod gitleaks;
|
||||
mod jsonl;
|
||||
mod safe_url;
|
||||
+mod secret_registry;
|
||||
|
||||
pub use jsonl::{redact_json_value, redact_jsonl_line};
|
||||
pub use safe_url::{DisplaySafeUrl, DisplaySafeUrlError};
|
||||
+pub use secret_registry::SecretRedactor;
|
||||
+
|
||||
+pub(crate) const REDACTION_MARKER: &str = "REDACTED";
|
||||
|
||||
/// Redact a URL string for log or error output.
|
||||
///
|
||||
@@ -65,7 +69,7 @@ pub fn redact_string(s: &str) -> String {
|
||||
let mut prev = 0;
|
||||
for r in &merged {
|
||||
result.push_str(&s[prev..r.start]);
|
||||
- result.push_str("REDACTED");
|
||||
+ result.push_str(REDACTION_MARKER);
|
||||
prev = r.end;
|
||||
}
|
||||
result.push_str(&s[prev..]);
|
||||
diff --git a/lib/crates/fabro-redact/src/secret_registry.rs b/lib/crates/fabro-redact/src/secret_registry.rs
|
||||
new file mode 100644
|
||||
index 000000000..77879018e
|
||||
--- /dev/null
|
||||
+++ b/lib/crates/fabro-redact/src/secret_registry.rs
|
||||
@@ -0,0 +1,224 @@
|
||||
+use std::sync::{Arc, PoisonError, RwLock};
|
||||
+
|
||||
+use serde_json::Value;
|
||||
+
|
||||
+use crate::Region;
|
||||
+
|
||||
+/// Per-run registry of exact secret values to redact from strings and JSON.
|
||||
+///
|
||||
+/// This complements the crate's content-based redaction by redacting registered
|
||||
+/// values even when they do not look like credentials. Clones share the same
|
||||
+/// registry so callers can hand a redactor to another subsystem and continue to
|
||||
+/// register values through the original.
|
||||
+#[derive(Clone, Default)]
|
||||
+pub struct SecretRedactor {
|
||||
+ values: Arc<RwLock<Vec<String>>>,
|
||||
+}
|
||||
+
|
||||
+impl SecretRedactor {
|
||||
+ /// Register a secret value for exact substring redaction.
|
||||
+ ///
|
||||
+ /// Empty or whitespace-only values are ignored so an accidental empty
|
||||
+ /// registration cannot redact every output boundary.
|
||||
+ pub fn register(&self, value: impl Into<String>) {
|
||||
+ let value = value.into();
|
||||
+ if value.trim().is_empty() {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let mut values = self.values.write().unwrap_or_else(PoisonError::into_inner);
|
||||
+ if !values.iter().any(|registered| registered == &value) {
|
||||
+ values.push(value);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /// Return `true` when no secret values have been registered.
|
||||
+ pub fn is_empty(&self) -> bool {
|
||||
+ self.values
|
||||
+ .read()
|
||||
+ .unwrap_or_else(PoisonError::into_inner)
|
||||
+ .is_empty()
|
||||
+ }
|
||||
+
|
||||
+ /// Redact all registered secret values from `s`.
|
||||
+ pub fn redact_into(&self, s: &str) -> String {
|
||||
+ let values = self.registered_values_longest_first();
|
||||
+ redact_string_values(s, &values)
|
||||
+ }
|
||||
+
|
||||
+ /// Redact registered secret values from every JSON string leaf.
|
||||
+ ///
|
||||
+ /// Object keys are left unchanged.
|
||||
+ pub fn redact_json(&self, mut value: Value) -> Value {
|
||||
+ let values = self.registered_values_longest_first();
|
||||
+ if values.is_empty() {
|
||||
+ return value;
|
||||
+ }
|
||||
+
|
||||
+ redact_json_value(&mut value, &values);
|
||||
+ value
|
||||
+ }
|
||||
+
|
||||
+ fn registered_values_longest_first(&self) -> Vec<String> {
|
||||
+ let values = self.values.read().unwrap_or_else(PoisonError::into_inner);
|
||||
+ let mut values = values.clone();
|
||||
+ values.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
|
||||
+ values
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn redact_json_value(value: &mut Value, values: &[String]) {
|
||||
+ match value {
|
||||
+ Value::Object(obj) => {
|
||||
+ for child in obj.values_mut() {
|
||||
+ redact_json_value(child, values);
|
||||
+ }
|
||||
+ }
|
||||
+ Value::Array(arr) => {
|
||||
+ for child in arr {
|
||||
+ redact_json_value(child, values);
|
||||
+ }
|
||||
+ }
|
||||
+ Value::String(text) => {
|
||||
+ let redacted = redact_string_values(text, values);
|
||||
+ if redacted != *text {
|
||||
+ *text = redacted;
|
||||
+ }
|
||||
+ }
|
||||
+ _ => {}
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+fn redact_string_values(s: &str, values: &[String]) -> String {
|
||||
+ if values.is_empty() {
|
||||
+ return s.to_string();
|
||||
+ }
|
||||
+
|
||||
+ let mut regions = Vec::new();
|
||||
+ for value in values {
|
||||
+ for (start, _) in s.match_indices(value) {
|
||||
+ let end = start + value.len();
|
||||
+ if !regions
|
||||
+ .iter()
|
||||
+ .any(|region: &Region| regions_overlap(start, end, region))
|
||||
+ {
|
||||
+ regions.push(Region { start, end });
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if regions.is_empty() {
|
||||
+ return s.to_string();
|
||||
+ }
|
||||
+
|
||||
+ regions.sort_by_key(|region| region.start);
|
||||
+
|
||||
+ let mut result = String::with_capacity(s.len());
|
||||
+ let mut previous = 0;
|
||||
+ for region in ®ions {
|
||||
+ result.push_str(&s[previous..region.start]);
|
||||
+ result.push_str(crate::REDACTION_MARKER);
|
||||
+ previous = region.end;
|
||||
+ }
|
||||
+ result.push_str(&s[previous..]);
|
||||
+ result
|
||||
+}
|
||||
+
|
||||
+fn regions_overlap(start: usize, end: usize, region: &Region) -> bool {
|
||||
+ start < region.end && region.start < end
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use serde_json::json;
|
||||
+
|
||||
+ use super::SecretRedactor;
|
||||
+
|
||||
+ #[test]
|
||||
+ fn redacts_registered_low_entropy_value() {
|
||||
+ let redactor = SecretRedactor::default();
|
||||
+ redactor.register("staging");
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ crate::redact_string("deploy to staging"),
|
||||
+ "deploy to staging"
|
||||
+ );
|
||||
+ assert_eq!(
|
||||
+ redactor.redact_into("deploy to staging"),
|
||||
+ "deploy to REDACTED"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn ignores_empty_and_whitespace_values() {
|
||||
+ let redactor = SecretRedactor::default();
|
||||
+ redactor.register("");
|
||||
+ redactor.register(" ");
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ redactor.redact_into("deploy to staging"),
|
||||
+ "deploy to staging"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn redacts_overlapping_values_longest_first() {
|
||||
+ let redactor = SecretRedactor::default();
|
||||
+ redactor.register("abc");
|
||||
+ redactor.register("abcdef");
|
||||
+
|
||||
+ assert_eq!(redactor.redact_into("token=abcdef"), "token=REDACTED");
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn empty_registry_is_identity() {
|
||||
+ let redactor = SecretRedactor::default();
|
||||
+ let value = json!({
|
||||
+ "env": "staging",
|
||||
+ "items": ["staging", 42],
|
||||
+ });
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ redactor.redact_into("deploy to staging"),
|
||||
+ "deploy to staging"
|
||||
+ );
|
||||
+ assert_eq!(redactor.redact_json(value.clone()), value);
|
||||
+ assert!(redactor.is_empty());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn redact_json_redacts_nested_object_values_and_array_elements() {
|
||||
+ let redactor = SecretRedactor::default();
|
||||
+ redactor.register("staging");
|
||||
+ let value = json!({
|
||||
+ "environment": "staging",
|
||||
+ "items": [
|
||||
+ "keep",
|
||||
+ "deploy staging now"
|
||||
+ ],
|
||||
+ "staging": "object keys are not redacted",
|
||||
+ });
|
||||
+
|
||||
+ assert_eq!(
|
||||
+ redactor.redact_json(value),
|
||||
+ json!({
|
||||
+ "environment": "REDACTED",
|
||||
+ "items": [
|
||||
+ "keep",
|
||||
+ "deploy REDACTED now"
|
||||
+ ],
|
||||
+ "staging": "object keys are not redacted",
|
||||
+ })
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn clones_share_registered_values() {
|
||||
+ let redactor = SecretRedactor::default();
|
||||
+ let clone = redactor.clone();
|
||||
+
|
||||
+ redactor.register("staging");
|
||||
+
|
||||
+ assert_eq!(clone.redact_into("deploy to staging"), "deploy to REDACTED");
|
||||
+ }
|
||||
+}
|
||||
6
stages/005-implement@1/status.json
Normal file
6
stages/005-implement@1/status.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"outcome": "succeeded",
|
||||
"notes": "Stage completed: implement",
|
||||
"failure_reason": null,
|
||||
"timestamp": "2026-07-01T16:44:45.145464933Z"
|
||||
}
|
||||
180
stages/006-simplify_opus@1/prompt.md
Normal file
180
stages/006-simplify_opus@1/prompt.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
Goal: # Plan A — `SecretRedactor` in `fabro-redact`
|
||||
|
||||
**This is Plan A of three** (split for parallel execution):
|
||||
|
||||
- **Plan A (this file)** — add a per-run secret-value redactor to `fabro-redact`.
|
||||
Self-contained; touches only `fabro-redact/`. **Run in parallel with Plan B.**
|
||||
- **Plan B** — resolve `secrets.*` tokens at the run boundary. **Run in parallel
|
||||
with Plan A.**
|
||||
- **Plan C** — wire redaction across leak surfaces + hooks. **Run after A and B
|
||||
merge** (it consumes this crate's type and Plan B's lookup).
|
||||
|
||||
This plan is inert on its own: it adds a tested library primitive that Plan C
|
||||
wires up. Shipping it alone changes no behavior.
|
||||
|
||||
> **Token notation.** Interpolation tokens are written in this file without their
|
||||
> enclosing double curly braces, so the file is safe to pass directly as a
|
||||
> workflow goal (the goal templater would otherwise try to expand them). Read
|
||||
> `secrets.NAME`, `env.NAME`, and `secrets.*` as the double-curly-brace-wrapped
|
||||
> token form used everywhere else in the codebase, and write the real
|
||||
> double-brace syntax in the code, tests, and docs you produce.
|
||||
|
||||
---
|
||||
|
||||
## Overall goal (shared context)
|
||||
|
||||
Make secret tokens (`secrets.NAME`) in workflow config resolve from the server
|
||||
vault, at the run boundary, with values that never get persisted, never leak, and
|
||||
fail closed when a secret is missing or the wrong type. The redaction guarantee
|
||||
for declared secrets is: content-based redaction (already present) is the
|
||||
universal baseline, plus a per-run registry of resolved secret **values** so a
|
||||
declared secret is redacted even when it does not look like a credential. **This
|
||||
plan builds that registry primitive.**
|
||||
|
||||
Why per-run and not a process global: a test-only in-process run path executes
|
||||
multiple runs in the same process, so redaction state must be per-run, never a
|
||||
`static`/global.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TDD.** Write the failing test first, then the code.
|
||||
- Match the codebase: Rust import style (types by name, functions via parent
|
||||
module, no glob imports in production), `strum` for enum string maps, keep
|
||||
test-only helpers behind `#[cfg(test)]`.
|
||||
- Plain-English commit messages, PR text, and comments — no internal planning
|
||||
identifiers.
|
||||
- The verify gate runs nightly `fmt --check`, nightly
|
||||
`clippy --all-targets -D warnings`, `cargo nextest run --workspace`, docs check,
|
||||
web/api-client typecheck, and a release build. Implement so all pass.
|
||||
- Never print or log a secret value.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### A.1 — Add the `SecretRedactor` type
|
||||
|
||||
File: new `lib/crates/fabro-redact/src/secret_registry.rs`, exported from
|
||||
`lib/crates/fabro-redact/src/lib.rs`.
|
||||
|
||||
Add a cheap, cloneable, per-run registry of secret values that redacts exact
|
||||
matches regardless of shape. It composes *after* the existing content-based
|
||||
redaction (`redact_string`, `redact_json_value`) — this type does not replace
|
||||
them.
|
||||
|
||||
Shape:
|
||||
|
||||
- `SecretRedactor` backed by shared, interior-mutable state (e.g.
|
||||
`Arc<Mutex<Vec<String>>>` or `Arc<RwLock<...>>`) so a clone handed to a
|
||||
different subsystem observes registrations. Derive `Clone` and `Default`; an
|
||||
empty redactor is a pure no-op.
|
||||
- `fn register(&self, value: impl Into<String>)` — store a secret value to be
|
||||
redacted. **Ignore empty or whitespace-only values** (registering an empty
|
||||
string would turn all output into `REDACTED`). De-duplicate.
|
||||
- `fn redact_into(&self, s: &str) -> String` — replace every registered value
|
||||
substring with the same `"REDACTED"` marker used by `redact_string`. Replace
|
||||
**longest values first** so a secret that is a substring of another is handled
|
||||
correctly. If the registry is empty, return the input unchanged (fast path).
|
||||
- `fn redact_json(&self, value: serde_json::Value) -> serde_json::Value` — walk
|
||||
the JSON tree and apply `redact_into` to every string leaf (both object values
|
||||
and array elements; object keys are left as-is). Exact-value matching is
|
||||
unambiguous, so unlike `redact_json_value` this pass does not skip any keys.
|
||||
- Optional `fn is_empty(&self) -> bool` for callers that want to skip work.
|
||||
|
||||
Reuse the crate's existing `"REDACTED"` replacement marker (see `redact_string`
|
||||
in `lib.rs`) rather than introducing a new literal.
|
||||
|
||||
### A.2 — Tests (unit, in the new module)
|
||||
|
||||
- A **low-entropy** value (e.g. `"staging"`) that `redact_string` would *not*
|
||||
catch is replaced with `REDACTED` by `redact_into` after `register("staging")`.
|
||||
- Registering `""` or `" "` is a no-op: `redact_into` leaves unrelated text
|
||||
intact (guard against the empty-value footgun).
|
||||
- Overlapping values: register both `"abc"` and `"abcdef"`; `redact_into` on a
|
||||
string containing `"abcdef"` redacts the whole token (longest-first), not just
|
||||
the `"abc"` prefix.
|
||||
- Empty registry: `redact_into` and `redact_json` are the identity.
|
||||
- `redact_json` redacts a registered value nested inside an object value and
|
||||
inside an array element.
|
||||
- A clone of the redactor observes values registered through the original (shared
|
||||
state), proving it can be handed to another subsystem.
|
||||
|
||||
### A.3 — Verify
|
||||
|
||||
- `cargo +nightly-2026-04-14 fmt --check --all`
|
||||
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`
|
||||
- `cargo nextest run -p fabro-redact`
|
||||
- release build (`cargo dev build -- -p fabro-cli --release`)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. Parallel-safe with Plan B. This type is consumed by Plan C.
|
||||
|
||||
|
||||
## Completed stages
|
||||
- **toolchain**: succeeded
|
||||
- Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1`
|
||||
- Output:
|
||||
```
|
||||
cargo 1.95.0 (f2d3ce0bd 2026-03-21)
|
||||
```
|
||||
- **preflight_compile**: succeeded
|
||||
- Script: `cargo check -q --workspace 2>&1`
|
||||
- Output: (empty)
|
||||
- **preflight_lint**: succeeded
|
||||
- Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1`
|
||||
- Output: (empty)
|
||||
- **implement**: succeeded
|
||||
- Model: gpt-5.5, 463.9k tokens in / 12.5k out
|
||||
|
||||
|
||||
# Simplify: Code Review and Cleanup
|
||||
|
||||
Review all changes for reuse, quality, and efficiency. Fix any issues found. Feel free to use any sub agents you need.
|
||||
|
||||
## Phase 1: Identify Changes
|
||||
|
||||
Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. (You may already have the changes in context, if so, feel free to skip this part)
|
||||
|
||||
## Phase 2: Launch Three Review Agents in Parallel
|
||||
|
||||
Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.
|
||||
|
||||
### Agent 1: Code Reuse Review
|
||||
|
||||
For each change:
|
||||
|
||||
1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.
|
||||
2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.
|
||||
3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.
|
||||
|
||||
Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.
|
||||
|
||||
### Agent 2: Code Quality Review
|
||||
|
||||
Review the same changes for hacky patterns:
|
||||
|
||||
1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls
|
||||
2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones
|
||||
3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction
|
||||
4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries
|
||||
5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase
|
||||
|
||||
Note: This is a greenfield app, so be aggressive in optimizing quality.
|
||||
|
||||
### Agent 3: Efficiency Review
|
||||
|
||||
Review the same changes for efficiency:
|
||||
|
||||
1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns
|
||||
2. Missed concurrency: independent operations run sequentially when they could run in parallel
|
||||
3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths
|
||||
4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error
|
||||
5. Memory: unbounded data structures, missing cleanup, event listener leaks
|
||||
6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one
|
||||
|
||||
## Phase 3: Fix Issues
|
||||
|
||||
Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.
|
||||
|
||||
When done, briefly summarize what was fixed (or confirm the code was already clean).
|
||||
5
stages/006-simplify_opus@1/provider_used.json
Normal file
5
stages/006-simplify_opus@1/provider_used.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"mode": "agent",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-8"
|
||||
}
|
||||
23
stages/006-simplify_opus@1/response.md
Normal file
23
stages/006-simplify_opus@1/response.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
The code is clean and all checks pass. Here's a summary of what the review found and what I fixed.
|
||||
|
||||
## Review outcome
|
||||
|
||||
Three parallel agents (reuse, quality, efficiency) converged on the same core issues. I fixed all high-value ones:
|
||||
|
||||
**1. Extracted a shared region helper (reuse + quality).**
|
||||
The sort → merge-overlaps → splice-with-`REDACTED` loop was duplicated between `redact_string` (lib.rs) and the new module. I pulled it into `pub(crate) fn redact_regions(s, Vec<Region>)` in lib.rs; both `redact_string` and the registry now call it.
|
||||
|
||||
**2. Deleted per-call clone + sort on the hot path (efficiency + quality).**
|
||||
The original `registered_values_longest_first` cloned the entire `Vec<String>` and re-sorted it on *every* `redact_into`/`redact_json` call. By reusing `redact_string`'s **merge** overlap strategy, longest-first ordering is no longer needed for correctness, so that method, the O(n²) `regions_overlap` drop scan, and all per-call sorting/cloning are gone. Redaction now just holds the read guard, collects match regions, and delegates.
|
||||
|
||||
**3. Merge is also strictly safer.** The old drop-overlapping-regions logic could leak a partial-secret tail when two registered secrets partially overlapped in the text; the merge strategy redacts the union.
|
||||
|
||||
**4. Empty fast-path (efficiency, plan-required).** `redact_into`/`redact_json` now check `is_empty()` first and return the input untouched before allocating anything.
|
||||
|
||||
**5. Centralized lock/poison handling (quality).** Added private `read()`/`write()` guard helpers so `unwrap_or_else(PoisonError::into_inner)` lives in one place instead of three, and simplified dedup to `Vec::contains`.
|
||||
|
||||
**6. Renamed** the private `redact_json_value` → `redact_json_leaves` to avoid colliding with the public `jsonl::redact_json_value`.
|
||||
|
||||
Not changed (false positives / intentional): keeping `RwLock` (now justified since redaction holds the read guard without cloning), the small-N per-value scan (documented as an assumption), and the owned-`Value` signature (matches existing `redact_json_value`).
|
||||
|
||||
Verification: `fmt --check`, `clippy --all-targets -D warnings`, and `cargo nextest run -p fabro-redact` (66 tests) all pass.
|
||||
Loading…
Add table
Reference in a new issue