SECURITY DEBT: Provisioner client_secret is persisted to local disk¶
Severity: HIGH — confidentiality of agent identity infrastructure Status: Open Owner: the user Filed: 2026-04-19
The problem, stated plainly¶
scripts/entra_provisioning.py::ensure_app_registration() creates a long-lived client secret on the EntraClaw Agent ID Provisioner app registration and writes it verbatim to .entraclaw-state.json at the repo root:
set_state("PROVISIONER_CLIENT_SECRET", client_secret)
print(" Stored provisioner app secret in state file")
The file is gitignored. That is not sufficient. A secret on a local filesystem is:
- Readable by anything running as that user — any Node process, any npm install postinstall, any stray docker build that mounts the repo root, any backup utility that ignores
.gitignore, any cloud-sync tool (iCloud Drive, Dropbox) that auto-includes the project folder. - Exfiltrated by the first compromise — a single malicious Python package or VS Code extension gets
.entraclaw-state.jsonand from there can mint Graph tokens with Application.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All, User.ReadWrite.All, AgentIdentity.*, AgentIdUser.*. That combination can create agent users, impersonate them, grant arbitrary consent, and read the entire directory. - Impossible to rotate cleanly without an out-of-band process. The file is the source of truth; if it's copied, there's no way to revoke the copy.
You already know this is wrong. The Blueprint private key lives in macOS Keychain (via keyring) exactly because a plaintext secret on disk is an unacceptable trust surface. The Provisioner got skipped when that discipline was applied to the Blueprint. It needs the same fix.
Non-negotiable rule this violates¶
From the top of CLAUDE.md:
Private keys never leave the compute boundary they were minted for Never write secrets to logs or memory files
Client secrets are secrets. .entraclaw-state.json is a file. The rule is clear.
What correct looks like¶
The Blueprint solved this. Do the same thing for the Provisioner:
- Authenticate the Provisioner with a self-signed X.509 cert, not a client_secret. Use
azure.identity.CertificateCredentialinstead ofClientSecretCredential. - Generate the cert on first bootstrap using
cryptography(RSA 2048, CN=entraclaw-provisioner, 365-day validity). Register the public cert on the app viaaz ad app credential reset --cert @<tempfile> --append. Discard the tempfile immediately. - Store the private key in macOS Keychain via
keyring— exactly likesetup.shalready does for the Blueprint cert. Service name convention:entraclaw-provisioner-cert, account = tenant id. - Purge
PROVISIONER_CLIENT_SECRETfrom.entraclaw-state.jsonat the same moment. Never write it again. - Delete any password credentials on the Provisioner app via Graph (
az ad app credential delete --id <app-id> --key-id <id>). A lingering password credential is a backdoor even after cert-auth is in place. - deploy.sh (and any other shell caller) shells out to a small Python helper (
scripts/provisioner-token.py) which callsget_graph_token()fromentra_provisioning.py. The helper prints the token on stdout. No bash construction of JWT assertions; the Python path usesCertificateCredentialwhich reads the PEM from Keychain in memory only.
Reference implementation¶
The persona-sati repo now has the cert-auth version. Read it to see the shape:
persona-sati/scripts/entra_provisioning.py—_generate_provisioner_cert,_upload_cert_to_app,_keychain_{get,store,delete}_cert,_remove_legacy_password_credentials, reworkedensure_app_registration()andget_graph_token().persona-sati/scripts/provisioner-token.py— the shell-callable Python helper.persona-sati/docs/runbooks/identity-migration.md— the user-facing migration runbook.persona-sati/pyproject.toml— the[provisioning]optional-dep group addskeyring>=24+cryptography>=42.
The file layout, naming, and migration path are all transferable to entraclaw with the service name changed (entraclaw-provisioner-cert vs persona-sati-provisioner-cert) and the state-file name changed (.entraclaw-state.json vs .persona-sati-state.json).
Migration requirement (non-negotiable)¶
This is not a green-field change. Existing developer machines almost certainly have a live PROVISIONER_CLIENT_SECRET in .entraclaw-state.json. The fix MUST include a one-shot migration:
- Read the existing
PROVISIONER_CLIENT_SECRETfrom.entraclaw-state.json(needed only to verify the app is live — do not use it for anything else). - Generate the new cert and register it.
- Store the private key in Keychain.
- Delete the password credential(s) from the app via
az ad app credential delete --id <prov-app> --key-id <keyId>. Enumerate allpasswordCredentials[].keyIdand delete each one. Zero password credentials must remain. - Remove
PROVISIONER_CLIENT_SECRETfrom.entraclaw-state.json. - Print a prominent summary: "Migrated Provisioner from secret-auth to cert-auth. X password credentials removed from the app registration. PROVISIONER_CLIENT_SECRET purged from state file."
If any of those steps fails, abort the migration with a red error; never leave the app in a half-migrated state with both a cert AND a password credential.
What is NOT being asked for¶
- Do not rename the app registration. Keep
EntraClaw Agent ID Provisioner; adding a cert is a credential-type change, not an identity change. - Do not touch the Blueprint cert. That path already works; leave it alone.
- Do not change the three-hop flow on the agent side. This fix is only about how the Provisioner authenticates to Graph during provisioning calls.
- Do not add a web of abstractions. One cert helper module, one token-mint helper, done. Read the persona-sati version — it is intentionally terse.
Verification checklist (for the agent doing the work)¶
Before opening the PR:
- [ ]
grep -r PROVISIONER_CLIENT_SECRET scripts/ src/ docs/returns nothing except comments in the migration code and this debt document. - [ ]
grep -r ClientSecretCredential scripts/ src/returns nothing (replaced withCertificateCredential). - [ ]
az ad app show --id <your-provisioner-app-id> --query "passwordCredentials"returns[]after running the migration on your own state. - [ ]
security find-generic-password -a <tenant-id> -s entraclaw-provisioner-certfinds the key. - [ ]
.entraclaw-state.jsoncontains noPROVISIONER_CLIENT_SECRETfield after the migration. - [ ]
setup.shruns cleanly end-to-end on a machine that has run the migration. - [ ] Every other script that acquires a Graph token (catch_up.py, dm.py, claude_memory_sync.py, diagnose-chat.py — grep for
ClientSecretCredentialor direct curl to/oauth2/v2.0/token) either usesentra_provisioning.get_graph_token()or aCertificateCredential. No direct secret usage anywhere. - [ ] The token-mint helper is reachable from both Python and shell (deploy.sh, if any). Helper name and path documented in
README.mdor an equivalent spot. - [ ] Test suite runs green. If the existing test suite mocked
ClientSecretCredential, the mocks need to flip toCertificateCredential. - [ ] A new runbook section (or a new file at
docs/runbooks/cert-auth-migration.md) walks a user through running the one-shot migration, including backup steps and rollback.
Why this is filed as a stern document, not a casual TODO¶
Because every week that passes with this unfixed is another week of:
- A live client_secret sitting in a known file path on your development machines.
- Any new developer who pulls this repo and runs setup.sh inheriting that same live secret.
- A broken symmetry with the Blueprint path — the latter is correct, the former is not, and the inconsistency itself is a smell that invites "why is the Blueprint cert in Keychain but the Provisioner secret on disk?" confusion in future reviews.
Treat this at the same severity as the Blueprint-cert work that was already done. It is not a lower-severity issue; it was just missed.