Harness laws: tool surface #
The shared rules (what a law secures, the adversary, H1 to H6) are in harness.md.
S7. The tool surface #
The tool surface is what the model can call: the tools of the roster, the
tools behind dyn, the internal URLs and the shell.
-
The roster is the list of tools that each request sends. The roster is fixed when the session opens, before its first request. After that, the harness rejects each change of the roster with the typed error
RosterFixed: an install or uninstall of an extension that changes the tools the model sees, aconfigureoftools,modelToolsorextensions, and anadd_toolsof a tool round. Each request sends the same roster, so the prompt cache stays valid. -
A tool that comes later (the tool of an MCP server, a host tool that joins) does not go into the roster. The roster tool
dynreaches it.dynwithoutnamelists the dynamic tools.dynwithnameandargumentsruns one. The toolmcp/<server>/<tool>is the tool of an MCP server that the user configured; the kernel's MCP client runs it. A host tool joins withToolSurface::joinand leaves withToolSurface::quit. A tool that left is not reachable. -
Each tool schema that the harness sends has the intent parameter
i(optional, first) and a version of 1 or more: the annotationtitle: "v<n>"in the schema itself. Every wire API keeps it. The session builds every schema at open: it addsiwhen the schema does not have it, keeps each declared parameter in its place, and sets the declared version, or 1 when there is none. Building a built schema changes nothing.codemodegetsiandv1in this way. -
report_issue(behinddyn) is the AutoQA path. An agent reports a tool that failed or did not do what its description says, with the argumentstoolanddescription. The report is stored in the session as a display-only row of custom typeeukhe.tool_issue. The user sees the row. The model context does not get it. -
Internal URLs. The
readtool (and the kernelread()) accepts an internal URLscheme://pathin place of a file path.repo://<path>reads a file or directory under the workspace root (the working directory of the session).skill://<name>/<path>reads a file under the directory of the skillname;skill://<name>reads itsSKILL.md.agent://<name|id>reads the whole chat of a subagent that the conversation started.memory://reads the chat view,memory://<id>reads messageidwhole (with its images), andmemory://<id>/<n>reads lineid+n. Arepo://orskill://read is a normal file read: it has the[path#TAG]header, and its lines are edit anchors. An unknown scheme gets the rejectionscheme.mcp://<server>lists the resources of a server andmcp://<server>/<uri>reads one (text; a binary part is a note with its size and MIME type), bounded like any read. The server must be one of the generic MCP servers of the session.ssh://<host>/<path>reads one remote text file (item 9 has the rules).globrejects all schemes;grepdoes not find a scheme path. -
Confinement. A URL path is a list of
/-separated segments under the root of its scheme...goes up one segment;.and an empty segment do not move. A path that goes above its root gets the rejectionescapes. The path is not clamped to the root. A path that stays under its root resolves. After this lexical check, arepo://orskill://read resolves the real path (it follows symlinks) and requires it under the real path of the root. A symlink out of the root also gets the rejectionescapes. This guard is against the filesystem; the model stays the lexical rule. Anssh://path has one of two roots on its host: the remote/, or the remote home when the first segment is exactly~. eukhe cannot see the remote filesystem, so a remote symlink is not checked. A plain file path is not confined; this is the same as before. Absolute paths and..from the working directory still read any file.lexical_normalizekeeps the filesystem rule (/..is/) and is not used for URL paths. -
The shell and the approval gate. The
bashtool (bash@1, extensioneukhe.shell) is in the roster at open. It runs a script in process (crateeukhe-shell). It does not start a system shell for each script. A script is a list of steps. A step is one simple command. Steps join with;, a newline,&&,||and|. Each step can have redirections (<,>,>>,2>&1,&>). The shell expands$NAME,${NAME},$?, a leading~and the patterns*,?,[...]. The shell runs the usual commands itself:cat,ls,head,tail,wc,echo,pwd,mkdir,rm,rmdir,touch,cpandmv. It runsgrepon the ripgrep engine (grep-regex,grep-searcher). It starts all other commands, and these commands with options it does not know, as external processes. Each external process gets its own process group and this environment: the environment of the daemon worker, with the session values on top (EUKHE_CODING_AGENT_DIR, andEUKHE_CHAT_MEMORYin a chat-memory session), less each credential (kernel::manager::local_envoverdurable::rlm::session_env). A credential is a provider credential that the provider code reads (eukhe_ai::env_api_keys::credential_env_vars, in any case), or a name whose upper case containsAPI_KEY,APIKEY,TOKEN,SECRET,PASSWORD,PASSWD,PASSPHRASE,CREDENTIAL,PRIVATE_KEYorACCESS_KEY. A local kernel gets the same environment, but it keeps the credentials that the MCP config of the user reads (abearerTokenEnvVar, a stdio{"env": NAME}reference, anmcp:*key inauth.jsonthat names a variable). It never keeps a provider credential. The laws (G3) inLAWS.bendsectionchild_envstate this. The commands of a pipeline run at the same time. OS pipes connect them, and the pipe buffer of the kernel limits each pipe. When a command stops, its input pipe closes. The command that writes to that pipe then stops too: an external process gets SIGPIPE, and a command of the shell stops with status 141. Thusyes | head -n 3stops. Some scripts use other syntax: control flow, functions, subshells, command substitution, here-documents or background jobs. Such a script is oneexecstep, andbash -cruns it. The tool obeys the S2 limits. The output goes through theTruncationof the call while the steps write it: the result keeps the output within the cap, and theoutput_boundeddiagnostic counts each dropped byte. With the opt-out, the result keeps all of the output. A call blocks for the blocking budget (toolBlockingBudgetMs) at most. A script that runs longer than the budget becomes a job (JobKind::Shell) in theJobTableof the session. The call then settlesdetachedwith the output until that time and the job id. The script continues, and a gated step still asks the user. When the script ends, it settles its job and sends aJobSettled::Shellrow ([job-settled job:ID status:S]and the bounded output) through the job-settled wake, the same as a detached cell. The kill of the job stops the script and kills the process group of each running external process; the job then settleskilled. -
Each step has a set of effects: read, write, exec, net, outside, protected and secret. A table in
eukhe-shell(effect.rs) gives the effects of the command from its name, thegitsubcommand and some options. A command that is not in the table has the effect exec. Each redirection adds the effects of its target: a write to a path inside the workspace is write, outside it write and outside, to a protected path (S7 G1: eukhe's own settings and credentials, a.gitdirectory, a directory onPATH, an ancestor of a protected or secret entry) write and protected; a read of a secret (ssh keys, credential stores) is read and secret. The paths that a step names add effects too: a word that is a secret or holds one (cat ~/.ssh/id_ed25519,grep -r KEY ~) adds secret; for a command that writes, a word outside the workspace adds outside and a protected word adds protected. The working directory adds secret, when it holds a secret, only to a command that reads it because it names no path of its own:ls,du,findwith no starting point,rg(the first operand is the pattern unless-e,-f,--regexp,--fileor--filesgives it),grep -r/-R(the same),git ls-files, andgit grepwith no pathspec after--(an operand before--may be a revision). An option whose value may be the next word takes it, so the count of paths errs low.echo hi,dateorgit -C repo login the home dir has no secret effect;lsorrg xthere has. Every other command in the table reads only what it names or its input; a command that is not in the table is exec. A script outside the grammar is one step with exec and every effect of every command it holds (subshells, command substitutions, pipelines inside control flow). A read step only reads. These rules make sure of that:- A program name with
/(./cat,bin/stat,/usr/bin/rm) is exec. The shell runs that file, whatever its name. - A step with an assignment (
PATH=./bin stat f,X=1) is exec, andexportwith an operand is exec. The environment can make a reading command run another program (PATH,LD_PRELOAD,LESSOPEN,GIT_*). git: before the subcommand, only-C DIRand options that set no configuration and no program (--no-pager,-P, the pathspec options,--no-optional-locks,--no-replace-objects) are allowed. Any other option (-c,--config-env,--exec-path,--git-dir,-p) is exec. A repository's configuration can make git run a program: the filesystem monitor, hooks, a pager, clean filters, external diff and textconv drivers, signature programs, ssh commands and credential helpers. Thus only these subcommands are read:cat-file,count-objects,describe,for-each-ref,grep,log,ls-files,ls-tree,merge-base,name-rev,rev-list,rev-parse,shortlog,show,show-ref,version,diff --cached,branchandsymbolic-refthat only show.branch,symbolic-ref,update-refandinitthat change refs are write. The shell runs each of these withGIT_UNTRUSTED_REPO_ARGS(eukhe-shell, also used by eukhe's own git probes: no fsmonitor, no hooks, no signature check, no pager, no fetch of missing objects), anddiff,log,showwith--no-ext-diff --no-textconv. Each of them is exec with--textconv,--ext-diff,--filters, a signature option or format (%G,signature, apretty.<name>alias),describe --dirty/--broken,ls-files -m,grep -O,branch --edit-description. Every other subcommand is exec:status,diffof the work tree,blameandaddhash work-tree files through the clean filters, which no argument can switch off;commit,merge,rebase -x,bisect run, aliases run commands. A subcommand that talks to a remote (clone,fetch,ls-remote,pull,push,submodule) is net and exec. A read subcommand with--outputis also write.curlwith an option that writes a file (-o,-O,-D,-c,-K,--output, ...) is net and write;wgetis net and write.sed: each script must be a known-safe form, else the step is exec. A safe script has only the commandsp P d D q Q = n N g G h H x { },swith the flagsg i I m M pand a number, andy. Each command can have the addressesN,$or/regex/, and!. The allowed options are-n -E -r -s -u -z -e -iand their long forms. Any other option (-f) is exec. With-iand safe scripts, the step is write.rgwith--pre,--pre-globor--hostname-binis exec.sortwith--compress-programis exec; with-oor--outputit is write.uniqandxxdwith two operands are write (the second operand is the output file).findwith-exec,-execdir,-okor-okdiris exec; with-delete,-fls,-fprint,-fprint0or-fprintfit is write.lesswith a+command that has!or|is exec; with-o,-O,--log-fileor--LOG-FILEit is write.filewith-Cor--compileis write.treeis not in the table (-owrites a file), so it is exec.
A long option can be an abbreviation (GNU
getopt_long). The rules accept each prefix of the option name. An option letter in a group of short options (-uo) counts.A step runs immediately only when each of its effects is in the auto set. The auto set is the Local cvar
shell.autoEffects(S4): a list of effect names from read, write, exec and net, default all four (["read", "write", "exec", "net"], lawdefault_auto_is_grantable). Outside, protected and secret are never in it: a step with one of them always asks (lawnever_auto_is_routed). A user who narrows the auto set is asked about the steps it no longer holds. A name that is not one of the four is a typed error when the value is set. The cvar is an execution gate, so an untrusted project cannot set it. For each other step, the host asks the user before the step runs. The question shows the step exactly as it runs: each control or bidi character in a word is escaped ($'…'), and the prompt keeps each line whole; Enter alone answers no. The worker sends ashell_approvalframe (effects: the names) to the attached clients. The firstanswer_shell_approval(approve or deny) is the answer. The phone gateway (eukhe gateway) shows no question. A session whose only attached clients are the gateway asks no one: it runs the step at once, but it refuses at once a step with the effect protected or secret (the gateway rule, G5). If a TUI or the dashboard is attached too, that client gets the question. If no client is attached, or if all clients detach without an answer, a subagent's session hands the question to its parent, up to the root (G5); a root has no one to ask, and there is no answer. No answer is a denial. The shell asks about the steps of a pipeline in order before any of them runs. A denial stops the script at that step. The steps before it ran, and their changes stay. The denied step, the other steps of its pipeline and all later steps do not run. The result gives the output and the exit status of each step that ran. It also tells how the script ended: finished, exited, or refused at step N (denied,unansweredorgateway_refused). The text of a refused step, of a cell that did not run and of a document access that did not run comes from one renderer (durable/shell.rsRefusalText): what failed, then the model's next step, as<what failed>. <next step>.. Denied: do not try it again unless the user asks for it. Unanswered: ask the user to attach a client, or to allow its effect inshell.autoEffectsif that is write, exec or net; or use read-only steps. Gateway refused: ask the user to run it from the eukhe TUI or the dashboard. Shell state (cd,export) lasts for one call only.The
ipythontool is gated too. A cell is arbitrary Python: it can start any process (subprocess,os.system, the kernelbash()). Thus each cell is one step with the effectexec. A cell runs immediately only whenexecis inshell.autoEffects, as it is by default. Else the host asks the user with the sameshell_approvalframe: request id<call>:cell, step 0, the code as the command (control and bidi characters escaped), effects["exec"]. The host asks before it starts the kernel, starts a job or starts the blocking budget. If the user denies, or no client is attached, or no answer comes, the cell does not run and the model gets the refusal's text (ipython did not run (exec):and the text above). Thebash()calls and the host requests of a cell run inside the cell that the gate let run; they do not ask again. - A program name with
-
Remote reads (
ssh://). Anssh://read is new outbound traffic, so it is off by default:- The host must be in the setting
ssh.hosts(a list of strings; default empty). The setting is Global: eukhe reads it from the globalsettings.jsononly, and a project cannot set it. The host must agree with an entry as written (box,me@box). When the list is empty, eachssh://read gets the rejectionnot_allowed, and the message namesssh.hosts. - The host is letters, digits,
.,-,_and@. It must not start with-. Other hosts get the rejectionarguments. Put ports, users and options in~/.ssh/config. ssh://box/etc/hostsreads/etc/hosts.ssh://box/~/notes/a.mdreadsnotes/a.mdunder the remote home.ssh://box/~/../xandssh://box/../xget the rejectionescapes(item 6).- eukhe runs the
sshprogram of the user with the user's~/.ssh/config:ssh -T -o BatchMode=yes -o ConnectTimeout=<s> -o ClearAllForwardings=yes -- <host> "cat -- '<path>'". eukhe handles no key and no password. WithBatchMode, a host that asks for a password or a passphrase fails at once. The path goes to the remote shell as one single-quoted word ('becomes'\''). Thus no character of the path is shell syntax on the remote host. The remote login shell must be a POSIX shell. - The output cap of the call (S2) keeps the first bytes of the file.
eukhe counts the remaining bytes, and the first line of the reply
gives the number that the cap dropped. The blocking budget
(
toolBlockingBudgetMs) ends the read:sshruns in its own process group, and eukhe kills the group at the deadline. A read that did not fill the cap by the deadline gets the rejectiontimeout. A read that filled the cap gives its bytes, and the first line tells that the cap dropped that number of bytes or more. - The read must run in a tool call. A kernel
read()outside a tool call has no output cap, so it gets the rejectionarguments. - The rejections are typed:
host(an unknown host, a host that does not connect, a host key that is not known or that changed),auth(the host refused each key),not_found,is_directory,permission_denied,binary(not UTF-8 text, or a NUL byte),timeout, andio(nosshonPATH, or another failure). - The read is of one whole file, as text. It has no
[path#TAG]header and no selector, and its lines are not edit anchors.
- The host must be in the setting
Model: bend/tools.bend. Laws: LAWS.bend section (S7). Proofs:
PROOF.bend section tools. Every input is free: any tools that join and
quit, any schemas, any paths, any scripts, any auto set, any answers of
the user.
| Law | Says |
|---|---|
roster_fixed |
No event changes the roster: tools that join go behind dyn (S7.1, S7.2). |
roster_sent_fixed |
Every request of a session sends the roster it started with (S7.1). |
dyn_reaches_joined |
A tool that joins is reachable through dyn (S7.2). |
dyn_drops_quit |
A tool that quits is not reachable through dyn (S7.2). |
schema_has_intent |
Every schema the harness sends has the intent parameter (S7.3). |
schema_versioned |
Every schema the harness sends has a version of at least 1 (S7.3). |
schema_keeps_params |
The harness keeps every parameter a tool declares (S7.3). |
schema_build_idempotent |
Building a built schema changes nothing (S7.3). |
path_rejects_escape |
A path that goes above its root is rejected, never clamped (S7.6). |
path_resolves_inside |
A path that stays under its root resolves (S7.6). |
gate_prefix |
The steps that ran are the first steps of the script, in order (S7.8). |
gate_needs_yes |
No more gated steps run than the user approved (S7.8). |
gate_deny_stops |
A denial stops the script at the denied step: the steps before it ran, it and every later step did not (S7.8). |
gate_finish_runs_all |
A script that finished ran every step (S7.8). |
Code: eukhe-durable/src/harness/registry.rs (Registry::fix_roster,
RosterFixed, RosterChange, the checks in install and uninstall),
harness/harness/conversation.rs Conversation::configure,
harness/generation/round.rs (add_tools);
eukhe-core/src/durable/registry.rs create_eukhe_registry (builds
every schema, then fixes the roster); durable/tool_contract.rs
(parameters, build); durable/tool_surface/mod.rs (ToolSurface,
the dyn tool, report_issue); kernel/manager/mod.rs
mcp_tool_call and the mcp_call request of the kernel's MCP lane
(eukhe-runtime/src/rlm/repl.py); eukhe-docs/src/url.rs (walk, the
one place that walks a URL path, under Url::names and Url::remote);
eukhe-core/src/durable/documents/ssh.rs (SshRead: the ssh.hosts
check, the ssh arguments, the bounded read and the typed errors);
eukhe-core/src/durable/shell.rs (the bash tool and its approval) and
the crate eukhe-shell (the in-process shell, effect.rs, the gate).
Tie: the bridge test
durable::tool_surface::bridge::the_rust_tool_surface_equals_the_bend_model
drives a real registry with its roster fixed, a ToolSurface and
tool_contract::build against bend/bridge_tools.bend (the model run,
step, dyn_call and build): sessions with a roster, tools that join
(the registry is also asked to install them) and quit (the registry is
also asked to drop the roster), requests resolved as the Harness resolves
them, and schemas built twice. bend/bridge_paths.bend compares
Url::names with the model resolve and stays; Url::remote walks
with the same walk. bend/bridge_shell.bend compares the gate of
eukhe-shell (run::tests::the_gate_runs_as_its_model) with the model
join and exec (see G2).
Regression tests:
harness::tests::registry::a_fixed_roster_rejects_every_change_of_the_tools_the_model_sees,
durable::tool_surface::tests (an open session over two turns: every
request sends the same roster while a late tool is listed and run through
dyn, report_issue stores its row, and install and configure are
rejected), the daemon test dyn_tool_e2e (a configured stdio MCP
server's tool is listed and called through dyn; the transcript holds one
tool loadout), url::tests::an_ssh_url_* (the host and path rules), and
durable::documents::ssh::tests (a fake ssh that records its arguments
and plays hosts: a path with shell syntax reads the file of that name and
runs nothing, each failure is its typed error, the output cap counts the
dropped bytes, and the budget kills ssh). Not modelled: the wire form of
the version annotation, the MCP listing and call through the kernel, the
row of a report, and the remote side of an ssh:// read. Their tests tie
them.
G1. Path policy #
One path policy judges each path that an agent tool reads or writes.
The bash tool (S7 item 8) and the document tools (read, write,
edit, grep, glob, and the kernel fs.* requests) use the same
policy (eukhe_types::path_policy::PathPolicy). Thus the two cannot
judge a path differently.
-
The real path. The policy judges the real path of a path, not the path as written.
resolvefollows each symlink on the part of the path that exists. It also follows a dangling symlink: a write through it creates the target..does not move...goes up from the real directory, not from the written name...of/is/. The part that does not exist stays as written. Thus a symlink gets the class of its target. After 40 symlinks (MAX_LINKS, as Linux) the path loops: the resolution does not follow more symlinks, and the policy does not trust the rest of the path. -
The classes. Each real path has one class. When more than one class applies, the first class in this list is the class:
- Secret: the path is a secret entry, or it is under one.
- Protected: a protected rule covers the path, or the path is an ancestor of a protected or secret entry. (A move or a removal of the ancestor moves or removes the entry.)
- Inside: the path is under a workspace root.
- Outside: all other paths.
A path whose symlinks loop is never Inside. It is Outside, or Protected or Secret when one of these applies.
-
The protected set. The settings loaders give the protected set; there is no separate list (
durable::path_policy::policy):- the agent dir and each entry directly in it (
settings.json,config.cfg,subagent.cfg,models.json,auth.json,binds.cfg): aDirrule; - the profile dir (
agents/<agent>.cfg): aTreerule; - the project config dir of the session (
<cwd>/.eukhe): aTreerule, and each.eukhedirectory below the workspace root: aNamedrule; - each settings file that the session loads and reads again
(
EukheSettings::watched): aTreerule; - each
.gitdirectory below the workspace root: aNamedrule (its config and its hooks name programs that git runs); - each directory on
PATH: aDirrule (a file in it replaces a command).
A
Namedrule looks at the part of the path below its workspace root (all of the path when no root holds it). The policy makes each entry real when it starts. - the agent dir and each entry directly in it (
-
The secret set:
auth.jsonin the agent dir, the Prime CLI config, the credential stores of other programs in the home dir (.ssh,.aws,.azure,.config/gcloud,.config/gh,.docker/config.json,.kube,.gnupg,.password-store,.local/share/keyrings,.netrc,.git-credentials,.npmrc,.pypirc,.cargo/credentials,.cargo/credentials.toml), and the files thatAWS_SHARED_CREDENTIALS_FILE,AWS_CONFIG_FILEandCLOUDSDK_CONFIGname. -
The effects of an access. A read or a list of a path is read; of a Secret path, read and secret. A search (a
grepof a directory) of a path that is a secret or that holds a secret is read and secret. A write is write when Inside, write and outside when Outside, write and protected when Protected, and write and secret when Secret. -
The gate of the document tools (
DocumentsGate). A document tool resolves each path one time. It gives that resolution (RealPath: the real path, and whether its symlinks looped) to the gate before the access. The access then occurs on that real path. The gate judges the resolution as it is. It does not resolve the real path again: a second resolution can lose a loop. An access runs without a question only when the auto set (shell.autoEffects, S7 item 8) holds each of its effects. Outside, protected and secret are never in the auto set. Thus:- a write runs without a question only Inside, and only when the auto set holds write;
- a read of a secret always asks;
- each other access asks when the auto set does not hold all of its effects.
The question goes through the approval sink of a
bashstep: theshell_approvalframe, with the verb and the real path as the command (write /path), and the effects. When the user approves, the access runs. When the user denies, or no answer comes, or the gateway rule refuses it (G5), the access does not run, and the model gets the refusal's text (S7 item 8), opened by the access's effects ((write+outside) it needs the user's approval, …). When no client can answer (the session has no approval sink), the gate does not ask: each access that must ask does not run, and the model gets the unanswered refusal's text, opened the same way. -
A write occurs on the real path. The writer locks the real path, then resolves the path again. When the real path changed (a symlink was made or moved), the write stops, and nothing is written. A replaced file keeps its mode. A created file gets the default mode: it has no execute bit.
Model: bend/path_policy.bend (alias Pp: real with max_links
links of fuel, class, holds, effs, runs, asks, the store
run and guarded). Laws: LAWS.bend section path_policy.
Proofs: PROOF.bend section path_policy. Every input is free: any
symlinks (loops too), any policy, any paths, any auto set, any answers.
| Law | Says |
|---|---|
path_real_has_no_link |
Unless its symlinks looped, no prefix of the real path of a path is a symlink (G1.1). |
path_secret_wins |
A path whose real path is under a secret entry is Secret (G1.2). |
path_inside_is_safe |
A path is Inside only when its symlinks did not loop, no secret covers it, no rule protects it, it is no ancestor of an entry, and a root holds it (G1.2). |
path_ancestor_guarded |
An ancestor of a protected or secret entry is Protected or Secret (G1.2). |
gate_write_auto_only_inside |
A write runs without a yes only Inside (G1.6). |
gate_secret_read_needs_yes |
A read of a secret runs only on a yes (G1.6). |
gate_yes_runs |
The yes of the user runs each access (G1.6). |
gate_inside_write_runs |
With write in the auto set, a write Inside runs without a question (G1.6). |
config_needs_yes |
No sequence of agent writes without a yes changes a protected or secret location (G1.6, G1.7). |
Code: eukhe-types/src/path_policy.rs (resolve, RealPath,
PathPolicy::class_of_resolved, holds_secret_of_resolved, PathRule,
MAX_LINKS), eukhe-core/src/durable/path_policy.rs (policy,
session_policy, effects, DocumentsGate), eukhe-docs/src/gate.rs
(AccessGate, Access), eukhe-docs/src/write.rs (write_file,
LockedWriter), eukhe-shell/src/run.rs (the effects of the paths of a
step: PathPolicy::class and holds_secret). Tie:
durable::path_policy::bridge::path_policy_and_gate_match_the_model
with bend/bridge_path_policy.bend. It runs 200 scenarios from a fixed
seed. Each scenario makes a tree in a temp dir (directories, files, and
symlinks with targets from / and from their directory, dangling targets
and loops too) and a policy (roots, Tree, Dir and Named rules,
secret entries). For each query path it compares the loop bit, the real
path, the class, holds_secret, and, for a read, a list, a search and a
write with a random auto set and answer, whether the real DocumentsGate
let the access run and whether it asked. Then it writes through
Documents::write with answers that are never yes, and compares whether
the gate asked, the path that the question names, and the content of each
protected or secret file after the writes (Pp.guarded of Pp.run: law
config_needs_yes). Regression tests:
durable::path_policy::tests::every_loader_file_is_guarded_and_a_project_file_is_inside,
durable::path_policy::tests::a_write_whose_links_looped_asks_even_when_its_real_path_is_inside,
and path_policy::tests in eukhe-types. Not modelled: a change of the
filesystem between the gate and the lock (the writer checks it), and the
file modes.
G2. Shell classification #
A step's effects are a set: every effect of every part of it (S7 item
8). The gate runs a step without a question only when the whole set is
within the auto set; outside, protected and secret are never within it.
By default the auto set holds every grantable effect (read, write, exec,
net; law default_auto_is_grantable, G5), so only a step with outside,
protected or secret asks.
A step has secret only when one of its parts names a path that is or
holds a secret (an operand, an option value, a redirection target, the
working directory of a command that reads it because it names no path).
Model: bend/tools.bend part 4 (Step{id, effs}, grantable,
within, join, Part{own, secret}, part.effs, parts.effs,
parts.secret, exec). Laws: LAWS.bend section
shell_classification. Proofs: PROOF.bend section shell.
| Law | Says |
|---|---|
step_holds_every_part |
A step made of parts has every effect of every part. |
secret_needs_a_secret_path |
A step has secret only when one of its parts names a path that is or holds a secret: a step whose parts name none has no secret. |
secret_path_gives_secret |
A part that names a path that is or holds a secret gives its step secret. |
auto_needs_every_effect |
A step with one effect that is not in the auto set, or that no auto set may hold, does not run without a question. |
auto_never_sensitive |
A step with outside, protected or secret never runs without a question, whatever the auto set holds. |
composed_needs_every_part |
A step made of parts runs without a question only when every effect of every part is within the auto set. |
auto_runs_within |
A step whose effects are within the auto set runs and uses no answer. |
held_needs_a_yes |
A step whose effects are not within the auto set does not run without a yes; the script stops there. |
Code: eukhe-types/src/shell.rs (ShellEffect, ShellEffects,
AutoSet), eukhe-shell/src/effect.rs (classify,
GIT_UNTRUSTED_REPO_ARGS, reads_cwd), run.rs (Shell::effects,
the parts of a script outside the grammar from parse::parts),
shown.rs (quote, visible), eukhe-tui/src/confirm.rs
(ConfirmPanel::approval). Tie: run::tests::the_gate_runs_as_its_model
builds steps from parts whose effects and secret paths the path classes
of a temp workspace fix (reads of a secret, writes outside and to a
protected tree, redirections, scripts outside the grammar), in the
workspace or in the dir above it, which holds a secret (echo and
date there have no secret part; ls and grep -r have one for the
working directory), runs them with random auto sets (outside, protected
and secret in them too) and answers, and compares the steps that ran
with their effects and the end with bend/bridge_shell.bend. Every
command and redirection kind runs in each directory. Regression tests:
every_effect_of_a_step_must_be_allowed,
only_a_step_that_reads_its_working_directory_reads_a_secret_in_it,
effect::tests::a_command_reads_the_working_directory_only_when_it_names_no_path,
effect::tests::a_git_step_that_can_run_a_repository_program_is_exec,
a_git_step_that_is_not_exec_runs_with_the_untrusted_repository_arguments,
shown::tests::a_hidden_character_is_escaped_and_bash_reads_the_word_back,
confirm::tests::an_approval_answers_no_on_enter_and_keeps_every_line.
Not modelled: which paths a command reads or writes (every word counts;
the working directory only by reads_cwd), and what an exec step does
once approved.
G4. Every runner of code goes through the gate #
A tool that runs code declares a Runner in its registration (extra):
its caps (each effect that its code can have) and how the gate covers it
(EachStep: bash; EachCall: ipython). A tool that declares no
Runner runs no code of its own. A Runner is always gated: Rust cannot
write an ungated one.
Model: bend/runners.bend (registry, the tools that a default session
installs, in install order; cell, the gate of one call). Laws:
LAWS.bend section runners. Proofs: PROOF.bend section runners.
| Law | Says |
|---|---|
gate_covers_every_runner |
Each tool of the registry whose caps hold exec is gated. |
ipython_gated_each_call |
ipython has the caps exec and the gate covers each call. |
cell_runs_only_within_or_approved |
A gated call runs only when its caps are within the auto set or the user approves it. |
cell_within_runs_unasked |
A call whose caps are within the auto set runs, and does not ask. |
cell_approved_runs |
A call outside the auto set that the user approves runs. |
cell_no_yes_stops |
A call outside the auto set without approval (a denial, no answer, no client) does not run. |
cell_exec_asks |
Code that can run a program is never within an auto set without exec. |
Code: eukhe-core/src/durable/runner.rs (Runner, Gate),
durable/rlm/tool.rs (the ipython gate), durable/shell.rs (the
bash runner). Tie: durable::runner::tests with
bend/bridge_runners.bend. the_registry_is_its_model dumps every tool
of an opened session (name, caps, gate) and compares it with registry.
the_ipython_gate_runs_as_its_model runs one real ipython call for
each auto set and asker (no client, no answer, deny, approve) and
compares whether it asked and how it ended with cell. Regression test:
a_cell_runs_only_within_the_auto_set_or_approved. Not modelled: the
dynamic tools behind dyn (MCP tools, remote/<name> functions of the
kernel).
G5. A child's question reaches its root's user; the phone never waits #
A subagent session (an RLM child) runs in its own worker and has no
client of its own, so its gated steps (S7 item 8: bash steps, ipython
cells) must ask the user of its root session. A session whose question no
client answers, because none is attached or all detach without an
answer, hands it to its parent session: the daemon command
relay_shell_approval (the parent's persisted session id, the request,
and agent, the name of the subagent that asks), which the supervisor
routes to the parent's worker like a turn-long wait. The parent asks its
own clients with a shell_approval frame that carries agent, or hands
the question on to its own parent. The first session up the chain whose
clients answer gives the answer ({"answer": "approve" | "deny" | "unanswered" | "gateway_refused"}); when the root has no client that
answers, the answer is unanswered (a denial, with the usual message to
the model). The TUI's prompt names the subagent (Subagent <name> asks: allow shell step N (<effects>)?), with the characters of the name that a
terminal hides or moves escaped. A frame without agent is the session's
own question.
A client tells its kind when it attaches (clientKind: interactive,
the default, or gateway). The phone gateway attaches as gateway: it
shows no question, so a session must never wait for its answer. A session
whose attached clients are all gateways asks no one and answers at once
by the gateway rule: a step with the effect protected (a write of eukhe's
own config or credentials, a .git or .eukhe dir, a PATH dir) or
secret is refused (gateway_refused), and every other step runs. The
bash result, the ipython result and the document tools' error tell the
model that the session runs only from the phone gateway and that the user
must run the step from the TUI or the dashboard. A subagent's question
that reaches such a session gets the same answer. When a TUI or the
dashboard is attached beside the gateway, that client gets the question;
if all of them detach without an answer while the gateway stays, the
gateway rule answers.
The auto set comes first: a step within it runs, and no one is asked
(gate). The auto set of a session whose user sets none holds every
grantable effect: read, write, exec and net. A step with outside,
protected or secret is never within any auto set, so its question goes
the route above: a TUI or the dashboard is asked, and from the phone
alone a write outside the workspace runs by the gateway rule while a
protected write or a secret is refused.
Model: bend/approvals.bend (route, a chain of sessions from the one
that asks up to its root: no client, clients that leave, clients that
answer, the gateway alone, clients that leave while the gateway stays;
fate, what becomes of a step with given effects; default_auto;
gate, the auto set before the route). Laws:
LAWS.bend section approvals. Proofs: PROOF.bend section approvals.
| Law | Says |
|---|---|
child_answered_by_root |
When no session below the root has clients that answer, a subagent's question reaches the root's clients, and their answer is the verdict. |
nearest_answer_wins |
The first session up the chain whose clients answer gives the verdict, whatever the sessions above it. |
unanswered_only_when_quiet |
A question is unanswered (a denial) only when no session of the whole chain has clients that answer. |
quiet_is_unanswered |
Then it is unanswered: no session invents an answer. |
gateway_never_waits |
A step of a session whose only clients are the phone gateway never waits: no client gets a frame, the question does not go up the chain, and the session's gateway rule answers at once. |
gateway_refuses_protected |
The gateway rule refuses a step that writes a protected path. |
gateway_refuses_secret |
The gateway rule refuses a step that reads or writes a secret. |
gateway_runs_the_rest |
The gateway rule runs every other step unasked. |
left_to_the_gateway |
A question the interactive clients leave unanswered while the gateway stays falls to the gateway rule, not up the chain. |
gateway_rules_a_subagent |
A subagent's question that reaches a session whose only clients are the gateway fares as that session's own step. |
default_auto_is_grantable |
The auto set of a session whose user sets none holds exactly the grantable effects: read, write, exec and net. |
never_auto_is_routed |
A step with outside, protected or secret never runs unasked through the auto set, in any session: its question goes its route. |
never_auto_asks_the_client |
In a session whose client answers (the TUI, the dashboard), such a step asks that client, and its answer decides. |
outside_runs_only_by_the_gateway |
From the phone alone, a write outside the workspace that is not protected and touches no secret runs, and only by the gateway rule. |
guarded_refused_from_the_phone |
From the phone alone, a protected write or a secret is refused at once, whatever the auto set. |
Code: eukhe-daemon/src/shell_approval.rs (ShellApprovals::ask,
relay), worker.rs (the relay over the supervisor link),
supervisor/routing.rs (the relay's budget), eukhe-tui/src/session_ui/ settings.rs (the prompt), worker/connection.rs (the client kind at
attach), eukhe-gateway/src/link.rs (the gateway attaches as
gateway), eukhe-core/src/settings/cvar/table.rs (SHELL_AUTO_EFFECTS,
the default auto set). Ties: shell_approval::bridge:: the_approval_route_is_its_model with bend/bridge_approvals.bend runs
every chain of up to 3 sessions with a write, a run of a program, a
protected write and a secret read, and generated longer ones, on real
ShellApprovals, each relaying through its parent's real handler, and
compares which sessions showed the question, whether each frame names
the subagent, and how it ended. durable::shell::tests:: the_default_auto_set_is_its_model reads the real auto set of a session
whose user sets none and compares, with the same model, the effects it
holds and whether the gate runs each of the 127 steps of one to seven
effects unasked. Regression tests:
with_no_auto_set_only_the_write_outside_the_workspace_asks (no client:
a write and a run of a program run, a write outside the workspace stops
the script unanswered),
rlm_child_approval_e2e::a_subagent_step_runs_when_the_root_client_approves
(a real supervisor, a root with an attached client, a subagent whose
bash step writes under an auto set of read) and
shell_approval_e2e::a_phone_only_session_runs_steps_unasked_and_refuses_a_protected_write
(a real supervisor and a client attached as gateway, under an auto set
of read: a write and a run of a program run unasked by the gateway
rule, a write of .eukhe/settings.json is refused at once with the
gateway message). Not modelled: the supervisor's routing of
the command (a parent whose worker is not resident is not asked; the
question is then unanswered), a client attaching while a question waits
above it. The dashboard shows a subagent's question without its name.
S8. The documents tools #
The documents tools are read, edit, grep and glob, and the kernel
functions read(), write(), edit(), grep() and glob() (crate
eukhe-docs). They share one edit store per session.
- An
editof many files is all or nothing for conflicts. The writer locks every path of the call, in one order. Then it checks each file: its bytes must be the bytes the edit was applied to, and anMVdestination must not exist. If one check fails, no file is written. Then the writer writes the files in order, and stops at the first failure. The reply tells which files landed (with their new tags) and which did not: "Applied to a; NOT applied to b, c". It never says that nothing was written when a file landed. A rename that landed but whose directory sync failed, and a move whose source stays, are landed writes with a warning. - A retry of a start or end insert with the old tag, after the insert landed, does not insert it again: the result says that the edit is already in the file.
write()replaces an existing file only when its bytes on disk are a version that this session saw: a read, a grep, or its own write or edit. Else it writes nothing and tells the model to read the file first. A new file is created. A replaced file keeps its BOM, line endings and mode; a warning tells when the content had other line endings, or when the old file was not UTF-8.MVnever replaces a file. The destination gets the bytes through a hard link, which fails on a name that exists.- A temporary file is
.eukhe-tmp-<hash of the name>-<pid>-<n>, so a long name still fits. Its writer locks it. Each write of a target removes the temporary files of that target that no writer locks (a crash left them).grepandglobdo not show these names. - A line ends at
\n. A\rbefore\nis part of the CRLF ending of that line; a lone\ris text.read,grepandedituse this one line model. An edit keeps every byte outside its lines. A new or changed line gets the ending of the line before it (at the start of the file: the first ending of the file, else LF). - The tag of a grep result is the hash of the bytes that grep searched, not of a second read of the file.
- A line is seen only when its row reached the model, after the output bound cut the middle of a long result. An edit anchor on a line that is not seen is rejected; the rejection shows the line and marks it seen, so the same edit applies on a retry. After an edit lands, the lines that were seen before and the lines that the edit wrote stay seen.
grepandglobtell each file that they skip or cut, with the reason and the count: binary, unreadable, searched only in part, excluded directories, symlinks, walk errors, the match caps. A continuation (skip=N, a line range) gives the next results.- One size limit (
MAX_SNAPSHOT_FILE_BYTES, 4 MiB) applies everywhere. A larger file has no tag; the message tells the model to read it in ranges and to change it withwrite()orbash. A file that is not UTF-8 gets a message with its path and its encoding.
Models: bend/doc_commit.bend, bend/doc_seen.bend,
bend/doc_lines.bend, bend/doc_tags.bend, bend/doc_search.bend. Laws:
LAWS.bend section documents. Proofs: PROOF.bend section documents,
PROOF.bend section doc_lines.
| Law | Says |
|---|---|
edit_reports_what_landed |
The reply calls a file applied exactly when its new content is on disk (S8.1). |
edit_all_or_nothing_on_conflict |
When one file changed since the edit staged it, no file lands (S8.1). |
edit_lands_every_file |
With no changed file and no failed write, every file lands (S8.1). |
write_never_loses_unseen |
A write over a version the session did not see is refused and changes nothing (S8.3). |
write_writes_seen |
A write over no file or a seen version writes it (S8.3). |
move_never_overwrites |
A move to a name that exists is refused and changes nothing (S8.4). |
move_moves |
A move to a free name moves the content (S8.4). |
temp_sweep_never_held |
A sweep never removes a temporary file a live writer locks (S8.5). |
temp_sweep_only_own |
A sweep removes only temporary files of its target (S8.5). |
temp_sweep_removes_free |
A sweep removes every temporary file of its target that no writer locks (S8.5). |
load_persist_roundtrip |
A file loaded and written with no edit is the same bytes (S8.6). |
edit_keeps_untouched_bytes |
An edit keeps every byte outside its lines (S8.6). |
grep_line_is_read_line |
grep and read show the same line text at the same number (S8.6). |
tag_names_shown_text |
The tag of a result names the text that its rows come from (S8.7). |
edit_only_seen_lines |
An edit applies only when every anchor is a seen line (S8.8). |
edit_seen_applies |
An edit whose anchors are all seen applies (S8.8). |
reveal_then_retry |
A rejected edit applies on a straight retry (S8.8). |
grep_accounts_every_file |
Each walked entry is in exactly one class, and the classes add up to the walk (S8.9). |
grep_notes_every_gap |
A class of skipped, cut or not-searched entries has its notice exactly when it is not empty (S8.9). |
grep_notes_every_cut |
A shown file whose matches were cut has its own notice (S8.9). |
continuation_progresses |
A continuation gives a page that is not empty (S8.9). |
continuation_contiguous |
A page and the page of its continuation are the next part of the list: nothing left out, nothing twice (S8.9). |
continuation_disjoint |
A page and the page of its continuation have no item in common (S8.9). |
Code: eukhe-docs/src/write.rs (LockedWriter::write_all, publish,
write_file, the sweep), hashline/mod.rs (apply_hashline, commit),
hashline/patcher.rs and hashline/apply.rs (the line origins, persist,
the seen guard), text.rs, read/mod.rs, grep/, glob.rs, store.rs
(saw_bytes); eukhe-core/src/durable/documents/tool.rs (bounded) and
requests.rs (Cut, Done::mark_seen).
Tie: the bridge tests write::bridge (an edit of many files with injected
faults and conflicts, the write tool with writes by another program, the
move, the sweep with live locks), hashline::bridge (random bytes with
\r, \n, \r\n and a BOM through a real read and edit), grep::bridge
(the rows of read and grep, and a grep whose file changes between the
search and the record), grep::bridge_accounts and glob::bridge
(generated trees, every continuation followed once), and
durable::documents::bridge (a long read cut by the output bound, then
edits at anchors in and around the cut). Not modelled: the bytes of the
notice texts, and the line model of the rebase of a stale edit (it maps
lines through a line diff).