~bigbes/sr-ht-spec

ref: e97532ccd54a6aacc597e9db1648e580e2936132 sr-ht-spec/mcpsrv d---------
a0fa81b3 — Eugene Blikh 25 days ago
refactor(authn): one Principal.CanRead() for the read-plane ACL (spec-ejq.1)

graph's gate, web's mayRead and mcpsrv's Gate each hand-spelled
'IsOwner() || IsAgent()' — three copies of the read ACL, which graph's own
comment warned is how a corpus leaks when they drift. Define it once as
authn.Principal.CanRead and route all three through it.

coreauth.Derive keeps its own owner||agent test on purpose: it answers a
different question (is this an owner-backed identity to bridge to
AUTH_INTERNAL), and coupling it to the read ACL would misroute a future
read-only viewer kind to the owner's UserID.

Closes spec-ejq.1
7cc652d5 — Eugene Blikh 25 days ago
fix(mcpsrv): gate read tools to owner+agents (spec-jjo)

The MCP read tools (spec_search/spec_read/spec_list) enforced no ACL:
anyone clearing the Host allowlist could read approved content. graph's
/query and the web UI gate reads to owner+agents; /mcp did not. Add the
same fail-closed gate to the whole MCP surface — initialize, tools/list
and tools/call — mounted inside the resolver middleware so it sees the
resolved principal. spec_propose was already fail-closed in service.Propose;
this makes the read tools match.

Pre-existing since Phase 2, cheap now that /mcp resolves a principal.

Closes spec-jjo
42d4ee13 — Eugene Blikh 26 days ago
feat(mcpsrv): spec_propose write tool (Phase 3)

The agent-facing half of the write plane over MCP. spec_propose uploads
whole documents and returns {proposal, url, merged}, calling the same
service.Propose the REST PUT will — one implementation of If-Match,
provenance and auto-merge behind both surfaces, never two that drift.

- Writer is an optional Backend field: nil keeps the server read-only
  (the three read tools, unchanged), so a read-only deploy or a test
  needs no mutable backend. Set, it registers spec_propose with neither
  the read-only nor the idempotent hint — proposing twice opens two
  proposals.
- The acting agent is resolved from the bearer token on the tool call by
  the resolver middleware now installed on /mcp; the read tools never
  needed it. The ACL stays in service/: service.Propose refuses a
  non-agent, so an anonymous or owner caller is rejected there.
e0938e94 — Eugene Blikh 27 days ago
refactor: make the filter-polarity trap inexpressible

service.SpaceFilter meant "empty membership selects nothing" — a newly
created project has no members — while search.Query.Spaces was a bare
[]core.SpaceRef whose empty case meant every space. Passing a project's
members into a query therefore turned an empty project into the whole
corpus: a silent scope inversion, invisible when it happens, and passing
every test written with a non-empty project.

The filter moves to core.SpaceFilter with unexported fields, and
search.Query takes it whole. There is no slice to hand over any more, so
the inversion cannot be written. Its three states are distinct: every
space (EverythingFilter), exactly these — possibly none — (SpacesFilter),
and the zero value, which is neither. Search refuses the zero value
rather than defaulting it, because both plausible defaults are wrong for
one of the two callers that can produce one, and returns no hits for a
filter that selects no space without asking the index.

service.SpaceFilter is now an alias for the core type, so ResolveProject
and its callers keep their names. Tests that built a Query without a
scope now say core.EverythingFilter(), which is what they always meant.
5a10600a — Eugene Blikh 27 days ago
feat: service.Archive — one accessor, one tree walk, one link graph

web/ and mcpsrv/ both needed a *doc.Archive and each invented its own way
to get one. web/ handed sp.Repo to doc.Scan — reaching past service/ into
gitx, which the layering rule forbids — and then listed the documents a
second time for their bodies, two tree walks per page view. mcpsrv/
converted []service.Document back into []gitx.Document, rebuilding hashes
service/ had already stringified. Two workarounds around one missing
accessor is how three agent-facing surfaces stop being identical.

Service.Archive resolves the revision, walks the tree once, and returns
the archive with the bodies. ArchiveFrom is the same construction over a
Document set a caller already holds — the one place a hex object name is
converted back to a hash, malformed ids refused rather than zeroed.

doc.Page.Links and Page.WordCount were documented as "filled in by a
render pass" and nothing filled them, so Archive.Backlinks always
returned empty; web/ worked around that by re-rendering every document of
the space on each page view. doc.Archive.LinkPass now owns the pass and
the accessor runs it, so the link graph exists wherever the archive does.

web/ and mcpsrv/ no longer import gitx or go-git, in tests either.
doc.DirOf replaces the three copies of the same fromDir helper.
8edca94e — bigbes 27 days ago
feat(mcpsrv): MCP read tools, with Host validation replacing the SDK guard

Adds the Phase 2 read tools (spec_search, spec_read, spec_list) over the
service layer.

Two security fixes came out of building them.

The read plane could serve proposal content. gitx resolves ref names, and
service.resolveRev passed any string through, so rev=proposals/42 made the
READ plane hand back unreviewed text — which would then flow into agent
context as though approved, the single failure this service exists to
prevent. ValidateReadRev now admits only the approved-head sentinel or a
full 40-character object name, at the layer all three surfaces share.
Abbreviations are refused too: one that is unique today can become
ambiguous later, so a pinned revision would silently stop meaning one
thing.

The MCP SDK's DNS-rebinding guard rejects a loopback listener whose Host
is not loopback, which is exactly nginx forwarding to 127.0.0.1 — it would
403 only in production, passing every local test. The SDK offers no
allowlist, so the guard is disabled and replaced by a stricter check: Host
must equal the configured origin, or a loopback name for development. A
rebinding attack carries the attacker's name in Host and fails it. An
unusable origin logs loudly rather than quietly unguarding the endpoint.