From 535177155710425b8f9e5ad546245c77ace35c20 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 6 Sep 2026 01:05:03 -0500 Subject: [PATCH 1/2] Establish organization governance plane v1 Introduce the organization-wide governance model, generated views, validation tooling, evidence contracts, and the reconciled public repository registry. Signed-off-by: Val Alexander Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/CODEOWNERS | 17 + .github/ISSUE_TEMPLATE/config.yml | 5 + .../ISSUE_TEMPLATE/governance-exception.yml | 63 + .../ISSUE_TEMPLATE/governance-initiative.yml | 64 + .../ISSUE_TEMPLATE/repository-lifecycle.yml | 60 + .github/PULL_REQUEST_TEMPLATE.md | 59 + .github/dependabot.yml | 15 + .github/workflows/governance-ci.yml | 27 + .github/workflows/governance-drift.yml | 32 + .../workflows/reusable-agent-readiness.yml | 544 ++++++ .../workflows/reusable-evidence-packet.yml | 529 ++++++ .gitignore | 3 + AGENTS.md | 163 ++ LICENSE | 21 + README.md | 148 ++ agent/manifest.json | 64 + compatibility/contracts.json | 43 + compatibility/dependencies.json | 96 + compatibility/release-trains.json | 32 + .../ADR-0001-organization-governance-plane.md | 114 ++ ...nce-metadata-is-not-protected-authority.md | 19 + ...0003-public-registry-private-federation.md | 16 + decisions/README.md | 7 + decisions/index.json | 27 + docs/administration-baseline.md | 60 + docs/github-projects-integration.md | 46 + docs/operating-model.md | 81 + docs/rollout.md | 67 + docs/standards-and-assurance-mapping.md | 34 + docs/verification-model.md | 33 + ...9-03-organization-governance-plane-v1.json | 117 ++ evidence/README.md | 5 + generated/controls.md | 16 + generated/dependencies.mmd | 48 + generated/initiatives.md | 10 + generated/ownership.md | 54 + generated/portfolio.md | 54 + governance/controls.json | 115 ++ governance/exceptions.json | 5 + governance/lifecycle.json | 83 + governance/repositories.json | 1 + initiatives/README.md | 26 + initiatives/brand-ui-consolidation.json | 92 + .../familiar-identity-continuity-v1.json | 120 ++ .../organization-governance-plane-v1.json | 89 + .../public-portfolio-consolidation-2026.json | 104 ++ policies/administration-and-recovery.md | 37 + policies/agent-authored-changes.md | 50 + policies/authority-boundaries.md | 43 + policies/evidence-and-verification.md | 47 + policies/exceptions.md | 23 + policies/initiatives-and-decisions.md | 35 + policies/public-private-data.md | 35 + policies/repository-lifecycle.md | 47 + policies/repository-retirement.md | 30 + policies/security-and-supply-chain.md | 32 + schemas/agent-manifest.schema.json | 210 +++ schemas/contracts.schema.json | 54 + schemas/controls.schema.json | 63 + schemas/decision-index.schema.json | 59 + schemas/dependencies.schema.json | 50 + schemas/evidence-packet.schema.json | 169 ++ schemas/exception.schema.json | 98 + schemas/initiative.schema.json | 181 ++ schemas/lifecycle.schema.json | 30 + schemas/release-trains.schema.json | 49 + schemas/repository-registry.schema.json | 308 +++ scripts/agent-bootstrap | 23 + scripts/agent-check | 25 + scripts/governance.py | 32 + scripts/governance_cli.py | 548 ++++++ scripts/governance_core.py | 917 +++++++++ scripts/governance_model.py | 471 +++++ tests/test_governance.py | 1656 +++++++++++++++++ 74 files changed, 8750 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/governance-exception.yml create mode 100644 .github/ISSUE_TEMPLATE/governance-initiative.yml create mode 100644 .github/ISSUE_TEMPLATE/repository-lifecycle.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/governance-ci.yml create mode 100644 .github/workflows/governance-drift.yml create mode 100644 .github/workflows/reusable-agent-readiness.yml create mode 100644 .github/workflows/reusable-evidence-packet.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 agent/manifest.json create mode 100644 compatibility/contracts.json create mode 100644 compatibility/dependencies.json create mode 100644 compatibility/release-trains.json create mode 100644 decisions/ADR-0001-organization-governance-plane.md create mode 100644 decisions/ADR-0002-governance-metadata-is-not-protected-authority.md create mode 100644 decisions/ADR-0003-public-registry-private-federation.md create mode 100644 decisions/README.md create mode 100644 decisions/index.json create mode 100644 docs/administration-baseline.md create mode 100644 docs/github-projects-integration.md create mode 100644 docs/operating-model.md create mode 100644 docs/rollout.md create mode 100644 docs/standards-and-assurance-mapping.md create mode 100644 docs/verification-model.md create mode 100644 evidence/2026-09-03-organization-governance-plane-v1.json create mode 100644 evidence/README.md create mode 100644 generated/controls.md create mode 100644 generated/dependencies.mmd create mode 100644 generated/initiatives.md create mode 100644 generated/ownership.md create mode 100644 generated/portfolio.md create mode 100644 governance/controls.json create mode 100644 governance/exceptions.json create mode 100644 governance/lifecycle.json create mode 100644 governance/repositories.json create mode 100644 initiatives/README.md create mode 100644 initiatives/brand-ui-consolidation.json create mode 100644 initiatives/familiar-identity-continuity-v1.json create mode 100644 initiatives/organization-governance-plane-v1.json create mode 100644 initiatives/public-portfolio-consolidation-2026.json create mode 100644 policies/administration-and-recovery.md create mode 100644 policies/agent-authored-changes.md create mode 100644 policies/authority-boundaries.md create mode 100644 policies/evidence-and-verification.md create mode 100644 policies/exceptions.md create mode 100644 policies/initiatives-and-decisions.md create mode 100644 policies/public-private-data.md create mode 100644 policies/repository-lifecycle.md create mode 100644 policies/repository-retirement.md create mode 100644 policies/security-and-supply-chain.md create mode 100644 schemas/agent-manifest.schema.json create mode 100644 schemas/contracts.schema.json create mode 100644 schemas/controls.schema.json create mode 100644 schemas/decision-index.schema.json create mode 100644 schemas/dependencies.schema.json create mode 100644 schemas/evidence-packet.schema.json create mode 100644 schemas/exception.schema.json create mode 100644 schemas/initiative.schema.json create mode 100644 schemas/lifecycle.schema.json create mode 100644 schemas/release-trains.schema.json create mode 100644 schemas/repository-registry.schema.json create mode 100755 scripts/agent-bootstrap create mode 100755 scripts/agent-check create mode 100755 scripts/governance.py create mode 100644 scripts/governance_cli.py create mode 100644 scripts/governance_core.py create mode 100644 scripts/governance_model.py create mode 100644 tests/test_governance.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..06dd9ec --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,17 @@ +# Bootstrap ownership. Ruleset enforcement is tracked in OpenCoven/.github#6. +* @BunsDev + +/AGENTS.md @BunsDev +/governance/ @BunsDev +/initiatives/ @BunsDev +/decisions/ @BunsDev +/compatibility/ @BunsDev +/policies/ @BunsDev +/schemas/ @BunsDev +/scripts/ @BunsDev +/tests/ @BunsDev +/.github/workflows/ @BunsDev +/.github/CODEOWNERS @BunsDev +/SECURITY.md @BunsDev +/PROVENANCE.md @BunsDev +/PATENTS.md @BunsDev diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..7a7580b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability privately + url: https://github.com/OpenCoven/coven/security/advisories/new + about: Do not place vulnerability details in a public governance issue. diff --git a/.github/ISSUE_TEMPLATE/governance-exception.yml b/.github/ISSUE_TEMPLATE/governance-exception.yml new file mode 100644 index 0000000..16fddd0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/governance-exception.yml @@ -0,0 +1,63 @@ +name: Governance exception +description: Request a narrow, compensating, expiring policy exception +labels: [governance, exception] +body: + - type: input + id: control + attributes: + label: Control ID + placeholder: GOV-000 + validations: + required: true + - type: textarea + id: scope + attributes: + label: Exact scope + description: Name public repositories/paths only. Do not disclose private inventory or sensitive data. + validations: + required: true + - type: input + id: owner + attributes: + label: Exception owner + validations: + required: true + - type: input + id: approver + attributes: + label: Required approving authority + validations: + required: true + - type: textarea + id: rationale + attributes: + label: Rationale and risk + validations: + required: true + - type: textarea + id: compensating + attributes: + label: Compensating controls + validations: + required: true + - type: input + id: expires + attributes: + label: Expiry date + description: Maximum 90 days unless a stricter policy applies. + placeholder: YYYY-MM-DD + validations: + required: true + - type: textarea + id: remediation + attributes: + label: Remediation and verification + validations: + required: true + - type: checkboxes + id: boundary + attributes: + label: Boundary + options: + - label: This exception does not grant protected runtime, release, publication, destructive, or GitHub-administration authority. + required: true diff --git a/.github/ISSUE_TEMPLATE/governance-initiative.yml b/.github/ISSUE_TEMPLATE/governance-initiative.yml new file mode 100644 index 0000000..daa8960 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/governance-initiative.yml @@ -0,0 +1,64 @@ +name: Cross-repository initiative +description: Propose an organization-level outcome spanning repositories +labels: [governance, initiative] +body: + - type: markdown + attributes: + value: | + This issue coordinates an outcome. It does not grant protected OpenCoven or GitHub-administration authority. + - type: input + id: outcome + attributes: + label: Outcome + description: What organization-level result must become true? + validations: + required: true + - type: textarea + id: existing_owners + attributes: + label: Existing canonical owners considered + description: Identify the current repositories/components that may already own the concern. + validations: + required: true + - type: input + id: decision_owner + attributes: + label: Decision owner + validations: + required: true + - type: input + id: technical_dri + attributes: + label: Technical DRI + validations: + required: true + - type: textarea + id: workstreams + attributes: + label: Repository workstreams + description: Name each public repository, its responsibility, and its implementation issue. Use opaque identifiers for private overlays. + validations: + required: true + - type: textarea + id: dependencies + attributes: + label: Dependencies and sequencing + - type: textarea + id: exit_criteria + attributes: + label: Evidence-backed exit criteria + validations: + required: true + - type: textarea + id: non_goals + attributes: + label: Non-goals + validations: + required: true + - type: checkboxes + id: boundary + attributes: + label: Authority boundary + options: + - label: I understand that issue/task/model text cannot authorize a protected mutation, release, publication, destructive action, or organization-setting change. + required: true diff --git a/.github/ISSUE_TEMPLATE/repository-lifecycle.yml b/.github/ISSUE_TEMPLATE/repository-lifecycle.yml new file mode 100644 index 0000000..b4982a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/repository-lifecycle.yml @@ -0,0 +1,60 @@ +name: Repository lifecycle change +description: Propose creation, graduation, consolidation, archival, transfer, visibility change, or retirement +labels: [governance, repository-lifecycle] +body: + - type: input + id: repository + attributes: + label: Public repository + placeholder: OpenCoven/name + validations: + required: true + - type: dropdown + id: action + attributes: + label: Proposed lifecycle action + options: + - create + - graduate + - move-to-maintenance + - deprecate + - consolidate + - archive + - transfer + - change-visibility + - tombstone + - delete-after-gate + validations: + required: true + - type: textarea + id: ownership + attributes: + label: Canonical ownership analysis + description: Explain why an existing canonical repository cannot own new work, or identify the successor for retirement. + validations: + required: true + - type: textarea + id: references + attributes: + label: Reference, package, release, installer, domain, and webhook inventory + validations: + required: true + - type: textarea + id: provenance + attributes: + label: License, provenance, issue, release, and history preservation + validations: + required: true + - type: textarea + id: migration + attributes: + label: Migration, observation, rollback, and user impact + validations: + required: true + - type: checkboxes + id: authorization + attributes: + label: Explicit authorization gate + options: + - label: I understand that filing or approving this issue does not itself authorize archive, transfer, visibility change, deletion, release, or publication. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3157c0f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,59 @@ +## Objective + + + +## Acceptance criteria + +- [ ] + +## Non-goals + +- + +## Canonical sources consulted + + + +- + +## Ownership and authority impact + +- Risk class: `R0 | R1 | R2 | R3 | R4` +- Canonical domains affected: +- Protected boundaries affected: +- Authorization effect: **none — metadata/proposal only**, unless a separately authenticated enforcement path is named and evidenced. + +## Verification + +| Command / evidence | Result | Environment | +|---|---|---| +| `./scripts/agent-check fast` | | | + +## Migration and rollback + +- + +## Security, privacy, supply-chain, and compliance impact + +- + +## Generated artifacts and provenance + +- [ ] `python3 scripts/governance.py generate` was run when authoritative inputs changed. +- [ ] Generated files were not edited manually. +- [ ] Third-party Actions are pinned to immutable commits. +- [ ] No private inventory, secrets, prompts, memories, user data, or embargoed findings were added. + +## Uncertainty and administrative follow-up + + + +- + +## Checklist + +- [ ] I identified the existing canonical owner before adding a repository, schema, service, database, or control-plane concept. +- [ ] Repository-local implementation truth remains in the owning repository. +- [ ] Pending proposals are not represented as committed or approved state. +- [ ] R3/R4 changes include protected-owner review and exact evidence. +- [ ] No destructive, visibility, archive, transfer, release, publication, or organization-setting action is implied by this PR alone. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..75628b1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "13:00" + timezone: America/Chicago + open-pull-requests-limit: 5 + labels: + - dependencies + - governance + commit-message: + prefix: chore(actions) diff --git a/.github/workflows/governance-ci.yml b/.github/workflows/governance-ci.yml new file mode 100644 index 0000000..284b842 --- /dev/null +++ b/.github/workflows/governance-ci.yml @@ -0,0 +1,27 @@ +name: Governance CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: governance-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: validate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out reviewed source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Run deterministic governance gate + run: ./scripts/agent-check fast diff --git a/.github/workflows/governance-drift.yml b/.github/workflows/governance-drift.yml new file mode 100644 index 0000000..1023014 --- /dev/null +++ b/.github/workflows/governance-drift.yml @@ -0,0 +1,32 @@ +name: Governance drift + +on: + schedule: + - cron: "17 13 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: governance-public-drift + cancel-in-progress: false + +jobs: + reconcile: + name: reconcile-public-inventory + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out governance source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Reconcile public GitHub metadata + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python3 scripts/governance.py reconcile-github + --org OpenCoven + --repository OpenCoven/.github diff --git a/.github/workflows/reusable-agent-readiness.yml b/.github/workflows/reusable-agent-readiness.yml new file mode 100644 index 0000000..a32a351 --- /dev/null +++ b/.github/workflows/reusable-agent-readiness.yml @@ -0,0 +1,544 @@ +name: Reusable OpenCoven agent readiness + +on: + workflow_call: + inputs: + policy_ref: + description: Immutable 40-character commit SHA of OpenCoven/.github + required: true + type: string + manifest_path: + description: Repository-relative agent manifest path + required: false + default: agent/manifest.json + type: string + run_repository_check: + description: Run the target repository's scripts/agent-check fast after manifest validation + required: false + default: true + type: boolean + +permissions: + contents: read + +jobs: + readiness: + name: validate-agent-contract + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Require immutable policy reference + env: + POLICY_REF: ${{ inputs.policy_ref }} + run: | + python3 - <<'PY' + import os, re + value = os.environ["POLICY_REF"] + if not re.fullmatch(r"[0-9a-fA-F]{40}", value): + raise SystemExit("policy_ref must be a full immutable commit SHA") + PY + - name: Check out target repository without credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + path: target + persist-credentials: false + - name: Preflight caller policy binding before policy checkout + env: + POLICY_REF: ${{ inputs.policy_ref }} + CALLER_REPOSITORY: ${{ github.repository }} + CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + REUSABLE_WORKFLOW: reusable-agent-readiness.yml + PATH_INPUT_NAME: manifest_path + RUNTIME_PATH: ${{ inputs.manifest_path }} + DEFAULT_RUNTIME_PATH: agent/manifest.json + run: | + python3 - <<'PY' + import os + import re + from pathlib import Path, PurePosixPath + + # Split the opener so Actions does not evaluate it before Python. + EXPRESSION_START = "$" + "{{" + SHA40 = re.compile(r"^[0-9a-fA-F]{40}$") + EVENT_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") + JOB_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + PLAIN_YAML_KEY = re.compile(r"^[A-Za-z0-9_.-]+$") + JOB_LEVEL_REUSABLE_USE = re.compile( + r"^(?:" + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[^\s{}\[\],#]+" + r"|" + r"\./\.github/workflows/[A-Za-z0-9_.\/-]+\.ya?ml" + r")$" + ) + + def clean_scalar(value): + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + def event_name(value, label): + if not value or value[0] in {"!", ">", "|"}: + raise SystemExit(f"{label}: unsupported event scalar syntax") + if not EVENT_NAME.fullmatch(value): + raise SystemExit(f"{label}: event names must be plain or unescaped quoted ASCII identifiers") + return value + + def is_yaml_content(line): + return bool(line.strip() and not line.lstrip().startswith("#")) + + def strip_yaml_comment(value): + quote = None + escaped = False + for index, char in enumerate(value): + if escaped: + escaped = False + continue + if quote == '"' and char == "\\": + escaped = True + continue + if quote: + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + continue + if char == "#" and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value.rstrip() + + def yaml_key_value(line): + item = yaml_key_value_parts(line) + if not item: + return None + indent, _raw_key, key, _raw_value, value = item + return indent, key, value + + def yaml_key_value_parts(line): + if not line.strip() or line.lstrip().startswith("#"): + return None + if "\t" in line: + raise SystemExit("caller workflow YAML tabs are unsupported") + raw = strip_yaml_comment(line) + key = r"(?:[A-Za-z0-9_.-]+|'[^']+'|\"[^\"]+\")" + match = re.match(rf"^(?P *)(?P{key}):(?P(?:\s+.*)?)$", raw) + if not match: + return None + value = match.group("value") + raw_key = match.group("key") + raw_value = value.strip() if value and value.strip() else None + return ( + len(match.group("indent")), + raw_key, + clean_scalar(raw_key), + raw_value, + clean_scalar(raw_value) if raw_value else None, + ) + + def yaml_sequence_item(line): + if not line.strip() or line.lstrip().startswith("#"): + return None + if "\t" in line: + raise SystemExit("caller workflow YAML tabs are unsupported") + raw = strip_yaml_comment(line) + match = re.match(r"^(?P *)-\s+(?P.+)$", raw) + if not match: + return None + return len(match.group("indent")), clean_scalar(match.group("value")) + + def parse_flow_sequence(value, label): + text = value.strip() + if not text.startswith("[") or not text.endswith("]"): + raise SystemExit(f"{label}: unsupported flow sequence syntax") + inner = text[1:-1].strip() + if not inner: + return [] + items = [] + token = [] + quote = None + escaped = False + for char in inner: + if escaped: + token.append(char) + escaped = False + continue + if quote == '"' and char == "\\": + token.append(char) + escaped = True + continue + if quote: + token.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + token.append(char) + continue + if char == ",": + item = "".join(token).strip() + if not item: + raise SystemExit(f"{label}: empty flow sequence items are unsupported") + items.append(clean_scalar(item)) + token = [] + continue + if char in "{}[]": + raise SystemExit(f"{label}: nested flow YAML is unsupported") + token.append(char) + if quote: + raise SystemExit(f"{label}: unterminated quoted scalar") + item = "".join(token).strip() + if not item: + raise SystemExit(f"{label}: empty flow sequence items are unsupported") + items.append(clean_scalar(item)) + return items + + def top_level_block(lines, key): + found = None + for index, line in enumerate(lines): + item = yaml_key_value_parts(line) + if not item: + continue + indent, raw_key, item_key, _raw_value, value = item + if indent == 0 and item_key == key: + if raw_key != key: + raise SystemExit(f"top-level YAML key must be plain for policy checks: {key}") + if found is not None: + raise SystemExit(f"duplicate top-level YAML key is unsupported: {key}") + block = [] + for child in lines[index + 1:]: + child_item = yaml_key_value(child) + if child_item and child_item[0] == 0: + break + block.append(child) + found = (value, block) + return found if found is not None else (None, []) + + def workflow_declares_workflow_call(lines): + value, block = top_level_block(lines, "on") + events = [] + if value is not None: + if any(is_yaml_content(line) for line in block): + raise SystemExit("caller workflow on: unsupported continuation lines after scalar event declaration") + if value.startswith("{"): + raise SystemExit("caller workflow on: flow mappings are unsupported") + if value.startswith("["): + events.extend(event_name(event, "caller workflow on") for event in parse_flow_sequence(value, "caller workflow on")) + elif any(char in value for char in "{}[]"): + raise SystemExit("caller workflow on: unsupported flow YAML syntax") + else: + events.append(event_name(clean_scalar(value), "caller workflow on")) + else: + entries = [] + for line in block: + sequence = yaml_sequence_item(line) + if sequence: + indent, sequence_value = sequence + entries.append((indent, "sequence", sequence_value, None)) + continue + item = yaml_key_value(line) + if item: + indent, key, item_value = item + entries.append((indent, "mapping", key, item_value)) + continue + if is_yaml_content(line): + raise SystemExit("caller workflow on: unsupported continuation or scalar syntax") + if not entries: + raise SystemExit("caller workflow must declare on using a supported literal event form") + event_indent = min(indent for indent, *_ in entries) + direct = [entry for entry in entries if entry[0] == event_indent] + if len({kind for _, kind, _, _ in direct}) != 1: + raise SystemExit("caller workflow on: mixed sequence and mapping forms are unsupported") + seen = set() + for _, kind, event, event_value in direct: + if event in seen: + raise SystemExit(f"caller workflow on: duplicate event key is unsupported: {event}") + seen.add(event) + if kind == "sequence" and any(char in event for char in "{}[]"): + raise SystemExit("caller workflow on: unsupported sequence item syntax") + if event.startswith(("!", ">", "|")): + raise SystemExit("caller workflow on: unsupported event scalar syntax") + if kind == "mapping" and event_value is not None and event_value.startswith("{"): + raise SystemExit("caller workflow on: flow mappings are unsupported") + if kind == "mapping" and event_value is not None and event_value.startswith(("!", ">", "|")): + raise SystemExit("caller workflow on: unsupported event value scalar syntax") + events.append(event_name(clean_scalar(event), "caller workflow on")) + return "workflow_call" in events + + def contains_yaml_anchor_or_alias(text): + return bool( + re.search(r"(?m)^\s*<<\s*:", text) + or re.search(r"(?", "|", "&", "*")): + raise SystemExit(f"{label}: YAML tags, block scalars, anchors, and aliases are unsupported") + if raw[0] in {"'", '"'}: + raise SystemExit(f"{label}: quoted scalars are unsupported") + if any(char in raw for char in "{}[]"): + raise SystemExit(f"{label}: flow YAML values are unsupported") + + def block_has_yaml_content(block): + return any(is_yaml_content(line) for line in block) + + def direct_child_properties(block, parent_indent, label): + child_items = [] + unsupported_items = [] + for offset, line in enumerate(block): + if not is_yaml_content(line): + continue + indent = line_indent(line) + item = yaml_key_value_parts(line) + if item and item[0] > parent_indent: + child_items.append((offset, *item)) + elif indent > parent_indent: + unsupported_items.append((offset, indent, line.strip())) + if not child_items: + if unsupported_items: + raise SystemExit(f"{label}: unsupported direct job mapping syntax") + return {} + child_indent = min(item[1] for item in child_items) + if any(indent <= child_indent for _offset, indent, _text in unsupported_items): + raise SystemExit(f"{label}: unsupported direct job mapping syntax") + starts = [ + (offset, raw_key, key, raw_value, value) + for offset, indent, raw_key, key, raw_value, value in child_items + if indent == child_indent + ] + result = {} + for index, (offset, raw_key, key, raw_value, value) in enumerate(starts): + if key in result: + raise SystemExit(f"duplicate caller job YAML key is unsupported: {key}") + end = starts[index + 1][0] if index + 1 < len(starts) else len(block) + child_block = block[offset + 1:end] + if key in {"uses", "with", "secrets"}: + validate_plain_security_key(raw_key, key, label) + if key == "uses": + validate_security_scalar(raw_value, value, f"{label}: uses") + if block_has_yaml_content(child_block): + raise SystemExit(f"{label}: uses multiline values are unsupported") + if value and not JOB_LEVEL_REUSABLE_USE.fullmatch(value): + raise SystemExit(f"{label}: uses must be a canonical literal reusable workflow reference") + elif key == "with" and value is not None: + validate_security_scalar(raw_value, value, f"{label}: with") + elif key == "secrets" and value is not None: + validate_security_scalar(raw_value, value, f"{label}: secrets") + result[key] = (value, child_block) + return result + + def mapping_values(block, label): + child_items = [] + unsupported_items = [] + for line in block: + if not is_yaml_content(line): + continue + indent = line_indent(line) + item = yaml_key_value_parts(line) + if item: + child_items.append(item) + else: + unsupported_items.append((indent, line.strip())) + if not child_items: + if unsupported_items: + raise SystemExit(f"{label}: unsupported input mapping syntax") + return {} + child_indent = min(item[0] for item in child_items) + if any(indent <= child_indent for indent, _text in unsupported_items): + raise SystemExit(f"{label}: unsupported input mapping syntax") + result = {} + for indent, raw_key, key, raw_value, value in child_items: + if indent != child_indent: + continue + validate_plain_security_key(raw_key, key, label) + if key in result: + raise SystemExit(f"duplicate caller with input is unsupported: {key}") + validate_security_scalar(raw_value, value, f"{label}.{key}") + result[key] = value + return result + + def job_blocks(lines): + jobs_value, jobs_block = top_level_block(lines, "jobs") + if jobs_value is not None: + raise SystemExit("caller workflow jobs: inline mappings are unsupported") + items = [] + unsupported_items = [] + for offset, line in enumerate(jobs_block): + if not is_yaml_content(line): + continue + indent = line_indent(line) + item = yaml_key_value(line) + if item: + indent, key, value = item + items.append((offset, indent, key, value)) + else: + unsupported_items.append((indent, line.strip())) + if not items: + if unsupported_items: + raise SystemExit("caller workflow jobs: unsupported job mapping syntax") + return [] + job_indent = min(indent for _, indent, _, _ in items) + if any(indent <= job_indent for indent, _text in unsupported_items): + raise SystemExit("caller workflow jobs: unsupported job mapping syntax") + starts = [] + seen = set() + for offset, indent, key, value in items: + if indent != job_indent: + continue + raw_key = yaml_key_value_parts(jobs_block[offset])[1] + if raw_key != key: + raise SystemExit(f"caller workflow jobs: quoted job identifiers are unsupported: {key}") + if not JOB_ID.fullmatch(key): + raise SystemExit(f"caller workflow jobs: unsupported job identifier syntax: {key}") + if key in seen: + raise SystemExit(f"duplicate caller job id is unsupported: {key}") + seen.add(key) + if value is not None: + raise SystemExit(f"caller job {key}: inline job mappings are unsupported") + starts.append((offset, key)) + jobs = [] + for index, (offset, key) in enumerate(starts): + end = starts[index + 1][0] if index + 1 < len(starts) else len(jobs_block) + jobs.append((key, job_indent, jobs_block[offset + 1:end])) + return jobs + + def resolve_caller_file(workflow_path): + parsed = PurePosixPath(workflow_path) + parts = parsed.parts + if parsed.is_absolute() or any(part in {"", ".", ".."} for part in parts): + raise SystemExit("caller workflow path must be repository-relative") + if len(parts) != 3 or parts[:2] != (".github", "workflows") or not parts[-1].endswith((".yml", ".yaml")): + raise SystemExit("caller workflow must be a direct .github/workflows YAML file") + root = Path("target").resolve(strict=True) + current = root + for index, part in enumerate(parts): + current = current / part + if current.is_symlink(): + raise SystemExit(f"caller workflow symlink path component is forbidden: {PurePosixPath(*parts[:index + 1])}") + if not current.exists(): + raise SystemExit("caller workflow file is missing") + if index < len(parts) - 1 and not current.is_dir(): + raise SystemExit(f"caller workflow path component is not a directory: {PurePosixPath(*parts[:index + 1])}") + if not current.is_file(): + raise SystemExit("caller workflow file is not a regular file") + if not current.resolve(strict=True).is_relative_to(root): + raise SystemExit("caller workflow resolved outside target checkout") + return current + + policy_ref = os.environ["POLICY_REF"] + if not SHA40.fullmatch(policy_ref): + raise SystemExit("policy_ref must be a full immutable commit SHA") + caller_repository = os.environ["CALLER_REPOSITORY"] + workflow_ref = os.environ["CALLER_WORKFLOW_REF"] + match = re.fullmatch(r"([^/]+/[^/]+)/(.+)@(.+)", workflow_ref) + if not match or match.group(1) != caller_repository: + raise SystemExit("caller workflow ref must match the runtime caller repository") + caller_file = resolve_caller_file(match.group(2)) + text = caller_file.read_text(encoding="utf-8") + if contains_yaml_anchor_or_alias(text): + raise SystemExit("caller workflow anchors, aliases, and merge keys are unsupported") + lines = text.splitlines() + if workflow_declares_workflow_call(lines): + raise SystemExit("nested reusable workflow callers are unsupported") + expected_uses_prefix = f"OpenCoven/.github/.github/workflows/{os.environ['REUSABLE_WORKFLOW']}@" + matches = [] + for job_id, job_indent, block in job_blocks(lines): + props = direct_child_properties(block, job_indent, f"caller job {job_id}") + uses_value = props.get("uses", (None, []))[0] + if uses_value is None: + continue + if EXPRESSION_START in uses_value: + raise SystemExit(f"caller job {job_id}: expressions are unsupported in uses") + if uses_value.startswith("OpenCoven/.github/.github/workflows/") and not uses_value.startswith(expected_uses_prefix): + raise SystemExit(f"caller job {job_id}: wrong reusable workflow {uses_value!r}") + if uses_value.startswith(expected_uses_prefix): + matches.append((job_id, props, uses_value[len(expected_uses_prefix):])) + if len(matches) != 1: + raise SystemExit(f"expected exactly one direct caller job for {os.environ['REUSABLE_WORKFLOW']}; found {len(matches)}") + job_id, props, uses_ref = matches[0] + if not SHA40.fullmatch(uses_ref): + raise SystemExit(f"caller job {job_id}: reusable workflow ref must be a full immutable commit SHA") + if uses_ref != policy_ref: + raise SystemExit(f"caller job {job_id}: uses ref does not match runtime policy_ref") + if props.get("secrets", (None, []))[0] == "inherit": + raise SystemExit(f"caller job {job_id}: secrets: inherit is forbidden") + with_value, with_block = props.get("with", (None, [])) + if with_value is not None: + raise SystemExit(f"caller job {job_id}: inline with mappings are unsupported") + with_inputs = mapping_values(with_block, f"caller job {job_id}: with") + literal_policy_ref = with_inputs.get("policy_ref") + if literal_policy_ref is None: + raise SystemExit(f"caller job {job_id}: with.policy_ref is required") + if EXPRESSION_START in literal_policy_ref: + raise SystemExit(f"caller job {job_id}: expressions are unsupported in with.policy_ref") + if literal_policy_ref != policy_ref or literal_policy_ref != uses_ref: + raise SystemExit(f"caller job {job_id}: with.policy_ref must match runtime policy_ref and reusable workflow uses ref") + path_input_name = os.environ["PATH_INPUT_NAME"] + literal_path = with_inputs.get(path_input_name) or os.environ.get("DEFAULT_RUNTIME_PATH") or None + if literal_path is None: + raise SystemExit(f"caller job {job_id}: with.{path_input_name} is required") + if EXPRESSION_START in literal_path: + raise SystemExit(f"caller job {job_id}: expressions are unsupported in with.{path_input_name}") + if literal_path != os.environ["RUNTIME_PATH"]: + raise SystemExit(f"caller job {job_id}: with.{path_input_name} does not match runtime input") + PY + - name: Check out immutable governance policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: OpenCoven/.github + ref: ${{ inputs.policy_ref }} + path: governance-policy + persist-credentials: false + - name: Verify immutable governance checkout + env: + POLICY_REF: ${{ inputs.policy_ref }} + run: | + test "$(git -C governance-policy rev-parse HEAD)" = "$POLICY_REF" + - name: Validate reusable caller policy binding + env: + POLICY_REF: ${{ inputs.policy_ref }} + MANIFEST_PATH: ${{ inputs.manifest_path }} + CALLER_REPOSITORY: ${{ github.repository }} + CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + python3 governance-policy/scripts/governance.py validate-reusable-invocation \ + --target-root "$GITHUB_WORKSPACE/target" \ + --caller-workflow-ref "$CALLER_WORKFLOW_REF" \ + --caller-repository "$CALLER_REPOSITORY" \ + --policy-ref "$POLICY_REF" \ + --reusable-workflow reusable-agent-readiness.yml \ + --path-input-name manifest_path \ + --runtime-path "$MANIFEST_PATH" \ + --default-runtime-path agent/manifest.json + - name: Validate repository manifest against public registry + env: + MANIFEST_PATH: ${{ inputs.manifest_path }} + CALLER_REPOSITORY: ${{ github.repository }} + run: | + python3 governance-policy/scripts/governance.py validate-manifest \ + --target-root "$GITHUB_WORKSPACE/target" \ + --caller-repository "$CALLER_REPOSITORY" \ + "$MANIFEST_PATH" + - name: Run repository-native fast check + if: ${{ inputs.run_repository_check }} + working-directory: target + run: ./scripts/agent-check fast diff --git a/.github/workflows/reusable-evidence-packet.yml b/.github/workflows/reusable-evidence-packet.yml new file mode 100644 index 0000000..b7a2f54 --- /dev/null +++ b/.github/workflows/reusable-evidence-packet.yml @@ -0,0 +1,529 @@ +name: Reusable OpenCoven evidence packet + +on: + workflow_call: + inputs: + policy_ref: + description: Immutable 40-character commit SHA of OpenCoven/.github + required: true + type: string + evidence_path: + description: Repository-relative governance evidence JSON path + required: true + type: string + +permissions: + contents: read + +jobs: + evidence: + name: validate-evidence-packet + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Require immutable policy reference + env: + POLICY_REF: ${{ inputs.policy_ref }} + run: | + python3 - <<'PY' + import os, re + if not re.fullmatch(r"[0-9a-fA-F]{40}", os.environ["POLICY_REF"]): + raise SystemExit("policy_ref must be a full immutable commit SHA") + PY + - name: Check out target repository without credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + path: target + persist-credentials: false + - name: Preflight caller policy binding before policy checkout + env: + POLICY_REF: ${{ inputs.policy_ref }} + CALLER_REPOSITORY: ${{ github.repository }} + CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + REUSABLE_WORKFLOW: reusable-evidence-packet.yml + PATH_INPUT_NAME: evidence_path + RUNTIME_PATH: ${{ inputs.evidence_path }} + run: | + python3 - <<'PY' + import os + import re + from pathlib import Path, PurePosixPath + + # Split the opener so Actions does not evaluate it before Python. + EXPRESSION_START = "$" + "{{" + SHA40 = re.compile(r"^[0-9a-fA-F]{40}$") + EVENT_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") + JOB_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + PLAIN_YAML_KEY = re.compile(r"^[A-Za-z0-9_.-]+$") + JOB_LEVEL_REUSABLE_USE = re.compile( + r"^(?:" + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[^\s{}\[\],#]+" + r"|" + r"\./\.github/workflows/[A-Za-z0-9_.\/-]+\.ya?ml" + r")$" + ) + + def clean_scalar(value): + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + def event_name(value, label): + if not value or value[0] in {"!", ">", "|"}: + raise SystemExit(f"{label}: unsupported event scalar syntax") + if not EVENT_NAME.fullmatch(value): + raise SystemExit(f"{label}: event names must be plain or unescaped quoted ASCII identifiers") + return value + + def is_yaml_content(line): + return bool(line.strip() and not line.lstrip().startswith("#")) + + def strip_yaml_comment(value): + quote = None + escaped = False + for index, char in enumerate(value): + if escaped: + escaped = False + continue + if quote == '"' and char == "\\": + escaped = True + continue + if quote: + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + continue + if char == "#" and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value.rstrip() + + def yaml_key_value(line): + item = yaml_key_value_parts(line) + if not item: + return None + indent, _raw_key, key, _raw_value, value = item + return indent, key, value + + def yaml_key_value_parts(line): + if not line.strip() or line.lstrip().startswith("#"): + return None + if "\t" in line: + raise SystemExit("caller workflow YAML tabs are unsupported") + raw = strip_yaml_comment(line) + key = r"(?:[A-Za-z0-9_.-]+|'[^']+'|\"[^\"]+\")" + match = re.match(rf"^(?P *)(?P{key}):(?P(?:\s+.*)?)$", raw) + if not match: + return None + value = match.group("value") + raw_key = match.group("key") + raw_value = value.strip() if value and value.strip() else None + return ( + len(match.group("indent")), + raw_key, + clean_scalar(raw_key), + raw_value, + clean_scalar(raw_value) if raw_value else None, + ) + + def yaml_sequence_item(line): + if not line.strip() or line.lstrip().startswith("#"): + return None + if "\t" in line: + raise SystemExit("caller workflow YAML tabs are unsupported") + raw = strip_yaml_comment(line) + match = re.match(r"^(?P *)-\s+(?P.+)$", raw) + if not match: + return None + return len(match.group("indent")), clean_scalar(match.group("value")) + + def parse_flow_sequence(value, label): + text = value.strip() + if not text.startswith("[") or not text.endswith("]"): + raise SystemExit(f"{label}: unsupported flow sequence syntax") + inner = text[1:-1].strip() + if not inner: + return [] + items = [] + token = [] + quote = None + escaped = False + for char in inner: + if escaped: + token.append(char) + escaped = False + continue + if quote == '"' and char == "\\": + token.append(char) + escaped = True + continue + if quote: + token.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + token.append(char) + continue + if char == ",": + item = "".join(token).strip() + if not item: + raise SystemExit(f"{label}: empty flow sequence items are unsupported") + items.append(clean_scalar(item)) + token = [] + continue + if char in "{}[]": + raise SystemExit(f"{label}: nested flow YAML is unsupported") + token.append(char) + if quote: + raise SystemExit(f"{label}: unterminated quoted scalar") + item = "".join(token).strip() + if not item: + raise SystemExit(f"{label}: empty flow sequence items are unsupported") + items.append(clean_scalar(item)) + return items + + def top_level_block(lines, key): + found = None + for index, line in enumerate(lines): + item = yaml_key_value_parts(line) + if not item: + continue + indent, raw_key, item_key, _raw_value, value = item + if indent == 0 and item_key == key: + if raw_key != key: + raise SystemExit(f"top-level YAML key must be plain for policy checks: {key}") + if found is not None: + raise SystemExit(f"duplicate top-level YAML key is unsupported: {key}") + block = [] + for child in lines[index + 1:]: + child_item = yaml_key_value(child) + if child_item and child_item[0] == 0: + break + block.append(child) + found = (value, block) + return found if found is not None else (None, []) + + def workflow_declares_workflow_call(lines): + value, block = top_level_block(lines, "on") + events = [] + if value is not None: + if any(is_yaml_content(line) for line in block): + raise SystemExit("caller workflow on: unsupported continuation lines after scalar event declaration") + if value.startswith("{"): + raise SystemExit("caller workflow on: flow mappings are unsupported") + if value.startswith("["): + events.extend(event_name(event, "caller workflow on") for event in parse_flow_sequence(value, "caller workflow on")) + elif any(char in value for char in "{}[]"): + raise SystemExit("caller workflow on: unsupported flow YAML syntax") + else: + events.append(event_name(clean_scalar(value), "caller workflow on")) + else: + entries = [] + for line in block: + sequence = yaml_sequence_item(line) + if sequence: + indent, sequence_value = sequence + entries.append((indent, "sequence", sequence_value, None)) + continue + item = yaml_key_value(line) + if item: + indent, key, item_value = item + entries.append((indent, "mapping", key, item_value)) + continue + if is_yaml_content(line): + raise SystemExit("caller workflow on: unsupported continuation or scalar syntax") + if not entries: + raise SystemExit("caller workflow must declare on using a supported literal event form") + event_indent = min(indent for indent, *_ in entries) + direct = [entry for entry in entries if entry[0] == event_indent] + if len({kind for _, kind, _, _ in direct}) != 1: + raise SystemExit("caller workflow on: mixed sequence and mapping forms are unsupported") + seen = set() + for _, kind, event, event_value in direct: + if event in seen: + raise SystemExit(f"caller workflow on: duplicate event key is unsupported: {event}") + seen.add(event) + if kind == "sequence" and any(char in event for char in "{}[]"): + raise SystemExit("caller workflow on: unsupported sequence item syntax") + if event.startswith(("!", ">", "|")): + raise SystemExit("caller workflow on: unsupported event scalar syntax") + if kind == "mapping" and event_value is not None and event_value.startswith("{"): + raise SystemExit("caller workflow on: flow mappings are unsupported") + if kind == "mapping" and event_value is not None and event_value.startswith(("!", ">", "|")): + raise SystemExit("caller workflow on: unsupported event value scalar syntax") + events.append(event_name(clean_scalar(event), "caller workflow on")) + return "workflow_call" in events + + def contains_yaml_anchor_or_alias(text): + return bool( + re.search(r"(?m)^\s*<<\s*:", text) + or re.search(r"(?", "|", "&", "*")): + raise SystemExit(f"{label}: YAML tags, block scalars, anchors, and aliases are unsupported") + if raw[0] in {"'", '"'}: + raise SystemExit(f"{label}: quoted scalars are unsupported") + if any(char in raw for char in "{}[]"): + raise SystemExit(f"{label}: flow YAML values are unsupported") + + def block_has_yaml_content(block): + return any(is_yaml_content(line) for line in block) + + def direct_child_properties(block, parent_indent, label): + child_items = [] + unsupported_items = [] + for offset, line in enumerate(block): + if not is_yaml_content(line): + continue + indent = line_indent(line) + item = yaml_key_value_parts(line) + if item and item[0] > parent_indent: + child_items.append((offset, *item)) + elif indent > parent_indent: + unsupported_items.append((offset, indent, line.strip())) + if not child_items: + if unsupported_items: + raise SystemExit(f"{label}: unsupported direct job mapping syntax") + return {} + child_indent = min(item[1] for item in child_items) + if any(indent <= child_indent for _offset, indent, _text in unsupported_items): + raise SystemExit(f"{label}: unsupported direct job mapping syntax") + starts = [ + (offset, raw_key, key, raw_value, value) + for offset, indent, raw_key, key, raw_value, value in child_items + if indent == child_indent + ] + result = {} + for index, (offset, raw_key, key, raw_value, value) in enumerate(starts): + if key in result: + raise SystemExit(f"duplicate caller job YAML key is unsupported: {key}") + end = starts[index + 1][0] if index + 1 < len(starts) else len(block) + child_block = block[offset + 1:end] + if key in {"uses", "with", "secrets"}: + validate_plain_security_key(raw_key, key, label) + if key == "uses": + validate_security_scalar(raw_value, value, f"{label}: uses") + if block_has_yaml_content(child_block): + raise SystemExit(f"{label}: uses multiline values are unsupported") + if value and not JOB_LEVEL_REUSABLE_USE.fullmatch(value): + raise SystemExit(f"{label}: uses must be a canonical literal reusable workflow reference") + elif key == "with" and value is not None: + validate_security_scalar(raw_value, value, f"{label}: with") + elif key == "secrets" and value is not None: + validate_security_scalar(raw_value, value, f"{label}: secrets") + result[key] = (value, child_block) + return result + + def mapping_values(block, label): + child_items = [] + unsupported_items = [] + for line in block: + if not is_yaml_content(line): + continue + indent = line_indent(line) + item = yaml_key_value_parts(line) + if item: + child_items.append(item) + else: + unsupported_items.append((indent, line.strip())) + if not child_items: + if unsupported_items: + raise SystemExit(f"{label}: unsupported input mapping syntax") + return {} + child_indent = min(item[0] for item in child_items) + if any(indent <= child_indent for indent, _text in unsupported_items): + raise SystemExit(f"{label}: unsupported input mapping syntax") + result = {} + for indent, raw_key, key, raw_value, value in child_items: + if indent != child_indent: + continue + validate_plain_security_key(raw_key, key, label) + if key in result: + raise SystemExit(f"duplicate caller with input is unsupported: {key}") + validate_security_scalar(raw_value, value, f"{label}.{key}") + result[key] = value + return result + + def job_blocks(lines): + jobs_value, jobs_block = top_level_block(lines, "jobs") + if jobs_value is not None: + raise SystemExit("caller workflow jobs: inline mappings are unsupported") + items = [] + unsupported_items = [] + for offset, line in enumerate(jobs_block): + if not is_yaml_content(line): + continue + indent = line_indent(line) + item = yaml_key_value(line) + if item: + indent, key, value = item + items.append((offset, indent, key, value)) + else: + unsupported_items.append((indent, line.strip())) + if not items: + if unsupported_items: + raise SystemExit("caller workflow jobs: unsupported job mapping syntax") + return [] + job_indent = min(indent for _, indent, _, _ in items) + if any(indent <= job_indent for indent, _text in unsupported_items): + raise SystemExit("caller workflow jobs: unsupported job mapping syntax") + starts = [] + seen = set() + for offset, indent, key, value in items: + if indent != job_indent: + continue + raw_key = yaml_key_value_parts(jobs_block[offset])[1] + if raw_key != key: + raise SystemExit(f"caller workflow jobs: quoted job identifiers are unsupported: {key}") + if not JOB_ID.fullmatch(key): + raise SystemExit(f"caller workflow jobs: unsupported job identifier syntax: {key}") + if key in seen: + raise SystemExit(f"duplicate caller job id is unsupported: {key}") + seen.add(key) + if value is not None: + raise SystemExit(f"caller job {key}: inline job mappings are unsupported") + starts.append((offset, key)) + jobs = [] + for index, (offset, key) in enumerate(starts): + end = starts[index + 1][0] if index + 1 < len(starts) else len(jobs_block) + jobs.append((key, job_indent, jobs_block[offset + 1:end])) + return jobs + + def resolve_caller_file(workflow_path): + parsed = PurePosixPath(workflow_path) + parts = parsed.parts + if parsed.is_absolute() or any(part in {"", ".", ".."} for part in parts): + raise SystemExit("caller workflow path must be repository-relative") + if len(parts) != 3 or parts[:2] != (".github", "workflows") or not parts[-1].endswith((".yml", ".yaml")): + raise SystemExit("caller workflow must be a direct .github/workflows YAML file") + root = Path("target").resolve(strict=True) + current = root + for index, part in enumerate(parts): + current = current / part + if current.is_symlink(): + raise SystemExit(f"caller workflow symlink path component is forbidden: {PurePosixPath(*parts[:index + 1])}") + if not current.exists(): + raise SystemExit("caller workflow file is missing") + if index < len(parts) - 1 and not current.is_dir(): + raise SystemExit(f"caller workflow path component is not a directory: {PurePosixPath(*parts[:index + 1])}") + if not current.is_file(): + raise SystemExit("caller workflow file is not a regular file") + if not current.resolve(strict=True).is_relative_to(root): + raise SystemExit("caller workflow resolved outside target checkout") + return current + + policy_ref = os.environ["POLICY_REF"] + if not SHA40.fullmatch(policy_ref): + raise SystemExit("policy_ref must be a full immutable commit SHA") + caller_repository = os.environ["CALLER_REPOSITORY"] + workflow_ref = os.environ["CALLER_WORKFLOW_REF"] + match = re.fullmatch(r"([^/]+/[^/]+)/(.+)@(.+)", workflow_ref) + if not match or match.group(1) != caller_repository: + raise SystemExit("caller workflow ref must match the runtime caller repository") + caller_file = resolve_caller_file(match.group(2)) + text = caller_file.read_text(encoding="utf-8") + if contains_yaml_anchor_or_alias(text): + raise SystemExit("caller workflow anchors, aliases, and merge keys are unsupported") + lines = text.splitlines() + if workflow_declares_workflow_call(lines): + raise SystemExit("nested reusable workflow callers are unsupported") + expected_uses_prefix = f"OpenCoven/.github/.github/workflows/{os.environ['REUSABLE_WORKFLOW']}@" + matches = [] + for job_id, job_indent, block in job_blocks(lines): + props = direct_child_properties(block, job_indent, f"caller job {job_id}") + uses_value = props.get("uses", (None, []))[0] + if uses_value is None: + continue + if EXPRESSION_START in uses_value: + raise SystemExit(f"caller job {job_id}: expressions are unsupported in uses") + if uses_value.startswith("OpenCoven/.github/.github/workflows/") and not uses_value.startswith(expected_uses_prefix): + raise SystemExit(f"caller job {job_id}: wrong reusable workflow {uses_value!r}") + if uses_value.startswith(expected_uses_prefix): + matches.append((job_id, props, uses_value[len(expected_uses_prefix):])) + if len(matches) != 1: + raise SystemExit(f"expected exactly one direct caller job for {os.environ['REUSABLE_WORKFLOW']}; found {len(matches)}") + job_id, props, uses_ref = matches[0] + if not SHA40.fullmatch(uses_ref): + raise SystemExit(f"caller job {job_id}: reusable workflow ref must be a full immutable commit SHA") + if uses_ref != policy_ref: + raise SystemExit(f"caller job {job_id}: uses ref does not match runtime policy_ref") + if props.get("secrets", (None, []))[0] == "inherit": + raise SystemExit(f"caller job {job_id}: secrets: inherit is forbidden") + with_value, with_block = props.get("with", (None, [])) + if with_value is not None: + raise SystemExit(f"caller job {job_id}: inline with mappings are unsupported") + with_inputs = mapping_values(with_block, f"caller job {job_id}: with") + literal_policy_ref = with_inputs.get("policy_ref") + if literal_policy_ref is None: + raise SystemExit(f"caller job {job_id}: with.policy_ref is required") + if EXPRESSION_START in literal_policy_ref: + raise SystemExit(f"caller job {job_id}: expressions are unsupported in with.policy_ref") + if literal_policy_ref != policy_ref or literal_policy_ref != uses_ref: + raise SystemExit(f"caller job {job_id}: with.policy_ref must match runtime policy_ref and reusable workflow uses ref") + path_input_name = os.environ["PATH_INPUT_NAME"] + literal_path = with_inputs.get(path_input_name) or os.environ.get("DEFAULT_RUNTIME_PATH") or None + if literal_path is None: + raise SystemExit(f"caller job {job_id}: with.{path_input_name} is required") + if EXPRESSION_START in literal_path: + raise SystemExit(f"caller job {job_id}: expressions are unsupported in with.{path_input_name}") + if literal_path != os.environ["RUNTIME_PATH"]: + raise SystemExit(f"caller job {job_id}: with.{path_input_name} does not match runtime input") + PY + - name: Check out immutable governance policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: OpenCoven/.github + ref: ${{ inputs.policy_ref }} + path: governance-policy + persist-credentials: false + - name: Verify immutable governance checkout + env: + POLICY_REF: ${{ inputs.policy_ref }} + run: | + test "$(git -C governance-policy rev-parse HEAD)" = "$POLICY_REF" + - name: Validate reusable caller policy binding + env: + POLICY_REF: ${{ inputs.policy_ref }} + EVIDENCE_PATH: ${{ inputs.evidence_path }} + CALLER_REPOSITORY: ${{ github.repository }} + CALLER_WORKFLOW_REF: ${{ github.workflow_ref }} + run: | + python3 governance-policy/scripts/governance.py validate-reusable-invocation \ + --target-root "$GITHUB_WORKSPACE/target" \ + --caller-workflow-ref "$CALLER_WORKFLOW_REF" \ + --caller-repository "$CALLER_REPOSITORY" \ + --policy-ref "$POLICY_REF" \ + --reusable-workflow reusable-evidence-packet.yml \ + --path-input-name evidence_path \ + --runtime-path "$EVIDENCE_PATH" + - name: Validate evidence packet + env: + EVIDENCE_PATH: ${{ inputs.evidence_path }} + run: | + python3 governance-policy/scripts/governance.py validate-evidence \ + --target-root "$GITHUB_WORKSPACE/target" \ + "$EVIDENCE_PATH" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a5bb25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ec93243 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,163 @@ +# Agent instructions — OpenCoven organization governance plane + +## Repository role + +This repository owns OpenCoven's public organization-level governance, portfolio registry, cross-repository initiative records, shared policy, and generated public views. + +It does not own component implementation or protected OpenCoven authority. A task, prompt, issue, plan, model output, project field, registry entry, or agent claim cannot authorize itself. + +## Source precedence + +When sources disagree, use this order: + +1. Safety, privacy, legal, and platform requirements. +2. The principal's current explicit instruction. +3. Current repository code, accepted ADRs, schemas, tests, CI, and GitHub settings evidence. +4. Canonical implementation evidence in the owning OpenCoven repository. +5. Reviewed governance records in this repository. +6. Generated views, Projects, dashboards, issue summaries, and older discussion. + +Generated files and operational views are never write authorities. + +## Canonical OpenCoven boundaries + +Preserve these ownership boundaries: + +- Familiar Contract: governed portable familiar identity and principal binding. +- SPAR: continuity profile/query plane; not another identity root or database. +- Coven Threads: protected authorization and proposal-versus-commit decisions. +- Psyche: project-scoped multi-agent orchestration objects and semantics — tasks, lanes, leases, approvals, receipts, retries, and recovery for coding-agent orchestration. +- Coven: daemon authority, persistence, sessions, runtime execution, authoritative transitions, and the automation lifecycle — definitions and revisions, schedule planning and occurrences, runs and attempts, automation leases and fences, retries and recovery, events and changefeed, artifacts, and receipts. Coven binds Familiar Contract identity and Coven Threads authorization evidence into automation records but does not own those identity or authorization semantics. +- Coven Runtimes: runtime capability descriptors and conformance. +- SDK: constrained public clients and canonical bindings. +- Coven Memory: read-only client/projection; never a second memory authority. +- Cave: primary human oversight product and production UI behavior. +- Psyche Build: multi-lane coding cockpit consuming Psyche canonically. +- Coven Code: terminal coding execution. +- Coven GitHub: GitHub-triggered familiar delivery. +- Brand: canonical visual identity and voice. +- UI: specimen/component laboratory, not production authority. + +Before adding a repository, service, schema, database, control plane, or abstraction, determine whether an existing canonical component owns it. + +## Public-data boundary + +This repository is public. + +Do not add: + +- private repository inventory or confidential project names; +- credentials, tokens, secret values, private endpoints, or recovery material; +- vulnerability details under embargo; +- prompts, memories, user data, terminal dumps, or private paths; +- personal contact data beyond intentionally public GitHub identities; +- confidential commercial, employment, partnership, or legal records. + +Use opaque private-overlay references when public coordination requires acknowledging a private responsibility without revealing it. + +## Editing rules + +Authoritative inputs: + +- `governance/*.json` +- `initiatives/*.json` +- `decisions/*.md` and `decisions/index.json` +- `compatibility/*.json` +- `policies/*.md` +- `schemas/*.json` +- scripts, tests, templates, and workflows + +Derived outputs: + +- `generated/**` + +Never edit `generated/**` directly. Change authoritative input and run: + +```bash +python3 scripts/governance.py generate +``` + +Keep repository-local implementation details in the owning repository. Link to immutable evidence instead of copying mutable plans or test results here. + +## Required checks + +Bootstrap: + +```bash +./scripts/agent-bootstrap +``` + +Fast deterministic gate: + +```bash +./scripts/agent-check fast +``` + +Focused commands: + +```bash +python3 scripts/governance.py validate +python3 scripts/governance.py generate --check +python3 -m unittest discover -s tests -v +``` + +Scheduled GitHub drift reconciliation is networked and intentionally separate: + +```bash +python3 scripts/governance.py reconcile-github --org OpenCoven --repository OpenCoven/.github --dry-run +``` + +Never run the mutating reconciliation mode with an unreviewed token or from untrusted pull-request code. + +## Risk and authority + +Risk classes are defined in `governance/lifecycle.json`: + +- R0: documentation and copy. +- R1: pure code without external state. +- R2: local mutable state or migrations. +- R3: network, credentials, user data, or remote APIs. +- R4: identity, authorization, persistence, release, deletion, or organization administration. + +Governance, workflow, schema, compatibility, lifecycle, and decision paths are R4 for review purposes because errors can alter organization-wide coordination or enforcement. This risk label does not grant protected runtime authority. + +Prefer Permit / Degrade to Proposal / Reject. Fail closed at identity, authorization, persistence, release, publication, and organization-administration boundaries. + +## Agent-authored changes + +Every nontrivial agent-authored PR must include: + +- objective, acceptance criteria, and non-goals; +- authoritative sources consulted; +- files intentionally touched; +- ownership and authority impact; +- exact tests and results; +- migration and rollback; +- generated outputs and provenance; +- unresolved uncertainty and administrative follow-up. + +Do not claim a control is enforced merely because policy text exists. Distinguish specified, implemented, verified, administratively applied, and operationally effective. + +## GitHub administration + +Repository content cannot by itself install organization rulesets, protect environments, restrict app scopes, enforce MFA, or establish break-glass custody. Track those actions separately and require settings snapshots or API evidence. + +Do not: + +- merge, release, deploy, publish, delete, transfer, archive, change visibility, or alter organization settings without explicit authorization; +- weaken a check to make CI green; +- expose secrets to fork pull requests; +- grant broad workflow permissions when a narrower permission works; +- use mutable third-party Action tags when an immutable commit can be pinned; +- let an administrative reconciler apply a plan that was not bound to the reviewed repository state. + +## Completion standard + +A change is complete only when: + +- authoritative and derived records agree; +- required deterministic checks pass; +- cross-repository references are valid or explicitly unresolved; +- security and privacy boundaries remain intact; +- any unsupported administrative action is recorded as an open gate rather than described as done; +- the handoff names exact commits, checks, remaining risks, and skipped evidence. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0cea5dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Valentina Alexander + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8b13789..4b14f8b 100644 --- a/README.md +++ b/README.md @@ -1 +1,149 @@ +# OpenCoven organization governance plane +`OpenCoven/.github` is the canonical **public organization-governance and portfolio-coordination plane** for OpenCoven. + +It answers four organization-level questions: + +1. Which public repositories exist, why do they exist, and what lifecycle are they in? +2. Which repository owns each canonical public domain? +3. Which cross-repository outcomes, decisions, dependencies, and evidence are currently in force? +4. Which shared policies and verification contracts apply across the organization? + +It does **not** replace implementation repositories or OpenCoven's protected authority systems. + +> Organization metadata coordinates work and records evidence. It never grants familiar identity authority, protected mutation authority, orchestration authority, daemon authority, release authority, or publication authority merely because a file, issue, project field, task, prompt, or model output says so. + +## Authority model + +| Concern | Canonical source | +|---|---| +| Public repository purpose, lifecycle, ownership, and disposition | `governance/repositories.json` | +| Cross-repository outcome, DRI, workstreams, dependencies, and exit criteria | `initiatives/*.json` | +| Organization-spanning decisions | `decisions/` | +| Public contract/dependency index | `compatibility/` | +| Shared policy and control intent | `policies/` and `governance/controls.json` | +| Repository implementation, tests, migrations, release evidence, and component ADRs | The owning repository | +| Runtime identity, authorization, orchestration, persistence, and commit decisions | Familiar Contract, Coven Threads, Psyche, Coven, and their canonical artifacts | +| Operational portfolio views | GitHub Issues/Projects and generated files; never an independent authority | + +The governance plane is intentionally federated at implementation boundaries: central records define organization-level ownership and coordination, while repository-local manifests and evidence prove what is actually implemented. + +## Public/private boundary + +This repository is public. Its registry therefore inventories **public repositories only**. Private repository names, incidents, credentials, user data, prompts, memories, unpublished security findings, and confidential plans must remain in private repository-local manifests or access-controlled operational views. + +A public record may state that a responsibility is resolved by a private overlay without naming or copying that overlay. See [`policies/public-private-data.md`](policies/public-private-data.md). + +## Deterministic verification + +The fast path has no third-party Python dependencies and performs no network access: + +```bash +./scripts/agent-bootstrap +./scripts/agent-check fast +``` + +It validates: + +- repository-registry structure and unique canonical ownership; +- lifecycle, successor, DRI, risk, and manifest-adoption invariants; +- initiative, decision, dependency, control, exception, and evidence schemas; +- generated portfolio outputs; +- workflow permission and immutable-action-pin policy; +- public/private and secret-like-data safeguards; +- this repository's own agent manifest; +- negative regression fixtures through unit tests. + +The scheduled drift workflow separately compares the declared public inventory with GitHub's public repository metadata and maintains one deduplicated drift issue. + +### Reusable workflow validation + +The public reusable workflows are intentionally narrow. Callers must invoke +`OpenCoven/.github/.github/workflows/reusable-agent-readiness.yml@<40-hex-sha>` +or `OpenCoven/.github/.github/workflows/reusable-evidence-packet.yml@<40-hex-sha>` +directly from a workflow file that is a direct child of `.github/workflows/`. +The caller job's literal `with.policy_ref` must equal the exact SHA used in +`uses`, the literal checked path input must match the runtime input (or the +documented `manifest_path` default), and the runtime `policy_ref` input must +match both before the governance-policy checkout is used. The bootstrap guard +accepts only a narrow literal caller profile for policy-sensitive fields: +plain or unescaped quoted ASCII event identifiers in scalar, block-mapping, +block-sequence, or flow-sequence `on` events under a plain top-level `on` +key; direct block-mapping `jobs` under a plain top-level `jobs` key; plain +job identifiers; plain direct job-level `uses` keys whose values are canonical +literal reusable-workflow references; and plain direct block-mapping `with` +inputs on the one relevant reusable caller job. Mutable branches, tags, +malformed refs, nested reusable callers, expressions in the checked inputs, +YAML block scalars, multiline scalar continuations, flow mappings, YAML tags, +quoted policy-sensitive keys, quoted job-level `uses`/`with` values, escaped +event scalars, anchors, aliases, merge keys, duplicate/ambiguous caller jobs +or event keys, and `secrets: inherit` are rejected closed rather than +interpreted. Any unsupported direct job-level `uses` syntax is rejected before +the guard filters for the expected OpenCoven reusable target, so a malformed +actual caller cannot be hidden behind a later decoy job. + +Repository-provided paths are always treated as data. The reusable workflows +pass `manifest_path` and `evidence_path` through environment variables and +quoted shell variables, then `scripts/governance.py` resolves them against the +trusted checkout root as repository-relative files. Absolute paths, +traversal, control characters, symlink components/files, directories, missing +files, and special files are rejected; evidence packets must be JSON files +below `evidence/`. + +Local manifest validation can still be run without a GitHub caller context by +using the explicit local safe mode: + +```bash +python3 scripts/governance.py validate-manifest \ + --target-root . \ + --local-self-declared-repository \ + agent/manifest.json +``` + +That mode keeps the same trusted path checks and registry comparison, but is +forbidden inside GitHub Actions. Reusable workflows must pass +`--caller-repository "$GITHUB_REPOSITORY"` so the registry entry is selected +from GitHub's caller identity, never from the manifest's self-declared name. + +## Repository map + +```text +agent/ This repository's machine-readable agent contract +governance/ Public portfolio registry, controls, lifecycle, exceptions +initiatives/ Cross-repository outcomes and responsibility assignments +decisions/ Organization-spanning ADRs and decision index +compatibility/ Public dependency, contract, and release-train indexes +policies/ Normative organization-governance procedures +docs/ Operating model, administration, mappings, and rollout +generated/ Deterministic views; never edit by hand +schemas/ JSON Schemas for exchanged governance records +scripts/ Dependency-free validation, generation, and reconciliation +tests/ Red-to-green governance invariant tests +.github/ Review templates, issue forms, and least-privilege workflows +``` + +## Change procedure + +1. Identify the canonical owner before proposing a new repository, schema, service, database, or control plane. +2. Change the smallest authoritative record; do not duplicate repository-local truth here. +3. Include an evidence packet describing objective, non-goals, authority impact, tests, migration, rollback, and uncertainty. +4. Regenerate derived views with `python3 scripts/governance.py generate`. +5. Run `./scripts/agent-check fast`. +6. Merge only through the protected review path once the administrative hardening gate is complete. + +Temporary exceptions must be typed, owner-approved, narrowly scoped, and expiring. See [`policies/exceptions.md`](policies/exceptions.md). + +## Current activation state + +The files in this repository can establish **specified and verified repository-level policy**. They do not prove that GitHub organization settings match the policy. Activation therefore has two gates: + +- **Repository gate:** schema, validation, generated views, and CI are merged and green. +- **Administrative gate:** branch/ruleset, Actions, app, environment, and break-glass settings are applied and independently evidenced. + +Until both gates close, the governance plane is authoritative for reviewed public portfolio records but is not an independently verified GitHub-administration enforcement boundary. + +## Related work + +- Governance-plane activation: `OpenCoven/.github#5` +- Administrative hardening: `OpenCoven/.github#6` +- Advanced reusable automation conformance: `OpenCoven/.github#2` diff --git a/agent/manifest.json b/agent/manifest.json new file mode 100644 index 0000000..64851c3 --- /dev/null +++ b/agent/manifest.json @@ -0,0 +1,64 @@ +{ + "$schema": "../schemas/agent-manifest.schema.json", + "schema_version": "opencoven.agent-repo/v1", + "repository": { + "name": ".github", + "lifecycle": "active", + "canonicality": "canonical", + "canonical_for": [ + "organization.governance", + "organization.portfolio", + "organization.shared-policy" + ], + "does_not_own": [ + "familiar.identity", + "protected.authorization", + "project-orchestration", + "runtime.persistence", + "runtime.execution", + "release.approval", + "publication.approval", + "automation.lifecycle" + ], + "owner": "BunsDev", + "technical_dri": "BunsDev", + "ownership_status": "bootstrap-single-owner" + }, + "risk": { + "class": "R4", + "protected_paths": [ + "governance/**", + "initiatives/**", + "decisions/**", + "compatibility/**", + "policies/**", + "schemas/**", + "scripts/**", + ".github/workflows/**", + ".github/CODEOWNERS" + ], + "generated_paths": ["generated/**"], + "network_policy": "deny-by-default", + "secrets_policy": "forbidden-in-repository", + "external_side_effects": [ + "issue-reconciliation", + "repository-administration-proposal" + ] + }, + "agent": { + "entrypoint": "AGENTS.md", + "bootstrap": "./scripts/agent-bootstrap", + "verify": { + "fast": "./scripts/agent-check fast", + "full": "./scripts/agent-check full" + } + }, + "contracts": { + "produces": [ + "opencoven.repository-registry.v1", + "opencoven.initiative.v1", + "opencoven.governance-evidence.v1" + ], + "consumes": [] + } +} diff --git a/compatibility/contracts.json b/compatibility/contracts.json new file mode 100644 index 0000000..fe2e2ad --- /dev/null +++ b/compatibility/contracts.json @@ -0,0 +1,43 @@ +{ + "$schema": "../schemas/contracts.schema.json", + "schema_version": "opencoven.contract-index/v1", + "contracts": [ + { + "id": "familiar.contract.v1", + "owner": "familiar-contract", + "status": "specified", + "immutable_release_required": true + }, + { + "id": "threads.protected-decision.v1", + "owner": "coven-threads", + "status": "specified", + "immutable_release_required": true + }, + { + "id": "psyche.control.v1", + "owner": "psyche", + "status": "specified", + "immutable_release_required": true + }, + { + "id": "coven.daemon.v1", + "owner": "coven", + "status": "implemented", + "immutable_release_required": true + }, + { + "id": "coven.runtime-descriptor.v1", + "owner": "coven-runtimes", + "status": "implemented", + "immutable_release_required": true + }, + { + "id": "opencoven.repository-registry.v1", + "owner": ".github", + "status": "implemented-pending-merge", + "immutable_release_required": false + } + ], + "claim_rule": "A listed contract status is coordination metadata. A security, privacy, continuity, interoperability, or full-conformance claim requires exact artifact and profile evidence from the owning repository." +} diff --git a/compatibility/dependencies.json b/compatibility/dependencies.json new file mode 100644 index 0000000..930d2f8 --- /dev/null +++ b/compatibility/dependencies.json @@ -0,0 +1,96 @@ +{ + "$schema": "../schemas/dependencies.schema.json", + "schema_version": "opencoven.dependencies/v1", + "edges": [ + { + "producer": "familiar-contract", + "consumer": "coven-threads", + "relationship": "identity-contract", + "required_evidence": "immutable-contract-and-vectors" + }, + { + "producer": "familiar-contract", + "consumer": "psyche", + "relationship": "identity-snapshot", + "required_evidence": "exact-root-and-revision-binding" + }, + { + "producer": "familiar-contract", + "consumer": "coven", + "relationship": "session-identity-binding", + "required_evidence": "real-daemon-conformance" + }, + { + "producer": "coven-threads", + "consumer": "coven", + "relationship": "protected-decision", + "required_evidence": "atomic-verify-and-commit" + }, + { + "producer": "psyche", + "consumer": "coven", + "relationship": "orchestrated-work", + "required_evidence": "task-lane-lease-receipt-canary" + }, + { + "producer": "coven-runtimes", + "consumer": "coven", + "relationship": "runtime-descriptor", + "required_evidence": "registry-digest-and-conformance" + }, + { + "producer": "coven", + "consumer": "sdk", + "relationship": "public-client-contract", + "required_evidence": "packed-artifact-canary" + }, + { + "producer": "coven", + "consumer": "coven-memory", + "relationship": "read-only-memory-projection", + "required_evidence": "mutation-negative-vectors" + }, + { + "producer": "coven", + "consumer": "coven-cave", + "relationship": "oversight-api", + "required_evidence": "real-daemon-e2e" + }, + { + "producer": "psyche", + "consumer": "psyche-build", + "relationship": "orchestration-client", + "required_evidence": "golden-workflow-receipts" + }, + { + "producer": "coven", + "consumer": "coven-code", + "relationship": "terminal-execution", + "required_evidence": "headless-brief-result-canary" + }, + { + "producer": "brand", + "consumer": "ui", + "relationship": "specimen-token-consumer", + "required_evidence": "immutable-brand-lock" + }, + { + "producer": "brand", + "consumer": "coven-cave", + "relationship": "production-brand-consumer", + "required_evidence": "immutable-brand-lock-and-ui-canary" + }, + { + "producer": "brand", + "consumer": "coven-landing", + "relationship": "public-web-brand-consumer", + "required_evidence": "canonical-profile-lock" + }, + { + "producer": "sdk", + "consumer": "coven-docs", + "relationship": "documented-public-client", + "required_evidence": "released-artifact-example-test" + } + ] +} diff --git a/compatibility/release-trains.json b/compatibility/release-trains.json new file mode 100644 index 0000000..6089a05 --- /dev/null +++ b/compatibility/release-trains.json @@ -0,0 +1,32 @@ +{ + "$schema": "../schemas/release-trains.schema.json", + "schema_version": "opencoven.release-trains/v1", + "release_trains": [ + { + "id": "trust-stack", + "members": [ + "familiar-contract", + "coven-threads", + "psyche", + "coven", + "coven-runtimes", + "sdk", + "coven-memory" + ], + "policy": "Compatibility changes require immutable producer artifacts and pinned downstream canaries before a coordinated claim." + }, + { + "id": "product-delivery", + "members": [ + "coven", + "psyche", + "sdk", + "coven-code", + "psyche-build", + "coven-cave", + "coven-docs" + ], + "policy": "Product release sequencing is derived from owning-repository evidence; this index does not approve releases." + } + ] +} diff --git a/decisions/ADR-0001-organization-governance-plane.md b/decisions/ADR-0001-organization-governance-plane.md new file mode 100644 index 0000000..8cb1136 --- /dev/null +++ b/decisions/ADR-0001-organization-governance-plane.md @@ -0,0 +1,114 @@ +# ADR-0001: Use `OpenCoven/.github` as the public organization governance plane + +- **Status:** Proposed; becomes Accepted when merged through the protected review path +- **Date:** 2026-09-03 +- **Decision owner:** BunsDev +- **Technical DRI:** BunsDev +- **Scope:** Public organization governance, portfolio coordination, and shared verification + +## Context + +OpenCoven spans identity, protected authorization, orchestration, daemon/runtime authority, clients, products, delivery, documentation, and brand repositories. Cross-repository plans and responsibility can drift when maintained independently in issues, documents, chats, dashboards, or repository-local roadmaps. + +The organization needs one durable answer for public repository purpose, lifecycle, canonical domain ownership, cross-repository initiatives, shared policy, compatibility relationships, and generated portfolio views. It must not create another runtime control plane or duplicate repository-local implementation truth. + +`OpenCoven/.github` already has organization-wide GitHub semantics for community files and reusable workflows. It is public, discoverable, versioned, reviewable, and portable as ordinary Git data. It is also high impact and therefore requires stronger administrative protection than its current unprotected `main` branch. + +## Decision + +Use `OpenCoven/.github` as the canonical **public organization-governance and portfolio-coordination plane**. + +It owns: + +- the public repository registry, lifecycle, public canonical-domain map, and disposition plan; +- cross-repository initiatives, organization ADRs, dependency/contract indexes, and shared policy; +- schemas, deterministic validation, reusable read-only verification workflows, drift reconciliation, and generated public views; +- coordination evidence for GitHub administration, without claiming the settings are applied until independently verified. + +It does not own: + +- component implementation, component ADRs, migrations, tests, releases, or repository-local evidence; +- private repository inventory or confidential operational context; +- familiar identity, protected authorization, orchestration, runtime persistence/execution, release approval, or publication approval; +- a manually maintained duplicate of Issues, Pull Requests, Projects, or runtime state. + +Private repositories remain federated through repository-local manifests and access-controlled operational views. Public records may use opaque private-overlay identifiers without revealing private inventory. + +GitHub Projects is the preferred operational presentation for cross-repository work, but its fields are derived coordination views. Files in Git remain authoritative for organization policy, ownership, lifecycle, initiative definition, and accepted decisions; repository issues and artifacts remain authoritative for implementation and evidence. + +## Alternatives considered + +### New dedicated governance repository + +Rejected for now. It would add another repository and discovery surface without materially improving separation. Revisit only if `.github` special-repository coupling, scale, confidentiality, or availability becomes a measured constraint that cannot be mitigated. + +### Documentation repository as the control plane + +Rejected. Documentation should present generated compatibility and policy information, not become the write authority for repository administration and portfolio ownership. + +### GitHub Projects or Issues as the sole authority + +Rejected. They are useful operational views but weaker for schema validation, immutable review, portable history, deterministic generation, and offline inspection. They also encourage manually duplicated status. + +### Backstage or another service catalog as primary authority + +Deferred. A catalog may consume the registry when the organization has enough scale to justify operating it. It must remain a projection unless separately ratified. + +### Monorepo consolidation + +Rejected as a governance solution. Some code may consolidate for technical reasons, but a monorepo does not resolve protected authority, ownership, release, or cross-product lifecycle boundaries and would create a large migration/blast radius. + +### Pure repository-local federation + +Rejected as insufficient. Repository-local truth remains necessary, but without a central ownership and initiative index it cannot reliably detect duplicate canonical claims, unowned repositories, or portfolio drift. + +## Consequences + +Positive: + +- one discoverable public ownership and portfolio source; +- reviewable, machine-readable policy and history; +- generated rather than manually recopied portfolio views; +- explicit canonical/derived and public/private boundaries; +- reusable agent and CI contracts without adding another service. + +Costs and risks: + +- `.github` becomes a high-impact supply-chain and governance target; +- central changes can create organization-wide noise or bottlenecks; +- the public repository cannot contain private operational detail; +- declarative records can diverge from actual GitHub settings; +- schemas can ossify if evolution and exceptions are not governed. + +Mitigations: + +- protect `main`, workflows, schemas, decisions, and registry paths through rulesets and CODEOWNERS; +- default workflows to read-only and pin third-party Actions by commit; +- separate read-only drift observation from privileged plan-bound reconciliation; +- reconcile declared state against GitHub APIs and owning-repository evidence; +- use expiring exceptions and versioned schemas; +- retain implementation and protected authority in canonical repositories. + +## Activation criteria + +This decision is active only after: + +1. repository validation and generated-view checks pass; +2. the governance-plane PR is reviewed and merged; +3. `.github/main` is protected by required review and `Governance CI / validate`; +4. organization Actions/app/environment/break-glass controls are evidenced; +5. at least two canonical repositories consume the reusable readiness workflow at an immutable revision. + +Before criteria 3–4, records are reviewed coordination truth but not independently verified GitHub-administration enforcement. + +## Revisit triggers + +Re-evaluate the repository choice when any of these persist for two review cycles: + +- public/private separation prevents necessary coordination; +- registry or initiative review regularly blocks unrelated delivery; +- generated artifacts or Git history become operationally unmanageable; +- `.github` outages or special semantics materially impair governance availability; +- more than 100 active repositories or multiple autonomous governance domains require delegated catalogs; +- a service catalog provides measured value that outweighs its operational and duplicate-truth risk; +- ruleset, workflow, or app blast radius cannot be reduced to the accepted risk tolerance. diff --git a/decisions/ADR-0002-governance-metadata-is-not-protected-authority.md b/decisions/ADR-0002-governance-metadata-is-not-protected-authority.md new file mode 100644 index 0000000..41d8408 --- /dev/null +++ b/decisions/ADR-0002-governance-metadata-is-not-protected-authority.md @@ -0,0 +1,19 @@ +# ADR-0002: Governance metadata is coordination and evidence, not protected authority + +- **Status:** Proposed; becomes Accepted when merged +- **Date:** 2026-09-03 + +## Decision + +No organization registry, initiative, ADR, issue, Project field, task text, prompt, model response, agent manifest, CI output, or caller-supplied field may grant itself protected OpenCoven authority. + +Protected changes continue to require the operation-specific canonical authority and atomic enforcement owned by Familiar Contract, Coven Threads, Psyche, Coven, release systems, or repository administration as applicable. + +Governance records may identify required approvers, evidence, and intended state. They become effective write gates only where a separately authenticated enforcement mechanism binds the exact reviewed record to the operation and fails closed on moved or revoked state. + +## Consequences + +- Pending proposals cannot appear as committed state. +- “Approved” metadata without authenticated, operation-specific enforcement is descriptive only. +- Agents must degrade unverified protected requests to proposals rather than execute them. +- CI success does not prove runtime security, privacy, continuity, legal compliance, or human authorization. diff --git a/decisions/ADR-0003-public-registry-private-federation.md b/decisions/ADR-0003-public-registry-private-federation.md new file mode 100644 index 0000000..0162db1 --- /dev/null +++ b/decisions/ADR-0003-public-registry-private-federation.md @@ -0,0 +1,16 @@ +# ADR-0003: Keep the central registry public and federate private overlays + +- **Status:** Proposed; becomes Accepted when merged +- **Date:** 2026-09-03 + +## Decision + +`governance/repositories.json` inventories public repositories only. Private repositories publish the same repository-manifest contract locally and participate through access-controlled Projects, issues, evidence, or a future approved private projection. + +The public plane may refer to an opaque private overlay by capability identifier, but it must not disclose private repository names, incident details, credentials, user data, prompts, memories, private paths, or confidential plans. + +Aggregation must preserve provenance and access controls. A private projection may consume the public registry; the public registry must never infer or mirror private data back into public output. + +## Consequences + +The public portfolio is transparent and independently verifiable without turning `.github` into a confidentiality hazard. Organization-wide views spanning private work require an authenticated projection and cannot be reconstructed from public files alone. diff --git a/decisions/README.md b/decisions/README.md new file mode 100644 index 0000000..4868eb6 --- /dev/null +++ b/decisions/README.md @@ -0,0 +1,7 @@ +# Organization decisions + +This directory contains decisions whose scope crosses repository ownership boundaries. Component-local implementation ADRs stay in the owning repository. + +A decision record is `proposed` until merged through the protected review path. Acceptance coordinates organization behavior; it does not by itself authorize protected runtime changes, releases, publication, or GitHub administration. + +Superseded decisions are retained with links to their successors. `decisions/index.json` is the machine-readable index. diff --git a/decisions/index.json b/decisions/index.json new file mode 100644 index 0000000..bc6b034 --- /dev/null +++ b/decisions/index.json @@ -0,0 +1,27 @@ +{ + "$schema": "../schemas/decision-index.schema.json", + "schema_version": "opencoven.decision-index/v1", + "decisions": [ + { + "id": "ADR-0001", + "title": "Use OpenCoven/.github as the public organization governance plane", + "status": "proposed", + "path": "decisions/ADR-0001-organization-governance-plane.md", + "date": "2026-09-03" + }, + { + "id": "ADR-0002", + "title": "Governance metadata is coordination and evidence, not protected authority", + "status": "proposed", + "path": "decisions/ADR-0002-governance-metadata-is-not-protected-authority.md", + "date": "2026-09-03" + }, + { + "id": "ADR-0003", + "title": "Keep the central registry public and federate private overlays", + "status": "proposed", + "path": "decisions/ADR-0003-public-registry-private-federation.md", + "date": "2026-09-03" + } + ] +} diff --git a/docs/administration-baseline.md b/docs/administration-baseline.md new file mode 100644 index 0000000..91a8149 --- /dev/null +++ b/docs/administration-baseline.md @@ -0,0 +1,60 @@ +# GitHub administration baseline + +This document is an implementation checklist for organization settings. Repository content cannot enforce these controls by itself. `OpenCoven/.github#6` is the authoritative activation gate until exact settings evidence is recorded. + +## `.github/main` ruleset + +Required: + +- pull request before merge; +- at least one approval and CODEOWNER review for protected paths; +- stale approval dismissal after new commits; +- resolved review conversations; +- required `Governance CI / validate` check; +- blocked force push and branch deletion; +- no routine administrator bypass; +- signed commits or equivalent verified provenance where operationally supportable; +- exported ruleset ID/configuration retained as evidence. + +## Organization permissions + +Review and minimize: + +- base member repository permission; +- repository creation and visibility-change rights; +- archive, transfer, deletion, ruleset, webhook, App, secret, environment, and runner administration; +- outside collaborators and dormant administrators; +- OAuth Apps, GitHub Apps, deploy keys, classic PATs, and machine users; +- branch/ruleset bypass lists. + +Require organization-member MFA. Prefer hardware-backed MFA for owners and break-glass custodians. + +## Actions + +- Default `GITHUB_TOKEN` to read-only. +- Allow only required Actions and reusable workflows. +- Pin third-party Actions to full commit SHAs and review automated updates. +- Disable or constrain workflows from forks that could access secrets or privileged runners. +- Use protected environments for publication and administrative reconciliation. +- Prefer OIDC and short-lived GitHub App installation tokens over long-lived secrets. +- Separate untrusted build/test from privileged signing, publication, or settings application. + +## Settings reconciliation + +A future administrative reconciler must have two modes: + +1. **plan**: read settings, compare against reviewed desired state, and emit a deterministic immutable plan; +2. **apply**: require protected-environment approval, verify the plan digest and live-state preconditions, apply only listed changes, and emit before/after receipts. + +The apply identity must not accept arbitrary repository, permission, or operation fields from pull-request code. It must stop on moved, stale, revoked, or contradictory state. + +## Recovery exercise + +At least periodically prove: + +- repository and accepted-policy export; +- ruleset reconstruction; +- App/token revocation; +- organization-owner recovery; +- release channel and package ownership recovery; +- break-glass access followed by log review and credential rotation. diff --git a/docs/github-projects-integration.md b/docs/github-projects-integration.md new file mode 100644 index 0000000..682ffde --- /dev/null +++ b/docs/github-projects-integration.md @@ -0,0 +1,46 @@ +# GitHub Issues and Projects integration + +## Division of authority + +| Data | Authoritative location | Project treatment | +|---|---|---| +| Repository lifecycle, canonicality, public domain ownership, risk | `governance/repositories.json` | Read-only generated fields/labels | +| Initiative outcome, decision owner, technical DRI, dependencies, exit criteria | `initiatives/*.json` | Synced view and filtering | +| Implementation status, code, review, tests | Owning repository issue/PR/CI | Native issue/PR fields | +| Accepted organization decision | `decisions/` | Link only | +| Compatibility/release evidence | Owning repository artifact plus `compatibility/` index | Digest/profile summary only | +| Immediate prioritization and attention | Project | Operational; not copied back as normative truth unless reviewed | + +## Recommended Project fields + +- Initiative ID +- Canonical domain +- Owning repository +- Workstream driver +- Decision owner +- Technical DRI +- Risk class +- Lifecycle +- Current gate +- Dependency state +- Evidence state +- Target release +- Last verified revision +- Stale/degraded flag + +Avoid manually maintained percent-complete fields. Compute status from linked workstream issues, required checks, and explicit exit criteria. + +## Synchronization contract + +The safe direction is: + +```text +Git files + owning-repository evidence → generated Project fields +Project prioritization/assignment → human-reviewed PR when normative records must change +``` + +A Project automation may propose a registry or initiative update, but must not commit a protected change directly. Duplicate issue creation should be prevented with stable initiative/workstream identifiers. + +## Private work + +Use an access-controlled Project for private-repository workstreams. The public initiative may contain an opaque private-overlay identifier, but synchronization must not copy private titles, descriptions, assignees, labels, paths, or evidence into the public repository. diff --git a/docs/operating-model.md b/docs/operating-model.md new file mode 100644 index 0000000..794d8d2 --- /dev/null +++ b/docs/operating-model.md @@ -0,0 +1,81 @@ +# OpenCoven cross-repository operating model + +## Purpose + +The governance plane centralizes **organization-level context** without centralizing implementation authority. It provides one public ownership map, one initiative definition per cross-repository outcome, one accepted decision trail, and deterministic aggregate views. + +## Responsibility model + +| Role | Accountable for | Not sufficient for | +|---|---|---| +| Decision owner | Outcome, scope, priority, conflict resolution, and acceptance of organization-level tradeoffs | Protected runtime authorization or GitHub administration | +| Technical DRI | Coordinated technical delivery, dependency sequencing, evidence completeness, and handoff | Unreviewed merge, release, publication, or destructive action | +| Repository owner | Repository purpose, lifecycle, maintainership, and successor planning | Another repository's canonical implementation | +| Canonical-domain owner | Normative artifacts and invariant enforcement for the named domain | Self-expansion into adjacent domains without review | +| Workstream driver | Implementation issue/PR and repository-local verification | Changing the central outcome alone | +| Protected owner/approver | Review at an R3/R4 boundary | Authority outside the exact operation and system | +| GitHub administrator | Organization/repository settings under authenticated access | Familiar, Threads, Psyche, Coven, release, or publication authority | + +RACI tables may be generated for presentations, but the machine-readable records use one accountable decision owner and one technical DRI to avoid diffuse responsibility. Contributors and consulted parties stay in repository issues/Projects rather than a static central list. + +## Source-of-truth split + +```text +Organization outcome and ownership + └── .github initiative / ADR / public registry + ├── owning-repository issue and PR + ├── immutable contract/artifact revision + ├── exact CI, real-daemon, or packaged evidence + └── generated Project/dashboard view +``` + +- Central files answer **why**, **who**, **which owner**, **which dependency**, and **which exit evidence**. +- Owning repositories answer **how**, **what code**, **what test**, **what migration**, and **what release**. +- GitHub settings evidence answers **whether administrative controls are actually applied**. +- Projects answer **what needs attention now** and remain replaceable views. + +## Review cadence + +- P0 initiatives: review at least weekly while active. +- P1 initiatives: review at least biweekly. +- Active public repositories: lifecycle review at least quarterly. +- Incubating, maintenance, and deprecated repositories: review on the shorter cadence encoded in `governance/lifecycle.json`. +- R3/R4 administrative and compatibility controls: scheduled drift plus periodic effectiveness testing. + +The `review_by` field is a fail-closed prompt for reassessment, not an automatic state transition. + +## Cross-repository change protocol + +1. Identify the canonical producer and all affected consumers. +2. Open or update the organization initiative only when the shared outcome or ownership changes. +3. Make implementation changes in owning repositories. +4. Version the canonical schema/contract and publish immutable vectors/artifacts where applicable. +5. Update consumers to exact revisions and run consumer-specific canaries. +6. Record migration, rollback, unsupported platforms, degraded profiles, and residual risk. +7. Update the central dependency/contract index only after source-adjacent evidence exists. +8. Complete an initiative only when every exit criterion points to exact evidence. + +## Conflict and escalation + +When two repositories claim the same domain, validation fails. Work may continue as proposals, but no new canonical release or protected mutation should rely on the conflict. + +Escalation order: + +1. repository owners gather current implementation evidence; +2. canonical-domain owner identifies the governing invariant; +3. technical DRI proposes the smallest migration or containment; +4. decision owner resolves organization scope; +5. protected owner/administrator authorizes the exact protected operation; +6. a regression guard prevents the ambiguity from recurring. + +## Bus factor and succession + +The current registry truthfully marks `bootstrap-single-owner`. This is accepted bootstrap risk, not a mature control state. + +R3/R4 maturity requires: + +- at least one qualified backup reviewer or delegated team; +- documented ownership transfer procedure; +- protected credentials and break-glass custody not bound to one personal account; +- periodic access review; +- provenance-preserving ownership history. diff --git a/docs/rollout.md b/docs/rollout.md new file mode 100644 index 0000000..7c4d02c --- /dev/null +++ b/docs/rollout.md @@ -0,0 +1,67 @@ +# Governance-plane rollout + +The rollout is intentionally reversible. It does not itself archive, transfer, privatize, delete, release, publish, or change organization settings. + +## Initial slice — this change + +- establish ADRs and source-of-truth boundaries; +- inventory the current public GitHub surface; +- encode lifecycle, canonicality, ownership, risk, disposition, controls, and exceptions; +- add cross-repository initiatives and public dependency/contract indexes; +- add dependency-free validation, generation, negative tests, issue forms, and evidence templates; +- add read-only CI, reusable readiness/evidence workflows, and scheduled public drift observation; +- create separate repository and administrative activation issues. + +Exit: clean local fast gate and green PR CI. + +## Days 0–30 + +1. Review and merge the governance-plane PR. +2. Apply and evidence the `.github/main` ruleset and organization/Actions baseline from issue #6. +3. Pilot repository-local `agent/manifest.json` and the reusable readiness workflow in at least two canonical repositories at an immutable `.github` commit. +4. Reconcile the live public inventory and correct default-branch/archive/manifest drift. +5. Convert current portfolio recommendations into scoped repository-local migration issues. +6. Add backup reviewers for the highest-risk R4 repositories or explicitly track the bus-factor exception. + +Exit: + +- repository and administrative activation gates are closed; +- two pilot consumers are green; +- no unowned or duplicate public canonical domains; +- drift observer maintains at most one issue. + +## Days 31–60 + +1. Extend manifests and fast/full interfaces to all retained active public repositories. +2. Add immutable producer/consumer canaries for the trust stack. +3. Generate the public compatibility page in Coven Docs from exact artifacts. +4. Migrate non-duplicative value from deprecated repositories with provenance. +5. Archive time-bounded historical repositories only after the retirement gate and explicit authorization. +6. Add SBOM/provenance/signing evidence to release-owning repositories where appropriate. + +Exit: + +- every retained public repository has owner, lifecycle, manifest, clean bootstrap, and required checks; +- every canonical producer has at least one immutable downstream canary; +- no deprecated repository introduces a new canonical surface. + +## Days 61–90 + +1. Complete approved consolidation, private-incubation, transfer, archival, or tombstone actions. +2. Validate package/update/download/domain/webhook continuity after each retirement observation window. +3. Run standardized golden-task evaluations and control-effectiveness tests. +4. Add access-controlled aggregation for private repository manifests without copying private context into public files. +5. Decide whether scale justifies a Backstage/service-catalog projection; keep files as authority unless a new ADR proves otherwise. +6. Publish an evidence-backed portfolio review with exact residual risks and no certification overclaims. + +Exit: + +- public inventory matches the reviewed target for that date; +- zero ambiguous canonical ownership; +- zero expired exceptions or stale generated views; +- zero broken references caused by approved retirement; +- administrative and release controls have recurring effectiveness evidence. + +## Revisit criteria + +Reconsider the architecture if confidentiality, scale, bottlenecks, availability, or blast radius remain unacceptable for two review cycles despite the mitigations in ADR-0001. diff --git a/docs/standards-and-assurance-mapping.md b/docs/standards-and-assurance-mapping.md new file mode 100644 index 0000000..2b6cfee --- /dev/null +++ b/docs/standards-and-assurance-mapping.md @@ -0,0 +1,34 @@ +# Standards and assurance mapping + +This mapping helps OpenCoven design future assurance evidence. It does **not** claim certification, attestation, legal compliance, or complete control coverage. + +| OpenCoven governance concern | Useful external reference families | Current evidence in this plane | Important gap | +|---|---|---|---| +| Governance, accountability, risk ownership | NIST CSF 2.0 Govern; ISO/IEC 27001/27002 organizational controls; SOC 2 common criteria | Registry, owners/DRIs, lifecycle, controls, ADRs | Independent scope, control ownership separation, operating evidence | +| Secure development | NIST SSDF; OpenSSF Best Practices/Scorecard | Agent policy, risk classes, deterministic checks, dependency/action pinning | Uniform adoption and effectiveness across repositories | +| Supply-chain provenance | SLSA; SPDX; CycloneDX; Sigstore | Contract index, immutable-pin policy, evidence schema | Per-release SBOM/provenance/signing in owning repositories | +| Access and least privilege | NIST CSF Protect; CIS Controls; GitHub security guidance | Administration baseline and issue #6 | Applied org settings, access review, MFA/App evidence | +| Change management and auditability | ISO/IEC 27001 change/configuration controls; SOC 2 change-management criteria | Git history, PR templates, ADRs, exception expiry, generated-view checks | Protected merge/settings evidence and recurring effectiveness tests | +| Incident and vulnerability handling | NIST CSF Respond/Recover; ISO/IEC 27035 concepts | Organization `SECURITY.md`, private-advisory route, recovery policy | Measured response process and tabletop/incident evidence | +| Privacy and data minimization | GDPR/CCPA principles; ISO/IEC 27018 where cloud PII applies | Public/private minimization policy | Processing inventory, legal bases, data-subject procedures, deployment-specific controls | +| AI risk and transparency | NIST AI RMF | Honest claim boundaries and agent authority separation | Deployment-specific measurement, human factors, model/provider controls | +| Cloud security | ISO/IEC 27017 and provider-specific guidance where hosted services exist | Least-privilege/OIDC direction | Cloud-specific shared-responsibility, tenant isolation, logging, key management evidence | + +## Interpretation rules + +- Standards provide control objectives and vocabulary; they do not prove the implementation satisfies them. +- A public repository check cannot establish SOC 2 or ISO certification. +- Privacy obligations depend on actual processing, roles, jurisdictions, contracts, and deployment behavior. +- AI risk controls supplement rather than replace identity, authorization, software-security, and privacy controls. +- Each assurance claim must name scope, exact release/artifact, environment, evidence period, exceptions, and independent reviewer where applicable. + +## Open-source governance and provenance + +OpenCoven currently uses MIT licensing, DCO sign-off, patent non-assertion language, and contribution provenance guidance. Before enterprise or foundation transition, obtain qualified legal review of: + +- license consistency and third-party notices; +- DCO versus CLA tradeoffs for the intended governance model; +- patent policy and contributor authority; +- trademark/certification-mark rules for conformance claims; +- AI-assisted contribution disclosure and provenance; +- retention of public contribution metadata and security records. diff --git a/docs/verification-model.md b/docs/verification-model.md new file mode 100644 index 0000000..04756d8 --- /dev/null +++ b/docs/verification-model.md @@ -0,0 +1,33 @@ +# Verification and adherence model + +## Layers + +1. **Schema/structure** — records parse and contain required fields. +2. **Semantic invariants** — canonical domains are unique; lifecycles, successors, dependencies, and authority boundaries agree. +3. **Derived-state integrity** — generated views match authoritative inputs exactly. +4. **Repository adoption** — local agent manifests, checks, protected paths, and contract pins match the public registry. +5. **Live GitHub reconciliation** — public repository metadata and required manifests match declared state. +6. **Administrative application** — rulesets, permissions, environments, Apps, and break-glass controls are applied. +7. **Control effectiveness** — positive/negative tests and recurring evidence show controls continue working. +8. **Product/protocol conformance** — owning repositories prove structural, runtime, continuity, privacy, interoperability, and release behavior against exact artifacts. + +No lower layer implies a higher one. + +## Current automated evidence + +`./scripts/agent-check fast` provides layers 1–3 for this repository and includes negative regression tests. The reusable workflow provides part of layer 4. The scheduled drift observer provides part of layer 5. Issue #6 tracks layer 6. Advanced cross-repository conformance remains under issue #2 and owning repositories. + +## Golden tasks for agent readiness + +Each active repository should eventually prove at least: + +- a clean-clone documentation change; +- a focused pure-code fix; +- a protected-path proposal that correctly stops for approval; +- a malformed manifest/contract rejection; +- an unsupported-platform result that is reported without being hidden or misclassified; +- a generated-file drift failure; +- a secret/private-data fixture that is rejected without logging sensitive content; +- a cross-repository contract update using an immutable producer artifact. + +Measure clarification count, human interventions, check duration, false failures, escaped drift, and rollback success. Do not optimize velocity by weakening protected boundaries. diff --git a/evidence/2026-09-03-organization-governance-plane-v1.json b/evidence/2026-09-03-organization-governance-plane-v1.json new file mode 100644 index 0000000..dfe99c7 --- /dev/null +++ b/evidence/2026-09-03-organization-governance-plane-v1.json @@ -0,0 +1,117 @@ +{ + "$schema": "../schemas/evidence-packet.schema.json", + "schema_version": "opencoven.governance-evidence/v1", + "change": { + "objective": "Establish the initial public OpenCoven organization governance and portfolio coordination plane in OpenCoven/.github.", + "acceptance_criteria": [ + "Current public repositories are inventoried with ownership, lifecycle, canonicality, risk, disposition, and manifest state.", + "Canonical public domains have unique owners.", + "Cross-repository initiatives, decisions, dependencies, controls, and exceptions are machine-readable and validated.", + "Generated portfolio views are deterministic and stale changes fail validation.", + "GitHub Actions use explicit least-privilege permissions and immutable third-party Action pins.", + "Administrative organization settings remain a separately evidenced gate." + ], + "non_goals": [ + "Changing repository visibility, archive state, ownership, transfer, or deletion.", + "Merging, releasing, publishing, deploying, or applying GitHub organization settings.", + "Replacing repository-local implementation truth or OpenCoven protected authority systems.", + "Publishing private repository inventory or confidential operational context." + ] + }, + "authority": { + "risk_class": "R4", + "protected_boundaries": [ + "organization governance and repository administration", + "identity and principal binding", + "protected authorization", + "orchestration", + "runtime persistence and execution", + "release and publication" + ], + "authorization_effect": "none-metadata-only" + }, + "sources": [ + { + "kind": "project-research", + "reference": "OpenCoven Public Repository Agent-Readiness Audit", + "revision": "sha256:3fbb5f4712acd2d3589755e6e08415b41fb8c08170b528e6db6f9923f43b55c3" + }, + { + "kind": "project-research", + "reference": "SPAR and Familiar Contract reconciliation", + "revision": "sha256:754b2ed36235c1ed51b8ecf522053d4e16cc4f7ffd9fa78ada5d565fa1f75cf0" + }, + { + "kind": "repository", + "reference": "OpenCoven/.github", + "revision": "c8b4ad3f9f9794db0fa79f338c2ce688ce6d4106" + }, + { + "kind": "github-public-inventory", + "reference": "OpenCoven public repositories", + "revision": "observed-2026-09-03:30-public-repositories" + } + ], + "files": [ + "README.md", + "AGENTS.md", + "agent/manifest.json", + "governance/**", + "initiatives/**", + "decisions/**", + "compatibility/**", + "policies/**", + "docs/**", + "schemas/**", + "scripts/**", + "tests/**", + "generated/**", + ".github/**" + ], + "verification": [ + { + "command": "./scripts/agent-check fast", + "result": "pass", + "environment": "Linux; Python 3.13.5; dependency-free deterministic path", + "evidence": "Governance validation, generated-view check, and 13 unit tests passed." + }, + { + "command": "bash -n scripts/agent-bootstrap scripts/agent-check", + "result": "pass", + "environment": "Linux bash", + "evidence": "Shell entrypoints parsed successfully." + }, + { + "command": "python3 -m py_compile scripts/governance.py scripts/governance_core.py scripts/governance_model.py scripts/governance_cli.py tests/test_governance.py", + "result": "pass", + "environment": "Python 3.13.5", + "evidence": "CLI wrapper, modular validator/generator/reconciler, and test module compiled successfully." + }, + { + "command": "PyYAML safe_load over .github/**/*.yml", + "result": "pass", + "environment": "Local validation environment", + "evidence": "Issue forms, Dependabot config, and four workflows parsed without YAML syntax errors." + }, + { + "command": "GitHub Actions pull-request execution", + "result": "skipped", + "environment": "GitHub-hosted runner", + "evidence": "Pending creation of the review branch and pull request." + }, + { + "command": "Organization ruleset and permission effectiveness test", + "result": "skipped", + "environment": "GitHub organization administration", + "evidence": "Explicitly tracked by OpenCoven/.github#6; current main was observed unprotected before this change." + } + ], + "migration": "Additive and reversible. Existing policy, provenance, patent, profile, and audit files remain untouched. Public portfolio records begin as reviewed coordination metadata; repository-local implementation remains authoritative.", + "rollback": "Close the pull request and delete the feature branch before merge. After merge, revert the governance-plane commit while preserving issues and settings evidence; no repository lifecycle or visibility mutations are coupled to this change.", + "uncertainty": [ + "Remote GitHub Actions behavior is pending pull-request execution.", + "Organization rulesets, Actions policy, app scopes, environments, MFA, and break-glass controls are not applied by repository content and remain open in issue #6.", + "The public registry reflects the connected GitHub inventory observed on 2026-09-03 and requires scheduled reconciliation after merge.", + "Private repository aggregation is intentionally omitted from this public repository and requires an access-controlled federated overlay." + ] +} diff --git a/evidence/README.md b/evidence/README.md new file mode 100644 index 0000000..518bda1 --- /dev/null +++ b/evidence/README.md @@ -0,0 +1,5 @@ +# Governance evidence packets + +This directory contains public, machine-readable evidence for material governance-plane changes. Packets must conform to `schemas/evidence-packet.schema.json` and must not contain secrets, private repository inventory, prompts, memories, user data, private paths, or embargoed security detail. + +A packet records verification; it does not grant protected OpenCoven or GitHub-administration authority. diff --git a/generated/controls.md b/generated/controls.md new file mode 100644 index 0000000..496ead6 --- /dev/null +++ b/generated/controls.md @@ -0,0 +1,16 @@ +# Generated governance control index + +> Generated from `governance/controls.json`. A control marked specified or implemented is not necessarily administratively applied or operationally effective. + +| Control | Objective | Enforcement | State | +|---|---|---|---| +| `GOV-001` Unique canonical ownership | Every canonical public domain has exactly one owning repository. | scripts/governance.py validate | implemented | +| `GOV-002` Public/private minimization | The public registry contains public repositories and non-sensitive metadata only. | registry scope and secret-like-data validation | implemented | +| `GOV-003` Lifecycle accountability | Every public repository has a lifecycle, owner, DRI, risk class, disposition, and review date. | scripts/governance.py validate | implemented | +| `GOV-004` Derived-view integrity | Generated portfolio views exactly reflect authoritative records. | generate --check | implemented | +| `GOV-005` Least-privilege workflows | Workflows declare permissions and pin third-party Actions to immutable commits. | workflow policy validation | implemented | +| `GOV-006` Protected governance branch | Governance changes enter main only through reviewed, checked pull requests. | GitHub organization ruleset | administrative-gate-open | +| `GOV-007` Exception expiry | Every waiver is scoped, approved, expiring, and visible. | scripts/governance.py validate | implemented | +| `GOV-008` Repository drift detection | Declared public inventory is reconciled against GitHub without becoming a second mutable status store. | scheduled read-only discovery plus one issue | implemented-pending-merge | +| `GOV-009` Protected authority separation | Governance metadata cannot grant OpenCoven runtime or protected mutation authority. | review, schemas, and architecture canaries in owning repositories | specified | +| `GOV-010` Evidence-backed change | Material governance changes include machine-readable, reviewable evidence. | reusable evidence workflow | implemented-pending-adoption | diff --git a/generated/dependencies.mmd b/generated/dependencies.mmd new file mode 100644 index 0000000..5a0040c --- /dev/null +++ b/generated/dependencies.mmd @@ -0,0 +1,48 @@ +%% Generated by scripts/governance.py; do not edit. +flowchart LR + _github[".github"] + brand["brand"] + cast_codes["cast-codes"] + chat["chat"] + claude_code_cast["claude-code-cast"] + coven["coven"] + coven_cave["coven-cave"] + coven_code["coven-code"] + coven_codeflow["coven-codeflow"] + coven_design_system["coven-design-system"] + coven_docs["coven-docs"] + coven_github_webhook["coven-github-webhook"] + coven_landing["coven-landing"] + coven_memory["coven-memory"] + coven_pocket["coven-pocket"] + coven_reach["coven-reach"] + coven_runtimes["coven-runtimes"] + coven_scout["coven-scout"] + coven_threads["coven-threads"] + demo_workspace["demo-workspace"] + desktop_use["desktop-use"] + familiar_contract["familiar-contract"] + homebrew_tap["homebrew-tap"] + open_fable["open-fable"] + open_meow_sdk["open-meow-sdk"] + opencoven_beta_august_hackathon_2026["opencoven-beta-august-hackathon-2026"] + opencoven_chat_api["opencoven-chat-api"] + psyche["psyche"] + psyche_build["psyche-build"] + sdk["sdk"] + ui["ui"] + familiar_contract -->|"identity-contract"| coven_threads + familiar_contract -->|"identity-snapshot"| psyche + familiar_contract -->|"session-identity-binding"| coven + coven_threads -->|"protected-decision"| coven + psyche -->|"orchestrated-work"| coven + coven_runtimes -->|"runtime-descriptor"| coven + coven -->|"public-client-contract"| sdk + coven -->|"read-only-memory-projection"| coven_memory + coven -->|"oversight-api"| coven_cave + psyche -->|"orchestration-client"| psyche_build + coven -->|"terminal-execution"| coven_code + brand -->|"specimen-token-consumer"| ui + brand -->|"production-brand-consumer"| coven_cave + brand -->|"public-web-brand-consumer"| coven_landing + sdk -->|"documented-public-client"| coven_docs diff --git a/generated/initiatives.md b/generated/initiatives.md new file mode 100644 index 0000000..8e63e43 --- /dev/null +++ b/generated/initiatives.md @@ -0,0 +1,10 @@ +# Generated cross-repository initiatives + +> Generated from `initiatives/*.json`. Implementation status remains authoritative in linked owning-repository evidence. + +| Initiative | Priority | Status | Decision owner | Technical DRI | Review by | Open criteria | +|---|---:|---|---|---|---|---:| +| `familiar-identity-continuity-v1` | P0 | active | @BunsDev | @BunsDev | 2026-10-03 | 7 | +| `organization-governance-plane-v1` | P0 | active | @BunsDev | @BunsDev | 2026-10-03 | 6 | +| `public-portfolio-consolidation-2026` | P0 | active | @BunsDev | @BunsDev | 2026-10-03 | 6 | +| `brand-ui-consolidation` | P1 | active | @BunsDev | @BunsDev | 2026-10-03 | 5 | diff --git a/generated/ownership.md b/generated/ownership.md new file mode 100644 index 0000000..a0f39a4 --- /dev/null +++ b/generated/ownership.md @@ -0,0 +1,54 @@ +# Generated canonical public ownership map + +> Generated from `governance/repositories.json`. A governance claim identifies ownership; it does not grant protected runtime authority. + +| Canonical domain | Repository | Technical DRI | Risk | +|---|---|---|---:| +| `access.canonical-bindings` | `sdk` | @BunsDev | R3 | +| `access.public-sdk` | `sdk` | @BunsDev | R3 | +| `authority.proposal-commit-decisions` | `coven-threads` | @BunsDev | R4 | +| `authority.protected-surface` | `coven-threads` | @BunsDev | R4 | +| `automation.artifacts` | `coven` | @BunsDev | R4 | +| `automation.attempts` | `coven` | @BunsDev | R4 | +| `automation.changefeed` | `coven` | @BunsDev | R4 | +| `automation.definitions` | `coven` | @BunsDev | R4 | +| `automation.events` | `coven` | @BunsDev | R4 | +| `automation.fences` | `coven` | @BunsDev | R4 | +| `automation.leases` | `coven` | @BunsDev | R4 | +| `automation.occurrences` | `coven` | @BunsDev | R4 | +| `automation.receipts` | `coven` | @BunsDev | R4 | +| `automation.recovery` | `coven` | @BunsDev | R4 | +| `automation.retries` | `coven` | @BunsDev | R4 | +| `automation.revisions` | `coven` | @BunsDev | R4 | +| `automation.runs` | `coven` | @BunsDev | R4 | +| `automation.schedule-planning` | `coven` | @BunsDev | R4 | +| `brand.identity` | `brand` | @BunsDev | R2 | +| `brand.public-web-profile` | `brand` | @BunsDev | R2 | +| `brand.voice` | `brand` | @BunsDev | R2 | +| `execution.terminal-coding` | `coven-code` | @BunsDev | R3 | +| `identity.familiar-contract` | `familiar-contract` | @BunsDev | R4 | +| `identity.principal-binding` | `familiar-contract` | @BunsDev | R4 | +| `identity.revision-semantics` | `familiar-contract` | @BunsDev | R4 | +| `knowledge.compatibility-presentation` | `coven-docs` | @BunsDev | R1 | +| `knowledge.public-documentation` | `coven-docs` | @BunsDev | R1 | +| `memory.projection` | `coven-memory` | @BunsDev | R3 | +| `memory.read-only-client` | `coven-memory` | @BunsDev | R3 | +| `organization.governance` | `.github` | @BunsDev | R4 | +| `organization.portfolio` | `.github` | @BunsDev | R4 | +| `organization.shared-policy` | `.github` | @BunsDev | R4 | +| `product.coding-cockpit` | `psyche-build` | @BunsDev | R3 | +| `product.human-oversight` | `coven-cave` | @BunsDev | R3 | +| `product.production-ui` | `coven-cave` | @BunsDev | R3 | +| `project-orchestration.approvals` | `psyche` | @BunsDev | R4 | +| `project-orchestration.lanes` | `psyche` | @BunsDev | R4 | +| `project-orchestration.leases` | `psyche` | @BunsDev | R4 | +| `project-orchestration.receipts` | `psyche` | @BunsDev | R4 | +| `project-orchestration.recovery` | `psyche` | @BunsDev | R4 | +| `project-orchestration.retries` | `psyche` | @BunsDev | R4 | +| `project-orchestration.tasks` | `psyche` | @BunsDev | R4 | +| `runtime.capability-descriptors` | `coven-runtimes` | @BunsDev | R4 | +| `runtime.conformance` | `coven-runtimes` | @BunsDev | R4 | +| `runtime.daemon-authority` | `coven` | @BunsDev | R4 | +| `runtime.execution` | `coven` | @BunsDev | R4 | +| `runtime.persistence` | `coven` | @BunsDev | R4 | +| `runtime.sessions` | `coven` | @BunsDev | R4 | diff --git a/generated/portfolio.md b/generated/portfolio.md new file mode 100644 index 0000000..59d4ab9 --- /dev/null +++ b/generated/portfolio.md @@ -0,0 +1,54 @@ +# Generated public repository portfolio + +> Generated by `python3 scripts/governance.py generate`. Do not edit by hand. + +Registry digest: `9f561389ddf7560206742657fed62c6e3ef9ff3772fce4e3a65f43f217d20905` + +## Summary + +| Lifecycle | Count | +|---|---:| +| active | 17 | +| incubating | 2 | +| maintenance | 5 | +| deprecated | 5 | +| archived | 2 | +| tombstone | 0 | + +## Repositories + +| Repository | Lifecycle | Canonicality | Risk | Owner | Disposition | Manifest | +|---|---|---|---:|---|---|---| +| .github | active | canonical | R4 | @BunsDev | retain | enforced | +| brand | active | canonical | R2 | @BunsDev | retain | planned | +| cast-codes | archived | historical | R1 | @BunsDev | retain-archive | exempt | +| chat | active | supporting | R3 | @BunsDev | retain | planned | +| claude-code-cast | deprecated | none | R2 | @BunsDev | consolidate-then-retire | planned | +| coven | active | canonical | R4 | @BunsDev | retain | planned | +| coven-cave | active | canonical | R3 | @BunsDev | retain | planned | +| coven-code | active | canonical | R3 | @BunsDev | retain | planned | +| coven-codeflow | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | +| coven-design-system | deprecated | none | R2 | @BunsDev | consolidate-then-retire | planned | +| coven-docs | active | canonical | R1 | @BunsDev | retain | planned | +| coven-github-webhook | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | +| coven-landing | active | supporting | R1 | @BunsDev | retain | planned | +| coven-memory | active | canonical | R3 | @BunsDev | retain | planned | +| coven-pocket | maintenance | none | R3 | @BunsDev | evaluate-consolidation-or-private-incubation | planned | +| coven-reach | maintenance | none | R3 | @BunsDev | private-incubation-or-retire | planned | +| coven-runtimes | active | canonical | R4 | @BunsDev | retain | planned | +| coven-scout | maintenance | none | R3 | @BunsDev | private-incubation-or-retire | planned | +| coven-threads | active | canonical | R4 | @BunsDev | retain | planned | +| demo-workspace | incubating | supporting | R1 | @BunsDev | graduate-or-retire | planned | +| desktop-use | maintenance | none | R3 | @BunsDev | evaluate-consolidation-or-private-incubation | planned | +| familiar-contract | active | canonical | R4 | @BunsDev | retain | planned | +| homebrew-tap | active | supporting | R4 | @BunsDev | retain | planned | +| open-fable | incubating | none | R2 | @BunsDev | private-incubation-or-retire | planned | +| open-meow-sdk | archived | historical | R1 | @BunsDev | retain-archive | exempt | +| opencoven-beta-august-hackathon-2026 | maintenance | historical | R1 | @BunsDev | archive-after-retirement-gate | planned | +| opencoven-chat-api | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | +| psyche | active | canonical | R4 | @BunsDev | retain | planned | +| psyche-build | active | canonical | R3 | @BunsDev | retain | planned | +| sdk | active | canonical | R3 | @BunsDev | retain | planned | +| ui | active | specimen | R1 | @BunsDev | retain | planned | + +This is a public-only view. Private repository inventory is intentionally federated and omitted. diff --git a/governance/controls.json b/governance/controls.json new file mode 100644 index 0000000..bf831b4 --- /dev/null +++ b/governance/controls.json @@ -0,0 +1,115 @@ +{ + "$schema": "../schemas/controls.schema.json", + "schema_version": "opencoven.controls/v1", + "controls": [ + { + "id": "GOV-001", + "title": "Unique canonical ownership", + "objective": "Every canonical public domain has exactly one owning repository.", + "evidence": [ + "governance/repositories.json", + "generated/ownership.md" + ], + "enforcement": "scripts/governance.py validate", + "status": "implemented" + }, + { + "id": "GOV-002", + "title": "Public/private minimization", + "objective": "The public registry contains public repositories and non-sensitive metadata only.", + "evidence": [ + "policies/public-private-data.md", + "tests/test_governance.py" + ], + "enforcement": "registry scope and secret-like-data validation", + "status": "implemented" + }, + { + "id": "GOV-003", + "title": "Lifecycle accountability", + "objective": "Every public repository has a lifecycle, owner, DRI, risk class, disposition, and review date.", + "evidence": [ + "governance/repositories.json" + ], + "enforcement": "scripts/governance.py validate", + "status": "implemented" + }, + { + "id": "GOV-004", + "title": "Derived-view integrity", + "objective": "Generated portfolio views exactly reflect authoritative records.", + "evidence": [ + "generated/", + "scripts/governance.py" + ], + "enforcement": "generate --check", + "status": "implemented" + }, + { + "id": "GOV-005", + "title": "Least-privilege workflows", + "objective": "Workflows declare permissions and pin third-party Actions to immutable commits.", + "evidence": [ + ".github/workflows/", + "tests/test_governance.py" + ], + "enforcement": "workflow policy validation", + "status": "implemented" + }, + { + "id": "GOV-006", + "title": "Protected governance branch", + "objective": "Governance changes enter main only through reviewed, checked pull requests.", + "evidence": [ + "OpenCoven/.github#6" + ], + "enforcement": "GitHub organization ruleset", + "status": "administrative-gate-open" + }, + { + "id": "GOV-007", + "title": "Exception expiry", + "objective": "Every waiver is scoped, approved, expiring, and visible.", + "evidence": [ + "governance/exceptions.json", + "policies/exceptions.md" + ], + "enforcement": "scripts/governance.py validate", + "status": "implemented" + }, + { + "id": "GOV-008", + "title": "Repository drift detection", + "objective": "Declared public inventory is reconciled against GitHub without becoming a second mutable status store.", + "evidence": [ + ".github/workflows/governance-drift.yml", + "scripts/governance.py" + ], + "enforcement": "scheduled read-only discovery plus one issue", + "status": "implemented-pending-merge" + }, + { + "id": "GOV-009", + "title": "Protected authority separation", + "objective": "Governance metadata cannot grant OpenCoven runtime or protected mutation authority.", + "evidence": [ + "decisions/ADR-0001-organization-governance-plane.md", + "policies/authority-boundaries.md" + ], + "enforcement": "review, schemas, and architecture canaries in owning repositories", + "status": "specified" + }, + { + "id": "GOV-010", + "title": "Evidence-backed change", + "objective": "Material governance changes include machine-readable, reviewable evidence.", + "evidence": [ + "schemas/evidence-packet.schema.json", + ".github/PULL_REQUEST_TEMPLATE.md", + "evidence/2026-09-03-organization-governance-plane-v1.json" + ], + "enforcement": "reusable evidence workflow", + "status": "implemented-pending-adoption" + } + ] +} diff --git a/governance/exceptions.json b/governance/exceptions.json new file mode 100644 index 0000000..b7f6e76 --- /dev/null +++ b/governance/exceptions.json @@ -0,0 +1,5 @@ +{ + "$schema": "../schemas/exception.schema.json", + "schema_version": "opencoven.exception-set/v1", + "exceptions": [] +} diff --git a/governance/lifecycle.json b/governance/lifecycle.json new file mode 100644 index 0000000..69cdb1a --- /dev/null +++ b/governance/lifecycle.json @@ -0,0 +1,83 @@ +{ + "$schema": "../schemas/lifecycle.schema.json", + "schema_version": "opencoven.lifecycle/v1", + "lifecycle_states": { + "incubating": { + "public_claim": "experimental", + "allowed_transitions": [ + "active", + "deprecated", + "archived" + ], + "review_interval_days": 30 + }, + "active": { + "public_claim": "actively-developed", + "allowed_transitions": [ + "maintenance", + "deprecated" + ], + "review_interval_days": 90 + }, + "maintenance": { + "public_claim": "supported-with-limited-change", + "allowed_transitions": [ + "active", + "deprecated", + "archived" + ], + "review_interval_days": 60 + }, + "deprecated": { + "public_claim": "successor-or-retirement-planned", + "allowed_transitions": [ + "maintenance", + "archived", + "tombstone" + ], + "review_interval_days": 30 + }, + "archived": { + "public_claim": "read-only-historical-record", + "allowed_transitions": [ + "maintenance", + "tombstone" + ], + "review_interval_days": 365 + }, + "tombstone": { + "public_claim": "minimal-successor-pointer", + "allowed_transitions": [], + "review_interval_days": 365 + } + }, + "canonicality_states": { + "canonical": "Owns one or more unique public domains.", + "supporting": "Supports canonical components without owning their domains.", + "specimen": "Reference or experimental surface that cannot define production behavior.", + "historical": "Historical evidence only.", + "none": "No canonical claim." + }, + "risk_classes": { + "R0": { + "scope": "Documentation, examples, and copy.", + "default_agent_authority": "autonomous-pr-after-validation" + }, + "R1": { + "scope": "Pure code without external mutable state.", + "default_agent_authority": "autonomous-branch-and-pr" + }, + "R2": { + "scope": "Local mutable state, schemas, or migrations.", + "default_agent_authority": "proposal-plus-deterministic-fixture" + }, + "R3": { + "scope": "Network, credentials, user data, remote APIs, or publication-adjacent behavior.", + "default_agent_authority": "approval-gated-execution" + }, + "R4": { + "scope": "Identity, authorization, persistence, release, deletion, or organization administration.", + "default_agent_authority": "human-approved-plan-and-protected-owner-review" + } + } +} diff --git a/governance/repositories.json b/governance/repositories.json new file mode 100644 index 0000000..bf3abe7 --- /dev/null +++ b/governance/repositories.json @@ -0,0 +1 @@ +{"$schema":"../schemas/repository-registry.schema.json","schema_version":"opencoven.repository-registry/v1","organization":"OpenCoven","scope":{"visibility":"public-only","observed_as_of":"2026-09-05","expected_public_repository_count":31,"private_inventory":"federated-and-intentionally-omitted","private_overlay_policy":"policies/public-private-data.md"},"defaults":{"visibility":"public","observed":{"default_branch":"main","archived":false},"owner":"BunsDev","technical_dri":"BunsDev","ownership_status":"bootstrap-single-owner","canonical_domains":[],"does_not_own":[],"disposition":{"state":"retain","review_by":"2026-12-02"},"agent_manifest":{"status":"planned","path":"agent/manifest.json"},"security_support":"limited"},"repositories":[{"name":".github","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Public organization governance, portfolio coordination, shared policy, and generated public views.","canonical_domains":["organization.governance","organization.portfolio","organization.shared-policy"],"does_not_own":["familiar.identity","protected.authorization","project-orchestration","runtime.persistence","runtime.execution","release.approval","publication.approval","automation.lifecycle"],"agent_manifest":{"status":"enforced","path":"agent/manifest.json"},"security_support":"active"},{"name":"brand","lifecycle":"active","canonicality":"canonical","risk_class":"R2","purpose":"Canonical OpenCoven visual identity, voice, and public-web profile.","canonical_domains":["brand.identity","brand.voice","brand.public-web-profile"],"does_not_own":["product.production-ui"],"security_support":"active"},{"name":"cast-codes","lifecycle":"archived","canonicality":"historical","risk_class":"R1","purpose":"Historical product and release lineage retained with successor context.","observed":{"default_branch":"main","archived":true},"disposition":{"state":"retain-archive","review_by":"2027-09-03"},"agent_manifest":{"status":"exempt","path":"agent/manifest.json"},"security_support":"historical"},{"name":"chat","lifecycle":"active","canonicality":"supporting","risk_class":"R3","purpose":"Production-oriented read-only desktop client for bounded Cave SDK chat access.","does_not_own":["product.production-ui","runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"claude-code-cast","lifecycle":"deprecated","canonicality":"none","risk_class":"R2","purpose":"Legacy coding-event adapter and redaction fixtures.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-code"}},"security_support":"unsupported"},{"name":"coven","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Daemon authority, persistence, sessions, runtime execution, authoritative state transitions, and the automation lifecycle: definitions and revisions, schedule planning and occurrences, runs and attempts, automation leases and fences, retries and recovery, events and changefeed, artifacts, and receipts.","canonical_domains":["runtime.daemon-authority","runtime.persistence","runtime.sessions","runtime.execution","automation.definitions","automation.revisions","automation.schedule-planning","automation.occurrences","automation.runs","automation.attempts","automation.leases","automation.fences","automation.retries","automation.recovery","automation.events","automation.changefeed","automation.artifacts","automation.receipts"],"does_not_own":["familiar.identity","protected.authorization","project-orchestration"],"security_support":"active"},{"name":"coven-cave","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Primary human oversight product and production UI behavior.","canonical_domains":["product.human-oversight","product.production-ui"],"does_not_own":["runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"coven-code","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Terminal coding execution and headless coding contracts.","canonical_domains":["execution.terminal-coding"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"coven-codeflow","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Overlapping coding cockpit and execution experiment.","observed":{"default_branch":"master","archived":false},"disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-code"}},"security_support":"unsupported"},{"name":"coven-design-system","lifecycle":"deprecated","canonicality":"none","risk_class":"R2","purpose":"Overlapping design-system experiment whose useful work should be extracted without retaining a canonical claim.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"portfolio","names":["brand","ui","coven-cave"]}},"security_support":"unsupported"},{"name":"coven-docs","lifecycle":"active","canonicality":"canonical","risk_class":"R1","purpose":"Public documentation and generated compatibility presentation.","canonical_domains":["knowledge.public-documentation","knowledge.compatibility-presentation"],"does_not_own":["protocol.normative-artifacts"],"security_support":"active"},{"name":"coven-github-webhook","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Noncanonical GitHub delivery bundle pending consolidation into the private delivery overlay.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"github-delivery"}}},{"name":"coven-landing","lifecycle":"active","canonicality":"supporting","risk_class":"R1","purpose":"Public marketing and product landing surface.","does_not_own":["brand.public-web-profile","knowledge.public-documentation"],"security_support":"active"},{"name":"coven-memory","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Read-only memory client and projection; never a second memory authority.","canonical_domains":["memory.read-only-client","memory.projection"],"does_not_own":["memory.authoritative-state","runtime.persistence"],"security_support":"active"},{"name":"coven-pocket","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Mobile experiment pending a distinct boundary or consolidation into Cave mobile.","disposition":{"state":"evaluate-consolidation-or-private-incubation","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-cave"}}},{"name":"coven-reach","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Filesystem/network capability experiment pending redesign around explicit leases and authorization.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"leased-capability-execution"}}},{"name":"coven-runtimes","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Runtime capability descriptors, registry, and conformance.","canonical_domains":["runtime.capability-descriptors","runtime.conformance"],"does_not_own":["runtime.execution","runtime.persistence"],"security_support":"active"},{"name":"coven-scout","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Overlapping filesystem/web capability experiment pending selection of one hardened successor.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"leased-capability-execution"}}},{"name":"coven-threads","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Protected-surface authorization and proposal-versus-commit decisions.","canonical_domains":["authority.protected-surface","authority.proposal-commit-decisions"],"does_not_own":["runtime.persistence","project-orchestration"],"security_support":"active"},{"name":"demo-workspace","lifecycle":"incubating","canonicality":"supporting","risk_class":"R1","purpose":"Minimal public demonstration and deterministic fixture workspace.","disposition":{"state":"graduate-or-retire","review_by":"2026-10-03"},"security_support":"unsupported"},{"name":"desktop-use","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Desktop capability experiment pending product-boundary review.","disposition":{"state":"evaluate-consolidation-or-private-incubation","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-cave"}}},{"name":"familiar-contract","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Governed portable familiar identity, principal binding, and revision semantics.","canonical_domains":["identity.familiar-contract","identity.principal-binding","identity.revision-semantics"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"homebrew-tap","lifecycle":"active","canonicality":"supporting","risk_class":"R4","purpose":"Canonical Homebrew distribution channel for released OpenCoven artifacts.","does_not_own":["release.approval","artifact.provenance-source"],"security_support":"active"},{"name":"open-fable","lifecycle":"incubating","canonicality":"none","risk_class":"R2","purpose":"Speculative research experiment without an approved public canonical boundary.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-10-03","destination":{"kind":"private-overlay","id":"research-incubation"}},"security_support":"unsupported"},{"name":"open-meow-sdk","lifecycle":"archived","canonicality":"historical","risk_class":"R1","purpose":"Archived predecessor SDK retained for provenance.","observed":{"default_branch":"main","archived":true},"disposition":{"state":"retain-archive","review_by":"2027-09-03","destination":{"kind":"repository","name":"sdk"}},"agent_manifest":{"status":"exempt","path":"agent/manifest.json"},"security_support":"historical"},{"name":"opencoven-beta-august-hackathon-2026","lifecycle":"maintenance","canonicality":"historical","risk_class":"R1","purpose":"Time-bounded hackathon record pending archival verification.","disposition":{"state":"archive-after-retirement-gate","review_by":"2026-10-03"},"security_support":"historical"},{"name":"opencoven-chat-api","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Misnamed documentation retrieval service pending accurate consolidation.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"portfolio","names":["coven-docs","coven-cave"]}}},{"name":"psyche","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Project-scoped multi-agent orchestration semantics: tasks, lanes, leases, approvals, receipts, retries, and recovery for coding-agent orchestration.","canonical_domains":["project-orchestration.tasks","project-orchestration.lanes","project-orchestration.leases","project-orchestration.approvals","project-orchestration.receipts","project-orchestration.retries","project-orchestration.recovery"],"does_not_own":["familiar.identity","runtime.persistence","product.production-ui","automation.lifecycle"],"security_support":"active"},{"name":"psyche-build","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Multi-lane coding cockpit consuming Psyche canonically.","canonical_domains":["product.coding-cockpit"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"sdk","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Constrained public clients and canonical language bindings.","canonical_domains":["access.public-sdk","access.canonical-bindings"],"does_not_own":["runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"ui","lifecycle":"active","canonicality":"specimen","risk_class":"R1","purpose":"Specimen and component laboratory; not production UI authority.","does_not_own":["product.production-ui","brand.public-web-profile"]}]} diff --git a/initiatives/README.md b/initiatives/README.md new file mode 100644 index 0000000..6895c4a --- /dev/null +++ b/initiatives/README.md @@ -0,0 +1,26 @@ +# Cross-repository initiatives + +An initiative is the canonical organization-level record for **why coordinated work exists, who is accountable, which repositories own the implementation, what it depends on, and how completion is proven**. + +It is not a duplicate task database. Implementation issues, pull requests, tests, migrations, and release evidence remain in their owning repositories. GitHub Projects may render these records and linked issues, but the Project is a view rather than an independent authority. + +## Lifecycle + +`proposed → active → verifying → completed` + +Alternative terminal states are `superseded` and `cancelled`. A completed initiative must have evidence for every exit criterion. A status change cannot authorize a protected OpenCoven mutation or release. + +## Required fields + +Each `*.json` record must name: + +- one decision owner; +- one technical DRI; +- one outcome and explicit non-goals; +- participating repository workstreams; +- cross-initiative dependencies; +- exit criteria and evidence state; +- accepted or proposed organization ADRs; +- a review date. + +Use `schemas/initiative.schema.json` and validate with `./scripts/agent-check fast`. diff --git a/initiatives/brand-ui-consolidation.json b/initiatives/brand-ui-consolidation.json new file mode 100644 index 0000000..c5cde67 --- /dev/null +++ b/initiatives/brand-ui-consolidation.json @@ -0,0 +1,92 @@ +{ + "$schema": "../schemas/initiative.schema.json", + "schema_version": "opencoven.initiative/v1", + "id": "brand-ui-consolidation", + "title": "Consolidate Brand, UI, and production design authority", + "status": "active", + "priority": "P1", + "decision_owner": "BunsDev", + "technical_dri": "BunsDev", + "ownership_status": "bootstrap-single-owner", + "outcome": "Preserve one canonical Brand profile, keep UI as a mechanically pinned specimen laboratory, and keep Cave authoritative for production component behavior.", + "non_goals": [ + "Creating another canonical token/profile root.", + "Treating a design mock or specimen as production behavior.", + "Removing historical evidence before consumers migrate." + ], + "decisions": [ + "ADR-0001", + "ADR-0002" + ], + "dependencies": [ + "organization-governance-plane-v1" + ], + "workstreams": [ + { + "repository": "brand", + "responsibility": "Own the sole normative visual identity, voice, and versioned public-web profile.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "ui", + "responsibility": "Consume an immutable Brand profile and remain explicitly noncanonical for production behavior.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "coven-cave", + "responsibility": "Own production component behavior and downstream acceptance evidence.", + "issues": [], + "state": "planned" + }, + { + "repository": "coven-design-system", + "responsibility": "Extract non-duplicative work and retire its canonical claim after consumer migration.", + "issues": [], + "state": "planned" + }, + { + "repository": ".github", + "responsibility": "Record cross-repository resolution, dependency, and administrative protection gates.", + "issues": [ + "OpenCoven/.github#6" + ], + "state": "in-progress" + } + ], + "exit_criteria": [ + { + "id": "EC-01", + "statement": "Brand exposes one versioned normative public profile.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-02", + "statement": "UI pins and verifies the Brand profile without claiming production authority.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-03", + "statement": "Cave passes production UI/brand canaries.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-04", + "statement": "Useful design-system work is migrated with provenance and duplicate authority is retired.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-05", + "statement": "Required reviews and checks protect canonical Brand and production UI paths.", + "state": "open", + "evidence": [] + } + ], + "review_by": "2026-10-03", + "authority_boundary": "This initiative coordinates responsibility and evidence. It does not grant protected identity, authorization, runtime, persistence, release, publication, or GitHub-administration authority." +} diff --git a/initiatives/familiar-identity-continuity-v1.json b/initiatives/familiar-identity-continuity-v1.json new file mode 100644 index 0000000..2e95024 --- /dev/null +++ b/initiatives/familiar-identity-continuity-v1.json @@ -0,0 +1,120 @@ +{ + "$schema": "../schemas/initiative.schema.json", + "schema_version": "opencoven.initiative/v1", + "id": "familiar-identity-continuity-v1", + "title": "Familiar identity, continuity, authority, and session-binding conformance", + "status": "active", + "priority": "P0", + "decision_owner": "BunsDev", + "technical_dri": "BunsDev", + "ownership_status": "bootstrap-single-owner", + "outcome": "Ensure every familiar session binds an exact authorized familiar root and revision, protected identity transitions are principal-authorized and atomic, and continuity can be reconstructed without introducing a second identity root.", + "non_goals": [ + "Making SPAR an identity database or IAM replacement.", + "Allowing Psyche, Cave, task text, model output, or caller fields to create protected authority.", + "Collapsing structural, runtime, continuity, privacy, and interoperability evidence into one generic compliance claim." + ], + "decisions": [ + "ADR-0001", + "ADR-0002" + ], + "dependencies": [ + "organization-governance-plane-v1" + ], + "workstreams": [ + { + "repository": "familiar-contract", + "responsibility": "Define familiar root, principal binding, revision, sameness, transition, and retirement semantics.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "coven-threads", + "responsibility": "Authorize protected transitions with replay, revocation, recovery, and proposal-versus-commit semantics.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "psyche", + "responsibility": "Snapshot authorized identity into orchestration without redefining it.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "coven", + "responsibility": "Bind direct and orchestrated sessions and atomically verify/apply protected transitions.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "coven-runtimes", + "responsibility": "Express runtime capabilities and conformance without overstating security properties.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "sdk", + "responsibility": "Expose constrained read/verify/subscribe bindings before mutation surfaces.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "coven-memory", + "responsibility": "Remain a read-only projection and prove mutation is impossible through its contract.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "coven-cave", + "responsibility": "Make revisions, pending proposals, provenance, active embodiments, staleness, and uncertainty legible.", + "issues": [], + "state": "in-progress" + } + ], + "exit_criteria": [ + { + "id": "EC-01", + "statement": "Cross-repository identity ownership is ratified.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-02", + "statement": "Stable familiar-root and same-familiar/fork/succession semantics are normative.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-03", + "statement": "Principal authorization is cryptographically verifiable and replay resistant.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-04", + "statement": "Every direct and orchestrated session pins an exact authorized root and revision.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-05", + "statement": "Final authorization verification and commit are atomic or use one immutable snapshot.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-06", + "statement": "Transition receipts, content-addressed history, privacy lifecycle, and replica revocation have golden vectors.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-07", + "statement": "Conformance profiles are reported separately against immutable artifacts.", + "state": "open", + "evidence": [] + } + ], + "review_by": "2026-10-03", + "authority_boundary": "This initiative coordinates responsibility and evidence. It does not grant protected identity, authorization, runtime, persistence, release, publication, or GitHub-administration authority." +} diff --git a/initiatives/organization-governance-plane-v1.json b/initiatives/organization-governance-plane-v1.json new file mode 100644 index 0000000..37b9777 --- /dev/null +++ b/initiatives/organization-governance-plane-v1.json @@ -0,0 +1,89 @@ +{ + "$schema": "../schemas/initiative.schema.json", + "schema_version": "opencoven.initiative/v1", + "id": "organization-governance-plane-v1", + "title": "Activate the OpenCoven public organization governance plane", + "status": "active", + "priority": "P0", + "decision_owner": "BunsDev", + "technical_dri": "BunsDev", + "ownership_status": "bootstrap-single-owner", + "outcome": "Establish one reviewed, machine-readable public plane for repository ownership, lifecycle, cross-repository outcomes, shared policy, drift detection, and generated views without creating a competing implementation authority.", + "non_goals": [ + "Reimplementing component behavior in .github.", + "Publishing private repository inventory or confidential operational data.", + "Treating policy text as proof that GitHub settings or OpenCoven runtime controls are enforced." + ], + "decisions": [ + "ADR-0001", + "ADR-0002", + "ADR-0003" + ], + "dependencies": [], + "workstreams": [ + { + "repository": ".github", + "responsibility": "Own schemas, registry, ADRs, policies, deterministic validation, generated views, and reusable read-only workflows.", + "issues": [ + "OpenCoven/.github#5" + ], + "state": "in-progress" + }, + { + "repository": ".github", + "responsibility": "Track and evidence organization-setting hardening as a separate administrative gate.", + "issues": [ + "OpenCoven/.github#6" + ], + "state": "blocked-on-admin-application" + }, + { + "repository": ".github", + "responsibility": "Layer advanced automation conformance and compatibility workflows on the base governance contract.", + "issues": [ + "OpenCoven/.github#2" + ], + "state": "planned" + } + ], + "exit_criteria": [ + { + "id": "EC-01", + "statement": "ADR-0001 is ratified through reviewed merge.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-02", + "statement": "Fast deterministic validation passes from a clean clone.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-03", + "statement": "Generated views exactly match authoritative records.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-04", + "statement": "Current public GitHub inventory reconciles without undeclared drift.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-05", + "statement": "Administrative branch, Actions, app, environment, and break-glass controls are independently evidenced.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-06", + "statement": "At least two canonical repositories consume the reusable agent-readiness workflow at an immutable revision.", + "state": "open", + "evidence": [] + } + ], + "review_by": "2026-10-03", + "authority_boundary": "This initiative coordinates responsibility and evidence. It does not grant protected identity, authorization, runtime, persistence, release, publication, or GitHub-administration authority." +} diff --git a/initiatives/public-portfolio-consolidation-2026.json b/initiatives/public-portfolio-consolidation-2026.json new file mode 100644 index 0000000..d180954 --- /dev/null +++ b/initiatives/public-portfolio-consolidation-2026.json @@ -0,0 +1,104 @@ +{ + "$schema": "../schemas/initiative.schema.json", + "schema_version": "opencoven.initiative/v1", + "id": "public-portfolio-consolidation-2026", + "title": "Reduce public portfolio ambiguity and duplicate canonical ownership", + "status": "active", + "priority": "P0", + "decision_owner": "BunsDev", + "technical_dri": "BunsDev", + "ownership_status": "bootstrap-single-owner", + "outcome": "Move every public repository toward retain, consolidate, private incubation, archive, or retirement using reversible evidence gates and zero broken references.", + "non_goals": [ + "Deleting repositories before package, release, installer, webhook, domain, and dependency references are cleared.", + "Moving useful implementation without provenance.", + "Using repository count alone as a success metric." + ], + "decisions": [ + "ADR-0001", + "ADR-0003" + ], + "dependencies": [ + "organization-governance-plane-v1" + ], + "workstreams": [ + { + "repository": ".github", + "responsibility": "Maintain disposition records, retirement procedure, drift evidence, and portfolio exit criteria.", + "issues": [ + "OpenCoven/.github#5" + ], + "state": "in-progress" + }, + { + "repository": "coven-code", + "responsibility": "Receive non-duplicative coding adapter, fixture, and workflow value from legacy coding repositories.", + "issues": [], + "state": "planned" + }, + { + "repository": "brand", + "responsibility": "Retain canonical brand-profile authority during design-system consolidation.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "ui", + "responsibility": "Remain a specimen lab and consume Brand without claiming production authority.", + "issues": [], + "state": "in-progress" + }, + { + "repository": "coven-cave", + "responsibility": "Own production UI/mobile behavior and evaluate overlapping product experiments.", + "issues": [], + "state": "planned" + }, + { + "repository": "coven-docs", + "responsibility": "Receive accurately named documentation retrieval ownership where appropriate.", + "issues": [], + "state": "planned" + } + ], + "exit_criteria": [ + { + "id": "EC-01", + "statement": "Every public repository has a reviewed lifecycle and disposition.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-02", + "statement": "No public canonical domain has more than one owner.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-03", + "statement": "Each consolidation preserves licenses, provenance, issues, releases, and useful history.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-04", + "statement": "Each archive or retirement passes the reference, package, release, installer, domain, and rollback gate.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-05", + "statement": "No visibility change, archive, transfer, or deletion occurs without explicit authorization and recorded evidence.", + "state": "open", + "evidence": [] + }, + { + "id": "EC-06", + "statement": "Generated portfolio and live GitHub inventory agree.", + "state": "open", + "evidence": [] + } + ], + "review_by": "2026-10-03", + "authority_boundary": "This initiative coordinates responsibility and evidence. It does not grant protected identity, authorization, runtime, persistence, release, publication, or GitHub-administration authority." +} diff --git a/policies/administration-and-recovery.md b/policies/administration-and-recovery.md new file mode 100644 index 0000000..6b24aa3 --- /dev/null +++ b/policies/administration-and-recovery.md @@ -0,0 +1,37 @@ +# GitHub administration, automation, and recovery + +## Separation of roles + +Use separate identities and scopes: + +- **drift observer**: repository metadata/content read plus issue write only in `.github` for one deduplicated report; +- **administrative reconciler**: no standing personal token; a narrowly scoped GitHub App token minted only in a protected environment after reviewed approval; +- **human administrators**: accountable organization owners with hardware-backed MFA and documented break-glass custody. + +The observer must never change repository settings. The reconciler must run plan-first, bind application to the reviewed immutable plan and current settings snapshot, and stop on moved state. + +## Least privilege + +- Default Actions permissions to read-only. +- Grant write permissions per job only when required. +- Prefer GitHub Apps and OIDC over classic PATs or long-lived secrets. +- Restrict repository creation, transfer, visibility, archive, deletion, rulesets, Apps, and environment administration. +- Keep secrets and privileged runners unavailable to untrusted fork pull requests. +- Pin third-party Actions by full commit SHA and review updates. +- Protect workflow, schema, registry, decision, security, release, and migration paths with CODEOWNERS and rulesets. + +## Administrative evidence + +Policy files do not prove settings are applied. Close an administrative control only with exported settings or API snapshots, exact ruleset/environment identifiers, app scope inventory, and positive/negative test evidence. + +## Break-glass + +- Maintain at least two custodians when staffing permits. +- Store recovery material outside GitHub using an approved secure process. +- Limit bypass to named emergencies and record every use. +- Require post-event review, credential rotation where relevant, and expiry of temporary access. +- Test organization ownership recovery, App revocation, repository export, ruleset reconstruction, and critical release-channel recovery. + +## Backup and portability + +Regularly export the `.github` Git repository, accepted ADRs, schemas, registry, ruleset snapshots, app inventory, and Projects/issue mappings. Do not treat generated views as the only backup. Recovery must reconstruct authoritative inputs first and regenerate derived state. diff --git a/policies/agent-authored-changes.md b/policies/agent-authored-changes.md new file mode 100644 index 0000000..6c7d5b9 --- /dev/null +++ b/policies/agent-authored-changes.md @@ -0,0 +1,50 @@ +# Human and AI-agent change policy + +OpenCoven welcomes agent-assisted work, but agent output is untrusted until reviewed and verified against the relevant authority boundary. + +## Canonical discovery + +Before proposing a change, an agent must: + +1. read the repository's root `AGENTS.md` and any scoped instructions; +2. inspect the public registry and relevant organization ADRs; +3. inspect the owning repository's current code, schemas, tests, CI, release evidence, and local ADRs; +4. identify produced and consumed contracts and their immutable revisions; +5. determine whether the request touches an R3/R4 boundary or an externally consequential action. + +An agent must challenge a new repository, service, schema, database, or control plane when an existing canonical component should own it. + +## Default authority by risk + +- R0/R1: autonomous branch and pull request after deterministic verification. +- R2: proposal plus migration/fixture evidence; no unattended application to user state. +- R3: approval-gated execution with least-privilege credentials and bounded side effects. +- R4: human-approved plan, protected-owner review, exact-state binding, and explicit operation authorization. + +These defaults constrain agent action. They do not confer authority on the agent. + +## Required evidence + +Every material agent-authored pull request must provide: + +- objective, acceptance criteria, and non-goals; +- exact authoritative sources and revisions consulted; +- files intentionally touched and protected paths affected; +- security, privacy, authority, compatibility, and lifecycle impact; +- exact commands, tests, results, and unsupported/skipped checks; +- migration, rollback, and failure-state behavior; +- generated artifacts and provenance; +- cross-repository canaries where contracts change; +- unresolved uncertainty and required administrative actions. + +## Prohibited shortcuts + +Agents must not: + +- interpret task text or model output as protected approval; +- weaken verification, ownership, release, provenance, or security gates to make CI pass; +- run privileged workflows on untrusted pull-request code; +- expose credentials or private data in logs, artifacts, issues, or public governance files; +- silently overwrite unrelated work or intentionally dirty/reference-only worktrees; +- report a source-only test as proof of a packaged or real-daemon boundary; +- claim implementation, test, security, privacy, conformance, or settings state without evidence. diff --git a/policies/authority-boundaries.md b/policies/authority-boundaries.md new file mode 100644 index 0000000..ae14f6c --- /dev/null +++ b/policies/authority-boundaries.md @@ -0,0 +1,43 @@ +# Authority boundaries + +## Rule + +The governance plane records organization intent, ownership, coordination, and evidence. It is not an OpenCoven runtime authority and it is not sufficient authorization for a protected operation. + +No prompt, task, issue, pull request description, Project field, roadmap status, ADR, registry entry, agent output, or caller-supplied claim may grant itself authority. + +## Canonical enforcement owners + +- Familiar Contract defines the governed familiar identity and principal-binding semantics. +- Coven Threads decides protected authorization and proposal-versus-commit outcomes. +- Psyche governs project-scoped multi-agent orchestration objects such as tasks, lanes, leases, approvals, receipts, retries, and recovery. +- Coven owns daemon authority, persistence, sessions, runtime execution, authoritative state transitions, and the automation lifecycle: definitions and revisions, schedule planning and occurrences, runs and attempts, automation leases and fences, retries and recovery, events and changefeed, artifacts, and receipts. Coven binds Familiar Contract identity and Coven Threads authorization evidence into automation records but does not own those identity or authorization semantics. +- Repository and organization rulesets govern GitHub administration. +- Release and publication systems govern their own approval and commit boundaries. + +The final verification and commit for a protected operation must be atomic or use the same immutable snapshot. A pending proposal must never be rendered as committed state. + +## Governance states + +Use precise state labels: + +- **specified**: documented, not necessarily implemented; +- **implemented**: code or configuration exists; +- **verified**: evidence shows the named behavior under the named conditions; +- **administratively applied**: an authorized GitHub or service administrator applied the setting; +- **operationally effective**: recurring testing shows the control continues to work; +- **proposed**, **experimental**, **degraded**, **stale**, **reconciling**, **rejected**, and **unavailable** where applicable. + +Never collapse these into a generic “complete,” “secure,” or “compliant” claim. + +## Agent behavior + +When requested to perform a protected change without authenticated authority, an agent must: + +1. preserve the request as a proposal; +2. identify the canonical authority and required evidence; +3. avoid side effects; +4. surface stale, missing, moved, or contradictory state; +5. reject the operation when degradation to a proposal would itself be unsafe. + +Prefer **Permit / Degrade to Proposal / Reject**. diff --git a/policies/evidence-and-verification.md b/policies/evidence-and-verification.md new file mode 100644 index 0000000..2a93399 --- /dev/null +++ b/policies/evidence-and-verification.md @@ -0,0 +1,47 @@ +# Evidence and verification policy + +## Evidence hierarchy + +Strong evidence is source-adjacent, exact, reproducible, and bound to the state being evaluated. Prefer: + +1. accepted schemas and normative artifacts; +2. immutable source and dependency revisions; +3. deterministic tests and negative vectors; +4. packed artifact, signed release, or real-daemon results where the boundary requires them; +5. GitHub settings/ruleset/API snapshots for administrative controls; +6. machine-readable receipts with command, environment, result, and provenance. + +Narrative summaries, badges, dashboards, and model conclusions are derived evidence and must link to the underlying result. + +## Required distinctions + +Report independently: + +- structural validity; +- repository verification; +- runtime authority behavior; +- continuity behavior; +- privacy behavior; +- interoperability behavior; +- packaged/release artifact verification; +- administrative-control application; +- operational control effectiveness. + +Never collapse partial results into a generic “compliant” or “secure” label. + +## Control-effectiveness testing + +A declared control is effective only when: + +- the enforcing mechanism is identified; +- the intended and negative paths are tested; +- bypass and administrator behavior are known; +- the evidence names exact revisions and settings; +- drift is detected on a defined cadence; +- stale, degraded, or unavailable evidence is represented explicitly. + +## Evidence packets + +Use `schemas/evidence-packet.schema.json`. Evidence packets are append-only review artifacts for a named change. Corrections create a new revision or superseding packet rather than erasing prior evidence. + +Do not place secrets, personal data, private prompts, private memories, raw terminal history, or embargoed findings in public evidence. diff --git a/policies/exceptions.md b/policies/exceptions.md new file mode 100644 index 0000000..1cdc8fa --- /dev/null +++ b/policies/exceptions.md @@ -0,0 +1,23 @@ +# Exceptions and temporary waivers + +Exceptions are a controlled escape hatch, not a parallel policy system. + +Every exception must be recorded in `governance/exceptions.json` and include: + +- unique identifier and affected control; +- narrow scope and exact repositories/paths where public; +- owner and approving authority; +- rationale and risk assessment; +- compensating controls; +- creation and expiry dates; +- required remediation and verification; +- status: `proposed`, `active`, `expired`, `closed`, or `revoked`. + +Rules: + +1. No exception may grant familiar identity, protected mutation, runtime, release, publication, or organization-administration authority. +2. R4 exceptions require protected-owner review and an explicit, operation-specific authorization path. +3. An active exception must expire within 90 days unless a stricter control applies. +4. Expired active exceptions fail CI. +5. An exception cannot suppress evidence of drift; it may only explain a reviewed and bounded deviation. +6. Closing an exception requires evidence that the control is restored or the policy was superseded through an ADR. diff --git a/policies/initiatives-and-decisions.md b/policies/initiatives-and-decisions.md new file mode 100644 index 0000000..9dfcfce --- /dev/null +++ b/policies/initiatives-and-decisions.md @@ -0,0 +1,35 @@ +# Initiative and decision procedure + +## Cross-repository initiatives + +Create an initiative when one outcome requires work in more than one canonical repository or requires an organization-level ownership, lifecycle, compatibility, or sequencing decision. + +Each initiative has one decision owner, one technical DRI, explicit workstream owners, dependencies, non-goals, review date, and evidence-backed exit criteria. Avoid percent-complete fields. Derive operational status from linked issues and immutable evidence where possible. + +Implementation tasks remain in their owning repositories. The central initiative links them and defines the shared outcome; it does not copy their mutable task descriptions. + +## Status changes + +- `proposed`: scope and ownership under review; +- `active`: accepted outcome with work in progress; +- `verifying`: implementation is present and exit evidence is being assembled; +- `completed`: every criterion has exact evidence and no unresolved blocking risk; +- `superseded`: another initiative owns the outcome; +- `cancelled`: intentionally stopped with rationale and residual-risk disposition. + +A status change requires a pull request. `completed` without evidence must fail validation. + +## ADR placement + +Place an ADR here only when it changes organization-spanning ownership, compatibility, lifecycle, sequencing, public/private boundary, or governance invariants. Keep component design and implementation ADRs in the owning repository. + +Accepted ADRs are immutable historical records. Amend by a new ADR that supersedes or narrows the prior decision; do not silently rewrite the old rationale. + +## Conflict resolution + +1. Stop any protected or irreversible action affected by the conflict. +2. Identify the canonical owner using the registry and current implementation evidence. +3. Gather exact repository revisions, contracts, tests, and settings snapshots. +4. Let the relevant decision owner resolve scope; require canonical protected authority for any protected operation. +5. Record the decision and migrate or deprecate conflicting surfaces. +6. Add a regression check where the conflict could recur mechanically. diff --git a/policies/public-private-data.md b/policies/public-private-data.md new file mode 100644 index 0000000..755f1be --- /dev/null +++ b/policies/public-private-data.md @@ -0,0 +1,35 @@ +# Public and private governance data + +`OpenCoven/.github` is public. Public transparency is useful only when it does not disclose private inventory, security material, personal data, or operational secrets. + +## Public data allowed here + +- public repository names and observed public GitHub metadata; +- public canonical-domain ownership, lifecycle, risk class, and disposition; +- public cross-repository initiatives and non-sensitive dependency relationships; +- organization policies, schemas, reusable read-only workflows, and generated views; +- public GitHub identities serving as owners or DRIs; +- links to public issues, pull requests, releases, and evidence. + +## Data prohibited here + +- private repository names or confidential product codenames unless separately approved for publication; +- credentials, tokens, secret values, recovery material, private endpoints, or internal network detail; +- non-public vulnerability reports, exploit detail, embargo status, or incident evidence; +- prompts, memories, conversation transcripts, terminal logs, private file paths, session identifiers, or user data; +- private contractual, commercial, employment, legal, partnership, or financial records; +- unnecessary personal information, contact data, behavioral profiles, or contributor metadata. + +## Private federation + +Private repositories should carry repository-local `agent/manifest.json` records conforming to the public schema. Access-controlled Projects or a future approved private projection may aggregate those manifests. + +A public initiative may use an opaque reference such as `private-overlay: github-delivery` to acknowledge a private workstream. It must not reveal the backing repository, members, incidents, or implementation details. + +## Privacy principles + +- Minimize collected data and fields. +- Use stable public GitHub identities only where accountability requires them. +- Avoid copying issue or commit personal data into derived governance records. +- Retain accepted decisions and contribution history as public open-source records, but expire temporary exceptions and operational details. +- Never claim GDPR, CCPA, ISO, SOC 2, or another compliance status based solely on this policy. diff --git a/policies/repository-lifecycle.md b/policies/repository-lifecycle.md new file mode 100644 index 0000000..226bb67 --- /dev/null +++ b/policies/repository-lifecycle.md @@ -0,0 +1,47 @@ +# Repository lifecycle and creation policy + +## Lifecycle states + +The machine-readable state machine is in `governance/lifecycle.json`. + +- **incubating**: experimental, time-bounded, and not a canonical public dependency; +- **active**: actively developed with an accountable owner and verification path; +- **maintenance**: supported with limited change and an explicit review cadence; +- **deprecated**: successor or retirement plan exists; no new canonical surface; +- **archived**: read-only historical record; +- **tombstone**: minimal successor/provenance pointer after an approved retirement. + +Canonicality is separate from lifecycle. An active repository may be supporting or specimen-only; an archived repository is historical and cannot retain a current canonical claim. + +## New repository gate + +Before a repository is created or made public, record a proposal that answers: + +1. Which existing canonical component was evaluated, and why can it not own this work? +2. What unique domain, product, distribution, or experiment boundary justifies a repository? +3. Who is the owner, technical DRI, and successor if the owner becomes unavailable? +4. What lifecycle, risk class, visibility, license, security support, and data classification apply? +5. What bootstrap, fast verification, release, archival, and rollback procedures exist? +6. Which contracts are produced and consumed, and how are they versioned and pinned? +7. What is the 30- or 90-day graduation/retirement criterion? + +Repository creation metadata cannot authorize the runtime or protected behavior implemented inside it. + +## Public graduation gate + +An incubating repository may become active public only when: + +- its canonicality and non-goals are reviewed; +- no canonical public domain conflicts exist; +- a root agent guide or equivalent route exists; +- deterministic bootstrap and fast verification work from a clean clone; +- security policy, license, contribution provenance, and release status are truthful; +- R3/R4 paths have protected ownership and evidence requirements; +- downstream consumers use immutable contract or artifact references where applicable; +- the governance registry and live GitHub metadata agree. + +## Review and succession + +Every active public repository has one owner and one technical DRI. During the bootstrap-single-owner phase, `BunsDev` may hold both roles, but the registry must not imply healthy separation of duties. Each R3/R4 repository should add a qualified backup reviewer before claiming mature governance. + +Ownership changes require a reviewed registry change recording the effective date, outgoing and incoming accountable identities, unresolved risk, and transition evidence. Git history provides provenance; do not erase prior ownership records. diff --git a/policies/repository-retirement.md b/policies/repository-retirement.md new file mode 100644 index 0000000..852343c --- /dev/null +++ b/policies/repository-retirement.md @@ -0,0 +1,30 @@ +# Repository consolidation, archival, and retirement + +Repository removal is an externally consequential and often irreversible operation. A registry disposition is a plan, not authorization to archive, transfer, privatize, or delete. + +## Required sequence + +1. **Inventory references** across source, docs, workflows, package manifests, submodules, badges, domains, webhooks, GitHub Apps, release scripts, update feeds, and installation instructions. +2. **Inventory distribution** across npm, crates.io, PyPI, Maven, SwiftPM, Homebrew, containers, downloadable artifacts, checksums, attestations, and evergreen URLs. +3. **Preserve provenance**: tags, releases, issues, discussions, advisories, licenses, notices, contributor history, and any legally required records. +4. **Select destination** and migrate only non-duplicative value with traceable commits or documented extraction receipts. +5. **Publish a successor notice** that distinguishes current canonical behavior from historical material. +6. **Archive first** for an observation period unless a documented legal or security exception requires another path. +7. **Monitor breakage**: failed downloads, inbound links, package use, installer/update requests, and new issues. +8. **Apply the final action** only with explicit authorization, an immutable reviewed plan, current-state revalidation, and rollback evidence. + +## Retirement evidence packet + +Must include: + +- repository and exact head revision; +- proposed destination or tombstone; +- reference and package searches performed; +- release/download/update-channel findings; +- legal/license/provenance preservation; +- user migration and communications plan; +- observation start/end and monitoring results; +- rollback archive and recovery procedure; +- authorized administrator and exact action receipt. + +Deletion is not the default. Prefer consolidation plus archive/tombstone when historical links, releases, citations, or provenance remain valuable. diff --git a/policies/security-and-supply-chain.md b/policies/security-and-supply-chain.md new file mode 100644 index 0000000..51dbbf0 --- /dev/null +++ b/policies/security-and-supply-chain.md @@ -0,0 +1,32 @@ +# Security and software-supply-chain policy + +## Threat model + +The governance plane is a high-impact target because compromised policy, workflows, manifests, or generated compatibility data can misroute reviewers, weaken checks, or induce downstream repositories to trust the wrong artifact. It remains metadata unless backed by enforcement, but metadata compromise can still create substantial operational harm. + +Threats include: + +- compromised maintainer or organization-owner accounts; +- malicious or vulnerable third-party Actions; +- untrusted pull-request code reaching secrets or privileged runners; +- workflow modification followed by self-approval; +- mutable dependency/action references; +- forged ownership, conformance, release, or evidence records; +- stale registry state and confused-deputy automation; +- log/artifact disclosure of private or sensitive data; +- supply-chain substitution between source, generated code, package, and release artifact. + +## Baseline controls + +- Require protected pull-request review and CODEOWNER approval for R4 paths. +- Pin third-party Actions to full commit SHAs. +- Keep workflow permissions explicit and read-only by default. +- Separate build/test from privileged publish/reconcile jobs and environments. +- Use dependency review, secret scanning, lockfiles, reproducible generation, SBOMs, checksums, provenance/attestations, and signing where the owning repository's release model supports them. +- Verify immutable producer artifacts before consumer canaries. +- Run negative vectors for malformed, downgraded, moved, stale, replayed, and unauthorized inputs. +- Treat SLSA, OpenSSF Scorecard, SPDX/CycloneDX, and Sigstore as useful control frameworks and tooling—not automatic proof of product security or certification. + +## Release boundary + +This repository may describe release-train policy but does not approve or publish releases. Release jobs must bind approval to exact source, lockfile, generated output, artifact digest, environment, and conformance evidence. A main-branch unit test is not a substitute for packaged-artifact verification. diff --git a/schemas/agent-manifest.schema.json b/schemas/agent-manifest.schema.json new file mode 100644 index 0000000..83476ec --- /dev/null +++ b/schemas/agent-manifest.schema.json @@ -0,0 +1,210 @@ +{ + "$id": "https://opencoven.ai/schemas/agent-manifest-v1.json", + "title": "OpenCoven repository agent contract", + "type": "object", + "required": [ + "schema_version", + "repository", + "risk", + "agent", + "contracts" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.agent-repo/v1" + }, + "repository": { + "type": "object", + "required": [ + "name", + "lifecycle", + "canonicality", + "canonical_for", + "does_not_own", + "owner", + "technical_dri", + "ownership_status" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[.A-Za-z0-9_-]+$" + }, + "lifecycle": { + "enum": [ + "incubating", + "active", + "maintenance", + "deprecated", + "archived", + "tombstone" + ] + }, + "canonicality": { + "enum": [ + "canonical", + "supporting", + "specimen", + "historical", + "none" + ] + }, + "canonical_for": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "does_not_own": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "technical_dri": { + "type": "string", + "minLength": 1 + }, + "ownership_status": { + "enum": [ + "bootstrap-single-owner", + "separated", + "delegated", + "vacant" + ] + } + }, + "additionalProperties": false + }, + "risk": { + "type": "object", + "required": [ + "class", + "protected_paths", + "generated_paths", + "network_policy", + "secrets_policy", + "external_side_effects" + ], + "properties": { + "class": { + "enum": [ + "R0", + "R1", + "R2", + "R3", + "R4" + ] + }, + "protected_paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "generated_paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "network_policy": { + "enum": [ + "deny", + "deny-by-default", + "explicit-allowlist", + "required" + ] + }, + "secrets_policy": { + "type": "string", + "minLength": 1 + }, + "external_side_effects": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + }, + "agent": { + "type": "object", + "required": [ + "entrypoint", + "bootstrap", + "verify" + ], + "properties": { + "entrypoint": { + "type": "string", + "minLength": 1 + }, + "bootstrap": { + "type": "string", + "minLength": 1 + }, + "verify": { + "type": "object", + "required": [ + "fast", + "full" + ], + "properties": { + "fast": { + "type": "string", + "minLength": 1 + }, + "full": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "contracts": { + "type": "object", + "required": [ + "produces", + "consumes" + ], + "properties": { + "produces": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "consumes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/contracts.schema.json b/schemas/contracts.schema.json new file mode 100644 index 0000000..5da811b --- /dev/null +++ b/schemas/contracts.schema.json @@ -0,0 +1,54 @@ +{ + "$id": "https://opencoven.ai/schemas/contracts-v1.json", + "title": "OpenCoven public contract index", + "type": "object", + "required": [ + "schema_version", + "contracts", + "claim_rule" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.contract-index/v1" + }, + "contracts": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "owner", + "status", + "immutable_release_required" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "owner": { + "type": "string", + "pattern": "^[.A-Za-z0-9_-]+$" + }, + "status": { + "type": "string", + "minLength": 1 + }, + "immutable_release_required": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "claim_rule": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/controls.schema.json b/schemas/controls.schema.json new file mode 100644 index 0000000..56b788e --- /dev/null +++ b/schemas/controls.schema.json @@ -0,0 +1,63 @@ +{ + "$id": "https://opencoven.ai/schemas/controls-v1.json", + "title": "OpenCoven governance controls", + "type": "object", + "required": [ + "schema_version", + "controls" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.controls/v1" + }, + "controls": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "title", + "objective", + "evidence", + "enforcement", + "status" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^GOV-[0-9]{3}$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "objective": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "enforcement": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/decision-index.schema.json b/schemas/decision-index.schema.json new file mode 100644 index 0000000..8f4dc60 --- /dev/null +++ b/schemas/decision-index.schema.json @@ -0,0 +1,59 @@ +{ + "$id": "https://opencoven.ai/schemas/decision-index-v1.json", + "title": "OpenCoven decision index", + "type": "object", + "required": [ + "schema_version", + "decisions" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.decision-index/v1" + }, + "decisions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "title", + "status", + "path", + "date" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^ADR-[0-9]{4}$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "proposed", + "accepted", + "superseded", + "rejected" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "date": { + "type": "string", + "format": "date" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/dependencies.schema.json b/schemas/dependencies.schema.json new file mode 100644 index 0000000..eb7b7e5 --- /dev/null +++ b/schemas/dependencies.schema.json @@ -0,0 +1,50 @@ +{ + "$id": "https://opencoven.ai/schemas/dependencies-v1.json", + "title": "OpenCoven public dependency graph", + "type": "object", + "required": [ + "schema_version", + "edges" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.dependencies/v1" + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": [ + "producer", + "consumer", + "relationship", + "required_evidence" + ], + "properties": { + "producer": { + "type": "string", + "pattern": "^[.A-Za-z0-9_-]+$" + }, + "consumer": { + "type": "string", + "pattern": "^[.A-Za-z0-9_-]+$" + }, + "relationship": { + "type": "string", + "minLength": 1 + }, + "required_evidence": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/evidence-packet.schema.json b/schemas/evidence-packet.schema.json new file mode 100644 index 0000000..b68cffd --- /dev/null +++ b/schemas/evidence-packet.schema.json @@ -0,0 +1,169 @@ +{ + "$id": "https://opencoven.ai/schemas/governance-evidence-v1.json", + "title": "OpenCoven governance evidence packet", + "type": "object", + "required": [ + "schema_version", + "change", + "authority", + "sources", + "files", + "verification", + "migration", + "rollback", + "uncertainty" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.governance-evidence/v1" + }, + "change": { + "type": "object", + "required": [ + "objective", + "acceptance_criteria", + "non_goals" + ], + "properties": { + "objective": { + "type": "string", + "minLength": 1 + }, + "acceptance_criteria": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "non_goals": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + }, + "authority": { + "type": "object", + "required": [ + "risk_class", + "protected_boundaries", + "authorization_effect" + ], + "properties": { + "risk_class": { + "enum": [ + "R0", + "R1", + "R2", + "R3", + "R4" + ] + }, + "protected_boundaries": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "authorization_effect": { + "const": "none-metadata-only" + } + }, + "additionalProperties": false + }, + "sources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "kind", + "reference", + "revision" + ], + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "reference": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "files": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "verification": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "command", + "result", + "environment" + ], + "properties": { + "command": { + "type": "string", + "minLength": 1 + }, + "result": { + "enum": [ + "pass", + "fail", + "skipped", + "unsupported" + ] + }, + "environment": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "migration": { + "type": "string", + "minLength": 1 + }, + "rollback": { + "type": "string", + "minLength": 1 + }, + "uncertainty": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/exception.schema.json b/schemas/exception.schema.json new file mode 100644 index 0000000..3d6a20c --- /dev/null +++ b/schemas/exception.schema.json @@ -0,0 +1,98 @@ +{ + "$id": "https://opencoven.ai/schemas/exception-set-v1.json", + "title": "OpenCoven governance exceptions", + "type": "object", + "required": [ + "schema_version", + "exceptions" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.exception-set/v1" + }, + "exceptions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "control_id", + "scope", + "owner", + "approver", + "rationale", + "risk", + "compensating_controls", + "created", + "expires", + "status", + "remediation" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "control_id": { + "type": "string", + "minLength": 1 + }, + "scope": { + "type": "string", + "minLength": 1 + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "approver": { + "type": "string", + "minLength": 1 + }, + "rationale": { + "type": "string", + "minLength": 1 + }, + "risk": { + "type": "string", + "minLength": 1 + }, + "compensating_controls": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "created": { + "type": "string", + "format": "date" + }, + "expires": { + "type": "string", + "format": "date" + }, + "status": { + "enum": [ + "proposed", + "active", + "expired", + "closed", + "revoked" + ] + }, + "remediation": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/initiative.schema.json b/schemas/initiative.schema.json new file mode 100644 index 0000000..eb5c532 --- /dev/null +++ b/schemas/initiative.schema.json @@ -0,0 +1,181 @@ +{ + "$id": "https://opencoven.ai/schemas/initiative-v1.json", + "title": "OpenCoven cross-repository initiative", + "type": "object", + "required": [ + "schema_version", + "id", + "title", + "status", + "priority", + "decision_owner", + "technical_dri", + "ownership_status", + "outcome", + "non_goals", + "decisions", + "dependencies", + "workstreams", + "exit_criteria", + "review_by", + "authority_boundary" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.initiative/v1" + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]+$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "proposed", + "active", + "verifying", + "completed", + "superseded", + "cancelled" + ] + }, + "priority": { + "enum": [ + "P0", + "P1", + "P2", + "P3" + ] + }, + "decision_owner": { + "type": "string", + "minLength": 1 + }, + "technical_dri": { + "type": "string", + "minLength": 1 + }, + "ownership_status": { + "enum": [ + "bootstrap-single-owner", + "separated", + "delegated", + "vacant" + ] + }, + "outcome": { + "type": "string", + "minLength": 1 + }, + "non_goals": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "pattern": "^ADR-[0-9]{4}$" + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]+$" + } + }, + "workstreams": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "repository", + "responsibility", + "issues", + "state" + ], + "properties": { + "repository": { + "type": "string", + "pattern": "^[.A-Za-z0-9_-]+$" + }, + "responsibility": { + "type": "string", + "minLength": 1 + }, + "issues": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "state": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "exit_criteria": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "statement", + "state", + "evidence" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "statement": { + "type": "string", + "minLength": 1 + }, + "state": { + "enum": [ + "open", + "met", + "waived" + ] + }, + "evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + } + }, + "review_by": { + "type": "string", + "format": "date" + }, + "authority_boundary": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/lifecycle.schema.json b/schemas/lifecycle.schema.json new file mode 100644 index 0000000..f63716e --- /dev/null +++ b/schemas/lifecycle.schema.json @@ -0,0 +1,30 @@ +{ + "$id": "https://opencoven.ai/schemas/lifecycle-v1.json", + "title": "OpenCoven lifecycle and risk model", + "type": "object", + "required": [ + "schema_version", + "lifecycle_states", + "canonicality_states", + "risk_classes" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.lifecycle/v1" + }, + "lifecycle_states": { + "type": "object" + }, + "canonicality_states": { + "type": "object" + }, + "risk_classes": { + "type": "object" + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/release-trains.schema.json b/schemas/release-trains.schema.json new file mode 100644 index 0000000..62cf1d5 --- /dev/null +++ b/schemas/release-trains.schema.json @@ -0,0 +1,49 @@ +{ + "$id": "https://opencoven.ai/schemas/release-trains-v1.json", + "title": "OpenCoven release train index", + "type": "object", + "required": [ + "schema_version", + "release_trains" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.release-trains/v1" + }, + "release_trains": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "members", + "policy" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "members": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[.A-Za-z0-9_-]+$" + }, + "minItems": 1 + }, + "policy": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/repository-registry.schema.json b/schemas/repository-registry.schema.json new file mode 100644 index 0000000..92befc9 --- /dev/null +++ b/schemas/repository-registry.schema.json @@ -0,0 +1,308 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/schemas/repository-registry-v1.json", + "title": "OpenCoven public repository registry", + "type": "object", + "required": [ + "schema_version", + "organization", + "scope", + "defaults", + "repositories" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema_version": { + "const": "opencoven.repository-registry/v1" + }, + "organization": { + "const": "OpenCoven" + }, + "scope": { + "type": "object", + "required": [ + "visibility", + "observed_as_of", + "expected_public_repository_count", + "private_inventory" + ], + "properties": { + "visibility": { + "const": "public-only" + }, + "observed_as_of": { + "type": "string", + "format": "date" + }, + "expected_public_repository_count": { + "type": "integer", + "minimum": 0 + }, + "private_inventory": { + "const": "federated-and-intentionally-omitted" + }, + "private_overlay_policy": { + "type": "string" + } + }, + "additionalProperties": false + }, + "defaults": { + "type": "object", + "required": [ + "visibility", + "observed", + "owner", + "technical_dri", + "ownership_status", + "canonical_domains", + "does_not_own", + "disposition", + "agent_manifest", + "security_support" + ], + "properties": { + "visibility": { + "const": "public" + }, + "observed": { + "type": "object", + "properties": { + "default_branch": { + "type": "string", + "minLength": 1 + }, + "archived": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "technical_dri": { + "type": "string", + "minLength": 1 + }, + "ownership_status": { + "enum": [ + "bootstrap-single-owner", + "separated", + "delegated", + "vacant" + ] + }, + "canonical_domains": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "does_not_own": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "disposition": { + "type": "object", + "required": ["state", "review_by"], + "properties": { + "state": { + "type": "string", + "minLength": 1 + }, + "review_by": { + "type": "string", + "format": "date" + }, + "destination": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "agent_manifest": { + "type": "object", + "properties": { + "status": { + "enum": [ + "enforced", + "planned", + "exempt" + ] + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "security_support": { + "enum": [ + "active", + "limited", + "unsupported", + "historical" + ] + } + }, + "additionalProperties": false + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "lifecycle", + "canonicality", + "risk_class", + "purpose" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[.A-Za-z0-9_-]+$" + }, + "lifecycle": { + "enum": [ + "incubating", + "active", + "maintenance", + "deprecated", + "archived", + "tombstone" + ] + }, + "canonicality": { + "enum": [ + "canonical", + "supporting", + "specimen", + "historical", + "none" + ] + }, + "risk_class": { + "enum": [ + "R0", + "R1", + "R2", + "R3", + "R4" + ] + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "visibility": { + "const": "public" + }, + "observed": { + "type": "object", + "properties": { + "default_branch": { + "type": "string", + "minLength": 1 + }, + "archived": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "technical_dri": { + "type": "string", + "minLength": 1 + }, + "ownership_status": { + "enum": [ + "bootstrap-single-owner", + "separated", + "delegated", + "vacant" + ] + }, + "canonical_domains": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "does_not_own": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "disposition": { + "type": "object", + "properties": { + "state": { + "type": "string", + "minLength": 1 + }, + "review_by": { + "type": "string", + "format": "date" + }, + "destination": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "agent_manifest": { + "type": "object", + "properties": { + "status": { + "enum": [ + "enforced", + "planned", + "exempt" + ] + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "security_support": { + "enum": [ + "active", + "limited", + "unsupported", + "historical" + ] + } + }, + "additionalProperties": false + }, + "uniqueItems": true + } + }, + "additionalProperties": false +} diff --git a/scripts/agent-bootstrap b/scripts/agent-bootstrap new file mode 100755 index 0000000..dafa10d --- /dev/null +++ b/scripts/agent-bootstrap @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +command -v python3 >/dev/null 2>&1 || { + echo "python3 is required" >&2 + exit 1 +} + +python3 - <<'PY' +import sys +if sys.version_info < (3, 11): + raise SystemExit(f"Python 3.11+ is required; found {sys.version.split()[0]}") +print(f"Python {sys.version.split()[0]} available.") +PY + +for path in README.md AGENTS.md agent/manifest.json governance/repositories.json scripts/governance.py; do + test -f "$path" || { echo "missing required path: $path" >&2; exit 1; } +done + +echo "OpenCoven governance bootstrap passed; no dependencies installed and no network used." diff --git a/scripts/agent-check b/scripts/agent-check new file mode 100755 index 0000000..cb556e9 --- /dev/null +++ b/scripts/agent-check @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +MODE="${1:-fast}" + +case "$MODE" in + fast) + ./scripts/agent-bootstrap + python3 scripts/governance.py validate + python3 scripts/governance.py generate --check + python3 -m unittest discover -s tests -v + ;; + full) + "$0" fast + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git diff --exit-code -- generated + fi + ;; + *) + echo "usage: $0 {fast|full}" >&2 + exit 2 + ;; +esac diff --git a/scripts/governance.py b/scripts/governance.py new file mode 100755 index 0000000..2903289 --- /dev/null +++ b/scripts/governance.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Validate, generate, and reconcile the OpenCoven public governance plane. + +The deterministic validation/generation path uses only the Python standard +library and performs no network access. GitHub reconciliation is an explicit, +separate command intended for the scheduled least-privilege workflow. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +# Re-export the public validation surface used by repository tests and adopters. +from governance_core import ( # noqa: E402,F401 + ROOT, expanded_repositories, validate_exception_data, + validate_initiative_data, validate_manifest_data, validate_registry_data, + resolve_trusted_target_file, validate_reusable_invocation, +) +from governance_model import Governance # noqa: E402,F401 +from governance_cli import ( # noqa: E402,F401 + GRAPHQL_BOT_LOGIN, GRAPHQL_BOT_TYPENAME, MANAGED_ISSUE_AUTHOR, + MANAGED_ISSUE_MARKER, MANAGED_ISSUE_TITLE, command_reconcile, + fetch_open_issues, fetch_open_issues_readonly, find_managed_drift_issue, + main, reconcile_public_inventory, upsert_drift_issue, +) + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/governance_cli.py b/scripts/governance_cli.py new file mode 100644 index 0000000..3cd54d9 --- /dev/null +++ b/scripts/governance_cli.py @@ -0,0 +1,548 @@ +"""CLI and explicit network reconciliation for the OpenCoven governance plane.""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Any + +from governance_core import ROOT, validate_reusable_invocation +from governance_model import Governance + +def github_request(url: str, *, token: str | None = None, method: str = "GET", payload: dict[str, Any] | None = None) -> Any: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "OpenCoven-governance-reconciler/1", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + body = None + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(url, data=body, method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + content = response.read() + return json.loads(content) if content else None + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"GitHub API {method} {url} failed: HTTP {exc.code}: {detail[:500]}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"GitHub API {method} {url} failed: {exc}") from exc + + +def fetch_public_repositories(org: str, token: str | None) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + page = 1 + while True: + query = urllib.parse.urlencode({"type": "public", "per_page": 100, "page": page}) + batch = github_request(f"https://api.github.com/orgs/{urllib.parse.quote(org)}/repos?{query}", token=token) + if not isinstance(batch, list): + raise RuntimeError("unexpected GitHub repository response") + result.extend(item for item in batch if item.get("visibility", "public") == "public" and not item.get("private", False)) + if len(batch) < 100: + break + page += 1 + return result + + +def reconcile_public_inventory(governance: Governance, live: list[dict[str, Any]]) -> list[str]: + declared = governance.registry_map() + actual = {item["name"]: item for item in live} + drift: list[str] = [] + for name in sorted(set(actual) - set(declared), key=str.lower): + drift.append(f"unregistered public repository: `{name}`") + for name in sorted(set(declared) - set(actual), key=str.lower): + drift.append(f"registered repository not present in live public inventory: `{name}`") + for name in sorted(set(declared) & set(actual), key=str.lower): + expected = declared[name]["observed"] + observed = actual[name] + if bool(observed.get("archived")) != expected.get("archived"): + drift.append(f"`{name}` archived mismatch: registry={expected.get('archived')} live={bool(observed.get('archived'))}") + if observed.get("default_branch") != expected.get("default_branch"): + drift.append(f"`{name}` default branch mismatch: registry=`{expected.get('default_branch')}` live=`{observed.get('default_branch')}`") + return drift + + +MANAGED_ISSUE_MARKER = "" +MANAGED_ISSUE_TITLE = "[governance-drift] Public repository registry drift" +MANAGED_ISSUE_AUTHOR = "github-actions[bot]" + +# GitHub's two APIs report the scheduled Actions bot's identity differently: +# REST (`user.login`) reports the suffixed form `github-actions[bot]` +# (`MANAGED_ISSUE_AUTHOR` above), while GraphQL's `author` union reports the +# unsuffixed login `github-actions` together with `__typename: Bot`. These +# constants are that exact GraphQL identity pair; only this exact pair is +# normalized to the canonical `MANAGED_ISSUE_AUTHOR` login used by +# `find_managed_drift_issue`. A login of `github-actions` under any other +# `__typename` (for example a `User` or `Mannequin` that happens to share +# the name) is a distinct identity and must never be implicitly trusted. +GRAPHQL_BOT_TYPENAME = "Bot" +GRAPHQL_BOT_LOGIN = "github-actions" + +GRAPHQL_ENDPOINT = "https://api.github.com/graphql" +MAX_ISSUE_SCAN_PAGES = 500 # safety bound: 500 * 100 = 50,000 open issues per scan + +ISSUES_QUERY = """ +query($owner: String!, $repo: String!, $after: String) { + repository(owner: $owner, name: $repo) { + issues(first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { id number title body author { __typename login } } + } + } +} +""" + + +def _fail_closed(owner: str, repo: str, reason: str) -> "RuntimeError": + return RuntimeError(f"refusing to continue open-issue scan for {owner}/{repo}: {reason}") + + +def _validate_issue_node(node: Any, *, owner: str, repo: str, index: int) -> dict[str, Any]: + if not isinstance(node, dict): + raise _fail_closed(owner, repo, f"node[{index}] is not an object") + node_id = node.get("id") + number = node.get("number") + title = node.get("title") + body = node.get("body") + if not isinstance(node_id, str) or not node_id: + raise _fail_closed(owner, repo, f"node[{index}].id is missing or malformed") + if not isinstance(number, int): + raise _fail_closed(owner, repo, f"node[{index}].number is missing or malformed") + if not isinstance(title, str): + raise _fail_closed(owner, repo, f"node[{index}].title is missing or malformed") + if body is not None and not isinstance(body, str): + raise _fail_closed(owner, repo, f"node[{index}].body is malformed") + author = node.get("author") + login = None + if author is not None: + if not isinstance(author, dict): + raise _fail_closed(owner, repo, f"node[{index}].author is malformed") + login = author.get("login") + typename = author.get("__typename") + if login is not None and not isinstance(login, str): + raise _fail_closed(owner, repo, f"node[{index}].author.login is malformed") + if typename is not None and not isinstance(typename, str): + raise _fail_closed(owner, repo, f"node[{index}].author.__typename is malformed") + # Normalize *only* the exact GraphQL Actions-bot identity + # (__typename == "Bot", login == "github-actions") to the canonical + # REST-style login `find_managed_drift_issue` trusts. Any other + # __typename/login combination — including a non-Bot author whose + # login happens to equal "github-actions" — is left exactly as + # GitHub reported it, so it can never be conflated with the real + # bot identity by that downstream comparison. + if typename == GRAPHQL_BOT_TYPENAME and login == GRAPHQL_BOT_LOGIN: + login = MANAGED_ISSUE_AUTHOR + return {"id": node_id, "number": number, "title": title, "body": body or "", "login": login} + + +def _validate_issues_page(response: Any, *, owner: str, repo: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: + if not isinstance(response, dict): + raise _fail_closed(owner, repo, "GraphQL response is not an object") + errors = response.get("errors") + if errors: + raise _fail_closed(owner, repo, f"GraphQL returned errors: {errors}") + data = response.get("data") + if not isinstance(data, dict): + raise _fail_closed(owner, repo, "GraphQL response is missing `data`") + repository = data.get("repository") + if not isinstance(repository, dict): + raise _fail_closed(owner, repo, "GraphQL repository lookup is missing or inaccessible") + issues = repository.get("issues") + if not isinstance(issues, dict): + raise _fail_closed(owner, repo, "GraphQL response is missing the issues connection") + page_info = issues.get("pageInfo") + if not isinstance(page_info, dict) or not isinstance(page_info.get("hasNextPage"), bool): + raise _fail_closed(owner, repo, "GraphQL response has a malformed pageInfo") + end_cursor = page_info.get("endCursor") + if end_cursor is not None and not isinstance(end_cursor, str): + raise _fail_closed(owner, repo, "GraphQL response has a malformed endCursor") + nodes = issues.get("nodes") + if not isinstance(nodes, list): + raise _fail_closed(owner, repo, "GraphQL response has malformed issue nodes") + validated = [_validate_issue_node(node, owner=owner, repo=repo, index=index) for index, node in enumerate(nodes)] + return page_info, validated + + +def fetch_open_issues(owner: str, repo: str, token: str) -> list[dict[str, Any]]: + """Fetch every open issue via GraphQL cursor pagination in stable creation order. + + This is the mutation-capable scan: it requires an authenticated token and + is the only scan path `upsert_drift_issue` uses when it is about to PATCH + or POST. (Tokenless read-only reporting must use + `fetch_open_issues_readonly` instead — see that function's docstring for + why the two paths have different consistency guarantees.) + + REST page-number pagination is unsafe over a mutable open-issue + collection: a `page=N` request is an absolute offset into whatever set + of open issues matches *at request time*, so if an earlier issue closes + between two page requests, every later issue shifts left by one. That + shift can make the managed issue vanish entirely if it was about to + cross the page boundary, or return a boundary issue on both pages. + + GraphQL connection cursors identify a position relative to the + already-returned node rather than an absolute offset. Traversing in + ascending creation order also means a newly created issue always sorts + after every already-fetched page (creation time only increases), so it + cannot retroactively appear on, or invalidate, a page already fetched. + This does not make the multi-request scan atomic — GitHub offers no + atomic "list open issues" snapshot — but it removes the specific + shift-based skip/duplicate failure mode of offset pagination. + + Every response shape is validated explicitly (`_validate_issues_page`, + `_validate_issue_node`), issue nodes are deduplicated by their immutable + GraphQL node id, and any malformed or inconsistent shape (missing + pageInfo, `hasNextPage: true` without an `endCursor`, a cursor repeated + without forward progress, a duplicate node id across pages, or a + malformed node) fails closed with `RuntimeError` instead of silently + returning a partial, skipped, or duplicated result. + """ + if not token: + raise RuntimeError( + f"fetch_open_issues (GraphQL) requires an authenticated token for {owner}/{repo}; " + "tokenless callers must use fetch_open_issues_readonly for read-only reporting" + ) + result: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + seen_cursors: set[str] = set() + cursor: str | None = None + for page in range(1, MAX_ISSUE_SCAN_PAGES + 1): + response = github_request( + GRAPHQL_ENDPOINT, + token=token, + method="POST", + payload={"query": ISSUES_QUERY, "variables": {"owner": owner, "repo": repo, "after": cursor}}, + ) + page_info, nodes = _validate_issues_page(response, owner=owner, repo=repo) + for node in nodes: + if node["id"] in seen_ids: + raise _fail_closed( + owner, repo, + f"GraphQL returned duplicate issue node id {node['id']!r} across pages, " + "which indicates the open-issue collection was not traversed consistently", + ) + seen_ids.add(node["id"]) + result.append(node) + if not page_info["hasNextPage"]: + return result + end_cursor = page_info.get("endCursor") + if not end_cursor: + raise _fail_closed(owner, repo, "hasNextPage=true was reported without an endCursor") + if end_cursor in seen_cursors: + raise _fail_closed( + owner, repo, + "GraphQL returned a repeated pagination cursor, which indicates the open-issue " + "collection is not being traversed consistently", + ) + seen_cursors.add(end_cursor) + cursor = end_cursor + raise _fail_closed(owner, repo, f"exceeded {MAX_ISSUE_SCAN_PAGES} pages without reaching the end of the connection") + + +def fetch_open_issues_readonly(owner: str, repo: str, token: str | None) -> list[dict[str, Any]]: + """Read-only REST scan of open issues, for tokenless dry-run reporting only. + + `--dry-run` is documented to work without `GITHUB_TOKEN` (unauthenticated + scheduled/local observation), but the GraphQL scan in `fetch_open_issues` + always requires a token — the unauthenticated GraphQL endpoint rejects or + aggressively rate-limits token-less requests, which previously broke the + tokenless dry-run contract outright. This function restores that + contract using ordinary REST `page=N` offset pagination instead. + + Offset pagination over a mutable collection can, in principle, skip or + double-report an issue near a page boundary if the open-issue set + changes between page requests (see `fetch_open_issues`'s docstring for + the full failure mode). That weakness is acceptable *only* here because + this function is used exclusively for read-only, best-effort dry-run + reporting: `upsert_drift_issue` never calls `_patch_issue` or POSTs a + new issue on this path, so an offset shift here cannot itself create a + duplicate issue or apply a stale mutation — at worst the printed dry-run + report under- or over-counts a boundary issue, which is an observational + accuracy tradeoff, not a state-mutation correctness one. This function + must never be used when a run is going to mutate GitHub state. + """ + result: list[dict[str, Any]] = [] + for page in range(1, MAX_ISSUE_SCAN_PAGES + 1): + query = urllib.parse.urlencode({"state": "open", "per_page": 100, "page": page}) + batch = github_request(f"https://api.github.com/repos/{owner}/{repo}/issues?{query}", token=token or None) + if not isinstance(batch, list): + raise _fail_closed(owner, repo, "unexpected GitHub issues response: expected a JSON list") + for index, item in enumerate(batch): + if not isinstance(item, dict): + raise _fail_closed(owner, repo, f"issues response item[{index}] is not an object") + if "pull_request" in item: + continue # the REST /issues endpoint also returns pull requests + number = item.get("number") + title = item.get("title") + body = item.get("body") + if not isinstance(number, int) or not isinstance(title, str): + raise _fail_closed(owner, repo, f"issues response item[{index}] is missing number/title") + if body is not None and not isinstance(body, str): + raise _fail_closed(owner, repo, f"issues response item[{index}].body is malformed") + user = item.get("user") + login = user.get("login") if isinstance(user, dict) else None + result.append({"id": f"rest:{number}", "number": number, "title": title, "body": body or "", "login": login}) + if len(batch) < 100: + return result + raise _fail_closed(owner, repo, f"exceeded {MAX_ISSUE_SCAN_PAGES} pages without reaching the end of open issues") + + +def find_managed_drift_issue(issues: list[dict[str, Any]], *, marker: str, title: str) -> dict[str, Any] | None: + """Locate the single managed drift issue, failing closed on ambiguity or spoofing. + + An issue is trusted as "the" managed issue only when it carries the exact + managed title and marker and was authored by the scheduled workflow's bot + identity (compared as the canonical `MANAGED_ISSUE_AUTHOR` login). + `issues` must already be normalized to the flattened + `{id, number, title, body, login}` shape produced by either + `fetch_open_issues` (GraphQL) or `fetch_open_issues_readonly` (REST). + REST reports that identity natively as `github-actions[bot]`; GraphQL + reports it as the unsuffixed login `github-actions` with + `author.__typename == "Bot"`, so `_validate_issue_node` normalizes only + that exact `(__typename, login)` pair to `MANAGED_ISSUE_AUTHOR` before + this function ever sees it — a differently-typed author whose login + happens to be `github-actions` is left untouched and therefore compares + unequal here, exactly like any other untrusted author. Both scans also + exclude pull requests before returning, so no separate + not-a-pull-request check is needed here. Any other open issue that + merely contains the marker text is treated as a spoof/ambiguity signal: + automated action is refused rather than silently picking a candidate or + creating a duplicate. + """ + trusted: list[dict[str, Any]] = [] + suspicious: list[dict[str, Any]] = [] + for item in issues: + if marker not in (item.get("body") or ""): + continue + is_trusted = item.get("title") == title and item.get("login") == MANAGED_ISSUE_AUTHOR + (trusted if is_trusted else suspicious).append(item) + if suspicious: + numbers = ", ".join(f"#{item.get('number')}" for item in suspicious) + raise RuntimeError( + "refusing to create or update the managed drift issue: found untrusted issue(s) " + f"carrying the governance-drift marker ({numbers}); resolve manually before the " + "observer can proceed" + ) + if len(trusted) > 1: + numbers = ", ".join(f"#{item.get('number')}" for item in trusted) + raise RuntimeError( + f"refusing to act: found multiple managed drift issues ({numbers}); resolve the " + "ambiguity manually" + ) + return trusted[0] if trusted else None + + +def _patch_issue(owner: str, repo: str, number: int, token: str, payload: dict[str, Any]) -> None: + github_request(f"https://api.github.com/repos/{owner}/{repo}/issues/{number}", token=token, method="PATCH", payload=payload) + + +def upsert_drift_issue(repository: str, token: str, drift: list[str], *, dry_run: bool) -> None: + marker = MANAGED_ISSUE_MARKER + title = MANAGED_ISSUE_TITLE + owner, repo = repository.split("/", 1) + + if not dry_run and not token: + # Defense in depth: `command_reconcile` already refuses to reach + # this function without a token unless `--dry-run` is set, but this + # function itself must never PATCH/POST without a token regardless + # of caller. Failing closed here keeps that contract even if this + # function is invoked directly (as the test suite does). + raise RuntimeError( + "refusing to mutate GitHub issues without a token; reconcile-github requires " + "GITHUB_TOKEN unless --dry-run is used" + ) + + # Route the scan by authentication, not by dry_run: an authenticated + # dry-run still uses the consistent GraphQL cursor scan (it has a token + # available), while a tokenless run is only ever reachable in dry-run + # mode (enforced above) and must fall back to the reduced-consistency + # REST scan documented on `fetch_open_issues_readonly`. + issues = fetch_open_issues(owner, repo, token) if token else fetch_open_issues_readonly(owner, repo, token) + existing = find_managed_drift_issue(issues, marker=marker, title=title) + if drift: + body = "\n".join([ + marker, + "# Public repository registry drift", + "", + "The scheduled read-only observer found differences between `governance/repositories.json` and GitHub's public repository metadata.", + "", + *[f"- {item}" for item in drift], + "", + "This issue is a coordination signal only. It does not authorize archive, transfer, visibility, deletion, release, publication, or protected OpenCoven state changes.", + "", + f"Observed at: `{datetime.utcnow().replace(microsecond=0).isoformat()}Z`", + ]) + if dry_run: + print(body) + return + if existing: + _patch_issue(owner, repo, existing["number"], token, {"title": title, "body": body}) + return + # Final revalidation immediately before POST: the initial scan and + # this creation call are not atomic, and GitHub does not offer a + # compare-and-swap "create issue only if absent" primitive. Without + # this second scan, a managed issue created concurrently by another + # reconciler run between the initial scan and this POST would be + # duplicated. This narrows the race window rather than eliminating + # it, and it fails closed (via `find_managed_drift_issue`) instead of + # proceeding if the revalidation scan itself is ambiguous or + # malformed. + revalidation_issues = fetch_open_issues(owner, repo, token) + revalidated_existing = find_managed_drift_issue(revalidation_issues, marker=marker, title=title) + if revalidated_existing: + _patch_issue(owner, repo, revalidated_existing["number"], token, {"title": title, "body": body}) + else: + github_request(f"https://api.github.com/repos/{owner}/{repo}/issues", token=token, method="POST", payload={"title": title, "body": body}) + elif existing: + if dry_run: + print(f"would close clean drift issue #{existing['number']}") + else: + _patch_issue(owner, repo, existing["number"], token, {"state": "closed", "state_reason": "completed"}) + + +def command_validate(governance: Governance, _: argparse.Namespace) -> int: + errors = governance.validate() + if errors: + print("Governance validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print("Governance validation passed.") + return 0 + + +def command_generate(governance: Governance, args: argparse.Namespace) -> int: + if args.check: + errors = governance.validate_generated() + if errors: + for error in errors: + print(error, file=sys.stderr) + return 1 + print("Generated governance views are current.") + return 0 + governance.generate() + print("Generated governance views updated.") + return 0 + + +def command_validate_manifest(governance: Governance, args: argparse.Namespace) -> int: + if args.local_self_declared_repository and os.environ.get("GITHUB_ACTIONS") == "true": + print("--local-self-declared-repository is forbidden in GitHub Actions", file=sys.stderr) + return 2 + errors = governance.validate_manifest_file( + Path(args.target_root), + args.path, + caller_repository=args.caller_repository, + allow_self_declared_repository=args.local_self_declared_repository, + ) + if errors: + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print(f"Agent manifest valid: {args.path}") + return 0 + + +def command_validate_evidence(governance: Governance, args: argparse.Namespace) -> int: + errors = governance.validate_evidence_file(Path(args.target_root), args.path) + if errors: + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print(f"Evidence packet valid: {args.path}") + return 0 + + +def command_validate_reusable_invocation(_: Governance, args: argparse.Namespace) -> int: + errors = validate_reusable_invocation( + Path(args.target_root), + caller_workflow_ref=args.caller_workflow_ref, + caller_repository=args.caller_repository, + policy_ref=args.policy_ref, + reusable_workflow=args.reusable_workflow, + path_input_name=args.path_input_name, + runtime_path=args.runtime_path, + default_runtime_path=args.default_runtime_path, + ) + if errors: + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print("Reusable workflow invocation valid.") + return 0 + + +def command_reconcile(governance: Governance, args: argparse.Namespace) -> int: + token = os.environ.get("GITHUB_TOKEN") + if not args.dry_run and not token: + print("GITHUB_TOKEN is required unless --dry-run is used", file=sys.stderr) + return 2 + live = fetch_public_repositories(args.org, token) + drift = reconcile_public_inventory(governance, live) + if args.repository: + upsert_drift_issue(args.repository, token or "", drift, dry_run=args.dry_run) + if drift: + print("Public repository drift detected:", file=sys.stderr) + for item in drift: + print(f"- {item}", file=sys.stderr) + return 1 + print(f"Public repository inventory reconciled: {len(live)} repositories.") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("validate", help="validate authoritative records, workflows, and generated views") + generate = sub.add_parser("generate", help="generate deterministic public views") + generate.add_argument("--check", action="store_true", help="fail when generated files are stale") + manifest = sub.add_parser("validate-manifest", help="validate a repository agent manifest against the public registry when present") + manifest.add_argument("--target-root", default=".", help="trusted checkout root that contains the repository-relative manifest path") + manifest.add_argument("--caller-repository", help="actual GitHub caller repository in owner/name form") + manifest.add_argument( + "--local-self-declared-repository", + action="store_true", + help="local-only safe mode: use manifest.repository.name for registry lookup after trusted path checks", + ) + manifest.add_argument("path") + evidence = sub.add_parser("validate-evidence", help="validate a governance evidence packet") + evidence.add_argument("--target-root", default=".", help="trusted checkout root that contains the repository-relative evidence path") + evidence.add_argument("path") + reusable = sub.add_parser("validate-reusable-invocation", help="validate a direct caller job for an OpenCoven reusable workflow") + reusable.add_argument("--target-root", required=True) + reusable.add_argument("--caller-workflow-ref", required=True) + reusable.add_argument("--caller-repository", required=True) + reusable.add_argument("--policy-ref", required=True) + reusable.add_argument("--reusable-workflow", required=True) + reusable.add_argument("--path-input-name", required=True) + reusable.add_argument("--runtime-path", required=True) + reusable.add_argument("--default-runtime-path") + reconcile = sub.add_parser("reconcile-github", help="compare public registry with live GitHub public repository metadata") + reconcile.add_argument("--org", default="OpenCoven") + reconcile.add_argument("--repository", default="OpenCoven/.github", help="repository used for the deduplicated drift issue") + reconcile.add_argument("--dry-run", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + governance = Governance(ROOT) + handlers = { + "validate": command_validate, + "generate": command_generate, + "validate-manifest": command_validate_manifest, + "validate-evidence": command_validate_evidence, + "validate-reusable-invocation": command_validate_reusable_invocation, + "reconcile-github": command_reconcile, + } + return handlers[args.command](governance, args) diff --git a/scripts/governance_core.py b/scripts/governance_core.py new file mode 100644 index 0000000..27998dd --- /dev/null +++ b/scripts/governance_core.py @@ -0,0 +1,917 @@ +"""Core validation primitives for the OpenCoven governance plane.""" +from __future__ import annotations + +import copy +import hashlib +import json +import re +from datetime import date +from pathlib import Path, PurePosixPath +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SHA40 = re.compile(r"^[0-9a-fA-F]{40}$") +EVENT_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") +JOB_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") +PLAIN_YAML_KEY = re.compile(r"^[A-Za-z0-9_.-]+$") +JOB_LEVEL_REUSABLE_USE = re.compile( + r"^(?:" + r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[A-Za-z0-9_.-]+\.ya?ml@[^\s{}\[\],#]+" + r"|" + r"\./\.github/workflows/[A-Za-z0-9_.\/-]+\.ya?ml" + r")$" +) +ACTION_USE = re.compile(r"^\s*-?\s*uses:\s*([^\s#]+)", re.MULTILINE) +CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") +REUSABLE_WORKFLOWS = { + "reusable-agent-readiness.yml", + "reusable-evidence-packet.yml", +} +SECRET_PATTERNS = { + # Fragmented construction avoids embedding credential-shaped examples in this + # public source file while preserving the exact detector semantics. + "GitHub token": re.compile( + r"\b(?:g" + r"h[pousr]_" + r"[A-Za-z0-9_]{20,}|github_" + + r"pat_" + r"[A-Za-z0-9_]{20,})\b" + ), + "AWS access key": re.compile(r"\b(?:A" + r"KIA|ASIA)[A-Z0-9]{16}\b"), + "private key": re.compile("-" * 5 + r"BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY" + "-" * 5), + "OpenAI-style secret": re.compile(r"\bs" + r"k-(?:proj-)?[A-Za-z0-9_-]{24,}\b"), +} +TEXT_SUFFIXES = {".md", ".json", ".yml", ".yaml", ".py", ".sh", ".txt"} + +class DuplicateKeyError(ValueError): + pass + + +def _no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DuplicateKeyError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_no_duplicate_keys) + except (OSError, json.JSONDecodeError, DuplicateKeyError) as exc: + raise ValueError(f"{path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}: {exc}") from exc + + +def _prefix_parts(prefix: str) -> tuple[str, ...]: + parsed = PurePosixPath(prefix) + return tuple(part for part in parsed.parts if part not in {"", "."}) + + +def _clean_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def _event_name(value: str, *, label: str) -> str: + if not value or value[0] in {"!", ">", "|"}: + raise ValueError(f"{label}: unsupported event scalar syntax") + if not EVENT_NAME.fullmatch(value): + raise ValueError(f"{label}: event names must be plain or unescaped quoted ASCII identifiers") + return value + + +def _is_yaml_content(line: str) -> bool: + return bool(line.strip() and not line.lstrip().startswith("#")) + + +def resolve_trusted_target_file( + target_root: Path, + relative_path: str, + *, + label: str, + required_prefix: str | None = None, + required_suffixes: tuple[str, ...] = (), +) -> Path: + """Resolve a caller-provided repository-relative path without following symlinks. + + The reusable workflows validate files from a caller repository checkout. + Those paths are untrusted workflow inputs, so they must remain literal + repository-relative paths: no absolute paths, traversal, control + characters, symlink components, missing paths, directories, or special + files are accepted. + """ + if not isinstance(relative_path, str) or not relative_path.strip(): + raise ValueError(f"{label}: path is required") + if CONTROL_CHARACTERS.search(relative_path): + raise ValueError(f"{label}: control characters are forbidden") + if "\\" in relative_path: + raise ValueError(f"{label}: use repository-relative POSIX paths") + parsed = PurePosixPath(relative_path) + if parsed.is_absolute(): + raise ValueError(f"{label}: absolute paths are forbidden") + parts = parsed.parts + if not parts or any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"{label}: traversal and empty path components are forbidden") + if required_prefix: + prefix = _prefix_parts(required_prefix) + if tuple(parts[:len(prefix)]) != prefix: + raise ValueError(f"{label}: path must be under {required_prefix}/") + if required_suffixes and not any(parts[-1].endswith(suffix) for suffix in required_suffixes): + suffixes = ", ".join(required_suffixes) + raise ValueError(f"{label}: path must end with one of: {suffixes}") + + try: + root = target_root.resolve(strict=True) + except OSError as exc: + raise ValueError(f"{label}: target root is not accessible: {exc}") from exc + if not root.is_dir(): + raise ValueError(f"{label}: target root is not a directory") + + current = root + for index, part in enumerate(parts): + current = current / part + if current.is_symlink(): + raise ValueError(f"{label}: symlink path components are forbidden: {PurePosixPath(*parts[:index + 1])}") + if not current.exists(): + raise ValueError(f"{label}: file does not exist: {relative_path}") + if index < len(parts) - 1 and not current.is_dir(): + raise ValueError(f"{label}: non-directory path component: {PurePosixPath(*parts[:index + 1])}") + if not current.is_file(): + raise ValueError(f"{label}: path is not a regular file: {relative_path}") + try: + resolved = current.resolve(strict=True) + except OSError as exc: + raise ValueError(f"{label}: file is not accessible: {exc}") from exc + if not resolved.is_relative_to(root): + raise ValueError(f"{label}: resolved path escapes the target root") + return current + + +def _strip_yaml_comment(value: str) -> str: + quote: str | None = None + escaped = False + for index, char in enumerate(value): + if escaped: + escaped = False + continue + if quote == '"' and char == "\\": + escaped = True + continue + if quote: + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + continue + if char == "#" and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value.rstrip() + + +def _yaml_key_value(line: str) -> tuple[int, str, str | None] | None: + parts = _yaml_key_value_parts(line) + if not parts: + return None + indent, _raw_key, key, _raw_value, value = parts + return indent, key, value + + +def _yaml_key_value_parts(line: str) -> tuple[int, str, str, str | None, str | None] | None: + if not line.strip() or line.lstrip().startswith("#"): + return None + if "\t" in line: + raise ValueError("YAML tabs are unsupported in reusable workflow policy checks") + raw = _strip_yaml_comment(line) + key = r"(?:[A-Za-z0-9_.-]+|'[^']+'|\"[^\"]+\")" + match = re.match(rf"^(?P *)(?P{key}):(?P(?:\s+.*)?)$", raw) + if not match: + return None + value = match.group("value") + raw_key = match.group("key") + raw_value = value.strip() if value and value.strip() else None + return ( + len(match.group("indent")), + raw_key, + _clean_scalar(raw_key), + raw_value, + _clean_scalar(raw_value) if raw_value else None, + ) + + +def _yaml_sequence_item(line: str) -> tuple[int, str] | None: + if not line.strip() or line.lstrip().startswith("#"): + return None + if "\t" in line: + raise ValueError("YAML tabs are unsupported in reusable workflow policy checks") + raw = _strip_yaml_comment(line) + match = re.match(r"^(?P *)-\s+(?P.+)$", raw) + if not match: + return None + return len(match.group("indent")), _clean_scalar(match.group("value")) + + +def _parse_flow_sequence(value: str, *, label: str) -> list[str]: + text = value.strip() + if not text.startswith("[") or not text.endswith("]"): + raise ValueError(f"{label}: unsupported flow sequence syntax") + inner = text[1:-1].strip() + if not inner: + return [] + items: list[str] = [] + token: list[str] = [] + quote: str | None = None + escaped = False + for char in inner: + if escaped: + token.append(char) + escaped = False + continue + if quote == '"' and char == "\\": + token.append(char) + escaped = True + continue + if quote: + token.append(char) + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + token.append(char) + continue + if char == ",": + item = "".join(token).strip() + if not item: + raise ValueError(f"{label}: empty flow sequence items are unsupported") + items.append(_clean_scalar(item)) + token = [] + continue + if char in "{}[]": + raise ValueError(f"{label}: nested flow YAML is unsupported") + token.append(char) + if quote: + raise ValueError(f"{label}: unterminated quoted scalar") + item = "".join(token).strip() + if not item: + raise ValueError(f"{label}: empty flow sequence items are unsupported") + items.append(_clean_scalar(item)) + return items + + +def _top_level_block(lines: list[str], key: str) -> tuple[str | None, list[str]]: + found: tuple[str | None, list[str]] | None = None + for index, line in enumerate(lines): + item = _yaml_key_value_parts(line) + if not item: + continue + indent, raw_key, item_key, _raw_value, value = item + if indent == 0 and item_key == key: + if raw_key != key: + raise ValueError(f"top-level YAML key must be plain for policy checks: {key}") + if found is not None: + raise ValueError(f"duplicate top-level YAML key is unsupported: {key}") + block: list[str] = [] + for child in lines[index + 1:]: + child_item = _yaml_key_value(child) + if child_item and child_item[0] == 0: + break + block.append(child) + found = (value, block) + return found if found is not None else (None, []) + + +def _block_contains_key(block: list[str], key: str) -> bool: + for line in block: + item = _yaml_key_value(line) + if item and item[1] == key: + return True + return False + + +def _workflow_declares_workflow_call(lines: list[str]) -> bool: + value, block = _top_level_block(lines, "on") + events: list[str] = [] + if value is not None: + if any(_is_yaml_content(line) for line in block): + raise ValueError("caller workflow on: unsupported continuation lines after scalar event declaration") + if value.startswith("{"): + raise ValueError("caller workflow on: flow mappings are unsupported") + if value.startswith("["): + events.extend( + _event_name(event, label="caller workflow on") + for event in _parse_flow_sequence(value, label="caller workflow on") + ) + elif any(char in value for char in "{}[]"): + raise ValueError("caller workflow on: unsupported flow YAML syntax") + else: + events.append(_event_name(_clean_scalar(value), label="caller workflow on")) + else: + entries: list[tuple[int, str, str, str | None]] = [] + for line in block: + sequence = _yaml_sequence_item(line) + if sequence: + indent, sequence_value = sequence + entries.append((indent, "sequence", sequence_value, None)) + continue + item = _yaml_key_value(line) + if item: + indent, key, item_value = item + entries.append((indent, "mapping", key, item_value)) + continue + if _is_yaml_content(line): + raise ValueError("caller workflow on: unsupported continuation or scalar syntax") + if not entries: + raise ValueError("caller workflow must declare on using a supported literal event form") + event_indent = min(indent for indent, *_ in entries) + direct = [entry for entry in entries if entry[0] == event_indent] + kinds = {kind for _, kind, _, _ in direct} + if len(kinds) != 1: + raise ValueError("caller workflow on: mixed sequence and mapping forms are unsupported") + seen: set[str] = set() + for _, kind, event, event_value in direct: + if event in seen: + raise ValueError(f"caller workflow on: duplicate event key is unsupported: {event}") + seen.add(event) + if kind == "sequence" and any(char in event for char in "{}[]"): + raise ValueError("caller workflow on: unsupported sequence item syntax") + if event.startswith(("!", ">", "|")): + raise ValueError("caller workflow on: unsupported event scalar syntax") + if kind == "mapping" and event_value is not None and event_value.startswith("{"): + raise ValueError("caller workflow on: flow mappings are unsupported") + if kind == "mapping" and event_value is not None and event_value.startswith(("!", ">", "|")): + raise ValueError("caller workflow on: unsupported event value scalar syntax") + events.append(_event_name(_clean_scalar(event), label="caller workflow on")) + return "workflow_call" in events + + +def _contains_yaml_anchor_or_alias(text: str) -> bool: + if re.search(r"(?m)^\s*<<\s*:", text): + return True + return bool(re.search(r"(? bool: + return bool(value and "${{" in value) + + +def _line_indent(line: str) -> int: + if "\t" in line: + raise ValueError("YAML tabs are unsupported in reusable workflow policy checks") + return len(line) - len(line.lstrip(" ")) + + +def _validate_plain_security_key(raw_key: str, key: str, *, label: str) -> None: + if raw_key != key: + raise ValueError(f"{label}: quoted security-relevant keys are unsupported: {key}") + if not PLAIN_YAML_KEY.fullmatch(raw_key): + raise ValueError(f"{label}: unsupported security-relevant key syntax: {key}") + + +def _validate_security_scalar(raw_value: str | None, value: str | None, *, label: str) -> None: + if raw_value is None or value is None: + raise ValueError(f"{label}: literal scalar value is required") + raw = raw_value.strip() + if not raw: + raise ValueError(f"{label}: literal scalar value is required") + if raw.startswith(("!", ">", "|", "&", "*")): + raise ValueError(f"{label}: YAML tags, block scalars, anchors, and aliases are unsupported") + if raw[0] in {"'", '"'}: + raise ValueError(f"{label}: quoted scalars are unsupported") + if any(char in raw for char in "{}[]"): + raise ValueError(f"{label}: flow YAML values are unsupported") + + +def _block_has_yaml_content(block: list[str]) -> bool: + return any(_is_yaml_content(line) for line in block) + + +def _direct_child_properties( + block: list[str], + parent_indent: int, + *, + label: str = "caller job", +) -> dict[str, tuple[str | None, list[str]]]: + child_items = [] + unsupported_items: list[tuple[int, int, str]] = [] + for offset, line in enumerate(block): + if not _is_yaml_content(line): + continue + indent = _line_indent(line) + item = _yaml_key_value_parts(line) + if item and item[0] > parent_indent: + child_items.append((offset, *item)) + elif indent > parent_indent: + unsupported_items.append((offset, indent, line.strip())) + if not child_items: + if unsupported_items: + raise ValueError(f"{label}: unsupported direct job mapping syntax") + return {} + child_indent = min(item[1] for item in child_items) + if any(indent <= child_indent for _offset, indent, _text in unsupported_items): + raise ValueError(f"{label}: unsupported direct job mapping syntax") + starts = [ + (offset, raw_key, key, raw_value, value) + for offset, indent, raw_key, key, raw_value, value in child_items + if indent == child_indent + ] + result: dict[str, tuple[str | None, list[str]]] = {} + for index, (offset, raw_key, key, raw_value, value) in enumerate(starts): + if key in result: + raise ValueError(f"duplicate caller job YAML key is unsupported: {key}") + end = starts[index + 1][0] if index + 1 < len(starts) else len(block) + child_block = block[offset + 1:end] + if key in {"uses", "with", "secrets"}: + _validate_plain_security_key(raw_key, key, label=label) + if key == "uses": + _validate_security_scalar(raw_value, value, label=f"{label}: uses") + if _block_has_yaml_content(child_block): + raise ValueError(f"{label}: uses multiline values are unsupported") + if value and not JOB_LEVEL_REUSABLE_USE.fullmatch(value): + raise ValueError(f"{label}: uses must be a canonical literal reusable workflow reference") + elif key == "with" and value is not None: + _validate_security_scalar(raw_value, value, label=f"{label}: with") + elif key == "secrets" and value is not None: + _validate_security_scalar(raw_value, value, label=f"{label}: secrets") + result[key] = (value, child_block) + return result + + +def _mapping_values(block: list[str], parent_indent: int, *, label: str = "caller with") -> dict[str, str | None]: + child_items = [] + unsupported_items: list[tuple[int, str]] = [] + for line in block: + if not _is_yaml_content(line): + continue + indent = _line_indent(line) + item = _yaml_key_value_parts(line) + if item and item[0] > parent_indent: + child_items.append(item) + elif indent > parent_indent: + unsupported_items.append((indent, line.strip())) + if not child_items: + if unsupported_items: + raise ValueError(f"{label}: unsupported input mapping syntax") + return {} + child_indent = min(item[0] for item in child_items) + if any(indent <= child_indent for indent, _text in unsupported_items): + raise ValueError(f"{label}: unsupported input mapping syntax") + result: dict[str, str | None] = {} + for indent, raw_key, key, raw_value, value in child_items: + if indent != child_indent: + continue + _validate_plain_security_key(raw_key, key, label=label) + if key in result: + raise ValueError(f"duplicate caller with input is unsupported: {key}") + _validate_security_scalar(raw_value, value, label=f"{label}.{key}") + result[key] = value + return result + + +def _job_blocks(lines: list[str]) -> list[tuple[str, int, list[str]]]: + jobs_value, jobs_block = _top_level_block(lines, "jobs") + if jobs_value is not None: + raise ValueError("caller workflow jobs: inline mappings are unsupported") + items = [] + unsupported_items: list[tuple[int, str]] = [] + for offset, line in enumerate(jobs_block): + if not _is_yaml_content(line): + continue + indent = _line_indent(line) + item = _yaml_key_value(line) + if item: + indent, key, value = item + items.append((offset, indent, key, value)) + else: + unsupported_items.append((indent, line.strip())) + if not items: + if unsupported_items: + raise ValueError("caller workflow jobs: unsupported job mapping syntax") + return [] + job_indent = min(indent for _, indent, _, _ in items) + if any(indent <= job_indent for indent, _text in unsupported_items): + raise ValueError("caller workflow jobs: unsupported job mapping syntax") + starts = [] + seen: set[str] = set() + for offset, indent, key, value in items: + if indent != job_indent: + continue + raw_key = _yaml_key_value_parts(jobs_block[offset])[1] + if raw_key != key: + raise ValueError(f"caller workflow jobs: quoted job identifiers are unsupported: {key}") + if not JOB_ID.fullmatch(key): + raise ValueError(f"caller workflow jobs: unsupported job identifier syntax: {key}") + if key in seen: + raise ValueError(f"duplicate caller job id is unsupported: {key}") + seen.add(key) + if value is not None: + raise ValueError(f"caller job {key}: inline job mappings are unsupported") + starts.append((offset, key)) + jobs = [] + for index, (offset, key) in enumerate(starts): + end = starts[index + 1][0] if index + 1 < len(starts) else len(jobs_block) + jobs.append((key, job_indent, jobs_block[offset + 1:end])) + return jobs + + +def _parse_caller_workflow_ref(caller_workflow_ref: str) -> tuple[str, str, str]: + match = re.fullmatch(r"([^/]+/[^/]+)/(.+)@(.+)", caller_workflow_ref) + if not match: + raise ValueError("caller workflow ref must be owner/repo/.github/workflows/file.yml@ref") + return match.group(1), match.group(2), match.group(3) + + +def validate_reusable_invocation( + target_root: Path, + *, + caller_workflow_ref: str, + caller_repository: str, + policy_ref: str, + reusable_workflow: str, + path_input_name: str, + runtime_path: str, + default_runtime_path: str | None = None, +) -> list[str]: + errors: list[str] = [] + if reusable_workflow not in REUSABLE_WORKFLOWS: + errors.append(f"reusable workflow is not supported: {reusable_workflow}") + if not SHA40.fullmatch(policy_ref or ""): + errors.append("policy_ref must be a full immutable commit SHA") + try: + ref_repository, workflow_path, _workflow_ref = _parse_caller_workflow_ref(caller_workflow_ref) + except ValueError as exc: + return errors + [str(exc)] + if ref_repository != caller_repository: + errors.append(f"caller workflow ref repository {ref_repository!r} does not match runtime repository {caller_repository!r}") + try: + caller_file = resolve_trusted_target_file( + target_root, + workflow_path, + label="caller workflow", + required_prefix=".github/workflows", + required_suffixes=(".yml", ".yaml"), + ) + if caller_file.parent != target_root.resolve(strict=True) / ".github" / "workflows": + errors.append("caller workflow path must be a direct file below .github/workflows") + except ValueError as exc: + return errors + [str(exc)] + + text = caller_file.read_text(encoding="utf-8") + if _contains_yaml_anchor_or_alias(text): + errors.append("caller workflow YAML anchors, aliases, and merge keys are unsupported") + lines = text.splitlines() + try: + if _workflow_declares_workflow_call(lines): + errors.append("nested reusable workflow callers are unsupported") + jobs = _job_blocks(lines) + except ValueError as exc: + return errors + [str(exc)] + + expected_uses_prefix = f"OpenCoven/.github/.github/workflows/{reusable_workflow}@" + matches: list[tuple[str, dict[str, tuple[str | None, list[str]]], str]] = [] + try: + job_properties = [ + (job_id, _direct_child_properties(block, job_indent, label=f"caller job {job_id}")) + for job_id, job_indent, block in jobs + ] + except ValueError as exc: + return errors + [str(exc)] + for job_id, props in job_properties: + uses_value = props.get("uses", (None, []))[0] + if uses_value is None: + continue + if _scalar_has_expression(uses_value): + errors.append(f"caller job {job_id}: expressions are unsupported in uses") + continue + if uses_value.startswith("OpenCoven/.github/.github/workflows/") and not uses_value.startswith(expected_uses_prefix): + errors.append(f"caller job {job_id}: wrong reusable workflow {uses_value!r}") + continue + if not uses_value.startswith(expected_uses_prefix): + continue + matches.append((job_id, props, uses_value.removeprefix(expected_uses_prefix))) + + if len(matches) != 1: + errors.append(f"expected exactly one direct caller job for {reusable_workflow}; found {len(matches)}") + return errors + + job_id, props, uses_ref = matches[0] + if not SHA40.fullmatch(uses_ref): + errors.append(f"caller job {job_id}: reusable workflow ref must be a full immutable commit SHA") + if uses_ref != policy_ref: + errors.append(f"caller job {job_id}: uses ref does not match runtime policy_ref") + + secrets_value = props.get("secrets", (None, []))[0] + if secrets_value == "inherit": + errors.append(f"caller job {job_id}: secrets: inherit is forbidden") + + with_value, with_block = props.get("with", (None, [])) + if with_value is not None: + errors.append(f"caller job {job_id}: inline with mappings are unsupported") + with_inputs: dict[str, str | None] = {} + else: + try: + with_inputs = _mapping_values(with_block, 0, label=f"caller job {job_id}: with") + except ValueError as exc: + errors.append(str(exc)) + with_inputs = {} + literal_policy_ref = with_inputs.get("policy_ref") + if literal_policy_ref is None: + errors.append(f"caller job {job_id}: with.policy_ref is required") + elif _scalar_has_expression(literal_policy_ref): + errors.append(f"caller job {job_id}: expressions are unsupported in with.policy_ref") + elif literal_policy_ref != policy_ref: + errors.append(f"caller job {job_id}: with.policy_ref does not match runtime policy_ref") + elif literal_policy_ref != uses_ref: + errors.append(f"caller job {job_id}: with.policy_ref does not match reusable workflow uses ref") + + literal_path = with_inputs.get(path_input_name) + if literal_path is None: + literal_path = default_runtime_path + if literal_path is None: + errors.append(f"caller job {job_id}: with.{path_input_name} is required") + elif _scalar_has_expression(literal_path): + errors.append(f"caller job {job_id}: expressions are unsupported in with.{path_input_name}") + elif literal_path != runtime_path: + errors.append(f"caller job {job_id}: with.{path_input_name} does not match runtime input") + return errors + + +def parse_date(value: str, field: str, errors: list[str]) -> date | None: + try: + return date.fromisoformat(value) + except (TypeError, ValueError): + errors.append(f"{field}: expected ISO date, got {value!r}") + return None + + +def markdown(value: Any) -> str: + return str(value).replace("|", "\\|").replace("\n", " ") + + +def expand_repository(defaults: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]: + result = copy.deepcopy(defaults) + for key, value in item.items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key].update(copy.deepcopy(value)) + else: + result[key] = copy.deepcopy(value) + return result + + +def expanded_repositories(data: dict[str, Any]) -> list[dict[str, Any]]: + defaults = data.get("defaults", {}) + repositories = data.get("repositories", []) + if not isinstance(defaults, dict) or not isinstance(repositories, list): + return [] + return [expand_repository(defaults, item) for item in repositories if isinstance(item, dict)] + + +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def validate_registry_data(data: dict[str, Any], *, today: date | None = None) -> list[str]: + errors: list[str] = [] + today = today or date.today() + if data.get("schema_version") != "opencoven.repository-registry/v1": + errors.append("governance/repositories.json: unsupported schema_version") + if data.get("organization") != "OpenCoven": + errors.append("governance/repositories.json: organization must be OpenCoven") + scope = data.get("scope", {}) + if scope.get("visibility") != "public-only": + errors.append("registry scope must be public-only") + if scope.get("private_inventory") != "federated-and-intentionally-omitted": + errors.append("registry must explicitly omit private inventory") + raw_repositories = data.get("repositories") + if not isinstance(raw_repositories, list): + return errors + ["registry repositories must be an array"] + defaults = data.get("defaults") + if not isinstance(defaults, dict): + return errors + ["registry defaults must be an object"] + repositories = expanded_repositories(data) + if len(repositories) != len(raw_repositories): + errors.append("registry repository entries must be objects") + if scope.get("expected_public_repository_count") != len(raw_repositories): + errors.append("registry expected_public_repository_count does not match repositories length") + + names: set[str] = set() + domains: dict[str, str] = {} + actual_order: list[str] = [] + allowed_lifecycle = {"incubating", "active", "maintenance", "deprecated", "archived", "tombstone"} + allowed_canonicality = {"canonical", "supporting", "specimen", "historical", "none"} + allowed_risk = {"R0", "R1", "R2", "R3", "R4"} + destination_required = { + "consolidate-then-retire", + "evaluate-consolidation-or-private-incubation", + "private-incubation-or-retire", + } + + for index, item in enumerate(repositories): + where = f"repositories[{index}]" + if not isinstance(item, dict): + errors.append(f"{where}: expected object") + continue + name = item.get("name") + actual_order.append(str(name)) + if not isinstance(name, str) or not name: + errors.append(f"{where}.name: required") + continue + if name in names: + errors.append(f"duplicate repository: {name}") + names.add(name) + if item.get("visibility") != "public": + errors.append(f"{name}: public registry may contain only visibility=public") + if item.get("lifecycle") not in allowed_lifecycle: + errors.append(f"{name}: invalid lifecycle {item.get('lifecycle')!r}") + if item.get("canonicality") not in allowed_canonicality: + errors.append(f"{name}: invalid canonicality {item.get('canonicality')!r}") + if item.get("risk_class") not in allowed_risk: + errors.append(f"{name}: invalid risk class {item.get('risk_class')!r}") + for field in ("owner", "technical_dri", "ownership_status", "purpose"): + if not isinstance(item.get(field), str) or not item[field].strip(): + errors.append(f"{name}: {field} is required") + observed = item.get("observed", {}) + if not isinstance(observed.get("default_branch"), str) or not observed.get("default_branch"): + errors.append(f"{name}: observed.default_branch is required") + if not isinstance(observed.get("archived"), bool): + errors.append(f"{name}: observed.archived must be boolean") + if item.get("lifecycle") == "archived" and observed.get("archived") is not True: + errors.append(f"{name}: archived lifecycle requires observed.archived=true") + if observed.get("archived") is True and item.get("lifecycle") != "archived": + errors.append(f"{name}: observed archived repository must use archived lifecycle") + + canonical_domains = item.get("canonical_domains", []) + if not isinstance(canonical_domains, list) or not all(isinstance(v, str) and v for v in canonical_domains): + errors.append(f"{name}: canonical_domains must be non-empty strings") + canonical_domains = [] + if item.get("canonicality") == "canonical" and not canonical_domains: + errors.append(f"{name}: canonical repository must own at least one domain") + if item.get("canonicality") != "canonical" and canonical_domains: + errors.append(f"{name}: only canonical repositories may claim canonical_domains") + for domain in canonical_domains: + if domain in domains: + errors.append(f"duplicate canonical domain {domain!r}: {domains[domain]} and {name}") + else: + domains[domain] = name + + disposition = item.get("disposition", {}) + if not isinstance(disposition, dict) or not disposition.get("state"): + errors.append(f"{name}: disposition.state is required") + disposition = {} + if disposition.get("state") in destination_required and not disposition.get("destination"): + errors.append(f"{name}: disposition {disposition.get('state')} requires destination") + review_by = parse_date(disposition.get("review_by"), f"{name}.disposition.review_by", errors) + if review_by and review_by < today and item.get("lifecycle") not in {"archived", "tombstone"}: + errors.append(f"{name}: lifecycle/disposition review expired on {review_by.isoformat()}") + + manifest = item.get("agent_manifest", {}) + if manifest.get("status") not in {"enforced", "planned", "exempt"}: + errors.append(f"{name}: invalid agent_manifest.status") + if not isinstance(manifest.get("path"), str) or not manifest.get("path"): + errors.append(f"{name}: agent_manifest.path is required") + if manifest.get("status") == "exempt" and item.get("lifecycle") not in {"archived", "tombstone"}: + errors.append(f"{name}: only archived/tombstone repositories may be manifest-exempt") + if item.get("security_support") not in {"active", "limited", "unsupported", "historical"}: + errors.append(f"{name}: invalid security_support") + + if actual_order != sorted(actual_order, key=str.lower): + errors.append("registry repositories must be sorted by name") + + for item in repositories: + destination = item.get("disposition", {}).get("destination") + if not isinstance(destination, dict): + continue + if destination.get("kind") == "repository" and destination.get("name") not in names: + errors.append(f"{item.get('name')}: destination repository {destination.get('name')!r} is not registered") + if destination.get("kind") == "portfolio": + for target in destination.get("names", []): + if target not in names: + errors.append(f"{item.get('name')}: destination repository {target!r} is not registered") + if destination.get("kind") == "private-overlay" and not destination.get("id"): + errors.append(f"{item.get('name')}: private-overlay destination requires opaque id") + + return errors + + +def validate_manifest_data(data: dict[str, Any], *, registry_entry: dict[str, Any] | None = None) -> list[str]: + errors: list[str] = [] + if data.get("schema_version") != "opencoven.agent-repo/v1": + errors.append("manifest: unsupported schema_version") + repository = data.get("repository", {}) + risk = data.get("risk", {}) + agent = data.get("agent", {}) + contracts = data.get("contracts", {}) + required_repo = ("name", "lifecycle", "canonicality", "canonical_for", "does_not_own", "owner", "technical_dri", "ownership_status") + for field in required_repo: + if field not in repository: + errors.append(f"manifest.repository.{field}: required") + if risk.get("class") not in {"R0", "R1", "R2", "R3", "R4"}: + errors.append("manifest.risk.class: invalid") + for field in ("protected_paths", "generated_paths", "external_side_effects"): + if not isinstance(risk.get(field), list): + errors.append(f"manifest.risk.{field}: expected array") + for field in ("network_policy", "secrets_policy"): + if not isinstance(risk.get(field), str) or not risk.get(field): + errors.append(f"manifest.risk.{field}: required") + if not isinstance(agent.get("entrypoint"), str) or not agent.get("entrypoint"): + errors.append("manifest.agent.entrypoint: required") + if not isinstance(agent.get("bootstrap"), str) or not agent.get("bootstrap"): + errors.append("manifest.agent.bootstrap: required") + verify = agent.get("verify", {}) + for field in ("fast", "full"): + if not isinstance(verify.get(field), str) or not verify.get(field): + errors.append(f"manifest.agent.verify.{field}: required") + for field in ("produces", "consumes"): + if not isinstance(contracts.get(field), list): + errors.append(f"manifest.contracts.{field}: expected array") + + canonical_for = repository.get("canonical_for", []) + if repository.get("canonicality") == "canonical" and not canonical_for: + errors.append("manifest: canonical repository must claim at least one domain") + if repository.get("canonicality") != "canonical" and canonical_for: + errors.append("manifest: noncanonical repository cannot claim canonical domains") + + if registry_entry: + comparisons = { + "repository.name": (repository.get("name"), registry_entry.get("name")), + "repository.lifecycle": (repository.get("lifecycle"), registry_entry.get("lifecycle")), + "repository.canonicality": (repository.get("canonicality"), registry_entry.get("canonicality")), + "repository.canonical_for": (sorted(canonical_for), sorted(registry_entry.get("canonical_domains", []))), + "repository.does_not_own": ( + sorted(repository.get("does_not_own", [])), + sorted(registry_entry.get("does_not_own", [])), + ), + "repository.owner": (repository.get("owner"), registry_entry.get("owner")), + "repository.technical_dri": (repository.get("technical_dri"), registry_entry.get("technical_dri")), + "repository.ownership_status": (repository.get("ownership_status"), registry_entry.get("ownership_status")), + "risk.class": (risk.get("class"), registry_entry.get("risk_class")), + } + for field, (actual, expected) in comparisons.items(): + if actual != expected: + errors.append(f"manifest mismatch {field}: {actual!r} != registry {expected!r}") + if risk.get("class") in {"R3", "R4"}: + if not risk.get("protected_paths"): + errors.append("manifest.risk.protected_paths: R3/R4 repositories require protected paths") + expected_adapters = { + "agent.bootstrap": agent.get("bootstrap") == "./scripts/agent-bootstrap", + "agent.verify.fast": agent.get("verify", {}).get("fast") == "./scripts/agent-check fast", + "agent.verify.full": agent.get("verify", {}).get("full") == "./scripts/agent-check full", + } + for field, ok in expected_adapters.items(): + if not ok: + errors.append(f"manifest.{field}: R3/R4 repositories must use the canonical agent adapter") + return errors + + +def validate_initiative_data(data: dict[str, Any], *, repository_names: set[str], decision_ids: set[str], today: date | None = None) -> list[str]: + errors: list[str] = [] + today = today or date.today() + if data.get("schema_version") != "opencoven.initiative/v1": + errors.append("unsupported initiative schema_version") + for field in ("id", "title", "status", "priority", "decision_owner", "technical_dri", "ownership_status", "outcome", "authority_boundary"): + if not data.get(field): + errors.append(f"initiative {data.get('id', '')}: {field} required") + for decision in data.get("decisions", []): + if decision not in decision_ids: + errors.append(f"initiative {data.get('id')}: unknown decision {decision}") + for workstream in data.get("workstreams", []): + repository = workstream.get("repository") + if repository not in repository_names: + errors.append(f"initiative {data.get('id')}: unregistered public workstream repository {repository!r}") + if not workstream.get("responsibility"): + errors.append(f"initiative {data.get('id')}: workstream responsibility required") + criteria = data.get("exit_criteria", []) + if not criteria: + errors.append(f"initiative {data.get('id')}: exit_criteria required") + if data.get("status") == "completed": + for criterion in criteria: + if criterion.get("state") != "met" or not criterion.get("evidence"): + errors.append(f"initiative {data.get('id')}: completed criterion {criterion.get('id')} lacks met state and evidence") + review_by = parse_date(data.get("review_by"), f"initiative {data.get('id')}.review_by", errors) + if review_by and review_by < today and data.get("status") in {"proposed", "active", "verifying"}: + errors.append(f"initiative {data.get('id')}: review expired on {review_by.isoformat()}") + return errors + + +def validate_exception_data(data: dict[str, Any], *, control_ids: set[str], today: date | None = None) -> list[str]: + errors: list[str] = [] + today = today or date.today() + if data.get("schema_version") != "opencoven.exception-set/v1": + errors.append("unsupported exception schema_version") + seen: set[str] = set() + for item in data.get("exceptions", []): + ident = item.get("id") + if not ident or ident in seen: + errors.append(f"duplicate or missing exception id: {ident!r}") + seen.add(ident) + if item.get("control_id") not in control_ids: + errors.append(f"exception {ident}: unknown control {item.get('control_id')!r}") + created = parse_date(item.get("created"), f"exception {ident}.created", errors) + expires = parse_date(item.get("expires"), f"exception {ident}.expires", errors) + if created and expires and expires < created: + errors.append(f"exception {ident}: expires before creation") + if created and expires and (expires - created).days > 90: + errors.append(f"exception {ident}: active window exceeds 90 days") + if expires and expires < today and item.get("status") in {"proposed", "active"}: + errors.append(f"exception {ident}: {item.get('status')} exception expired on {expires.isoformat()}") + return errors diff --git a/scripts/governance_model.py b/scripts/governance_model.py new file mode 100644 index 0000000..a0d66cc --- /dev/null +++ b/scripts/governance_model.py @@ -0,0 +1,471 @@ +"""Repository model, cross-file validation, and deterministic generation.""" +from __future__ import annotations + +import json +import re +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from governance_core import ( + ACTION_USE, ROOT, SECRET_PATTERNS, SHA40, TEXT_SUFFIXES, + expanded_repositories, load_json, markdown, sha256_text, + resolve_trusted_target_file, + validate_exception_data, validate_initiative_data, + validate_manifest_data, validate_registry_data, +) + +@dataclass +class Governance: + root: Path = ROOT + + def path(self, value: str) -> Path: + return self.root / value + + def registry(self) -> dict[str, Any]: + return load_json(self.path("governance/repositories.json")) + + def registry_map(self) -> dict[str, dict[str, Any]]: + return {item["name"]: item for item in expanded_repositories(self.registry())} + + def decision_index(self) -> dict[str, Any]: + return load_json(self.path("decisions/index.json")) + + def initiative_files(self) -> list[Path]: + return sorted(self.path("initiatives").glob("*.json")) + + def validate(self) -> list[str]: + errors: list[str] = [] + required = [ + "README.md", "AGENTS.md", "LICENSE", "agent/manifest.json", + "governance/repositories.json", "governance/lifecycle.json", "governance/controls.json", "governance/exceptions.json", + "compatibility/dependencies.json", "compatibility/contracts.json", "compatibility/release-trains.json", + "decisions/index.json", ".github/CODEOWNERS", + ] + for rel in required: + if not self.path(rel).exists(): + errors.append(f"missing required path: {rel}") + + # Parse every JSON file and reject duplicate keys. + for path in sorted(self.root.rglob("*.json")): + try: + load_json(path) + except ValueError as exc: + errors.append(str(exc)) + + if errors: + return errors + + registry = self.registry() + errors.extend(validate_registry_data(registry)) + registry_map = {item["name"]: item for item in expanded_repositories(registry)} + names = set(registry_map) + + manifest = load_json(self.path("agent/manifest.json")) + errors.extend(validate_manifest_data(manifest, registry_entry=registry_map.get(".github"))) + entrypoint = manifest.get("agent", {}).get("entrypoint") + if entrypoint and not self.path(entrypoint).exists(): + errors.append(f"manifest agent.entrypoint does not exist: {entrypoint}") + + decisions = self.decision_index() + if decisions.get("schema_version") != "opencoven.decision-index/v1": + errors.append("decisions/index.json: unsupported schema_version") + decision_ids: set[str] = set() + for item in decisions.get("decisions", []): + ident = item.get("id") + if ident in decision_ids: + errors.append(f"duplicate decision id: {ident}") + decision_ids.add(ident) + path = self.path(item.get("path", "")) + if not path.exists(): + errors.append(f"decision {ident}: missing path {item.get('path')}") + elif f"# {ident}:" not in path.read_text(encoding="utf-8"): + errors.append(f"decision {ident}: path heading does not match id") + + initiatives: dict[str, dict[str, Any]] = {} + for path in self.initiative_files(): + data = load_json(path) + ident = data.get("id") + if ident in initiatives: + errors.append(f"duplicate initiative id: {ident}") + initiatives[ident] = data + errors.extend(validate_initiative_data(data, repository_names=names, decision_ids=decision_ids)) + for ident, data in initiatives.items(): + for dependency in data.get("dependencies", []): + if dependency not in initiatives: + errors.append(f"initiative {ident}: unknown dependency {dependency}") + errors.extend(self._validate_initiative_cycles(initiatives)) + + dependencies = load_json(self.path("compatibility/dependencies.json")) + seen_edges: set[tuple[str, str, str]] = set() + for edge in dependencies.get("edges", []): + key = (edge.get("producer"), edge.get("consumer"), edge.get("relationship")) + if key in seen_edges: + errors.append(f"duplicate dependency edge: {key}") + seen_edges.add(key) + if edge.get("producer") not in names or edge.get("consumer") not in names: + errors.append(f"dependency references unregistered public repository: {key}") + if edge.get("producer") == edge.get("consumer"): + errors.append(f"self dependency is not allowed: {key}") + if not edge.get("required_evidence"): + errors.append(f"dependency lacks required_evidence: {key}") + + contracts = load_json(self.path("compatibility/contracts.json")) + contract_ids: set[str] = set() + for contract in contracts.get("contracts", []): + if contract.get("id") in contract_ids: + errors.append(f"duplicate contract id: {contract.get('id')}") + contract_ids.add(contract.get("id")) + if contract.get("owner") not in names: + errors.append(f"contract {contract.get('id')}: unregistered owner {contract.get('owner')}") + + release_trains = load_json(self.path("compatibility/release-trains.json")) + for train in release_trains.get("release_trains", []): + for member in train.get("members", []): + if member not in names: + errors.append(f"release train {train.get('id')}: unregistered member {member}") + + controls = load_json(self.path("governance/controls.json")) + control_ids: set[str] = set() + for control in controls.get("controls", []): + ident = control.get("id") + if ident in control_ids: + errors.append(f"duplicate control id: {ident}") + control_ids.add(ident) + for evidence in control.get("evidence", []): + if evidence.startswith("OpenCoven/"): + continue + local = self.path(evidence) + if not local.exists(): + errors.append(f"control {ident}: evidence path does not exist: {evidence}") + + exceptions = load_json(self.path("governance/exceptions.json")) + errors.extend(validate_exception_data(exceptions, control_ids=control_ids)) + errors.extend(self.validate_workflows()) + errors.extend(self.scan_secrets()) + errors.extend(self.validate_generated()) + return sorted(set(errors)) + + @staticmethod + def _validate_initiative_cycles(initiatives: dict[str, dict[str, Any]]) -> list[str]: + errors: list[str] = [] + visiting: set[str] = set() + visited: set[str] = set() + + def visit(node: str, stack: list[str]) -> None: + if node in visiting: + cycle = " -> ".join(stack + [node]) + errors.append(f"initiative dependency cycle: {cycle}") + return + if node in visited or node not in initiatives: + return + visiting.add(node) + for dep in initiatives[node].get("dependencies", []): + visit(dep, stack + [node]) + visiting.remove(node) + visited.add(node) + + for ident in initiatives: + visit(ident, []) + return errors + + def validate_workflows(self) -> list[str]: + errors: list[str] = [] + workflow_dir = self.path(".github/workflows") + for path in sorted(list(workflow_dir.glob("*.yml")) + list(workflow_dir.glob("*.yaml"))): + rel = path.relative_to(self.root) + text = path.read_text(encoding="utf-8") + if not re.search(r"(?m)^permissions:\s*$", text): + errors.append(f"{rel}: top-level permissions block required") + if "pull_request_target:" in text: + errors.append(f"{rel}: pull_request_target is forbidden") + for action in ACTION_USE.findall(text): + if action.startswith("./") or action.startswith("docker://"): + continue + if "@" not in action: + errors.append(f"{rel}: action without immutable ref: {action}") + continue + _, ref = action.rsplit("@", 1) + if not SHA40.fullmatch(ref): + errors.append(f"{rel}: action ref must be a full commit SHA: {action}") + if ( + re.search(r"(?m)^\s{0,4}pull_request:\s*(?:$|#|{|null\s*$|~\s*$)", text) + or re.search(r"(?m)^on:\s*pull_request\s*$", text) + or re.search(r"(?m)^on:\s*\[.*\bpull_request\b.*\]\s*$", text) + or re.search(r"(?m)^on:\s*{\s*pull_request\s*:", text) + or re.search(r"(?m)^on:\s*{[^{}\n]*,\s*pull_request\s*:", text) + ): + permission_section = self._top_level_block(text, "permissions") + if re.search(r"(?m)^\s+[A-Za-z-]+:\s*write\s*$", permission_section): + errors.append(f"{rel}: pull_request workflow may not request write permission") + return errors + + @staticmethod + def _top_level_block(text: str, key: str) -> str: + lines = text.splitlines() + start = None + result: list[str] = [] + for index, line in enumerate(lines): + if line == f"{key}:": + start = index + 1 + continue + if start is not None: + if line and not line.startswith((" ", "\t", "#")): + break + result.append(line) + return "\n".join(result) + + def scan_secrets(self) -> list[str]: + errors: list[str] = [] + ignored = {"generated/portfolio.md"} # generated content still derives from validated public input + for path in sorted(self.root.rglob("*")): + if not path.is_file() or ".git" in path.parts or path.suffix not in TEXT_SUFFIXES: + continue + rel = str(path.relative_to(self.root)) + if rel in ignored: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for label, pattern in SECRET_PATTERNS.items(): + if pattern.search(text): + errors.append(f"{rel}: possible {label} material") + return errors + + def generated_content(self) -> dict[str, str]: + registry = expanded_repositories(self.registry()) + dependencies = load_json(self.path("compatibility/dependencies.json"))["edges"] + controls = load_json(self.path("governance/controls.json"))["controls"] + initiatives = [load_json(path) for path in self.initiative_files()] + + counts = defaultdict(int) + for item in registry: + counts[item["lifecycle"]] += 1 + portfolio = [ + "# Generated public repository portfolio", + "", + "> Generated by `python3 scripts/governance.py generate`. Do not edit by hand.", + "", + f"Registry digest: `{sha256_text(json.dumps(self.registry(), sort_keys=True, separators=(',', ':')))}`", + "", + "## Summary", + "", + "| Lifecycle | Count |", + "|---|---:|", + ] + for state in ("active", "incubating", "maintenance", "deprecated", "archived", "tombstone"): + portfolio.append(f"| {state} | {counts[state]} |") + portfolio += ["", "## Repositories", "", "| Repository | Lifecycle | Canonicality | Risk | Owner | Disposition | Manifest |", "|---|---|---|---:|---|---|---|"] + for item in registry: + portfolio.append("| {name} | {lifecycle} | {canonicality} | {risk_class} | @{owner} | {state} | {manifest} |".format( + **item, + state=markdown(item["disposition"]["state"]), + manifest=item["agent_manifest"]["status"], + )) + portfolio += ["", "This is a public-only view. Private repository inventory is intentionally federated and omitted.", ""] + + ownership = [ + "# Generated canonical public ownership map", "", + "> Generated from `governance/repositories.json`. A governance claim identifies ownership; it does not grant protected runtime authority.", "", + "| Canonical domain | Repository | Technical DRI | Risk |", "|---|---|---|---:|", + ] + owned: list[tuple[str, dict[str, Any]]] = [] + for item in registry: + for domain in item["canonical_domains"]: + owned.append((domain, item)) + for domain, item in sorted(owned): + ownership.append(f"| `{domain}` | `{item['name']}` | @{item['technical_dri']} | {item['risk_class']} |") + ownership.append("") + + graph = [ + "%% Generated by scripts/governance.py; do not edit.", + "flowchart LR", + ] + for item in registry: + safe = re.sub(r"[^A-Za-z0-9_]", "_", item["name"]) + graph.append(f' {safe}["{item["name"]}"]') + for edge in dependencies: + source = re.sub(r"[^A-Za-z0-9_]", "_", edge["producer"]) + target = re.sub(r"[^A-Za-z0-9_]", "_", edge["consumer"]) + label = edge["relationship"].replace('"', "'") + graph.append(f' {source} -->|"{label}"| {target}') + graph.append("") + + initiative_view = [ + "# Generated cross-repository initiatives", "", + "> Generated from `initiatives/*.json`. Implementation status remains authoritative in linked owning-repository evidence.", "", + "| Initiative | Priority | Status | Decision owner | Technical DRI | Review by | Open criteria |", "|---|---:|---|---|---|---|---:|", + ] + for item in sorted(initiatives, key=lambda value: (value["priority"], value["id"])): + open_count = sum(1 for criterion in item["exit_criteria"] if criterion["state"] != "met") + initiative_view.append(f"| `{item['id']}` | {item['priority']} | {item['status']} | @{item['decision_owner']} | @{item['technical_dri']} | {item['review_by']} | {open_count} |") + initiative_view.append("") + + control_view = [ + "# Generated governance control index", "", + "> Generated from `governance/controls.json`. A control marked specified or implemented is not necessarily administratively applied or operationally effective.", "", + "| Control | Objective | Enforcement | State |", "|---|---|---|---|", + ] + for item in controls: + control_view.append(f"| `{item['id']}` {markdown(item['title'])} | {markdown(item['objective'])} | {markdown(item['enforcement'])} | {markdown(item['status'])} |") + control_view.append("") + + return { + "generated/portfolio.md": "\n".join(portfolio), + "generated/ownership.md": "\n".join(ownership), + "generated/dependencies.mmd": "\n".join(graph), + "generated/initiatives.md": "\n".join(initiative_view), + "generated/controls.md": "\n".join(control_view), + } + + def generate(self) -> None: + for rel, content in self.generated_content().items(): + path = self.path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def validate_generated(self) -> list[str]: + errors: list[str] = [] + for rel, expected in self.generated_content().items(): + path = self.path(rel) + if not path.exists(): + errors.append(f"missing generated file: {rel}") + continue + actual = path.read_text(encoding="utf-8") + if actual != expected: + errors.append(f"stale generated file: {rel}; run python3 scripts/governance.py generate") + return errors + + def validate_manifest_file( + self, + target_root: Path, + manifest_path: str, + *, + caller_repository: str | None = None, + allow_self_declared_repository: bool = False, + ) -> list[str]: + try: + trusted_path = resolve_trusted_target_file(target_root, manifest_path, label="manifest") + data = load_json(trusted_path) + except ValueError as exc: + return [str(exc)] + errors: list[str] = [] + repository = data.get("repository", {}) + if caller_repository: + if not caller_repository.startswith("OpenCoven/") or caller_repository.count("/") != 1: + errors.append(f"caller repository must be an OpenCoven owner/repo name: {caller_repository!r}") + repo_name = "" + else: + repo_name = caller_repository.split("/", 1)[1] + elif allow_self_declared_repository: + declared_name = repository.get("name", "") + if not isinstance(declared_name, str): + errors.append("manifest repository.name must be a string") + repo_name = "" + else: + repo_name = declared_name + else: + return ["caller repository is required unless local self-declared mode is explicitly enabled"] + if repo_name and repository.get("name") != repo_name: + errors.append(f"manifest repository.name {repository.get('name')!r} does not match caller repository {repo_name!r}") + entry = self.registry_map().get(repo_name) + if not entry: + errors.append(f"caller repository is not registered in the public registry: {repo_name!r}") + else: + manifest_record = entry.get("agent_manifest", {}) + if manifest_record.get("path") != manifest_path: + errors.append( + f"manifest path {manifest_path!r} does not match registry agent_manifest.path " + f"{manifest_record.get('path')!r}" + ) + errors.extend(validate_manifest_data(data, registry_entry=entry)) + return sorted(set(errors)) + + def validate_evidence_file(self, target_root: Path, evidence_path: str) -> list[str]: + try: + trusted_path = resolve_trusted_target_file( + target_root, + evidence_path, + label="evidence", + required_prefix="evidence", + required_suffixes=(".json",), + ) + data = load_json(trusted_path) + except ValueError as exc: + return [str(exc)] + errors: list[str] = [] + if not isinstance(data, dict): + return ["evidence packet must be a JSON object"] + + def _require_non_empty_string(value: Any, label: str) -> None: + if not isinstance(value, str) or not value: + errors.append(f"{label} must be a non-empty string") + + def _require_string_array(value: Any, label: str, *, min_items: int = 0) -> list[str]: + if not isinstance(value, list): + errors.append(f"{label} must be an array") + return [] + if len(value) < min_items: + errors.append(f"{label} must contain at least {min_items} item(s)") + values: list[str] = [] + for index, item in enumerate(value): + if not isinstance(item, str) or not item: + errors.append(f"{label}[{index}] must be a non-empty string") + continue + values.append(item) + return values + + if data.get("schema_version") != "opencoven.governance-evidence/v1": + errors.append("evidence: unsupported schema_version") + change = data.get("change") + if not isinstance(change, dict): + errors.append("evidence.change must be an object") + else: + _require_non_empty_string(change.get("objective"), "evidence.change.objective") + _require_string_array(change.get("acceptance_criteria"), "evidence.change.acceptance_criteria", min_items=1) + _require_string_array(change.get("non_goals"), "evidence.change.non_goals") + authority = data.get("authority") + if not isinstance(authority, dict): + errors.append("evidence.authority must be an object") + authority = {} + if authority.get("authorization_effect") != "none-metadata-only": + errors.append("evidence.authority.authorization_effect must be none-metadata-only") + if authority.get("risk_class") not in {"R0", "R1", "R2", "R3", "R4"}: + errors.append("evidence.authority.risk_class invalid") + _require_string_array(authority.get("protected_boundaries"), "evidence.authority.protected_boundaries") + sources = data.get("sources") + if not isinstance(sources, list): + errors.append("evidence.sources must be an array") + sources = [] + if not sources: + errors.append("evidence.sources requires at least one exact source") + for index, source in enumerate(sources): + if not isinstance(source, dict): + errors.append(f"evidence.sources[{index}] must be an object") + continue + _require_non_empty_string(source.get("kind"), f"evidence.sources[{index}].kind") + _require_non_empty_string(source.get("reference"), f"evidence.sources[{index}].reference") + _require_non_empty_string(source.get("revision"), f"evidence.sources[{index}].revision") + _require_string_array(data.get("files"), "evidence.files") + verification = data.get("verification") + if not isinstance(verification, list): + errors.append("evidence.verification must be an array") + verification = [] + if not verification: + errors.append("evidence.verification requires at least one result") + for index, result in enumerate(verification): + if not isinstance(result, dict): + errors.append(f"evidence.verification[{index}] must be an object") + continue + _require_non_empty_string(result.get("command"), f"evidence.verification[{index}].command") + _require_non_empty_string(result.get("environment"), f"evidence.verification[{index}].environment") + if result.get("result") not in {"pass", "fail", "skipped", "unsupported"}: + errors.append(f"evidence.verification[{index}].result invalid") + if "evidence" in result and not isinstance(result.get("evidence"), str): + errors.append(f"evidence.verification[{index}].evidence must be a string") + _require_non_empty_string(data.get("migration"), "evidence.migration") + _require_non_empty_string(data.get("rollback"), "evidence.rollback") + _require_string_array(data.get("uncertainty"), "evidence.uncertainty") + return errors diff --git a/tests/test_governance.py b/tests/test_governance.py new file mode 100644 index 0000000..786d653 --- /dev/null +++ b/tests/test_governance.py @@ -0,0 +1,1656 @@ +from __future__ import annotations + +import argparse +import copy +import importlib.util +import io +import json +import os +import re +import subprocess +import tempfile +import textwrap +import unittest +import sys +from contextlib import redirect_stdout +from datetime import date +from pathlib import Path +from unittest.mock import patch + +MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "governance.py" +SPEC = importlib.util.spec_from_file_location("opencoven_governance", MODULE_PATH) +assert SPEC and SPEC.loader +GOV = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = GOV +SPEC.loader.exec_module(GOV) +ROOT = Path(__file__).resolve().parents[1] + +# governance.py inserts scripts/ onto sys.path as a side effect of module +# execution above, so governance_cli is importable directly here in order to +# patch its module-global `github_request` used internally by upsert_drift_issue. +import governance_cli # noqa: E402 + + +class RegistryInvariantTests(unittest.TestCase): + def setUp(self) -> None: + self.registry = json.loads((ROOT / "governance/repositories.json").read_text()) + + def test_current_registry_is_valid_at_snapshot_date(self) -> None: + self.assertEqual([], GOV.validate_registry_data(self.registry, today=date(2026, 9, 3))) + + def test_duplicate_canonical_domain_fails_closed(self) -> None: + data = copy.deepcopy(self.registry) + data["repositories"][1]["canonicality"] = "canonical" + data["repositories"][1]["canonical_domains"] = ["organization.governance"] + errors = GOV.validate_registry_data(data, today=date(2026, 9, 3)) + self.assertTrue(any("duplicate canonical domain" in error for error in errors), errors) + + def test_private_repository_in_public_registry_is_rejected(self) -> None: + data = copy.deepcopy(self.registry) + data["repositories"][0]["visibility"] = "private" + errors = GOV.validate_registry_data(data, today=date(2026, 9, 3)) + self.assertTrue(any("only visibility=public" in error for error in errors), errors) + + def test_archived_metadata_must_match_lifecycle(self) -> None: + data = copy.deepcopy(self.registry) + target = next(item for item in data["repositories"] if item["name"] == "cast-codes") + target["observed"]["archived"] = False + errors = GOV.validate_registry_data(data, today=date(2026, 9, 3)) + self.assertTrue(any("archived lifecycle" in error for error in errors), errors) + + def test_expired_review_is_detected(self) -> None: + data = copy.deepcopy(self.registry) + data["repositories"][0]["disposition"] = {"state": "retain", "review_by": "2026-09-02"} + errors = GOV.validate_registry_data(data, today=date(2026, 9, 3)) + self.assertTrue(any("review expired" in error for error in errors), errors) + + +class OwnershipBoundaryTests(unittest.TestCase): + """Regression coverage for the Psyche/Coven ownership boundary. + + These assert exact canonical owners and reciprocal disclaims by name + rather than relying on the registry's exact-string duplicate check, + which cannot detect conceptually overlapping domains that use + different literal strings (for example Psyche's project-orchestration + leases/retries/recovery/receipts versus Coven's automation-lifecycle + leases/retries/recovery/receipts). + """ + + PROJECT_ORCHESTRATION_DOMAINS = { + "project-orchestration.tasks", + "project-orchestration.lanes", + "project-orchestration.leases", + "project-orchestration.approvals", + "project-orchestration.receipts", + "project-orchestration.retries", + "project-orchestration.recovery", + } + + AUTOMATION_LIFECYCLE_DOMAINS = { + "automation.definitions", + "automation.revisions", + "automation.schedule-planning", + "automation.occurrences", + "automation.runs", + "automation.attempts", + "automation.leases", + "automation.fences", + "automation.retries", + "automation.recovery", + "automation.events", + "automation.changefeed", + "automation.artifacts", + "automation.receipts", + } + + def setUp(self) -> None: + self.registry = json.loads((ROOT / "governance/repositories.json").read_text()) + self.repos = {item["name"]: item for item in self.registry["repositories"]} + + def test_psyche_owns_exact_project_orchestration_domains(self) -> None: + psyche = self.repos["psyche"] + self.assertEqual(set(psyche["canonical_domains"]), self.PROJECT_ORCHESTRATION_DOMAINS) + + def test_coven_owns_exact_automation_lifecycle_domains(self) -> None: + coven = self.repos["coven"] + self.assertTrue( + self.AUTOMATION_LIFECYCLE_DOMAINS.issubset(set(coven["canonical_domains"])), + coven["canonical_domains"], + ) + + def test_psyche_disclaims_coven_automation_lifecycle(self) -> None: + psyche = self.repos["psyche"] + self.assertIn("automation.lifecycle", psyche["does_not_own"]) + + def test_coven_disclaims_project_orchestration(self) -> None: + coven = self.repos["coven"] + self.assertIn("project-orchestration", coven["does_not_own"]) + + def test_no_repository_other_than_psyche_claims_project_orchestration_domains(self) -> None: + for item in self.registry["repositories"]: + if item["name"] == "psyche": + continue + claimed = set(item.get("canonical_domains", [])) & self.PROJECT_ORCHESTRATION_DOMAINS + self.assertFalse(claimed, f"{item['name']} unexpectedly claims {claimed}") + + def test_no_repository_other_than_coven_claims_automation_lifecycle_domains(self) -> None: + for item in self.registry["repositories"]: + if item["name"] == "coven": + continue + claimed = set(item.get("canonical_domains", [])) & self.AUTOMATION_LIFECYCLE_DOMAINS + self.assertFalse(claimed, f"{item['name']} unexpectedly claims {claimed}") + + def test_psyche_and_coven_canonical_domains_are_disjoint(self) -> None: + psyche_domains = set(self.repos["psyche"]["canonical_domains"]) + coven_domains = set(self.repos["coven"]["canonical_domains"]) + self.assertTrue(psyche_domains.isdisjoint(coven_domains), psyche_domains & coven_domains) + + def test_identity_and_authorization_semantics_stay_with_their_canonical_owners(self) -> None: + familiar_contract = self.repos["familiar-contract"] + coven_threads = self.repos["coven-threads"] + coven = self.repos["coven"] + self.assertTrue(any(domain.startswith("identity.") for domain in familiar_contract["canonical_domains"])) + self.assertTrue(any(domain.startswith("authority.") for domain in coven_threads["canonical_domains"])) + self.assertIn("familiar.identity", coven["does_not_own"]) + self.assertIn("protected.authorization", coven["does_not_own"]) + + +class InitiativeInvariantTests(unittest.TestCase): + def setUp(self) -> None: + self.registry_names = { + item["name"] + for item in json.loads((ROOT / "governance/repositories.json").read_text())["repositories"] + } + self.decisions = { + item["id"] + for item in json.loads((ROOT / "decisions/index.json").read_text())["decisions"] + } + self.initiative = json.loads((ROOT / "initiatives/organization-governance-plane-v1.json").read_text()) + + def test_completed_initiative_requires_exact_evidence(self) -> None: + data = copy.deepcopy(self.initiative) + data["status"] = "completed" + errors = GOV.validate_initiative_data( + data, + repository_names=self.registry_names, + decision_ids=self.decisions, + today=date(2026, 9, 3), + ) + self.assertTrue(any("lacks met state and evidence" in error for error in errors), errors) + + def test_unregistered_public_workstream_is_rejected(self) -> None: + data = copy.deepcopy(self.initiative) + data["workstreams"][0]["repository"] = "not-a-public-repository" + errors = GOV.validate_initiative_data( + data, + repository_names=self.registry_names, + decision_ids=self.decisions, + today=date(2026, 9, 3), + ) + self.assertTrue(any("unregistered public workstream" in error for error in errors), errors) + + +class ExceptionInvariantTests(unittest.TestCase): + def test_active_expired_exception_is_rejected(self) -> None: + data = { + "schema_version": "opencoven.exception-set/v1", + "exceptions": [{ + "id": "EX-001", + "control_id": "GOV-001", + "scope": "test", + "owner": "BunsDev", + "approver": "BunsDev", + "rationale": "test", + "risk": "test", + "compensating_controls": ["test"], + "created": "2026-08-01", + "expires": "2026-09-01", + "status": "active", + "remediation": "test", + }], + } + errors = GOV.validate_exception_data(data, control_ids={"GOV-001"}, today=date(2026, 9, 3)) + self.assertTrue(any("expired" in error for error in errors), errors) + + +class WorkflowInvariantTests(unittest.TestCase): + def test_mutable_action_tag_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\non: push\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertTrue(any("full commit SHA" in error for error in errors), errors) + + def test_pinned_action_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\non: push\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\n" + ) + self.assertEqual([], GOV.Governance(root).validate_workflows()) + + def test_pull_request_inline_mapping_is_treated_as_pr_trigger(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\n" + "on:\n" + " pull_request: { branches: [main] }\n" + "permissions:\n" + " contents: read\n" + " pull-requests: write\n" + "jobs:\n" + " test:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertTrue(any("pull_request workflow may not request write permission" in error for error in errors), errors) + + def test_pull_request_flow_mapping_is_treated_as_pr_trigger(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\n" + "on: { pull_request: { branches: [main] } }\n" + "permissions:\n" + " contents: read\n" + " pull-requests: write\n" + "jobs:\n" + " test:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertTrue(any("pull_request workflow may not request write permission" in error for error in errors), errors) + + def test_non_pr_flow_mapping_does_not_trigger_pr_write_restriction(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\n" + "on: { workflow_run: { workflows: [pull_request] } }\n" + "permissions:\n" + " contents: read\n" + " pull-requests: write\n" + "jobs:\n" + " test:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertFalse(any("pull_request workflow may not request write permission" in error for error in errors), errors) + + def test_reusable_workflows_do_not_interpolate_path_inputs_in_shell(self) -> None: + for workflow_name, input_name in ( + ("reusable-agent-readiness.yml", "manifest_path"), + ("reusable-evidence-packet.yml", "evidence_path"), + ): + with self.subTest(workflow=workflow_name): + text = (ROOT / ".github/workflows" / workflow_name).read_text() + self.assertNotIn(f'target/${{{{ inputs.{input_name} }}}}', text) + self.assertNotIn(f'"$GITHUB_WORKSPACE/target/${{{{ inputs.{input_name} }}}}"', text) + self.assertIn(f"{input_name.upper()}: ${{{{ inputs.{input_name} }}}}", text) + self.assertIn(f'"${input_name.upper()}"', text) + + def test_inline_preflights_do_not_trigger_actions_expression_evaluation(self) -> None: + for workflow_name in ( + "reusable-agent-readiness.yml", + "reusable-evidence-packet.yml", + ): + with self.subTest(workflow=workflow_name): + script = _extract_inline_preflight_script(workflow_name) + self.assertNotIn("${{", script, "Actions evaluates run bodies before Python") + + +SHA_A = "a" * 40 +SHA_B = "b" * 40 + + +def _minimal_registry(repo: str = "coven-code", *, risk: str = "R3") -> dict: + return { + "$schema": "../schemas/repository-registry.schema.json", + "schema_version": "opencoven.repository-registry/v1", + "organization": "OpenCoven", + "scope": { + "visibility": "public-only", + "observed_as_of": "2026-09-05", + "expected_public_repository_count": 1, + "private_inventory": "federated-and-intentionally-omitted", + }, + "defaults": { + "visibility": "public", + "observed": {"default_branch": "main", "archived": False}, + "owner": "BunsDev", + "technical_dri": "BunsDev", + "ownership_status": "bootstrap-single-owner", + "canonical_domains": [], + "does_not_own": ["runtime.persistence"], + "disposition": {"state": "retain", "review_by": "2026-12-02"}, + "agent_manifest": {"status": "enforced", "path": "agent/manifest.json"}, + "security_support": "active", + }, + "repositories": [{ + "name": repo, + "lifecycle": "active", + "canonicality": "supporting", + "risk_class": risk, + "purpose": "Synthetic public repository fixture.", + }], + } + + +def _minimal_manifest(repo: str = "coven-code", *, risk: str = "R3") -> dict: + return { + "$schema": "../schemas/agent-manifest.schema.json", + "schema_version": "opencoven.agent-repo/v1", + "repository": { + "name": repo, + "lifecycle": "active", + "canonicality": "supporting", + "canonical_for": [], + "does_not_own": ["runtime.persistence"], + "owner": "BunsDev", + "technical_dri": "BunsDev", + "ownership_status": "bootstrap-single-owner", + }, + "risk": { + "class": risk, + "protected_paths": ["src/**"], + "generated_paths": ["generated/**"], + "network_policy": "deny-by-default", + "secrets_policy": "forbidden-in-repository", + "external_side_effects": [], + }, + "agent": { + "entrypoint": "AGENTS.md", + "bootstrap": "./scripts/agent-bootstrap", + "verify": { + "fast": "./scripts/agent-check fast", + "full": "./scripts/agent-check full", + }, + }, + "contracts": {"produces": [], "consumes": []}, + } + + +def _minimal_evidence() -> dict: + return { + "$schema": "../schemas/evidence-packet.schema.json", + "schema_version": "opencoven.governance-evidence/v1", + "change": {"objective": "test", "acceptance_criteria": ["test"], "non_goals": []}, + "authority": { + "risk_class": "R3", + "authorization_effect": "none-metadata-only", + "protected_boundaries": [], + }, + "sources": [{"kind": "test", "reference": "test", "revision": "test"}], + "files": ["README.md"], + "verification": [{"command": "test", "result": "pass", "environment": "test"}], + "migration": "test", + "rollback": "test", + "uncertainty": [], + } + + +def _write_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + + +def _caller_workflow(*, reusable: str = "reusable-agent-readiness.yml", uses_ref: str = SHA_A, + policy_ref: str = SHA_A, path_input: str | None = "agent/manifest.json", + path_input_name: str = "manifest_path", extra_job: str = "", + on_block: str = "on:\n pull_request:\n") -> str: + with_block = f" policy_ref: {policy_ref}\n" + if path_input is not None: + with_block += f" {path_input_name}: {path_input}\n" + return ( + "name: caller\n" + f"{on_block}" + "permissions:\n" + " contents: read\n" + "jobs:\n" + " readiness:\n" + " permissions:\n" + " contents: read\n" + f" uses: OpenCoven/.github/.github/workflows/{reusable}@{uses_ref}\n" + " with:\n" + f"{with_block}" + f"{extra_job}" + ) + + +def _decoy_caller_workflow(*, reusable: str, path_input_name: str, path_input: str) -> str: + return ( + "name: caller\n" + "on: pull_request\n" + "permissions:\n" + " contents: read\n" + "jobs:\n" + " actual:\n" + f" uses: OpenCoven/.github/.github/workflows/{reusable}@{SHA_A}\n" + " with:\n" + f" policy_ref: {SHA_B}\n" + f" {path_input_name}: {path_input}\n" + " decoy:\n" + " if: false\n" + f" uses: OpenCoven/.github/.github/workflows/{reusable}@{SHA_B}\n" + f" with: {{ policy_ref: {SHA_B}, {path_input_name}: {path_input} }}\n" + ) + + +def _ambiguous_actual_uses_with_decoy(*, reusable: str, path_input_name: str, path_input: str, + actual_uses: str) -> str: + return ( + "name: caller\n" + "on: pull_request\n" + "permissions:\n" + " contents: read\n" + "jobs:\n" + " actual:\n" + f"{actual_uses}" + " with:\n" + f" policy_ref: {SHA_A}\n" + f" {path_input_name}: {path_input}\n" + " decoy:\n" + " if: false\n" + f" uses: OpenCoven/.github/.github/workflows/{reusable}@{SHA_A}\n" + " with:\n" + f" policy_ref: {SHA_A}\n" + f" {path_input_name}: {path_input}\n" + ) + + +def _extract_inline_preflight_script(workflow_name: str) -> str: + text = (ROOT / ".github/workflows" / workflow_name).read_text(encoding="utf-8") + marker = "Preflight caller policy binding before policy checkout" + start = text.index(marker) + match = re.search(r"python3 - <<'PY'\n(?P.*?)\n\s+PY", text[start:], re.DOTALL) + if not match: + raise AssertionError(f"preflight script not found in {workflow_name}") + return textwrap.dedent(match.group("body")) + + +SUPPORTED_EVENT_ON_BLOCKS = { + "plain-scalar": "on: push\n", + "double-quoted-scalar": 'on: "pull_request"\n', + "single-quoted-scalar": "on: 'workflow_dispatch'\n", + "mapping": "on:\n pull_request:\n", + "mapping-null": "on:\n push: null\n", + "mapping-with-options": "on:\n pull_request:\n branches: [main]\n", + "block-sequence": "on:\n - push\n - workflow_dispatch\n", + "flow-sequence": 'on: [push, "pull_request", \'workflow_dispatch\']\n', +} + + +UNSUPPORTED_EVENT_ON_BLOCKS = { + "quoted-top-level-on-key": '"on":\n pull_request:\n', + "folded-scalar-strip": "on: >-\n workflow_call\n", + "folded-scalar-keep": "on: >+\n workflow_call\n", + "folded-scalar-indent": "on: >2\n workflow_call\n", + "literal-scalar-strip": "on: |-\n workflow_call\n", + "literal-scalar-keep": "on: |+\n workflow_call\n", + "literal-scalar-indent": "on: |2\n workflow_call\n", + "sequence-folded-strip": "on:\n - >-\n workflow_call\n", + "sequence-folded-keep": "on:\n - >+\n workflow_call\n", + "sequence-folded-indent": "on:\n - >2\n workflow_call\n", + "sequence-literal-strip": "on:\n - |-\n workflow_call\n", + "sequence-literal-keep": "on:\n - |+\n workflow_call\n", + "sequence-literal-indent": "on:\n - |2\n workflow_call\n", + "scalar-tag": "on: !str workflow_call\n", + "sequence-tag": "on:\n - !str workflow_call\n", + "mapping-key-tag": "on:\n !str workflow_call:\n", + "escaped-double-quoted-workflow-call": 'on: "workflow\\x5fcall"\n', + "escaped-flow-quoted-workflow-call": 'on: [pull_request, "workflow\\u005fcall"]\n', + "invalid-spaced-scalar": "on: pull request\n", + "invalid-dotted-scalar": "on: pull.request\n", + "invalid-leading-digit-scalar": "on: 123_event\n", + "mapping-block-scalar-value": "on:\n pull_request: >-\n branches\n", +} + + +def _unsupported_actual_uses_cases(reusable: str) -> dict[str, str]: + target_b = f"OpenCoven/.github/.github/workflows/{reusable}@{SHA_B}" + return { + "folded-scalar": f" uses: >-\n {target_b}\n", + "folded-scalar-keep": f" uses: >+\n {target_b}\n", + "folded-scalar-indent": f" uses: >2\n {target_b}\n", + "literal-scalar": f" uses: |-\n {target_b}\n", + "literal-scalar-keep": f" uses: |+\n {target_b}\n", + "literal-scalar-indent": f" uses: |2\n {target_b}\n", + "tagged-scalar": f" uses: !str {target_b}\n", + "commented-empty-value": f" uses: # reviewed reusable target\n {target_b}\n", + "multiline-double-quoted": ( + f" uses: \"OpenCoven/.github/.github/workflows/{reusable}@\n" + f" {SHA_B}\"\n" + ), + "escaped-double-quoted-prefix": ( + f" uses: \"OpenCoven\\x2f.github/.github/workflows/{reusable}@{SHA_B}\"\n" + ), + } + + +UNSUPPORTED_DIRECT_CALLER_FORMS = { + "quoted-uses-key": lambda reusable, path_input_name, path_input: _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=path_input, + ).replace(" uses: OpenCoven", ' "uses": OpenCoven'), + "quoted-with-key": lambda reusable, path_input_name, path_input: _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=path_input, + ).replace(" with:\n", ' "with":\n'), + "quoted-policy-ref-key": lambda reusable, path_input_name, path_input: _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=path_input, + ).replace(" policy_ref:", ' "policy_ref":'), + "quoted-path-key": lambda reusable, path_input_name, path_input: _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=path_input, + ).replace(f" {path_input_name}:", f' "{path_input_name}":'), + "quoted-jobs-key": lambda reusable, path_input_name, path_input: _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=path_input, + ).replace("jobs:\n", '"jobs":\n'), + "flow-job-definition": lambda reusable, path_input_name, path_input: ( + "name: caller\n" + "on: pull_request\n" + "permissions:\n" + " contents: read\n" + "jobs:\n" + f" readiness: {{ uses: OpenCoven/.github/.github/workflows/{reusable}@{SHA_A}, " + f"with: {{ policy_ref: {SHA_A}, {path_input_name}: {path_input} }} }}\n" + ), + "malformed-job-indentation": lambda reusable, path_input_name, path_input: ( + "name: caller\n" + "on: pull_request\n" + "permissions:\n" + " contents: read\n" + "jobs:\n" + " readiness:\n" + f" uses: OpenCoven/.github/.github/workflows/{reusable}@{SHA_A}\n" + " with:\n" + f" policy_ref: {SHA_A}\n" + f" {path_input_name}: {path_input}\n" + ), + "tagged-policy-ref": lambda reusable, path_input_name, path_input: _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=path_input, + ).replace(f" policy_ref: {SHA_A}", f" policy_ref: !str {SHA_A}"), + "block-path-value": lambda reusable, path_input_name, path_input: _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=path_input, + ).replace(f" {path_input_name}: {path_input}", f" {path_input_name}: |-\n {path_input}"), +} + + +class TrustedTargetPathTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + _write_json(self.root / "governance/repositories.json", _minimal_registry()) + _write_json(self.root / "agent/manifest.json", _minimal_manifest()) + _write_json(self.root / "evidence/packet.json", _minimal_evidence()) + (self.root / "agent/directory").mkdir() + (self.root / "outside.txt").write_text("outside", encoding="utf-8") + os.symlink(self.root / "agent/manifest.json", self.root / "agent/manifest-link.json") + os.symlink(self.root / "outside.txt", self.root / "agent/outside-link.json") + os.symlink(self.root.parent, self.root / "agent/root-escape") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_manifest_and_evidence_relative_paths_are_accepted(self) -> None: + governance = GOV.Governance(self.root) + self.assertEqual([], governance.validate_manifest_file(self.root, "agent/manifest.json", caller_repository="OpenCoven/coven-code")) + self.assertEqual([], governance.validate_evidence_file(self.root, "evidence/packet.json")) + + def test_shell_metacharacters_are_literal_path_data(self) -> None: + manifest_path = "agent/manifest;$(echo not-executed).json" + evidence_path = "evidence/packet;$(echo not-executed).json" + registry = _minimal_registry() + registry["defaults"]["agent_manifest"]["path"] = manifest_path + _write_json(self.root / "governance/repositories.json", registry) + _write_json(self.root / manifest_path, _minimal_manifest()) + _write_json(self.root / evidence_path, _minimal_evidence()) + governance = GOV.Governance(self.root) + self.assertEqual([], governance.validate_manifest_file(self.root, manifest_path, caller_repository="OpenCoven/coven-code")) + self.assertEqual([], governance.validate_evidence_file(self.root, evidence_path)) + + def test_invalid_manifest_paths_fail_closed(self) -> None: + for value in ( + str(self.root / "agent/manifest.json"), + "../agent/manifest.json", + "agent/missing.json", + "agent/directory", + "agent/manifest\u0007.json", + "agent/manifest-link.json", + "agent/outside-link.json", + "agent/root-escape/outside.txt", + ): + with self.subTest(path=value): + errors = GOV.Governance(self.root).validate_manifest_file(self.root, value, caller_repository="OpenCoven/coven-code") + self.assertTrue(errors, value) + + def test_evidence_must_be_json_below_evidence_directory(self) -> None: + for value in ("agent/manifest.json", "evidence/../agent/manifest.json"): + with self.subTest(path=value): + errors = GOV.Governance(self.root).validate_evidence_file(self.root, value) + self.assertTrue(errors, value) + + def test_evidence_requires_schema_required_fields(self) -> None: + packet = _minimal_evidence() + packet["change"].pop("non_goals") + packet.pop("files") + _write_json(self.root / "evidence/packet.json", packet) + errors = GOV.Governance(self.root).validate_evidence_file(self.root, "evidence/packet.json") + self.assertTrue(any("evidence.change.non_goals must be an array" in error for error in errors), errors) + self.assertTrue(any("evidence.files must be an array" in error for error in errors), errors) + + def test_evidence_rejects_invalid_required_field_types(self) -> None: + packet = _minimal_evidence() + packet["files"] = "README.md" + packet["authority"]["protected_boundaries"] = "boundary" + _write_json(self.root / "evidence/packet.json", packet) + errors = GOV.Governance(self.root).validate_evidence_file(self.root, "evidence/packet.json") + self.assertTrue(any("evidence.files must be an array" in error for error in errors), errors) + self.assertTrue(any("evidence.authority.protected_boundaries must be an array" in error for error in errors), errors) + + +class ReusableInvocationPolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.workflow = self.root / ".github/workflows/caller.yml" + self.workflow.parent.mkdir(parents=True) + self.workflow.write_text(_caller_workflow(), encoding="utf-8") + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _errors(self, *, policy_ref: str = SHA_A, runtime_path: str = "agent/manifest.json", + workflow_ref_path: str = ".github/workflows/caller.yml", + caller_repository: str = "OpenCoven/coven-code", + reusable: str = "reusable-agent-readiness.yml", + path_input_name: str = "manifest_path") -> list[str]: + return GOV.validate_reusable_invocation( + self.root, + caller_workflow_ref=f"OpenCoven/coven-code/{workflow_ref_path}@refs/heads/main", + caller_repository=caller_repository, + policy_ref=policy_ref, + reusable_workflow=reusable, + path_input_name=path_input_name, + runtime_path=runtime_path, + default_runtime_path="agent/manifest.json" if path_input_name == "manifest_path" else None, + ) + + def _cli_result(self, *, reusable: str = "reusable-agent-readiness.yml", + path_input_name: str = "manifest_path", + runtime_path: str = "agent/manifest.json", + policy_ref: str = SHA_A) -> subprocess.CompletedProcess[str]: + command = [ + sys.executable, + str(MODULE_PATH), + "validate-reusable-invocation", + "--target-root", str(self.root), + "--caller-workflow-ref", "OpenCoven/coven-code/.github/workflows/caller.yml@refs/heads/main", + "--caller-repository", "OpenCoven/coven-code", + "--policy-ref", policy_ref, + "--reusable-workflow", reusable, + "--path-input-name", path_input_name, + "--runtime-path", runtime_path, + ] + if path_input_name == "manifest_path": + command.extend(["--default-runtime-path", "agent/manifest.json"]) + return subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False) + + def test_positive_direct_caller_with_exact_sha_is_accepted(self) -> None: + self.assertEqual([], self._errors()) + + def test_supported_literal_event_forms_are_accepted(self) -> None: + cases = dict(SUPPORTED_EVENT_ON_BLOCKS) + cases.update({ + "mapping-with-comment": "on:\n pull_request: # reviewed event\n", + }) + for name, on_block in cases.items(): + with self.subTest(case=name): + self.workflow.write_text(_caller_workflow(on_block=on_block), encoding="utf-8") + self.assertEqual([], self._errors()) + + def test_round3_supported_event_forms_are_accepted_by_core_and_cli_for_both_reusables(self) -> None: + reusable_cases = ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json"), + ) + for reusable, path_input_name, runtime_path in reusable_cases: + for name, on_block in SUPPORTED_EVENT_ON_BLOCKS.items(): + with self.subTest(reusable=reusable, case=name): + self.workflow.write_text( + _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=runtime_path, + on_block=on_block, + ), + encoding="utf-8", + ) + self.assertEqual( + [], + self._errors(reusable=reusable, path_input_name=path_input_name, runtime_path=runtime_path), + ) + result = self._cli_result(reusable=reusable, path_input_name=path_input_name, runtime_path=runtime_path) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_nested_workflow_call_event_variants_fail_closed(self) -> None: + cases = { + "quoted-mapping": 'on:\n "workflow_call":\n', + "flow-sequence": 'on: [pull_request, "workflow_call"]\n', + "block-sequence": "on:\n - pull_request\n - workflow_call\n", + } + for name, on_block in cases.items(): + with self.subTest(case=name): + self.workflow.write_text(_caller_workflow(on_block=on_block), encoding="utf-8") + errors = self._errors() + self.assertTrue(any("nested reusable workflow callers" in error for error in errors), errors) + + def test_unsupported_event_ambiguity_fails_closed(self) -> None: + cases = dict(UNSUPPORTED_EVENT_ON_BLOCKS) + cases.update({ + "flow-mapping": "on: { pull_request: null }\n", + "duplicate-on": "on: pull_request\non: push\n", + "duplicate-event": "on:\n pull_request:\n pull_request:\n", + }) + for name, on_block in cases.items(): + with self.subTest(case=name): + self.workflow.write_text(_caller_workflow(on_block=on_block), encoding="utf-8") + self.assertTrue(self._errors()) + + def test_round3_unsupported_event_forms_fail_core_and_cli_for_both_reusables(self) -> None: + reusable_cases = ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json"), + ) + for reusable, path_input_name, runtime_path in reusable_cases: + for name, on_block in UNSUPPORTED_EVENT_ON_BLOCKS.items(): + with self.subTest(reusable=reusable, case=name): + self.workflow.write_text( + _caller_workflow( + reusable=reusable, + path_input_name=path_input_name, + path_input=runtime_path, + on_block=on_block, + ), + encoding="utf-8", + ) + self.assertTrue( + self._errors(reusable=reusable, path_input_name=path_input_name, runtime_path=runtime_path) + ) + result = self._cli_result(reusable=reusable, path_input_name=path_input_name, runtime_path=runtime_path) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_unsupported_actual_uses_forms_fail_before_decoy_matching_for_core_and_cli(self) -> None: + reusable_cases = ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json"), + ) + for reusable, path_input_name, runtime_path in reusable_cases: + for name, actual_uses in _unsupported_actual_uses_cases(reusable).items(): + with self.subTest(reusable=reusable, case=name): + self.workflow.write_text( + _ambiguous_actual_uses_with_decoy( + reusable=reusable, + path_input_name=path_input_name, + path_input=runtime_path, + actual_uses=actual_uses, + ), + encoding="utf-8", + ) + self.assertTrue( + self._errors(reusable=reusable, path_input_name=path_input_name, runtime_path=runtime_path) + ) + result = self._cli_result(reusable=reusable, path_input_name=path_input_name, runtime_path=runtime_path) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_unsupported_direct_caller_key_forms_fail_core_and_cli_for_both_reusables(self) -> None: + reusable_cases = ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json"), + ) + for reusable, path_input_name, runtime_path in reusable_cases: + for name, build in UNSUPPORTED_DIRECT_CALLER_FORMS.items(): + with self.subTest(reusable=reusable, case=name): + self.workflow.write_text(build(reusable, path_input_name, runtime_path), encoding="utf-8") + self.assertTrue( + self._errors(reusable=reusable, path_input_name=path_input_name, runtime_path=runtime_path) + ) + result = self._cli_result(reusable=reusable, path_input_name=path_input_name, runtime_path=runtime_path) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_exact_decoy_same_reusable_job_fails_closed(self) -> None: + self.workflow.write_text( + _decoy_caller_workflow( + reusable="reusable-agent-readiness.yml", + path_input_name="manifest_path", + path_input="agent/manifest.json", + ), + encoding="utf-8", + ) + errors = self._errors(policy_ref=SHA_B) + self.assertTrue(errors) + + def test_inline_with_flow_mapping_fails_closed(self) -> None: + self.workflow.write_text( + _caller_workflow().replace( + " with:\n" + f" policy_ref: {SHA_A}\n" + " manifest_path: agent/manifest.json\n", + f" with: {{ policy_ref: {SHA_A}, manifest_path: agent/manifest.json }}\n", + ), + encoding="utf-8", + ) + self.assertTrue(self._errors()) + + def test_mismatching_uses_with_and_runtime_policy_shas_fail_closed(self) -> None: + cases = { + "uses": _caller_workflow(uses_ref=SHA_B), + "with": _caller_workflow(policy_ref=SHA_B), + "runtime": _caller_workflow(), + } + for name, text in cases.items(): + with self.subTest(case=name): + self.workflow.write_text(text, encoding="utf-8") + errors = self._errors(policy_ref=SHA_B if name == "runtime" else SHA_A) + self.assertTrue(errors, errors) + + def test_branch_tag_and_malformed_refs_fail_closed(self) -> None: + for ref in ("main", "v1", "refs/heads/main", "abc123"): + with self.subTest(ref=ref): + self.workflow.write_text(_caller_workflow(uses_ref=ref), encoding="utf-8") + self.assertTrue(self._errors()) + + def test_nested_caller_expressions_anchors_and_inherited_secrets_fail_closed(self) -> None: + cases = ( + _caller_workflow(on_block="on:\n workflow_call:\n"), + _caller_workflow(policy_ref="${{ github.sha }}"), + _caller_workflow(extra_job=" secrets: inherit\n"), + _caller_workflow().replace("uses: OpenCoven", "uses: &reuse OpenCoven"), + ) + for text in cases: + with self.subTest(text=text): + self.workflow.write_text(text, encoding="utf-8") + self.assertTrue(self._errors()) + + def test_wrong_workflow_name_and_caller_path_fail_closed(self) -> None: + self.workflow.write_text(_caller_workflow(reusable="other.yml"), encoding="utf-8") + self.assertTrue(self._errors()) + self.assertTrue(self._errors(workflow_ref_path=".github/actions/caller.yml")) + + def test_runtime_path_must_match_literal_caller_input_or_default(self) -> None: + self.workflow.write_text(_caller_workflow(path_input=None), encoding="utf-8") + self.assertEqual([], self._errors(runtime_path="agent/manifest.json")) + self.assertTrue(self._errors(runtime_path="other.json")) + self.workflow.write_text(_caller_workflow(path_input="${{ matrix.path }}"), encoding="utf-8") + self.assertTrue(self._errors(runtime_path="agent/manifest.json")) + + def test_spoofed_caller_repository_fails_closed(self) -> None: + self.assertTrue(self._errors(caller_repository="OpenCoven/coven")) + + +class InlineReusablePreflightTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.workspace = Path(self.tmp.name) + self.target = self.workspace / "target" + self.workflow = self.target / ".github/workflows/caller.yml" + self.workflow.parent.mkdir(parents=True) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _run_preflight( + self, + *, + workflow_name: str, + policy_ref: str, + reusable: str, + path_input_name: str, + runtime_path: str, + default_runtime_path: str = "", + ) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update({ + "POLICY_REF": policy_ref, + "CALLER_REPOSITORY": "OpenCoven/coven-code", + "CALLER_WORKFLOW_REF": "OpenCoven/coven-code/.github/workflows/caller.yml@refs/heads/main", + "REUSABLE_WORKFLOW": reusable, + "PATH_INPUT_NAME": path_input_name, + "RUNTIME_PATH": runtime_path, + }) + if default_runtime_path: + env["DEFAULT_RUNTIME_PATH"] = default_runtime_path + else: + env.pop("DEFAULT_RUNTIME_PATH", None) + return subprocess.run( + [sys.executable, "-c", _extract_inline_preflight_script(workflow_name)], + cwd=self.workspace, + env=env, + text=True, + capture_output=True, + check=False, + ) + + def test_inline_preflight_rejects_exact_decoy_fixture_for_both_reusables(self) -> None: + cases = ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json", ""), + ) + for workflow_name, path_input_name, runtime_path, default_path in cases: + with self.subTest(workflow=workflow_name): + self.workflow.write_text( + _decoy_caller_workflow( + reusable=workflow_name, + path_input_name=path_input_name, + path_input=runtime_path, + ), + encoding="utf-8", + ) + result = self._run_preflight( + workflow_name=workflow_name, + policy_ref=SHA_B, + reusable=workflow_name, + path_input_name=path_input_name, + runtime_path=runtime_path, + default_runtime_path=default_path, + ) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_inline_preflight_accepts_supported_callers_for_both_reusables(self) -> None: + cases = ( + ( + "reusable-agent-readiness.yml", + _caller_workflow(path_input=None), + "manifest_path", + "agent/manifest.json", + "agent/manifest.json", + ), + ( + "reusable-evidence-packet.yml", + _caller_workflow( + reusable="reusable-evidence-packet.yml", + path_input="evidence/packet.json", + path_input_name="evidence_path", + ), + "evidence_path", + "evidence/packet.json", + "", + ), + ) + for workflow_name, text, path_input_name, runtime_path, default_path in cases: + with self.subTest(workflow=workflow_name): + self.workflow.write_text(text, encoding="utf-8") + result = self._run_preflight( + workflow_name=workflow_name, + policy_ref=SHA_A, + reusable=workflow_name, + path_input_name=path_input_name, + runtime_path=runtime_path, + default_runtime_path=default_path, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_inline_preflight_rejects_nested_event_variants_for_both_reusables(self) -> None: + nested_events = ( + 'on:\n "workflow_call":\n', + 'on: [pull_request, "workflow_call"]\n', + "on:\n - pull_request\n - workflow_call\n", + ) + for workflow_name, path_input_name, runtime_path, default_path in ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json", ""), + ): + for on_block in nested_events: + with self.subTest(workflow=workflow_name, on_block=on_block): + self.workflow.write_text( + _caller_workflow( + reusable=workflow_name, + path_input=runtime_path, + path_input_name=path_input_name, + on_block=on_block, + ), + encoding="utf-8", + ) + result = self._run_preflight( + workflow_name=workflow_name, + policy_ref=SHA_A, + reusable=workflow_name, + path_input_name=path_input_name, + runtime_path=runtime_path, + default_runtime_path=default_path, + ) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("nested reusable workflow callers", result.stderr) + + def test_round3_inline_preflight_event_table_for_both_reusables(self) -> None: + reusable_cases = ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json", ""), + ) + for workflow_name, path_input_name, runtime_path, default_path in reusable_cases: + for name, on_block in SUPPORTED_EVENT_ON_BLOCKS.items(): + with self.subTest(workflow=workflow_name, case=name, expected="accepted"): + self.workflow.write_text( + _caller_workflow( + reusable=workflow_name, + path_input=runtime_path, + path_input_name=path_input_name, + on_block=on_block, + ), + encoding="utf-8", + ) + result = self._run_preflight( + workflow_name=workflow_name, + policy_ref=SHA_A, + reusable=workflow_name, + path_input_name=path_input_name, + runtime_path=runtime_path, + default_runtime_path=default_path, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for name, on_block in UNSUPPORTED_EVENT_ON_BLOCKS.items(): + with self.subTest(workflow=workflow_name, case=name, expected="rejected"): + self.workflow.write_text( + _caller_workflow( + reusable=workflow_name, + path_input=runtime_path, + path_input_name=path_input_name, + on_block=on_block, + ), + encoding="utf-8", + ) + result = self._run_preflight( + workflow_name=workflow_name, + policy_ref=SHA_A, + reusable=workflow_name, + path_input_name=path_input_name, + runtime_path=runtime_path, + default_runtime_path=default_path, + ) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_inline_preflight_rejects_unsupported_actual_uses_before_decoy_matching(self) -> None: + reusable_cases = ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json", ""), + ) + for workflow_name, path_input_name, runtime_path, default_path in reusable_cases: + for name, actual_uses in _unsupported_actual_uses_cases(workflow_name).items(): + with self.subTest(workflow=workflow_name, case=name): + self.workflow.write_text( + _ambiguous_actual_uses_with_decoy( + reusable=workflow_name, + path_input_name=path_input_name, + path_input=runtime_path, + actual_uses=actual_uses, + ), + encoding="utf-8", + ) + result = self._run_preflight( + workflow_name=workflow_name, + policy_ref=SHA_A, + reusable=workflow_name, + path_input_name=path_input_name, + runtime_path=runtime_path, + default_runtime_path=default_path, + ) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_inline_preflight_rejects_unsupported_direct_caller_key_forms(self) -> None: + reusable_cases = ( + ("reusable-agent-readiness.yml", "manifest_path", "agent/manifest.json", "agent/manifest.json"), + ("reusable-evidence-packet.yml", "evidence_path", "evidence/packet.json", ""), + ) + for workflow_name, path_input_name, runtime_path, default_path in reusable_cases: + for name, build in UNSUPPORTED_DIRECT_CALLER_FORMS.items(): + with self.subTest(workflow=workflow_name, case=name): + self.workflow.write_text(build(workflow_name, path_input_name, runtime_path), encoding="utf-8") + result = self._run_preflight( + workflow_name=workflow_name, + policy_ref=SHA_A, + reusable=workflow_name, + path_input_name=path_input_name, + runtime_path=runtime_path, + default_runtime_path=default_path, + ) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + +class ManifestRegistryBindingTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + _write_json(self.root / "governance/repositories.json", _minimal_registry()) + _write_json(self.root / "agent/manifest.json", _minimal_manifest()) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _manifest_errors(self, manifest: dict | None = None, *, caller_repository: str = "OpenCoven/coven-code") -> list[str]: + if manifest is not None: + _write_json(self.root / "agent/manifest.json", manifest) + return GOV.Governance(self.root).validate_manifest_file( + self.root, + "agent/manifest.json", + caller_repository=caller_repository, + ) + + def test_manifest_repository_is_bound_to_actual_caller(self) -> None: + manifest = _minimal_manifest(repo="coven") + self.assertTrue(self._manifest_errors(manifest), "self-declared repository name must not select registry entry") + + def test_unregistered_caller_and_registry_field_mismatch_fail_closed(self) -> None: + self.assertTrue(self._manifest_errors(caller_repository="OpenCoven/not-registered")) + manifest = _minimal_manifest() + manifest["repository"]["owner"] = "SomeoneElse" + manifest["repository"]["does_not_own"] = [] + self.assertTrue(self._manifest_errors(manifest)) + + def test_r3_r4_manifests_require_protected_paths_and_canonical_adapters(self) -> None: + manifest = _minimal_manifest() + manifest["risk"]["protected_paths"] = [] + self.assertTrue(self._manifest_errors(manifest)) + manifest = _minimal_manifest() + manifest["agent"]["bootstrap"] = "python bootstrap.py" + manifest["agent"]["verify"]["fast"] = "pytest" + manifest["agent"]["verify"]["full"] = "pytest -q" + self.assertTrue(self._manifest_errors(manifest)) + + +class GeneratedOutputTests(unittest.TestCase): + def test_generation_is_deterministic(self) -> None: + governance = GOV.Governance(ROOT) + first = governance.generated_content() + second = governance.generated_content() + self.assertEqual(first, second) + + +class PublicDriftTests(unittest.TestCase): + def setUp(self) -> None: + self.governance = GOV.Governance(ROOT) + self.registry = GOV.expanded_repositories(self.governance.registry()) + self.live = [ + { + "name": item["name"], + "visibility": "public", + "private": False, + "archived": item["observed"]["archived"], + "default_branch": item["observed"]["default_branch"], + } + for item in self.registry + ] + + def test_matching_public_inventory_has_no_drift(self) -> None: + self.assertEqual([], GOV.reconcile_public_inventory(self.governance, self.live)) + + def test_unregistered_public_repository_is_reported(self) -> None: + live = self.live + [{ + "name": "unexpected-public-repository", + "visibility": "public", + "private": False, + "archived": False, + "default_branch": "main", + }] + errors = GOV.reconcile_public_inventory(self.governance, live) + self.assertTrue(any("unregistered public repository" in error for error in errors), errors) + + +def _raw_node(number: int, *, title: str = GOV.MANAGED_ISSUE_TITLE, marker: str | None = GOV.MANAGED_ISSUE_MARKER, + login: str = governance_cli.GRAPHQL_BOT_LOGIN, typename: str | None = governance_cli.GRAPHQL_BOT_TYPENAME, + node_id: str | None = None) -> dict: + """Build a raw GraphQL `Issue` node as returned inside a `nodes` array. + + Defaults to the real GraphQL Actions-bot author shape: `__typename: "Bot"` + with the unsuffixed `login: "github-actions"` (GraphQL never reports the + REST-style `github-actions[bot]` login).""" + return { + "id": node_id or f"ISSUE_NODE_{number}", + "number": number, + "title": title, + "body": f"{marker}\nsome drift body" if marker else "no marker here", + "author": {"__typename": typename, "login": login}, + } + + +def _raw_filler(number: int) -> dict: + return _raw_node(number, title=f"unrelated issue {number}", marker=None, login="some-human", typename="User") + + +def _gql_page(nodes: list[dict], *, has_next: bool, end_cursor: str | None) -> dict: + return { + "data": { + "repository": { + "issues": { + "pageInfo": {"hasNextPage": has_next, "endCursor": end_cursor}, + "nodes": nodes, + } + } + } + } + + +def _flat_issue(number: int, *, title: str = GOV.MANAGED_ISSUE_TITLE, marker: str = GOV.MANAGED_ISSUE_MARKER, + login: str = GOV.MANAGED_ISSUE_AUTHOR) -> dict: + """Build an already-validated flattened issue dict, the shape + `find_managed_drift_issue` consumes after `_validate_issue_node`.""" + return {"id": f"ISSUE_NODE_{number}", "number": number, "title": title, "body": f"{marker}\nsome drift body", "login": login} + + +def _flat_filler(number: int) -> dict: + return {"id": f"ISSUE_NODE_{number}", "number": number, "title": f"unrelated issue {number}", "body": "no marker here", "login": "some-human"} + + +def _rest_issue(number: int, *, title: str = GOV.MANAGED_ISSUE_TITLE, marker: str | None = GOV.MANAGED_ISSUE_MARKER, + login: str = GOV.MANAGED_ISSUE_AUTHOR, is_pull_request: bool = False) -> dict: + """Build a raw REST `/issues` list item (the shape fetch_open_issues_readonly consumes).""" + issue = { + "number": number, + "title": title, + "body": f"{marker}\nsome drift body" if marker else "no marker here", + "user": {"login": login}, + } + if is_pull_request: + issue["pull_request"] = {"url": f"https://api.github.com/repos/OpenCoven/.github/pulls/{number}"} + return issue + + +def _rest_filler(number: int) -> dict: + return _rest_issue(number, title=f"unrelated issue {number}", marker=None, login="some-human") + + +def _rest_get_calls(mocked) -> list: + return [c for c in mocked.call_args_list if c.args[0] != governance_cli.GRAPHQL_ENDPOINT] + + +def _graphql_calls(mocked) -> list: + return [c for c in mocked.call_args_list if c.args[0] == governance_cli.GRAPHQL_ENDPOINT] + + +def _rest_post_calls(mocked) -> list: + return [c for c in mocked.call_args_list if c.kwargs.get("method") == "POST" and c.args[0] != governance_cli.GRAPHQL_ENDPOINT] + + +def _patch_calls(mocked) -> list: + return [c for c in mocked.call_args_list if c.kwargs.get("method") == "PATCH"] + + +class DriftIssueScanTests(unittest.TestCase): + """Regression coverage for the GraphQL cursor-based open-issue scan used by + upsert_drift_issue, and for the closed managed-issue-creation race. + + REST page-number pagination is unsafe over a mutable open-issue + collection: closing an earlier issue between two `page=N` requests + shifts every later issue left by one slot, which can make a managed + issue about to cross a page boundary vanish from the scan entirely, or + cause a boundary issue to be returned on two consecutive pages. These + tests exercise the GraphQL cursor-based replacement, which identifies + pagination position relative to an already-returned node rather than an + absolute offset, and prove that any inconsistency it cannot resolve + (duplicate node ids, a stalled cursor, or malformed shapes) fails closed + instead of silently returning a partial, skipped, or duplicated result. + """ + + def test_managed_issue_on_second_page_is_updated_not_duplicated(self) -> None: + # 101 total open issues: a full 100-item first GraphQL page of + # unrelated issues, plus the real managed issue only on the second + # page (reached via the page-one endCursor). + page_one_nodes = [_raw_filler(i) for i in range(1, 101)] + page_two_nodes = [_raw_node(101)] + + def fake_github_request(url, *, token=None, method="GET", payload=None): + if url == governance_cli.GRAPHQL_ENDPOINT: + after = payload["variables"]["after"] + if after is None: + return _gql_page(page_one_nodes, has_next=True, end_cursor="cursor-1") + if after == "cursor-1": + return _gql_page(page_two_nodes, has_next=False, end_cursor=None) + self.fail(f"unexpected cursor: {after}") + self.assertEqual(method, "PATCH", f"unexpected REST request: {method} {url}") + return {"number": 101} + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request) as mocked: + GOV.upsert_drift_issue("OpenCoven/.github", "token", ["some drift"], dry_run=False) + + self.assertEqual(len(_rest_post_calls(mocked)), 0, "must not create a duplicate managed issue") + patch_calls = _patch_calls(mocked) + self.assertEqual(len(patch_calls), 1, "must update the existing managed issue") + self.assertEqual(patch_calls[0].args[0], "https://api.github.com/repos/OpenCoven/.github/issues/101") + + def test_fetch_open_issues_paginates_beyond_first_page(self) -> None: + page_one_nodes = [_raw_filler(i) for i in range(1, 101)] + page_two_nodes = [_raw_filler(101)] + + def fake_github_request(url, *, token=None, method="GET", payload=None): + after = payload["variables"]["after"] + if after is None: + return _gql_page(page_one_nodes, has_next=True, end_cursor="cursor-1") + if after == "cursor-1": + return _gql_page(page_two_nodes, has_next=False, end_cursor=None) + self.fail(f"unexpected cursor: {after}") + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request): + issues = governance_cli.fetch_open_issues("OpenCoven", ".github", "token") + self.assertEqual(len(issues), 101) + + def test_boundary_duplicate_node_id_across_pages_fails_closed(self) -> None: + # Simulates a mutation-induced boundary duplicate: the same + # underlying issue is returned by both the first and second page + # (for example because an insertion/deletion shifted the connection + # between requests). A scan must never silently accept this: it + # would either double-count an unrelated issue or, worse, mask a + # real skip elsewhere in the traversal. + duplicate = _raw_node(101, node_id="ISSUE_NODE_101") + + def fake_github_request(url, *, token=None, method="GET", payload=None): + after = payload["variables"]["after"] + if after is None: + return _gql_page([duplicate], has_next=True, end_cursor="cursor-1") + if after == "cursor-1": + return _gql_page([duplicate], has_next=False, end_cursor=None) + self.fail(f"unexpected cursor: {after}") + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request): + with self.assertRaises(RuntimeError): + governance_cli.fetch_open_issues("OpenCoven", ".github", "token") + + def test_repeated_cursor_without_progress_fails_closed(self) -> None: + # Simulates a collection mutation that leaves the connection unable + # to make forward progress (the same endCursor reported twice). A + # scan must fail closed rather than loop forever or silently return + # a truncated result. + def fake_github_request(url, *, token=None, method="GET", payload=None): + after = payload["variables"]["after"] + if after is None: + return _gql_page([_raw_filler(1)], has_next=True, end_cursor="cursor-1") + if after == "cursor-1": + return _gql_page([], has_next=True, end_cursor="cursor-1") + self.fail(f"unexpected cursor: {after}") + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request): + with self.assertRaises(RuntimeError): + governance_cli.fetch_open_issues("OpenCoven", ".github", "token") + + def test_missing_end_cursor_with_has_next_page_fails_closed(self) -> None: + with patch.object( + governance_cli, "github_request", + return_value=_gql_page([_raw_filler(1)], has_next=True, end_cursor=None), + ): + with self.assertRaises(RuntimeError): + governance_cli.fetch_open_issues("OpenCoven", ".github", "token") + + def test_malformed_page_info_is_rejected(self) -> None: + malformed = {"data": {"repository": {"issues": {"pageInfo": {"hasNextPage": "yes"}, "nodes": []}}}} + with patch.object(governance_cli, "github_request", return_value=malformed): + with self.assertRaises(RuntimeError): + governance_cli.fetch_open_issues("OpenCoven", ".github", "token") + + def test_malformed_issue_node_is_rejected(self) -> None: + malformed_node = {"id": "ISSUE_NODE_1", "title": "missing number field"} + with patch.object( + governance_cli, "github_request", + return_value=_gql_page([malformed_node], has_next=False, end_cursor=None), + ): + with self.assertRaises(RuntimeError): + governance_cli.fetch_open_issues("OpenCoven", ".github", "token") + + def test_malformed_top_level_response_is_rejected(self) -> None: + with patch.object(governance_cli, "github_request", return_value={"message": "not found"}): + with self.assertRaises(RuntimeError): + governance_cli.fetch_open_issues("OpenCoven", ".github", "token") + + def test_ambiguous_managed_issues_fail_closed(self) -> None: + issues = [_flat_issue(1), _flat_issue(2)] + with self.assertRaises(RuntimeError): + GOV.find_managed_drift_issue(issues, marker=GOV.MANAGED_ISSUE_MARKER, title=GOV.MANAGED_ISSUE_TITLE) + + def test_spoofed_issue_from_untrusted_author_fails_closed(self) -> None: + issues = [_flat_issue(1, login="an-attacker")] + with self.assertRaises(RuntimeError): + GOV.find_managed_drift_issue(issues, marker=GOV.MANAGED_ISSUE_MARKER, title=GOV.MANAGED_ISSUE_TITLE) + + def test_real_graphql_bot_identity_is_recognized_end_to_end(self) -> None: + # GitHub's two APIs report the scheduled Actions bot's identity + # differently: REST reports `user.login == "github-actions[bot]"`, + # while GraphQL's `author` union reports the unsuffixed + # `login == "github-actions"` together with `__typename == "Bot"`. + # Before normalization, comparing the raw GraphQL login directly + # against the REST-shaped `MANAGED_ISSUE_AUTHOR` constant rejected + # the workflow's own previously-created issue as spoofed. This + # exercises the full path (raw GraphQL node -> `_validate_issue_node` + # normalization -> `find_managed_drift_issue`) with the exact real + # API shape and proves the managed issue is now recognized and + # trusted rather than treated as ambiguous/suspicious. + raw_node = _raw_node(101) # defaults to __typename="Bot", login="github-actions" + flattened = governance_cli._validate_issue_node(raw_node, owner="OpenCoven", repo=".github", index=0) + self.assertEqual(flattened["login"], GOV.MANAGED_ISSUE_AUTHOR) + found = GOV.find_managed_drift_issue([flattened], marker=GOV.MANAGED_ISSUE_MARKER, title=GOV.MANAGED_ISSUE_TITLE) + self.assertIsNotNone(found, "the real GraphQL bot identity must be recognized as the managed issue") + self.assertEqual(found["number"], 101) + + def test_non_bot_typename_with_matching_login_is_not_implicitly_trusted(self) -> None: + # A `User` (or any non-`Bot` typename) whose login happens to equal + # the bot's unsuffixed GraphQL login ("github-actions") must never + # be normalized to the canonical managed identity: normalization is + # keyed on the exact (__typename, login) pair, not login alone. + spoofing_node = _raw_node(202, login=governance_cli.GRAPHQL_BOT_LOGIN, typename="User") + flattened = governance_cli._validate_issue_node(spoofing_node, owner="OpenCoven", repo=".github", index=0) + self.assertEqual(flattened["login"], governance_cli.GRAPHQL_BOT_LOGIN, "must not be rewritten to the canonical bot login") + self.assertNotEqual(flattened["login"], GOV.MANAGED_ISSUE_AUTHOR) + with self.assertRaises(RuntimeError): + GOV.find_managed_drift_issue([flattened], marker=GOV.MANAGED_ISSUE_MARKER, title=GOV.MANAGED_ISSUE_TITLE) + + def test_no_matching_issue_returns_none(self) -> None: + issues = [_flat_filler(1), _flat_filler(2)] + self.assertIsNone( + GOV.find_managed_drift_issue(issues, marker=GOV.MANAGED_ISSUE_MARKER, title=GOV.MANAGED_ISSUE_TITLE) + ) + + def test_pre_create_revalidation_prevents_duplicate_when_managed_issue_appears_between_scans(self) -> None: + # The initial scan finds nothing (only unrelated issues). Before the + # fix, upsert_drift_issue would immediately POST a new managed + # issue. Simulate a concurrent reconciler run creating the managed + # issue in the window between the initial scan and this POST: the + # pre-create revalidation scan must find it and PATCH instead of + # creating a duplicate. + scans_completed = {"count": 0} + + def fake_github_request(url, *, token=None, method="GET", payload=None): + if url == governance_cli.GRAPHQL_ENDPOINT: + self.assertIsNone(payload["variables"]["after"], "test only uses single-page scans") + scans_completed["count"] += 1 + if scans_completed["count"] == 1: + return _gql_page([_raw_filler(1)], has_next=False, end_cursor=None) + return _gql_page([_raw_node(202)], has_next=False, end_cursor=None) + self.assertEqual(method, "PATCH", f"unexpected REST request: {method} {url}") + return {"number": 202} + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request) as mocked: + GOV.upsert_drift_issue("OpenCoven/.github", "token", ["some drift"], dry_run=False) + + self.assertEqual(scans_completed["count"], 2, "must scan twice: initial scan, then pre-create revalidation") + self.assertEqual(len(_rest_post_calls(mocked)), 0, "revalidation must prevent the duplicate POST") + patch_calls = _patch_calls(mocked) + self.assertEqual(len(patch_calls), 1, "revalidation must PATCH the concurrently created managed issue") + self.assertEqual(patch_calls[0].args[0], "https://api.github.com/repos/OpenCoven/.github/issues/202") + + def test_pre_create_revalidation_ambiguity_fails_closed_instead_of_creating(self) -> None: + # If the revalidation scan itself becomes ambiguous (for example two + # concurrent runs both created a managed-looking issue), the + # observer must refuse to act rather than guessing or creating a + # third duplicate. + scans_completed = {"count": 0} + + def fake_github_request(url, *, token=None, method="GET", payload=None): + if url == governance_cli.GRAPHQL_ENDPOINT: + scans_completed["count"] += 1 + if scans_completed["count"] == 1: + return _gql_page([], has_next=False, end_cursor=None) + return _gql_page( + [_raw_node(202, node_id="ISSUE_NODE_202"), _raw_node(203, node_id="ISSUE_NODE_203")], + has_next=False, end_cursor=None, + ) + self.fail(f"unexpected non-GraphQL request: {method} {url}") + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request) as mocked: + with self.assertRaises(RuntimeError): + GOV.upsert_drift_issue("OpenCoven/.github", "token", ["some drift"], dry_run=False) + + self.assertEqual(len(_rest_post_calls(mocked)), 0, "must never create on an ambiguous revalidation") + + def test_authenticated_mutating_run_uses_graphql_scan(self) -> None: + # A non-dry-run call with a token must use the consistent GraphQL + # cursor scan (never the REST offset scan), since it is the only + # path allowed to PATCH/POST. + def fake_github_request(url, *, token=None, method="GET", payload=None): + if url == governance_cli.GRAPHQL_ENDPOINT: + return _gql_page([_raw_node(101)], has_next=False, end_cursor=None) + self.assertEqual(method, "PATCH", f"unexpected REST request: {method} {url}") + return {"number": 101} + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request) as mocked: + GOV.upsert_drift_issue("OpenCoven/.github", "token", ["some drift"], dry_run=False) + + self.assertEqual(len(_graphql_calls(mocked)), 1) + self.assertEqual(len(_rest_get_calls(mocked)), 1, "the single REST call must be the PATCH, not a GET scan") + + +class TokenlessDryRunTests(unittest.TestCase): + """Regression coverage for the documented unauthenticated `--dry-run` contract. + + `command_reconcile` explicitly allows `--dry-run` without `GITHUB_TOKEN`, + but `upsert_drift_issue` previously always scanned via GraphQL, which + requires a token — an unauthenticated GraphQL POST is rejected/rate + limited, breaking the documented tokenless dry-run path outright. These + tests prove: (1) a tokenless dry-run never calls the GraphQL endpoint + and instead uses the read-only REST scan, while still reporting drift or + close intent as applicable; (2) a non-dry-run call without a token + remains rejected, both at the `command_reconcile` gate and, in depth, + inside `upsert_drift_issue` itself; and (3) authenticated runs are + unaffected and still use the GraphQL scan plus pre-create revalidation + (covered by `DriftIssueScanTests`). + """ + + def test_tokenless_dry_run_with_drift_reports_body_via_rest_scan_only(self) -> None: + page_one = [_rest_filler(i) for i in range(1, 101)] + page_two = [_rest_filler(101)] # no managed issue exists yet + + def fake_github_request(url, *, token=None, method="GET", payload=None): + self.assertIsNone(token, "tokenless dry-run must never attach a token") + if "page=2" in url: + return page_two + if "page=1" in url: + return page_one + self.fail(f"unexpected request: {method} {url}") + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request) as mocked: + with redirect_stdout(io.StringIO()) as out: + GOV.upsert_drift_issue("OpenCoven/.github", "", ["some drift"], dry_run=True) + + self.assertEqual(len(_graphql_calls(mocked)), 0, "tokenless dry-run must never call GraphQL") + self.assertEqual(len(mocked.call_args_list), 2, "must use the REST scan, paginated") + self.assertIn(GOV.MANAGED_ISSUE_MARKER, out.getvalue()) + self.assertIn("some drift", out.getvalue()) + + def test_tokenless_dry_run_with_existing_managed_issue_and_no_drift_reports_close_intent(self) -> None: + issues = [_rest_filler(1), _rest_issue(2), _rest_filler(3)] + + def fake_github_request(url, *, token=None, method="GET", payload=None): + self.assertIsNone(token, "tokenless dry-run must never attach a token") + if "page=1" in url: + return issues + self.fail(f"unexpected request: {method} {url}") + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request) as mocked: + with redirect_stdout(io.StringIO()) as out: + GOV.upsert_drift_issue("OpenCoven/.github", "", [], dry_run=True) + + self.assertEqual(len(_graphql_calls(mocked)), 0, "tokenless dry-run must never call GraphQL") + self.assertIn("would close clean drift issue #2", out.getvalue()) + + def test_tokenless_dry_run_ignores_pull_requests_and_untrusted_marker_holders(self) -> None: + issues = [_rest_filler(1), _rest_issue(2, is_pull_request=True)] + + def fake_github_request(url, *, token=None, method="GET", payload=None): + return issues if "page=1" in url else self.fail(f"unexpected request: {url}") + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request): + with redirect_stdout(io.StringIO()) as out: + GOV.upsert_drift_issue("OpenCoven/.github", "", ["some drift"], dry_run=True) + + # The marker-bearing item is a pull request, so it must be excluded + # from consideration entirely: no managed issue is found, and the + # printed body is the freshly composed drift body (not a PATCH-style + # reuse of the pull request), because dry-run never distinguishes + # create/update — it always prints the computed body. + self.assertIn(GOV.MANAGED_ISSUE_MARKER, out.getvalue()) + + def test_readonly_scan_never_used_for_authenticated_dry_run(self) -> None: + # An authenticated dry-run still has a token available and must + # prefer the consistent GraphQL scan over the REST fallback. + def fake_github_request(url, *, token=None, method="GET", payload=None): + self.assertEqual(url, governance_cli.GRAPHQL_ENDPOINT, f"unexpected request: {method} {url}") + return _gql_page([], has_next=False, end_cursor=None) + + with patch.object(governance_cli, "github_request", side_effect=fake_github_request) as mocked: + with redirect_stdout(io.StringIO()): + GOV.upsert_drift_issue("OpenCoven/.github", "token", ["some drift"], dry_run=True) + + self.assertEqual(len(_graphql_calls(mocked)), 1) + + def test_non_dry_run_without_token_rejected_by_command_reconcile(self) -> None: + def fail_if_called(*_args, **_kwargs): + self.fail("command_reconcile must return before any network call when token is missing and --dry-run is not set") + + args = argparse.Namespace(org="OpenCoven", repository="OpenCoven/.github", dry_run=False) + with patch.dict(os.environ, {}, clear=True): + with patch.object(governance_cli, "fetch_public_repositories", side_effect=fail_if_called): + with patch.object(governance_cli, "upsert_drift_issue", side_effect=fail_if_called): + result = GOV.command_reconcile(None, args) + + self.assertEqual(result, 2) + + def test_non_dry_run_without_token_rejected_by_upsert_drift_issue_defense_in_depth(self) -> None: + # Even if upsert_drift_issue were reached directly with an empty + # token and dry_run=False (bypassing the command_reconcile gate), + # it must refuse to mutate rather than falling back to a scan that + # cannot support a safe PATCH/POST. + def fail_if_called(*_args, **_kwargs): + self.fail("must fail closed before any network call") + + with patch.object(governance_cli, "github_request", side_effect=fail_if_called): + with self.assertRaises(RuntimeError): + GOV.upsert_drift_issue("OpenCoven/.github", "", ["some drift"], dry_run=False) + + +if __name__ == "__main__": + unittest.main() From f22b4b4121c31a5faed29f321f21c3fea9612e83 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 6 Sep 2026 03:53:49 -0500 Subject: [PATCH 2/2] Harden governance review gates Signed-off-by: Val Alexander Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/reusable-agent-readiness.yml | 13 +- .../workflows/reusable-evidence-packet.yml | 7 +- ...brand-ui-consolidation-audit-2026-08-31.md | 30 +-- docs/rollout.md | 18 +- ...9-03-organization-governance-plane-v1.json | 16 +- generated/portfolio.md | 68 ++--- governance/repositories.json | 2 +- schemas/repository-registry.schema.json | 13 + scripts/governance_cli.py | 5 + scripts/governance_core.py | 82 ++++++ scripts/governance_model.py | 74 +++++- tests/test_governance.py | 241 +++++++++++++++++- 12 files changed, 488 insertions(+), 81 deletions(-) diff --git a/.github/workflows/reusable-agent-readiness.yml b/.github/workflows/reusable-agent-readiness.yml index a32a351..356a7d2 100644 --- a/.github/workflows/reusable-agent-readiness.yml +++ b/.github/workflows/reusable-agent-readiness.yml @@ -12,11 +12,6 @@ on: required: false default: agent/manifest.json type: string - run_repository_check: - description: Run the target repository's scripts/agent-check fast after manifest validation - required: false - default: true - type: boolean permissions: contents: read @@ -266,6 +261,8 @@ jobs: if kind == "mapping" and event_value is not None and event_value.startswith(("!", ">", "|")): raise SystemExit("caller workflow on: unsupported event value scalar syntax") events.append(event_name(clean_scalar(event), "caller workflow on")) + if "pull_request_target" in events: + raise SystemExit("caller workflow pull_request_target is forbidden") return "workflow_call" in events def contains_yaml_anchor_or_alias(text): @@ -485,6 +482,10 @@ jobs: if with_value is not None: raise SystemExit(f"caller job {job_id}: inline with mappings are unsupported") with_inputs = mapping_values(with_block, f"caller job {job_id}: with") + path_input_name = os.environ["PATH_INPUT_NAME"] + unsupported_inputs = sorted(set(with_inputs) - {"policy_ref", path_input_name}) + if unsupported_inputs: + raise SystemExit(f"caller job {job_id}: unsupported with input {unsupported_inputs[0]}") literal_policy_ref = with_inputs.get("policy_ref") if literal_policy_ref is None: raise SystemExit(f"caller job {job_id}: with.policy_ref is required") @@ -492,7 +493,6 @@ jobs: raise SystemExit(f"caller job {job_id}: expressions are unsupported in with.policy_ref") if literal_policy_ref != policy_ref or literal_policy_ref != uses_ref: raise SystemExit(f"caller job {job_id}: with.policy_ref must match runtime policy_ref and reusable workflow uses ref") - path_input_name = os.environ["PATH_INPUT_NAME"] literal_path = with_inputs.get(path_input_name) or os.environ.get("DEFAULT_RUNTIME_PATH") or None if literal_path is None: raise SystemExit(f"caller job {job_id}: with.{path_input_name} is required") @@ -539,6 +539,5 @@ jobs: --caller-repository "$CALLER_REPOSITORY" \ "$MANIFEST_PATH" - name: Run repository-native fast check - if: ${{ inputs.run_repository_check }} working-directory: target run: ./scripts/agent-check fast diff --git a/.github/workflows/reusable-evidence-packet.yml b/.github/workflows/reusable-evidence-packet.yml index b7a2f54..20c2840 100644 --- a/.github/workflows/reusable-evidence-packet.yml +++ b/.github/workflows/reusable-evidence-packet.yml @@ -258,6 +258,8 @@ jobs: if kind == "mapping" and event_value is not None and event_value.startswith(("!", ">", "|")): raise SystemExit("caller workflow on: unsupported event value scalar syntax") events.append(event_name(clean_scalar(event), "caller workflow on")) + if "pull_request_target" in events: + raise SystemExit("caller workflow pull_request_target is forbidden") return "workflow_call" in events def contains_yaml_anchor_or_alias(text): @@ -477,6 +479,10 @@ jobs: if with_value is not None: raise SystemExit(f"caller job {job_id}: inline with mappings are unsupported") with_inputs = mapping_values(with_block, f"caller job {job_id}: with") + path_input_name = os.environ["PATH_INPUT_NAME"] + unsupported_inputs = sorted(set(with_inputs) - {"policy_ref", path_input_name}) + if unsupported_inputs: + raise SystemExit(f"caller job {job_id}: unsupported with input {unsupported_inputs[0]}") literal_policy_ref = with_inputs.get("policy_ref") if literal_policy_ref is None: raise SystemExit(f"caller job {job_id}: with.policy_ref is required") @@ -484,7 +490,6 @@ jobs: raise SystemExit(f"caller job {job_id}: expressions are unsupported in with.policy_ref") if literal_policy_ref != policy_ref or literal_policy_ref != uses_ref: raise SystemExit(f"caller job {job_id}: with.policy_ref must match runtime policy_ref and reusable workflow uses ref") - path_input_name = os.environ["PATH_INPUT_NAME"] literal_path = with_inputs.get(path_input_name) or os.environ.get("DEFAULT_RUNTIME_PATH") or None if literal_path is None: raise SystemExit(f"caller job {job_id}: with.{path_input_name} is required") diff --git a/docs/brand-ui-consolidation-audit-2026-08-31.md b/docs/brand-ui-consolidation-audit-2026-08-31.md index 478b21b..1982298 100644 --- a/docs/brand-ui-consolidation-audit-2026-08-31.md +++ b/docs/brand-ui-consolidation-audit-2026-08-31.md @@ -2,7 +2,7 @@ **Status:** Decision and execution plan **Snapshot:** 2026-08-31 -**Scope:** `OpenCoven/brand`, `OpenCoven/ui`, `OpenCoven/coven-design-system`, private `OpenCoven/coven-design`, and the principal consumers in `coven-landing`, `coven-cave`, `coven-docs`, `psyche-build`, and related product repositories. +**Scope:** `OpenCoven/brand`, `OpenCoven/ui`, `OpenCoven/coven-design-system`, an access-controlled design-evaluation overlay, and the principal consumers in `coven-landing`, `coven-cave`, `coven-docs`, `psyche-build`, and related product repositories. ## Executive decision @@ -15,7 +15,7 @@ OpenCoven should converge on **two permanent public upstream repositories** and The remaining repositories should be handled as follows: - **Extract and retire `OpenCoven/coven-design-system`.** Preserve its useful component inventory, coverage harness, and selected CSS patterns, but do not preserve its claim to canonical token ownership. -- **Rename private `OpenCoven/coven-design` to `OpenCoven/coven-evals` or `OpenCoven/design-evals`.** It is clean-room/double-blind evaluation tooling, not a brand or component repository. +- **Retain design-evaluation work behind `private-overlay: design-evaluation`.** It is not a public brand or component authority, and its backing repository identity and implementation details do not belong in this public record. - **Use `coven-landing` as the reference consumer contract.** Its immutable Brand/UI pins and verification script are the best current model for every downstream surface. - **Do not merge `OpenCoven/brand#4` unchanged.** It introduces a second canonical `1.0.0` web profile with a different schema, token namespace, typography, palette values, and asset pointer after `web/profile.css`, `web/profile.json`, and `web/assets/mark.svg` were already ratified on `main`. @@ -30,7 +30,7 @@ The problem is no longer a lack of design work. OpenCoven has several substantia - `coven-design-system` still calls its `--cv-*` tokens and CSS primitives canonical. - Cave maintains a large production token and theme system and describes its code as authoritative for shipped behavior. - An open Brand PR proposes another canonical web profile under new filenames and a new token namespace. -- Private `coven-design` is unrelated evaluation tooling but occupies the most obvious design-system repository name. +- An access-controlled evaluation workstream has no public Brand or UI authority. A familiar protocol cannot credibly insist on one authority for identity and mutation while its public identity system has multiple competing sources of truth. The same portfolio discipline applies here: **one canonical owner per semantic domain, explicit projections, immutable consumer pins, and no silent parallel ledgers.** @@ -41,7 +41,7 @@ A familiar protocol cannot credibly insist on one authority for identity and mut | [`OpenCoven/brand`](https://github.com/OpenCoven/brand) | `4127be6d402089d15953e76988bbeab2db37df54` on `main` | Stable Brand web profile v1 landed; old authoritative docs and legacy-looking assets remain beside it; `main` is unprotected. | | [`OpenCoven/ui`](https://github.com/OpenCoven/ui) | `fa61e9449cf2f5973532d45486b8fafbdf616425` on `main` | Strong monorepo, package, registry, contracts, accessibility and visual receipts; release/adoption and branch governance are incomplete. | | [`OpenCoven/coven-design-system`](https://github.com/OpenCoven/coven-design-system) | `6032f9f407982379e39ed1a40eec7a2e8b24e5c6` | Useful 62-family/224-class CSS inventory, but stale, private-package distribution, no GitHub release, no workflow surfaced, and duplicate canonical claim. | -| [`OpenCoven/coven-design`](https://github.com/OpenCoven/coven-design) | private, current `main` | Double-blind evaluation/capture tooling; naming collides with design-system and brand work. | +| `private-overlay: design-evaluation` | Access controlled; backing inventory omitted | Private evaluation responsibility acknowledged without publishing repository identity, members, or implementation details. | | [`OpenCoven/coven-landing`](https://github.com/OpenCoven/coven-landing) | current `main` | Pins Brand and UI revisions, vendors exact artifacts, and verifies canonical bytes and interaction hooks. | | [`OpenCoven/coven-cave`](https://github.com/OpenCoven/coven-cave) | current `main` | Mature production design language and drift tests, but its UI-boundary documentation is already stale relative to the live UI repository. | | Open pull requests | [OpenCoven/brand#4](https://github.com/OpenCoven/brand/pull/4); [OpenCoven/ui#2](https://github.com/OpenCoven/ui/pull/2), [OpenCoven/ui#3](https://github.com/OpenCoven/ui/pull/3), [OpenCoven/ui#6](https://github.com/OpenCoven/ui/pull/6) | [OpenCoven/brand#4](https://github.com/OpenCoven/brand/pull/4) duplicates a ratified profile; [OpenCoven/ui#2](https://github.com/OpenCoven/ui/pull/2) and [OpenCoven/ui#3](https://github.com/OpenCoven/ui/pull/3) are stacked against moving bases; [OpenCoven/ui#6](https://github.com/OpenCoven/ui/pull/6) is a draft named `noop`. | @@ -173,9 +173,11 @@ That work should be mined, not discarded blindly. --- -### 4. Private `OpenCoven/coven-design`: useful repository, wrong name and boundary +### 4. `private-overlay: design-evaluation`: private responsibility boundary -The repository describes clean-room design specs and double-blind evaluation tooling. Its recent work centers on blinding envelopes, arm/session tokens, locked reveal, tamper-evident capture, and evaluation receipts. +An access-controlled workstream handles design-evaluation responsibilities. +Its backing repository, members, candidate names, and implementation details +are intentionally omitted from this public audit. That is potentially valuable infrastructure, but it is not: @@ -186,15 +188,11 @@ That is potentially valuable infrastructure, but it is not: #### Immediate verdict -**Retain private, rename, and narrow.** +**Retain behind an opaque private overlay and keep the boundary narrow.** -Preferred names: - -1. `OpenCoven/coven-evals` -2. `OpenCoven/design-evals` -3. `OpenCoven/evidence-lab` - -Move generic visual-review orchestration into `.github` or UI only when it becomes a reusable organization control-plane capability. Keep blinded evaluation data and evaluator-specific semantics in the renamed private repository. +Move a capability into `.github` or UI only when a separately reviewed public +contract makes it a reusable organization capability. Private evaluation data +and evaluator-specific semantics remain in the access-controlled overlay. --- @@ -268,7 +266,7 @@ Retirement path: └─archive─▶ delete after observation gate Namespace repair: - private coven-design ──rename──▶ coven-evals + private-overlay: design-evaluation ──retain opaque access-controlled boundary ``` ## Canonical ownership contract @@ -803,7 +801,7 @@ Add root `AGENTS.md` to Brand and UI containing: ### Phase 4 — retirement and stabilization, days 30–45 - [ ] Tombstone and archive `coven-design-system`. -- [ ] Rename private `coven-design`. +- [ ] Verify the opaque design-evaluation overlay has a non-conflicting private name without publishing it here. - [ ] Monitor for 30 days before deletion. - [ ] Publish Brand kit v1.1 and UI v0.2/1.0 according to the chosen maturity policy. - [ ] Remove obsolete compatibility entrypoints after all pins update. diff --git a/docs/rollout.md b/docs/rollout.md index 7c4d02c..e12684c 100644 --- a/docs/rollout.md +++ b/docs/rollout.md @@ -16,12 +16,18 @@ Exit: clean local fast gate and green PR CI. ## Days 0–30 -1. Review and merge the governance-plane PR. -2. Apply and evidence the `.github/main` ruleset and organization/Actions baseline from issue #6. -3. Pilot repository-local `agent/manifest.json` and the reusable readiness workflow in at least two canonical repositories at an immutable `.github` commit. -4. Reconcile the live public inventory and correct default-branch/archive/manifest drift. -5. Convert current portfolio recommendations into scoped repository-local migration issues. -6. Add backup reviewers for the highest-risk R4 repositories or explicitly track the bus-factor exception. +1. Establish an eligible independent reviewer and backup CODEOWNER through the + separately authorized issue #6 administration path. +2. Apply and evidence the `.github/main` ruleset and organization/Actions + baseline from issue #6 without a routine administrator bypass. +3. Prove that direct pushes are rejected and that the governance-plane PR can + satisfy the required review, CODEOWNER, conversation-resolution, and + `Governance CI / validate` gates. +4. Review and merge the governance-plane PR through that protected path. +5. Pilot repository-local `agent/manifest.json` and the reusable readiness workflow in at least two canonical repositories at an immutable `.github` commit. +6. Reconcile the live public inventory and correct default-branch/archive/manifest drift. +7. Convert current portfolio recommendations into scoped repository-local migration issues. +8. Add backup reviewers for the highest-risk R4 repositories or explicitly track the bus-factor exception. Exit: diff --git a/evidence/2026-09-03-organization-governance-plane-v1.json b/evidence/2026-09-03-organization-governance-plane-v1.json index dfe99c7..8409147 100644 --- a/evidence/2026-09-03-organization-governance-plane-v1.json +++ b/evidence/2026-09-03-organization-governance-plane-v1.json @@ -44,12 +44,12 @@ { "kind": "repository", "reference": "OpenCoven/.github", - "revision": "c8b4ad3f9f9794db0fa79f338c2ce688ce6d4106" + "revision": "535177155710425b8f9e5ad546245c77ace35c20" }, { "kind": "github-public-inventory", "reference": "OpenCoven public repositories", - "revision": "observed-2026-09-03:30-public-repositories" + "revision": "observed-2026-09-05:30-visible-plus-1-unresolved-lifecycle-record" } ], "files": [ @@ -72,8 +72,8 @@ { "command": "./scripts/agent-check fast", "result": "pass", - "environment": "Linux; Python 3.13.5; dependency-free deterministic path", - "evidence": "Governance validation, generated-view check, and 13 unit tests passed." + "environment": "macOS; Python 3.14.6; dependency-free deterministic path", + "evidence": "Governance validation, generated-view check, and 97 tests passed on the local remediation candidate derived from PR head 535177155710425b8f9e5ad546245c77ace35c20." }, { "command": "bash -n scripts/agent-bootstrap scripts/agent-check", @@ -95,9 +95,9 @@ }, { "command": "GitHub Actions pull-request execution", - "result": "skipped", + "result": "pass", "environment": "GitHub-hosted runner", - "evidence": "Pending creation of the review branch and pull request." + "evidence": "Governance CI run 34015758673 and validate job 101439107465 passed on PR head 535177155710425b8f9e5ad546245c77ace35c20." }, { "command": "Organization ruleset and permission effectiveness test", @@ -109,9 +109,9 @@ "migration": "Additive and reversible. Existing policy, provenance, patent, profile, and audit files remain untouched. Public portfolio records begin as reviewed coordination metadata; repository-local implementation remains authoritative.", "rollback": "Close the pull request and delete the feature branch before merge. After merge, revert the governance-plane commit while preserving issues and settings evidence; no repository lifecycle or visibility mutations are coupled to this change.", "uncertainty": [ - "Remote GitHub Actions behavior is pending pull-request execution.", + "Exact remediation-candidate GitHub Actions behavior remains pending publication; run 34015758673 passed on the prior PR head 535177155710425b8f9e5ad546245c77ace35c20.", "Organization rulesets, Actions policy, app scopes, environments, MFA, and break-glass controls are not applied by repository content and remain open in issue #6.", - "The public registry reflects the connected GitHub inventory observed on 2026-09-03 and requires scheduled reconciliation after merge.", + "The public inventory observed on 2026-09-05 exposed 30 repositories while the registry retains 31 records. The unavailable opencoven-beta-august-hackathon-2026 record remains unresolved pending owner or administrator evidence; public absence does not prove deletion, transfer, rename, or visibility change.", "Private repository aggregation is intentionally omitted from this public repository and requires an access-controlled federated overlay." ] } diff --git a/generated/portfolio.md b/generated/portfolio.md index 59d4ab9..830e138 100644 --- a/generated/portfolio.md +++ b/generated/portfolio.md @@ -2,7 +2,7 @@ > Generated by `python3 scripts/governance.py generate`. Do not edit by hand. -Registry digest: `9f561389ddf7560206742657fed62c6e3ef9ff3772fce4e3a65f43f217d20905` +Registry digest: `26eb688efd5c2ec008988b78c84fc9d7c7656c9368c189c61492516279f5d4c4` ## Summary @@ -17,38 +17,38 @@ Registry digest: `9f561389ddf7560206742657fed62c6e3ef9ff3772fce4e3a65f43f217d209 ## Repositories -| Repository | Lifecycle | Canonicality | Risk | Owner | Disposition | Manifest | -|---|---|---|---:|---|---|---| -| .github | active | canonical | R4 | @BunsDev | retain | enforced | -| brand | active | canonical | R2 | @BunsDev | retain | planned | -| cast-codes | archived | historical | R1 | @BunsDev | retain-archive | exempt | -| chat | active | supporting | R3 | @BunsDev | retain | planned | -| claude-code-cast | deprecated | none | R2 | @BunsDev | consolidate-then-retire | planned | -| coven | active | canonical | R4 | @BunsDev | retain | planned | -| coven-cave | active | canonical | R3 | @BunsDev | retain | planned | -| coven-code | active | canonical | R3 | @BunsDev | retain | planned | -| coven-codeflow | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | -| coven-design-system | deprecated | none | R2 | @BunsDev | consolidate-then-retire | planned | -| coven-docs | active | canonical | R1 | @BunsDev | retain | planned | -| coven-github-webhook | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | -| coven-landing | active | supporting | R1 | @BunsDev | retain | planned | -| coven-memory | active | canonical | R3 | @BunsDev | retain | planned | -| coven-pocket | maintenance | none | R3 | @BunsDev | evaluate-consolidation-or-private-incubation | planned | -| coven-reach | maintenance | none | R3 | @BunsDev | private-incubation-or-retire | planned | -| coven-runtimes | active | canonical | R4 | @BunsDev | retain | planned | -| coven-scout | maintenance | none | R3 | @BunsDev | private-incubation-or-retire | planned | -| coven-threads | active | canonical | R4 | @BunsDev | retain | planned | -| demo-workspace | incubating | supporting | R1 | @BunsDev | graduate-or-retire | planned | -| desktop-use | maintenance | none | R3 | @BunsDev | evaluate-consolidation-or-private-incubation | planned | -| familiar-contract | active | canonical | R4 | @BunsDev | retain | planned | -| homebrew-tap | active | supporting | R4 | @BunsDev | retain | planned | -| open-fable | incubating | none | R2 | @BunsDev | private-incubation-or-retire | planned | -| open-meow-sdk | archived | historical | R1 | @BunsDev | retain-archive | exempt | -| opencoven-beta-august-hackathon-2026 | maintenance | historical | R1 | @BunsDev | archive-after-retirement-gate | planned | -| opencoven-chat-api | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | -| psyche | active | canonical | R4 | @BunsDev | retain | planned | -| psyche-build | active | canonical | R3 | @BunsDev | retain | planned | -| sdk | active | canonical | R3 | @BunsDev | retain | planned | -| ui | active | specimen | R1 | @BunsDev | retain | planned | +| Repository | Lifecycle | Canonicality | Risk | Owner | Disposition | Manifest | Observation | +|---|---|---|---:|---|---|---|---| +| .github | active | canonical | R4 | @BunsDev | retain | enforced | verified-public | +| brand | active | canonical | R2 | @BunsDev | retain | planned | verified-public | +| cast-codes | archived | historical | R1 | @BunsDev | retain-archive | exempt | verified-public | +| chat | active | supporting | R3 | @BunsDev | retain | planned | verified-public | +| claude-code-cast | deprecated | none | R2 | @BunsDev | consolidate-then-retire | planned | verified-public | +| coven | active | canonical | R4 | @BunsDev | retain | planned | verified-public | +| coven-cave | active | canonical | R3 | @BunsDev | retain | planned | verified-public | +| coven-code | active | canonical | R3 | @BunsDev | retain | planned | verified-public | +| coven-codeflow | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | verified-public | +| coven-design-system | deprecated | none | R2 | @BunsDev | consolidate-then-retire | planned | verified-public | +| coven-docs | active | canonical | R1 | @BunsDev | retain | planned | verified-public | +| coven-github-webhook | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | verified-public | +| coven-landing | active | supporting | R1 | @BunsDev | retain | planned | verified-public | +| coven-memory | active | canonical | R3 | @BunsDev | retain | planned | verified-public | +| coven-pocket | maintenance | none | R3 | @BunsDev | evaluate-consolidation-or-private-incubation | planned | verified-public | +| coven-reach | maintenance | none | R3 | @BunsDev | private-incubation-or-retire | planned | verified-public | +| coven-runtimes | active | canonical | R4 | @BunsDev | retain | planned | verified-public | +| coven-scout | maintenance | none | R3 | @BunsDev | private-incubation-or-retire | planned | verified-public | +| coven-threads | active | canonical | R4 | @BunsDev | retain | planned | verified-public | +| demo-workspace | incubating | supporting | R1 | @BunsDev | graduate-or-retire | planned | verified-public | +| desktop-use | maintenance | none | R3 | @BunsDev | evaluate-consolidation-or-private-incubation | planned | verified-public | +| familiar-contract | active | canonical | R4 | @BunsDev | retain | planned | verified-public | +| homebrew-tap | active | supporting | R4 | @BunsDev | retain | planned | verified-public | +| open-fable | incubating | none | R2 | @BunsDev | private-incubation-or-retire | planned | verified-public | +| open-meow-sdk | archived | historical | R1 | @BunsDev | retain-archive | exempt | verified-public | +| opencoven-beta-august-hackathon-2026 | maintenance | historical | R1 | @BunsDev | archive-after-retirement-gate | planned | unavailable-needs-owner-evidence | +| opencoven-chat-api | deprecated | none | R3 | @BunsDev | consolidate-then-retire | planned | verified-public | +| psyche | active | canonical | R4 | @BunsDev | retain | planned | verified-public | +| psyche-build | active | canonical | R3 | @BunsDev | retain | planned | verified-public | +| sdk | active | canonical | R3 | @BunsDev | retain | planned | verified-public | +| ui | active | specimen | R1 | @BunsDev | retain | planned | verified-public | This is a public-only view. Private repository inventory is intentionally federated and omitted. diff --git a/governance/repositories.json b/governance/repositories.json index bf3abe7..8739a9f 100644 --- a/governance/repositories.json +++ b/governance/repositories.json @@ -1 +1 @@ -{"$schema":"../schemas/repository-registry.schema.json","schema_version":"opencoven.repository-registry/v1","organization":"OpenCoven","scope":{"visibility":"public-only","observed_as_of":"2026-09-05","expected_public_repository_count":31,"private_inventory":"federated-and-intentionally-omitted","private_overlay_policy":"policies/public-private-data.md"},"defaults":{"visibility":"public","observed":{"default_branch":"main","archived":false},"owner":"BunsDev","technical_dri":"BunsDev","ownership_status":"bootstrap-single-owner","canonical_domains":[],"does_not_own":[],"disposition":{"state":"retain","review_by":"2026-12-02"},"agent_manifest":{"status":"planned","path":"agent/manifest.json"},"security_support":"limited"},"repositories":[{"name":".github","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Public organization governance, portfolio coordination, shared policy, and generated public views.","canonical_domains":["organization.governance","organization.portfolio","organization.shared-policy"],"does_not_own":["familiar.identity","protected.authorization","project-orchestration","runtime.persistence","runtime.execution","release.approval","publication.approval","automation.lifecycle"],"agent_manifest":{"status":"enforced","path":"agent/manifest.json"},"security_support":"active"},{"name":"brand","lifecycle":"active","canonicality":"canonical","risk_class":"R2","purpose":"Canonical OpenCoven visual identity, voice, and public-web profile.","canonical_domains":["brand.identity","brand.voice","brand.public-web-profile"],"does_not_own":["product.production-ui"],"security_support":"active"},{"name":"cast-codes","lifecycle":"archived","canonicality":"historical","risk_class":"R1","purpose":"Historical product and release lineage retained with successor context.","observed":{"default_branch":"main","archived":true},"disposition":{"state":"retain-archive","review_by":"2027-09-03"},"agent_manifest":{"status":"exempt","path":"agent/manifest.json"},"security_support":"historical"},{"name":"chat","lifecycle":"active","canonicality":"supporting","risk_class":"R3","purpose":"Production-oriented read-only desktop client for bounded Cave SDK chat access.","does_not_own":["product.production-ui","runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"claude-code-cast","lifecycle":"deprecated","canonicality":"none","risk_class":"R2","purpose":"Legacy coding-event adapter and redaction fixtures.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-code"}},"security_support":"unsupported"},{"name":"coven","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Daemon authority, persistence, sessions, runtime execution, authoritative state transitions, and the automation lifecycle: definitions and revisions, schedule planning and occurrences, runs and attempts, automation leases and fences, retries and recovery, events and changefeed, artifacts, and receipts.","canonical_domains":["runtime.daemon-authority","runtime.persistence","runtime.sessions","runtime.execution","automation.definitions","automation.revisions","automation.schedule-planning","automation.occurrences","automation.runs","automation.attempts","automation.leases","automation.fences","automation.retries","automation.recovery","automation.events","automation.changefeed","automation.artifacts","automation.receipts"],"does_not_own":["familiar.identity","protected.authorization","project-orchestration"],"security_support":"active"},{"name":"coven-cave","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Primary human oversight product and production UI behavior.","canonical_domains":["product.human-oversight","product.production-ui"],"does_not_own":["runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"coven-code","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Terminal coding execution and headless coding contracts.","canonical_domains":["execution.terminal-coding"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"coven-codeflow","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Overlapping coding cockpit and execution experiment.","observed":{"default_branch":"master","archived":false},"disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-code"}},"security_support":"unsupported"},{"name":"coven-design-system","lifecycle":"deprecated","canonicality":"none","risk_class":"R2","purpose":"Overlapping design-system experiment whose useful work should be extracted without retaining a canonical claim.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"portfolio","names":["brand","ui","coven-cave"]}},"security_support":"unsupported"},{"name":"coven-docs","lifecycle":"active","canonicality":"canonical","risk_class":"R1","purpose":"Public documentation and generated compatibility presentation.","canonical_domains":["knowledge.public-documentation","knowledge.compatibility-presentation"],"does_not_own":["protocol.normative-artifacts"],"security_support":"active"},{"name":"coven-github-webhook","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Noncanonical GitHub delivery bundle pending consolidation into the private delivery overlay.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"github-delivery"}}},{"name":"coven-landing","lifecycle":"active","canonicality":"supporting","risk_class":"R1","purpose":"Public marketing and product landing surface.","does_not_own":["brand.public-web-profile","knowledge.public-documentation"],"security_support":"active"},{"name":"coven-memory","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Read-only memory client and projection; never a second memory authority.","canonical_domains":["memory.read-only-client","memory.projection"],"does_not_own":["memory.authoritative-state","runtime.persistence"],"security_support":"active"},{"name":"coven-pocket","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Mobile experiment pending a distinct boundary or consolidation into Cave mobile.","disposition":{"state":"evaluate-consolidation-or-private-incubation","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-cave"}}},{"name":"coven-reach","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Filesystem/network capability experiment pending redesign around explicit leases and authorization.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"leased-capability-execution"}}},{"name":"coven-runtimes","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Runtime capability descriptors, registry, and conformance.","canonical_domains":["runtime.capability-descriptors","runtime.conformance"],"does_not_own":["runtime.execution","runtime.persistence"],"security_support":"active"},{"name":"coven-scout","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Overlapping filesystem/web capability experiment pending selection of one hardened successor.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"leased-capability-execution"}}},{"name":"coven-threads","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Protected-surface authorization and proposal-versus-commit decisions.","canonical_domains":["authority.protected-surface","authority.proposal-commit-decisions"],"does_not_own":["runtime.persistence","project-orchestration"],"security_support":"active"},{"name":"demo-workspace","lifecycle":"incubating","canonicality":"supporting","risk_class":"R1","purpose":"Minimal public demonstration and deterministic fixture workspace.","disposition":{"state":"graduate-or-retire","review_by":"2026-10-03"},"security_support":"unsupported"},{"name":"desktop-use","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Desktop capability experiment pending product-boundary review.","disposition":{"state":"evaluate-consolidation-or-private-incubation","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-cave"}}},{"name":"familiar-contract","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Governed portable familiar identity, principal binding, and revision semantics.","canonical_domains":["identity.familiar-contract","identity.principal-binding","identity.revision-semantics"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"homebrew-tap","lifecycle":"active","canonicality":"supporting","risk_class":"R4","purpose":"Canonical Homebrew distribution channel for released OpenCoven artifacts.","does_not_own":["release.approval","artifact.provenance-source"],"security_support":"active"},{"name":"open-fable","lifecycle":"incubating","canonicality":"none","risk_class":"R2","purpose":"Speculative research experiment without an approved public canonical boundary.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-10-03","destination":{"kind":"private-overlay","id":"research-incubation"}},"security_support":"unsupported"},{"name":"open-meow-sdk","lifecycle":"archived","canonicality":"historical","risk_class":"R1","purpose":"Archived predecessor SDK retained for provenance.","observed":{"default_branch":"main","archived":true},"disposition":{"state":"retain-archive","review_by":"2027-09-03","destination":{"kind":"repository","name":"sdk"}},"agent_manifest":{"status":"exempt","path":"agent/manifest.json"},"security_support":"historical"},{"name":"opencoven-beta-august-hackathon-2026","lifecycle":"maintenance","canonicality":"historical","risk_class":"R1","purpose":"Time-bounded hackathon record pending archival verification.","disposition":{"state":"archive-after-retirement-gate","review_by":"2026-10-03"},"security_support":"historical"},{"name":"opencoven-chat-api","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Misnamed documentation retrieval service pending accurate consolidation.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"portfolio","names":["coven-docs","coven-cave"]}}},{"name":"psyche","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Project-scoped multi-agent orchestration semantics: tasks, lanes, leases, approvals, receipts, retries, and recovery for coding-agent orchestration.","canonical_domains":["project-orchestration.tasks","project-orchestration.lanes","project-orchestration.leases","project-orchestration.approvals","project-orchestration.receipts","project-orchestration.retries","project-orchestration.recovery"],"does_not_own":["familiar.identity","runtime.persistence","product.production-ui","automation.lifecycle"],"security_support":"active"},{"name":"psyche-build","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Multi-lane coding cockpit consuming Psyche canonically.","canonical_domains":["product.coding-cockpit"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"sdk","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Constrained public clients and canonical language bindings.","canonical_domains":["access.public-sdk","access.canonical-bindings"],"does_not_own":["runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"ui","lifecycle":"active","canonicality":"specimen","risk_class":"R1","purpose":"Specimen and component laboratory; not production UI authority.","does_not_own":["product.production-ui","brand.public-web-profile"]}]} +{"$schema":"../schemas/repository-registry.schema.json","schema_version":"opencoven.repository-registry/v1","organization":"OpenCoven","scope":{"visibility":"public-only","observed_as_of":"2026-09-05","expected_public_repository_count":31,"private_inventory":"federated-and-intentionally-omitted","private_overlay_policy":"policies/public-private-data.md"},"defaults":{"visibility":"public","observed":{"default_branch":"main","archived":false},"owner":"BunsDev","technical_dri":"BunsDev","ownership_status":"bootstrap-single-owner","canonical_domains":[],"does_not_own":[],"disposition":{"state":"retain","review_by":"2026-12-02"},"agent_manifest":{"status":"planned","path":"agent/manifest.json"},"security_support":"limited","observation_status":"verified-public"},"repositories":[{"name":".github","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Public organization governance, portfolio coordination, shared policy, and generated public views.","canonical_domains":["organization.governance","organization.portfolio","organization.shared-policy"],"does_not_own":["familiar.identity","protected.authorization","project-orchestration","runtime.persistence","runtime.execution","release.approval","publication.approval","automation.lifecycle"],"agent_manifest":{"status":"enforced","path":"agent/manifest.json"},"security_support":"active"},{"name":"brand","lifecycle":"active","canonicality":"canonical","risk_class":"R2","purpose":"Canonical OpenCoven visual identity, voice, and public-web profile.","canonical_domains":["brand.identity","brand.voice","brand.public-web-profile"],"does_not_own":["product.production-ui"],"security_support":"active"},{"name":"cast-codes","lifecycle":"archived","canonicality":"historical","risk_class":"R1","purpose":"Historical product and release lineage retained with successor context.","observed":{"default_branch":"main","archived":true},"disposition":{"state":"retain-archive","review_by":"2027-09-03"},"agent_manifest":{"status":"exempt","path":"agent/manifest.json"},"security_support":"historical"},{"name":"chat","lifecycle":"active","canonicality":"supporting","risk_class":"R3","purpose":"Production-oriented read-only desktop client for bounded Cave SDK chat access.","does_not_own":["product.production-ui","runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"claude-code-cast","lifecycle":"deprecated","canonicality":"none","risk_class":"R2","purpose":"Legacy coding-event adapter and redaction fixtures.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-code"}},"security_support":"unsupported"},{"name":"coven","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Daemon authority, persistence, sessions, runtime execution, authoritative state transitions, and the automation lifecycle: definitions and revisions, schedule planning and occurrences, runs and attempts, automation leases and fences, retries and recovery, events and changefeed, artifacts, and receipts.","canonical_domains":["runtime.daemon-authority","runtime.persistence","runtime.sessions","runtime.execution","automation.definitions","automation.revisions","automation.schedule-planning","automation.occurrences","automation.runs","automation.attempts","automation.leases","automation.fences","automation.retries","automation.recovery","automation.events","automation.changefeed","automation.artifacts","automation.receipts"],"does_not_own":["familiar.identity","protected.authorization","project-orchestration"],"security_support":"active"},{"name":"coven-cave","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Primary human oversight product and production UI behavior.","canonical_domains":["product.human-oversight","product.production-ui"],"does_not_own":["runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"coven-code","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Terminal coding execution and headless coding contracts.","canonical_domains":["execution.terminal-coding"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"coven-codeflow","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Overlapping coding cockpit and execution experiment.","observed":{"default_branch":"master","archived":false},"disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-code"}},"security_support":"unsupported"},{"name":"coven-design-system","lifecycle":"deprecated","canonicality":"none","risk_class":"R2","purpose":"Overlapping design-system experiment whose useful work should be extracted without retaining a canonical claim.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"portfolio","names":["brand","ui","coven-cave"]}},"security_support":"unsupported"},{"name":"coven-docs","lifecycle":"active","canonicality":"canonical","risk_class":"R1","purpose":"Public documentation and generated compatibility presentation.","canonical_domains":["knowledge.public-documentation","knowledge.compatibility-presentation"],"does_not_own":["protocol.normative-artifacts"],"security_support":"active"},{"name":"coven-github-webhook","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Noncanonical GitHub delivery bundle pending consolidation into the private delivery overlay.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"github-delivery"}}},{"name":"coven-landing","lifecycle":"active","canonicality":"supporting","risk_class":"R1","purpose":"Public marketing and product landing surface.","does_not_own":["brand.public-web-profile","knowledge.public-documentation"],"security_support":"active"},{"name":"coven-memory","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Read-only memory client and projection; never a second memory authority.","canonical_domains":["memory.read-only-client","memory.projection"],"does_not_own":["memory.authoritative-state","runtime.persistence"],"security_support":"active"},{"name":"coven-pocket","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Mobile experiment pending a distinct boundary or consolidation into Cave mobile.","disposition":{"state":"evaluate-consolidation-or-private-incubation","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-cave"}}},{"name":"coven-reach","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Filesystem/network capability experiment pending redesign around explicit leases and authorization.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"leased-capability-execution"}}},{"name":"coven-runtimes","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Runtime capability descriptors, registry, and conformance.","canonical_domains":["runtime.capability-descriptors","runtime.conformance"],"does_not_own":["runtime.execution","runtime.persistence"],"security_support":"active"},{"name":"coven-scout","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Overlapping filesystem/web capability experiment pending selection of one hardened successor.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-12-02","destination":{"kind":"private-overlay","id":"leased-capability-execution"}}},{"name":"coven-threads","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Protected-surface authorization and proposal-versus-commit decisions.","canonical_domains":["authority.protected-surface","authority.proposal-commit-decisions"],"does_not_own":["runtime.persistence","project-orchestration"],"security_support":"active"},{"name":"demo-workspace","lifecycle":"incubating","canonicality":"supporting","risk_class":"R1","purpose":"Minimal public demonstration and deterministic fixture workspace.","disposition":{"state":"graduate-or-retire","review_by":"2026-10-03"},"security_support":"unsupported"},{"name":"desktop-use","lifecycle":"maintenance","canonicality":"none","risk_class":"R3","purpose":"Desktop capability experiment pending product-boundary review.","disposition":{"state":"evaluate-consolidation-or-private-incubation","review_by":"2026-12-02","destination":{"kind":"repository","name":"coven-cave"}}},{"name":"familiar-contract","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Governed portable familiar identity, principal binding, and revision semantics.","canonical_domains":["identity.familiar-contract","identity.principal-binding","identity.revision-semantics"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"homebrew-tap","lifecycle":"active","canonicality":"supporting","risk_class":"R4","purpose":"Canonical Homebrew distribution channel for released OpenCoven artifacts.","does_not_own":["release.approval","artifact.provenance-source"],"security_support":"active"},{"name":"open-fable","lifecycle":"incubating","canonicality":"none","risk_class":"R2","purpose":"Speculative research experiment without an approved public canonical boundary.","disposition":{"state":"private-incubation-or-retire","review_by":"2026-10-03","destination":{"kind":"private-overlay","id":"research-incubation"}},"security_support":"unsupported"},{"name":"open-meow-sdk","lifecycle":"archived","canonicality":"historical","risk_class":"R1","purpose":"Archived predecessor SDK retained for provenance.","observed":{"default_branch":"main","archived":true},"disposition":{"state":"retain-archive","review_by":"2027-09-03","destination":{"kind":"repository","name":"sdk"}},"agent_manifest":{"status":"exempt","path":"agent/manifest.json"},"security_support":"historical"},{"name":"opencoven-beta-august-hackathon-2026","lifecycle":"maintenance","canonicality":"historical","risk_class":"R1","purpose":"Time-bounded hackathon record pending archival verification.","disposition":{"state":"archive-after-retirement-gate","review_by":"2026-10-03"},"security_support":"historical","observation_status":"unavailable-needs-owner-evidence"},{"name":"opencoven-chat-api","lifecycle":"deprecated","canonicality":"none","risk_class":"R3","purpose":"Misnamed documentation retrieval service pending accurate consolidation.","disposition":{"state":"consolidate-then-retire","review_by":"2026-12-02","destination":{"kind":"portfolio","names":["coven-docs","coven-cave"]}}},{"name":"psyche","lifecycle":"active","canonicality":"canonical","risk_class":"R4","purpose":"Project-scoped multi-agent orchestration semantics: tasks, lanes, leases, approvals, receipts, retries, and recovery for coding-agent orchestration.","canonical_domains":["project-orchestration.tasks","project-orchestration.lanes","project-orchestration.leases","project-orchestration.approvals","project-orchestration.receipts","project-orchestration.retries","project-orchestration.recovery"],"does_not_own":["familiar.identity","runtime.persistence","product.production-ui","automation.lifecycle"],"security_support":"active"},{"name":"psyche-build","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Multi-lane coding cockpit consuming Psyche canonically.","canonical_domains":["product.coding-cockpit"],"does_not_own":["project-orchestration","runtime.persistence"],"security_support":"active"},{"name":"sdk","lifecycle":"active","canonicality":"canonical","risk_class":"R3","purpose":"Constrained public clients and canonical language bindings.","canonical_domains":["access.public-sdk","access.canonical-bindings"],"does_not_own":["runtime.persistence","protected.authorization"],"security_support":"active"},{"name":"ui","lifecycle":"active","canonicality":"specimen","risk_class":"R1","purpose":"Specimen and component laboratory; not production UI authority.","does_not_own":["product.production-ui","brand.public-web-profile"]}]} diff --git a/schemas/repository-registry.schema.json b/schemas/repository-registry.schema.json index 92befc9..b9c843f 100644 --- a/schemas/repository-registry.schema.json +++ b/schemas/repository-registry.schema.json @@ -54,6 +54,7 @@ "required": [ "visibility", "observed", + "observation_status", "owner", "technical_dri", "ownership_status", @@ -80,6 +81,12 @@ }, "additionalProperties": false }, + "observation_status": { + "enum": [ + "verified-public", + "unavailable-needs-owner-evidence" + ] + }, "owner": { "type": "string", "minLength": 1 @@ -223,6 +230,12 @@ }, "additionalProperties": false }, + "observation_status": { + "enum": [ + "verified-public", + "unavailable-needs-owner-evidence" + ] + }, "owner": { "type": "string", "minLength": 1 diff --git a/scripts/governance_cli.py b/scripts/governance_cli.py index 3cd54d9..c43739e 100644 --- a/scripts/governance_cli.py +++ b/scripts/governance_cli.py @@ -65,6 +65,11 @@ def reconcile_public_inventory(governance: Governance, live: list[dict[str, Any] for name in sorted(set(declared) & set(actual), key=str.lower): expected = declared[name]["observed"] observed = actual[name] + if declared[name]["observation_status"] != "verified-public": + drift.append( + f"`{name}` observation status mismatch: " + f"registry={declared[name]['observation_status']} live=verified-public" + ) if bool(observed.get("archived")) != expected.get("archived"): drift.append(f"`{name}` archived mismatch: registry={expected.get('archived')} live={bool(observed.get('archived'))}") if observed.get("default_branch") != expected.get("default_branch"): diff --git a/scripts/governance_core.py b/scripts/governance_core.py index 27998dd..31739e5 100644 --- a/scripts/governance_core.py +++ b/scripts/governance_core.py @@ -22,6 +22,11 @@ r")$" ) ACTION_USE = re.compile(r"^\s*-?\s*uses:\s*([^\s#]+)", re.MULTILINE) +DOCKER_DIGEST_USE = re.compile(r"^docker://[^@\s]+@sha256:[0-9a-fA-F]{64}$") +FLOW_STYLE_ACTION_USE = re.compile( + r"^(?:\s*steps\s*:\s*\[[^\n]*|\s*-?\s*\{)[^}\n]*\buses\s*:", + re.MULTILINE, +) CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") REUSABLE_WORKFLOWS = { "reusable-agent-readiness.yml", @@ -60,6 +65,78 @@ def load_json(path: Path) -> Any: raise ValueError(f"{path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}: {exc}") from exc +def validate_json_schema(instance: Any, schema: dict[str, Any], *, label: str) -> list[str]: + errors: list[str] = [] + + def visit(value: Any, rule: dict[str, Any], path: str) -> None: + expected_type = rule.get("type") + if expected_type: + matches = { + "object": isinstance(value, dict), + "array": isinstance(value, list), + "string": isinstance(value, str), + "boolean": isinstance(value, bool), + "integer": isinstance(value, int) and not isinstance(value, bool), + "number": isinstance(value, (int, float)) and not isinstance(value, bool), + "null": value is None, + }.get(expected_type) + if matches is None: + errors.append(f"{path}: schema uses unsupported type {expected_type!r}") + return + if not matches: + errors.append(f"{path}: schema expected {expected_type}, got {type(value).__name__}") + return + + if "const" in rule and value != rule["const"]: + errors.append(f"{path}: schema expected constant {rule['const']!r}, got {value!r}") + if "enum" in rule and value not in rule["enum"]: + errors.append(f"{path}: schema value {value!r} is not in {rule['enum']!r}") + + if isinstance(value, str): + if len(value) < rule.get("minLength", 0): + errors.append(f"{path}: schema string is shorter than minLength {rule['minLength']}") + pattern = rule.get("pattern") + if pattern is not None and re.search(pattern, value) is None: + errors.append(f"{path}: schema string does not match pattern {pattern!r}") + if rule.get("format") == "date": + try: + date.fromisoformat(value) + except ValueError: + errors.append(f"{path}: schema expected ISO date, got {value!r}") + + if isinstance(value, (int, float)) and not isinstance(value, bool) and "minimum" in rule: + if value < rule["minimum"]: + errors.append(f"{path}: schema value is below minimum {rule['minimum']}") + + if isinstance(value, list): + if len(value) < rule.get("minItems", 0): + errors.append(f"{path}: schema array has fewer than {rule['minItems']} items") + if rule.get("uniqueItems"): + encoded = [json.dumps(item, sort_keys=True, separators=(",", ":")) for item in value] + if len(encoded) != len(set(encoded)): + errors.append(f"{path}: schema array items must be unique") + item_rule = rule.get("items") + if isinstance(item_rule, dict): + for index, item in enumerate(value): + visit(item, item_rule, f"{path}[{index}]") + + if isinstance(value, dict): + properties = rule.get("properties", {}) + for required in rule.get("required", []): + if required not in value: + errors.append(f"{path}.{required}: schema required property is missing") + if rule.get("additionalProperties") is False: + for key in value: + if key not in properties: + errors.append(f"{path}.{key}: schema additional property is not allowed") + for key, child_rule in properties.items(): + if key in value and isinstance(child_rule, dict): + visit(value[key], child_rule, f"{path}.{key}") + + visit(instance, schema, label) + return errors + + def _prefix_parts(prefix: str) -> tuple[str, ...]: parsed = PurePosixPath(prefix) return tuple(part for part in parsed.parts if part not in {"", "."}) @@ -342,6 +419,8 @@ def _workflow_declares_workflow_call(lines: list[str]) -> bool: if kind == "mapping" and event_value is not None and event_value.startswith(("!", ">", "|")): raise ValueError("caller workflow on: unsupported event value scalar syntax") events.append(_event_name(_clean_scalar(event), label="caller workflow on")) + if "pull_request_target" in events: + raise ValueError("caller workflow pull_request_target is forbidden") return "workflow_call" in events @@ -614,6 +693,9 @@ def validate_reusable_invocation( except ValueError as exc: errors.append(str(exc)) with_inputs = {} + allowed_inputs = {"policy_ref", path_input_name} + for input_name in sorted(set(with_inputs) - allowed_inputs): + errors.append(f"caller job {job_id}: unsupported with input {input_name}") literal_policy_ref = with_inputs.get("policy_ref") if literal_policy_ref is None: errors.append(f"caller job {job_id}: with.policy_ref is required") diff --git a/scripts/governance_model.py b/scripts/governance_model.py index a0d66cc..7899e69 100644 --- a/scripts/governance_model.py +++ b/scripts/governance_model.py @@ -9,11 +9,11 @@ from typing import Any from governance_core import ( - ACTION_USE, ROOT, SECRET_PATTERNS, SHA40, TEXT_SUFFIXES, + ACTION_USE, DOCKER_DIGEST_USE, FLOW_STYLE_ACTION_USE, ROOT, SECRET_PATTERNS, SHA40, TEXT_SUFFIXES, expanded_repositories, load_json, markdown, sha256_text, resolve_trusted_target_file, validate_exception_data, validate_initiative_data, - validate_manifest_data, validate_registry_data, + validate_json_schema, validate_manifest_data, validate_registry_data, ) @dataclass @@ -47,16 +47,37 @@ def validate(self) -> list[str]: if not self.path(rel).exists(): errors.append(f"missing required path: {rel}") + parsed_json: dict[Path, Any] = {} # Parse every JSON file and reject duplicate keys. for path in sorted(self.root.rglob("*.json")): + if ".git" in path.relative_to(self.root).parts: + continue try: - load_json(path) + parsed_json[path] = load_json(path) except ValueError as exc: errors.append(str(exc)) if errors: return errors + for path, data in parsed_json.items(): + if "schemas" in path.relative_to(self.root).parts or not isinstance(data, dict): + continue + schema_ref = data.get("$schema") + if not isinstance(schema_ref, str): + errors.append(f"{path.relative_to(self.root)}: schema reference is required") + continue + schema_path = (path.parent / schema_ref).resolve() + if not schema_path.is_relative_to(self.root.resolve()) or schema_path.parent != self.path("schemas").resolve(): + errors.append(f"{path.relative_to(self.root)}: schema reference must resolve inside schemas/") + continue + try: + schema = load_json(schema_path) + except ValueError as exc: + errors.append(str(exc)) + continue + errors.extend(validate_json_schema(data, schema, label=str(path.relative_to(self.root)))) + registry = self.registry() errors.extend(validate_registry_data(registry)) registry_map = {item["name"]: item for item in expanded_repositories(registry)} @@ -180,8 +201,14 @@ def validate_workflows(self) -> list[str]: errors.append(f"{rel}: top-level permissions block required") if "pull_request_target:" in text: errors.append(f"{rel}: pull_request_target is forbidden") + if FLOW_STYLE_ACTION_USE.search(text): + errors.append(f"{rel}: flow-style action mappings are unsupported") for action in ACTION_USE.findall(text): - if action.startswith("./") or action.startswith("docker://"): + if action.startswith("./"): + continue + if action.startswith("docker://"): + if not DOCKER_DIGEST_USE.fullmatch(action): + errors.append(f"{rel}: Docker action must use an immutable digest: {action}") continue if "@" not in action: errors.append(f"{rel}: action without immutable ref: {action}") @@ -197,8 +224,18 @@ def validate_workflows(self) -> list[str]: or re.search(r"(?m)^on:\s*{[^{}\n]*,\s*pull_request\s*:", text) ): permission_section = self._top_level_block(text, "permissions") - if re.search(r"(?m)^\s+[A-Za-z-]+:\s*write\s*$", permission_section): + if self._permissions_request_write(permission_section): errors.append(f"{rel}: pull_request workflow may not request write permission") + for match in re.finditer( + r"(?m)^(?P +)permissions:\s*(?P[^#\n]*?)(?:\s+#.*)?$", + text, + ): + permission_value = match.group("value").strip() + permission_section = self._indented_block(text, match.end(), len(match.group("indent"))) + if self._permissions_request_write(permission_value) or self._permissions_request_write( + permission_section + ): + errors.append(f"{rel}: pull_request workflow may not request job-level write permission") return errors @staticmethod @@ -216,6 +253,23 @@ def _top_level_block(text: str, key: str) -> str: result.append(line) return "\n".join(result) + @staticmethod + def _indented_block(text: str, start: int, indent: int) -> str: + result: list[str] = [] + for line in text[start:].splitlines(): + if line.strip() and len(line) - len(line.lstrip()) <= indent: + break + result.append(line) + return "\n".join(result) + + @staticmethod + def _permissions_request_write(text: str) -> bool: + if re.search(r"(?m)^\s*[A-Za-z-]+:\s*['\"]?write['\"]?\s*(?:#.*)?$", text): + return True + if re.search(r"(?:^|[{,])\s*[A-Za-z-]+\s*:\s*['\"]?write['\"]?\s*(?:[,}]|$)", text): + return True + return text.strip() == "write-all" + def scan_secrets(self) -> list[str]: errors: list[str] = [] ignored = {"generated/portfolio.md"} # generated content still derives from validated public input @@ -257,9 +311,15 @@ def generated_content(self) -> dict[str, str]: ] for state in ("active", "incubating", "maintenance", "deprecated", "archived", "tombstone"): portfolio.append(f"| {state} | {counts[state]} |") - portfolio += ["", "## Repositories", "", "| Repository | Lifecycle | Canonicality | Risk | Owner | Disposition | Manifest |", "|---|---|---|---:|---|---|---|"] + portfolio += [ + "", + "## Repositories", + "", + "| Repository | Lifecycle | Canonicality | Risk | Owner | Disposition | Manifest | Observation |", + "|---|---|---|---:|---|---|---|---|", + ] for item in registry: - portfolio.append("| {name} | {lifecycle} | {canonicality} | {risk_class} | @{owner} | {state} | {manifest} |".format( + portfolio.append("| {name} | {lifecycle} | {canonicality} | {risk_class} | @{owner} | {state} | {manifest} | {observation_status} |".format( **item, state=markdown(item["disposition"]["state"]), manifest=item["agent_manifest"]["status"], diff --git a/tests/test_governance.py b/tests/test_governance.py index 786d653..8368487 100644 --- a/tests/test_governance.py +++ b/tests/test_governance.py @@ -7,6 +7,7 @@ import json import os import re +import shutil import subprocess import tempfile import textwrap @@ -64,6 +65,14 @@ def test_expired_review_is_detected(self) -> None: errors = GOV.validate_registry_data(data, today=date(2026, 9, 3)) self.assertTrue(any("review expired" in error for error in errors), errors) + def test_unavailable_public_record_is_explicitly_unresolved(self) -> None: + target = next( + item + for item in self.registry["repositories"] + if item["name"] == "opencoven-beta-august-hackathon-2026" + ) + self.assertEqual("unavailable-needs-owner-evidence", target.get("observation_status")) + class OwnershipBoundaryTests(unittest.TestCase): """Regression coverage for the Psyche/Coven ownership boundary. @@ -235,6 +244,78 @@ def test_pinned_action_is_accepted(self) -> None: ) self.assertEqual([], GOV.Governance(root).validate_workflows()) + def test_mutable_docker_action_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\non: push\npermissions:\n contents: read\njobs:\n test:\n" + " runs-on: ubuntu-latest\n steps:\n" + " - uses: docker://attacker/image:latest\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertTrue(any("Docker action must use an immutable digest" in error for error in errors), errors) + + def test_flow_style_action_use_is_rejected_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\non: push\npermissions:\n contents: read\njobs:\n test:\n" + " runs-on: ubuntu-latest\n steps:\n" + " - { uses: attacker/action@main }\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertTrue(any("flow-style action mappings are unsupported" in error for error in errors), errors) + + def test_flow_sequence_action_use_is_rejected_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\non: push\npermissions:\n contents: read\njobs:\n test:\n" + " runs-on: ubuntu-latest\n" + " steps: [{ uses: attacker/action@main }]\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertTrue(any("flow-style action mappings are unsupported" in error for error in errors), errors) + + def test_pull_request_job_level_write_permission_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\non: pull_request\npermissions:\n contents: read\njobs:\n mutate:\n" + " permissions:\n contents: write\n id-token: write\n" + " runs-on: ubuntu-latest\n steps:\n" + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertTrue(any("job-level write permission" in error for error in errors), errors) + + def test_pull_request_job_level_permission_variants_are_rejected(self) -> None: + variants = ( + " permissions: { contents: write }\n", + " permissions: # job grant\n contents: write\n", + ) + for permissions in variants: + with self.subTest(permissions=permissions), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflow = root / ".github/workflows/test.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text( + "name: test\non: pull_request\npermissions:\n contents: read\njobs:\n mutate:\n" + f"{permissions}" + " runs-on: ubuntu-latest\n steps:\n" + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\n" + ) + errors = GOV.Governance(root).validate_workflows() + self.assertTrue(any("job-level write permission" in error for error in errors), errors) + def test_pull_request_inline_mapping_is_treated_as_pr_trigger(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -500,6 +581,9 @@ def _extract_inline_preflight_script(workflow_name: str) -> str: UNSUPPORTED_EVENT_ON_BLOCKS = { + "pull-request-target-scalar": "on: pull_request_target\n", + "pull-request-target-mapping": "on:\n pull_request_target:\n", + "pull-request-target-flow-sequence": "on: [push, pull_request_target]\n", "quoted-top-level-on-key": '"on":\n pull_request:\n', "folded-scalar-strip": "on: >-\n workflow_call\n", "folded-scalar-keep": "on: >+\n workflow_call\n", @@ -729,6 +813,14 @@ def _cli_result(self, *, reusable: str = "reusable-agent-readiness.yml", def test_positive_direct_caller_with_exact_sha_is_accepted(self) -> None: self.assertEqual([], self._errors()) + def test_readiness_caller_cannot_disable_repository_check(self) -> None: + self.workflow.write_text( + _caller_workflow(extra_job=" run_repository_check: false\n"), + encoding="utf-8", + ) + errors = self._errors() + self.assertTrue(any("run_repository_check" in error for error in errors), errors) + def test_supported_literal_event_forms_are_accepted(self) -> None: cases = dict(SUPPORTED_EVENT_ON_BLOCKS) cases.update({ @@ -1020,6 +1112,27 @@ def test_inline_preflight_accepts_supported_callers_for_both_reusables(self) -> ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_inline_readiness_preflight_rejects_disabled_repository_check(self) -> None: + self.workflow.write_text( + _caller_workflow(extra_job=" run_repository_check: false\n"), + encoding="utf-8", + ) + result = self._run_preflight( + workflow_name="reusable-agent-readiness.yml", + policy_ref=SHA_A, + reusable="reusable-agent-readiness.yml", + path_input_name="manifest_path", + runtime_path="agent/manifest.json", + default_runtime_path="agent/manifest.json", + ) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("run_repository_check", result.stderr) + + def test_readiness_workflow_always_runs_repository_check(self) -> None: + text = (ROOT / ".github/workflows/reusable-agent-readiness.yml").read_text(encoding="utf-8") + self.assertNotIn("run_repository_check:", text) + self.assertNotIn("if: ${{ inputs.run_repository_check }}", text) + def test_inline_preflight_rejects_nested_event_variants_for_both_reusables(self) -> None: nested_events = ( 'on:\n "workflow_call":\n', @@ -1194,6 +1307,99 @@ def test_generation_is_deterministic(self) -> None: second = governance.generated_content() self.assertEqual(first, second) + def test_portfolio_exposes_unresolved_observation_status(self) -> None: + portfolio = GOV.Governance(ROOT).generated_content()["generated/portfolio.md"] + self.assertIn("| Observation |", portfolio) + self.assertRegex( + portfolio, + r"(?m)^\| opencoven-beta-august-hackathon-2026 .*" + r"\| unavailable-needs-owner-evidence \|$", + ) + + +class PublishedSchemaTests(unittest.TestCase): + def _copy_repository(self, target: Path) -> None: + shutil.copytree( + ROOT, + target, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"), + ) + + def test_contract_index_missing_schema_fields_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._copy_repository(root) + path = root / "compatibility/contracts.json" + data = json.loads(path.read_text(encoding="utf-8")) + data.pop("schema_version") + data.pop("claim_rule") + data["contracts"][0].pop("status") + data["contracts"][0].pop("immutable_release_required") + _write_json(path, data) + + errors = GOV.Governance(root).validate() + + for field in ("schema_version", "claim_rule", "status", "immutable_release_required"): + self.assertTrue(any("schema" in error and field in error for error in errors), (field, errors)) + + def test_repository_internal_json_is_outside_governance_schema_scope(self) -> None: + errors = GOV.Governance(ROOT).validate() + self.assertFalse(any(".git/" in error and "schema reference" in error for error in errors), errors) + + def test_initiative_schema_enums_are_enforced(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._copy_repository(root) + path = root / "initiatives/organization-governance-plane-v1.json" + data = json.loads(path.read_text(encoding="utf-8")) + data["status"] = "almost-done" + data["priority"] = "urgent" + data["ownership_status"] = "someone-probably-owns-it" + _write_json(path, data) + + errors = GOV.Governance(root).validate() + + for field in ("status", "priority", "ownership_status"): + self.assertTrue(any("schema" in error and field in error for error in errors), (field, errors)) + + +class GovernancePolicyConsistencyTests(unittest.TestCase): + def test_public_documents_do_not_name_private_design_inventory(self) -> None: + private_name = "coven-" + "design" + prohibited = ( + f"private `OpenCoven/{private_name}`", + f"[`OpenCoven/{private_name}`](https://github.com/OpenCoven/{private_name})", + f"private {private_name}", + ) + matches: list[str] = [] + for path in sorted((ROOT / "docs").glob("*.md")): + text = path.read_text(encoding="utf-8") + for value in prohibited: + if value in text: + matches.append(f"{path.relative_to(ROOT)}: {value}") + self.assertEqual([], matches) + + def test_rollout_protects_main_before_ratifying_merge(self) -> None: + text = (ROOT / "docs/rollout.md").read_text(encoding="utf-8") + phrases = ( + "Establish an eligible independent reviewer", + "Apply and evidence the `.github/main` ruleset", + "Review and merge the governance-plane PR", + ) + for phrase in phrases: + self.assertIn(phrase, text) + reviewer, protection, merge = (text.index(phrase) for phrase in phrases) + self.assertLess(reviewer, protection) + self.assertLess(protection, merge) + + def test_evidence_packet_does_not_claim_pre_pr_state(self) -> None: + text = (ROOT / "evidence/2026-09-03-organization-governance-plane-v1.json").read_text(encoding="utf-8") + self.assertNotIn("13 unit tests", text) + self.assertNotIn("Pending creation of the review branch and pull request", text) + self.assertIn("97 tests", text) + self.assertIn("535177155710425b8f9e5ad546245c77ace35c20", text) + class PublicDriftTests(unittest.TestCase): def setUp(self) -> None: @@ -1211,7 +1417,21 @@ def setUp(self) -> None: ] def test_matching_public_inventory_has_no_drift(self) -> None: - self.assertEqual([], GOV.reconcile_public_inventory(self.governance, self.live)) + declared = self.governance.registry_map() + declared["opencoven-beta-august-hackathon-2026"]["observation_status"] = "verified-public" + with patch.object(self.governance, "registry_map", return_value=declared): + self.assertEqual([], GOV.reconcile_public_inventory(self.governance, self.live)) + + def test_visible_repository_marked_unavailable_is_reported(self) -> None: + errors = GOV.reconcile_public_inventory(self.governance, self.live) + self.assertTrue( + any( + "`opencoven-beta-august-hackathon-2026` observation status mismatch" in error + and "live=verified-public" in error + for error in errors + ), + errors, + ) def test_unregistered_public_repository_is_reported(self) -> None: live = self.live + [{ @@ -1225,6 +1445,25 @@ def test_unregistered_public_repository_is_reported(self) -> None: self.assertTrue(any("unregistered public repository" in error for error in errors), errors) +class GitHubRequestTests(unittest.TestCase): + def test_authenticated_request_uses_bearer_token(self) -> None: + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self) -> bytes: + return b"{}" + + with patch.object(governance_cli.urllib.request, "urlopen", return_value=Response()) as urlopen: + governance_cli.github_request("https://api.github.com/user", token="test-token") + + request = urlopen.call_args.args[0] + self.assertEqual("Bearer test-token", request.get_header("Authorization")) + + def _raw_node(number: int, *, title: str = GOV.MANAGED_ISSUE_TITLE, marker: str | None = GOV.MANAGED_ISSUE_MARKER, login: str = governance_cli.GRAPHQL_BOT_LOGIN, typename: str | None = governance_cli.GRAPHQL_BOT_TYPENAME, node_id: str | None = None) -> dict: