Four call sites fork a second agent with skip_memory=True, which leaves the
child with _memory_manager = None: a fork is not the user, and its scratch
transcript is not the user's memory. With compression.checkpoint_required
armed that saving turns into a kill. The gate is armed globally per agent
instance from config, not from whether the agent has a provider, so the child
arms the same gate its parent did and then meets its first compaction with
nothing to checkpoint against. It raises CompressionCheckpointUnavailable --
"no active provider implements checkpoint API v2" -- and the delegate turn
dies with status=error and a null summary while the parent runs on. The gate
is not bypassed there; it is armed against a provider that was never loaded.
- The four sites (delegate_tool, curator, batch_runner, feishu_comment) pass
skip_memory=not checkpoint_required_from_config(). Gate off is the default
and stays byte-identical: skip_memory=True, no provider init, no hook. Same
shape and the same reason as the two gateway disposable-agent sites that
already read the key this way (run_turn_hygiene.py,
slash_commands_session.py). delegate_tool reads it inside the delegated
profile scope, where the child reads it.
- The detached review and /btw forks (build_cache_parity_fork) are not
touched: they persist nothing, and _pre_compress_memory_context already
exempts exactly that shape (decabd79f1), so the gate never fails them.
- checkpoint_required_from_config() in conversation_compression_checkpoint,
next to the gate's init refusals. One resolver rather than four parses, and
the call sites need it before an agent exists:
agent.compression_checkpoint_required is assigned inside the child's own
init, and the parent's says nothing about the child. It reads
load_config_readonly() -- the call init_agent makes to arm the gate on that
child, so parent and child answer from one source and a fork is given a
provider precisely when the child would refuse to compact without one. An
unreadable config reads as "not armed", matching init_agent's own except
around the same load.
- New memory_agent_context kwarg on AIAgent/init_agent, handed to the
provider's initialize() as agent_context; unset, the platform rule decides
as before (cron/subagent, else "primary"). The platform rule alone cannot
scope these forks: the curator runs as platform "curator", the batch runner
takes its platform from the caller's config, and the Feishu responder sets
none, so each would file its transcript as the user's own memory in every
provider that scopes writes by that key. All four pass "subagent" -- the
value memory_provider.py documents and supermemory gates on. A fresh
per-site string ("curator", "batch", ...) would drop out of every
provider's non-primary membership test and re-enable writes the provider
had deliberately disabled, which is a fail-open on the one path this commit
opens. platform still separates the shapes.
Costs, taken deliberately rather than hidden: one provider initialize() per
fork (a round trip for a network-backed store, on a path that made none), and
fork transcripts reaching the archive as checkpoints. Both are named at the
call site and in the contract doc, where a provider author reads them.
Ten tests (tests/agent/test_fork_memory_under_checkpoint_gate.py). The
four site probes drive the real spawn functions with the smallest parent stub
they accept and record the child constructor kwargs -- red at all four against
the previous commit with the literal skip_memory=True. The gate-off probes are
the control in the other direction: without them "inherit the provider" could
have shipped as "always inherit". The recorder aborts with a BaseException
because the sites wrap the spawn in except Exception and would have swallowed
an ordinary failure into a green test. The provider-initialize test drives a
real AIAgent init, the only way to observe the keyword at initialize().
Live probe (on the August base), isolated HERMES_HOME with a stub API-v2
provider and the gate armed: before, the child aborts with failure_class
"exception:CompressionCheckpointUnavailable" and delegate_task returns
status=error, summary=null. After, the child initializes the provider
(agent_context "subagent", platform "subagent"), its own session fires
on_pre_compress, no CompressionCheckpointUnavailable appears, and the parent
returns the subagent's answer.
(cherry picked from commit 31af59c54ebff4c6c1861961111700b8e42dba76)
(cherry picked from commit 9bf3feaf8c98dff6e90eadf0f533204adc150a8c)
Review round on the snapshot safety PR:
- A full rollback to a checkpoint taken before the store was excluded checked
the captured store copy out over the live one, rewinding refs and losing
newer history. The store pathspecs are now excluded from that checkout and
from a pathspec-selected restore.
- Entries the old tree carries but this build excludes (browser profiles) are
dropped from the index BEFORE git add -A, so a locked profile file is never
re-read, and a failed cleanup stops the snapshot instead of retrying the
same failure each turn. rm --cached needs -f against a re-seeded index.
- The breadth-cap walk also prunes directories matching a slash-less pattern
such as *.log, as git does.
- A directory that contains $HOME (/home, /Users) is as broad as $HOME itself:
the snapshot guard now refuses every ancestor of home, not only / and home.
- Hermes' own browser user-data dirs (chrome_cdp_profile/, agent-browser-chrome-*/)
are excluded: their locked SQLite files froze git add -A on Windows and they
are never project state.
- A store created by an older build gets its info/exclude refreshed on open, and
each snapshot drops index entries the excludes now cover, because add -A keeps
paths it already tracks even after they are excluded.
Co-authored-by: Thomasp23 <100884186+Thomasp23@users.noreply.github.com>
An exclude pathspec stops add -A from staging new store paths but leaves
entries already in the index. _take re-seeds the index from the last
checkpoint, so a project snapshotted before the exclusion kept the store
in every later snapshot. Unstage the base after add in _take, and exclude
it from the diff/restore-plan comparisons so an old polluted checkpoint
never reports the store as a change.
Also correct the helper docstring: info/exclude can name the store; the
pathspec is preferred because it works at any depth without rewriting
the store's exclude file.
When the checkpointed working directory is HERMES_HOME (or a profile home
under it, the documented way to give a profile a stable terminal.cwd), the
shadow store at <home>/checkpoints/store sits inside the tree being
snapshotted. The shared info/exclude holds relative patterns and cannot
name the store, so every snapshot staged the previous snapshot's packfiles
and the per-project index. The store grew by about its own size per
checkpoint, `git add -A` eventually hit the timeout, and the timed-out add
left indexes/<hash>.lock behind, wedging the project.
_stage_all_args() adds `:(exclude)<base>` and `:(exclude).<base>.lock`
pathspecs to the add when the checkpoint base is inside the working
directory, and the three `add -A` sites (_take, _diff, _safe_restore_plan)
use it. A store outside the working directory keeps the plain add.
Delta review of b90c705 found that a cmd /c script carrying its own
escaped quotes was missed, which 954217f caught:
cmd /c "wmic shadowcopy where \"ID=1\" delete" (also /node:\"srv\" and
a trailing && echo \"done\"). In the normalized variant those \" become
plain quotes, and the start-title alternative in the switch-stripping
regex swallowed "wmic shadowcopy where " as a window title.
- The quoted-title skip now applies to start only.
- Option values may be a quoted string (ssh -o "A B" host, -ep "x"),
and a bare -- ends the options (wsl --, ssh host --).
- -a is a value-taking option, so xargs -a wmic echo ... stays quiet.
- A carrier script that opens with a quote is read both as one quoted
script and as a quoted program word, so "wmic" shadowcopy delete
behind cmd /c, xargs or ssh host is caught.
Still unflagged, as on main: start "" "wmic" ... and a cmd /c script
whose program is an escaped-quoted full path. start "wmic shadowcopy
delete" fails closed (prompts) as on c61aa4c. The new tests fail on
b90c705 and pass here.
Delta review of 954217f found wmic forms the original c61aa4c rule caught
that the command-word matcher missed:
- carrier switches other than /c and -opts: cmd /s /c, cmd /q /c,
cmd /d /c, start /b, start "title", powershell -ExecutionPolicy Bypass
- carriers not in the list: start, call, doas, wsl, xargs, ssh host
- wmic after another command inside the carrier script:
cmd /c "echo hi & wmic shadowcopy delete", powershell "& wmic ..."
The matcher now strips the carrier's switches (with values only for
options that take one), unquotes its script, and walks that script's
command words recursively (depth capped at 3), so each nested command
is judged like a top-level one. wmic shadowcopy list && rm x -- delete
and cmd /c echo wmic shadowcopy delete stay quiet; 4000 repeats of the
command-position and prose forms scan in under 1 s.
The new tests fail on 954217f and pass here.
Delta review of b47e534 found:
- A path-qualified or wrapped wmic (C:\Windows\System32\wbem\WMIC.exe,
/usr/bin/wmic, sudo/nohup/env wmic, powershell wmic,
cmd /c "C:\...\WMIC.exe ...") no longer matched the anchored regex.
The rule is now a small matcher, like the pipe-to-shell one: it walks
the command-position words, accepts wmic directly or as the script of
cmd /c / pwsh / powershell, and looks for shadowcopy then delete in
that command only. Quoted prose and wmic shadowcopy list stay quiet,
and 800 prose repeats scan in 0.13 s.
- The stdin-pipe check for pwsh re-searched the whole prefix at every
pwsh word (pwsh -v | x4000: 15 s). It now checks only the stage piped
into that pwsh: 2 s, all of it main's existing encoded-command rule.
- ssh option tokens could cross ; and newlines, so "; ssh -x" x3000 took
6.7 s. Tokens now stop at ;&| and the ssh prefix never spans a line:
0.29 s / 0.34 s.
The new tests fail on b47e534 and pass here.
Review of c61aa4c found three regressions in rules this PR adds:
- The wmic shadowcopy rule matched anywhere with two unbounded gaps, so
400 repeats of "wmic shadowcopy" in quoted prose took 3 s (800: 24 s).
It now anchors wmic at command position (or cmd /c) and stays inside
one command.
- The ssh and wrapper alternatives of the PowerShell command-position
prefix restarted at every ssh/sudo word, which made a run of them
quadratic (ssh -x x2000: 1.5 s, x4000: 5.8 s). They now start only at
command position: 0.08 s and 0.16 s.
- A launcher word anywhere in the command made every quoted pipe-alias
count as executed, so echo ssh "gci | ri -r" and
pwsh --version && echo "gci | ri -r" prompted. The alias must now sit
in the launcher's own command, or be piped into a pwsh reading stdin.
ssh host "...", cmd /c, iex, echo "..." | pwsh - and the nested-shell
positives still prompt. The new tests fail on c61aa4c (the file times
out at 300 s).
After the rebase, `curl x | env -u a=1 ... cat` made the new
command-position prefix try every split of `-u a=1` as a valued flag
versus a bare flag plus an assignment, so the scan grew
exponentially. main's test_wrapper_chain_does_not_backtrack went red
(25 s against a 3 s bound). An option value can no longer contain `=`,
which leaves one parse per token: 40 repeats now scan in 4 ms, and
`sudo -u bob`, `env -i`, `env FOO=1`, `timeout 5` still reach pwsh.
Delta review of a6664b8:
- The pipe-alias rule's quote mask hid deletes whose quoted text is executed
(`ssh host "gci | ri -r -fo"`, `cmd /c "..."`, `iex "..."`, `echo "..." | pwsh -`).
The mask now applies only when no unquoted launcher or shell carrier runs the
quoted text, so `Write-Host "run gci | ri -r to clean"` stays quiet.
- The command-position anchor missed xargs, doas, time, timeout N, nice, command,
busybox, wsl, `sudo -u bob`, `env FOO=1`, `ssh -o X=1 host`, `ssh -p 22 host`, and a
quoted ssh remote command (`ssh host "pwsh -c 'gci | ri'"`).
- The optional path prefix excludes `(){}` and backticks, so a run of them is no
longer rescanned from every position (`'{'*100000`: 36 s -> 6 ms for the rule).
- Greptile: the script span runs to the OUTER quote, so a nested
`'C:\work dir'` no longer ends it before `; rd build -r`.
Review findings on f96e656d: the alias-after-pipe rule matched hyphenated
words (`| ri-tool -fo`, `| rm-helper -r`) and quoted prose (`Write-Host
"run gci | ri -r"`), and the later-in-the-script rule matched any
pwsh/powershell token followed by any quote (`pwsh -v; jq ".a | del(.b)"`,
`cd ~/powershell-scripts && git grep "foo|rd"`, `echo powershell "gci | ri"`).
The verb now ends with (?![\w-]); the later-in-script rule requires
powershell/pwsh at command position (line start, a separator, or behind
sudo/env/cmd /c/ssh <host>, optional path prefix) with the quote opening its
first non-flag argument; the alias-after-pipe rule scans the quote-masked
variant, since a quoted script PowerShell runs is the anchored rule's job.
Dropping the unanchored [^\n"']* scan also takes the rule's own cost on
100 KB adversarial input from 1.7-2.6 s to ~6 ms, back to main's totals.
The piped rule from the first commit still required -r/-fo after the verb, so
`powershell -NoProfile "gci C:\data -File -Recurse | Remove-Item"` (deletes every
file, no switch) and `"cd C:\x; rd build -r"` / `"gci | % { ri $_ -r }"` ran with no
prompt. The command-position rule for the same carrier already gates a bare delete
verb with no switch; the later-in-script rule now matches the verb after `|`, `;`,
`{` or `(` inside the quoted script with the same no-switch bar. The separator must
be inside the quotes, so an outer-shell compound is left to the rm rules.
PowerShell resolves a unique parameter prefix: on Stop-Process and Stop-Service
-Force is the only F parameter, so `-f` is -Force (checked on pwsh 7.4 with
Get-Command parameter metadata). On Remove-Item / Remove-ItemProperty `-f` is
ambiguous with -Filter and refuses to bind, so those rules keep `-fo`.
The Windows approval tier only matched full parameter names, so a
recursive forced delete spelled `Remove-Item -r -fo` (or `ri`/`rm`/`del`
after a pipe inside a positional script) ran with no approval prompt.
PowerShell accepts any unambiguous parameter prefix, which the reporter
verified deletes for real on Windows 11 PowerShell 5.1.
- Accept -Recurse/-Force prefixes (-r, -rec, -fo, -forc, ...) on the
Remove-Item, Stop-Process, Remove-ItemProperty and Stop-Service rules
- Gate Remove-Item aliases (ri, rm, del, erase, rd, rmdir) that follow a
pipe with a destructive flag, closing the positional-script gap where
the first command is not itself a delete verb
- Add the wmic shadowcopy delete twin of the vssadmin rule
Windows PowerShell runs a positional argument as a command, so
`powershell -NoProfile "gci C:\data | ri -r -fo"` deleted a tree with no approval
prompt while the spelled-out form was gated (#144024). Two gaps:
- the prefixed rule anchors the delete verb to command position, which a pipeline
defeats, so `gci … | ri` walked past it;
- PowerShell resolves any unambiguous parameter prefix and the short aliases, so
`-r` (-Recurse), `-fo` (-Force), `ri` (Remove-Item) and `Stop-Process -Fo` never
matched the spelled-out switch rules.
The new rule requires a destructive switch after a piped delete verb, so read-only
pipelines (`gci | measure`, `Get-Content | Select-String`) stay quiet, and `-f` alone
still does not count as -Force because it is ambiguous with -Filter.
Review findings "Background batches lose siblings on a foreground interrupt" and
"A sibling skipped by the gate is never detached or closed":
- _run_gated now takes honor_parent_interrupt and passes parent=None for a
detached (background) unit, so stopping the parent's foreground turn no
longer cancels siblings still waiting on the prompt-cache warm-up.
- A child the gate skips never reaches _run_single_child's finally, so it is
detached from parent._active_children and closed before its entry is built.
The same teardown runs for futures the parent-interrupt path cancels while
they were still queued behind max_concurrent_children (never started).
- Document that the leader is always task 0 (docs + code comment).
Sibling subagents in one tasks batch send byte-identical tools[] and system
prompt scaffold; only the goal (their first user turn) differs, plus the system
prompt tail when tasks carry different context. Started together, every
child's first request misses the provider prompt cache and pays the full prefix.
delegation.prefix_stagger_seconds (default 0 = off) now holds the siblings on
the first child's route until that child's first response starts streaming
(its request has written the cache), it finishes, or the cap elapses (max 60 s).
The wait ends as soon as the cache is readable, so opting in costs about one
time-to-first-token instead of a fixed (N-1)*S ramp. Routes Hermes does not
prompt-cache and siblings on a different model start without waiting, and a
sibling stopped during the wait is reported interrupted without starting.
Rebuilt on the split delegate_tool_* layout: the gate is its own sibling
(tools/delegate_tool_prefix_gate.py); dispatch only wraps run_child.
Three defects an adversarial review found in the previous masking round:
1. Interpreter latch: any later arg whose basename matched the (over-broad) interpreter pattern
(ssh, publish, a --mode r value) made every following -c/-e/-p count as inline code, so the
named secret after it was shown. Interpreter status is now argv[0] only (or the effective
command resolved past wrappers), with a precise interpreter name list; a secret behind
docker run --name ssh -e GITHUB_TOKEN=x, node server.js -e API_KEY=x, etc. masks again.
2. The "plain value only" rule showed real passwords containing shell metacharacters
(--password=Pa$$w0rd!, -e DB_PASSWORD=x$y, ?token=a(b), Authorization: Bearer ab{c}).
Named-secret values (flags, -e/--env, headers, URL userinfo/query) are masked whatever they
hold; the plain-value check stays only on a bare KEY=VALUE, where it tells an interpreter's
inline code from an env var.
3. Readback: a control/invisible arg rendered unquoted, so shlex read it wrong and a real newline
collided with the literal text \n. Such args are now bash $'...' words (escapes doubled), so a
real newline is distinct from \n and no hidden code point is shown raw.
Pre-existing, left per the review: a userinfo password holding a raw / # ? (an invalid URL) is
shown, because the authority splits on the first /.
Bear P1 "A secret-looking assignment can hide the program the user is being
asked to run": a KEY=VALUE, secret-flag, header or URL-userinfo value is now
masked only when it is plain data (no line break, shell metacharacter,
bracket or quote), and the program given to an interpreter's inline flag
(sh -c, python -c, node -e/--eval, perl -e, ...) is never masked. `sh -c
'GITHUB_TOKEN=x; curl ...'`, `python -c GITHUB_TOKEN=print(731)` and
`-H 'Authorization:\n/bin/true'` now show whole.
unsupportedpastels MAJOR "significant spaces and empty arguments are lost":
each argv word (and cwd) is shell-quoted when whitespace, a quote, backslash,
shell metacharacter or emptiness would change how it reads, and the line is no
longer whitespace-collapsed, so shlex.split(line) == argv. The card keeps the
spaces with whitespace-pre-wrap.
Second security re-review: `--token -c x` hid the short flag that runs (P1: hiding execution is
worse than showing a secret), and `-eGITHUB_TOKEN=...` / java `-Dapi.token=...` showed the value
because any arg starting with `-` skipped the KEY=VALUE check (P2).
The consent card exists so the user approves what will run, so hiding execution is worse than
showing a secret. Drop the 400-char middle cut, the shape-based blob/opaque-path/short-flag masking
and the shape-based redactor pass; mask only values whose own name says they are secret (long
flags, KEY=VALUE, auth/cookie/api-key headers, userinfo password, secret-named query values).
Escape combining marks and U+FFFC. Remove the duplicated constants and unused _mask_url_path.
The manage_connections MCP card asked the user to Install / Enable / Authorize
a server by name only. Each MCP target now carries a backend-built `launch`
line ({kind: stdio|remote, target}) in connection.request: the catalog
manifest's transport for install, the configured mcp_servers entry for
enable/authorize. The card renders it under the row as plain text
("Runs: npx -y ...", "Connects to: https://...") until the row resolves.
The line is scrubbed in the backend (tools/connectors/mcp_launch.py): values
of secret-named flags (--api-key=..., --token ...), credential query params
and URL userinfo, long opaque URL path segments, then the standard egress
redaction; control characters are flattened and the text is capped at 400
chars after scrubbing. The env map and headers are never part of it. The
model's tool result drops the field.
i18n: launchCommand / launchRemote in all nine locales, _keys.desktop.json
regenerated. Gateway contract regenerated.
Discord already binds its approval view to the tools.approval request id
(4f86e97791, 50f57a131f) and the runner passes ExecApprovalPrompt.request_id
since cfed2ca71f. Every other native-button surface still resolved by
session key alone, FIFO: when the request a card was rendered for had
already settled (timeout, typed /approve), a press on that card resolved
the NEXT pending approval in the session, one the user had not seen.
Telegram, Slack, Feishu, Matrix, WhatsApp Cloud, Teams and the relay
adapter now remember prompt.request_id with the card and resolve through
tools.approval.resolve_gateway_approval_card, which targets only that
request. A card sent without an id keeps the FIFO behaviour.
Review findings (BearHuddleston): `curl -f x || sudo bash fallback.sh` prompted where main stayed
quiet (the widened scan read `||` as a pipe), and `| setsid -w bash` / `| ionice -c3 bash` ran a
piped script unflagged. The widened sink now takes its own operator regex that skips `||`; main's
raw bare-shell sink keeps the old operator, so `|| bash` keeps main's verdict. setsid and ionice
join the launcher table (ionice with its valued flags).
Probe over 3,063 generated commands vs the previous head: 267 verdicts go quiet (all `|| <launcher>
<shell>` or-lists), 360 become flagged (all setsid/ionice); nothing else moves.
Review finding (BearHuddleston): DANGEROUS_PATTERNS_COMPILED swaps in _PipeRemoteToShellMatcher
for this description, so the row string only feeds the legacy approval-key alias.
A backtick ending the shell token flagged README heredocs and PR bodies that quote an install
one-liner in backticks. Main never caught backtick substitution either, so the ending is dropped.
Second re-review follow-up.
Security re-review follow-up on 15d56a4e20f:
- P1: masking quoted text for the whole rule hid fetch-to-shell payloads
handed to carriers outside the eval/sh/bash list (su -c, ssh host, xargs
sh -c, docker exec, find -exec ...). The matcher now runs main's original
bare-shell sink on the RAW text first, so nothing main flagged is lost;
only the widened launcher/path/quoted sink scans quote-masked text.
- P2: `sudo -pl bash` / `-ul` / `-Dl` read as list mode; the `l` there is the
value of -p/-u/-D and bash runs. Lookup mode now needs `l` as a bare flag.
- P2: `env -S STRING` runs STRING; -S is no longer a value-swallowing flag.
`env -a NAME` (argv0) added; timeout durations accept infinity, 1e3, .5m.
- P3: a backtick right after the shell name ends the token.
Review follow-up on the widened pipe-remote-to-shell rule:
- Quoted prose (commit messages, echo text, PR titles) is masked before the
sink check, like the other prose-sensitive rules; shell carriers (bash -c,
eval) still scan raw because their quoted argument is code. Lookup/list
modes (command -v, sudo -l / --list) are not execution.
- Each launcher gets its own table of flags that take a separate value, so a
bare flag (time -p, command -p, exec -a x, timeout -s KILL, stdbuf -o L,
sudo -iu root) no longer swallows the shell name. busybox ash is covered.
- The rule is a small matcher: find the first curl/wget, then try the sink
only at each later pipe or process substitution. The old leading `.*`
rescanned the tail from every fetch word (quadratic): 28 KB of repeated
fetch pipes took 4.8 s on main, 0.68 s now.
The pipe-remote-to-shell rule only matched a bare shell name right after the
pipe, so `curl ... | sudo bash`, `| /bin/sh`, `| env -i zsh`, `| timeout 60 bash`,
`|& bash`, `| busybox sh`, `| tee >(sh)`, `| bash.exe` and `| "/usr/bin/fish"`
ran without approval.
The sink now allows launcher words (sudo/doas/env/exec/command/nohup/nice/time/
timeout/stdbuf/busybox, path-qualified, with options, option values and
VAR=value tokens) before a path-qualified, quoted or .exe shell, plus `|&` and
`>(...)`. Launcher tokens use disjoint classes inside atomic/possessive groups,
so the scan is linear and `sudo -u bash cat` reads `bash` as the user. Also
flags `source <(curl ...)` / `. <(curl ...)` at command position.
Freeform requests mix timing with work ("every morning at 9, check X").
When the model copies that cadence into the stored prompt, the run-time
agent re-reads it as an instruction (waits, re-schedules, checks the
clock). One clause in the prompt field description now says the prompt
holds the work only and timing goes in 'schedule'.
Schema growth: +10 o200k tokens on cronjob_manage (1814 -> 1824).
_load_mcp_config interpolated only config.yaml entries and merged portable
(plugin mcp.json) servers raw. The first connect re-renders, so it worked,
but every transport rebuild calls _refresh_remote_config, which compares the
running (rendered) headers with the raw ${VAR} from the load, sees a changed
definition, adopts the literal placeholder and parks the server with
"${VAR} in url/headers is not set". A plugin remote server with header auth
therefore never survived a retry or reconnect.
Finding: fh.read(cap + 1) reserves 250 MiB for every host read.
BufferedReader.read(n) preallocates n bytes, so a 10 KB screenshot raised
MemoryError under RLIMIT_AS / strict overcommit. The first read now asks for
st_size + 1; when st_size under-reports, the rest streams in 1 MiB chunks and
stops past the cap.
Finding: the 250 MiB host budget also reaches cached videos, which are always
refused later. Videos are never downscaled and video_analyze refuses over
50 MB of base64, so a permitted=('video',) host read now keeps _MAX_INGEST_BYTES
and a 50-250 MiB cached video fails at the stat instead of being read, copied
and base64-encoded before the same refusal.
Review finding (P2, _resolve_container_fallback): raising the shared ingest
cap to 250 MiB also raised the sandbox exec-read, which returns the file as
base64 on stdout and peaks near 4x the file in this process (200 MiB file ->
+800 MiB RSS). _MAX_INGEST_BYTES is back to main's 50 MiB for the sandbox and
data: paths; host-file reads (bounded during the read) use the new
_MAX_HOST_READ_BYTES = 250 MiB, which _finalize also uses as the ceiling.
Also fixes the gateway/media_fetch.py comment (P3) that claimed its 50 MB
fetch cap mirrors the ingest cap.
The host-side read in resolve_image_source used Path.read_bytes(), which
buffers the whole file before _finalize checks the ingest cap, so pointing
vision_analyze at a multi-GB file (or one whose st_size under-reports)
allocated all of it first. _read_host_bytes_bounded() rejects an honestly
oversized file from stat without opening it, and otherwise reads at most
cap+1 bytes, the same bound the in-sandbox exec-read already applies with
head -c.
The local/sandbox/data: ingest cap moves from 50 MiB to 250 MiB (binary
MiB, matching every other *_BYTES cap in tools/: N * 1024 * 1024). HTTP
downloads keep their own 50 MiB stream cap in vision_tools.
Review finding (still open): the label reread the permanent store after the
acceptance check, so a grant revoked in between was reported as a session
answer. _approval_scope reads both stores once under the lock; the guards
decide and label from that one snapshot.