Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pup <domain> <subgroup> <action> [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 | ✅ |
Expand Down
4 changes: 3 additions & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
3 changes: 3 additions & 0 deletions docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
17 changes: 17 additions & 0 deletions docs/OAUTH2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
58 changes: 53 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11444,8 +11444,8 @@ enum AuthActions {
#[arg(long, value_name = "SITE")]
site: Option<String>,
},
/// 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,
Expand Down Expand Up @@ -11868,7 +11868,9 @@ fn build_compact_agent_schema(cmd: &clap::Command) -> serde_json::Value {

let mut subs: Vec<serde_json::Value> = 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| {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<serde_json::Value> = 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| {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/test_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down