--- 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/ 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" --- 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 @/Users/bapung/.config/opencode/gsd-core/workflows/execute-plan.md @/Users/bapung/.config/opencode/gsd-core/templates/summary.md @/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 Task 1: Create session-init.sh mount verification script ~/.hermes/scripts/session-init.sh ~/.hermes/scripts/ngn-jira (shebang pattern) ~/.hermes/scripts/ngn-bitbucket (shebang pattern) This file's RESEARCH.md §Code Examples for the reference implementation 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 <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` 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" 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 Task 2: Add DEFAULT_REPOS to .env and update config.yaml with mounts + shell_init_files + forward_env ~/.hermes/.env, ~/.hermes/config.yaml ~/.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 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. # 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 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) Task 3: Verify end-to-end — SSH auth, repo mounts, script execution via Docker test (no files modified — verification only) Phase research §Verified Findings (SSH auth, subpath mounts, known_hosts requirements) 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//` - 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. # 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" 1. SSH auth succeeds — `authenticated via ssh key` response from Bitbucket 2. All 3 DEFAULT_REPOS are mounted git repos at `/workspace/` (`.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 ## 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. 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 1. ✅ SSH keys mounted (`:ro`) and Bitbucket auth works inside Docker (verified by container test) 2. ✅ All 3 DEFAULT_REPOS mounted at `/workspace/` (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) ## 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 | Create `.planning/phases/06-default-repos-ssh-mount/06-01-SUMMARY.md` when done