* feat(credential_store): add GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND env var Add gogcli-style backend selection for encryption key storage: - keyring (default): OS keyring with file fallback - file: .encryption_key file only (Docker/CI/headless) Never delete .encryption_key — it always serves as a durable fallback for environments where the keyring is ephemeral. When generating new keys with backend=keyring, save to both keyring and file. Extracts KeyringProvider trait + resolve_key() for testability. 25 tests covering both backends and all edge cases. Fixes #344 * chore: regenerate skills [skip ci] * fix(credential_store): use O_EXCL for race-safe key generation Use create_new(true) (O_EXCL on Unix, CREATE_NEW on Windows) when generating a new encryption key file. If another process wins the race, read their key instead. Platform-independent. * fix(credential_store): sync winner's key into keyring after file race When two processes race to create the encryption key file, the loser now syncs the winner's key back into the keyring. Without this, the keyring and file could permanently diverge. * test(credential_store): add 9 tests covering file exclusion, env parsing, and race paths - save_key_file_exclusive: creates new file, rejects existing - save_key_file: overwrites existing - ensure_key_dir: creates nested dirs - KeyringBackend: file/FILE/invalid parsing - Race loser: syncs winner key to keyring - Race loser: corrupt file gets overwritten * feat(credential_store): security and robustness hardening 1. Warn on unrecognized KEYRING_BACKEND values instead of silent default 2. fsync after key file writes for crash durability 3. Zeroize decoded key material from heap after copy 4. Warn if key file has overly permissive Unix permissions (mode & 077) 5. Log which keyring backend was selected to stderr 6. Expose keyring_backend in 'gws auth status' JSON output --------- Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
9.8 KiB
AGENTS.md
Project Overview
gws is a Rust CLI tool for interacting with Google Workspace APIs. It dynamically generates its command surface at runtime by parsing Google Discovery Service JSON documents.
Important
Dynamic Discovery: This project does NOT use generated Rust crates (e.g.,
google-drive3) for API interaction. Instead, it fetches the Discovery JSON at runtime and buildsclapcommands dynamically. When adding a new service, you only need to register it insrc/services.rsand verify the Discovery URL pattern insrc/discovery.rs. Do NOT add new crates toCargo.tomlfor standard Google APIs.
Note
Package Manager: Use
pnpminstead ofnpmfor Node.js package management in this repository.
Build & Test
Important
Test Coverage: The
codecov/patchcheck requires that new or modified lines are covered by tests. When adding code, extract testable helper functions rather than embedding logic inmain/runwhere it's hard to unit-test. Runcargo testlocally and verify new branches are exercised.
cargo build # Build in dev mode
cargo clippy -- -D warnings # Lint check
cargo test # Run tests
Changesets
Every PR must include a changeset file. Create one at .changeset/<descriptive-name>.md:
---
"@googleworkspace/cli": patch
---
Brief description of the change
Use patch for fixes/chores, minor for new features, major for breaking changes. The CI policy check will fail without a changeset.
Architecture
The CLI uses a two-phase argument parsing strategy:
- Parse argv to extract the service name (e.g.,
drive) - Fetch the service's Discovery Document, build a dynamic
clap::Commandtree, then re-parse
Source Layout
| File | Purpose |
|---|---|
src/main.rs |
Entrypoint, two-phase CLI parsing, method resolution |
src/discovery.rs |
Serde models for Discovery Document + fetch/cache |
src/services.rs |
Service alias → Discovery API name/version mapping |
src/auth.rs |
OAuth2 token acquisition via env vars, encrypted credentials, or ADC |
src/credential_store.rs |
AES-256-GCM encryption/decryption of credential files |
src/auth_commands.rs |
gws auth subcommands: login, logout, setup, status, export |
src/commands.rs |
Recursive clap::Command builder from Discovery resources |
src/executor.rs |
HTTP request construction, response handling, schema validation |
src/schema.rs |
gws schema command — introspect API method schemas |
src/error.rs |
Structured JSON error output |
Demo Videos
Demo recordings are generated with VHS (.tape files).
vhs docs/demo.tape
VHS quoting rules
- Use double quotes for simple strings:
Type "gws --help" Enter - Use backtick quotes when the typed text contains JSON with double quotes:
Type `gws drive files list --params '{"pageSize":5}'` Enter\"escapes inside double-quotedTypestrings are not supported by VHS and will cause parse errors.
Scene art
ASCII art title cards live in art/. The scripts/show-art.sh helper clears the screen and cats the file. Portrait scenes use scene*.txt; landscape chapters use long-*.txt.
Input Validation & URL Safety
Important
This CLI is frequently invoked by AI/LLM agents. Always assume inputs can be adversarial — validate paths against traversal (
../../.ssh), restrict format strings to allowlists, reject control characters, and encode user values before embedding them in URLs.
Note
Environment variables are trusted inputs. The validation rules above apply to CLI arguments that may be passed by untrusted AI agents. Environment variables (e.g.
GOOGLE_WORKSPACE_CLI_CONFIG_DIR) are set by the user themselves — in their shell profile,.envfile, or deployment config — and are not subject to path traversal validation. This is consistent with standard conventions likeXDG_CONFIG_HOME,CARGO_HOME, etc.
Path Safety (src/validate.rs)
When adding new helpers or CLI flags that accept file paths, always validate using the shared helpers:
| Scenario | Validator | Rejects |
|---|---|---|
File path for writing (--output-dir) |
validate::validate_safe_output_dir() |
Absolute paths, ../ traversal, symlinks outside CWD, control chars |
File path for reading (--dir) |
validate::validate_safe_dir_path() |
Absolute paths, ../ traversal, symlinks outside CWD, control chars |
Enum/allowlist values (--msg-format) |
clap value_parser (see gmail/mod.rs) |
Any value not in the allowlist |
// In your argument parser:
if let Some(output_dir) = matches.get_one::<String>("output-dir") {
crate::validate::validate_safe_output_dir(output_dir)?;
builder.output_dir(Some(output_dir.clone()));
}
URL Encoding (src/helpers/mod.rs)
User-supplied values embedded in URL path segments must be percent-encoded. Use the shared helper:
// CORRECT — encodes slashes, spaces, and special characters
let url = format!(
"https://www.googleapis.com/drive/v3/files/{}",
crate::helpers::encode_path_segment(file_id),
);
// WRONG — raw user input in URL path
let url = format!("https://www.googleapis.com/drive/v3/files/{}", file_id);
For query parameters, use reqwest's .query() builder which handles encoding automatically:
// CORRECT — reqwest encodes query values
client.get(url).query(&[("q", user_query)]).send().await?;
// WRONG — manual string interpolation in query strings
let url = format!("{}?q={}", base_url, user_query);
Resource Name Validation (src/helpers/mod.rs)
When a user-supplied string is used as a GCP resource identifier (project ID, topic name, space name, etc.) that gets embedded in a URL path, validate it first:
// Validates the string does not contain path traversal segments (`..`), control characters, or URL-breaking characters like `?` and `#`.
let project = crate::helpers::validate_resource_name(&project_id)?;
let url = format!("https://pubsub.googleapis.com/v1/projects/{}/topics/my-topic", project);
This prevents injection of query parameters, path traversal, or other malicious payloads through resource name arguments like --project or --space.
Checklist for New Features
When adding a new helper or CLI command:
- File paths → Use
validate_safe_output_dir/validate_safe_dir_path - Enum flags → Constrain via clap
value_parserorvalidate_msg_format - URL path segments → Use
encode_path_segment() - Query parameters → Use reqwest
.query()builder - Resource names (project IDs, space names, topic names) → Use
validate_resource_name() - Write tests for both the happy path AND the rejection path (e.g., pass
../../.sshand assertErr)
PR Labels
Use these labels to categorize pull requests and issues:
area: discovery— Discovery document fetching, caching, parsingarea: http— Request execution, URL building, response handlingarea: docs— README, contributing guides, documentationarea: tui— Setup wizard, picker, input fieldsarea: distribution— Nix flake, cargo-dist, npm packaging, install methodsarea: auth— OAuth, credentials, multi-account, ADCarea: skills— AI skill generation and management
Environment Variables
Authentication
| Variable | Description |
|---|---|
GOOGLE_WORKSPACE_CLI_TOKEN |
Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading) |
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE |
Path to OAuth credentials JSON (no default; if unset, falls back to encrypted credentials in ~/.config/gws/) |
GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND |
Keyring backend: keyring (default, uses OS keyring with file fallback) or file (file only, for Docker/CI/headless) |
| GOOGLE_APPLICATION_CREDENTIALS | Standard Google ADC path; used as fallback when no gws-specific credentials are configured |
Configuration
| Variable | Description |
|---|---|
GOOGLE_WORKSPACE_CLI_CONFIG_DIR |
Override the config directory (default: ~/.config/gws) |
OAuth Client
| Variable | Description |
|---|---|
GOOGLE_WORKSPACE_CLI_CLIENT_ID |
OAuth client ID (for gws auth login when no client_secret.json is saved) |
GOOGLE_WORKSPACE_CLI_CLIENT_SECRET |
OAuth client secret (paired with CLIENT_ID above) |
Sanitization (Model Armor)
| Variable | Description |
|---|---|
GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE |
Default Model Armor template (overridden by --sanitize flag) |
GOOGLE_WORKSPACE_CLI_SANITIZE_MODE |
warn (default) or block |
Helpers
| Variable | Description |
|---|---|
GOOGLE_WORKSPACE_PROJECT_ID |
GCP project ID override for quota/billing and fallback for helper commands (overridden by --project flag) |
All variables can also live in a .env file (loaded via dotenvy).