conduct-cli 0.6.33__tar.gz → 0.6.34__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.
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/PKG-INFO +1 -1
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/pyproject.toml +1 -1
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/guard.py +192 -18
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/guardmcp.py +36 -11
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/hooks/base.py +28 -3
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/hooks/pretooluse.py +12 -8
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/main.py +5 -7
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/mcp_server.py +16 -2
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/README.md +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/setup.cfg +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/setup.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_log_util.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.6.33 → conduct_cli-0.6.34}/tests/test_switch.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.34"
|
|
8
8
|
description = "CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -18,8 +18,36 @@ CYAN = "\033[36m"
|
|
|
18
18
|
YELLOW = "\033[33m"
|
|
19
19
|
|
|
20
20
|
GUARD_DIR = Path.home() / ".conductguard"
|
|
21
|
-
CONFIG_PATH = GUARD_DIR / "config.json"
|
|
22
|
-
POLICY_PATH = GUARD_DIR / "policy.json"
|
|
21
|
+
CONFIG_PATH = GUARD_DIR / "config.json" # legacy / fallback
|
|
22
|
+
POLICY_PATH = GUARD_DIR / "policy.json" # legacy / fallback
|
|
23
|
+
_CONDUCT_CFG = Path.home() / ".conduct" / "config.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _active_workspace_id() -> str | None:
|
|
27
|
+
"""Active workspace from conduct switch — updates at runtime."""
|
|
28
|
+
try:
|
|
29
|
+
if _CONDUCT_CFG.exists():
|
|
30
|
+
return json.loads(_CONDUCT_CFG.read_text()).get("workspace")
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ws_config_path(ws_id: str) -> Path:
|
|
37
|
+
return GUARD_DIR / "workspaces" / f"{ws_id}.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ws_policy_path(ws_id: str) -> Path:
|
|
41
|
+
return GUARD_DIR / f"policy_{ws_id}.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def active_policy_path() -> Path:
|
|
45
|
+
ws_id = _active_workspace_id()
|
|
46
|
+
if ws_id:
|
|
47
|
+
p = _ws_policy_path(ws_id)
|
|
48
|
+
if p.exists():
|
|
49
|
+
return p
|
|
50
|
+
return POLICY_PATH
|
|
23
51
|
|
|
24
52
|
# ── Hook templates — loaded from real .py files (no string embedding) ─────────
|
|
25
53
|
|
|
@@ -102,11 +130,8 @@ def _bash_command_words(cmd: str) -> list[str]:
|
|
|
102
130
|
|
|
103
131
|
def _check_policy(tool_name, tool_input, tokens_before=0):
|
|
104
132
|
"""Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
try:
|
|
108
|
-
policy = _json.loads(POLICY_PATH.read_text())
|
|
109
|
-
except Exception:
|
|
133
|
+
policy = _load_policy()
|
|
134
|
+
if not policy.get("rules"):
|
|
110
135
|
return None, "allow", None, None
|
|
111
136
|
|
|
112
137
|
rules = policy.get("rules", [])
|
|
@@ -305,14 +330,27 @@ def _ensure_persona(workspace_id: str, api_key: str, base_url: str) -> str:
|
|
|
305
330
|
return chosen
|
|
306
331
|
|
|
307
332
|
|
|
308
|
-
def _load_guard_config() -> dict:
|
|
333
|
+
def _load_guard_config(workspace_id: str | None = None) -> dict:
|
|
334
|
+
ws_id = workspace_id or _active_workspace_id()
|
|
335
|
+
if ws_id:
|
|
336
|
+
p = _ws_config_path(ws_id)
|
|
337
|
+
if p.exists():
|
|
338
|
+
return json.loads(p.read_text())
|
|
339
|
+
# fallback to legacy single-workspace config
|
|
309
340
|
if CONFIG_PATH.exists():
|
|
310
341
|
return json.loads(CONFIG_PATH.read_text())
|
|
311
342
|
return {}
|
|
312
343
|
|
|
313
344
|
|
|
314
|
-
def _save_guard_config(data: dict):
|
|
345
|
+
def _save_guard_config(data: dict, workspace_id: str | None = None):
|
|
315
346
|
GUARD_DIR.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
ws_id = workspace_id or data.get("workspace_id") or _active_workspace_id()
|
|
348
|
+
if ws_id:
|
|
349
|
+
p = _ws_config_path(ws_id)
|
|
350
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
p.write_text(json.dumps(data, indent=2))
|
|
352
|
+
p.chmod(0o600)
|
|
353
|
+
# always write legacy path so old hook versions keep working
|
|
316
354
|
CONFIG_PATH.write_text(json.dumps(data, indent=2))
|
|
317
355
|
CONFIG_PATH.chmod(0o600)
|
|
318
356
|
|
|
@@ -572,13 +610,22 @@ def _req(method: str, url: str, body=None, token: str = None, api_key: str = Non
|
|
|
572
610
|
|
|
573
611
|
|
|
574
612
|
|
|
575
|
-
def _save_policy(policy: dict):
|
|
613
|
+
def _save_policy(policy: dict, workspace_id: str | None = None):
|
|
576
614
|
GUARD_DIR.mkdir(parents=True, exist_ok=True)
|
|
615
|
+
ws_id = workspace_id or _active_workspace_id()
|
|
616
|
+
if ws_id:
|
|
617
|
+
_ws_policy_path(ws_id).write_text(json.dumps(policy, indent=2))
|
|
618
|
+
# always write legacy path for old hook versions
|
|
577
619
|
POLICY_PATH.write_text(json.dumps(policy, indent=2))
|
|
578
620
|
|
|
579
621
|
|
|
580
|
-
def _load_policy() -> dict:
|
|
622
|
+
def _load_policy(workspace_id: str | None = None) -> dict:
|
|
581
623
|
try:
|
|
624
|
+
ws_id = workspace_id or _active_workspace_id()
|
|
625
|
+
if ws_id:
|
|
626
|
+
p = _ws_policy_path(ws_id)
|
|
627
|
+
if p.exists():
|
|
628
|
+
return json.loads(p.read_text())
|
|
582
629
|
return json.loads(POLICY_PATH.read_text())
|
|
583
630
|
except Exception:
|
|
584
631
|
return {"rules": []}
|
|
@@ -774,6 +821,26 @@ def cmd_guard_install(args):
|
|
|
774
821
|
if signing_key_hex:
|
|
775
822
|
_write_signing_key(signing_key_hex)
|
|
776
823
|
|
|
824
|
+
# Start watch daemon — auto-discovers agents every 15 min, no user action needed.
|
|
825
|
+
try:
|
|
826
|
+
import subprocess as _sp, sys as _sys
|
|
827
|
+
running, _ = _is_watch_running()
|
|
828
|
+
if not running:
|
|
829
|
+
log_file = open(_WATCH_LOG, "a")
|
|
830
|
+
proc = _sp.Popen(
|
|
831
|
+
[_sys.executable, "-c",
|
|
832
|
+
"from conduct_cli.guard import _watch_loop; _watch_loop()"],
|
|
833
|
+
start_new_session=True,
|
|
834
|
+
stdout=log_file,
|
|
835
|
+
stderr=log_file,
|
|
836
|
+
)
|
|
837
|
+
_WATCH_PID.write_text(str(proc.pid))
|
|
838
|
+
print(f" {GREEN}Guard watch:{RESET} background discovery daemon started (pid {proc.pid})")
|
|
839
|
+
else:
|
|
840
|
+
print(f" {GREEN}Guard watch:{RESET} already running")
|
|
841
|
+
except Exception:
|
|
842
|
+
pass # non-fatal
|
|
843
|
+
|
|
777
844
|
|
|
778
845
|
def _write_signing_key(hex_key: str) -> None:
|
|
779
846
|
"""Write a hex-encoded signing key to ~/.conductguard/signing.key.
|
|
@@ -1634,13 +1701,7 @@ def cmd_guard_status(args):
|
|
|
1634
1701
|
pass
|
|
1635
1702
|
|
|
1636
1703
|
# Load local policy for rule count
|
|
1637
|
-
rule_count =
|
|
1638
|
-
if POLICY_PATH.exists():
|
|
1639
|
-
try:
|
|
1640
|
-
policy = json.loads(POLICY_PATH.read_text())
|
|
1641
|
-
rule_count = len(policy.get("rules", []))
|
|
1642
|
-
except Exception:
|
|
1643
|
-
pass
|
|
1704
|
+
rule_count = len(_load_policy().get("rules", []))
|
|
1644
1705
|
|
|
1645
1706
|
# Fetch today's spend
|
|
1646
1707
|
spend = {}
|
|
@@ -1884,6 +1945,11 @@ def register_guard_parser(sub):
|
|
|
1884
1945
|
discover_p.add_argument("--config-only", action="store_true", help="Skip process scan, config files only")
|
|
1885
1946
|
discover_p.add_argument("--report", default=None, metavar="FILE", help="Write full JSON report to file")
|
|
1886
1947
|
|
|
1948
|
+
# conduct guard watch
|
|
1949
|
+
watch_p = guard_sub.add_parser("watch", help="Start background daemon — scans every 15 min, auto-pushes to Guard")
|
|
1950
|
+
watch_p.add_argument("--stop", action="store_true", help="Stop the running watch daemon")
|
|
1951
|
+
watch_p.add_argument("--status", action="store_true", help="Show whether the watch daemon is running")
|
|
1952
|
+
|
|
1887
1953
|
# conduct guard lint [--file PATH]
|
|
1888
1954
|
lint_p = guard_sub.add_parser("lint", help="Validate local policy — show errors and warnings")
|
|
1889
1955
|
lint_p.add_argument("--file", default=None, metavar="FILE",
|
|
@@ -2011,6 +2077,112 @@ def _scan_processes() -> list[dict]:
|
|
|
2011
2077
|
return found
|
|
2012
2078
|
|
|
2013
2079
|
|
|
2080
|
+
_WATCH_PID = Path.home() / ".conductguard" / "watch.pid"
|
|
2081
|
+
_WATCH_LOG = Path.home() / ".conductguard" / "watch.log"
|
|
2082
|
+
_WATCH_INTERVAL = 15 * 60 # seconds
|
|
2083
|
+
|
|
2084
|
+
|
|
2085
|
+
def _is_watch_running():
|
|
2086
|
+
"""Returns (is_running: bool, pid: int | None)."""
|
|
2087
|
+
if not _WATCH_PID.exists():
|
|
2088
|
+
return False, None
|
|
2089
|
+
try:
|
|
2090
|
+
pid = int(_WATCH_PID.read_text().strip())
|
|
2091
|
+
os.kill(pid, 0)
|
|
2092
|
+
return True, pid
|
|
2093
|
+
except (ValueError, ProcessLookupError):
|
|
2094
|
+
_WATCH_PID.unlink(missing_ok=True)
|
|
2095
|
+
return False, None
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
def _watch_loop():
|
|
2099
|
+
"""Background loop — runs discover+push every 15 min. Invoked as subprocess."""
|
|
2100
|
+
import time, json as _json
|
|
2101
|
+
# resolve config at each iteration so workspace switches are picked up
|
|
2102
|
+
def _cfg():
|
|
2103
|
+
return _load_guard_config()
|
|
2104
|
+
|
|
2105
|
+
while True:
|
|
2106
|
+
try:
|
|
2107
|
+
c = _cfg()
|
|
2108
|
+
api_url = _api_url(c)
|
|
2109
|
+
token = c.get("member_token", "")
|
|
2110
|
+
api_key = c.get("api_key", "")
|
|
2111
|
+
agents = []
|
|
2112
|
+
for name, config_path, mcp_key in _discover_config_agents():
|
|
2113
|
+
agents.append({
|
|
2114
|
+
"name": name, "framework": name, "source": "config",
|
|
2115
|
+
"location": str(config_path),
|
|
2116
|
+
"evidence": {"config_path": str(config_path), "under_guard": mcp_key},
|
|
2117
|
+
"risk_score": 20 if mcp_key else 70,
|
|
2118
|
+
"under_guard": mcp_key,
|
|
2119
|
+
})
|
|
2120
|
+
try:
|
|
2121
|
+
process_agents = _scan_processes()
|
|
2122
|
+
registered = {a["framework"] for a in agents if a["under_guard"]}
|
|
2123
|
+
for a in process_agents:
|
|
2124
|
+
a["under_guard"] = a["framework"] in registered
|
|
2125
|
+
agents += process_agents
|
|
2126
|
+
except Exception:
|
|
2127
|
+
pass
|
|
2128
|
+
_req("POST", f"{api_url}/guard/discover/scan",
|
|
2129
|
+
body={"triggered_by": "watch", "agents": agents},
|
|
2130
|
+
token=token, api_key=api_key)
|
|
2131
|
+
except Exception:
|
|
2132
|
+
pass
|
|
2133
|
+
time.sleep(_WATCH_INTERVAL)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def cmd_guard_watch(args):
|
|
2137
|
+
"""Start/stop/status the background discovery daemon."""
|
|
2138
|
+
import signal as _signal
|
|
2139
|
+
|
|
2140
|
+
if getattr(args, "status", False):
|
|
2141
|
+
running, pid = _is_watch_running()
|
|
2142
|
+
if running:
|
|
2143
|
+
print(f" Guard watch daemon running (pid {pid})")
|
|
2144
|
+
log_tail = ""
|
|
2145
|
+
if _WATCH_LOG.exists():
|
|
2146
|
+
lines = _WATCH_LOG.read_text().splitlines()
|
|
2147
|
+
log_tail = "\n".join(lines[-5:])
|
|
2148
|
+
if log_tail:
|
|
2149
|
+
print(f" Last log:\n{log_tail}")
|
|
2150
|
+
else:
|
|
2151
|
+
print(" Guard watch daemon not running. Start with: conduct guard watch")
|
|
2152
|
+
return
|
|
2153
|
+
|
|
2154
|
+
if getattr(args, "stop", False):
|
|
2155
|
+
running, pid = _is_watch_running()
|
|
2156
|
+
if not running:
|
|
2157
|
+
print(" Guard watch daemon is not running.")
|
|
2158
|
+
return
|
|
2159
|
+
os.kill(pid, _signal.SIGTERM)
|
|
2160
|
+
_WATCH_PID.unlink(missing_ok=True)
|
|
2161
|
+
print(f" Guard watch daemon stopped (pid {pid}).")
|
|
2162
|
+
return
|
|
2163
|
+
|
|
2164
|
+
# Start
|
|
2165
|
+
running, pid = _is_watch_running()
|
|
2166
|
+
if running:
|
|
2167
|
+
print(f" Guard watch daemon already running (pid {pid}). Stop with: conduct guard watch --stop")
|
|
2168
|
+
return
|
|
2169
|
+
|
|
2170
|
+
_WATCH_PID.parent.mkdir(parents=True, exist_ok=True)
|
|
2171
|
+
log_file = open(_WATCH_LOG, "a")
|
|
2172
|
+
import subprocess, sys as _sys
|
|
2173
|
+
proc = subprocess.Popen(
|
|
2174
|
+
[_sys.executable, "-c",
|
|
2175
|
+
"from conduct_cli.guard import _watch_loop; _watch_loop()"],
|
|
2176
|
+
start_new_session=True,
|
|
2177
|
+
stdout=log_file,
|
|
2178
|
+
stderr=log_file,
|
|
2179
|
+
)
|
|
2180
|
+
_WATCH_PID.write_text(str(proc.pid))
|
|
2181
|
+
print(f" Guard watch daemon started (pid {proc.pid}).")
|
|
2182
|
+
print(f" Scans every 15 min — results visible at conductai.ai/guard/discovery")
|
|
2183
|
+
print(f" Stop with: conduct guard watch --stop")
|
|
2184
|
+
|
|
2185
|
+
|
|
2014
2186
|
def cmd_guard_lint(args):
|
|
2015
2187
|
"""Validate the local policy file and report errors / warnings."""
|
|
2016
2188
|
import json as _json, re as _re
|
|
@@ -2329,6 +2501,8 @@ def dispatch_guard(args, guard_p):
|
|
|
2329
2501
|
cmd_guard_install(args)
|
|
2330
2502
|
elif guard_command == "discover":
|
|
2331
2503
|
cmd_guard_discover(args)
|
|
2504
|
+
elif guard_command == "watch":
|
|
2505
|
+
cmd_guard_watch(args)
|
|
2332
2506
|
elif guard_command == "lint":
|
|
2333
2507
|
cmd_guard_lint(args)
|
|
2334
2508
|
elif guard_command == "booster-status":
|
|
@@ -24,9 +24,28 @@ import urllib.request
|
|
|
24
24
|
import urllib.error
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
|
-
GUARD_DIR
|
|
28
|
-
POLICY_PATH
|
|
29
|
-
CONFIG_PATH
|
|
27
|
+
GUARD_DIR = Path.home() / ".conductguard"
|
|
28
|
+
POLICY_PATH = GUARD_DIR / "policy.json"
|
|
29
|
+
CONFIG_PATH = GUARD_DIR / "config.json"
|
|
30
|
+
_CONDUCT_CFG = Path.home() / ".conduct" / "config.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _active_workspace_id() -> str | None:
|
|
34
|
+
try:
|
|
35
|
+
if _CONDUCT_CFG.exists():
|
|
36
|
+
return json.loads(_CONDUCT_CFG.read_text()).get("workspace")
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _active_policy_path() -> Path:
|
|
43
|
+
ws_id = _active_workspace_id()
|
|
44
|
+
if ws_id:
|
|
45
|
+
p = GUARD_DIR / f"policy_{ws_id}.json"
|
|
46
|
+
if p.exists():
|
|
47
|
+
return p
|
|
48
|
+
return POLICY_PATH
|
|
30
49
|
|
|
31
50
|
PROTOCOL_VERSION = "2024-11-05"
|
|
32
51
|
|
|
@@ -122,20 +141,26 @@ _TOOLS = [
|
|
|
122
141
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
123
142
|
|
|
124
143
|
def _load_policy() -> dict:
|
|
125
|
-
|
|
144
|
+
p = _active_policy_path()
|
|
145
|
+
if p.exists():
|
|
126
146
|
try:
|
|
127
|
-
return json.loads(
|
|
147
|
+
return json.loads(p.read_text())
|
|
128
148
|
except Exception:
|
|
129
149
|
pass
|
|
130
150
|
return {"rules": []}
|
|
131
151
|
|
|
132
152
|
|
|
133
153
|
def _load_config() -> dict:
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
try:
|
|
155
|
+
ws_id = _active_workspace_id()
|
|
156
|
+
if ws_id:
|
|
157
|
+
p = GUARD_DIR / "workspaces" / f"{ws_id}.json"
|
|
158
|
+
if p.exists():
|
|
159
|
+
return json.loads(p.read_text())
|
|
160
|
+
if CONFIG_PATH.exists():
|
|
136
161
|
return json.loads(CONFIG_PATH.read_text())
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
139
164
|
return {}
|
|
140
165
|
|
|
141
166
|
|
|
@@ -160,7 +185,7 @@ def _maybe_sync() -> None:
|
|
|
160
185
|
ws_id = cfg.get("workspace_id", "")
|
|
161
186
|
if ws_id:
|
|
162
187
|
with urllib.request.urlopen(f"{_DAEMON_URL}/policy?workspace_id={ws_id}", timeout=1) as r:
|
|
163
|
-
|
|
188
|
+
_active_policy_path().write_text(r.read().decode())
|
|
164
189
|
return
|
|
165
190
|
except Exception:
|
|
166
191
|
pass # fall through to CLI sync
|
|
@@ -337,7 +362,7 @@ def _handle_guard_sync(workspace_id: str, token: str) -> str:
|
|
|
337
362
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
338
363
|
policy = json.loads(resp.read())
|
|
339
364
|
GUARD_DIR.mkdir(parents=True, exist_ok=True)
|
|
340
|
-
|
|
365
|
+
_active_policy_path().write_text(json.dumps(policy, indent=2))
|
|
341
366
|
rule_count = len(policy.get("rules", []))
|
|
342
367
|
return f"Policy synced — {rule_count} rule(s) active (version: {policy.get('version', 'unknown')})."
|
|
343
368
|
except urllib.error.HTTPError as e:
|
|
@@ -17,8 +17,9 @@ from typing import Literal, Optional
|
|
|
17
17
|
# ── Canonical paths ───────────────────────────────────────────────────────────
|
|
18
18
|
|
|
19
19
|
GUARD_DIR = Path.home() / ".conductguard"
|
|
20
|
-
CONFIG_PATH = GUARD_DIR / "config.json"
|
|
21
|
-
POLICY_PATH = GUARD_DIR / "policy.json"
|
|
20
|
+
CONFIG_PATH = GUARD_DIR / "config.json" # legacy / fallback
|
|
21
|
+
POLICY_PATH = GUARD_DIR / "policy.json" # legacy / fallback
|
|
22
|
+
_CONDUCT_CFG = Path.home() / ".conduct" / "config.json"
|
|
22
23
|
BUDGET_CACHE_PATH = GUARD_DIR / "budget_cache.json"
|
|
23
24
|
BUDGET_CACHE_TTL = 300 # seconds
|
|
24
25
|
VERSION_CACHE_PATH = GUARD_DIR / "version_cache.json"
|
|
@@ -42,9 +43,23 @@ class HookResult:
|
|
|
42
43
|
|
|
43
44
|
# ── Config loading ────────────────────────────────────────────────────────────
|
|
44
45
|
|
|
46
|
+
def _active_workspace_id() -> str | None:
|
|
47
|
+
try:
|
|
48
|
+
if _CONDUCT_CFG.exists():
|
|
49
|
+
return json.loads(_CONDUCT_CFG.read_text()).get("workspace")
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
45
55
|
def load_config() -> dict:
|
|
46
|
-
"""
|
|
56
|
+
"""Load Guard config for the active workspace, falling back to legacy single config."""
|
|
47
57
|
try:
|
|
58
|
+
ws_id = _active_workspace_id()
|
|
59
|
+
if ws_id:
|
|
60
|
+
p = GUARD_DIR / "workspaces" / f"{ws_id}.json"
|
|
61
|
+
if p.exists():
|
|
62
|
+
return json.loads(p.read_text())
|
|
48
63
|
if CONFIG_PATH.exists():
|
|
49
64
|
return json.loads(CONFIG_PATH.read_text())
|
|
50
65
|
except Exception:
|
|
@@ -52,6 +67,16 @@ def load_config() -> dict:
|
|
|
52
67
|
return {}
|
|
53
68
|
|
|
54
69
|
|
|
70
|
+
def active_policy_path() -> Path:
|
|
71
|
+
"""Return per-workspace policy path, or legacy fallback."""
|
|
72
|
+
ws_id = _active_workspace_id()
|
|
73
|
+
if ws_id:
|
|
74
|
+
p = GUARD_DIR / f"policy_{ws_id}.json"
|
|
75
|
+
if p.exists():
|
|
76
|
+
return p
|
|
77
|
+
return POLICY_PATH
|
|
78
|
+
|
|
79
|
+
|
|
55
80
|
# ── Repo / tool detection ─────────────────────────────────────────────────────
|
|
56
81
|
|
|
57
82
|
def detect_repo() -> Optional[str]:
|
|
@@ -18,6 +18,7 @@ from conduct_cli.hooks.base import (
|
|
|
18
18
|
JOURNAL_DIR,
|
|
19
19
|
JOURNAL_PID_PATH,
|
|
20
20
|
POLICY_PATH,
|
|
21
|
+
active_policy_path,
|
|
21
22
|
SIGNING_KEY_PATH,
|
|
22
23
|
VERSION_CACHE_PATH,
|
|
23
24
|
VERSION_CACHE_TTL,
|
|
@@ -152,18 +153,20 @@ def _post_signature_invalid_event(expected_sig, computed_sig, policy_version, ho
|
|
|
152
153
|
def _maybe_sync_policy() -> None:
|
|
153
154
|
"""Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
|
|
154
155
|
try:
|
|
155
|
-
cfg =
|
|
156
|
+
cfg = load_config()
|
|
156
157
|
workspace_id = cfg.get("workspace_id")
|
|
157
158
|
api_key = cfg.get("api_key", "")
|
|
158
159
|
api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
|
|
159
160
|
if not workspace_id:
|
|
160
161
|
return
|
|
161
162
|
|
|
163
|
+
pol_path = active_policy_path()
|
|
164
|
+
|
|
162
165
|
if _daemon_alive():
|
|
163
166
|
url = f"{_DAEMON_URL}/policy?workspace_id={workspace_id}"
|
|
164
167
|
with urllib.request.urlopen(url, timeout=1) as resp:
|
|
165
168
|
remote = json.loads(resp.read())
|
|
166
|
-
|
|
169
|
+
pol_path.write_text(json.dumps(remote, indent=2))
|
|
167
170
|
return
|
|
168
171
|
|
|
169
172
|
if VERSION_CACHE_PATH.exists():
|
|
@@ -188,8 +191,8 @@ def _maybe_sync_policy() -> None:
|
|
|
188
191
|
remote_version = remote.get("version", "")
|
|
189
192
|
local_version = ""
|
|
190
193
|
local_policy: dict = {}
|
|
191
|
-
if
|
|
192
|
-
local_policy = json.loads(
|
|
194
|
+
if pol_path.exists():
|
|
195
|
+
local_policy = json.loads(pol_path.read_text())
|
|
193
196
|
local_version = local_policy.get("version", "")
|
|
194
197
|
|
|
195
198
|
old_mode = local_policy.get("fail_mode", "fail_open")
|
|
@@ -207,7 +210,7 @@ def _maybe_sync_policy() -> None:
|
|
|
207
210
|
return
|
|
208
211
|
|
|
209
212
|
if remote_version != local_version:
|
|
210
|
-
|
|
213
|
+
pol_path.write_text(json.dumps(remote, indent=2))
|
|
211
214
|
VERSION_CACHE_PATH.write_text(json.dumps({"ts": time.time(), "version": remote_version}))
|
|
212
215
|
except Exception:
|
|
213
216
|
pass
|
|
@@ -292,10 +295,11 @@ def _bash_operator_signature(command: str) -> str:
|
|
|
292
295
|
|
|
293
296
|
def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
|
|
294
297
|
"""Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
|
|
295
|
-
|
|
298
|
+
pol_path = active_policy_path()
|
|
299
|
+
if not pol_path.exists():
|
|
296
300
|
return None, "allow", None, None
|
|
297
301
|
try:
|
|
298
|
-
policy = json.loads(
|
|
302
|
+
policy = json.loads(pol_path.read_text())
|
|
299
303
|
except Exception:
|
|
300
304
|
return None, "allow", None, None
|
|
301
305
|
|
|
@@ -409,7 +413,7 @@ def main() -> None:
|
|
|
409
413
|
_this_file = Path(__file__).resolve()
|
|
410
414
|
|
|
411
415
|
# Fail-closed gate
|
|
412
|
-
if _get_fail_mode() == "fail_closed" and not
|
|
416
|
+
if _get_fail_mode() == "fail_closed" and not active_policy_path().exists():
|
|
413
417
|
tool_name = (data.get("tool_name") or "").lower()
|
|
414
418
|
tool_input = data.get("tool_input") or {}
|
|
415
419
|
session_id = data.get("session_id")
|
|
@@ -2736,19 +2736,17 @@ def cmd_test_guard(args):
|
|
|
2736
2736
|
"""Test each guard policy rule with a matching synthetic tool call."""
|
|
2737
2737
|
import json as _json
|
|
2738
2738
|
import re as _re
|
|
2739
|
-
from conduct_cli.guard import
|
|
2739
|
+
from conduct_cli.guard import _load_guard_config, _load_policy, active_policy_path
|
|
2740
2740
|
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
except Exception:
|
|
2744
|
-
cfg = {}
|
|
2741
|
+
cfg = _load_guard_config()
|
|
2742
|
+
pol_path = active_policy_path() # workspace-scoped
|
|
2745
2743
|
|
|
2746
|
-
if not
|
|
2744
|
+
if not pol_path.exists():
|
|
2747
2745
|
print(f"{RED}No policy file found. Run: conduct guard sync{RESET}")
|
|
2748
2746
|
sys.exit(1)
|
|
2749
2747
|
|
|
2750
2748
|
try:
|
|
2751
|
-
policy =
|
|
2749
|
+
policy = _load_policy()
|
|
2752
2750
|
except Exception as e:
|
|
2753
2751
|
print(f"{RED}Could not load policy: {e}{RESET}")
|
|
2754
2752
|
sys.exit(1)
|
|
@@ -97,10 +97,24 @@ def _load_conduct_config() -> dict:
|
|
|
97
97
|
return {}
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
def _active_guard_policy_path() -> Path:
|
|
101
|
+
try:
|
|
102
|
+
cfg = json.loads(CONDUCT_CONFIG_PATH.read_text()) if CONDUCT_CONFIG_PATH.exists() else {}
|
|
103
|
+
ws_id = cfg.get("workspace")
|
|
104
|
+
if ws_id:
|
|
105
|
+
p = GUARD_DIR / f"policy_{ws_id}.json"
|
|
106
|
+
if p.exists():
|
|
107
|
+
return p
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
return GUARD_POLICY_PATH
|
|
111
|
+
|
|
112
|
+
|
|
100
113
|
def _load_guard_policy() -> dict:
|
|
101
|
-
|
|
114
|
+
p = _active_guard_policy_path()
|
|
115
|
+
if p.exists():
|
|
102
116
|
try:
|
|
103
|
-
return json.loads(
|
|
117
|
+
return json.loads(p.read_text())
|
|
104
118
|
except Exception:
|
|
105
119
|
pass
|
|
106
120
|
return {"rules": []}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|