sernixa-cli 0.1.1__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.
- sernixa_cli/__init__.py +3 -0
- sernixa_cli/__main__.py +3 -0
- sernixa_cli/adapters.py +279 -0
- sernixa_cli/config.py +129 -0
- sernixa_cli/formatting.py +151 -0
- sernixa_cli/hooks.py +108 -0
- sernixa_cli/local_agent_targets.py +1031 -0
- sernixa_cli/main.py +1021 -0
- sernixa_cli-0.1.1.dist-info/METADATA +179 -0
- sernixa_cli-0.1.1.dist-info/RECORD +13 -0
- sernixa_cli-0.1.1.dist-info/WHEEL +5 -0
- sernixa_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sernixa_cli-0.1.1.dist-info/top_level.txt +1 -0
sernixa_cli/__init__.py
ADDED
sernixa_cli/__main__.py
ADDED
sernixa_cli/adapters.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ProviderMode = Literal["plain", "claude", "codex"]
|
|
10
|
+
|
|
11
|
+
SENSITIVE_KEY_PARTS = (
|
|
12
|
+
"api_key",
|
|
13
|
+
"apikey",
|
|
14
|
+
"authorization",
|
|
15
|
+
"bearer",
|
|
16
|
+
"client_secret",
|
|
17
|
+
"password",
|
|
18
|
+
"secret",
|
|
19
|
+
"token",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AdapterError(ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CanonicalGovernanceRequest:
|
|
29
|
+
input: dict[str, Any]
|
|
30
|
+
source: ProviderMode
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def redact_sensitive(value: Any) -> Any:
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
redacted: dict[str, Any] = {}
|
|
36
|
+
for key, item in value.items():
|
|
37
|
+
normalized = str(key).lower().replace("-", "_")
|
|
38
|
+
if any(part in normalized for part in SENSITIVE_KEY_PARTS):
|
|
39
|
+
redacted[str(key)] = "[redacted]"
|
|
40
|
+
else:
|
|
41
|
+
redacted[str(key)] = redact_sensitive(item)
|
|
42
|
+
return redacted
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
return [redact_sensitive(item) for item in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_json_payload(content: str) -> Any:
|
|
49
|
+
if not content.strip():
|
|
50
|
+
raise AdapterError("No JSON payload received.")
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(content)
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
return _load_json_lines(content)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_json_lines(content: str) -> list[Any]:
|
|
58
|
+
items: list[Any] = []
|
|
59
|
+
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
60
|
+
if not line.strip():
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
items.append(json.loads(line))
|
|
64
|
+
except json.JSONDecodeError as exc:
|
|
65
|
+
raise AdapterError(
|
|
66
|
+
f"Invalid JSON at line {line_number}, column {exc.colno}: {exc.msg}"
|
|
67
|
+
) from exc
|
|
68
|
+
if not items:
|
|
69
|
+
raise AdapterError("No JSON payload received.")
|
|
70
|
+
return items
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_payload_source(path: Path | None) -> str:
|
|
74
|
+
import sys
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return path.read_text(encoding="utf-8") if path else sys.stdin.read()
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise AdapterError(f"Unable to read payload: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def canonicalize_payload(raw: Any, *, mode: ProviderMode) -> CanonicalGovernanceRequest:
|
|
83
|
+
if mode == "plain":
|
|
84
|
+
if not isinstance(raw, dict):
|
|
85
|
+
raise AdapterError("Governance payload must be a JSON object.")
|
|
86
|
+
return CanonicalGovernanceRequest(input=raw, source="plain")
|
|
87
|
+
if mode == "claude":
|
|
88
|
+
return CanonicalGovernanceRequest(input=_claude_to_governance(raw), source="claude")
|
|
89
|
+
if mode == "codex":
|
|
90
|
+
return CanonicalGovernanceRequest(input=_codex_to_governance(raw), source="codex")
|
|
91
|
+
raise AdapterError(f"Unsupported payload adapter: {mode}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def payload_from_content(content: str, *, mode: ProviderMode) -> CanonicalGovernanceRequest:
|
|
95
|
+
raw = load_json_payload(content)
|
|
96
|
+
return canonicalize_payload(raw, mode=mode)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _claude_to_governance(raw: Any) -> dict[str, Any]:
|
|
100
|
+
if not isinstance(raw, dict):
|
|
101
|
+
raise AdapterError("Claude hook payload must be a JSON object.")
|
|
102
|
+
tool_name = _string(raw.get("tool_name") or raw.get("name"))
|
|
103
|
+
tool_input = raw.get("tool_input")
|
|
104
|
+
if tool_input is None and isinstance(raw.get("input"), dict):
|
|
105
|
+
tool_input = raw["input"]
|
|
106
|
+
if not tool_name:
|
|
107
|
+
raise AdapterError("Claude hook payload is missing tool_name.")
|
|
108
|
+
if tool_input is None:
|
|
109
|
+
raise AdapterError("Claude hook payload is missing tool_input.")
|
|
110
|
+
if not isinstance(tool_input, dict):
|
|
111
|
+
raise AdapterError("Claude hook tool_input must be a JSON object.")
|
|
112
|
+
return _provider_tool_request(
|
|
113
|
+
source="claude",
|
|
114
|
+
hook_event_name=_string(raw.get("hook_event_name")) or "PreToolUse",
|
|
115
|
+
tool_name=tool_name,
|
|
116
|
+
tool_input=tool_input,
|
|
117
|
+
provider_context={
|
|
118
|
+
"session_id": raw.get("session_id"),
|
|
119
|
+
"transcript_path": raw.get("transcript_path"),
|
|
120
|
+
"cwd": raw.get("cwd"),
|
|
121
|
+
"tool_use_id": raw.get("tool_use_id"),
|
|
122
|
+
"user": _first_present(raw, "user", "user_id", "email", "account_id"),
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _codex_to_governance(raw: Any) -> dict[str, Any]:
|
|
128
|
+
event = _select_codex_event(raw)
|
|
129
|
+
if not isinstance(event, dict):
|
|
130
|
+
raise AdapterError("Codex payload must be a JSON object or JSON-lines stream.")
|
|
131
|
+
tool_name = _string(event.get("tool_name") or event.get("name") or event.get("tool"))
|
|
132
|
+
tool_input = event.get("tool_input")
|
|
133
|
+
if tool_input is None:
|
|
134
|
+
if isinstance(event.get("input"), dict):
|
|
135
|
+
tool_input = event["input"]
|
|
136
|
+
elif isinstance(event.get("arguments"), dict):
|
|
137
|
+
tool_input = event["arguments"]
|
|
138
|
+
elif event.get("command") or event.get("cmd"):
|
|
139
|
+
tool_input = {"command": event.get("command") or event.get("cmd")}
|
|
140
|
+
tool_name = tool_name or "Bash"
|
|
141
|
+
if not tool_name:
|
|
142
|
+
raise AdapterError("Codex payload is missing tool_name.")
|
|
143
|
+
if tool_input is None:
|
|
144
|
+
raise AdapterError("Codex payload is missing tool_input.")
|
|
145
|
+
if not isinstance(tool_input, dict):
|
|
146
|
+
raise AdapterError("Codex hook tool_input must be a JSON object.")
|
|
147
|
+
return _provider_tool_request(
|
|
148
|
+
source="codex",
|
|
149
|
+
hook_event_name=_string(event.get("hook_event_name")) or "PreToolUse",
|
|
150
|
+
tool_name=tool_name,
|
|
151
|
+
tool_input=tool_input,
|
|
152
|
+
provider_context={
|
|
153
|
+
"session_id": event.get("session_id"),
|
|
154
|
+
"turn_id": event.get("turn_id"),
|
|
155
|
+
"transcript_path": event.get("transcript_path"),
|
|
156
|
+
"cwd": event.get("cwd"),
|
|
157
|
+
"model": event.get("model"),
|
|
158
|
+
"permission_mode": event.get("permission_mode"),
|
|
159
|
+
"tool_use_id": event.get("tool_use_id"),
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _select_codex_event(raw: Any) -> Any:
|
|
165
|
+
if isinstance(raw, dict):
|
|
166
|
+
return raw
|
|
167
|
+
if not isinstance(raw, list):
|
|
168
|
+
return raw
|
|
169
|
+
candidates = [
|
|
170
|
+
item
|
|
171
|
+
for item in raw
|
|
172
|
+
if isinstance(item, dict)
|
|
173
|
+
and (
|
|
174
|
+
item.get("hook_event_name") in {"PreToolUse", "PermissionRequest"}
|
|
175
|
+
or item.get("tool_name")
|
|
176
|
+
or item.get("command")
|
|
177
|
+
or item.get("cmd")
|
|
178
|
+
)
|
|
179
|
+
]
|
|
180
|
+
if not candidates:
|
|
181
|
+
raise AdapterError("No recognizable Codex tool event found in JSON-lines stream.")
|
|
182
|
+
return candidates[-1]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _provider_tool_request(
|
|
186
|
+
*,
|
|
187
|
+
source: ProviderMode,
|
|
188
|
+
hook_event_name: str,
|
|
189
|
+
tool_name: str,
|
|
190
|
+
tool_input: dict[str, Any],
|
|
191
|
+
provider_context: dict[str, Any],
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
sanitized_input = redact_sensitive(tool_input)
|
|
194
|
+
action = _action_for_tool(tool_name, sanitized_input)
|
|
195
|
+
return {
|
|
196
|
+
"schema_version": "sernixa.governance_request.v1",
|
|
197
|
+
"source": source,
|
|
198
|
+
"provider": source,
|
|
199
|
+
"hook_event_name": hook_event_name,
|
|
200
|
+
"tool_name": tool_name,
|
|
201
|
+
"action": action["action"],
|
|
202
|
+
"operation_class": action["operation_class"],
|
|
203
|
+
"resource": action.get("resource"),
|
|
204
|
+
"intent": action.get("intent"),
|
|
205
|
+
"parameters": sanitized_input,
|
|
206
|
+
"provider_context": {
|
|
207
|
+
key: value
|
|
208
|
+
for key, value in redact_sensitive(provider_context).items()
|
|
209
|
+
if value is not None
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _action_for_tool(tool_name: str, tool_input: dict[str, Any]) -> dict[str, Any]:
|
|
215
|
+
normalized = tool_name.lower()
|
|
216
|
+
if normalized in {"bash", "shell"}:
|
|
217
|
+
command = _string(tool_input.get("command"))
|
|
218
|
+
return {
|
|
219
|
+
"action": "shell.execute",
|
|
220
|
+
"operation_class": "shell",
|
|
221
|
+
"resource": command,
|
|
222
|
+
"intent": _string(tool_input.get("description")),
|
|
223
|
+
}
|
|
224
|
+
if normalized in {"write", "edit", "multiedit", "apply_patch"}:
|
|
225
|
+
return {
|
|
226
|
+
"action": "file.write",
|
|
227
|
+
"operation_class": "write",
|
|
228
|
+
"resource": _string(tool_input.get("file_path"))
|
|
229
|
+
or _string(tool_input.get("path"))
|
|
230
|
+
or _string(tool_input.get("command")),
|
|
231
|
+
"intent": _string(tool_input.get("description")),
|
|
232
|
+
}
|
|
233
|
+
if normalized in {"read", "glob", "grep", "ls"}:
|
|
234
|
+
return {
|
|
235
|
+
"action": "file.read",
|
|
236
|
+
"operation_class": "read",
|
|
237
|
+
"resource": _string(tool_input.get("file_path"))
|
|
238
|
+
or _string(tool_input.get("path"))
|
|
239
|
+
or _string(tool_input.get("pattern")),
|
|
240
|
+
"intent": _string(tool_input.get("description")),
|
|
241
|
+
}
|
|
242
|
+
if normalized in {"webfetch", "websearch"}:
|
|
243
|
+
return {
|
|
244
|
+
"action": "network.request",
|
|
245
|
+
"operation_class": "network",
|
|
246
|
+
"resource": _string(tool_input.get("url")) or _string(tool_input.get("query")),
|
|
247
|
+
"intent": _string(tool_input.get("prompt")),
|
|
248
|
+
}
|
|
249
|
+
if normalized.startswith("mcp__"):
|
|
250
|
+
return {
|
|
251
|
+
"action": "mcp.tool",
|
|
252
|
+
"operation_class": "mcp",
|
|
253
|
+
"resource": tool_name,
|
|
254
|
+
"intent": _string(tool_input.get("description")),
|
|
255
|
+
}
|
|
256
|
+
if normalized == "agent":
|
|
257
|
+
return {
|
|
258
|
+
"action": "agent.delegate",
|
|
259
|
+
"operation_class": "delegate",
|
|
260
|
+
"resource": _string(tool_input.get("subagent_type")),
|
|
261
|
+
"intent": _string(tool_input.get("prompt")) or _string(tool_input.get("description")),
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
"action": f"tool.{normalized}",
|
|
265
|
+
"operation_class": "tool",
|
|
266
|
+
"resource": tool_name,
|
|
267
|
+
"intent": _string(tool_input.get("description")),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _string(value: Any) -> str | None:
|
|
272
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _first_present(payload: dict[str, Any], *keys: str) -> Any:
|
|
276
|
+
for key in keys:
|
|
277
|
+
if payload.get(key) is not None:
|
|
278
|
+
return payload[key]
|
|
279
|
+
return None
|
sernixa_cli/config.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from platformdirs import user_config_path
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.sernixa.com"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CliConfig:
|
|
19
|
+
api_key: str
|
|
20
|
+
base_url: str = DEFAULT_BASE_URL
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def config_path() -> Path:
|
|
24
|
+
override = os.getenv("SERNIXA_CONFIG_FILE", "").strip()
|
|
25
|
+
if override:
|
|
26
|
+
return Path(override).expanduser()
|
|
27
|
+
return user_config_path("sernixa", appauthor=False) / "config.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def plan_session_path() -> Path:
|
|
31
|
+
override = os.getenv("SERNIXA_PLAN_SESSION_FILE", "").strip()
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override).expanduser()
|
|
34
|
+
return user_config_path("sernixa", appauthor=False) / "plan-session.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_plan_session(evaluation: dict[str, object]) -> Path:
|
|
38
|
+
token = evaluation.get("plan_token")
|
|
39
|
+
proofs = evaluation.get("step_proofs")
|
|
40
|
+
if not isinstance(token, str) or not token or not isinstance(proofs, list):
|
|
41
|
+
raise ConfigError(
|
|
42
|
+
"Approved plan response did not include a plan token and step proofs."
|
|
43
|
+
)
|
|
44
|
+
session = {
|
|
45
|
+
"plan_id": evaluation.get("plan_id"),
|
|
46
|
+
"plan_hash": evaluation.get("plan_hash"),
|
|
47
|
+
"plan_token": token,
|
|
48
|
+
"plan_token_id": evaluation.get("plan_token_id"),
|
|
49
|
+
"plan_token_expires_at": evaluation.get("plan_token_expires_at"),
|
|
50
|
+
"policy_version_id": evaluation.get("policy_version_id"),
|
|
51
|
+
"step_proofs": proofs,
|
|
52
|
+
"consumed_proof_ids": [],
|
|
53
|
+
"allowed": True,
|
|
54
|
+
}
|
|
55
|
+
path = plan_session_path()
|
|
56
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
57
|
+
temporary = path.with_suffix(".tmp")
|
|
58
|
+
temporary.write_text(
|
|
59
|
+
json.dumps(session, separators=(",", ":")) + "\n", encoding="utf-8"
|
|
60
|
+
)
|
|
61
|
+
if os.name == "posix":
|
|
62
|
+
temporary.chmod(0o600)
|
|
63
|
+
temporary.replace(path)
|
|
64
|
+
if os.name == "posix":
|
|
65
|
+
path.chmod(0o600)
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remove_plan_session() -> bool:
|
|
70
|
+
path = plan_session_path()
|
|
71
|
+
if not path.exists():
|
|
72
|
+
return False
|
|
73
|
+
path.unlink()
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalize_base_url(value: str) -> str:
|
|
78
|
+
normalized = value.strip().rstrip("/")
|
|
79
|
+
if not normalized.startswith(("https://", "http://")):
|
|
80
|
+
raise ConfigError("API base URL must start with http:// or https://.")
|
|
81
|
+
return normalized
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_config(config: CliConfig) -> Path:
|
|
85
|
+
path = config_path()
|
|
86
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
87
|
+
temporary = path.with_suffix(".tmp")
|
|
88
|
+
temporary.write_text(json.dumps(asdict(config), indent=2) + "\n", encoding="utf-8")
|
|
89
|
+
if os.name == "posix":
|
|
90
|
+
temporary.chmod(0o600)
|
|
91
|
+
temporary.replace(path)
|
|
92
|
+
if os.name == "posix":
|
|
93
|
+
path.chmod(0o600)
|
|
94
|
+
return path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_config(
|
|
98
|
+
*, require_key: bool = True, base_url_override: str | None = None
|
|
99
|
+
) -> CliConfig:
|
|
100
|
+
env_key = os.getenv("SERNIXA_API_KEY", "").strip()
|
|
101
|
+
env_base_url = os.getenv("SERNIXA_BASE_URL", "").strip()
|
|
102
|
+
stored: dict[str, object] = {}
|
|
103
|
+
path = config_path()
|
|
104
|
+
if path.exists():
|
|
105
|
+
try:
|
|
106
|
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
|
107
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
108
|
+
raise ConfigError(f"Configuration file is unreadable: {path}") from exc
|
|
109
|
+
if not isinstance(parsed, dict):
|
|
110
|
+
raise ConfigError(f"Configuration file must contain a JSON object: {path}")
|
|
111
|
+
stored = parsed
|
|
112
|
+
|
|
113
|
+
api_key = env_key or str(stored.get("api_key") or "").strip()
|
|
114
|
+
base_url = normalize_base_url(
|
|
115
|
+
base_url_override
|
|
116
|
+
or env_base_url
|
|
117
|
+
or str(stored.get("base_url") or DEFAULT_BASE_URL)
|
|
118
|
+
)
|
|
119
|
+
if require_key and not api_key:
|
|
120
|
+
raise ConfigError("No API key configured. Run `sernixa auth login` first.")
|
|
121
|
+
return CliConfig(api_key=api_key, base_url=base_url)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def remove_config() -> bool:
|
|
125
|
+
path = config_path()
|
|
126
|
+
if not path.exists():
|
|
127
|
+
return False
|
|
128
|
+
path.unlink()
|
|
129
|
+
return True
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Decision = Literal["allow", "deny"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_allowed(decision: dict[str, Any]) -> bool:
|
|
13
|
+
return str(decision.get("final_decision", "deny")).lower() == "allow"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def exit_code_for_decision(decision: dict[str, Any]) -> int:
|
|
17
|
+
return 0 if is_allowed(decision) else 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decision_summary(
|
|
21
|
+
backend_decision: dict[str, Any],
|
|
22
|
+
*,
|
|
23
|
+
include_raw: bool = True,
|
|
24
|
+
request: dict[str, Any] | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
allowed = is_allowed(backend_decision)
|
|
27
|
+
summary: dict[str, Any] = {
|
|
28
|
+
"decision": "allow" if allowed else "deny",
|
|
29
|
+
"final_decision": backend_decision.get("final_decision", "deny"),
|
|
30
|
+
"violations": _violations(backend_decision, request=request) if not allowed else [],
|
|
31
|
+
"matched_policies": backend_decision.get("matched_policies") or [],
|
|
32
|
+
"reasons": backend_decision.get("reasons") or [],
|
|
33
|
+
}
|
|
34
|
+
if include_raw:
|
|
35
|
+
summary["raw_backend_response"] = backend_decision
|
|
36
|
+
return summary
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_decision(
|
|
40
|
+
backend_decision: dict[str, Any],
|
|
41
|
+
*,
|
|
42
|
+
json_output: bool = False,
|
|
43
|
+
quiet: bool = False,
|
|
44
|
+
request: dict[str, Any] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
if quiet:
|
|
47
|
+
return
|
|
48
|
+
allowed = is_allowed(backend_decision)
|
|
49
|
+
if json_output:
|
|
50
|
+
typer.echo(json.dumps(decision_summary(backend_decision, request=request), sort_keys=True))
|
|
51
|
+
return
|
|
52
|
+
if allowed:
|
|
53
|
+
typer.secho("Allowed by Sernixa policy.", fg=typer.colors.GREEN)
|
|
54
|
+
matched = backend_decision.get("matched_policies") or []
|
|
55
|
+
if matched:
|
|
56
|
+
typer.echo(f"Matched policies: {', '.join(str(item) for item in matched)}")
|
|
57
|
+
return
|
|
58
|
+
typer.secho("Denied by Sernixa policy.", fg=typer.colors.RED, err=True)
|
|
59
|
+
for violation in _violations(backend_decision, request=request):
|
|
60
|
+
label = (
|
|
61
|
+
violation.get("policy_id")
|
|
62
|
+
or violation.get("rule")
|
|
63
|
+
or violation.get("principle")
|
|
64
|
+
or "policy"
|
|
65
|
+
)
|
|
66
|
+
category = violation.get("risk_category") or violation.get("category")
|
|
67
|
+
severity = violation.get("severity")
|
|
68
|
+
message = violation.get("message") or violation.get("reason") or "Denied by policy."
|
|
69
|
+
prefix = f"{label}"
|
|
70
|
+
if category:
|
|
71
|
+
prefix = f"{prefix} [{category}]"
|
|
72
|
+
if severity:
|
|
73
|
+
prefix = f"{prefix} severity={severity}"
|
|
74
|
+
typer.echo(f"- {prefix}: {message}", err=True)
|
|
75
|
+
if violation.get("principle"):
|
|
76
|
+
typer.echo(f" principle: {violation['principle']}", err=True)
|
|
77
|
+
if category:
|
|
78
|
+
typer.echo(f" risk category: {category}", err=True)
|
|
79
|
+
if violation.get("severity"):
|
|
80
|
+
typer.echo(f" severity: {violation['severity']}", err=True)
|
|
81
|
+
if violation.get("resource"):
|
|
82
|
+
typer.echo(f" resource: {violation['resource']}", err=True)
|
|
83
|
+
if violation.get("action"):
|
|
84
|
+
typer.echo(f" action: {violation['action']}", err=True)
|
|
85
|
+
if violation.get("remediation"):
|
|
86
|
+
typer.echo(f" remediation: {violation['remediation']}", err=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def hook_response(provider: str, backend_decision: dict[str, Any]) -> dict[str, Any] | None:
|
|
90
|
+
if is_allowed(backend_decision):
|
|
91
|
+
return None
|
|
92
|
+
reason = "; ".join(str(item) for item in backend_decision.get("reasons") or []) or (
|
|
93
|
+
"Blocked by Sernixa policy."
|
|
94
|
+
)
|
|
95
|
+
if provider == "codex":
|
|
96
|
+
return {
|
|
97
|
+
"hookSpecificOutput": {
|
|
98
|
+
"hookEventName": "PreToolUse",
|
|
99
|
+
"permissionDecision": "deny",
|
|
100
|
+
"permissionDecisionReason": reason,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if provider == "claude":
|
|
104
|
+
return {
|
|
105
|
+
"hookSpecificOutput": {
|
|
106
|
+
"hookEventName": "PreToolUse",
|
|
107
|
+
"permissionDecision": "deny",
|
|
108
|
+
"permissionDecisionReason": reason,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {"decision": "block", "reason": reason}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _violations(
|
|
115
|
+
backend_decision: dict[str, Any],
|
|
116
|
+
*,
|
|
117
|
+
request: dict[str, Any] | None = None,
|
|
118
|
+
) -> list[dict[str, Any]]:
|
|
119
|
+
existing = backend_decision.get("violations")
|
|
120
|
+
if isinstance(existing, list):
|
|
121
|
+
return [
|
|
122
|
+
item
|
|
123
|
+
for item in existing
|
|
124
|
+
if isinstance(item, dict)
|
|
125
|
+
]
|
|
126
|
+
reasons = backend_decision.get("reasons") or ["No policy reason was returned."]
|
|
127
|
+
matched = backend_decision.get("matched_policies") or []
|
|
128
|
+
violations: list[dict[str, Any]] = []
|
|
129
|
+
for index, reason in enumerate(reasons):
|
|
130
|
+
policy = matched[index] if index < len(matched) else (matched[0] if matched else None)
|
|
131
|
+
violations.append(
|
|
132
|
+
{
|
|
133
|
+
"policy_id": policy,
|
|
134
|
+
"rule": policy,
|
|
135
|
+
"risk_category": backend_decision.get("risk_category"),
|
|
136
|
+
"severity": backend_decision.get("severity"),
|
|
137
|
+
"message": str(reason),
|
|
138
|
+
"resource": request.get("resource") if request else None,
|
|
139
|
+
"action": request.get("action") if request else None,
|
|
140
|
+
"remediation": _first_remediation(backend_decision.get("remediation")),
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
return violations
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _first_remediation(value: Any) -> str | None:
|
|
147
|
+
if isinstance(value, list) and value:
|
|
148
|
+
return str(value[0])
|
|
149
|
+
if isinstance(value, str):
|
|
150
|
+
return value
|
|
151
|
+
return None
|
sernixa_cli/hooks.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from .local_agent_targets import (
|
|
9
|
+
Provider,
|
|
10
|
+
TargetConfigError,
|
|
11
|
+
TargetID,
|
|
12
|
+
hook_snippet,
|
|
13
|
+
install_target,
|
|
14
|
+
repair_target,
|
|
15
|
+
resolve_local_agent_target,
|
|
16
|
+
target_status,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
HookTarget = TargetID
|
|
21
|
+
HookInstallError = TargetConfigError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class HookInstallResult:
|
|
26
|
+
provider: Provider
|
|
27
|
+
target: HookTarget
|
|
28
|
+
status: str
|
|
29
|
+
path: Path
|
|
30
|
+
created: bool
|
|
31
|
+
backup_path: Path | None = None
|
|
32
|
+
error: str | None = None
|
|
33
|
+
manual_steps: list[str] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class HookRepairResult:
|
|
38
|
+
provider: Provider
|
|
39
|
+
target: HookTarget
|
|
40
|
+
status: str
|
|
41
|
+
path: Path
|
|
42
|
+
backup_paths: tuple[Path, ...] = ()
|
|
43
|
+
error: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def hook_config_path(provider: Literal["claude", "codex"]) -> Path:
|
|
47
|
+
return resolve_local_agent_target(provider).selected_path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def manual_steps(target: str) -> list[str]:
|
|
51
|
+
resolution = resolve_local_agent_target(target)
|
|
52
|
+
return [
|
|
53
|
+
f"Create or edit {resolution.selected_path}.",
|
|
54
|
+
"Merge in this managed Sernixa configuration:",
|
|
55
|
+
json.dumps(hook_snippet(resolution.target_id), indent=2, sort_keys=True),
|
|
56
|
+
f"Run 'sernixa hook {resolution.provider}' any time to print the hook snippet again.",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def install_hook(target: str) -> HookInstallResult:
|
|
61
|
+
try:
|
|
62
|
+
resolution = resolve_local_agent_target(target)
|
|
63
|
+
existed_before = resolution.selected_path.exists()
|
|
64
|
+
resolution, status, backup_path = install_target(target)
|
|
65
|
+
except HookInstallError as exc:
|
|
66
|
+
resolution = resolve_local_agent_target(target)
|
|
67
|
+
return HookInstallResult(
|
|
68
|
+
provider=resolution.provider,
|
|
69
|
+
target=resolution.target_id,
|
|
70
|
+
status="manual_required",
|
|
71
|
+
path=resolution.selected_path,
|
|
72
|
+
created=False,
|
|
73
|
+
error=str(exc),
|
|
74
|
+
manual_steps=manual_steps(target),
|
|
75
|
+
)
|
|
76
|
+
return HookInstallResult(
|
|
77
|
+
provider=resolution.provider,
|
|
78
|
+
target=resolution.target_id,
|
|
79
|
+
status=status,
|
|
80
|
+
path=resolution.selected_path,
|
|
81
|
+
created=not existed_before,
|
|
82
|
+
backup_path=backup_path,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def hook_detected(provider: Literal["claude", "codex"]) -> bool:
|
|
87
|
+
return target_status(provider).installed
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def repair_hook(target: str) -> HookRepairResult:
|
|
91
|
+
try:
|
|
92
|
+
resolution, status, backup_paths = repair_target(target)
|
|
93
|
+
except HookInstallError as exc:
|
|
94
|
+
resolution = resolve_local_agent_target(target)
|
|
95
|
+
return HookRepairResult(
|
|
96
|
+
provider=resolution.provider,
|
|
97
|
+
target=resolution.target_id,
|
|
98
|
+
status="manual_required",
|
|
99
|
+
path=resolution.selected_path,
|
|
100
|
+
error=str(exc),
|
|
101
|
+
)
|
|
102
|
+
return HookRepairResult(
|
|
103
|
+
provider=resolution.provider,
|
|
104
|
+
target=resolution.target_id,
|
|
105
|
+
status=status,
|
|
106
|
+
path=resolution.selected_path,
|
|
107
|
+
backup_paths=backup_paths,
|
|
108
|
+
)
|