Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,50 @@ All notable changes to MessageFoundry are documented here. The format follows

## [Unreleased]

### Added
- **`messagefoundry audit-anchor`, and `audit-verify --expected-anchor` / `--expected-anchor-file` to
check one back.** The audit hash chain links each row to its predecessor, so deleting the *newest*
rows leaves a shorter chain that still walks cleanly — `audit-verify` on its own reports OK after a
tail-truncation, which is the shape an attacker hiding what they just did leaves behind. The store
could always compare against an external anchor; nothing exposed it, so the capability was
unreachable. `audit-anchor` prints `COUNT:HEAD` (a row count plus a digest — no PHI, no secret, safe
to hold in a ticket or an object store); passing it back reports `truncated or rewritten` when the
live chain differs.
**Know what it is before you build a job on it: an EXACT point-in-time seal**, comparing the count
*and* the head hash. The head half is not redundant — an attacker who cuts the newest rows and forges
the same number of replacements restores the count and leaves a chain that walks cleanly, so the head
is the only thing that differs. The cost of that detection is that a chain which merely **grew** also
reports `truncated or rewritten`. So it seals a chain **at rest across a gap in custody**: quiesce the
engine, anchor, hold the value off-box, re-verify while the chain is still quiesced — around a
maintenance window, a database move, a backup/restore, a hand-off. Anchoring and immediately
re-verifying compares a value to itself; re-checking a held anchor against a **running** engine alarms
on every ordinary boot. For continuous coverage of a live engine the off-box log forward / tee remains
the control, and `[integrity].audit_verify_on_start` is unchanged — it is a bare walk and stays blind
to a truncated tail. ([BACKLOG #328](docs/BACKLOG.md))

### Changed
- **A PHI instance reached through a declared reverse proxy with `[security].require_mfa` explicitly
off would refuse to start on first deployment, where it previously would not have.** The
MFA-at-exposure gate derived "is this instance exposed?" from `[api].serve_ui`, a field the ADR 0143
console degrade arms rewrite **in place** earlier in the same startup. On the topology the runbooks
recommend — a loopback bind behind a declared TLS terminator, with the web console left at its
default — the auto-degrade cleared that flag first, so the gate evaluated "not exposed" and the
refusal was unreachable, while the ASVS 11.7.1 arm in the same startup classified the identical boot
as exposed. The gate now reads a single console-independent predicate (an off-loopback bind **or**
`[api].tls_terminated_upstream`), so it also fires when the console is auto-degraded, when
`serve_web_console = false` disables it outright, and when the console package is simply not
installed: the surface authenticating with one factor is the JSON operator API, which the proxy
serves either way. The `#189` dual-control advisory reads the same predicate and gains the same reach
(still warn-only).
**Who this would bite:** a deploying site that has explicitly set `require_mfa = false` on a
PHI-carrying environment behind a declared TLS terminator, under `enforcement = enforce`. **Two
remedies, both existing:** set `[security].require_mfa = true`, or set the already-shipped
acknowledgment `[security].allow_single_factor_admin_when_exposed = true`, which downgrades the
refusal to a loud audited warning. A plain loopback bind with nothing declared is **not** exposed and
is byte-identical. An **undeclared** proxy (`web_console_public_address` set, no
`tls_terminated_upstream`) deliberately still does not refuse — exposure there would be an inference —
but it no longer passes in silence: a new warning names single-factor admin directly on a PHI instance
with `require_mfa` off. ([BACKLOG #326](docs/BACKLOG.md), [ADR 0140](docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment)
- **BREAKING — an `[[alerts.rules]]` block that routes to an unconfigured transport now refuses at
startup instead of being silently ignored.** `notifier_from_settings` returned early when **no**
transport was configured, *before* the loop that cross-checks each rule's `transports` against the
Expand Down
37 changes: 29 additions & 8 deletions docs/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,11 @@ stdout); on error it prints `{"error": "..."}`. It prints **config only, never m
## IDE gating behavior

The IDE assistant ([ide/src/chat.ts](../ide/src/chat.ts)) resolves the policy **before** every
request: it first calls `GET /ai/policy` (authoritative); on any error it falls back to the local
`messagefoundry ai-policy` CLI; if that also fails it uses a conservative built-in default
(`byo` / `code_only` / `prod`, `assist_permitted: null`) so the safe assistant still works offline.
request: it first calls `GET /ai/policy` (authoritative, and cached on success); on any error it falls
back to that cached authoritative policy, then to the local `messagefoundry ai-policy` CLI; if none of
those can positively confirm a policy it uses a fail-closed built-in default (`mode: unverified`),
which **disables** assistance rather than re-enabling BYO — a central *off* must not be bypassable by
taking the engine offline (SEC-022).

Then it applies the effective policy:

Expand All @@ -186,14 +188,33 @@ Then it applies the effective policy:
| `mode == off` | **Disabled.** "AI assistance is turned off by your MessageFoundry policy." |
| `mode == managed_claude` / `managed_claude_baa` | **Disabled.** This IDE version can't service a managed provider; it does **not** silently fall back to BYO (that would violate operator intent). |
| `mode == byo` and `assist_permitted == false` | **Disabled.** "Your role does not include the `ai:assist` permission." |
| `mode == byo` and `assist_permitted` is `true` **or** `null` | **Enabled** (proceeds as today). |
| `mode == byo` and `assist_permitted` is `true` **or** `null` | **Enabled** — *unless* an authoritative `false` was previously observed; see the sticky-deny rule below. |
| `mode == unverified` (nothing could confirm a policy) | **Disabled.** Fail-closed; see above. |

**The tokenless-IDE / `assist_permitted == null` trust note.** Under BYO, `null` (RBAC not evaluable
offline) is **allowed**. This is safe by construction: BYO sends only **code-only** context to the
developer's own provider — it never sees the engine or any message data, so there is no PHI to
protect with RBAC at this stage. The central *off* switch is still honored because `mode` is read
**The `assist_permitted == null` trust note.** Under BYO, `null` (RBAC not evaluable) is **allowed**.
This is safe by construction: BYO sends only **code-only** context to the developer's own provider —
it never sees the engine or any message data, so there is no PHI to protect with RBAC at this stage.
The central *off* switch is honored regardless, because `mode` is identity-independent and is read
straight from the policy, token or not.

**The IDE's gate read is authenticated (BACKLOG #330).** `assist_permitted` is computed from the
acting identity, so a tokenless caller can only ever be told `null` and the deny row above could never
fire. `resolveAiPolicy` therefore attaches the cached bearer — never prompting for one, and never over
plain `http://` to a non-loopback host. Two things this does **not** change: the engine endpoint stays
tokenless-*readable* (the `GET /ai/policy` section above is unchanged and still true), and the status
bar's **separate**, timer-driven read of the same route stays **tokenless** — it wants only the
identity-independent `environment`, and a bearer on that timer would keep refreshing the session's
idle clock and make the engine's 30-minute idle timeout unreachable (CWE-613).

**The sticky-deny rule (ADR 0035 AC-7).** Because `null` means "could not be evaluated" rather than
"permitted", a fresh `null` must not *upgrade* assistance a central policy switched off: an
authoritative `assist_permitted: false` the IDE has already observed is **retained** over a later
`null`, so under BYO that combination resolves to **Disabled**. The rule is deliberately one-way — a
cached `true` is *not* sticky, since fabricating a permit from stale state is the fail-open direction
— and any evaluable `true`/`false` replaces the cached value outright, so signing in is the escape
hatch. Anything that is not the literal `true`/`false`, **including a response that omits the field**,
counts as "not evaluated" and never as a permit.

`messagefoundry.showAiPolicy` (command **"MessageFoundry: Show AI Policy"**) displays the current
resolved policy in the IDE.

Expand Down
Loading
Loading