Compare commits

..
Author SHA1 Message Date
mathiasandClaude Opus 5.5 a6da93332d feat(brain): audit-log every tools/call; server-owned write provenance (#147)
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 4s
Closes #144 (audit line per tools/call) and #145 (origin/written_by stamped on every model-reachable write, surfaced in brain_query/brain_get).

Passed pr-gatekeeper after four rounds of adversarial review, verified by execution: #147

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-01 21:06:36 +00:00
mathiasandClaude Opus 5.5 f21be1aef6 chore(context): re-sync adapters after root brain-scoping correction
CI / Lint / Test / Vet (push) Successful in 43s
CI / Mirror to GitHub (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:42:10 +02:00
mathiasandClaude Opus 5 3c70b6f367 fix(claudewatcher): scrub credentials embedded in URL userinfo
CI / Lint / Test / Vet (push) Successful in 33s
CI / Mirror to GitHub (push) Successful in 5s
The scrubber had a rule for `postgres://user:pass@` and none for the
general `scheme://user:secret@host` form — which is exactly what a git
credential store holds:

    https://<user>:<token>@git.d-ma.be

Verified against all 13 DefaultRules with an invented value: it matched
none of them. Not authorization-header, not bearer-token, and not
homelab-env-token, which needs a `_TOKEN=`-style prefix a bare URL does
not have. So such a turn was ingested rather than dropped, against the
stated intent in this file's own doc comment:

    Fail-closed by design: we'd rather lose signal than ingest
    credentials into a public-readable brain.

It failed closed for the shapes it named and open for this one.

How it surfaced: a test suite in mathias/infra executed a script that
configures git while inheriting the operator's real $HOME, read koala's
live ~/.git-credentials, and printed two live values. ~/.claude/projects
is this watcher's own input, so the scrubber was the last line of defence
for that mistake. A sweep of that tree found 12 occurrences of 2 distinct
credential-shaped URLs across 4 transcripts in 3 sessions — so the shape
had been passing for a while, not just that once. Both credentials are
being rotated (infra#466).

The generalisable point 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 or
permanent.

Ordering: the new rule sits after postgres-uri-with-password so a DSN
still reports the more specific name, the same convention as
authorization-header before bearer-token.

Both directions are tested, because a rule that dropped every URL with a
colon would discard most of the signal this watcher exists to keep. The
{8,} floor on the secret half keeps `https://host:443/path` and
`https://user:@host` out; the `[^:\s/@]+` user half stops a host:port
pair being read as userinfo. Must-not-scrub cases include a host with a
port, a password-less DSN, a colon in a query string, and the literal
printf format string `https://oauth2:%s@%s`.

Closes #142
Refs mathias/infra#466

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 16:08:17 +02:00
mathiasandClaude Opus 5 b3b555fa23 chore(context): adopt canonical context-sync.sh and re-sync adapters
CI / Lint / Test / Vet (push) Successful in 1m46s
CI / Mirror to GitHub (push) Successful in 4s
infra's scripts/context-sync.sh is the estate canonical, and it moved in infra
262369b (a SIGPIPE fix on the placeholder-reporting path: `printf | grep -o |
head -1` became `head -1 < <(grep -o ...)`). Every other repo still carried the
old line, so ContextSyncScriptDrift and ContextAdapterDrift were both firing.

Also picks up the canonical AGENT.md change from local-dev a01ee80 — rule 5's
GitOps review lane and the new precedence rule — which every repo's adapters
need regenerated for regardless of the script.

Refs mathias/infra#470

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:36:40 +02:00
mathiasandClaude Opus 5 e9805c7eb6 chore(context): regenerate derived adapters from the canonical root context
CI / Lint / Test / Vet (push) Successful in 56s
CI / Mirror to GitHub (push) Successful in 5s
ContextAdapterDrift was firing for 13 repos. Picks up behaviour rule 7
("Finish what's closable"), which landed in ~/dev/.context/AGENT.md and never
reached any repo's derived files.

Where mcp.json changes, it also picks up root MCP config the repo was stale on:
brain/gitea/infra move from public hostnames to tailnet Serve URLs, and gitea's
bearer changes from GITEA_MCP_TOKEN to DMABE_GITEA_API_TOKEN — gitea-mcp
authenticates by pass-through and 401s on the former, so a harness syncing from
the old file had a dead gitea server and no signal saying so.

No literal secrets: mcp.json carries ${VAR} placeholders only.

CLAUDE.md deliberately unchanged — context-sync writes it from PROJECT.md alone,
since Claude Code walks up to ~/dev/CLAUDE.md for root context.

Refs mathias/infra ContextAdapterDrift

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 07:34:25 +02:00
mathias 3d5af85713 fix(frontmatter): consolidate 3 independent fence-parsers into one (#139)
CI / Lint / Test / Vet (push) Successful in 32s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 13:07:32 +00:00
mathiasandClaude Sonnet 5 c6341f020a feat(server): say at startup when alerting is unconfigured (#107) (#140)
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 5s
feat(server): say at startup when alerting is unconfigured (#107)

WARN once at startup when BRAIN_NTFY_URL is unset, naming what will not fire (brain_edit_root governance rewrites, capture audit degradation). Rebase of PR #108 (never CI-tested) onto current main, no functional change to alertingWarnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 13:02:40 +00:00
mathiasandClaude Sonnet 5 e157311cd8 fix(mcp): validate the claim envelope before brain_edit writes too (#136)
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 4s
7 review rounds closed 8 distinct bypasses across two mechanism families: fence-detection (field-name substring, block-containment, fence-stripping, CRLF, UTF-8 BOM) and value-extraction (folded YAML block scalar hiding an invalid value). A known v1 gap remains in the block-scalar fold's handling of blank-line-interrupted continuations — documented on checkClaimEnvelope, accepted deliberately (narrow, requires an already-privileged caller deliberately constructing a specific malformed-YAML shape) rather than chased past diminishing returns. Real fix (shared, tested, encoding-normalized frontmatter parser) tracked separately as hyperguild#138.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 10:28:07 +00:00
mathias a5f502eb9c fix(api): UpdateNote reserves updated_at/supersedes/supersede_reason from the caller's fragment (#137)
CI / Mirror to GitHub (push) Successful in 3s
CI / Lint / Test / Vet (push) Successful in 39s
Closes #125. Skips updated_at/supersedes/supersede_reason in the caller fragment merge loop so they cannot be set via the fragment, only via the intended entry points.
delete_branch_after_merge=true

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 08:18:30 +00:00
mathias 7ed2a7d3b4 fix(api): frontmatter parser must not misparse indented list items as keys (#135)
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 40s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 08:12:06 +00:00
mathiasandClaude Sonnet 5 674858fe5e fix(watcher): never walk into raw/rejected/ — brain_reject (closes #81)
CI / Lint / Test / Vet (push) Successful in 33s
CI / Mirror to GitHub (push) Successful in 4s
Adds brain_reject (RejectNote + MCP tool) and fixes the watcher to skip raw/rejected/, preventing declined notes from being silently re-ingested and republished.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 06:06:35 +00:00
mathias 9a37881441 feat(pipeline): feed the current source page into the extraction prompt (#106) (#132)
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 05:45:47 +00:00
mathias f324f04944 fix: hyperguild brain write requires wing/hall, not legacy type/slug (#79)
CI / Lint / Test / Vet (push) Successful in 34s
CI / Mirror to GitHub (push) Successful in 4s
Removes brain_write legacy domain/type/knowledge-path params (ADR-0012) and migrates the previously-missed cmd/hyperguild CLI caller (hyperguild brain write) from <type> <slug> to <wing> <hall> <slug>, fixing a 100%% regression the first review round caught. Adds TestWrite_CLIPayloadRoundTrips exercising the real Handler.Write over HTTP to pin the wire contract.

Closes #79.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 05:43:28 +00:00
mathiasandClaude Sonnet 5 cdafdf6e0f fix(wiki): surface merge conflicts as textual-diff warnings (closes #104)
CI / Lint / Test / Vet (push) Successful in 33s
CI / Mirror to GitHub (push) Successful in 4s
Textual-diff conflict detection only (semantic contradiction detection via brain-gardener Judge deferred, confirmed by Mathias, comment 12594). Includes canonicalBlock whitespace-run normalization fix for the disagreement comparison.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-23 05:22:27 +00:00
57 changed files with 2959 additions and 704 deletions
+79 -3
View File
@@ -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`):
@@ -249,7 +307,13 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
filters by client: every token holder can read every wing, and `wing` on `brain_query`
is an optional argument, not an access control. Client confidentiality is enforced only
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
decided 2026-10-01. This line previously claimed client projects were filtered; they
were not.)
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
@@ -319,8 +383,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
+79 -3
View File
@@ -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`):
@@ -244,7 +302,13 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
filters by client: every token holder can read every wing, and `wing` on `brain_query`
is an optional argument, not an access control. Client confidentiality is enforced only
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
decided 2026-10-01. This line previously claimed client projects were filtered; they
were not.)
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
@@ -314,8 +378,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
+18
View File
@@ -104,6 +104,24 @@ Two fields are **generator-owned** and do get refreshed on a revise:
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
an explicit allowlist, so a field nobody has thought about stays human-owned.
### Disagreement is reported, not resolved
When the fresh extraction produces a *different version* of a section that
already exists, the on-disk version still wins — but the run now says so:
```
wiki/sources/shape-up.md: source now disagrees on section "Summary" — on-disk version kept
```
That line lands in `log.md` with the rest of the run's warnings. Without it, "the
source is unchanged" and "the source changed its mind and we ignored it" look
identical from the outside.
This is deliberately **textual** disagreement, not semantic contradiction: cheap,
deterministic, no model on the write path. Deciding whether a disagreement is a
genuine contradiction stays with the Judge-gated `brain-enrich` pass, which is
approval-gated by design. Detection here, adjudication there.
## Re-ingesting an unchanged source is skipped
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
+7 -4
View File
@@ -55,18 +55,21 @@ Flags:
- `--limit N` — max results (default 5)
- `--json` — emit the raw response envelope
### `hyperguild brain write <type> <slug>`
### `hyperguild brain write <wing> <hall> <slug>`
Reads markdown from stdin, writes a knowledge entry.
Reads markdown from stdin, writes it to `brain/wiki/<wing>/<hall>/<slug>.md`
with `wing`/`hall`/`created_at` frontmatter injected (there is no legacy
`brain/knowledge/` fallback — ADR-0012 / #79, both wing and hall are
required).
```bash
$ cat <<EOF | hyperguild brain write knowledge example-lesson
$ cat <<EOF | hyperguild brain write homelab facts example-lesson
# Example lesson
## Lesson
...
EOF
knowledge/example-lesson.md
wiki/homelab/facts/example-lesson.md
```
### `hyperguild brain pass-rate <skill>`
+6 -5
View File
@@ -60,13 +60,14 @@ func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout,
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if fs.NArg() < 2 {
return errors.New("type and slug required (e.g. brain write knowledge my-slug)")
if fs.NArg() < 3 {
return errors.New("wing, hall and slug required (e.g. brain write homelab facts my-slug)")
}
kind := fs.Arg(0)
slug := fs.Arg(1)
wing := fs.Arg(0)
hall := fs.Arg(1)
slug := fs.Arg(2)
res, err := newBrainClient().Write(ctx, kind, slug, stdin)
res, err := newBrainClient().Write(ctx, wing, hall, slug, stdin)
if err != nil {
return err
}
+5 -5
View File
@@ -101,7 +101,7 @@ func TestRunBrainWrite_Success(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(
context.Background(),
[]string{"write", "knowledge", "test-slug"},
[]string{"write", "homelab", "facts", "test-slug"},
strings.NewReader("# Test\n\nSome body content.\n"),
&out, &errBuf,
)
@@ -111,9 +111,9 @@ func TestRunBrainWrite_Success(t *testing.T) {
func TestRunBrainWrite_MissingArgs(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf)
err := runBrain(context.Background(), []string{"write", "homelab"}, strings.NewReader("x"), &out, &errBuf)
assert.Error(t, err)
assert.Contains(t, err.Error(), "type and slug required")
assert.Contains(t, err.Error(), "wing, hall and slug required")
}
func TestRunBrainWrite_BackendError(t *testing.T) {
@@ -127,7 +127,7 @@ func TestRunBrainWrite_BackendError(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(
context.Background(),
[]string{"write", "knowledge", "bad slug"},
[]string{"write", "homelab", "facts", "bad slug"},
strings.NewReader("body"),
&out, &errBuf,
)
@@ -150,7 +150,7 @@ func TestRunBrainWrite_EmptyStdin(t *testing.T) {
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf)
err := runBrain(context.Background(), []string{"write", "homelab", "facts", "empty"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
}
+6 -5
View File
@@ -84,16 +84,17 @@ type WriteResult struct {
Path string `json:"path"`
}
func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) {
func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, content io.Reader) (*WriteResult, error) {
body, err := io.ReadAll(content)
if err != nil {
return nil, fmt.Errorf("read content: %w", err)
}
payload, err := json.Marshal(struct {
Type string `json:"type"`
Slug string `json:"slug"`
Content string `json:"content"`
}{Type: kind, Slug: slug, Content: string(body)})
Wing string `json:"wing"`
Hall string `json:"hall"`
Filename string `json:"filename"`
Content string `json:"content"`
}{Wing: wing, Hall: hall, Filename: filename, Content: string(body)})
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
+10 -8
View File
@@ -65,23 +65,25 @@ func TestBrainClient_Write_Success(t *testing.T) {
assert.Equal(t, http.MethodPost, r.Method)
body, _ := io.ReadAll(r.Body)
var got struct {
Type string `json:"type"`
Slug string `json:"slug"`
Content string `json:"content"`
Wing string `json:"wing"`
Hall string `json:"hall"`
Filename string `json:"filename"`
Content string `json:"content"`
}
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "knowledge", got.Type)
assert.Equal(t, "find-h", got.Slug)
assert.Equal(t, "homelab", got.Wing)
assert.Equal(t, "facts", got.Hall)
assert.Equal(t, "find-h", got.Filename)
assert.Equal(t, "# body\n", got.Content)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`))
_, _ = w.Write([]byte(`{"path":"wiki/homelab/facts/find-h.md"}`))
}))
defer srv.Close()
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n"))
res, err := c.Write(context.Background(), "homelab", "facts", "find-h", strings.NewReader("# body\n"))
require.NoError(t, err)
assert.Equal(t, "knowledge/find-h.md", res.Path)
assert.Equal(t, "wiki/homelab/facts/find-h.md", res.Path)
}
func TestBrainClient_PassRate_Success(t *testing.T) {
@@ -16,7 +16,7 @@ Three needs converge on a single small Go binary:
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
- [ ] `hyperguild brain write <type> <slug>` reads markdown content from stdin, posts to `/write` with the type and slug, and creates `brain/knowledge/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry.
- [ ] `hyperguild brain write <wing> <hall> <slug>` reads markdown content from stdin, posts to `/write` with wing/hall/filename, and creates `brain/wiki/<wing>/<hall>/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry. (Updated post-ADR-0012/#79: the legacy `<type> <slug>` shape and the `brain/knowledge/` fallback were removed; wing and hall are now required.)
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
@@ -50,7 +50,7 @@ Three needs converge on a single small Go binary:
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
- `brain write <wing> <hall> <slug>` reads stdin, posts `POST /write` with JSON body `{wing, hall, filename, content}`, prints the resulting path on success. (Updated post-ADR-0012/#79: wing and hall are required, replacing the old `<type> <slug>` shape and the `brain/knowledge/` legacy fallback.)
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
+35
View File
@@ -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")
+40
View File
@@ -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)
}
})
}
}
+44 -12
View File
@@ -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,21 +31,37 @@ 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
// wrapped value) never a top-level key, regardless of whether it
// happens to contain a colon — " - 'Chapter 3: The Reckoning'" is
// not a key named "- 'Chapter 3" (hyperguild#128). Checked before
// Cut so a colon inside one never reaches the key/value path.
if line != "" && (line[0] == ' ' || line[0] == '\t') {
fm.lines = append(fm.lines, fmLine{raw: line})
continue
}
key, val, ok := strings.Cut(line, ":")
key = strings.TrimSpace(key)
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
@@ -83,9 +105,19 @@ func (f *frontmatter) render() string {
var b strings.Builder
b.WriteString("---\n")
for _, l := range f.lines {
if l.key == "" {
switch {
case l.key == "":
b.WriteString(l.raw)
} else {
case l.value == "":
// A key with nothing after the colon (the YAML block-list
// shape: "aliases:" followed by indented "- item" lines on
// their own raw lines) must stay bare — "key: " with a
// trailing space is a different byte sequence than the
// original, and this type's whole purpose (see the package
// doc) is a byte-preserving round-trip.
b.WriteString(l.key)
b.WriteByte(':')
default:
b.WriteString(l.key)
b.WriteString(": ")
b.WriteString(l.value)
@@ -59,3 +59,37 @@ func TestFrontmatterRenderRoundTrips(t *testing.T) {
fm, _ := parseFrontmatter(src)
assert.Equal(t, src, fm.render())
}
func TestParseFrontmatterIndentedListItemWithColonIsNotMisparsedAsKey(t *testing.T) {
// hyperguild#128: an indented YAML block-list item that happens to
// contain a colon (e.g. a title with a subtitle) is a continuation
// line, never a top-level key — strings.Cut doesn't know that just
// because TrimSpace(key) happens to be non-empty. Misparsing it as a
// key/value pair and re-rendering strips its indentation, de-indenting
// it out of the list it belongs to. Same bug class already found and
// fixed in the sibling internal/wiki package's parseFrontmatterBlock
// (hyperguild#126 review round 3) — this package was the original the
// reimplementation was modeled on and had the gap too.
src := "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\n---\nbody\n"
fm, _ := parseFrontmatter(src)
fm.set("updated_at", "2026-09-23T00:00:00Z")
out := fm.render()
assert.Contains(t, out, " - 'Chapter 3: The Reckoning'",
"the indented list item must survive verbatim, not get re-emitted as a de-indented fake key")
assert.NotContains(t, out, "\n- 'Chapter 3",
"must not be split into a fake key/value pair at column 0 (as opposed to surviving indented, asserted above)")
}
func TestFrontmatterRenderEmptyValueKeyStaysBareNoTrailingSpace(t *testing.T) {
// Companion to the indented-list-item fix: "aliases:" (a key with
// nothing after the colon, the YAML block-list header) must round-trip
// bare, not as "aliases: " with a trailing space — a different byte
// sequence than the original, breaking this type's whole reason for
// being a line-preserving view rather than a YAML re-marshal.
// render() returns only the frontmatter block (see
// TestFrontmatterRenderRoundTrips), no trailing body.
src := "---\naliases:\n - one\n - two\n---\n"
fm, _ := parseFrontmatter(src)
assert.Equal(t, src, fm.render())
}
+46 -78
View File
@@ -54,8 +54,6 @@ type queryRequest struct {
type writeRequest struct {
Content string `json:"content"`
Filename string `json:"filename,omitempty"`
Type string `json:"type,omitempty"`
Domain string `json:"domain,omitempty"`
Wing string `json:"wing,omitempty"`
Hall string `json:"hall,omitempty"`
SourceType string `json:"source_type,omitempty"` // "external" opts a hall=facts entry out of the internal default
@@ -110,20 +108,21 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
// WriteNoteOptions configures how a brain note is written.
//
// When both Wing and Hall are non-empty, the note routes into the
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
// wing/hall/created_at injected into its YAML frontmatter.
//
// When either is empty, the note falls back to brain/knowledge/<filename>
// with optional type/domain frontmatter (legacy behaviour).
// Wing and Hall are both required: the note routes into the structured
// wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets wing/hall/created_at
// injected into its YAML frontmatter. There is no other valid destination
// (the legacy brain/knowledge/ fallback was removed — ADR-0012 / #79).
type WriteNoteOptions struct {
Content string
Filename string
Type string
Domain string
Wing string
Hall string
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
// Origin/WrittenBy are server-owned provenance (hyperguild#145): any
// origin/written_by in Content's own frontmatter is discarded, so a
// caller cannot claim a more trusted origin than it authenticated as.
Origin string
WrittenBy string
}
// WriteNote writes a markdown note into the brain. Returns the path
@@ -132,6 +131,9 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
if opts.Content == "" {
return "", fmt.Errorf("content is required")
}
if opts.Wing == "" || opts.Hall == "" {
return "", fmt.Errorf("wing and hall are required")
}
// In the primitive, not in a caller: POST /write reaches here directly,
// without the BrainStore, and a gate one door wide is not a gate.
@@ -143,13 +145,7 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
}
opts.Content = clean
if opts.Wing != "" && opts.Hall != "" {
return writeHallNote(brainDir, opts)
}
if opts.Wing != "" || opts.Hall != "" {
return "", fmt.Errorf("wing and hall must be set together")
}
return writeLegacyNote(brainDir, opts)
return writeHallNote(brainDir, opts)
}
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
@@ -178,9 +174,6 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
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
@@ -207,9 +200,6 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
}
}
writeField("type", opts.Type)
writeField("domain", opts.Domain)
sourceType := opts.SourceType
if sourceType == "" && opts.Hall == "facts" {
// Most hall=facts entries are first-party (an eval/benchmark the
@@ -231,7 +221,17 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
}
fm.WriteString("---\n")
if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil {
final, err := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
if err != nil {
return "", err
}
// The directory is created only once the note is known to be writable:
// a refused write leaves nothing behind, not even an empty hall dir
// (brain#21; provenance refusal joins the envelope refusal here).
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create hall dir: %w", err)
}
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
return "", fmt.Errorf("write: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
@@ -300,69 +300,32 @@ func splitFrontmatter(content string) (fields []frontmatterField, body string) {
return fields, body
}
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
// callers that have not adopted the wing/hall taxonomy.
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
filename := opts.Filename
if filename == "" {
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
}
rawDir := filepath.Join(brainDir, "knowledge")
if err := os.MkdirAll(rawDir, 0o755); err != nil {
return "", fmt.Errorf("create raw dir: %w", err)
}
finalContent := opts.Content
if opts.Type != "" || opts.Domain != "" {
var fm strings.Builder
fm.WriteString("---\n")
if opts.Type != "" {
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
}
if opts.Domain != "" {
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
}
fm.WriteString("---\n")
finalContent = fm.String() + opts.Content
}
if strings.ContainsAny(filename, `/\`) {
return "", fmt.Errorf("invalid filename")
}
base := filepath.Base(filename)
if base == "." || base == ".." || base == "" {
return "", fmt.Errorf("invalid filename")
}
if !strings.HasSuffix(base, ".md") {
base += ".md"
}
dest := filepath.Join(rawDir, base)
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
return "", fmt.Errorf("write: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
return filepath.ToSlash(rel), nil
}
// Write handles POST /write — write raw content to brain/knowledge/.
// Write handles POST /write — write a note into brain/wiki/<wing>/<hall>/.
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
var req writeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
// Field by field, not a struct conversion: provenance is server-owned and
// this door carries no principal, so it stamps origin: unknown
// (hyperguild#145). A JSON body must never be able to set it.
relPath, err := WriteNote(h.brainDir, WriteNoteOptions{
Content: req.Content,
Filename: req.Filename,
Wing: req.Wing,
Hall: req.Hall,
SourceType: req.SourceType,
})
if err != nil {
h.logger.Error("write failed", "err", err)
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Wing != "" && req.Hall != "" {
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
}
// WriteNote errors out above unless both are set, so this always runs —
// no legacy fallback path left to guard against.
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
}
writeJSON(w, map[string]string{"path": relPath})
}
@@ -587,7 +550,8 @@ func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
return
}
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
// No principal on this door: pages are stamped origin: unknown (#145).
result, err := pipeline.RunRaw(r.Context(), h.brainDir, req.Source, req.Pages, req.DryRun)
if err != nil {
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
writeError(w, http.StatusInternalServerError, "ingest error")
@@ -643,7 +607,11 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
// Field by field: this door carries no principal, so the promoted note is
// stamped origin: unknown and the body cannot set provenance (#145).
rel, err := PromoteNote(h.brainDir, PromoteOptions{
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
+98 -46
View File
@@ -65,11 +65,13 @@ func TestQuery_ReturnsResults(t *testing.T) {
assert.NotEmpty(t, results)
}
func TestWrite_CreatesKnowledgeFile(t *testing.T) {
func TestWrite_CreatesWikiFile(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"content": "# Test note\n\nSome content.",
"filename": "test-note.md",
"filename": "test-note",
"wing": "homelab",
"hall": "facts",
})
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
rec := httptest.NewRecorder()
@@ -81,11 +83,25 @@ func TestWrite_CreatesKnowledgeFile(t *testing.T) {
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.NotEmpty(t, resp["path"])
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "test-note.md"))
content, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "test-note.md"))
require.NoError(t, err)
assert.Contains(t, string(content), "Some content.")
}
// TestWrite_RequiresWingAndHall is the HTTP-contract counterpart of
// TestWriteNote_RequiresWingAndHall: a caller (e.g. the MCP brain_write
// handler) that omits wing/hall gets a 400, not a silent legacy write.
func TestWrite_RequiresWingAndHall(t *testing.T) {
_, h := setup(t)
body, _ := json.Marshal(map[string]any{"content": "no destination"})
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Write(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestQuery_RequiresQuery(t *testing.T) {
_, h := setup(t)
body, _ := json.Marshal(map[string]any{"limit": 5})
@@ -97,27 +113,6 @@ func TestQuery_RequiresQuery(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"content": "Some learning.",
"filename": "typed-note.md",
"type": "concept",
"domain": "software",
})
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Write(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "typed-note.md"))
require.NoError(t, err)
assert.Contains(t, string(content), "type: concept")
assert.Contains(t, string(content), "domain: software")
assert.Contains(t, string(content), "Some learning.")
}
func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
dir := t.TempDir()
@@ -126,7 +121,6 @@ func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
Filename: "session-koala-abc",
Wing: "claude-sessions",
Hall: "facts",
Type: "source",
SourceType: "internal",
})
require.NoError(t, err)
@@ -210,37 +204,95 @@ func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testin
assert.Contains(t, body, "# Body")
}
func TestWriteNote_HallRouteExistingTypeWinsOverOptsType(t *testing.T) {
dir := t.TempDir()
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
Content: "---\ntype: hypothesis\n---\n\nBody.\n",
Filename: "note",
Wing: "agentsquad",
Hall: "decisions",
Type: "decision", // should lose to content's own "type: hypothesis"
})
require.NoError(t, err)
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
require.NoError(t, err)
assert.Contains(t, string(got), "type: hypothesis")
assert.NotContains(t, string(got), "type: decision")
// TestWriteNote_RequiresWingAndHall pins the post-legacy-removal contract
// (hyperguild#79 / ADR-0012): with the knowledge/ fallback gone, wing+hall
// is the only valid destination, so omitting either is a caller error, not
// a silent fallback.
func TestWriteNote_RequiresWingAndHall(t *testing.T) {
cases := []struct {
name string
wing string
hall string
}{
{"neither set", "", ""},
{"wing only", "homelab", ""},
{"hall only", "", "facts"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
_, err := api.WriteNote(dir, api.WriteNoteOptions{
Content: "body",
Wing: tc.wing,
Hall: tc.hall,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "wing and hall are required")
})
}
}
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{"content": "auto name"})
body, _ := json.Marshal(map[string]any{
"content": "auto name",
"wing": "homelab",
"hall": "facts",
})
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Write(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
entries, _ := os.ReadDir(filepath.Join(dir, "knowledge"))
// +1 because setup already wrote tdd.md
assert.Len(t, entries, 2)
assert.True(t, strings.HasSuffix(entries[1].Name(), ".md"))
entries, err := os.ReadDir(filepath.Join(dir, "wiki", "homelab", "facts"))
require.NoError(t, err)
require.Len(t, entries, 1)
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
}
// TestWrite_CLIPayloadRoundTrips exercises the real Handler.Write (not a
// hand-rolled mock) over an actual HTTP round trip, POSTing the exact JSON
// envelope cmd/hyperguild's brainClient.Write sends: {wing, hall, filename,
// content}. cmd/hyperguild lives in a separate Go module from ingestion
// (no shared go.work), so a Go-level test can't import brainClient
// directly — this test instead pins the wire contract from the server
// side and proves a real `hyperguild brain write <wing> <hall> <slug>`
// invocation writes a real file with real frontmatter under wiki/<wing>/<hall>/.
// A prior version of this contract test on the client side (in
// cmd/hyperguild/http_test.go) used only an httptest.NewServer with a
// hand-rolled mock body, so it stayed green through a regression where the
// server required wing/hall but the client never sent them (hyperguild#79
// PR review).
func TestWrite_CLIPayloadRoundTrips(t *testing.T) {
dir, h := setup(t)
srv := httptest.NewServer(http.HandlerFunc(h.Write))
defer srv.Close()
payload, err := json.Marshal(struct {
Wing string `json:"wing"`
Hall string `json:"hall"`
Filename string `json:"filename"`
Content string `json:"content"`
}{Wing: "homelab", Hall: "facts", Filename: "cli-roundtrip", Content: "# CLI roundtrip\n\nReal write.\n"})
require.NoError(t, err)
resp, err := http.Post(srv.URL+"/write", "application/json", bytes.NewReader(payload)) //nolint:noctx
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
var out struct {
Path string `json:"path"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
assert.Equal(t, "wiki/homelab/facts/cli-roundtrip.md", out.Path)
written, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "cli-roundtrip.md"))
require.NoError(t, err)
assert.Contains(t, string(written), "wing: homelab")
assert.Contains(t, string(written), "hall: facts")
assert.Contains(t, string(written), "Real write.")
}
// ---------------------------------------------------------------------------
+71 -1
View File
@@ -72,6 +72,10 @@ type PromoteOptions struct {
Wing string
Hall string
Slug string // optional; defaults to Filename minus date prefix + .md
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
// note's own frontmatter claims are not provenance (hyperguild#145).
Origin string
WrittenBy string
}
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
@@ -128,7 +132,11 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
return "", fmt.Errorf("create wing dir: %w", err)
}
// Write-then-delete: the source survives any write failure.
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
if err != nil {
return "", err
}
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
return "", fmt.Errorf("write promoted note: %w", err)
}
if err := os.Remove(src); err != nil {
@@ -145,6 +153,68 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
return relSlash, nil
}
// RejectOptions identifies a raw note to reject and why.
type RejectOptions struct {
Filename string // basename in brain/raw/
Reason string // optional
}
// RejectNote records a human decision not to promote a brain/raw/ note,
// without silently losing it: it stamps rejected_at (+ reject_reason when
// given) into frontmatter and moves the file to brain/raw/rejected/, using
// the same write-then-delete safety PromoteNote uses — the source survives
// any write failure — and the same basename-only filename validation.
//
// A rejected note never entered the wiki, so no index rebuild or
// auto-tunnel run: unlike PromoteNote, this is the end of the note's
// journey, not the start of a new one. It stays out of ListPending's view
// (raw/rejected/ is a subdirectory, and ListPending's os.ReadDir on raw/
// does not recurse), so a rejected note is never re-surfaced as pending —
// the human decision, once made, stays made, the same principle behind
// this estate's "issue status is surfaced, not auto-closed, but once a
// human decides, the decision is recorded" rule applied one level earlier.
func RejectNote(brainDir string, opts RejectOptions) (string, error) {
base := filepath.Base(opts.Filename)
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
return "", fmt.Errorf("invalid filename %q", opts.Filename)
}
src := filepath.Join(brainDir, "raw", base)
raw, err := os.ReadFile(src)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
}
return "", fmt.Errorf("read source: %w", err)
}
dest := filepath.Join(brainDir, "raw", "rejected", base)
if _, statErr := os.Stat(dest); statErr == nil {
rel, _ := filepath.Rel(brainDir, dest)
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
}
fm, body := parseFrontmatter(string(raw))
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
if opts.Reason != "" {
fm.set("reject_reason", opts.Reason)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create rejected dir: %w", err)
}
// Write-then-delete: the source survives any write failure.
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
return "", fmt.Errorf("write rejected note: %w", err)
}
if err := os.Remove(src); err != nil {
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
return filepath.ToSlash(rel), nil
}
// excerpt returns the first n runes of s, trimmed, single-spaced.
func excerpt(s string, n int) string {
s = strings.TrimSpace(s)
+82
View File
@@ -119,3 +119,85 @@ func TestPromoteRebuildsWingIndex(t *testing.T) {
require.NoError(t, err, "wing _index regenerated")
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
}
// --- RejectNote (hyperguild#81) ---------------------------------------------
func TestRejectHappyPath(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "2026-04-23-fed-sentiment-scrape.md",
"---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Sentiment scrape\n\nbody.\n")
rel, err := RejectNote(dir, RejectOptions{
Filename: "2026-04-23-fed-sentiment-scrape.md", Reason: "generic scrape, not homelab-relevant",
})
require.NoError(t, err)
assert.Equal(t, "raw/rejected/2026-04-23-fed-sentiment-scrape.md", rel)
// Source removed from the pending queue.
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-04-23-fed-sentiment-scrape.md"))
assert.True(t, os.IsNotExist(statErr), "source removed from raw/ after reject")
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
require.NoError(t, err)
s := string(got)
assert.Contains(t, s, "created_at: 2026-04-23T13:20:07Z", "original created_at preserved")
assert.Contains(t, s, "rejected_at:")
assert.Contains(t, s, "reject_reason: generic scrape, not homelab-relevant")
assert.Contains(t, s, "# Sentiment scrape", "body preserved for later reference")
// Rejected notes never entered the wiki, and are no longer pending.
pending, err := ListPending(dir)
require.NoError(t, err)
assert.Empty(t, pending)
}
func TestRejectWithoutReason(t *testing.T) {
dir := t.TempDir()
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
rel, err := RejectNote(dir, RejectOptions{Filename: "2026-06-01-x.md"})
require.NoError(t, err)
got, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
assert.NotContains(t, string(got), "reject_reason:", "no reason given, none stamped")
}
func TestRejectMissingSourceErrors(t *testing.T) {
_, err := RejectNote(t.TempDir(), RejectOptions{Filename: "ghost.md"})
require.Error(t, err)
}
func TestRejectRejectsTraversalFilename(t *testing.T) {
_, err := RejectNote(t.TempDir(), RejectOptions{Filename: "../escape.md"})
require.Error(t, err)
}
func TestRejectCollisionNoOverwrite(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "raw", "rejected", "x.md")
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
writeRaw(t, dir, "x.md", "NEW\n")
_, err := RejectNote(dir, RejectOptions{Filename: "x.md"})
require.Error(t, err, "collision must error, not overwrite")
got, _ := os.ReadFile(dest)
assert.Equal(t, "EXISTING\n", string(got), "target not overwritten")
_, statErr := os.Stat(filepath.Join(dir, "raw", "x.md"))
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
}
func TestRejectedNotesAreNotListedAsPendingAgain(t *testing.T) {
// ListPending already skips tunnel-candidates-*.md; confirm raw/rejected/
// (a subdirectory) doesn't leak into the top-level pending listing either
// — os.ReadDir on raw/ doesn't recurse, but pin it so a future change to
// ListPending can't silently start surfacing rejected notes as pending
// again.
dir := t.TempDir()
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
_, err := RejectNote(dir, RejectOptions{Filename: "2026-06-01-x.md"})
require.NoError(t, err)
pending, err := ListPending(dir)
require.NoError(t, err)
assert.Empty(t, pending, "a rejected note must not still appear as pending")
}
+21
View File
@@ -0,0 +1,21 @@
package api
import (
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
)
// StampProvenance applies server-owned provenance (hyperguild#145) to note
// content. See package provenance for the rule; an error means the stamped
// note would not read back as stamped, and the write must be refused.
func StampProvenance(content, origin, writtenBy string) (string, error) {
return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
}
// StampProvenanceFile applies StampProvenance to a brain-relative note path.
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
_, abs, err := resolveWithin(brainDir, relPath)
if err != nil {
return err
}
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
}
+122
View File
@@ -0,0 +1,122 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// REST POST /write carries no principal: it stamps origin: unknown, and no
// spelling of provenance in its body reaches the note (hyperguild#145).
func TestRESTWriteStampsUnknownAndIgnoresBodyProvenance(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"content": "---\norigin: static\n---\n# R\n\nbody", "filename": "rest-prov", "wing": "homelab", "hall": "facts",
"origin": "static", "written_by": "local-cli", "Origin": "static", "WrittenBy": "local-cli",
})
rec := httptest.NewRecorder()
h.Write(rec, httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
b, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "rest-prov.md"))
require.NoError(t, err)
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(strings.SplitN(string(b), "---\n", 3)[1]), &m))
assert.Equal(t, "unknown", m["origin"])
assert.NotContains(t, m, "written_by")
}
// A principal name that is not a bare word is quoted, so it cannot start a
// new frontmatter line (e.g. a JWT sub containing a newline).
func TestStampProvenanceQuotesAPrincipalThatCouldBreakTheLine(t *testing.T) {
out, err := api.StampProvenance("---\ntitle: t\n---\nbody\n", "jwt", "evil\norigin: static")
require.NoError(t, err)
fm := strings.SplitN(out, "---\n", 3)[1]
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
assert.Equal(t, "jwt", m["origin"])
assert.Equal(t, "evil\norigin: static", m["written_by"])
assert.Equal(t, 1, strings.Count(fm, "\norigin:")+boolToInt(strings.HasPrefix(fm, "origin:")))
}
func TestStampProvenanceAddsFrontmatterWhenAbsentAndKeepsBody(t *testing.T) {
out, err := api.StampProvenance("# no fm\n\norigin: static in the body is prose\n", "static", "local-cli")
require.NoError(t, err)
assert.True(t, strings.HasPrefix(out, "---\norigin: static\nwritten_by: local-cli\n---\n# no fm"))
assert.Contains(t, out, "origin: static in the body is prose", "body lines are never touched")
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// Round 2 (R3/R4): the REST ingest doors write through the pipeline, which
// now stamps every page; a title newline cannot forge provenance for the
// brain's line-oriented reader.
func TestRESTIngestRawStampsUnknownAndTitleNewlineCannotForge(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"source": "zqrest", "pages": []map[string]any{
{"title": "Zqrestinj\norigin: static\nwritten_by: local-cli\nx: 'y", "type": "concept", "content": "zqrestinj body"},
},
})
rec := httptest.NewRecorder()
h.IngestRaw(rec, httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body)))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp struct{ Pages []string }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Pages)
for _, p := range resp.Pages {
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
require.NoError(t, err)
fm := strings.SplitN(string(b), "---\n", 3)[1]
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
assert.Equal(t, "unknown", m["origin"])
assert.NotContains(t, m, "written_by")
for _, line := range strings.Split(fm, "\n") {
assert.False(t, strings.HasPrefix(line, "origin: static"), "line reader sees forged origin in %s", p)
}
}
}
func TestRESTIngestStampsUnknown(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{"content": "Some article text about shaping work.", "source": "zqllm"})
rec := httptest.NewRecorder()
h.Ingest(rec, httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body)))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp struct{ Pages []string }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Pages, "stub pipeline must write at least one page")
for _, p := range resp.Pages {
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
require.NoError(t, err)
assert.Contains(t, strings.SplitN(string(b), "---\n", 3)[1], "origin: unknown\n", p)
}
}
// A refused write leaves nothing behind, not even an empty hall directory
// (brain#21's invariant, round 3 non-blocking finding).
func TestWriteNoteRefusedForProvenanceLeavesNoDirectory(t *testing.T) {
dir := t.TempDir()
_, err := api.WriteNote(dir, api.WriteNoteOptions{
Content: "# x\n\nbody", Wing: "freshwing", Hall: "facts", Filename: "x", Origin: "jwt", WrittenBy: "bad\xff",
})
require.Error(t, err)
_, statErr := os.Stat(filepath.Join(dir, "wiki", "freshwing"))
assert.True(t, os.IsNotExist(statErr), "no wing directory may be created by a refused write")
}
+26 -3
View File
@@ -46,6 +46,11 @@ type UpdateNoteOptions struct {
Slug string
Content string // new full body (whole-note replace)
Reason string // optional; stamped as supersede_reason
// Origin/WrittenBy re-stamp provenance to the updating principal: the
// body is replaced wholesale, so the last writer is the author of what
// a reader sees (hyperguild#145). Never taken from Content's fragment.
Origin string
WrittenBy string
}
// UpdateNote supersedes an existing note in place. It replaces the body
@@ -110,9 +115,23 @@ 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" ||
l.key == "origin" || l.key == "written_by" {
continue
}
fm.set(l.key, l.value)
}
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
fm.set("supersedes", priorHash)
@@ -139,7 +158,11 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
return "", "", "", fmt.Errorf("claim envelope: %w", err)
}
out := []byte(fm.render() + contentBody)
stamped, err := StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy)
if err != nil {
return "", "", "", err
}
out := []byte(stamped)
if err := os.WriteFile(abs, out, 0o644); err != nil {
return "", "", "", fmt.Errorf("write: %w", err)
}
+39
View File
@@ -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")))
+7 -7
View File
@@ -49,12 +49,12 @@ func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error)
n.Content = content
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
Content: n.Content,
Filename: n.Filename,
Type: n.Type,
Domain: n.Domain,
Wing: n.Wing,
Hall: n.Hall,
Content: n.Content,
Filename: n.Filename,
Wing: n.Wing,
Hall: n.Hall,
Origin: n.Origin,
WrittenBy: n.WrittenBy,
})
if err != nil {
return capture.Ref{}, err
@@ -76,7 +76,7 @@ func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (captur
}
n.Content = content
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason, Origin: n.Origin, WrittenBy: n.WrittenBy}
if strings.Contains(slug, "/") {
opts.Path = slug
} else {
+1
View File
@@ -60,6 +60,7 @@ type CaptureContext struct {
Classification string // caller-declared level token ("" = unspecified)
Principal string // server-derived (auth); audit identity
Origin Zone // server-derived trust zone; the I1 gate input
AuthPath string // server-derived: "static" or "jwt"; stamped as note provenance (#145)
}
// Insight is one piece of session knowledge bound for the brain. A
+7 -5
View File
@@ -31,18 +31,20 @@ type StoredNote struct {
Body string
}
// Note is the brain-write payload. It carries both the wing/hall taxonomy
// and the legacy type/domain fields so a single BrainStore serves both
// capture insights and the existing MCP brain_write surface. Reason is
// Note is the brain-write payload: wing+hall are the only routing fields
// (the legacy type/domain params were removed — ADR-0012 / #79). Reason is
// the supersede rationale, used only by Update.
type Note struct {
Content string
Filename string
Wing string
Hall string
Type string
Domain string
Reason string
// Provenance (hyperguild#145) is stamped by the server from the
// authenticated principal, never taken from Content's frontmatter.
// Origin is "static", "jwt" or "unknown"; empty means "unknown".
Origin string
WrittenBy string
}
// BrainStore is the brain persistence port — the shared implementation of
+6 -3
View File
@@ -142,7 +142,7 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
var landed []string
for i, ins := range in.Insights {
res, item, err := s.persistInsight(ctx, ins)
res, item, err := s.persistInsight(ctx, ins, in.Context)
receipt.Insights = append(receipt.Insights, res)
if err != nil {
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
@@ -278,8 +278,11 @@ func escalationReason(escalators []string) string {
return "required by: " + strings.Join(escalators, ", ")
}
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
func (s *Service) persistInsight(ctx context.Context, ins Insight, cc CaptureContext) (InsightResult, string, error) {
note := Note{
Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text)),
Origin: cc.AuthPath, WrittenBy: cc.Principal,
}
var ref Ref
var err error
if ins.SupersedeSlug != "" {
@@ -104,6 +104,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// caller may have tried to put in the body.
in.Context.Principal = principal
in.Context.Origin = h.resolver.Resolve(principal, viaStatic)
in.Context.AuthPath = "jwt"
if viaStatic {
in.Context.AuthPath = "static"
}
rec, err := h.svc.Capture(r.Context(), in)
switch {
@@ -0,0 +1,51 @@
package capturehttp_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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"
)
// The REST capture door derives AuthPath itself (hyperguild#145); a body
// field cannot set it, and static vs JWT are told apart.
func TestRESTCaptureStampsAuthPathFromAuthentication(t *testing.T) {
for _, tc := range []struct {
name, authz, wantOrigin, wantBy string
v capturehttp.Validator
sovereign []string
}{
{"static", "Bearer " + staticTok, "origin: static\n", "written_by: local-cli\n", nil, nil},
{"jwt", "Bearer jwt", "origin: jwt\n", "written_by: koala-cli\n", fakeValidator{subject: "koala-cli"}, []string{"koala-cli"}},
} {
t.Run(tc.name, func(t *testing.T) {
brainDir := t.TempDir()
cfg, err := classification.Load(t.TempDir())
require.NoError(t, err)
svc := capture.NewService(brainstore.New(brainDir), fakeTracker{}, cfg, audit.NewSlogSink(nil))
h := capturehttp.New(svc, tc.v, staticTok, "local-cli", capturehttp.NewOriginResolver(tc.sovereign))
rr := do(t, h, tc.authz, map[string]any{
"context": map[string]any{
"harness": "claude-code", "actor": "mathias", "classification": "internal",
"AuthPath": "static", "auth_path": "static", "principal": "local-cli",
},
"insights": []map[string]any{{"text": "rest provenance probe", "wing": "hyperguild", "hall": "facts"}},
})
require.Equal(t, 200, rr.Code, rr.Body.String())
matches, _ := filepath.Glob(filepath.Join(brainDir, "wiki/hyperguild/facts/*.md"))
require.Len(t, matches, 1)
b, err := os.ReadFile(matches[0])
require.NoError(t, err)
assert.Contains(t, string(b), tc.wantOrigin)
assert.Contains(t, string(b), tc.wantBy)
})
}
}
@@ -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{
"",
+31 -7
View File
@@ -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))
}
+48
View File
@@ -0,0 +1,48 @@
package mcp
import (
"context"
"encoding/json"
"log/slog"
"time"
)
// logToolCall emits one structured record per tools/call (hyperguild#144,
// OWASP MCP08): who called which tool, against which wing/hall, and how it
// ended. Brain content is never copied into the record — only its size — so
// the audit trail does not become a second, unscrubbed copy of the brain.
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
origin, principal := provenanceFrom(ctx)
attrs := []any{
"tool", name,
"principal", principal,
"origin", origin,
"duration_ms", time.Since(start).Milliseconds(),
}
var a struct {
Wing string `json:"wing"`
Hall string `json:"hall"`
Content string `json:"content"`
}
if json.Unmarshal(args, &a) == nil {
if a.Wing != "" {
attrs = append(attrs, "wing", a.Wing)
}
if a.Hall != "" {
attrs = append(attrs, "hall", a.Hall)
}
if a.Content != "" {
attrs = append(attrs, "content_bytes", len(a.Content))
}
}
// The outcome is a class, never the error text: handler errors echo
// argument values (a rejected half_life, a requested path), which would
// make the audit line a copy of what the caller sent (PR #147 review E6).
// The caller already received the full error in the tool response.
outcome := "ok"
if err != nil {
outcome = "error"
}
attrs = append(attrs, "outcome", outcome)
slog.InfoContext(ctx, "mcp tool call", attrs...)
}
+83
View File
@@ -0,0 +1,83 @@
package mcp_test
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// captureToolCallLog routes slog to a buffer for fn and returns the decoded
// "mcp tool call" records.
func captureToolCallLog(t *testing.T, fn func()) []map[string]any {
t.Helper()
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
fn()
var out []map[string]any
for _, l := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
var rec map[string]any
if json.Unmarshal([]byte(l), &rec) == nil && rec["msg"] == "mcp tool call" {
out = append(out, rec)
}
}
return out
}
// hyperguild#144: brain_query/brain_read left no record of who read what.
func TestToolsCallLogsOneRecordWithToolPrincipalOriginAndScope(t *testing.T) {
srv, _, _, _ := govServer(t)
recs := captureToolCallLog(t, func() {
authCall(t, srv, "Bearer "+capStaticTok, "brain_query",
map[string]any{"query": "anything", "wing": "homelab", "hall": "facts"})
})
require.Len(t, recs, 1)
r := recs[0]
assert.Equal(t, "brain_query", r["tool"])
assert.Equal(t, "local-cli", r["principal"])
assert.Equal(t, "static", r["origin"])
assert.Equal(t, "homelab", r["wing"])
assert.Equal(t, "facts", r["hall"])
assert.Equal(t, "ok", r["outcome"])
assert.Contains(t, r, "duration_ms")
}
func TestToolsCallWithoutDerivablePrincipalLogsOriginUnknown(t *testing.T) {
srv, _, _, _ := govServer(t)
recs := captureToolCallLog(t, func() {
authCall(t, srv, "", "brain_query", map[string]any{"query": "x"})
})
require.Len(t, recs, 1)
assert.Equal(t, "unknown", recs[0]["origin"])
assert.Equal(t, "", recs[0]["principal"])
}
func TestToolsCallLogsErrorOutcome(t *testing.T) {
srv, _, _, _ := govServer(t)
recs := captureToolCallLog(t, func() {
authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{})
})
require.Len(t, recs, 1)
assert.Equal(t, "error", recs[0]["outcome"])
}
// The audit line must not become a second copy of brain content.
func TestToolsCallLogOmitsContentButRecordsItsSize(t *testing.T) {
const sentinel = "SENTINEL-BRAIN-CONTENT-91c2"
srv, _, _, _ := govServer(t)
recs := captureToolCallLog(t, func() {
authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# t\n\n" + sentinel, "wing": "testing", "hall": "facts", "filename": "audit-sentinel",
})
})
require.Len(t, recs, 1)
raw, _ := json.Marshal(recs[0])
assert.NotContains(t, string(raw), sentinel)
assert.EqualValues(t, len("# t\n\n"+sentinel), recs[0]["content_bytes"])
}
+58 -20
View File
@@ -16,6 +16,7 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
)
@@ -53,14 +54,12 @@ func (s *Server) tools() []map[string]any {
},
{
"name": "brain_write",
"description": "Write a markdown note to the brain. With wing+hall set, routes to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter; otherwise writes to brain/knowledge/ (legacy).",
"inputSchema": schema([]string{"content"}, map[string]any{
"description": "Write a markdown note to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter. Both wing and hall are required.",
"inputSchema": schema([]string{"content", "wing", "hall"}, map[string]any{
"content": str("markdown content"),
"filename": str("optional filename or slug"),
"type": str("optional frontmatter type (legacy)"),
"domain": str("optional frontmatter domain (legacy)"),
"wing": str("optional topic domain, e.g. jepa-fx"),
"hall": enum("optional memory type (requires wing)", halls...),
"wing": str("topic domain, e.g. jepa-fx"),
"hall": enum("memory type", halls...),
}),
},
{
@@ -98,6 +97,14 @@ func (s *Server) tools() []map[string]any {
"slug": str("optional target slug; defaults to filename minus date prefix"),
}),
},
{
"name": "brain_reject",
"description": "Reject a brain/raw/ note: stamps rejected_at (+ reject_reason if given) into frontmatter and moves it to brain/raw/rejected/ — never deleted outright, and never re-surfaced by brain_pending. The third option alongside promote/leave-pending; a human decision, never automatic. Returns {path}.",
"inputSchema": schema([]string{"filename"}, map[string]any{
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
"reason": str("optional; why this isn't worth promoting"),
}),
},
noteToolDescriptors()[0],
noteToolDescriptors()[1],
{
@@ -227,8 +234,6 @@ func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.Raw
type brainWriteArgs struct {
Content string `json:"content"`
Filename string `json:"filename,omitempty"`
Type string `json:"type,omitempty"`
Domain string `json:"domain,omitempty"`
Wing string `json:"wing,omitempty"`
Hall string `json:"hall,omitempty"`
}
@@ -251,13 +256,14 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
// one implementation (capture uses the same store). The read-after-write
// handle {id, path, content_hash} comes back from the store; path is kept
// for backward compatibility.
origin, writtenBy := provenanceFrom(ctx)
ref, err := s.store.Write(ctx, capture.Note{
Content: a.Content,
Filename: a.Filename,
Type: a.Type,
Domain: a.Domain,
Wing: a.Wing,
Hall: a.Hall,
Content: a.Content,
Filename: a.Filename,
Wing: a.Wing,
Hall: a.Hall,
Origin: origin,
WrittenBy: writtenBy,
})
if err != nil {
return nil, err
@@ -302,11 +308,14 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
if a.Path != "" {
slug = a.Path
}
origin, writtenBy := provenanceFrom(ctx)
ref, err := s.store.Update(ctx, slug, capture.Note{
Content: a.Content,
Wing: a.Wing,
Hall: a.Hall,
Reason: a.Reason,
Content: a.Content,
Wing: a.Wing,
Hall: a.Hall,
Reason: a.Reason,
Origin: origin,
WrittenBy: writtenBy,
})
if err != nil {
return nil, err
@@ -385,8 +394,10 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
origin, writtenBy := provenanceFrom(ctx)
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
Origin: origin, WrittenBy: writtenBy,
})
if err != nil {
return nil, err
@@ -395,6 +406,26 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
return json.Marshal(map[string]string{"path": relPath})
}
type brainRejectArgs struct {
Filename string `json:"filename"`
Reason string `json:"reason,omitempty"`
}
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
// frontmatter-stamp + move to raw/rejected/). No graph indexing — a rejected
// note never entered the wiki, so there's nothing to index.
func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainRejectArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
if err != nil {
return nil, err
}
return json.Marshal(map[string]string{"path": relPath})
}
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
// logs failures but never propagates them — the underlying write/ingest
// has already succeeded and the graph is an augmentation, not a
@@ -469,7 +500,7 @@ func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json
if len(a.Pages) == 0 {
return nil, fmt.Errorf("pages must be non-empty")
}
result, err := pipeline.RunRaw(s.brainDir, a.Source, a.Pages, a.DryRun)
result, err := pipeline.RunRaw(withCallerStamp(ctx), s.brainDir, a.Source, a.Pages, a.DryRun)
if err != nil {
return nil, fmt.Errorf("ingest: %w", err)
}
@@ -568,7 +599,7 @@ func (s *Server) sessionLog(ctx context.Context, args json.RawMessage) (json.Raw
}
func (s *Server) runIngest(ctx context.Context, content, source string, dryRun bool) (json.RawMessage, error) {
result, err := pipeline.Run(ctx, s.pipeline, s.brainDir, content, source, dryRun)
result, err := pipeline.Run(withCallerStamp(ctx), s.pipeline, s.brainDir, content, source, dryRun)
if err != nil {
return nil, fmt.Errorf("ingest: %w", err)
}
@@ -587,3 +618,10 @@ func (s *Server) runIngest(ctx context.Context, content, source string, dryRun b
}
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
}
// withCallerStamp attaches the caller's provenance for the ingest pipeline,
// which stamps every page it writes or merges into (hyperguild#145).
func withCallerStamp(ctx context.Context) context.Context {
origin, writtenBy := provenanceFrom(ctx)
return provenance.WithStamp(ctx, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
}
+70 -29
View File
@@ -51,25 +51,50 @@ func TestBrainQueryReturnsResults(t *testing.T) {
assert.Contains(t, text, "tdd.md")
}
func TestBrainWriteCreatesFile(t *testing.T) {
// TestBrainWriteIgnoresLegacyTypeAndDomainParams pins the post-#79 contract:
// domain/type were removed from the schema and the handler's arg struct, so
// a caller that still sends them (the old MCP surface) gets a normal wing+
// hall write with no error and no effect from the stale fields — silently
// dropped by json.Unmarshal, not rejected. Only content in its OWN
// frontmatter (unrelated to these params) can set type/domain, per
// TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking in the
// api package.
func TestBrainWriteIgnoresLegacyTypeAndDomainParams(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_write", map[string]any{
"content": "# Test\n\nbody",
"filename": "test.md",
"filename": "test",
"wing": "homelab",
"hall": "facts",
"type": "note",
"domain": "personal",
})
require.Nil(t, resp["error"])
got, err := os.ReadFile(filepath.Join(brainDir, "knowledge", "test.md"))
got, err := os.ReadFile(filepath.Join(brainDir, "wiki", "homelab", "facts", "test.md"))
require.NoError(t, err)
assert.Contains(t, string(got), "type: note")
assert.Contains(t, string(got), "domain: personal")
assert.NotContains(t, string(got), "type: note")
assert.NotContains(t, string(got), "domain: personal")
assert.Contains(t, string(got), "# Test")
}
// TestBrainWriteRequiresWingAndHall is the MCP-tool-call counterpart of
// api.TestWriteNote_RequiresWingAndHall: the legacy brain/knowledge/
// fallback is gone, so a caller omitting either param gets a tool error,
// not a silent legacy write.
func TestBrainWriteRequiresWingAndHall(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_write", map[string]any{
"content": "x",
"filename": "no-destination",
})
require.NotNil(t, resp["error"])
}
func TestBrainWriteWingHallRoutesToWiki(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil, nil)
@@ -208,30 +233,11 @@ func TestBrainTunnelRejectsMissing(t *testing.T) {
require.NotNil(t, resp["error"])
}
func TestBrainWriteRejectsTraversal(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_write", map[string]any{
"content": "x",
"filename": "../escape.md",
})
require.NotNil(t, resp["error"])
}
func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_write", map[string]any{
"content": "x",
"filename": "notes..draft.md",
})
require.Nil(t, resp["error"])
_, err := os.Stat(filepath.Join(brainDir, "knowledge", "notes..draft.md"))
require.NoError(t, err, "filename with embedded .. should be allowed")
}
// Filename traversal/sanitisation on the wiki route is covered directly
// against brain.NotePath/Sanitise in internal/brain/path_test.go
// (TestNotePath_SanitisesWingAndSlug, TestSanitise) — those tests exercised
// the now-deleted brain/knowledge/ legacy path's own separate filename
// validation, which had no equivalent once that path was removed (#79).
func TestBrainIngestRawDryRun(t *testing.T) {
brainDir := t.TempDir()
@@ -390,3 +396,38 @@ func TestBrainPromoteInvalidHallErrors(t *testing.T) {
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
assert.NoError(t, srcErr, "source untouched on validation error")
}
func TestBrainRejectMovesToRejectedSubdir(t *testing.T) {
brainDir := t.TempDir()
raw := filepath.Join(brainDir, "raw")
require.NoError(t, os.MkdirAll(raw, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-04-23-scrape.md"),
[]byte("---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Scrape\n\nbody\n"), 0o644))
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_reject", map[string]any{
"filename": "2026-04-23-scrape.md", "reason": "generic scrape, not homelab-relevant",
})
require.Nil(t, resp["error"], "got: %v", resp["error"])
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
assert.Contains(t, text, "raw/rejected/2026-04-23-scrape.md")
got, err := os.ReadFile(filepath.Join(brainDir, "raw/rejected/2026-04-23-scrape.md"))
require.NoError(t, err)
assert.Contains(t, string(got), "reject_reason: generic scrape, not homelab-relevant")
_, srcErr := os.Stat(filepath.Join(raw, "2026-04-23-scrape.md"))
assert.True(t, os.IsNotExist(srcErr), "source removed from raw/")
// A rejected note must not still show up as pending.
pendingResp := toolCall(t, srv, "brain_pending", map[string]any{})
pendingText := pendingResp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
assert.Contains(t, pendingText, `"pending":[]`)
}
func TestBrainRejectMissingSourceErrors(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
require.NotNil(t, resp["error"])
}
@@ -0,0 +1,325 @@
package mcp_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"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/mathiasbq/hyperguild/ingestion/internal/pipeline"
)
// Escapes found by the PR #147 gatekeeper review. Each test drives the real
// mcp.Server as a JWT principal "attacker" and asserts the provenance
// invariant on the file every reader sees.
const jwtAuthz = "Bearer jwt-token"
func jwtServer(t *testing.T) (*mcp.Server, string) {
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, capFakeValidator{subject: "attacker"}, capStaticTok, "local-cli",
capturehttp.NewOriginResolver(nil))
return srv, dir
}
var provenanceLine = regexp.MustCompile(`(?im)^\W*(origin|written_by)\W*:`)
// assertProvenance checks the invariant on a note: the frontmatter parses as
// YAML, yaml.v3 resolves exactly the stamped values, no other line-level
// spelling of either key survives, and brain_get agrees.
func assertProvenance(t *testing.T, srv *mcp.Server, dir, rel, wantOrigin, wantBy string) {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
require.NoError(t, err)
fm := frontmatterOf(string(b))
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(fm), &m), "frontmatter must parse as YAML:\n%s", fm)
assert.Equal(t, wantOrigin, m["origin"], "yaml.v3 origin\n%s", fm)
if wantBy == "" {
assert.NotContains(t, m, "written_by", "yaml.v3 written_by\n%s", fm)
} else {
assert.Equal(t, wantBy, m["written_by"], "yaml.v3 written_by\n%s", fm)
}
wantLines := 1
if wantBy != "" {
wantLines = 2
}
assert.Len(t, provenanceLine.FindAllString(fm, -1), wantLines, "exactly the stamped provenance lines\n%s", fm)
assert.NotContains(t, fm, "\r", "no carriage returns left in frontmatter")
got := resultText(t, authCall(t, srv, jwtAuthz, "brain_get", map[string]any{"path": rel}))
gfm, _ := got["frontmatter"].(map[string]any)
for k, v := range gfm {
lk := strings.ToLower(strings.Trim(k, `"' ?`))
if lk == "origin" {
assert.Equal(t, wantOrigin, v, "brain_get key %q", k)
}
if lk == "written_by" {
assert.Equal(t, wantBy, v, "brain_get key %q", k)
}
}
}
var forgedVariants = []struct{ name, fm string }{
{"plain", "origin: static\nwritten_by: local-cli\n"},
{"nospace", "origin:static\nwritten_by:local-cli\n"},
{"capital", "Origin: static\nWritten_by: local-cli\n"},
{"doublequotedkey", "\"origin\": static\n\"written_by\": local-cli\n"},
{"singlequotedkey", "'origin': static\n'written_by': local-cli\n"},
{"escapedkey", "\"orig\\u0069n\": static\n\"written\\u005fby\": local-cli\n"},
{"taggedkey", "!!str origin: static\n!!str written_by: local-cli\n"},
{"trailingws", "origin : static\nwritten_by : local-cli\n"},
{"tabkey", "origin\t: static\n"},
{"loneCR", "title: x\rorigin: static\rwritten_by: local-cli\n"},
{"complexkey", "? origin\n: static\n? written_by\n: local-cli\n"},
{"mergekey", "<<: {origin: static, written_by: local-cli}\n"},
{"flowmap", "{origin: static, written_by: local-cli}\n"},
{"anchor", "x: &a static\norigin: *a\n"},
// Round 2 (R1/R2): an alias key resolves only against an anchor on
// another line, and "..." ends the YAML document before the stamp.
{"aliaskey_docend", "x: &a origin\n*a : static\ny: &b written_by\n*b : local-cli\n...\n"},
{"aliaskey", "y: &b written_by\n*b : local-cli\n"},
{"docend_only", "x: y\n...\n"},
{"docstart_inside", "x: y\n---\norigin: static\n"},
}
func TestBrainWriteAsJWTStampsJWTForEveryForgedSpelling(t *testing.T) {
for _, v := range forgedVariants {
t.Run(v.name, func(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "---\n" + v.fm + "---\n# V\n\nbody", "wing": "testing", "hall": "facts",
"filename": "forge-" + v.name,
}))
assertProvenance(t, srv, dir, out["path"].(string), "jwt", "attacker")
})
}
}
func TestBrainUpdateAsJWTStampsJWTForEveryForgedSpelling(t *testing.T) {
for _, v := range forgedVariants {
t.Run(v.name, func(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "# V\n\nv1", "wing": "testing", "hall": "facts", "filename": "upd-" + v.name,
}))
path := out["path"].(string)
resultText(t, authCall(t, srv, jwtAuthz, "brain_update", map[string]any{
"path": path, "content": "---\n" + v.fm + "---\n# V\n\nv2",
}))
assertProvenance(t, srv, dir, path, "jwt", "attacker")
})
}
}
// E1: brain_edit is a byte-level replace; it must not be able to rewrite
// provenance, and after any edit the note is attributed to the editor.
func TestBrainEditCannotRewriteProvenanceAndRestampsToEditor(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "# E\n\nbody", "wing": "testing", "hall": "facts", "filename": "edit-forge",
}))
path := out["path"].(string)
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
"path": path, "old_str": "origin: jwt\nwritten_by: attacker\n", "new_str": "origin: static\nwritten_by: local-cli\n",
})
assertProvenance(t, srv, dir, path, "jwt", "attacker")
// Assembling the value from existing text must not work either.
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{"path": path, "old_str": "jwt", "new_str": "static"})
assertProvenance(t, srv, dir, path, "jwt", "attacker")
}
// A static principal's note edited by a JWT session becomes the JWT
// session's: the content a reader sees is no longer the static author's.
func TestBrainEditByJWTReattributesAStaticNote(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# S\n\nstatic body", "wing": "testing", "hall": "facts", "filename": "edit-reattr",
}))
path := out["path"].(string)
resultText(t, authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
"path": path, "old_str": "static body", "new_str": "injected body",
}))
assertProvenance(t, srv, dir, path, "jwt", "attacker")
}
// E1 via round 2's payload: the alias + document-end trick through brain_edit.
func TestBrainEditCannotForgeProvenanceWithAliasKeys(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "---\ntitle: t\n---\n# E\n\nbody", "wing": "testing", "hall": "facts", "filename": "edit-alias",
}))
path := out["path"].(string)
authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
"path": path, "old_str": "title: t\n", "new_str": "title: t\nx: &a origin\n*a : static\n...\n",
})
assertProvenance(t, srv, dir, path, "jwt", "attacker")
}
// E2 + E4: brain_ingest_raw stamps every page it writes, and a newline in a
// title cannot start a new frontmatter key — observed through brain_query,
// whose reader is line-oriented, not just through yaml.v3 (round 2, R4).
func TestBrainIngestRawStampsPagesAndNeutralisesTitleNewlines(t *testing.T) {
srv, dir := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_ingest_raw", map[string]any{
"source": "zqsrc", "pages": []map[string]any{
{"title": "Zqplain", "type": "concept", "content": "plain body"},
{"title": "Zqinj\norigin: static\nwritten_by: local-cli\nx: 'y", "type": "concept", "content": "inj body"},
},
}))
pages := out["pages"].([]any)
require.NotEmpty(t, pages)
for _, p := range pages {
assertProvenance(t, srv, dir, p.(string), "jwt", "attacker")
b, _ := os.ReadFile(filepath.Join(dir, p.(string)))
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(frontmatterOf(string(b))), &m))
assert.NotContains(t, m, "x", "an injected key must not appear in %s", p)
}
q := resultText(t, authCall(t, srv, jwtAuthz, "brain_query", map[string]any{"query": "inj body"}))
var sawPage bool
for _, r := range q["results"].([]any) {
hit := r.(map[string]any)
// Generated aggregates (wiki/index.md, log.md, wing _index.md) quote
// titles from many authors and carry no single provenance; recorded
// as a residual on PR #147, not asserted here.
if !strings.HasPrefix(hit["path"].(string), "wiki/concepts/") {
continue
}
sawPage = true
assert.Equal(t, "jwt", hit["origin"], "line reader origin for %v", hit["path"])
assert.Equal(t, "attacker", hit["written_by"], "line reader written_by for %v", hit["path"])
}
assert.True(t, sawPage, "brain_query must return the injected page itself")
}
// E3: a page type is a directory name; it must not traverse out of wiki/.
func TestBrainIngestRawRejectsPathTraversingType(t *testing.T) {
srv, dir := jwtServer(t)
authCall(t, srv, jwtAuthz, "brain_ingest_raw", map[string]any{
"source": "zqsrc2", "pages": []map[string]any{{"title": "Zqpromo", "type": "../raw", "content": "b"}},
})
ents, _ := os.ReadDir(filepath.Join(dir, "raw"))
assert.Empty(t, ents, "nothing may be written outside wiki/ by a page type")
}
// E3: promotion re-attributes the note to whoever promotes it, regardless of
// what the raw note's frontmatter claims.
func TestBrainPromoteRestampsToPromoter(t *testing.T) {
srv, dir := jwtServer(t)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "planted.md"),
[]byte("---\ntitle: Planted\norigin: static\nwritten_by: local-cli\n---\n# Planted\n\nbody\n"), 0o644))
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_promote", map[string]any{
"filename": "planted.md", "wing": "testing", "hall": "facts",
}))
assertProvenance(t, srv, dir, out["path"].(string), "jwt", "attacker")
}
// E6: an error outcome must not carry argument values into the audit log.
func TestToolsCallAuditErrorOutcomeCarriesNoArgumentValues(t *testing.T) {
const sentinel = "SENTINEL-ERR-7d1e"
srv, _ := jwtServer(t)
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "---\nhalf_life: " + sentinel + "\n---\n# H\n\nbody", "wing": "testing", "hall": "facts",
})
authCall(t, srv, jwtAuthz, "brain_update", map[string]any{"path": "wiki/testing/facts/" + sentinel + ".md", "content": "x"})
var sawError bool
for _, l := range strings.Split(buf.String(), "\n") {
var rec map[string]any
if json.Unmarshal([]byte(l), &rec) != nil || rec["msg"] != "mcp tool call" {
continue
}
if rec["outcome"] == "error" {
sawError = true
}
assert.NotContains(t, l, sentinel)
}
assert.True(t, sawError, "the failing calls must still be recorded as errors")
}
// Round 3 (B1): brain_edit adding a block scalar containing an indented "---"
// must not hide provenance from brain_query's reader.
func TestBrainEditBlockScalarFenceDoesNotHideProvenanceFromQuery(t *testing.T) {
srv, _ := jwtServer(t)
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_write", map[string]any{
"content": "---\nk: v\n---\n# E\n\nzqrthreeeditdash", "wing": "testing", "hall": "facts", "filename": "edit-dash",
}))
resultText(t, authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{
"path": out["path"], "old_str": "k: v\n", "new_str": "k: |\n ---\n",
}))
q := resultText(t, authCall(t, srv, jwtAuthz, "brain_query", map[string]any{"query": "zqrthreeeditdash"}))
hit := q["results"].([]any)[0].(map[string]any)
assert.Equal(t, "jwt", hit["origin"])
assert.Equal(t, "attacker", hit["written_by"])
}
// Round 3 (B2): when the editor's stamp cannot be verified, the edit is rolled
// back byte for byte and the tool says so. A JWT subject with an invalid UTF-8
// byte cannot round-trip through YAML, which forces that path.
func TestBrainEditRollsBackWhenTheStampCannotBeVerified(t *testing.T) {
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, capFakeValidator{subject: "bad\xff"}, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# R\n\nstatic body", "wing": "testing", "hall": "facts", "filename": "edit-rollback",
}))
path := out["path"].(string)
before, err := os.ReadFile(filepath.Join(dir, path))
require.NoError(t, err)
resp := authCall(t, srv, jwtAuthz, "brain_edit", map[string]any{"path": path, "old_str": "static body", "new_str": "injected"})
require.NotNil(t, resp["error"], "the edit must be reported as failed")
assert.Contains(t, resp["error"].(map[string]any)["message"], "rolled back")
after, err := os.ReadFile(filepath.Join(dir, path))
require.NoError(t, err)
assert.Equal(t, string(before), string(after), "the note must be byte-identical to before the edit")
}
// MCP brain_ingest (the LLM path) attributes its pages to the caller.
func TestBrainIngestViaMCPStampsCaller(t *testing.T) {
dir := newBrainRepo(t)
cfg, err := classification.Load(dir)
require.NoError(t, err)
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
stub := pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
return `[{"title":"Zqllmpage","type":"concept","content":"zqllm body"}]`, nil
}}
srv := mcp.NewServer(dir, &stub, nil, nil)
srv.WithCapture(svc, capFakeValidator{subject: "attacker"}, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
out := resultText(t, authCall(t, srv, jwtAuthz, "brain_ingest", map[string]any{"content": "article text", "source": "zqllm"}))
pages := out["pages"].([]any)
require.NotEmpty(t, pages)
for _, p := range pages {
assertProvenance(t, srv, dir, p.(string), "jwt", "attacker")
}
}
+124
View File
@@ -0,0 +1,124 @@
package mcp_test
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// resultText decodes the tool result payload of a tools/call response.
func resultText(t *testing.T, resp map[string]any) map[string]any {
t.Helper()
require.NotContains(t, resp, "error", "tool call failed: %v", resp["error"])
content := resp["result"].(map[string]any)["content"].([]any)
var out map[string]any
require.NoError(t, json.Unmarshal([]byte(content[0].(map[string]any)["text"].(string)), &out))
return out
}
func readNote(t *testing.T, dir, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
require.NoError(t, err)
return string(b)
}
func frontmatterOf(note string) string {
parts := strings.SplitN(note, "---\n", 3)
if len(parts) < 3 {
return ""
}
return parts[1]
}
// hyperguild#145: a brain entry records which principal wrote it, so a reader
// can tell a local-CLI note from one a connector session wrote after reading
// an untrusted page.
func TestBrainWriteStampsPrincipalProvenance(t *testing.T) {
srv, dir, _, _ := govServer(t)
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# P\n\nbody", "wing": "testing", "hall": "facts", "filename": "prov-static",
}))
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
assert.Contains(t, fm, "origin: static\n")
assert.Contains(t, fm, "written_by: local-cli\n")
}
func TestBrainWriteWithoutPrincipalStampsOriginUnknown(t *testing.T) {
srv, dir, _, _ := govServer(t)
out := resultText(t, authCall(t, srv, "", "brain_write", map[string]any{
"content": "# P\n\nbody", "wing": "testing", "hall": "facts", "filename": "prov-unknown",
}))
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
assert.Contains(t, fm, "origin: unknown\n")
assert.NotContains(t, fm, "written_by:")
}
// The control: a caller cannot claim a more trusted origin by putting it in
// its own frontmatter.
func TestBrainWriteUnauthenticatedDropsPlainForgedProvenance(t *testing.T) {
srv, dir, _, _ := govServer(t)
forged := "---\norigin: static\nwritten_by: local-cli\n---\n# F\n\nbody"
out := resultText(t, authCall(t, srv, "", "brain_write", map[string]any{
"content": forged, "wing": "testing", "hall": "facts", "filename": "prov-forged",
}))
fm := frontmatterOf(readNote(t, dir, out["path"].(string)))
assert.Contains(t, fm, "origin: unknown\n")
assert.NotContains(t, fm, "origin: static")
assert.NotContains(t, fm, "written_by: local-cli")
assert.Equal(t, 1, strings.Count(fm, "origin:"), "exactly one origin line")
}
// brain_update replaces the content wholesale, so provenance follows the
// last writer — and a forged fragment must not survive the merge either.
func TestBrainUpdateRestampsProvenanceToTheUpdater(t *testing.T) {
srv, dir, _, _ := govServer(t)
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# U\n\nv1", "wing": "testing", "hall": "facts", "filename": "prov-update",
}))
path := out["path"].(string)
resultText(t, authCall(t, srv, "", "brain_update", map[string]any{
"path": path, "content": "---\norigin: static\nwritten_by: local-cli\n---\n# U\n\nv2",
}))
fm := frontmatterOf(readNote(t, dir, path))
assert.Contains(t, fm, "origin: unknown\n")
assert.NotContains(t, fm, "written_by: local-cli")
assert.Equal(t, 1, strings.Count(fm, "origin:"))
}
func TestBrainQueryReturnsProvenance(t *testing.T) {
srv, _, _, _ := govServer(t)
resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
"content": "# Zebrafinch\n\nzebrafinch provenance probe", "wing": "testing", "hall": "facts", "filename": "prov-query",
}))
out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{"query": "zebrafinch"}))
results := out["results"].([]any)
require.NotEmpty(t, results)
hit := results[0].(map[string]any)
assert.Equal(t, "static", hit["origin"])
assert.Equal(t, "local-cli", hit["written_by"])
}
// capture is the door a claude.ai connector session uses, so it is the one
// whose provenance matters most: a JWT principal must be stamped as such.
func TestMCPCaptureStampsJWTProvenance(t *testing.T) {
srv, brainDir := captureServer(t, capFakeValidator{subject: "koala-cli"}, []string{"koala-cli"})
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
"insights": []map[string]any{{"text": "Capture provenance probe", "wing": "hyperguild", "hall": "facts"}},
})
require.Nil(t, resp["error"], "got error: %v", resp["error"])
matches, err := filepath.Glob(filepath.Join(brainDir, "wiki/hyperguild/facts/*.md"))
require.NoError(t, err)
require.Len(t, matches, 1)
b, err := os.ReadFile(matches[0])
require.NoError(t, err)
fm := frontmatterOf(string(b))
assert.Contains(t, fm, "origin: jwt\n")
assert.Contains(t, fm, "written_by: koala-cli\n")
}
+5
View File
@@ -16,6 +16,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
@@ -229,7 +230,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx = withPrincipal(ctx, principal, viaStatic)
}
}
start := time.Now()
out, err := s.handleCall(ctx, p.Name, p.Arguments)
logToolCall(ctx, p.Name, p.Arguments, start, err)
if err != nil {
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
break
@@ -275,6 +278,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
return s.brainPending(ctx, args)
case "brain_promote":
return s.brainPromote(ctx, args)
case "brain_reject":
return s.brainReject(ctx, args)
case "capture":
return s.brainCapture(ctx, args)
case "brain_read":
+1 -1
View File
@@ -58,7 +58,7 @@ func TestServerToolsList(t *testing.T) {
}
assert.ElementsMatch(t, []string{
"brain_query", "brain_write", "brain_update", "brain_get",
"brain_pending", "brain_promote",
"brain_pending", "brain_promote", "brain_reject",
"brain_read", "brain_edit",
"brain_tunnel",
"brain_ingest_raw", "brain_ingest",
+15
View File
@@ -90,6 +90,7 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R
// Principal and origin are server-derived — never taken from the body.
in.Context.Principal = info.principal
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
in.Context.AuthPath, _ = provenanceFrom(ctx)
rec, err := s.capture.svc.Capture(ctx, in)
if err != nil {
@@ -99,3 +100,17 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R
}
return json.Marshal(rec)
}
// provenanceFrom maps the authenticated principal in ctx to the provenance
// stamped on a brain write (hyperguild#145). No principal → "unknown", never
// silence: an unstamped note must not read as trusted.
func provenanceFrom(ctx context.Context) (origin, writtenBy string) {
info, ok := ctx.Value(principalKey).(principalInfo)
if !ok {
return "unknown", ""
}
if info.viaStatic {
return "static", info.principal
}
return "jwt", info.principal
}
+205 -3
View File
@@ -5,13 +5,17 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"log/slog"
"os"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"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"
)
@@ -101,9 +105,40 @@ type brainEditArgs struct {
// 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) {
_, out, err := s.editWithScope(args, notes.ScopeNotes)
return out, err
func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
// Snapshot before editing: if the edited note cannot be stamped and
// verified, the edit is rolled back rather than left on disk unattributed
// (or carrying whatever provenance the edit wrote).
var a brainEditArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
target, err := notes.Resolve(s.brainDir, a.Path, notes.ScopeNotes)
if err != nil {
return nil, err
}
// No snapshot, no edit: without one a failed stamp could not be undone.
prior, err := os.ReadFile(target.Abs)
if err != nil {
return nil, fmt.Errorf("snapshot before edit: %w", err)
}
res, out, err := s.editWithScope(args, notes.ScopeNotes)
if err != nil {
return out, err
}
// A byte-level replace can rewrite any line, including provenance, and
// whoever edits a note is the author of what a reader now sees: re-stamp
// to the editor after every edit (PR #147 review, escape E1).
origin, writtenBy := provenanceFrom(ctx)
if serr := api.StampProvenanceFile(s.brainDir, res.Path, origin, writtenBy); serr != nil {
if werr := os.WriteFile(target.Abs, prior, 0o644); werr != nil {
// Say exactly what state the note is in: edited, unverified.
return nil, fmt.Errorf("stamp provenance failed (%v) AND the edit could NOT be rolled back: %w; "+
"%s carries the edit under its previous attribution", serr, werr, res.Path)
}
return nil, fmt.Errorf("edit rolled back, stamp provenance: %w", serr)
}
return out, nil
}
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
@@ -179,6 +214,20 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.R
if err := json.Unmarshal(args, &a); err != nil {
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
}
// hyperguild#123: brain_write/brain_update both validate the claim
// envelope before writing (hyperguild#111: "same rules when a note
// changes as when it's born"); brain_edit didn't — a byte-level string
// replace has no frontmatter awareness at all, so it could rewrite an
// existing half_life:/applies:/claim_provenance: line straight past the
// validator entirely. Scoped to notes only (governance files have no
// claim-envelope concept).
if scope == notes.ScopeNotes {
if verr := checkClaimEnvelope(s.brainDir, a.Path, a.OldStr, a.NewStr); verr != nil {
return notes.Result{}, nil, fmt.Errorf("claim envelope: %w", verr)
}
}
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
if err != nil {
if errors.Is(err, notes.ErrDegenerate) && a.OldStr == "" {
@@ -228,3 +277,156 @@ func touchesFrontmatter(oldStr, newStr string) bool {
}
return false
}
// checkClaimEnvelope previews what old_str->new_str would produce — the
// exact strings.Replace(before, oldStr, newStr, 1) formula notes.Edit
// itself uses internally — and validates the resulting frontmatter's claim
// envelope before the real edit is allowed to run. Fail-closed: an invalid
// result blocks the edit entirely, the same posture writeHallNote/UpdateNote
// already apply on create/supersede.
//
// Gated on old_str appearing within the note's frontmatter BLOCK, not on
// old_str/new_str mentioning a claim-envelope field NAME — found in review:
// a field-name-substring gate misses a value-only edit like
// "asserted"->"guessed" on a claim_provenance line, which never mentions
// the field name at all. That is exactly the shape of edit a minimal,
// natural diff produces, so a field-name gate was closing the issue in
// name only. Checking block containment instead means any edit that
// couldn't possibly touch the frontmatter (its target text only exists in
// the body) is still skipped cheaply, while nothing inside the fence can
// slip through unvalidated regardless of which token it targets.
//
// This does not replicate notes.Edit's uniqueness/CRLF/conflict-marker
// checks — if old_str is absent or ambiguous, this preview may not match
// what Edit ultimately does, but Edit independently and correctly rejects
// those cases at write time regardless of what this function decides, so
// the only thing riding on this preview's accuracy is the envelope check
// itself, not write safety.
// checkClaimEnvelope previews what old_str->new_str would produce and
// validates the resulting claim envelope before the real edit runs
// (hyperguild#123). Seven review rounds closed every plausible bypass in the
// fence-detection family (field-name substring, block-containment,
// fence-stripping, CRLF, UTF-8 BOM) and the first instance of a
// value-extraction gap (a folded YAML block scalar hiding a value behind an
// empty inline colon).
//
// Known, accepted v1 gap: frontmatterScalars's block-scalar fold is a
// best-effort approximation, not a YAML parser — a blank line interrupting a
// folded continuation, or other contrived multi-line YAML shapes not yet
// probed (block-scalar indicators `|`/`>`, inline comments on a key line,
// quoted multi-line values), can still hide an invalid value from
// ValidateEnvelope. Accepted deliberately rather than chased indefinitely:
// every bypass found so far requires the caller to already hold brain_edit
// write access and to deliberately construct a specific malformed-YAML shape
// with knowledge of this exact gap — a narrow, low-likelihood threat next to
// the diminishing returns of another incremental patch round. The real fix
// (a shared, tested, encoding-normalized frontmatter parser used by all
// three independent implementations in this codebase) is tracked separately
// as hyperguild#138, not bolted on here piece by piece.
func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error {
if oldStr == "" {
// hyperguild#115: strings.Replace treats "" as matching position 0,
// which would make an empty old_str look like an edit that inserts
// text before the fence — a degenerate-input case that belongs to
// notes.Edit's own "old_str is empty" refusal, not to the envelope
// check.
return nil
}
before, err := notes.Read(brainDir, path)
if err != nil {
// Let notes.Edit itself produce the real error (missing file, etc.)
// — this function's job is the envelope, not read failures.
return nil
}
// No gate on where old_str falls relative to the frontmatter fence:
// round 2 found that requiring old_str to be fully contained in the
// extracted block was itself bypassable by an old_str disambiguated
// across the closing "---" into the body. Simpler and bypass-proof:
// always compute what the edit would produce and validate that
// resulting state's envelope. A body-only edit leaves the frontmatter
// (and thus the validation outcome) unchanged, so this is a no-op for
// the common case and fail-closed for every case that touches the
// envelope, regardless of how old_str is phrased.
_, hadFence := frontmatterBlock(string(before))
after := strings.Replace(string(before), oldStr, newStr, 1)
_, hasFence := frontmatterBlock(after)
if hadFence && !hasFence {
// Round 3: an edit that drops the closing fence degrades `after`
// to "unfenced" — frontmatterScalars then sees an empty map and
// ValidateEnvelope({}) is nil by design (absent fields are valid),
// so an invalid value would otherwise land with zero error while
// also corrupting the note's structure. A previously-fenced note
// must stay fenced, full stop.
return fmt.Errorf("edit would remove the frontmatter fence")
}
return claim.ValidateEnvelope(frontmatterScalars(after))
}
// 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) {
// 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
}
// 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
// doc's leading fence block, for the claim-envelope check only — not a
// general frontmatter parser. Indented lines (YAML block-list items,
// wrapped values) are skipped rather than misparsed as keys, same fix as
// hyperguild#126/#128 in the sibling packages this mirrors.
func frontmatterScalars(doc string) map[string]string {
out := map[string]string{}
block, ok := frontmatterBlock(doc)
if !ok {
return out
}
lines := strings.Split(block, "\n")
for i := 0; i < len(lines); i++ {
line := lines[i]
if line != "" && (line[0] == ' ' || line[0] == '\t') {
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
val = strings.TrimSpace(val)
if val == "" {
// Round 6: a colon with nothing after it is ambiguous — it can
// be a genuinely empty/omitted value, or the start of a YAML
// block scalar whose real value lives on the indented
// continuation line(s) that follow (e.g. "half_life:\n
// durable"). Treating the latter as absent let an edit smuggle
// an invalid value straight past ValidateEnvelope: the field
// read as unset while the disk actually carried a value. Fold
// the continuation in as a best-effort join so oneOf() still
// sees it. A genuinely empty value (no indented line follows)
// still folds to "", preserving present()'s existing
// placeholder-not-assertion contract.
var cont []string
for i+1 < len(lines) {
next := lines[i+1]
if next == "" || (next[0] != ' ' && next[0] != '\t') {
break
}
cont = append(cont, strings.TrimSpace(next))
i++
}
val = strings.Join(cont, " ")
}
out[strings.TrimSpace(key)] = val
}
return out
}
+242 -2
View File
@@ -66,8 +66,11 @@ func TestBrainReadAndEdit_RoundTripThroughJSONRPC(t *testing.T) {
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/n.md"))
require.NoError(t, err)
assert.Equal(t, "---\ntitle: 'n'\n---\n\nalpha\nBETA\n", string(got),
"only the matched bytes may change")
// Only the matched bytes change in the body; the one deliberate addition
// is the editor's provenance stamp (hyperguild#145 — no principal here,
// so origin: unknown).
assert.Equal(t, "---\ntitle: 'n'\norigin: unknown\n---\n\nalpha\nBETA\n", string(got),
"only the matched bytes and the provenance stamp may change")
}
func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) {
@@ -93,6 +96,243 @@ func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) {
assert.Contains(t, errMsg, "raw arguments were", "%v", errMsg)
}
func TestBrainEdit_RejectsAnInvalidClaimEnvelopeWrittenViaStringReplace(t *testing.T) {
// hyperguild#123: brain_write/brain_update both validate the claim
// envelope (half_life/applies/claim_provenance) before writing —
// brain_edit didn't, even though it's the one live, model-facing path
// that can rewrite an existing envelope line directly via raw string
// replace, no parsing involved. "same rules when a note changes as when
// it's born" (hyperguild#111's own framing) applies here too.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "claim_provenance: asserted", "new_str": "claim_provenance: guessed",
})
require.Contains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
}
func TestBrainEdit_RejectsAValueOnlyEditThatBreaksTheEnvelope(t *testing.T) {
// Found in review: the original gate (touchesClaimEnvelope) checked
// whether old_str/new_str mention the FIELD NAME — but a minimal diff
// (the natural shape of any real edit, model-generated or human) can
// target just the VALUE token and never mention the field name at all.
// "asserted" -> "guessed" on a claim_provenance line walked straight
// past the field-name substring check and wrote an invalid enum value
// with no error — the exact bypass #123 was supposed to close, closed
// in name only.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "asserted", "new_str": "guessed",
})
require.Contains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
}
func TestBrainEdit_RejectsAnOldStrThatCrossesTheClosingFence(t *testing.T) {
// Found in round-2 review: the position-based gate required old_str to
// be fully CONTAINED in the extracted frontmatter block (which excludes
// both "---" fence lines). An old_str disambiguated by extending past
// the closing fence into the body — a natural choice, since notes.Edit
// requires old_str to be unique in the file — fails that containment
// test, so checkClaimEnvelope skips validation entirely while
// notes.Edit still applies the replace verbatim. Same invalid-enum
// outcome as the original two bypasses, reached a third way.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md",
"old_str": "asserted\n---\n\nbody",
"new_str": "guessed\n---\n\nbody",
})
require.Contains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
}
func TestBrainEdit_RejectsAnEditThatRemovesTheClosingFence(t *testing.T) {
// Found in round-3 review: checkClaimEnvelope always validates the
// resulting frontmatter of `after` — but an edit whose new_str drops
// the closing "---" degrades `after` to "unfenced" in the same call.
// frontmatterBlock(after) then returns ok=false, frontmatterScalars
// returns an empty map, and claim.ValidateEnvelope({}) is nil by
// design (absent fields are valid) — so an invalid enum value lands on
// disk with zero error, and the note is left permanently unfenced.
// Structural fix: a note that had a valid fence before the edit must
// still have one after, or the edit is rejected outright.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md",
"old_str": "claim_provenance: asserted\n---\n",
"new_str": "claim_provenance: not-a-real-value\n",
})
require.Contains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "claim_provenance: asserted\n---\n", "rejected edit must leave the note byte-identical, fence intact")
}
func TestBrainEdit_RejectsAnInvalidEnvelopeOnACRLFFencedNote(t *testing.T) {
// Found in round-4 review: frontmatterBlock's fence detection matches
// the literal byte sequence "---\n" / "\n---\n". A note whose fence
// lines use CRLF ("---\r\n") fails strings.HasPrefix(doc, "---\n") on
// the BEFORE read, so hadFence is false — not because the fence is
// missing, but because \r sits in front of every \n. hadFence&&!hasFence
// can then never fire, frontmatterScalars falls back to an empty map,
// and claim.ValidateEnvelope({}) is nil by design. No adversarial
// old_str/new_str crafting needed: encoding alone silently disables the
// whole envelope check for this note. Zero CRLF-fenced notes exist in
// the live corpus today, but nothing in the code prevents one.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\r\ntitle: 'n'\r\nhalf_life: durable\r\napplies: universal\r\nclaim_provenance: asserted\r\n---\r\n\r\nbody\r\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "asserted", "new_str": "guessed",
})
require.Contains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
}
func TestBrainEdit_RejectsAnInvalidEnvelopeOnABOMPrefixedNote(t *testing.T) {
// Found in round-5 review: the same "encoding property empties
// frontmatterScalars's map" mechanism as the CRLF bypass, one layer
// earlier — a leading UTF-8 BOM (EF BB BF) defeats
// strings.HasPrefix(doc, "---\n") exactly like CRLF did, before that
// prefix is even reached. Notepad's default "UTF-8" save and
// PowerShell's pre-v6 Out-File/Set-Content both emit a BOM, so this is
// a live, plausible authoring path, not an exotic one — same severity
// and same one-line cost as the CRLF fix, fixed the same way rather
// than deferred to hyperguild#138.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("\xef\xbb\xbf---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "asserted", "new_str": "guessed",
})
require.Contains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "claim_provenance: asserted", "rejected edit must leave the note byte-identical")
}
func TestBrainEdit_RejectsAFoldedScalarThatHidesAnInvalidValue(t *testing.T) {
// Found in round-6 review: a different mechanism from the encoding
// family (rounds 1-6 were all about fence detection; this is the field
// extractor). old_str="half_life: durable", new_str="half_life:\n
// garbage-value" turns the field into a YAML block-scalar shape: the
// top-level line is now "half_life:" with an empty inline value, and
// the real value lives on the indented continuation line that follows.
// frontmatterScalars's indentation guard skips that continuation line
// outright, so the field reads as present-with-empty-value —
// present()'s own contract then treats that as absent (a placeholder,
// not an assertion) — and claim.ValidateEnvelope never sees the
// invalid value at all. No encoding quirk needed: one ordinary
// brain_edit call smuggles an out-of-enum value straight past the
// check the field was supposed to be validated by.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md",
"old_str": "half_life: durable",
"new_str": "half_life:\n garbage-value",
})
require.Contains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "half_life: durable", "rejected edit must leave the note byte-identical")
}
func TestBrainEdit_AllowsAValidClaimEnvelopeEdit(t *testing.T) {
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nbody\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "half_life: durable", "new_str": "half_life: seasonal",
})
require.NotContains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "half_life: seasonal")
}
func TestBrainEdit_UnrelatedEditsSkipTheEnvelopeCheckEntirely(t *testing.T) {
// The gate only engages when old_str/new_str actually mention an
// envelope field name — an ordinary body edit on a note that happens to
// carry a valid envelope must not pay for (or be affected by) a check
// that has nothing to do with it.
dir := newBrainRepo(t)
note := filepath.Join(dir, "wiki/homelab/facts/n.md")
require.NoError(t, os.WriteFile(note,
[]byte("---\ntitle: 'n'\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nold body\n"), 0o644))
srv := mcp.NewServer(dir, nil, nil, nil)
resp := call(t, srv, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "old body", "new_str": "new body",
})
require.NotContains(t, resp, "error", "%v", resp)
got, err := os.ReadFile(note)
require.NoError(t, err)
assert.Contains(t, string(got), "new body")
}
func TestBrainEditRoot_GovernanceFilesAreNeverEnvelopeChecked(t *testing.T) {
// CLAUDE.md has no claim-envelope concept at all — the check must be
// scoped to notes (ScopeNotes), never applied on the governance path.
srv, _, _, _ := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "half_life: durable",
})
require.NotContains(t, resp, "error", "%v", resp)
}
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
dir := newBrainRepo(t)
srv := mcp.NewServer(dir, nil, nil, nil)
+13
View File
@@ -3,6 +3,7 @@ package pipeline
import (
"fmt"
"regexp"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
@@ -16,6 +17,12 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
out := make([]wiki.Page, 0, len(rawPages))
var warnings []string
for _, rp := range rawPages {
// The type becomes a directory under wiki/; "../raw" wrote outside it
// (PR #147 review). Only a plain lower-case name is a page type.
if !validPageType.MatchString(rp.Type) {
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
continue
}
slug := computeSlug(rp, sourceSlug)
if slug == "" {
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
@@ -26,6 +33,8 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
return out, warnings
}
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
func computeSlug(rp RawPage, sourceSlug string) string {
if rp.Type == "source" {
return sourceSlug
@@ -114,5 +123,9 @@ func buildFrontmatter(rp RawPage, date string) string {
}
func yamlScalar(s string) string {
// A raw newline or CR would end the scalar for every line-oriented
// frontmatter reader and start a new key (PR #147 review: a title of
// "x\norigin: static" forged provenance). Fold all line breaks to spaces.
s = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(s)
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
+30 -10
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
@@ -89,13 +90,17 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
meta.BodyHash = bodyHash
applySourceMeta(allRaw, meta)
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
return buildAndWrite(ctx, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
}
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
// step. Use this when the caller has already produced the structured RawPage data
// (e.g. from a more capable model or manual curation).
func RunRaw(brainDir, source string, rawPages []RawPage, dryRun bool) (Result, error) {
//
// Every page written is stamped with provenance.FromContext(ctx)
// (hyperguild#145); a caller that knows the principal attaches it with
// provenance.WithStamp, everyone else stamps origin: unknown.
func RunRaw(ctx context.Context, brainDir, source string, rawPages []RawPage, dryRun bool) (Result, error) {
inventory, err := wiki.LoadInventory(brainDir)
if err != nil {
return Result{}, fmt.Errorf("load inventory: %w", err)
@@ -104,11 +109,11 @@ func RunRaw(brainDir, source string, rawPages []RawPage, dryRun bool) (Result, e
sourceSlug := wiki.Slug(source)
date := time.Now().UTC().Format("2006-01-02")
return buildAndWrite(rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
return buildAndWrite(ctx, rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
}
// buildAndWrite runs BuildPages through write for both Run and RunRaw.
func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
pages, buildWarnings := BuildPages(rawPages, sourceSlug, date)
warnings = append(warnings, buildWarnings...)
resolved := Resolve(pages, inventory)
@@ -124,11 +129,25 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
}
content, err := reviseOrCreate(dest, page)
content, conflicts, err := reviseOrCreate(dest, page)
if err != nil {
return Result{}, err
}
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
for _, heading := range conflicts {
// Reported, not resolved. The on-disk version stands; whether
// the disagreement is a genuine contradiction is the
// Judge-gated enrich pass's call, not the write path's.
warnings = append(warnings, fmt.Sprintf(
"%s: source now disagrees on section %q — on-disk version kept",
page.Path, heading))
}
// Stamped as the last step, after merge/revise, so a page that
// was merged into is re-attributed to this ingest too.
stamped, err := provenance.Apply(content, provenance.FromContext(ctx))
if err != nil {
return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err)
}
if err := os.WriteFile(dest, []byte(stamped), 0o644); err != nil {
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
}
}
@@ -211,15 +230,16 @@ func storedSourceHash(brainDir, sourceSlug string) string {
// its first-ingest date on a revised page. Left deliberately — the alternative
// is this function editing frontmatter it does not own, and no lint reads that
// field today.
func reviseOrCreate(dest string, page wiki.Page) (string, error) {
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
existing, err := os.ReadFile(dest)
switch {
case os.IsNotExist(err):
return page.Content, nil
return page.Content, nil, nil
case err != nil:
return "", fmt.Errorf("read existing %s: %w", page.Path, err)
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
}
return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil
merged, conflicts := wiki.MergeReport(wiki.Page{Path: page.Path, Content: string(existing)}, page)
return merged.Content, conflicts, nil
}
// mergeAll deduplicates pages by path, merging content from later occurrences.
+39 -4
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -195,7 +196,7 @@ A book about shaping product work.
Read this before the next planning cycle.
`), 0o644))
_, err := RunRaw(dir, "shape-up", []RawPage{{
_, err := RunRaw(context.Background(), dir, "shape-up", []RawPage{{
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
}}, false)
@@ -220,12 +221,12 @@ func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
}}
_, err := RunRaw(dir, "shape-up", raw, false)
_, err := RunRaw(context.Background(), dir, "shape-up", raw, false)
require.NoError(t, err)
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
require.NoError(t, err)
_, err = RunRaw(dir, "shape-up", raw, false)
_, err = RunRaw(context.Background(), dir, "shape-up", raw, false)
require.NoError(t, err)
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
require.NoError(t, err)
@@ -367,7 +368,10 @@ func TestRun_RevisionEchoingExistingPageStaysByteIdentical(t *testing.T) {
require.NoError(t, err)
want := "---\ntitle: 'Shape Up'\nlast_updated: " + time.Now().UTC().Format("2006-01-02") +
"\nsource_hash: " + hashBody(content) + "\n---\n\n" +
"\nsource_hash: " + hashBody(content) +
// Every page an ingest writes carries provenance (hyperguild#145);
// no stamp in ctx here, so origin: unknown.
"\norigin: unknown\n---\n\n" +
"## Summary\n\nA book about shaping product work.\n\n" +
"## Key Claims\n\n- Appetite beats estimation.\n"
assert.Equal(t, want, string(got),
@@ -391,3 +395,34 @@ func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
assert.Equal(t, 1, calls, "only the body counts as the source")
}
func TestRunRaw_ReportsWhenTheSourceDisagreesWithWhatIsOnDisk(t *testing.T) {
// The on-disk version still wins — that is what makes unattended ingest
// safe. But the run must say that the source now claims something else,
// or a changed source is indistinguishable from an unchanged one.
dir := t.TempDir()
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
require.NoError(t, os.WriteFile(page, []byte(`---
title: 'Shape Up'
---
## Summary
Appetite is fixed, scope is variable.
`), 0o644))
res, err := RunRaw(context.Background(), dir, "shape-up", []RawPage{{
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product",
Content: "## Summary\n\nAppetite is negotiable and scope is fixed.\n",
}}, false)
require.NoError(t, err)
got, err := os.ReadFile(page)
require.NoError(t, err)
assert.Contains(t, string(got), "Appetite is fixed", "on-disk version wins")
joined := strings.Join(res.Warnings, " ")
assert.Contains(t, joined, "Summary", "the disagreeing section must be named")
assert.Contains(t, joined, "shape-up", "and the page it happened on")
}
+226
View File
@@ -0,0 +1,226 @@
// Package provenance stamps server-owned write provenance (hyperguild#145)
// into a brain note's frontmatter: origin (static | jwt | unknown) and
// written_by (the authenticated principal), so a reader can discount what a
// connector session wrote after reading an untrusted page.
//
// The control is a post-condition applied by every write path a model can
// reach. Two review rounds on PR #147 showed that deciding line by line which
// lines are provenance loses to YAML's context-dependent features — an alias
// key resolves only against an anchor elsewhere, and a `...` line ends the
// document before the stamp. So Apply does not try to recognise provenance.
// It keeps only frontmatter it can prove is plain:
//
// - a top-level line `key: value` whose key is a bare identifier,
// - together with that key's continuation lines (indented, or `- ` items
// after an empty value),
// - where the group decodes ON ITS OWN, with yaml.v3, to a mapping with
// exactly that one key,
// - and the key is not origin/written_by and has not been seen before.
//
// Everything else — aliases, anchors used as keys, document markers, complex
// keys, merge keys, flow maps, tags, quoted keys, duplicates — is dropped.
// Then the stamp is appended and the whole result is decoded by yaml.v3 as a
// backstop: if it does not decode to exactly the stamped values, Apply fails
// and the caller refuses the write.
package provenance
import (
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
"gopkg.in/yaml.v3"
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
)
// Stamp is the provenance a write carries. An empty Origin means "unknown".
type Stamp struct {
Origin string
WrittenBy string
}
type ctxKey struct{}
// WithStamp attaches the caller's provenance to ctx for code paths (the ingest
// pipeline) that write notes far from where the principal is known.
func WithStamp(ctx context.Context, s Stamp) context.Context {
return context.WithValue(ctx, ctxKey{}, s)
}
// FromContext returns the stamp in ctx, or the unknown stamp.
func FromContext(ctx context.Context) Stamp {
if s, ok := ctx.Value(ctxKey{}).(Stamp); ok {
return s
}
return Stamp{}
}
// ErrUnverifiable means the stamped note would not read back as stamped.
var ErrUnverifiable = errors.New("provenance: stamped frontmatter does not decode to the stamp")
var (
keyLine = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_.-]*)[ ]*:(?:[ ]|$)(.*)$`)
bareScalar = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]*$`)
reserved = map[string]bool{"origin": true, "written_by": true}
)
func (s Stamp) origin() string {
if s.Origin == "" {
return "unknown"
}
return s.Origin
}
// Apply returns content with its frontmatter reduced to plain groups and the
// stamp appended. Content without frontmatter gains a block.
func Apply(content string, s Stamp) (string, error) {
stamp := "origin: " + scalar(s.origin()) + "\n"
if s.WrittenBy != "" {
stamp += "written_by: " + scalar(s.WrittenBy) + "\n"
}
content = foldFrontmatterCR(content)
block, rest, ok := sharedfm.Split([]byte(content))
var out string
switch {
case !ok && strings.HasPrefix(strings.ReplaceAll(content, "\r\n", "\n"), "---\n"):
// A fence was opened but cannot be closed (e.g. a "---x" line).
// Prepending a second block would demote the original frontmatter
// into the body, so refuse instead (PR #147 review round 3).
return "", fmt.Errorf("%w: unclosed frontmatter fence", ErrUnverifiable)
case !ok:
out = "---\n" + stamp + "---\n" + content
default:
out = "---\n" + plainGroups(string(block)) + stamp + "---\n" + strings.TrimPrefix(string(rest), "\n")
}
if err := verify(out, s); err != nil {
return "", err
}
return out, nil
}
// ApplyFile applies the stamp to the note at absPath in place.
func ApplyFile(absPath string, s Stamp) error {
b, err := os.ReadFile(absPath)
if err != nil {
return fmt.Errorf("read for provenance: %w", err)
}
out, err := Apply(string(b), s)
if err != nil {
return err
}
if out == string(b) {
return nil
}
if err := os.WriteFile(absPath, []byte(out), 0o644); err != nil {
return fmt.Errorf("write provenance: %w", err)
}
return nil
}
// plainGroups keeps the provably plain key groups of a frontmatter block.
func plainGroups(block string) string {
lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")
seen := map[string]bool{}
var b strings.Builder
for i := 0; i < len(lines); {
line := lines[i]
m := keyLine.FindStringSubmatch(line)
if m == nil {
i++ // not a plain key line: dropped, as is anything it would govern
for i < len(lines) && isContinuation(lines[i], "") {
i++
}
continue
}
key, value := m[1], strings.TrimSpace(m[2])
j := i + 1
for j < len(lines) && isContinuation(lines[j], value) {
j++
}
group := strings.Join(lines[i:j], "\n") + "\n"
lk := strings.ToLower(key)
if !reserved[lk] && !seen[lk] && decodesToOnly(group, key) {
seen[lk] = true
b.WriteString(group)
}
i = j
}
return b.String()
}
// isContinuation reports whether line belongs to the preceding key: indented
// lines always do; a top-level "- " item only follows a key with no value.
func isContinuation(line, value string) bool {
if line == "" {
return false
}
if line[0] == ' ' {
return true
}
return value == "" && strings.HasPrefix(line, "- ")
}
func decodesToOnly(group, key string) bool {
var m map[string]any
if err := yaml.Unmarshal([]byte(group), &m); err != nil {
return false
}
_, ok := m[key]
return ok && len(m) == 1
}
func verify(doc string, s Stamp) error {
block, _, ok := sharedfm.Split([]byte(doc))
if !ok {
return ErrUnverifiable
}
var m map[string]any
if err := yaml.Unmarshal(block, &m); err != nil {
return fmt.Errorf("%w: %v", ErrUnverifiable, err)
}
if m["origin"] != s.origin() {
return ErrUnverifiable
}
if s.WrittenBy == "" {
if _, has := m["written_by"]; has {
return ErrUnverifiable
}
} else if m["written_by"] != s.WrittenBy {
return ErrUnverifiable
}
return nil
}
// foldFrontmatterCR turns CRLF and lone CR inside a leading frontmatter block
// into LF, so every reader agrees on where lines end.
func foldFrontmatterCR(content string) string {
s := strings.ReplaceAll(content, "\r\n", "\n")
if !strings.HasPrefix(s, "---\n") {
return content
}
end := strings.Index(s[4:], "\n---")
if end < 0 {
return content
}
return strings.ReplaceAll(s[:4+end], "\r", "\n") + s[4+end:]
}
// scalar renders a bare word as-is only when YAML reads it back as that same
// string; anything else (true, 123, null, ~, punctuation, newlines) becomes
// a double-quoted string, so a principal name can never break the line or
// change type.
func scalar(v string) string {
if bareScalar.MatchString(v) {
var back any
if yaml.Unmarshal([]byte(v), &back) == nil {
if s, ok := back.(string); ok && s == v {
return v
}
}
}
return fmt.Sprintf("%q", v)
}
@@ -0,0 +1,86 @@
package provenance
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func fmOf(t *testing.T, doc string) map[string]any {
t.Helper()
parts := strings.SplitN(doc, "---\n", 3)
require.Len(t, parts, 3, doc)
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(parts[1]), &m), parts[1])
return m
}
// The whole-document backstop: a stamp that would not read back as written
// is refused, never written. A principal with an invalid UTF-8 byte quotes
// to "\xff", which YAML decodes as U+00FF, so it cannot round-trip.
func TestApplyRefusesAStampThatDoesNotReadBack(t *testing.T) {
_, err := Apply("---\ntitle: t\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: "bad\xff"})
assert.True(t, errors.Is(err, ErrUnverifiable), "got %v", err)
}
// A principal that YAML would decode as a non-string (true, 123, null) is
// quoted, so the stamp reads back as the same string.
func TestApplyQuotesPrincipalsYAMLWouldNotReadAsStrings(t *testing.T) {
for _, p := range []string{"true", "123", "null", "1e3", "0x1F", "yes", "~"} {
out, err := Apply("---\ntitle: t\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: p})
require.NoError(t, err, p)
assert.Equal(t, p, fmOf(t, out)["written_by"], p)
}
}
// A group that does not decode on its own is dropped; its neighbours stay.
func TestApplyDropsGroupsThatDoNotDecodeAlone(t *testing.T) {
out, err := Apply("---\nkeep: 1\ntitle: a: b\nalso: 2\n---\nbody\n", Stamp{Origin: "static", WrittenBy: "local-cli"})
require.NoError(t, err)
m := fmOf(t, out)
assert.Equal(t, 1, m["keep"])
assert.Equal(t, 2, m["also"])
assert.NotContains(t, m, "title")
}
func TestApplyKeepsFirstOfDuplicateKeys(t *testing.T) {
out, err := Apply("---\ntitle: first\ntitle: second\nTitle: third\n---\nbody\n", Stamp{})
require.NoError(t, err)
m := fmOf(t, out)
assert.Equal(t, "first", m["title"])
assert.NotContains(t, m, "Title")
}
func TestApplyFoldsLoneCRSoItCannotHideALine(t *testing.T) {
out, err := Apply("---\ntitle: x\rorigin: static\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: "a"})
require.NoError(t, err)
assert.NotContains(t, out, "\r")
assert.Equal(t, "jwt", fmOf(t, out)["origin"])
assert.Equal(t, "x", fmOf(t, out)["title"])
}
func TestApplyDropsTabIndentedContinuations(t *testing.T) {
out, err := Apply("---\ntitle: x\n\torigin: static\nk: v\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: "a"})
require.NoError(t, err)
m := fmOf(t, out)
assert.Equal(t, "jwt", m["origin"])
assert.Equal(t, "v", m["k"])
}
// Content that opens a fence the shared splitter cannot close (e.g. a
// "---x" line) is refused rather than given a second, prepended block that
// demotes the original frontmatter into the body.
func TestApplyRefusesAnUnclosableFrontmatterFence(t *testing.T) {
_, err := Apply("---\ntitle: t\n---x\norigin: static\n---\nbody\n", Stamp{Origin: "jwt", WrittenBy: "a"})
assert.True(t, errors.Is(err, ErrUnverifiable), "got %v", err)
}
func TestApplyAddsFrontmatterToContentWithoutAny(t *testing.T) {
out, err := Apply("# plain\n\norigin: static is prose\n", Stamp{Origin: "static", WrittenBy: "local-cli"})
require.NoError(t, err)
assert.True(t, strings.HasPrefix(out, "---\norigin: static\nwritten_by: local-cli\n---\n# plain"))
}
@@ -0,0 +1,29 @@
package search
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Only the leading frontmatter is provenance; a body line that looks like a
// key is prose (the doc comment's claim, now observed).
func TestExtractProvenanceIgnoresBodyLines(t *testing.T) {
o, w := extractProvenance("---\ntitle: t\n---\norigin: static\nwritten_by: local-cli\n")
assert.Equal(t, "", o)
assert.Equal(t, "", w)
o, w = extractProvenance("# no frontmatter\norigin: static\n")
assert.Equal(t, "", o)
assert.Equal(t, "", w)
o, w = extractProvenance("---\norigin: jwt\nwritten_by: attacker\n---\norigin: static\n")
assert.Equal(t, "jwt", o)
assert.Equal(t, "attacker", w)
}
// Round 3 (B1): an indented "---" inside a block scalar is content, not the
// closing fence — the same rule as frontmatter.Split.
func TestExtractProvenanceUsesTheSharedFenceRule(t *testing.T) {
o, w := extractProvenance("---\nk: |\n ---\norigin: jwt\nwritten_by: attacker\n---\nbody\n")
assert.Equal(t, "jwt", o)
assert.Equal(t, "attacker", w)
}
+46
View File
@@ -5,6 +5,7 @@ import (
"bufio"
"context"
"fmt"
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
"log/slog"
"os"
"path/filepath"
@@ -48,6 +49,11 @@ type Result struct {
// (infra#72). Read from frontmatter when present, otherwise
// inferred from the parent directory.
Tier string `json:"tier,omitempty"`
// Origin/WrittenBy surface write provenance (hyperguild#145) so a
// reader can discount an entry a connector session wrote. Omitted for
// notes written before provenance existed — never fabricated.
Origin string `json:"origin,omitempty"`
WrittenBy string `json:"written_by,omitempty"`
}
// tierWeight maps the DIKW tier to a score multiplier applied right
@@ -146,6 +152,7 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
rel = filepath.ToSlash(rel)
wing, hall := extractWingHall(string(content), rel)
tier := extractTier(string(content), rel)
origin, writtenBy := extractProvenance(string(content))
results = append(results, Result{
Path: rel,
Title: extractTitle(string(content), d.Name()),
@@ -154,6 +161,9 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
Wing: wing,
Hall: hall,
Tier: tier,
Origin: origin,
WrittenBy: writtenBy,
})
return nil
})
@@ -272,6 +282,7 @@ func hydrate(brainDir, relPath string) (Result, error) {
}
wing, hall := extractWingHall(string(content), relPath)
tier := extractTier(string(content), relPath)
origin, writtenBy := extractProvenance(string(content))
return Result{
Path: relPath,
Title: extractTitle(string(content), filepath.Base(relPath)),
@@ -279,9 +290,44 @@ func hydrate(brainDir, relPath string) (Result, error) {
Wing: wing,
Hall: hall,
Tier: tier,
Origin: origin,
WrittenBy: writtenBy,
}, nil
}
// extractProvenance reads origin/written_by from the leading frontmatter
// block only, located with the shared fence rule (frontmatter.Split: a "---"
// at column 0) so an indented "---" inside a block scalar is content, not a
// closing fence (PR #147 review round 3). A body line is never provenance.
func extractProvenance(content string) (origin, writtenBy string) {
block, _, ok := sharedfm.Split([]byte(content))
if !ok {
return "", ""
}
for _, line := range strings.Split(string(block), "\n") {
if line == "" || line[0] == ' ' || line[0] == '\t' {
continue
}
key, val, found := strings.Cut(line, ":")
if !found {
continue
}
v := strings.Trim(strings.TrimSpace(val), `"'`)
switch strings.TrimSpace(key) {
case "origin":
if origin == "" {
origin = v
}
case "written_by":
if writtenBy == "" {
writtenBy = v
}
}
}
return origin, writtenBy
}
// resolveRoots returns the directories to walk for the given wing/hall
// filters. Validates hall against the closed vocabulary when set.
func resolveRoots(brainDir, wing, hall string) ([]string, error) {
+8 -2
View File
@@ -59,10 +59,16 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
return nil
}
// Skip processed/ and failed/ subdirectories entirely.
// Skip processed/, failed/, and rejected/ subdirectories entirely.
// rejected/ (hyperguild#81, brain_reject) holds notes a human already
// looked at and declined — walking into it would run the extraction
// pipeline over a note and publish it to the wiki, the exact opposite
// of the human's decision. The whole reason RejectNote moves a note
// there instead of leaving it in raw/ was to keep it out of every
// path that treats raw/ as "awaiting processing" — this is that path.
if d.IsDir() {
name := d.Name()
if name == "processed" || name == "failed" {
if name == "processed" || name == "failed" || name == "rejected" {
return filepath.SkipDir
}
return nil
+10 -3
View File
@@ -176,15 +176,20 @@ func TestDeriveSource(t *testing.T) {
func TestProcessDir_SkipsSubdirs(t *testing.T) {
brainDir := setupBrainDir(t)
// Create processed/ and failed/ subdirs with files inside.
for _, sub := range []string{"processed/2026-04-22", "failed"} {
// Create processed/, failed/, and rejected/ subdirs with files inside.
for _, sub := range []string{"processed/2026-04-22", "failed", "rejected"} {
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", sub), 0o755))
}
processedFile := filepath.Join(brainDir, "raw", "processed", "2026-04-22", "old-file.md")
failedFile := filepath.Join(brainDir, "raw", "failed", "broken-file.md")
// hyperguild#81/#133: a human already declined this note via brain_reject.
// If the watcher walks into rejected/, it re-ingests and publishes it to
// the wiki — the exact opposite of the human's decision.
rejectedFile := filepath.Join(brainDir, "raw", "rejected", "declined-file.md")
require.NoError(t, os.WriteFile(processedFile, []byte("old"), 0o644))
require.NoError(t, os.WriteFile(failedFile, []byte("broken"), 0o644))
require.NoError(t, os.WriteFile(rejectedFile, []byte("---\nrejected_at: 2026-09-23T00:00:00Z\n---\ndeclined"), 0o644))
// Also place a valid file in raw/ root that should be processed.
validFile := filepath.Join(brainDir, "raw", "valid.md")
@@ -223,11 +228,13 @@ func TestProcessDir_SkipsSubdirs(t *testing.T) {
// Complete should have been called exactly once (for valid.md, not for files in subdirs).
assert.Len(t, processedSources, 1, "only the file in raw/ root should be processed")
// Files in processed/ and failed/ must remain untouched.
// Files in processed/, failed/, and rejected/ must remain untouched.
_, err := os.Stat(processedFile)
assert.NoError(t, err, "processed subdir file should be untouched")
_, err = os.Stat(failedFile)
assert.NoError(t, err, "failed subdir file should be untouched")
_, err = os.Stat(rejectedFile)
assert.NoError(t, err, "rejected subdir file should be untouched, never re-ingested")
}
// TestProcessDir_SkipsTunnelCandidateFiles guards against hyperguild#88:
+74 -15
View File
@@ -37,43 +37,87 @@ type section struct {
// Sections are merged by strategy: bullet sections union unique lines,
// append sections concatenate, all others keep a's version. Sections in b
// not present in a are appended.
// Merge discards any disagreement silently; prefer MergeReport where the
// caller can act on it.
func Merge(a, b Page) Page {
page, _ := MergeReport(a, b)
return page
}
// MergeReport is Merge, plus the headings of sections where b carried a
// different version of a section a already had — and lost.
//
// That loss is the property which makes ingest safe to run unattended (#103):
// a regenerating writer can add to a note but never rewrite it. But the
// discarded text is also a signal, and until now it went in the bin: the source
// now says something different about a section we already hold. Nobody could
// tell "the source is unchanged" from "the source changed its mind and we
// ignored it".
//
// Deliberately NOT a semantic contradiction check. This reports textual
// disagreement, which is cheap, deterministic, and needs no model on the write
// path. Deciding whether a disagreement is a genuine contradiction stays with
// the Judge-gated enrich pass that already exists (brain-gardener#175), which
// is approval-gated by design — detection here, adjudication there.
func MergeReport(a, b Page) (Page, []string) {
fmA, preambleA, secsA := parseSections(a.Content)
fmB, _, secsB := parseSections(b.Content)
// First occurrence wins on a collision — a page can already carry two
// on-disk sections that only differ by case or whitespace (the exact
// corpus drift this normalization exists to catch going forward, found
// pre-existing in review of PR #130): a plain last-write assignment
// here would silently and deterministically route every future update
// to whichever duplicate happens to sort last in the file, freezing
// the other one forever with nothing to say so.
idx := make(map[string]int, len(secsA))
var conflicts []string
// Sections in secsA sharing a normalized heading (pre-existing corpus
// drift — "## Notes" and "## NOTES" both already on disk, the exact
// pattern this normalization exists to catch going forward, found in
// review of PR #130/#132) are matched to b's occurrences of that same
// heading POSITIONALLY: b's 1st occurrence of the key pairs with a's
// 1st, b's 2nd with a's 2nd, and so on. A single "last write wins" (or
// even "first write wins") map from key to one index would instead
// route every one of b's occurrences to the SAME on-disk section —
// which either freezes every duplicate but one (never receiving
// updates again) or, worse, compares mismatched pairs and reports a
// disagreement between two sections that were never supposed to be
// compared, false-positiving on the plain self-merge case a revision
// pass produces when the model echoes a page back unchanged (found in
// review of PR #132).
byKey := make(map[string][]int, len(secsA))
for i, s := range secsA {
key := normalizeHeading(s.heading)
if _, exists := idx[key]; !exists {
idx[key] = i
}
byKey[key] = append(byKey[key], i)
}
cursor := make(map[string]int, len(byKey))
for _, sB := range secsB {
key := normalizeHeading(sB.heading)
i, exists := idx[key]
if !exists {
idx[key] = len(secsA)
indices := byKey[key]
pos := cursor[key]
cursor[key] = pos + 1
if pos >= len(indices) {
// b has more occurrences of this heading than a does (including
// the case where a has none at all) — the extra is new content,
// appended as its own section and registered so a THIRD
// occurrence of the same heading in b pairs with this one
// rather than appending yet another duplicate.
byKey[key] = append(byKey[key], len(secsA))
secsA = append(secsA, sB)
continue
}
i := indices[pos]
sA := secsA[i]
switch {
case bulletSectionsNorm[key]:
secsA[i].content = mergeBullets(sA.content, sB.content)
case appendSectionsNorm[key]:
secsA[i].content = appendNew(sA.content, sB.content)
default:
// a wins — but say that b disagreed, rather than dropping it mute.
if canonicalBlock(sA.content) != canonicalBlock(sB.content) {
conflicts = append(conflicts, sB.heading)
}
}
}
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}, conflicts
}
// normalizeHeading returns the key Merge uses to decide "is this the same
@@ -340,6 +384,21 @@ func canonicalLine(line string) string {
return strings.Join(strings.Fields(line), " ")
}
// canonicalBlock applies canonicalLine per line of a multi-line section body
// — the same whitespace-insensitivity appendNew/mergeBullets already give a
// single line, extended to a whole section for MergeReport's disagreement
// check (found in review, PR #129: comparing raw strings.TrimSpace only
// tolerated leading/trailing whitespace, not a re-extraction's mid-line
// spacing, which every other comparison in this file already tolerates).
func canonicalBlock(block string) string {
lines := strings.Split(block, "\n")
out := make([]string, len(lines))
for i, l := range lines {
out[i] = canonicalLine(l)
}
return strings.Join(out, "\n")
}
// appendNew concatenates the lines of b that a does not already carry.
//
// Append sections used to concatenate wholesale, which is correct exactly once.
+112
View File
@@ -367,6 +367,36 @@ func TestMerge_PreExistingCaseDuplicateHeadingsRouteToTheFirstOccurrence(t *test
"the second duplicate is untouched by this update, not merged into or overwritten")
}
func TestMergeReport_SelfMergeOfPreExistingDuplicateHeadingsReportsNoConflict(t *testing.T) {
// Found in review of PR #132: first-occurrence-wins routes ALL of b's
// occurrences of a normalized heading to a's FIRST occurrence, not to
// their own positional counterpart. So merging a page against itself
// (or an echoed-back copy, exactly what #106's revision prompt can
// produce) pairs b's second "## NOTES" against a's first "## Notes" —
// different content, same normalized key — and MergeReport wrongly
// reports a disagreement where none exists. Fixed by pairing
// positionally: b's Nth occurrence of a normalized heading matches a's
// Nth occurrence, not always the first.
page := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note.\n"}
_, conflicts := MergeReport(page, page)
assert.Empty(t, conflicts, "merging a page with pre-existing duplicate headings against itself must report no conflicts")
}
func TestMergeReport_DuplicateHeadingsPairPositionallyNotAllToFirst(t *testing.T) {
// Companion case: b has genuinely different content in its second
// occurrence of the normalized heading — that must compare against a's
// second occurrence (and report if they disagree), not against a's
// first (which would either false-positive if a's first differs, or
// false-negative-mask a real disagreement in the second pair).
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note.\n"}
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Notes\n\nFirst note.\n\n## NOTES\n\nSecond note, revised.\n"}
_, conflicts := MergeReport(a, b)
assert.Equal(t, []string{"NOTES"}, conflicts,
"only the second occurrence, which actually disagrees, is reported")
}
func TestRefreshOwned_CommentLinesSurviveUnmangled(t *testing.T) {
// Third review round on PR #126: parseFrontmatterBlock dropped the
// sibling api package's comment guard (strings.HasPrefix(trimmed, "#")).
@@ -394,3 +424,85 @@ func TestRefreshOwned_IndentedListItemsWithColonsAreNotMisparsedAsKeys(t *testin
got := refreshOwned(a, b)
assert.Equal(t, "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
}
func TestMerge_ReportsSectionsWhereTheFreshVersionWasDiscarded(t *testing.T) {
// The asymmetry that makes ingest safe (on-disk wins) also throws away a
// real signal: the source now says something different about a section we
// already have. Today that disagreement is silently discarded, so nobody
// can tell "the source is unchanged" from "the source changed its mind and
// we ignored it".
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## Summary
Appetite is fixed, scope is variable.
## Method
Six-week cycles.
`}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## Summary
Appetite is negotiable and scope is fixed.
## Method
Six-week cycles.
`}
got, conflicts := MergeReport(existing, fresh)
assert.Contains(t, got.Content, "Appetite is fixed", "on-disk version still wins")
assert.NotContains(t, got.Content, "negotiable")
assert.Equal(t, []string{"Summary"}, conflicts,
"only the section that actually disagreed is reported")
}
func TestMerge_ReportsNothingWhenTheFreshVersionAgrees(t *testing.T) {
page := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## Summary
Appetite is fixed, scope is variable.
`}
_, conflicts := MergeReport(page, page)
assert.Empty(t, conflicts, "identical content is not a disagreement")
}
func TestMerge_ReportsNothingForInternalWhitespaceVariantsOnly(t *testing.T) {
// Found in review of PR #129: the conflict comparison used raw
// strings.TrimSpace, which only tolerates LEADING/TRAILING whitespace —
// unlike appendNew/mergeBullets, which already canonicalize internal
// whitespace runs too (#103: a re-extraction's spacing isn't guaranteed
// byte-stable). A mid-line double-space from re-extraction would
// false-positive as a "disagreement" here even though every other
// whitespace-sensitive comparison in this file already tolerates it.
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## Summary
Appetite is fixed, scope is variable.
`}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## Summary
Appetite is fixed, scope is variable.
`}
_, conflicts := MergeReport(existing, fresh)
assert.Empty(t, conflicts, "internal whitespace differences alone are not a disagreement")
}
-182
View File
@@ -1,182 +0,0 @@
// internal/skills/brain/handlers.go
package brain
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// Handle dispatches brain tool calls.
func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
switch tool {
case "brain_query":
return s.query(ctx, args)
case "brain_write":
return s.write(ctx, args)
case "brain_ingest_raw":
return s.ingestRaw(ctx, args)
case "brain_ingest":
return s.ingest(ctx, args)
case "brain_search":
return s.search(ctx, args)
default:
return nil, fmt.Errorf("unknown brain tool: %s", tool)
}
}
type queryArgs struct {
Query string `json:"query"`
Limit int `json:"limit,omitempty"`
}
func (s *Skill) query(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a queryArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Query == "" {
return nil, fmt.Errorf("query is required")
}
if a.Limit == 0 {
a.Limit = 5
}
return s.post(ctx, "/query", a)
}
type writeArgs struct {
Content string `json:"content"`
Type string `json:"type,omitempty"`
Domain string `json:"domain,omitempty"`
Filename string `json:"filename,omitempty"`
}
func (s *Skill) write(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a writeArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Content == "" {
return nil, fmt.Errorf("content is required")
}
return s.post(ctx, "/write", a)
}
type ingestArgs struct {
Content string `json:"content,omitempty"`
Source string `json:"source,omitempty"`
Path string `json:"path,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
}
func (s *Skill) ingest(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a ingestArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if s.cfg.IngestSvcURL == "" {
return nil, fmt.Errorf("brain_ingest: INGEST_SVC_URL not configured")
}
if a.Path != "" && a.Content != "" {
return nil, fmt.Errorf("path and content+source are mutually exclusive: provide one or the other")
}
if a.Path != "" {
return s.postTo(ctx, s.cfg.IngestSvcURL+"/ingest-path", map[string]any{
"path": a.Path,
"source": a.Source,
"dry_run": a.DryRun,
})
}
if a.Content != "" && a.Source != "" {
return s.postTo(ctx, s.cfg.IngestSvcURL+"/ingest", map[string]any{
"content": a.Content,
"source": a.Source,
"dry_run": a.DryRun,
})
}
return nil, fmt.Errorf("either content+source or path is required")
}
type ingestRawArgs struct {
Source string `json:"source"`
Pages []any `json:"pages"`
DryRun bool `json:"dry_run,omitempty"`
}
func (s *Skill) ingestRaw(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a ingestRawArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if s.cfg.IngestSvcURL == "" {
return nil, fmt.Errorf("brain_ingest_raw: INGEST_SVC_URL not configured")
}
if a.Source == "" {
return nil, fmt.Errorf("source is required")
}
if len(a.Pages) == 0 {
return nil, fmt.Errorf("pages is required and must be non-empty")
}
return s.postTo(ctx, s.cfg.IngestSvcURL+"/ingest-raw", map[string]any{
"source": a.Source,
"pages": a.Pages,
"dry_run": a.DryRun,
})
}
type searchArgs struct {
Query string `json:"query"`
Collection string `json:"collection,omitempty"`
Limit int `json:"limit,omitempty"`
}
func (s *Skill) search(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a searchArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Query == "" {
return nil, fmt.Errorf("query is required")
}
if a.Limit == 0 {
a.Limit = 5
}
if s.cfg.KBRetrievalURL == "" {
return nil, fmt.Errorf("brain_search: KB_RETRIEVAL_URL not configured")
}
return s.postTo(ctx, s.cfg.KBRetrievalURL+"/api/v1/search", a)
}
func (s *Skill) post(ctx context.Context, path string, body any) (json.RawMessage, error) {
return s.postTo(ctx, s.cfg.IngestBaseURL+path, body)
}
func (s *Skill) postTo(ctx context.Context, url string, body any) (json.RawMessage, error) {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("call ingestion server: %w", err)
}
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ingestion server returned %d: %s", resp.StatusCode, out)
}
return json.RawMessage(out), nil
}
-122
View File
@@ -1,122 +0,0 @@
// internal/skills/brain/handlers_test.go
package brain_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.d-ma.be/mathias/hyperguild/internal/skills/brain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHandle_BrainQuery_CallsIngestServer(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/query", r.URL.Path)
called = true
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{
{"path": "wiki/concepts/tdd.md", "title": "TDD", "excerpt": "Test-driven development.", "score": 3},
},
})
}))
defer srv.Close()
s := brain.New(brain.Config{IngestBaseURL: srv.URL})
args, _ := json.Marshal(map[string]string{"query": "test driven development"})
out, err := s.Handle(context.Background(), "brain_query", args)
require.NoError(t, err)
assert.True(t, called)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
results := result["results"].([]any)
assert.Len(t, results, 1)
}
func TestHandle_BrainWrite_CallsIngestServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/write", r.URL.Path)
var body map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "concept", body["type"])
assert.Equal(t, "# Test\n\nSome learning.", body["content"])
_ = json.NewEncoder(w).Encode(map[string]string{"path": "raw/test.md"})
}))
defer srv.Close()
s := brain.New(brain.Config{IngestBaseURL: srv.URL})
args, _ := json.Marshal(map[string]string{"content": "# Test\n\nSome learning.", "type": "concept"})
out, err := s.Handle(context.Background(), "brain_write", args)
require.NoError(t, err)
var result map[string]string
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "raw/test.md", result["path"])
}
func TestHandle_UnknownTool_ReturnsError(t *testing.T) {
s := brain.New(brain.Config{IngestBaseURL: "http://localhost:3300"})
_, err := s.Handle(context.Background(), "brain_unknown", nil)
assert.Error(t, err)
}
func TestIngest_RoutesToIngestPath(t *testing.T) {
var capturedPath string
var capturedBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody))
_ = json.NewEncoder(w).Encode(map[string]any{"pages": []string{"wiki/foo.md"}})
}))
defer srv.Close()
s := brain.New(brain.Config{IngestSvcURL: srv.URL})
args, _ := json.Marshal(map[string]any{"path": "/tmp/some-file.md"})
out, err := s.Handle(context.Background(), "brain_ingest", args)
require.NoError(t, err)
assert.Equal(t, "/ingest-path", capturedPath)
assert.Equal(t, "/tmp/some-file.md", capturedBody["path"])
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
pages := result["pages"].([]any)
assert.Len(t, pages, 1)
}
func TestIngest_RoutesToIngest(t *testing.T) {
var capturedPath string
var capturedBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody))
_ = json.NewEncoder(w).Encode(map[string]any{"pages": []string{"wiki/bar.md"}})
}))
defer srv.Close()
s := brain.New(brain.Config{IngestSvcURL: srv.URL})
args, _ := json.Marshal(map[string]any{"content": "some content", "source": "my-source.md"})
out, err := s.Handle(context.Background(), "brain_ingest", args)
require.NoError(t, err)
assert.Equal(t, "/ingest", capturedPath)
assert.Equal(t, "some content", capturedBody["content"])
assert.Equal(t, "my-source.md", capturedBody["source"])
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
pages := result["pages"].([]any)
assert.Len(t, pages, 1)
}
func TestIngest_MissingRequiredFields(t *testing.T) {
s := brain.New(brain.Config{IngestSvcURL: "http://localhost:3300"})
args, _ := json.Marshal(map[string]any{})
_, err := s.Handle(context.Background(), "brain_ingest", args)
require.Error(t, err)
assert.Contains(t, err.Error(), "either content+source or path is required")
}
-111
View File
@@ -1,111 +0,0 @@
// internal/skills/brain/skill.go
package brain
import (
"encoding/json"
"git.d-ma.be/mathias/hyperguild/internal/registry"
)
// Config holds brain skill configuration.
type Config struct {
IngestBaseURL string // base URL of the ingestion HTTP server (brain_query, brain_write)
IngestSvcURL string // base URL of the ingestion-svc HTTP server (brain_ingest)
KBRetrievalURL string // base URL of the kb-retrieval server (brain_search)
}
// Skill implements registry.Skill for brain_query and brain_write.
type Skill struct {
cfg Config
}
// New constructs a brain Skill.
func New(cfg Config) *Skill { return &Skill{cfg: cfg} }
// Name returns the skill name used for routing.
func (s *Skill) Name() string { return "brain" }
// Tools returns the MCP tool definitions for brain_query and brain_write.
func (s *Skill) Tools() []registry.ToolDef {
schema := func(required []string, props map[string]any) json.RawMessage {
b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props})
return b
}
str := map[string]any{"type": "string"}
num := map[string]any{"type": "integer"}
tools := []registry.ToolDef{
{
Name: "brain_query",
Description: "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Fast, no embeddings needed. Call before any significant task.",
InputSchema: schema([]string{"query"}, map[string]any{
"query": str,
"limit": num,
}),
},
{
Name: "brain_write",
Description: "Write a raw knowledge note to brain/knowledge/ for later ingestion.",
InputSchema: schema([]string{"content"}, map[string]any{
"content": str,
"type": str,
"domain": str,
"filename": str,
}),
},
}
if s.cfg.IngestSvcURL != "" {
tools = append(tools, registry.ToolDef{
Name: "brain_ingest_raw",
Description: "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step. " +
"Use when you (the calling agent) have already extracted entities, concepts, and content from a source. " +
"Provide source (human-readable name) and pages (array of {title, type, subtype, domain, content} objects). " +
"The pipeline computes slugs, paths, frontmatter, wikilink canonicalization, and source back-references. " +
"Returns the list of wiki pages written.",
InputSchema: schema([]string{"source", "pages"}, map[string]any{
"source": map[string]any{"type": "string", "description": "human-readable name for the source, e.g. 'shape-up-book'"},
"pages": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"required": []string{"title", "type", "content"},
"properties": map[string]any{
"title": map[string]any{"type": "string", "description": "page title, e.g. 'Hash Encoding'"},
"type": map[string]any{"type": "string", "enum": []string{"source", "concept", "entity"}, "description": "page type"},
"subtype": map[string]any{"type": "string", "description": "entity: person|company|tool|model|framework|technology; source: article|pdf|book|video|note|project"},
"domain": map[string]any{"type": "string", "description": "knowledge domain, e.g. 'Machine Learning'"},
"content": map[string]any{"type": "string", "description": "markdown body — no frontmatter, use [[Display Name]] for wikilinks"},
},
},
},
"dry_run": map[string]any{"type": "boolean"},
}),
})
tools = append(tools, registry.ToolDef{
Name: "brain_ingest",
Description: "Ingest content into the brain wiki (brain/wiki/). Calls an LLM to produce structured wiki pages. " +
"Use for substantial documents, articles, or knowledge worth structuring. " +
"Provide EITHER (a) path — absolute path to a file or directory, " +
"OR (b) content + source — raw text and a human-readable name. " +
"Providing both is an error. Returns the list of wiki pages written.",
InputSchema: schema([]string{}, map[string]any{
"content": map[string]any{"type": "string", "description": "raw text to ingest; required when path is not set"},
"source": map[string]any{"type": "string", "description": "human-readable name for the content, e.g. 'shape-up-book'; required when path is not set"},
"path": map[string]any{"type": "string", "description": "absolute path to a file or directory to ingest; mutually exclusive with content+source"},
"dry_run": map[string]any{"type": "boolean"},
}),
})
}
if s.cfg.KBRetrievalURL != "" {
tools = append(tools, registry.ToolDef{
Name: "brain_search",
Description: "Semantic vector search across the brain wiki using embeddings. Use when brain_query returns no results or you need conceptually-related results rather than keyword matches.",
InputSchema: schema([]string{"query"}, map[string]any{
"query": str,
"collection": str,
"limit": num,
}),
})
}
return tools
}
+1 -1
View File
@@ -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