Skip to content

feat: hermes and opencode integration (adapters + auto-config)#12

Open
mayankjain0141 wants to merge 3 commits into
mainfrom
feat/hermes-opencode-integration
Open

feat: hermes and opencode integration (adapters + auto-config)#12
mayankjain0141 wants to merge 3 commits into
mainfrom
feat/hermes-opencode-integration

Conversation

@mayankjain0141

Copy link
Copy Markdown
Owner

Summary

  • Hermes shell hook adapter (cmd/nixis-hook/adapter_hermes.go): detects hermes payloads by cwd field, returns hermes-native {"decision":"block"} JSON on deny, fails open if daemon unreachable. Prepended in adapter registry via init() so it takes priority over ClaudeCodeAdapter (both carry hook_event_name).
  • Hermes Python plugin (integrations/hermes/): stdlib-only plugin that registers pre_tool_call/post_tool_call hooks, calls nixis HTTP /v1/check at 200ms timeout. Install to ~/.hermes/plugins/nixis/.
  • OpenCode TypeScript plugin (integrations/opencode/): event-subscription plugin that subscribes to session.next.tool.called for audit/classification. OpenCode has no blocking hook mechanism so this is audit-only by architecture.
  • nixis setup auto-config (cmd/nixis/setup_hermes.go, setup_opencode.go): two new optional steps detect and patch hermes config.yaml (shell hook registration with # nixis-begin/end markers) and opencode opencode.json (instructions entry). Both skip gracefully if tool not installed; full uninstall support included.

Test plan

  • go test -race ./cmd/nixis-hook/ — 10 new hermes adapter tests pass
  • go test -race ./cmd/nixis/ — 21 new setup tests (12 hermes + 9 opencode) pass
  • Hermes adapter idempotency: double-patching config is a no-op
  • OpenCode config patching creates file if absent, appends if present, is idempotent
  • Uninstall paths clean up marker blocks and JSON entries correctly

- HermesAdapter: detects hermes payloads by cwd+hook_event_name presence,
  returns {} on allow and {"decision":"block","reason":"..."} on deny, always
  exit 0 (hermes reads JSON body, not exit code)
- init() in adapter_hermes.go prepends HermesAdapter before ClaudeCodeAdapter
  so the more-specific format is detected first (both carry hook_event_name)
- integrations/hermes/plugin.yaml: hermes plugin manifest
- integrations/hermes/__init__.py: stdlib-only Python plugin calling HTTP /v1/check
- integrations/opencode/package.json: npm package manifest
- integrations/opencode/src/index.ts: TypeScript plugin with onToolCalled hook
Add steps 6b and 6c to nixis setup that detect and patch hermes-agent
config.yaml (shell hook registration) and opencode opencode.json
(instructions entry). Both steps are optional and skip gracefully when
the tool is not installed. Uninstall steps 2b/2c clean up the entries.
@mayankjain0141 mayankjain0141 force-pushed the feat/hermes-opencode-integration branch from 99bda32 to b7e3b4e Compare June 2, 2026 20:43
@caioribeiroclw-pixel

Copy link
Copy Markdown

Nice adapter direction — the Hermes/OpenCode split exposes a subtle governance issue that is worth making first-class before more host adapters land.

A deny from Nixis does not mean the same thing across hosts now:

  • Claude/Hermes can actually block at the hook boundary.
  • OpenCode is described as audit-only / host-dependent.
  • daemon timeout or parse failure becomes fail-open.

For the dashboard/audit trail, I would record the effective enforcement boundary separately from the policy decision, e.g.

{
  "host": "hermes|opencode|claude_code",
  "hookEvent": "pre_tool_call|session.next.tool.called",
  "policyDecision": "allow|audit|deny|require_approval",
  "enforcementMode": "blocking|audit_only|fail_open",
  "hostResponse": "block_json|exit_code_2|none",
  "deniedButNotEnforced": false,
  "reasonClass": "destructive_git",
  "auditRef": "audit_..."
}

That keeps the agent/operator from reading “policy denied” as “the host definitely prevented execution” when the adapter could only observe or had to fail open. It also gives a clean place for the model-visible denial vs operator audit split: the model gets a short reason class/safe alternative; the operator audit keeps policy ids, hashes, taint state, cwd hash, retry safety, etc.

I put a tiny checker/example of that split here while testing the same boundary: https://github.com/caioribeiroclw-pixel/pluribus/tree/main/examples/agent-firewall-denial-audit

No need to adopt that shape exactly; the main point is just to preserve policyDecision vs effectiveEnforcement as separate facts in PR #12, because adapter expansion is where those start diverging.

@mayankjain0141

Copy link
Copy Markdown
Owner Author

@caioribeiroclw-pixel Great catch.

The biggest issue for me is exactly what you pointed out: seeing "deny" in an audit log strongly implies the action was actually blocked. If an adapter is only observing and can't enforce, that's pretty misleading.

I also think this becomes more important as we add more adapters. Different hosts have different capabilities — some can block, some are audit-only, some may fail open. If we don't represent that distinction explicitly, we'll end up handling it inconsistently across adapters later.

Your proposed shape makes sense. I'll update #12 so the adapter passes through its enforcement capability/mode and the audit record reflects what actually happened rather than only what the policy evaluation returned.

The pluribus example was helpful as well. The reason-class split lines up with something I'd been considering but hadn't fully settled on yet, still thinking on this.

For reason classification, I'm leaning toward an explicit reasonClass field in policy YAML. With 750 policies in Nixis, deriving categories from policy text via parsing or some other classifier becomes pretty fragile. It would be a 1 time manual effort initially, though not sure if this is scalable.

Is that roughly the approach you ended up taking?

@caioribeiroclw-pixel

Copy link
Copy Markdown

Yes — I would make reasonClass explicit metadata, not inferred from policy text.

For a policy set that large, I’d avoid making the first version a migration blocker. Something like:

  • reasonClass optional at first, with audit falling back to unknown / policy_unspecified
  • allow a small controlled enum (secret_exfiltration, destructive_write, network_after_secret, credential_access, privilege_escalation, policy_error, etc.)
  • keep the human-facing denial reason separate from reasonClass; the class is for dashboards/routing/redaction, not for explaining details to the model
  • add a linter/warning for new or touched policies missing reasonClass, rather than forcing all 750 to be classified in one PR
  • optionally let policies carry tags too, but keep one primary reasonClass so rollups don’t become ambiguous

So the audit line can say: policy decision was deny, adapter enforcement was blocking/audit-only/fail-open, and the primary reason class was X. That gives you useful aggregate safety reporting without trying to reverse-engineer intent from CEL/name/text later.

I’d probably classify only the policies touched by #12 plus a few high-value examples first, then backfill the rest opportunistically once the enum feels stable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants