Files

391 lines
21 KiB
Markdown

---
phase: 06-default-repos-ssh-mount
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ~/.hermes/config.yaml
- ~/.hermes/.env
- ~/.hermes/scripts/session-init.sh
autonomous: true
requirements:
- REPO-01
- REPO-02
must_haves:
truths:
- "New Hermes Docker container has SSH keys mounted at /root/.ssh/ — git clone to Bitbucket works without manual auth"
- "DEFAULT_REPOS (rai-ops, rai-deployment, rai-devtools) are available at /workspace/<name> in every new session"
- "Container restart does NOT lose repo mounts — repos persist because they're host-mounted, not cloned"
- "SSH credentials are mounted read-only inside Docker — agent cannot modify keys even as root"
- "session-init.sh logs repo mount status at shell start without blocking the agent prompt"
- "DEFAULT_REPOS is configurable via .env — user adds/removes repos by editing one variable + docker_volumes"
artifacts:
- path: "~/.hermes/scripts/session-init.sh"
provides: "Mount verification script for DEFAULT_REPOS at session start"
min_lines: 25
- path: "~/.hermes/config.yaml"
provides: "Docker volume mounts (SSH keys + repos), shell_init_files, docker_forward_env"
contains: "docker_volumes.*id_ed25519razer"
- path: "~/.hermes/.env"
provides: "DEFAULT_REPOS environment variable"
contains: "DEFAULT_REPOS"
key_links:
- from: "~/.hermes/config.yaml (docker_volumes)"
to: "~/.ssh/id_ed25519razer"
via: "volume mount"
pattern: "id_ed25519razer.*ro"
- from: "~/.hermes/config.yaml (docker_volumes)"
to: "~/Razer/rai-ops"
via: "volume mount"
pattern: "rai-ops.*rw"
- from: "~/.hermes/config.yaml (shell_init_files)"
to: "~/.hermes/scripts/session-init.sh"
via: "shell_init_files path"
pattern: "session-init.sh"
- from: "~/.hermes/.env"
to: "~/.hermes/config.yaml (docker_forward_env)"
via: "env forwarding"
pattern: "DEFAULT_REPOS"
---
<objective>
Mount DEFAULT_REPOS (rai-ops, rai-deployment, rai-devtools) directly from host filesystem into every new Hermes Docker session with SSH credentials mounted read-only for git operations, plus a lightweight session-init.sh verification script.
Purpose: Eliminate the #1 user friction point — manually cloning repos every session. Host-direct mounts preserve existing git worktrees, branches, and uncommitted changes across container restarts (D-02). SSH keys are mounted per-file as read-only (`:ro`) to limit credential exposure (D-01).
Output:
- `~/.hermes/scripts/session-init.sh` — non-blocking mount verification script
- `~/.hermes/.env``DEFAULT_REPOS=rai-ops,rai-deployment,rai-devtools`
- `~/.hermes/config.yaml` — 4 SSH key mounts, 3 repo mounts, shell_init_files, docker_forward_env additions
</objective>
<execution_context>
@/Users/bapung/.config/opencode/gsd-core/workflows/execute-plan.md
@/Users/bapung/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@/Users/bapung/.planning/PROJECT.md
@/Users/bapung/.planning/ROADMAP.md
# Current Hermes config for editing
@/Users/bapung/.hermes/config.yaml
@/Users/bapung/.hermes/.env
# SSH config for reference
@/Users/bapung/.ssh/config
# Existing scripts for shebang/pattern consistency
@/Users/bapung/.hermes/scripts/ngn-jira
@/Users/bapung/.hermes/scripts/ngn-bitbucket
</context>
<tasks>
<task type="auto">
<name>Task 1: Create session-init.sh mount verification script</name>
<files>~/.hermes/scripts/session-init.sh</files>
<read_first>
~/.hermes/scripts/ngn-jira (shebang pattern)
~/.hermes/scripts/ngn-bitbucket (shebang pattern)
This file's RESEARCH.md §Code Examples for the reference implementation
</read_first>
<action>
Create `~/.hermes/scripts/session-init.sh` with the following behavior (per D-03, D-04):
- **Shebang:** `#!/bin/bash`
- **set options:** `set -uo pipefail` — deliberately NOT `-e` so missing repos don't abort the script (non-blocking session start per D-03)
- **Reads DEFAULT_REPOS** from environment (forwarded via `docker_forward_env` per D-04)
- **Splits comma-separated list** and trims whitespace from each entry
- **For each repo:** checks `[ -d "/workspace/$repo/.git" ]` — confirm it's a valid git checkout, not just an empty dir
- **Logs:**
- `[session-init] ✓ repo — mounted at /workspace/repo` on success
- `[session-init] ⚠ repo — NOT FOUND at /workspace/repo` on failure
- **Summary:** "All DEFAULT_REPOS verified" vs "Some repos missing — check docker_volumes in config.yaml"
- **Exit code:** Always 0 — session starts regardless (discretion area)
- **Empty DEFAULT_REPOS guard:** If env var unset/empty, log "[session-init] DEFAULT_REPOS not set — skipping verification" and exit 0
**Do NOT** include:
- `set -e` — would abort on first missing repo (blocks session start)
- Any `git clone`, `git pull`, or network operations — script must complete in &lt;1s (ROADMAP success criteria 5: 30s timeout)
- Any file writes or modifications — this is a read-only checker
Make the script executable: `chmod +x ~/.hermes/scripts/session-init.sh`
</action>
<verify>
<automated>bash -n ~/.hermes/scripts/session-init.sh 2>&1; chmod +x ~/.hermes/scripts/session-init.sh 2>/dev/null; stat -f "%Sp" ~/.hermes/scripts/session-init.sh | grep -q -- "-rwx"</automated>
</verify>
<acceptance_criteria>
1. Script passes bash syntax check (`bash -n` exits 0)
2. Script is executable (`stat` shows `-rwx` permissions)
3. Logic: does NOT use `set -e`, DOES use `set -uo pipefail`
4. Guard: if DEFAULT_REPOS unset, exits 0 with log message (not error)
5. Verification: `test -d "/workspace/$repo/.git"` (not just `test -d "/workspace/$repo"`)
6. Always exits 0 regardless of mount status
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Add DEFAULT_REPOS to .env and update config.yaml with mounts + shell_init_files + forward_env</name>
<files>~/.hermes/.env, ~/.hermes/config.yaml</files>
<read_first>
~/.hermes/.env (full file — already read in planning context)
~/.hermes/config.yaml (full file — already read in planning context)
The RESEARCH.md §Code Examples for exact mount strings
</read_first>
<action>
Make the following changes:
### A. `~/.hermes/.env` — Append DEFAULT_REPOS
At the tail of the file, before the last line (HINDSIGHT_LLM_API_KEY) or after it, add:
```bash
# DEFAULT_REPOS — repos mounted into every session workspace
# Comma-separated list. Each repo must have a matching docker_volume entry in config.yaml.
DEFAULT_REPOS=rai-ops,rai-deployment,rai-devtools
```
Place this after the LAST existing section (after the "# ngn-agent: OpenRouter fallback" block, near where JIRA_API_TOKEN and HINDSIGHT_LLM_API_KEY are). Group it with the other ngn-agent-specific vars.
### B. `~/.hermes/config.yaml` — Append SSH key mounts to `terminal.docker_volumes`
Add these 4 entries to the `docker_volumes` list (after the existing scripts mount). Each entry is a quoted string. Per D-01:
```yaml
terminal:
docker_volumes:
# ... existing entries remain unchanged ...
# SSH key mounts (read-only — per D-01)
- /Users/bapung/.ssh/id_ed25519razer:/root/.ssh/id_ed25519razer:ro
- /Users/bapung/.ssh/id_rsa:/root/.ssh/id_rsa:ro
- /Users/bapung/.ssh/config:/root/.ssh/config:ro
- /Users/bapung/.ssh/known_hosts:/root/.ssh/known_hosts:ro
# Repo mounts (read-write — per D-02)
- /Users/bapung/Razer/rai-ops:/workspace/rai-ops:rw
- /Users/bapung/Razer/rai-deployment:/workspace/rai-deployment:rw
- /Users/bapung/Razer/rai-devtools:/workspace/rai-devtools:rw
```
**WHY `known_hosts` is included (research finding):** Without `/root/.ssh/known_hosts`, SSH prompts interactively for host key confirmation on first Bitbucket connection. Since the container is non-interactive, this hangs the clone. The host's `~/.ssh/known_hosts` already contains bitbucket.org host keys (verified: 3 keys present).
### C. `~/.hermes/config.yaml` — Set `terminal.shell_init_files`
Change from `shell_init_files: []` to:
```yaml
terminal:
shell_init_files:
- /usr/local/bin/session-init.sh
```
Per D-03. The `~/.hermes/scripts/` directory is already mounted to `/usr/local/bin:ro`, so `/usr/local/bin/session-init.sh` resolves automatically.
### D. `~/.hermes/config.yaml` — Append `DEFAULT_REPOS` to `terminal.docker_forward_env`
Change from `docker_forward_env: [JIRA_EMAIL, JIRA_API_TOKEN]` to include `DEFAULT_REPOS`:
```yaml
terminal:
docker_forward_env:
- JIRA_EMAIL
- JIRA_API_TOKEN
- DEFAULT_REPOS # per D-04
```
**Important YAML formatting rules:**
- `docker_volumes` entries are list items under the existing key — append to the existing list, do NOT replace the entire list.
- `shell_init_files` replaces the existing empty list `[]` with a new list containing one item.
- `docker_forward_env` appends to the existing list — do NOT replace the existing entries.
- Maintain consistent indentation (2-space YAML).
- Preserve all other config keys untouched.
</action>
<verify>
<automated>
# Verify config.yaml is valid YAML
python3 -c "import yaml; c=yaml.safe_load(open('$HOME/.hermes/config.yaml')); volumes=c['terminal']['docker_volumes']; assert any('id_ed25519razer' in v for v in volumes), 'SSH key mount missing'; assert any('rai-ops' in v for v in volumes), 'Repo mount missing'; assert c['terminal']['shell_init_files'] == ['/usr/local/bin/session-init.sh'], 'shell_init_files wrong'; assert 'DEFAULT_REPOS' in c['terminal']['docker_forward_env'], 'DEFAULT_REPOS not in forward_env'" 2>&1
# Verify .env has DEFAULT_REPOS
grep -q 'DEFAULT_REPOS=rai-ops,rai-deployment,rai-devtools' $HOME/.hermes/.env && echo "ENV: OK" || echo "ENV: MISSING"
# Verify SSH key entries are :ro (read-only)
python3 -c "import yaml; c=yaml.safe_load(open('$HOME/.hermes/config.yaml')); vols=c['terminal']['docker_volumes']; ro=[v for v in vols if 'id_ed25519razer' in v]; assert ro and ro[0].endswith(':ro'), f'SSH key not :ro: {ro}'" 2>&1
# Verify repo entries are :rw (read-write)
python3 -c "import yaml; c=yaml.safe_load(open('$HOME/.hermes/config.yaml')); vols=c['terminal']['docker_volumes']; rw=[v for v in vols if 'rai-ops' in v]; assert rw and rw[0].endswith(':rw'), f'Repo mount not :rw: {rw}'" 2>&1
</automated>
</verify>
<acceptance_criteria>
1. `config.yaml` has 4 SSH key mount entries under `docker_volumes`, all `:ro`
2. `config.yaml` has 3 repo mount entries (`rai-ops`, `rai-deployment`, `rai-devtools`) under `docker_volumes`, all `:rw`
3. `config.yaml` `shell_init_files` is set to `["/usr/local/bin/session-init.sh"]`
4. `config.yaml` `docker_forward_env` includes `DEFAULT_REPOS` alongside `JIRA_EMAIL` and `JIRA_API_TOKEN`
5. `.env` has `DEFAULT_REPOS=rai-ops,rai-deployment,rai-devtools`
6. All YAML is valid (python yaml parser confirms)
7. Original config entries are preserved (not deleted)
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 3: Verify end-to-end — SSH auth, repo mounts, script execution via Docker test</name>
<files>(no files modified — verification only)</files>
<read_first>
Phase research §Verified Findings (SSH auth, subpath mounts, known_hosts requirements)
</read_first>
<action>
Run a Docker test container with the EXACT volume mounts defined in Task 2 to verify everything works end-to-end before the agent needs it.
Use the nikolaik/python-nodejs:python3.11-nodejs20 image (same as Hermes uses).
Docker command:
```bash
docker run --rm \
-v ~/.ssh/id_ed25519razer:/root/.ssh/id_ed25519razer:ro \
-v ~/.ssh/id_rsa:/root/.ssh/id_rsa:ro \
-v ~/.ssh/config:/root/.ssh/config:ro \
-v ~/.ssh/known_hosts:/root/.ssh/known_hosts:ro \
-v ~/Razer/rai-ops:/workspace/rai-ops:rw \
-v ~/Razer/rai-deployment:/workspace/rai-deployment:rw \
-v ~/Razer/rai-devtools:/workspace/rai-devtools:rw \
-v ~/.hermes/scripts:/usr/local/bin:ro \
-e DEFAULT_REPOS=rai-ops,rai-deployment,rai-devtools \
nikolaik/python-nodejs:python3.11-nodejs20 \
bash -c '
echo "=== 1. SSH Keys Mounted ==="
ls -la /root/.ssh/ && echo ""
echo "=== 2. SSH Authentication ==="
ssh -T git@bitbucket.org 2>&1 || true && echo ""
echo "=== 3. Repo Mounts ==="
for repo in rai-ops rai-deployment rai-devtools; do
if [ -d "/workspace/$repo/.git" ]; then
echo " ✓ $repo — mounted git repo"
else
echo " ✗ $repo — NOT FOUND"
fi
done && echo ""
echo "=== 4. session-init.sh ==="
session-init.sh && echo "exit code: $?" && echo ""
echo "=== 5. On-Demand Clone (REPO-02) ==="
git clone --depth 1 git@bitbucket.org:razersw/rai-ansible.git /workspace/rai-ansible 2>&1 && \
echo " ✓ Clone succeeded" || echo " ✗ Clone failed"
'
```
**Expected output:**
- SSH keys present at `/root/.ssh/` with correct permissions
- `ssh -T git@bitbucket.org` returns "authenticated via ssh key" (exit code 1 from SSH is expected — this is auth success, just no shell)
- All 3 repos show `✓ repo — mounted git repo`
- `session-init.sh` runs without error, shows all repos verified
- `git clone` of an additional repo succeeds
**If ANY check fails:** Diagnose and fix:
- SSH auth fails: check key permissions (need `600`/`700`), check `known_hosts` has bitbucket.org entries
- Repo mount fails: verify the repo directories exist on host at `~/Razer/<name>/`
- session-init.sh fails: debug the script logic
- Clone fails: check SSH config file mounting
Also verify the parent `/workspace` mount mode (research Pitfall 1):
```bash
docker run --rm \
-v /Users/bapung/Razer/ngn-agent:/workspace:rw \
nikolaik/python-nodejs:python3.11-nodejs20 \
mount | grep /workspace
```
This should show `rw` — NOT `ro`. If `ro`, the parent mount blocks subpath volume creation and the repo mounts will fail at container start.
</action>
<verify>
<automated>
# Run the verification and check output for success signals
TEST_OUTPUT=$(docker run --rm \
-v ~/.ssh/id_ed25519razer:/root/.ssh/id_ed25519razer:ro \
-v ~/.ssh/id_rsa:/root/.ssh/id_rsa:ro \
-v ~/.ssh/config:/root/.ssh/config:ro \
-v ~/.ssh/known_hosts:/root/.ssh/known_hosts:ro \
-v ~/Razer/rai-ops:/workspace/rai-ops:rw \
-v ~/Razer/rai-deployment:/workspace/rai-deployment:rw \
-v ~/Razer/rai-devtools:/workspace/rai-devtools:rw \
-v ~/.hermes/scripts:/usr/local/bin:ro \
-e DEFAULT_REPOS=rai-ops,rai-deployment,rai-devtools \
nikolaik/python-nodejs:python3.11-nodejs20 \
bash -c 'ssh -T git@bitbucket.org 2>&1 | grep -q "authenticated" && echo "SSH_AUTH_OK" || echo "SSH_AUTH_FAIL"; for r in rai-ops rai-deployment rai-devtools; do [ -d "/workspace/$r/.git" ] && echo "REPO_${r}_OK" || echo "REPO_${r}_FAIL"; done; session-init.sh 2>&1 | grep -q "All DEFAULT_REPOS verified" && echo "SCRIPT_OK" || echo "SCRIPT_FAIL"; git clone --depth 1 git@bitbucket.org:razersw/rai-ansible.git /tmp/test-clone 2>&1 | grep -q "done" && echo "CLONE_OK" || echo "CLONE_FAIL"' 2>&1)
echo "$TEST_OUTPUT"
echo "$TEST_OUTPUT" | grep -q "SSH_AUTH_OK" && echo "PASS: SSH auth" || echo "FAIL: SSH auth"
echo "$TEST_OUTPUT" | grep -q "REPO_rai-ops_OK" && echo "PASS: rai-ops mounted" || echo "FAIL: rai-ops mounted"
echo "$TEST_OUTPUT" | grep -q "REPO_rai-deployment_OK" && echo "PASS: rai-deployment mounted" || echo "FAIL: rai-deployment mounted"
echo "$TEST_OUTPUT" | grep -q "REPO_rai-devtools_OK" && echo "PASS: rai-devtools mounted" || echo "FAIL: rai-devtools mounted"
echo "$TEST_OUTPUT" | grep -q "SCRIPT_OK" && echo "PASS: session-init.sh" || echo "FAIL: session-init.sh"
echo "$TEST_OUTPUT" | grep -q "CLONE_OK" && echo "PASS: on-demand clone" || echo "FAIL: on-demand clone"
</automated>
</verify>
<acceptance_criteria>
1. SSH auth succeeds — `authenticated via ssh key` response from Bitbucket
2. All 3 DEFAULT_REPOS are mounted git repos at `/workspace/<name>` (`.git` directory present)
3. `session-init.sh` runs and reports all repos verified
4. On-demand `git clone` of additional repo succeeds (verifies REPO-02 capability)
5. Parent `/workspace` mount is `:rw` (not `:ro`) — verified by mount table inspection
</acceptance_criteria>
</task>
</tasks>
<threat_model>
## Assets
| Asset | Sensitivity | Protection |
|-------|-------------|------------|
| SSH private keys (id_ed25519razer, id_rsa) | HIGH — Bitbucket access | Docker `:ro` mount prevents modification. Per-file mount (not full `~/.ssh/`) limits blast radius. |
| SSH config | MEDIUM — auth routing info | Mounted `:ro` — read-only in container. |
| Repo source code (rai-ops, rai-deployment, rai-devtools) | HIGH — proprietary code | Mounted `:rw` intentionally — agent needs to commit/push. Same exposure as having repo on host. |
| `known_hosts` | LOW — public host keys | Mounted `:ro`. Read-only, prevents MitM host key injection. |
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Host → Docker container | SSH keys cross from macOS host into container via volume mount. Docker enforces read-only at VFS level. |
| Container → Bitbucket | Agent inside container authenticates to Bitbucket via mounted SSH keys. Network boundary. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-01 | Information Disclosure | SSH keys in Docker | mitigate | Keys mounted `:ro` — agent can read (required for auth) but cannot modify. Per-file mount (not full `~/.ssh/`) limits exposure to only the two declared keys. |
| T-06-02 | Spoofing | Bitbucket auth via stolen key | accept | Key is user's personal SSH key. Deferred to future phase: per-repo deploy keys (deferred idea in CONTEXT.md). Current mitigation: key can only be read inside container, not exfiltrated to external hosts. |
| T-06-03 | Tampering | Repo source code | accept | Repos mounted `:rw` by design — agent must create branches, commit, push. Same trust model as developer's local environment. |
| T-06-04 | Tampering | `known_hosts` | mitigate | File mounted `:ro` — prevents agent from injecting fraudulent host keys. File is from trusted host source. |
| T-06-SC | Tampering | Package installs | n/a | Zero packages installed. All changes are config edits + one shell script. |
## Residual Risk
- SSH key is readable by agent inside container. If the agent is compromised via prompt injection, the key could be read. Mitigated by: (a) key permits only Bitbucket read/write to known repos, (b) no other services use the same key, (c) per-repo deploy keys deferred to future phase.
</threat_model>
<verification>
1. All 4 Python-based YAML assertions pass for config.yaml validity
2. `grep` confirms DEFAULT_REPOS in .env
3. Docker test container confirms SSH auth, repo mounts, script execution, and on-demand clone
</verification>
<success_criteria>
1. ✅ SSH keys mounted (`:ro`) and Bitbucket auth works inside Docker (verified by container test)
2. ✅ All 3 DEFAULT_REPOS mounted at `/workspace/<name>` (verified by container test)
3. ✅ `session-init.sh` runs at shell start and reports mount status (verified by container test)
4. ✅ On-demand git clone works inside Docker (verified by container test)
5. ✅ `DEFAULT_REPOS` configurable via `.env` — one variable to add/remove repos
6. ✅ All changes are additive (no existing config removed)
7. ✅ Parent `/workspace` mount confirmed `:rw` (subpath volumes will work)
</success_criteria>
## Artifacts This Phase Produces
| Artifact | Path | Type | Purpose |
|----------|------|------|---------|
| session-init.sh | `~/.hermes/scripts/session-init.sh` | Shell script | Verifies DEFAULT_REPOS mounts at session start |
| DEFAULT_REPOS env var | `~/.hermes/.env` | Config | Defines which repos to verify |
| SSH key mounts (4) | `~/.hermes/config.yaml` → `docker_volumes` | Config | Mounts id_ed25519razer, id_rsa, config, known_hosts as `:ro` |
| Repo mounts (3) | `~/.hermes/config.yaml` → `docker_volumes` | Config | Mounts rai-ops, rai-deployment, rai-devtools as `:rw` |
| shell_init_files entry | `~/.hermes/config.yaml` | Config | Triggers session-init.sh at shell start |
| docker_forward_env entry | `~/.hermes/config.yaml` | Config | Forwards DEFAULT_REPOS into container |
<output>
Create `.planning/phases/06-default-repos-ssh-mount/06-01-SUMMARY.md` when done
</output>