canon-gate 0.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- canon_gate/__init__.py +1 -0
- canon_gate/boundary/__init__.py +1 -0
- canon_gate/boundary/adapters/__init__.py +13 -0
- canon_gate/boundary/adapters/mock_adapter.py +76 -0
- canon_gate/boundary/adapters/script_adapter.py +103 -0
- canon_gate/boundary/audit.py +39 -0
- canon_gate/boundary/decorator.py +50 -0
- canon_gate/boundary/main.py +162 -0
- canon_gate/cli.py +85 -0
- canon_gate/core/__init__.py +1 -0
- canon_gate/core/adapters.py +39 -0
- canon_gate/core/config.py +89 -0
- canon_gate/core/manifest_generator.py +66 -0
- canon_gate/core/models.py +133 -0
- canon_gate/core/orchestration.py +104 -0
- canon_gate/core/policy.py +60 -0
- canon_gate/demo.py +122 -0
- canon_gate/resources/__init__.py +1 -0
- canon_gate/resources/adapter_policy.example.toml +39 -0
- canon_gate/resources/policy.example.toml +23 -0
- canon_gate-0.5.2.dist-info/METADATA +345 -0
- canon_gate-0.5.2.dist-info/RECORD +37 -0
- canon_gate-0.5.2.dist-info/WHEEL +5 -0
- canon_gate-0.5.2.dist-info/entry_points.txt +2 -0
- canon_gate-0.5.2.dist-info/licenses/LICENSE +177 -0
- canon_gate-0.5.2.dist-info/licenses/NOTICE +3 -0
- canon_gate-0.5.2.dist-info/top_level.txt +4 -0
- operator_console/__init__.py +0 -0
- operator_console/main.py +95 -0
- operator_console/models.py +49 -0
- operator_console/static/console.html +218 -0
- runtime_host/__init__.py +0 -0
- runtime_host/main.py +289 -0
- scripts/delete_demonstration_artifact.py +20 -0
- scripts/fail.py +3 -0
- scripts/health_check.py +3 -0
- scripts/write_demonstration_artifact.py +23 -0
canon_gate/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This file makes 'canon_gate' a Python package.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Boundary interaction layer
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This package contains adapter implementations that are part of the boundary layer.
|
|
3
|
+
|
|
4
|
+
Adapters are responsible for:
|
|
5
|
+
1. **Egress Validation:** Applying strict, deterministic checks to the parameters
|
|
6
|
+
of a proposed action before execution.
|
|
7
|
+
2. **Execution:** Interacting with external systems (e.g., databases, APIs,
|
|
8
|
+
shell commands) to carry out the approved action.
|
|
9
|
+
"""
|
|
10
|
+
from .mock_adapter import MockRunQueryAdapter
|
|
11
|
+
from .script_adapter import ScriptAdapter
|
|
12
|
+
|
|
13
|
+
__all__ = ["MockRunQueryAdapter", "ScriptAdapter"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
4
|
+
from canon_gate.core.adapters import BaseAdapter
|
|
5
|
+
from canon_gate.core.config import RUN_QUERY_FORBIDDEN_KEYWORDS
|
|
6
|
+
from canon_gate.core.models import (
|
|
7
|
+
ActionProposal,
|
|
8
|
+
ActionRejected,
|
|
9
|
+
AdapterAction,
|
|
10
|
+
AdapterManifest,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from canon_gate.core.models import ActionProposal
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MockAdapterParameters(BaseModel):
|
|
18
|
+
query: str = Field(..., description="The read-only SQL query to execute.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MockRunQueryAdapter(BaseAdapter):
|
|
22
|
+
"""
|
|
23
|
+
A mock adapter for demonstration purposes. It handles the 'run_query' action
|
|
24
|
+
and performs its own egress validation on the SQL parameters before execution.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def get_manifest(self) -> AdapterManifest:
|
|
28
|
+
"""Returns the manifest describing the adapter's capabilities."""
|
|
29
|
+
return AdapterManifest(
|
|
30
|
+
adapter_name="run_query_adapter",
|
|
31
|
+
actions=[
|
|
32
|
+
AdapterAction.from_pydantic_model(
|
|
33
|
+
name="run_query",
|
|
34
|
+
description="Runs a read-only SQL query against a mock database.",
|
|
35
|
+
model=MockAdapterParameters,
|
|
36
|
+
)
|
|
37
|
+
],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _validate_egress(self, query: str) -> list[str]:
|
|
41
|
+
"""
|
|
42
|
+
Performs a final validation check on the SQL query itself.
|
|
43
|
+
This is the Egress Validation step.
|
|
44
|
+
"""
|
|
45
|
+
errors = []
|
|
46
|
+
sql_lower = query.lower()
|
|
47
|
+
for keyword in RUN_QUERY_FORBIDDEN_KEYWORDS:
|
|
48
|
+
if keyword in sql_lower:
|
|
49
|
+
errors.append(f"SQL query contains forbidden keyword: '{keyword}'")
|
|
50
|
+
return errors
|
|
51
|
+
|
|
52
|
+
def execute(self, proposal: "ActionProposal") -> dict:
|
|
53
|
+
"""
|
|
54
|
+
Validates the egress parameters and then simulates executing a SQL query.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
params = MockAdapterParameters.model_validate(proposal.parameters)
|
|
58
|
+
except ValidationError as e:
|
|
59
|
+
raise ActionRejected(f"Egress validation failed: Invalid parameters: {e}")
|
|
60
|
+
|
|
61
|
+
# Perform Egress Validation
|
|
62
|
+
egress_errors = self._validate_egress(params.query)
|
|
63
|
+
if egress_errors:
|
|
64
|
+
raise ActionRejected(f"Egress validation failed: {', '.join(egress_errors)}")
|
|
65
|
+
|
|
66
|
+
print("---")
|
|
67
|
+
print(f"[Mock Adapter] INFO: Egress validation passed for action 'run_query'.")
|
|
68
|
+
print(f"[Mock Adapter] INFO: Would execute query against production database: {params.query}")
|
|
69
|
+
print("---")
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"status": "success",
|
|
73
|
+
"message": "Query was mock-executed successfully after passing egress validation.",
|
|
74
|
+
"query_sent_to_db": params.query,
|
|
75
|
+
"egress_checks_passed": True,
|
|
76
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# canon_gate/boundary/adapters/script_adapter.py
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
8
|
+
|
|
9
|
+
from canon_gate.core.adapters import BaseAdapter
|
|
10
|
+
from canon_gate.core.config import SCRIPT_ADAPTER_ALLOWED_SCRIPTS
|
|
11
|
+
from canon_gate.core.models import (
|
|
12
|
+
ActionProposal,
|
|
13
|
+
ActionRejected,
|
|
14
|
+
AdapterAction,
|
|
15
|
+
AdapterManifest,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# The base path to the directory where the scripts are stored
|
|
19
|
+
SCRIPT_DIRECTORY = Path(__file__).parent.parent.parent.parent / "scripts"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# 1. Define the parameter schema for the adapter's action
|
|
23
|
+
class ScriptAdapterParameters(BaseModel):
|
|
24
|
+
script_name: str = Field(
|
|
25
|
+
..., description="The name of the script to execute (without the .py extension)."
|
|
26
|
+
)
|
|
27
|
+
script_args: list[str] = Field(
|
|
28
|
+
default=[], description="A list of arguments to pass to the script."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ScriptAdapter(BaseAdapter):
|
|
33
|
+
"""
|
|
34
|
+
An adapter for executing predefined python scripts from the /scripts directory.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# 2. Implement get_manifest to make the adapter self-describing
|
|
38
|
+
def get_manifest(self) -> AdapterManifest:
|
|
39
|
+
"""Returns the manifest describing the adapter's capabilities."""
|
|
40
|
+
return AdapterManifest(
|
|
41
|
+
adapter_name="execute_script_adapter",
|
|
42
|
+
actions=[
|
|
43
|
+
AdapterAction.from_pydantic_model(
|
|
44
|
+
name="execute_script",
|
|
45
|
+
description="Executes a predefined system script.",
|
|
46
|
+
model=ScriptAdapterParameters,
|
|
47
|
+
)
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# 3. Refactor execute to use the parameter schema
|
|
52
|
+
def execute(self, proposal: ActionProposal) -> Dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
Executes a script after performing Egress validation.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
params = ScriptAdapterParameters.model_validate(proposal.parameters)
|
|
58
|
+
except ValidationError as e:
|
|
59
|
+
raise ActionRejected(f"Egress validation failed: Invalid parameters: {e}")
|
|
60
|
+
|
|
61
|
+
# Egress Validation: Policy check
|
|
62
|
+
if params.script_name not in SCRIPT_ADAPTER_ALLOWED_SCRIPTS:
|
|
63
|
+
raise ActionRejected(
|
|
64
|
+
f"Egress validation failed: Script '{params.script_name}' is not in the list of allowed scripts."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
script_path = SCRIPT_DIRECTORY / f"{params.script_name}.py"
|
|
68
|
+
|
|
69
|
+
# Egress Validation: Existence check
|
|
70
|
+
if not script_path.exists():
|
|
71
|
+
raise ActionRejected(
|
|
72
|
+
f"Egress validation failed: Script '{params.script_name}.py' not found at '{script_path}'."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Execution
|
|
77
|
+
command = [sys.executable, str(script_path)] + params.script_args
|
|
78
|
+
completed_process = subprocess.run(
|
|
79
|
+
command,
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
stdout = completed_process.stdout.strip()
|
|
86
|
+
stderr = completed_process.stderr.strip()
|
|
87
|
+
exit_code = completed_process.returncode
|
|
88
|
+
|
|
89
|
+
if exit_code == 0:
|
|
90
|
+
return {
|
|
91
|
+
"status": "succeeded",
|
|
92
|
+
"message": stdout or "Script executed successfully with no output.",
|
|
93
|
+
"output": {"stdout": stdout, "stderr": stderr, "exit_code": exit_code},
|
|
94
|
+
}
|
|
95
|
+
else:
|
|
96
|
+
return {
|
|
97
|
+
"status": "failed",
|
|
98
|
+
"message": stderr or stdout or f"Script failed with exit code {exit_code}.",
|
|
99
|
+
"output": {"stdout": stdout, "stderr": stderr, "exit_code": exit_code},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
raise ActionRejected(f"Execution failed: An unexpected error occurred: {str(e)}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from canon_gate.core.models import GateDecision
|
|
9
|
+
|
|
10
|
+
# This basic configuration sends structured JSON logs to stdout.
|
|
11
|
+
# In a real application, you would configure handlers to route this
|
|
12
|
+
# to a dedicated logging service or file.
|
|
13
|
+
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def log_denial(decision: "GateDecision"):
|
|
18
|
+
"""
|
|
19
|
+
Logs a denied GateDecision in a structured JSON format.
|
|
20
|
+
|
|
21
|
+
This creates a critical audit trail for security and review purposes.
|
|
22
|
+
"""
|
|
23
|
+
if decision.allowed:
|
|
24
|
+
# This function is specifically for logging denials.
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
log_data = {
|
|
28
|
+
"event": "action_denied",
|
|
29
|
+
"decision_id": decision.decision_id,
|
|
30
|
+
"timestamp": decision.timestamp.isoformat(),
|
|
31
|
+
"source": decision.source,
|
|
32
|
+
"action": decision.action,
|
|
33
|
+
"allowed": decision.allowed,
|
|
34
|
+
"errors": decision.errors,
|
|
35
|
+
"failed_checks": decision.failed_checks,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# The use of json.dumps creates a structured, machine-readable log entry.
|
|
39
|
+
logger.info(json.dumps(log_data))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from canon_gate.core.models import ActionProposal, GateDecision
|
|
7
|
+
from canon_gate.core.policy import validate_agent_output, ValidationResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def protected_by_canon_gate(action_type: str):
|
|
11
|
+
"""
|
|
12
|
+
A decorator to protect a function with Canon Gate's validation.
|
|
13
|
+
"""
|
|
14
|
+
def decorator(func: Callable[..., Any]):
|
|
15
|
+
@wraps(func)
|
|
16
|
+
def wrapper(*args, **kwargs):
|
|
17
|
+
# 1. Construct the generic ActionProposal from function arguments
|
|
18
|
+
proposal = ActionProposal(
|
|
19
|
+
action=action_type,
|
|
20
|
+
reason=f"Automated call to protected function '{func.__name__}'",
|
|
21
|
+
parameters={
|
|
22
|
+
"function_name": func.__name__,
|
|
23
|
+
"function_args": kwargs,
|
|
24
|
+
# Note: args are ignored in this simple version
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# 2. Validate the proposal
|
|
29
|
+
validation_result = validate_agent_output(proposal)
|
|
30
|
+
|
|
31
|
+
# 3. If allowed, execute the original function
|
|
32
|
+
if validation_result.allowed:
|
|
33
|
+
print(f"Canon Gate validated '{proposal.action}'. Executing function.")
|
|
34
|
+
return func(*args, **kwargs)
|
|
35
|
+
else:
|
|
36
|
+
# If not allowed, block execution and return a GateDecision
|
|
37
|
+
print(f"Canon Gate BLOCKED '{proposal.action}'. Reason: {validation_result.errors}")
|
|
38
|
+
|
|
39
|
+
decision = GateDecision(
|
|
40
|
+
source="decorator",
|
|
41
|
+
action=validation_result.action,
|
|
42
|
+
allowed=validation_result.allowed,
|
|
43
|
+
errors=validation_result.errors,
|
|
44
|
+
passed_checks=validation_result.passed_checks,
|
|
45
|
+
failed_checks=validation_result.failed_checks,
|
|
46
|
+
execution=None,
|
|
47
|
+
)
|
|
48
|
+
return decision
|
|
49
|
+
return wrapper
|
|
50
|
+
return decorator
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Dict
|
|
7
|
+
import json
|
|
8
|
+
from importlib import metadata, resources
|
|
9
|
+
import toml
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, FastAPI, Request, status
|
|
12
|
+
|
|
13
|
+
from fastapi.responses import JSONResponse, HTMLResponse
|
|
14
|
+
|
|
15
|
+
# --- Core Imports ---
|
|
16
|
+
from canon_gate.core.policy import validate_agent_output
|
|
17
|
+
from canon_gate.core.models import ActionProposal, GateDecision, Manifest
|
|
18
|
+
from canon_gate.core.adapters import BaseAdapter
|
|
19
|
+
from canon_gate.core.orchestration import run_execute_flow
|
|
20
|
+
from canon_gate.core.manifest_generator import generate_manifest
|
|
21
|
+
|
|
22
|
+
# --- Boundary Imports ---
|
|
23
|
+
from .audit import log_denial
|
|
24
|
+
from .audit import logger as audit_logger
|
|
25
|
+
|
|
26
|
+
from .adapters import MockRunQueryAdapter, ScriptAdapter
|
|
27
|
+
from operator_console.main import router as console_router, SESSIONS_DIR
|
|
28
|
+
|
|
29
|
+
from contextlib import asynccontextmanager
|
|
30
|
+
|
|
31
|
+
# --- Adapter Registry (Boundary-level concern) ---
|
|
32
|
+
|
|
33
|
+
def register_adapters() -> Dict[str, BaseAdapter]:
|
|
34
|
+
"""Initializes and registers all available adapters."""
|
|
35
|
+
registry = {}
|
|
36
|
+
adapters_to_register = [MockRunQueryAdapter(), ScriptAdapter()]
|
|
37
|
+
|
|
38
|
+
for adapter in adapters_to_register:
|
|
39
|
+
manifest = adapter.get_manifest()
|
|
40
|
+
for action in manifest.actions:
|
|
41
|
+
if action.name in registry:
|
|
42
|
+
logging.warning(
|
|
43
|
+
f"Action '{action.name}' is already registered. Overwriting with adapter '{manifest.adapter_name}'."
|
|
44
|
+
)
|
|
45
|
+
registry[action.name] = adapter
|
|
46
|
+
logging.info(
|
|
47
|
+
f"Registered action '{action.name}' to adapter '{manifest.adapter_name}'"
|
|
48
|
+
)
|
|
49
|
+
return registry
|
|
50
|
+
|
|
51
|
+
# --- FastAPI Application Factory ---
|
|
52
|
+
|
|
53
|
+
def get_version() -> str:
|
|
54
|
+
"""Reads the package version from metadata, with fallbacks for development."""
|
|
55
|
+
try:
|
|
56
|
+
# Primary method: For installed packages
|
|
57
|
+
return metadata.version("canon-gate")
|
|
58
|
+
except metadata.PackageNotFoundError:
|
|
59
|
+
# Fallback for local development: read from pyproject.toml
|
|
60
|
+
try:
|
|
61
|
+
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
|
62
|
+
with open(pyproject_path, "r") as f:
|
|
63
|
+
data = toml.load(f)
|
|
64
|
+
return data["project"]["version"]
|
|
65
|
+
except (FileNotFoundError, KeyError):
|
|
66
|
+
# Final fallback if all else fails
|
|
67
|
+
return "0.0.0-dev"
|
|
68
|
+
|
|
69
|
+
_VERSION = get_version()
|
|
70
|
+
|
|
71
|
+
def get_adapter_registry(request: Request) -> Dict[str, BaseAdapter]:
|
|
72
|
+
"""Dependency to get the adapter registry from the app state."""
|
|
73
|
+
return request.app.state.adapter_registry
|
|
74
|
+
|
|
75
|
+
def create_app(env: str | None = None) -> FastAPI:
|
|
76
|
+
@asynccontextmanager
|
|
77
|
+
async def lifespan(app: FastAPI):
|
|
78
|
+
# Create the adapter registry on startup and store it in the app state
|
|
79
|
+
app.state.adapter_registry = register_adapters()
|
|
80
|
+
|
|
81
|
+
# Ensure the directory for operator sessions exists in the user's app data dir
|
|
82
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
environment = os.getenv("CANON_GATE_ENV", "production")
|
|
85
|
+
startup_log_data = {
|
|
86
|
+
"event": "service_startup",
|
|
87
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
88
|
+
"version": _VERSION,
|
|
89
|
+
"environment": environment,
|
|
90
|
+
"message": "Canon Gate service started.",
|
|
91
|
+
}
|
|
92
|
+
audit_logger.info(json.dumps(startup_log_data))
|
|
93
|
+
yield
|
|
94
|
+
|
|
95
|
+
app = FastAPI(title="Canon Gate Framework", version=_VERSION, lifespan=lifespan)
|
|
96
|
+
app.include_router(console_router)
|
|
97
|
+
actual_env = env or os.getenv("CANON_GATE_ENV", "production")
|
|
98
|
+
|
|
99
|
+
@app.exception_handler(Exception)
|
|
100
|
+
async def unhandled_exception_handler(request: Request, exc: Exception):
|
|
101
|
+
error_log_data = {"event": "unhandled_exception", "message": str(exc)}
|
|
102
|
+
audit_logger.error(json.dumps(error_log_data))
|
|
103
|
+
return JSONResponse(status_code=500, content=error_log_data)
|
|
104
|
+
|
|
105
|
+
@app.get("/")
|
|
106
|
+
def root() -> dict:
|
|
107
|
+
endpoints = {
|
|
108
|
+
"message": "Canon Gate Framework operational.",
|
|
109
|
+
"endpoints": ["/validate_output", "/execute", "/manifest"],
|
|
110
|
+
}
|
|
111
|
+
if actual_env == "development":
|
|
112
|
+
endpoints["message"] = "Canon Gate Framework running (Development Mode)"
|
|
113
|
+
endpoints["operator_console"] = "/console"
|
|
114
|
+
return endpoints
|
|
115
|
+
|
|
116
|
+
@app.get("/console", response_class=HTMLResponse)
|
|
117
|
+
def serve_console():
|
|
118
|
+
"""Serves the Operator Console main page from package resources."""
|
|
119
|
+
try:
|
|
120
|
+
content = resources.files('operator_console.static').joinpath('console.html').read_text()
|
|
121
|
+
return HTMLResponse(content)
|
|
122
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
123
|
+
return HTMLResponse("<h1>Error: Operator Console not found.</h1>", status_code=500)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.get("/manifest", response_model=Manifest, tags=["Runtime Discovery"])
|
|
127
|
+
def get_manifest(
|
|
128
|
+
adapter_registry: Annotated[Dict, Depends(get_adapter_registry)]
|
|
129
|
+
) -> Manifest:
|
|
130
|
+
"""
|
|
131
|
+
Provides a discoverable, machine-readable overview of the actions
|
|
132
|
+
and parameters governed by this Canon Gate instance.
|
|
133
|
+
"""
|
|
134
|
+
return generate_manifest(adapter_registry)
|
|
135
|
+
|
|
136
|
+
@app.post("/validate_output", response_model=GateDecision)
|
|
137
|
+
def validate_output_endpoint(proposal: ActionProposal) -> GateDecision:
|
|
138
|
+
result = validate_agent_output(proposal)
|
|
139
|
+
decision = GateDecision(
|
|
140
|
+
source="validate",
|
|
141
|
+
action=result.action,
|
|
142
|
+
proposal=proposal,
|
|
143
|
+
allowed=result.allowed,
|
|
144
|
+
errors=result.errors,
|
|
145
|
+
passed_checks=result.passed_checks,
|
|
146
|
+
failed_checks=result.failed_checks,
|
|
147
|
+
execution=None,
|
|
148
|
+
)
|
|
149
|
+
if not decision.allowed:
|
|
150
|
+
log_denial(decision)
|
|
151
|
+
return decision
|
|
152
|
+
|
|
153
|
+
@app.post("/execute", response_model=GateDecision)
|
|
154
|
+
def execute_endpoint(
|
|
155
|
+
proposal: ActionProposal,
|
|
156
|
+
adapter_registry: Annotated[Dict, Depends(get_adapter_registry)],
|
|
157
|
+
) -> GateDecision:
|
|
158
|
+
return run_execute_flow(proposal, adapter_registry)
|
|
159
|
+
|
|
160
|
+
return app
|
|
161
|
+
|
|
162
|
+
app = create_app()
|
canon_gate/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
import typer
|
|
3
|
+
from typing_extensions import Annotated
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
@app.command()
|
|
8
|
+
def serve(
|
|
9
|
+
host: Annotated[str, typer.Option(help="The host to bind the server to.")] = "127.0.0.1",
|
|
10
|
+
port: Annotated[int, typer.Option(help="The port to run the server on.")] = 8000,
|
|
11
|
+
console: Annotated[bool, typer.Option("--console/--no-console", help="Launch the Operator Console in a browser.")] = True,
|
|
12
|
+
):
|
|
13
|
+
"""
|
|
14
|
+
Starts the Canon Gate server and optionally launches the console.
|
|
15
|
+
"""
|
|
16
|
+
url = f"http://{host}:{port}"
|
|
17
|
+
print(f"Canon Gate server starting on {url}")
|
|
18
|
+
|
|
19
|
+
if console:
|
|
20
|
+
print(f"Launching Operator Console in your browser: {url}/console")
|
|
21
|
+
typer.launch(f"{url}/console")
|
|
22
|
+
|
|
23
|
+
from canon_gate.boundary.main import app as fastapi_app
|
|
24
|
+
uvicorn.run(fastapi_app, host=host, port=port)
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def demo():
|
|
28
|
+
"""
|
|
29
|
+
Runs the full, orchestrated reference demonstration.
|
|
30
|
+
"""
|
|
31
|
+
from canon_gate.demo import run_full_demo
|
|
32
|
+
run_full_demo()
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def init(
|
|
36
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing policy.toml and adapter_policy.toml files if they exist.")] = False
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initializes a new Canon Gate project with example policy templates.
|
|
40
|
+
"""
|
|
41
|
+
from importlib import resources
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
policy_dest = Path.cwd() / "policy.toml"
|
|
45
|
+
adapter_dest = Path.cwd() / "adapter_policy.toml"
|
|
46
|
+
|
|
47
|
+
# 1. Checks if files exist
|
|
48
|
+
if not force:
|
|
49
|
+
exists_files = []
|
|
50
|
+
if policy_dest.exists():
|
|
51
|
+
exists_files.append("policy.toml")
|
|
52
|
+
if adapter_dest.exists():
|
|
53
|
+
exists_files.append("adapter_policy.toml")
|
|
54
|
+
|
|
55
|
+
if exists_files:
|
|
56
|
+
print(f"❌ ERROR: Project initialization aborted.")
|
|
57
|
+
print(f"The following configuration files already exist: {', '.join(exists_files)}")
|
|
58
|
+
print("Use --force (or -f) to overwrite them.")
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
|
|
61
|
+
# 2. Load templates from package resources
|
|
62
|
+
try:
|
|
63
|
+
policy_content = resources.files("canon_gate.resources").joinpath("policy.example.toml").read_text(encoding="utf-8")
|
|
64
|
+
adapter_content = resources.files("canon_gate.resources").joinpath("adapter_policy.example.toml").read_text(encoding="utf-8")
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"❌ ERROR: Failed to load packaged templates: {e}")
|
|
67
|
+
raise typer.Exit(code=1)
|
|
68
|
+
|
|
69
|
+
# 3. Write templates to destination
|
|
70
|
+
try:
|
|
71
|
+
policy_dest.write_text(policy_content, encoding="utf-8")
|
|
72
|
+
adapter_dest.write_text(adapter_content, encoding="utf-8")
|
|
73
|
+
except Exception as e:
|
|
74
|
+
print(f"❌ ERROR: Failed to write policy files to current directory: {e}")
|
|
75
|
+
raise typer.Exit(code=1)
|
|
76
|
+
|
|
77
|
+
print("canon-gate init\n")
|
|
78
|
+
print("↓\n")
|
|
79
|
+
print("Initialized Canon Gate project.")
|
|
80
|
+
print("Created:")
|
|
81
|
+
print(" policy.toml")
|
|
82
|
+
print(" adapter_policy.toml")
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Core validation engine
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Dict
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .models import ActionProposal, AdapterManifest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseAdapter(ABC):
|
|
10
|
+
"""
|
|
11
|
+
The abstract base class for all execution adapters.
|
|
12
|
+
|
|
13
|
+
Each adapter is responsible for handling the execution logic for a single,
|
|
14
|
+
specific action type. It is also responsible for describing its own
|
|
15
|
+
capabilities for runtime discovery via a manifest.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def get_manifest(self) -> "AdapterManifest":
|
|
20
|
+
"""
|
|
21
|
+
Returns a manifest describing the adapter's capabilities, including
|
|
22
|
+
the actions it supports and their parameter schemas.
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def execute(self, proposal: "ActionProposal") -> Dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Executes the validated action.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
proposal: The validated ActionProposal object.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A dictionary containing the results of the execution, which will
|
|
36
|
+
be embedded in the final GateDecision. Must include at least
|
|
37
|
+
a "status" and "message" key.
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import toml
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
from typing import List, Set
|
|
6
|
+
import logging
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# --- Ingress Policy Models ---
|
|
11
|
+
|
|
12
|
+
class ActionsConfig(BaseModel):
|
|
13
|
+
allowed: Set[str] = Field(default_factory=set)
|
|
14
|
+
|
|
15
|
+
class PolicyConfig(BaseModel):
|
|
16
|
+
actions: ActionsConfig = ActionsConfig()
|
|
17
|
+
|
|
18
|
+
# --- Egress Policy Models ---
|
|
19
|
+
|
|
20
|
+
class RunQueryAdapterPolicy(BaseModel):
|
|
21
|
+
forbidden_keywords: Set[str] = Field(default_factory=set)
|
|
22
|
+
|
|
23
|
+
class ScriptAdapterPolicy(BaseModel):
|
|
24
|
+
allowed_scripts: Set[str] = Field(default_factory=set)
|
|
25
|
+
|
|
26
|
+
class AdapterPolicyConfig(BaseModel):
|
|
27
|
+
run_query_adapter: RunQueryAdapterPolicy = RunQueryAdapterPolicy()
|
|
28
|
+
execute_script_adapter: ScriptAdapterPolicy = ScriptAdapterPolicy()
|
|
29
|
+
|
|
30
|
+
# --- Settings Loading ---
|
|
31
|
+
|
|
32
|
+
def _load_local_policy(filename: str) -> dict:
|
|
33
|
+
"""
|
|
34
|
+
Loads a policy file from the current working directory.
|
|
35
|
+
Does NOT fall back to package-bundled files to maintain
|
|
36
|
+
strict Default-Deny and preserve explicit operator ownership.
|
|
37
|
+
"""
|
|
38
|
+
policy_path = Path.cwd() / filename
|
|
39
|
+
try:
|
|
40
|
+
if policy_path.is_file():
|
|
41
|
+
with open(policy_path, "r", encoding="utf-8") as f:
|
|
42
|
+
return toml.load(f)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logging.error(f"Error reading local policy file '{filename}': {e}")
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
# Load Ingress Policy (policy.toml)
|
|
48
|
+
_policy_config = PolicyConfig()
|
|
49
|
+
try:
|
|
50
|
+
if not (Path.cwd() / "policy.toml").is_file():
|
|
51
|
+
warning_msg = (
|
|
52
|
+
"\n"
|
|
53
|
+
"===============================================================================\n"
|
|
54
|
+
" No local policy.toml was found.\n\n"
|
|
55
|
+
" Canon Gate is operating in Default-Deny mode.\n"
|
|
56
|
+
" No actions will be permitted until a project policy is supplied.\n\n"
|
|
57
|
+
" To create starter policy files run:\n"
|
|
58
|
+
" canon-gate init\n"
|
|
59
|
+
"==============================================================================="
|
|
60
|
+
)
|
|
61
|
+
print(warning_msg)
|
|
62
|
+
logging.warning("No local policy.toml was found. Canon Gate is operating in Default-Deny mode.")
|
|
63
|
+
else:
|
|
64
|
+
policy_data = _load_local_policy("policy.toml")
|
|
65
|
+
if policy_data:
|
|
66
|
+
_policy_config = PolicyConfig.model_validate(policy_data)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logging.error(f"Failed to load or parse policy.toml: {e}")
|
|
69
|
+
_policy_config = PolicyConfig()
|
|
70
|
+
|
|
71
|
+
# Load Egress Policy (adapter_policy.toml)
|
|
72
|
+
_adapter_policy_config = AdapterPolicyConfig()
|
|
73
|
+
try:
|
|
74
|
+
if (Path.cwd() / "adapter_policy.toml").is_file():
|
|
75
|
+
adapter_policy_data = _load_local_policy("adapter_policy.toml")
|
|
76
|
+
if adapter_policy_data:
|
|
77
|
+
_adapter_policy_config = AdapterPolicyConfig.model_validate(adapter_policy_data)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logging.error(f"Failed to load or parse adapter_policy.toml: {e}")
|
|
80
|
+
_adapter_policy_config = AdapterPolicyConfig()
|
|
81
|
+
|
|
82
|
+
# --- Publicly Exposed Settings ---
|
|
83
|
+
|
|
84
|
+
# Ingress Settings
|
|
85
|
+
ALLOWED_ACTIONS: Set[str] = _policy_config.actions.allowed
|
|
86
|
+
|
|
87
|
+
# Egress Settings
|
|
88
|
+
RUN_QUERY_FORBIDDEN_KEYWORDS: Set[str] = _adapter_policy_config.run_query_adapter.forbidden_keywords
|
|
89
|
+
SCRIPT_ADAPTER_ALLOWED_SCRIPTS: Set[str] = _adapter_policy_config.execute_script_adapter.allowed_scripts
|