Repository navigation
feat(budget): enforce the budget cap in aegis hook and aegis install - #39
Conversation
- hook: after the policy decision (BLOCK stands), budget gate for every covered tool call and for prompt events (Claude/Codex UserPromptSubmit, Gemini BeforeAgent); warn once per session per threshold via systemMessage / OpenCode toast / Copilot stderr; an unusable budget.yaml fails closed - install: budget hooks when a budget.yaml applies (--budget/--no-budget): all-tools matcher plus the prompt hook; reinstall and remove clean every aegis-owned event - Claude plugin and Gemini extension: budget-only entries whose wrapper exits before starting aegis when no budget.yaml applies - OpenCode plugin: every tool call with sessionID where a budget applies - design §6 table re-checked against each agent's hook docs
moneytool
left a comment
There was a problem hiding this comment.
Needs fixes before merging. All 1,963 existing tests pass locally and CI is green, but four additional regression tests reproduce three integration issues below (two install-discovery cases, a plugin-wrapper bypass after changing directories, and OpenCode ignoring --no-budget). The ordinary signed-budget hook path is covered; the remaining problems are in discovery and installation behavior.
| # at or above the project, or ~/.config/aegis (as aegis_core/hook.py does). | ||
| policy_dir() { | ||
| if [ -n "${AEGIS_CONFIG_DIR:-}" ]; then printf '%s' "$AEGIS_CONFIG_DIR"; return 0; fi | ||
| d=${CLAUDE_PROJECT_DIR:-${COPILOT_PROJECT_DIR:-${GEMINI_PROJECT_DIR:-$PWD}}} |
There was a problem hiding this comment.
[P1] Discover the budget from the hook's current directory
The budget-only fast path prefers CLAUDE_PROJECT_DIR before inspecting the payload, but run_hook prefers payload.cwd. Claude documents that CLAUDE_PROJECT_DIR stays at the session's original root while cwd follows cd/worktree changes (hook path reference). If the original checkout has no budget but the current worktree has .aegis/budget.yaml, this wrapper exits 0 before starting Aegis, so Edit/Read and prompt hooks never enforce that budget. I reproduced this with CLAUDE_PROJECT_DIR pointing at the original project and both process cwd and payload.cwd pointing at a budget-enabled worktree. Use discovery consistent with run_hook (payload.cwd first, with environment fallbacks), or conservatively start Aegis when the directories differ rather than incorrectly taking the no-budget fast path. Apply the fix to the synchronized wrapper copies and add a regression covering a directory change.
There was a problem hiding this comment.
Fixed in cb43401.
- Reading the payload first: the budget-only fast path now reads the payload before deciding, and checks every
"cwd"value in it (top level and nested), then$PWD, thenCLAUDE_PROJECT_DIR,GEMINI_PROJECT_DIRandCOPILOT_PROJECT_DIR. It starts aegis if the policy directory found from any of them holds abudget.yaml, and skips aegis only when none does. - Unreadable cwd: if a
cwdcan't be read (escaped characters), it starts aegis rather than taking the fast path. - Paths with spaces: candidates are read with a
while readloop over a heredoc, so they stay whole. - Copies: all three copies are updated.
- Regression tests: your case (
CLAUDE_PROJECT_DIRat the original root, process cwd and payload cwd in a budget-enabled worktree whose path has a space), plus payload cwd only, no budget anywhere, and an unreadable cwd. Two of these fail on the previous commit.
| for a user-level install.""" | ||
| env = os.environ.get(config_module.CONFIG_ENV_VAR) | ||
| dirs = [Path(env)] if env else [] | ||
| dirs.append(Path.home() / ".config" / "aegis" if user else project / ".aegis") |
There was a problem hiding this comment.
[P2] Use runtime policy precedence when detecting budget hooks
For a project install, this checks only project/.aegis and never the nearest parent .aegis or ~/.config/aegis fallback, even though find_opt_in_config uses both at runtime. I reproduced installing in a nested project inheriting a parent's budget and in a project inheriting the user budget: both produce PreToolUse matcher Bash with no UserPromptSubmit. Thus non-shell calls and prompts are not covered even though the applicable policy has opted into a budget. Resolve the applicable directory with the same precedence as find_opt_in_config for project installs and then check its budget.yaml; do not use any() across shadowed directories. Keep user-level discovery explicit if needed, and add tests for inherited parent/user budgets and an AEGIS_CONFIG_DIR that shadows a local budget.
There was a problem hiding this comment.
Fixed in cb43401. budget_configured now resolves the one policy directory the hook would use, and checks only that directory's budget.yaml: no any() across shadowed directories.
- Project install:
find_opt_in_config(project), which is$AEGIS_CONFIG_DIR, then the nearest.aegis/upwards, then~/.config/aegis. - User install:
$AEGIS_CONFIG_DIR, else~/.config/aegis. - Where detection lives: it moved into
install()itself (budget=Nonemeans detect), so library callers get it too. - Tests: a nested project inheriting the parent's budget; a project inheriting the user budget;
AEGIS_CONFIG_DIRwithout a budget shadowing a local one; and a nearer.aegis/without a budget shadowing the parent's. The first two fail on the previous commit.
| const payload = JSON.stringify({ tool_name: "bash", tool_input: { command }, cwd }) | ||
| const isBash = input?.tool === "bash" | ||
| const command = output?.args?.command | ||
| if (isBash && (typeof command !== "string" || !command.trim())) return |
There was a problem hiding this comment.
[P2] Honor --no-budget in the installed OpenCode plugin
The installer accepts --budget/--no-budget for OpenCode, but install() calls _install_opencode_plugin without passing the choice, and this template always forwards non-bash tools whenever budget.yaml exists. I reproduced install('opencode', budget=False), loaded the generated plugin in Node, and invoked a read tool: it still started Aegis and blocked on its exit 2. This contradicts --no-budget's promise to install only the shell-command hook and prevents switching budget hook coverage off for this agent. Carry the selected coverage mode into the generated plugin and gate the non-bash path on it (while preserving the standalone plugin's documented autodetection), or explicitly reject unsupported overrides rather than silently ignoring them. Add an executable plugin test for switching budget=True to budget=False.
There was a problem hiding this comment.
Fixed in cb43401. The plugin template now has const BUDGET_MODE = "auto", and install writes it: auto by default or with --budget (runtime detection, as before), and off with --no-budget. With off, only bash goes to aegis, whatever budget.yaml says. The install message says which mode was installed.
- Executable test:
test_no_budget_keeps_non_bash_tools_away_from_aegisinstalls withbudget=None,TrueandFalse, loads each generated plugin in Node, and calls areadtool with anAEGIS_BINthat denies everything. WithFalsethe call is allowed without reaching aegis, which fails on the previous commit; with the other two it is denied.
…et for OpenCode - wrapper --budget-only: decide from every cwd in the payload, the process directory and the project variables, starting aegis if any has a budget.yaml (or a cwd cannot be read); a cd into a worktree with a budget no longer bypasses it - install: detect the budget in the policy directory the hook would use (AEGIS_CONFIG_DIR, nearest .aegis/ upwards, ~/.config/aegis), never past a directory that shadows another - OpenCode plugin: BUDGET_MODE written at install (auto, or off with --no-budget); off sends only bash to aegis Review of #39.
moneytool
left a comment
There was a problem hiding this comment.
The three original findings are fixed: all four original reproductions pass, and the full local suite collected before the new edge-case test passes (1,977 tests, including those four). CI is green. One new regression in the revised wrapper remains: scanning every nested cwd can send a relative path into an ancestor walk that never terminates. Please address the inline finding before merging.
| found=0 # a cwd this cannot read (escaped characters): let aegis decide | ||
| else | ||
| while IFS= read -r d; do | ||
| if budget_applies_from "$d"; then found=0; break; fi |
There was a problem hiding this comment.
[P2] Handle relative cwd values before walking their ancestors
The new scan includes cwd values inside arbitrary tool_input objects, where relative directories such as . are valid tool arguments. Those values now reach policy_dir unchanged. In a directory without .aegis, policy_dir('.') repeatedly assigns dirname('.') == '.', and its only termination condition is '/', so the hook loops indefinitely. I reproduced a budget-only Claude call with an absolute top-level cwd in an unconfigured project and tool_input: {cwd: '.'}: it timed out instead of exiting immediately without Aegis. Every affected non-shell call incurs the hook timeout even in projects without any budget. Resolve relative candidates against the hook's process directory before ancestor traversal, or conservatively defer to Aegis for non-absolute candidates; also stop when dirname returns the same directory. Update all wrapper copies and add a bounded-time regression for nested cwd: '.' (and preferably ../a relative subdirectory).
There was a problem hiding this comment.
Fixed in c896de2, in all three wrapper copies. policy_dir resolves a relative candidate against the hook's directory ($PWD/$d) before walking up, and the walk stops when dirname returns the same path, which covers /, . and //. The older opted_in walk had the same termination condition, so it gets the same guard.
- Bounded-time regression: nested
cwdvalues.,..,sub/dirand./x/../yin an unconfigured project. The previous commit hits the 10 s test timeout on all four; now they exit at once. - Second test: a relative top-level
cwd: "sub"still finds thebudget.yamlabove it and starts aegis.
…tor walk
A nested {"cwd": "."} reached the walk unresolved and dirname(".")
never reached "/". Relative candidates are resolved from the hook's
directory, and the walk (and the older opted_in walk) stops when dirname
stops changing. Review of #39.
Implementation step 2 of the v1.0 budget cap design (§6), building on the core library from #36. Step 3 (
aegis budget status|check,docs/budget.md, the latency benchmark) and step 4 (a live test in each agent) come next.aegis hookbudget.yamland the agent is measured,aegis_core.budget.gaterefreshes the session's usage from the agent's logs and decides:UserPromptSubmit({"decision": "block"}), and Gemini CLIBeforeAgent({"decision": "deny"}).systemMessagefor Claude, Codex and Gemini, as an OpenCode toast, and to stderr for Copilot CLI, whose docs give it no message channel.budget.yamlis loaded with the same key and authority resolution as the other policy files (cli.load_budget_for_hook). A budget that cannot be used, for example because it was edited after signing, fails closed with a message saying to fix and re-sign it or remove it.aegis installaegis installadds them when the policy directory that applies (.aegis/,$AEGIS_CONFIG_DIR, or~/.config/aegiswith--user) holds abudget.yaml.--budget/--no-budgetoverride the detection.PreToolUsematcher*plusUserPromptSubmit..*plusUserPromptSubmit.BeforeTool .*plusBeforeAgent.--removenow clean every event aegis owns, so turning the budget on and off leaves no stale prompt hook, and other hooks are kept.Plugins
^(?!Bash$).*and^(?!run_shell_command$).*) plus the prompt hook.--budget-onlymode finds the policy directory aegis would use and exits before starting aegis when it holds nobudget.yaml. Users without a budget pay nothing; there is a test for it.sessionID, only where a budget applies, and shows a warning as a toast.Design doc
The §6 table was re-checked on 2026-10-08 against each agent's hook docs (Claude Code, Codex, Gemini CLI, Copilot CLI, OpenCode plugin types). Codex now has
UserPromptSubmitandsystemMessage. Copilot has no warning channel, and its prompt hook cannot block.Tests and measurements
Tests: 18 new end-to-end hook tests run the real hook against an
aegis initpolicy, abudget.yamlsigned with the example key, and a synthetic Claude transcript. They cover:on_unknown_log: deny);There are also 3 wrapper tests for the budget-only fast path. The full suite passes locally (1,963) and ruff is clean.
Latency: measured as a process, 15 runs of
aegis hook claudeon an Edit call, median 98 ms without a budget and 121 ms with one. The ~23 ms added is just over the design's 20 ms target, and most of it is loading and verifying the signed policy on every call. Step 3 measures it formally and can cache the verified policy.Known sharp edge
On the first hook after installing a budget, if other sessions in the same project already ran today, the day is reported as unknown completeness, because no inventory existed for them. With
on_unknown_log: allowthat is a single warning. Withon_unknown_log: denyit denies until the next day; the signedaegis budget reset --dayfrom the design arrives in step 3.