DocsWhat may it doWriting policies

Writing policies

Policies are plain TOML, reviewable, diffable, and enforced deterministically. They live in your repository, and they stay there: a rule set that can be changed over HTTP is one nobody can review in a diff.

toml
version = 1
project = "checkout"
 
[[policies]]
name = "production-database-protection"
 
[policies.match]
actions = [ "database.delete", "database.drop" ]
environments = [ "production" ]
 
[policies.decision]
effect = "deny"
reason = "No AI-initiated destructive database operations in production."

The rule above is the whole of it. What it refuses, and the reason a person reads when it fires, follow from those fields and nothing else.

The fields, in one paragraph

A rule is a match and a decision. Match takes the action, and optionally the target, the environment, the branch, the working directory, the agent or the role, a time window, the state in force, how the request sat against the task the session declared, and, locally only, the call's own arguments. Decision takes an effect and, where it needs them, a reason, approvers, a quorum, a rate limit, and the alternative a refusal names.

An omitted field matches everything, which is the single most common source of a rule that fires more widely than intended. When several rules match, the most restrictive effect wins:

deny  >  ask  >  allow

Order in the file does not matter, and neither does which file: every rule file the machine loads is evaluated together. One of them is not in any repository. The denies on secret reads that setup writes are about this machine, since a key in ~/.ssh is the same key from every checkout, so they live in ~/.memnox/machine.policies.toml and apply in every repository, where no checkout can delete or move them.

Order in the file does not matter. Every field, with its type, default and the precedence rules in full, is on the policy file reference. The rest of this page is what the reference cannot tell you: which rules to write, and where they come from.

The one field that does the most work

toml
[[policies]]
name = "secrets-not-required"
[policies.match]
actions = [ "filesystem.read" ]
targets = [ ".env" ]
[policies.decision]
effect = "deny"
reason = "This task declared no credential need."
[policies.decision.alternative]
action = "filesystem.read"
resource = ".env.example"
note = ".env.example is readable."

alternative is what a denying rule permits instead. An agent told only no abandons the task; one told what to use instead finishes it under constraint. It is resolved from the rule rather than invented at the moment of refusal, which is what makes redirection reliable enough to depend on.

Name one only where a substitute exists. A container socket has no example beside it, and sending an agent at a path that is not there is worse than telling it no.

Down to the argument

The same tool can be routine in one directory and refused in another:

toml
[[policies]]
name = "no-recursive-delete-in-payments"
[policies.match]
actions = [ "shell.execute" ]
workingDirectories = [ "/srv/payments*" ]
arguments = { command = [ "*rm -rf*" ] }
[policies.decision]
effect = "deny"
reason = "Recursive delete is not an agent action here."

No match is not approval

When no rule matches, the action is ungoverned, not endorsed, and the configured default decides what happens to it. On a first install that default is allow, so the runtime observes rather than refuses: a rule nobody has read yet must not wedge an editor on minute one.

What changes with confidence is the mode rather than the default. memnox config set mode enforce, or memnox protect --enforce, is the step where recorded verdicts start biting. See From watching it to letting it run.

Lifecycle

bash
memnox policy check                       # every rule file this machine loads
memnox policy check memnox.policies.toml  # one file
memnox policy test 'gh pr merge 12'       # what one action would get
memnox doctor --wiring                    # is anything gating, and on which rule set

policy check exits non-zero when something will not parse, which is what lets CI run it. doctor --wiring answers a different question, whether Memnox is gating anything at all right now. Both are described in full in the CLI reference.

Deciding on this machine

Sometimes the decision comes before anybody writes a file: this should always ask, this should never run, I have approved this enough times.

$memnox protect --ask <action...>

Always ask a person before these. Written at once.

$memnox protect --deny <action...>

Never run these. Written at once.

$memnox protect --allow <action...>

Stop being asked about something already approved enough times. Said out loud, because an allow is the one change that widens what may happen.

On a machine enrolled in a workspace, each of these is also offered to the team on the next sync. It arrives as a proposal, never as a rule in force, and a second admin decides whether it becomes a team rule. The person who decided it is the owner of the machine that sent it, never a name the machine supplies, so a machine nobody owns offers nothing.

Publishing a set to a workspace

A rule file on one machine is that machine's business. A rule set published to a workspace binds every machine in it, so it is not published by the call that sends it:

Step

Proposed

Sending a rule set records a proposal and changes nothing. The response says what it would change against the version in force, added, removed and modified by name.

Announced

It is posted where the team already talks, and it waits in the console under Waiting on you, in rule changes waiting on a second admin, with Approve and Reject. The people a policy binds otherwise find out when something is refused.

Approved

A second administrator lets it in, compared by identity rather than by name, and publishing happens inside that approval.

Withdrawing your own draft needs nobody, because rejecting publishes nothing. Making somebody find a colleague to take back their own proposal is how a proposal gets left pending instead.

An approval counts in either place, the console or the conversation it was announced in. The console has no setting for it; a script can still narrow it to one of the two with PUT :ws/governance and approveIn. And the second approver is only required where a second administrator exists, so a team with one admin is not locked out of publishing, since publishing policy is not a paid feature.

A rule remembers who decided it

A rule that says "ask first" and nothing else reads as the product being difficult, and the person it stops cannot find out that a colleague decided it on purpose. So a team rule can carry its source: which act it came from, who decided it and when, the decision in plain words, and once published, who approved it.

Three doors lead there, and all three end as a proposal a second admin approves: a held call answered "always" on its chat message (see Approvals), a rule somebody decided on an enrolled machine with protect, and a recommendation from the approval history under Autonomy, in What you could stop being asked about. Who decided is never a caller's claim: it is the person signed in, or the owner of the machine that sent it.

Machines show it with no change of their own, because the reason they already print in a terminal prompt, a hook refusal and an MCP refusal is composed from the source:

Team rule: <the decision>. Decided by <who> on <day>, approved by <who>.

An approved rule with a source is also remembered as a decision, so what the team decided and why reaches the context agents are given as well as the gate.

Exceptions, with an owner and an end

A rule scoped to one repository used to be the only way to say "except here", and a scoped rule has no owner, no reason and no end. An exception records one as its own thing, proposed through POST :ws/policies/exceptions: the rule it bends, or an action pattern to hold more strictly, whether it relaxes or tightens, where it applies (a repository, an agent, or both), who answers for it and why, and the day it stops. It stands for at most ninety days.

An exception is approved by a second admin like any rule set, under Waiting on you while it waits, and nowhere else once it is decided. It reaches machines as part of the rule bundle. When it expires the bundle is published again without it, so it ends on the machines too rather than only in the console.

Writing a good reason

The reason string is what a human reads at the moment they are refused, usually while irritated and in a hurry.

Bad: Denied by policy. Good: Recursive delete is not an agent action here, ask #platform if you need it.

Where a starting set comes from

The runtime has no pack registry and nothing to install. Rules are generated from what is actually on your machine, which is the only starting point that is true about your machine. A rule that has to hold across forty machines is a different problem, and it is solved by publishing a set to a workspace rather than by installing a catalogue.

bash
memnox protect --yes          # a baseline from the scan
memnox protect --for gh       # rules for one CLI, from its verb table
memnox protect --from-usage 30d   # ask rules for what was never used

protect --yes denies the sensitive paths it found, denies destructive commands, puts write-capable MCP tools behind an ask, and allows reads. protect --for narrows that to one thing: for an authenticated CLI it denies the credential file and leaves the CLI working, which is the distinction that makes any of this adoptable.

The verb tables

Nineteen CLIs have a table saying which of their subcommands read, which write and which destroy: aws, gcloud, az, gh, kubectl, terraform, docker, vercel, railway, fly, heroku, netlify, psql, mysql, mongosh, npm, stripe, git, playwright.

bash
memnox explain gh      # the credential, the projects, the table, the rule

The table explain prints is the one enforcement reads, so what the screen promises is what the gate does.

Generated rules and your own are evaluated together under the same most-restrictive-wins semantics, so you never fork a generated rule to tighten it: add one beside it. To loosen one, edit it, and the diff shows what your team actually chose.