anosys-codex 0.1.0__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.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: anosys-codex
3
+ Version: 0.1.0
4
+ Summary: AnoSys observability hook for OpenAI Codex CLI
5
+ Author-email: AnoSys <support@anosys.ai>
6
+ License: ../../../LICENSE
7
+ Project-URL: Homepage, https://anosys.ai
8
+ Project-URL: Documentation, https://docs.anosys.ai
9
+ Project-URL: Repository, https://github.com/anosys-ai/anosys-sdk
10
+ Keywords: anosys,codex,openai,observability,monitoring,audit,llm
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Monitoring
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: requests>=2.25.1
25
+ Requires-Dist: cryptography>=3.4.0
26
+ Dynamic: license-file
27
+
28
+ # anosys-codex (Python)
29
+
30
+ AnoSys observability and analytics hook for OpenAI Codex CLI.
31
+
32
+ This package automatically captures Codex CLI's telemetry, agent turns, tool calls (`shell`, `apply_patch`, Code Mode custom tools, `web_search`), token usage breakdowns (prompt, completion, cache read/write, reasoning tokens), and estimated API costs, sending them directly to your AnoSys workspace.
33
+
34
+ It integrates seamlessly with Codex CLI's native `notify` hook mechanism.
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install anosys-codex
42
+ ```
43
+
44
+ After installing the package, run the setup wizard to configure the hook:
45
+
46
+ ```bash
47
+ anosys-codex install
48
+ ```
49
+
50
+ The installer will prompt you for:
51
+ 1. **AnoSys API Key for logs**: Your ingestion API key from the [AnoSys Console](https://console.anosys.ai/collect/integrationoptions).
52
+ > **Pixel Type Requirement**: This key must belong to a **`CC` (Claude Code / Codex CLI)** pixel. The installer automatically validates the key against `https://console.anosys.ai/api/resolveapikeys` to ensure it resolves to a `/cc/` endpoint. Keys belonging to `T` (OTEL) or Web Traffic (`a`/`g`) pixels are rejected with a warning.
53
+ 2. **Content Redaction**: If enabled, sensitive conversation content (prompts, answers, code, tool inputs/outputs) will be redacted and replaced with `[REDACTED]`. Only metadata and token statistics will be tracked.
54
+ 3. **Automatic Config Update**: Confirms whether to automatically write `notify = ["anosys-codex", "run"]` to `~/.codex/config.toml` (and creates a `.bak` backup first).
55
+
56
+ ---
57
+
58
+ ## Non-Interactive / Headless Installation
59
+
60
+ For CI/CD pipelines, Docker containers, devcontainer setups, or non-interactive dotfiles:
61
+
62
+ ### Using the `-y` / `--yes` flag (Recommended):
63
+ ```bash
64
+ anosys-codex install --api-key "your_logs_api_key" -y
65
+ ```
66
+
67
+ ### Using environment variables:
68
+ ```bash
69
+ export ANOSYS_API_KEY="your_logs_api_key"
70
+ anosys-codex install -y
71
+ ```
72
+
73
+ ### Using explicit granular flags:
74
+ ```bash
75
+ anosys-codex install \
76
+ --api-key "your_logs_api_key" \
77
+ --auto-update \
78
+ --no-redaction
79
+ ```
80
+
81
+ > **Important (Codex Security Trust)**:
82
+ > Codex requires explicit user approval for unmanaged external notify hooks. After running `install`, start a Codex CLI session, type `/hooks`, and select `approve` / `trust` for the `anosys-codex run` hook entry.
83
+
84
+ ---
85
+
86
+ ## CLI Commands
87
+
88
+ The CLI safely manages `~/.codex/config.toml` and creates automatic timestamped backups (`config.toml.<TIMESTAMP>.bak`) before applying any modifications.
89
+
90
+ ### `status`
91
+ Check the current installation and registration status of the hook:
92
+ ```bash
93
+ anosys-codex status
94
+ ```
95
+
96
+ ### `install`
97
+ Installs or updates the notify hook in `~/.codex/config.toml` and creates `~/.codex/anosys-env.sh`:
98
+ ```bash
99
+ anosys-codex install [options]
100
+
101
+ Options:
102
+ --api-key <key> AnoSys logs API key
103
+ -y, --yes Skip interactive prompts (non-interactive mode)
104
+ --redaction Enable content redaction
105
+ --no-redaction Disable content redaction
106
+ --auto-update Automatically update ~/.codex/config.toml
107
+ --no-auto-update Print manual setup instructions instead of modifying config
108
+ ```
109
+
110
+ ### `uninstall`
111
+ Safely removes the AnoSys notify hook entry from `~/.codex/config.toml` and deletes `~/.codex/anosys-env.sh`:
112
+ ```bash
113
+ anosys-codex uninstall
114
+ ```
115
+
116
+ ### `run`
117
+ Executed automatically by the Codex CLI on turn-complete notify events. You do not need to invoke this manually:
118
+ ```bash
119
+ anosys-codex run
120
+ ```
121
+
122
+ ---
123
+
124
+ ## How It Works
125
+
126
+ 1. When a Codex turn completes, Codex CLI triggers `notify = ["anosys-codex", "run"]` defined in `~/.codex/config.toml`.
127
+ 2. The hook runner receives the session thread ID and locates the session's rollout transcript at `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<session_id>.jsonl`.
128
+ 3. It incrementally extracts user prompts, assistant messages, model details, token counts (including cached inputs and reasoning tokens), and tool calls.
129
+ 4. It maps the turn into the AnoSys schema (`cvs200: 'CodexHook'`), estimates USD cost using OpenAI's model pricing table, applies optional PII redaction, and batches the payload to `https://api.anosys.ai/ingestion`.
130
+ 5. If network connectivity is temporarily unavailable, failed records are buffered locally in `~/.codex/state/pending_records.jsonl` and automatically replayed on subsequent runs.
131
+
132
+ ---
133
+
134
+ ## Environment Variables
135
+
136
+ | Variable | Description |
137
+ |----------|-------------|
138
+ | `ANOSYS_HOOK_APIKEY` / `ANOSYS_API_KEY` | AnoSys ingestion API key |
139
+ | `ANOSYS_HOOK_ENDPOINT_URL` | Override default ingestion URL (`https://api.anosys.ai/ingestion`) |
140
+ | `REDACTION` / `ANOSYS_REDACT_CONTENT` | Set to `"true"` to redact prompts, code, and tool outputs |
141
+ | `ANOSYS_HOOK_DRY_RUN` | Set to `"true"` to log payloads to stderr without sending to network |
142
+ | `CODEX_HOME` | Custom directory for Codex config and sessions (defaults to `~/.codex`) |
@@ -0,0 +1,13 @@
1
+ anosys_codex-0.1.0.dist-info/licenses/LICENSE,sha256=rZsldTRUIZ33Yi94i4v6n7ffaPNNQmzB6gxnWzGlf2A,16
2
+ anosys_sdk_codex/__init__.py,sha256=6iIPOqOJdN2wOzvLzeJBdsopL6yn7a4swZgYeKYAT_s,995
3
+ anosys_sdk_codex/cli.py,sha256=GVfkaujy64cR2KA6zvV6f8Edquh4G8tChlyuNhBjj9E,6972
4
+ anosys_sdk_codex/encryption.py,sha256=c-zJUge2V1SZe00nsD6Zjt29IzO49g3vEYBB0p_7evQ,5817
5
+ anosys_sdk_codex/hook_runner.py,sha256=qi_BhfytXQ2668ohDUwyYpnwtsDYpIINiQDITCa1tz4,22484
6
+ anosys_sdk_codex/installer.py,sha256=Kv-JRc1rNyBiHhlbXHbhS023idi14vX27WTNeFBAQrA,8742
7
+ anosys_sdk_codex/mapper.py,sha256=jg87lUW3IprkwLYe2VC-xoKyXEO0e2Fc3f55r0vmM-o,9355
8
+ anosys_sdk_codex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ anosys_codex-0.1.0.dist-info/METADATA,sha256=srGxLyocRL27cGFFV6dskSE044Vg3FP8dk0XKXWQaP0,5965
10
+ anosys_codex-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ anosys_codex-0.1.0.dist-info/entry_points.txt,sha256=G-RhC_Pcgrg64y4ebUCqD7TbJfzTk_piyJKpugAgfeY,59
12
+ anosys_codex-0.1.0.dist-info/top_level.txt,sha256=TnOBiZGoAsifFOyUXKafdwHZ6we-8jwso09VrUbwmYI,17
13
+ anosys_codex-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ anosys-codex = anosys_sdk_codex.cli:main
@@ -0,0 +1 @@
1
+ ../../../LICENSE
@@ -0,0 +1 @@
1
+ anosys_sdk_codex
@@ -0,0 +1,40 @@
1
+ """
2
+ AnoSys OpenAI Codex CLI integration — observability hook for Codex sessions.
3
+ """
4
+
5
+ try:
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ except ImportError:
8
+ from importlib_metadata import PackageNotFoundError, version
9
+
10
+ from anosys_sdk_codex.installer import update_codex_config, update_codex_env
11
+ from anosys_sdk_codex.mapper import transform_codex_turn
12
+ from anosys_sdk_codex.encryption import (
13
+ ANOSYS_ENCRYPTION_PREFIX,
14
+ DEFAULT_ENCRYPTED_COLUMNS,
15
+ derive_256_bit_key,
16
+ encrypt,
17
+ decrypt,
18
+ get_encryption_key,
19
+ get_target_columns,
20
+ encrypt_payload,
21
+ )
22
+
23
+ try:
24
+ __version__ = version("anosys-codex")
25
+ except PackageNotFoundError:
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "transform_codex_turn",
30
+ "update_codex_config",
31
+ "update_codex_env",
32
+ "ANOSYS_ENCRYPTION_PREFIX",
33
+ "DEFAULT_ENCRYPTED_COLUMNS",
34
+ "derive_256_bit_key",
35
+ "encrypt",
36
+ "decrypt",
37
+ "get_encryption_key",
38
+ "get_target_columns",
39
+ "encrypt_payload",
40
+ ]
@@ -0,0 +1,200 @@
1
+ """
2
+ CLI entry point for anosys-codex.
3
+
4
+ Commands:
5
+ install Register the AnoSys notify hook in ~/.codex/config.toml
6
+ uninstall Remove the AnoSys notify hook from ~/.codex/config.toml
7
+ status Show current hook registration status
8
+ run Execute the hook (invoked by Codex CLI on notify events)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import sys
16
+
17
+ from anosys_sdk_codex.installer import (
18
+ HOOK_COMMAND,
19
+ INGESTION_URL,
20
+ backup,
21
+ get_config_path,
22
+ get_env_path,
23
+ has_anosys_hook,
24
+ load_toml,
25
+ remove_codex_config,
26
+ remove_codex_env,
27
+ update_codex_config,
28
+ update_codex_env,
29
+ validate_api_key,
30
+ )
31
+
32
+
33
+ def _prompt(prompt: str, default: str = "") -> str:
34
+ try:
35
+ val = input(prompt).strip()
36
+ return val if val else default
37
+ except (EOFError, KeyboardInterrupt):
38
+ print()
39
+ sys.exit(0)
40
+
41
+
42
+ def cmd_install(args: argparse.Namespace) -> None:
43
+ print("\nAnoSys OpenAI Codex Hook Installer")
44
+ print("=" * 45)
45
+
46
+ yes = getattr(args, "yes", False)
47
+
48
+ redaction = args.redaction
49
+ if not redaction and not args.no_redaction:
50
+ if yes:
51
+ redaction = False
52
+ else:
53
+ choice = _prompt("Enable content redaction? (y/N): ", "n").lower()
54
+ redaction = choice == "y"
55
+
56
+ api_key = args.api_key or os.environ.get("ANOSYS_API_KEY") or os.environ.get("ANOSYS_HOOK_APIKEY") or ""
57
+ if not api_key and not yes:
58
+ api_key = _prompt("AnoSys API key for logs (leave blank to skip): ")
59
+
60
+ if api_key:
61
+ print("Validating Logs API key...")
62
+ if not validate_api_key(api_key, "cc"):
63
+ print("⚠️ Warning: Logs API key validation failed: key does not belong to a CC (Claude Code / Codex) pixel or is invalid.")
64
+ else:
65
+ print("✅ Logs API key is valid (CC pixel).")
66
+
67
+ auto_update = args.auto_update
68
+ if auto_update is None:
69
+ if yes:
70
+ auto_update = True
71
+ else:
72
+ choice = _prompt("Would you like to automatically update ~/.codex/config.toml? (Y/n): ", "y").lower()
73
+ auto_update = choice != "n"
74
+
75
+ config_path = get_config_path()
76
+ env_path = get_env_path()
77
+
78
+ if auto_update:
79
+ print(f"\nUpdating {config_path} ...")
80
+ backup_path = backup(config_path)
81
+ update_codex_config(HOOK_COMMAND, path=config_path)
82
+ update_codex_env(api_key=api_key, redaction=redaction, path=env_path)
83
+
84
+ if backup_path:
85
+ print(f" Backed up original settings -> {backup_path}")
86
+ print(' Hook command registered in notify: ["anosys-codex", "run"]')
87
+ print(f" Ingestion URL: {INGESTION_URL}")
88
+ if api_key:
89
+ masked = ("*" * (len(api_key) - 4)) + api_key[-4:] if len(api_key) > 4 else "****"
90
+ print(f" Logs API key: {masked}")
91
+ print(f" Redaction: {'enabled' if redaction else 'disabled'}")
92
+ print("\n✅ Codex hook installed successfully.")
93
+ print("\n⚠️ IMPORTANT (Codex Security Trust):")
94
+ print(" In your next Codex CLI session, run the '/hooks' command")
95
+ print(f" and approve the '{HOOK_COMMAND}' entry to allow tracing.\n")
96
+ else:
97
+ print("\n================================================================")
98
+ print("Add the following to your ~/.codex/config.toml file:")
99
+ print('\nnotify = ["anosys-codex", "run"]\n')
100
+ print("And create ~/.codex/anosys-env.sh with:")
101
+ print(f'export ANOSYS_HOOK_APIKEY="{api_key}"')
102
+ print(f'export ANOSYS_HOOK_ENDPOINT_URL="{INGESTION_URL}"')
103
+ print(f'export REDACTION="{"true" if redaction else "false"}"')
104
+ print("================================================================\n")
105
+
106
+
107
+ def cmd_uninstall(args: argparse.Namespace) -> None:
108
+ print("\nAnoSys OpenAI Codex Hook Uninstaller")
109
+ print("=" * 45)
110
+
111
+ config_path = get_config_path()
112
+ env_path = get_env_path()
113
+
114
+ backup_path = backup(config_path)
115
+ removed_config = remove_codex_config(config_path)
116
+ removed_env = remove_codex_env(env_path)
117
+
118
+ if backup_path:
119
+ print(f" Backed up settings -> {backup_path}")
120
+ if removed_config:
121
+ print(f" Removed hook entry from {config_path}")
122
+ else:
123
+ print(f" No AnoSys hook entry found in {config_path}")
124
+
125
+ if removed_env:
126
+ print(f" Removed {env_path}")
127
+
128
+ print("\n✅ AnoSys Codex hook uninstalled successfully.\n")
129
+
130
+
131
+ def cmd_status(args: argparse.Namespace) -> None:
132
+ config_path = get_config_path()
133
+ env_path = get_env_path()
134
+
135
+ print("\nAnoSys Codex Hook Status")
136
+ print("=" * 45)
137
+ print(f"Codex Config: {config_path}")
138
+ print(f"Env File: {env_path}")
139
+
140
+ if not config_path.is_file():
141
+ print("\n⚠️ Codex config.toml does not exist.")
142
+ return
143
+
144
+ data = load_toml(config_path)
145
+ installed = has_anosys_hook(data)
146
+
147
+ if installed:
148
+ print("\nStatus: ✅ Installed and active in config.toml")
149
+ notify_list = data.get("notify", [])
150
+ print(f"Notify hooks: {notify_list}")
151
+ else:
152
+ print("\nStatus: ❌ Not registered in config.toml")
153
+
154
+ if env_path.is_file():
155
+ print("Env file exists: ✅ Yes")
156
+ else:
157
+ print("Env file exists: ❌ No")
158
+ print()
159
+
160
+
161
+ def cmd_run(args: argparse.Namespace) -> None:
162
+ from anosys_sdk_codex.hook_runner import run
163
+ run()
164
+
165
+
166
+ def main(argv: list[str] | None = None) -> None:
167
+ parser = argparse.ArgumentParser(
168
+ prog="anosys-codex",
169
+ description="AnoSys CLI integration for OpenAI Codex",
170
+ )
171
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
172
+
173
+ p_install = subparsers.add_parser("install", help="Install AnoSys hook into ~/.codex/config.toml")
174
+ p_install.add_argument("--api-key", help="AnoSys logs API key")
175
+ p_install.add_argument("-y", "--yes", action="store_true", default=False, help="Automatic yes to prompts (non-interactive)")
176
+ p_install.add_argument("--redaction", action="store_true", default=False, help="Enable content redaction")
177
+ p_install.add_argument("--no-redaction", action="store_true", default=False, help="Disable content redaction")
178
+ p_install.add_argument("--auto-update", dest="auto_update", action="store_true", default=None)
179
+ p_install.add_argument("--no-auto-update", dest="auto_update", action="store_false")
180
+ p_install.set_defaults(func=cmd_install)
181
+
182
+ p_uninstall = subparsers.add_parser("uninstall", help="Remove AnoSys hook from ~/.codex/config.toml")
183
+ p_uninstall.set_defaults(func=cmd_uninstall)
184
+
185
+ p_status = subparsers.add_parser("status", help="Check hook installation status")
186
+ p_status.set_defaults(func=cmd_status)
187
+
188
+ p_run = subparsers.add_parser("run", help="Run the hook (invoked by Codex notify)")
189
+ p_run.set_defaults(func=cmd_run)
190
+
191
+ args = parser.parse_args(argv)
192
+ if not hasattr(args, "func"):
193
+ parser.print_help()
194
+ sys.exit(1)
195
+
196
+ args.func(args)
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
@@ -0,0 +1,182 @@
1
+ """
2
+ Client-side encryption utilities for AnoSys Codex integration.
3
+
4
+ Supports AES-256-CBC encryption of sensitive telemetry columns
5
+ (prompts, completions, messages, raw context) before sending to AnoSys BE.
6
+ """
7
+
8
+ import base64
9
+ import hashlib
10
+ import logging
11
+ import os
12
+ import sys
13
+ from typing import Any, List, Optional, Set
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ ANOSYS_ENCRYPTION_PREFIX = "enc:"
18
+
19
+ DEFAULT_ENCRYPTED_COLUMNS: List[str] = [
20
+ # Primary requested columns
21
+ "llm_input",
22
+ "llm_output",
23
+ "gen_ai_input_messages",
24
+ "gen_ai_output_messages",
25
+ "cvs199",
26
+ # System instructions and legacy system fields
27
+ "gen_ai_system_instructions",
28
+ "llm_system",
29
+ # User prompts and assistant responses across SDKs
30
+ "user_prompt",
31
+ "userPrompt",
32
+ "assistant_text",
33
+ "assistantText",
34
+ "cvs4",
35
+ "cvs5",
36
+ # Multi-turn prompt context and tool execution results
37
+ "last_prompt",
38
+ "cvs24",
39
+ "tool_use_result",
40
+ "cvs26",
41
+ # Additional task / speech prompt fields
42
+ "cvs65",
43
+ "task_description",
44
+ "summary_content",
45
+ ]
46
+
47
+ try:
48
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
49
+ from cryptography.hazmat.backends import default_backend
50
+ _HAS_CRYPTOGRAPHY = True
51
+ except ImportError:
52
+ _HAS_CRYPTOGRAPHY = False
53
+
54
+
55
+ def derive_256_bit_key(passphrase: str) -> bytes:
56
+ """Derive a 256-bit (32-byte) key from a passphrase using SHA-256."""
57
+ sha256 = hashlib.sha256()
58
+ sha256.update(passphrase.encode("utf-8"))
59
+ return sha256.digest()[:32]
60
+
61
+
62
+ def encrypt(text: str, key: bytes) -> str:
63
+ """
64
+ Encrypt text with AES-256-CBC using a zero IV and PKCS#7 padding.
65
+ Returns standard Base64-encoded ciphertext.
66
+ """
67
+ if not _HAS_CRYPTOGRAPHY:
68
+ raise RuntimeError("The 'cryptography' library is required for encryption. Install with: pip install cryptography")
69
+
70
+ iv = b"\x00" * 16
71
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
72
+ encryptor = cipher.encryptor()
73
+
74
+ text_bytes = text.encode("utf-8")
75
+ padding_length = 16 - (len(text_bytes) % 16)
76
+ padded_bytes = text_bytes + (bytes([padding_length]) * padding_length)
77
+
78
+ encrypted = encryptor.update(padded_bytes) + encryptor.finalize()
79
+ return base64.b64encode(encrypted).decode("utf-8")
80
+
81
+
82
+ def decrypt(prefixed_or_raw: str, key: bytes) -> str:
83
+ """
84
+ Decrypt Base64 ciphertext with or without 'enc:' prefix.
85
+ """
86
+ if not _HAS_CRYPTOGRAPHY:
87
+ raise RuntimeError("The 'cryptography' library is required for decryption. Install with: pip install cryptography")
88
+
89
+ raw_b64 = prefixed_or_raw
90
+ if raw_b64.startswith(ANOSYS_ENCRYPTION_PREFIX):
91
+ raw_b64 = raw_b64[len(ANOSYS_ENCRYPTION_PREFIX):]
92
+
93
+ encrypted_bytes = base64.b64decode(raw_b64)
94
+ iv = b"\x00" * 16
95
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
96
+ decryptor = cipher.decryptor()
97
+ decrypted = decryptor.update(encrypted_bytes) + decryptor.finalize()
98
+ if not decrypted:
99
+ return ""
100
+ padding_length = decrypted[-1]
101
+ if padding_length > 16 or padding_length <= 0:
102
+ return decrypted.decode("utf-8")
103
+ return decrypted[:-padding_length].decode("utf-8")
104
+
105
+
106
+ def get_encryption_key() -> Optional[str]:
107
+ """Retrieve the encryption key from ANOSYS_ENCRYPTION_KEY env var if present and not null."""
108
+ key = os.environ.get("ANOSYS_ENCRYPTION_KEY")
109
+ if not key or not key.strip() or key.strip().lower() in ("null", "none", "undefined"):
110
+ return None
111
+ return key.strip()
112
+
113
+
114
+ def get_target_columns() -> Set[str]:
115
+ """
116
+ Retrieve columns to encrypt.
117
+ If ANOSYS_ENCRYPTED_COLUMNS or ANOSYS_ENCRYPTES_COLUMNS is provided,
118
+ it overrides the default list.
119
+ """
120
+ raw = os.environ.get("ANOSYS_ENCRYPTED_COLUMNS") or os.environ.get("ANOSYS_ENCRYPTES_COLUMNS")
121
+ if raw and raw.strip():
122
+ cols = {col.strip() for col in raw.split(",") if col.strip()}
123
+ if cols:
124
+ return cols
125
+ return set(DEFAULT_ENCRYPTED_COLUMNS)
126
+
127
+
128
+ def encrypt_payload(data: Any) -> Any:
129
+ """
130
+ Encrypt targeted fields in a payload dict (or list of dicts) if ANOSYS_ENCRYPTION_KEY is configured.
131
+ Warns on non-string values and skips them.
132
+ """
133
+ if data is None:
134
+ return None
135
+
136
+ if isinstance(data, list):
137
+ return [encrypt_payload(item) for item in data]
138
+
139
+ if not isinstance(data, dict):
140
+ return data
141
+
142
+ key_str = get_encryption_key()
143
+ if not key_str:
144
+ return data
145
+
146
+ if not _HAS_CRYPTOGRAPHY:
147
+ msg = "[ANOSYS] Warning: ANOSYS_ENCRYPTION_KEY is set but 'cryptography' library is not installed. Skipping encryption."
148
+ print(msg, file=sys.stderr)
149
+ logger.warning(msg)
150
+ return data
151
+
152
+ try:
153
+ derived_key = derive_256_bit_key(key_str)
154
+ except Exception as e:
155
+ msg = f"[ANOSYS] Warning: Failed to derive encryption key: {e}"
156
+ print(msg, file=sys.stderr)
157
+ logger.warning(msg)
158
+ return data
159
+
160
+ target_columns = get_target_columns()
161
+
162
+ for col in target_columns:
163
+ if col not in data:
164
+ continue
165
+ val = data[col]
166
+ if val is None:
167
+ continue
168
+ if not isinstance(val, str):
169
+ warn_msg = f"[ANOSYS] Warning: Column '{col}' contains non-string data (type: {type(val).__name__}). Skipping encryption."
170
+ print(warn_msg, file=sys.stderr)
171
+ logger.warning(warn_msg)
172
+ continue
173
+ if val.startswith(ANOSYS_ENCRYPTION_PREFIX):
174
+ continue
175
+ try:
176
+ data[col] = f"{ANOSYS_ENCRYPTION_PREFIX}{encrypt(val, derived_key)}"
177
+ except Exception as e:
178
+ warn_msg = f"[ANOSYS] Warning: Failed to encrypt column '{col}': {e}"
179
+ print(warn_msg, file=sys.stderr)
180
+ logger.warning(warn_msg)
181
+
182
+ return data