18 Commits
Author SHA1 Message Date
xxoingr 8c42340ea4 test(netclient): pin the stream dialer's proxy boundary
NewStreamDialer is how SSH reaches a remote host under the user's proxy
(remote/attach), and none of dialer.go was exercised: netclient sat at
47% against a 47.2% floor on a declared-sensitive path.

Four tests drive it against fake proxies on loopback:
- an http proxy gets CONNECT to the target with Proxy-Authorization, and
  the credentials never also travel as Authorization;
- a proxy that answers 407 yields an error, not an open stream;
- an https proxy sees a TLS handshake first, so CONNECT and credentials
  never cross in cleartext;
- a SOCKS5 proxy gets username/password auth and the target by name.

Each fails when the matching guard in dialer.go is removed. The
netclient floor rises from 47.2 to 65.8.
2026-10-25 23:54:17 +08:00
esengine e1e5301681 test(control): cover the push trust gate and its refusals, and hold the file to it
internal/session/control/git_push.go measured 0 from the control package's own
tests, so declaring it sensitive recorded a floor of 0 that could never fail.
The tests cover the trust refusal for both calls, a repository that is not
there, a branch with no upstream, and the commit lock released after a refused
push; the floor is now 100.
2026-10-18 09:47:51 -07:00
esengine 8126f5cf1e chore(security): declare the Git push path and the host-only route list sensitive
The push is the one git action Reasonix performs that leaves the machine, and
the host-only route list decides which requests a paired device may make. Both
now demand review plus security_review and are held to the coverage they have:
gitpush 84.0, serve/git_push.go 77.0, serve/host_routes.go 93.0, and
control/git_push.go 0 (its behaviour is covered by the serve and boot effect
tests, which the per-package gate does not count).
2026-10-18 09:17:17 -07:00
esengine 11aa4014d0 fix(providers): keep key-carrying provider requests on the configured origin
Provider clients took netclient.NewHTTPClient, so a redirect from the
configured address was followed by Go's default policy and the key went with
it. Add redirectguard.StayOnOrigin, build the probe, provider, System One,
balance and doctor clients through endpointclient.New, and guard the default
clients of the model listing, balance and System One calls. A refused redirect
is a permanent error that names the target host and reaches the user as
redirect_refused instead of a network fault retried ten times.
2026-10-11 14:00:48 -07:00
esengine 45f28b6778 feat(sandbox): bash egress can be limited to named domains
Network in the bash sandbox was one bit: open to everything, or shut. With
[sandbox] allowed_domains set, bash's external traffic goes through a
host-side proxy (internal/safety/egress) that admits only the listed hosts;
denied_domains outrank them.

- The proxy serves CONNECT and absolute-URI HTTP on loopback, decides from
  the requested name, refuses names that resolve only to loopback,
  link-local, metadata or this host's own addresses, and chains through the
  upstream HTTP or SOCKS5 proxy the environment names.
- macOS: the Seatbelt profile shuts every destination but loopback, Unix
  sockets and the proxy port, and closes the loopback ports of the proxies
  the environment and the system proxy settings name. Loopback stays open
  so tests and dev servers keep working. Bind, inbound and outbound are
  separate grants, because (local ip "localhost:*") on network* matches the
  local end of every outbound connection and admits all of them.
- Each confined launch carries a token as proxy userinfo; what the proxy
  refused under it is appended to the bash result by the host, so a policy
  refusal reaches the model as one instead of a bare connection error.
- The list only narrows open network: it applies where network is on and
  the sandbox confines; a repo may set one only where the user has not, and
  its denials only add to the user's.
- Linux and Windows warn that the list is not enforced there.

Known gaps, stated where they live: a host proxy on a loopback port nobody
declared stays reachable, name lookups still reach the system resolver,
the decision reads the requested name (domain fronting is not caught), and
MCP servers are not routed through the proxy.

internal/safety/egress is declared sensitive, with its coverage floor.
2026-09-24 20:26:16 -07:00
yhh 394da0a313 refactor: the kernel's packages live in the layer they belong to
internal/ was 141 packages side by side, so nothing but a reader's memory
said which were foundations and which assembled the rest. Each now sits in a
layer directory, and the directory is the declaration:

  base      L0  foundation with no domain in it
  contract  L1  types and interfaces the layers above agree on
  model, tools, safety, ext, state, platform
            L2  subsystems, peers of one another
  runtime   L3  the turn engine
  session   L4  the controller
  assembly  L5  the composition root
  frontend  L6  cli, serve, acp and the host adapters

Only paths change: package names, code and behaviour do not. Imports,
repolint and coverage baselines, sensitive paths, CI package lists and docs
follow the new paths; release notes keep the paths they were written with,
and sample paths inside tests and fixtures keep theirs.

repolint gains layer-order: a package may import its own layer and those
below it, read from where it lives. The 47 imports that point upward today
are recorded as that rule's opening debt (-allow-widen, a new rule's first
baseline) and can only go down. Most of it is config, which still reaches
into eight subsystems and is the next thing to take apart; the rest is
event, history and a few tools reaching into runtime for types that belong
in contract.
2026-09-23 18:28:21 -07:00
esengine 591326b98b feat(computer): the pointer the person is holding, borrowed and given back
Accessibility can only touch what an application published as an element. A
canvas, a drawn view, a map, a game — the other implementations reach those by
moving the pointer, and this one answered computer.no_action. The live test
pins the difference on one view: AXPress finds nothing there, and the same
point under pointer_click reaches the application as a real mouseDown.

So the pointer becomes an explicit class rather than another step in the list:

- pointer_move, pointer_click, pointer_drag and pointer_position aim in the
  pixels of the screenshot the model was given, the same frame every other
  point-taking step reads, and the helper posts real events to the HID tap.
- The helper refuses a point whose window belongs to another application, so
  an approval for one application cannot walk into the next one's window.
- It is the person's pointer: taking it parks where they left it and the run
  gives it back, whether the steps finished, failed part way, or were stopped.
- The permission subject carries a pointer prefix, and that subject is only
  ever answered by an exact grant — no posture, no application-wide allow, can
  hand over the pointer on its own. The approval says so in as many words.

Alongside it, the steps the other implementations have and an accessibility
tree already exposes: right_click opens the element's own menu, scroll says
whether it turned the wheel or brought a named element into view, hold_key
holds a chord for a duration, key repeats, and wait is bounded.

internal/computer's floor rises 85.8% -> 89.8%.
2026-09-17 20:45:57 -07:00
esengine 049ee1a75d feat(computer): the agent can read and operate this Mac's other applications
Two tools over a native helper. computer_read lists the applications a person
can see (what=apps), reads one's accessibility tree with host-issued [aN] refs
(snapshot), or captures its front window (screenshot). computer_act runs steps
in order on one application — click by ref or at x/y in the latest
screenshot's pixels, focus, set_value, type, key — stops at the first failure
with its code, and returns the application's tree afterwards. An application
is named by its bundle id.

Nothing takes the person's pointer or the foreground. A click is an
accessibility action on the element under the point, and only an element of
the application the call names takes it; keys go to that application's
process. An element with no accessibility action answers computer.no_action
instead of being clicked some other way. The helper draws its own cursor where
it acts, and Escape pressed while that cursor shows stops the run between
steps (computer.stopped). Terminals, the keychain, password managers, System
Settings, authorization prompts and Studio itself are refused before the
helper is asked. A missing Accessibility or Screen Recording grant is requested
from the system once per helper, and the failure tells the model what the
person has to switch on and where.

desktop/computer-helper is the helper, in Swift: JSON lines on stdin/stdout,
built for macOS 12 with ScreenCaptureKit checked at run time. internal/computer
owns everything it is not: which applications, what a ref means, and the
screenshot geometry that turns image pixels into screen points. It is a leaf
package and declared sensitive, with a coverage floor of 85.8 from tests that
run the test binary as a fake helper; live_test.go drives a real application
through the real helper when REASONIX_LIVE_COMPUTER names one.

Permission: both tools form the Computer rule group, granted per application
as Computer=<bundle id>. A call on another application needs a person the way
typing a secret into a site does — auto, a glob and a bare rule do not answer
it; a person, the session grant they gave for that application, an exact rule,
or YOLO does. Headless auto refuses and says why. computer_act is an unknown
mutation for evidence, since an application can write into the workspace and
names no paths; computer_read is none.

The tools reach a provider only in a host that has a helper:
reasonix-studio-host takes -computer-helper, and no other host passes one, so
the default prefix and its goldens do not move. The built-in schema budget
is widened 6049 -> 6346 for these two schemas, which only a Studio session on
macOS carries.
2026-09-17 09:42:38 -07:00
esengine 20a7a0cf6b feat(browser): drive an installed Chromium over its debugging protocol
A kernel package for an agent that has to see and operate web pages, with no
frontend attached: the tools and the Studio panel sit on top of it.

It finds a Chrome, Edge or Chromium already on the machine (never downloads
one), runs it on a profile of its own, and drives it over
--remote-debugging-pipe; Windows cannot hand a child descriptors 3 and 4, so
there it falls back to a loopback port. One process per profile is shared by
every session through a Pool, since a profile admits one process.

A page is rendered from the accessibility tree, one element per line, with a
ref the package issues, never reuses, and retires when its document navigates
away — so a stale ref fails as browser.stale_ref instead of landing on
whatever replaced the element. Inputs are real pointer and keyboard events; a
pointer input whose point another element would receive is refused as
browser.covered, naming that element. A JavaScript dialog stops the page from
answering anything that runs on it, so page-bound commands fail at once while
one is open and are abandoned when one opens mid-call. Every failure carries a
Code.

checkURL bounds a page to http(s), about:blank and files inside the workspace
once symlinks resolve; a page reached past that by a link is refused when read
or acted on. The package is declared sensitive, and a utility-layer leaf.

Live tests (REASONIX_LIVE_BROWSER=<executable>) cover both transports, forms,
selects, covering elements, dialogs, popups, stale refs and screenshot
coordinates; they skip where no browser is installed.
2026-09-17 07:47:37 -07:00
esengine 24599c2fca refactor(control): controller.go loses what already had a home
Seven groups of methods move to the file their subject already owns: skills to
skill.go beside skillSet, checkpoints and summarize to checkpoint.go beside
checkpointManager, memory to memory_command.go, the in-flight and interrupted
turn bookkeeping to turn_cancel.go (where appendUniqueString's four callers
already were), branches to branches.go, session artifacts and recovery notices
to session_snapshot.go, and the goal verbs to a new goal_control.go rather than
into goal.go, which is over the ceiling already.

controller.go goes 3232 -> 2024. The essay, frontend-parity, complexity and
function-size budgets are unchanged to the point — this is the same code under
the same rules — while file-size drops 1208, which is what a 2400-line overage
becoming a 1200-line one is worth. The baseline is updated with -allow-widen
because an extraction moves each file's share, and REASONIX.md asks for that to
be visible in the command.

The approval bridge earns more than a move. gateApprover decides whether a tool
call runs, and it sat in controller.go — outside the sensitive paths, which
name approval.go and approval_orchestration.go and could not reach it there.
approval_bridge.go is declared sensitive, so the review and the coverage floor
now cover it: 77%, against 94% and 91% for the two files beside it.

Two coverage floors move on their own merit — approval_bridge.go is new, and
shellsafe rose to 81.6% when the redirect guard got the tests its own comment
promised. The rest are restored to what they were: `covergate -update` had
raised every floor to one run's reading, and providerbroker's 83.6% was seen at
83.1% on the next, which is a gate that fails for no reason.
2026-09-16 03:16:19 -07:00
esengine badbc0dd7d refactor: one spelling for sorting, and math/rand/v2
Two ways to sort a string slice were in the tree at once — slices.Sort in 30
places, sort.Strings in 148, and internal/hook/inspect.go used both. Since Go
1.22 sort.Strings is a call to slices.Sort, so this settles which one a reader
meets, not what the code does.

The four files that would have grown by an import line are already over their
size budget, so their remaining sort.Slice calls move too: cmp.Or over
cmp.Compare says a multi-field ordering in one expression where the boolean
form needed a chain of ifs, and the four files come out shorter than they went
in.

math/rand becomes math/rand/v2 throughout. The seeded call sites all use a
fixed seed for repeatability rather than asserting particular values, so PCG
replacing the v1 source changes which sequence they walk and nothing about
what they prove.

Backoff stays as it is. Four packages compute a retry delay and it is tempting
to share that too, but provider caps at 15s and the stream retry at 8s to match
a reference implementation's timing, while feishu uses proportional jitter and
remote full jitter. Those are four calibrated decisions, not one judgement
copied four times, and the formula is the part that was never hard.
2026-09-15 22:05:50 -07:00
esengine 6e7f189e76 fix(ci): the heap corruption's two upgradable causes, and a floor set on the wrong platform
The windows-latest crash is the GC finding a bad pointer while marking:
runtime.scanObject in mgcmark_greenteagc.go, under gcDrain. Green Tea is
the detector, not the cause — turning it off would move where the
corruption surfaces, not stop it, so this does not do that.

Two things in that binary can produce it and were both behind. The Go
toolchain moves 1.26.6 -> 1.26.8, which carries compiler and runtime
fixes; and modernc.org/sqlite moves 1.57.0 -> 1.58.0, taking libc from
1.74.4 to 1.75.6. libc is the only unsafe-heavy code linked into that
test binary that is not the standard library, and internal/bot reaches
it through historycatalog and projectiondb. Neither upgrade is proven to
be the fix: one crash cannot attribute itself, and golang/go#81238 —
windows/amd64 exception recovery writing below a goroutine stack — is
open, unfixed, and the same class. If it recurs on these versions, that
issue is where it goes.

covergate was red on a third path this had not read: internal/sandbox is
78.0% on linux against a 78.1% floor, while the same tree measures 78.1%
on darwin. The package compiles a different set of files per GOOS, so a
floor recorded on one platform can be unreachable on the one the gate
runs on. Rather than lower it, three statements nothing reached are now
covered — podman's per-user socket, an authority the table does not
govern, and a nil context carrying no escape approver — which takes
darwin to 78.9% and lifts linux above its floor by the same edges.

covergate now names the lines it is short of. A percentage alone left
whoever has to restore it measuring a package by hand on a platform they
may not be on, which is how this one sat red across three runs.
2026-09-06 09:47:46 -07:00
esengine cd5d82ff6b refactor(control): move the approval orchestration, and the gates that guard it
What waits on the user and which call answers it — tool approvals, plan
decisions, asks, the pending-prompt replay a reattaching frontend needs, and
the modes that decide whether any of it is reached — is one responsibility, and
it was the last cohesive cluster sharing controller.go with everything else.
approvalManager already owns the bookkeeping; this is the orchestration half,
the part that emits, records a receipt and rebuilds a gate.

Verbatim move, 24 declarations: all 194 Controller methods present, none
duplicated, no body differs by a byte. controller.go 4,649 -> 4,092, which is
5,837 -> 4,092 across the three splits.

The move is only half of it. internal/control/approval.go is a declared
sensitive path, so code leaving it for a file that is not one would drop out of
both effects that declaration has: the review gate, and the coverage floor.
REASONIX.md declares the new file too, which is why this could not be a tidy-up
— and the floor is real, recorded at 90.7% and sabotage-checked by raising it,
which fails the gate.

Two things surfaced while recording it, neither mine:

internal/providerbroker/** was declared sensitive without a floor ever being
recorded, so it has been passing the gate vacuously since it landed. It has one
now. Its coverage flickers between 83.1% and 83.6% across runs (5 and 2 of 7),
so the floor is the observed lower bound: a floor set at the higher number is a
gate that fails at random, which teaches people to rerun CI rather than read it.

internal/sandbox/** rises 73.5% -> 78.1%, which is a ratchet catching up with
coverage already written.
2026-09-02 16:48:53 -07:00
esengine dddbe21a1a refactor(net): one redirect guard, three trust decisions
The same judgement existed three times: validateCLIUpgradeRedirect,
validateOfficialRedirect, and the update package's TrustedRedirect. The
first and third are line-for-line identical apart from an error prefix and
the name of the host predicate; the second is the same rules folded into
one condition. The third had never been wired to a client at all, which is
the shape this kind of duplication fails in -- a copy nobody is looking at
stops being maintained before anyone notices it stopped being called.

internal/redirectguard holds the mechanism: HTTPS only, no credentials, no
port, a hop limit, and a host list the caller names. ErrRefused gives the
refusal an identity so a caller tells one from a transport failure through
errors.Is rather than by reading the sentence.

The host lists stay apart on purpose. Which hosts a download trusts is
that path's own security decision, and that the CLI upgrade and the remote
CLI provisioner both name only GitHub is a fact about where we publish,
not one about them. Merged, a change to either would silently move the
other. Mechanism shared, policy separate.

The layering rule is what had made this impossible: it refused a leaf
importing anything under reasonix/, including another leaf, while its
stated intent is that a utility drags no dependency graph along. A closed
layer reaching inside itself drags nothing, and Go already refuses a cycle.
So the rule now says what it meant -- a leaf may import a leaf -- which is
a sharpening rather than a loosening, and releaseasset can share the guard
instead of keeping the copy the old rule forced it to keep.

redirectguard joins the sensitive list beside netclient: it decides which
host a download may land on, and a new security boundary outside that
governance is one nobody reviews. One declaration, both effects -- the
review gate and a coverage floor, recorded at 92.6%.
2026-08-31 09:12:56 -07:00
esengine fb0ab483c8 fix(control): build the test executor through New, not as a zero value
Three tests assembled their executor as &agent.Agent{}, and New is where
sink stops being nil -- it falls back to event.Discard. So the first thing
a seeded plan observed dereferenced that nil and panicked, failing
internal/control as a package.

That is what had been holding the coverage gate shut. covergate runs the
tests of every sensitive path, internal/control/approval.go is one of
them, and a package that exits 1 takes the whole gate down with it. A gate
that is always red is not a gate, and its floors stop at whatever they
read on the day it stopped running. With the panic gone they move to what
the code actually covers: sandbox 69.4 -> 73.5, shellparse 63.6 -> 64.5,
shellsafe 59.3 -> 61.1, approval.go 93.9 -> 94.3.

The fix is on the test side because a zero-value Agent is not a state
production reaches -- boot assembles one through New -- so this was a test
stepping around an invariant the constructor holds, not a gap the
constructor should have defended against. Adding a nil check would have
put one at each of the 71 places that dereference the sink today.
2026-08-31 09:12:00 -07:00
yhh a35c8fe759 test: temp dirs retry their cleanup, because Windows may still hold the file
t.TempDir removes its directory once. On Windows a deleted file keeps its
directory entry until the last handle closes, so a test that finished while the
OS still held something it wrote fails the removal as non-empty when every file
is already gone. Measured at ~2% of session saves, always inside 10ms.

testenv.TempDir is a drop-in whose cleanup retries RemoveAll and logs the one
that never frees rather than failing the test. The desktop module cannot import
it, so its 15 files keep t.TempDir.
2026-08-28 00:30:04 -07:00
esengine 747b9fe4ee fix(covergate): a package pattern is slash-separated on every host
packagesFor took the directory of a declared file with filepath.Dir, which
on Windows answers `internal\control`. That is not a pattern go test can
match, and it sorted the list wrong on the way out, because a backslash
outranks a slash — one root cause, two symptoms in the same assertion.

The gate itself runs on ubuntu, so nothing it gates was affected; its test
runs on the whole matrix, and has failed windows-latest since the gate
landed. A declared glob and a go package pattern are both slash-separated
whatever the host is, so both reads move to path.
2026-08-26 07:52:58 -07:00
esengine 86daa61e9d feat(covergate): the declared-sensitive paths hold their coverage
REASONIX.md calls these paths the ones where a wrong change is "a hole in a
boundary". Measured, they were the least-tested part of the kernel rather
than the best: shellsafe 59.3%, shellparse 63.6%, sandbox 69.4%, netclient
47.2%, against a tree median of 76.5%. The code deciding what the agent may
execute and reach had the weakest evidence that it works.

The gate reads the same `sensitive:` bullets the runtime reads for its
review requirement, so a path is declared once and gets both effects — there
is no second list to fall out of sync. Floors are per declared path and
measured from the coverage profile per file, not per package, because
`internal/control/approval.go` is sensitive while the package around it is
not; measured on its own it is 93.9%, which no package number would show.

Floors ratchet in the opposite direction to repolint's budgets and for the
same reason: raising one is free, lowering one needs -allow-drop and a
reason in the PR. They record what is there today rather than an aspiration,
so the gate is green on arrival and only fails on a regression.

Verified by removing one shellsafe test file: the gate reports 47.3% against
its 59.3% floor. Adding an unrelated `sensitive:` line puts that path under
the gate with no other change.
2026-08-25 18:22:23 -07:00