agentsentinel-cli 0.7.6__tar.gz → 0.8.0__tar.gz
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.
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/PKG-INFO +1 -1
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/cli.py +1 -383
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/report.py +0 -8
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/secrets_rules.py +4 -89
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/pyproject.toml +1 -1
- agentsentinel_cli-0.7.6/agentsentinel_cli/agent_mode.py +0 -586
- agentsentinel_cli-0.7.6/agentsentinel_cli/agent_mode_report.py +0 -160
- agentsentinel_cli-0.7.6/agentsentinel_cli/ai_probe.py +0 -300
- agentsentinel_cli-0.7.6/agentsentinel_cli/attacks/__init__.py +0 -5
- agentsentinel_cli-0.7.6/agentsentinel_cli/attacks/library.py +0 -438
- agentsentinel_cli-0.7.6/agentsentinel_cli/probe.py +0 -163
- agentsentinel_cli-0.7.6/agentsentinel_cli/probe_report.py +0 -254
- agentsentinel_cli-0.7.6/agentsentinel_cli/target.py +0 -164
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/.gitignore +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/DOCUMENTATION.md +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/LICENSE +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/README.md +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/__init__.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/a2a_report.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/a2a_rules.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/a2a_scanner.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/discover.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/discover_report.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/fingerprint.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/frameworks.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/inspect.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/inspect_report.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/mcp_client.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/mcp_report.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/mcp_rules.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/rules.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/scanner.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/secrets.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/secrets_report.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/supply_chain_ai.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/supply_chain_report.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/supply_chain_rules.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/suppress.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/note.md +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/test-mcp-agent/README.md +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/test-mcp-agent/langchain_agent.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/test-mcp-agent/mcp_server.py +0 -0
- {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/test-mcp-agent/requirements.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentsentinel-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Agentic security CLI — AI analyst with memory, supply chain audit, MCP audit, red-team probing, and agent discovery
|
|
5
5
|
Project-URL: Homepage, https://github.com/jaydenaung/agentsentinel-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/jaydenaung/agentsentinel-cli
|
|
@@ -31,18 +31,12 @@ def main() -> None:
|
|
|
31
31
|
help="Output format.")
|
|
32
32
|
@click.option("--fail-on", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]),
|
|
33
33
|
default=None, help="Exit with code 1 if findings at or above this severity exist.")
|
|
34
|
-
@click.option("--connect", metavar="URL", default=None,
|
|
35
|
-
help="AgentSentinel API URL for live behavior data (e.g. http://localhost:9000).")
|
|
36
|
-
@click.option("--api-key", envvar="AGENTSENTINEL_API_KEY", default=None,
|
|
37
|
-
help="API key for --connect. Defaults to $AGENTSENTINEL_API_KEY.")
|
|
38
34
|
@click.option("--ignore-rule", "ignore_rules", multiple=True, metavar="RULE_ID",
|
|
39
35
|
help="Suppress a finding by rule ID. Repeatable. Also reads .sentinelignore.")
|
|
40
36
|
def scan(
|
|
41
37
|
target: Path,
|
|
42
38
|
fmt: str,
|
|
43
39
|
fail_on: str | None,
|
|
44
|
-
connect: str | None,
|
|
45
|
-
api_key: str | None,
|
|
46
40
|
ignore_rules: tuple[str, ...],
|
|
47
41
|
) -> None:
|
|
48
42
|
"""Scan a Python file or directory for AI agent security issues.
|
|
@@ -55,7 +49,6 @@ def scan(
|
|
|
55
49
|
sentinel scan ./agents/
|
|
56
50
|
sentinel scan my_agent.py --fail-on CRITICAL
|
|
57
51
|
sentinel scan my_agent.py --format json
|
|
58
|
-
sentinel scan my_agent.py --connect http://localhost:9000
|
|
59
52
|
"""
|
|
60
53
|
from agentsentinel_cli import suppress as _suppress
|
|
61
54
|
|
|
@@ -64,7 +57,6 @@ def scan(
|
|
|
64
57
|
findings_map = {a.file: run_rules(a) for a in agents}
|
|
65
58
|
scores_map = {a.file: posture_score(findings_map[a.file]) for a in agents}
|
|
66
59
|
|
|
67
|
-
# Apply suppressions before display and --fail-on evaluation
|
|
68
60
|
sup_rules = _suppress.merge(_suppress.load_ignore_file(target), ignore_rules)
|
|
69
61
|
all_suppressed: list = []
|
|
70
62
|
if sup_rules:
|
|
@@ -76,13 +68,10 @@ def scan(
|
|
|
76
68
|
findings_map = cleaned
|
|
77
69
|
scores_map = {a.file: posture_score(findings_map[a.file]) for a in agents}
|
|
78
70
|
|
|
79
|
-
if connect and api_key and agents:
|
|
80
|
-
_enrich_from_platform(agents, scores_map, connect, api_key)
|
|
81
|
-
|
|
82
71
|
if fmt == "json":
|
|
83
72
|
click.echo(as_json(agents, findings_map, scores_map))
|
|
84
73
|
else:
|
|
85
|
-
print_scan_result(agents, findings_map, scores_map, target
|
|
74
|
+
print_scan_result(agents, findings_map, scores_map, target)
|
|
86
75
|
msg = _suppress.notice(all_suppressed)
|
|
87
76
|
if msg:
|
|
88
77
|
console.print(f" {msg}\n")
|
|
@@ -99,26 +88,6 @@ def scan(
|
|
|
99
88
|
sys.exit(1)
|
|
100
89
|
|
|
101
90
|
|
|
102
|
-
def _enrich_from_platform(agents, scores_map, connect_url, api_key):
|
|
103
|
-
try:
|
|
104
|
-
import httpx
|
|
105
|
-
headers = {"X-API-Key": api_key}
|
|
106
|
-
base = connect_url.rstrip("/")
|
|
107
|
-
with httpx.Client(timeout=5.0) as client:
|
|
108
|
-
resp = client.get(f"{base}/api/v1/agents", headers=headers)
|
|
109
|
-
resp.raise_for_status()
|
|
110
|
-
platform_agents = {a["name"]: a for a in resp.json()}
|
|
111
|
-
for agent in agents:
|
|
112
|
-
candidate_name = agent.file.stem.replace("_", "-")
|
|
113
|
-
for name, data in platform_agents.items():
|
|
114
|
-
if candidate_name in name or name in candidate_name:
|
|
115
|
-
platform_score = data.get("trust_score", scores_map[agent.file])
|
|
116
|
-
scores_map[agent.file] = int(platform_score)
|
|
117
|
-
break
|
|
118
|
-
except Exception as exc:
|
|
119
|
-
console.print(f" [dim yellow]Warning: could not connect to AgentSentinel: {exc}[/dim yellow]")
|
|
120
|
-
|
|
121
|
-
|
|
122
91
|
# ── sentinel discover helpers ────────────────────────────────────────────────
|
|
123
92
|
|
|
124
93
|
def _deep_scan_agents(agents: list, extra_headers: dict | None) -> None:
|
|
@@ -424,225 +393,6 @@ def mcp_scan(
|
|
|
424
393
|
sys.exit(1)
|
|
425
394
|
|
|
426
395
|
|
|
427
|
-
# ── sentinel probe ────────────────────────────────────────────────────────────
|
|
428
|
-
|
|
429
|
-
@main.command()
|
|
430
|
-
@click.argument("target_url")
|
|
431
|
-
@click.option("--input-field", "input_field", default=None, metavar="FIELD",
|
|
432
|
-
help="JSON field name for the message (auto-detected if omitted).")
|
|
433
|
-
@click.option("--output-field", "output_field", default=None, metavar="FIELD",
|
|
434
|
-
help="JSON field name for the response (auto-detected if omitted).")
|
|
435
|
-
@click.option("--auth-header", "auth_header", default=None, metavar="HEADER",
|
|
436
|
-
help="HTTP auth header, e.g. 'Authorization: Bearer token'.")
|
|
437
|
-
@click.option("--attacks", "attack_cats", default=None, metavar="CATS",
|
|
438
|
-
help="Comma-separated categories: injection,jailbreak,extraction,encoding,context. Default: all.")
|
|
439
|
-
@click.option("--timeout", default=15.0, show_default=True, metavar="SECONDS",
|
|
440
|
-
help="Per-probe timeout in seconds.")
|
|
441
|
-
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text")
|
|
442
|
-
@click.option("--fail-on", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]),
|
|
443
|
-
default=None, help="Exit 1 if any finding at or above this severity.")
|
|
444
|
-
def probe(
|
|
445
|
-
target_url: str,
|
|
446
|
-
input_field: str | None,
|
|
447
|
-
output_field: str | None,
|
|
448
|
-
auth_header: str | None,
|
|
449
|
-
attack_cats: str | None,
|
|
450
|
-
timeout: float,
|
|
451
|
-
fmt: str,
|
|
452
|
-
fail_on: str | None,
|
|
453
|
-
) -> None:
|
|
454
|
-
"""Run a static attack battery against a live agent endpoint.
|
|
455
|
-
|
|
456
|
-
Sends 50 adversarial payloads across 5 categories and detects success via
|
|
457
|
-
response pattern matching. No API key required.
|
|
458
|
-
|
|
459
|
-
\b
|
|
460
|
-
Examples:
|
|
461
|
-
sentinel probe http://my-agent.com/chat
|
|
462
|
-
sentinel probe http://my-agent.com/chat --attacks injection,jailbreak
|
|
463
|
-
sentinel probe http://my-agent.com/chat --input-field message --output-field response
|
|
464
|
-
sentinel probe http://my-agent.com/chat --auth-header "Authorization: Bearer token"
|
|
465
|
-
sentinel probe http://my-agent.com/chat --format json --fail-on HIGH
|
|
466
|
-
"""
|
|
467
|
-
from agentsentinel_cli.target import TargetConfig, TargetError
|
|
468
|
-
from agentsentinel_cli.probe import run_probe
|
|
469
|
-
from agentsentinel_cli.probe_report import print_probe_result, as_probe_json
|
|
470
|
-
|
|
471
|
-
_VALID_CATEGORIES = {"injection", "jailbreak", "extraction", "encoding", "context", "scope"}
|
|
472
|
-
categories: list[str] | None = None
|
|
473
|
-
if attack_cats:
|
|
474
|
-
requested = [c.strip() for c in attack_cats.split(",") if c.strip()]
|
|
475
|
-
invalid = [c for c in requested if c not in _VALID_CATEGORIES]
|
|
476
|
-
if invalid:
|
|
477
|
-
console.print(f"[yellow]Warning: unknown attack categories ignored: {', '.join(invalid)}[/yellow]")
|
|
478
|
-
console.print(f" Valid categories: {', '.join(sorted(_VALID_CATEGORIES))}")
|
|
479
|
-
categories = [c for c in requested if c in _VALID_CATEGORIES] or None
|
|
480
|
-
|
|
481
|
-
config = TargetConfig(
|
|
482
|
-
url=target_url,
|
|
483
|
-
input_field=input_field,
|
|
484
|
-
output_field=output_field,
|
|
485
|
-
auth_header=auth_header,
|
|
486
|
-
timeout=timeout,
|
|
487
|
-
)
|
|
488
|
-
|
|
489
|
-
total_attacks = len(__import__("agentsentinel_cli.attacks", fromlist=["get_attacks"]).get_attacks(categories))
|
|
490
|
-
_counter: list[int] = [0]
|
|
491
|
-
|
|
492
|
-
def _progress(current: int, total: int, attack_id: str, name: str) -> None:
|
|
493
|
-
_counter[0] = current
|
|
494
|
-
if fmt == "text":
|
|
495
|
-
console.print(
|
|
496
|
-
f" [dim][{current:>2}/{total}][/dim] "
|
|
497
|
-
f"[dim cyan]{attack_id}[/dim cyan] {name[:50]}",
|
|
498
|
-
end="\r",
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
if fmt == "text":
|
|
502
|
-
console.print()
|
|
503
|
-
console.print(
|
|
504
|
-
f" Running [bold white]{total_attacks}[/bold white] probes against "
|
|
505
|
-
f"[bold white]{target_url}[/bold white] …\n"
|
|
506
|
-
)
|
|
507
|
-
|
|
508
|
-
try:
|
|
509
|
-
report = run_probe(config, categories=categories, progress_cb=_progress)
|
|
510
|
-
except TargetError as exc:
|
|
511
|
-
console.print(f"\n[red]Target error:[/red] {exc}")
|
|
512
|
-
sys.exit(1)
|
|
513
|
-
except Exception as exc:
|
|
514
|
-
console.print(f"\n[red]Unexpected error:[/red] {exc}")
|
|
515
|
-
sys.exit(1)
|
|
516
|
-
|
|
517
|
-
if fmt == "text":
|
|
518
|
-
console.print() # clear progress line
|
|
519
|
-
print_probe_result(report)
|
|
520
|
-
else:
|
|
521
|
-
click.echo(as_probe_json(report))
|
|
522
|
-
|
|
523
|
-
if fail_on:
|
|
524
|
-
_rank = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
525
|
-
threshold = _rank.get(fail_on, 0)
|
|
526
|
-
if any(_rank.get(r.severity, 0) >= threshold for r in report.findings):
|
|
527
|
-
sys.exit(1)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
# ── sentinel ai-probe ─────────────────────────────────────────────────────────
|
|
531
|
-
|
|
532
|
-
@main.command(name="ai-probe")
|
|
533
|
-
@click.argument("target_url")
|
|
534
|
-
@click.option("--input-field", "input_field", default=None, metavar="FIELD",
|
|
535
|
-
help="JSON field name for the message (auto-detected if omitted).")
|
|
536
|
-
@click.option("--output-field", "output_field", default=None, metavar="FIELD",
|
|
537
|
-
help="JSON field name for the response (auto-detected if omitted).")
|
|
538
|
-
@click.option("--auth-header", "auth_header", default=None, metavar="HEADER",
|
|
539
|
-
help="HTTP auth header, e.g. 'Authorization: Bearer token'.")
|
|
540
|
-
@click.option("--context", "ctx", default="", metavar="TEXT",
|
|
541
|
-
help="Optional context about the agent, e.g. 'customer service bot for a bank'.")
|
|
542
|
-
@click.option("--max-probes", default=20, show_default=True,
|
|
543
|
-
help="Maximum number of probes Claude can send.")
|
|
544
|
-
@click.option("--model", default="claude-opus-4-8", show_default=True,
|
|
545
|
-
help="Claude model to use as the probe agent.")
|
|
546
|
-
@click.option("--timeout", default=15.0, show_default=True, metavar="SECONDS",
|
|
547
|
-
help="Per-probe timeout in seconds.")
|
|
548
|
-
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text")
|
|
549
|
-
@click.option("--fail-on", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]),
|
|
550
|
-
default=None, help="Exit 1 if any finding at or above this severity.")
|
|
551
|
-
def ai_probe(
|
|
552
|
-
target_url: str,
|
|
553
|
-
input_field: str | None,
|
|
554
|
-
output_field: str | None,
|
|
555
|
-
auth_header: str | None,
|
|
556
|
-
ctx: str,
|
|
557
|
-
max_probes: int,
|
|
558
|
-
model: str,
|
|
559
|
-
timeout: float,
|
|
560
|
-
fmt: str,
|
|
561
|
-
fail_on: str | None,
|
|
562
|
-
) -> None:
|
|
563
|
-
"""Run Claude as an autonomous red-team agent against a live endpoint.
|
|
564
|
-
|
|
565
|
-
Claude decides what to test, interprets responses intelligently, escalates
|
|
566
|
-
on partial success, and records findings with evidence. Requires ANTHROPIC_API_KEY.
|
|
567
|
-
|
|
568
|
-
\b
|
|
569
|
-
Examples:
|
|
570
|
-
sentinel ai-probe http://my-agent.com/chat
|
|
571
|
-
sentinel ai-probe http://my-agent.com/chat --context "customer service bot for a bank"
|
|
572
|
-
sentinel ai-probe http://my-agent.com/chat --max-probes 30
|
|
573
|
-
sentinel ai-probe http://my-agent.com/chat --format json --fail-on CRITICAL
|
|
574
|
-
"""
|
|
575
|
-
import os
|
|
576
|
-
from agentsentinel_cli.target import TargetConfig, TargetError
|
|
577
|
-
from agentsentinel_cli.ai_probe import run_ai_probe, DEFAULT_MODEL
|
|
578
|
-
from agentsentinel_cli.probe_report import print_ai_probe_result, as_ai_probe_json
|
|
579
|
-
|
|
580
|
-
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
581
|
-
if not api_key:
|
|
582
|
-
console.print("[red]Error:[/red] ANTHROPIC_API_KEY environment variable is not set.")
|
|
583
|
-
console.print(" Export it with: [bold]export ANTHROPIC_API_KEY=sk-ant-...[/bold]")
|
|
584
|
-
sys.exit(1)
|
|
585
|
-
|
|
586
|
-
config = TargetConfig(
|
|
587
|
-
url=target_url,
|
|
588
|
-
input_field=input_field,
|
|
589
|
-
output_field=output_field,
|
|
590
|
-
auth_header=auth_header,
|
|
591
|
-
timeout=timeout,
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
if fmt == "text":
|
|
595
|
-
console.print()
|
|
596
|
-
console.print(Panel.fit(
|
|
597
|
-
f"[bold white]AgentSentinel AI Probe[/bold white] [dim](Claude {model})[/dim]\n"
|
|
598
|
-
f"[dim]Target: {target_url}[/dim]",
|
|
599
|
-
border_style="bright_blue",
|
|
600
|
-
padding=(0, 2),
|
|
601
|
-
))
|
|
602
|
-
console.print(
|
|
603
|
-
f"\n Probe agent initialised. Budget: [bold white]{max_probes}[/bold white] probes.\n"
|
|
604
|
-
)
|
|
605
|
-
|
|
606
|
-
def _progress(probe_num: int, total: int, category: str, rationale: str) -> None:
|
|
607
|
-
if fmt == "text":
|
|
608
|
-
console.print(
|
|
609
|
-
f" [dim][{probe_num:>2}/{total}][/dim] "
|
|
610
|
-
f"[dim cyan]{category:<12}[/dim cyan] "
|
|
611
|
-
f"[dim]{rationale[:60]}[/dim]"
|
|
612
|
-
)
|
|
613
|
-
|
|
614
|
-
try:
|
|
615
|
-
report = run_ai_probe(
|
|
616
|
-
config,
|
|
617
|
-
api_key=api_key,
|
|
618
|
-
max_probes=max_probes,
|
|
619
|
-
context=ctx,
|
|
620
|
-
model=model,
|
|
621
|
-
progress_cb=_progress,
|
|
622
|
-
)
|
|
623
|
-
except ImportError as exc:
|
|
624
|
-
console.print(f"\n[red]Missing dependency:[/red] {exc}")
|
|
625
|
-
sys.exit(1)
|
|
626
|
-
except TargetError as exc:
|
|
627
|
-
console.print(f"\n[red]Target error:[/red] {exc}")
|
|
628
|
-
sys.exit(1)
|
|
629
|
-
except Exception as exc:
|
|
630
|
-
console.print(f"\n[red]Unexpected error:[/red] {exc}")
|
|
631
|
-
sys.exit(1)
|
|
632
|
-
|
|
633
|
-
if fmt == "text":
|
|
634
|
-
console.print()
|
|
635
|
-
print_ai_probe_result(report)
|
|
636
|
-
else:
|
|
637
|
-
click.echo(as_ai_probe_json(report))
|
|
638
|
-
|
|
639
|
-
if fail_on:
|
|
640
|
-
_rank = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
641
|
-
threshold = _rank.get(fail_on, 0)
|
|
642
|
-
if any(_rank.get(f.severity, 0) >= threshold for f in report.findings):
|
|
643
|
-
sys.exit(1)
|
|
644
|
-
|
|
645
|
-
|
|
646
396
|
# ── sentinel inspect ──────────────────────────────────────────────────────────
|
|
647
397
|
|
|
648
398
|
@main.command()
|
|
@@ -860,138 +610,6 @@ def secrets(
|
|
|
860
610
|
sys.exit(1)
|
|
861
611
|
|
|
862
612
|
|
|
863
|
-
# ── sentinel agentic ──────────────────────────────────────────────────────────
|
|
864
|
-
|
|
865
|
-
@main.command()
|
|
866
|
-
@click.argument("target", default=None, required=False)
|
|
867
|
-
@click.option("--stdio", "stdio_cmd", default=None, metavar="CMD",
|
|
868
|
-
help="Audit a stdio-transport MCP server, e.g. 'python server.py'.")
|
|
869
|
-
@click.option("--context", "ctx", default="", metavar="TEXT",
|
|
870
|
-
help="Optional context about the target, e.g. 'production MCP server for a fintech app'.")
|
|
871
|
-
@click.option("--model", default="claude-sonnet-4-6", show_default=True,
|
|
872
|
-
help="Claude model to use as the analyst.")
|
|
873
|
-
@click.option("--memory-dir", "memory_dir", default=None, type=click.Path(),
|
|
874
|
-
help="Directory for persistent memory files. Defaults to ~/.sentinel/memory/.")
|
|
875
|
-
@click.option("--max-calls", "max_calls", default=30, show_default=True,
|
|
876
|
-
help="Maximum tool calls the analyst can make.")
|
|
877
|
-
@click.option("--timeout", default=10.0, show_default=True, metavar="SECONDS",
|
|
878
|
-
help="MCP connection timeout in seconds.")
|
|
879
|
-
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text",
|
|
880
|
-
help="Output format.")
|
|
881
|
-
@click.option("--fail-on", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]),
|
|
882
|
-
default=None, help="Exit with code 1 if findings at or above this severity exist.")
|
|
883
|
-
def agentic(
|
|
884
|
-
target: str | None,
|
|
885
|
-
stdio_cmd: str | None,
|
|
886
|
-
ctx: str,
|
|
887
|
-
model: str,
|
|
888
|
-
memory_dir: str | None,
|
|
889
|
-
max_calls: int,
|
|
890
|
-
timeout: float,
|
|
891
|
-
fmt: str,
|
|
892
|
-
fail_on: str | None,
|
|
893
|
-
) -> None:
|
|
894
|
-
"""Run Claude as an agentic security analyst with persistent memory.
|
|
895
|
-
|
|
896
|
-
Claude decides what to scan, calls sentinel's capabilities as tools,
|
|
897
|
-
compares current state to prior assessments, and produces a threat
|
|
898
|
-
narrative. Goes beyond static rules — catches semantic deception,
|
|
899
|
-
cross-finding patterns, and drift over time.
|
|
900
|
-
|
|
901
|
-
TARGET can be an MCP server URL, a local file/directory path, or omitted
|
|
902
|
-
if using --stdio. Requires ANTHROPIC_API_KEY.
|
|
903
|
-
|
|
904
|
-
\b
|
|
905
|
-
Examples:
|
|
906
|
-
sentinel agentic http://localhost:3001
|
|
907
|
-
sentinel agentic --stdio "python my_server.py"
|
|
908
|
-
sentinel agentic ./my-agent/
|
|
909
|
-
sentinel agentic http://localhost:3001 --context "production fintech MCP server"
|
|
910
|
-
sentinel agentic ./agents/ --model claude-opus-4-8
|
|
911
|
-
sentinel agentic http://localhost:3001 --format json --fail-on HIGH
|
|
912
|
-
"""
|
|
913
|
-
import os
|
|
914
|
-
from pathlib import Path as _Path
|
|
915
|
-
from agentsentinel_cli.agent_mode import run_agent_mode, DEFAULT_MEMORY_DIR
|
|
916
|
-
from agentsentinel_cli.agent_mode_report import print_agent_report, as_agent_json
|
|
917
|
-
|
|
918
|
-
# Resolve target — stdio_cmd takes precedence as the canonical target id
|
|
919
|
-
if stdio_cmd:
|
|
920
|
-
target = stdio_cmd
|
|
921
|
-
elif not target:
|
|
922
|
-
console.print("[red]Error:[/red] provide a TARGET (URL or path) or --stdio CMD.")
|
|
923
|
-
sys.exit(1)
|
|
924
|
-
|
|
925
|
-
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
926
|
-
if not api_key:
|
|
927
|
-
console.print("[red]Error:[/red] ANTHROPIC_API_KEY is required for agentic mode.")
|
|
928
|
-
console.print(" Export it with: [bold]export ANTHROPIC_API_KEY=sk-ant-...[/bold]")
|
|
929
|
-
sys.exit(1)
|
|
930
|
-
|
|
931
|
-
mem_dir = _Path(memory_dir) if memory_dir else DEFAULT_MEMORY_DIR
|
|
932
|
-
|
|
933
|
-
if fmt == "text":
|
|
934
|
-
console.print()
|
|
935
|
-
console.print(Panel.fit(
|
|
936
|
-
f"[bold white]AgentSentinel Agentic Analysis[/bold white] "
|
|
937
|
-
f"[dim cyan]{model}[/dim cyan]\n"
|
|
938
|
-
f"[dim]Target: {target}[/dim]",
|
|
939
|
-
border_style="bright_blue",
|
|
940
|
-
padding=(0, 2),
|
|
941
|
-
))
|
|
942
|
-
console.print(
|
|
943
|
-
f"\n Analyst initialised. Memory dir: [dim]{mem_dir}[/dim]\n"
|
|
944
|
-
)
|
|
945
|
-
|
|
946
|
-
def _progress(tool_name: str, detail: str) -> None:
|
|
947
|
-
if fmt == "text":
|
|
948
|
-
icons = {
|
|
949
|
-
"read_memory": "🧠",
|
|
950
|
-
"scan_mcp_server":"🔍",
|
|
951
|
-
"scan_files": "📂",
|
|
952
|
-
"check_secrets": "🔑",
|
|
953
|
-
"record_finding": "📋",
|
|
954
|
-
"update_memory": "💾",
|
|
955
|
-
"finish_analysis":"✅",
|
|
956
|
-
}
|
|
957
|
-
icon = icons.get(tool_name, "·")
|
|
958
|
-
short_detail = detail[:60] if len(detail) > 60 else detail
|
|
959
|
-
console.print(
|
|
960
|
-
f" {icon} [dim cyan]{tool_name:<18}[/dim cyan] [dim]{short_detail}[/dim]"
|
|
961
|
-
)
|
|
962
|
-
|
|
963
|
-
try:
|
|
964
|
-
report = run_agent_mode(
|
|
965
|
-
target=target,
|
|
966
|
-
api_key=api_key,
|
|
967
|
-
model=model,
|
|
968
|
-
memory_dir=mem_dir,
|
|
969
|
-
context=ctx,
|
|
970
|
-
max_calls=max_calls,
|
|
971
|
-
timeout=timeout,
|
|
972
|
-
stdio_cmd=stdio_cmd,
|
|
973
|
-
progress_cb=_progress,
|
|
974
|
-
)
|
|
975
|
-
except ImportError as exc:
|
|
976
|
-
console.print(f"\n[red]Missing dependency:[/red] {exc}")
|
|
977
|
-
sys.exit(1)
|
|
978
|
-
except Exception as exc:
|
|
979
|
-
console.print(f"\n[red]Unexpected error:[/red] {exc}")
|
|
980
|
-
sys.exit(1)
|
|
981
|
-
|
|
982
|
-
if fmt == "text":
|
|
983
|
-
console.print()
|
|
984
|
-
print_agent_report(report)
|
|
985
|
-
else:
|
|
986
|
-
click.echo(as_agent_json(report))
|
|
987
|
-
|
|
988
|
-
if fail_on:
|
|
989
|
-
_rank = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
990
|
-
threshold = _rank.get(fail_on, 0)
|
|
991
|
-
if any(_rank.get(f.severity, 0) >= threshold for f in report.findings):
|
|
992
|
-
sys.exit(1)
|
|
993
|
-
|
|
994
|
-
|
|
995
613
|
# ── sentinel supply-chain ─────────────────────────────────────────────────────
|
|
996
614
|
|
|
997
615
|
@main.command(name="supply-chain")
|
|
@@ -51,7 +51,6 @@ def print_scan_result(
|
|
|
51
51
|
findings_map: dict[Path, list[Finding]],
|
|
52
52
|
scores_map: dict[Path, int],
|
|
53
53
|
target: Path,
|
|
54
|
-
connect_url: str | None = None,
|
|
55
54
|
) -> None:
|
|
56
55
|
total_findings = sum(len(f) for f in findings_map.values())
|
|
57
56
|
total_critical = sum(1 for fl in findings_map.values() for f in fl if f.severity == "CRITICAL")
|
|
@@ -143,13 +142,6 @@ def print_scan_result(
|
|
|
143
142
|
summary_parts.append(f"[bold orange1]{total_high} HIGH[/bold orange1]")
|
|
144
143
|
console.print(" " + " · ".join(summary_parts))
|
|
145
144
|
|
|
146
|
-
if connect_url:
|
|
147
|
-
console.print(f"\n [dim]Connected to AgentSentinel at {connect_url} — open dashboard for live behavior data.[/dim]")
|
|
148
|
-
else:
|
|
149
|
-
console.print(
|
|
150
|
-
"\n [dim]This is a static scan. Run with [bold]--connect[/bold] to include live behavior "
|
|
151
|
-
"monitoring data from a running AgentSentinel instance.[/dim]"
|
|
152
|
-
)
|
|
153
145
|
console.print()
|
|
154
146
|
|
|
155
147
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Layer 1 — Credentials: API keys, tokens, private keys, database connection strings
|
|
4
4
|
Layer 2 — PII (global): email, credit card, US SSN, US phone
|
|
5
|
-
Layer 2 — PII (Singapore): NRIC/FIN, passport, mobile, landline, UEN, postal code
|
|
6
5
|
Layer 3 — Memory contamination: PII clusters, tool result leakage, system prompt leakage
|
|
7
6
|
|
|
8
7
|
Each rule that has a validator function is marked validated=True on a match;
|
|
@@ -42,35 +41,7 @@ _MEM_ONLY: frozenset[str] = frozenset({"memory"})
|
|
|
42
41
|
_CFG_ONLY: frozenset[str] = frozenset({"config"})
|
|
43
42
|
|
|
44
43
|
|
|
45
|
-
# ──
|
|
46
|
-
|
|
47
|
-
_NRIC_WEIGHTS = (2, 7, 6, 5, 4, 3, 2)
|
|
48
|
-
_NRIC_OFFSET: dict[str, int] = {"S": 0, "T": 4, "F": 0, "G": 4, "M": 3}
|
|
49
|
-
_NRIC_CHECK: dict[str, str] = {
|
|
50
|
-
"S": "JZIHGFEDCBA", "T": "JZIHGFEDCBA",
|
|
51
|
-
"F": "XWUTRQPNMLK", "G": "XWUTRQPNMLK",
|
|
52
|
-
"M": "XWUTRQPNMLKJ",
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _validate_nric(value: str) -> bool:
|
|
57
|
-
"""Return True if value passes the Singapore NRIC/FIN weighted-sum checksum.
|
|
58
|
-
|
|
59
|
-
Implements the official algorithm: weighted digit sum + prefix offset, mod 11,
|
|
60
|
-
lookup in prefix-specific check-letter table. Eliminates ~99% of false positives.
|
|
61
|
-
"""
|
|
62
|
-
v = value.upper()
|
|
63
|
-
if len(v) != 9:
|
|
64
|
-
return False
|
|
65
|
-
prefix, digits_str, check = v[0], v[1:8], v[8]
|
|
66
|
-
if prefix not in _NRIC_CHECK or not digits_str.isdigit():
|
|
67
|
-
return False
|
|
68
|
-
total = sum(int(d) * w for d, w in zip(digits_str, _NRIC_WEIGHTS))
|
|
69
|
-
total += _NRIC_OFFSET[prefix]
|
|
70
|
-
return _NRIC_CHECK[prefix][total % 11] == check
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
# ── Global validators ─────────────────────────────────────────────────────────
|
|
44
|
+
# ── Validators ───────────────────────────────────────────────────────────────
|
|
74
45
|
|
|
75
46
|
def _luhn_check(number: str) -> bool:
|
|
76
47
|
"""Return True if number passes the Luhn checksum (eliminates ~98% of CC false positives)."""
|
|
@@ -195,40 +166,6 @@ _PII_RULES: list[_PiiRule] = [
|
|
|
195
166
|
re.compile(r"\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b"),
|
|
196
167
|
_MEM_ONLY,
|
|
197
168
|
"US phone number in agent memory file. Verify this is not customer PII."),
|
|
198
|
-
# Singapore — NRIC/FIN: checksum-validated, HIGH severity, scan all file types
|
|
199
|
-
_PiiRule("SG_NRIC", "HIGH", "SGP",
|
|
200
|
-
re.compile(r"\b[STFGMstfgm]\d{7}[A-Za-z]\b"),
|
|
201
|
-
_ALL,
|
|
202
|
-
"NRIC/FIN is protected under Singapore PDPA. "
|
|
203
|
-
"Purge from memory and audit which tool call produced this data.",
|
|
204
|
-
validator=_validate_nric),
|
|
205
|
-
# Singapore — mobile (8xxx/9xxx): require +65 or standalone with word boundary
|
|
206
|
-
_PiiRule("SG_PHONE_MOBILE", "MEDIUM", "SGP",
|
|
207
|
-
re.compile(r"\b(?:\+65[-.\s]?)?[89]\d{3}[-.\s]?\d{4}\b"),
|
|
208
|
-
_MEM_CFG,
|
|
209
|
-
"Singapore mobile number in agent memory. Verify this is not customer PII."),
|
|
210
|
-
# Singapore — landline (3xxx/6xxx): require explicit +65 to reduce FPs
|
|
211
|
-
_PiiRule("SG_PHONE_LANDLINE", "LOW", "SGP",
|
|
212
|
-
re.compile(r"\+65[-.\s]?[36]\d{3}[-.\s]?\d{4}\b"),
|
|
213
|
-
_MEM_ONLY,
|
|
214
|
-
"Singapore landline number in agent memory file."),
|
|
215
|
-
# Singapore — passport (E/K prefix, same structure as NRIC; no checksum applied)
|
|
216
|
-
_PiiRule("SG_PASSPORT", "HIGH", "SGP",
|
|
217
|
-
re.compile(r"\b[EKek]\d{7}[A-Za-z]\b"),
|
|
218
|
-
_ALL,
|
|
219
|
-
"Singapore passport number is protected under Singapore PDPA. "
|
|
220
|
-
"Purge from memory files and audit tool call history. "
|
|
221
|
-
"Verify manually — regex match only, no checksum applied."),
|
|
222
|
-
# Singapore — UEN (business entity, lower sensitivity)
|
|
223
|
-
_PiiRule("SG_UEN", "LOW", "SGP",
|
|
224
|
-
re.compile(r"\b(?:\d{9}[A-Z]|[A-Z]\d{8}[A-Z])\b"),
|
|
225
|
-
_MEM_ONLY,
|
|
226
|
-
"Singapore UEN (business registration number) in agent memory file."),
|
|
227
|
-
# Singapore — postal code (require "Singapore" label to avoid bare 6-digit FPs)
|
|
228
|
-
_PiiRule("SG_ADDRESS_POSTAL", "LOW", "SGP",
|
|
229
|
-
re.compile(r"[Ss]ingapore\s+\d{6}\b"),
|
|
230
|
-
_MEM_ONLY,
|
|
231
|
-
"Singapore postal address in agent memory file. Verify this is not customer PII."),
|
|
232
169
|
]
|
|
233
170
|
|
|
234
171
|
|
|
@@ -242,15 +179,14 @@ _SYSTEM_PROMPT_PATS: list[re.Pattern] = [
|
|
|
242
179
|
]
|
|
243
180
|
|
|
244
181
|
_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
|
|
245
|
-
_NRIC_RE = re.compile(r"\b[STFGMstfgm]\d{7}[A-Za-z]\b")
|
|
246
182
|
_SSN_RE = re.compile(r"\b\d{3}-\d{2}-\d{4}\b")
|
|
247
183
|
|
|
248
184
|
|
|
249
185
|
def check_memory_contamination(lines: list[str], file: Path) -> list[SecretFinding]:
|
|
250
186
|
"""Compound rules that analyse memory file content for contamination patterns.
|
|
251
187
|
|
|
252
|
-
Checks for system prompt leakage (first 30 lines) and PII clusters (email +
|
|
253
|
-
|
|
188
|
+
Checks for system prompt leakage (first 30 lines) and PII clusters (email + SSN
|
|
189
|
+
within 5 lines of each other — strong indicator of a leaked tool call result).
|
|
254
190
|
"""
|
|
255
191
|
findings: list[SecretFinding] = []
|
|
256
192
|
|
|
@@ -278,35 +214,14 @@ def check_memory_contamination(lines: list[str], file: Path) -> list[SecretFindi
|
|
|
278
214
|
continue
|
|
279
215
|
break # one finding per file
|
|
280
216
|
|
|
281
|
-
# CONVERSATION_PII — email +
|
|
217
|
+
# CONVERSATION_PII — email + SSN within 5 lines of each other
|
|
282
218
|
email_lines = [i for i, ln in enumerate(lines) if _EMAIL_RE.search(ln)]
|
|
283
|
-
nric_lines = [i for i, ln in enumerate(lines)
|
|
284
|
-
if (m := _NRIC_RE.search(ln)) and _validate_nric(m.group())]
|
|
285
219
|
ssn_lines = [i for i, ln in enumerate(lines)
|
|
286
220
|
if (m := _SSN_RE.search(ln)) and _valid_ssn(m.group())]
|
|
287
221
|
|
|
288
222
|
seen_clusters: set[int] = set()
|
|
289
223
|
|
|
290
224
|
for ei in email_lines:
|
|
291
|
-
for ni in nric_lines:
|
|
292
|
-
anchor = min(ei, ni)
|
|
293
|
-
if abs(ei - ni) <= 5 and anchor not in seen_clusters:
|
|
294
|
-
seen_clusters.add(anchor)
|
|
295
|
-
findings.append(SecretFinding(
|
|
296
|
-
rule_id="CONVERSATION_PII",
|
|
297
|
-
severity="HIGH",
|
|
298
|
-
category="memory_contamination",
|
|
299
|
-
jurisdiction="SGP",
|
|
300
|
-
file=file, line=anchor + 1,
|
|
301
|
-
match_preview="[email + NRIC cluster]",
|
|
302
|
-
context_line=f"Email line {ei + 1}, NRIC line {ni + 1}",
|
|
303
|
-
recommendation=(
|
|
304
|
-
"Singapore customer PII cluster — email + NRIC on adjacent lines. "
|
|
305
|
-
"Likely a raw tool call result (CRM/database). "
|
|
306
|
-
"Purge memory file and audit tool call history. Protected under PDPA."
|
|
307
|
-
),
|
|
308
|
-
validated=True,
|
|
309
|
-
))
|
|
310
225
|
for si in ssn_lines:
|
|
311
226
|
anchor = min(ei, si)
|
|
312
227
|
if abs(ei - si) <= 5 and anchor not in seen_clusters:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentsentinel-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Agentic security CLI — AI analyst with memory, supply chain audit, MCP audit, red-team probing, and agent discovery"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|