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.
- {codec_cortex-0.3.2.dist-info → codec_cortex-0.3.4.dist-info}/METADATA +1 -1
- {codec_cortex-0.3.2.dist-info → codec_cortex-0.3.4.dist-info}/RECORD +20 -13
- {codec_cortex-0.3.2.dist-info → codec_cortex-0.3.4.dist-info}/scm_file_list.json +11 -0
- {codec_cortex-0.3.2.dist-info → codec_cortex-0.3.4.dist-info}/scm_version.json +2 -2
- cortex/_version.py +3 -3
- cortex/audit/__init__.py +43 -0
- cortex/audit/logger.py +271 -0
- cortex/cli/commands/audit.py +95 -0
- cortex/cli/commands/doctor.py +53 -5
- cortex/cli/commands/verify.py +57 -1
- cortex/cli/main.py +211 -21
- cortex/core/errors.py +8 -0
- cortex/core/modes.py +287 -0
- cortex/security/__init__.py +47 -0
- cortex/security/secret_scanner.py +386 -0
- cortex/security/signature.py +137 -0
- {codec_cortex-0.3.2.dist-info → codec_cortex-0.3.4.dist-info}/WHEEL +0 -0
- {codec_cortex-0.3.2.dist-info → codec_cortex-0.3.4.dist-info}/entry_points.txt +0 -0
- {codec_cortex-0.3.2.dist-info → codec_cortex-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {codec_cortex-0.3.2.dist-info → codec_cortex-0.3.4.dist-info}/top_level.txt +0 -0
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
codec_cortex-0.3.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
76
|
-
codec_cortex-0.3.
|
|
77
|
-
codec_cortex-0.3.
|
|
78
|
-
codec_cortex-0.3.
|
|
79
|
-
codec_cortex-0.3.
|
|
80
|
-
codec_cortex-0.3.
|
|
81
|
-
codec_cortex-0.3.
|
|
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",
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
21
|
+
__version__ = version = '0.3.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 4)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gfda71bbd8'
|
cortex/audit/__init__.py
ADDED
|
@@ -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
|
cortex/cli/commands/doctor.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|