Repository navigation
Keep a local security event log (AI consent, MCP, policy, updates, refused signatures) with an opt-out, for the CRA #456
Description
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):
- Cloud AI consent given / withdrawn:
accept_cloud_ai_consentandrevoke_cloud_ai_consentinapps/desktop/src-tauri/src/ai/cloud_consent.rs. Fields: the transition only. - MCP server turned on / off:
set_mcp_enabledinapps/desktop/src-tauri/src/commands/mcp.rs(the user toggle), plus the launch-time start inmcp/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. - MCP request rejected for a missing or wrong token: the
Errpath ofvalidate_tokeninapps/desktop/src-tauri/src/mcp/auth.rs(and thevalidate_originrefusal 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. - Organization policy applied or changed:
apply_changeinapps/desktop/src-tauri/src/managed_policy/cache.rs(fires on every change after launch), plus the first read atinitwhen a policy is present. Fields: which policy KEYS are set before and after (the public key names frommanaged_policy/keys.rs), never their values (an allowlist value can name an internal host). - Update installed: the success path of
install_updateinapps/desktop/src-tauri/src/updater/mod.rs. Fields: from-version, to-version. - Update refused by policy: the refusal in
staged_to_install(same file). Fields: the version and the typed refusal reason. - Update signature didn't verify: where
signature::verifymaps toUpdateDownloadError::SignatureMismatchinupdater/mod.rs. Fields: the offered version. Not thedetailstring. - SMB reply with a bad signature refused: today the
smb2crate rejects it inclient/connection.rs(the sub-frame verify) andclient/session.rs, but returns a genericError::InvalidData, so Cmdr can't tell it apart without matching the message (which AGENTS.md forbids). Prerequisite: add a typed kind insmb2(for exampleErrorKind::SignatureInvalid), then map it incrates/cmdr-smb/src/errors.rsandvolume/mapping.rs. Fields: the volume id. Record per connection episode, like the MCP rejections. - 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
SecurityEventis a Rust enum with#[serde(tag = "kind", rename_all = "snake_case")]. Each variant carries only typed fields (enums, versions, counts, ids). Thekindtoken is part of the file format: renaming a token is a format change, renaming a variant is free (same rule asoperation_log/types.rs).- ❌ No free-text
messagefield, and no field that takes aStringbuilt from an error'sDisplay. 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', inapps/desktop/src/lib/settings/definitions/updates-privacy.ts, in a new "Security events" card inUpdatesSection.svelte(Settings › Updates & privacy). mcpSettable: false. An MCP client must not be able to turn off the record of what MCP clients do. (offOnlyis 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
AtomicBoolthatrecordchecks first. Followdocs/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_wipethat names the file explicitly, so moving it out of the data dir later can't silently escape the wipe.
Acceptance criteria
- 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 typedkind. - No event carries a token, key, password, file name, file contents, or free-text error message.
- With the setting off, nothing is appended except the single "recording turned off" line at the moment of switching.
- The file rotates at the size cap and never grows past two files.
- Recording never blocks or fails the action it records: a write error logs a warning and drops that one event.
- "Show in Finder" reveals the file (or its folder when it doesn't exist yet).
- "Delete all Cmdr data" removes it, and nothing writes it again before quit.
- The security log isn't included in error reports.
- Docs: a
security_log/CLAUDE.md+DETAILS.mdpair, a line indocs/architecture.md,docs/security.mdgains a "Security event log" section next to "Operation log", and the/trustpage and privacy policy mention the new local file.
Test plan (TDD: these first, see them fail)
- Schema round-trip: every
SecurityEventvariant serializes to its stablekindtoken and back. A snapshot of one line per variant pins the format. - 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). - 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(). - Opt-out: with the flag off,
recordwrites nothing; switching off writes exactly the "turned off" line. - Episode coalescing for MCP token rejections: N rejections within the window produce one "started" and one "ended with count N" event.
- Hook points: one test per source that the action emits its event, using a capturing sink installed per test (the operation log's
TestJournalGuardpattern).managed_policy::cache::override_for_testalready exists for the policy one. - smb2: a frame with a wrong signature yields the new typed kind (in the
smb2repo), andcmdr-smbmaps it. - Data wipe: the plan includes the file; an exit-time event after a wipe doesn't recreate it.
- Frontend: the registry entry renders in the Updates & privacy section,
mcpSettableis 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 inapps/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:
smb2repo (crates/smb2/src/error.rs,client/connection.rs,client/session.rs), thencrates/cmdr-smb/src/errors.rsandvolume/mapping.rs. - Settings:
apps/desktop/src/lib/settings/definitions/updates-privacy.ts,types.ts,settings-applier.ts,sections/UpdatesSection.svelte(+UpdatesSection.rows.tsfor the button's searchable row). - Wipe:
data_wipe/plan.rstests,data_wipe/CLAUDE.mdif 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.
-
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.
-
Turning recording off keeps the existing file. "Delete all Cmdr data" removes it.
-
Retention: size cap only, no age limit.
-
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/trustdeployment 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. -
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.
-
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
vdavid commentedon Oct 16, 2026
The notification side is now #457 ("Tell the user about rejected access attempts ..."). It reuses this issue's
SecurityEventenum 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 typedsmb2error kinds for a bad signature and a refused guest session are a prerequisite for both.feat(security): Cmdr keeps a local security event log with an opt-out…
feat(smb): a server reply refused for its signature reaches the secur…
i18n: the "Security events" card, its "Record security events" switch…
feat(security): Cmdr tells the person, in the moment, when it turns a…
fix(security-log): Linux builds compile again: the policy-refusal re-…