Files
J3vbandClaude Fable 5 0eb603cefc feat(b3-6): authz-chokepoint invariant rule — raw permission checks route through the B2-5 predicates (#1451)
* feat(b3-6): authz-chokepoint rule — raw permission checks route through the B2-5 predicates

B2-5 gave every channel-scoped security property exactly one predicate. This
is the guardrail that keeps the next call site from re-deriving one by hand,
which is how the thirteen hand-rolled decision sites B2-5 collapsed came to
exist.

The rule fails any production file outside Server/permissions that names one
of the six raw bit helpers (HasPerm, HasAnyPerm, HasServerPerm, HasAdmin,
EffectivePerms, EffectiveChannelPerms) — the whole exported surface of
permissions.go except Name — unless the enclosing symbol has a residue row.
It matches the selector rather than the call, so taking a helper as a value
does not evade it, and reports a dot-import of the package separately, since
that would let the helpers be spelled bare.

AuthzResidueAllow is HP-2 question 5's residue table: 19 symbols, 21 call
sites, re-measured at dev 75d64dd4 and unchanged in count. Rows are keyed by
directory plus enclosing function or method, never file:line — the table's
line numbers had already moved under B3-2. Each row carries one of question
5's five classes and a reason, so B3-8 can retire a class at a time.
TestAuthzResidueAllowIsLive fails any row whose symbol stopped calling a raw
helper, so the list can only shrink honestly.

importNames and the walker's rule-set parameter move to invariants.go: the
syncutil rule already needed the first, and the liveness test needs the
second. No production behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): authz-chokepoint evidence block — allowlist size, RED and GREEN runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): authz-chokepoint — tighten the exclusion guard, reject file-scope rows, document the class set

Four review Minors, none behaviour-changing for the 19 real rows.

The permissions/ early return is gone rather than tested. It could never fire
for the package itself — a file there cannot import itself, so it binds no
"permissions" identifier and matches nothing — while its HasPrefix arm would
have silently exempted a future permissions/<sub>, which is a different
package that does import permissions. Dropping it is the strict choice. A new
fixture at permissions/policy/x.go pins that: re-adding the exclusion fails it.

An allowlist row keyed <dir>.<file-scope> would have blanket-exempted every
package-scope raw call and every dot-import in that directory at once.
TestAuthzResidueAllowIsLive now rejects such a row, and TestFileScopeRowsAreRejected
covers the predicate directly.

The class set is documented as closed: a row cannot invent a class, including
the "unclassified" escape valve the brief sketched, so new residue needs a
constant added as a deliberate edit.

The violation message said the call "decides authorization", which is wrong for
EffectivePerms and EffectiveChannelPerms — they compute the mask a decision
reads. It now says "resolves permission bits", distinguishes the two groups,
and names the five legal classes from authzClassList, a constant folded from
the class constants so it cannot drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): authz-chokepoint — bind residue rows to helper and multiplicity (Codex P2 on #1451)

The symbol-only lookup exempted the whole function. A second raw call added
inside any of the 19 allowlisted symbols, or a switch to a different helper
there, passed silently — and the liveness test only asked for at least one hit,
so the 21-call residue could grow without review. Correct by construction.

Each row now carries Calls, a helper-name to count multiset filled from what
the tree actually contains: 18 rows bind one call, mentionReaders binds three
(EffectivePerms 1, HasAdmin 2). By helper: HasAdmin 13, HasServerPerm 6,
HasAnyPerm 1, EffectivePerms 1 — 21 in all, unchanged.

The rule counts hits per symbol as it walks a file and flags the call that
takes a helper past its bound count, so an extra call, one more of the same
helper, and a helper the row never listed (bound count 0) all fail at the
offending line, with the helper and the expected-versus-found counts in the
message. A dot-import is now flagged inside an allowlisted symbol too — it
binds no call to count, and a row is no excuse for one. Fewer calls than the
row binds is left to TestAuthzResidueAllowIsLive, which compares the multiset
exactly (maps.Equal) instead of asking for at least one hit, so an over-counted
row cannot leave headroom either.

Symbol keying is unchanged: the plan mandates file:line independence.

RED, then restored: a second permissions.HasAdmin in api.serveFileAuthorize
fails naming "binds 1 call(s) of HasAdmin here, found 2"; swapping it to
HasPerm fails naming "binds 0 call(s) of HasPerm here, found 1"; setting that
row to HasAdmin: 2 fails the liveness test with the multiset diff. All three
have unit fixtures, alongside one that a shrinking residue is not the rule's
business.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:07:37 +00:00

80 lines
2.5 KiB
Go

package invariants
import (
"go/ast"
"go/token"
)
// syncutilLocksID is the rule's stable id. It is a const, not a field read
// off syncutilLocks, because the Rule var's own initializer (Check:
// checkSyncutilLocks) would otherwise form an initialization cycle with the
// function that emits it.
const syncutilLocksID = "syncutil-locks"
// syncutilLocks forbids raw sync.Mutex and sync.RWMutex in the packages whose
// lock order the -tags deadlock CI pass exists to observe.
//
// syncutil.Mutex is a build-tag alias: sync.Mutex in production,
// deadlock.Mutex under -tags deadlock. A lock declared as sync.Mutex is
// therefore invisible to that pass. Server/CLAUDE.md states the rule directly:
// "syncutil exists so lock usage is uniform and detectable; do not hand-roll
// around it."
var syncutilLocks = Rule{
ID: syncutilLocksID,
Scope: []string{"ws", "service"},
Check: checkSyncutilLocks,
}
// checkSyncutilLocks flags any sync.Mutex/sync.RWMutex selector, wherever it
// syntactically appears: struct field, embedded field, var spec (typed or
// inferred from a composite literal), short assignment, type alias, or
// composite element/value type ([]sync.Mutex, map[K]sync.Mutex). A single
// selector match subsumes all of these, so no per-construct cases are needed.
//
// A dot-import of "sync" is reported separately: it would let a bare Mutex
// evade the selector match entirely.
func checkSyncutilLocks(f *ast.File, fset *token.FileSet, rel string) []Violation {
var out []Violation
names, dotImports := importNames(f, "sync")
for _, imp := range dotImports {
out = append(out, Violation{
Rule: syncutilLocksID,
File: rel,
Line: fset.Position(imp.Pos()).Line,
Msg: `dot-import of "sync" defeats syncutil-locks (a bare Mutex/RWMutex can no longer be matched); import sync normally`,
})
}
if len(names) == 0 {
return out
}
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
pkg, ok := sel.X.(*ast.Ident)
if !ok || !names[pkg.Name] {
return true
}
name := sel.Sel.Name
if name != "Mutex" && name != "RWMutex" {
return true
}
out = append(out, Violation{
Rule: syncutilLocksID,
File: rel,
Line: fset.Position(sel.Pos()).Line,
Msg: "raw sync." + name + " is invisible to the -tags deadlock CI pass; " +
"declare it as syncutil." + name + " (github.com/J3vb/OwnCord/Server/syncutil), " +
"or add //invariant:allow syncutil-locks — <reason>",
})
return true
})
return out
}