Skip to content

Keep a local security event log (AI consent, MCP, policy, updates, refused signatures) with an opt-out, for the CRA #456

Closed

Description

@vdavid

Why

The EU Cyber Resilience Act (Annex I, Part I, (2)(l)) asks a product to "provide security related information by recording and monitoring relevant internal activity, including the access to or modification of data, services or functions, with an opt-out mechanism for the user".

Where Cmdr stands today:

  • Modification of data: recorded well. The operation log journals every file mutation (apps/desktop/src-tauri/src/operation_log/CLAUDE.md).
  • Changes to security-relevant services and functions: not recorded as security information. Nothing durable says "cloud AI consent was given on this date", "the MCP server was turned on", "an organization policy started applying", "Cmdr installed version X", or "Cmdr refused a reply whose signature didn't check out". Some of it reaches the app log, but that's a rotating debug stream, not a record a person can rely on.
  • Opt-out: the operation log has retention choices but no "off", and the new record needs one too.

This issue adds a small, local, append-only security event log, with an "off" choice. The operation log's own "off" switch is #458.

Sibling issue: "Tell the user about rejected access attempts" covers the notification side (a rejected MCP token, a refused unsigned SMB reply, a failed update signature reaching the person in the UI). This issue is the record: the same events land here whether or not anyone is told. The two share event sources, so whoever builds the second one hooks into the emitter this one adds. (It wasn't filed yet when this one was; search open issues for that title.)

What to record

One typed variant per event. The hook points (verified in the code on 2026-10-16):

  1. Cloud AI consent given / withdrawn: accept_cloud_ai_consent and revoke_cloud_ai_consent in apps/desktop/src-tauri/src/ai/cloud_consent.rs. Fields: the transition only.
  2. MCP server turned on / off: set_mcp_enabled in apps/desktop/src-tauri/src/commands/mcp.rs (the user toggle), plus the launch-time start in mcp/server.rs (start_mcp_server_background) so a log that begins mid-session still says the server was running. Fields: on/off, and whether it came from the user or from launch.
  3. MCP request rejected for a missing or wrong token: the Err path of validate_token in apps/desktop/src-tauri/src/mcp/auth.rs (and the validate_origin refusal next to it). A misconfigured client retries constantly, so record per episode: the first rejection, then one summary event with a count when the episode ends (for example, no rejection for 10 minutes or a successful request). Never the token or the request body.
  4. Organization policy applied or changed: apply_change in apps/desktop/src-tauri/src/managed_policy/cache.rs (fires on every change after launch), plus the first read at init when a policy is present. Fields: which policy KEYS are set before and after (the public key names from managed_policy/keys.rs), never their values (an allowlist value can name an internal host).
  5. Update installed: the success path of install_update in apps/desktop/src-tauri/src/updater/mod.rs. Fields: from-version, to-version.
  6. Update refused by policy: the refusal in staged_to_install (same file). Fields: the version and the typed refusal reason.
  7. Update signature didn't verify: where signature::verify maps to UpdateDownloadError::SignatureMismatch in updater/mod.rs. Fields: the offered version. Not the detail string.
  8. SMB reply with a bad signature refused: today the smb2 crate rejects it in client/connection.rs (the sub-frame verify) and client/session.rs, but returns a generic Error::InvalidData, so Cmdr can't tell it apart without matching the message (which AGENTS.md forbids). Prerequisite: add a typed kind in smb2 (for example ErrorKind::SignatureInvalid), then map it in crates/cmdr-smb/src/errors.rs and volume/mapping.rs. Fields: the volume id. Record per connection episode, like the MCP rejections.
  9. Recording turned on / off: the opt-out itself. Turning it off writes one last "recording turned off" event, then stops; turning it on writes "recording turned on" first.

Out of scope here: reads of file data. The operation log covers modifications; journaling every read would be a big privacy and performance cost for little security value. Worth one line in the CRA technical file saying why.

Proposed design

Storage: a separate small file, not the operation log's DB

Recommendation: security-events.jsonl in the app data dir (config::resolved_app_data_dir), one JSON object per line, size-rotated into security-events.jsonl.1.

Why not a new table in operation-log.db:

  • The volume is tiny (a handful of events a day), and the operation log's store is built for millions of rows with a migration ladder, interning, and a 3 GB budget. A new table would be a migration step for little gain.
  • The two need independent opt-outs. Turning the operation log off shouldn't silently stop the security record, and the other way around.
  • A plain text file is readable with any editor, which is what "Show in Finder" relies on.
  • Putting it in the data dir (not the log dir) keeps it out of error-report bundles, which collect logging::log_dir() (error_reporter/bundle_builder.rs). That's deliberate: the security record stays local and never travels.

Write path: a tiny module apps/desktop/src-tauri/src/security_log/ with one record(event: SecurityEvent) free function backed by a background writer thread (the operation log's "one writer, never block the caller" rule, on a smaller scale). Reuse logging::rotating_file::RotatingFile for rotation if its interface fits (it's pub(crate)), rather than writing a second rotator.

Each line: { "v": 1, "at": "<UTC ISO 8601>", "event": { "kind": "<stable token>", ...typed fields } }.

Event schema: typed end to end

  • SecurityEvent is a Rust enum with #[serde(tag = "kind", rename_all = "snake_case")]. Each variant carries only typed fields (enums, versions, counts, ids). The kind token is part of the file format: renaming a token is a format change, renaming a variant is free (same rule as operation_log/types.rs).
  • ❌ No free-text message field, and no field that takes a String built from an error's Display. That makes "a secret slipped into the log" structurally hard, and keeps every reader (UI, tests, the sibling notification issue) matching on variants.
  • No secrets (tokens, keys, passwords), no file contents, no file names. Volume ids and version numbers are fine.

Retention

Size-capped: rotate at 1 MiB, keep one rotated file (so at most about 2 MiB, which at this event rate is years). No age setting: the opt-out plus the size cap cover it, and a second retention control would add UI for little value. (Open question 3.)

Opt-out setting

  • New registry entry, for example securityLog.enabled, type: 'boolean', default: true, component: 'switch', in apps/desktop/src/lib/settings/definitions/updates-privacy.ts, in a new "Security events" card in UpdatesSection.svelte (Settings › Updates & privacy).
  • mcpSettable: false. An MCP client must not be able to turn off the record of what MCP clients do. (offOnly is the pattern for consent answers; this one is different.)
  • The backend reads it at startup and gets changes through the settings applier, held in an AtomicBool that record checks first. Follow docs/guides/adding-a-new-setting.md.
  • Turning it off keeps the existing file (open question 2).

How a person sees it

Recommendation for v1: the "Security events" card has the switch and a "Show in Finder" button that reveals the file (reuse the reveal/ module's IPC). That's honest, cheap, and meets the requirement. An in-app viewer (a list rendered from the typed fields, localized per variant) is a good follow-up, not needed now.

"Delete all Cmdr data"

The file lives in the data dir, which the wipe already removes as a whole target (data_wipe/plan.rs). Still needed:

  • The writer must check data_wipe::has_wiped() before every write, so an exit-time event (for example "MCP server stopped" during quit) can't recreate the file after a wipe. Same rule as every other exit writer (data_wipe/CLAUDE.md).
  • A test in data_wipe that names the file explicitly, so moving it out of the data dir later can't silently escape the wipe.

Acceptance criteria

  1. Each of the nine events above appends exactly one line (or one per episode for 3 and 8) to security-events.jsonl, with a UTC timestamp and a typed kind.
  2. No event carries a token, key, password, file name, file contents, or free-text error message.
  3. With the setting off, nothing is appended except the single "recording turned off" line at the moment of switching.
  4. The file rotates at the size cap and never grows past two files.
  5. Recording never blocks or fails the action it records: a write error logs a warning and drops that one event.
  6. "Show in Finder" reveals the file (or its folder when it doesn't exist yet).
  7. "Delete all Cmdr data" removes it, and nothing writes it again before quit.
  8. The security log isn't included in error reports.
  9. Docs: a security_log/CLAUDE.md + DETAILS.md pair, a line in docs/architecture.md, docs/security.md gains a "Security event log" section next to "Operation log", and the /trust page and privacy policy mention the new local file.

Test plan (TDD: these first, see them fail)

  1. Schema round-trip: every SecurityEvent variant serializes to its stable kind token and back. A snapshot of one line per variant pins the format.
  2. No secrets by construction: a test that builds each variant and asserts the serialized line has no field outside the expected set (catches someone adding a detail: String).
  3. Writer: appends, rotates at the cap, keeps one rotated file, survives a missing data dir, drops (not panics) on a write error, and writes nothing after has_wiped().
  4. Opt-out: with the flag off, record writes nothing; switching off writes exactly the "turned off" line.
  5. Episode coalescing for MCP token rejections: N rejections within the window produce one "started" and one "ended with count N" event.
  6. Hook points: one test per source that the action emits its event, using a capturing sink installed per test (the operation log's TestJournalGuard pattern). managed_policy::cache::override_for_test already exists for the policy one.
  7. smb2: a frame with a wrong signature yields the new typed kind (in the smb2 repo), and cmdr-smb maps it.
  8. Data wipe: the plan includes the file; an exit-time event after a wipe doesn't recreate it.
  9. Frontend: the registry entry renders in the Updates & privacy section, mcpSettable is false, and the button calls the reveal command.

Files to touch

  • New: apps/desktop/src-tauri/src/security_log/ (mod.rs, types.rs, writer.rs, tests, CLAUDE.md, DETAILS.md), its IPC command in apps/desktop/src-tauri/src/commands/.
  • Hooks: ai/cloud_consent.rs, commands/mcp.rs, mcp/server.rs, mcp/auth.rs, managed_policy/cache.rs, updater/mod.rs.
  • SMB: smb2 repo (crates/smb2/src/error.rs, client/connection.rs, client/session.rs), then crates/cmdr-smb/src/errors.rs and volume/mapping.rs.
  • Settings: apps/desktop/src/lib/settings/definitions/updates-privacy.ts, types.ts, settings-applier.ts, sections/UpdatesSection.svelte (+ UpdatesSection.rows.ts for the button's searchable row).
  • Wipe: data_wipe/plan.rs tests, data_wipe/CLAUDE.md if the exit-writer list names writers.
  • Docs: listed in acceptance criterion 10.

i18n

Write English strings only, each with a good @key description. Don't translate them; the translator agent does that afterwards (docs/guides/i18n-translation.md). The log file itself isn't localized: it's typed tokens, by design.

Decisions (David, 2026-10-16)

These override the proposals above where they differ.

  1. App log: stays always on, no switch. The CRA technical file says why: it's local diagnostics that error reports are built from, and the opt-out the CRA asks for covers the security record, which this log owns.

  2. Turning recording off keeps the existing file. "Delete all Cmdr data" removes it.

  3. Retention: size cap only, no age limit.

  4. Managed policy: in scope. Add a policy key that forces the security log on (name it to match managed_policy/keys.rs; document it wherever the other keys are documented for admins, including the /trust deployment section and any example profile). When the policy is set, the switch shows as managed by the organization, like other policy-locked settings, and turning recording off isn't possible.

  5. Operation log "off": split out to Let people turn the operation log off (no undo while it's off), for the CRA #458. This issue doesn't touch the operation log.

  6. Viewing: v1 is the "Show in Finder" button. Possible later, not needed now: open the file in Cmdr's built-in viewer as plain text, scrolled to the end.

Activity

  1. vdavid commented on Oct 16, 2026

    @vdavid
    OwnerAuthor

    The notification side is now #457 ("Tell the user about rejected access attempts ..."). It reuses this issue's SecurityEvent enum and proposes one emitter that feeds both the log here and a rate-limited notice. Whichever lands second hooks into the first one's emitter. The typed smb2 error kinds for a bad signature and a refused guest session are a prerequisite for both.

  2. added
    needs-decisionWaits on a product or architecture call from David; the issue carries the question and its context
    on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-createdenhancementNew feature or requestneeds-decisionWaits on a product or architecture call from David; the issue carries the question and its context

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions