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.
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/client.py +1 -1
- devops_bot_sdk-1.4.6/sdk/crucial.py +217 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/git_ops.py +106 -14
- devops_bot_sdk-1.4.6/sdk/hooks/__init__.py +1 -0
- devops_bot_sdk-1.4.6/sdk/hooks/crucial_guard.py +77 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/ipc/handlers.py +292 -148
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/local_exec.py +19 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/updater.py +33 -29
- devops_bot_sdk-1.4.3/sdk/crucial.py +0 -61
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/README.md +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/run_auto.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.6}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devops-bot-sdk"
|
|
7
|
-
version = "1.4.
|
|
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
|
+
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
206
|
-
"no PR created — REST API returned no URL"
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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()
|