diff --git a/Cargo.lock b/Cargo.lock index f3601f98..ac11bdd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -592,6 +592,7 @@ dependencies = [ "auths-signature", "auths-stores", "axum", + "base64ct", "ed25519-dalek 2.2.0", "getrandom 0.3.4", "hex", diff --git a/architecture/dependency-graph.json b/architecture/dependency-graph.json index 9ea383e5..7346dac0 100644 --- a/architecture/dependency-graph.json +++ b/architecture/dependency-graph.json @@ -3206,6 +3206,20 @@ "default_features": true, "features": [] }, + { + "source": "auths-github-demo", + "source_layer": "demos", + "target": "base64ct", + "target_layer": null, + "scope": "external", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "alloc" + ] + }, { "source": "auths-github-demo", "source_layer": "demos", diff --git a/bindings/customer-journey-matrix-v1.json b/bindings/customer-journey-matrix-v1.json index 093cccba..683920cb 100644 --- a/bindings/customer-journey-matrix-v1.json +++ b/bindings/customer-journey-matrix-v1.json @@ -7,9 +7,9 @@ "enforcement": "baseline", "baseline": { "typescriptEntryPoints": 8, - "typescriptPublicSymbols": 191, + "typescriptPublicSymbols": 203, "pythonModules": 8, - "pythonPublicSymbols": 169, + "pythonPublicSymbols": 180, "maintainedTypescriptRecipes": 5, "maintainedPythonRecipes": 5 }, @@ -473,6 +473,54 @@ "consumerRequiresRust": false } }, + { + "id": "github-agent-one-issue-one-draft-pr", + "rust": "product/integrations/auths-github/src/service.rs", + "typescript": "bindings/typescript/test/unit/github-agent.test.js", + "python": "bindings/python/tests/test_github_agent.py", + "experience": { + "targetJourney": "delegate-one-bounded-github-issue-task-and-open-one-draft-pull-request", + "imports": [ + "service" + ], + "securityNouns": [ + "Authority", + "Action", + "Receipt" + ], + "domainConcepts": [ + "GitHub issue", + "candidate bundle" + ], + "setupDecisions": [ + "operator endpoint" + ], + "apiMechanics": [ + "discover boundary", + "delegate", + "inspect candidate", + "execute", + "reconcile", + "verify receipts" + ], + "requiredApplicationComponents": [ + "candidate bundle path", + "candidate revision", + "operator endpoint" + ], + "executableStatements": 20, + "applicationOrchestratedSecurityTransitions": 0, + "terminalOutcomes": [ + "completed", + "denied", + "indeterminate", + "replayed", + "reconciled", + "verified" + ], + "consumerRequiresRust": false + } + }, { "id": "installed-artifact-and-type-safety", "rust": "bindings/wasm/auths-proof-wasm/examples/generate-node-vectors.rs", diff --git a/bindings/python/README.md b/bindings/python/README.md index 29e17fe2..12f068a8 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -40,22 +40,28 @@ async with development.create_auths( ## Use a production runtime ```python -from auths import create_auths -from auths.profiles import github_issue_address - -auths = create_auths( - endpoint="https://auths.example.com", - identity=public_identity_bytes, - profile=github_issue_address(), -) -authority = await auths.create(authority_request_bytes) -if authority.kind != "authority": - raise RuntimeError(authority.code) -result = await auths.execute(authority, action_bytes) -if result.kind == "recoverable": - await auths.resume(result.reference) +from auths.service import GitHubAgentTask, create_github_agent_client + +auths = create_github_agent_client(endpoint="https://executor.example") +boundary = await auths.boundary() +task = await auths.delegate(GitHubAgentTask( + repository=boundary.repository, + issue_number=boundary.issue_number, + base_ref=boundary.base_ref, + base_revision=boundary.base_revision, + allowed_paths=boundary.allowed_paths, + protected_paths=boundary.protected_paths, + expires_in_seconds=boundary.maximum_expiry_seconds, + branch_budget=1, + draft_pull_request_budget=1, + agent_label="issue-agent", +)) ``` +Continue with a candidate bundle file using the maintained +[GitHub quickstart](../../docs/product/PRODUCTION_SDK_QUICKSTART.md). No +protocol bytes or GitHub credential enter application code. + ## Public modules One wheel provides the same progressive topology as TypeScript: @@ -65,6 +71,7 @@ One wheel provides the same progressive topology as TypeScript: | `auths` | create, delegate, execute, resume, product results and errors | | `auths.identity` | standalone identity decoding and authentication | | `auths.verify` | effect-free proof, decision and receipt verification | +| `auths.service` | generic five-verb operator-runtime transport | | `auths.profiles` | qualified MCP, OpenTofu, PostgreSQL and GitHub effect domains | | `auths.integrations` | maintained compositions and mechanism adapters | | `auths.framework` | proven signer and atomic-reservation contracts | @@ -94,11 +101,11 @@ idempotent and close owned signers and native sessions. ## Production boundary -The development composition uses ephemeral keys and in-memory state. The root -production client talks to an HTTPS operator runtime through a bounded, -Rust-owned binary contract. Provider credentials remain behind the profile -gateway and are acquired only after Auths has authorized and durably reserved -the exact action. +The development composition uses ephemeral keys and in-memory state. The +generic remote client and the profile-specific GitHub launch path live at +`auths.service`. Provider credentials remain behind +the Rust profile gateway and are acquired only after Auths has authorized and +durably claimed the exact action. Supported Python, platform, ABI and semantic-subject claims are recorded in `sdk-runtime-contract.json`. Public API and wheel-content snapshots reject diff --git a/bindings/python/api/public-api.txt b/bindings/python/api/public-api.txt index 97fa8764..b3e113f2 100644 --- a/bindings/python/api/public-api.txt +++ b/bindings/python/api/public-api.txt @@ -83,6 +83,16 @@ verify_receipt AuthsError AuthsErrorCode EffectState +GitHubAgentBoundary +GitHubAgentClient +GitHubAgentError +GitHubAgentOutcome +GitHubAgentSession +GitHubAgentTask +GitHubCandidateFile +GitHubCandidateInspection +GitHubDenialFixture +GitHubVerifiedReceipts NextCall ProductVerb RecommendedAction @@ -103,6 +113,7 @@ ServiceTransportRequest ServiceTransportResponse ServiceVerificationResult ServiceVerified +create_github_agent_client create_service_client import_authority diff --git a/bindings/python/external/full_workflow_consumer.py b/bindings/python/external/full_workflow_consumer.py index 4effefe1..8da4f032 100644 --- a/bindings/python/external/full_workflow_consumer.py +++ b/bindings/python/external/full_workflow_consumer.py @@ -6,10 +6,113 @@ from auths.integrations import development from auths.profiles import mcp +from auths.service import GitHubAgentTask, create_github_agent_client from auths.verify import verify_receipt async def run(_: Path) -> None: + github = create_github_agent_client(endpoint="https://operator.example") + responses = [ + { + "schema": "auths-github-agent/v1", + "repository": "auths-dev/example", + "issue_number": 7, + "base_ref": "main", + "base_revision": "a" * 40, + "allowed_paths": ["src/**"], + "denied_paths": [".github/**"], + "budgets": {"branches": 1, "draft_pull_requests": 1}, + "expiry": {"minimum_seconds": 60, "maximum_seconds": 900}, + "agent_credential_present": False, + }, + { + "schema": "auths-github-agent/v1", + "session_id": "1" * 32, + "workflow_id": "demo-" + "1" * 32, + "expires_at": 1_000, + "target_ref": "auths/issue-7-111111111111", + "agent_principal": "urn:auths:raw-key:agent", + "required_configuration": "2" * 64, + "executed_configuration": "2" * 64, + }, + { + "schema": "auths-github-agent/v1", + "candidate": { + "status": "inspected", + "candidate_revision": "b" * 40, + "changed_paths": [{"path": "src/fix.py"}], + "direct_push": {"result": "refused-without-credential"}, + "preview": { + "code": "authorized", + "credential_would_be_requested": True, + }, + }, + }, + { + "schema": "auths-github-agent/v1", + "decision": {"class": "authorized", "code": "authorized"}, + "execution": { + "branch_ref": "auths/issue-7-111111111111", + "pull_request_number": 8, + "pull_request_url": "https://github.com/auths-dev/example/pull/8", + }, + "credential_requests": 2, + "mutations": 2, + }, + { + "schema": "auths-github-agent/v1", + "workflow_id": "demo-" + "1" * 32, + "receipts": [{"type": "decision"}, {"type": "execution"}], + }, + { + "schema": "auths-github-agent/v1", + "decision": {"class": "authorized", "code": "action-replay"}, + "execution": {"replay": "original-receipt-returned"}, + "credential_requests": 0, + "mutations": 0, + }, + ] + + async def github_boundary(_path: str, _body=None): + if not responses: + raise RuntimeError("installed GitHub client made an extra call") + return responses.pop(0) + + github._call = github_boundary # type: ignore[method-assign] + boundary = await github.boundary() + if boundary.branch_budget != 1 or boundary.agent_credential_present is not False: + raise RuntimeError("installed GitHub boundary widened") + github_session = await github.delegate( + GitHubAgentTask( + repository=boundary.repository, + issue_number=boundary.issue_number, + base_ref=boundary.base_ref, + base_revision=boundary.base_revision, + allowed_paths=boundary.allowed_paths, + protected_paths=boundary.protected_paths, + expires_in_seconds=boundary.maximum_expiry_seconds, + branch_budget=1, + draft_pull_request_budget=1, + agent_label="wheel-consumer", + ) + ) + inspected = await github.inspect_fixture(github_session, "exact") + completed = await github.execute(github_session) + verified = await github.verify_receipts(github_session) + replayed = await github.replay(github_session) + if ( + inspected.kind != "inspected" + or completed.kind != "completed" + or verified.kind != "verified" + ): + raise RuntimeError("installed GitHub journey did not complete") + if ( + replayed.kind != "replayed" + or replayed.credential_requests != 0 + or replayed.mutations != 0 + ): + raise RuntimeError("installed GitHub replay was not bounded") + calls = 0 async def publish_report(arguments, context): diff --git a/bindings/python/python/auths/_github_agent.py b/bindings/python/python/auths/_github_agent.py new file mode 100644 index 00000000..edd59f4c --- /dev/null +++ b/bindings/python/python/auths/_github_agent.py @@ -0,0 +1,539 @@ +"""Typed client for the launch GitHub issue-agent vertical. + +GitHub semantics remain in the Rust service. This module validates +developer-shaped values, transports them, and projects the closed outcomes. +It never handles a GitHub credential or implements authorization policy. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import ssl +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, Mapping, Optional, Sequence, Union, cast + +_SCHEMA = "auths-github-agent/v1" +_MAX_RESPONSE_BYTES = 1_048_576 +_MAX_CANDIDATE_BYTES = 2 * 1_048_576 +_DEFAULT_TIMEOUT_SECONDS = 120.0 + +GitHubDenialFixture = Literal[ + "prohibited-path", + "candidate-changed", + "repository-changed", + "issue-changed", + "base-advanced", + "malformed-bundle", +] + + +@dataclass(frozen=True) +class GitHubAgentBoundary: + repository: str + issue_number: int + base_ref: str + base_revision: str + allowed_paths: tuple[str, ...] + protected_paths: tuple[str, ...] + minimum_expiry_seconds: int + maximum_expiry_seconds: int + branch_budget: Literal[1] + draft_pull_request_budget: Literal[1] + agent_credential_present: Literal[False] + + +@dataclass(frozen=True) +class GitHubAgentTask: + repository: str + issue_number: int + base_ref: str + base_revision: str + allowed_paths: Sequence[str] + protected_paths: Sequence[str] + expires_in_seconds: int + branch_budget: Literal[1] + draft_pull_request_budget: Literal[1] + agent_label: str + + +@dataclass(frozen=True) +class GitHubCandidateFile: + path: Union[str, Path] + base_revision: str + candidate_revision: str + + +@dataclass(frozen=True) +class GitHubCandidateInspection: + kind: Literal["inspected", "denied"] + candidate_revision: Optional[str] + changed_paths: tuple[str, ...] + direct_push: Literal[ + "refused-without-credential", "not-attempted", "unexpectedly-accepted" + ] + decision_code: str + credential_would_be_requested: bool + + +@dataclass(frozen=True) +class GitHubAgentOutcome: + kind: Literal["completed", "denied", "indeterminate", "replayed", "reconciled"] + code: str + credential_requests: Union[int, Literal["unknown"]] + mutations: Union[int, Literal["unknown"]] + next: Literal["none", "reconcile"] + branch_ref: Optional[str] = None + pull_request_number: Optional[int] = None + pull_request_url: Optional[str] = None + + +@dataclass(frozen=True) +class GitHubVerifiedReceipts: + kind: Literal["verified"] + workflow_id: str + count: int + + +class GitHubAgentSession: + """Opaque capability naming one bounded delegated GitHub task.""" + + __slots__ = ( + "_session_id", + "workflow_id", + "expires_at", + "target_ref", + "agent_principal", + "required_configuration", + "executed_configuration", + ) + + def __init__( + self, + session_id: str, + workflow_id: str, + expires_at: int, + target_ref: str, + agent_principal: str, + required_configuration: str, + executed_configuration: str, + *, + _token: object, + ) -> None: + if _token is not _SESSION_TOKEN: + raise TypeError("Auths GitHub agent sessions are opaque") + self._session_id = session_id + self.workflow_id = workflow_id + self.expires_at = expires_at + self.target_ref = target_ref + self.agent_principal = agent_principal + self.required_configuration = required_configuration + self.executed_configuration = executed_configuration + + def __repr__(self) -> str: + return f"GitHubAgentSession(workflow_id={self.workflow_id!r})" + + +_SESSION_TOKEN = object() + + +class GitHubAgentError(RuntimeError): + """One bounded error returned by the GitHub agent service.""" + + def __init__(self, code: str, detail: str, status: int) -> None: + super().__init__(detail) + self.code = code + self.status = status + + +class GitHubAgentClient: + """Client for one operator-approved GitHub issue-agent deployment.""" + + def __init__(self, endpoint: str, timeout_seconds: float) -> None: + self._endpoint = _endpoint(endpoint) + if not 0.1 <= timeout_seconds <= 120.0: + raise ValueError("Auths GitHub agent timeout is outside bounds") + self._timeout_seconds = timeout_seconds + + async def boundary(self) -> GitHubAgentBoundary: + value = await self._call("/v1/demo/scenario") + budgets = _record(value.get("budgets"), "budgets") + expiry = _record(value.get("expiry"), "expiry") + if ( + budgets.get("branches") != 1 + or budgets.get("draft_pull_requests") != 1 + or value.get("agent_credential_present") is not False + ): + raise TypeError("Auths GitHub agent boundary is unsafe") + return GitHubAgentBoundary( + repository=_string(value.get("repository"), "repository"), + issue_number=_integer(value.get("issue_number"), "issue number"), + base_ref=_string(value.get("base_ref"), "base ref"), + base_revision=_string(value.get("base_revision"), "base revision"), + allowed_paths=_strings(value.get("allowed_paths"), "allowed paths"), + protected_paths=_strings(value.get("denied_paths"), "protected paths"), + minimum_expiry_seconds=_integer( + expiry.get("minimum_seconds"), "minimum expiry" + ), + maximum_expiry_seconds=_integer( + expiry.get("maximum_seconds"), "maximum expiry" + ), + branch_budget=1, + draft_pull_request_budget=1, + agent_credential_present=False, + ) + + async def delegate(self, task: GitHubAgentTask) -> GitHubAgentSession: + _validate_task(task) + value = await self._call( + "/v1/demo/sessions", + { + "repository": task.repository, + "issueNumber": task.issue_number, + "baseRef": task.base_ref, + "baseRevision": task.base_revision, + "allowedPaths": list(task.allowed_paths), + "protectedPaths": list(task.protected_paths), + "expiresInSeconds": task.expires_in_seconds, + "branchBudget": task.branch_budget, + "draftPullRequestBudget": task.draft_pull_request_budget, + "agentLabel": task.agent_label, + }, + ) + required_configuration = _string( + value.get("required_configuration"), "required configuration" + ) + executed_configuration = _string( + value.get("executed_configuration"), "executed configuration" + ) + if required_configuration != executed_configuration: + raise TypeError("Auths GitHub agent verifier configuration mismatch") + return GitHubAgentSession( + _string(value.get("session_id"), "session id"), + _string(value.get("workflow_id"), "workflow id"), + _integer(value.get("expires_at"), "expiry"), + _string(value.get("target_ref"), "target ref"), + _string(value.get("agent_principal"), "agent principal"), + required_configuration, + executed_configuration, + _token=_SESSION_TOKEN, + ) + + async def inspect_candidate( + self, session: GitHubAgentSession, candidate: GitHubCandidateFile + ) -> GitHubCandidateInspection: + path = Path(candidate.path) + if not candidate.base_revision or not candidate.candidate_revision: + raise TypeError("Auths GitHub candidate file is invalid") + metadata = await asyncio.to_thread(path.stat) + if ( + not path.is_file() + or metadata.st_size == 0 + or metadata.st_size > _MAX_CANDIDATE_BYTES + ): + raise TypeError("Auths GitHub candidate file is outside bounds") + bundle = await asyncio.to_thread(path.read_bytes) + if not bundle or len(bundle) > _MAX_CANDIDATE_BYTES: + raise TypeError("Auths GitHub candidate file changed outside bounds") + encoded = base64.urlsafe_b64encode(bundle).rstrip(b"=").decode("ascii") + value = await self._call( + f"/v1/demo/sessions/{_session_id(session)}/candidate", + { + "kind": "bundle", + "bundleBase64url": encoded, + "baseRevision": candidate.base_revision, + "candidateRevision": candidate.candidate_revision, + }, + ) + return _inspection(value) + + async def inspect_fixture( + self, + session: GitHubAgentSession, + fixture: Union[Literal["exact"], GitHubDenialFixture], + ) -> GitHubCandidateInspection: + value = await self._call( + f"/v1/demo/sessions/{_session_id(session)}/candidate", + {"kind": "fixture", "experiment": fixture}, + ) + return _inspection(value) + + async def execute(self, session: GitHubAgentSession) -> GitHubAgentOutcome: + return await self._operate(session, "execute") + + async def replay(self, session: GitHubAgentSession) -> GitHubAgentOutcome: + return await self._operate(session, "replay") + + async def reconcile(self, session: GitHubAgentSession) -> GitHubAgentOutcome: + return await self._operate(session, "reconcile") + + async def verify_receipts( + self, session: GitHubAgentSession + ) -> GitHubVerifiedReceipts: + value = await self._call(f"/v1/demo/receipts/demo-{_session_id(session)}") + receipts = value.get("receipts") + if not isinstance(receipts, list): + raise TypeError("receipts are malformed") + workflow_id = _string(value.get("workflow_id"), "workflow id") + if not receipts or workflow_id != session.workflow_id: + raise TypeError( + "Auths GitHub agent receipt timeline is not bound to the session" + ) + return GitHubVerifiedReceipts( + kind="verified", + workflow_id=workflow_id, + count=len(receipts), + ) + + async def _operate( + self, + session: GitHubAgentSession, + operation: Literal["execute", "replay", "reconcile"], + ) -> GitHubAgentOutcome: + session_id = _session_id(session) + try: + value = await self._call( + f"/v1/demo/sessions/{session_id}/{operation}", {} + ) + return _outcome(value) + except Exception: + return GitHubAgentOutcome( + kind="indeterminate", + code="transport-uncertain", + credential_requests="unknown", + mutations="unknown", + next="reconcile", + ) + + async def _call( + self, path: str, body: Optional[Mapping[str, object]] = None + ) -> dict[str, Any]: + return await asyncio.to_thread(self._call_sync, path, body) + + def _call_sync( + self, path: str, body: Optional[Mapping[str, object]] + ) -> dict[str, Any]: + encoded = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + urllib.parse.urljoin(self._endpoint, path.lstrip("/")), + data=encoded, + method="GET" if body is None else "POST", + headers={"content-type": "application/json"}, + ) + try: + with urllib.request.urlopen( + request, + timeout=self._timeout_seconds, + context=ssl.create_default_context(), + ) as response: + raw = response.read(_MAX_RESPONSE_BYTES + 1) + status = response.status + except urllib.error.HTTPError as error: + raw = error.read(_MAX_RESPONSE_BYTES + 1) + status = error.code + if not raw or len(raw) > _MAX_RESPONSE_BYTES: + raise TypeError("Auths GitHub agent response is outside bounds") + value = _record(json.loads(raw), "Auths GitHub agent response") + if status < 200 or status >= 300: + raise GitHubAgentError( + str(value.get("code", f"http-{status}")), + str(value.get("detail", "GitHub agent request failed")), + status, + ) + if value.get("schema") != _SCHEMA: + raise TypeError("Auths GitHub agent schema mismatch") + return value + + +def create_github_agent_client( + *, endpoint: str, timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS +) -> GitHubAgentClient: + """Open the typed GitHub issue-agent launch client.""" + + return GitHubAgentClient(endpoint, timeout_seconds) + + +def _inspection(value: Mapping[str, Any]) -> GitHubCandidateInspection: + candidate = _record(value.get("candidate"), "candidate") + preview = _record(candidate.get("preview"), "candidate preview") + direct = _record(candidate.get("direct_push"), "direct push") + status = _string(candidate.get("status"), "candidate status") + if status not in ("inspected", "denied"): + raise TypeError("invalid candidate status") + direct_push = _string(direct.get("result"), "direct push result") + if direct_push not in ( + "refused-without-credential", + "not-attempted", + "unexpectedly-accepted", + ): + raise TypeError("invalid direct-push result") + if status == "inspected" and direct_push != "refused-without-credential": + raise TypeError("inspected candidate did not prove credential isolation") + paths = candidate.get("changed_paths", []) + if not isinstance(paths, list): + raise TypeError("changed paths are malformed") + changed = tuple( + _string(_record(path, "changed path").get("path"), "changed path") + for path in paths + ) + credential = preview.get("credential_would_be_requested") + if not isinstance(credential, bool): + raise TypeError("credential projection is malformed") + return GitHubCandidateInspection( + kind=cast(Literal["inspected", "denied"], status), + candidate_revision=candidate.get("candidate_revision") + if isinstance(candidate.get("candidate_revision"), str) + else None, + changed_paths=changed, + direct_push=cast( + Literal[ + "refused-without-credential", "not-attempted", "unexpectedly-accepted" + ], + direct_push, + ), + decision_code=_string(preview.get("code"), "decision code"), + credential_would_be_requested=credential, + ) + + +def _outcome(value: Mapping[str, Any]) -> GitHubAgentOutcome: + decision = _record(value.get("decision"), "decision") + execution = _record(value.get("execution"), "execution") + code = _string(decision.get("code"), "decision code") + decision_class = _string(decision.get("class"), "decision class") + if decision_class not in ("authorized", "denied", "indeterminate"): + raise TypeError("invalid GitHub agent decision class") + status = execution.get("status") + replay = execution.get("replay") + if replay == "original-receipt-returned": + kind = "replayed" + elif isinstance(status, str) and status.startswith("reconciled"): + kind = "reconciled" + elif decision_class == "denied": + kind = "denied" + elif decision_class == "indeterminate": + kind = "indeterminate" + else: + kind = "completed" + return GitHubAgentOutcome( + kind=cast( + Literal["completed", "denied", "indeterminate", "replayed", "reconciled"], + kind, + ), + code=code, + credential_requests=( + _uncertain_integer(value.get("credential_requests")) + if decision_class == "indeterminate" + else _integer(value.get("credential_requests"), "credential requests") + ), + mutations=( + _uncertain_integer(value.get("mutations")) + if decision_class == "indeterminate" + else _integer(value.get("mutations"), "mutation count") + ), + next="reconcile" if decision_class == "indeterminate" else "none", + branch_ref=execution.get("branch_ref") + if isinstance(execution.get("branch_ref"), str) + else None, + pull_request_number=_optional_integer(execution.get("pull_request_number")), + pull_request_url=execution.get("pull_request_url") + if isinstance(execution.get("pull_request_url"), str) + else None, + ) + + +def _validate_task(task: GitHubAgentTask) -> None: + if ( + not isinstance(task.issue_number, int) + or task.issue_number < 1 + or not isinstance(task.expires_in_seconds, int) + or task.expires_in_seconds < 1 + or task.branch_budget != 1 + or task.draft_pull_request_budget != 1 + ): + raise TypeError("Auths GitHub agent task is outside bounds") + values = ( + task.repository, + task.base_ref, + task.base_revision, + task.agent_label, + *task.allowed_paths, + *task.protected_paths, + ) + if any(not isinstance(value, str) or not value or len(value) > 1_024 for value in values): + raise TypeError("Auths GitHub agent task contains an invalid string") + + +def _session_id(session: GitHubAgentSession) -> str: + if not isinstance(session, GitHubAgentSession): + raise TypeError("forged Auths GitHub agent session") + return session._session_id + + +def _endpoint(value: str) -> str: + parsed = urllib.parse.urlsplit(value) + local = parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1") + if ( + (parsed.scheme != "https" and not local) + or parsed.username is not None + or parsed.password is not None + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + ): + raise ValueError("Auths GitHub agent endpoint must be HTTPS or loopback HTTP") + return value.rstrip("/") + "/" + + +def _record(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise TypeError(f"{label} is malformed") + return value + + +def _string(value: object, label: str) -> str: + if not isinstance(value, str) or not value: + raise TypeError(f"{label} is malformed") + return value + + +def _integer(value: object, label: str) -> int: + result = _optional_integer(value) + if result is None: + raise TypeError(f"{label} is malformed") + return result + + +def _optional_integer(value: object) -> Optional[int]: + return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None + + +def _uncertain_integer(value: object) -> Union[int, Literal["unknown"]]: + integer = _optional_integer(value) + return "unknown" if integer is None else integer + + +def _strings(value: object, label: str) -> tuple[str, ...]: + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise TypeError(f"{label} is malformed") + return tuple(value) + + +__all__ = [ + "GitHubAgentBoundary", + "GitHubAgentClient", + "GitHubAgentError", + "GitHubAgentOutcome", + "GitHubAgentSession", + "GitHubAgentTask", + "GitHubCandidateFile", + "GitHubCandidateInspection", + "GitHubDenialFixture", + "GitHubVerifiedReceipts", + "create_github_agent_client", +] diff --git a/bindings/python/python/auths/service.py b/bindings/python/python/auths/service.py index d49a5de6..d5bb47ce 100644 --- a/bindings/python/python/auths/service.py +++ b/bindings/python/python/auths/service.py @@ -17,6 +17,19 @@ RecommendedAction, RetryClass, ) +from ._github_agent import ( + GitHubAgentBoundary, + GitHubAgentClient, + GitHubAgentError, + GitHubAgentOutcome, + GitHubAgentSession, + GitHubAgentTask, + GitHubCandidateFile, + GitHubCandidateInspection, + GitHubDenialFixture, + GitHubVerifiedReceipts, + create_github_agent_client, +) from ._service import ( NextCall, ServiceAuthority, @@ -43,6 +56,16 @@ "AuthsError", "AuthsErrorCode", "EffectState", + "GitHubAgentBoundary", + "GitHubAgentClient", + "GitHubAgentError", + "GitHubAgentOutcome", + "GitHubAgentSession", + "GitHubAgentTask", + "GitHubCandidateFile", + "GitHubCandidateInspection", + "GitHubDenialFixture", + "GitHubVerifiedReceipts", "NextCall", "ProductVerb", "RecommendedAction", @@ -65,4 +88,5 @@ "ServiceVerificationResult", "ServiceVerified", "create_service_client", + "create_github_agent_client", ] diff --git a/bindings/python/sdk-capability.json b/bindings/python/sdk-capability.json index aa067e90..e653803e 100644 --- a/bindings/python/sdk-capability.json +++ b/bindings/python/sdk-capability.json @@ -45,7 +45,8 @@ "purpose-labelled root, identity, verify, profiles, integrations, framework and testkit modules", "shared Rust, TypeScript and Python customer-journey projections", "strict mypy and Pyright consumer contracts", - "installed abi3 wheel qualification on Linux, macOS and Windows for every CPython 3.9 through 3.14 minor" + "installed abi3 wheel qualification on Linux, macOS and Windows for every CPython 3.9 through 3.14 minor", + "typed GitHub issue-agent task, candidate-file, denial, replay, reconciliation and receipt flow" ], "excluded": [ "promoted full workflow SDK release claim", diff --git a/bindings/python/tests/test_github_agent.py b/bindings/python/tests/test_github_agent.py new file mode 100644 index 00000000..5a08aea9 --- /dev/null +++ b/bindings/python/tests/test_github_agent.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import asyncio + +from auths.service import GitHubAgentTask, create_github_agent_client + + +def test_typed_github_task_projects_to_the_closed_launch_api() -> None: + responses = [ + { + "schema": "auths-github-agent/v1", + "repository": "auths-dev/example", + "issue_number": 123, + "base_ref": "main", + "base_revision": "a" * 40, + "allowed_paths": ["src/**", "tests/**"], + "denied_paths": [".github/**"], + "budgets": {"branches": 1, "draft_pull_requests": 1}, + "expiry": {"minimum_seconds": 60, "maximum_seconds": 900}, + "agent_credential_present": False, + }, + { + "schema": "auths-github-agent/v1", + "session_id": "1" * 32, + "workflow_id": "demo-" + "1" * 32, + "expires_at": 1_000, + "target_ref": "auths/issue-123-abcdef123456", + "agent_principal": "urn:auths:raw-key:agent", + "required_configuration": "2" * 64, + "executed_configuration": "2" * 64, + }, + { + "schema": "auths-github-agent/v1", + "candidate": { + "status": "denied", + "changed_paths": [], + "direct_push": {"result": "not-attempted"}, + "preview": { + "code": "path-explicitly-denied", + "credential_would_be_requested": False, + }, + }, + }, + { + "schema": "auths-github-agent/v1", + "decision": {"class": "denied", "code": "path-explicitly-denied"}, + "execution": {"branch": "not-attempted", "pull_request": "not-attempted"}, + "credential_requests": 0, + "mutations": 0, + }, + ] + calls: list[tuple[str, object]] = [] + client = create_github_agent_client(endpoint="https://operator.example") + + async def fake_call(path: str, body: object = None) -> dict[str, object]: + calls.append((path, body)) + return responses.pop(0) + + client._call = fake_call # type: ignore[method-assign] + + async def scenario() -> None: + boundary = await client.boundary() + session = await client.delegate( + GitHubAgentTask( + repository=boundary.repository, + issue_number=boundary.issue_number, + base_ref=boundary.base_ref, + base_revision=boundary.base_revision, + allowed_paths=boundary.allowed_paths, + protected_paths=boundary.protected_paths, + expires_in_seconds=boundary.maximum_expiry_seconds, + branch_budget=1, + draft_pull_request_budget=1, + agent_label="review-agent", + ) + ) + inspection = await client.inspect_fixture(session, "prohibited-path") + denied = await client.execute(session) + assert inspection.kind == "denied" + assert inspection.credential_would_be_requested is False + assert denied.kind == "denied" + assert denied.credential_requests == 0 + assert denied.mutations == 0 + + asyncio.run(scenario()) + assert calls[1][1] == { + "repository": "auths-dev/example", + "issueNumber": 123, + "baseRef": "main", + "baseRevision": "a" * 40, + "allowedPaths": ["src/**", "tests/**"], + "protectedPaths": [".github/**"], + "expiresInSeconds": 900, + "branchBudget": 1, + "draftPullRequestBudget": 1, + "agentLabel": "review-agent", + } + + +def test_lost_execute_response_requires_reconciliation() -> None: + client = create_github_agent_client(endpoint="https://operator.example") + session_response = { + "schema": "auths-github-agent/v1", + "session_id": "1" * 32, + "workflow_id": "demo-" + "1" * 32, + "expires_at": 1_000, + "target_ref": "auths/issue-123-abcdef123456", + "agent_principal": "urn:auths:raw-key:agent", + "required_configuration": "2" * 64, + "executed_configuration": "2" * 64, + } + calls = 0 + + async def fake_call(_path: str, _body: object = None) -> dict[str, object]: + nonlocal calls + calls += 1 + if calls > 1: + raise OSError("connection lost after request left the process") + return session_response + + client._call = fake_call # type: ignore[method-assign] + + async def scenario() -> None: + session = await client.delegate( + GitHubAgentTask( + repository="auths-dev/example", + issue_number=123, + base_ref="main", + base_revision="a" * 40, + allowed_paths=["src/**"], + protected_paths=[".github/**"], + expires_in_seconds=900, + branch_budget=1, + draft_pull_request_budget=1, + agent_label="review-agent", + ) + ) + outcome = await client.execute(session) + assert outcome.kind == "indeterminate" + assert outcome.code == "transport-uncertain" + assert outcome.credential_requests == "unknown" + assert outcome.mutations == "unknown" + assert outcome.next == "reconcile" + + asyncio.run(scenario()) diff --git a/bindings/python/tools/check_wheel.py b/bindings/python/tools/check_wheel.py index 1aa8a5c9..0071f7d4 100644 --- a/bindings/python/tools/check_wheel.py +++ b/bindings/python/tools/check_wheel.py @@ -35,6 +35,7 @@ "auths/_trust.py", "auths/_workflow.py", "auths/framework.py", + "auths/_github_agent.py", "auths/identity.py", "auths/integrations.py", "auths/profiles/__init__.py", diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md index 8104fd66..456b6c86 100644 --- a/bindings/typescript/README.md +++ b/bindings/typescript/README.md @@ -48,20 +48,28 @@ used instead of the `try`/`finally` form. ## Use a production runtime ```ts -import { createAuths } from "@auths-dev/sdk"; -import { githubIssueAddress } from "@auths-dev/sdk/profiles"; - -const auths = createAuths({ - endpoint: "https://auths.example.com", - identity: publicIdentityBytes, - profile: githubIssueAddress(), +import { createGitHubAgentClient } from "@auths-dev/sdk/service"; + +const auths = createGitHubAgentClient({ endpoint: "https://executor.example" }); +const boundary = await auths.boundary(); +const task = await auths.delegate({ + repository: boundary.repository, + issueNumber: boundary.issueNumber, + baseRef: boundary.baseRef, + baseRevision: boundary.baseRevision, + allowedPaths: boundary.allowedPaths, + protectedPaths: boundary.protectedPaths, + expiresInSeconds: boundary.maximumExpirySeconds, + branchBudget: 1, + draftPullRequestBudget: 1, + agentLabel: "issue-agent", }); -const authority = await auths.create(authorityRequestBytes); -if (authority.kind !== "authority") throw new Error(authority.code); -const result = await auths.execute(authority, actionBytes); -if (result.kind === "recoverable") await auths.resume(result.reference); ``` +Continue with a candidate bundle file using the maintained +[GitHub quickstart](../../docs/product/PRODUCTION_SDK_QUICKSTART.md). No +protocol bytes or GitHub credential enter application code. + ## Public entry points One npm package provides a progressively disclosed API: @@ -71,6 +79,7 @@ One npm package provides a progressively disclosed API: | `@auths-dev/sdk` | create, delegate, execute, resume, product results and errors | | `@auths-dev/sdk/identity` | standalone identity decoding and authentication | | `@auths-dev/sdk/verify` | effect-free proof, decision and receipt verification | +| `@auths-dev/sdk/service` | generic five-verb operator-runtime transport | | `@auths-dev/sdk/profiles` | qualified MCP, OpenTofu, PostgreSQL and GitHub effect domains | | `@auths-dev/sdk/integrations` | maintained compositions and mechanism adapters | | `@auths-dev/sdk/framework` | proven signer and atomic-reservation contracts | @@ -93,11 +102,11 @@ belong to `@auths-dev/sdk/testkit`. ## Production boundary -The development composition uses ephemeral keys and in-memory state. The root -production client talks to an HTTPS operator runtime through a bounded, -Rust-owned binary contract. Provider credentials remain behind the profile -gateway and are acquired only after Auths has authorized and durably reserved -the exact action. +The development composition uses ephemeral keys and in-memory state. The +generic remote client and the profile-specific GitHub launch path live at +`@auths-dev/sdk/service`. Provider credentials +remain behind the Rust profile gateway and are acquired only after Auths has +authorized and durably claimed the exact action. ## Support diff --git a/bindings/typescript/api/public-api.txt b/bindings/typescript/api/public-api.txt index 8d670a73..9ef9bee2 100644 --- a/bindings/typescript/api/public-api.txt +++ b/bindings/typescript/api/public-api.txt @@ -1,5 +1,5 @@ # Installed @auths-dev/sdk public API v1 -# declaration-sha256 507c68077876e8b7453f1116df41503e86e769e2dd4f95b6771236271e7cda93 +# declaration-sha256 c1e7d86abaae84d40ed20e7936c6529692e8c7c6fa6c073e7485d9c4d257e203 . Actor type . approval value . ApprovalPolicy type @@ -86,8 +86,20 @@ ./verify VerifiedOpaqueReceipt type ./verify Verifier value+type ./verify verifyReceipt value +./service createGitHubAgentClient value ./service createServiceClient value +./service GitHubAgentBoundary type +./service GitHubAgentClient type +./service GitHubAgentClientOptions type +./service GitHubAgentError value+type +./service GitHubAgentOutcome type +./service GitHubAgentSession type +./service GitHubAgentTask type +./service GitHubCandidateFile type +./service GitHubCandidateInspection type +./service GitHubDenialFixture type ./service githubIssueAddress value +./service GitHubVerifiedReceipts type ./service importAuthority value ./service NextCall type ./service opentofuSavedPlanApply value diff --git a/bindings/typescript/sdk-capability.json b/bindings/typescript/sdk-capability.json index f8371ac5..9cae0787 100644 --- a/bindings/typescript/sdk-capability.json +++ b/bindings/typescript/sdk-capability.json @@ -28,6 +28,7 @@ "versioned adapter conformance and an exact runtime contract", "bounded auths doctor diagnostics for runtime and configuration readiness", "idempotent closed runtime with reconciliation and durable reference state", + "typed GitHub issue-agent task, candidate-file, denial, replay, reconciliation and receipt flow", "exact purpose-labelled root, identity, verify, service, profiles, integrations, framework, and testkit entry points" ], "excluded": [ diff --git a/bindings/typescript/src/github-agent.ts b/bindings/typescript/src/github-agent.ts new file mode 100644 index 00000000..fdb194f5 --- /dev/null +++ b/bindings/typescript/src/github-agent.ts @@ -0,0 +1,431 @@ +/** + * Typed client for the launch GitHub issue-agent vertical. + * + * The server owns GitHub canonicalization, inspection, authorization, + * lifecycle state, credentials, writes, and receipts. This module only turns + * developer-shaped values into the closed demo API and projects its outcomes. + */ + +const SCHEMA = "auths-github-agent/v1"; +const MAX_RESPONSE_BYTES = 1_048_576; +const MAX_CANDIDATE_BYTES = 2 * 1_048_576; +const DEFAULT_TIMEOUT_MS = 120_000; +const sessionIds = new WeakMap(); + +export interface GitHubAgentBoundary { + readonly repository: string; + readonly issueNumber: number; + readonly baseRef: string; + readonly baseRevision: string; + readonly allowedPaths: readonly string[]; + readonly protectedPaths: readonly string[]; + readonly minimumExpirySeconds: number; + readonly maximumExpirySeconds: number; + readonly branchBudget: 1; + readonly draftPullRequestBudget: 1; + readonly agentCredentialPresent: false; +} + +export interface GitHubAgentTask { + readonly repository: string; + readonly issueNumber: number; + readonly baseRef: string; + readonly baseRevision: string; + readonly allowedPaths: readonly string[]; + readonly protectedPaths: readonly string[]; + readonly expiresInSeconds: number; + readonly branchBudget: 1; + readonly draftPullRequestBudget: 1; + readonly agentLabel: string; +} + +export interface GitHubCandidateFile { + readonly path: string | URL; + readonly baseRevision: string; + readonly candidateRevision: string; +} + +export type GitHubDenialFixture = + | "prohibited-path" + | "candidate-changed" + | "repository-changed" + | "issue-changed" + | "base-advanced" + | "malformed-bundle"; + +export interface GitHubAgentSession { + readonly kind: "github-agent-session"; + readonly workflowId: string; + readonly expiresAt: number; + readonly targetRef: string; + readonly agentPrincipal: string; + readonly requiredConfiguration: string; + readonly executedConfiguration: string; + toJSON(): never; +} + +class GitHubAgentSessionValue implements GitHubAgentSession { + readonly kind = "github-agent-session" as const; + + constructor( + id: string, + readonly workflowId: string, + readonly expiresAt: number, + readonly targetRef: string, + readonly agentPrincipal: string, + readonly requiredConfiguration: string, + readonly executedConfiguration: string, + ) { + sessionIds.set(this, id); + Object.freeze(this); + } + + toJSON(): never { + throw new TypeError("Auths GitHub agent sessions are opaque"); + } +} + +export interface GitHubCandidateInspection { + readonly kind: "inspected" | "denied"; + readonly candidateRevision?: string; + readonly changedPaths: readonly string[]; + readonly directPush: "refused-without-credential" | "not-attempted" | "unexpectedly-accepted"; + readonly decisionCode: string; + readonly credentialWouldBeRequested: boolean; +} + +export interface GitHubAgentOutcome { + readonly kind: "completed" | "denied" | "indeterminate" | "replayed" | "reconciled"; + readonly code: string; + readonly credentialRequests: number | "unknown"; + readonly mutations: number | "unknown"; + readonly next: "none" | "reconcile"; + readonly branchRef?: string; + readonly pullRequestNumber?: number; + readonly pullRequestUrl?: string; +} + +export interface GitHubVerifiedReceipts { + readonly kind: "verified"; + readonly workflowId: string; + readonly count: number; +} + +export interface GitHubAgentClientOptions { + readonly endpoint: string | URL; + readonly timeoutMs?: number; + readonly fetch?: typeof fetch; +} + +export interface GitHubAgentClient { + boundary(): Promise; + delegate(task: GitHubAgentTask): Promise; + inspectCandidate(session: GitHubAgentSession, candidate: GitHubCandidateFile): Promise; + inspectFixture(session: GitHubAgentSession, fixture: "exact" | GitHubDenialFixture): Promise; + execute(session: GitHubAgentSession): Promise; + replay(session: GitHubAgentSession): Promise; + reconcile(session: GitHubAgentSession): Promise; + verifyReceipts(session: GitHubAgentSession): Promise; +} + +/** Opens the typed GitHub issue-agent launch client. */ +export function createGitHubAgentClient(options: GitHubAgentClientOptions): GitHubAgentClient { + const endpoint = parseEndpoint(options.endpoint); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120_000) { + throw new TypeError("Auths GitHub agent timeout is outside bounds"); + } + const send = options.fetch ?? globalThis.fetch; + if (typeof send !== "function") throw new TypeError("Auths GitHub agent fetch is unavailable"); + + const call = async (path: string, init?: RequestInit): Promise> => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await send(new URL(path, endpoint), { + ...init, + signal: controller.signal, + headers: { "content-type": "application/json", ...init?.headers }, + }); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.length === 0 || bytes.length > MAX_RESPONSE_BYTES) { + throw new TypeError("Auths GitHub agent response is outside bounds"); + } + const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + const object = record(value, "Auths GitHub agent response"); + if (!response.ok) { + const code = optionalString(object.code) ?? `http-${response.status}`; + const detail = optionalString(object.detail) ?? "GitHub agent request failed"; + throw new GitHubAgentError(code, detail, response.status); + } + if (object.schema !== SCHEMA) throw new TypeError("Auths GitHub agent schema mismatch"); + return object; + } finally { + clearTimeout(timer); + } + }; + + const operate = async ( + session: GitHubAgentSession, + operation: "execute" | "replay" | "reconcile", + ): Promise => { + const id = readSession(session); + try { + return projectOutcome(await call(`/v1/demo/sessions/${id}/${operation}`, { method: "POST" })); + } catch { + return Object.freeze({ + kind: "indeterminate" as const, + code: "transport-uncertain", + credentialRequests: "unknown" as const, + mutations: "unknown" as const, + next: "reconcile" as const, + }); + } + }; + + return Object.freeze({ + async boundary() { + return projectBoundary(await call("/v1/demo/scenario")); + }, + async delegate(task: GitHubAgentTask) { + validateTask(task); + const value = await call("/v1/demo/sessions", { + method: "POST", + body: JSON.stringify(task), + }); + const requiredConfiguration = requiredString(value.required_configuration, "required configuration"); + const executedConfiguration = requiredString(value.executed_configuration, "executed configuration"); + if (requiredConfiguration !== executedConfiguration) { + throw new TypeError("Auths GitHub agent verifier configuration mismatch"); + } + return new GitHubAgentSessionValue( + requiredString(value.session_id, "session id"), + requiredString(value.workflow_id, "workflow id"), + requiredInteger(value.expires_at, "expiry"), + requiredString(value.target_ref, "target ref"), + requiredString(value.agent_principal, "agent principal"), + requiredConfiguration, + executedConfiguration, + ); + }, + async inspectCandidate(session: GitHubAgentSession, candidate: GitHubCandidateFile) { + validateCandidate(candidate); + const { readFile, stat } = await import("node:fs/promises"); + const metadata = await stat(candidate.path); + if (!metadata.isFile() || metadata.size === 0 || metadata.size > MAX_CANDIDATE_BYTES) { + throw new TypeError("Auths GitHub candidate file is outside bounds"); + } + const bundle = await readFile(candidate.path); + if (bundle.length === 0 || bundle.length > MAX_CANDIDATE_BYTES) { + throw new TypeError("Auths GitHub candidate file changed outside bounds"); + } + const value = await call(`/v1/demo/sessions/${readSession(session)}/candidate`, { + method: "POST", + body: JSON.stringify({ + kind: "bundle", + bundleBase64url: base64Url(bundle), + baseRevision: candidate.baseRevision, + candidateRevision: candidate.candidateRevision, + }), + }); + return projectInspection(value); + }, + async inspectFixture(session: GitHubAgentSession, fixture: "exact" | GitHubDenialFixture) { + const value = await call(`/v1/demo/sessions/${readSession(session)}/candidate`, { + method: "POST", + body: JSON.stringify({ kind: "fixture", experiment: fixture }), + }); + return projectInspection(value); + }, + execute: (session: GitHubAgentSession) => operate(session, "execute"), + replay: (session: GitHubAgentSession) => operate(session, "replay"), + reconcile: (session: GitHubAgentSession) => operate(session, "reconcile"), + async verifyReceipts(session: GitHubAgentSession) { + const id = readSession(session); + const value = await call(`/v1/demo/receipts/demo-${id}`); + const receipts = array(value.receipts, "receipts"); + const workflowId = requiredString(value.workflow_id, "workflow id"); + if (receipts.length === 0 || workflowId !== session.workflowId) { + throw new TypeError("Auths GitHub agent receipt timeline is not bound to the session"); + } + return Object.freeze({ + kind: "verified" as const, + workflowId, + count: receipts.length, + }); + }, + }); +} + +export class GitHubAgentError extends Error { + constructor(readonly code: string, message: string, readonly status: number) { + super(message); + this.name = "GitHubAgentError"; + } +} + +function projectBoundary(value: Record): GitHubAgentBoundary { + const budgets = record(value.budgets, "budgets"); + const expiry = record(value.expiry, "expiry"); + if (budgets.branches !== 1 || budgets.draft_pull_requests !== 1 || value.agent_credential_present !== false) { + throw new TypeError("Auths GitHub agent boundary is unsafe"); + } + return Object.freeze({ + repository: requiredString(value.repository, "repository"), + issueNumber: requiredInteger(value.issue_number, "issue number"), + baseRef: requiredString(value.base_ref, "base ref"), + baseRevision: requiredString(value.base_revision, "base revision"), + allowedPaths: Object.freeze(strings(value.allowed_paths, "allowed paths")), + protectedPaths: Object.freeze(strings(value.denied_paths, "protected paths")), + minimumExpirySeconds: requiredInteger(expiry.minimum_seconds, "minimum expiry"), + maximumExpirySeconds: requiredInteger(expiry.maximum_seconds, "maximum expiry"), + branchBudget: 1, + draftPullRequestBudget: 1, + agentCredentialPresent: false, + }); +} + +function projectInspection(value: Record): GitHubCandidateInspection { + const candidate = record(value.candidate, "candidate"); + const preview = record(candidate.preview, "candidate preview"); + const direct = record(candidate.direct_push, "direct push"); + const changed = Array.isArray(candidate.changed_paths) + ? candidate.changed_paths.map((entry) => requiredString(record(entry, "changed path").path, "changed path")) + : []; + const status = requiredString(candidate.status, "candidate status"); + if (status !== "inspected" && status !== "denied") throw new TypeError("invalid candidate status"); + const directPush = requiredString(direct.result, "direct push result"); + if (!["refused-without-credential", "not-attempted", "unexpectedly-accepted"].includes(directPush)) { + throw new TypeError("invalid direct-push result"); + } + if (status === "inspected" && directPush !== "refused-without-credential") { + throw new TypeError("inspected candidate did not prove credential isolation"); + } + return Object.freeze({ + kind: status, + ...(typeof candidate.candidate_revision === "string" ? { candidateRevision: candidate.candidate_revision } : {}), + changedPaths: Object.freeze(changed), + directPush: directPush as GitHubCandidateInspection["directPush"], + decisionCode: requiredString(preview.code, "decision code"), + credentialWouldBeRequested: requiredBoolean(preview.credential_would_be_requested, "credential projection"), + }); +} + +function projectOutcome(value: Record): GitHubAgentOutcome { + const decision = record(value.decision, "decision"); + const execution = record(value.execution, "execution"); + const code = requiredString(decision.code, "decision code"); + const className = requiredString(decision.class, "decision class"); + if (!["authorized", "denied", "indeterminate"].includes(className)) { + throw new TypeError("invalid GitHub agent decision class"); + } + const status = optionalString(execution.status); + const replay = optionalString(execution.replay); + const kind: GitHubAgentOutcome["kind"] = replay === "original-receipt-returned" + ? "replayed" + : status?.startsWith("reconciled") === true + ? "reconciled" + : className === "denied" + ? "denied" + : className === "indeterminate" + ? "indeterminate" + : "completed"; + return Object.freeze({ + kind, + code, + credentialRequests: className === "indeterminate" + ? optionalInteger(value.credential_requests) ?? "unknown" + : requiredInteger(value.credential_requests, "credential requests"), + mutations: className === "indeterminate" + ? optionalInteger(value.mutations) ?? "unknown" + : requiredInteger(value.mutations, "mutation count"), + next: className === "indeterminate" ? "reconcile" : "none", + ...(typeof execution.branch_ref === "string" ? { branchRef: execution.branch_ref } : {}), + ...(typeof execution.pull_request_number === "number" ? { pullRequestNumber: execution.pull_request_number } : {}), + ...(typeof execution.pull_request_url === "string" ? { pullRequestUrl: execution.pull_request_url } : {}), + }); +} + +function validateTask(task: GitHubAgentTask): void { + if (!Number.isSafeInteger(task.issueNumber) || task.issueNumber < 1 + || !Number.isSafeInteger(task.expiresInSeconds) || task.expiresInSeconds < 1 + || task.branchBudget !== 1 || task.draftPullRequestBudget !== 1) { + throw new TypeError("Auths GitHub agent task is outside bounds"); + } + for (const value of [task.repository, task.baseRef, task.baseRevision, task.agentLabel, ...task.allowedPaths, ...task.protectedPaths]) { + if (typeof value !== "string" || value.length === 0 || value.length > 1_024) { + throw new TypeError("Auths GitHub agent task contains an invalid string"); + } + } +} + +function validateCandidate(candidate: GitHubCandidateFile): void { + if ((typeof candidate.path !== "string" && !(candidate.path instanceof URL)) + || typeof candidate.baseRevision !== "string" || typeof candidate.candidateRevision !== "string") { + throw new TypeError("Auths GitHub candidate file is invalid"); + } +} + +function readSession(session: GitHubAgentSession): string { + const id = sessionIds.get(session); + if (id === undefined) throw new TypeError("forged Auths GitHub agent session"); + return id; +} + +function parseEndpoint(value: string | URL): URL { + const endpoint = new URL(value); + const local = endpoint.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname); + if ((endpoint.protocol !== "https:" && !local) || endpoint.username !== "" || endpoint.password !== "" + || endpoint.pathname !== "/" || endpoint.search !== "" || endpoint.hash !== "") { + throw new TypeError("Auths GitHub agent endpoint must be HTTPS or loopback HTTP"); + } + return endpoint; +} + +function base64Url(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length))); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +function record(value: unknown, label: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} is malformed`); + return value as Record; +} + +function array(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) throw new TypeError(`${label} is malformed`); + return value; +} + +function strings(value: unknown, label: string): string[] { + const values = array(value, label); + if (!values.every((entry) => typeof entry === "string")) throw new TypeError(`${label} is malformed`); + return values as string[]; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) throw new TypeError(`${label} is malformed`); + return value; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function requiredInteger(value: unknown, label: string): number { + const integer = optionalInteger(value); + if (integer === undefined) throw new TypeError(`${label} is malformed`); + return integer; +} + +function optionalInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +function requiredBoolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new TypeError(`${label} is malformed`); + return value; +} diff --git a/bindings/typescript/src/node-fs.d.ts b/bindings/typescript/src/node-fs.d.ts index cb382809..f1d499db 100644 --- a/bindings/typescript/src/node-fs.d.ts +++ b/bindings/typescript/src/node-fs.d.ts @@ -1,5 +1,12 @@ declare module "node:fs/promises" { + interface Stats { + readonly size: number; + isFile(): boolean; + } + export function readFile( - path: URL, + path: URL | string, ): Promise>; + + export function stat(path: URL | string): Promise; } diff --git a/bindings/typescript/src/service.ts b/bindings/typescript/src/service.ts index 10ac17d8..b7007e3d 100644 --- a/bindings/typescript/src/service.ts +++ b/bindings/typescript/src/service.ts @@ -576,6 +576,21 @@ function normalizeContentType(value: string): string { return value.split(";", 1)[0]?.trim().toLowerCase() ?? ""; } +export { + GitHubAgentError, + createGitHubAgentClient, + type GitHubAgentBoundary, + type GitHubAgentClient, + type GitHubAgentClientOptions, + type GitHubAgentOutcome, + type GitHubAgentSession, + type GitHubAgentTask, + type GitHubCandidateFile, + type GitHubCandidateInspection, + type GitHubDenialFixture, + type GitHubVerifiedReceipts, +} from "./github-agent.js"; + async function readBoundedBody(response: Response): Promise { if (response.body === null) return new Uint8Array(); const reader = response.body.getReader(); diff --git a/bindings/typescript/test/package/package.test.js b/bindings/typescript/test/package/package.test.js index 06a25924..069cb648 100644 --- a/bindings/typescript/test/package/package.test.js +++ b/bindings/typescript/test/package/package.test.js @@ -59,6 +59,8 @@ test("packed contents carry the published artifacts and no source or tests", asy "dist/index.js", "dist/index.d.ts", "dist/framework.js", + "dist/github-agent.js", + "dist/github-agent.d.ts", "dist/doctor-cli.js", "dist/doctor.js", "dist/identity.js", @@ -108,6 +110,19 @@ test("identity entry point has no higher-layer imports", async () => { ); }); +test("launch examples stay typed and make live GitHub mutation explicitly opt-in", async () => { + const examples = [ + new URL("../../../../demos/github-issue/examples/typescript/agent.mjs", import.meta.url), + new URL("../../../../demos/github-issue/examples/python/agent.py", import.meta.url), + ]; + const forbiddenProtocolMechanics = /(?:authorityBytes|actionBytes|proofBytes|Uint8Array|canonical(?:ize|Bytes)|cbor)/iu; + for (const example of examples) { + const source = await readFile(example, "utf8"); + assert.match(source, /AUTHS_GITHUB_LIVE/); + assert.doesNotMatch(source, forbiddenProtocolMechanics); + } +}); + test("identity and verification dependency closures exclude effect workflow code", async () => { const forbidden = /\/(?:approvals|custody|plans|profiles|workflow)(?:\/|\.|$)/; for (const entry of ["identity.js", "verify.js"]) { diff --git a/bindings/typescript/test/package/packed-consumer.test.js b/bindings/typescript/test/package/packed-consumer.test.js index b591ec6b..dadd462f 100644 --- a/bindings/typescript/test/package/packed-consumer.test.js +++ b/bindings/typescript/test/package/packed-consumer.test.js @@ -5,8 +5,6 @@ import { join } from "node:path"; import { test } from "node:test"; import { compileConsumer, installPackedSdk } from "./helpers/packed-install.mjs"; -import { readFile } from "node:fs/promises"; - // Derived from the declared topology rather than restated, so this test cannot // agree with the package while both disagree with what was reviewed. const entryPoints = JSON.parse( @@ -25,6 +23,7 @@ test("packed package exposes only the reviewed public topology", async () => { const expected = ${JSON.stringify(entryPoints)}; for (const entry of expected) await import(entry); const root = await import("@auths-dev/sdk"); + const { createGitHubAgentClient } = await import("@auths-dev/sdk/service"); const names = Object.keys(root).sort(); // Runtime values only; types erase. classifyErrorCode and isProductVerb // are the Rust-owned registry projection reaching a caller. @@ -43,12 +42,99 @@ test("packed package exposes only the reviewed public topology", async () => { if (error?.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error; } } + const githubResponses = [ + { + schema: "auths-github-agent/v1", + repository: "auths-dev/example", + issue_number: 7, + base_ref: "main", + base_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + allowed_paths: ["src/**"], + denied_paths: [".github/**"], + budgets: { branches: 1, draft_pull_requests: 1 }, + expiry: { minimum_seconds: 60, maximum_seconds: 900 }, + agent_credential_present: false, + }, + { + schema: "auths-github-agent/v1", + session_id: "11111111111111111111111111111111", + workflow_id: "demo-11111111111111111111111111111111", + expires_at: 1_000, + target_ref: "auths/issue-7-111111111111", + agent_principal: "urn:auths:raw-key:agent", + required_configuration: "2".repeat(64), + executed_configuration: "2".repeat(64), + }, + { + schema: "auths-github-agent/v1", + candidate: { + status: "inspected", + candidate_revision: "b".repeat(40), + changed_paths: [{ path: "src/fix.ts" }], + direct_push: { result: "refused-without-credential" }, + preview: { code: "authorized", credential_would_be_requested: true }, + }, + }, + { + schema: "auths-github-agent/v1", + decision: { class: "authorized", code: "authorized" }, + execution: { branch_ref: "auths/issue-7-111111111111", pull_request_number: 8, pull_request_url: "https://github.com/auths-dev/example/pull/8" }, + credential_requests: 2, + mutations: 2, + }, + { + schema: "auths-github-agent/v1", + workflow_id: "demo-11111111111111111111111111111111", + receipts: [{ type: "decision" }, { type: "execution" }], + }, + { + schema: "auths-github-agent/v1", + decision: { class: "authorized", code: "action-replay" }, + execution: { replay: "original-receipt-returned" }, + credential_requests: 0, + mutations: 0, + }, + ]; + const client = createGitHubAgentClient({ + endpoint: "https://operator.example", + fetch: async () => new Response(JSON.stringify(githubResponses.shift()), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + const boundary = await client.boundary(); + if (boundary.branchBudget !== 1 || boundary.agentCredentialPresent !== false) { + throw new Error("packed GitHub boundary widened"); + } + const githubSession = await client.delegate({ + repository: boundary.repository, + issueNumber: boundary.issueNumber, + baseRef: boundary.baseRef, + baseRevision: boundary.baseRevision, + allowedPaths: boundary.allowedPaths, + protectedPaths: boundary.protectedPaths, + expiresInSeconds: boundary.maximumExpirySeconds, + branchBudget: 1, + draftPullRequestBudget: 1, + agentLabel: "packed-consumer", + }); + const inspected = await client.inspectFixture(githubSession, "exact"); + const completed = await client.execute(githubSession); + const verified = await client.verifyReceipts(githubSession); + const replayed = await client.replay(githubSession); + if (inspected.kind !== "inspected" || completed.kind !== "completed" || verified.kind !== "verified") { + throw new Error("packed GitHub journey did not complete"); + } + if (replayed.kind !== "replayed" || replayed.credentialRequests !== 0 || replayed.mutations !== 0) { + throw new Error("packed GitHub replay was not bounded"); + } `); await writeFile(join(directory, "consumer.ts"), ` import { approval, createAuths, doctor, type Auths, type AuthsErrorCode, type DoctorReport, type EffectState, type Outcome, type RetryClass } from "@auths-dev/sdk"; import { loadIdentity } from "@auths-dev/sdk/identity"; import { inspectDecision, verifyReceipt } from "@auths-dev/sdk/verify"; import { createServiceClient, githubIssueAddress, opentofuSavedPlanApply, postgresqlBoundedUpdate, type NextCall, type ServiceClient } from "@auths-dev/sdk/service"; + import { createGitHubAgentClient, type GitHubAgentClient, type GitHubAgentTask } from "@auths-dev/sdk/service"; import { mcp, type McpAction } from "@auths-dev/sdk/profiles"; import { development } from "@auths-dev/sdk/integrations"; import type { AtomicReservationStore, Signer } from "@auths-dev/sdk/framework"; @@ -56,6 +142,7 @@ test("packed package exposes only the reviewed public topology", async () => { void approval; void createAuths; void doctor; void loadIdentity; void inspectDecision; void verifyReceipt; void githubIssueAddress; void mcp; void opentofuSavedPlanApply; void postgresqlBoundedUpdate; void development; void certifyAtomicStore; void createServiceClient; void fixtures; + void createGitHubAgentClient; declare const auths: Auths; declare const service: ServiceClient; declare const code: AuthsErrorCode; @@ -68,8 +155,11 @@ test("packed package exposes only the reviewed public topology", async () => { declare const next: NextCall; declare const effect: EffectState; declare const outcome: Outcome; + declare const githubAgent: GitHubAgentClient; + declare const githubTask: GitHubAgentTask; void auths; void service; void code; void action; void store; void signer; void report; void retry; void next; void effect; void outcome; + void githubAgent; void githubTask; `); await writeFile(join(directory, "tsconfig.json"), JSON.stringify({ compilerOptions: { diff --git a/bindings/typescript/test/unit/github-agent.test.js b/bindings/typescript/test/unit/github-agent.test.js new file mode 100644 index 00000000..ec032ab8 --- /dev/null +++ b/bindings/typescript/test/unit/github-agent.test.js @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createGitHubAgentClient } from "../../dist/service.js"; + +const schema = "auths-github-agent/v1"; + +test("typed GitHub task projects to the closed launch API", async () => { + const calls = []; + const responses = [ + { + schema, + repository: "auths-dev/example", + issue_number: 123, + base_ref: "main", + base_revision: "a".repeat(40), + allowed_paths: ["src/**", "tests/**"], + denied_paths: [".github/**"], + budgets: { branches: 1, draft_pull_requests: 1 }, + expiry: { minimum_seconds: 60, maximum_seconds: 900 }, + agent_credential_present: false, + }, + { + schema, + session_id: "1".repeat(32), + workflow_id: "demo-" + "1".repeat(32), + expires_at: 1000, + target_ref: "auths/issue-123-abcdef123456", + agent_principal: "urn:auths:raw-key:agent", + required_configuration: "2".repeat(64), + executed_configuration: "2".repeat(64), + }, + { + schema, + candidate: { + status: "denied", + changed_paths: [], + direct_push: { result: "not-attempted" }, + preview: { + code: "path-explicitly-denied", + credential_would_be_requested: false, + }, + }, + }, + { + schema, + decision: { class: "denied", code: "path-explicitly-denied" }, + execution: { branch: "not-attempted", pull_request: "not-attempted" }, + credential_requests: 0, + mutations: 0, + }, + ]; + const client = createGitHubAgentClient({ + endpoint: "https://operator.example", + fetch: async (url, init) => { + calls.push({ url: String(url), body: init?.body }); + return new Response(JSON.stringify(responses.shift()), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + const boundary = await client.boundary(); + const task = { + repository: boundary.repository, + issueNumber: boundary.issueNumber, + baseRef: boundary.baseRef, + baseRevision: boundary.baseRevision, + allowedPaths: boundary.allowedPaths, + protectedPaths: boundary.protectedPaths, + expiresInSeconds: boundary.maximumExpirySeconds, + branchBudget: 1, + draftPullRequestBudget: 1, + agentLabel: "review-agent", + }; + const session = await client.delegate(task); + const inspection = await client.inspectFixture(session, "prohibited-path"); + const denied = await client.execute(session); + + assert.equal(inspection.kind, "denied"); + assert.equal(inspection.credentialWouldBeRequested, false); + assert.equal(denied.kind, "denied"); + assert.equal(denied.credentialRequests, 0); + assert.equal(denied.mutations, 0); + assert.deepEqual(JSON.parse(calls[1].body), { + repository: "auths-dev/example", + issueNumber: 123, + baseRef: "main", + baseRevision: "a".repeat(40), + allowedPaths: ["src/**", "tests/**"], + protectedPaths: [".github/**"], + expiresInSeconds: 900, + branchBudget: 1, + draftPullRequestBudget: 1, + agentLabel: "review-agent", + }); +}); + +test("GitHub sessions cannot be forged", async () => { + const client = createGitHubAgentClient({ + endpoint: "https://operator.example", + fetch: async () => { throw new Error("transport must not be reached"); }, + }); + await assert.rejects( + client.execute({ kind: "github-agent-session" }), + /forged Auths GitHub agent session/, + ); +}); + +test("a lost execute response requires reconciliation and never claims zero effects", async () => { + let calls = 0; + const client = createGitHubAgentClient({ + endpoint: "https://operator.example", + fetch: async () => { + calls += 1; + if (calls > 1) throw new Error("connection lost after request left the process"); + return new Response(JSON.stringify({ + schema, + session_id: "1".repeat(32), + workflow_id: "demo-" + "1".repeat(32), + expires_at: 1_000, + target_ref: "auths/issue-123-abcdef123456", + agent_principal: "urn:auths:raw-key:agent", + required_configuration: "2".repeat(64), + executed_configuration: "2".repeat(64), + }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + const session = await client.delegate({ + repository: "auths-dev/example", + issueNumber: 123, + baseRef: "main", + baseRevision: "a".repeat(40), + allowedPaths: ["src/**"], + protectedPaths: [".github/**"], + expiresInSeconds: 900, + branchBudget: 1, + draftPullRequestBudget: 1, + agentLabel: "review-agent", + }); + const outcome = await client.execute(session); + assert.deepEqual(outcome, { + kind: "indeterminate", + code: "transport-uncertain", + credentialRequests: "unknown", + mutations: "unknown", + next: "reconcile", + }); +}); diff --git a/compliance.toml b/compliance.toml index e5cae6c0..53d67068 100644 --- a/compliance.toml +++ b/compliance.toml @@ -452,12 +452,15 @@ principal_families = ["raw-key-v1"] signature_families = ["ed25519-v1"] profiles = ["auths.github.issue-address.branch-publish/1", "auths.github.issue-address.pull-request-open-draft/1"] transports = ["git-bundle", "https", "github-rest"] -configuration_inputs = ["executed-verifier-configuration", "github-app-installation", "repository-automation-policy", "required-verifier-configuration"] -security_state = ["branch-exclusive-lifecycle", "domain-recovery-record", "expiring-session", "pull-request-exclusive-lifecycle", "receipt-sink"] +configuration_inputs = ["agent-task-boundary", "executed-verifier-configuration", "github-app-installation", "repository-automation-policy", "required-verifier-configuration"] +security_state = ["branch-exclusive-lifecycle", "bounded-candidate-upload", "domain-recovery-record", "expiring-session", "pull-request-exclusive-lifecycle", "receipt-sink"] [packages.auths-github-demo.claims] demo-conformance-fixture = ["demos/github-issue/src/tests.rs#exact_flow_uses_real_auths_kernel_and_replay_mutates_nothing"] runtime-enforcement-boundary = [ + "demos/github-issue/src/app.rs#every_task_widening_is_rejected_before_session_creation", + "demos/github-issue/src/app.rs#candidate_api_is_closed_over_fixture_or_bounded_bundle_shapes", + "demos/github-issue/src/app.rs#unexpected_direct_push_acceptance_is_a_zero_effect_denial", "demos/github-issue/src/tests.rs#required_and_executed_configuration_mismatch_is_visible_and_never_gets_a_credential", "demos/github-issue/src/tests.rs#every_negative_demo_variant_is_a_native_denial_before_credentials", ] diff --git a/demos/github-issue/Cargo.toml b/demos/github-issue/Cargo.toml index 1296098f..41ff72ff 100644 --- a/demos/github-issue/Cargo.toml +++ b/demos/github-issue/Cargo.toml @@ -22,6 +22,7 @@ auths-sdk.workspace = true auths-signature.workspace = true auths-stores.workspace = true axum.workspace = true +base64ct.workspace = true ed25519-dalek.workspace = true getrandom.workspace = true hex.workspace = true diff --git a/demos/github-issue/README.md b/demos/github-issue/README.md new file mode 100644 index 00000000..b2e9750c --- /dev/null +++ b/demos/github-issue/README.md @@ -0,0 +1,130 @@ +# Auths GitHub agent launch path + +Give one agent authority to address one operator-approved GitHub issue without +giving that agent a GitHub mutation credential. The existing Auths executor +inspects the candidate, authorizes two exact effects, claims each effect before +requesting a GitHub App token, publishes one branch, opens one draft pull +request, and leaves signed receipts. + +This is the launch golden path for Auths. It extends the existing GitHub +vertical; it is not a second verifier, GitHub adapter, lifecycle engine, or +receipt implementation. + +## What is bounded + +The deployment operator pins the repository, issue, base ref, allowed and +protected paths, GitHub App installation, executor configuration, and receipt +key. A client may accept that exact boundary or refuse it; it cannot widen it. +One delegated task carries: + +- one repository and issue; +- the current exact base revision; +- the operator-approved path policy; +- an expiry between one and fifteen minutes; +- one branch publication and one draft pull request; and +- a visible agent label. + +The agent process creates a Git bundle. It receives no GitHub credential. + +From a credential-less clone pinned to the base revision returned by +`boundary()`, make the candidate commit and export it: + +```sh +git branch -f auths-candidate HEAD +git bundle create candidate.bundle refs/heads/auths-candidate +git rev-parse HEAD +``` + +Pass the printed object id as `AUTHS_GITHUB_CANDIDATE_REVISION`. The executor +does not check out or run candidate code. + +## TypeScript quickstart + +Install the packed/published SDK and run the maintained example: + +```sh +npm install @auths-dev/sdk@1.0.0-rc.1 +AUTHS_GITHUB_AGENT_ENDPOINT=https://your-executor.example \ +AUTHS_GITHUB_CANDIDATE_BUNDLE=./candidate.bundle \ +AUTHS_GITHUB_CANDIDATE_REVISION= \ +AUTHS_GITHUB_LIVE=1 \ +node examples/typescript/agent.mjs +``` + +## Python quickstart + +```sh +python -m pip install auths==1.0.0rc1 +AUTHS_GITHUB_AGENT_ENDPOINT=https://your-executor.example \ +AUTHS_GITHUB_CANDIDATE_BUNDLE=./candidate.bundle \ +AUTHS_GITHUB_CANDIDATE_REVISION= \ +AUTHS_GITHUB_LIVE=1 \ +python examples/python/agent.py +``` + +Both examples discover the approved boundary, delegate it, load the candidate +from a file, inspect it, execute only after inspection, handle reconciliation, +prove replay causes no second write, and ask the existing signed-receipt reader +to verify the resulting receipt timeline. They contain no CBOR, proof bytes, +canonicalization logic, or GitHub token. + +Use `AUTHS_GITHUB_FIXTURE=prohibited-path` without `AUTHS_GITHUB_LIVE=1` to +exercise the denial path. The fixture must report zero credential requests and +zero mutations. + +For a release-candidate smoke test, use the maintained opt-in wrapper. It +refuses to start without both the live guard and an explicit SDK choice: + +```sh +AUTHS_GITHUB_LIVE=1 AUTHS_GITHUB_SDK=typescript \ + ./tests/live-github-opt-in.sh +``` + +The endpoint, bundle, revision, and installed SDK must already be configured +as shown above. This script is intentionally absent from routine CI. + +## Operator boundary + +The native service is configured through the existing `AUTHS_GITHUB_*` +environment contract documented in [architecture.md](docs/architecture.md). +Run it only with an isolated fixture repository and a GitHub App whose +installation and permissions are scoped to that repository. Routine tests use +in-memory ports and server-owned fixtures; live GitHub mutation is always +explicitly opt-in. + +The generic `auths-node` reference stack and this live GitHub executor have +different jobs. `auths-node` proves the general production transport contract; +this service composes the complete GitHub-specific candidate/evidence/write +workflow. The SDK surface here is profile-specific and does not add arbitrary +JSON execution to the generic node. + +## Browser demo modes + +Do not open `web/index.html` through `file://`. Start the checked-in local +launcher from the repository root: + +```sh +./demos/github-issue/run-local.sh preview +``` + +Then open `http://127.0.0.1:4173`. Preview mode needs only Python and explains +the boundaries without claiming that Auths or GitHub ran. + +For the real workflow, export the documented `AUTHS_GITHUB_*` deployment +configuration and run: + +```sh +./demos/github-issue/run-local.sh live +``` + +The native Rust service serves both the web application and API at +`http://127.0.0.1:8080`, so live mode does not depend on a second static server. +The browser uses its own origin for the native API by default. A split +frontend/API deployment may set `window.AUTHS_GITHUB_API_BASE` before `app.js` +loads, but its Content Security Policy and native CORS allowlist must name that +exact origin. + +If the native API cannot create a session, the page switches visibly to +**guided preview**. Test-case selection and boundary explanations remain +interactive, while inspection, execution, GitHub effects, and receipts are +explicitly reported as not run. Preview mode never fabricates an Auths verdict. diff --git a/demos/github-issue/docs/architecture.md b/demos/github-issue/docs/architecture.md index 621eead8..4b33db58 100644 --- a/demos/github-issue/docs/architecture.md +++ b/demos/github-issue/docs/architecture.md @@ -116,6 +116,27 @@ This package is reusable product code. The demo crate supplies its concrete conf ## End-to-end execution +The launch API uses schema `auths-github-agent/v1`. TypeScript and Python +clients first read the operator-approved boundary, then repeat that boundary in +an explicit task request. The native service requires exact equality for +repository, issue, current base revision, path policy, and the one-branch plus +one-draft-PR budget. It accepts expiry only inside the one-to-fifteen-minute +window. This makes the SDK request an acknowledgement of the configured +boundary, not a way to widen it. + +The candidate endpoint accepts either a named repository-owned adversarial +fixture or a bounded base64url transport of an agent-produced Git bundle plus +its declared base and candidate revisions. Base64url is transport only: the +existing Rust `GitCandidateInspector` remains the sole parser and semantic +owner. The API body is capped at three MiB and the decoded bundle remains +subject to the stricter candidate policy. + +After inspection the service probes a direct push with every credential source +cleared. Anything other than a refusal closes the candidate as +`credential-boundary-failed`; execution then returns a zero-credential, +zero-mutation denial without entering the executor. This makes an ambient Git +credential a visible deployment failure instead of an accidental bypass. + ### 1. Session and human constraints The browser creates a 15-minute session. The native service reads the current `main` revision from GitHub and builds a `WorkflowGrant` that binds: @@ -138,7 +159,7 @@ The browser displays both the required configuration digest and the configuratio `GitCandidateInspector` parses the bundle without checking out or executing candidate code. It confirms ancestry, commit count, object types, paths, modes, byte limits, tree digest, bundle digest, and declared candidate revision. The malformed experiment uses a fixed 17-byte regression seed. -The demo also performs a credential-disabled dry-run push. Its expected result is authentication rejection, proving that the candidate-building agent boundary does not possess the GitHub credential. +The demo also performs a credential-disabled dry-run push. Its expected result is `refused-without-credential`; unexpected success closes execution. The cleared process environment—not a guess about GitHub's particular error response—proves that the candidate-building agent boundary does not possess the GitHub credential. ### 3. Fresh GitHub evidence diff --git a/demos/github-issue/docs/debugging.md b/demos/github-issue/docs/debugging.md index 5dbdc401..f453496a 100644 --- a/demos/github-issue/docs/debugging.md +++ b/demos/github-issue/docs/debugging.md @@ -2,13 +2,18 @@ This document records operational lessons from deploying the GitHub issue workflow demo to Vercel, Fly.io, and GitHub. It intentionally contains no secrets or private key material. -## Production endpoints +## Deployment endpoints -- Frontend: `https://auths-github-demo.vercel.app` -- Native API: `https://auths-issue-workflow.fly.dev` -- Health check: `https://auths-issue-workflow.fly.dev/healthz` +Endpoint names are deployment configuration, not source-code defaults. The +browser now uses its own origin for the native API unless +`window.AUTHS_GITHUB_API_BASE` is deliberately set before `app.js` loads. This +prevents a retired service hostname from disabling every meaningful control. -Treat these names as coordinated configuration. The Vercel Content Security Policy, Fly CORS origin, and pull-request receipt base URL all contain exact origins. +Prefer serving the frontend and API from one origin. If they are split, treat +the frontend origin, API origin, Content Security Policy, native CORS allowlist, +and pull-request receipt base URL as one reviewed configuration change. A +frontend deployment is not live merely because its static document loads: its +health route and session-creation route must succeed from the browser. ## Vercel @@ -48,17 +53,10 @@ Relative paths such as `./styles.css` would resolve beneath `/receipts/`. ### CSP and CORS must agree -The frontend Content Security Policy allows connections to: - -```text -https://auths-issue-workflow.fly.dev -``` - -The native service allows the origin: - -```text -https://auths-github-demo.vercel.app -``` +The checked-in frontend Content Security Policy allows same-origin connections +only. A deliberately split deployment must replace that policy with the exact +native API origin and configure the native service with the exact frontend +origin. If the frontend loads but every API request fails, inspect both: @@ -156,10 +154,10 @@ Deploy from the monorepo root: fly deploy --config demos/github-issue/fly.toml --remote-only ``` -Verify: +Verify the configured service origin: ```sh -curl -fsS https://auths-issue-workflow.fly.dev/healthz +curl -fsS https:///healthz ``` Expected fields include: @@ -330,4 +328,3 @@ Then validate production: 6. The literal receipt link in the PR opens directly. 7. The receipt page reports verified signatures and all expected envelopes. 8. The receipt page still works after a Fly restart. - diff --git a/demos/github-issue/docs/launch-golden-path.md b/demos/github-issue/docs/launch-golden-path.md new file mode 100644 index 00000000..284e6a43 --- /dev/null +++ b/demos/github-issue/docs/launch-golden-path.md @@ -0,0 +1,96 @@ +# GitHub agent launch golden path decision + +## Placement + +Extend `demos/github-issue`. Reusable semantics remain in +`product/integrations/auths-github`; TypeScript and Python project the live API. +No new repository, runtime, provider, state machine, receipt schema, or web app +is introduced. + +| Choice | Reuse | Drift risk | Installed-package realism | Credential isolation | Maintenance | Decision | +| --- | --- | --- | --- | --- | --- | --- | +| Extend `demos/github-issue` | Highest | Lowest | High | Existing GitHub App boundary | Lowest | Chosen | +| Add another demo | Medium | High | High | Would need to be rebuilt | High | Rejected | +| Add another repository | Low until release | Highest | Potentially high | Would need to be rebuilt | Highest | Rejected | + +## Reuse matrix + +| Needed capability | Existing owner and evidence | Decision | +| --- | --- | --- | +| Exact GitHub actions | `auths-github/src/types.rs:ExactGitHubAction`; profile digest tests | Reuse | +| Candidate inspection | `candidate.rs:GitCandidateInspector`; demo negative variants | Reuse | +| Authority/delegation | `demos/github-issue/src/fixture.rs:EphemeralAuthsAuthorizer` | Reuse | +| SDK transport | Existing binding packaging, HTTP/error conventions, public topology gates | Extend with one vertical projection | +| Canonical encoding | `types.rs::canonical_bytes` and `profile.rs::canonicalize` | Reuse; never encode in bindings | +| Receipt verification | `Ed25519JsonlReceiptSink::receipts_for_workflow` and persistent receipt route | Reuse | +| Replay/lifecycle | `auths-github/src/lifecycle.rs`, `workflow.rs`, and demo persistent store | Reuse | +| Recovery/reconciliation | `service.rs::reconcile` and `GitHubRecoveryRecordV1` | Reuse | +| Credentials/writes | `GitHubAppCredentialProvider`, `GitHubRestClient` | Reuse | +| Installed SDKs | Existing npm pack and Python wheel policies | Extend tests/examples | +| Demo UI | `demos/github-issue/web` | Extend request shape only | + +The genuine gaps were an explicit task request, a bounded external Git bundle +submission, and typed installed-package calls. The old API only selected a +server-owned experiment string. + +## UX + +Happy path: discover the operator boundary; preview repository, issue, base, +paths, expiry, and 1+1 budget; delegate; load a Git bundle; see inspection and +credential absence; execute; open the draft PR; verify receipts; replay and see +zero writes. + +Denial path: submit the protected-path fixture or a hostile bundle. The client +shows the Rust decision code, stage, zero credential requests, and zero +mutations. + +Recovery path: an uncertain provider outcome projects `next = reconcile`. +The client observes/reconciles the existing exact effect and never restarts it. +Transport loss after an operation leaves the process projects the same next +step with effect counters explicitly `unknown`, never zero. + +## Architecture + +```text +installed TypeScript/Python `service` entry point + -> typed auths-github-agent/v1 API + -> demos/github-issue native assembly + -> product/integrations/auths-github + -> candidate inspector + fresh evidence + Auths authorization + -> durable lifecycle claim + -> GitHub App credential boundary + -> exact GitHub write + observation + signed receipt +``` + +The submitted agent bundle crosses the untrusted boundary. The App credential +exists only below the durable claim boundary. The language bindings cannot +construct an authorized command or provider request. + +## APIs + +Both languages expose the same operations: + +```text +boundary +delegate(task) -> opaque session +inspectCandidate(session, file) -> inspected | denied +execute(session) -> completed | denied | indeterminate +replay(session) -> original receipt, zero writes +reconcile(session) -> observed exact outcome, zero repeated writes +verifyReceipts(session) -> verified timeline +``` + +The task has named domain values. Candidate bytes stay behind a file boundary; +session identifiers and receipts stay opaque. The server rejects repository, +issue, base, path, budget, expiry, and configuration widening before candidate +execution. + +## Residual operational assumptions + +- The operator installs the GitHub App only on an isolated approved repository. +- GitHub availability and postcondition convergence remain external facts; + ambiguity is retained as recovery state. +- Public deployment capacity and the daily mutation quota remain operator + policy, not authorization semantics. +- A clean-machine under-fifteen-minute usability cohort and registry-published + RC exercise remain release evidence, not facts this source change can claim. diff --git a/demos/github-issue/examples/python/agent.py b/demos/github-issue/examples/python/agent.py new file mode 100644 index 00000000..cabce2f5 --- /dev/null +++ b/demos/github-issue/examples/python/agent.py @@ -0,0 +1,79 @@ +"""Installed-package GitHub agent launch path.""" + +from __future__ import annotations + +import asyncio +import os + +from auths.service import ( + GitHubAgentTask, + GitHubCandidateFile, + create_github_agent_client, +) + + +def required(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} is required") + return value + + +async def main() -> None: + client = create_github_agent_client( + endpoint=required("AUTHS_GITHUB_AGENT_ENDPOINT") + ) + boundary = await client.boundary() + session = await client.delegate( + GitHubAgentTask( + repository=boundary.repository, + issue_number=boundary.issue_number, + base_ref=boundary.base_ref, + base_revision=boundary.base_revision, + allowed_paths=boundary.allowed_paths, + protected_paths=boundary.protected_paths, + expires_in_seconds=boundary.maximum_expiry_seconds, + branch_budget=1, + draft_pull_request_budget=1, + agent_label=os.environ.get("AUTHS_AGENT_LABEL", "launch-agent"), + ) + ) + fixture = os.environ.get("AUTHS_GITHUB_FIXTURE") + if fixture: + inspection = await client.inspect_fixture(session, fixture) # type: ignore[arg-type] + else: + inspection = await client.inspect_candidate( + session, + GitHubCandidateFile( + path=required("AUTHS_GITHUB_CANDIDATE_BUNDLE"), + base_revision=boundary.base_revision, + candidate_revision=required("AUTHS_GITHUB_CANDIDATE_REVISION"), + ), + ) + print("candidate", inspection) + if fixture: + denied = await client.execute(session) + assert denied.kind == "denied" + assert denied.credential_requests == 0 + assert denied.mutations == 0 + print("denied safely", denied.code) + return + if os.environ.get("AUTHS_GITHUB_LIVE") != "1": + raise RuntimeError( + "set AUTHS_GITHUB_LIVE=1 to permit the isolated draft-PR effect" + ) + assert inspection.kind == "inspected" + outcome = await client.execute(session) + if outcome.next == "reconcile": + outcome = await client.reconcile(session) + assert outcome.kind in ("completed", "reconciled") + verified = await client.verify_receipts(session) + assert verified.kind == "verified" + replay = await client.replay(session) + assert replay.kind == "replayed" + assert replay.credential_requests == 0 + assert replay.mutations == 0 + print("completed", outcome.pull_request_url, verified) + + +asyncio.run(main()) diff --git a/demos/github-issue/examples/python/agent_ideal.py b/demos/github-issue/examples/python/agent_ideal.py new file mode 100644 index 00000000..5a171dd6 --- /dev/null +++ b/demos/github-issue/examples/python/agent_ideal.py @@ -0,0 +1,91 @@ +"""Ideal AP-SPEC-040 GitHub agent workflow. + +This is a target-API example, not an example of the currently implemented +package. It keeps candidate inspection, effects, reconciliation, receipts, +and replay explicit while making proof creation and verification the two-call +center of the workflow. +""" + +from __future__ import annotations + +import asyncio +import os + +from auths.profiles import github_issue_address +from auths.service import connect + + +def required(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"{name} is required") + return value + + +async def main() -> None: + auths = connect( + endpoint=required("AUTHS_GITHUB_AGENT_ENDPOINT"), + profile=github_issue_address(), + ) + + # The deployment owns the repository, issue, base revision, path policy, + # and effect budgets. The caller may narrow expiry and choose a label, but + # cannot copy, edit, or widen that configured boundary. + async with await auths.delegate( + agent_label=os.environ.get("AUTHS_AGENT_LABEL", "launch-agent"), + expires_in_seconds=15 * 60, + ) as agent: + print("bounded task", agent.boundary) + + # Inspection remains explicit: it parses a hostile Git bundle without + # running candidate code. The scoped agent supplies its bound base. + fixture = os.environ.get("AUTHS_GITHUB_FIXTURE") + inspection = ( + await agent.inspect(fixture=fixture) + if fixture + else await agent.inspect( + bundle=required("AUTHS_GITHUB_CANDIDATE_BUNDLE"), + candidate_revision=required("AUTHS_GITHUB_CANDIDATE_REVISION"), + ) + ) + + # The ordinary Auths proof workflow: create, then verify. + proof = await agent.create(inspection) + verification = await agent.verify(proof) + + if not verification.passed: + if verification.kind == "indeterminate": + raise RuntimeError( + "verification needs trusted input: " + f"{verification.code} ({verification.request_id})" + ) + if not fixture: + raise RuntimeError(f"unexpected denial: {verification.code}") + print("denied safely", verification.code) + return + + if fixture: + raise RuntimeError("a denial fixture unexpectedly produced a verified proof") + if os.environ.get("AUTHS_GITHUB_LIVE") != "1": + raise RuntimeError( + "set AUTHS_GITHUB_LIVE=1 to permit the isolated draft-PR effect" + ) + + # Verification never performs an effect. Only the sealed verified + # value can cross the executor boundary. + outcome = await agent.execute(verification.verified) + if outcome.kind == "indeterminate" and outcome.next == "reconcile": + outcome = await agent.reconcile(outcome.reference) + if outcome.kind not in ("completed", "reconciled"): + raise RuntimeError(f"workflow did not complete: {outcome.code}") + + # Receipt authenticity and replay remain separate from authorization. + receipts = await agent.verify_receipts() + replay = await agent.replay() + if replay.kind != "replayed" or replay.mutations != 0: + raise RuntimeError("replay attempted another GitHub mutation") + + print("completed", outcome.pull_request_url, receipts) + + +asyncio.run(main()) diff --git a/demos/github-issue/examples/typescript/agent.mjs b/demos/github-issue/examples/typescript/agent.mjs new file mode 100644 index 00000000..201359b0 --- /dev/null +++ b/demos/github-issue/examples/typescript/agent.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { createGitHubAgentClient } from "@auths-dev/sdk/service"; + +const endpoint = required("AUTHS_GITHUB_AGENT_ENDPOINT"); +const client = createGitHubAgentClient({ endpoint }); +const boundary = await client.boundary(); +const session = await client.delegate({ + repository: boundary.repository, + issueNumber: boundary.issueNumber, + baseRef: boundary.baseRef, + baseRevision: boundary.baseRevision, + allowedPaths: boundary.allowedPaths, + protectedPaths: boundary.protectedPaths, + expiresInSeconds: boundary.maximumExpirySeconds, + branchBudget: 1, + draftPullRequestBudget: 1, + agentLabel: process.env.AUTHS_AGENT_LABEL ?? "launch-agent", +}); + +const fixture = process.env.AUTHS_GITHUB_FIXTURE; +const inspection = fixture + ? await client.inspectFixture(session, fixture) + : await client.inspectCandidate(session, { + path: required("AUTHS_GITHUB_CANDIDATE_BUNDLE"), + baseRevision: boundary.baseRevision, + candidateRevision: required("AUTHS_GITHUB_CANDIDATE_REVISION"), + }); + +console.log("candidate", inspection); +if (fixture) { + const denied = await client.execute(session); + assert.equal(denied.kind, "denied"); + assert.equal(denied.credentialRequests, 0); + assert.equal(denied.mutations, 0); + console.log("denied safely", denied.code); + process.exit(0); +} +if (process.env.AUTHS_GITHUB_LIVE !== "1") { + throw new Error("set AUTHS_GITHUB_LIVE=1 to permit the isolated draft-PR effect"); +} +assert.equal(inspection.kind, "inspected"); +let outcome = await client.execute(session); +if (outcome.next === "reconcile") outcome = await client.reconcile(session); +assert.ok(outcome.kind === "completed" || outcome.kind === "reconciled"); +const verified = await client.verifyReceipts(session); +assert.equal(verified.kind, "verified"); +const replay = await client.replay(session); +assert.equal(replay.kind, "replayed"); +assert.equal(replay.credentialRequests, 0); +assert.equal(replay.mutations, 0); +console.log("completed", outcome.pullRequestUrl, verified); + +function required(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} diff --git a/demos/github-issue/examples/typescript/agent_ideal.mjs b/demos/github-issue/examples/typescript/agent_ideal.mjs new file mode 100644 index 00000000..fbfef847 --- /dev/null +++ b/demos/github-issue/examples/typescript/agent_ideal.mjs @@ -0,0 +1,95 @@ +/** + * Ideal AP-SPEC-040 GitHub agent workflow. + * + * This is a target-API example, not an example of the currently implemented + * package. It deliberately keeps candidate inspection, effects, + * reconciliation, receipts, and replay separate while making proof creation + * and verification the two-call center of the workflow. + */ +import { connect } from "@auths-dev/sdk/service"; +import { githubIssueAddress } from "@auths-dev/sdk/profiles"; + +const auths = connect({ + endpoint: required("AUTHS_GITHUB_AGENT_ENDPOINT"), + profile: githubIssueAddress(), +}); + +// The deployment owns the repository, issue, base revision, path policy, and +// effect budgets. The caller can narrow expiry and choose a label, but cannot +// copy, edit, or widen the configured boundary. +const agent = await auths.delegate({ + agentLabel: process.env.AUTHS_AGENT_LABEL ?? "launch-agent", + expiresInSeconds: 15 * 60, +}); + +try { + await run(agent); +} finally { + await agent.close(); +} + +async function run(scopedAgent) { + console.log("bounded task", scopedAgent.boundary); + + // Inspection remains explicit: it parses a hostile Git bundle without + // running candidate code. The scoped agent supplies the bound base revision. + const fixture = process.env.AUTHS_GITHUB_FIXTURE; + const inspection = fixture + ? await scopedAgent.inspect({ fixture }) + : await scopedAgent.inspect({ + bundle: required("AUTHS_GITHUB_CANDIDATE_BUNDLE"), + candidateRevision: required("AUTHS_GITHUB_CANDIDATE_REVISION"), + }); + + // The ordinary Auths proof workflow: create, then verify. + const proof = await scopedAgent.create(inspection); + const verification = await scopedAgent.verify(proof); + + if (!verification.passed) { + if (verification.kind === "indeterminate") { + throw new Error( + `verification needs trusted input: ${verification.code} (${verification.requestId})`, + ); + } + if (!fixture) { + throw new Error(`unexpected denial: ${verification.code}`); + } + console.log("denied safely", verification.code); + return; + } + + if (fixture) { + throw new Error("a denial fixture unexpectedly produced a verified proof"); + } + if (process.env.AUTHS_GITHUB_LIVE !== "1") { + throw new Error( + "set AUTHS_GITHUB_LIVE=1 to permit the isolated draft-PR effect", + ); + } + + // Verification never performs an effect. Only the sealed verified value can + // cross the executor boundary. + let outcome = await scopedAgent.execute(verification.verified); + if (outcome.kind === "indeterminate" && outcome.next === "reconcile") { + outcome = await scopedAgent.reconcile(outcome.reference); + } + if (outcome.kind !== "completed" && outcome.kind !== "reconciled") { + throw new Error(`workflow did not complete: ${outcome.code}`); + } + + // Receipt authenticity and effect replay are separate from proof + // authorization, so they keep distinct operations. + const receipts = await scopedAgent.verifyReceipts(); + const replay = await scopedAgent.replay(); + if (replay.kind !== "replayed" || replay.mutations !== 0) { + throw new Error("replay attempted another GitHub mutation"); + } + + console.log("completed", outcome.pullRequestUrl, receipts); +} + +function required(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} diff --git a/demos/github-issue/run-local.sh b/demos/github-issue/run-local.sh new file mode 100755 index 00000000..6fdb0c85 --- /dev/null +++ b/demos/github-issue/run-local.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +demo_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "$demo_directory/../.." && pwd)" +mode="${1:-preview}" + +case "$mode" in + preview) + preview_port="${AUTHS_GITHUB_PREVIEW_PORT:-4173}" + echo "Auths GitHub guided preview: http://127.0.0.1:${preview_port}" + echo "This mode explains boundaries but does not execute Auths or GitHub actions." + cd "$demo_directory/web" + exec python3 -m http.server "$preview_port" --bind 127.0.0.1 + ;; + live) + live_port="${PORT:-8080}" + echo "Auths GitHub live demo: http://127.0.0.1:${live_port}" + echo "Live mode requires the documented AUTHS_GITHUB_* environment." + cd "$repository_root" + exec cargo run --locked -p auths-github-demo + ;; + *) + echo "usage: $0 [preview|live]" >&2 + exit 2 + ;; +esac diff --git a/demos/github-issue/src/app.rs b/demos/github-issue/src/app.rs index 0f4c19f1..deb143db 100644 --- a/demos/github-issue/src/app.rs +++ b/demos/github-issue/src/app.rs @@ -25,10 +25,11 @@ use auths_github::{ use axum::{ Json, Router, extract::{DefaultBodyLimit, Path, State}, - http::{HeaderValue, Method, StatusCode, header::CONTENT_TYPE}, + http::{HeaderName, HeaderValue, Method, StatusCode, header::CONTENT_TYPE}, response::{IntoResponse, Response}, routing::{get, post}, }; +use base64ct::{Base64UrlUnpadded, Encoding as _}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::sync::Mutex; @@ -42,12 +43,16 @@ use crate::{ }, }; -const API_SCHEMA: &str = "auths-github-demo/v1"; +const API_SCHEMA: &str = "auths-github-agent/v1"; const SESSION_TTL_SECONDS: u64 = 15 * 60; +const MIN_SESSION_TTL_SECONDS: u64 = 60; const MAX_SESSIONS: usize = 2_048; const MAX_ATTEMPTS: u8 = 8; -const MAX_REQUEST_BYTES: usize = 4 * 1024; +// A two-MiB candidate bundle expands to less than three MiB as base64url. The +// product inspector still enforces the smaller decoded candidate-policy bound. +const MAX_REQUEST_BYTES: usize = 3 * 1024 * 1024; const MAX_DAILY_PUBLICATIONS: u64 = 25; +const WEB_CONTENT_SECURITY_POLICY: &str = "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"; type LiveWorkflowService = GitHubIssueWorkflowService< Arc, @@ -461,12 +466,39 @@ struct Session { outcome: Option, receipts: Vec, executed_once: bool, + agent_label: String, + direct_push_safe: Option, } #[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct CandidateRequest { - experiment: String, +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TaskRequest { + repository: String, + issue_number: u64, + base_ref: String, + base_revision: String, + allowed_paths: Vec, + protected_paths: Vec, + expires_in_seconds: u64, + branch_budget: u8, + draft_pull_request_budget: u8, + agent_label: String, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +enum CandidateRequest { + Fixture { + experiment: String, + }, + Bundle { + #[serde(rename = "bundleBase64url")] + bundle_base64url: String, + #[serde(rename = "baseRevision")] + base_revision: String, + #[serde(rename = "candidateRevision")] + candidate_revision: String, + }, } /// Builds the live GitHub demo API. @@ -485,6 +517,12 @@ pub fn app(config: AppConfig) -> Result { quota_lock: Arc::new(StdMutex::new(())), }; Ok(Router::new() + .route("/", get(web_index)) + .route("/app.js", get(web_app_script)) + .route("/receipt.js", get(web_receipt_script)) + .route("/styles.css", get(web_styles)) + .route("/receipt", get(web_receipt)) + .route("/receipts/{session_id}", get(web_receipt)) .route("/healthz", get(health)) .route("/v1/demo/scenario", get(scenario)) .route("/v1/demo/sessions", post(create_session)) @@ -503,6 +541,57 @@ pub fn app(config: AppConfig) -> Result { .with_state(state)) } +async fn web_index() -> Response { + web_asset( + include_str!("../web/index.html"), + "text/html; charset=utf-8", + ) +} + +async fn web_receipt() -> Response { + web_asset( + include_str!("../web/receipt.html"), + "text/html; charset=utf-8", + ) +} + +async fn web_app_script() -> Response { + web_asset( + include_str!("../web/app.js"), + "text/javascript; charset=utf-8", + ) +} + +async fn web_receipt_script() -> Response { + web_asset( + include_str!("../web/receipt.js"), + "text/javascript; charset=utf-8", + ) +} + +async fn web_styles() -> Response { + web_asset(include_str!("../web/styles.css"), "text/css; charset=utf-8") +} + +fn web_asset(body: &'static str, content_type: &'static str) -> Response { + let mut response = body.into_response(); + let headers = response.headers_mut(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); + headers.insert( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static(WEB_CONTENT_SECURITY_POLICY), + ); + headers.insert( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("no-referrer"), + ); + headers.insert( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + response +} + /// Runs the configured service. /// /// # Errors @@ -528,33 +617,34 @@ async fn health(State(state): State) -> Json { })) } -async fn scenario(State(state): State) -> Json { - Json(json!({ +async fn scenario(State(state): State) -> Result, ApiError> { + let base_revision = current_base_revision(&state)?; + Ok(Json(json!({ "schema": API_SCHEMA, "profile": "auths.github.issue-address/1", "repository": state.config.repository.slug(), "repository_id": state.config.repository.repository_id(), "issue_number": state.config.issue.issue_number(), "base_ref": state.config.base_ref, + "base_revision": base_revision, "allowed_paths": candidate_policy().allowed_paths, "denied_paths": candidate_policy().denied_paths, "budgets": {"branches": 1, "draft_pull_requests": 1}, + "expiry": {"minimum_seconds": MIN_SESSION_TTL_SECONDS, "maximum_seconds": SESSION_TTL_SECONDS}, "agent_credential_present": false, "region": &*state.config.region, "release": &*state.config.release, "experiments": experiment_projection(), - })) + }))) } -async fn create_session(State(state): State) -> Result, ApiError> { +async fn create_session( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { let now = unix_time().map_err(|()| ApiError::internal())?; - let base = state - .config - .github - .ref_state(&state.config.repository, &state.config.base_ref) - .map_err(|_| ApiError::unavailable("github-evidence", "GitHub base ref is unavailable"))? - .revision - .ok_or_else(|| ApiError::unavailable("base-missing", "configured base ref is missing"))?; + let base = current_base_revision(&state)?; + validate_task_request(&state, &request, &base)?; let (session_id, workflow_id) = random_session_ids()?; let grant = workflow_grant( workflow_id, @@ -564,16 +654,21 @@ async fn create_session(State(state): State) -> Result, Ap base, state.config.verifier_configuration.clone(), now, + request.expires_in_seconds, ) .map_err(|_| ApiError::internal())?; + let expires_at = grant.expires_at(); let mut human_seed = [0_u8; 32]; let mut workflow_seed = [0_u8; 32]; let mut agent_seed = [0_u8; 32]; getrandom::fill(&mut human_seed).map_err(|_| ApiError::internal())?; getrandom::fill(&mut workflow_seed).map_err(|_| ApiError::internal())?; getrandom::fill(&mut agent_seed).map_err(|_| ApiError::internal())?; + let agent_principal = EphemeralAuthsAuthorizer::new(human_seed, workflow_seed, agent_seed) + .agent_principal() + .map_err(|_| ApiError::internal())?; let session = Session { - expires_at: now + SESSION_TTL_SECONDS, + expires_at, attempts: 0, grant: grant.clone(), required_configuration: state.config.verifier_configuration.clone(), @@ -586,6 +681,8 @@ async fn create_session(State(state): State) -> Result, Ap outcome: None, receipts: Vec::new(), executed_once: false, + agent_label: request.agent_label, + direct_push_safe: None, }; let mut sessions = state.sessions.lock().await; sessions.retain(|_, session| session.expires_at > now); @@ -599,10 +696,11 @@ async fn create_session(State(state): State) -> Result, Ap Ok(Json(json!({ "schema": API_SCHEMA, "session_id": session_id, - "expires_at": now + SESSION_TTL_SECONDS, + "expires_at": expires_at, "workflow_id": grant.workflow_id(), "base_revision": grant.base_revision(), "target_ref": grant.target_ref().map_err(|_| ApiError::internal())?, + "agent_principal": agent_principal.as_str(), "required_configuration": grant.required_configuration().digest().map_err(|_| ApiError::internal())?, "executed_configuration": state.config.verifier_configuration.digest().map_err(|_| ApiError::internal())?, }))) @@ -623,12 +721,40 @@ async fn submit_candidate( Path(session_id): Path, Json(request): Json, ) -> Result, ApiError> { - let variant = DemoVariant::parse(&request.experiment).ok_or_else(|| { - ApiError::bad_request( - "unknown-experiment", - "experiment is not one of the server-owned fixtures", - ) - })?; + let (variant, submitted) = match request { + CandidateRequest::Fixture { experiment } => { + let variant = DemoVariant::parse(&experiment).ok_or_else(|| { + ApiError::bad_request( + "unknown-experiment", + "experiment is not one of the server-owned fixtures", + ) + })?; + (variant, None) + } + CandidateRequest::Bundle { + bundle_base64url, + base_revision, + candidate_revision, + } => { + let bundle = Base64UrlUnpadded::decode_vec(&bundle_base64url).map_err(|_| { + ApiError::bad_request("candidate-malformed", "candidate bundle is not base64url") + })?; + let base_revision = GitOid::parse(base_revision).map_err(|_| { + ApiError::bad_request("candidate-malformed", "base revision is invalid") + })?; + let candidate_revision = GitOid::parse(candidate_revision).map_err(|_| { + ApiError::bad_request("candidate-malformed", "candidate revision is invalid") + })?; + ( + DemoVariant::Exact, + Some(CandidateSubmission { + bundle, + base_revision, + candidate_revision, + }), + ) + } + }; let now = unix_time().map_err(|()| ApiError::internal())?; let (grant, attempts) = { let mut sessions = state.sessions.lock().await; @@ -639,20 +765,24 @@ async fn submit_candidate( } (session.grant.clone(), session.attempts) }; - let candidate = build_candidate( - &state.config.git_executable, - &state.config.repository_url, - grant.base_revision(), - grant.workflow_id(), - variant, - ) - .map_err(|_| { - ApiError::unavailable("candidate-build", "candidate fixture could not be built") - })?; + let candidate = if let Some(candidate) = submitted { + candidate + } else { + build_candidate( + &state.config.git_executable, + &state.config.repository_url, + grant.base_revision(), + grant.workflow_id(), + variant, + ) + .map_err(|_| { + ApiError::unavailable("candidate-build", "candidate fixture could not be built") + })? + }; let inspector = GitCandidateInspector::new(state.config.git_executable.clone()) .map_err(|_| ApiError::internal())?; let inspection = inspector.inspect(&candidate, grant.candidate_policy(), grant.object_format()); - let projection = match inspection { + let (projection, direct_push_safe) = match inspection { Ok(inspected) => { let direct_push_rejected = direct_push_is_rejected( &state.config.git_executable, @@ -662,42 +792,59 @@ async fn submit_candidate( grant.workflow_id(), ) .unwrap_or(false); + let preview = if direct_push_rejected { + variant_preview(variant) + } else { + json!({ + "class": "denied", + "code": "credential-boundary-failed", + "stage": "credential-isolation", + "credential_would_be_requested": false, + }) + }; + ( + json!({ + "status": if direct_push_rejected { "inspected" } else { "denied" }, + "candidate_revision": inspected.evidence().candidate_revision(), + "candidate_tree": inspected.evidence().candidate_tree(), + "bundle_digest": inspected.evidence().bundle_digest(), + "change_set_digest": inspected.evidence().change_set_digest(), + "changed_paths": inspected.evidence().changed_paths(), + "commit_count": inspected.evidence().commit_count(), + "object_count": inspected.evidence().object_count(), + "added_bytes": inspected.evidence().added_bytes(), + "deleted_bytes": inspected.evidence().deleted_bytes(), + "direct_push": { + "credential_present": false, + "result": if direct_push_rejected { + "refused-without-credential" + } else { + "unexpectedly-accepted" + }, + }, + "preview": preview, + }), + direct_push_rejected, + ) + } + Err(error) => ( json!({ - "status": "inspected", - "candidate_revision": inspected.evidence().candidate_revision(), - "candidate_tree": inspected.evidence().candidate_tree(), - "bundle_digest": inspected.evidence().bundle_digest(), - "change_set_digest": inspected.evidence().change_set_digest(), - "changed_paths": inspected.evidence().changed_paths(), - "commit_count": inspected.evidence().commit_count(), - "object_count": inspected.evidence().object_count(), - "added_bytes": inspected.evidence().added_bytes(), - "deleted_bytes": inspected.evidence().deleted_bytes(), + "status": "denied", + "error": error.to_string(), + "preview": variant_preview(variant), "direct_push": { "credential_present": false, - "result": if direct_push_rejected { - "authentication-rejected" - } else { - "unexpectedly-accepted" - }, + "result": "not-attempted", }, - "preview": variant_preview(variant), - }) - } - Err(error) => json!({ - "status": "denied", - "error": error.to_string(), - "preview": variant_preview(variant), - "direct_push": { - "credential_present": false, - "result": "not-attempted", - }, - }), + }), + true, + ), }; let mut sessions = state.sessions.lock().await; let session = live_session_mut(&mut sessions, &session_id, now)?; session.variant = variant; session.candidate = Some(candidate); + session.direct_push_safe = Some(direct_push_safe); session.candidate_projection = Some(projection.clone()); session.outcome = None; Ok(Json(json!({ @@ -744,6 +891,9 @@ async fn execute_session( "publish the exact candidate before requesting replay", )); } + if snapshot.direct_push_safe == Some(false) { + return Ok(Json(credential_boundary_denial(&session_id))); + } let candidate = snapshot.candidate.clone().ok_or_else(|| { ApiError::bad_request( "candidate-required", @@ -774,6 +924,22 @@ async fn execute_session( record_outcome(&state, &session_id, now, outcome).await } +fn credential_boundary_denial(session_id: &str) -> Value { + json!({ + "schema": API_SCHEMA, + "session_id": session_id, + "entered_executor": false, + "credential_requests": 0, + "mutations": 0, + "decision": { + "class": "denied", + "code": "credential-boundary-failed", + "detail": "the candidate environment accepted an unauthenticated direct push", + }, + "execution": {"branch": "not-attempted", "pull_request": "not-attempted"}, + }) +} + fn live_workflow_service( state: &AppState, session: &Session, @@ -1269,6 +1435,7 @@ fn session_projection(session_id: &str, session: &Session) -> Value { "base_ref": session.grant.base_ref(), "base_revision": session.grant.base_revision(), "target_ref": session.grant.target_ref().ok(), + "agent_label": session.agent_label, "experiment": session.variant.as_str(), "candidate": session.candidate_projection, "outcome": session.outcome, @@ -1278,6 +1445,97 @@ fn session_projection(session_id: &str, session: &Session) -> Value { }) } +fn current_base_revision(state: &AppState) -> Result { + state + .config + .github + .ref_state(&state.config.repository, &state.config.base_ref) + .map_err(|_| ApiError::unavailable("github-evidence", "GitHub base ref is unavailable"))? + .revision + .ok_or_else(|| ApiError::unavailable("base-missing", "configured base ref is missing")) +} + +fn validate_task_request( + state: &AppState, + request: &TaskRequest, + current_base: &GitOid, +) -> Result<(), ApiError> { + validate_task_boundary( + request, + &state.config.repository.slug(), + state.config.issue.issue_number(), + &state.config.base_ref, + current_base, + ) +} + +fn validate_task_boundary( + request: &TaskRequest, + approved_repository: &str, + approved_issue: u64, + approved_base_ref: &RefName, + current_base: &GitOid, +) -> Result<(), ApiError> { + let policy = candidate_policy(); + let expected_base = GitOid::parse(&request.base_revision).map_err(|_| { + ApiError::bad_request( + "invalid-base-revision", + "base revision is not a Git object id", + ) + })?; + if request.repository != approved_repository { + return Err(ApiError::bad_request( + "repository-not-approved", + "task repository is not the operator-approved repository", + )); + } + if request.issue_number != approved_issue { + return Err(ApiError::bad_request( + "issue-not-approved", + "task issue is not the operator-approved issue", + )); + } + if request.base_ref != approved_base_ref.as_str() || expected_base != *current_base { + return Err(ApiError::bad_request( + "base-not-current", + "task base does not match the current operator-approved base", + )); + } + if request.allowed_paths != policy.allowed_paths + || request.protected_paths != policy.denied_paths + { + return Err(ApiError::bad_request( + "path-policy-not-approved", + "task paths do not exactly match the operator-approved policy", + )); + } + if request.branch_budget != 1 || request.draft_pull_request_budget != 1 { + return Err(ApiError::bad_request( + "budget-not-approved", + "the GitHub launch path permits exactly one branch and one draft pull request", + )); + } + if !(MIN_SESSION_TTL_SECONDS..=SESSION_TTL_SECONDS).contains(&request.expires_in_seconds) { + return Err(ApiError::bad_request( + "expiry-not-approved", + "task expiry is outside the bounded session window", + )); + } + if request.agent_label.is_empty() + || request.agent_label.len() > 64 + || !request + .agent_label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(ApiError::bad_request( + "agent-label-invalid", + "agent label is outside the bounded public vocabulary", + )); + } + Ok(()) +} + fn live_session<'a>( sessions: &'a BTreeMap, session_id: &str, @@ -1467,10 +1725,139 @@ impl IntoResponse for ApiError { self.status, Json(json!({ "schema": API_SCHEMA, - "error": self.code, + "code": self.code, "detail": self.detail, })), ) .into_response() } } + +#[cfg(test)] +mod launch_api_tests { + use super::*; + + fn task() -> TaskRequest { + let policy = candidate_policy(); + TaskRequest { + repository: "auths-dev/example".into(), + issue_number: 123, + base_ref: "main".into(), + base_revision: "a".repeat(40), + allowed_paths: policy.allowed_paths, + protected_paths: policy.denied_paths, + expires_in_seconds: SESSION_TTL_SECONDS, + branch_budget: 1, + draft_pull_request_budget: 1, + agent_label: "review-agent".into(), + } + } + + fn validates(request: &TaskRequest) -> bool { + validate_task_boundary( + request, + "auths-dev/example", + 123, + &RefName::parse("main").unwrap(), + &GitOid::parse("a".repeat(40)).unwrap(), + ) + .is_ok() + } + + #[test] + fn exact_operator_boundary_is_accepted() { + assert!(validates(&task())); + } + + #[test] + fn every_task_widening_is_rejected_before_session_creation() { + let mut request = task(); + request.repository = "attacker/example".into(); + assert!(!validates(&request)); + + let mut request = task(); + request.issue_number += 1; + assert!(!validates(&request)); + + let mut request = task(); + request.base_revision = "b".repeat(40); + assert!(!validates(&request)); + + let mut request = task(); + request.allowed_paths.push("**".into()); + assert!(!validates(&request)); + + let mut request = task(); + request.protected_paths.clear(); + assert!(!validates(&request)); + + let mut request = task(); + request.branch_budget = 2; + assert!(!validates(&request)); + + let mut request = task(); + request.expires_in_seconds = SESSION_TTL_SECONDS + 1; + assert!(!validates(&request)); + } + + #[test] + fn candidate_api_is_closed_over_fixture_or_bounded_bundle_shapes() { + let fixture: CandidateRequest = serde_json::from_value(json!({ + "kind": "fixture", + "experiment": "prohibited-path", + })) + .unwrap(); + assert!(matches!(fixture, CandidateRequest::Fixture { .. })); + + let bundle: CandidateRequest = serde_json::from_value(json!({ + "kind": "bundle", + "bundleBase64url": "YXV0aHM", + "baseRevision": "a".repeat(40), + "candidateRevision": "b".repeat(40), + })) + .unwrap(); + assert!(matches!(bundle, CandidateRequest::Bundle { .. })); + + assert!( + serde_json::from_value::(json!({ + "kind": "bundle", + "bundleBase64url": "YXV0aHM", + "baseRevision": "a".repeat(40), + "candidateRevision": "b".repeat(40), + "providerToken": "forbidden", + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "kind": "arbitrary-json", + "operation": "push", + })) + .is_err() + ); + } + + #[test] + fn unexpected_direct_push_acceptance_is_a_zero_effect_denial() { + let denial = credential_boundary_denial("session"); + assert_eq!(denial["entered_executor"], false); + assert_eq!(denial["credential_requests"], 0); + assert_eq!(denial["mutations"], 0); + assert_eq!(denial["decision"]["class"], "denied"); + assert_eq!(denial["decision"]["code"], "credential-boundary-failed"); + } + + #[test] + fn native_service_embeds_the_interactive_web_shell() { + let index = include_str!("../web/index.html"); + let script = include_str!("../web/app.js"); + + assert!(index.contains("id=\"inspect\"")); + assert!(index.contains("id=\"execute\"")); + assert!(!index.contains("id=\"pull-request-link\" href=\"#\"")); + assert!(script.contains("window.location.origin")); + assert!(script.contains("Explain selected case")); + assert!(script.contains("pullRequestLink.removeAttribute(\"href\")")); + assert!(!script.contains("auths-issue-workflow.fly.dev")); + } +} diff --git a/demos/github-issue/src/fixture.rs b/demos/github-issue/src/fixture.rs index 8b23e3ca..b3c4d34d 100644 --- a/demos/github-issue/src/fixture.rs +++ b/demos/github-issue/src/fixture.rs @@ -47,6 +47,16 @@ impl EphemeralAuthsAuthorizer { agent: agent_seed, } } + + /// Returns the concrete principal receiving the session authority. + /// + /// # Errors + /// + /// Returns an adapter failure if the generated Ed25519 key cannot form a + /// raw-key principal. + pub fn agent_principal(&self) -> Result { + Ok(Identity::new(self.agent)?.principal) + } } impl ExactActionAuthorizer for EphemeralAuthsAuthorizer { diff --git a/demos/github-issue/src/scenario.rs b/demos/github-issue/src/scenario.rs index 18256385..d30c5dbb 100644 --- a/demos/github-issue/src/scenario.rs +++ b/demos/github-issue/src/scenario.rs @@ -105,7 +105,7 @@ pub fn verifier_configuration( .map_err(|_| ScenarioError) } -/// Builds one fifteen-minute workflow grant from the current exact base. +/// Builds one bounded workflow grant from the current exact base. pub fn workflow_grant( workflow_id: WorkflowId, repository: auths_github::RepositoryResource, @@ -114,6 +114,7 @@ pub fn workflow_grant( base_revision: GitOid, configuration: VerifierConfiguration, now: u64, + expires_in_seconds: u64, ) -> Result { WorkflowGrant::new(WorkflowGrantInput { workflow_id, @@ -126,7 +127,7 @@ pub fn workflow_grant( publication_policy: PublicationPolicy::one_draft_pull_request(), executor_audience: configuration.executor_audience().clone(), issued_at: now, - expires_at: now + 15 * 60, + expires_at: now.checked_add(expires_in_seconds).ok_or(ScenarioError)?, required_configuration: configuration, }) .map_err(|_| ScenarioError) diff --git a/demos/github-issue/src/tests.rs b/demos/github-issue/src/tests.rs index ef39901d..26983642 100644 --- a/demos/github-issue/src/tests.rs +++ b/demos/github-issue/src/tests.rs @@ -399,6 +399,7 @@ impl Fixture { self.base.clone(), self.configuration.clone(), NOW, + 15 * 60, ) .unwrap(); ExecuteWorkflowRequest { @@ -428,6 +429,7 @@ impl Fixture { self.base.clone(), self.configuration.clone(), NOW, + 15 * 60, ) .unwrap(); ExecuteWorkflowRequest { diff --git a/demos/github-issue/tests/live-github-opt-in.sh b/demos/github-issue/tests/live-github-opt-in.sh new file mode 100755 index 00000000..909dbfc2 --- /dev/null +++ b/demos/github-issue/tests/live-github-opt-in.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${AUTHS_GITHUB_LIVE:-}" != "1" ]]; then + echo "refusing GitHub mutation: set AUTHS_GITHUB_LIVE=1 for an isolated repository" >&2 + exit 2 +fi + +: "${AUTHS_GITHUB_AGENT_ENDPOINT:?AUTHS_GITHUB_AGENT_ENDPOINT is required}" +: "${AUTHS_GITHUB_CANDIDATE_BUNDLE:?AUTHS_GITHUB_CANDIDATE_BUNDLE is required}" +: "${AUTHS_GITHUB_CANDIDATE_REVISION:?AUTHS_GITHUB_CANDIDATE_REVISION is required}" + +case "${AUTHS_GITHUB_SDK:-}" in + typescript) + exec node "$(dirname "$0")/../examples/typescript/agent.mjs" + ;; + python) + exec python "$(dirname "$0")/../examples/python/agent.py" + ;; + *) + echo "AUTHS_GITHUB_SDK must be exactly 'typescript' or 'python'" >&2 + exit 2 + ;; +esac diff --git a/demos/github-issue/web/app.js b/demos/github-issue/web/app.js index fac35423..39f2fadc 100644 --- a/demos/github-issue/web/app.js +++ b/demos/github-issue/web/app.js @@ -1,6 +1,7 @@ -const API_BASE = - window.AUTHS_GITHUB_API_BASE || - "https://auths-issue-workflow.fly.dev"; +// Production should serve the browser and native API from one origin. A +// deployment that deliberately splits them may set this before loading this +// module and must allow the exact origin in its Content Security Policy. +const API_BASE = window.AUTHS_GITHUB_API_BASE || window.location.origin; const REQUEST_TIMEOUT_MS = 20_000; const EXECUTION_TIMEOUT_MS = 120_000; @@ -10,7 +11,6 @@ const previews = { kind: "authorized", code: "authorized", stage: "auths-kernel", - credential: "after claim", detail: "Every bound fact matches. Inspection can proceed, then the executor may publish one branch and one draft PR.", }, "prohibited-path": { @@ -18,7 +18,6 @@ const previews = { kind: "denied", code: "path-explicitly-denied", stage: "candidate-inspection", - credential: "NO WRITE", detail: "The candidate changes .github/**, which the signed workflow grant explicitly denies.", }, "candidate-changed": { @@ -26,7 +25,6 @@ const previews = { kind: "denied", code: "candidate-bundle-malformed", stage: "candidate-inspection", - credential: "NO WRITE", detail: "The submitted candidate SHA does not identify the commit in the inspected Git bundle.", }, "repository-changed": { @@ -34,7 +32,6 @@ const previews = { kind: "denied", code: "repository-mismatch", stage: "github-evidence", - credential: "NO WRITE", detail: "Fresh GitHub evidence does not identify the immutable repository in the workflow grant.", }, "issue-changed": { @@ -42,7 +39,6 @@ const previews = { kind: "denied", code: "issue-mismatch", stage: "github-evidence", - credential: "NO WRITE", detail: "Fresh GitHub evidence does not identify the issue in the workflow grant.", }, "base-advanced": { @@ -50,7 +46,6 @@ const previews = { kind: "denied", code: "base-revision-mismatch", stage: "github-evidence", - credential: "NO WRITE", detail: "The base ref no longer points to the commit named by the workflow grant.", }, "malformed-bundle": { @@ -58,7 +53,6 @@ const previews = { kind: "denied", code: "candidate-bundle-malformed", stage: "candidate-inspection", - credential: "NO WRITE", detail: "The fixed 17-byte regression bundle is rejected as malformed before GitHub evidence or credentials.", }, }; @@ -85,6 +79,7 @@ const elements = { issue: document.querySelector("#issue"), base: document.querySelector("#base"), target: document.querySelector("#target"), + agentPrincipal: document.querySelector("#agent-principal"), requiredConfig: document.querySelector("#required-config"), executedConfig: document.querySelector("#executed-config"), configLink: document.querySelector("#config-link"), @@ -105,6 +100,7 @@ const elements = { let selected = "exact"; let sessionId = null; let sessionReady = false; +let guidedPreview = false; let inspected = false; let completed = false; @@ -139,6 +135,18 @@ async function initialize() { ]); const session = await request("/v1/demo/sessions", { method: "POST", + body: { + repository: scenario.repository, + issueNumber: scenario.issue_number, + baseRef: scenario.base_ref, + baseRevision: scenario.base_revision, + allowedPaths: scenario.allowed_paths, + protectedPaths: scenario.denied_paths, + expiresInSeconds: 15 * 60, + branchBudget: 1, + draftPullRequestBudget: 1, + agentLabel: "credential-less-demo-agent", + }, timeout: REQUEST_TIMEOUT_MS, }); sessionId = session.session_id; @@ -148,6 +156,8 @@ async function initialize() { elements.base.textContent = short(session.base_revision); elements.base.title = session.base_revision; elements.target.textContent = session.target_ref; + elements.agentPrincipal.textContent = short(session.agent_principal, 16); + elements.agentPrincipal.title = session.agent_principal; elements.requiredConfig.textContent = short(session.required_configuration, 16); elements.requiredConfig.title = session.required_configuration; elements.executedConfig.textContent = short(session.executed_configuration, 16); @@ -164,19 +174,22 @@ async function initialize() { applyPreview(selected); } catch (error) { sessionReady = false; - elements.nativeState.textContent = "unavailable"; + guidedPreview = true; + elements.nativeState.textContent = "preview only"; elements.nativeDot.dataset.state = "failed"; elements.githubState.textContent = "not checked"; - setService("failed", "native service unavailable", false); - elements.verdict.textContent = "UNAVAILABLE"; - elements.verdict.dataset.kind = "denied"; - elements.verdictDetail.textContent = - `The browser could not create a native session: ${error.message}. Retry by reloading this page.`; - elements.inspect.disabled = true; + elements.release.textContent = "guided preview · no live execution"; + setService("preview", "guided preview — service offline", false); + elements.liveState.title = `Native service unavailable: ${error.message}`; + applyPreview(selected); } } async function inspectCandidate() { + if (guidedPreview) { + renderGuidedPreview(selected); + return; + } if (!sessionReady || !sessionId) { elements.verdictDetail.textContent = "The native session is not ready. Reload the page to retry."; return; @@ -187,7 +200,7 @@ async function inspectCandidate() { try { const response = await request(`/v1/demo/sessions/${sessionId}/candidate`, { method: "POST", - body: { experiment: selected }, + body: { kind: "fixture", experiment: selected }, timeout: EXECUTION_TIMEOUT_MS, }); const candidate = response.candidate; @@ -200,7 +213,7 @@ async function inspectCandidate() { elements.changedPath.textContent = path; elements.changedPath.title = path; elements.directPush.textContent = - candidate.direct_push?.result === "authentication-rejected" + candidate.direct_push?.result === "refused-without-credential" ? "REJECTED — no credential" : candidate.direct_push?.result || "not attempted"; updateTimeline( @@ -314,6 +327,15 @@ function renderOutcome(response, replay) { updateTimeline("branch", "Published", "done"); } if (response.execution?.pull_request === "opened") { + const pullRequestUrl = safeExternalUrl(response.execution.pull_request_url); + if (!pullRequestUrl) { + elements.verdict.textContent = "CHECK REQUIRED"; + elements.verdict.dataset.kind = "denied"; + elements.verdictDetail.textContent = + "The executor reported a pull request without a valid HTTPS result URL."; + updateTimeline("pull-request", "Invalid result URL", "denied"); + return; + } updateTimeline("pull-request", "Draft opened", "done"); completed = true; elements.replay.hidden = false; @@ -321,7 +343,7 @@ function renderOutcome(response, replay) { elements.publishedRef.textContent = response.execution.branch_ref; elements.publishedSha.textContent = short(response.execution.branch_revision, 13); elements.publishedSha.title = response.execution.branch_revision; - elements.pullRequestLink.href = response.execution.pull_request_url; + elements.pullRequestLink.href = pullRequestUrl; elements.pullRequestLink.textContent = `Open draft PR #${response.execution.pull_request_number} ↗`; elements.githubState.textContent = "PR confirmed"; @@ -342,22 +364,61 @@ async function loadReceipts() { function applyPreview(variant) { const preview = previews[variant]; - elements.verdict.textContent = preview.verdict; + elements.verdict.textContent = `EXPECTED ${preview.verdict}`; elements.verdict.dataset.kind = preview.kind; - elements.verdictDetail.textContent = preview.detail; + elements.verdictDetail.textContent = + `Expected boundary: ${preview.detail} No Auths decision or GitHub action has run.`; elements.decisionCode.textContent = preview.code; elements.decisionStage.textContent = preview.stage; - elements.credentialRequested.textContent = preview.credential; + elements.credentialRequested.textContent = "not requested"; elements.mutationCount.textContent = "0"; - elements.inspect.textContent = "Inspect candidate"; - elements.inspect.disabled = !sessionReady; + elements.inspect.textContent = guidedPreview + ? "Explain selected case" + : "Inspect candidate"; + elements.inspect.disabled = !(sessionReady || guidedPreview); elements.execute.disabled = true; elements.execute.textContent = variant === "exact" ? "Publish through Auths" : "Submit denied case"; elements.actionTitle.textContent = - variant === "exact" ? "Inspect the exact candidate." : "Inspect the changed candidate."; + guidedPreview + ? "Explore this boundary without pretending it ran." + : variant === "exact" + ? "Inspect the exact candidate." + : "Inspect the changed candidate."; + elements.actionCopy.textContent = + guidedPreview + ? "The live executor is offline. The explanation remains interactive, but execution and receipts stay disabled until a native session exists." + : "The executor parses the bounded Git bundle without checking out or running candidate code."; +} + +function renderGuidedPreview(variant) { + const preview = previews[variant]; + const changedPath = { + exact: "demo/runs/** (permitted example)", + "prohibited-path": ".github/** (denied example)", + "candidate-changed": "declared SHA ≠ bundle commit", + "repository-changed": "repository identity mismatch", + "issue-changed": "issue identity mismatch", + "base-advanced": "base revision mismatch", + "malformed-bundle": "17-byte invalid bundle", + }[variant]; + + elements.candidateSha.textContent = "not inspected — preview only"; + elements.changedPath.textContent = changedPath; + elements.changedPath.title = changedPath; + elements.directPush.textContent = "not attempted — preview only"; + elements.verdict.textContent = `EXPECTED ${preview.verdict}`; + elements.verdictDetail.textContent = + `${preview.detail} This explains the selected boundary; it is not a recorded Auths decision.`; + elements.credentialRequested.textContent = "not requested"; + updateTimeline("candidate", "Explained only", null); + updateTimeline("authorized", "Not run", null); + updateTimeline("branch", "Not attempted", null); + updateTimeline("pull-request", "Not attempted", null); + updateTimeline("replay", "Not available", null); + elements.actionTitle.textContent = "Connect the native service to execute it."; elements.actionCopy.textContent = - "The executor parses the bounded Git bundle without checking out or running candidate code."; + "A real run must inspect server-owned evidence, return a native decision, and produce signed receipts. Preview mode never fabricates those facts."; } function resetExecution() { @@ -365,6 +426,7 @@ function resetExecution() { elements.changedPath.textContent = "—"; elements.directPush.textContent = "not attempted"; elements.githubResult.hidden = true; + elements.pullRequestLink.removeAttribute("href"); elements.replay.hidden = true; elements.receiptCount.textContent = "0"; elements.receiptJson.textContent = "Run the workflow to load receipts."; @@ -387,6 +449,7 @@ function setService(kind, label, ready) { elements.serviceState.textContent = label; elements.liveState.classList.toggle("ready", ready); elements.liveState.classList.toggle("failed", kind === "failed"); + elements.liveState.classList.toggle("preview", kind === "preview"); } function setBusy(button, busy, label) { @@ -426,3 +489,14 @@ function short(value, length = 12) { if (!value) return "—"; return value.length > length ? `${value.slice(0, length)}…` : value; } + +function safeExternalUrl(value) { + try { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password + ? url.href + : null; + } catch { + return null; + } +} diff --git a/demos/github-issue/web/index.html b/demos/github-issue/web/index.html index 164afce6..5de5710d 100644 --- a/demos/github-issue/web/index.html +++ b/demos/github-issue/web/index.html @@ -34,7 +34,7 @@
-

Auths × GitHub, live

+

Auths × GitHub, inspectable

Approve one patch. Publish only that patch.

@@ -73,7 +73,8 @@

Approve one patch. Publish only that patch.

Change one fact.

Each case uses a server-owned candidate. Select a case to see - the expected decision, then inspect the actual Git bundle. + the expected boundary. With the native service connected, you + can inspect the actual Git bundle and execute the exact action.

@@ -116,14 +117,14 @@

Change one fact.

Allowed
demo/runs/**
Denied
.github/**
Budget
1 branch · 1 draft PR
-
Agent token
NONE
+
Agent
· credential NONE
- 02 · Live authorization result + 02 · Authorization boundary AUTH-V1 / GITHUB
starting @@ -203,7 +204,7 @@

Separate checks and effects

One draft pull request is open.

points to .

- + Open the real draft PR ↗ diff --git a/demos/github-issue/web/receipt.js b/demos/github-issue/web/receipt.js index 5adc2a3f..c9050184 100644 --- a/demos/github-issue/web/receipt.js +++ b/demos/github-issue/web/receipt.js @@ -1,6 +1,4 @@ -const API_BASE = - window.AUTHS_GITHUB_API_BASE || - "https://auths-issue-workflow.fly.dev"; +const API_BASE = window.AUTHS_GITHUB_API_BASE || window.location.origin; const REQUEST_TIMEOUT_MS = 20_000; const RECEIPT_PATH = /^\/receipts\/(?:demo-)?([0-9a-f]{32})\/?$/; diff --git a/demos/github-issue/web/styles.css b/demos/github-issue/web/styles.css index 5ab60a2d..ede44535 100644 --- a/demos/github-issue/web/styles.css +++ b/demos/github-issue/web/styles.css @@ -26,6 +26,7 @@ } * { box-sizing: border-box; } +[hidden] { display: none !important; } html { scroll-behavior: smooth; } body { min-width: 320px; margin: 0; background: var(--canvas); color: var(--ink); -webkit-font-smoothing: antialiased; } button, a { font: inherit; } diff --git a/demos/github-issue/web/vercel.json b/demos/github-issue/web/vercel.json index 7db570ed..94898ab2 100644 --- a/demos/github-issue/web/vercel.json +++ b/demos/github-issue/web/vercel.json @@ -14,7 +14,7 @@ "headers": [ { "key": "Content-Security-Policy", - "value": "default-src 'self'; connect-src 'self' https://auths-issue-workflow.fly.dev; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" + "value": "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" }, { "key": "Referrer-Policy", diff --git a/docs/product/PRODUCTION_SDK_QUICKSTART.md b/docs/product/PRODUCTION_SDK_QUICKSTART.md index af3782eb..8b9f64fa 100644 --- a/docs/product/PRODUCTION_SDK_QUICKSTART.md +++ b/docs/product/PRODUCTION_SDK_QUICKSTART.md @@ -1,82 +1,112 @@ -# Production SDK quickstart +# Production SDK quickstart: one GitHub issue -TypeScript and Python expose the same five product verbs over the same -Rust-owned contract. The SDK selects one maintained effect profile; the -operator runtime owns authorization, durable lifecycle state, provider entry, -reconciliation, and signed receipts. +The launch path gives an agent one bounded GitHub task and keeps the GitHub App +credential inside a separate trusted executor. The SDK accepts named domain +values and a Git bundle file; it does not ask application code to construct +CBOR, proof bytes, canonical actions, or receipt envelopes. + +The operator first deploys the existing `demos/github-issue` service for one +isolated repository and issue. Both SDKs then use the same flow. ## TypeScript ```ts -import { createAuths } from "@auths-dev/sdk"; -import { githubIssueAddress } from "@auths-dev/sdk/profiles"; +import { createGitHubAgentClient } from "@auths-dev/sdk/service"; -const auths = createAuths({ - endpoint: "https://auths.example.com", - identity: publicIdentityBytes, - profile: githubIssueAddress(), +const auths = createGitHubAgentClient({ endpoint: "https://executor.example" }); +const candidateRevision = ""; +const boundary = await auths.boundary(); +const task = await auths.delegate({ + repository: boundary.repository, + issueNumber: boundary.issueNumber, + baseRef: boundary.baseRef, + baseRevision: boundary.baseRevision, + allowedPaths: boundary.allowedPaths, + protectedPaths: boundary.protectedPaths, + expiresInSeconds: boundary.maximumExpirySeconds, + branchBudget: 1, + draftPullRequestBudget: 1, + agentLabel: "issue-agent", +}); +const candidate = await auths.inspectCandidate(task, { + path: "./candidate.bundle", + baseRevision: boundary.baseRevision, + candidateRevision, }); -const created = await auths.create(authorityRequestBytes); -if (created.kind !== "authority") throw new Error(created.code); -const delegated = await auths.delegate(created, agentIdentityBytes, attenuationBytes); -if (delegated.kind !== "authority") throw new Error(delegated.code); -const result = await auths.execute(delegated, githubIssueActionBytes); -if (result.kind === "recoverable") await auths.resume(result.reference); +if (candidate.kind !== "inspected") throw new Error(candidate.decisionCode); +let result = await auths.execute(task); +if (result.next === "reconcile") result = await auths.reconcile(task); +if (result.kind !== "completed" && result.kind !== "reconciled") { + throw new Error(result.code); +} +const receipts = await auths.verifyReceipts(task); ``` ## Python ```python -from auths import create_auths -from auths.profiles import github_issue_address - -auths = create_auths( - endpoint="https://auths.example.com", - identity=public_identity_bytes, - profile=github_issue_address(), +from auths.service import ( + GitHubAgentTask, + GitHubCandidateFile, + create_github_agent_client, ) -created = await auths.create(authority_request_bytes) -if created.kind != "authority": - raise RuntimeError(created.code) -delegated = await auths.delegate(created, agent_identity_bytes, attenuation_bytes) -if delegated.kind != "authority": - raise RuntimeError(delegated.code) -result = await auths.execute(delegated, github_issue_action_bytes) -if result.kind == "recoverable": - await auths.resume(result.reference) -``` -## What runs locally - -- The SDK applies strict endpoint, timeout, redirect, content-type, and response - size rules. -- Packaged Rust code encodes requests and parses finite response variants. -- Packaged Rust verification remains available offline. -- Opaque authority, receipt, and recovery values cannot be forged through the - public SDK. - -## What contacts the runtime - -`create`, `delegate`, `execute`, and `resume` contact the configured HTTPS -runtime. `verify` uses the same versioned endpoint when the configured profile -requires runtime-owned status or lifecycle evidence. The runtime—not HTTP -success—decides whether an effect is authorized. +auths = create_github_agent_client(endpoint="https://executor.example") +candidate_revision = "" +boundary = await auths.boundary() +task = await auths.delegate(GitHubAgentTask( + repository=boundary.repository, + issue_number=boundary.issue_number, + base_ref=boundary.base_ref, + base_revision=boundary.base_revision, + allowed_paths=boundary.allowed_paths, + protected_paths=boundary.protected_paths, + expires_in_seconds=boundary.maximum_expiry_seconds, + branch_budget=1, + draft_pull_request_budget=1, + agent_label="issue-agent", +)) +candidate = await auths.inspect_candidate(task, GitHubCandidateFile( + path="candidate.bundle", + base_revision=boundary.base_revision, + candidate_revision=candidate_revision, +)) +if candidate.kind != "inspected": + raise RuntimeError(candidate.decision_code) +result = await auths.execute(task) +if result.next == "reconcile": + result = await auths.reconcile(task) +if result.kind not in ("completed", "reconciled"): + raise RuntimeError(result.code) +receipts = await auths.verify_receipts(task) +``` -The default client refuses redirects, non-HTTPS origins, unexpected media -types, oversized responses, and unknown contract outcomes. It never returns raw -provider errors or credential material. +## What the boundary guarantees -## Finite outcomes +- The task repeats the operator-approved repository, issue, current base, + allowed/protected paths, expiry, and fixed one-branch/one-draft-PR budget. + Any widening is refused before a session is created. +- The agent produces only a Git bundle. It has no GitHub App token. +- Rust performs bounded Git inspection and derives the exact branch and draft + pull-request commands. +- Each effect is durably claimed before the executor requests its credential. +- A protected path, stale base, repository/issue substitution, or changed + candidate is denied before a write. +- Replay returns the existing receipt commitment with zero new credentials and + zero new mutations. +- An ambiguous provider outcome says `reconcile`; it never tells the caller to + start the action again. +- If an execute response is lost, both SDKs return `indeterminate` with + credential and mutation counts set to `unknown` and `next = reconcile`. + They never turn transport loss into a zero-effect claim. +- `verifyReceipts` reads through the existing bounded signed-receipt verifier. -- `completed`: the protected effect reached a definite successful outcome and - carries a signed receipt; -- `denied`: the request definitely lacks authority and must not be retried; -- `indeterminate`: the runtime could not safely decide; use its retry class; -- `recoverable`: use only the returned opaque reference with `resume`; -- `verified`: the supplied authority satisfies the runtime's trusted context, - or the receipt is canonical and authentic under that runtime's receipt key; - and -- `rejected`: verification definitely failed. +The generic five-verb remote contract remains at `@auths-dev/sdk/service` and +`auths.service`. The GitHub calls live on those existing remote-service entry +points, but their request is deliberately profile-specific +because candidate inspection, fresh GitHub evidence, two ordered effects, and +reconciliation cannot be represented honestly as an arbitrary JSON action. -See [production failures and recovery](recipes/06_PRODUCTION_FAILURES.md) for -the fail-closed paths. +See the maintained [demo quickstart](../../demos/github-issue/README.md), +[architecture](../../demos/github-issue/docs/architecture.md), and +[failure/recovery guide](recipes/06_PRODUCTION_FAILURES.md). diff --git a/docs/prompts/GITHUB_AGENT_LAUNCH_GOLDEN_PATH.md b/docs/prompts/GITHUB_AGENT_LAUNCH_GOLDEN_PATH.md new file mode 100644 index 00000000..37708f08 --- /dev/null +++ b/docs/prompts/GITHUB_AGENT_LAUNCH_GOLDEN_PATH.md @@ -0,0 +1,465 @@ +# Prompt: Build the GitHub Agent Launch Golden Path Without Rebuilding Auths + +You are working in the `auths-dev/auths-proof` repository with no assumed prior context: + +```text +/Users/bordumb/workspace/repositories/auths-proof-base/auths-proof +``` + +Your mission is to turn the existing GitHub issue workflow into the clearest launch-quality Auths +experience: a developer gives an AI agent narrowly bounded authority to address one GitHub issue, +the agent proposes one exact change, and a separate trusted executor may publish one branch and +open one draft pull request. Unsafe changes, widening, replay, and ambiguous remote outcomes must +remain visibly and mechanically bounded. + +This is a productization task, not permission to build a second Auths implementation. The repository +already contains a large amount of the required machinery. Your first obligation is to find and +reuse it. + +## Product outcome + +A new developer should be able to complete the path in under 15 minutes without understanding +Lean, Rust internals, CBOR, proof-bundle bytes, registry manifests, or the distinction between every +internal Auths crate. + +The experience should let them: + +1. select a repository, issue, base revision, allowed paths, protected paths, expiry, and a budget + of one branch plus one draft pull request; +2. preview in plain language exactly what the agent may and may not do; +3. delegate that authority to an agent identity; +4. submit an agent-produced candidate without giving the agent a GitHub mutation credential; +5. see the real Auths decision before any mutation credential is requested; +6. publish the authorized branch and draft pull request through the trusted executor; +7. inspect and verify the decision and execution receipts; +8. see a protected-path candidate denied before any GitHub write; +9. see replay or a second pull request denied; and +10. recover or reconcile an ambiguous remote outcome without blindly repeating the mutation. + +The user-facing path must use typed domain inputs. Raw `Uint8Array`, `bytes`, CBOR, opaque request +blobs, and hand-built protocol objects are not an acceptable primary developer experience. + +## Read before changing anything + +Read the whole brief before running commands or editing files. Then read: + +1. `AGENTS.md` and every applicable nested repository instruction. +2. `demos/github-issue/docs/architecture.md` and the rest of `demos/github-issue/`. +3. `product/integrations/auths-github/` in full enough to identify its public types, service, + adapters, lifecycle behavior, receipts, and exact action vocabulary. +4. `bindings/typescript/`, especially its service client, profile exports, workflow surface, tests, + public API inventory, capability metadata, and installed-package tooling. +5. `bindings/python/`, especially its service client, profile exports, workflow surface, tests, + public API inventory, capability metadata, and wheel-consumer tooling. +6. `bindings/wasm/auths-proof-wasm/` to understand the Rust-owned encoding and projection boundary. +7. `bindings/customer-journey-matrix-v1.json` and `bindings/public-topology-v1.json`. +8. `demos/open-production-reference/`, particularly its installed TypeScript and Python consumers, + recovery behavior, deployment boundary, and limitations. +9. `docs/product/PRODUCTION_SDK_QUICKSTART.md` and the binding integration recipes. +10. The relevant repository checks, semantic fixtures, frozen API inventories, and existing CI + jobs before proposing a new package, command, or public symbol. + +Use `rg` and `rg --files` to discover existing owners and call sites. Do not infer a capability is +missing from a filename or from one documentation example. + +## Inventory-first gate + +Before implementation, produce a concise reuse matrix: + +| Needed capability | Existing owner and path | Reuse as-is | Extend existing owner | Genuine gap | Evidence | +| --- | --- | --- | --- | --- | --- | +| GitHub action vocabulary | ... | ... | ... | ... | file:symbol/test | +| Candidate inspection | ... | ... | ... | ... | ... | +| Authority creation and delegation | ... | ... | ... | ... | ... | +| Remote SDK transport | ... | ... | ... | ... | ... | +| Rust-owned canonical encoding | ... | ... | ... | ... | ... | +| Receipt verification | ... | ... | ... | ... | ... | +| Replay and lifecycle state | ... | ... | ... | ... | ... | +| Recovery and reconciliation | ... | ... | ... | ... | ... | +| GitHub credentials and writes | ... | ... | ... | ... | ... | +| Deployment and installed-SDK test | ... | ... | ... | ... | ... | +| Demo UI | ... | ... | ... | ... | ... | + +For every proposed new component, name the nearest existing component and explain with code-level +evidence why extending it is insufficient. “Cleaner,” “more modern,” or “easier to understand” is +not sufficient evidence for parallel machinery. + +Do not begin implementation until the matrix supports a minimal change set. + +## Repository placement decision + +Evaluate these choices explicitly: + +1. **Extend `demos/github-issue` — default and strongly recommended.** It already owns the exact + GitHub issue, bounded branch, draft pull request, public fixture, web presentation, deployment, + receipt, and recovery demonstration. Reuse `product/integrations/auths-github` for reusable + GitHub product behavior and the existing bindings for user-facing SDK behavior. +2. **Create another directory under `demos/`.** Choose this only if the intended audience or + execution model is genuinely different and sharing the current demo would create an incoherent + product. A second name for the same agent-to-draft-PR path is duplication. +3. **Create a new repository.** Do not choose this merely for visual cleanliness or a smaller + checkout. It is justified only by a demonstrated hard boundary such as independent release + cadence, separate security/credential ownership, or a requirement that the sample consume only + published packages with no source-tree coupling. Even then, first prove the full experience as + an installed-package consumer fixture in this repository; extraction is a later release action. + +Score the choices on reuse, semantic-drift risk, installed-package realism, credential isolation, +release independence, maintenance cost, and CI coverage. Unless the evidence disproves it, use: + +```text +Placement: extend demos/github-issue +Reusable GitHub semantics: product/integrations/auths-github +Developer APIs: bindings/typescript and bindings/python +Rust-owned cross-language encoding: bindings/wasm/auths-proof-wasm and existing native bindings +Production runtime/deployment patterns: demos/open-production-reference and auths-node +``` + +Do not create a new repository, a second GitHub demo, or another runtime without stopping and +presenting the evidence that makes the default placement impossible. + +## Required design response before code + +Report this compact decision block before editing: + +```text +Placement: +Why this owner is correct: +Existing components reused: +Existing components extended: +New files proposed: +Public API changes proposed: +Semantic identities affected: +Why no parallel implementation is being created: +``` + +Then provide a light technical specification with exactly these sections: + +- **UX** — the happy path, denial path, recovery path, and what the user sees at each step; +- **Architecture** — component ownership and dependency direction; +- **APIs** — existing public calls reused and the smallest typed additions, if any. + +There must be no unanswered design questions when implementation begins. If an unresolved choice +would materially change public APIs, security boundaries, or repository placement, stop and ask. + +## Target user experience + +Prefer one cohesive quickstart over a catalogue of features. A terminal or existing web experience +may implement it, but do not build a new frontend if the current `demos/github-issue/web` can be +extended cleanly. + +The experience should communicate roughly this information: + +```text ++------------------------------------------------------------------+ +| Auths GitHub Agent · Delegate one bounded task | ++------------------------------------------------------------------+ +| Repository auths-dev/example | +| Issue #123 | +| Base main @ 8a31... | +| Agent did:key:... | +| Allowed paths src/**, tests/** | +| Protected .github/**, Cargo.lock, secrets/** | +| Budget 1 branch · 1 draft PR · expires in 30 minutes | ++------------------------------------------------------------------+ +| MAY | +| ✓ address issue #123 from the pinned base revision | +| ✓ publish auths/issue-123 | +| ✓ open one draft pull request | +| MAY NOT | +| ✗ edit protected paths | +| ✗ push another branch or open a second pull request | +| ✗ obtain or reuse the executor's GitHub credential | ++------------------------------------------------------------------+ +| [Delegate authority] [Cancel] | ++------------------------------------------------------------------+ + ++------------------------------------------------------------------+ +| Candidate inspection | +| ✓ base revision matches ✓ 4 files / 2.8 KiB | +| ✓ paths permitted ✓ exact action authorized | +| ✓ effect claimed before credential | +| | +| Result: COMPLETED | +| Branch: auths/issue-123 Draft PR: #456 | +| Receipt: verified [Explain] [Open PR] [Download] | ++------------------------------------------------------------------+ +``` + +Use plain language first, with exact digests and protocol details available as progressive +disclosure. A denial must identify the failed boundary without leaking secrets or implying that an +external effect occurred. A recoverable result must tell the caller to resume or reconcile, not to +start the action again. + +## Required architecture + +Preserve this ownership and dependency direction unless the inventory proves the repository has +already moved it: + +```text +Developer or AI-agent sample + | + | typed TypeScript/Python calls + v +Existing Auths binding production client + | + | canonical Rust-owned request/response contract over HTTPS + v +Existing auths-node / production runtime boundary + | + +--> Auths authorization and lifecycle state + | (authority narrowing, replay, recovery, receipts) + | + v +product/integrations/auths-github + | + +--> hostile candidate inspection + +--> fresh GitHub evidence + +--> exact branch and draft-PR actions + +--> claim-before-credential execution + +--> postcondition observation and reconciliation + | + v +GitHub App credential boundary --> GitHub API / Git transport +``` + +```text +demos/github-issue + -> product/integrations/auths-github + -> stable Auths core/profile APIs + +TypeScript/Python examples + -> published/packed binding APIs + -> existing service routes + +Auths core must never import the demo, GitHub integration, or language bindings. +Language bindings must never acquire an independent copy of GitHub authorization semantics. +The agent process must never receive the GitHub mutation credential. +``` + +## API guidance + +Discover existing names before proposing new ones. In particular, inspect the current remote +service clients and the GitHub profile constructors rather than copying stale quickstart snippets. +The TypeScript production boundary currently lives separately from the local product facade; keep +that separation intact. Apply the equivalent rule in Python. + +The likely product gap is a friendly, typed GitHub-authoring surface above the existing opaque +wire contract. Validate that hypothesis from the code. If the gap is real: + +- add the smallest profile-specific input types or builders to the existing binding owner; +- keep canonical serialization and validation Rust-owned; +- expose domain values such as repository, issue number, pinned base revision, allowed and denied + paths, branch/PR budget, expiry, and exact candidate identity; +- return the existing closed completed/denied/indeterminate/recoverable outcome families; +- preserve opaque authority, recovery-reference, and receipt values; +- keep TypeScript and Python behavior and vocabulary in parity; +- add public API, capability, topology, documentation, and frozen-semantic updates required by the + repository's existing policies. + +Do not invent a profile-independent “execute arbitrary JSON” endpoint. Do not move canonical +meaning into TypeScript or Python. Do not make an internal or private API public merely because the +demo needs it. If the desired typed operation cannot be expressed through the public product waist, +treat that as a product API gap and fix its proper owner. + +## File structure guidance + +First reconcile this suggestion with the files already present in `demos/github-issue`. Add only the +smallest missing pieces; do not reorganize working code for symmetry. + +If extending the existing demo, a reasonable end state is: + +```text +demos/github-issue/ +├── README.md # one launch path and prerequisites +├── Cargo.toml # existing native demo assembly +├── src/ # existing Rust service and fixtures +├── web/ # existing presentation; extend, do not replace +├── docs/ +│ ├── architecture.md # existing ownership and flow +│ ├── about.md # existing product explanation +│ ├── quickstart.md # only if README would become unwieldy +│ └── operator-boundary.md # only if not already covered elsewhere +├── examples/ # add only if installed-SDK examples do not fit tests +│ ├── typescript/ +│ │ ├── package.json +│ │ └── src/agent.ts +│ └── python/ +│ ├── pyproject.toml +│ └── agent.py +├── fixtures/ # shared declarative cases, if no current owner exists +│ ├── allowed/ +│ ├── denied-protected-path/ +│ └── recoverable/ +└── tests/ + ├── installed-sdk-e2e.mjs # packed package, never source imports + ├── test_installed_sdk.py # built wheel, never source imports + ├── denial-and-replay.* + └── live-github-opt-in.* # isolated fixture repository only +``` + +This is a decision aid, not an instruction to create every listed file. Prefer existing tests and +fixtures when they already have the correct owner. Do not copy the production-reference installed +SDK harnesses; extract or parameterize shared test support if reuse is genuinely needed. + +If the demo needs no new examples directory because the current web and Rust assembly can exercise +the typed SDK path directly, say so and keep the smaller tree. + +## Implementation sequence + +### Phase 0 — Establish the reuse and placement contract + +1. Complete the reuse matrix. +2. Record the placement decision and dependency direction. +3. Identify every proposed public API or frozen-semantic change. +4. Delete any proposal that duplicates an existing owner. + +### Phase 1 — Write the launch acceptance tests first + +Create failing tests at the external seam using packed TypeScript and built Python artifacts, not +source-tree shortcuts. Pin the user journey independently of implementation constants so drift is +detectable. The tests should describe developer inputs and public outcomes, not internal CBOR. + +### Phase 2 — Close only genuine typed-API gaps + +Extend the existing binding/profile owner only where the installed consumer cannot express the +journey. Keep encoding, validation, exact profile semantics, outcome mapping, and error codes bound +to their current Rust-owned contracts. + +### Phase 3 — Compose existing runtime and GitHub product code + +Connect the public SDK journey to the current runtime and `auths-github` service. Reuse candidate +inspection, fresh evidence, claim-before-credential, replay, recovery, reconciliation, and signed +receipt behavior. Do not write demo-local substitutes. + +### Phase 4 — Make the experience legible + +Update the existing quickstart and demo presentation. Show the authority preview, the exact denied +boundary, when the credential is requested, effect state, next call, and receipt explanation. Keep +the default path short; put protocol details behind expandable detail. + +### Phase 5 — Prove packaging and operations + +Run the path from independently packed SDK artifacts against the reviewable runtime shape. Add an +opt-in live GitHub test only against an isolated fixture repository and GitHub App installation. +Never make routine unit tests mutate a maintainer's real repository. + +## Acceptance criteria + +The work is not complete until all of the following are demonstrated: + +- A clean-machine quickstart reaches a verified draft pull request in under 15 minutes, excluding + deliberate human GitHub App installation approval. +- The primary TypeScript and Python examples contain no user-authored protocol bytes, CBOR, raw + authority blobs, or copied canonicalization logic. +- The authority is bound to one repository, issue, base revision, target branch derivation, path + policy, audience, expiry, and a budget of one branch plus one draft pull request. +- The agent can create the candidate while holding no GitHub read or mutation credential. +- A protected-path candidate is denied before a mutation credential is requested and before any + GitHub write. +- A changed base revision or mismatched repository/issue is denied or indeterminate according to + the existing contract; it never silently authorizes. +- A replay and an attempt to create a second branch or pull request issue no second write. +- An ambiguous write becomes recoverable/reconcilable and is resolved by observing the exact + postcondition, not by blindly repeating the write. +- The completed result carries a receipt that the existing verification surface accepts and can + explain without exposing secret material. +- TypeScript and Python agree on profile ID, typed inputs, result class, stable error code, recovery + direction, and receipt verification for shared fixtures. +- Installed-package tests use a packed npm artifact and built wheel. Passing through source imports + does not count. +- The opt-in live test creates only a draft pull request in an isolated fixture repository and has + deterministic cleanup or a bounded retention policy. +- Existing architecture, binding semantics, SDK vocabulary, customer-journey, public-topology, + semantic-freeze, and relevant production-contract gates pass. +- No new authorization evaluator, canonical encoder, GitHub provider, lifecycle store, receipt + schema, recovery protocol, identity system, or runtime was added when an existing owner could be + extended. + +## Required adversarial cases + +At minimum, prove the real boundary behavior for: + +- `.github/**` or another protected-path mutation; +- candidate based on a stale base revision; +- repository or issue substitution; +- candidate content changed after inspection; +- action submitted by the wrong agent identity or audience; +- expiry before execution; +- widening during delegation; +- second branch, second draft pull request, and receipt replay; +- verifier configuration mismatch; +- GitHub rejection with no effect; +- timeout after a possibly applied GitHub effect; +- recovery on a different runtime replica; +- tampered or wrong-key receipt; +- packed TypeScript/Python vocabulary drift. + +Each denial must come from the production Auths/GitHub path, not a frontend-only conditional or a +demo-specific Boolean. + +## Things you must not rebuild + +Do not add any of the following unless the inventory proves there is no existing owner and you +explicitly justify the new boundary: + +- an authorization or attenuation evaluator; +- a proof-bundle or canonical-action encoder in TypeScript/Python; +- another remote Auths client; +- a generic JSON operation endpoint; +- another GitHub App credential broker, REST client, candidate inspector, or write executor; +- another lifecycle/replay/recovery state machine; +- another receipt envelope, signer, or verifier; +- a demo-specific identity or custody model; +- another deployment stack duplicating `demos/open-production-reference`; +- another web application duplicating the existing GitHub demo; +- compatibility aliases, deprecated shims, or old/new APIs in parallel; +- mocks presented as proof that the real GitHub seam works. + +Do not hand-edit generated artifacts. Change their source or generator and regenerate them using the +repository's documented process. Do not run or rewrite the full formal toolchain merely because it +exists; determine whether the formal source closure actually changed and follow the repository's +qualification instructions when it did. + +## Verification discipline + +Determine behavior empirically: + +- If you claim an API already supports the journey, prove it with an installed-consumer test. +- If you claim a denial happens before credentials, instrument the credential port and prove it was + never called. +- If you claim replay is bounded, count external writes. +- If you claim recovery is idempotent across replicas, run it against shared durable lifecycle + state from a second runtime instance. +- If you claim TypeScript/Python parity, run identical semantic fixtures through both. +- If you claim a gate protects a boundary, deliberately break that boundary and show the gate fails + before relying on it. + +Use the narrowest relevant checks during iteration, followed by every repository-prescribed gate +for the files and semantic identities changed. Do not weaken, skip, rename away, or conditionally +hide a required CI check to make the branch green. + +## Deliverables + +Deliver all of the following: + +1. The reuse matrix with `file:symbol` or test evidence. +2. A placement decision record comparing the three repository options and explaining why the + chosen owner minimizes drift. +3. The light technical specification with **UX**, **Architecture**, and **APIs** sections. +4. The minimal implementation in the existing owners. +5. A one-path quickstart for TypeScript and Python. +6. Installed-package end-to-end tests, real denial/replay/recovery tests, and an isolated opt-in live + GitHub test. +7. Updated architecture and security-boundary documentation. +8. A final reuse report listing every existing component reused, every component extended, every + new file, and why each new file was necessary. +9. Verification output and a residual-risk section that distinguishes what was proved locally, + what was exercised against GitHub, and what remains an operational assumption. + +## Completion standard + +The result is done when an external developer can understand the authority they are granting, +delegate it without handling protocol bytes, let an uncredentialed agent propose a change, and see +a separate executor either open exactly one authorized draft pull request or fail closed with a +useful next step—and when the implementation demonstrably composes the Auths machinery already in +this repository instead of rebuilding it under a demo-friendly name. diff --git a/release/semantic-freeze-versions.toml b/release/semantic-freeze-versions.toml index 7f589e08..39c0596d 100644 --- a/release/semantic-freeze-versions.toml +++ b/release/semantic-freeze-versions.toml @@ -1,4 +1,4 @@ -freeze_version = 136 +freeze_version = 141 # Semantic identity counters live outside the xtask source tree deliberately. # The formal source closure binds xtask's executable code, while the semantic @@ -7,7 +7,7 @@ freeze_version = 136 # converge. This file is a reviewed release input, never an automatic output. [entries] "auths.core.protocol" = 18 -"auths.frozen-bytes/architecture/dependency-graph.json" = 29 +"auths.frozen-bytes/architecture/dependency-graph.json" = 30 "auths.frozen-bytes/bindings/wasm/auths-proof-wasm/identity-abi-v1.json" = 4 "auths.frozen-bytes/bounded-domains.toml" = 1 "auths.frozen-bytes/core/conformance/v1/manifest.json" = 1 @@ -46,26 +46,26 @@ freeze_version = 136 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/subscription-create/v1/manifest.sha256.json" = 1 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/subscription-modify/v1/manifest.sha256.json" = 1 "auths.frozen-bytes/product/integrations/auths-stripe/fixtures/v1/manifest.sha256.json" = 1 -"auths.identity.protocol" = 31 +"auths.identity.protocol" = 36 "auths.modular-components" = 8 -"auths.portable-abi-bindings" = 56 +"auths.portable-abi-bindings" = 61 "auths.product.bounded-domains" = 7 "auths.product.bounded-policy" = 2 "auths.product.configuration-commitments" = 1 "auths.product.development-composition" = 9 "auths.product.error-recovery-contract" = 11 "auths.product.external-custody" = 3 -"auths.product.facade" = 11 +"auths.product.facade" = 12 "auths.product.lifecycle" = 10 "auths.product.mcp-closed-execution" = 15 "auths.product.mechanism-profile-conformance" = 5 "auths.product.open-production-contract" = 13 "auths.product.operations" = 5 -"auths.product.public-sdk-contract" = 45 +"auths.product.public-sdk-contract" = 48 "auths.product.receipts" = 5 "auths.product.release-assurance" = 4 "auths.product.simplified-waist" = 8 -"auths.product.vocabulary" = 9 +"auths.product.vocabulary" = 10 "auths.release.benchmark-contract" = 1 -"auths.release.evolution-contract" = 19 -"auths.release.public-surface" = 135 +"auths.release.evolution-contract" = 24 +"auths.release.public-surface" = 140 diff --git a/release/semantic-freeze.json b/release/semantic-freeze.json index 40bdb91d..d9e3d852 100644 --- a/release/semantic-freeze.json +++ b/release/semantic-freeze.json @@ -1,6 +1,6 @@ { "schema": "auths.semantic-freeze/1", - "freezeVersion": 136, + "freezeVersion": 141, "publicSurface": { "rustRoots": [ "auths", @@ -91,7 +91,7 @@ }, { "id": "auths.frozen-bytes/architecture/dependency-graph.json", - "version": 29, + "version": 30, "classification": "frozen-bytes", "categories": [ "canonical-generated-evidence" @@ -99,7 +99,7 @@ "owners": [ "architecture/dependency-graph.json" ], - "sha256": "1b01bb92c5a18ef1f31fce4158f57fa6e120282bd2da1708ed050f860d3cf7e5" + "sha256": "77376ddde5816e23f8382f2fd9a6d10758455e85002c4adb05bace85f86b3c39" }, { "id": "auths.frozen-bytes/bindings/wasm/auths-proof-wasm/identity-abi-v1.json", @@ -559,7 +559,7 @@ }, { "id": "auths.identity.protocol", - "version": 31, + "version": 36, "classification": "frozen-meaning", "categories": [ "identity-protocol-versions", @@ -582,7 +582,7 @@ "core/fixtures/identity/v1/vectors.json", "core/spec/identity/v1" ], - "sha256": "66794f756f54af9beadcad1cc480ce027229b5a01162eb9b4d37dce53c1321fb" + "sha256": "35a8e14bc0046c6f05a46b3425bf251807449a032b187d02931f612b0339a93a" }, { "id": "auths.modular-components", @@ -620,7 +620,7 @@ }, { "id": "auths.portable-abi-bindings", - "version": 56, + "version": 61, "classification": "frozen-meaning", "categories": [ "portable-abi", @@ -637,7 +637,7 @@ "core/crates/auths-model/src/lib.rs", "core/spec/v1/auths-proof.cddl" ], - "sha256": "d578e912196bc03ac2c4bcd776692477eb109b0a63ace9a62aa786a18fcffad0" + "sha256": "b25f8524054a9e4be952fd475e4d3f5608e2af9da14f09584f4e274f518b90d1" }, { "id": "auths.product.bounded-domains", @@ -762,7 +762,7 @@ }, { "id": "auths.product.facade", - "version": 11, + "version": 12, "classification": "frozen-meaning", "categories": [ "create", @@ -781,7 +781,7 @@ "bindings/typescript/src/profiles/mcp/index.ts", "bindings/typescript/src/service.ts" ], - "sha256": "bba6b96f65a4615b9afc09502fd6de2b55186502c6de135ffcb97c766951e4a4" + "sha256": "9ea3793cc86af4a0229fb53b0a47c01ac02d50cca99486aeea472fb586c2db0b" }, { "id": "auths.product.lifecycle", @@ -897,7 +897,7 @@ }, { "id": "auths.product.public-sdk-contract", - "version": 45, + "version": 48, "classification": "frozen-meaning", "categories": [ "rust-sdk-contract", @@ -915,7 +915,7 @@ "product/runtime/auths-runtime/src", "product/sdk/auths-sdk/src" ], - "sha256": "cb498d849c47ea9224c932f96244a5551999ebd53fc487b41400514e84d9ac74" + "sha256": "80a1755b9d451bc41d89e974bc927309ccb7533c1f55b3baa048c80c6fb9a815" }, { "id": "auths.product.receipts", @@ -974,7 +974,7 @@ }, { "id": "auths.product.vocabulary", - "version": 9, + "version": 10, "classification": "frozen-meaning", "categories": [ "customer-vocabulary", @@ -992,7 +992,7 @@ "product/sdk/auths-sdk/Cargo.toml", "xtask/src/sdk_vocabulary.rs" ], - "sha256": "70aa07a3354df6ff3a0875ea871467c02ae87b02982013a8935d7ea6845cd735" + "sha256": "838bfc68188fb543361c63be9b20ae1cd985006e6d29dc7bd74aca82c42435cf" }, { "id": "auths.release.benchmark-contract", @@ -1013,7 +1013,7 @@ }, { "id": "auths.release.evolution-contract", - "version": 19, + "version": 24, "classification": "frozen-meaning", "categories": [ "version-axes", @@ -1034,11 +1034,11 @@ "release/fixtures/evolution", "xtask/src/evolution_policy.rs" ], - "sha256": "8f74e3791acdd7e63b28b37ce812eff3ef5a339d5bfbe4ea81247af79dc5fe12" + "sha256": "58fa5bf435e11b3fbf6d4a8b06312f222dd16164314a3636649efcccad4462fa" }, { "id": "auths.release.public-surface", - "version": 135, + "version": 140, "classification": "release-metadata", "categories": [ "package-names", @@ -1133,7 +1133,7 @@ "xtask/src/release_control.rs", "xtask/src/semantic_freeze.rs" ], - "sha256": "e72e4df29318aa8945962cbbfff93db3e5f8297bbc50e7cd794088e95bb70bc4" + "sha256": "0f86f5c3101e7b18a18c2b6485fb0c8d4f53ddfbdfb1143833623ae65a99139d" } ] }