devops-bot-sdk 1.4.1__tar.gz → 1.4.6__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 (40) hide show
  1. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/pyproject.toml +1 -1
  5. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/__init__.py +2 -2
  6. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/client.py +1 -1
  7. devops_bot_sdk-1.4.6/sdk/crucial.py +217 -0
  8. devops_bot_sdk-1.4.6/sdk/git_ops.py +306 -0
  9. devops_bot_sdk-1.4.6/sdk/hooks/__init__.py +1 -0
  10. devops_bot_sdk-1.4.6/sdk/hooks/crucial_guard.py +77 -0
  11. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/ipc/handlers.py +297 -148
  12. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/local_exec.py +35 -1
  13. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/updater.py +33 -29
  14. devops_bot_sdk-1.4.1/sdk/crucial.py +0 -61
  15. devops_bot_sdk-1.4.1/sdk/git_ops.py +0 -124
  16. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/README.md +0 -0
  17. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  18. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  19. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/requires.txt +0 -0
  20. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  21. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/collectors/__init__.py +0 -0
  22. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/collectors/files.py +0 -0
  23. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/collectors/process.py +0 -0
  24. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/collectors/screenshot.py +0 -0
  25. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/config.py +0 -0
  26. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/exceptions.py +0 -0
  27. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/graphify.py +0 -0
  28. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/ipc/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/ipc/electron_bridge.py +0 -0
  30. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/models/__init__.py +0 -0
  31. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/models/envelope.py +0 -0
  32. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/models/requests.py +0 -0
  33. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/models/responses.py +0 -0
  34. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/models/snapshots.py +0 -0
  35. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/py.typed +0 -0
  36. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/run_auto.py +0 -0
  37. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/sse.py +0 -0
  38. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/test.py +0 -0
  39. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/sdk/test_pipeline.py +0 -0
  40. {devops_bot_sdk-1.4.1 → devops_bot_sdk-1.4.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.1
3
+ Version: 1.4.6
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.1
3
+ Version: 1.4.6
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -24,6 +24,8 @@ sdk/collectors/__init__.py
24
24
  sdk/collectors/files.py
25
25
  sdk/collectors/process.py
26
26
  sdk/collectors/screenshot.py
27
+ sdk/hooks/__init__.py
28
+ sdk/hooks/crucial_guard.py
27
29
  sdk/ipc/__init__.py
28
30
  sdk/ipc/electron_bridge.py
29
31
  sdk/ipc/handlers.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.1"
7
+ version = "1.4.6"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.1
3
+ Version: 1.4.6
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.1"
33
+ __version__ = "1.4.6"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -36,7 +36,7 @@ from sdk.sse import _check_status, stream_with_reconnect
36
36
 
37
37
  logger = logging.getLogger(__name__)
38
38
 
39
- SDK_VERSION = "1.4.1"
39
+ SDK_VERSION = "1.4.6"
40
40
  _POLL_INTERVAL = 3.0
41
41
  _POLL_TIMEOUT = 600.0
42
42
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -0,0 +1,217 @@
1
+ """Mid-run crucial-decision gate.
2
+
3
+ When agentMode=true, the agent must PAUSE for human approval before a crucial /
4
+ irreversible action (DB schema changes/migrations, dropping data, destructive
5
+ shell, prod deploy, auth/billing changes, or anything the operator flagged in
6
+ humanInstructions).
7
+
8
+ Mechanism: the prompt instructs the agent to STOP and emit a one-line marker
9
+ before any such action. The SDK detects the marker, opens an approval gate, and
10
+ resumes the session on approval. (A Claude Code PreToolUse *blocking* hook is a
11
+ planned hardening on top of this — its block protocol needs verifying against
12
+ the installed claude version.)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import shlex
20
+
21
+ MARKER = "AGENTOS_APPROVAL_REQUIRED:"
22
+
23
+ # ── Hard-enforcement file protocol (PreToolUse hook ⇄ SDK) ─────────────
24
+ # Both files live inside a per-task approval dir (AGENTOS_APPROVAL_DIR). The
25
+ # hook writes PENDING_FILE when it blocks a destructive call; the SDK writes
26
+ # GRANTED_FILE (one-shot) after a human approves, and the hook consumes it.
27
+ GRANTED_FILE = "granted"
28
+ PENDING_FILE = "pending"
29
+
30
+ _BUILTIN_CRUCIAL = (
31
+ "Database schema changes or migrations (CREATE/ALTER/DROP TABLE, "
32
+ "prisma/alembic/knex/typeorm migrate, etc.)",
33
+ "Deleting or dropping data (DROP/TRUNCATE/DELETE, dropdb)",
34
+ "Destructive shell commands (rm -rf, git push --force, terraform destroy, "
35
+ "kubectl delete, dropping volumes)",
36
+ "Production deployment or release",
37
+ "Changes to authentication, secrets/credentials, billing or payment logic",
38
+ )
39
+
40
+
41
+ def crucial_prompt(human_instructions: str | None) -> str:
42
+ """Prompt block telling the agent when and how to pause for approval."""
43
+ items = "\n".join(f"- {x}" for x in _BUILTIN_CRUCIAL)
44
+
45
+ extra = ""
46
+ hi = human_instructions or ""
47
+ flagged = [
48
+ ln.strip() for ln in hi.splitlines()
49
+ if any(k in ln.lower() for k in ("crucial", "approval", "sensitive", "do not"))
50
+ ]
51
+ if flagged:
52
+ extra = "\nThe operator additionally marked these as crucial:\n" + "\n".join(
53
+ f"- {ln}" for ln in flagged[:10]
54
+ )
55
+
56
+ return (
57
+ "\n\n=== CRUCIAL-DECISION GATE (mandatory) ===\n"
58
+ "Before performing any CRUCIAL or IRREVERSIBLE action you MUST pause for "
59
+ "human approval. Crucial actions include:\n" + items + extra + "\n"
60
+ "When you reach such a step, DO NOT perform it. Output EXACTLY one line:\n"
61
+ f"{MARKER} <one-line description of the action needing approval>\n"
62
+ "then STOP and end your turn. Do not proceed until you are told it is approved."
63
+ )
64
+
65
+
66
+ def detect_marker(text: str | None) -> str | None:
67
+ """Return the description after the marker, or None if not present."""
68
+ if not text:
69
+ return None
70
+ for line in text.splitlines():
71
+ if MARKER in line:
72
+ return line.split(MARKER, 1)[1].strip() or "crucial action"
73
+ return None
74
+
75
+
76
+ # ── Destructive-action detection (used by the PreToolUse hook) ─────────
77
+
78
+ # Bash command patterns that are crucial/irreversible. High-precision on
79
+ # purpose: a false positive stalls the run on a WhatsApp approval, so routine
80
+ # commands must NOT match (see _RM_SAFE_TARGET for the rm carve-out).
81
+ _DESTRUCTIVE_BASH = (
82
+ (re.compile(r"\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|(^|\s)-f\b)", re.I), "git force-push"),
83
+ (re.compile(r"\bgit\s+reset\s+--hard\b", re.I), "git reset --hard"),
84
+ (re.compile(r"\bgit\s+clean\s+-\w*f", re.I), "git clean -f"),
85
+ (re.compile(r"\bterraform\s+destroy\b", re.I), "terraform destroy"),
86
+ (re.compile(r"\bkubectl\s+delete\b", re.I), "kubectl delete"),
87
+ (re.compile(r"\bdropdb\b", re.I), "dropdb"),
88
+ (re.compile(r"\bDROP\s+(TABLE|DATABASE|SCHEMA)\b", re.I), "SQL DROP"),
89
+ (re.compile(r"\bTRUNCATE\b", re.I), "SQL TRUNCATE"),
90
+ (re.compile(r"\bDELETE\s+FROM\b", re.I), "SQL DELETE"),
91
+ (re.compile(r"\b(prisma|alembic|knex|sequelize|typeorm|rails|php\s+artisan)\b[^\n]*\bmigrat", re.I), "database migration"),
92
+ (re.compile(r"\b(mkfs|dd)\s", re.I), "disk format/overwrite"),
93
+ (re.compile(r"\b(npm|yarn|pnpm)\s+publish\b", re.I), "package publish"),
94
+ (re.compile(r"\bchmod\s+-R\s+777\b", re.I), "recursive chmod 777"),
95
+ )
96
+
97
+ # `rm -rf <target>` is routine for build artifacts but catastrophic for source
98
+ # /data/system paths. Allow when EVERY target is a known throwaway dir; block
99
+ # otherwise (incl. /, ~, .., the repo root, src, etc.).
100
+ _RM_RECURSIVE = re.compile(r"\brm\s+-\S*[rf]", re.I)
101
+ _RM_SAFE_TARGET = re.compile(
102
+ r"^[./]*(node_modules|dist|build|out|coverage|\.next|\.nuxt|\.svelte-kit|\.cache|"
103
+ r"\.turbo|\.angular|target|__pycache__|\.pytest_cache|\.parcel-cache|\.venv|venv|"
104
+ r"vendor|tmp|temp)([/\\].*)?$",
105
+ re.I,
106
+ )
107
+
108
+ # Files whose modification needs approval (secrets / private keys). Conservative:
109
+ # exact `.env` and key/cert files only — scaffolding `.env.example`/`.env.local`
110
+ # stays unblocked.
111
+ _SENSITIVE_FILE = re.compile(r"(^|/)(\.env|id_rsa|id_ed25519|.*\.pem|.*\.key)$", re.I)
112
+
113
+
114
+ def _rm_is_dangerous(cmd: str) -> bool:
115
+ """True if a recursive rm targets anything outside the safe throwaway set."""
116
+ if not _RM_RECURSIVE.search(cmd):
117
+ return False
118
+ # Targets = non-flag tokens after the first `rm`.
119
+ toks = cmd.split()
120
+ try:
121
+ start = toks.index("rm") + 1
122
+ except ValueError:
123
+ return True
124
+ targets = [t.strip("'\"") for t in toks[start:] if not t.startswith("-")]
125
+ if not targets:
126
+ return True
127
+ return any(not _RM_SAFE_TARGET.match(t) for t in targets)
128
+
129
+
130
+ def is_destructive(tool_name: str, tool_input: dict | None) -> str | None:
131
+ """Return a short description if this tool call is crucial/destructive, else None."""
132
+ inp = tool_input or {}
133
+ if tool_name == "Bash":
134
+ cmd = str(inp.get("command", ""))
135
+ if _rm_is_dangerous(cmd):
136
+ return f"recursive delete (rm -rf): {cmd[:120]}"
137
+ for rx, desc in _DESTRUCTIVE_BASH:
138
+ if rx.search(cmd):
139
+ return f"{desc}: {cmd[:120]}"
140
+ return None
141
+ if tool_name in ("Write", "Edit", "MultiEdit"):
142
+ fp = str(inp.get("file_path", ""))
143
+ if fp and _SENSITIVE_FILE.search(fp):
144
+ return f"write to sensitive file: {fp}"
145
+ return None
146
+
147
+
148
+ # ── File protocol helpers ──────────────────────────────────────────────
149
+
150
+ def _granted_path(approval_dir: str) -> str:
151
+ return os.path.join(approval_dir, GRANTED_FILE)
152
+
153
+
154
+ def _pending_path(approval_dir: str) -> str:
155
+ return os.path.join(approval_dir, PENDING_FILE)
156
+
157
+
158
+ def grant(approval_dir: str) -> None:
159
+ """SDK: authorize the NEXT destructive action (one-shot)."""
160
+ try:
161
+ with open(_granted_path(approval_dir), "w") as f:
162
+ f.write("1")
163
+ except Exception: # noqa: BLE001
164
+ pass
165
+
166
+
167
+ def consume_grant(approval_dir: str) -> bool:
168
+ """Hook: if a one-shot grant exists, consume it and return True."""
169
+ try:
170
+ os.remove(_granted_path(approval_dir))
171
+ return True
172
+ except OSError:
173
+ return False
174
+
175
+
176
+ def write_pending(approval_dir: str, desc: str) -> None:
177
+ """Hook: record the blocked action so the SDK can open the gate."""
178
+ try:
179
+ with open(_pending_path(approval_dir), "w") as f:
180
+ json.dump({"desc": desc}, f)
181
+ except Exception: # noqa: BLE001
182
+ pass
183
+
184
+
185
+ def read_pending(approval_dir: str) -> str | None:
186
+ """SDK: read + clear the pending blocked-action description, if any."""
187
+ try:
188
+ with open(_pending_path(approval_dir)) as f:
189
+ data = json.load(f) or {}
190
+ os.remove(_pending_path(approval_dir))
191
+ return data.get("desc") or "crucial action"
192
+ except (OSError, ValueError):
193
+ return None
194
+
195
+
196
+ def hook_settings(python_exe: str, *, timeout: int = 15) -> dict:
197
+ """A `--settings` block registering this module as a PreToolUse guard.
198
+
199
+ Invoked as `<python_exe> -m sdk.hooks.crucial_guard` so it runs in the SDK's
200
+ own interpreter (where the `sdk` package is importable) regardless of cwd.
201
+ """
202
+ return {
203
+ "hooks": {
204
+ "PreToolUse": [
205
+ {
206
+ "matcher": "Bash|Edit|Write|MultiEdit",
207
+ "hooks": [
208
+ {
209
+ "type": "command",
210
+ "command": f"{shlex.quote(python_exe)} -m sdk.hooks.crucial_guard",
211
+ "timeout": timeout,
212
+ }
213
+ ],
214
+ }
215
+ ]
216
+ }
217
+ }
@@ -0,0 +1,306 @@
1
+ """Local git operations — feature branch + PR via the user's GitHub token.
2
+
3
+ Runs `git`/`gh` as subprocesses on the user's machine, authenticated with the
4
+ token fetched from the backend (`/ml-api/github/token`). The SDK owns branching,
5
+ commit, push and PR for the PRIMARY project so it's deterministic; the agent is
6
+ told not to do git for that repo (it may still clone/PR other repos the ticket
7
+ names). Everything here is best-effort and never raises into the run.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import os
14
+ import re
15
+ import shutil
16
+ from pathlib import Path
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def slugify(text: str | None, max_len: int = 40) -> str:
22
+ s = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
23
+ return s[:max_len].strip("-") or "task"
24
+
25
+
26
+ def _git_env(github_token: str | None) -> dict:
27
+ env = dict(os.environ)
28
+ if github_token:
29
+ # Token present → authenticate git transport over HTTPS with the token.
30
+ env["GH_TOKEN"] = github_token
31
+ env["GITHUB_TOKEN"] = github_token
32
+ env["GIT_CONFIG_COUNT"] = "1"
33
+ env["GIT_CONFIG_KEY_0"] = (
34
+ f"url.https://x-access-token:{github_token}@github.com/.insteadOf"
35
+ )
36
+ env["GIT_CONFIG_VALUE_0"] = "https://github.com/"
37
+ else:
38
+ # No token → use the box's SSH keys: rewrite GitHub HTTPS URLs to SSH so
39
+ # clone / fetch / push authenticate via SSH. (PR creation itself still
40
+ # goes through `gh`, using its own stored auth — SSH can't open a PR.)
41
+ env.pop("GH_TOKEN", None)
42
+ env.pop("GITHUB_TOKEN", None)
43
+ env["GIT_CONFIG_COUNT"] = "1"
44
+ env["GIT_CONFIG_KEY_0"] = "url.git@github.com:.insteadOf"
45
+ env["GIT_CONFIG_VALUE_0"] = "https://github.com/"
46
+ env.setdefault("GIT_AUTHOR_NAME", "AgentOS")
47
+ env.setdefault("GIT_AUTHOR_EMAIL", "agentos@users.noreply.github.com")
48
+ env.setdefault("GIT_COMMITTER_NAME", "AgentOS")
49
+ env.setdefault("GIT_COMMITTER_EMAIL", "agentos@users.noreply.github.com")
50
+ return env
51
+
52
+
53
+ async def _run(cmd: list[str], cwd: str, env: dict, timeout: float = 120.0):
54
+ """Run a command; return (returncode, stdout, stderr). Never raises."""
55
+ try:
56
+ proc = await asyncio.create_subprocess_exec(
57
+ *cmd, cwd=cwd, env=env,
58
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
59
+ )
60
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
61
+ return (
62
+ proc.returncode,
63
+ out.decode("utf-8", "replace").strip(),
64
+ err.decode("utf-8", "replace").strip(),
65
+ )
66
+ except Exception as exc: # noqa: BLE001
67
+ return 1, "", str(exc)
68
+
69
+
70
+ async def _is_git_repo(path: str, env: dict) -> bool:
71
+ rc, out, _ = await _run(["git", "rev-parse", "--is-inside-work-tree"], path, env)
72
+ return rc == 0 and out == "true"
73
+
74
+
75
+ async def detect_default_branch(path: str, env: dict) -> str:
76
+ """Repo default branch — the PR base.
77
+
78
+ Must NEVER return a `feature/*` branch: on a re-run the working tree may
79
+ already be checked out ON the feature branch, and returning that as the base
80
+ makes base == head (GitHub rejects with "No commits between X and X"). Tries,
81
+ in order: origin/HEAD → the remote's advertised HEAD → main/master if they
82
+ exist → the current branch only if it isn't a feature branch → "main".
83
+ """
84
+ # 1. Local symbolic ref for origin/HEAD (set by `git clone` / `remote set-head`).
85
+ rc, out, _ = await _run(
86
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], path, env,
87
+ )
88
+ if rc == 0 and "/" in out:
89
+ return out.rsplit("/", 1)[-1]
90
+ # 2. Ask the remote directly — works even when origin/HEAD isn't set locally.
91
+ rc, out, _ = await _run(["git", "remote", "show", "origin"], path, env, timeout=60.0)
92
+ if rc == 0:
93
+ m = re.search(r"HEAD branch:\s*(\S+)", out)
94
+ if m and m.group(1) not in ("", "(unknown)"):
95
+ return m.group(1)
96
+ # 3. Common defaults, if they exist as remote-tracking branches.
97
+ for cand in ("main", "master"):
98
+ rc, _, _ = await _run(
99
+ ["git", "rev-parse", "--verify", "--quiet", f"origin/{cand}"], path, env,
100
+ )
101
+ if rc == 0:
102
+ return cand
103
+ # 4. Last resort: the current branch, but never a feature branch.
104
+ rc, out, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
105
+ if rc == 0 and out and not out.startswith("feature/"):
106
+ return out
107
+ return "main"
108
+
109
+
110
+ async def ensure_repo(
111
+ project_path: str, repo_url: str | None, github_token: str | None,
112
+ ) -> bool:
113
+ """Make sure ``project_path`` is a usable git repo, cloning if needed.
114
+
115
+ - Already a populated git repo → True.
116
+ - Missing or empty dir + a known ``repo_url`` → clone it there, then True.
117
+ - Non-empty but not a git repo → False (don't clobber the user's files).
118
+ Best-effort; never raises.
119
+ """
120
+ path = Path(project_path).expanduser()
121
+ env = _git_env(github_token)
122
+
123
+ has_content = path.is_dir() and any(path.iterdir())
124
+ if has_content:
125
+ if await _is_git_repo(str(path), env):
126
+ return True
127
+ logger.warning("git_ops.path_not_repo path=%s (non-empty, not cloning)", path)
128
+ return False
129
+
130
+ if not repo_url:
131
+ logger.warning("git_ops.no_repo_url path=%s (empty/missing, nothing to clone)", path)
132
+ return False
133
+
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ rc, _, err = await _run(
136
+ ["git", "clone", repo_url, str(path)], str(path.parent), env, timeout=600.0,
137
+ )
138
+ if rc != 0:
139
+ logger.warning("git_ops.clone_failed url=%s path=%s err=%s", repo_url, path, err[:300])
140
+ return False
141
+ logger.info("git_ops.cloned url=%s path=%s", repo_url, path)
142
+ return await _is_git_repo(str(path), env)
143
+
144
+
145
+ async def start_branch(
146
+ project_path: str, jira_key: str | None, summary: str | None, github_token: str | None,
147
+ ) -> dict | None:
148
+ """Create + check out `feature/<jira_key>-<slug>` off the default branch.
149
+
150
+ Returns {"branch", "base"} or None when the path isn't a git repo (e.g. a
151
+ fresh scratch dir) or the checkout failed.
152
+ """
153
+ path = str(Path(project_path).expanduser())
154
+ env = _git_env(github_token)
155
+ if not await _is_git_repo(path, env):
156
+ return None
157
+ # Fetch first so origin/HEAD and origin/main refs are present/fresh before we
158
+ # detect the base (avoids resolving the base to the current feature branch).
159
+ await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
160
+ base = await detect_default_branch(path, env)
161
+ key = slugify(jira_key or "task", max_len=24)
162
+ branch = f"feature/{key}-{slugify(summary)}"
163
+ if base == branch:
164
+ # Defensive: never base a PR on the branch we're about to create.
165
+ base = "main"
166
+ # Cut the feature branch from the FRESH remote base so it never chains off a
167
+ # previous ticket's feature branch left checked out in a shared working tree.
168
+ rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{base}"], path, env)
169
+ if rc != 0:
170
+ rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
171
+ if rc != 0:
172
+ return None
173
+ return {"branch": branch, "base": base}
174
+
175
+
176
+ async def _remote_owner_repo(path: str, env: dict) -> tuple[str, str] | None:
177
+ """Parse origin's URL into (owner, repo). Handles https and ssh forms."""
178
+ rc, out, _ = await _run(["git", "remote", "get-url", "origin"], path, env)
179
+ if rc != 0 or not out:
180
+ return None
181
+ m = re.search(r"github\.com[:/]+([^/]+)/(.+?)(?:\.git)?/?$", out.strip())
182
+ if not m:
183
+ return None
184
+ return m.group(1), m.group(2)
185
+
186
+
187
+ async def _create_pr_via_api(
188
+ owner: str, repo: str, base: str, head: str, title: str, body: str, token: str,
189
+ ) -> str | None:
190
+ """Open a PR through the GitHub REST API (no `gh` CLI needed).
191
+
192
+ The token from /ml-api/github/token carries `repo` scope, so it can both push
193
+ and open PRs. Returns the PR html_url, or the existing open PR's url when one
194
+ already exists for this head (422). Returns None (and logs) on any other error.
195
+ """
196
+ import httpx
197
+
198
+ api = f"https://api.github.com/repos/{owner}/{repo}/pulls"
199
+ headers = {
200
+ "Authorization": f"token {token}",
201
+ "Accept": "application/vnd.github+json",
202
+ "X-GitHub-Api-Version": "2022-11-28",
203
+ }
204
+ try:
205
+ async with httpx.AsyncClient(timeout=30.0) as c:
206
+ resp = await c.post(
207
+ api, headers=headers,
208
+ json={"title": title, "head": head, "base": base, "body": body},
209
+ )
210
+ if resp.status_code == 201:
211
+ return resp.json().get("html_url")
212
+ # 422 = a PR already exists for this head (or validation issue). Try to
213
+ # surface the existing open PR rather than reporting "no PR".
214
+ if resp.status_code == 422:
215
+ async with httpx.AsyncClient(timeout=30.0) as c:
216
+ r2 = await c.get(
217
+ api, headers=headers,
218
+ params={"head": f"{owner}:{head}", "state": "open"},
219
+ )
220
+ if r2.status_code == 200 and r2.json():
221
+ return r2.json()[0].get("html_url")
222
+ logger.warning(
223
+ "git_ops.pr_api_failed owner=%s repo=%s head=%s status=%s body=%s",
224
+ owner, repo, head, resp.status_code, resp.text[:300],
225
+ )
226
+ except Exception as exc: # noqa: BLE001
227
+ logger.warning("git_ops.pr_api_error head=%s error=%s", head, exc)
228
+ return None
229
+
230
+
231
+ async def finish_pr(
232
+ project_path: str, branch: str, base: str, title: str, body: str,
233
+ github_token: str | None,
234
+ ) -> dict:
235
+ """Stage all, commit, push the branch, open a PR. Best-effort.
236
+
237
+ Auth modes (decided by whether a token is available, see _git_env):
238
+ - token present → push over HTTPS+token; PR via REST API (no `gh` needed),
239
+ falling back to `gh`.
240
+ - no token → push over SSH (the box's keys); PR via `gh pr create`
241
+ (GitHub has no SSH path for opening a PR, so `gh` must be authenticated:
242
+ `gh auth login`). SSH alone covers git transport, not the PR.
243
+
244
+ Every failure path is logged so a missing PR is diagnosable instead of silent.
245
+ Returns {"pushed": bool, "pr_url": str | None, "pr_error": str | None}.
246
+ """
247
+ path = str(Path(project_path).expanduser())
248
+ env = _git_env(github_token)
249
+
250
+ await _run(["git", "add", "-A"], path, env)
251
+ # commit — no-op (non-zero) when there's nothing staged; ignore that case.
252
+ await _run(["git", "commit", "-m", title], path, env)
253
+
254
+ rc, _, push_err = await _run(
255
+ ["git", "push", "-u", "origin", branch, "--force-with-lease"],
256
+ path, env, timeout=300.0,
257
+ )
258
+ pushed = rc == 0
259
+ if not pushed:
260
+ logger.warning("git_ops.push_failed branch=%s error=%s", branch, push_err[:300])
261
+ return {"pushed": False, "pr_url": None, "pr_error": f"push failed: {push_err[:200]}"}
262
+
263
+ pr_url: str | None = None
264
+ pr_error: str | None = None
265
+
266
+ # Never open a PR with base == head (GitHub: "No commits between X and X").
267
+ if base == branch:
268
+ base = await detect_default_branch(path, env)
269
+ if base == branch:
270
+ base = "main"
271
+ logger.warning("git_ops.base_equals_head_fixed branch=%s base=%s", branch, base)
272
+
273
+ # 1. Preferred: REST API (no `gh` dependency).
274
+ owner_repo = await _remote_owner_repo(path, env)
275
+ if github_token and owner_repo:
276
+ owner, repo = owner_repo
277
+ pr_url = await _create_pr_via_api(owner, repo, base, branch, title, body, github_token)
278
+
279
+ # 2. Fallback: gh CLI, if installed.
280
+ if not pr_url and shutil.which("gh"):
281
+ rc, out, gh_err = await _run(
282
+ ["gh", "pr", "create", "--base", base, "--head", branch,
283
+ "--title", title, "--body", body],
284
+ path, env, timeout=120.0,
285
+ )
286
+ if rc == 0 and out:
287
+ pr_url = out.strip().splitlines()[-1]
288
+ else:
289
+ pr_error = f"gh pr create failed: {gh_err[:200]}"
290
+ logger.warning("git_ops.gh_pr_failed branch=%s error=%s", branch, gh_err[:300])
291
+
292
+ if not pr_url and pr_error is None:
293
+ # Pushed, but no PR and no specific error captured above.
294
+ if github_token and owner_repo:
295
+ pr_error = "no PR created — REST API returned no URL"
296
+ elif not github_token and not shutil.which("gh"):
297
+ pr_error = ("no PR created — no token and `gh` not installed; SSH "
298
+ "transport can't open a PR. Install gh + `gh auth login`, "
299
+ "or provide a GitHub token.")
300
+ elif not shutil.which("gh"):
301
+ pr_error = "no PR created — missing GitHub token or unrecognized origin remote"
302
+ else:
303
+ pr_error = "no PR created"
304
+ logger.warning("git_ops.no_pr branch=%s reason=%s", branch, pr_error)
305
+
306
+ return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error}
@@ -0,0 +1 @@
1
+ """Claude Code hooks shipped with the SDK (run in the SDK interpreter)."""
@@ -0,0 +1,77 @@
1
+ """PreToolUse guard — HARD-blocks destructive tool calls pending human approval.
2
+
3
+ Registered by ``local_exec`` via ``claude --settings`` and run as
4
+ ``python -m sdk.hooks.crucial_guard`` in the SDK's own interpreter. Protocol
5
+ (Claude Code PreToolUse hook, exit 0 + JSON decision):
6
+
7
+ - reads the PreToolUse event JSON on stdin
8
+ - AGENTOS_APPROVAL_DIR unset → no opinion (normal permissions)
9
+ - non-destructive tool call → no opinion (normal permissions)
10
+ - destructive call WITH a one-shot grant → consume grant, ALLOW
11
+ - destructive call WITHOUT a grant → write a `pending` record (so the
12
+ SDK opens the WhatsApp gate even if the model never emits the marker) and
13
+ DENY, telling the model to STOP and emit the approval marker.
14
+
15
+ This is the hard-enforcement layer under the soft, prompt-based marker gate: the
16
+ destructive command cannot execute until a human approves, regardless of whether
17
+ the model obeys the prompt.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+
25
+
26
+ def _emit(decision: str, reason: str = "") -> None:
27
+ payload: dict = {
28
+ "hookSpecificOutput": {
29
+ "hookEventName": "PreToolUse",
30
+ "permissionDecision": decision,
31
+ }
32
+ }
33
+ if reason:
34
+ payload["hookSpecificOutput"]["permissionDecisionReason"] = reason
35
+ sys.stdout.write(json.dumps(payload))
36
+
37
+
38
+ def main() -> None:
39
+ try:
40
+ event = json.load(sys.stdin)
41
+ except Exception: # noqa: BLE001 — malformed input: stay out of the way
42
+ return
43
+
44
+ approval_dir = os.environ.get("AGENTOS_APPROVAL_DIR")
45
+ if not approval_dir:
46
+ return # guard inactive for this run
47
+
48
+ try:
49
+ from sdk import crucial
50
+ except Exception: # noqa: BLE001 — can't load guard logic: never block
51
+ return
52
+
53
+ desc = crucial.is_destructive(
54
+ event.get("tool_name", ""), event.get("tool_input") or {}
55
+ )
56
+ if not desc:
57
+ return # not destructive → defer to normal permission flow
58
+
59
+ # A human already approved this step → let exactly one destructive call run.
60
+ if crucial.consume_grant(approval_dir):
61
+ _emit("allow", "Human-approved crucial action — proceeding.")
62
+ return
63
+
64
+ # Block, and record it so the SDK can gate even without the model's marker.
65
+ crucial.write_pending(approval_dir, desc)
66
+ _emit(
67
+ "deny",
68
+ "This is a CRUCIAL / IRREVERSIBLE action and is BLOCKED pending human "
69
+ "approval. Do NOT attempt a workaround or an alternative command. Output "
70
+ "EXACTLY one line:\n"
71
+ f"{crucial.MARKER} {desc}\n"
72
+ "then STOP and end your turn. Wait until you are told it is approved.",
73
+ )
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()