Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54237af7ad | ||
|
|
b6ceccca26 | ||
|
|
36a45068ab | ||
|
|
055af368cd | ||
|
|
eb9e3e69a9 | ||
|
|
b7cf0efb9d |
@@ -0,0 +1,2 @@
|
||||
# Presence of this file marks this repo as opt-in for headless dispatch.
|
||||
# See dispatch#3.
|
||||
@@ -5,6 +5,11 @@ Instead of letting Claude Code do whatever it wants, hyperguild enforces structu
|
||||
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
||||
into a searchable brain.
|
||||
|
||||
**This repo is also where `brain-mcp` lives** — the MCP server at
|
||||
`brain-mcp.d-ma.be` that `brain_query`, `brain_write`, `brain_read`, `brain_edit`
|
||||
and `capture` are served from. It is not a separate deployable; see
|
||||
[Where brain-mcp lives](#where-brain-mcp-lives).
|
||||
|
||||
## Hypothesis
|
||||
|
||||
> We believe routing skill tasks through local models, backed by brain context,
|
||||
@@ -20,7 +25,7 @@ usage, the fast-model routing path should be reconsidered.
|
||||
|
||||
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
||||
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
||||
[agentsquad](http://gitea.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
||||
[agentsquad](https://git.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
||||
orchestration model. Skills (mathias/skills) are shared between both.
|
||||
|
||||
## How it works
|
||||
@@ -44,6 +49,33 @@ brain/
|
||||
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
||||
```
|
||||
|
||||
## Where brain-mcp lives
|
||||
|
||||
`brain-mcp` is served from **`ingestion/internal/mcp`** in this repo, at
|
||||
**`brain-mcp.d-ma.be`**. It is a component *inside* the `ingestion` service, not
|
||||
a standalone deployable — one pod in the `supervisor` namespace serves the MCP
|
||||
endpoint, the REST API, and the claudewatcher together.
|
||||
|
||||
| You are looking for | It is here |
|
||||
|---|---|
|
||||
| The MCP tool surface (`brain_query`, `brain_write`, `brain_read`, `brain_edit`, `capture`, …) | `ingestion/internal/mcp` |
|
||||
| The HTTP/REST API (`/query`, `/write`, `/ingest`) | `ingestion/internal/api` |
|
||||
| Session ingestion from Claude Code transcripts | `ingestion/internal/claudewatcher` |
|
||||
| The brain *content* (notes, wings, halls) | [`mathias/brain`](https://git.d-ma.be/mathias/brain) — a separate repo |
|
||||
| Deployment manifests | `config/supervisor` |
|
||||
|
||||
### Why it is not its own repo
|
||||
|
||||
Asked and decided in [#91](https://git.d-ma.be/mathias/hyperguild/issues/91)
|
||||
(part 2). `internal/mcp` imports 17 of ingestion's other internal packages and
|
||||
has exactly one importer — it is the MCP facade over the service's own
|
||||
internals, so "move it out" resolves to renaming `ingestion`, publishing 17
|
||||
packages as a shared library, or maintaining a shim. The discoverability problem
|
||||
that prompted the question is answered by naming it here instead.
|
||||
|
||||
Full reasoning, including the reversal trigger: brain
|
||||
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
||||
|
||||
## Phase 1 tools (available now)
|
||||
|
||||
| Tool | What it does |
|
||||
@@ -58,7 +90,7 @@ brain/
|
||||
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
||||
|
||||
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
||||
> They are now SKILL.md files in [mathias/skills](http://gitea.d-ma.be/mathias/skills).
|
||||
> They are now SKILL.md files in [mathias/skills](https://git.d-ma.be/mathias/skills).
|
||||
|
||||
## Connect a project
|
||||
|
||||
@@ -147,7 +179,7 @@ The routing pod selects models per skill call based on historical pass rate:
|
||||
|
||||
## Open issues
|
||||
|
||||
See [issues](http://gitea.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||
See [issues](https://git.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||
|
||||
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
||||
- **#24** — reduce context burn from skill listing
|
||||
|
||||
@@ -144,6 +144,16 @@ func envInt(key string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 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.
|
||||
func buildNotifier() audit.Notifier {
|
||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||
return n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
||||
// set it builds the classification-aware DegradingSink (loki central +
|
||||
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
||||
@@ -161,12 +171,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
|
||||
logger.Error("capture audit buffer init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Keep notifier as a nil interface (not a typed-nil) when unconfigured
|
||||
// so DegradingSink/Reconcile skip it cleanly.
|
||||
var notifier audit.Notifier
|
||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||
notifier = n
|
||||
}
|
||||
notifier := buildNotifier()
|
||||
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
||||
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
||||
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
||||
@@ -490,7 +495,13 @@ func main() {
|
||||
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||
// WithCapture mutates the same instance, so the tool appears live.
|
||||
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
|
||||
// hyperguild#97: a governance-file rewrite gets the same trail a
|
||||
// capture already gets, plus an out-of-band alert. Wired here because
|
||||
// brain_edit_root needs the capture plumbing anyway — that is what
|
||||
// re-derives the static principal its gate checks.
|
||||
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
|
||||
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
|
||||
"governance_observer", true)
|
||||
} else {
|
||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
@@ -152,15 +153,24 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create hall dir: %w", err)
|
||||
}
|
||||
|
||||
existingFields, body := splitFrontmatter(opts.Content)
|
||||
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
||||
for _, f := range existingFields {
|
||||
existingByKey[f.key] = f
|
||||
}
|
||||
|
||||
// Validate the claim-property envelope BEFORE creating the directory, so a
|
||||
// rejected write leaves nothing behind — not even an empty hall dir
|
||||
// (brain#21). Fail closed: a value outside the enum is worse than an absent
|
||||
// one, because a lint skips what it cannot parse and the note then reads as
|
||||
// classified while nothing acts on it.
|
||||
if err := claim.ValidateEnvelope(scalarValues(existingFields)); err != nil {
|
||||
return "", fmt.Errorf("claim envelope: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create hall dir: %w", err)
|
||||
}
|
||||
emitted := make(map[string]bool, 6)
|
||||
|
||||
var fm strings.Builder
|
||||
@@ -218,6 +228,25 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
// scalarValues flattens frontmatter fields to key -> scalar value for
|
||||
// validation. Multi-line values (lists, nested blocks) yield the text after
|
||||
// the colon on the first line, which is empty for a list — correct here,
|
||||
// since every claim-property field is a scalar enum.
|
||||
func scalarValues(fields []frontmatterField) map[string]string {
|
||||
out := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
if len(f.lines) == 0 {
|
||||
continue
|
||||
}
|
||||
_, value, found := strings.Cut(f.lines[0], ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
out[f.key] = strings.TrimSpace(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// frontmatterField is one top-level YAML key from a frontmatter block,
|
||||
// along with its raw line and any indented continuation lines (e.g. a
|
||||
// bulleted list value spanning multiple lines).
|
||||
|
||||
@@ -440,3 +440,60 @@ func TestIngestPath_Directory(t *testing.T) {
|
||||
require.True(t, ok, "pages must be an array")
|
||||
assert.NotEmpty(t, pagesSlice)
|
||||
}
|
||||
|
||||
// --- brain#21: claim-property envelope on the write path -------------------
|
||||
|
||||
func TestWriteNote_PreservesClaimEnvelopeVerbatim(t *testing.T) {
|
||||
// writeHallNote already passes unknown keys through; this pins that
|
||||
// behaviour for the envelope specifically, so a future refactor of the
|
||||
// merge loop cannot silently drop it.
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nA durable claim.\n",
|
||||
Wing: "homelab",
|
||||
Hall: "decisions",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "half_life: durable")
|
||||
assert.Contains(t, string(got), "applies: universal")
|
||||
assert.Contains(t, string(got), "claim_provenance: asserted")
|
||||
}
|
||||
|
||||
func TestWriteNote_RejectsInvalidClaimEnvelopeAndWritesNothing(t *testing.T) {
|
||||
// Fail closed: a value outside the enum must not land. A lint skips what
|
||||
// it cannot parse, so an unvalidated write produces a note that reads as
|
||||
// classified while nothing acts on it.
|
||||
dir := t.TempDir()
|
||||
|
||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "---\nhalf_life: forever\nclaim_provenance: asserted\n---\n\nBody.\n",
|
||||
Filename: "bad-envelope",
|
||||
Wing: "homelab",
|
||||
Hall: "facts",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "half_life")
|
||||
|
||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/bad-envelope.md"))
|
||||
assert.True(t, os.IsNotExist(statErr), "a rejected write must leave no file behind")
|
||||
}
|
||||
|
||||
func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "---\napplies: homelab\n---\n\nBody.\n",
|
||||
Filename: "no-provenance",
|
||||
Wing: "homelab",
|
||||
Hall: "facts",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "claim_provenance")
|
||||
|
||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package claim validates the claim-property frontmatter that says when and
|
||||
// where a note's claim holds, and who decided that (brain#21).
|
||||
//
|
||||
// Two families were designed together and land separately. This is the
|
||||
// applicability envelope:
|
||||
//
|
||||
// half_life: volatile | seasonal | durable — how fast it decays
|
||||
// applies: local | homelab | universal — how widely it holds
|
||||
// claim_provenance: asserted | inferred — who set the above
|
||||
//
|
||||
// The strength family (confidence, contested) follows once the ingest-time
|
||||
// contradiction detector exists to populate it.
|
||||
//
|
||||
// Why the envelope is validated rather than merely accepted: a value outside
|
||||
// the enum is worse than an absent one. A lint skips what it cannot parse, so
|
||||
// the note reads as classified while nothing acts on it — the field becomes
|
||||
// decoration that suppresses the very review it was added to trigger.
|
||||
//
|
||||
// Why `applies` and not `scope`: `scope:` has been in use since brain#5 for
|
||||
// glossary terms (`homelab-core`, `project:<repo>`) — a disjoint value space
|
||||
// on the same key. The shipped schema keeps the name; this one takes another.
|
||||
package claim
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Field names, exported so writers and lints agree on the spelling.
|
||||
const (
|
||||
FieldHalfLife = "half_life"
|
||||
FieldApplies = "applies"
|
||||
FieldProvenance = "claim_provenance"
|
||||
)
|
||||
|
||||
var (
|
||||
halfLives = []string{"volatile", "seasonal", "durable"}
|
||||
appliesTo = []string{"local", "homelab", "universal"}
|
||||
provenances = []string{"asserted", "inferred"}
|
||||
)
|
||||
|
||||
// ValidateEnvelope checks the envelope fields of one note's frontmatter.
|
||||
//
|
||||
// Absent fields are valid: the envelope is opt-in for new writes, and a
|
||||
// corpus-wide backfill is deliberately out of scope (brain#21) — inferring
|
||||
// 1113 notes' properties in bulk has the same failure mode as the extraction
|
||||
// pass that produced 28 wrong wikilinks out of 67.
|
||||
func ValidateEnvelope(fm map[string]string) error {
|
||||
halfLife, hasHalfLife := present(fm, FieldHalfLife)
|
||||
applies, hasApplies := present(fm, FieldApplies)
|
||||
prov, hasProv := present(fm, FieldProvenance)
|
||||
|
||||
if hasHalfLife {
|
||||
if err := oneOf(FieldHalfLife, halfLife, halfLives); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hasApplies {
|
||||
if err := oneOf(FieldApplies, applies, appliesTo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hasProv {
|
||||
if err := oneOf(FieldProvenance, prov, provenances); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// The field and its provenance travel together or not at all. A value
|
||||
// whose author is unknown cannot be trusted differently from one Mathias
|
||||
// asserted, which is how an inferred guess hardens into accepted fact.
|
||||
switch {
|
||||
case (hasHalfLife || hasApplies) && !hasProv:
|
||||
return fmt.Errorf("%s is required when %s or %s is set",
|
||||
FieldProvenance, FieldHalfLife, FieldApplies)
|
||||
case hasProv && !hasHalfLife && !hasApplies:
|
||||
return fmt.Errorf("%s is set but neither %s nor %s is",
|
||||
FieldProvenance, FieldHalfLife, FieldApplies)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// present reports a trimmed field value and whether it was set to anything
|
||||
// non-empty. A key written with an empty value is treated as absent rather
|
||||
// than as an error: that is a caller emitting a placeholder, not asserting.
|
||||
func present(fm map[string]string, key string) (string, bool) {
|
||||
v := strings.TrimSpace(fm[key])
|
||||
return v, v != ""
|
||||
}
|
||||
|
||||
func oneOf(field, value string, allowed []string) error {
|
||||
for _, a := range allowed {
|
||||
if a == value {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s: %q is not one of %s", field, value, strings.Join(allowed, ", "))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package claim_test covers the claim-property envelope (brain#21).
|
||||
package claim_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fm map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "absent is fine — the fields are opt-in on new writes",
|
||||
fm: map[string]string{"wing": "homelab", "hall": "facts"},
|
||||
},
|
||||
{
|
||||
name: "complete envelope with provenance",
|
||||
fm: map[string]string{
|
||||
"half_life": "durable", "applies": "universal", "claim_provenance": "asserted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inferred provenance is equally valid — it just must be stated",
|
||||
fm: map[string]string{
|
||||
"half_life": "volatile", "applies": "local", "claim_provenance": "inferred",
|
||||
},
|
||||
},
|
||||
{
|
||||
// A value nobody can act on is worse than no value: a lint
|
||||
// silently skips it, so the note looks classified and isn't.
|
||||
name: "unknown half_life value",
|
||||
fm: map[string]string{"half_life": "forever", "claim_provenance": "asserted"},
|
||||
wantErr: `half_life: "forever" is not one of volatile, seasonal, durable`,
|
||||
},
|
||||
{
|
||||
name: "unknown applies value",
|
||||
fm: map[string]string{"applies": "galaxy", "claim_provenance": "asserted"},
|
||||
wantErr: `applies: "galaxy" is not one of local, homelab, universal`,
|
||||
},
|
||||
{
|
||||
// brain#21: a value a model inferred must never be
|
||||
// indistinguishable from one Mathias asserted. Writing the field
|
||||
// without saying who set it is exactly that laundering.
|
||||
name: "envelope field without provenance",
|
||||
fm: map[string]string{"half_life": "durable"},
|
||||
wantErr: "claim_provenance is required when half_life or applies is set",
|
||||
},
|
||||
{
|
||||
name: "applies without provenance",
|
||||
fm: map[string]string{"applies": "homelab"},
|
||||
wantErr: "claim_provenance is required when half_life or applies is set",
|
||||
},
|
||||
{
|
||||
name: "unknown provenance value",
|
||||
fm: map[string]string{"half_life": "durable", "claim_provenance": "probably"},
|
||||
wantErr: `claim_provenance: "probably" is not one of asserted, inferred`,
|
||||
},
|
||||
{
|
||||
// Provenance alone says who set fields that aren't there.
|
||||
name: "provenance without any envelope field",
|
||||
fm: map[string]string{"claim_provenance": "asserted"},
|
||||
wantErr: "claim_provenance is set but neither half_life nor applies is",
|
||||
},
|
||||
{
|
||||
// The glossary wing has used scope: homelab-core since #5. The
|
||||
// envelope deliberately does NOT claim that key (brain#21).
|
||||
name: "glossary scope is untouched",
|
||||
fm: map[string]string{"scope": "homelab-core", "term": "koala"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := claim.ValidateEnvelope(tt.fm)
|
||||
if tt.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ type rpcError struct {
|
||||
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
||||
type Server struct {
|
||||
brainDir string
|
||||
govAudit capture.AuditSink
|
||||
govNotify govNotifier
|
||||
pipeline pipeline.Config
|
||||
llm pipeline.CompleteFunc
|
||||
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
|
||||
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||
)
|
||||
|
||||
@@ -52,6 +55,20 @@ func noteToolDescriptors() []map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// --- hyperguild#97: governance-rewrite observability -----------------------
|
||||
|
||||
// govNotifier raises an out-of-band alert. Declared here rather than importing
|
||||
// internal/audit so the MCP package keeps depending on capture alone.
|
||||
type govNotifier interface {
|
||||
Notify(ctx context.Context, msg string) error
|
||||
}
|
||||
|
||||
// WithGovernanceObserver attaches the audit sink and notifier that observe a
|
||||
// successful brain_edit_root. Both are optional; either may be nil.
|
||||
func (s *Server) WithGovernanceObserver(sink capture.AuditSink, n govNotifier) {
|
||||
s.govAudit, s.govNotify = sink, n
|
||||
}
|
||||
|
||||
type brainReadArgs struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
@@ -75,8 +92,17 @@ type brainEditArgs struct {
|
||||
}
|
||||
|
||||
// brainEdit is the model-facing editor: notes only.
|
||||
//
|
||||
// Deliberately NOT audited (#97). A note edit is already fully reconstructible
|
||||
// from git history, which carries the same before/after bytes plus a diff.
|
||||
// Governance files get the extra trail because they change what later agents
|
||||
// obey — the asymmetry is about instruction-carrying content, not about how
|
||||
// hard the change is to reconstruct. Auditing every note edit would also bury
|
||||
// the rare governance record under routine traffic, which is how an alert
|
||||
// stops being read.
|
||||
func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
return s.editWithScope(args, notes.ScopeNotes)
|
||||
_, out, err := s.editWithScope(args, notes.ScopeNotes)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
|
||||
@@ -89,22 +115,72 @@ func (s *Server) brainEditRoot(ctx context.Context, args json.RawMessage) (json.
|
||||
return nil, fmt.Errorf("brain_edit_root requires the local static principal; " +
|
||||
"a governance file is not editable from a model-facing origin")
|
||||
}
|
||||
out, err := s.editWithScope(args, notes.ScopeRoot)
|
||||
if err == nil {
|
||||
// A durable instruction rewrite should be loud, not silent.
|
||||
slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out))
|
||||
res, out, err := s.editWithScope(args, notes.ScopeRoot)
|
||||
if err != nil {
|
||||
// A refusal is not a rewrite. Recording or alerting on one would cry
|
||||
// wolf, and the gate already logs its own denials.
|
||||
return out, err
|
||||
}
|
||||
// A durable instruction rewrite should be loud, not silent.
|
||||
slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out))
|
||||
s.observeGovernanceEdit(ctx, info.principal, res)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (json.RawMessage, error) {
|
||||
// observeGovernanceEdit records and alerts on a rewrite that already landed.
|
||||
//
|
||||
// Best-effort by construction: the bytes are written and brain-sync will commit
|
||||
// them within the quarter hour, so failing the call here would report a
|
||||
// non-rewrite to the caller while the rewrite sits on disk. A failure to
|
||||
// observe is therefore logged at ERROR — the one case where the trail itself is
|
||||
// what broke, and the only remaining signal.
|
||||
func (s *Server) observeGovernanceEdit(ctx context.Context, principal string, res notes.Result) {
|
||||
// Path and hashes only. The notification leaves the cluster for a phone
|
||||
// via a third-party topic; a hash identifies the rewrite, the bytes would
|
||||
// leak whatever was written into an instruction file.
|
||||
msg := fmt.Sprintf("brain_edit_root applied\npath=%s\nprincipal=%s\nbefore=%s\nafter=%s",
|
||||
res.Path, principal, res.PrevContentHash, res.ContentHash)
|
||||
|
||||
if s.govNotify != nil {
|
||||
if err := s.govNotify.Notify(ctx, msg); err != nil {
|
||||
slog.Error("brain_edit_root notify failed", "path", res.Path, "err", err)
|
||||
}
|
||||
}
|
||||
if s.govAudit == nil {
|
||||
return
|
||||
}
|
||||
// Internal, not Confidential: a governance file is not client data, and
|
||||
// Confidential would make DegradingSink REFUSE to record while loki is
|
||||
// down rather than buffer — losing exactly the record that matters most.
|
||||
outcome, err := s.govAudit.Reserve(ctx, classification.Internal)
|
||||
if err != nil {
|
||||
slog.Error("brain_edit_root audit reserve failed", "path", res.Path, "err", err)
|
||||
return
|
||||
}
|
||||
entry := capture.AuditEntry{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Principal: principal,
|
||||
Actor: "brain_edit_root",
|
||||
Harness: "mcp",
|
||||
EffectiveClassification: classification.Internal.String(),
|
||||
Items: []string{res.Path},
|
||||
SecurityEvents: []string{fmt.Sprintf(
|
||||
"governance file rewritten: %s before=%s after=%s",
|
||||
res.Path, res.PrevContentHash, res.ContentHash)},
|
||||
}
|
||||
if err := s.govAudit.Record(ctx, entry, outcome); err != nil {
|
||||
slog.Error("brain_edit_root audit record failed", "path", res.Path, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.Result, json.RawMessage, error) {
|
||||
var a brainEditArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return notes.Result{}, nil, err
|
||||
}
|
||||
|
||||
// Reindex only when the edit could have changed what the index reads.
|
||||
@@ -121,7 +197,8 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (json.Ra
|
||||
res.ReindexError = ierr.Error()
|
||||
}
|
||||
}
|
||||
return json.Marshal(res)
|
||||
out, merr := json.Marshal(res)
|
||||
return res, out, merr
|
||||
}
|
||||
|
||||
// wingOf returns the wing for a wiki path, or "" for knowledge/ and root.
|
||||
|
||||
@@ -2,13 +2,22 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -125,3 +134,164 @@ func mustJSON(t *testing.T, v any) []byte {
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
// ---- hyperguild#97: a governance rewrite must be observed, not just gated ----
|
||||
|
||||
// govSink captures audit records. Governance edits are rare, so the whole
|
||||
// history is kept and asserted on rather than sampled.
|
||||
type govSink struct {
|
||||
entries []capture.AuditEntry
|
||||
outcomes []capture.AuditOutcome
|
||||
recErr error
|
||||
}
|
||||
|
||||
func (s *govSink) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||
return capture.AuditCentral, nil
|
||||
}
|
||||
|
||||
func (s *govSink) Record(_ context.Context, e capture.AuditEntry, o capture.AuditOutcome) error {
|
||||
s.entries = append(s.entries, e)
|
||||
s.outcomes = append(s.outcomes, o)
|
||||
return s.recErr
|
||||
}
|
||||
|
||||
type govNotifier struct {
|
||||
msgs []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (n *govNotifier) Notify(_ context.Context, msg string) error {
|
||||
n.msgs = append(n.msgs, msg)
|
||||
return n.err
|
||||
}
|
||||
|
||||
// govServer wires capture (which is what re-derives the static principal) plus
|
||||
// the #97 observer, and returns the brain dir so the test can read the file.
|
||||
func govServer(t *testing.T) (*mcp.Server, string, *govSink, *govNotifier) {
|
||||
t.Helper()
|
||||
dir := newBrainRepo(t)
|
||||
cfg, err := classification.Load(dir)
|
||||
require.NoError(t, err)
|
||||
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||
|
||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||
srv.WithCapture(svc, nil, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||
sink, notifier := &govSink{}, &govNotifier{}
|
||||
srv.WithGovernanceObserver(sink, notifier)
|
||||
return srv, dir, sink, notifier
|
||||
}
|
||||
|
||||
// authCall is call() with an Authorization header, so the static principal is
|
||||
// present and brain_edit_root's gate opens.
|
||||
func authCall(t *testing.T, srv *mcp.Server, authz, name string, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": name, "arguments": args},
|
||||
}))
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
return resp
|
||||
}
|
||||
|
||||
func sha256hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestBrainEditRoot_RecordsAnAuditEntryNamingPrincipalPathAndBothHashes(t *testing.T) {
|
||||
// The gate proves a rewrite was ALLOWED. Only a record proves one HAPPENED
|
||||
// — and brain-sync will commit it within 15 minutes authored `brain-sync`,
|
||||
// so git history cannot tell it apart from routine note traffic.
|
||||
srv, dir, sink, _ := govServer(t)
|
||||
|
||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
||||
})
|
||||
require.NotContains(t, resp, "error", "%v", resp["error"])
|
||||
|
||||
require.Len(t, sink.entries, 1, "exactly one audit record per governance rewrite")
|
||||
e := sink.entries[0]
|
||||
assert.Equal(t, "local-cli", e.Principal)
|
||||
assert.Contains(t, e.Items, "CLAUDE.md", "the record must name what was rewritten")
|
||||
|
||||
joined := strings.Join(e.SecurityEvents, " ")
|
||||
assert.Contains(t, joined, sha256hex("# rules\n"), "before-hash must be recorded")
|
||||
assert.Contains(t, joined, sha256hex("# rewritten\n"), "after-hash must be recorded")
|
||||
assert.NotZero(t, e.Timestamp)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "# rewritten\n", string(got))
|
||||
}
|
||||
|
||||
func TestBrainEditRoot_NotifiesWithPathAndHashesButNeverContent(t *testing.T) {
|
||||
srv, _, _, notifier := govServer(t)
|
||||
|
||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
||||
})
|
||||
require.NotContains(t, resp, "error", "%v", resp["error"])
|
||||
|
||||
require.Len(t, notifier.msgs, 1)
|
||||
msg := notifier.msgs[0]
|
||||
assert.Contains(t, msg, "CLAUDE.md")
|
||||
assert.Contains(t, msg, "local-cli")
|
||||
assert.Contains(t, msg, sha256hex("# rules\n"))
|
||||
assert.Contains(t, msg, sha256hex("# rewritten\n"))
|
||||
|
||||
// The notification travels to a phone through a third-party topic. A hash
|
||||
// identifies a rewrite; the bytes would leak whatever was written.
|
||||
assert.NotContains(t, msg, "# rewritten", "no file content in the notification body")
|
||||
assert.NotContains(t, msg, "# rules")
|
||||
}
|
||||
|
||||
func TestBrainEditRoot_RefusedProducesNoRecordNoNotificationNoWrite(t *testing.T) {
|
||||
srv, dir, sink, notifier := govServer(t)
|
||||
|
||||
// No Authorization header → no static principal → the gate stays shut.
|
||||
resp := authCall(t, srv, "", "brain_edit_root", map[string]any{
|
||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
||||
})
|
||||
require.Contains(t, resp, "error")
|
||||
|
||||
assert.Empty(t, sink.entries, "a refusal is not a rewrite — recording one would cry wolf")
|
||||
assert.Empty(t, notifier.msgs)
|
||||
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "# rules\n", string(got))
|
||||
}
|
||||
|
||||
func TestBrainEditRoot_FailedEditProducesNoRecordNoNotification(t *testing.T) {
|
||||
// Authorised but no match: the door opened and nothing went through it.
|
||||
srv, _, sink, notifier := govServer(t)
|
||||
|
||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
||||
"path": "CLAUDE.md", "old_str": "absent-from-the-file", "new_str": "x",
|
||||
})
|
||||
require.Contains(t, resp, "error")
|
||||
|
||||
assert.Empty(t, sink.entries)
|
||||
assert.Empty(t, notifier.msgs)
|
||||
}
|
||||
|
||||
func TestBrainEdit_OnOrdinaryNotesIsNotAudited(t *testing.T) {
|
||||
// The decided asymmetry (#97): note edits are covered by git history, which
|
||||
// carries the same before/after bytes with a diff. Governance files get the
|
||||
// extra trail because they change what later agents obey, not because they
|
||||
// are harder to reconstruct.
|
||||
srv, _, sink, notifier := govServer(t)
|
||||
|
||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit", map[string]any{
|
||||
"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA",
|
||||
})
|
||||
require.NotContains(t, resp, "error", "%v", resp["error"])
|
||||
|
||||
assert.Empty(t, sink.entries, "note edits must not fill the governance audit trail")
|
||||
assert.Empty(t, notifier.msgs)
|
||||
}
|
||||
|
||||
@@ -36,11 +36,15 @@ var pathLocks sync.Map // string -> *sync.Mutex
|
||||
|
||||
// Result is what a successful edit reports.
|
||||
type Result struct {
|
||||
Path string `json:"path"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
BytesBefore int `json:"bytes_before"`
|
||||
BytesAfter int `json:"bytes_after"`
|
||||
ReindexError string `json:"reindex_error,omitempty"`
|
||||
Path string `json:"path"`
|
||||
// PrevContentHash is the sha256 of the file as it was before this edit.
|
||||
// Paired with ContentHash it identifies a rewrite in an audit trail or an
|
||||
// alert without carrying any of the bytes (hyperguild#97).
|
||||
PrevContentHash string `json:"prev_content_hash"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
BytesBefore int `json:"bytes_before"`
|
||||
BytesAfter int `json:"bytes_after"`
|
||||
ReindexError string `json:"reindex_error,omitempty"`
|
||||
}
|
||||
|
||||
// Read returns a note's bytes verbatim.
|
||||
@@ -149,11 +153,13 @@ func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(after))
|
||||
prev := sha256.Sum256(before)
|
||||
return Result{
|
||||
Path: t.Rel,
|
||||
ContentHash: hex.EncodeToString(sum[:]),
|
||||
BytesBefore: len(before),
|
||||
BytesAfter: len(after),
|
||||
Path: t.Rel,
|
||||
PrevContentHash: hex.EncodeToString(prev[:]),
|
||||
ContentHash: hex.EncodeToString(sum[:]),
|
||||
BytesBefore: len(before),
|
||||
BytesAfter: len(after),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user