-
Notifications
You must be signed in to change notification settings - Fork 0
Permissions Sandboxing
← Wiki home · Rendered viewer with search
Derives the narrowest permission grant a loop config actually needs, renders it for human review, and writes it into the settings file the harness reads. A second, separate concern lives alongside it: detector scripts run with no shell, so the module also ships the portability shim those scripts source.
Files
| Path | Role |
|---|---|
runtime/crates/loopsmith-cli/src/permissions.rs |
Derivation, rendering, and settings-file merge |
runtime/crates/loopsmith-cli/src/cmd/permissions.rs |
loopsmith permissions subcommand |
runtime/crates/loopsmith-cli/templates/permissions.template.json |
Documented reference shape (not a generated artifact) |
runtime/crates/loopsmith-cli/templates/compat.template.sh |
Sourced by generated detector scripts |
A hands-off run cannot pause to ask for consent, but a run that grants itself blanket access is not hands-off — it is unsupervised. The resolution is to compute the rules from the config, show them once, and grant once. A loop with no script detectors never asks to run cargo; a loop whose skills.acquisition_order never reaches the marketplace never asks for network access.
The important thing to understand before changing anything here: this grant is not the safety mechanism. The $deny_note in the template says so explicitly, and render repeats it to the user — the constraint block in the loop config is what stops on irreversible actions, and it stops regardless of what the settings file allows. Permissions exist to remove prompts, not to bound behaviour. Don't add denial logic here expecting it to enforce policy.
graph LR
CFG[LoopConfig] --> REQ[required]
REQ --> G["Vec<String><br/>sorted, deduped"]
G --> R[render] --> OUT[stdout preflight]
G --> M[merge_into] --> F[settings.local.json]
CMD[cmd::permissions::execute] --> REQ
SC[scaffold] --> REQ
WEB[web::assemble::review_config] --> REQ
Collects into a BTreeSet<String>, so the returned vector is sorted and duplicate-free by construction — the_grant_has_no_duplicates_and_is_sorted pins that property rather than the function sorting explicitly. Four sources contribute:
-
Providers — every entry in
cfg.providers.providersis a command, so each yieldsBash({p.command}:*). -
Script detectors — each
Detector::Script { command, .. }incfg.validationsyieldsBash({command}:*), so the gate can run its checks without a mid-run prompt. -
Skill acquisition — only when
cfg.skills.acquisition_ordercontainsAcquisitionSource::Marketplace, and then bothBash(npx skills:*)andWebFetch(domain:claudemarketplaces.com). -
Core tools —
Read,Write,Edit,Glob,Grep, unconditionally, because the loop reads and writes inside its own directory.
Note that the provider and detector rules collapse when they share a binary: a config whose provider command and detector command are both cargo produces one rule, not two.
The preflight block shown before the single grant. It lists the rules, then states two things the reader needs: nothing outside the list is requested, and human checkpoints still stop and wait regardless of the grant. render_mentions_that_checkpoints_still_stop guards the second sentence — it is a load-bearing claim about the system, not decoration, so keep it if you rewrite the copy.
Additive, idempotent, and deliberately forgiving of whatever is already in the file:
- Read and parse the existing file if it exists; unparseable JSON falls back to
json!({})rather than erroring. A non-object root (array, string, number) is likewise replaced. - Walk to
permissions.allow, creatingpermissionsas{}andallowas[]if absent. - Build a
BTreeSetof the existing string entries and push only rules not already present. - Pretty-print,
create_dir_allthe parent, write with a trailing newline, and return the serialized JSON so the caller can display it.
Two behaviours worth knowing before you touch this:
-
Unrelated keys survive.
merging_preserves_existing_rules_and_adds_new_onesasserts both that a pre-existingSkill(claude-api)rule is retained and that a sibling"theme": "light"is untouched. Any rewrite of this function must keep that true — it edits a file the user also owns. -
A malformed
permissionsvalue is silently skipped. Ifpermissionsexists but is not an object,as_object_mut()yieldsNone,allowbecomesNone, and no rules are added — yet the file is still rewritten andOkis returned. The caller reports success with a count that was never applied. If you need that surfaced, this is the place to change it.
Corrupt-file recovery is destructive by design (step 1 discards unparseable content), which is fine for settings.local.json but is the reason this function should not be pointed at a file whose content it did not expect.
cmd::permissions::execute(config, write) loads the config via loopsmith_core::load, computes the grant, and branches on the destination:
loopsmith permissions ./loop.yaml
→ render(&grant) to stdout, for a human to read before granting
loopsmith permissions ./loop.yaml --write .claude/settings.local.json
→ merge_into, then print "wrote N permission rule(s) to <path>" and the merged JSON
N is grant.len(), i.e. the number of rules required, not the number newly added — a second identical run reports the same count while changing nothing.
Two paths consume the derivation without going through the subcommand, and both must keep working when it changes:
-
scaffold::scaffold(loopsmith-cli/src/scaffold.rs) — bakes the grant into a freshly generated loop directory. -
web::assemble::review_config(src/web/assemble.rs) — surfaces it in the browser review step, so the guided flow shows the same list the CLI would.
Because all three share one function, a new rule source added to required propagates everywhere at once. That is the intended design; adding a rule in a caller instead is the thing to avoid.
The template's own $comment is blunt about this: "This file is the shape, not the answer." It exists so a reader can see the full space of rules, with $sections explaining each category and $still_stops listing what remains gated no matter what (constraints.human_checkpoint entries, publishing/sending/deleting/paying, promoting a quarantined sub-agent out of generated-skills/, applying anything in proposals/).
One concrete divergence to be aware of: the template lists Bash(loopsmith:*) under control_plane, but required does not emit it. Nothing keeps the two in sync automatically — if you add a rule source, update the template's prose too, and if you believe the control-plane rule should be granted, that's a change to required, not to the template.
Loopsmith runs a detector with no shell: command is argv[0] and args are literal. A detector is therefore a real file with a real shebang, not a shell string, and this file is what it sources:
. ./scripts/compat.shEverything in it detects at run time rather than being baked in when the loop was generated — a loop directory gets copied to a build box, a container, or a colleague's laptop, and a pre-computed answer would be wrong on arrival with no sign that anything had changed.
Exported environment
-
LOOPSMITH_OS—uname -s, orunknown. -
LOOPSMITH_USERLAND—gnuifsed --versionsucceeds, elsebsd. -
LOOPSMITH_BASH_MAJOR— major version of thebashonPATH, or0when there is none.
Helpers
| Function | Papers over |
|---|---|
sed_i <expr> <file>… |
GNU sed -i takes no argument, BSD requires one. Getting it wrong on BSD consumes the next argument as a backup suffix — which is how a script ends up editing a file named -e. |
stat_size / stat_mtime
|
-c%s/-c%Y on GNU, -f%z/-f%m on BSD. |
readlink_f <path> |
readlink -f is absent from BSD readlink before macOS 12; falls back to a cd/pwd -P walk. |
sha256 <file> |
sha256sum or shasum -a 256, whichever exists; exits 127 with a message if neither. |
need_bash <major> |
macOS ships bash 3.2.57 (4.0 changed licence). Associative arrays, ${x,,}, mapfile, &>>, and ** all arrived in 4.0. |
require <command>… |
A tool the detector needs is not on PATH. |
compat_report |
One line of environment for a log or bug report. |
need_bash and require exit 2, not 1, and this is the single most important contract in the file. A detector's exit code is its verdict: 1 means the check failed, 2 means this machine cannot run the check. A gate that cannot distinguish them reports missing tooling as unfinished work. Preserve the distinction in any new helper that can abort.
Target POSIX sh unless need_bash 4 says otherwise — that is what the rest of a generated loop uses. On Windows the file runs under Git Bash, WSL, or MSYS and behaves as it does on Linux; nothing here runs under cmd.exe or PowerShell. A detector meant for those is a .cmd or .ps1 naming its own interpreter, precisely because there is no shell to interpret it.
The unit tests in permissions.rs build configs from YAML through loopsmith_core::parse_str via the local cfg(extra: &str) helper, which is how the marketplace test mutates skills.acquisition_order to [Installed] and asserts the network and npx skills rules disappear. Filesystem tests use loopsmith_util::testing::temp_dir and clean up with a best-effort remove_dir_all. When adding a rule source, add both halves of the marketplace pattern: the rule appears when the config needs it, and is absent when it does not.
Generated by gitnexus wiki from bitphill/loopsmith. Do not edit by hand — regenerated on each run.
- CLI Command Surface
- Distribution & Installers
- Evolution & Perturbation
- Example Loops
- Execution Graph & Concurrency Planning
- Gating & Success Criteria
- Loop Configuration Schema
- Loop Execution Engine
- Markdown/YAML Config Interchange
- MCP Server
- Memory & Episode Store
- Other
- Permissions & Sandboxing
- Platform Utilities
- Project Documentation
- Provider Integration
- Scheduling & Triggers
- Skills System