Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e8b2569
test(cli): prohibit secrets in process argv
seonghobae Aug 9, 2026
e14bc64
test(cli): define bounded no-echo secret input
seonghobae Aug 9, 2026
ba11d16
fix(cli): ingest secrets without process argv exposure
seonghobae Aug 9, 2026
0ed6e28
test(cli): route legacy secret command through stdin
seonghobae Aug 9, 2026
2a71066
docs(security): define CLI secret input boundary
seonghobae Aug 9, 2026
b01965c
docs(cli): remove plaintext secret argv examples
seonghobae Aug 9, 2026
dbcb99c
docs(changelog): record safe CLI secret ingestion
seonghobae Aug 9, 2026
882e43c
docs(security): classify RED PR evidence accurately
seonghobae Aug 9, 2026
e88fe90
test(cli): fail closed on getpass echo fallback
seonghobae Aug 9, 2026
44ffbe5
fix(cli): refuse echoed interactive secret fallback
seonghobae Aug 9, 2026
7be44e8
test(cli): match fail-closed echo diagnostic
seonghobae Aug 9, 2026
3f394f3
docs(cli): document fail-closed echo fallback
seonghobae Aug 9, 2026
818aa5e
docs(changelog): record fail-closed secret prompt
seonghobae Aug 9, 2026
a3f6fe8
docs(cli): scope secret non-disclosure claim
seonghobae Aug 9, 2026
20e7fb2
test(cli): prevent rejected argv secret disclosure
seonghobae Aug 9, 2026
6729687
fix(cli): redact rejected argv values
seonghobae Aug 9, 2026
c771a68
docs(cli): record argv diagnostic redaction
seonghobae Aug 9, 2026
e68d645
docs(cli): document parser diagnostic confidentiality
seonghobae Aug 9, 2026
9cca9f1
test(cli): reject non-LF secret line boundaries
seonghobae Aug 10, 2026
bd342cc
fix(cli): reject non-LF secret line boundaries
seonghobae Aug 10, 2026
2c961d7
test(docs): require complete secret line boundary
seonghobae Aug 10, 2026
e1158f1
docs(cli): define complete secret line boundary
seonghobae Aug 10, 2026
4d474f9
docs(cli): record complete secret line boundary
seonghobae Aug 10, 2026
6418ae9
merge(main): reconcile secret-input hardening with exact-source gover…
seonghobae Aug 11, 2026
efe95d9
Merge protected main into agent/secret-stdin-cli
seonghobae Aug 11, 2026
cdf03c3
Merge protected main into agent/secret-stdin-cli
seonghobae Aug 11, 2026
82110ed
Merge branch 'main' into agent/secret-stdin-cli
opencode-agent[bot] Aug 11, 2026
331af12
Merge branch 'main' into agent/secret-stdin-cli
opencode-agent[bot] Aug 11, 2026
cae78e7
Merge branch 'main' into agent/secret-stdin-cli
opencode-agent[bot] Aug 11, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Removed plaintext secret values from `config set-secret` process arguments;
interactive entry now uses a no-echo prompt and automation can supply one
bounded logical line over standard input with fail-closed shape validation.
- Enforced byte-accurate control-plane limits for multi-byte `memoryview`
chunks using `nbytes`, and rejected malformed non-byte adapter chunks with
bounded body-free diagnostics.
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ relicensed to **Apache-2.0** (see [`NOTICE`](NOTICE) for provenance).
- **No secrets in the environment.** All configuration and credentials live in
Postgres KV tables (`com_config`, `com_secrets`). The environment is only a
*bootstrap transport* for the DSN and an optional Fernet key. This replaces
the ~75 `os.getenv` reads in the upstream app.
the ~75 `os.getenv` reads in the upstream app. CLI secret values are entered
through a no-echo prompt or bounded standard input, never as process arguments.
- **Disk-free assembly.** JSONL payloads are stored as `JSONB` and reconstructed
by JOIN, never written to disk.

Expand Down Expand Up @@ -71,9 +72,14 @@ curl -fsS localhost:8080/healthz
export PG_LLM_BATCH_DSN=postgresql://pgllm:pgllm@localhost:5432/pgllm
python -m pg_llm_batch init-db # idempotent
python -m pg_llm_batch config set gateway base_url https://your-gateway/v1
python -m pg_llm_batch config set-secret gateway_api_key.default sk-your-key
python -m pg_llm_batch config set-secret gateway_api_key.default # no-echo prompt
```

`config set-secret` never accepts the secret plaintext in process arguments.
On an interactive terminal it prompts without echo. Automation may pipe exactly
one bounded logical line on standard input from an existing credential source;
the command does not require or define a particular external secret manager.

Production gateway destinations must use HTTPS. Plain HTTP is accepted only for
explicit loopback development endpoints (`localhost`, `127.0.0.0/8`, or `::1`).
URLs containing user information, query parameters, fragments, whitespace, or
Expand All @@ -83,7 +89,7 @@ Encrypt secrets at rest by exporting a Fernet key as bootstrap transport:

```bash
export PG_LLM_BATCH_SECRET_KEY=$(python -c "from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())")
python -m pg_llm_batch config set-secret gateway_api_key.default sk-your-key
python -m pg_llm_batch config set-secret gateway_api_key.default # no-echo prompt
```

### 3. Count, submit, wait, retrieve
Expand Down Expand Up @@ -223,6 +229,9 @@ PG_LLM_BATCH_TEST_DSN=postgresql://pgllm:pgllm@localhost:5432/pgllm \

## Docs

- [`docs/doctoring/cli-secret-input.md`](docs/doctoring/cli-secret-input.md)
— no-echo interactive secret entry, bounded stdin automation, fail-closed
validation, verification, and security references.
- [`docs/doctoring/opentelemetry-operations.md`](docs/doctoring/opentelemetry-operations.md)
— opt-in operation traces/metrics, host ownership, privacy and cardinality
boundaries, verification, and APA 7 references.
Expand Down
63 changes: 63 additions & 0 deletions docs/doctoring/cli-secret-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# CLI secret-input boundary

## Problem

`config set-secret` is an operator credential-ingestion boundary. A secret value
must not be supplied as a positional command-line argument because process
arguments are an observable invocation surface on common operating systems and
can also be retained by shell tooling. The secret-store encryption boundary does
not mitigate exposure that happens before the value reaches PostgreSQL.

## Contract

The command accepts only the secret key in process arguments:

```text
python -m pg_llm_batch config set-secret gateway_api_key.default
```

The value is acquired separately:

- when standard input is an interactive terminal, `getpass.getpass()` requests
one value without echoing typed characters;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- when standard input is non-interactive, exactly one bounded logical line is
read from standard input so an existing secret-management process can pipe a
value without placing it in the `pg_llm_batch` process argument vector;
- one terminal LF or CRLF is removed from non-interactive input;
- empty, multiline, carriage-return-containing, and values longer than 65,536
characters fail closed before `SecretStore` is constructed or written; and
- errors, success output, logs, and tests never include the supplied plaintext.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

The non-interactive path intentionally does not define or require a particular
external secret manager. Deployment owners may connect their existing
credential source to standard input without changing the repository's secret
storage or bootstrap-key contract.

## Verification

`tests/test_cli_secret_input_security.py` proves that plaintext is rejected from
process arguments, TTY input uses the no-echo primitive rather than an ordinary
stdin read, piped input is bounded and single-line, common terminal line endings
are normalized, and unsafe shapes fail closed. `tests/test_bootstrap_cli.py`
keeps the existing CLI routing test while providing its fixture value through
stdin.

The development sequence is intentionally test-first. PR CI run `31298176124`
was triggered from RED source head `e8b256992b34a8651ab5a2f0cef350d5fbde4b75`
but, under the current protected-main workflow, checked GitHub synthetic merge
commit `519ada6d67a01e66b877fc48d0b0ae5691a428d9`. It failed the two initial
secret-input contracts on Python 3.10, 3.12, and 3.14 because the old parser
still accepted a positional secret and required it when stdin-only invocation
was attempted. This is valid fail-first development evidence for the behavior,
but it is not exact-source-head acceptance evidence. No predecessor or
synthetic-merge-only result is counted as post-fix acceptance.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

## References

MITRE. (2025). *CWE-214: Invocation of process using visible sensitive
information*. Common Weakness Enumeration.
https://cwe.mitre.org/data/definitions/214.html

Python Software Foundation. (2026). *getpass — Portable password input*.
Python 3.14 documentation.
https://docs.python.org/3/library/getpass.html
52 changes: 46 additions & 6 deletions pg_llm_batch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
init-db apply the batch schema (idempotent)
config set set a KV config value
config get read a KV config value
config set-secret store a secret (Fernet-encrypted when a key is present)
config set-secret store a secret from no-echo TTY input or stdin
count-tokens count tokens for text via pg_tiktoken
submit upload a prepared batch payload and create a batch job
poll poll a batch job's status once
Expand All @@ -16,13 +16,15 @@
serve-healthz serve GET /healthz

The DSN is resolved from --dsn or the PG_LLM_BATCH_DSN bootstrap env var only.
All other config/secrets come from the database KV stores.
All other config/secrets come from the database KV stores. Secret plaintext is
never accepted as a command-line argument.
"""

from __future__ import annotations

import argparse
import asyncio
import getpass
import json
import sys
from typing import List, Optional
Expand All @@ -31,10 +33,12 @@
from .batch_api_client import BatchAPIClient, config_credentials_provider
from .bootstrap import resolve_dsn, resolve_secret_key
from .config import PostgresConfigStore, SecretStore
from .exceptions import PgLlmBatchError
from .exceptions import ConfigError, PgLlmBatchError
from .health import check_health, serve_healthz
from .token_counter import TokenCounter

MAX_SECRET_INPUT_CHARACTERS = 65_536


def _add_common(parser: argparse.ArgumentParser) -> None:
"""Add the shared ``--dsn`` option to a subcommand parser."""
Expand All @@ -45,6 +49,39 @@ def _add_common(parser: argparse.ArgumentParser) -> None:
)


def _validate_secret_input(value: str) -> str:
"""Require one non-empty bounded line of secret input without echoing it."""
if not value:
raise ConfigError("Secret value must not be empty")
if len(value) > MAX_SECRET_INPUT_CHARACTERS:
raise ConfigError(
f"Secret value must not exceed {MAX_SECRET_INPUT_CHARACTERS} characters"
)
if "\n" in value or "\r" in value:
raise ConfigError("Secret value must be a single line")
return value


def _read_secret_input() -> str:
"""Read one bounded secret from a no-echo TTY prompt or standard input.

Interactive terminals use :func:`getpass.getpass`, so typed credentials are
not echoed. Non-interactive callers may pipe exactly one logical line on
standard input; one trailing LF or CRLF line ending is removed. Secret
plaintext is never accepted through process arguments.
"""
is_tty = getattr(sys.stdin, "isatty", None)
if callable(is_tty) and is_tty():
return _validate_secret_input(getpass.getpass("Secret value: "))

raw = sys.stdin.read(MAX_SECRET_INPUT_CHARACTERS + 3)
if raw.endswith("\r\n"):
raw = raw[:-2]
elif raw.endswith("\n"):
raw = raw[:-1]
return _validate_secret_input(raw)


def build_parser() -> argparse.ArgumentParser:
"""Build the command-line parser and all supported subcommands."""
parser = argparse.ArgumentParser(
Expand All @@ -67,10 +104,12 @@ def build_parser() -> argparse.ArgumentParser:
_add_common(p_get)
p_get.add_argument("category")
p_get.add_argument("key")
p_secret = cfg_sub.add_parser("set-secret", help="Store a secret")
p_secret = cfg_sub.add_parser(
"set-secret",
help="Store a secret from a no-echo prompt or standard input",
)
_add_common(p_secret)
p_secret.add_argument("secret_key")
p_secret.add_argument("secret_value")

p_count = sub.add_parser("count-tokens", help="Count tokens for text")
_add_common(p_count)
Expand Down Expand Up @@ -149,8 +188,9 @@ def _dispatch(argv: Optional[List[str]]) -> int:
print(store.get(args.category, args.key))
return 0
if args.config_command == "set-secret": # pragma: no branch - exhaustive parser
secret_value = _read_secret_input()
secrets = SecretStore(dsn, fernet_key=resolve_secret_key())
secrets.set_secret(args.secret_key, args.secret_value)
secrets.set_secret(args.secret_key, secret_value)
print("Secret stored.")
return 0

Expand Down
3 changes: 2 additions & 1 deletion tests/test_bootstrap_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import io
import json
import runpy
from types import SimpleNamespace
Expand Down Expand Up @@ -74,14 +75,14 @@ def set_secret(self, key, value):
assert cli._dispatch(
["config", "get", "--dsn", "postgresql://x", "gateway", "url"]
) == 0
monkeypatch.setattr(cli.sys, "stdin", io.StringIO("secret"))
assert cli._dispatch(
[
"config",
"set-secret",
"--dsn",
"postgresql://x",
"gateway_api_key.default",
"secret",
]
) == 0

Expand Down
Loading
Loading