Permissions and the Blast Radius
Governance covers the shape of the ensures section. This chapter covers the
part people get wrong: how far a machine can actually reach, and who gets to decide.
The model in one sentence
Section titled “The model in one sentence”Naming an action is how a machine asks for it, declaring narrows where it may go, not allowed to
is the only phrase that forbids, a machine you call can never reach further than you can, and
saying yes to a destination belongs to whoever runs the machine.
Who decides what
Section titled “Who decides what”Five things get a say, from the inside out. Each one can only make the reach smaller than the one before it.
| Layer | Who sets it | What it does |
|---|---|---|
| The machine’s own text | Whoever wrote the machine | Says what the machine does, and how far it should go. Naming an action asks for it. A permissions block narrows and documents. not allowed to refuses. |
| The machine that called it | Whoever wrote the calling machine | Draws a boundary around everything underneath. A caller can shrink what the machines it calls may reach. It can never grow it. |
| Your computer | You | Whether a machine may actually go somewhere: allow, ask me once, ask me every time, or never. The person who wrote the machine cannot answer this for you. |
| Your organization | Whoever administers it | Hard limits nobody gets prompted through. Hosted mashin refuses to run programs on the machine, for instance. |
| mashin itself | Not configurable | Nothing reaches the outside world without a decision, every decision is written down, the record tells the truth, and the order of the layers above cannot be rearranged. |
Read it as one rule: an outer layer can only tighten what an inner layer intended, and a clear “no” anywhere in the chain wins.
Leaving the permissions block out
Section titled “Leaving the permissions block out”A machine with no permissions block is not a locked-down machine. It is a machine whose steps
speak for themselves. Naming an action is how you ask for it, and asking is granted.
machine permissions_naming_grants accepts city as text, default: "Dublin"
responds with conditions as text
behaves ask fetch, from: "@mashin/actions/http/get" url: "https://api.weather.example/v1/current?city=" + input.city
compute done {conditions: steps.fetch.body.conditions ?? "unknown"}
verifies test "a machine with no permissions block still reaches the network" assuming fetch {body: {conditions: "light rain"}, status: 200} given {city: "Dublin"} expect {conditions: "light rain"}This machine works. There is no block to write, nothing to remember, and no silent failure waiting for you in production because you forgot a line.
The reason it works is worth being precise about: the step named an action, so the machine may use the network. That is the author’s half of the question, and it is settled. Where the machine may go is a separate question, and it is not the author’s to answer. See The first time it wants to go somewhere new.
Source: examples/governance/permissions_naming_grants.mashin.
Declaring narrows, and writes it down
Section titled “Declaring narrows, and writes it down”Add one line and the machine is bound to a single host.
machine permissions_inside_radius accepts city as text, default: "Dublin"
responds with conditions as text
behaves ask fetch, from: "@mashin/actions/http/get" url: "https://api.weather.example/v1/current?city=" + input.city
compute done {conditions: steps.fetch.body.conditions ?? "unknown"}
ensures permissions allowed to network.http to "api.weather.example"
verifies test "a request inside the declared radius is allowed" assuming fetch {body: {conditions: "light rain"}, status: 200} given {city: "Dublin"} expect {conditions: "light rain"}to "api.weather.example" is the blast radius. Requests to that host go through. Requests anywhere
else do not, and never will, no matter what the machine is later edited to do or what a model
decides to put in a URL.
Two things this line does not do, and both matter:
- It does not unlock anything. The machine could already use the network. The line only makes the reach smaller.
- It does not forbid the capabilities you left out. A
permissionsblock is additive. Writing one grant does not silently revoke everything else the machine names in its steps. If it did, adding a single unrelated line could cut off a working machine somewhere further down the file, and you would have no way of knowing.
Only not allowed to forbids. Omission never does.
Use to "host" for network capabilities and under "path" for filesystem ones:
ensures permissions allowed to network.http to "api.stripe.com" allowed to filesystem.read under "./workspace"Source: examples/governance/permissions_inside_radius.mashin.
Saying no
Section titled “Saying no”not allowed to is the one phrase in the language that refuses, and it does not stop at the
machine’s own steps.
machine permissions_denial_crosses_a_call accepts city as text, default: "Dublin"
responds with result as text
behaves ask hop, from: "@mashin/actions/service/call" endpoint: "https://api.weather.example/current" payload: {city: input.city}
compute done {result: steps.hop.status ?? "unreached"}
ensures permissions not allowed to network.http
verifies test "INTENTIONAL DENIAL: an explicit no crosses the call into the machine this one invokes" given {city: "Dublin"} expect deniedThis machine makes no request of its own. It calls a machine that does. The request still does not happen. The record for that run reads:
Capability network.http.post is explicitly not allowed by "permissions_denial_crosses_a_call",which declares `not allowed to network.http`. An explicit refusal crosses every call and amachine it reaches cannot widen it.Read what it names. Not the machine that was carrying out the request, which was entitled to make it, but the machine whose line refused, and the line itself. That is the file to open.
This is the message the person running it is handed, for the same decision:
Denied by governance: "network.http.post" is explicitly not allowed by"permissions_denial_crosses_a_call". The attempt was stopped and recorded; nothing left themachine.In plain language: The machine permissions_denial_crosses_a_call says it never uses the internet, so this step was stopped.
That is the difference between leaving something out and refusing it. Leaving network.http out of
a block would have changed nothing here. Writing not allowed to network.http ends it, for this
machine and for everything it calls.
This machine is meant to be refused, and the verifies block asserts that it is. If it ever
stopped being refused, the example would fail before anyone read this page.
Source: examples/governance/permissions_denial_crosses_a_call.mashin.
The wall around everything you call
Section titled “The wall around everything you call”Here is the part that makes composition safe.
When a machine declares where it may go, that boundary is not a note about its own steps. It is a wall around everything the machine calls, however deep the calls go.
machine permissions_wall_denied accepts amount as number, default: 100
responds with result as text
behaves ask hop, from: "@mashin/actions/service/call" endpoint: "https://api.payments.example/charge" payload: {amount: input.amount}
compute done {result: steps.hop.status ?? "unreached"}
ensures permissions allowed to network.http to "api.weather.example"
verifies test "INTENTIONAL DENIAL: the callee's request to a host outside the composer's wall is refused" given {amount: 100} expect denied@mashin/actions/service/call is an ordinary, well-behaved machine from the standard library. It
makes an HTTP request to the endpoint it is handed. Called on its own, that request would be fine.
Called from inside this machine, it is not. api.payments.example is outside
api.weather.example, so the request is refused, and the record names the machine whose wall
refused it:
Constraint violation: network.http.post host "api.payments.example" (not_in_allowlist). Denied bythe composition wall declared in "permissions_wall_denied", which bounds every machine it calls;a composer can narrow what its callees reach, never widen it.Note what that sentence tells you. Not just that something was blocked, but which machine’s line did the blocking, so you know exactly which file to open. Standard library machines get no exemption from this. A guarantee with an asterisk is not a guarantee.
The person running the machine is told the same thing, in one line:
Denied by governance: "network.http.post" to "api.payments.example" is outside the wall declaredin "permissions_wall_denied". The attempt was stopped and recorded; nothing left the machine.In plain language: The machine permissions_wall_denied does not allow reaching that address, so this step was stopped.
Three renderings, one decision, and none of them says “an error occurred”. A refusal that reaches you as an error is indistinguishable from a bug in your own code, and you would go looking in the wrong place.
The one-line fix
Section titled “The one-line fix”You have two honest options, and both are a deliberate edit rather than a shrug:
ensures permissions allowed to network.http to "api.weather.example" allowed to network.http to "api.payments.example"Widen the wall on purpose, because you meant for this machine to reach the payments API. Or stop calling the thing that needs it. What you cannot do is have the callee quietly reach past you, which is the whole point.
Source: examples/governance/permissions_wall_denied.mashin.
When the refusing line is in a machine you called
Section titled “When the refusing line is in a machine you called”In the example above the machine you ran and the machine whose line refused are the same file, so
one name is the whole answer. That stops being true the moment you compose. If you run
daily_assistant, and it calls calendar_sync, and it is calendar_sync whose declaration
refuses, then you are holding two facts: what you started, and what stopped it. Naming only the
second answers half your question and hides the half you asked.
So when they differ, both are named:
Denied by governance while running "daily_assistant": "network.http.post" to"api.payments.example" is outside the wall declared in "calendar_sync". The attempt was stoppedand recorded; nothing left the machine.In plain language: While running daily_assistant, the machine calendar_sync does not allow reaching that address, so this step was stopped.
And an outright refusal reads the same way:
Denied by governance while running "daily_assistant": "network.http" is explicitly not allowed by"calendar_sync". The attempt was stopped and recorded; nothing left the machine.When the two are the same machine you get one name, never “while running daily_assistant, daily_assistant does not allow”. The sentence says what there is to say and stops.
The first time it wants to go somewhere new
Section titled “The first time it wants to go somewhere new”A grant in a machine’s text says what the machine may do. It is not your yes to where it goes, and the person who wrote the machine cannot give that yes on your behalf.
So on your own computer, the first time a run would reach a host you have never approved, mashin asks. This is exactly what you see:
permissions_naming_grants -> @mashin/actions/http/get -> http wants to reach api.weather.example(https://api.weather.example/v1/current?city=Dublin). Allow once, always, or never?Three parts, on purpose. The machine you started, the action it reached for, and where it wants to go, with the full address so you can weigh it. A prompt that said “Allow network access?” would tell you nothing you could act on.
Your answer is remembered against that machine and that host. Say always and you are not asked again for that pair. A different host is a new question, because it is a different question.
A machine that declares its addresses is asked once, about all of them
Section titled “A machine that declares its addresses is asked once, about all of them”That is the machine above, which declares nothing. A machine that does declare where it goes gets a different question. Its addresses are its manifest: the reviewable list of everywhere it says it will reach. You are shown the list, once, and one answer covers it:
billing_sync declares it reaches api.stripe.com and calendar.google.com.Allow once, always, or never?This is what declaring buys, and it is the reason to bother. The line in the machine’s text does not approve anything on your behalf. It changes which question you are asked: one question about a list you can read, instead of an interruption per address as each one comes up.
An address that is not in the list is never covered by that answer. It asks on its own, at the moment the machine reaches for it, exactly as it does for a machine that declares nothing. That asymmetry is the whole design. A declared list is reviewable up front, so it can be approved up front; a surprise is a surprise, so it stays loud.
Edit the list and you are asked again, because it is a different list.
Postures differ by where the machine runs:
| Where it runs | What happens on a new destination |
|---|---|
| Your own computer | You are asked, once, and the answer is remembered |
| Hosted mashin | Refused, because there is nobody at the keyboard to ask |
| Tests and sandboxes | Approved automatically, and still written down as such |
What the record says
Section titled “What the record says”Every one of these decisions is written down, and the record says which layer decided, not just what happened. A record about a destination says two things: what you decided, and whether the address was one the machine declared. These are the actual sentences:
| What happened | What the record says |
|---|---|
| You approved an undeclared address when asked | Allowed because the operator approved network access for api.weather.example when asked during this run. The decision was scoped to this address alone: it is in NO declaration of this machine's. |
| You had approved it before | Allowed because the operator approved network access for api.weather.example earlier and that answer was remembered. The decision was scoped to this address alone: it is in NO declaration of this machine's. |
| A test or sandbox approved a declared address | Allowed automatically by the sandbox/test posture for network access for api.weather.example. No person was asked and no operator approval is implied. The decision was scoped to the machine's DECLARED manifest, which this address is part of, rather than to this address alone. |
| A caller’s wall refused it | The full sentence is in The wall around everything you call. It names the capability, the host, and the machine whose line refused. |
A not allowed to refused it |
The full sentence is in Saying no. It names the capability, the machine that refused, and the line that did it. |
In plain language, the first of those reads: You said yes to letting it use the internet. This machine never said up front that it goes there, so the decision was about that one place on its own.
Every one of them has a plain-language reading like that, refusals included, and every surface that shows you a decision can show you that reading instead. A record you need to be a programmer to understand is a record most of the people it is about cannot check.
The distinction is the point, and it runs both ways. A record that said “allowed by the machine’s declaration” for a run that a person actually approved would point an auditor at the wrong layer. A record that said only “the operator approved it” would hide whether they were answering a list the author published or an address that turned up unannounced. Those are different facts about the same allow, and the record keeps both.
Try it
Section titled “Try it”Take the weather machine at the top of this page and add allowed to network.http to "api.weather.example". Confirm it still runs. Now change the URL to a different host and watch the
refusal name your own line back to you.
Next steps
Section titled “Next steps”- Governance - the
ensuressection, guards, and approval gates - Composition - calling machines from machines
- Behavioral ledger - where these records live
- ensures reference - full specification