Skip to content

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) — SecretPattern enum + Scan(params string?[]).
  • src/FleetMemory.Core/InjectionScanner.cs (new) — InjectionPattern enum + Scan(params string?[]).
  • src/FleetMemory.Core/FactRecord.cs (modified) — added FactTrust enum (ClaudePromoted only, for now) and Trust property, default ClaudePromoted, 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) — Append now runs the secret scan (always, no override) then the injection scan (default reject, overridable via allowInjectionPatterns) before ever touching disk. Every writer (emit, promote, retract) goes through this one method.
  • src/FleetMemory.Cli/Program.cs (modified) — promote gained --override-injection (bool flag); when used it logs a WARNING: line to stderr naming the record id and ticket, then calls store.Append(house, allowInjectionPatterns: true). emit never reads this flag — its handler always calls the zero-arg Append overload, so an injection-shaped device-scope observation is refused unconditionally, flag or not (proven by CliEmitIgnoresTheOverrideFlagAndStillRefuses).

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: - FactStoreAppendRejectsInjectionShapedRecordByDefaultAppend 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").