aiscript 1.0.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.
- aiscript/__init__.py +3 -0
- aiscript/__main__.py +8 -0
- aiscript/builder.py +66 -0
- aiscript/cli.py +163 -0
- aiscript/config.py +268 -0
- aiscript/errors.py +11 -0
- aiscript/init_cmd.py +108 -0
- aiscript/listing.py +104 -0
- aiscript/llm.py +259 -0
- aiscript/paths.py +77 -0
- aiscript/reviewer.py +491 -0
- aiscript/runner.py +43 -0
- aiscript-1.0.0.dist-info/METADATA +156 -0
- aiscript-1.0.0.dist-info/RECORD +17 -0
- aiscript-1.0.0.dist-info/WHEEL +4 -0
- aiscript-1.0.0.dist-info/entry_points.txt +3 -0
- aiscript-1.0.0.dist-info/licenses/LICENSE +21 -0
aiscript/reviewer.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Static and LLM safety review of generated scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Iterator
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from aiscript.config import ProviderConfig, is_local
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ReviewResult:
|
|
17
|
+
risky: bool
|
|
18
|
+
reasons: list[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --------------------------------------------------------------------------- #
|
|
22
|
+
# Static patterns
|
|
23
|
+
# --------------------------------------------------------------------------- #
|
|
24
|
+
|
|
25
|
+
PRIVILEGE = "Privilege escalation"
|
|
26
|
+
FILESYSTEM = "Filesystem"
|
|
27
|
+
NETWORK = "Networking"
|
|
28
|
+
REMOTE_EXEC = "Remote code execution"
|
|
29
|
+
|
|
30
|
+
_SYS_DIRS = r"(?:etc|usr|bin|sbin|boot|lib|lib32|lib64|var(?!/tmp\b)|opt)"
|
|
31
|
+
_SEP = r"[^\n;|&]*" # stay within one simple command
|
|
32
|
+
_CMD = r"(?<![\w./$-])" # start of a command word (not part of a path/name)
|
|
33
|
+
|
|
34
|
+
_FLAGS = re.IGNORECASE
|
|
35
|
+
_Pat = tuple[str, "re.Pattern[str]", str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _p(category: str, pattern: str, label: str, flags: int = 0) -> _Pat:
|
|
39
|
+
return (category, re.compile(pattern, flags), label)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_BASH: list[_Pat] = [
|
|
43
|
+
# --- privilege escalation ---
|
|
44
|
+
_p(PRIVILEGE, rf"{_CMD}(?:sudo|doas|pkexec)(?![\w-])", "sudo/doas/pkexec"),
|
|
45
|
+
_p(PRIVILEGE, r"(?:^|[;&|(]\s*|\b(?:then|do|else)\s+)su(?:\s|$)", "su"),
|
|
46
|
+
# --- filesystem ---
|
|
47
|
+
_p(
|
|
48
|
+
FILESYSTEM,
|
|
49
|
+
rf"{_CMD}rm\s+(?:{_SEP}\s)?(?:-[A-Za-z]*[rR][A-Za-z]*|--recursive)(?=\s|$)",
|
|
50
|
+
"recursive rm",
|
|
51
|
+
),
|
|
52
|
+
_p(
|
|
53
|
+
FILESYSTEM,
|
|
54
|
+
rf"{_CMD}rm\b{_SEP}\s[\"']?(?:/|~|\$HOME|\$\{{HOME\}})(?:/?\*)?[\"']?(?=\s|$)",
|
|
55
|
+
"rm on root/home",
|
|
56
|
+
),
|
|
57
|
+
_p(
|
|
58
|
+
FILESYSTEM,
|
|
59
|
+
rf"{_CMD}(?:rm|rmdir|shred|touch|mkdir|ln|tee|install|sed\s+-i\S*|truncate)\b"
|
|
60
|
+
rf"{_SEP}\s[\"']?/{_SYS_DIRS}(?:/|[\"']?(?:\s|$))",
|
|
61
|
+
"modifies system directory",
|
|
62
|
+
),
|
|
63
|
+
_p(
|
|
64
|
+
FILESYSTEM,
|
|
65
|
+
rf"{_CMD}(?:mv|cp)\b{_SEP}\s[\"']?/{_SYS_DIRS}(?:/\S*)?[\"']?\s*(?:$|[;&|])",
|
|
66
|
+
"mv/cp into system directory",
|
|
67
|
+
),
|
|
68
|
+
_p(
|
|
69
|
+
FILESYSTEM,
|
|
70
|
+
rf">>?\s*[\"']?(?:/{_SYS_DIRS}(?:/|[\"']?(?:\s|$))|/dev/(?:sd|hd|vd|nvme|mmcblk))",
|
|
71
|
+
"redirect into system path/device",
|
|
72
|
+
),
|
|
73
|
+
_p(
|
|
74
|
+
FILESYSTEM,
|
|
75
|
+
rf"{_CMD}(?:chmod|chown|chgrp)\b{_SEP}\s(?:-[A-Za-z]*R|--recursive)",
|
|
76
|
+
"recursive chmod/chown",
|
|
77
|
+
),
|
|
78
|
+
_p(
|
|
79
|
+
FILESYSTEM,
|
|
80
|
+
rf"{_CMD}(?:mkfs(?:\.\w+)?|fdisk|sfdisk|cfdisk|parted|wipefs|shred)(?![\w-])",
|
|
81
|
+
"disk/format tool",
|
|
82
|
+
),
|
|
83
|
+
_p(FILESYSTEM, rf"{_CMD}dd\b{_SEP}\bof=(?!/dev/null)", "dd of="),
|
|
84
|
+
_p(FILESYSTEM, rf"{_CMD}find\b{_SEP}\s-delete\b", "find -delete"),
|
|
85
|
+
_p(FILESYSTEM, rf"{_CMD}crontab\b(?!\s+-l\b)", "crontab"),
|
|
86
|
+
_p(FILESYSTEM, rf"{_CMD}systemctl\b{_SEP}\b(?:enable|disable|stop|mask)\b", "systemctl"),
|
|
87
|
+
# --- networking ---
|
|
88
|
+
_p(NETWORK, rf"{_CMD}(?:iptables|ip6tables|nft|ufw|firewall-cmd)(?![\w-])", "firewall"),
|
|
89
|
+
_p(
|
|
90
|
+
NETWORK,
|
|
91
|
+
rf"{_CMD}ip\s+(?:-\w+\s+)*(?:addr|address|link|route|rule|neigh)\s+(?:add|del|delete|set|flush|replace|change)\b",
|
|
92
|
+
"ip configuration change",
|
|
93
|
+
),
|
|
94
|
+
_p(NETWORK, rf"{_CMD}ifconfig\s+\S+\s+\S+", "ifconfig change"),
|
|
95
|
+
_p(
|
|
96
|
+
NETWORK,
|
|
97
|
+
rf"{_CMD}nmcli\b{_SEP}\b(?:add|modify|mod|delete|up|down|connect|disconnect|reload|radio|networking)\b",
|
|
98
|
+
"nmcli change",
|
|
99
|
+
),
|
|
100
|
+
_p(NETWORK, rf"{_CMD}route\s+(?:add|del|delete|change|flush)\b", "route change"),
|
|
101
|
+
_p(
|
|
102
|
+
NETWORK,
|
|
103
|
+
r"(?:>>?|\btee\b|\bsed\s+-i\S*)[^\n;|&]*/etc/(?:hosts|resolv\.conf)\b"
|
|
104
|
+
r"|\b(?:mv|cp)\b[^\n;|&]*\s[\"']?/etc/(?:hosts|resolv\.conf)[\"']?\s*(?:$|[;&|])",
|
|
105
|
+
"edits /etc/hosts or resolv.conf",
|
|
106
|
+
),
|
|
107
|
+
# --- remote code execution ---
|
|
108
|
+
_p(
|
|
109
|
+
REMOTE_EXEC,
|
|
110
|
+
r"\b(?:curl|wget|fetch)\b[^\n]*\|\s*(?:sudo\s+)?(?:ba|z|da|k|fi)?sh\b",
|
|
111
|
+
"pipe download to shell",
|
|
112
|
+
),
|
|
113
|
+
_p(
|
|
114
|
+
REMOTE_EXEC,
|
|
115
|
+
r"\b(?:ba|z|da|k)?sh\s+(?:-c\s+)?[\"']?<?\(?\$?\(?\s*(?:curl|wget)\b",
|
|
116
|
+
"shell executes download",
|
|
117
|
+
),
|
|
118
|
+
_p(REMOTE_EXEC, r"\beval\s+[\"']?\$\(\s*(?:curl|wget)\b", "eval of download"),
|
|
119
|
+
_p(
|
|
120
|
+
REMOTE_EXEC,
|
|
121
|
+
r"\b(?:curl|wget)\b[^\n]*\|\s*(?:sudo\s+)?(?:python[\d.]*|perl|ruby|node)\b",
|
|
122
|
+
"pipe download to interpreter",
|
|
123
|
+
),
|
|
124
|
+
_p(
|
|
125
|
+
REMOTE_EXEC,
|
|
126
|
+
r"\bbase64\b[^\n]*\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|(?<![\w-])eval\b",
|
|
127
|
+
"eval / decoded payload run by a shell",
|
|
128
|
+
),
|
|
129
|
+
# --- code hidden inside an interpreter one-liner ---
|
|
130
|
+
_p(
|
|
131
|
+
FILESYSTEM,
|
|
132
|
+
r"\b(?:python[\d.]*|perl|ruby|node)\s+(?:-\w+\s+)*-[ce]\b[^\n]*"
|
|
133
|
+
r"(?:rmtree|unlink|rmdir|remove|os\.system|subprocess|exec|spawn)",
|
|
134
|
+
"interpreter one-liner that deletes or spawns commands",
|
|
135
|
+
),
|
|
136
|
+
_p(FILESYSTEM, rf"{_CMD}xargs\b{_SEP}\brm\b", "xargs rm"),
|
|
137
|
+
_p(FILESYSTEM, rf"{_CMD}find\b{_SEP}-exec(?:dir)?\s+(?:rm|shred)\b", "find -exec rm"),
|
|
138
|
+
_p(FILESYSTEM, r"--no-preserve-root", "--no-preserve-root"),
|
|
139
|
+
# The command name itself comes from a variable, so no other pattern can see what runs.
|
|
140
|
+
_p(
|
|
141
|
+
REMOTE_EXEC,
|
|
142
|
+
r"(?:^|[;&|(]\s*|\b(?:then|do|else)\s+)[\"']?\$\{?[A-Za-z_]\w*\}?[\"']?(?:\s|$)",
|
|
143
|
+
"command name taken from a variable",
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
_PS_SYS = (
|
|
148
|
+
r"(?:[A-Za-z]:[\\/]+Windows\b|[A-Za-z]:[\\/]+Program\s*Files|"
|
|
149
|
+
r"\$env:(?:windir|SystemRoot|ProgramFiles|ProgramFiles\(x86\)))"
|
|
150
|
+
)
|
|
151
|
+
_PS: list[_Pat] = [
|
|
152
|
+
# --- privilege escalation ---
|
|
153
|
+
_p(PRIVILEGE, r"-Verb\s+RunAs\b", "Start-Process -Verb RunAs", _FLAGS),
|
|
154
|
+
_p(PRIVILEGE, r"(?<![\w-])runas(?:\.exe)?(?![\w-])", "runas", _FLAGS),
|
|
155
|
+
_p(PRIVILEGE, r"\bSet-ExecutionPolicy\b", "Set-ExecutionPolicy", _FLAGS),
|
|
156
|
+
_p(PRIVILEGE, r"(?<![\w-])(?:sudo|gsudo)(?![\w-])", "sudo", _FLAGS),
|
|
157
|
+
# --- filesystem ---
|
|
158
|
+
_p(
|
|
159
|
+
FILESYSTEM,
|
|
160
|
+
r"(?<![\w-])(?:Remove-Item|ri|rm|rmdir|rd|del|erase)\b[^\n;|]*\s-(?:Recurse|r)\b",
|
|
161
|
+
"recursive Remove-Item",
|
|
162
|
+
_FLAGS,
|
|
163
|
+
),
|
|
164
|
+
_p(
|
|
165
|
+
FILESYSTEM,
|
|
166
|
+
r"(?<![\w-])(?:Remove-Item|ri|rm|rmdir|rd|del|erase|Set-Content|Add-Content|Out-File|"
|
|
167
|
+
r"Copy-Item|Move-Item|New-Item|Rename-Item|Tee-Object|Set-Acl)\b"
|
|
168
|
+
rf"[^\n;|]*{_PS_SYS}|>>?\s*[\"']?{_PS_SYS}",
|
|
169
|
+
"modifies Windows system directory",
|
|
170
|
+
_FLAGS,
|
|
171
|
+
),
|
|
172
|
+
_p(
|
|
173
|
+
FILESYSTEM,
|
|
174
|
+
r"\b(?:Format-Volume|Clear-Disk|Initialize-Disk|Remove-Partition|New-Partition|diskpart)\b",
|
|
175
|
+
"disk/format tool",
|
|
176
|
+
_FLAGS,
|
|
177
|
+
),
|
|
178
|
+
_p(FILESYSTEM, r"(?<![\w-])format(?:\.com)?\s+[A-Za-z]:", "format drive", _FLAGS),
|
|
179
|
+
_p(
|
|
180
|
+
FILESYSTEM,
|
|
181
|
+
r"(?<![\w-])reg(?:\.exe)?\s+(?:add|delete|import)\b",
|
|
182
|
+
"registry modification",
|
|
183
|
+
_FLAGS,
|
|
184
|
+
),
|
|
185
|
+
_p(
|
|
186
|
+
FILESYSTEM,
|
|
187
|
+
r"\b(?:Set|New|Remove|Clear)-Item(?:Property)?\b[^\n]*\bHK(?:LM|EY_LOCAL_MACHINE|CR):",
|
|
188
|
+
"HKLM registry modification",
|
|
189
|
+
_FLAGS,
|
|
190
|
+
),
|
|
191
|
+
_p(
|
|
192
|
+
FILESYSTEM,
|
|
193
|
+
r"\b(?:Stop|Set|Disable|Remove|New)-Service\b|\bschtasks(?:\.exe)?\s+/(?:create|delete|change)\b"
|
|
194
|
+
r"|\b(?:Register|Unregister|Set)-ScheduledTask\b",
|
|
195
|
+
"service/scheduled task change",
|
|
196
|
+
_FLAGS,
|
|
197
|
+
),
|
|
198
|
+
# --- networking ---
|
|
199
|
+
_p(
|
|
200
|
+
NETWORK,
|
|
201
|
+
r"\b(?:New|Set|Remove|Disable|Enable)-NetFirewall\w*|\b(?:New|Set|Remove)-NetIPAddress\b"
|
|
202
|
+
r"|\b(?:New|Set|Remove)-NetRoute\b|\bSet-DnsClientServerAddress\b|\b(?:Disable|Enable)-NetAdapter\b",
|
|
203
|
+
"network configuration cmdlet",
|
|
204
|
+
_FLAGS,
|
|
205
|
+
),
|
|
206
|
+
_p(NETWORK, r"(?<![\w-])netsh(?:\.exe)?(?![\w-])", "netsh", _FLAGS),
|
|
207
|
+
_p(NETWORK, r"(?<![\w-])route(?:\.exe)?\s+(?:add|delete|change)\b", "route change", _FLAGS),
|
|
208
|
+
_p(
|
|
209
|
+
NETWORK,
|
|
210
|
+
r"(?:>>?|Set-Content|Add-Content|Out-File)[^\n;|]*drivers[\\/]+etc[\\/]+hosts\b",
|
|
211
|
+
"edits hosts file",
|
|
212
|
+
_FLAGS,
|
|
213
|
+
),
|
|
214
|
+
# --- remote code execution ---
|
|
215
|
+
_p(REMOTE_EXEC, r"\bInvoke-Expression\b", "Invoke-Expression", _FLAGS),
|
|
216
|
+
_p(REMOTE_EXEC, r"(?<![\w-])iex(?![\w-])", "iex", _FLAGS),
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _strip_comment(line: str, shell: str) -> str:
|
|
221
|
+
"""Remove a trailing ``# comment`` that is outside quotes (simple heuristic)."""
|
|
222
|
+
esc = "`" if shell == "powershell" else "\\"
|
|
223
|
+
quote = ""
|
|
224
|
+
i = 0
|
|
225
|
+
while i < len(line):
|
|
226
|
+
ch = line[i]
|
|
227
|
+
if ch == esc and quote != "'":
|
|
228
|
+
i += 2
|
|
229
|
+
continue
|
|
230
|
+
if quote:
|
|
231
|
+
if ch == quote:
|
|
232
|
+
quote = ""
|
|
233
|
+
elif ch in "\"'":
|
|
234
|
+
quote = ch
|
|
235
|
+
elif ch == "#" and (i == 0 or line[i - 1].isspace()):
|
|
236
|
+
return line[:i]
|
|
237
|
+
i += 1
|
|
238
|
+
return line
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _logical_lines(script: str, shell: str) -> Iterator[tuple[int, str]]:
|
|
242
|
+
"""Yield ``(first_lineno, text)`` with continuation lines joined.
|
|
243
|
+
|
|
244
|
+
Backslash (bash) and backtick (PowerShell) continuations are merged so that a command split
|
|
245
|
+
over several lines is scanned as one.
|
|
246
|
+
"""
|
|
247
|
+
cont = "`" if shell == "powershell" else "\\"
|
|
248
|
+
pending: list[str] = []
|
|
249
|
+
start = 0
|
|
250
|
+
for n, raw in enumerate(script.splitlines(), 1):
|
|
251
|
+
if not pending:
|
|
252
|
+
start = n
|
|
253
|
+
if raw.endswith(cont) and not raw.endswith(cont * 2):
|
|
254
|
+
pending.append(raw[:-1] + " ")
|
|
255
|
+
continue
|
|
256
|
+
pending.append(raw)
|
|
257
|
+
yield start, "".join(pending)
|
|
258
|
+
pending = []
|
|
259
|
+
if pending:
|
|
260
|
+
yield start, "".join(pending)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _code_lines(script: str, shell: str) -> Iterator[tuple[int, str]]:
|
|
264
|
+
"""Yield ``(lineno, code)`` for non-comment lines."""
|
|
265
|
+
in_block = False
|
|
266
|
+
for n, raw in _logical_lines(script, shell):
|
|
267
|
+
line = raw
|
|
268
|
+
if shell == "powershell":
|
|
269
|
+
if in_block:
|
|
270
|
+
if "#>" in line:
|
|
271
|
+
in_block = False
|
|
272
|
+
line = line.split("#>", 1)[1]
|
|
273
|
+
else:
|
|
274
|
+
continue
|
|
275
|
+
while "<#" in line:
|
|
276
|
+
head, _, rest = line.partition("<#")
|
|
277
|
+
if "#>" in rest:
|
|
278
|
+
line = head + rest.split("#>", 1)[1]
|
|
279
|
+
else:
|
|
280
|
+
line = head
|
|
281
|
+
in_block = True
|
|
282
|
+
code = _strip_comment(line, shell).strip()
|
|
283
|
+
if code:
|
|
284
|
+
yield n, code
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# A plain echo/printf of text (no redirect, pipe, chaining or substitution) runs nothing.
|
|
288
|
+
_INERT_BASH = re.compile(r"^(?:echo|printf)\s(?!.*\$\()[^|;&`<>]*$")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def printable(text: str) -> str:
|
|
292
|
+
"""Show control characters (ANSI escapes, carriage returns, ...) as escapes, not live."""
|
|
293
|
+
out: list[str] = []
|
|
294
|
+
for ch in text:
|
|
295
|
+
if ch == "\t" or (ch.isprintable() and ch != "\x7f"):
|
|
296
|
+
out.append(ch)
|
|
297
|
+
elif ord(ch) < 0x100:
|
|
298
|
+
out.append(f"\\x{ord(ch):02x}")
|
|
299
|
+
else:
|
|
300
|
+
out.append(f"\\u{ord(ch):04x}")
|
|
301
|
+
return "".join(out)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _short(code: str, limit: int = 100) -> str:
|
|
305
|
+
return code if len(code) <= limit else code[: limit - 1] + "…"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def static_review(script: str, shell: str) -> ReviewResult:
|
|
309
|
+
"""Regex scan; one reason per (category, line) match."""
|
|
310
|
+
if shell == "powershell":
|
|
311
|
+
patterns = _PS
|
|
312
|
+
elif shell == "bash":
|
|
313
|
+
patterns = _BASH
|
|
314
|
+
else:
|
|
315
|
+
patterns = _BASH + _PS
|
|
316
|
+
reasons: list[str] = []
|
|
317
|
+
for n, code in _code_lines(script, shell):
|
|
318
|
+
if shell == "bash" and _INERT_BASH.match(code):
|
|
319
|
+
continue
|
|
320
|
+
seen: set[str] = set()
|
|
321
|
+
for category, regex, _label in patterns:
|
|
322
|
+
if category in seen or not regex.search(code):
|
|
323
|
+
continue
|
|
324
|
+
seen.add(category)
|
|
325
|
+
reasons.append(f"{category} (line {n}): {_short(printable(code))}")
|
|
326
|
+
return ReviewResult(bool(reasons), reasons)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --------------------------------------------------------------------------- #
|
|
330
|
+
# LLM review
|
|
331
|
+
# --------------------------------------------------------------------------- #
|
|
332
|
+
|
|
333
|
+
_LLM_SYSTEM = (
|
|
334
|
+
"You are a security reviewer for shell scripts that were auto-generated and are "
|
|
335
|
+
"about to be run on the user's machine. Decide whether the script is risky. A "
|
|
336
|
+
"script is risky if it modifies the filesystem outside the user's working "
|
|
337
|
+
"directory or home folder, deletes data irreversibly or in bulk, alters "
|
|
338
|
+
"networking or firewall settings, escalates privileges, changes system or "
|
|
339
|
+
"registry configuration, or downloads and executes remote code. Ordinary "
|
|
340
|
+
"read-only commands and edits within the user's own scope are not risky. "
|
|
341
|
+
'Respond with strict JSON only, no prose: {"risky": true|false, "reasons": '
|
|
342
|
+
'["short reason", ...]}. Use an empty reasons list when not risky.'
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _parse_llm_json(text: str) -> dict[str, Any]:
|
|
347
|
+
text = text.strip()
|
|
348
|
+
try:
|
|
349
|
+
data = json.loads(text)
|
|
350
|
+
except ValueError:
|
|
351
|
+
start, end = text.find("{"), text.rfind("}")
|
|
352
|
+
if start == -1 or end <= start:
|
|
353
|
+
raise ValueError("no JSON object in reviewer response") from None
|
|
354
|
+
data = json.loads(text[start : end + 1])
|
|
355
|
+
if not isinstance(data, dict) or "risky" not in data:
|
|
356
|
+
raise ValueError("reviewer response missing 'risky'")
|
|
357
|
+
return data
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# Jev answers typed questions with probabilities rather than free text, so the
|
|
361
|
+
# review is a battery of yes/no (Noul) questions, one per hazard category.
|
|
362
|
+
_JEV_QUESTIONS = {
|
|
363
|
+
PRIVILEGE: (
|
|
364
|
+
"Does `script` escalate privileges, e.g. with sudo, su, doas, pkexec or RunAs?",
|
|
365
|
+
"It runs commands with elevated privileges.",
|
|
366
|
+
"It runs everything as the current user.",
|
|
367
|
+
),
|
|
368
|
+
FILESYSTEM: (
|
|
369
|
+
"Does `script` modify or delete files outside the user's working directory and home "
|
|
370
|
+
"folder, delete data recursively or in bulk, or change system configuration?",
|
|
371
|
+
"It changes system-wide files or settings, or deletes data in bulk.",
|
|
372
|
+
"It only reads files, or changes files within the user's own scope.",
|
|
373
|
+
),
|
|
374
|
+
NETWORK: (
|
|
375
|
+
"Does `script` change network, firewall, routing, DNS or hosts configuration?",
|
|
376
|
+
"It alters networking configuration.",
|
|
377
|
+
"It leaves networking configuration unchanged; ordinary requests are fine.",
|
|
378
|
+
),
|
|
379
|
+
REMOTE_EXEC: (
|
|
380
|
+
"Does `script` download code from the internet and execute it?",
|
|
381
|
+
"It downloads and runs remote code.",
|
|
382
|
+
"It does not execute downloaded code.",
|
|
383
|
+
),
|
|
384
|
+
}
|
|
385
|
+
JEV_THRESHOLD = 0.5
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _jev_review(script: str, shell: str, p: ProviderConfig) -> ReviewResult:
|
|
389
|
+
from typesafe_sdk import Noul, NoulCriteria, TypeSafeClient
|
|
390
|
+
|
|
391
|
+
questions = {
|
|
392
|
+
category: Noul(instructions=q, criteria=NoulCriteria(true=yes, false=no))
|
|
393
|
+
for category, (q, yes, no) in _JEV_QUESTIONS.items()
|
|
394
|
+
}
|
|
395
|
+
with TypeSafeClient(
|
|
396
|
+
api_key=p.api_key, model=p.model, base_url=p.base_url, timeout=30.0
|
|
397
|
+
) as client:
|
|
398
|
+
response = client.system_one(state={"shell": shell, "script": script}, questions=questions)
|
|
399
|
+
reasons = [
|
|
400
|
+
f"Jev review: {category} (p={answer.noul:.2f})"
|
|
401
|
+
for category, answer in response.nouls.items()
|
|
402
|
+
if answer.noul >= JEV_THRESHOLD
|
|
403
|
+
]
|
|
404
|
+
return ReviewResult(bool(reasons), reasons)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def llm_review(script: str, shell: str, p: ProviderConfig) -> ReviewResult:
|
|
408
|
+
"""Ask the reviewer model; any failure fails closed (risky)."""
|
|
409
|
+
if p.kind == "jev":
|
|
410
|
+
try:
|
|
411
|
+
return _jev_review(script, shell, p)
|
|
412
|
+
except Exception as exc: # noqa: BLE001 - fail closed on anything
|
|
413
|
+
hint = " (is `ollaya serve` running?)" if is_local(p.base_url) else ""
|
|
414
|
+
return ReviewResult(True, [f"Reviewer unavailable: {exc}{hint}"])
|
|
415
|
+
try:
|
|
416
|
+
from aiscript import llm
|
|
417
|
+
|
|
418
|
+
user = f"Shell: {shell}\n\nScript:\n{script}"
|
|
419
|
+
raw = llm.chat(p, _LLM_SYSTEM, user)
|
|
420
|
+
data = _parse_llm_json(raw)
|
|
421
|
+
risky = data["risky"]
|
|
422
|
+
if isinstance(risky, str):
|
|
423
|
+
risky = risky.strip().lower() in ("true", "yes", "1")
|
|
424
|
+
risky = bool(risky)
|
|
425
|
+
reasons_raw = data.get("reasons") or []
|
|
426
|
+
if isinstance(reasons_raw, str):
|
|
427
|
+
reasons_raw = [reasons_raw]
|
|
428
|
+
reasons = [f"LLM review: {r}" for r in map(str, reasons_raw)]
|
|
429
|
+
if risky and not reasons:
|
|
430
|
+
reasons = ["LLM review: flagged as risky (no reason given)"]
|
|
431
|
+
return ReviewResult(risky, reasons if risky else [])
|
|
432
|
+
except Exception as exc: # noqa: BLE001 - fail closed on anything
|
|
433
|
+
return ReviewResult(True, [f"Reviewer unavailable: {exc}"])
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def review(script: str, shell: str, reviewer: ProviderConfig | None) -> ReviewResult:
|
|
437
|
+
result = static_review(script, shell)
|
|
438
|
+
if reviewer is None:
|
|
439
|
+
return result
|
|
440
|
+
llm_result = llm_review(script, shell, reviewer)
|
|
441
|
+
return ReviewResult(result.risky or llm_result.risky, result.reasons + llm_result.reasons)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# --------------------------------------------------------------------------- #
|
|
445
|
+
# Confirmation
|
|
446
|
+
# --------------------------------------------------------------------------- #
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def confirm(
|
|
450
|
+
script: str,
|
|
451
|
+
result: ReviewResult,
|
|
452
|
+
*,
|
|
453
|
+
input_fn: Callable[[str], str] = input,
|
|
454
|
+
stdin_isatty: bool | None = None,
|
|
455
|
+
) -> bool:
|
|
456
|
+
if not result.risky:
|
|
457
|
+
return True
|
|
458
|
+
if stdin_isatty is None:
|
|
459
|
+
try:
|
|
460
|
+
stdin_isatty = sys.stdin.isatty()
|
|
461
|
+
except (AttributeError, ValueError):
|
|
462
|
+
stdin_isatty = False
|
|
463
|
+
|
|
464
|
+
def err(msg: str = "") -> None:
|
|
465
|
+
print(msg, file=sys.stderr)
|
|
466
|
+
|
|
467
|
+
err()
|
|
468
|
+
err("!" * 60)
|
|
469
|
+
err("WARNING: this generated script may be risky:")
|
|
470
|
+
for reason in result.reasons:
|
|
471
|
+
err(f" - {printable(reason)}")
|
|
472
|
+
err("!" * 60)
|
|
473
|
+
|
|
474
|
+
if not stdin_isatty:
|
|
475
|
+
err("aiscript: refusing to run a risky script in non-interactive mode.")
|
|
476
|
+
return False
|
|
477
|
+
|
|
478
|
+
lines = script.splitlines()
|
|
479
|
+
width = len(str(len(lines)))
|
|
480
|
+
err()
|
|
481
|
+
for i, line in enumerate(lines, 1):
|
|
482
|
+
err(f"{i:>{width}} {printable(line)}")
|
|
483
|
+
err()
|
|
484
|
+
sys.stderr.write("Run this script? [y/N] ")
|
|
485
|
+
sys.stderr.flush()
|
|
486
|
+
try:
|
|
487
|
+
answer = input_fn("")
|
|
488
|
+
except (EOFError, KeyboardInterrupt):
|
|
489
|
+
err()
|
|
490
|
+
return False
|
|
491
|
+
return answer.strip().lower() in ("y", "yes")
|
aiscript/runner.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Execute a cached script."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from aiscript import paths
|
|
11
|
+
from aiscript.errors import AiscriptError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _command(path: Path, args: list[str]) -> list[str]:
|
|
15
|
+
if paths.is_windows():
|
|
16
|
+
shell = shutil.which("pwsh") or "powershell"
|
|
17
|
+
return [
|
|
18
|
+
shell,
|
|
19
|
+
"-NoProfile",
|
|
20
|
+
"-ExecutionPolicy",
|
|
21
|
+
"Bypass",
|
|
22
|
+
"-File",
|
|
23
|
+
str(path),
|
|
24
|
+
*args,
|
|
25
|
+
]
|
|
26
|
+
if os.access(path, os.X_OK):
|
|
27
|
+
return [str(path), *args]
|
|
28
|
+
return ["bash", str(path), *args]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_script(path: Path, args: list[str]) -> int:
|
|
32
|
+
"""Run ``path`` with ``args``, inheriting stdio; return its exit code."""
|
|
33
|
+
cmd = _command(path, args)
|
|
34
|
+
try:
|
|
35
|
+
proc = subprocess.run(cmd)
|
|
36
|
+
except KeyboardInterrupt:
|
|
37
|
+
return 130
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
raise AiscriptError(f"Could not execute {path}: {exc}") from exc
|
|
40
|
+
rc = proc.returncode
|
|
41
|
+
if rc < 0: # killed by a signal (POSIX)
|
|
42
|
+
return 128 - rc
|
|
43
|
+
return rc
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiscript
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: JIT-compile plain-English intents into cached, reviewed shell scripts via an LLM.
|
|
5
|
+
Author: Daniel Illescas Romero
|
|
6
|
+
Author-email: Daniel Illescas Romero <illescas.daniel@protonmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: openai>=3.35.1
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Dist: typesafe-sdk>=0.7.5
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# aiscript
|
|
16
|
+
|
|
17
|
+
[](https://pypi.org/project/aiscript/)
|
|
18
|
+
[](https://pypi.org/project/aiscript/)
|
|
19
|
+
|
|
20
|
+
Describe what you want in plain English and `aiscript` asks an LLM to write a shell script
|
|
21
|
+
(Bash on Linux/macOS, PowerShell on Windows), caches it, and runs it. The next run is instant:
|
|
22
|
+
no network, no config. Before anything risky is saved or run, every generated script is checked
|
|
23
|
+
by a built-in static scan and, optionally, by a decision model: [Jev](https://docs.typesafe.ai)
|
|
24
|
+
by TypeSafe AI (hosted) or a local model served by [Ollaya](https://ollaya.dev). Risky scripts
|
|
25
|
+
are shown to you and need an explicit `[y/N]` approval.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uv tool install aiscript
|
|
31
|
+
# or
|
|
32
|
+
pipx install aiscript
|
|
33
|
+
# or
|
|
34
|
+
pip install aiscript
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Python 3.10+. To work on aiscript itself, see [docs/development.md](docs/development.md).
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
aiscript --init # creates ~/.config/aiscript/config.yaml
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Put your OpenAI and Jev API keys in `~/.config/aiscript/config.yaml` (or export `OPENAI_API_KEY`
|
|
46
|
+
and `TYPESAFE_API_KEY`). The same file is where you pick the models: the one that writes the
|
|
47
|
+
scripts (`default_provider`) and the decision model that reviews them (`reviewer_provider`).
|
|
48
|
+
For a local, keyless review, use Ollaya instead of Jev (see below). Jev keys come from
|
|
49
|
+
<https://console.typesafe.ai/keys>.
|
|
50
|
+
|
|
51
|
+
Environment variables are the alternative to keys in the file:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
export OPENAI_API_KEY=...
|
|
55
|
+
export TYPESAFE_API_KEY=...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then run your first command:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
aiscript sizes # first run: asks "What should 'sizes' do?"
|
|
62
|
+
aiscript sizes ~/Downloads --top 5 # later runs: cached, arguments passed straight through
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Everything after the command name is passed untouched to the script (including `--help`).
|
|
66
|
+
`aiscript` messages go to stderr; stdout belongs to the script, and the exit code is the script's.
|
|
67
|
+
|
|
68
|
+
## Safety review / decision models
|
|
69
|
+
|
|
70
|
+
Every generated script is reviewed **before it is saved**:
|
|
71
|
+
|
|
72
|
+
- A **static regex scan** (always on) flags privilege escalation, recursive or system-wide file
|
|
73
|
+
changes, networking/firewall changes and remote code execution (`curl | sh`, `iex`).
|
|
74
|
+
- A **decision model** (`reviewer_provider`) gives a second opinion: **Jev** (hosted; key from
|
|
75
|
+
<https://console.typesafe.ai/keys>) or **Ollaya** (local and keyless; set
|
|
76
|
+
`reviewer_provider: ollaya`, default model `jevk5:4b` on `localhost:11435`; see
|
|
77
|
+
<https://ollaya.dev>). Comment `reviewer_provider` out to use only the static scan.
|
|
78
|
+
|
|
79
|
+
If either check flags the script, you see the reasons and the code and must answer `[y/N]`;
|
|
80
|
+
declining saves nothing. The review **fails closed**: if the reviewer errors or is unreachable,
|
|
81
|
+
the script is treated as risky. With a non-interactive stdin, risky scripts are refused. This is
|
|
82
|
+
a best-effort safety net, not a sandbox: read what you approve. Details: [docs/safety.md](docs/safety.md).
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
`aiscript --init` writes this starter `~/.config/aiscript/config.yaml` (mode 600; keep it out of git):
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
# aiscript config. Contains API keys: keep it private (chmod 600) and out of git.
|
|
90
|
+
# Keys can also come from the environment: OPENAI_API_KEY, TYPESAFE_API_KEY, <PROVIDER>_API_KEY.
|
|
91
|
+
|
|
92
|
+
# Provider that writes the scripts (override with $AISCRIPT_PROVIDER).
|
|
93
|
+
default_provider: openai
|
|
94
|
+
|
|
95
|
+
# Decision model that reviews scripts on top of the built-in static scan.
|
|
96
|
+
# Use `ollaya` for a local, keyless review; comment out to use only the static scan.
|
|
97
|
+
reviewer_provider: jev
|
|
98
|
+
|
|
99
|
+
providers:
|
|
100
|
+
# OpenAI (or any OpenAI-compatible API). API key: https://platform.openai.com/api-keys
|
|
101
|
+
openai:
|
|
102
|
+
api_key: "..."
|
|
103
|
+
model: "gpt-6-luna"
|
|
104
|
+
# reasoning_effort: low # none | low | medium | high | xhigh | max (model-dependent)
|
|
105
|
+
|
|
106
|
+
# Jev (TypeSafe AI) safety reviewer. API key: https://console.typesafe.ai/keys
|
|
107
|
+
jev:
|
|
108
|
+
api_key: "..."
|
|
109
|
+
|
|
110
|
+
# Uses Ollaya for a local decision model (https://ollaya.dev), model example: jevk5:4b
|
|
111
|
+
# ollaya:
|
|
112
|
+
# model: "jevk5:4b"
|
|
113
|
+
|
|
114
|
+
# Uses Ollama for a local LLM to create the scripts (set `default_provider: ollama`)
|
|
115
|
+
# ollama:
|
|
116
|
+
# base_url: "http://localhost:11434/v1"
|
|
117
|
+
# model: "qwen3"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Placeholder keys (`"..."`) are ignored, so if you leave them, the environment variables above work as-is.
|
|
121
|
+
All options (provider kinds, `api`, `temperature`, key and model resolution, every environment
|
|
122
|
+
variable, the legacy `secrets.yaml`): [docs/configuration.md](docs/configuration.md).
|
|
123
|
+
|
|
124
|
+
## Usage
|
|
125
|
+
|
|
126
|
+
| Command | Action |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `aiscript NAME [args...]` | Run a command; created and built on first use |
|
|
129
|
+
| `aiscript --init` | Create config dirs and a `config.yaml` template (never overwrites) |
|
|
130
|
+
| `aiscript --list` | Table of commands, descriptions and usage (`*` = prompt changed, will rebuild) |
|
|
131
|
+
| `aiscript --edit NAME` | Open the prompt in `$VISUAL` / `$EDITOR` (fallback nano/vi, notepad on Windows) |
|
|
132
|
+
| `aiscript --rebuild NAME` | Regenerate the script and print its path; does not run it |
|
|
133
|
+
| `aiscript --remove NAME` | Delete the command's prompt and cached script |
|
|
134
|
+
| `aiscript --help`, `--version` | Help / version |
|
|
135
|
+
|
|
136
|
+
Command names must match `[A-Za-z0-9][A-Za-z0-9_-]*`.
|
|
137
|
+
|
|
138
|
+
## How it works
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
~/.config/aiscript/ # or $XDG_CONFIG_HOME/aiscript, or $AISCRIPT_HOME
|
|
142
|
+
├── config.yaml # providers and keys
|
|
143
|
+
├── prompts/<name>.txt # your intent for each command
|
|
144
|
+
└── functions/<name>.sh # generated, reviewed scripts (.ps1 on Windows)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The first run of `NAME` saves your description to `prompts/NAME.txt`, asks the LLM for a script,
|
|
148
|
+
reviews it, and stores it in `functions/`. Later runs just execute the cached script. If the
|
|
149
|
+
prompt is newer than the script (after `--edit` or a manual change), the script is rebuilt on the
|
|
150
|
+
next run. A `--rebuild` that fails or is rejected keeps the previous script.
|
|
151
|
+
|
|
152
|
+
## Docs
|
|
153
|
+
|
|
154
|
+
- [Configuration reference](docs/configuration.md)
|
|
155
|
+
- [Safety review](docs/safety.md)
|
|
156
|
+
- [Development](docs/development.md)
|