diff --git a/ingestion/internal/classification/classification.go b/ingestion/internal/classification/classification.go new file mode 100644 index 0000000..cbca9f8 --- /dev/null +++ b/ingestion/internal/classification/classification.go @@ -0,0 +1,189 @@ +// Package classification defines the data-sensitivity taxonomy and the +// per-wing / per-repo tagging the capture server reads to enforce the I1 +// sovereignty gate (issue #50, capture spec §4.1). +// +// The single load-bearing property is fail-safe-to-strictest: a target +// with no explicit tag and no known default classifies as Confidential, +// never as something more permissive. A missing tag must never silently +// downgrade — that would turn the I1 gate into theatre. +// +// Classification is read from an optional classification.yaml at the +// brain root. A central, Flux-reconcilable file is deliberate: it is +// auditable in one place (I2/I5), it does not require a live Gitea client +// to classify a repo (so this package has no dependency on the gitea +// tracker work), and it avoids tagging a wing's _index.md frontmatter — +// which BuildWingIndex regenerates and would clobber. +package classification + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Level is a data-sensitivity tier. Higher is stricter, so the "stricter +// wins" rule (spec §4.1 model C) is a plain max. +type Level int + +const ( + Public Level = iota + Internal + Confidential +) + +// String returns the canonical lowercase token for a level. +func (l Level) String() string { + switch l { + case Public: + return "public" + case Internal: + return "internal" + case Confidential: + return "confidential" + default: + return fmt.Sprintf("level(%d)", int(l)) + } +} + +// ParseLevel parses a level token (case-insensitive, surrounding space +// tolerated). An unknown token is an error — callers must decide what to +// do with bad input rather than have it silently coerced. +func ParseLevel(s string) (Level, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "public": + return Public, nil + case "internal": + return Internal, nil + case "confidential": + return Confidential, nil + default: + return Confidential, fmt.Errorf("unknown classification level %q (want public/internal/confidential)", s) + } +} + +// Stricter returns the more restrictive of two levels. +func Stricter(a, b Level) Level { + if a > b { + return a + } + return b +} + +// TargetKind distinguishes the two kinds of capture destination. +type TargetKind int + +const ( + WingTarget TargetKind = iota // a brain wing (insights land here) + RepoTarget // a Gitea repo (tickets / summaries land here) +) + +// Target names a capture destination to classify. +type Target struct { + Kind TargetKind + Name string +} + +// Config holds the explicit per-wing / per-repo classification tags read +// from classification.yaml. Absent entries fall through to the built-in +// defaults in defaultFor. The zero value (no file) is valid and applies +// defaults to everything. +type Config struct { + wings map[string]Level + repos map[string]Level +} + +// rawConfig is the on-disk YAML shape: string→string maps, parsed into +// validated levels by Load. +type rawConfig struct { + Wings map[string]string `yaml:"wings"` + Repos map[string]string `yaml:"repos"` +} + +// Load reads classification.yaml from brainDir. An absent file is not an +// error — it yields an empty config where every target classifies by the +// built-in defaults. A malformed file, or any unparseable level token in +// it, is a hard error: a classification source the server cannot trust +// must fail loud, not degrade silently. +func Load(brainDir string) (*Config, error) { + cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}} + + data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml")) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return nil, fmt.Errorf("read classification.yaml: %w", err) + } + + var raw rawConfig + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse classification.yaml: %w", err) + } + for name, lvl := range raw.Wings { + parsed, perr := ParseLevel(lvl) + if perr != nil { + return nil, fmt.Errorf("wing %q: %w", name, perr) + } + cfg.wings[normalise(name)] = parsed + } + for name, lvl := range raw.Repos { + parsed, perr := ParseLevel(lvl) + if perr != nil { + return nil, fmt.Errorf("repo %q: %w", name, perr) + } + cfg.repos[normalise(name)] = parsed + } + return cfg, nil +} + +// Derive returns the classification for any target — the function the +// capture use-case calls per item. +func (c *Config) Derive(t Target) Level { + if t.Kind == RepoTarget { + return c.Repo(t.Name) + } + return c.Wing(t.Name) +} + +// Wing classifies a brain wing: an explicit tag wins, else defaults. +func (c *Config) Wing(name string) Level { + if lvl, ok := c.wings[normalise(name)]; ok { + return lvl + } + return defaultFor(name) +} + +// Repo classifies a Gitea repo: an explicit tag wins, else defaults. +func (c *Config) Repo(name string) Level { + if lvl, ok := c.repos[normalise(name)]; ok { + return lvl + } + return defaultFor(name) +} + +// defaultFor applies the built-in defaulting rules when a target has no +// explicit tag: +// - client-* → Confidential (client work is confidential by default) +// - hyperguild / homelab → Internal (the operator's own infra) +// - everything else → Confidential (fail safe to strictest) +func defaultFor(name string) Level { + n := normalise(name) + if strings.HasPrefix(n, "client-") { + return Confidential + } + switch n { + case "hyperguild", "homelab": + return Internal + default: + return Confidential + } +} + +// normalise lowercases and trims a wing/repo name so matching and the +// client-* prefix check are case-insensitive. +func normalise(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} diff --git a/ingestion/internal/classification/classification_test.go b/ingestion/internal/classification/classification_test.go new file mode 100644 index 0000000..1c1d45f --- /dev/null +++ b/ingestion/internal/classification/classification_test.go @@ -0,0 +1,112 @@ +package classification + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLevelOrderingAndString(t *testing.T) { + assert.True(t, Public < Internal) + assert.True(t, Internal < Confidential) + assert.Equal(t, "public", Public.String()) + assert.Equal(t, "internal", Internal.String()) + assert.Equal(t, "confidential", Confidential.String()) +} + +func TestParseLevel(t *testing.T) { + for s, want := range map[string]Level{ + "public": Public, "internal": Internal, "confidential": Confidential, + "PUBLIC": Public, " Confidential ": Confidential, + } { + got, err := ParseLevel(s) + require.NoError(t, err, s) + assert.Equal(t, want, got, s) + } + _, err := ParseLevel("secret") + require.Error(t, err, "unknown level must error, not silently default") + _, err = ParseLevel("") + require.Error(t, err) +} + +func TestStricterReturnsMax(t *testing.T) { + assert.Equal(t, Confidential, Stricter(Internal, Confidential)) + assert.Equal(t, Confidential, Stricter(Confidential, Public)) + assert.Equal(t, Internal, Stricter(Public, Internal)) + assert.Equal(t, Public, Stricter(Public, Public)) +} + +func TestLoadAbsentFileIsDefaultsOnly(t *testing.T) { + cfg, err := Load(t.TempDir()) + require.NoError(t, err, "absent classification.yaml must not be an error — defaults apply") + require.NotNil(t, cfg) + // Pure defaulting still works. + assert.Equal(t, Internal, cfg.Wing("hyperguild")) + assert.Equal(t, Confidential, cfg.Wing("anything-unknown")) +} + +func TestLoadParsesExplicitTags(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"), []byte( + "wings:\n research-public: public\n hyperguild: confidential\nrepos:\n infra: internal\n research-public: public\n", + ), 0o644)) + + cfg, err := Load(dir) + require.NoError(t, err) + // Explicit tag wins over the built-in default (hyperguild default is internal). + assert.Equal(t, Confidential, cfg.Wing("hyperguild")) + // Explicit public is honoured. + assert.Equal(t, Public, cfg.Wing("research-public")) + assert.Equal(t, Internal, cfg.Repo("infra")) + assert.Equal(t, Public, cfg.Repo("research-public")) +} + +func TestLoadRejectsUnknownLevelInFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"), + []byte("wings:\n x: top-secret\n"), 0o644)) + _, err := Load(dir) + require.Error(t, err, "an unparseable level in the config must fail loud, not be ignored") +} + +func TestWingDefaulting(t *testing.T) { + cfg, err := Load(t.TempDir()) + require.NoError(t, err) + cases := map[string]Level{ + "client-seb": Confidential, // client-* → confidential + "client-mastercard": Confidential, + "hyperguild": Internal, + "homelab": Internal, + "jepa-fx": Confidential, // unknown → fail safe to strictest + "": Confidential, // empty → fail safe + } + for wing, want := range cases { + assert.Equal(t, want, cfg.Wing(wing), "wing %q", wing) + } +} + +func TestRepoDefaulting(t *testing.T) { + cfg, err := Load(t.TempDir()) + require.NoError(t, err) + assert.Equal(t, Confidential, cfg.Repo("client-seb-pipeline")) + assert.Equal(t, Internal, cfg.Repo("hyperguild")) + assert.Equal(t, Confidential, cfg.Repo("some-unknown-repo"), "untagged repo → confidential (fail safe)") +} + +func TestDeriveUnifiedTarget(t *testing.T) { + cfg, err := Load(t.TempDir()) + require.NoError(t, err) + assert.Equal(t, Internal, cfg.Derive(Target{Kind: WingTarget, Name: "homelab"})) + assert.Equal(t, Confidential, cfg.Derive(Target{Kind: RepoTarget, Name: "client-x"})) + assert.Equal(t, Confidential, cfg.Derive(Target{Kind: WingTarget, Name: "untagged"})) +} + +func TestCaseInsensitiveMatching(t *testing.T) { + cfg, err := Load(t.TempDir()) + require.NoError(t, err) + assert.Equal(t, Confidential, cfg.Wing("Client-SEB"), "client- prefix match is case-insensitive") + assert.Equal(t, Internal, cfg.Wing("HyperGuild")) +} diff --git a/specs/capture-bdd-spec.md b/specs/capture-bdd-spec.md index e31aee1..a5e67e9 100644 --- a/specs/capture-bdd-spec.md +++ b/specs/capture-bdd-spec.md @@ -203,6 +203,12 @@ binding design decisions for the build. - **Prerequisite (new build work):** a classification taxonomy (e.g. `public` / `internal` / `confidential`) and a per-wing / per-repo classification tag the server can read. This must exist before the I1 gate is load-bearing. Tracked as a sub-task of #49. + - **Implemented (#50):** taxonomy `public < internal < confidential` (ordered so "stricter wins" + is `max`) in `ingestion/internal/classification/`. Tags are read from an optional + `classification.yaml` at the brain root (`wings:` / `repos:` maps); absent entries fall to + built-in defaults (`client-*` → confidential; `hyperguild`/`homelab` → internal; everything + else → **confidential, fail-safe**). `Config.Derive(Target)` is the function the use-case + calls. See brain `wiki/hyperguild/decisions/capture-classification-taxonomy`. - Rationale: composes with decision 2; fails safe; honours a caller flagging something *more* sensitive than its destination. Pure caller-trust (A) was rejected — it makes the gate theatre.