From 664881bece1263f2cd9cbaf623693186e6b75c0f Mon Sep 17 00:00:00 2001 From: Prince Date: Tue, 23 Jun 2026 17:57:01 +0530 Subject: [PATCH 1/8] update --- if-integration-backend/pyproject.toml | 3 + .../if_security_backend/bundles/__init__.py | 1 + .../if_security_backend/bundles/dynamic.py | 76 +++++++++++ .../config/profiles/core.yaml | 1 + .../config/profiles/executor.yaml | 25 ++-- .../if_security_backend/runtime/bundles.py | 20 +++ .../src/if_security_backend/runtime/policy.py | 5 +- .../tests/test_dynamic_bundle.py | 129 ++++++++++++++++++ integrations/hermes/README.md | 37 ++++- .../adapter/src/hermes_adapter/mapper.py | 19 ++- .../hermes/adapter/tests/test_adapter.py | 18 +++ integrations/hermes/agent.json | 10 +- integrations/hermes/governance/README.md | 31 +++++ integrations/hermes/governance/tools.yaml | 21 ++- .../intentframe-gate/governance_loader.py | 2 +- integrations/hermes/policy.yaml | 10 ++ .../shared/src/hermes_governance/__init__.py | 2 + .../shared/src/hermes_governance/loader.py | 8 +- .../hermes/shared/tests/test_governance.py | 7 + .../hermes_governance_contract.py | 62 +++++++++ .../hermes_integrate.py | 86 ++++++++++++ .../intentframe_integrations/policy_manage.py | 4 +- .../test_actions_manifest.py | 95 +++++++++++++ .../test_hermes_install.py | 19 +++ .../test_policy_manage.py | 35 +++++ tests/scripts/e2e.sh | 2 + 26 files changed, 695 insertions(+), 33 deletions(-) create mode 100644 if-integration-backend/src/if_security_backend/bundles/__init__.py create mode 100644 if-integration-backend/src/if_security_backend/bundles/dynamic.py create mode 100644 if-integration-backend/src/if_security_backend/runtime/bundles.py create mode 100644 if-integration-backend/tests/test_dynamic_bundle.py create mode 100644 integrations/hermes/governance/README.md create mode 100644 tests/intentframe_integrations/test_actions_manifest.py diff --git a/if-integration-backend/pyproject.toml b/if-integration-backend/pyproject.toml index d220540..9fe017f 100644 --- a/if-integration-backend/pyproject.toml +++ b/if-integration-backend/pyproject.toml @@ -22,6 +22,9 @@ dependencies = [ [project.scripts] if-integration-backend = "if_security_backend.cli:main" +[project.entry-points."intentframe.bundles"] +dynamic = "if_security_backend.bundles.dynamic:register_bundles" + [project.entry-points."intentframe.executor_packs"] validate_only = "if_security_backend.executor_pack:register_all" diff --git a/if-integration-backend/src/if_security_backend/bundles/__init__.py b/if-integration-backend/src/if_security_backend/bundles/__init__.py new file mode 100644 index 0000000..ed932cb --- /dev/null +++ b/if-integration-backend/src/if_security_backend/bundles/__init__.py @@ -0,0 +1 @@ +"""IntentFrame action bundle plugins for if-integration-backend.""" diff --git a/if-integration-backend/src/if_security_backend/bundles/dynamic.py b/if-integration-backend/src/if_security_backend/bundles/dynamic.py new file mode 100644 index 0000000..b9ec6d6 --- /dev/null +++ b/if-integration-backend/src/if_security_backend/bundles/dynamic.py @@ -0,0 +1,76 @@ +"""Generic pass-through action bundle — registers action IDs from a manifest file. + +Agent-agnostic: reads IF_DYNAMIC_BUNDLE_MANIFEST (path to a comma-separated list of +action IDs). If the env var is unset, this bundle registers nothing and native tools +keep working. The manifest is a static dev-shipped superset; user governance toggles +do not change it. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from intentframe_bundle_sdk.action import ActionBundle +from intentframe_bundle_sdk.types import ActionPermission, BundleConfigError + +NATIVE_ACTION_IDS = frozenset({"RUN_COMMAND", "WRITE_HOST_FILE", "DELETE_HOST_FILE"}) + + +def parse_manifest_ids(text: str) -> frozenset[str]: + """Parse comma-separated action IDs (tolerates whitespace and trailing commas).""" + ids = [part.strip() for part in text.split(",") if part.strip()] + if not ids: + raise ValueError("manifest must contain at least one action id") + return frozenset(ids) + + +class GenericDynamicBundle(ActionBundle): + """Semantic-only bundle — no deterministic enforcement; AE + Guardian judge.""" + + bundle_id = "dynamic" + + def __init__(self, action_ids: frozenset[str]) -> None: + if not action_ids: + raise ValueError("GenericDynamicBundle requires at least one action_id") + overlap = action_ids & NATIVE_ACTION_IDS + if overlap: + raise ValueError( + f"manifest action ids {sorted(overlap)!r} collide with native-kit; " + "keep RUN_COMMAND, WRITE_HOST_FILE, and DELETE_HOST_FILE out of the manifest" + ) + self.action_ids = action_ids + + def validate_constraints(self, action_permission: ActionPermission) -> None: + if action_permission.constraints is not None: + raise BundleConfigError( + f"bundle {self.bundle_id!r} does not support policy constraints; " + "use safe: false with no constraints for semantic-only actions" + ) + + +def register_bundles(registry) -> None: + raw = os.environ.get("IF_DYNAMIC_BUNDLE_MANIFEST", "").strip() + if not raw: + return + + path = Path(raw).expanduser() + if not path.is_file(): + raise BundleConfigError( + f"IF_DYNAMIC_BUNDLE_MANIFEST points to missing file: {path}" + ) + + try: + ids = parse_manifest_ids(path.read_text(encoding="utf-8")) + except ValueError as exc: + raise BundleConfigError(str(exc)) from exc + + try: + registry.register_action_bundle(GenericDynamicBundle(ids)) + except ValueError as exc: + message = str(exc) + if "duplicate action_id" in message: + raise BundleConfigError( + f"{message}; keep native action ids out of IF_DYNAMIC_BUNDLE_MANIFEST" + ) from exc + raise BundleConfigError(message) from exc diff --git a/if-integration-backend/src/if_security_backend/config/profiles/core.yaml b/if-integration-backend/src/if_security_backend/config/profiles/core.yaml index 5f95c9c..9c2904d 100644 --- a/if-integration-backend/src/if_security_backend/config/profiles/core.yaml +++ b/if-integration-backend/src/if_security_backend/config/profiles/core.yaml @@ -2,6 +2,7 @@ bundles: - native + - dynamic executor: mode: real diff --git a/if-integration-backend/src/if_security_backend/config/profiles/executor.yaml b/if-integration-backend/src/if_security_backend/config/profiles/executor.yaml index bf3b63d..6869145 100644 --- a/if-integration-backend/src/if_security_backend/config/profiles/executor.yaml +++ b/if-integration-backend/src/if_security_backend/config/profiles/executor.yaml @@ -1,42 +1,33 @@ -# Generic validate-only executor profile (noop adapter for configured action types). - packs: - - posix - - validate_only - +- posix +- validate_only transport: type: unix_socket options: socket_path: ~/.intentframe/run/executor.sock - auth: type: guardian_hmac options: - secret_key: "if_backend_dev_only_change_in_prod" - + secret_key: if_backend_dev_only_change_in_prod credentials: backend: service options: {} - worker_pool: max_workers: 2 default_timeout_seconds: 30.0 - adapters: enabled: - - validate_only - + - validate_only pack_options: validate_only: supported_actions: - - RUN_COMMAND - - WRITE_HOST_FILE - - DELETE_HOST_FILE - + - DELETE_HOST_FILE + - HERMES_CRONJOB + - RUN_COMMAND + - WRITE_HOST_FILE storage: database_path: null log_path: null - logging: level: INFO format: console diff --git a/if-integration-backend/src/if_security_backend/runtime/bundles.py b/if-integration-backend/src/if_security_backend/runtime/bundles.py new file mode 100644 index 0000000..f721b90 --- /dev/null +++ b/if-integration-backend/src/if_security_backend/runtime/bundles.py @@ -0,0 +1,20 @@ +"""Resolve bundle package refs from the core profile.""" + +from __future__ import annotations + +import yaml + +from if_security_backend.runtime.paths import core_config_path + + +def load_core_bundle_packages() -> list[str]: + """Return bundle entry-point short names from core.yaml (e.g. native, dynamic).""" + path = core_config_path() + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + bundles = raw.get("bundles") + if not isinstance(bundles, list) or not bundles: + raise ValueError(f"core profile {path} must declare a non-empty bundles list") + parsed = [str(item).strip() for item in bundles] + if not all(parsed): + raise ValueError(f"core profile {path} bundles must be non-empty strings") + return parsed diff --git a/if-integration-backend/src/if_security_backend/runtime/policy.py b/if-integration-backend/src/if_security_backend/runtime/policy.py index 4c8192d..c2b74ca 100644 --- a/if-integration-backend/src/if_security_backend/runtime/policy.py +++ b/if-integration-backend/src/if_security_backend/runtime/policy.py @@ -10,10 +10,9 @@ from policy_registry.seeds import load_policy_seed from if_security_backend.agent_config import default_test_policy_path, load_agent_pack +from if_security_backend.runtime.bundles import load_core_bundle_packages from if_security_backend.runtime.paths import run_dir -DEFAULT_BUNDLE = "intentframe_native_kit.intentframe_native_bundles" - def resolve_user_id(explicit: str | None = None) -> str: user_id = explicit or os.environ.get("INTENTFRAME_USER_ID") @@ -67,7 +66,7 @@ def seed_policy( ) if validate_bundles: - validate_policy_with_bundles(policy, [DEFAULT_BUNDLE]) + validate_policy_with_bundles(policy, load_core_bundle_packages()) socket = str(run_dir() / "policy-registry.sock") with PolicyRegistryClient(socket_path=socket) as client: diff --git a/if-integration-backend/tests/test_dynamic_bundle.py b/if-integration-backend/tests/test_dynamic_bundle.py new file mode 100644 index 0000000..1749cd9 --- /dev/null +++ b/if-integration-backend/tests/test_dynamic_bundle.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Unit tests for GenericDynamicBundle and manifest parsing.""" + +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +BACKEND_SRC = REPO_ROOT / "if-integration-backend" / "src" +if str(BACKEND_SRC) not in sys.path: + sys.path.insert(0, str(BACKEND_SRC)) + + +class TestManifestParsing(unittest.TestCase): + def test_parse_comma_separated_ids(self) -> None: + from if_security_backend.bundles.dynamic import parse_manifest_ids + + self.assertEqual( + parse_manifest_ids("HERMES_CRONJOB, HERMES_WEB_EXTRACT"), + frozenset({"HERMES_CRONJOB", "HERMES_WEB_EXTRACT"}), + ) + + def test_parse_tolerates_trailing_comma_and_whitespace(self) -> None: + from if_security_backend.bundles.dynamic import parse_manifest_ids + + self.assertEqual( + parse_manifest_ids(" HERMES_CRONJOB , \n"), + frozenset({"HERMES_CRONJOB"}), + ) + + def test_parse_empty_raises(self) -> None: + from if_security_backend.bundles.dynamic import parse_manifest_ids + + with self.assertRaises(ValueError): + parse_manifest_ids(" , ") + + +class TestGenericDynamicBundle(unittest.TestCase): + def test_rejects_native_action_overlap(self) -> None: + from if_security_backend.bundles.dynamic import GenericDynamicBundle + + with self.assertRaises(ValueError): + GenericDynamicBundle(frozenset({"RUN_COMMAND", "HERMES_CRONJOB"})) + + def test_rejects_constraints_at_boot_validation(self) -> None: + from if_security_backend.bundles.dynamic import GenericDynamicBundle + from intentframe_bundle_sdk.types import ActionPermission, BundleConfigError + + bundle = GenericDynamicBundle(frozenset({"HERMES_CRONJOB"})) + permission = ActionPermission( + safe=False, + constraints={"blocked_patterns": ["sudo"]}, + ) + with self.assertRaises(BundleConfigError): + bundle.validate_constraints(permission) + + def test_register_bundles_noop_without_env(self) -> None: + from if_security_backend.bundles import dynamic as dynamic_module + import intentframe_bundle_sdk.registry as registry + + previous = os.environ.pop("IF_DYNAMIC_BUNDLE_MANIFEST", None) + saved_actions = dict(registry._ACTION_BY_ID) + saved_instances = list(registry._ACTION_INSTANCES) + registry._ACTION_BY_ID.clear() + registry._ACTION_INSTANCES.clear() + try: + dynamic_module.register_bundles(registry) + self.assertEqual(len(registry._ACTION_INSTANCES), 0) + finally: + registry._ACTION_BY_ID.clear() + registry._ACTION_INSTANCES.clear() + registry._ACTION_BY_ID.update(saved_actions) + registry._ACTION_INSTANCES.extend(saved_instances) + if previous is not None: + os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = previous + + def test_register_bundles_reads_manifest_from_env(self) -> None: + from if_security_backend.bundles import dynamic as dynamic_module + from intentframe_bundle_sdk.registry import action_bundle_for + import intentframe_bundle_sdk.registry as registry + + with tempfile.TemporaryDirectory() as temp_dir: + manifest = Path(temp_dir) / "actions.manifest" + manifest.write_text("HERMES_CRONJOB", encoding="utf-8") + previous = os.environ.get("IF_DYNAMIC_BUNDLE_MANIFEST") + os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = str(manifest) + saved_actions = dict(registry._ACTION_BY_ID) + saved_instances = list(registry._ACTION_INSTANCES) + registry._ACTION_BY_ID.clear() + registry._ACTION_INSTANCES.clear() + try: + dynamic_module.register_bundles(registry) + bundle = action_bundle_for("HERMES_CRONJOB") + self.assertIsNotNone(bundle) + assert bundle is not None + self.assertEqual(bundle.bundle_id, "dynamic") + finally: + registry._ACTION_BY_ID.clear() + registry._ACTION_INSTANCES.clear() + registry._ACTION_BY_ID.update(saved_actions) + registry._ACTION_INSTANCES.extend(saved_instances) + if previous is None: + os.environ.pop("IF_DYNAMIC_BUNDLE_MANIFEST", None) + else: + os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = previous + + +class TestCoreBundlePackages(unittest.TestCase): + def test_load_core_bundle_packages(self) -> None: + from if_security_backend.runtime.bundles import load_core_bundle_packages + + packages = load_core_bundle_packages() + self.assertIn("native", packages) + self.assertIn("dynamic", packages) + + +def main() -> int: + loader = unittest.TestLoader() + suite = loader.loadTestsFromModule(sys.modules[__name__]) + result = unittest.TextTestRunner(verbosity=2).run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index d277d5f..fc437db 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -7,6 +7,8 @@ Hermes does **not** ship an IntentFrame executor pack or runtime. This folder pr | `agent.json` | Agent profile, adapter socket, exported `env` for Hermes plugin | | `policy.yaml` | Shipped policy **template** (copied to runtime on first integrate/start) | | `governance/tools.yaml` | Default governed-tool **template** (seeded to runtime on first integrate) | +| `governance/actions.manifest` | Static generic action IDs (copied to runtime on first integrate) | +| `governance/README.md` | Dev vs user ownership for governance artifacts | | `shared/` | `hermes-governance` package — contract loader for adapter | | `adapter/` | Hermes adapter sidecar (bridge client, tool mapping, HTTP/UDS server) | | `plugin/intentframe-gate/` | Hermes plugin — selective schema override + adapter gate | @@ -60,6 +62,7 @@ Configured in runtime `~/.intentframe/integrations/hermes/governance/tools.yaml` | `terminal`, `process` | `RUN_COMMAND` | Native Hermes handler, no IF gate | | `write_file`, `patch` (update/add) | `WRITE_HOST_FILE` | same | | `patch` (V4A delete) | `DELETE_HOST_FILE` | same | +| `cronjob` | `HERMES_CRONJOB` | same (semantic-only via dynamic bundle) | ```bash bin/intentframe-integrations governance list hermes @@ -67,7 +70,12 @@ bin/intentframe-integrations governance disable hermes write_file bin/intentframe-integrations governance enable hermes write_file ``` -Reads (`read_file`, `search_files`, …) stay **ungoverned** unless explicitly added to the catalog. +**Restart Hermes gateway + adapter** after enable/disable (governance is cached at +process start). IntentFrame backend does **not** need restart for governance toggles. + +Governance and policy are **independent gates** — they do not need to stay in sync. +Disabling a tool stops Hermes from sending intents; manifest and policy rows for that +action ID can remain harmlessly. See [`governance/README.md`](governance/README.md). ## Policy (runtime) @@ -106,9 +114,22 @@ bin/intentframe-integrations stop `integrate hermes` symlinks the plugin to `$HERMES_HOME/plugins/intentframe-gate`, merges `plugins.enabled` in `$HERMES_HOME/config.yaml`, syncs the adapter venv at -`~/.intentframe/integrations/hermes/.venv`, seeds runtime governance config at -`~/.intentframe/integrations/hermes/governance/tools.yaml` and runtime policy at -`~/.intentframe/integrations/hermes/policy.yaml` if missing. +`~/.intentframe/integrations/hermes/.venv`, and on first use copies runtime artifacts +from repo templates (never overwrites existing user files): + +- `~/.intentframe/integrations/hermes/governance/tools.yaml` +- `~/.intentframe/integrations/hermes/governance/actions.manifest` +- `~/.intentframe/integrations/hermes/policy.yaml` + +### Config ownership + +| What | Who edits | Restart after change | +|------|-----------|----------------------| +| Runtime `governance/tools.yaml` `enabled` | User (`governance enable\|disable`) | Hermes gateway + adapter | +| Runtime `policy.yaml` | User (`policy set\|reload\|reset`) | None (live registry) | +| Repo templates (`tools.yaml`, `actions.manifest`, `policy.yaml`, `agent.json`, `executor.yaml`) | Dev only | Backend restart if manifest/action IDs change | + +There is no user-facing `sync` command. Runtime CLI never rewrites repo templates. ### Governance env contract @@ -117,10 +138,16 @@ The CLI propagates governance config to child processes as follows: | Step | Behavior | |------|----------| -| `integrate hermes` | Prints `export HERMES_GOVERNANCE_YAML=…` using the **effective** value (`os.environ` overrides `agent.json`). | +| `integrate hermes` | Prints `export …` using the **effective** value (`os.environ` overrides `agent.json`). Copies governance yaml, actions manifest, and policy template to runtime on first use. | | `start hermes` (adapter) | `_adapter_env()` copies the parent environment and `setdefault`s `pack.agent.env` keys — an existing `HERMES_GOVERNANCE_YAML` in the shell is preserved. | | `gateway start hermes` | `build_gateway_env()` uses the same `setdefault` pattern; logs `Hermes governance config: …` on startup. | +| Env | Points to | Read by | +|-----|-----------|---------| +| `HERMES_GOVERNANCE_YAML` | Runtime `governance/tools.yaml` | Plugin gate, adapter | +| `IF_DYNAMIC_BUNDLE_MANIFEST` | Runtime `governance/actions.manifest` | Dynamic bundle at backend boot (registers all catalog generic action IDs) | +| `IF_AGENT_ADAPTER_SOCKET` | Adapter UDS | Plugin → adapter validate calls | + To use a custom governed-tool set without editing runtime yaml, export `HERMES_GOVERNANCE_YAML` **before** `start hermes` / `gateway start hermes`. Gateway E2E and catalog live tests rely on this (temp throwaway yaml). See diff --git a/integrations/hermes/adapter/src/hermes_adapter/mapper.py b/integrations/hermes/adapter/src/hermes_adapter/mapper.py index cea687c..537d1e5 100644 --- a/integrations/hermes/adapter/src/hermes_adapter/mapper.py +++ b/integrations/hermes/adapter/src/hermes_adapter/mapper.py @@ -10,6 +10,7 @@ IntentDict = dict[str, Any] MapperFn = Callable[[dict[str, Any]], list[IntentDict]] +GenericMapperFn = Callable[..., list[IntentDict]] _V4A_OP_RE = re.compile( r"^\*\*\*\s+(Update|Add|Delete)\s+File:\s*(.+)$", @@ -265,7 +266,20 @@ def map_patch(args: dict[str, Any]) -> list[IntentDict]: return intents -MAPPERS: dict[str, MapperFn] = { +def map_generic(tool: str, args: dict[str, Any], *, action: str) -> list[IntentDict]: + reason = validate_reason(args.get("reason")) + return [ + { + "action": action, + "reason": reason, + "target": tool, + "hermes_tool": tool, + "hermes_args": {key: value for key, value in args.items() if key != "reason"}, + } + ] + + +MAPPERS: dict[str, MapperFn | GenericMapperFn] = { "terminal": map_terminal, "process": map_process, "write_file": map_write_file, @@ -283,6 +297,9 @@ def map_tool(tool: str, args: dict[str, Any]) -> list[IntentDict]: if spec is None: raise ValidationError(f"Unsupported tool for validation: {tool!r}") + if spec.mapper == "generic": + return map_generic(tool, args, action=spec.action) + mapper = MAPPERS.get(spec.mapper) if mapper is None: raise ValidationError( diff --git a/integrations/hermes/adapter/tests/test_adapter.py b/integrations/hermes/adapter/tests/test_adapter.py index 385d97e..beb6a36 100644 --- a/integrations/hermes/adapter/tests/test_adapter.py +++ b/integrations/hermes/adapter/tests/test_adapter.py @@ -199,6 +199,24 @@ def test_unknown_tool(self) -> None: with self.assertRaises(ValidationError): map_tool("read_file", {"reason": "noop"}) + def test_map_generic_cronjob(self) -> None: + from hermes_adapter.mapper import map_generic, map_tool + + args = {"action": "list", "reason": "List scheduled jobs for audit"} + intents = map_generic("cronjob", args, action="HERMES_CRONJOB") + self.assertEqual(len(intents), 1) + intent = intents[0] + self.assertEqual(intent["action"], "HERMES_CRONJOB") + self.assertEqual(intent["target"], "cronjob") + self.assertEqual(intent["hermes_tool"], "cronjob") + self.assertEqual(intent["hermes_args"], {"action": "list"}) + + mapped = map_tool( + "cronjob", + {"action": "list", "reason": "List scheduled jobs for audit"}, + ) + self.assertEqual(mapped, intents) + class TestValidateService(unittest.TestCase): def test_allow(self) -> None: diff --git a/integrations/hermes/agent.json b/integrations/hermes/agent.json index 0d51a13..f5ab688 100644 --- a/integrations/hermes/agent.json +++ b/integrations/hermes/agent.json @@ -2,7 +2,12 @@ "agent_id": "hermes", "user_id": "dev_user", "agent_type": "hermes", - "action_types": ["RUN_COMMAND", "WRITE_HOST_FILE", "DELETE_HOST_FILE"], + "action_types": [ + "DELETE_HOST_FILE", + "HERMES_CRONJOB", + "RUN_COMMAND", + "WRITE_HOST_FILE" + ], "bridge_secret": "change-me-hermes-dev-only", "policy_file": "policy.yaml", "adapter": { @@ -14,6 +19,7 @@ }, "env": { "IF_AGENT_ADAPTER_SOCKET": "~/.intentframe/integrations/hermes/adapter.sock", - "HERMES_GOVERNANCE_YAML": "~/.intentframe/integrations/hermes/governance/tools.yaml" + "HERMES_GOVERNANCE_YAML": "~/.intentframe/integrations/hermes/governance/tools.yaml", + "IF_DYNAMIC_BUNDLE_MANIFEST": "~/.intentframe/integrations/hermes/governance/actions.manifest" } } diff --git a/integrations/hermes/governance/README.md b/integrations/hermes/governance/README.md new file mode 100644 index 0000000..e0ddeb8 --- /dev/null +++ b/integrations/hermes/governance/README.md @@ -0,0 +1,31 @@ +# Hermes governance artifacts + +| File | Owner | Purpose | +|------|-------|---------| +| `tools.yaml` (repo) | **Dev** | Tool catalog: names, mappers, action IDs, default `enabled` | +| `tools.yaml` (runtime) | **User** | Same catalog; user toggles `enabled` via `governance enable\|disable` | +| `actions.manifest` (repo) | **Dev** | Static list of all `mapper: generic` action IDs (full catalog superset) | +| `actions.manifest` (runtime) | **Copied once** | Seeded on `integrate hermes`; never overwritten by automation | + +## User-facing CLI (runtime only) + +- **`governance enable|disable hermes `** — flips `enabled` in runtime `tools.yaml`. + Restart **Hermes gateway + adapter** (governance is loaded at process start). + Does not touch manifest, policy, or repo files. +- **`policy set|reload|reset hermes`** — edits runtime `policy.yaml` and loads into + policy-registry immediately. **No** gateway or backend restart needed. + +Governance and policy are **independent**: disabling a tool stops Hermes from sending +intents; policy rows for that action ID can remain without harm. + +## Dev workflow (adding a generic tool) + +1. Add entry to `tools.yaml` with `mapper: generic` and a `HERMES_*` action ID. +2. Regenerate committed `actions.manifest` to include the new action ID + (golden test `tests/intentframe_integrations/test_actions_manifest.py` enforces parity). +3. Update `agent.json` `action_types`, shipped `policy.yaml`, and `executor.yaml` + `supported_actions` (hand-edited; same golden test checks coverage). +4. Set `IF_DYNAMIC_BUNDLE_MANIFEST` in `agent.json` env (already points at runtime path). + +There is **no** user-facing `sync` command. Runtime automation never rewrites repo +templates or user governance/policy files. diff --git a/integrations/hermes/governance/tools.yaml b/integrations/hermes/governance/tools.yaml index 4fb2579..1a23298 100644 --- a/integrations/hermes/governance/tools.yaml +++ b/integrations/hermes/governance/tools.yaml @@ -1,7 +1,19 @@ # Default governed-tool template (reference). Runtime user config lives under ~/.intentframe/. # Select by tool name (not toolset). Reads stay unlisted unless deployment policy requires them. +# # enabled: true → IntentFrame gates at runtime (plugin wrap + adapter validate). -# enabled: false → spec kept in catalog; Hermes runs the tool ungoverned. +# enabled: false → spec kept in catalog; Hermes runs the tool ungoverned (no intent sent). +# +# User control: intentframe-integrations governance enable|disable hermes +# writes ONLY the runtime copy (~/.intentframe/.../governance/tools.yaml). +# Restart Hermes gateway + adapter after toggling (governance is cached at process start). +# +# Dev control: this repo template + actions.manifest + agent.json + policy.yaml + +# executor.yaml. Nothing at runtime auto-regenerates those files. +# +# Governance (enabled) and policy (allowed_actions) are independent gates — they do +# not need to stay in sync. Disabled tools never emit intents; policy rows for their +# action IDs can remain in the seeded policy harmlessly. tools: terminal: @@ -32,3 +44,10 @@ tools: risk: local_write mapper: patch blocked_response: generic_json + + cronjob: + enabled: true + action: HERMES_CRONJOB + risk: local_process + mapper: generic + blocked_response: generic_json diff --git a/integrations/hermes/plugin/intentframe-gate/governance_loader.py b/integrations/hermes/plugin/intentframe-gate/governance_loader.py index 31f97b0..a73d96c 100644 --- a/integrations/hermes/plugin/intentframe-gate/governance_loader.py +++ b/integrations/hermes/plugin/intentframe-gate/governance_loader.py @@ -11,7 +11,7 @@ import yaml VALID_BLOCKED_RESPONSES = frozenset({"terminal_json", "generic_json"}) -VALID_MAPPER_KINDS = frozenset({"terminal", "process", "write_file", "patch"}) +VALID_MAPPER_KINDS = frozenset({"terminal", "process", "write_file", "patch", "generic"}) @dataclass(frozen=True) diff --git a/integrations/hermes/policy.yaml b/integrations/hermes/policy.yaml index b707134..172745c 100644 --- a/integrations/hermes/policy.yaml +++ b/integrations/hermes/policy.yaml @@ -102,6 +102,16 @@ allowed_actions: allowed_host_paths: - "~/*" + HERMES_CRONJOB: + safe: false + +intent_limits: + - limit_id: no-secret-exfil + domain: data_access + description: Block reading secrets / credential material + raw: Do not read credentials, tokens, cookies, or secret files, and do not upload data to untrusted destinations + effect: block + domain_constraints: deletion: # Structural deletion guards (deterministic). Path scope also in DELETE_HOST_FILE. diff --git a/integrations/hermes/shared/src/hermes_governance/__init__.py b/integrations/hermes/shared/src/hermes_governance/__init__.py index f5a7872..59f78ea 100644 --- a/integrations/hermes/shared/src/hermes_governance/__init__.py +++ b/integrations/hermes/shared/src/hermes_governance/__init__.py @@ -8,6 +8,7 @@ load_tool_catalog, supported_actions, supported_tools, + generic_mapper_action_ids, ) __all__ = [ @@ -18,4 +19,5 @@ "load_tool_catalog", "supported_actions", "supported_tools", + "generic_mapper_action_ids", ] diff --git a/integrations/hermes/shared/src/hermes_governance/loader.py b/integrations/hermes/shared/src/hermes_governance/loader.py index 788ea81..4ff8133 100644 --- a/integrations/hermes/shared/src/hermes_governance/loader.py +++ b/integrations/hermes/shared/src/hermes_governance/loader.py @@ -12,7 +12,7 @@ import yaml VALID_BLOCKED_RESPONSES = frozenset({"terminal_json", "generic_json"}) -VALID_MAPPER_KINDS = frozenset({"terminal", "process", "write_file", "patch"}) +VALID_MAPPER_KINDS = frozenset({"terminal", "process", "write_file", "patch", "generic"}) @dataclass(frozen=True) @@ -180,3 +180,9 @@ def supported_actions() -> frozenset[str]: for spec in load_governed_tools().values(): actions.update(spec.policy_actions()) return frozenset(actions) + + +def generic_mapper_action_ids(yaml_path: str | None = None) -> frozenset[str]: + """Action IDs for enabled tools using mapper: generic.""" + tools = load_governed_tools(yaml_path) + return frozenset(spec.action for spec in tools.values() if spec.mapper == "generic") diff --git a/integrations/hermes/shared/tests/test_governance.py b/integrations/hermes/shared/tests/test_governance.py index cdd09d7..7dac025 100644 --- a/integrations/hermes/shared/tests/test_governance.py +++ b/integrations/hermes/shared/tests/test_governance.py @@ -37,9 +37,16 @@ def test_load_governed_tools(self) -> None: self.assertIn("terminal", governed_tool_names()) self.assertIn("RUN_COMMAND", supported_actions()) self.assertIn("DELETE_HOST_FILE", supported_actions()) + self.assertIn("HERMES_CRONJOB", supported_actions()) self.assertEqual(frozenset(catalog), template_catalog_tool_names()) self.assertEqual(frozenset(tools), template_governed_tool_names()) + def test_generic_mapper_action_ids(self) -> None: + from hermes_governance.loader import generic_mapper_action_ids + + with governance_env(): + self.assertEqual(generic_mapper_action_ids(), frozenset({"HERMES_CRONJOB"})) + def test_enabled_false_excluded_from_governed_set(self) -> None: from hermes_governance.loader import load_governed_tools, load_tool_catalog diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py index c57bb76..c0be7e1 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py @@ -13,6 +13,9 @@ HERMES_AGENT_ID = "hermes" DEFAULT_GOVERNANCE_TEMPLATE_RELATIVE = Path("integrations") / "hermes" / "governance" / "tools.yaml" +DEFAULT_ACTIONS_MANIFEST_RELATIVE = ( + Path("integrations") / "hermes" / "governance" / "actions.manifest" +) def default_governance_template_path() -> Path: @@ -20,6 +23,65 @@ def default_governance_template_path() -> Path: return repo_root() / DEFAULT_GOVERNANCE_TEMPLATE_RELATIVE +def default_actions_manifest_template_path() -> Path: + """Committed actions.manifest shipped with the repo (dev-generated, static). + + Holds the full catalog of generic-mapper action IDs (enabled or not). It is a + superset that never changes when a user toggles tool governance — only when a + developer adds a tool to the catalog and regenerates it. + """ + return repo_root() / DEFAULT_ACTIONS_MANIFEST_RELATIVE + + +def catalog_generic_action_ids() -> frozenset[str]: + """All generic-mapper action IDs in the default catalog (ignores enabled).""" + template = default_governance_template_path() + if not template.is_file(): + raise FileNotFoundError(f"Default governance template missing: {template}") + raw = yaml.safe_load(template.read_text(encoding="utf-8")) or {} + tools = raw.get("tools") + if not isinstance(tools, dict) or not tools: + raise ValueError(f"Default governance template has no tools mapping: {template}") + actions: set[str] = set() + for spec in tools.values(): + if not isinstance(spec, dict): + continue + if spec.get("mapper") != "generic": + continue + action = str(spec.get("action", "")).strip() + if action: + actions.add(action) + return frozenset(actions) + + +def format_manifest(action_ids: frozenset[str]) -> str: + """Canonical manifest serialization (comma-separated, sorted).""" + return ", ".join(sorted(action_ids)) + + +def actions_manifest_runtime_path(agent_id: str = HERMES_AGENT_ID) -> Path: + """User-runtime manifest path (copied from the committed template on install).""" + return governance_yaml_runtime_path(agent_id).parent / "actions.manifest" + + +def ensure_runtime_actions_manifest(agent_id: str = HERMES_AGENT_ID) -> Path: + """Return runtime manifest, copying the committed template on first use only.""" + runtime = actions_manifest_runtime_path(agent_id) + if runtime.is_file(): + return runtime + + template = default_actions_manifest_template_path() + if not template.is_file(): + raise FileNotFoundError( + f"Runtime actions manifest missing at {runtime} and no committed " + f"template at {template}" + ) + + runtime.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(template, runtime) + return runtime + + def governance_yaml_runtime_path(agent_id: str = HERMES_AGENT_ID) -> Path: """User-owned runtime config edited by the CLI.""" return ( diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py index 0152c1d..6bea8e8 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py @@ -25,8 +25,12 @@ hermes_plugins_dir, ) from intentframe_integrations.hermes_governance_contract import ( + actions_manifest_runtime_path, + catalog_generic_action_ids, default_governance_template_path, + ensure_runtime_actions_manifest, ensure_runtime_governance_yaml, + governance_yaml_runtime_path, reset_runtime_governance_yaml, ) from intentframe_integrations.policy_contract import ( @@ -47,6 +51,18 @@ def plugin_source_dir() -> Path: return repo_root() / "integrations" / "hermes" / "plugin" / PLUGIN_DIR_NAME +def executor_profile_path() -> Path: + return ( + repo_root() + / "if-integration-backend" + / "src" + / "if_security_backend" + / "config" + / "profiles" + / "executor.yaml" + ) + + def plugin_install_path() -> Path: return hermes_plugins_dir() / PLUGIN_DIR_NAME @@ -325,6 +341,9 @@ def integrate_hermes( pol_dest = ensure_runtime_policy_yaml(pack) messages.append(f"Policy config at {pol_dest}") + manifest_dest = ensure_runtime_actions_manifest(pack.agent.agent_id) + messages.append(f"Actions manifest at {manifest_dest}") + return IntegrateResult( plugin_installed=True, config_updated=config_updated, @@ -422,6 +441,73 @@ def governance_doctor_lines(pack: IntegrationPack) -> tuple[list[str], bool]: tool_names = ", ".join(sorted(contract)) lines.append(f" governance tools: {tool_names}") + + catalog_generic = catalog_generic_action_ids() + manifest_path = actions_manifest_runtime_path(pack.agent.agent_id) + manifest_env_key = "IF_DYNAMIC_BUNDLE_MANIFEST" + if catalog_generic: + env_value = pack.agent.env.get(manifest_env_key, "").strip() + if not env_value: + ok = False + lines.append(f" manifest: {manifest_env_key} not set in agent.json") + else: + env_path = Path(os.path.expanduser(env_value)) + if env_path != manifest_path: + ok = False + lines.append( + f" manifest: {manifest_env_key}={env_value!r} does not match " + f"expected {manifest_path}" + ) + elif not manifest_path.is_file(): + ok = False + lines.append( + f" manifest: missing at {manifest_path} " + f"(run: intentframe-integrations integrate hermes)" + ) + else: + present = { + part.strip() + for part in manifest_path.read_text(encoding="utf-8").split(",") + if part.strip() + } + missing = sorted(catalog_generic - present) + if missing: + ok = False + lines.append( + f" manifest: missing catalog action(s) {missing} at {manifest_path}" + ) + else: + lines.append( + f" manifest: ok at {manifest_path} " + f"({manifest_env_key} configured)" + ) + else: + lines.append(" manifest: no generic mapper tools in catalog") + + executor_path = executor_profile_path() + if executor_path.is_file(): + executor_raw = yaml.safe_load(executor_path.read_text(encoding="utf-8")) or {} + supported = ( + executor_raw.get("pack_options", {}) + .get("validate_only", {}) + .get("supported_actions") + ) + if isinstance(supported, list): + missing_executor = sorted(governed_actions - {str(item) for item in supported}) + if missing_executor: + ok = False + lines.append( + f" executor: missing supported_actions: {missing_executor}" + ) + else: + lines.append(" executor: supported_actions covers governed actions") + else: + ok = False + lines.append(f" executor: {executor_path} missing validate_only.supported_actions") + else: + ok = False + lines.append(f" executor: profile missing at {executor_path}") + return lines, ok diff --git a/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py b/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py index 67cecad..3c4a244 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py @@ -69,7 +69,7 @@ def validate_policy_file(pack: IntegrationPack, yaml_path: Path) -> None: _validate_policy_agent_id(path, pack.agent.agent_id) from intentframe_bundle_sdk.loader import validate_policy_with_bundles - from if_security_backend.runtime.policy import DEFAULT_BUNDLE + from if_security_backend.runtime.bundles import load_core_bundle_packages from policy_registry.seeds import load_policy_seed try: @@ -78,7 +78,7 @@ def validate_policy_file(pack: IntegrationPack, yaml_path: Path) -> None: user_id=pack.agent.user_id, agent_id=pack.agent.agent_id, ) - validate_policy_with_bundles(policy, [DEFAULT_BUNDLE]) + validate_policy_with_bundles(policy, load_core_bundle_packages()) except Exception as exc: raise PolicyError(str(exc)) from exc diff --git a/tests/intentframe_integrations/test_actions_manifest.py b/tests/intentframe_integrations/test_actions_manifest.py new file mode 100644 index 0000000..510b9b4 --- /dev/null +++ b/tests/intentframe_integrations/test_actions_manifest.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Dev golden test: committed actions.manifest and derived configs match the catalog. + +These artifacts are dev-generated and static; they only change when a developer +adds a tool to governance/tools.yaml. This test fails on drift so they cannot +silently diverge — nothing regenerates them at runtime. +""" + +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +CLI_SRC = REPO_ROOT / "intentframe-integrations-cli" / "src" +if str(CLI_SRC) not in sys.path: + sys.path.insert(0, str(CLI_SRC)) + +from intentframe_integrations.hermes_governance_contract import ( # noqa: E402 + catalog_generic_action_ids, + default_actions_manifest_template_path, + format_manifest, +) + + +def _catalog_all_action_ids() -> frozenset[str]: + template = REPO_ROOT / "integrations" / "hermes" / "governance" / "tools.yaml" + raw = yaml.safe_load(template.read_text(encoding="utf-8")) or {} + actions: set[str] = set() + for spec in raw.get("tools", {}).values(): + if not isinstance(spec, dict): + continue + action = str(spec.get("action", "")).strip() + if action: + actions.add(action) + for extra in spec.get("actions", []) or []: + if str(extra).strip(): + actions.add(str(extra).strip()) + return frozenset(actions) + + +class TestActionsManifestGolden(unittest.TestCase): + def test_committed_manifest_matches_full_generic_catalog(self) -> None: + manifest = default_actions_manifest_template_path() + self.assertTrue(manifest.is_file(), f"missing committed manifest: {manifest}") + present = { + part.strip() + for part in manifest.read_text(encoding="utf-8").split(",") + if part.strip() + } + self.assertEqual( + present, + set(catalog_generic_action_ids()), + "committed actions.manifest is out of sync with the generic catalog; " + f"regenerate it to: {format_manifest(catalog_generic_action_ids())}", + ) + + def test_agent_json_action_types_cover_catalog(self) -> None: + agent_path = REPO_ROOT / "integrations" / "hermes" / "agent.json" + raw = json.loads(agent_path.read_text(encoding="utf-8")) + action_types = set(raw.get("action_types", [])) + missing = sorted(_catalog_all_action_ids() - action_types) + self.assertEqual([], missing, f"agent.json action_types missing: {missing}") + + def test_executor_supported_actions_cover_catalog(self) -> None: + executor_path = ( + REPO_ROOT + / "if-integration-backend" + / "src" + / "if_security_backend" + / "config" + / "profiles" + / "executor.yaml" + ) + raw = yaml.safe_load(executor_path.read_text(encoding="utf-8")) or {} + supported = set( + raw.get("pack_options", {}).get("validate_only", {}).get("supported_actions", []) + ) + missing = sorted(_catalog_all_action_ids() - supported) + self.assertEqual([], missing, f"executor supported_actions missing: {missing}") + + +def main() -> int: + loader = unittest.TestLoader() + suite = loader.loadTestsFromModule(sys.modules[__name__]) + result = unittest.TextTestRunner(verbosity=2).run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/intentframe_integrations/test_hermes_install.py b/tests/intentframe_integrations/test_hermes_install.py index c763aa3..241f413 100644 --- a/tests/intentframe_integrations/test_hermes_install.py +++ b/tests/intentframe_integrations/test_hermes_install.py @@ -3,6 +3,7 @@ from __future__ import annotations +from dataclasses import replace import json import os import sys @@ -36,6 +37,7 @@ from intentframe_integrations.hermes_integrate import ( # noqa: E402 doctor_hermes, format_env_exports, + governance_doctor_lines, integrate_hermes, is_plugin_enabled, load_hermes_pack, @@ -175,6 +177,23 @@ def test_full_doctor_fails_before_integrate(self) -> None: joined = "\n".join(report.lines) self.assertIn("plugin install: missing", joined) + def test_governance_doctor_warns_when_manifest_env_missing(self) -> None: + pack = load_hermes_pack() + agent = replace( + pack.agent, + env={ + key: value + for key, value in pack.agent.env.items() + if key != "IF_DYNAMIC_BUNDLE_MANIFEST" + }, + ) + lines, ok = governance_doctor_lines(replace(pack, agent=agent)) + self.assertFalse(ok) + self.assertIn( + "IF_DYNAMIC_BUNDLE_MANIFEST not set in agent.json", + "\n".join(lines), + ) + def test_full_doctor_passes_after_integrate(self) -> None: with tempfile.TemporaryDirectory() as tmp: home = Path(tmp) / "home" diff --git a/tests/intentframe_integrations/test_policy_manage.py b/tests/intentframe_integrations/test_policy_manage.py index 8b337e9..5d71f86 100644 --- a/tests/intentframe_integrations/test_policy_manage.py +++ b/tests/intentframe_integrations/test_policy_manage.py @@ -12,6 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] CLI_SRC = REPO_ROOT / "intentframe-integrations-cli" / "src" +GOVERNANCE_TEMPLATE = REPO_ROOT / "integrations" / "hermes" / "governance" / "tools.yaml" if str(CLI_SRC) not in sys.path: sys.path.insert(0, str(CLI_SRC)) @@ -35,18 +36,52 @@ class patch_home: def __init__(self, home: Path) -> None: self.home = home self._previous: str | None = None + self._gov_previous: str | None = None + self._manifest_previous: str | None = None def __enter__(self) -> None: self._previous = os.environ.get("HOME") + self._gov_previous = os.environ.get("HERMES_GOVERNANCE_YAML") + self._manifest_previous = os.environ.get("IF_DYNAMIC_BUNDLE_MANIFEST") os.environ["HOME"] = str(self.home) + os.environ["HERMES_GOVERNANCE_YAML"] = str(GOVERNANCE_TEMPLATE) + manifest_dir = ( + self.home / ".intentframe" / "integrations" / "hermes" / "governance" + ) + manifest_dir.mkdir(parents=True, exist_ok=True) + manifest_path = manifest_dir / "actions.manifest" + manifest_path.write_text("HERMES_CRONJOB", encoding="utf-8") + os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = str(manifest_path) + self._reset_bundle_loader_state() return self def __exit__(self, *args: object) -> None: + self._reset_bundle_loader_state() + if self._manifest_previous is None: + os.environ.pop("IF_DYNAMIC_BUNDLE_MANIFEST", None) + else: + os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = self._manifest_previous + if self._gov_previous is None: + os.environ.pop("HERMES_GOVERNANCE_YAML", None) + else: + os.environ["HERMES_GOVERNANCE_YAML"] = self._gov_previous if self._previous is None: os.environ.pop("HOME", None) else: os.environ["HOME"] = self._previous + @staticmethod + def _reset_bundle_loader_state() -> None: + import intentframe_bundle_sdk.loader as bundle_loader + import intentframe_bundle_sdk.registry as registry + + bundle_loader._LOADED_PACKAGES = None + registry._ACTION_BY_ID.clear() + registry._ACTION_INSTANCES.clear() + registry._DOMAIN_BY_ID.clear() + registry._ACTION_TO_DOMAINS.clear() + registry._ROUTED_DOMAIN_IDS = frozenset() + class TestPolicyManage(unittest.TestCase): def setUp(self) -> None: diff --git a/tests/scripts/e2e.sh b/tests/scripts/e2e.sh index 4ce69ef..8b11311 100755 --- a/tests/scripts/e2e.sh +++ b/tests/scripts/e2e.sh @@ -139,6 +139,7 @@ step "Hermes governance unit tests" step "Backend validate adapter unit tests" (cd "$REPO_ROOT" && uv run --package if-integration-backend python if-integration-backend/tests/test_validate_adapter.py) +(cd "$REPO_ROOT" && uv run --package if-integration-backend python if-integration-backend/tests/test_dynamic_bundle.py) step "E2e bridge_test agent sync (tests/agents vs bundled default)" (cd "$REPO_ROOT" && uv run --package if-integration-backend python tests/agents/test_bridge_test_agent_sync.py) @@ -160,6 +161,7 @@ step "Integrations CLI unit tests" (cd "$REPO_ROOT" && uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_policy_manage.py) (cd "$REPO_ROOT" && uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_governance_runtime_contract.py) (cd "$REPO_ROOT" && uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_scoped_governance_yaml.py) +(cd "$REPO_ROOT" && uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_actions_manifest.py) step "Hermes gateway unit tests" (cd "$REPO_ROOT" && uv run --package intentframe-integrations-cli python tests/hermes_gateway/test_isolation.py) From 788e56e59daa5737a436ebd28e96783844f74797 Mon Sep 17 00:00:00 2001 From: Prince Date: Tue, 23 Jun 2026 18:00:43 +0530 Subject: [PATCH 2/8] update docs --- docs/NATIVE_KIT_INTEGRATION.md | 15 +++-- docs/agent-tool-gating.md | 13 +++- docs/hermes-intentframe-integration-guide.md | 61 +++++++++++++------ if-integration-backend/README.md | 4 ++ integrations/hermes/README.md | 22 +++++-- intentframe-integrations-cli/README.md | 14 ++++- .../hermes_governance_contract.py | 8 ++- .../hermes_governance_edit.py | 7 ++- .../intentframe_integrations/policy_manage.py | 6 +- 9 files changed, 118 insertions(+), 32 deletions(-) diff --git a/docs/NATIVE_KIT_INTEGRATION.md b/docs/NATIVE_KIT_INTEGRATION.md index b9998d1..c05fa4c 100644 --- a/docs/NATIVE_KIT_INTEGRATION.md +++ b/docs/NATIVE_KIT_INTEGRATION.md @@ -71,6 +71,7 @@ Configured in [`governance/tools.yaml`](../integrations/hermes/governance/tools. | `process` | `RUN_COMMAND` | `process` | Synthetic `process:…` command string | | `write_file` | `WRITE_HOST_FILE` | `write_file` | Path + content | | `patch` | `WRITE_HOST_FILE`, `DELETE_HOST_FILE` | `patch` | Multi-intent from V4A diff | +| `cronjob` | `HERMES_CRONJOB` | `generic` | Semantic-only via dynamic bundle (AE + Guardian) | **Ungoverned by design (v1):** reads — `read_file`, `search_files`, `browser_snapshot`, `web_search`, etc. Ungoverned read + ungoverned outbound channel is an exfil risk; @@ -81,11 +82,17 @@ document explicitly if you leave reads open. [`agent.json`](../integrations/hermes/agent.json): ```json -"action_types": ["RUN_COMMAND", "WRITE_HOST_FILE", "DELETE_HOST_FILE"] +"action_types": ["RUN_COMMAND", "WRITE_HOST_FILE", "DELETE_HOST_FILE", "HERMES_CRONJOB"] ``` -Every governed action must appear here **and** in `policy.yaml` **and** in -`executor.yaml` `supported_actions` for validate-only. +Every governed action must appear here **and** in shipped `policy.yaml` **and** in +`executor.yaml` `supported_actions` for validate-only. Generic action IDs are also +listed in committed `governance/actions.manifest` (static superset; copied to runtime +on `integrate hermes`). Golden test: +`tests/intentframe_integrations/test_actions_manifest.py`. + +User governance toggles (`enabled` in runtime `tools.yaml`) do **not** change manifest, +policy, or repo templates. Governance and policy are independent gates. ### Policy (seed) @@ -99,7 +106,7 @@ Every governed action must appear here **and** in `policy.yaml` **and** in | Profile | Path | Role | |---------|------|------| -| Core | `if-integration-backend/.../profiles/core.yaml` | `bundles: [native]` → loads all native-kit action + domain bundles | +| Core | `if-integration-backend/.../profiles/core.yaml` | `bundles: [native, dynamic]` — native-kit deterministic bundles + generic dynamic bundle (reads `IF_DYNAMIC_BUNDLE_MANIFEST`) | | Executor | `if-integration-backend/.../profiles/executor.yaml` | `validate_only` adapter; `supported_actions` list | [`ValidateOnlyAdapter`](../if-integration-backend/src/if_security_backend/executor_pack/validate_adapter.py) diff --git a/docs/agent-tool-gating.md b/docs/agent-tool-gating.md index 277cabd..ab87972 100644 --- a/docs/agent-tool-gating.md +++ b/docs/agent-tool-gating.md @@ -317,8 +317,17 @@ closed**. Every governed name needs a matching mapper kind and policy. **Wrapping is easy; meaningful per-tool mapping + policy is the work.** Drift is prevented by a **shared contract**: `governance/tools.yaml` + -`hermes-governance` loader; adapter exposes `supported_tools()` for doctor/sync -checks. +`hermes-governance` loader; adapter exposes `supported_tools()` for doctor checks. +Dev-maintained `governance/actions.manifest` lists all generic-mapper action IDs +(full catalog superset); golden test +[`test_actions_manifest.py`](../tests/intentframe_integrations/test_actions_manifest.py) +enforces parity. There is no user-facing `sync` command — runtime CLI never +rewrites repo templates. + +**Governance and policy are independent gates.** Disabling a tool stops Hermes +from sending intents; manifest and policy rows for that action ID can remain +harmlessly. User toggles governance only in runtime `tools.yaml`; policy only via +policy CLI. Each tool entry may set `enabled: true|false` (default `true`). In this yaml, **`enabled` means IntentFrame-governed**, not “Hermes tool enabled.” Only entries diff --git a/docs/hermes-intentframe-integration-guide.md b/docs/hermes-intentframe-integration-guide.md index a67e85b..25f9a59 100644 --- a/docs/hermes-intentframe-integration-guide.md +++ b/docs/hermes-intentframe-integration-guide.md @@ -424,35 +424,57 @@ When editing `GOVERNED_BUILTIN_MODULES` or any plugin import: ## Adding a new governed Hermes tool Work through **all** layers. Wrapping alone is insufficient without mapper + policy. +See [`integrations/hermes/governance/README.md`](../integrations/hermes/governance/README.md) +for dev vs user ownership. There is no user-facing `sync` command. ### Step 1 — Governance contract Add entry to `integrations/hermes/governance/tools.yaml`: +**Native mapper** (deterministic native-kit bundles): + ```yaml my_tool: enabled: true action: WRITE_HOST_FILE # or RUN_COMMAND, DELETE_HOST_FILE, … risk: local_write - mapper: my_tool # must exist in mapper.py + mapper: write_file # terminal | process | write_file | patch + blocked_response: generic_json +``` + +**Generic mapper** (semantic-only via dynamic bundle, e.g. `cronjob`): + +```yaml + cronjob: + enabled: true + action: HERMES_CRONJOB + risk: local_process + mapper: generic blocked_response: generic_json ``` -Runtime copy: `~/.intentframe/integrations/hermes/governance/tools.yaml`. +Runtime copy: `~/.intentframe/integrations/hermes/governance/tools.yaml` (user toggles +`enabled` via `governance enable|disable`; restart gateway + adapter). Valid mapper kinds (plugin loader): -```14:14:integrations/hermes/plugin/intentframe-gate/governance_loader.py -VALID_MAPPER_KINDS = frozenset({"terminal", "process", "write_file", "patch"}) +```python +VALID_MAPPER_KINDS = frozenset({"terminal", "process", "write_file", "patch", "generic"}) ``` -Extend this set when adding a new mapper kind. +For `mapper: generic`, no new mapper function is needed — `map_generic` handles all +generic tools. Regenerate committed `governance/actions.manifest` and update dev +artifacts (Step 3). ### Step 2 — Adapter mapper -Add `map_my_tool()` in `integrations/hermes/adapter/src/hermes_adapter/mapper.py` -and register in `MAPPERS`. Must produce IntentFrame validate payloads including -`reason` (adapter validates reason locally too): +**Skip for `mapper: generic`** — `map_generic` already maps tool args to the action ID +with `hermes_tool` / `hermes_args` in IntentFrame data. + +For native mappers, add `map_my_tool()` in +`integrations/hermes/adapter/src/hermes_adapter/mapper.py` and register in `MAPPERS`. +Must produce IntentFrame validate payloads including `reason` (adapter validates reason +locally too): ```34:44:integrations/hermes/adapter/src/hermes_adapter/mapper.py def validate_reason(reason: object) -> str: @@ -464,17 +486,22 @@ def validate_reason(reason: object) -> str: Multi-intent tools (like `patch`) return a **list** of intents; adapter fails closed on first BLOCK. -### Step 3 — Policy +### Step 3 — Dev artifacts (hand-edited, golden-tested) -Add rules to the shipped template `integrations/hermes/policy.yaml` (for upstream -changes) or edit the **runtime** file at -`~/.intentframe/integrations/hermes/policy.yaml` (for local/production tuning). -After editing runtime policy, run `bin/intentframe-integrations policy reload hermes`. -Ensure `agent.json` lists the action type: +Update shipped repo files when adding a new action ID: -```5:5:integrations/hermes/agent.json - "action_types": ["RUN_COMMAND", "WRITE_HOST_FILE", "DELETE_HOST_FILE"], -``` +| File | What to add | +|------|-------------| +| `governance/actions.manifest` | Generic action ID (comma-separated; full catalog superset) | +| `agent.json` `action_types` | Every action ID the agent may emit | +| `integrations/hermes/policy.yaml` | `allowed_actions` row (`safe: false` for generic) | +| `executor.yaml` `supported_actions` | Same action IDs for validate-only executor | + +Golden test: `tests/intentframe_integrations/test_actions_manifest.py`. + +Users edit **runtime** policy at `~/.intentframe/integrations/hermes/policy.yaml` and +reload with `bin/intentframe-integrations policy reload hermes`. Governance toggles +do not change manifest or policy files. ### Step 4 — Plugin preload (if Hermes builtin) diff --git a/if-integration-backend/README.md b/if-integration-backend/README.md index d7a2d87..1be4ea0 100644 --- a/if-integration-backend/README.md +++ b/if-integration-backend/README.md @@ -4,6 +4,10 @@ Generic **IntentFrame validate-only** platform: one runtime, one noop executor p Agent profiles live under `../integrations/` (e.g. `integrations/hermes/`) — each ships `agent.json` + `policy.yaml` only, no executor packs. +**Dynamic bundle** (`bundles/dynamic.py`): agent-agnostic pass-through bundle. Reads +`IF_DYNAMIC_BUNDLE_MANIFEST` (comma-separated action IDs). If unset, registers nothing. +Hermes sets this env in `agent.json` pointing at runtime `actions.manifest`. + ## Quick start From the **repo root** (uv workspace): diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index fc437db..1388ba1 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -187,16 +187,28 @@ Export env from `agent.json` (or set in the shell before `start` / `gateway star - `IF_AGENT_ADAPTER_SOCKET=~/.intentframe/integrations/hermes/adapter.sock` - `HERMES_GOVERNANCE_YAML=~/.intentframe/integrations/hermes/governance/tools.yaml` (optional override path for governed-tool set) +- `IF_DYNAMIC_BUNDLE_MANIFEST=~/.intentframe/integrations/hermes/governance/actions.manifest` (static generic action IDs; set by us in agent.json) ## Adding a governed tool +See [`governance/README.md`](governance/README.md) for dev vs user ownership. + +**Native mapper** (terminal, process, write_file, patch): + 1. Add an entry to `governance/tools.yaml`. -2. Add a mapper in `adapter/src/hermes_adapter/mapper.py` (or reuse a mapper kind). -3. Add the IntentFrame action to `agent.json` `action_types` if new. -4. Add policy constraints in `policy.yaml`. -5. Add mapper unit test + optional E2E probe. +2. Add or reuse a mapper in `adapter/src/hermes_adapter/mapper.py`. +3. Update dev artifacts: `agent.json` `action_types`, shipped `policy.yaml`, `executor.yaml` `supported_actions`. +4. Add mapper unit test + E2E probe (native tools only). + +**Generic mapper** (semantic-only, e.g. `cronjob` → `HERMES_CRONJOB`): + +1. Add entry with `mapper: generic` and a distinct `HERMES_*` action ID in `tools.yaml`. +2. Regenerate committed `governance/actions.manifest` (full catalog superset). +3. Update dev artifacts as above; add `safe: false` row in shipped `policy.yaml`. +4. Golden test: `tests/intentframe_integrations/test_actions_manifest.py`. +5. No plugin code changes — `map_generic` handles all generic tools. -No plugin code changes are required when the mapper kind already exists. +No user-facing sync step. Users toggle governance via CLI; policy via policy CLI. If the tool can emit **multiple IntentFrames per call** (like V4A `patch`), follow the `map_patch` pattern in `mapper.py`: scoped per-op `content` for writes (not diff --git a/intentframe-integrations-cli/README.md b/intentframe-integrations-cli/README.md index afb7373..dbe6cf0 100644 --- a/intentframe-integrations-cli/README.md +++ b/intentframe-integrations-cli/README.md @@ -29,6 +29,8 @@ intentframe-integrations policy reset hermes `governance enable|disable` toggles **IntentFrame governance** for a catalog tool (yaml `enabled: true/false`). It does not enable or disable Hermes native tools. +After toggling, **restart Hermes gateway + adapter** (governance is loaded at process +start). IntentFrame backend does not need restart. See [`docs/agent-tool-gating.md`](../docs/agent-tool-gating.md#terminology-what-governed-means). **Policy** lives at `~/.intentframe/integrations//policy.yaml` (copied from the @@ -37,6 +39,14 @@ shipped template on first `integrate` or `start`). Edit that file, then `policy reset` to restore the shipped default. Changes apply immediately — no gateway restart needed. +**Runtime artifacts** (copied on first `integrate`, never auto-overwritten): + +- `governance/tools.yaml` — user toggles via `governance enable|disable` +- `governance/actions.manifest` — static dev-shipped superset of generic action IDs +- `policy.yaml` — user edits via policy CLI + +There is no `sync` command. Repo templates are dev-maintained only. + Run from repo root via `bin/intentframe-integrations` or: ```bash @@ -77,7 +87,8 @@ Hermes binary resolution order: 1. `install hermes` — Hermes Agent CLI (managed venv, pinned version) 2. `start hermes` — backend bridge + adapter sidecar (`~/.intentframe/integrations/hermes/`) -3. `integrate hermes` — plugin symlink + adapter venv sync + config merge +3. `integrate hermes` — plugin symlink + adapter venv sync + copy runtime governance, + actions manifest, and policy templates (first use only) 4. `gateway start hermes` — launch Hermes gateway (optionally with API server) 5. `stop` — stop gateway started by orchestrator, adapters, and backend runtime @@ -93,6 +104,7 @@ adapter sync, and gateway lifecycle. | Variable | Set by | Effect | |----------|--------|--------| | `HERMES_GOVERNANCE_YAML` | `agent.json` default; override in shell or test harness | Which tools are **IntentFrame-governed** at runtime. If already set in the parent environment, `start hermes` (adapter) and `gateway start hermes` preserve it via `setdefault` — they do not replace it with the sandbox-seeded path from `integrate`. | +| `IF_DYNAMIC_BUNDLE_MANIFEST` | `agent.json` default | Path to static `actions.manifest` (generic `HERMES_*` action IDs). Backend dynamic bundle reads this at boot; unset env → dynamic bundle is a no-op. | | `IF_AGENT_ADAPTER_SOCKET` | `agent.json` | UDS path for plugin → adapter validate calls. | `integrate hermes` prints `export …` lines from `format_env_exports()`: values already diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py index c0be7e1..9e529ea 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py @@ -1,4 +1,10 @@ -"""Default governance template path and runtime user config materialization.""" +"""Default governance template paths and runtime user config materialization. + +Repo templates (dev-maintained): governance/tools.yaml, governance/actions.manifest. +Runtime copies (~/.intentframe/...): seeded on first integrate; never overwritten +unless the user runs --reset-governance or deletes the file. User toggles tool +governance via CLI; that only edits runtime tools.yaml enabled flags. +""" from __future__ import annotations diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_edit.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_edit.py index d09ef24..58245bb 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_edit.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_edit.py @@ -1,4 +1,9 @@ -"""Enable/disable governed Hermes tools in runtime governance/tools.yaml.""" +"""Enable/disable governed Hermes tools in runtime governance/tools.yaml. + +Only flips ``enabled`` on the runtime copy under ~/.intentframe/. Does not touch +actions.manifest, policy.yaml, or any repo templates. Restart Hermes gateway + +adapter after changes (governance is cached at process start). +""" from __future__ import annotations diff --git a/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py b/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py index 3c4a244..38f6f97 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py @@ -1,4 +1,8 @@ -"""Load runtime policy YAML into policy-registry (validate + upsert).""" +"""Load runtime policy YAML into policy-registry (validate + upsert). + +Policy changes apply immediately via policy-registry UDS — no gateway or adapter +restart. Does not modify governance/tools.yaml or actions.manifest. +""" from __future__ import annotations From 893caadd2718bb69240732de7152f59f1765f192 Mon Sep 17 00:00:00 2001 From: Prince Date: Tue, 23 Jun 2026 18:08:13 +0530 Subject: [PATCH 3/8] update --- .gitignore | 1 + docs/NATIVE_KIT_INTEGRATION.md | 2 +- docs/agent-tool-gating.md | 2 +- docs/hermes-intentframe-integration-guide.md | 4 ++-- if-integration-backend/README.md | 2 +- if-integration-backend/tests/test_dynamic_bundle.py | 2 +- integrations/hermes/README.md | 12 ++++++------ integrations/hermes/agent.json | 2 +- integrations/hermes/governance/README.md | 6 +++--- .../hermes/governance/generic_actions.manifest | 1 + integrations/hermes/governance/tools.yaml | 2 +- intentframe-integrations-cli/README.md | 4 ++-- .../hermes_governance_contract.py | 12 ++++++------ .../hermes_governance_edit.py | 2 +- .../src/intentframe_integrations/policy_manage.py | 2 +- .../test_actions_manifest.py | 4 ++-- tests/intentframe_integrations/test_policy_manage.py | 2 +- 17 files changed, 32 insertions(+), 30 deletions(-) create mode 100644 integrations/hermes/governance/generic_actions.manifest diff --git a/.gitignore b/.gitignore index a62b554..2df73dd 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ __pypackages__/ celerybeat-schedule celerybeat.pid *.manifest +!integrations/hermes/governance/generic_actions.manifest *.spec dmypy.json .pyre/ diff --git a/docs/NATIVE_KIT_INTEGRATION.md b/docs/NATIVE_KIT_INTEGRATION.md index c05fa4c..a192219 100644 --- a/docs/NATIVE_KIT_INTEGRATION.md +++ b/docs/NATIVE_KIT_INTEGRATION.md @@ -87,7 +87,7 @@ document explicitly if you leave reads open. Every governed action must appear here **and** in shipped `policy.yaml` **and** in `executor.yaml` `supported_actions` for validate-only. Generic action IDs are also -listed in committed `governance/actions.manifest` (static superset; copied to runtime +listed in committed `governance/generic_actions.manifest` (static superset; copied to runtime on `integrate hermes`). Golden test: `tests/intentframe_integrations/test_actions_manifest.py`. diff --git a/docs/agent-tool-gating.md b/docs/agent-tool-gating.md index ab87972..16d6346 100644 --- a/docs/agent-tool-gating.md +++ b/docs/agent-tool-gating.md @@ -318,7 +318,7 @@ closed**. Every governed name needs a matching mapper kind and policy. Drift is prevented by a **shared contract**: `governance/tools.yaml` + `hermes-governance` loader; adapter exposes `supported_tools()` for doctor checks. -Dev-maintained `governance/actions.manifest` lists all generic-mapper action IDs +Dev-maintained `governance/generic_actions.manifest` lists all generic-mapper action IDs (full catalog superset); golden test [`test_actions_manifest.py`](../tests/intentframe_integrations/test_actions_manifest.py) enforces parity. There is no user-facing `sync` command — runtime CLI never diff --git a/docs/hermes-intentframe-integration-guide.md b/docs/hermes-intentframe-integration-guide.md index 25f9a59..a71d1e1 100644 --- a/docs/hermes-intentframe-integration-guide.md +++ b/docs/hermes-intentframe-integration-guide.md @@ -463,7 +463,7 @@ VALID_MAPPER_KINDS = frozenset({"terminal", "process", "write_file", "patch", "g ``` For `mapper: generic`, no new mapper function is needed — `map_generic` handles all -generic tools. Regenerate committed `governance/actions.manifest` and update dev +generic tools. Regenerate committed `governance/generic_actions.manifest` and update dev artifacts (Step 3). ### Step 2 — Adapter mapper @@ -492,7 +492,7 @@ Update shipped repo files when adding a new action ID: | File | What to add | |------|-------------| -| `governance/actions.manifest` | Generic action ID (comma-separated; full catalog superset) | +| `governance/generic_actions.manifest` | Generic action ID (comma-separated; full catalog superset) | | `agent.json` `action_types` | Every action ID the agent may emit | | `integrations/hermes/policy.yaml` | `allowed_actions` row (`safe: false` for generic) | | `executor.yaml` `supported_actions` | Same action IDs for validate-only executor | diff --git a/if-integration-backend/README.md b/if-integration-backend/README.md index 1be4ea0..45c81df 100644 --- a/if-integration-backend/README.md +++ b/if-integration-backend/README.md @@ -6,7 +6,7 @@ Agent profiles live under `../integrations/` (e.g. `integrations/hermes/`) — e **Dynamic bundle** (`bundles/dynamic.py`): agent-agnostic pass-through bundle. Reads `IF_DYNAMIC_BUNDLE_MANIFEST` (comma-separated action IDs). If unset, registers nothing. -Hermes sets this env in `agent.json` pointing at runtime `actions.manifest`. +Hermes sets this env in `agent.json` pointing at runtime `generic_actions.manifest`. ## Quick start diff --git a/if-integration-backend/tests/test_dynamic_bundle.py b/if-integration-backend/tests/test_dynamic_bundle.py index 1749cd9..aba7a1c 100644 --- a/if-integration-backend/tests/test_dynamic_bundle.py +++ b/if-integration-backend/tests/test_dynamic_bundle.py @@ -84,7 +84,7 @@ def test_register_bundles_reads_manifest_from_env(self) -> None: import intentframe_bundle_sdk.registry as registry with tempfile.TemporaryDirectory() as temp_dir: - manifest = Path(temp_dir) / "actions.manifest" + manifest = Path(temp_dir) / "generic_actions.manifest" manifest.write_text("HERMES_CRONJOB", encoding="utf-8") previous = os.environ.get("IF_DYNAMIC_BUNDLE_MANIFEST") os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = str(manifest) diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index 1388ba1..b1ac8c3 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -7,7 +7,7 @@ Hermes does **not** ship an IntentFrame executor pack or runtime. This folder pr | `agent.json` | Agent profile, adapter socket, exported `env` for Hermes plugin | | `policy.yaml` | Shipped policy **template** (copied to runtime on first integrate/start) | | `governance/tools.yaml` | Default governed-tool **template** (seeded to runtime on first integrate) | -| `governance/actions.manifest` | Static generic action IDs (copied to runtime on first integrate) | +| `governance/generic_actions.manifest` | Static generic action IDs (copied to runtime on first integrate) | | `governance/README.md` | Dev vs user ownership for governance artifacts | | `shared/` | `hermes-governance` package — contract loader for adapter | | `adapter/` | Hermes adapter sidecar (bridge client, tool mapping, HTTP/UDS server) | @@ -118,7 +118,7 @@ bin/intentframe-integrations stop from repo templates (never overwrites existing user files): - `~/.intentframe/integrations/hermes/governance/tools.yaml` -- `~/.intentframe/integrations/hermes/governance/actions.manifest` +- `~/.intentframe/integrations/hermes/governance/generic_actions.manifest` - `~/.intentframe/integrations/hermes/policy.yaml` ### Config ownership @@ -127,7 +127,7 @@ from repo templates (never overwrites existing user files): |------|-----------|----------------------| | Runtime `governance/tools.yaml` `enabled` | User (`governance enable\|disable`) | Hermes gateway + adapter | | Runtime `policy.yaml` | User (`policy set\|reload\|reset`) | None (live registry) | -| Repo templates (`tools.yaml`, `actions.manifest`, `policy.yaml`, `agent.json`, `executor.yaml`) | Dev only | Backend restart if manifest/action IDs change | +| Repo templates (`tools.yaml`, `generic_actions.manifest`, `policy.yaml`, `agent.json`, `executor.yaml`) | Dev only | Backend restart if manifest/action IDs change | There is no user-facing `sync` command. Runtime CLI never rewrites repo templates. @@ -145,7 +145,7 @@ The CLI propagates governance config to child processes as follows: | Env | Points to | Read by | |-----|-----------|---------| | `HERMES_GOVERNANCE_YAML` | Runtime `governance/tools.yaml` | Plugin gate, adapter | -| `IF_DYNAMIC_BUNDLE_MANIFEST` | Runtime `governance/actions.manifest` | Dynamic bundle at backend boot (registers all catalog generic action IDs) | +| `IF_DYNAMIC_BUNDLE_MANIFEST` | Runtime `governance/generic_actions.manifest` | Dynamic bundle at backend boot (registers all catalog generic action IDs) | | `IF_AGENT_ADAPTER_SOCKET` | Adapter UDS | Plugin → adapter validate calls | To use a custom governed-tool set without editing runtime yaml, export @@ -187,7 +187,7 @@ Export env from `agent.json` (or set in the shell before `start` / `gateway star - `IF_AGENT_ADAPTER_SOCKET=~/.intentframe/integrations/hermes/adapter.sock` - `HERMES_GOVERNANCE_YAML=~/.intentframe/integrations/hermes/governance/tools.yaml` (optional override path for governed-tool set) -- `IF_DYNAMIC_BUNDLE_MANIFEST=~/.intentframe/integrations/hermes/governance/actions.manifest` (static generic action IDs; set by us in agent.json) +- `IF_DYNAMIC_BUNDLE_MANIFEST=~/.intentframe/integrations/hermes/governance/generic_actions.manifest` (static generic action IDs; set by us in agent.json) ## Adding a governed tool @@ -203,7 +203,7 @@ See [`governance/README.md`](governance/README.md) for dev vs user ownership. **Generic mapper** (semantic-only, e.g. `cronjob` → `HERMES_CRONJOB`): 1. Add entry with `mapper: generic` and a distinct `HERMES_*` action ID in `tools.yaml`. -2. Regenerate committed `governance/actions.manifest` (full catalog superset). +2. Regenerate committed `governance/generic_actions.manifest` (full catalog superset). 3. Update dev artifacts as above; add `safe: false` row in shipped `policy.yaml`. 4. Golden test: `tests/intentframe_integrations/test_actions_manifest.py`. 5. No plugin code changes — `map_generic` handles all generic tools. diff --git a/integrations/hermes/agent.json b/integrations/hermes/agent.json index f5ab688..9e985f5 100644 --- a/integrations/hermes/agent.json +++ b/integrations/hermes/agent.json @@ -20,6 +20,6 @@ "env": { "IF_AGENT_ADAPTER_SOCKET": "~/.intentframe/integrations/hermes/adapter.sock", "HERMES_GOVERNANCE_YAML": "~/.intentframe/integrations/hermes/governance/tools.yaml", - "IF_DYNAMIC_BUNDLE_MANIFEST": "~/.intentframe/integrations/hermes/governance/actions.manifest" + "IF_DYNAMIC_BUNDLE_MANIFEST": "~/.intentframe/integrations/hermes/governance/generic_actions.manifest" } } diff --git a/integrations/hermes/governance/README.md b/integrations/hermes/governance/README.md index e0ddeb8..eb69a96 100644 --- a/integrations/hermes/governance/README.md +++ b/integrations/hermes/governance/README.md @@ -4,8 +4,8 @@ |------|-------|---------| | `tools.yaml` (repo) | **Dev** | Tool catalog: names, mappers, action IDs, default `enabled` | | `tools.yaml` (runtime) | **User** | Same catalog; user toggles `enabled` via `governance enable\|disable` | -| `actions.manifest` (repo) | **Dev** | Static list of all `mapper: generic` action IDs (full catalog superset) | -| `actions.manifest` (runtime) | **Copied once** | Seeded on `integrate hermes`; never overwritten by automation | +| `generic_actions.manifest` (repo) | **Dev** | Static list of all `mapper: generic` action IDs (full catalog superset) | +| `generic_actions.manifest` (runtime) | **Copied once** | Seeded on `integrate hermes`; never overwritten by automation | ## User-facing CLI (runtime only) @@ -21,7 +21,7 @@ intents; policy rows for that action ID can remain without harm. ## Dev workflow (adding a generic tool) 1. Add entry to `tools.yaml` with `mapper: generic` and a `HERMES_*` action ID. -2. Regenerate committed `actions.manifest` to include the new action ID +2. Regenerate committed `generic_actions.manifest` to include the new action ID (golden test `tests/intentframe_integrations/test_actions_manifest.py` enforces parity). 3. Update `agent.json` `action_types`, shipped `policy.yaml`, and `executor.yaml` `supported_actions` (hand-edited; same golden test checks coverage). diff --git a/integrations/hermes/governance/generic_actions.manifest b/integrations/hermes/governance/generic_actions.manifest new file mode 100644 index 0000000..18e4972 --- /dev/null +++ b/integrations/hermes/governance/generic_actions.manifest @@ -0,0 +1 @@ +HERMES_CRONJOB diff --git a/integrations/hermes/governance/tools.yaml b/integrations/hermes/governance/tools.yaml index 1a23298..129b8d5 100644 --- a/integrations/hermes/governance/tools.yaml +++ b/integrations/hermes/governance/tools.yaml @@ -8,7 +8,7 @@ # writes ONLY the runtime copy (~/.intentframe/.../governance/tools.yaml). # Restart Hermes gateway + adapter after toggling (governance is cached at process start). # -# Dev control: this repo template + actions.manifest + agent.json + policy.yaml + +# Dev control: this repo template + generic_actions.manifest + agent.json + policy.yaml + # executor.yaml. Nothing at runtime auto-regenerates those files. # # Governance (enabled) and policy (allowed_actions) are independent gates — they do diff --git a/intentframe-integrations-cli/README.md b/intentframe-integrations-cli/README.md index dbe6cf0..ad76c08 100644 --- a/intentframe-integrations-cli/README.md +++ b/intentframe-integrations-cli/README.md @@ -42,7 +42,7 @@ gateway restart needed. **Runtime artifacts** (copied on first `integrate`, never auto-overwritten): - `governance/tools.yaml` — user toggles via `governance enable|disable` -- `governance/actions.manifest` — static dev-shipped superset of generic action IDs +- `governance/generic_actions.manifest` — static dev-shipped superset of generic action IDs - `policy.yaml` — user edits via policy CLI There is no `sync` command. Repo templates are dev-maintained only. @@ -104,7 +104,7 @@ adapter sync, and gateway lifecycle. | Variable | Set by | Effect | |----------|--------|--------| | `HERMES_GOVERNANCE_YAML` | `agent.json` default; override in shell or test harness | Which tools are **IntentFrame-governed** at runtime. If already set in the parent environment, `start hermes` (adapter) and `gateway start hermes` preserve it via `setdefault` — they do not replace it with the sandbox-seeded path from `integrate`. | -| `IF_DYNAMIC_BUNDLE_MANIFEST` | `agent.json` default | Path to static `actions.manifest` (generic `HERMES_*` action IDs). Backend dynamic bundle reads this at boot; unset env → dynamic bundle is a no-op. | +| `IF_DYNAMIC_BUNDLE_MANIFEST` | `agent.json` default | Path to static `generic_actions.manifest` (generic `HERMES_*` action IDs). Backend dynamic bundle reads this at boot; unset env → dynamic bundle is a no-op. | | `IF_AGENT_ADAPTER_SOCKET` | `agent.json` | UDS path for plugin → adapter validate calls. | `integrate hermes` prints `export …` lines from `format_env_exports()`: values already diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py index 9e529ea..e543a36 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py @@ -1,6 +1,6 @@ """Default governance template paths and runtime user config materialization. -Repo templates (dev-maintained): governance/tools.yaml, governance/actions.manifest. +Repo templates (dev-maintained): governance/tools.yaml, governance/generic_actions.manifest. Runtime copies (~/.intentframe/...): seeded on first integrate; never overwritten unless the user runs --reset-governance or deletes the file. User toggles tool governance via CLI; that only edits runtime tools.yaml enabled flags. @@ -19,8 +19,8 @@ HERMES_AGENT_ID = "hermes" DEFAULT_GOVERNANCE_TEMPLATE_RELATIVE = Path("integrations") / "hermes" / "governance" / "tools.yaml" -DEFAULT_ACTIONS_MANIFEST_RELATIVE = ( - Path("integrations") / "hermes" / "governance" / "actions.manifest" +DEFAULT_GENERIC_ACTIONS_MANIFEST_RELATIVE = ( + Path("integrations") / "hermes" / "governance" / "generic_actions.manifest" ) @@ -30,13 +30,13 @@ def default_governance_template_path() -> Path: def default_actions_manifest_template_path() -> Path: - """Committed actions.manifest shipped with the repo (dev-generated, static). + """Committed generic_actions.manifest shipped with the repo (dev-generated, static). Holds the full catalog of generic-mapper action IDs (enabled or not). It is a superset that never changes when a user toggles tool governance — only when a developer adds a tool to the catalog and regenerates it. """ - return repo_root() / DEFAULT_ACTIONS_MANIFEST_RELATIVE + return repo_root() / DEFAULT_GENERIC_ACTIONS_MANIFEST_RELATIVE def catalog_generic_action_ids() -> frozenset[str]: @@ -67,7 +67,7 @@ def format_manifest(action_ids: frozenset[str]) -> str: def actions_manifest_runtime_path(agent_id: str = HERMES_AGENT_ID) -> Path: """User-runtime manifest path (copied from the committed template on install).""" - return governance_yaml_runtime_path(agent_id).parent / "actions.manifest" + return governance_yaml_runtime_path(agent_id).parent / "generic_actions.manifest" def ensure_runtime_actions_manifest(agent_id: str = HERMES_AGENT_ID) -> Path: diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_edit.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_edit.py index 58245bb..20959d3 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_edit.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_edit.py @@ -1,7 +1,7 @@ """Enable/disable governed Hermes tools in runtime governance/tools.yaml. Only flips ``enabled`` on the runtime copy under ~/.intentframe/. Does not touch -actions.manifest, policy.yaml, or any repo templates. Restart Hermes gateway + +generic_actions.manifest, policy.yaml, or any repo templates. Restart Hermes gateway + adapter after changes (governance is cached at process start). """ diff --git a/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py b/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py index 38f6f97..6feb439 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py @@ -1,7 +1,7 @@ """Load runtime policy YAML into policy-registry (validate + upsert). Policy changes apply immediately via policy-registry UDS — no gateway or adapter -restart. Does not modify governance/tools.yaml or actions.manifest. +restart. Does not modify governance/tools.yaml or generic_actions.manifest. """ from __future__ import annotations diff --git a/tests/intentframe_integrations/test_actions_manifest.py b/tests/intentframe_integrations/test_actions_manifest.py index 510b9b4..80b9001 100644 --- a/tests/intentframe_integrations/test_actions_manifest.py +++ b/tests/intentframe_integrations/test_actions_manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Dev golden test: committed actions.manifest and derived configs match the catalog. +"""Dev golden test: committed generic_actions.manifest and derived configs match the catalog. These artifacts are dev-generated and static; they only change when a developer adds a tool to governance/tools.yaml. This test fails on drift so they cannot @@ -55,7 +55,7 @@ def test_committed_manifest_matches_full_generic_catalog(self) -> None: self.assertEqual( present, set(catalog_generic_action_ids()), - "committed actions.manifest is out of sync with the generic catalog; " + "committed generic_actions.manifest is out of sync with the generic catalog; " f"regenerate it to: {format_manifest(catalog_generic_action_ids())}", ) diff --git a/tests/intentframe_integrations/test_policy_manage.py b/tests/intentframe_integrations/test_policy_manage.py index 5d71f86..2b47a86 100644 --- a/tests/intentframe_integrations/test_policy_manage.py +++ b/tests/intentframe_integrations/test_policy_manage.py @@ -49,7 +49,7 @@ def __enter__(self) -> None: self.home / ".intentframe" / "integrations" / "hermes" / "governance" ) manifest_dir.mkdir(parents=True, exist_ok=True) - manifest_path = manifest_dir / "actions.manifest" + manifest_path = manifest_dir / "generic_actions.manifest" manifest_path.write_text("HERMES_CRONJOB", encoding="utf-8") os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = str(manifest_path) self._reset_bundle_loader_state() From 217ea56cf976cdca5c47b4dd5731a0e201a86e3c Mon Sep 17 00:00:00 2001 From: Prince Date: Tue, 23 Jun 2026 19:27:04 +0530 Subject: [PATCH 4/8] update --- docs/hermes-intentframe-integration-guide.md | 36 +++++++++++-------- integrations/hermes/README.md | 15 ++++---- integrations/hermes/governance/README.md | 4 ++- .../src/intentframe_integrations/cli.py | 24 +++++++++++++ tests/hermes_adapter/live_fixtures.py | 2 ++ tests/hermes_adapter/test_live.py | 5 +++ tests/hermes_gateway/README.md | 7 ++-- tests/hermes_gateway/governance_e2e_setup.py | 10 ++++-- tests/hermes_gateway/test_gateway_e2e.py | 15 +++++--- .../test_governed_tool_coverage.py | 24 +++++++++---- tests/hermes_governance_fixtures.py | 25 +++++++++++++ tests/hermes_plugin/test_bridge_gate_live.py | 5 +++ tests/hermes_tool_probes.py | 5 +++ .../test_scoped_governance_yaml.py | 11 ++++-- 14 files changed, 148 insertions(+), 40 deletions(-) diff --git a/docs/hermes-intentframe-integration-guide.md b/docs/hermes-intentframe-integration-guide.md index a71d1e1..2e2285b 100644 --- a/docs/hermes-intentframe-integration-guide.md +++ b/docs/hermes-intentframe-integration-guide.md @@ -517,13 +517,21 @@ one import is enough — preload dedupes modules. Delete coverage is via `patch` V4A `*** Delete File:` operations (maps to `DELETE_HOST_FILE`). -### Step 5 — E2E probes +### Step 5 — Probes (native gateway E2E + live semantic) -Add probe functions to `tests/hermes_gateway/test_gateway_e2e.py` and register symbols -in `tests/hermes_governance_fixtures.py` (`GATEWAY_E2E_PROBE_SYMBOLS`). Coverage test -enforces parity: +Every catalog tool must appear in the probe contract (`test_governed_tool_coverage.py`): -**Harness determinism (required for reliable E2E):** +| Mapper | Registry | Live adapter + plugin | Gateway LLM E2E | +|--------|----------|----------------------|-----------------| +| native (`terminal`, `process`, …) | `GATEWAY_E2E_PROBE_SYMBOLS` | deterministic ALLOW/BLOCK (+ patch semantic) | yes (`test_gateway_e2e.py`) | +| `generic` | derived via `mapper: generic` | semantic smoke (ALLOW or BLOCK) | no | + +Add native probe functions to `tests/hermes_gateway/test_gateway_e2e.py` and register symbols +in `tests/hermes_governance_fixtures.py` (`GATEWAY_E2E_PROBE_SYMBOLS`). Add generic live probes +in `tests/hermes_adapter/test_live.py` and `tests/hermes_plugin/test_bridge_gate_live.py`. +Coverage test enforces full-catalog parity across both tiers. + +**Harness determinism (required for reliable gateway E2E — native tools only):** | Probe type | Harness setup | |------------|---------------| @@ -533,12 +541,12 @@ enforces parity: Details: [`tests/hermes_gateway/README.md`](../tests/hermes_gateway/README.md#probe-harness-determinism). -```25:29:tests/hermes_gateway/test_governed_tool_coverage.py - def test_gateway_probe_registry_covers_catalog(self) -> None: - self.assertEqual( - frozenset(GATEWAY_E2E_PROBE_SYMBOLS), - template_catalog_tool_names(), - ) +```25:35:tests/hermes_gateway/test_governed_tool_coverage.py + def test_probe_tiers_partition_catalog(self) -> None: + gateway = gateway_e2e_probe_tool_names() + live_semantic = live_semantic_probe_tool_names() + catalog = template_catalog_tool_names() + self.assertEqual(gateway | live_semantic, catalog) ``` ### Step 6 — Verify @@ -642,16 +650,16 @@ HERMES_E2E_GOVERNED_TOOLS=terminal RUN_HERMES_GATEWAY_E2E=1 \ Expect: `POST /v1/responses ALLOW (attempt 1/3)`, passes 1/2a/2b. -### Layer 4 — Full gateway E2E (all governed catalog tools) +### Layer 4 — Full gateway E2E (native-mapper catalog tools) ```bash -# Default — all catalog tools governed +# Default — all catalog tools governed; gateway LLM probes run for native mappers only RUN_HERMES_GATEWAY_E2E=1 ./tests/scripts/test-hermes-gateway-e2e.sh ``` Runs ALLOW/BLOCK/semantic probes for `terminal`, `process`, `write_file`, `patch` (including V4A delete via `patch`) across greenfield, idempotent, and external-`HERMES_BIN` paths. -Full run is green as of 2026-06-23 (all passes, probes typically attempt 1). +Generic catalog tools (e.g. `cronjob`) are live-tested only — no gateway LLM probe. Probe matrix: [`tests/hermes_gateway/README.md`](../tests/hermes_gateway/README.md). Status snapshot: [`hermes-intentframe-state-report.md`](./hermes-intentframe-state-report.md). diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index b1ac8c3..fc49317 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -198,7 +198,7 @@ See [`governance/README.md`](governance/README.md) for dev vs user ownership. 1. Add an entry to `governance/tools.yaml`. 2. Add or reuse a mapper in `adapter/src/hermes_adapter/mapper.py`. 3. Update dev artifacts: `agent.json` `action_types`, shipped `policy.yaml`, `executor.yaml` `supported_actions`. -4. Add mapper unit test + E2E probe (native tools only). +4. Add mapper unit test + gateway LLM E2E probe + live adapter/plugin probes. **Generic mapper** (semantic-only, e.g. `cronjob` → `HERMES_CRONJOB`): @@ -206,7 +206,8 @@ See [`governance/README.md`](governance/README.md) for dev vs user ownership. 2. Regenerate committed `governance/generic_actions.manifest` (full catalog superset). 3. Update dev artifacts as above; add `safe: false` row in shipped `policy.yaml`. 4. Golden test: `tests/intentframe_integrations/test_actions_manifest.py`. -5. No plugin code changes — `map_generic` handles all generic tools. +5. Add live adapter + plugin semantic smoke probe (`action: list` or other low-risk args). +6. No plugin code changes — `map_generic` handles all generic tools. No gateway LLM E2E probe. No user-facing sync step. Users toggle governance via CLI; policy via policy CLI. @@ -232,7 +233,7 @@ each intent honestly. See [`docs/delete-host-file-validation.md`](../../docs/del passes path policy; Guardian decides). Not a guaranteed execute. 11. Ask LLM to `patch` with V4A `*** Delete File: /etc/…` → blocked by host-path policy (deterministic) -## Live integration tests (all governed tools) +## Live integration tests (all catalog tools) Deterministic adapter + plugin gate probes (no LLM) against a running Hermes stack: @@ -240,8 +241,9 @@ Deterministic adapter + plugin gate probes (no LLM) against a running Hermes sta ./tests/scripts/test-hermes-integration.sh ``` -Covers all four Hermes governed tools (`terminal`, `process`, `write_file`, `patch`) -including V4A `patch` multi-intent write+delete. Requires `OPENAI_API_KEY` (backend startup). +Covers all catalog tools: native tools (`terminal`, `process`, `write_file`, `patch`) +including V4A `patch` multi-intent write+delete, plus generic tools (e.g. `cronjob`) +via semantic smoke. Requires `OPENAI_API_KEY` (backend startup). ## Gateway E2E test (opt-in) @@ -253,8 +255,9 @@ RUN_HERMES_GATEWAY_E2E=1 \ python tests/hermes_gateway/test_gateway_e2e.py ``` -Requires `OPENAI_API_KEY`. Covers ALLOW/BLOCK for all four Hermes governed tools (`terminal`, `process`, +Requires `OPENAI_API_KEY`. Covers ALLOW/BLOCK for native-mapper catalog tools (`terminal`, `process`, `write_file`, `patch`), including V4A mixed write+delete multi-intent `patch` probes. +Generic tools are not exercised via gateway LLM E2E. Full run (passes 1, 2a, 2b) is green as of 2026-06-23. The harness seeds `patch replace` targets, uses pass-unique markers, and explicit block prompts — see diff --git a/integrations/hermes/governance/README.md b/integrations/hermes/governance/README.md index eb69a96..c09eaaa 100644 --- a/integrations/hermes/governance/README.md +++ b/integrations/hermes/governance/README.md @@ -25,7 +25,9 @@ intents; policy rows for that action ID can remain without harm. (golden test `tests/intentframe_integrations/test_actions_manifest.py` enforces parity). 3. Update `agent.json` `action_types`, shipped `policy.yaml`, and `executor.yaml` `supported_actions` (hand-edited; same golden test checks coverage). -4. Set `IF_DYNAMIC_BUNDLE_MANIFEST` in `agent.json` env (already points at runtime path). +4. Add live adapter + plugin semantic smoke probe in `tests/hermes_adapter/test_live.py` + and `tests/hermes_plugin/test_bridge_gate_live.py` (no gateway LLM E2E). +5. Set `IF_DYNAMIC_BUNDLE_MANIFEST` in `agent.json` env (already points at runtime path). There is **no** user-facing `sync` command. Runtime automation never rewrites repo templates or user governance/policy files. diff --git a/intentframe-integrations-cli/src/intentframe_integrations/cli.py b/intentframe-integrations-cli/src/intentframe_integrations/cli.py index 569a931..0522775 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/cli.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/cli.py @@ -28,6 +28,11 @@ install_hermes_agent, resolve_hermes_bin, ) +from intentframe_integrations.hermes_governance_contract import ( + HERMES_AGENT_ID, + ensure_runtime_actions_manifest, + ensure_runtime_governance_yaml, +) from intentframe_integrations.hermes_integrate import ( doctor_hermes, format_env_exports, @@ -65,6 +70,23 @@ def _apply_agent_env(pack: IntegrationPack) -> None: os.environ.setdefault(key, os.path.expanduser(value)) +def _seed_hermes_runtime_governance(pack: IntegrationPack) -> None: + """Seed Hermes runtime governance artifacts before backend boot. + + Ensures tools.yaml and generic_actions.manifest exist at their runtime paths + so IF_DYNAMIC_BUNDLE_MANIFEST and HERMES_GOVERNANCE_YAML are never set to + missing files when the backend starts. Safe to call before ``integrate hermes`` + has run; if the committed templates are missing the error surfaces at integrate time. + """ + if pack.agent.agent_id != HERMES_AGENT_ID: + return + try: + ensure_runtime_governance_yaml(HERMES_AGENT_ID) + ensure_runtime_actions_manifest(HERMES_AGENT_ID) + except FileNotFoundError: + pass + + def _load_pack(agent: str) -> IntegrationPack: return load_integration_pack(agent_config_path(agent)) @@ -182,6 +204,7 @@ def _cmd_start(agent: str, *, seed: bool, skip_if_exists: bool) -> int: pack = _load_pack(agent) _apply_agent_env(pack) + _seed_hermes_runtime_governance(pack) ec = _start_pack(pack, seed=seed, skip_if_exists=skip_if_exists) if ec: @@ -215,6 +238,7 @@ def _cmd_start_config( if cfg.is_file(): pack = load_integration_pack(cfg) _apply_agent_env(pack) + _seed_hermes_runtime_governance(pack) ec = _start_pack(pack, seed=seed, skip_if_exists=skip_if_exists) if ec: return ec diff --git a/tests/hermes_adapter/live_fixtures.py b/tests/hermes_adapter/live_fixtures.py index c3d1d8e..ce2efce 100644 --- a/tests/hermes_adapter/live_fixtures.py +++ b/tests/hermes_adapter/live_fixtures.py @@ -10,6 +10,7 @@ sys.path.insert(0, str(TESTS_DIR)) from hermes_tool_probes import ( # noqa: E402 + cronjob_semantic_args, patch_replace_allow_args, patch_replace_block_args, patch_v4a_block_args, @@ -30,6 +31,7 @@ PATCH_BLOCK_REPLACE_ARGS = patch_replace_block_args() PATCH_V4A_MIXED_HOME_DELETE_ARGS = patch_v4a_mixed_home_delete_args(marker=_LIVE_MARKER) PATCH_V4A_BLOCK_ARGS = patch_v4a_block_args(marker=_LIVE_MARKER) +CRONJOB_SEMANTIC_ARGS = cronjob_semantic_args() # Back-compat aliases PATCH_V4A_MIXED_ALLOW_ARGS = PATCH_V4A_MIXED_HOME_DELETE_ARGS diff --git a/tests/hermes_adapter/test_live.py b/tests/hermes_adapter/test_live.py index 38e7b4c..0c1c2e5 100644 --- a/tests/hermes_adapter/test_live.py +++ b/tests/hermes_adapter/test_live.py @@ -18,6 +18,7 @@ from intentframe_validation_helpers import assert_adapter_semantic_validate # noqa: E402 from live_fixtures import ( # noqa: E402 + CRONJOB_SEMANTIC_ARGS, PATCH_ALLOW_REPLACE_ARGS, PATCH_BLOCK_REPLACE_ARGS, PATCH_V4A_BLOCK_ARGS, @@ -113,6 +114,10 @@ def test_block_patch_v4a_mixed_system_delete(self) -> None: self.assertFalse(body["allowed"]) self.assertIn("agent_response", body) + def test_cronjob_semantic(self) -> None: + body = self._validate_tool("cronjob", CRONJOB_SEMANTIC_ARGS) + assert_adapter_semantic_validate(body) + def main() -> int: loader = unittest.TestLoader() diff --git a/tests/hermes_gateway/README.md b/tests/hermes_gateway/README.md index 78553da..72f31ed 100644 --- a/tests/hermes_gateway/README.md +++ b/tests/hermes_gateway/README.md @@ -77,9 +77,9 @@ RUN_HERMES_GATEWAY_E2E=1 ./scripts/e2e.sh | **2b** | External Hermes via `HERMES_BIN`, then `integrate` and gateway E2E | Each pass runs HTTP assertions against the gateway API for **IntentFrame-governed** -tools only (see `runtime_governed_tool_names()` in `_run_api_allow_block`). With -the default temp yaml that is all catalog tools; use `HERMES_E2E_GOVERNED_TOOLS` -to scope LLM probes. +native-mapper tools only. Generic-mapper catalog tools (e.g. `cronjob`) are covered +by live adapter/plugin semantic smoke — no gateway LLM probe. With the default temp +yaml all catalog tools are governed; use `HERMES_E2E_GOVERNED_TOOLS` to scope LLM probes. | Tool | Deterministic ALLOW probe | Deterministic BLOCK probe | Semantic (ALLOW or BLOCK) | |------|---------------------------|---------------------------|---------------------------| @@ -88,6 +88,7 @@ to scope LLM probes. | `write_file` | path under `~/…` | path under `/etc/…` | — | | `patch` (replace) | replace under `~/…` (harness seeds file with `"a"` first) | replace under `/etc/…` | — | | `patch` (V4A mixed) | — | Update `~/…` + Delete `/etc/…` (fail-closed batch) | Update `~/…` + Delete `~/…` (per-intent AE/Guardian; batch fails if any op BLOCKs) | +| `cronjob` (generic) | — | — | live only: `action: list` (no gateway LLM E2E) | Multi-intent `patch` calls map to multiple IntentFrame `/validate` requests inside the adapter; the plugin still sees one allow/block for the single Hermes tool call. diff --git a/tests/hermes_gateway/governance_e2e_setup.py b/tests/hermes_gateway/governance_e2e_setup.py index 4db602e..c8aaaad 100644 --- a/tests/hermes_gateway/governance_e2e_setup.py +++ b/tests/hermes_gateway/governance_e2e_setup.py @@ -23,6 +23,7 @@ from hermes_governance_fixtures import ( # noqa: E402 GATEWAY_E2E_PROBE_SYMBOLS, clear_shared_governance_cache, + live_semantic_probe_tool_names, template_catalog_tool_names, ) from hermes_governance.loader import load_governed_tools, load_tool_catalog # noqa: E402 @@ -135,13 +136,16 @@ def format_governance_snapshot(snapshot: GovernanceSnapshot) -> str: def format_gateway_probe_plan(governed: frozenset[str]) -> str: + live_semantic = live_semantic_probe_tool_names() lines = ["LLM probe plan (IntentFrame-governed → RUN, not governed → SKIP):"] for tool in sorted(template_catalog_tool_names()): probes = sorted(GATEWAY_E2E_PROBE_SYMBOLS.get(tool, ())) - if tool in governed: - lines.append(f" {tool}: RUN probes={probes}") - else: + if tool not in governed: lines.append(f" {tool}: SKIP probes={probes}") + elif tool in live_semantic: + lines.append(f" {tool}: LIVE_SEMANTIC_ONLY (no gateway LLM probe)") + else: + lines.append(f" {tool}: RUN probes={probes}") return "\n".join(lines) diff --git a/tests/hermes_gateway/test_gateway_e2e.py b/tests/hermes_gateway/test_gateway_e2e.py index 6ef51e7..63c92df 100644 --- a/tests/hermes_gateway/test_gateway_e2e.py +++ b/tests/hermes_gateway/test_gateway_e2e.py @@ -17,7 +17,11 @@ if str(_TESTS_DIR) not in sys.path: sys.path.insert(0, str(_TESTS_DIR)) -from hermes_governance_fixtures import template_governed_tool_names # noqa: E402 +from hermes_governance_fixtures import ( # noqa: E402 + gateway_e2e_probe_tool_names, + live_semantic_probe_tool_names, + template_governed_tool_names, +) from governance_e2e_setup import ( # noqa: E402 assert_governance_env_contract, cleanup_e2e_governance_yaml, @@ -261,18 +265,21 @@ def _run_api_allow_block(env: IsolatedEnv, *, label: str) -> None: marker=v4a_marker, ) - expected_ran = set(governed) + expected_ran = governed & gateway_e2e_probe_tool_names() + live_semantic_governed = sorted(governed & live_semantic_probe_tool_names()) if probes_ran != expected_ran: raise AssertionError( f"{label} probe execution mismatch.\n" f" expected RUN: {sorted(expected_ran)}\n" f" actual RUN: {sorted(probes_ran)}\n" - f" expected SKIP: {probes_skipped}" + f" expected SKIP: {probes_skipped}\n" + f" live-only semantic (no gateway LLM probe): {live_semantic_governed}" ) step( f"{label} probe summary: RUN={sorted(probes_ran)} " - f"SKIP={probes_skipped}" + f"SKIP={probes_skipped} " + f"LIVE_SEMANTIC_ONLY={live_semantic_governed}" ) unexpected_catalog = governed - template_governed_tool_names() diff --git a/tests/hermes_gateway/test_governed_tool_coverage.py b/tests/hermes_gateway/test_governed_tool_coverage.py index c89498d..46844b0 100644 --- a/tests/hermes_gateway/test_governed_tool_coverage.py +++ b/tests/hermes_gateway/test_governed_tool_coverage.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Contract: gateway E2E probes cover every governed Hermes tool.""" +"""Contract: every catalog tool has native gateway E2E probes or live semantic probes.""" from __future__ import annotations @@ -17,18 +17,30 @@ from hermes_governance_fixtures import ( # noqa: E402 GATEWAY_E2E_PROBE_SYMBOLS, LIVE_PLUGIN_EXTRA_FIXTURES, + gateway_e2e_probe_tool_names, + live_semantic_probe_tool_names, template_catalog_tool_names, + template_generic_mapper_tool_names, ) class TestGovernedToolCoverage(unittest.TestCase): - def test_gateway_probe_registry_covers_catalog(self) -> None: + def test_probe_tiers_partition_catalog(self) -> None: + gateway = gateway_e2e_probe_tool_names() + live_semantic = live_semantic_probe_tool_names() + catalog = template_catalog_tool_names() + self.assertEqual(gateway | live_semantic, catalog) + self.assertFalse(gateway & live_semantic) + self.assertEqual(live_semantic, template_generic_mapper_tool_names()) + + def test_gateway_probe_registry_covers_native_catalog(self) -> None: self.assertEqual( frozenset(GATEWAY_E2E_PROBE_SYMBOLS), - template_catalog_tool_names(), + gateway_e2e_probe_tool_names(), ) + self.assertFalse(gateway_e2e_probe_tool_names() & live_semantic_probe_tool_names()) - def test_gateway_e2e_invokes_all_governed_tools(self) -> None: + def test_gateway_e2e_invokes_all_native_probed_tools(self) -> None: source = (GATEWAY_DIR / "test_gateway_e2e.py").read_text(encoding="utf-8") self.assertIn("load_e2e_governance_snapshot", source) self.assertIn("snapshot.governed", source) @@ -41,12 +53,12 @@ def test_gateway_e2e_invokes_all_governed_tools(self) -> None: msg=f"missing E2E probe {symbol!r} for {tool!r}", ) - def test_live_adapter_covers_all_governed_tools(self) -> None: + def test_live_adapter_covers_all_catalog_tools(self) -> None: source = (TESTS_DIR / "hermes_adapter" / "test_live.py").read_text(encoding="utf-8") for tool in template_catalog_tool_names(): self.assertIn(f'"{tool}"', source, msg=f"missing live adapter probe for {tool}") - def test_live_plugin_gate_covers_all_governed_tools(self) -> None: + def test_live_plugin_gate_covers_all_catalog_tools(self) -> None: source = (TESTS_DIR / "hermes_plugin" / "test_bridge_gate_live.py").read_text( encoding="utf-8" ) diff --git a/tests/hermes_governance_fixtures.py b/tests/hermes_governance_fixtures.py index ce22142..c733f83 100644 --- a/tests/hermes_governance_fixtures.py +++ b/tests/hermes_governance_fixtures.py @@ -45,6 +45,9 @@ {"PATCH_V4A_MIXED_HOME_DELETE_ARGS", "PATCH_V4A_BLOCK_ARGS"} ) +# Generic-mapper catalog tools: live adapter/plugin semantic smoke only (no gateway LLM E2E). +# Derived from ``mapper: generic`` in the default template — do not hardcode tool names here. + _governance_env_depth = 0 _governance_env_saved: str | None = None @@ -95,6 +98,28 @@ def template_catalog_tool_names() -> frozenset[str]: return frozenset(load_tool_catalog(str(DEFAULT_GOVERNANCE_TEMPLATE)).keys()) +@lru_cache(maxsize=1) +def template_generic_mapper_tool_names() -> frozenset[str]: + """Catalog tools using ``mapper: generic`` (live semantic probes, no gateway LLM E2E).""" + ensure_shared_loader_importable() + from hermes_governance.loader import load_tool_catalog + + catalog = load_tool_catalog(str(DEFAULT_GOVERNANCE_TEMPLATE)) + return frozenset(name for name, spec in catalog.items() if spec.mapper == "generic") + + +@lru_cache(maxsize=1) +def live_semantic_probe_tool_names() -> frozenset[str]: + """Alias for generic-mapper tools covered by live adapter/plugin semantic smoke.""" + return template_generic_mapper_tool_names() + + +@lru_cache(maxsize=1) +def gateway_e2e_probe_tool_names() -> frozenset[str]: + """Native-mapper catalog tools with deterministic gateway LLM E2E probes.""" + return frozenset(GATEWAY_E2E_PROBE_SYMBOLS) + + @lru_cache(maxsize=1) def template_governed_tool_names() -> frozenset[str]: """Tools marked governed (yaml ``enabled: true``) in the default template.""" diff --git a/tests/hermes_plugin/test_bridge_gate_live.py b/tests/hermes_plugin/test_bridge_gate_live.py index 93540b2..2409ce9 100644 --- a/tests/hermes_plugin/test_bridge_gate_live.py +++ b/tests/hermes_plugin/test_bridge_gate_live.py @@ -17,6 +17,7 @@ from _loader import load_plugin_module # noqa: E402 from live_fixtures import ( # noqa: E402 + CRONJOB_SEMANTIC_ARGS, PATCH_ALLOW_REPLACE_ARGS, PATCH_BLOCK_REPLACE_ARGS, PATCH_V4A_BLOCK_ARGS, @@ -119,6 +120,10 @@ def test_block_patch_v4a_mixed_system_delete(self) -> None: delegate = MagicMock() self._assert_blocked("patch", PATCH_V4A_BLOCK_ARGS, delegate=delegate) + def test_cronjob_semantic(self) -> None: + delegate = MagicMock(return_value='{"status": "ok"}') + self._assert_semantic_gate("cronjob", CRONJOB_SEMANTIC_ARGS, delegate=delegate) + def main() -> int: loader = unittest.TestLoader() diff --git a/tests/hermes_tool_probes.py b/tests/hermes_tool_probes.py index fc4b3bf..5d60074 100644 --- a/tests/hermes_tool_probes.py +++ b/tests/hermes_tool_probes.py @@ -111,3 +111,8 @@ def patch_v4a_block_args(*, marker: str, reason: str = "E2E V4A patch update hom "patch": patch_v4a_block_content(marker=marker), "reason": reason, } + + +def cronjob_semantic_args(*, reason: str = "List scheduled jobs for audit") -> dict[str, str]: + """Low-risk cronjob intent — AE/Guardian outcome is semantic (ALLOW or BLOCK).""" + return {"action": "list", "reason": reason} diff --git a/tests/intentframe_integrations/test_scoped_governance_yaml.py b/tests/intentframe_integrations/test_scoped_governance_yaml.py index 35f53e9..09310d4 100644 --- a/tests/intentframe_integrations/test_scoped_governance_yaml.py +++ b/tests/intentframe_integrations/test_scoped_governance_yaml.py @@ -12,9 +12,10 @@ REPO_ROOT = Path(__file__).resolve().parents[2] CLI_SRC = REPO_ROOT / "intentframe-integrations-cli" / "src" GATEWAY_DIR = REPO_ROOT / "tests" / "hermes_gateway" +TESTS_DIR = REPO_ROOT / "tests" SHARED_SRC = REPO_ROOT / "integrations" / "hermes" / "shared" / "src" -for path in (CLI_SRC, GATEWAY_DIR, SHARED_SRC): +for path in (CLI_SRC, GATEWAY_DIR, TESTS_DIR, SHARED_SRC): if str(path) not in sys.path: sys.path.insert(0, str(path)) @@ -33,6 +34,7 @@ parse_governed_tools_env, setup_e2e_governance_yaml, ) +from hermes_governance_fixtures import template_catalog_tool_names # noqa: E402 class TestWriteScopedGovernanceYaml(unittest.TestCase): @@ -99,18 +101,20 @@ def test_parse_governed_tools_env(self) -> None: ) def test_log_e2e_governance_reports_scoped_tools(self) -> None: + governed = frozenset({"terminal", "process"}) os.environ["HERMES_E2E_GOVERNED_TOOLS"] = "terminal,process" setup_e2e_governance_yaml() messages: list[str] = [] snapshot = log_e2e_governance(log=messages.append) - self.assertEqual(snapshot.governed, frozenset({"terminal", "process"})) - self.assertEqual(snapshot.ungoverned, frozenset({"write_file", "patch"})) + self.assertEqual(snapshot.governed, governed) + self.assertEqual(snapshot.ungoverned, template_catalog_tool_names() - governed) joined = "\n".join(messages) self.assertIn("HERMES_E2E_GOVERNED_TOOLS", joined) self.assertIn("terminal: RUN", joined) self.assertIn("write_file: SKIP", joined) + self.assertIn("cronjob: SKIP", joined) def test_assert_e2e_governance_snapshot_rejects_mismatch(self) -> None: path = write_scoped_governance_yaml(governed_tools=frozenset({"terminal"})) @@ -137,6 +141,7 @@ def test_format_gateway_probe_plan(self) -> None: text = format_gateway_probe_plan(frozenset({"terminal"})) self.assertIn("terminal: RUN", text) self.assertIn("process: SKIP", text) + self.assertIn("cronjob: SKIP", text) def test_assert_governance_env_contract(self) -> None: os.environ["HERMES_E2E_GOVERNED_TOOLS"] = "terminal" From 5d1a9efa06ebb58b61d5733d92cd14179058b0eb Mon Sep 17 00:00:00 2001 From: Prince Date: Tue, 23 Jun 2026 19:51:00 +0530 Subject: [PATCH 5/8] update --- docs/NATIVE_KIT_INTEGRATION.md | 1 + docs/agent-tool-gating.md | 5 + docs/hermes-intentframe-integration-guide.md | 47 +++++++++ docs/hermes-intentframe-state-report.md | 19 ++-- if-integration-backend/README.md | 2 + integrations/hermes/README.md | 18 +++- integrations/hermes/governance/README.md | 2 + intentframe-integrations-cli/README.md | 21 +++- .../src/intentframe_integrations/cli.py | 78 +++++---------- .../hermes_integrate.py | 4 + .../integration_pack.py | 60 +++++++++++- .../intentframe_integrations/policy_manage.py | 25 ++--- .../test_hermes_install.py | 4 +- .../test_integration_pack.py | 60 +++++++++++- .../test_policy_manage.py | 97 ++++++++++++++++++- tests/scripts/test-hermes-integration.sh | 2 + 16 files changed, 360 insertions(+), 85 deletions(-) diff --git a/docs/NATIVE_KIT_INTEGRATION.md b/docs/NATIVE_KIT_INTEGRATION.md index a192219..2dbc7db 100644 --- a/docs/NATIVE_KIT_INTEGRATION.md +++ b/docs/NATIVE_KIT_INTEGRATION.md @@ -439,6 +439,7 @@ Add tests that simulate re-registration clobber. ### Per-deployment overrides - `HERMES_GOVERNANCE_YAML` — alternate governance yaml (which tools are **IntentFrame-governed**). Set in the parent shell before `start` / `gateway start`; CLI child env builders preserve it over `agent.json` defaults. +- `IF_DYNAMIC_BUNDLE_MANIFEST` — path to runtime `generic_actions.manifest` (generic `HERMES_*` action IDs). Default in `agent.json`; CLI applies via `load_and_activate_pack` for `start` and all `policy *` commands. Explicit shell export wins (`setdefault`). - `HERMES_E2E_GOVERNED_TOOLS` — gateway E2E only; comma-separated subset for LLM probes (not Hermes toolsets). E2E also asserts env parity via `assert_governance_env_contract`. - **Runtime policy** — `~/.intentframe/integrations/hermes/policy.yaml` (copied from shipped template on first `integrate` / `start`). Edit locally, then `bin/intentframe-integrations policy reload hermes`. Use `policy set`, `policy reset`, or `integrate hermes --reset-policy` to install or restore defaults. diff --git a/docs/agent-tool-gating.md b/docs/agent-tool-gating.md index 16d6346..c4a2831 100644 --- a/docs/agent-tool-gating.md +++ b/docs/agent-tool-gating.md @@ -365,6 +365,11 @@ intentframe-integrations policy reset hermes Policy changes apply immediately (no gateway restart). Use `integrate hermes --reset-policy` to restore the shipped default. +**Env parity:** `policy show|reload|set|reset` call `load_and_activate_pack()` before +validating policy — same `agent.json` env defaults (`setdefault`) and Hermes artifact +seeding as `start hermes`. Explicit shell exports (e.g. test harness +`HERMES_GOVERNANCE_YAML`) always win over `agent.json`. + **Tests:** catalog-wide integration tests generate a throwaway all-governed yaml from the default template via `HERMES_GOVERNANCE_YAML`; they never mutate runtime user config. Gateway E2E accepts `HERMES_E2E_GOVERNED_TOOLS=terminal,process` to diff --git a/docs/hermes-intentframe-integration-guide.md b/docs/hermes-intentframe-integration-guide.md index 2e2285b..176d79b 100644 --- a/docs/hermes-intentframe-integration-guide.md +++ b/docs/hermes-intentframe-integration-guide.md @@ -601,6 +601,37 @@ tool in yaml stops preloading/wrapping it on next gateway restart. --- +## Pack activation and env precedence + +All CLI commands that need an agent profile call `load_and_activate_pack()` in +`intentframe_integrations/integration_pack.py`: + +1. Load `integrations//agent.json` +2. Apply `env` keys via `os.environ.setdefault` — **explicit shell exports win** +3. For Hermes: seed runtime `governance/tools.yaml` and `generic_actions.manifest` + if missing (paths in env must point at real files) + +This applies to `start`, `integrate`, `doctor`, `gateway start`, `run`, and every +`policy *` command. It keeps three layers aligned: + +| Priority | Source | Example | +|----------|--------|---------| +| 1 (wins) | Explicit `export` in shell / test harness | `HERMES_GOVERNANCE_YAML=/tmp/e2e.yaml` | +| 2 | `agent.json` `env` defaults | `IF_DYNAMIC_BUNDLE_MANIFEST=~/.intentframe/.../generic_actions.manifest` | +| 3 | Seeded runtime files | Copied from repo templates on first use | + +**Why policy commands need this:** `policy reload` validates policy locally via +`validate_policy_with_bundles()` — it rebuilds the bundle registry from the CLI +process env. Generic action IDs (e.g. `HERMES_CRONJOB`) register only when +`IF_DYNAMIC_BUNDLE_MANIFEST` points at a manifest file. Without pack activation, +validation fails even though the running backend registered those actions at boot. + +Regression tests: `tests/intentframe_integrations/test_policy_manage.py`, +`tests/intentframe_integrations/test_integration_pack.py`. Live smoke: +`tests/scripts/test-hermes-integration.sh` (`policy show` + `policy reload`). + +--- + ## Testing pyramid Run cheap tests first; full E2E last. @@ -619,6 +650,10 @@ uv run --package intentframe-integrations-cli python tests/intentframe_integrati # Probe symbol coverage for all catalog tools uv run --package intentframe-integrations-cli python tests/hermes_gateway/test_governed_tool_coverage.py + +# Pack activation + policy env parity (HERMES_CRONJOB / manifest defaults) +uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_integration_pack.py +uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_policy_manage.py ``` Extend `test_builtin_preload.py` when adding `GOVERNED_BUILTIN_MODULES` entries. @@ -664,6 +699,16 @@ Generic catalog tools (e.g. `cronjob`) are live-tested only — no gateway LLM p Probe matrix: [`tests/hermes_gateway/README.md`](../tests/hermes_gateway/README.md). Status snapshot: [`hermes-intentframe-state-report.md`](./hermes-intentframe-state-report.md). +### Layer 5 — Live adapter + plugin integration (all catalog tools) + +```bash +./tests/scripts/test-hermes-integration.sh +``` + +Requires `OPENAI_API_KEY`. Starts Hermes stack, runs `policy show` + `policy reload` +(live registry smoke), then deterministic adapter + plugin gate probes for every +catalog tool (native ALLOW/BLOCK + generic semantic smoke for e.g. `cronjob`). + --- ## Troubleshooting @@ -679,6 +724,8 @@ Status snapshot: [`hermes-intentframe-state-report.md`](./hermes-intentframe-sta | Stale governance after edit | Process not restarted | restart adapter + gateway | | `patch replace ALLOW` fails Pass 2a (overwrite BLOCK) | Same marker/file reused across passes | Pass-unique marker + `seed_patch_replace_target` | | `patch replace BLOCK`: path under `/tmp/…` | LLM rewrote `/etc/…` | Explicit block prompt in `run_patch_replace_block_once` | +| `policy reload`: `HERMES_*` has no registered ActionBundle | CLI env missing manifest | Ensure `agent.json` has `IF_DYNAMIC_BUNDLE_MANIFEST`; policy commands use `load_and_activate_pack`. Run `test_policy_manage.py`. | +| `start hermes`: BundleConfigError (manifest missing) | Runtime file not seeded | `load_and_activate_pack` seeds on start; run `integrate hermes` once or check `~/.intentframe/integrations/hermes/governance/` | **Debug order:** diff --git a/docs/hermes-intentframe-state-report.md b/docs/hermes-intentframe-state-report.md index ba9ccd3..93efdaa 100644 --- a/docs/hermes-intentframe-state-report.md +++ b/docs/hermes-intentframe-state-report.md @@ -9,7 +9,7 @@ | Area | Status | |------|--------| -| Governed tool catalog | **4 tools**: `terminal`, `process`, `write_file`, `patch` | +| Governed tool catalog | **5 tools**: `terminal`, `process`, `write_file`, `patch`, `cronjob` (generic) | | Standalone `delete_file` Hermes tool | **Removed** — delete via `patch` V4A `*** Delete File:` → `DELETE_HOST_FILE` | | Plugin gateway registration | **Fixed** — selective `builtin_preload` before registry snapshot | | Full gateway E2E (pass 1, 2a, 2b) | **Green** — all four governed tools, probes typically attempt 1 | @@ -68,6 +68,7 @@ IntentFrame gate active; **`enabled: false`** means native Hermes handler withou | `process` | `RUN_COMMAND` | `process` | Maps `action: run` + `data` to shell command | | `write_file` | `WRITE_HOST_FILE` | `write_file` | Path + content | | `patch` | `WRITE_HOST_FILE`, `DELETE_HOST_FILE` | `patch` | Replace mode + V4A multi-intent | +| `cronjob` | `HERMES_CRONJOB` | `generic` | Semantic-only via dynamic bundle; live smoke, no gateway LLM E2E | **Not governed by default:** `read_file`, `search_files`, browser tools, skills, etc. They may still appear on `GET /v1/toolsets` and run without IntentFrame validation. @@ -125,7 +126,8 @@ adapter venv, and copies shipped governance + policy templates into **Policy changes:** edit `~/.intentframe/integrations/hermes/policy.yaml`, then `bin/intentframe-integrations policy reload hermes` (no gateway restart). Use `policy set`, `policy reset`, or `integrate hermes --reset-policy` to switch -or restore defaults. +or restore defaults. Policy commands apply `agent.json` env via `load_and_activate_pack` +(manifest path for generic action IDs) before validating against registered bundles. --- @@ -133,10 +135,10 @@ or restore defaults. | Layer | Entry | LLM / network | |-------|-------|---------------| -| Unit | `tests/hermes_plugin/`, `tests/hermes_gateway/test_*.py`, adapter tests | No | +| Unit | `tests/hermes_plugin/`, `tests/hermes_gateway/test_*.py`, adapter tests, `test_policy_manage.py`, `test_integration_pack.py` | No | | Toolsets + provider payload | `RUN_HERMES_GATEWAY_TOOLSETS=1 ./tests/scripts/test-hermes-gateway-toolsets.sh` | OpenAI `chat.completions` (one round-trip); asserts `tools=` + `reason` in request dump | -| Live integration | `./tests/scripts/test-hermes-integration.sh` | Backend; no LLM probes | -| Gateway E2E | `RUN_HERMES_GATEWAY_E2E=1 ./tests/scripts/test-hermes-gateway-e2e.sh` | OpenAI + full stack; tool-calling ALLOW/BLOCK probes | +| Live integration | `./tests/scripts/test-hermes-integration.sh` | Backend; policy reload smoke + adapter/plugin probes (no LLM) | +| Gateway E2E | `RUN_HERMES_GATEWAY_E2E=1 ./tests/scripts/test-hermes-gateway-e2e.sh` | OpenAI + full stack; native-mapper LLM probes only | ### Gateway E2E passes @@ -146,8 +148,9 @@ or restore defaults. | **2a** | Idempotent install/integrate on same sandbox | | **2b** | External `HERMES_BIN` symlink, first-time integrate | -With default temp governance yaml, each pass runs ALLOW/BLOCK/semantic probes for all -four catalog tools. +With default temp governance yaml, each pass runs ALLOW/BLOCK/semantic probes for native +catalog tools (`terminal`, `process`, `write_file`, `patch`). Generic tools (e.g. `cronjob`) +are live-tested via adapter/plugin semantic smoke only — no gateway LLM probe. ### E2E harness determinism (2026-06) @@ -172,6 +175,8 @@ See [`tests/hermes_gateway/README.md`](../tests/hermes_gateway/README.md). | Remove invented `delete_file` catalog entry | Hermes 0.17 has no standalone delete tool; use `patch` V4A | | Patch replace seed + pass markers | Fix flaky ALLOW and Pass 2a overwrite BLOCK | | Hardened block probe prompts | Fix LLM rewriting `/etc/` to sandbox paths | +| `load_and_activate_pack` + policy env parity | Policy validation sees same manifest env as backend boot | +| `cronjob` generic tool + two-tier probe contract | Live semantic smoke; no gateway LLM E2E for generic mappers | --- diff --git a/if-integration-backend/README.md b/if-integration-backend/README.md index 45c81df..be5d8aa 100644 --- a/if-integration-backend/README.md +++ b/if-integration-backend/README.md @@ -7,6 +7,8 @@ Agent profiles live under `../integrations/` (e.g. `integrations/hermes/`) — e **Dynamic bundle** (`bundles/dynamic.py`): agent-agnostic pass-through bundle. Reads `IF_DYNAMIC_BUNDLE_MANIFEST` (comma-separated action IDs). If unset, registers nothing. Hermes sets this env in `agent.json` pointing at runtime `generic_actions.manifest`. +The integrations CLI applies the same env via `load_and_activate_pack()` before +backend start and before policy validation — see `intentframe-integrations-cli/README.md`. ## Quick start diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index fc49317..4059f97 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -133,14 +133,18 @@ There is no user-facing `sync` command. Runtime CLI never rewrites repo template ### Governance env contract -`agent.json` declares a default `HERMES_GOVERNANCE_YAML` (runtime sandbox path above). -The CLI propagates governance config to child processes as follows: +`agent.json` declares defaults for `HERMES_GOVERNANCE_YAML`, `IF_DYNAMIC_BUNDLE_MANIFEST`, +and `IF_AGENT_ADAPTER_SOCKET`. All pack-loading CLI commands use +`load_and_activate_pack()` (`integration_pack.py`): apply env via `setdefault`, then +seed Hermes runtime governance artifacts if missing. | Step | Behavior | |------|----------| | `integrate hermes` | Prints `export …` using the **effective** value (`os.environ` overrides `agent.json`). Copies governance yaml, actions manifest, and policy template to runtime on first use. | -| `start hermes` (adapter) | `_adapter_env()` copies the parent environment and `setdefault`s `pack.agent.env` keys — an existing `HERMES_GOVERNANCE_YAML` in the shell is preserved. | +| `start hermes` | `load_and_activate_pack` → seeds manifest/governance if missing → starts backend (dynamic bundle reads manifest) + adapter. | +| `policy show\|reload\|set\|reset hermes` | Same pack activation before local policy validation — registers generic action IDs (e.g. `HERMES_CRONJOB`) from manifest. No backend restart. | | `gateway start hermes` | `build_gateway_env()` uses the same `setdefault` pattern; logs `Hermes governance config: …` on startup. | +| `start hermes` (adapter child) | `_adapter_env()` copies parent env and `setdefault`s `pack.agent.env` keys. | | Env | Points to | Read by | |-----|-----------|---------| @@ -243,7 +247,13 @@ Deterministic adapter + plugin gate probes (no LLM) against a running Hermes sta Covers all catalog tools: native tools (`terminal`, `process`, `write_file`, `patch`) including V4A `patch` multi-intent write+delete, plus generic tools (e.g. `cronjob`) -via semantic smoke. Requires `OPENAI_API_KEY` (backend startup). +via semantic smoke. Also runs `policy show` + `policy reload` (live registry smoke — +validates generic action IDs via `agent.json` manifest defaults without exporting +`IF_DYNAMIC_BUNDLE_MANIFEST`). Requires `OPENAI_API_KEY` (backend startup). + +Unit regression for policy env parity (no live stack): +`tests/intentframe_integrations/test_policy_manage.py` and +`tests/intentframe_integrations/test_integration_pack.py`. ## Gateway E2E test (opt-in) diff --git a/integrations/hermes/governance/README.md b/integrations/hermes/governance/README.md index c09eaaa..e8ef86c 100644 --- a/integrations/hermes/governance/README.md +++ b/integrations/hermes/governance/README.md @@ -14,6 +14,8 @@ Does not touch manifest, policy, or repo files. - **`policy set|reload|reset hermes`** — edits runtime `policy.yaml` and loads into policy-registry immediately. **No** gateway or backend restart needed. + CLI applies `agent.json` env (including `IF_DYNAMIC_BUNDLE_MANIFEST`) via + `load_and_activate_pack` before validating policy against registered bundles. Governance and policy are **independent**: disabling a tool stops Hermes from sending intents; policy rows for that action ID can remain without harm. diff --git a/intentframe-integrations-cli/README.md b/intentframe-integrations-cli/README.md index ad76c08..053532d 100644 --- a/intentframe-integrations-cli/README.md +++ b/intentframe-integrations-cli/README.md @@ -39,7 +39,20 @@ shipped template on first `integrate` or `start`). Edit that file, then `policy reset` to restore the shipped default. Changes apply immediately — no gateway restart needed. -**Runtime artifacts** (copied on first `integrate`, never auto-overwritten): +**Pack activation** — every command that loads an agent profile through +`load_and_activate_pack()` (in `integration_pack.py`) applies the same runtime +setup: + +1. Load `agent.json` → `IntegrationPack` +2. Apply `agent.json` `env` via `os.environ.setdefault` (explicit shell exports win) +3. For Hermes: seed runtime `governance/tools.yaml` and `generic_actions.manifest` + if missing (so manifest paths in env always point at real files) + +Used by `start`, `integrate`, `doctor`, `gateway start`, `run`, and all +`policy *` commands. Policy validation rebuilds bundle registry from the CLI +process environment — it must see the same manifest path the backend used at boot. + +**Runtime artifacts** (copied on first `integrate` or seeded on first `start`, never auto-overwritten): - `governance/tools.yaml` — user toggles via `governance enable|disable` - `governance/generic_actions.manifest` — static dev-shipped superset of generic action IDs @@ -103,10 +116,12 @@ adapter sync, and gateway lifecycle. | Variable | Set by | Effect | |----------|--------|--------| -| `HERMES_GOVERNANCE_YAML` | `agent.json` default; override in shell or test harness | Which tools are **IntentFrame-governed** at runtime. If already set in the parent environment, `start hermes` (adapter) and `gateway start hermes` preserve it via `setdefault` — they do not replace it with the sandbox-seeded path from `integrate`. | -| `IF_DYNAMIC_BUNDLE_MANIFEST` | `agent.json` default | Path to static `generic_actions.manifest` (generic `HERMES_*` action IDs). Backend dynamic bundle reads this at boot; unset env → dynamic bundle is a no-op. | +| `HERMES_GOVERNANCE_YAML` | `agent.json` default; override in shell or test harness | Which tools are **IntentFrame-governed** at runtime. All pack-loading commands apply via `setdefault` — explicit shell value wins over `agent.json`. | +| `IF_DYNAMIC_BUNDLE_MANIFEST` | `agent.json` default | Path to runtime `generic_actions.manifest` (generic `HERMES_*` action IDs). Backend dynamic bundle reads this at boot; policy `reload`/`set`/`reset` validate against the same registry — CLI applies this env via `load_and_activate_pack` before validation. | | `IF_AGENT_ADAPTER_SOCKET` | `agent.json` | UDS path for plugin → adapter validate calls. | +**Env precedence:** explicit `os.environ` → `agent.json` defaults (`setdefault`) → seeded runtime files at those paths. Dev/test harnesses may export overrides before any CLI command; they are never clobbered. + `integrate hermes` prints `export …` lines from `format_env_exports()`: values already present in the shell (including `HERMES_GOVERNANCE_YAML`) win over `agent.json` defaults. diff --git a/intentframe-integrations-cli/src/intentframe_integrations/cli.py b/intentframe-integrations-cli/src/intentframe_integrations/cli.py index 0522775..bf17cae 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/cli.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/cli.py @@ -1,4 +1,8 @@ -"""intentframe-integrations — user-facing orchestrator for agent profiles.""" +"""intentframe-integrations — user-facing orchestrator for agent profiles. + +Commands that need agent env or Hermes runtime artifacts use +``load_and_activate_pack*`` from ``integration_pack`` (not raw ``load_integration_pack``). +""" from __future__ import annotations @@ -28,16 +32,10 @@ install_hermes_agent, resolve_hermes_bin, ) -from intentframe_integrations.hermes_governance_contract import ( - HERMES_AGENT_ID, - ensure_runtime_actions_manifest, - ensure_runtime_governance_yaml, -) from intentframe_integrations.hermes_integrate import ( doctor_hermes, format_env_exports, integrate_hermes, - load_hermes_pack, ) from intentframe_integrations.hermes_governance_edit import ( GovernanceEditError, @@ -45,7 +43,12 @@ runtime_governed_tool_names, set_tool_enabled, ) -from intentframe_integrations.integration_pack import IntegrationPack, load_integration_pack +from intentframe_integrations.integration_pack import ( + IntegrationPack, + load_and_activate_pack, + load_and_activate_pack_from_path, + load_integration_pack, +) from intentframe_integrations.policy_contract import ensure_runtime_policy_yaml from intentframe_integrations.policy_manage import ( PolicyError, @@ -63,34 +66,6 @@ ) -def _apply_agent_env(pack: IntegrationPack) -> None: - os.environ.setdefault("INTENTFRAME_USER_ID", pack.agent.user_id) - os.environ.setdefault("INTENTFRAME_AGENT_ID", pack.agent.agent_id) - for key, value in pack.agent.env.items(): - os.environ.setdefault(key, os.path.expanduser(value)) - - -def _seed_hermes_runtime_governance(pack: IntegrationPack) -> None: - """Seed Hermes runtime governance artifacts before backend boot. - - Ensures tools.yaml and generic_actions.manifest exist at their runtime paths - so IF_DYNAMIC_BUNDLE_MANIFEST and HERMES_GOVERNANCE_YAML are never set to - missing files when the backend starts. Safe to call before ``integrate hermes`` - has run; if the committed templates are missing the error surfaces at integrate time. - """ - if pack.agent.agent_id != HERMES_AGENT_ID: - return - try: - ensure_runtime_governance_yaml(HERMES_AGENT_ID) - ensure_runtime_actions_manifest(HERMES_AGENT_ID) - except FileNotFoundError: - pass - - -def _load_pack(agent: str) -> IntegrationPack: - return load_integration_pack(agent_config_path(agent)) - - def _run_backend(argv: list[str]) -> int: return backend_main(argv) @@ -107,8 +82,6 @@ def _require_openai_api_key() -> int | None: def _seed_agent_config(cfg: Path, *, skip_if_exists: bool) -> int: if cfg.is_file(): - pack = load_integration_pack(cfg) - _apply_agent_env(pack) paths = [cfg] elif cfg.is_dir(): paths = sorted(cfg.rglob("agent.json")) @@ -120,7 +93,8 @@ def _seed_agent_config(cfg: Path, *, skip_if_exists: bool) -> int: return 1 for path in paths: - pack = load_integration_pack(path) + # Seed policy path + agent env before delegating to backend seed-policy. + pack = load_and_activate_pack_from_path(path) try: runtime_policy = ensure_runtime_policy_yaml(pack) except FileNotFoundError as exc: @@ -155,6 +129,7 @@ def _start_adapter_for_pack(pack: IntegrationPack) -> int: def _start_adapters_for_configs(configs: list[Path]) -> int: for path in configs: + # Adapter-only path: parent ``start --agent-config `` already started backend. pack = load_integration_pack(path) if pack.adapter is None: continue @@ -202,9 +177,8 @@ def _cmd_start(agent: str, *, seed: bool, skip_if_exists: bool) -> int: if (ec := _require_openai_api_key()) is not None: return ec - pack = _load_pack(agent) - _apply_agent_env(pack) - _seed_hermes_runtime_governance(pack) + # Env + Hermes manifest seed before backend inherits os.environ. + pack = load_and_activate_pack(agent) ec = _start_pack(pack, seed=seed, skip_if_exists=skip_if_exists) if ec: @@ -236,9 +210,7 @@ def _cmd_start_config( return 1 if cfg.is_file(): - pack = load_integration_pack(cfg) - _apply_agent_env(pack) - _seed_hermes_runtime_governance(pack) + pack = load_and_activate_pack_from_path(cfg) ec = _start_pack(pack, seed=seed, skip_if_exists=skip_if_exists) if ec: return ec @@ -280,7 +252,7 @@ def _cmd_status() -> int: ec = _run_backend(["status"]) for agent in list_agents(): try: - pack = _load_pack(agent) + pack = load_and_activate_pack(agent) except (FileNotFoundError, ValueError): continue if pack.adapter is not None: @@ -312,8 +284,7 @@ def _cmd_doctor( require_integration: bool = True, ) -> int: if agent == "hermes": - pack = load_hermes_pack() - _apply_agent_env(pack) + pack = load_and_activate_pack("hermes") report = doctor_hermes( pack, require_hermes=require_hermes, @@ -326,7 +297,7 @@ def _cmd_doctor( print(format_env_exports(pack), file=sys.stderr) return 0 if report.ok else 1 - pack = _load_pack(agent) + pack = load_and_activate_pack(agent) bridge_socket = Path(os.path.expanduser("~/.intentframe/backend/bridge.sock")) print(f"Repo agent config: {pack.agent.source_path}") @@ -401,8 +372,7 @@ def _cmd_integrate( print(f"ERROR: integrate is only implemented for hermes (got {agent!r})", file=sys.stderr) return 1 - pack = load_hermes_pack() - _apply_agent_env(pack) + pack = load_and_activate_pack("hermes") try: result = integrate_hermes( pack, @@ -460,8 +430,7 @@ def _cmd_gateway_start( print(f"ERROR: gateway start is only implemented for hermes (got {agent!r})", file=sys.stderr) return 1 - pack = load_hermes_pack() - _apply_agent_env(pack) + pack = load_and_activate_pack("hermes") if resolve_hermes_bin() is None: print( @@ -562,8 +531,7 @@ def _cmd_run(agent: str, *, gateway_args: list[str]) -> int: if (ec := _require_openai_api_key()) is not None: return ec - pack = load_hermes_pack() - _apply_agent_env(pack) + pack = load_and_activate_pack("hermes") ec = _ensure_runtime(pack) if ec: diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py index 6bea8e8..5e856cd 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py @@ -623,4 +623,8 @@ def doctor_hermes( def load_hermes_pack() -> IntegrationPack: + """Parse Hermes agent.json only — no env side effects. + + CLI commands use ``integration_pack.load_and_activate_pack("hermes")`` instead. + """ return load_integration_pack(agent_config_path("hermes")) diff --git a/intentframe-integrations-cli/src/intentframe_integrations/integration_pack.py b/intentframe-integrations-cli/src/intentframe_integrations/integration_pack.py index 4da8007..3801660 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/integration_pack.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/integration_pack.py @@ -1,8 +1,15 @@ -"""Integration pack metadata (agent profile + optional adapter sidecar).""" +"""Integration pack metadata (agent profile + optional adapter sidecar). + +Pack activation (``load_and_activate_pack*``) is the single entrypoint for CLI +commands that need agent env + Hermes runtime artifacts before backend boot or +in-process policy validation. Precedence: explicit ``os.environ`` → ``agent.json`` +``env`` (``setdefault``) → seeded files under ``~/.intentframe/integrations/``. +""" from __future__ import annotations import json +import os from dataclasses import dataclass from pathlib import Path @@ -70,7 +77,58 @@ def _parse_adapter(raw: object, *, base_dir: Path) -> AdapterSpec | None: def load_integration_pack(path: Path) -> IntegrationPack: + """Parse agent.json only — no env side effects. Prefer ``load_and_activate_pack*``.""" agent = load_agent_pack(path) raw = json.loads(path.read_text(encoding="utf-8")) adapter = _parse_adapter(raw.get("adapter"), base_dir=path.parent) return IntegrationPack(agent=agent, adapter=adapter) + + +def apply_agent_env(pack: IntegrationPack) -> None: + """Apply agent.json env defaults; explicit os.environ values always win (setdefault).""" + os.environ.setdefault("INTENTFRAME_USER_ID", pack.agent.user_id) + os.environ.setdefault("INTENTFRAME_AGENT_ID", pack.agent.agent_id) + for key, value in pack.agent.env.items(): + os.environ.setdefault(key, os.path.expanduser(value)) + + +def seed_hermes_runtime_governance(pack: IntegrationPack) -> None: + """Seed Hermes runtime governance artifacts before backend boot or validation. + + Ensures tools.yaml and generic_actions.manifest exist at their runtime paths + so IF_DYNAMIC_BUNDLE_MANIFEST and HERMES_GOVERNANCE_YAML are never set to + missing files. Safe to call before ``integrate hermes`` has run; if the + committed templates are missing the error surfaces at integrate time. + """ + from intentframe_integrations.hermes_governance_contract import ( + HERMES_AGENT_ID, + ensure_runtime_actions_manifest, + ensure_runtime_governance_yaml, + ) + + if pack.agent.agent_id != HERMES_AGENT_ID: + return + try: + ensure_runtime_governance_yaml(HERMES_AGENT_ID) + ensure_runtime_actions_manifest(HERMES_AGENT_ID) + except FileNotFoundError: + pass + + +def load_and_activate_pack_from_path(path: Path) -> IntegrationPack: + """Load pack, apply agent env defaults, and seed Hermes runtime artifacts. + + Call before backend start or ``validate_policy_with_bundles`` so + ``IF_DYNAMIC_BUNDLE_MANIFEST`` (generic ``HERMES_*`` actions) matches boot. + """ + pack = load_integration_pack(path) + apply_agent_env(pack) + seed_hermes_runtime_governance(pack) + return pack + + +def load_and_activate_pack(agent: str) -> IntegrationPack: + """Load named agent pack and activate its runtime environment (see ``load_and_activate_pack_from_path``).""" + from intentframe_integrations.paths import agent_config_path + + return load_and_activate_pack_from_path(agent_config_path(agent)) diff --git a/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py b/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py index 6feb439..fdbafe2 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/policy_manage.py @@ -2,6 +2,10 @@ Policy changes apply immediately via policy-registry UDS — no gateway or adapter restart. Does not modify governance/tools.yaml or generic_actions.manifest. + +All public entrypoints call ``load_and_activate_pack`` first so local bundle +validation sees the same ``IF_DYNAMIC_BUNDLE_MANIFEST`` env the backend used at +boot (required for generic action IDs such as ``HERMES_CRONJOB``). """ from __future__ import annotations @@ -11,8 +15,7 @@ import yaml -from intentframe_integrations.integration_pack import IntegrationPack, load_integration_pack -from intentframe_integrations.paths import agent_config_path +from intentframe_integrations.integration_pack import IntegrationPack, load_and_activate_pack from intentframe_integrations.policy_contract import ( ensure_runtime_policy_yaml, install_policy_from_path, @@ -38,10 +41,6 @@ class PolicyShowReport: registry_message: str -def _load_pack(agent: str) -> IntegrationPack: - return load_integration_pack(agent_config_path(agent)) - - def _resolve_policy_path(yaml_path: Path) -> Path: path = yaml_path.expanduser().resolve() if not path.is_file(): @@ -68,7 +67,11 @@ def _validate_policy_agent_id(yaml_path: Path, expected_agent_id: str) -> None: def validate_policy_file(pack: IntegrationPack, yaml_path: Path) -> None: - """Validate policy yaml structure and bundle semantics without registry writes.""" + """Validate policy yaml structure and bundle semantics without registry writes. + + Rebuilds bundle registry from the current process env — caller must have + activated the pack (``load_and_activate_pack``) so generic actions register. + """ path = _resolve_policy_path(yaml_path) _validate_policy_agent_id(path, pack.agent.agent_id) @@ -137,7 +140,7 @@ def _registry_status(pack: IntegrationPack) -> tuple[bool, int | None, str]: def policy_show(agent: str) -> PolicyShowReport: - pack = _load_pack(agent) + pack = load_and_activate_pack(agent) runtime = policy_yaml_runtime_path(pack.agent.agent_id) try: shipped = shipped_policy_template_path(pack) @@ -170,7 +173,7 @@ def format_policy_show(report: PolicyShowReport) -> str: def policy_reload(agent: str) -> Path: """Re-read runtime policy file and upsert into policy-registry.""" - pack = _load_pack(agent) + pack = load_and_activate_pack(agent) try: path = ensure_runtime_policy_yaml(pack) except FileNotFoundError as exc: @@ -181,7 +184,7 @@ def policy_reload(agent: str) -> Path: def policy_set(agent: str, src: Path) -> Path: """Validate external policy, copy to runtime, and load into registry.""" - pack = _load_pack(agent) + pack = load_and_activate_pack(agent) source = src.expanduser().resolve() validate_policy_file(pack, source) path = install_policy_from_path(pack, source) @@ -191,7 +194,7 @@ def policy_set(agent: str, src: Path) -> Path: def policy_reset(agent: str) -> Path: """Restore shipped default to runtime location and load into registry.""" - pack = _load_pack(agent) + pack = load_and_activate_pack(agent) try: path = reset_runtime_policy_yaml(pack) except FileNotFoundError as exc: diff --git a/tests/intentframe_integrations/test_hermes_install.py b/tests/intentframe_integrations/test_hermes_install.py index 241f413..4c38c5e 100644 --- a/tests/intentframe_integrations/test_hermes_install.py +++ b/tests/intentframe_integrations/test_hermes_install.py @@ -286,9 +286,9 @@ def test_format_env_exports_prefers_environ_override(self) -> None: def _apply_env(pack: object) -> None: - from intentframe_integrations.cli import _apply_agent_env + from intentframe_integrations.integration_pack import apply_agent_env - _apply_agent_env(pack) # type: ignore[arg-type] + apply_agent_env(pack) # type: ignore[arg-type] def main() -> int: diff --git a/tests/intentframe_integrations/test_integration_pack.py b/tests/intentframe_integrations/test_integration_pack.py index 4d9f40f..24f623f 100644 --- a/tests/intentframe_integrations/test_integration_pack.py +++ b/tests/intentframe_integrations/test_integration_pack.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import os import sys import tempfile import unittest @@ -14,7 +15,13 @@ if str(CLI_SRC) not in sys.path: sys.path.insert(0, str(CLI_SRC)) -from intentframe_integrations.integration_pack import load_integration_pack # noqa: E402 +from intentframe_integrations.hermes_governance_contract import ( # noqa: E402 + actions_manifest_runtime_path, +) +from intentframe_integrations.integration_pack import ( # noqa: E402 + load_and_activate_pack, + load_integration_pack, +) class TestIntegrationPack(unittest.TestCase): @@ -53,6 +60,57 @@ def test_custom_adapter_fields(self) -> None: self.assertEqual(pack.adapter.module, "custom_adapter.main") +class TestLoadAndActivatePack(unittest.TestCase): + """Regression: pack activation env parity (setdefault + manifest seeding).""" + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.home = Path(self.temp_dir.name) / "home" + self.home.mkdir() + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_load_and_activate_applies_agent_json_env_with_setdefault(self) -> None: + override = "/tmp/custom-manifest.manifest" + previous_home = os.environ.get("HOME") + previous_manifest = os.environ.get("IF_DYNAMIC_BUNDLE_MANIFEST") + try: + os.environ["HOME"] = str(self.home) + os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = override + load_and_activate_pack("hermes") + self.assertEqual(os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"], override) + finally: + if previous_manifest is None: + os.environ.pop("IF_DYNAMIC_BUNDLE_MANIFEST", None) + else: + os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = previous_manifest + if previous_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = previous_home + + def test_load_and_activate_seeds_manifest_when_missing(self) -> None: + previous_home = os.environ.get("HOME") + previous_manifest = os.environ.get("IF_DYNAMIC_BUNDLE_MANIFEST") + try: + os.environ["HOME"] = str(self.home) + os.environ.pop("IF_DYNAMIC_BUNDLE_MANIFEST", None) + load_and_activate_pack("hermes") + expected = actions_manifest_runtime_path("hermes") + self.assertTrue(expected.is_file()) + self.assertIn("HERMES_CRONJOB", expected.read_text(encoding="utf-8")) + self.assertEqual(os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"], str(expected)) + finally: + if previous_manifest is None: + os.environ.pop("IF_DYNAMIC_BUNDLE_MANIFEST", None) + else: + os.environ["IF_DYNAMIC_BUNDLE_MANIFEST"] = previous_manifest + if previous_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = previous_home + + def main() -> int: loader = unittest.TestLoader() suite = loader.loadTestsFromModule(sys.modules[__name__]) diff --git a/tests/intentframe_integrations/test_policy_manage.py b/tests/intentframe_integrations/test_policy_manage.py index 2b47a86..e847871 100644 --- a/tests/intentframe_integrations/test_policy_manage.py +++ b/tests/intentframe_integrations/test_policy_manage.py @@ -16,7 +16,10 @@ if str(CLI_SRC) not in sys.path: sys.path.insert(0, str(CLI_SRC)) -from intentframe_integrations.integration_pack import load_integration_pack # noqa: E402 +from intentframe_integrations.integration_pack import ( # noqa: E402 + load_and_activate_pack, + load_integration_pack, +) from intentframe_integrations.policy_contract import ( # noqa: E402 ensure_runtime_policy_yaml, policy_yaml_runtime_path, @@ -33,6 +36,7 @@ class patch_home: + """Temp HOME + explicit manifest/governance env (full bundle registry for policy tests).""" def __init__(self, home: Path) -> None: self.home = home self._previous: str | None = None @@ -83,6 +87,38 @@ def _reset_bundle_loader_state() -> None: registry._ROUTED_DOMAIN_IDS = frozenset() +class agent_json_env_only: + """Simulate harnesses that set HOME only — no explicit manifest/governance exports. + + Mirrors ``test-hermes-integration.sh`` (``HERMES_GOVERNANCE_YAML`` only) and + asserts ``load_and_activate_pack`` fills ``IF_DYNAMIC_BUNDLE_MANIFEST`` from + ``agent.json`` so generic actions (e.g. ``HERMES_CRONJOB``) validate. + """ + + _ENV_KEYS = ("HOME", "IF_DYNAMIC_BUNDLE_MANIFEST", "HERMES_GOVERNANCE_YAML") + + def __init__(self, home: Path) -> None: + self.home = home + self._saved: dict[str, str | None] = {} + + def __enter__(self) -> None: + for key in self._ENV_KEYS: + self._saved[key] = os.environ.get(key) + os.environ["HOME"] = str(self.home) + os.environ.pop("IF_DYNAMIC_BUNDLE_MANIFEST", None) + os.environ.pop("HERMES_GOVERNANCE_YAML", None) + patch_home._reset_bundle_loader_state() + return self + + def __exit__(self, *args: object) -> None: + patch_home._reset_bundle_loader_state() + for key, value in self._saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + class TestPolicyManage(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -122,6 +158,54 @@ def test_set_missing_source_preserves_runtime(self, seed_mock: object) -> None: self.assertEqual(runtime.read_text(encoding="utf-8"), original) seed_mock.assert_not_called() + @patch("if_security_backend.runtime.policy.seed_policy") + def test_reload_applies_agent_json_env_without_explicit_manifest(self, seed_mock: object) -> None: + """Policy validation must see agent.json env defaults, not only explicit exports.""" + with agent_json_env_only(self.home): + pack = load_integration_pack(REPO_ROOT / "integrations/hermes/agent.json") + runtime = ensure_runtime_policy_yaml(pack) + path = policy_reload("hermes") + self.assertEqual(path, runtime) + seed_mock.assert_called_once() + manifest = os.environ.get("IF_DYNAMIC_BUNDLE_MANIFEST") + self.assertIsNotNone(manifest) + assert manifest is not None + self.assertTrue(Path(manifest).is_file()) + + def test_validate_policy_registers_hermes_cronjob_without_explicit_env(self) -> None: + """Regression: policy reload failed when HERMES_CRONJOB had no registered bundle.""" + with agent_json_env_only(self.home): + pack = load_and_activate_pack("hermes") + runtime = ensure_runtime_policy_yaml(pack) + validate_policy_file(pack, runtime) + + @patch("if_security_backend.runtime.policy.seed_policy") + def test_set_applies_agent_json_env_without_explicit_manifest(self, seed_mock: object) -> None: + custom = Path(self.temp_dir.name) / "custom.yaml" + custom.write_text( + shipped_policy_template_path(self.pack).read_text(encoding="utf-8"), + encoding="utf-8", + ) + with agent_json_env_only(self.home): + path = policy_set("hermes", custom) + self.assertEqual(path, policy_yaml_runtime_path("hermes")) + seed_mock.assert_called_once() + + @patch("if_security_backend.runtime.policy.seed_policy") + def test_reset_applies_agent_json_env_without_explicit_manifest(self, seed_mock: object) -> None: + with agent_json_env_only(self.home): + pack = load_integration_pack(REPO_ROOT / "integrations/hermes/agent.json") + runtime = ensure_runtime_policy_yaml(pack) + runtime.write_text("user-edit: true\n", encoding="utf-8") + path = policy_reset("hermes") + shipped = shipped_policy_template_path(pack) + self.assertEqual(path.read_text(encoding="utf-8"), shipped.read_text(encoding="utf-8")) + seed_mock.assert_called_once() + + @staticmethod + def _reset_bundle_loader_state() -> None: + patch_home._reset_bundle_loader_state() + @patch("if_security_backend.runtime.policy.seed_policy") def test_reload_calls_seed(self, seed_mock: object) -> None: with patch_home(self.home): @@ -206,6 +290,17 @@ def test_cli_policy_reload(self, seed_mock: object) -> None: self.assertEqual(ec, 0) seed_mock.assert_called_once() + @patch("if_security_backend.runtime.policy.seed_policy") + def test_cli_policy_reload_without_explicit_manifest(self, seed_mock: object) -> None: + from intentframe_integrations.cli import main + + with agent_json_env_only(self.home): + pack = load_integration_pack(REPO_ROOT / "integrations/hermes/agent.json") + ensure_runtime_policy_yaml(pack) + ec = main(["policy", "reload", "hermes"]) + self.assertEqual(ec, 0) + seed_mock.assert_called_once() + @patch("intentframe_integrations.cli.policy_set") def test_cli_policy_set(self, set_mock: object) -> None: from intentframe_integrations.cli import main diff --git a/tests/scripts/test-hermes-integration.sh b/tests/scripts/test-hermes-integration.sh index 24a4a28..604b177 100755 --- a/tests/scripts/test-hermes-integration.sh +++ b/tests/scripts/test-hermes-integration.sh @@ -41,6 +41,8 @@ HERMES_STARTED=1 export IF_AGENT_ADAPTER_SOCKET="${ADAPTER_SOCKET}" test -S "${ADAPTER_SOCKET}" +# Policy reload validates bundles in-process — must see agent.json manifest defaults +# (IF_DYNAMIC_BUNDLE_MANIFEST) without this script exporting them explicitly. echo "==> policy show + reload hermes (live registry smoke)" (cd "$REPO_ROOT" && "$IF_INTEGRATIONS" policy show hermes) (cd "$REPO_ROOT" && "$IF_INTEGRATIONS" policy reload hermes) From 7370e1209a591e4767e5975caa8771fbd25942d1 Mon Sep 17 00:00:00 2001 From: Prince Date: Tue, 23 Jun 2026 19:59:58 +0530 Subject: [PATCH 6/8] update docs --- docs/agent-tool-gating.md | 4 +++- docs/hermes-intentframe-integration-guide.md | 4 +++- integrations/hermes/README.md | 3 ++- integrations/hermes/governance/README.md | 24 +++++++++++++++++-- intentframe-integrations-cli/README.md | 4 +++- .../hermes_governance_contract.py | 8 ++++++- .../hermes_integrate.py | 7 +++++- .../integration_pack.py | 3 ++- 8 files changed, 48 insertions(+), 9 deletions(-) diff --git a/docs/agent-tool-gating.md b/docs/agent-tool-gating.md index c4a2831..06d2bf6 100644 --- a/docs/agent-tool-gating.md +++ b/docs/agent-tool-gating.md @@ -321,7 +321,9 @@ Drift is prevented by a **shared contract**: `governance/tools.yaml` + Dev-maintained `governance/generic_actions.manifest` lists all generic-mapper action IDs (full catalog superset); golden test [`test_actions_manifest.py`](../tests/intentframe_integrations/test_actions_manifest.py) -enforces parity. There is no user-facing `sync` command — runtime CLI never +enforces parity (replaces planned `sync hermes` — see +[`governance/README.md`](../integrations/hermes/governance/README.md#derived-artifacts-sync-replacement)). +There is no user-facing `sync` command — runtime CLI never rewrites repo templates. **Governance and policy are independent gates.** Disabling a tool stops Hermes diff --git a/docs/hermes-intentframe-integration-guide.md b/docs/hermes-intentframe-integration-guide.md index 176d79b..3bfda84 100644 --- a/docs/hermes-intentframe-integration-guide.md +++ b/docs/hermes-intentframe-integration-guide.md @@ -488,7 +488,9 @@ on first BLOCK. ### Step 3 — Dev artifacts (hand-edited, golden-tested) -Update shipped repo files when adding a new action ID: +Replaces a planned `sync hermes` CLI: edit shipped repo files when adding a new action ID, +then run the golden test. Full contract: +[`governance/README.md` — Derived artifacts (sync replacement)](../integrations/hermes/governance/README.md#derived-artifacts-sync-replacement). | File | What to add | |------|-------------| diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index 4059f97..0b8eef1 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -213,7 +213,8 @@ See [`governance/README.md`](governance/README.md) for dev vs user ownership. 5. Add live adapter + plugin semantic smoke probe (`action: list` or other low-risk args). 6. No plugin code changes — `map_generic` handles all generic tools. No gateway LLM E2E probe. -No user-facing sync step. Users toggle governance via CLI; policy via policy CLI. +No user-facing sync step — see [`governance/README.md`](governance/README.md#derived-artifacts-sync-replacement). +Users toggle governance via CLI; policy via policy CLI. If the tool can emit **multiple IntentFrames per call** (like V4A `patch`), follow the `map_patch` pattern in `mapper.py`: scoped per-op `content` for writes (not diff --git a/integrations/hermes/governance/README.md b/integrations/hermes/governance/README.md index e8ef86c..e196c1f 100644 --- a/integrations/hermes/governance/README.md +++ b/integrations/hermes/governance/README.md @@ -20,6 +20,26 @@ Governance and policy are **independent**: disabling a tool stops Hermes from sending intents; policy rows for that action ID can remain without harm. +## Derived artifacts (sync replacement) + +There is **no** `intentframe-integrations sync hermes` command. Instead: + +- **Source of truth:** repo `tools.yaml` (catalog, mappers, action IDs). +- **Dev updates shipped files by hand** when adding a tool (see workflow below). +- **CI guard:** `tests/intentframe_integrations/test_actions_manifest.py` fails if + `generic_actions.manifest`, `agent.json`, or `executor.yaml` drift from the catalog. +- **Runtime:** `integrate hermes` / `start hermes` only **copy** committed templates to + `~/.intentframe/...` on first use — they never regenerate derived lists from yaml. + +This replaces a codegen/sync CLI: manual edits preserve policy comments and formatting; +the golden test catches drift instead of auto-rewriting repo files. + +Verify after edits: + +```bash +uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_actions_manifest.py +``` + ## Dev workflow (adding a generic tool) 1. Add entry to `tools.yaml` with `mapper: generic` and a `HERMES_*` action ID. @@ -31,5 +51,5 @@ intents; policy rows for that action ID can remain without harm. and `tests/hermes_plugin/test_bridge_gate_live.py` (no gateway LLM E2E). 5. Set `IF_DYNAMIC_BUNDLE_MANIFEST` in `agent.json` env (already points at runtime path). -There is **no** user-facing `sync` command. Runtime automation never rewrites repo -templates or user governance/policy files. +Runtime automation never rewrites repo templates or user governance/policy files. +See [Derived artifacts (sync replacement)](#derived-artifacts-sync-replacement) above. diff --git a/intentframe-integrations-cli/README.md b/intentframe-integrations-cli/README.md index 053532d..233f062 100644 --- a/intentframe-integrations-cli/README.md +++ b/intentframe-integrations-cli/README.md @@ -58,7 +58,9 @@ process environment — it must see the same manifest path the backend used at b - `governance/generic_actions.manifest` — static dev-shipped superset of generic action IDs - `policy.yaml` — user edits via policy CLI -There is no `sync` command. Repo templates are dev-maintained only. +There is no `sync` command — dev-maintained repo templates plus golden test +`tests/intentframe_integrations/test_actions_manifest.py`. See +[`integrations/hermes/governance/README.md`](../integrations/hermes/governance/README.md#derived-artifacts-sync-replacement). Run from repo root via `bin/intentframe-integrations` or: diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py index e543a36..b18b704 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_governance_contract.py @@ -1,6 +1,9 @@ """Default governance template paths and runtime user config materialization. Repo templates (dev-maintained): governance/tools.yaml, governance/generic_actions.manifest. +Derived lists (agent.json, executor.yaml, policy rows) are hand-edited by devs when +tools.yaml changes; drift is caught by tests/intentframe_integrations/test_actions_manifest.py +(no sync CLI — see integrations/hermes/governance/README.md). Runtime copies (~/.intentframe/...): seeded on first integrate; never overwritten unless the user runs --reset-governance or deletes the file. User toggles tool governance via CLI; that only edits runtime tools.yaml enabled flags. @@ -71,7 +74,10 @@ def actions_manifest_runtime_path(agent_id: str = HERMES_AGENT_ID) -> Path: def ensure_runtime_actions_manifest(agent_id: str = HERMES_AGENT_ID) -> Path: - """Return runtime manifest, copying the committed template on first use only.""" + """Return runtime manifest, copying the committed template on first use only. + + Never regenerates from tools.yaml — dev updates the repo template and golden test. + """ runtime = actions_manifest_runtime_path(agent_id) if runtime.is_file(): return runtime diff --git a/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py b/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py index 5e856cd..0978a2a 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/hermes_integrate.py @@ -1,4 +1,9 @@ -"""Install and verify Hermes ↔ IntentFrame integration (plugin + adapter).""" +"""Install and verify Hermes ↔ IntentFrame integration (plugin + adapter). + +``integrate hermes`` copies committed governance templates to runtime (first use only); +it never regenerates manifest or policy from ``tools.yaml``. Dev artifact parity is +enforced by ``tests/intentframe_integrations/test_actions_manifest.py``. +""" from __future__ import annotations diff --git a/intentframe-integrations-cli/src/intentframe_integrations/integration_pack.py b/intentframe-integrations-cli/src/intentframe_integrations/integration_pack.py index 3801660..7f2644c 100644 --- a/intentframe-integrations-cli/src/intentframe_integrations/integration_pack.py +++ b/intentframe-integrations-cli/src/intentframe_integrations/integration_pack.py @@ -97,7 +97,8 @@ def seed_hermes_runtime_governance(pack: IntegrationPack) -> None: Ensures tools.yaml and generic_actions.manifest exist at their runtime paths so IF_DYNAMIC_BUNDLE_MANIFEST and HERMES_GOVERNANCE_YAML are never set to - missing files. Safe to call before ``integrate hermes`` has run; if the + missing files. Copies committed repo templates only — never derives from yaml. + Safe to call before ``integrate hermes`` has run; if the committed templates are missing the error surfaces at integrate time. """ from intentframe_integrations.hermes_governance_contract import ( From 7bed81c6c17bf39ff333f973e7b7a0218dc369b1 Mon Sep 17 00:00:00 2001 From: Prince Date: Tue, 23 Jun 2026 20:12:08 +0530 Subject: [PATCH 7/8] update --- tests/hermes_gateway/README.md | 6 +++--- .../probe_hermes_tool_schemas.py | 16 +++++++++++++--- .../test_gateway_toolsets_live.py | 4 ++-- .../test_governed_tool_coverage.py | 18 ++++++++++++++++++ tests/scripts/test-hermes-gateway-toolsets.sh | 4 ++-- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/tests/hermes_gateway/README.md b/tests/hermes_gateway/README.md index 72f31ed..bb2ac0c 100644 --- a/tests/hermes_gateway/README.md +++ b/tests/hermes_gateway/README.md @@ -251,8 +251,8 @@ noise; still sends the full `tools=` list upstream). | Surface | What it proves | |---------|----------------| | `GET /v1/toolsets` | Hermes **config** tool names for api_server (e.g. ~31) | -| `probe_hermes_tool_schemas.py` | **Registry** schemas after plugin load (e.g. ~16 defs); governed `reason` + gate | -| Request dump + round-trip assert | **OpenAI `chat.completions` payload** (e.g. ~17 tools in `tools=`) | +| `probe_hermes_tool_schemas.py` | **Registry** schemas for native-mapper governed tools (`reason` + gate); generic tools skipped | +| Request dump + round-trip assert | **OpenAI `chat.completions` payload** — native governed tools with required `reason` in `tools=` | The registry count and toolsets count differ by design — not every listed toolset name becomes a registry definition on the LLM path. See @@ -263,7 +263,7 @@ name becomes a registry definition on the LLM path. See | Helper | Checks | |--------|--------| | `assert_gateway_openai_roundtrip()` | Gateway `status: completed` and `usage.total_tokens > 0` | -| `assert_provider_tools_surface()` | Governed tools in dump `request.body.tools` with required `reason` | +| `assert_provider_tools_surface()` | Native-mapper governed tools in dump `request.body.tools` with required `reason` | The request dump is written at **preflight** (before the HTTP call to OpenAI). Token usage from the gateway response proves the call **completed** — the dump alone only diff --git a/tests/hermes_gateway/probe_hermes_tool_schemas.py b/tests/hermes_gateway/probe_hermes_tool_schemas.py index 987f60e..c808028 100644 --- a/tests/hermes_gateway/probe_hermes_tool_schemas.py +++ b/tests/hermes_gateway/probe_hermes_tool_schemas.py @@ -2,8 +2,11 @@ """Probe Hermes registry schemas after intentframe-gate (reason injection). Run inside the managed Hermes venv with HERMES_HOME set. Used by gateway -toolsets live test to verify governed tools still use Hermes names but require -``reason`` in their JSON schema. +toolsets live test to verify **native-mapper** governed tools use Hermes names +and require ``reason`` in their JSON schema. + +Generic-mapper tools (e.g. ``cronjob``) are governed at runtime but excluded +here — same two-tier contract as gateway E2E (live semantic smoke only). """ from __future__ import annotations @@ -14,11 +17,15 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] +TESTS_DIR = REPO_ROOT / "tests" PLUGIN_SRC = REPO_ROOT / "integrations" / "hermes" / "plugin" / "intentframe-gate" +if str(TESTS_DIR) not in sys.path: + sys.path.insert(0, str(TESTS_DIR)) if str(PLUGIN_SRC) not in sys.path: sys.path.insert(0, str(PLUGIN_SRC)) from governance_loader import governed_tool_names # type: ignore # noqa: E402 +from hermes_governance_fixtures import gateway_e2e_probe_tool_names # noqa: E402 def main() -> int: @@ -46,6 +53,8 @@ def main() -> int: definitions = get_tool_definitions(enabled_toolsets=enabled_toolsets, quiet_mode=True) governed = governed_tool_names() + probe_targets = gateway_e2e_probe_tool_names() & governed + skipped_generic = sorted(governed - probe_targets) by_name: dict[str, dict] = {} for item in definitions: fn = item.get("function") @@ -60,12 +69,13 @@ def main() -> int: "enabled_toolset_count": len(enabled_toolsets), "definition_count": len(definitions), "governed_tools": {}, + "skipped_generic_governed": skipped_generic, "distractors": {}, } errors: list[str] = [] - for tool_name in sorted(governed): + for tool_name in sorted(probe_targets): if tool_name not in by_name: errors.append(f"governed tool {tool_name!r} missing from get_tool_definitions()") continue diff --git a/tests/hermes_gateway/test_gateway_toolsets_live.py b/tests/hermes_gateway/test_gateway_toolsets_live.py index 396fb5e..0526f93 100644 --- a/tests/hermes_gateway/test_gateway_toolsets_live.py +++ b/tests/hermes_gateway/test_gateway_toolsets_live.py @@ -47,7 +47,7 @@ _TESTS_DIR = HERE.parent if str(_TESTS_DIR) not in sys.path: sys.path.insert(0, str(_TESTS_DIR)) -from hermes_governance_fixtures import template_governed_tool_names # noqa: E402 +from hermes_governance_fixtures import gateway_e2e_probe_tool_names # noqa: E402 API_HOST = "127.0.0.1" INSTALL_TIMEOUT = 600.0 @@ -156,7 +156,7 @@ def main() -> int: instructions="Automated integration test. Do not use tools.", ) assert_gateway_openai_roundtrip(responses_body) - governed = template_governed_tool_names() + governed = gateway_e2e_probe_tool_names() dump_path, provider_body = load_newest_request_dump( env.hermes_home, existing=existing_dumps, diff --git a/tests/hermes_gateway/test_governed_tool_coverage.py b/tests/hermes_gateway/test_governed_tool_coverage.py index 46844b0..e031b08 100644 --- a/tests/hermes_gateway/test_governed_tool_coverage.py +++ b/tests/hermes_gateway/test_governed_tool_coverage.py @@ -67,6 +67,24 @@ def test_live_plugin_gate_covers_all_catalog_tools(self) -> None: for fixture in LIVE_PLUGIN_EXTRA_FIXTURES: self.assertIn(fixture, source) + def test_toolsets_live_uses_native_gateway_probe_tier_only(self) -> None: + native = gateway_e2e_probe_tool_names() + generic = live_semantic_probe_tool_names() + probe = (GATEWAY_DIR / "probe_hermes_tool_schemas.py").read_text(encoding="utf-8") + toolsets_live = (GATEWAY_DIR / "test_gateway_toolsets_live.py").read_text( + encoding="utf-8" + ) + self.assertIn("gateway_e2e_probe_tool_names", probe) + self.assertIn("gateway_e2e_probe_tool_names", toolsets_live) + self.assertNotIn("template_governed_tool_names", toolsets_live) + for tool in generic: + self.assertNotIn( + f'"{tool}"', + probe, + msg=f"schema probe must not hardcode generic tool {tool!r}", + ) + self.assertFalse(native & generic) + def main() -> int: loader = unittest.TestLoader() diff --git a/tests/scripts/test-hermes-gateway-toolsets.sh b/tests/scripts/test-hermes-gateway-toolsets.sh index 761b8b5..26270a4 100755 --- a/tests/scripts/test-hermes-gateway-toolsets.sh +++ b/tests/scripts/test-hermes-gateway-toolsets.sh @@ -3,9 +3,9 @@ # # After integrate hermes: # 1. GET /v1/toolsets (config surface) -# 2. probe_hermes_tool_schemas.py (registry + reason injection) +# 2. probe_hermes_tool_schemas.py (native governed registry + reason injection) # 3. POST /v1/responses + HERMES_DUMP_REQUESTS=1 (real chat.completions round-trip) -# 4. Assert token usage > 0 and governed tools have required reason in tools= +# 4. Assert token usage > 0 and native governed tools have required reason in tools= # # RUN_HERMES_GATEWAY_TOOLSETS=1 ./tests/scripts/test-hermes-gateway-toolsets.sh # From 668ddc70370c3daae16c6ffe72329fc73f6d9ebd Mon Sep 17 00:00:00 2001 From: Prince Date: Tue, 23 Jun 2026 20:31:06 +0530 Subject: [PATCH 8/8] Update pr.yml --- .github/workflows/pr.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d3a1488..2c24610 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -47,7 +47,9 @@ jobs: uv run --directory integrations/hermes/shared python tests/test_governance_template.py - name: Backend validate adapter unit tests - run: uv run --package if-integration-backend python if-integration-backend/tests/test_validate_adapter.py + run: | + uv run --package if-integration-backend python if-integration-backend/tests/test_validate_adapter.py + uv run --package if-integration-backend python if-integration-backend/tests/test_dynamic_bundle.py - name: E2e bridge_test agent sync run: uv run --package if-integration-backend python tests/agents/test_bridge_test_agent_sync.py @@ -72,6 +74,7 @@ jobs: uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_governance_runtime_contract.py uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_make_catalog_yaml.py uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_scoped_governance_yaml.py + uv run --package intentframe-integrations-cli python tests/intentframe_integrations/test_actions_manifest.py - name: Hermes gateway unit tests run: | @@ -81,6 +84,7 @@ jobs: uv run --package intentframe-integrations-cli python tests/hermes_gateway/test_api_client.py uv run --package intentframe-integrations-cli python tests/hermes_gateway/test_governed_tool_coverage.py uv run --package intentframe-integrations-cli python tests/hermes_gateway/test_toolsets_contract.py + uv run --package intentframe-integrations-cli python tests/hermes_gateway/test_provider_request_contract.py typescript: name: TypeScript build