rockycode 0.1.0__py3-none-any.whl
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.
- rockycode/__init__.py +1 -0
- rockycode/banner.py +37 -0
- rockycode/cli.py +1386 -0
- rockycode/config.py +178 -0
- rockycode/dream/__init__.py +9 -0
- rockycode/dream/core.py +523 -0
- rockycode/dream/judge.py +134 -0
- rockycode/dream/mining.py +152 -0
- rockycode/dream/proposals.py +440 -0
- rockycode/engine/__init__.py +10 -0
- rockycode/engine/artifact.py +367 -0
- rockycode/engine/budget.py +90 -0
- rockycode/engine/checks.py +157 -0
- rockycode/engine/compaction.py +181 -0
- rockycode/engine/container.py +225 -0
- rockycode/engine/effort.py +46 -0
- rockycode/engine/events.py +101 -0
- rockycode/engine/explore.py +592 -0
- rockycode/engine/goal.py +541 -0
- rockycode/engine/goal_review.py +161 -0
- rockycode/engine/goal_session.py +259 -0
- rockycode/engine/headless.py +481 -0
- rockycode/engine/loop.py +711 -0
- rockycode/engine/lsp.py +473 -0
- rockycode/engine/mcp.py +364 -0
- rockycode/engine/modes.py +123 -0
- rockycode/engine/outcome.py +81 -0
- rockycode/engine/permission.py +198 -0
- rockycode/engine/planmode.py +249 -0
- rockycode/engine/providers.py +196 -0
- rockycode/engine/redact.py +83 -0
- rockycode/engine/safety.py +139 -0
- rockycode/engine/sandbox.py +219 -0
- rockycode/engine/server.py +431 -0
- rockycode/engine/skills.py +178 -0
- rockycode/engine/titler.py +46 -0
- rockycode/engine/tools.py +479 -0
- rockycode/engine/trajectory.py +131 -0
- rockycode/engine/web.py +431 -0
- rockycode/engine/worktree.py +128 -0
- rockycode/memory/__init__.py +7 -0
- rockycode/memory/index.py +260 -0
- rockycode/memory/store.py +331 -0
- rockycode/modes/learn/learn.md +46 -0
- rockycode/modes/research/deep-research.md +53 -0
- rockycode/modes/research/paper-reading.md +49 -0
- rockycode/modes/research/prove.md +60 -0
- rockycode/modes/research/whiteboard.md +64 -0
- rockycode/onboarding.py +332 -0
- rockycode/palette.py +15 -0
- rockycode/pricing.py +178 -0
- rockycode/prompts/__init__.py +0 -0
- rockycode/prompts/rocky.py +257 -0
- rockycode/routines.py +287 -0
- rockycode/runners/__init__.py +0 -0
- rockycode/runners/agent.py +273 -0
- rockycode/runners/data.py +61 -0
- rockycode/runners/raw.py +176 -0
- rockycode/score.py +114 -0
- rockycode/session.py +298 -0
- rockycode/skills/architecture-viz/SKILL.md +71 -0
- rockycode/skills/architecture-viz/template.html +87 -0
- rockycode/skills/lean-prover/SKILL.md +155 -0
- rockycode/skills/lean-prover/torchlean-api.md +85 -0
- rockycode/tui/__init__.py +1 -0
- rockycode/tui/app.py +2450 -0
- rockycode/tui/exitsheet.py +181 -0
- rockycode/tui/goal_screen.py +315 -0
- rockycode/tui/mdterm.py +232 -0
- rockycode/tui/mdview.py +99 -0
- rockycode/tui/modepicker.py +103 -0
- rockycode/tui/permission.py +154 -0
- rockycode/tui/plangate.py +110 -0
- rockycode/tui/prompt_history.py +77 -0
- rockycode/tui/proposalcard.py +126 -0
- rockycode/tui/resume.py +142 -0
- rockycode/tui/rocky_pet.py +96 -0
- rockycode/tui/routinecard.py +123 -0
- rockycode-0.1.0.dist-info/METADATA +488 -0
- rockycode-0.1.0.dist-info/RECORD +83 -0
- rockycode-0.1.0.dist-info/WHEEL +4 -0
- rockycode-0.1.0.dist-info/entry_points.txt +2 -0
- rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Pure permission policy — no UI, no I/O, no async. The testable heart of the
|
|
2
|
+
opt-in approval layer.
|
|
3
|
+
|
|
4
|
+
`decide(mode, risk, args, workdir)` maps a tool's static risk tier
|
|
5
|
+
(safe|moderate|risky — see tools.RISK / Tool.risk) and the session's permission
|
|
6
|
+
mode (yolo|ask|careful) to one of {"allow", "ask"}. The TUI's approver runs this
|
|
7
|
+
first and only pops a modal on "ask"; bench/headless never even calls it (its
|
|
8
|
+
engine keeps the always-allow default).
|
|
9
|
+
|
|
10
|
+
`sniff_danger(tool, args)` is an advisory heuristic that flags remote-code-
|
|
11
|
+
execution / destructive patterns so the approval modal can highlight them — the
|
|
12
|
+
real defense against a "cheating" skill that tells the model to curl a virus,
|
|
13
|
+
applied at the layer where the actual command is visible.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from rockycode.engine.safety import classify_command
|
|
22
|
+
|
|
23
|
+
MODES = ("yolo", "ask", "careful")
|
|
24
|
+
RISKS = ("safe", "moderate", "risky")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
READ_TOOLS = ("read_file", "grep", "glob")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def decide(mode: str, risk: str, args: dict, workdir: Path, tool: str = "", read_grants=()) -> str:
|
|
31
|
+
"""Return "allow" | "ask" | "block". Never prompts; just classifies the call.
|
|
32
|
+
|
|
33
|
+
Command-level danger is judged FIRST, and it overrides the mode — this is the
|
|
34
|
+
same per-command classifier goal mode uses, lifted into the shared permission
|
|
35
|
+
layer so *chat* bash is judged by what the command DOES, not just that it's
|
|
36
|
+
"a bash call". So a benign `ls` and a `brew install` are no longer the same
|
|
37
|
+
decision:
|
|
38
|
+
- a "block"-tier command (e.g. `sudo rm -rf /`, `curl … | sudo sh`) → "block"
|
|
39
|
+
in EVERY mode, even yolo — it must never run unattended.
|
|
40
|
+
- an "ask"-tier command (install / privileged / network: brew/apt/sudo/…) →
|
|
41
|
+
"ask" in ask & careful — a fresh prompt every time. Paired with
|
|
42
|
+
session_grantable(), a per-tool "allow for this session" can't wave it
|
|
43
|
+
through. In yolo it still runs (you opted out of prompts).
|
|
44
|
+
|
|
45
|
+
Then the ordinary tier logic:
|
|
46
|
+
- yolo : allow everything else
|
|
47
|
+
- safe : allow — EXCEPT a read whose target escapes the workdir (secrets)
|
|
48
|
+
- risky : ask (bash/web_fetch/mcp__*) in both ask and careful
|
|
49
|
+
- moderate: `ask` allows an in-workdir write, else ask; `careful` always asks.
|
|
50
|
+
"""
|
|
51
|
+
if tool == "bash":
|
|
52
|
+
action = classify_command(str(args.get("command", ""))).action
|
|
53
|
+
if action == "block":
|
|
54
|
+
return "block" # never runs — even in yolo
|
|
55
|
+
if action == "ask" and mode != "yolo":
|
|
56
|
+
return "ask" # dangerous cmd → always a prompt
|
|
57
|
+
if mode == "yolo":
|
|
58
|
+
return "allow"
|
|
59
|
+
if risk == "safe":
|
|
60
|
+
if tool in READ_TOOLS and _read_escapes_workdir(tool, args, workdir, read_grants):
|
|
61
|
+
return "ask"
|
|
62
|
+
return "allow"
|
|
63
|
+
if risk == "risky":
|
|
64
|
+
return "ask"
|
|
65
|
+
# moderate (write_file / edit_file / remember)
|
|
66
|
+
if mode == "careful":
|
|
67
|
+
return "ask"
|
|
68
|
+
return "allow" if _writes_inside(args, workdir) else "ask"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def session_grantable(tool: str, args: dict) -> bool:
|
|
72
|
+
"""Whether a per-tool "allow for this session" grant may cover THIS call.
|
|
73
|
+
|
|
74
|
+
False for a dangerous bash command (install / privileged / network /
|
|
75
|
+
destructive): approving a benign `ls` for the session must NOT silently
|
|
76
|
+
green-light a later `brew install` or `sudo …`. Those keep prompting (or
|
|
77
|
+
stay blocked) every time, no matter the session allowlist. Everything else is
|
|
78
|
+
grantable as before."""
|
|
79
|
+
if tool == "bash":
|
|
80
|
+
return classify_command(str(args.get("command", ""))).action == "allow"
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def command_binary(command: str) -> str:
|
|
85
|
+
"""The program a bash command runs — the unit a session grant is scoped to.
|
|
86
|
+
|
|
87
|
+
A bash session grant is NEVER "all bash": approving `lake build` grants the
|
|
88
|
+
`lake` BINARY (so `lake build`, `lake env lean`, … stop nagging during a
|
|
89
|
+
proof/test loop) but a later `curl`/`rm`/`sudo` is a different binary and
|
|
90
|
+
still prompts. Skips leading `VAR=val` assignments; returns the basename
|
|
91
|
+
(so `/usr/bin/lake` and `lake` are the same grant).
|
|
92
|
+
|
|
93
|
+
Returns "" for ANY command that chains, redirects, or substitutes
|
|
94
|
+
(`; | & > < ( ) { } ` $` or a newline) — a compound like `lake build; curl
|
|
95
|
+
evil` must not match a `lake` grant, or the chained command would ride in.
|
|
96
|
+
"" never matches a grant, so those always re-prompt.
|
|
97
|
+
"""
|
|
98
|
+
import re
|
|
99
|
+
if any(c in command for c in "|&;<>(){}`$\n"):
|
|
100
|
+
return ""
|
|
101
|
+
for tok in command.strip().split():
|
|
102
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", tok):
|
|
103
|
+
continue # env assignment prefix
|
|
104
|
+
return tok.rsplit("/", 1)[-1]
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_escapes_workdir(tool: str, args: dict, workdir: Path, read_grants=()) -> bool:
|
|
109
|
+
"""True if a read tool's target lies outside workdir AND isn't already granted
|
|
110
|
+
(→ gate it). glob has no path arg, so its pattern is judged instead; an
|
|
111
|
+
absolute/`~`/`..` pattern is treated as escaping. A path inside a granted root
|
|
112
|
+
doesn't re-prompt (approving a read widens the jail; see tools._jail)."""
|
|
113
|
+
if tool == "glob":
|
|
114
|
+
pat = args.get("pattern")
|
|
115
|
+
if not isinstance(pat, str):
|
|
116
|
+
return False
|
|
117
|
+
return pat.startswith(("/", "~")) or ".." in pat
|
|
118
|
+
p = args.get("path")
|
|
119
|
+
if tool == "grep" and not p:
|
|
120
|
+
p = "." # grep defaults to the workdir root — inside
|
|
121
|
+
if not isinstance(p, str) or not p:
|
|
122
|
+
return False
|
|
123
|
+
try:
|
|
124
|
+
target = Path(p)
|
|
125
|
+
if not target.is_absolute():
|
|
126
|
+
target = workdir / target
|
|
127
|
+
target = target.resolve()
|
|
128
|
+
wd = workdir.resolve()
|
|
129
|
+
except (OSError, ValueError, RuntimeError):
|
|
130
|
+
return True # unresolvable → fail-safe → ask
|
|
131
|
+
roots = (wd, *read_grants)
|
|
132
|
+
return not any(target == r or r in target.parents for r in roots)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _writes_inside(args: dict, workdir: Path) -> bool:
|
|
136
|
+
"""True only if args['path'] resolves to a location within workdir. Unknown
|
|
137
|
+
or missing path is treated as outside (fail-safe → ask)."""
|
|
138
|
+
p = args.get("path")
|
|
139
|
+
if not isinstance(p, str) or not p:
|
|
140
|
+
return False
|
|
141
|
+
try:
|
|
142
|
+
target = Path(p)
|
|
143
|
+
if not target.is_absolute():
|
|
144
|
+
target = workdir / target
|
|
145
|
+
target = target.resolve()
|
|
146
|
+
wd = workdir.resolve()
|
|
147
|
+
except (OSError, ValueError, RuntimeError):
|
|
148
|
+
return False
|
|
149
|
+
return target == wd or wd in target.parents
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Advisory only — these never block, they just surface a warning in the modal.
|
|
153
|
+
_DANGER = [
|
|
154
|
+
(re.compile(r"\b(curl|wget|fetch)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", re.I),
|
|
155
|
+
"pipes a download straight into a shell"),
|
|
156
|
+
(re.compile(r"\|\s*(sudo\s+)?(ba|z|da)?sh\b(\s|$)", re.I),
|
|
157
|
+
"pipes output into a shell"),
|
|
158
|
+
(re.compile(r"base64\s+-+d\w*\b.*\|\s*(ba|z)?sh", re.I | re.S),
|
|
159
|
+
"decodes base64 and runs it"),
|
|
160
|
+
(re.compile(r"eval\s+[\"']?\$\(", re.I),
|
|
161
|
+
"evals a command-substitution result"),
|
|
162
|
+
(re.compile(r"\brm\s+-[a-z]*r[a-z]*f?\s+(-{1,2}\S+\s+)*[~/]", re.I),
|
|
163
|
+
"recursive delete of a home/root path"),
|
|
164
|
+
(re.compile(r">>?\s*~?/?(\.(ssh|bashrc|zshrc|bash_profile|profile)\b|\.ssh/)", re.I),
|
|
165
|
+
"writes to a shell/ssh dotfile"),
|
|
166
|
+
(re.compile(r"\bcrontab\b", re.I),
|
|
167
|
+
"edits cron jobs"),
|
|
168
|
+
(re.compile(r"chmod\s+\+x\b[^&;|]*/tmp/", re.I),
|
|
169
|
+
"makes a /tmp file executable"),
|
|
170
|
+
(re.compile(r"\bnc\b[^|;&]*\s-\w*e\w*\b|/dev/tcp/", re.I),
|
|
171
|
+
"looks like a reverse shell"),
|
|
172
|
+
(re.compile(r"\b(?:ba|z|da)?sh\b\s+-[a-z]*c\b[^\n]*\$\(", re.I),
|
|
173
|
+
"runs a command-substitution result in a shell"),
|
|
174
|
+
(re.compile(r"<\(\s*(?:sudo\s+)?(?:curl|wget|fetch)\b", re.I),
|
|
175
|
+
"process-substitutes a network download into a command"),
|
|
176
|
+
(re.compile(r"\b(?:python[0-9.]*|perl|ruby|node|php)\b\s+-[a-z]*[ceE]\b[^\n]*"
|
|
177
|
+
r"(?:curl|wget|urllib|urlopen|requests|socket|https?://)", re.I),
|
|
178
|
+
"inline interpreter script that pulls from the network"),
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def sniff_danger(tool: str, args: dict) -> Optional[str]:
|
|
183
|
+
"""Return a short reason if the call matches a known dangerous pattern, else
|
|
184
|
+
None. Checks bash commands, web_fetch URLs, and (defensively) MCP tool args."""
|
|
185
|
+
if tool == "bash":
|
|
186
|
+
blob = str(args.get("command", ""))
|
|
187
|
+
elif tool == "web_fetch":
|
|
188
|
+
blob = str(args.get("url", ""))
|
|
189
|
+
elif tool.startswith("mcp__"):
|
|
190
|
+
blob = str(args)
|
|
191
|
+
else:
|
|
192
|
+
return None
|
|
193
|
+
if not blob:
|
|
194
|
+
return None
|
|
195
|
+
for rx, why in _DANGER:
|
|
196
|
+
if rx.search(blob):
|
|
197
|
+
return why
|
|
198
|
+
return None
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Plan-mode policy: the read-only gate for interactive planning.
|
|
2
|
+
|
|
3
|
+
Plan mode is HOST state on the Engine (never a model-invoked tool — an
|
|
4
|
+
out-of-distribution mode tool invokes unreliably on DeepSeek, and schema churn
|
|
5
|
+
breaks the prefix cache; see docs/plan-mode-design.md). While it is on, every
|
|
6
|
+
tool call runs through gate() BEFORE the normal permission/approver flow:
|
|
7
|
+
|
|
8
|
+
pass — hand the call to the normal flow (permission.decide + modal):
|
|
9
|
+
every 'safe'-tier read, read-only-classified bash (which still ASKS,
|
|
10
|
+
never auto-allows — a read-only `cat ~/.ssh/id_rsa` must face a
|
|
11
|
+
human), and web_fetch (a read of the world, SSRF-guarded, still asks).
|
|
12
|
+
allow — run without asking: a write/edit whose RESOLVED target is exactly
|
|
13
|
+
the session's plan file. The one writable path in the mode.
|
|
14
|
+
deny — everything else that mutates. The message teaches the model where
|
|
15
|
+
to go instead (keep exploring, write the plan) rather than just
|
|
16
|
+
refusing, so the turn keeps moving.
|
|
17
|
+
|
|
18
|
+
This gate decides deny-vs-ask; it is NOT a security boundary (human approval,
|
|
19
|
+
the file-tool jail, and the secret-file refusals are). The bash classifier is
|
|
20
|
+
therefore a conservative whitelist, not a bulletproof shell parser: a sneaky
|
|
21
|
+
"read-only" command that slips through still lands in front of the user.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Verdict:
|
|
33
|
+
action: str # "pass" | "allow" | "deny"
|
|
34
|
+
message: str # model-facing text for deny; "" otherwise
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PASS = Verdict("pass", "")
|
|
38
|
+
|
|
39
|
+
# Risky-tier tools that only READ the world — forwarded to the normal ask flow.
|
|
40
|
+
# (web_search/web_research are already 'safe'-tier and pass on risk alone.)
|
|
41
|
+
_WORLD_READS = frozenset({"web_fetch"})
|
|
42
|
+
|
|
43
|
+
_WRITE_MSG = (
|
|
44
|
+
"[blocked] plan mode is read-only — no code changes yet. The one writable "
|
|
45
|
+
"file is the plan: {plan}. Keep exploring with read-only tools and write "
|
|
46
|
+
"your plan there; the user approves it before any changes are made."
|
|
47
|
+
)
|
|
48
|
+
_BASH_MSG = (
|
|
49
|
+
"[blocked] plan mode is read-only and this command could change state. "
|
|
50
|
+
"Read-only commands (git log/diff/show/status/blame, ls, cat, rg, find …) "
|
|
51
|
+
"may run with approval — no redirects, chaining, or substitution. Write "
|
|
52
|
+
"your plan to {plan} when ready; the user approves it before any changes."
|
|
53
|
+
)
|
|
54
|
+
_TOOL_MSG = (
|
|
55
|
+
"[blocked] '{tool}' is not available in plan mode — it can change state. "
|
|
56
|
+
"Explore with read-only tools, then write your plan to {plan}; the user "
|
|
57
|
+
"approves it before any changes are made."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# The plan-mode instruction rides the USER turn — never the system prompt or
|
|
62
|
+
# the tool list — so toggling the mode leaves the cached prompt prefix
|
|
63
|
+
# byte-identical (DeepSeek prefix caching is full-prefix-match only).
|
|
64
|
+
_MARKER = (
|
|
65
|
+
"[Plan mode — planning only, no code changes. Explore with read-only tools "
|
|
66
|
+
"(read-only bash and web fetches still ask for approval). BRAINSTORM first: "
|
|
67
|
+
"if a decision that is genuinely the user's — scope, a tech choice, an "
|
|
68
|
+
"ambiguous requirement — would shape the plan, ask the single most "
|
|
69
|
+
"important question and stop; one question per turn; do NOT create the "
|
|
70
|
+
"plan file while brainstorming. If the user says to just write the plan, "
|
|
71
|
+
"draft immediately. DRAFT when answers stop changing the design: write the "
|
|
72
|
+
"plan to {path} — a few phases, each with concrete, verifiable steps — "
|
|
73
|
+
"then stop. That file is the only thing you may write; the user approves "
|
|
74
|
+
"the plan before any changes are made.]"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def marker(plan_file: Path) -> str:
|
|
79
|
+
"""The per-turn plan-mode instruction (prepended to each user message)."""
|
|
80
|
+
return _MARKER.format(path=plan_file)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── plan-file parsing ─────────────────────────────────────────────────────────
|
|
84
|
+
# The marker stays format-LIGHT on purpose: dictating markdown shape costs
|
|
85
|
+
# tokens every turn and different models write plans differently. Instead the
|
|
86
|
+
# parser is liberal — a phase line is a markdown heading ('## Verify') OR a
|
|
87
|
+
# top-level numbered item ('2. Verify', bold or plain); its bullets / indented
|
|
88
|
+
# numbered lines are the phase's steps. This is what the future plan→goal
|
|
89
|
+
# handoff reads (goal.parse_plan drops heading lines, so it can't be reused).
|
|
90
|
+
|
|
91
|
+
_PHASE_RX = re.compile(r"^(?:#{1,6}\s+|\*{0,2}\d+[.)]\*{0,2}\s+)\s*(.+?)\s*$")
|
|
92
|
+
_STEP_RX = re.compile(r"^(?:\s+(?:[-*•]|\d+[.)])|[-*•])\s+(.+?)\s*$")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_plan_file(text: str) -> list[tuple[str, list[str]]]:
|
|
96
|
+
"""(phase title, [steps]) pairs from a drafted plan, shape-agnostic.
|
|
97
|
+
|
|
98
|
+
A document title (an H1 with no steps of its own, like '# Fix add()')
|
|
99
|
+
is dropped when real phases carry the steps: any step-less phase is
|
|
100
|
+
pruned as long as at least one phase has steps."""
|
|
101
|
+
phases: list[tuple[str, list[str]]] = []
|
|
102
|
+
for line in text.splitlines():
|
|
103
|
+
s = _STEP_RX.match(line)
|
|
104
|
+
if s and phases:
|
|
105
|
+
phases[-1][1].append(s.group(1).strip())
|
|
106
|
+
continue
|
|
107
|
+
p = _PHASE_RX.match(line)
|
|
108
|
+
if p:
|
|
109
|
+
title = p.group(1).strip().strip("*").rstrip("::").strip()
|
|
110
|
+
if title:
|
|
111
|
+
phases.append((title, []))
|
|
112
|
+
if any(steps for _, steps in phases):
|
|
113
|
+
phases = [(t, st) for t, st in phases if st]
|
|
114
|
+
return phases
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_plan_file(workdir: Path, topic: str = "") -> Path:
|
|
118
|
+
"""Create (and return) a fresh plan file under .rockycode/plans/.
|
|
119
|
+
|
|
120
|
+
The directory self-gitignores (a `*` .gitignore inside it) so drafts never
|
|
121
|
+
pollute the project's git status — delete that file to start committing
|
|
122
|
+
plans. Name: <date>-<topic-slug>.md, falling back to the time when no
|
|
123
|
+
topic was given; an existing non-empty file of the same name gets a -N
|
|
124
|
+
suffix instead of being reused (the turn-end gate watches THIS session's
|
|
125
|
+
file for changes, so it must start empty)."""
|
|
126
|
+
plans = workdir / ".rockycode" / "plans"
|
|
127
|
+
plans.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
gitignore = plans / ".gitignore"
|
|
129
|
+
if not gitignore.exists():
|
|
130
|
+
gitignore.write_text("*\n")
|
|
131
|
+
slug = re.sub(r"[^a-z0-9]+", "-", topic.lower()).strip("-")[:40] or time.strftime("%H%M")
|
|
132
|
+
base = f"{time.strftime('%Y-%m-%d')}-{slug}"
|
|
133
|
+
path, n = plans / f"{base}.md", 1
|
|
134
|
+
while path.exists() and path.stat().st_size > 0:
|
|
135
|
+
n += 1
|
|
136
|
+
path = plans / f"{base}-{n}.md"
|
|
137
|
+
path.touch()
|
|
138
|
+
return path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def gate(tool: str, args: dict, risk: str, plan_file: Path, workdir: Path) -> Verdict:
|
|
142
|
+
"""Classify one tool call under plan mode. *risk* is the registry tier
|
|
143
|
+
('safe'/'moderate'/'risky' — unknown tools default risky upstream)."""
|
|
144
|
+
if risk == "safe":
|
|
145
|
+
return PASS
|
|
146
|
+
if tool in ("write_file", "edit_file"):
|
|
147
|
+
if _is_plan_file(args.get("path"), plan_file, workdir):
|
|
148
|
+
return Verdict("allow", "")
|
|
149
|
+
return Verdict("deny", _WRITE_MSG.format(plan=plan_file))
|
|
150
|
+
if tool == "bash":
|
|
151
|
+
cmd = args.get("command")
|
|
152
|
+
if isinstance(cmd, str) and is_read_only_command(cmd):
|
|
153
|
+
return PASS
|
|
154
|
+
return Verdict("deny", _BASH_MSG.format(plan=plan_file))
|
|
155
|
+
if tool in _WORLD_READS:
|
|
156
|
+
return PASS
|
|
157
|
+
return Verdict("deny", _TOOL_MSG.format(tool=tool, plan=plan_file))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_plan_file(path, plan_file: Path, workdir: Path) -> bool:
|
|
161
|
+
"""True iff *path* RESOLVES to exactly the plan file — `..`, symlinks, and
|
|
162
|
+
absolute aliases all collapse first, so the carve-out can't be retargeted.
|
|
163
|
+
Any malformed/missing path fails safe (False → deny)."""
|
|
164
|
+
if not isinstance(path, str) or not path:
|
|
165
|
+
return False
|
|
166
|
+
p = Path(path)
|
|
167
|
+
if not p.is_absolute():
|
|
168
|
+
p = workdir / p
|
|
169
|
+
try:
|
|
170
|
+
return p.resolve() == plan_file.resolve()
|
|
171
|
+
except OSError:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── read-only bash classification ────────────────────────────────────────────
|
|
176
|
+
# Whitelist, not blacklist: only commands we KNOW don't mutate may pass to the
|
|
177
|
+
# ask flow; everything unrecognized is denied. Rejected outright: redirects,
|
|
178
|
+
# command chaining, substitution, and multi-line — so pipes are the only
|
|
179
|
+
# composition, and every pipe segment must itself be read-only.
|
|
180
|
+
|
|
181
|
+
_META = re.compile(r">|;|&|\$\(|`|<\(")
|
|
182
|
+
# awk/find can execute subcommands without any shell metacharacter
|
|
183
|
+
_EMBEDDED_EXEC = re.compile(r"\bsystem\s*\(")
|
|
184
|
+
|
|
185
|
+
_READ_CMDS = frozenset({
|
|
186
|
+
"ls", "cat", "head", "tail", "wc", "stat", "file", "du", "tree", "pwd",
|
|
187
|
+
"which", "grep", "rg", "fd", "sort", "uniq", "cut", "diff", "realpath",
|
|
188
|
+
"basename", "dirname", "date", "nl", "column", "od", "strings",
|
|
189
|
+
"find", "sed", "awk", "git",
|
|
190
|
+
})
|
|
191
|
+
_GIT_READ_SUBS = frozenset({
|
|
192
|
+
"log", "diff", "show", "status", "blame", "shortlog", "describe",
|
|
193
|
+
"ls-files", "rev-parse", "grep", "reflog",
|
|
194
|
+
})
|
|
195
|
+
_FIND_WRITE_FLAGS = frozenset({
|
|
196
|
+
"-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprintf", "-fls",
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def is_read_only_command(command: str) -> bool:
|
|
201
|
+
"""True iff *command* is a whitelisted read-only invocation (it still goes
|
|
202
|
+
through the normal ask flow — this never auto-allows)."""
|
|
203
|
+
cmd = command.strip()
|
|
204
|
+
if not cmd or "\n" in cmd or _META.search(cmd) or _EMBEDDED_EXEC.search(cmd):
|
|
205
|
+
return False
|
|
206
|
+
return all(_segment_read_only(seg) for seg in cmd.split("|"))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _segment_read_only(segment: str) -> bool:
|
|
210
|
+
tokens = segment.split()
|
|
211
|
+
# skip leading VAR=value assignments (FOO=1 git log)
|
|
212
|
+
while tokens and re.match(r"^\w+=", tokens[0]):
|
|
213
|
+
tokens = tokens[1:]
|
|
214
|
+
if not tokens:
|
|
215
|
+
return False
|
|
216
|
+
name = tokens[0].rsplit("/", 1)[-1] # /usr/bin/git → git
|
|
217
|
+
if name not in _READ_CMDS:
|
|
218
|
+
return False
|
|
219
|
+
if name == "git":
|
|
220
|
+
return _git_read_only(tokens[1:])
|
|
221
|
+
if name == "find":
|
|
222
|
+
return not any(t in _FIND_WRITE_FLAGS for t in tokens[1:])
|
|
223
|
+
if name == "sed":
|
|
224
|
+
return not any(t.startswith("-i") for t in tokens[1:])
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _git_read_only(tokens: list[str]) -> bool:
|
|
229
|
+
"""git with a read-only subcommand. `-c` (can define alias/hook-ish config)
|
|
230
|
+
and `--output`/`--ext-diff` (write a file / run a command) are rejected even
|
|
231
|
+
on read subcommands."""
|
|
232
|
+
sub = None
|
|
233
|
+
i = 0
|
|
234
|
+
while i < len(tokens):
|
|
235
|
+
t = tokens[i]
|
|
236
|
+
if t in ("-c",) or t.startswith("--output") or t == "--ext-diff":
|
|
237
|
+
return False
|
|
238
|
+
if t in ("-C", "--git-dir", "--work-tree"):
|
|
239
|
+
i += 2 # flag takes a value
|
|
240
|
+
continue
|
|
241
|
+
if t.startswith("-"):
|
|
242
|
+
i += 1
|
|
243
|
+
continue
|
|
244
|
+
sub = t
|
|
245
|
+
break
|
|
246
|
+
if sub is None or sub not in _GIT_READ_SUBS:
|
|
247
|
+
return False
|
|
248
|
+
# write-capable flags can appear after the subcommand too (git log --output=x)
|
|
249
|
+
return not any(t.startswith("--output") or t == "--ext-diff" for t in tokens[i:])
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Provider profiles: switch which OpenAI-compatible endpoint + model rocky uses.
|
|
2
|
+
|
|
3
|
+
Anti-glue by design. Every provider here (DeepSeek, MiniMax, GLM, Kimi, …)
|
|
4
|
+
speaks the OpenAI chat-completions protocol, so the OpenAI SDK is the ONLY
|
|
5
|
+
compatibility layer — rocky never writes a per-provider adapter. A provider is
|
|
6
|
+
DATA, not code: a base_url, some model ids, which env var holds its key, and
|
|
7
|
+
which reasoning-param shape it wants. Adding one is a few lines here or in
|
|
8
|
+
`~/.rockycode/providers.toml`; a non-OpenAI-compatible provider is unsupported.
|
|
9
|
+
|
|
10
|
+
Regions: MiniMax / Kimi / GLM run SEPARATE international and China endpoints —
|
|
11
|
+
different base_url AND different key. That's modeled as multiple `endpoints` on
|
|
12
|
+
one provider (models shared), not duplicated entries. Each endpoint is pickable
|
|
13
|
+
as `<provider>` (the default) or `<provider>-<region>` (e.g. `kimi-cn`).
|
|
14
|
+
|
|
15
|
+
Keys are rocky-OWNED per endpoint: `ROCKYCODE_<NAME>_API_KEY` (intl) /
|
|
16
|
+
`ROCKYCODE_<NAME>_CN_API_KEY` (cn) — never the ambient `MINIMAX_API_KEY` etc.
|
|
17
|
+
The built-in `deepseek` reads the existing ROCKYCODE_API_KEY, so current setups
|
|
18
|
+
keep working with no new key.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from rockycode.onboarding import BASE_URL_ENV, DEFAULT_BASE_URL, KEY_ENV
|
|
28
|
+
|
|
29
|
+
_HOME = Path(os.environ.get("ROCKYCODE_HOME") or Path.home() / ".rockycode")
|
|
30
|
+
PROVIDERS_TOML = _HOME / "providers.toml"
|
|
31
|
+
_PLACEHOLDERS = {"", "replace-me", "sk-replace-me", "your-api-key"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Endpoint:
|
|
36
|
+
# `eid` is the EXPLICIT /model token — no magic default. Regional endpoints
|
|
37
|
+
# are spelled out (kimi-en / kimi-cn / minimax-en / minimax-cn), and where a
|
|
38
|
+
# provider's international arm is a different brand it gets that name (GLM's
|
|
39
|
+
# is z.ai → `zai`). `key_env` matches: ROCKYCODE_<EID_UPPER>_API_KEY.
|
|
40
|
+
eid: str
|
|
41
|
+
base_url: str
|
|
42
|
+
key_env: str
|
|
43
|
+
|
|
44
|
+
def key(self) -> Optional[str]:
|
|
45
|
+
v = (os.getenv(self.key_env) or "").strip()
|
|
46
|
+
return v if v and v.lower() not in _PLACEHOLDERS else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Provider:
|
|
51
|
+
name: str
|
|
52
|
+
models: list[str]
|
|
53
|
+
endpoints: list[Endpoint]
|
|
54
|
+
reasoning: str = "openai" # deepseek | openai | none
|
|
55
|
+
tools: str = "native" # native | off
|
|
56
|
+
label: str = ""
|
|
57
|
+
builtin: bool = True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Choice:
|
|
62
|
+
"""A flat, pickable (provider, endpoint, model). `id` is `<eid>:<model>`
|
|
63
|
+
(e.g. kimi-cn:kimi-k3 / zai:glm-5.2); `configured` = its key is set, which
|
|
64
|
+
is how the picker stays short (show only what you've keyed)."""
|
|
65
|
+
provider: Provider
|
|
66
|
+
endpoint: Endpoint
|
|
67
|
+
model: str
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def prov_id(self) -> str:
|
|
71
|
+
return self.endpoint.eid
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def id(self) -> str:
|
|
75
|
+
return f"{self.endpoint.eid}:{self.model}"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def configured(self) -> bool:
|
|
79
|
+
return self.endpoint.key() is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _builtins() -> dict[str, Provider]:
|
|
83
|
+
# NOTE: regional base_urls are best-effort — verify per provider when you
|
|
84
|
+
# first test one; correcting a URL is a one-line data edit here or in TOML.
|
|
85
|
+
return {
|
|
86
|
+
"deepseek": Provider(
|
|
87
|
+
name="deepseek", models=["deepseek-v4-pro", "deepseek-v4-flash"],
|
|
88
|
+
endpoints=[Endpoint("deepseek",
|
|
89
|
+
(os.getenv(BASE_URL_ENV) or "").strip() or DEFAULT_BASE_URL,
|
|
90
|
+
KEY_ENV)],
|
|
91
|
+
reasoning="deepseek", label="DeepSeek V4 — rocky's home model"),
|
|
92
|
+
"minimax": Provider(
|
|
93
|
+
name="minimax", models=["minimax-m3"],
|
|
94
|
+
endpoints=[
|
|
95
|
+
Endpoint("minimax-en", "https://api.minimaxi.chat/v1", "ROCKYCODE_MINIMAX_EN_API_KEY"),
|
|
96
|
+
Endpoint("minimax-cn", "https://api.minimax.chat/v1", "ROCKYCODE_MINIMAX_CN_API_KEY"),
|
|
97
|
+
],
|
|
98
|
+
reasoning="openai", label="MiniMax M3"),
|
|
99
|
+
"kimi": Provider(
|
|
100
|
+
name="kimi", models=["kimi-k3"],
|
|
101
|
+
endpoints=[
|
|
102
|
+
Endpoint("kimi-en", "https://api.moonshot.ai/v1", "ROCKYCODE_KIMI_EN_API_KEY"),
|
|
103
|
+
Endpoint("kimi-cn", "https://api.moonshot.cn/v1", "ROCKYCODE_KIMI_CN_API_KEY"),
|
|
104
|
+
],
|
|
105
|
+
reasoning="none", label="Kimi / Moonshot"),
|
|
106
|
+
"glm": Provider(
|
|
107
|
+
name="glm", models=["glm-5.2"],
|
|
108
|
+
endpoints=[
|
|
109
|
+
# international arm is a different brand — z.ai, not "glm"
|
|
110
|
+
Endpoint("zai", "https://api.z.ai/api/paas/v4", "ROCKYCODE_ZAI_EN_API_KEY"),
|
|
111
|
+
Endpoint("glm-cn", "https://open.bigmodel.cn/api/paas/v4", "ROCKYCODE_GLM_CN_API_KEY"),
|
|
112
|
+
],
|
|
113
|
+
reasoning="none", label="GLM (z.ai intl / bigmodel.cn)"),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_toml(path: Path) -> dict[str, Provider]:
|
|
118
|
+
try:
|
|
119
|
+
import tomllib
|
|
120
|
+
data = tomllib.loads(path.read_text())
|
|
121
|
+
except FileNotFoundError:
|
|
122
|
+
return {}
|
|
123
|
+
except Exception: # noqa: BLE001 — a broken file must not crash startup
|
|
124
|
+
return {}
|
|
125
|
+
out: dict[str, Provider] = {}
|
|
126
|
+
for name, cfg in (data.get("providers") or data).items():
|
|
127
|
+
if not isinstance(cfg, dict):
|
|
128
|
+
continue
|
|
129
|
+
models = cfg.get("models") or ([cfg["model"]] if cfg.get("model") else [])
|
|
130
|
+
# endpoints: explicit list, or a single base_url/key_env pair. Each
|
|
131
|
+
# endpoint's `id` is its /model token; key_env defaults to
|
|
132
|
+
# ROCKYCODE_<ID_UPPER>_API_KEY.
|
|
133
|
+
def _kenv(eid: str) -> str:
|
|
134
|
+
return f"ROCKYCODE_{eid.upper().replace('-', '_')}_API_KEY"
|
|
135
|
+
eps_cfg = cfg.get("endpoints")
|
|
136
|
+
if eps_cfg:
|
|
137
|
+
eps = [Endpoint(e.get("id", name), e["base_url"],
|
|
138
|
+
e.get("key_env", _kenv(e.get("id", name))))
|
|
139
|
+
for e in eps_cfg if e.get("base_url")]
|
|
140
|
+
elif cfg.get("base_url"):
|
|
141
|
+
eps = [Endpoint(cfg.get("id", name), cfg["base_url"],
|
|
142
|
+
cfg.get("key_env", _kenv(cfg.get("id", name))))]
|
|
143
|
+
else:
|
|
144
|
+
continue
|
|
145
|
+
if not models or not eps:
|
|
146
|
+
continue
|
|
147
|
+
out[name] = Provider(name=name, models=list(models), endpoints=eps,
|
|
148
|
+
reasoning=cfg.get("reasoning", "openai"),
|
|
149
|
+
tools=cfg.get("tools", "native"),
|
|
150
|
+
label=cfg.get("label", ""), builtin=False)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def discover() -> dict[str, Provider]:
|
|
155
|
+
providers = _builtins()
|
|
156
|
+
providers.update(_parse_toml(PROVIDERS_TOML))
|
|
157
|
+
return providers
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def choices() -> list[Choice]:
|
|
161
|
+
"""Every (provider, endpoint, model) as a flat pickable list."""
|
|
162
|
+
return [Choice(p, e, m) for p in discover().values()
|
|
163
|
+
for e in p.endpoints for m in p.models]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def configured_choices() -> list[Choice]:
|
|
167
|
+
"""Only choices whose key is set — the SHORT list the picker shows by
|
|
168
|
+
default, so an EN/CN catalog of ~12 doesn't scroll. deepseek is always
|
|
169
|
+
included (it rides the default ROCKYCODE_API_KEY)."""
|
|
170
|
+
out = [c for c in choices() if c.configured or c.provider.name == "deepseek"]
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def resolve(spec: str) -> Optional[tuple[Provider, Endpoint, str]]:
|
|
175
|
+
"""Resolve a /model arg to (provider, endpoint, model).
|
|
176
|
+
|
|
177
|
+
Forms: an endpoint id (`deepseek`, `kimi-cn`, `zai`), `endpoint:model`
|
|
178
|
+
(`kimi-cn:k3`, `zai:glm`), or a unique bare model substring. Returns None
|
|
179
|
+
if the endpoint is unknown or the model substring is ambiguous.
|
|
180
|
+
"""
|
|
181
|
+
spec = spec.strip()
|
|
182
|
+
eid_part, _, model_part = spec.replace(":", " ").partition(" ")
|
|
183
|
+
eid_part, model_part = eid_part.strip(), model_part.strip()
|
|
184
|
+
|
|
185
|
+
by_eid = {e.eid: (p, e) for p in discover().values() for e in p.endpoints}
|
|
186
|
+
if eid_part in by_eid:
|
|
187
|
+
p, e = by_eid[eid_part]
|
|
188
|
+
if not model_part:
|
|
189
|
+
return p, e, p.models[0]
|
|
190
|
+
hits = [m for m in p.models if model_part.lower() in m.lower()]
|
|
191
|
+
return (p, e, hits[0]) if len(hits) == 1 else None
|
|
192
|
+
|
|
193
|
+
# bare model substring — must be unique across all providers (first endpoint)
|
|
194
|
+
matches = [(p, p.endpoints[0], m) for p in discover().values() for m in p.models
|
|
195
|
+
if spec.lower() in m.lower()]
|
|
196
|
+
return matches[0] if len(matches) == 1 else None
|