diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a74fbb31..fc06f6a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,34 @@ jobs: - uses: ./.github/actions/install-musl-toolchain - name: Build x86_64 musl run: cargo build --release --target x86_64-unknown-linux-musl --features vendored-openssl + - name: Smoke test auth token credential command + run: | + set -euo pipefail + bin=target/x86_64-unknown-linux-musl/release/pup + expected_token=pup-ci-placeholder-token + config_dir="$(mktemp -d)" + trap 'rm -rf "$config_dir"' EXIT + + test "$(DD_ACCESS_TOKEN="$expected_token" "$bin" --no-agent auth token)" = "$expected_token" + "$bin" --no-agent auth --help >"$config_dir/help" + grep -q '^ token' "$config_dir/help" + "$bin" completions zsh >"$config_dir/completions" + grep -q 'token:Print the current OAuth access token for credential-command integrations' "$config_dir/completions" + PUP_CONFIG_DIR="$config_dir" DD_TOKEN_STORAGE=file \ + "$bin" --agent agent schema >"$config_dir/full-schema" + ! grep -q '"full_path": "auth token"' "$config_dir/full-schema" + PUP_CONFIG_DIR="$config_dir" DD_TOKEN_STORAGE=file \ + "$bin" --agent agent schema --compact >"$config_dir/compact-schema" + ! grep -q '"full_path": "auth token"' "$config_dir/compact-schema" + + if env -u DD_ACCESS_TOKEN -u DD_API_KEY -u DD_APP_KEY \ + PUP_CONFIG_DIR="$config_dir" DD_TOKEN_STORAGE=file \ + "$bin" --no-agent auth token >"$config_dir/stdout" 2>"$config_dir/stderr"; then + echo "ERROR: auth token succeeded without credentials" + exit 1 + fi + test ! -s "$config_dir/stdout" + grep -q 'no token available' "$config_dir/stderr" - name: Verify x86_64 static linkage run: | file target/x86_64-unknown-linux-musl/release/pup diff --git a/README.md b/README.md index 252bd0ac..743ba51b 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,9 @@ pup monitors list # Check status pup auth status +# Export the current access token to a credential-command integration +pup auth token + # Logout pup auth logout ``` @@ -304,6 +307,11 @@ The storage backend can be overridden with `DD_TOKEN_STORAGE` (env var) or `toke See [docs/OAUTH2.md](docs/OAUTH2.md) for detailed OAuth2 documentation. +`pup auth token` prints the current OAuth access token for command-backed +integrations, refreshing a stored token when needed. It writes only the token to +stdout, is native-only, and is omitted from AI-agent schemas. Treat its output as +a secret. + ### API Key Authentication (Fallback) If OAuth2 tokens are not available, Pup automatically falls back to API key authentication. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1c93dd53..9dfa0732 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -22,7 +22,7 @@ pup [options] # Nested commands | Domain | Subcommands | File | Status | |--------|-------------|------|--------| | acp | serve | src/commands/acp.rs | ✅ | -| auth | login, logout, status, refresh | src/commands/auth.rs | ✅ | +| auth | login, logout, status, token, refresh | src/commands/auth.rs | ✅ | | metrics | query, list, search, timeseries, metadata, tags, submit | src/commands/metrics.rs | ✅ | | logs | search, list, aggregate, patterns, saved-views (list, get, create, delete) | src/commands/logs.rs | ✅ | | traces | metrics (list, get, create, update, delete) | src/commands/traces.rs | ✅ | diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 26eabe48..01dc074c 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -345,7 +345,9 @@ See [TESTING.md](TESTING.md) for detailed testing guidelines. **OAuth2 Security:** - Use PKCE S256 for code challenge - Validate state parameter to prevent CSRF -- Never log or print access/refresh tokens +- Never log or print access/refresh tokens, except for the explicit + `pup auth token` credential-export command, which prints only the access token + to stdout and keeps diagnostics on stderr - Use OS keychain for primary token storage - Restrict fallback file-storage permissions to `0600` diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index c3c80cce..b4ec7eeb 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -15,6 +15,9 @@ pup --site=datadoghq.eu auth login # Check authentication status pup auth status +# Print the current OAuth access token for a command-backed integration +pup auth token + # Logout pup auth logout ``` diff --git a/docs/OAUTH2.md b/docs/OAUTH2.md index 785d1403..0282de67 100644 --- a/docs/OAUTH2.md +++ b/docs/OAUTH2.md @@ -83,6 +83,23 @@ touch the shared client credentials. See [Multi-Org Support](#multi-org-support) for managing multiple named sessions side-by-side. +### 5. Export an access token to a credential command + +Native Pup builds expose `pup auth token` for programs that integrate through a +command-backed bearer-token interface: + +```bash +pup auth token +pup --org staging-child auth token +``` + +The command writes only the current access token and a trailing newline to +stdout. It uses the normal `DD_ACCESS_TOKEN`-then-stored-OAuth precedence and +refreshes an expired stored token automatically when possible. Diagnostics and +errors are written to stderr. Treat stdout as a secret: do not record it in logs, +terminal transcripts, or shell traces. This explicit export command is omitted +from Pup's AI-agent command schemas and is not available in WASM builds. + ## OAuth2 Flow Details ### Step-by-Step Process diff --git a/docs/REVIEW.md b/docs/REVIEW.md index 23c6aed5..82e67df0 100644 --- a/docs/REVIEW.md +++ b/docs/REVIEW.md @@ -33,7 +33,7 @@ See [TESTING.md](TESTING.md) for full test strategy, coverage thresholds, and co See [CONTRIBUTING.md](CONTRIBUTING.md) security guidelines for full details. Review-specific expectations: - **No malicious code.** PRs containing obfuscated code, backdoors, exfiltration attempts, unauthorized network calls, or any code that does not serve the stated purpose of the PR will be rejected and the contributor banned. -- **No credential exposure.** Never log, print, or include in error messages: API keys, tokens, secrets, or passwords. Grep your diff for `DD_API_KEY`, `DD_APP_KEY`, access tokens, and similar patterns. +- **No credential exposure.** Never log, print, or include in error messages: API keys, tokens, secrets, or passwords. The sole exception is the explicit `pup auth token` credential-export command, which may print only the access token to stdout while keeping diagnostics on stderr. Grep your diff for `DD_API_KEY`, `DD_APP_KEY`, access tokens, and similar patterns. - **Input validation at boundaries.** All user-supplied input (CLI args, environment variables, config file values) must be validated before use. Prevent command injection, path traversal, and other OWASP Top 10 vulnerabilities. ### 4. Dependency Hygiene (MUST) diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 8bb35d2b..97fcea23 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -521,7 +521,10 @@ fn build_non_oauth_status(cfg: &Config) -> (String, serde_json::Value) { } } -#[cfg(debug_assertions)] +/// Print only the current OAuth access token for an explicit credential-command +/// integration. Config loading has already applied environment/session precedence +/// and refreshed an expired stored token when possible. +#[cfg(not(target_arch = "wasm32"))] pub fn token(cfg: &Config) -> Result<()> { if let Some(token) = &cfg.access_token { println!("{token}"); diff --git a/src/main.rs b/src/main.rs index 03e76f1a..8e7e01b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11444,8 +11444,8 @@ enum AuthActions { #[arg(long, value_name = "SITE")] site: Option, }, - /// Print access token (debug builds only) - #[cfg(debug_assertions)] + /// Print the current OAuth access token for credential-command integrations + #[cfg(not(target_arch = "wasm32"))] Token, /// Refresh access token Refresh, @@ -11868,7 +11868,9 @@ fn build_compact_agent_schema(cmd: &clap::Command) -> serde_json::Value { let mut subs: Vec = cmd .get_subcommands() - .filter(|s| s.get_name() != "help") + .filter(|s| { + s.get_name() != "help" && is_visible_in_agent_schema(&full_path, s.get_name()) + }) .map(|s| compact_cmd(s, &full_path)) .collect(); subs.sort_by(|a, b| { @@ -11903,6 +11905,12 @@ fn build_compact_agent_schema(cmd: &clap::Command) -> serde_json::Value { serde_json::Value::Object(root) } +/// Keep commands that disclose credentials out of schemas presented to AI agents. +/// They remain available in normal human help for explicit credential-command use. +fn is_visible_in_agent_schema(parent_path: &str, name: &str) -> bool { + !(parent_path == "auth" && name == "token") +} + /// Returns true if a leaf subcommand name represents a write (mutating) operation. /// Used by both the read-only runtime guard and the agent JSON schema. pub(crate) fn is_write_command_name(name: &str) -> bool { @@ -12062,7 +12070,7 @@ fn build_command_schema(cmd: &clap::Command, parent_path: &str) -> serde_json::V // Subcommands — sorted alphabetically to match Go let mut subs: Vec = cmd .get_subcommands() - .filter(|s| s.get_name() != "help") + .filter(|s| s.get_name() != "help" && is_visible_in_agent_schema(&full_path, s.get_name())) .map(|s| build_command_schema(s, &full_path)) .collect(); subs.sort_by(|a, b| { @@ -12108,6 +12116,46 @@ mod test_agent_schema { assert!(schema.get("commands").and_then(|v| v.as_array()).is_some()); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn auth_token_is_hidden_from_full_agent_schema() { + let schema = get_schema(); + let commands = schema["commands"].as_array().unwrap(); + assert!(find_command(commands, &["auth", "token"]).is_none()); + assert!(find_command(commands, &["auth", "status"]).is_some()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn auth_token_is_hidden_from_compact_agent_schema() { + let cmd = Cli::command(); + let schema = build_compact_agent_schema(&cmd); + let commands = schema["commands"].as_array().unwrap(); + assert!(find_command(commands, &["auth", "token"]).is_none()); + assert!(find_command(commands, &["auth", "status"]).is_some()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn auth_token_is_hidden_from_scoped_agent_schema() { + let cmd = Cli::command(); + let auth_cmd = cmd + .get_subcommands() + .find(|s| s.get_name() == "auth") + .expect("auth subcommand not found"); + let schema = build_agent_schema_scoped(&cmd, auth_cmd, &["auth"]); + let commands = schema["commands"].as_array().unwrap(); + assert!(find_command(commands, &["auth", "token"]).is_none()); + assert!(find_command(commands, &["auth", "status"]).is_some()); + } + + #[test] + fn agent_schema_visibility_filter_is_narrow() { + assert!(!is_visible_in_agent_schema("auth", "token")); + assert!(is_visible_in_agent_schema("auth", "status")); + assert!(is_visible_in_agent_schema("other", "token")); + } + #[test] fn events_post_is_classified_as_write() { // Regression: the "post" verb must count as a write so read-only mode @@ -17544,7 +17592,7 @@ async fn main_inner() -> anyhow::Result<()> { cfg.ensure_site_trusted(cli.trust_site, interactive, &trusted_sites)?; commands::auth::status(&cfg)? } - #[cfg(debug_assertions)] + #[cfg(not(target_arch = "wasm32"))] AuthActions::Token => commands::auth::token(&cfg)?, AuthActions::Refresh => { // Refresh POSTs the stored refresh token to cfg.site; gate it like diff --git a/src/test_commands.rs b/src/test_commands.rs index d01e81ce..e63cc2b6 100644 --- a/src/test_commands.rs +++ b/src/test_commands.rs @@ -286,6 +286,33 @@ fn test_read_only_allows_skills_remote_reads() { // Auth status --site flag // ------------------------------------------------------------------------- +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auth_token_parses_and_appears_in_human_help() { + use clap::Parser; + + let cli = crate::Cli::try_parse_from(["pup", "auth", "token"]) + .expect("auth token should parse in native builds"); + assert!(matches!( + cli.command, + crate::Commands::Auth { + action: crate::AuthActions::Token + } + )); + + let help = crate::Cli::command() + .find_subcommand("auth") + .expect("auth command should exist") + .clone() + .render_long_help() + .to_string(); + assert!( + help.lines() + .any(|line| line.split_whitespace().next() == Some("token")), + "auth token missing from help: {help}" + ); +} + #[test] fn test_auth_status_accepts_site_flag() { use clap::Parser;