Compare commits

..
21 Commits
Author SHA1 Message Date
AIPass cf6aa37d1e Merge pull request #638 from AIOSAI/dev
Git law + head-move discipline in sunday_merge playbook (S208 incident)
2026-06-11 13:22:59 -07:00
AIOSAI 2af856d81d chore(release): v2.5.3 — date-based CHANGELOG headers + rename sunday_merge playbook to merge (drop weekly cadence) 2026-06-11 12:49:07 -07:00
AIOSAI 54d55abebc feat(aipass): init detects missing Claude Code, offers install (yes/no) 2026-06-11 00:12:32 -07:00
AIOSAI ed17630b76 fix(drone): broker start_background blocks until listening — kill connect-before-bind race 2026-06-10 23:23:56 -07:00
AIOSAI 707f54a6f2 fix(ci): guard remaining AF_UNIX broker tests for Windows — ai_mail + aipass 2026-06-10 23:04:52 -07:00
AIOSAIandClaude Opus 4.8 e33cf2bfbe fix(ci): guard Linux-only sandbox tests for Windows — skipif(sys.platform != linux)
test_broker.py (AF_UNIX sockets + openat2) and test_sandbox.py (bwrap) are
Linux-only; module-level pytestmark skips them on windows-latest while leaving
Linux runs unchanged. Unblocks the windows-setup CI check (red since the
sandbox build 0b4ba63).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:50:44 -07:00
AIOSAIandClaude Opus 4.8 53340ab169 fix(seedgo): audit local==CI parity — output-dir skips + diagnostics fail-honesty + pyright determinism (FPLAN-0261)
Hoist per-checker SKIP_DIRS to shared SOURCE_SKIP_DIRS (artifacts/dropbox are
output dirs, not source; no git coupling). Diagnostics: python3->sys.executable,
parse/run failures now fail loud instead of silent 0-errors-clean, and pin
pyright resolution with --pythonpath sys.executable. drone bypasses test-only
broker start_background. Proven all-13-branches-100% deterministic in an
unactivated shell (local==CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:09:01 -07:00
AIOSAI 17863ac4c5 refactor(shared): re-home aipass.common into its steward — src/aipass/aipass/shared (FPLAN-0260)
src/aipass/common was the only non-citizen directory in the agent namespace.
Per @seedgo design review: moved into @aipass (owner) as aipass.aipass.shared,
content byte-identical. @spawn imports across (blessed shared-infra category,
same as aipass.prax/cli). New subprocess guard test pins the bootstrap-safety
invariant (shared/ loads zero branch deps — aipass init stays pre-drone-safe).
9 import/doc sites updated, 9 documented seedgo bypasses (pre-infra leaf,
stdlib-only by design). aipass 480 + spawn 315 tests green, both audits 100%,
repo-wide zero refs to the old path.
2026-06-10 18:26:26 -07:00
AIOSAIandClaude Opus 4.8 0b4ba63fae feat(sandbox): kernel filesystem boundary for agent containment (DPLAN-0202/FPLAN-0250)
Every autonomous agent can launch inside a kernel-enforced mount namespace
(srt -> bwrap+seccomp): reads stay open (shared live FS preserved, bind-mount not
isolation; own-tree writes land live), but rm/python/find/Write on .git or sibling
trees hit EROFS. /tmp + own tree writable; .git RW devpulse, RO builders. Inert by
default behind AIPASS_SANDBOX_ENABLED (off); flag-off path byte-identical to old.

hooks: srt wrapper + per-role build_policy + broker_secret mask; rm_gate demoted.
drone: out-of-sandbox broker (identity allowlist, openat2 RESOLVE_BENEATH, HMAC
handshake over inherited fd, audit); drone rm via broker when sandboxed.
ai_mail: dispatch gate + broker-fd wiring (fail-loud exit -4, never silent).
aipass: doctor Sandbox group + setup.sh prereqs (LOUD on missing).

Proven by a live 16-check red-team suite. seedgo 100% + 2859 tests green across
all 5 touched branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:16:22 -07:00
AIOSAIandClaude Opus 4.8 0c6e8ac425 fix(hooks): auto_watchdog sound-migration (FPLAN-0249 tail)
Same action-gated pattern as the notification handlers — speak() -> 'sound'
return-key. Missed in b26bd7c. Hooks suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:10:27 -07:00
AIOSAIandClaude Opus 4.8 b26bd7c853 fix(hooks): cadence sound-migration tail — action-gated sound via return-key (FPLAN-0249)
Notification handlers (announce, email, stop_sound, tool_sound) return a
'sound' key the engine plays on action instead of calling speak() on every
invocation — quieter and honest (skipped loaders stay silent). Slim
cadence_investigation.md. Tests updated to assert the return-key form. 472/472
hooks green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:09:32 -07:00
AIOSAIandClaude Fable 5 00edd8b3a0 fix(hooks): auto_fix ruff legs were silently dead — invoke via venv interpreter
Three ruff call sites called bare `ruff`, absent from the hook subprocess
PATH (ruff lives only in .venv) — logged 'ruff not found' 177x over ~a month,
silently skipping lint+format on every edit. Also `--output-format=text` was
removed from modern ruff. Fixed all three sites to `sys.executable -m ruff`
(the pattern the working pyright leg already uses) + concise format + honest
rc>=2 error logging. Tests now pin the invocation (argv == sys.executable -m
ruff) so a regression fails loud — the subprocess-mock is how this hid.
438/438 hooks tests green; live-verified through the real hook pipeline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:34:38 -07:00
AIOSAIandClaude Fable 5 c3c6c2dde7 docs(changelog): slim global prompt (DPLAN-0201) + prax hook-color fix (td-007)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:17:19 -07:00
AIOSAIandClaude Fable 5 2aafade678 feat(prompt): slim global prompt to context-on-demand map, 13.8KB->7.8KB (DPLAN-0201)
Rewrite the always-injected global prompt from a ~13.8KB encyclopedia
into a ~7.8KB navigation map. The prompt now carries AWARENESS; detail
is fetched on demand via 'drone @agent --help'. Dissolves the harness
~10k-char truncation bug — the old prompt's tail (Hard Rules onward)
silently never arrived; the slim one injects whole.

- drone pinned at top as the router; one drilled reflex: --help before use
- framework tree restored; all 13 agents as 2-3 sentence bios
- introspection named as our term; breadcrumb-first navigation flow
- git its own section (raw git/gh blocked, drone-only, devpulse-writes)
- plans section (DPLAN/FPLAN/PPLAN/RPLAN + 'drone @flow templates')
- @memory chroma awareness (local + global stores, search before cold)
- sub-agent usage section incl. model practice (never fable)
- PROMPT_STYLE-conformant; 7855 chars (cap 8000, measured in chars)

Backup retained: .aipass/aipass_global_prompt.BACKUP-2026-06-09-S211.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:16:29 -07:00
AIOSAIandClaude Fable 5 73aedef20f fix(prax): hook events render styled in monitor — regex required phantom action= field (td-007)
Root cause: _HOOK_PATTERN required an action= key that cadence never
emits — it logs the action as the bare second word (fired/skipped).
Extraction failed, so events never reached the styled print_hook_event
renderer (bold-green lightning / dim dot). Pipeline was already correct.

- _HOOK_PATTERN -> bare-word capture: r'\[HOOKS\]\s+(\w+)\s+(\w+)'
- enriched hook event detail (period, offset, short session id)
- corrected docstring that documented the phantom action= format
- tests updated to real production format + real-pipeline test added
- type:ignore on watchdog imports (repo convention)

Verified: 914/914 prax suite green (90 log_watcher). @prax dispatched
for full-pipeline trace; stale monitor process explained the no-show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:16:16 -07:00
AIOSAIandClaude Fable 5 fc4b263504 fix(hooks): cadence separate-process race + action-gated sound + auto_fix diagnostics regression (DPLAN-0200, FPLAN-0249)
Cadence redo — verified against the live execution model, not unit tests:
- Counter now advances exactly once per real turn (mtime debounce +
  transcript-size token + flock). Fixes the separate-process leapfrog where
  global/branch loaders double-incremented and fired erratically.
- Structured [HOOKS] cadence fired|skipped logging; prax monitor renders
  hook events distinctly for live visibility.
- Action-gated sound: handlers return a 'sound' key the engine plays only on
  real action — skipped loaders are silent (no more false piper every turn).
- Fixed auto_fix.py: leftover speak() NameError (swallowed by broad except)
  meant diagnostics silently never ran on any edit. Removed; sound moved to
  the error path.
- Tests rewritten to model separate-process execution (leapfrog regression
  test added); sound assertions across all refactored handlers. 438 pass.

prax: hook fire/skip event rendering in the live monitor. 913 pass.

README: hardcoded metrics (version/tests/PRs/standards) -> live PyPI+codecov
badges and qualitative wording; killed the 33-vs-36 drift. CHANGELOG W24.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:28:26 -07:00
AIOSAI 2bccf0311e feat(hooks): prompt injection cadence — fire loaders every Nth turn, config-tunable (DPLAN-0200, FPLAN-0249)
Stop re-injecting the global + branch prompts every turn (~3k tokens/turn).
They now fire together every 5th turn; the prior injection persists in context
between fires. Identity + email stay every-turn.

- apps/modules/cadence.py: per-session turn counter (/tmp/aipass-cadence-
  {session_id}.json), should_fire(loader)/reset_counter(), DEFAULTS + deep-merge
  config (api provider.py pattern). 'drone @hooks cadence' introspection.
- global_loader.py + branch_loader.py: cadence guard via importlib (crash-
  isolated); non-fire turn returns empty.
- compact.py: PreCompact resets counter to -1 -> next turn = 0 = all fire
  (rebuild context after compaction). New session = fresh counter = all fire.
- hooks_json/custom_config/cadence_config.json: tunable knob (period/offsets/
  enabled), one file, no code edits. Data lives in the json home, not the code
  dir. Missing file = code DEFAULTS = safe.
- .seedgo/bypass: documented stdlib-json config read (json_handler N/A for a
  dispatch engine).

435 tests pass (26 new), seedgo 100%, pyright 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:07:36 -07:00
AIOSAI d24887b7f5 feat(memory): template-conformance normalize + auto-heal on triggers (DPLAN-0200)
Memory files now reconcile to template, not just clean known fields:
- normalize.py: rewrote from field-targeted cleanup to template-conformance —
  strips ANY key not in the template at every level (root/metadata/limits/status).
  Kills legacy orphans (old 'st' blocks, active_tasks, current_lines, max_lines)
  that field-targeted cleanup was blind to. Fixed _MEMORY_ROOT path (parents[3])
  that silently skipped template loading in production.
- memory_watcher.py: wired normalize_memory_file into both scan paths
  (check_and_rollover + on_modified) with a write-loop guard — drift now
  self-heals on every trigger, no manual run needed.
- line_counter.py: stop writing current_lines (entry-count is the only metric).
- LOCAL/OBSERVATIONS templates: removed line-count fields.

Entry-count is the sole rollover metric, both files, all branches.
873 tests pass, seedgo 100%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:07:24 -07:00
AIOSAI a53ea93b17 fix(ai_mail): preserve multi-line reply/send bodies — join args[1:]
Reply and send silently truncated multi-line bodies to the first CLI arg.
handle_reply(args[1]) and parse_send_args(rest[1]) dropped args[2:]/rest[2:]
when a body word-split into multiple args. Now join all remaining args.
Backwards-compatible; single-arg messages unchanged. +6 tests (718 total).

Found via @hooks replies arriving as first-line-only (60/48 chars).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:27:23 -07:00
AIOSAI ff9cc3f3b5 docs(playbook): strengthen — never move HEAD lightly (Patrick's rule) 2026-06-08 11:29:48 -07:00
AIOSAI e97130ffbc docs(playbook)+memory: git law — local=truth, never checkout main / move heads lightly; squash vs ff realign corrected 2026-06-08 11:28:33 -07:00
113 changed files with 7909 additions and 1024 deletions
+97 -226
View File
@@ -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.
+132 -7
View File
@@ -2,13 +2,138 @@
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.W24] - 2026-06-08
## [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
@@ -28,7 +153,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
---
## [2026.W23] - 2026-06-02
## [2026-06-02]
### Fixed
@@ -369,7 +494,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
---
## [2026.W22] - 2026-05-30
## [2026-05-30]
### Added
@@ -431,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
+14 -14
View File
@@ -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 | [![PyPI](https://img.shields.io/pypi/v/aipass?label=)](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 | [![codecov](https://codecov.io/gh/AIOSAI/AIPass/graph/badge.svg)](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
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "2.5.2"
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"
+86
View File
@@ -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
+1 -1
View File
@@ -4,4 +4,4 @@ pip install aipass
https://github.com/AIOSAI/AIPass
"""
__version__ = "2.5.2"
__version__ = "2.5.3"
+6 -1
View File
@@ -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
+2 -1
View File
@@ -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"
+37
View File
@@ -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,
+61 -1
View File
@@ -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")
+108 -3
View File
@@ -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()
+1 -1
View File
@@ -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:
+1
View File
@@ -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.
@@ -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.
+60
View File
@@ -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"):
+2 -2
View File
@@ -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
View File
@@ -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.
+25
View File
@@ -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": {
+67
View File
@@ -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": {
+13 -5
View File
@@ -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]
+119
View File
@@ -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")
+12
View File
@@ -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)
+853
View File
@@ -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()
+1 -1
View File
@@ -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.
@@ -1,17 +1,17 @@
# {plan_number} - {subject} (SUNDAY MERGE)
# {plan_number} - {subject} (MERGE)
**Created**: {today}
**Branch**: {location}
**Status**: Active
**Type**: Playbook — Sunday Merge SOP
**Type**: Playbook — 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.
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
@@ -21,24 +21,65 @@ the vectorized trail. Close when done.
---
## 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 this week — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok)
- [ ] 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 week?** (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.)
- [ ] 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 the current week's `[YYYY.WNN]` section (don't batch; mostly done as work landed). Sort into Added / Changed / Fixed.
- [ ] 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 "Week summary: what's shipping"`
- [ ] `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
@@ -62,18 +103,19 @@ The PR gate (verified against `.github/workflows/`):
## 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`)
- [ ] **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** (keeps the version from inflating weekly):
- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only → the default, most weeks
**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 keeps its `YYYY.WNN` header as a date index.)
(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).
@@ -84,10 +126,11 @@ How the release fires (verified `publish.yml`): a `v*` **git tag push** runs bui
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
- [ ] After merge + `drone @git sync`, get the **real** merged-main sha: `git rev-parse HEAD`. **Verify the version on that exact commit BEFORE tagging:** `git show HEAD:pyproject.toml | grep '^version'` and `git show HEAD:src/aipass/__init__.py | grep __version__` — both must equal the tag.
- [ ] **Push the tag — MANUAL (drone has no `tag` verb; devpulse can't push tags):** user runs it, via `!` or terminal. **Two SEPARATE lines, paste the real sha (no `<…>` placeholders, no `&&`):**
- [ ] 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 tag v<version> <real-sha-from-rev-parse>
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'])"`)
@@ -116,7 +159,7 @@ Steps:
## Listen (TTS-friendly summary)
Write a plain English summary of this Sunday merge here when done. No markdown, no symbols,
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.
---
File diff suppressed because it is too large Load Diff
+37 -6
View File
@@ -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);
}
+288
View File
@@ -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
+9
View File
@@ -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
+284
View File
@@ -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.
+6 -10
View File
@@ -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"
+35 -25
View File
@@ -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):
+26 -11
View File
@@ -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"]
+491
View File
@@ -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
+15 -18
View File
@@ -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
+23 -38
View File
@@ -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({})
+19 -9
View File
@@ -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"
+13 -15
View File
@@ -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
+15 -17
View File
@@ -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
+487
View 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
+9 -14
View File
@@ -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
+19 -20
View File
@@ -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
+12 -18
View File
@@ -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}}"
}
},
+28 -27
View File
@@ -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:
+74
View File
@@ -71,6 +71,21 @@ def _prepare_watcher_mocks(monkeypatch):
monkeypatch.setitem(sys.modules, "watchdog.observers", mock_watchdog_observers)
monkeypatch.setitem(sys.modules, "watchdog.events", mock_watchdog_events)
# Mock normalize (lazy import inside on_modified and check_and_rollover)
mock_normalize_memory_file = MagicMock(return_value={"success": True, "changes": []})
mock_normalize = MagicMock()
mock_normalize.normalize_memory_file = mock_normalize_memory_file
monkeypatch.setitem(
sys.modules,
"aipass.memory.apps.handlers.schema",
MagicMock(),
)
monkeypatch.setitem(
sys.modules,
"aipass.memory.apps.handlers.schema.normalize",
mock_normalize,
)
# Mock rollover orchestrator (lazy import inside on_modified)
mock_execute_rollover = MagicMock(return_value={"success": True})
mock_orchestrator = MagicMock()
@@ -92,6 +107,7 @@ def _prepare_watcher_mocks(monkeypatch):
"observer_instance": mock_observer_instance,
"observer_cls": mock_observer_cls,
"execute_rollover": mock_execute_rollover,
"normalize_memory_file": mock_normalize_memory_file,
}
@@ -495,3 +511,61 @@ class TestMemoryFileWatcherOnModified:
watcher.on_modified(event)
mocks["execute_rollover"].assert_called_once()
def test_normalize_called_on_modification(self, monkeypatch):
"""on_modified calls normalize_memory_file before update_line_count."""
mod, mocks = _import_watcher(monkeypatch)
watcher = mod.MemoryFileWatcher()
event = MagicMock()
event.is_directory = False
event.src_path = "/some/branch/.trinity/local.json"
watcher.on_modified(event)
mocks["normalize_memory_file"].assert_called_once()
mocks["update_line_count"].assert_called_once()
def test_normalize_changes_guard_write_loop(self, monkeypatch):
"""When normalize makes changes, file_key is added to _recent_modifications to prevent write-loop."""
mod, mocks = _import_watcher(monkeypatch)
mocks["normalize_memory_file"].return_value = {
"success": True,
"changes": ["Stripped orphan 'stale_key' from root"],
}
watcher = mod.MemoryFileWatcher()
event = MagicMock()
event.is_directory = False
event.src_path = "/some/branch/.trinity/local.json"
watcher.on_modified(event)
from pathlib import Path as _P
file_key = str(_P("/some/branch/.trinity/local.json"))
assert file_key in watcher._recent_modifications
def test_normalize_no_changes_no_guard(self, monkeypatch):
"""When normalize makes no changes, _recent_modifications is not populated by normalize step."""
mod, mocks = _import_watcher(monkeypatch)
mocks["normalize_memory_file"].return_value = {
"success": True,
"changes": [],
}
watcher = mod.MemoryFileWatcher()
event = MagicMock()
event.is_directory = False
event.src_path = "/some/branch/.trinity/local.json"
watcher.on_modified(event)
from pathlib import Path as _P
file_key = str(_P("/some/branch/.trinity/local.json"))
# file_key should NOT be in _recent_modifications from normalize (may be from rollover)
mocks["check_single_file"].return_value = {"success": True, "should_rollover": False}
assert file_key not in watcher._recent_modifications
@@ -6,7 +6,7 @@ Handlers for file watching, log monitoring, branch detection, and filtering.
"""
# Export main handler interfaces
from .unified_stream import print_event, print_command_separator
from .unified_stream import print_event, print_command_separator, print_hook_event
from .branch_detector import detect_branch_from_path
from .interactive_filter import (
parse_command,
@@ -34,6 +34,7 @@ from .log_watcher import start_log_watcher, stop_log_watcher, is_log_watcher_act
__all__ = [
"print_event",
"print_command_separator",
"print_hook_event",
"detect_branch_from_path",
"parse_command",
"get_help_text",
@@ -28,8 +28,8 @@ from typing import Optional, Dict, Any
import re
from aipass.prax.apps.modules.logger import get_direct_logger
from watchdog.observers import Observer as WatchdogObserver
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer as WatchdogObserver # type: ignore
from watchdog.events import FileSystemEventHandler # type: ignore
# Import from prax config
from aipass.prax.apps.handlers.config.load import get_system_logs_dir
@@ -102,9 +102,13 @@ class LogFileWatcher(FileSystemEventHandler):
self.last_command_per_branch: Dict[str, str] = {}
def _process_log_line(self, branch: str, line: str, file_path: str) -> None:
"""Process a single log line: detect commands or emit as log event."""
"""Process a single log line: detect hooks, commands, or emit as log event."""
if not line.strip():
return
hook_info = self._extract_hook_info(line)
if hook_info:
self._emit_hook_event(branch, hook_info)
return
command_info = self._extract_command_info(line)
if command_info:
self._emit_command_separator(branch, command_info)
@@ -159,6 +163,63 @@ class LogFileWatcher(FileSystemEventHandler):
except Exception as e:
logger.info(f"Error reading log file {file_path}: {e}")
_HOOK_PATTERN = re.compile(r"\[HOOKS\]\s+(\w+)\s+(\w+)")
def _extract_hook_info(self, log_line: str) -> Optional[Dict[str, str]]:
"""Extract hook event info from structured [HOOKS] log lines.
The action is the bare second word (matches what hooks emits), e.g.:
[HOOKS] cadence fired loader=global turn=35 period=5 offset=0 session=...
[HOOKS] cadence skipped loader=branch turn=37 period=5 offset=0 session=...
group(1)=name (cadence), group(2)=action (fired/skipped). Remaining
key=value fields are parsed by the finditer loop below.
"""
match = self._HOOK_PATTERN.search(log_line)
if not match:
return None
name = match.group(1)
action = match.group(2)
details: Dict[str, str] = {"name": name, "action": action}
for kv_match in re.finditer(r"(\w+)=(\S+)", log_line):
details[kv_match.group(1)] = kv_match.group(2)
return details
def _emit_hook_event(self, branch: str, hook_info: Dict[str, str]) -> None:
"""Emit a hook event to the monitoring queue."""
action = hook_info.get("action", "unknown")
name = hook_info.get("name", "hook")
loader = hook_info.get("loader", "")
turn = hook_info.get("turn", "")
period = hook_info.get("period", "")
offset = hook_info.get("offset", "")
session = hook_info.get("session", "")
parts = [f"{name}:{action}"]
if loader:
parts.append(f"loader={loader}")
if turn:
parts.append(f"t={turn}")
if period:
parts.append(f"p={period}")
if offset and offset != "0":
parts.append(f"off={offset}")
if session:
parts.append(f"s={session[:8]}")
message = " ".join(parts)
level = "success" if action == "fired" else "info"
hook_event = MonitoringEvent(
priority=2,
event_type="hook",
branch=branch,
action=action,
message=message,
level=level,
timestamp=datetime.now(),
)
self.event_queue.enqueue(hook_event)
def _should_display_log(self, _log_line: str) -> bool:
"""Check if log line should be displayed. No filtering — show everything."""
return True
@@ -483,7 +544,7 @@ def start_log_watcher(event_queue: MonitoringQueue, use_polling: bool = False) -
# Create observer — polling fallback when inotify unavailable
if use_polling:
from watchdog.observers.polling import PollingObserver
from watchdog.observers.polling import PollingObserver # type: ignore
observer = PollingObserver(timeout=1)
logger.info("Log watcher using polling observer (1s interval)")
@@ -185,6 +185,28 @@ def print_command_separator(branch: str, command: str, caller: Optional[str] = N
console.print(f"[bold {branch_color}]{'─' * 60}[/bold {branch_color}]")
def print_hook_event(branch: str, message: str, action: str = "unknown"):
"""Print a hook event with distinct fired/skipped styling.
Args:
branch: Branch the hook event originated from
message: Hook event summary (e.g. "cadence:fired loader=global turn=35")
action: "fired" or "skipped" (controls color)
"""
with _print_lock:
timestamp = datetime.now().strftime("%H:%M:%S")
if action == "fired":
style = "bold green"
symbol = "⚡"
elif action == "skipped":
style = "dim"
symbol = "·"
else:
style = "white"
symbol = "?"
console.print(f"[dim]{timestamp}[/dim] [{style}]{symbol} HOOK {message}[/{style}]")
def print_status(watched_branches: List[str], verbosity: int, filters: Optional[Dict] = None):
"""
Display current monitoring status
+3
View File
@@ -38,6 +38,7 @@ from aipass.prax.apps.handlers.json import json_handler
from aipass.prax.apps.handlers.monitoring import (
print_event, # unified_stream.py
print_command_separator, # unified_stream.py - command headers
print_hook_event, # unified_stream.py - hook fire/skip display
MonitoringQueue, # event_queue.py
ModuleTracker, # module_tracker.py
)
@@ -338,6 +339,8 @@ def _render_event(event) -> None:
if len(parts) == 2 and parts[1]:
target = parts[1]
print_command_separator(event.branch, event.message, caller, target)
elif event.event_type == "hook":
print_hook_event(event.branch, event.message, event.action)
else:
print_event(event.event_type, event.branch, event.message, event.level, pid=branch_pid)
+140
View File
@@ -1063,3 +1063,143 @@ class TestInitializePositionsAdditional:
watcher.initialize_positions()
assert str(log_file) not in watcher.log_positions
class TestExtractHookInfo:
"""Test _extract_hook_info for structured [HOOKS] log lines."""
def test_fired_line_extracted(self):
"""Should extract name, action, and key-value details from a fired line."""
mod = _import_log_watcher()
watcher, _ = _make_watcher(mod)
# Real format hooks emits: action is the bare second word, no action= field.
line = "[HOOKS] cadence fired loader=global turn=35 period=5 offset=0 session=abc12345"
result = watcher._extract_hook_info(line)
assert result is not None
assert result["name"] == "cadence"
assert result["action"] == "fired"
assert result["loader"] == "global"
assert result["turn"] == "35"
def test_skipped_line_extracted(self):
"""Should extract skipped hook events."""
mod = _import_log_watcher()
watcher, _ = _make_watcher(mod)
line = "[HOOKS] cadence skipped loader=branch turn=37 period=5 offset=0 session=abc12345"
result = watcher._extract_hook_info(line)
assert result is not None
assert result["name"] == "cadence"
assert result["action"] == "skipped"
assert result["loader"] == "branch"
def test_non_hook_line_returns_none(self):
"""Non-hook log lines should return None."""
mod = _import_log_watcher()
watcher, _ = _make_watcher(mod)
result = watcher._extract_hook_info("[FLOW] Creating plan FPLAN-0099")
assert result is None
def test_hook_info_line_returns_none(self):
"""A [HOOKS] info/error line (colon after the name) is not a fire/skip event → None."""
mod = _import_log_watcher()
watcher, _ = _make_watcher(mod)
# These are real cadence info lines; the colon stops the action capture.
assert watcher._extract_hook_info("[HOOKS] cadence: config load failed, using defaults") is None
assert watcher._extract_hook_info("[HOOKS] cadence: counter reset for post-compact re-injection") is None
class TestEmitHookEvent:
"""Test _emit_hook_event queues properly."""
def test_fired_event_queued_with_correct_kwargs(self):
"""Fired hook events should pass event_type=hook, level=success to MonitoringEvent."""
mod = _import_log_watcher()
watcher, mock_queue = _make_watcher(mod)
mock_event_cls = MagicMock()
with patch.object(mod, "MonitoringEvent", mock_event_cls):
hook_info = {
"name": "cadence",
"action": "fired",
"loader": "global",
"turn": "35",
"period": "5",
"offset": "0",
"session": "abc12345",
}
watcher._emit_hook_event("HOOKS", hook_info)
mock_event_cls.assert_called_once()
kwargs = mock_event_cls.call_args[1]
assert kwargs["event_type"] == "hook"
assert kwargs["action"] == "fired"
assert kwargs["level"] == "success"
assert "cadence:fired" in kwargs["message"]
assert "loader=global" in kwargs["message"]
assert "t=35" in kwargs["message"]
assert "p=5" in kwargs["message"]
assert "s=abc12345" in kwargs["message"]
def test_skipped_event_queued_with_info_level(self):
"""Skipped hook events should pass level=info to MonitoringEvent."""
mod = _import_log_watcher()
watcher, mock_queue = _make_watcher(mod)
mock_event_cls = MagicMock()
with patch.object(mod, "MonitoringEvent", mock_event_cls):
hook_info = {"name": "cadence", "action": "skipped", "loader": "branch", "turn": "37"}
watcher._emit_hook_event("HOOKS", hook_info)
kwargs = mock_event_cls.call_args[1]
assert kwargs["action"] == "skipped"
assert kwargs["level"] == "info"
def test_process_log_line_routes_hook_to_emit(self):
"""Hook lines in _process_log_line should route to _emit_hook_event, not _emit_log_event."""
mod = _import_log_watcher()
watcher, mock_queue = _make_watcher(mod)
with (
patch.object(watcher, "_emit_hook_event") as mock_hook,
patch.object(watcher, "_emit_command_separator") as mock_cmd,
patch.object(watcher, "_emit_log_event") as mock_log,
):
watcher._process_log_line(
"HOOKS",
"[HOOKS] cadence fired loader=global turn=35 period=5 offset=0 session=abc",
"/fake/file.log",
)
mock_hook.assert_called_once()
mock_cmd.assert_not_called()
mock_log.assert_not_called()
def test_process_real_pipe_delimited_hook_line(self):
"""Real log lines are pipe-delimited — hook detection must match through the prefix."""
mod = _import_log_watcher()
watcher, mock_queue = _make_watcher(mod)
real_line = (
"2026-06-09 19:56:04 | captured_cadence | INFO | "
"[HOOKS] cadence skipped loader=branch turn=18 period=5 offset=0 session=c98a722b"
)
with (
patch.object(watcher, "_emit_hook_event") as mock_hook,
patch.object(watcher, "_emit_command_separator") as mock_cmd,
patch.object(watcher, "_emit_log_event") as mock_log,
):
watcher._process_log_line("HOOKS", real_line, "/fake/hooks_cadence.log")
mock_hook.assert_called_once()
hook_info = mock_hook.call_args[0][1]
assert hook_info["name"] == "cadence"
assert hook_info["action"] == "skipped"
assert hook_info["loader"] == "branch"
assert hook_info["turn"] == "18"
mock_cmd.assert_not_called()
mock_log.assert_not_called()
+10
View File
@@ -261,6 +261,16 @@
{
"file": "tests/test_coverage_arch_checklist.py",
"reason": "Test file: outside 3-layer structure by convention, imports handlers directly for unit testing, and test functions omit docstrings by pytest convention."
},
{
"file": "apps/handlers/aipass_standards/skip_dirs.py",
"standard": "json_structure",
"reason": "Pure constants module — defines SOURCE_SKIP_DIRS frozenset only, no operations to log."
},
{
"file": "apps/handlers/aipass_proof/",
"standard": "handlers",
"reason": "Proof handlers import SOURCE_SKIP_DIRS from aipass_standards/skip_dirs.py — shared constant, same-branch cross-handler import, intentional (FPLAN-0261)."
}
],
"notes": {
@@ -26,9 +26,10 @@ from pathlib import Path
from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler
from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS
# Directories to skip during scanning
_SKIP_DIRS = {".archive", ".sorting_unprocessed", "__pycache__"}
_SKIP_DIRS = SOURCE_SKIP_DIRS
def _parse_public_functions(file_path: Path) -> list[str]:

Some files were not shown because too many files have changed in this diff Show More