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).
This commit is contained in:
2026-06-18 22:48:43 +02:00
parent 152a83907a
commit af2972a460
8 changed files with 350 additions and 5 deletions

View File

@@ -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 "<describe your need>" --json
warden route show <catalog-id> --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`

View File

@@ -101,6 +101,58 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
---
## 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 "<describe your need>" --json
warden route show <catalog-id> --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

View File

@@ -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

View File

@@ -101,6 +101,14 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
---
{CREDENTIAL_ROUTING}
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a

View File

@@ -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

View File

@@ -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 "<describe your need>" --json
warden route show <catalog-id> --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`

View File

@@ -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 = "<!-- REPO-AGENTS-EXTENSIONS -->"
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))

View File

@@ -16,12 +16,34 @@ def fetch(path: str):
return json.load(response)
EXTENSION_MARKER = "<!-- REPO-AGENTS-EXTENSIONS -->"
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():