From 937cb39de601e92ac75f48c210b373325b6a416f Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 25 May 2026 00:09:40 +0200 Subject: [PATCH 01/10] Require MFA during bootstrap mode --- src/internal/adapters/privacyidea/adapter.go | 4 +++ .../adapters/privacyidea/adapter_test.go | 21 ++++++++++++ src/internal/adapters/privacyidea/config.go | 11 ++++-- src/internal/config/config_test.go | 34 +++++++++++++++++++ src/internal/server/oidc/authorize.go | 8 +++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/internal/adapters/privacyidea/adapter.go b/src/internal/adapters/privacyidea/adapter.go index 4b07511..cfdf4ce 100644 --- a/src/internal/adapters/privacyidea/adapter.go +++ b/src/internal/adapters/privacyidea/adapter.go @@ -38,6 +38,10 @@ func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter { // registered in privacyIDEA. Fails closed: any infrastructure error returns // (false, err) so callers cannot bypass the check. func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) { + if a.cfg.RequireForAll { + return true, nil + } + endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/" q := url.Values{} diff --git a/src/internal/adapters/privacyidea/adapter_test.go b/src/internal/adapters/privacyidea/adapter_test.go index 670defa..99f07e1 100644 --- a/src/internal/adapters/privacyidea/adapter_test.go +++ b/src/internal/adapters/privacyidea/adapter_test.go @@ -101,6 +101,27 @@ func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) { } } +func TestCheckMFARequired_RequireForAll_ReturnsTrueWithoutTokenList(t *testing.T) { + client := &mockHTTPClient{ + doFn: func(_ *http.Request) (*http.Response, error) { + t.Fatal("token-list endpoint must not be called when RequireForAll is enabled") + return nil, nil + }, + } + + cfg := testConfig() + cfg.RequireForAll = true + adapter := privacyidea.New(cfg, client) + + required, err := adapter.CheckMFARequired(context.Background(), "alice") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !required { + t.Error("expected MFA required=true when RequireForAll is enabled") + } +} + func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) { client := &mockHTTPClient{ doFn: func(_ *http.Request) (*http.Response, error) { diff --git a/src/internal/adapters/privacyidea/config.go b/src/internal/adapters/privacyidea/config.go index 533ffc6..cfe05e2 100644 --- a/src/internal/adapters/privacyidea/config.go +++ b/src/internal/adapters/privacyidea/config.go @@ -8,15 +8,20 @@ import "net/http" // Config holds all connection parameters for the privacyIDEA adapter. type Config struct { // BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local". - BaseURL string + BaseURL string `yaml:"baseURL"` // AdminToken is the service-account JWT used to authenticate requests to the // privacyIDEA admin API. - AdminToken string + AdminToken string `yaml:"adminToken"` // Realm is the privacyIDEA realm to scope token and validate requests. // Defaults to "netkingdom" when empty. - Realm string + Realm string `yaml:"realm"` + + // RequireForAll skips privacyIDEA token-list discovery and requires MFA for + // every authenticated upstream user. This is useful during bootstrap when + // token-list admin credentials may not be durable yet. + RequireForAll bool `yaml:"requireForAll,omitempty"` } // realm returns the effective realm, falling back to "netkingdom". diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index 4413cd9..f5afd2c 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -127,6 +127,40 @@ clients: } } +func TestLoad_PrivacyIDEARequireForAll(t *testing.T) { + keyPath := writeTempFile(t, "placeholder-key") + yaml := ` +issuer: "https://kc.example.com" +port: 8080 +tokenLifetime: "15m" +privateKeyPem: "` + keyPath + `" +environment: "dev" +privacyidea: + baseURL: "http://privacyidea.mfa.svc.cluster.local:8080" + adminToken: "service-token" + realm: "coulomb" + requireForAll: true +clients: + - clientId: "netkingdom-bootstrap-console" + displayName: "NetKingdom Bootstrap Console" + redirectUris: + - "http://127.0.0.1:8876/oidc/callback" + clientType: "public" +` + cfgPath := writeTempFile(t, yaml) + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load: unexpected error: %v", err) + } + if cfg.PrivacyIDEA.Realm != "coulomb" { + t.Errorf("PrivacyIDEA.Realm: got %q", cfg.PrivacyIDEA.Realm) + } + if !cfg.PrivacyIDEA.RequireForAll { + t.Error("PrivacyIDEA.RequireForAll should load from YAML") + } +} + func TestLoad_FileNotFound(t *testing.T) { _, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml")) if err == nil { diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index 8a541d2..b3160b7 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -279,6 +279,14 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ // Check MFA requirement. mfaRequired, err := h.MFA.CheckMFARequired(ctx, result.Username) if err != nil { + h.Emitter.Emit(ctx, telemetry.Event{ + Timestamp: time.Now(), + EventType: telemetry.EventAuthFailure, + ClientID: ps.ClientID, + Endpoint: "/authorize/callback", + Result: "failure", + ErrorType: "mfa_check_error", + }) http.Error(w, "mfa check error", http.StatusInternalServerError) return } From 06d20c337940b79963029030871197710004c822 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 25 May 2026 00:28:33 +0200 Subject: [PATCH 02/10] Load LLDAP organizational unit config --- src/internal/adapters/lldap/config.go | 14 +++++------ src/internal/config/config_test.go | 36 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/internal/adapters/lldap/config.go b/src/internal/adapters/lldap/config.go index bb2b60b..8e91fe3 100644 --- a/src/internal/adapters/lldap/config.go +++ b/src/internal/adapters/lldap/config.go @@ -6,26 +6,26 @@ package lldap // Config holds all connection parameters for the LLDAP adapter. type Config struct { // URL is the LDAP server address, e.g. "ldap://lldap:389" or "ldaps://lldap:636". - URL string + URL string `yaml:"url"` // BindDN is the distinguished name used for the service account bind, // e.g. "cn=admin,dc=netkingdom,dc=local". - BindDN string + BindDN string `yaml:"bindDN"` // BindPW is the service account password. - BindPW string + BindPW string `yaml:"bindPW"` // BaseDN is the search base, e.g. "dc=netkingdom,dc=local". - BaseDN string + BaseDN string `yaml:"baseDN"` // UserOU is the organisational unit for users. Defaults to "ou=users" when empty. - UserOU string + UserOU string `yaml:"userOU,omitempty"` // GroupOU is the organisational unit for groups. Defaults to "ou=groups" when empty. - GroupOU string + GroupOU string `yaml:"groupOU,omitempty"` // TLSSkipVerify disables TLS certificate verification. For development only. - TLSSkipVerify bool + TLSSkipVerify bool `yaml:"tlsSkipVerify,omitempty"` } // userOU returns the effective UserOU, falling back to the default. diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index f5afd2c..73060c1 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -161,6 +161,42 @@ clients: } } +func TestLoad_LLDAPOrganisationalUnits(t *testing.T) { + keyPath := writeTempFile(t, "placeholder-key") + yaml := ` +issuer: "https://kc.example.com" +port: 8080 +tokenLifetime: "15m" +privateKeyPem: "` + keyPath + `" +environment: "dev" +lldap: + url: "ldap://lldap.sso.svc.cluster.local:3890" + bindDN: "uid=admin,ou=people,dc=netkingdom,dc=local" + bindPW: "secret" + baseDN: "dc=netkingdom,dc=local" + userOU: "ou=people" + groupOU: "ou=groups" +clients: + - clientId: "netkingdom-bootstrap-console" + displayName: "NetKingdom Bootstrap Console" + redirectUris: + - "http://127.0.0.1:8876/oidc/callback" + clientType: "public" +` + cfgPath := writeTempFile(t, yaml) + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load: unexpected error: %v", err) + } + if cfg.LLDAP.UserOU != "ou=people" { + t.Errorf("LLDAP.UserOU: got %q", cfg.LLDAP.UserOU) + } + if cfg.LLDAP.GroupOU != "ou=groups" { + t.Errorf("LLDAP.GroupOU: got %q", cfg.LLDAP.GroupOU) + } +} + func TestLoad_FileNotFound(t *testing.T) { _, err := config.Load(filepath.Join(t.TempDir(), "nonexistent.yaml")) if err == nil { From d6d41dd84f4ef900964c8e9ca5e0ca6eb793bdba Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 1 Jun 2026 21:20:54 +0200 Subject: [PATCH 03/10] Fix OpenBao OIDC token exchange compatibility --- src/internal/adapters/lldap/adapter.go | 9 ++++-- src/internal/adapters/lldap/adapter_test.go | 33 +++++++++++++++------ src/internal/server/oidc/authorize.go | 4 +++ src/internal/server/oidc/session.go | 1 + src/internal/server/oidc/token.go | 3 ++ src/internal/server/oidc/token_test.go | 4 +++ 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/internal/adapters/lldap/adapter.go b/src/internal/adapters/lldap/adapter.go index e929564..12c3c93 100644 --- a/src/internal/adapters/lldap/adapter.go +++ b/src/internal/adapters/lldap/adapter.go @@ -125,11 +125,16 @@ func (a *LDAPAdapter) LookupUser(ctx context.Context, username string) (*domain. entry := result.Entries[0] user := mapEntryToUser(entry) - // Run the canonical LDAP schema validator. + // Runtime login should not fail because a live directory entry is missing + // provisioning metadata such as cn/sn. Keep the warning visible for + // diagnostics, but return the resolved user so token issuance can proceed. snap := validator.Snapshot{Users: []domain.User{user}} report := validator.Validate(snap, validator.ModeProvisioning) if !report.Passed { - return nil, fmt.Errorf("lldap: validation failed for user %q: %s", username, validationSummary(report)) + if user.LDAPAttributes == nil { + user.LDAPAttributes = make(map[string]string) + } + user.LDAPAttributes["_validation_warning"] = validationSummary(report) } return &user, nil diff --git a/src/internal/adapters/lldap/adapter_test.go b/src/internal/adapters/lldap/adapter_test.go index b556af4..72c5bb3 100644 --- a/src/internal/adapters/lldap/adapter_test.go +++ b/src/internal/adapters/lldap/adapter_test.go @@ -154,16 +154,20 @@ func TestLookupUser_NotFound(t *testing.T) { } } -func TestLookupUser_ValidationFailure(t *testing.T) { - // Return an entry with an empty DisplayName and empty sn — will fail validator. - dn := "uid=broken,ou=users,dc=netkingdom,dc=local" +func TestLookupUser_ValidationWarningDoesNotBlockRuntimeLogin(t *testing.T) { + // Return an entry with an empty DisplayName and empty sn. Runtime login + // should still resolve the user; provisioning validators report the warning. + dn := "uid=platform-root,ou=people,dc=netkingdom,dc=local" conn := &mockConn{ searchFn: func(req *ldap.SearchRequest) (*ldap.SearchResult, error) { + if req.BaseDN != "ou=people,dc=netkingdom,dc=local" { + t.Fatalf("BaseDN: want ou=people,dc=netkingdom,dc=local, got %q", req.BaseDN) + } attrs := []*ldap.EntryAttribute{ - {Name: "uid", Values: []string{"broken"}}, + {Name: "uid", Values: []string{"platform-root"}}, {Name: "cn", Values: []string{""}}, {Name: "sn", Values: []string{""}}, - {Name: "mail", Values: []string{"broken@example.com"}}, + {Name: "mail", Values: []string{"bernd.worsch@gmail.com"}}, } return &ldap.SearchResult{ Entries: []*ldap.Entry{{DN: dn, Attributes: attrs}}, @@ -171,10 +175,21 @@ func TestLookupUser_ValidationFailure(t *testing.T) { }, } - adapter := makeAdapter(testConfig(), conn) - _, err := adapter.LookupUser(context.Background(), "broken") - if err == nil { - t.Fatal("expected validation error, got nil") + cfg := testConfig() + cfg.UserOU = "ou=people" + adapter := makeAdapter(cfg, conn) + user, err := adapter.LookupUser(context.Background(), "platform-root") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.ID != dn { + t.Errorf("ID: want %q, got %q", dn, user.ID) + } + if user.Username != "platform-root" { + t.Errorf("Username: want platform-root, got %q", user.Username) + } + if user.LDAPAttributes["_validation_warning"] == "" { + t.Error("expected validation warning for missing displayName") } } diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index b3160b7..94ef3f2 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -23,6 +23,7 @@ type PendingState struct { PKCEChallenge string PKCEChallengeMethod string State string + Nonce string Scopes []string ExpiresAt time.Time AuthenticatedUser string @@ -103,6 +104,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request responseType := q.Get("response_type") scope := q.Get("scope") state := q.Get("state") + nonce := q.Get("nonce") codeChallenge := q.Get("code_challenge") codeChallengeMethod := q.Get("code_challenge_method") @@ -191,6 +193,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request PKCEChallenge: codeChallenge, PKCEChallengeMethod: codeChallengeMethod, State: state, + Nonce: nonce, Scopes: strings.Fields(scope), ExpiresAt: time.Now().Add(10 * time.Minute), }) @@ -358,6 +361,7 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http. PKCEChallenge: ps.PKCEChallenge, PKCEChallengeMethod: ps.PKCEChallengeMethod, State: ps.State, + Nonce: ps.Nonce, Username: username, Scopes: ps.Scopes, ExpiresAt: time.Now().Add(10 * time.Minute), diff --git a/src/internal/server/oidc/session.go b/src/internal/server/oidc/session.go index 75ea6f4..72405b0 100644 --- a/src/internal/server/oidc/session.go +++ b/src/internal/server/oidc/session.go @@ -15,6 +15,7 @@ type PKCESession struct { PKCEChallenge string // S256 challenge PKCEChallengeMethod string // always "S256" State string + Nonce string Username string // set after auth Scopes []string ExpiresAt time.Time diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index ee0848a..35f1d4b 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -111,6 +111,9 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { "exp": exp.Unix(), "iat": now.Unix(), } + if sess.Nonce != "" { + claims["nonce"] = sess.Nonce + } scopeSet := make(map[string]bool) for _, s := range sess.Scopes { diff --git a/src/internal/server/oidc/token_test.go b/src/internal/server/oidc/token_test.go index 8192c8e..555a432 100644 --- a/src/internal/server/oidc/token_test.go +++ b/src/internal/server/oidc/token_test.go @@ -107,6 +107,7 @@ func seededSession(sessions *oidc.SessionStore, verifier string) (code string) { PKCEChallenge: challenge, PKCEChallengeMethod: "S256", State: "state1", + Nonce: "nonce1", Username: "alice", Scopes: []string{"openid", "profile", "email", "groups"}, ExpiresAt: time.Now().Add(10 * time.Minute), @@ -323,6 +324,9 @@ func TestTokenHandler_JWTClaims_CorrectSubAndIssuer(t *testing.T) { if claims["aud"] != "test-client" { t.Errorf("aud: expected test-client, got %v", claims["aud"]) } + if claims["nonce"] != "nonce1" { + t.Errorf("nonce: expected nonce1, got %v", claims["nonce"]) + } } func TestTokenHandler_ScopeFiltering_ProfileScope(t *testing.T) { From 593b5af8dcdd718a247bc2b8ff4e4d859df30cb7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 16 Jun 2026 01:53:59 +0200 Subject: [PATCH 04/10] Add capability registry scaffold (REUSE-WP-0014-T05 B03) --- registry/README.md | 12 ++++++++++++ registry/capabilities/.gitkeep | 0 registry/indexes/capabilities.yaml | 4 ++++ 3 files changed, 16 insertions(+) create mode 100644 registry/README.md create mode 100644 registry/capabilities/.gitkeep create mode 100644 registry/indexes/capabilities.yaml diff --git a/registry/README.md b/registry/README.md new file mode 100644 index 0000000..569abe9 --- /dev/null +++ b/registry/README.md @@ -0,0 +1,12 @@ +# Capability Registry + +Markdown-first capability index for federation and reuse planning. + +## Authoring + +1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`). +2. Add the row to `indexes/capabilities.yaml`. +3. Run `reuse-surface validate` from a checkout with the CLI installed. +4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`. + +Federation contract: reuse-surface `docs/RegistryFederation.md`. diff --git a/registry/capabilities/.gitkeep b/registry/capabilities/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/registry/indexes/capabilities.yaml b/registry/indexes/capabilities.yaml new file mode 100644 index 0000000..f944e47 --- /dev/null +++ b/registry/indexes/capabilities.yaml @@ -0,0 +1,4 @@ +version: 1 +updated: '2026-06-16' +domain: helix_forge +capabilities: [] From c9838a48119680db1afe8fd067f7b08793f99006 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 18 Jun 2026 22:48:38 +0200 Subject: [PATCH 05/10] Add credential routing instructions for all agent runtimes Propagate shared credential-routing section (Codex, Claude, Grok, llm-connect) from state-hub template via scripts/propagate_credential_routing.py. --- .claude/rules/credential-routing.md | 50 +++++++++++++++++++++++++++ AGENTS.md | 52 +++++++++++++++++++++++++++++ CLAUDE.md | 1 + 3 files changed, 103 insertions(+) create mode 100644 .claude/rules/credential-routing.md diff --git a/.claude/rules/credential-routing.md b/.claude/rules/credential-routing.md new file mode 100644 index 0000000..b2f4e80 --- /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=key-cape` 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 cb416cc..c756fc9 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=key-cape` 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 759e715..e81c1b4 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 From bee021735c7311dffeae2a5f45d23064fb1d04eb Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 17:47:37 +0200 Subject: [PATCH 06/10] Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) --- .repo-classification.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .repo-classification.yaml diff --git a/.repo-classification.yaml b/.repo-classification.yaml new file mode 100644 index 0000000..f77dd7a --- /dev/null +++ b/.repo-classification.yaml @@ -0,0 +1,19 @@ +repo_classification: + standard: Repo Classification Standard + version: '1.0' + classified_at: '2026-06-22' + classified_by: agent + category: project + domain: communication + secondary_domains: [] + capability_tags: + - identity + - access-control + - security + business_stake: + - product + - experience + - technology + business_mechanics: + - coordination + - operation From c4f281a37685219ede75df515fef3985d51196d4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 17:56:17 +0200 Subject: [PATCH 07/10] Human-review .repo-classification.yaml (CUST-WP-0050 follow-up) --- .repo-classification.yaml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.repo-classification.yaml b/.repo-classification.yaml index f77dd7a..4fc521f 100644 --- a/.repo-classification.yaml +++ b/.repo-classification.yaml @@ -2,18 +2,25 @@ repo_classification: standard: Repo Classification Standard version: '1.0' classified_at: '2026-06-22' - classified_by: agent - category: project - domain: communication - secondary_domains: [] + classified_by: human + category: product + domain: infotech + secondary_domains: + - communication capability_tags: - identity - access-control - security + - platform + - operations business_stake: - - product - - experience - technology + - operations + - legal + - product business_mechanics: - - coordination + - control - operation + - adaptation + notes: NetKingdom IAM Profile lightweight mode (Authelia/LLDAP/privacyIDEA); human + corrected domain from communication→infotech. From d076e7ee7b1725f5d6e2084107664a85df25a112 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 18:02:26 +0200 Subject: [PATCH 08/10] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-06-22: - update .custodian-brief.md for key-cape --- .custodian-brief.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 26011a0..f467281 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -1,8 +1,8 @@ # Custodian Brief — key-cape -**Domain:** (unknown) -**Last synced:** 2026-03-26 16:47 UTC +**Domain:** communication +**Last synced:** 2026-06-22 16:02 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -13,6 +13,6 @@ ## MCP Orientation (when available) If the state-hub MCP server is reachable, call: -`get_domain_summary("")` +`get_domain_summary("communication")` This provides richer cross-domain context. If the MCP call fails, use this file as your orientation source. From afc01456a559aa35b0fc28dd3758332945629231 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 18:40:55 +0200 Subject: [PATCH 09/10] Fixed workplan frontmatter --- workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md b/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md index 28f2038..6bc1219 100644 --- a/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md +++ b/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md @@ -9,6 +9,7 @@ owner: codex topic_slug: netkingdom created: "2026-05-24" updated: "2026-05-24" +state_hub_workstream_id: "02990009-a2b3-44f6-a579-487fbacae41a" --- # KEY-WP-0003 - Bootstrap Console OIDC Login and MFA Verification @@ -104,6 +105,7 @@ ceremony, and restart the KeyCape deployment. id: KEY-WP-0003-T01 status: done priority: high +state_hub_task_id: "b396c99f-d711-475a-9cba-4f03a1db561d" ``` Add a KeyCape client registration for the bootstrap console. Either create a @@ -125,6 +127,7 @@ Gate: an authorize request using the local callback no longer returns id: KEY-WP-0003-T02 status: done priority: high +state_hub_task_id: "46172e6d-3e11-493c-b223-79c2fc321aec" ``` Confirm whether the current `authelia.baseURL` is safe to use for both browser @@ -141,6 +144,7 @@ inside the deployment. id: KEY-WP-0003-T03 status: done priority: high +state_hub_task_id: "92fca4d0-6215-4ea6-9f80-9178ae183acb" ``` When `CheckMFARequired` returns true after the Authelia callback, render a @@ -163,6 +167,7 @@ browser and is returned to the registered downstream callback. id: KEY-WP-0003-T04 status: done priority: medium +state_hub_task_id: "079a5929-1864-4461-a64c-746cebca469d" ``` Add tests that cover: @@ -182,6 +187,7 @@ Gate: `make test` passes and the negative redirect URI tests remain green. id: KEY-WP-0003-T05 status: done priority: medium +state_hub_task_id: "1d67225d-a20b-4e36-9b2e-20836be2f439" ``` Document the deployment path for updating live KeyCape config without From 2fd69f03740c91448fb1ddc21199f91ec8f513e3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 23:16:27 +0200 Subject: [PATCH 10/10] Normalize agent instructions and workplan frontmatter (STATE-WP-0067) - Align agent files with on-disk workplan prefixes (infer from workplan ids) - Set workplan domain to registered domain_slug; add topic_slug where applicable - Repair frontmatter delimiter formatting; migrate legacy task status literals - Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates --- .claude/rules/first-session.md | 14 +++++------ .claude/rules/repo-identity.md | 4 ++-- .claude/rules/session-protocol.md | 13 ++++++----- .claude/rules/workplan-convention.md | 18 ++++++++++++--- AGENTS.md | 23 +++++++++++-------- .../KEY-WP-0001-keycape-implementation.md | 2 +- .../KEY-WP-0002-container-image-gitea.md | 2 +- ...P-0003-bootstrap-console-oidc-mfa-login.md | 2 +- 8 files changed, 48 insertions(+), 30 deletions(-) diff --git a/.claude/rules/first-session.md b/.claude/rules/first-session.md index 58f2cc7..80aeac7 100644 --- a/.claude/rules/first-session.md +++ b/.claude/rules/first-session.md @@ -1,11 +1,11 @@ ## First Session Protocol -Triggered when `get_domain_summary("netkingdom")` shows **no workstreams**. +Triggered when `get_domain_summary("infotech")` shows **no workstreams**. The project is registered but work has not yet been structured. **Step 1 — Read, don't write** -- `~/the-custodian/canon/projects/netkingdom/project_charter_v0.1.md` — purpose, scope -- `~/the-custodian/canon/projects/netkingdom/roadmap_v0.1.md` — planned phases +- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope +- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases - Scan repo root: README, directory structure, existing code or docs **Step 2 — Survey in-progress work** @@ -17,20 +17,20 @@ roadmap phase. **Wait for approval before creating.** **Step 4 — Create workplan file first, then DB record (ADR-001)** ``` -workplans/key-cape-WP-NNNN-.md ← write this first +workplans/KEY-WP-NNNN-.md ← write this first ``` Then register in the hub: ``` -create_workstream(topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", title="...", owner="...", description="...") +create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...") create_task(workstream_id="", title="...", priority="high|medium|low") ``` **Step 5 — Record the setup** ``` add_progress_event( - summary="First session: structured netkingdom into N workstreams, M tasks", + summary="First session: structured infotech into N workstreams, M tasks", event_type="milestone", - topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", + topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", detail={"workstreams": [...], "tasks_created": M} ) ``` diff --git a/.claude/rules/repo-identity.md b/.claude/rules/repo-identity.md index 55343f4..c4b8fa1 100644 --- a/.claude/rules/repo-identity.md +++ b/.claude/rules/repo-identity.md @@ -1,5 +1,5 @@ **Purpose:** Lightweight IAM profile implementation for NetKingdom — "prepare for Keycloak without Keycloak". Implements the NetKingdom IAM Profile (OIDC/PKCE) via Authelia + LLDAP + privacyIDEA, with migration path to Keycloak in expanded mode. -**Domain:** netkingdom +**Domain:** infotech **Repo slug:** key-cape -**Topic ID:** a6c6e745-bf54-4465-9340-1534a2be493e +**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a diff --git a/.claude/rules/session-protocol.md b/.claude/rules/session-protocol.md index c80ea41..381d29c 100644 --- a/.claude/rules/session-protocol.md +++ b/.claude/rules/session-protocol.md @@ -1,6 +1,7 @@ ## Session Protocol -State Hub: http://127.0.0.1:8000 +Dev Hub (State Hub API): http://127.0.0.1:8000 +MCP server name in `~/.claude.json`: `dev-hub` **Step 1 — Orient** @@ -10,7 +11,7 @@ cat .custodian-brief.md ``` Then call the MCP tool for richer cross-domain context when MCP tools are exposed: ``` -get_domain_summary("netkingdom") +get_domain_summary("infotech") ``` If MCP tools are unavailable in the current agent session, use the REST API: ```bash @@ -39,11 +40,11 @@ curl -s -X PATCH "http://127.0.0.1:8000/messages//read" \ ls workplans/ ``` For each file with `status: ready`, `active`, or `blocked`, note pending -`todo`/`in_progress` tasks. +`wait`/`todo`/`progress` tasks. **Step 4 — Present brief** -1. **Active workstreams** for `netkingdom` — title, task counts, blocking decisions +1. **Active workstreams** for `infotech` — title, task counts, blocking decisions 2. **Pending tasks** from `workplans/` + any `[repo:key-cape]` hub tasks 3. **Goal guidance** — if `goal_guidance` in summary: - `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"* @@ -61,13 +62,13 @@ If no workstreams: follow First Session Protocol (`first-session.md`). **Session close:** With MCP tools: ``` -add_progress_event(summary="...", topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", workstream_id="") +add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="") ``` Without MCP tools: ```bash curl -s -X POST http://127.0.0.1:8000/progress/ \ -H "Content-Type: application/json" \ - -d '{"topic_id":"a6c6e745-bf54-4465-9340-1534a2be493e","workstream_id":"","event_type":"note","summary":"what changed","author":"codex"}' + -d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"","event_type":"note","summary":"what changed","author":"codex"}' ``` If workplan files were modified, ensure the local copy is up to date first: ```bash diff --git a/.claude/rules/workplan-convention.md b/.claude/rules/workplan-convention.md index 9e3608c..aacacf4 100644 --- a/.claude/rules/workplan-convention.md +++ b/.claude/rules/workplan-convention.md @@ -1,7 +1,7 @@ ## Workplan Convention (ADR-001) -File location: `workplans/key-cape-WP-NNNN-.md` -ID prefix: `KEY-WP` +File location: `workplans/KEY-WP-NNNN-.md` +ID prefix: `KEY-WP-` Work items originate as files in this repo **before** being registered in the hub. @@ -12,7 +12,7 @@ repo state, and `finished` when implementation is complete. `stalled` and `needs_review` are derived health labels, not stored statuses. Closed workplans may be moved to `workplans/archived/` with a completion-date -prefix: `YYMMDD-key-cape-WP-NNNN-.md`. The frontmatter id remains +prefix: `YYMMDD-KEY-WP-NNNN-.md`. The frontmatter id remains unchanged; the prefix is only for quick visual reference. Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**: @@ -25,4 +25,16 @@ Ecosystem todos from other agents arrive as `[repo:key-cape]` hub tasks — visible at session start. Pick one up by creating the workplan file, then registering the workstream. +Task blocks use this shape: + +```task +id: KEY-WP-NNNN-T01 +status: wait | todo | progress | done | cancel +priority: high | medium | low +state_hub_task_id: "" # written by fix-consistency — do not edit +``` + +Status progression is `todo` → `progress` → `done`; use `wait` for waiting or +blocked work and `cancel` for stopped work. + diff --git a/AGENTS.md b/AGENTS.md index c756fc9..823d45c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,9 @@ **Purpose:** Lightweight IAM profile implementation for NetKingdom — "prepare for Keycloak without Keycloak". Implements the NetKingdom IAM Profile (OIDC/PKCE) via Authelia + LLDAP + privacyIDEA, with migration path to Keycloak in expanded mode. -**Domain:** netkingdom +**Domain:** infotech **Repo slug:** key-cape -**Topic ID:** `a6c6e745-bf54-4465-9340-1534a2be493e` +**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a` **Workplan prefix:** `KEY-WP-` --- @@ -28,7 +28,7 @@ there is no MCP server for Codex agents. cat .custodian-brief.md # Active workstreams for this domain -curl -s "http://127.0.0.1:8000/workstreams/?topic_id=a6c6e745-bf54-4465-9340-1534a2be493e&status=active" \ +curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \ | python3 -m json.tool # Check inbox @@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable. ```bash curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ -H "Content-Type: application/json" \ - -d '{"status": "in_progress"}' -# values: todo | in_progress | done | blocked + -d '{"status": "progress"}' +# values: wait | todo | progress | done | cancel ``` ### Flag a task for human review @@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ 1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe) 2. Check inbox: `GET /messages/?to_agent=key-cape&unread_only=true`; mark read 3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks -4. Check blocked tasks: `GET /tasks/?needs_human=true` +4. Check human-needed tasks: `GET /tasks/?needs_human=true` **During work:** - Update task statuses in workplan files as tasks progress @@ -151,6 +151,11 @@ every repo's agent instructions because it is high-frequency, high-risk, and eas get wrong. **Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml` + + + + --- ## Workplan Convention (ADR-001) @@ -176,7 +181,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases. id: KEY-WP-NNNN type: workplan title: "..." -domain: netkingdom +domain: infotech repo: key-cape status: proposed | ready | active | blocked | backlog | finished | archived owner: codex @@ -198,7 +203,7 @@ derived health labels, not frontmatter statuses. ` ` `task id: KEY-WP-NNNN-T01 -status: todo | in_progress | done | blocked +status: wait | todo | progress | done | cancel priority: high | medium | low state_hub_task_id: "" # written by fix-consistency — do not edit ` ` ` @@ -206,7 +211,7 @@ state_hub_task_id: "" # written by fix-consistency — do not edit Task description text. ``` -Status progression: `todo` → `in_progress` → `done` (or `blocked`) +Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work. To create a new workplan: 1. Write the file following the format above diff --git a/workplans/KEY-WP-0001-keycape-implementation.md b/workplans/KEY-WP-0001-keycape-implementation.md index 194eae4..e42fbc5 100644 --- a/workplans/KEY-WP-0001-keycape-implementation.md +++ b/workplans/KEY-WP-0001-keycape-implementation.md @@ -2,7 +2,7 @@ id: KEY-WP-0001 type: workplan title: "KeyCape Implementation — Lightweight IAM Profile" -domain: netkingdom +domain: infotech repo: key-cape status: done owner: Bernd diff --git a/workplans/KEY-WP-0002-container-image-gitea.md b/workplans/KEY-WP-0002-container-image-gitea.md index 14de695..983d2e0 100644 --- a/workplans/KEY-WP-0002-container-image-gitea.md +++ b/workplans/KEY-WP-0002-container-image-gitea.md @@ -2,7 +2,7 @@ id: KEY-WP-0002 type: workplan title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry" -domain: netkingdom +domain: infotech repo: key-cape status: done owner: netkingdom diff --git a/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md b/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md index 6bc1219..9896c67 100644 --- a/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md +++ b/workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md @@ -2,7 +2,7 @@ id: KEY-WP-0003 type: workplan title: "Bootstrap Console OIDC Login and MFA Verification" -domain: netkingdom +domain: infotech repo: key-cape status: finished owner: codex