Support fetching tokens from an external command - #469
Conversation
Note on integration test count updates (
|
|
@ammachado, thanks for looking into the test issues. Yes, it really seems that some of the expected issues are not included in the search results anymore. Interestingly, for pull requests your changes seem not needed anymore. Something weird is happening on the GitHub side. Here's a slightly modified version of your adjustments: |
psss
left a comment
There was a problem hiding this comment.
Thanks much for implementing this! Looks good, just two minor comments. Also the following statement from the pull request description is not valid, right?
- Breaking change: setting more than one of
token,token_file,token_commandin a section is now a hardConfigError. Previously the lower-precedence keys were silently ignored.
…re widely - Remove CLAUDE.md from this branch per review request; the repo guidance content is fine but belongs in its own PR. - Document token_command (alongside token/token_file) in the github and gitlab plugin docstrings, matching the jira/confluence notes.
…re widely - Remove CLAUDE.md from this branch per review request; the repo guidance content is fine but belongs in its own PR. - Document token_command (alongside token/token_file) in the github and gitlab plugin docstrings, matching the jira/confluence notes.
…re widely - Remove CLAUDE.md from this branch per review request; the repo guidance content is fine but belongs in its own PR. - Document token_command (alongside token/token_file) in the github and gitlab plugin docstrings, matching the jira/confluence notes.
lukaszachy
left a comment
There was a problem hiding this comment.
LGTM, finally a safer way to store tokens
| type = gitlab | ||
| url = https://gitlab.com/ | ||
| token = <authentication-token> | ||
| token_file = <authentication-token-file> |
There was a problem hiding this comment.
| token_file = <authentication-token-file> | |
| token_command = <command-to-fetch-token> |
There was a problem hiding this comment.
Since both, token and token_file are already listed, I think we should add the token_command as well.
| type = jira | ||
| url = https://issues.redhat.com/ | ||
| auth_type = token | ||
| token_file = ~/.did/jira_api_token |
There was a problem hiding this comment.
| token_file = ~/.did/jira_api_token | |
| token_command = <command-to-fetch-token> |
| Shell-style command line whose stdout is used as the token, e.g. | ||
| ``bw get password did-jira`` or | ||
| ``op read op://Personal/Jira/token``. The command is parsed with | ||
| ``shlex`` and executed without a shell. |
There was a problem hiding this comment.
I'm a bit confused what "without a shell" means here. Will there be no environment variables available when the command is run? The reason I ask is that for bitwarden you need the BW_SESSION env var to be set.
There was a problem hiding this comment.
Fair point, the wording was misleading. "Without a shell" only meant that the argv is built with shlex.split() and handed straight to subprocess.run() instead of going through /bin/sh. The environment is untouched: subprocess.run() inherits os.environ by default, so BW_SESSION (and OP_SERVICE_ACCOUNT_TOKEN, GNUPGHOME, etc.) are visible to the command.
What you do not get is shell expansion: no pipes, no redirects, no $VAR substitution, no globbing. So bw get password did-jira works, but bw get password did-jira | tr -d '\\n' would not. sh -c '...' still works if someone really wants a pipeline.
Documented that explicitly in 1f3a204, both in the jira plugin docstring and in the _run_token_command docstring.
| log.info("Using login alias '%s' for '%s'", login, stats) | ||
|
|
||
|
|
||
| @lru_cache(maxsize=None) |
There was a problem hiding this comment.
I wonder if it is such a good idea to cache passwords?
There was a problem hiding this comment.
Worth spelling out, since "cache" is a loaded word for secrets.
This is an lru_cache on a module-level function, so it lives entirely in the process heap and dies with it. Nothing is written to disk, no temp file, no keyring, no cross-invocation state. did is a short-lived CLI, so "lifetime of the process" is the duration of one report.
It also does not widen exposure: get_token() returns the token to its caller, which hands it to the plugin, which keeps it on the session object for the whole run. The secret is already resident in memory for at least as long as the cache holds it. Dropping the lru_cache would not remove the token from memory, it would just make us shell out to the password manager once per config section.
That last part is the actual motivation. A config with [jira], [gitlab] and [github] all pointing at the same token_command would otherwise trigger three separate bw get/op read calls, each with its own network round trip and, for some setups, its own biometric or PIN prompt. Failures are deliberately not cached, so a transient error can be retried.
Documented the reasoning in 1f3a204. If you would still rather not keep it, the alternative I would suggest is caching per Config instance instead of per process, but that is a larger change and I do not think it buys real security.
| except subprocess.CalledProcessError as exc: | ||
| raise ConfigError( | ||
| f"Token command failed (exit {exc.returncode}): " | ||
| f"{exc.stderr.strip()}") from exc |
There was a problem hiding this comment.
I think it does make sense to show the actual command in all cases FileNotFoundError, TimeoutExpired, CalledProcessError.
There was a problem hiding this comment.
Agreed, fixed in 1f3a204. The command line is now in all three messages:
Token command not found: bw: bw get password did-jira
Token command timed out after 30s: bw get password did-jira
Token command failed (exit 1): bw get password did-jira: You are not logged in.
FileNotFoundError keeps exc.filename as well, since that names the specific binary that is missing, which is not obvious when the command is something like sh -c '...'.
| """ Tests for the `get_token` function """ | ||
|
|
||
| def setUp(self) -> None: | ||
| # Clear the per-process token-command cache so memoized results |
There was a problem hiding this comment.
Typo?
| # Clear the per-process token-command cache so memoized results | |
| # Clear the per-process token-command cache so memorized results |
There was a problem hiding this comment.
Not a typo, "memoized" is the standard term for what functools.lru_cache does, and codespell in the pre-commit config does not flag it. But it clearly read as one, which is reason enough, so I reworded it to "cached results" in 1f3a204. Same meaning, no double-take.
| res = did.utils.color( | ||
| "text", text_color=None, background=None, light=False, enabled=True) | ||
| assert res == "\033[0mtext\033[1;m" | ||
| assert res == "\033[0mtext\033[0m" |
There was a problem hiding this comment.
Same here. Why make this change in this PR?
There was a problem hiding this comment.
These three assertions are the direct consequence of the color() change in did/utils.py, answered in detail there: #469 (comment)
Short version: the reset sequence emitted by color() changed from \033[1;m to \033[0m, so the expected strings had to follow or the tests fail. They are not an independent change, and if the utils.py change gets split out into its own PR these three go with it.
| res = did.utils.color( | ||
| "text", text_color="red", background=None, light=False, enabled=True) | ||
| assert res == "\033[0;31mtext\033[1;m" | ||
| assert res == "\033[0;31mtext\033[0m" |
There was a problem hiding this comment.
Same as above: follows the color() reset change. #469 (comment)
| res = did.utils.color( | ||
| "text", text_color="red", background=None, light=True, enabled=True) | ||
| assert res == "\033[1;31mtext\033[1;m" | ||
| assert res == "\033[1;31mtext\033[0m" |
There was a problem hiding this comment.
Same as above: follows the color() reset change. #469 (comment)
| additional_dependencies: | ||
| [ | ||
| bodhi-client, | ||
| feedparser, | ||
| google-api-python-client, | ||
| gssapi, | ||
| koji, | ||
| nitrate, | ||
| oauth2client, | ||
| pytest, | ||
| python-bugzilla, | ||
| python-dateutil, | ||
| requests, | ||
| requests-gssapi, | ||
| setuptools, | ||
| tenacity, | ||
| ] |
There was a problem hiding this comment.
Why do we need those dependencies added in this PR?
There was a problem hiding this comment.
The pylint hook was failing with import-error across the plugin modules. import-error is not in the disable= list in .pylintrc, and pre-commit runs each hook in its own isolated virtualenv, so the init-hook sys.path extension only ever sees the hook's own venv. Without these, pylint cannot resolve feedparser, koji, nitrate, bodhi-client and friends, and reports an error per plugin.
This is not a new pattern, it is the same list the mypy hook right below already carries:
- repo: https://github.com/pre-commit/mirrors-mypy
hooks:
- id: mypy
additional_dependencies: [
bodhi-client,
feedparser,
google-api-python-client,
...The pylint hook was simply missing the equivalent. The difference in the two lists is that pylint needs the real packages where mypy wants the stubs, hence requests and python-dateutil instead of types-requests and types-python-dateutil.
Fair to ask why it landed here rather than separately, and the honest answer is that it surfaced while getting the full pre-commit suite green for this branch. Worth noting @psss reviewed it and then added setuptools to the same list himself in 4cd37b4, so the list as it stands is partly his. I can still split it into a standalone PR if you would rather keep this one narrow.
- List `token_command` in the gitlab and jira config examples, next to the `token` and `token_file` entries that were already there - Clarify that the token command inherits did's environment (so `BW_SESSION` and friends work) and only loses shell expansion - Include the command line in the `FileNotFoundError` and `CalledProcessError` messages, matching the timeout message - Document why caching the token for the process lifetime does not widen exposure of the secret - Reword a test comment that read as a typo Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add `token_command` as a third token source alongside `token` and `token_file`, so secrets can be pulled from password managers such as BitWarden (`bw get password ...`) or 1Password (`op read op://...`). The command is parsed with shlex (no shell) and its stdout is used as the token; failures raise `ConfigError`. Results are memoized per process so multiple sections sharing a command only invoke the tool once. Setting more than one of `token`, `token_file`, `token_command` is now a hard `ConfigError` (previously the lower-precedence keys were silently ignored). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Fix terminal color reset sequence (\033[1;m -> \033[0m) so background color does not bleed into subsequent output - Extract the human-readable 'message' field from GitHub API JSON error responses instead of printing the raw JSON blob - Drop the Future object reference from ReportError log lines so only the error text is shown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirror the dependency list from the mypy hook so pylint can resolve all project imports, substituting requests/python-dateutil for their type-stub equivalents. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Adriano Machado <60320+ammachado@users.noreply.github.com>
Update expected ANSI escape sequences from \033[1;m to \033[0m following the fix in cbef398. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED
…re widely - Remove CLAUDE.md from this branch per review request; the repo guidance content is fine but belongs in its own PR. - Document token_command (alongside token/token_file) in the github and gitlab plugin docstrings, matching the jira/confluence notes.
- List `token_command` in the gitlab and jira config examples, next to the `token` and `token_file` entries that were already there - Clarify that the token command inherits did's environment (so `BW_SESSION` and friends work) and only loses shell expansion - Include the command line in the `FileNotFoundError` and `CalledProcessError` messages, matching the timeout message - Document why caching the token for the process lifetime does not widen exposure of the secret - Reword a test comment that read as a typo Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@kwk, all fourteen threads from your review are answered, and the branch is rebased on current Changed in 179036d:
Answered without a code change, reasoning in each thread:
I offered in several threads to split the colour-reset and pre-commit changes into separate PRs if you would still prefer this one narrower. Happy to do that, just say which. One note on CI: the four Fedora |
|
Okay, let me make one thing clear, I like the feature. But I also like Big Buts (the song is cool as well but written differently). Can we please get a policy for AI contributions? I simply do not buy that my last review was answered by a human. And I refuse to work this way. I'm getting really angry about such comments. Not because they are incorrect or inaccurate but because I feel like someone is operating a machine instead of thinking for him or herself. This is disrespectful. @psss please look at the responses and tell me this was written by a person. |
Summary
token_commandas a third token source alongsidetoken/token_file, so plugins can pull secrets from password managers such as BitWarden (bw get password did-jira) or 1Password (op read op://Personal/Jira/token). The command is parsed withshlex(no shell) and its stripped stdout is used as the token; failures raiseConfigError. Results are memoized per process viafunctools.lru_cacheso multiple config sections sharing a command only invoke the external tool once.token,token_file,token_commandis set in a section:token>token_file>token_command, matching the existingtokenvs.token_fileprecedence — lower-precedence keys are silently ignored, same as before.jira,confluence,github, andgitlabplugin docstrings to documenttoken_commandand the precedence rule. Other plugins automatically gain the feature through the shareddid.base.get_tokenhelper.Test plan
pytest tests/unit/test_base.py::TestGetToken— 13/13 passing (5 new tests for the command source, precedence, failure modes, and memoization)pytest tests/unit -n autoclean except for pre-existing failures unrelated to token handling (nitrate/psycopg2build needspg_configlocally; oneredminelive-data test).[all]installedtoken_command = printf %s ...and confirm the report runs🤖 Generated with Claude Code