diff --git a/src/aipass/devpulse/apps/__init__.py b/src/aipass/devpulse/apps/__init__.py new file mode 100644 index 00000000..418d47bc --- /dev/null +++ b/src/aipass/devpulse/apps/__init__.py @@ -0,0 +1 @@ +# Temporary home for aipass init — will port to CLI branch later. diff --git a/src/aipass/devpulse/apps/init_project.py b/src/aipass/devpulse/apps/init_project.py new file mode 100644 index 00000000..3313ffa0 --- /dev/null +++ b/src/aipass/devpulse/apps/init_project.py @@ -0,0 +1,183 @@ +""" +aipass init — Bootstrap an AIPass project in any directory. + +Temporary home in devpulse. Will port to CLI branch once proven. + +Usage: + python -m aipass.devpulse.apps.init_project [target_dir] + +What it does: + 1. Generates a UUID for the project registry + 2. Creates *_REGISTRY.json with metadata.id + 3. Creates .trinity/ (passport with registry_id) + 4. Creates .aipass/ (aipass_local_prompt.md) + 5. Creates AIPASS.md (project prompt) +""" + +import json +import re +import sys +import uuid +from datetime import date +from pathlib import Path + + +def _sanitize_name(raw: str) -> str: + """Sanitize a project name for use in filenames. + + Replaces non-alphanumeric characters (except underscore/hyphen) with + underscores and strips leading/trailing underscores. + """ + return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_") + + +def init_project(target: Path, project_name: str | None = None) -> dict: + """Initialize an AIPass project in the target directory. + + Args: + target: Directory to initialize + project_name: Name for the registry (defaults to directory name) + + Returns: + dict with created files and registry_id + """ + target = target.resolve() + if not target.exists(): + target.mkdir(parents=True) + + raw_name = project_name or target.name + name = _sanitize_name(raw_name) + if not name: + raise ValueError( + f"Cannot derive project name from '{raw_name}'. " + "Pass a project name explicitly." + ) + registry_id = str(uuid.uuid4()) + today = date.today().isoformat() + created = [] + + # 1. Registry + registry_filename = f"{name}_REGISTRY.json" + registry_path = target / registry_filename + if registry_path.exists(): + raise FileExistsError(f"Registry already exists: {registry_path}") + + registry_data = { + "metadata": { + "id": registry_id, + "name": name, + "version": "1.0.0", + "created": today, + "last_updated": today, + "total_branches": 0, + }, + "branches": [], + } + registry_path.write_text( + json.dumps(registry_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + created.append(str(registry_path)) + + # 2. .trinity/ + trinity_dir = target / ".trinity" + trinity_dir.mkdir(exist_ok=True) + + passport = { + "document_metadata": { + "document_type": "project_identity", + "document_name": f"{name}.PASSPORT", + "version": "1.0.0", + "created": today, + "last_updated": today, + }, + "identity": { + "project_name": name, + "role": "project_root", + "purpose": "", + }, + "citizenship": { + "registered": True, + "registry_id": registry_id, + "registry_name": name, + }, + } + passport_path = trinity_dir / "passport.json" + if passport_path.exists(): + raise FileExistsError( + f"Passport already exists: {passport_path}. " + "Remove .trinity/passport.json to re-initialize." + ) + passport_path.write_text( + json.dumps(passport, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + created.append(str(passport_path)) + + # local.json + observations.json + for filename in ("local.json", "observations.json"): + p = trinity_dir / filename + if not p.exists(): + p.write_text("{}\n", encoding="utf-8") + created.append(str(p)) + + # 3. .aipass/ + aipass_dir = target / ".aipass" + aipass_dir.mkdir(exist_ok=True) + + prompt_path = aipass_dir / "aipass_local_prompt.md" + if not prompt_path.exists(): + prompt_path.write_text( + f"# {name} — Local Prompt\n\nInjected every turn. Add project-specific context here.\n", + encoding="utf-8", + ) + created.append(str(prompt_path)) + + # 4. AIPASS.md + aipass_md = target / "AIPASS.md" + if not aipass_md.exists(): + aipass_md.write_text( + "# AIPass — Project Prompt\n\n" + "Project-level instructions. Persists in context for the entire conversation.\n\n" + "## Startup\n\n" + "On any greeting, silently read these files from CWD and run the commands " + "— no narration, no announcing steps. Just do it and respond with the status.\n\n" + "**Read:** `.trinity/passport.json`, `.trinity/local.json`, " + "`.trinity/observations.json`, `README.md`\n" + "**Run:** `git status`\n\n" + "## Memories\n\n" + "Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. " + "If compaction hits before you save, it's gone.\n", + encoding="utf-8", + ) + created.append(str(aipass_md)) + + return { + "registry_id": registry_id, + "registry_file": registry_filename, + "project_name": name, + "target": str(target), + "created_files": created, + } + + +def main(): + """CLI entry point.""" + target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() + name = sys.argv[2] if len(sys.argv) > 2 else None + + try: + result = init_project(target, name) + except (FileExistsError, ValueError, OSError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f"Initialized AIPass project: {result['project_name']}") + print(f"Registry: {result['registry_file']} (id: {result['registry_id'][:8]}...)") + print(f"Created {len(result['created_files'])} files:") + for f in result["created_files"]: + print(f" {f}") + + +if __name__ == "__main__": + main() diff --git a/src/aipass/devpulse/dplan-003/README.md b/src/aipass/devpulse/dplan-003/README.md index 2b4ac53b..764c173b 100644 --- a/src/aipass/devpulse/dplan-003/README.md +++ b/src/aipass/devpulse/dplan-003/README.md @@ -4,12 +4,19 @@ Research, mapping, and planning files for "AIPass as Operating System." Parent plan: `AIPass/DPLAN-003_aipass_as_operating_system_2026-03-13.md` +## Scope + +**In scope:** `src/aipass/` branches only (drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, devpulse, backup, daemon, memory). + +**Out of scope:** `src/commons/`, `src/skills/` — these are separate and not part of this refactor. + ## Files | File | Purpose | Status | |------|---------|--------| -| `registry_discovery_map.md` | Every find_registry() call, file, line number | Pending | -| `registry_refactor_plan.md` | Shared commons function design, migration steps | Pending | +| `registry_discovery_map.md` | Every find_registry() call in src/aipass/ | Done | +| `portability_audit.md` | Full investigation results (session 24) | Done | +| `credential_model.md` | Registry credential design (UUID now, macaroons later) | Active | +| `registry_refactor_plan.md` | Shared function design, migration steps | Pending | | `aipass_init_spec.md` | What `aipass init` creates, CLI design, templates | Pending | | `drone_help_spec.md` | `drone aipass help` module design | Pending | -| `portability_audit.md` | Full investigation results (session 24) | Done | diff --git a/src/aipass/devpulse/dplan-003/credential_model.md b/src/aipass/devpulse/dplan-003/credential_model.md new file mode 100644 index 00000000..27c5e43a --- /dev/null +++ b/src/aipass/devpulse/dplan-003/credential_model.md @@ -0,0 +1,151 @@ +# DPLAN-003: Registry Credential Model + +## The Idea + +Registries get a unique token. Passports carry that token. Access is identity-based, not filesystem-based. No walk-up needed — your credential proves which registry is yours. + +## Why + +Current system finds registries by walking up directories. Works for one project, breaks with multiple. If two AIPass projects exist on one machine, a citizen launched from the wrong directory finds the wrong registry. Credentials solve this — your passport carries proof of membership. + +## Prior Art (Research) + +| System | Pattern | Fit | +|--------|---------|-----| +| **Macaroons** (Google Research) | Token IS the credential. Delegatable with caveats. Offline verification. DeepMind validated for AI agent delegation (2026). | Highest | +| **Vault Namespaces** | Project = namespace. Token scoped to namespace. Mini-registry per project. | High | +| **AWS STS / Token Vending** | Agent presents project ID, gets scoped credential. Credential itself is the boundary. | High | +| **K8s Namespace + ServiceAccount** | Token carries project scope as claim. RBAC composable. | High | +| **SPIFFE/SPIRE** | Process-level attestation without static secrets. | Medium | +| **direnv** | Auto-set env vars on directory entry. Zero-friction UX. | UX pattern | + +Full research: agent output from session 25. + +## Design: Two Stages + +### Stage 1: UUID Match (Manual, Now) + +Simple. Prove the concept works before adding crypto. + +**Registry gets an ID:** +```json +{ + "metadata": { + "id": "a1b2c3d4-...", + "name": "AIPASS", + "version": "1.0.0", + "last_updated": "2026-03-13", + "total_branches": 15 + }, + "branches": [...] +} +``` + +**Passports get the matching ID:** +```json +{ + "citizenship": { + "registered": true, + "registry_id": "a1b2c3d4-...", + "registry_name": "AIPASS", + "citizen_number": 7 + } +} +``` + +**Lookup flow:** +1. Walk up from CWD, find `*_REGISTRY.json` +2. Read its `metadata.id` +3. Check citizen's `citizenship.registry_id` matches +4. If mismatch → error ("citizen belongs to registry X, found registry Y") +5. If match → proceed + +**Spawn changes:** +- `aipass init` (or manual setup) generates the UUID for the registry +- Spawn reads registry UUID and injects into new passports via `{{REGISTRY_ID}}` placeholder +- Existing 15 branches get the UUID added to their passports (one-time migration) + +### Stage 2: Macaroon Tokens (Future, When Cross-Project Needed) + +Upgrade path when we need delegation and cross-project access. + +**Root token:** Created at `aipass init`. HMAC-based. Stored at `~/.secrets/aipass/projects/.token` + +**Citizen token:** Attenuated copy in passport. Can prove membership but can't mint new citizens. + +**Agent token:** Further attenuated. Carries: +- Registry ID (which project) +- Scope (full access — agents do the real work in AIPass) +- Expiry (session-scoped, dies when agent dies) +- Issuer (which citizen spawned this agent) + +Agents are NOT read-only. They're the builders — they write code, run tests, modify files. The token proves they belong to a project, it doesn't restrict what they do within it. Scope restrictions would be role-based (e.g., "can't modify other branches' files") not capability-based. + +**Verification:** Local HMAC check. No daemon needed for basic validation. Daemon is optional enhancement for audit logging and revocation. + +**direnv integration:** Entering a project directory auto-sets `AIPASS_PROJECT_TOKEN` in env. Agents inherit it. + +## What Changes (Stage 1) + +| Component | Change | +|-----------|--------| +| `AIPASS_REGISTRY.json` | Add `metadata.id` (UUID) | +| Passport template | Add `citizenship.registry_id` placeholder | +| Spawn `build_replacements_dict()` | Read registry UUID, add `{{REGISTRY_ID}}` | +| Spawn `add_to_registry()` | No change (branch entries stay the same) | +| Drone `find_registry()` | Optional: verify passport.registry_id matches found registry | +| All 15 passports | One-time: add `registry_id` field | + +## What Does NOT Change + +- Registry filename stays `*_REGISTRY.json` +- Walk-up discovery still works (credential is verification layer on top, not replacement) +- Branch structure unchanged +- No daemon needed +- No new dependencies + +## Resolved Questions + +1. **Registry ID location** → `metadata.id` — it's the project's identity, not the citizen's. +2. **UUID4 vs hash** → UUID4 (random). Simple, guaranteed unique, no inputs needed. +3. **Verification mode** → Hard error on mismatch. Fail loudly — that's the AIPass way. +4. **Where does init live?** → Temporary: `src/aipass/devpulse/apps/init_project.py`. Future: CLI branch. + +## Bugs, Quirks, and Findings + +Discovered during testing. Reference for future work. + +### init_project.py (10 edge cases tested) +- **Spaces in dir name** → registry filename gets spaces (`MY COOL PROJECT_REGISTRY.json`). Fixed: `_sanitize_name()` replaces non-alphanumeric with `_`. +- **Root path `/`** → `Path("/").name` is empty string, creates `_REGISTRY.json`. Fixed: validation rejects empty name. +- **Permission errors** → raw traceback instead of clean message. Fixed: `main()` catches `OSError`. +- **Passport overwrite on re-init** → if registry deleted but `.trinity/` survives, re-init would silently overwrite passport with new UUID. Fixed: passport guarded with `exists()` check. +- **Double init** → correctly blocked by `FileExistsError` on registry file. +- **Deep nested paths** → `mkdir(parents=True)` handles correctly. +- **UUID uniqueness** → 5 runs, 5 unique UUIDs. No collisions. +- **JSON validity** → all generated files parse clean. + +### Drone isolation (6 scenarios tested) +- **Sibling projects** → PASS. Two projects in same parent dir, fully isolated. +- **Nested project** → PASS. Inner registry wins over outer. No bleed-through. +- **Deep subdir walk-up** → PASS. Finds nearest ancestor registry correctly. +- **Cross-project contamination** → PASS. Branches are registry-scoped; modules are global. +- **Empty directory (no registry)** → CONCERN. Drone silently falls back to AIPass source registry via `__file__` walk-up. Any dir on this machine without a registry sees production branches. This is by design in `find_registry()` but will be dangerous with multi-project. Credential verification would catch this — citizen's registry_id won't match the fallback registry. +- **`drone @ai_mail` from mock project** → correctly returns "Branch not found in registry" (empty project has no branches). + +### Registry/passport gitignore +- `AIPASS_REGISTRY.json` and all `.trinity/passport.json` files are gitignored. UUID migration is local-only. This is correct for now — credentials are machine-specific, not repo state. But means `aipass init` must run on every clone/install. Future: consider whether UUID should be in-repo or machine-local. + +### AI mail after migration +- Send/receive works fine after UUID migration. AI mail's 4 internal `find_registry()` copies still hardcode `AIPASS_REGISTRY.json` — functional for now since that filename exists, but won't find `*_REGISTRY.json` in other projects. + +### Drone built-in modules vs branches +- `@drone` and `@seedgo` are hardcoded as "modules" in `module_registry.py`, always visible everywhere. Other branches (`@ai_mail`, `@spawn`, etc.) are registry-scoped. This distinction matters: modules are global services, branches are project citizens. + +## Decision Log + +- **2026-03-14:** Patrick proposed credential-based registry access. Agents do the real work — tokens prove membership, not restrict capability. Manual first, `aipass init` later. +- **2026-03-14:** Research confirmed macaroons as best-fit pattern (Google Research + DeepMind 2026 validation). Stage 1 = UUID match, Stage 2 = macaroon upgrade. +- **2026-03-14:** Scope limited to `src/aipass/` branches only. Commons and skills excluded. +- **2026-03-14:** All 4 open questions resolved. `init_project.py` built and tested — creates registry with UUID, passport with matching registry_id, .trinity/, .aipass/, AIPASS.md. Tested with temp directory — drone isolation confirmed (only built-in modules visible, not AIPass branches). +- **2026-03-14:** UUID migration executed (FPLAN-0030). Registry + 13 passports updated. Registry and passports are gitignored — UUID is machine-local, not repo state. diff --git a/dplan-003/registry_discovery_map.md b/src/aipass/devpulse/dplan-003/registry_discovery_map.md similarity index 100% rename from dplan-003/registry_discovery_map.md rename to src/aipass/devpulse/dplan-003/registry_discovery_map.md