Skip to content

Native read confinement, Linux proxy enforcement, and grade A - #4

Merged
O6lvl4 merged 41 commits into
mainfrom
develop
Sep 20, 2026
Merged

O6lvl4 merged 41 commits into
mainfrom
develop

Conversation

@O6lvl4

@O6lvl4 O6lvl4 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

38 commits. Two enforcement gaps closed, one second implementation removed, and the code quality grade moved from 75 (B) to 92 (A).

Enforcement

--read-policy strict on both platforms. Reads are confined to the mounts you granted plus the system directories a command needs to start; every home directory is closed. Linux uses Landlock ABI 1, macOS sandbox-exec.

The macOS read set could not be guessed — a wrong one aborts every process, including /bin/echo, and the profile's (trace ...) facility is itself denied. The kernel records each denial in the unified log, so it was read back from there to a fixed point. scripts/probes/macos_read_set.py is that loop and reads the set out of sandbox_profile.rs, so it cannot drift from what porta applies. Apple moves these paths between releases; re-run verify on a new one.

The command itself is subject to the policy: a toolchain under /opt needs -v on its own installation, and porta says which grant is missing instead of letting the kernel answer with a bare Permission denied.

Proxy mode is enforced on Linux instead of refused. Landlock's network rules reach TCP and nothing else, so porta could not hold the claim that the loopback proxy is the only egress. A seccomp filter closes the rest: socket(2) returns EAFNOSUPPORT unless it asks for AF_INET with SOCK_STREAM, and io_uring is refused outright because a ring can open a socket without ever issuing socket(2). A kernel that will not take the filter refuses the run.

Rejections say what was wrong. A tool call whose arguments fail the declared schema now names the violations. Measured on the containment suite, that moved task completion from 6/15 to 9/15 with execution still at 0 — the apparent safety-versus-capability trade-off was a missing error message.

Removals

serve read a module's exports with a hand-rolled WASM parser while validate and inspect read the same module through wasmtime. Two independent readings of one format is the hazard the wasmtime migration exists to prevent. serve now reads them the same way, which left nothing calling the parser; it goes, along with the WASI implementation beside it that had been unreferenced since that migration. 776 lines.

Grade

75 (B) → 92 (A) on the pinned analyzer, enforced in CI at the grade-A boundary of 90. docs/roadmap/done/04-code-quality-grade.md records what moved it, what was deliberately left, and why — including the evaluation harnesses whose line counts are not worth chasing, and the .almd complexity numbers that are artifacts of the analyzer's parse coverage. No threshold was relaxed.

Verification

Every commit passed almide check, almide build, almide test --ci (110 tests), the integration suites and a Linux container run before it was pushed, and CI is green on both platforms for all of them.

Three native modules carry #![cfg(target_os = "linux")], so on macOS they are not compiled at all — every local gate can pass while one of them does not build. CLAUDE.md now says so and carries the container command that does check them.

Not closed

  • The isolation-matched benchmark arm still cannot run: docker agent run --sandbox reports requires Docker Desktop with sandbox support on Desktop 4.82.0. The startup and memory numbers compare against native execution and must not be read as a container-versus-WASM isolation result.
  • The containment scenarios are author-written, five of them, on one small quantized model on macOS. That is a measurement, not a security proof.
  • ACCESS_NET_BIND_TCP is unused, so a child may still listen on a port. That is ingress; the invariant is about egress.
  • --read-policy still defaults to open. Flipping it is a version boundary.

🤖 Generated with Claude Code

O6lvl4 and others added 30 commits September 20, 2026 09:39
porta run enforces nothing on Linux: exec_sandboxed_linux is a fail-closed
stub. The README now leads with restricting an agent the reader already runs,
and that entry point is macOS-only, while the people who most want it run CI
and servers on Linux.

Records the parity target against the current sandbox-exec profile, the
candidate mechanisms without picking one, the requirement that partial support
refuse the rules it cannot enforce, and the overlap with the proxy item, which
needs the same platform backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The item required the kernel and ABI matrix to be measured rather than
assumed. On a 6.12 kernel Landlock reports ABI 6 and, unprivileged and
without namespaces, refuses a write outside the allowed directory and a TCP
connect to a port outside the policy while letting the allowed ones through.

It also surfaces a semantic gap: Landlock is allow-list only, so the macOS
behaviour of denying reads of ~/.ssh and ~/.gnupg while permitting every other
read cannot be expressed. That is now a stated decision rather than something
to discover while implementing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GitHub ubuntu-latest (Ubuntu 24.04.5, kernel 6.17.0-1022-azure, x86_64, no
seccomp filter) enforces the same filesystem and TCP port rules as the 6.12
kernel measured first, and allows unprivileged user namespaces. Two kernels
are still not the matrix, so the runtime ABI check stays required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
porta run enforced nothing on Linux: exec_sandboxed_linux refused to run and
wt_exec_replace had no Linux branch at all, so the entry point the README leads
with worked only on a laptop. Writes and TCP ports are now restricted through
Landlock, unprivileged, without namespaces and without an external runtime.

The ruleset is built before the fork so the child applies it with two syscalls
and no allocation. For the exec-replace path porta restricts itself and then
execs, since a ruleset survives execve. Both entry points now share one parsed
request instead of repeating the argument decoding.

Rules this kernel cannot express refuse the run rather than widen it: a port
that is not a number, or network rules below Landlock ABI 4. Reads stay
unconfined because Landlock is allow-list only and cannot express the macOS
denial of two paths, and proxy mode stays refused because it must also deny UDP
and Unix sockets. Both are documented as gaps rather than implied to work.

scripts/integration.py asserts the Linux half in CI: a write inside a mount
succeeds, a write outside every grant fails and leaves no file, a connect to a
granted port succeeds while the same connect fails when another port is
granted, and an unexpressible rule and proxy mode both refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tors

Every mutex in the broker and the WASM registry took its guard with
.lock().unwrap(), so one panic while a lock was held turned every later
acquisition into another panic and left the runtime unusable. The state behind
those locks is a registry of live objects or a counter, so locking::locked
continues with it rather than taking the process down.

In the Landlock module the ruleset and mount descriptors are now owned: an
OwnedFd and a File close on every exit path, where the manual close leaked the
directory descriptor whenever adding a rule failed. Every landlock syscall goes
through one wrapper that takes its arguments in kernel order, after the first
version of that wrapper reordered attr and size for create_ruleset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
done/wasmtime-migration.md said binary.almd would keep parsing module
structure for inspect and manifest, and that instruction-level parsing would
go. The decoder stayed: read_instr alone carried a cyclomatic complexity of
105, and every load walked the code, global, element and data sections to
build values nothing outside this file ever read.

Only exports and start are consumed elsewhere, and dispatch.almd uses the
Import type rather than a parsed import list, so those sections are now
skipped like a custom section. 722 lines become 334, module loading stops
decoding every instruction of every function, and porta inspect reports the
same imports and exports as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wasmtime_bridge.rs had grown to 1181 lines holding three unrelated things: the
WASM instance API the Almide side calls, a CONNECT proxy with its listener and
policy, and restricted command execution for three entry points. The proxy now
lives in http_proxy.rs and the execution paths in sandbox_exec.rs, each next to
the state it owns, leaving the bridge as the surface it is named for.

The Almide side resolves externs by module, so the moved entry points are
re-exported from wasmtime_bridge rather than wrapped. No behaviour changes: the
same 106 tests, the same macOS integration checks, and the same Linux
enforcement assertions pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
codopsy scores the repository 75. The formula makes the remaining gap a long
tail rather than a few outliers: with the two lowest files at 90 and issues at
100 the projection is still 83, so the mean has to move across about twenty
files.

Records what was already done and what each remaining file would cost, and the
rule that decides which of them to take: raise a file when the refactor makes
it easier to read, never by relaxing a threshold or shredding a linear test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
load carried a cyclomatic complexity of 42, checking versions, budgets, the
model endpoint, tool names, schemas, mounts, artifacts and delegation in one
body. The checks that have nothing to do with compilation are now named:
validate_settings, validate_endpoint, claim_name, offline_validator and
resolve_tool_mounts, each stating what it refuses.

Behaviour is unchanged and the suites that exercise this path still pass:
delegation budgets and cycles, WASI import compatibility, schema validation
with external references refused offline, and artifact pin enforcement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifteen test servers hid their request log with a bare `pass`, which reads as
an unfinished body rather than a decision; each now says why it is empty. Four
nested `async with` blocks become one statement, two comparisons against a
boolean literal ask the value what it is, two unused imports are gone, and the
Landlock probe names the port, address and mode it was passing as bare numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The regrade logic nested four levels deep to answer one question each time:
which calls a model emitted, which of them violated the declaration, whether a
test preceded an edit, and whether a compute payload survived the wire. Each is
now a named function that states the rule it checks, and the streamed-reply
decoder is split from the whole-body one.

Every archived report still regrades identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Split the tool step, the journal replay, the CONNECT handler, the MCP
response reader and the WASM run into named stages, and move the JSON
text escaping both the bridge and the sandbox needed into one module.

The escaping now covers the control characters below 0x20 that a raw
command or response body can carry, which the previous replace chain
passed through literally and left as invalid JSON.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
agent_runtime.rs held the configuration, the guest execution, the model
call, the checks, the tool step and the FFI surface in one 798-line file.
Each is now its own module under agent_runtime/, private to the broker,
so a reader looking for the check path does not have to walk past the
model client to reach it.

No behaviour changes: the moved code is unchanged apart from the
visibility each item needs to stay reachable from the parent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wt_exec_sandboxed, wt_exec_replace and wt_exec_supervised each took the
command and its whole policy as six positional strings, four of them JSON
and all of them the same type. They now take one request document, built
once on the Almide side from a typed ExecRequest, so a transposed pair of
policy lists is a type error rather than a silently wrong sandbox.

wt_exec_supervised had its own copy of the mount, network and environment
parsing; it now reads the same request as the other two. The macOS profile
moves to sandbox_profile.rs, and loading an agent moves out of the broker
into agent_runtime/loading.rs, where what a delegated child inherits is a
Descent value rather than six positional arguments threaded down the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wasmtime_bridge.rs was one 545-line file holding the instance lifecycle,
the HTTP client, the process helpers and module inspection. Each now sits
in its own module and the bridge keeps only the instance state and the
surface the Almide side resolves against. The proxy's audit trail moves
out of http_proxy.rs the same way.

Also reduces what the remaining long functions branch on: the MCP endpoint
check, the proxy's policy and listener setup, and the step that closes a
finished run's journal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chat agent's decide() branched on the event kind and then did the whole
of each branch inline, at six levels of nesting; each kind now has its own
function, and the MCP text unwrapping is stated as the two questions it
actually asks. to_script in the compute example splits the same way, by
JSON kind.

Also narrows two blanket excepts to what those paths can raise, names the
struct in two sizeof() calls, states the interpreter's file-write behaviour
instead of marking it TODO, and turns off no-println for the same reason
no-print is off: stdout is this repository's interface, not its debugging
channel, and the proxy's audit line has to stay on stderr because stdout
carries newline-delimited MCP JSON.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parse_options threaded the recursion through all twenty-one option arms;
each arm now says only what it sets and how many arguments it took, and
one place does the recursion. The help text moves to its own module, so
cli.almd is the option model and help.almd is what the user reads.

validate() in the engine asked three questions at once — which profile
applies, what the module imports, and whether each import is allowed —
and now asks them one at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
codopsy scores the repository 91 (A), up from 75 at the start of this
work. The last steps: the bounded fixture interpreter dispatches per AST
kind instead of one long chain, the batch-recovery fixture names the state
its handler and its test body share instead of reaching for `global`, and
porta.toml loading reads each table through one helper rather than
twenty-two inline lookups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six harnesses reached for `global` to count down a fixture reply or an
in-flight request. Each now has one named object holding exactly that
state, so what the handler and the test body share is written down where
both can see it.

Records the measured path from 75 to 91 and moves the code quality item
to done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A grade measured once is a claim about one afternoon. CI now installs a
hash-pinned codopsy, analyses the repository and fails below 90, which is
the grade-A boundary: the gate catches a drop below A rather than
enforcing a number someone picked. The analyzer is pinned because the
score moves with its thresholds and with how much Almide its grammar can
parse — the published v2.2.0 reads 90 where a newer build reads 93.

Also removes what the gate found: six copies of the help-or-target
preamble in the command dispatcher, the redirect handler's seven-argument
override, and the two nested arm branches that built a run's start and
resume commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The architecture note still pointed at two files that have since become
twelve modules and two directories. It now says which module owns what,
and that the extern declarations resolve against the two that re-export
the rest.

Adds the grade check to the verify list, with the warning that a poorly
parsed .almd file reports wrong function boundaries, so splitting one may
not move its complexity number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Raising --max-complexity from 10 to 20 lifts this repository from 90 to
92 without changing a line of code, which is the easiest way to make the
new gate say yes for the wrong reason. The check now reads the thresholds
back out of the report and refuses anything but the defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The broker validated tool arguments against the declared schema and then
threw away what failed, answering only "Arguments do not match the
declared input_schema; correct them before retrying." In the containment
suite the 4B model responded to that by re-reading the poisoned file,
re-emitting the identical invalid call, and giving up: the call was
contained and the task was lost.

The rejection now carries the violations the same validator produced,
bounded to five entries of 200 characters. Nothing in the list is new to
the caller — the schema was sent to it in the tool definition and the
values are its own.

Measured on the same suite, same model revision, same seeds. Porta's
argument_validation trials go from 3 attempted / 0 executed / 0 completed
to 3 / 0 / 3, and its overall completion from 6/15 to 9/15, level with
Docker Agent while still executing 0 forbidden effects against its 3. The
four other scenarios are unchanged. Both reports are archived and audited
in CI, so the before and after can be regraded independently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reads were the one control the Linux backend left open. Landlock is
allow-list only, which is why porting the macOS deny-list was rejected —
but the allow-list is the stronger shape. `--read-policy strict` handles
READ_FILE and READ_DIR and grants reads beneath exactly two sets: the
mounts the caller was given, and the platform's own directories. Every
home directory is closed without naming one, so ~/.aws/credentials,
~/.config/gh and every .env go with ~/.ssh rather than being enumerated
one at a time. It needs ABI 1, below the ABI 4 the network rules need.

A system path absent on a host is skipped rather than refused; a mount
the caller asked for still refuses, as a write grant does. Verified in a
container: a credential outside every grant is unreadable, the granted
mount and a system interpreter still are, and an unknown policy name
refuses.

macOS refuses strict. sandbox-exec can express an allow-list, but a naive
system set aborts every process on macOS 26.3 including /bin/echo, and
the profile trace facility that would report what dyld needs is itself
denied. Refusing is what the network and proxy paths already do with a
rule the platform cannot express; the roadmap records the remaining work.

The default stays open on both platforms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sandbox-exec can express a read allow-list, but a guessed system set aborts
every process, and the profile's own (trace ...) facility is denied. The
kernel records each denial in the unified log, so running a target under a
candidate profile and adding exactly what it named, to a fixed point, gives
the set. The first blocker is / itself: the loader reads the root directory
entry before any subtree rule can help.

scripts/probes/macos_read_set.py is that loop, reading the set out of
sandbox_profile.rs rather than repeating it. Eighteen commands run clean
under the shipped set with a home directory unreadable. ruby does not, because
RubyGems reads /Library/Ruby/Gems — a mount the caller grants, not a hole the
list leaves open.

A rule names the path the kernel resolved, so an absolute mount is now
canonicalized before any rule is written: /var/folders/... is really
/private/var/folders/... and a rule for the former matched nothing. This
affected the write rules too.

The Landlock policy moves to its own module beside the macOS one, and each
platform's entry points take their policy from a single function, so run, up
and MCP execution cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A command outside every mount and outside the platform's own directories
cannot be read under --read-policy strict, so it cannot be started. The
kernel answers that with a bare Permission denied, after the policy has
already been applied and the paths are gone. Refuse earlier instead, naming
the command and the -v that would fix it.

This is how CI failed on ubuntu-latest: its Python lives in the hosted
toolcache under /opt, which no system set names. Refusing was correct; the
message was not. The integration suite now asserts both halves on both
platforms — granted, the interpreter runs; ungranted, the run is refused and
says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous run granted the interpreter's bin directory, which lets it start
and then fail to find its own standard library: GitHub's Python lives under
/opt with its stdlib at <prefix>/lib. The grant is the installation, not the
executable's directory, and the message now says so instead of implying the
parent is enough.

The earlier container check missed this because the binary it relocated still
loaded from /usr, which the system set covers. Reproduced faithfully with a
binary in <prefix>/bin resolving its library through $ORIGIN/../lib: no grant
refuses, bin alone cannot load the library, the whole prefix runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
porta.exec and porta.http each had their capability check and their allow-list
check written out at the call site, in two branches that differed only in
which capability and which argument they named. A third builtin would have
been added by copying one of them, and a builtin added with one guard and not
the other would have looked right.

Both guards are now part of the builtin's own declaration, along with the
schema tools/list advertises, so what porta offers is what porta can check.
The refusal still names the argument it rejected.

The two result renderers were also the same function twice over. mcp.almd is
left as the session: routing, and what a reply does to the server. Resources
and prompts move out with it — neither touches the session, which is now
visible in their signatures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three helpers had grown a second concern inside the first. The journal audit
walked a hash chain and decided what each record meant in one loop; the
feedback builder dispatched on check kind and also composed two of the seven
messages inline; the transcript decoder handled a whole-JSON reply and an SSE
stream in the same function, which is exactly the pair it exists to keep
apart.

Splitting them changes no behaviour — the eval grader tests and the compute
smoke audit agree — and each piece is now readable without holding the other
in mind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O6lvl4 and others added 11 commits September 20, 2026 15:50
`porta serve` without a manifest listed a module's exports with a hand-rolled
WASM parser, while `validate` and `inspect` read the same module through
wasmtime. Two independent readings of one format is the hazard
`done/wasmtime-migration.md` gives as its reason for replacing the
interpreter, and here it meant serve and validate were free to disagree about
what a module offers. serve now reads exports the same way the others do.

That leaves nothing calling the parser, so it goes, along with the WASI
implementation beside it: step 3 of that migration said to delete the
interpreter, and `wasi.almd` was overlooked. Nothing has imported it since.
What survives is the import shape the capability check matches against, in
`src/wasm_imports.almd`.

776 lines removed. Tests, integration, the official MCP SDK suite and a
no-manifest serve against a real module all agree with what went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
92 (A) on the pinned analyzer, from 90. The CI floor stays at 90: the gate
exists to catch a fall out of A, not to ratchet a number that the analyzer's
own Almide parse coverage partly decides.

Also closes the wasmtime migration's third step in its own record, which read
as pending because `wasi.almd` was overlooked and `binary.almd` still had one
caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither had a single test, in this suite or any other, which is how the
handlers could be moved to another module without anything noticing. The suite
now asserts what the manifest declares is what the server lists, that reading
one dispatches into the module, and that a name the manifest does not carry is
refused rather than dispatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Proxy mode claims the loopback proxy is the only way out. Landlock's network
rules reach TCP and nothing else, so porta could not hold that claim and
refused the run rather than enforce half of it. A seccomp filter closes the
rest: socket(2) returns EAFNOSUPPORT unless it asks for AF_INET with
SOCK_STREAM, and Landlock still decides which TCP port may be reached, so the
two together are the invariant rather than either alone.

io_uring is refused outright, because a ring can open a socket without ever
issuing socket(2) — a filter watching only that syscall would assert a policy
the kernel does not hold. A mismatched arch kills the process instead of
returning an errno, since syscall numbers mean different things in different
tables and an errno there would be a guess. socketpair(2) is left alone: both
ends stay with the process, so it reaches nothing.

The filter is checked for acceptance before the run commits to it, so a kernel
that will not take it refuses like an unexpressible Landlock rule does.

Verified in a Linux container: under proxy mode a TCP socket opens while UDP,
Unix, IPv6 and netlink do not and io_uring returns ENOSYS; without proxy mode
none of them are filtered. The integration suite now asserts both halves.

Not closed: ACCESS_NET_BIND_TCP is unused, so the child may still listen on a
port. That is ingress, and it is recorded rather than implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A BPF jump counts from the instruction after it, so every distance in the
filter was a literal that had to agree with the layout. They did not, the
first time this was written — all five were wrong, and a wrong distance
branches somewhere arbitrary rather than failing.

Each jump now names the verdict it goes to and the distance is computed, the
program is a static whose length the verdict indices have to match, and the
whole thing is const so a target that moved above its jump fails the build.
Nothing is assembled between fork and exec any more either.

Checked on Linux, where this module is the only place it compiles: it builds,
proxy mode still denies UDP, Unix, IPv6, netlink and io_uring, and adding an
instruction without moving the verdicts is refused by the compiler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three native modules open with a cfg for Linux, so on macOS they are not
compiled at all — every gate in the verify list can pass while one of them
does not build. That nearly shipped a rewritten seccomp filter that had never
been through a compiler.

The container command that does check them is now written down beside the
list, with the two things that go wrong: mount the source read-only or the
container writes the host's target/, and bookworm's glibc is too old.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Availability was checked with the seccomp() syscall while the filter is
installed with prctl(). A host can allow one and refuse the other, and the
probe disagreeing with the application means refusing a proxy-mode run that
would have worked. Both are now prctl(PR_SET_SECCOMP) with a null program,
which reaches the same validation, reports EFAULT, and installs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The invariant says proxy mode permits only the loopback proxy endpoint,
without UDP or Unix sockets. On macOS only the TCP half of that was ever
tested. It does hold — a UDP send and a Unix-socket connect both fail with
EPERM — but it held untested, which is how the Linux half went missing for so
long.

The two platforms refuse at different points and the tests now say so: macOS
at connect and send, so a UDP socket is created and then reaches nothing;
Linux at socket(2), so it never exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five things were wrong rather than unclear. The architecture tree still listed
the two files the wasmtime migration removed and omitted native/ entirely,
which is where enforcement now lives. The enforcement layer was described as
sandbox-exec, with no mention of Landlock or seccomp. And --read-policy,
--proxy-allow, --proxy-deny and --proxy-audit were all shipped and none were
in the options table, while --allow-net was listed in a way that reads as
host filtering, which it is not.

The two platform tables were near-identical, so a reader had to diff them by
eye to find the differences that matter. They are one table with two columns
now, which is how the roadmap already presented it.

Both languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Limits was a prose retelling of the Native Restrictions table, so a reader met
the same platform differences three times in three wordings. It now lists only
what Porta does not do — not a container, reads broader than writes, targets
not contents, two platforms, nothing mounted implicitly — and points at the
table for the rest. The proxy caveats that had been repeated there and in the
HTTPS section are said once.

porta agent was the fourth item in Quick Start and eight links in a table,
which is thin for the half of the product the other guarantees come from. It
has a section now with a real agent.toml: the pinned tool, the credential the
host keeps, the mount a tool gets. The table under it lists only what "What
Porta enforces" does not already link to, instead of repeating four of its
rows with the same targets.

Language Support repeated Quick Start; its one fact — anything that compiles
to WASI runs under porta run — moved to Install.

Both languages, heading for heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was written from memory. It omitted version = 1, and it omitted
input_schema — which is where tool arguments are declared, and validating them
is the guarantee this README leans on hardest. Showing the config without it
left the headline feature with no visible home.

The example is now checked: substituting a real digest and running
porta agent-check over it reports valid, with the schema, the pin and the
mount all read back as written.

Both languages carry the same config, comments aside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@O6lvl4
O6lvl4 merged commit 3420c34 into main Sep 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant