6811 Commits
Author SHA1 Message Date
Jan-Stefan Janetzky f02ba87b8c feat(agent): fork sub-agents inherit the memory provider under checkpoint_required
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)
2026-11-05 19:55:33 -06:00
IAvecilla 1e81ee57e3 fix(webapp): voice relays through the host, and a created profile is served right away 2026-11-05 10:15:02 -08:00
Teknium 8283794aa4 fix(checkpoints): rollback never rewinds the store; excludes drop before staging
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.
2026-11-05 09:24:23 -08:00
TekniumandThomasp23 201326cf58 fix(checkpoints): never snapshot home's parents or Hermes browser profiles
- 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>
2026-11-05 09:24:23 -08:00
Richard Biely 3017252df0 test(checkpoints): cover nested excludes and symlinks 2026-11-05 09:24:23 -08:00
Richard Biely af70d77be2 fix(checkpoints): ignore excluded paths in size guard 2026-11-05 09:24:23 -08:00
andyleo41-bot 11c48ab334 Handle tracked files growing past checkpoint cap 2026-11-05 09:24:23 -08:00
andyleo41-bot 4915941958 Fix oversized checkpoint prefiltering 2026-11-05 09:24:23 -08:00
skyeyesec333 18a8ad0bd6 fix(checkpoints): satisfy current lint before review 2026-11-05 09:24:23 -08:00
BALEXANDROS f2e0f6ed9d fix(checkpoints): repair stores already staged into their own snapshots
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.
2026-11-05 09:24:23 -08:00
BALEXANDROS 45bfd0ea40 fix(checkpoints): never stage the checkpoint store into its own snapshot
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.
2026-11-05 09:24:23 -08:00
Teknium 87ed66ee48 fix(approval): keep wmic visible through escaped quotes and quoted program words
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.
2026-11-05 02:13:06 -08:00
Teknium 267bf8faf9 fix(approval): walk carrier scripts for wmic, not just their first word
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.
2026-11-05 02:13:06 -08:00
Teknium 556121f08c fix(approval): match wmic by its command word; keep the launcher scans linear
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.
2026-11-05 02:13:06 -08:00
Teknium b3621441ee fix(approval): bound the WMIC and launcher scans and tie quoted deletes to their launcher
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).
2026-11-05 02:13:06 -08:00
Teknium 327fa56f59 fix(approval): launcher options in the PowerShell rule cannot backtrack
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.
2026-11-05 02:13:06 -08:00
Teknium cf4dfba6f8 fix(approval): gate PowerShell deletes in quoted scripts a launcher runs, behind launcher options, and past nested quotes
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`.
2026-11-05 02:13:06 -08:00
Teknium f33860f635 fix(approval): anchor the PowerShell later-in-script rule to the executable's first argument
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.
2026-11-05 02:13:06 -08:00
Teknium 3d4b6cfb93 fix(approval): gate a PowerShell delete anywhere in the script, and -f on Stop-Process/Stop-Service
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`.
2026-11-05 02:13:06 -08:00
liuhao1024 c0cf987ff0 fix(tools): gate PowerShell deletes passed with abbreviated parameters or after a pipe
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
2026-11-05 02:13:06 -08:00
Baophan00 7fe65ea5c8 fix(approval): gate PowerShell deletes behind a pipeline and with abbreviated switches
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.
2026-11-05 02:13:06 -08:00
Teknium c58e6de692 docs(delegation): prefix_stagger_seconds is a cap on the warm-up wait, not a stagger 2026-11-04 21:50:30 -08:00
Teknium fbec17e9e2 fix(delegation): background siblings outlive a parent stop; a gate-skipped child is released
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).
2026-11-04 21:50:30 -08:00
Teknium 6d0686ba3c feat(delegation): opt-in prompt-cache warm-up gate for batch subagents
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.
2026-11-04 21:50:30 -08:00
Teknium b63eb8bf5a fix(mcp): consent card never hides inline code and masks secrets with metacharacters
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 /.
2026-11-04 21:44:24 -08:00
Teknium 6858e9a386 fix(mcp): consent line never masks inline code and reads back as the exact argv
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.
2026-11-04 21:44:24 -08:00
Teknium 14f74b3f89 fix(mcp): consent line never masks a flag after a secret flag; attached KEY=VALUE secrets masked
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).
2026-11-04 21:44:24 -08:00
Teknium b9ca214c0a fix(connectors): MCP launch line masks only secret-named values and is never cut
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.
2026-11-04 21:44:24 -08:00
Teknium a67f1d31e0 fix(desktop): MCP consent line wraps in full; an overlong line drops its middle, never its tail 2026-11-04 21:44:24 -08:00
Teknium f24a54c3d7 feat(desktop): MCP consent line names the env vars and working directory a stdio server adds; a string args value is one argument 2026-11-04 21:44:24 -08:00
Teknium cb452bdd47 fix(connectors): MCP launch line masks short/attached secret flags, header args, key blobs and URL query/fragment values 2026-11-04 21:44:24 -08:00
Teknium da0c262a77 fix(connectors): MCP launch line shows bidi, zero-width and other format characters as escapes 2026-11-04 21:44:24 -08:00
Teknium 728d930d18 fix(connectors): quote MCP launch args that contain whitespace
A joined command line read `node /srv/my file.js` as two arguments. Quote
any argument with whitespace (shlex) so the consent line is unambiguous.
2026-11-04 21:44:24 -08:00
Teknium 3dbb1303fe feat(desktop): show what an MCP consent row runs or contacts before approval
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.
2026-11-04 21:44:24 -08:00
Teknium 5ab58da1f6 fix(gateway): a stale approval card no longer approves the next request
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.
2026-11-04 21:40:32 -08:00
Teknium 6602031910 fix(approval): or-lists are not pipes for the widened sink; setsid and ionice are launchers
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.
2026-11-04 21:36:54 -08:00
Teknium bd88ed5170 docs(approval): mark the pipe-to-shell table row as uncompiled
Review finding (BearHuddleston): DANGEROUS_PATTERNS_COMPILED swaps in _PipeRemoteToShellMatcher
for this description, so the row string only feeds the legacy approval-key alias.
2026-11-04 21:36:54 -08:00
Teknium 7b0ac910c5 fix(approval): markdown inline code naming a fetch-to-shell pipe stays quiet
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.
2026-11-04 21:36:54 -08:00
Teknium b7628b4abf fix(approval): pipe-to-shell keeps every case main caught; sudo -pl, env -S, timeout infinity
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.
2026-11-04 21:36:54 -08:00
Teknium 27cb49e144 fix(approval): pipe-to-shell rule ignores quoted text, reads each launcher's own flags, scans in linear time
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.
2026-11-04 21:36:54 -08:00
Teknium d95b5f9c02 fix(approval): flag remote content piped to a wrapped or path-qualified shell
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.
2026-11-04 21:36:54 -08:00
Teknium ce1370430e fix(cron): scope prompt clause to cadence and cover update 2026-11-04 21:32:20 -08:00
Teknium 8a900e6715 fix(cron): keep scheduling cadence out of cron job prompts
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).
2026-11-04 21:32:20 -08:00
Teknium ca14c438e4 fix(mcp): an unscoped multiplex load keeps plugin servers after a ${VAR}-templated one 2026-11-04 21:30:46 -08:00
Teknium 8f7a27a426 fix(mcp): render ${VAR} in plugin-provided MCP servers like native ones
_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.
2026-11-04 21:30:46 -08:00
Teknium b5d859515c fix(vision): size the host read to the file, not the 250 MiB cap
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.
2026-11-04 21:29:42 -08:00
Teknium 96ba809c3a fix(vision): keep cached video host reads on the 50 MiB ingest 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.
2026-11-04 21:29:42 -08:00
Teknium 82a9ef60ef fix(vision): keep the sandbox exec-read at 50 MiB; only host reads get 250 MiB
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.
2026-11-04 21:29:42 -08:00
Teknium 0b8a4da7d2 fix(vision): bound local image reads during the read; ingest cap 250 MiB
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.
2026-11-04 21:29:42 -08:00
Teknium f8950a11ed fix(approval): label provenance from the same store read the check accepted
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.
2026-11-04 21:28:26 -08:00