Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ test on each agent are in.
rebuilds that report whether the day is complete, partial or of unknown completeness, and
usage bucketed by the time the log recorded, never the time it was read.
- Evaluation to allow / warn / deny with the measured usage, limits and observed overshoot.
- The budget cap in `aegis hook` (design §6): in a project whose policy directory holds a
`budget.yaml`, after the policy decision (a BLOCK always stands), every covered tool call is
denied once the usage available at the hook reaches a limit, and new prompts where the agent
can block them (Claude Code and Codex `UserPromptSubmit`, Gemini CLI `BeforeAgent`); a warning
is shown once per session per threshold (`systemMessage`; an OpenCode toast; stderr for Copilot
CLI). A `budget.yaml` that cannot be used (bad signature, shape, authority) fails closed.
- `aegis install` adds the budget hooks when the policy directory that applies holds a
`budget.yaml` (`--budget` / `--no-budget` to choose): the tool hook sees every tool call and
the prompt hook is added where the agent has one. The Claude Code plugin and the Gemini
extension carry budget-only entries that start no process in projects without a budget; the
OpenCode plugin sends every tool call, with its session id, only where a budget applies.
- The example `authority.yaml` gives `admin` the `budget` class.
- `agents.yaml`: a trusted AWS `source-identity` now warns (`source-identity-inherited`): a
source identity survives role chaining, so an agent started in a session that carries it is
Expand Down
18 changes: 11 additions & 7 deletions docs/dev/DESIGN-v1.0-budget-cap.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,17 @@ commands to **all tool calls**, and adds a prompt hook where the agent has one.
| Agent | Before each tool call | Before a new prompt | How "warn" shows |
|---|---|---|---|
| Claude Code | `PreToolUse` (matcher `*`) | `UserPromptSubmit` | `systemMessage` to the user |
| Codex CLI | `PreToolUse` | — | stderr message |
| Gemini CLI | `BeforeTool` | `BeforeAgent` | `systemMessage` |
| OpenCode | plugin `tool.execute.before` | — | toast |
| Copilot CLI | `preToolUse` (premium-request cap) | — | stderr message |

Hook and event names in this table come from the 2026-10-06 research and must be re-checked
against each agent's current version before implementation (step 4 of §10).
| Codex CLI | `PreToolUse` (matcher `.*`) | `UserPromptSubmit` | `systemMessage` (a UI warning) |
| Gemini CLI | `BeforeTool` (matcher `.*`) | `BeforeAgent` | `systemMessage` |
| OpenCode | plugin `tool.execute.before` (every tool, where a budget applies) | — | toast (`client.tui.showToast`) |
| Copilot CLI | `preToolUse` (no matcher: every tool; premium-request cap) | — (`userPromptSubmitted` cannot block) | stderr only: Copilot CLI has no message field |

Re-checked against each agent's hook documentation on 2026-10-08 (implementation PR 2): Codex
now has `UserPromptSubmit` and `systemMessage`; Claude Code and Codex sub-agent hooks carry the
parent's `session_id`; OpenCode's `tool.execute.before` input carries `sessionID`. The Claude
Code plugin and the Gemini extension ship fixed hook files, so they carry extra budget-only
entries (every other tool, and the prompt hook) whose wrapper exits at once unless the policy
directory holds a `budget.yaml`: projects without a budget start no extra process.

- **Below `warn_at`:** nothing changes; budget adds no output.
- **Crossing `warn_at`:** one warning per session per threshold (not per call), with usage, the
Expand Down
63 changes: 60 additions & 3 deletions hooks/aegis-hook.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# Plugin entry point: find the aegis binary and hand it the hook payload.
#
# aegis-hook.sh <agent> (the payload arrives on stdin)
# aegis-hook.sh <agent> (the payload arrives on stdin)
# aegis-hook.sh <agent> --budget-only (non-shell tools and prompts: only the
# budget cap applies, so this exits at
# once unless the policy directory that
# applies holds a budget.yaml)
#
# aegis itself decides everything (see aegis_core/hook.py): outside a
# project with a policy it allows every command, inside one it blocks only
Expand All @@ -12,7 +16,55 @@
# names an infrastructure binary, and allow the rest.
set -u
agent=${1:-claude}
mode=${2:-}

# The policy directory aegis would use from directory $1: $AEGIS_CONFIG_DIR,
# the nearest .aegis/ at or above $1, or ~/.config/aegis (as hook.py does).
policy_dir() {
if [ -n "${AEGIS_CONFIG_DIR:-}" ]; then printf '%s' "$AEGIS_CONFIG_DIR"; return 0; fi
d=$1
case "$d" in /*) ;; *) d="$PWD/$d" ;; esac # relative: from the hook's directory
while [ -n "$d" ]; do
if [ -d "$d/.aegis" ]; then printf '%s' "$d/.aegis"; return 0; fi
parent=$(dirname "$d")
[ "$parent" = "$d" ] && break # reached the root (or "." / "//")
d=$parent
done
if [ -d "$HOME/.config/aegis" ]; then printf '%s' "$HOME/.config/aegis"; return 0; fi
return 1
}

budget_applies_from() {
[ -n "$1" ] || return 1
dir=$(policy_dir "$1") || return 1
[ -f "$dir/budget.yaml" ]
}

payload=$(cat)
if [ "$mode" = "--budget-only" ]; then
# aegis decides from the payload's cwd first (the agent's directory may have
# changed since the session started, e.g. into a worktree), then the
# project variables. Skip starting aegis only when none of those has a
# budget.yaml; if the payload names a cwd this cannot read, start it.
found=1
cwds=$(printf '%s' "$payload" | grep -o '"cwd"[[:space:]]*:[[:space:]]*"[^"\\]*"' \
| sed 's/.*:[[:space:]]*"\(.*\)"$/\1/')
ncwd=$(printf '%s' "$payload" | grep -o '"cwd"' | wc -l | tr -d ' ')
if [ "$ncwd" != "$(printf '%s' "$cwds" | grep -c .)" ]; then
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.

done <<DIRS
$cwds
$PWD
${CLAUDE_PROJECT_DIR:-}
${GEMINI_PROJECT_DIR:-}
${COPILOT_PROJECT_DIR:-}
DIRS
fi
[ "$found" = 0 ] || exit 0
fi

aegis=${AEGIS_BIN:-}
if [ -z "$aegis" ] && command -v aegis >/dev/null 2>&1; then aegis=aegis; fi
Expand All @@ -27,14 +79,19 @@ if command -v uvx >/dev/null 2>&1; then
case "$rc" in 0|2) exit "$rc" ;; esac
fi

# Without aegis nothing can be measured; the budget-only entries allow.
[ "$mode" = "--budget-only" ] && exit 0

opted_in() {
[ -n "${AEGIS_CONFIG_DIR:-}" ] && return 0
[ -d "$HOME/.config/aegis" ] && return 0
d=${CLAUDE_PROJECT_DIR:-${COPILOT_PROJECT_DIR:-${GEMINI_PROJECT_DIR:-$PWD}}}
case "$d" in /*) ;; *) d="$PWD/$d" ;; esac
while [ -n "$d" ]; do
[ -d "$d/.aegis" ] && return 0
[ "$d" = "/" ] && break
d=$(dirname "$d")
parent=$(dirname "$d")
[ "$parent" = "$d" ] && break
d=$parent
done
return 1
}
Expand Down
23 changes: 23 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@
"timeout": 30000
}
]
},
{
"matcher": "^(?!run_shell_command$).*",
"hooks": [
{
"name": "aegis-devops-budget",
"type": "command",
"command": "\"${extensionPath}${/}hooks${/}aegis-hook.sh\" gemini --budget-only",
"timeout": 30000
}
]
}
],
"BeforeAgent": [
{
"hooks": [
{
"name": "aegis-devops-budget",
"type": "command",
"command": "\"${extensionPath}${/}hooks${/}aegis-hook.sh\" gemini --budget-only",
"timeout": 30000
}
]
}
]
}
Expand Down
63 changes: 60 additions & 3 deletions plugins/claude/hooks/aegis-hook.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# Plugin entry point: find the aegis binary and hand it the hook payload.
#
# aegis-hook.sh <agent> (the payload arrives on stdin)
# aegis-hook.sh <agent> (the payload arrives on stdin)
# aegis-hook.sh <agent> --budget-only (non-shell tools and prompts: only the
# budget cap applies, so this exits at
# once unless the policy directory that
# applies holds a budget.yaml)
#
# aegis itself decides everything (see aegis_core/hook.py): outside a
# project with a policy it allows every command, inside one it blocks only
Expand All @@ -12,7 +16,55 @@
# names an infrastructure binary, and allow the rest.
set -u
agent=${1:-claude}
mode=${2:-}

# The policy directory aegis would use from directory $1: $AEGIS_CONFIG_DIR,
# the nearest .aegis/ at or above $1, or ~/.config/aegis (as hook.py does).
policy_dir() {
if [ -n "${AEGIS_CONFIG_DIR:-}" ]; then printf '%s' "$AEGIS_CONFIG_DIR"; return 0; fi
d=$1
case "$d" in /*) ;; *) d="$PWD/$d" ;; esac # relative: from the hook's directory
while [ -n "$d" ]; do
if [ -d "$d/.aegis" ]; then printf '%s' "$d/.aegis"; return 0; fi
parent=$(dirname "$d")
[ "$parent" = "$d" ] && break # reached the root (or "." / "//")
d=$parent
done
if [ -d "$HOME/.config/aegis" ]; then printf '%s' "$HOME/.config/aegis"; return 0; fi
return 1
}

budget_applies_from() {
[ -n "$1" ] || return 1
dir=$(policy_dir "$1") || return 1
[ -f "$dir/budget.yaml" ]
}

payload=$(cat)
if [ "$mode" = "--budget-only" ]; then
# aegis decides from the payload's cwd first (the agent's directory may have
# changed since the session started, e.g. into a worktree), then the
# project variables. Skip starting aegis only when none of those has a
# budget.yaml; if the payload names a cwd this cannot read, start it.
found=1
cwds=$(printf '%s' "$payload" | grep -o '"cwd"[[:space:]]*:[[:space:]]*"[^"\\]*"' \
| sed 's/.*:[[:space:]]*"\(.*\)"$/\1/')
ncwd=$(printf '%s' "$payload" | grep -o '"cwd"' | wc -l | tr -d ' ')
if [ "$ncwd" != "$(printf '%s' "$cwds" | grep -c .)" ]; then
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
done <<DIRS
$cwds
$PWD
${CLAUDE_PROJECT_DIR:-}
${GEMINI_PROJECT_DIR:-}
${COPILOT_PROJECT_DIR:-}
DIRS
fi
[ "$found" = 0 ] || exit 0
fi

aegis=${AEGIS_BIN:-}
if [ -z "$aegis" ] && command -v aegis >/dev/null 2>&1; then aegis=aegis; fi
Expand All @@ -27,14 +79,19 @@ if command -v uvx >/dev/null 2>&1; then
case "$rc" in 0|2) exit "$rc" ;; esac
fi

# Without aegis nothing can be measured; the budget-only entries allow.
[ "$mode" = "--budget-only" ] && exit 0

opted_in() {
[ -n "${AEGIS_CONFIG_DIR:-}" ] && return 0
[ -d "$HOME/.config/aegis" ] && return 0
d=${CLAUDE_PROJECT_DIR:-${COPILOT_PROJECT_DIR:-${GEMINI_PROJECT_DIR:-$PWD}}}
case "$d" in /*) ;; *) d="$PWD/$d" ;; esac
while [ -n "$d" ]; do
[ -d "$d/.aegis" ] && return 0
[ "$d" = "/" ] && break
d=$(dirname "$d")
parent=$(dirname "$d")
[ "$parent" = "$d" ] && break
d=$parent
done
return 1
}
Expand Down
27 changes: 26 additions & 1 deletion plugins/claude/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,32 @@
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/aegis-hook.sh claude", "timeout": 30 }
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/aegis-hook.sh claude",
"timeout": 30
}
]
},
{
"matcher": "^(?!Bash$).*",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/aegis-hook.sh claude --budget-only",
"timeout": 30
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/aegis-hook.sh claude --budget-only",
"timeout": 30
}
]
}
]
Expand Down
63 changes: 60 additions & 3 deletions plugins/copilot/aegis-hook.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# Plugin entry point: find the aegis binary and hand it the hook payload.
#
# aegis-hook.sh <agent> (the payload arrives on stdin)
# aegis-hook.sh <agent> (the payload arrives on stdin)
# aegis-hook.sh <agent> --budget-only (non-shell tools and prompts: only the
# budget cap applies, so this exits at
# once unless the policy directory that
# applies holds a budget.yaml)
#
# aegis itself decides everything (see aegis_core/hook.py): outside a
# project with a policy it allows every command, inside one it blocks only
Expand All @@ -12,7 +16,55 @@
# names an infrastructure binary, and allow the rest.
set -u
agent=${1:-claude}
mode=${2:-}

# The policy directory aegis would use from directory $1: $AEGIS_CONFIG_DIR,
# the nearest .aegis/ at or above $1, or ~/.config/aegis (as hook.py does).
policy_dir() {
if [ -n "${AEGIS_CONFIG_DIR:-}" ]; then printf '%s' "$AEGIS_CONFIG_DIR"; return 0; fi
d=$1
case "$d" in /*) ;; *) d="$PWD/$d" ;; esac # relative: from the hook's directory
while [ -n "$d" ]; do
if [ -d "$d/.aegis" ]; then printf '%s' "$d/.aegis"; return 0; fi
parent=$(dirname "$d")
[ "$parent" = "$d" ] && break # reached the root (or "." / "//")
d=$parent
done
if [ -d "$HOME/.config/aegis" ]; then printf '%s' "$HOME/.config/aegis"; return 0; fi
return 1
}

budget_applies_from() {
[ -n "$1" ] || return 1
dir=$(policy_dir "$1") || return 1
[ -f "$dir/budget.yaml" ]
}

payload=$(cat)
if [ "$mode" = "--budget-only" ]; then
# aegis decides from the payload's cwd first (the agent's directory may have
# changed since the session started, e.g. into a worktree), then the
# project variables. Skip starting aegis only when none of those has a
# budget.yaml; if the payload names a cwd this cannot read, start it.
found=1
cwds=$(printf '%s' "$payload" | grep -o '"cwd"[[:space:]]*:[[:space:]]*"[^"\\]*"' \
| sed 's/.*:[[:space:]]*"\(.*\)"$/\1/')
ncwd=$(printf '%s' "$payload" | grep -o '"cwd"' | wc -l | tr -d ' ')
if [ "$ncwd" != "$(printf '%s' "$cwds" | grep -c .)" ]; then
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
done <<DIRS
$cwds
$PWD
${CLAUDE_PROJECT_DIR:-}
${GEMINI_PROJECT_DIR:-}
${COPILOT_PROJECT_DIR:-}
DIRS
fi
[ "$found" = 0 ] || exit 0
fi

aegis=${AEGIS_BIN:-}
if [ -z "$aegis" ] && command -v aegis >/dev/null 2>&1; then aegis=aegis; fi
Expand All @@ -27,14 +79,19 @@ if command -v uvx >/dev/null 2>&1; then
case "$rc" in 0|2) exit "$rc" ;; esac
fi

# Without aegis nothing can be measured; the budget-only entries allow.
[ "$mode" = "--budget-only" ] && exit 0

opted_in() {
[ -n "${AEGIS_CONFIG_DIR:-}" ] && return 0
[ -d "$HOME/.config/aegis" ] && return 0
d=${CLAUDE_PROJECT_DIR:-${COPILOT_PROJECT_DIR:-${GEMINI_PROJECT_DIR:-$PWD}}}
case "$d" in /*) ;; *) d="$PWD/$d" ;; esac
while [ -n "$d" ]; do
[ -d "$d/.aegis" ] && return 0
[ "$d" = "/" ] && break
d=$(dirname "$d")
parent=$(dirname "$d")
[ "$parent" = "$d" ] && break
d=$parent
done
return 1
}
Expand Down
Loading
Loading