devops-bot-sdk 1.4.3__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 (39) hide show
  1. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/pyproject.toml +1 -1
  5. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/__init__.py +2 -2
  6. {devops_bot_sdk-1.4.3 → 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.3 → devops_bot_sdk-1.4.6}/sdk/git_ops.py +106 -14
  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.3 → devops_bot_sdk-1.4.6}/sdk/ipc/handlers.py +292 -148
  12. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/local_exec.py +19 -0
  13. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/updater.py +33 -29
  14. devops_bot_sdk-1.4.3/sdk/crucial.py +0 -61
  15. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/README.md +0 -0
  16. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  17. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  18. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/requires.txt +0 -0
  19. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  20. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/collectors/__init__.py +0 -0
  21. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/collectors/files.py +0 -0
  22. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/collectors/process.py +0 -0
  23. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/collectors/screenshot.py +0 -0
  24. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/config.py +0 -0
  25. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/exceptions.py +0 -0
  26. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/graphify.py +0 -0
  27. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/ipc/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/ipc/electron_bridge.py +0 -0
  29. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/__init__.py +0 -0
  30. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/envelope.py +0 -0
  31. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/requests.py +0 -0
  32. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/responses.py +0 -0
  33. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/snapshots.py +0 -0
  34. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/py.typed +0 -0
  35. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/run_auto.py +0 -0
  36. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/sse.py +0 -0
  37. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/test.py +0 -0
  38. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/test_pipeline.py +0 -0
  39. {devops_bot_sdk-1.4.3 → 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.3
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.3
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.3"
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.3
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.3"
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.3"
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
+ }
@@ -26,6 +26,7 @@ def slugify(text: str | None, max_len: int = 40) -> str:
26
26
  def _git_env(github_token: str | None) -> dict:
27
27
  env = dict(os.environ)
28
28
  if github_token:
29
+ # Token present → authenticate git transport over HTTPS with the token.
29
30
  env["GH_TOKEN"] = github_token
30
31
  env["GITHUB_TOKEN"] = github_token
31
32
  env["GIT_CONFIG_COUNT"] = "1"
@@ -33,6 +34,15 @@ def _git_env(github_token: str | None) -> dict:
33
34
  f"url.https://x-access-token:{github_token}@github.com/.insteadOf"
34
35
  )
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/"
36
46
  env.setdefault("GIT_AUTHOR_NAME", "AgentOS")
37
47
  env.setdefault("GIT_AUTHOR_EMAIL", "agentos@users.noreply.github.com")
38
48
  env.setdefault("GIT_COMMITTER_NAME", "AgentOS")
@@ -63,14 +73,73 @@ async def _is_git_repo(path: str, env: dict) -> bool:
63
73
 
64
74
 
65
75
  async def detect_default_branch(path: str, env: dict) -> str:
66
- """Repo default branch (origin/HEAD), falling back to the current branch."""
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`).
67
85
  rc, out, _ = await _run(
68
86
  ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], path, env,
69
87
  )
70
88
  if rc == 0 and "/" in out:
71
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.
72
104
  rc, out, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
73
- return out if (rc == 0 and out) else "main"
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)
74
143
 
75
144
 
76
145
  async def start_branch(
@@ -85,11 +154,20 @@ async def start_branch(
85
154
  env = _git_env(github_token)
86
155
  if not await _is_git_repo(path, env):
87
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)
88
160
  base = await detect_default_branch(path, env)
89
161
  key = slugify(jira_key or "task", max_len=24)
90
162
  branch = f"feature/{key}-{slugify(summary)}"
91
- await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
92
- rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
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)
93
171
  if rc != 0:
94
172
  return None
95
173
  return {"branch": branch, "base": base}
@@ -156,10 +234,14 @@ async def finish_pr(
156
234
  ) -> dict:
157
235
  """Stage all, commit, push the branch, open a PR. Best-effort.
158
236
 
159
- PR creation prefers the GitHub REST API (works with just the token no `gh`
160
- install required) and falls back to the `gh` CLI. Every failure path is
161
- logged so a missing PR is diagnosable instead of silent.
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.
162
243
 
244
+ Every failure path is logged so a missing PR is diagnosable instead of silent.
163
245
  Returns {"pushed": bool, "pr_url": str | None, "pr_error": str | None}.
164
246
  """
165
247
  path = str(Path(project_path).expanduser())
@@ -181,6 +263,13 @@ async def finish_pr(
181
263
  pr_url: str | None = None
182
264
  pr_error: str | None = None
183
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
+
184
273
  # 1. Preferred: REST API (no `gh` dependency).
185
274
  owner_repo = await _remote_owner_repo(path, env)
186
275
  if github_token and owner_repo:
@@ -202,13 +291,16 @@ async def finish_pr(
202
291
 
203
292
  if not pr_url and pr_error is None:
204
293
  # Pushed, but no PR and no specific error captured above.
205
- pr_error = (
206
- "no PR created — REST API returned no URL"
207
- if (github_token and owner_repo)
208
- else "no PR created — missing GitHub token or unrecognized origin remote"
209
- if not shutil.which("gh")
210
- else "no PR created"
211
- )
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"
212
304
  logger.warning("git_ops.no_pr branch=%s reason=%s", branch, pr_error)
213
305
 
214
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()