跳转到内容
Developer Preview — APIs and language features may change before 1.0

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.

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.

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.

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.

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 permissions block 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.

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 denied

This 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 a
machine 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 the
machine.

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.

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 by
the 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 declared
in "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.

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 stopped
and 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

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.

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.