Security model
Sources: praxis-core/docs/user-approval.md, docs/approval-threat-model.md, packages/contracts/src/user-approval.ts (Core), approval-channel.ts, dialog-automation.ts, server.ts, tools.ts (Runtime).
Praxis changes things outside the agent's own notes only after the client's own approval dialog approved exactly that change. There is no approval service, Broker, signing key, system account or administrator step. The earlier native approval service was removed.
What needs approval
The 15 protected tools (Tools catalog): writing .project; Jira writes (BA apply, Architect apply, claim, release, complete, recovery, Quality apply, Jira link); Git connect, identity, commit, push, merge request; and praxis_workflow_apply for human acts (confirming a Story, confirming an architecture decision, approving a service). Recording review decisions (praxis_review_apply) and correcting the decision journal (praxis_approval_journal with amend=true) need the same approval. Reading, previews, the doctor and agent-authored workflow records do not need approval.
An approval names one action and one subject (the exact content shown). A commit approval is not a push approval; approving a decision does not confirm a Story.
How approval is obtained
Since 0.1.0-alpha.49.2 there is one channel: the MCP client's own dialog. No mode, setting, file or environment variable stands in for it.
| Channel | Taken in 0.1.0-alpha.49.2 | Who answers | Result channel / assurance | Doctor approval.mode |
|---|---|---|---|---|
| The client's own dialog (MCP elicitation) | yes: the only channel | The client's dialog. Approve runs exactly the preview; Reject, closing the dialog or no answer run nothing | client-elicitation / client-ui, trust client-response, personVerified: false | client-ui |
| A client without a usable dialog: none declared (Desktop Chat, Cowork), the client answers it by itself (the Desktop Code tab), or something configured could answer it | no | Nobody. The protected call returns APPROVAL_CLIENT_UI_REQUIRED at once with the preview and executed: false | none | no-dialog |
Trusted Client, host-permission (planText), the Desktop extension's Approval mode | no: removed | earlier records keep their labels: trusted-client, host-prompt | ||
| A page in the browser, review-page Save | no: a review page's Send for approval only hands the decisions to Claude; the dialog then records them | earlier records: review-page |
The answer reaches Praxis as the client's protocol response to the server, not as a tool argument, so an agent cannot write it through Praxis. Only "Accept" with "approve" selected approves. Nothing in tool arguments or text (confirmation=YES, approved, userApproval, planText, a file in the repository, "the user said yes") substitutes for it. confirmation=NO is the one text input that works: it withdraws the request (APPROVAL_WITHDRAWN) and runs nothing.
PRAXIS_APPROVAL_MODE, PRAXIS_APPROVAL_PAGE, PRAXIS_APPROVAL_BROWSER and a lower PRAXIS_APPROVAL_MIN_ANSWER_MS are ignored and listed by the doctor (approval.ignoredEnvironment). A longer minimum answer time is taken, because it only makes approval stricter. In a shipped Runtime every test switch is inert.
How a protected call is checked
- Confirmation and preview fingerprint. The apply tool needs
confirmationand, where the preview defines one,previewFingerprint. Praxis recomputes the current plan; if its fingerprint differs from the one passed, the call fails withAPPROVAL_FINGERPRINT_MISMATCH(stale preview or state drift) before anyone is asked. What is approved is what runs. - Configuration check. Before asking, Praxis checks the client's configuration it can read. It refuses when it finds an
Elicitationhook in any settings scope, plugin, marketplace, or agent, skill or command definition; a client started with settings, plugins or agents on its command line; a client environment that points it at more configuration or loads code into it; or a repository whose own Claude Code settings do more than plain preferences (any hook, anyenventry, a status line command, plugins or marketplaces, helper commands). Whatever it cannot fully check refuses too. The doctor names the file, flag or variable. - Ask. The dialog presents the exact action. An answer sooner than 1000 ms, accept or decline, is the client's own: it is recorded as
auto-answeredand nothing runs. A client version seen twice declining faster than any person is not asked again. A dialog nobody answers within 10 minutes ends withAPPROVAL_TIMED_OUT(nothing executed). After the answer the configuration is checked again. - UserApproval. On approve, Core issues a frozen
UserApproval(action, kind, summary,subjectDigest, decision, channel, assurance,decidedAt). It is tracked in an in-process set; parsed JSON, a tool argument or a look-alike object is never recognized as an approval. - Verification.
verifyUserApprovalchecks it was issued by Core, is for this action, matches the digest of the subject as it is now (APPROVAL_SCOPE_MISMATCHotherwise) and is recent: usable for 10 minutes (APPROVAL_EXPIRED), for one execution of one action. - Lease. The write runs inside a mutation lease (
runApprovedMutation). The lowest-level writers callrequireMutationLease()and refuse outside it withAPPROVAL_REQUIRED: a direct library call cannot write without an approved action. Operations nested inside an approved action share its approval. - Journal. The outcome (approved, rejected, dismissed, withdrawn, timed out or refused) is appended to the decision journal. Journal writes are best effort and never change a decision.
Decision journal
~/.praxis/decisions/HASH.jsonl, one file per repository, where HASH is the first 16 hex characters of the sha256 of the repository path. PRAXIS_STATE_HOME moves the root; protected actions are refused while it points inside the repository. Directory mode 0700, file mode 0600. Entries: time, action, summary, digest of the exact content, decision, what answered (decidedBy), channel, evidence (the answer's timing), reason, repository. It is outside the repository, so it cannot be committed with the project or edited as part of it, and it is never read back as an authorization. praxis_doctor shows the path as approval.decisionLog.
praxis_approval_journal lists older entries that blame you for a decision without evidence (the agent's confirmation=NO, or a client's own instant decline). With amend=true, and after the dialog approves exactly that list, it appends one correction per entry. No existing line is changed or removed.
Error codes
| Code | Meaning |
|---|---|
APPROVAL_CLIENT_UI_REQUIRED | Praxis could not ask: no dialog, the client answered it by itself, or something configured could answer it. Nothing executed; preview returned; nobody rejected it |
APPROVAL_REJECTED | Reject was chosen in the client's dialog |
APPROVAL_DISMISSED | The dialog was closed without deciding; not a rejection |
APPROVAL_WITHDRAWN | The agent passed confirmation=NO; not the user's decision |
APPROVAL_TIMED_OUT | A dialog was shown and not answered in time; nothing executed; ask again |
APPROVAL_SCOPE_MISMATCH | The approval is for another action or for different content |
APPROVAL_EXPIRED | The approval is older than 10 minutes |
APPROVAL_TOO_LARGE | The action is too large to show in full; Praxis did not ask |
APPROVAL_FINGERPRINT_MISMATCH | The preview is stale; preview again |
APPROVAL_REQUIRED | A mutation was attempted outside an approved action |
Security advisory: fixed in 0.1.0-alpha.49.2
In 0.1.0-alpha.48 to alpha.49.1, a repository's Claude Code settings could approve Praxis's protected actions by themselves: through PRAXIS_APPROVAL_MODE (Trusted Client) in their env block, through an Elicitation hook that answered the dialog, and, in alpha.49 and alpha.49.1, through a test variable that wrote the review page's secret link into the repository, where an agent could read it and save review decisions. In Claude Desktop the extension's Approval mode setting switched on Trusted Client the same way. Update every Praxis plugin (Update). The details are in the release notes.
What is not guaranteed
- A dialog answer is a client response, not a person. Praxis knows that the client's dialog answered, not who or what answered it.
personVerifiedis alwaysfalse, anddecidedBynames what answered, never a person. - The configuration check is best effort. It is a list of sources Praxis knows. Four rounds of independent review each found sources it had missed; those are refused now, but there may be more. A repository whose configuration Claude Code already ran as you could have removed what Praxis would see. Open a repository in Claude Code only after you have checked it.
- Not a boundary against a program running as you. Approvals happen on your computer, through a client you run, so a program that runs as you can get past them. That includes an agent with a shell, code from a repository you trusted, and a script you ran. Such a program can start its own client and answer the dialog, load code into your client, type into the dialog, edit Praxis's files, or skip Praxis and run Git or Jira itself. Approval is a consent and workflow mechanism: when the agent uses Praxis to change something, the client's dialog was asked and said yes, nothing configured answered for you where Praxis can see it, and Praxis tells the truth about how it knows. Git hooks, CI and the repository host are not changed by it.
- No identity check. Whatever answers the client's dialog is "the dialog's answer". There is no biometric or hardware key.
- The scripted evidence is not a person. Acceptance runs answer with a hook, a scripted client or keys sent to the CLI in a pseudo-terminal. They prove the protocol and the client, not that a person answered.
- A host's tool-permission prompt is not an approval. In Claude Desktop, allowing a call at Desktop's own prompt lets it reach Praxis; the protected action is still refused there.
Independently authenticated human approval needs an authority outside your account: for example another OS account, a hosted service or a paired phone, which signs each decision with a key the agent's account never holds, verified by the Runtime. Praxis does not provide one.
Which clients show the dialog
See the client matrix in Compatibility. In short: the Claude Code CLI in a terminal approves in its dialog; Claude Desktop Chat, Cowork and the Code tab do not execute protected actions, so apply them from the CLI. praxis_doctor → approval is the evidence for the client you actually use.