diff --git a/docs/drive.md b/docs/drive.md index cba27d5d..d7384012 100644 --- a/docs/drive.md +++ b/docs/drive.md @@ -1084,11 +1084,45 @@ Mac. and the authentication prompt for that folder — see [Write permissions](#write-permissions). -**Off-macOS and headless**, `drive lease acquire` fails closed: no -authenticator is available, so no lease can ever be acquired there, and +**Global settings** (ADR-0080 §13) let this machine's operator set defaults +for `--expiry-minutes`, `--backup-dir`, and `--biometrics-only` without +passing them on every invocation, plus the headless opt-out below. They live +in a top-level `lease` block, sibling of `drive`: + +```jsonc +{ + "lease": { + "default_expiry_minutes": 60, + "backup_dir": "/Users/alice/drive-backups", + "biometrics_only": true, + "allow_headless": false + } +} +``` + +Each also has an env var, and every setting resolves in the same order: +the CLI flag, if given, wins outright; then the env var; then the +`settings.json` field; then the built-in default. + +| Setting | Flag | Env var | Default | +|------------------|---------------------|----------------------------------------|--------------------------------------| +| Lease expiry | `--expiry-minutes` | `OMNI_DEV_DRIVE_LEASE_EXPIRY_MINUTES` | 30 | +| Backup directory | `--backup-dir` | `OMNI_DEV_DRIVE_LEASE_BACKUP_DIR` | `/omni-dev/drive-backups` | +| Auth policy | `--biometrics-only` | `OMNI_DEV_DRIVE_LEASE_BIOMETRICS_ONLY` | device-owner | +| Headless opt-out | `--allow-headless` | `OMNI_DEV_DRIVE_LEASE_ALLOW_HEADLESS` | off (fails closed) | + +For `biometrics_only`/`allow_headless`, any layer that opts in wins — there +is no way to force one back off from a lower layer once it is set. + +**Off-macOS and headless**, `drive lease acquire` fails closed by default: +no authenticator is available, so no lease can ever be acquired there, and every gated write refuses in turn. This is deliberate (ADR-0080 §8) — a TTY prompt would let a script answer on the human's behalf, defeating the -point. +point. An operator can explicitly waive this with `--allow-headless`, the +`OMNI_DEV_DRIVE_LEASE_ALLOW_HEADLESS` env var, or `lease.allow_headless` in +`settings.json` — the acquisition then proceeds with no human ever +prompted, and the resulting lease (and its audit record) is marked as +having used the waiver, so it stays visible after the fact. ### Restore diff --git a/src/cli/drive/lease.rs b/src/cli/drive/lease.rs index abaaaeb0..1b74f0a4 100644 --- a/src/cli/drive/lease.rs +++ b/src/cli/drive/lease.rs @@ -1,7 +1,7 @@ //! CLI commands for `omni-dev drive lease` — the Drive write lease //! ([ADR-0080](../../../docs/adrs/adr-0080.md)). -use anyhow::{Context, Result}; +use anyhow::Result; use clap::{Parser, Subcommand}; use crate::cli::drive::format::{output_as, OutputFormat}; @@ -13,10 +13,9 @@ use crate::drive::lease::acquire::{ use crate::drive::lease::authenticate::{self, AuthPolicy}; use crate::drive::lease::ledger::{self, LeaseBackup}; use crate::drive::lease::restore::{self, RestoreOptions, RestoreResult}; +use crate::drive::lease::settings as lease_settings; use crate::drive::sheets::client::SheetsClient; - -/// Default lease expiry when `--expiry-minutes` is not given (ADR-0080 §5). -const DEFAULT_EXPIRY_MINUTES: i64 = 30; +use crate::utils::settings::{Settings, SettingsEnv}; /// Validates `--expiry-minutes` before anything else runs — no network /// call, no Touch ID prompt — so a bad value is a clean parse error instead @@ -65,6 +64,81 @@ impl LeaseCommand { } } +/// The four global-policy flags shared verbatim by `acquire` and `restore` +/// (ADR-0080 §13, issue #1677) — flattened into both subcommands rather than +/// duplicated, since each also needs the identical resolution logic in +/// [`LeaseFlags::resolve`]. +#[derive(Parser)] +pub struct LeaseFlags { + /// Local directory byte backups are written under. Defaults to + /// `OMNI_DEV_DRIVE_LEASE_BACKUP_DIR`, then `settings.json`'s + /// `lease.backup_dir`, then `/omni-dev/drive-backups`. + #[arg(long, value_name = "PATH")] + pub backup_dir: Option, + + /// Minutes the lease stays live once authorised. A write never extends + /// this — a fresh window means a fresh `drive lease acquire` (ADR-0080 + /// §5). Defaults to `OMNI_DEV_DRIVE_LEASE_EXPIRY_MINUTES`, then + /// `settings.json`'s `lease.default_expiry_minutes`, then 30. + #[arg(long, value_name = "N", value_parser = parse_expiry_minutes)] + pub expiry_minutes: Option, + + /// Require Touch ID specifically, failing outright rather than falling + /// back to the account password (ADR-0080 §7). Needs Touch ID hardware; + /// the default policy works on any Mac. Also settable via + /// `settings.json`'s `lease.biometrics_only` or + /// `OMNI_DEV_DRIVE_LEASE_BIOMETRICS_ONLY`; any layer selecting it wins. + #[arg(long)] + pub biometrics_only: bool, + + /// Proceed even when no device-owner authenticator is available in this + /// context — off-macOS, or a macOS process with no attached GUI session + /// (ADR-0080 §8) — waiving the human-presence guarantee instead of + /// refusing outright. Also settable via `settings.json`'s + /// `lease.allow_headless` or `OMNI_DEV_DRIVE_LEASE_ALLOW_HEADLESS`; any + /// layer opting in wins. + #[arg(long)] + pub allow_headless: bool, +} + +/// What [`LeaseFlags`] resolved to, after layering the CLI flags over +/// `OMNI_DEV_DRIVE_LEASE_*`/`settings.json`/hard-coded defaults. +struct ResolvedLeaseFlags { + backup_dir: std::path::PathBuf, + expiry: chrono::Duration, + auth_policy: AuthPolicy, + allow_headless: bool, +} + +impl LeaseFlags { + fn resolve(self) -> Result { + // One disk read/parse of settings.json, not two — `SettingsEnv::load()` + // and `Settings::load_lease()` each independently re-read it; + // `SettingsEnv::from_settings` was added for exactly this (issue + // #1533), so both views come from the same parse (issue #1677 review + // finding). + let loaded = Settings::load().unwrap_or_default(); + let lease = loaded.lease.clone(); + let profile = crate::utils::settings::active_profile_from(&crate::utils::env::SystemEnv); + let env = SettingsEnv::from_settings(loaded, profile.as_deref()); + let backup_dir = lease_settings::resolve_backup_dir(self.backup_dir, &env, &lease)?; + let expiry = chrono::Duration::minutes(lease_settings::resolve_expiry_minutes( + self.expiry_minutes, + &env, + &lease, + )?); + let auth_policy = lease_settings::resolve_auth_policy(self.biometrics_only, &env, &lease); + let allow_headless = + lease_settings::resolve_allow_headless(self.allow_headless, &env, &lease); + Ok(ResolvedLeaseFlags { + backup_dir, + expiry, + auth_policy, + allow_headless, + }) + } +} + /// Backs up `FILE_ID`'s current content and mints a lease token /// (ADR-0080 §2). Refuses a Google-native document (Docs/Sheets/Slides) /// unless the active account has `lease_backup_folder_id` configured, in @@ -76,22 +150,8 @@ pub struct AcquireCommand { /// a Drive URL). pub file_id: String, - /// Local directory byte backups are written under. Defaults to - /// `/omni-dev/drive-backups`. - #[arg(long, value_name = "PATH")] - pub backup_dir: Option, - - /// Minutes the lease stays live once authorised. A write never - /// extends this — a fresh window means a fresh `drive lease acquire` - /// (ADR-0080 §5). - #[arg(long, value_name = "N", default_value_t = DEFAULT_EXPIRY_MINUTES, value_parser = parse_expiry_minutes)] - pub expiry_minutes: i64, - - /// Require Touch ID specifically, failing outright rather than - /// falling back to the account password (ADR-0080 §7). Needs Touch ID - /// hardware; the default policy works on any Mac. - #[arg(long)] - pub biometrics_only: bool, + #[command(flatten)] + pub flags: LeaseFlags, /// Output format. #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)] @@ -100,24 +160,18 @@ pub struct AcquireCommand { impl AcquireCommand { pub async fn execute(self, client: &DriveClient) -> Result<()> { - let backup_dir = match self.backup_dir { - Some(dir) => dir, - None => default_backup_dir()?, - }; + let resolved = self.flags.resolve()?; let ledger_path = ledger::ledger_path()?; let native_backup_folder_id = crate::cli::drive::helpers::active_account_lease_backup_folder_id()?; let opts = AcquireOptions { file_id: self.file_id, - backup_dir, + backup_dir: resolved.backup_dir, native_backup_folder_id, - expiry: chrono::Duration::minutes(self.expiry_minutes), - auth_policy: if self.biometrics_only { - AuthPolicy::BiometricsOnly - } else { - AuthPolicy::DeviceOwner - }, + expiry: resolved.expiry, + auth_policy: resolved.auth_policy, ledger_path, + allow_headless: resolved.allow_headless, }; let authenticator = authenticate::platform_authenticator(); let result = acquire::acquire(client, &opts, authenticator.as_ref()).await; @@ -129,15 +183,6 @@ impl AcquireCommand { } } -/// `/omni-dev/drive-backups` — a sibling of the request log and -/// lease ledger, same posture. -fn default_backup_dir() -> Result { - let base = dirs::state_dir() - .or_else(dirs::data_dir) - .context("could not resolve the state/data directory for the default backup directory")?; - Ok(base.join("omni-dev").join("drive-backups")) -} - /// Restores a file from the backup a lease recorded (ADR-0080 §10). `TOKEN` /// is the *backup* lease — it locates the backup and authorises nothing /// itself; restoring mints its own fresh lease, prompting for device-owner @@ -148,19 +193,8 @@ pub struct RestoreCommand { /// not — an expired-but-kept row is the expected common case. pub token: String, - /// Local directory the fresh lease's own byte backup is written under. - /// Defaults to `/omni-dev/drive-backups`. - #[arg(long, value_name = "PATH")] - pub backup_dir: Option, - - /// Minutes the fresh lease stays live once authorised. - #[arg(long, value_name = "N", default_value_t = DEFAULT_EXPIRY_MINUTES, value_parser = parse_expiry_minutes)] - pub expiry_minutes: i64, - - /// Require Touch ID specifically for the fresh lease, failing outright - /// rather than falling back to the account password (ADR-0080 §7). - #[arg(long)] - pub biometrics_only: bool, + #[command(flatten)] + pub flags: LeaseFlags, /// Output format. #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)] @@ -169,25 +203,19 @@ pub struct RestoreCommand { impl RestoreCommand { pub async fn execute(self, client: &DriveClient) -> Result<()> { - let backup_dir = match self.backup_dir { - Some(dir) => dir, - None => default_backup_dir()?, - }; + let resolved = self.flags.resolve()?; let ledger_path = ledger::ledger_path()?; let native_backup_folder_id = crate::cli::drive::helpers::active_account_lease_backup_folder_id()?; let rules = crate::cli::drive::helpers::active_account_rules()?; let opts = RestoreOptions { token: self.token, - backup_dir, + backup_dir: resolved.backup_dir, native_backup_folder_id, - expiry: chrono::Duration::minutes(self.expiry_minutes), - auth_policy: if self.biometrics_only { - AuthPolicy::BiometricsOnly - } else { - AuthPolicy::DeviceOwner - }, + expiry: resolved.expiry, + auth_policy: resolved.auth_policy, ledger_path, + allow_headless: resolved.allow_headless, }; let sheets = SheetsClient::from_drive_client(client)?; let authenticator = authenticate::platform_authenticator(); @@ -206,6 +234,7 @@ fn print_result(result: &AcquireResult) { token, expires_at, backup, + headless_waiver, } => { println!("{token}"); // The backup path embeds the file's Drive name (`backup_name` @@ -221,6 +250,12 @@ fn print_result(result: &AcquireResult) { } }; eprintln!("Backed up to {backup_desc} (expires {expires_at})"); + if *headless_waiver { + eprintln!( + "Warning: acquired under the headless opt-out (ADR-0080 §8) — no \ + device-owner prompt was presented for this lease." + ); + } } AcquireResult::AlreadyLeased { token, expires_at } => { println!("{token}"); @@ -248,6 +283,7 @@ fn print_restore_result(result: &RestoreResult) { new_token, expires_at, backup, + headless_waiver, } => { println!("{new_token}"); let backup_desc = match backup { @@ -262,6 +298,12 @@ fn print_restore_result(result: &RestoreResult) { "Restored. Backed up the pre-restore content to {backup_desc} (expires \ {expires_at})" ); + if *headless_waiver { + eprintln!( + "Warning: the fresh lease was minted under the headless opt-out (ADR-0080 \ + §8) — no device-owner prompt was presented for it." + ); + } } RestoreResult::RestoredSheet { new_token, @@ -270,6 +312,7 @@ fn print_restore_result(result: &RestoreResult) { spreadsheet_id, sheet_id, sheet_title, + headless_waiver, } => { println!("{new_token}"); let backup_desc = match backup { @@ -288,6 +331,12 @@ fn print_restore_result(result: &RestoreResult) { sanitize_for_terminal(sheet_title), sanitize_for_terminal(spreadsheet_id) ); + if *headless_waiver { + eprintln!( + "Warning: the fresh lease was minted under the headless opt-out (ADR-0080 \ + §8) — no device-owner prompt was presented for it." + ); + } } RestoreResult::NoSuchBackupToken => { eprintln!( @@ -435,9 +484,12 @@ mod tests { let cmd = LeaseCommand { action: LeaseAction::Acquire(AcquireCommand { file_id: "f1".to_string(), - backup_dir: None, - expiry_minutes: DEFAULT_EXPIRY_MINUTES, - biometrics_only: false, + flags: LeaseFlags { + backup_dir: None, + expiry_minutes: None, + biometrics_only: false, + allow_headless: false, + }, output: OutputFormat::Table, }), }; @@ -454,9 +506,12 @@ mod tests { let root = tempfile::tempdir().unwrap(); let cmd = AcquireCommand { file_id: "f1".to_string(), - backup_dir: Some(root.path().join("backups")), - expiry_minutes: 10, - biometrics_only: true, + flags: LeaseFlags { + backup_dir: Some(root.path().join("backups")), + expiry_minutes: Some(10), + biometrics_only: true, + allow_headless: false, + }, output: OutputFormat::Json, }; cmd.execute(&client).await.unwrap(); @@ -477,9 +532,12 @@ mod tests { let cmd = LeaseCommand { action: LeaseAction::Restore(RestoreCommand { token: "no-such-token".to_string(), - backup_dir: None, - expiry_minutes: DEFAULT_EXPIRY_MINUTES, - biometrics_only: false, + flags: LeaseFlags { + backup_dir: None, + expiry_minutes: None, + biometrics_only: false, + allow_headless: false, + }, output: OutputFormat::Table, }), }; @@ -569,9 +627,12 @@ mod tests { // gate still blocks first, so neither ever reaches use. let cmd = RestoreCommand { token: "backup-token".to_string(), - backup_dir: Some(dir.path().join("fresh-backups")), - expiry_minutes: DEFAULT_EXPIRY_MINUTES, - biometrics_only: true, + flags: LeaseFlags { + backup_dir: Some(dir.path().join("fresh-backups")), + expiry_minutes: None, + biometrics_only: true, + allow_headless: false, + }, output: OutputFormat::Json, }; cmd.execute(&client).await.unwrap(); @@ -594,6 +655,7 @@ mod tests { sha256: "deadbeef".to_string(), size: 0, }, + headless_waiver: false, }, RestoreResult::Restored { new_token: "tok-2".to_string(), @@ -601,6 +663,7 @@ mod tests { backup: LeaseBackup::DriveCopy { file_id: "copy-1".to_string(), }, + headless_waiver: true, }, RestoreResult::RestoredSheet { new_token: "tok-5".to_string(), @@ -611,6 +674,7 @@ mod tests { spreadsheet_id: "sheet-1".to_string(), sheet_id: 999, sheet_title: "Deleted".to_string(), + headless_waiver: false, }, RestoreResult::NoSuchBackupToken, RestoreResult::NoTypedRestorePath { @@ -651,7 +715,17 @@ mod tests { #[test] fn default_backup_dir_ends_with_the_expected_suffix() { - let dir = default_backup_dir().unwrap(); + // `LeaseFlags::resolve` delegates the whole chain to + // `lease_settings::resolve_backup_dir`, whose own precedence tiers + // are covered in `src/drive/lease/settings.rs` — this just confirms + // the hard-coded bottom of the chain is still what this CLI's docs + // promise. + let dir = lease_settings::resolve_backup_dir( + None, + &crate::test_support::env::MapEnv::new(), + &crate::utils::settings::LeaseSettings::default(), + ) + .unwrap(); assert!( dir.ends_with(std::path::Path::new("omni-dev").join("drive-backups")), "{}", @@ -670,6 +744,7 @@ mod tests { sha256: "deadbeef".to_string(), size: 0, }, + headless_waiver: false, }, AcquireResult::Acquired { token: "tok-2".to_string(), @@ -677,6 +752,7 @@ mod tests { backup: LeaseBackup::DriveCopy { file_id: "copy-1".to_string(), }, + headless_waiver: true, }, AcquireResult::AlreadyLeased { token: "tok-3".to_string(), @@ -735,20 +811,25 @@ mod tests { fn defaults_are_sane() { let cmd = parse(&["acquire", "file1"]); assert_eq!(cmd.file_id, "file1"); - assert!(cmd.backup_dir.is_none()); - assert_eq!(cmd.expiry_minutes, DEFAULT_EXPIRY_MINUTES); - assert!(!cmd.biometrics_only); + assert!(cmd.flags.backup_dir.is_none()); + assert!(cmd.flags.expiry_minutes.is_none()); + assert!(!cmd.flags.biometrics_only); + assert!(!cmd.flags.allow_headless); } #[test] fn expiry_minutes_boundary_values_are_accepted() { assert_eq!( - parse(&["acquire", "file1", "--expiry-minutes", "1"]).expiry_minutes, - 1 + parse(&["acquire", "file1", "--expiry-minutes", "1"]) + .flags + .expiry_minutes, + Some(1) ); assert_eq!( - parse(&["acquire", "file1", "--expiry-minutes", "1440"]).expiry_minutes, - 1440 + parse(&["acquire", "file1", "--expiry-minutes", "1440"]) + .flags + .expiry_minutes, + Some(1440) ); } @@ -806,12 +887,14 @@ mod tests { "--expiry-minutes", "10", "--biometrics-only", + "--allow-headless", ]); assert_eq!( - cmd.backup_dir, + cmd.flags.backup_dir, Some(std::path::PathBuf::from("/tmp/backups")) ); - assert_eq!(cmd.expiry_minutes, 10); - assert!(cmd.biometrics_only); + assert_eq!(cmd.flags.expiry_minutes, Some(10)); + assert!(cmd.flags.biometrics_only); + assert!(cmd.flags.allow_headless); } } diff --git a/src/drive/lease.rs b/src/drive/lease.rs index 1638f54c..c1a1cb90 100644 --- a/src/drive/lease.rs +++ b/src/drive/lease.rs @@ -7,10 +7,13 @@ //! ([`acquire`]) reads and writes; [`check`] is the shared "does this //! presented `--lease` token authorise this write" gate every //! content-mutating engine (`drive edit`, every Sheets/Docs write verb) -//! calls; [`restore`] is `drive lease restore` (ADR-0080 §10). +//! calls; [`restore`] is `drive lease restore` (ADR-0080 §10); [`settings`] +//! resolves the global CLI-flag/env-var/`settings.json` policy layer +//! (ADR-0080 §13, issue #1677). pub(crate) mod acquire; pub(crate) mod authenticate; pub(crate) mod check; pub(crate) mod ledger; pub(crate) mod restore; +pub(crate) mod settings; diff --git a/src/drive/lease/acquire.rs b/src/drive/lease/acquire.rs index 01e702d9..11f73e3c 100644 --- a/src/drive/lease/acquire.rs +++ b/src/drive/lease/acquire.rs @@ -42,6 +42,13 @@ pub struct AcquireOptions { /// pass a path under a `tempdir` so a test run never touches the real /// ledger. pub ledger_path: PathBuf, + /// The global headless/off-macOS opt-out (ADR-0080 §8/§13, issue + /// #1677): when `true`, an [`AuthOutcome::Unavailable`] outcome — no + /// authenticator exists in this context at all — is waived instead of + /// refusing the lease, and the acquisition proceeds without a human + /// ever having been prompted. Resolved by + /// `crate::drive::lease::settings::resolve_allow_headless`. + pub allow_headless: bool, } /// What happened. @@ -57,6 +64,12 @@ pub enum AcquireResult { expires_at: DateTime, /// Where the backup landed. backup: LeaseBackup, + /// `true` when this acquisition proceeded under the headless + /// opt-out (ADR-0080 §8/§13) instead of a real device-owner + /// prompt — no human presence was verified. Carried into the audit + /// record ([`record_attempt`]) so a waived acquisition is durably + /// distinguishable from a normally-authorised one. + headless_waiver: bool, }, /// A live lease already covers this file — its token is returned for /// reuse rather than minting a second, independent one. Two leases @@ -197,11 +210,21 @@ async fn acquire_inner( // `spawn_blocking`. let auth_outcome = tokio::task::block_in_place(|| authenticator.authenticate(&reason, opts.auth_policy)); - match auth_outcome { - AuthOutcome::Authorized => {} + let headless_waiver = match auth_outcome { + AuthOutcome::Authorized => false, AuthOutcome::Denied(detail) => return AcquireResult::Denied { detail }, - AuthOutcome::Unavailable(detail) => return AcquireResult::Unavailable { detail }, - } + AuthOutcome::Unavailable(detail) => { + // ADR-0080 §8/§13: an explicit, per-installation opt-out lets + // this proceed with no human ever having been prompted, rather + // than refusing outright. `headless_waiver` on the eventual + // `Acquired` result (and so the audit record) is what makes + // this waiver durably visible. + if !opts.allow_headless { + return AcquireResult::Unavailable { detail }; + } + true + } + }; // 2. Backup — bytes for a binary file, a Drive-side copy for a native // document (ADR-0080 §3). `native_backup_folder_id` is guaranteed @@ -296,6 +319,7 @@ async fn acquire_inner( token, expires_at, backup, + headless_waiver, } } @@ -439,6 +463,7 @@ fn record_attempt(opts: &AcquireOptions, result: &AcquireResult) { token, backup, expires_at: _, + headless_waiver, } => { let (backup_location, backup_sha256, backup_size) = match backup { LeaseBackup::Bytes { path, sha256, size } => ( @@ -465,7 +490,16 @@ fn record_attempt(opts: &AcquireOptions, result: &AcquireResult) { integration: "drive", file_id: opts.file_id.clone(), lease_id: Some(token.clone()), - verdict: "acquired".to_string(), + // ADR-0080 §8/§13, issue #1677: a distinct verdict, rather + // than a separate field on this shared, free-form-vocabulary + // struct (ADR-0080 §11), durably distinguishes an + // acquisition that waived the human-presence guarantee from + // a normally-authorised one. + verdict: if *headless_waiver { + "acquired-headless-waiver".to_string() + } else { + "acquired".to_string() + }, version_after, modified_time_after, backup_location, @@ -581,6 +615,7 @@ mod tests { expiry: ChronoDuration::minutes(30), auth_policy: AuthPolicy::DeviceOwner, ledger_path: dir.join("lease-ledger.jsonl"), + allow_headless: false, } } @@ -634,6 +669,54 @@ mod tests { assert!(!root.path().join("backups").exists()); } + #[tokio::test(flavor = "multi_thread")] + async fn headless_opt_out_proceeds_without_an_authenticator() { + // ADR-0080 §8/§13, issue #1677: the same `Unsupported` authenticator + // as `unavailable_authentication_takes_no_backup` above, but with + // `allow_headless: true` — this must now proceed to a real backup + // and ledger row instead of refusing, with `headless_waiver` set so + // the waiver is durably visible. + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/drive/v3/files/f1")) + .and(wiremock::matchers::query_param_is_missing("alt")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "f1", "name": "n", "mimeType": "application/pdf", "version": "1" + })), + ) + .mount(&server) + .await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/drive/v3/files/f1")) + .and(wiremock::matchers::query_param("alt", "media")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"bytes".to_vec())) + .mount(&server) + .await; + let client = client_with_bootstrapped_token(&server).await; + let root = tempfile::tempdir().unwrap(); + let _audit = AuditGuard::redirect(root.path()); + + let result = acquire( + &client, + &AcquireOptions { + allow_headless: true, + ..opts(root.path()) + }, + &Unsupported, + ) + .await; + + let AcquireResult::Acquired { + headless_waiver, .. + } = result + else { + panic!("expected Acquired, got {result:?}"); + }; + assert!(headless_waiver); + assert!(root.path().join("backups").exists()); + } + #[tokio::test] async fn get_metadata_failure_is_reported_as_failed_before_authenticating() { let server = wiremock::MockServer::start().await; @@ -806,6 +889,7 @@ mod tests { sha256: "deadbeef".to_string(), size: 0, }, + headless_waiver: false, } .write_jsonl(&mut buf) .unwrap(); diff --git a/src/drive/lease/restore.rs b/src/drive/lease/restore.rs index ecda106a..99d9417a 100644 --- a/src/drive/lease/restore.rs +++ b/src/drive/lease/restore.rs @@ -79,6 +79,10 @@ pub struct RestoreOptions { /// Path to the lease ledger — both the backup token's own row and the /// fresh lease this restore mints live here. pub ledger_path: std::path::PathBuf, + /// The global headless/off-macOS opt-out (ADR-0080 §8/§13, issue + /// #1677), forwarded verbatim into the internal fresh-lease + /// [`AcquireOptions`]'s own field of the same name. + pub allow_headless: bool, } /// What happened. @@ -95,6 +99,11 @@ pub enum RestoreResult { /// Where the fresh lease's own backup (of the file's state /// immediately before this restore) landed. backup: LeaseBackup, + /// `true` when the fresh lease was minted under the headless + /// opt-out (ADR-0080 §8/§13) rather than a real device-owner + /// prompt — see `AcquireResult::Acquired`'s field of the same + /// name. + headless_waiver: bool, }, /// A single deleted sheet was detected and restored via /// `spreadsheets.sheets.copyTo` — see the module doc. `sheet_title` is @@ -115,6 +124,11 @@ pub enum RestoreResult { sheet_id: i64, /// The restored sheet's actual resulting title. sheet_title: String, + /// `true` when the fresh lease was minted under the headless + /// opt-out (ADR-0080 §8/§13) rather than a real device-owner + /// prompt — see `AcquireResult::Acquired`'s field of the same + /// name. + headless_waiver: bool, }, /// `` names no row this ledger has ever recorded. NoSuchBackupToken, @@ -225,7 +239,19 @@ impl RestoreResult { /// vocabulary). fn verdict(&self) -> &'static str { match self { + // A distinct verdict for a headless-waived restore, mirroring + // `acquire::record_attempt`'s "acquired-headless-waiver" (ADR-0080 + // §8/§13, issue #1677) — without it, an operator auditing waived- + // authentication events could never find one here. + Self::Restored { + headless_waiver: true, + .. + } => "restored-headless-waiver", Self::Restored { .. } => "restored", + Self::RestoredSheet { + headless_waiver: true, + .. + } => "restored-sheet-headless-waiver", Self::RestoredSheet { .. } => "restored-sheet", Self::NoSuchBackupToken => "no-such-backup-token", Self::NoTypedRestorePath { .. } => "no-typed-restore-path", @@ -367,14 +393,16 @@ async fn restore_inner( expiry: opts.expiry, auth_policy: opts.auth_policy, ledger_path: opts.ledger_path.clone(), + allow_headless: opts.allow_headless, }; - let (new_token, expires_at, fresh_backup) = + let (new_token, expires_at, fresh_backup, headless_waiver) = match acquire::acquire(client, &acquire_opts, authenticator).await { AcquireResult::Acquired { token, expires_at, backup, - } => (token, expires_at, backup), + headless_waiver, + } => (token, expires_at, backup, headless_waiver), AcquireResult::AlreadyLeased { token, expires_at } => { return RestoreResult::AlreadyLeased { token, expires_at } } @@ -490,6 +518,7 @@ async fn restore_inner( new_token: new_token.clone(), expires_at, backup: fresh_backup, + headless_waiver, } } Err(err) => record_failure(err.to_string()), @@ -546,6 +575,7 @@ async fn restore_inner( spreadsheet_id: file_id.clone(), sheet_id: new_sheet_id, sheet_title: final_title, + headless_waiver, } } }; @@ -1079,6 +1109,7 @@ mod tests { expiry: ChronoDuration::minutes(30), auth_policy: AuthPolicy::DeviceOwner, ledger_path: dir.join("lease-ledger.jsonl"), + allow_headless: false, } } @@ -2830,6 +2861,7 @@ mod tests { sha256: "deadbeef".to_string(), size: 0, }, + headless_waiver: false, } .write_jsonl(&mut buf) .unwrap(); @@ -2837,4 +2869,42 @@ mod tests { assert!(text.contains("\"status\":\"restored\""), "{text}"); assert!(text.contains("tok-2"), "{text}"); } + + #[test] + fn verdict_distinguishes_a_headless_waived_restore() { + let restored = |headless_waiver| RestoreResult::Restored { + new_token: "tok".to_string(), + expires_at: Utc::now(), + backup: LeaseBackup::Bytes { + path: PathBuf::from("/tmp/backup"), + sha256: "deadbeef".to_string(), + size: 0, + }, + headless_waiver, + }; + assert_eq!(restored(false).verdict(), "restored"); + assert_eq!(restored(true).verdict(), "restored-headless-waiver"); + } + + #[test] + fn verdict_distinguishes_a_headless_waived_sheet_restore() { + let restored_sheet = |headless_waiver| RestoreResult::RestoredSheet { + new_token: "tok".to_string(), + expires_at: Utc::now(), + backup: LeaseBackup::Bytes { + path: PathBuf::from("/tmp/backup"), + sha256: "deadbeef".to_string(), + size: 0, + }, + spreadsheet_id: "sheet-1".to_string(), + sheet_id: 1, + sheet_title: "Sheet1".to_string(), + headless_waiver, + }; + assert_eq!(restored_sheet(false).verdict(), "restored-sheet"); + assert_eq!( + restored_sheet(true).verdict(), + "restored-sheet-headless-waiver" + ); + } } diff --git a/src/drive/lease/settings.rs b/src/drive/lease/settings.rs new file mode 100644 index 00000000..2039f4e1 --- /dev/null +++ b/src/drive/lease/settings.rs @@ -0,0 +1,380 @@ +//! Global policy resolution for `drive lease acquire`/`restore` +//! ([ADR-0080](../../../docs/adrs/adr-0080.md) §13, issue #1677). +//! +//! Mirrors `crate::claude::backend`'s `resolve_model`/ +//! `resolve_structured_output_disabled` shape: every resolver here reads the +//! environment only through an [`EnvSource`], so production callers pass +//! `&SettingsEnv::load()` (letting a value also come from a `settings.json` +//! `env` bundle or profile) while tests inject a pure `MapEnv`. +//! +//! Precedence, stopping at the first value found, for all four settings: +//! +//! 1. The CLI flag, when given explicitly. +//! 2. The dedicated env var below. +//! 3. The matching field of [`LeaseSettings`] (the `lease` section of +//! `settings.json`). +//! 4. A hard-coded default. +//! +//! [`resolve_auth_policy`] and [`resolve_allow_headless`] are OR-chains +//! rather than first-non-empty-wins: any layer saying "yes" wins, with no way +//! for a lower layer's "yes" to be forced back to "no" — the same +//! additive-only shape as this codebase's other opt-in/opt-out escape +//! hatches (e.g. `--claude-cli-allow-tools`). + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::drive::lease::acquire::{MAX_EXPIRY_MINUTES, MIN_EXPIRY_MINUTES}; +use crate::drive::lease::authenticate::AuthPolicy; +use crate::utils::env::EnvSource; +use crate::utils::settings::LeaseSettings; + +/// Default lease expiry when no layer sets one (ADR-0080 §5). +pub(crate) const DEFAULT_EXPIRY_MINUTES: i64 = 30; + +/// Env var overriding the default `--expiry-minutes`. +pub(crate) const LEASE_EXPIRY_MINUTES_ENV: &str = "OMNI_DEV_DRIVE_LEASE_EXPIRY_MINUTES"; +/// Env var overriding the default `--backup-dir`. +pub(crate) const LEASE_BACKUP_DIR_ENV: &str = "OMNI_DEV_DRIVE_LEASE_BACKUP_DIR"; +/// Env var overriding the default authentication policy. Truthy values are +/// `1`, `true`, and `yes` (trimmed, case-insensitive), matching +/// `resolve_structured_output_disabled`'s convention. +pub(crate) const LEASE_BIOMETRICS_ONLY_ENV: &str = "OMNI_DEV_DRIVE_LEASE_BIOMETRICS_ONLY"; +/// Env var enabling the headless/off-macOS opt-out (ADR-0080 §8). Same +/// truthy-value convention as [`LEASE_BIOMETRICS_ONLY_ENV`]. +pub(crate) const LEASE_ALLOW_HEADLESS_ENV: &str = "OMNI_DEV_DRIVE_LEASE_ALLOW_HEADLESS"; + +/// Returns `var`'s value when it is set and non-empty — see +/// `crate::claude::backend::non_empty_var`, which this mirrors. +fn non_empty_var(env: &impl EnvSource, key: &str) -> Option { + env.var(key).filter(|v| !v.is_empty()) +} + +/// Parses `var` as a truthy boolean: `1`, `true`, or `yes` (trimmed, +/// case-insensitive) is `true`; `0`, `false`, or `no` is `false`; unset or +/// empty is `false` with no warning (the ordinary "not set" case). Anything +/// else — a typo like `treu` — is also treated as `false`, but logs a +/// warning first: a silently-discarded, unrecognized value is exactly the +/// broken-configuration case docs/STYLE_GUIDE.md's silent-discard rule +/// warns against (issue #1677 review finding). Otherwise mirrors +/// `crate::claude::backend::resolve_structured_output_disabled`. +fn truthy_var(env: &impl EnvSource, key: &str) -> bool { + let Some(raw) = non_empty_var(env, key) else { + return false; + }; + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" => true, + "0" | "false" | "no" => false, + _ => { + tracing::warn!( + "{key}={raw:?} is not a recognized boolean (expected 1/true/yes or \ + 0/false/no); treating it as unset" + ); + false + } + } +} + +/// `/omni-dev/drive-backups` — a sibling of the request log and +/// lease ledger, same posture. The hard-coded default at the bottom of +/// [`resolve_backup_dir`]'s chain. +fn default_backup_dir() -> Result { + let base = dirs::state_dir() + .or_else(dirs::data_dir) + .context("could not resolve the state/data directory for the default backup directory")?; + Ok(base.join("omni-dev").join("drive-backups")) +} + +/// Resolves `--expiry-minutes`, range-checked against the same +/// [`MIN_EXPIRY_MINUTES`]/[`MAX_EXPIRY_MINUTES`] bounds the CLI's own +/// `value_parser` enforces on an explicit flag. Checking here too — not just +/// in `crate::drive::lease::acquire::acquire_inner`'s own defense-in-depth +/// pass — matters because the caller turns this into a `chrono::Duration` +/// immediately (`chrono::Duration::minutes` panics on an out-of-range `i64` +/// long before `acquire_inner` ever runs), so an unchecked env/settings.json +/// value would crash the process instead of erroring cleanly (issue #1677 +/// review finding). An unparseable env value is treated as unset, the same +/// spirit as an empty string. +pub(crate) fn resolve_expiry_minutes( + explicit: Option, + env: &impl EnvSource, + settings: &LeaseSettings, +) -> Result { + let fallback = || { + settings + .default_expiry_minutes + .unwrap_or(DEFAULT_EXPIRY_MINUTES) + }; + let value = if let Some(value) = explicit { + value + } else if let Some(raw) = non_empty_var(env, LEASE_EXPIRY_MINUTES_ENV) { + if let Ok(value) = raw.parse() { + value + } else { + tracing::warn!( + "{LEASE_EXPIRY_MINUTES_ENV}={raw:?} is not a valid integer; falling back to \ + settings.json/the hard-coded default" + ); + fallback() + } + } else { + fallback() + }; + if !(MIN_EXPIRY_MINUTES..=MAX_EXPIRY_MINUTES).contains(&value) { + anyhow::bail!( + "lease expiry must be between {MIN_EXPIRY_MINUTES} and {MAX_EXPIRY_MINUTES} \ + minutes (24 hours), got {value} (from {LEASE_EXPIRY_MINUTES_ENV} or \ + settings.json's lease.default_expiry_minutes)" + ); + } + Ok(value) +} + +/// Resolves `--backup-dir`. +pub(crate) fn resolve_backup_dir( + explicit: Option, + env: &impl EnvSource, + settings: &LeaseSettings, +) -> Result { + if let Some(dir) = explicit { + return Ok(dir); + } + if let Some(dir) = non_empty_var(env, LEASE_BACKUP_DIR_ENV) { + return Ok(PathBuf::from(dir)); + } + if let Some(dir) = settings.backup_dir.clone() { + return Ok(dir); + } + default_backup_dir() +} + +/// Resolves the authentication policy from `--biometrics-only`. Any layer +/// saying "biometrics-only" wins; there is no way for a lower layer's `true` +/// to be overridden back to device-owner. +pub(crate) fn resolve_auth_policy( + cli_biometrics_only: bool, + env: &impl EnvSource, + settings: &LeaseSettings, +) -> AuthPolicy { + if cli_biometrics_only || truthy_var(env, LEASE_BIOMETRICS_ONLY_ENV) || settings.biometrics_only + { + AuthPolicy::BiometricsOnly + } else { + AuthPolicy::DeviceOwner + } +} + +/// Resolves the headless/off-macOS opt-out (ADR-0080 §8). Any layer saying +/// `true` wins. An explicit `--allow-headless` is a deliberate, visible, +/// per-invocation act and warrants no extra warning; but because this waives +/// the human-presence authentication guarantee rather than merely widening a +/// sandbox, a `true` from the *ambient* layers — an inherited env var or a +/// machine-wide `settings.json` — is warned about, naming which layer +/// triggered it, the same way `--claude-cli-allow-tools`' escape hatch names +/// its own source (issue #1677 review finding). +pub(crate) fn resolve_allow_headless( + cli_flag: bool, + env: &impl EnvSource, + settings: &LeaseSettings, +) -> bool { + if cli_flag { + return true; + } + if truthy_var(env, LEASE_ALLOW_HEADLESS_ENV) { + tracing::warn!( + "drive lease: the human-presence authentication guarantee is waived for this \ + invocation because {LEASE_ALLOW_HEADLESS_ENV} is set (ADR-0080 §8)" + ); + return true; + } + if settings.allow_headless { + tracing::warn!( + "drive lease: the human-presence authentication guarantee is waived because \ + settings.json's lease.allow_headless is true (ADR-0080 §8/§13) — this applies to \ + every invocation on this machine until that setting is turned off" + ); + return true; + } + false +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::test_support::env::MapEnv; + + fn settings() -> LeaseSettings { + LeaseSettings::default() + } + + // ── resolve_expiry_minutes ── + + #[test] + fn expiry_minutes_explicit_wins() { + let env = MapEnv::new().with(LEASE_EXPIRY_MINUTES_ENV, "10"); + let mut s = settings(); + s.default_expiry_minutes = Some(20); + assert_eq!(resolve_expiry_minutes(Some(5), &env, &s).unwrap(), 5); + } + + #[test] + fn expiry_minutes_env_beats_settings() { + let env = MapEnv::new().with(LEASE_EXPIRY_MINUTES_ENV, "10"); + let mut s = settings(); + s.default_expiry_minutes = Some(20); + assert_eq!(resolve_expiry_minutes(None, &env, &s).unwrap(), 10); + } + + #[test] + fn expiry_minutes_settings_beats_hardcoded_default() { + let mut s = settings(); + s.default_expiry_minutes = Some(20); + assert_eq!( + resolve_expiry_minutes(None, &MapEnv::new(), &s).unwrap(), + 20 + ); + } + + #[test] + fn expiry_minutes_falls_back_to_hardcoded_default() { + assert_eq!( + resolve_expiry_minutes(None, &MapEnv::new(), &settings()).unwrap(), + DEFAULT_EXPIRY_MINUTES + ); + } + + #[test] + fn expiry_minutes_unparseable_env_is_treated_as_unset() { + let env = MapEnv::new().with(LEASE_EXPIRY_MINUTES_ENV, "not-a-number"); + let mut s = settings(); + s.default_expiry_minutes = Some(20); + assert_eq!(resolve_expiry_minutes(None, &env, &s).unwrap(), 20); + } + + #[test] + fn expiry_minutes_empty_env_is_treated_as_unset() { + let env = MapEnv::new().with(LEASE_EXPIRY_MINUTES_ENV, ""); + let mut s = settings(); + s.default_expiry_minutes = Some(20); + assert_eq!(resolve_expiry_minutes(None, &env, &s).unwrap(), 20); + } + + #[test] + fn expiry_minutes_out_of_range_env_value_is_a_clean_error_not_a_panic() { + let env = MapEnv::new().with(LEASE_EXPIRY_MINUTES_ENV, "9223372036854775807"); + let err = resolve_expiry_minutes(None, &env, &settings()).unwrap_err(); + assert!(err.to_string().contains("must be between"), "{err}"); + } + + #[test] + fn expiry_minutes_out_of_range_settings_value_is_a_clean_error_not_a_panic() { + let mut s = settings(); + s.default_expiry_minutes = Some(0); + let err = resolve_expiry_minutes(None, &MapEnv::new(), &s).unwrap_err(); + assert!(err.to_string().contains("must be between"), "{err}"); + } + + // ── resolve_backup_dir ── + + #[test] + fn backup_dir_explicit_wins() { + let env = MapEnv::new().with(LEASE_BACKUP_DIR_ENV, "/from/env"); + let mut s = settings(); + s.backup_dir = Some(PathBuf::from("/from/settings")); + assert_eq!( + resolve_backup_dir(Some(PathBuf::from("/from/cli")), &env, &s).unwrap(), + PathBuf::from("/from/cli") + ); + } + + #[test] + fn backup_dir_env_beats_settings() { + let env = MapEnv::new().with(LEASE_BACKUP_DIR_ENV, "/from/env"); + let mut s = settings(); + s.backup_dir = Some(PathBuf::from("/from/settings")); + assert_eq!( + resolve_backup_dir(None, &env, &s).unwrap(), + PathBuf::from("/from/env") + ); + } + + #[test] + fn backup_dir_settings_beats_hardcoded_default() { + let mut s = settings(); + s.backup_dir = Some(PathBuf::from("/from/settings")); + assert_eq!( + resolve_backup_dir(None, &MapEnv::new(), &s).unwrap(), + PathBuf::from("/from/settings") + ); + } + + #[test] + fn backup_dir_falls_back_to_hardcoded_default() { + let resolved = resolve_backup_dir(None, &MapEnv::new(), &settings()).unwrap(); + assert!(resolved.ends_with("omni-dev/drive-backups")); + } + + // ── resolve_auth_policy ── + + #[test] + fn auth_policy_defaults_to_device_owner() { + assert_eq!( + resolve_auth_policy(false, &MapEnv::new(), &settings()), + AuthPolicy::DeviceOwner + ); + } + + #[test] + fn auth_policy_cli_flag_selects_biometrics_only() { + assert_eq!( + resolve_auth_policy(true, &MapEnv::new(), &settings()), + AuthPolicy::BiometricsOnly + ); + } + + #[test] + fn auth_policy_env_selects_biometrics_only() { + let env = MapEnv::new().with(LEASE_BIOMETRICS_ONLY_ENV, "true"); + assert_eq!( + resolve_auth_policy(false, &env, &settings()), + AuthPolicy::BiometricsOnly + ); + } + + #[test] + fn auth_policy_settings_selects_biometrics_only() { + let mut s = settings(); + s.biometrics_only = true; + assert_eq!( + resolve_auth_policy(false, &MapEnv::new(), &s), + AuthPolicy::BiometricsOnly + ); + } + + // ── resolve_allow_headless ── + + #[test] + fn allow_headless_defaults_to_false() { + assert!(!resolve_allow_headless(false, &MapEnv::new(), &settings())); + } + + #[test] + fn allow_headless_cli_flag_wins() { + assert!(resolve_allow_headless(true, &MapEnv::new(), &settings())); + } + + #[test] + fn allow_headless_env_wins() { + let env = MapEnv::new().with(LEASE_ALLOW_HEADLESS_ENV, "yes"); + assert!(resolve_allow_headless(false, &env, &settings())); + } + + #[test] + fn allow_headless_settings_wins() { + let mut s = settings(); + s.allow_headless = true; + assert!(resolve_allow_headless(false, &MapEnv::new(), &s)); + } +} diff --git a/src/utils/settings.rs b/src/utils/settings.rs index c3fca86a..b72aeaa5 100644 --- a/src/utils/settings.rs +++ b/src/utils/settings.rs @@ -321,6 +321,38 @@ pub struct DriveSettings { pub accounts: HashMap, } +/// The `lease` section of `settings.json` — global, machine-wide policy for +/// `drive lease acquire`/`restore`. +/// +/// Sibling of `drive` rather than nested under it (issue #1677, +/// [ADR-0080](../../docs/adrs/adr-0080.md) §13): none of these are +/// meaningful per-Drive-account, they describe *this machine's* policy. +/// Every field is optional; an unset field falls back to the built-in +/// default, so an absent `lease` block preserves `drive lease`'s behaviour +/// byte-for-byte. See `crate::drive::lease::settings` for the resolvers that +/// layer a CLI flag and an env var on top of each of these. +#[derive(Debug, Default, Clone, Deserialize)] +pub struct LeaseSettings { + /// Default `--expiry-minutes` when the flag is omitted (ADR-0080 §5/§13). + #[serde(default)] + pub default_expiry_minutes: Option, + + /// Default `--backup-dir` when the flag is omitted (ADR-0080 §3/§13). + #[serde(default)] + pub backup_dir: Option, + + /// Default authentication policy: `false` selects device-owner (the + /// default), `true` selects biometrics-only (ADR-0080 §7/§13). + #[serde(default)] + pub biometrics_only: bool, + + /// Opt-out for headless/off-macOS contexts (ADR-0080 §8/§13): `true` + /// lets `drive lease acquire`/`restore` proceed with no device-owner + /// authenticator, waiving the human-presence guarantee. + #[serde(default)] + pub allow_headless: bool, +} + /// Settings loaded from $HOME/.omni-dev/settings.json. #[derive(Debug, Default, Deserialize)] pub struct Settings { @@ -348,6 +380,11 @@ pub struct Settings { /// [`DriveSettings::default`], which is an empty account map. #[serde(default)] pub drive: DriveSettings, + + /// Global Drive lease policy (issue #1677); an absent block yields + /// [`LeaseSettings::default`]. + #[serde(default)] + pub lease: LeaseSettings, } /// Returns the active profile name from `raw` (the process environment), or @@ -1376,6 +1413,48 @@ mod tests { assert!(settings.drive.accounts.is_empty()); } + #[test] + fn settings_parse_lease_section_from_json() { + let json = r#"{ + "lease": { + "default_expiry_minutes": 45, + "backup_dir": "/tmp/drive-backups", + "biometrics_only": true, + "allow_headless": true + } + }"#; + let settings: Settings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.lease.default_expiry_minutes, Some(45)); + assert_eq!( + settings.lease.backup_dir.as_deref(), + Some(Path::new("/tmp/drive-backups")) + ); + assert!(settings.lease.biometrics_only); + assert!(settings.lease.allow_headless); + } + + #[test] + fn settings_without_lease_key_defaults_all_unset() { + // An absent `lease` block must leave every field at its built-in + // default so `drive lease` behaves byte-for-byte as before (issue + // #1677). + let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap(); + assert!(settings.lease.default_expiry_minutes.is_none()); + assert!(settings.lease.backup_dir.is_none()); + assert!(!settings.lease.biometrics_only); + assert!(!settings.lease.allow_headless); + } + + #[test] + fn settings_lease_partial_section_leaves_others_default() { + let settings: Settings = + serde_json::from_str(r#"{ "lease": { "biometrics_only": true } }"#).unwrap(); + assert!(settings.lease.biometrics_only); + assert!(settings.lease.default_expiry_minutes.is_none()); + assert!(settings.lease.backup_dir.is_none()); + assert!(!settings.lease.allow_headless); + } + // ── free get_env_var seam (pure: injected raw env + lazy settings loader) ── #[test] diff --git a/tests/snapshots/integration_test__help_all_output.snap b/tests/snapshots/integration_test__help_all_output.snap index 3d68e0f0..f05f076b 100644 --- a/tests/snapshots/integration_test__help_all_output.snap +++ b/tests/snapshots/integration_test__help_all_output.snap @@ -4053,9 +4053,10 @@ Arguments: Drive file id to lease (from `drive search`, or the `id` segment of a Drive URL) Options: - --backup-dir Local directory byte backups are written under. Defaults to `/omni-dev/drive-backups` - --expiry-minutes Minutes the lease stays live once authorised. A write never extends this — a fresh window means a fresh `drive lease acquire` (ADR-0080 §5) [default: 30] - --biometrics-only Require Touch ID specifically, failing outright rather than falling back to the account password (ADR-0080 §7). Needs Touch ID hardware; the default policy works on any Mac + --backup-dir Local directory byte backups are written under. Defaults to `OMNI_DEV_DRIVE_LEASE_BACKUP_DIR`, then `settings.json`'s `lease.backup_dir`, then `/omni-dev/drive-backups` + --expiry-minutes Minutes the lease stays live once authorised. A write never extends this — a fresh window means a fresh `drive lease acquire` (ADR-0080 §5). Defaults to `OMNI_DEV_DRIVE_LEASE_EXPIRY_MINUTES`, then `settings.json`'s `lease.default_expiry_minutes`, then 30 + --biometrics-only Require Touch ID specifically, failing outright rather than falling back to the account password (ADR-0080 §7). Needs Touch ID hardware; the default policy works on any Mac. Also settable via `settings.json`'s `lease.biometrics_only` or `OMNI_DEV_DRIVE_LEASE_BIOMETRICS_ONLY`; any layer selecting it wins + --allow-headless Proceed even when no device-owner authenticator is available in this context — off-macOS, or a macOS process with no attached GUI session (ADR-0080 §8) — waiving the human-presence guarantee instead of refusing outright. Also settable via `settings.json`'s `lease.allow_headless` or `OMNI_DEV_DRIVE_LEASE_ALLOW_HEADLESS`; any layer opting in wins -o, --output Output format [default: table] [possible values: table, json, yaml, yamls, jsonl] -h, --help Print help (see more with '--help') @@ -4072,9 +4073,10 @@ Arguments: The backup lease's token (from `drive lease acquire`), expired or not — an expired-but-kept row is the expected common case Options: - --backup-dir Local directory the fresh lease's own byte backup is written under. Defaults to `/omni-dev/drive-backups` - --expiry-minutes Minutes the fresh lease stays live once authorised [default: 30] - --biometrics-only Require Touch ID specifically for the fresh lease, failing outright rather than falling back to the account password (ADR-0080 §7) + --backup-dir Local directory byte backups are written under. Defaults to `OMNI_DEV_DRIVE_LEASE_BACKUP_DIR`, then `settings.json`'s `lease.backup_dir`, then `/omni-dev/drive-backups` + --expiry-minutes Minutes the lease stays live once authorised. A write never extends this — a fresh window means a fresh `drive lease acquire` (ADR-0080 §5). Defaults to `OMNI_DEV_DRIVE_LEASE_EXPIRY_MINUTES`, then `settings.json`'s `lease.default_expiry_minutes`, then 30 + --biometrics-only Require Touch ID specifically, failing outright rather than falling back to the account password (ADR-0080 §7). Needs Touch ID hardware; the default policy works on any Mac. Also settable via `settings.json`'s `lease.biometrics_only` or `OMNI_DEV_DRIVE_LEASE_BIOMETRICS_ONLY`; any layer selecting it wins + --allow-headless Proceed even when no device-owner authenticator is available in this context — off-macOS, or a macOS process with no attached GUI session (ADR-0080 §8) — waiving the human-presence guarantee instead of refusing outright. Also settable via `settings.json`'s `lease.allow_headless` or `OMNI_DEV_DRIVE_LEASE_ALLOW_HEADLESS`; any layer opting in wins -o, --output Output format [default: table] [possible values: table, json, yaml, yamls, jsonl] -h, --help Print help (see more with '--help')