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