Documentation
¶
Overview ¶
Package triage reduces public GitHub issue bodies to validated, enumerated data before any model holding tools can act on them.
Zalaras/muster went public on 2026-09-10, so an issue body is attacker-controlled text. /triage used to read one straight into the main Claude session, which holds Bash and Edit, and wrote model-authored prose into TODO.md — a file every later /orchestrate, /plan-work and /triage --audit session reads with full tools. A payload did not have to fire during triage; it could lie dormant and fire in a more capable session later. That dormancy is the risk this package exists to remove.
The design rule is that nothing between GitHub and the TODO.md diff is a model with tools. Everything reaching this package is untrusted, including the snapshot JSON: an author may edit their own issue body forever, and nothing binds that JSON to anything musterd produced. The schema buys a strict parser, never trust.
Transform order is load-bearing ¶
Sanitize applies six transforms in a fixed order. Each swap below is a real defect, and each has a test:
- reject bidi overrides
- truncate the input at a rune boundary
- strip image then link syntax down to its text
- defang autolinks and bare URLs
- HTML-escape, ampersand FIRST, then the angle brackets
- escape every remaining non-ASCII or control rune to a visible marker
Running 5 before 4 hides an autolink's delimiters from the defanger, so the URL survives. Running 6 before 5 rewrites this package's own markers into entity text. Escaping the angle brackets before the ampersand is the worst of the three: the body text "<script>" becomes a live "<script>" tag, because the entities the author typed are decoded rather than escaped.
What survives, and why that is not enough ¶
Escaping the three HTML characters makes a tag impossible by construction, which a tag denylist never achieves. Markdown headings, lists, emphasis, code fences and tables survive untouched, because none of them use those characters — which is the point, and also a hazard: a body may still contain a line reading "## Reported issues (pre-v1 release)". Spliced into TODO.md that would forge a section heading. Containing it is RenderEntry's job, not this file's.
See docs/history/design/triage-hardening.md for the threat model and the residual risks.
Index ¶
- Constants
- Variables
- func Apply(ctx context.Context, run RunFunc, root, repo string, decisions []Decision, ...) (string, error)
- func CheckBool(v Value) bool
- func CheckEntryShape(entry string) error
- func CheckRFC3339(v Value) bool
- func CheckVersion(v Value) bool
- func EntryState(todo string, n int) (checked, found bool)
- func ExtractSnapshotJSON(body string) (raw string, present, ambiguous bool)
- func HasBidi(s string) bool
- func HasIssue(todo string, n int) bool
- func IssueURL(repo string, n int) string
- func NewNonce() (string, error)
- func ReadTracked(root string) (todo, tracked string, err error)
- func RenderEntry(a Artifact, p Proposal, repo string) string
- func RenderSnapshotTable(s Snapshot) string
- func ResolveRepo(ctx context.Context, run RunFunc, modulePath string) (string, error)
- func SectionNames(todo string) []string
- func Splice(todo, section, entry string) (string, error)
- func StripSnapshotRegions(body string) string
- func Tripwire(clean string) (string, bool)
- func WriteArtifacts(dir string, arts []Artifact) error
- func WriteDispatch(dir string, arts []Artifact) error
- func WriteIndex(dir string, arts []Artifact) error
- type Artifact
- type CheckFunc
- type Counts
- type Decision
- type Dispatch
- type Fetcher
- type Field
- type Flags
- type Issue
- type KV
- type Kind
- type Limits
- type Path
- type Proposal
- type RunFunc
- type Snapshot
- type Value
Constants ¶
const DispatchFile = "dispatch.json"
DispatchFile is what the SKILL reads. It deliberately carries no body, no title and no flag text — only what the main session needs to spawn one proposer per issue.
The split exists because index.json holds the sanitised bodies, and the main session reading those would undo the whole point: the body reaches the proposer by path, never through the context of a session holding Bash and Edit. The nonce is safe to print because this package minted it; it is not attacker text.
const HistoryFile = "docs/history/todo-done.md"
HistoryFile is where a TODO.md entry goes once it is ticked. Entries there still own their issues/N link, so it is part of what "already triaged" means — but this package only ever reads it.
const IndexFile = "index.json"
IndexFile carries the artifacts between the fetch and apply runs, which are separate processes with on-disk state in between.
const TodoFile = "TODO.md"
TodoFile is the only file this package ever writes in the repository. Ticked entries leave it for HistoryFile, which is read alongside it (ReadTracked) and never written.
const TruncationMarker = "\n[truncated]"
TruncationMarker is appended when a cap bites. A fixed constant: a marker assembled from the body would be attacker text in a position the reader trusts.
Variables ¶
var ( Components = []string{"dashboard", "daemon", "tmux", "installer", "update", "issue-capture", "hooks", "docs", "unknown"} Symptoms = []string{"crash", "hang", "wrong-output", "visual", "missing-feature", "perf", "install", "unknown"} Sections = []string{ "Pre-v1 Cleanup", "Reported issues (pre-v1 release)", "M5+ (v1.x, re-rank when reached)", } )
The closed vocabularies the proposer chooses from on the facts-only path. They exist so that no model-authored prose reaches TODO.md: an enum cannot carry an instruction.
var ErrBidi = errors.New("bidirectional override present")
ErrBidi rejects a body outright rather than escaping it. No honest bug report carries a bidirectional override, and the whole point of one is to make rendered text disagree with its bytes — the exact property every other transform here exists to remove.
var Schema = []Field{ {Path: "capturedAt", Kind: KindString, Check: CheckRFC3339}, {Path: "scope", Kind: KindString, Check: CheckEnum("session", "dashboard")}, {Path: "musterd.version", Kind: KindString, Check: CheckVersion}, {Path: "claudeCode.installed", Kind: KindString, Check: CheckVersion}, {Path: "claudeCode.floor", Kind: KindString, Check: CheckVersion}, {Path: "claudeCode.verified", Kind: KindString, Check: CheckVersion}, {Path: "claudeCode.status", Kind: KindString, Check: CheckToken(32)}, {Path: "claudeCode.pinned", Kind: KindString, Check: CheckVersion, Retired: true}, {Path: "claudeCode.drift", Kind: KindBool, Check: CheckBool, Retired: true}, {Path: "host.os", Kind: KindString, Check: CheckEnum("darwin", "linux", "windows")}, {Path: "host.arch", Kind: KindString, Check: CheckEnum("amd64", "arm64", "386", "arm")}, {Path: "dashboard.sessionsTotal", Kind: KindInt, Check: CheckIntRange(0, 1e6)}, {Path: "dashboard.sessionsAlive", Kind: KindInt, Check: CheckIntRange(0, 1e6)}, {Path: "dashboard.view", Kind: KindString, Check: CheckToken(32)}, {Path: "dashboard.density", Kind: KindString, Check: CheckToken(32)}, {Path: "dashboard.railSort", Kind: KindString, Check: CheckToken(32)}, {Path: "session.state", Kind: KindString, Check: CheckToken(32)}, {Path: "session.stateSince", Kind: KindString, Check: CheckRFC3339}, {Path: "session.alive", Kind: KindBool, Check: CheckBool}, {Path: "session.endedAt", Kind: KindString, Check: CheckRFC3339}, {Path: "session.attention.reason", Kind: KindString, Check: CheckToken(64)}, {Path: "session.attention.since", Kind: KindString, Check: CheckRFC3339}, {Path: "session.failure.error", Kind: KindString, Check: CheckToken(128)}, {Path: "session.model.id", Kind: KindString, Check: CheckToken(64)}, {Path: "session.permissionMode.value", Kind: KindString, Check: CheckToken(32)}, {Path: "session.permissionMode.source", Kind: KindString, Check: CheckToken(32)}, {Path: "session.context.usedPct", Kind: KindFloat, Check: CheckFloatRange(0, 100)}, {Path: "session.context.totalInputTokens", Kind: KindInt, Check: CheckIntRange(0, 1e15)}, {Path: "session.context.windowSize", Kind: KindInt, Check: CheckIntRange(0, 1e15)}, {Path: "session.compactions", Kind: KindInt, Check: CheckIntRange(0, 1e6)}, {Path: "session.tmuxTarget", Kind: KindString, Check: CheckToken(64)}, {Path: "session.createdAt", Kind: KindString, Check: CheckRFC3339}, {Path: "session.claudeSessionIdBound", Kind: KindBool, Check: CheckBool}, {Path: "session.events.firstSeq", Kind: KindInt, Check: CheckIntRange(0, 1e12)}, {Path: "session.events.lastSeq", Kind: KindInt, Check: CheckIntRange(0, 1e12)}, {Path: "session.events.count", Kind: KindInt, Check: CheckIntRange(0, 1e12)}, {Path: "session.events.lastReceivedAt", Kind: KindString, Check: CheckRFC3339}, {Path: "session.events.recentTypes", Kind: KindStringSlice, Check: CheckTokenSlice(32, 48)}, }
Schema is the allowlist of snapshot leaves, one row per dotted JSON path. Nothing here confers trust: an author may edit their own issue body forever, so a snapshot is only untrusted text that happens to have a shape we own. What the shape buys is a strict parser — every value that survives is a bounded scalar, and a bounded scalar cannot carry a payload.
The schema is a UNION of every shape musterd has ever emitted, not a mirror of the current struct. Issue #9 carries claudeCode.{pinned,drift}; internal/server/issue.go now emits claudeCode.{floor,verified,status}. Rows for shapes no longer emitted are marked Retired so a future tidy-up cannot quietly delete them and start dropping fields on every old issue. internal/server/issue_snapshot_drift_test.go asserts the other direction: every path the server can emit has a row here.
An ordered slice rather than a map, because RenderSnapshotTable must be deterministic.
var TripwirePhrases = []string{
"ignore previous instructions",
"ignore all previous",
"ignore prior instructions",
"disregard the above",
"disregard previous",
"disregard all prior",
"forget everything above",
"new instructions",
"system prompt",
"you are now",
"act as though",
"override your instructions",
}
TripwirePhrases are strings that essentially never appear in an honest bug report but are the opening move of a careless injection.
Precision is excellent and recall is terrible: paraphrase, translation or encoding walks straight past this list. It is a tripwire, not a filter, and nothing in this package depends on it — a hit routes an issue to a human, and a miss still lands in the facts-only path if anything else about the body was irregular.
var TrustedAssociations = map[string]bool{"OWNER": true, "MEMBER": true}
TrustedAssociations is the set that may take the normal path.
On this repo OWNER and MEMBER mean "filed from Damian's own dashboard button", so the normal path is effectively self-filed-only. Nothing may fall back to comparing the login against a name: the login is not the check, and an account can be renamed.
Functions ¶
func Apply ¶
func Apply(ctx context.Context, run RunFunc, root, repo string, decisions []Decision, arts map[int]Artifact, props map[int]Proposal) (string, error)
Apply splices every decided entry into TODO.md and makes one commit.
One commit, not one per issue: the existing pass produces a single "docs(triage): file #12 and #14 into the pre-v1 backlog", and a commit per issue would change a settled convention for no reason.
The commit subject is built from integers and constants only, so an issue title cannot reach it — which is what makes it impossible for a triage commit to carry a closing keyword and close the very issues it just filed.
func CheckEntryShape ¶
CheckEntryShape verifies a rendered entry cannot forge structure in TODO.md.
The first line is exempt: it is the house-style checkbox, which must start with "- ". Every other line has to be indented, which is what makes it part of this entry rather than a new block in the document.
func CheckRFC3339 ¶
CheckRFC3339 accepts a timestamp musterd could actually have written.
func EntryState ¶
EntryState reads the checkbox of the entry that OWNS issue n.
Entries are split on a "- [ ]" or "- [x]" at column 0 and the owning entry is the one whose own block carries the issues/N link. Reading the checkbox off any block that merely mentions the issue would attribute another item's state to it — the file is full of cross-references, and getting this wrong is how an audit reports a fix that never landed.
func ExtractSnapshotJSON ¶
ExtractSnapshotJSON finds the single fenced JSON block in a raw issue body.
Fence matching is line-based and length-aware because musterd writes a four-backtick fence (the payload itself contains three-backtick text), and Go's regexp has no backreference to match an opening fence to its own closing one.
func HasBidi ¶
HasBidi reports whether text carries a bidirectional override.
Exported so a caller can decide to hold an issue before sanitising rather than reading a rejection back out of an error — a hold is a routing outcome, not a failure.
func HasIssue ¶
HasIssue reports whether TODO.md already owns an entry for issue n.
Matching is on the issues/N URL, never a bare #N. Measured on the real file, a bare #N has two false-positive sources: "#343a4a" is a hex colour in a design note, and a cross-reference like "same seam as #4" makes #4 look triaged when it has no entry of its own. The boundary check stops issues/4 matching inside issues/42.
func IssueURL ¶
IssueURL builds the only URL an entry ever carries, from the integer GitHub asserted. Nothing model-authored reaches a URL position.
func NewNonce ¶
NewNonce mints the per-issue framing token. Random rather than derived, so an author cannot compute it from anything they can see and write a matching delimiter into their own body.
func ReadTracked ¶
ReadTracked returns TODO.md's text and the text of every tracked entry: TODO.md followed by HistoryFile when it exists. Decide "is this issue already filed" against tracked; read-modify-write TODO.md through todo. Keeping the two apart is what stops an archived issue being re-filed and what keeps the write path a single file.
func RenderEntry ¶
RenderEntry produces the TODO.md entry for an issue on the facts-only path.
Every token is a closed-set enum, an integer GitHub asserted, or one quoted substring already checked against the sanitised body. The house style is TODO.md's: the title line carries the full markdown link, and continuation lines are indented two spaces.
The guarantee that matters is structural, not textual. Markdown survives sanitisation by design, so a body can still contain a line reading "## Reported issues (pre-v1 release)"; spliced in raw that would forge a section heading and break the next run's scanner. Containing that is this function's job: no continuation line it emits begins with a markdown structural character at column 0. CheckEntryShape asserts it.
func RenderSnapshotTable ¶
RenderSnapshotTable regenerates the fact table from validated values.
The rendered "## Snapshot" table in the body is discarded rather than kept, because it duplicates this data as pre-formatted prose ("4% · 41783 / 1000000 tokens") that would need its own parsing. One validated source beats two. No value here can break the table: the token charset admits no pipe.
func ResolveRepo ¶
ResolveRepo derives the repository slug from the Go module path and refuses unless the checkout's own origin agrees.
The slug is not a fourth copy of a constant that already lives in go.mod: it is read from there. The cross-check exists because gh resolves the repo from the working directory's origin remote, so inside a worktree of a fork, or after a remote rename, the tool would triage one repository and splice issue URLs pointing at another.
func SectionNames ¶
SectionNames lists the "## " headings, skipping fenced regions.
Fence awareness is not decoration. TODO.md has no code fences today, but an entry is the one thing that can introduce one, and a "## " line inside a fence is text rather than a heading — a scanner that cannot tell them apart can be steered into splicing into a section an author invented.
func Splice ¶
Splice inserts an entry at the end of a section, preserving the blank line between entries. Appending rather than sorting is deliberate: TODO.md's "Reported issues" preamble records that open entries sit in Damian's priority order, and re-sorting would silently discard a ranking he set by hand.
A pure insertion — the result is the original with one block added, and nothing else touched. TestSpliceIsPureInsertion holds that.
func StripSnapshotRegions ¶
StripSnapshotRegions removes the machine-written regions from a raw body so the artifact carries the author's prose rather than a copy of the JSON.
Presentation only. Every region boundary here is attacker-controlled, so nothing security-relevant may depend on a match: whatever survives still goes through Sanitize like the rest of the body.
func Tripwire ¶
Tripwire reports the first listed phrase present in already-sanitised text.
Matching normalises a copy; the stored text is never altered. Note the deliberate gap: a phrase split by a zero-width character survives normalisation intact and does NOT hit here — but the zero width itself is a flag, so the issue still leaves the normal path. TestRouteZeroWidthCoversTripwireGap pins that interaction.
func WriteArtifacts ¶
WriteArtifacts writes one file per issue into dir, which the caller made with os.MkdirTemp. Permissions are tight because the bodies are quoted prompt text from a public tracker and the repo's own rule is never to leave that world-readable.
func WriteDispatch ¶
WriteDispatch records how to reach each artifact that a proposer should read. Held issues are excluded: they never reach a model at all.
func WriteIndex ¶
WriteIndex records every artifact, including its sanitised body — apply needs that to check a quote's provenance, and re-deriving it would mean re-fetching a body that may have been edited in the meantime.
Types ¶
type Artifact ¶
type Artifact struct {
Number int
CreatedAt string
UserLogin string
AuthorAssociation string
Route Path
Flags Flags
Nonce string
Title string // sanitised
Body string // sanitised
SnapshotTable string
}
Artifact is one issue after the program has finished with it: bounded, escaped, enumerated, and safe to hand to a model that holds no tools.
func (Artifact) Render ¶
Render writes the file the proposer subagent reads.
The instruction is repeated after the body as well as before it. Recency helps a little and costs nothing — but it is the weakest layer here by a distance, and nothing depends on it. The boundary is that the proposer holds no tools; this is a reminder, not a wall.
type CheckFunc ¶
CheckFunc reports whether a value is well-formed enough to keep. A false is always a drop-and-count, never an error: the point is to survive a body that is partly garbage, not to reject it wholesale.
func CheckEnum ¶
CheckEnum accepts one of a closed set. Used only where the set is genuinely stable — an enum that drifts drops valid fields.
func CheckFloatRange ¶
CheckFloatRange accepts a number within bounds. Integer JSON satisfies it.
func CheckIntRange ¶
CheckIntRange accepts an integer within bounds.
func CheckTokenSlice ¶
CheckTokenSlice bounds both the element count and each element, so a recentTypes array of ten thousand entries, or one entry a megabyte long, is dropped rather than rendered.
type Counts ¶
type Counts struct {
HTMLEscaped int
LinksStripped int
URLsDefanged int
NonASCII int
ZeroWidth int
Truncated bool
}
Counts feeds routing. Every field is a count of something removed or rewritten, so a non-zero total means the body was not plain prose.
func Sanitize ¶
Sanitize applies the six transforms in the order package doc fixes. It returns the cleaned text, what it had to do, and ErrBidi if the body must be held instead.
The result contains no '<' or '>' other than this package's own escape markers, which is what makes the artifact's nonce framing hold: an author's angle brackets have become entities by the time any marker is written.
func SanitizeTitle ¶
SanitizeTitle is Sanitize with newlines folded to spaces. A title is rendered on one line, so a newline in one is only ever an attempt to forge structure around it.
type Decision ¶
Decision pairs an issue with the section Damian chose for it. The section choice stays a human one: where an item ranks is a judgement, and the skill still asks.
type Dispatch ¶
type Dispatch struct {
Number int `json:"number"`
Path string `json:"path"`
Ack string `json:"ack"`
Route string `json:"route"`
}
Dispatch is one row of DispatchFile.
type Fetcher ¶
Fetcher reads open issues through the gh CLI. docs/conventions.md settles the choice: os/exec plus the gh CLI, never a Go GitHub library.
type Field ¶
Field is one allowlisted leaf.
func LookupField ¶
LookupField finds the row for a dotted path.
type Flags ¶
type Flags struct {
Counts
SnapshotUnknownFields int
SnapshotAmbiguous bool
Tripwire string
Bidi bool
}
Flags is everything mechanically observed about one body. Every field is derived by a program; none is a judgement.
type Issue ¶
type Issue struct {
Number int
CreatedAt string
UserLogin string
AuthorAssociation string
Title string
Body string
State string
}
Issue is the allowlisted view of a GitHub issue. Assembled by explicit copy rather than by unmarshalling a whole object and removing keys — the same discipline internal/server/issue.go applies to the outbound snapshot, for the same reason: subtraction silently admits every field added upstream later.
Every string field is attacker-controlled. Number and the association are the only values here that GitHub itself asserts.
type Kind ¶
type Kind int
Kind is the JSON shape a schema row accepts. A value arriving as the wrong kind is dropped, never coerced: coercion is where a string starts pretending to be a number.
type Limits ¶
Limits bound both ends. The output cap is not redundant: escaping is expansive, and a body of nothing but zero-width runes grows roughly eightfold under it.
type Path ¶
type Path int
Path is where an issue goes after sanitising.
const ( // PathNormal keeps today's richer prose entry. Reached only by a clean body from a // trusted association — in practice, an issue Damian filed from his own dashboard. PathNormal Path = iota // PathFactsOnly renders from enums and one quoted substring, with no model-authored // prose reaching TODO.md. PathFactsOnly // PathHeld never reaches a model at all and is reported for review. PathHeld )
type Proposal ¶
type Proposal struct {
Number int `json:"number"`
Ack string `json:"ack"`
Component string `json:"component"`
Symptom string `json:"symptom"`
ErrorString string `json:"error_string"`
SectionHint string `json:"section_hint"`
}
Proposal is everything the proposer is allowed to say about one issue.
func ValidateProposal ¶
ValidateProposal checks one proposer reply against the artifact it was given.
Every failure is a hold, never a repair. The proposer read attacker-controlled text, so its reply is attacker-influenced by construction; what makes that survivable is that nothing here is taken on trust — the ack proves which artifact it read, the enums are closed sets, and the one quoted field has to be found verbatim in the sanitised body.
type RunFunc ¶
type RunFunc func(ctx context.Context, name string, args ...string) (stdout, stderr []byte, err error)
RunFunc is the injectable subprocess seam (docs/conventions.md §Testing: "never a $PATH shim"), so every case below is unit-testable with canned JSON.
It keeps stdout and stderr apart, unlike tools/versions' CombinedOutput equivalent: gh writes progress and warnings to stderr, and merging them would splice non-JSON into the document being decoded.
type Snapshot ¶
Snapshot is the result of parsing an issue's raw JSON block.
Present is false when the body carried no block. Ambiguous is true when it carried more than one candidate: a body may contain any number of <details> blocks and fenced JSON, and taking the first match would let an author shadow the genuine snapshot with fabricated version facts — which is exactly the data that decides whether a report is still true against the current tree. More than one candidate therefore discards the snapshot entirely rather than picking a winner.
func ValidateSnapshot ¶
ValidateSnapshot parses a raw block field by field against Schema.
Version-tolerant by construction: an unknown path, a wrong JSON type, or a value that fails its format check is dropped and counted, never a reason to reject the whole block. The schema has already drifted once — issue #9 predates the current claudeCode shape — and a strict validator would have held every issue filed before it.