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.
- sernixa_cli-0.1.1/PKG-INFO +179 -0
- sernixa_cli-0.1.1/README.md +155 -0
- sernixa_cli-0.1.1/pyproject.toml +43 -0
- sernixa_cli-0.1.1/sernixa_cli/__init__.py +3 -0
- sernixa_cli-0.1.1/sernixa_cli/__main__.py +3 -0
- sernixa_cli-0.1.1/sernixa_cli/adapters.py +279 -0
- sernixa_cli-0.1.1/sernixa_cli/config.py +129 -0
- sernixa_cli-0.1.1/sernixa_cli/formatting.py +151 -0
- sernixa_cli-0.1.1/sernixa_cli/hooks.py +108 -0
- sernixa_cli-0.1.1/sernixa_cli/local_agent_targets.py +1031 -0
- sernixa_cli-0.1.1/sernixa_cli/main.py +1021 -0
- sernixa_cli-0.1.1/sernixa_cli.egg-info/PKG-INFO +179 -0
- sernixa_cli-0.1.1/sernixa_cli.egg-info/SOURCES.txt +18 -0
- sernixa_cli-0.1.1/sernixa_cli.egg-info/dependency_links.txt +1 -0
- sernixa_cli-0.1.1/sernixa_cli.egg-info/entry_points.txt +2 -0
- sernixa_cli-0.1.1/sernixa_cli.egg-info/requires.txt +3 -0
- sernixa_cli-0.1.1/sernixa_cli.egg-info/top_level.txt +1 -0
- sernixa_cli-0.1.1/setup.cfg +4 -0
- sernixa_cli-0.1.1/tests/test_cli.py +486 -0
- sernixa_cli-0.1.1/tests/test_local_agent_targets.py +563 -0
|
@@ -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,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
|