Skip to content

volunteer: project card and worker card documents #4041

Description

@chernistry

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([...]) — see src/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.py redacts credential-shaped values in log text (_PREFIXED_CREDENTIAL_PATTERN at line ~79 matches sk-..., 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). A ProjectCard.from_manifest(manifest: VolunteerManifest, *, demand: DemandSnapshot) -> ProjectCard constructor, 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.py imports bernstein.core.volunteer.manifest, never the reverse — matching how manifest.py already imports from bernstein.core.path_scope but 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 is dict[str, Any] or similarly open-ended, and a dedicated _KNOWN_CREDENTIAL_NAME_FRAGMENTS denylist ("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

  • One-way dependency, protocols/volunteer -> core/volunteer/manifest, never back. manifest.py is a foundational, already-shipped, heavily-tested module (487 lines of tests in tests/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 new ProjectCard (which already depends on manifest.py for the manifest's own fields) keeps the existing module's test surface and import graph untouched.
  • Worker card closed, not extensible — resolving the decision the substrate sub-issue flagged. Every other document in this layer plans to preserve unknown fields the way VolunteerManifest.extensions does (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."
  • No existing canonical credential-name list to import, so this ships its own small one, deliberately narrow. The 40-adapter build_filtered_env([...]) calls are provider-specific and would need updating every time a new adapter ships (this is explicitly the multiplication bernstein.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 from env_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). Reuse log_redact.py's _PREFIXED_CREDENTIAL_PATTERN regex (sk-, gh[pousr]_, AKIA prefixes) 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.py itself (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
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

Metadata

Metadata

Assignees

No one assigned

    Labels

    discoveryAdvertising, registries, browse and join surfacesenhancementNew feature or requestsecuritySecurity hardeningsize/mup-for-grabsListed on up-for-grabs.net — no commitment, low frictionvolunteerVolunteer workers program

    Type

    No type

    Projects

    Status
    Todo

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions