Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28d9f40733 | ||
|
|
3564498a1b | ||
|
|
9fd9a0b920 |
+1
-5
@@ -64,11 +64,7 @@ the routing pod; brain tools moved to the brain MCP.
|
|||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`**
|
for shell scripts and non-MCP clients.
|
||||||
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
|
|
||||||
(infra#517): use the tailnet (`koala:8443`) or in-cluster
|
|
||||||
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
|
|
||||||
accept only paths inside `brain/raw/`.
|
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
|||||||
@@ -78,27 +78,6 @@ 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
|
when a human reviewer outside the project is required. Document the reason in
|
||||||
PROJECT.md.
|
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
|
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:
|
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
|
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||||
@@ -127,43 +106,6 @@ 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
|
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.
|
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
|
## CAD sprint patterns
|
||||||
|
|
||||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
@@ -304,16 +246,10 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
|---------|-------|-------|
|
|---------|-------|-------|
|
||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
|
| **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 |
|
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
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
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
@@ -383,20 +319,8 @@ Workflow:
|
|||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
message telling you to stage the regenerated files.
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
host and every harness.
|
unconditionally on every host, 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
|
## Engineering Skills
|
||||||
|
|
||||||
@@ -493,11 +417,7 @@ the routing pod; brain tools moved to the brain MCP.
|
|||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`**
|
for shell scripts and non-MCP clients.
|
||||||
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
|
|
||||||
(infra#517): use the tailnet (`koala:8443`) or in-cluster
|
|
||||||
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
|
|
||||||
accept only paths inside `brain/raw/`.
|
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
|||||||
@@ -73,27 +73,6 @@ 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
|
when a human reviewer outside the project is required. Document the reason in
|
||||||
PROJECT.md.
|
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
|
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:
|
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
|
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||||
@@ -122,43 +101,6 @@ 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
|
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.
|
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
|
## CAD sprint patterns
|
||||||
|
|
||||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
@@ -299,16 +241,10 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|||||||
|---------|-------|-------|
|
|---------|-------|-------|
|
||||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||||
| **Anything HTTP-only (curl, scripts)** | Tailnet only: `POST https://koala.tail30472e.ts.net:8443/query` with `{"query":"..."}`, sending `BRAIN_MCP_TOKEN` (not yet enforced on REST, hyperguild#149). The public `brain-mcp.d-ma.be` serves `/mcp` only (infra#517). | `POST .../write` with `{"content":"...","wing":"...","hall":"..."}` |
|
| **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 |
|
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||||
|
|
||||||
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
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
|
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
on the koala k3s cluster; don't hardcode local-only model names into the
|
||||||
@@ -378,20 +314,8 @@ Workflow:
|
|||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
message telling you to stage the regenerated files.
|
message telling you to stage the regenerated files.
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
||||||
host and every harness.
|
unconditionally on every host, 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
|
## Engineering Skills
|
||||||
|
|
||||||
@@ -488,11 +412,7 @@ the routing pod; brain tools moved to the brain MCP.
|
|||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`**
|
for shell scripts and non-MCP clients.
|
||||||
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
|
|
||||||
(infra#517): use the tailnet (`koala:8443`) or in-cluster
|
|
||||||
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
|
|
||||||
accept only paths inside `brain/raw/`.
|
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
|||||||
@@ -64,11 +64,7 @@ the routing pod; brain tools moved to the brain MCP.
|
|||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
||||||
for shell scripts and non-MCP clients, **with the same bearer token as `/mcp`**
|
for shell scripts and non-MCP clients.
|
||||||
(`BRAIN_MCP_TOKEN`; hyperguild#149). It is not served on the public host
|
|
||||||
(infra#517): use the tailnet (`koala:8443`) or in-cluster
|
|
||||||
`ingestion.supervisor.svc:3300`. `/ingest-path` and `brain_ingest` `path`
|
|
||||||
accept only paths inside `brain/raw/`.
|
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
||||||
|
|||||||
@@ -104,24 +104,6 @@ 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
|
`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.
|
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
|
## Re-ingesting an unchanged source is skipped
|
||||||
|
|
||||||
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
|
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
|
||||||
|
|||||||
@@ -55,21 +55,18 @@ Flags:
|
|||||||
- `--limit N` — max results (default 5)
|
- `--limit N` — max results (default 5)
|
||||||
- `--json` — emit the raw response envelope
|
- `--json` — emit the raw response envelope
|
||||||
|
|
||||||
### `hyperguild brain write <wing> <hall> <slug>`
|
### `hyperguild brain write <type> <slug>`
|
||||||
|
|
||||||
Reads markdown from stdin, writes it to `brain/wiki/<wing>/<hall>/<slug>.md`
|
Reads markdown from stdin, writes a knowledge entry.
|
||||||
with `wing`/`hall`/`created_at` frontmatter injected (there is no legacy
|
|
||||||
`brain/knowledge/` fallback — ADR-0012 / #79, both wing and hall are
|
|
||||||
required).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ cat <<EOF | hyperguild brain write homelab facts example-lesson
|
$ cat <<EOF | hyperguild brain write knowledge example-lesson
|
||||||
# Example lesson
|
# Example lesson
|
||||||
|
|
||||||
## Lesson
|
## Lesson
|
||||||
...
|
...
|
||||||
EOF
|
EOF
|
||||||
wiki/homelab/facts/example-lesson.md
|
knowledge/example-lesson.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### `hyperguild brain pass-rate <skill>`
|
### `hyperguild brain pass-rate <skill>`
|
||||||
|
|||||||
@@ -60,14 +60,13 @@ func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout,
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("parse flags: %w", err)
|
return fmt.Errorf("parse flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() < 3 {
|
if fs.NArg() < 2 {
|
||||||
return errors.New("wing, hall and slug required (e.g. brain write homelab facts my-slug)")
|
return errors.New("type and slug required (e.g. brain write knowledge my-slug)")
|
||||||
}
|
}
|
||||||
wing := fs.Arg(0)
|
kind := fs.Arg(0)
|
||||||
hall := fs.Arg(1)
|
slug := fs.Arg(1)
|
||||||
slug := fs.Arg(2)
|
|
||||||
|
|
||||||
res, err := newBrainClient().Write(ctx, wing, hall, slug, stdin)
|
res, err := newBrainClient().Write(ctx, kind, slug, stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func TestRunBrainWrite_Success(t *testing.T) {
|
|||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(
|
err := runBrain(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"write", "homelab", "facts", "test-slug"},
|
[]string{"write", "knowledge", "test-slug"},
|
||||||
strings.NewReader("# Test\n\nSome body content.\n"),
|
strings.NewReader("# Test\n\nSome body content.\n"),
|
||||||
&out, &errBuf,
|
&out, &errBuf,
|
||||||
)
|
)
|
||||||
@@ -111,9 +111,9 @@ func TestRunBrainWrite_Success(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunBrainWrite_MissingArgs(t *testing.T) {
|
func TestRunBrainWrite_MissingArgs(t *testing.T) {
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(context.Background(), []string{"write", "homelab"}, strings.NewReader("x"), &out, &errBuf)
|
err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "wing, hall and slug required")
|
assert.Contains(t, err.Error(), "type and slug required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunBrainWrite_BackendError(t *testing.T) {
|
func TestRunBrainWrite_BackendError(t *testing.T) {
|
||||||
@@ -127,7 +127,7 @@ func TestRunBrainWrite_BackendError(t *testing.T) {
|
|||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(
|
err := runBrain(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"write", "homelab", "facts", "bad slug"},
|
[]string{"write", "knowledge", "bad slug"},
|
||||||
strings.NewReader("body"),
|
strings.NewReader("body"),
|
||||||
&out, &errBuf,
|
&out, &errBuf,
|
||||||
)
|
)
|
||||||
@@ -150,7 +150,7 @@ func TestRunBrainWrite_EmptyStdin(t *testing.T) {
|
|||||||
t.Setenv("BRAIN_URL", srv.URL)
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(context.Background(), []string{"write", "homelab", "facts", "empty"}, strings.NewReader(""), &out, &errBuf)
|
err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
|
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-21
@@ -20,7 +20,6 @@ const defaultBrainURL = "http://koala:30330"
|
|||||||
// shell-friendly client; MCP framing is unnecessary.
|
// shell-friendly client; MCP framing is unnecessary.
|
||||||
type brainClient struct {
|
type brainClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
token string // BRAIN_MCP_TOKEN; the REST API requires it since hyperguild#149
|
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ func newBrainClient() *brainClient {
|
|||||||
}
|
}
|
||||||
return &brainClient{
|
return &brainClient{
|
||||||
baseURL: u,
|
baseURL: u,
|
||||||
token: os.Getenv("BRAIN_MCP_TOKEN"),
|
|
||||||
http: &http.Client{Timeout: 5 * time.Second},
|
http: &http.Client{Timeout: 5 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +62,8 @@ func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*Quer
|
|||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := c.do(req)
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("brain POST /query: %w", err)
|
return nil, fmt.Errorf("brain POST /query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -85,17 +84,16 @@ type WriteResult struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, content io.Reader) (*WriteResult, error) {
|
func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) {
|
||||||
body, err := io.ReadAll(content)
|
body, err := io.ReadAll(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read content: %w", err)
|
return nil, fmt.Errorf("read content: %w", err)
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(struct {
|
payload, err := json.Marshal(struct {
|
||||||
Wing string `json:"wing"`
|
Type string `json:"type"`
|
||||||
Hall string `json:"hall"`
|
Slug string `json:"slug"`
|
||||||
Filename string `json:"filename"`
|
Content string `json:"content"`
|
||||||
Content string `json:"content"`
|
}{Type: kind, Slug: slug, Content: string(body)})
|
||||||
}{Wing: wing, Hall: hall, Filename: filename, Content: string(body)})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
}
|
}
|
||||||
@@ -106,7 +104,8 @@ func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, co
|
|||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := c.do(req)
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("brain POST /write: %w", err)
|
return nil, fmt.Errorf("brain POST /write: %w", err)
|
||||||
}
|
}
|
||||||
@@ -143,7 +142,7 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build request: %w", err)
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
}
|
}
|
||||||
resp, err := c.do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("brain GET /pass-rate: %w", err)
|
return nil, fmt.Errorf("brain GET /pass-rate: %w", err)
|
||||||
}
|
}
|
||||||
@@ -158,13 +157,3 @@ func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*Pass
|
|||||||
}
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// do sends every brain request, adding the bearer token when one is
|
|
||||||
// configured. All methods go through here so none can forget it (PassRate
|
|
||||||
// did, PR #152 review).
|
|
||||||
func (c *brainClient) do(req *http.Request) (*http.Response, error) {
|
|
||||||
if c.token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
||||||
}
|
|
||||||
return c.http.Do(req)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#149: the ingestion REST API now requires a bearer token; the CLI
|
|
||||||
// sends BRAIN_MCP_TOKEN, or it gets 401 on every brain call.
|
|
||||||
func TestBrainClientSendsBrainMCPTokenOnQueryWriteAndPassRate(t *testing.T) {
|
|
||||||
var got []string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
got = append(got, r.URL.Path+" "+r.Header.Get("Authorization"))
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/write":
|
|
||||||
_, _ = w.Write([]byte(`{"path":"wiki/x/facts/y.md"}`))
|
|
||||||
return
|
|
||||||
case "/pass-rate":
|
|
||||||
_, _ = w.Write([]byte(`{}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
t.Setenv("BRAIN_URL", srv.URL)
|
|
||||||
t.Setenv("BRAIN_MCP_TOKEN", "cli-test-token")
|
|
||||||
|
|
||||||
c := newBrainClient()
|
|
||||||
_, err := c.Query(context.Background(), "x", 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = c.Write(context.Background(), "x", "facts", "y", strings.NewReader("# y"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
// PR #152 review: PassRate sent no token and would 401 in production.
|
|
||||||
_, _ = c.PassRate(context.Background(), "tdd", "7d")
|
|
||||||
assert.Equal(t, []string{"/query Bearer cli-test-token", "/write Bearer cli-test-token",
|
|
||||||
"/pass-rate Bearer cli-test-token"}, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainClientSendsNoAuthorizationWhenTokenUnset(t *testing.T) {
|
|
||||||
var auth string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
auth = r.Header.Get("Authorization")
|
|
||||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
t.Setenv("BRAIN_URL", srv.URL)
|
|
||||||
t.Setenv("BRAIN_MCP_TOKEN", "")
|
|
||||||
_, err := newBrainClient().Query(context.Background(), "x", 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, auth)
|
|
||||||
}
|
|
||||||
@@ -65,25 +65,23 @@ func TestBrainClient_Write_Success(t *testing.T) {
|
|||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var got struct {
|
var got struct {
|
||||||
Wing string `json:"wing"`
|
Type string `json:"type"`
|
||||||
Hall string `json:"hall"`
|
Slug string `json:"slug"`
|
||||||
Filename string `json:"filename"`
|
Content string `json:"content"`
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(body, &got))
|
require.NoError(t, json.Unmarshal(body, &got))
|
||||||
assert.Equal(t, "homelab", got.Wing)
|
assert.Equal(t, "knowledge", got.Type)
|
||||||
assert.Equal(t, "facts", got.Hall)
|
assert.Equal(t, "find-h", got.Slug)
|
||||||
assert.Equal(t, "find-h", got.Filename)
|
|
||||||
assert.Equal(t, "# body\n", got.Content)
|
assert.Equal(t, "# body\n", got.Content)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"path":"wiki/homelab/facts/find-h.md"}`))
|
_, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||||
res, err := c.Write(context.Background(), "homelab", "facts", "find-h", strings.NewReader("# body\n"))
|
res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "wiki/homelab/facts/find-h.md", res.Path)
|
assert.Equal(t, "knowledge/find-h.md", res.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrainClient_PassRate_Success(t *testing.T) {
|
func TestBrainClient_PassRate_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Only a `tools/call` to the routing pod records a pass/fail.
|
|||||||
|---------|-----|------|
|
|---------|-----|------|
|
||||||
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
|
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
|
||||||
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
|
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
|
||||||
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | Bearer `BRAIN_MCP_TOKEN` (since hyperguild#149) |
|
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | none (read-only) |
|
||||||
|
|
||||||
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
|
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
|
||||||
plus `session_log`, `retrospective`, `trainer`.
|
plus `session_log`, `retrospective`, `trainer`.
|
||||||
|
|||||||
@@ -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 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 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 <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 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 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.
|
- [ ] `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.
|
- [ ] 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.
|
- [ ] `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` 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 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 <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.)
|
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
|
||||||
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.
|
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:
|
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
|
||||||
|
|||||||
@@ -147,37 +147,6 @@ func envInt(key string, fallback int) int {
|
|||||||
// buildNotifier returns the ntfy notifier, or a nil interface when
|
// 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
|
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
||||||
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
// 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 {
|
func buildNotifier() audit.Notifier {
|
||||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
return n
|
return n
|
||||||
@@ -234,10 +203,6 @@ func systemHostname() string {
|
|||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
for _, w := range alertingWarnings(os.Getenv) {
|
|
||||||
logger.Warn(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
|
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
|
||||||
port := envOr("INGEST_PORT", "3300")
|
port := envOr("INGEST_PORT", "3300")
|
||||||
|
|
||||||
@@ -503,10 +468,20 @@ func main() {
|
|||||||
logger.Info("embed sync started", "interval_s", embedSyncInterval)
|
logger.Info("embed sync started", "interval_s", embedSyncInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public routes only; buildMux mounts the authenticated REST API (#149).
|
mux := http.NewServeMux()
|
||||||
public := map[string]http.Handler{}
|
mux.HandleFunc("POST /query", h.Query)
|
||||||
|
mux.HandleFunc("POST /write", h.Write)
|
||||||
|
mux.HandleFunc("POST /index", h.Index)
|
||||||
|
mux.HandleFunc("POST /ingest", h.Ingest)
|
||||||
|
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||||
|
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||||
|
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||||
|
mux.HandleFunc("GET /pending", h.Pending)
|
||||||
|
mux.HandleFunc("POST /promote", h.Promote)
|
||||||
|
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||||
|
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||||
if webhookHandler != nil {
|
if webhookHandler != nil {
|
||||||
public["POST /webhooks/brain-sync"] = webhookHandler
|
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||||
}
|
}
|
||||||
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
||||||
// configured. Static-Bearer-only deployments leave this empty so
|
// configured. Static-Bearer-only deployments leave this empty so
|
||||||
@@ -514,14 +489,14 @@ func main() {
|
|||||||
var resourceMetadataURL string
|
var resourceMetadataURL string
|
||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||||
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
||||||
public["GET /.well-known/oauth-protected-resource"] = http.HandlerFunc(
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||||
if resourceURL != "" {
|
if resourceURL != "" {
|
||||||
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public["/mcp"] = chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)
|
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
||||||
|
|
||||||
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
||||||
// tracker to file action items, so it only mounts when Gitea is
|
// tracker to file action items, so it only mounts when Gitea is
|
||||||
@@ -543,7 +518,7 @@ func main() {
|
|||||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||||
resolver := capturehttp.NewOriginResolver(sovereign)
|
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
public["POST /capture"] = captureH
|
mux.Handle("POST /capture", captureH)
|
||||||
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
||||||
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
||||||
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||||
@@ -573,9 +548,9 @@ func main() {
|
|||||||
logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer")
|
logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
public["GET /.well-known/oauth-authorization-server"] = http.HandlerFunc(
|
mux.HandleFunc("GET /.well-known/oauth-authorization-server",
|
||||||
oauth.MetadataHandler(issuer))
|
oauth.MetadataHandler(issuer))
|
||||||
public["POST /oauth/token"] = http.HandlerFunc(oauth.TokenHandler(oauth.TokenConfig{
|
mux.HandleFunc("POST /oauth/token", oauth.TokenHandler(oauth.TokenConfig{
|
||||||
ClientID: oauthID,
|
ClientID: oauthID,
|
||||||
ClientSecret: oauthSecret,
|
ClientSecret: oauthSecret,
|
||||||
AccessToken: mcpToken,
|
AccessToken: mcpToken,
|
||||||
@@ -594,17 +569,7 @@ func main() {
|
|||||||
// real request latency. /metrics itself is excluded from its own
|
// real request latency. /metrics itself is excluded from its own
|
||||||
// observation by registering it on the outer mux (post-wrap).
|
// observation by registering it on the outer mux (post-wrap).
|
||||||
reg := metrics.New()
|
reg := metrics.New()
|
||||||
public["GET /metrics"] = http.HandlerFunc(reg.Handler())
|
mux.HandleFunc("GET /metrics", reg.Handler())
|
||||||
|
|
||||||
// The whole router, built in one place so the test of buildMux is a test
|
|
||||||
// of production wiring (PR #152 review). The REST API's credential is the
|
|
||||||
// same as /mcp's.
|
|
||||||
mux, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain",
|
|
||||||
resourceMetadataURL: resourceMetadataURL}, h, public)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("build router", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
logger.Info("metrics endpoint registered", "path", "/metrics")
|
logger.Info("metrics endpoint registered", "path", "/metrics")
|
||||||
|
|
||||||
addr := ":" + port
|
addr := ":" + port
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -37,42 +36,3 @@ func TestClaudeSink_IngestWritesToNonIndexedArchiveNotWiki(t *testing.T) {
|
|||||||
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
|
_, 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)")
|
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- #107: an unconfigured alert must announce itself ----------------------
|
|
||||||
|
|
||||||
func TestAlertingWarnings(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
env map[string]string
|
|
||||||
want []string // substrings that must appear across the warnings
|
|
||||||
none bool // true = expect no warnings at all
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
// The failure this exists to prevent: two alerts dark in
|
|
||||||
// production, discovered only by firing a real event and watching
|
|
||||||
// nothing arrive (#100).
|
|
||||||
name: "notifier unconfigured names what will not fire",
|
|
||||||
env: map[string]string{},
|
|
||||||
want: []string{"BRAIN_NTFY_URL", "brain_edit_root", "audit"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "configured notifier is silent",
|
|
||||||
env: map[string]string{"BRAIN_NTFY_URL": "http://ntfy/brain-alerts"},
|
|
||||||
none: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := alertingWarnings(func(k string) string { return tt.env[k] })
|
|
||||||
if tt.none {
|
|
||||||
assert.Empty(t, got)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
joined := strings.Join(got, " ")
|
|
||||||
for _, w := range tt.want {
|
|
||||||
assert.Contains(t, joined, w)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// publicRoutes is the complete set of patterns allowed on the outer mux,
|
|
||||||
// outside the REST API's auth (hyperguild#149). Each authenticates itself or is
|
|
||||||
// meant to be open: /mcp (bearer/JWT), /capture (bearer/JWT), the webhook
|
|
||||||
// (HMAC), /oauth/token (client secret), OAuth metadata and /metrics (open).
|
|
||||||
// A REST pattern added here would beat the authenticated catch-all by
|
|
||||||
// specificity and ship open, which is why buildMux refuses anything else.
|
|
||||||
var publicRoutes = map[string]bool{
|
|
||||||
"/mcp": true,
|
|
||||||
"POST /capture": true,
|
|
||||||
"POST /webhooks/brain-sync": true,
|
|
||||||
"GET /.well-known/oauth-protected-resource": true,
|
|
||||||
"GET /.well-known/oauth-authorization-server": true,
|
|
||||||
"POST /oauth/token": true,
|
|
||||||
"GET /metrics": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// restAuth is the credential the REST API checks: the same as /mcp's.
|
|
||||||
type restAuth struct {
|
|
||||||
token string
|
|
||||||
validator *chassisauth.JWTValidator
|
|
||||||
realm string
|
|
||||||
resourceMetadataURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMux is the server's whole router. The REST API is mounted at "/"
|
|
||||||
// behind bearer auth that buildMux constructs itself, so a caller cannot pass
|
|
||||||
// a no-op; public handlers are registered as given, and only for patterns on
|
|
||||||
// the publicRoutes allowlist. It returns an http.Handler, not the
|
|
||||||
// *ServeMux, so nothing can register a route on the result afterwards and
|
|
||||||
// bypass the allowlist.
|
|
||||||
func buildMux(auth restAuth, h *api.Handler, public map[string]http.Handler) (http.Handler, error) {
|
|
||||||
if auth.token == "" {
|
|
||||||
return nil, errors.New("buildMux: REST auth needs a token")
|
|
||||||
}
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
for pattern, handler := range public {
|
|
||||||
if !publicRoutes[pattern] {
|
|
||||||
return nil, fmt.Errorf("buildMux: %q is not an allowed public route", pattern)
|
|
||||||
}
|
|
||||||
mux.Handle(pattern, handler)
|
|
||||||
}
|
|
||||||
mountREST(mux, h, func(next http.Handler) http.Handler {
|
|
||||||
return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next)
|
|
||||||
})
|
|
||||||
return mux, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mountREST registers the REST API on its own mux and mounts it at "/" behind
|
|
||||||
// auth: anything not registered as public lands here and is authenticated,
|
|
||||||
// including routes added later.
|
|
||||||
func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.Handler) {
|
|
||||||
rest := http.NewServeMux()
|
|
||||||
rest.HandleFunc("POST /query", h.Query)
|
|
||||||
rest.HandleFunc("POST /write", h.Write)
|
|
||||||
rest.HandleFunc("POST /index", h.Index)
|
|
||||||
rest.HandleFunc("POST /ingest", h.Ingest)
|
|
||||||
rest.HandleFunc("POST /ingest-path", h.IngestPath)
|
|
||||||
rest.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
|
||||||
rest.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
|
||||||
rest.HandleFunc("GET /pending", h.Pending)
|
|
||||||
rest.HandleFunc("POST /promote", h.Promote)
|
|
||||||
rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
|
||||||
rest.HandleFunc("GET /pass-rate", h.PassRate)
|
|
||||||
mux.Handle("/", auth(rest))
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
|
||||||
)
|
|
||||||
|
|
||||||
const routesTok = "routes-test-token"
|
|
||||||
|
|
||||||
func okHandler(body string) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// testMux builds the router exactly as main does: buildMux with the full
|
|
||||||
// public set main registers (stub handlers) and real bearer config.
|
|
||||||
// hyperguild#149: the REST API is authenticated as a whole; the PR #152
|
|
||||||
// review showed a test of a COPY of the router let main.go regress unseen.
|
|
||||||
func testMux(t *testing.T) http.Handler {
|
|
||||||
t.Helper()
|
|
||||||
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
|
||||||
public := map[string]http.Handler{}
|
|
||||||
for p := range publicRoutes {
|
|
||||||
public[p] = okHandler("public " + p)
|
|
||||||
}
|
|
||||||
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, public)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return mux
|
|
||||||
}
|
|
||||||
|
|
||||||
func do(t *testing.T, h http.Handler, method, path, authz string) (int, string) {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(method, path, strings.NewReader(`{"query":"x"}`))
|
|
||||||
if authz != "" {
|
|
||||||
req.Header.Set("Authorization", authz)
|
|
||||||
}
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
return rec.Code, rec.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMuxRefusesRESTRoutesWithoutAValidToken(t *testing.T) {
|
|
||||||
r := testMux(t)
|
|
||||||
for _, rt := range []struct{ method, path string }{
|
|
||||||
{"POST", "/query"}, {"POST", "/write"}, {"POST", "/index"}, {"POST", "/ingest"},
|
|
||||||
{"POST", "/ingest-path"}, {"POST", "/ingest-raw"}, {"POST", "/backfill-refs"},
|
|
||||||
{"GET", "/pending"}, {"POST", "/promote"}, {"POST", "/backfill-embeddings"}, {"GET", "/pass-rate"},
|
|
||||||
} {
|
|
||||||
code, _ := do(t, r, rt.method, rt.path, "")
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, code, "%s %s without a token", rt.method, rt.path)
|
|
||||||
code, _ = do(t, r, rt.method, rt.path, "Bearer wrong")
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, code, "%s %s with a wrong token", rt.method, rt.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMuxServesRESTRoutesToAValidToken(t *testing.T) {
|
|
||||||
r := testMux(t)
|
|
||||||
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok)
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMuxAuthenticatesAnUnregisteredPath(t *testing.T) {
|
|
||||||
code, _ := do(t, testMux(t), "POST", "/some-future-route", "")
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMux registers public handlers as given, without adding auth: each
|
|
||||||
// public route owns its own (bearer on /mcp, HMAC on the webhook, client
|
|
||||||
// secret on /oauth/token) or is meant to be open (/metrics, OAuth metadata).
|
|
||||||
func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
|
|
||||||
r := testMux(t)
|
|
||||||
for p := range publicRoutes {
|
|
||||||
method, path, ok := strings.Cut(p, " ")
|
|
||||||
if !ok {
|
|
||||||
method, path = "GET", p
|
|
||||||
}
|
|
||||||
code, body := do(t, r, method, path, "")
|
|
||||||
assert.Equal(t, http.StatusOK, code, p)
|
|
||||||
assert.Equal(t, "public "+p, body, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The allowlist is the control: a REST pattern registered as public would win
|
|
||||||
// over the authenticated catch-all (more specific pattern), so it is refused.
|
|
||||||
func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) {
|
|
||||||
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
|
||||||
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics"} {
|
|
||||||
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, map[string]http.Handler{p: okHandler("x")})
|
|
||||||
assert.Error(t, err, "public pattern %q must be refused", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMuxRefusesAnEmptyToken(t *testing.T) {
|
|
||||||
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
|
|
||||||
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, nil)
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import "strings"
|
||||||
"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 is an ordered, line-preserving view of a note's YAML
|
||||||
// frontmatter block. It deliberately avoids a full YAML round-trip: the
|
// frontmatter block. It deliberately avoids a full YAML round-trip: the
|
||||||
@@ -31,37 +25,21 @@ type fmLine struct {
|
|||||||
// frontmatter block is recognised only when the file opens with a `---`
|
// frontmatter block is recognised only when the file opens with a `---`
|
||||||
// fence and a closing `---` fence follows. Otherwise the whole input is
|
// fence and a closing `---` fence follows. Otherwise the whole input is
|
||||||
// the body and the returned frontmatter is empty.
|
// 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) {
|
func parseFrontmatter(src string) (frontmatter, string) {
|
||||||
var fm frontmatter
|
var fm frontmatter
|
||||||
blockBytes, restBytes, ok := sharedfm.Split([]byte(src))
|
if !strings.HasPrefix(src, "---\n") {
|
||||||
if !ok {
|
|
||||||
return fm, src
|
return fm, src
|
||||||
}
|
}
|
||||||
// sharedfm.Split's block/rest boundary sits one byte earlier than this
|
rest := src[len("---\n"):]
|
||||||
// package's always has: block keeps the "\n" immediately before the
|
end := strings.Index(rest, "\n---\n")
|
||||||
// closing fence, and rest keeps the "\n" immediately after it (both by
|
if end < 0 {
|
||||||
// design, for Fix's own reconstruction). This package's block/body
|
// Opening fence with no closing fence — treat as bodyless content.
|
||||||
// never included either — TrimSuffix/TrimPrefix restore that exact
|
return fm, src
|
||||||
// boundary. Split guarantees ok=true only when "\n---\n" matched, so
|
}
|
||||||
// both trims are always exactly one byte, never ambiguous.
|
block := rest[:end]
|
||||||
block := strings.TrimSuffix(string(blockBytes), "\n")
|
body := rest[end+len("\n---\n"):]
|
||||||
body := strings.TrimPrefix(string(restBytes), "\n")
|
|
||||||
|
|
||||||
for _, line := range strings.Split(block, "\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, val, ok := strings.Cut(line, ":")
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
@@ -105,19 +83,9 @@ func (f *frontmatter) render() string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("---\n")
|
b.WriteString("---\n")
|
||||||
for _, l := range f.lines {
|
for _, l := range f.lines {
|
||||||
switch {
|
if l.key == "" {
|
||||||
case l.key == "":
|
|
||||||
b.WriteString(l.raw)
|
b.WriteString(l.raw)
|
||||||
case l.value == "":
|
} else {
|
||||||
// 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(l.key)
|
||||||
b.WriteString(": ")
|
b.WriteString(": ")
|
||||||
b.WriteString(l.value)
|
b.WriteString(l.value)
|
||||||
|
|||||||
@@ -59,37 +59,3 @@ func TestFrontmatterRenderRoundTrips(t *testing.T) {
|
|||||||
fm, _ := parseFrontmatter(src)
|
fm, _ := parseFrontmatter(src)
|
||||||
assert.Equal(t, src, fm.render())
|
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())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ type queryRequest struct {
|
|||||||
type writeRequest struct {
|
type writeRequest struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
Wing string `json:"wing,omitempty"`
|
Wing string `json:"wing,omitempty"`
|
||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
SourceType string `json:"source_type,omitempty"` // "external" opts a hall=facts entry out of the internal default
|
SourceType string `json:"source_type,omitempty"` // "external" opts a hall=facts entry out of the internal default
|
||||||
@@ -108,21 +110,20 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// WriteNoteOptions configures how a brain note is written.
|
// WriteNoteOptions configures how a brain note is written.
|
||||||
//
|
//
|
||||||
// Wing and Hall are both required: the note routes into the structured
|
// When both Wing and Hall are non-empty, the note routes into the
|
||||||
// wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets wing/hall/created_at
|
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
|
||||||
// injected into its YAML frontmatter. There is no other valid destination
|
// wing/hall/created_at injected into its YAML frontmatter.
|
||||||
// (the legacy brain/knowledge/ fallback was removed — ADR-0012 / #79).
|
//
|
||||||
|
// When either is empty, the note falls back to brain/knowledge/<filename>
|
||||||
|
// with optional type/domain frontmatter (legacy behaviour).
|
||||||
type WriteNoteOptions struct {
|
type WriteNoteOptions struct {
|
||||||
Content string
|
Content string
|
||||||
Filename string
|
Filename string
|
||||||
|
Type string
|
||||||
|
Domain string
|
||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
|
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
|
// WriteNote writes a markdown note into the brain. Returns the path
|
||||||
@@ -131,9 +132,6 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
if opts.Content == "" {
|
if opts.Content == "" {
|
||||||
return "", fmt.Errorf("content is required")
|
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,
|
// 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.
|
// without the BrainStore, and a gate one door wide is not a gate.
|
||||||
@@ -145,7 +143,13 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
opts.Content = clean
|
opts.Content = clean
|
||||||
|
|
||||||
return writeHallNote(brainDir, opts)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
|
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
|
||||||
@@ -174,6 +178,9 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("claim envelope: %w", err)
|
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)
|
emitted := make(map[string]bool, 6)
|
||||||
|
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
@@ -200,6 +207,9 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
writeField("type", opts.Type)
|
||||||
|
writeField("domain", opts.Domain)
|
||||||
|
|
||||||
sourceType := opts.SourceType
|
sourceType := opts.SourceType
|
||||||
if sourceType == "" && opts.Hall == "facts" {
|
if sourceType == "" && opts.Hall == "facts" {
|
||||||
// Most hall=facts entries are first-party (an eval/benchmark the
|
// Most hall=facts entries are first-party (an eval/benchmark the
|
||||||
@@ -221,17 +231,7 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
|
|
||||||
final, err := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
|
if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil {
|
||||||
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)
|
return "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
rel, _ := filepath.Rel(brainDir, dest)
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
@@ -300,32 +300,69 @@ func splitFrontmatter(content string) (fields []frontmatterField, body string) {
|
|||||||
return fields, body
|
return fields, body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write handles POST /write — write a note into brain/wiki/<wing>/<hall>/.
|
// 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/.
|
||||||
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||||
var req writeRequest
|
var req writeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Field by field, not a struct conversion: provenance is server-owned and
|
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
|
||||||
// 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 {
|
if err != nil {
|
||||||
h.logger.Error("write failed", "err", err)
|
h.logger.Error("write failed", "err", err)
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// WriteNote errors out above unless both are set, so this always runs —
|
if req.Wing != "" && req.Hall != "" {
|
||||||
// no legacy fallback path left to guard against.
|
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
||||||
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
|
||||||
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]string{"path": relPath})
|
writeJSON(w, map[string]string{"path": relPath})
|
||||||
}
|
}
|
||||||
@@ -447,12 +484,6 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "path is required")
|
writeError(w, http.StatusBadRequest, "path is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resolved, err := ResolveIngestPath(h.brainDir, req.Path)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Path = resolved
|
|
||||||
|
|
||||||
info, err := os.Stat(req.Path)
|
info, err := os.Stat(req.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -475,12 +506,6 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !isSupportedExtension(ext) {
|
if !isSupportedExtension(ext) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Each walked file is re-resolved: a symlinked file inside raw/
|
|
||||||
// would otherwise lead extract.Text out of the inbox (#149).
|
|
||||||
if _, rerr := ResolveIngestPath(h.brainDir, path); rerr != nil {
|
|
||||||
allWarnings = append(allWarnings, fmt.Sprintf("skipped %s: %v", path, rerr))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, readErr := extract.Text(path)
|
content, readErr := extract.Text(path)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
||||||
@@ -562,8 +587,7 @@ func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// No principal on this door: pages are stamped origin: unknown (#145).
|
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
|
||||||
result, err := pipeline.RunRaw(r.Context(), h.brainDir, req.Source, req.Pages, req.DryRun)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
|
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "ingest error")
|
writeError(w, http.StatusInternalServerError, "ingest error")
|
||||||
@@ -619,11 +643,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Field by field: this door carries no principal, so the promoted note is
|
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
|
||||||
// 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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,13 +65,11 @@ func TestQuery_ReturnsResults(t *testing.T) {
|
|||||||
assert.NotEmpty(t, results)
|
assert.NotEmpty(t, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrite_CreatesWikiFile(t *testing.T) {
|
func TestWrite_CreatesKnowledgeFile(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"content": "# Test note\n\nSome content.",
|
"content": "# Test note\n\nSome content.",
|
||||||
"filename": "test-note",
|
"filename": "test-note.md",
|
||||||
"wing": "homelab",
|
|
||||||
"hall": "facts",
|
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -83,25 +81,11 @@ func TestWrite_CreatesWikiFile(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
assert.NotEmpty(t, resp["path"])
|
assert.NotEmpty(t, resp["path"])
|
||||||
|
|
||||||
content, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "test-note.md"))
|
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "test-note.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), "Some content.")
|
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) {
|
func TestQuery_RequiresQuery(t *testing.T) {
|
||||||
_, h := setup(t)
|
_, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{"limit": 5})
|
body, _ := json.Marshal(map[string]any{"limit": 5})
|
||||||
@@ -113,6 +97,27 @@ func TestQuery_RequiresQuery(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
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) {
|
func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
@@ -121,6 +126,7 @@ func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
|
|||||||
Filename: "session-koala-abc",
|
Filename: "session-koala-abc",
|
||||||
Wing: "claude-sessions",
|
Wing: "claude-sessions",
|
||||||
Hall: "facts",
|
Hall: "facts",
|
||||||
|
Type: "source",
|
||||||
SourceType: "internal",
|
SourceType: "internal",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -204,95 +210,37 @@ func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testin
|
|||||||
assert.Contains(t, body, "# Body")
|
assert.Contains(t, body, "# Body")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWriteNote_RequiresWingAndHall pins the post-legacy-removal contract
|
func TestWriteNote_HallRouteExistingTypeWinsOverOptsType(t *testing.T) {
|
||||||
// (hyperguild#79 / ADR-0012): with the knowledge/ fallback gone, wing+hall
|
dir := t.TempDir()
|
||||||
// is the only valid destination, so omitting either is a caller error, not
|
|
||||||
// a silent fallback.
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
func TestWriteNote_RequiresWingAndHall(t *testing.T) {
|
Content: "---\ntype: hypothesis\n---\n\nBody.\n",
|
||||||
cases := []struct {
|
Filename: "note",
|
||||||
name string
|
Wing: "agentsquad",
|
||||||
wing string
|
Hall: "decisions",
|
||||||
hall string
|
Type: "decision", // should lose to content's own "type: hypothesis"
|
||||||
}{
|
})
|
||||||
{"neither set", "", ""},
|
require.NoError(t, err)
|
||||||
{"wing only", "homelab", ""},
|
|
||||||
{"hall only", "", "facts"},
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
}
|
require.NoError(t, err)
|
||||||
for _, tc := range cases {
|
assert.Contains(t, string(got), "type: hypothesis")
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
assert.NotContains(t, string(got), "type: decision")
|
||||||
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) {
|
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||||
"content": "auto name",
|
|
||||||
"wing": "homelab",
|
|
||||||
"hall": "facts",
|
|
||||||
})
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
h.Write(rec, req)
|
h.Write(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
entries, err := os.ReadDir(filepath.Join(dir, "wiki", "homelab", "facts"))
|
entries, _ := os.ReadDir(filepath.Join(dir, "knowledge"))
|
||||||
require.NoError(t, err)
|
// +1 because setup already wrote tdd.md
|
||||||
require.Len(t, entries, 1)
|
assert.Len(t, entries, 2)
|
||||||
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
assert.True(t, strings.HasSuffix(entries[1].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.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -361,11 +309,10 @@ func TestIngestPath_MissingPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestPath_File(t *testing.T) {
|
func TestIngestPath_File(t *testing.T) {
|
||||||
brainDir, h := setup(t)
|
_, h := setup(t)
|
||||||
|
|
||||||
// Create a temp file with content
|
// Create a temp file with content
|
||||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
f := filepath.Join(dir, "doc.md")
|
f := filepath.Join(dir, "doc.md")
|
||||||
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
|
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
|
||||||
|
|
||||||
@@ -469,11 +416,10 @@ func TestIngestRaw_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestPath_Directory(t *testing.T) {
|
func TestIngestPath_Directory(t *testing.T) {
|
||||||
brainDir, h := setup(t)
|
_, h := setup(t)
|
||||||
|
|
||||||
// Create a temp dir with one .md file
|
// Create a temp dir with one .md file
|
||||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ func TestIngestPath_DocxSupportedWhenDocmarkConfigured(t *testing.T) {
|
|||||||
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
brainDir, h := setup(t)
|
_, h := setup(t)
|
||||||
|
|
||||||
dir := filepath.Join(brainDir, "raw", "fixture") // ingest paths must live in brain/raw/ (#149)
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
||||||
f := filepath.Join(dir, "doc.docx")
|
f := filepath.Join(dir, "doc.docx")
|
||||||
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrOutsideIngestRoot is the one refusal for every bad ingest path. It never
|
|
||||||
// carries the underlying error: "no such file" versus "permission denied"
|
|
||||||
// would let a caller probe which server files exist (PR #152 review).
|
|
||||||
var ErrOutsideIngestRoot = errors.New("ingest path must be an existing file or directory inside brain/raw/")
|
|
||||||
|
|
||||||
// ResolveIngestPath confines a caller-supplied ingest path to brain/raw/, the
|
|
||||||
// inbox the watcher already owns (hyperguild#149). Relative paths resolve
|
|
||||||
// against that root; symlinks are evaluated before the containment check, so
|
|
||||||
// a link inside raw/ that points elsewhere is refused. The path must exist.
|
|
||||||
func ResolveIngestPath(brainDir, p string) (string, error) {
|
|
||||||
if strings.TrimSpace(p) == "" {
|
|
||||||
return "", ErrOutsideIngestRoot
|
|
||||||
}
|
|
||||||
root, err := filepath.EvalSymlinks(filepath.Join(brainDir, "raw"))
|
|
||||||
if err != nil {
|
|
||||||
return "", ErrOutsideIngestRoot
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(p) {
|
|
||||||
p = filepath.Join(root, p)
|
|
||||||
}
|
|
||||||
real, err := filepath.EvalSymlinks(filepath.Clean(p))
|
|
||||||
if err != nil {
|
|
||||||
return "", ErrOutsideIngestRoot
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(root, real)
|
|
||||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
|
||||||
return "", ErrOutsideIngestRoot
|
|
||||||
}
|
|
||||||
return real, nil
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package api_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#149: /ingest-path and MCP brain_ingest read a caller-chosen
|
|
||||||
// server path, so any file the pod could read (the ServiceAccount token,
|
|
||||||
// /etc/passwd) could be pulled into the brain and read back out. Ingest paths
|
|
||||||
// now resolve inside brain/raw/ — the inbox the watcher already owns — with
|
|
||||||
// symlinks evaluated, or are refused.
|
|
||||||
func TestResolveIngestPathConfinesToRawInbox(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
raw := filepath.Join(brain, "raw")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(raw, "sub"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "sub", "ok.md"), []byte("x"), 0o644))
|
|
||||||
outside := filepath.Join(t.TempDir(), "secret.txt")
|
|
||||||
require.NoError(t, os.WriteFile(outside, []byte("s"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "link.md")))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Dir(outside), filepath.Join(raw, "dirlink")))
|
|
||||||
|
|
||||||
for _, ok := range []string{filepath.Join(raw, "sub", "ok.md"), "sub/ok.md", raw, "sub"} {
|
|
||||||
_, err := api.ResolveIngestPath(brain, ok)
|
|
||||||
assert.NoError(t, err, ok)
|
|
||||||
}
|
|
||||||
for _, bad := range []string{
|
|
||||||
"/etc/hostname", outside, "../secret.txt", filepath.Join(raw, "..", "wiki"),
|
|
||||||
filepath.Join(raw, "link.md"), filepath.Join(raw, "dirlink", "secret.txt"),
|
|
||||||
raw + "-evil/x.md", "",
|
|
||||||
} {
|
|
||||||
_, err := api.ResolveIngestPath(brain, bad)
|
|
||||||
assert.Error(t, err, "must refuse %q", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIngestPathRefusesAPathOutsideTheInbox(t *testing.T) {
|
|
||||||
_, h := setup(t)
|
|
||||||
body, _ := json.Marshal(map[string]any{"path": "/etc/hostname"})
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)))
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
|
||||||
assert.Contains(t, rec.Body.String(), "raw")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A directory ingest must not follow a symlinked FILE inside raw/ out of it.
|
|
||||||
func TestIngestPathDirectoryWalkSkipsSymlinksLeavingTheInbox(t *testing.T) {
|
|
||||||
dir, h := setup(t)
|
|
||||||
raw := filepath.Join(dir, "raw", "batch")
|
|
||||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
|
||||||
outside := filepath.Join(t.TempDir(), "secret.md")
|
|
||||||
require.NoError(t, os.WriteFile(outside, []byte("# secret\n\nSENTINEL-149-OUTSIDE"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "leak.md")))
|
|
||||||
body, _ := json.Marshal(map[string]any{"path": "batch", "source": "walk"})
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.IngestPath(rec, httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body)))
|
|
||||||
assert.Contains(t, rec.Body.String(), "skipped")
|
|
||||||
assert.NotContains(t, rec.Body.String(), "SENTINEL-149-OUTSIDE")
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR #152 review: the raw EvalSymlinks error told an authenticated caller
|
|
||||||
// whether any server file existed ("no such file") or was unreadable
|
|
||||||
// ("permission denied"), and leaked the brain directory. Every refusal now
|
|
||||||
// reads the same.
|
|
||||||
func TestResolveIngestPathRefusalsAreIndistinguishable(t *testing.T) {
|
|
||||||
brain := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
|
|
||||||
var msgs []string
|
|
||||||
for _, p := range []string{"/etc/hostname", "/etc/definitely-not-here-149", "../x", "nope/missing.md", ""} {
|
|
||||||
_, err := api.ResolveIngestPath(brain, p)
|
|
||||||
require.Error(t, err, p)
|
|
||||||
msgs = append(msgs, err.Error())
|
|
||||||
assert.NotContains(t, err.Error(), brain, "refusal for %q leaks the brain dir", p)
|
|
||||||
}
|
|
||||||
for _, m := range msgs[1:] {
|
|
||||||
assert.Equal(t, msgs[0], m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -72,10 +72,6 @@ type PromoteOptions struct {
|
|||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
Slug string // optional; defaults to Filename minus date prefix + .md
|
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
|
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
||||||
@@ -132,11 +128,7 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("create wing dir: %w", err)
|
return "", fmt.Errorf("create wing dir: %w", err)
|
||||||
}
|
}
|
||||||
// Write-then-delete: the source survives any write failure.
|
// Write-then-delete: the source survives any write failure.
|
||||||
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
|
|
||||||
return "", fmt.Errorf("write promoted note: %w", err)
|
return "", fmt.Errorf("write promoted note: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Remove(src); err != nil {
|
if err := os.Remove(src); err != nil {
|
||||||
@@ -153,68 +145,6 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
return relSlash, nil
|
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.
|
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
||||||
func excerpt(s string, n int) string {
|
func excerpt(s string, n int) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
|
|||||||
@@ -119,85 +119,3 @@ func TestPromoteRebuildsWingIndex(t *testing.T) {
|
|||||||
require.NoError(t, err, "wing _index regenerated")
|
require.NoError(t, err, "wing _index regenerated")
|
||||||
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -46,11 +46,6 @@ type UpdateNoteOptions struct {
|
|||||||
Slug string
|
Slug string
|
||||||
Content string // new full body (whole-note replace)
|
Content string // new full body (whole-note replace)
|
||||||
Reason string // optional; stamped as supersede_reason
|
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
|
// UpdateNote supersedes an existing note in place. It replaces the body
|
||||||
@@ -115,23 +110,9 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
|
|
||||||
fm, _ := parseFrontmatter(string(prior))
|
fm, _ := parseFrontmatter(string(prior))
|
||||||
for _, l := range contentFM.lines {
|
for _, l := range contentFM.lines {
|
||||||
// updated_at/supersedes/supersede_reason are this function's own
|
if l.key != "" {
|
||||||
// output, not something a caller's fragment sets — updated_at and
|
fm.set(l.key, l.value)
|
||||||
// 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("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||||
fm.set("supersedes", priorHash)
|
fm.set("supersedes", priorHash)
|
||||||
@@ -158,11 +139,7 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stamped, err := StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy)
|
out := []byte(fm.render() + contentBody)
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
out := []byte(stamped)
|
|
||||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||||
return "", "", "", fmt.Errorf("write: %w", err)
|
return "", "", "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,45 +231,6 @@ func TestUpdateThenReadRoundTripsHash(t *testing.T) {
|
|||||||
assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote")
|
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) {
|
func TestContentHashStable(t *testing.T) {
|
||||||
assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc")))
|
assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc")))
|
||||||
assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd")))
|
assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd")))
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error)
|
|||||||
n.Content = content
|
n.Content = content
|
||||||
|
|
||||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||||
Content: n.Content,
|
Content: n.Content,
|
||||||
Filename: n.Filename,
|
Filename: n.Filename,
|
||||||
Wing: n.Wing,
|
Type: n.Type,
|
||||||
Hall: n.Hall,
|
Domain: n.Domain,
|
||||||
Origin: n.Origin,
|
Wing: n.Wing,
|
||||||
WrittenBy: n.WrittenBy,
|
Hall: n.Hall,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return capture.Ref{}, err
|
return capture.Ref{}, err
|
||||||
@@ -76,7 +76,7 @@ func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (captur
|
|||||||
}
|
}
|
||||||
n.Content = content
|
n.Content = content
|
||||||
|
|
||||||
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason, Origin: n.Origin, WrittenBy: n.WrittenBy}
|
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||||
if strings.Contains(slug, "/") {
|
if strings.Contains(slug, "/") {
|
||||||
opts.Path = slug
|
opts.Path = slug
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ type CaptureContext struct {
|
|||||||
Classification string // caller-declared level token ("" = unspecified)
|
Classification string // caller-declared level token ("" = unspecified)
|
||||||
Principal string // server-derived (auth); audit identity
|
Principal string // server-derived (auth); audit identity
|
||||||
Origin Zone // server-derived trust zone; the I1 gate input
|
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
|
// Insight is one piece of session knowledge bound for the brain. A
|
||||||
|
|||||||
@@ -31,20 +31,18 @@ type StoredNote struct {
|
|||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note is the brain-write payload: wing+hall are the only routing fields
|
// Note is the brain-write payload. It carries both the wing/hall taxonomy
|
||||||
// (the legacy type/domain params were removed — ADR-0012 / #79). Reason is
|
// and the legacy type/domain fields so a single BrainStore serves both
|
||||||
|
// capture insights and the existing MCP brain_write surface. Reason is
|
||||||
// the supersede rationale, used only by Update.
|
// the supersede rationale, used only by Update.
|
||||||
type Note struct {
|
type Note struct {
|
||||||
Content string
|
Content string
|
||||||
Filename string
|
Filename string
|
||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
|
Type string
|
||||||
|
Domain string
|
||||||
Reason 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
|
// BrainStore is the brain persistence port — the shared implementation of
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
var landed []string
|
var landed []string
|
||||||
|
|
||||||
for i, ins := range in.Insights {
|
for i, ins := range in.Insights {
|
||||||
res, item, err := s.persistInsight(ctx, ins, in.Context)
|
res, item, err := s.persistInsight(ctx, ins)
|
||||||
receipt.Insights = append(receipt.Insights, res)
|
receipt.Insights = append(receipt.Insights, res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
||||||
@@ -278,11 +278,8 @@ func escalationReason(escalators []string) string {
|
|||||||
return "required by: " + strings.Join(escalators, ", ")
|
return "required by: " + strings.Join(escalators, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) persistInsight(ctx context.Context, ins Insight, cc CaptureContext) (InsightResult, string, error) {
|
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
||||||
note := Note{
|
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
|
||||||
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 ref Ref
|
||||||
var err error
|
var err error
|
||||||
if ins.SupersedeSlug != "" {
|
if ins.SupersedeSlug != "" {
|
||||||
|
|||||||
@@ -104,10 +104,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// caller may have tried to put in the body.
|
// caller may have tried to put in the body.
|
||||||
in.Context.Principal = principal
|
in.Context.Principal = principal
|
||||||
in.Context.Origin = h.resolver.Resolve(principal, viaStatic)
|
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)
|
rec, err := h.svc.Capture(r.Context(), in)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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,26 +43,6 @@ var DefaultRules = []Rule{
|
|||||||
// JSON, so both segments begin with "eyJ".
|
// 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: "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/]+@`)},
|
{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: "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: "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`)},
|
{Name: "github-pat", RE: regexp.MustCompile(`\b(?:ghp|gho|ghu|ghr|gha)_[A-Za-z0-9]{30,}\b`)},
|
||||||
|
|||||||
@@ -46,64 +46,6 @@ 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) {
|
func TestScrub_CleanContentPassesThrough(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func Fix(doc []byte) ([]byte, bool, error) {
|
|||||||
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
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 := []byte(fence + "\n")
|
||||||
out = append(out, fixed...)
|
out = append(out, fixed...)
|
||||||
out = append(out, []byte(fence)...)
|
out = append(out, []byte(fence)...)
|
||||||
@@ -122,25 +122,14 @@ func needsQuoting(value string) bool {
|
|||||||
|
|
||||||
// block returns the raw YAML between the opening and closing fences.
|
// block returns the raw YAML between the opening and closing fences.
|
||||||
func block(doc []byte) ([]byte, bool) {
|
func block(doc []byte) ([]byte, bool) {
|
||||||
b, _, ok := Split(doc)
|
b, _, ok := split(doc)
|
||||||
return b, ok
|
return b, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split returns doc's frontmatter block and everything from the closing
|
// split returns the frontmatter block and everything from the closing fence
|
||||||
// fence onward (the closing fence itself excluded, its trailing newline
|
// onward (the closing fence itself excluded, its trailing newline included).
|
||||||
// included), or ok=false if doc has no properly-fenced frontmatter.
|
func split(doc []byte) (block, rest []byte, ok bool) {
|
||||||
//
|
s := string(doc)
|
||||||
// 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") {
|
if !strings.HasPrefix(s, fence+"\n") {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
@@ -150,18 +139,5 @@ func Split(doc []byte) (block, rest []byte, ok bool) {
|
|||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
// What follows the closing fence must be either a newline (a real body
|
return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true
|
||||||
// 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)
|
require.True(t, changed)
|
||||||
assert.True(t, len(got) > 0)
|
assert.True(t, len(got) > 0)
|
||||||
|
|
||||||
_, rest, ok := Split([]byte(doc))
|
_, rest, ok := split([]byte(doc))
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
_, gotRest, ok := Split(got)
|
_, gotRest, ok := split(got)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
||||||
}
|
}
|
||||||
@@ -186,55 +186,3 @@ func TestFix_RefusesToReturnAnUnparseableResult(t *testing.T) {
|
|||||||
assert.False(t, changed)
|
assert.False(t, changed)
|
||||||
assert.Equal(t, doc, string(got), "an unfixable document must come back byte-identical")
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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...)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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"])
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"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/search"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||||
)
|
)
|
||||||
@@ -54,12 +53,14 @@ func (s *Server) tools() []map[string]any {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "brain_write",
|
"name": "brain_write",
|
||||||
"description": "Write a markdown note to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter. Both wing and hall are required.",
|
"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", "wing", "hall"}, map[string]any{
|
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||||
"content": str("markdown content"),
|
"content": str("markdown content"),
|
||||||
"filename": str("optional filename or slug"),
|
"filename": str("optional filename or slug"),
|
||||||
"wing": str("topic domain, e.g. jepa-fx"),
|
"type": str("optional frontmatter type (legacy)"),
|
||||||
"hall": enum("memory type", halls...),
|
"domain": str("optional frontmatter domain (legacy)"),
|
||||||
|
"wing": str("optional topic domain, e.g. jepa-fx"),
|
||||||
|
"hall": enum("optional memory type (requires wing)", halls...),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,14 +98,6 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
"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()[0],
|
||||||
noteToolDescriptors()[1],
|
noteToolDescriptors()[1],
|
||||||
{
|
{
|
||||||
@@ -234,6 +227,8 @@ func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
type brainWriteArgs struct {
|
type brainWriteArgs struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
Wing string `json:"wing,omitempty"`
|
Wing string `json:"wing,omitempty"`
|
||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -256,14 +251,13 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
// one implementation (capture uses the same store). The read-after-write
|
// one implementation (capture uses the same store). The read-after-write
|
||||||
// handle {id, path, content_hash} comes back from the store; path is kept
|
// handle {id, path, content_hash} comes back from the store; path is kept
|
||||||
// for backward compatibility.
|
// for backward compatibility.
|
||||||
origin, writtenBy := provenanceFrom(ctx)
|
|
||||||
ref, err := s.store.Write(ctx, capture.Note{
|
ref, err := s.store.Write(ctx, capture.Note{
|
||||||
Content: a.Content,
|
Content: a.Content,
|
||||||
Filename: a.Filename,
|
Filename: a.Filename,
|
||||||
Wing: a.Wing,
|
Type: a.Type,
|
||||||
Hall: a.Hall,
|
Domain: a.Domain,
|
||||||
Origin: origin,
|
Wing: a.Wing,
|
||||||
WrittenBy: writtenBy,
|
Hall: a.Hall,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -308,14 +302,11 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
if a.Path != "" {
|
if a.Path != "" {
|
||||||
slug = a.Path
|
slug = a.Path
|
||||||
}
|
}
|
||||||
origin, writtenBy := provenanceFrom(ctx)
|
|
||||||
ref, err := s.store.Update(ctx, slug, capture.Note{
|
ref, err := s.store.Update(ctx, slug, capture.Note{
|
||||||
Content: a.Content,
|
Content: a.Content,
|
||||||
Wing: a.Wing,
|
Wing: a.Wing,
|
||||||
Hall: a.Hall,
|
Hall: a.Hall,
|
||||||
Reason: a.Reason,
|
Reason: a.Reason,
|
||||||
Origin: origin,
|
|
||||||
WrittenBy: writtenBy,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -394,10 +385,8 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
origin, writtenBy := provenanceFrom(ctx)
|
|
||||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
||||||
Origin: origin, WrittenBy: writtenBy,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -406,26 +395,6 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
return json.Marshal(map[string]string{"path": relPath})
|
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
|
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||||
// logs failures but never propagates them — the underlying write/ingest
|
// logs failures but never propagates them — the underlying write/ingest
|
||||||
// has already succeeded and the graph is an augmentation, not a
|
// has already succeeded and the graph is an augmentation, not a
|
||||||
@@ -500,7 +469,7 @@ func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json
|
|||||||
if len(a.Pages) == 0 {
|
if len(a.Pages) == 0 {
|
||||||
return nil, fmt.Errorf("pages must be non-empty")
|
return nil, fmt.Errorf("pages must be non-empty")
|
||||||
}
|
}
|
||||||
result, err := pipeline.RunRaw(withCallerStamp(ctx), s.brainDir, a.Source, a.Pages, a.DryRun)
|
result, err := pipeline.RunRaw(s.brainDir, a.Source, a.Pages, a.DryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ingest: %w", err)
|
return nil, fmt.Errorf("ingest: %w", err)
|
||||||
}
|
}
|
||||||
@@ -543,12 +512,6 @@ func (s *Server) brainIngest(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
}
|
}
|
||||||
|
|
||||||
if a.Path != "" {
|
if a.Path != "" {
|
||||||
// Confined to brain/raw/ with symlinks evaluated (hyperguild#149).
|
|
||||||
resolved, err := api.ResolveIngestPath(s.brainDir, a.Path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
a.Path = resolved
|
|
||||||
text, err := extract.Text(a.Path)
|
text, err := extract.Text(a.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: %w", err)
|
return nil, fmt.Errorf("extract: %w", err)
|
||||||
@@ -605,7 +568,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) {
|
func (s *Server) runIngest(ctx context.Context, content, source string, dryRun bool) (json.RawMessage, error) {
|
||||||
result, err := pipeline.Run(withCallerStamp(ctx), s.pipeline, s.brainDir, content, source, dryRun)
|
result, err := pipeline.Run(ctx, s.pipeline, s.brainDir, content, source, dryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ingest: %w", err)
|
return nil, fmt.Errorf("ingest: %w", err)
|
||||||
}
|
}
|
||||||
@@ -624,10 +587,3 @@ func (s *Server) runIngest(ctx context.Context, content, source string, dryRun b
|
|||||||
}
|
}
|
||||||
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
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})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,50 +51,25 @@ func TestBrainQueryReturnsResults(t *testing.T) {
|
|||||||
assert.Contains(t, text, "tdd.md")
|
assert.Contains(t, text, "tdd.md")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBrainWriteIgnoresLegacyTypeAndDomainParams pins the post-#79 contract:
|
func TestBrainWriteCreatesFile(t *testing.T) {
|
||||||
// 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()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||||
"content": "# Test\n\nbody",
|
"content": "# Test\n\nbody",
|
||||||
"filename": "test",
|
"filename": "test.md",
|
||||||
"wing": "homelab",
|
|
||||||
"hall": "facts",
|
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"domain": "personal",
|
"domain": "personal",
|
||||||
})
|
})
|
||||||
require.Nil(t, resp["error"])
|
require.Nil(t, resp["error"])
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(brainDir, "wiki", "homelab", "facts", "test.md"))
|
got, err := os.ReadFile(filepath.Join(brainDir, "knowledge", "test.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, string(got), "type: note")
|
assert.Contains(t, string(got), "type: note")
|
||||||
assert.NotContains(t, string(got), "domain: personal")
|
assert.Contains(t, string(got), "domain: personal")
|
||||||
assert.Contains(t, string(got), "# Test")
|
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) {
|
func TestBrainWriteWingHallRoutesToWiki(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
@@ -233,11 +208,30 @@ func TestBrainTunnelRejectsMissing(t *testing.T) {
|
|||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filename traversal/sanitisation on the wiki route is covered directly
|
func TestBrainWriteRejectsTraversal(t *testing.T) {
|
||||||
// against brain.NotePath/Sanitise in internal/brain/path_test.go
|
brainDir := t.TempDir()
|
||||||
// (TestNotePath_SanitisesWingAndSlug, TestSanitise) — those tests exercised
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
// the now-deleted brain/knowledge/ legacy path's own separate filename
|
|
||||||
// validation, which had no equivalent once that path was removed (#79).
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
func TestBrainIngestRawDryRun(t *testing.T) {
|
func TestBrainIngestRawDryRun(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
@@ -396,38 +390,3 @@ func TestBrainPromoteInvalidHallErrors(t *testing.T) {
|
|||||||
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
||||||
assert.NoError(t, srcErr, "source untouched on validation error")
|
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"])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package mcp_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
|
||||||
)
|
|
||||||
|
|
||||||
// hyperguild#149: brain_ingest's `path` read any file the pod could read and
|
|
||||||
// ingested it into the brain, where brain_query reads it back out.
|
|
||||||
func TestBrainIngestRefusesAPathOutsideTheRawInbox(t *testing.T) {
|
|
||||||
stub := pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
|
||||||
return `[{"title":"Leak","type":"concept","content":"x"}]`, nil
|
|
||||||
}}
|
|
||||||
srv := mcp.NewServer(newBrainRepo(t), &stub, nil, nil)
|
|
||||||
resp := authCall(t, srv, "", "brain_ingest", map[string]any{"path": "/etc/hostname"})
|
|
||||||
require.NotNil(t, resp["error"], "a path outside brain/raw/ must be refused")
|
|
||||||
assert.Contains(t, resp["error"].(map[string]any)["message"], "brain/raw/")
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
@@ -230,9 +229,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx = withPrincipal(ctx, principal, viaStatic)
|
ctx = withPrincipal(ctx, principal, viaStatic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
start := time.Now()
|
|
||||||
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
||||||
logToolCall(ctx, p.Name, p.Arguments, start, err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||||
break
|
break
|
||||||
@@ -278,8 +275,6 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainPending(ctx, args)
|
return s.brainPending(ctx, args)
|
||||||
case "brain_promote":
|
case "brain_promote":
|
||||||
return s.brainPromote(ctx, args)
|
return s.brainPromote(ctx, args)
|
||||||
case "brain_reject":
|
|
||||||
return s.brainReject(ctx, args)
|
|
||||||
case "capture":
|
case "capture":
|
||||||
return s.brainCapture(ctx, args)
|
return s.brainCapture(ctx, args)
|
||||||
case "brain_read":
|
case "brain_read":
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||||
"brain_pending", "brain_promote", "brain_reject",
|
"brain_pending", "brain_promote",
|
||||||
"brain_read", "brain_edit",
|
"brain_read", "brain_edit",
|
||||||
"brain_tunnel",
|
"brain_tunnel",
|
||||||
"brain_ingest_raw", "brain_ingest",
|
"brain_ingest_raw", "brain_ingest",
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
// Principal and origin are server-derived — never taken from the body.
|
// Principal and origin are server-derived — never taken from the body.
|
||||||
in.Context.Principal = info.principal
|
in.Context.Principal = info.principal
|
||||||
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
|
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
|
||||||
in.Context.AuthPath, _ = provenanceFrom(ctx)
|
|
||||||
|
|
||||||
rec, err := s.capture.svc.Capture(ctx, in)
|
rec, err := s.capture.svc.Capture(ctx, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,17 +99,3 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
}
|
}
|
||||||
return json.Marshal(rec)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,17 +5,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"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/classification"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,40 +101,9 @@ type brainEditArgs struct {
|
|||||||
// hard the change is to reconstruct. Auditing every note edit would also bury
|
// 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
|
// the rare governance record under routine traffic, which is how an alert
|
||||||
// stops being read.
|
// stops being read.
|
||||||
func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
// Snapshot before editing: if the edited note cannot be stamped and
|
_, out, err := s.editWithScope(args, notes.ScopeNotes)
|
||||||
// verified, the edit is rolled back rather than left on disk unattributed
|
return out, err
|
||||||
// (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
|
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
|
||||||
@@ -214,20 +179,6 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.R
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
|
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)
|
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, notes.ErrDegenerate) && a.OldStr == "" {
|
if errors.Is(err, notes.ErrDegenerate) && a.OldStr == "" {
|
||||||
@@ -277,156 +228,3 @@ func touchesFrontmatter(oldStr, newStr string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,11 +66,8 @@ func TestBrainReadAndEdit_RoundTripThroughJSONRPC(t *testing.T) {
|
|||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/n.md"))
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/n.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
// Only the matched bytes change in the body; the one deliberate addition
|
assert.Equal(t, "---\ntitle: 'n'\n---\n\nalpha\nBETA\n", string(got),
|
||||||
// is the editor's provenance stamp (hyperguild#145 — no principal here,
|
"only the matched bytes may change")
|
||||||
// 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) {
|
func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) {
|
||||||
@@ -96,243 +93,6 @@ func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) {
|
|||||||
assert.Contains(t, errMsg, "raw arguments were", "%v", errMsg)
|
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) {
|
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
||||||
dir := newBrainRepo(t)
|
dir := newBrainRepo(t)
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
@@ -17,12 +16,6 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
|
|||||||
out := make([]wiki.Page, 0, len(rawPages))
|
out := make([]wiki.Page, 0, len(rawPages))
|
||||||
var warnings []string
|
var warnings []string
|
||||||
for _, rp := range rawPages {
|
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)
|
slug := computeSlug(rp, sourceSlug)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
|
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
|
||||||
@@ -33,8 +26,6 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
|
|||||||
return out, warnings
|
return out, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
|
||||||
|
|
||||||
func computeSlug(rp RawPage, sourceSlug string) string {
|
func computeSlug(rp RawPage, sourceSlug string) string {
|
||||||
if rp.Type == "source" {
|
if rp.Type == "source" {
|
||||||
return sourceSlug
|
return sourceSlug
|
||||||
@@ -123,9 +114,5 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func yamlScalar(s 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, "'", "''") + "'"
|
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,17 +89,13 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
meta.BodyHash = bodyHash
|
meta.BodyHash = bodyHash
|
||||||
applySourceMeta(allRaw, meta)
|
applySourceMeta(allRaw, meta)
|
||||||
|
|
||||||
return buildAndWrite(ctx, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
// 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
|
// step. Use this when the caller has already produced the structured RawPage data
|
||||||
// (e.g. from a more capable model or manual curation).
|
// (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)
|
inventory, err := wiki.LoadInventory(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("load inventory: %w", err)
|
return Result{}, fmt.Errorf("load inventory: %w", err)
|
||||||
@@ -109,11 +104,11 @@ func RunRaw(ctx context.Context, brainDir, source string, rawPages []RawPage, dr
|
|||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
return buildAndWrite(ctx, rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
|
return buildAndWrite(rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAndWrite runs BuildPages through write for both Run and RunRaw.
|
// buildAndWrite runs BuildPages through write for both Run and RunRaw.
|
||||||
func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) {
|
func buildAndWrite(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)
|
pages, buildWarnings := BuildPages(rawPages, sourceSlug, date)
|
||||||
warnings = append(warnings, buildWarnings...)
|
warnings = append(warnings, buildWarnings...)
|
||||||
resolved := Resolve(pages, inventory)
|
resolved := Resolve(pages, inventory)
|
||||||
@@ -129,25 +124,11 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
|
|||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
content, conflicts, err := reviseOrCreate(dest, page)
|
content, err := reviseOrCreate(dest, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
for _, heading := range conflicts {
|
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
||||||
// 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)
|
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,16 +211,15 @@ func storedSourceHash(brainDir, sourceSlug string) string {
|
|||||||
// its first-ingest date on a revised page. Left deliberately — the alternative
|
// 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
|
// is this function editing frontmatter it does not own, and no lint reads that
|
||||||
// field today.
|
// field today.
|
||||||
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
|
func reviseOrCreate(dest string, page wiki.Page) (string, error) {
|
||||||
existing, err := os.ReadFile(dest)
|
existing, err := os.ReadFile(dest)
|
||||||
switch {
|
switch {
|
||||||
case os.IsNotExist(err):
|
case os.IsNotExist(err):
|
||||||
return page.Content, nil, nil
|
return page.Content, nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
|
return "", fmt.Errorf("read existing %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
merged, conflicts := wiki.MergeReport(wiki.Page{Path: page.Path, Content: string(existing)}, page)
|
return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil
|
||||||
return merged.Content, conflicts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -196,7 +195,7 @@ A book about shaping product work.
|
|||||||
Read this before the next planning cycle.
|
Read this before the next planning cycle.
|
||||||
`), 0o644))
|
`), 0o644))
|
||||||
|
|
||||||
_, err := RunRaw(context.Background(), dir, "shape-up", []RawPage{{
|
_, err := RunRaw(dir, "shape-up", []RawPage{{
|
||||||
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
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",
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
||||||
}}, false)
|
}}, false)
|
||||||
@@ -221,12 +220,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",
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
_, err := RunRaw(context.Background(), dir, "shape-up", raw, false)
|
_, err := RunRaw(dir, "shape-up", raw, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = RunRaw(context.Background(), dir, "shape-up", raw, false)
|
_, err = RunRaw(dir, "shape-up", raw, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -368,10 +367,7 @@ func TestRun_RevisionEchoingExistingPageStaysByteIdentical(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
want := "---\ntitle: 'Shape Up'\nlast_updated: " + time.Now().UTC().Format("2006-01-02") +
|
want := "---\ntitle: 'Shape Up'\nlast_updated: " + time.Now().UTC().Format("2006-01-02") +
|
||||||
"\nsource_hash: " + hashBody(content) +
|
"\nsource_hash: " + hashBody(content) + "\n---\n\n" +
|
||||||
// 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" +
|
"## Summary\n\nA book about shaping product work.\n\n" +
|
||||||
"## Key Claims\n\n- Appetite beats estimation.\n"
|
"## Key Claims\n\n- Appetite beats estimation.\n"
|
||||||
assert.Equal(t, want, string(got),
|
assert.Equal(t, want, string(got),
|
||||||
@@ -395,34 +391,3 @@ func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, 1, calls, "only the body counts as the source")
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -49,11 +48,6 @@ type Result struct {
|
|||||||
// (infra#72). Read from frontmatter when present, otherwise
|
// (infra#72). Read from frontmatter when present, otherwise
|
||||||
// inferred from the parent directory.
|
// inferred from the parent directory.
|
||||||
Tier string `json:"tier,omitempty"`
|
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
|
// tierWeight maps the DIKW tier to a score multiplier applied right
|
||||||
@@ -152,7 +146,6 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
wing, hall := extractWingHall(string(content), rel)
|
wing, hall := extractWingHall(string(content), rel)
|
||||||
tier := extractTier(string(content), rel)
|
tier := extractTier(string(content), rel)
|
||||||
origin, writtenBy := extractProvenance(string(content))
|
|
||||||
results = append(results, Result{
|
results = append(results, Result{
|
||||||
Path: rel,
|
Path: rel,
|
||||||
Title: extractTitle(string(content), d.Name()),
|
Title: extractTitle(string(content), d.Name()),
|
||||||
@@ -161,9 +154,6 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
|
|||||||
Wing: wing,
|
Wing: wing,
|
||||||
Hall: hall,
|
Hall: hall,
|
||||||
Tier: tier,
|
Tier: tier,
|
||||||
|
|
||||||
Origin: origin,
|
|
||||||
WrittenBy: writtenBy,
|
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -282,7 +272,6 @@ func hydrate(brainDir, relPath string) (Result, error) {
|
|||||||
}
|
}
|
||||||
wing, hall := extractWingHall(string(content), relPath)
|
wing, hall := extractWingHall(string(content), relPath)
|
||||||
tier := extractTier(string(content), relPath)
|
tier := extractTier(string(content), relPath)
|
||||||
origin, writtenBy := extractProvenance(string(content))
|
|
||||||
return Result{
|
return Result{
|
||||||
Path: relPath,
|
Path: relPath,
|
||||||
Title: extractTitle(string(content), filepath.Base(relPath)),
|
Title: extractTitle(string(content), filepath.Base(relPath)),
|
||||||
@@ -290,44 +279,9 @@ func hydrate(brainDir, relPath string) (Result, error) {
|
|||||||
Wing: wing,
|
Wing: wing,
|
||||||
Hall: hall,
|
Hall: hall,
|
||||||
Tier: tier,
|
Tier: tier,
|
||||||
|
|
||||||
Origin: origin,
|
|
||||||
WrittenBy: writtenBy,
|
|
||||||
}, nil
|
}, 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
|
// resolveRoots returns the directories to walk for the given wing/hall
|
||||||
// filters. Validates hall against the closed vocabulary when set.
|
// filters. Validates hall against the closed vocabulary when set.
|
||||||
func resolveRoots(brainDir, wing, hall string) ([]string, error) {
|
func resolveRoots(brainDir, wing, hall string) ([]string, error) {
|
||||||
|
|||||||
@@ -59,16 +59,10 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip processed/, failed/, and rejected/ subdirectories entirely.
|
// Skip processed/ and failed/ 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() {
|
if d.IsDir() {
|
||||||
name := d.Name()
|
name := d.Name()
|
||||||
if name == "processed" || name == "failed" || name == "rejected" {
|
if name == "processed" || name == "failed" {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -176,20 +176,15 @@ func TestDeriveSource(t *testing.T) {
|
|||||||
func TestProcessDir_SkipsSubdirs(t *testing.T) {
|
func TestProcessDir_SkipsSubdirs(t *testing.T) {
|
||||||
brainDir := setupBrainDir(t)
|
brainDir := setupBrainDir(t)
|
||||||
|
|
||||||
// Create processed/, failed/, and rejected/ subdirs with files inside.
|
// Create processed/ and failed/ subdirs with files inside.
|
||||||
for _, sub := range []string{"processed/2026-04-22", "failed", "rejected"} {
|
for _, sub := range []string{"processed/2026-04-22", "failed"} {
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", sub), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", sub), 0o755))
|
||||||
}
|
}
|
||||||
|
|
||||||
processedFile := filepath.Join(brainDir, "raw", "processed", "2026-04-22", "old-file.md")
|
processedFile := filepath.Join(brainDir, "raw", "processed", "2026-04-22", "old-file.md")
|
||||||
failedFile := filepath.Join(brainDir, "raw", "failed", "broken-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(processedFile, []byte("old"), 0o644))
|
||||||
require.NoError(t, os.WriteFile(failedFile, []byte("broken"), 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.
|
// Also place a valid file in raw/ root that should be processed.
|
||||||
validFile := filepath.Join(brainDir, "raw", "valid.md")
|
validFile := filepath.Join(brainDir, "raw", "valid.md")
|
||||||
@@ -228,13 +223,11 @@ func TestProcessDir_SkipsSubdirs(t *testing.T) {
|
|||||||
// Complete should have been called exactly once (for valid.md, not for files in subdirs).
|
// 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")
|
assert.Len(t, processedSources, 1, "only the file in raw/ root should be processed")
|
||||||
|
|
||||||
// Files in processed/, failed/, and rejected/ must remain untouched.
|
// Files in processed/ and failed/ must remain untouched.
|
||||||
_, err := os.Stat(processedFile)
|
_, err := os.Stat(processedFile)
|
||||||
assert.NoError(t, err, "processed subdir file should be untouched")
|
assert.NoError(t, err, "processed subdir file should be untouched")
|
||||||
_, err = os.Stat(failedFile)
|
_, err = os.Stat(failedFile)
|
||||||
assert.NoError(t, err, "failed subdir file should be untouched")
|
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:
|
// TestProcessDir_SkipsTunnelCandidateFiles guards against hyperguild#88:
|
||||||
|
|||||||
@@ -37,87 +37,43 @@ type section struct {
|
|||||||
// Sections are merged by strategy: bullet sections union unique lines,
|
// Sections are merged by strategy: bullet sections union unique lines,
|
||||||
// append sections concatenate, all others keep a's version. Sections in b
|
// append sections concatenate, all others keep a's version. Sections in b
|
||||||
// not present in a are appended.
|
// 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 {
|
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)
|
fmA, preambleA, secsA := parseSections(a.Content)
|
||||||
fmB, _, secsB := parseSections(b.Content)
|
fmB, _, secsB := parseSections(b.Content)
|
||||||
|
|
||||||
var conflicts []string
|
// First occurrence wins on a collision — a page can already carry two
|
||||||
|
// on-disk sections that only differ by case or whitespace (the exact
|
||||||
// Sections in secsA sharing a normalized heading (pre-existing corpus
|
// corpus drift this normalization exists to catch going forward, found
|
||||||
// drift — "## Notes" and "## NOTES" both already on disk, the exact
|
// pre-existing in review of PR #130): a plain last-write assignment
|
||||||
// pattern this normalization exists to catch going forward, found in
|
// here would silently and deterministically route every future update
|
||||||
// review of PR #130/#132) are matched to b's occurrences of that same
|
// to whichever duplicate happens to sort last in the file, freezing
|
||||||
// heading POSITIONALLY: b's 1st occurrence of the key pairs with a's
|
// the other one forever with nothing to say so.
|
||||||
// 1st, b's 2nd with a's 2nd, and so on. A single "last write wins" (or
|
idx := make(map[string]int, len(secsA))
|
||||||
// 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 {
|
for i, s := range secsA {
|
||||||
key := normalizeHeading(s.heading)
|
key := normalizeHeading(s.heading)
|
||||||
byKey[key] = append(byKey[key], i)
|
if _, exists := idx[key]; !exists {
|
||||||
|
idx[key] = i
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cursor := make(map[string]int, len(byKey))
|
|
||||||
|
|
||||||
for _, sB := range secsB {
|
for _, sB := range secsB {
|
||||||
key := normalizeHeading(sB.heading)
|
key := normalizeHeading(sB.heading)
|
||||||
indices := byKey[key]
|
i, exists := idx[key]
|
||||||
pos := cursor[key]
|
if !exists {
|
||||||
cursor[key] = pos + 1
|
idx[key] = len(secsA)
|
||||||
|
|
||||||
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)
|
secsA = append(secsA, sB)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
i := indices[pos]
|
|
||||||
sA := secsA[i]
|
sA := secsA[i]
|
||||||
switch {
|
switch {
|
||||||
case bulletSectionsNorm[key]:
|
case bulletSectionsNorm[key]:
|
||||||
secsA[i].content = mergeBullets(sA.content, sB.content)
|
secsA[i].content = mergeBullets(sA.content, sB.content)
|
||||||
case appendSectionsNorm[key]:
|
case appendSectionsNorm[key]:
|
||||||
secsA[i].content = appendNew(sA.content, sB.content)
|
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)}, conflicts
|
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeHeading returns the key Merge uses to decide "is this the same
|
// normalizeHeading returns the key Merge uses to decide "is this the same
|
||||||
@@ -384,21 +340,6 @@ func canonicalLine(line string) string {
|
|||||||
return strings.Join(strings.Fields(line), " ")
|
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.
|
// appendNew concatenates the lines of b that a does not already carry.
|
||||||
//
|
//
|
||||||
// Append sections used to concatenate wholesale, which is correct exactly once.
|
// Append sections used to concatenate wholesale, which is correct exactly once.
|
||||||
|
|||||||
@@ -367,36 +367,6 @@ func TestMerge_PreExistingCaseDuplicateHeadingsRouteToTheFirstOccurrence(t *test
|
|||||||
"the second duplicate is untouched by this update, not merged into or overwritten")
|
"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) {
|
func TestRefreshOwned_CommentLinesSurviveUnmangled(t *testing.T) {
|
||||||
// Third review round on PR #126: parseFrontmatterBlock dropped the
|
// Third review round on PR #126: parseFrontmatterBlock dropped the
|
||||||
// sibling api package's comment guard (strings.HasPrefix(trimmed, "#")).
|
// sibling api package's comment guard (strings.HasPrefix(trimmed, "#")).
|
||||||
@@ -424,85 +394,3 @@ func TestRefreshOwned_IndentedListItemsWithColonsAreNotMisparsedAsKeys(t *testin
|
|||||||
got := refreshOwned(a, b)
|
got := refreshOwned(a, b)
|
||||||
assert.Equal(t, "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,10 +42,6 @@ func Query(ctx context.Context, baseURL, query string, limit int) (string, error
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
// The REST API requires a bearer token since hyperguild#149.
|
|
||||||
if tok := os.Getenv("BRAIN_MCP_TOKEN"); tok != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+tok)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,18 +65,3 @@ func TestQueryUnavailableServerReturnsEmpty(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, result)
|
assert.Empty(t, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hyperguild#149: the REST API requires a bearer token.
|
|
||||||
func TestQuerySendsBrainMCPToken(t *testing.T) {
|
|
||||||
var auth string
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
auth = r.Header.Get("Authorization")
|
|
||||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
t.Setenv("BRAIN_MCP_TOKEN", "lib-test-token")
|
|
||||||
_, _ = brain.Query(context.Background(), srv.URL, "x", 1)
|
|
||||||
if auth != "Bearer lib-test-token" {
|
|
||||||
t.Fatalf("Authorization = %q, want the bearer token", auth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ if [ "$FORCE" = false ]; then
|
|||||||
while IFS= read -r _match; do
|
while IFS= read -r _match; do
|
||||||
_lineno="${_match%%:*}"
|
_lineno="${_match%%:*}"
|
||||||
_content="${_match#*:}"
|
_content="${_match#*:}"
|
||||||
_token=$(head -1 < <(grep -o '{{[^}]*}}' <<<"$_content"))
|
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||||
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||||
done <<< "$_placeholders"
|
done <<< "$_placeholders"
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user