git:20260612.d606677 to git:20260723.a8460cb

24 added, 0 removed. Audit A to A.

# Log Export — Workspace Allowlist Rules
`POST /v1/export` (handled by `log-export-routes.ts`) builds a tar.gz archive
from audit DB rows, daemon logs under `<workspace>/data/logs/`, and a
sanitized `config.json` snapshot. This directory
(`assistant/src/runtime/routes/log-export/`) houses the allowlist module
that governs which subpaths of the user's workspace directory
(`$VELLUM_WORKSPACE_DIR/`) are permitted to flow into that archive.
Workspace contents are **opt-in (allowlist), not opt-out**. The workspace
contains arbitrary user files — skills, hooks, routes, conversations,
credentials scaffolding, and other material the user has authored or
installed locally. Accidentally bundling any of that into a support
archive would exfiltrate data the user never intended to share. The
default must therefore be "nothing from the workspace ships" and each
individual entry that _does_ ship must be justified against the rules
below.
## Rule 1 — Prefer time-filterable data
Only allowlist a workspace subpath if its contents can be narrowed to the
`[startTime, endTime]` window carried on the export request.
- When the data is organized as per-record files or per-record
directories whose **names encode a timestamp**, filter by parsing the
name. The canonical example is the per-conversation directory layout
where each directory is named `<ISO-with-dashes>_<conversationId>`
(the ISO date comes first so ordinary lexicographic comparison yields
chronological order, and colons in the ISO string are replaced with
`-` so the name is filesystem-safe). A time filter can be implemented
by parsing the prefix and comparing it to `startTime` / `endTime`
without reading file contents.
- When the relevant time information lives **only inside files**, the
allowlist entry should err on the side of **not** being included —
unless the file is small, rarely changes, and its full contents are
acceptable to ship regardless of the requested window.
## Rule 2 — Prefer conversation-filterable data
When the export request carries a `conversationId`, every allowlisted
subpath should narrow itself to that conversation **if at all possible**.
- Data that is intrinsically global (i.e. not associated with a single
conversation) is acceptable to include **only** when Rule 1 alone is
sufficient and the request has no `conversationId` filter.
- When a `conversationId` _is_ set and an entry cannot be scoped to it,
prefer omitting the entry for that particular export rather than
shipping unrelated conversation data.
The `<ISO-with-dashes>_<conversationId>` directory naming is again the
motivating example: the suffix lets us select exactly one
per-conversation directory without scanning file contents.
## Rule 3 — Default deny
Anything in the workspace that is not explicitly added to the allowlist
module must remain excluded from the export archive. Adding a new entry
requires, in the same PR:
1. Updating the allowlist module in this directory to teach it about the
new subpath (including its time filter, conversation filter, and
size cap).
2. Updating this `AGENTS.md` to record the entry name, which filters it
honors, and its size cap under `## Allowlisted entries`.
Review must confirm both updates landed together. A workspace subpath
that is not mentioned in the registry below is, by definition, not
allowed in the export archive.
## Rule 4 — Bounded size
Every allowlisted entry must enforce a byte cap so that a misbehaving
workspace (e.g. a runaway log, a giant attachment, a pathological skill)
cannot blow up the archive and defeat the export endpoint.
The current convention is **10 MB** across the workspace allowlist,
mirroring `MAX_LOG_PAYLOAD_BYTES` in `log-export-routes.ts`. Entries
should track the number of bytes already consumed and stop adding files
once the cap would be exceeded, preferring to include the newest /
most-relevant records first.
## Allowlisted entries
- **`conversations/`**
- **Path**: `<workspace>/conversations/<ISO-with-dashes>_<conversationId>/`
- **Honors filters**: time (union of parsed `createdAt` prefix _and_
per-message `ts` inside `messages.jsonl`) and `conversationId`
(exact match on the directory-name suffix — no substring matching).
- **Time semantics**: A conversation directory is included if EITHER
its `createdAt` (parsed from the directory name) falls in the
requested window OR `messages.jsonl` contains at least one message
whose `ts` falls in the window. The cheap directory-name check runs
first; the per-message scan only runs as a fallback when the cheap
check failed, so the common in-window case stays IO-free. This is
the "support bundle" union — false positives are cheaper than false
negatives because the user almost always wants the conversations
they were _active in_ during the window, not just the ones they
_started_ during it.
- **Cap**: shares the 10 MB workspace cap defined by
`MAX_WORKSPACE_PAYLOAD_BYTES` in `workspace-allowlist.ts`.
- **Notes**: Directory names that don't match the canonical
`<ISO-with-dashes>_<conversationId>` format are silently skipped
(Rule 3 — default deny). Legacy `<id>_<ISO>` directories are
intentionally excluded until they migrate to the canonical format.
+ ## Derived metadata (not file contents)
+
+ The rules above govern shipping workspace **file contents**. A handful of
+ export entries instead ship a **derived metadata summary** the handler
+ assembles directly (`log-export-routes.ts`), never a file body — the same
+ category as `config-snapshot.json` (a redacted `config.json` derivative)
+ and `clients-list.json` (runtime state). These are outside the allowlist
+ regime because they emit no workspace bytes.
+
+ - **`installed-inventory.json`** (`installed-inventory.ts`): one row per
+ installed skill and plugin with its **name, `lastUpdated` date, and
+ content fingerprint** (`v2:<sha256>`), plus source / state / version /
+ disabled flags. Answers "what was installed, at what version" from a
+ bundle alone — otherwise unrecoverable. Every field is **read from the
+ already-persisted `install-meta.json` sidecars** (`installedAt` +
+ `contentHash`) — nothing is walked or re-hashed at export time — so the
+ fingerprint is the install-time identity (post-install in-place drift is
+ `plugins diff`'s job, not this snapshot's). Copies no file body. A section
+ that fails to enumerate is recorded under `errors` in the JSON (its array
+ left empty) so "collection failed" is never mistaken for "nothing
+ installed"; a hard failure of the whole collector still falls back to
+ `installed-inventory-error.json`. Carries no time/conversation filter —
+ the inventory is a point-in-time snapshot, not per-turn data.
+