@tpsdev-ai/flair 0.44.13 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config.yaml CHANGED
@@ -16,9 +16,42 @@ authentication:
16
16
  clientId: ${OAUTH_GITHUB_CLIENT_ID}
17
17
  clientSecret: ${OAUTH_GITHUB_CLIENT_SECRET}
18
18
  mcp:
19
- enabled: false
19
+ # WHOLE-TOKEN env reference — the same FLAIR_MCP_OAUTH flag flair's
20
+ # in-process /mcp route gates on (resources/mcp-oauth-flag.ts). The on/off
21
+ # choice lives in the instance ENVIRONMENT, not in this packed file, so a
22
+ # re-packed deploy can no longer revert an operator's enablement
23
+ # (flair#1152). Requires @harperfast/oauth >= 2.5.0 (resolved-version
24
+ # assertion + behavioral gate: test/integration/mcp-oauth-boot-safety.test.ts).
25
+ #
26
+ # ASYMMETRY (load-bearing, measured on oauth 2.5.0): the two readers of
27
+ # this flag accept DIFFERENT vocabularies. The component's
28
+ # coerceConfigBoolean accepts ONLY "true"/"false" and DELETES any other
29
+ # string (unresolved placeholder, "1", "yes", garbage) so its disabled
30
+ # default applies. flair's mcpOAuthEnabled() accepts 1/true/yes/on.
31
+ #
32
+ # Failure modes (oauth 2.5.0):
33
+ # unset -> placeholder deleted -> both sides OFF, clean boot
34
+ # true -> BOTH sides ON — the one working enable value
35
+ # (`flair mcp enable` stages exactly this)
36
+ # false -> both sides OFF
37
+ # 1 / yes / on -> flair /mcp handler ON, component AS OFF ->
38
+ # fail-closed broken-on: every /mcp request 401s,
39
+ # no AS is advertised. Use "true" instead.
40
+ # garbage (maybe) -> deleted -> component OFF; flair strict -> OFF.
41
+ # Inert: no /mcp handler, no data path (flair's own
42
+ # discovery documents still serve, by design).
43
+ # On oauth <2.5.0 there is NO normalization: an unresolved placeholder is
44
+ # a truthy string (fail-open) — the version assertion exists for that.
45
+ # If the component's `enabled` vocabulary changes, or it ever drives
46
+ # flair's handler registration directly, re-derive this table first.
47
+ enabled: ${FLAIR_MCP_OAUTH}
20
48
  issuer: ${FLAIR_MCP_ISSUER}
21
- resource: ${FLAIR_MCP_ISSUER}/mcp
49
+ # No `resource:` key ON PURPOSE (flair#1180): when absent, the component
50
+ # derives `<issuer>/mcp` at request time (resolveResource) — identical to
51
+ # flair's in-process derivation. A composite like ${FLAIR_MCP_ISSUER}/mcp
52
+ # NEVER interpolates (env expansion is whole-token-only) and fails every
53
+ # connect with invalid_target. Escape hatch: an operator needing a
54
+ # non-standard resource sets an explicit LITERAL absolute URL here.
22
55
  accessTokenTtl: 900
23
56
  dynamicClientRegistration:
24
57
  enabled: false
package/dist/cli.js CHANGED
@@ -23,8 +23,8 @@ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemi
23
23
  import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
24
24
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
25
25
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
26
- import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
27
- import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
26
+ import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
27
+ import { installHook, uninstallHook, hookStatus, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
28
28
  import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
29
29
  import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
30
30
  import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
@@ -1604,6 +1604,164 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
1604
1604
  }
1605
1605
  }
1606
1606
  }
1607
+ /**
1608
+ * Positive control for the Harper audit log (flair#970): verify that audit
1609
+ * ACTUALLY records by writing and then reading the audit trail back — never by
1610
+ * trusting the `audit: true` flag `describe_table` reports.
1611
+ *
1612
+ * Why a positive control: records applied via cluster base-copy/resync are
1613
+ * committed with audit explicitly disabled (harper Table.ts isCopyApply, filed
1614
+ * upstream as harper#2212), so a node can report audit enabled, answer
1615
+ * read_audit_log with HTTP 200, and still hold zero entries. Before this
1616
+ * check, nothing in flair declared, read, or verified audit — the canonical
1617
+ * check that cannot fire.
1618
+ *
1619
+ * Probe (modeled on verifySemanticSearch above, same skipped/degraded/ok
1620
+ * discipline):
1621
+ * 1. PUT an ephemeral probe row (id `flair-doctor-audit-probe-<uuid>`,
1622
+ * short inert marker content — ephemeral durability so a failed cleanup
1623
+ * self-prunes; the DELETE in `finally` is best-effort).
1624
+ * 2. PATCH it — verified live on harper@5.2.0: PATCH /Memory/<id> returns
1625
+ * 204 and generates an audit entry with operation "patch" (PUT generates
1626
+ * "upsert", DELETE "delete").
1627
+ * 3. `read_audit_log` (search_type hash_value, the probe id) over the ops
1628
+ * API with Basic admin auth — the operations API only exists on its own
1629
+ * port/socket, so the agent's Ed25519 header cannot authenticate it.
1630
+ * 4. Assert BOTH write entries are present. The probe's own DELETE lands
1631
+ * AFTER the read, so it is never required (audit is append-only; the
1632
+ * probe's entries persisting after the row is gone is by design).
1633
+ *
1634
+ * All assertions are BOOLEAN (entry counts only). Audit entries carry full
1635
+ * record images (`records: [value]`), so no entry content is ever copied into
1636
+ * a result detail — the detail strings are fixed text plus counts/statuses.
1637
+ */
1638
+ export async function verifyAuditLog(baseUrl, agentIdOpt, keysDir, opsUrl, adminUser, adminPass) {
1639
+ // Resolve an agent + key to sign the probe writes with — identical
1640
+ // resolution to verifySemanticSearch so the two probes agree on identity.
1641
+ let agentId = agentIdOpt || process.env.FLAIR_AGENT_ID || undefined;
1642
+ if (!agentId) {
1643
+ try {
1644
+ const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
1645
+ const agentKeyFile = keyFiles.find((f) => !isNodeKeyId(f.replace(/\.key$/, ""), keysDir));
1646
+ if (agentKeyFile)
1647
+ agentId = agentKeyFile.replace(/\.key$/, "");
1648
+ }
1649
+ catch { /* keysDir missing */ }
1650
+ }
1651
+ if (!agentId) {
1652
+ return { state: "skipped", reason: "no-agent", detail: "no agent id or key found" };
1653
+ }
1654
+ let keyPath = resolveKeyPath(agentId);
1655
+ if (!keyPath) {
1656
+ const candidate = join(keysDir, `${agentId}.key`);
1657
+ if (existsSync(candidate))
1658
+ keyPath = candidate;
1659
+ }
1660
+ if (!keyPath) {
1661
+ return { state: "skipped", reason: "no-key", detail: `no private key for agent '${agentId}'` };
1662
+ }
1663
+ if (!adminUser || !adminPass) {
1664
+ return {
1665
+ state: "skipped",
1666
+ reason: "no-admin-credentials",
1667
+ detail: "no admin credentials for the ops API (read_audit_log requires them)",
1668
+ };
1669
+ }
1670
+ const id = `flair-doctor-audit-probe-${randomUUID()}`;
1671
+ const path = `/Memory/${id}`;
1672
+ let stored = false;
1673
+ try {
1674
+ // Write 1: PUT the probe row. Ephemeral durability — TTL is the cleanup
1675
+ // backstop if the finally-DELETE fails.
1676
+ const putRes = await authFetch(baseUrl, agentId, keyPath, "PUT", path, {
1677
+ id,
1678
+ agentId,
1679
+ content: `flair doctor audit probe (inert marker, safe to ignore) [${id}]`,
1680
+ durability: "ephemeral",
1681
+ createdAt: new Date().toISOString(),
1682
+ });
1683
+ if (!putRes.ok && putRes.status !== 204) {
1684
+ return { state: "skipped", reason: "probe-failed", detail: `could not write probe row: HTTP ${putRes.status}` };
1685
+ }
1686
+ stored = true;
1687
+ // Write 2: PATCH — a second, distinct audit-visible write (live-verified
1688
+ // to produce its own entry on harper@5.2.0; see doc comment).
1689
+ const patchRes = await authFetch(baseUrl, agentId, keyPath, "PATCH", path, {
1690
+ content: `flair doctor audit probe (inert marker, second write) [${id}]`,
1691
+ });
1692
+ if (!patchRes.ok && patchRes.status !== 204) {
1693
+ return { state: "skipped", reason: "probe-failed", detail: `could not apply second probe write: HTTP ${patchRes.status}` };
1694
+ }
1695
+ // Read the audit trail back over the ops API.
1696
+ let auditRes;
1697
+ try {
1698
+ auditRes = await fetch(`${opsUrl.replace(/\/+$/, "")}/`, {
1699
+ method: "POST",
1700
+ headers: {
1701
+ "Content-Type": "application/json",
1702
+ Authorization: `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`,
1703
+ },
1704
+ body: JSON.stringify({
1705
+ operation: "read_audit_log",
1706
+ database: "flair",
1707
+ table: "Memory",
1708
+ search_type: "hash_value",
1709
+ search_values: [id],
1710
+ }),
1711
+ signal: AbortSignal.timeout(10_000),
1712
+ });
1713
+ }
1714
+ catch (err) {
1715
+ const message = err instanceof Error ? err.message : String(err);
1716
+ return { state: "skipped", reason: "probe-failed", detail: `ops API unreachable at ${opsUrl} (${message.slice(0, 80)})` };
1717
+ }
1718
+ if (auditRes.status === 400) {
1719
+ // harper rejects read_audit_log with HTTP 400 ("To use this operation
1720
+ // audit log must be enabled in harperdb-config.yaml") when
1721
+ // logging.auditLog is off — live-verified on harper@5.2.0.
1722
+ return { state: "degraded", cause: "disabled", detail: "read_audit_log rejected the probe: audit logging is not enabled on this instance" };
1723
+ }
1724
+ if (!auditRes.ok) {
1725
+ // 401/403/404/5xx tell us nothing about whether audit records — that is
1726
+ // "could not verify", never "verified" and never "broken".
1727
+ return { state: "skipped", reason: "probe-failed", detail: `read_audit_log failed: HTTP ${auditRes.status}` };
1728
+ }
1729
+ const body = (await auditRes.json().catch(() => null));
1730
+ // BOOLEAN classification only: count the probe's write entries. Audit
1731
+ // entries carry full record images — none of that content may reach the
1732
+ // result (and via it, doctor/init output).
1733
+ const entries = body && Array.isArray(body[id]) ? body[id] : [];
1734
+ const writeEntries = entries.filter((e) => e && typeof e === "object" && e.operation !== "delete");
1735
+ if (writeEntries.length >= 2) {
1736
+ return { state: "ok" };
1737
+ }
1738
+ // Enabled-but-empty (or partial) is the critical row: we put data in and
1739
+ // could not see it come back — the pipeline is broken, not "empty". The
1740
+ // pre-fix world silently passed this as ok.
1741
+ return {
1742
+ state: "degraded",
1743
+ cause: "not-recording",
1744
+ detail: `probe made 2 writes; read_audit_log returned ${writeEntries.length} write ${writeEntries.length === 1 ? "entry" : "entries"}`,
1745
+ };
1746
+ }
1747
+ catch (err) {
1748
+ if (err instanceof KeyLoadError) {
1749
+ return { state: "skipped", reason: "key-load", detail: err.message };
1750
+ }
1751
+ const message = err instanceof Error ? err.message : String(err);
1752
+ return { state: "skipped", reason: "probe-failed", detail: `probe error: ${message.slice(0, 100)}` };
1753
+ }
1754
+ finally {
1755
+ // Best-effort cleanup — ephemeral TTL is the backstop if this fails. The
1756
+ // DELETE itself appends one more audit entry AFTER the read, by design.
1757
+ if (stored) {
1758
+ try {
1759
+ await authFetch(baseUrl, agentId, keyPath, "DELETE", path);
1760
+ }
1761
+ catch { /* leave the ephemeral row; it'll age out */ }
1762
+ }
1763
+ }
1764
+ }
1607
1765
  // ─── Doctor: client-integration network checks (flair#588) ────────────────────
1608
1766
  //
1609
1767
  // The pure filesystem checks (MCP block parsing, CLAUDE.md, SessionStart hook)
@@ -3355,6 +3513,33 @@ program
3355
3513
  else {
3356
3514
  console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
3357
3515
  }
3516
+ // Verify the audit log ACTUALLY records (flair#970) — a positive
3517
+ // control, not a flag read: `describe_table` reports `audit: true` on
3518
+ // nodes whose audit trail is empty (base-copy elision, harper#2212).
3519
+ // Same surface as the semantic-search check above.
3520
+ console.log("Verifying audit log...");
3521
+ const auditCheck = await verifyAuditLog(httpUrl, agentId, keysDir, `http://127.0.0.1:${opsPort}`, adminUser, adminPass);
3522
+ if (auditCheck.state === "ok") {
3523
+ // Present tense ONLY: the probe proves current recording, never
3524
+ // historical completeness — see AuditVerifyResult's doc comment.
3525
+ console.log(`Audit log: recording (verified now) ✓ ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
3526
+ }
3527
+ else if (auditCheck.state === "degraded") {
3528
+ if (auditCheck.cause === "disabled") {
3529
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log DISABLED")} — ${auditCheck.detail}.`);
3530
+ console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
3531
+ }
3532
+ else {
3533
+ console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log NOT RECORDING")} — ${auditCheck.detail}.`);
3534
+ console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
3535
+ console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
3536
+ console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
3537
+ }
3538
+ }
3539
+ else {
3540
+ // An unrun check must not look like a pass.
3541
+ console.log(`${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditCheck.detail})`);
3542
+ }
3358
3543
  // Output — admin password printed once, never written to disk
3359
3544
  console.log("\n✅ Flair initialized successfully");
3360
3545
  console.log(` Agent ID: ${agentId}`);
@@ -3743,6 +3928,7 @@ agent
3743
3928
  .option("--name <name>", "Display name (defaults to id)")
3744
3929
  .option("--port <port>", "Harper HTTP port")
3745
3930
  .option("--admin-pass <pass>", "Admin password for registration")
3931
+ .option("--admin-pass-file <path>", "Read the admin password from a file (chmod 600 enforced). Preferred over inline --admin-pass — keeps the secret out of ps and shell history; works for remote targets too (an explicit flag is operator intent).")
3746
3932
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
3747
3933
  .option("--ops-port <port>", "Harper operations API port")
3748
3934
  .option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
@@ -3759,6 +3945,22 @@ agent
3759
3945
  // `flair import`: explicit --ops-target > derive from --target > localhost.
3760
3946
  const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
3761
3947
  const isRemoteTarget = typeof seedOpsTarget === "string";
3948
+ // flair#1259 — --admin-pass-file resolves into the same explicit slot the
3949
+ // inline flag uses (same shape as `flair federation sync`), read in-process
3950
+ // via readAdminPassFileSecure so the secret never appears in ps or shell
3951
+ // history. This does NOT weaken the #1085 remote guard below: an explicit
3952
+ // flag naming a file IS operator intent toward this target, exactly like an
3953
+ // explicit inline --admin-pass — what the guard blocks is the AMBIENT
3954
+ // env/local-file fallbacks silently traveling to a third-party host.
3955
+ if (!opts.adminPass && opts.adminPassFile) {
3956
+ try {
3957
+ opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
3958
+ }
3959
+ catch (err) {
3960
+ console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
3961
+ process.exit(1);
3962
+ }
3963
+ }
3762
3964
  // #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
3763
3965
  // ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
3764
3966
  // applied for a remote target — see resolveLocalAdminPass.
@@ -3772,12 +3974,13 @@ agent
3772
3974
  }
3773
3975
  if (!adminPass) {
3774
3976
  if (isRemoteTarget) {
3775
- console.error("Error: --admin-pass is required for agent add when targeting a remote instance " +
3776
- "(--target/--ops-target) — the local ~/.flair/admin-pass fallback is not used for remote targets.");
3977
+ console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add when targeting " +
3978
+ "a remote instance (--target/--ops-target) — the local ~/.flair/admin-pass and FLAIR_ADMIN_PASS " +
3979
+ "fallbacks are never used for remote targets. Prefer --admin-pass-file: it keeps the secret out of ps.");
3777
3980
  }
3778
3981
  else {
3779
- console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table). " +
3780
- "Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
3982
+ console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add (needed to insert " +
3983
+ "into Agent table). Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
3781
3984
  }
3782
3985
  process.exit(1);
3783
3986
  }
@@ -4341,6 +4544,7 @@ hook
4341
4544
  .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
4342
4545
  .option("--agent-id <id>", "Alias for --agent")
4343
4546
  .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else the existing claude-code MCP wiring, else the local default)")
4547
+ .option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
4344
4548
  .action((opts) => {
4345
4549
  const harness = requireSupportedHarness(opts.harness);
4346
4550
  const home = homedir();
@@ -4351,6 +4555,18 @@ hook
4351
4555
  }
4352
4556
  const flairUrl = resolveHookFlairUrl(opts, home);
4353
4557
  const dryRun = !!opts.dryRun;
4558
+ if (opts.continuity) {
4559
+ const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
4560
+ console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4561
+ console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
4562
+ if (result.backupPath) {
4563
+ console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
4564
+ }
4565
+ console.log("");
4566
+ if (!result.ok)
4567
+ process.exit(1);
4568
+ return;
4569
+ }
4354
4570
  const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
4355
4571
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4356
4572
  console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
@@ -4370,10 +4586,23 @@ hook
4370
4586
  .description("Remove the Flair SessionStart hook entry — only ours, everything else in the file is left untouched")
4371
4587
  .option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
4372
4588
  .option("--dry-run", "Print the exact JSON delta without writing")
4589
+ .option("--continuity", "Remove the continuity capture hooks instead (PostToolUse + Stop — flair#1257)")
4373
4590
  .action((opts) => {
4374
4591
  const harness = requireSupportedHarness(opts.harness);
4375
4592
  const home = homedir();
4376
4593
  const dryRun = !!opts.dryRun;
4594
+ if (opts.continuity) {
4595
+ const result = uninstallContinuityHooks({ homeDir: home, harness, dryRun });
4596
+ console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4597
+ console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
4598
+ if (result.backupPath) {
4599
+ console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
4600
+ }
4601
+ console.log("");
4602
+ if (!result.ok)
4603
+ process.exit(1);
4604
+ return;
4605
+ }
4377
4606
  const result = uninstallHook({ homeDir: home, harness, dryRun });
4378
4607
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
4379
4608
  console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
@@ -4396,6 +4625,22 @@ hook
4396
4625
  const harness = requireSupportedHarness(opts.harness);
4397
4626
  const home = homedir();
4398
4627
  const status = hookStatus(home, harness);
4628
+ // Continuity pair (flair#1257) — reported alongside the SessionStart
4629
+ // status in every branch below. "absent" is NOT a failure: installing the
4630
+ // pair is the opt-in, so absence renders as "not enabled".
4631
+ const renderContinuity = () => {
4632
+ const cont = continuityHookStatus(home, harness);
4633
+ if (cont.state === "installed") {
4634
+ console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
4635
+ }
4636
+ else if (cont.state === "absent") {
4637
+ console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, "(opt-in: flair hook install --continuity)")}`);
4638
+ }
4639
+ else {
4640
+ const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
4641
+ console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, "— re-run: flair hook install --continuity")}`);
4642
+ }
4643
+ };
4399
4644
  console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
4400
4645
  console.log(` ${render.wrap(render.c.dim, "Harness:")} ${status.harness}`);
4401
4646
  console.log(` ${render.wrap(render.c.dim, "Config:")} ${status.path}`);
@@ -4407,6 +4652,7 @@ hook
4407
4652
  if (!status.wired) {
4408
4653
  console.log(` ${render.icons.error} not wired`);
4409
4654
  console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
4655
+ renderContinuity();
4410
4656
  console.log("");
4411
4657
  process.exit(1);
4412
4658
  }
@@ -4421,6 +4667,7 @@ hook
4421
4667
  else {
4422
4668
  console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`flair hook install\` to adopt the silent form`);
4423
4669
  }
4670
+ renderContinuity();
4424
4671
  console.log("");
4425
4672
  });
4426
4673
  // ─── flair mcp ───────────────────────────────────────────────────────────────
@@ -6415,6 +6662,11 @@ export async function runFederationSyncOnce(opts) {
6415
6662
  return await syncRes.json();
6416
6663
  }
6417
6664
  let totalBatches = 0;
6665
+ // Memory rows that passed the since-cursor filter but were excluded as
6666
+ // private. Used only so the quiet path can distinguish "nothing since
6667
+ // the cursor" from "found rows, all withheld" (flair#1232). Does not
6668
+ // change what gets pushed — private still never leaves the instance.
6669
+ let privateHeldBack = 0;
6418
6670
  for (const table of tables) {
6419
6671
  let rows = [];
6420
6672
  for (const query of [
@@ -6448,9 +6700,11 @@ export async function runFederationSyncOnce(opts) {
6448
6700
  // filter only applies there; on the other 3 tables `row.visibility`
6449
6701
  // is always undefined, which isFederationPrivateVisibility() treats
6450
6702
  // as non-private (included) — a no-op for them.
6451
- rows = rows.concat(batch
6452
- .filter((r) => r.updatedAt !== null || r.createdAt > since)
6453
- .filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility)));
6703
+ const sinceCursor = batch.filter((r) => r.updatedAt !== null || r.createdAt > since);
6704
+ const federable = sinceCursor.filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility));
6705
+ if (table === "Memory")
6706
+ privateHeldBack += sinceCursor.length - federable.length;
6707
+ rows = rows.concat(federable);
6454
6708
  }
6455
6709
  if (rows.length === 0)
6456
6710
  continue;
@@ -6565,7 +6819,13 @@ export async function runFederationSyncOnce(opts) {
6565
6819
  catch (pingErr) {
6566
6820
  console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
6567
6821
  }
6568
- console.log("No changes since last sync.");
6822
+ // flair#1232: "No changes" is true only when nothing was found since
6823
+ // the cursor. If rows were found and every one was withheld as private,
6824
+ // say so — count and reason only, never content. A zero withheld count
6825
+ // must not invent a private-withheld story.
6826
+ console.log(privateHeldBack > 0
6827
+ ? `No federable changes since last sync (${privateHeldBack} row${privateHeldBack === 1 ? "" : "s"} held back: private visibility).`
6828
+ : "No changes since last sync.");
6569
6829
  return { pushed: 0, skipped: 0 };
6570
6830
  }
6571
6831
  console.log(`✅ Synced ${totalMerged} records (${totalSkipped} skipped) across ${totalBatches} batches`);
@@ -12285,6 +12545,60 @@ program
12285
12545
  }
12286
12546
  }
12287
12547
  }
12548
+ // 4b. Audit-log positive control (flair#970) — REAL write→read_audit_log
12549
+ // round-trip, only if Harper is responding. `describe_table` reporting
12550
+ // `audit: true` proves nothing: a node that joined or resynced via
12551
+ // cluster base copy holds zero audit history while reporting audit
12552
+ // enabled and answering read_audit_log with clean empty (harper#2212).
12553
+ // So doctor writes probe rows and asserts their audit entries come back —
12554
+ // never trusts the flag. Same ok/degraded/skipped discipline as the
12555
+ // embeddings check above: skipped is rendered UNVERIFIED, never as a pass.
12556
+ if (harperResponding) {
12557
+ // read_audit_log only exists on the ops API (its own port), which the
12558
+ // agent's Ed25519 header cannot authenticate — resolve the local admin
12559
+ // credential (env or ~/.flair/admin-pass; never prompts). A file with
12560
+ // unsafe permissions throws — that is "could not probe", not "broken".
12561
+ let auditAdminPass;
12562
+ let auditCredIssue = null;
12563
+ try {
12564
+ auditAdminPass = resolveLocalAdminPass(undefined);
12565
+ }
12566
+ catch (err) {
12567
+ auditCredIssue = err instanceof Error ? err.message : String(err);
12568
+ }
12569
+ const auditStatus = auditCredIssue
12570
+ ? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
12571
+ : await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, DEFAULT_ADMIN_USER, auditAdminPass);
12572
+ switch (auditStatus.state) {
12573
+ case "ok":
12574
+ // Present-tense claim ONLY (see AuditVerifyResult): the probe
12575
+ // proves the log records writes NOW — never that history is
12576
+ // complete. Overclaiming here would rebuild the false trust
12577
+ // anchor this check exists to kill, one layer up.
12578
+ console.log(` ${render.icons.ok} Audit log: recording (verified now) ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
12579
+ break;
12580
+ case "degraded":
12581
+ if (auditStatus.cause === "disabled") {
12582
+ console.log(` ${render.icons.error} Audit log DISABLED ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
12583
+ console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
12584
+ }
12585
+ else {
12586
+ console.log(` ${render.icons.error} Audit log NOT RECORDING ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
12587
+ console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
12588
+ console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
12589
+ console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
12590
+ }
12591
+ issues++;
12592
+ break;
12593
+ case "skipped":
12594
+ // An unrun check must not look like a pass — UNVERIFIED, visually
12595
+ // distinct from ok, but not a hard issue (mirrors the embeddings
12596
+ // skip: the operator may simply have no agent or no local admin
12597
+ // credential on this box).
12598
+ console.log(` ${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditStatus.detail})`);
12599
+ break;
12600
+ }
12601
+ }
12288
12602
  // 5. Stale PID file (skip if already reported in port check)
12289
12603
  const dataDir = defaultDataDir();
12290
12604
  const pidFile = join(dataDir, "hdb.pid");
@@ -12598,6 +12912,73 @@ program
12598
12912
  }
12599
12913
  issues++;
12600
12914
  }
12915
+ // flair#1257 slice 2 — continuity capture pair (the check-5 twin of
12916
+ // the SessionStart check above: installed / absent / stale-form).
12917
+ // Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
12918
+ // opt-in — so "absent" renders as informational "not enabled": NEVER
12919
+ // a pass (an unrun check must not look green) and never counted as an
12920
+ // issue. A partial/stale install IS an issue and is --fix-able;
12921
+ // --fix also offers first-time enablement (the y/N prompt is the
12922
+ // consent; non-TTY --fix is itself the consent signal, matching every
12923
+ // other doctor fix).
12924
+ const continuity = checkContinuityCaptureHooks(homedir());
12925
+ if (continuity.state === "installed") {
12926
+ console.log(` ${render.icons.ok} Continuity capture hooks: PostToolUse + Stop wired in ${render.wrap(render.c.dim, continuity.path)}`);
12927
+ }
12928
+ else if (continuity.state === "absent") {
12929
+ console.log(` ${render.icons.info} Continuity capture hooks: not enabled ${render.wrap(render.c.dim, "(opt-in — auto-journal working state into the ephemeral memory tier; enable: flair hook install --continuity)")}`);
12930
+ if (autoFix) {
12931
+ if (dryRun) {
12932
+ console.log(` ${render.wrap(render.c.dim, "Would wire the continuity capture hooks (PostToolUse + Stop) in")} ${continuity.path}`);
12933
+ }
12934
+ else {
12935
+ const proceed = await confirmFix(` Enable continuity capture (PostToolUse + Stop hooks in ${continuity.path})? [y/N] `);
12936
+ if (!proceed) {
12937
+ console.log(` Skipped.`);
12938
+ }
12939
+ else {
12940
+ const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
12941
+ const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId);
12942
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
12943
+ if (fixRes.ok && fixRes.changed)
12944
+ fixed++;
12945
+ }
12946
+ }
12947
+ }
12948
+ }
12949
+ else {
12950
+ const continuityDetail = continuity.state === "partial"
12951
+ ? (!continuity.postToolUse.present ? "the PostToolUse entry is missing" : "the Stop entry is missing")
12952
+ : "an entry is not the current form (unsilenced, hand-altered, or a drifted PostToolUse matcher)";
12953
+ console.log(` ${render.icons.warn} Continuity capture hooks: ${continuity.state} — ${continuityDetail}`);
12954
+ if (autoFix) {
12955
+ if (dryRun) {
12956
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the continuity capture hooks in")} ${continuity.path}`);
12957
+ }
12958
+ else {
12959
+ const proceed = await confirmFix(` Rewrite the continuity capture hooks in ${continuity.path} to the current form? [y/N] `);
12960
+ if (!proceed) {
12961
+ console.log(` Skipped.`);
12962
+ }
12963
+ else {
12964
+ const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
12965
+ // Preserve the FLAIR_URL an existing entry already carries —
12966
+ // a repair must never silently re-point the hooks at a
12967
+ // different instance.
12968
+ const existingCommand = continuity.postToolUse.command || continuity.stop.command || "";
12969
+ const existingUrl = existingCommand.match(/FLAIR_URL=(\S+)/)?.[1];
12970
+ const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId, existingUrl);
12971
+ console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
12972
+ if (fixRes.ok && fixRes.changed)
12973
+ fixed++;
12974
+ }
12975
+ }
12976
+ }
12977
+ else {
12978
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites both entries to the current form — same agent, same instance)")}`);
12979
+ }
12980
+ issues++;
12981
+ }
12601
12982
  }
12602
12983
  }
12603
12984
  // 7a. Resolve which agent identities the two verified-read sections below
@@ -13891,18 +14272,26 @@ sessionSnapshot
13891
14272
  console.error(` Pass --target <new-path> or remove the existing dir.`);
13892
14273
  process.exit(1);
13893
14274
  }
14275
+ // flair#903 — fail CLOSED on a tampered archive. node-tar's defaults below
14276
+ // do contain malicious entries (leading "/" stripped, ".." entries
14277
+ // dropped, no writing through a symlink — verified on the pinned tar
14278
+ // against all four vectors), but they discard those entries SILENTLY: the
14279
+ // restore printed the target dir as a plain success minus the parts it
14280
+ // never mentioned. Validation runs BEFORE the target directory is even
14281
+ // created — a tampered snapshot aborts the whole restore, names the
14282
+ // offending entry, and writes nothing (same posture as the data-dir
14283
+ // restore's extractSnapshotSafely). The default extract flags stay as
14284
+ // containment defense-in-depth — deliberately NOT preservePaths; if that
14285
+ // flag is ever added here, this call MUST move to extractSnapshotSafely.
14286
+ // See src/lib/safe-snapshot-extract.ts.
14287
+ try {
14288
+ await validateSnapshotArchive({ file: snapshotPath, targetDir });
14289
+ }
14290
+ catch (err) {
14291
+ console.error(`Error: ${err.message}`);
14292
+ process.exit(1);
14293
+ }
13894
14294
  mkdirSync(targetDir, { recursive: true, mode: 0o700 });
13895
- // Deliberately NOT preservePaths, and deliberately NOT routed through
13896
- // extractSnapshotSafely. --snapshot is an operator-supplied path, so
13897
- // provenance here is no more controlled than the data-dir restore's — the
13898
- // difference is the flag, not the trust. node-tar's defaults keep their
13899
- // own containment: leading "/" stripped from entry paths, ".." entries
13900
- // dropped, and no writing through a symlink (including one created
13901
- // earlier in the same archive). Verified against the pinned tar (7.5.20)
13902
- // on all four cases, each contained, with a benign control entry landing
13903
- // to prove the archives parsed. Add `preservePaths` here and that
13904
- // containment disappears — this call would then need extractSnapshotSafely,
13905
- // exactly as the data-dir restore does. See src/lib/safe-snapshot-extract.ts.
13906
14295
  await tarExtract({ file: snapshotPath, cwd: targetDir });
13907
14296
  console.log(targetDir);
13908
14297
  console.error(` extracted to: ${targetDir}`);