Skip to main content

zad_cli/cli/
onepass.rs

1//! `zad 1pass <verb>` — runtime surface for the 1Password service.
2//!
3//! Every read-side verb runs the permission layer **before** calling
4//! `op`, and the filter helpers strip out-of-scope targets so they
5//! look as if they don't exist. `get`/`read` on a hidden target
6//! return the same "no item found" shape the real `op` returns for a
7//! missing item.
8//!
9//! `create` is the only write verb; it always surfaces
10//! `PermissionDenied` (not `NotFound`) so the agent learns why its
11//! write was refused.
12
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15
16use clap::{Args, Subcommand};
17use serde::Serialize;
18
19use crate::cli::lifecycle::leak;
20use zad::config::{self, OnePassServiceCfg};
21use zad::error::{Result, ZadError};
22use zad::secrets::{self, Scope};
23use zad::service::onepass::client::{
24    CreateItemRequest, Item, ItemSummary, ListItemsFilter, OnePassClient, ParsedOpRef, Vault,
25    parse_op_ref, scan_op_refs,
26};
27use zad::service::onepass::permissions::{self as perms, EffectivePermissions, OnePassFunction};
28
29// ---------------------------------------------------------------------------
30// top-level args
31// ---------------------------------------------------------------------------
32
33#[derive(Debug, Args)]
34pub struct OnePassArgs {
35    #[command(subcommand)]
36    pub action: Action,
37}
38
39#[derive(Debug, Subcommand)]
40pub enum Action {
41    /// List the vaults this account can see (filtered by policy).
42    Vaults(VaultsArgs),
43    /// List items (filtered by vault, tags, category, and policy).
44    Items(ItemsArgs),
45    /// List distinct tags across visible items.
46    Tags(TagsArgs),
47    /// Fetch metadata for one item. Fields are filtered by policy —
48    /// labels/types stay visible, `value` on denied fields is dropped.
49    Get(GetArgs),
50    /// Resolve a single `op://vault/item/field` reference.
51    Read(ReadArgs),
52    /// Substitute every `op://…` reference in a template. Each ref
53    /// is gated through the same policy as `read` before `op inject`
54    /// runs, so the whole call aborts if any ref is hidden.
55    Inject(InjectArgs),
56    /// Create a new item. Gated by the deny-by-default `[create]`
57    /// block; the agent must be explicitly allowed in a vault.
58    Create(CreateItemArgs),
59    /// Confirm the stored credentials work.
60    Whoami(WhoamiArgs),
61    /// Inspect or scaffold the permissions policy.
62    Permissions(PermissionsArgs),
63}
64
65#[derive(Debug, Args)]
66pub struct VaultsArgs {
67    #[arg(long)]
68    pub json: bool,
69}
70
71#[derive(Debug, Args)]
72pub struct ItemsArgs {
73    /// Filter to one vault by name or UUID. Default: every visible vault.
74    #[arg(long)]
75    pub vault: Option<String>,
76    /// Filter to items carrying any of these tags (repeatable).
77    #[arg(long = "tag")]
78    pub tags: Vec<String>,
79    /// Filter to items in any of these categories (repeatable).
80    #[arg(long = "category")]
81    pub categories: Vec<String>,
82    #[arg(long)]
83    pub json: bool,
84}
85
86#[derive(Debug, Args)]
87pub struct TagsArgs {
88    #[arg(long)]
89    pub json: bool,
90}
91
92#[derive(Debug, Args)]
93pub struct GetArgs {
94    /// Item title or UUID.
95    pub item: String,
96    #[arg(long)]
97    pub vault: Option<String>,
98    #[arg(long)]
99    pub json: bool,
100}
101
102#[derive(Debug, Args)]
103pub struct ReadArgs {
104    /// A single `op://vault/item/field` reference.
105    pub reference: String,
106    #[arg(long)]
107    pub json: bool,
108}
109
110#[derive(Debug, Args)]
111pub struct InjectArgs {
112    /// Path to the template file. Use `-` for stdin.
113    #[arg(long = "in", default_value = "-")]
114    pub input: String,
115    /// Optional output path. Defaults to stdout.
116    #[arg(long = "out")]
117    pub output: Option<String>,
118    #[arg(long)]
119    pub json: bool,
120}
121
122#[derive(Debug, Args)]
123pub struct CreateItemArgs {
124    #[arg(long)]
125    pub title: String,
126    #[arg(long)]
127    pub vault: String,
128    #[arg(long, default_value = "Login")]
129    pub category: String,
130    /// Repeatable. When `[create].tags.allow` is non-empty, at least
131    /// one of these must match.
132    #[arg(long = "tag")]
133    pub tags: Vec<String>,
134    /// Raw `op item create` field assignments
135    /// (`username=bot`, `password=…`, `section.key[password]=…`).
136    /// Repeatable.
137    #[arg(long = "field")]
138    pub fields: Vec<String>,
139    #[arg(long)]
140    pub json: bool,
141}
142
143#[derive(Debug, Args)]
144pub struct WhoamiArgs {
145    #[arg(long)]
146    pub json: bool,
147}
148
149// ---------------------------------------------------------------------------
150// permissions subgroup
151// ---------------------------------------------------------------------------
152
153#[derive(Debug, Args)]
154pub struct PermissionsArgs {
155    #[command(subcommand)]
156    pub action: Option<PermissionsAction>,
157    #[arg(long)]
158    pub json: bool,
159}
160
161#[derive(Debug, Subcommand)]
162#[allow(clippy::large_enum_variant)]
163pub enum PermissionsAction {
164    /// Print the effective policy (both file paths + bodies).
165    Show(PermissionsShowArgs),
166    /// Print the two candidate file paths, one per line.
167    Path(PermissionsPathArgs),
168    /// Write a starter policy to the selected scope.
169    Init(PermissionsInitArgs),
170    /// Dry-run a permissions check without hitting the network.
171    Check(PermissionsCheckArgs),
172    /// Staged-commit workflow: queue mutations in a `.pending` file and
173    /// only sign on `commit`. See `cli::permissions`.
174    #[command(flatten)]
175    Staging(crate::cli::permissions::StagingAction),
176}
177
178#[derive(Debug, Args)]
179pub struct PermissionsShowArgs {
180    #[arg(long)]
181    pub json: bool,
182}
183
184#[derive(Debug, Args)]
185pub struct PermissionsPathArgs {
186    #[arg(long)]
187    pub json: bool,
188}
189
190#[derive(Debug, Args)]
191pub struct PermissionsInitArgs {
192    #[arg(long)]
193    pub local: bool,
194    #[arg(long)]
195    pub force: bool,
196    #[arg(long)]
197    pub json: bool,
198}
199
200#[derive(Debug, Args)]
201pub struct PermissionsCheckArgs {
202    /// One of: `vaults`, `items`, `tags`, `get`, `read`, `inject`, `create`.
203    #[arg(long)]
204    pub function: String,
205    #[arg(long)]
206    pub vault: Option<String>,
207    #[arg(long)]
208    pub item: Option<String>,
209    #[arg(long = "tag")]
210    pub tags: Vec<String>,
211    #[arg(long)]
212    pub category: Option<String>,
213    #[arg(long)]
214    pub field: Option<String>,
215    #[arg(long)]
216    pub title: Option<String>,
217    #[arg(long)]
218    pub reference: Option<String>,
219    #[arg(long)]
220    pub json: bool,
221}
222
223// ---------------------------------------------------------------------------
224// dispatch
225// ---------------------------------------------------------------------------
226
227pub async fn run(args: OnePassArgs) -> Result<()> {
228    match args.action {
229        Action::Vaults(a) => run_vaults(a).await,
230        Action::Items(a) => run_items(a).await,
231        Action::Tags(a) => run_tags(a).await,
232        Action::Get(a) => run_get(a).await,
233        Action::Read(a) => run_read(a).await,
234        Action::Inject(a) => run_inject(a).await,
235        Action::Create(a) => run_create(a).await,
236        Action::Whoami(a) => run_whoami(a).await,
237        Action::Permissions(a) => run_permissions(a),
238    }
239}
240
241// ---------------------------------------------------------------------------
242// verbs
243// ---------------------------------------------------------------------------
244
245async fn run_vaults(args: VaultsArgs) -> Result<()> {
246    let client = client_for(&["read"])?;
247    let permissions = perms::load_effective()?;
248    permissions.check_time(OnePassFunction::Vaults)?;
249
250    let vaults = client.list_vaults().await?;
251    let visible = permissions.filter_vaults(vaults);
252
253    if args.json {
254        println!("{}", serde_json::to_string_pretty(&visible).unwrap());
255        return Ok(());
256    }
257    if visible.is_empty() {
258        println!("(no vaults visible to this account)");
259        return Ok(());
260    }
261    for v in &visible {
262        println!("{}\t{}", v.id, v.name);
263    }
264    Ok(())
265}
266
267async fn run_items(args: ItemsArgs) -> Result<()> {
268    let client = client_for(&["read"])?;
269    let permissions = perms::load_effective()?;
270    permissions.check_time(OnePassFunction::Items)?;
271
272    let filter = ListItemsFilter {
273        vault: args.vault.clone(),
274        tags: args.tags.clone(),
275        categories: args.categories.clone(),
276    };
277    let items = client.list_items(&filter).await?;
278    let visible = permissions.filter_items(items);
279
280    if args.json {
281        println!("{}", serde_json::to_string_pretty(&visible).unwrap());
282        return Ok(());
283    }
284    if visible.is_empty() {
285        println!("(no items match)");
286        return Ok(());
287    }
288    for it in &visible {
289        let tags = if it.tags.is_empty() {
290            String::new()
291        } else {
292            format!(" [{}]", it.tags.join(","))
293        };
294        println!(
295            "{}\t{}\t{}\t{}{tags}",
296            it.id, it.vault.name, it.category, it.title
297        );
298    }
299    Ok(())
300}
301
302async fn run_tags(args: TagsArgs) -> Result<()> {
303    let client = client_for(&["read"])?;
304    let permissions = perms::load_effective()?;
305    permissions.check_time(OnePassFunction::Tags)?;
306
307    let items = client.list_items(&ListItemsFilter::default()).await?;
308    let visible_items = permissions.filter_items(items);
309    let tags = permissions.filter_tags(&visible_items);
310
311    if args.json {
312        println!("{}", serde_json::to_string_pretty(&tags).unwrap());
313        return Ok(());
314    }
315    if tags.is_empty() {
316        println!("(no tags visible)");
317        return Ok(());
318    }
319    for t in &tags {
320        println!("{t}");
321    }
322    Ok(())
323}
324
325async fn run_get(args: GetArgs) -> Result<()> {
326    let client = client_for(&["read"])?;
327    let permissions = perms::load_effective()?;
328    permissions.check_time(OnePassFunction::Get)?;
329
330    let vault = args.vault.clone().or_else(|| {
331        effective_config()
332            .ok()
333            .and_then(|(c, _, _, _)| c.default_vault)
334    });
335    let item = client.get_item(&args.item, vault.as_deref()).await?;
336    permissions.check_get(&args.item, &item)?;
337    let filtered = permissions.filter_fields(item);
338
339    if args.json {
340        println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
341        return Ok(());
342    }
343    println!("id       : {}", filtered.id);
344    println!("title    : {}", filtered.title);
345    println!("category : {}", filtered.category);
346    println!("vault    : {} ({})", filtered.vault.name, filtered.vault.id);
347    if !filtered.tags.is_empty() {
348        println!("tags     : {}", filtered.tags.join(", "));
349    }
350    if !filtered.fields.is_empty() {
351        println!("fields   :");
352        for f in &filtered.fields {
353            let value_marker = if f.value.is_some() {
354                ""
355            } else {
356                " (value hidden)"
357            };
358            println!(
359                "  - {label} [{ftype}]{marker}",
360                label = if f.label.is_empty() {
361                    f.id.as_str()
362                } else {
363                    f.label.as_str()
364                },
365                ftype = f.field_type,
366                marker = value_marker
367            );
368        }
369    }
370    Ok(())
371}
372
373async fn run_read(args: ReadArgs) -> Result<()> {
374    let client = client_for(&["read"])?;
375    let permissions = perms::load_effective()?;
376    permissions.check_time(OnePassFunction::Read)?;
377
378    let parsed = parse_op_ref(&args.reference)?;
379    // Resolve the item so we can gate vault/item/category/tags + field
380    // through the full policy surface.
381    let item = client
382        .get_item(&parsed.item, Some(parsed.vault.as_str()))
383        .await?;
384    permissions.check_read(&args.reference, &item, &parsed.field)?;
385
386    let value = client.read(&args.reference).await?;
387
388    if args.json {
389        let out = serde_json::json!({
390            "command": "1pass.read",
391            "reference": args.reference,
392            "value": value,
393        });
394        println!("{}", serde_json::to_string_pretty(&out).unwrap());
395        return Ok(());
396    }
397    // Write to stdout without extra framing so `$(zad 1pass read …)`
398    // works as expected.
399    println!("{value}");
400    Ok(())
401}
402
403async fn run_inject(args: InjectArgs) -> Result<()> {
404    let client = client_for(&["read"])?;
405    let permissions = perms::load_effective()?;
406    permissions.check_time(OnePassFunction::Inject)?;
407
408    let template = read_input(&args.input)?;
409    // Pre-scan every `op://…` reference so a single out-of-scope ref
410    // aborts the call before touching the network.
411    let refs = scan_op_refs(&template);
412    for r in &refs {
413        permissions.check_inject_ref(r)?;
414    }
415
416    let rendered = client.inject(&template).await?;
417    permissions.check_inject_body(&rendered)?;
418
419    match args.output.as_deref() {
420        Some(p) => {
421            std::fs::write(p, &rendered).map_err(|e| ZadError::Io {
422                path: PathBuf::from(p),
423                source: e,
424            })?;
425        }
426        None => {
427            if args.json {
428                let out = serde_json::json!({
429                    "command": "1pass.inject",
430                    "rendered": rendered,
431                    "refs": refs.iter().map(|r| &r.source).collect::<Vec<_>>(),
432                });
433                println!("{}", serde_json::to_string_pretty(&out).unwrap());
434                return Ok(());
435            }
436            print!("{rendered}");
437        }
438    }
439    Ok(())
440}
441
442async fn run_create(args: CreateItemArgs) -> Result<()> {
443    let client = client_for(&["write"])?;
444    let permissions = perms::load_effective()?;
445    permissions.check_create(&args.vault, &args.category, &args.title, &args.tags)?;
446
447    let req = CreateItemRequest {
448        title: args.title.clone(),
449        vault: args.vault.clone(),
450        category: args.category.clone(),
451        tags: args.tags.iter().cloned().collect::<BTreeSet<_>>(),
452        fields: args.fields.clone(),
453    };
454    let created = client.create_item(&req).await?;
455
456    if args.json {
457        println!("{}", serde_json::to_string_pretty(&created).unwrap());
458        return Ok(());
459    }
460    println!(
461        "created: {} ({}) in {}",
462        created.title, created.id, created.vault.name
463    );
464    Ok(())
465}
466
467async fn run_whoami(args: WhoamiArgs) -> Result<()> {
468    // whoami doesn't need a scope — it's a diagnostic.
469    let client = client_for(&[])?;
470    let me = client.whoami().await?;
471    if args.json {
472        println!("{}", serde_json::to_string_pretty(&me).unwrap());
473        return Ok(());
474    }
475    if !me.url.is_empty() {
476        println!("url          : {}", me.url);
477    }
478    if !me.service_account_type.is_empty() {
479        println!("account_type : {}", me.service_account_type);
480    }
481    if !me.user_uuid.is_empty() {
482        println!("user_uuid    : {}", me.user_uuid);
483    }
484    if !me.account_uuid.is_empty() {
485        println!("account_uuid : {}", me.account_uuid);
486    }
487    Ok(())
488}
489
490// ---------------------------------------------------------------------------
491// permissions subcommands
492// ---------------------------------------------------------------------------
493
494fn run_permissions(args: PermissionsArgs) -> Result<()> {
495    match args.action {
496        None => run_permissions_show(PermissionsShowArgs { json: args.json }),
497        Some(PermissionsAction::Show(a)) => run_permissions_show(a),
498        Some(PermissionsAction::Path(a)) => run_permissions_path(a),
499        Some(PermissionsAction::Init(a)) => run_permissions_init(a),
500        Some(PermissionsAction::Check(a)) => run_permissions_check(a),
501        Some(PermissionsAction::Staging(a)) => {
502            crate::cli::permissions::run::<perms::PermissionsService>(a)
503        }
504    }
505}
506
507#[derive(Debug, Serialize)]
508struct PermissionsScopeOut {
509    path: String,
510    present: bool,
511}
512
513#[derive(Debug, Serialize)]
514struct PermissionsShowOut {
515    command: &'static str,
516    global: PermissionsScopeOut,
517    local: PermissionsScopeOut,
518}
519
520fn run_permissions_show(args: PermissionsShowArgs) -> Result<()> {
521    let global_path = perms::global_path()?;
522    let local_path = perms::local_path_current()?;
523    let _ = perms::load_effective()?;
524
525    if args.json {
526        let out = PermissionsShowOut {
527            command: "1pass.permissions.show",
528            global: PermissionsScopeOut {
529                path: global_path.display().to_string(),
530                present: global_path.exists(),
531            },
532            local: PermissionsScopeOut {
533                path: local_path.display().to_string(),
534                present: local_path.exists(),
535            },
536        };
537        println!("{}", serde_json::to_string_pretty(&out).unwrap());
538        return Ok(());
539    }
540    println!("1Password permissions");
541    print_scope_block("global", &global_path);
542    print_scope_block("local", &local_path);
543    Ok(())
544}
545
546fn print_scope_block(label: &str, path: &Path) {
547    println!();
548    println!("  [{label}] {}", path.display());
549    if !path.exists() {
550        println!("    status : not present (no restrictions from this scope)");
551        return;
552    }
553    match std::fs::read_to_string(path) {
554        Ok(body) => {
555            for line in body.lines() {
556                println!("    {line}");
557            }
558        }
559        Err(e) => println!("    status : read error — {e}"),
560    }
561}
562
563#[derive(Debug, Serialize)]
564struct PermissionsPathOut {
565    command: &'static str,
566    global: String,
567    local: String,
568}
569
570fn run_permissions_path(args: PermissionsPathArgs) -> Result<()> {
571    let global_path = perms::global_path()?;
572    let local_path = perms::local_path_current()?;
573    if args.json {
574        let out = PermissionsPathOut {
575            command: "1pass.permissions.path",
576            global: global_path.display().to_string(),
577            local: local_path.display().to_string(),
578        };
579        println!("{}", serde_json::to_string_pretty(&out).unwrap());
580        return Ok(());
581    }
582    println!("{}", global_path.display());
583    println!("{}", local_path.display());
584    Ok(())
585}
586
587#[derive(Debug, Serialize)]
588struct PermissionsInitOut {
589    command: &'static str,
590    scope: &'static str,
591    path: String,
592    written: bool,
593}
594
595fn run_permissions_init(args: PermissionsInitArgs) -> Result<()> {
596    let (path, scope_label): (PathBuf, &'static str) = if args.local {
597        (perms::local_path_current()?, "local")
598    } else {
599        (perms::global_path()?, "global")
600    };
601    if path.exists() && !args.force {
602        return Err(ZadError::Invalid(format!(
603            "{} already exists — pass --force to overwrite",
604            path.display()
605        )));
606    }
607    let key = zad::permissions::signing::load_or_create_from_keychain()?;
608    zad::permissions::signing::write_public_key_cache(&key)?;
609    perms::save_file(&path, &perms::starter_template(), &key)?;
610    if args.json {
611        let out = PermissionsInitOut {
612            command: "1pass.permissions.init",
613            scope: scope_label,
614            path: path.display().to_string(),
615            written: true,
616        };
617        println!("{}", serde_json::to_string_pretty(&out).unwrap());
618        return Ok(());
619    }
620    println!(
621        "Wrote 1pass permissions starter policy to {} ({scope_label}).",
622        path.display()
623    );
624    println!("Signed with key {}.", key.fingerprint());
625    println!("Edit to narrow further; re-run `zad 1pass permissions show` to inspect.");
626    Ok(())
627}
628
629#[derive(Debug, Serialize)]
630struct PermissionsCheckOut {
631    command: &'static str,
632    function: String,
633    allowed: bool,
634    #[serde(skip_serializing_if = "Option::is_none")]
635    reason: Option<String>,
636    #[serde(skip_serializing_if = "Option::is_none")]
637    config_path: Option<String>,
638}
639
640fn run_permissions_check(args: PermissionsCheckArgs) -> Result<()> {
641    let permissions = perms::load_effective()?;
642    let outcome = check_hypothetical(&permissions, &args);
643    emit_check_result(&args, outcome)
644}
645
646fn emit_check_result(args: &PermissionsCheckArgs, outcome: Result<()>) -> Result<()> {
647    match outcome {
648        Ok(()) => {
649            if args.json {
650                let out = PermissionsCheckOut {
651                    command: "1pass.permissions.check",
652                    function: args.function.clone(),
653                    allowed: true,
654                    reason: None,
655                    config_path: None,
656                };
657                println!("{}", serde_json::to_string_pretty(&out).unwrap());
658            } else {
659                println!("allowed");
660            }
661            Ok(())
662        }
663        Err(ZadError::PermissionDenied {
664            function,
665            reason,
666            config_path,
667        }) => {
668            if args.json {
669                let out = PermissionsCheckOut {
670                    command: "1pass.permissions.check",
671                    function: function.to_string(),
672                    allowed: false,
673                    reason: Some(reason.clone()),
674                    config_path: Some(config_path.display().to_string()),
675                };
676                println!("{}", serde_json::to_string_pretty(&out).unwrap());
677            } else {
678                println!("denied: {reason}");
679                println!("  edit: {}", config_path.display());
680            }
681            std::process::exit(1);
682        }
683        Err(ZadError::Service {
684            name: "1pass",
685            message,
686        }) => {
687            // NotFound-shaped result from a hidden-target read check.
688            // Report as "denied (hidden)" so the operator running
689            // `permissions check` can distinguish this from an allowed
690            // outcome.
691            if args.json {
692                let out = PermissionsCheckOut {
693                    command: "1pass.permissions.check",
694                    function: args.function.clone(),
695                    allowed: false,
696                    reason: Some(format!("hidden: {message}")),
697                    config_path: None,
698                };
699                println!("{}", serde_json::to_string_pretty(&out).unwrap());
700            } else {
701                println!("denied (hidden): {message}");
702            }
703            std::process::exit(1);
704        }
705        Err(other) => Err(other),
706    }
707}
708
709fn check_hypothetical(
710    permissions: &EffectivePermissions,
711    args: &PermissionsCheckArgs,
712) -> Result<()> {
713    // `create` is a separate path — doesn't go through OnePassFunction.
714    if args.function == "create" {
715        let vault = args
716            .vault
717            .as_deref()
718            .ok_or_else(|| ZadError::Invalid("--vault is required for create".into()))?;
719        let category = args.category.as_deref().unwrap_or("Login");
720        let title = args.title.as_deref().unwrap_or("");
721        return permissions.check_create(vault, category, title, &args.tags);
722    }
723
724    let func = OnePassFunction::parse(&args.function)?;
725    permissions.check_time(func)?;
726
727    // Synthesize a minimal item from the provided axis flags so the
728    // shared `item_admitted` path can gate everything we know about.
729    if args.item.is_some()
730        || args.vault.is_some()
731        || !args.tags.is_empty()
732        || args.category.is_some()
733    {
734        let item = synthetic_item(args);
735        match func {
736            OnePassFunction::Get | OnePassFunction::Read => {
737                permissions.check_get(
738                    args.item
739                        .as_deref()
740                        .or(args.reference.as_deref())
741                        .unwrap_or(""),
742                    &item,
743                )?;
744            }
745            _ => {
746                // list-style verbs use filter_items; reuse that via a
747                // single-item slice.
748                let vec = vec![summarize(&item)];
749                if permissions.filter_items(vec).is_empty() {
750                    return Err(ZadError::Service {
751                        name: "1pass",
752                        message: "item is hidden at this scope".into(),
753                    });
754                }
755            }
756        }
757    }
758
759    if func == OnePassFunction::Read {
760        if let Some(r) = args.reference.as_deref() {
761            let parsed = parse_op_ref(r)?;
762            let item = synthetic_item_for_ref(&parsed, args);
763            permissions.check_read(r, &item, &parsed.field)?;
764        }
765    }
766    if func == OnePassFunction::Inject {
767        if let Some(r) = args.reference.as_deref() {
768            let parsed = parse_op_ref(r)?;
769            permissions.check_inject_ref(&parsed)?;
770        }
771    }
772    Ok(())
773}
774
775fn synthetic_item(args: &PermissionsCheckArgs) -> Item {
776    Item {
777        id: args.item.clone().unwrap_or_default(),
778        title: args.item.clone().unwrap_or_default(),
779        category: args.category.clone().unwrap_or_default(),
780        tags: args.tags.clone(),
781        vault: zad::service::onepass::client::VaultRef {
782            id: args.vault.clone().unwrap_or_default(),
783            name: args.vault.clone().unwrap_or_default(),
784        },
785        fields: args
786            .field
787            .as_ref()
788            .map(|f| {
789                vec![zad::service::onepass::client::ItemField {
790                    id: f.clone(),
791                    label: f.clone(),
792                    field_type: String::new(),
793                    purpose: None,
794                    value: None,
795                    section: None,
796                }]
797            })
798            .unwrap_or_default(),
799        sections: vec![],
800        updated_at: None,
801        created_at: None,
802    }
803}
804
805fn synthetic_item_for_ref(r: &ParsedOpRef, args: &PermissionsCheckArgs) -> Item {
806    Item {
807        id: r.item.clone(),
808        title: r.item.clone(),
809        category: args.category.clone().unwrap_or_default(),
810        tags: args.tags.clone(),
811        vault: zad::service::onepass::client::VaultRef {
812            id: r.vault.clone(),
813            name: r.vault.clone(),
814        },
815        fields: vec![zad::service::onepass::client::ItemField {
816            id: r.field.clone(),
817            label: r.field.clone(),
818            field_type: String::new(),
819            purpose: None,
820            value: None,
821            section: None,
822        }],
823        sections: vec![],
824        updated_at: None,
825        created_at: None,
826    }
827}
828
829fn summarize(item: &Item) -> ItemSummary {
830    ItemSummary {
831        id: item.id.clone(),
832        title: item.title.clone(),
833        category: item.category.clone(),
834        tags: item.tags.clone(),
835        vault: item.vault.clone(),
836        updated_at: None,
837        created_at: None,
838    }
839}
840
841// ---------------------------------------------------------------------------
842// shared helpers
843// ---------------------------------------------------------------------------
844
845/// Load the effective `OnePassServiceCfg` plus the scope label and the
846/// keychain scope to read the token from. Mirrors `gcal::effective_config`.
847pub(crate) fn effective_config()
848-> Result<(OnePassServiceCfg, &'static str, Scope<'static>, PathBuf)> {
849    let slug = config::path::project_slug()?;
850    let local_path = config::path::project_service_config_path_for(&slug, "1pass")?;
851    let global_path = config::path::global_service_config_path("1pass")?;
852
853    let project_cfg = config::load()?;
854    if !project_cfg.has_service("1pass") {
855        return Err(ZadError::Invalid(format!(
856            "1pass is not enabled for this project ({}). Run `zad service enable 1pass` first.",
857            config::path::project_config_path()?.display()
858        )));
859    }
860
861    if let Some(cfg) = config::load_flat::<OnePassServiceCfg>(&local_path)? {
862        let slug_leaked = leak(slug);
863        return Ok((cfg, "local", Scope::Project(slug_leaked), local_path));
864    }
865    if let Some(cfg) = config::load_flat::<OnePassServiceCfg>(&global_path)? {
866        return Ok((cfg, "global", Scope::Global, global_path));
867    }
868    Err(ZadError::Invalid(format!(
869        "no 1pass credentials found.\n  looked in:\n    {}\n    {}\n  Run `zad service create 1pass`.",
870        local_path.display(),
871        global_path.display()
872    )))
873}
874
875/// Scope gate: each runtime verb names the zad-level scopes it needs;
876/// if any are missing from the effective config we raise `ScopeDenied`
877/// pointing at the config file.
878fn client_for(required_scopes: &[&'static str]) -> Result<OnePassClient> {
879    let (cfg, _label, scope, path) = effective_config()?;
880    for s in required_scopes {
881        if !cfg.scopes.iter().any(|x| x == s) {
882            return Err(ZadError::ScopeDenied {
883                service: "1pass",
884                scope: s,
885                config_path: path.clone(),
886            });
887        }
888    }
889    let token = secrets::load(&secrets::account("1pass", "service-account", scope))?.ok_or(
890        ZadError::Service {
891            name: "1pass",
892            message:
893                "service-account token missing from keychain; re-run `zad service create 1pass`"
894                    .into(),
895        },
896    )?;
897    Ok(OnePassClient::new(token, cfg.account))
898}
899
900/// Resolve an `--in` argument to a template body. `-` reads stdin.
901fn read_input(input: &str) -> Result<String> {
902    if input == "-" {
903        use std::io::Read;
904        let mut buf = String::new();
905        std::io::stdin()
906            .read_to_string(&mut buf)
907            .map_err(|e| ZadError::Io {
908                path: PathBuf::from("<stdin>"),
909                source: e,
910            })?;
911        return Ok(buf);
912    }
913    std::fs::read_to_string(input).map_err(|e| ZadError::Io {
914        path: PathBuf::from(input),
915        source: e,
916    })
917}
918
919// Silence unused-import warnings that only fire with narrow feature
920// subsets; the direct usages keep these types in the symbol table.
921#[allow(dead_code)]
922fn _type_anchors() -> (Vault,) {
923    (Vault {
924        id: String::new(),
925        name: String::new(),
926        content_version: None,
927    },)
928}