Skip to content

feat(secret-prompt): ask the release manager for an upload token without exposing it - #1696

Merged
potiuk merged 6 commits into
apache:mainfrom
potiuk:feat/secret-prompt
Oct 18, 2026
Merged

potiuk merged 6 commits into
apache:mainfrom
potiuk:feat/secret-prompt

Conversation

@potiuk

@potiuk potiuk commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

What we are asking for

Please review and accept:

  1. A new tool, tools/secret-prompt/ (substrate:release, stdlib-only Python). It asks the release manager for a secret and execs one command with it in one environment variable:
    secret-prompt --env TWINE_PASSWORD --set TWINE_USERNAME=__token__ --prefix pypi- -- twine upload dist/*
    
    It asks in the terminal when there is one, and otherwise through a macOS osascript dialog, zenity/kdialog on a Linux desktop, or pinentry.
  2. A recommendation, not a requirement: adopters declare credentialed stage_command/publish_command entries wrapped in it. This goes in the release-build.md template, the promote skill's convenience-artefacts.md and the release-management spec. The skills still render those commands verbatim and never ask for or handle the token themselves.

We are not asking to change how any skill behaves, to add a dependency, or to store a credential anywhere.

Why

Release steps like the PyPI upload need a credential only the release manager holds. When an agent runs the step on their behalf, or a script has no terminal, read -s has nowhere to prompt. The token then ends up in a file or on a command line, and from there in shell history, ps or the agent's transcript. This came up during Apache Airflow's 2026-10 providers waves: three PyPI uploads (two final, one rc) ran from an agent session using exactly this osascript hidden-answer dialog, with the token never written down.

Guarantees

  • The secret reaches only the one command's environment, through exec, so that command keeps its exit status.
  • Nothing runs unless a non-empty secret matching --prefix arrives; cancelling runs nothing.
  • Error messages name only the expected prefix, never what was entered.
  • Every prompt ends with what the caller can't change: "It will be passed to:" the command, resolved to an absolute path before asking and run exactly as shown, and "with:" every --set variable plus any inherited variable sharing the secret's prefix (TWINE_* for TWINE_PASSWORD, so a TWINE_REPOSITORY_URL pointing elsewhere is visible). An agent picks the command, so this is what stops a look-alike prompt from handing the token to something that prints or forwards it.
  • Those lines can't be faked. The command is shown in full or refused (over 1000 characters). Arguments, values and inherited names with a newline, escape sequence or bidi character are refused before anything is asked. --prompt/--title must be one plain line. zenity/kdialog decline text they might render as markup.
  • An inherited value is shown only when its name routes the secret (*_URL, *_HOST, *_REPOSITORY, *_INDEX, *_USERNAME, *_CERT, …) and has nothing credential-like in it. Every other value is listed as NAME=(set; value not shown), since a dialog receives the prompt in its argv. That way a credential under an unexpected name stays hidden. A URL always shows its host, so where the secret may go is never hidden: under a routing name up to its path, otherwise as scheme://host/***. --set values are shown as given.
  • Nothing is asked when an inherited variable can't be shown safely. That covers a URL carrying a user or password, a routing value that may hold a credential (user:pw@proxy, alice:s3cret, host/simple?token=…), a value too quoted to parse, and a credential-named variable that also changes how the command runs (an *_ASKPASS). The error names the variable to unset.
  • --env/--set refuse names that change how the command runs (PATH, HOME, LD_*, DYLD_*, PYTHON*, NODE_* except NODE_AUTH_TOKEN, GIT_*, …).
  • Not covered: an environment the caller already controls, such as an LD_PRELOAD or a ~/.pypirc set up beforehand. The README says so.

Testing

70 tests run without a real dialog. They cover:

  • backend selection, the run gates, and the cancel-versus-failure fallback;
  • a forced backend that isn't installed, and zenity with a stale display;
  • AppleScript receiving the prompt after --;
  • the pinentry protocol, against a fake pinentry;
  • each refusal above, the resolved-path prompt and inherited-value hiding;
  • an end-to-end run checking that the secret appears in none of the output.

ruff and mypy are clean, and the full workspace-pytest hook passed on the first commit.

Cross-references are refreshed: the capability map row, plus the regenerated docs/vendor-neutrality.md and .github/labeler.yml. The release-management-lifecycle spec is updated with a new behaviour bullet.


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Opus 5)

Generated-by: Claude Code (Opus 5)

@github-actions github-actions Bot added family:setup setup-* skills family:tools tools/* family:docs Docs, MISSION.md, READMEs family:ci .github workflows, prek, validators capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement family:release-management release-* skills labels Oct 18, 2026
Comment thread tools/secret-prompt/src/secret_prompt/__init__.py Fixed
Comment thread tools/secret-prompt/tests/test_secret_prompt.py Fixed
…out exposing it

Release steps such as the PyPI upload of convenience packages need a
credential only the release manager holds. When an agent runs the step,
or a script with no terminal, the usual "read -s" prompt has nowhere to
go, so the token ends up in a file or on a command line, and from there
in shell history, ps or the agent's transcript.

secret-prompt asks for the secret in the terminal when there is one, and
otherwise as a macOS osascript dialog, a zenity/kdialog dialog on a
Linux desktop, or pinentry. It then execs one command with the secret
in one environment variable. Every prompt names that command, so the
release manager can see where the secret goes before pasting it; a
--prefix check rejects a wrong paste, and a cancelled prompt runs
nothing. A backend that cannot ask falls through to the next one.

The release-build template, the promote skill's convenience-artefacts
reference and the release-management spec now recommend declaring
credentialed stage/publish commands wrapped in it.

Generated-by: Claude Code (Opus 5)
…e secret

Showing the command is what lets the release manager check where the
token goes, so it must not be possible to make that line lie. The
command is now resolved to an absolute path before asking, shown in full
(or refused, never truncated) and run exactly as shown; the prompt also
lists every --set variable and any inherited variable sharing the
secret's prefix, such as a TWINE_REPOSITORY_URL. Names that change how a
command runs (PATH, HOME, LD_*, DYLD_*, PYTHON*, ...) cannot be set,
--prompt and --title must be one plain line, osascript gets "--" before
the text, and zenity/kdialog decline text they might render as markup.

Generated-by: Claude Code (Opus 5)
… of the prompt

The "It will be passed to" and "with:" lines are what the operator checks
before pasting, so nothing the caller controls may rewrite them: an escape
sequence, newline or bidi character in a command argument, a value or an
inherited variable name could still fake those lines. And the prompt text
reaches a dialog in its argv, so inherited values that may be credentials
(another token, a URL with userinfo or a query) must not be copied into it.

NODE_AUTH_TOKEN, npm's publish credential, was blocked by the NODE_ denial
although it only authenticates.

Generated-by: Claude Code (Opus 5)
Hiding every inherited value whose name did not look like routing also hid
variables that redirect the secret (a TWINE_CERT, an XSP_TOKEN_URL), so the
prompt could not show where it went. Only values whose names say they are a
credential are hidden now; a URL always shows its host. A value that can be
neither shown nor hidden safely, such as a URL carrying a user and password
or a credential-named variable that also changes how the command runs, stops
the prompt instead of being quietly hidden.

Generated-by: Claude Code (Opus 5)
… the secret

Showing every inherited value whose name did not look like a credential put
any credential under an unexpected name (TOKEN2, a bare token@host) into the
dialog's argv. Hiding by default fails closed, and a URL still always shows
its host, so where the secret may go stays visible either way.

Generated-by: Claude Code (Opus 5)
- main() ended in `try: os.execve(...)` with the only return in its
  `except`, so the end of the function looked reachable and returned None
  (py/mixed-returns). execve replaces the process or raises; the 127 is
  now returned after the try, so every path returns explicitly.
- The tests imported secret_prompt both as a module and with
  `from ... import` (py/import-and-import-from). They import the names
  they use, and patch module attributes by their dotted path.

Generated-by: Claude Opus 5
@potiuk
potiuk force-pushed the feat/secret-prompt branch from 2240eea to d2ccc51 Compare October 18, 2026 18:21
@potiuk
potiuk merged commit ccb53a3 into apache:main Oct 18, 2026
61 checks passed
@github-actions github-actions Bot added the substrate:release Tool substrate: release-artefact helpers (reproducible archive build, lint, comparison) label Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement family:ci .github workflows, prek, validators family:docs Docs, MISSION.md, READMEs family:release-management release-* skills family:setup setup-* skills family:tools tools/* substrate:release Tool substrate: release-artefact helpers (reproducible archive build, lint, comparison)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants