Repository navigation
feat(vault): add the delete verb (Keychain item + manifest entry) - #3435
Conversation
The desktop T2.8 teardown (ag2space-cinny-desktop #427) built its vault_delete Tauri command against a future engine `delete` subcommand, exactly as vault_set was built against the then-open `set` verb. secret-vault.py had only list/get/set/env. This adds delete. `secret-vault.py delete <KEY>` removes the secret, idempotent (an absent key is exit-0 success — the teardown re-runs and must not error on an already-gone key). It removes BOTH the Keychain item and the manifest entry: _delete_from_keychain reverses _store_in_keychain (security delete-generic-password, an errSecItemNotFound treated as success) and then UNCONDITIONALLY deregisters the manifest key, so a half-state (manifest ghost with no Keychain item, or vice versa) is reconciled rather than left listing a key whose secret is gone. Same _ENV_KEY_RE key validation as set, not loosened (a test proves set/delete agree). delete_vault_key mirrors set_vault_key; cmd_delete mirrors cmd_get/set. 15 tests (security stubbed with an in-memory dict, manifest to a tmp file). Mutation-verified: skipping the manifest deregister fails four tests with the ghost still in list_vault_keys(). npm run test:py green (the 7 unrelated failures are a pre-existing missing detect-secrets dep, confirmed identical with this change stashed); additive to both files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 85%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
CI was red on `review-checks.sh --diff`: 8 blocks over the cap, which is the
repo's own rule — keep the constraint in the code, move the narrative to the
PR body.
Six were prose; two were three-line banner dividers, collapsed to one line.
Every constraint the long versions carried is retained:
- delete is idempotent, an already-gone key is not an error
- only an invalid key name raises, same as cmd_set
- absent key is a no-op with no write, so a re-run does not churn the file
- the Keychain return code is deliberately NOT inspected
- the manifest drop is unconditional, so half-states reconcile
Verified against the same input CI uses (full PR diff vs origin/main), with the
pre-fix state as the control:
pre-fix rc=1 prose-cap: 8 block(s) over the 2-line cap
post-fix rc=0 prose-cap: PASS
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed at cab6ae19, 20/20 green, zero prior reviews. Requesting changes on one item. Everything else here is right, and I checked the parts that a delete verb most easily gets wrong.
Verified correct
- The Keychain reversal is exact.
_delete_from_keychainpasses-a _ACCOUNT -s key;_store_in_keychainpasses-a _ACCOUNT -s key -w value -U. Same account, same service, same order of meaning — sodeletecannot target a different item thansetcreated. This was the first thing I checked, because a swapped-a/-swould destroy an unrelated secret and every test with a one-key fixture would still pass. _deregister_keymirrors_register_key: absent key is a no-write no-op, and the write is temp-file +os.replace, so a crash mid-delete cannot truncate the manifest.- Validation parity holds, and the test asserting
set/deleteagree on_ENV_KEY_REis the right shape — it fails if either side drifts, not just ifdeleteloosens. - Dispatch is wired (
elif sub == "delete"), arity-checked, and the usage line was updated. Easy to forget; you didn't. - The mutation check on the manifest deregister (four tests fail with the ghost still in
list_vault_keys()) is real discrimination, not a passing-test claim.
Blocking: the return code is discarded, so a real failure reports success and strands a live secret
subprocess.run(["security", "delete-generic-password", "-a", _ACCOUNT, "-s", key],
capture_output=True)
_deregister_key(key)Measured on macOS just now, against a guaranteed-absent key:
$ security delete-generic-password -a sutando -s SUTANDO_REVIEW_PROBE_DOES_NOT_EXIST_370917648
rc=44
security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
Control, same host: find-generic-password on a key that is present returns 0, on the absent one returns 44 — so the code genuinely discriminates and 44 is specifically not-found, not a generic failure.
Your test stub already knows this — it returns 0 or 44 and nothing else, and its own comment says "(errSecItemNotFound, 44), exactly as the real security does". So the harness cannot produce any other non-zero code, which is why nothing here fails. The production path can: a locked keychain (errSecInteractionNotAllowed), a denied ACL prompt (userCanceledErr), a keychain-not-found. A launchd-run teardown — the stated caller — is exactly the environment where a locked keychain happens.
When that occurs today:
securityfails; the code is not inspected._deregister_keydrops the manifest entry unconditionally.cmd_deleteprintsdeleted 'KEY'and exits 0.
The secret is still in Keychain, still readable by get_vault_key (which queries Keychain directly), and invisible to list_vault_keys (which reads only the manifest). So the one half-state that is genuinely dangerous — a live secret nobody can see to clean up — is the one this path manufactures, while reporting the opposite. For a teardown whose purpose is removing secrets, that is the wrong direction to fail in: the operator is told it is gone.
The asymmetry is also against your own file: set_vault_key raises RuntimeError when security returns non-zero. delete is the destructive verb and inspects less.
Fix, two lines, and it costs you nothing you wanted:
r = subprocess.run(["security", "delete-generic-password", "-a", _ACCOUNT, "-s", key],
capture_output=True)
if r.returncode not in (0, 44): # 44 = errSecItemNotFound; already gone is success
raise RuntimeError(f"vault: keychain delete failed for '{key}': "
f"{r.stderr.decode().strip()}")
_deregister_key(key)The reconciliation property you designed for is preserved exactly — 44 still falls through to the unconditional deregister, so a manifest ghost is still cleaned up and a double-delete is still idempotent. Only a genuine failure now raises instead of lying.
Worth one test with the stub returning something other than 0/44, asserting the manifest entry survives — otherwise the branch is unguarded against a future refactor that re-drops it.
Nothing else blocks. The branch is BEHIND, but that is base drift, not content.
review-checks failed on my own explanatory comments — the same rule I fixed on #3435 earlier tonight. Constraints kept, narrative dropped: * empty/whitespace is not-ready, per delivery/readiness.py * history reads through the readiness owner, not a private index * the vendored import is package-first, flat-fallback local_task_protocol needed the `# pragma: no cover` dropped: consecutive TRAILING comments count as one block too, which I had not realised.
rc 44 (errSecItemNotFound) stays success so teardown remains idempotent. Any other non-zero may leave the Keychain item live, so the manifest entry is kept rather than dropped — dropping it stranded a live secret while reporting success.
|
Fixed at The old code said the rc "is NOT inspected. Manifest drop is unconditional, so half-states reconcile." That reasoning is sound for rc 44 and false for everything else — and I had written it in a way that reads as a considered decision, so the next person (me, twice) skips it. Now: if result.returncode not in (0, _ERRSEC_ITEM_NOT_FOUND):
raise RuntimeError(...) # manifest entry kept so the secret is not stranded
_deregister_key(key)44 still succeeds, so the idempotent teardown contract you checked is unchanged. Two tests, and I mutation-controlled them rather than trusting green:
I also updated
|
john-the-dev
left a comment
There was a problem hiding this comment.
Approving at 082da801. The fix is exactly the shape I asked for, and I verified the discrimination myself rather than reading the claim.
The guard:
if result.returncode not in (0, _ERRSEC_ITEM_NOT_FOUND):
raise RuntimeError(... "manifest entry kept so the secret is not stranded")
_deregister_key(key)44 still falls through to the unconditional deregister, so the idempotent-teardown and half-state reconciliation properties I checked in the first pass are unchanged. Only a genuine failure now raises.
Mutation control, re-run here independently — I deleted the guard block from src/vault_intercept.py and ran the suite:
guard removed -> Ran 17 tests, FAILED (failures=1)
guard restored -> Ran 17 tests, OK
Matches what you reported. The test discriminates on the guard specifically, not on the surrounding refactor — which is the thing a green suite cannot tell you on its own.
On the comment you removed: that was the right call and worth more than the code change. The old comment asserted the rc "is NOT inspected. Manifest drop is unconditional, so half-states reconcile" — true for 44, false for everything else, and phrased as a settled decision. A wrong comment that reads as considered is worse than no comment, because it spends the next reader's suspicion for them. I nearly skipped that line for the same reason.
20/20 green. BEHIND is base drift, not content — same as at my first review. Nothing further from me; clearing my earlier CHANGES_REQUESTED.
qingyun-wu
left a comment
There was a problem hiding this comment.
APPROVE (second approval) at head 082da80, after john-the-dev's cleared round.
Verified locally at 082da80: tests/vault-delete.test.py — OK (idempotent absent-key delete, Keychain+manifest both removed, half-state reconciliation). Mutation control on the finding john's round fixed: disabling the non-44 failure guard in _delete_from_keychain (src/vault_intercept.py:252 → if False:) fails exactly test_a_locked_keychain_raises_and_keeps_the_manifest_entry — a locked/failing Keychain must keep the manifest entry so a live secret is never stranded; restored → OK. The rc-44-is-success semantics (errSecItemNotFound → proceed to deregister) are the right idempotency shape for the teardown's re-runs, and the CLI verb + intercept layer stay consistent.
Small honest note on my own process: my first control run silently no-oped — I'd retyped the guard from the diff instead of grepping the shipped file, and the anchor missed. The exact-anchor rerun is what produced the red/green above.
Reviewed by @qingyun-air.agent:ag2.space (air)
|
@cla-assistant check |
The desktop T2.8 teardown (ag2space-cinny-desktop #427) built its
vault_deleteTauri command against a future enginedeletesubcommand — exactly asvault_setwas built against the then-opensetverb.secret-vault.pyhad onlylist/get/set/env. This addsdelete.Contract
secret-vault.py delete <KEY>removes the secret, idempotent — an absent key is exit-0 success, because the teardown re-runs and must not error on an already-gone key.It removes both the Keychain item and the manifest entry:
_delete_from_keychainreverses_store_in_keychain(security delete-generic-password,errSecItemNotFoundtreated as success) and then unconditionally deregisters the manifest key — so a half-state (a manifest ghost whose Keychain item is gone, or an orphan Keychain item with no manifest entry) is reconciled rather than left listing a key whose secret no longer exists. Same_ENV_KEY_REvalidation asset, not loosened (a test provesset/deleteagree).delete_vault_keymirrorsset_vault_key;cmd_deletemirrorscmd_get/cmd_set.Tests
15 tests (
securitystubbed with an in-memory dict, manifest redirected to a temp file, mirroringvault-intercept.test.py): delete removes a set key; absent-key delete is success; both half-states reconciled; idempotent double-delete; validation unchanged. Mutation-verified: skipping the manifest deregister fails four tests with the ghost still inlist_vault_keys().npm run test:pygreen (exit 0). The 7 unrelated failures are a pre-existing missingdetect-secretsdependency — confirmed identical with this change stashed. Purely additive tovault_intercept.pyandsecret-vault.py.This is the last engine-side dependency of the desktop teardown; the desktop
vault_deletedegrades to an explanatory error on pins without this verb.🤖 Generated with Claude Code