GOAL
Two document types: ProjectCard (derived from the existing VolunteerManifest)
and WorkerCard (a new, deliberately closed schema that cannot structurally
carry a credential).
READ FIRST (in this order, and why)
1. src/bernstein/core/volunteer/manifest.py — re-read VolunteerManifest's
field list and to_canonical_dict() (you've read this file already for
other volunteer work, but re-read it now specifically to inventory which
fields a project card should surface: license, gates (as commands only,
never their content — a project card advertises capability, not its
acceptance script), allowed_paths, sandbox, max_wall_clock_minutes,
task_label, local_ok). ProjectCard.from_manifest is a projection of a
SUBSET of these plus new fields (task types, duration bands, current
demand) that do not exist on VolunteerManifest at all and have no source
yet other than whatever the hub/index tracks — stub these as
caller-supplied parameters to from_manifest, do not invent a data source
for them in this issue.
2. src/bernstein/adapters/aider.py:80-90, charm.py:70-80, cline.py:75-85 —
three of the ~40 build_filtered_env([...]) call sites. Skim these to see
the actual shape of provider-key env var names in the wild
(ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY,
CLINE_API_KEY, AZURE_OPENAI_API_KEY, ...). You are NOT importing any of
these lists — you are looking at them to confirm the substring-denylist
approach below (matching "KEY", "TOKEN", "SECRET" as case-insensitive
substrings) actually catches this real-world naming convention, which it
does: every one of them ends in _API_KEY or _KEY or _TOKEN.
3. src/bernstein/core/observability/log_redact.py:75-80
(_PREFIXED_CREDENTIAL_PATTERN). Reuse this regex object directly (import
it, or copy the exact pattern string with attribution in a comment — do
not approximate it from memory) as the value-shape half of worker card
validation.
4. The shared substrate module from the prior sub-issue (documents.py) —
your two document dataclasses build on its canonical-bytes/sign/verify
helpers. Read whatever it actually shipped, not this brief's
description of what it should ship.
CURRENT SHAPE (quoted)
manifest.py's canonical-dict pattern your ProjectCard should mirror, manifest.py
(VolunteerManifest.to_canonical_dict, see the full method — every field name
lowercase snake_case, lists as list(), nested dataclasses as list of their
own to_dict()-equivalent). Re-read it at the file rather than copied here
twice.
log_redact.py's credential-shape regex, log_redact.py:78-80:
_PREFIXED_CREDENTIAL_PATTERN = re.compile(
r"(?<![A-Za-z0-9])(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})(?![A-Za-z0-9])"
)
A representative adapter allowlist call, aider.py:88:
env = build_filtered_env(["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"])
CALL SITES
None for worker_card.py (brand new, no existing callers).
For project_card.py: manifest.py itself has ZERO new call sites — the
dependency runs from project_card.py INTO manifest.py, not the reverse, so
merging this issue changes no behavior for any existing caller of
load_manifest / load_manifest_from_repo / VolunteerManifest. Confirm this
stays true — if you find yourself wanting to add an import of
protocols.volunteer into manifest.py, stop; that is the wrong direction and
means the projection belongs elsewhere.
EXISTING HELPERS TO REUSE, NOT REINVENT
- Canonical bytes / sign / verify: the shared substrate from the prior
sub-issue — do not write a fourth copy of _sort_recursive.
- Credential value-shape detection: log_redact.py's
_PREFIXED_CREDENTIAL_PATTERN (above) — import or exact-copy with a comment
citing the source line, do not approximate.
- Manifest field access: VolunteerManifest's existing properties
(.digest, .gates, .allowed_paths, etc.) — do not re-parse
.bernstein/volunteer.json a second time inside project_card.py; take a
loaded VolunteerManifest instance as input.
TEST MATRIX (tests/unit/protocols/volunteer/test_project_card.py and
test_worker_card.py, two new files in the directory the substrate sub-issue
established)
Project card:
1. test_project_card_from_manifest_carries_the_manifests_own_digest
2. test_project_card_never_includes_gate_command_contents_only_their_count_or_names
— decide and pin whether gate argv strings themselves belong on a
PUBLIC advertisement document; err toward not, and this test enforces
whichever way you decided.
3. test_two_project_cards_from_the_same_manifest_and_demand_hash_identically
Worker card:
4. test_a_worker_card_with_only_documented_fields_validates
5. test_worker_card_schema_has_no_dict_or_open_ended_field_anywhere
— an introspection test over the dataclass's own field types (e.g.
`dataclasses.fields(WorkerCard)`), not a runtime behavior test. This is
the test that actually enforces "structurally impossible," by failing
the moment someone adds a `dict[str, Any]` field to WorkerCard for
convenience two years from now.
6. test_a_field_value_matching_a_known_credential_env_var_name_is_refused
— parametrize over ["ANTHROPIC_API_KEY", "sk-abcdef0123456789ABCDEF",
"ghp_" + "a" * 20, "my-token-value"], asserting each is refused when
passed as e.g. the adapter-name or model-name field.
7. test_a_legitimate_model_name_containing_the_substring_key_is_not_refused
— the false-positive check for the substring denylist. Something like a
model name "keyboard-7b" or an adapter id containing "keystone" must NOT
be rejected just because "key" is a substring — decide the exact
matching rule (whole-word vs substring vs case-sensitive) and write this
test against whatever you decided, because "KEY" as a bare substring
match WILL false-positive on ordinary words and you need to know that
before a real contributor hits it, not after.
8. test_worker_cards_are_never_emitted_by_the_forge_projection
— per #3883's own security section: "the forge path never publishes a
worker card, so a donor's availability and capacity stay private by
default." If your conformance harness (prior sub-issue) has a generic
"project this document through both projections" test, worker_card must
be the one document type EXCLUDED from the forge/GitHub projection path
entirely — assert that calling the GitHub-projection function on a
WorkerCard raises or is simply not offered (no such function exists for
this type), not that it silently produces empty output.
THE TRAP YOU WOULD HAVE HIT YOURSELF
A schema-level denylist that only rejects *field names* (rejecting a field
called "api_key") does nothing, because nobody names a field "api_key" on a
form they are trying to smuggle a credential through — they put a credential
VALUE into a legitimately-named field like "adapter_config" or
"notes". The denylist has to run over field VALUES at construction/validation
time, checked against both the name-fragment list (does this string look
like it's echoing an env var name) and the value-shape regex (does this
string look like an actual key/token). Test 6 above is written to catch
exactly the "only checked field names, not values" version of this bug —
if your first implementation passes test 6 by rejecting only when the FIELD
name matches, add a test that puts "ANTHROPIC_API_KEY=sk-real-looking-value"
as the VALUE of an otherwise-innocuous field and confirm it is caught too.
DECISION TO MAKE, NOT GUESS
Exact matching rule for the credential-name denylist: substring
(case-insensitive "KEY" matches "keyboard"), whole-word-with-separators
("_KEY" or "-KEY" or start/end anchored), or a maintained explicit list of
full names harvested from the ~40 adapters' build_filtered_env calls
(exhaustive today, silently stale the day adapter #41 ships a new provider
key name). Pick one and write down why in the module docstring — this
directly trades false positives (test 7's problem) against false negatives
(test 6's problem), and "guess something reasonable" produces a different
security posture depending which way you guessed.
VERIFICATION
uv run pytest tests/unit/protocols/volunteer/test_project_card.py tests/unit/protocols/volunteer/test_worker_card.py -v
uv run ruff check src/bernstein/core/protocols/volunteer/project_card.py src/bernstein/core/protocols/volunteer/worker_card.py
uv run ruff format --check src/bernstein/core/protocols/volunteer/project_card.py src/bernstein/core/protocols/volunteer/worker_card.py
uv run mypy src/bernstein/core/protocols/volunteer/project_card.py src/bernstein/core/protocols/volunteer/worker_card.py
Problem
Two of #3883's five document types are advertisements rather than transactions: the project card (what a project offers — task types, requirements, current demand; "extends the manifest") and the worker card (what a donor offers — capabilities, resource ceilings, availability; "never credentials"). Nothing emits or consumes either today. The project card's seed data already exists and is fully specified —
VolunteerManifest(src/bernstein/core/volunteer/manifest.py) — but nothing projects it outward into the wider "task types offered, expected duration bands, current status and demand" shape #3883 describes. The worker card has no existing analog anywhere in the volunteer package, and carries a security requirement none of the existing volunteer documents do: it must be structurally incapable of carrying a credential, because unlike the manifest (which a maintainer commits deliberately) or a result bundle (signed by the worker about its own completed work), a worker card is speculative self-description published to a hub or index before any task exists to scope what it should contain.There is no existing canonical list of "known credential env var names" anywhere in this codebase to check a schema against. Every adapter declares its own ad hoc allowlist via
build_filtered_env([...])— seesrc/bernstein/adapters/aider.py:88(["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"]),src/bernstein/adapters/charm.py:76,src/bernstein/adapters/cline.py:79, and roughly 40 more adapter files, each naming its own provider keys inline.src/bernstein/core/observability/log_redact.pyredacts credential-shaped values in log text (_PREFIXED_CREDENTIAL_PATTERNat line ~79 matchessk-...,gh[pousr]_...,AKIA...prefixes) but has no name-based denylist either — it is a value-shape filter for arbitrary text, not a schema validator.Proposal
src/bernstein/core/protocols/volunteer/project_card.py— built from the shared substrate (sibling sub-issue). AProjectCard.from_manifest(manifest: VolunteerManifest, *, demand: DemandSnapshot) -> ProjectCardconstructor, so the manifest loader "emits... the project card" per volunteer: transport-neutral protocol documents (project card, worker card, claim, submission) #3883's acceptance criteria without the manifest module importing anything from the new protocols subpackage (the dependency runs one way:protocols/volunteer/project_card.pyimportsbernstein.core.volunteer.manifest, never the reverse — matching howmanifest.pyalready imports frombernstein.core.path_scopebut nothing imports manifest.py's internals back).src/bernstein/core/protocols/volunteer/worker_card.py— a closed schema (see the decision this issue resolves, below): a fixed, typed field set (adapter names, model names as opaque strings, CPU/RAM/GPU ceiling numbers, sandbox tier enum, availability window, budget posture enum) with no unknown-field passthrough at all, unlike every other document in this protocol layer. The schema module itself asserts this structurally: no field isdict[str, Any]or similarly open-ended, and a dedicated_KNOWN_CREDENTIAL_NAME_FRAGMENTSdenylist ("KEY","TOKEN","SECRET","PASSWORD","CREDENTIAL"as case-insensitive substrings) rejects any string field value containing what looks like an env-var-style credential name, as a second line of defense against a capability string like"adapter=custom,env=sk-abc123"smuggled into a field that is typed as a string but not otherwise constrained.Why this shape
protocols/volunteer->core/volunteer/manifest, never back.manifest.pyis a foundational, already-shipped, heavily-tested module (487 lines of tests intests/unit/volunteer/test_volunteer_manifest.py); it must not grow a dependency on a not-yet-shipped protocol layer just to emit a card. Building the projection as a classmethod on the newProjectCard(which already depends on manifest.py for the manifest's own fields) keeps the existing module's test surface and import graph untouched.VolunteerManifest.extensionsdoes (forward-compatible, digest-bound). Worker card cannot, because "no free-form secret-bearing fields" and "an extension bag preserved verbatim" are contradictory: an unknown field IS a free-form field. Closing the schema is the cheaper, more honest fix than trying to sanitize an open bag — a closed schema has no field a credential could go into by construction, which is a stronger claim than "we scan every field for things that look like credentials and hope the scan is complete."build_filtered_env([...])calls are provider-specific and would need updating every time a new adapter ships (this is explicitly the multiplicationbernstein.adapters.env_isolation's own module docstring exists to contain, for a different problem — see the sandbox_profile.py docstring's note that it is deliberately not derived fromenv_isolation's allowlist "built for adapter processes, which legitimately carry provider credentials" — same reasoning applies here in reverse: a worker card is never an adapter process and should carry no credential shape at all, allowlist or not). Reuselog_redact.py's_PREFIXED_CREDENTIAL_PATTERNregex (sk-,gh[pousr]_,AKIAprefixes) as the value-shape half of the check rather than inventing a second one; add the substring-based name check as the schema-level half, since volunteer: transport-neutral protocol documents (project card, worker card, claim, submission) #3883's security section asks for both ("no free-form secret-bearing fields, schema-level denylist").Scope
Does NOT include: claim document or the shared substrate (prior sub-issue, this one depends on it), submission or verification-verdict documents (sibling sub-issue), publishing a worker card anywhere (that is #3877's hub enrollment and #3890's publish flow — this issue only defines and validates the document shape), or changing
manifest.pyitself (the one-way dependency means this issue only adds a new classmethod-consumer in the new subpackage).Part of #3883 (volunteer: transport-neutral protocol documents). Depends on the shared-substrate sub-issue (documents.py, sign/verify helpers) being merged first.
Brief for a coding agent