Skip to content
Merged
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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **`omni-dev drive lease restore <TOKEN>`** ([#1664](https://github.com/rust-works/omni-dev/issues/1664), [ADR-0080](docs/adrs/adr-0080.md) §10, Phase 4): restores a file from a backup lease's recorded content, closing the recovery gap [ADR-0077](docs/adrs/adr-0077-sheets-deletion-via-batchupdate.md) §5 admitted. `<TOKEN>` names the *backup* lease (expired or not — the expected common case) and authorises nothing itself; `restore` mints its own fresh lease internally first — Touch ID, a backup of the file's *current* state, a new ledger row — then performs the restore write through the exact same audited, fail-closed path every other leased write uses, and prints the new token so the restore is itself reversible. A binary file's byte backup restores in full, verified against its recorded SHA-256 first so a corrupted or tampered backup is never silently written back. A native document (Sheet/Doc/Slide) has no typed restore path yet — its backup is a Drive copy, and `restore` honestly reports the copy's location and stops rather than guessing; a following change adds the one typed path worth building (a deleted sheet's backup copy still contains that sheet, restorable via `spreadsheets.sheets.copyTo`). The write-permission gate still applies — a third, independent check alongside OAuth scope and the lease, never bypassed for restore. The backup lease's own row is marked `restored_at` once used (kept, never dropped); the restore is audit-logged carrying both tokens (`lease_id` the fresh one, `restored_from_lease_id` the backup one read from). See [docs/drive.md](docs/drive.md#restore).
- **`omni-dev drive lease restore <TOKEN>`** ([#1664](https://github.com/rust-works/omni-dev/issues/1664), [ADR-0080](docs/adrs/adr-0080.md) §10, Phase 4): restores a file from a backup lease's recorded content, closing the recovery gap [ADR-0077](docs/adrs/adr-0077-sheets-deletion-via-batchupdate.md) §5 admitted. `<TOKEN>` names the *backup* lease (expired or not — the expected common case) and authorises nothing itself; `restore` mints its own fresh lease internally first — Touch ID, a backup of the file's *current* state, a new ledger row — then performs the restore write through the exact same audited, fail-closed path every other leased write uses, and prints the new token so the restore is itself reversible. A binary file's byte backup restores in full, verified against its recorded SHA-256 first so a corrupted or tampered backup is never silently written back. A native document (Sheet/Doc/Slide) has no general typed restore path — its backup is a Drive copy, and for most shapes `restore` honestly reports the copy's location and stops rather than guessing; the one exception is a spreadsheet with exactly one sheet deleted since the backup, which restores that sheet automatically via `spreadsheets.sheets.copyTo`. The write-permission gate still applies — a third, independent check alongside OAuth scope and the lease, never bypassed for restore. The backup lease's own row is marked `restored_at` once used (kept, never dropped); the restore is audit-logged carrying both tokens (`lease_id` the fresh one, `restored_from_lease_id` the backup one read from). See [docs/drive.md](docs/drive.md#restore).
- **`omni-dev drive lease prune`** ([#1678](https://github.com/rust-works/omni-dev/issues/1678), [ADR-0080](docs/adrs/adr-0080.md) Consequences): bounds the lease ledger's and backup directory/folder's otherwise-unbounded growth by dropping expired rows together with the backups they point at, mirroring [`omni-dev log prune`](docs/log.md#omni-dev-log-prune)'s shape. `--older-than <DUR>` drops non-live rows whose expiry is strictly before the window; `--max-size <SIZE>` additionally drops the oldest-expiring survivors until local backup bytes total at most the budget (a Drive-copy backup counts as zero local bytes, reachable only through `--older-than`); `--dry-run` previews without deleting or modifying the ledger; at least one of `--older-than`/`--max-size` is required. A **live** (unexpired, unreleased) lease is never a removal candidate, and `--max-size` always keeps at least the most-recently-expired row's backup. A row and its backup are dropped together, never one without the other: a byte backup is deleted from local disk, a Drive-copy backup is moved to Drive Trash (recoverable for ~30 days via the Drive UI) via the new `FilesApi::trash` — the integration's first delete-adjacent capability, though still reversible — and the ledger row is dropped only once its backup has been cleared. Every removal attempt writes a best-effort `audit.jsonl` record (`verdict: "pruned"`/`"prune-failed"`). See [docs/drive.md](docs/drive.md#prune).
- **Every leased Drive write now leaves a fail-closed intent/outcome audit pair in `audit.jsonl`** ([#1664](https://github.com/rust-works/omni-dev/issues/1664), [ADR-0080](docs/adrs/adr-0080.md) §11, rest of Phase 3): before the mutating call a valid `--lease` authorises, `drive edit`, every Sheets write verb and `drive docs write` write an `fsync`ed `verdict: pending` intent record — and refuse the write if it cannot be written, the one place the ADR's fail-closed contract applies. After the call, a best-effort `allowed` record (with the post-write `version`/`modifiedTime`) or `failed` record (with the error) follows; a lease refusal writes a single `refused-no-lease`/`refused-lease-expired`/`refused-lease-wrong-file`/`refused-lease-stale` record instead. An intent record with no outcome is the signal that a write was interrupted. A record's `command` is `["drive", <operation>]` with the verb's name (`sheets-delete-sheet`, `docs-replace`, …), identical to the same write's `drivemutation` record, so `omni-dev log --audit --query 'lease_id:<token>'` lists a lease's whole history and a `command:` query joins the two files. The synced append also `fsync`s the parent directory on unix, so the first-ever record's freshly created file is durable too.
- Test builds now route audit writes per thread: a test that opted into nothing is pinned to a shared scratch file and never sees the machine's real `audit.jsonl` *or* another test's `OMNI_DEV_AUDIT_LOG_FILE`, closing a test-pollution incident (and the cross-test race it left behind) found while wiring this; `crate::test_support::AuditLogGuard` redirects a test that inspects its own records, `AuditEnvRouteGuard` opts a test of the env override itself into release-build resolution.
- **`drive lease acquire` now writes to the fail-closed audit log** ([#1664](https://github.com/rust-works/omni-dev/issues/1664), [ADR-0080](docs/adrs/adr-0080.md) §11, rest of Phase 3): one `audit.jsonl` record per attempt, regardless of outcome — `verdict: acquired`/`refused-native-document`/`denied`/`unavailable`/`failed`, plus (when acquired) the lease id, the Drive `version`/`modifiedTime` actually recorded into the ledger, the backup's location, a byte backup's SHA-256/size, and which authentication policy was satisfied. `audit.jsonl` (`RecordKind::Audit`, `omni-dev log --audit`) shipped empty in Phase 1; this is its first writer. Unlike ADR-0080 §11's write-ahead contract for a leased *write* (a later phase), this record is best-effort: acquiring mutates no Drive content, so there is no mutating call for a write-ahead record to precede, and a logging failure is warned rather than turning a genuine acquisition into a reported failure. New shared primitives in `src/request_log.rs`: `AuditOutcome` and `record_audit_event`, mirroring `DriveMutationOutcome`/`record_drive_mutation`'s existing shape.
Expand Down Expand Up @@ -70,7 +71,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **A new `sheets-delete` gate operation, never `sheets-structure`.** The same non-widening argument ADR-0075 §1 made for `sheets-structure` over `sheets-write`, one level up: every `allow: ["sheets-structure"]` rule that exists today was written when deletion was impossible, so reusing it would have retroactively granted every one of them the power to destroy data. Granting one operation does not grant the other, in either direction — a folder allowed `sheets-structure` still cannot delete anything, and a folder allowed `sheets-delete` still cannot add, rename or insert.
- **Still no interactive confirmation and no `--force`**, deletion included. ADR-0075 §3 raised and declined that pattern as "the worst possible place to introduce it" for the single most dangerous operation in the tree; this issue asked that stance be re-examined deliberately, and it is — and kept, for the same reason: introducing a prompt only here would be the exact inconsistency ADR-0075 warned about. The permission gate plus an honest `--dry-run` remain the whole consent mechanism, as for every other write in this tool.
- **`--dry-run` stays structural-only** — no extra `values.get` read, no cell content in its output or the request log, exactly like the additive verbs' preview. In place of a content preview, every destructive preview and every real outcome states a fixed caveat instead: formulas elsewhere in the workbook that reference what would be deleted may break, and this cannot be checked from the target sheet's own dimensions alone. Naming that limit honestly is judged better than either ignoring it or paying for a read that still couldn't answer it completely.
- **There is still no `files.delete` or undo anywhere in this integration**, so every real (non-`--dry-run`) delete says how to recover. Since the `--lease` requirement landed on these verbs ([ADR-0080](docs/adrs/adr-0080.md) §9, below), that message names the lease's own backup — the primary recovery path, superseding ADR-0077 §5's "no recovery path but Drive's own version history": `this cannot be undone through omni-dev — the lease this write required backed the whole spreadsheet up when it was acquired (Drive copy <ID>); run \`omni-dev drive lease restore <TOKEN>\` to locate it, restore from that copy in the Drive UI, or fall back to Google Drive's own version history`. The copy is read off the ledger record the write was checked against, never assumed — so it is dated honestly (a multi-use lease may have written since acquisition) and a `require_lease: false` folder, which takes no backup, gets ADR-0077's original "version history is the only recovery path" wording instead of a claim about a copy that does not exist. The `--output json` outcome carries the same copy as an additive `backup` field, omitted when none was taken. `drive lease restore` is [ADR-0080](docs/adrs/adr-0080.md) §10, Phase 4 (below): for a native document it only locates the copy today, restoring it back into the live document still done by hand in the Drive UI.
- **There is still no `files.delete` in this integration**, so every real (non-`--dry-run`) delete says how to recover. Since the `--lease` requirement landed on these verbs ([ADR-0080](docs/adrs/adr-0080.md) §9, below), that message names the lease's own backup — the primary recovery path, superseding ADR-0077 §5's "no recovery path but Drive's own version history": `this cannot be undone through omni-dev — the lease this write required backed the whole spreadsheet up when it was acquired (Drive copy <ID>); run \`omni-dev drive lease restore <TOKEN>\` to locate it, restore from that copy in the Drive UI, or fall back to Google Drive's own version history`. The copy is read off the ledger record the write was checked against, never assumed — so it is dated honestly (a multi-use lease may have written since acquisition) and a `require_lease: false` folder, which takes no backup, gets ADR-0077's original "version history is the only recovery path" wording instead of a claim about a copy that does not exist. The `--output json` outcome carries the same copy as an additive `backup` field, omitted when none was taken. `drive lease restore` is [ADR-0080](docs/adrs/adr-0080.md) §10, Phase 4 (below): for a spreadsheet with exactly one sheet deleted it now restores that sheet automatically via `spreadsheets.sheets.copyTo`; for every other native-document shape it only locates the copy today, restoring it back into the live document still done by hand in the Drive UI.
- Bounds validation mirrors the insert verbs, inverted: `--at`/`--count` on `delete-rows`/`delete-columns` use the same 1-based-inclusive-to-zero-based-half-open conversion as `insert-rows`/`insert-columns`, but deletion has no append-boundary case — every row/column named must already exist. `delete-range` takes all four bounds (`--start-row`/`--end-row`/`--start-column`/`--end-column`) together as a rectangle, plus a required `--shift rows|columns` naming which way remaining cells close the gap, matching the Sheets API's own `shiftDimension` field.
- The `no_destructive_request_is_reachable` grep-guard test from ADR-0075 §4 is gone — it asserted an absence that is no longer true. What replaces it pins a presence property instead: every delete verb is proven to route through `sheets-delete`, never `sheets-structure`, and a rule granting one operation is proven end to end not to permit the other.
- Every attempt records one `kind: "drivemutation"` entry per verb (`sheets-delete-sheet`/`sheets-delete-rows`/`sheets-delete-columns`/`sheets-delete-range`), reusing the existing `sheet_id`/`sheet_title`/`dimension_range` context fields plus one new one, `grid_range`, for `delete-range`'s rectangle — `dimension_range` only ever spans one axis, so a rectangle needed its own key.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ A local, append-only NDJSON log (`log.jsonl`) of every invocation **and** the HT
- `src/cli/log.rs` + `src/cli/log/{format,query,stream,prune}.rs` — the `omni-dev log` command: with no subcommand, the read-only search (the filter matrix, the `--query` mini-language — AND/OR/NOT, `field:value`, fuzzy tokens; `oneline`/`json`/`full` renderers where `json` is byte-identical to the on-disk lines; the streaming reader with a `--limit` ring buffer and `-f/--follow`); the `prune` subcommand (`--older-than`/`--max-size`/`--dry-run`) bounds on-disk growth via `request_log::prune`.
- The invocation record is emitted by the [`main.rs`](src/main.rs) shell around `cli.execute()`; HTTP records are emitted from one hook per transport method (Atlassian, Datadog, Snowflake `send_once`, the Claude/AI backends + `claude-cli`, and the browser bridge `dispatch`/`start_stream`). The MCP server's hand-written `call_tool` scopes the task-local context to `source=mcp` per tool call.

**No secret is ever written:** auth headers are redacted centrally and request/response bodies are opt-in via `OMNI_DEV_LOG_BODIES` (headers via `OMNI_DEV_LOG_HEADERS`); `OMNI_DEV_LOG_DISABLE` is an absolute opt-out. **No new trust boundary** — the log is local-machine state with the same `0700`/`0600` posture as other runtime state. Keep the operator guide [docs/log.md](docs/log.md) in sync, and run the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill on any CLI-surface change (see Code Changes §5).
**No secret is ever written:** auth headers are redacted centrally and request/response bodies are opt-in via `OMNI_DEV_LOG_BODIES` (headers via `OMNI_DEV_LOG_HEADERS`); `OMNI_DEV_LOG_DISABLE` is an absolute opt-out for `log.jsonl` only — the fail-closed audit sink (`audit.jsonl`, `record_audit`) is a separate, forensic log deliberately exempt from it and from rotation/pruning; see [docs/log.md](docs/log.md#audit-log). **No new trust boundary** — the log is local-machine state with the same `0700`/`0600` posture as other runtime state. Keep the operator guide [docs/log.md](docs/log.md) in sync, and run the [`update-snapshots`](.claude/skills/update-snapshots/SKILL.md) skill on any CLI-surface change (see Code Changes §5).

### Skill Structure
Claude skills are organized in `.claude/skills/`, one subdirectory per skill with a `SKILL.md` file.
Expand Down
22 changes: 13 additions & 9 deletions docs/drive.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ search/read/dedupe; rename/move need the opt-in `drive.metadata` scope
covers `files.update` on `name`/`parents` only, with no file-content access
at all. Content mutation needs a broader grant still: `--write-file`
(`drive.file`, app-created files only) or `--write-full` (the unrestricted
`drive` scope, needed to edit any pre-existing file). There is still no
trash/share/permission-mutation capability anywhere in this surface.
`drive` scope, needed to edit any pre-existing file). `drive lease prune`
has a trash capability now ([`FilesApi::trash`](#lease), see
[Prune](#prune)); share/permission-mutation is still absent anywhere in
this surface.

**Move is security-gated.** Moving a file can change who can see it — Drive
resolves a file's effective visibility from both direct permissions on the
Expand Down Expand Up @@ -1654,9 +1656,10 @@ omni-dev drive sheets delete-range <ID> --sheet Q2 \
--start-row 2 --end-row 4 --start-column 2 --end-column 3 --shift rows
```

Every real (non-`--dry-run`) delete says how to recover, since there is no
`files.delete` or undo anywhere in this integration. The `--lease` these
verbs require ([ADR-0080](adrs/adr-0080.md) §9) backed the whole
Every real (non-`--dry-run`) delete says how to recover — there is still
no `files.delete` in this integration, and `drive lease restore` (below)
is only a partial undo. The `--lease` these verbs require
([ADR-0080](adrs/adr-0080.md) §9) backed the whole
spreadsheet up as a Drive copy when it was acquired, so that copy — named
by its file id, not Drive's own version history — is the primary recovery
path:
Expand All @@ -1681,10 +1684,11 @@ recovery path`` rather than pointing at a copy that does not exist. The
`--output json` outcome carries the same copy as a `backup` field
(`{"kind": "drive_copy", "file_id": …}`), omitted when none was taken.
`drive lease restore` ([ADR-0080](adrs/adr-0080.md) §10) is named here for
what it actually does today: for a native document like this one, it only
*locates* the copy (no typed sheet-restore path exists yet, see
[Restore](#restore) below) — restoring it into the live spreadsheet is
still a manual Drive-UI copy-back.
what it actually does today: deleting exactly one sheet is the one typed
path it restores automatically, via `spreadsheets.sheets.copyTo` (see
[Restore](#restore) below) — every other shape here (multiple sheets,
rows, columns or a range) it only *locates* the copy for, restoring it
into the live spreadsheet is still a manual Drive-UI copy-back.

The same bounds-checking as `insert-rows`/`insert-columns` applies, inverted:
`--at`/`--count` (or the range bounds) must name rows/columns/cells that
Expand Down
4 changes: 3 additions & 1 deletion src/request_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
//!
//! - **Best effort.** [`record`] swallows every error (logging only at
//! `tracing::debug`); a logging failure can never change the program's exit
//! code. Honors `OMNI_DEV_LOG_DISABLE=1` for an absolute opt-out.
//! code. Honors `OMNI_DEV_LOG_DISABLE=1` for an absolute opt-out of this
//! log only — [`record_audit`]'s fail-closed sink is deliberately exempt
//! (see its own doc comment).
//! - **No secrets.** Auth headers/tokens are never written; only a non-secret
//! `auth_principal` identity is kept. Headers are redacted centrally
//! ([`redact_headers`]), secret-bearing URL query/fragment parameter values
Expand Down
Loading