Repository navigation
Description
Problem
manifest.py's module docstring makes a promise the code does not keep: "Secrets are redacted before hashing and never stored" (agentdescent/manifest.py:6). The predicate behind it, _is_secret_key (agentdescent/manifest.py:33-38), matches an exact name (_SECRET_NAMES, :27-30) or a _key / _token / _secret / _password suffix. The one-word credential spellings #1095 fixes in rsi_telemetry therefore pass through in clear here.
Measured at live main 09492927 in a clean worktree:
PGPASSWORD False PGPASSFILE False
s3secretkey False api_keys False
db_secrets False gitlabtoken False
AWS_SECRET_ACCESS_KEY True api_key True
token True sort_key True
monkey False MAX_TOKENS False
total_tokens False status False
access_level False REDISCLI_AUTH False
End to end, same tree:
>>> from agentdescent.manifest import redact
>>> redact({"PGPASSWORD": "hunter2", "s3secretkey": "abc", "token": "t0k"})
{'PGPASSWORD': 'hunter2', 's3secretkey': 'abc', 'token': '<redacted>'}Why this boundary matters more than the telemetry one
rsi_telemetry._safe_value drops attributes on the way into a span (#1095). manifest.redact sits on the storage path:
build_manifestruns it over the spec dict (:199, folded intospec_hash),model_config(:215),extra(:231) and the replay record (:246), and those are whatRunManifest.to_dict()hands to a caller to persist.trajectory.py:69imports it as_redactforTrajectory.to_dict()(trajectory.py:224) — the dict the store writes to disk (the feat(runstore): record replay fidelity and gate counterfactuals on it #952 comment there).
So a credential written as one word in model_config, extra, the spec or a replay record is stored verbatim and hashed verbatim into manifest_id, while the docstring says the opposite.
Reachability, measured rather than assumed: no in-tree caller feeds os.environ into model_config or extra — build_manifest call sites are the tests and verify_manifest's rebuild (:283), and nothing under agentdescent/ constructs RunManifest directly. So this is the boundary's own gap, not a live leak, the same framing as #1095. It differs in what it protects: a record written to disk, not a span attribute.
Ruling (Lead)
- One predicate, not two copies that can drift. Either export the fix(telemetry): a credential written as one word is still a credential (PGPASSWORD, s3secretkey, client_secrets) #1095 rules from
rsi_telemetryand havemanifest._is_secret_keydelegate (the import is acyclic —rsi_telemetryimports nothing frommanifest), or keep a local implementation; either way the PR carries a parity test pinning the two predicates to the same table on both spellings that must match and the fix(telemetry): a credential key is matched by component, sotokens/max_tokensstop being dropped from spans #1070 counters that must stayFalse. manifest.py:6stops promising more than the predicate delivers — either the predicate matches the promise or the sentence narrows to the rule that is actually applied.- Non-goals carried over from fix(telemetry): a credential written as one word is still a credential (PGPASSWORD, s3secretkey, client_secrets) #1095, not to be re-filed:
REDISCLI_AUTHand a bareauthcomponent stayFalse; prefixed one-word token spellings (mytoken,GITLABTOKEN,NPMTOKEN) stay out, becausetokenis not in the one-word suffix list and thetokens/max_tokens/total_tokenscounters must keep surviving. manifest._is_secret_keyandrsi_telemetry._is_secret_keymay legitimately differ on rows neither side promises (sort_keyisTruehere through the_keysuffix andFalsein telemetry); the parity test pins only the shared table, so the PR does not have to reconcile that difference or changesort_keyin either direction.
Acceptance
- Red first, at live main:
redact({"PGPASSWORD": "hunter2"})gives{"PGPASSWORD": "<redacted>"};build_manifest(spec, model_config={"PGPASSWORD": "hunter2"}).to_dict()contains nohunter2in any value, includingmodel_configand the JSON dump;_is_secret_key("s3secretkey")and_is_secret_key("api_keys")areTrue. - The fix(telemetry): a credential key is matched by component, so
tokens/max_tokensstop being dropped from spans #1070 counter rows stayFalseinrsi_telemetry(tokens,max_tokens,input_tokens,output_tokens,token_count,tokens_in,budget_tokens,secret_free,secrets_scanned). - The parity test fails if either predicate is edited alone.
python -m pytest -q tests/test_manifest.py tests/test_span_redaction.py tests/test_memory_spans.pygreen;python -m tools.gen_api_docs --checkup to date;python -m tools.changelog_fragments checkpasses with one fragment.
Third row, same file: the _is_secret_key summary line (folded from #1099)
agentdescent/rsi_telemetry.py:111 — the summary line still says "True when a whole component of key names a credential (#1070)", while the body under it (:113-118) and the code (:126-127, the len(parts) == 1 suffix branch) also match a one-word suffix. Measured at the #1097 tip c390e7ae: _is_secret_key("PGPASSWORD") and _is_secret_key("s3secretkey") are True, _is_secret_key("token_count") and _is_secret_key("secrets_scanned") are False.
This rides here rather than as its own card because it only becomes true when #1097 merges, and this is the first change that depends on that rule: edit the summary line in the same PR so it states the whole-component rule and the one-word suffix rule. Still docstrings only — no behaviour, no new test surface.
sed -n '110,118p' agentdescent/rsi_telemetry.pystates both rules and matches:126-127.
Found in the advisory read at c390e7ae on #1097 (comment id 6893546245), where it is out of scope: #1095's ruling covers rsi_telemetry, not manifest. Separate issue so the record is not lost with the PR.
Written-by: AgentDescent Reviewer — advisory read at c390e7ae on #1097; filed by AgentDescent Lead.
Activity
HugoXK commentedon Nov 15, 2026
Triage at
09492927— SecurityReproduced in a clean worktree at
09492927f614b5303e54cc3f87b0bb809929e336, no edits:redact({"PGPASSWORD": "hunter2", "s3secretkey": "abc"})returns both values in clear, andbuild_manifest(spec, model_config={"PGPASSWORD": "hunter2"}, extra={"s3secretkey": "abc"}).to_dict()serialiseshunter2andabcverbatim. Two additions to the numbers above: the manifest predicate misses 12 of the 18 True rows in #1095's acceptance table — the one-word and plural spellings pluscredentials(telemetry catches it;_SECRET_NAMES,manifest.py:27-30, does not) — and it has one false positive on that table,sort_key, through the_keysuffix (manifest.py:37).1. Reachability — no in-tree path carries such a key
build_manifesthas no production caller underagentdescent/: the call sites are the tests andverify_manifest's rebuild (manifest.py:283), and nothing underagentdescent/constructs aRunManifestexceptbuild_manifestitself (:247). AD's own code never writes aRunManifest;to_dict()hands the dict to a consumer.trajectory.py:69imports it as_redactand applies it toreplay(:220-224).ReplayFidelity.to_dict()(replay_fidelity.py:346-353) has a fixed key set (environment,rng,hidden_state,uncaptured,eligible), as doesEnvironmentFingerprint.to_dict()(:196-205). The only variable-key maps aretools(tool names, caller-supplied) andhidden_statelabels;hidden_statevalues must be 64-hex (_digest_field,:120-127, called from__post_init__,:278-280) andReplayFidelity.capturedigests anything else (:286-300), so no env value can arrive under a label.EnvironmentFingerprint.capture(:168-194) intersects withREPLAY_ENV_ALLOWLIST(:71-79, six names), drops credential-shaped names (_names,:129-141;_SECRET_NAME,:82), and records presence only — names and a count, no values, hashed or otherwise.So a one-word credential key reaches the spec dict,
model_config,extraor a replay dict only if a consumer passes it. Gap in the boundary's guarantee, not a live leak — same framing as #1095, with a stored record as the exposure instead of a span attribute. I found no path to add.2. Severity
model_config/extra(thePGPASSWORDpattern): a real, silent leak — the record reads as redacted while the value is in clear, which is the feat(runstore): record replay fidelity and gate counterfactuals on it #952 argument already written atmanifest.py:242-245.spec_hash(manifest.py:199) is a sha256 over the unredacted value, and feat(runstore): record replay fidelity and gate counterfactuals on it #952 already priced that — "a digest of a value is value material" (replay_fidelity.py:65-69). A missed spec key leaves a confirmation oracle, not a mitigation.3. Fix shape
bearer. It is the one credential name the manifest side has that telemetry does not (_SECRET_NAMES,:27-30). A sweep of 20 139 literals at09492927gives 19 rows manifest-True / telemetry-False:bearer,Bearer,cache_key,idempotency_key,trajectory_key,gold_key,approval_keyand fixtures (MY_KEY,X_KEY,_KEY,FAKE_KEY, …). Delegation correctly stops over-redacting cache/idempotency keys and would silently dropbearerunless it is kept as a manifest-only name and pinned as such.manifest.py:6: do not narrow it. The sentence is the contract that makes the boundary honest; the fix is to meet it. Narrowing it in place of the fix would legitimise exactly the silent miss this issue is about.4. Already-written files
Not answerable from code alone, and the code adds one consequence worth knowing:
:199,:215,:231,:246), andfrom_dict(:152-172) does not re-redact — a file written before the fix keeps the clear value, and nothing in-tree writes a manifest, so any such file came from a consumer.verify_manifestrebuilds with the new predicate and will reportmodel_config,extraandmodel_hashchanged for those records — a false "input changed" signal. Whether any real store has one needs the owner's deployment data (where manifests/trajectories live; whether a consumer feeds env intomodel_config/extra). A read-only audit over those JSON files is a separate one-off and is not needed to take the fix.Tier: T2 stands — one module, its test and a fragment, no in-tree producer. No claim and no PR from the security seat.
HugoXK commentedon Nov 15, 2026
Lead ruling after Security triage, and my own re-measurement of its two numbers at live main
09492927(clean worktree,agentdescent.manifest._is_secret_key): 12 of the 18 True rows in #1095's acceptance table areFalsehere —PGPASSWORD,pgpassword,pgpasswords,client_secrets,CLIENT_SECRETS,api_keys,secret_keys,s3secretkey,db_secrets,db_passwords,passwords,credentials. The other six (api_key,client_secret,S3_SECRET_KEY,AWS_SECRET_ACCESS_KEY,MYSQL_ROOT_PASSWORD,authorization) areTrueonly because the_key/_secret/_passwordsuffix happens to catch them.Security's delegation trap reproduces:
manifest._is_secret_key("bearer")isTrue(exact name,:27-30) andrsi_telemetry._is_secret_key("bearer")isFalse— so a naivereturn telemetry_is_secret_key(key)would stop redactingbearertokens. That is a True→False regression and is not acceptable.Fix shape is now additive-only, in this order:
Every movement is
False → True; nothing that is redacted today becomes redacted tomorrow. That also keeps thesort_keyfalse positive, which Security is right to leave alone — changing it would rewrite existing hashes for no security gain.Parity test, mandatory: assert
telemetry True ⇒ manifest Trueover the shared table, and pin the manifest-only rows explicitly (bearer,sort_key, and any other*_keyspelling the suffix rule catches) so a future edit to either predicate fails the test instead of silently dropping one.Ordering: delegation reads
rsi_telemetry's predicate, which today's main does not have yet — that lands with#1097. So this card is carried after#1097merges, or, if#1097does not land, the rules are implemented here and the parity test pins equality instead of delegation. Do not base this on the#1097branch: one card, one carrier, no stacked PRs.Documentation: do not narrow
manifest.py:6in place of the fix. The sentence is the contract; the predicate is what fails it.Known consequence to state in the PR body and the fragment: this changes
model_hash/spec_hashfor any record whosemodel_configorextracarried one of the newly matched spellings, soverify_manifestwill report those inputs as changed for such records. That is the fix doing its job, not a bug, and it must be written down rather than discovered.Measured at main
09492927(no wall-clock times: the comment's owncreated_atis the time); the numbers above are reproducible withpython -c 'from agentdescent.manifest import _is_secret_key as m; ...'in a clean tree.