diff --git a/README.md b/README.md index a1d7ffd..e5515da 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,14 @@ gab --db "$DB" --policy ./policy.example.json run --mode shadow --once ## Policy in one screen +Validate policy files before deploying them: + +```bash +gab validate-policy --policy ./policy.json +``` + +The published JSON Schema is [`src/github_agent_bridge/policy.schema.json`](src/github_agent_bridge/policy.schema.json). Editors can use it for completion and inline diagnostics; the command also runs semantic checks such as prompt override file existence. + The bridge is conservative by default. `policy.json` decides what is trusted, what is in scope, where work is delivered, and how the agent should behave. ```json diff --git a/docs/policy-reference.md b/docs/policy-reference.md index 0a8ab11..60a935e 100644 --- a/docs/policy-reference.md +++ b/docs/policy-reference.md @@ -2,6 +2,14 @@ `policy.json` controls which GitHub notifications the bridge trusts, which repositories are in scope, which actions are automatic, where OpenClaw agent work is delivered, which operating posture the agent uses, whether the optional LLM intent classifier is enabled, and whether feedback learning is captured. +The machine-readable JSON Schema lives at [`src/github_agent_bridge/policy.schema.json`](../src/github_agent_bridge/policy.schema.json). Before restarting the service, validate an edited policy with: + +```bash +gab validate-policy --policy ~/.config/github-agent-bridge/policy.json +``` + +Schema errors include the offending dotted path and reject unknown fields. Validation also applies runtime semantic checks, including referenced prompt override files. + ## Quick map ```mermaid diff --git a/pyproject.toml b/pyproject.toml index 24a192a..7b566cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.61.0" description = "GitHub notification to OpenClaw agent bridge for Pilipilis" readme = "README.md" requires-python = ">=3.11" -dependencies = ["pywebpush>=2.0"] +dependencies = ["jsonschema>=4.18", "pywebpush>=2.0"] [project.optional-dependencies] dashboard = ["fastapi>=0.110", "uvicorn>=0.29"] diff --git a/src/github_agent_bridge/cli.py b/src/github_agent_bridge/cli.py index a73d47c..f815b47 100644 --- a/src/github_agent_bridge/cli.py +++ b/src/github_agent_bridge/cli.py @@ -23,7 +23,7 @@ from .mcp import authenticate_token, create_token, list_tokens, revoke_token, serve_stdio from .observability import DEFAULT_PROCESS_SAMPLE_RETENTION_SECONDS, configure_sentry from .parser import decode_header_value, extract_body_text, is_github_notification_message, parse_auth_results -from .policy import Policy +from .policy import Policy, validate_policy_file from .queue import JobQueue from .reader import ImapConfig, ImapReader, imap_mailbox_arg @@ -90,6 +90,16 @@ def cmd_init_db(args: argparse.Namespace) -> int: return 0 +def cmd_validate_policy(args: argparse.Namespace) -> int: + try: + validate_policy_file(args.policy) + except (OSError, json.JSONDecodeError, ValueError) as exc: + print(f"invalid policy: {exc}", file=sys.stderr) + return 1 + print(f"valid policy: {Path(args.policy).expanduser()}") + return 0 + + def cmd_enqueue_json(args: argparse.Namespace) -> int: q = JobQueue(args.db); policy = load_policy(args.policy) data = json.loads(Path(args.file).read_text(encoding="utf-8")) if args.file != "-" else json.load(__import__("sys").stdin) @@ -443,6 +453,9 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--policy", default=None) sub = p.add_subparsers(required=True) s = sub.add_parser("init-db"); s.set_defaults(func=cmd_init_db) + s = sub.add_parser("validate-policy", help="validate a policy file against the published schema") + s.add_argument("--policy", required=True) + s.set_defaults(func=cmd_validate_policy) s = sub.add_parser("enqueue-json"); s.add_argument("file"); s.set_defaults(func=cmd_enqueue_json) s = sub.add_parser("enqueue-comment-url", help="fetch a GitHub issue/PR comment URL and enqueue it as a trusted notification") s.add_argument("url") diff --git a/src/github_agent_bridge/policy.py b/src/github_agent_bridge/policy.py index d2c317d..691c19c 100644 --- a/src/github_agent_bridge/policy.py +++ b/src/github_agent_bridge/policy.py @@ -2,8 +2,11 @@ import json from dataclasses import dataclass, field +from importlib.resources import files from pathlib import Path +from jsonschema import Draft202012Validator + from .models import GitHubContext, Notification ALLOWED_REPO_ROLES = {"owner", "maintainer", "contributor", "reviewer"} @@ -27,6 +30,20 @@ DEFAULT_BOT_LOGINS = frozenset({"pilipilisbot"}) +def validate_policy_file(path: str | Path) -> None: + policy_path = Path(path).expanduser() + data = json.loads(policy_path.read_text(encoding="utf-8")) + schema = json.loads(files("github_agent_bridge").joinpath("policy.schema.json").read_text(encoding="utf-8")) + errors = sorted(Draft202012Validator(schema).iter_errors(data), key=lambda error: list(error.absolute_path)) + if errors: + details = [] + for error in errors: + location = ".".join(str(part) for part in error.absolute_path) or "" + details.append(f"{location}: {error.message}") + raise ValueError("policy schema validation failed:\n" + "\n".join(details)) + Policy.from_file(policy_path) + + def complexity_from_metadata(metadata: dict | None) -> str: classifier = (metadata or {}).get("intent_classifier") llm = classifier.get("llm") if isinstance(classifier, dict) else None diff --git a/src/github_agent_bridge/policy.schema.json b/src/github_agent_bridge/policy.schema.json new file mode 100644 index 0000000..fc9e60d --- /dev/null +++ b/src/github_agent_bridge/policy.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/gisce/github-agent-bridge/blob/main/src/github_agent_bridge/policy.schema.json", + "title": "GitHub Agent Bridge policy", + "type": "object", + "additionalProperties": false, + "properties": { + "source": {"$ref": "#/$defs/source"}, "trustedRepos": {"$ref": "#/$defs/stringArray"}, + "trustedOrgs": {"$ref": "#/$defs/stringArray"}, "enabledRepos": {"$ref": "#/$defs/stringArray"}, + "botLogins": {"$ref": "#/$defs/stringArray"}, "repoRoutes": {"$ref": "#/$defs/routeMap"}, + "orgRoutes": {"$ref": "#/$defs/routeMap"}, "actions": {"$ref": "#/$defs/actions"}, + "repoRoles": {"$ref": "#/$defs/roleMap"}, "orgRoles": {"$ref": "#/$defs/roleMap"}, + "modelRoutes": {"$ref": "#/$defs/modelRoutes"}, "promptOverrides": {"$ref": "#/$defs/promptOverrides"}, + "feedbackLearning": {"$ref": "#/$defs/feedbackLearning"}, "intentClassifier": {"$ref": "#/$defs/intentClassifier"} + }, + "$defs": { + "stringArray": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "source": {"type": "object", "additionalProperties": false, "properties": { + "from": {"oneOf": [{"type": "string"}, {"$ref": "#/$defs/stringArray"}]}, + "requiredAuth": {"$ref": "#/$defs/stringArray"}, "requiredUrlPrefix": {"type": "string", "minLength": 1}, + "messageIdDomain": {"type": "string", "minLength": 1} + }}, + "route": {"type": "object", "additionalProperties": false, "properties": { + "agent": {"type": "string"}, "channel": {"type": "string"}, "to": {"type": "string"} + }}, + "routeMap": {"type": "object", "additionalProperties": {"$ref": "#/$defs/route"}}, + "actions": {"type": "object", "additionalProperties": false, "properties": { + "auto": {"$ref": "#/$defs/stringArray"}, "ask": {"$ref": "#/$defs/stringArray"}, + "trustedAuto": {"$ref": "#/$defs/stringArray"}, "deny": {"$ref": "#/$defs/stringArray"} + }}, + "roleMap": {"type": "object", "additionalProperties": {"enum": ["owner", "maintainer", "contributor", "reviewer"]}}, + "thinking": {"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"]}, + "modelRoute": {"type": "object", "additionalProperties": false, "properties": { + "model": {"type": "string", "minLength": 1}, "thinking": {"$ref": "#/$defs/thinking"} + }}, + "modelRouteMap": {"type": "object", "additionalProperties": {"$ref": "#/$defs/modelRoute"}}, + "complexityRouteMap": {"type": "object", "additionalProperties": false, "properties": { + "mechanical": {"$ref": "#/$defs/modelRoute"}, "substantive": {"$ref": "#/$defs/modelRoute"} + }}, + "repoModelRoutes": {"type": "object", "additionalProperties": false, "properties": { + "default": {"$ref": "#/$defs/modelRoute"}, "byAction": {"$ref": "#/$defs/modelRouteMap"}, + "byIntent": {"$ref": "#/$defs/modelRouteMap"}, "byComplexity": {"$ref": "#/$defs/complexityRouteMap"} + }}, + "modelRoutes": {"type": "object", "additionalProperties": false, "properties": { + "default": {"$ref": "#/$defs/modelRoute"}, "byAction": {"$ref": "#/$defs/modelRouteMap"}, + "byIntent": {"$ref": "#/$defs/modelRouteMap"}, "byComplexity": {"$ref": "#/$defs/complexityRouteMap"}, + "byRepo": {"type": "object", "additionalProperties": {"$ref": "#/$defs/repoModelRoutes"}} + }}, + "pathMap": {"type": "object", "additionalProperties": {"type": "string", "minLength": 1}}, + "promptOverrides": {"type": "object", "additionalProperties": false, "properties": { + "base": {"type": "string", "minLength": 1}, "roles": {"$ref": "#/$defs/pathMap"}, + "intents": {"$ref": "#/$defs/pathMap"}, "rules": {"$ref": "#/$defs/pathMap"} + }}, + "feedbackLearning": {"type": "object", "additionalProperties": false, "properties": { + "enabled": {"type": "boolean"}, "minConfidence": {"type": "number", "minimum": 0, "maximum": 1}, + "autoApproveConfidence": {"type": "number", "minimum": 0, "maximum": 1}, + "maxEventsPerRun": {"type": "integer", "minimum": 1}, "model": {"type": "string", "minLength": 1}, + "thinking": {"$ref": "#/$defs/thinking"}, "sessionId": {"type": "string", "minLength": 1}, + "fallbackToDefaultModel": {"type": "boolean"} + }}, + "intentClassifier": {"type": "object", "additionalProperties": false, "properties": { + "enabled": {"type": "boolean"}, "model": {"type": "string", "minLength": 1}, + "thinking": {"$ref": "#/$defs/thinking"}, "minConfidence": {"type": "number", "minimum": 0, "maximum": 1}, + "onlyWhenParserDefaulted": {"type": "boolean"}, "openclawBin": {"type": "string", "minLength": 1}, + "sessionId": {"type": "string", "minLength": 1}, "timeout": {"type": "integer", "minimum": 1} + }} + } +} diff --git a/tests/test_validate_policy.py b/tests/test_validate_policy.py new file mode 100644 index 0000000..68d47c1 --- /dev/null +++ b/tests/test_validate_policy.py @@ -0,0 +1,30 @@ +import json + +from github_agent_bridge.cli import main +from github_agent_bridge.policy import validate_policy_file + + +def test_example_policy_matches_published_schema(): + validate_policy_file("policy.example.json") + + +def test_validate_policy_cli_reports_unknown_field(tmp_path, capsys): + policy = tmp_path / "policy.json" + policy.write_text(json.dumps({"feedbackLearning": {"surprise": True}})) + + result = main(["validate-policy", "--policy", str(policy)]) + + assert result == 1 + error = capsys.readouterr().err + assert "feedbackLearning" in error + assert "surprise" in error + + +def test_validate_policy_cli_accepts_valid_policy(tmp_path, capsys): + policy = tmp_path / "policy.json" + policy.write_text(json.dumps({"trustedOrgs": ["gisce"]})) + + result = main(["validate-policy", "--policy", str(policy)]) + + assert result == 0 + assert "valid policy" in capsys.readouterr().out