Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c70b6f367 | ||
|
|
b3b555fa23 | ||
|
|
e9805c7eb6 | ||
|
|
3d5af85713 | ||
|
|
c6341f020a | ||
|
|
e157311cd8 | ||
|
|
a5f502eb9c |
@@ -78,6 +78,27 @@ These rules apply to every task across every project, regardless of harness.
|
||||
when a human reviewer outside the project is required. Document the reason in
|
||||
PROJECT.md.
|
||||
|
||||
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||
PR would buy a review of a state that was never deployed.
|
||||
|
||||
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||
green" is not the same claim.
|
||||
|
||||
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||
|
||||
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||
lane silently does not apply.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
@@ -106,6 +127,43 @@ These rules apply to every task across every project, regardless of harness.
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||
landed, the state changed, you verified it live — close/merge/check it off
|
||||
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||
on me to confirm something that already happened; that's bookkeeping, not
|
||||
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||
destructive-git already gate. Before claiming something is closable,
|
||||
verify it live (re-check the issue, re-run the command) rather than
|
||||
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||
|
||||
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||
passing suite only asserts what its author already thought of, and for a
|
||||
control the author is precisely the person whose imagination just failed.
|
||||
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||
execution, and **CI was green on every one of them** while `git rm
|
||||
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||
route this work through a review with authority to block, instructed to
|
||||
verify by execution rather than by reading the implementation. Expect to be
|
||||
blocked more than once; that is the control working, not the reviewer being
|
||||
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||
practices live in the `pr-gatekeeper` agent.)
|
||||
|
||||
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||
after the mechanism it observes, not the goal it serves.
|
||||
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||
support. This is not pedantry about naming — when name and assertion
|
||||
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||
which is what lets the defect survive review. Found three times in one PR,
|
||||
twice in tests written to fix the previous instance of it; each passed while
|
||||
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||
you do not yet know what the test observes.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -319,8 +377,20 @@ Workflow:
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
message telling you to stage the regenerated files.
|
||||
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||
unconditionally on every host, every harness.
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||
host and every harness.
|
||||
|
||||
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||
must state its reason.** Both halves matter: the project file wins because it knows
|
||||
something this file cannot (infra's Flux constraint is real and not visible from
|
||||
here), and it must say why because a bare override is indistinguishable from drift.
|
||||
|
||||
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
|
||||
@@ -73,6 +73,27 @@ These rules apply to every task across every project, regardless of harness.
|
||||
when a human reviewer outside the project is required. Document the reason in
|
||||
PROJECT.md.
|
||||
|
||||
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||
PR would buy a review of a state that was never deployed.
|
||||
|
||||
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||
green" is not the same claim.
|
||||
|
||||
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||
|
||||
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||
lane silently does not apply.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
@@ -101,6 +122,43 @@ These rules apply to every task across every project, regardless of harness.
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||
landed, the state changed, you verified it live — close/merge/check it off
|
||||
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||
on me to confirm something that already happened; that's bookkeeping, not
|
||||
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||
destructive-git already gate. Before claiming something is closable,
|
||||
verify it live (re-check the issue, re-run the command) rather than
|
||||
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||
|
||||
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||
passing suite only asserts what its author already thought of, and for a
|
||||
control the author is precisely the person whose imagination just failed.
|
||||
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||
execution, and **CI was green on every one of them** while `git rm
|
||||
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||
route this work through a review with authority to block, instructed to
|
||||
verify by execution rather than by reading the implementation. Expect to be
|
||||
blocked more than once; that is the control working, not the reviewer being
|
||||
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||
practices live in the `pr-gatekeeper` agent.)
|
||||
|
||||
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||
after the mechanism it observes, not the goal it serves.
|
||||
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||
support. This is not pedantry about naming — when name and assertion
|
||||
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||
which is what lets the defect survive review. Found three times in one PR,
|
||||
twice in tests written to fix the previous instance of it; each passed while
|
||||
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||
you do not yet know what the test observes.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -314,8 +372,20 @@ Workflow:
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
message telling you to stage the regenerated files.
|
||||
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||
unconditionally on every host, every harness.
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||
host and every harness.
|
||||
|
||||
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||
must state its reason.** Both halves matter: the project file wins because it knows
|
||||
something this file cannot (infra's Flux constraint is real and not visible from
|
||||
here), and it must say why because a bare override is indistinguishable from drift.
|
||||
|
||||
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
|
||||
@@ -147,6 +147,37 @@ func envInt(key string, fallback int) int {
|
||||
// buildNotifier returns the ntfy notifier, or a nil interface when
|
||||
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
||||
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
||||
// alertingWarnings names every alert that will not fire under the current
|
||||
// configuration (#107).
|
||||
//
|
||||
// buildNotifier returns a nil interface when BRAIN_NTFY_URL is unset, and every
|
||||
// consumer skips a nil notifier silently — right for an unconfigured homelab,
|
||||
// wrong for an alert whose entire value is that someone finds out. The cost of
|
||||
// the silence is that "configured and working" and "never configured" are
|
||||
// byte-identical in behaviour: no error, no warning, only a message that never
|
||||
// arrives, which nobody notices because nobody is waiting for an alert they
|
||||
// have never seen. Two alerts were dark in production for exactly that reason
|
||||
// (#100), found by firing a real event rather than by reading anything.
|
||||
//
|
||||
// WARN rather than fatal, deliberately. The brain sibling rule
|
||||
// (`empty-credential-must-refuse-to-start-not-switch-modes`) says an empty
|
||||
// credential must be a startup error, never a silent mode switch — but that is
|
||||
// about a control being removed. Here observability is degraded, not a gate
|
||||
// bypassed, and refusing to boot would break the unconfigured-homelab case the
|
||||
// nil notifier exists to support. What the rule does demand, and what this
|
||||
// provides, is that the mode switch stops being silent.
|
||||
//
|
||||
// Pure and env-injected so the message is testable without a running server:
|
||||
// the reason this gap survived is that nothing about it was observable.
|
||||
func alertingWarnings(getenv func(string) string) []string {
|
||||
var out []string
|
||||
if getenv("BRAIN_NTFY_URL") == "" {
|
||||
out = append(out, "alerting DISABLED: BRAIN_NTFY_URL unset — "+
|
||||
"brain_edit_root governance rewrites and capture audit degradation will not notify")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildNotifier() audit.Notifier {
|
||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||
return n
|
||||
@@ -203,6 +234,10 @@ func systemHostname() string {
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
for _, w := range alertingWarnings(os.Getenv) {
|
||||
logger.Warn(w)
|
||||
}
|
||||
|
||||
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
|
||||
port := envOr("INGEST_PORT", "3300")
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -36,3 +37,42 @@ func TestClaudeSink_IngestWritesToNonIndexedArchiveNotWiki(t *testing.T) {
|
||||
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
|
||||
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
|
||||
}
|
||||
|
||||
// --- #107: an unconfigured alert must announce itself ----------------------
|
||||
|
||||
func TestAlertingWarnings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want []string // substrings that must appear across the warnings
|
||||
none bool // true = expect no warnings at all
|
||||
}{
|
||||
{
|
||||
// The failure this exists to prevent: two alerts dark in
|
||||
// production, discovered only by firing a real event and watching
|
||||
// nothing arrive (#100).
|
||||
name: "notifier unconfigured names what will not fire",
|
||||
env: map[string]string{},
|
||||
want: []string{"BRAIN_NTFY_URL", "brain_edit_root", "audit"},
|
||||
},
|
||||
{
|
||||
name: "configured notifier is silent",
|
||||
env: map[string]string{"BRAIN_NTFY_URL": "http://ntfy/brain-alerts"},
|
||||
none: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := alertingWarnings(func(k string) string { return tt.env[k] })
|
||||
if tt.none {
|
||||
assert.Empty(t, got)
|
||||
return
|
||||
}
|
||||
joined := strings.Join(got, " ")
|
||||
for _, w := range tt.want {
|
||||
assert.Contains(t, joined, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package api
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
// Aliased: this package's own frontmatter type (below) would collide
|
||||
// with the imported package's name otherwise.
|
||||
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||
)
|
||||
|
||||
// frontmatter is an ordered, line-preserving view of a note's YAML
|
||||
// frontmatter block. It deliberately avoids a full YAML round-trip: the
|
||||
@@ -25,19 +31,26 @@ type fmLine struct {
|
||||
// frontmatter block is recognised only when the file opens with a `---`
|
||||
// fence and a closing `---` fence follows. Otherwise the whole input is
|
||||
// the body and the returned frontmatter is empty.
|
||||
//
|
||||
// Fence detection itself is frontmatter.Split (hyperguild#138) — this
|
||||
// package's own contribution is purely the ordered, line-preserving fmLine
|
||||
// shape below, which a shared parser has no business owning since it's
|
||||
// specific to how brain_update does a surgical, byte-round-trip edit.
|
||||
func parseFrontmatter(src string) (frontmatter, string) {
|
||||
var fm frontmatter
|
||||
if !strings.HasPrefix(src, "---\n") {
|
||||
blockBytes, restBytes, ok := sharedfm.Split([]byte(src))
|
||||
if !ok {
|
||||
return fm, src
|
||||
}
|
||||
rest := src[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---\n")
|
||||
if end < 0 {
|
||||
// Opening fence with no closing fence — treat as bodyless content.
|
||||
return fm, src
|
||||
}
|
||||
block := rest[:end]
|
||||
body := rest[end+len("\n---\n"):]
|
||||
// sharedfm.Split's block/rest boundary sits one byte earlier than this
|
||||
// package's always has: block keeps the "\n" immediately before the
|
||||
// closing fence, and rest keeps the "\n" immediately after it (both by
|
||||
// design, for Fix's own reconstruction). This package's block/body
|
||||
// never included either — TrimSuffix/TrimPrefix restore that exact
|
||||
// boundary. Split guarantees ok=true only when "\n---\n" matched, so
|
||||
// both trims are always exactly one byte, never ambiguous.
|
||||
block := strings.TrimSuffix(string(blockBytes), "\n")
|
||||
body := strings.TrimPrefix(string(restBytes), "\n")
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
// An indented line is a continuation (a YAML block-list item, a
|
||||
|
||||
@@ -110,9 +110,22 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
||||
|
||||
fm, _ := parseFrontmatter(string(prior))
|
||||
for _, l := range contentFM.lines {
|
||||
if l.key != "" {
|
||||
fm.set(l.key, l.value)
|
||||
// updated_at/supersedes/supersede_reason are this function's own
|
||||
// output, not something a caller's fragment sets — updated_at and
|
||||
// supersedes are already unconditionally re-set below regardless of
|
||||
// what the loop does with them, but supersede_reason is only
|
||||
// re-set when opts.Reason is non-empty, so without this skip a
|
||||
// caller could set it via its own fragment and have it survive
|
||||
// even when Reason is empty, bypassing the one intended entry
|
||||
// point for that field (hyperguild#125, found in #111's review).
|
||||
// Skipping all three here rather than relying on the unconditional
|
||||
// overwrites below to save the other two keeps the invariant
|
||||
// explicit at the one place it can be silently broken by a future
|
||||
// reordering, not implicit in call order.
|
||||
if l.key == "" || l.key == "updated_at" || l.key == "supersedes" || l.key == "supersede_reason" {
|
||||
continue
|
||||
}
|
||||
fm.set(l.key, l.value)
|
||||
}
|
||||
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||
fm.set("supersedes", priorHash)
|
||||
|
||||
@@ -231,6 +231,45 @@ func TestUpdateThenReadRoundTripsHash(t *testing.T) {
|
||||
assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote")
|
||||
}
|
||||
|
||||
func TestUpdateNoteSupersedeReasonNotSettableViaCallerFragmentWhenReasonOptionEmpty(t *testing.T) {
|
||||
// hyperguild#125, found in #111's review: the merge loop applied every
|
||||
// field from the caller's leading frontmatter fragment, including
|
||||
// supersede_reason -- which is only re-set unconditionally when
|
||||
// opts.Reason is non-empty, so a caller could set it via its own
|
||||
// fragment and have it survive even with Reason left empty, bypassing
|
||||
// the one intended entry point for that field.
|
||||
brainDir := t.TempDir()
|
||||
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel,
|
||||
Content: "---\nsupersede_reason: snuck in via the fragment\n---\nnew\n",
|
||||
// Reason deliberately left empty.
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(got), "supersede_reason",
|
||||
"supersede_reason must not appear at all when opts.Reason is empty, regardless of what the fragment tried to set")
|
||||
}
|
||||
|
||||
func TestUpdateNoteSupersedeReasonFromOptionStillWorks(t *testing.T) {
|
||||
// Regression pin: the legitimate entry point (opts.Reason) must be
|
||||
// completely unaffected by #125's fix.
|
||||
brainDir := t.TempDir()
|
||||
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel, Content: "new\n", Reason: "facts changed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "supersede_reason: facts changed")
|
||||
}
|
||||
|
||||
func TestContentHashStable(t *testing.T) {
|
||||
assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc")))
|
||||
assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd")))
|
||||
|
||||
@@ -43,6 +43,26 @@ var DefaultRules = []Rule{
|
||||
// JSON, so both segments begin with "eyJ".
|
||||
{Name: "jwt", RE: regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)},
|
||||
{Name: "postgres-uri-with-password", RE: regexp.MustCompile(`postgres(?:ql)?://[^:\s/]+:[^@\s/]+@`)},
|
||||
// Any credential embedded in a URL's userinfo, which is what a git credential
|
||||
// store holds: `https://<user>:<token>@host`. The postgres rule above is the
|
||||
// same shape narrowed to one scheme, and is kept ahead of this one only so a
|
||||
// DSN reports the more specific name -- same ordering convention as
|
||||
// authorization-header before bearer-token.
|
||||
//
|
||||
// Added after this shape passed ALL rules and was therefore ingested rather
|
||||
// than dropped (hyperguild#142). It reached ~/.claude/projects -- this
|
||||
// watcher's own input -- because a test suite in mathias/infra executed a
|
||||
// script that configures git while inheriting the operator's real $HOME, read
|
||||
// koala's live credential store, and printed it. Two credentials were rotated
|
||||
// (infra#466). The scrubber is the control that decides whether that class of
|
||||
// mistake is survivable, and for this shape it was failing open.
|
||||
//
|
||||
// The {8,} floor on the secret half is what keeps `https://host:443/path` and
|
||||
// `https://user:@host` out; the `[^:\s/@]+` user half stops a host:port pair
|
||||
// being read as userinfo. Both directions are covered by
|
||||
// TestScrub_URLUserinfoCredential, because a rule that dropped every URL with
|
||||
// a colon in it would discard most of the signal this watcher exists to keep.
|
||||
{Name: "url-userinfo-credential", RE: regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^:\s/@]+:[^@\s/]{8,}@`)},
|
||||
{Name: "private-key", RE: regexp.MustCompile(`-----BEGIN[^-]*PRIVATE KEY-----`)},
|
||||
{Name: "ssh-key", RE: regexp.MustCompile(`ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}`)},
|
||||
{Name: "github-pat", RE: regexp.MustCompile(`\b(?:ghp|gho|ghu|ghr|gha)_[A-Za-z0-9]{30,}\b`)},
|
||||
|
||||
@@ -46,6 +46,64 @@ func TestScrub_PoisonedFixtures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A credential embedded in a URL's userinfo is the shape a git credential store
|
||||
// holds -- `https://<user>:<token>@host` -- and it walked past every rule until
|
||||
// hyperguild#142. There was a rule for `postgres://user:pass@` and nothing for the
|
||||
// general form, so `~/.git-credentials` content was ingested rather than dropped.
|
||||
//
|
||||
// Found the hard way: a test suite in mathias/infra executed a script that
|
||||
// configures git while inheriting the operator's real $HOME, read koala's live
|
||||
// credential store, and printed two live credentials into a session transcript.
|
||||
// ~/.claude/projects is this watcher's own input, so the scrubber was the last
|
||||
// line of defence for that mistake and it failed open. Two credentials were
|
||||
// rotated (infra#466).
|
||||
//
|
||||
// The lesson is not "do not read the credential file" -- that mistake will recur.
|
||||
// It is that this shape must fail closed, because the scrubber is what decides
|
||||
// whether such a mistake is survivable.
|
||||
func TestScrub_URLUserinfoCredential(t *testing.T) {
|
||||
// Every secret below is INVENTED. None is or was a real credential.
|
||||
mustScrub := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"git-credential-store-user", "https://mathias:0123456789abcdef0123456789abcdef01234567@git.d-ma.be"},
|
||||
{"git-credential-store-oauth2", "https://oauth2:0123456789abcdef0123456789abcdef01234567@git.d-ma.be"},
|
||||
{"in-context-payload-line", `payload line: https://mathias:0123456789abcdef0123456789abcdef01234567@git.d-ma.be`},
|
||||
{"clone-url-in-argv", "git clone https://oauth2:abcdefghijklmnopqrstuvwx@git.d-ma.be/mathias/infra.git"},
|
||||
{"http-not-https", "http://admin:supersecretvalue123@10.0.1.20/api"},
|
||||
{"git-plus-ssh-scheme", "git+ssh://deploy:abcdefghijklmnop@example.com/repo"},
|
||||
{"redis-scheme", "redis://default:abcdefghijklmnopqrst@10.0.1.20:6379/0"},
|
||||
}
|
||||
for _, tc := range mustScrub {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.NotEmpty(t, Scrub(tc.content),
|
||||
"a credential in URL userinfo must be dropped, not ingested")
|
||||
})
|
||||
}
|
||||
|
||||
// The other direction matters as much: a rule that scrubs every URL with a
|
||||
// colon in it would drop most of the signal this watcher exists to keep.
|
||||
mustNotScrub := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"host-with-port", "cloning https://git.d-ma.be:443/mathias/infra.git"},
|
||||
{"postgres-no-password", "postgres://localhost:5432/brain"},
|
||||
{"url-with-colon-in-query", "GET https://example.com/path?filter=a:b&x=1"},
|
||||
{"plain-url", "see https://git.d-ma.be/mathias/infra/issues/466"},
|
||||
{"userinfo-no-secret", "https://user:@example.com"},
|
||||
{"format-string-placeholder", `printf 'https://oauth2:%s@%s\n' "$TOKEN" "$HOST"`},
|
||||
{"scheme-relative-time", "duration https://example.com took 00:03:22"},
|
||||
}
|
||||
for _, tc := range mustNotScrub {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Empty(t, Scrub(tc.content),
|
||||
"this is not a credential and must pass through")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrub_CleanContentPassesThrough(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
|
||||
@@ -66,7 +66,7 @@ func Fix(doc []byte) ([]byte, bool, error) {
|
||||
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
||||
}
|
||||
|
||||
_, rest, _ := split(doc)
|
||||
_, rest, _ := Split(doc)
|
||||
out := []byte(fence + "\n")
|
||||
out = append(out, fixed...)
|
||||
out = append(out, []byte(fence)...)
|
||||
@@ -122,14 +122,25 @@ func needsQuoting(value string) bool {
|
||||
|
||||
// block returns the raw YAML between the opening and closing fences.
|
||||
func block(doc []byte) ([]byte, bool) {
|
||||
b, _, ok := split(doc)
|
||||
b, _, ok := Split(doc)
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// split returns the frontmatter block and everything from the closing fence
|
||||
// onward (the closing fence itself excluded, its trailing newline included).
|
||||
func split(doc []byte) (block, rest []byte, ok bool) {
|
||||
s := string(doc)
|
||||
// Split returns doc's frontmatter block and everything from the closing
|
||||
// fence onward (the closing fence itself excluded, its trailing newline
|
||||
// included), or ok=false if doc has no properly-fenced frontmatter.
|
||||
//
|
||||
// The canonical fence detector for this codebase (hyperguild#138): three
|
||||
// independent reimplementations of this same logic each grew their own gap
|
||||
// through separate bug-fixing before being consolidated here —
|
||||
// internal/mcp's needed a CRLF/BOM guard added after both silently defeated
|
||||
// its own HasPrefix check, and internal/api's closing-fence match had no
|
||||
// trailing-newline requirement, so a document ending in "\n---extra" with no
|
||||
// real newline after the fence was treated as validly closed. Both classes
|
||||
// are closed here, once, for every caller.
|
||||
func Split(doc []byte) (block, rest []byte, ok bool) {
|
||||
s := strings.TrimPrefix(string(doc), "\xef\xbb\xbf")
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
if !strings.HasPrefix(s, fence+"\n") {
|
||||
return nil, nil, false
|
||||
}
|
||||
@@ -139,5 +150,18 @@ func split(doc []byte) (block, rest []byte, ok bool) {
|
||||
if idx < 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true
|
||||
// What follows the closing fence must be either a newline (a real body
|
||||
// follows) or nothing at all (the fence is the literal end of the
|
||||
// document — no body, not a bypass attempt). Anything else, e.g.
|
||||
// "\n---extra", is not a valid close: an earlier version of this check
|
||||
// accepted it, which is exactly the looser match hyperguild#138 closes.
|
||||
after := idx + 1 + len(fence)
|
||||
switch {
|
||||
case after == len(body):
|
||||
return []byte(body[:idx+1]), nil, true
|
||||
case body[after] == '\n':
|
||||
return []byte(body[:idx+1]), []byte(body[after:]), true
|
||||
default:
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,9 +168,9 @@ func TestFix_PreservesBodyExactly(t *testing.T) {
|
||||
require.True(t, changed)
|
||||
assert.True(t, len(got) > 0)
|
||||
|
||||
_, rest, ok := split([]byte(doc))
|
||||
_, rest, ok := Split([]byte(doc))
|
||||
require.True(t, ok)
|
||||
_, gotRest, ok := split(got)
|
||||
_, gotRest, ok := Split(got)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
||||
}
|
||||
@@ -186,3 +186,55 @@ func TestFix_RefusesToReturnAnUnparseableResult(t *testing.T) {
|
||||
assert.False(t, changed)
|
||||
assert.Equal(t, doc, string(got), "an unfixable document must come back byte-identical")
|
||||
}
|
||||
|
||||
// TestSplit_NormalizesCRLFBeforeFenceDetection is hyperguild#138: a note
|
||||
// whose fence lines use CRLF ("---\r\n") failed strings.HasPrefix(doc,
|
||||
// "---\n") on the precondition read in internal/mcp's independent
|
||||
// reimplementation -- encoding alone, no adversarial input, silently
|
||||
// defeated fence detection there before this consolidation. Split must
|
||||
// never repeat that.
|
||||
func TestSplit_NormalizesCRLFBeforeFenceDetection(t *testing.T) {
|
||||
doc := "---\r\ntitle: 'n'\r\nhalf_life: durable\r\n---\r\n\r\nbody\r\n"
|
||||
block, rest, ok := Split([]byte(doc))
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, string(block), "half_life: durable")
|
||||
assert.Contains(t, string(rest), "body")
|
||||
}
|
||||
|
||||
// TestSplit_StripsALeadingUTF8BOM is the same mechanism one layer earlier:
|
||||
// a leading BOM (Notepad's default "UTF-8" save, PowerShell's pre-v6
|
||||
// Out-File/Set-Content) defeats the same HasPrefix check before CRLF
|
||||
// normalization even applies.
|
||||
func TestSplit_StripsALeadingUTF8BOM(t *testing.T) {
|
||||
doc := "\xef\xbb\xbf---\ntitle: 'n'\n---\n\nbody\n"
|
||||
block, rest, ok := Split([]byte(doc))
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, string(block), "title: 'n'")
|
||||
assert.Contains(t, string(rest), "body")
|
||||
}
|
||||
|
||||
// TestSplit_RequiresANewlineAfterTheClosingFence closes the looser match
|
||||
// internal/api's independent reimplementation carried (strings.Index(body,
|
||||
// "\n"+fence) with no trailing-newline requirement): a document ending in
|
||||
// "\n---extra" with no newline separating the fence from what follows it is
|
||||
// not a valid closing fence and must not be treated as one.
|
||||
func TestSplit_RequiresANewlineAfterTheClosingFence(t *testing.T) {
|
||||
doc := "---\ntitle: 'n'\n---extra-not-a-real-close"
|
||||
_, _, ok := Split([]byte(doc))
|
||||
assert.False(t, ok, "a fence line not followed by a newline is not a valid closing fence")
|
||||
}
|
||||
|
||||
// TestSplit_AcceptsAClosingFenceThatIsTheLastBytesOfTheDocument is a
|
||||
// regression pin found in review of hyperguild#138: the fix for the
|
||||
// "\n---extra" bypass above must not also reject the legitimate case of a
|
||||
// closing fence with nothing after it at all (no trailing newline because
|
||||
// there's no body, not because a bypass is being attempted) -- this
|
||||
// package's own private split() accepted that shape before the
|
||||
// consolidation, and cmd/frontmatterlint's Lint/Fix relied on it.
|
||||
func TestSplit_AcceptsAClosingFenceThatIsTheLastBytesOfTheDocument(t *testing.T) {
|
||||
doc := "---\ntitle: 'n'\n---"
|
||||
block, rest, ok := Split([]byte(doc))
|
||||
require.True(t, ok, "a closing fence at literal end-of-file, with nothing after it, is valid -- there is no body to require a newline before")
|
||||
assert.Contains(t, string(block), "title: 'n'")
|
||||
assert.Equal(t, "", string(rest))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||
)
|
||||
|
||||
@@ -331,27 +332,20 @@ func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error {
|
||||
// frontmatterBlock returns the raw text between doc's opening and closing
|
||||
// `---` fences, or ok=false if doc has no (properly closed) fence.
|
||||
func frontmatterBlock(doc string) (block string, ok bool) {
|
||||
// Round 4 found a note with CRLF fence lines ("---\r\n") silently
|
||||
// defeats HasPrefix(doc, "---\n") on the precondition read — not an
|
||||
// adversarial old_str/new_str, just an encoding difference — which
|
||||
// makes checkClaimEnvelope no-op entirely. Round 5 found the same
|
||||
// mechanism one layer earlier: a leading UTF-8 BOM (a live, plausible
|
||||
// byte sequence — Notepad's default "UTF-8" save and PowerShell's
|
||||
// pre-v6 Out-File/Set-Content both emit one) defeats the same prefix
|
||||
// check before CRLF normalization even applies. Strip both, here, so
|
||||
// every caller (hadFence/hasFence detection and frontmatterScalars)
|
||||
// agrees regardless of encoding.
|
||||
doc = strings.TrimPrefix(doc, "\xef\xbb\xbf")
|
||||
doc = strings.ReplaceAll(doc, "\r\n", "\n")
|
||||
if !strings.HasPrefix(doc, "---\n") {
|
||||
// hyperguild#138: fence detection itself is frontmatter.Split, shared
|
||||
// with internal/api and internal/frontmatter — the CRLF/BOM guards that
|
||||
// used to live here (added piecemeal across rounds 4 and 5 of #123's
|
||||
// review) are now the shared parser's job, so every caller inherits a
|
||||
// fix instead of needing bug-for-bug parity edits.
|
||||
b, _, ok := frontmatter.Split([]byte(doc))
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := doc[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---\n")
|
||||
if end < 0 {
|
||||
return "", false
|
||||
}
|
||||
return rest[:end], true
|
||||
// Split's block keeps the "\n" immediately before the closing fence
|
||||
// (by design — see its own doc comment); this function never included
|
||||
// it. Split guarantees ok=true only when "\n---\n" matched, so this is
|
||||
// always exactly one byte, never ambiguous.
|
||||
return strings.TrimSuffix(string(b), "\n"), true
|
||||
}
|
||||
|
||||
// frontmatterScalars extracts top-level "key: value" scalar pairs from
|
||||
|
||||
@@ -58,7 +58,7 @@ if [ "$FORCE" = false ]; then
|
||||
while IFS= read -r _match; do
|
||||
_lineno="${_match%%:*}"
|
||||
_content="${_match#*:}"
|
||||
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||
_token=$(head -1 < <(grep -o '{{[^}]*}}' <<<"$_content"))
|
||||
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||
done <<< "$_placeholders"
|
||||
echo "" >&2
|
||||
|
||||
Reference in New Issue
Block a user