feat(system): credential model design + aipass init prototype (DPLAN-003)

Registry credential model: registries get UUID in metadata.id, passports
carry matching registry_id. Stage 1 (UUID match) designed, Stage 2
(macaroon tokens) planned. Research confirmed macaroons as best-fit
pattern for AI agent delegation.

init_project.py prototype: bootstraps AIPass project in any directory
(registry, .trinity, .aipass, AIPASS.md). Hardened against 10 edge
cases (name sanitization, permission errors, passport overwrite guard).
Drone isolation verified across 6 scenarios (sibling, nested, deep
walk-up, cross-project contamination). All findings documented in
credential_model.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-03-13 22:38:23 -07:00
co-authored by Claude Opus 4.6
parent 8ab43a2cd0
commit aa271ba883
5 changed files with 345 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
# Temporary home for aipass init — will port to CLI branch later.
+183
View File
@@ -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()
+10 -3
View File
@@ -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 |
@@ -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/<uuid>.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.