fix(brain): provenance as a post-condition on every model-reachable write
The PR #147 review forged origin: static as a JWT principal through three tools the first version never touched (brain_edit, brain_ingest_raw, ingest_raw + brain_promote), and through YAML spellings a key-name match missed (quoted, escaped, tagged, flow-mapped, merge keys, lone CR). - api.StampProvenance removes every spelling of origin/written_by from the frontmatter and appends the real values. A line is dropped if EITHER yaml.v3 (decoding quotes, escapes, tags, flow maps) or the brain's line-oriented readers would read it as provenance; complex keys, orphan values and merge keys are dropped outright. Lone CRs in frontmatter become LF. Principal values that are not bare words are quoted. - Applied as the last step of writeHallNote, UpdateNote, PromoteNote (re-attributed to the promoter), brain_edit (re-attributed to the editor) and both ingests (every page written or merged). REST /write and /promote carry no principal and stamp unknown. - Ingest: newlines in scalar fields fold to spaces; a page type must be a plain lower-case name (../raw wrote outside wiki/, pre-existing). - Audit: outcome is ok|error, never the error text, which echoed argument values. Tests: every escape from the review is a table case asserting the invariant on the file (parses as YAML, yaml.v3 and brain_get agree, exactly the stamped lines). Plus REST /write, REST capture AuthPath, body-line provenance in search, and quoted principals; two of the review's surviving flips now go red. Three tests renamed so names claim only what they assert. Refs #145 #144 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -95,18 +95,6 @@ func (f *frontmatter) set(key, value string) {
|
||||
f.lines = append(f.lines, fmLine{key: key, value: value})
|
||||
}
|
||||
|
||||
// del removes every top-level line for key. Continuation lines of a
|
||||
// multi-line value are not tracked per key, so del is only for scalars.
|
||||
func (f *frontmatter) del(key string) {
|
||||
kept := f.lines[:0]
|
||||
for _, l := range f.lines {
|
||||
if l.key != key {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
f.lines = kept
|
||||
}
|
||||
|
||||
// render serialises the frontmatter back into a `---`-fenced block. An
|
||||
// empty frontmatter renders to the empty string so bodies without a
|
||||
// header stay header-less.
|
||||
|
||||
@@ -194,11 +194,6 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
||||
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
||||
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
|
||||
fmt.Fprintf(&fm, "origin: %s\n", provenanceOrigin(opts.Origin))
|
||||
emitted["origin"], emitted["written_by"] = true, true
|
||||
if opts.WrittenBy != "" {
|
||||
fmt.Fprintf(&fm, "written_by: %s\n", opts.WrittenBy)
|
||||
}
|
||||
|
||||
// writeField merges one key: opts.Content's own value (if the note already
|
||||
// carries this field in its own frontmatter) always wins over the fallback,
|
||||
@@ -238,7 +233,8 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
}
|
||||
fm.WriteString("---\n")
|
||||
|
||||
if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil {
|
||||
final := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
|
||||
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
@@ -613,7 +609,11 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
|
||||
// Field by field: this door carries no principal, so the promoted note is
|
||||
// stamped origin: unknown and the body cannot set provenance (#145).
|
||||
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
||||
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
||||
@@ -72,6 +72,10 @@ type PromoteOptions struct {
|
||||
Wing string
|
||||
Hall string
|
||||
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
||||
// note's own frontmatter claims are not provenance (hyperguild#145).
|
||||
Origin string
|
||||
WrittenBy string
|
||||
}
|
||||
|
||||
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
||||
@@ -128,7 +132,8 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||
return "", fmt.Errorf("create wing dir: %w", err)
|
||||
}
|
||||
// Write-then-delete: the source survives any write failure.
|
||||
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||
promoted := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
||||
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write promoted note: %w", err)
|
||||
}
|
||||
if err := os.Remove(src); err != nil {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// StampProvenanceFile applies StampProvenance to a note on disk.
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 := api.StampProvenance("---\ntitle: t\n---\nbody\n", "jwt", "evil\norigin: static")
|
||||
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 := api.StampProvenance("# no fm\n\norigin: static in the body is prose\n", "static", "local-cli")
|
||||
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
|
||||
}
|
||||
@@ -134,12 +134,6 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
||||
fm.set(l.key, l.value)
|
||||
}
|
||||
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||
fm.set("origin", provenanceOrigin(opts.Origin))
|
||||
if opts.WrittenBy != "" {
|
||||
fm.set("written_by", opts.WrittenBy)
|
||||
} else {
|
||||
fm.del("written_by")
|
||||
}
|
||||
fm.set("supersedes", priorHash)
|
||||
if opts.Reason != "" {
|
||||
fm.set("supersede_reason", opts.Reason)
|
||||
@@ -164,7 +158,7 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
||||
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
||||
}
|
||||
|
||||
out := []byte(fm.render() + contentBody)
|
||||
out := []byte(StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy))
|
||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||
return "", "", "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user