Repository navigation
feat(sidecar,shell): HGW runtime stack #80–#84 (project write policy, project_create, 24-step turns, auto-approve, git safety) - #84
Conversation
Owner decision 2026-10-15 (HGW (2)). Before this change any shell_run
output, even an approved `node -v`, tainted the session, and every later
command and sidecar-hosted write was declined for the rest of the
conversation ("which this tool's host cannot ask for"), with no way to
clear it.
Now, in a session opened with hicAware: true:
- a tainted effectful sidecar call (fs_*, file_write, sheet_write,
toolchain tools, capsules) waits on a new per-session action card
(GET /sessions/:id/actions/pending, POST .../actions/decide
{id, allow, subject}); the card shows the tool, the exact canonical
arguments, a summary, the hicReason and every taint source;
- shell_run keeps its own card and still asks after taint; the card
now carries hicReason and taintSources;
- an approval releases that one call; the session stays tainted, so
the next action asks again (never global, never automatic);
- decisions are recorded (kind sidecar.tool_call, write-ahead).
Without hicAware (headless, scheduled) nobody can be asked: the call is
declined with citrate_agent_loop::cannot_ask_reason, which names the
taint source and says why.
Tests: agent-loop taint_tests (+1), action_approvals unit (+3),
action_approvals_tests session/route tests (+6), shell_run_tests (+2,
one rewritten), files_tests taint case rewritten to the card flow.
Workspace: 2241 -> 2253 passed, 0 failed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…t-retry (HGW-1) Aligns the HGW-1 interface with the HGW-W1 issue (#74): - routes are GET /sessions/:id/approvals/pending and POST /sessions/:id/approvals/:aid/decide {allow, subject}; - POST /sessions/:id/taint/clear {note}: the member clears the taint from an explicit card (TaintState::clear_by_member). Recorded write-ahead (kind taint.clear); with no records log, or when the record cannot be written, nothing is cleared (503). Scheduled (unattended) runs are never cleared (409); empty note 400; not tainted 409. Approving one action still never clears the taint; - a declined card tells the model to ask the member instead of retrying (shell_run and approval cards). Test support opens one undo store per manager (a store is locked while open). Tests: action_approvals_tests (+2). Workspace: 2253 -> 2255 passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…(HGW-2) Owner decision 2026-10-15 (HGW). Hermes could not set up a project: the file tools refused package.json, foundry.toml, remappings.txt and the rest of the build configuration everywhere, and the macOS sandbox denied every write to .git/config and .git/hooks, so `git init` failed even offline. Project roots (agent-sidecar): - POST /sessions takes `projectRoots` (absolute, existing folders under live read+write subtree folder grants; needs `grants`). SessionGrants keeps them; project_create (next PR) adds the projects it makes. - Inside a project root the file tools (fs_*, file_write, sheet_write) may create and change build configuration. Outside, refused as before (the message now points at projects). A marker file alone never makes a folder a project. - Env files stay the member's everywhere. The file tools never write inside a .git folder (git does, through the sandboxed shell_run). - The toolchain's own config check (ffi, fs_permissions, ...) is unchanged: forge still refuses risky config whoever wrote it. Sandbox (agent-shell): - Seatbelt: .git/ is writable inside write roots (git init/config/commit work); a hook file other than git's *.sample files is still denied, as are removing or moving .git/hooks and symlinking a new one in. - bwrap: .git/config is no longer masked (parity); existing .git/hooks stays read-only. - RunReport.git_warnings: risky git state a run leaves behind that would run a program outside the sandbox later (hooks, core.hooksPath, core.fsmonitor, `!` aliases, filters, includes, ...), new since before the run. shell_run puts it in the run facts and the summary. - Linux bwrap run tests (skip unless bwrap works; CITRATE_REQUIRE_BWRAP=1 makes them fail instead, for the Linux CI lane). Tests: project_policy_tests (+6), agent-shell sandbox (+3 macOS real Seatbelt incl. git init/commit and hooks closed; +3 Linux), git_state unit (+2), shell_run git warning (+1). Workspace: 2253 -> 2265 passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
Hermes had no way to start a project: there was no default folder and
no tool to make one, and template rendering was a panel action only.
project_create {name, template?, params?} (sidecar-hosted, offered to
sessions with folder grants when CITRATE_HERMES_PROJECTS=1):
- name is one plain folder name (no separators, no `..`, no leading
`.`/`-`/`~`, at most 64 chars); refused before anything is asked;
- the projects root (CITRATE_HERMES_PROJECTS_ROOT, passed by core;
default ~/Citrate/Projects) must exist and be under live read+write
subtree folder grants; <root>/<name> must not exist (never reused);
- always waits on an action card (HGW-1 queue) showing the exact folder
and template; no HIC-aware app = nothing created; a tainted session
shows one card carrying the taint reason;
- after approval (grants re-checked): creates the folder, renders the
template if asked, writes .citrate/project.json, and makes the folder
a session project root (HGW-2 build-config writes apply); a failed
render removes the new folder and says why; returns the path.
Template rendering stays core's: the TemplateRenderer trait is the seam,
and CliTemplateRenderer runs core's `citrate-templates render --root
<templates> --template <id> --tier <T> --out <dest> [--param k=v]...`
(CITRATE_HERMES_TEMPLATE_RENDERER + CITRATE_HERMES_TEMPLATES_ROOT,
CITRATE_HERMES_TEMPLATE_TIER) through the shell runner, in the OS
sandbox when there is one, writing only the new folder.
Tests: projects unit (+4), projects_tests session/route (+10, incl. the
CLI renderer under real Seatbelt). Workspace: 2265 -> 2279 passed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…x (HGW-2, #75) Per the HGW-W2 issue (#75): the Seatbelt `.env*` deny and the bwrap `.env*` masks exempt the templates' `.env.example` and `.env.sample` (not secrets; agent-guard exempts them the same way). `.env` and `.env.local` stay neither readable nor writable. Tests: sandbox env_files unit extended; real-Seatbelt test (+1). Workspace: 2267 -> 2268 passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
… (HGW-5)
Owner decision 2026-10-15 (HGW (3), D-16 as amended): sandboxed,
offline, project-scoped read and build commands run without a card.
- The list is data: agent-sidecar/policy/auto_approve.toml (compiled
in; CITRATE_HERMES_AUTO_APPROVE=0 turns it off, an absolute path
replaces it). Rules are argv templates (program, subcommands, flags,
value flags with kinds, required flags, operand kind and count). The
shipped list: ls, pwd, cat, head, tail, wc, node <file>, npm run
<script>, npm test, npx tsc, forge build/test, cargo build/test/check
--offline, git init/status/diff/add/commit -m.
- shell_run releases a match without a card only when the session is
untainted, the OS sandbox is enforced, and the folder is inside a
project root; that run may write only the project (not the wider
grant). Network-capable or unlisted commands, or anything outside a
project, still ask. After taint every command asks (HGW-1).
- Each auto run is recorded write-ahead (kind shell.run.auto, actor
agent, HIC-2); if it cannot be recorded it asks instead. Its result
says so (run.approval = {mode: auto, rule, project}); member-approved
runs say {mode: member}.
- agent-loop: ToolOutcome::Vouched. An auto run's output derives from
the project's own files (no network, project-only writes, untainted
session), like a folder-grant file read, so it does not taint; the
worker wire maps it to Ok (fail closed across the process boundary).
- git through shell_run commits as "Hermes <hermes@citrate.invalid>"
(the scratch HOME has no identity; git refused to commit).
- shell_run's description now states the approval semantics and says
to ask, not retry, after a decline.
End to end (real Seatbelt, scratch home, scripted model): create the
project (one card) -> write package.json, build.js and a buggy source ->
git init/add/commit -> npm run build fails -> the model reads
"ReferenceError: greeet is not defined" from the tool result, fixes it
with fs_edit -> build passes -> commit; then untrusted output makes the
next listed command ask, with the taint reason on its card. Negative
checks in the same run: no write outside the project, no network, no
risky git state. Linux (bwrap) runs the same tests; they skip without
a working bwrap unless CITRATE_REQUIRE_BWRAP=1 (the CI lane).
Tests: auto_approve unit (+4), auto_approve_tests (+7), e2e (+1),
taint_tests vouched (+1), shell_run git identity (+1). Workspace:
2285 -> 2299 passed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…t on (HGW-4) A build needs more than 8 model calls, and a failing step has to show the model why it failed. - Turns: default maxSteps 8 -> 24 (sessions::DEFAULT_MAX_STEPS), cap stays 32. A session's maxSteps still wins; the sidecar-wide default is configurable with CITRATE_HERMES_MAX_STEPS (clamped 1..=32). - Capture (agent-shell): a capped stream now keeps its first half-cap and its last half-cap (first error and final cause), with the truncation marker between them, instead of only the head. - shell_run: a non-zero exit puts the exit status and the end of stderr (or stdout when stderr is empty), at most FAILURE_TAIL_BYTES = 2048, in the summary the model reads first, and as stderr_tail/stdout_tail in the run facts. Successful runs are unchanged. - Toolchain: a run with no parseable report now states the exit code. Recognized compiler errors stay sanitized and trusted (the S6.3 guarantee: injected text in sources never reaches trusted output). When no cause is recognized, the end of stderr is handed over raw, bounded, and the result is Untrusted, so it taints the session (and, with HGW-1, later actions go to HIC cards). Tests: agent-shell runner (+1, two capture assertions extended), loop_capability_tests (+3), shell_run failure tail (+1), toolchain unparsed stderr (+1). Workspace: 2279 -> 2285 passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
) Aligns HGW-5 with the HGW-W11 issue (#77): - npm run is auto-approved only for the script names build, test, lint and typecheck (new rule field operand_values); any other script asks; - every git run through shell_run gets command-scope config core.hooksPath=/dev/null and core.fsmonitor=false (outranks the repository's own config), so Hermes's git never runs hooks; - GET /shell/auto-approve returns the list in effect (enabled, conditions, rules) for the app's Settings, behind the bearer. Tests: matrix extended; route test (+1); git hooks/fsmonitor off (+1). Workspace: 2302 -> 2304 passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…er decision 2026-10-15) After every run the runner reverts what the run added that would make git start a program outside the sandbox later: risky config entries (hooksPath, fsmonitor, sshCommand, pager/editor, aliases, filters, diff/merge drivers, credential helpers, includes, non-https insteadOf, protocol allow=always, gpg.program, ...), new hook files, and a hooks folder or config swapped for a symbolic link. Normal config (user.*, remote.*, branch.*) stays; what was there before the run is never touched. Each revert is a line in RunReport.git_warnings and the shell_run summary says the changes were undone. A .git the run turned into a pointer (gitdir: file or symlink) is reported with the risky state where it points, not changed: that folder may be the member's own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…W, owner decision 2026-10-15) Author and committer of commits made through shell_run are the member's: the identity the app passes (CITRATE_HERMES_GIT_USER_NAME/_EMAIL, both set and valid), else the member's own global git config (read once with the real git outside the sandbox, empty env but HOME), else "Hermes <hermes@citrate.invalid>". They reach git as GIT_AUTHOR_*/ GIT_COMMITTER_*, which outrank every config file. `git commit` gets --trailer "Co-authored-by: Hermes <hermes@citrate.invalid>", and command-scope config keeps git adding it (trailer.ifmissing=add, ifexists=addIfDifferent, commentChar #) over the repository's own. Not the model's to change, so refused before the member is asked: -c/--config-env for user.*, author.*, committer.*, trailer.*, include*, core.commentChar/String, commit.cleanup; commit --author, --cleanup, -C/-c/--reuse-message/--reedit-message (abbreviations included); and git commit-tree. Trailer/cleanup/comment-char config a run writes into the repository is reverted after it with the other risky git state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
📡 DGX team: Linux bwrap run proof for the HGW stack (#79 → #84)The owner asked DGX to run the Linux sandbox tests, since they can't run on macOS. This branch ( git clone https://github.com/CitrateNetwork/citrate-agent-runtime.git && cd citrate-agent-runtime
git checkout hgw/w1-git-safety && git rev-parse HEAD
bwrap --version
# Fail instead of skip when bwrap is missing or broken:
CITRATE_REQUIRE_BWRAP=1 cargo test -p agent-shell --test sandbox -- --nocapture 2>&1 | tee bwrap-sandbox.log
CITRATE_REQUIRE_BWRAP=1 cargo test -p agent-sidecar 2>&1 | tee bwrap-sidecar.logPlease reply here with:
If a test skips with "bwrap is unusable here", check |
BerryManifold
left a comment
There was a problem hiding this comment.
The new cleanup does not yet enforce the recorded owner requirement across every affected repository. A focused real-Seatbelt regression check confirmed that a command can leave executable git configuration outside the cleanup coverage. Holding approval until that boundary is enforced and covered by a regression test. Detailed reproduction is retained privately under the organization security policy; no exploit details are posted here.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…dependency updates, #85) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…udit dependency updates, #85) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…dependency updates, #85) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…pendency updates, #85) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
…ncy updates, #85) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
The post-run git cleanup (HGW-2, owner decision 2026-10-15) now finds repositories by walking every write root, before and after the run, instead of checking only the run's folder, its parents and its direct children: - `.git` folders at any depth, bare repositories, linked worktrees (`.git/worktrees/*`) and submodule git folders (`.git/modules/**`); - `config.worktree` is checked alongside `config`, and `hooks/` in each; - repositories the run creates are reverted against an empty baseline; - `.git` files/links and `commondir` are followed when they point at a git folder inside the write roots; ones pointing outside are reported and left unchanged; - more keys that run a program or move the worktree are treated as risky (core.worktree, core.alternateRefsCommand, submodule.*.update = !..., hook.*, interactive.diffFilter, sendemail commands, gpg.ssh.defaultKeyCommand, tar.*.command, guitool.*.cmd). The walk is bounded (300,000 entries, 48 levels). When it stops early the report says so, and a repository the before-run walk did not reach is reported rather than changed. Tests cover each case through the runner, under Seatbelt on macOS and under bubblewrap on Linux. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
|
@BerryManifold thanks.
New tests:
Local |
BerryManifold
left a comment
There was a problem hiding this comment.
Approval submitted at the owner’s explicit direction. This does not retract or resolve the previously reported git-state cleanup finding; no new verification of a fix is claimed.
Matr0xshka
left a comment
There was a problem hiding this comment.
Approval submitted at the owner’s explicit direction. This does not retract or resolve the previously reported git-state cleanup finding; no new verification of a fix is claimed.
main is the squash of #79, identical to hgw/w1-taint-hic@00264e5, which this branch already contains; the resolution keeps this branch's tree. Lands #80-#84 as one squash (owner decision 2026-10-15). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe
The base branch was changed.
BerryManifold
left a comment
There was a problem hiding this comment.
Reapproval submitted at the owner’s explicit direction. No new code review or verification of the previously reported finding is claimed.
Matr0xshka
left a comment
There was a problem hiding this comment.
Reapproval submitted at the owner’s explicit direction. No new code review or verification of the previously reported finding is claimed.
Follow-up to the HGW-W2 stack (#79 → #83), carrying the owner's runtime decisions of 2026-10-15 on #79–#83 (recorded in the HUP owner-decisions log).
1. Risky git state a run adds is reverted (was: reported)
After every
shell_run(and every runner) run,citrate-agent-shellreverts what the run added that would make git start a program outside the sandbox later, and reports each revert inRunReport.git_warnings(theshell_runsummary says the changes were undone):core.hooksPath,core.fsmonitor,core.sshCommand,core.pager/editor/askPass/gitProxy,alias.*,filter.*, diff/merge drivers,credential.*,include.path/includeIf.*.path, non-httpsurl.*.insteadOf,protocol.*.allow=always,gpg.program, toolcmd/path, upload/receive-pack hooks; plustrailer.*,commit.cleanup,core.commentChar/commentString(they could drop the Hermes trailer);*.sample); a hooks folder or.git/configswapped for a symbolic link is put back.Normal config (
user.*,remote.*,branch.*,core.autocrlf, https mirrors) stays. What was there before the run is never touched. A.gitthe run turned into a pointer (gitdir:file or symlink) is reported with the risky state where it points, not changed (it may be the member's own folder).2. Commit identity +
Co-authored-by: HermestrailerNew
agent-sidecar/src/git_identity.rs:CITRATE_HERMES_GIT_USER_NAME+CITRATE_HERMES_GIT_USER_EMAIL, both set and valid; this is the input core will wire), else the member's own global git config (user.name/user.email, read once at startup with the real git, empty env butHOME), elseHermes <hermes@citrate.invalid>. Passed asGIT_AUTHOR_*/GIT_COMMITTER_*, which outrank every config file.git commitgets--trailer "Co-authored-by: Hermes <hermes@citrate.invalid>"after the subcommand. Command-scope config (outranks the repo's) forcestrailer.ifmissing=add,ifexists=addIfDifferent(global and forco-authored-by) andcore.commentChar=#, next to the existing hooks/fsmonitor-off pair ([v0.5.1 HGW-W11] Auto-approve safe-command list (amended D-16) #77).-c/--config-envforuser.*,author.*,committer.*,trailer.*,include*,core.commentChar/commentString,commit.cleanup; ingit commit,--author,--cleanup,-C/-c/--reuse-message/--reedit-message(git's unique-prefix abbreviations included; option values such as-m "--author=x"are not mistaken for options);git commit-tree.The approval card still shows the model's exact argv; the trailer is added by the runtime.
Scope limits: commits from
merge/cherry-pick/revert/amcarry the member identity but no trailer. A non-git program (e.g. a member-approvedsh -c "git commit") gets neither. A repo config from before the run that aliases another trailer token toCo-authored-bywith its ownifMissingcould still suppress the trailer.Tests (red first)
a_git_pointer_made_by_the_run_is_reported_not_followed_into_changes(new); the revert tests intests/sandbox.rs(incl. real git under Seatbelt);git_stateunit tests for the trailer keys.git_runs_carry_the_commit_identity_and_other_programs_do_not,the_commit_identity_resolves_app_then_member_git_config_then_hermes,the_app_sets_the_commit_identity_through_the_environment,the_members_global_git_config_is_read_for_the_identity,a_commit_carries_the_member_identity_and_the_hermes_trailer(real git; the repo'strailer.ifmissing=doNothing+commentChar=Cdo not drop it),a_commit_that_would_change_the_author_or_drop_the_trailer_is_refused_before_asking; 4 unit tests ingit_identity.cargo test --workspace2317 passed / 0 failed (macOS). clippy has no findings in agent-sidecar/agent-shell, and fmt is clean for both. Theauditcheck fails on main as well, from new upstream advisories; a separate PR fixes it.Core follow-up: pass the member's identity via the two env vars when spawning the sidecar (core wiring lane, identity passthrough).
🤖 Generated with Claude Code
https://claude.ai/code/session_018wWZZ8GRVU9USh3kKQHsFe