conduct-cli 0.6.28__tar.gz → 0.6.30__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 (38) hide show
  1. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/PKG-INFO +3 -2
  2. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/pyproject.toml +3 -3
  3. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/guard.py +294 -0
  4. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli.egg-info/PKG-INFO +3 -2
  5. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli.egg-info/requires.txt +1 -0
  6. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/README.md +0 -0
  7. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/setup.cfg +0 -0
  8. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/setup.py +0 -0
  9. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/__init__.py +0 -0
  10. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/api.py +0 -0
  11. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/guardmcp.py +0 -0
  12. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/hooks/__init__.py +0 -0
  13. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/hooks/base.py +0 -0
  14. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/hooks/posttooluse.py +0 -0
  15. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/hooks/precompact.py +0 -0
  16. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/hooks/pretooluse.py +0 -0
  17. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/hooks/session_parser.py +0 -0
  18. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/hooks/session_start.py +0 -0
  19. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/hooks/stop.py +0 -0
  20. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/log_util.py +0 -0
  21. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/main.py +0 -0
  22. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/mcp_server.py +0 -0
  23. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/memory.py +0 -0
  24. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli/paxel.py +0 -0
  25. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  26. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  27. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  28. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/src/conduct_cli.egg-info/top_level.txt +0 -0
  29. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_bash_operator_signature.py +0 -0
  30. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_command_word_matcher.py +0 -0
  31. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_guard_policy.py +0 -0
  32. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_guard_savings.py +0 -0
  33. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_hook_syntax.py +0 -0
  34. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_journal_drain.py +0 -0
  35. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_log_util.py +0 -0
  36. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_proxy_env.py +0 -0
  37. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_signed_policy.py +0 -0
  38. {conduct_cli-0.6.28 → conduct_cli-0.6.30}/tests/test_switch.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.28
4
- Summary: CLI for Conduct AI — install agents, manage projects, run tests
3
+ Version: 0.6.30
4
+ Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://conductai.ai
@@ -22,6 +22,7 @@ Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: pyyaml>=6.0
24
24
  Requires-Dist: rich>=13.0
25
+ Requires-Dist: psutil>=5.9
25
26
  Provides-Extra: booster
26
27
  Requires-Dist: agent-booster[watch]>=0.6.6; extra == "booster"
27
28
 
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.6.28"
8
- description = "CLI for Conduct AI — install agents, manage projects, run tests"
7
+ version = "0.6.30"
8
+ description = "CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.9"
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: 3.12",
24
24
  "Topic :: Software Development :: Libraries :: Application Frameworks",
25
25
  ]
26
- dependencies = ["pyyaml>=6.0", "rich>=13.0"]
26
+ dependencies = ["pyyaml>=6.0", "rich>=13.0", "psutil>=5.9"]
27
27
 
28
28
  [project.optional-dependencies]
29
29
  booster = ["agent-booster[watch]>=0.6.6"]
@@ -1203,6 +1203,18 @@ def cmd_guard_sync(args):
1203
1203
  except Exception:
1204
1204
  pass
1205
1205
 
1206
+ # Run shadow agent discovery in background — results pushed to API, visible in /guard/discovery
1207
+ try:
1208
+ import subprocess as _sp, shutil as _shutil
1209
+ _conduct = _shutil.which("conduct") or sys.executable
1210
+ _sp.Popen(
1211
+ [_conduct, "guard", "discover"],
1212
+ stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
1213
+ )
1214
+ print(f" {CYAN}Discovery daemon started{RESET} — agent inventory updating in background")
1215
+ except Exception:
1216
+ pass
1217
+
1206
1218
  print(f"\n{BOLD}Policy refreshed ({rule_count} rule(s)).{RESET}")
1207
1219
 
1208
1220
  # Print Claude.ai remote MCP URL — requires one-time browser paste
@@ -1867,9 +1879,287 @@ def register_guard_parser(sub):
1867
1879
  help="Which hook to test",
1868
1880
  )
1869
1881
 
1882
+ # conduct guard discover
1883
+ discover_p = guard_sub.add_parser("discover", help="Scan for AI agents and show Guard coverage")
1884
+ discover_p.add_argument("--config-only", action="store_true", help="Skip process scan, config files only")
1885
+ discover_p.add_argument("--report", default=None, metavar="FILE", help="Write full JSON report to file")
1886
+
1887
+ # conduct guard lint [--file PATH]
1888
+ lint_p = guard_sub.add_parser("lint", help="Validate local policy — show errors and warnings")
1889
+ lint_p.add_argument("--file", default=None, metavar="FILE",
1890
+ help="Path to policy YAML/JSON (default: ~/.conductguard/policy.json)")
1891
+
1870
1892
  return guard_p, guard_sub
1871
1893
 
1872
1894
 
1895
+ def _discover_config_agents() -> list[tuple]:
1896
+ """Detect installed AI tools from config dirs + .env files. No cross-module import.
1897
+ Returns list of (name, config_path, under_guard) tuples.
1898
+ """
1899
+ home = Path.home()
1900
+ results = []
1901
+
1902
+ def _mcp_registered(path: Path) -> bool:
1903
+ try:
1904
+ import json as _j
1905
+ d = _j.loads(path.read_text()) if path.exists() else {}
1906
+ return "conduct" in d.get("mcpServers", {})
1907
+ except Exception:
1908
+ return False
1909
+
1910
+ def _hook_registered(path: Path) -> bool:
1911
+ try:
1912
+ import json as _j
1913
+ d = _j.loads(path.read_text()) if path.exists() else {}
1914
+ hooks = d.get("hooks", {})
1915
+ return any("conductguard" in str(h).lower() or "conduct" in str(h).lower()
1916
+ for h in hooks.get("PreToolUse", []))
1917
+ except Exception:
1918
+ return False
1919
+
1920
+ # Known tool config locations
1921
+ TOOLS = [
1922
+ ("claude-code", home / ".claude", home / ".claude" / "settings.json", "mcp"),
1923
+ ("codex", home / ".codex", home / ".codex" / "mcp.json", "mcp"),
1924
+ ("cursor", home / ".cursor", home / ".cursor" / "mcp.json", "mcp"),
1925
+ ("windsurf", home / ".codeium" / "windsurf", home / ".codeium" / "windsurf" / "mcp_config.json", "mcp"),
1926
+ ]
1927
+ for name, check_dir, config_path, _ in TOOLS:
1928
+ if check_dir.exists():
1929
+ under = _mcp_registered(config_path) or _hook_registered(config_path)
1930
+ results.append((name, config_path, under))
1931
+
1932
+ # .env file scan for LLM API keys — shadow agents not using known tools
1933
+ LLM_KEYS = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "COHERE_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY"]
1934
+ search_paths = [home, home / "projects", home / "code", home / "dev", Path.cwd()]
1935
+ seen_envs: set[str] = set()
1936
+ for base in search_paths:
1937
+ if not base.exists():
1938
+ continue
1939
+ for env_file in list(base.glob(".env")) + list(base.glob("**/.env")):
1940
+ if str(env_file) in seen_envs or ".git" in str(env_file):
1941
+ continue
1942
+ seen_envs.add(str(env_file))
1943
+ try:
1944
+ content = env_file.read_text(errors="ignore")
1945
+ found_keys = [k for k in LLM_KEYS if k in content]
1946
+ if found_keys:
1947
+ results.append(("env-agent", env_file, False)) # .env with LLM key = unregistered
1948
+ except Exception:
1949
+ pass
1950
+
1951
+ return results
1952
+
1953
+
1954
+ def _scan_processes() -> list[dict]:
1955
+ """Detect AI agent processes using psutil. Returns list of discovered agent dicts."""
1956
+ try:
1957
+ import psutil
1958
+ except ImportError:
1959
+ return []
1960
+
1961
+ # Known framework signatures: (framework_name, cmdline_patterns)
1962
+ SIGNATURES = [
1963
+ ("langchain", ["langchain"]),
1964
+ ("crewai", ["crewai", "crew_ai"]),
1965
+ ("autogen", ["autogen", "pyautogen"]),
1966
+ ("openai-agents", ["openai-agents", "openai_agents"]),
1967
+ ("llama-index", ["llama_index", "llamaindex"]),
1968
+ ("claude-code", ["claude"]),
1969
+ ("codex", ["codex"]),
1970
+ ("cursor", ["cursor"]),
1971
+ ("windsurf", ["windsurf"]),
1972
+ ("copilot", ["copilot-language-server", "github.copilot"]),
1973
+ ]
1974
+
1975
+ found = []
1976
+ seen = set()
1977
+ for proc in psutil.process_iter(["pid", "name", "cmdline"]):
1978
+ try:
1979
+ cmdline = " ".join(proc.info["cmdline"] or []).lower()
1980
+ name = (proc.info["name"] or "").lower()
1981
+ combined = f"{name} {cmdline}"
1982
+ for framework, patterns in SIGNATURES:
1983
+ if any(p in combined for p in patterns):
1984
+ key = (framework, proc.info["name"])
1985
+ if key not in seen:
1986
+ seen.add(key)
1987
+ found.append({
1988
+ "name": proc.info["name"],
1989
+ "framework": framework,
1990
+ "source": "process",
1991
+ "location": f"pid:{proc.info['pid']} {proc.info['name']}",
1992
+ "evidence": {"pid": proc.info["pid"], "cmdline": cmdline[:200]},
1993
+ "risk_score": 60,
1994
+ })
1995
+ break
1996
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
1997
+ pass
1998
+ return found
1999
+
2000
+
2001
+ def cmd_guard_lint(args):
2002
+ """Validate the local policy file and report errors / warnings."""
2003
+ import json as _json, re as _re
2004
+
2005
+ policy_path = getattr(args, "file", None)
2006
+ if policy_path:
2007
+ from pathlib import Path as _Path
2008
+ p = _Path(policy_path).expanduser().resolve()
2009
+ if not p.exists():
2010
+ print(f" {RED}File not found: {p}{RESET}")
2011
+ sys.exit(1)
2012
+ raw = p.read_text()
2013
+ else:
2014
+ policy = _load_policy()
2015
+ raw = None
2016
+
2017
+ if raw is not None:
2018
+ try:
2019
+ policy = _json.loads(raw)
2020
+ except Exception:
2021
+ try:
2022
+ import yaml as _yaml
2023
+ policy = _yaml.safe_load(raw)
2024
+ except Exception as e:
2025
+ print(f" {RED}Cannot parse file: {e}{RESET}")
2026
+ sys.exit(1)
2027
+
2028
+ rules = policy.get("rules", [])
2029
+ fail_mode = policy.get("fail_mode")
2030
+
2031
+ # ── local lint (no API call needed) ──────────────────────────────────────
2032
+ VALID_ACTIONS = {"block", "warn", "audit", "approval", "inject"}
2033
+ VALID_FAIL_MODES = {"fail_open", "fail_closed"}
2034
+ MATCH_FIELDS = {"match_tool", "match_pattern", "match_path_pattern",
2035
+ "match_command_word", "match_tokens_before_gt"}
2036
+
2037
+ errors: list[dict] = []
2038
+ warnings: list[dict] = []
2039
+ seen_ids: set[str] = set()
2040
+
2041
+ if fail_mode and fail_mode not in VALID_FAIL_MODES:
2042
+ errors.append({"rule_id": "(policy)", "field": "fail_mode",
2043
+ "message": f"must be one of: {', '.join(sorted(VALID_FAIL_MODES))}"})
2044
+
2045
+ for i, rule in enumerate(rules):
2046
+ rid = rule.get("rule_id") or rule.get("id") or f"(rule[{i}])"
2047
+ if not rule.get("rule_id") and not rule.get("id"):
2048
+ errors.append({"rule_id": rid, "field": "rule_id", "message": "rule_id is required"})
2049
+ if rid in seen_ids:
2050
+ errors.append({"rule_id": rid, "field": "rule_id", "message": f"Duplicate rule_id '{rid}'"})
2051
+ seen_ids.add(rid)
2052
+
2053
+ action = rule.get("action")
2054
+ if not action:
2055
+ errors.append({"rule_id": rid, "field": "action", "message": "action is required"})
2056
+ elif action not in VALID_ACTIONS:
2057
+ errors.append({"rule_id": rid, "field": "action",
2058
+ "message": f"'{action}' must be one of: {', '.join(sorted(VALID_ACTIONS))}"})
2059
+
2060
+ for rf in ("match_pattern", "match_path_pattern"):
2061
+ val = rule.get(rf)
2062
+ if val:
2063
+ try:
2064
+ _re.compile(val)
2065
+ except _re.error as e:
2066
+ errors.append({"rule_id": rid, "field": rf, "message": f"Invalid regex: {e}"})
2067
+
2068
+ if not any(rule.get(f) for f in MATCH_FIELDS):
2069
+ warnings.append({"rule_id": rid, "field": "match_*",
2070
+ "message": "No match condition — rule fires on every tool call"})
2071
+
2072
+ tokens = rule.get("match_tokens_before_gt")
2073
+ if tokens is not None and (not isinstance(tokens, int) or tokens <= 0):
2074
+ errors.append({"rule_id": rid, "field": "match_tokens_before_gt",
2075
+ "message": "must be a positive integer"})
2076
+
2077
+ # ── output ────────────────────────────────────────────────────────────────
2078
+ print(f"\n {BOLD}Policy lint{RESET} — {len(rules)} rule(s)\n")
2079
+
2080
+ if not errors and not warnings:
2081
+ print(f" {GREEN}No issues found{RESET}\n")
2082
+ return
2083
+
2084
+ for e in errors:
2085
+ print(f" {RED}ERROR{RESET} [{e['rule_id']}] {e['field']}: {e['message']}")
2086
+ for w in warnings:
2087
+ print(f" {YELLOW}WARN{RESET} [{w['rule_id']}] {w['field']}: {w['message']}")
2088
+
2089
+ print()
2090
+ if errors:
2091
+ sys.exit(1)
2092
+
2093
+
2094
+ def cmd_guard_discover(args):
2095
+ """Scan for AI agents across config files and processes, report Guard coverage."""
2096
+ import json as _json
2097
+
2098
+ cfg = _load_guard_config()
2099
+ api_url = _api_url(cfg)
2100
+ token = cfg.get("member_token", "")
2101
+ hdrs = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
2102
+
2103
+ # Config file scan — detect AI tools from known config dirs
2104
+ config_agents = []
2105
+ for name, config_path, mcp_key in _discover_config_agents():
2106
+ under = mcp_key
2107
+ config_agents.append({
2108
+ "name": name,
2109
+ "framework": name,
2110
+ "source": "config",
2111
+ "location": str(config_path),
2112
+ "evidence": {"config_path": str(config_path), "under_guard": under},
2113
+ "risk_score": 20 if under else 70,
2114
+ "under_guard": under,
2115
+ })
2116
+
2117
+ # Process scan
2118
+ process_agents: list[dict] = []
2119
+ if not getattr(args, "config_only", False):
2120
+ process_agents = _scan_processes()
2121
+ # Mark as under Guard if same framework is already registered
2122
+ registered_frameworks = {a["framework"] for a in config_agents if a["under_guard"]}
2123
+ for a in process_agents:
2124
+ a["under_guard"] = a["framework"] in registered_frameworks
2125
+
2126
+ all_agents = config_agents + process_agents
2127
+ total = len(all_agents)
2128
+ covered = sum(1 for a in all_agents if a["under_guard"])
2129
+ missing = total - covered
2130
+ pct = round(covered / total * 100) if total else 0
2131
+
2132
+ # Print coverage meter
2133
+ print(f"\n Discovered {total} AI agents across your environment\n")
2134
+ config_count = len(config_agents)
2135
+ process_count = len(process_agents)
2136
+ config_cov = sum(1 for a in config_agents if a["under_guard"])
2137
+ process_cov = sum(1 for a in process_agents if a["under_guard"])
2138
+ if config_count:
2139
+ print(f" Config files: {config_count:3d} agents ({config_cov} under Guard, {config_count - config_cov} not)")
2140
+ if process_count:
2141
+ print(f" Processes: {process_count:3d} running ({process_cov} under Guard, {process_count - process_cov} not)")
2142
+ print(f" {'─' * 52}")
2143
+ print(f" Guard coverage: {covered} of {total} agents ({pct}%)\n")
2144
+ if missing:
2145
+ print(f" Run: conduct guard discover --register to close the gap")
2146
+ print(f" GitHub scan available — coming in v2\n")
2147
+
2148
+ # POST to API
2149
+ try:
2150
+ payload = {"triggered_by": "cli", "agents": all_agents}
2151
+ resp = _req("POST", f"{api_url}/guard/discover/scan", body=payload, token=token)
2152
+ except Exception:
2153
+ pass # local output still useful even if API is down
2154
+
2155
+ # Write report if requested
2156
+ report_path = getattr(args, "report", None)
2157
+ if report_path:
2158
+ report = {"total": total, "under_guard": covered, "missing": missing, "coverage_pct": pct, "agents": all_agents}
2159
+ Path(report_path).write_text(_json.dumps(report, indent=2))
2160
+ print(f" Report written to {report_path}")
2161
+
2162
+
1873
2163
  def cmd_guard_booster_status(args):
1874
2164
  """Show whether booster is intercepting Read/Grep in this project."""
1875
2165
  import shutil, sqlite3, subprocess
@@ -2023,6 +2313,10 @@ def dispatch_guard(args, guard_p):
2023
2313
  cmd_guard_audit(args)
2024
2314
  elif guard_command == "install":
2025
2315
  cmd_guard_install(args)
2316
+ elif guard_command == "discover":
2317
+ cmd_guard_discover(args)
2318
+ elif guard_command == "lint":
2319
+ cmd_guard_lint(args)
2026
2320
  elif guard_command == "booster-status":
2027
2321
  cmd_guard_booster_status(args)
2028
2322
  elif guard_command == "debug-hook":
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.28
4
- Summary: CLI for Conduct AI — install agents, manage projects, run tests
3
+ Version: 0.6.30
4
+ Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://conductai.ai
@@ -22,6 +22,7 @@ Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: pyyaml>=6.0
24
24
  Requires-Dist: rich>=13.0
25
+ Requires-Dist: psutil>=5.9
25
26
  Provides-Extra: booster
26
27
  Requires-Dist: agent-booster[watch]>=0.6.6; extra == "booster"
27
28
 
@@ -1,5 +1,6 @@
1
1
  pyyaml>=6.0
2
2
  rich>=13.0
3
+ psutil>=5.9
3
4
 
4
5
  [booster]
5
6
  agent-booster[watch]>=0.6.6
File without changes
File without changes
File without changes