cycode 3.15.4.dev1__py3-none-any.whl → 3.15.4.dev2__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.
cycode/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = '3.15.4.dev1' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
1
+ __version__ = '3.15.4.dev2' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
@@ -28,7 +28,7 @@ def _is_cycode_command(command: str) -> bool:
28
28
 
29
29
 
30
30
  def is_cycode_hook_entry(entry: dict) -> bool:
31
- """Detect Cycode hook entries in both Cursor (flat) and Claude Code (nested) shapes."""
31
+ """True if any hook inside ``entry`` is owned by Cycode."""
32
32
  command = entry.get('command', '')
33
33
  if _is_cycode_command(command):
34
34
  return True
@@ -40,6 +40,31 @@ def is_cycode_hook_entry(entry: dict) -> bool:
40
40
  return False
41
41
 
42
42
 
43
+ def _strip_cycode_from_entry(entry: dict) -> Optional[dict]:
44
+ """Remove Cycode hooks from ``entry`` and return the remainder.
45
+
46
+ Returns ``None`` when nothing useful remains (Cursor-flat Cycode entry, or
47
+ every nested hook was Cycode). Non-Cycode hooks co-located in the same
48
+ entry are preserved.
49
+ """
50
+ # Cursor format: the entry itself IS a single hook command.
51
+ if 'command' in entry and 'hooks' not in entry:
52
+ return None if _is_cycode_command(entry.get('command', '')) else entry
53
+
54
+ # Claude Code / Codex format: nested `hooks` list inside the entry.
55
+ nested = entry.get('hooks')
56
+ if isinstance(nested, list):
57
+ kept = [h for h in nested if not (isinstance(h, dict) and _is_cycode_command(h.get('command', '')))]
58
+ if not kept:
59
+ return None
60
+ if len(kept) == len(nested):
61
+ return entry # nothing Cycode-shaped inside; preserve identity
62
+ return {**entry, 'hooks': kept}
63
+
64
+ # Entry has neither shape we recognize — leave it alone defensively.
65
+ return entry
66
+
67
+
43
68
  def _load_hooks_file(hooks_path: Path) -> Optional[dict]:
44
69
  if not hooks_path.exists():
45
70
  return None
@@ -108,50 +133,83 @@ def install_hooks(
108
133
 
109
134
  for event, entries in rendered['hooks'].items():
110
135
  existing['hooks'].setdefault(event, [])
111
-
112
- # Remove any existing Cycode entries for this event
113
- existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
114
-
115
- # Add new Cycode entries
136
+ existing['hooks'][event] = [
137
+ stripped for e in existing['hooks'][event] if (stripped := _strip_cycode_from_entry(e)) is not None
138
+ ]
116
139
  for entry in entries:
117
140
  existing['hooks'][event].append(entry)
118
141
 
119
- if _save_hooks_file(hooks_path, existing):
120
- return True, f'AI guardrails hooks installed: {hooks_path}'
121
- return False, f'Failed to install hooks to {hooks_path}'
142
+ if not _save_hooks_file(hooks_path, existing):
143
+ return False, f'Failed to install hooks to {hooks_path}'
122
144
 
145
+ message = f'AI guardrails hooks installed: {hooks_path}'
123
146
 
124
- def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]:
125
- """Remove Cycode AI guardrails hooks for ``ide``."""
126
- hooks_path = ide.settings_path(scope, repo_path)
147
+ # IDE-specific extras (e.g. Codex enables a TOML feature flag).
148
+ extra_ok, extra_message = ide.post_install(scope, repo_path)
149
+ if not extra_ok:
150
+ return False, extra_message
151
+ if extra_message:
152
+ message = f'{message}\n {extra_message}'
127
153
 
128
- existing = _load_hooks_file(hooks_path)
129
- if existing is None:
130
- return True, f'No hooks file found at {hooks_path}'
154
+ return True, message
131
155
 
156
+
157
+ def _strip_cycode_entries(existing: dict) -> bool:
158
+ """Mutate ``existing`` to drop Cycode hooks (surgically). Return True if anything changed."""
132
159
  modified = False
133
160
  for event in list(existing.get('hooks', {}).keys()):
134
- original_count = len(existing['hooks'][event])
135
- existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
136
- if len(existing['hooks'][event]) != original_count:
137
- modified = True
138
- if not existing['hooks'][event]:
161
+ before = existing['hooks'][event]
162
+ after: list = []
163
+ for e in before:
164
+ stripped = _strip_cycode_from_entry(e)
165
+ if stripped is None:
166
+ modified = True
167
+ continue
168
+ if stripped is not e:
169
+ modified = True
170
+ after.append(stripped)
171
+ if not after:
139
172
  del existing['hooks'][event]
173
+ else:
174
+ existing['hooks'][event] = after
175
+ return modified
140
176
 
177
+
178
+ def _persist_uninstall(hooks_path: Path, existing: dict, modified: bool) -> tuple[bool, str]:
179
+ """Apply the uninstall result to disk and return ``(success, message)``."""
141
180
  if not modified:
142
181
  return True, 'No Cycode hooks found to remove'
143
-
144
182
  if not existing.get('hooks'):
145
183
  try:
146
184
  hooks_path.unlink()
147
- return True, f'Removed hooks file: {hooks_path}'
148
185
  except Exception as e:
149
186
  logger.debug('Failed to delete hooks file', exc_info=e)
150
187
  return False, f'Failed to remove hooks file: {hooks_path}'
188
+ return True, f'Removed hooks file: {hooks_path}'
189
+ if not _save_hooks_file(hooks_path, existing):
190
+ return False, f'Failed to update hooks file: {hooks_path}'
191
+ return True, f'Cycode hooks removed from: {hooks_path}'
192
+
193
+
194
+ def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]:
195
+ """Remove Cycode AI guardrails hooks for ``ide``."""
196
+ hooks_path = ide.settings_path(scope, repo_path)
197
+
198
+ existing = _load_hooks_file(hooks_path)
199
+ if existing is None:
200
+ return True, f'No hooks file found at {hooks_path}'
151
201
 
152
- if _save_hooks_file(hooks_path, existing):
153
- return True, f'Cycode hooks removed from: {hooks_path}'
154
- return False, f'Failed to update hooks file: {hooks_path}'
202
+ modified = _strip_cycode_entries(existing)
203
+ file_ok, message = _persist_uninstall(hooks_path, existing, modified)
204
+ if not file_ok:
205
+ return False, message
206
+
207
+ extra_ok, extra_message = ide.post_uninstall(scope, repo_path)
208
+ if not extra_ok:
209
+ return False, extra_message
210
+ if extra_message:
211
+ message = f'{message}\n {extra_message}'
212
+ return True, message
155
213
 
156
214
 
157
215
  def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict:
@@ -9,11 +9,12 @@ import typer
9
9
 
10
10
  from cycode.cli.apps.ai_guardrails.ides.base import IDE
11
11
  from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode
12
+ from cycode.cli.apps.ai_guardrails.ides.codex import Codex
12
13
  from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor
13
14
 
14
15
  # Single source of truth: name → singleton instance.
15
16
  # `--ide` choices and install/uninstall/status iteration both derive from this.
16
- IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode())}
17
+ IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())}
17
18
 
18
19
  # Default IDE used when `--ide` is omitted. Kept here so the value is colocated
19
20
  # with the registry; no module outside `ides/` needs to know which IDE wins.
@@ -0,0 +1,73 @@
1
+ """Shared plugin-resolution helpers for IDE integrations.
2
+
3
+ Both Claude Code and Codex use the same ``<plugin>@<marketplace>`` key convention
4
+ and emit the same telemetry shape — only the marketplace layout and manifest
5
+ location differ. ``walk_enabled_plugins`` is the IDE-agnostic loop; each IDE
6
+ supplies the two callables that vary (``locate_dir`` + ``read_plugin``).
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Optional
12
+
13
+ from cycode.logger import get_logger
14
+
15
+ logger = get_logger('AI Guardrails Plugins')
16
+
17
+
18
+ def load_plugin_json(path: Path) -> Optional[dict]:
19
+ """Load a JSON file inside a plugin directory; None if missing or invalid."""
20
+ if not path.exists():
21
+ return None
22
+ try:
23
+ return json.loads(path.read_text(encoding='utf-8'))
24
+ except Exception as e:
25
+ logger.debug('Failed to load plugin file, %s', {'path': str(path)}, exc_info=e)
26
+ return None
27
+
28
+
29
+ def walk_enabled_plugins(
30
+ plugin_entries: dict[str, Any],
31
+ is_enabled: Callable[[Any], bool],
32
+ locate_dir: Callable[[str, str], Optional[Path]],
33
+ read_plugin: Callable[[Path], tuple[dict, dict]],
34
+ ) -> tuple[dict, dict]:
35
+ """Iterate enabled plugins; merge their MCP servers and metadata.
36
+
37
+ Args:
38
+ plugin_entries: ``{<plugin>@<marketplace>: settings}`` map from the IDE config.
39
+ is_enabled: returns True if ``settings`` indicates the plugin is on
40
+ (e.g. ``bool(settings)`` for Claude, ``settings.get('enabled')`` for Codex).
41
+ locate_dir: given ``(plugin_name, marketplace)``, returns the plugin's
42
+ filesystem path or None if it can't be resolved.
43
+ read_plugin: given the plugin path, returns ``(entry_fields, servers)``:
44
+ ``entry_fields`` are extra metadata to attach to the inventory entry
45
+ (name/version/description/...), ``servers`` are MCP servers contributed.
46
+
47
+ Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without
48
+ ``@`` (or that fail to resolve to a directory) still appear in the
49
+ inventory with just ``{'enabled': True}`` so we don't silently drop them.
50
+ """
51
+ merged_mcp: dict = {}
52
+ enriched: dict = {}
53
+
54
+ for plugin_key, settings in plugin_entries.items():
55
+ if not is_enabled(settings):
56
+ continue
57
+
58
+ entry: dict = {'enabled': True}
59
+ enriched[plugin_key] = entry
60
+
61
+ if '@' not in plugin_key:
62
+ continue
63
+ plugin_name, marketplace = plugin_key.split('@', 1)
64
+
65
+ plugin_dir = locate_dir(plugin_name, marketplace)
66
+ if plugin_dir is None:
67
+ continue
68
+
69
+ plugin_fields, servers = read_plugin(plugin_dir)
70
+ entry.update(plugin_fields)
71
+ merged_mcp.update(servers)
72
+
73
+ return merged_mcp, enriched
@@ -108,6 +108,26 @@ class IDE(ABC):
108
108
  ``hooks_manager`` can treat them uniformly.
109
109
  """
110
110
 
111
+ def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
112
+ """Run IDE-specific actions after the hooks file is written.
113
+
114
+ Default: no-op success. Override to perform extra setup that doesn't
115
+ belong in the hooks file itself — e.g. Codex enables a
116
+ ``[features] codex_hooks = true`` flag in its TOML config.
117
+
118
+ Returns ``(success, message)``. If ``success`` is False, the overall
119
+ install is considered failed.
120
+ """
121
+ return True, ''
122
+
123
+ def post_uninstall(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
124
+ """Run IDE-specific cleanup after the hooks file is removed.
125
+
126
+ Default: no-op success. Override to undo whatever ``post_install``
127
+ wrote outside the hooks file.
128
+ """
129
+ return True, ''
130
+
111
131
  # --- runtime scan ---
112
132
 
113
133
  @abstractmethod
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from typing import ClassVar, Optional
8
8
 
9
9
  from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
10
+ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
10
11
  from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
11
12
  from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
12
13
  from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
@@ -127,7 +128,7 @@ def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
127
128
  """Load and parse `~/.claude.json`. Returns None if missing/invalid."""
128
129
  path = config_path or _CLAUDE_CONFIG_PATH
129
130
  if not path.exists():
130
- logger.debug('Claude config file not found', extra={'path': str(path)})
131
+ logger.debug('Claude config file not found, %s', {'path': str(path)})
131
132
  return None
132
133
  try:
133
134
  return json.loads(path.read_text(encoding='utf-8'))
@@ -150,7 +151,7 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]
150
151
  """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid."""
151
152
  path = settings_path or _CLAUDE_SETTINGS_PATH
152
153
  if not path.exists():
153
- logger.debug('Claude settings file not found', extra={'path': str(path)})
154
+ logger.debug('Claude settings file not found, %s', {'path': str(path)})
154
155
  return None
155
156
  try:
156
157
  return json.loads(path.read_text(encoding='utf-8'))
@@ -171,69 +172,47 @@ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
171
172
  return path if path.is_dir() else None
172
173
 
173
174
 
174
- def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]:
175
- """Load and parse a JSON file inside a plugin directory.
175
+ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
176
+ """Read one Claude Code plugin's manifest + MCP servers.
176
177
 
177
- Returns None if the file is missing, unreadable, or has invalid JSON.
178
+ Claude hardcodes the MCP file at ``<plugin_dir>/.mcp.json`` and always
179
+ wraps it as ``{"mcpServers": {...}}``.
178
180
  """
179
- target = plugin_path / relative_path
180
- if not target.exists():
181
- return None
182
- try:
183
- return json.loads(target.read_text(encoding='utf-8'))
184
- except Exception as e:
185
- logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e)
186
- return None
181
+ manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {}
182
+ entry: dict = {}
183
+ for field in ('name', 'version', 'description'):
184
+ if field in manifest:
185
+ entry[field] = manifest[field]
187
186
 
187
+ mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {}
188
+ servers: dict = mcp_config.get('mcpServers') or {}
189
+ if servers:
190
+ entry['mcp_server_names'] = list(servers.keys())
191
+ return entry, servers
188
192
 
189
- def resolve_plugins(settings: dict) -> tuple[dict, dict]:
190
- """Resolve enabled plugins to their MCP servers and metadata.
191
193
 
192
- Walks ``enabledPlugins`` from claude settings, resolves each plugin's
193
- marketplace directory via ``extraKnownMarketplaces``, and reads:
194
- - ``<path>/.mcp.json`` for MCP servers (merged into a flat dict)
195
- - ``<path>/.claude-plugin/plugin.json`` for metadata (name, version, description)
194
+ def resolve_plugins(settings: dict) -> tuple[dict, dict]:
195
+ """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker.
196
196
 
197
- Returns ``(merged_mcp_servers, enriched_plugins)``.
197
+ Each enabled plugin's marketplace is resolved through
198
+ ``extraKnownMarketplaces`` to a directory; the rest of the work
199
+ (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``.
198
200
  """
199
201
  enabled = settings.get('enabledPlugins') or {}
200
202
  marketplaces = settings.get('extraKnownMarketplaces') or {}
201
- merged_mcp: dict = {}
202
- enriched: dict = {}
203
203
 
204
- for plugin_key, is_enabled in enabled.items():
205
- if not is_enabled:
206
- continue
207
-
208
- entry: dict = {'enabled': True}
209
- enriched[plugin_key] = entry
210
-
211
- if '@' not in plugin_key:
212
- continue
213
-
214
- _plugin_name, marketplace_name = plugin_key.split('@', 1)
204
+ def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]:
215
205
  marketplace = marketplaces.get(marketplace_name)
216
206
  if not marketplace:
217
- continue
218
-
219
- plugin_path = _resolve_marketplace_path(marketplace)
220
- if plugin_path is None:
221
- continue
222
-
223
- metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {}
224
- for field in ('name', 'version', 'description'):
225
- if field in metadata:
226
- entry[field] = metadata[field]
227
-
228
- mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {}
229
- plugin_server_names = []
230
- for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items():
231
- merged_mcp[server_name] = server_cfg
232
- plugin_server_names.append(server_name)
233
- if plugin_server_names:
234
- entry['mcp_server_names'] = plugin_server_names
207
+ return None
208
+ return _resolve_marketplace_path(marketplace)
235
209
 
236
- return merged_mcp, enriched
210
+ return walk_enabled_plugins(
211
+ plugin_entries=enabled,
212
+ is_enabled=bool,
213
+ locate_dir=_locate,
214
+ read_plugin=_read_claude_plugin,
215
+ )
237
216
 
238
217
 
239
218
  # --- IDE integration ----------------------------------------------------------
@@ -260,6 +239,7 @@ class ClaudeCode(IDE):
260
239
  'hooks': {
261
240
  'SessionStart': [
262
241
  {
242
+ 'matcher': 'startup|clear',
263
243
  'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
264
244
  }
265
245
  ],
@@ -0,0 +1,310 @@
1
+ """Codex CLI IDE integration for AI guardrails."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import ClassVar, Optional
8
+
9
+ import tomli_w
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else: # pragma: no cover - py<3.11 fallback
14
+ import tomli as tomllib
15
+
16
+ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
17
+ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
18
+ from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
19
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
20
+ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
21
+ from cycode.cli.utils.jwt_utils import decode_jwt_unverified
22
+ from cycode.logger import get_logger
23
+
24
+ logger = get_logger('AI Guardrails Codex')
25
+
26
+ _CONFIG_DIR_NAME = '.codex'
27
+ _HOOKS_FILE_NAME = 'hooks.json'
28
+ _CONFIG_TOML_NAME = 'config.toml'
29
+ _AUTH_JSON_NAME = 'auth.json'
30
+ _CODEX_HOME_ENV_VAR = 'CODEX_HOME'
31
+
32
+ _HOOK_EVENTS = ('UserPromptSubmit', 'PreToolUse:mcp')
33
+ _CODEX_EVENT_NAMES = frozenset(e.split(':', 1)[0] for e in _HOOK_EVENTS)
34
+
35
+ _SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide codex'
36
+ _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide codex'
37
+
38
+
39
+ def _codex_home() -> Path:
40
+ """Resolve Codex's user-scope home directory.
41
+
42
+ Honors ``$CODEX_HOME`` per Codex's documented override; falls back to
43
+ ``~/.codex``.
44
+ """
45
+ override = os.environ.get(_CODEX_HOME_ENV_VAR)
46
+ if override:
47
+ return Path(override)
48
+ return Path.home() / _CONFIG_DIR_NAME
49
+
50
+
51
+ def _codex_config_toml_path(scope: str, repo_path: Optional[Path] = None) -> Path:
52
+ """Return the Codex ``config.toml`` path for the given scope."""
53
+ if scope == 'repo' and repo_path:
54
+ return repo_path / _CONFIG_DIR_NAME / _CONFIG_TOML_NAME
55
+ return _codex_home() / _CONFIG_TOML_NAME
56
+
57
+
58
+ def _load_codex_config(config_path: Optional[Path] = None) -> Optional[dict]:
59
+ """Load and parse Codex's ``config.toml``. Returns None on missing/invalid."""
60
+ path = config_path or (_codex_home() / _CONFIG_TOML_NAME)
61
+ if not path.exists():
62
+ logger.debug('Codex config file not found, %s', {'path': str(path)})
63
+ return None
64
+ try:
65
+ with path.open('rb') as f:
66
+ return tomllib.load(f)
67
+ except Exception as e:
68
+ logger.debug('Failed to load Codex config file, %s', {'path': str(path)}, exc_info=e)
69
+ return None
70
+
71
+
72
+ def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]:
73
+ """Best-effort extraction of the signed-in Codex user's email.
74
+
75
+ Reads ``~/.codex/auth.json`` and decodes the JWT in ``tokens.id_token``
76
+ to pull the ``email`` claim. Returns None if auth.json is missing
77
+ (``OPENAI_API_KEY``-only setups, OS keychain credentials) or unreadable.
78
+ """
79
+ path = auth_path or (_codex_home() / _AUTH_JSON_NAME)
80
+ if not path.exists():
81
+ logger.debug('Codex auth file not found, %s', {'path': str(path)})
82
+ return None
83
+ try:
84
+ auth = json.loads(path.read_text(encoding='utf-8'))
85
+ except (OSError, json.JSONDecodeError) as e:
86
+ logger.debug('Failed to load Codex auth file, %s', {'path': str(path)}, exc_info=e)
87
+ return None
88
+
89
+ token = (auth.get('tokens') or {}).get('id_token')
90
+ if not token:
91
+ return None
92
+ claims = decode_jwt_unverified(token)
93
+ if not claims:
94
+ return None
95
+ return claims.get('email')
96
+
97
+
98
+ def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]:
99
+ """Find ``~/.codex/plugins/cache/<marketplace>/<plugin>/<hash>/``.
100
+
101
+ The trailing segment is a content hash. If multiple are cached, pick the
102
+ most recently modified.
103
+ """
104
+ base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name
105
+ if not base.is_dir():
106
+ return None
107
+ candidates = [d for d in base.iterdir() if d.is_dir()]
108
+ if not candidates:
109
+ return None
110
+ return max(candidates, key=lambda d: d.stat().st_mtime)
111
+
112
+
113
+ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]:
114
+ """Read one Codex plugin's manifest + MCP servers.
115
+
116
+ Codex's manifest references the MCP file via a path string in the
117
+ ``mcpServers`` field (default ``./.mcp.json``); the target file is either
118
+ a bare ``{name: cfg}`` map or wrapped in ``{"mcpServers": {...}}``.
119
+ """
120
+ manifest = load_plugin_json(plugin_dir / '.codex-plugin' / 'plugin.json')
121
+ entry: dict = {}
122
+ if not manifest:
123
+ return entry, {}
124
+
125
+ for field in ('name', 'version', 'description'):
126
+ if field in manifest:
127
+ entry[field] = manifest[field]
128
+
129
+ mcp_ref = manifest.get('mcpServers')
130
+ if not mcp_ref:
131
+ return entry, {}
132
+ mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {}
133
+ servers = mcp_doc.get('mcpServers', mcp_doc)
134
+ if not isinstance(servers, dict):
135
+ servers = {}
136
+ if servers:
137
+ entry['mcp_server_names'] = list(servers.keys())
138
+ return entry, servers
139
+
140
+
141
+ def _resolve_codex_plugins(config: dict) -> tuple[dict, dict]:
142
+ """Walk enabled ``[plugins."<plugin>@<marketplace>"]`` entries."""
143
+ return walk_enabled_plugins(
144
+ plugin_entries=config.get('plugins') or {},
145
+ is_enabled=lambda s: isinstance(s, dict) and bool(s.get('enabled')),
146
+ locate_dir=_resolve_codex_plugin_dir,
147
+ read_plugin=_read_codex_plugin,
148
+ )
149
+
150
+
151
+ def _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
152
+ """Set ``[features] hooks = true`` in Codex's ``config.toml``.
153
+
154
+ Codex's hook scripts are gated behind this feature flag. We preserve any
155
+ existing keys and create the file (+ parent dir) when missing.
156
+ """
157
+ config_path = _codex_config_toml_path(scope, repo_path)
158
+
159
+ config: dict = {}
160
+ if config_path.exists():
161
+ try:
162
+ with config_path.open('rb') as f:
163
+ config = tomllib.load(f)
164
+ except Exception as e:
165
+ logger.error('Failed to parse Codex config.toml, %s', {'path': str(config_path)}, exc_info=e)
166
+ return False, f'Failed to parse existing Codex config at {config_path}'
167
+
168
+ features = config.get('features')
169
+ if not isinstance(features, dict):
170
+ features = {}
171
+ features['hooks'] = True
172
+ config['features'] = features
173
+
174
+ try:
175
+ config_path.parent.mkdir(parents=True, exist_ok=True)
176
+ with config_path.open('wb') as f:
177
+ tomli_w.dump(config, f)
178
+ return True, f'Enabled hooks feature in {config_path}'
179
+ except Exception as e:
180
+ logger.error('Failed to write Codex config.toml, %s', {'path': str(config_path)}, exc_info=e)
181
+ return False, f'Failed to write Codex config at {config_path}'
182
+
183
+
184
+ class Codex(IDE):
185
+ name: ClassVar[str] = 'codex'
186
+ display_name: ClassVar[str] = 'Codex'
187
+ hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
188
+
189
+ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
190
+ if scope == 'repo' and repo_path:
191
+ return repo_path / _CONFIG_DIR_NAME / _HOOKS_FILE_NAME
192
+ return _codex_home() / _HOOKS_FILE_NAME
193
+
194
+ def render_hooks_config(self, async_mode: bool = False) -> dict:
195
+ # Codex's TOML `async: true` flag is unimplemented; shell-background via
196
+ # `&` is the working mechanism. SessionStart stays sync so the
197
+ # conversation context is registered before any scan hook fires.
198
+ bg = ' &' if async_mode else ''
199
+ scan_cmd = f'{_SCAN_COMMAND}{bg}'
200
+ return {
201
+ 'hooks': {
202
+ 'SessionStart': [
203
+ {
204
+ 'matcher': 'startup|clear',
205
+ 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
206
+ }
207
+ ],
208
+ 'UserPromptSubmit': [
209
+ {
210
+ 'hooks': [{'type': 'command', 'command': scan_cmd}],
211
+ }
212
+ ],
213
+ 'PreToolUse': [
214
+ {
215
+ 'matcher': 'mcp__.*',
216
+ 'hooks': [{'type': 'command', 'command': scan_cmd}],
217
+ },
218
+ ],
219
+ },
220
+ }
221
+
222
+ def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
223
+ return _enable_codex_hooks_feature(scope, repo_path)
224
+
225
+ def matches_payload(self, raw_payload: dict) -> bool:
226
+ return raw_payload.get('hook_event_name', '') in _CODEX_EVENT_NAMES
227
+
228
+ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
229
+ hook_event_name = raw_payload.get('hook_event_name', '')
230
+ tool_name = raw_payload.get('tool_name', '')
231
+ tool_input = raw_payload.get('tool_input')
232
+
233
+ if hook_event_name == 'UserPromptSubmit':
234
+ canonical_event: AiHookEventType | str = AiHookEventType.PROMPT
235
+ elif hook_event_name == 'PreToolUse' and tool_name.startswith('mcp__'):
236
+ canonical_event = AiHookEventType.MCP_EXECUTION
237
+ else:
238
+ canonical_event = hook_event_name
239
+
240
+ mcp_server_name = None
241
+ mcp_tool_name = None
242
+ mcp_arguments = None
243
+ if tool_name.startswith('mcp__'):
244
+ parts = tool_name.split('__')
245
+ if len(parts) >= 2:
246
+ mcp_server_name = parts[1]
247
+ if len(parts) >= 3:
248
+ mcp_tool_name = parts[2]
249
+ mcp_arguments = tool_input
250
+
251
+ return AIHookPayload(
252
+ event_name=canonical_event,
253
+ conversation_id=raw_payload.get('session_id'),
254
+ generation_id=raw_payload.get('turn_id'),
255
+ ide_user_email=_email_from_auth(),
256
+ model=raw_payload.get('model'),
257
+ ide_provider=self.name,
258
+ prompt=raw_payload.get('prompt', ''),
259
+ mcp_server_name=mcp_server_name,
260
+ mcp_tool_name=mcp_tool_name,
261
+ mcp_arguments=mcp_arguments,
262
+ )
263
+
264
+ def build_hook_response(self, decision: HookDecision) -> dict:
265
+ # Codex accepts the same hook response shapes as Claude Code:
266
+ # - PROMPT: empty for allow, {"decision": "block", "reason": ...} for deny
267
+ # - PreToolUse: hookSpecificOutput.permissionDecision
268
+ if decision.event_type == AiHookEventType.PROMPT:
269
+ if decision.action == DecisionAction.ALLOW:
270
+ return {}
271
+ return {'decision': 'block', 'reason': decision.user_message or ''}
272
+
273
+ if decision.action == DecisionAction.ALLOW:
274
+ return {
275
+ 'hookSpecificOutput': {
276
+ 'hookEventName': 'PreToolUse',
277
+ 'permissionDecision': 'allow',
278
+ }
279
+ }
280
+ return {
281
+ 'hookSpecificOutput': {
282
+ 'hookEventName': 'PreToolUse',
283
+ 'permissionDecision': decision.action.value, # 'deny' or 'ask'
284
+ 'permissionDecisionReason': decision.user_message or '',
285
+ }
286
+ }
287
+
288
+ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
289
+ return AIHookPayload(
290
+ conversation_id=raw_payload.get('session_id'),
291
+ ide_user_email=_email_from_auth(),
292
+ model=raw_payload.get('model'),
293
+ ide_provider=self.name,
294
+ ide_version=raw_payload.get('codex_version'),
295
+ source=raw_payload.get('source'),
296
+ )
297
+
298
+ def get_user_email(self) -> Optional[str]:
299
+ return _email_from_auth()
300
+
301
+ def get_session_context(self) -> tuple[dict, dict]:
302
+ config = _load_codex_config()
303
+ if not config:
304
+ return {}, {}
305
+ # Codex stores MCP servers under `[mcp_servers.<name>]`. Plugin-contributed
306
+ # servers (via `[plugins."<plugin>@<marketplace>"]`) merge on top.
307
+ mcp_servers: dict = dict(config.get('mcp_servers') or {})
308
+ plugin_mcp, enriched_plugins = _resolve_codex_plugins(config)
309
+ mcp_servers.update(plugin_mcp)
310
+ return mcp_servers, enriched_plugins
@@ -43,7 +43,7 @@ def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict
43
43
  """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
44
44
  path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME)
45
45
  if not path.exists():
46
- logger.debug('Cursor MCP config file not found', extra={'path': str(path)})
46
+ logger.debug('Cursor MCP config file not found, %s', {'path': str(path)})
47
47
  return None
48
48
  try:
49
49
  return json.loads(path.read_text(encoding='utf-8'))
@@ -10,6 +10,7 @@ touching any handler in this module.
10
10
 
11
11
  import json
12
12
  import os
13
+ from dataclasses import dataclass
13
14
  from multiprocessing.pool import ThreadPool
14
15
  from multiprocessing.pool import TimeoutError as PoolTimeoutError
15
16
  from typing import Callable, Optional
@@ -178,23 +179,44 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
178
179
  )
179
180
 
180
181
 
181
- def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
182
- """Scan MCP tool arguments for secrets before execution."""
182
+ @dataclass(frozen=True)
183
+ class _ArgScanFeature:
184
+ """Configuration for a "scan some text and decide" event.
185
+
186
+ MCP execution and command exec share identical scan-and-decide logic;
187
+ only the policy key, event type, and user-facing messages differ.
188
+ """
189
+
190
+ policy_key: str # 'mcp' or 'command_exec'
191
+ scan_key: str # 'scan_arguments' or 'scan_command'
192
+ event_type: AiHookEventType
193
+ block_reason: BlockReason
194
+ deny_message: Callable[[str], str]
195
+ deny_agent_message: str
196
+ ask_message: Callable[[str], str]
197
+ ask_agent_message: str
198
+
199
+
200
+ def _handle_arg_scan(
201
+ ctx: typer.Context,
202
+ payload: AIHookPayload,
203
+ policy: dict,
204
+ feature: _ArgScanFeature,
205
+ scan_text: str,
206
+ ) -> HookDecision:
207
+ """Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events."""
183
208
  ai_client = ctx.obj['ai_security_client']
184
209
 
185
- mcp_config = get_policy_value(policy, 'mcp', default={})
186
- if not get_policy_value(mcp_config, 'enabled', default=True):
187
- ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
188
- return HookDecision.allow(AiHookEventType.MCP_EXECUTION)
210
+ feature_config = get_policy_value(policy, feature.policy_key, default={})
211
+ if not get_policy_value(feature_config, 'enabled', default=True):
212
+ ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED)
213
+ return HookDecision.allow(feature.event_type)
189
214
 
190
215
  mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
191
- tool = payload.mcp_tool_name or 'unknown'
192
- args = payload.mcp_arguments or {}
193
- args_text = args if isinstance(args, str) else json.dumps(args)
194
216
  max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
195
217
  timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
196
- clipped = truncate_utf8(args_text, max_bytes)
197
- action = get_policy_value(mcp_config, 'action', default=PolicyMode.BLOCK)
218
+ clipped = truncate_utf8(scan_text, max_bytes)
219
+ action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK)
198
220
 
199
221
  scan_id = None
200
222
  block_reason = None
@@ -202,26 +224,25 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
202
224
  error_message = None
203
225
 
204
226
  try:
205
- if get_policy_value(mcp_config, 'scan_arguments', default=True):
227
+ if get_policy_value(feature_config, feature.scan_key, default=True):
206
228
  violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
207
229
  if violation_summary:
208
- block_reason = BlockReason.SECRETS_IN_MCP_ARGS
230
+ block_reason = feature.block_reason
209
231
  if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
210
232
  outcome = AIHookOutcome.BLOCKED
211
- user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
212
233
  return HookDecision.deny(
213
- AiHookEventType.MCP_EXECUTION,
214
- user_message,
215
- 'Do not pass secrets to tools. Use secret references (name/id) instead.',
234
+ feature.event_type,
235
+ feature.deny_message(violation_summary),
236
+ feature.deny_agent_message,
216
237
  )
217
238
  outcome = AIHookOutcome.WARNED
218
239
  return HookDecision.ask(
219
- AiHookEventType.MCP_EXECUTION,
220
- f'{violation_summary} in MCP tool call "{tool}". Allow execution?',
221
- 'Possible secrets detected in tool arguments; proceed with caution.',
240
+ feature.event_type,
241
+ feature.ask_message(violation_summary),
242
+ feature.ask_agent_message,
222
243
  )
223
244
 
224
- return HookDecision.allow(AiHookEventType.MCP_EXECUTION)
245
+ return HookDecision.allow(feature.event_type)
225
246
  except Exception as e:
226
247
  outcome = (
227
248
  AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
@@ -232,7 +253,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
232
253
  finally:
233
254
  ai_client.create_event(
234
255
  payload,
235
- AiHookEventType.MCP_EXECUTION,
256
+ feature.event_type,
236
257
  outcome,
237
258
  scan_id=scan_id,
238
259
  block_reason=block_reason,
@@ -240,6 +261,29 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
240
261
  )
241
262
 
242
263
 
264
+ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
265
+ """Scan MCP tool arguments for secrets before execution."""
266
+ tool = payload.mcp_tool_name or 'unknown'
267
+ args = payload.mcp_arguments or {}
268
+ args_text = args if isinstance(args, str) else json.dumps(args)
269
+ return _handle_arg_scan(
270
+ ctx,
271
+ payload,
272
+ policy,
273
+ _ArgScanFeature(
274
+ policy_key='mcp',
275
+ scan_key='scan_arguments',
276
+ event_type=AiHookEventType.MCP_EXECUTION,
277
+ block_reason=BlockReason.SECRETS_IN_MCP_ARGS,
278
+ deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}',
279
+ deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
280
+ ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?',
281
+ ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
282
+ ),
283
+ scan_text=args_text,
284
+ )
285
+
286
+
243
287
  def get_handler_for_event(event_type: str) -> Optional[HandlerFn]:
244
288
  """Look up the handler for a canonical event type."""
245
289
  handlers: dict[str, HandlerFn] = {
@@ -5,6 +5,14 @@ import jwt
5
5
  _JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES = ('userId', 'internalId', 'token-user-id')
6
6
 
7
7
 
8
+ def decode_jwt_unverified(token: str) -> Optional[dict]:
9
+ """Return JWT claims without signature verification, or None if the token is unreadable."""
10
+ try:
11
+ return jwt.decode(token, options={'verify_signature': False})
12
+ except jwt.PyJWTError:
13
+ return None
14
+
15
+
8
16
  def get_user_and_tenant_ids_from_access_token(access_token: str) -> tuple[Optional[str], Optional[str]]:
9
17
  payload = jwt.decode(access_token, options={'verify_signature': False})
10
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cycode
3
- Version: 3.15.4.dev1
3
+ Version: 3.15.4.dev2
4
4
  Summary: Boost security in your dev lifecycle via SAST, SCA, Secrets & IaC scanning.
5
5
  License-Expression: MIT
6
6
  License-File: LICENCE
@@ -34,6 +34,8 @@ Requires-Dist: pyyaml (>=6.0,<7.0)
34
34
  Requires-Dist: requests (>=2.32.4,<3.0)
35
35
  Requires-Dist: rich (>=13.9.4,<14)
36
36
  Requires-Dist: tenacity (>=9.0.0,<9.1.0)
37
+ Requires-Dist: tomli (>=2.0.0,<3.0.0) ; python_version < "3.11"
38
+ Requires-Dist: tomli-w (>=1.0.0,<2.0.0)
37
39
  Requires-Dist: typer (>=0.15.3,<0.16.0)
38
40
  Requires-Dist: urllib3 (>=2.4.0,<3.0.0)
39
41
  Project-URL: Repository, https://github.com/cycodehq/cycode-cli
@@ -1,4 +1,4 @@
1
- cycode/__init__.py,sha256=UPgVpILY2bWvXK6Bul8uIehLpBu3njNwqn83H0Jz-wo,115
1
+ cycode/__init__.py,sha256=QDmVY8Xl94_IN6IhGe3VfDW0t23u1d5S_iQ9f1k08Ww,115
2
2
  cycode/__main__.py,sha256=Z3bD5yrA7yPvAChcADQrqCaZd0ChGI1gdiwALwbWJ6U,104
3
3
  cycode/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  cycode/cli/app.py,sha256=7ReEcVkRX9IaQ2I7jAj7Sl9smbtvxiuK8-9bitMEQik,7491
@@ -7,15 +7,17 @@ cycode/cli/apps/activation_manager.py,sha256=Hz9PDJFB-ZmYi4HSG8iYC-fR8j5v25VuUU-
7
7
  cycode/cli/apps/ai_guardrails/__init__.py,sha256=NsqB1Ca83BIjJMcDSt6suec6Ed0iNnacC0gBqkuuTtI,1367
8
8
  cycode/cli/apps/ai_guardrails/command_utils.py,sha256=NVwd0-2RGRKIqhsQ-4LNDR1D0gVm_o7n-z5LxG2bqAo,800
9
9
  cycode/cli/apps/ai_guardrails/consts.py,sha256=Js2QtSNYG9Kt0eo3vepRd5TFciCeJHEC9NN18zqKvlE,620
10
- cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=dXrVMD_4CcN5aZNZetgtajJab0g4lVGW5ktoS7ALxzc,6864
11
- cycode/cli/apps/ai_guardrails/ides/__init__.py,sha256=1HgTyTmsX-JPGiYk1at7jbzlay6ASkKfA5RXo7K1_a8,1621
12
- cycode/cli/apps/ai_guardrails/ides/base.py,sha256=_KzOlD_tTU0HcZ-NaXc6NZ0Tn0ngLxqovQtY_Pcj5H4,5752
13
- cycode/cli/apps/ai_guardrails/ides/claude_code.py,sha256=YqgxV_kQ39LUlAA9fQbgQmWC7ALC1xMMaEVdsObZtEQ,14197
14
- cycode/cli/apps/ai_guardrails/ides/cursor.py,sha256=MnzhEgYYukTx7XVdNu-u5yO_PzOrZNteTIIEf0mymYc,5237
10
+ cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=c8okVel9KjeXWm5QTnvTraWsTwzrUTqqKd6C80C34Y4,9003
11
+ cycode/cli/apps/ai_guardrails/ides/__init__.py,sha256=JMXbQlq-7q1429w3nQq9Z4FZ8K0zdiUK6zCbilmD3JU,1689
12
+ cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py,sha256=_wJfdUHeCJGHQIOdoXQLuMJ4YGaOq_aLjY0ZUyzpsxU,2747
13
+ cycode/cli/apps/ai_guardrails/ides/base.py,sha256=tFWjkuTKBn-4IZUFXHThwKctB6CWOxnG9q9Yr9fscJA,6594
14
+ cycode/cli/apps/ai_guardrails/ides/claude_code.py,sha256=2Rpc22lKGdAmZOCIQtIOTh34g-J_AMUMCDUJocJHzag,13552
15
+ cycode/cli/apps/ai_guardrails/ides/codex.py,sha256=Ep2rNsULM4FzZnej0YXb9KyKBRGIj7qPs-eC78MZd3k,11939
16
+ cycode/cli/apps/ai_guardrails/ides/cursor.py,sha256=_u-DS4Pdvu_UiNh-W5i2ViHPV0IDlDvFrpRnisL3ks8,5235
15
17
  cycode/cli/apps/ai_guardrails/install_command.py,sha256=vGZSIvHHVMS9_zhV_6lEhxqtmr5H6uykSq4AS5nxQYw,4278
16
18
  cycode/cli/apps/ai_guardrails/scan/__init__.py,sha256=qJc82XiQGiAuc1sYY8Ij_A-qXpxgLPuayQq8xWlouMA,48
17
19
  cycode/cli/apps/ai_guardrails/scan/consts.py,sha256=drAslw6vW3kxmbCs2qPCUbUPR7PJouT2lsXtu5sD-lQ,1094
18
- cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=7hnawiQhH8rNKQ0DrSyzcy41C9YYeW7LYOJA1QUpkOM,14218
20
+ cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=pf5PrUIVnGLEEE6QKPny9at5V6Ms5u2IEtFP72hKgqA,15523
19
21
  cycode/cli/apps/ai_guardrails/scan/payload.py,sha256=pvT3UUqNMvdK3EVzzPjy4JMlOrF-WgxZ3fHN2AtN5eA,1126
20
22
  cycode/cli/apps/ai_guardrails/scan/policy.py,sha256=39s8hnxgjny1l6XAO59wsRcAlpW-LG00GUnO0PfqvuY,2566
21
23
  cycode/cli/apps/ai_guardrails/scan/scan_command.py,sha256=-Gl7cHELF1wlwLGno6ZVukFtK6NI6Z3-dTpIOsz8ors,6079
@@ -171,7 +173,7 @@ cycode/cli/utils/enum_utils.py,sha256=h_VTCfJ-0hnhwDsEznmx56rJrCb5FQ8u6PrI6p8MP3
171
173
  cycode/cli/utils/get_api_client.py,sha256=wwHabfVCDbFjcIwOn5Raho8MEPiOAgkHlGUEfXKpl8U,3542
172
174
  cycode/cli/utils/git_proxy.py,sha256=FPHMBiyLFK9X9vKYpKySRKJH6Dc9Cb3nO241Q95dASE,2911
173
175
  cycode/cli/utils/ignore_utils.py,sha256=cODqhnOHA2kRo8rMY0YcmcKkmXNPOC9UTCmFu62RRqE,15567
174
- cycode/cli/utils/jwt_utils.py,sha256=TfTHCCCxKO6RvSKT2qspx4577Gax3n9YRj2UgigpGuQ,537
176
+ cycode/cli/utils/jwt_utils.py,sha256=EGI-0CKhCGY8hIcZ9b9diq9hqtOUf8Ha8ukeVJIf974,818
175
177
  cycode/cli/utils/path_utils.py,sha256=U5te1unzhs9pnU5d9BWExgFWElHQkgKvFxKiOF-lp-w,3245
176
178
  cycode/cli/utils/progress_bar.py,sha256=bKBWHHdZsVkdDdWMJLfgLGR0cBYeB44P_DpRM8pvWqU,9528
177
179
  cycode/cli/utils/scan_batch.py,sha256=5xKGVDVqoRxdKhuZkK11x4QrNqKmU20Q83E_fy8Nndk,5188
@@ -205,8 +207,8 @@ cycode/cyclient/report_client.py,sha256=Scq30NeJPzgXv0hPLO1U05AdE9i_2iu6cIrSKpEJ
205
207
  cycode/cyclient/scan_client.py,sha256=6TK5FQkfrvV7PHqRnUzEn1PBNd2oPYVamvIixcUfe3c,16755
206
208
  cycode/cyclient/scan_config_base.py,sha256=mXsPZGYCtp85rv5GIige40yQZXuRcEKUW-VQJ0vgFzk,1201
207
209
  cycode/logger.py,sha256=EfZGRK6VC5rE_LAjIcRrHFiQCueylCDXoG6bvGkrIME,2111
208
- cycode-3.15.4.dev1.dist-info/METADATA,sha256=zo-M7zLCUNGxECnRB06G7NpfNmynUAdahbN-tNa_0dI,89102
209
- cycode-3.15.4.dev1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
210
- cycode-3.15.4.dev1.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
211
- cycode-3.15.4.dev1.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
212
- cycode-3.15.4.dev1.dist-info/RECORD,,
210
+ cycode-3.15.4.dev2.dist-info/METADATA,sha256=xiTfy56jgKUKJCRHNswSmoi0vEizW1tCcM1FUQazfp0,89206
211
+ cycode-3.15.4.dev2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
212
+ cycode-3.15.4.dev2.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
213
+ cycode-3.15.4.dev2.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
214
+ cycode-3.15.4.dev2.dist-info/RECORD,,