codec-cortex 0.3.2__py3-none-any.whl → 0.3.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codec-cortex
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: CODEC-CORTEX CLI — modular deterministic processor for .cortex files
5
5
  Author: Fidel Ernesto Lozada A.
6
6
  License: MIT
@@ -1,17 +1,20 @@
1
- codec_cortex-0.3.2.dist-info/licenses/LICENSE,sha256=XH2MkxKGvJ_LcevUWgv7_OSxq37wpijTP-FogFipXCU,1080
1
+ codec_cortex-0.3.4.dist-info/licenses/LICENSE,sha256=XH2MkxKGvJ_LcevUWgv7_OSxq37wpijTP-FogFipXCU,1080
2
2
  cortex/__init__.py,sha256=1Bs8--g5g1S2qeoIvX6uClpbBQWFAMaCQtdQw-dwPK4,899
3
3
  cortex/__main__.py,sha256=xUyHJQ7WGSMVwVG4SwzLyHz29cfLJNBzbPgY_x1QO8s,140
4
- cortex/_version.py,sha256=7Bzncojp21iM-dWXdN6Fqc8CpgZtvRE0o2j65xrTsR8,528
4
+ cortex/_version.py,sha256=6VPYIvhLZJtMwihR7AOyYh2jhPuzbRCBdFS28bhCBjs,528
5
5
  cortex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ cortex/audit/__init__.py,sha256=wnng23HFOmMsxOGzx99gZgY2RVppKSybqwQucGFR8EE,859
7
+ cortex/audit/logger.py,sha256=4BnBU_EVuQmlcmRnQNg_0m_amGv1rtbdkTYD64M4QJI,9167
6
8
  cortex/cli/__init__.py,sha256=tvd7DQfNaSTI3DZ3h6crL-R-KIZIEQ11bmzmMvI66nU,93
7
- cortex/cli/main.py,sha256=qBuSsVoNgNNYewM_09_T8q9AOJQ0Q7gr8uKhafjU-0Q,28799
9
+ cortex/cli/main.py,sha256=ZPCPpB8dXGBKjueBhKJEc1hEO-SGFIQkbZnntBvF7l8,36713
8
10
  cortex/cli/commands/__init__.py,sha256=SEEf4ZLEoWUWWyfTHJAX5Xa4ioenYNDnqZpU5xaogxw,5689
9
11
  cortex/cli/commands/add.py,sha256=UC4Lg_GAupSOPNHNAhsRnCOjSl2B-B6iu95hWfP_Ng8,3118
12
+ cortex/cli/commands/audit.py,sha256=L0HI8jI9BXdzE6p6QkOL0vtEp1lZ7PpTDyo4VUzUmRc,2485
10
13
  cortex/cli/commands/compile.py,sha256=PeiE3F0_kbA-ibC-f2evCAaaNB288eVKMeoM6tWrvs8,1076
11
14
  cortex/cli/commands/delete.py,sha256=0zeGt2Ny8ar_ggV3nbMNcJSpU_7iM8QorCDLnYCQd7M,1286
12
15
  cortex/cli/commands/diagram.py,sha256=B7gacxogQph9UTgiC3CY1PRJEAnFoQialeoFsNToj_w,4469
13
16
  cortex/cli/commands/diff.py,sha256=Wuc6ItkoMTlbfRbRD2n9pJq3uA5JlewDXK7WDS1Xruc,5795
14
- cortex/cli/commands/doctor.py,sha256=k6biTi5DNwROnulrxhGj3XRWflQLh4V_49xGPn8ZZ90,2833
17
+ cortex/cli/commands/doctor.py,sha256=qtl2Z9z763fPkvqsWLMP_cBSXMVM8LBX_qoNz6Ls-AI,5131
15
18
  cortex/cli/commands/format.py,sha256=ioxiFCZn9O4oifwrzXckBODA-V2zafPfhKa1C8I0w5E,783
16
19
  cortex/cli/commands/get.py,sha256=hpHKZPo5y1Gk0NTwfoqzttFt7PNa0edQTJ9qMNY6z1E,1425
17
20
  cortex/cli/commands/glossary.py,sha256=cPRwl278gWbveceBcs0DziIFTZK2EwolYzwBB39hXWY,3088
@@ -30,13 +33,14 @@ cortex/cli/commands/v2_inspect.py,sha256=CI7W5RC8pv837NQhD4uMPoi0Exk6A2MlfUhiiVp
30
33
  cortex/cli/commands/v2_roundtrip.py,sha256=T6K_fZKylujuM2Lvb2KWCyA43y-LyrkOiwBqf5mCWcQ,4086
31
34
  cortex/cli/commands/v2_roundtrip_bidir.py,sha256=EFPQ9lh8xy_-3cSZ75IaDFJjn18juhCrka21jm0mPWg,5051
32
35
  cortex/cli/commands/v2_verify_view.py,sha256=PMaYt4iPumOkhCWyKceJ9I6mp07wQp-Ov43V-g5LFMI,2098
33
- cortex/cli/commands/verify.py,sha256=6szMscLYBgWlmDFl4IsV-RA1C-yK8x0HwJwpOL5QwaA,4250
36
+ cortex/cli/commands/verify.py,sha256=fqSzAp5J35WKdUcdgsEyGWXdDLwwEXPh9RgQcTxlFIg,6325
34
37
  cortex/core/__init__.py,sha256=iy6qwId5H9UgPLg1UdaqxkxCR-WxKoUjplNqFHU2KWM,2760
35
38
  cortex/core/ast.py,sha256=1m_vXO3C-gx_uOmLinmZH_W6hSb5bFZiylzJGjJCOVs,9523
36
39
  cortex/core/compare.py,sha256=eYbO5CMP9Uz1QiTcktONO_TuggeDDv-NkBtAuFHzYn0,7221
37
40
  cortex/core/document_kind.py,sha256=pDe9k5zReudIZxOgg9odkSAvzcbfPkMjvXioXW20rE8,22618
38
- cortex/core/errors.py,sha256=UAdkxPPcyhbS5e5VkTsPNBMbJBLw_FwGF5mG1iyxIN8,13191
41
+ cortex/core/errors.py,sha256=4puLs1sSbIx-5jrmQ1yYYNmeAxX7OpfMEoIlSu7DIy8,13538
39
42
  cortex/core/lexer.py,sha256=EfoQGzByljkxa7xYo9N7HM8KQuNQsCW8KIxL7Y9yFE0,8145
43
+ cortex/core/modes.py,sha256=NjLENepLYECuWxf62IgO9lDI_7_4JkcZnGEG19Ygkds,10306
40
44
  cortex/core/parser.py,sha256=SbMAqua8dC-7s61TqlUFne_qvviL3Tgrhk7tNFgNpds,23866
41
45
  cortex/core/validator.py,sha256=6tusJCqKBR66cSc36WTFea2mkRBmgbAt-ynHsK3EtTY,10167
42
46
  cortex/core/writer.py,sha256=7DFQa0wauAl3B-5zCMWJ675io3Ke1kPyKqDTprJUsNY,9207
@@ -56,6 +60,9 @@ cortex/hcortex/markdown_model.py,sha256=JLHyN07a5ZOmLsxcSOZagsCJpWsURjRpW2JZdXXZ
56
60
  cortex/hcortex/profiles.py,sha256=e4ir9O99UThtPThffIP_h68EnE5F0V_iuOtTBfVn4uE,5777
57
61
  cortex/hcortex/read_renderer.py,sha256=6usu_ZTp4gS74g9vgNUSCprFo54Vqc1jCndGDiqzmT8,11317
58
62
  cortex/hcortex/recovery.py,sha256=huT_EOteDFi_3zOfRglufC3xN-gEHoO2uDDkui9kDPo,29832
63
+ cortex/security/__init__.py,sha256=3pbqQGCNffN-MirAdQgNPl2WyFx-unhNnMChLabPbpE,941
64
+ cortex/security/secret_scanner.py,sha256=wf0rJ0yfA7f3L-T7SHFP-v0v5JN0tdVsW6X8cUkiKk8,12906
65
+ cortex/security/signature.py,sha256=gfbcr0j6CRlmJiRq2TKnuL4ZJuVolOzr10l2mNWEyYc,3843
59
66
  cortex/templates/__init__.py,sha256=D49Z4J-Nvl_o_tPM_z_KvW2m9fRIpHL4jYeSoCBmioo,292
60
67
  cortex/templates/brain.py,sha256=Ngs5Aj8Rf3c2LAckJxrN4K9oKGpmaZ-Ou_zZQhkA7jY,3531
61
68
  cortex/templates/minimal_glossary.py,sha256=ZeP4GH929_bqoKBVlluAF9Wgv9Ajtz-8HcIETkjsXcY,1564
@@ -72,10 +79,10 @@ cortex/v2/parser.py,sha256=wQkq7NnBQQDIzgkWxMpn-0GKxEppOPwidDnQOp4VMnY,20075
72
79
  cortex/v2/view.py,sha256=Rm9qcEcNDeidWy3Y5CAsSv4dvMrOqvb1Vl3k7WhTetA,15197
73
80
  cortex/v2/view_renderer.py,sha256=LQ5IP9b9giYofTL42F4cp6v3uAGvVZWYm0kYeGFw3nA,18553
74
81
  cortex/v2/writer.py,sha256=TiEbTfFPwtpFa-jNVbrShviyVvCNAu61baeCCAFnMDM,10913
75
- codec_cortex-0.3.2.dist-info/METADATA,sha256=rQLFkQ1SE1UxcOEPVbo-3GPx9w5DNdmqSDHwoK9zuI4,8137
76
- codec_cortex-0.3.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
77
- codec_cortex-0.3.2.dist-info/entry_points.txt,sha256=_Fl_fZk_6jJeX4QXcG1dT4WakyDZVu_G320FkcyQUOI,48
78
- codec_cortex-0.3.2.dist-info/scm_file_list.json,sha256=Zm6OWQv8RtPgN8c5w4xfa8pzJSYNvNzH976T_BmOpRQ,4411
79
- codec_cortex-0.3.2.dist-info/scm_version.json,sha256=a54wpletfE6bmUiRUMXsGhr1UwZJYX1Wc2KqMusTe34,160
80
- codec_cortex-0.3.2.dist-info/top_level.txt,sha256=79LAeTJJ_pMIBy3mkF7uNaN0mdBRt5tGrnne5N_iAio,7
81
- codec_cortex-0.3.2.dist-info/RECORD,,
82
+ codec_cortex-0.3.4.dist-info/METADATA,sha256=AjyYUSKLXVF9R_3ruWswCJVGjQ8f8bsjF59bNk0WzLk,8137
83
+ codec_cortex-0.3.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
84
+ codec_cortex-0.3.4.dist-info/entry_points.txt,sha256=_Fl_fZk_6jJeX4QXcG1dT4WakyDZVu_G320FkcyQUOI,48
85
+ codec_cortex-0.3.4.dist-info/scm_file_list.json,sha256=6nPrUvtSqoE42QLS-MNk8RQqj54iqKuCUKMGjBfefjU,4826
86
+ codec_cortex-0.3.4.dist-info/scm_version.json,sha256=tjJ3rcPApqh4nIht1WUSkNblALHnqBZPrw4caDtl8nE,160
87
+ codec_cortex-0.3.4.dist-info/top_level.txt,sha256=79LAeTJJ_pMIBy3mkF7uNaN0mdBRt5tGrnne5N_iAio,7
88
+ codec_cortex-0.3.4.dist-info/RECORD,,
@@ -22,6 +22,7 @@
22
22
  "src/cortex/core/document_kind.py",
23
23
  "src/cortex/core/parser.py",
24
24
  "src/cortex/core/writer.py",
25
+ "src/cortex/core/modes.py",
25
26
  "src/cortex/core/ast.py",
26
27
  "src/cortex/core/lexer.py",
27
28
  "src/cortex/core/compare.py",
@@ -58,6 +59,11 @@
58
59
  "src/cortex/templates/skill.py",
59
60
  "src/cortex/templates/brain.py",
60
61
  "src/cortex/templates/minimal_glossary.py",
62
+ "src/cortex/security/__init__.py",
63
+ "src/cortex/security/secret_scanner.py",
64
+ "src/cortex/security/signature.py",
65
+ "src/cortex/audit/__init__.py",
66
+ "src/cortex/audit/logger.py",
61
67
  "src/cortex/cli/__init__.py",
62
68
  "src/cortex/cli/main.py",
63
69
  "src/cortex/cli/commands/verify.py",
@@ -78,6 +84,7 @@
78
84
  "src/cortex/cli/commands/render.py",
79
85
  "src/cortex/cli/commands/micro.py",
80
86
  "src/cortex/cli/commands/list.py",
87
+ "src/cortex/cli/commands/audit.py",
81
88
  "src/cortex/cli/commands/new.py",
82
89
  "src/cortex/cli/commands/glossary.py",
83
90
  "src/cortex/cli/commands/update.py",
@@ -87,11 +94,14 @@
87
94
  "src/cortex/cli/commands/diff.py",
88
95
  "src/cortex/cli/commands/v2_roundtrip.py",
89
96
  "src/tests/test_fixtures.py",
97
+ "src/tests/test_e2_modes.py",
90
98
  "src/tests/test_v1_1_6_fixes.py",
91
99
  "src/tests/test_v2_3_0_acceptance.py",
100
+ "src/tests/test_e2_secret_scanner.py",
92
101
  "src/tests/__init__.py",
93
102
  "src/tests/test_audit_gates.py",
94
103
  "src/tests/test_v1_1_2_fixes.py",
104
+ "src/tests/test_e2_signature.py",
95
105
  "src/tests/test_v1_1_7_fixes.py",
96
106
  "src/tests/test_acceptance.py",
97
107
  "src/tests/test_v2_2_3_fixes.py",
@@ -107,6 +117,7 @@
107
117
  "src/tests/test_errors.py",
108
118
  "src/tests/test_v2_2_1_fixes.py",
109
119
  "src/tests/test_v1_1_9_fixes.py",
120
+ "src/tests/test_e2_audit.py",
110
121
  "src/tests/test_v2_2_view.py",
111
122
  "src/tests/test_reaudit_gates.py",
112
123
  "src/tests/fixtures/SKILL_v2.cortex.md",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.3.2",
2
+ "tag": "0.3.4",
3
3
  "distance": 0,
4
- "node": "g5c75b370509905a226dc45a9d4ba8a5e4aa83e40",
4
+ "node": "gfda71bbd80142ab185405525e9ed3fb006f0d937",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-07-01"
cortex/_version.py CHANGED
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.2'
22
- __version_tuple__ = version_tuple = (0, 3, 2)
21
+ __version__ = version = '0.3.4'
22
+ __version_tuple__ = version_tuple = (0, 3, 4)
23
23
 
24
- __commit_id__ = commit_id = 'g5c75b3705'
24
+ __commit_id__ = commit_id = 'gfda71bbd8'
@@ -0,0 +1,43 @@
1
+ """Audit logging module (E2.4, v0.3.4+).
2
+
3
+ Public surface:
4
+
5
+ - :mod:`cortex.audit.logger` — append-only JSONL audit log with
6
+ on-demand session state, daily rotation, and snapshot export.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .logger import (
12
+ AuditEntry,
13
+ SessionState,
14
+ append_entry,
15
+ audit_dir,
16
+ daily_log_path,
17
+ disable_logging,
18
+ enable_logging,
19
+ prune_old_logs,
20
+ read_session_state,
21
+ rotate_if_needed,
22
+ session_state_path,
23
+ snapshot_file,
24
+ snapshots_dir,
25
+ write_session_state,
26
+ )
27
+
28
+ __all__ = [
29
+ "AuditEntry",
30
+ "SessionState",
31
+ "append_entry",
32
+ "audit_dir",
33
+ "daily_log_path",
34
+ "disable_logging",
35
+ "enable_logging",
36
+ "prune_old_logs",
37
+ "read_session_state",
38
+ "rotate_if_needed",
39
+ "session_state_path",
40
+ "snapshot_file",
41
+ "snapshots_dir",
42
+ "write_session_state",
43
+ ]
cortex/audit/logger.py ADDED
@@ -0,0 +1,271 @@
1
+ """E2.4 (v0.3.4) — On-demand audit logging.
2
+
3
+ This module implements the audit log described in the E2.4 plan:
4
+
5
+ - Logging is OFF by default. No automatic logging of any operation.
6
+ - ``cortex audit on`` → enable logging for the current session.
7
+ - ``cortex audit off`` → disable logging for the current session.
8
+ - ``cortex audit status`` → show whether logging is on or off.
9
+ - ``cortex audit snapshot`` → export a single snapshot of a .cortex
10
+ file to ``~/.codec-cortex/audit/snapshots/`` (one-shot, not a stream).
11
+
12
+ The session state (on/off) is stored in
13
+ ``~/.codec-cortex/audit/session.state`` so it persists across CLI
14
+ invocations within the same shell/session. The state file is removed
15
+ by ``cortex audit off``.
16
+
17
+ When logging is ON, mutation commands (add/update/delete/move/format)
18
+ append a JSONL line to ``~/.codec-cortex/audit/YYYY-MM-DD.jsonl``::
19
+
20
+ {"t":"2026-07-01T12:00:00Z","op":"add","file":"brain.cortex",
21
+ "mode":"editor","result":"ok"}
22
+
23
+ Design principles:
24
+ - **No silent logging.** Nothing is written unless the user opts in.
25
+ - **Append-only.** Existing entries are never modified.
26
+ - **Out-of-repo.** Logs live in the user's home, not in the project
27
+ repo, so they don't pollute git history.
28
+ - **Per-day rotation.** A new JSONL file is created each day.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import datetime
34
+ import json
35
+ import os
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Any, Optional
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Paths
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def audit_dir() -> Path:
46
+ """Return the audit directory under the user's home.
47
+
48
+ Defaults to ``~/.codec-cortex/audit/``. Can be overridden by the
49
+ ``CORTEX_AUDIT_DIR`` environment variable (useful for tests).
50
+ """
51
+ override = os.environ.get("CORTEX_AUDIT_DIR")
52
+ if override:
53
+ return Path(override).expanduser()
54
+ return Path.home() / ".codec-cortex" / "audit"
55
+
56
+
57
+ def session_state_path() -> Path:
58
+ """Path to the session state file (created by `audit on`)."""
59
+ return audit_dir() / "session.state"
60
+
61
+
62
+ def daily_log_path(date: Optional[datetime.date] = None) -> Path:
63
+ """Path to the daily JSONL log file (``YYYY-MM-DD.jsonl``)."""
64
+ if date is None:
65
+ date = datetime.date.today()
66
+ return audit_dir() / f"{date.isoformat()}.jsonl"
67
+
68
+
69
+ def snapshots_dir() -> Path:
70
+ """Directory where `audit snapshot` exports one-shot snapshots."""
71
+ return audit_dir() / "snapshots"
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Session state
76
+ # ---------------------------------------------------------------------------
77
+
78
+ @dataclass
79
+ class SessionState:
80
+ """In-memory representation of the audit session state."""
81
+
82
+ enabled: bool
83
+ started_at: Optional[str] = None # ISO 8601 timestamp
84
+ pid: Optional[int] = None
85
+
86
+ def to_dict(self) -> dict:
87
+ return {
88
+ "enabled": self.enabled,
89
+ "started_at": self.started_at,
90
+ "pid": self.pid,
91
+ }
92
+
93
+
94
+ def read_session_state() -> SessionState:
95
+ """Read the current session state from disk.
96
+
97
+ Returns a disabled SessionState if no state file exists.
98
+ """
99
+ path = session_state_path()
100
+ if not path.is_file():
101
+ return SessionState(enabled=False)
102
+ try:
103
+ data = json.loads(path.read_text(encoding="utf-8"))
104
+ return SessionState(
105
+ enabled=bool(data.get("enabled", False)),
106
+ started_at=data.get("started_at"),
107
+ pid=data.get("pid"),
108
+ )
109
+ except (json.JSONDecodeError, OSError):
110
+ return SessionState(enabled=False)
111
+
112
+
113
+ def write_session_state(state: SessionState) -> None:
114
+ """Persist the session state to disk. Creates the audit dir if needed."""
115
+ path = session_state_path()
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ path.write_text(
118
+ json.dumps(state.to_dict(), indent=2, ensure_ascii=False) + "\n",
119
+ encoding="utf-8",
120
+ )
121
+
122
+
123
+ def enable_logging() -> SessionState:
124
+ """Enable audit logging for the current session."""
125
+ state = SessionState(
126
+ enabled=True,
127
+ started_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
128
+ pid=os.getpid(),
129
+ )
130
+ write_session_state(state)
131
+ # Also create the daily log file if it doesn't exist (touch).
132
+ log_path = daily_log_path()
133
+ log_path.parent.mkdir(parents=True, exist_ok=True)
134
+ if not log_path.exists():
135
+ log_path.touch()
136
+ return state
137
+
138
+
139
+ def disable_logging() -> SessionState:
140
+ """Disable audit logging. The state file is removed; existing log
141
+ entries are preserved."""
142
+ path = session_state_path()
143
+ if path.is_file():
144
+ path.unlink()
145
+ return SessionState(enabled=False)
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Log entry
150
+ # ---------------------------------------------------------------------------
151
+
152
+ @dataclass
153
+ class AuditEntry:
154
+ """A single audit log entry."""
155
+
156
+ op: str # "add" | "update" | "delete" | "move" | "format" | ...
157
+ file: str # path to the .cortex file
158
+ mode: str # "read-only" | "editor" | "admin"
159
+ result: str # "ok" | "error" | "dry-run"
160
+ selector: Optional[str] = None # entry selector (e.g. "FCS:primary")
161
+ error_code: Optional[str] = None
162
+ timestamp: Optional[str] = None # ISO 8601; defaults to now()
163
+
164
+ def to_dict(self) -> dict:
165
+ ts = self.timestamp or datetime.datetime.now(datetime.timezone.utc).isoformat()
166
+ d: dict[str, Any] = {
167
+ "t": ts,
168
+ "op": self.op,
169
+ "file": self.file,
170
+ "mode": self.mode,
171
+ "result": self.result,
172
+ }
173
+ if self.selector:
174
+ d["selector"] = self.selector
175
+ if self.error_code:
176
+ d["error_code"] = self.error_code
177
+ return d
178
+
179
+ def to_jsonl(self) -> str:
180
+ return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
181
+
182
+
183
+ def append_entry(entry: AuditEntry) -> Optional[Path]:
184
+ """Append ``entry`` to today's JSONL log if logging is enabled.
185
+
186
+ Returns the path to the log file if written, or None if logging is
187
+ disabled.
188
+ """
189
+ state = read_session_state()
190
+ if not state.enabled:
191
+ return None
192
+
193
+ log_path = daily_log_path()
194
+ log_path.parent.mkdir(parents=True, exist_ok=True)
195
+ # Append-only: open in append mode, write one line, close.
196
+ with log_path.open("a", encoding="utf-8") as f:
197
+ f.write(entry.to_jsonl() + "\n")
198
+ return log_path
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Snapshot
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def snapshot_file(cortex_path: Path, *, label: Optional[str] = None) -> Path:
206
+ """Export a one-shot snapshot of ``cortex_path`` to the snapshots dir.
207
+
208
+ The snapshot is a verbatim copy of the .cortex file, named with a
209
+ timestamp so multiple snapshots don't collide. An optional ``label``
210
+ can be appended to the filename for human readability.
211
+
212
+ Returns the path to the written snapshot.
213
+ """
214
+ if not cortex_path.is_file():
215
+ raise FileNotFoundError(f"cannot snapshot non-existent file: {cortex_path}")
216
+
217
+ snapshots_dir().mkdir(parents=True, exist_ok=True)
218
+ ts = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
219
+ stem = cortex_path.stem
220
+ suffix = cortex_path.suffix
221
+ label_part = f"_{label}" if label else ""
222
+ snap_name = f"{stem}_{ts}{label_part}{suffix}"
223
+ snap_path = snapshots_dir() / snap_name
224
+ snap_path.write_bytes(cortex_path.read_bytes())
225
+ return snap_path
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Rotation (E2 risk mitigation)
230
+ # ---------------------------------------------------------------------------
231
+
232
+ MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
233
+
234
+
235
+ def rotate_if_needed(log_path: Path) -> Optional[Path]:
236
+ """Rotate ``log_path`` if it exceeds :data:`MAX_LOG_SIZE_BYTES`.
237
+
238
+ Renames ``YYYY-MM-DD.jsonl`` to ``YYYY-MM-DD.jsonl.1`` (overwriting
239
+ any previous rotation). Returns the rotated path if rotation
240
+ happened, None otherwise.
241
+ """
242
+ if not log_path.is_file():
243
+ return None
244
+ if log_path.stat().st_size < MAX_LOG_SIZE_BYTES:
245
+ return None
246
+ rotated = log_path.with_suffix(log_path.suffix + ".1")
247
+ log_path.rename(rotated)
248
+ log_path.touch()
249
+ return rotated
250
+
251
+
252
+ def prune_old_logs(*, keep_days: int = 30) -> int:
253
+ """Delete daily log files older than ``keep_days`` days.
254
+
255
+ Returns the number of files deleted.
256
+ """
257
+ cutoff = datetime.date.today() - datetime.timedelta(days=keep_days)
258
+ deleted = 0
259
+ for p in audit_dir().glob("*.jsonl"):
260
+ # Skip rotated files (.jsonl.1) — they're handled separately.
261
+ if p.name.endswith(".1"):
262
+ continue
263
+ try:
264
+ date_str = p.stem # "YYYY-MM-DD"
265
+ file_date = datetime.date.fromisoformat(date_str)
266
+ except ValueError:
267
+ continue
268
+ if file_date < cutoff:
269
+ p.unlink()
270
+ deleted += 1
271
+ return deleted
@@ -0,0 +1,95 @@
1
+ """``cortex audit`` — on-demand audit logging control (E2.4, v0.3.4).
2
+
3
+ Subcommands::
4
+
5
+ cortex audit on # enable logging for this session
6
+ cortex audit off # disable logging
7
+ cortex audit status # show whether logging is on/off
8
+ cortex audit snapshot <file> # export a one-shot snapshot
9
+ cortex audit prune [--keep-days N] # delete old logs
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from ...audit.logger import (
19
+ audit_dir,
20
+ daily_log_path,
21
+ disable_logging,
22
+ enable_logging,
23
+ prune_old_logs,
24
+ read_session_state,
25
+ snapshot_file,
26
+ )
27
+
28
+
29
+ def _emit_status() -> int:
30
+ state = read_session_state()
31
+ payload = {
32
+ "enabled": state.enabled,
33
+ "started_at": state.started_at,
34
+ "pid": state.pid,
35
+ "audit_dir": str(audit_dir()),
36
+ "daily_log": str(daily_log_path()),
37
+ }
38
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
39
+ return 0
40
+
41
+
42
+ def run_on(args) -> int:
43
+ state = enable_logging()
44
+ print("audit logging: ON")
45
+ print(f" started_at: {state.started_at}")
46
+ print(f" pid: {state.pid}")
47
+ print(f" log file: {daily_log_path()}")
48
+ return 0
49
+
50
+
51
+ def run_off(args) -> int:
52
+ disable_logging()
53
+ print("audit logging: OFF")
54
+ print(f" (existing entries in {audit_dir()} are preserved)")
55
+ return 0
56
+
57
+
58
+ def run_status(args) -> int:
59
+ return _emit_status()
60
+
61
+
62
+ def run_snapshot(args) -> int:
63
+ target = Path(args.file).expanduser().resolve()
64
+ if not target.is_file():
65
+ print(f"error: file not found: {target}", file=sys.stderr)
66
+ return 1
67
+ label = getattr(args, "label", None)
68
+ snap = snapshot_file(target, label=label)
69
+ print(f"snapshot written: {snap}")
70
+ print(f" size: {snap.stat().st_size} bytes")
71
+ return 0
72
+
73
+
74
+ def run_prune(args) -> int:
75
+ keep_days = getattr(args, "keep_days", 30)
76
+ deleted = prune_old_logs(keep_days=keep_days)
77
+ print(f"pruned {deleted} log file(s) older than {keep_days} days")
78
+ return 0
79
+
80
+
81
+ def run(args) -> int:
82
+ # Dispatch by subcommand.
83
+ sub = getattr(args, "audit_command", None)
84
+ if sub == "on":
85
+ return run_on(args)
86
+ if sub == "off":
87
+ return run_off(args)
88
+ if sub == "status":
89
+ return run_status(args)
90
+ if sub == "snapshot":
91
+ return run_snapshot(args)
92
+ if sub == "prune":
93
+ return run_prune(args)
94
+ print(f"error: unknown audit subcommand: {sub}", file=sys.stderr)
95
+ return 2
@@ -1,11 +1,20 @@
1
- """``cortex doctor`` — deep diagnostic of a .cortex file."""
1
+ """``cortex doctor`` — deep diagnostic of a .cortex file.
2
+
3
+ v0.3.4 (E2.2): adds secret scanning via :mod:`cortex.security.secret_scanner`.
4
+ By default the scan is reported in the diagnostic summary; pass
5
+ ``--scan-secrets`` to exit 1 if any high-severity finding is present.
6
+ Use ``--scan-secrets-paths <path>...`` to scan additional paths beyond
7
+ the .cortex file itself (useful for scanning a whole repo).
8
+ """
2
9
 
3
10
  from __future__ import annotations
4
11
 
5
12
  import json
13
+ from pathlib import Path
6
14
 
7
15
  from ...core.document_kind import infer_document_kind
8
16
  from ...core.validator import validate
17
+ from ...security.secret_scanner import redact, scan_paths
9
18
  from ..commands import load_doc
10
19
 
11
20
 
@@ -13,7 +22,6 @@ def run(args) -> int:
13
22
  doc = load_doc(args.input)
14
23
  strict = getattr(args, "strict", False)
15
24
 
16
- # Apply explicit kind if provided (audit gap H-01/H-02)
17
25
  kind = None
18
26
  if getattr(args, "kind", None):
19
27
  from ...core.document_kind import DocumentKind
@@ -30,9 +38,19 @@ def run(args) -> int:
30
38
  micro = list(doc.glossary.micro.values())
31
39
  entries = [(s.id, e) for s in sections if s.id != "$0" for e in s.entries]
32
40
 
41
+ # E2.2 (v0.3.4): secret scan.
42
+ # The .cortex file itself is always scanned. Additional paths can be
43
+ # provided via --scan-secrets-paths (typically used in CI to scan
44
+ # the whole repo, not just the .cortex artefact).
45
+ scan_paths_arg = getattr(args, "scan_secrets_paths", None) or []
46
+ scan_targets = [Path(args.input)] + [Path(p) for p in scan_paths_arg]
47
+ baseline = getattr(args, "secrets_baseline", None)
48
+ baseline_path = Path(baseline) if baseline else None
49
+ scan_result = scan_paths(scan_targets, baseline=baseline_path)
50
+
33
51
  json_mode = getattr(args, "_json_mode", False)
34
52
  if args.format == "json" or json_mode:
35
- print(json.dumps({
53
+ payload = {
36
54
  "ok": len(errors) == 0,
37
55
  "input": args.input,
38
56
  "kind": kind.kind,
@@ -47,7 +65,13 @@ def run(args) -> int:
47
65
  "warnings": warnings,
48
66
  "section_ids": [s.id for s in sections],
49
67
  "sigil_names": [s.sigil for s in sigils],
50
- }, indent=2, default=str))
68
+ "secret_scan": scan_result.to_dict(),
69
+ }
70
+ # Redact matches in the JSON output too, so the doctor output
71
+ # itself doesn't become a secret-leaking artefact.
72
+ for f in payload["secret_scan"]["findings"]:
73
+ f["match"] = redact(f["match"])
74
+ print(json.dumps(payload, indent=2, default=str))
51
75
  else:
52
76
  lines = []
53
77
  lines.append(f"doctor: {args.input}")
@@ -60,6 +84,22 @@ def run(args) -> int:
60
84
  lines.append(f" micro: {len(micro)}")
61
85
  lines.append(f" errors: {len(errors)}")
62
86
  lines.append(f" warnings: {len(warnings)}")
87
+ # Secret scan summary
88
+ lines.append("")
89
+ lines.append("secret scan:")
90
+ lines.append(f" files scanned: {scan_result.files_scanned}")
91
+ lines.append(f" bytes scanned: {scan_result.bytes_scanned}")
92
+ lines.append(f" findings: {len(scan_result.findings)} "
93
+ f"(high={len(scan_result.by_severity('high'))}, "
94
+ f"medium={len(scan_result.by_severity('medium'))})")
95
+ if scan_result.findings:
96
+ lines.append("")
97
+ lines.append(" findings (match redacted):")
98
+ for f in scan_result.findings:
99
+ lines.append(
100
+ f" [{f.severity.upper():6}] {f.path}:{f.line} "
101
+ f"{f.rule} — {redact(f.match)}"
102
+ )
63
103
  if errors:
64
104
  lines.append("")
65
105
  lines.append("errors:")
@@ -71,4 +111,12 @@ def run(args) -> int:
71
111
  for w in warnings:
72
112
  lines.append(f" [{w.get('code')}] {w.get('message')}")
73
113
  print("\n".join(lines))
74
- return 1 if errors else 0
114
+
115
+ # Exit codes:
116
+ # 1 if there are validation errors
117
+ # 2 if --scan-secrets was set and any high-severity finding exists
118
+ # (both can be true; the higher code wins)
119
+ rc = 1 if errors else 0
120
+ if getattr(args, "scan_secrets", False) and scan_result.has_high:
121
+ rc = max(rc, 2)
122
+ return rc