From 3ba4d90ae139db8bb6b082b8a43e277e9714ef8b Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 5 May 2026 17:44:06 -0400 Subject: [PATCH 1/6] chore: declare sourceos-devtools manifest --- .sourceos/manifest.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .sourceos/manifest.json diff --git a/.sourceos/manifest.json b/.sourceos/manifest.json new file mode 100644 index 0000000..91cebe3 --- /dev/null +++ b/.sourceos/manifest.json @@ -0,0 +1,23 @@ +{ + "repo": "SourceOS-Linux/sourceos-devtools", + "domain": "tooling", + "specVersion": "0.1.0", + "ownedSchemas": [], + "syncEngines": [], + "sourceChannels": [], + "policyClasses": [ + "high" + ], + "auditEvents": [ + "devtools.contract.validated", + "devtools.repo.scanned" + ], + "dangerousSurfaces": [ + "devtools.schema.validation_bypass", + "devtools.repo_scan.incomplete" + ], + "authorityRepos": [ + "SourceOS-Linux/sourceos-spec" + ], + "notes": "Developer/operator tooling surface for SourceOS contract validation, repo scanning, graph doctor, sync doctor, and policy explanation commands." +} From e6649e991e3a76a62477f50a49d9affb0ff2d2e1 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 5 May 2026 19:27:21 -0400 Subject: [PATCH 2/6] feat: add contract validation and repo scanning commands --- sourceosctl/commands/contracts.py | 293 ++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 sourceosctl/commands/contracts.py diff --git a/sourceosctl/commands/contracts.py b/sourceosctl/commands/contracts.py new file mode 100644 index 0000000..2d6734e --- /dev/null +++ b/sourceosctl/commands/contracts.py @@ -0,0 +1,293 @@ +"""SourceOS contract validation and estate scanning helpers. + +The M1 implementation is intentionally local-only and dependency-light. It +validates JSON shape and the minimum SourceOS repo manifest contract until the +full schema mirror from SourceOS-Linux/sourceos-spec is vendored or fetched by a +future hardened validator. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Tuple + + +REQUIRED_REPO_MANIFEST_FIELDS = [ + "repo", + "domain", + "specVersion", + "ownedSchemas", + "syncEngines", + "sourceChannels", + "policyClasses", + "auditEvents", + "dangerousSurfaces", +] + +VALID_DOMAINS = { + "spec", + "tooling", + "workspace", + "agent", + "policy", + "memory", + "shell", + "browser", + "os", + "transport", + "observability", + "model", + "security", + "integration", +} + +VALID_POLICY_CLASSES = {"low", "medium", "high", "critical"} + + +def _load_json(path: Path) -> Tuple[Dict[str, Any] | None, List[str]]: + if not path.exists(): + return None, [f"missing file: {path}"] + if not path.is_file(): + return None, [f"not a file: {path}"] + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return None, [f"invalid JSON: {exc}"] + if not isinstance(payload, dict): + return None, ["top-level JSON value must be an object"] + return payload, [] + + +def validate_repo_manifest(payload: Dict[str, Any]) -> List[str]: + """Return validation errors for a SourceOSRepoManifest-like payload.""" + errors: List[str] = [] + for field in REQUIRED_REPO_MANIFEST_FIELDS: + if field not in payload: + errors.append(f"missing required field: {field}") + + repo = payload.get("repo") + if repo is not None and (not isinstance(repo, str) or "/" not in repo): + errors.append("repo must be a GitHub owner/name string") + + domain = payload.get("domain") + if domain is not None and domain not in VALID_DOMAINS: + errors.append(f"domain must be one of: {', '.join(sorted(VALID_DOMAINS))}") + + for list_field in [ + "ownedSchemas", + "syncEngines", + "sourceChannels", + "policyClasses", + "auditEvents", + "dangerousSurfaces", + ]: + value = payload.get(list_field) + if value is not None and not isinstance(value, list): + errors.append(f"{list_field} must be an array") + + policy_classes = payload.get("policyClasses") + if isinstance(policy_classes, list): + for policy_class in policy_classes: + if policy_class not in VALID_POLICY_CLASSES: + errors.append(f"invalid policy class: {policy_class}") + + sync_engines = payload.get("syncEngines") + if isinstance(sync_engines, list): + for index, engine in enumerate(sync_engines): + if not isinstance(engine, dict): + errors.append(f"syncEngines[{index}] must be an object") + continue + for field in ["engineId", "collection", "ownerRepo", "policyClass", "mergeStrategy"]: + if field not in engine: + errors.append(f"syncEngines[{index}] missing {field}") + + return errors + + +def _classify_manifest(path: Path) -> Dict[str, Any]: + payload, errors = _load_json(path) + if payload is None: + return {"path": str(path), "status": "missing-manifest", "errors": errors} + errors.extend(validate_repo_manifest(payload)) + status = "compliant" if not errors else "invalid-manifest" + return { + "path": str(path), + "repo": payload.get("repo"), + "domain": payload.get("domain"), + "status": status, + "errors": errors, + } + + +def contract_validate(args: argparse.Namespace) -> int: + path = Path(args.path) + payload, errors = _load_json(path) + if payload is not None and (path.name == "manifest.json" or "repo" in payload): + errors.extend(validate_repo_manifest(payload)) + + result = { + "path": str(path), + "status": "valid" if not errors else "invalid", + "errors": errors, + } + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(f"{result['status'].upper()}: {path}") + for error in errors: + print(f"- {error}") + return 0 if not errors else 1 + + +def repo_scan(args: argparse.Namespace) -> int: + root = Path(args.path) + manifest = root / ".sourceos" / "manifest.json" + result = _classify_manifest(manifest) + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(f"{result['status']}: {root}") + if result.get("repo"): + print(f"repo: {result['repo']}") + if result.get("domain"): + print(f"domain: {result['domain']}") + for error in result.get("errors", []): + print(f"- {error}") + return 0 if result["status"] == "compliant" else 1 + + +def _candidate_repos(root: Path) -> Iterable[Path]: + if (root / ".sourceos" / "manifest.json").exists(): + yield root + for child in sorted(root.iterdir()) if root.exists() and root.is_dir() else []: + if child.is_dir() and (child / ".sourceos" / "manifest.json").exists(): + yield child + + +def estate_scan(args: argparse.Namespace) -> int: + root = Path(args.path) + results = [_classify_manifest(repo / ".sourceos" / "manifest.json") for repo in _candidate_repos(root)] + missing = not results + if args.json: + print(json.dumps({"root": str(root), "results": results}, indent=2, sort_keys=True)) + else: + if missing: + print(f"missing-manifest: no .sourceos/manifest.json files found under {root}") + for result in results: + print(f"{result['status']}: {result.get('repo') or result['path']}") + for error in result.get("errors", []): + print(f" - {error}") + return 1 if missing or any(r["status"] != "compliant" for r in results) else 0 + + +def graph_doctor(args: argparse.Namespace) -> int: + print("SourceGraph doctor: contract surface present; runtime graph backend not configured in sourceos-devtools.") + print("Expected contracts: SourceGraphWrite, AuditEvent, PolicyDecision, AgentCapabilityLease.") + return 0 + + +def sync_doctor(args: argparse.Namespace) -> int: + print("SourceSync doctor: local manifest validation available; relay/sync runtime checks are not configured here.") + print("Expected contracts: SourceOSRepoManifest and SyncEngineManifest.") + return 0 + + +def policy_explain(args: argparse.Namespace) -> int: + payload, errors = _load_json(Path(args.path)) + if errors: + for error in errors: + print(f"- {error}") + return 1 + decision = payload.get("decision") or payload.get("outcome") or "unknown" + reason = payload.get("reasonCode") or payload.get("decisionHash") or "no reasonCode/decisionHash present" + print(f"decision: {decision}") + print(f"reason: {reason}") + if payload.get("policyId"): + print(f"policy: {payload['policyId']}") + if payload.get("policyDomain"): + print(f"policyDomain: {payload['policyDomain']}") + return 0 + + +def build_contract_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="sourceosctl contract", description="SourceOS contract helpers") + sub = parser.add_subparsers(dest="contract_command", metavar="") + sub.required = True + validate_p = sub.add_parser("validate", help="Validate a JSON contract file") + validate_p.add_argument("path") + validate_p.add_argument("--json", action="store_true", default=False) + validate_p.set_defaults(func=contract_validate) + return parser + + +def contract_main(argv: List[str] | None = None) -> int: + parser = build_contract_parser() + args = parser.parse_args(argv) + return args.func(args) or 0 + + +def build_repo_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="sourceosctl repo", description="SourceOS repo helpers") + sub = parser.add_subparsers(dest="repo_command", metavar="") + sub.required = True + scan_p = sub.add_parser("scan", help="Scan one repo for .sourceos/manifest.json") + scan_p.add_argument("path") + scan_p.add_argument("--json", action="store_true", default=False) + scan_p.set_defaults(func=repo_scan) + return parser + + +def repo_main(argv: List[str] | None = None) -> int: + parser = build_repo_parser() + args = parser.parse_args(argv) + return args.func(args) or 0 + + +def build_estate_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="sourceosctl estate", description="SourceOS estate helpers") + sub = parser.add_subparsers(dest="estate_command", metavar="") + sub.required = True + scan_p = sub.add_parser("scan", help="Scan child repos for .sourceos/manifest.json") + scan_p.add_argument("path", nargs="?", default=".") + scan_p.add_argument("--json", action="store_true", default=False) + scan_p.set_defaults(func=estate_scan) + return parser + + +def estate_main(argv: List[str] | None = None) -> int: + parser = build_estate_parser() + args = parser.parse_args(argv) + return args.func(args) or 0 + + +def graph_main(argv: List[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="sourceosctl graph", description="SourceGraph helpers") + sub = parser.add_subparsers(dest="graph_command", metavar="") + sub.required = True + doctor_p = sub.add_parser("doctor", help="Inspect SourceGraph contract posture") + doctor_p.set_defaults(func=graph_doctor) + args = parser.parse_args(argv) + return args.func(args) or 0 + + +def sync_main(argv: List[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="sourceosctl sync", description="SourceSync helpers") + sub = parser.add_subparsers(dest="sync_command", metavar="") + sub.required = True + doctor_p = sub.add_parser("doctor", help="Inspect SourceSync contract posture") + doctor_p.set_defaults(func=sync_doctor) + args = parser.parse_args(argv) + return args.func(args) or 0 + + +def policy_main(argv: List[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="sourceosctl policy", description="SourcePolicy helpers") + sub = parser.add_subparsers(dest="policy_command", metavar="") + sub.required = True + explain_p = sub.add_parser("explain", help="Explain a PolicyDecision/AuditEvent JSON file") + explain_p.add_argument("path") + explain_p.set_defaults(func=policy_explain) + args = parser.parse_args(argv) + return args.func(args) or 0 From a258f9f9f5206b710a1f2eeabf4152e74fe4d3de Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 5 May 2026 19:39:27 -0400 Subject: [PATCH 3/6] feat: route contract and estate scanner commands --- bin/sourceosctl | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bin/sourceosctl b/bin/sourceosctl index 81998c8..db27135 100755 --- a/bin/sourceosctl +++ b/bin/sourceosctl @@ -9,6 +9,36 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Lightweight plugin routing for newer command groups while keeping the core # argparse surface stable. These command groups are non-mutating plan/probe # surfaces and own their own subparsers. +if len(sys.argv) > 1 and sys.argv[1] == "contract": + from sourceosctl.commands.contracts import contract_main + + sys.exit(contract_main(sys.argv[2:])) + +if len(sys.argv) > 1 and sys.argv[1] == "repo": + from sourceosctl.commands.contracts import repo_main + + sys.exit(repo_main(sys.argv[2:])) + +if len(sys.argv) > 1 and sys.argv[1] == "estate": + from sourceosctl.commands.contracts import estate_main + + sys.exit(estate_main(sys.argv[2:])) + +if len(sys.argv) > 1 and sys.argv[1] == "graph": + from sourceosctl.commands.contracts import graph_main + + sys.exit(graph_main(sys.argv[2:])) + +if len(sys.argv) > 1 and sys.argv[1] == "sync": + from sourceosctl.commands.contracts import sync_main + + sys.exit(sync_main(sys.argv[2:])) + +if len(sys.argv) > 1 and sys.argv[1] == "policy": + from sourceosctl.commands.contracts import policy_main + + sys.exit(policy_main(sys.argv[2:])) + if len(sys.argv) > 1 and sys.argv[1] == "network": from sourceosctl.commands.network import network_main From a8b0960ad8da44f5ed08f87ca44135217f85dd93 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 5 May 2026 19:46:32 -0400 Subject: [PATCH 4/6] test: cover contract validation and repo scanner --- tests/test_contracts.py | 88 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_contracts.py diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..a33d4a0 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,88 @@ +"""Tests for SourceOS contract validation and repo scanning commands.""" + +import json +import pathlib +import sys +import tempfile +import unittest + +_REPO_ROOT = pathlib.Path(__file__).parent.parent +sys.path.insert(0, str(_REPO_ROOT)) + +from sourceosctl.commands import contracts + + +VALID_MANIFEST = { + "repo": "SourceOS-Linux/sourceos-devtools", + "domain": "tooling", + "specVersion": "0.1.0", + "ownedSchemas": [], + "syncEngines": [], + "sourceChannels": [], + "policyClasses": ["high"], + "auditEvents": ["devtools.contract.validated"], + "dangerousSurfaces": ["devtools.schema.validation_bypass"], +} + + +class TestContractValidation(unittest.TestCase): + def test_validate_repo_manifest_accepts_valid_manifest(self): + errors = contracts.validate_repo_manifest(dict(VALID_MANIFEST)) + self.assertEqual(errors, []) + + def test_validate_repo_manifest_rejects_missing_required_field(self): + payload = dict(VALID_MANIFEST) + payload.pop("repo") + errors = contracts.validate_repo_manifest(payload) + self.assertIn("missing required field: repo", errors) + + def test_validate_repo_manifest_rejects_invalid_domain(self): + payload = dict(VALID_MANIFEST) + payload["domain"] = "unknown" + errors = contracts.validate_repo_manifest(payload) + self.assertTrue(any("domain must be one of" in error for error in errors)) + + def test_validate_repo_manifest_rejects_invalid_policy_class(self): + payload = dict(VALID_MANIFEST) + payload["policyClasses"] = ["root"] + errors = contracts.validate_repo_manifest(payload) + self.assertIn("invalid policy class: root", errors) + + def test_contract_validate_valid_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "manifest.json" + path.write_text(json.dumps(VALID_MANIFEST), encoding="utf-8") + args = type("Args", (), {"path": str(path), "json": True})() + self.assertEqual(contracts.contract_validate(args), 0) + + def test_contract_validate_bad_json(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "manifest.json" + path.write_text("not json", encoding="utf-8") + args = type("Args", (), {"path": str(path), "json": True})() + self.assertEqual(contracts.contract_validate(args), 1) + + +class TestRepoScan(unittest.TestCase): + def test_repo_scan_valid_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + manifest_dir = root / ".sourceos" + manifest_dir.mkdir() + (manifest_dir / "manifest.json").write_text(json.dumps(VALID_MANIFEST), encoding="utf-8") + args = type("Args", (), {"path": str(root), "json": True})() + self.assertEqual(contracts.repo_scan(args), 0) + + def test_repo_scan_missing_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + args = type("Args", (), {"path": tmp, "json": True})() + self.assertEqual(contracts.repo_scan(args), 1) + + def test_graph_and_sync_doctors_are_non_mutating(self): + args = type("Args", (), {})() + self.assertEqual(contracts.graph_doctor(args), 0) + self.assertEqual(contracts.sync_doctor(args), 0) + + +if __name__ == "__main__": + unittest.main() From 441c7d5b1f3aa4126ca484d9dc3ea54a353e8ab4 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 5 May 2026 19:54:38 -0400 Subject: [PATCH 5/6] docs: add agentic graph contract validation guide --- .../agentic-graph-contract-validation.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/integration/agentic-graph-contract-validation.md diff --git a/docs/integration/agentic-graph-contract-validation.md b/docs/integration/agentic-graph-contract-validation.md new file mode 100644 index 0000000..e067d93 --- /dev/null +++ b/docs/integration/agentic-graph-contract-validation.md @@ -0,0 +1,90 @@ +# Agentic Graph Contract Validation + +Status: draft +Related spec: `SourceOS-Linux/sourceos-spec#94` +Related tracker: `SourceOS-Linux/sourceos-spec#86` + +## Purpose + +This document defines the first `sourceosctl` tooling slice for the SourceOS/SociOS local-first agentic graph foundation. + +The M1 objective is intentionally bounded: validate `.sourceos/manifest.json`, scan repos for contract posture, and provide non-mutating doctor/explain commands before runtime sync, agent, browser, shell, or relay integrations begin. + +## Commands + +### Validate a contract file + +```bash +python3 bin/sourceosctl contract validate .sourceos/manifest.json +python3 bin/sourceosctl contract validate .sourceos/manifest.json --json +``` + +The first validator pass checks JSON parseability and the minimum `SourceOSRepoManifest` shape. + +### Scan one repo + +```bash +python3 bin/sourceosctl repo scan . +python3 bin/sourceosctl repo scan . --json +``` + +The repo scanner checks for `.sourceos/manifest.json` and reports whether it is compliant, missing, or invalid. + +### Scan an estate root + +```bash +python3 bin/sourceosctl estate scan ~/dev +python3 bin/sourceosctl estate scan ~/dev --json +``` + +The estate scanner checks immediate child repos for `.sourceos/manifest.json` and reports each repo status. + +### Inspect graph/sync posture + +```bash +python3 bin/sourceosctl graph doctor +python3 bin/sourceosctl sync doctor +``` + +These are non-mutating posture probes. Runtime graph and sync backends are not configured in `sourceos-devtools` yet. + +### Explain a policy/audit JSON file + +```bash +python3 bin/sourceosctl policy explain path/to/decision-or-audit.json +``` + +This prints the available decision/outcome, reason, policy ID, and policy domain fields. + +## M1 validation statuses + +The scanner uses these status classes: + +- `compliant` +- `missing-manifest` +- `invalid-manifest` + +Future hardening should add: + +- `partial` +- `missing-required-engine` +- `missing-policy-class` +- `missing-audit-events` +- `schema-version-mismatch` +- `authority-repo-mismatch` + +## Current limitations + +- The validator is dependency-light and does not yet perform full JSON Schema draft 2020-12 validation. +- Schema loading from `SourceOS-Linux/sourceos-spec` is not yet vendored or pinned. +- Estate scanning currently checks immediate child directories only. +- Runtime SourceGraph, SourceSync, SourcePolicy, and SourceChannel backends are not configured in this repo. + +## Acceptance criteria for this slice + +1. `sourceos-devtools` declares a `.sourceos/manifest.json`. +2. `sourceosctl contract validate` can validate the local manifest shape. +3. `sourceosctl repo scan` can classify a repo manifest. +4. `sourceosctl estate scan` can classify child repos with manifests. +5. `sourceosctl graph doctor`, `sourceosctl sync doctor`, and `sourceosctl policy explain` are present and non-mutating. +6. Tests cover manifest validation, repo scan, and doctor commands. From 0b9a3f701e2c64e7cb881112251bf520f6021532 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 5 May 2026 20:13:18 -0400 Subject: [PATCH 6/6] fix: keep contract helpers Python 3.8 compatible --- sourceosctl/commands/contracts.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/sourceosctl/commands/contracts.py b/sourceosctl/commands/contracts.py index 2d6734e..513174b 100644 --- a/sourceosctl/commands/contracts.py +++ b/sourceosctl/commands/contracts.py @@ -11,7 +11,7 @@ import argparse import json from pathlib import Path -from typing import Any, Dict, Iterable, List, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple REQUIRED_REPO_MANIFEST_FIELDS = [ @@ -46,7 +46,7 @@ VALID_POLICY_CLASSES = {"low", "medium", "high", "critical"} -def _load_json(path: Path) -> Tuple[Dict[str, Any] | None, List[str]]: +def _load_json(path: Path) -> Tuple[Optional[Dict[str, Any]], List[str]]: if not path.exists(): return None, [f"missing file: {path}"] if not path.is_file(): @@ -161,7 +161,8 @@ def repo_scan(args: argparse.Namespace) -> int: def _candidate_repos(root: Path) -> Iterable[Path]: if (root / ".sourceos" / "manifest.json").exists(): yield root - for child in sorted(root.iterdir()) if root.exists() and root.is_dir() else []: + children = sorted(root.iterdir()) if root.exists() and root.is_dir() else [] + for child in children: if child.is_dir() and (child / ".sourceos" / "manifest.json").exists(): yield child @@ -222,7 +223,7 @@ def build_contract_parser() -> argparse.ArgumentParser: return parser -def contract_main(argv: List[str] | None = None) -> int: +def contract_main(argv: Optional[List[str]] = None) -> int: parser = build_contract_parser() args = parser.parse_args(argv) return args.func(args) or 0 @@ -239,7 +240,7 @@ def build_repo_parser() -> argparse.ArgumentParser: return parser -def repo_main(argv: List[str] | None = None) -> int: +def repo_main(argv: Optional[List[str]] = None) -> int: parser = build_repo_parser() args = parser.parse_args(argv) return args.func(args) or 0 @@ -256,13 +257,13 @@ def build_estate_parser() -> argparse.ArgumentParser: return parser -def estate_main(argv: List[str] | None = None) -> int: +def estate_main(argv: Optional[List[str]] = None) -> int: parser = build_estate_parser() args = parser.parse_args(argv) return args.func(args) or 0 -def graph_main(argv: List[str] | None = None) -> int: +def graph_main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser(prog="sourceosctl graph", description="SourceGraph helpers") sub = parser.add_subparsers(dest="graph_command", metavar="") sub.required = True @@ -272,7 +273,7 @@ def graph_main(argv: List[str] | None = None) -> int: return args.func(args) or 0 -def sync_main(argv: List[str] | None = None) -> int: +def sync_main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser(prog="sourceosctl sync", description="SourceSync helpers") sub = parser.add_subparsers(dest="sync_command", metavar="") sub.required = True @@ -282,7 +283,7 @@ def sync_main(argv: List[str] | None = None) -> int: return args.func(args) or 0 -def policy_main(argv: List[str] | None = None) -> int: +def policy_main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser(prog="sourceosctl policy", description="SourcePolicy helpers") sub = parser.add_subparsers(dest="policy_command", metavar="") sub.required = True