From 9737577f0b71cc9ef4d6b78cc607a8ed5e9cd145 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 21:08:15 +0200 Subject: [PATCH] Add repository grant v2 publish scope and adopt daily security flow ACTIVITY-WP-0046: grant v2 carries a publish scope (remote, branch, path prefixes inside allowed_paths, fast-forward only) in its canonical grant id; v1 stays local-only with unchanged ids. ACTIVITY-WP-0047: disabled daily-security-flow definition dispatching the the-custodian runner as an ops_run (CUST-WP-0079-T03). Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 296847@bnt-lap001 Assistant-Session: 4f997d46-1b9d-4131-9699-0c353a215155 --- .custodian-brief.md | 14 ++- WORK-RECORDS.md | 2 +- activity-definitions/daily-security-flow.md | 57 +++++++++ docs/ops-run-queue.md | 26 ++++ src/activity_core/glas_evidence.py | 10 +- src/activity_core/repository_grant.py | 117 ++++++++++++++---- tests/test_repository_grant.py | 51 ++++++++ ...-0046-repository-grant-v2-publish-scope.md | 62 ++++++++++ ...TY-WP-0047-daily-security-flow-adoption.md | 62 ++++++++++ 9 files changed, 372 insertions(+), 29 deletions(-) create mode 100644 activity-definitions/daily-security-flow.md create mode 100644 workplans/ACTIVITY-WP-0046-repository-grant-v2-publish-scope.md create mode 100644 workplans/ACTIVITY-WP-0047-daily-security-flow-adoption.md diff --git a/.custodian-brief.md b/.custodian-brief.md index c5f26e9..ee82692 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — activity-core **Domain:** infotech -**Last synced:** 2026-10-11 18:31 UTC +**Last synced:** 2026-10-13 18:57 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -19,8 +19,9 @@ Progress: 1/3 done | workplan_id: `12798750-3eee-5d81-a411-c3c4c5ce2d2c` **Open tasks:** - ! Adopt the declared resource set into railiance01 ArgoCD `af09865d` - *(wait: Adopted 2026-09-27 (manual sync); automated sync waits on RPF-WP-0048-T02: soak evidence plus founder confirmation of scoped identity/checks (D2).)* -- · Establish standing authority for bounded routine releases `8e2b4ed9` + *(wait: Automated sync after RPF-WP-0048-T02 soak; founder confirmation given by D2.)* +- ! Establish standing authority for bounded routine releases `8e2b4ed9` + *(wait: RPF-IN-0011: return dedicated release principal/custody owner task and native acceptance; D2 scope already accepted)* ### Owner-return pickup adapter for State Hub STATE-WP-0094-T03 Progress: 5/6 done | workplan_id: `e65f59d4-2272-557d-9071-734c88186d19` @@ -45,7 +46,12 @@ Progress: 5/8 done | workplan_id: `e1bfb5a4-e647-592d-883f-fe8728ad2f5f` - ! Agree the platform runner contract `a2d95c80` *(wait: Platform owner must accept the candidate request/receipt contract, execution authority checks, limits and credential scope before runtime wiring.)* - ! Refresh the existing backup consumer after the owner repairs the lane `020d19be` - *(wait: KV4 provider repair is delivered. Current exact-primary projection needs repo-manager historical-prefix acceptance (owner request 6bf8ca98-ada1-43ab-83e7-207d4ab195fe); separate approved CNPG hostPath/Forgejo GitOps bundle delivery, ESO/env refresh and native consumer receipt remain. Founder per-change go is required before live promotion.)* + *(wait: KV4 provider repair is delivered; repo-manager historical-prefix acceptance is delivered (receipt 39f2c067, 2026-10-06). Separate approved CNPG hostPath/Forgejo GitOps bundle delivery, ESO/env refresh and native consumer receipt remain. Founder per-change go is required before live promotion.)* + +## Inbox Hygiene + +**Stale unread:** 1 message(s) older than 3 day(s) — triage at session start. +**Missing thread_id:** 2 unread message(s) lack supersession chains. --- ## MCP Orientation (when available) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 0e73122..4219d9c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -243,7 +243,7 @@ | task | ACTIVITY-WP-0040-T03 | done | — | workplans/ACTIVITY-WP-0040-frontend-patterns-feedback.md | | task | ACTIVITY-WP-0041-T01 | done | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md | | task | ACTIVITY-WP-0041-T02 | wait | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md | -| task | ACTIVITY-WP-0041-T03 | todo | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md | +| task | ACTIVITY-WP-0041-T03 | wait | — | workplans/ACTIVITY-WP-0041-gitops-adoption.md | | task | ACTIVITY-WP-0042-T01 | done | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md | | task | ACTIVITY-WP-0042-T02 | wait | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md | | task | ACTIVITY-WP-0042-T03 | done | — | workplans/ACTIVITY-WP-0042-platform-backup-restore-evidence-scheduling.md | diff --git a/activity-definitions/daily-security-flow.md b/activity-definitions/daily-security-flow.md new file mode 100644 index 0000000..b45014e --- /dev/null +++ b/activity-definitions/daily-security-flow.md @@ -0,0 +1,57 @@ +--- +id: daily-security-flow +name: Daily Security Flow +enabled: false +owner: activity-core +governance: custodian +status: proposed +trigger: + type: cron + cron_expression: "30 7 * * *" + timezone: Europe/Berlin + misfire_policy: skip +--- + +# Daily Security Flow + +Adopted from the-custodian CUST-WP-0079-T03, tracked in ACTIVITY-WP-0047. +Activity Core owns the schedule; the-custodian owns the runner +(`scripts/security_flow_daily.py`), its report and its delivery checkpoints. + +Each fire emits one ops_run for a rein on the workstation profile, which runs a +fixed command in the-custodian checkout: + +```text +python3 scripts/security_flow_daily.py --apply +``` + +The runner resolves hub decisions already recorded as resolved in repo decision +logs (the sanctioned `POST /decisions/{id}/resolve`), writes a deterministic +JSON report, and messages coding agents only when READY/UNBLOCKED change and the +founder only when ATTENDED is nonempty. Its per-recipient checkpoint and run +lock make a repeated or overlapping fire safe. + +- Daily 07:30 Europe/Berlin; misfires are skipped, never replayed. +- No repository grant: the run reads repo files and changes nothing in any + repository. A rein must not commit or push for this run. +- Activity Core does not run the script itself (out of scope: general + execution); it dispatches and records the ops_run outcome. + +**Not yet enabled.** Activation (ACTIVITY-WP-0047-T02) flips `enabled: true` +once the claiming rein confirms it can run the command against workstation +checkouts. Rollback: `enabled: false`. + +```rule +id: run-daily-security-flow +condition: "True" +action: + task_template: Run the daily security flow + description: 'In the-custodian checkout run exactly: python3 scripts/security_flow_daily.py --apply. Report its exit code and the printed JSON report. Do not edit, commit or push any repository and do not send messages yourself; the script handles delivery.' + target_repo: the-custodian + priority: medium + labels: ["automated", "security-flow"] + harness_profile_ref: harness.agent-dev-local@1.1.1 + execution_refs: + correlation_id: CUST-WP-0079-T03 + assignment_ref: ACTIVITY-WP-0047-T01 +``` diff --git a/docs/ops-run-queue.md b/docs/ops-run-queue.md index 6b123c8..e7573e7 100644 --- a/docs/ops-run-queue.md +++ b/docs/ops-run-queue.md @@ -267,6 +267,32 @@ types, unknown fields, and publication are rejected at definition sync and again across the full emission batch. The admitted mapping is copied unchanged to the queue claim/read response and never merged into `execution_refs`. +Repository-grant v2 (ACTIVITY-WP-0046) is the publish-capable form. It keeps +the v1 fields, requires `publish: true`, and adds a mandatory `publish_scope`: + +```yaml +repository_grant: + version: "2" + allowed_paths: [checkpoints/] + commit_count: {min: 1, max: 1} + publish: true + publish_scope: + remote: origin # plain remote name, never a URL + branch: main # plain branch name, no refs/, no .. + path_prefixes: [checkpoints/] + fast_forward_only: true # the only accepted value; no force push +``` + +Every publish path prefix must lie inside `allowed_paths`. A v1 grant with +`publish: true` or a `publish_scope`, and a v2 grant without one, are +rejected. The grant id is the first 32 hex characters of SHA-256 over the +canonical JSON (sorted keys, compact separators) of `allowed_paths` (sorted), +`commit_count`, `publish`, `version` and, for v2, `publish_scope` with sorted +`path_prefixes`. v1 grant ids are unchanged. The rein echoes the grant id in +completion evidence and must refuse any push the grant does not cover; +normalized evidence may add `remote`, `branch`, `pushed_head`, +`fast_forward_only` and `published`. + `ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE=true` makes a missing profile ref an error both during file sync and emission. Deterministic report-only instructions are exempt because they create no execution request. The flag diff --git a/src/activity_core/glas_evidence.py b/src/activity_core/glas_evidence.py index 9bfec4b..f6ee182 100644 --- a/src/activity_core/glas_evidence.py +++ b/src/activity_core/glas_evidence.py @@ -198,6 +198,9 @@ def _normalise_repository_grant_evidence(raw: Any) -> dict[str, Any]: "acceptance_policy_id", "version", "allowed_paths_digest", + "remote", + "branch", + "pushed_head", ): value = raw.get(key) if isinstance(value, str): @@ -206,9 +209,10 @@ def _normalise_repository_grant_evidence(raw: Any) -> dict[str, Any]: value = raw.get(key) if isinstance(value, int) and not isinstance(value, bool) and value >= 0: result[key] = min(value, 1_000_000) - publish = raw.get("publish") - if isinstance(publish, bool): - result["publish"] = publish + for key in ("publish", "fast_forward_only", "published"): + value = raw.get(key) + if isinstance(value, bool): + result[key] = value return result diff --git a/src/activity_core/repository_grant.py b/src/activity_core/repository_grant.py index 5106e6b..2e1d807 100644 --- a/src/activity_core/repository_grant.py +++ b/src/activity_core/repository_grant.py @@ -1,5 +1,8 @@ """Typed repository mutation authority carried by Activity Core. +Version 1 is local-only. Version 2 adds an explicit publish scope (remote, +branch, path prefixes, fast-forward only) for reins that push their result. + The v1 schema mirrors the accepted rein-aharness repository-grant contract, but Activity Core owns admission and unchanged queue carriage at its boundary. It deliberately does not infer grants from prose, labels, profiles, or @@ -10,6 +13,7 @@ from __future__ import annotations import hashlib import json +import re from typing import Any, Literal from pydantic import ( @@ -42,15 +46,69 @@ class CommitCount(BaseModel): return self -class RepositoryGrant(BaseModel): - """Versioned, local-only repository authority envelope.""" +_REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$") + + +class PublishScope(BaseModel): + """Where a version-2 grant may push: one remote branch, fast-forward only.""" model_config = ConfigDict(extra="forbid", frozen=True) - version: Literal["1"] + remote: str + branch: str + path_prefixes: list[str] = Field(min_length=1, max_length=20) + fast_forward_only: Literal[True] + + @field_validator("remote") + @classmethod + def _validate_remote(cls, value: str) -> str: + if not _REMOTE_RE.fullmatch(value): + raise ValueError(f"remote must be a plain remote name: {value!r}") + return value + + @field_validator("branch") + @classmethod + def _validate_branch(cls, value: str) -> str: + if ( + not _BRANCH_RE.fullmatch(value) + or value == "HEAD" + or value.startswith("refs/") + or ".." in value + or "//" in value + or value.endswith(("/", ".", ".lock")) + ): + raise ValueError(f"branch must be a plain branch name: {value!r}") + return value + + @field_validator("path_prefixes") + @classmethod + def _validate_path_prefixes(cls, value: list[str]) -> list[str]: + if len(set(value)) != len(value): + raise ValueError("path_prefixes must not contain duplicates") + for pattern in value: + _validate_path_pattern(pattern) + return value + + def canonical(self) -> dict[str, Any]: + return { + "branch": self.branch, + "fast_forward_only": self.fast_forward_only, + "path_prefixes": sorted(self.path_prefixes), + "remote": self.remote, + } + + +class RepositoryGrant(BaseModel): + """Versioned repository authority envelope (v1 local-only, v2 publish).""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + version: Literal["1", "2"] allowed_paths: list[str] = Field(min_length=1, max_length=100) commit_count: CommitCount publish: StrictBool + publish_scope: PublishScope | None = None @field_validator("allowed_paths") @classmethod @@ -61,33 +119,44 @@ class RepositoryGrant(BaseModel): _validate_path_pattern(pattern) return value - @field_validator("publish") - @classmethod - def _local_only(cls, value: bool) -> bool: - if value: + @model_validator(mode="after") + def _publish_matches_version(self) -> RepositoryGrant: + if self.version == "1": + if self.publish or self.publish_scope is not None: + raise ValueError( + "version 1 does not grant publication; publish must be false" + ) + return self + if not self.publish or self.publish_scope is None: raise ValueError( - "version 1 does not grant publication; publish must be false" + "version 2 requires publish: true and a publish_scope" ) - return value + for prefix in self.publish_scope.path_prefixes: + if not any(_covers(allowed, prefix) for allowed in self.allowed_paths): + raise ValueError( + f"publish path prefix {prefix!r} is outside allowed_paths" + ) + return self def payload(self) -> dict[str, Any]: """Return the admitted field without semantic rewriting or inference.""" - return self.model_dump(mode="python") + return self.model_dump(mode="python", exclude_none=True) @property def grant_id(self) -> str: """Match rein-aharness's canonical, path-order-independent grant id.""" - return _digest( - { - "allowed_paths": sorted(self.allowed_paths), - "commit_count": { - "max": self.commit_count.max, - "min": self.commit_count.min, - }, - "publish": self.publish, - "version": self.version, - } - )[:32] + identity: dict[str, Any] = { + "allowed_paths": sorted(self.allowed_paths), + "commit_count": { + "max": self.commit_count.max, + "min": self.commit_count.min, + }, + "publish": self.publish, + "version": self.version, + } + if self.publish_scope is not None: + identity["publish_scope"] = self.publish_scope.canonical() + return _digest(identity)[:32] @property def acceptance_policy_id(self) -> str: @@ -128,6 +197,12 @@ def _validate_path_pattern(pattern: str) -> None: raise ValueError("path patterns cannot grant protected .git metadata") +def _covers(allowed: str, prefix: str) -> bool: + allowed_parts = allowed.rstrip("/").split("/") + prefix_parts = prefix.rstrip("/").split("/") + return prefix_parts[: len(allowed_parts)] == allowed_parts + + def _digest(value: dict[str, Any]) -> str: payload = json.dumps(value, sort_keys=True, separators=(",", ":")) return hashlib.sha256(payload.encode("utf-8")).hexdigest() diff --git a/tests/test_repository_grant.py b/tests/test_repository_grant.py index c10ba58..2a0a185 100644 --- a/tests/test_repository_grant.py +++ b/tests/test_repository_grant.py @@ -80,3 +80,54 @@ def test_ops_run_schema_has_separate_grant_and_close_identity_columns() -> None: assert columns["repository_grant"].nullable is True assert columns["close_intent_digest"].nullable is True + + +def _publish_grant(**scope_changes: object) -> dict: + scope = { + "remote": "origin", + "branch": "main", + "path_prefixes": ["checkpoints/"], + "fast_forward_only": True, + } + scope.update(scope_changes) + return _grant( + version="2", + allowed_paths=["checkpoints/"], + publish=True, + publish_scope=scope, + ) + + +def test_v2_grant_carries_publish_scope_in_payload_and_identity() -> None: + raw = _publish_grant() + grant = RepositoryGrant.model_validate(raw) + narrower = RepositoryGrant.model_validate( + _publish_grant(path_prefixes=["checkpoints/2026/"]) + ) + + assert grant.payload() == raw + assert grant.grant_id != narrower.grant_id + assert "publish_scope" not in RepositoryGrant.model_validate(_grant()).payload() + + +@pytest.mark.parametrize( + "value", + [ + _publish_grant(fast_forward_only=False), + _publish_grant(remote="https://example.org/repo.git"), + _publish_grant(branch="refs/heads/main"), + _publish_grant(branch="feature/../main"), + _publish_grant(branch="-force"), + _publish_grant(path_prefixes=["docs/"]), + _publish_grant(path_prefixes=["checkpoints-other/"]), + _publish_grant(path_prefixes=[]), + _publish_grant(force=True), + {**_publish_grant(), "publish": False}, + {**_publish_grant(), "version": "1"}, + _grant(version="2", publish=True), + _grant(publish_scope=_publish_grant()["publish_scope"]), + ], +) +def test_v2_grant_rejects_unsafe_or_inconsistent_publish_scope(value: dict) -> None: + with pytest.raises(RepositoryGrantError): + validate_repository_grant(value) diff --git a/workplans/ACTIVITY-WP-0046-repository-grant-v2-publish-scope.md b/workplans/ACTIVITY-WP-0046-repository-grant-v2-publish-scope.md new file mode 100644 index 0000000..d6f25d5 --- /dev/null +++ b/workplans/ACTIVITY-WP-0046-repository-grant-v2-publish-scope.md @@ -0,0 +1,62 @@ +--- +id: ACTIVITY-WP-0046 +type: workplan +title: "Repository grant v2: publish scope for governed pushes (REINAH-WP-0003-T04)" +domain: infotech +repo: activity-core +status: active +owner: claude-activity-core +topic_slug: activity-core +created: "2026-10-13" +updated: "2026-10-13" +flavor: implementation +related: [REINAH-WP-0003, ACTIVITY-WP-0038, ASSEMBLY-WP-0010] +--- + +Origin: rein-aharness (message 3eb4fa11, 2026-10-13) needs the repository grant +that Activity Core carries on each ops_run to scope publication, so that +assembly-checkpoint can leave legacy substring routing before +`AGENT_HARNESS_LEGACY_APPROACHES_UNTIL` (2026-12-31). Founder chose option 1a +on 2026-10-13: Activity Core adds the scope; no temporary tenant-declared grant. + +Grant v1 stays local-only and its grant ids do not change. v2 adds +`publish_scope` (remote, branch, path prefixes inside `allowed_paths`, +fast-forward only). Activity Core admits and carries the grant; rein-aharness +enforces it and refuses any push without it. + +## Admission and carriage + +```task +id: ACTIVITY-WP-0046-T01 +status: done +priority: high +``` + +Extend `repository_grant.py` with `PublishScope` and version "2"; include the +scope in the canonical grant id; reject inconsistent v1/v2 combinations and +prefixes outside `allowed_paths`; accept publish evidence keys in +`glas_evidence`. Document the contract in `docs/ops-run-queue.md`. Tests in +`tests/test_repository_grant.py`. + +## rein-aharness contract confirmation + +```task +id: ACTIVITY-WP-0046-T02 +status: wait +priority: high +``` + +rein-aharness confirms it computes the same v2 grant id and enforces the scope +(REINAH-WP-0003-T04). Wait: reply sent 2026-10-13. + +## assembly-checkpoint definitions adopt v2 + +```task +id: ACTIVITY-WP-0046-T03 +status: wait +priority: medium +``` + +assembly-economics declares a v2 grant on its checkpoint definitions and the +production image carrying this change is rolled out before 2026-12-15. Wait: +T02, then an image release through the normal GitOps path. diff --git a/workplans/ACTIVITY-WP-0047-daily-security-flow-adoption.md b/workplans/ACTIVITY-WP-0047-daily-security-flow-adoption.md new file mode 100644 index 0000000..e7ed407 --- /dev/null +++ b/workplans/ACTIVITY-WP-0047-daily-security-flow-adoption.md @@ -0,0 +1,62 @@ +--- +id: ACTIVITY-WP-0047 +type: workplan +title: "Adopt the daily security-flow schedule (CUST-WP-0079-T03)" +domain: infotech +repo: activity-core +status: active +owner: claude-activity-core +topic_slug: activity-core +created: "2026-10-13" +updated: "2026-10-13" +flavor: implementation +related: [CUST-WP-0079] +--- + +Origin: the-custodian handed over the daily security-flow lane (message +117e538e, case `case:cross-owner:CUST-WP-0079-T03:activity-core`). The workstation +user systemd bus is unavailable, so no host timer can run it. Founder accepted +adoption on 2026-10-13. + +Boundary: Activity Core owns the schedule and the ops_run record. the-custodian +owns `scripts/security_flow_daily.py`, its report and delivery checkpoints. A +rein runs the fixed command; Activity Core does not execute it (SCOPE: no +general execution). The run carries no repository grant. + +## Source definition + +```task +id: ACTIVITY-WP-0047-T01 +status: done +priority: high +``` + +`activity-definitions/daily-security-flow.md`: daily 07:30 Europe/Berlin, +misfire skip, one unconditional rule emitting an ops_run on +`harness.agent-dev-local@1.1.1` with the fixed command and correlation +`CUST-WP-0079-T03`. Committed with `enabled: false`. + +## Activation + +```task +id: ACTIVITY-WP-0047-T02 +status: wait +priority: high +``` + +Wait: the claiming rein (rein-aharness, workstation profile) confirms it can +run the command against workstation checkouts with hub access. Then flip +`enabled: true`, release through the normal image/GitOps path, admin-sync, and +run one manual trigger. + +## First unassisted fire + +```task +id: ACTIVITY-WP-0047-T03 +status: wait +priority: medium +``` + +Observe one scheduled fire end to end (ops_run completed, report written, no +duplicate deliveries) and tell the-custodian so CUST-WP-0079-T03 can close. +Wait: T02.