sernixa-cli 0.1.1__tar.gz

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.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: sernixa-cli
3
+ Version: 0.1.1
4
+ Summary: Official Sernixa CLI for policy-governed local AI agent actions
5
+ Author: Sernixa Team
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://sernixa.com
8
+ Project-URL: Repository, https://github.com/abhishekdhull63/Sernixa.ai-Web
9
+ Project-URL: Documentation, https://github.com/abhishekdhull63/Sernixa.ai-Web/tree/main/docs/cli.md
10
+ Keywords: sernixa,cli,governance,agents,policy
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Security
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: platformdirs>=4.0.0
22
+ Requires-Dist: sernixa>=0.3.1
23
+ Requires-Dist: typer>=0.12.0
24
+
25
+ # Sernixa CLI
26
+
27
+ The official terminal client for evaluating local AI-agent actions against the real Sernixa control plane.
28
+
29
+ From the repository root:
30
+
31
+ ```bash
32
+ python3 -m venv .venv-cli
33
+ source .venv-cli/bin/activate
34
+ pip install -e packages/sernixa -e apps/cli
35
+ sernixa auth login
36
+ sernixa validate payload.json
37
+ ```
38
+
39
+ The CLI defaults to `https://api.sernixa.com`. It never evaluates policy locally and never stores raw credentials outside the protected user configuration file.
40
+
41
+ ## Governance commands
42
+
43
+ Exit codes are stable for shell and CI use:
44
+
45
+ * 0: allowed / success
46
+ * 1: denied by policy
47
+ * 2: operational error such as config, parsing, auth, or network failure
48
+
49
+ Core commands:
50
+
51
+ sernixa validate payload.json
52
+ cat payload.json | sernixa validate --json
53
+ sernixa validate --from-claude < claude-hook-payload.json
54
+ sernixa validate --from-codex < codex-hook-payload.jsonl
55
+ sernixa validate-plan --agent-type codex codex-plan.json
56
+ sernixa validate-many --output json policies/*.json
57
+ sernixa exec --plan plan.json -- npm test
58
+ sernixa codex-exec --plan codex-plan.json -- codex exec "fix lint"
59
+ sernixa doctor --json
60
+
61
+ validate accepts plain Sernixa governance JSON by default. --from-claude and
62
+ --from-codex parse provider-native hook payloads and map them into the same
63
+ governance request before calling the real Python SDK Client.governance_test()
64
+ endpoint.
65
+
66
+ watch currently validates newline-delimited JSON action streams:
67
+
68
+ generate-actions | sernixa watch --json --plan-stream -
69
+
70
+ It does not claim side-effect streaming enforcement for arbitrary child
71
+ processes until the backend exposes a streaming governance contract.
72
+
73
+ validate-plan calls the canonical agent-plan governance endpoint. Plan files
74
+ can be either a JSON array of steps or an object with a plan array. Each step
75
+ uses the shared schema: tool_name, action, arguments, resource, and metadata.
76
+ The backend records a governance_agent_plan_evaluated audit event with a
77
+ redacted plan and plan hash.
78
+
79
+ ## Local agent hooks and MCP integrations
80
+
81
+ Print hook snippets:
82
+
83
+ sernixa hook claude
84
+ sernixa hook claude-desktop
85
+ sernixa hook codex --json
86
+
87
+ Best-effort install:
88
+
89
+ sernixa hook install claude
90
+ sernixa hook install claude-desktop
91
+ sernixa hook install codex
92
+
93
+ Repair stale or duplicate Sernixa-managed entries:
94
+
95
+ sernixa hook repair claude
96
+ sernixa hook repair claude-desktop
97
+ sernixa hook repair codex
98
+
99
+ Inspect all supported targets:
100
+
101
+ sernixa hook status
102
+ sernixa hook status --json
103
+
104
+ Sernixa resolves local agent config targets through the shared OS-aware target
105
+ resolver. If an official target location is uncertain, the CLI reports the
106
+ selected path, alternates found, and any uncertainty instead of inventing a
107
+ hidden path.
108
+
109
+ Supported install/status targets:
110
+
111
+ - `claude` / Claude Code: global user JSON settings, default
112
+ `~/.claude/settings.json`, override `SERNIXA_CLAUDE_CONFIG_FILE`.
113
+ - `claude-desktop` / Claude Desktop: MCP server block in the official app config:
114
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
115
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`, with packaged
116
+ `%LOCALAPPDATA%\Packages\...\LocalCache\Roaming\Claude\claude_desktop_config.json`
117
+ fallbacks when they already exist
118
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
119
+ - override `SERNIXA_CLAUDE_DESKTOP_CONFIG_FILE`
120
+ - `codex` / Codex: global user config only. The resolver inspects
121
+ `~/.codex/hooks.json` and `~/.codex/config.toml`. Both files may exist and
122
+ both may contain hook state. Status reports `hooks_json_present`,
123
+ `inline_toml_hooks_present`, `detected_hook_sources`, and
124
+ `sernixa_installed_in` instead of pretending only one file is active. Install
125
+ writes to the primary write target: existing `hooks.json` first, existing
126
+ `config.toml` when `hooks.json` is absent, and `hooks.json` when neither
127
+ exists. Overrides: `SERNIXA_CODEX_HOOKS_FILE`, `SERNIXA_CODEX_CONFIG_FILE`.
128
+ Codex status also separates `hooks_declared`, `hooks_enabled`, and
129
+ `hooks_effective`. A dedicated `hooks.json` declaration is treated as enabled
130
+ by that file. Inline TOML hook declarations require an explicit enablement
131
+ signal such as `hooks.enabled = true` or the status output will include an
132
+ `activation_hint`.
133
+
134
+ Project-scoped `.codex/` files are intentionally ignored by these commands
135
+ unless a future command explicitly adds project mode. Existing files are backed
136
+ up before changes. Empty configs are treated as empty. Invalid JSON/TOML fails
137
+ closed with the exact broken file path and remediation guidance. If both Codex
138
+ global files contain Sernixa-managed hooks, status reports a duplicate install
139
+ and `sernixa hook repair codex` keeps the primary write target while removing
140
+ duplicate Sernixa-managed entries from the alternate file.
141
+
142
+ `sernixa hook status --json` returns the same core fields for every supported
143
+ target: `target_id`, `target_name`, `supported`, `install_mode`,
144
+ `config_format`, `candidate_paths`, `primary_write_target`, `existing_paths`,
145
+ `parse_errors`, `detected_sources`, `sernixa_installed`,
146
+ `sernixa_installed_in`, `wrapper_valid`, `malformed`, `hooks_declared`,
147
+ `hooks_enabled`, `hooks_effective`, `activation_hint`, and `repair_hint`.
148
+ Serialized JSON paths are always absolute; human output may shorten paths for
149
+ readability.
150
+
151
+ The installed hooks call sernixa hook run <provider>, which reads provider JSON
152
+ on stdin, calls live Sernixa governance, and emits provider-specific deny JSON
153
+ when policy blocks a tool call.
154
+
155
+ ## Bootstrap
156
+
157
+ sernixa init
158
+ sernixa init --chat
159
+ sernixa init --claude
160
+ sernixa init --codex
161
+
162
+ init requires an interactive terminal, validates the API key before writing
163
+ config, writes the config with private file permissions, and never echoes the
164
+ key back to the terminal.
165
+
166
+ For chat agents, use the Python SDK adapter layer:
167
+
168
+ from sernixa import Client
169
+ from sernixa.adapters import SernixaAgentGuard, chat_plan_step
170
+
171
+ guard = SernixaAgentGuard(Client(), agent_type="chat", user_identity=user, context=context)
172
+ guard.declare_plan([chat_plan_step(tool_name="calendar.create", action="calendar.write", arguments=args)])
173
+ guard.execute_tool(calendar_create, tool_name="calendar.create", action="calendar.write", arguments=args)
174
+
175
+ ## Deferred audit commands
176
+
177
+ sernixa events, sernixa decisions, and sernixa replay <decision-id> are present
178
+ but return operational errors until the backend exposes supported audit listing
179
+ / decision replay endpoints for CLI use.
@@ -0,0 +1,155 @@
1
+ # Sernixa CLI
2
+
3
+ The official terminal client for evaluating local AI-agent actions against the real Sernixa control plane.
4
+
5
+ From the repository root:
6
+
7
+ ```bash
8
+ python3 -m venv .venv-cli
9
+ source .venv-cli/bin/activate
10
+ pip install -e packages/sernixa -e apps/cli
11
+ sernixa auth login
12
+ sernixa validate payload.json
13
+ ```
14
+
15
+ The CLI defaults to `https://api.sernixa.com`. It never evaluates policy locally and never stores raw credentials outside the protected user configuration file.
16
+
17
+ ## Governance commands
18
+
19
+ Exit codes are stable for shell and CI use:
20
+
21
+ * 0: allowed / success
22
+ * 1: denied by policy
23
+ * 2: operational error such as config, parsing, auth, or network failure
24
+
25
+ Core commands:
26
+
27
+ sernixa validate payload.json
28
+ cat payload.json | sernixa validate --json
29
+ sernixa validate --from-claude < claude-hook-payload.json
30
+ sernixa validate --from-codex < codex-hook-payload.jsonl
31
+ sernixa validate-plan --agent-type codex codex-plan.json
32
+ sernixa validate-many --output json policies/*.json
33
+ sernixa exec --plan plan.json -- npm test
34
+ sernixa codex-exec --plan codex-plan.json -- codex exec "fix lint"
35
+ sernixa doctor --json
36
+
37
+ validate accepts plain Sernixa governance JSON by default. --from-claude and
38
+ --from-codex parse provider-native hook payloads and map them into the same
39
+ governance request before calling the real Python SDK Client.governance_test()
40
+ endpoint.
41
+
42
+ watch currently validates newline-delimited JSON action streams:
43
+
44
+ generate-actions | sernixa watch --json --plan-stream -
45
+
46
+ It does not claim side-effect streaming enforcement for arbitrary child
47
+ processes until the backend exposes a streaming governance contract.
48
+
49
+ validate-plan calls the canonical agent-plan governance endpoint. Plan files
50
+ can be either a JSON array of steps or an object with a plan array. Each step
51
+ uses the shared schema: tool_name, action, arguments, resource, and metadata.
52
+ The backend records a governance_agent_plan_evaluated audit event with a
53
+ redacted plan and plan hash.
54
+
55
+ ## Local agent hooks and MCP integrations
56
+
57
+ Print hook snippets:
58
+
59
+ sernixa hook claude
60
+ sernixa hook claude-desktop
61
+ sernixa hook codex --json
62
+
63
+ Best-effort install:
64
+
65
+ sernixa hook install claude
66
+ sernixa hook install claude-desktop
67
+ sernixa hook install codex
68
+
69
+ Repair stale or duplicate Sernixa-managed entries:
70
+
71
+ sernixa hook repair claude
72
+ sernixa hook repair claude-desktop
73
+ sernixa hook repair codex
74
+
75
+ Inspect all supported targets:
76
+
77
+ sernixa hook status
78
+ sernixa hook status --json
79
+
80
+ Sernixa resolves local agent config targets through the shared OS-aware target
81
+ resolver. If an official target location is uncertain, the CLI reports the
82
+ selected path, alternates found, and any uncertainty instead of inventing a
83
+ hidden path.
84
+
85
+ Supported install/status targets:
86
+
87
+ - `claude` / Claude Code: global user JSON settings, default
88
+ `~/.claude/settings.json`, override `SERNIXA_CLAUDE_CONFIG_FILE`.
89
+ - `claude-desktop` / Claude Desktop: MCP server block in the official app config:
90
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
91
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`, with packaged
92
+ `%LOCALAPPDATA%\Packages\...\LocalCache\Roaming\Claude\claude_desktop_config.json`
93
+ fallbacks when they already exist
94
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
95
+ - override `SERNIXA_CLAUDE_DESKTOP_CONFIG_FILE`
96
+ - `codex` / Codex: global user config only. The resolver inspects
97
+ `~/.codex/hooks.json` and `~/.codex/config.toml`. Both files may exist and
98
+ both may contain hook state. Status reports `hooks_json_present`,
99
+ `inline_toml_hooks_present`, `detected_hook_sources`, and
100
+ `sernixa_installed_in` instead of pretending only one file is active. Install
101
+ writes to the primary write target: existing `hooks.json` first, existing
102
+ `config.toml` when `hooks.json` is absent, and `hooks.json` when neither
103
+ exists. Overrides: `SERNIXA_CODEX_HOOKS_FILE`, `SERNIXA_CODEX_CONFIG_FILE`.
104
+ Codex status also separates `hooks_declared`, `hooks_enabled`, and
105
+ `hooks_effective`. A dedicated `hooks.json` declaration is treated as enabled
106
+ by that file. Inline TOML hook declarations require an explicit enablement
107
+ signal such as `hooks.enabled = true` or the status output will include an
108
+ `activation_hint`.
109
+
110
+ Project-scoped `.codex/` files are intentionally ignored by these commands
111
+ unless a future command explicitly adds project mode. Existing files are backed
112
+ up before changes. Empty configs are treated as empty. Invalid JSON/TOML fails
113
+ closed with the exact broken file path and remediation guidance. If both Codex
114
+ global files contain Sernixa-managed hooks, status reports a duplicate install
115
+ and `sernixa hook repair codex` keeps the primary write target while removing
116
+ duplicate Sernixa-managed entries from the alternate file.
117
+
118
+ `sernixa hook status --json` returns the same core fields for every supported
119
+ target: `target_id`, `target_name`, `supported`, `install_mode`,
120
+ `config_format`, `candidate_paths`, `primary_write_target`, `existing_paths`,
121
+ `parse_errors`, `detected_sources`, `sernixa_installed`,
122
+ `sernixa_installed_in`, `wrapper_valid`, `malformed`, `hooks_declared`,
123
+ `hooks_enabled`, `hooks_effective`, `activation_hint`, and `repair_hint`.
124
+ Serialized JSON paths are always absolute; human output may shorten paths for
125
+ readability.
126
+
127
+ The installed hooks call sernixa hook run <provider>, which reads provider JSON
128
+ on stdin, calls live Sernixa governance, and emits provider-specific deny JSON
129
+ when policy blocks a tool call.
130
+
131
+ ## Bootstrap
132
+
133
+ sernixa init
134
+ sernixa init --chat
135
+ sernixa init --claude
136
+ sernixa init --codex
137
+
138
+ init requires an interactive terminal, validates the API key before writing
139
+ config, writes the config with private file permissions, and never echoes the
140
+ key back to the terminal.
141
+
142
+ For chat agents, use the Python SDK adapter layer:
143
+
144
+ from sernixa import Client
145
+ from sernixa.adapters import SernixaAgentGuard, chat_plan_step
146
+
147
+ guard = SernixaAgentGuard(Client(), agent_type="chat", user_identity=user, context=context)
148
+ guard.declare_plan([chat_plan_step(tool_name="calendar.create", action="calendar.write", arguments=args)])
149
+ guard.execute_tool(calendar_create, tool_name="calendar.create", action="calendar.write", arguments=args)
150
+
151
+ ## Deferred audit commands
152
+
153
+ sernixa events, sernixa decisions, and sernixa replay <decision-id> are present
154
+ but return operational errors until the backend exposes supported audit listing
155
+ / decision replay endpoints for CLI use.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "sernixa-cli"
3
+ version = "0.1.1"
4
+ description = "Official Sernixa CLI for policy-governed local AI agent actions"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "Sernixa Team" }]
9
+ keywords = ["sernixa", "cli", "governance", "agents", "policy"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Security",
19
+ ]
20
+ dependencies = [
21
+ "platformdirs>=4.0.0",
22
+ "sernixa>=0.3.1",
23
+ "typer>=0.12.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://sernixa.com"
28
+ Repository = "https://github.com/abhishekdhull63/Sernixa.ai-Web"
29
+ Documentation = "https://github.com/abhishekdhull63/Sernixa.ai-Web/tree/main/docs/cli.md"
30
+
31
+ [project.scripts]
32
+ sernixa = "sernixa_cli.main:app"
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=69.0"]
36
+ build-backend = "setuptools.build_meta"
37
+
38
+ [tool.setuptools.packages.find]
39
+ include = ["sernixa_cli*"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ pythonpath = ["."]
@@ -0,0 +1,3 @@
1
+ """Official Sernixa command-line client."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,3 @@
1
+ from .main import app
2
+
3
+ app()
@@ -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