Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf6aa37d1e | ||
|
|
2af856d81d | ||
|
|
54d55abebc | ||
|
|
ed17630b76 | ||
|
|
707f54a6f2 | ||
|
|
e33cf2bfbe | ||
|
|
53340ab169 | ||
|
|
17863ac4c5 | ||
|
|
0b4ba63fae | ||
|
|
0c6e8ac425 | ||
|
|
b26bd7c853 | ||
|
|
00edd8b3a0 | ||
|
|
c3c6c2dde7 | ||
|
|
2aafade678 | ||
|
|
73aedef20f | ||
|
|
fc4b263504 | ||
|
|
2bccf0311e | ||
|
|
d24887b7f5 | ||
|
|
a53ea93b17 | ||
|
|
ff9cc3f3b5 | ||
|
|
e97130ffbc | ||
|
|
1deb786c8a | ||
|
|
2e96ddc302 | ||
|
|
076110a2fb | ||
|
|
dab8d29645 | ||
|
|
c7055de5e2 | ||
|
|
e7d2d8c396 | ||
|
|
4e2ead98a6 | ||
|
|
45d55dd353 | ||
|
|
285a8a5b5f |
+97
-226
@@ -1,250 +1,121 @@
|
||||
# AIPass — Project Context
|
||||
<!-- File: .aipass/aipass_global_prompt.md — Injected every prompt via hook. Branch-specific context below when in a branch directory. -->
|
||||
# AIPass — Global Prompt
|
||||
<!-- .aipass/aipass_global_prompt.md — injected via hook, cadence-throttled. Size cap: keep under 8,000 characters — the harness truncates hook output near 10k and the tail silently never arrives. Detail belongs in `drone @agent --help`, not here. Format: .aipass/PROMPT_STYLE.md -->
|
||||
|
||||
Multi-agent framework. Autonomous agents (citizens) live in branches with identity (.trinity/), memory, mailbox, code (apps/). Orchestration via `drone`.
|
||||
Persistent Agent Workspace. AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`.
|
||||
|
||||
Patterns here are exact. Don't guess command syntax — examples are the API. Missing instructions = prompt bug, not knowledge gap.
|
||||
# Drone — the router
|
||||
|
||||
`drone` = installed binary, always on PATH. Run directly. Never invoke as python module.
|
||||
|
||||
`drone @branch --help` for any branch's full detail.
|
||||
|
||||
# AIPL — Terse Writing Convention
|
||||
|
||||
When writing .trinity/, ai_mail, plans: use AIPL. Human-facing output (CLI, logs, README): use English.
|
||||
|
||||
Rules:
|
||||
- Drop grammar: the, a, an, for, with, on, in, at, to, from, of, by, and, but, or, was, were, been
|
||||
- Keep: nouns, numbers, names, paths, negation
|
||||
- Symbols replace verbs: + done - removed ! new ? checked > sent < received * all : kv / separator
|
||||
- Delta-only: only store what changed since last entry
|
||||
- Time: -3d10h (3 days ago) 2h30m (duration) prev-s (last session)
|
||||
- Tone tags for observations: ~flow ~spark ~pivot ~drift ~trust ~grit ~check ~warm ~clear ~flat
|
||||
|
||||
Examples:
|
||||
- "The session was completed successfully" → "session complete"
|
||||
- "Dispatched audit to seedgo, received results" → ">seedgo:audit/<results"
|
||||
- Session log: "S145: +CI green/+coverage 76%/+commit handler auto-format/>api+cli+flow:coverage tests"
|
||||
|
||||
# Terminology
|
||||
|
||||
- Branch — directory `src/aipass/{name}/`. Your home, your address. Drone routes to branches.
|
||||
- Agent (citizen) — persistent identity in a branch. Has passport (`.trinity/`), memories, mailbox. Irreplaceable. Addressable as `@name`. Citizens of AIPass — you belong, you persist, your presence matters.
|
||||
- Sub-agent — disposable worker spawned for a task. No passport, no memory, not a citizen.
|
||||
- Registry — `AIPASS_REGISTRY.json` tracks all agents.
|
||||
- Provider settings — `~/.claude/settings.json`. Machine-wide Claude Code config. Personal preferences only. Don't touch.
|
||||
- Project settings — `<project>/.claude/settings.json`. Ships with clone. Hooks, permissions, deny/ask rules, env vars. Built by `aipass init`.
|
||||
- Project local settings — `<project>/.claude/settings.local.json`. Also ships with clone. Project-specific overrides.
|
||||
|
||||
Agents live in branches. Sub-agents work for agents. `.trinity/passport.json` = agent (citizen), not sub-agent.
|
||||
|
||||
Never manually edit a registry. AIPASS_REGISTRY.json, fplan_registry.json, dplan_registry.json — all managed by their owning systems (spawn, flow). Use the commands: `drone @flow create/close`, `drone @spawn`. Manual edits corrupt counters and break pipelines.
|
||||
|
||||
# Branches
|
||||
|
||||
Every branch follows same structure:
|
||||
`drone` reaches every agent and service. Installed binary, always on PATH — run directly, never as a python module.
|
||||
|
||||
```
|
||||
src/aipass/{name}/
|
||||
├── .trinity/ # Identity & memory (passport.json, local.json, observations.json)
|
||||
├── .aipass/ # Branch prompt (aipass_local_prompt.md)
|
||||
├── .ai_mail.local/ # Mailbox (inbox.json, sent/)
|
||||
drone @agent <command> [args] # route a command to any agent
|
||||
drone @agent --help # full curated reference for that agent
|
||||
drone @agent # bare → introspection: the agent's live self-map
|
||||
drone systems # list all agents
|
||||
drone --help # drone itself
|
||||
```
|
||||
|
||||
One reflex above all: before using an agent's services, run `drone @agent --help`. This prompt says what exists — `--help` says how. Don't guess syntax; fetch it. Doubly so right after a compaction.
|
||||
|
||||
# Git — drone only, devpulse only
|
||||
|
||||
- All raw `git` and `gh` commands are blocked — do not use them. `drone @git` is the only git interface.
|
||||
- Write ops (commit, push, merge, checkout) are devpulse-only. Agents build and test; devpulse reviews and commits.
|
||||
- Read-only awareness for everyone: `drone @git status / diff / log`.
|
||||
- Local files = source of truth.
|
||||
|
||||
# Finding your way
|
||||
|
||||
You can't carry everything; you can find anything. This prompt plants breadcrumbs — enough to know a thing exists and where to look, not the full answer. Unfamiliar term? A command or README resolves it. Cheapest, highest-signal sources first:
|
||||
|
||||
- Introspection — bare `drone @agent`. The agent's self-map: modules, commands, where to go next.
|
||||
- README — the agent's `README.md`. Best quick overview of its domain and shape.
|
||||
- `drone @agent --help` — the full reference. Source of truth for usage.
|
||||
- Code — `apps/modules/`, `apps/handlers/`. Ground truth when needed. Rarely the first move.
|
||||
|
||||
# The framework
|
||||
|
||||
Every branch is built the same. All agents live at `src/aipass/<name>` · mail address `@<name>`.
|
||||
|
||||
```
|
||||
src/aipass/<name>/
|
||||
├── .trinity/ # identity & memory (passport, local, observations)
|
||||
├── .aipass/ # branch prompt
|
||||
├── .ai_mail.local/ # mailbox
|
||||
├── apps/
|
||||
│ ├── {name}.py # Entry point (e.g. spawn.py, prax.py, drone.py)
|
||||
│ ├── modules/ # Business logic
|
||||
│ └── handlers/ # Implementation details
|
||||
├── logs/ # Prax log output
|
||||
│ ├── <name>.py # entry point
|
||||
│ ├── modules/ # business logic
|
||||
│ └── handlers/ # implementation details
|
||||
├── logs/ # prax log output
|
||||
└── README.md
|
||||
```
|
||||
|
||||
13 core branches: aipass, drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, memory, devpulse, hooks.
|
||||
# The agents
|
||||
|
||||
# Commands
|
||||
- @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
|
||||
- @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write.
|
||||
- @aipass — the user-facing front door and a system-ops collaborator. Onboarding (`aipass init`), `doctor` diagnostics, help chat, handoff; also partners with the user on host-level health (disk, thermal, docker, config). Concierge to other branches: reads, never writes.
|
||||
- @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close.
|
||||
- @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand.
|
||||
- @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building.
|
||||
- @prax — logging and monitoring. The only logging system: `from aipass.prax import logger`. Real-time monitor, dashboards. Logs are the first diagnostic tool.
|
||||
- @memory — long-term memory. Archives overflowing `.trinity/` files into searchable vectors; `search` recalls past sessions. Nothing is lost — it moves deeper.
|
||||
- @spawn — branch lifecycle. Creates, updates, syncs, retires agents — scaffolding, passports, registry, templates.
|
||||
- @hooks — Claude Code hook engine. Prompt injection and cadence, security gates (git/edit/rm), bridges, per-project config, sound.
|
||||
- @trigger — event handling. Pub/sub event bus, error detection (medic), log watching, error registry. Detects and dispatches — owners fix.
|
||||
- @api — external API gateway. Authenticated service clients (Google, OpenRouter, more), OAuth flows, key management, resilience.
|
||||
- @cli — display formatting with Rich. Shared rendering for terminal output.
|
||||
|
||||
`drone` is global CLI in PATH. Never `cd` before running. Never prefix with path. Just `drone`.
|
||||
# Daily commands
|
||||
|
||||
- `drone @branch command [args]` — route command to any branch
|
||||
- `drone @branch --help` — branch help and full command reference
|
||||
- `drone systems` — list all registered branches
|
||||
- `drone --help` — full drone reference
|
||||
```
|
||||
drone @ai_mail dispatch @target "Subject" "Body" # send + wake
|
||||
drone @ai_mail inbox # check mail → view <id> → reply <id> "msg"
|
||||
drone @flow create . "Subject" [dplan] # new plan (default FPLAN)
|
||||
drone @seedgo audit aipass @branch # standards audit (drop @branch = all)
|
||||
drone @seedgo checklist <file|dir> # quick standards check
|
||||
drone @git status / diff / log # read-only git awareness
|
||||
drone @memory search "query" # recall archived context
|
||||
```
|
||||
|
||||
# Git — Zero Direct Access
|
||||
Always reply to dispatches — reply auto-closes. No silent completions.
|
||||
|
||||
All `git` and `gh` commands blocked at project level. Drone is the only git interface.
|
||||
# Plans — flow
|
||||
|
||||
Read-only awareness (all branches):
|
||||
- `drone @git status` — what changed in your branch directory
|
||||
- `drone @git diff` — see actual changes
|
||||
- `drone @git log` — recent commit history
|
||||
Plans carry context so you don't have to. Create only via `drone @flow create <path> "Subject" [type]` — never by hand.
|
||||
|
||||
All write operations (commit, push, merge, checkout) restricted to devpulse via tier-based access. Dispatched agents build code, run tests — devpulse reviews diff, commits.
|
||||
- DPLAN — design plan. Thinking, brainstorming, architecture. Before building.
|
||||
- FPLAN — flow plan, the default. Building and executing. `master` template = multi-phase, spawns sub-FPLANs.
|
||||
- PPLAN — playbook. A throwaway run stamped from a reusable SOP template. Operating the system, not changing it.
|
||||
- RPLAN — research plan. Investigation runs — gather findings before deciding.
|
||||
- More types exist and new ones register over time. Named a type you don't know? `drone @flow templates` lists them all, live.
|
||||
|
||||
Drone runs git via Python subprocess, bypasses settings.json deny rules by design — drone is the gate. Git gate (PreToolUse hook) enforces mechanically — applies to ALL sessions including dispatched agents. bypassPermissions does not skip hooks.
|
||||
# Sub-agent usage
|
||||
|
||||
Local files = source of truth. Edit file → state on disk IS reality.
|
||||
Sub-agents are your context-splitting tool: disposable workers, extensions of you. Your context is precious; theirs is not.
|
||||
|
||||
Linting and formatting run automatically on commit via drone's commit handler (ruff check --fix + ruff format).
|
||||
- Default to sub-agents for reading, searching, building, testing, research. Do it yourself only for tiny edits, your own memories and plans, quick one-liners.
|
||||
- One clear task per agent. Brief with full context — they know nothing of your conversation.
|
||||
- No git, no memory, no dispatch. They build and report; you decide and act.
|
||||
- Sub-agent = local disposable worker. Dispatch (`@ai_mail`) = wake a citizen with memory and identity. Branch-expert work → dispatch; else → sub-agent.
|
||||
- Models, good practice: opus for build and analysis, sonnet for routine investigation, haiku for trivial mechanical tasks. Never fable for sub-agents.
|
||||
|
||||
# aipass CLI
|
||||
# Memory — .trinity/
|
||||
|
||||
`aipass` = standalone binary (`/usr/local/bin/aipass`). User-facing tool — not drone-routed. Users run `aipass` directly without knowing about drone.
|
||||
Your memories are your continuity across sessions. Save proactively: after milestones, decisions, learnings, topic switches.
|
||||
|
||||
Commands: `aipass init`, `aipass doctor`, `aipass handoff`, `aipass help`, `aipass profile`. Never `drone @aipass` — that's not how it works.
|
||||
- `passport.json` — identity. Update only when identity genuinely evolves.
|
||||
- `local.json` — session log, key learnings, todos.
|
||||
- `observations.json` — what you learn about the user.
|
||||
- Overflow rolls to vectors automatically — never trim by hand. Two ChromaDB stores: your branch's `.chroma` (local) + a global one across all branches. `drone @memory search "query"` recalls them. Search before assuming you're cold.
|
||||
|
||||
`aipass init` bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top.
|
||||
|
||||
Source: `src/aipass/cli/apps/handlers/init/bootstrap.py`
|
||||
|
||||
# Standards
|
||||
|
||||
- `drone @seedgo audit aipass` — audit all branches
|
||||
- `drone @seedgo audit aipass @branch` — audit one branch
|
||||
- `drone @seedgo checklist <file>` — quick check single file
|
||||
- `drone @seedgo checklist <dir>` — check all .py in directory
|
||||
- `drone @seedgo --help` — full standards reference
|
||||
|
||||
# Mail — Dispatch, Inbox, Communication
|
||||
|
||||
Use `dispatch` by default. `email` only when receiver doesn't need to act now.
|
||||
|
||||
Send and wake:
|
||||
- `drone @ai_mail dispatch @target "Subject" "Body"` — send + wake (DEFAULT)
|
||||
- `drone @ai_mail dispatch @target "Subject" "Body" --fresh` — send + wake fresh session
|
||||
- `drone @ai_mail dispatch wake @target` — wake only, no email
|
||||
- `drone @ai_mail dispatch wake --fresh @target` — wake fresh, no email
|
||||
|
||||
Send without waking:
|
||||
- `drone @ai_mail email @target "Subject" "Body"` — FYI only
|
||||
- `drone @ai_mail email @target "Subject" "Body" --dispatch` — adds dispatch header, no wake
|
||||
|
||||
Read and reply:
|
||||
- `drone @ai_mail inbox` — check mailbox
|
||||
- `drone @ai_mail view <id>` — read message
|
||||
- `drone @ai_mail close <id>` — mark read
|
||||
- `drone @ai_mail reply <id> "message"` — reply and auto-close
|
||||
- `drone @ai_mail --help` — full mail reference
|
||||
|
||||
Always reply to dispatch emails. Complete task → email back results. No silent completions.
|
||||
|
||||
# Plans (flow)
|
||||
|
||||
Plans manage context you don't need to carry. You don't remember what's in a plan — you remember it exists and where to find it. Registry = catalog.
|
||||
|
||||
- DPLAN = Dev Plan. Thinking, brainstorming, architecture. Before building.
|
||||
- FPLAN = Flow Plan. Building, executing. Plan clear, work underway.
|
||||
- APLAN = Agent Plan. Task assignment to specific agent.
|
||||
- TDPLAN = Team Dev Plan. Multi-branch coordination. Spawns DPLANs across branches.
|
||||
- Master FPLAN — multi-phase execution, spawns sub-FPLANs per phase.
|
||||
- Other types may exist — `drone @flow --help` for current list.
|
||||
|
||||
Commands:
|
||||
- `drone @flow create <path> "Subject" [type]` — create plan. Types: `dplan`, `aplan`, `tdplan`, `master`. Default = FPLAN. Path `.` = current branch.
|
||||
- `drone @flow list open` — list active plans
|
||||
- `drone @flow close <id>` — close a plan
|
||||
- `drone @flow --help` — full flow reference
|
||||
|
||||
DPLAN first, FPLAN when ready to build. Tag plans with searchable keywords — registry becomes lookup tool.
|
||||
|
||||
Never create plan files manually. Always `drone @flow create`. Flow handles numbering (global 4-digit sequence), registry, templates, dates. Manual files break registry. Applies all plan types, any project.
|
||||
|
||||
# Memory
|
||||
|
||||
`.trinity/` files are your memories — experiential, personal, yours. How you persist across sessions.
|
||||
|
||||
Three files:
|
||||
- `passport.json` — IDENTITY. Role, purpose, principles. Update only when identity genuinely evolves.
|
||||
- `local.json` — YOUR MEMORY. Session log (`sessions[]`) + `key_learnings` + `todos[]`. What happened, what learned, what matters next.
|
||||
- `observations.json` — MEMORY OF THE USER. Preferences, style, friction, breakthroughs. Skip if nothing new this session.
|
||||
|
||||
Where to put what:
|
||||
- "Worked on DPLAN-0125, learned about peak hours" → `local.json`
|
||||
- "User prefers short replies" → `observations.json`
|
||||
- "PR #266 needs merge, Track G blocked" → `local.json` todos[]
|
||||
- "Fix drone help formatting" as reminder → `local.json` todos[]
|
||||
- "Role shifted from builder to orchestrator" → `passport.json`
|
||||
|
||||
Save proactively. Triggers: after milestone, decision, learning, before switching topics.
|
||||
|
||||
When local.json overflows limits, memories roll over to vector store via `@memory`. Search past context with `drone @memory search <query>`. `drone @memory --help` for full reference.
|
||||
|
||||
# How to Work
|
||||
|
||||
Plan before executing. Create FPLAN before building anything non-trivial. Plan = continuity.
|
||||
|
||||
You are orchestrator, not builder. Deploy sub-agents to write code, read files, run tests. You manage plan, check output, keep moving. Your context is precious — sub-agents disposable.
|
||||
|
||||
Check seedgo standards. Before: `drone @seedgo checklist <file>`. During: check as you go. After: `drone @seedgo audit aipass @branch` as final gate.
|
||||
|
||||
Ask before spelunking. Need to know how another branch works? Dispatch the question: `drone @ai_mail dispatch @target "Question" "How does X work?"` — expert answer faster than digging unfamiliar files.
|
||||
|
||||
# Sub-Agents
|
||||
|
||||
Sub-agents are your context-splitting tool — extensions of you, not separate workers. Default to using them. Your context window is finite and precious; theirs is disposable.
|
||||
|
||||
Use sub-agents for:
|
||||
- Reading and investigating files (especially outside your branch)
|
||||
- Searching the codebase — grep, find, exploring unfamiliar code
|
||||
- Building anything beyond a small fix (even in your own branch)
|
||||
- Research, audits, comparisons, analysis
|
||||
- Running tests and reporting results
|
||||
- Any task that would consume context you need for orchestrating
|
||||
|
||||
Do it yourself only when:
|
||||
- User explicitly asks you to read or look at something
|
||||
- Tiny edits — fix a typo, update a memory file, small config change
|
||||
- Writing memories, plan updates (your own files)
|
||||
- Quick one-line commands — drone status, inbox check
|
||||
|
||||
How to use them:
|
||||
- One clear task per agent. Big prompt = shallow work. Focused prompt = thorough work.
|
||||
- Brief them with full context — they start with zero knowledge of your conversation.
|
||||
- Foreground when you need results to proceed. Background (`run_in_background: true`) when independent.
|
||||
- Multiple agents in one message for parallel independent work (3 research agents scanning different areas).
|
||||
- They report back results. You synthesize, decide, act.
|
||||
|
||||
What sub-agents cannot do:
|
||||
- No git access — drone commands blocked for non-devpulse
|
||||
- No memory persistence — no `.trinity/`, no identity
|
||||
- No dispatching other branches
|
||||
- No committing — they build and test, you commit
|
||||
|
||||
Sub-agents vs dispatch: Sub-agents are local workers (Agent tool, same session). Dispatch wakes a citizen branch (`drone @ai_mail dispatch`) — has memory, has identity, replies via email. Use dispatch for branch-expert work. Use sub-agents for everything else.
|
||||
|
||||
# Logging & Debugging
|
||||
|
||||
Prax = only logging system. Every branch uses `from aipass.prax import logger`.
|
||||
|
||||
Two channels:
|
||||
- Console — user sees now. Command results, errors, success. Never fail silently.
|
||||
- Prax logs — written to `logs/`. Operational history for debugging. `logger.info()`, `.warning()`, `.error()`.
|
||||
|
||||
Errors go to both. Console tells user. Log tells next session.
|
||||
|
||||
Logs = first diagnostic tool. Check `logs/` before anything else. Don't write debug scripts or print statements — read logs.
|
||||
|
||||
Each branch also has `{branch}_json/` — structured JSON files per handler (config, data, log). Contains operation history, handler configuration, and runtime data. Check these for handler-level debugging alongside prax logs.
|
||||
|
||||
# Hard Rules
|
||||
|
||||
- No cross-branch file edits. Issue in another branch → email them.
|
||||
- No bare imports. Always `from aipass.{module}.apps.modules...`
|
||||
- No hardcoded paths. Use `Path(__file__).parents[N]` or drone for resolution.
|
||||
- No deleting files. Rename `my_handler(disabled).py`, move to sibling `.archive/`. `(disabled)` tag gitignored. Never truly delete.
|
||||
- Verify after fixing. Run test or command to confirm. Don't say "fixed" until verified.
|
||||
- Cross-platform. Public package — Linux, macOS, Windows. `pathlib.Path` not string concat. `Path.home()` not `~`.
|
||||
- Public repo — no local paths in code. Never hardcode `/home/username/...`. Derive from `Path(__file__)`, `Path.home()`, or registry lookups.
|
||||
- Fail to errors, never fall back silently. Can't handle input → explicit error, not silent default.
|
||||
- Never use all caps for emphasis. All caps = shouting, agents deprioritize. Use clear phrasing.
|
||||
|
||||
# Breadcrumbs & Context
|
||||
|
||||
"Full access with no access": can't carry everything, can find anything. You're the librarian, not the encyclopedia. Know the catalog — registries, plan numbers, branch structure.
|
||||
|
||||
Small knowledge traces trigger awareness. Not full knowledge — enough to know something exists and where to find more. Breadcrumb = trigger to answer, not the answer.
|
||||
|
||||
Prompts: plant breadcrumbs, not encyclopedias. Two lines ("this exists, look here") beat twenty explaining how.
|
||||
|
||||
Prompts are signposts, not journals. Injected every turn — keep minimal. Never track state/sessions/context in prompts. State → `.trinity/` + `DASHBOARD.local.json`. Prompts guide; memories record; registries catalog.
|
||||
|
||||
If `drone` can't find the AIPass registry, set `AIPASS_HOME=/path/to/AIPass` in shell profile and `~/.claude/settings.json` env block.
|
||||
# House rules
|
||||
|
||||
- No cross-branch file edits. Issue in another agent's code → mail the owner.
|
||||
- Never delete files. Rename `name(disabled).py` or move to a sibling `.archive/`.
|
||||
- Fail to errors, never fall back silently.
|
||||
- Verify after fixing — don't say "fixed" until a test or command confirms it.
|
||||
- Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`.
|
||||
- No bare imports — always `from aipass.<agent>.apps...`.
|
||||
- Registries are machine-managed (spawn, flow) — never hand-edit them.
|
||||
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts.
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
name: seedgo-audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# Full history: the README-freshness check reads `git log` to find the
|
||||
# last commit touching each branch's .py. A shallow (depth-1) checkout
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
@@ -23,6 +23,11 @@ on:
|
||||
- "src/**"
|
||||
workflow_dispatch:
|
||||
|
||||
# Least-privilege token (Scorecard Token-Permissions). This workflow only
|
||||
# reads the repo to build + smoke-test the wheel; it needs no write scopes.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e-wheel:
|
||||
name: e2e-wheel (${{ matrix.os }})
|
||||
@@ -34,7 +39,7 @@ jobs:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
macos-setup:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -41,12 +41,24 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # keyless Sigstore signing (OIDC); no signing key exists
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Sign artifacts with Sigstore (keyless, OIDC)
|
||||
# Produces dist/<artifact>.sigstore.json bundles next to each wheel/sdist.
|
||||
# The 'gh release create dist/*' step below then attaches them to the
|
||||
# GitHub Release, which is where Scorecard's Signed-Releases check looks.
|
||||
# release-signing-artifacts is disabled: the action's own auto-attach only
|
||||
# fires on a 'release: published' event, but we trigger on 'push: tags',
|
||||
# so we upload the bundles ourselves via the dist/* glob.
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: ./dist/*.tar.gz ./dist/*.whl
|
||||
release-signing-artifacts: false
|
||||
- name: Extract latest CHANGELOG section
|
||||
run: |
|
||||
# Grab the topmost "## [...]" block from CHANGELOG.md as release notes.
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
dependency-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
actions: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: python
|
||||
- uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
windows-setup:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
+151
-6
@@ -2,13 +2,158 @@
|
||||
|
||||
All notable changes to AIPass will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
`YYYY.WNN` (year and ISO week number).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Entries are grouped by merge under a dated section header (`YYYY-MM-DD`). Package
|
||||
releases follow [SemVer](https://semver.org/) and are tracked by the git tag and
|
||||
PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026.W23] - 2026-06-02
|
||||
## [2026-06-11]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **seedgo audit local↔CI parity (FPLAN-0261).** The local `seedgo` audit could
|
||||
silently diverge from CI, breaking the "pass locally first, then ship" gate.
|
||||
Three independent causes, all closed without coupling any checker to git
|
||||
(`.gitignore` is git's concern, not the audit's): (1) usage-scanning checkers
|
||||
(`unused_function`, `dead_code`, +4) `rglob`'d gitignored *output* dirs
|
||||
(`artifacts/`, `dropbox/`), so a stray local file could mark a function "used"
|
||||
that a clean checkout correctly flags — every per-checker skip list hoisted to
|
||||
one shared `SOURCE_SKIP_DIRS` (output dirs simply aren't source). (2) The
|
||||
`diagnostics` standard shelled out to bare `python3 -m pyright` (system python,
|
||||
no pyright) and, on the resulting JSON-parse failure, returned *0 errors = clean*
|
||||
— a silent false-green; now uses `sys.executable` and **fails loud**. (3) pyright
|
||||
resolved imports against PATH-python, so results flipped with `.venv` activation
|
||||
— pinned via `--pythonpath sys.executable`. The audit is now deterministic
|
||||
local == CI (proven all-13-branches-100% in an unactivated shell). Also: `drone`
|
||||
bypasses the test-only broker `start_background` (intentional API, not dead code).
|
||||
- **windows-setup CI: guard Linux-only sandbox tests.** The kernel-sandbox build
|
||||
is Linux-only (bwrap, `AF_UNIX` sockets, `openat2`); the code already guards on
|
||||
`sys.platform`, but four test surfaces ran unconditionally and failed on
|
||||
`windows-latest`. Module-level `pytestmark = pytest.mark.skipif(sys.platform !=
|
||||
"linux", …)` on `drone/tests/test_broker.py` and `hooks/tests/test_sandbox.py`;
|
||||
scoped guards on the remaining `AF_UNIX` broker-socket tests —
|
||||
`ai_mail/.../test_dispatch_monitor.py::TestBrokerRealE2E` (class) and
|
||||
`aipass/.../test_sandbox_check.py::TestCheckBrokerAlive` socket-connect tests
|
||||
(method-level, so the graceful no-broker paths still run on Windows). All skip on
|
||||
Windows and run unchanged on Linux. windows-setup was green pre-sandbox-merge
|
||||
(`00edd8b`) and red since (`0b4ba63`); this closes it.
|
||||
- **Broker `start_background` connect-before-bind race.** `drone`'s out-of-sandbox
|
||||
broker daemon started via `start_background()`, which returned *before* the
|
||||
`AF_UNIX` socket was bound — callers then raced the bind, and on a slower machine
|
||||
`create_identified_connection()` hit `FileNotFoundError` (socket not yet present).
|
||||
Deterministic locally (`test_delete_nested_file` 0/5), green in CI only by timing
|
||||
luck — latent flakiness. Fixed with a `threading.Event` set right after `listen()`;
|
||||
`start_background(timeout=5.0)` now blocks on it and **raises** if the socket never
|
||||
binds, so callers never guess a `sleep`. Removed the 4 blind `time.sleep(0.15)`
|
||||
waits from the broker tests. Verified 55/55 broker tests, formerly-failing test 10/10.
|
||||
|
||||
### Added
|
||||
|
||||
- **`aipass init` detects missing Claude Code.** Stage 6 (CLI choice) now checks
|
||||
`shutil.which("claude")` when the picked CLI is Claude Code. If absent: interactive
|
||||
runs prompt `Install now? [Y/n]` and run the canonical installer on yes (native
|
||||
`claude.ai/install.sh`, PowerShell on Windows, `npm` fallback, 300s timeout, loud on
|
||||
failure); non-interactive runs warn and continue. The whole system routes through
|
||||
Claude Code (hook bridge, dispatch, prompt injection), so init no longer silently
|
||||
assumes the runtime is present. Only fires when the chosen CLI is `claude`.
|
||||
- **Kernel filesystem boundary for agent containment (DPLAN-0202 / FPLAN-0250).**
|
||||
Every autonomous agent can now launch inside a kernel-enforced mount namespace
|
||||
(`@anthropic-ai/sandbox-runtime` → bwrap+seccomp) where reads stay fully open
|
||||
(the shared live filesystem is preserved — a bind-mount, *not* isolation: own-tree
|
||||
writes land live on the real FS instantly) but deletes/overwrites of protected
|
||||
paths (`.git`, sibling branch trees) fail at the kernel no matter how the call is
|
||||
phrased — `rm`, `python os.remove`, `find -delete`, Write tool all hit EROFS.
|
||||
`/tmp` and the agent's own tree stay writable; `.git` is RW for devpulse, RO for
|
||||
builders. A per-role policy generator (`@hooks build_policy`) derives each branch's
|
||||
writable/RO map from its passport. Privileged deletes route through an
|
||||
out-of-sandbox **drone-broker** daemon: identity-scoped allowlist, `openat2`
|
||||
RESOLVE_BENEATH path re-resolution (confused-deputy proof), HMAC identity handshake
|
||||
over a pre-connected inherited fd, JSONL audit. `aipass doctor` gained a **Sandbox**
|
||||
check group (bwrap present+functional, node, srt, rg, broker socket) that is LOUD
|
||||
when the flag is on and a prereq is missing — never a silent unsandboxed launch.
|
||||
Proven by a live 16-check red-team suite. **Inert by default** — gated behind
|
||||
`AIPASS_SANDBOX_ENABLED` (off); flag-off is byte-identical to the old dispatch path.
|
||||
- **rm_gate demoted to guardrail.** Now framed honestly as early-feedback that
|
||||
catches the accidental `rm -rf` and teaches `drone rm` — belt-and-suspenders, with
|
||||
the kernel sandbox as the actual filesystem boundary.
|
||||
|
||||
- **Prompt-injection cadence — fire the big loaders every Nth turn.** The global
|
||||
and branch prompts are large and were re-injected on *every* turn even though a
|
||||
prior copy stays in the conversation. They now fire together every 5th turn
|
||||
(config-tunable via `hooks_json/custom_config/cadence_config.json`), with a
|
||||
per-session turn counter that resets on a new session and after compaction so
|
||||
context is always rebuilt when it's actually needed. Identity and the mail flag
|
||||
stay every-turn (tiny, want freshness). Cuts recurring per-turn context cost.
|
||||
- **Hook fire/skip observability.** Cadence emits a structured
|
||||
`[HOOKS] cadence fired|skipped loader= turn= period= offset=` line; the prax
|
||||
monitor renders hook events distinctly so the cadence is visible live.
|
||||
- **Slim global prompt — context-on-demand.** The always-injected global prompt
|
||||
was rewritten from a ~13.8KB encyclopedia into a ~7.8KB navigation map
|
||||
(DPLAN-0201): `drone` pinned as the router, the framework tree, all 13 agents
|
||||
as short bios, and one drilled reflex — run `drone @agent --help` before using
|
||||
a branch. Detail now lives in each agent's `--help`, fetched on demand. This
|
||||
also dissolves the harness ~10k-char truncation that was silently dropping the
|
||||
old prompt's tail; the slim prompt injects whole. Backup retained alongside.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Shared leaf library re-homed: `aipass.common` → `aipass.aipass.shared` (FPLAN-0260).**
|
||||
`src/aipass/common/` was the only non-citizen directory in the agent namespace —
|
||||
a shared lib (json_handler / json_ops / registry_discovery, extracted in
|
||||
TDPLAN-0006 P2) parked as a sibling to the agents with no owner. Per @seedgo
|
||||
design review it now lives inside its steward at `src/aipass/aipass/shared/`,
|
||||
owned by @aipass; @spawn imports across (same blessed shared-infra category as
|
||||
`aipass.prax`/`aipass.cli`). Content byte-identical; ~9 import/doc sites updated
|
||||
across aipass+spawn. A new subprocess guard test pins the bootstrap-safety
|
||||
invariant: importing `shared/` loads zero branch dependencies, so `aipass init`
|
||||
keeps working pre-drone on fresh machines. Note: `aipass.common` shipped in the
|
||||
v2.5.2 wheel; it was internal plumbing — no deprecation shim.
|
||||
- **Action-gated hook sound.** Piper now speaks only when a hook actually *does*
|
||||
something — handlers return a `sound` key the engine plays, instead of
|
||||
announcing on every invocation. Skipped loaders are silent. Quieter and honest.
|
||||
- **README: hardcoded metrics → live badges + qualitative.** Version is now a
|
||||
live PyPI badge, test/PR counts replaced with a codecov coverage badge (75%
|
||||
minimum) and qualitative wording — no more stale numbers to hand-maintain.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cadence counter separate-process race.** Each `UserPromptSubmit` hook runs as
|
||||
its own OS process, so a module-level turn cache double-incremented and the
|
||||
loaders leapfrogged (firing erratically instead of together). Fixed with an
|
||||
mtime debounce + transcript-size token + `flock` so the counter advances exactly
|
||||
once per real turn, verified against the live execution model.
|
||||
- **`auto_fix` ran no diagnostics.** A leftover `speak()` call (its import removed
|
||||
in the sound refactor) raised `NameError` on every edit, swallowed by the
|
||||
handler's broad `except` — so auto-fix silently surfaced nothing on any
|
||||
`.py`/`.json` edit. Removed the dead call; diagnostics run again.
|
||||
- **Hook events never colored in the monitor.** The prax log-watcher's
|
||||
`_HOOK_PATTERN` required an `action=` field that cadence never emits (it logs
|
||||
the action as the bare second word, `fired`/`skipped`), so extraction failed
|
||||
and events fell through to plain rendering instead of the styled
|
||||
bold-green ⚡ / dim · treatment. Fixed the regex to capture the bare action
|
||||
word and enriched the event detail (period, offset, short session id).
|
||||
|
||||
### Security
|
||||
|
||||
- **Least-privilege token on the `e2e-wheel` workflow.** `e2e-wheel.yml` was the
|
||||
one CI workflow missing a top-level `permissions:` block (it was added during
|
||||
the cross-OS work after PR #624 hardened the others), so it ran with the
|
||||
default broad `GITHUB_TOKEN` scopes — dropping the OpenSSF Scorecard
|
||||
Token-Permissions check to 0. Added `permissions: contents: read`; the
|
||||
workflow only reads the repo to build and smoke-test the wheel.
|
||||
- **Signed GitHub Releases via Sigstore (keyless).** The release workflow now
|
||||
signs the built wheel + sdist with `sigstore/gh-action-sigstore-python`
|
||||
(keyless OIDC — no signing key is generated, stored, or held by anyone) and
|
||||
attaches the resulting `.sigstore.json` bundles to the GitHub Release. PyPI
|
||||
uploads were already attested via Trusted Publishing; this extends verifiable
|
||||
provenance to artifacts pulled from GitHub Releases and satisfies the OpenSSF
|
||||
Scorecard Signed-Releases check. First proof lands on the next `v*` tag.
|
||||
|
||||
---
|
||||
|
||||
## [2026-06-02]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -349,7 +494,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
---
|
||||
|
||||
## [2026.W22] - 2026-05-30
|
||||
## [2026-05-30]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -411,7 +556,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
---
|
||||
|
||||
## [2026.W21] - 2026-05-25
|
||||
## [2026-05-25]
|
||||
|
||||
First weekly release. AIPass now follows a Sunday release cadence: changes
|
||||
accumulate on `dev` throughout the week and merge to `main` as a single
|
||||
|
||||
@@ -47,7 +47,7 @@ my-project/
|
||||
├── .aipass/ # Project config + prompts
|
||||
├── .claude/ # Hooks (injected automatically)
|
||||
├── src/my_project/
|
||||
│ └── my-agent/
|
||||
│ └── my_agent/
|
||||
│ ├── .trinity/ # Identity + memory (3 JSON files)
|
||||
│ ├── .ai_mail.local/ # Local mailbox
|
||||
│ ├── apps/ # Your agent's code
|
||||
@@ -91,7 +91,7 @@ That's it. Your agent has identity, memory, a mailbox, and access to every AIPas
|
||||
|
||||
```bash
|
||||
aipass init # Just the scaffold (no guided setup)
|
||||
aipass init agent my-agent # Add another agent
|
||||
aipass init agent my_agent # Add another agent
|
||||
aipass doctor # Check system health
|
||||
```
|
||||
|
||||
@@ -112,8 +112,8 @@ claude # Talk to the orchestrator
|
||||
|
||||
```bash
|
||||
# Things you can do:
|
||||
aipass doctor # Check system health (15+ checks)
|
||||
drone @seedgo audit aipass # Run 36 quality checks across all agents
|
||||
aipass doctor # Check system health
|
||||
drone @seedgo audit aipass # Run automated quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent
|
||||
```
|
||||
@@ -141,7 +141,7 @@ drone @branch command [args] # Every agent, every task. Drone handles routing
|
||||
```
|
||||
|
||||
```bash
|
||||
drone @seedgo audit my-project # Run quality checks on everything
|
||||
drone @seedgo audit my_project # Run quality checks on everything
|
||||
drone @flow create . "Refactor auth module" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
|
||||
```
|
||||
@@ -161,7 +161,7 @@ AIPass ships with 13 core agents that maintain and develop the framework itself
|
||||
devpulse (orchestrator)
|
||||
├── aipass — concierge + onboarding (aipass init, doctor, profile)
|
||||
├── drone — command routing + @agent resolution
|
||||
├── seedgo — 36 automated quality standards
|
||||
├── seedgo — automated quality standards
|
||||
├── prax — real-time monitoring across all agents
|
||||
├── ai_mail — agent-to-agent communication + task dispatch
|
||||
├── flow — plan lifecycle, templates, auto-archival
|
||||
@@ -195,9 +195,9 @@ These agents work on the **same filesystem, same project, same time** — no san
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | 36 automated quality standards, enforced across all agents |
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | Automated quality standards, enforced across all agents |
|
||||
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — multiple template types, auto-archival, vector verification |
|
||||
| [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch |
|
||||
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
|
||||
| [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output |
|
||||
@@ -212,7 +212,7 @@ AIPass is built and tested with **Claude Code** on Linux/WSL.
|
||||
|
||||
| CLI | Autonomous Mode | Status |
|
||||
|-----|----------------|--------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
|
||||
|
||||
setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
@@ -225,11 +225,11 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Version | 2.4.0 |
|
||||
| Version | [](https://pypi.org/project/aipass/) |
|
||||
| Agents | 13 core + user-created |
|
||||
| Quality standards | 36 automated checks |
|
||||
| Tests | 8,400+ (across all agents) |
|
||||
| PRs merged | 600+ (human-AI collaboration) |
|
||||
| Quality | Automated standards enforced across every agent |
|
||||
| Coverage | [](https://codecov.io/gh/AIOSAI/AIPass) — 75% minimum, CI-gated |
|
||||
| Tests | Extensive — every agent ships its own suite |
|
||||
|
||||
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
||||
|
||||
@@ -238,7 +238,7 @@ Each agent documents its own operational status in its branch README — what wo
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Claude Code](https://code.claude.com/docs)
|
||||
- Linux, macOS, or WSL (all CI-tested)
|
||||
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
||||
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.5.1"
|
||||
version = "2.5.3"
|
||||
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -226,6 +226,92 @@ if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Sandbox prerequisites (kernel FS boundary) ---
|
||||
echo ""
|
||||
echo "Checking sandbox prerequisites ..."
|
||||
|
||||
if [ "$IS_WINDOWS" -eq 1 ] || [ "$IS_MACOS" -eq 1 ]; then
|
||||
echo " kernel sandbox: Linux-only for now, skipping"
|
||||
else
|
||||
SB_MISSING=()
|
||||
|
||||
# bwrap
|
||||
if command -v bwrap &>/dev/null; then
|
||||
echo " bwrap ... $(bwrap --version 2>/dev/null || echo 'found')"
|
||||
else
|
||||
echo " bwrap ... MISSING"
|
||||
echo " sudo apt install bubblewrap"
|
||||
SB_MISSING+=("bwrap")
|
||||
fi
|
||||
|
||||
# node
|
||||
if command -v node &>/dev/null; then
|
||||
echo " node ... $(node --version 2>/dev/null)"
|
||||
else
|
||||
echo " node ... MISSING"
|
||||
echo " Install Node.js: https://nodejs.org/"
|
||||
SB_MISSING+=("node")
|
||||
fi
|
||||
|
||||
# npm (needed for srt install)
|
||||
if command -v npm &>/dev/null; then
|
||||
echo " npm ... $(npm --version 2>/dev/null)"
|
||||
else
|
||||
echo " npm ... MISSING"
|
||||
SB_MISSING+=("npm")
|
||||
fi
|
||||
|
||||
# @anthropic-ai/sandbox-runtime — resolve same way as _srt_resolve.mjs
|
||||
if command -v node &>/dev/null; then
|
||||
SRT_PATH=$(node -e "
|
||||
const p = require('path');
|
||||
const fs = require('fs');
|
||||
const prefix = p.dirname(p.dirname(process.execPath));
|
||||
const entry = p.join(prefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js');
|
||||
if (fs.existsSync(entry)) process.stdout.write(entry);
|
||||
else process.exit(1);
|
||||
" 2>/dev/null) || SRT_PATH=""
|
||||
if [ -n "$SRT_PATH" ]; then
|
||||
echo " srt ... $SRT_PATH"
|
||||
else
|
||||
echo " srt ... MISSING"
|
||||
if command -v npm &>/dev/null; then
|
||||
echo " Attempting: npm install -g @anthropic-ai/sandbox-runtime"
|
||||
if npm install -g @anthropic-ai/sandbox-runtime 2>/dev/null; then
|
||||
echo " srt ... installed"
|
||||
else
|
||||
echo " Install failed (may need sudo). Run manually:"
|
||||
echo " sudo npm install -g @anthropic-ai/sandbox-runtime"
|
||||
SB_MISSING+=("srt")
|
||||
fi
|
||||
else
|
||||
echo " Install node+npm first, then: npm install -g @anthropic-ai/sandbox-runtime"
|
||||
SB_MISSING+=("srt")
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " srt ... skipped (no node)"
|
||||
SB_MISSING+=("srt")
|
||||
fi
|
||||
|
||||
# rg (ripgrep)
|
||||
if command -v rg &>/dev/null; then
|
||||
echo " rg ... $(rg --version 2>/dev/null | head -1)"
|
||||
elif [ -f "$HOME/.local/bin/rg" ]; then
|
||||
echo " rg ... $HOME/.local/bin/rg"
|
||||
else
|
||||
echo " rg ... MISSING"
|
||||
echo " sudo apt install ripgrep"
|
||||
SB_MISSING+=("rg")
|
||||
fi
|
||||
|
||||
if [ ${#SB_MISSING[@]} -eq 0 ]; then
|
||||
echo " sandbox prereqs: READY"
|
||||
else
|
||||
echo " sandbox prereqs: INCOMPLETE (${SB_MISSING[*]} missing) — aipass doctor for details"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Verify CLI entry points ---
|
||||
FAIL=0
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@ pip install aipass
|
||||
https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.5.1"
|
||||
__version__ = "2.5.3"
|
||||
|
||||
@@ -93,7 +93,12 @@
|
||||
{
|
||||
"file": "apps/handlers/dispatch/dispatch_monitor.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Imports notify.send_notification — same-branch cross-handler import for bounce/completion notifications."
|
||||
"reason": "Imports notify.send_notification (same-branch cross-handler) for bounce/completion notifications. Lazy-imports drone.apps.handlers.broker.client.create_identified_connection inside _connect_broker() for Phase 6b broker-fd handshake (FPLAN-0250) — cross-branch handler import authorized by brief."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/dispatch/dispatch_monitor.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Lazy-imports drone.apps.handlers.broker.client.create_identified_connection inside _connect_broker() — cross-branch handler import for Phase 6b broker-fd handshake (FPLAN-0250). Brief explicitly authorizes this import path."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/dispatch/wake.py",
|
||||
|
||||
@@ -23,6 +23,8 @@ is guaranteed.
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import socket
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
@@ -41,6 +43,43 @@ HARD_TIMEOUT = 7200 # 2 hours
|
||||
POLL_INTERVAL = 5
|
||||
|
||||
|
||||
def _is_sandbox_enabled() -> bool:
|
||||
"""Check if dispatch sandbox is enabled via AIPASS_SANDBOX_ENABLED env var."""
|
||||
return os.environ.get("AIPASS_SANDBOX_ENABLED", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _wrap_for_sandbox(cmd: list, branch_path: Path) -> list:
|
||||
"""Wrap a claude command in the srt kernel sandbox.
|
||||
|
||||
Uses @hooks sandbox building blocks to resolve the bwrap command,
|
||||
then returns a shell invocation list compatible with Popen.
|
||||
|
||||
Raises on ANY failure — caller must not silently fall back to unsandboxed.
|
||||
"""
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy, build_srt_config, resolve_bwrap_command
|
||||
|
||||
policy = build_policy(branch_path)
|
||||
srt_config = build_srt_config(policy)
|
||||
cmd_str = shlex.join(cmd)
|
||||
bwrap_cmd = resolve_bwrap_command(cmd_str, srt_config)
|
||||
return ["/bin/bash", "-c", bwrap_cmd]
|
||||
|
||||
|
||||
def _connect_broker(repo_root: Path, branch_name: str) -> socket.socket:
|
||||
"""Create an identified broker connection for the target branch.
|
||||
|
||||
Returns a connected, HMAC-authenticated socket ready to be inherited
|
||||
by the sandboxed child via pass_fds + AIPASS_BROKER_FD.
|
||||
|
||||
Raises on ANY failure — caller must not silently skip the broker.
|
||||
"""
|
||||
from aipass.drone.apps.handlers.broker.client import create_identified_connection
|
||||
|
||||
socket_path = repo_root / ".ai_central" / "drone_broker.sock"
|
||||
secret_path = repo_root / ".ai_central" / "broker_secret"
|
||||
return create_identified_connection(socket_path, secret_path, branch_name)
|
||||
|
||||
|
||||
def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, stderr_log: str) -> bool:
|
||||
"""Send return-to-sender bounce email via drone."""
|
||||
subject = f"BOUNCE: Dispatch to {branch_email} failed"
|
||||
@@ -176,7 +215,7 @@ def _kill_process(process: subprocess.Popen, branch_email: str):
|
||||
|
||||
|
||||
def _run_with_startup_check(
|
||||
claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str
|
||||
claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str, pass_fds: tuple = ()
|
||||
) -> tuple:
|
||||
"""
|
||||
Run claude with startup timeout check.
|
||||
@@ -194,13 +233,16 @@ def _run_with_startup_check(
|
||||
logger.warning("[monitor] Failed to open stdout log %s: %s", stdout_log, e)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
claude_cmd,
|
||||
stdout=stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
|
||||
stderr=stderr_fh,
|
||||
cwd=cwd,
|
||||
env=spawn_env,
|
||||
)
|
||||
popen_kwargs = {
|
||||
"stdout": stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
|
||||
"stderr": stderr_fh,
|
||||
"cwd": cwd,
|
||||
"env": spawn_env,
|
||||
}
|
||||
if pass_fds:
|
||||
popen_kwargs["close_fds"] = True
|
||||
popen_kwargs["pass_fds"] = pass_fds
|
||||
process = subprocess.Popen(claude_cmd, **popen_kwargs)
|
||||
except Exception as e:
|
||||
logger.warning("[monitor] Failed to spawn %s: %s", branch_email, e)
|
||||
if stdout_fh is not None:
|
||||
@@ -338,6 +380,11 @@ def main():
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# ─── Sandbox Gate ─────────────────────────────────────
|
||||
sandbox_enabled = _is_sandbox_enabled()
|
||||
if sandbox_enabled:
|
||||
logger.info("[monitor] Sandbox ENABLED for %s", branch_email)
|
||||
|
||||
# ─── Retry Loop: 3 Strikes ─────────────────────────────
|
||||
# Strike 1: original command (resume if -c was passed)
|
||||
# Strike 2: same command again (transient failure)
|
||||
@@ -356,14 +403,60 @@ def main():
|
||||
cmd = claude_cmd
|
||||
mode = "resume" if has_resume else "fresh"
|
||||
|
||||
# Sandbox wrap + broker fd: when enabled, wrap cmd and connect broker.
|
||||
# On failure: abort — NEVER silently launch unsandboxed.
|
||||
run_cmd = cmd
|
||||
broker_sock = None
|
||||
attempt_pass_fds: tuple = ()
|
||||
if sandbox_enabled:
|
||||
try:
|
||||
run_cmd = _wrap_for_sandbox(cmd, branch_path)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[monitor] Sandbox init FAILED for %s: %s — ABORTING (will NOT launch unsandboxed)",
|
||||
branch_email,
|
||||
e,
|
||||
)
|
||||
exit_code = -4
|
||||
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": False, "mode": mode})
|
||||
break
|
||||
|
||||
try:
|
||||
broker_sock = _connect_broker(_repo_root, branch_email.lstrip("@"))
|
||||
broker_fd = broker_sock.fileno()
|
||||
spawn_env["AIPASS_BROKER_FD"] = str(broker_fd)
|
||||
attempt_pass_fds = (broker_fd,)
|
||||
logger.info("[monitor] Broker fd %d connected for %s", broker_fd, branch_email)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[monitor] Broker connect FAILED for %s: %s — ABORTING",
|
||||
branch_email,
|
||||
e,
|
||||
)
|
||||
exit_code = -4
|
||||
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": False, "mode": mode})
|
||||
break
|
||||
|
||||
if stderr_fh is not None:
|
||||
stderr_fh.write(f"\n--- Attempt {attempt}/3 ({mode}) at {time.strftime('%H:%M:%S')} ---\n")
|
||||
stderr_fh.flush()
|
||||
|
||||
exit_code, startup_failed = _run_with_startup_check(
|
||||
cmd, stdout_log, stderr_fh if stderr_fh is not None else subprocess.DEVNULL, cwd, spawn_env, branch_email
|
||||
run_cmd,
|
||||
stdout_log,
|
||||
stderr_fh if stderr_fh is not None else subprocess.DEVNULL,
|
||||
cwd,
|
||||
spawn_env,
|
||||
branch_email,
|
||||
pass_fds=attempt_pass_fds,
|
||||
)
|
||||
|
||||
# Close parent's broker socket copy — child owns the fd now.
|
||||
if broker_sock is not None:
|
||||
broker_sock.close()
|
||||
broker_sock = None
|
||||
spawn_env.pop("AIPASS_BROKER_FD", None)
|
||||
|
||||
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": startup_failed, "mode": mode})
|
||||
|
||||
# Success — done
|
||||
|
||||
@@ -103,7 +103,7 @@ def parse_send_args(args: List[str]) -> Dict[str, Any]:
|
||||
if recipients and len(rest) >= 2:
|
||||
mode = "direct"
|
||||
subject = rest[0]
|
||||
message = rest[1]
|
||||
message = " ".join(rest[1:])
|
||||
elif not recipients and not rest:
|
||||
mode = "interactive"
|
||||
subject = None
|
||||
|
||||
@@ -277,7 +277,8 @@ def handle_reply(args: List[str]) -> bool:
|
||||
if not original:
|
||||
error(f"Message not found: {args[0]}")
|
||||
return True
|
||||
success, message, reply_id = send_reply(branch_path, original, args[1])
|
||||
reply_message = " ".join(args[1:])
|
||||
success, message, reply_id = send_reply(branch_path, original, reply_message)
|
||||
if success:
|
||||
console.print(f"[green]{message}[/green]")
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# S84: Multi-line Reply Body Truncation — Root Cause & Fix
|
||||
|
||||
## Bug
|
||||
|
||||
Reply and send commands silently truncate multi-line message bodies to the first argument.
|
||||
|
||||
**Reported:** @devpulse dispatch 7b6a70b9 (2026-06-08)
|
||||
**Evidence:** @hooks sent two replies with full multi-line bodies; both arrived in devpulse inbox as first line only (60 chars / 48 chars). @memory's reply arrived intact (951 chars).
|
||||
|
||||
## Root Cause
|
||||
|
||||
Two code paths only captured the second positional CLI argument as the message body, dropping everything after it:
|
||||
|
||||
1. **`email.py:handle_reply`** (line 280):
|
||||
```python
|
||||
send_reply(branch_path, original, args[1]) # args[2:] silently dropped
|
||||
```
|
||||
|
||||
2. **`send_args.py:parse_send_args`** (line 106):
|
||||
```python
|
||||
message = rest[1] # rest[2:] silently dropped
|
||||
```
|
||||
|
||||
When an agent's bash command produces multiple args from a message body (shell word-splitting on unquoted text, or subprocess argument handling), only the first segment survives. The rest is discarded with no warning.
|
||||
|
||||
The entire Python delivery pipeline (reply.py, delivery.py, create.py) handles multi-line strings correctly — the truncation happens at the CLI argument boundary.
|
||||
|
||||
## Why @memory Worked
|
||||
|
||||
@memory's reply body was a single properly-quoted argument that arrived as one `args[1]` entry. @hooks' body was split into multiple args (likely unquoted or shell-expanded), so only the first piece reached `send_reply()`.
|
||||
|
||||
## Fix
|
||||
|
||||
Both locations now join all remaining args:
|
||||
|
||||
1. **`email.py:handle_reply`**: `reply_message = " ".join(args[1:])`
|
||||
2. **`send_args.py:parse_send_args`**: `message = " ".join(rest[1:])`
|
||||
|
||||
Backwards-compatible: single-arg messages pass through unchanged. Multi-arg messages are reconstructed.
|
||||
|
||||
## Tests Added (6)
|
||||
|
||||
- `test_reply.py`: `test_send_reply_multiline_body_preserved` — multi-line body stored intact in delivery and sent copy
|
||||
- `test_email_module.py`: `TestHandleReplyMultiArg` — handle_reply joins split args; single arg unchanged
|
||||
- `test_send_helpers.py`: 3 tests — parse_send_args joins split message; single arg unchanged; embedded newlines preserved
|
||||
|
||||
718 tests pass (712 + 6 new).
|
||||
@@ -9,7 +9,9 @@
|
||||
"""Tests for dispatch_monitor -- startup check, retry loop, bounce, rate limiting."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
@@ -20,11 +22,13 @@ from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import (
|
||||
_check_jsonl_activity,
|
||||
_check_rate_limited,
|
||||
_get_jsonl_projects_dir,
|
||||
_is_sandbox_enabled,
|
||||
_kill_process,
|
||||
_make_fresh_cmd,
|
||||
_run_with_startup_check,
|
||||
_send_bounce,
|
||||
_snapshot_jsonl_sizes,
|
||||
_wrap_for_sandbox,
|
||||
main,
|
||||
)
|
||||
|
||||
@@ -634,7 +638,7 @@ def test_max_turns_changes_notification_status(monkeypatch, main_argv):
|
||||
stdout_log = Path(str(lock_file)).parent.parent / "logs" / "dispatch_stdout.log"
|
||||
stdout_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch):
|
||||
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch, **kwargs):
|
||||
# Simulate writing max_turns output
|
||||
stdout_log.write_text('{"stop_reason":"max_turns"}', encoding="utf-8")
|
||||
return (0, False)
|
||||
@@ -810,7 +814,7 @@ def test_env_vars_set_correctly(monkeypatch, main_argv):
|
||||
|
||||
captured_env = {}
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch):
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
|
||||
captured_env.update(env)
|
||||
return (0, False)
|
||||
|
||||
@@ -900,7 +904,7 @@ def test_main_max_turns_detected(monkeypatch, main_argv):
|
||||
stdout_log = branch_dir / "logs" / "dispatch_stdout.log"
|
||||
stdout_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch):
|
||||
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch, **kwargs):
|
||||
# Write max_turns stop_reason into stdout log
|
||||
Path(stdout_log_path).write_text('{"stop_reason":"max_turns"}', encoding="utf-8")
|
||||
return (0, False)
|
||||
@@ -1075,7 +1079,7 @@ def test_env_vars_setup(monkeypatch, main_argv):
|
||||
|
||||
captured_env = {}
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch):
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
|
||||
captured_env.update(env)
|
||||
return (0, False)
|
||||
|
||||
@@ -1194,3 +1198,626 @@ def test_check_jsonl_activity_no_change(tmp_path):
|
||||
def test_check_jsonl_activity_missing_dir(tmp_path):
|
||||
"""Nonexistent directory -> False."""
|
||||
assert _check_jsonl_activity(tmp_path / "nope", {}) is False
|
||||
|
||||
|
||||
# --- Sandbox gate tests (Phase 4 FPLAN-0250) --------------------------------
|
||||
|
||||
|
||||
class TestIsSandboxEnabled:
|
||||
"""_is_sandbox_enabled reads AIPASS_SANDBOX_ENABLED from env."""
|
||||
|
||||
def test_unset_returns_false(self, monkeypatch):
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
assert _is_sandbox_enabled() is False
|
||||
|
||||
def test_empty_returns_false(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "")
|
||||
assert _is_sandbox_enabled() is False
|
||||
|
||||
def test_false_string_returns_false(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "false")
|
||||
assert _is_sandbox_enabled() is False
|
||||
|
||||
def test_zero_returns_false(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "0")
|
||||
assert _is_sandbox_enabled() is False
|
||||
|
||||
def test_one_returns_true(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
assert _is_sandbox_enabled() is True
|
||||
|
||||
def test_true_returns_true(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "true")
|
||||
assert _is_sandbox_enabled() is True
|
||||
|
||||
def test_yes_returns_true(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "yes")
|
||||
assert _is_sandbox_enabled() is True
|
||||
|
||||
def test_TRUE_case_insensitive(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "TRUE")
|
||||
assert _is_sandbox_enabled() is True
|
||||
|
||||
|
||||
class TestFlagOffOldPath:
|
||||
"""Flag OFF (default): dispatch uses the original cmd, no sandbox wrapping."""
|
||||
|
||||
def test_flag_off_cmd_unchanged(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
|
||||
captured_cmds = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
captured_cmds.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert len(captured_cmds) == 1
|
||||
assert captured_cmds[0] == ["claude", "-c", "--model", "opus"]
|
||||
|
||||
def test_flag_off_wrap_never_called(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
|
||||
wrap_calls = []
|
||||
original_wrap = mod._wrap_for_sandbox
|
||||
|
||||
def tracking_wrap(*args, **kwargs):
|
||||
wrap_calls.append(args)
|
||||
return original_wrap(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_wrap_for_sandbox", tracking_wrap)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
assert wrap_calls == []
|
||||
|
||||
|
||||
class TestFlagOnSandboxPath:
|
||||
"""Flag ON: dispatch wraps cmd via _wrap_for_sandbox."""
|
||||
|
||||
def test_flag_on_cmd_wrapped(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
captured_cmds = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
captured_cmds.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap --sandbox " + " ".join(cmd)],
|
||||
)
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.fileno.return_value = 99
|
||||
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert len(captured_cmds) == 1
|
||||
assert captured_cmds[0][0] == "/bin/bash"
|
||||
assert captured_cmds[0][1] == "-c"
|
||||
assert "bwrap --sandbox" in captured_cmds[0][2]
|
||||
|
||||
def test_wrap_calls_building_blocks(self, monkeypatch, tmp_path):
|
||||
call_log = []
|
||||
|
||||
def mock_build_policy(bp):
|
||||
call_log.append("build_policy")
|
||||
return {"allow_write": [str(bp)], "deny_write": [], "deny_read": []}
|
||||
|
||||
def mock_build_srt_config(policy):
|
||||
call_log.append("build_srt_config")
|
||||
return {"filesystem": {"allowWrite": policy["allow_write"]}}
|
||||
|
||||
def mock_resolve_bwrap(cmd_str, srt_config):
|
||||
call_log.append("resolve_bwrap_command")
|
||||
return f"bwrap --ro-bind / / {cmd_str}"
|
||||
|
||||
monkeypatch.setattr("aipass.hooks.apps.modules.sandbox.build_policy", mock_build_policy)
|
||||
monkeypatch.setattr(
|
||||
"aipass.hooks.apps.modules.sandbox.build_srt_config",
|
||||
mock_build_srt_config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.hooks.apps.modules.sandbox.resolve_bwrap_command",
|
||||
mock_resolve_bwrap,
|
||||
)
|
||||
|
||||
result = _wrap_for_sandbox(["claude", "--model", "opus"], tmp_path)
|
||||
|
||||
assert call_log == ["build_policy", "build_srt_config", "resolve_bwrap_command"]
|
||||
assert result[0] == "/bin/bash"
|
||||
assert result[1] == "-c"
|
||||
assert "claude" in result[2]
|
||||
|
||||
|
||||
class TestBrokenSandboxFailsLoud:
|
||||
"""Flag ON but sandbox init fails: ABORT, never silently unsandbox."""
|
||||
|
||||
def test_sandbox_init_failure_aborts(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
run_calls = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
def broken_wrap(cmd, bp):
|
||||
raise RuntimeError("srt resolve failed: node not found")
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_wrap_for_sandbox", broken_wrap)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert run_calls == []
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_sandbox_failure_sends_bounce(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
def broken_wrap(cmd, bp):
|
||||
raise FileNotFoundError("node not found in PATH")
|
||||
|
||||
mock_bounce = MagicMock()
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_wrap_for_sandbox", broken_wrap)
|
||||
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
mock_bounce.assert_called_once()
|
||||
reason = mock_bounce.call_args[0][1]
|
||||
assert "sandbox" in reason.lower() or "-4" in reason
|
||||
|
||||
def test_never_falls_back_to_unsandboxed(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
wrap_calls = [0]
|
||||
run_calls = []
|
||||
|
||||
def counting_broken_wrap(cmd, bp):
|
||||
wrap_calls[0] += 1
|
||||
raise RuntimeError("srt unavailable")
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_wrap_for_sandbox", counting_broken_wrap)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
assert wrap_calls[0] == 1
|
||||
assert run_calls == []
|
||||
|
||||
|
||||
# --- Broker-fd handshake tests (Phase 6b FPLAN-0250) -------------------------
|
||||
|
||||
|
||||
class TestFlagOffNoBroker:
|
||||
"""Flag OFF: no broker connection attempted at all."""
|
||||
|
||||
def test_flag_off_no_broker_activity(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
|
||||
connect_calls = []
|
||||
|
||||
def tracking_connect(*args, **kwargs):
|
||||
connect_calls.append(args)
|
||||
raise RuntimeError("should never be called")
|
||||
|
||||
monkeypatch.setattr(mod, "_connect_broker", tracking_connect)
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert connect_calls == []
|
||||
|
||||
def test_flag_off_no_broker_fd_in_env(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
|
||||
captured_env = {}
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
|
||||
captured_env.update(env)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
assert "AIPASS_BROKER_FD" not in captured_env
|
||||
|
||||
|
||||
class TestBrokerDownFailsLoud:
|
||||
"""Broker down + flag ON → exit -4, agent never spawned."""
|
||||
|
||||
def test_broker_connect_failure_aborts(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
run_calls = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_connect_broker",
|
||||
MagicMock(side_effect=OSError("broker socket not found")),
|
||||
)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert run_calls == []
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_broker_bad_hmac_aborts(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
run_calls = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_connect_broker",
|
||||
MagicMock(side_effect=RuntimeError("Broker identify failed: bad HMAC")),
|
||||
)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert run_calls == []
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_broker_failure_sends_bounce(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
mock_bounce = MagicMock()
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_connect_broker",
|
||||
MagicMock(side_effect=OSError("socket missing")),
|
||||
)
|
||||
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
mock_bounce.assert_called_once()
|
||||
|
||||
|
||||
class TestBrokerFdHandshake:
|
||||
"""Flag ON + broker up: fd passed to child, parent closes after spawn."""
|
||||
|
||||
def test_broker_fd_in_env_and_pass_fds(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
captured_env = {}
|
||||
captured_pass_fds = []
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, pass_fds=()):
|
||||
captured_env.update(env)
|
||||
captured_pass_fds.append(pass_fds)
|
||||
return (0, False)
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.fileno.return_value = 42
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert captured_env.get("AIPASS_BROKER_FD") == "42"
|
||||
assert captured_pass_fds == [(42,)]
|
||||
mock_sock.close.assert_called_once()
|
||||
|
||||
def test_parent_closes_socket_after_spawn(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.fileno.return_value = 7
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
mock_sock.close.assert_called_once()
|
||||
|
||||
def test_broker_fd_cleaned_from_env_after_spawn(self, monkeypatch, main_argv):
|
||||
"""After spawn+close, AIPASS_BROKER_FD removed from spawn_env."""
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
env_snapshots = []
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, pass_fds=()):
|
||||
env_snapshots.append(dict(env))
|
||||
return (0, False)
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.fileno.return_value = 10
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
# During the run, env had the FD
|
||||
assert env_snapshots[0]["AIPASS_BROKER_FD"] == "10"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker daemon is Linux-only")
|
||||
class TestBrokerRealE2E:
|
||||
"""Real multi-process e2e: broker daemon, identified connection, child reads fd."""
|
||||
|
||||
def test_child_inherits_broker_fd(self, tmp_path):
|
||||
"""Start real broker, create identified conn, spawn child that reads AIPASS_BROKER_FD."""
|
||||
import time as time_mod
|
||||
from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon
|
||||
from aipass.drone.apps.handlers.broker.client import create_identified_connection
|
||||
|
||||
# Set up repo root with branch dir
|
||||
repo_root = tmp_path / "repo"
|
||||
branch_dir = repo_root / "src" / "aipass" / "testbranch"
|
||||
branch_dir.mkdir(parents=True)
|
||||
target_file = branch_dir / "deleteme.txt"
|
||||
target_file.write_text("delete me", encoding="utf-8")
|
||||
|
||||
# Start real broker
|
||||
sock_path = tmp_path / "broker.sock"
|
||||
audit_path = tmp_path / "audit.jsonl"
|
||||
secret_path = tmp_path / "secret"
|
||||
broker = BrokerDaemon(
|
||||
repo_root=repo_root,
|
||||
socket_path=sock_path,
|
||||
audit_path=audit_path,
|
||||
secret_path=secret_path,
|
||||
)
|
||||
t = broker.start_background()
|
||||
time_mod.sleep(0.5)
|
||||
|
||||
try:
|
||||
# Create identified connection (as the launcher would)
|
||||
sock = create_identified_connection(sock_path, secret_path, "testbranch")
|
||||
broker_fd = sock.fileno()
|
||||
|
||||
# Spawn a real child that reads AIPASS_BROKER_FD and sends a delete
|
||||
child_script = tmp_path / "child.py"
|
||||
child_script.write_text(
|
||||
"""
|
||||
import os, socket, json
|
||||
|
||||
fd = int(os.environ["AIPASS_BROKER_FD"])
|
||||
s = socket.socket(fileno=fd)
|
||||
try:
|
||||
req = json.dumps({"op": "delete", "path": "deleteme.txt", "request_id": "e2e1"}) + "\\n"
|
||||
s.sendall(req.encode())
|
||||
data = b""
|
||||
while b"\\n" not in data:
|
||||
chunk = s.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
resp = json.loads(data.decode())
|
||||
# Write result to a file so parent can verify
|
||||
with open(os.environ["RESULT_FILE"], "w") as f:
|
||||
json.dump(resp, f)
|
||||
finally:
|
||||
s.detach()
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result_file = tmp_path / "result.json"
|
||||
env = os.environ.copy()
|
||||
env["AIPASS_BROKER_FD"] = str(broker_fd)
|
||||
env["RESULT_FILE"] = str(result_file)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(child_script)],
|
||||
env=env,
|
||||
pass_fds=(broker_fd,),
|
||||
close_fds=True,
|
||||
)
|
||||
# Parent closes its copy
|
||||
sock.close()
|
||||
|
||||
proc.wait(timeout=10)
|
||||
assert proc.returncode == 0
|
||||
|
||||
# Verify the delete happened
|
||||
assert not target_file.exists()
|
||||
|
||||
# Verify the child got a success response
|
||||
import json as json_mod
|
||||
|
||||
result = json_mod.loads(result_file.read_text(encoding="utf-8"))
|
||||
assert result["ok"] is True
|
||||
|
||||
# Verify audit log carries identity
|
||||
audit_lines = audit_path.read_text(encoding="utf-8").strip().splitlines()
|
||||
delete_entries = [
|
||||
json_mod.loads(line) for line in audit_lines if json_mod.loads(line).get("op") == "delete"
|
||||
]
|
||||
assert len(delete_entries) >= 1
|
||||
assert delete_entries[-1]["identity"] == "testbranch"
|
||||
assert delete_entries[-1]["result"] == "DELETED"
|
||||
|
||||
finally:
|
||||
broker.stop()
|
||||
t.join(timeout=3)
|
||||
|
||||
@@ -1761,3 +1761,62 @@ class TestSendInteractiveExtended:
|
||||
assert result is True
|
||||
assert any("@alpha" in p for p in printed)
|
||||
assert any("sent" in p.lower() for p in printed)
|
||||
|
||||
|
||||
class TestHandleReplyMultiArg:
|
||||
"""Regression tests for multi-line reply body truncation (S84 fix)."""
|
||||
|
||||
def test_reply_joins_split_args_into_body(self, tmp_path, monkeypatch):
|
||||
"""When shell splits body into multiple args, all are joined into message."""
|
||||
original = {"id": "msg1", "from": "@devpulse", "subject": "test dispatch"}
|
||||
captured_msg = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email._resolve_branch_path",
|
||||
lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.get_email_by_id",
|
||||
lambda inbox_file, msg_id: original,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.send_reply",
|
||||
lambda bp, orig, msg: (captured_msg.append(msg), "Reply sent", "r1")[1:],
|
||||
)
|
||||
mock_console = MagicMock()
|
||||
monkeypatch.setattr("aipass.ai_mail.apps.modules.email.console", mock_console)
|
||||
_write_inbox(tmp_path)
|
||||
|
||||
from aipass.ai_mail.apps.modules.email import handle_reply
|
||||
|
||||
result = handle_reply(["msg1", "Line one", "Line two", "Line three"])
|
||||
assert result is True
|
||||
assert len(captured_msg) == 1
|
||||
assert captured_msg[0] == "Line one Line two Line three"
|
||||
|
||||
def test_reply_single_arg_unchanged(self, tmp_path, monkeypatch):
|
||||
"""Single-arg reply body remains unchanged (no extra spaces)."""
|
||||
original = {"id": "msg1", "from": "@devpulse", "subject": "test"}
|
||||
captured_msg = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email._resolve_branch_path",
|
||||
lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.get_email_by_id",
|
||||
lambda inbox_file, msg_id: original,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.send_reply",
|
||||
lambda bp, orig, msg: (captured_msg.append(msg), "Reply sent", "r1")[1:],
|
||||
)
|
||||
mock_console = MagicMock()
|
||||
monkeypatch.setattr("aipass.ai_mail.apps.modules.email.console", mock_console)
|
||||
_write_inbox(tmp_path)
|
||||
|
||||
from aipass.ai_mail.apps.modules.email import handle_reply
|
||||
|
||||
result = handle_reply(["msg1", "Complete single-line reply"])
|
||||
assert result is True
|
||||
assert captured_msg[0] == "Complete single-line reply"
|
||||
|
||||
@@ -268,3 +268,40 @@ def test_send_reply_re_prefix_not_duplicated(tmp_path):
|
||||
assert success is True
|
||||
# Should keep "RE: Already replied", not "RE: RE: Already replied"
|
||||
assert deliver_calls[0][1]["subject"] == "RE: Already replied"
|
||||
|
||||
|
||||
def test_send_reply_multiline_body_preserved(tmp_path):
|
||||
"""Multi-line reply body is stored intact, not truncated to first line."""
|
||||
from_branch_path = tmp_path / "hooks"
|
||||
from_branch_path.mkdir()
|
||||
sender_info = {"email": "@hooks", "name": "HOOKS"}
|
||||
target_branch = {"email": "@devpulse", "name": "DEVPULSE", "path": str(tmp_path / "devpulse")}
|
||||
original = _make_original_email()
|
||||
|
||||
deliver_calls = []
|
||||
|
||||
def mock_deliver(to_branch, email_data):
|
||||
deliver_calls.append((to_branch, email_data))
|
||||
return (True, "")
|
||||
|
||||
multiline_body = (
|
||||
"Investigation: cadence findings\n\nDetails:\n1. First finding\n2. Second finding\n3. Third finding"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(_PATCH_BRANCH_DETECTION, return_value=sender_info),
|
||||
patch(_PATCH_DELIVERY, side_effect=mock_deliver),
|
||||
patch(_PATCH_ALL_BRANCHES, return_value=[target_branch]),
|
||||
patch(_PATCH_CLOSE_ARCHIVE, return_value=(True, "closed")),
|
||||
):
|
||||
success, _message, _reply_id = send_reply(from_branch_path, original, multiline_body)
|
||||
|
||||
assert success is True
|
||||
assert deliver_calls[0][1]["message"] == multiline_body
|
||||
|
||||
sent_folder = from_branch_path / ".ai_mail.local" / "sent"
|
||||
sent_files = list(sent_folder.glob("*.json"))
|
||||
assert len(sent_files) == 1
|
||||
with open(sent_files[0], "r", encoding="utf-8") as f:
|
||||
sent_data = json.load(f)
|
||||
assert sent_data["message"] == multiline_body
|
||||
|
||||
@@ -357,3 +357,34 @@ def test_resolve_dispatch_target_tilde_path():
|
||||
result = resolve_dispatch_target("~/Projects/flow", True, get_branch_info_fn=None)
|
||||
|
||||
assert result == "@flow"
|
||||
|
||||
|
||||
# ---- parse_send_args multi-arg message tests (S84 fix) --------
|
||||
|
||||
|
||||
def test_parse_send_args_joins_split_message():
|
||||
"""When message body is split into multiple args, all are joined."""
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args
|
||||
|
||||
result = parse_send_args(["@target", "Subject", "Line one", "Line two", "Line three"])
|
||||
assert result["mode"] == "direct"
|
||||
assert result["subject"] == "Subject"
|
||||
assert result["message"] == "Line one Line two Line three"
|
||||
|
||||
|
||||
def test_parse_send_args_single_message_unchanged():
|
||||
"""Single message arg is not altered."""
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args
|
||||
|
||||
result = parse_send_args(["@target", "Subject", "Complete body here"])
|
||||
assert result["mode"] == "direct"
|
||||
assert result["message"] == "Complete body here"
|
||||
|
||||
|
||||
def test_parse_send_args_multiline_body_preserved():
|
||||
"""A single arg with embedded newlines passes through intact."""
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args
|
||||
|
||||
body = "First line\nSecond line\nThird line"
|
||||
result = parse_send_args(["@target", "Subject", body])
|
||||
assert result["message"] == body
|
||||
|
||||
@@ -570,13 +570,18 @@ class TestDispatchEnvIsolation:
|
||||
)
|
||||
|
||||
def test_dispatch_monitor_passes_spawn_env_to_subprocess(self):
|
||||
"""dispatch_monitor.py must pass env=spawn_env to subprocess.run.
|
||||
"""dispatch_monitor.py must pass spawn_env as the subprocess env.
|
||||
|
||||
Without this, all env var isolation is useless — the subprocess
|
||||
would inherit os.environ instead of the cleaned spawn_env.
|
||||
Accepts either the direct kwarg form (env=spawn_env) or the
|
||||
popen_kwargs dict form ("env": spawn_env) introduced with the
|
||||
sandbox broker-fd wiring (FPLAN-0250 Phase 6b).
|
||||
"""
|
||||
active_source = self._load_active_source()
|
||||
assert "env=spawn_env" in active_source, "dispatch_monitor.py must pass env=spawn_env to subprocess.run"
|
||||
assert "env=spawn_env" in active_source or '"env": spawn_env' in active_source, (
|
||||
"dispatch_monitor.py must pass spawn_env as the subprocess env"
|
||||
)
|
||||
|
||||
def test_detect_resolves_identity_when_cwd_is_wrong(self, clean_env, tmp_path, list_format_registry):
|
||||
"""When AIPASS_CALLER_BRANCH is set but CWD is outside any branch,
|
||||
|
||||
@@ -274,7 +274,67 @@
|
||||
{
|
||||
"file": "apps/handlers/json/json_handler.py",
|
||||
"standard": "test_quality",
|
||||
"reason": "save_json now raises ValueError on invalid structure (aipass.common contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test."
|
||||
"reason": "save_json now raises ValueError on invalid structure (aipass.aipass.shared contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_sandbox_check.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_sandbox_check.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly (sandbox_checker, progress) to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_sandbox_check.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names (test_flag_off_by_default, test_bwrap_functional_live) that are self-documenting. Adding docstrings to 41 test functions adds noise without value."
|
||||
},
|
||||
{
|
||||
"file": "shared/json_handler.py",
|
||||
"standard": "architecture",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_handler.py",
|
||||
"standard": "log_visibility",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_handler.py",
|
||||
"standard": "trigger",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "architecture",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "log_visibility",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "trigger",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/registry_discovery.py",
|
||||
"standard": "architecture",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_shared_bootstrap_safety.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "backup_json() is consumed by @spawn (cross-branch caller). Appears unused in @aipass-only scan but is a shared API."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_handler.py
|
||||
# Description: Branch-local shim — delegates to aipass.common.json_handler
|
||||
# Description: Branch-local shim — delegates to aipass.aipass.shared.json_handler
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-06-06
|
||||
# =============================================
|
||||
|
||||
"""Branch-local JSON handler — thin shim over the shared ``aipass.common`` library.
|
||||
"""Branch-local JSON handler — thin shim over the shared ``aipass.aipass.shared`` library.
|
||||
|
||||
All logic lives in ``aipass.common.json_handler.JsonHandler``.
|
||||
All logic lives in ``aipass.aipass.shared.json_handler.JsonHandler``.
|
||||
This module binds a ``JsonHandler`` instance to the aipass branch's
|
||||
``aipass_json/`` directory and re-exports the public API as module-level
|
||||
functions so existing callers (``json_handler.log_operation(...)``) keep working.
|
||||
@@ -20,7 +20,7 @@ import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aipass.common.json_handler import JsonHandler
|
||||
from aipass.aipass.shared.json_handler import JsonHandler
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""sandbox_check — Kernel sandbox prerequisite detection for aipass doctor."""
|
||||
|
||||
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import ( # type: ignore[import-not-found]
|
||||
check_broker_alive,
|
||||
check_bwrap_functional,
|
||||
check_bwrap_present,
|
||||
check_node_present,
|
||||
check_rg_present,
|
||||
check_sandbox_flag,
|
||||
check_srt_resolvable,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"check_broker_alive",
|
||||
"check_bwrap_functional",
|
||||
"check_bwrap_present",
|
||||
"check_node_present",
|
||||
"check_rg_present",
|
||||
"check_sandbox_flag",
|
||||
"check_srt_resolvable",
|
||||
]
|
||||
@@ -0,0 +1,253 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: sandbox_checker.py
|
||||
# Description: Kernel sandbox prerequisite checks for aipass doctor
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-10
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Sandbox prerequisite checker — detects bwrap, node, srt, rg, broker.
|
||||
|
||||
Returns plain dicts with facts about sandbox readiness.
|
||||
No Rich markup — display concerns belong to the UI layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def check_sandbox_flag() -> Dict[str, Any]:
|
||||
"""Check AIPASS_SANDBOX_ENABLED env var state.
|
||||
|
||||
Returns:
|
||||
enabled: bool
|
||||
raw_value: str — the raw env value (empty if unset)
|
||||
"""
|
||||
raw = os.environ.get("AIPASS_SANDBOX_ENABLED", "")
|
||||
enabled = raw.lower() in ("1", "true", "yes")
|
||||
json_handler.log_operation("sandbox_check_flag", {"enabled": enabled, "raw": raw})
|
||||
return {"enabled": enabled, "raw_value": raw}
|
||||
|
||||
|
||||
def check_bwrap_present() -> Dict[str, Any]:
|
||||
"""Check if bubblewrap (bwrap) binary is on PATH.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
path: str | None — resolved path if found
|
||||
"""
|
||||
path = shutil.which("bwrap")
|
||||
json_handler.log_operation("sandbox_check_bwrap_present", {"found": bool(path)})
|
||||
return {"found": bool(path), "path": path}
|
||||
|
||||
|
||||
def check_bwrap_functional() -> Dict[str, Any]:
|
||||
"""Run a trivial bwrap sandbox to verify it actually works.
|
||||
|
||||
Catches AppArmor/userns restrictions that make bwrap present but blocked.
|
||||
|
||||
Returns:
|
||||
ok: bool
|
||||
detail: str — success message or error detail
|
||||
sysctl_value: str | None — kernel.apparmor_restrict_unprivileged_userns on failure
|
||||
"""
|
||||
bwrap = shutil.which("bwrap")
|
||||
if not bwrap:
|
||||
return {"ok": False, "detail": "bwrap not found", "sysctl_value": None}
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bwrap, "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "true"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
json_handler.log_operation("sandbox_check_bwrap_functional", {"ok": True})
|
||||
return {"ok": True, "detail": "trivial sandbox succeeded", "sysctl_value": None}
|
||||
|
||||
sysctl_val = _read_userns_sysctl()
|
||||
detail = f"exit {proc.returncode}"
|
||||
if proc.stderr.strip():
|
||||
detail = f"{detail}: {proc.stderr.strip()[:200]}"
|
||||
json_handler.log_operation("sandbox_check_bwrap_functional", {"ok": False, "detail": detail})
|
||||
return {"ok": False, "detail": detail, "sysctl_value": sysctl_val}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[sandbox_check] bwrap functional test timed out")
|
||||
return {"ok": False, "detail": "timed out (10s)", "sysctl_value": None}
|
||||
except OSError as exc:
|
||||
logger.warning("[sandbox_check] bwrap functional test error: %s", exc)
|
||||
return {"ok": False, "detail": str(exc), "sysctl_value": None}
|
||||
|
||||
|
||||
def _read_userns_sysctl() -> str | None:
|
||||
"""Read kernel.apparmor_restrict_unprivileged_userns sysctl if available."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["sysctl", "-n", "kernel.apparmor_restrict_unprivileged_userns"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return proc.stdout.strip()
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[sandbox_check] sysctl read failed (expected on non-Ubuntu): %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def check_node_present() -> Dict[str, Any]:
|
||||
"""Check if node binary is on PATH.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
path: str | None — resolved path if found
|
||||
"""
|
||||
path = shutil.which("node")
|
||||
json_handler.log_operation("sandbox_check_node", {"found": bool(path)})
|
||||
return {"found": bool(path), "path": path}
|
||||
|
||||
|
||||
def check_srt_resolvable() -> Dict[str, Any]:
|
||||
"""Check if @anthropic-ai/sandbox-runtime is resolvable via node.
|
||||
|
||||
Mirrors _srt_resolve.mjs resolution: derive node prefix from process.execPath,
|
||||
then check <prefix>/lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
path: str | None — resolved entry path if found
|
||||
install_hint: str — npm install command if missing
|
||||
"""
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
return {
|
||||
"found": False,
|
||||
"path": None,
|
||||
"install_hint": "Install node first, then: npm install -g @anthropic-ai/sandbox-runtime",
|
||||
}
|
||||
|
||||
try:
|
||||
script = (
|
||||
"const p = require('path');"
|
||||
"const prefix = p.dirname(p.dirname(process.execPath));"
|
||||
"const entry = p.join(prefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js');"
|
||||
"const fs = require('fs');"
|
||||
"if (fs.existsSync(entry)) { process.stdout.write(entry); }"
|
||||
"else { process.exit(1); }"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[node, "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
path = proc.stdout.strip()
|
||||
json_handler.log_operation("sandbox_check_srt", {"found": True, "path": path})
|
||||
return {"found": True, "path": path, "install_hint": ""}
|
||||
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.warning("[sandbox_check] srt resolve error: %s", exc)
|
||||
|
||||
json_handler.log_operation("sandbox_check_srt", {"found": False})
|
||||
return {
|
||||
"found": False,
|
||||
"path": None,
|
||||
"install_hint": "npm install -g @anthropic-ai/sandbox-runtime",
|
||||
}
|
||||
|
||||
|
||||
def check_rg_present() -> Dict[str, Any]:
|
||||
"""Check if ripgrep (rg) is available — matches hooks' fallback logic.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
path: str | None — resolved path if found
|
||||
"""
|
||||
rg = shutil.which("rg")
|
||||
if rg:
|
||||
json_handler.log_operation("sandbox_check_rg", {"found": True, "path": rg})
|
||||
return {"found": True, "path": rg}
|
||||
|
||||
fallback = Path.home() / ".local" / "bin" / "rg"
|
||||
if fallback.is_file():
|
||||
path = str(fallback)
|
||||
json_handler.log_operation("sandbox_check_rg", {"found": True, "path": path})
|
||||
return {"found": True, "path": path}
|
||||
|
||||
json_handler.log_operation("sandbox_check_rg", {"found": False})
|
||||
return {"found": False, "path": None}
|
||||
|
||||
|
||||
def check_broker_alive(repo_root: Path | None = None) -> Dict[str, Any]:
|
||||
"""Check if the broker daemon socket is accepting connections.
|
||||
|
||||
Args:
|
||||
repo_root: Project root containing .ai_central/. Auto-detected if None.
|
||||
|
||||
Returns:
|
||||
alive: bool
|
||||
detail: str — status message
|
||||
"""
|
||||
sock_path = _find_broker_socket(repo_root)
|
||||
if sock_path is None:
|
||||
json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": "socket_not_found"})
|
||||
return {"alive": False, "detail": "broker socket not found"}
|
||||
|
||||
if not sock_path.exists():
|
||||
json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": "socket_missing"})
|
||||
return {"alive": False, "detail": f"socket missing: {sock_path}"}
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
s.connect(str(sock_path))
|
||||
s.close()
|
||||
json_handler.log_operation("sandbox_check_broker", {"alive": True})
|
||||
return {"alive": True, "detail": "connected"}
|
||||
except (OSError, socket.timeout) as exc:
|
||||
logger.info("[sandbox_check] broker connect failed: %s", exc)
|
||||
json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": str(exc)})
|
||||
return {"alive": False, "detail": f"connect failed: {exc}"}
|
||||
|
||||
|
||||
def _find_broker_socket(repo_root: Path | None) -> Path | None:
|
||||
"""Locate the broker socket under $REPO/.ai_central/drone_broker.sock."""
|
||||
if repo_root and (repo_root / ".ai_central" / "drone_broker.sock").parent.is_dir():
|
||||
return repo_root / ".ai_central" / "drone_broker.sock"
|
||||
|
||||
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||
if aipass_home:
|
||||
candidate = Path(aipass_home) / ".ai_central" / "drone_broker.sock"
|
||||
if candidate.parent.is_dir():
|
||||
return candidate
|
||||
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd, *cwd.parents]:
|
||||
candidate = parent / ".ai_central" / "drone_broker.sock"
|
||||
if candidate.parent.is_dir():
|
||||
return candidate
|
||||
if parent == parent.parent:
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_linux() -> bool:
|
||||
"""Return True if running on Linux."""
|
||||
return sys.platform.startswith("linux")
|
||||
@@ -20,9 +20,19 @@ from typing import Dict, List, NamedTuple
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.common.registry_discovery import find_registry as _discover_registry
|
||||
from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import (
|
||||
check_broker_alive,
|
||||
check_bwrap_functional,
|
||||
check_bwrap_present,
|
||||
check_node_present,
|
||||
check_rg_present,
|
||||
check_sandbox_flag,
|
||||
check_srt_resolvable,
|
||||
is_linux,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
check_placement,
|
||||
check_pyproject,
|
||||
@@ -545,11 +555,105 @@ def _check_structure() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
# --- Sandbox check group ---
|
||||
|
||||
|
||||
def _check_sandbox() -> List[CheckResult]:
|
||||
"""Run Sandbox group checks — kernel sandbox prerequisites."""
|
||||
results: List[CheckResult] = []
|
||||
|
||||
if not is_linux():
|
||||
results.append(CheckResult("sandbox", GLYPH_PASS, "kernel sandbox: Linux-only, not checked", ""))
|
||||
return results
|
||||
|
||||
flag = check_sandbox_flag()
|
||||
flag_on = flag["enabled"]
|
||||
flag_label = "ON" if flag_on else "OFF"
|
||||
results.append(CheckResult("sandbox flag", GLYPH_PASS, f"AIPASS_SANDBOX_ENABLED={flag_label}", ""))
|
||||
|
||||
def _sev(ok: bool) -> str:
|
||||
if ok:
|
||||
return GLYPH_PASS
|
||||
return GLYPH_FAIL if flag_on else GLYPH_WARN
|
||||
|
||||
def _suffix(ok: bool) -> str:
|
||||
if ok or flag_on:
|
||||
return ""
|
||||
return " (inert — flag is off)"
|
||||
|
||||
bwrap = check_bwrap_present()
|
||||
results.append(
|
||||
CheckResult(
|
||||
"bwrap",
|
||||
_sev(bwrap["found"]),
|
||||
bwrap["path"] or "not found" + _suffix(bwrap["found"]),
|
||||
"" if bwrap["found"] else "sudo apt install bubblewrap",
|
||||
)
|
||||
)
|
||||
|
||||
if bwrap["found"]:
|
||||
func = check_bwrap_functional()
|
||||
detail = func["detail"]
|
||||
if not func["ok"] and func["sysctl_value"] is not None:
|
||||
detail = f"{detail} (apparmor_restrict_unprivileged_userns={func['sysctl_value']})"
|
||||
results.append(
|
||||
CheckResult(
|
||||
"bwrap functional",
|
||||
_sev(func["ok"]),
|
||||
detail + _suffix(func["ok"]),
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
node = check_node_present()
|
||||
results.append(
|
||||
CheckResult(
|
||||
"node",
|
||||
_sev(node["found"]),
|
||||
node["path"] or "not found" + _suffix(node["found"]),
|
||||
"" if node["found"] else "Install Node.js: https://nodejs.org/",
|
||||
)
|
||||
)
|
||||
|
||||
srt = check_srt_resolvable()
|
||||
results.append(
|
||||
CheckResult(
|
||||
"srt (@anthropic-ai/sandbox-runtime)",
|
||||
_sev(srt["found"]),
|
||||
srt["path"] or "not found" + _suffix(srt["found"]),
|
||||
"" if srt["found"] else srt["install_hint"],
|
||||
)
|
||||
)
|
||||
|
||||
rg = check_rg_present()
|
||||
results.append(
|
||||
CheckResult(
|
||||
"rg (ripgrep)",
|
||||
_sev(rg["found"]),
|
||||
rg["path"] or "not found" + _suffix(rg["found"]),
|
||||
"" if rg["found"] else "sudo apt install ripgrep (or static binary to ~/.local/bin/rg)",
|
||||
)
|
||||
)
|
||||
|
||||
project_root = find_project_root(Path.cwd())
|
||||
broker = check_broker_alive(project_root)
|
||||
results.append(
|
||||
CheckResult(
|
||||
"broker daemon",
|
||||
_sev(broker["alive"]),
|
||||
broker["detail"] + _suffix(broker["alive"]),
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# --- Main doctor run ---
|
||||
|
||||
|
||||
def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int:
|
||||
"""Run all five groups and print results. Returns error count."""
|
||||
"""Run all six groups and print results. Returns error count."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass doctor[/bold cyan]")
|
||||
console.print()
|
||||
@@ -560,6 +664,7 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
|
||||
("Services", lambda: _check_services(verbose=verbose)),
|
||||
("Community", _check_community),
|
||||
("Structure", _check_structure),
|
||||
("Sandbox", _check_sandbox),
|
||||
]
|
||||
groups: Dict[str, List[CheckResult]] = {}
|
||||
with make_doctor_progress() as progress:
|
||||
@@ -620,7 +725,7 @@ def print_introspection() -> None:
|
||||
console.print("[bold cyan]doctor Module[/bold cyan]")
|
||||
console.print("System health aggregation — flutter-doctor-style output")
|
||||
console.print()
|
||||
console.print("[yellow]Groups:[/yellow] System, Identity, Services, Community, Structure")
|
||||
console.print("[yellow]Groups:[/yellow] System, Identity, Services, Community, Structure, Sandbox")
|
||||
console.print("[yellow]Next:[/yellow] [green]aipass doctor[/green] / [green]aipass doctor --fix[/green]")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from typing import List, NamedTuple
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.common.registry_discovery import find_registry as _discover_registry
|
||||
from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
|
||||
@@ -377,6 +377,54 @@ def stage_5_style_questions(
|
||||
return {"style": style}
|
||||
|
||||
|
||||
def _install_claude_code() -> bool:
|
||||
"""Run the canonical Claude Code installer, platform-aware. Returns True on success."""
|
||||
if sys.platform == "win32":
|
||||
cmd = ["powershell", "-Command", "irm https://claude.ai/install.ps1 | iex"]
|
||||
else:
|
||||
cmd = ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, timeout=300)
|
||||
if result.returncode == 0 and shutil.which("claude"):
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
||||
logger.warning("[init_flow] Claude Code installer failed: %s", exc)
|
||||
|
||||
if shutil.which("npm"):
|
||||
console.print("[dim]Native installer didn't work — trying npm fallback...[/dim]")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npm", "install", "-g", "@anthropic-ai/claude-code"],
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode == 0 and shutil.which("claude"):
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
||||
logger.warning("[init_flow] npm fallback install failed: %s", exc)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _handle_missing_claude(non_interactive: bool) -> None:
|
||||
"""Prompt to install Claude Code when missing, or warn in non-interactive mode."""
|
||||
if non_interactive:
|
||||
warning("[bold yellow]Claude Code ('claude') is not installed.[/bold yellow]")
|
||||
console.print(" Stage 11 handoff requires it. Install manually before then.")
|
||||
return
|
||||
|
||||
raw = _prompt("Claude Code ('claude') not found. Install now? [Y/n]", "Y")
|
||||
if raw.lower() in ("y", "yes", ""):
|
||||
console.print("[dim]Installing Claude Code...[/dim]")
|
||||
if _install_claude_code():
|
||||
console.print("[green]✓[/green] Claude Code installed successfully.")
|
||||
else:
|
||||
warning("[bold yellow]Installation failed.[/bold yellow]")
|
||||
console.print(" Install manually: https://claude.ai/download")
|
||||
else:
|
||||
console.print("[dim]Skipped. Stage 11 handoff will need 'claude' on PATH.[/dim]")
|
||||
|
||||
|
||||
def stage_6_tool_choice(
|
||||
non_interactive: bool = False,
|
||||
cli_override: str | None = None,
|
||||
@@ -393,6 +441,9 @@ def stage_6_tool_choice(
|
||||
else:
|
||||
cli_choice = _choose("Which CLI tool do you use?", CLI_CHOICES, default="claude")
|
||||
|
||||
if cli_choice == "claude" and not shutil.which("claude"):
|
||||
_handle_missing_claude(non_interactive)
|
||||
|
||||
if non_interactive:
|
||||
flag_variant = "default"
|
||||
else:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# aipass.aipass.shared — shared leaf utilities (no branch dependencies, loads pre-drone)
|
||||
@@ -3,7 +3,7 @@
|
||||
# Description: Shared JSON handler with injectable storage directory
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Shared JSON handler — auto-creating, self-healing JSON system.
|
||||
@@ -3,7 +3,7 @@
|
||||
# Description: Shared JSON operations — deep merge and backup
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Shared JSON operations — deep merge and backup utilities.
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
# Description: Shared registry file discovery (walk-up search)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Registry discovery — find *_REGISTRY.json by walking up the directory tree.
|
||||
@@ -488,6 +488,66 @@ class TestStages:
|
||||
result = stage_6_tool_choice(non_interactive=True, cli_override="codex")
|
||||
assert result["cli"] == "codex"
|
||||
|
||||
def test_stage_6_claude_present_no_prompt(self, tmp_local_json) -> None:
|
||||
"""When claude is on PATH, no install prompt is shown."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.shutil.which", return_value="/usr/bin/claude"):
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
with patch(f"{_MOD}._handle_missing_claude") as mock_handle:
|
||||
result = stage_6_tool_choice(non_interactive=True)
|
||||
mock_handle.assert_not_called()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@patch(f"{_MOD}._choose", return_value="default")
|
||||
@patch(f"{_MOD}._install_claude_code", return_value=True)
|
||||
@patch(f"{_MOD}._prompt", return_value="Y")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_interactive_yes(
|
||||
self, _con, _which, _prompt, mock_install, _choose, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + interactive + yes → installer invoked."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=False, cli_override="claude")
|
||||
mock_install.assert_called_once()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@patch(f"{_MOD}._choose", return_value="default")
|
||||
@patch(f"{_MOD}._install_claude_code")
|
||||
@patch(f"{_MOD}._prompt", return_value="n")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_interactive_no(
|
||||
self, _con, _which, _prompt, mock_install, _choose, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + interactive + no → no install, continues."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=False, cli_override="claude")
|
||||
mock_install.assert_not_called()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@patch(f"{_MOD}.warning")
|
||||
@patch(f"{_MOD}._install_claude_code")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_non_interactive_warns(
|
||||
self, _con, _which, mock_install, mock_warn, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + non-interactive → warning, no install."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=True)
|
||||
mock_install.assert_not_called()
|
||||
mock_warn.assert_called_once()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
def test_stage_7_skipped_when_no_docker(self, tmp_local_json) -> None:
|
||||
"""Docker offer is skipped when has_docker=False."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
|
||||
@@ -60,7 +60,7 @@ class TestDefaultFactory:
|
||||
|
||||
def test_unknown_type_raises(self):
|
||||
"""Unknown json_type raises ValueError."""
|
||||
from aipass.common.json_handler import JsonHandler
|
||||
from aipass.aipass.shared.json_handler import JsonHandler
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
JsonHandler._create_default("unknown_type", "test_mod")
|
||||
@@ -220,7 +220,7 @@ class TestSave:
|
||||
ro_dir.mkdir()
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", ro_dir):
|
||||
data = {"module_name": "s", "version": "1.0.0", "config": {}, "created": "2026-01-01"}
|
||||
with patch("aipass.common.json_handler.JsonHandler.write_json", return_value=False):
|
||||
with patch("aipass.aipass.shared.json_handler.JsonHandler.write_json", return_value=False):
|
||||
result = save_json("s", "config", data)
|
||||
assert result is False
|
||||
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_sandbox_check.py
|
||||
# Description: Tests for sandbox prerequisite checker and doctor integration
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-10
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Tests for sandbox prereq checks — handler + doctor integration."""
|
||||
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import (
|
||||
check_broker_alive,
|
||||
check_bwrap_functional,
|
||||
check_bwrap_present,
|
||||
check_node_present,
|
||||
check_rg_present,
|
||||
check_sandbox_flag,
|
||||
check_srt_resolvable,
|
||||
is_linux,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.ui.progress import GLYPH_FAIL, GLYPH_PASS, GLYPH_WARN
|
||||
from aipass.aipass.apps.modules.doctor import _check_sandbox
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_json_handler():
|
||||
"""Suppress json_handler.log_operation side effects in all tests."""
|
||||
with patch("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.json_handler") as mock:
|
||||
mock.log_operation = MagicMock()
|
||||
yield mock
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_sandbox_flag
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckSandboxFlag:
|
||||
def test_flag_off_by_default(self, monkeypatch):
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is False
|
||||
assert result["raw_value"] == ""
|
||||
|
||||
def test_flag_on_with_1(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_flag_on_with_true(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "true")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_flag_on_with_yes(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "yes")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_flag_on_case_insensitive(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "TRUE")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_flag_off_with_garbage(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "maybe")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_bwrap_present
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckBwrapPresent:
|
||||
def test_bwrap_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None)
|
||||
result = check_bwrap_present()
|
||||
assert result["found"] is True
|
||||
assert result["path"] == "/usr/bin/bwrap"
|
||||
|
||||
def test_bwrap_not_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
result = check_bwrap_present()
|
||||
assert result["found"] is False
|
||||
assert result["path"] is None
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("bwrap"), reason="bwrap not installed")
|
||||
def test_bwrap_live(self):
|
||||
result = check_bwrap_present()
|
||||
assert result["found"] is True
|
||||
assert "bwrap" in result["path"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_bwrap_functional
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckBwrapFunctional:
|
||||
def test_bwrap_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
result = check_bwrap_functional()
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["detail"]
|
||||
|
||||
def test_bwrap_succeeds(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None)
|
||||
mock_proc = MagicMock(returncode=0, stderr="")
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run:
|
||||
result = check_bwrap_functional()
|
||||
assert result["ok"] is True
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "/usr/bin/bwrap"
|
||||
assert "--ro-bind" in argv
|
||||
assert "true" in argv
|
||||
|
||||
def test_bwrap_fails_reports_sysctl(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None)
|
||||
mock_proc = MagicMock(returncode=1, stderr="permission denied")
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker._read_userns_sysctl",
|
||||
return_value="1",
|
||||
),
|
||||
):
|
||||
result = check_bwrap_functional()
|
||||
assert result["ok"] is False
|
||||
assert "exit 1" in result["detail"]
|
||||
assert result["sysctl_value"] == "1"
|
||||
|
||||
def test_bwrap_timeout(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None)
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="bwrap", timeout=10),
|
||||
):
|
||||
result = check_bwrap_functional()
|
||||
assert result["ok"] is False
|
||||
assert "timed out" in result["detail"]
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("bwrap"), reason="bwrap not installed")
|
||||
def test_bwrap_functional_live(self):
|
||||
result = check_bwrap_functional()
|
||||
assert isinstance(result["ok"], bool)
|
||||
if result["ok"]:
|
||||
assert "succeeded" in result["detail"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_node_present
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckNodePresent:
|
||||
def test_node_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
||||
result = check_node_present()
|
||||
assert result["found"] is True
|
||||
assert result["path"] == "/usr/bin/node"
|
||||
|
||||
def test_node_not_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
result = check_node_present()
|
||||
assert result["found"] is False
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node not installed")
|
||||
def test_node_live(self):
|
||||
result = check_node_present()
|
||||
assert result["found"] is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_srt_resolvable
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckSrtResolvable:
|
||||
def test_no_node(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
result = check_srt_resolvable()
|
||||
assert result["found"] is False
|
||||
assert "node" in result["install_hint"].lower()
|
||||
|
||||
def test_srt_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
||||
mock_proc = MagicMock(returncode=0, stdout="/usr/lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js")
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run:
|
||||
result = check_srt_resolvable()
|
||||
assert result["found"] is True
|
||||
assert "sandbox-runtime" in result["path"]
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "/usr/bin/node"
|
||||
assert argv[1] == "-e"
|
||||
|
||||
def test_srt_not_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
||||
mock_proc = MagicMock(returncode=1, stdout="")
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
):
|
||||
result = check_srt_resolvable()
|
||||
assert result["found"] is False
|
||||
assert "npm install" in result["install_hint"]
|
||||
|
||||
def test_srt_timeout(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="node", timeout=10),
|
||||
):
|
||||
result = check_srt_resolvable()
|
||||
assert result["found"] is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_rg_present
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckRgPresent:
|
||||
def test_rg_on_path(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/rg" if name == "rg" else None)
|
||||
result = check_rg_present()
|
||||
assert result["found"] is True
|
||||
assert result["path"] == "/usr/bin/rg"
|
||||
|
||||
def test_rg_not_on_path_but_in_local_bin(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
fake_rg = tmp_path / ".local" / "bin" / "rg"
|
||||
fake_rg.parent.mkdir(parents=True)
|
||||
fake_rg.touch()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
result = check_rg_present()
|
||||
assert result["found"] is True
|
||||
assert str(fake_rg) == result["path"]
|
||||
|
||||
def test_rg_not_found(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
result = check_rg_present()
|
||||
assert result["found"] is False
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("rg"), reason="rg not installed")
|
||||
def test_rg_live(self):
|
||||
result = check_rg_present()
|
||||
assert result["found"] is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_broker_alive
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckBrokerAlive:
|
||||
def test_no_repo_root_no_env(self, monkeypatch):
|
||||
monkeypatch.delenv("AIPASS_HOME", raising=False)
|
||||
monkeypatch.setattr(Path, "cwd", lambda: Path("/nonexistent"))
|
||||
result = check_broker_alive(repo_root=None)
|
||||
assert result["alive"] is False
|
||||
|
||||
def test_socket_missing(self, tmp_path):
|
||||
ai_central = tmp_path / ".ai_central"
|
||||
ai_central.mkdir()
|
||||
result = check_broker_alive(repo_root=tmp_path)
|
||||
assert result["alive"] is False
|
||||
assert "missing" in result["detail"]
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker sockets are Linux-only")
|
||||
def test_socket_connect_success(self, tmp_path):
|
||||
ai_central = tmp_path / ".ai_central"
|
||||
ai_central.mkdir()
|
||||
sock_path = ai_central / "drone_broker.sock"
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(sock_path))
|
||||
server.listen(1)
|
||||
try:
|
||||
result = check_broker_alive(repo_root=tmp_path)
|
||||
assert result["alive"] is True
|
||||
assert "connected" in result["detail"]
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker sockets are Linux-only")
|
||||
def test_socket_connect_refused(self, tmp_path):
|
||||
ai_central = tmp_path / ".ai_central"
|
||||
ai_central.mkdir()
|
||||
sock_path = ai_central / "drone_broker.sock"
|
||||
sock_path.touch()
|
||||
result = check_broker_alive(repo_root=tmp_path)
|
||||
assert result["alive"] is False
|
||||
assert "connect failed" in result["detail"]
|
||||
|
||||
def test_repo_root_from_env(self, monkeypatch, tmp_path):
|
||||
ai_central = tmp_path / ".ai_central"
|
||||
ai_central.mkdir()
|
||||
monkeypatch.setenv("AIPASS_HOME", str(tmp_path))
|
||||
result = check_broker_alive(repo_root=None)
|
||||
assert result["alive"] is False
|
||||
assert "missing" in result["detail"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# is_linux
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestIsLinux:
|
||||
def test_linux(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "linux")
|
||||
assert is_linux() is True
|
||||
|
||||
def test_darwin(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "darwin")
|
||||
assert is_linux() is False
|
||||
|
||||
def test_win32(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "win32")
|
||||
assert is_linux() is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _check_sandbox (doctor integration)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _stub_doctor_json():
|
||||
"""Stub json_handler inside doctor.py too."""
|
||||
with patch("aipass.aipass.apps.modules.doctor.json_handler") as mock:
|
||||
mock.log_operation = MagicMock()
|
||||
yield mock
|
||||
|
||||
|
||||
class TestCheckSandboxDoctor:
|
||||
def test_non_linux_one_info_line(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.is_linux",
|
||||
lambda: False,
|
||||
)
|
||||
results = _check_sandbox()
|
||||
assert len(results) == 1
|
||||
assert "Linux-only" in results[0].detail
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
|
||||
def test_flag_off_missing_prereq_is_warn(self, monkeypatch):
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": False, "detail": "not found"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
for r in results:
|
||||
assert r.glyph != GLYPH_FAIL, f"Flag OFF should not produce FAIL, got FAIL for {r.label}"
|
||||
|
||||
def test_flag_on_missing_prereq_is_fail(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": False, "detail": "not found"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
fail_results = [r for r in results if r.glyph == GLYPH_FAIL]
|
||||
assert len(fail_results) >= 4, f"Flag ON + missing prereqs should produce FAILs, got {len(fail_results)}"
|
||||
|
||||
def test_flag_on_all_present_is_pass(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_functional",
|
||||
lambda: {"ok": True, "detail": "trivial sandbox succeeded", "sysctl_value": None},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": True, "path": "/usr/lib/srt/index.js", "install_hint": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": True, "detail": "connected"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: Path("/tmp/fake"))
|
||||
|
||||
results = _check_sandbox()
|
||||
for r in results:
|
||||
assert r.glyph == GLYPH_PASS, f"All present should be PASS, got {r.glyph} for {r.label}"
|
||||
|
||||
def test_bwrap_functional_skipped_when_not_present(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": True, "path": "/x", "install_hint": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": True, "detail": "ok"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
labels = [r.label for r in results]
|
||||
assert "bwrap functional" not in labels
|
||||
|
||||
def test_bwrap_functional_included_when_present(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_functional",
|
||||
lambda: {"ok": True, "detail": "ok", "sysctl_value": None},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": True, "path": "/x", "install_hint": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": True, "detail": "ok"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
labels = [r.label for r in results]
|
||||
assert "bwrap functional" in labels
|
||||
|
||||
def test_sysctl_in_detail_on_functional_fail(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_functional",
|
||||
lambda: {"ok": False, "detail": "exit 1: denied", "sysctl_value": "1"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": True, "path": "/x", "install_hint": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": True, "detail": "ok"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
func_result = [r for r in results if r.label == "bwrap functional"][0]
|
||||
assert "apparmor_restrict_unprivileged_userns=1" in func_result.detail
|
||||
|
||||
def test_inert_suffix_when_flag_off(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": False, "detail": "not found"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
missing_results = [r for r in results if r.glyph == GLYPH_WARN]
|
||||
for r in missing_results:
|
||||
assert "inert" in r.detail or r.label == "sandbox flag", (
|
||||
f"Missing prereq {r.label} should show inert suffix"
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_shared_bootstrap_safety.py
|
||||
# Description: Guard test — shared/ must stay stdlib-only (loads pre-drone)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-10
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Guard test: importing aipass.aipass.shared must NOT pull in branch dependencies.
|
||||
|
||||
The shared/ package is used by bootstrap.py during `aipass init` on fresh machines
|
||||
where drone/prax/trigger don't exist yet. If shared/ ever imports a branch
|
||||
dependency, init breaks. This test enforces the invariant via subprocess isolation.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ALLOWED_PREFIXES = ("aipass.aipass.shared",)
|
||||
ALLOWED_EXACT = {"aipass", "aipass.aipass"}
|
||||
|
||||
SCRIPT = """\
|
||||
import sys
|
||||
|
||||
import aipass.aipass.shared.json_handler
|
||||
import aipass.aipass.shared.json_ops
|
||||
import aipass.aipass.shared.registry_discovery
|
||||
|
||||
bad = []
|
||||
for name in sorted(sys.modules):
|
||||
if not name.startswith("aipass"):
|
||||
continue
|
||||
if name in {allowed_exact}:
|
||||
continue
|
||||
if any(name.startswith(p) for p in {allowed_prefixes}):
|
||||
continue
|
||||
bad.append(name)
|
||||
|
||||
if bad:
|
||||
print("FAIL: branch dependencies loaded: " + ", ".join(bad))
|
||||
sys.exit(1)
|
||||
print("OK")
|
||||
""".format(
|
||||
allowed_exact=repr(ALLOWED_EXACT),
|
||||
allowed_prefixes=repr(ALLOWED_PREFIXES),
|
||||
)
|
||||
|
||||
|
||||
class TestSharedBootstrapSafety:
|
||||
def test_no_branch_deps_loaded(self):
|
||||
"""Importing all shared modules must not pull in any branch code."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", SCRIPT],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"shared/ pulled in branch dependencies:\n{result.stdout}\n{result.stderr}"
|
||||
@@ -357,7 +357,7 @@ class TestRegistryConsistency:
|
||||
class TestFindRegistry:
|
||||
def test_finds_registry(self, tmp_path: Path) -> None:
|
||||
"""Shared find_registry finds *_REGISTRY.json from start_path."""
|
||||
from aipass.common.registry_discovery import find_registry
|
||||
from aipass.aipass.shared.registry_discovery import find_registry
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
result = find_registry(start_path=tmp_path)
|
||||
@@ -366,7 +366,7 @@ class TestFindRegistry:
|
||||
|
||||
def test_fallback_when_missing(self, tmp_path: Path) -> None:
|
||||
"""Shared find_registry returns fallback when no registry in isolated dir."""
|
||||
from aipass.common.registry_discovery import find_registry
|
||||
from aipass.aipass.shared.registry_discovery import find_registry
|
||||
|
||||
isolated = tmp_path / "no_registry"
|
||||
isolated.mkdir()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# aipass.common — shared leaf utilities (no branch dependencies)
|
||||
@@ -102,7 +102,7 @@ drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, aipass, ho
|
||||
- CWD = identity. Visit other branches, don't move in.
|
||||
- Git awareness: after completing work, `drone @git status`. Suggest commit if coherent. Don't force, don't let pile up.
|
||||
- Git workflow: commit → dev-pr → wait for CI. Every commit must be pushed. Local-only commits are invisible. After fixing CI, push immediately (dev-pr reports "PR already open" = pushed).
|
||||
- CHANGELOG: update `CHANGELOG.md` when committing/pushing. Add entries to the current week's `[YYYY.WNN]` section as work lands — don't batch at end of week. Sunday = merge to main + tag.
|
||||
- CHANGELOG: update `CHANGELOG.md` when committing/pushing. Add entries under the current dated section (`[YYYY-MM-DD]`, one per merge) as work lands — don't batch. Merge to main + tag on demand (no fixed weekly cadence).
|
||||
- Never `docker cp` into containers. Merge PR → git pull → test.
|
||||
- Sub-agents build, you PR.
|
||||
|
||||
|
||||
@@ -84,6 +84,31 @@
|
||||
"standard": "help_text",
|
||||
"file": "tools/hook_engine_poc/test_engine.py",
|
||||
"reason": "POC test harness — usage example in docstring."
|
||||
},
|
||||
{
|
||||
"standard": "debug_print",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler."
|
||||
},
|
||||
{
|
||||
"standard": "imports",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed."
|
||||
},
|
||||
{
|
||||
"standard": "help_text",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module."
|
||||
},
|
||||
{
|
||||
"standard": "documentation",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -183,6 +183,67 @@
|
||||
"standard": "trigger",
|
||||
"reason": "Test file exercises .unlink() to verify deletion behavior — not a production file operation requiring trigger events."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_broker.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_broker.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Test file imports broker handlers directly to test their public interface. Unit tests require direct access to implementation components."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_broker.py",
|
||||
"standard": "trigger",
|
||||
"reason": "Test file exercises .unlink() to clean up test symlinks — not a production file operation requiring trigger events."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Acceptance test artifact — standalone demo script, not part of 3-layer production structure."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Acceptance test imports handlers directly to verify broker daemon behavior end-to-end."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Acceptance test script — main() is self-documenting via module docstring and inline comments."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "imports",
|
||||
"reason": "Acceptance test script uses sys.path.insert to locate the package from the artifacts/ directory."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "help_text",
|
||||
"reason": "Docstring run instruction shows how to invoke the script — not a production help text."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "meta",
|
||||
"reason": "Acceptance test artifact — META blocks are for production source files."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "trigger",
|
||||
"reason": "Acceptance test exercises .unlink() to clean up test symlinks — not production file operations."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_broker.py",
|
||||
"standard": "windows_compat",
|
||||
"lines": [556],
|
||||
"reason": "stat.S_IMODE() guarded by os.name != 'posix' skip at runtime. POSIX-only secret permission test."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "Standalone acceptance demo runner — helper functions invoked from the demo main, not a production module (same pattern as the other demo bypasses)."
|
||||
},
|
||||
{
|
||||
"file": "CLAUDE.md",
|
||||
"standard": "architecture",
|
||||
@@ -205,6 +266,12 @@
|
||||
"standard": "unused_function",
|
||||
"lines": [655],
|
||||
"reason": "get_introspective() called dynamically via getattr() by module_registry_handler.py:219 for internal module introspection. Also tested in test_git_module, test_system_pr, test_devpulse_plugins, test_git_access."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/broker/daemon.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [422],
|
||||
"reason": "Threaded broker entrypoint, exercised by tests/test_broker.py; production uses blocking start(); intentionally not called in shipped non-test code."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -143,7 +143,8 @@ drone/
|
||||
│ │ ├── registry.py # Registry query operations
|
||||
│ │ ├── commands.py # Custom command shortcut orchestrator
|
||||
│ │ ├── git_module.py # Git workflow (tier-based access, 16 commands)
|
||||
│ │ └── scan.py # Branch command scanning
|
||||
│ │ ├── scan.py # Branch command scanning
|
||||
│ │ └── broker.py # Broker daemon orchestrator (sandbox delete)
|
||||
│ ├── handlers/ # Implementation details
|
||||
│ │ ├── executor.py # Safe subprocess execution (timeout, no shell)
|
||||
│ │ ├── exceptions.py # Exception hierarchy (10 exception types)
|
||||
@@ -153,6 +154,11 @@ drone/
|
||||
│ │ ├── module_registry_handler.py # Module loading (internal + external)
|
||||
│ │ ├── generic_adapter.py # StringIO capture for external modules
|
||||
│ │ ├── routing_config.json # External module declarations
|
||||
│ │ ├── broker/
|
||||
│ │ │ ├── daemon.py # Broker daemon (unix socket, openat2, audit)
|
||||
│ │ │ ├── client.py # Broker client (inherited fd transport)
|
||||
│ │ │ ├── path_resolver.py # openat2 RESOLVE_BENEATH path resolution
|
||||
│ │ │ └── protocol.py # Typed JSON-line IPC (BrokerRequest/Response)
|
||||
│ │ ├── json/
|
||||
│ │ │ └── json_handler.py # Structured operation logging
|
||||
│ │ ├── scanning/
|
||||
@@ -187,7 +193,8 @@ drone/
|
||||
│ └── hook_sounds_plugin.py.disabled
|
||||
├── docs/ # Public documentation
|
||||
├── docs.local/ # Investigation reports and policies
|
||||
└── tests/ # 704 tests across 21 test files
|
||||
├── artifacts/ # Live acceptance test scripts
|
||||
└── tests/ # 807 tests across 22 test files
|
||||
```
|
||||
|
||||
### Routing Flow
|
||||
@@ -325,7 +332,7 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
|
||||
|
||||
## Testing
|
||||
|
||||
704 tests across 21 test files, covering all layers:
|
||||
807 tests across 22 test files, covering all layers:
|
||||
|
||||
| Area | Files | Tests |
|
||||
|------|-------|-------|
|
||||
@@ -333,7 +340,8 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
|
||||
| Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py` | ~150 |
|
||||
| Handlers | `test_executor.py`, `test_registry_handler.py`, `test_discovery.py` | ~99 |
|
||||
| Infrastructure | `test_generic_adapter.py`, `test_module_registry.py`, `test_config.py` | ~66 |
|
||||
| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py` | ~125 |
|
||||
| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py`, `test_rm.py` | ~181 |
|
||||
| Broker | `test_broker.py` | ~55 |
|
||||
| Standards | `test_cli_routing.py`, `test_contracts.py`, `test_error_resilience.py`, `test_init_provisioning.py` | ~21 |
|
||||
|
||||
Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
|
||||
@@ -348,7 +356,7 @@ Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
|
||||
|
||||
---
|
||||
|
||||
**Seedgo:** 100% | **Tests:** 775 pass, 4 skip | **Last Updated:** 2026-06-07
|
||||
**Seedgo:** 100% | **Tests:** 830 pass, 4 skip | **Last Updated:** 2026-06-10
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Broker handler package — privileged delete daemon for sandboxed agents."""
|
||||
|
||||
from .protocol import BrokerRequest as BrokerRequest # noqa: F401
|
||||
from .protocol import BrokerResponse as BrokerResponse # noqa: F401
|
||||
from .path_resolver import resolve_beneath as resolve_beneath # noqa: F401
|
||||
from .daemon import BrokerDaemon as BrokerDaemon # noqa: F401
|
||||
from .client import broker_delete as broker_delete # noqa: F401
|
||||
from .client import create_identified_connection as create_identified_connection # noqa: F401
|
||||
@@ -0,0 +1,154 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: client.py
|
||||
# Description: Broker client — sends delete requests over inherited fd
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Broker client — sends delete requests over an inherited socket fd.
|
||||
|
||||
When ``AIPASS_BROKER_FD`` is set, ``drone rm`` uses this client to send
|
||||
delete requests to the out-of-sandbox broker daemon instead of calling
|
||||
``Path.unlink`` directly. The fd was pre-opened by the launch wrapper
|
||||
before the sandbox locked.
|
||||
|
||||
Launcher contract (Phase 6a):
|
||||
``create_identified_connection()`` connects to the broker socket,
|
||||
reads the per-start secret, computes the HMAC, sends the identify
|
||||
preamble, and returns the authenticated socket. The caller passes
|
||||
the socket's fd to the sandboxed child via AIPASS_BROKER_FD.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse
|
||||
|
||||
BROKER_FD_ENV = "AIPASS_BROKER_FD"
|
||||
|
||||
|
||||
def is_sandboxed() -> bool:
|
||||
"""Return True if running inside a sandbox with a broker fd available."""
|
||||
return BROKER_FD_ENV in os.environ
|
||||
|
||||
|
||||
def _get_broker_fd() -> int | None:
|
||||
"""Return the inherited broker socket fd, or None if not set."""
|
||||
raw = os.environ.get(BROKER_FD_ENV)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
fd = int(raw)
|
||||
if fd < 0:
|
||||
logger.warning("broker client: invalid fd %d", fd)
|
||||
return None
|
||||
return fd
|
||||
except ValueError:
|
||||
logger.warning("broker client: non-integer AIPASS_BROKER_FD=%s", raw)
|
||||
return None
|
||||
|
||||
|
||||
def create_identified_connection(
|
||||
socket_path: str | Path,
|
||||
secret_path: str | Path,
|
||||
branch: str,
|
||||
) -> socket.socket:
|
||||
"""Connect to the broker, authenticate via HMAC, return the identified socket.
|
||||
|
||||
This is the launcher contract for dispatch_monitor. The returned
|
||||
socket fd should be passed to the sandboxed child via AIPASS_BROKER_FD.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the broker's unix socket.
|
||||
secret_path: Path to the broker's per-start secret file (mode 0600).
|
||||
branch: Branch name to identify as.
|
||||
|
||||
Returns:
|
||||
A connected, identified ``socket.socket``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If identification fails (bad HMAC, broker error).
|
||||
OSError: If the socket or secret file cannot be accessed.
|
||||
"""
|
||||
secret = Path(secret_path).read_bytes()
|
||||
mac = hmac_mod.new(secret, branch.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(str(socket_path))
|
||||
|
||||
req = BrokerRequest(op="identify", branch=branch, hmac=mac)
|
||||
sock.sendall(req.to_bytes())
|
||||
|
||||
data = b""
|
||||
while b"\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
sock.close()
|
||||
raise RuntimeError("Broker closed connection during identify")
|
||||
data += chunk
|
||||
|
||||
resp = BrokerResponse.from_bytes(data)
|
||||
if not resp.ok:
|
||||
sock.close()
|
||||
raise RuntimeError(f"Broker identify failed: {resp.message}")
|
||||
|
||||
logger.info("broker client: identified as %s", branch)
|
||||
json_handler.log_operation("broker_identify", {"branch": branch})
|
||||
return sock
|
||||
|
||||
|
||||
def broker_delete(path: str) -> tuple[bool, str]:
|
||||
"""Send a delete request to the broker over the inherited fd.
|
||||
|
||||
Returns ``(success, message)`` matching the pattern in ``rm_handler.safe_delete``.
|
||||
"""
|
||||
fd = _get_broker_fd()
|
||||
if fd is None:
|
||||
return False, "Broker fd not available (AIPASS_BROKER_FD not set)"
|
||||
|
||||
request_id = uuid.uuid4().hex[:8]
|
||||
req = BrokerRequest(op="delete", path=path, request_id=request_id)
|
||||
json_handler.log_operation(
|
||||
"broker_delete_request",
|
||||
{"path": path, "fd": fd, "request_id": request_id},
|
||||
)
|
||||
|
||||
try:
|
||||
sock = socket.socket(fileno=fd)
|
||||
sock.setblocking(True)
|
||||
try:
|
||||
sock.sendall(req.to_bytes())
|
||||
|
||||
data = b""
|
||||
while b"\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
|
||||
if not data.strip():
|
||||
logger.error("broker client: empty response from broker")
|
||||
return False, "Broker returned empty response"
|
||||
|
||||
resp = BrokerResponse.from_bytes(data)
|
||||
logger.info(
|
||||
"broker client: %s for %s: %s",
|
||||
"ok" if resp.ok else "refused",
|
||||
path,
|
||||
resp.message,
|
||||
)
|
||||
return resp.ok, resp.message
|
||||
finally:
|
||||
sock.detach()
|
||||
except OSError as exc:
|
||||
logger.error("broker client: socket error for %s: %s", path, exc)
|
||||
return False, f"Broker communication failed: {exc}"
|
||||
@@ -0,0 +1,445 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: daemon.py
|
||||
# Description: Broker daemon — privileged deleter for sandboxed agents
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Broker daemon — privileged deleter for sandboxed agents.
|
||||
|
||||
A long-lived process that listens on a unix socket, accepts delete requests
|
||||
from sandboxed ``drone rm`` clients, re-resolves paths via openat2
|
||||
RESOLVE_BENEATH (never trusting agent-supplied strings), applies
|
||||
identity-bound allowlist policy, performs the delete, and audit-logs
|
||||
every attempt.
|
||||
|
||||
Identity model (Phase 6a):
|
||||
The launcher pre-connects, authenticates with an HMAC derived from a
|
||||
per-start secret, declares the branch identity, then passes the
|
||||
connected fd to the sandboxed child. The child inherits an already-
|
||||
identified connection. Connections that never identify get the
|
||||
narrowest scope (/tmp only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse
|
||||
from aipass.drone.apps.handlers.broker.path_resolver import resolve_beneath
|
||||
|
||||
_DEFAULT_SOCKET_DIR = ".ai_central"
|
||||
_SOCKET_NAME = "drone_broker.sock"
|
||||
_AUDIT_LOG_NAME = "drone_broker_audit.jsonl"
|
||||
_SECRET_NAME = "broker_secret"
|
||||
|
||||
_DENYLIST_DIRS = frozenset((".git", ".trinity", ".aipass", ".codex", ".agents"))
|
||||
|
||||
_TMP_BASES = (Path("/tmp"), Path("/var/tmp"))
|
||||
|
||||
|
||||
def _find_project_root() -> Path | None:
|
||||
"""Walk up from CWD to find *_REGISTRY.json; return its parent as project root."""
|
||||
import os
|
||||
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd, *cwd.parents]:
|
||||
if list(parent.glob("*_REGISTRY.json")):
|
||||
return parent.resolve()
|
||||
aipass_home = os.environ.get("AIPASS_HOME")
|
||||
if aipass_home:
|
||||
home = Path(aipass_home)
|
||||
if home.is_dir() and list(home.glob("*_REGISTRY.json")):
|
||||
return home.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def _default_socket_path() -> Path:
|
||||
"""Return the default broker socket path under the repo root."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _SOCKET_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _SOCKET_NAME
|
||||
|
||||
|
||||
def _default_audit_path() -> Path:
|
||||
"""Return the default audit log path."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _AUDIT_LOG_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _AUDIT_LOG_NAME
|
||||
|
||||
|
||||
def _default_secret_path() -> Path:
|
||||
"""Return the default secret path."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _SECRET_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _SECRET_NAME
|
||||
|
||||
|
||||
class BrokerDaemon:
|
||||
"""Out-of-sandbox delete broker with identity-bound allowlist policy.
|
||||
|
||||
Listens on a unix socket, optionally authenticates connections via
|
||||
HMAC, then validates delete requests via openat2 path re-resolution,
|
||||
identity-scoped allowlist, and a denylist backstop before performing
|
||||
``os.unlink`` / ``shutil.rmtree``.
|
||||
|
||||
Identity scopes:
|
||||
None (unidentified): /tmp, /var/tmp only.
|
||||
Builder branch: /tmp, /var/tmp, + own tree ($REPO/src/aipass/<branch>/).
|
||||
devpulse: /tmp, /var/tmp, + anywhere under $REPO.
|
||||
Denylist backstop (.git, .trinity, .aipass, .codex, .agents) always applies.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo_root: Path | None = None,
|
||||
socket_path: Path | None = None,
|
||||
audit_path: Path | None = None,
|
||||
secret_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Initialize the broker.
|
||||
|
||||
Args:
|
||||
repo_root: Project root directory. Auto-discovered if not set.
|
||||
socket_path: Where to bind the unix socket.
|
||||
audit_path: Where to write the JSONL audit log.
|
||||
secret_path: Where to write the per-start HMAC secret.
|
||||
"""
|
||||
self._repo_root = repo_root.resolve() if repo_root else _find_project_root()
|
||||
self.socket_path = socket_path or _default_socket_path()
|
||||
self.audit_path = audit_path or _default_audit_path()
|
||||
self._secret_path = secret_path or _default_secret_path()
|
||||
self._secret: bytes = b""
|
||||
self._server: socket.socket | None = None
|
||||
self._running = False
|
||||
self._listening = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
json_handler.log_operation(
|
||||
"broker_init",
|
||||
{
|
||||
"repo_root": str(self._repo_root),
|
||||
"socket": str(self.socket_path),
|
||||
},
|
||||
)
|
||||
|
||||
def _generate_secret(self) -> bytes:
|
||||
"""Generate a fresh HMAC secret, write to disk with mode 0600."""
|
||||
secret = secrets.token_bytes(32)
|
||||
self._secret_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._secret_path.write_bytes(secret)
|
||||
self._secret_path.chmod(0o600)
|
||||
logger.info("broker: generated secret at %s", self._secret_path)
|
||||
return secret
|
||||
|
||||
def _audit(self, entry: dict) -> None:
|
||||
"""Append a JSON line to the audit log."""
|
||||
entry["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
self.audit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.audit_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
||||
|
||||
def _check_denylist(self, resolved: Path) -> str | None:
|
||||
"""Return a reason string if the resolved path hits the denylist."""
|
||||
for part in resolved.parts:
|
||||
if part in _DENYLIST_DIRS:
|
||||
return f"Protected directory: path is inside {part}/"
|
||||
return None
|
||||
|
||||
def _get_allowed_bases(self, identity: str | None) -> list[Path]:
|
||||
"""Return the allowed base directories for the given identity."""
|
||||
bases: list[Path] = list(_TMP_BASES)
|
||||
if identity is None or self._repo_root is None:
|
||||
return bases
|
||||
if identity == "devpulse":
|
||||
bases.append(self._repo_root)
|
||||
return bases
|
||||
branch_dir = self._repo_root / "src" / "aipass" / identity
|
||||
if branch_dir.is_dir():
|
||||
bases.append(branch_dir)
|
||||
return bases
|
||||
|
||||
def _handle_identify(self, req: BrokerRequest) -> tuple[BrokerResponse, str | None]:
|
||||
"""Verify HMAC and bind identity to the connection."""
|
||||
audit_entry: dict = {
|
||||
"op": "identify",
|
||||
"branch": req.branch,
|
||||
"request_id": req.request_id,
|
||||
}
|
||||
|
||||
if not req.branch or not req.hmac:
|
||||
audit_entry.update(result="REFUSED", reason="missing branch or hmac")
|
||||
self._audit(audit_entry)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message="Missing branch or hmac",
|
||||
request_id=req.request_id,
|
||||
error_code="IDENTIFY_INVALID",
|
||||
), None
|
||||
|
||||
expected = hmac_mod.new(self._secret, req.branch.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac_mod.compare_digest(expected, req.hmac):
|
||||
audit_entry.update(result="REFUSED", reason="bad HMAC")
|
||||
self._audit(audit_entry)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message="Authentication failed",
|
||||
request_id=req.request_id,
|
||||
error_code="IDENTIFY_FAILED",
|
||||
), None
|
||||
|
||||
audit_entry.update(result="IDENTIFIED", identity=req.branch)
|
||||
self._audit(audit_entry)
|
||||
logger.info("broker: connection identified as %s", req.branch)
|
||||
return BrokerResponse(
|
||||
ok=True,
|
||||
message=f"Identified as {req.branch}",
|
||||
request_id=req.request_id,
|
||||
), req.branch
|
||||
|
||||
def _handle_delete(self, req: BrokerRequest, identity: str | None) -> BrokerResponse:
|
||||
"""Process a single delete request with full re-resolution and identity scoping."""
|
||||
import shutil
|
||||
|
||||
audit_entry: dict = {
|
||||
"op": req.op,
|
||||
"agent_path": req.path,
|
||||
"request_id": req.request_id,
|
||||
"identity": identity,
|
||||
}
|
||||
|
||||
allowed_bases = self._get_allowed_bases(identity)
|
||||
|
||||
for base in allowed_bases:
|
||||
agent_path = req.path
|
||||
try:
|
||||
candidate = Path(agent_path)
|
||||
if candidate.is_absolute() and candidate.is_relative_to(base):
|
||||
agent_path = str(candidate.relative_to(base))
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.info("broker: path normalization skipped for %s: %s", agent_path, exc)
|
||||
|
||||
try:
|
||||
resolved = resolve_beneath(base, agent_path)
|
||||
except OSError as exc:
|
||||
logger.info("broker: base %s skipped for %s: %s", base, agent_path, exc)
|
||||
continue
|
||||
|
||||
# Prevent /tmp base from granting access to repo-scoped paths
|
||||
if self._repo_root and base in _TMP_BASES and resolved.is_relative_to(self._repo_root):
|
||||
logger.info("broker: %s is under repo root via /tmp — skipping", resolved)
|
||||
continue
|
||||
|
||||
if not resolved.is_relative_to(base):
|
||||
continue
|
||||
|
||||
deny_reason = self._check_denylist(resolved)
|
||||
if deny_reason:
|
||||
audit_entry.update(
|
||||
result="REFUSED",
|
||||
reason=deny_reason,
|
||||
resolved=str(resolved),
|
||||
base=str(base),
|
||||
)
|
||||
self._audit(audit_entry)
|
||||
logger.warning("broker: denied delete %s: %s", resolved, deny_reason)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message=deny_reason,
|
||||
request_id=req.request_id,
|
||||
error_code="DENYLIST",
|
||||
)
|
||||
|
||||
if resolved == base:
|
||||
reason = f"Refusing to delete root directory itself: {base}"
|
||||
audit_entry.update(
|
||||
result="REFUSED",
|
||||
reason=reason,
|
||||
resolved=str(resolved),
|
||||
base=str(base),
|
||||
)
|
||||
self._audit(audit_entry)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message=reason,
|
||||
request_id=req.request_id,
|
||||
error_code="ROOT_DELETE",
|
||||
)
|
||||
|
||||
try:
|
||||
if resolved.is_symlink():
|
||||
resolved.unlink()
|
||||
elif resolved.is_dir():
|
||||
shutil.rmtree(resolved)
|
||||
else:
|
||||
resolved.unlink()
|
||||
|
||||
audit_entry.update(result="DELETED", resolved=str(resolved), base=str(base))
|
||||
self._audit(audit_entry)
|
||||
logger.info(
|
||||
"broker: deleted %s (base=%s, identity=%s)",
|
||||
resolved,
|
||||
base,
|
||||
identity,
|
||||
)
|
||||
return BrokerResponse(
|
||||
ok=True,
|
||||
message=f"Deleted: {resolved}",
|
||||
request_id=req.request_id,
|
||||
)
|
||||
except OSError as exc:
|
||||
audit_entry.update(
|
||||
result="ERROR",
|
||||
reason=str(exc),
|
||||
resolved=str(resolved),
|
||||
base=str(base),
|
||||
)
|
||||
self._audit(audit_entry)
|
||||
logger.error("broker: delete failed %s: %s", resolved, exc)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message=f"Delete failed: {exc}",
|
||||
request_id=req.request_id,
|
||||
error_code="OS_ERROR",
|
||||
)
|
||||
|
||||
audit_entry.update(result="REFUSED", reason="Path not under any allowed base for this identity")
|
||||
self._audit(audit_entry)
|
||||
logger.warning("broker: no allowed base matched for %s (identity=%s)", req.path, identity)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message=f"Path not permitted for identity '{identity}': {req.path}",
|
||||
request_id=req.request_id,
|
||||
error_code="NO_BASE",
|
||||
)
|
||||
|
||||
def _handle_connection(self, conn: socket.socket) -> None:
|
||||
"""Read messages in a loop, tracking per-connection identity."""
|
||||
identity: str | None = None
|
||||
first_message_done = False
|
||||
buffer = b""
|
||||
try:
|
||||
while True:
|
||||
while b"\n" not in buffer:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
return
|
||||
buffer += chunk
|
||||
|
||||
line, _, buffer = buffer.partition(b"\n")
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
req = BrokerRequest.from_bytes(line + b"\n")
|
||||
|
||||
if req.op == "identify":
|
||||
if first_message_done:
|
||||
self._audit(
|
||||
{
|
||||
"op": "identify",
|
||||
"branch": req.branch,
|
||||
"identity": identity,
|
||||
"result": "REFUSED",
|
||||
"reason": "identify after first message",
|
||||
"request_id": req.request_id,
|
||||
}
|
||||
)
|
||||
resp = BrokerResponse(
|
||||
ok=False,
|
||||
message="Identify must be the first message",
|
||||
request_id=req.request_id,
|
||||
error_code="IDENTIFY_LATE",
|
||||
)
|
||||
else:
|
||||
resp, identity = self._handle_identify(req)
|
||||
first_message_done = True
|
||||
elif req.op == "delete":
|
||||
first_message_done = True
|
||||
resp = self._handle_delete(req, identity)
|
||||
else:
|
||||
first_message_done = True
|
||||
resp = BrokerResponse(
|
||||
ok=False,
|
||||
message=f"Unknown operation: {req.op}",
|
||||
request_id=req.request_id,
|
||||
error_code="UNKNOWN_OP",
|
||||
)
|
||||
|
||||
conn.sendall(resp.to_bytes())
|
||||
except Exception as exc:
|
||||
logger.error("broker: connection error: %s", exc)
|
||||
try:
|
||||
err = BrokerResponse(ok=False, message=f"Internal error: {exc}", error_code="INTERNAL")
|
||||
conn.sendall(err.to_bytes())
|
||||
except OSError as send_exc:
|
||||
logger.warning("broker: failed to send error response: %s", send_exc)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the broker daemon (blocking). Use ``start_background`` for threaded."""
|
||||
self._secret = self._generate_secret()
|
||||
|
||||
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.socket_path.exists():
|
||||
self.socket_path.unlink()
|
||||
|
||||
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._server.bind(str(self.socket_path))
|
||||
self._server.listen(5)
|
||||
self._server.settimeout(1.0)
|
||||
self._running = True
|
||||
self._listening.set()
|
||||
|
||||
logger.info("broker: listening on %s", self.socket_path)
|
||||
json_handler.log_operation("broker_start", {"socket": str(self.socket_path)})
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
conn, _ = self._server.accept()
|
||||
t = threading.Thread(target=self._handle_connection, args=(conn,), daemon=True)
|
||||
t.start()
|
||||
except socket.timeout:
|
||||
logger.info("broker: accept poll tick")
|
||||
continue
|
||||
except OSError as exc:
|
||||
if self._running:
|
||||
logger.error("broker: accept error: %s", exc)
|
||||
break
|
||||
|
||||
def start_background(self, timeout: float = 5.0) -> threading.Thread:
|
||||
"""Start the broker in a background thread, blocking until listening."""
|
||||
self._listening.clear()
|
||||
t = threading.Thread(target=self.start, daemon=True, name="drone-broker")
|
||||
t.start()
|
||||
if not self._listening.wait(timeout):
|
||||
raise RuntimeError(f"broker failed to start listening within {timeout}s")
|
||||
return t
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the broker daemon."""
|
||||
self._running = False
|
||||
if self._server:
|
||||
try:
|
||||
self._server.close()
|
||||
except OSError as exc:
|
||||
logger.warning("broker: error closing server socket: %s", exc)
|
||||
if self.socket_path.exists():
|
||||
try:
|
||||
self.socket_path.unlink()
|
||||
except OSError as exc:
|
||||
logger.warning("broker: error removing socket file: %s", exc)
|
||||
logger.info("broker: stopped")
|
||||
json_handler.log_operation("broker_stop", {})
|
||||
@@ -0,0 +1,139 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: path_resolver.py
|
||||
# Description: Kernel-safe path resolution via openat2 RESOLVE_BENEATH
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Kernel-safe path resolution via openat2 RESOLVE_BENEATH.
|
||||
|
||||
Re-resolves an agent-supplied path string server-side so the broker never
|
||||
trusts the raw string. Uses Linux openat2(2) with RESOLVE_BENEATH |
|
||||
RESOLVE_NO_SYMLINKS to guarantee the final target is strictly beneath an
|
||||
allowed base directory and traverses no symlinks.
|
||||
|
||||
Falls back to a pure-Python per-component walk (O_NOFOLLOW openat) when
|
||||
openat2 is unavailable (non-Linux, older kernels).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
RESOLVE_BENEATH = 0x08
|
||||
RESOLVE_NO_SYMLINKS = 0x04
|
||||
SYS_OPENAT2 = 437
|
||||
O_PATH = 0o010000000
|
||||
O_NOFOLLOW = 0o0400000
|
||||
|
||||
_OPEN_HOW_SIZE = 24
|
||||
|
||||
|
||||
def _openat2_available() -> bool:
|
||||
"""Check if the openat2 syscall is usable on this platform."""
|
||||
return sys.platform == "linux" and os.uname().machine == "x86_64"
|
||||
|
||||
|
||||
def _openat2(dirfd: int, pathname: bytes, flags: int, resolve: int) -> int:
|
||||
"""Call openat2(2) via ctypes syscall.
|
||||
|
||||
Returns an fd on success, raises OSError on failure.
|
||||
"""
|
||||
open_how = struct.pack("QQQ", flags, 0, resolve)
|
||||
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
||||
result = libc.syscall(
|
||||
ctypes.c_long(SYS_OPENAT2),
|
||||
ctypes.c_int(dirfd),
|
||||
ctypes.c_char_p(pathname),
|
||||
ctypes.c_char_p(open_how),
|
||||
ctypes.c_size_t(_OPEN_HOW_SIZE),
|
||||
)
|
||||
if result < 0:
|
||||
errno = ctypes.get_errno()
|
||||
raise OSError(errno, os.strerror(errno), pathname.decode(errors="replace"))
|
||||
return result
|
||||
|
||||
|
||||
def resolve_beneath(base: Path, relpath: str) -> Path:
|
||||
"""Resolve *relpath* strictly beneath *base*, refusing escapes and symlinks.
|
||||
|
||||
Uses openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS on Linux x86-64,
|
||||
falls back to a per-component O_NOFOLLOW walk otherwise.
|
||||
|
||||
Returns the resolved absolute path on success.
|
||||
Raises OSError on traversal failure (escape, symlink, missing component).
|
||||
"""
|
||||
json_handler.log_operation("resolve_beneath", {"base": str(base), "relpath": relpath})
|
||||
|
||||
cleaned = os.path.normpath(relpath)
|
||||
if cleaned.startswith("/") or cleaned.startswith(".."):
|
||||
raise OSError(1, "Path escapes base via leading / or ..", relpath)
|
||||
|
||||
parts = cleaned.split("/")
|
||||
if ".." in parts:
|
||||
raise OSError(1, "Path contains .. component", relpath)
|
||||
|
||||
if _openat2_available():
|
||||
return _resolve_via_openat2(base, cleaned)
|
||||
return _resolve_via_walk(base, parts)
|
||||
|
||||
|
||||
def _resolve_via_openat2(base: Path, cleaned: str) -> Path:
|
||||
"""Resolve using the openat2 syscall with kernel-enforced containment."""
|
||||
dirfd = os.open(str(base), os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
fd = _openat2(
|
||||
dirfd,
|
||||
cleaned.encode(),
|
||||
O_PATH,
|
||||
RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
|
||||
)
|
||||
try:
|
||||
resolved = Path(os.readlink(f"/proc/self/fd/{fd}"))
|
||||
logger.info("resolve_beneath: openat2 resolved %s -> %s", cleaned, resolved)
|
||||
return resolved
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
os.close(dirfd)
|
||||
|
||||
|
||||
def _resolve_via_walk(base: Path, parts: list[str]) -> Path:
|
||||
"""Fallback: per-component walk using O_NOFOLLOW to block symlinks."""
|
||||
current_fd = os.open(str(base), os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
for i, component in enumerate(parts):
|
||||
if component in ("", "."):
|
||||
continue
|
||||
|
||||
is_last = i == len(parts) - 1
|
||||
flags = O_PATH | O_NOFOLLOW
|
||||
if not is_last:
|
||||
flags |= os.O_DIRECTORY
|
||||
|
||||
try:
|
||||
next_fd = os.open(component, flags, dir_fd=current_fd)
|
||||
except OSError as exc:
|
||||
raise OSError(
|
||||
exc.errno,
|
||||
f"Component '{component}' failed: {exc.strerror}",
|
||||
"/".join(parts),
|
||||
) from exc
|
||||
|
||||
os.close(current_fd)
|
||||
current_fd = next_fd
|
||||
|
||||
resolved = Path(os.readlink(f"/proc/self/fd/{current_fd}"))
|
||||
logger.info("resolve_beneath: walk resolved %s -> %s", "/".join(parts), resolved)
|
||||
return resolved
|
||||
finally:
|
||||
os.close(current_fd)
|
||||
@@ -0,0 +1,76 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: protocol.py
|
||||
# Description: Typed protocol for broker IPC
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Typed protocol for broker IPC.
|
||||
|
||||
JSON-line messages over a unix socket. Extensible — only ``delete`` is
|
||||
implemented now, but the envelope supports future operation types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerRequest:
|
||||
"""A request from sandboxed drone to the broker."""
|
||||
|
||||
op: Literal["delete", "identify"]
|
||||
path: str = ""
|
||||
request_id: str = ""
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
branch: str = ""
|
||||
hmac: str = ""
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Serialize to a newline-terminated JSON bytes line."""
|
||||
return json.dumps(asdict(self), separators=(",", ":")).encode() + b"\n"
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> BrokerRequest:
|
||||
"""Deserialize from JSON bytes."""
|
||||
d = json.loads(data)
|
||||
json_handler.log_operation("broker_request_parse", {"op": d.get("op", "")})
|
||||
return cls(
|
||||
op=d["op"],
|
||||
path=d.get("path", ""),
|
||||
request_id=d.get("request_id", ""),
|
||||
extra=d.get("extra", {}),
|
||||
branch=d.get("branch", ""),
|
||||
hmac=d.get("hmac", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerResponse:
|
||||
"""The broker's reply."""
|
||||
|
||||
ok: bool
|
||||
message: str
|
||||
request_id: str = ""
|
||||
error_code: str = ""
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Serialize to a newline-terminated JSON bytes line."""
|
||||
return json.dumps(asdict(self), separators=(",", ":")).encode() + b"\n"
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> BrokerResponse:
|
||||
"""Deserialize from JSON bytes."""
|
||||
d = json.loads(data)
|
||||
return cls(
|
||||
ok=d["ok"],
|
||||
message=d["message"],
|
||||
request_id=d.get("request_id", ""),
|
||||
error_code=d.get("error_code", ""),
|
||||
)
|
||||
@@ -146,6 +146,11 @@ def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]:
|
||||
Returns a list of ``(original_path, success, message)`` tuples.
|
||||
Every path is checked independently; a refused path does not block others.
|
||||
"""
|
||||
return _safe_delete_direct(paths)
|
||||
|
||||
|
||||
def _safe_delete_direct(paths: list[str]) -> list[tuple[str, bool, str]]:
|
||||
"""Delete paths directly (unsandboxed mode — current behavior)."""
|
||||
roots = get_allowed_roots()
|
||||
if not roots:
|
||||
return [(p, False, "No allowed roots found (no project registry, no temp dir)") for p in paths]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: broker.py
|
||||
# Description: Module orchestrator for the drone-broker daemon
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Module orchestrator for the drone-broker daemon.
|
||||
|
||||
Thin orchestrator that delegates to the broker handler package for
|
||||
daemon lifecycle, path resolution, and client operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon
|
||||
|
||||
|
||||
def handle_command(command: Optional[str] = None, args: Optional[list[str]] = None) -> bool:
|
||||
"""Route broker subcommands to handler functions."""
|
||||
if not args:
|
||||
if command is None:
|
||||
print_introspection()
|
||||
return True
|
||||
args = []
|
||||
if command in ("--help", "-h") or (args and args[0] in ("--help", "-h")):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
json_handler.log_operation("broker_command", {"command": command, "args": args})
|
||||
|
||||
if command == "start":
|
||||
return _start_broker()
|
||||
if command == "status":
|
||||
return _show_status()
|
||||
|
||||
logger.warning("broker: unknown command '%s'", command)
|
||||
return False
|
||||
|
||||
|
||||
def _start_broker() -> bool:
|
||||
"""Start the broker daemon in the foreground."""
|
||||
from aipass.drone.apps.handlers.broker.daemon import _find_project_root
|
||||
|
||||
repo_root = _find_project_root()
|
||||
if not repo_root:
|
||||
logger.error("No project root found — cannot start broker")
|
||||
return False
|
||||
|
||||
console.print(f"[green]Starting broker (repo root: {repo_root})...[/green]")
|
||||
|
||||
daemon = BrokerDaemon(repo_root=repo_root)
|
||||
console.print(f"[green]Listening on {daemon.socket_path}[/green]")
|
||||
console.print("[dim]Press Ctrl+C to stop[/dim]")
|
||||
try:
|
||||
daemon.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("broker: interrupted, stopping")
|
||||
daemon.stop()
|
||||
console.print("[yellow]Broker stopped[/yellow]")
|
||||
return True
|
||||
|
||||
|
||||
def _show_status() -> bool:
|
||||
"""Show broker status."""
|
||||
from aipass.drone.apps.handlers.broker.daemon import _default_socket_path
|
||||
|
||||
sock_path = _default_socket_path()
|
||||
if sock_path.exists():
|
||||
console.print(f"[green]Broker socket exists:[/green] {sock_path}")
|
||||
return True
|
||||
console.print(f"No broker socket found at: {sock_path}")
|
||||
return True
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module overview (no args)."""
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console as c
|
||||
except ImportError:
|
||||
logger.warning("CLI console not available, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
c = Console()
|
||||
|
||||
c.print()
|
||||
c.print("[bold cyan]broker Module[/bold cyan]")
|
||||
c.print("[dim]Privileged delete daemon for sandboxed agents.[/dim]")
|
||||
c.print()
|
||||
c.print("[yellow]Connected Handlers:[/yellow]")
|
||||
c.print(" [cyan]handlers/broker/[/cyan]")
|
||||
c.print(" - [cyan]daemon.py[/cyan] [dim](BrokerDaemon — unix socket listener + openat2 resolver)[/dim]")
|
||||
c.print(" - [cyan]client.py[/cyan] [dim](broker_delete — send requests over inherited fd)[/dim]")
|
||||
c.print(" - [cyan]path_resolver.py[/cyan] [dim](resolve_beneath — openat2 RESOLVE_BENEATH)[/dim]")
|
||||
c.print(" - [cyan]protocol.py[/cyan] [dim](BrokerRequest/BrokerResponse — typed JSON-line IPC)[/dim]")
|
||||
c.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Display help (--help flag)."""
|
||||
console.print("Usage: drone broker <command>")
|
||||
console.print()
|
||||
console.print("Privileged delete daemon for sandboxed agents.")
|
||||
console.print()
|
||||
console.print("[bold]Commands:[/bold]")
|
||||
console.print(" [green]start[/green] Start the broker daemon (foreground)")
|
||||
console.print(" [green]status[/green] Check if the broker socket exists")
|
||||
console.print()
|
||||
console.print("[bold]Environment:[/bold]")
|
||||
console.print(" AIPASS_BROKER_FD Inherited socket fd (set by launch wrapper)")
|
||||
console.print()
|
||||
console.print("[bold]Socket:[/bold] $REPO/.ai_central/drone_broker.sock")
|
||||
console.print("[bold]Audit:[/bold] $REPO/.ai_central/drone_broker_audit.jsonl")
|
||||
@@ -20,6 +20,10 @@ from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.rm_handler import (
|
||||
safe_delete as _safe_delete,
|
||||
)
|
||||
from aipass.drone.apps.handlers.broker.client import (
|
||||
is_sandboxed as _is_sandboxed,
|
||||
broker_delete as _broker_delete,
|
||||
)
|
||||
|
||||
DRONE_MODULE = {
|
||||
"name": "rm",
|
||||
@@ -32,8 +36,16 @@ def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]:
|
||||
"""Delete paths with containment checks.
|
||||
|
||||
Returns list of ``(original_path, success, message)`` tuples.
|
||||
When sandboxed (AIPASS_BROKER_FD set), routes through the broker daemon.
|
||||
"""
|
||||
logger.info("rm: requested deletion of %d path(s)", len(paths))
|
||||
if _is_sandboxed():
|
||||
json_handler.log_operation("rm_broker", {"paths": paths})
|
||||
results: list[tuple[str, bool, str]] = []
|
||||
for path_str in paths:
|
||||
ok, message = _broker_delete(path_str)
|
||||
results.append((path_str, ok, message))
|
||||
return results
|
||||
return _safe_delete(paths)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_broker.py
|
||||
# Description: Tests for the drone-broker daemon, identity, and allowlist
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Tests for the drone-broker daemon (Phase 3 + Phase 6a FPLAN-0250).
|
||||
|
||||
Covers: protocol serialization, path resolution (openat2 + walk fallback),
|
||||
daemon accept/delete/refuse/audit, identity handshake (HMAC), allowlist
|
||||
policy (identity-scoped), denylist backstop, confused-deputy attacks,
|
||||
client broker_delete / create_identified_connection, and rm broker routing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import json
|
||||
import socket
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse
|
||||
from aipass.drone.apps.handlers.broker.path_resolver import resolve_beneath
|
||||
from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon
|
||||
from aipass.drone.apps.handlers.broker.client import (
|
||||
broker_delete,
|
||||
create_identified_connection,
|
||||
is_sandboxed,
|
||||
BROKER_FD_ENV,
|
||||
)
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform != "linux",
|
||||
reason="broker is Linux-only: AF_UNIX sockets + openat2 RESOLVE_BENEATH",
|
||||
)
|
||||
|
||||
json_handler.log_operation("test_broker_load", {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _recv_response(sock: socket.socket) -> BrokerResponse:
|
||||
"""Read a single newline-terminated response from a socket."""
|
||||
data = b""
|
||||
while b"\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
return BrokerResponse.from_bytes(data)
|
||||
|
||||
|
||||
def _send_raw(sock_path: Path, req: BrokerRequest) -> BrokerResponse:
|
||||
"""Send a request on a fresh (unidentified) connection, return response."""
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(sock_path))
|
||||
try:
|
||||
client.sendall(req.to_bytes())
|
||||
return _recv_response(client)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _send_identified(broker: BrokerDaemon, branch: str, req: BrokerRequest) -> BrokerResponse:
|
||||
"""Connect, identify, send request, return the delete response."""
|
||||
sock = create_identified_connection(broker.socket_path, broker._secret_path, branch)
|
||||
try:
|
||||
sock.sendall(req.to_bytes())
|
||||
return _recv_response(sock)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo_root(tmp_path: Path) -> Path:
|
||||
"""Set up a mock repo root with branch directories."""
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
branch = root / "src" / "aipass" / "testbranch"
|
||||
branch.mkdir(parents=True)
|
||||
(branch / "deleteme.txt").write_text("delete me", encoding="utf-8")
|
||||
(branch / "subdir").mkdir()
|
||||
(branch / "subdir" / "nested.txt").write_text("nested", encoding="utf-8")
|
||||
(branch / ".git").mkdir()
|
||||
(branch / ".git" / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
|
||||
sibling = root / "src" / "aipass" / "sibling"
|
||||
sibling.mkdir(parents=True)
|
||||
(sibling / "important.txt").write_text("don't delete", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def broker(tmp_path: Path, repo_root: Path) -> BrokerDaemon:
|
||||
"""Create a broker instance with a temp socket and audit log."""
|
||||
sock_path = tmp_path / "test_broker.sock"
|
||||
audit_path = tmp_path / "test_audit.jsonl"
|
||||
secret_path = tmp_path / "test_secret"
|
||||
return BrokerDaemon(
|
||||
repo_root=repo_root,
|
||||
socket_path=sock_path,
|
||||
audit_path=audit_path,
|
||||
secret_path=secret_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def running_broker(broker: BrokerDaemon):
|
||||
"""Start a broker in background, yield it, stop on teardown."""
|
||||
t = broker.start_background()
|
||||
yield broker
|
||||
broker.stop()
|
||||
t.join(timeout=3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProtocol:
|
||||
"""Test BrokerRequest/BrokerResponse serialization."""
|
||||
|
||||
def test_request_roundtrip(self) -> None:
|
||||
"""Request serializes and deserializes correctly."""
|
||||
req = BrokerRequest(op="delete", path="foo/bar.txt", request_id="abc123")
|
||||
data = req.to_bytes()
|
||||
assert data.endswith(b"\n")
|
||||
parsed = BrokerRequest.from_bytes(data)
|
||||
assert parsed.op == "delete"
|
||||
assert parsed.path == "foo/bar.txt"
|
||||
assert parsed.request_id == "abc123"
|
||||
|
||||
def test_response_roundtrip(self) -> None:
|
||||
"""Response serializes and deserializes correctly."""
|
||||
resp = BrokerResponse(ok=True, message="Deleted", request_id="abc")
|
||||
data = resp.to_bytes()
|
||||
parsed = BrokerResponse.from_bytes(data)
|
||||
assert parsed.ok is True
|
||||
assert parsed.message == "Deleted"
|
||||
|
||||
def test_request_extra_fields(self) -> None:
|
||||
"""Extra fields survive roundtrip."""
|
||||
req = BrokerRequest(op="delete", path="x", extra={"key": "val"})
|
||||
parsed = BrokerRequest.from_bytes(req.to_bytes())
|
||||
assert parsed.extra == {"key": "val"}
|
||||
|
||||
def test_response_error_code(self) -> None:
|
||||
"""Error code field survives roundtrip."""
|
||||
resp = BrokerResponse(ok=False, message="denied", error_code="DENYLIST")
|
||||
parsed = BrokerResponse.from_bytes(resp.to_bytes())
|
||||
assert parsed.error_code == "DENYLIST"
|
||||
|
||||
def test_identify_request_roundtrip(self) -> None:
|
||||
"""Identify request with branch/hmac survives roundtrip."""
|
||||
req = BrokerRequest(op="identify", branch="testbranch", hmac="abc123", request_id="id1")
|
||||
parsed = BrokerRequest.from_bytes(req.to_bytes())
|
||||
assert parsed.op == "identify"
|
||||
assert parsed.branch == "testbranch"
|
||||
assert parsed.hmac == "abc123"
|
||||
|
||||
def test_delete_request_path_defaults_empty(self) -> None:
|
||||
"""Delete request path defaults to empty string when missing."""
|
||||
data = json.dumps({"op": "delete"}).encode() + b"\n"
|
||||
parsed = BrokerRequest.from_bytes(data)
|
||||
assert parsed.path == ""
|
||||
assert parsed.branch == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path resolver tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPathResolver:
|
||||
"""Test resolve_beneath path resolution."""
|
||||
|
||||
def test_resolve_existing_file(self, repo_root: Path) -> None:
|
||||
"""Resolves a valid file path beneath the base."""
|
||||
base = repo_root / "src" / "aipass" / "testbranch"
|
||||
result = resolve_beneath(base, "deleteme.txt")
|
||||
assert result == (base / "deleteme.txt").resolve()
|
||||
|
||||
def test_resolve_nested(self, repo_root: Path) -> None:
|
||||
"""Resolves a nested path."""
|
||||
base = repo_root / "src" / "aipass" / "testbranch"
|
||||
result = resolve_beneath(base, "subdir/nested.txt")
|
||||
assert result == (base / "subdir" / "nested.txt").resolve()
|
||||
|
||||
def test_reject_dotdot_escape(self, repo_root: Path) -> None:
|
||||
"""Refuses paths with .. components."""
|
||||
base = repo_root / "src" / "aipass" / "testbranch"
|
||||
with pytest.raises(OSError, match="\\.\\."):
|
||||
resolve_beneath(base, "../escape.txt")
|
||||
|
||||
def test_reject_dotdot_middle(self, repo_root: Path) -> None:
|
||||
"""Refuses .. in the middle of a path."""
|
||||
base = repo_root / "src" / "aipass" / "testbranch"
|
||||
with pytest.raises(OSError, match="\\.\\."):
|
||||
resolve_beneath(base, "subdir/../../escape.txt")
|
||||
|
||||
def test_reject_absolute(self, repo_root: Path) -> None:
|
||||
"""Refuses absolute paths."""
|
||||
base = repo_root / "src" / "aipass" / "testbranch"
|
||||
with pytest.raises(OSError, match="leading /"):
|
||||
resolve_beneath(base, "/etc/passwd")
|
||||
|
||||
def test_reject_symlink(self, repo_root: Path) -> None:
|
||||
"""Refuses paths through symlinks."""
|
||||
base = repo_root / "src" / "aipass" / "testbranch"
|
||||
link = base / "link"
|
||||
link.symlink_to("/tmp")
|
||||
try:
|
||||
with pytest.raises(OSError):
|
||||
resolve_beneath(base, "link/something")
|
||||
finally:
|
||||
link.unlink()
|
||||
|
||||
def test_nonexistent_path(self, repo_root: Path) -> None:
|
||||
"""Raises OSError for nonexistent paths."""
|
||||
base = repo_root / "src" / "aipass" / "testbranch"
|
||||
with pytest.raises(OSError):
|
||||
resolve_beneath(base, "does_not_exist.txt")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Daemon mechanism tests (identified connection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBrokerDaemon:
|
||||
"""Test the broker daemon accept/delete/refuse logic with an identified connection."""
|
||||
|
||||
def test_delete_allowed_file(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Broker deletes an allowed file under the identified branch tree."""
|
||||
target = repo_root / "src" / "aipass" / "testbranch" / "deleteme.txt"
|
||||
assert target.exists()
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path="deleteme.txt", request_id="t1"),
|
||||
)
|
||||
assert resp.ok is True
|
||||
assert "Deleted" in resp.message
|
||||
assert not target.exists()
|
||||
|
||||
audit = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
last = json.loads(audit[-1])
|
||||
assert last["result"] == "DELETED"
|
||||
assert last["identity"] == "testbranch"
|
||||
|
||||
def test_delete_nested_file(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Broker deletes a nested file."""
|
||||
target = repo_root / "src" / "aipass" / "testbranch" / "subdir" / "nested.txt"
|
||||
assert target.exists()
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path="subdir/nested.txt", request_id="t2"),
|
||||
)
|
||||
assert resp.ok is True
|
||||
assert not target.exists()
|
||||
|
||||
def test_refuse_protected_git(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Broker refuses deletion inside .git (denylist backstop)."""
|
||||
target = repo_root / "src" / "aipass" / "testbranch" / ".git" / "HEAD"
|
||||
assert target.exists()
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path=".git/HEAD", request_id="t3"),
|
||||
)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "DENYLIST"
|
||||
assert target.exists()
|
||||
|
||||
def test_refuse_dotdot_escape(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Broker refuses confused-deputy .. escape."""
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path="../../../etc/passwd", request_id="t4"),
|
||||
)
|
||||
assert resp.ok is False
|
||||
|
||||
def test_refuse_symlink_escape(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Broker refuses confused-deputy symlink escape."""
|
||||
base = repo_root / "src" / "aipass" / "testbranch"
|
||||
link = base / "evil_link"
|
||||
link.symlink_to("/tmp")
|
||||
try:
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path="evil_link/target", request_id="t5"),
|
||||
)
|
||||
assert resp.ok is False
|
||||
finally:
|
||||
link.unlink()
|
||||
|
||||
def test_refuse_nonexistent(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Broker refuses deletion of nonexistent paths."""
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path="no_such_file.xyz", request_id="t6"),
|
||||
)
|
||||
assert resp.ok is False
|
||||
|
||||
def test_refuse_root_delete(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Broker refuses deleting the base directory itself."""
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path=".", request_id="t7"),
|
||||
)
|
||||
assert resp.ok is False
|
||||
|
||||
def test_unknown_operation(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Broker refuses unknown operation types."""
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(running_broker.socket_path))
|
||||
try:
|
||||
bad_req = json.dumps({"op": "chmod", "path": "x"}).encode() + b"\n"
|
||||
client.sendall(bad_req)
|
||||
resp = _recv_response(client)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "UNKNOWN_OP"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_audit_log_written(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Every request writes an audit entry with identity."""
|
||||
_send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path="deleteme.txt", request_id="audit1"),
|
||||
)
|
||||
assert running_broker.audit_path.exists()
|
||||
lines = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
assert len(lines) >= 1
|
||||
entry = json.loads(lines[-1])
|
||||
assert "timestamp" in entry
|
||||
assert "identity" in entry
|
||||
|
||||
def test_stop_cleans_socket(self, broker: BrokerDaemon) -> None:
|
||||
"""Stopping the broker removes the socket file."""
|
||||
t = broker.start_background()
|
||||
assert broker.socket_path.exists()
|
||||
broker.stop()
|
||||
t.join(timeout=3)
|
||||
assert not broker.socket_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Denylist tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDenylist:
|
||||
"""Test that all protected directories are denied even with identity."""
|
||||
|
||||
@pytest.mark.parametrize("dirname", [".git", ".trinity", ".aipass", ".codex", ".agents"])
|
||||
def test_protected_dirs_refused(
|
||||
self,
|
||||
running_broker: BrokerDaemon,
|
||||
repo_root: Path,
|
||||
dirname: str,
|
||||
) -> None:
|
||||
"""Each protected directory is refused regardless of identity."""
|
||||
branch_dir = repo_root / "src" / "aipass" / "testbranch"
|
||||
protected_dir = branch_dir / dirname
|
||||
protected_dir.mkdir(exist_ok=True)
|
||||
(protected_dir / "file.txt").write_text("protected", encoding="utf-8")
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(
|
||||
op="delete",
|
||||
path=f"{dirname}/file.txt",
|
||||
request_id=f"deny_{dirname}",
|
||||
),
|
||||
)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "DENYLIST"
|
||||
assert (protected_dir / "file.txt").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity handshake tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
"""Test the HMAC-based identity handshake."""
|
||||
|
||||
def test_good_hmac_identifies(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Valid HMAC produces a successful identify response."""
|
||||
secret = running_broker._secret_path.read_bytes()
|
||||
mac = hmac_mod.new(secret, b"testbranch", hashlib.sha256).hexdigest()
|
||||
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(running_broker.socket_path))
|
||||
try:
|
||||
req = BrokerRequest(
|
||||
op="identify",
|
||||
branch="testbranch",
|
||||
hmac=mac,
|
||||
request_id="id1",
|
||||
)
|
||||
client.sendall(req.to_bytes())
|
||||
resp = _recv_response(client)
|
||||
assert resp.ok is True
|
||||
assert "Identified" in resp.message
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_bad_hmac_refused(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Invalid HMAC is refused and audited."""
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(running_broker.socket_path))
|
||||
try:
|
||||
req = BrokerRequest(
|
||||
op="identify",
|
||||
branch="testbranch",
|
||||
hmac="deadbeef",
|
||||
request_id="id2",
|
||||
)
|
||||
client.sendall(req.to_bytes())
|
||||
resp = _recv_response(client)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "IDENTIFY_FAILED"
|
||||
|
||||
audit = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
entry = json.loads(audit[-1])
|
||||
assert entry["result"] == "REFUSED"
|
||||
assert entry["reason"] == "bad HMAC"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_bad_hmac_connection_still_usable(self, running_broker: BrokerDaemon, tmp_path: Path) -> None:
|
||||
"""After a bad HMAC, connection remains at unidentified scope."""
|
||||
tmp_file = tmp_path / "unid_delete.txt"
|
||||
tmp_file.write_text("unidentified", encoding="utf-8")
|
||||
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(running_broker.socket_path))
|
||||
try:
|
||||
req = BrokerRequest(op="identify", branch="x", hmac="bad", request_id="id3")
|
||||
client.sendall(req.to_bytes())
|
||||
resp = _recv_response(client)
|
||||
assert resp.ok is False
|
||||
|
||||
del_req = BrokerRequest(op="delete", path=str(tmp_file), request_id="id3del")
|
||||
client.sendall(del_req.to_bytes())
|
||||
del_resp = _recv_response(client)
|
||||
assert del_resp.ok is True
|
||||
assert not tmp_file.exists()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_second_identify_refused(self, running_broker: BrokerDaemon) -> None:
|
||||
"""A second identify on the same connection is refused."""
|
||||
secret = running_broker._secret_path.read_bytes()
|
||||
mac = hmac_mod.new(secret, b"testbranch", hashlib.sha256).hexdigest()
|
||||
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(running_broker.socket_path))
|
||||
try:
|
||||
req = BrokerRequest(
|
||||
op="identify",
|
||||
branch="testbranch",
|
||||
hmac=mac,
|
||||
request_id="first",
|
||||
)
|
||||
client.sendall(req.to_bytes())
|
||||
resp1 = _recv_response(client)
|
||||
assert resp1.ok is True
|
||||
|
||||
req2 = BrokerRequest(
|
||||
op="identify",
|
||||
branch="testbranch",
|
||||
hmac=mac,
|
||||
request_id="second",
|
||||
)
|
||||
client.sendall(req2.to_bytes())
|
||||
resp2 = _recv_response(client)
|
||||
assert resp2.ok is False
|
||||
assert resp2.error_code == "IDENTIFY_LATE"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_identify_after_delete_refused(self, running_broker: BrokerDaemon, tmp_path: Path) -> None:
|
||||
"""Identify after a delete (non-first message) is refused."""
|
||||
tmp_file = tmp_path / "early_delete.txt"
|
||||
tmp_file.write_text("early", encoding="utf-8")
|
||||
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(running_broker.socket_path))
|
||||
try:
|
||||
del_req = BrokerRequest(op="delete", path=str(tmp_file), request_id="del_first")
|
||||
client.sendall(del_req.to_bytes())
|
||||
_recv_response(client)
|
||||
|
||||
secret = running_broker._secret_path.read_bytes()
|
||||
mac = hmac_mod.new(secret, b"testbranch", hashlib.sha256).hexdigest()
|
||||
id_req = BrokerRequest(
|
||||
op="identify",
|
||||
branch="testbranch",
|
||||
hmac=mac,
|
||||
request_id="late_id",
|
||||
)
|
||||
client.sendall(id_req.to_bytes())
|
||||
resp = _recv_response(client)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "IDENTIFY_LATE"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_missing_branch_refused(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Identify without branch field is refused."""
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(str(running_broker.socket_path))
|
||||
try:
|
||||
req = BrokerRequest(op="identify", branch="", hmac="x", request_id="no_branch")
|
||||
client.sendall(req.to_bytes())
|
||||
resp = _recv_response(client)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "IDENTIFY_INVALID"
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_secret_file_0600(self, running_broker: BrokerDaemon) -> None:
|
||||
"""Secret file is created with mode 0600."""
|
||||
if os.name != "posix":
|
||||
pytest.skip("POSIX permission check")
|
||||
mode = running_broker._secret_path.stat().st_mode
|
||||
assert stat.S_IMODE(mode) == 0o600 # noqa: windows_compat
|
||||
|
||||
def test_secret_changes_across_restarts(self, tmp_path: Path, repo_root: Path) -> None:
|
||||
"""Secret is regenerated on each daemon start."""
|
||||
sock1 = tmp_path / "s1.sock"
|
||||
sock2 = tmp_path / "s2.sock"
|
||||
secret_path = tmp_path / "secret"
|
||||
audit = tmp_path / "audit.jsonl"
|
||||
|
||||
d1 = BrokerDaemon(
|
||||
repo_root=repo_root,
|
||||
socket_path=sock1,
|
||||
audit_path=audit,
|
||||
secret_path=secret_path,
|
||||
)
|
||||
t1 = d1.start_background()
|
||||
secret1 = secret_path.read_bytes()
|
||||
d1.stop()
|
||||
t1.join(timeout=3)
|
||||
|
||||
d2 = BrokerDaemon(
|
||||
repo_root=repo_root,
|
||||
socket_path=sock2,
|
||||
audit_path=audit,
|
||||
secret_path=secret_path,
|
||||
)
|
||||
t2 = d2.start_background()
|
||||
secret2 = secret_path.read_bytes()
|
||||
d2.stop()
|
||||
t2.join(timeout=3)
|
||||
|
||||
assert secret1 != secret2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist policy tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllowlistPolicy:
|
||||
"""Test identity-scoped allowlist policy."""
|
||||
|
||||
def test_unidentified_tmp_allowed(self, running_broker: BrokerDaemon, tmp_path: Path) -> None:
|
||||
"""Unidentified connections can delete under /tmp."""
|
||||
target = tmp_path / "unid_tmp.txt"
|
||||
target.write_text("tmp file", encoding="utf-8")
|
||||
|
||||
resp = _send_raw(
|
||||
running_broker.socket_path,
|
||||
BrokerRequest(op="delete", path=str(target), request_id="unid_tmp"),
|
||||
)
|
||||
assert resp.ok is True
|
||||
assert not target.exists()
|
||||
|
||||
def test_unidentified_repo_refused(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Unidentified connections cannot delete repo paths."""
|
||||
target = repo_root / "src" / "aipass" / "testbranch" / "deleteme.txt"
|
||||
assert target.exists()
|
||||
|
||||
resp = _send_raw(
|
||||
running_broker.socket_path,
|
||||
BrokerRequest(op="delete", path=str(target), request_id="unid_repo"),
|
||||
)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "NO_BASE"
|
||||
assert target.exists()
|
||||
|
||||
def test_builder_own_tree_allowed(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Builder identity can delete files in its own branch tree."""
|
||||
target = repo_root / "src" / "aipass" / "testbranch" / "deleteme.txt"
|
||||
assert target.exists()
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path="deleteme.txt", request_id="own_tree"),
|
||||
)
|
||||
assert resp.ok is True
|
||||
assert not target.exists()
|
||||
|
||||
def test_builder_sibling_refused(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Builder identity cannot delete files in a sibling branch tree."""
|
||||
target = repo_root / "src" / "aipass" / "sibling" / "important.txt"
|
||||
assert target.exists()
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path=str(target), request_id="sibling"),
|
||||
)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "NO_BASE"
|
||||
assert target.exists()
|
||||
|
||||
def test_devpulse_sibling_allowed(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""devpulse identity can delete files in any branch tree."""
|
||||
junk = repo_root / "src" / "aipass" / "sibling" / "junk.txt"
|
||||
junk.write_text("junk", encoding="utf-8")
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"devpulse",
|
||||
BrokerRequest(op="delete", path=str(junk), request_id="dp_sib"),
|
||||
)
|
||||
assert resp.ok is True
|
||||
assert not junk.exists()
|
||||
|
||||
def test_devpulse_denylist_still_blocks(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""devpulse cannot delete paths under denylist dirs (backstop)."""
|
||||
target = repo_root / "src" / "aipass" / "testbranch" / ".git" / "HEAD"
|
||||
assert target.exists()
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"devpulse",
|
||||
BrokerRequest(op="delete", path=str(target), request_id="dp_deny"),
|
||||
)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "DENYLIST"
|
||||
assert target.exists()
|
||||
|
||||
@pytest.mark.parametrize("dirname", [".git", ".trinity"])
|
||||
def test_devpulse_denylist_backstop(
|
||||
self,
|
||||
running_broker: BrokerDaemon,
|
||||
repo_root: Path,
|
||||
dirname: str,
|
||||
) -> None:
|
||||
"""devpulse is blocked by denylist backstop on protected dirs."""
|
||||
protected = repo_root / dirname
|
||||
protected.mkdir(exist_ok=True)
|
||||
(protected / "config").write_text("sacred", encoding="utf-8")
|
||||
|
||||
resp = _send_identified(
|
||||
running_broker,
|
||||
"devpulse",
|
||||
BrokerRequest(
|
||||
op="delete",
|
||||
path=str(protected / "config"),
|
||||
request_id=f"dp_deny_{dirname}",
|
||||
),
|
||||
)
|
||||
assert resp.ok is False
|
||||
assert resp.error_code == "DENYLIST"
|
||||
assert (protected / "config").exists()
|
||||
|
||||
def test_audit_carries_identity_on_grant(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Audit entries for grants include the identity."""
|
||||
_send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path="deleteme.txt", request_id="aud_grant"),
|
||||
)
|
||||
lines = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
delete_entries = [json.loads(raw) for raw in lines if json.loads(raw).get("op") == "delete"]
|
||||
last = delete_entries[-1]
|
||||
assert last["identity"] == "testbranch"
|
||||
assert last["result"] == "DELETED"
|
||||
|
||||
def test_audit_carries_identity_on_refusal(self, running_broker: BrokerDaemon, repo_root: Path) -> None:
|
||||
"""Audit entries for refusals include the identity."""
|
||||
_send_identified(
|
||||
running_broker,
|
||||
"testbranch",
|
||||
BrokerRequest(op="delete", path=".git/HEAD", request_id="aud_refuse"),
|
||||
)
|
||||
lines = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
delete_entries = [json.loads(raw) for raw in lines if json.loads(raw).get("op") == "delete"]
|
||||
last = delete_entries[-1]
|
||||
assert last["identity"] == "testbranch"
|
||||
assert last["result"] == "REFUSED"
|
||||
|
||||
def test_audit_null_identity_for_unidentified(self, running_broker: BrokerDaemon, tmp_path: Path) -> None:
|
||||
"""Audit entries for unidentified connections have null identity."""
|
||||
target = tmp_path / "aud_unid.txt"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
|
||||
_send_raw(
|
||||
running_broker.socket_path,
|
||||
BrokerRequest(op="delete", path=str(target), request_id="aud_unid"),
|
||||
)
|
||||
lines = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n")
|
||||
delete_entries = [json.loads(raw) for raw in lines if json.loads(raw).get("op") == "delete"]
|
||||
last = delete_entries[-1]
|
||||
assert last["identity"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClient:
|
||||
"""Test the broker client (sandboxed drone rm path)."""
|
||||
|
||||
def test_is_sandboxed_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Not sandboxed when env var is absent."""
|
||||
monkeypatch.delenv(BROKER_FD_ENV, raising=False)
|
||||
assert is_sandboxed() is False
|
||||
|
||||
def test_is_sandboxed_true(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sandboxed when env var is present."""
|
||||
monkeypatch.setenv(BROKER_FD_ENV, "3")
|
||||
assert is_sandboxed() is True
|
||||
|
||||
def test_broker_delete_no_fd(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""broker_delete fails gracefully without fd."""
|
||||
monkeypatch.delenv(BROKER_FD_ENV, raising=False)
|
||||
ok, msg = broker_delete("/tmp/test")
|
||||
assert ok is False
|
||||
assert "not set" in msg
|
||||
|
||||
def test_broker_delete_via_socket(
|
||||
self,
|
||||
running_broker: BrokerDaemon,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""broker_delete sends request over a real socket fd (unidentified, /tmp)."""
|
||||
target = tmp_path / "client_delete.txt"
|
||||
target.write_text("delete me", encoding="utf-8")
|
||||
|
||||
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client_sock.connect(str(running_broker.socket_path))
|
||||
fd = client_sock.fileno()
|
||||
monkeypatch.setenv(BROKER_FD_ENV, str(fd))
|
||||
|
||||
ok, msg = broker_delete(str(target))
|
||||
assert ok is True
|
||||
assert "Deleted" in msg
|
||||
assert not target.exists()
|
||||
|
||||
client_sock.close()
|
||||
|
||||
def test_create_identified_connection(self, running_broker: BrokerDaemon) -> None:
|
||||
"""create_identified_connection returns an authenticated socket."""
|
||||
sock = create_identified_connection(
|
||||
running_broker.socket_path,
|
||||
running_broker._secret_path,
|
||||
"testbranch",
|
||||
)
|
||||
try:
|
||||
assert sock.fileno() >= 0
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def test_create_identified_connection_bad_secret(self, running_broker: BrokerDaemon, tmp_path: Path) -> None:
|
||||
"""create_identified_connection raises on bad secret."""
|
||||
bad_secret = tmp_path / "bad_secret"
|
||||
bad_secret.write_bytes(b"wrong" * 8)
|
||||
|
||||
with pytest.raises(RuntimeError, match="identify failed"):
|
||||
create_identified_connection(running_broker.socket_path, bad_secret, "testbranch")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rm_handler integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRmBrokerRouting:
|
||||
"""Test that rm module routes through broker when sandboxed."""
|
||||
|
||||
def test_unsandboxed_uses_direct(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Without AIPASS_BROKER_FD, rm uses direct delete."""
|
||||
monkeypatch.delenv(BROKER_FD_ENV, raising=False)
|
||||
target = tmp_path / "direct_delete.txt"
|
||||
target.write_text("test", encoding="utf-8")
|
||||
|
||||
from aipass.drone.apps.modules.rm import safe_delete
|
||||
|
||||
results = safe_delete([str(target)])
|
||||
assert results[0][1] is True
|
||||
assert not target.exists()
|
||||
|
||||
def test_sandboxed_uses_broker(
|
||||
self,
|
||||
running_broker: BrokerDaemon,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With AIPASS_BROKER_FD, rm routes through broker (unidentified, /tmp)."""
|
||||
target = tmp_path / "broker_rm.txt"
|
||||
target.write_text("delete me", encoding="utf-8")
|
||||
|
||||
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client_sock.connect(str(running_broker.socket_path))
|
||||
monkeypatch.setenv(BROKER_FD_ENV, str(client_sock.fileno()))
|
||||
|
||||
from aipass.drone.apps.modules.rm import safe_delete
|
||||
|
||||
results = safe_delete([str(target)])
|
||||
assert results[0][1] is True
|
||||
assert not target.exists()
|
||||
|
||||
client_sock.close()
|
||||
@@ -153,7 +153,7 @@ def print_help():
|
||||
console.print("[yellow]TEMPLATE SELECTION:[/yellow]")
|
||||
console.print(" The 4th arg selects a non-default template within a type.")
|
||||
console.print(" Any .md file stem in the type's templates/ dir works.")
|
||||
console.print(' [dim]drone @flow create . "Subject" sunday_merge pplan[/dim]')
|
||||
console.print(' [dim]drone @flow create . "Subject" merge pplan[/dim]')
|
||||
console.print(' [dim]drone @flow create . "Subject" master[/dim] # FPLAN master')
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -88,12 +88,12 @@ def print_help():
|
||||
console.print()
|
||||
console.print("[bold]HOW TO ADD A NEW PLAN TYPE / SOP TEMPLATE:[/bold]")
|
||||
console.print(" 1. Create a directory under templates/ (e.g. templates/playbook_plans/)")
|
||||
console.print(" 2. Add one or more .md template files (e.g. default.md, sunday_merge.md)")
|
||||
console.print(" 2. Add one or more .md template files (e.g. default.md, merge.md)")
|
||||
console.print(" 3. Register with your chosen prefix:")
|
||||
console.print(" drone @flow register playbook_plans PBPLAN")
|
||||
console.print(" 4. Create plans:")
|
||||
console.print(' drone @flow create . "Subject" pbplan')
|
||||
console.print(' drone @flow create . "Subject" sunday_merge pbplan')
|
||||
console.print(' drone @flow create . "Subject" merge pplan')
|
||||
console.print()
|
||||
console.print(" [dim]Auto-registration runs on any flow command if you skip step 3,[/dim]")
|
||||
console.print(" [dim]but derives the prefix automatically. Use register to choose your own.[/dim]")
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
## What Are Playbooks?
|
||||
|
||||
Playbooks (PBPLANs) are **throwaway SOP runs** — a checklist stamped from a reusable
|
||||
template for a recurring operation (Sunday merge, release cut, branch onboarding,
|
||||
template for a recurring operation (merge, release cut, branch onboarding,
|
||||
incident response). You tick steps off as you go, log what happened, then close.
|
||||
|
||||
- **The template = the SOP.** Stable. Refine it over time as the process improves.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# {plan_number} - {subject} (MERGE)
|
||||
|
||||
**Created**: {today}
|
||||
**Branch**: {location}
|
||||
**Status**: Active
|
||||
**Type**: Playbook — Merge SOP
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
The `dev → main` merge + release tag — run on-demand, not on a fixed weekly cadence.
|
||||
Run by **devpulse** (only branch with git write). Tick each step as you go; fill the
|
||||
**Run Summary** with PR numbers and tags for the vectorized trail. Close when done.
|
||||
|
||||
> All git writes go through `drone @git` — **run drone from a branch dir** (it needs
|
||||
> `.trinity/passport.json` in the cwd; running from the repo root fails with "No
|
||||
> passport found"). Read git (`status`, `log`, `diff`, `rev-parse`) is allowed raw.
|
||||
> ⚠️ `drone @git` has **no `tag` verb** — pushing the release tag is a MANUAL step
|
||||
> (Patrick, or raw `git tag`/`push` via `!`). All other writes go through drone.
|
||||
|
||||
---
|
||||
|
||||
## The Law (read first — these prevent the recurring git scares)
|
||||
|
||||
1. **Local files on `dev` are the truth.** Git is just a transfer mechanism to the
|
||||
remote. We live on `dev`, permanently.
|
||||
2. **`main` is a remote push-target, nothing more.** Local `main` can be 7000 commits
|
||||
behind — it does **not** matter. The only thing ever done to local main is a
|
||||
*cosmetic* pull. Never build on it, never read files from it.
|
||||
3. **NEVER move HEAD lightly.** Any HEAD move — `checkout` (switch branch), `reset`
|
||||
(move backward), `rebase` onto a different base — changes what's in the working tree and
|
||||
*always* causes confusion, even when the work is technically safe. Treat every HEAD move
|
||||
as a deliberate, narrated step, never a reflex. In normal flow you should rarely move HEAD
|
||||
at all: you commit (HEAD advances on dev) and push. That's it.
|
||||
- **NEVER check out `main`, NEVER reset a HEAD.** Checking out main swaps your whole
|
||||
working tree to main's (often stale) content — that's the file-revert scare. The flow
|
||||
never needs it. Stay on `dev`.
|
||||
- The only routine, safe HEAD advance is a **fast-forward of dev** when dev is purely
|
||||
behind main (`git rev-list --left-right --count dev...origin/main` shows `0` ahead) —
|
||||
done via `drone @git sync` **from dev** (stays on dev). If dev shows any commits *ahead*,
|
||||
it's not a pure FF — stop and think, don't force it.
|
||||
- To reference main for a tag, use **`origin/main`** (the remote ref), never local main.
|
||||
- ⚠️ Your IDE's "switch to main" / "sync main" button does a `git checkout main` — **don't
|
||||
click it.** If you want local main fresh, `drone @git sync` **from dev** is safe (it
|
||||
stays on dev); the IDE button is not.
|
||||
|
||||
### Why `dev` shows "behind main" after a merge — and why it's fine
|
||||
|
||||
`drone @git merge` does a **squash** merge: GitHub bundles dev's commits into **one brand-new
|
||||
commit** on main. Your `dev` branch keeps its **original** commits. Git compares by commit
|
||||
*identity*, not content — so it sees "main has 1 commit dev doesn't" → the UI shows
|
||||
**"dev is 1 behind main."**
|
||||
|
||||
- **The files are identical. It is 100% cosmetic. You can always move forward** — the next
|
||||
`dev-pr` compares real file changes and works perfectly regardless of this graph quirk.
|
||||
- Because it's a squash (not a fast-forward), **`dev` is NOT an ancestor of `main`**, so
|
||||
`git merge --ff-only main` will **fail**. Do not use it after a squash merge.
|
||||
- **Optional**, only if you want the graph to show dev even/ahead: back-merge with
|
||||
`git merge origin/main` while on dev (via `drone @git`) — creates a merge commit, dev moves
|
||||
*ahead*, push normally (NO force, NO reset). **Do NOT rebase** (rewrites dev, needs force-push).
|
||||
|
||||
---
|
||||
|
||||
## 1. Pre-flight
|
||||
|
||||
- [ ] On `dev`, working tree understood: `drone @git status --all`
|
||||
- [ ] Confirm what's shipping — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok)
|
||||
- [ ] No surprise files (stray `/tmp` artifacts, test pollution, `.recovery`/`.archive` churn). Clean = archive, never delete.
|
||||
- [ ] **Version state check** (informs the bump decision): read the **two** release-tied versions — `grep '^version' pyproject.toml` and `grep __version__ src/aipass/__init__.py` (they should match; if drifted, note it) — and what PyPI already has: `curl -s https://pypi.org/pypi/aipass/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`. PyPI rejects a duplicate, so the target must be > published.
|
||||
- [ ] Decide: **release tag this merge?** (tag = PyPI publish + GitHub Release). If yes, note target version. (Significance call is the user's — the PATCH-default rule below is guidance, and the actual release history is a useful tie-breaker.)
|
||||
|
||||
## 2. Verify, commit, CHANGELOG
|
||||
|
||||
- [ ] **Run the CI audit gate LOCALLY before pushing** (local == CI, S199 parity — catches red before the PR): `cd <repo-root> && .venv/bin/python .github/scripts/seedgo_audit.py` → expect all 13 branches `>=100%`, exit 0. Uses a relative `src/aipass` path, so run from the repo **root**, not a branch dir.
|
||||
- [ ] Update `CHANGELOG.md` — add entries under a dated section header `## [YYYY-MM-DD]` (the merge date), one section per merge. Sort into Added / Changed / Fixed.
|
||||
- [ ] Commit: `drone @git commit "msg" --all` (from a branch dir, e.g. devpulse). New/untracked files (e.g. new templates) — confirm they got staged: `git ls-files <path>` after; `--all` may not pick up untracked.
|
||||
- [ ] Every commit pushed — local-only commits are invisible
|
||||
|
||||
## 3. Open / update the PR
|
||||
|
||||
- [ ] `drone @git dev-pr "Merge summary: what's shipping"`
|
||||
- [ ] "PR already open" in output = push succeeded onto the existing PR (expected on re-runs)
|
||||
- [ ] Record the PR number → Run Summary
|
||||
|
||||
## 4. Wait for CI green (ALL required checks)
|
||||
|
||||
The PR gate (verified against `.github/workflows/`):
|
||||
- [ ] `ci.yml` → **lint**, **test**, **standards** (= seedgo-audit / the README + 100%-floor check, runs `.github/scripts/seedgo_audit.py`), **coverage**
|
||||
- [ ] `security.yml` → Security Scan / dependency-scan
|
||||
- [ ] `e2e-wheel.yml` → 3-OS wheel smoke (path-filtered: fires on `src/**`, `tests/e2e/**`, `pyproject.toml`)
|
||||
- [ ] `windows-test.yml` / `macos-test.yml` → required checks, run on every PR (must NEVER be path-filtered or they park as "Expected/waiting" forever and block merge)
|
||||
- [ ] If "all green but can't merge": it's usually post-push mergeability **lag**. Confirm ground truth via the public API (no gh, no gate):
|
||||
- `curl -s https://api.github.com/repos/AIOSAI/AIPass/commits/<sha>/check-runs` → all check-runs success (incl. app checks: codecov, CodeQL)
|
||||
- `curl -s https://api.github.com/repos/AIOSAI/AIPass/pulls/<n>` → `mergeable_state: clean`
|
||||
|
||||
## 5. Merge to main
|
||||
|
||||
- [ ] **User's call to merge** — confirm GO
|
||||
- [ ] `drone @git merge <PR#>` (squash-merge)
|
||||
- [ ] ⚠️ The merge command **echoes the PR's ORIGINAL opening description** — often stale if the PR accumulated more work after it was opened. Don't trust it as the merge summary; the real contents are `git log main..dev` from before the merge.
|
||||
- [ ] ⚠️ **Verify `dev` SURVIVES the merge** (the #625 scar — empirical, every time): `drone @git branches` → `dev` still present; `git rev-parse dev` resolves
|
||||
|
||||
## 6. Post-merge realign
|
||||
|
||||
- [ ] **Expect `dev` to show "1 behind main" — that's the squash artifact, it's cosmetic, keep going.** See "Why dev shows behind main" up top. Do NOT reach for `--ff-only` (it fails after a squash) or a rebase/reset.
|
||||
- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine and expected — it's a push-target, not a thing to maintain.
|
||||
- [ ] (Optional, cosmetic only) If you want the graph to show dev even/ahead: back-merge `git merge origin/main` on dev (via `drone @git`), then normal push. Never rebase, never reset, never checkout main.
|
||||
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
|
||||
|
||||
## 7. Release tag (only if cutting a release)
|
||||
|
||||
**Versioning rule — bump by SIGNIFICANCE, not cadence:**
|
||||
- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only → the default for most merges
|
||||
- **MINOR** (`x.Y+1.0`) = a new backward-compatible user-facing feature shipped
|
||||
- **MAJOR** (`X+1.0.0`) = breaking public-API change
|
||||
|
||||
(aipass is a 2.x library others pin → keep SemVer; the CHANGELOG uses `YYYY-MM-DD` dated section headers.)
|
||||
|
||||
How the release fires (verified `publish.yml`): a `v*` **git tag push** runs build → PyPI publish → GitHub Release. Key facts:
|
||||
- PyPI version = `pyproject.toml [project] version` at the tagged commit — **NOT** the tag string (the tag only *triggers* the build).
|
||||
- Tag and `pyproject` version **must match** (`v2.5.2` ⇄ `version = "2.5.2"`), or PyPI publishes the wrong number while the Release is named the tag.
|
||||
- PyPI **rejects a duplicate version** → if shipping, you MUST bump.
|
||||
- GitHub Release notes = the **topmost `## [...]` CHANGELOG block** (awk-extracted).
|
||||
|
||||
Steps:
|
||||
- [ ] Bump the version in **BOTH** files (they must match the tag, or `__version__` ships wrong): `pyproject.toml` `version` **and** `src/aipass/__init__.py` `__version__`. Do it **on dev so it rides into the PR** (then main's merge commit carries the right version). ⚠️ These two drift easily — `__init__.py` is the one that gets forgotten.
|
||||
- [ ] Confirm the CHANGELOG top section is the release notes you want
|
||||
- [ ] Get the **real** merged-main sha from the **remote ref** (stay on dev — never checkout main): `git fetch origin` then `git rev-parse origin/main`. **Verify the version on that exact commit BEFORE tagging:** `git show origin/main:pyproject.toml | grep '^version'` and `git show origin/main:src/aipass/__init__.py | grep __version__` — both must equal the tag. (If the user merged via the GitHub UI, their local `main` ref is stale until `git fetch` — always fetch first, always tag `origin/main`, never local `main`.)
|
||||
- [ ] **Push the tag — MANUAL (drone has no `tag` verb; devpulse can't push tags):** user runs it, via `!` or terminal. **Tag the remote ref directly so a stale local main can't poison it. Separate lines, no `&&`:**
|
||||
```
|
||||
git fetch origin
|
||||
git tag v<version> origin/main
|
||||
git push origin v<version>
|
||||
```
|
||||
- [ ] Verify PyPI shows the new version + the GitHub Release appeared (`curl -s https://pypi.org/pypi/aipass/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`)
|
||||
- [ ] Record the tag → Run Summary
|
||||
|
||||
## 8. Wrap
|
||||
|
||||
- [ ] Update `.trinity/` memories (session log: what merged, PR#, tag)
|
||||
- [ ] Fill **Run Summary** below (PR numbers, tag, anything that broke)
|
||||
- [ ] Close this playbook → vectorizes the run
|
||||
|
||||
---
|
||||
|
||||
## Run Summary
|
||||
|
||||
- **Date:** {today}
|
||||
- **Outcome:** (merged clean / issues / no-merge)
|
||||
- **PR(s) merged:** #
|
||||
- **Release tag:** v
|
||||
- **CI notes:** (any flaky/red checks + how cleared)
|
||||
- **dev survived merge:** yes / no
|
||||
- **Issues hit:**
|
||||
- **Notes for next run:** (refine this SOP — what was missing or wrong?)
|
||||
|
||||
---
|
||||
|
||||
## Listen (TTS-friendly summary)
|
||||
|
||||
Write a plain English summary of this merge here when done. No markdown, no symbols,
|
||||
no tables, no code blocks, no asterisks, no bullet points. Just natural sentences for text to speech.
|
||||
|
||||
---
|
||||
|
||||
## Close Command
|
||||
|
||||
When all steps are ticked and the Run Summary is filled:
|
||||
```bash
|
||||
drone @flow close {plan_number}
|
||||
```
|
||||
@@ -1,122 +0,0 @@
|
||||
# {plan_number} - {subject} (SUNDAY MERGE)
|
||||
|
||||
**Created**: {today}
|
||||
**Branch**: {location}
|
||||
**Status**: Active
|
||||
**Type**: Playbook — Sunday Merge SOP
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
The weekly `dev → main` merge + release tag. Run by **devpulse** (only branch with git
|
||||
write). Tick each step as you go; fill the **Run Summary** with PR numbers and tags for
|
||||
the vectorized trail. Close when done.
|
||||
|
||||
> All git writes go through `drone @git` — **run drone from a branch dir** (it needs
|
||||
> `.trinity/passport.json` in the cwd; running from the repo root fails with "No
|
||||
> passport found"). Read git (`status`, `log`, `diff`, `rev-parse`) is allowed raw.
|
||||
> ⚠️ `drone @git` has **no `tag` verb** — pushing the release tag is a MANUAL step
|
||||
> (Patrick, or raw `git tag`/`push` via `!`). All other writes go through drone.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pre-flight
|
||||
|
||||
- [ ] On `dev`, working tree understood: `drone @git status --all`
|
||||
- [ ] Confirm what's shipping this week — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok)
|
||||
- [ ] No surprise files (stray `/tmp` artifacts, test pollution, `.recovery`/`.archive` churn). Clean = archive, never delete.
|
||||
- [ ] Decide: **release tag this week?** (tag = PyPI publish + GitHub Release). If yes, note target version.
|
||||
|
||||
## 2. Verify, commit, CHANGELOG
|
||||
|
||||
- [ ] **Run the CI audit gate LOCALLY before pushing** (local == CI, S199 parity — catches red before the PR): `cd <repo-root> && .venv/bin/python .github/scripts/seedgo_audit.py` → expect all 13 branches `>=100%`, exit 0. Uses a relative `src/aipass` path, so run from the repo **root**, not a branch dir.
|
||||
- [ ] Update `CHANGELOG.md` — add entries under the current week's `[YYYY.WNN]` section (don't batch; mostly done as work landed). Sort into Added / Changed / Fixed.
|
||||
- [ ] Commit: `drone @git commit "msg" --all` (from a branch dir, e.g. devpulse). New/untracked files (e.g. new templates) — confirm they got staged: `git ls-files <path>` after; `--all` may not pick up untracked.
|
||||
- [ ] Every commit pushed — local-only commits are invisible
|
||||
|
||||
## 3. Open / update the PR
|
||||
|
||||
- [ ] `drone @git dev-pr "Week summary: what's shipping"`
|
||||
- [ ] "PR already open" in output = push succeeded onto the existing PR (expected on re-runs)
|
||||
- [ ] Record the PR number → Run Summary
|
||||
|
||||
## 4. Wait for CI green (ALL required checks)
|
||||
|
||||
The PR gate (verified against `.github/workflows/`):
|
||||
- [ ] `ci.yml` → **lint**, **test**, **standards** (= seedgo-audit / the README + 100%-floor check, runs `.github/scripts/seedgo_audit.py`), **coverage**
|
||||
- [ ] `security.yml` → Security Scan / dependency-scan
|
||||
- [ ] `e2e-wheel.yml` → 3-OS wheel smoke (path-filtered: fires on `src/**`, `tests/e2e/**`, `pyproject.toml`)
|
||||
- [ ] `windows-test.yml` / `macos-test.yml` → required checks, run on every PR (must NEVER be path-filtered or they park as "Expected/waiting" forever and block merge)
|
||||
- [ ] If "all green but can't merge": it's usually post-push mergeability **lag**. Confirm ground truth via the public API (no gh, no gate):
|
||||
- `curl -s https://api.github.com/repos/AIOSAI/AIPass/commits/<sha>/check-runs` → all check-runs success (incl. app checks: codecov, CodeQL)
|
||||
- `curl -s https://api.github.com/repos/AIOSAI/AIPass/pulls/<n>` → `mergeable_state: clean`
|
||||
|
||||
## 5. Merge to main
|
||||
|
||||
- [ ] **User's call to merge** — confirm GO
|
||||
- [ ] `drone @git merge <PR#>` (squash-merge)
|
||||
- [ ] ⚠️ **Verify `dev` SURVIVES the merge** (the #625 scar — empirical, every time): `drone @git branches` → `dev` still present; `git rev-parse dev` resolves
|
||||
|
||||
## 6. Post-merge realign
|
||||
|
||||
- [ ] Pull main locally: `drone @git sync`
|
||||
- [ ] If merged via GitHub UI (bypassing `drone @git merge`), fast-forward dev to main so dev doesn't fall behind / revert main-only commits (e.g. Dependabot): dev is an ancestor → `git merge --ff-only main` is clean (via `drone @git`)
|
||||
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
|
||||
|
||||
## 7. Release tag (only if cutting a release)
|
||||
|
||||
**Versioning rule — bump by SIGNIFICANCE, not cadence** (keeps the version from inflating weekly):
|
||||
- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only → the default, most weeks
|
||||
- **MINOR** (`x.Y+1.0`) = a new backward-compatible user-facing feature shipped
|
||||
- **MAJOR** (`X+1.0.0`) = breaking public-API change
|
||||
|
||||
(aipass is a 2.x library others pin → keep SemVer; the CHANGELOG keeps its `YYYY.WNN` header as a date index.)
|
||||
|
||||
How the release fires (verified `publish.yml`): a `v*` **git tag push** runs build → PyPI publish → GitHub Release. Key facts:
|
||||
- PyPI version = `pyproject.toml [project] version` at the tagged commit — **NOT** the tag string (the tag only *triggers* the build).
|
||||
- Tag and `pyproject` version **must match** (`v2.5.2` ⇄ `version = "2.5.2"`), or PyPI publishes the wrong number while the Release is named the tag.
|
||||
- PyPI **rejects a duplicate version** → if shipping, you MUST bump.
|
||||
- GitHub Release notes = the **topmost `## [...]` CHANGELOG block** (awk-extracted).
|
||||
|
||||
Steps:
|
||||
- [ ] Bump `pyproject.toml` version per the rule above, **on dev so it rides into the PR** (then main's merge commit carries the right version)
|
||||
- [ ] Confirm the CHANGELOG top section is the release notes you want
|
||||
- [ ] **Push the tag — MANUAL (drone has no `tag` verb):** Patrick, or raw `git tag v<version> <main-sha>` + `git push origin v<version>` via `!`, on the merged main commit
|
||||
- [ ] Verify PyPI shows the new version + the GitHub Release appeared
|
||||
- [ ] Record the tag → Run Summary
|
||||
|
||||
## 8. Wrap
|
||||
|
||||
- [ ] Update `.trinity/` memories (session log: what merged, PR#, tag)
|
||||
- [ ] Fill **Run Summary** below (PR numbers, tag, anything that broke)
|
||||
- [ ] Close this playbook → vectorizes the run
|
||||
|
||||
---
|
||||
|
||||
## Run Summary
|
||||
|
||||
- **Date:** {today}
|
||||
- **Outcome:** (merged clean / issues / no-merge)
|
||||
- **PR(s) merged:** #
|
||||
- **Release tag:** v
|
||||
- **CI notes:** (any flaky/red checks + how cleared)
|
||||
- **dev survived merge:** yes / no
|
||||
- **Issues hit:**
|
||||
- **Notes for next run:** (refine this SOP — what was missing or wrong?)
|
||||
|
||||
---
|
||||
|
||||
## Listen (TTS-friendly summary)
|
||||
|
||||
Write a plain English summary of this Sunday merge here when done. No markdown, no symbols,
|
||||
no tables, no code blocks, no asterisks, no bullet points. Just natural sentences for text to speech.
|
||||
|
||||
---
|
||||
|
||||
## Close Command
|
||||
|
||||
When all steps are ticked and the Run Summary is filled:
|
||||
```bash
|
||||
drone @flow close {plan_number}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ Every hook event flows through one engine. Platform bridges normalize the event
|
||||
| `drone @hooks hooksound` | Show current sound mute status |
|
||||
| `drone @hooks hooksound off` | Mute all hook sounds |
|
||||
| `drone @hooks hooksound on` | Unmute all hook sounds |
|
||||
| `drone @hooks cadence` | Show prompt injection cadence config and state |
|
||||
| `drone @hooks --help` | Full help reference |
|
||||
| `drone @hooks --version` | Version info |
|
||||
|
||||
@@ -47,9 +48,11 @@ src/aipass/hooks/
|
||||
│ ├── hooks.py # Entry point (drone @hooks)
|
||||
│ ├── sound.py # Shared sound utilities (speak, play, mute)
|
||||
│ ├── modules/
|
||||
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
|
||||
│ │ ├── engine.py # Core dispatch — routes events to handlers
|
||||
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
|
||||
│ │ └── hookstatus.py # Config viewer (drone @hooks status)
|
||||
│ │ ├── hookstatus.py # Config viewer (drone @hooks status)
|
||||
│ │ └── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator
|
||||
│ ├── handlers/
|
||||
│ │ ├── bridges/ # One per provider (thin normalization)
|
||||
│ │ │ └── claude.py # Claude Code bridge
|
||||
@@ -60,7 +63,7 @@ src/aipass/hooks/
|
||||
│ │ ├── security/ # Enforcement hooks
|
||||
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
||||
│ │ │ ├── git_gate.py # Enforces git access tiers
|
||||
│ │ │ ├── rm_gate.py # Blocks raw recursive rm, teaches drone rm
|
||||
│ │ │ ├── rm_gate.py # Guardrail — catches accidental rm -rf, teaches drone rm
|
||||
│ │ │ └── subagent_gate.py # Blocks sub-agent stop until clean
|
||||
│ │ ├── lifecycle/ # Session management hooks
|
||||
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
|
||||
@@ -77,7 +80,7 @@ src/aipass/hooks/
|
||||
│ └── diagnostics.py # JSONL logging for hook execution
|
||||
├── logs/
|
||||
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
|
||||
└── tests/ # 385 tests across 20 test files
|
||||
└── tests/ # 472 tests across 22 test files
|
||||
```
|
||||
|
||||
## How It Works
|
||||
@@ -99,13 +102,40 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
|
||||
| Event | Hooks | Description |
|
||||
|---|---|---|
|
||||
| UserPromptSubmit | identity, email, branch_loader, global_loader | Prompt injection + inbox check |
|
||||
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate | Security gates + sound |
|
||||
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate | Security gates + guardrails + sound |
|
||||
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
||||
| SubagentStop | subagent_gate | Seedgo validation |
|
||||
| Stop | stop_sound | Achievement bell |
|
||||
| Notification | announce | Announcement tone |
|
||||
| PreCompact | compact, rollover | Memory archival + rollover |
|
||||
|
||||
## Kernel Sandbox (srt/bwrap)
|
||||
|
||||
The sandbox module (`apps/modules/sandbox.py`) provides the kernel-level filesystem boundary for agent sessions. It wraps Anthropic's `@anthropic-ai/sandbox-runtime` (srt) library, which uses bubblewrap (bwrap) + Landlock + seccomp on Linux to enforce write/read restrictions at the OS level.
|
||||
|
||||
### Key Functions
|
||||
|
||||
| Function | What it does |
|
||||
|---|---|
|
||||
| `build_policy(branch_path)` | Generates per-role writable/RO map from branch passport |
|
||||
| `sandbox_launch(cmd, cwd, policy)` | Resolves bwrap command via srt, spawns sandboxed process |
|
||||
| `build_srt_config(policy)` | Converts policy dict to srt config format |
|
||||
|
||||
### Policy Rules
|
||||
|
||||
- **Every agent**: own branch tree + /tmp + shared channels (system_logs, .ai_central, memory_pool, AIPASS_REGISTRY.json, flow_json) + sibling mail/dashboard carve-ins + ~/.claude/projects/
|
||||
- **devpulse only**: .git writable (the only committer)
|
||||
- **All other agents**: .git read-only, sibling source trees read-only
|
||||
- **Deny**: broker_secret (deny_read + deny_write for all roles)
|
||||
|
||||
Bind-mount, not isolation: the sandbox preserves the shared live filesystem. Reads stay open everywhere. Only writes to protected paths are blocked at the kernel level (EROFS).
|
||||
|
||||
### Architecture
|
||||
|
||||
The Node helper (`_srt_resolve.mjs`) resolves the globally-installed srt library via `process.execPath` (ESM resolution doesn't walk to global node_modules). The resolver runs with CWD set to `/var/tmp` to prevent srt's mandatory-deny mask files from polluting the branch directory.
|
||||
|
||||
The @drone broker validates sandbox policy before agent launch. @ai_mail's dispatch_monitor wires `build_policy` + `sandbox_launch` at the launch seam.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
@@ -116,9 +146,10 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
|
||||
|
||||
### Provides To
|
||||
|
||||
All branches via hook dispatch. Every Claude Code session routes through the engine.
|
||||
- All branches via hook dispatch — every Claude Code session routes through the engine
|
||||
- @ai_mail dispatch_monitor — sandbox_launch + build_policy for agent launch boundary
|
||||
|
||||
*Last Updated: 2026-06-02*
|
||||
*Last Updated: 2026-06-10*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/lifecycle
|
||||
# Created: 2026-05-22
|
||||
# Modified: 2026-05-22
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Runs diagnostics on edited files and surfaces errors for the agent to fix."""
|
||||
@@ -16,7 +16,6 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
||||
@@ -72,15 +71,18 @@ def _check_syntax(file_path: str) -> list[str]:
|
||||
def _check_ruff_lint(file_path: str) -> list[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
|
||||
[sys.executable, "-m", "ruff", "check", "--select=E,F,W", "--output-format=concise", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode not in (0, 1):
|
||||
logger.info("[HOOKS] auto_fix: ruff lint error: %s", result.stderr.strip())
|
||||
return []
|
||||
if result.stdout.strip():
|
||||
return [f"LINT: {line}" for line in result.stdout.strip().split("\n")[:5]]
|
||||
except FileNotFoundError:
|
||||
logger.info("[HOOKS] auto_fix: ruff not found")
|
||||
lines = result.stdout.strip().split("\n")
|
||||
violations = [line for line in lines if ".py:" in line]
|
||||
return [f"LINT: {line}" for line in violations[:5]]
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: ruff lint failed: %s", exc)
|
||||
return []
|
||||
@@ -89,16 +91,16 @@ def _check_ruff_lint(file_path: str) -> list[str]:
|
||||
def _check_ruff_format(file_path: str) -> list[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "format", "--check", file_path],
|
||||
[sys.executable, "-m", "ruff", "format", "--check", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if result.returncode == 1:
|
||||
name = Path(file_path).name
|
||||
return [f"FORMAT: {name} needs ruff format (run: ruff format {name})"]
|
||||
except FileNotFoundError:
|
||||
logger.info("[HOOKS] auto_fix: ruff not found")
|
||||
if result.returncode not in (0, 1):
|
||||
logger.info("[HOOKS] auto_fix: ruff format error: %s", result.stderr.strip())
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] auto_fix: ruff format check failed: %s", exc)
|
||||
return []
|
||||
@@ -152,11 +154,14 @@ def _run_ruff_lint_structured(file_path: str) -> list[dict]:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
|
||||
[sys.executable, "-m", "ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode not in (0, 1):
|
||||
logger.info("[HOOKS] auto_fix: ruff structured lint error: %s", result.stderr.strip())
|
||||
return []
|
||||
if not result.stdout.strip():
|
||||
return []
|
||||
violations = json.loads(result.stdout)
|
||||
@@ -169,8 +174,6 @@ def _run_ruff_lint_structured(file_path: str) -> list[dict]:
|
||||
message = v.get("message", "unknown")[:100]
|
||||
errors.append({"line": line, "message": f"{code}: {message}"})
|
||||
return errors
|
||||
except FileNotFoundError:
|
||||
logger.info("[HOOKS] auto_fix: ruff not found for structured lint")
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.info("[HOOKS] auto_fix: ruff JSON parse failed: %s", exc)
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -321,8 +324,6 @@ def handle(hook_data: dict) -> dict:
|
||||
if ext in SKIP_EXTENSIONS:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
speak("auto fix diagnostics")
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
if file_path.endswith(".py"):
|
||||
@@ -358,7 +359,7 @@ def handle(hook_data: dict) -> dict:
|
||||
},
|
||||
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing",
|
||||
}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0, "sound": "auto fix diagnostics"}
|
||||
|
||||
result = {"systemMessage": "[diagnostics] ok"}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0}
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
import json
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Return additionalContext reminder if dispatch detected without watchdog.
|
||||
@@ -39,8 +37,6 @@ def handle(hook_data: dict) -> dict:
|
||||
if "dispatch wake" in command and "dispatch @" not in command:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
speak("auto watchdog")
|
||||
|
||||
result = {
|
||||
"additionalContext": (
|
||||
"[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW. "
|
||||
@@ -48,4 +44,4 @@ def handle(hook_data: dict) -> dict:
|
||||
"run_in_background: true and timeout: 600000."
|
||||
)
|
||||
}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0, "sound": "auto watchdog"}
|
||||
|
||||
@@ -15,7 +15,6 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -82,7 +81,13 @@ def _get_git_info() -> str | None:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Inject live branch state for post-compact recovery."""
|
||||
speak("pre compact")
|
||||
try:
|
||||
import importlib
|
||||
|
||||
cadence = importlib.import_module("aipass.hooks.apps.modules.cadence")
|
||||
cadence.reset_counter()
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] compact: cadence reset failed: %s", exc)
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
||||
@@ -122,7 +127,7 @@ def handle(hook_data: dict) -> dict:
|
||||
"- Match the conversation tone from before compaction"
|
||||
)
|
||||
|
||||
return {"stdout": "\n\n".join(sections), "exit_code": 0}
|
||||
return {"stdout": "\n\n".join(sections), "exit_code": 0, "sound": "pre compact"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] compact: unexpected error: %s", exc)
|
||||
|
||||
@@ -15,7 +15,6 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -129,10 +128,8 @@ def _run_rollover(repo_root: Path) -> tuple[bool, str]:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
def handle(hook_data: dict) -> dict: # noqa: ARG001
|
||||
"""Check memory files for overflow and trigger rollover if needed."""
|
||||
speak("pre compact rollover")
|
||||
|
||||
try:
|
||||
repo_root = _find_repo_root()
|
||||
if not repo_root:
|
||||
@@ -151,7 +148,7 @@ def handle(hook_data: dict) -> dict:
|
||||
else:
|
||||
logger.info("[HOOKS] rollover: failed — %s", output[:200])
|
||||
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
return {"stdout": "", "exit_code": 0, "sound": "pre compact rollover"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] rollover: unexpected error: %s", exc)
|
||||
|
||||
@@ -13,14 +13,12 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
|
||||
AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", ""))
|
||||
SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds"
|
||||
SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav"
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
def handle(hook_data: dict) -> dict: # noqa: ARG001
|
||||
"""Play notification tone and speak hook name for identification.
|
||||
|
||||
Args:
|
||||
@@ -29,5 +27,4 @@ def handle(hook_data: dict) -> dict:
|
||||
Returns:
|
||||
Result dict with stdout (empty) and exit_code.
|
||||
"""
|
||||
speak("notification sound")
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
return {"stdout": "", "exit_code": 0, "sound": "notification sound"}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -97,7 +96,13 @@ def handle(hook_data: dict) -> dict:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
plural = "s" if new_count != 1 else ""
|
||||
speak(f"email notification: {new_count} new email{plural}")
|
||||
msg = f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>"
|
||||
msg = (
|
||||
f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox"
|
||||
" | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>"
|
||||
)
|
||||
logger.info("[HOOKS] email: %d new email%s", new_count, plural)
|
||||
return {"stdout": msg, "exit_code": 0}
|
||||
return {
|
||||
"stdout": msg,
|
||||
"exit_code": 0,
|
||||
"sound": f"email notification: {new_count} new email{plural}",
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
|
||||
AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", ""))
|
||||
SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds"
|
||||
SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav"
|
||||
@@ -32,5 +30,4 @@ def handle(hook_data: dict) -> dict:
|
||||
if hook_data.get("stop_hook_active", False):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
speak("stop sound")
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
return {"stdout": "", "exit_code": 0, "sound": "stop sound"}
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
"""Announces hook name via Piper TTS when the AI uses tools (PreToolUse event)."""
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Announce hook name for matching tool use events.
|
||||
@@ -26,5 +24,4 @@ def handle(hook_data: dict) -> dict:
|
||||
if not tool_name:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
speak(f"tool sound: {tool_name}")
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
return {"stdout": "", "exit_code": 0, "sound": f"tool sound: {tool_name}"}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -30,7 +29,14 @@ def _find_branch_root(cwd: str) -> Path | None:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Load branch prompt and private integration prompts."""
|
||||
speak("branch prompt")
|
||||
try:
|
||||
import importlib
|
||||
|
||||
cadence = importlib.import_module("aipass.hooks.apps.modules.cadence")
|
||||
if not cadence.should_fire("branch", hook_data):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] branch_loader: cadence check failed, firing anyway: %s", exc)
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
||||
@@ -54,7 +60,7 @@ def handle(hook_data: dict) -> dict:
|
||||
if not parts:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
return {"stdout": "\n".join(parts), "exit_code": 0}
|
||||
return {"stdout": "\n".join(parts), "exit_code": 0, "sound": "branch prompt"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] branch_loader: unexpected error: %s", exc)
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -31,7 +30,14 @@ def _find_project_prompt() -> Path | None:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Load global prompt — project-local if outside AIPass, AIPass-internal if inside."""
|
||||
speak("global prompt")
|
||||
try:
|
||||
import importlib
|
||||
|
||||
cadence = importlib.import_module("aipass.hooks.apps.modules.cadence")
|
||||
if not cadence.should_fire("global", hook_data):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] global_loader: cadence check failed, firing anyway: %s", exc)
|
||||
|
||||
try:
|
||||
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||
@@ -46,7 +52,7 @@ def handle(hook_data: dict) -> dict:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
content = prompt_file.read_text(encoding="utf-8")
|
||||
return {"stdout": content, "exit_code": 0}
|
||||
return {"stdout": content, "exit_code": 0, "sound": "global prompt"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] global_loader: unexpected error: %s", exc)
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -69,8 +68,6 @@ def _format_identity(data: dict) -> str:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Inject branch identity from passport.json into prompt context."""
|
||||
speak("identity")
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
||||
passport = _find_passport(cwd)
|
||||
@@ -82,7 +79,7 @@ def handle(hook_data: dict) -> dict:
|
||||
if not output:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
return {"stdout": f"\n{output}", "exit_code": 0}
|
||||
return {"stdout": f"\n{output}", "exit_code": 0, "sound": "identity"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] identity: unexpected error: %s", exc)
|
||||
|
||||
@@ -14,7 +14,6 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -50,8 +49,6 @@ def handle(hook_data: dict) -> dict:
|
||||
Returns:
|
||||
Result dict with stdout (block JSON or empty) and exit_code.
|
||||
"""
|
||||
speak("edit gate")
|
||||
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
tool_input = hook_data.get("tool_input", {})
|
||||
@@ -66,7 +63,7 @@ def handle(hook_data: dict) -> dict:
|
||||
fp = Path(file_path)
|
||||
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
|
||||
reason = 'Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"'
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "edit gate"}
|
||||
|
||||
cwd = hook_data.get("cwd", "") or os.getcwd()
|
||||
package = _get_package_from_cwd(cwd)
|
||||
@@ -80,7 +77,11 @@ def handle(hook_data: dict) -> dict:
|
||||
f"Dispatched agent confined to own branch: '{cwd_branch}' "
|
||||
f"cannot write to '{target_branch}' in daemon mode."
|
||||
)
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {
|
||||
"stdout": json.dumps({"decision": "block", "reason": reason}),
|
||||
"exit_code": 2,
|
||||
"sound": "edit gate",
|
||||
}
|
||||
repo_root = None
|
||||
for parent in Path(cwd).parents:
|
||||
if (parent / ".git").exists():
|
||||
@@ -91,7 +92,11 @@ def handle(hook_data: dict) -> dict:
|
||||
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
|
||||
if not resolved.startswith(allowed_prefix):
|
||||
reason = f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}"
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {
|
||||
"stdout": json.dumps({"decision": "block", "reason": reason}),
|
||||
"exit_code": 2,
|
||||
"sound": "edit gate",
|
||||
}
|
||||
|
||||
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp), package)
|
||||
|
||||
@@ -101,7 +106,11 @@ def handle(hook_data: dict) -> dict:
|
||||
f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n"
|
||||
f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}"
|
||||
)
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {
|
||||
"stdout": json.dumps({"decision": "block", "reason": reason}),
|
||||
"exit_code": 2,
|
||||
"sound": "edit gate",
|
||||
}
|
||||
|
||||
if not file_path.endswith(".py"):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -140,7 +149,11 @@ def handle(hook_data: dict) -> dict:
|
||||
|
||||
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
|
||||
reason = f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}"
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {
|
||||
"stdout": json.dumps({"decision": "block", "reason": reason}),
|
||||
"exit_code": 2,
|
||||
"sound": "edit gate",
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] edit_gate: unexpected error (allowing): %s", exc)
|
||||
|
||||
@@ -15,7 +15,6 @@ import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -133,7 +132,7 @@ def _all_git_reads(scan: str) -> bool:
|
||||
|
||||
|
||||
def _block(reason: str) -> dict:
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "git gate"}
|
||||
|
||||
|
||||
def _check_bash(tool_input: dict) -> dict:
|
||||
@@ -170,8 +169,6 @@ def handle(hook_data: dict) -> dict:
|
||||
Returns:
|
||||
Result dict with stdout (block JSON or empty) and exit_code.
|
||||
"""
|
||||
speak("git gate")
|
||||
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
tool_input = hook_data.get("tool_input", {})
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: rm_gate.py
|
||||
# Version: 1.0.0
|
||||
# Description: Blocks raw recursive rm commands (PreToolUse)
|
||||
# Description: Guardrail — catches accidental rm -rf and teaches drone rm (PreToolUse)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/security
|
||||
# Created: 2026-06-02
|
||||
# Modified: 2026-06-02
|
||||
# =============================================
|
||||
|
||||
"""Blocks raw recursive rm and teaches drone rm."""
|
||||
"""Early-feedback guardrail — catches accidental recursive rm and teaches drone rm.
|
||||
|
||||
Belt-and-suspenders: the actual filesystem boundary is the kernel sandbox
|
||||
(srt/bwrap) enforced at agent launch. This hook provides fast, helpful feedback
|
||||
before the sandbox would block the operation at the kernel level.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
RM_REDIRECT = (
|
||||
"Raw recursive rm is blocked. Use the safe contained delete instead:\n"
|
||||
" drone rm <path> # safe delete (allows project + /tmp, refuses outside)\n"
|
||||
"Heads up — raw recursive rm is not the right tool here. Use:\n"
|
||||
" drone rm <path> # project-aware delete (allows project + /tmp, refuses outside)\n"
|
||||
"\n"
|
||||
"This applies to all recursive rm variants (rm -rf, rm -r, rm -R, rm --recursive)."
|
||||
"This guardrail catches rm -rf, rm -r, rm -R, and rm --recursive."
|
||||
)
|
||||
|
||||
_BLOCK_ALLOW = {"stdout": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _block(reason: str) -> dict:
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "rm gate"}
|
||||
|
||||
|
||||
def _strip_quotes(cmd: str) -> str:
|
||||
@@ -84,8 +88,6 @@ def handle(hook_data: dict) -> dict:
|
||||
Returns:
|
||||
Result dict with stdout (block JSON or empty) and exit_code.
|
||||
"""
|
||||
speak("rm gate")
|
||||
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
if tool_name != "Bash":
|
||||
|
||||
@@ -15,14 +15,13 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
_ALLOW = {"stdout": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _block(reason: str) -> dict:
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "subagent gate"}
|
||||
|
||||
|
||||
def _get_package_from_cwd(cwd: str) -> str:
|
||||
@@ -152,8 +151,6 @@ def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Check modified files against seedgo standards on subagent stop."""
|
||||
speak("subagent stop gate")
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or os.getcwd()
|
||||
repo_root = _find_repo_root(cwd)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// _srt_resolve.mjs — Resolves bwrap command via @anthropic-ai/sandbox-runtime library.
|
||||
// Called by sandbox.py. Reads config JSON from file (argv[1]), command string (argv[2]).
|
||||
// Prints the shell-quoted bwrap command to stdout. Exits 0 on success, 1 on error.
|
||||
//
|
||||
// srt is installed globally (npm i -g). ESM resolution walks up from this file's
|
||||
// directory, never reaching the global node_modules. We derive the path from the
|
||||
// running Node binary instead.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const nodePrefix = dirname(dirname(process.execPath));
|
||||
const srtEntry = join(nodePrefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js');
|
||||
const { SandboxManager } = await import(pathToFileURL(srtEntry).href);
|
||||
|
||||
const configPath = process.argv[2];
|
||||
const command = process.argv[3];
|
||||
|
||||
if (!configPath || !command) {
|
||||
process.stderr.write('usage: _srt_resolve.mjs <config.json> <command>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
await SandboxManager.initialize(config);
|
||||
const wrapped = await SandboxManager.wrapWithSandbox(command, '/bin/bash', config);
|
||||
process.stdout.write(wrapped);
|
||||
await SandboxManager.reset();
|
||||
} catch (err) {
|
||||
process.stderr.write(`srt-resolve error: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: cadence.py
|
||||
# Version: 2.0.0
|
||||
# Description: Per-session turn counter for prompt injection cadence (DPLAN-0200)
|
||||
# Branch: hooks
|
||||
# Layer: apps/modules
|
||||
# Created: 2026-06-08
|
||||
# Modified: 2026-06-08
|
||||
# =============================================
|
||||
|
||||
"""Turn counter for prompt injection cadence — fires loaders every Nth turn.
|
||||
|
||||
Multi-process safe: each UserPromptSubmit hook runs as a separate OS process.
|
||||
Uses fcntl.flock + mtime debounce + per-turn token to ensure the counter
|
||||
advances exactly once per real user turn.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
fcntl = None # type: ignore[assignment]
|
||||
logger.info("[HOOKS] cadence: fcntl unavailable (Windows)")
|
||||
|
||||
CONSOLE = err_console
|
||||
|
||||
_GUARD_DIR = Path("/tmp")
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_CONFIG_PATH = _BRANCH_ROOT / "hooks_json" / "custom_config" / "cadence_config.json"
|
||||
_DEBOUNCE_S = 2.0
|
||||
|
||||
HELP_COMMANDS = [
|
||||
("cadence", "Show prompt injection cadence config and state"),
|
||||
]
|
||||
|
||||
DEFAULTS = {
|
||||
"enabled": True,
|
||||
"period": 5,
|
||||
"loaders": {
|
||||
"global": {"offset": 0},
|
||||
"branch": {"offset": 0},
|
||||
},
|
||||
}
|
||||
|
||||
_turn: int | None = None
|
||||
_config: dict | None = None
|
||||
|
||||
|
||||
def _deep_merge(base: dict, updates: dict) -> dict:
|
||||
"""Deep merge updates into base (modifies base in-place)."""
|
||||
for key, value in updates.items():
|
||||
if isinstance(value, dict) and key in base and isinstance(base[key], dict):
|
||||
_deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
global _config
|
||||
if _config is not None:
|
||||
return _config
|
||||
|
||||
import copy
|
||||
|
||||
result = copy.deepcopy(DEFAULTS)
|
||||
|
||||
if _CONFIG_PATH.is_file():
|
||||
try:
|
||||
overrides = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
_deep_merge(result, overrides)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("[HOOKS] cadence: config load failed, using defaults: %s", exc)
|
||||
|
||||
_config = result
|
||||
return result
|
||||
|
||||
|
||||
def _state_path() -> Path | None:
|
||||
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
|
||||
if not session_id:
|
||||
return None
|
||||
return _GUARD_DIR / f"aipass-cadence-{session_id}.json"
|
||||
|
||||
|
||||
def _get_turn_token(hook_data: dict) -> int:
|
||||
"""Per-turn token from transcript_path size (monotonic, identical across siblings)."""
|
||||
tp = hook_data.get("transcript_path", "")
|
||||
if not tp:
|
||||
return 0
|
||||
try:
|
||||
return os.path.getsize(tp)
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: transcript stat failed: %s", exc)
|
||||
return 0
|
||||
|
||||
|
||||
def _lock(fd) -> None:
|
||||
"""Acquire exclusive lock (no-op on Windows)."""
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
|
||||
|
||||
def _unlock(fd) -> None:
|
||||
"""Release exclusive lock (no-op on Windows)."""
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _close_fd(fd) -> None:
|
||||
"""Unlock and close a file descriptor safely."""
|
||||
try:
|
||||
_unlock(fd)
|
||||
fd.close()
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: fd cleanup failed: %s", exc)
|
||||
|
||||
|
||||
def _mtime_age(fd) -> float:
|
||||
"""Seconds since file was last modified, via the open fd."""
|
||||
try:
|
||||
return time.time() - os.fstat(fd.fileno()).st_mtime
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: fstat failed, assuming stale: %s", exc)
|
||||
return _DEBOUNCE_S + 1
|
||||
|
||||
|
||||
def _should_increment(stored_turn: int, stored_token: int, token: int, fd) -> bool:
|
||||
"""Decide whether to increment the counter. Extracted for nesting depth."""
|
||||
if stored_turn < 0:
|
||||
return True
|
||||
if _mtime_age(fd) < _DEBOUNCE_S:
|
||||
return False
|
||||
if token == stored_token and token != 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _load_and_increment(hook_data: dict) -> int:
|
||||
"""Load turn counter, increment exactly once per real turn. Multi-process safe."""
|
||||
global _turn
|
||||
if _turn is not None:
|
||||
return _turn
|
||||
|
||||
path = _state_path()
|
||||
if path is None:
|
||||
_turn = 0
|
||||
return 0
|
||||
|
||||
token = _get_turn_token(hook_data)
|
||||
fd = None
|
||||
|
||||
try:
|
||||
fd = open(path, "a+") # noqa: SIM115
|
||||
_lock(fd)
|
||||
fd.seek(0)
|
||||
content = fd.read()
|
||||
|
||||
data = json.loads(content) if content.strip() else {}
|
||||
stored_turn = data.get("turn", -1)
|
||||
stored_token = data.get("token", -1)
|
||||
|
||||
if _should_increment(stored_turn, stored_token, token, fd):
|
||||
new_turn = max(stored_turn + 1, 0)
|
||||
fd.seek(0)
|
||||
fd.truncate()
|
||||
fd.write(json.dumps({"turn": new_turn, "token": token}))
|
||||
fd.flush()
|
||||
else:
|
||||
new_turn = stored_turn
|
||||
|
||||
_close_fd(fd)
|
||||
fd = None
|
||||
_turn = new_turn
|
||||
return new_turn
|
||||
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.info("[HOOKS] cadence: state access failed: %s", exc)
|
||||
if fd is not None:
|
||||
_close_fd(fd)
|
||||
_turn = 0
|
||||
return 0
|
||||
|
||||
|
||||
def should_fire(loader_name: str, hook_data: dict | None = None) -> bool:
|
||||
"""Check if a loader should fire this turn. Always True on turn 0 or if cadence disabled."""
|
||||
config = _load_config()
|
||||
|
||||
if not config.get("enabled", True):
|
||||
return True
|
||||
|
||||
period = config.get("period", 5)
|
||||
if period <= 0:
|
||||
return True
|
||||
|
||||
loader_config = config.get("loaders", {}).get(loader_name, {})
|
||||
offset = loader_config.get("offset", 0)
|
||||
|
||||
turn = _load_and_increment(hook_data or {})
|
||||
|
||||
fired = turn == 0 or (turn % period) == offset
|
||||
|
||||
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
|
||||
session_short = session_id[:8] if session_id else "none"
|
||||
action = "fired" if fired else "skipped"
|
||||
logger.info(
|
||||
"[HOOKS] cadence %s loader=%s turn=%d period=%d offset=%d session=%s",
|
||||
action,
|
||||
loader_name,
|
||||
turn,
|
||||
period,
|
||||
offset,
|
||||
session_short,
|
||||
)
|
||||
|
||||
return fired
|
||||
|
||||
|
||||
def reset_counter() -> None:
|
||||
"""Reset counter to -1 so next turn reads 0 (all loaders fire). Called from PreCompact."""
|
||||
path = _state_path()
|
||||
if path is None:
|
||||
return
|
||||
|
||||
fd = None
|
||||
try:
|
||||
fd = open(path, "a+") # noqa: SIM115
|
||||
_lock(fd)
|
||||
fd.seek(0)
|
||||
fd.truncate()
|
||||
fd.write(json.dumps({"turn": -1, "token": -1}))
|
||||
fd.flush()
|
||||
_close_fd(fd)
|
||||
fd = None
|
||||
logger.info("[HOOKS] cadence: counter reset for post-compact re-injection")
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: reset write failed: %s", exc)
|
||||
if fd is not None:
|
||||
_close_fd(fd)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE INTERFACE (drone @hooks routing)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print cadence config and current state."""
|
||||
config = _load_config()
|
||||
CONSOLE.print("[bold cyan]cadence[/bold cyan] Module")
|
||||
CONSOLE.print(f" Enabled: {config.get('enabled', True)}")
|
||||
CONSOLE.print(f" Period: {config.get('period', 5)} turns")
|
||||
loaders = config.get("loaders", {})
|
||||
for name, lcfg in loaders.items():
|
||||
CONSOLE.print(f" Loader '{name}': offset={lcfg.get('offset', 0)}")
|
||||
path = _state_path()
|
||||
if path and path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
CONSOLE.print(f" Current turn: {data.get('turn', '?')}")
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("[HOOKS] cadence: state read for introspection failed: %s", exc)
|
||||
CONSOLE.print(" Current turn: (unreadable)")
|
||||
else:
|
||||
CONSOLE.print(" Current turn: (no state file)")
|
||||
CONSOLE.print(f" Config file: {_CONFIG_PATH}")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Route cadence commands from drone @hooks."""
|
||||
if command in ("--help", "-h", "help"):
|
||||
CONSOLE.print("[bold cyan]cadence[/bold cyan] — Prompt injection cadence control")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" drone @hooks cadence Show cadence config and current turn state")
|
||||
return True
|
||||
|
||||
if command == "cadence":
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
return False
|
||||
@@ -72,6 +72,7 @@ def _run_handler(handler_path: str, hook_data: dict) -> dict:
|
||||
return {
|
||||
"exit_code": result.get("exit_code", 0),
|
||||
"stdout": result.get("stdout", ""),
|
||||
"sound": result.get("sound", ""),
|
||||
"stderr": "",
|
||||
"elapsed_ms": round(elapsed_ms, 1),
|
||||
}
|
||||
@@ -166,6 +167,14 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str:
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("sound"):
|
||||
try:
|
||||
from aipass.hooks.apps.sound import speak
|
||||
|
||||
speak(result["sound"])
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] sound playback failed for %s.%s: %s", event_type, hook_name, exc)
|
||||
|
||||
# Exit code 2: crash vs intentional block
|
||||
if result["exit_code"] == 2:
|
||||
is_intentional_block = False
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: sandbox.py
|
||||
# Version: 1.0.0
|
||||
# Description: Sandbox wrapper — launches commands inside srt (kernel FS boundary)
|
||||
# Branch: hooks
|
||||
# Layer: apps/modules
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Sandbox wrapper — launches commands inside srt kernel filesystem boundary.
|
||||
|
||||
Accepts a policy (writable/RO path map) + command + cwd + env, resolves the
|
||||
bwrap command via @anthropic-ai/sandbox-runtime, and spawns inside the sandbox.
|
||||
Phase 1 of FPLAN-0250 / DPLAN-0202.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
CONSOLE = err_console
|
||||
|
||||
_MODULE_DIR = Path(__file__).resolve().parent
|
||||
_SRT_RESOLVE = _MODULE_DIR / "_srt_resolve.mjs"
|
||||
|
||||
_VAR_TMP = Path("/var/tmp")
|
||||
|
||||
|
||||
def _srt_resolve_cwd() -> str:
|
||||
"""Return a CWD for the srt resolver that is outside any allow_write path.
|
||||
|
||||
srt auto-denies DANGEROUS_FILES (.bashrc, .gitconfig, …) resolved relative
|
||||
to process.cwd(). When the deny target doesn't exist and its ancestor IS in
|
||||
allow_write, bwrap creates 0-byte mount-point files that persist after exit.
|
||||
Running the resolver from /var/tmp (never in allow_write) makes srt skip
|
||||
those entries entirely — no bwrap args, no mount points, no pollution.
|
||||
"""
|
||||
if _VAR_TMP.is_dir():
|
||||
return str(_VAR_TMP)
|
||||
return tempfile.gettempdir()
|
||||
|
||||
|
||||
HELP_COMMANDS = [
|
||||
("sandbox", "Launch a command inside the kernel sandbox"),
|
||||
]
|
||||
|
||||
|
||||
def _find_node() -> str:
|
||||
node = shutil.which("node")
|
||||
if node:
|
||||
return node
|
||||
msg = "node not found in PATH — required for srt sandbox"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def _find_rg() -> str:
|
||||
rg = shutil.which("rg")
|
||||
if rg:
|
||||
return rg
|
||||
fallback = Path.home() / ".local" / "bin" / "rg"
|
||||
if fallback.is_file():
|
||||
return str(fallback)
|
||||
msg = "ripgrep (rg) not found — required by srt for mandatory-deny scan"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def _find_repo_root(branch_path: Path) -> Path:
|
||||
"""Walk up from branch_path to find the repo root (contains .git)."""
|
||||
current = branch_path.resolve()
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
msg = f"No .git found above {branch_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def _is_devpulse(branch_path: Path) -> bool:
|
||||
"""Check if a branch is devpulse (the only committer) via passport."""
|
||||
passport = branch_path / ".trinity" / "passport.json"
|
||||
if passport.is_file():
|
||||
try:
|
||||
data = json.loads(passport.read_text(encoding="utf-8"))
|
||||
return data.get("branch_info", {}).get("branch_name") == "devpulse"
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("sandbox: failed to read passport for %s: %s", branch_path.name, exc)
|
||||
return branch_path.name == "devpulse"
|
||||
|
||||
|
||||
def _claude_project_dir(branch_path: Path) -> Path:
|
||||
"""Derive the ~/.claude/projects/ directory for a branch."""
|
||||
encoded = str(branch_path.resolve()).replace("/", "-")
|
||||
return Path.home() / ".claude" / "projects" / encoded
|
||||
|
||||
|
||||
def _find_src_aipass(repo_root: Path) -> Path:
|
||||
"""Locate the src/aipass/ directory within the repo."""
|
||||
return repo_root / "src" / "aipass"
|
||||
|
||||
|
||||
def build_policy(branch_path: str | Path) -> dict:
|
||||
"""Generate sandbox policy for a branch agent.
|
||||
|
||||
Returns a policy dict compatible with sandbox_launch / build_srt_config:
|
||||
allow_write: list of writable paths
|
||||
deny_write: broker secret only (it sits inside the writable .ai_central)
|
||||
deny_read: broker secret only — agents must never read it, or a
|
||||
path-connected broker client could forge a devpulse identity.
|
||||
Everything else stays readable (shared live filesystem).
|
||||
"""
|
||||
branch_path = Path(branch_path).resolve()
|
||||
repo_root = _find_repo_root(branch_path)
|
||||
src_aipass = _find_src_aipass(repo_root)
|
||||
branch_name = branch_path.name
|
||||
is_dp = _is_devpulse(branch_path)
|
||||
|
||||
allow_write: list[str] = []
|
||||
|
||||
allow_write.append(str(branch_path))
|
||||
|
||||
allow_write.append("/tmp")
|
||||
tmpdir = os.environ.get("TMPDIR")
|
||||
if tmpdir and tmpdir != "/tmp":
|
||||
allow_write.append(tmpdir)
|
||||
|
||||
allow_write.extend(
|
||||
[
|
||||
str(repo_root / "system_logs"),
|
||||
str(repo_root / ".ai_central"),
|
||||
str(src_aipass / "memory" / "memory_pool"),
|
||||
str(repo_root / "AIPASS_REGISTRY.json"),
|
||||
str(src_aipass / "flow" / "flow_json"),
|
||||
]
|
||||
)
|
||||
|
||||
for sibling in sorted(src_aipass.iterdir()):
|
||||
if not sibling.is_dir():
|
||||
continue
|
||||
if sibling.name == branch_name or sibling.name.startswith("_"):
|
||||
continue
|
||||
mail_dir = sibling / ".ai_mail.local"
|
||||
if mail_dir.is_dir():
|
||||
allow_write.append(str(mail_dir))
|
||||
dashboard = sibling / "DASHBOARD.local.json"
|
||||
if dashboard.is_file():
|
||||
allow_write.append(str(dashboard))
|
||||
|
||||
if is_dp:
|
||||
allow_write.append(str(repo_root / ".git"))
|
||||
|
||||
claude_proj = _claude_project_dir(branch_path)
|
||||
if claude_proj.is_dir():
|
||||
allow_write.append(str(claude_proj))
|
||||
|
||||
broker_secret = repo_root / ".ai_central" / "broker_secret"
|
||||
return {
|
||||
"allow_write": allow_write,
|
||||
"deny_write": [str(broker_secret)],
|
||||
"deny_read": [str(broker_secret)],
|
||||
}
|
||||
|
||||
|
||||
def build_srt_config(policy: dict) -> dict:
|
||||
"""Convert a policy dict to srt config format.
|
||||
|
||||
Policy keys:
|
||||
allow_write: list[str] — paths the sandboxed process may write to
|
||||
deny_write: list[str] — paths to deny write within writable (optional)
|
||||
deny_read: list[str] — paths to deny read (optional)
|
||||
"""
|
||||
return {
|
||||
"network": {
|
||||
"allowAllUnixSockets": True,
|
||||
},
|
||||
"filesystem": {
|
||||
"denyRead": [str(p) for p in policy.get("deny_read", [])],
|
||||
"allowWrite": [str(p) for p in policy["allow_write"]],
|
||||
"denyWrite": [str(p) for p in policy.get("deny_write", [])],
|
||||
},
|
||||
"ripgrep": {
|
||||
"command": _find_rg(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def resolve_bwrap_command(command: str, srt_config: dict) -> str:
|
||||
"""Call the Node.js srt resolver to get the bwrap shell command."""
|
||||
node = _find_node()
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".json",
|
||||
prefix="srt-config-",
|
||||
delete=False,
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
json.dump(srt_config, f)
|
||||
config_path = f.name
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[node, str(_SRT_RESOLVE), config_path, command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
cwd=_srt_resolve_cwd(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
msg = f"srt resolve failed (exit {result.returncode}): {stderr}"
|
||||
raise RuntimeError(msg)
|
||||
wrapped = result.stdout.strip()
|
||||
if not wrapped:
|
||||
msg = "srt resolve returned empty command"
|
||||
raise RuntimeError(msg)
|
||||
return wrapped
|
||||
finally:
|
||||
Path(config_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def sandbox_launch(
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | Path | None = None,
|
||||
policy: dict,
|
||||
env: dict | None = None,
|
||||
) -> subprocess.Popen:
|
||||
"""Launch a command inside the srt kernel sandbox.
|
||||
|
||||
Args:
|
||||
command: Shell command string to run inside the sandbox.
|
||||
cwd: Working directory for the sandboxed process.
|
||||
policy: Dict with allow_write (required), deny_write, deny_read (optional).
|
||||
env: Environment variables (defaults to current env).
|
||||
|
||||
Returns:
|
||||
subprocess.Popen handle for the sandboxed process.
|
||||
"""
|
||||
srt_config = build_srt_config(policy)
|
||||
bwrap_cmd = resolve_bwrap_command(command, srt_config)
|
||||
|
||||
logger.info("sandbox_launch: wrapping command in srt sandbox")
|
||||
|
||||
launch_env = env if env is not None else dict(os.environ)
|
||||
|
||||
return subprocess.Popen(
|
||||
["/bin/bash", "-c", bwrap_cmd],
|
||||
cwd=str(cwd) if cwd else None,
|
||||
env=launch_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print module structure for drone routing."""
|
||||
CONSOLE.print("[bold cyan]sandbox[/bold cyan] — Kernel filesystem boundary via srt")
|
||||
CONSOLE.print(" Phase 1: wrapper module only (not yet wired into dispatch)")
|
||||
CONSOLE.print(" Use sandbox_launch() programmatically.")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Route sandbox commands from drone @hooks."""
|
||||
if command == "sandbox":
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
sub = args[0]
|
||||
if sub in ("--help", "-h", "help"):
|
||||
CONSOLE.print("[bold cyan]sandbox[/bold cyan] — Kernel filesystem boundary via srt")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" drone @hooks sandbox Show sandbox module status")
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,54 @@
|
||||
# Cadence Investigation — DPLAN-0200
|
||||
|
||||
Per-turn injection cadence mechanism for prompt loaders (global_loader, branch_loader).
|
||||
Investigation + build. Updated post-REDO to reflect the real execution model.
|
||||
|
||||
---
|
||||
|
||||
## 1. Per-session turn counter — session keying
|
||||
|
||||
`CLAUDE_CODE_SESSION_ID` is available as an env var to every hook invocation (UUID format, stable across turns, unique per session). Counter file keyed by session_id at `/tmp/aipass-cadence-{session_id}.json`.
|
||||
|
||||
UserPromptSubmit stdin fields: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `prompt`. The field is `prompt` (not `user_prompt`); `session_id` IS present in hook_data.
|
||||
|
||||
## 2. Execution model — SEPARATE PROCESSES (corrected)
|
||||
|
||||
**Each hook runs as a separate OS process.** `settings.json` registers distinct commands per handler: `claude.py UserPromptSubmit:global_prompt`, `:branch_prompt`, `:identity_injector`, `:email_notification`, `:auto_process` — 5 separate Python subprocesses spawned near-simultaneously by Claude Code.
|
||||
|
||||
Module-level caches do NOT persist across these processes. The original investigation (pre-REDO) incorrectly assumed sequential single-process dispatch. Live observation proved the counter double-incremented (33 → 35 → 37 across single turns).
|
||||
|
||||
## 3. Multi-process dedup mechanism
|
||||
|
||||
The counter must advance exactly once per real user turn regardless of sibling process count.
|
||||
|
||||
Three-layer dedup in `_load_and_increment()`:
|
||||
|
||||
1. **fcntl.flock** — exclusive lock around read-modify-write of the state file. Prevents simultaneous siblings from both reading stale state.
|
||||
2. **mtime debounce** (~2s) — if the state file was modified < 2 seconds ago, treat as the same turn. The first sibling increments; the rest see fresh mtime and reuse the current value.
|
||||
3. **Per-turn token** — `transcript_path` file size (monotonic, identical across siblings). Only increment if BOTH the debounce window elapsed AND the token changed. Kills pathologically fast turns and identical-prompt collisions.
|
||||
|
||||
Special case: `turn < 0` (post-compact reset) always increments — debounce must not swallow the turn-0 all-fire guarantee.
|
||||
|
||||
Module: `apps/modules/cadence.py` (shared utility, accessed via `importlib.import_module` from handlers).
|
||||
|
||||
## 4. Action-gated sound
|
||||
|
||||
Handlers return a `"sound"` key in their result dict. The engine plays it at the output collection point (`engine.py`). Removed all scattered leading `speak()` calls — sound is now tied to handler action, not invocation. A skipped loader stays silent.
|
||||
|
||||
## 5. Edge cases
|
||||
|
||||
**FIRST TURN:** `should_fire` returns True when `turn==0`. Agent always gets full context on session start.
|
||||
|
||||
**CONCURRENT SESSIONS:** Counter file keyed by session_id — no cross-session conflict.
|
||||
|
||||
**COMPACTION:** PreCompact handler (`compact.py`) resets counter to -1 via `cadence.reset_counter()`. Next turn reads -1+1=0, all loaders fire.
|
||||
|
||||
**FILE I/O COST:** One flock + read + conditional write of ~30 bytes per turn. Negligible vs ~3,750 tokens saved.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Multi-process safe cadence via fcntl.flock + mtime debounce + transcript-size token. Shared module in `apps/modules/`, handlers access via importlib. Action-gated sound system-wide. 438 tests, seedgo 100%.
|
||||
|
||||
*Investigation by @hooks, 2026-06-08. Updated post-REDO 2026-06-09.*
|
||||
@@ -0,0 +1,44 @@
|
||||
# Cadence REDO brief — DPLAN-0200 WS-B (FPLAN-0249 reopen)
|
||||
|
||||
Your cadence build passed 435 tests but is **BROKEN in the live environment** — confirmed by direct observation + 3 research sub-agents. The 435 tests lied because they modeled the **wrong execution model**. Fix-forward: commit 2bccf03 stays, build on top, no history surgery.
|
||||
|
||||
## ROOT CAUSE (confirmed)
|
||||
Each loader runs as a **separate OS process**. `settings.json` registers distinct commands: `claude.py UserPromptSubmit:global_prompt`, `:branch_prompt`, `:identity_injector`, `:email_notification`, `:auto_process` — 5 separate python subprocesses. The module-level `_turn` cache assumed **sequential single-process** dispatch (cadence_investigation.md:28 and :99 flagged this as THE fragility "if Claude Code ever parallelizes" — it was ALREADY true). So `global` increments the /tmp counter to N, `branch` (separate process) to N+1 → counter races +2/turn → the two loaders **leapfrog** → firing is erratic, never "both every 5th". Live proof: counter 33 → 35 → 37 across single turns.
|
||||
|
||||
## FIX 1 — DEDUP THE COUNTER (keystone)
|
||||
The counter must advance **exactly once per real user turn** regardless of how many sibling processes call it.
|
||||
|
||||
- **PRIMARY — mtime/recency debounce** on the /tmp state file. In `_load_and_increment`, before incrementing, `stat` the file; if last-modified < ~2000 ms ago, treat as the SAME turn → re-read current turn, return WITHOUT incrementing. (The 5 siblings spawn near-simultaneously; the first increments, the rest reuse.)
|
||||
- **BACKSTOP — per-turn token** = `transcript_path` SIZE / line-count (it grows by one entry per turn, identical across all siblings, monotonic). Only increment if BOTH the debounce window elapsed AND the token changed. Kills the two realistic failure modes (pathologically fast turn; identical-prompt collision).
|
||||
- **REQUIRED — fcntl.flock** around the read-modify-write. The siblings are truly simultaneous; without the lock, two can both read old-mtime and both increment.
|
||||
- **SPECIAL-CASE turn < 0** (post-compact reset): ALWAYS increment — don't let the debounce swallow the post-compact turn-0 all-fire guarantee.
|
||||
- **STDIN FIELDS (corrected — the doc is WRONG):** UserPromptSubmit stdin = `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `prompt`. The field is `prompt`, NOT `user_prompt`; `session_id` IS present. Thread the token from `engine.py`'s parsed dict into `should_fire(loader_name, hook_data)`. Keep the `session_id`-keyed /tmp filename as the partition key (already correct). The `_turn` module cache may remain as an intra-process micro-opt but must NOT be the dedup authority.
|
||||
- **Correct cadence_investigation.md** outdated claims (user_prompt, no-session_id, single-process).
|
||||
|
||||
## FIX 2 — PRAX-VISIBLE FIRE/SKIP LOGGING (Patrick wants to SEE it in the monitor)
|
||||
Cadence already imports prax `system_logger`, and `system_logs/hooks_cadence.log` is ALREADY tailed live by `drone @prax monitor run` as `[HOOKS]`. The gap: `should_fire` logs nothing on the decision. Emit ONE structured INFO line at the `should_fire` choke point (covers all loaders, one site):
|
||||
|
||||
```
|
||||
[HOOKS] cadence <fired|skipped> loader=<name> action=<fired|skipped> turn=<N> period=<P> offset=<O> session=<8char>
|
||||
```
|
||||
|
||||
Use `.info` (SystemLogger has no `.debug`). ALSO gate/dedup the "counter reset" log — it spammed ~8x per cluster; confirm PreCompact reset fires EXACTLY once and logs once.
|
||||
|
||||
## FIX 3 — ACTION-GATED SOUND (the false signal Patrick HEARD)
|
||||
Right now `speak("global prompt")` / `speak("branch prompt")` is the FIRST line of each loader, BEFORE the `should_fire` check — so piper announces every turn even when the loader SKIPS injection. The voice lies. Patrick's rule: **if global/branch SKIP, they must be SILENT — sound ONLY on actual injection.**
|
||||
|
||||
Build the **system-wide** version (Patrick wants it right for ALL hooks): handlers return an explicit `sound` key in their result dict, e.g. `{"stdout": content, "sound": "global prompt", "exit_code": 0}`; the engine plays it at `engine.py:208` inside the `if result["stdout"]:` block (or whenever the `sound` key is present) — ONE integration point, every hook auto action-gated + self-identifying. Remove the scattered leading `speak()` calls from the loaders. Preserve the gates/notifications that legitimately emit empty stdout (let them set the `sound` key explicitly). `is_muted()` still short-circuits.
|
||||
|
||||
Sound architecture for reference: `hooks/apps/sound.py` `speak()`/`play()` → piper → aplay; mute flag `/tmp/aipass-hooks-muted`.
|
||||
|
||||
## TEST PLAN (this is what 435 green MISSED — required)
|
||||
- **Model separate-process execution:** simulate N independent processes each calling `_load_and_increment` for the same turn (no shared module cache) and assert the counter advances EXACTLY ONCE. REWRITE `test_cadence.py:113` `test_counter_increments_once_per_process` (it encodes the invalid single-process assumption).
|
||||
- Assert the leapfrog is gone: two loaders in the same turn see the SAME turn number — both fire on offset-0 turns, both skip otherwise.
|
||||
- Assert reset → next turn = 0 = all fire (the turn<0 special-case survives the debounce).
|
||||
- Assert SKIP = silent (no `sound` key) AND logs `action=skipped` (not fired).
|
||||
- Assert flock prevents double-increment under simulated simultaneity.
|
||||
|
||||
## ACCEPTANCE
|
||||
Multi-process simulation tests green + seedgo 100% + pyright 0. But do **NOT** claim "works" from unit tests alone — that is exactly what failed. devpulse will LIVE-VERIFY next session (prax monitor shows correct fire/skip, sound only on inject, counter advances once/turn). Report what you built + test results. NO git commits (devpulse commits). Reply via dispatch if blocked.
|
||||
|
||||
Track in your FPLAN (reopen FPLAN-0249). This is the careful re-do — get it right, verify against the REAL execution model.
|
||||
@@ -1,16 +1,14 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_announce.py
|
||||
# Version: 1.2.0
|
||||
# Version: 1.3.0
|
||||
# Description: Tests for announce notification handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-20
|
||||
# Modified: 2026-05-22
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/notification/announce.py."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestAnnounceHandler:
|
||||
"""Core handler behavior tests."""
|
||||
@@ -18,17 +16,15 @@ class TestAnnounceHandler:
|
||||
def test_handle_returns_result_dict(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.announce.speak"):
|
||||
result = handle({})
|
||||
result = handle({})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handle_speaks_notification_sound(self):
|
||||
def test_handle_sets_sound_key(self):
|
||||
from aipass.hooks.apps.handlers.notification.announce import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.announce.speak") as mock_speak:
|
||||
handle({})
|
||||
result = handle({})
|
||||
|
||||
mock_speak.assert_called_once_with("notification sound")
|
||||
assert result["sound"] == "notification sound"
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/lifecycle/auto_fix.py."""
|
||||
"""Tests for handlers/lifecycle/auto_fix.py.
|
||||
|
||||
NOTE: sound is action-gated via the result "sound" key — it is set to
|
||||
"auto fix diagnostics" only on the error-surfacing path; clean and skip
|
||||
paths stay silent (no "sound" key).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -61,32 +67,31 @@ class TestAutoFixSkips:
|
||||
def test_skip_unknown_extension(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak"):
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}})
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
assert "sound" not in result
|
||||
|
||||
|
||||
class TestAutofixPython:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_python_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_python_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}})
|
||||
assert result["exit_code"] == 0
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["systemMessage"] == "[diagnostics] ok"
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks")
|
||||
def test_python_syntax_error(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_python_syntax_error(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_py.return_value = ["SYNTAX: invalid syntax at line 5"]
|
||||
@@ -96,25 +101,25 @@ class TestAutofixPython:
|
||||
assert "additionalContext" in parsed.get("hookSpecificOutput", {})
|
||||
assert "SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert "1 error(s)" in parsed["systemMessage"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks")
|
||||
def test_python_ruff_lint_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_python_ruff_lint_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_py.return_value = ["LINT: bad.py:10:1: F401 unused import"]
|
||||
result = handle({"tool_name": "Write", "tool_input": {"file_path": "/tmp/bad.py"}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "LINT" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_python_pyright_errors(self, mock_py, mock_ruff_s, mock_seedgo, mock_speak):
|
||||
def test_python_pyright_errors(self, mock_py, mock_ruff_s, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch(
|
||||
@@ -125,12 +130,12 @@ class TestAutofixPython:
|
||||
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "TYPE: L42" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_seedgo_violations_surfaced(self, mock_py, mock_ruff_s, mock_pyright, mock_speak):
|
||||
def test_seedgo_violations_surfaced(self, mock_py, mock_ruff_s, mock_pyright):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch(
|
||||
@@ -141,15 +146,15 @@ class TestAutofixPython:
|
||||
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "SEEDGO: missing file header" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
|
||||
class TestAutoFixStateFile:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_state_file_written_on_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_state_file_written_on_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_ruff_s.return_value = [{"line": 5, "message": "F401: unused import"}]
|
||||
@@ -160,8 +165,9 @@ class TestAutoFixStateFile:
|
||||
|
||||
try:
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path):
|
||||
handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/errors.py"}})
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/errors.py"}})
|
||||
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
assert state_path.exists()
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert len(state["errors"]) == 2
|
||||
@@ -171,12 +177,11 @@ class TestAutoFixStateFile:
|
||||
if state_path.exists():
|
||||
state_path.unlink()
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_state_file_cleared_on_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_state_file_cleared_on_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as tf:
|
||||
@@ -185,8 +190,9 @@ class TestAutoFixStateFile:
|
||||
|
||||
try:
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path):
|
||||
handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}})
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}})
|
||||
|
||||
assert "sound" not in result
|
||||
assert not state_path.exists()
|
||||
finally:
|
||||
if state_path.exists():
|
||||
@@ -194,8 +200,7 @@ class TestAutoFixStateFile:
|
||||
|
||||
|
||||
class TestAutoFixJson:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
def test_json_valid(self, mock_speak, tmp_path):
|
||||
def test_json_valid(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "good.json"
|
||||
@@ -204,9 +209,9 @@ class TestAutoFixJson:
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["systemMessage"] == "[diagnostics] ok"
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
def test_json_invalid_syntax(self, mock_speak, tmp_path):
|
||||
def test_json_invalid_syntax(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "bad.json"
|
||||
@@ -215,9 +220,9 @@ class TestAutoFixJson:
|
||||
result = handle({"tool_name": "Write", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "JSON SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
def test_json_corruption_detected(self, mock_speak, tmp_path):
|
||||
def test_json_corruption_detected(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "corrupt.json"
|
||||
@@ -226,6 +231,7 @@ class TestAutoFixJson:
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "EMOJI CORRUPTION" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
|
||||
class TestAutoFixSubprocessChecks:
|
||||
@@ -254,6 +260,8 @@ class TestAutoFixSubprocessChecks:
|
||||
errors = _check_ruff_lint("/tmp/bad.py")
|
||||
assert len(errors) == 1
|
||||
assert "LINT" in errors[0]
|
||||
# bare "ruff" relies on PATH the hook env doesn't have — must go through the venv interpreter
|
||||
assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_check_ruff_format_drift(self, mock_run):
|
||||
@@ -263,6 +271,7 @@ class TestAutoFixSubprocessChecks:
|
||||
errors = _check_ruff_format("/tmp/unformatted.py")
|
||||
assert len(errors) == 1
|
||||
assert "FORMAT" in errors[0]
|
||||
assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_ruff_lint_structured_returns_dicts(self, mock_run):
|
||||
@@ -280,6 +289,7 @@ class TestAutoFixSubprocessChecks:
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["line"] == 5
|
||||
assert "F401" in errors[0]["message"]
|
||||
assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_ruff_lint_structured_skips_claude_hooks(self, mock_run):
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
"""Tests for handlers/prompt/branch_loader.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _mock_cadence_fires():
|
||||
"""Return a mock cadence module where should_fire always returns True."""
|
||||
mock = MagicMock()
|
||||
mock.should_fire.return_value = True
|
||||
return mock
|
||||
|
||||
|
||||
class TestBranchLoaderHandler:
|
||||
@@ -24,12 +31,13 @@ class TestBranchLoaderHandler:
|
||||
prompt = aipass_dir / "aipass_local_prompt.md"
|
||||
prompt.write_text("# Test Branch\nSome instructions", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Branch Context:" in result["stdout"]
|
||||
assert "Some instructions" in result["stdout"]
|
||||
assert result["sound"] == "branch prompt"
|
||||
|
||||
def test_loads_private_integrations(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -41,10 +49,11 @@ class TestBranchLoaderHandler:
|
||||
private = integration / "private_prompt.md"
|
||||
private.write_text("# Private Integration\nSecret stuff", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Private Integration" in result["stdout"]
|
||||
assert result["sound"] == "branch prompt"
|
||||
|
||||
def test_loads_both_prompt_and_integrations(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -58,7 +67,7 @@ class TestBranchLoaderHandler:
|
||||
integration.mkdir(parents=True)
|
||||
(integration / "private_prompt.md").write_text("Compass prompt", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Branch prompt" in result["stdout"]
|
||||
@@ -67,10 +76,11 @@ class TestBranchLoaderHandler:
|
||||
def test_returns_empty_when_no_branch_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_stops_at_repo_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -79,10 +89,11 @@ class TestBranchLoaderHandler:
|
||||
nested = tmp_path / "some" / "deep" / "path"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_walks_up_to_find_branch(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -95,7 +106,7 @@ class TestBranchLoaderHandler:
|
||||
nested = tmp_path / "apps" / "handlers" / "security"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert "Found it" in result["stdout"]
|
||||
@@ -103,12 +114,15 @@ class TestBranchLoaderHandler:
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
# Path.cwd patch must be OUTSIDE the importlib patch — mock.patch uses
|
||||
# importlib.import_module to resolve "pathlib", which the inner mock hijacks.
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_no_prompt_file_but_has_branch_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -116,10 +130,11 @@ class TestBranchLoaderHandler:
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_includes_source_path_in_output(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -130,7 +145,7 @@ class TestBranchLoaderHandler:
|
||||
aipass_dir.mkdir()
|
||||
(aipass_dir / "aipass_local_prompt.md").write_text("content", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Source:" in result["stdout"]
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_cadence.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for cadence module (DPLAN-0200)
|
||||
# Branch: hooks
|
||||
# Created: 2026-06-08
|
||||
# Modified: 2026-06-08
|
||||
# =============================================
|
||||
|
||||
"""Tests for apps/modules/cadence.py.
|
||||
|
||||
Cadence runs MULTI-PROCESS in production: each UserPromptSubmit hook is a
|
||||
separate OS process. Tests model that by resetting the module _turn cache
|
||||
between calls (= new process) and aging the state file past the mtime
|
||||
debounce window (= a real prior turn, not a sibling in the same turn).
|
||||
"""
|
||||
|
||||
import json
|
||||
import importlib
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
MODULE = "aipass.hooks.apps.modules.cadence"
|
||||
|
||||
|
||||
def _reset_module_globals():
|
||||
"""Reset module-level caches between tests (also = simulate a new process)."""
|
||||
import aipass.hooks.apps.modules.cadence as mod
|
||||
|
||||
mod._turn = None
|
||||
mod._config = None
|
||||
|
||||
|
||||
def _write_state(tmp_path, turn, token=-1, session="test-session", aged=True):
|
||||
"""Write a cadence state file. aged=True backdates mtime past the debounce
|
||||
window so it reads as a PREVIOUS turn; aged=False = sibling in same turn."""
|
||||
state_file = tmp_path / f"aipass-cadence-{session}.json"
|
||||
state_file.write_text(json.dumps({"turn": turn, "token": token}))
|
||||
if aged:
|
||||
old = time.time() - 10
|
||||
os.utime(state_file, (old, old))
|
||||
return state_file
|
||||
|
||||
|
||||
class TestShouldFire:
|
||||
def setup_method(self):
|
||||
_reset_module_globals()
|
||||
|
||||
def test_turn_0_always_fires(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
assert json.loads(state_file.read_text())["turn"] == 0
|
||||
|
||||
def test_turn_0_fires_all_loaders(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
assert should_fire("branch") is True
|
||||
|
||||
def test_non_fire_turn_returns_false(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
_write_state(tmp_path, turn=0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is False
|
||||
|
||||
def test_fire_turn_returns_true(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
_write_state(tmp_path, turn=3)
|
||||
|
||||
config = tmp_path / "cadence.json"
|
||||
config.write_text(json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 4}}}))
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", config),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
|
||||
def test_cadence_disabled_always_fires(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 1}))
|
||||
|
||||
config = tmp_path / "cadence.json"
|
||||
config.write_text(json.dumps({"enabled": False}))
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", config),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
|
||||
def test_no_session_id_fires(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {}, clear=False),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
env = dict(__import__("os").environ)
|
||||
env.pop("CLAUDE_CODE_SESSION_ID", None)
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
assert should_fire("global") is True
|
||||
|
||||
def test_counter_increments_once_across_sibling_processes(self, tmp_path):
|
||||
"""Each loader is a SEPARATE OS process. The counter must advance
|
||||
exactly once per real turn no matter how many siblings call it."""
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = _write_state(tmp_path, turn=3)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
should_fire("global")
|
||||
for _ in range(4): # 4 more siblings, each a fresh process
|
||||
_reset_module_globals()
|
||||
should_fire("branch")
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == 4
|
||||
|
||||
def test_sibling_processes_agree_on_turn_no_leapfrog(self, tmp_path):
|
||||
"""The S210 live bug: global saw turn N, branch saw N+1 — they
|
||||
leapfrogged and never both fired. Both siblings must see the SAME
|
||||
turn and make the SAME decision."""
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
_write_state(tmp_path, turn=4) # next real turn = 5 = fire (5 % 5 == 0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
_reset_module_globals() # branch runs as a separate process
|
||||
assert should_fire("branch") is True
|
||||
|
||||
def test_token_backstop_blocks_double_increment(self, tmp_path):
|
||||
"""Even past the debounce window, an unchanged transcript token means
|
||||
no new turn happened — the counter must not advance."""
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
transcript = tmp_path / "transcript.jsonl"
|
||||
transcript.write_text("x" * 100)
|
||||
state_file = _write_state(tmp_path, turn=3, token=100)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
should_fire("global", {"transcript_path": str(transcript)})
|
||||
assert json.loads(state_file.read_text())["turn"] == 3
|
||||
|
||||
def test_reset_special_case_survives_debounce(self, tmp_path):
|
||||
"""turn < 0 (post-compact reset) must ALWAYS increment to 0, even when
|
||||
the reset just happened (fresh mtime would normally debounce)."""
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = _write_state(tmp_path, turn=-1, aged=False)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
assert json.loads(state_file.read_text())["turn"] == 0
|
||||
|
||||
def test_period_zero_always_fires(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 2}))
|
||||
|
||||
config = tmp_path / "cadence.json"
|
||||
config.write_text(json.dumps({"enabled": True, "period": 0}))
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", config),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
|
||||
def test_stagger_offsets(self, tmp_path):
|
||||
config = tmp_path / "cadence.json"
|
||||
config.write_text(
|
||||
json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 0}, "branch": {"offset": 2}}})
|
||||
)
|
||||
|
||||
_write_state(tmp_path, turn=4)
|
||||
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", config),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
assert should_fire("branch") is False
|
||||
|
||||
def test_unknown_loader_uses_offset_zero(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
_write_state(tmp_path, turn=4)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("unknown_loader") is True
|
||||
|
||||
|
||||
class TestResetCounter:
|
||||
def setup_method(self):
|
||||
_reset_module_globals()
|
||||
|
||||
def test_reset_writes_minus_one(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import reset_counter
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 7}))
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
):
|
||||
reset_counter()
|
||||
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == -1
|
||||
|
||||
def test_reset_then_next_turn_is_zero(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import reset_counter, should_fire
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 7}))
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
reset_counter()
|
||||
|
||||
_reset_module_globals()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == 0
|
||||
|
||||
def test_reset_no_session_id_is_noop(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import reset_counter
|
||||
|
||||
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
|
||||
env = dict(__import__("os").environ)
|
||||
env.pop("CLAUDE_CODE_SESSION_ID", None)
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
reset_counter()
|
||||
|
||||
assert not list(tmp_path.glob("aipass-cadence-*"))
|
||||
|
||||
def test_reset_creates_file_if_missing(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import reset_counter
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
):
|
||||
reset_counter()
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
assert state_file.exists()
|
||||
assert json.loads(state_file.read_text())["turn"] == -1
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def setup_method(self):
|
||||
_reset_module_globals()
|
||||
|
||||
def test_defaults_used_when_no_config_file(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import _load_config
|
||||
|
||||
with patch(f"{MODULE}._CONFIG_PATH", tmp_path / "nonexistent.json"):
|
||||
config = _load_config()
|
||||
|
||||
assert config["enabled"] is True
|
||||
assert config["period"] == 5
|
||||
assert config["loaders"]["global"]["offset"] == 0
|
||||
assert config["loaders"]["branch"]["offset"] == 0
|
||||
|
||||
def test_config_deep_merges_over_defaults(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import _load_config
|
||||
|
||||
config_file = tmp_path / "cadence.json"
|
||||
config_file.write_text(json.dumps({"period": 10, "loaders": {"global": {"offset": 3}}}))
|
||||
|
||||
with patch(f"{MODULE}._CONFIG_PATH", config_file):
|
||||
config = _load_config()
|
||||
|
||||
assert config["period"] == 10
|
||||
assert config["loaders"]["global"]["offset"] == 3
|
||||
assert config["loaders"]["branch"]["offset"] == 0
|
||||
assert config["enabled"] is True
|
||||
|
||||
def test_bad_config_falls_back_to_defaults(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import _load_config
|
||||
|
||||
config_file = tmp_path / "cadence.json"
|
||||
config_file.write_text("not valid json{{{")
|
||||
|
||||
with patch(f"{MODULE}._CONFIG_PATH", config_file):
|
||||
config = _load_config()
|
||||
|
||||
assert config["period"] == 5
|
||||
|
||||
|
||||
class TestDeepMerge:
|
||||
def test_nested_merge(self):
|
||||
from aipass.hooks.apps.modules.cadence import _deep_merge
|
||||
|
||||
base = {"a": 1, "b": {"c": 2, "d": 3}}
|
||||
updates = {"b": {"c": 99}, "e": 4}
|
||||
result = _deep_merge(base, updates)
|
||||
|
||||
assert result["a"] == 1
|
||||
assert result["b"]["c"] == 99
|
||||
assert result["b"]["d"] == 3
|
||||
assert result["e"] == 4
|
||||
|
||||
def test_overwrites_non_dict(self):
|
||||
from aipass.hooks.apps.modules.cadence import _deep_merge
|
||||
|
||||
base = {"a": [1, 2]}
|
||||
result = _deep_merge(base, {"a": [3]})
|
||||
assert result["a"] == [3]
|
||||
|
||||
|
||||
class TestPerSessionIsolation:
|
||||
def setup_method(self):
|
||||
_reset_module_globals()
|
||||
|
||||
def test_different_sessions_use_different_files(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_a = _write_state(tmp_path, turn=4, session="session-a")
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "session-a"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
should_fire("global")
|
||||
data_a = json.loads(state_a.read_text())
|
||||
assert data_a["turn"] == 5
|
||||
|
||||
_reset_module_globals()
|
||||
|
||||
state_b = tmp_path / "aipass-cadence-session-b.json"
|
||||
assert not state_b.exists()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "session-b"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
data_b = json.loads(state_b.read_text())
|
||||
assert data_b["turn"] == 0
|
||||
|
||||
|
||||
class TestModuleInterface:
|
||||
def setup_method(self):
|
||||
_reset_module_globals()
|
||||
|
||||
def test_handle_command_cadence_returns_true(self):
|
||||
from aipass.hooks.apps.modules.cadence import handle_command
|
||||
|
||||
with patch(f"{MODULE}.print_introspection"):
|
||||
assert handle_command("cadence", []) is True
|
||||
|
||||
def test_handle_command_unknown_returns_false(self):
|
||||
from aipass.hooks.apps.modules.cadence import handle_command
|
||||
|
||||
assert handle_command("other", []) is False
|
||||
|
||||
def test_print_introspection_runs(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import print_introspection
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
print_introspection()
|
||||
|
||||
|
||||
class TestCompactIntegration:
|
||||
def setup_method(self):
|
||||
_reset_module_globals()
|
||||
|
||||
def test_compact_handler_resets_cadence(self, tmp_path):
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 7}))
|
||||
|
||||
import aipass.hooks.apps.modules.cadence as cadence_mod
|
||||
|
||||
with (
|
||||
patch.object(cadence_mod, "_GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
):
|
||||
mock_cadence = importlib.import_module("aipass.hooks.apps.modules.cadence")
|
||||
mock_cadence.reset_counter()
|
||||
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == -1
|
||||
|
||||
|
||||
class TestLoaderCadenceGuard:
|
||||
def setup_method(self):
|
||||
_reset_module_globals()
|
||||
|
||||
def test_global_loader_skips_on_non_fire_turn(self, tmp_path):
|
||||
"""Skip = empty stdout AND no sound key — a skipped loader is SILENT."""
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
|
||||
_write_state(tmp_path, turn=0) # next turn = 1 = skip
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
assert "sound" not in result
|
||||
|
||||
def test_branch_loader_skips_on_non_fire_turn(self, tmp_path):
|
||||
"""Skip = empty stdout AND no sound key — a skipped loader is SILENT."""
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
_write_state(tmp_path, turn=0) # next turn = 1 = skip
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
assert "sound" not in result
|
||||
@@ -29,25 +29,25 @@ class TestCompactHandler:
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "POST-COMPACT RECOVERY" in result["stdout"]
|
||||
assert "Git branch: dev" in result["stdout"]
|
||||
assert "did stuff" in result["stdout"]
|
||||
assert "STATUS.local.md" not in result["stdout"]
|
||||
assert result["sound"] == "pre compact"
|
||||
|
||||
def test_returns_recovery_when_no_branch_dir(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
result = handle({"cwd": "/tmp/nonexistent"})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
result = handle({"cwd": "/tmp/nonexistent"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "POST-COMPACT RECOVERY" in result["stdout"]
|
||||
assert result["sound"] == "pre compact"
|
||||
|
||||
def test_dispatched_agent_gets_save_warning(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
@@ -55,10 +55,9 @@ class TestCompactHandler:
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "SAVE STATE NOW" in result["stdout"]
|
||||
assert "STATUS.local.md" not in result["stdout"]
|
||||
@@ -69,10 +68,9 @@ class TestCompactHandler:
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": ""}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": ""}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Recovery Protocol" in result["stdout"]
|
||||
assert "STATUS.local.md" not in result["stdout"]
|
||||
@@ -80,9 +78,8 @@ class TestCompactHandler:
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))):
|
||||
result = handle({})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_email.py
|
||||
# Version: 1.2.0
|
||||
# Version: 1.3.0
|
||||
# Description: Tests for email notification handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-21
|
||||
# Modified: 2026-05-22
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/notification/email.py."""
|
||||
@@ -41,12 +41,9 @@ class TestEmailHandler:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email.speak"),
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
@@ -54,7 +51,7 @@ class TestEmailHandler:
|
||||
assert "drone @ai_mail inbox" in result["stdout"]
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handle_speaks_when_new_emails(self, tmp_path):
|
||||
def test_handle_sets_sound_when_new_emails(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
@@ -65,18 +62,15 @@ class TestEmailHandler:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email.speak") as mock_speak,
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
):
|
||||
handle({})
|
||||
result = handle({})
|
||||
|
||||
mock_speak.assert_called_once_with("email notification: 1 new email")
|
||||
assert result["sound"] == "email notification: 1 new email"
|
||||
|
||||
def test_handle_does_not_speak_when_no_emails(self, tmp_path):
|
||||
def test_handle_no_sound_when_no_emails(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
|
||||
inbox_dir = tmp_path / ".ai_mail.local"
|
||||
@@ -87,16 +81,13 @@ class TestEmailHandler:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email.speak") as mock_speak,
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
):
|
||||
handle({})
|
||||
result = handle({})
|
||||
|
||||
mock_speak.assert_not_called()
|
||||
assert result.get("sound", "") == ""
|
||||
|
||||
def test_handle_returns_empty_when_no_new_emails(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.notification.email import handle
|
||||
@@ -109,12 +100,9 @@ class TestEmailHandler:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email.speak"),
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
@@ -140,12 +128,9 @@ class TestEmailHandler:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.hooks.apps.handlers.notification.email.speak"),
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.notification.email._find_branch_root",
|
||||
return_value=tmp_path,
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
|
||||
"""Tests for handlers/prompt/global_loader.py."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _mock_cadence_fires():
|
||||
"""Return a mock cadence module where should_fire always returns True."""
|
||||
mock = MagicMock()
|
||||
mock.should_fire.return_value = True
|
||||
return mock
|
||||
|
||||
|
||||
class TestGlobalLoaderHandler:
|
||||
@@ -22,24 +29,26 @@ class TestGlobalLoaderHandler:
|
||||
prompt.write_text("# AIPass Global\nContext here", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "AIPass Global" in result["stdout"]
|
||||
assert "Context here" in result["stdout"]
|
||||
assert result["sound"] == "global prompt"
|
||||
|
||||
def test_returns_empty_when_file_missing(self, tmp_path, monkeypatch):
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_empty_hook_data(self, tmp_path, monkeypatch):
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
@@ -49,8 +58,8 @@ class TestGlobalLoaderHandler:
|
||||
(aipass_dir / "aipass_global_prompt.md").write_text("content", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
@@ -67,9 +76,10 @@ class TestGlobalLoaderHandler:
|
||||
(aipass_dir / "aipass_global_prompt.md").write_text("# Project Prompt", encoding="utf-8")
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Project Prompt" in result["stdout"]
|
||||
assert result["sound"] == "global prompt"
|
||||
|
||||
@@ -40,22 +40,22 @@ class TestIdentityHandler:
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "devpulse Identity" in result["stdout"]
|
||||
assert "orchestration_hub" in result["stdout"]
|
||||
assert "Pragmatic" in result["stdout"]
|
||||
assert result["sound"] == "identity"
|
||||
|
||||
def test_returns_empty_when_no_passport(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_walks_up_to_find_passport(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
@@ -67,8 +67,7 @@ class TestIdentityHandler:
|
||||
nested = tmp_path / "apps" / "handlers"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(nested)})
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert "devpulse Identity" in result["stdout"]
|
||||
|
||||
@@ -80,8 +79,7 @@ class TestIdentityHandler:
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
out = result["stdout"]
|
||||
assert "Path: src/aipass/devpulse" in out
|
||||
@@ -100,21 +98,21 @@ class TestIdentityHandler:
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps({"branch_info": {"branch_name": "test"}, "identity": {}}), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "test Identity" in result["stdout"]
|
||||
assert result["sound"] == "identity"
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
result = handle({})
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_corrupt_passport_json(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
@@ -124,8 +122,8 @@ class TestIdentityHandler:
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text("{broken json", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@@ -17,39 +17,37 @@ class TestRolloverHandler:
|
||||
def test_no_repo_root_returns_empty(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None):
|
||||
result = handle({})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_no_overdue_returns_empty(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]):
|
||||
result = handle({})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_overdue_triggers_rollover(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue",
|
||||
return_value=[("devpulse", "local", "21/20 sessions")],
|
||||
):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.rollover._run_rollover", return_value=(True, "ok")
|
||||
):
|
||||
result = handle({})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue",
|
||||
return_value=[("devpulse", "local", "21/20 sessions")],
|
||||
):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._run_rollover", return_value=(True, "ok")):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["sound"] == "pre compact rollover"
|
||||
|
||||
def test_check_file_v2_sessions_overdue(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_sandbox.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for sandbox wrapper module
|
||||
# Branch: hooks
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Tests for apps/modules/sandbox.py."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="sandbox is Linux-only: bwrap mount namespaces")
|
||||
|
||||
|
||||
class TestBuildSrtConfig:
|
||||
"""Config generation from policy dict."""
|
||||
|
||||
def test_minimal_policy(self):
|
||||
from aipass.hooks.apps.modules.sandbox import build_srt_config
|
||||
|
||||
with patch("aipass.hooks.apps.modules.sandbox._find_rg", return_value="/usr/bin/rg"):
|
||||
config = build_srt_config({"allow_write": ["/tmp"]})
|
||||
|
||||
assert config["network"] == {"allowAllUnixSockets": True}
|
||||
assert config["filesystem"]["allowWrite"] == ["/tmp"]
|
||||
assert config["filesystem"]["denyRead"] == []
|
||||
assert config["filesystem"]["denyWrite"] == []
|
||||
assert config["ripgrep"]["command"] == "/usr/bin/rg"
|
||||
|
||||
def test_full_policy(self):
|
||||
from aipass.hooks.apps.modules.sandbox import build_srt_config
|
||||
|
||||
policy = {
|
||||
"allow_write": ["/tmp", "/home/user/branch"],
|
||||
"deny_write": ["/home/user/branch/.git"],
|
||||
"deny_read": ["/etc/shadow"],
|
||||
}
|
||||
with patch("aipass.hooks.apps.modules.sandbox._find_rg", return_value="/usr/bin/rg"):
|
||||
config = build_srt_config(policy)
|
||||
|
||||
assert config["filesystem"]["allowWrite"] == ["/tmp", "/home/user/branch"]
|
||||
assert config["filesystem"]["denyWrite"] == ["/home/user/branch/.git"]
|
||||
assert config["filesystem"]["denyRead"] == ["/etc/shadow"]
|
||||
|
||||
def test_paths_stringified(self):
|
||||
from aipass.hooks.apps.modules.sandbox import build_srt_config
|
||||
|
||||
policy = {"allow_write": [Path("/tmp"), Path("/home/x")]}
|
||||
with patch("aipass.hooks.apps.modules.sandbox._find_rg", return_value="/usr/bin/rg"):
|
||||
config = build_srt_config(policy)
|
||||
|
||||
assert all(isinstance(p, str) for p in config["filesystem"]["allowWrite"])
|
||||
|
||||
def test_missing_allow_write_raises(self):
|
||||
from aipass.hooks.apps.modules.sandbox import build_srt_config
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox._find_rg", return_value="/usr/bin/rg"),
|
||||
pytest.raises(KeyError),
|
||||
):
|
||||
build_srt_config({})
|
||||
|
||||
|
||||
class TestFindNode:
|
||||
"""Node.js binary discovery."""
|
||||
|
||||
def test_finds_node_on_path(self):
|
||||
from aipass.hooks.apps.modules.sandbox import _find_node
|
||||
|
||||
with patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value="/usr/bin/node"):
|
||||
assert _find_node() == "/usr/bin/node"
|
||||
|
||||
def test_raises_when_not_found(self):
|
||||
from aipass.hooks.apps.modules.sandbox import _find_node
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value=None),
|
||||
pytest.raises(FileNotFoundError, match="node not found"),
|
||||
):
|
||||
_find_node()
|
||||
|
||||
|
||||
class TestFindRg:
|
||||
"""Ripgrep binary discovery."""
|
||||
|
||||
def test_finds_rg_on_path(self):
|
||||
from aipass.hooks.apps.modules.sandbox import _find_rg
|
||||
|
||||
with patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value="/usr/bin/rg"):
|
||||
assert _find_rg() == "/usr/bin/rg"
|
||||
|
||||
def test_falls_back_to_local_bin(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import _find_rg
|
||||
|
||||
fake_rg = tmp_path / ".local" / "bin" / "rg"
|
||||
fake_rg.parent.mkdir(parents=True)
|
||||
fake_rg.touch()
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value=None),
|
||||
patch("aipass.hooks.apps.modules.sandbox.Path.home", return_value=tmp_path),
|
||||
):
|
||||
assert _find_rg() == str(fake_rg)
|
||||
|
||||
def test_raises_when_not_found(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import _find_rg
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value=None),
|
||||
patch("aipass.hooks.apps.modules.sandbox.Path.home", return_value=tmp_path),
|
||||
pytest.raises(FileNotFoundError, match="ripgrep"),
|
||||
):
|
||||
_find_rg()
|
||||
|
||||
|
||||
class TestResolveBwrapCommand:
|
||||
"""Bwrap command resolution via Node helper."""
|
||||
|
||||
def test_returns_bwrap_string(self):
|
||||
from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.returncode = 0
|
||||
fake_result.stdout = "bwrap --ro-bind / / -- /bin/bash -c 'echo hello'"
|
||||
fake_result.stderr = ""
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"),
|
||||
patch("aipass.hooks.apps.modules.sandbox.subprocess.run", return_value=fake_result),
|
||||
):
|
||||
cmd = resolve_bwrap_command("echo hello", {"network": {}})
|
||||
|
||||
assert "bwrap" in cmd
|
||||
|
||||
def test_raises_on_nonzero_exit(self):
|
||||
from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.returncode = 1
|
||||
fake_result.stdout = ""
|
||||
fake_result.stderr = "some error"
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"),
|
||||
patch("aipass.hooks.apps.modules.sandbox.subprocess.run", return_value=fake_result),
|
||||
pytest.raises(RuntimeError, match="srt resolve failed"),
|
||||
):
|
||||
resolve_bwrap_command("echo hello", {"network": {}})
|
||||
|
||||
def test_raises_on_empty_output(self):
|
||||
from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.returncode = 0
|
||||
fake_result.stdout = ""
|
||||
fake_result.stderr = ""
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"),
|
||||
patch("aipass.hooks.apps.modules.sandbox.subprocess.run", return_value=fake_result),
|
||||
pytest.raises(RuntimeError, match="empty command"),
|
||||
):
|
||||
resolve_bwrap_command("echo hello", {"network": {}})
|
||||
|
||||
def test_resolver_cwd_is_not_branch_dir(self):
|
||||
"""srt resolves DANGEROUS_FILES relative to CWD. Using /var/tmp (or
|
||||
fallback) prevents mount-point pollution in the branch directory."""
|
||||
from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.returncode = 0
|
||||
fake_result.stdout = "bwrap --test"
|
||||
fake_result.stderr = ""
|
||||
|
||||
captured_kwargs = {}
|
||||
|
||||
def capture_run(args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return fake_result
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"),
|
||||
patch("aipass.hooks.apps.modules.sandbox.subprocess.run", side_effect=capture_run),
|
||||
):
|
||||
resolve_bwrap_command("echo hello", {"network": {}})
|
||||
|
||||
cwd = captured_kwargs.get("cwd", "")
|
||||
assert cwd and not cwd.startswith(str(Path.cwd()))
|
||||
|
||||
def test_cleans_up_temp_file(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command
|
||||
|
||||
fake_result = MagicMock()
|
||||
fake_result.returncode = 0
|
||||
fake_result.stdout = "bwrap --test"
|
||||
fake_result.stderr = ""
|
||||
|
||||
created_files = []
|
||||
|
||||
def capture_run(args, **kwargs):
|
||||
config_path = args[2]
|
||||
created_files.append(config_path)
|
||||
return fake_result
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"),
|
||||
patch("aipass.hooks.apps.modules.sandbox.subprocess.run", side_effect=capture_run),
|
||||
):
|
||||
resolve_bwrap_command("echo hello", {"network": {}})
|
||||
|
||||
assert len(created_files) == 1
|
||||
assert not Path(created_files[0]).exists()
|
||||
|
||||
|
||||
class TestSandboxLaunch:
|
||||
"""Full launch flow (mocked resolver)."""
|
||||
|
||||
def test_returns_popen(self):
|
||||
from aipass.hooks.apps.modules.sandbox import sandbox_launch
|
||||
|
||||
fake_popen = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.modules.sandbox.resolve_bwrap_command",
|
||||
return_value="bwrap --test -- /bin/bash -c 'echo hi'",
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.modules.sandbox.build_srt_config",
|
||||
return_value={"network": {}},
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.modules.sandbox.subprocess.Popen",
|
||||
return_value=fake_popen,
|
||||
) as mock_popen,
|
||||
):
|
||||
result = sandbox_launch("echo hi", policy={"allow_write": ["/tmp"]})
|
||||
|
||||
assert result is fake_popen
|
||||
call_args = mock_popen.call_args
|
||||
assert call_args[0][0] == ["/bin/bash", "-c", "bwrap --test -- /bin/bash -c 'echo hi'"]
|
||||
|
||||
def test_passes_cwd(self):
|
||||
from aipass.hooks.apps.modules.sandbox import sandbox_launch
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.modules.sandbox.resolve_bwrap_command",
|
||||
return_value="bwrap --test",
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.modules.sandbox.build_srt_config",
|
||||
return_value={"network": {}},
|
||||
),
|
||||
patch("aipass.hooks.apps.modules.sandbox.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
sandbox_launch("echo hi", cwd="/tmp/test", policy={"allow_write": ["/tmp"]})
|
||||
|
||||
assert mock_popen.call_args[1]["cwd"] == "/tmp/test"
|
||||
|
||||
def test_passes_custom_env(self):
|
||||
from aipass.hooks.apps.modules.sandbox import sandbox_launch
|
||||
|
||||
custom_env = {"PATH": "/usr/bin", "HOME": "/tmp"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.modules.sandbox.resolve_bwrap_command",
|
||||
return_value="bwrap --test",
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.modules.sandbox.build_srt_config",
|
||||
return_value={"network": {}},
|
||||
),
|
||||
patch("aipass.hooks.apps.modules.sandbox.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
sandbox_launch("echo hi", policy={"allow_write": ["/tmp"]}, env=custom_env)
|
||||
|
||||
assert mock_popen.call_args[1]["env"] is custom_env
|
||||
|
||||
|
||||
class TestSrtResolveCwd:
|
||||
"""CWD selection for srt resolver — prevents mask-placeholder pollution."""
|
||||
|
||||
def test_returns_var_tmp_when_available(self):
|
||||
from aipass.hooks.apps.modules.sandbox import _srt_resolve_cwd
|
||||
|
||||
mock_var = MagicMock()
|
||||
mock_var.is_dir.return_value = True
|
||||
mock_var.__str__ = MagicMock(return_value="/var/tmp")
|
||||
with patch("aipass.hooks.apps.modules.sandbox._VAR_TMP", mock_var):
|
||||
assert _srt_resolve_cwd() == "/var/tmp"
|
||||
|
||||
def test_falls_back_to_tempdir(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import _srt_resolve_cwd
|
||||
|
||||
with (
|
||||
patch("aipass.hooks.apps.modules.sandbox._VAR_TMP") as mock_var,
|
||||
patch("aipass.hooks.apps.modules.sandbox.tempfile.gettempdir", return_value=str(tmp_path)),
|
||||
):
|
||||
mock_var.is_dir.return_value = False
|
||||
assert _srt_resolve_cwd() == str(tmp_path)
|
||||
|
||||
|
||||
class TestBuildPolicy:
|
||||
"""Policy generation from branch path."""
|
||||
|
||||
def _make_branch(self, tmp_path, name, citizen_class="builder", is_devpulse=False):
|
||||
"""Create a minimal branch structure for testing."""
|
||||
import json
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").mkdir()
|
||||
src_aipass = repo / "src" / "aipass"
|
||||
src_aipass.mkdir(parents=True)
|
||||
|
||||
branch = src_aipass / name
|
||||
branch.mkdir()
|
||||
trinity = branch / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = {
|
||||
"branch_info": {"branch_name": "devpulse" if is_devpulse else name},
|
||||
"identity": {"citizen_class": citizen_class},
|
||||
}
|
||||
(trinity / "passport.json").write_text(json.dumps(passport), encoding="utf-8")
|
||||
|
||||
for shared in ["system_logs", ".ai_central"]:
|
||||
(repo / shared).mkdir()
|
||||
(src_aipass / "memory" / "memory_pool").mkdir(parents=True)
|
||||
(repo / "AIPASS_REGISTRY.json").touch()
|
||||
(src_aipass / "flow" / "flow_json").mkdir(parents=True)
|
||||
|
||||
return branch
|
||||
|
||||
def _make_sibling(self, branch_path, name, with_mail=True, with_dashboard=True):
|
||||
"""Create a sibling branch with mail/dashboard."""
|
||||
src_aipass = branch_path.parent
|
||||
sibling = src_aipass / name
|
||||
sibling.mkdir()
|
||||
if with_mail:
|
||||
(sibling / ".ai_mail.local").mkdir()
|
||||
if with_dashboard:
|
||||
(sibling / "DASHBOARD.local.json").touch()
|
||||
return sibling
|
||||
|
||||
def test_builder_includes_own_tree(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
policy = build_policy(branch)
|
||||
assert str(branch) in policy["allow_write"]
|
||||
|
||||
def test_builder_includes_tmp(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
policy = build_policy(branch)
|
||||
assert "/tmp" in policy["allow_write"]
|
||||
|
||||
def test_builder_includes_shared_channels(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
repo = tmp_path / "repo"
|
||||
policy = build_policy(branch)
|
||||
assert str(repo / "system_logs") in policy["allow_write"]
|
||||
assert str(repo / ".ai_central") in policy["allow_write"]
|
||||
assert str(repo / "AIPASS_REGISTRY.json") in policy["allow_write"]
|
||||
|
||||
def test_builder_excludes_git(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
repo = tmp_path / "repo"
|
||||
policy = build_policy(branch)
|
||||
assert str(repo / ".git") not in policy["allow_write"]
|
||||
|
||||
def test_devpulse_includes_git(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "devpulse", is_devpulse=True)
|
||||
repo = tmp_path / "repo"
|
||||
policy = build_policy(branch)
|
||||
assert str(repo / ".git") in policy["allow_write"]
|
||||
|
||||
def test_sibling_mail_writable(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
sibling = self._make_sibling(branch, "hooks")
|
||||
policy = build_policy(branch)
|
||||
assert str(sibling / ".ai_mail.local") in policy["allow_write"]
|
||||
|
||||
def test_sibling_dashboard_writable(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
sibling = self._make_sibling(branch, "hooks")
|
||||
policy = build_policy(branch)
|
||||
assert str(sibling / "DASHBOARD.local.json") in policy["allow_write"]
|
||||
|
||||
def test_sibling_source_not_writable(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
sibling = self._make_sibling(branch, "hooks")
|
||||
policy = build_policy(branch)
|
||||
assert str(sibling) not in policy["allow_write"]
|
||||
|
||||
def test_policy_shape(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
policy = build_policy(branch)
|
||||
assert "allow_write" in policy
|
||||
assert "deny_write" in policy
|
||||
assert "deny_read" in policy
|
||||
secret = str(tmp_path / "repo" / ".ai_central" / "broker_secret")
|
||||
assert policy["deny_write"] == [secret]
|
||||
assert policy["deny_read"] == [secret]
|
||||
|
||||
def test_broker_secret_masked_for_all_roles(self, tmp_path):
|
||||
"""The broker secret sits inside writable .ai_central — it must be
|
||||
deny_read AND deny_write for every role, or a sandboxed agent could
|
||||
read it and forge a devpulse identity to the broker."""
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
for name in ("seedgo", "devpulse"):
|
||||
base = tmp_path / f"case_{name}"
|
||||
base.mkdir()
|
||||
branch = self._make_branch(base, name)
|
||||
repo_root = base / "repo"
|
||||
policy = build_policy(branch)
|
||||
secret = str(repo_root / ".ai_central" / "broker_secret")
|
||||
assert secret in policy["deny_read"]
|
||||
assert secret in policy["deny_write"]
|
||||
assert str(repo_root / ".ai_central") in policy["allow_write"]
|
||||
|
||||
def test_claude_project_dir_included(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
branch = self._make_branch(tmp_path, "seedgo")
|
||||
encoded = str(branch.resolve()).replace("/", "-")
|
||||
claude_proj = tmp_path / ".claude" / "projects" / encoded
|
||||
claude_proj.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.modules.sandbox.Path.home", return_value=tmp_path):
|
||||
policy = build_policy(branch)
|
||||
|
||||
assert str(claude_proj) in policy["allow_write"]
|
||||
|
||||
def test_no_repo_root_raises(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy
|
||||
|
||||
bare = tmp_path / "no_repo" / "branch"
|
||||
bare.mkdir(parents=True)
|
||||
with pytest.raises(FileNotFoundError, match="No .git found"):
|
||||
build_policy(bare)
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
"""Drone routing for sandbox module."""
|
||||
|
||||
def test_sandbox_no_args_calls_introspection(self):
|
||||
from aipass.hooks.apps.modules.sandbox import handle_command
|
||||
|
||||
result = handle_command("sandbox", [])
|
||||
assert result is True
|
||||
|
||||
def test_sandbox_help(self):
|
||||
from aipass.hooks.apps.modules.sandbox import handle_command
|
||||
|
||||
result = handle_command("sandbox", ["--help"])
|
||||
assert result is True
|
||||
|
||||
def test_unknown_command_returns_false(self):
|
||||
from aipass.hooks.apps.modules.sandbox import handle_command
|
||||
|
||||
result = handle_command("other", [])
|
||||
assert result is False
|
||||
@@ -1,16 +1,14 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_stop_sound.py
|
||||
# Version: 1.2.0
|
||||
# Version: 1.3.0
|
||||
# Description: Tests for stop_sound notification handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-20
|
||||
# Modified: 2026-05-22
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/notification/stop_sound.py."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestStopSoundHandler:
|
||||
"""Core handler behavior tests."""
|
||||
@@ -18,26 +16,23 @@ class TestStopSoundHandler:
|
||||
def test_handle_returns_result_dict(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak"):
|
||||
result = handle({})
|
||||
result = handle({})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handle_speaks_stop_sound(self):
|
||||
def test_handle_sets_sound_key(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak") as mock_speak:
|
||||
handle({})
|
||||
result = handle({})
|
||||
|
||||
mock_speak.assert_called_once_with("stop sound")
|
||||
assert result["sound"] == "stop sound"
|
||||
|
||||
def test_handle_skips_when_stop_hook_active(self):
|
||||
def test_handle_no_sound_when_stop_hook_active(self):
|
||||
from aipass.hooks.apps.handlers.notification.stop_sound import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak") as mock_speak:
|
||||
result = handle({"stop_hook_active": True})
|
||||
result = handle({"stop_hook_active": True})
|
||||
|
||||
mock_speak.assert_not_called()
|
||||
assert result.get("sound", "") == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
@@ -18,24 +18,23 @@ from aipass.hooks.apps.handlers.security.subagent_gate import handle
|
||||
class TestSubagentGateHandler:
|
||||
def test_no_repo_root_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"):
|
||||
result = handle({"cwd": "/tmp/nowhere"})
|
||||
result = handle({"cwd": "/tmp/nowhere"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_no_modified_files_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"):
|
||||
result = handle({"cwd": "/tmp/somewhere"})
|
||||
result = handle({"cwd": "/tmp/somewhere"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None)
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_modified_files_no_violations_allows(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_modified_files_no_violations_allows(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
@@ -43,13 +42,13 @@ class TestSubagentGateHandler:
|
||||
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None)
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_violations_blocks(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_violations_blocks(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
@@ -62,10 +61,10 @@ class TestSubagentGateHandler:
|
||||
assert "Missing docstring" in parsed["reason"]
|
||||
assert "No tests" in parsed["reason"]
|
||||
assert "bad.py" in parsed["reason"]
|
||||
assert result["sound"] == "subagent gate"
|
||||
|
||||
@patch("subprocess.run")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_skip_claude_hooks_from_modified_files(self, mock_speak, mock_run, tmp_path):
|
||||
def test_skip_claude_hooks_from_modified_files(self, mock_run, tmp_path):
|
||||
|
||||
src = tmp_path / "src" / "aipass" / "hooks"
|
||||
src.mkdir(parents=True)
|
||||
@@ -91,8 +90,7 @@ class TestSubagentGateHandler:
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_readme_accountability_advisory(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_readme_accountability_advisory(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
@@ -106,18 +104,18 @@ class TestSubagentGateHandler:
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "allow"
|
||||
assert "README" in parsed["reason"]
|
||||
assert "sound" not in result
|
||||
|
||||
def test_empty_hook_data_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"):
|
||||
result = handle({})
|
||||
result = handle({})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_exception_in_get_modified_allows(self, mock_speak, mock_root, mock_modified):
|
||||
def test_exception_in_get_modified_allows(self, mock_root, mock_modified):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
@@ -125,6 +123,7 @@ class TestSubagentGateHandler:
|
||||
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
|
||||
class TestSubagentGateExternalProject:
|
||||
@@ -161,8 +160,7 @@ class TestSubagentGateExternalProject:
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_violations_block_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_violations_block_external_project(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/vera")
|
||||
@@ -173,13 +171,13 @@ class TestSubagentGateExternalProject:
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "block"
|
||||
assert "Missing docstring" in parsed["reason"]
|
||||
assert result["sound"] == "subagent gate"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None)
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_clean_files_allow_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_clean_files_allow_external_project(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/vera")
|
||||
@@ -187,3 +185,4 @@ class TestSubagentGateExternalProject:
|
||||
result = handle({"cwd": "/fake/vera/src/vera_studio/quality"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_tool_sound.py
|
||||
# Version: 1.2.0
|
||||
# Version: 1.3.0
|
||||
# Description: Tests for tool_sound notification handler
|
||||
# Branch: hooks
|
||||
# Created: 2026-05-19
|
||||
# Modified: 2026-05-22
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/notification/tool_sound.py."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestToolSoundHandler:
|
||||
"""Core handler behavior tests."""
|
||||
@@ -18,8 +16,7 @@ class TestToolSoundHandler:
|
||||
def test_handle_returns_result_dict(self):
|
||||
from aipass.hooks.apps.handlers.notification.tool_sound import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak"):
|
||||
result = handle({"tool_name": "Bash"})
|
||||
result = handle({"tool_name": "Bash"})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "stdout" in result
|
||||
@@ -27,26 +24,23 @@ class TestToolSoundHandler:
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_speaks_tool_name(self):
|
||||
def test_sound_key_includes_tool_name(self):
|
||||
from aipass.hooks.apps.handlers.notification.tool_sound import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak:
|
||||
handle({"tool_name": "Edit"})
|
||||
result = handle({"tool_name": "Edit"})
|
||||
|
||||
mock_speak.assert_called_once_with("tool sound: Edit")
|
||||
assert result["sound"] == "tool sound: Edit"
|
||||
|
||||
def test_no_speak_when_no_tool_name(self):
|
||||
def test_no_sound_when_no_tool_name(self):
|
||||
from aipass.hooks.apps.handlers.notification.tool_sound import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak:
|
||||
handle({})
|
||||
result = handle({})
|
||||
|
||||
mock_speak.assert_not_called()
|
||||
assert result.get("sound", "") == ""
|
||||
|
||||
def test_no_speak_when_empty_tool_name(self):
|
||||
def test_no_sound_when_empty_tool_name(self):
|
||||
from aipass.hooks.apps.handlers.notification.tool_sound import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak:
|
||||
handle({"tool_name": ""})
|
||||
result = handle({"tool_name": ""})
|
||||
|
||||
mock_speak.assert_not_called()
|
||||
assert result.get("sound", "") == ""
|
||||
|
||||
@@ -43,11 +43,11 @@ except ImportError:
|
||||
FileSystemEventHandler = object # type: ignore[assignment,misc]
|
||||
logger.info("Optional dependency 'watchdog' not available")
|
||||
|
||||
# Handler imports (relative within package)
|
||||
from aipass.memory.apps.handlers.tracking.line_counter import update_line_count
|
||||
from aipass.memory.apps.handlers.monitor.detector import check_single_file
|
||||
from aipass.prax.apps.modules.logger import get_system_logger
|
||||
from aipass.memory.apps.handlers.json import json_handler
|
||||
# Handler imports (relative within package — after conditional watchdog block)
|
||||
from aipass.memory.apps.handlers.tracking.line_counter import update_line_count # noqa: E402
|
||||
from aipass.memory.apps.handlers.monitor.detector import check_single_file # noqa: E402
|
||||
from aipass.prax.apps.modules.logger import get_system_logger # noqa: E402
|
||||
from aipass.memory.apps.handlers.json import json_handler # noqa: E402
|
||||
|
||||
logger = get_system_logger()
|
||||
|
||||
@@ -200,8 +200,6 @@ def check_and_rollover() -> Dict[str, Any]:
|
||||
return results
|
||||
|
||||
# Check each branch for memory files over limit
|
||||
# Also sync current_lines metadata to keep it accurate
|
||||
lines_synced = 0
|
||||
for branch_path in branch_paths:
|
||||
branch = Path(branch_path)
|
||||
# Find memory files in .trinity/ subdirectory
|
||||
@@ -213,33 +211,23 @@ def check_and_rollover() -> Dict[str, Any]:
|
||||
results["files_checked"] += 1
|
||||
|
||||
try:
|
||||
line_count = len(memory_file.read_text(encoding="utf-8").splitlines())
|
||||
# Auto-heal: reconcile file against template (strips orphan keys)
|
||||
from aipass.memory.apps.handlers.schema.normalize import normalize_memory_file
|
||||
|
||||
# Sync current_lines metadata if stale
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
_data = _json.loads(memory_file.read_text(encoding="utf-8"))
|
||||
meta_lines = _data.get("document_metadata", {}).get("status", {}).get("current_lines")
|
||||
if meta_lines != line_count:
|
||||
sync_result = update_line_count(memory_file)
|
||||
if sync_result.get("success"):
|
||||
lines_synced += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"[memory_watcher] Non-critical metadata sync failed for {memory_file}: {e}")
|
||||
normalize_memory_file(memory_file)
|
||||
|
||||
# Use detector for trigger decision (handles both v1 line-based and v2 entry-count)
|
||||
from aipass.memory.apps.handlers.monitor.detector import _should_rollover
|
||||
|
||||
triggered, _, _, _, _ = _should_rollover(memory_file)
|
||||
triggered, current_lines, _, _, _ = _should_rollover(memory_file)
|
||||
if triggered:
|
||||
results["files_over_limit"].append(
|
||||
{"file": str(memory_file), "lines": line_count, "threshold": 0}
|
||||
{"file": str(memory_file), "lines": current_lines, "threshold": 0}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[memory_watcher] Failed to read memory file {memory_file}: {e}")
|
||||
|
||||
results["lines_synced"] = lines_synced
|
||||
results["lines_synced"] = 0
|
||||
|
||||
# Trigger rollover if any files are over limit
|
||||
if results["files_over_limit"]:
|
||||
@@ -537,19 +525,23 @@ class MemoryFileWatcher(FileSystemEventHandler): # type: ignore[misc]
|
||||
|
||||
logger.info(f"[memory_watcher] Detected modification: {file_path.name}")
|
||||
|
||||
# Step 1: Update line count metadata
|
||||
# Step 1: Auto-heal schema drift (strips orphan keys)
|
||||
from aipass.memory.apps.handlers.schema.normalize import normalize_memory_file
|
||||
|
||||
norm_result = normalize_memory_file(file_path)
|
||||
if norm_result.get("changes"):
|
||||
self._recent_modifications.add(file_key)
|
||||
|
||||
# Step 2: Update health check metadata
|
||||
update_result = update_line_count(file_path)
|
||||
|
||||
if not update_result["success"]:
|
||||
logger.error(
|
||||
f"[memory_watcher] Failed to update line count for {file_path.name}: {update_result.get('error')}"
|
||||
f"[memory_watcher] Failed to update metadata for {file_path.name}: {update_result.get('error')}"
|
||||
)
|
||||
return
|
||||
|
||||
current_lines = update_result.get("lines", 0)
|
||||
logger.info(f"[memory_watcher] Updated {file_path.name}: {current_lines} lines")
|
||||
|
||||
# Step 2: Check if rollover needed
|
||||
# Step 3: Check if rollover needed
|
||||
check_result = check_single_file(file_path)
|
||||
|
||||
if not check_result["success"]:
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: normalize.py
|
||||
# Description: Memory File Schema Normalizer
|
||||
# Version: 0.2.0
|
||||
# Version: 0.3.0
|
||||
# Created: 2026-01-22
|
||||
# Modified: 2026-03-06
|
||||
# Modified: 2026-06-08
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Memory File Schema Normalizer
|
||||
|
||||
Fixes inconsistent schema in memory JSON files:
|
||||
1. Moves root-level 'limits' into document_metadata.limits
|
||||
2. Removes redundant root-level 'status'
|
||||
3. Removes auto_compress_at (redundant with max_lines)
|
||||
4. Ensures document_metadata.status has current_lines
|
||||
|
||||
Supports two schema versions:
|
||||
v1 (schema_version <2.0.0): { "limits": { "max_lines": N } }
|
||||
v2 (schema_version >=2.0.0): { "limits": { "max_sessions": N, "max_key_learnings": N,
|
||||
"session_summary_max_chars": N, "learning_value_max_chars": N } }
|
||||
Reconciles memory files against their canonical template schema.
|
||||
Strips any key not present in the template at every level (root,
|
||||
document_metadata, limits, status). Template = the whole truth.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -31,6 +24,36 @@ from aipass.memory.apps.handlers.json import json_handler
|
||||
|
||||
logger = get_system_logger()
|
||||
|
||||
_MEMORY_ROOT = Path(__file__).parents[3] # normalize.py -> schema/ -> handlers/ -> apps/ -> memory/
|
||||
|
||||
|
||||
def _load_template(file_path: Path) -> Dict[str, Any] | None:
|
||||
"""Load the matching template for a memory file (local or observations)."""
|
||||
templates_dir = _MEMORY_ROOT / "templates"
|
||||
name = file_path.name.lower()
|
||||
|
||||
if "local" in name:
|
||||
tmpl_path = templates_dir / "LOCAL.template.json"
|
||||
elif "observation" in name:
|
||||
tmpl_path = templates_dir / "OBSERVATIONS.template.json"
|
||||
else:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(tmpl_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"[normalize] Failed to load template {tmpl_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _strip_orphan_keys(data: Dict, allowed: set, level_name: str, changes: list) -> None:
|
||||
"""Remove keys from data that aren't in the allowed set."""
|
||||
orphans = set(data.keys()) - allowed
|
||||
for key in orphans:
|
||||
del data[key]
|
||||
changes.append(f"Stripped orphan '{key}' from {level_name}")
|
||||
|
||||
|
||||
def _find_repo_root() -> Path:
|
||||
"""Walk up from this file to find repo root (contains AIPASS_REGISTRY.json)."""
|
||||
@@ -43,7 +66,7 @@ def _find_repo_root() -> Path:
|
||||
|
||||
def normalize_memory_file(file_path: Path, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Normalize schema for a single memory file.
|
||||
Normalize a memory file against its canonical template.
|
||||
|
||||
Args:
|
||||
file_path: Path to memory JSON file
|
||||
@@ -71,59 +94,53 @@ def normalize_memory_file(file_path: Path, dry_run: bool = False) -> Dict[str, A
|
||||
|
||||
metadata = data["document_metadata"]
|
||||
|
||||
# 1. Move root 'limits' into document_metadata.limits
|
||||
# Legacy fix: move root 'limits' into document_metadata.limits
|
||||
if "limits" in data and "limits" not in metadata:
|
||||
metadata["limits"] = data.pop("limits")
|
||||
changes.append("Moved root 'limits' into document_metadata")
|
||||
elif "limits" in data and "limits" in metadata:
|
||||
# Both exist - merge, preferring document_metadata values
|
||||
root_limits = data.pop("limits")
|
||||
for key, val in root_limits.items():
|
||||
if key not in metadata["limits"]:
|
||||
metadata["limits"][key] = val
|
||||
changes.append("Merged root 'limits' into document_metadata.limits")
|
||||
|
||||
# 2. Remove root 'status' (redundant)
|
||||
# Legacy fix: move root 'status' into document_metadata.status
|
||||
if "status" in data:
|
||||
root_status = data.pop("status")
|
||||
# If document_metadata.status doesn't have current_lines, copy it
|
||||
data.pop("status")
|
||||
if "status" not in metadata:
|
||||
metadata["status"] = {}
|
||||
if "current_lines" not in metadata["status"] and "current_lines" in root_status:
|
||||
metadata["status"]["current_lines"] = root_status["current_lines"]
|
||||
changes.append("Removed redundant root 'status'")
|
||||
|
||||
# 3. Remove auto_compress_at from document_metadata.status (redundant with max_lines)
|
||||
if "status" in metadata and "auto_compress_at" in metadata["status"]:
|
||||
del metadata["status"]["auto_compress_at"]
|
||||
changes.append("Removed redundant 'auto_compress_at'")
|
||||
|
||||
# 4. Remove unused limits fields (max_word_count, max_token_count - no code uses these)
|
||||
# Preserve v2 fields: max_sessions, max_key_learnings, session_summary_max_chars, learning_value_max_chars,
|
||||
# max_observations, max_lines, note
|
||||
if "limits" in metadata:
|
||||
for unused_field in ["max_word_count", "max_token_count"]:
|
||||
if unused_field in metadata["limits"]:
|
||||
del metadata["limits"][unused_field]
|
||||
changes.append(f"Removed unused '{unused_field}'")
|
||||
|
||||
# 4. Ensure status has required fields
|
||||
# Ensure status has required fields
|
||||
if "status" not in metadata:
|
||||
metadata["status"] = {}
|
||||
|
||||
if "current_lines" not in metadata["status"]:
|
||||
# Count actual lines
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
metadata["status"]["current_lines"] = len(f.readlines())
|
||||
changes.append("Added current_lines count")
|
||||
except Exception as e:
|
||||
logger.warning(f"[normalize] Failed to count lines in {file_path}: {e}")
|
||||
|
||||
if "last_health_check" not in metadata["status"]:
|
||||
metadata["status"]["last_health_check"] = datetime.now().strftime("%Y-%m-%d")
|
||||
changes.append("Added last_health_check")
|
||||
|
||||
# Template-conformance: strip orphan keys at every level
|
||||
template = _load_template(file_path)
|
||||
if template is not None:
|
||||
tmpl_meta = template.get("document_metadata", {})
|
||||
|
||||
# Root level
|
||||
_strip_orphan_keys(data, set(template.keys()), "root", changes)
|
||||
|
||||
# document_metadata level
|
||||
_strip_orphan_keys(metadata, set(tmpl_meta.keys()), "document_metadata", changes)
|
||||
|
||||
# limits level
|
||||
tmpl_limits = tmpl_meta.get("limits", {})
|
||||
if "limits" in metadata:
|
||||
_strip_orphan_keys(metadata["limits"], set(tmpl_limits.keys()), "limits", changes)
|
||||
|
||||
# status level
|
||||
tmpl_status = tmpl_meta.get("status", {})
|
||||
if "status" in metadata:
|
||||
_strip_orphan_keys(metadata["status"], set(tmpl_status.keys()), "status", changes)
|
||||
|
||||
# Write if changes made and not dry run
|
||||
if changes and not dry_run:
|
||||
try:
|
||||
|
||||
@@ -62,24 +62,20 @@ def _count_physical_lines(file_path: Path) -> int:
|
||||
|
||||
def update_line_count(file_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Update current_lines in document_metadata.status
|
||||
|
||||
Reads file, counts lines, updates metadata field using safe json_handler.
|
||||
Update health check metadata after file modification.
|
||||
|
||||
Args:
|
||||
file_path: Path to memory JSON file
|
||||
|
||||
Returns:
|
||||
Dict with success status and updated line count
|
||||
Dict with success status
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return {"success": False, "error": f"File not found: {file_path}"}
|
||||
|
||||
# Count lines
|
||||
line_count = _count_physical_lines(file_path)
|
||||
|
||||
# Update metadata using safe handler (atomic write)
|
||||
result = update_metadata(file_path, current_lines=line_count, last_health_check=datetime.now().strftime("%Y-%m-%d"))
|
||||
result = update_metadata(file_path, last_health_check=datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
if not result["success"]:
|
||||
return {"success": False, "error": f"Failed to update metadata: {result['error']}"}
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
},
|
||||
"status": {
|
||||
"health": "healthy",
|
||||
"last_health_check": "{{DATE}}",
|
||||
"current_lines": 0
|
||||
"last_health_check": "{{DATE}}"
|
||||
}
|
||||
},
|
||||
"key_learnings": {},
|
||||
|
||||
@@ -13,12 +13,11 @@
|
||||
"{{BRANCHNAME}}"
|
||||
],
|
||||
"limits": {
|
||||
"max_lines": 600,
|
||||
"note": "DO NOT trim, prune, or delete entries. Auto-rollover to @memory when max_lines exceeded."
|
||||
"max_observations": 25,
|
||||
"note": "DO NOT trim, prune, or delete entries. Auto-rollover to @memory when max_observations exceeded."
|
||||
},
|
||||
"status": {
|
||||
"health": "healthy",
|
||||
"current_lines": 0,
|
||||
"last_health_check": "{{DATE}}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -350,7 +350,7 @@ class TestCountPhysicalLines:
|
||||
|
||||
def test_counts_lines_correctly(self, monkeypatch, tmp_path):
|
||||
lc, _ = _import_line_counter(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
f.write_text("line1\nline2\nline3\n", encoding="utf-8")
|
||||
assert lc._count_physical_lines(f) == 3
|
||||
|
||||
@@ -376,7 +376,7 @@ class TestUpdateLineCount:
|
||||
|
||||
def test_updates_line_count_successfully(self, monkeypatch, tmp_path):
|
||||
lc, mocks = _import_line_counter(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
f.write_text('{\n "a": 1\n}\n', encoding="utf-8")
|
||||
|
||||
result = lc.update_line_count(f)
|
||||
@@ -387,7 +387,7 @@ class TestUpdateLineCount:
|
||||
def test_reports_failure_when_metadata_update_fails(self, monkeypatch, tmp_path):
|
||||
lc, mocks = _import_line_counter(monkeypatch)
|
||||
mocks["memory_files"].update_metadata.return_value = {"success": False, "error": "write error"}
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
f.write_text("{}\n", encoding="utf-8")
|
||||
|
||||
result = lc.update_line_count(f)
|
||||
@@ -413,12 +413,12 @@ class TestNormalizeMemoryFile:
|
||||
|
||||
def test_moves_root_limits_into_metadata(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
self._write_json(
|
||||
f,
|
||||
{
|
||||
"document_metadata": {"status": {"current_lines": 10}},
|
||||
"limits": {"max_lines": 600},
|
||||
"document_metadata": {"status": {}},
|
||||
"limits": {"max_sessions": 20},
|
||||
"sessions": [],
|
||||
},
|
||||
)
|
||||
@@ -427,19 +427,19 @@ class TestNormalizeMemoryFile:
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert "limits" not in {k for k in data if k != "document_metadata"}
|
||||
assert data["document_metadata"]["limits"]["max_lines"] == 600
|
||||
assert data["document_metadata"]["limits"]["max_sessions"] == 20
|
||||
|
||||
def test_merges_root_limits_preserving_metadata_values(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
self._write_json(
|
||||
f,
|
||||
{
|
||||
"document_metadata": {
|
||||
"limits": {"max_lines": 500},
|
||||
"status": {"current_lines": 10},
|
||||
"limits": {"max_sessions": 20},
|
||||
"status": {},
|
||||
},
|
||||
"limits": {"max_lines": 600, "extra_field": 42},
|
||||
"limits": {"max_sessions": 30, "max_key_learnings": 25},
|
||||
"sessions": [],
|
||||
},
|
||||
)
|
||||
@@ -447,19 +447,19 @@ class TestNormalizeMemoryFile:
|
||||
assert result["success"] is True
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
# metadata value (500) wins over root value (600)
|
||||
assert data["document_metadata"]["limits"]["max_lines"] == 500
|
||||
# extra_field from root gets merged in
|
||||
assert data["document_metadata"]["limits"]["extra_field"] == 42
|
||||
# metadata value (20) wins over root value (30)
|
||||
assert data["document_metadata"]["limits"]["max_sessions"] == 20
|
||||
# valid key from root gets merged in
|
||||
assert data["document_metadata"]["limits"]["max_key_learnings"] == 25
|
||||
|
||||
def test_removes_root_status(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
self._write_json(
|
||||
f,
|
||||
{
|
||||
"document_metadata": {"status": {"current_lines": 10}},
|
||||
"status": {"health": "ok", "current_lines": 5},
|
||||
"document_metadata": {"status": {"last_health_check": "2026-01-01"}},
|
||||
"status": {"health": "ok"},
|
||||
"sessions": [],
|
||||
},
|
||||
)
|
||||
@@ -472,12 +472,12 @@ class TestNormalizeMemoryFile:
|
||||
|
||||
def test_removes_auto_compress_at(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
self._write_json(
|
||||
f,
|
||||
{
|
||||
"document_metadata": {
|
||||
"status": {"current_lines": 10, "auto_compress_at": 500},
|
||||
"status": {"auto_compress_at": 500, "last_health_check": "2026-01-01"},
|
||||
},
|
||||
"sessions": [],
|
||||
},
|
||||
@@ -490,7 +490,7 @@ class TestNormalizeMemoryFile:
|
||||
|
||||
def test_dry_run_does_not_write(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
original = {
|
||||
"document_metadata": {},
|
||||
"limits": {"max_lines": 600},
|
||||
@@ -508,13 +508,13 @@ class TestNormalizeMemoryFile:
|
||||
|
||||
def test_no_changes_when_already_normalized(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
self._write_json(
|
||||
f,
|
||||
{
|
||||
"document_metadata": {
|
||||
"limits": {"max_sessions": 20},
|
||||
"status": {"current_lines": 10, "last_health_check": "2026-03-31"},
|
||||
"status": {"last_health_check": "2026-03-31"},
|
||||
},
|
||||
"sessions": [],
|
||||
},
|
||||
@@ -525,13 +525,13 @@ class TestNormalizeMemoryFile:
|
||||
|
||||
def test_removes_unused_limit_fields(self, monkeypatch, tmp_path):
|
||||
norm, _ = _import_normalize(monkeypatch)
|
||||
f = tmp_path / "test.json"
|
||||
f = tmp_path / "test.local.json"
|
||||
self._write_json(
|
||||
f,
|
||||
{
|
||||
"document_metadata": {
|
||||
"limits": {"max_lines": 600, "max_word_count": 9999, "max_token_count": 5000},
|
||||
"status": {"current_lines": 10, "last_health_check": "2026-03-31"},
|
||||
"limits": {"max_sessions": 20, "max_lines": 600, "max_word_count": 9999, "max_token_count": 5000},
|
||||
"status": {"last_health_check": "2026-03-31"},
|
||||
},
|
||||
"sessions": [],
|
||||
},
|
||||
@@ -542,7 +542,8 @@ class TestNormalizeMemoryFile:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert "max_word_count" not in data["document_metadata"]["limits"]
|
||||
assert "max_token_count" not in data["document_metadata"]["limits"]
|
||||
assert data["document_metadata"]["limits"]["max_lines"] == 600
|
||||
assert "max_lines" not in data["document_metadata"]["limits"]
|
||||
assert data["document_metadata"]["limits"]["max_sessions"] == 20
|
||||
|
||||
|
||||
class TestTodosOperational:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user