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/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
+ [![PyPI version](https://img.shields.io/pypi/v/aiscript)](https://pypi.org/project/aiscript/)
18
+ [![Python versions](https://img.shields.io/pypi/pyversions/aiscript)](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)