Skip to content

feat(budget): enforce the budget cap in aegis hook and aegis install - #39

Merged
moneytool merged 3 commits into
mainfrom
v1-budget-hook
Oct 10, 2026
Merged

moneytool merged 3 commits into
mainfrom
v1-budget-hook

Conversation

@moneytool

@moneytool moneytool commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

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 hook

  • Order: the infrastructure policy decides first, exactly as before, and a BLOCK stands. Then, only where the opted-in policy directory holds a budget.yaml and the agent is measured, aegis_core.budget.gate refreshes the session's usage from the agent's logs and decides:
    • deny every covered tool call once the usage available at the hook reaches a limit. The reason says what was spent, which limit was hit, and how to continue (a new session, the next day, or a re-signed higher limit), and reports the observed overshoot.
    • deny new prompts where the agent can block them: Claude Code and Codex UserPromptSubmit ({"decision": "block"}), and Gemini CLI BeforeAgent ({"decision": "deny"}).
    • warn once per session per threshold, kept in the cache. The warning goes out as systemMessage for Claude, Codex and Gemini, as an OpenCode toast, and to stderr for Copilot CLI, whose docs give it no message channel.
  • Loading the budget: budget.yaml is 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.
  • No budget: nothing changes. Non-shell tool calls are still not looked at, and no cache is created.

aegis install

  • Budget hooks: aegis install adds them when the policy directory that applies (.aegis/, $AEGIS_CONFIG_DIR, or ~/.config/aegis with --user) holds a budget.yaml. --budget / --no-budget override the detection.
    • Claude Code: PreToolUse matcher * plus UserPromptSubmit.
    • Codex: .* plus UserPromptSubmit.
    • Gemini CLI: BeforeTool .* plus BeforeAgent.
    • Copilot CLI: unchanged, since its hook already sees every tool and its prompt hook cannot block.
  • Clean switching: reinstalling and --remove now clean every event aegis owns, so turning the budget on and off leaves no stale prompt hook, and other hooks are kept.

Plugins

  • Static hook files: the Claude Code plugin and the Gemini extension ship fixed hook files, so they gain budget-only entries: every non-shell tool (^(?!Bash$).* and ^(?!run_shell_command$).*) plus the prompt hook.
    • The shared wrapper's --budget-only mode finds the policy directory aegis would use and exits before starting aegis when it holds no budget.yaml. Users without a budget pay nothing; there is a test for it.
    • With no aegis installed, those entries allow.
  • OpenCode plugin: sends every tool call, with its 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 UserPromptSubmit and systemMessage. 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 init policy, a budget.yaml signed with the example key, and a synthetic Claude transcript. They cover:

    • silence without a budget;
    • silence under the threshold;
    • warn-once;
    • denying every tool at the limit, with overshoot;
    • a policy BLOCK standing;
    • prompt blocking and the reply shape for each agent;
    • the fail-closed tampered budget;
    • an unlocatable session (warn once, or deny under on_unknown_log: deny);
    • an unlisted agent;
    • each agent's warning channel;
    • install shapes, and turning the budget hooks on and off.

    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 claude on 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: allow that is a single warning. With on_unknown_log: deny it denies until the next day; the signed aegis budget reset --day from the design arrives in step 3.

- 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 moneytool left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread hooks/aegis-hook.sh Outdated
# 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}}}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, then CLAUDE_PROJECT_DIR, GEMINI_PROJECT_DIR and COPILOT_PROJECT_DIR. It starts aegis if the policy directory found from any of them holds a budget.yaml, and skips aegis only when none does.
  • Unreadable cwd: if a cwd can't be read (escaped characters), it starts aegis rather than taking the fast path.
  • Paths with spaces: candidates are read with a while read loop over a heredoc, so they stay whole.
  • Copies: all three copies are updated.
  • Regression tests: your case (CLAUDE_PROJECT_DIR at 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.

Comment thread src/aegis_core/hook.py Outdated
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")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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=None means detect), so library callers get it too.
  • Tests: a nested project inheriting the parent's budget; a project inheriting the user budget; AEGIS_CONFIG_DIR without 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_aegis installs with budget=None, True and False, loads each generated plugin in Node, and calls a read tool with an AEGIS_BIN that denies everything. With False the 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 moneytool left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread hooks/aegis-hook.sh
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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 cwd values ., .., sub/dir and ./x/../y in 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 the budget.yaml above 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.
@moneytool
moneytool merged commit 2ebf9f7 into main Oct 10, 2026
9 checks passed
@moneytool
moneytool deleted the v1-budget-hook branch October 10, 2026 01:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant