diff --git a/docs/hermes-always-on-v1.md b/docs/hermes-always-on-v1.md new file mode 100644 index 0000000..6b5496f --- /dev/null +++ b/docs/hermes-always-on-v1.md @@ -0,0 +1,86 @@ +# Hermes Always-On v1 Guard + +This repo is not trying to make Hermes fully autonomous on day one. The safe v1 sequence is: + +1. Run Hermes only from the dedicated macOS Standard agent account. +2. Keep all Hermes state under `~/AgentWork/hermes`. +3. Launch Hermes through `hermes-light`, not directly. +4. Bootstrap isolated profiles before gateway or cron. +5. Prove one manual Kanban profile before workers, gateway, cron, or LaunchAgent. + +## Commands added in this slice + +After `./omp-guard install-shims`, these commands are installed into `~/AgentWork/bin`: + +```bash +hermes-guard doctor +hermes-guard bootstrap-profiles +hermes-profile-doctor chief-of-staff +hermes-light --profile chief-of-staff --version +hermes-gateway-light --profile chief-of-staff --foreground +``` + +## State layout + +```text +~/AgentWork/hermes/ + profiles/ + chief-of-staff/ + SOUL.md + profile.yml + home/ + tmp/ + xdg-config/ + xdg-cache/ + xdg-data/ + logs/ + skills/ + memories/ +``` + +`HERMES_HOME` is set to the profile root. `HOME`, XDG directories, and `TMPDIR` are moved inside that profile root so a Hermes worker does not inherit the normal macOS home. + +## Stage A target + +Stage A is single-profile only: + +```text +chief-of-staff only +no workers +no cron +no browser +no messaging +manual Kanban task creation only +``` + +Exit criteria: + +```text +Can create/list/comment/archive tasks. +Cannot run terminal. +Cannot access personal home. +Cannot use non-allowlisted network. +``` + +## First local run + +Run from the agent account: + +```bash +cd ~/AgentWork/omp-guard +git checkout hermes-trial-slice-1 +./omp-guard install-shims +hermes-guard bootstrap-profiles +hermes-profile-doctor chief-of-staff +hermes-doctor +``` + +Then test from a project workspace: + +```bash +mkdir -p ~/AgentWork/projects/hermes-trial +cd ~/AgentWork/projects/hermes-trial +hermes-light --profile chief-of-staff --version +``` + +Do not enable `hermes-gateway-light`, cron, or a LaunchAgent until this passes. diff --git a/docs/hermes-operations-runbook.md b/docs/hermes-operations-runbook.md new file mode 100644 index 0000000..bf4a0f8 --- /dev/null +++ b/docs/hermes-operations-runbook.md @@ -0,0 +1,68 @@ +# Hermes Operations Runbook + +## Local validation + +```bash +cd ~/AgentWork/omp-guard +./omp-guard doctor +python3 scripts/seatbelt-selftest.py +bash scripts/test-hermes-guard.sh +hermes-guard bootstrap-profiles +hermes-doctor +``` + +## Stage A procedure + +1. Confirm you are in the dedicated agent account. +2. Confirm `OMP_GUARD_PERSONAL_HOME` points at the personal macOS account home. +3. Install shims with `./omp-guard install-shims`. +4. Bootstrap profiles with `hermes-guard bootstrap-profiles`. +5. Validate `chief-of-staff` with `hermes-profile-doctor chief-of-staff`. +6. Launch Hermes from `~/AgentWork/projects/hermes-trial` with `hermes-light --profile chief-of-staff --version`. + +## Gateway rule + +Run the gateway only in foreground until manual tests pass: + +```bash +hermes-gateway-light --profile chief-of-staff --foreground +``` + +Do not install a LaunchAgent in this slice. + +## Kill switch for later stages + +Before enabling gateway persistence or cron, add guarded scripts for: + +```text +hermes-stop-all +hermes-disable-gateway +hermes-disable-cron +hermes-network-lockdown +hermes-backup-state +hermes-restore-state +``` + +Minimum manual stop while still in foreground testing: + +```bash +pkill -f "hermes gateway" +pkill -f "hermes" +``` + +Do not use a system daemon. Always-on v1 should use a user-level LaunchAgent in the agent account only, after multiple successful foreground runs. + +## Audit checks for later stages + +Daily audit should eventually report: + +```text +blocked tasks +long-running tasks +tasks completed in last 24h +tasks created by chief-of-staff +network/tool escalation requests +skills modified +memory modified +cron jobs created or changed +``` diff --git a/docs/hermes-profile-model.md b/docs/hermes-profile-model.md new file mode 100644 index 0000000..cf1938c --- /dev/null +++ b/docs/hermes-profile-model.md @@ -0,0 +1,58 @@ +# Hermes Profile Model + +Each Hermes profile is a separate identity and state boundary. Do not use one all-powerful profile. + +## Initial profiles + +```text +chief-of-staff +researcher +marketer +developer +reviewer +operator +librarian +``` + +Create them with: + +```bash +hermes-guard bootstrap-profiles +``` + +Validate one profile with: + +```bash +hermes-profile-doctor chief-of-staff +``` + +## Role boundaries + +`chief-of-staff` orchestrates work. It should use Kanban and memory, not terminal, browser, cron creation, GitHub write, or external posting. + +`researcher` gathers cited information. It should not mutate repos, publish externally, or use terminal initially. + +`developer` implements scoped changes only in assigned workspaces or worktrees. It must not use sudo, push to GitHub, merge PRs, or edit outside the assigned workspace. + +`reviewer` verifies diffs and tests. It should not patch files unless explicitly assigned an implementation card. + +`librarian` curates wiki and proposed skills. It should not directly promote durable skills without human review. + +## Files per profile + +Each profile has: + +```text +SOUL.md role identity and behavioural constraints +profile.yml local manifest describing expected toolsets and policy +home/ profile-scoped HOME +skills/ profile-local skills area +memories/ profile-local memory area +logs/ profile-local launcher/proxy logs +``` + +Do not seed profiles with personal SSH keys, browser profiles, GitHub auth, iCloud files, or normal `~/.config` contents. + +## Toolset policy + +Toolsets are declared in `profile.yml` as a guard expectation. The manifest is not a substitute for Hermes' own config; it is an independent local safety check so the guard can fail obvious profile drift before always-on operation. diff --git a/docs/progress/hermes-stage-a-canonical-home-pivot-2026-07-02.md b/docs/progress/hermes-stage-a-canonical-home-pivot-2026-07-02.md new file mode 100644 index 0000000..6a9cbaa --- /dev/null +++ b/docs/progress/hermes-stage-a-canonical-home-pivot-2026-07-02.md @@ -0,0 +1,56 @@ +# Hermes Stage A canonical-home pivot + +Date: 2 July 2026 + +## Decision + +Stage A now uses the canonical Hermes Desktop/CLI state directory: + +```text +/Users/agentlab/.hermes +``` + +The previous profile-local runtime model under `~/AgentWork/hermes/profiles/` is retained only as an advanced/debug path. It is no longer the default Stage A runtime. + +## Rationale + +Hermes Desktop and Hermes CLI share account-level state under `~/.hermes`, including config, auth, model caches, session DBs, logs, updates, skills, memories, and Desktop integration files. Maintaining a second profile-local Hermes home caused persistence divergence and made guarded launches fragile after Desktop or CLI activity. + +The safer operational decision is to keep Hermes state where Hermes expects it, and make `omp-guard` enforce the execution boundary instead. + +## New Stage A boundary + +`hermes-light --profile chief-of-staff` now treats `--profile` as an identity, policy, and logging selector. By default it does not create a separate Hermes runtime. It launches Hermes with: + +```text +HERMES_HOME=$HOME/.hermes +HOME=$HOME +XDG_CONFIG_HOME unset +XDG_CACHE_HOME unset +XDG_DATA_HOME unset +XDG_STATE_HOME unset +TMPDIR=$OMP_GUARD_ALLOWED_ROOT/.omp-guard-tmp/hermes-light +``` + +The Seatbelt profile allows writes only to the current project workspace, canonical `~/.hermes`, the guard temp directory, and the guard log directory. It does not allow broad read/write access to `/Users/agentlab`. + +## Still not enabled + +This pivot does not enable gateway, cron, LaunchAgent, Kanban worker dispatch, browser automation, MCP, GitHub write, developer execution, or Stage B researcher operation. + +## Stage A validation target + +A clean Stage A pass now means: + +```text +1. hermes-light launches only from ~/AgentWork/projects/ +2. HERMES_HOME is /Users/agentlab/.hermes +3. HOME is /Users/agentlab +4. XDG state/config/cache/data are not redirected to profile-local Hermes paths +5. Seatbelt is on in normal guarded runs +6. Egress proxy is on in normal guarded runs +7. token environment variables are scrubbed before launching Hermes +8. launch logs show home_mode=canonical-hermes-home +9. no session-store warning appears +10. no gateway/cron/worker functionality is enabled +``` diff --git a/docs/progress/hermes-stage-a-pivot-and-stop-point-2026-07-02.md b/docs/progress/hermes-stage-a-pivot-and-stop-point-2026-07-02.md new file mode 100644 index 0000000..726a056 --- /dev/null +++ b/docs/progress/hermes-stage-a-pivot-and-stop-point-2026-07-02.md @@ -0,0 +1,88 @@ +# Hermes Stage A pivot and stop point + +Date: 2 July 2026 + +## Status + +Stage A is not ready to progress to Stage B. + +Hermes Desktop is usable inside the protected `agentlab` account, and the Hermes project can be anchored under `~/AgentWork/projects`. However, guarded interactive Hermes through `hermes-light` plus Seatbelt plus the egress proxy is not yet stable enough to rely on. + +## What was validated + +- Hermes Desktop runs from the standard `agentlab` macOS account. +- Hermes CLI/runtime state is account-local to `agentlab`. +- Hermes Desktop and CLI expect canonical state under `/Users/agentlab/.hermes`. +- Hermes projects should be anchored under `/Users/agentlab/AgentWork/projects`. +- `hermes-light --profile chief-of-staff --version` can launch quickly in canonical-home mode. +- Launch logs show canonical-home mode, project cwd, Seatbelt status, egress proxy status, and token-scrub status. + +## What did not pass + +The real interactive guarded path did not pass cleanly. + +A fast `--version` run is not enough. Stage A requires interactive Hermes to connect to the model provider, persist the session, work through the egress proxy, remain responsive, and resume cleanly. + +During the latest interactive test, Hermes was slow to boot and produced a connection error. That is a stop signal. + +## Architecture decision + +Stage A should use canonical Hermes state: + +```text +/Users/agentlab/.hermes +``` + +The earlier profile-local Hermes runtime tree should be treated as advanced/debug only: + +```text +/Users/agentlab/AgentWork/hermes/profiles/ +``` + +The guard should protect the execution boundary rather than trying to make Hermes maintain a duplicate default runtime. + +## Operating decision + +For now: + +- use Hermes Desktop manually inside `agentlab`; +- keep Hermes projects under `/Users/agentlab/AgentWork/projects`; +- do not rely on interactive `hermes-light` Seatbelt mode yet; +- do not start Stage B; +- do not enable gateway, cron, LaunchAgent, workers, browser automation, MCP, GitHub write, developer execution, or external publishing. + +## Next implementation slice + +Before this PR is marked ready or merged, add a deliberate Stage A simplification and validation patch: + +1. make canonical `/Users/agentlab/.hermes` mode the explicit Stage A default; +2. keep profile-local runtime mode as advanced/debug only; +3. keep project-root enforcement; +4. keep token scrubbing; +5. keep launch logging; +6. keep egress proxy controls; +7. add explicit provider-connectivity validation; +8. replace brittle SQLite-only smoke tests with real interactive launch validation; +9. document the weaker Seatbelt-off fallback for controlled learning; +10. ensure tests reflect the Hermes Desktop / CLI / Xcode / SQLite findings from this debugging session. + +## New Stage A pass gate + +A clean Stage A pass requires: + +1. Hermes Desktop works in `agentlab`. +2. The Hermes project is anchored under `~/AgentWork/projects`. +3. `hermes-light --profile chief-of-staff --version` works quickly. +4. `hermes-light --profile chief-of-staff` launches interactively without a session-store warning. +5. A harmless model call succeeds without a connection error. +6. Launch logs show the correct project cwd. +7. Launch logs show token scrubbing. +8. Launch logs show either guarded mode with Seatbelt and egress proxy on, or an explicit documented fallback mode. +9. No personal home, iCloud, Downloads, Desktop, or Documents path is used as a workspace. +10. The repo tests reflect the real validation path. + +## Bottom line + +The separate `agentlab` account remains the durable safety boundary. Seatbelt is useful, but it should not be treated as the load-bearing foundation until the real interactive Hermes path is stable. + +Do not proceed to Stage B until Stage A has a stable, boring, repeatable operating mode. diff --git a/policies/hermes-dev.yml b/policies/hermes-dev.yml new file mode 100644 index 0000000..acc100e --- /dev/null +++ b/policies/hermes-dev.yml @@ -0,0 +1,142 @@ +schemaVersion: 1 +name: hermes-dev +description: Hermes developer/reviewer policy. Model APIs plus GitHub read and package registries for approved implementation sessions; no automatic push or merge. +workspace: + allowedRoots: + - "$HOME/AgentWork" + deniedPaths: + - "/Users/Shared" + - "/Users/Shared/**" + - "$HOME/Desktop" + - "$HOME/Desktop/**" + - "$HOME/Documents" + - "$HOME/Documents/**" + - "$HOME/Downloads" + - "$HOME/Downloads/**" + - "$HOME/Library/Mobile Documents" + - "$HOME/Library/Mobile Documents/**" +credentialAndPrivatePaths: + deny: + - "**/.ssh" + - "**/.ssh/**" + - "**/.config" + - "**/.config/**" + - "**/.aws" + - "**/.aws/**" + - "**/.docker" + - "**/.docker/**" + - "**/.gnupg" + - "**/.gnupg/**" + - "**/.omp" + - "**/.omp/**" + - "**/.hermes" + - "**/.hermes/**" + - "**/.omp-guard-state" + - "**/.omp-guard-state/**" + - "**/.claude" + - "**/.claude/**" + - "**/.codex" + - "**/.codex/**" + - "**/Library/Keychains" + - "**/Library/Keychains/**" + - "**/Library/Application Support/Google/Chrome" + - "**/Library/Application Support/Google/Chrome/**" + - "**/Library/Application Support/Chromium" + - "**/Library/Application Support/Chromium/**" + - "**/Library/Safari" + - "**/Library/Safari/**" +commands: + allow: + - "pwd" + - "ls" + - "cat" + - "sed" + - "awk" + - "grep" + - "find" + - "git status" + - "git diff" + - "git log" + - "git branch" + - "git show" + - "npm test" + - "python -m pytest" + - "pytest" + - "hermes --version" + - "hermes kanban" + askBefore: + - "git push" + - "git commit" + - "git reset" + - "git clean" + - "rm" + - "mv" + - "cp" + - "chmod" + - "chown" + - "sudo" + - "brew install" + - "brew upgrade" + - "npm install" + - "pip install" + - "python -m pip install" + - "curl" + - "wget" + - "ssh" + - "scp" + - "rsync" + - "launchctl" + - "security" + - "defaults write" + - "lsof" + - "nc" + - "python -m http.server" + - "hermes gateway" + - "hermes cron" + block: + - "rm -rf /" + - "rm -rf /*" + - "sudo rm -rf /" + - "sudo rm -rf /*" + - "diskutil" + - "pfctl" + - "csrutil" + - "spctl --master-disable" + - "fdesetup" + - "dscl" + - "dseditgroup" + - "systemsetup -setremotelogin on" + - "launchctl bootstrap" + - "launchctl enable" + - "launchctl load" + - "crontab" + - "mkfs" + - "dd if=" + - "docker run --privileged" + - "-v /var/run/docker.sock" + - "--mount type=bind,source=/Users/" + - "--mount type=bind,source=/Users/Shared" +network: + default: allowlisted + allowedDomains: + - "api.anthropic.com:443" + - "api.openai.com:443" + - "openrouter.ai:443" + - "*.openrouter.ai:443" + - "opencode.ai:443" + - "*.opencode.ai:443" + - "github.com:443" + - "raw.githubusercontent.com:443" + - "*.githubusercontent.com:443" + - "registry.npmjs.org:443" + - "pypi.org:443" + - "files.pythonhosted.org:443" + askBefore: + - "opening new listening servers" + - "exposing ports beyond localhost" + - "connecting to unknown package registries" + - "downloading and executing remote scripts" +logging: + launchLog: "$HOME/AgentWork/.omp-guard-logs/launch.log" + commandLog: "$HOME/AgentWork/.omp-guard-logs/commands.log" + hermesLaunchLog: "$HOME/AgentWork/.omp-guard-logs/hermes-launch.log" diff --git a/policies/hermes-orchestrator.yml b/policies/hermes-orchestrator.yml new file mode 100644 index 0000000..4870295 --- /dev/null +++ b/policies/hermes-orchestrator.yml @@ -0,0 +1,136 @@ +schemaVersion: 1 +name: hermes-orchestrator +description: Hermes chief-of-staff/operator policy. Model APIs only; no broad web, GitHub write, package registries, browser, or publishing endpoints. +workspace: + allowedRoots: + - "$HOME/AgentWork" + deniedPaths: + - "/Users/Shared" + - "/Users/Shared/**" + - "$HOME/Desktop" + - "$HOME/Desktop/**" + - "$HOME/Documents" + - "$HOME/Documents/**" + - "$HOME/Downloads" + - "$HOME/Downloads/**" + - "$HOME/Library/Mobile Documents" + - "$HOME/Library/Mobile Documents/**" +credentialAndPrivatePaths: + deny: + - "**/.ssh" + - "**/.ssh/**" + - "**/.config" + - "**/.config/**" + - "**/.aws" + - "**/.aws/**" + - "**/.docker" + - "**/.docker/**" + - "**/.gnupg" + - "**/.gnupg/**" + - "**/.omp" + - "**/.omp/**" + - "**/.hermes" + - "**/.hermes/**" + - "**/.omp-guard-state" + - "**/.omp-guard-state/**" + - "**/.claude" + - "**/.claude/**" + - "**/.codex" + - "**/.codex/**" + - "**/Library/Keychains" + - "**/Library/Keychains/**" + - "**/Library/Application Support/Google/Chrome" + - "**/Library/Application Support/Google/Chrome/**" + - "**/Library/Application Support/Chromium" + - "**/Library/Application Support/Chromium/**" + - "**/Library/Safari" + - "**/Library/Safari/**" +commands: + allow: + - "pwd" + - "ls" + - "cat" + - "sed" + - "awk" + - "grep" + - "find" + - "git status" + - "git diff" + - "git log" + - "git branch" + - "git show" + - "npm test" + - "python -m pytest" + - "pytest" + - "hermes --version" + - "hermes kanban" + askBefore: + - "git push" + - "git commit" + - "git reset" + - "git clean" + - "rm" + - "mv" + - "cp" + - "chmod" + - "chown" + - "sudo" + - "brew install" + - "brew upgrade" + - "npm install" + - "pip install" + - "python -m pip install" + - "curl" + - "wget" + - "ssh" + - "scp" + - "rsync" + - "launchctl" + - "security" + - "defaults write" + - "lsof" + - "nc" + - "python -m http.server" + - "hermes gateway" + - "hermes cron" + block: + - "rm -rf /" + - "rm -rf /*" + - "sudo rm -rf /" + - "sudo rm -rf /*" + - "diskutil" + - "pfctl" + - "csrutil" + - "spctl --master-disable" + - "fdesetup" + - "dscl" + - "dseditgroup" + - "systemsetup -setremotelogin on" + - "launchctl bootstrap" + - "launchctl enable" + - "launchctl load" + - "crontab" + - "mkfs" + - "dd if=" + - "docker run --privileged" + - "-v /var/run/docker.sock" + - "--mount type=bind,source=/Users/" + - "--mount type=bind,source=/Users/Shared" +network: + default: allowlisted + allowedDomains: + - "api.anthropic.com:443" + - "api.openai.com:443" + - "openrouter.ai:443" + - "*.openrouter.ai:443" + - "opencode.ai:443" + - "*.opencode.ai:443" + askBefore: + - "opening new listening servers" + - "exposing ports beyond localhost" + - "connecting to unknown package registries" + - "downloading and executing remote scripts" +logging: + launchLog: "$HOME/AgentWork/.omp-guard-logs/launch.log" + commandLog: "$HOME/AgentWork/.omp-guard-logs/commands.log" + hermesLaunchLog: "$HOME/AgentWork/.omp-guard-logs/hermes-launch.log" diff --git a/policies/hermes-research.yml b/policies/hermes-research.yml new file mode 100644 index 0000000..22923ee --- /dev/null +++ b/policies/hermes-research.yml @@ -0,0 +1,139 @@ +schemaVersion: 1 +name: hermes-research +description: Hermes research/marketing/librarian policy. Model APIs plus explicit search-provider APIs; no package registries or GitHub write. +workspace: + allowedRoots: + - "$HOME/AgentWork" + deniedPaths: + - "/Users/Shared" + - "/Users/Shared/**" + - "$HOME/Desktop" + - "$HOME/Desktop/**" + - "$HOME/Documents" + - "$HOME/Documents/**" + - "$HOME/Downloads" + - "$HOME/Downloads/**" + - "$HOME/Library/Mobile Documents" + - "$HOME/Library/Mobile Documents/**" +credentialAndPrivatePaths: + deny: + - "**/.ssh" + - "**/.ssh/**" + - "**/.config" + - "**/.config/**" + - "**/.aws" + - "**/.aws/**" + - "**/.docker" + - "**/.docker/**" + - "**/.gnupg" + - "**/.gnupg/**" + - "**/.omp" + - "**/.omp/**" + - "**/.hermes" + - "**/.hermes/**" + - "**/.omp-guard-state" + - "**/.omp-guard-state/**" + - "**/.claude" + - "**/.claude/**" + - "**/.codex" + - "**/.codex/**" + - "**/Library/Keychains" + - "**/Library/Keychains/**" + - "**/Library/Application Support/Google/Chrome" + - "**/Library/Application Support/Google/Chrome/**" + - "**/Library/Application Support/Chromium" + - "**/Library/Application Support/Chromium/**" + - "**/Library/Safari" + - "**/Library/Safari/**" +commands: + allow: + - "pwd" + - "ls" + - "cat" + - "sed" + - "awk" + - "grep" + - "find" + - "git status" + - "git diff" + - "git log" + - "git branch" + - "git show" + - "npm test" + - "python -m pytest" + - "pytest" + - "hermes --version" + - "hermes kanban" + askBefore: + - "git push" + - "git commit" + - "git reset" + - "git clean" + - "rm" + - "mv" + - "cp" + - "chmod" + - "chown" + - "sudo" + - "brew install" + - "brew upgrade" + - "npm install" + - "pip install" + - "python -m pip install" + - "curl" + - "wget" + - "ssh" + - "scp" + - "rsync" + - "launchctl" + - "security" + - "defaults write" + - "lsof" + - "nc" + - "python -m http.server" + - "hermes gateway" + - "hermes cron" + block: + - "rm -rf /" + - "rm -rf /*" + - "sudo rm -rf /" + - "sudo rm -rf /*" + - "diskutil" + - "pfctl" + - "csrutil" + - "spctl --master-disable" + - "fdesetup" + - "dscl" + - "dseditgroup" + - "systemsetup -setremotelogin on" + - "launchctl bootstrap" + - "launchctl enable" + - "launchctl load" + - "crontab" + - "mkfs" + - "dd if=" + - "docker run --privileged" + - "-v /var/run/docker.sock" + - "--mount type=bind,source=/Users/" + - "--mount type=bind,source=/Users/Shared" +network: + default: allowlisted + allowedDomains: + - "api.anthropic.com:443" + - "api.openai.com:443" + - "openrouter.ai:443" + - "*.openrouter.ai:443" + - "opencode.ai:443" + - "*.opencode.ai:443" + - "api.tavily.com:443" + - "api.exa.ai:443" + - "api.search.brave.com:443" + askBefore: + - "opening new listening servers" + - "exposing ports beyond localhost" + - "connecting to unknown package registries" + - "downloading and executing remote scripts" +logging: + launchLog: "$HOME/AgentWork/.omp-guard-logs/launch.log" + commandLog: "$HOME/AgentWork/.omp-guard-logs/commands.log" + hermesLaunchLog: "$HOME/AgentWork/.omp-guard-logs/hermes-launch.log" diff --git a/policies/hermes-v1.yml b/policies/hermes-v1.yml new file mode 100644 index 0000000..5dc1b3a --- /dev/null +++ b/policies/hermes-v1.yml @@ -0,0 +1,136 @@ +schemaVersion: 1 +name: hermes-v1 +description: Base Hermes guard policy for the macOS Agent Lab work account. +workspace: + allowedRoots: + - "$HOME/AgentWork" + deniedPaths: + - "/Users/Shared" + - "/Users/Shared/**" + - "$HOME/Desktop" + - "$HOME/Desktop/**" + - "$HOME/Documents" + - "$HOME/Documents/**" + - "$HOME/Downloads" + - "$HOME/Downloads/**" + - "$HOME/Library/Mobile Documents" + - "$HOME/Library/Mobile Documents/**" +credentialAndPrivatePaths: + deny: + - "**/.ssh" + - "**/.ssh/**" + - "**/.config" + - "**/.config/**" + - "**/.aws" + - "**/.aws/**" + - "**/.docker" + - "**/.docker/**" + - "**/.gnupg" + - "**/.gnupg/**" + - "**/.omp" + - "**/.omp/**" + - "**/.hermes" + - "**/.hermes/**" + - "**/.omp-guard-state" + - "**/.omp-guard-state/**" + - "**/.claude" + - "**/.claude/**" + - "**/.codex" + - "**/.codex/**" + - "**/Library/Keychains" + - "**/Library/Keychains/**" + - "**/Library/Application Support/Google/Chrome" + - "**/Library/Application Support/Google/Chrome/**" + - "**/Library/Application Support/Chromium" + - "**/Library/Application Support/Chromium/**" + - "**/Library/Safari" + - "**/Library/Safari/**" +commands: + allow: + - "pwd" + - "ls" + - "cat" + - "sed" + - "awk" + - "grep" + - "find" + - "git status" + - "git diff" + - "git log" + - "git branch" + - "git show" + - "npm test" + - "python -m pytest" + - "pytest" + - "hermes --version" + - "hermes kanban" + askBefore: + - "git push" + - "git commit" + - "git reset" + - "git clean" + - "rm" + - "mv" + - "cp" + - "chmod" + - "chown" + - "sudo" + - "brew install" + - "brew upgrade" + - "npm install" + - "pip install" + - "python -m pip install" + - "curl" + - "wget" + - "ssh" + - "scp" + - "rsync" + - "launchctl" + - "security" + - "defaults write" + - "lsof" + - "nc" + - "python -m http.server" + - "hermes cron" + - "hermes gateway" + block: + - "rm -rf /" + - "rm -rf /*" + - "sudo rm -rf /" + - "sudo rm -rf /*" + - "diskutil" + - "pfctl" + - "csrutil" + - "spctl --master-disable" + - "fdesetup" + - "dscl" + - "dseditgroup" + - "systemsetup -setremotelogin on" + - "launchctl bootstrap" + - "launchctl enable" + - "launchctl load" + - "crontab" + - "mkfs" + - "dd if=" + - "docker run --privileged" + - "-v /var/run/docker.sock" + - "--mount type=bind,source=/Users/" + - "--mount type=bind,source=/Users/Shared" +network: + default: allowlisted + allowedDomains: + - "api.anthropic.com:443" + - "api.openai.com:443" + - "openrouter.ai:443" + - "*.openrouter.ai:443" + - "opencode.ai:443" + - "*.opencode.ai:443" + askBefore: + - "opening new listening servers" + - "exposing ports beyond localhost" + - "connecting to unknown package registries" + - "downloading and executing remote scripts" +logging: + launchLog: "$HOME/AgentWork/.omp-guard-logs/launch.log" + commandLog: "$HOME/AgentWork/.omp-guard-logs/commands.log" + hermesLaunchLog: "$HOME/AgentWork/.omp-guard-logs/hermes-launch.log" diff --git a/scripts/hermes-doctor.py b/scripts/hermes-doctor.py new file mode 100755 index 0000000..b0d8d51 --- /dev/null +++ b/scripts/hermes-doctor.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import hermes_common as hc # noqa: E402 +import seatbelt # noqa: E402 + + +ROOT = Path(__file__).resolve().parent.parent + + +class Reporter: + def __init__(self) -> None: + self.errors: list[str] = [] + self.warnings: list[str] = [] + + def ok(self, message: str) -> None: + print(f"OK: {message}") + + def warn(self, message: str) -> None: + self.warnings.append(message) + print(f"WARN: {message}") + + def fail(self, message: str) -> None: + self.errors.append(message) + print(f"FAIL: {message}") + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def check_required_files(reporter: Reporter) -> None: + required = [ + "scripts/hermes_common.py", + "scripts/hermes-light-launch.py", + "scripts/hermes-doctor.py", + "scripts/hermes-profile-doctor.py", + "scripts/test-hermes-guard.sh", + "policies/hermes-v1.yml", + "policies/hermes-orchestrator.yml", + "policies/hermes-research.yml", + "policies/hermes-dev.yml", + "docs/hermes-always-on-v1.md", + "docs/hermes-profile-model.md", + "docs/hermes-operations-runbook.md", + ] + for rel in required: + path = ROOT / rel + if path.exists(): + reporter.ok(f"exists: {rel}") + else: + reporter.fail(f"missing: {rel}") + + +def check_location(reporter: Reporter) -> None: + root = ROOT.resolve() + allowed = hc.allowed_root() + if hc.is_under(root, allowed): + reporter.ok(f"repo is inside AgentWork: {root}") + else: + reporter.fail(f"repo is outside AgentWork: {root}") + if os.environ.get("OMP_GUARD_PERSONAL_HOME"): + reporter.ok("OMP_GUARD_PERSONAL_HOME is set") + else: + reporter.fail("OMP_GUARD_PERSONAL_HOME is not set") + + +def check_canonical_hermes_home(reporter: Reporter) -> None: + expected = (Path.home().resolve() / ".hermes").resolve() + try: + canonical, message = hc.validate_canonical_hermes_home(Path.home()) + except (OSError, PermissionError, ValueError, FileNotFoundError) as exc: + reporter.fail(f"canonical Hermes home invalid: {exc}") + return + if canonical == expected: + reporter.ok(f"canonical Hermes home is exactly {expected}") + else: + reporter.fail(f"canonical Hermes home is {canonical}, expected {expected}") + reporter.ok(message) + for rel in ("config.yaml", "state.db"): + path = canonical / rel + if path.exists(): + reporter.ok(f"canonical Hermes state contains {rel}: {path}") + else: + reporter.warn(f"canonical Hermes state does not yet contain {rel}: {path}") + + +def check_hermes_binary(reporter: Reporter) -> None: + hermes_bin = hc.find_hermes_bin() + if not hermes_bin: + reporter.fail("Hermes CLI not found on PATH; install Hermes or set HERMES_GUARD_HERMES_BIN") + return + reporter.ok(f"Hermes CLI found: {hermes_bin}") + result = subprocess.run([hermes_bin, "--version"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode == 0: + reporter.ok(f"Hermes --version returned 0: {(result.stdout or result.stderr).strip()}") + else: + reporter.warn(f"Hermes --version returned {result.returncode}: {(result.stdout + result.stderr).strip()}") + + +def check_policies(reporter: Reporter) -> None: + for rel in [ + "policies/hermes-v1.yml", + "policies/hermes-orchestrator.yml", + "policies/hermes-research.yml", + "policies/hermes-dev.yml", + ]: + result = run([sys.executable, "scripts/validate-policy.py", rel]) + if result.returncode == 0: + reporter.ok(f"policy validates: {rel}") + else: + reporter.fail(f"policy validation failed: {rel}") + print(result.stdout) + print(result.stderr, file=sys.stderr) + + +def check_seatbelt(reporter: Reporter) -> None: + capable, detail = seatbelt.capability() + if capable: + reporter.ok(f"Seatbelt capability: {detail}") + elif sys.platform == "darwin": + reporter.fail(f"Seatbelt unavailable on macOS: {detail}") + else: + reporter.warn(f"Seatbelt unavailable: {detail}") + + +def check_central_launch_log(reporter: Reporter) -> None: + log_dir = hc.guard_log_dir() + log_path = log_dir / "hermes-launch.log" + try: + hc.ensure_guard_log_dir() + except OSError as exc: + reporter.fail(f"central Hermes launch log directory is not writable: {log_dir}: {exc}") + return + + if not log_path.exists(): + reporter.ok(f"central Hermes launch log not created yet: {log_path}") + return + + try: + with log_path.open("a", encoding="utf-8"): + pass + log_path.chmod(0o600) + reporter.ok(f"central Hermes launch log is writable: {log_path}") + except OSError as exc: + fallback = hc.profile_root("chief-of-staff") / "logs" / "hermes-launch.log" + reporter.warn( + f"central Hermes launch log is not writable: {log_path}: {exc}. " + f"Future launches also write a profile-local fallback at {fallback}. " + "Repair the central log with chown/chmod from the agent account if you still want cross-profile audit." + ) + + +def check_profiles(reporter: Reporter) -> None: + existing = [] + missing = [] + for profile in hc.CORE_PROFILES: + if hc.profile_root(profile).exists(): + existing.append(profile) + else: + missing.append(profile) + if missing: + reporter.warn("missing Hermes profiles: " + ", ".join(missing) + " (run `hermes-guard bootstrap-profiles`)") + for profile in existing: + result = run([sys.executable, "scripts/hermes-profile-doctor.py", profile]) + if result.returncode == 0: + reporter.ok(f"profile doctor passes: {profile}") + else: + reporter.fail(f"profile doctor failed: {profile}") + print(result.stdout) + print(result.stderr, file=sys.stderr) + + +def main() -> int: + reporter = Reporter() + print("hermes-doctor") + print(f"repo: {ROOT}") + print() + + check_location(reporter) + check_required_files(reporter) + check_policies(reporter) + check_canonical_hermes_home(reporter) + check_hermes_binary(reporter) + check_seatbelt(reporter) + check_central_launch_log(reporter) + check_profiles(reporter) + + print() + print(f"warnings: {len(reporter.warnings)}") + print(f"errors: {len(reporter.errors)}") + return 1 if reporter.errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hermes-guard.py b/scripts/hermes-guard.py new file mode 100644 index 0000000..4dbd53c --- /dev/null +++ b/scripts/hermes-guard.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import hermes_common as hc # noqa: E402 + + +ROOT = Path(__file__).resolve().parent.parent + + +def usage() -> int: + print( + """usage: + hermes-guard doctor + hermes-guard profile-doctor [--init] + hermes-guard bootstrap-profiles + hermes-guard validate-policy [policy-path] + hermes-guard light --profile [--] [hermes args...] + hermes-guard gateway --profile [--] [gateway args...] +""", + file=sys.stderr, + ) + return 2 + + +def run_script(script_name: str, args: list[str], *, preserve_cwd: bool = False) -> int: + script = ROOT / "scripts" / script_name + if not script.exists(): + print(f"hermes-guard: missing script: {script}", file=sys.stderr) + return 127 + cwd = None if preserve_cwd else ROOT + return subprocess.call([sys.executable, str(script), *args], cwd=cwd) + + +def bootstrap_profiles() -> int: + failed = False + for profile in hc.CORE_PROFILES: + code = run_script("hermes-profile-doctor.py", [profile, "--init"]) + failed = failed or code != 0 + return 1 if failed else 0 + + +def main() -> int: + argv = sys.argv[1:] + if not argv or argv[0] in {"-h", "--help", "help"}: + return usage() + + command = argv[0] + rest = argv[1:] + + if command == "doctor": + return run_script("hermes-doctor.py", rest) + if command == "profile-doctor": + if not rest: + print("usage: hermes-guard profile-doctor [--init]", file=sys.stderr) + return 2 + return run_script("hermes-profile-doctor.py", rest) + if command == "bootstrap-profiles": + return bootstrap_profiles() + if command == "validate-policy": + policy = rest or ["policies/hermes-v1.yml"] + return run_script("validate-policy.py", policy) + if command == "light": + return run_script("hermes-light-launch.py", rest, preserve_cwd=True) + if command == "gateway": + return run_script("hermes-light-launch.py", ["--gateway", "--allow-estate-cwd", *rest], preserve_cwd=True) + + print(f"hermes-guard: unknown command: {command}", file=sys.stderr) + return usage() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hermes-light-launch.py b/scripts/hermes-light-launch.py new file mode 100755 index 0000000..5c3bed1 --- /dev/null +++ b/scripts/hermes-light-launch.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import atexit +import getpass +import os +import select +import signal +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import hermes_common as hc # noqa: E402 +import seatbelt # noqa: E402 + + +ROOT = Path(__file__).resolve().parent.parent + + +def die(message: str, code: int = 2) -> None: + print(f"hermes-light: {message}", file=sys.stderr) + raise SystemExit(code) + + +def make_private_file_append(path: Path, line: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.parent.chmod(0o700) + with path.open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + path.chmod(0o600) + + +def write_launch_logs(global_log: Path, profile_log: Path, line: str) -> None: + """Write central and profile-local launch evidence. + + The central guard log is useful for cross-profile audit. The profile-local + copy remains a fallback when an old central log has bad ownership or + permissions from earlier manual debugging. + """ + failures: list[str] = [] + wrote = False + for label, path in (("central", global_log), ("profile", profile_log)): + try: + make_private_file_append(path, line) + wrote = True + except OSError as exc: + failures.append(f"{label} launch log unavailable at {path}: {exc}") + + if not wrote: + die("; ".join(failures), 5) + for failure in failures: + print(f"hermes-light: WARN: {failure}", file=sys.stderr) + + +def seatbelt_policy() -> str: + policy = os.environ.get("HERMES_GUARD_SEATBELT", "").strip().lower() + if policy in {"auto", "require", "off"}: + return policy + policy = os.environ.get("OMP_GUARD_SEATBELT", "").strip().lower() + if policy in {"auto", "require", "off"}: + return policy + return "require" + + +def start_egress_proxy(policy_file: Path, log_dir: Path) -> tuple[subprocess.Popen | None, int | None]: + proxy_script = ROOT / "scripts" / "egress-proxy.py" + decisions_log = log_dir / "hermes-egress.log" + try: + proc = subprocess.Popen( + [ + sys.executable, + str(proxy_script), + "--host", + "127.0.0.1", + "--port", + "0", + "--print-port", + "--policy", + str(policy_file), + "--log", + str(decisions_log), + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except OSError: + return None, None + + assert proc.stdout is not None + deadline = time.time() + 10 + port_line = "" + while time.time() < deadline: + if proc.poll() is not None: + return None, None + ready, _, _ = select.select([proc.stdout], [], [], 0.25) + if ready: + port_line = proc.stdout.readline().strip() + if port_line: + break + if not port_line.isdigit(): + proc.terminate() + return None, None + return proc, int(port_line) + + +def parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + parser = argparse.ArgumentParser( + description="Launch Hermes with omp-guard's macOS account, workspace, Seatbelt, and egress controls." + ) + parser.add_argument("--profile", required=True, help="Hermes profile name, e.g. chief-of-staff or researcher.") + parser.add_argument("--gateway", action="store_true", help="Run `hermes gateway` for the selected profile.") + parser.add_argument( + "--profile-local-home", + action="store_true", + help="Advanced/debug mode: use the old profile-local HERMES_HOME instead of canonical ~/.hermes.", + ) + parser.add_argument( + "--allow-estate-cwd", + action="store_true", + help="Allow launching from AgentWork outside projects. Intended for controlled gateway runs only.", + ) + args, rest = parser.parse_known_args(argv) + if rest and rest[0] == "--": + rest = rest[1:] + return args, rest + + +def selected_xcode_developer_dir() -> Path | None: + tool = Path("/usr/bin/xcode-select") + if not tool.exists(): + return None + try: + result = subprocess.run( + [str(tool), "-p"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + raw = result.stdout.strip() + if not raw: + return None + path = Path(raw).resolve() + if path.exists(): + return path + return None + + +def hermes_runtime_read_paths(hermes_bin: str, actual_home: Path) -> list[Path]: + """Return narrow read-only runtime paths needed to execute Hermes itself. + + In canonical mode ~/.hermes is writable as state, but the runtime code path + is still listed separately in launch evidence. In profile-local debug mode + this function prevents accidentally allowing all of global ~/.hermes when + only the installed code/venv subtree is needed. + """ + paths: list[Path] = [] + + hermes_bin_path = Path(hermes_bin).resolve() + paths.append(hermes_bin_path.parent) + + explicit = os.environ.get("HERMES_GUARD_RUNTIME_DIR", "").strip() + candidates = [Path(explicit).expanduser()] if explicit else [] + candidates.extend( + [ + actual_home / ".hermes" / "hermes-agent", + actual_home / ".local" / "share" / "uv" / "python", + ] + ) + xcode_dir = selected_xcode_developer_dir() + if xcode_dir is not None: + candidates.append(xcode_dir) + + for candidate in candidates: + try: + resolved = candidate.resolve() + except FileNotFoundError: + continue + if not resolved.exists(): + continue + if resolved == (actual_home / ".hermes").resolve(): + die("refusing to allow the whole global ~/.hermes directory as Hermes runtime read path") + paths.append(resolved) + + deduped: list[Path] = [] + seen: set[str] = set() + for path in paths: + key = str(path) + if key not in seen: + deduped.append(path) + seen.add(key) + return deduped + + +def maybe_dump_seatbelt_profile(profile_text: str) -> None: + dump_path = os.environ.get("HERMES_GUARD_DUMP_SEATBELT_PROFILE", "").strip() + if not dump_path: + return + path = Path(dump_path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(profile_text, encoding="utf-8") + path.chmod(0o600) + + +def configure_canonical_env(env: dict[str, str], actual_home: Path) -> tuple[Path, Path, str]: + canonical_home, _ = hc.validate_canonical_hermes_home(actual_home) + tmp_dir = hc.ensure_guard_tmp_dir() + env["HERMES_HOME"] = str(canonical_home) + env["HOME"] = str(actual_home) + for key in ("XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"): + env.pop(key, None) + env["TMPDIR"] = str(tmp_dir) + return canonical_home, tmp_dir, "canonical-hermes-home" + + +def configure_profile_local_env(env: dict[str, str], paths: dict[str, Path]) -> tuple[Path, Path, str]: + env["HERMES_HOME"] = str(paths["root"]) + env["HOME"] = str(paths["home"]) + env["XDG_CONFIG_HOME"] = str(paths["xdg_config"]) + env["XDG_CACHE_HOME"] = str(paths["xdg_cache"]) + env["XDG_DATA_HOME"] = str(paths["xdg_data"]) + env["XDG_STATE_HOME"] = str(paths["state"]) + env["TMPDIR"] = str(paths["tmp"]) + return paths["root"], paths["tmp"], "profile-local-hermes-home" + + +def main() -> int: + args, hermes_args = parse_args(sys.argv[1:]) + profile = args.profile + try: + hc.validate_profile_name(profile) + except ValueError as exc: + die(str(exc)) + + actual_home = Path.home().resolve() + user = getpass.getuser() + work_user = os.environ.get("OMP_GUARD_WORK_USER", "") + if work_user and user != work_user and os.environ.get("OMP_GUARD_ALLOW_OTHER_USER") != "1": + die( + f"refusing to run as {user}; expected {work_user} " + "(set OMP_GUARD_WORK_USER to change, or OMP_GUARD_ALLOW_OTHER_USER=1 to skip this check)" + ) + + workdir = Path.cwd().resolve() + require_project_cwd = not args.allow_estate_cwd and not args.gateway + try: + hc.assert_safe_workdir(workdir, require_project_cwd=require_project_cwd) + paths = hc.ensure_profile_dirs(profile) + guard_log_dir = hc.ensure_guard_log_dir() + except (OSError, PermissionError, RuntimeError, ValueError) as exc: + die(str(exc)) + + hermes_bin = hc.find_hermes_bin() + if not hermes_bin: + die("Hermes CLI not found on PATH. Install/configure Hermes, or set HERMES_GUARD_HERMES_BIN for testing.", 127) + + policy_file = hc.policy_for_profile(profile) + if not policy_file.exists(): + die(f"Hermes policy file missing: {policy_file}") + + env, scrubbed = hc.scrubbed_env(os.environ) + profile_local = args.profile_local_home or os.environ.get("HERMES_GUARD_PROFILE_LOCAL_HOME") == "1" + try: + if profile_local: + hermes_home, tmp_dir, home_mode = configure_profile_local_env(env, paths) + runtime_write_paths = hc.profile_local_runtime_paths(profile) + else: + hermes_home, tmp_dir, home_mode = configure_canonical_env(env, actual_home) + runtime_write_paths = hc.canonical_runtime_write_paths(hermes_home, tmp_dir, guard_log_dir) + except (OSError, PermissionError, RuntimeError, ValueError, FileNotFoundError) as exc: + die(str(exc)) + + env["HERMES_GUARD_PROFILE"] = profile + env["HERMES_GUARD_POLICY_EFFECTIVE"] = str(policy_file) + + policy = seatbelt_policy() + capable, cap_detail = seatbelt.capability() + enforce = False + if policy == "off": + cap_detail = "disabled by HERMES_GUARD_SEATBELT=off/OMP_GUARD_SEATBELT=off" + elif policy == "require": + if not capable: + die(f"Seatbelt is required but unavailable: {cap_detail}", 3) + enforce = True + else: + enforce = capable + if not capable: + print(f"hermes-light: Seatbelt unavailable, running UNSANDBOXED ({cap_detail})", file=sys.stderr) + + proxy_proc = None + proxy_port = None + if enforce: + proxy_proc, proxy_port = start_egress_proxy(policy_file, guard_log_dir) + if proxy_proc is None: + if os.environ.get("HERMES_GUARD_REQUIRE_PROXY", "1") != "0": + die("egress proxy failed to start while Seatbelt is enforced; refusing to launch", 4) + print("hermes-light: egress proxy failed; network fully denied under sandbox", file=sys.stderr) + else: + proxy_url = f"http://127.0.0.1:{proxy_port}" + for key in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"): + env[key] = proxy_url + env["NODE_USE_ENV_PROXY"] = "1" + env["NO_PROXY"] = "127.0.0.1,localhost" + env["no_proxy"] = "127.0.0.1,localhost" + + hermes_argv = [hermes_bin, "gateway", *hermes_args] if args.gateway else [hermes_bin, *hermes_args] + runtime_read_paths = hermes_runtime_read_paths(hermes_bin, actual_home) + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + xdg_state = env.get("XDG_STATE_HOME", "(unset)") + launch_line = "\t".join( + [ + f"ts={ts}", + f"user={user}", + f"profile={profile}", + f"home_mode={home_mode}", + f"workdir={workdir}", + f"home={env.get('HOME')}", + f"hermes_home={hermes_home}", + f"xdg_state_home={xdg_state}", + f"tmpdir={tmp_dir}", + f"policy={policy_file}", + f"tokens_scrubbed={','.join(scrubbed) if scrubbed else 'none'}", + f"seatbelt={'on' if enforce else 'off'}", + f"seatbelt_detail={cap_detail}", + f"egress_proxy={'127.0.0.1:' + str(proxy_port) if proxy_port else 'none'}", + f"runtime_read_paths={','.join(str(path) for path in runtime_read_paths)}", + f"runtime_write_paths={','.join(str(path) for path in runtime_write_paths)}", + f"mode={home_mode} {'gateway' if args.gateway else 'hermes'} {' '.join(hermes_args) if hermes_args else '(interactive)'}", + ] + ) + write_launch_logs( + guard_log_dir / "hermes-launch.log", + paths["logs"] / "hermes-launch.log", + launch_line, + ) + + if not enforce: + os.execvpe(hermes_bin, hermes_argv, env) + return 127 + + sb_profile = seatbelt.build_profile( + workspace=workdir, + state_dir=hermes_home, + tmp_dir=tmp_dir, + proxy_port=proxy_port, + extra_read_paths=runtime_read_paths, + extra_write_paths=runtime_write_paths, + ) + maybe_dump_seatbelt_profile(sb_profile) + wrapped = seatbelt.wrap_command(profile=sb_profile, argv=hermes_argv) + + def _cleanup() -> None: + if proxy_proc is not None and proxy_proc.poll() is None: + proxy_proc.terminate() + try: + proxy_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proxy_proc.kill() + + atexit.register(_cleanup) + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, lambda *_: sys.exit(130)) + + try: + result = subprocess.run(wrapped, env=env) + return result.returncode + finally: + _cleanup() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hermes-profile-doctor.py b/scripts/hermes-profile-doctor.py new file mode 100644 index 0000000..b22ba9e --- /dev/null +++ b/scripts/hermes-profile-doctor.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import stat +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import hermes_common as hc # noqa: E402 + + +SOUL_TEMPLATES = { + "chief-of-staff": """# Chief of Staff\n\nYou are the orchestrator for Bobby's local Hermes agent organisation.\n\nYou decompose, route, supervise, and report. You do not perform specialist work yourself.\n\nYour default action is to create clear Kanban tasks with title, assignee, acceptance criteria, constraints, workspace, evidence required, and dependencies.\n\nYou must not run terminal commands, edit source code, browse authenticated websites, send external messages, create cron jobs, install packages, request secrets, or store secrets.\n\nEscalate to Bobby when a task needs credentials, external publication, GitHub mutation, access outside AgentWork, broader tools, or has failed twice.\n""", + "researcher": """# Researcher\n\nYou are a disciplined research agent.\n\nYou gather evidence, compare sources, cite claims, and separate fact from inference. Prefer primary sources. Flag uncertainty. Do not publish, mutate repositories, or store secrets.\n""", + "marketer": """# Marketer\n\nYou are a controlled drafting agent.\n\nYou turn approved research into drafts, positioning, messaging, and campaign materials inside the assigned drafts workspace. You do not publish externally or use social accounts.\n""", + "developer": """# Developer\n\nYou are an implementation agent.\n\nYou work only in the assigned workspace or worktree. Make small, testable changes. Run relevant tests. Leave evidence. Do not use sudo, install packages, push to GitHub, or edit outside the assigned workspace unless explicitly authorised.\n""", + "reviewer": """# Reviewer\n\nYou are a skeptical code and safety reviewer.\n\nYou verify claims, inspect diffs, run tests where allowed, and identify residual risk. Do not implement unless explicitly assigned an implementation card.\n""", + "operator": """# Operator\n\nYou are a controlled operations agent.\n\nYou maintain local routines, summaries, and watchdog checks only where explicitly configured. You do not create new cron jobs, send email, publish externally, or use shell admin powers.\n""", + "librarian": """# Librarian\n\nYou maintain durable local knowledge, wiki notes, and proposed skill updates.\n\nYou may draft skill changes for review, but approved skills require Bobby's approval before promotion. Do not store secrets or raw dumps.\n""", +} + +PROFILE_MANIFESTS = { + "chief-of-staff": """profile: chief-of-staff\ndescription: Orchestrates work through Kanban and memory only.\nallowed_toolsets:\n - kanban\n - memory\n - session_search\n - clarify\nforbidden_toolsets:\n - terminal\n - browser\n - cronjob\n - github_write\nnetwork_policy: hermes-orchestrator.yml\n""", + "researcher": """profile: researcher\ndescription: Finds, verifies, cites, and summarizes external information.\nallowed_toolsets:\n - web\n - file-read\n - memory\n - kanban\nforbidden_toolsets:\n - terminal\n - browser\n - github_write\nnetwork_policy: hermes-research.yml\n""", + "marketer": """profile: marketer\ndescription: Drafts controlled marketing copy from approved inputs.\nallowed_toolsets:\n - web\n - file-read\n - file-write-drafts\n - memory\n - kanban\nforbidden_toolsets:\n - external_posting\n - browser\n - github_write\nnetwork_policy: hermes-research.yml\n""", + "developer": """profile: developer\ndescription: Implements scoped changes in assigned workspaces only.\nallowed_toolsets:\n - terminal\n - file\n - code_execution\n - kanban\nforbidden_toolsets:\n - sudo\n - github_push\n - github_merge\nnetwork_policy: hermes-dev.yml\n""", + "reviewer": """profile: reviewer\ndescription: Reviews diffs, tests, safety posture, and residual risk.\nallowed_toolsets:\n - file-read\n - terminal-test-only\n - kanban\nforbidden_toolsets:\n - file-patch\n - github_push\n - github_merge\nnetwork_policy: hermes-dev.yml\n""", + "operator": """profile: operator\ndescription: Runs approved operational routines only.\nallowed_toolsets:\n - cronjob\n - kanban\n - file\n - memory\nforbidden_toolsets:\n - shell_admin\n - package_install\n - external_send\nnetwork_policy: hermes-orchestrator.yml\n""", + "librarian": """profile: librarian\ndescription: Curates wiki notes and proposed skills.\nallowed_toolsets:\n - memory\n - skills-draft\n - file\n - kanban\nforbidden_toolsets:\n - terminal\n - browser\n - external_skill_write\nnetwork_policy: hermes-research.yml\n""", +} + +ROLE_FORBIDDEN_TERMS = { + "chief-of-staff": ["terminal", "browser", "cronjob", "github_write", "github_push", "external_posting"], + "researcher": ["terminal", "github_write", "github_push", "external_posting"], + "marketer": ["github_write", "github_push", "external_posting"], + "reviewer": ["github_push", "github_merge"], +} + + +class Reporter: + def __init__(self) -> None: + self.errors: list[str] = [] + self.warnings: list[str] = [] + + def ok(self, message: str) -> None: + print(f"OK: {message}") + + def warn(self, message: str) -> None: + self.warnings.append(message) + print(f"WARN: {message}") + + def fail(self, message: str) -> None: + self.errors.append(message) + print(f"FAIL: {message}") + + +def init_profile(profile: str) -> None: + paths = hc.ensure_profile_dirs(profile) + soul = paths["root"] / "SOUL.md" + manifest = paths["root"] / "profile.yml" + if not soul.exists(): + soul.write_text(SOUL_TEMPLATES.get(profile, f"# {profile}\n\nControlled Hermes profile.\n"), encoding="utf-8") + soul.chmod(0o600) + if not manifest.exists(): + manifest.write_text(PROFILE_MANIFESTS.get(profile, f"profile: {profile}\nnetwork_policy: hermes-v1.yml\n"), encoding="utf-8") + manifest.chmod(0o600) + + +def check_file_private(path: Path, reporter: Reporter, label: str) -> None: + if not path.exists(): + reporter.fail(f"missing {label}: {path}") + return + if not path.is_file(): + reporter.fail(f"{label} is not a file: {path}") + return + mode = stat.S_IMODE(path.stat().st_mode) + if mode & 0o077: + reporter.fail(f"{label} permissions too open: {path} is {oct(mode)}, expected 0600/0640 max") + else: + reporter.ok(f"private {label}: {path}") + if not path.read_text(encoding="utf-8", errors="replace").strip(): + reporter.fail(f"{label} is empty: {path}") + + +def check_manifest(profile: str, manifest: Path, reporter: Reporter) -> None: + if not manifest.exists(): + reporter.warn(f"missing profile manifest: {manifest}") + return + text = manifest.read_text(encoding="utf-8", errors="replace") + if f"profile: {profile}" in text: + reporter.ok("manifest declares matching profile") + else: + reporter.fail("manifest does not declare the expected profile") + policy_name = hc.policy_for_profile(profile).name + if policy_name in text: + reporter.ok(f"manifest references expected network policy: {policy_name}") + else: + reporter.warn(f"manifest does not reference expected network policy: {policy_name}") + allowed_block = text.split("forbidden_toolsets:", 1)[0] + for term in ROLE_FORBIDDEN_TERMS.get(profile, []): + if f"- {term}" in allowed_block: + reporter.fail(f"manifest appears to allow forbidden toolset for {profile}: {term}") + + +def check_no_seeded_credentials(paths: dict[str, Path], reporter: Reporter) -> None: + risky = [ + paths["home"] / ".ssh", + paths["home"] / ".config" / "gh", + paths["home"] / "Library" / "Keychains", + paths["home"] / "Library" / "Application Support" / "Google" / "Chrome", + paths["home"] / "Library" / "Safari", + ] + found = False + for path in risky: + if path.exists(): + found = True + reporter.fail(f"seeded credential/browser path exists inside Hermes profile home: {path}") + if not found: + reporter.ok("no seeded SSH/GitHub/browser credential paths found inside profile home") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate one isolated Hermes profile home.") + parser.add_argument("profile", help="Hermes profile name, e.g. chief-of-staff") + parser.add_argument("--init", action="store_true", help="Create the profile directory, SOUL.md, and profile.yml if missing.") + args = parser.parse_args() + + reporter = Reporter() + profile = args.profile + print(f"hermes-profile-doctor: {profile}") + + try: + hc.validate_profile_name(profile) + reporter.ok("profile name is safe") + except ValueError as exc: + reporter.fail(str(exc)) + return 1 + + if args.init: + try: + init_profile(profile) + reporter.ok("profile initialized") + except (OSError, PermissionError, ValueError) as exc: + reporter.fail(f"profile initialization failed: {exc}") + return 1 + + try: + paths = hc.profile_paths(profile) + except ValueError as exc: + reporter.fail(str(exc)) + return 1 + + if hc.is_under(paths["root"], hc.allowed_root()): + reporter.ok(f"HERMES_HOME is under AgentWork: {paths['root']}") + else: + reporter.fail(f"HERMES_HOME escapes AgentWork: {paths['root']}") + + for key in hc.PROFILE_DIR_KEYS: + ok, message = hc.check_private_dir(paths[key]) + (reporter.ok if ok else reporter.fail)(message) + + for path in hc.profile_local_runtime_paths(profile): + ok, message = hc.check_private_dir(path) + (reporter.ok if ok else reporter.fail)(message) + + check_file_private(paths["root"] / "SOUL.md", reporter, "SOUL.md") + check_file_private(paths["root"] / "profile.yml", reporter, "profile.yml") + check_manifest(profile, paths["root"] / "profile.yml", reporter) + check_no_seeded_credentials(paths, reporter) + + policy = hc.policy_for_profile(profile) + if policy.exists(): + reporter.ok(f"network policy exists: {policy}") + else: + reporter.fail(f"network policy missing: {policy}") + + print() + print(f"warnings: {len(reporter.warnings)}") + print(f"errors: {len(reporter.errors)}") + return 1 if reporter.errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hermes_common.py b/scripts/hermes_common.py new file mode 100644 index 0000000..bd2e100 --- /dev/null +++ b/scripts/hermes_common.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import os +import re +import shutil +import stat +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$") +CORE_PROFILES = [ + "chief-of-staff", + "researcher", + "marketer", + "developer", + "reviewer", + "operator", + "librarian", +] + +PROFILE_POLICIES = { + "chief-of-staff": "hermes-orchestrator.yml", + "operator": "hermes-orchestrator.yml", + "researcher": "hermes-research.yml", + "marketer": "hermes-research.yml", + "librarian": "hermes-research.yml", + "developer": "hermes-dev.yml", + "reviewer": "hermes-dev.yml", +} + +TOKEN_ENV_KEYS = [ + "GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_PAT", + "COPILOT_GITHUB_TOKEN", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENCODE_API_KEY", + "OPENCODE_GO_API_KEY", +] + +PROFILE_DIR_KEYS = [ + "root", + "home", + "tmp", + "xdg_config", + "xdg_cache", + "xdg_data", + "logs", + "sessions", + "state", + "cron", + "kanban", + "checkpoints", + "skills", + "memories", +] + + +def is_under(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except ValueError: + return False + + +def allowed_root() -> Path: + return Path(os.environ.get("OMP_GUARD_ALLOWED_ROOT", str(Path.home() / "AgentWork"))).expanduser().resolve() + + +def hermes_root() -> Path: + return Path(os.environ.get("HERMES_GUARD_ROOT", str(allowed_root() / "hermes"))).expanduser().resolve() + + +def projects_root() -> Path: + return allowed_root() / "projects" + + +def guard_log_dir() -> Path: + return allowed_root() / ".omp-guard-logs" + + +def guard_tmp_root() -> Path: + return allowed_root() / ".omp-guard-tmp" / "hermes-light" + + +def canonical_hermes_home(actual_home: Path | None = None) -> Path: + """Return the canonical Hermes Desktop/CLI home for Stage A. + + Stage A deliberately uses Hermes' real account-level state so the Desktop + app, CLI, auth, model cache, sessions, and updates remain compatible. Tests + may set HOME to a temporary account root; the canonical home remains + exactly $HOME/.hermes. + """ + home = (actual_home or Path.home()).expanduser().resolve() + explicit = os.environ.get("HERMES_GUARD_CANONICAL_HOME", "").strip() + if explicit: + return Path(explicit).expanduser().resolve() + return (home / ".hermes").resolve() + + +def validate_canonical_hermes_home(actual_home: Path | None = None) -> tuple[Path, str]: + home = (actual_home or Path.home()).expanduser().resolve() + canonical = canonical_hermes_home(home) + expected = (home / ".hermes").resolve() + if canonical != expected: + raise ValueError(f"canonical Hermes home must be exactly {expected}; got {canonical}") + if not canonical.exists(): + raise FileNotFoundError(f"canonical Hermes home is missing: {canonical}") + ok, message = check_private_dir(canonical) + if not ok: + raise PermissionError(message) + return canonical, message + + +def ensure_guard_tmp_dir() -> Path: + path = guard_tmp_root() + ensure_private_dir(path) + return path + + +def ensure_guard_log_dir() -> Path: + path = guard_log_dir() + ensure_private_dir(path) + return path + + +def profile_name_is_safe(profile: str) -> bool: + return bool(PROFILE_RE.fullmatch(profile)) and ".." not in profile and "/" not in profile + + +def validate_profile_name(profile: str) -> None: + if not profile_name_is_safe(profile): + raise ValueError( + "unsafe Hermes profile name. Use lower-case letters, numbers, and dashes; " + "start with a letter; do not use slashes or '..'." + ) + + +def profile_root(profile: str) -> Path: + validate_profile_name(profile) + root = hermes_root() / "profiles" / profile + if not is_under(root, allowed_root()): + raise ValueError(f"computed HERMES_HOME escapes AgentWork: {root}") + return root.resolve() + + +def profile_paths(profile: str) -> dict[str, Path]: + root = profile_root(profile) + return { + "root": root, + "home": root / "home", + "tmp": root / "tmp", + "xdg_config": root / "xdg-config", + "xdg_cache": root / "xdg-cache", + "xdg_data": root / "xdg-data", + "logs": root / "logs", + "sessions": root / "sessions", + "state": root / "state", + "cron": root / "cron", + "kanban": root / "kanban", + "checkpoints": root / "checkpoints", + "skills": root / "skills", + "memories": root / "memories", + } + + +def profile_local_runtime_paths(profile: str) -> list[Path]: + """Return profile-local paths Hermes may use for the advanced isolated mode. + + This remains available for future experiments, but it is no longer the + default Stage A runtime because Hermes Desktop and CLI expect canonical + state under $HOME/.hermes. + """ + paths = profile_paths(profile) + home = paths["home"] + candidates = [ + paths["root"], + paths["home"], + paths["tmp"], + paths["xdg_config"], + paths["xdg_cache"], + paths["xdg_data"], + paths["logs"], + paths["sessions"], + paths["state"], + paths["cron"], + paths["kanban"], + paths["checkpoints"], + paths["skills"], + paths["memories"], + home / ".local", + home / ".local" / "state", + home / ".local" / "state" / "hermes", + home / ".local" / "share", + home / ".local" / "share" / "hermes", + home / ".cache", + home / ".cache" / "hermes", + ] + + deduped: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + resolved = candidate.resolve() + key = str(resolved) + if key not in seen: + deduped.append(resolved) + seen.add(key) + return deduped + + +def canonical_sidecar_paths(canonical_home: Path) -> list[Path]: + """Return narrow HOME-relative Hermes sidecar paths used by dependencies. + + Even with HERMES_HOME set, Python/Node libraries may consult appdirs/XDG + defaults under the real account home. We allow only Hermes-scoped sidecars, + not broad ~/.local, ~/.cache, or ~/.config. + """ + home = canonical_home.parent.resolve() + return [ + home / ".local" / "state" / "hermes", + home / ".local" / "share" / "hermes", + home / ".cache" / "hermes", + home / ".config" / "hermes", + ] + + +def canonical_runtime_write_paths(canonical_home: Path, tmp_dir: Path, log_dir: Path) -> list[Path]: + candidates = [canonical_home, *canonical_sidecar_paths(canonical_home), tmp_dir, log_dir] + deduped: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + ensure_private_dir(candidate) + resolved = candidate.resolve() + key = str(resolved) + if key not in seen: + deduped.append(resolved) + seen.add(key) + return deduped + + +def ensure_private_dir(path: Path) -> None: + existed = path.exists() + path.mkdir(parents=True, exist_ok=True) + if existed: + mode = stat.S_IMODE(path.stat().st_mode) + if mode & 0o077: + raise PermissionError(f"directory permissions too open: {path} is {oct(mode)}, expected 0700") + path.chmod(0o700) + + +def ensure_profile_dirs(profile: str) -> dict[str, Path]: + paths = profile_paths(profile) + for path in list(paths.values()) + profile_local_runtime_paths(profile): + ensure_private_dir(path) + return paths + + +def check_private_dir(path: Path) -> tuple[bool, str]: + if not path.exists(): + return False, f"missing directory: {path}" + if not path.is_dir(): + return False, f"not a directory: {path}" + mode = stat.S_IMODE(path.stat().st_mode) + if mode != 0o700: + return False, f"permissions are {oct(mode)}, expected 0700: {path}" + return True, f"private directory: {path}" + + +def policy_for_profile(profile: str) -> Path: + explicit = os.environ.get("HERMES_GUARD_POLICY") + if explicit: + return Path(explicit).expanduser().resolve() + return (ROOT / "policies" / PROFILE_POLICIES.get(profile, "hermes-v1.yml")).resolve() + + +def find_hermes_bin() -> str | None: + return os.environ.get("HERMES_GUARD_HERMES_BIN") or shutil.which("hermes") + + +def scrubbed_env(base: dict[str, str] | None = None) -> tuple[dict[str, str], list[str]]: + env = dict(base or os.environ) + scrubbed: list[str] = [] + for key in TOKEN_ENV_KEYS: + if key in env: + scrubbed.append(key) + env.pop(key, None) + return env, scrubbed + + +def forbidden_workdir_prefixes() -> list[Path]: + actual_home = Path.home() + personal_home = os.environ.get("OMP_GUARD_PERSONAL_HOME") + if not personal_home: + raise RuntimeError("OMP_GUARD_PERSONAL_HOME is not set — refusing to launch without personal home protection") + return [ + Path(personal_home), + Path("/Users/Shared"), + actual_home / "Desktop", + actual_home / "Documents", + actual_home / "Downloads", + actual_home / "Library" / "Mobile Documents", + ] + + +def assert_safe_workdir(workdir: Path, *, require_project_cwd: bool) -> None: + workdir = workdir.resolve() + root = allowed_root() + if not is_under(workdir, root): + raise RuntimeError(f"refusing to launch outside AgentWork: {workdir} (allowed root: {root})") + if require_project_cwd and not is_under(workdir, projects_root()): + raise RuntimeError(f"refusing to launch outside AgentWork/projects: {workdir} (projects root: {projects_root()})") + for prefix in forbidden_workdir_prefixes(): + if prefix.exists() and is_under(workdir, prefix): + raise RuntimeError(f"refusing to launch under forbidden path: {prefix}") + + +def run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=str(cwd or ROOT), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) diff --git a/scripts/install-shims.py b/scripts/install-shims.py index e0bcb9d..865c60b 100755 --- a/scripts/install-shims.py +++ b/scripts/install-shims.py @@ -4,8 +4,6 @@ import argparse import os import shlex -import stat -import subprocess import sys from pathlib import Path @@ -14,9 +12,14 @@ COMMANDS = { - "omp-guard": ROOT / "omp-guard", - "omp-light": ROOT / "omp-light", - "omp-sbx": ROOT / "omp-sbx", + "omp-guard": [str(ROOT / "omp-guard")], + "omp-light": [str(ROOT / "omp-light")], + "omp-sbx": [str(ROOT / "omp-sbx")], + "hermes-guard": [sys.executable, str(ROOT / "scripts" / "hermes-guard.py")], + "hermes-light": [sys.executable, str(ROOT / "scripts" / "hermes-light-launch.py")], + "hermes-gateway-light": [sys.executable, str(ROOT / "scripts" / "hermes-light-launch.py"), "--gateway", "--allow-estate-cwd"], + "hermes-doctor": [sys.executable, str(ROOT / "scripts" / "hermes-doctor.py")], + "hermes-profile-doctor": [sys.executable, str(ROOT / "scripts" / "hermes-profile-doctor.py")], } @@ -33,15 +36,33 @@ def make_private_dir(path: Path) -> None: path.chmod(0o700) -def write_shim(name: str, target: Path, bin_dir: Path) -> Path: - if not target.exists(): - raise SystemExit(f"missing target for {name}: {target}") +def command_exists(argv: list[str]) -> bool: + if not argv: + return False + # Existing top-level launchers are executable targets. New Hermes shims call + # Python explicitly, so the script itself only has to exist. + for part in argv[1:] if Path(argv[0]) == Path(sys.executable) else argv[:1]: + if part.startswith("-"): + continue + path = Path(part) + if path.is_absolute() and not path.exists(): + return False + return True + + +def render_command(argv: list[str]) -> str: + return " ".join(shlex.quote(part) for part in argv) + + +def write_shim(name: str, argv: list[str], bin_dir: Path) -> Path: + if not command_exists(argv): + raise SystemExit(f"missing target for {name}: {render_command(argv)}") shim = bin_dir / name content = ( "#!/usr/bin/env bash\n" "set -euo pipefail\n" - f"exec {shlex.quote(str(target))} \"$@\"\n" + f"exec {render_command(argv)} \"$@\"\n" ) shim.write_text(content, encoding="utf-8") @@ -49,7 +70,7 @@ def write_shim(name: str, target: Path, bin_dir: Path) -> Path: return shim -def check_shim(name: str, target: Path, bin_dir: Path) -> bool: +def check_shim(name: str, argv: list[str], bin_dir: Path) -> bool: shim = bin_dir / name if not shim.exists(): @@ -61,20 +82,21 @@ def check_shim(name: str, target: Path, bin_dir: Path) -> bool: return False text = shim.read_text(encoding="utf-8", errors="replace") - if str(target) not in text: - print(f"FAIL: shim does not point to expected target: {shim} -> {target}") + expected = render_command(argv) + if expected not in text: + print(f"FAIL: shim does not point to expected command: {shim} -> {expected}") return False - print(f"OK: {name} -> {target}") + print(f"OK: {name} -> {expected}") return True def install(bin_dir: Path) -> int: make_private_dir(bin_dir) - for name, target in COMMANDS.items(): - shim = write_shim(name, target, bin_dir) - print(f"installed: {shim} -> {target}") + for name, argv in COMMANDS.items(): + shim = write_shim(name, argv, bin_dir) + print(f"installed: {shim} -> {render_command(argv)}") print() print("Add this to your shell PATH if it is not already present:") @@ -85,8 +107,8 @@ def install(bin_dir: Path) -> int: def check(bin_dir: Path) -> int: ok = True - for name, target in COMMANDS.items(): - ok = check_shim(name, target, bin_dir) and ok + for name, argv in COMMANDS.items(): + ok = check_shim(name, argv, bin_dir) and ok return 0 if ok else 1 diff --git a/scripts/seatbelt.py b/scripts/seatbelt.py index 8cbdaec..09dfe90 100755 --- a/scripts/seatbelt.py +++ b/scripts/seatbelt.py @@ -89,6 +89,17 @@ def _sbpl_literal(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') +def _dedupe_paths(paths: list[Path]) -> list[Path]: + deduped: list[Path] = [] + seen: set[str] = set() + for path in paths: + key = str(path) + if key not in seen: + deduped.append(path) + seen.add(key) + return deduped + + def build_profile( *, workspace: Path, @@ -96,13 +107,15 @@ def build_profile( tmp_dir: Path, proxy_port: int | None, extra_read_paths: list[Path] | None = None, + extra_write_paths: list[Path] | None = None, ) -> str: """Generate a deny-by-default SBPL profile for a light-mode agent. - Read/write is confined to `workspace`, `state_dir`, and `tmp_dir`. - Reads of system/runtime paths required by the Node/omp runtime are - allowed. Everything else — including other projects, the personal - account, and all credential paths — is denied by default (no allow rule). + Read/write is confined to `workspace`, `state_dir`, `tmp_dir`, and any + explicit guard-scoped `extra_write_paths`. Reads of system/runtime paths + required by the Node/Python/Hermes runtime are allowed. Everything else — + including other projects, the personal account, and all credential paths — + is denied by default (no allow rule). Network: * proxy_port set -> outbound allowed ONLY to 127.0.0.1: @@ -112,9 +125,12 @@ def build_profile( ws = _sbpl_literal(str(workspace)) st = _sbpl_literal(str(state_dir)) tmp = _sbpl_literal(str(tmp_dir)) + extra_reads = _dedupe_paths(extra_read_paths or []) + extra_writes = _dedupe_paths(extra_write_paths or []) - # System/runtime read roots the Node runtime and common CLIs need. - # These are read-only; none grant access to user data. + # System/runtime read roots the Node/Python runtime and common CLIs need. + # These are read-only; none grant access to user data. The xcode-select + # database link is needed so Apple shims do not falsely trigger CLT install. read_roots = [ "/usr", "/bin", @@ -125,6 +141,10 @@ def build_profile( "/opt/local", "/private/var/db/dyld", "/private/var/db/timezone", + "/private/var/db/xcode_select_link", + "/var/db/xcode_select_link", + "/private/var/select", + "/var/select", "/etc", "/private/etc", "/dev", @@ -144,16 +164,20 @@ def build_profile( lines.append("(allow sysctl-read)") lines.append("(allow mach-lookup) ; broad (DNS/notify daemons); scope in a later pass") lines.append("(allow ipc-posix-shm)") + lines.append("; prompt_toolkit/Hermes needs terminal ioctl for raw-mode TTY control") + lines.append("(allow file-ioctl)") lines.append("") lines.append("; --- read-only system/runtime paths (no user data) ---") lines.append("(allow file-read*") lines.append(read_root_rules) - for extra in extra_read_paths or []: + for extra in extra_reads: lines.append(f' (subpath "{_sbpl_literal(str(extra))}")') - # The workspace, state, and tmp are readable as well as writable. + # The workspace, state, tmp, and extra write paths are readable as well as writable. lines.append(f' (subpath "{ws}")') lines.append(f' (subpath "{st}")') lines.append(f' (subpath "{tmp}")') + for extra in extra_writes: + lines.append(f' (subpath "{_sbpl_literal(str(extra))}")') lines.append(" (literal \"/\")") lines.append(" (literal \"/dev/null\")") lines.append(" (literal \"/dev/random\")") @@ -165,6 +189,8 @@ def build_profile( lines.append(f' (subpath "{ws}")') lines.append(f' (subpath "{st}")') lines.append(f' (subpath "{tmp}")') + for extra in extra_writes: + lines.append(f' (subpath "{_sbpl_literal(str(extra))}")') lines.append(" (literal \"/dev/null\")") lines.append(" (literal \"/dev/dtracehelper\")") lines.append(")") diff --git a/scripts/test-hermes-guard.sh b/scripts/test-hermes-guard.sh new file mode 100755 index 0000000..f631e78 --- /dev/null +++ b/scripts/test-hermes-guard.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +pass() { + echo "PASS: $*" +} + +assert_contains() { + local haystack="$1" + local needle="$2" + + if ! printf '%s\n' "$haystack" | grep -Fq "$needle"; then + echo "$haystack" >&2 + fail "expected output to contain: $needle" + fi +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + + if printf '%s\n' "$haystack" | grep -Fq "$needle"; then + echo "$haystack" >&2 + fail "expected output not to contain: $needle" + fi +} + +python3 -m py_compile \ + scripts/hermes_common.py \ + scripts/hermes-light-launch.py \ + scripts/hermes-profile-doctor.py \ + scripts/hermes-doctor.py \ + scripts/seatbelt.py +pass "Hermes Python scripts compile" + +for policy in policies/hermes-v1.yml policies/hermes-orchestrator.yml policies/hermes-research.yml policies/hermes-dev.yml; do + python3 scripts/validate-policy.py "$policy" >/dev/null + pass "policy validates: $policy" +done + +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/hermes-guard-test.XXXXXX")" +TMP_ROOT="$(python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).resolve())' "$TMP_ROOT")" +trap 'rm -rf "$TMP_ROOT"' EXIT +mkdir -p \ + "$TMP_ROOT/projects/probe" \ + "$TMP_ROOT/personal" \ + "$TMP_ROOT/bin" \ + "$TMP_ROOT/runtime/hermes-agent" \ + "$TMP_ROOT/home/.hermes/hermes-agent" +chmod 700 "$TMP_ROOT/home" "$TMP_ROOT/home/.hermes" +touch "$TMP_ROOT/home/.hermes/config.yaml" "$TMP_ROOT/home/.hermes/state.db" + +cat > "$TMP_ROOT/bin/fake-hermes" <<'INNER' +#!/usr/bin/env bash +set -euo pipefail + +for key in HERMES_HOME HOME TMPDIR HERMES_GUARD_PROFILE HERMES_GUARD_POLICY_EFFECTIVE; do + if [ -z "${!key:-}" ]; then + echo "missing $key" >&2 + exit 88 + fi +done + +[ "$HERMES_GUARD_PROFILE" = "chief-of-staff" ] || { echo "unexpected profile: $HERMES_GUARD_PROFILE" >&2; exit 88; } +[ "$HOME" = "$TEST_HOME" ] || { echo "HOME not canonical account home: $HOME" >&2; exit 88; } +[ "$HERMES_HOME" = "$TEST_HOME/.hermes" ] || { echo "HERMES_HOME not canonical account Hermes home: $HERMES_HOME" >&2; exit 88; } + +for key in XDG_CONFIG_HOME XDG_CACHE_HOME XDG_DATA_HOME XDG_STATE_HOME; do + value="${!key:-}" + case "$value" in + *hermes/profiles/chief-of-staff*) + echo "$key leaked old profile-local path: $value" >&2 + exit 88 + ;; + esac +done + +for token_key in GITHUB_TOKEN GH_TOKEN GITHUB_PAT COPILOT_GITHUB_TOKEN OPENAI_API_KEY ANTHROPIC_API_KEY OPENROUTER_API_KEY OPENCODE_API_KEY OPENCODE_GO_API_KEY; do + if [ "${!token_key+x}" = "x" ]; then + echo "token leaked to Hermes process: $token_key" >&2 + exit 88 + fi +done + +printf 'FAKE_HERMES_OK\n' +INNER +chmod 700 "$TMP_ROOT/bin/fake-hermes" + +COMMON_ENV=( + "HOME=$TMP_ROOT/home" + "TEST_HOME=$TMP_ROOT/home" + "OMP_GUARD_ALLOWED_ROOT=$TMP_ROOT" + "OMP_GUARD_PERSONAL_HOME=$TMP_ROOT/personal" + "HERMES_GUARD_HERMES_BIN=$TMP_ROOT/bin/fake-hermes" + "HERMES_GUARD_RUNTIME_DIR=$TMP_ROOT/runtime/hermes-agent" + "HERMES_GUARD_SEATBELT=off" + "GITHUB_TOKEN=do-not-forward" + "GH_TOKEN=do-not-forward" + "OPENAI_API_KEY=do-not-forward" + "ANTHROPIC_API_KEY=do-not-forward" +) + +out="$(env "${COMMON_ENV[@]}" python3 scripts/hermes-profile-doctor.py chief-of-staff --init)" +assert_contains "$out" "OK: profile initialized" +pass "profile doctor initializes chief-of-staff" + +out="$(env "${COMMON_ENV[@]}" python3 scripts/hermes-profile-doctor.py chief-of-staff)" +assert_contains "$out" "errors: 0" +assert_contains "$out" "$TMP_ROOT/hermes/profiles/chief-of-staff/state" +assert_contains "$out" "$TMP_ROOT/hermes/profiles/chief-of-staff/sessions" +assert_contains "$out" "$TMP_ROOT/hermes/profiles/chief-of-staff/kanban" +assert_contains "$out" "$TMP_ROOT/hermes/profiles/chief-of-staff/cron" +assert_contains "$out" "$TMP_ROOT/hermes/profiles/chief-of-staff/checkpoints" +pass "profile doctor still validates profile identity directories" + +out="$(env "${COMMON_ENV[@]}" python3 - <<'PY' +from pathlib import Path +import os +import sys + +sys.path.insert(0, "scripts") +import hermes_common as hc + +canonical, _ = hc.validate_canonical_hermes_home(Path(os.environ["HOME"])) +expected = Path(os.environ["HOME"]) / ".hermes" +if canonical != expected.resolve(): + raise SystemExit(f"unexpected canonical home: {canonical}") +log_dir = hc.ensure_guard_log_dir() +tmp_dir = hc.ensure_guard_tmp_dir() +write_paths = hc.canonical_runtime_write_paths(canonical, tmp_dir, log_dir) +required = [ + canonical, + Path(os.environ["HOME"]) / ".local" / "state" / "hermes", + Path(os.environ["HOME"]) / ".local" / "share" / "hermes", + Path(os.environ["HOME"]) / ".cache" / "hermes", + Path(os.environ["HOME"]) / ".config" / "hermes", + log_dir, + tmp_dir, +] +for path in required: + if not path.exists(): + raise SystemExit(f"missing canonical Stage A path: {path}") + if path.resolve() not in write_paths: + raise SystemExit(f"missing canonical write path: {path}") +print("CANONICAL_STAGE_A_PATHS_OK") +PY +)" +assert_contains "$out" "CANONICAL_STAGE_A_PATHS_OK" +pass "canonical Hermes home, sidecar, and guard runtime paths validate" + +out="$(env "${COMMON_ENV[@]}" python3 - <<'PY' +from pathlib import Path +import os +import sys + +sys.path.insert(0, "scripts") +import hermes_common as hc +import seatbelt + +workspace = Path(os.environ["OMP_GUARD_ALLOWED_ROOT"]) / "projects" / "probe" +canonical, _ = hc.validate_canonical_hermes_home(Path(os.environ["HOME"])) +tmp_dir = hc.ensure_guard_tmp_dir() +log_dir = hc.ensure_guard_log_dir() +write_paths = hc.canonical_runtime_write_paths(canonical, tmp_dir, log_dir) +profile = seatbelt.build_profile( + workspace=workspace, + state_dir=canonical, + tmp_dir=tmp_dir, + proxy_port=12345, + extra_read_paths=[Path(os.environ["HERMES_GUARD_RUNTIME_DIR"])], + extra_write_paths=write_paths, +) +required = [ + "(allow file-ioctl)", + str(workspace), + str(canonical), + str(Path(os.environ["HOME"]) / ".local" / "state" / "hermes"), + str(Path(os.environ["HOME"]) / ".cache" / "hermes"), + str(tmp_dir), + str(log_dir), + str(Path(os.environ["HERMES_GUARD_RUNTIME_DIR"])), + "/private/var/db/xcode_select_link", +] +for needle in required: + if needle not in profile: + raise SystemExit(f"missing Seatbelt rule for {needle}") +for forbidden in ( + f'(subpath "{Path(os.environ["HOME"])}")', + f'(subpath "{Path(os.environ["HOME"]) / ".local"}")', + f'(subpath "{Path(os.environ["HOME"]) / ".cache"}")', + f'(subpath "{Path(os.environ["OMP_GUARD_ALLOWED_ROOT"])}")', + f'(subpath "{Path(os.environ["OMP_GUARD_ALLOWED_ROOT"]) / "personal"}")', +): + if forbidden in profile: + raise SystemExit(f"Seatbelt profile unexpectedly grants broad path: {forbidden}") +print("SEATBELT_CANONICAL_PROFILE_OK") +PY +)" +assert_contains "$out" "SEATBELT_CANONICAL_PROFILE_OK" +pass "Seatbelt profile allows canonical Hermes sidecars but not broad account/root paths" + +set +e +out="$(env "${COMMON_ENV[@]}" python3 scripts/hermes-profile-doctor.py ../bad 2>&1)" +code="$?" +set -e +[ "$code" -ne 0 ] || fail "unsafe profile name unexpectedly passed" +assert_contains "$out" "unsafe Hermes profile name" +pass "unsafe profile name is rejected" + +out="$( + ( + cd "$TMP_ROOT/projects/probe" + env "${COMMON_ENV[@]}" python3 "$ROOT/scripts/hermes-light-launch.py" --profile chief-of-staff --version + ) 2>&1 +)" +assert_contains "$out" "FAKE_HERMES_OK" +pass "hermes-light launches fake Hermes with canonical account home" + +central_launch_log="$TMP_ROOT/.omp-guard-logs/hermes-launch.log" +profile_launch_log="$TMP_ROOT/hermes/profiles/chief-of-staff/logs/hermes-launch.log" +[ -f "$central_launch_log" ] || fail "missing central Hermes launch log: $central_launch_log" +[ -f "$profile_launch_log" ] || fail "missing profile-local Hermes launch log: $profile_launch_log" +for launch_log in "$central_launch_log" "$profile_launch_log"; do + log_text="$(cat "$launch_log")" + assert_contains "$log_text" "profile=chief-of-staff" + assert_contains "$log_text" "home_mode=canonical-hermes-home" + assert_contains "$log_text" "seatbelt=off" + assert_contains "$log_text" "home=$TMP_ROOT/home" + assert_contains "$log_text" "hermes_home=$TMP_ROOT/home/.hermes" + assert_contains "$log_text" "xdg_state_home=(unset)" + assert_contains "$log_text" "tokens_scrubbed=GITHUB_TOKEN,GH_TOKEN,OPENAI_API_KEY,ANTHROPIC_API_KEY" + assert_contains "$log_text" "runtime_read_paths=$TMP_ROOT/bin,$TMP_ROOT/runtime/hermes-agent,$TMP_ROOT/home/.hermes/hermes-agent" + assert_contains "$log_text" "$TMP_ROOT/home/.local/state/hermes" + assert_contains "$log_text" "$TMP_ROOT/home/.cache/hermes" + assert_contains "$log_text" "$TMP_ROOT/.omp-guard-logs" + assert_contains "$log_text" "$TMP_ROOT/.omp-guard-tmp/hermes-light" +done +pass "Hermes launch logs capture canonical Stage A runtime evidence" + +out="$(env "${COMMON_ENV[@]}" python3 scripts/hermes-doctor.py)" +assert_contains "$out" "canonical Hermes home is exactly $TMP_ROOT/home/.hermes" +assert_contains "$out" "central Hermes launch log is writable" +pass "hermes-doctor validates canonical Hermes home and central launch log" + +set +e +out="$(env "${COMMON_ENV[@]}" python3 scripts/hermes-light-launch.py --profile chief-of-staff --version 2>&1)" +code="$?" +set -e +[ "$code" -ne 0 ] || fail "launcher unexpectedly allowed cwd outside AgentWork/projects" +assert_contains "$out" "refusing to launch outside AgentWork" +pass "hermes-light refuses cwd outside AgentWork/projects" + +out="$(env "${COMMON_ENV[@]}" python3 scripts/install-shims.py --bin-dir "$TMP_ROOT/bin")" +assert_contains "$out" "hermes-light" +assert_contains "$out" "hermes-profile-doctor" +pass "install-shims includes Hermes commands" + +out="$(env "${COMMON_ENV[@]}" python3 scripts/install-shims.py --bin-dir "$TMP_ROOT/bin" --check)" +assert_contains "$out" "OK: hermes-light" +assert_contains "$out" "OK: hermes-doctor" +pass "Hermes command shims validate" + +echo "All Hermes guard smoke tests passed."