Compare commits

...
11 Commits
Author SHA1 Message Date
mathias dc127a05b5 Merge pull request 'fix(tools): reject case-variant arg keys and ref-shaped branch names (#65)' (#66) from fix/65-strict-args-branch-names into main
CD / Lint / Test / Vet (push) Successful in 14s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Skipped
2026-10-05 05:10:51 +00:00
mathiasandClaude Opus 5.5 cd61239451 perf(tools): linear duplicate-key scan keyed on SimpleFold orbit
PR #66 review round 2: the pairwise EqualFold loop was quadratic, 7s of
CPU for ~90k keys under the 1 MiB body cap. foldKey maps each rune to
the smallest member of its unicode.SimpleFold orbit, equal exactly when
strings.EqualFold is, so the scan is a map lookup again. The scaling
test fails on the previous code (7.1s).

Refs #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:05:00 +02:00
mathiasandClaude Opus 5.5 31a3dd3e0e fix(tools): fail closed when the key scan cannot read the input
PR #66 review: json.Decoder.Token() fails on a number outside float64
range (1e999), which json.Unmarshal ignores in an unknown key, so a
decoy key ended the scan and the #65 repro wrote to main. The decoder
now uses UseNumber, and any scan error is a validation error. The
duplicate check folds with strings.EqualFold (as encoding/json does)
instead of ToLower, so long s and the Kelvin sign are caught in nested
objects too.

Refs #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:02:27 +02:00
mathiasandClaude Opus 5.5 ae50501ba3 fix(tools): reject case-variant arg keys and ref-shaped branch names
parseArgs now refuses, before any request: two keys equal under
strings.EqualFold in any object (exact duplicates included), and a
top-level key that differs from a schema field only in case. Without
this, encoding/json's case-insensitive last-wins match let
{"branch":"feat/x","Branch":"main"} write to main while a client-side
hook read feat/x.

file_write_branch (branch) and tbd_ship (branch and base) validate the
branch name: ^[A-Za-z0-9][A-Za-z0-9._/-]*$ plus no refs/ prefix, HEAD,
"..", "//", "/.", trailing "/" or ".", or ".lock". refs/heads/main
otherwise created refs/heads/refs/heads/main and wedged API writes to
main. tbd_ship's base is the protection-probe key, so a refs/-prefixed
base would also have read as unprotected.

Closes #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:58:31 +02:00
mathiasandClaude Opus 5.5 9396b4daaa chore(context): resync derived adapters from canonical AGENT.md
CD / Lint / Test / Vet (push) Successful in 28s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 6s
Clears ContextAdapterDrift; canonical changed in local-dev 12f694b/218cf0d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:49:57 +02:00
mathiasandClaude Opus 5.5 0e2bd3d90e fix(mcp): audit record keeps legacy name/index alias targets
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 15s
CD / Build & Import (push) Successful in 7s
A caller using the #38 aliases (name for repo, index for number) left no
target in the tools/call audit record.

Refs #64

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:41:51 +02:00
mathiasandClaude Opus 5.5 b149dccdbe chore(context): re-sync adapters after root brain-scoping correction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:41:47 +02:00
mathiasandClaude Opus 5.5 724a997002 feat(mcp): audit-log every tools/call (OWASP MCP08)
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 31s
CD / Build & Import (push) Successful in 14s
tools/call dispatched with no record, so repo_delete, pr_merge and
file_delete left no trail in gitea-mcp; only auth acceptance was logged.

One slog record per call: tool, caller, token fingerprint, target
(owner/repo/number/branch/path/tag), outcome, duration. Every other
argument is reduced to args_bytes so the audit line never copies file
content or issue bodies.

Closes #64

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:34:59 +02:00
mathiasandClaude Opus 5 59dabc9455 chore(context): adopt canonical context-sync.sh and re-sync adapters
CD / Lint / Test / Vet (push) Successful in 13s
CD / Build & Import (push) Successful in 17s
CD / Deploy via GitOps (push) Successful in 5s
infra's scripts/context-sync.sh is the estate canonical, and it moved in infra
262369b (a SIGPIPE fix on the placeholder-reporting path: `printf | grep -o |
head -1` became `head -1 < <(grep -o ...)`). Every other repo still carried the
old line, so ContextSyncScriptDrift and ContextAdapterDrift were both firing.

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

Refs mathias/infra#470

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 10:36:32 +02:00
mathiasandClaude Opus 5 6b57a8ba8a chore(context): regenerate derived adapters from the canonical root context
CD / Lint / Test / Vet (push) Successful in 41s
CD / Build & Import (push) Successful in 22s
CD / Deploy via GitOps (push) Successful in 10s
ContextAdapterDrift was firing for 13 repos. Picks up behaviour rule 7
("Finish what's closable"), which landed in ~/dev/.context/AGENT.md and never
reached any repo's derived files.

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

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

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

Refs mathias/infra ContextAdapterDrift

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 07:34:24 +02:00
mathiasandClaude Opus 5 e1e9a2e30e feat(auth): every accepted request names the path that granted it
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 16s
CD / Build & Import (push) Successful in 16s
Closes #60. Closes #58 (fixed in mcp-chassis v0.7.0, consumed here).

Three auth paths reach this server and none of them said so on success.

PassthroughMiddleware logged nothing at all, success or failure. While verifying
#59 end to end there was no way to confirm from the logs whether calls were
using the new PAT pass-through — the caller's own Gitea PAT, the caller's own
identity — or falling through to the shared JWT/static path. The only available
reasoning was inference from which failures were absent.

    INFO mcp auth accepted auth_path=passthrough caller=alice token_fp=...
    INFO mcp auth accepted auth_path=jwt        caller=mathiasbq token_fp=...
    INFO mcp auth accepted auth_path=static     token_fp=...

The first line matters more than the other two. Pass-through is the path where a
request acts as a SPECIFIC Gitea user; an audit trail that cannot say which
identity performed a write is not an audit trail.

chassisauth.Fingerprint, not a local equivalent. The chassis emits token_fp on
its own accepted and rejected lines, and two different fingerprints for the same
token cannot be correlated — which is the only reason the field exists. Exported
from the chassis in v0.7.1 for exactly this.

A token that is not a valid PAT still falls through SILENTLY. The chassis owns
the verdict, and logging here as well would double-count every static-token
request, since all of them reach this middleware first. Asserted, because "logs
nothing on the failure path" is indistinguishable from "was never called".

Also lands #58 by consuming mcp-chassis v0.7.0: a JWT presented to a server with
no JWT auth configured now reports jwt_auth_not_configured rather than
static_token_mismatch. That log line is what sent a debugging session into
re-running the OAuth flow while the credentials were valid the whole time.

Verified: task check EXIT=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:59:04 +02:00
15 changed files with 775 additions and 12 deletions
+80 -4
View File
@@ -78,6 +78,27 @@ These rules apply to every task across every project, regardless of harness.
when a human reviewer outside the project is required. Document the reason in 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
@@ -106,6 +127,43 @@ These rules apply to every task across every project, regardless of harness.
revisit Y". Those are exactly the items that need a locator. If it is worth 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`):
@@ -246,10 +304,16 @@ 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)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` | | **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":"..."}` |
| **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**: defaults to `public` collection; client projects filter to `{client}` + `public`. - **Scoping — the brain holds no confidential client content.** Nothing on the read path
filters by client: every token holder can read every wing, and `wing` on `brain_query`
is an optional argument, not an access control. Client confidentiality is enforced only
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
decided 2026-10-01. This line previously claimed client projects were filtered; they
were not.)
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as - **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
@@ -319,8 +383,20 @@ 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 Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
unconditionally on every host, every harness. host and every harness.
**Where a project rule contradicts a rule here, the project rule governs — and it
must state its reason.** Both halves matter: the project file wins because it knows
something this file cannot (infra's Flux constraint is real and not visible from
here), and it must say why because a bare override is indistinguishable from drift.
This replaces the previous wording, which said rules here and in `PROJECT.md` both
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
at the same time (infra#470). A standard can hold two individually sensible clauses
that are jointly unsatisfiable, and survive for as long as everyone reads it
charitably — the same failure the Inception Sprint Oath's G6 had.
## Engineering Skills ## Engineering Skills
+80 -4
View File
@@ -73,6 +73,27 @@ These rules apply to every task across every project, regardless of harness.
when a human reviewer outside the project is required. Document the reason in 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
@@ -101,6 +122,43 @@ These rules apply to every task across every project, regardless of harness.
revisit Y". Those are exactly the items that need a locator. If it is worth 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`):
@@ -241,10 +299,16 @@ 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)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` | | **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":"..."}` |
| **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**: defaults to `public` collection; client projects filter to `{client}` + `public`. - **Scoping — the brain holds no confidential client content.** Nothing on the read path
filters by client: every token holder can read every wing, and `wing` on `brain_query`
is an optional argument, not an access control. Client confidentiality is enforced only
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
decided 2026-10-01. This line previously claimed client projects were filtered; they
were not.)
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as - **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
@@ -314,8 +378,20 @@ 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 Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
unconditionally on every host, every harness. host and every harness.
**Where a project rule contradicts a rule here, the project rule governs — and it
must state its reason.** Both halves matter: the project file wins because it knows
something this file cannot (infra's Flux constraint is real and not visible from
here), and it must say why because a bare override is indistinguishable from drift.
This replaces the previous wording, which said rules here and in `PROJECT.md` both
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
at the same time (infra#470). A standard can hold two individually sensible clauses
that are jointly unsatisfiable, and survive for as long as everyone reads it
charitably — the same failure the Inception Sprint Oath's G6 had.
## Engineering Skills ## Engineering Skills
+1 -1
View File
@@ -3,7 +3,7 @@ module git.d-ma.be/mathias/gitea-mcp
go 1.26.2 go 1.26.2
require ( require (
git.d-ma.be/mathias/mcp-chassis v0.6.0 git.d-ma.be/mathias/mcp-chassis v0.7.1
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )
+4
View File
@@ -2,6 +2,10 @@ git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bT
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.6.0 h1:/MuxT9IyzZbN26+COlI2FK0moTyXMLG/qXc/cm0i0Vg= git.d-ma.be/mathias/mcp-chassis v0.6.0 h1:/MuxT9IyzZbN26+COlI2FK0moTyXMLG/qXc/cm0i0Vg=
git.d-ma.be/mathias/mcp-chassis v0.6.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= git.d-ma.be/mathias/mcp-chassis v0.6.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.7.0 h1:ct31v1F1PMhMCva/V0ErEbgMzwnT+27lYKbug2A/quE=
git.d-ma.be/mathias/mcp-chassis v0.7.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.7.1 h1:4h8TOBHTA0L/no4IPcr4tLcQtmKUlhE2/cXyPdNFGmM=
git.d-ma.be/mathias/mcp-chassis v0.7.1/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+26
View File
@@ -2,9 +2,12 @@ package auth
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"strings" "strings"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/gitea"
) )
@@ -25,11 +28,34 @@ func PassthroughMiddleware(validator TokenValidator, onValid, fallback http.Hand
token, hasBearer := strings.CutPrefix(authz, "Bearer ") token, hasBearer := strings.CutPrefix(authz, "Bearer ")
if hasBearer && token != "" { if hasBearer && token != "" {
if login, ok := validator.ValidateToken(r.Context(), token); ok { if login, ok := validator.ValidateToken(r.Context(), token); ok {
// gitea-mcp#60. This middleware logged nothing at all, so there
// was no way to tell a PAT pass-through request from one that
// fell through to the shared JWT/static path — only inference
// from which failures were absent.
//
// It matters more here than on the other two paths: this is
// where a request acts as a SPECIFIC Gitea user. An audit trail
// that cannot say which identity performed a write is not an
// audit trail.
//
// chassisauth.Fingerprint, not a local equivalent: the chassis
// emits the same field on its own accepted/rejected lines, and
// two different fingerprints for one token cannot be correlated.
slog.Info("mcp auth accepted",
"auth_path", "passthrough",
"caller", login,
"token_fp", chassisauth.Fingerprint(token),
"path", r.URL.Path,
)
ctx := withCaller(r.Context(), login) ctx := withCaller(r.Context(), login)
ctx = gitea.WithToken(ctx, token) ctx = gitea.WithToken(ctx, token)
onValid.ServeHTTP(w, r.WithContext(ctx)) onValid.ServeHTTP(w, r.WithContext(ctx))
return return
} }
// A token that is not a valid PAT falls through SILENTLY. The
// chassis owns the verdict; logging here too would double-count
// every static-token request, since all of them reach this
// middleware first.
} }
fallback.ServeHTTP(w, r) fallback.ServeHTTP(w, r)
}) })
+83
View File
@@ -0,0 +1,83 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type stubValidator struct{ login string }
func (s stubValidator) ValidateToken(context.Context, string) (string, bool) {
return s.login, s.login != ""
}
// gitea-mcp#60: PassthroughMiddleware logged nothing at all, success or failure.
//
// While verifying #59 end to end there was no way to confirm from the server
// logs whether calls were using the new PAT pass-through (caller's own Gitea
// PAT, own identity) or falling through to the shared JWT/static path. The only
// way to reason about it was inference. The chassis names its two paths as of
// v0.7.0; this is the third.
//
// This matters more than the other two: pass-through is the path where the
// request acts as a SPECIFIC Gitea user. An audit trail that cannot say which
// identity performed a write is not an audit trail.
func TestPassthroughMiddleware_LogsTheAcceptedPath(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
reached := false
h := PassthroughMiddleware(stubValidator{login: "alice"},
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }),
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not fall back") }))
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
req.Header.Set("Authorization", "Bearer a-real-gitea-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
require.True(t, reached)
var line map[string]any
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &line))
require.Equal(t, "mcp auth accepted", line["msg"])
require.Equal(t, "passthrough", line["auth_path"])
require.Equal(t, "alice", line["caller"])
require.NotEmpty(t, line["token_fp"], "correlatable without being a credential")
for k, v := range line {
require.NotContains(t, []any{v}, "a-real-gitea-pat",
"the PAT must never appear in any field, including %q", k)
}
}
// A token that is not a valid PAT must fall through silently to the chassis,
// which owns the rejection log. Logging here as well would double-count every
// static-token request — every one of them reaches this middleware first.
func TestPassthroughMiddleware_FallsThroughWithoutLogging(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
fellBack := false
h := PassthroughMiddleware(stubValidator{},
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("must not accept") }),
http.HandlerFunc(func(http.ResponseWriter, *http.Request) { fellBack = true }))
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
req.Header.Set("Authorization", "Bearer not-a-pat")
h.ServeHTTP(httptest.NewRecorder(), req)
require.True(t, fellBack)
require.Empty(t, strings.TrimSpace(buf.String()),
"the chassis owns the verdict; logging here too would double-count every static-token call")
}
+46
View File
@@ -0,0 +1,46 @@
package mcp
import (
"context"
"encoding/json"
"log/slog"
"time"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
// auditTargetKeys are the only argument values copied into the audit record:
// they identify what a call acted on without copying what it wrote. Every
// other argument (file content, issue bodies) is reduced to args_bytes so the
// audit trail never becomes a second copy of repo content (gitea-mcp#64).
var auditTargetKeys = []string{"owner", "repo", "name", "number", "index", "branch", "path", "tag"}
// logToolCall emits one structured record per tools/call (OWASP MCP08).
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
attrs := []any{
"tool", name,
"caller", auth.Caller(ctx),
"args_bytes", len(args),
"duration_ms", time.Since(start).Milliseconds(),
}
if tok, ok := gitea.TokenFromContext(ctx); ok {
attrs = append(attrs, "token_fp", chassisauth.Fingerprint(tok))
}
var parsed map[string]any
if json.Unmarshal(args, &parsed) == nil {
for _, k := range auditTargetKeys {
if v, ok := parsed[k]; ok {
attrs = append(attrs, k, v)
}
}
}
outcome := "ok"
if err != nil {
outcome = err.Error()
}
attrs = append(attrs, "outcome", outcome)
slog.InfoContext(ctx, "mcp tool call", attrs...)
}
+135
View File
@@ -0,0 +1,135 @@
package mcp_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/auth"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type auditStubTool struct {
name string
err error
}
func (s auditStubTool) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{Name: s.name, InputSchema: json.RawMessage(`{"type":"object"}`)}
}
func (s auditStubTool) Call(context.Context, json.RawMessage) (json.RawMessage, error) {
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"ok":true}`), nil
}
// captureAudit routes slog to a buffer and returns the decoded
// "mcp tool call" records emitted while fn runs.
func captureAudit(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") {
if l == "" {
continue
}
var rec map[string]any
require.NoError(t, json.Unmarshal([]byte(l), &rec))
if rec["msg"] == "mcp tool call" {
out = append(out, rec)
}
}
return out
}
func auditServer(tools ...registry.Tool) http.Handler {
reg := registry.New()
for _, tl := range tools {
reg.Register(tl)
}
srv := mcp.NewServer(mcp.ServerOptions{Registry: reg})
return auth.CallerMiddleware(slog.Default(), srv)
}
func callTool(t *testing.T, h http.Handler, name string, args any) {
t.Helper()
body := map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
}
b, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth-Request-User", "alice")
h.ServeHTTP(httptest.NewRecorder(), req)
}
// gitea-mcp#64: tools/call dispatched with no record at all.
func TestToolsCallLogsOneAuditRecordWithToolCallerAndTarget(t *testing.T) {
recs := captureAudit(t, func() {
callTool(t, auditServer(auditStubTool{name: "repo_delete"}), "repo_delete",
map[string]any{"owner": "mathias", "repo": "scratch", "confirm": "scratch"})
})
require.Len(t, recs, 1)
r := recs[0]
assert.Equal(t, "repo_delete", r["tool"])
assert.Equal(t, "alice", r["caller"])
assert.Equal(t, "mathias", r["owner"])
assert.Equal(t, "scratch", r["repo"])
assert.Equal(t, "ok", r["outcome"])
assert.Contains(t, r, "duration_ms")
}
func TestToolsCallAuditRecordsErrorOutcome(t *testing.T) {
recs := captureAudit(t, func() {
callTool(t, auditServer(auditStubTool{name: "pr_merge", err: errors.New("boom")}),
"pr_merge", map[string]any{"owner": "mathias", "repo": "x", "number": 3})
})
require.Len(t, recs, 1)
assert.Equal(t, "boom", recs[0]["outcome"])
assert.EqualValues(t, 3, recs[0]["number"])
}
// The audit line must not become a second copy of repo content.
func TestToolsCallAuditOmitsArgumentValuesOtherThanTarget(t *testing.T) {
const sentinel = "SENTINEL-FILE-CONTENT-7f3a"
recs := captureAudit(t, func() {
callTool(t, auditServer(auditStubTool{name: "file_write_branch"}), "file_write_branch",
map[string]any{"owner": "mathias", "repo": "x", "path": "a.txt", "content": sentinel})
})
require.Len(t, recs, 1)
raw, err := json.Marshal(recs[0])
require.NoError(t, err)
assert.NotContains(t, string(raw), sentinel)
assert.Contains(t, recs[0], "args_bytes")
}
// #38 keeps `name` (for repo) and `index` (for number) as legacy aliases; a
// caller using them must still leave a target in the audit record.
func TestToolsCallAuditRecordsLegacyAliasTargets(t *testing.T) {
recs := captureAudit(t, func() {
callTool(t, auditServer(auditStubTool{name: "issue_close"}), "issue_close",
map[string]any{"owner": "mathias", "name": "infra", "index": 7})
})
require.Len(t, recs, 1)
assert.Equal(t, "infra", recs[0]["name"])
assert.EqualValues(t, 7, recs[0]["index"])
}
+3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"time"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "git.d-ma.be/mathias/gitea-mcp/internal/registry"
) )
@@ -90,7 +91,9 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
NewErrorResponse(req.ID, -32602, "invalid params", nil)) NewErrorResponse(req.ID, -32602, "invalid params", nil))
return return
} }
start := time.Now()
out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments) out, err := s.opts.Registry.Dispatch(r.Context(), p.Name, p.Arguments)
logToolCall(r.Context(), p.Name, p.Arguments, start, err)
if err != nil { if err != nil {
code := -32000 code := -32000
if errors.Is(err, registry.ErrToolNotFound) { if errors.Is(err, registry.ErrToolNotFound) {
+3
View File
@@ -63,6 +63,9 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
if args.Branch == "" { if args.Branch == "" {
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation) return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
} }
if err := ValidateBranchName(args.Branch); err != nil {
return nil, err
}
if args.Message == "" { if args.Message == "" {
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation) return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
} }
+42
View File
@@ -0,0 +1,42 @@
package tools
import (
"fmt"
"strings"
"testing"
"time"
)
// foldKey must agree with strings.EqualFold, which is how encoding/json matches.
func TestFoldKeyAgreesWithEqualFold(t *testing.T) {
pairs := [][2]string{{"branch", "Branch"}, {"sha", "ſha"}, {"k", "K"}, {"ss", "ß"}, {"a", "b"}, {"Σ", "ς"}}
for _, p := range pairs {
if got, want := foldKey(p[0]) == foldKey(p[1]), strings.EqualFold(p[0], p[1]); got != want {
t.Errorf("%q vs %q: foldKey equal=%v, EqualFold=%v", p[0], p[1], got, want)
}
}
}
// The duplicate scan is linear in the number of keys: 90k keys (about the
// 1 MiB body cap) must scan well under a second.
func TestRejectAmbiguousKeysScalesLinearly(t *testing.T) {
var b strings.Builder
b.WriteString("{")
for i := 0; i < 90_000; i++ {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `"k%d":1`, i)
}
b.WriteString("}")
var dst struct {
Owner string `json:"owner"`
}
start := time.Now()
if err := rejectAmbiguousKeys([]byte(b.String()), &dst); err != nil {
t.Fatal(err)
}
if d := time.Since(start); d > 2*time.Second {
t.Errorf("scan of 90k keys took %v", d)
}
}
+116
View File
@@ -0,0 +1,116 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// countingServer fails nothing by itself; it records how many requests reached
// it so a test can assert that a rejected call never touched Gitea.
func countingServer(t *testing.T) (*httptest.Server, *atomic.Int32) {
t.Helper()
var n atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n.Add(1)
w.WriteHeader(http.StatusTeapot)
}))
t.Cleanup(srv.Close)
return srv, &n
}
// #65 (1): encoding/json matches keys case-insensitively and the last one wins,
// so a client-side check reading `branch` could see a different value from the
// one the server acts on. Any two keys equal under strings.EqualFold, and any
// key that differs from a schema field only in case, are rejected before a
// request is made.
func TestParseArgsRejectsCaseVariantKeys(t *testing.T) {
cases := map[string]string{
"issue repro: branch + Branch": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","branch":"feat/y8","Branch":"main"}`,
"exact duplicate key": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","branch":"main"}`,
"case-only schema mismatch": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","BRANCH":"main"}`,
"nested object duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"a":1,"A":2}}`,
// PR #66 review: a number the token scanner cannot parse as float64 must
// not make the scan give up and let the duplicate through.
"1e999 decoy before duplicate": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","z":1e999,"branch":"feat/y8","Branch":"main"}`,
"-1e309 decoy before exact dup": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","z":-1e309,"branch":"feat/y8","branch":"main"}`,
// encoding/json folds like strings.EqualFold: long s (U+017F) == s.
"nested long-s duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"s":1,"\u017f":2}}`,
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
srv, n := countingServer(t)
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
_, err := tool.Call(context.Background(), json.RawMessage(raw))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Zero(t, n.Load(), "no request may reach gitea")
})
}
}
// #65 (2): `refs/heads/main` as a branch makes Gitea create
// refs/heads/refs/heads/main, after which every API write to main 500s.
// Branch names that can create such refs are rejected before any request.
func TestBranchNameValidationRejectsBeforeRequest(t *testing.T) {
bad := []string{
"refs/heads/main", "refs/tags/v1", "a..b", "feat x", "feat\tx", "feat/",
"/feat", "-feat", ".feat", "feat//x", "feat/.x", "feat.lock", "feat.", "HEAD", "feat@{1}", "",
}
for _, b := range bad {
t.Run("file_write_branch "+b, func(t *testing.T) {
srv, n := countingServer(t)
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
args, _ := json.Marshal(map[string]string{
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
})
_, err := tool.Call(context.Background(), args)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Zero(t, n.Load(), "no request may reach gitea")
})
if b == "" {
continue // tbd_ship derives a branch when empty
}
t.Run("tbd_ship "+b, func(t *testing.T) {
srv, n := countingServer(t)
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
args, _ := json.Marshal(map[string]string{
"owner": "probe", "repo": "t", "path": "p", "content": "x", "message": "m", "branch": b,
})
_, err := tool.Call(context.Background(), args)
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Zero(t, n.Load(), "no request may reach gitea")
})
}
}
// tbd_ship's base is the branch-protection probe key: `refs/heads/main` would
// read as unprotected, so it is held to the same rule as the head branch.
func TestTBDShipRejectsRefsPrefixedBase(t *testing.T) {
srv, n := countingServer(t)
tool := tools.NewTBDShip(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"probe"}))
_, err := tool.Call(context.Background(), json.RawMessage(
`{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","base":"refs/heads/main"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
assert.Zero(t, n.Load(), "no request may reach gitea")
}
func TestBranchNameValidationAcceptsOrdinaryNames(t *testing.T) {
for _, b := range []string{"main", "feat/y8", "tbd/fix-abc-1234abcd", "hold/mail-chat-infra-21", "v1.2_x"} {
t.Run(b, func(t *testing.T) {
assert.NoError(t, tools.ValidateBranchName(b))
})
}
}
+7
View File
@@ -160,6 +160,13 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if branch == "" { if branch == "" {
branch = deriveBranch(args.Message, args.Path, args.Content) branch = deriveBranch(args.Message, args.Path, args.Content)
} }
// Both names reach a ref: base is also the protection-probe key, so a
// `refs/heads/main` base would read as unprotected (#65).
for _, b := range []string{base, branch} {
if err := ValidateBranchName(b); err != nil {
return nil, err
}
}
deleteBranch := true deleteBranch := true
if args.DeleteBranch != nil { if args.DeleteBranch != nil {
deleteBranch = *args.DeleteBranch deleteBranch = *args.DeleteBranch
+148 -2
View File
@@ -1,10 +1,14 @@
package tools package tools
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"reflect" "reflect"
"regexp"
"strings" "strings"
"unicode"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"git.d-ma.be/mathias/gitea-mcp/internal/registry" "git.d-ma.be/mathias/gitea-mcp/internal/registry"
@@ -22,12 +26,154 @@ func parseArgs(raw json.RawMessage, dst any) error {
if err := json.Unmarshal([]byte("{}"), dst); err != nil { if err := json.Unmarshal([]byte("{}"), dst); err != nil {
return err return err
} }
} else if err := json.Unmarshal(normalizeAliases(raw), dst); err != nil { } else {
return err // Reject ambiguity BEFORE alias normalisation: re-marshalling through a
// map would otherwise silently collapse exact duplicates (#65).
if err := rejectAmbiguousKeys(raw, dst); err != nil {
return err
}
if err := json.Unmarshal(normalizeAliases(raw), dst); err != nil {
return err
}
} }
return validateRequiredIdentifiers(dst) return validateRequiredIdentifiers(dst)
} }
// rejectAmbiguousKeys refuses input whose meaning depends on encoding/json's
// case-insensitive, last-key-wins matching (#65). A client-side check that reads
// `branch` must see the value the server acts on, so: no object at any depth may
// carry two keys equal under strings.EqualFold (exact duplicates included), and
// no top-level key may differ from a field of dst only in case.
func rejectAmbiguousKeys(raw json.RawMessage, dst any) error {
dec := json.NewDecoder(bytes.NewReader(raw))
// UseNumber: without it Token() fails on a number outside float64 range,
// which json.Unmarshal ignores in an unknown key — a decoy that would end
// the scan before the duplicate (PR #66 review).
dec.UseNumber()
if err := scanJSONValue(dec); err != nil {
if errors.Is(err, gitea.ErrValidation) {
return err
}
// Fail closed: input the scanner cannot read is input it cannot vouch for.
return fmt.Errorf("arguments are not valid JSON: %v: %w", err, gitea.ErrValidation)
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return nil // not an object; nothing to compare against the schema
}
fields := jsonFieldNames(dst)
for k := range top {
for _, f := range fields {
if k != f && strings.EqualFold(k, f) {
return fmt.Errorf("argument %q does not match field %q exactly (keys are case-sensitive): %w", k, f, gitea.ErrValidation)
}
}
}
return nil
}
// scanJSONValue consumes one JSON value from dec, failing with ErrValidation if
// any object within it has two keys that are equal under strings.EqualFold.
func scanJSONValue(dec *json.Decoder) error {
tok, err := dec.Token()
if err != nil {
return err
}
d, ok := tok.(json.Delim)
if !ok {
return nil
}
switch d {
case '{':
seen := map[string]string{}
for dec.More() {
kt, err := dec.Token()
if err != nil {
return err
}
k, _ := kt.(string)
fk := foldKey(k)
if prev, dup := seen[fk]; dup {
return fmt.Errorf("duplicate argument keys %q and %q (keys must be unique ignoring case): %w", prev, k, gitea.ErrValidation)
}
seen[fk] = k
if err := scanJSONValue(dec); err != nil {
return err
}
}
case '[':
for dec.More() {
if err := scanJSONValue(dec); err != nil {
return err
}
}
}
_, err = dec.Token() // closing delimiter
return err
}
// foldKey maps every rune to the smallest member of its unicode.SimpleFold
// orbit, so foldKey(a) == foldKey(b) exactly when strings.EqualFold(a, b) —
// the folding encoding/json uses (long s == s, Kelvin sign == k). A map keyed
// on it keeps the duplicate scan linear (PR #66 review: a pairwise EqualFold
// loop cost 7s of CPU on ~90k keys).
func foldKey(s string) string {
var b strings.Builder
for _, r := range s {
min := r
for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) {
if f < min {
min = f
}
}
b.WriteRune(min)
}
return b.String()
}
// jsonFieldNames returns the json tag names of dst's top-level struct fields.
func jsonFieldNames(dst any) []string {
v := reflect.ValueOf(dst)
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct {
return nil
}
t := v.Elem().Type()
names := make([]string, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
if name == "" || name == "-" {
name = t.Field(i).Name
}
names = append(names, name)
}
return names
}
// branchNameRe is the shape the skills mcp-destructive-guard hook enforces.
var branchNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
// ValidateBranchName rejects branch names that Gitea would turn into a ref
// other than refs/heads/<name> as the caller meant it. `refs/heads/main` in
// particular creates refs/heads/refs/heads/main, after which every API write to
// main fails with "matches more than one" until the ref is deleted by hand
// (#65). Beyond the shape regexp it applies git's own check-ref-format rules
// that the regexp admits.
func ValidateBranchName(b string) error {
bad := !branchNameRe.MatchString(b) ||
strings.HasPrefix(b, "refs/") ||
b == "HEAD" ||
strings.Contains(b, "..") ||
strings.Contains(b, "//") ||
strings.Contains(b, "/.") ||
strings.HasSuffix(b, "/") ||
strings.HasSuffix(b, ".") ||
strings.HasSuffix(b, ".lock")
if bad {
return fmt.Errorf("invalid branch name %q (want a short name like feat/x, no refs/ prefix): %w", b, gitea.ErrValidation)
}
return nil
}
// validateRequiredIdentifiers rejects an empty repo identifier at the tool layer // validateRequiredIdentifiers rejects an empty repo identifier at the tool layer
// before it reaches the client, where it would build a trailing-empty path // before it reaches the client, where it would build a trailing-empty path
// segment (`/api/v1/repos/{owner}/`) and leak gitea's bare 404 (#37). `repo` and // segment (`/api/v1/repos/{owner}/`) and leak gitea's bare 404 (#37). `repo` and
+1 -1
View File
@@ -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=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1) _token=$(head -1 < <(grep -o '{{[^}]*}}' <<<"$_content"))
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2 echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
done <<< "$_placeholders" done <<< "$_placeholders"
echo "" >&2 echo "" >&2