Skip to content

Security: dan-petty/devops-cli

SECURITY.md

Security Policy — DevOps CLI

DevOps CLI follows an enterprise zero-trust security model designed to safeguard developer workstations, local and remote Kubernetes clusters, CI/CD pipelines, and agentic AI execution environments.


Supported Versions

Only the latest release gets security fixes. A fix ships in the next release.


Zero-Trust Security & Egress Safety Principles

All code authored in or evaluated by DevOps CLI must strictly adhere to the following security guarantees:

  1. Zero Secret Leakage: Plaintext secrets, tokens, private keys, or API credentials must never be committed to code repositories, written to unencrypted configuration files, or emitted into terminal outputs/logs. Each secret comes from a secret store: the OS keyring, HashiCorp Vault, a Kubernetes Secret or the credential store of the tool that owns it. Never copy the content of a gitignored file (.env*, .ssh/, .data/, credentials, private keys) into a document, changelog, finding, commit or code.

  2. Strict Egress & SSRF Protection: All outbound HTTP requests are built by devops_cli.http.entry and checked at the connect by the egress transport of devops_cli.http.client against their egress level (public, loopback or private) to prevent Server-Side Request Forgery (SSRF). Kept pre-flight checks (Qdrant, kubectl manifest apply, container engine endpoint, config init wizard) call vet_addresses directly at their level. Cloud metadata addresses are refused at every level. The in-process kubernetes client (urllib3) is trusted because its address comes from the operator's kubeconfig or the in-cluster environment.

  3. Subprocess Bounded Execution: All external commands (git, helm, kubectl, tofu, docker) are executed via bounded argument lists (preventing shell injection) with mandatory non-infinite timeouts.

  4. Data Isolation: Agent-generated review logs, benchmarks, and temporary telemetry artifacts are isolated under dedicated agent subfolders (<data_dir>/agent, e.g. ./.data/agent), completely segregated from the primary user workspace data tier (DEVOPS_CLI_DATA_DIR / data.dir).

  5. Least Privilege Runtime: Kubernetes pods, Docker containers, and CI jobs run under unprivileged, non-root user contexts (USER 1000:1000).

  6. Host Sandbox Confinement: Executable verification criteria and model-generated shell commands are confined within Linux unprivileged namespaces via bubblewrap (bwrap), enforcing unshared namespaces, cleared environment, read-only repository and system mounts, zero network egress, and isolated POSIX process groups. The host sandbox's /tmp is a private 64 MiB tmpfs mounted nosuid and nodev, which the confined command can write to and execute from, because bubblewrap has no noexec option for a tmpfs.

  7. Review Tool Execution: A review starts its tools through a ToolExecutor (src/devops_cli/security/tool_executor.py). The tools are Bandit, Gitleaks, Semgrep, Trivy, kube-linter, Pluto, osv-scanner, golangci-lint, Ruff, mypy and the canary of each. The record of each run names the executor in force. It is the executor field in tool_runs of profile.json, and the Executor column of the Tool Runs table in review.md. The record also names the network of the tool in the network field: host for the direct executor, none for the sandbox, and kube-api for the sandbox with that network profile. Two executors exist:

    • sandbox is SandboxToolExecutor, and it is the default of devops review path, branch and pr and of their MCP tools. It runs the tool in a bubblewrap sandbox with no network, a cleared environment, a read-only tree and a private HOME. The sandbox shows the system directories, the tree and only what the request names: the program of the tool (its virtualenv, with the Python installation that the virtualenv links to, behind the guard of HostSandbox), its config and ruleset files, the data that a fetch step got before (the Trivy cache and the osv-scanner database, read-only; devops review fetch-databases is that step, and a scan that lacks a database, or finds the osv-scanner database older than 24 hours, gives a coverage gap that names it) and the host paths in CONST_TOOL_HOST_READ_ONLY_PATHS (the CA certificates for Semgrep). golangci-lint also gets the Go toolchain and the module cache that go env names on the host, read-only, with the bin of the toolchain first on its PATH. Its go command fetches no module and no toolchain (GOPROXY=off, GOTOOLCHAIN=local), so a module whose modules are not in the module cache gives a coverage gap that tells the person to run go mod download. A directory that the tool writes, the cache of mypy or of Ruff, or the Go build cache and the golangci-lint cache, for the tree, is bound read-write. The sandbox refuses a bind that holds the home directory or the tree, or passes through a credential directory. Each process of the tool has the CPU limit CONST_TOOL_CPU_LIMIT_SECONDS and the address-space limit CONST_TOOL_MIN_ADDRESS_SPACE_BYTES. Gitleaks and Semgrep get the larger value that CONST_TOOL_ADDRESS_SPACE_BYTES names, and Semgrep runs CONST_SEMGREP_JOBS jobs. A Go tool gets GOMEMLIMIT set to CONST_TOOL_GO_MEMORY_LIMIT. The wall-clock limit applies as well. The sandbox sets no limit on the number of processes, because RLIMIT_NPROC counts every process of the user and would starve the host.
    • direct is DirectToolExecutor. It starts the tool as a child process of devops-cli. It has no confinement: the tool has the network, the environment and the files of the user. It has no limit except the wall-clock time of the scan, DEFAULT_SECURITY_SCANNER_TIMEOUT_SECONDS. A review never uses it: no option, config key or MCP tool turns the sandbox off (decision 1 of #772). The scans of devops scan and of the agent tools are not reviews, and each scanner except Popeye uses this executor there.
    • The sandbox has one network profile, kube-api, and only Popeye can select it (decision 2 of #772). devops k8s audit, the agent tool scan_popeye and the scanner registry run Popeye in the sandbox with this profile, from an empty directory. The sandbox keeps no network of its own (--unshare-all). Outside the sandbox, kubectl proxy listens on a Unix socket in a scratch directory for one context. It reads the kubeconfig and the credentials of the user, and refuses the methods POST, PUT, PATCH and DELETE (CONST_KUBE_API_REJECTED_METHODS) and the exec, attach, port-forward and proxy paths (CONST_KUBE_API_REJECTED_PATHS). setpriv --pdeathsig KILL ends the proxy with the run. In the sandbox, socat relays 127.0.0.1:8001 to the socket, and Popeye reads a kubeconfig that names the relay and holds no credentials. The sandbox binds the scratch directory and /etc/passwd read-only. So Popeye reaches only the API server of the context, only to read, with the RBAC of the user. The limits: the path filter is a deny list, so a new subresource that reaches past the API server passes until CONST_KUBE_API_REJECTED_PATHS lists it, and Popeye reads each response that the user can read, such as a Secret. The direct executor refuses a request that selects the profile. A host without sh, socat, kubectl or setpriv, or one that cannot start the sandbox, runs no Popeye scan: the scan reports a coverage gap with the cause, and devops k8s audit exits 1.

    Before its first tool runs, a review checks that the host can start the sandbox: bubblewrap and prlimit are installed, and bubblewrap can make a sandbox. On Ubuntu 24.04 and later, AppArmor refuses unprivileged user namespaces to a program that has no profile with the userns rule (kernel.apparmor_restrict_unprivileged_userns). A host that fails the check fails closed: no tool runs, in the sandbox or outside it, each scan reports a coverage gap with the cause, and the review says the cause and what a person can change on the host one time. The record is read before the tool starts. Under the direct executor, a tool that fetches its rules during the run, such as Trivy with an old cache, may use newer rules than the record names, and Ruff also applies a config file above the tree and its user config file, which the record does not name.

  8. Repository Hygiene: Never write an internal hostname (*.lan, *.local, a machine name), a private address (RFC 1918), a private registry, a non-standard NodePort, a device or mount path, or the cluster topology into a committed file. Use <storage-node>, <gpu-node>, <worker-node> or <host> for a host, RFC 5737 blocks (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) or loopback for an address, ghcr.io/<org>/<image>:<tag> for a registry and <storage-mount-path> for a path. Committed templates (config.example.yaml) use localhost endpoints. The user's config.yaml is not committed: never reset its URLs or ports. tests/invariants/test_committed_addresses.py holds this rule for addresses and .lan hosts. It parses each tracked file with ipaddress and fails on an RFC 1918 host address or a .lan host. A range in CIDR form names no machine, and the upstream defaults and examples that the test names (the minikube node, the AKS DNS service, the OpenTelemetry server.address example) are not ours, so both pass. Test code, which must hold private addresses to test the egress and reference classifiers, the archived OpenSpec changes and CHANGELOG.md, which release-please writes, are outside its scope.

  9. No Phone-Home: Turn off third-party telemetry, usage reports, crash analytics and update checks in every tool, workload and container when the user loses no function. Examples are DO_NOT_TRACK=1, GOTELEMETRY=off, PIP_DISABLE_PIP_VERSION_CHECK=1, HF_HUB_DISABLE_TELEMETRY=1 and OPENSPEC_TELEMETRY=0. Ask the user before you turn off a feature that shows content, such as update notices or a marketplace.

  10. Bounded Error Detail: Cap each external value in an exception details dictionary or a structured log at 256 characters (CWE-209, CWE-400).

  11. MCP Tool Inputs: A URL or host that a model gives to an MCP tool is untrusted input, not configuration. A tool publishes no option for it, and queries the service that the configuration names. _PATH_PARAM_RESTRICTIONS in src/devops_cli/ai/mcp/generator.py sets the options that a tool publishes. A credential goes only to the origin that the configuration names, whatever the egress level is. _key_for in src/devops_cli/ai/gateway.py applies this rule to the AI API key. A tool that publishes such an option is a defect. A file path that a model gives is untrusted input too: a tool publishes no option for a path that the command writes or reads, unless a path helper confines the path (safe_resolve_subpath in src/devops_cli/core/paths.py).

The --url option of the devops valkey commands is an accepted case. A person types it, and no model gives the URL. So valkey.password goes to the host that the person names. Six of the nine devops valkey commands are MCP tools: valkey_ping, valkey_stats, valkey_info, valkey_get, valkey_set and valkey_flush. These tools publish no URL option. The commands valkey keys, valkey backup and valkey cli are not MCP tools.

The streamable-http transport is a network listener. See MCP Server on the Network.

  1. MCP Tool Secrets: A model writes the arguments of an MCP tool call. A secret in an argument passes through the model context, the provider, the LLM cache and the run trace, and the model can send it to another tool. So a tool publishes no parameter that takes a secret value (a password, a token, an API key or a private key). A credential for a call is carried on the connection, as a header that the harness sets, or the server reads it from its configuration, or it is an opaque reference that the server resolves from the secret store, such as keyring:<name> for the OIDC token of docker_sign. A tool never returns or logs a credential, so vault_get publishes no show option, and vault_set is not a tool. CONST_MCP_SECRET_PARAMETER_WORDS in src/devops_cli/config/constants.py lists the words that make a parameter name suggest a secret. tests/ai/mcp/test_mcp_tool_secrets.py reads the published schema of every tool and fails on a text parameter whose name holds one of them. CONST_MCP_SECRET_PARAMETER_ALLOWLIST names the parameters that hold no secret value, such as the name of a Kubernetes Secret. A new entry needs a reason, and the test fails on an entry that matches no parameter. RunTrace hides the value of an argument under such a name, and mask_secrets is the second guard. ArgumentValueLogFilter keeps all arguments out of the FastMCP log.

  2. Hosted Models: The LiteLLM gateway serves four groups that call a provider outside the cluster: gemini-3.8-flash, claude-sonnet-5-5, claude-haiku-5-5 and deepseek-v4.1-flash-1017 (through OpenRouter, which picks a third-party provider and is asked to skip providers that store or train on prompts). A prompt sent to one of them leaves the cluster, and the provider bills each token. So no pool names a hosted group, no fallback sends a group that is not hosted to a hosted group, and a request reaches a provider only when its caller names a hosted group. The Service config names deepseek-v4.1-flash-1017 for every chat slot: ai.model and the tasks analysis, implement, verification, chat and triage (k8s/devops/configmap.example.yaml, owner decision of 2026-11-07, #3874). So the code of the repositories of the Service, which can be private, leaves the cluster to OpenRouter in the prompts of a step, and so do the titles and bodies of the issues in the prompts of intake and chat; claude-sonnet-5-5 serves no slot of the Service. Only the embedding slot stays in the cluster. The one fallback, claude-sonnet-5-5 to deepseek-v4.1-flash-1017 (#3152), sends the prompt of a failed Claude call to OpenRouter, not to Anthropic. The keys are optional keys of the Secret llm/llm-gateway-secrets (keyring: gemini.api_key, anthropic.api_key, openrouter.api_key). The gateway NetworkPolicy admits TCP 443 to public addresses and to no private or metadata range. A Claude group sets its prices, drops the sampling parameters that its API refuses, and sets supports_native_structured_output and supports_response_schema, or a structured call becomes a forced tool call that Sonnet 5.5 refuses (#3140). tests/ai/benchmark/test_k8s_llm_gateway.py fails when any of this breaks.

  3. Roadmap Item Authors: The repository is public, and a roadmap job gives the text of an item to a model. The model writes into the roadmap and, through Ready items, steers the agents that build.

    • The authors. A job takes an item only when its author is a person with write access. For such a person, author_association is OWNER, MEMBER or COLLABORATOR (#2478). A job also takes an item of two bots. Their login is exactly service.github_app.bot_login or github-actions[bot] (#3502).
    • The login. GitHub gives both bots the association CONTRIBUTOR. It gives the same association to any account with a merged commit. So the exact login is the only proof. The check compares the whole login, so a login that only resembles it is not trusted. With no service.github_app.slug set, no item of the App is trusted.
    • Other accounts. A job leaves out any other bot and any outside account. Refine tells the author once with a comment.
    • The review. An item of a bot is text that no person wrote. It reaches Ready only after the review of refine approves the issue and the proposal (refine.judge). The stage, implement and alternatives jobs take it only with that approval. The approval holds only for the body that the review read. A body edit ends it, and a refine round reviews the item again.
    • The controls. The review is a model, and text in an item can mislead it, so it is no security control. The controls are the login, the filter for replies, and the person who merges each pull request. Refine and the alternatives job give their models only the replies of a person with write access and of the App's bot. A comment of a bot starts no implement: retry, because that check keeps the association test.
    • The risk that stays. github-actions[bot] is the identity of every workflow of the repository. A workflow that copies untrusted text into an issue gives that text a path to Ready, behind the review. A record block in that text would forge the approval, because the job record is a hidden block of the issue body. The audit workflow writes a fixed text, and refine neutralizes <! in the text that it files for a Service item (#2478). So neither gives that path today.
    • The tests. tests/roadmap/test_roadmap_trust.py and tests/roadmap/refine/test_roadmap_refine_trust.py fail when any of this breaks.

MCP Server on the Network

The streamable-http transport of devops mcp serve is a network listener, and it fails closed (src/devops_cli/ai/mcp/remote.py). It refuses to start without the mcp.token credential (DEVOPS_CLI_MCP_TOKEN, at least 32 characters), and it needs --allow-remote for a host that is not loopback. Every request must carry that token as a bearer token. The server compares it in constant time before any tool runs, and answers 401 otherwise. The server is meant to be reachable only from the LiteLLM gateway, which holds the token, and a NetworkPolicy must admit only the gateway. The role of a connection is its path: /planning/mcp, /implementation/mcp and /review/mcp each serve a separate FastMCP server that holds only the tools of its role (src/devops_cli/ai/mcp/roles.py), so a tool outside the role does not exist on that path. A job role holds a tool only when the role table names it and the exposure table marks it read-only. The table names no tool that reads or writes a path that a model gives, because on the server the path is a path of the server. The ops role holds every tool, has no endpoint and is never served over the network. A role server has no resource, no prompt and no hydrate_tool_domain, so no connection changes what another connection sees. The server logs the role and the tool name of each call, and no argument. tests/ai/mcp/test_mcp_http.py and tests/ai/mcp/test_mcp_roles.py fail when any of this breaks.

A Finding with No Fix

A scanner can name a finding that no update of the pinned package solves yet. The agent then adds a time-limited ignore entry itself and does not ask the owner. The entry expires by itself, and the failing scan after the date is the reminder. The owner set these limits on 2026-11-07, and the owner can lift them.

When the rule applies. A scanner (Trivy on an image, OSV, uv audit or npm audit) names a vulnerable package or a vendored library, and no released version of the package that the project pins holds the fix. Prove it in a pull request comment: the fixed version that the scanner names, the newest release of the pinned package, what the go.mod or lock file of that release holds, and the date you read it. If any released version holds the fix, bump the pin instead. The two-week release rule still applies. Do not ignore the finding.

What the agent does.

  1. Add one entry to .trivyignore, or to the ignore file of the other scanner. Scope it as narrowly as the scanner allows: the finding ID, and the path or package when the file format holds them. Give it a reason, a link to the tracking issue, and an exp: date. The date is the day of the entry plus 3 days.
  2. Search open issues for a duplicate, then file the tracking issue "bump <package> and drop the ignore".
  3. Open the pull request with the entry.

Renewal. On the date, check again for a released fix. If one exists, bump the pin and delete the entry. If none exists, renew the entry for 3 more days and put the new proof in the pull request. The first entry and its renewals last at most 14 days in all. After that, stop and ask the owner: a longer ignore is the owner's decision. An entry that the owner names with another date keeps that date.

What the agent never does.

  • Ignore a finding of severity CRITICAL. Ask the owner.
  • Ignore a secret finding (gitleaks), a finding in code of this project, or a finding for which a fix exists.
  • Ignore a finding of a package that only the owner pins, by a decision that an ADR records.
  • Use a wildcard.
  • Lower the severity threshold, turn off ignore-unfixed, or turn off a scanner.
  • Add an ignore without a reason, an exp: date and a tracking issue.

Report. The pull request comment lists each ignore that the agent added, its expiry and its tracking issue. The final message of the agent says the same.

Reporting a Vulnerability

We deeply appreciate responsible security disclosures. If you discover a security vulnerability within DevOps CLI:

  1. Do NOT open a public issue, pull request, or discussion.
  2. Report the vulnerability privately via GitHub Security Advisory.
  3. If you cannot use GitHub Security Advisories, contact the project maintainers directly via email at contact@danielpetty.com with the subject prefix [SECURITY VULNERABILITY].

What to Include

To help us triage and remediate the issue rapidly, please include:

  • A detailed description of the vulnerability and its potential impact.
  • Affected component or CLI subcommand.
  • Step-by-step reproduction instructions or a minimal Proof of Concept (PoC).
  • Any proposed remediation or mitigation steps.

Response & Remediation SLA

  • Initial Triage & Acknowledgment: Within 24 hours of receipt.
  • Vulnerability Assessment & Severity Rating: Within 48 hours using CVSS v3.1 scoring.
  • Patch Development & Release: Critical security fixes merge into main and ship in a release within 72 hours of triage confirmation.
  • Public Disclosure: Coordinated after the patch release is confirmed and deployed.

There aren't any published security advisories