Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
15 changes: 14 additions & 1 deletion src/github_agent_bridge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
17 changes: 17 additions & 0 deletions src/github_agent_bridge/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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 "<root>"
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
Expand Down
68 changes: 68 additions & 0 deletions src/github_agent_bridge/policy.schema.json
Original file line number Diff line number Diff line change
@@ -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}
}}
}
}
30 changes: 30 additions & 0 deletions tests/test_validate_policy.py
Original file line number Diff line number Diff line change
@@ -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
Loading