Skip to content

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.

ChannelTaken in 0.1.0-alpha.49.2Who answersResult channel / assuranceDoctor approval.mode
The client's own dialog (MCP elicitation)yes: the only channelThe client's dialog. Approve runs exactly the preview; Reject, closing the dialog or no answer run nothingclient-elicitation / client-ui, trust client-response, personVerified: falseclient-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 itnoNobody. The protected call returns APPROVAL_CLIENT_UI_REQUIRED at once with the preview and executed: falsenoneno-dialog
Trusted Client, host-permission (planText), the Desktop extension's Approval modeno: removedearlier records keep their labels: trusted-client, host-prompt
A page in the browser, review-page Saveno: a review page's Send for approval only hands the decisions to Claude; the dialog then records themearlier 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 ​

  1. Confirmation and preview fingerprint. The apply tool needs confirmation and, where the preview defines one, previewFingerprint. Praxis recomputes the current plan; if its fingerprint differs from the one passed, the call fails with APPROVAL_FINGERPRINT_MISMATCH (stale preview or state drift) before anyone is asked. What is approved is what runs.
  2. Configuration check. Before asking, Praxis checks the client's configuration it can read. It refuses when it finds an Elicitation hook 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, any env entry, a status line command, plugins or marketplaces, helper commands). Whatever it cannot fully check refuses too. The doctor names the file, flag or variable.
  3. 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-answered and nothing runs. A client version seen twice declining faster than any person is not asked again. A dialog nobody answers within 10 minutes ends with APPROVAL_TIMED_OUT (nothing executed). After the answer the configuration is checked again.
  4. 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.
  5. Verification. verifyUserApproval checks it was issued by Core, is for this action, matches the digest of the subject as it is now (APPROVAL_SCOPE_MISMATCH otherwise) and is recent: usable for 10 minutes (APPROVAL_EXPIRED), for one execution of one action.
  6. Lease. The write runs inside a mutation lease (runApprovedMutation). The lowest-level writers call requireMutationLease() and refuse outside it with APPROVAL_REQUIRED: a direct library call cannot write without an approved action. Operations nested inside an approved action share its approval.
  7. 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 ​

CodeMeaning
APPROVAL_CLIENT_UI_REQUIREDPraxis 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_REJECTEDReject was chosen in the client's dialog
APPROVAL_DISMISSEDThe dialog was closed without deciding; not a rejection
APPROVAL_WITHDRAWNThe agent passed confirmation=NO; not the user's decision
APPROVAL_TIMED_OUTA dialog was shown and not answered in time; nothing executed; ask again
APPROVAL_SCOPE_MISMATCHThe approval is for another action or for different content
APPROVAL_EXPIREDThe approval is older than 10 minutes
APPROVAL_TOO_LARGEThe action is too large to show in full; Praxis did not ask
APPROVAL_FINGERPRINT_MISMATCHThe preview is stale; preview again
APPROVAL_REQUIREDA 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. personVerified is always false, and decidedBy names 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.