samagotchi 0.128.0 → 0.129.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -1
- data/docs/architecture.md +12 -5
- data/docs/cli.md +2 -1
- data/docs/guardrails.md +150 -25
- data/docs/internals/participants.md +17 -3
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
- data/lib/samagotchi/guardrails/outside.rb +54 -5
- data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
- data/lib/samagotchi/guardrails/pair_command.rb +261 -66
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +22 -8
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
- data/lib/samagotchi/guardrails/targets.rb +128 -24
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +108 -16
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +9 -7
- data/lib/samagotchi/web/public/app.js +8 -1
- data/lib/samagotchi/web/public/participant.js +2 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
- data/lib/samagotchi/worker.rb +82 -32
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 91648ee9b8cf9906ded9d6f865f424b10148222e8007516d8d74718a5a4a9c41
|
|
4
|
+
data.tar.gz: 0ca9cd428118fd52e4c2dc3f1b5e1750d6d7f4bf41ca0d039c8769660a2db2e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1fa243881749c0b856bcb0c40f42937a253f33b0a0904d6145950cbf81c52262bfd3bdcf883389041f06383d7796150d7ee74a74bb9b8a8118d90ac3b4807a1c
|
|
7
|
+
data.tar.gz: dc1b3dafdca94da6af0338e1a76b3df6e914847514f6eeb9e61359de941cc789955e5a4b04f6e04417402fcdc221d460940051dcde3101737694cb080b038e1a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,86 @@ and commands may change between minor versions. How releases are made:
|
|
|
7
7
|
[docs/releasing.md](docs/releasing.md).
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.129.0] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- A delegate child's read-only command that writes only into a tmp dir (`sqlite3 -readonly -safe ../main/x.db "…" >
|
|
15
|
+
/tmp/rows`, `cp ../main/x.db /tmp/`, `… | tee /tmp/f`) no longer asks as a change outside its folder
|
|
16
|
+
(`child-boundary`); a tmp path that links back into the repository still asks, as does a target spelt with `$` or
|
|
17
|
+
`~`, or one after a `cd`/`pushd` the shell may not have taken (anything but one existing dir joined by `&&`).
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- The owner page's Verify fresh button no longer stays disabled ("restart this session to use Verify") after the
|
|
22
|
+
session's worker comes back with the `verify` feature: the cards follow the session's update without a reload.
|
|
23
|
+
- The web's "Couldn't start a fresh check." under a verdict card no longer vanishes at the next redraw (the participant
|
|
24
|
+
page re-creates its cards on every update): the page keeps it until the next attempt.
|
|
25
|
+
- A `write` or `edit` of a dangling symlink (or a path through one) or of a file with another hard link no longer
|
|
26
|
+
counts as in a tmp dir or in the session's repo: a write there lands outside, so the delegate child's
|
|
27
|
+
`child-boundary` and `path: outside_repo` rules ask for it.
|
|
28
|
+
- A `task_create` call's `env:` now counts in the guardrails' path checks: `env: {TMPDIR: <main checkout>}` with
|
|
29
|
+
`echo x > $TMPDIR/a.txt` (or a `HOME`, `GIT_DIR`, `GIT_WORK_TREE` there) asks as the delegate child's
|
|
30
|
+
`child-boundary`, and `rm -rf`, chi-dir and `git: outside_repo` checks read it too. An `env:` setting `BASH_ENV`,
|
|
31
|
+
`ENV`, `CDPATH`, `IFS`, an exported `BASH_FUNC_…`, `LD_PRELOAD` or a git config/diff-program variable makes the
|
|
32
|
+
command not read-only.
|
|
33
|
+
- An exit and a restart asked of one worker at once (an attached terminal's `/exit` with "stop them and exit" while
|
|
34
|
+
chi web's "Restart workers" runs) no longer both hear yes: the first one taken decides, and the other is refused
|
|
35
|
+
("the worker is already leaving as a plain exit"), so a restart no longer reports "restarted" for a worker that
|
|
36
|
+
left with no successor.
|
|
37
|
+
- A delegate child's `task_create` whose `env:` redefines the shell (`BASH_ENV`, an exported `BASH_FUNC_cat%%`,
|
|
38
|
+
`CDPATH`, …) now asks as `child-boundary` from its own folder too: `cat a.txt` there with a startup file or an
|
|
39
|
+
exported function that writes the main checkout no longer ran unasked.
|
|
40
|
+
|
|
41
|
+
### Security
|
|
42
|
+
|
|
43
|
+
- A participant's pair turn (`chi share`, a working session) can no longer call this machine's chi web on a loopback
|
|
44
|
+
name (`curl -X POST 127.0.0.1:4567/api/sessions/ID/participants`, `localhost`, `[::1]`, `0.0.0.0`, the host name,
|
|
45
|
+
with chi web's recorded or configured port, by any client or script): a LAN chi web takes a token-less loopback
|
|
46
|
+
request as the owner's, so the command could mint a grant or queue an owner turn. chi web's own auth is unchanged.
|
|
47
|
+
- A pair turn's "a write outside the project always asks the owner" now fails closed. A command runs unasked only when
|
|
48
|
+
it only reads, only writes (`>`, `cp`, `tee`) into the project or a tmp dir, or stays inside the project (it runs
|
|
49
|
+
there and every path it names lands there or in a tmp dir). `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`,
|
|
50
|
+
`dd of=~/x`, `touch`/`mkdir` outside, `perl -pi`, `python -c "open('/…', 'w')"`, `cd .. && make`, and a command with
|
|
51
|
+
a `$var`, a `$(…)` (but a commit message's heredoc), `xargs` or a `cd` it can't follow now ask. A tmp path is one by
|
|
52
|
+
the delegate child's rule (`/var/tmp` too, not the tmp dir the project is in, never through a dangling symlink or
|
|
53
|
+
onto a file with another hard link).
|
|
54
|
+
- A pair turn in a session whose folder is the home folder or above it asks before every write (the review boundary
|
|
55
|
+
already read nothing there).
|
|
56
|
+
- A pair command that makes a link (`ln`, `cp -s`/`-l`, a script's `symlink`) asks the owner, and one linking to home
|
|
57
|
+
or a folder above a secret is denied: `ln -s ~/.config g && cat g/gh/hosts.yml` passed, as the link didn't exist yet
|
|
58
|
+
when the paths were judged.
|
|
59
|
+
- A pair turn can no longer write git's hooks (`.git/hooks/*`, by `write`/`edit` or a command), schedule a command
|
|
60
|
+
(`crontab`, `at`, `batch`), run a credential printer (`gh auth token`, `git credential fill`, `gcloud … print-access-token`,
|
|
61
|
+
`op`, `aws configure get`, …) or `ps -E`, read the owner's background tasks (`task_wait`, `task_get`, `task_list`), or
|
|
62
|
+
run a program whose name is a `$var`, a `$(…)` or a glob (`bin/ch? …`).
|
|
63
|
+
- A guardrail check no longer cuts a heredoc's body out of the command it then runs (since 0.81.0,
|
|
64
|
+
`ShellLex.code_text` built its result on the caller's string): with the guardrails bundle's `command:` rules, an
|
|
65
|
+
owner's `git commit -m "$(cat <<'EOF' … EOF)"` or `git commit -F - <<EOF` could run with an empty message, and a pair
|
|
66
|
+
turn's `cat > docs/x.md <<EOF … EOF` with an empty body.
|
|
67
|
+
- Guardrails take a path's `..` the way the kernel does: after a symlinked folder it climbs out of the link's target.
|
|
68
|
+
A participant's review turn could read `node_modules/foo/../../.config/gh/hosts.yml` (with `foo` an `npm link` to
|
|
69
|
+
`~/src/foo`) as a project file while the read opened `~/.config/gh/hosts.yml`; a pair turn's secret check, the
|
|
70
|
+
protected paths (`mem/../config.yml` with `mem` a link to chi's memories), `outside_repo`, the delegate child's
|
|
71
|
+
boundary, `touches: chi_dirs` and `rm: outside_tmp` had the same gap. Each now refuses or asks when either reading of
|
|
72
|
+
the path does, and a write through a dangling symlink to `config.yml`, the hooks or `.bundles/` is asked or denied
|
|
73
|
+
like the file it creates.
|
|
74
|
+
- A shell `cd` or `pushd` whose `..` climbs out of a symlinked folder's target now counts as landing in either folder:
|
|
75
|
+
zsh (chi's shell on macOS) and bash chdir to the physical one when the logical one doesn't exist, and `cd -P`
|
|
76
|
+
always does. A pair turn's `cd node_modules/foo/../../.config/gh && cat hosts.yml` (or `cd -P
|
|
77
|
+
node_modules/foo/../.. && echo x > .zshrc`) was allowed while it read or wrote outside the project; `touches:
|
|
78
|
+
chi_dirs`, `git: outside_repo` and the delegate child's boundary read the same cd logically only. Later words are
|
|
79
|
+
now resolved and judged against both folders, and `cd -` leaves the folder unknown.
|
|
80
|
+
- System 1 (`on` mode) no longer auto-allows a command that climbs out of chi's memories folder into its config:
|
|
81
|
+
`cd …/memories; cd ..; echo 'mode: off' >> config.yml`, `cd "${PWD%/*}"` or `cd $(dirname $PWD)` passed its
|
|
82
|
+
own-files check, since only the text was read. The check now resolves the command's words like
|
|
83
|
+
`shell-touches-chi` (cd chains, symlinks, `..`, the call's `env:`), so `cd ~/.config; cd samagotchi; cat
|
|
84
|
+
config.yml` is refused too. A memories command keeps its exemption while its text shows no climb (`..`, `$`,
|
|
85
|
+
backticks, `cd -`) and every word resolves or is data (a `python3 -c` or heredoc body, `echo "- a note"`); a call
|
|
86
|
+
whose `env:` redefines the shell is never auto-allowed.
|
|
87
|
+
|
|
88
|
+
Update with `chi update`, then restart `chi web` and the session workers (`chi sessions restart`).
|
|
89
|
+
|
|
10
90
|
## [0.128.0] - 2026-10-28
|
|
11
91
|
|
|
12
92
|
### Changed
|
|
@@ -4810,7 +4890,8 @@ and long-lived sessions.
|
|
|
4810
4890
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4811
4891
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4812
4892
|
|
|
4813
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4893
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.129.0...HEAD
|
|
4894
|
+
[0.129.0]: https://github.com/dm1try/samagotchi/compare/v0.128.0...v0.129.0
|
|
4814
4895
|
[0.128.0]: https://github.com/dm1try/samagotchi/compare/v0.127.0...v0.128.0
|
|
4815
4896
|
[0.127.0]: https://github.com/dm1try/samagotchi/compare/v0.126.0...v0.127.0
|
|
4816
4897
|
[0.126.0]: https://github.com/dm1try/samagotchi/compare/v0.125.0...v0.126.0
|
data/docs/architecture.md
CHANGED
|
@@ -120,7 +120,7 @@ terminal renders the events it gets over the Bridge.
|
|
|
120
120
|
- Attached (`bin/chi`, `--attach ID`, `--shared [--resume ID]`) → `TerminalUI::AttachLauncher`: no `Engine`
|
|
121
121
|
and no `OwnerLock`; finds or starts the session's worker and runs `TerminalUI::AttachedLoop`
|
|
122
122
|
as a client of its Bridge (`BridgeClient#follow`, `post_turn`, `post_command`, `cancel`, `answer`,
|
|
123
|
-
`dismiss_question`). The worker runs in the session's `working_directory`, so `!cmd` and
|
|
123
|
+
`dismiss_question`; opening the SSE connection answers `BridgeClient::StreamOpen::Opened`, `Refused` or `Unreachable`). The worker runs in the session's `working_directory`, so `!cmd` and
|
|
124
124
|
the tools don't depend on where the terminal attached from.
|
|
125
125
|
- `bin/chi web` → builds `Web::Server` (Rack+WEBrick on `127.0.0.1:4567`, `--port`/`SAMAGOTCHI_WEB_PORT`, `--open`;
|
|
126
126
|
`web.host: lan` adds a LAN address, `--new-token` replaces its access token).
|
|
@@ -173,6 +173,8 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
|
|
|
173
173
|
but `RelineSeam` (prepended to `Reline::LineEditor`) sends its drawing to the
|
|
174
174
|
`Screen`. The saved history and cards in a join or resync are rendered by
|
|
175
175
|
`TerminalUI::JoinReplay` with the loop's screen, view, link, gap clock and renderer.
|
|
176
|
+
The loop's concerns live in mixins under `terminal_ui/attached_loop/`: `AttachedLoop::Composer`, `Questions`,
|
|
177
|
+
`Queueing`, `Sending` and `ExitCommands` (the `/exit` ask's reply text is `TerminalUI::ExitLines`).
|
|
176
178
|
With input from a pipe (`chi -p X </dev/null`), `TerminalUI::OwnTurns` keeps this
|
|
177
179
|
run's own prompts until their turns end; `#run` distinguishes an empty answer from a
|
|
178
180
|
prompt this terminal removed before it ran (both exit 1), as well as chi's other outcomes.
|
|
@@ -376,7 +378,10 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
376
378
|
the event log held (`WorkerIdleExit#hold_for_request`): `200 {status: "exiting"}` and it leaves
|
|
377
379
|
like an idle exit, or `409 {status: "held", reason:}` with `turn_running`, `input_queued`,
|
|
378
380
|
`continue_offered`, `client_connected` (a stream not named by the asker), `reminders`,
|
|
379
|
-
`background_tasks` (with `tasks: [{id, command}]`: the running tasks a finish wake watches)
|
|
381
|
+
`background_tasks` (with `tasks: [{id, command}]`: the running tasks a finish wake watches), `starting`, or
|
|
382
|
+
`leaving_exit` / `leaving_delete` / `leaving_restart` when another client's exit of another kind is already taken
|
|
383
|
+
(`Worker#claim_exit`, claimed before a stop-all; the words are `WorkerIdleExit::LEAVING_WORDS`). A second exit of
|
|
384
|
+
the kind already taken is answered `200` as that one.
|
|
380
385
|
- `GET /session/:id/state` — `session_state_snapshot` (JSON).
|
|
381
386
|
- `GET /session/:id/stats` — `Engine#stats_snapshot` for attached `/stats`: the metrics, with the context window and prompt profile asked from the server before the first turn.
|
|
382
387
|
- `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
|
|
@@ -506,7 +511,8 @@ for client discovery, with the Bridge's secret (see above).
|
|
|
506
511
|
reading `bridge.json` or probing a port itself: `WorkerLink.resolve(session_dir, unlink:, owner:)` answers a typed
|
|
507
512
|
outcome. The rule is the owner lock plus the sidecar's `pid`; there is no port probe (a port can be reused by another
|
|
508
513
|
session's Bridge, a free lock is the kernel's proof the worker died). In order: no lock holder, or a chi REPL's: a
|
|
509
|
-
sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read
|
|
514
|
+
sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read and the lock,
|
|
515
|
+
read again just before, is still free: a worker that took it meanwhile is resolved as that worker), else `None`;
|
|
510
516
|
a holder with no kind yet: `Starting`; a leaving worker: `Leaving` (marked before its Bridge stops, on every way out:
|
|
511
517
|
`Worker#teardown` marks it too, with `TEARDOWN_LEAVE_WAIT`); a worker whose sidecar names no port or another
|
|
512
518
|
pid than the holder's (a worker that has not written its own yet): `Starting`, `stuck` once it has held the lock
|
|
@@ -565,7 +571,8 @@ it holds with `update.notify: off`; a child that prints no rows fails with its o
|
|
|
565
571
|
done install refreshes the hub's installed version (`SessionHub#refresh_installed`) before its `done`.
|
|
566
572
|
Worker restarts target every worker older than the version installed now (refreshed the same way)
|
|
567
573
|
and publish per-session results. Only one of an install, a worker restart pass and a web restart runs
|
|
568
|
-
at a time (409 `update_running`). `POST /api/update/
|
|
574
|
+
at a time (409 `update_running`). `POST /api/update/restart_workers` (`handle_restart_workers`) starts the worker pass
|
|
575
|
+
by itself. `POST /api/update/restart_web` (409 `restart_unavailable` without a
|
|
569
576
|
chi wrapper to exec) schedules WEBrick shutdown only after its response is sent; after the hub and
|
|
570
577
|
listener stop, `Server` execs the RubyGems chi wrapper with every flag chi web was started with, as
|
|
571
578
|
`RunOptions`' parser recorded them (`RunOptions.web_restart_argv`), but the one-shot `--open` and
|
|
@@ -618,7 +625,7 @@ model: [internals/remote-sessions.md](internals/remote-sessions.md).
|
|
|
618
625
|
|---|---|---|---|---|
|
|
619
626
|
| `GET /api/sessions/resolve?id=` | owner | an id or a unique prefix | 200 `{id}` | 404 `not_found`; 400 `ambiguous_id` with `ids` |
|
|
620
627
|
| `GET /api/sessions/summaries` | owner | `dir` (a folder: its project; none: every project), `cwd`, `live=1`, `archived=1`, `tests=0`, `sort`, `order`, `limit` | 200: the rows `chi sessions list` prints (`SessionManager.session_summaries`: `status`, `live`, `busy`, `owner`, `recap`, `ctx_pct`, `waiting`, `waiting_id`, `title`, `preview`, …) | 400 `invalid_dir`, `invalid_cwd` |
|
|
621
|
-
| `GET /api/projects?dir=` | owner | optional page-scope folder | 200: project rows with counts, roots, default/current marks; no `dir`: no current project | 400 `invalid_dir`; 503 `no_hub` |
|
|
628
|
+
| `GET /api/projects?dir=` | owner | optional page-scope folder | 200: project rows (`Web::ProjectList`: the hub's projects plus the start project) with counts, roots, default/current marks; no `dir`: no current project | 400 `invalid_dir`; 503 `no_hub` |
|
|
622
629
|
| `POST /api/sessions` (more fields) | owner | `toolkit`, `tools`, `no_tools`, `parent_answers`; `client_id` (`cli:send`/`cli:answer`: the first line is checked as chi send checks it); `check_vision: true` (images follow) | 201 adds `model_warning`, `parent_answers_warning` (a level looser than config.yml's, capped) and `first_turn_id` (the id the prompt it starts with gets for its turn; null for an `idle` create) | 400 `invalid_toolkit`, `invalid_parent_answers`, `invalid_continues` (beside `continues`); 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
|
|
623
630
|
| `POST /api/sessions/:id/turn` (more fields) | owner | `client_id` as above; `check_vision: true` with `images` | as before | 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
|
|
624
631
|
| `POST /api/sessions/:id/wait/prepare` | owner | `question_id` (absent: the pending one; `null`: none), `turn`, `created`, `busy` (+ `queue`) | 200 `{session_id, cursor, baseline: {messages, question_id, last_turn}, own_turn, known_turn, running, live, settled, last_turn_id}` (`WaitStart`) | 409 `busy`, `waiting` (`SendCommand::Busy`'s words, and its fields: `question`, `parent_strategy`) |
|
data/docs/cli.md
CHANGED
|
@@ -1210,7 +1210,8 @@ pills there (a review turn refuses `/` lines).
|
|
|
1210
1210
|
|
|
1211
1211
|
After the presets, both pages have a built-in **Verify**: the quote, then
|
|
1212
1212
|
an ask to check the claim against the code and end on a line `Verdict:
|
|
1213
|
-
confirmed|refuted|unclear — <reason>`.
|
|
1213
|
+
confirmed|refuted|unclear — <reason>`. A session whose worker predates Verify (started by an older chi) refuses it (409
|
|
1214
|
+
`verify_unavailable`); a participant's page disables Verify and Verify fresh with a hint to restart the session. On your page a selection in a turn
|
|
1214
1215
|
also offers **Copy link**: a link to that turn and quote for a participant's
|
|
1215
1216
|
browser (the one their review link was opened in), which opens their page
|
|
1216
1217
|
scrolled to the quote and highlights it; with no one invited, a link to it
|
data/docs/guardrails.md
CHANGED
|
@@ -274,7 +274,8 @@ sub-agent](sub-agent.md)) tell them to use `chi answer` only.
|
|
|
274
274
|
|
|
275
275
|
## Protected paths
|
|
276
276
|
|
|
277
|
-
Built in, for `write`, `edit` and `memory_write` (symlinks resolved
|
|
277
|
+
Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
|
|
278
|
+
[How paths resolve](#how-paths-resolve)):
|
|
278
279
|
|
|
279
280
|
- deny: the approval store's dir, installed bundles (`memories/.bundles/`),
|
|
280
281
|
and [attached context](context.md) sources (`<state dir>/context/`: a
|
|
@@ -305,7 +306,21 @@ else the parent's checkout). Rule `child-boundary`, source `core`; it asks when
|
|
|
305
306
|
overwriting a branch other than its own.
|
|
306
307
|
|
|
307
308
|
Read-only commands (`git -C ../app log`, `cat ../app/x`) and paths elsewhere
|
|
308
|
-
(`/usr/bin`, `~/.gem`) never ask
|
|
309
|
+
(`/usr/bin`, `~/.gem`) never ask, nor does a read-only command that writes only
|
|
310
|
+
into a tmp dir (`/tmp`, `/var/tmp`, `$TMPDIR`): its `>` / `>>` targets, `cp`'s
|
|
311
|
+
destination (the last argument, or `-t` / `--target-directory`) and `tee`'s
|
|
312
|
+
files, resolved against its `cwd:` and any `cd` or `pushd` before them, as in
|
|
313
|
+
`sqlite3 -readonly -safe ../app/x.db "select 1" > /tmp/rows` or
|
|
314
|
+
`cp ../app/x.db /tmp/x.db`. A target that links back out of the tmp dir (a
|
|
315
|
+
symlink, a dangling one, a file with another hard link), `cp -l`/`-s`, `mv`,
|
|
316
|
+
`&>`, `>|` and `>&file` still ask, as does a target with a `$` or `~` (the
|
|
317
|
+
call's `env:` may set it), a `popd`, and a `cd`/`pushd` that isn't one existing
|
|
318
|
+
dir spelt `/…`, `.` or `..` joined by `&&` only (it may fail or be skipped and
|
|
319
|
+
leave the shell elsewhere); so does every tmp write when the
|
|
320
|
+
child's folder is itself in that tmp dir. A `task_create` call's paths resolve under chi's env and
|
|
321
|
+
under its own `env:` (its `$TMPDIR`, `$HOME`, `GIT_DIR`, …): either one naming another checkout asks. An `env:` that
|
|
322
|
+
redefines the shell (`BASH_ENV`, `BASH_FUNC_cat%%`, `CDPATH`, …, as under `skip_read_only` below) always asks, even
|
|
323
|
+
for `cat a.txt` in the child's own folder: what it runs is in no word of the command. The answer is once or for the child's
|
|
309
324
|
session only: a repo or rule answer would hold in every worktree, for every
|
|
310
325
|
child, so a stored repo or rule approval doesn't let it through either. Its
|
|
311
326
|
ask goes before the rules, so with the guardrails bundle in strict mode the
|
|
@@ -337,7 +352,9 @@ participant's turn may call only the `review` kit's tools:
|
|
|
337
352
|
`.pypirc`, ssh keys (`id_rsa`, `id_ed25519`, …), `*.tfstate`,
|
|
338
353
|
`secrets.yml`/`.json`, `.docker/config.json`, `.kube/config` and any config
|
|
339
354
|
under `.git` (a submodule's too). The path is checked as given and as it
|
|
340
|
-
resolves, so a symlink in the project to `~/.ssh` is `~/.ssh
|
|
355
|
+
resolves, so a symlink in the project to `~/.ssh` is `~/.ssh`, and
|
|
356
|
+
`node_modules/foo/../../.config/gh/hosts.yml` with `foo` a link to `~/src/foo`
|
|
357
|
+
is `~/.config/gh/hosts.yml` ([How paths resolve](#how-paths-resolve)). A session
|
|
341
358
|
whose folder is the home folder or above it reads nothing.
|
|
342
359
|
- `memory_read` with `scope: project` (a read without a scope would fall back
|
|
343
360
|
to the system memories). The prompt's preloaded memories and the memory
|
|
@@ -391,31 +408,56 @@ message:
|
|
|
391
408
|
each word resolved as `touches: chi_dirs` resolves it; a glob that matches a
|
|
392
409
|
secret or whose fixed part leads into a denied folder (`~/.ss?/id_*`,
|
|
393
410
|
`~/.config/*/config.yml`, `.en?`), a `$var` word whose fixed part does
|
|
394
|
-
(`~/.ss${d}h`), and a text match for the rest
|
|
411
|
+
(`~/.ss${d}h`), and a text match for the rest. A prefix that comes through a
|
|
412
|
+
variable (`x=~/.s; cat ${x}sh/config`) isn't followed: such a command isn't
|
|
413
|
+
denied, it asks (its `$var` word and the assignment's path place it outside
|
|
414
|
+
the project);
|
|
395
415
|
- sweeping a folder that holds secrets: `find`, `tar`, `cp`, `rsync`, `scp`,
|
|
396
416
|
`zip`, `ditto`, `grep`/`rg`/`ag` and the like given home or any folder above
|
|
397
|
-
a denied one;
|
|
398
|
-
- the environment and
|
|
399
|
-
`export`, `declare` or `typeset` that list, `launchctl
|
|
400
|
-
`security`, `/proc/*/environ
|
|
417
|
+
a denied one, and linking to one (`ln -s ~/.config g`, `ln -s ../.. up`);
|
|
418
|
+
- the environment, the keychain and stored credentials: `env` or `printenv`
|
|
419
|
+
alone, `set`, `export`, `declare` or `typeset` that list, `launchctl
|
|
420
|
+
getenv`, `ps e`/`ps -E`, `security`, `/proc/*/environ`, and the commands
|
|
421
|
+
that print a credential (`gh auth token`, `git credential fill` and the
|
|
422
|
+
`git credential-*`/`docker-credential-*` helpers, `gcloud … print-access-token`,
|
|
423
|
+
`op`, `aws configure get`/`export-credentials`, `az account
|
|
424
|
+
get-access-token`, `heroku auth:token`, `kubectl config view --raw`;
|
|
425
|
+
`PairCommand::CREDENTIAL_PRINTERS`). On top of it, a pair turn's `execute` runs
|
|
401
426
|
without every variable whose name matches `KEY|TOKEN|SECRET|PASS|CREDENTIAL|AUTH`
|
|
402
427
|
(`PairBoundary::SECRET_ENV_NAME`, case-insensitive), and a command still
|
|
403
428
|
running at the limit is killed, never handed over as a background task;
|
|
404
429
|
- git settings that make git run a command (`core.hooksPath`,
|
|
405
430
|
`core.fsmonitor`, `core.sshCommand`, `core.pager`, `core.editor`, a `!`
|
|
406
431
|
alias) by `git config` or `git -c`, and `git config --global`/`--system`;
|
|
432
|
+
and writing git's hooks: a `write` or `edit` of a path in a `.git/hooks`
|
|
433
|
+
folder (as given or as it resolves), or a command that isn't read-only and
|
|
434
|
+
names one (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`);
|
|
407
435
|
- running chi, by any wrapper (`bin/chi`, `bundle exec chi`, `ruby … bin/chi`,
|
|
408
436
|
`env … chi`, `watch`, `npx`, `sh -c 'chi …'`, `$(chi …)`), in an assignment's
|
|
409
437
|
value or an alias, a function, after `-exec`/`-c`/`-e`, but `chi --version` and
|
|
410
438
|
`chi --help`: its commands act in the owner's name (shares, caps, sends,
|
|
411
439
|
answers, sessions);
|
|
440
|
+
- calling chi web on this machine: a command whose text names a loopback name
|
|
441
|
+
(`localhost` and its subdomains, `127.x`, `0.0.0.0`, `[::1]`, `::ffff:127.x`,
|
|
442
|
+
`0:PORT`) or this machine's host name together with chi web's port (the one
|
|
443
|
+
the running chi web recorded in `web.json`, config's `web.port`, 4567), by
|
|
444
|
+
`curl`, `wget`, `nc`, `/dev/tcp`, a script's URL or anything else: a loopback
|
|
445
|
+
request without a token is the owner on a LAN chi web
|
|
446
|
+
(`PairBoundary::CHI_WEB`). A Bridge answers only with its secret, which sits
|
|
447
|
+
in chi's state dir;
|
|
448
|
+
- a program whose name the shell expands: a `$var`, a `$(…)` or a glob in a
|
|
449
|
+
command's name (`bin/ch? …`, `e=ch; ${e}i …`, `$CMD x`), whatever it would
|
|
450
|
+
turn out to be (`PairBoundary::VERB`);
|
|
412
451
|
- leaving something running: `&` at a command's end, `nohup`, `setsid`,
|
|
413
|
-
`disown`, `screen -d…`, `tmux new -d
|
|
452
|
+
`disown`, `screen -d…`, `tmux new -d`, and scheduling it: `crontab`, `at`,
|
|
453
|
+
`batch`;
|
|
414
454
|
- owner-voiced tools, which act in the owner's name beyond the turn:
|
|
415
455
|
`delegate` and any `delegate_*` tool, `register_reminder`,
|
|
416
456
|
`cancel_reminder`, `send_note`, `forget_outputs`, `task_create`,
|
|
417
457
|
`task_stop`, `find_tools` (it loads tools into the session), and
|
|
418
|
-
`memory_write` (a memory reaches the owner's later sessions);
|
|
458
|
+
`memory_write` (a memory reaches the owner's later sessions); and the tools
|
|
459
|
+
that read the owner's background tasks and their output, `task_wait`,
|
|
460
|
+
`task_get`, `task_list` (a pair turn starts none);
|
|
419
461
|
- `ask_user_question`, with the advice to ask in the reply: nobody on the
|
|
420
462
|
participant's side answers a card.
|
|
421
463
|
|
|
@@ -426,13 +468,41 @@ own, one another interpreter runs (`ruby -e`, `python - <<EOF`) by its text
|
|
|
426
468
|
`tee`, `git commit -F -`) is not looked into: where it goes is checked. A
|
|
427
469
|
command the checks can't read is refused, never let through on a text match.
|
|
428
470
|
|
|
429
|
-
And it **asks the owner** before a `write` or `edit` outside the
|
|
430
|
-
project root, and before a command that
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
471
|
+
And it **asks the owner** before a `write` or `edit` that may land outside the
|
|
472
|
+
session's project root, and before a command that may write there, every time:
|
|
473
|
+
the ask is fresh (`Verdict#fresh_ask`: no stored approval answers it, and no
|
|
474
|
+
rule lowers it), so it comes even in auto mode. A command fails closed
|
|
475
|
+
(`PairCommand#outside_writes?`): it runs unasked only when it
|
|
476
|
+
|
|
477
|
+
- only reads (`ReadOnlyShell.read_only?`, wherever it reads), or
|
|
478
|
+
- only reads but for writes it can name (a `>`/`>>` target, `cp`'s
|
|
479
|
+
destination, `tee`'s files) into the project or a tmp dir
|
|
480
|
+
(`ReadOnlyShell.read_only_but_writes?`), or
|
|
481
|
+
- stays inside the project: every part of it runs in the project, and every
|
|
482
|
+
path it names lands in the project or a tmp dir, an option's or an
|
|
483
|
+
assignment's value (`of=`, `--output=`, `GIT_DIR=`), a glob's fixed part
|
|
484
|
+
(`~/oth*`), `git -C`'s folder and the paths in a script it hands on (`sh -c`
|
|
485
|
+
read as a command, `python -c`/`ruby -e`/a heredoc by the paths in its text)
|
|
486
|
+
among them. The program's own word doesn't count (`/usr/bin/sed`), nor does
|
|
487
|
+
`PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`).
|
|
488
|
+
|
|
489
|
+
Anything else asks: `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`, `dd of=~/x`,
|
|
490
|
+
`touch`/`mkdir` outside, `perl -pi … ~/x`, `cd .. && make`, `cd && make`, and
|
|
491
|
+
whatever the checks can't place: a `$var` (`rm -rf $DIR`, `> "$OUT"`), a `$(…)`,
|
|
492
|
+
`xargs`, a `cd $X`. A path "lands" in the project or a tmp dir every way it
|
|
493
|
+
resolves ([How paths resolve](#how-paths-resolve)) and never through a dangling
|
|
494
|
+
symlink or onto a file with another hard link (`Outside.inside?`,
|
|
495
|
+
`Outside.tmp_write?`, the child boundary's tmp rule): the tmp dirs are
|
|
496
|
+
`Dir.tmpdir`, `/tmp` and `/var/tmp`, except the one the project itself is in.
|
|
497
|
+
`/dev/null` and the like neither ask nor deny. A script file's body (`ruby
|
|
498
|
+
x.rb`, `make`, `sh ./run.sh`) isn't read: it runs as in-project work.
|
|
499
|
+
A command that makes a link asks too, wherever it points (`ln`, `link`, `cp
|
|
500
|
+
-s`/`-l`, `sh -c 'ln …'`, an interpreter's `symlink`/`link(`): a path through
|
|
501
|
+
a link the same command makes is judged before the link exists, so `ln -s ..
|
|
502
|
+
up && cat up/…` can't be read for what it reaches.
|
|
503
|
+
A session whose folder is the home folder or above it has no project to
|
|
504
|
+
keep the turn in (`ReviewPaths.home_root?`, the review boundary's test): every
|
|
505
|
+
write there asks.
|
|
436
506
|
|
|
437
507
|
A participant's ask names them on the owner's card ("Sam's turn asks: approve
|
|
438
508
|
tool call?", `approval.author`/`author_name`, `approval.asked_by`, the same
|
|
@@ -448,7 +518,10 @@ The trust model is a team's (everyone invited is trusted): the boundary is an
|
|
|
448
518
|
hostile participant; a review session is. A pair turn can still reach, through
|
|
449
519
|
the owner's rules: `web_fetch` (an open egress path: what the turn can read it
|
|
450
520
|
can send out in a URL), plugin and MCP tools, `memory_read` and `context_read`,
|
|
451
|
-
`list_sessions` (the owner's other sessions' titles),
|
|
521
|
+
`list_sessions` (the owner's other sessions' titles), chi web by a name the
|
|
522
|
+
check doesn't know (a DNS name or a decimal address for 127.0.0.1, a port
|
|
523
|
+
forward, a port spelt in a variable), where a LAN chi web still takes a
|
|
524
|
+
token-less loopback request as the owner's, and anything a script
|
|
452
525
|
does that the command checks don't read (a script file's body, an interpreter's
|
|
453
526
|
`-e` code). Share a working session only with people you would let type in it.
|
|
454
527
|
|
|
@@ -564,7 +637,10 @@ only `run list|view`, `pr list|view|diff|checks`, `issue list|view` and
|
|
|
564
637
|
`repo view`; `--web`/`-w`, `gh api` and `pr checks --watch` are not read-only.
|
|
565
638
|
`$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
|
|
566
639
|
at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
|
|
567
|
-
like make a command not read-only
|
|
640
|
+
like make a command not read-only, as does a `task_create` `env:` that sets `BASH_ENV`, `ENV`, `CDPATH`, `IFS`,
|
|
641
|
+
`SHELLOPTS`, `BASHOPTS`, `BASH_FUNC_…`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `GIT_EXTERNAL_DIFF`,
|
|
642
|
+
`GIT_CONFIG_PARAMETERS` or `GIT_CONFIG_COUNT` (they change what its words run; `PATH` doesn't count). It errs
|
|
643
|
+
towards asking.
|
|
568
644
|
|
|
569
645
|
A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
|
|
570
646
|
say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
|
|
@@ -647,6 +723,41 @@ one folder; a link in the repo that points elsewhere is outside). Never outside:
|
|
|
647
723
|
- a tmp folder (`$TMPDIR`, `/tmp`, `/var/tmp`), unless the session's repo is
|
|
648
724
|
itself in that tmp folder: a sandbox there still gets asked about its siblings.
|
|
649
725
|
|
|
726
|
+
A path that is or goes through a dangling symlink (a write would create the
|
|
727
|
+
link's target) or a file with another hard link (a write changes the other
|
|
728
|
+
name too) is always outside, in the repo or a tmp folder alike
|
|
729
|
+
(`Outside.resolve`, which the child boundary's tmp writes read as well).
|
|
730
|
+
|
|
731
|
+
#### How paths resolve
|
|
732
|
+
|
|
733
|
+
Every path check (protected paths, `outside_repo`, `touches`, `rm`, `memory:
|
|
734
|
+
prompt`, the child, review and pair boundaries, System 1's own-files test)
|
|
735
|
+
resolves paths with `Guardrails::ProtectedPaths`:
|
|
736
|
+
|
|
737
|
+
- `..` is taken the kernel's way (`.expand`): after a symlinked folder it
|
|
738
|
+
climbs out of the link's target, so `repo/link/../x` with `link` pointing
|
|
739
|
+
to `/elsewhere/deep` is `/elsewhere/x`, not `repo/x`;
|
|
740
|
+
- symlinks are resolved on the longest part that exists, and a dangling
|
|
741
|
+
symlink is followed to the file a write through it would create (`.real`);
|
|
742
|
+
- a check reads the path both ways, the lexical answer (`File.expand_path`,
|
|
743
|
+
stopping at a dangling link) and where the kernel lands (`.reals`): it
|
|
744
|
+
refuses, asks or votes when either is protected, outside or a secret, so
|
|
745
|
+
the kernel's answer only ever adds to the lexical one. A file tool call's
|
|
746
|
+
paths (`Targets#paths`, what approvals show) list both when they differ.
|
|
747
|
+
|
|
748
|
+
In a shell command a file argument resolves the same way, against the folder
|
|
749
|
+
the command is in by then. A `cd` or `pushd` to such a path may land either
|
|
750
|
+
way: `cd link/..` is back where it started only while that logical folder
|
|
751
|
+
exists (zsh, which chi's shell is on macOS, and bash chdir to the physical one
|
|
752
|
+
when it doesn't), and `cd -P`, `set -P` and zsh's `chase_links`/`chase_dots`
|
|
753
|
+
always take the physical one. So after it the command counts as in both
|
|
754
|
+
folders: each later word is resolved against each (a word that can't be
|
|
755
|
+
resolved from one isn't resolved), and judged both ways as above; past eight
|
|
756
|
+
folders, or after `cd -`, the folder is unknown. After `cd link` a `../x`
|
|
757
|
+
climbs out of the link's target as well. git's `-C`, `--git-dir` and
|
|
758
|
+
`--work-tree` (and `GIT_DIR`/`GIT_WORK_TREE`) are counted both ways, from each
|
|
759
|
+
folder the command may be in.
|
|
760
|
+
|
|
650
761
|
`git: outside_repo` (shell tools only) reads the command for git subcommands
|
|
651
762
|
that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
|
|
652
763
|
`rebase`, `merge`, `push`, `stash` (not `stash list`/`show`), `rm`, `mv`,
|
|
@@ -883,12 +994,26 @@ gate below passes, in this order:
|
|
|
883
994
|
config dir (config.yml), the hooks dir, the installed bundles dir, the
|
|
884
995
|
guardrails store (where `approvals.json` and `judgments.jsonl` live) or the
|
|
885
996
|
attached context dir, and no `execute` or `task_create` command naming any
|
|
886
|
-
of them
|
|
887
|
-
`$XDG_CONFIG_HOME/samagotchi/config.yml`,
|
|
888
|
-
`cd ~/.config` then `samagotchi/config.yml`)
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
997
|
+
of them (or chi's state dir). A command is checked twice: by its text
|
|
998
|
+
(`ParentApprovals::CHI_TEXT`: `$XDG_CONFIG_HOME/samagotchi/config.yml`,
|
|
999
|
+
`~/.config/samagotchi/…`, a `cd ~/.config` then `samagotchi/config.yml`),
|
|
1000
|
+
and by its words resolved as `shell-touches-chi` resolves them
|
|
1001
|
+
(`Guardrails::ShellPaths::Walk`: cd chains, `~`, `$HOME` and the XDG dirs,
|
|
1002
|
+
symlinks, `..`; under the call's `env:` too), so `cd ~/.config; cd
|
|
1003
|
+
samagotchi; cat config.yml` or a symlink into the config dir is refused.
|
|
1004
|
+
A call whose `env:` redefines the shell (`Targets#redefines_shell?`:
|
|
1005
|
+
`BASH_ENV`, `BASH_FUNC_…`, …) is refused whatever it names. The memories
|
|
1006
|
+
dir is deliberately not one of these: reading or appending a memory file
|
|
1007
|
+
can be auto-allowed, by a command none of whose words lands in chi's dirs
|
|
1008
|
+
outside the memories, whose text shows no climb (`SelfProtect::CLIMB`: a
|
|
1009
|
+
`..` segment, any `$` or `$(…)`, backticks, a `~` other than the home one,
|
|
1010
|
+
`cd -`), and whose words the walk can't resolve are only data read while
|
|
1011
|
+
it knew the dir: a script or heredoc body (`python3 -c '…'`, `cat >> x.md
|
|
1012
|
+
<<'EOF'`), a phrase (`echo "- a note"`) or a glob with no `/`. So
|
|
1013
|
+
`cd memories; cd ..`, `cd "${PWD%/*}"`, `cd $(dirname $PWD)` or `cd -`
|
|
1014
|
+
loses the exemption, and the memories text then refuses it. A script body
|
|
1015
|
+
is not looked into beyond its text: an accident guard, not containment.
|
|
1016
|
+
A protected rule
|
|
892
1017
|
(`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
|
|
893
1018
|
it, but a call that really writes config.yml or the hooks is refused here;
|
|
894
1019
|
5. the judgment's `p` is at least `guardrails.system_one.threshold` (0.8);
|
|
@@ -176,8 +176,11 @@ Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt
|
|
|
176
176
|
to act: the turn only reads (`Guardrails::ReviewBoundary`), and proposes what the owner applies ([Apply](#apply)).
|
|
177
177
|
- **`:pair`**, a working session's participant invited to act: the turn acts with the session's tools under the
|
|
178
178
|
owner's guardrails, its asks go to the owner and offer once only, and `Guardrails::PairBoundary` denies secrets,
|
|
179
|
-
chi itself
|
|
180
|
-
|
|
179
|
+
chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git's
|
|
180
|
+
hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
|
|
181
|
+
that makes a link and before a write that may land outside the project, failing closed (a session at or above
|
|
182
|
+
home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it
|
|
183
|
+
acted itself.
|
|
181
184
|
|
|
182
185
|
`ParticipantMode.of(session, features:)` is the session's: what a participant invited now gets. No session is
|
|
183
186
|
`:review`. A worker names `pair_turns` (`Delivery::PAIR_FEATURE`) when it runs pair turns; one without it runs
|
|
@@ -230,6 +233,15 @@ worker checks their link (`participants.json`: revoked, expired) and their cap a
|
|
|
230
233
|
dequeued (`:turn_dequeued`) and audited (`revoked`, `cap_reached`) instead of run, so a revoke also stops what they
|
|
231
234
|
had queued; its settled record (`TurnCheckpoint.record_refused`: canceled, `cancel_reason` the reason) ends their
|
|
232
235
|
wait on it at once, which `ParentReport` reads as status `refused`, exit 1.
|
|
236
|
+
|
|
237
|
+
**The Verify gate.** A worker names `verify` (`VerifyAnchor::FEATURE`, in the Bridge's `FEATURES`) when it understands a
|
|
238
|
+
Verify turn's anchor (`VerifyAnchor`, `{turn_id, quote}`: a UUID and a quote of 1 to 200 characters). A turn carrying an
|
|
239
|
+
anchor, or a Verify fresh check, sent to a connected worker without it is refused 409 `verify_unavailable`
|
|
240
|
+
(`VerifyAnchor::UNAVAILABLE`: "restart this session to use Verify"; `SessionManager` checks `worker_has?` before it
|
|
241
|
+
delivers, on the features of the sidecar its client was made from), since an older worker would run it as a plain prompt. A session with no connected worker is not gated: the
|
|
242
|
+
worker that starts for it is current. The participant page's session summary carries `verify_unavailable`
|
|
243
|
+
(`VerifyAnchor.worker_unavailable?`, from the worker's features; `participant_review.rb`), and the page disables
|
|
244
|
+
Verify and Verify fresh with a "restart this session" hint.
|
|
233
245
|
**The owner's input during a review turn queues.** While a participant's turn runs, the owner's input never merges
|
|
234
246
|
into it, from any client (the web, an attached TUI, `chi send` without `--queue`): the Bridge's `POST /turn` makes it
|
|
235
247
|
`delivery: queue` whatever it asked, so it never steers or cuts (`TurnAccumulator#participant_turn?`, under the event
|
|
@@ -264,7 +276,9 @@ queued, as `--turn T` does.
|
|
|
264
276
|
**The tool calls.** In a review session `Guardrails::ReviewBoundary` denies a participant's turn everything but
|
|
265
277
|
reads of the project ([guardrails](../guardrails.md#a-participants-review-boundary)). In a working session
|
|
266
278
|
`Guardrails::PairBoundary` takes its seat: the owner's checks and rules decide the rest, as for the owner's turns
|
|
267
|
-
([guardrails](../guardrails.md#a-participants-pair-boundary)).
|
|
279
|
+
([guardrails](../guardrails.md#a-participants-pair-boundary)). Both judge a path every way it resolves
|
|
280
|
+
(`ProtectedPaths.reals`: a `..` after a symlinked folder climbs out of its target, as the read opens it;
|
|
281
|
+
[guardrails](../guardrails.md#how-paths-resolve)). Approvals and questions are owner-only: either
|
|
268
282
|
boundary denies the model's own question, and `/answer` is an owner route. A pair turn's ask reaches the owner's card
|
|
269
283
|
naming who asks (`approval.author`, `author_name`; `Guardrails::Approval::PARTICIPANT_HEADER`) with Allow once as its
|
|
270
284
|
only scope, and System 1 never judges it (`GuardrailWiring#system_one_judge`). A grant without `--identity` (a LAN
|
data/docs/internals/web-push.md
CHANGED
|
@@ -202,7 +202,10 @@ yet). Results: `:ok` stamps the device's
|
|
|
202
202
|
`last_ok_at`; `:gone` removes it; `:failed` keeps its code in `last_error` and logs `push_failed`; `:bad_jwt` logs
|
|
203
203
|
one `push_bad_jwt` warning (on chi web's terminal too) and stops all pushes until chi web restarts. Log lines carry
|
|
204
204
|
`device:` (the short id), `code:`, `ms:`, `kind:`: never the endpoint, keys or text. `#stop` drops pending holds and
|
|
205
|
-
joins with a timeout; `Server.start`'s ensure calls it.
|
|
205
|
+
joins with a timeout; `Server.start`'s ensure calls it. chi web's own restart (`restart_web`, `Server.reexec`) execs
|
|
206
|
+
without either: the shows still in their hold and the retries waiting in `@pending` / `@retries` go with the process,
|
|
207
|
+
unsent and not in `sent.json`. The exec'd chi web's seed brings back only what restart catch-up covers (a question
|
|
208
|
+
still open); a closed-meanwhile or non-question show held then is not pushed. Specs drive `#pump` with a moved clock
|
|
206
209
|
(`start(hub, sender: false)`), and one example runs the real thread with a client that blocks.
|
|
207
210
|
|
|
208
211
|
## The routes (`web/push_routes.rb`)
|
data/docs/sessions.md
CHANGED
|
@@ -21,7 +21,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
21
21
|
|
|
22
22
|
A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
|
|
23
23
|
|
|
24
|
-
**Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken, a later exit
|
|
24
|
+
**Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
|
|
25
25
|
|
|
26
26
|
**Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
|
|
27
27
|
|
|
@@ -88,7 +88,8 @@ module Samagotchi
|
|
|
88
88
|
def self.for_sidecar(session_id, sidecar, host: HOST, read_timeout: READ_TIMEOUT)
|
|
89
89
|
return nil unless sidecar
|
|
90
90
|
|
|
91
|
-
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout
|
|
91
|
+
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout,
|
|
92
|
+
features: sidecar.features)
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
# How often a wait for a new worker's Bridge looks: a missing sidecar is
|
|
@@ -111,6 +112,9 @@ module Samagotchi
|
|
|
111
112
|
end
|
|
112
113
|
|
|
113
114
|
attr_reader :session_id, :port, :host
|
|
115
|
+
# @return [Array<String>] what the worker's sidecar named (WorkerSidecar#features)
|
|
116
|
+
# as this client was made from it; empty for one made without a sidecar
|
|
117
|
+
attr_reader :features
|
|
114
118
|
|
|
115
119
|
# Which worker this client talks to, for EventStream: a rediscovered
|
|
116
120
|
# client with another key is a new worker. A Bridge's port, and a
|
|
@@ -122,7 +126,9 @@ module Samagotchi
|
|
|
122
126
|
# with every request (nil: a worker from before secrets, which asks for
|
|
123
127
|
# none); from the worker's sidecar (.for_sidecar), never typed
|
|
124
128
|
# @param read_timeout [Numeric] see READ_TIMEOUT
|
|
125
|
-
|
|
129
|
+
# @param features [Array<String>] the sidecar's (#features)
|
|
130
|
+
def initialize(session_id:, port:, secret:, host: HOST, read_timeout: READ_TIMEOUT, features: [])
|
|
131
|
+
@features = Array(features).freeze
|
|
126
132
|
@session_id = session_id
|
|
127
133
|
@port = port
|
|
128
134
|
@secret = secret
|
|
@@ -314,7 +314,7 @@ module Samagotchi
|
|
|
314
314
|
# one that passes every gate is allowed once without reaching them
|
|
315
315
|
# (docs/guardrails.md, "System 1").
|
|
316
316
|
judgment = begin
|
|
317
|
-
system_one_judge(payload)
|
|
317
|
+
system_one_judge(payload, targets: verdict.targets)
|
|
318
318
|
rescue LLM::RequestCancelled
|
|
319
319
|
return turn_stopped(verdict)
|
|
320
320
|
end
|
|
@@ -357,7 +357,7 @@ module Samagotchi
|
|
|
357
357
|
# host) means no line at all. A judgment that fails is logged with p nil,
|
|
358
358
|
# so the failures are in the log too.
|
|
359
359
|
# @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
|
|
360
|
-
def system_one_judge(payload)
|
|
360
|
+
def system_one_judge(payload, targets: nil)
|
|
361
361
|
return nil if Steer.participant_author?(turn_author)
|
|
362
362
|
|
|
363
363
|
client = @system_one.call
|
|
@@ -385,7 +385,7 @@ module Samagotchi
|
|
|
385
385
|
end
|
|
386
386
|
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
387
387
|
p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
|
|
388
|
-
auto = system_one_auto_allow?(client, approval, p, entries)
|
|
388
|
+
auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
|
|
389
389
|
decision = system_one_decision(client, auto)
|
|
390
390
|
|
|
391
391
|
id = log.judgment(session_id: @session_lookup.call&.id,
|
|
@@ -417,13 +417,14 @@ module Samagotchi
|
|
|
417
417
|
# the rule's record. Any one that fails leaves the ask to the user. The
|
|
418
418
|
# entries are the judgment log read once for this ask.
|
|
419
419
|
# @param entries [Array<Hash>] SystemOne::Log#entries
|
|
420
|
-
|
|
420
|
+
# @param targets [Guardrails::Targets, nil] the call's, for its env:
|
|
421
|
+
def system_one_auto_allow?(client, approval, p, entries, targets: nil)
|
|
421
422
|
return false unless client.mode == "on"
|
|
422
423
|
|
|
423
424
|
rule = approval[:rule].to_s
|
|
424
425
|
return false if rule.empty?
|
|
425
426
|
return false unless system_one_rules.include?(rule)
|
|
426
|
-
return false if SystemOne::SelfProtect.refuse?(approval)
|
|
427
|
+
return false if SystemOne::SelfProtect.refuse?(approval, targets: targets)
|
|
427
428
|
|
|
428
429
|
threshold = Config.get("guardrails.system_one.threshold").to_f
|
|
429
430
|
return false if p.nil? || p.to_f < threshold
|