From af2972a46020f0a54d5dd0de4e5c739a788971f8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 18 Jun 2026 22:48:43 +0200 Subject: [PATCH] Add shared credential-routing template and propagation tooling Introduce credential-routing.template for Codex, Claude Code, Grok, and llm-connect agents. Wire into agents-codex.template and claude-md.template. Add propagate_credential_routing.py for surgical rollout without clobbering repo-specific AGENTS.md extensions (REPO-AGENTS-EXTENSIONS marker). --- .claude/rules/credential-routing.md | 50 +++++++ AGENTS.md | 52 +++++++ CLAUDE.md | 1 + scripts/project_rules/agents-codex.template | 8 ++ scripts/project_rules/claude-md.template | 1 + .../project_rules/credential-routing.template | 50 +++++++ scripts/propagate_credential_routing.py | 136 ++++++++++++++++++ scripts/update_agent_instruction_files.py | 57 +++++++- 8 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 .claude/rules/credential-routing.md create mode 100644 scripts/project_rules/credential-routing.template create mode 100644 scripts/propagate_credential_routing.py diff --git a/.claude/rules/credential-routing.md b/.claude/rules/credential-routing.md new file mode 100644 index 0000000..cc3cbbd --- /dev/null +++ b/.claude/rules/credential-routing.md @@ -0,0 +1,50 @@ +# Credential and access routing + +**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect** +for inference. Run this check **before** requesting secrets, API keys, SSH access, +login tokens, or database passwords — in any repo, not only `ops-warden`. + +ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every +other credential need belongs to another subsystem. **Do not** message +`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key. + +### Lookup (do this first) + +```bash +warden route find "" --json +warden route show --json +``` + +Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`). + +| Agent runtime | How to orient | +| --- | --- | +| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=state-hub` is for coordination, not secret vending | +| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership | +| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` | + +### Quick routing table + +| I need… | Owner | ops-warden executes? | +| --- | --- | --- | +| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` | +| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only | +| Login / OIDC / MFA | key-cape / Keycloak | No — route only | +| Authorization decision | flex-auth | No — route only | +| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` | +| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only | + +### Anti-patterns (do not do these) + +- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc. +- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist +- Pasting secrets into Git, State Hub, workplans, logs, or chat + +### Other capabilities (reuse-surface) + +Non-credential capabilities are usually discovered through **reuse-surface** federation +(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in +every repo's agent instructions because it is high-frequency, high-risk, and easy to +get wrong. + +**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml` \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a1c432b..8ebc54d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,58 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ --- +## Credential and access routing + +**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect** +for inference. Run this check **before** requesting secrets, API keys, SSH access, +login tokens, or database passwords — in any repo, not only `ops-warden`. + +ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every +other credential need belongs to another subsystem. **Do not** message +`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key. + +### Lookup (do this first) + +```bash +warden route find "" --json +warden route show --json +``` + +Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`). + +| Agent runtime | How to orient | +| --- | --- | +| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=state-hub` is for coordination, not secret vending | +| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership | +| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` | + +### Quick routing table + +| I need… | Owner | ops-warden executes? | +| --- | --- | --- | +| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` | +| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only | +| Login / OIDC / MFA | key-cape / Keycloak | No — route only | +| Authorization decision | flex-auth | No — route only | +| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` | +| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only | + +### Anti-patterns (do not do these) + +- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc. +- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist +- Pasting secrets into Git, State Hub, workplans, logs, or chat + +### Other capabilities (reuse-surface) + +Non-credential capabilities are usually discovered through **reuse-surface** federation +(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in +every repo's agent instructions because it is high-frequency, high-risk, and easy to +get wrong. + +**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml` +--- + ## Workplan Convention (ADR-001) Work items originate as files in this repo — not in the hub. The hub is a diff --git a/CLAUDE.md b/CLAUDE.md index 5c72245..ea58cad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,4 +8,5 @@ @.claude/rules/stack-and-commands.md @.claude/rules/architecture.md @.claude/rules/repo-boundary.md +@.claude/rules/credential-routing.md @.claude/rules/agents.md diff --git a/scripts/project_rules/agents-codex.template b/scripts/project_rules/agents-codex.template index 164b401..633b1d3 100644 --- a/scripts/project_rules/agents-codex.template +++ b/scripts/project_rules/agents-codex.template @@ -101,6 +101,14 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ --- +{CREDENTIAL_ROUTING} + + + + +--- + ## Workplan Convention (ADR-001) Work items originate as files in this repo — not in the hub. The hub is a diff --git a/scripts/project_rules/claude-md.template b/scripts/project_rules/claude-md.template index bb274fb..77f2ea8 100644 --- a/scripts/project_rules/claude-md.template +++ b/scripts/project_rules/claude-md.template @@ -8,4 +8,5 @@ @.claude/rules/stack-and-commands.md @.claude/rules/architecture.md @.claude/rules/repo-boundary.md +@.claude/rules/credential-routing.md @.claude/rules/agents.md diff --git a/scripts/project_rules/credential-routing.template b/scripts/project_rules/credential-routing.template new file mode 100644 index 0000000..68ec02c --- /dev/null +++ b/scripts/project_rules/credential-routing.template @@ -0,0 +1,50 @@ +## Credential and access routing + +**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect** +for inference. Run this check **before** requesting secrets, API keys, SSH access, +login tokens, or database passwords — in any repo, not only `ops-warden`. + +ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every +other credential need belongs to another subsystem. **Do not** message +`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key. + +### Lookup (do this first) + +```bash +warden route find "" --json +warden route show --json +``` + +Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`). + +| Agent runtime | How to orient | +| --- | --- | +| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent={REPO_SLUG}` is for coordination, not secret vending | +| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership | +| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` | + +### Quick routing table + +| I need… | Owner | ops-warden executes? | +| --- | --- | --- | +| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` | +| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only | +| Login / OIDC / MFA | key-cape / Keycloak | No — route only | +| Authorization decision | flex-auth | No — route only | +| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` | +| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only | + +### Anti-patterns (do not do these) + +- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc. +- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist +- Pasting secrets into Git, State Hub, workplans, logs, or chat + +### Other capabilities (reuse-surface) + +Non-credential capabilities are usually discovered through **reuse-surface** federation +(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in +every repo's agent instructions because it is high-frequency, high-risk, and easy to +get wrong. + +**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml` \ No newline at end of file diff --git a/scripts/propagate_credential_routing.py b/scripts/propagate_credential_routing.py new file mode 100644 index 0000000..4e78451 --- /dev/null +++ b/scripts/propagate_credential_routing.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Surgically propagate credential-routing instructions to local repos. + +Writes `.claude/rules/credential-routing.md`, inserts the section into AGENTS.md +(if missing), and adds the CLAUDE.md @include — without overwriting other +custom rules or repo-specific AGENTS.md extensions below REPO-AGENTS-EXTENSIONS. + +Usage: + python3 scripts/propagate_credential_routing.py # all /home/worsch repos with AGENTS.md + python3 scripts/propagate_credential_routing.py activity-core issue-core ops-warden +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TEMPLATE_DIR = ROOT / "scripts" / "project_rules" +WORKSPACE = Path("/home/worsch") +EXTENSION_MARKER = "" +SECTION_HEADING = "## Credential and access routing" +CLAUDE_INCLUDE = "@.claude/rules/credential-routing.md" + + +def render(template: str, values: dict[str, str]) -> str: + for key, value in values.items(): + template = template.replace("{" + key + "}", value) + return template + + +def repo_values(repo_path: Path) -> dict[str, str]: + slug = repo_path.name + prefix = slug.split("-", 1)[0].upper() + return { + "PROJECT_NAME": slug, + "PROJECT_DESCRIPTION": slug, + "DOMAIN": "", + "TOPIC_ID": "(none)", + "REPO_SLUG": slug, + "WP_PREFIX": prefix, + } + + +def insert_credential_section(agents_text: str, cred_section: str) -> str: + """Insert general credential routing before repo-specific or workplan sections.""" + if SECTION_HEADING in agents_text: + return agents_text + block = "\n---\n\n" + cred_section.strip() + for anchor in ( + "\n---\n\n## Issue-core emission", + "\n---\n\n## REST ingestion API key", + "\n---\n\n## Workplan Convention", + "\n## Workplan Convention", + ): + if anchor in agents_text: + return agents_text.replace(anchor, block + anchor, 1) + return agents_text.rstrip() + block + "\n" + + +def patch_claude_md(claude_path: Path) -> bool: + if not claude_path.exists(): + return False + text = claude_path.read_text(encoding="utf-8") + if CLAUDE_INCLUDE in text: + return False + if "@.claude/rules/agents.md" in text: + text = text.replace( + "@.claude/rules/agents.md", + f"{CLAUDE_INCLUDE}\n@.claude/rules/agents.md", + ) + else: + text = text.rstrip() + "\n" + CLAUDE_INCLUDE + "\n" + claude_path.write_text(text, encoding="utf-8") + return True + + +def discover_repos(slugs: list[str] | None) -> list[Path]: + if slugs: + return [WORKSPACE / slug for slug in slugs if (WORKSPACE / slug).is_dir()] + return sorted( + p.parent + for p in WORKSPACE.glob("*/AGENTS.md") + if p.parent.is_dir() and not p.parent.name.startswith(".") + ) + + +def main(argv: list[str]) -> int: + slugs = argv[1:] if len(argv) > 1 else None + cred_template = (TEMPLATE_DIR / "credential-routing.template").read_text(encoding="utf-8") + agents_template = (TEMPLATE_DIR / "agents-codex.template").read_text(encoding="utf-8") + + cred_rule_template = ( + "# Credential and access routing\n\n" + + cred_template.lstrip().removeprefix("## Credential and access routing\n\n") + ) + + updated: list[str] = [] + for repo_path in discover_repos(slugs): + values = repo_values(repo_path) + cred_section = render(cred_template, values) + + rules_dir = repo_path / ".claude" / "rules" + rules_dir.mkdir(parents=True, exist_ok=True) + (rules_dir / "credential-routing.md").write_text( + render(cred_rule_template, values), encoding="utf-8" + ) + + agents_path = repo_path / "AGENTS.md" + if agents_path.exists(): + agents_text = agents_path.read_text(encoding="utf-8") + if SECTION_HEADING not in agents_text: + agents_path.write_text( + insert_credential_section(agents_text, cred_section), + encoding="utf-8", + ) + updated.append(f"{repo_path.name}\tAGENTS.md (insert)") + else: + body = render(agents_template, {**values, "CREDENTIAL_ROUTING": cred_section}) + agents_path.write_text(body, encoding="utf-8") + updated.append(f"{repo_path.name}\tAGENTS.md (new)") + + if patch_claude_md(repo_path / "CLAUDE.md"): + updated.append(f"{repo_path.name}\tCLAUDE.md") + + if f"{repo_path.name}\tcredential-routing.md" not in updated: + updated.append(f"{repo_path.name}\tcredential-routing.md") + + print(f"Propagated to {len(discover_repos(slugs))} repo(s):") + for line in updated: + print(f" {line}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) \ No newline at end of file diff --git a/scripts/update_agent_instruction_files.py b/scripts/update_agent_instruction_files.py index 9ec1fce..da12998 100644 --- a/scripts/update_agent_instruction_files.py +++ b/scripts/update_agent_instruction_files.py @@ -16,12 +16,34 @@ def fetch(path: str): return json.load(response) +EXTENSION_MARKER = "" + + def render(template: str, values: dict[str, str]) -> str: for key, value in values.items(): template = template.replace("{" + key + "}", value) return template +def read_agents_extensions(agents_path: Path) -> str: + if not agents_path.exists(): + return "" + text = agents_path.read_text(encoding="utf-8") + if EXTENSION_MARKER not in text: + return "" + return text.split(EXTENSION_MARKER, 1)[1] + + +def build_agents_md(template: str, values: dict[str, str], extensions: str) -> str: + body = render(template, values) + if extensions.strip(): + if EXTENSION_MARKER in body: + body = body.split(EXTENSION_MARKER, 1)[0] + EXTENSION_MARKER + extensions + else: + body = body.rstrip() + "\n\n" + EXTENSION_MARKER + extensions + return body + + def repo_topic_id(repo: dict, topics: list[dict]) -> str: if repo.get("topic_id"): return repo["topic_id"] @@ -71,6 +93,9 @@ def main() -> None: agents_template = (TEMPLATE_DIR / "agents-codex.template").read_text(encoding="utf-8") claude_template = (TEMPLATE_DIR / "claude-md.template").read_text(encoding="utf-8") scope_template = (TEMPLATE_DIR / "scope.template").read_text(encoding="utf-8") + credential_routing_template = ( + TEMPLATE_DIR / "credential-routing.template" + ).read_text(encoding="utf-8") rule_names = [ "repo-identity", "session-protocol", @@ -79,12 +104,22 @@ def main() -> None: "stack-and-commands", "architecture", "repo-boundary", + "credential-routing", "agents", ] - rule_templates = { - name: (TEMPLATE_DIR / f"{name}.template").read_text(encoding="utf-8") - for name in rule_names - } + rule_templates: dict[str, str] = {} + for name in rule_names: + if name == "credential-routing": + rule_templates[name] = ( + "# Credential and access routing\n\n" + + credential_routing_template.lstrip().removeprefix( + "## Credential and access routing\n\n" + ) + ) + else: + rule_templates[name] = ( + TEMPLATE_DIR / f"{name}.template" + ).read_text(encoding="utf-8") updated: list[str] = [] for repo in choose_repos(repos): @@ -99,9 +134,21 @@ def main() -> None: "TOPIC_ID": repo_topic_id(repo, topics), "REPO_SLUG": repo_slug, "WP_PREFIX": wp_prefix(repo_slug), + "CREDENTIAL_ROUTING": render(credential_routing_template, { + "PROJECT_NAME": project_name, + "PROJECT_DESCRIPTION": description, + "DOMAIN": repo.get("domain_slug") or "", + "TOPIC_ID": repo_topic_id(repo, topics), + "REPO_SLUG": repo_slug, + "WP_PREFIX": wp_prefix(repo_slug), + }), } - (path / "AGENTS.md").write_text(render(agents_template, values), encoding="utf-8") + agents_path = path / "AGENTS.md" + extensions = read_agents_extensions(agents_path) + agents_path.write_text( + build_agents_md(agents_template, values, extensions), encoding="utf-8" + ) (path / "CLAUDE.md").write_text(render(claude_template, values), encoding="utf-8") scope_path = path / "SCOPE.md" if not scope_path.exists():