fix(brain): provenance keeps only provably plain frontmatter; pipeline stamps
Round 2 of the PR #147 review forged origin: static for yaml.v3 with an alias key plus a "..." document-end line: per-line judgement cannot see context-dependent YAML. It also found REST ingest writing pages with no provenance (stampPages lived only in the MCP server). - New package internal/provenance. Apply keeps only top-level groups `key: value` with a bare-identifier key whose group decodes on its own to exactly that key, drops reserved and duplicate keys and everything else (aliases, anchors as keys, document markers, complex/merge keys, flow maps, tags, quoted keys), appends the stamp, then decodes the whole result with yaml.v3 and refuses the write unless it reads back exactly the stamp. api.StampProvenance wraps it and now returns an error, which every caller propagates. - The ingest pipeline stamps every page it writes or merges, from provenance.FromContext(ctx); MCP attaches the caller's stamp, REST and the watcher stamp unknown. RunRaw takes a ctx. - brain_edit snapshots the note and rolls the edit back if the stamp cannot be verified. Tests: R1/R2 payloads join the write/update variant table, plus brain_edit with the alias payload; REST /ingest-raw (title newline checked with the line-oriented reader) and REST /ingest (stub LLM) stamp unknown. Two existing byte-exact tests updated for the stamp line. Refs #145 #144 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -125,15 +125,6 @@ type WriteNoteOptions struct {
|
||||
WrittenBy string
|
||||
}
|
||||
|
||||
// provenanceOrigin normalises an origin for the frontmatter: absence must
|
||||
// never read as trusted, so empty becomes "unknown".
|
||||
func provenanceOrigin(o string) string {
|
||||
if o == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// WriteNote writes a markdown note into the brain. Returns the path
|
||||
// relative to brainDir (forward-slashed). Filename traversal is rejected.
|
||||
func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
@@ -233,7 +224,10 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
}
|
||||
fm.WriteString("---\n")
|
||||
|
||||
final := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
|
||||
final, err := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
@@ -553,7 +547,8 @@ func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
|
||||
// No principal on this door: pages are stamped origin: unknown (#145).
|
||||
result, err := pipeline.RunRaw(r.Context(), h.brainDir, req.Source, req.Pages, req.DryRun)
|
||||
if err != nil {
|
||||
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "ingest error")
|
||||
|
||||
@@ -132,7 +132,10 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||
return "", fmt.Errorf("create wing dir: %w", err)
|
||||
}
|
||||
// Write-then-delete: the source survives any write failure.
|
||||
promoted := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
||||
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write promoted note: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,172 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
||||
)
|
||||
|
||||
// Provenance (hyperguild#145) is server-owned: origin and written_by say
|
||||
// which authenticated principal last wrote a note, so a reader can discount
|
||||
// what a connector session wrote after reading an untrusted page.
|
||||
//
|
||||
// The control is a post-condition, not a set of per-door rules: every write
|
||||
// path a model can reach ends with StampProvenance, which removes EVERY
|
||||
// spelling of the two keys from the frontmatter and appends the real values.
|
||||
// Deciding what counts as a spelling is delegated to yaml.v3 itself — each
|
||||
// frontmatter line is decoded the way a YAML reader would decode it — so
|
||||
// quoted, escaped, tagged, flow-mapped and merged keys are all recognised
|
||||
// (PR #147 review: each of those survived a key-name string match).
|
||||
|
||||
var plainScalar = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]*$`)
|
||||
|
||||
// provenanceKeys are compared after yaml decoding and lower-casing.
|
||||
var provenanceKeys = map[string]bool{"origin": true, "written_by": true}
|
||||
|
||||
// StampProvenance returns content with all provenance spellings removed from
|
||||
// its frontmatter and origin (empty → "unknown") and written_by (omitted when
|
||||
// empty) appended. Content without frontmatter gains a block.
|
||||
func StampProvenance(content, origin, writtenBy string) string {
|
||||
content = normaliseFenceNewlines(content)
|
||||
stamp := "origin: " + scalar(provenanceOrigin(origin)) + "\n"
|
||||
if writtenBy != "" {
|
||||
stamp += "written_by: " + scalar(writtenBy) + "\n"
|
||||
}
|
||||
block, rest, ok := sharedfm.Split([]byte(content))
|
||||
if !ok {
|
||||
return "---\n" + stamp + "---\n" + content
|
||||
}
|
||||
// block is the frontmatter lines without the opening fence, ending in the
|
||||
// newline before the closing fence; rest starts with the newline after it.
|
||||
var kept []string
|
||||
if len(block) > 0 {
|
||||
for _, l := range strings.Split(strings.TrimSuffix(string(block), "\n"), "\n") {
|
||||
if !isProvenanceLine(l) {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "---\n" + joinLines(kept) + stamp + "---\n" + strings.TrimPrefix(string(rest), "\n")
|
||||
// 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 note on disk.
|
||||
// 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
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read for provenance: %w", err)
|
||||
}
|
||||
out := StampProvenance(string(b), origin, writtenBy)
|
||||
if out == string(b) {
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(out), 0o644); err != nil {
|
||||
return fmt.Errorf("write provenance %s: %w", filepath.ToSlash(relPath), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isProvenanceLine reports whether a frontmatter line could, to any YAML
|
||||
// reader, set origin or written_by. It errs towards dropping: a line it drops
|
||||
// wrongly loses attacker-shaped metadata; a line it keeps wrongly is a forgery.
|
||||
func isProvenanceLine(line string) bool {
|
||||
t := strings.TrimSpace(line)
|
||||
switch {
|
||||
case t == "":
|
||||
return false
|
||||
case strings.HasPrefix(t, "?"), strings.HasPrefix(t, ":"), strings.HasPrefix(t, "<<"):
|
||||
// Complex keys, their orphaned values, and merge keys: none appear in
|
||||
// frontmatter the brain writes, and each can introduce a key a
|
||||
// line-oriented check cannot see.
|
||||
return true
|
||||
}
|
||||
// Two readers, two verdicts, and a line is dropped if EITHER would see
|
||||
// provenance: yaml.v3 decodes quoting, escapes, tags and flow maps, while
|
||||
// the brain's line-oriented readers split on the first colon — to them
|
||||
// "origin:static" is a key, to YAML it is a plain scalar.
|
||||
return yamlSeesProvenance(t) || textSeesProvenance(t)
|
||||
}
|
||||
|
||||
func yamlSeesProvenance(t string) bool {
|
||||
var v any
|
||||
if err := yaml.Unmarshal([]byte(t), &v); err != nil {
|
||||
return false
|
||||
}
|
||||
switch m := v.(type) {
|
||||
case map[string]any:
|
||||
for k := range m {
|
||||
if provenanceKeys[strings.ToLower(strings.TrimSpace(k))] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[any]any:
|
||||
for k := range m {
|
||||
if provenanceKeys[strings.ToLower(strings.TrimSpace(fmt.Sprint(k)))] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// textSeesProvenance takes the text before the first colon, drops YAML tags,
|
||||
// brackets and quotes, and compares case-insensitively.
|
||||
func textSeesProvenance(t string) bool {
|
||||
key, _, found := strings.Cut(t, ":")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
for strings.HasPrefix(key, "!") {
|
||||
_, after, ok := strings.Cut(key, " ")
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
key = strings.TrimSpace(after)
|
||||
}
|
||||
key = strings.ToLower(strings.Trim(key, "{[\"' \t"))
|
||||
return provenanceKeys[key]
|
||||
}
|
||||
|
||||
// normaliseFenceNewlines turns a lone CR inside the leading frontmatter
|
||||
// block into LF, so "title: x\rorigin: static" is two lines to every reader,
|
||||
// including this one. CRLF is already folded by frontmatter.Split.
|
||||
func normaliseFenceNewlines(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 value plainly when it is a safe bare word, otherwise as a
|
||||
// JSON string (valid YAML double-quoted), so a principal name can never break
|
||||
// the line.
|
||||
func scalar(v string) string {
|
||||
if plainScalar.MatchString(v) {
|
||||
return v
|
||||
}
|
||||
return fmt.Sprintf("%q", v)
|
||||
}
|
||||
|
||||
func joinLines(lines []string) string {
|
||||
var b strings.Builder
|
||||
for _, l := range lines {
|
||||
b.WriteString(l)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ func TestRESTWriteStampsUnknownAndIgnoresBodyProvenance(t *testing.T) {
|
||||
// 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 := api.StampProvenance("---\ntitle: t\n---\nbody\n", "jwt", "evil\norigin: static")
|
||||
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))
|
||||
@@ -49,7 +50,8 @@ func TestStampProvenanceQuotesAPrincipalThatCouldBreakTheLine(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStampProvenanceAddsFrontmatterWhenAbsentAndKeepsBody(t *testing.T) {
|
||||
out := api.StampProvenance("# no fm\n\norigin: static in the body is prose\n", "static", "local-cli")
|
||||
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")
|
||||
}
|
||||
@@ -60,3 +62,49 @@ func boolToInt(b bool) int {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,11 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
||||
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
||||
}
|
||||
|
||||
out := []byte(StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy))
|
||||
stamped, err := StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
out := []byte(stamped)
|
||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||
return "", "", "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user