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.
Files changed (43) hide show
  1. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/PKG-INFO +1 -1
  2. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/cli.py +1 -383
  3. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/report.py +0 -8
  4. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/secrets_rules.py +4 -89
  5. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/pyproject.toml +1 -1
  6. agentsentinel_cli-0.7.6/agentsentinel_cli/agent_mode.py +0 -586
  7. agentsentinel_cli-0.7.6/agentsentinel_cli/agent_mode_report.py +0 -160
  8. agentsentinel_cli-0.7.6/agentsentinel_cli/ai_probe.py +0 -300
  9. agentsentinel_cli-0.7.6/agentsentinel_cli/attacks/__init__.py +0 -5
  10. agentsentinel_cli-0.7.6/agentsentinel_cli/attacks/library.py +0 -438
  11. agentsentinel_cli-0.7.6/agentsentinel_cli/probe.py +0 -163
  12. agentsentinel_cli-0.7.6/agentsentinel_cli/probe_report.py +0 -254
  13. agentsentinel_cli-0.7.6/agentsentinel_cli/target.py +0 -164
  14. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/.gitignore +0 -0
  15. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/DOCUMENTATION.md +0 -0
  16. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/LICENSE +0 -0
  17. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/README.md +0 -0
  18. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/__init__.py +0 -0
  19. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/a2a_report.py +0 -0
  20. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/a2a_rules.py +0 -0
  21. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/a2a_scanner.py +0 -0
  22. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/discover.py +0 -0
  23. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/discover_report.py +0 -0
  24. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/fingerprint.py +0 -0
  25. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/frameworks.py +0 -0
  26. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/inspect.py +0 -0
  27. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/inspect_report.py +0 -0
  28. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/mcp_client.py +0 -0
  29. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/mcp_report.py +0 -0
  30. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/mcp_rules.py +0 -0
  31. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/rules.py +0 -0
  32. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/scanner.py +0 -0
  33. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/secrets.py +0 -0
  34. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/secrets_report.py +0 -0
  35. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/supply_chain_ai.py +0 -0
  36. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/supply_chain_report.py +0 -0
  37. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/supply_chain_rules.py +0 -0
  38. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/agentsentinel_cli/suppress.py +0 -0
  39. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/note.md +0 -0
  40. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/test-mcp-agent/README.md +0 -0
  41. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/test-mcp-agent/langchain_agent.py +0 -0
  42. {agentsentinel_cli-0.7.6 → agentsentinel_cli-0.8.0}/tmp/test-mcp-agent/mcp_server.py +0 -0
  43. {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.7.6
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, connect_url=connect)
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
- # ── Singapore validators ──────────────────────────────────────────────────────
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 + NRIC
253
- or SSN within 5 lines of each other — strong indicator of a leaked tool call result).
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 + (NRIC | SSN) within 5 lines of each other
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.6"
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"