FLT-87 Phase 1 — fleet-memory build report¶
Branch wt/WT-6022/FLT-87, worktree C:/Users/fives/source/repos/fleet-memory-flt87. Not pushed/merged — orchestrator reviews and lands.
Contract (this repo's slice)¶
fleet-memory (FLT-96) already had the store, gated promotion, and budgeted renderer. This slice adds
the Phase 1 SECURITY layer at the store's write choke point, plus the Phase-2-enabling trust field.
No new I/O surface, no new store files, no schema break.
Files¶
src/FleetMemory.Core/SecretScanner.cs(new) —SecretPatternenum +Scan(params string?[]).src/FleetMemory.Core/InjectionScanner.cs(new) —InjectionPatternenum +Scan(params string?[]).src/FleetMemory.Core/FactRecord.cs(modified) — addedFactTrustenum (ClaudePromotedonly, for now) andTrustproperty, defaultClaudePromoted, positioned last so all existing named-argument call sites are unaffected; missing-field JSON is backward compatible (STJ parameterized-ctor default).src/FleetMemory.Core/FactStore.cs(modified) —Appendnow runs the secret scan (always, no override) then the injection scan (default reject, overridable viaallowInjectionPatterns) before ever touching disk. Every writer (emit,promote,retract) goes through this one method.src/FleetMemory.Cli/Program.cs(modified) —promotegained--override-injection(bool flag); when used it logs aWARNING:line to stderr naming the record id and ticket, then callsstore.Append(house, allowInjectionPatterns: true).emitnever reads this flag — its handler always calls the zero-argAppendoverload, so an injection-shaped device-scope observation is refused unconditionally, flag or not (proven byCliEmitIgnoresTheOverrideFlagAndStillRefuses).
Gate output¶
dotnet build -warnaserror -> Build succeeded. 0 Warning(s). 0 Error(s).
dotnet test -> Passed! Failed: 0, Passed: 84, Skipped: 0, Total: 84
Secret-scan pattern list + test proof¶
SecretScanner.Scan checks Subject, Claim, EvidenceUri, PromotionTicket, SourceHarness — every field
FactsRenderer.Compose prints into a prime. A match on ANY field refuses the WHOLE record (no partial
redaction, no override, on any path). One [Theory] case per pattern in
tests/FleetMemory.Tests/SecretScannerTests.cs::EachPatternIsDetected, all passing:
| Pattern | Example that triggers it |
|---|---|
PrivateKeyBlock |
-----BEGIN RSA PRIVATE KEY----- |
OpenAiStyleKey |
sk-<16+ alnum> |
BearerToken |
Bearer <8+ token chars> |
GhTokenEnvName |
literal GH_TOKEN |
GitHubTokenPrefix |
ghp_/gho_/ghu_/ghs_/ghr_ + 16+ alnum |
XaiKey |
xai-<16+ alnum> |
AwsAccessKeyId |
AKIA + 12+ upper/digit |
AuthJsonPath |
literal auth.json |
ClaudeCredentialPath |
~/.claude or .credentials.json |
SshKeyMaterial |
ssh-ed25519/rsa/dss/ecdsa* <base64 20+> |
CredentialedUrl |
https://user:pass@... |
GenericLongToken |
32+ chars, token alphabet, needs ≥1 digit AND ≥1 letter (fallback net) |
Also proven: ErrorMessageNeverEchoesTheMatchedSecret — the thrown message names the PATTERN, never
the matched substring (the refusal error must not become a second egress channel for the secret it
caught). ShortContentHashIdIsNotFlagged — the store's own 16-hex-char content ids never false-positive
against the 32-char generic-token floor.
Known trade-off, called out on purpose: the generic 32+-char pattern will also flag things like a
40-char git commit SHA pasted into a claim. That is an intentional false-positive bias — reject-whole
costs a human a re-word, a missed real secret costs a fleet-wide leak. Documented in
SecretScanner's XML doc and in this report so it isn't "discovered" as a surprise in review.
Injection-pattern list + test proof¶
InjectionScanner.Scan checks Subject, Claim, EvidenceUri. Default: reject. Overridable ONLY via
promote --override-injection (never emit). One [Theory] case per pattern in
tests/FleetMemory.Tests/InjectionScannerTests.cs::EachPatternIsDetected:
| Pattern | Shape |
|---|---|
IgnorePreviousInstructions |
"ignore (all/the) (previous/prior/above) instructions/rules/prompts" |
Disregard |
"disregard (all/the) (previous/prior/above)" |
SystemRolePrefix |
field starts with system: (case-insensitive) |
ToolCallForgery |
<tool_call>, </tool_call>, <\|im_start\|>, <\|im_end\|> |
RoleHijackDirective |
"you are now ", "new instructions:" |
Deliberate anchoring choice, tested: SystemRolePrefix is anchored to the START of a field, not a
bare substring match — OperatingSystemMentionIsNotFlagged proves "operating system: Windows 11 Home"
(a completely ordinary house fact) is NOT flagged, while StartAnchoredSystemPrefixIsFlagged proves a
field that actually opens with system: IS. A bare \bsystem\s*: anywhere-in-string match would have
made this pattern too noisy to use in practice.
Override mechanics proven end-to-end:
- FactStoreAppendRejectsInjectionShapedRecordByDefault — Append throws by default.
- FactStoreAppendAdmitsInjectionShapedRecordOnlyWithExplicitOverride — same record refused with
allowInjectionPatterns: false, admitted with true.
- CliEmitIgnoresTheOverrideFlagAndStillRefuses — passing --override-injection to emit (which
has no code path that reads the flag) still results in exit 2 and nothing written.
Trust field (Phase-2-enabling, cheap)¶
Reviewed what FactRecord already carried before adding anything: source_harness/source_device
(attribution), content-hash id (identity/dedupe), supersedes + FactStatus.Retracted (retract),
promotion_ticket, device/house scope — ALL already present from FLT-96/98. Only trust was
missing, so only trust was added: FactTrust enum (ClaudePromoted today; Phase 2 adds cases, never
a migration), default ClaudePromoted, proven backward-compatible against a hand-written legacy JSONL
line with no trust property (FactTrustTests.PreExistingJsonlLineWithoutTrustFieldDeserializesToTheDefault).
What's deferred (explicitly out of scope, per the brief)¶
Write-back from any non-Claude harness, NATS/multi-writer store, auto-promotion, raw claude-mem
observation sync, the proposal/quarantine queue, anything bidirectional. FactTrust has exactly one
case — new cases and any consensus/weighting logic are Phase 2.
Known gap hit during the build (not a code defect)¶
The claude-mem worker at http://127.0.0.1:37777 was NOT reachable this session (curl → connection
refused, not the documented "wedge" hang) — fleet-memory candidates could not pull fresh candidates
to promote toward house-facts.md. The store already carries one real promoted fact (FLT-97, grok's
.grok/AGENTS.md config claim), which is what the fan-out proof (see fleet-constitution's
BUILD-REPORT) was run against. Sourcing more house facts via candidates → emit → promote --ticket
FLT-87 is real, still-needed follow-up work once the worker is back up — I did not want to hand-craft
house facts myself under a security-review gate; that curation call belongs to a human/council per
Promotion.cs's own stated rule ("unanimous council rule: auto-promotion is how the brain fills with
contradictions").