Skip to content

Commit 9192dfe

Browse files
fix(config): do not inject unfilled placeholder values as credentials (#307)
Merge PR #307
1 parent dae3503 commit 9192dfe

4 files changed

Lines changed: 225 additions & 10 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ Claude Workflow Engine is a 3-level LangGraph-based orchestration pipeline for a
2020
| **Status** | Active Development |
2121
| **Primary Location** | langgraph_engine/ |
2222
| **MCP Servers** | 13 servers -- all in separate repos under [techdeveloper-org](https://github.com/orgs/techdeveloper-org/repositories); 1 also keeps an in-engine copy in `src/mcp/` |
23-
| **Total Python Files** | 248 (langgraph_engine/); 492 repo-wide |
24-
| **Test Files** | 104 (95 unit, 5 integration, 3 e2e, 1 load) |
23+
| **Total Python Files** | 248 (langgraph_engine/); 493 repo-wide |
24+
| **Test Files** | 105 (96 unit, 5 integration, 3 e2e, 1 load) |
2525
| **Call Graph** | 578 classes, 3,985 methods, 4 languages (Python/Java/TS/Kotlin) |
2626

2727
---

‎README.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![Python](https://img.shields.io/badge/Python-3.10%2B-green)](https://python.org)
77
[![CI](https://github.com/techdeveloper-org/claude-workflow-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/techdeveloper-org/claude-workflow-engine/actions/workflows/ci.yml)
88
[![License](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
9-
[![Tests](https://img.shields.io/badge/Tests-104%20files%20·%205%20integration-brightgreen)](tests/)
9+
[![Tests](https://img.shields.io/badge/Tests-105%20files%20·%205%20integration-brightgreen)](tests/)
1010
[![Discussions](https://img.shields.io/badge/Discussions-GitHub-blueviolet)](../../discussions)
1111

1212
---
@@ -469,8 +469,8 @@ claude-workflow-engine/ # 369 Python files total
469469
├── policies/ # non-.md policy state only (failure-kb.json) — see note below
470470
│ └── 03-execution-system/failure-prevention/failure-kb.json
471471
│
472-
├── tests/ # 104 test_*.py files (125 total Python files)
473-
│ ├── test_*.py # 95 unit tests
472+
├── tests/ # 105 test_*.py files (126 total Python files)
473+
│ ├── test_*.py # 96 unit tests
474474
│ ├── integration/ # 5 integration tests (GitHub, MCP, runtime verification)
475475
│ ├── e2e/ # 3 end-to-end scenario tests
476476
│ └── load/ # 1 concurrency / load test
@@ -628,11 +628,11 @@ ENABLE_HEALTH_SERVER=1 ENABLE_METRICS=1 LOG_FORMAT=json \
628628

629629
## Testing
630630

631-
104 test_*.py files (125 total Python files in `tests/` including conftest and `__init__` files):
631+
105 test_*.py files (126 total Python files in `tests/` including conftest and `__init__` files):
632632

633633
| Category | Files | Notes |
634634
|---|:---:|---|
635-
| Unit tests | 95 | In `tests/test_*.py` — no external dependencies required |
635+
| Unit tests | 96 | In `tests/test_*.py` — no external dependencies required |
636636
| Integration tests | 5 | `tests/integration/` — require live GitHub token or MCP servers |
637637
| E2E tests | 3 | `tests/e2e/` — require full pipeline environment |
638638
| Load tests | 1 | `tests/load/` — enabled with `RUN_LOAD_TESTS=1` |

‎langgraph_engine/core/config_loader.py‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,54 @@ def _flatten(data: dict, prefix: str = "") -> dict[str, str]:
100100
return out
101101

102102

103+
# Values that mean "you were supposed to fill this in". The shipped
104+
# workflow-config.json carries them, so an untouched config injects them exactly
105+
# as if they were real credentials.
106+
#
107+
# This is deliberately narrow. A false positive here rejects a working secret and
108+
# breaks a setup that was fine, which is worse than the bug it guards against, so
109+
# nothing is matched on length, entropy or character class -- only on shapes no
110+
# real credential has. A real GitHub token starts ghp_/gho_/github_pat_ and a real
111+
# API key is not spelled CHANGEME.
112+
_PLACEHOLDER_PREFIXES = ("your_", "your-", "my_", "replace_", "replaceme", "changeme", "change_me", "todo", "xxx")
113+
114+
115+
def _is_placeholder(value: str) -> bool:
116+
"""Return True when a config value is an unfilled template, not a secret.
117+
118+
Args:
119+
value: The raw value read from workflow-config.json.
120+
121+
Returns:
122+
bool: True if the value should be treated as absent.
123+
"""
124+
if not isinstance(value, str):
125+
return False
126+
stripped = value.strip()
127+
if not stripped:
128+
return True
129+
if stripped.startswith("<") and stripped.endswith(">"):
130+
return True
131+
return stripped.lower().startswith(_PLACEHOLDER_PREFIXES)
132+
133+
103134
def load_workflow_config(path: Path | None = None) -> dict[str, str]:
104135
"""Load workflow-config.json and inject into os.environ (no-override).
105136
106137
Covers any remaining os.environ.get() call sites that have not yet
107138
been migrated to use get_section() directly.
108139
140+
Unfilled template values are skipped rather than injected. The reason is
141+
specific and was expensive: the shipped config carries
142+
``"github": {"token": "YOUR_GITHUB_TOKEN"}``, this function injected it into
143+
GITHUB_TOKEN, and that one string then defeated BOTH GitHub backends at once
144+
-- the MCP client authenticated with it and got 401 Bad credentials, and the
145+
gh CLI fallback also lost, because gh prefers an environment token over its
146+
own keyring and reported itself unauthenticated. `gh auth login` therefore
147+
appeared to do nothing, and Step 2 fell back to "no GitHub backend available"
148+
on every run. Injecting nothing leaves the keyring reachable, which is the
149+
working path.
150+
109151
Returns a dict of env-var-name -> value pairs that were injected.
110152
"""
111153
raw = _load_raw(path)
@@ -114,10 +156,27 @@ def load_workflow_config(path: Path | None = None) -> dict[str, str]:
114156

115157
flat = _flatten(raw)
116158
injected: dict[str, str] = {}
159+
skipped: list[str] = []
117160

118161
for json_key, env_key in _MAPPING.items():
119-
if json_key in flat and env_key not in os.environ:
120-
os.environ[env_key] = flat[json_key]
121-
injected[env_key] = flat[json_key]
162+
if json_key not in flat or env_key in os.environ:
163+
continue
164+
value = flat[json_key]
165+
if _is_placeholder(value):
166+
skipped.append(json_key)
167+
continue
168+
os.environ[env_key] = value
169+
injected[env_key] = value
170+
171+
# Said out loud, because a value silently ignored is indistinguishable from a
172+
# value that was never read -- and the reader is someone wondering why their
173+
# config has no effect.
174+
if skipped:
175+
import sys # noqa: PLC0415
176+
177+
print(
178+
"[config_loader] ignored %d unfilled placeholder value(s): %s" % (len(skipped), ", ".join(sorted(skipped))),
179+
file=sys.stderr,
180+
)
122181

123182
return injected
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""An unfilled config template must not be injected as if it were a credential.
2+
3+
The shipped workflow-config.json carries `"github": {"token": "YOUR_GITHUB_TOKEN"}`.
4+
load_workflow_config injected that string into GITHUB_TOKEN, and one placeholder
5+
then defeated BOTH GitHub backends at once: the MCP client authenticated with it
6+
and got 401 Bad credentials, and the gh CLI fallback also lost, because gh prefers
7+
an environment token over its own keyring and reported itself unauthenticated.
8+
9+
`gh auth login` therefore appeared to do nothing. Step 2 fell back to "no GitHub
10+
backend available" on every run, and the pipeline could not create an issue or a
11+
branch on a machine that was, by every other measure, correctly authenticated.
12+
Injecting nothing leaves the keyring reachable, which is the working path.
13+
14+
The predicate is deliberately narrow, and half of these tests exist to hold it
15+
that way. A false positive rejects a real secret and breaks a setup that was
16+
working -- worse than the bug it guards against -- so nothing is matched on
17+
length, entropy or character class.
18+
"""
19+
20+
import json
21+
import sys
22+
from pathlib import Path
23+
24+
import pytest
25+
26+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
27+
if str(_PROJECT_ROOT) not in sys.path:
28+
sys.path.insert(0, str(_PROJECT_ROOT))
29+
30+
from langgraph_engine.core import config_loader # noqa: E402
31+
from langgraph_engine.core.config_loader import _is_placeholder, load_workflow_config # noqa: E402
32+
33+
34+
@pytest.fixture(autouse=True)
35+
def _clean_env(monkeypatch):
36+
"""Reset the env vars and the module's config cache before each test.
37+
38+
_load_raw memoises into _CONFIG_CACHE on first read, after which the `path`
39+
argument is silently ignored. Without this reset the first test to run would
40+
pin the real ~/.claude/workflow-config.json for every test after it, and the
41+
suite would be asserting against the developer's own machine -- which is how
42+
a green suite ends up proving nothing.
43+
"""
44+
for var in ("GITHUB_TOKEN", "GITHUB_DEFAULT_LABEL", "CLAUDE_GITHUB_OWNER", "JIRA_API_TOKEN"):
45+
monkeypatch.delenv(var, raising=False)
46+
monkeypatch.setattr(config_loader, "_CONFIG_CACHE", None)
47+
48+
49+
def _write_config(tmp_path, payload):
50+
"""Write a workflow-config.json and return its path."""
51+
path = tmp_path / "workflow-config.json"
52+
path.write_text(json.dumps(payload), encoding="utf-8")
53+
return path
54+
55+
56+
class TestThePlaceholderIsNotInjected:
57+
"""The exact failure that cost a day."""
58+
59+
def test_the_shipped_github_placeholder_is_skipped(self, tmp_path):
60+
path = _write_config(tmp_path, {"github": {"token": "YOUR_GITHUB_TOKEN"}})
61+
injected = load_workflow_config(path)
62+
assert "GITHUB_TOKEN" not in injected
63+
assert "GITHUB_TOKEN" not in __import__("os").environ
64+
65+
def test_a_real_looking_token_is_still_injected(self, tmp_path):
66+
"""The half that fails if the predicate became greedy.
67+
68+
Without this, a predicate that rejected everything would satisfy the test
69+
above -- and would break every correctly configured machine instead.
70+
"""
71+
path = _write_config(tmp_path, {"github": {"token": "ghp_16CharsOrMoreOfRealLookingSecret"}})
72+
injected = load_workflow_config(path)
73+
assert injected.get("GITHUB_TOKEN") == "ghp_16CharsOrMoreOfRealLookingSecret"
74+
75+
def test_other_real_values_survive_alongside_a_skipped_one(self, tmp_path):
76+
"""One placeholder must not suppress the rest of the file."""
77+
path = _write_config(
78+
tmp_path,
79+
{"github": {"token": "YOUR_GITHUB_TOKEN", "owner": "techdeveloper-org", "default_label": "feature"}},
80+
)
81+
injected = load_workflow_config(path)
82+
assert "GITHUB_TOKEN" not in injected
83+
assert injected.get("CLAUDE_GITHUB_OWNER") == "techdeveloper-org"
84+
assert injected.get("GITHUB_DEFAULT_LABEL") == "feature"
85+
86+
87+
class TestTheSkipIsAnnounced:
88+
"""A value silently ignored is indistinguishable from one never read."""
89+
90+
def test_the_skip_is_reported(self, tmp_path, capsys):
91+
path = _write_config(tmp_path, {"github": {"token": "YOUR_GITHUB_TOKEN"}})
92+
load_workflow_config(path)
93+
assert "github.token" in capsys.readouterr().err
94+
95+
def test_nothing_is_reported_when_nothing_was_skipped(self, tmp_path, capsys):
96+
"""The half that fails if the notice were unconditional."""
97+
path = _write_config(tmp_path, {"github": {"owner": "techdeveloper-org"}})
98+
load_workflow_config(path)
99+
assert "config_loader" not in capsys.readouterr().err
100+
101+
102+
class TestThePredicateIsNarrow:
103+
"""A false positive breaks a working setup, so the shapes are enumerated."""
104+
105+
@pytest.mark.parametrize(
106+
"value",
107+
[
108+
"YOUR_GITHUB_TOKEN",
109+
"your-api-key",
110+
"<your-token-here>",
111+
"CHANGEME",
112+
"change_me",
113+
"REPLACE_ME",
114+
"TODO",
115+
"xxxxxxxx",
116+
"",
117+
" ",
118+
],
119+
)
120+
def test_template_shapes_are_placeholders(self, value):
121+
assert _is_placeholder(value) is True
122+
123+
@pytest.mark.parametrize(
124+
"value",
125+
[
126+
"ghp_realLookingToken1234567890",
127+
"gho_anotherRealShape",
128+
"github_pat_11ABCDEF",
129+
"techdeveloper-org",
130+
"feature",
131+
"sk-ant-api03-abcdef",
132+
"yourcompany-prod-key", # starts with "your" but not "your_" or "your-"
133+
"0123456789abcdef",
134+
],
135+
)
136+
def test_real_values_are_not_placeholders(self, value):
137+
assert _is_placeholder(value) is False
138+
139+
def test_a_non_string_is_not_a_placeholder(self):
140+
"""Config values may be bools or numbers; those are not templates."""
141+
assert _is_placeholder(True) is False
142+
assert _is_placeholder(0) is False
143+
144+
145+
class TestExistingBehaviourIsUnchanged:
146+
"""The no-override rule and missing-file handling must not have moved."""
147+
148+
def test_an_existing_env_var_still_wins(self, tmp_path, monkeypatch):
149+
monkeypatch.setenv("CLAUDE_GITHUB_OWNER", "already-set")
150+
path = _write_config(tmp_path, {"github": {"owner": "from-config"}})
151+
injected = load_workflow_config(path)
152+
assert "CLAUDE_GITHUB_OWNER" not in injected
153+
assert __import__("os").environ["CLAUDE_GITHUB_OWNER"] == "already-set"
154+
155+
def test_a_missing_file_returns_empty(self, tmp_path):
156+
assert load_workflow_config(tmp_path / "absent.json") == {}

0 commit comments

Comments
 (0)