licit-ai-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- licit/__init__.py +3 -0
- licit/__main__.py +5 -0
- licit/changelog/__init__.py +0 -0
- licit/cli.py +555 -0
- licit/config/__init__.py +0 -0
- licit/config/defaults.py +12 -0
- licit/config/loader.py +80 -0
- licit/config/schema.py +116 -0
- licit/connectors/__init__.py +0 -0
- licit/core/__init__.py +0 -0
- licit/core/evidence.py +213 -0
- licit/core/models.py +118 -0
- licit/core/project.py +307 -0
- licit/frameworks/__init__.py +0 -0
- licit/frameworks/eu_ai_act/__init__.py +0 -0
- licit/frameworks/owasp_agentic/__init__.py +0 -0
- licit/logging/__init__.py +0 -0
- licit/logging/setup.py +29 -0
- licit/provenance/__init__.py +0 -0
- licit/provenance/session_readers/__init__.py +0 -0
- licit/py.typed +0 -0
- licit/reports/__init__.py +0 -0
- licit_ai_cli-0.1.0.dist-info/METADATA +267 -0
- licit_ai_cli-0.1.0.dist-info/RECORD +27 -0
- licit_ai_cli-0.1.0.dist-info/WHEEL +4 -0
- licit_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- licit_ai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
licit/__init__.py
ADDED
licit/__main__.py
ADDED
|
File without changes
|
licit/cli.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""licit CLI — regulatory compliance for AI-powered development teams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
from licit import __version__
|
|
13
|
+
from licit.config.loader import load_config, save_config
|
|
14
|
+
from licit.config.schema import FrameworkConfig, LicitConfig
|
|
15
|
+
from licit.core.evidence import EvidenceCollector
|
|
16
|
+
from licit.core.models import ComplianceStatus, ControlResult
|
|
17
|
+
from licit.core.project import ProjectDetector
|
|
18
|
+
from licit.logging.setup import setup_logging
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
@click.version_option(version=__version__)
|
|
25
|
+
@click.option("--config", "config_path", type=click.Path(), help="Path to .licit.yaml")
|
|
26
|
+
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def main(ctx: click.Context, config_path: str | None, verbose: bool) -> None:
|
|
29
|
+
"""licit — Regulatory compliance for AI-powered development teams."""
|
|
30
|
+
setup_logging(verbose)
|
|
31
|
+
ctx.ensure_object(dict)
|
|
32
|
+
ctx.obj["config_path"] = config_path
|
|
33
|
+
ctx.obj["verbose"] = verbose
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command()
|
|
37
|
+
@click.option(
|
|
38
|
+
"--framework",
|
|
39
|
+
type=click.Choice(["eu-ai-act", "owasp", "all"]),
|
|
40
|
+
default="all",
|
|
41
|
+
help="Pre-configure for a specific framework",
|
|
42
|
+
)
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def init(ctx: click.Context, framework: str) -> None:
|
|
45
|
+
"""Initialize licit in the current project.
|
|
46
|
+
|
|
47
|
+
Detects project characteristics, creates .licit.yaml,
|
|
48
|
+
and sets up the .licit/ directory.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
licit init
|
|
52
|
+
licit init --framework eu-ai-act
|
|
53
|
+
"""
|
|
54
|
+
root = str(Path.cwd())
|
|
55
|
+
detector = ProjectDetector()
|
|
56
|
+
context = detector.detect(root)
|
|
57
|
+
|
|
58
|
+
click.echo(f"\n Project: {context.name}")
|
|
59
|
+
click.echo(f" Languages: {', '.join(context.languages) or 'none detected'}")
|
|
60
|
+
click.echo(f" Agent configs: {len(context.agent_configs)} detected")
|
|
61
|
+
click.echo(f" CI/CD: {context.cicd.platform}")
|
|
62
|
+
click.echo(f" Testing: {context.test_framework or 'none detected'}")
|
|
63
|
+
|
|
64
|
+
tools: list[str] = []
|
|
65
|
+
if context.security.has_vigil:
|
|
66
|
+
tools.append("vigil")
|
|
67
|
+
if context.security.has_semgrep:
|
|
68
|
+
tools.append("semgrep")
|
|
69
|
+
if context.security.has_snyk:
|
|
70
|
+
tools.append("snyk")
|
|
71
|
+
click.echo(f" Security tools: {', '.join(tools) if tools else 'none detected'}")
|
|
72
|
+
|
|
73
|
+
if context.agent_configs:
|
|
74
|
+
click.echo("\n Agent configurations found:")
|
|
75
|
+
for cfg in context.agent_configs:
|
|
76
|
+
click.echo(f" - {cfg.path} ({cfg.agent_type})")
|
|
77
|
+
|
|
78
|
+
# Build config
|
|
79
|
+
config = LicitConfig()
|
|
80
|
+
if framework == "eu-ai-act":
|
|
81
|
+
config.frameworks = FrameworkConfig(eu_ai_act=True, owasp_agentic=False)
|
|
82
|
+
elif framework == "owasp":
|
|
83
|
+
config.frameworks = FrameworkConfig(eu_ai_act=False, owasp_agentic=True)
|
|
84
|
+
|
|
85
|
+
# Auto-configure connectors
|
|
86
|
+
if context.has_architect:
|
|
87
|
+
config.connectors.architect.enabled = True
|
|
88
|
+
if context.architect_config_path:
|
|
89
|
+
config.connectors.architect.config_path = context.architect_config_path
|
|
90
|
+
if context.security.has_vigil:
|
|
91
|
+
config.connectors.vigil.enabled = True
|
|
92
|
+
|
|
93
|
+
config_file = save_config(config, ctx.obj.get("config_path"))
|
|
94
|
+
|
|
95
|
+
# Create .licit directory
|
|
96
|
+
(Path(root) / ".licit").mkdir(exist_ok=True)
|
|
97
|
+
|
|
98
|
+
click.echo(f"\n Created {config_file.name}")
|
|
99
|
+
click.echo(" Created .licit/ directory")
|
|
100
|
+
click.echo("\n Next steps:")
|
|
101
|
+
click.echo(" licit trace Track code provenance")
|
|
102
|
+
click.echo(" licit fria Complete FRIA assessment")
|
|
103
|
+
click.echo(" licit report Generate compliance report")
|
|
104
|
+
click.echo(" licit gaps Check compliance gaps")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@main.command()
|
|
108
|
+
@click.option("--since", help="Analyze commits since date (YYYY-MM-DD) or tag")
|
|
109
|
+
@click.option("--report", "gen_report", is_flag=True, help="Generate provenance report")
|
|
110
|
+
@click.option("--stats", is_flag=True, help="Show provenance statistics")
|
|
111
|
+
@click.pass_context
|
|
112
|
+
def trace(ctx: click.Context, since: str | None, gen_report: bool, stats: bool) -> None:
|
|
113
|
+
"""Track code provenance — who (human/AI) wrote what.
|
|
114
|
+
|
|
115
|
+
Analyzes git history to infer which code was generated by AI agents.
|
|
116
|
+
Results are stored in .licit/provenance.jsonl.
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
licit trace Analyze full git history
|
|
120
|
+
licit trace --since 2026-01-01 Analyze since January 2026
|
|
121
|
+
licit trace --stats Show provenance statistics
|
|
122
|
+
licit trace --report Generate full provenance report
|
|
123
|
+
"""
|
|
124
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
125
|
+
|
|
126
|
+
if stats:
|
|
127
|
+
from licit.provenance.store import ProvenanceStore # type: ignore[import-not-found]
|
|
128
|
+
|
|
129
|
+
store: Any = ProvenanceStore(config.provenance.store_path)
|
|
130
|
+
s: dict[str, Any] = store.get_stats()
|
|
131
|
+
click.echo("\n Provenance Statistics")
|
|
132
|
+
click.echo(f" {'─' * 40}")
|
|
133
|
+
click.echo(f" Total files tracked: {s['total_files']}")
|
|
134
|
+
click.echo(f" AI-generated: {s['ai_files']} ({s['ai_percentage']:.1f}%)")
|
|
135
|
+
click.echo(f" Human-written: {s['human_files']}")
|
|
136
|
+
click.echo(f" Mixed: {s.get('mixed_files', 0)}")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
click.echo(" Analyzing git history for AI provenance...")
|
|
140
|
+
|
|
141
|
+
from licit.provenance.tracker import ProvenanceTracker # type: ignore[import-not-found]
|
|
142
|
+
|
|
143
|
+
root = str(Path.cwd())
|
|
144
|
+
tracker: Any = ProvenanceTracker(root, config.provenance)
|
|
145
|
+
records: list[Any] = tracker.analyze(since=since)
|
|
146
|
+
|
|
147
|
+
ai_count = sum(1 for r in records if r.source == "ai")
|
|
148
|
+
human_count = sum(1 for r in records if r.source == "human")
|
|
149
|
+
click.echo(f" Analyzed {len(records)} file records")
|
|
150
|
+
click.echo(f" AI-generated: {ai_count} files")
|
|
151
|
+
click.echo(f" Human-written: {human_count} files")
|
|
152
|
+
|
|
153
|
+
if gen_report:
|
|
154
|
+
from licit.provenance.report import ( # type: ignore[import-not-found]
|
|
155
|
+
generate_provenance_report,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
report_path = ".licit/provenance-report.md"
|
|
159
|
+
generate_provenance_report(records, report_path)
|
|
160
|
+
click.echo(f" Report saved to: {report_path}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@main.command()
|
|
164
|
+
@click.option("--since", help="Show changes since date or tag")
|
|
165
|
+
@click.option(
|
|
166
|
+
"--format",
|
|
167
|
+
"fmt",
|
|
168
|
+
type=click.Choice(["markdown", "json"]),
|
|
169
|
+
default="markdown",
|
|
170
|
+
)
|
|
171
|
+
@click.pass_context
|
|
172
|
+
def changelog(ctx: click.Context, since: str | None, fmt: str) -> None:
|
|
173
|
+
"""Generate changelog of agent configuration changes.
|
|
174
|
+
|
|
175
|
+
Monitors CLAUDE.md, .cursorrules, AGENTS.md, architect config,
|
|
176
|
+
and other agent configuration files for changes across git history.
|
|
177
|
+
|
|
178
|
+
Examples:
|
|
179
|
+
licit changelog
|
|
180
|
+
licit changelog --since v1.0.0
|
|
181
|
+
licit changelog --format json
|
|
182
|
+
"""
|
|
183
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
184
|
+
root = str(Path.cwd())
|
|
185
|
+
|
|
186
|
+
from licit.changelog.classifier import ChangeClassifier # type: ignore[import-not-found]
|
|
187
|
+
from licit.changelog.renderer import ChangelogRenderer # type: ignore[import-not-found]
|
|
188
|
+
from licit.changelog.watcher import ConfigWatcher # type: ignore[import-not-found]
|
|
189
|
+
|
|
190
|
+
watcher: Any = ConfigWatcher(root, config.changelog.watch_files)
|
|
191
|
+
history: dict[str, list[Any]] = watcher.get_config_history(since=since)
|
|
192
|
+
|
|
193
|
+
if not history:
|
|
194
|
+
click.echo(" No agent configuration changes found.")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
classifier: Any = ChangeClassifier()
|
|
198
|
+
all_changes: list[Any] = []
|
|
199
|
+
for file_path, snapshots in history.items():
|
|
200
|
+
for i in range(len(snapshots) - 1):
|
|
201
|
+
changes = classifier.classify_changes(
|
|
202
|
+
old_content=snapshots[i + 1].content,
|
|
203
|
+
new_content=snapshots[i].content,
|
|
204
|
+
file_path=file_path,
|
|
205
|
+
commit_sha=snapshots[i].commit_sha,
|
|
206
|
+
)
|
|
207
|
+
all_changes.extend(changes)
|
|
208
|
+
|
|
209
|
+
renderer: Any = ChangelogRenderer()
|
|
210
|
+
output: str = renderer.render(all_changes, fmt=fmt)
|
|
211
|
+
|
|
212
|
+
output_path = config.changelog.output_path
|
|
213
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
Path(output_path).write_text(output, encoding="utf-8")
|
|
215
|
+
|
|
216
|
+
click.echo(output)
|
|
217
|
+
click.echo(f"\n Changelog saved to {output_path}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@main.command()
|
|
221
|
+
@click.option("--update", is_flag=True, help="Update existing FRIA")
|
|
222
|
+
@click.pass_context
|
|
223
|
+
def fria(ctx: click.Context, update: bool) -> None:
|
|
224
|
+
"""Complete the Fundamental Rights Impact Assessment (Art. 27).
|
|
225
|
+
|
|
226
|
+
Interactive questionnaire that generates a FRIA document.
|
|
227
|
+
Auto-detects answers from project configuration where possible.
|
|
228
|
+
|
|
229
|
+
Examples:
|
|
230
|
+
licit fria Start new FRIA
|
|
231
|
+
licit fria --update Update existing FRIA
|
|
232
|
+
"""
|
|
233
|
+
root = str(Path.cwd())
|
|
234
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
235
|
+
detector = ProjectDetector()
|
|
236
|
+
context = detector.detect(root)
|
|
237
|
+
evidence = EvidenceCollector(root, context).collect()
|
|
238
|
+
|
|
239
|
+
from licit.frameworks.eu_ai_act.fria import FRIAGenerator # type: ignore[import-not-found]
|
|
240
|
+
|
|
241
|
+
generator: Any = FRIAGenerator(context, evidence)
|
|
242
|
+
|
|
243
|
+
if update and Path(config.fria.data_path).exists():
|
|
244
|
+
import json
|
|
245
|
+
|
|
246
|
+
existing = json.loads(Path(config.fria.data_path).read_text(encoding="utf-8"))
|
|
247
|
+
click.echo(" Updating existing FRIA...")
|
|
248
|
+
responses: dict[str, Any] = generator.run_interactive()
|
|
249
|
+
for key, value in existing.items():
|
|
250
|
+
if key not in responses or not responses[key]:
|
|
251
|
+
responses[key] = value
|
|
252
|
+
else:
|
|
253
|
+
responses = generator.run_interactive()
|
|
254
|
+
|
|
255
|
+
generator.save_data(responses, config.fria.data_path)
|
|
256
|
+
generator.generate_report(responses, config.fria.output_path)
|
|
257
|
+
|
|
258
|
+
click.echo(f"\n FRIA data saved to: {config.fria.data_path}")
|
|
259
|
+
click.echo(f" FRIA report saved to: {config.fria.output_path}")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@main.command("annex-iv")
|
|
263
|
+
@click.option("--organization", help="Organization name")
|
|
264
|
+
@click.option("--product", help="Product name")
|
|
265
|
+
@click.pass_context
|
|
266
|
+
def annex_iv(ctx: click.Context, organization: str | None, product: str | None) -> None:
|
|
267
|
+
"""Generate Annex IV Technical Documentation.
|
|
268
|
+
|
|
269
|
+
Auto-populates from project metadata (pyproject.toml, package.json,
|
|
270
|
+
CI/CD configs, agent configs, test frameworks).
|
|
271
|
+
|
|
272
|
+
Examples:
|
|
273
|
+
licit annex-iv
|
|
274
|
+
licit annex-iv --organization "ACME Corp" --product "WebApp"
|
|
275
|
+
"""
|
|
276
|
+
root = str(Path.cwd())
|
|
277
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
278
|
+
detector = ProjectDetector()
|
|
279
|
+
context = detector.detect(root)
|
|
280
|
+
evidence = EvidenceCollector(root, context).collect()
|
|
281
|
+
|
|
282
|
+
from licit.frameworks.eu_ai_act.annex_iv import ( # type: ignore[import-not-found]
|
|
283
|
+
AnnexIVGenerator,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
generator: Any = AnnexIVGenerator(context, evidence)
|
|
287
|
+
|
|
288
|
+
org = organization or config.annex_iv.organization or context.name
|
|
289
|
+
prod = product or config.annex_iv.product_name or context.name
|
|
290
|
+
|
|
291
|
+
generator.generate(
|
|
292
|
+
output_path=config.annex_iv.output_path,
|
|
293
|
+
organization=org,
|
|
294
|
+
product_name=prod,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
click.echo(f"\n Annex IV documentation saved to: {config.annex_iv.output_path}")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@main.command()
|
|
301
|
+
@click.option(
|
|
302
|
+
"--framework",
|
|
303
|
+
type=click.Choice(["eu-ai-act", "owasp", "all"]),
|
|
304
|
+
default="all",
|
|
305
|
+
)
|
|
306
|
+
@click.option(
|
|
307
|
+
"--format",
|
|
308
|
+
"fmt",
|
|
309
|
+
type=click.Choice(["markdown", "json", "html"]),
|
|
310
|
+
default="markdown",
|
|
311
|
+
)
|
|
312
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
|
313
|
+
@click.pass_context
|
|
314
|
+
def report(ctx: click.Context, framework: str, fmt: str, output: str | None) -> None:
|
|
315
|
+
"""Generate unified compliance report.
|
|
316
|
+
|
|
317
|
+
Evaluates project against configured frameworks and generates
|
|
318
|
+
a report with evidence, status, and recommendations.
|
|
319
|
+
|
|
320
|
+
Examples:
|
|
321
|
+
licit report
|
|
322
|
+
licit report --framework eu-ai-act
|
|
323
|
+
licit report --format json -o compliance.json
|
|
324
|
+
licit report --format html -o compliance.html
|
|
325
|
+
"""
|
|
326
|
+
root = str(Path.cwd())
|
|
327
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
328
|
+
detector = ProjectDetector()
|
|
329
|
+
context = detector.detect(root)
|
|
330
|
+
evidence = EvidenceCollector(root, context).collect()
|
|
331
|
+
|
|
332
|
+
from licit.reports.unified import UnifiedReportGenerator # type: ignore[import-not-found]
|
|
333
|
+
|
|
334
|
+
generator: Any = UnifiedReportGenerator(context, evidence, config)
|
|
335
|
+
frameworks_to_eval = _get_frameworks(framework, config)
|
|
336
|
+
report_data: Any = generator.generate(frameworks_to_eval)
|
|
337
|
+
|
|
338
|
+
from licit.reports import html as html_reporter # type: ignore[attr-defined]
|
|
339
|
+
from licit.reports import json_fmt # type: ignore[attr-defined]
|
|
340
|
+
from licit.reports import markdown as md_reporter # type: ignore[attr-defined]
|
|
341
|
+
|
|
342
|
+
if fmt == "json":
|
|
343
|
+
content: str = json_fmt.render(report_data)
|
|
344
|
+
elif fmt == "html":
|
|
345
|
+
content = html_reporter.render(report_data)
|
|
346
|
+
else:
|
|
347
|
+
content = md_reporter.render(report_data)
|
|
348
|
+
|
|
349
|
+
ext = {"markdown": "md", "json": "json", "html": "html"}[fmt]
|
|
350
|
+
output_path = output or f".licit/reports/compliance-report.{ext}"
|
|
351
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
352
|
+
Path(output_path).write_text(content, encoding="utf-8")
|
|
353
|
+
|
|
354
|
+
from licit.reports.summary import print_summary # type: ignore[import-not-found]
|
|
355
|
+
|
|
356
|
+
print_summary(report_data)
|
|
357
|
+
|
|
358
|
+
click.echo(f"\n Report saved to: {output_path}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@main.command()
|
|
362
|
+
@click.option(
|
|
363
|
+
"--framework",
|
|
364
|
+
type=click.Choice(["eu-ai-act", "owasp", "all"]),
|
|
365
|
+
default="all",
|
|
366
|
+
)
|
|
367
|
+
@click.pass_context
|
|
368
|
+
def gaps(ctx: click.Context, framework: str) -> None:
|
|
369
|
+
"""Identify compliance gaps with actionable recommendations.
|
|
370
|
+
|
|
371
|
+
Shows what's missing for compliance and suggests specific
|
|
372
|
+
actions and tools to close each gap.
|
|
373
|
+
|
|
374
|
+
Examples:
|
|
375
|
+
licit gaps
|
|
376
|
+
licit gaps --framework eu-ai-act
|
|
377
|
+
"""
|
|
378
|
+
root = str(Path.cwd())
|
|
379
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
380
|
+
detector = ProjectDetector()
|
|
381
|
+
context = detector.detect(root)
|
|
382
|
+
evidence = EvidenceCollector(root, context).collect()
|
|
383
|
+
|
|
384
|
+
from licit.reports.gap_analyzer import GapAnalyzer # type: ignore[import-not-found]
|
|
385
|
+
|
|
386
|
+
analyzer: Any = GapAnalyzer(context, evidence, config)
|
|
387
|
+
frameworks_to_eval = _get_frameworks(framework, config)
|
|
388
|
+
gap_items: list[Any] = analyzer.analyze(frameworks_to_eval)
|
|
389
|
+
|
|
390
|
+
if not gap_items:
|
|
391
|
+
click.echo("\n No compliance gaps found! All requirements met.")
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
click.echo(f"\n {len(gap_items)} compliance gap(s) found:\n")
|
|
395
|
+
for i, gap in enumerate(gap_items, 1):
|
|
396
|
+
icon = "X" if gap.status == ComplianceStatus.NON_COMPLIANT else "!"
|
|
397
|
+
click.echo(f" {i}. [{icon}] [{gap.requirement.id}] {gap.requirement.name}")
|
|
398
|
+
click.echo(f" {gap.gap_description}")
|
|
399
|
+
click.echo(f" -> {gap.recommendation}")
|
|
400
|
+
if gap.tools_suggested:
|
|
401
|
+
click.echo(f" Tools: {', '.join(gap.tools_suggested)}")
|
|
402
|
+
click.echo()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@main.command()
|
|
406
|
+
@click.option(
|
|
407
|
+
"--framework",
|
|
408
|
+
type=click.Choice(["eu-ai-act", "owasp", "all"]),
|
|
409
|
+
default="all",
|
|
410
|
+
)
|
|
411
|
+
@click.pass_context
|
|
412
|
+
def verify(ctx: click.Context, framework: str) -> None:
|
|
413
|
+
"""Verify compliance and return exit code for CI/CD.
|
|
414
|
+
|
|
415
|
+
Exit code 0 if all critical requirements are met.
|
|
416
|
+
Exit code 1 if any critical requirement is non-compliant.
|
|
417
|
+
Exit code 2 if requirements are partially met.
|
|
418
|
+
|
|
419
|
+
Examples:
|
|
420
|
+
licit verify
|
|
421
|
+
licit verify --framework eu-ai-act
|
|
422
|
+
"""
|
|
423
|
+
root = str(Path.cwd())
|
|
424
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
425
|
+
detector = ProjectDetector()
|
|
426
|
+
context = detector.detect(root)
|
|
427
|
+
evidence = EvidenceCollector(root, context).collect()
|
|
428
|
+
|
|
429
|
+
frameworks_to_eval = _get_frameworks(framework, config)
|
|
430
|
+
|
|
431
|
+
all_results: list[ControlResult] = []
|
|
432
|
+
for fw in frameworks_to_eval:
|
|
433
|
+
results: list[ControlResult] = fw.evaluate(context, evidence)
|
|
434
|
+
all_results.extend(results)
|
|
435
|
+
|
|
436
|
+
non_compliant = [r for r in all_results if r.status == ComplianceStatus.NON_COMPLIANT]
|
|
437
|
+
partial = [r for r in all_results if r.status == ComplianceStatus.PARTIAL]
|
|
438
|
+
compliant = [r for r in all_results if r.status == ComplianceStatus.COMPLIANT]
|
|
439
|
+
|
|
440
|
+
click.echo("\n Compliance Verification")
|
|
441
|
+
click.echo(f" Compliant: {len(compliant)}")
|
|
442
|
+
click.echo(f" Partial: {len(partial)}")
|
|
443
|
+
click.echo(f" Non-compliant: {len(non_compliant)}")
|
|
444
|
+
|
|
445
|
+
if non_compliant:
|
|
446
|
+
click.echo("\n Non-compliant controls:")
|
|
447
|
+
for r in non_compliant:
|
|
448
|
+
click.echo(f" [X] {r.requirement.id}: {r.requirement.name}")
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
elif partial:
|
|
451
|
+
sys.exit(2)
|
|
452
|
+
else:
|
|
453
|
+
click.echo("\n All requirements met.")
|
|
454
|
+
sys.exit(0)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@main.command()
|
|
458
|
+
@click.pass_context
|
|
459
|
+
def status(ctx: click.Context) -> None:
|
|
460
|
+
"""Show licit status and connected sources.
|
|
461
|
+
|
|
462
|
+
Examples:
|
|
463
|
+
licit status
|
|
464
|
+
"""
|
|
465
|
+
root = str(Path.cwd())
|
|
466
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
467
|
+
detector = ProjectDetector()
|
|
468
|
+
context = detector.detect(root)
|
|
469
|
+
evidence = EvidenceCollector(root, context).collect()
|
|
470
|
+
|
|
471
|
+
click.echo("\n licit Status")
|
|
472
|
+
click.echo(f" {'─' * 40}")
|
|
473
|
+
click.echo(f" Project: {context.name}")
|
|
474
|
+
config_exists = Path(".licit.yaml").exists()
|
|
475
|
+
click.echo(f" Config: {'.licit.yaml' if config_exists else 'not found'}")
|
|
476
|
+
|
|
477
|
+
click.echo("\n Frameworks:")
|
|
478
|
+
click.echo(f" {'[x]' if config.frameworks.eu_ai_act else '[ ]'} EU AI Act")
|
|
479
|
+
click.echo(
|
|
480
|
+
f" {'[x]' if config.frameworks.owasp_agentic else '[ ]'} OWASP Agentic Top 10"
|
|
481
|
+
)
|
|
482
|
+
click.echo(" [ ] NIST AI RMF (V1)")
|
|
483
|
+
click.echo(" [ ] ISO 42001 (V1)")
|
|
484
|
+
|
|
485
|
+
click.echo("\n Data Sources:")
|
|
486
|
+
click.echo(
|
|
487
|
+
f" {'[x]' if context.git_initialized else '[ ]'} "
|
|
488
|
+
f"Git history ({context.total_commits} commits)"
|
|
489
|
+
)
|
|
490
|
+
click.echo(f" {'[x]' if evidence.has_provenance else '[ ]'} Provenance tracking")
|
|
491
|
+
click.echo(f" {'[x]' if evidence.has_changelog else '[ ]'} Config changelog")
|
|
492
|
+
click.echo(f" {'[x]' if evidence.has_fria else '[ ]'} FRIA document")
|
|
493
|
+
click.echo(f" {'[x]' if evidence.has_annex_iv else '[ ]'} Annex IV documentation")
|
|
494
|
+
|
|
495
|
+
click.echo("\n Connectors:")
|
|
496
|
+
click.echo(
|
|
497
|
+
f" {'[x]' if context.has_architect else '[ ]'} "
|
|
498
|
+
f"architect ({context.architect_config_path or 'not detected'})"
|
|
499
|
+
)
|
|
500
|
+
click.echo(
|
|
501
|
+
f" {'[x]' if context.security.has_vigil else '[ ]'} "
|
|
502
|
+
f"vigil ({context.security.vigil_config_path or 'not detected'})"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
click.echo(f"\n Agent Configs ({len(context.agent_configs)}):")
|
|
506
|
+
for cfg in context.agent_configs:
|
|
507
|
+
click.echo(f" - {cfg.path} ({cfg.agent_type})")
|
|
508
|
+
if not context.agent_configs:
|
|
509
|
+
click.echo(" (none detected)")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@main.command()
|
|
513
|
+
@click.argument("connector", type=click.Choice(["architect", "vigil"]))
|
|
514
|
+
@click.option("--enable/--disable", default=True, help="Enable or disable the connector")
|
|
515
|
+
@click.pass_context
|
|
516
|
+
def connect(ctx: click.Context, connector: str, enable: bool) -> None:
|
|
517
|
+
"""Configure optional connectors (architect, vigil).
|
|
518
|
+
|
|
519
|
+
Examples:
|
|
520
|
+
licit connect architect
|
|
521
|
+
licit connect vigil --enable
|
|
522
|
+
licit connect architect --disable
|
|
523
|
+
"""
|
|
524
|
+
config = load_config(ctx.obj.get("config_path"))
|
|
525
|
+
|
|
526
|
+
if connector == "architect":
|
|
527
|
+
config.connectors.architect.enabled = enable
|
|
528
|
+
elif connector == "vigil":
|
|
529
|
+
config.connectors.vigil.enabled = enable
|
|
530
|
+
|
|
531
|
+
save_config(config, ctx.obj.get("config_path"))
|
|
532
|
+
state = "enabled" if enable else "disabled"
|
|
533
|
+
click.echo(f" Connector '{connector}' {state}.")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _get_frameworks(framework: str, config: LicitConfig) -> list[Any]:
|
|
537
|
+
"""Build list of framework evaluators based on selection and config.
|
|
538
|
+
|
|
539
|
+
Returns evaluators that implement .evaluate(context, evidence) -> list[ControlResult].
|
|
540
|
+
Actual types come from Phase 4+ modules.
|
|
541
|
+
"""
|
|
542
|
+
frameworks: list[Any] = []
|
|
543
|
+
if framework in ("eu-ai-act", "all") and config.frameworks.eu_ai_act:
|
|
544
|
+
from licit.frameworks.eu_ai_act.evaluator import ( # type: ignore[import-not-found]
|
|
545
|
+
EUAIActEvaluator,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
frameworks.append(EUAIActEvaluator())
|
|
549
|
+
if framework in ("owasp", "all") and config.frameworks.owasp_agentic:
|
|
550
|
+
from licit.frameworks.owasp_agentic.evaluator import ( # type: ignore[import-not-found]
|
|
551
|
+
OWASPAgenticEvaluator,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
frameworks.append(OWASPAgenticEvaluator())
|
|
555
|
+
return frameworks
|
licit/config/__init__.py
ADDED
|
File without changes
|
licit/config/defaults.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Default configuration values for licit."""
|
|
2
|
+
|
|
3
|
+
from licit.config.schema import LicitConfig
|
|
4
|
+
|
|
5
|
+
# Canonical default config instance
|
|
6
|
+
DEFAULTS = LicitConfig()
|
|
7
|
+
|
|
8
|
+
# Default config file name
|
|
9
|
+
CONFIG_FILENAME = ".licit.yaml"
|
|
10
|
+
|
|
11
|
+
# Default licit data directory
|
|
12
|
+
DATA_DIR = ".licit"
|
licit/config/loader.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Load and merge configuration from YAML file, defaults, and CLI overrides."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from licit.config.defaults import CONFIG_FILENAME
|
|
9
|
+
from licit.config.schema import LicitConfig
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config(config_path: str | None = None) -> LicitConfig:
|
|
15
|
+
"""Load configuration from YAML file, falling back to defaults.
|
|
16
|
+
|
|
17
|
+
Resolution order:
|
|
18
|
+
1. Explicit path (--config flag)
|
|
19
|
+
2. .licit.yaml in current directory
|
|
20
|
+
3. Default values from schema
|
|
21
|
+
"""
|
|
22
|
+
path = _resolve_config_path(config_path)
|
|
23
|
+
|
|
24
|
+
if path is not None:
|
|
25
|
+
return _load_from_file(path)
|
|
26
|
+
|
|
27
|
+
logger.debug("config_not_found", msg="Using default configuration")
|
|
28
|
+
return LicitConfig()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_config_path(explicit_path: str | None) -> Path | None:
|
|
32
|
+
"""Find the config file to load."""
|
|
33
|
+
if explicit_path:
|
|
34
|
+
p = Path(explicit_path)
|
|
35
|
+
if p.exists():
|
|
36
|
+
return p
|
|
37
|
+
logger.warning("config_path_not_found", path=explicit_path)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
default = Path.cwd() / CONFIG_FILENAME
|
|
41
|
+
if default.exists():
|
|
42
|
+
return default
|
|
43
|
+
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_from_file(path: Path) -> LicitConfig:
|
|
48
|
+
"""Parse YAML config file into LicitConfig."""
|
|
49
|
+
try:
|
|
50
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
51
|
+
except yaml.YAMLError as exc:
|
|
52
|
+
logger.error("config_parse_error", path=str(path), error=str(exc))
|
|
53
|
+
return LicitConfig()
|
|
54
|
+
|
|
55
|
+
if not isinstance(raw, dict):
|
|
56
|
+
logger.warning("config_not_dict", path=str(path))
|
|
57
|
+
return LicitConfig()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
config = LicitConfig.model_validate(raw)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
logger.error("config_validation_error", path=str(path), error=str(exc))
|
|
63
|
+
return LicitConfig()
|
|
64
|
+
|
|
65
|
+
logger.debug("config_loaded", path=str(path))
|
|
66
|
+
return config
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_config(config: LicitConfig, path: str | None = None) -> Path:
|
|
70
|
+
"""Save config to YAML file."""
|
|
71
|
+
target = Path(path) if path else Path.cwd() / CONFIG_FILENAME
|
|
72
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
data = config.model_dump(exclude_defaults=False)
|
|
75
|
+
target.write_text(
|
|
76
|
+
yaml.dump(data, default_flow_style=False, sort_keys=False),
|
|
77
|
+
encoding="utf-8",
|
|
78
|
+
)
|
|
79
|
+
logger.debug("config_saved", path=str(target))
|
|
80
|
+
return target
|