krnl-code 1.0.4__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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/sandbox.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Per-tool command sandbox + network egress policy.
|
|
2
|
+
|
|
3
|
+
A deny-by-default safety layer for `run_command`, evaluated *before* a command
|
|
4
|
+
runs (and before the approval prompt). Configured under `sandbox:` in config.yaml:
|
|
5
|
+
|
|
6
|
+
sandbox:
|
|
7
|
+
block_network: true # block curl/wget/ssh/etc. and URLs
|
|
8
|
+
deny_commands: ["rm -rf /", "git push"] # regex, always denied
|
|
9
|
+
allow_commands: ["pytest", "npm (run )?test"] # if set, ONLY these allowed
|
|
10
|
+
|
|
11
|
+
This keeps autonomous / dangerous-mode runs contained: even with every approval
|
|
12
|
+
auto-granted, a denied command never executes.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
# Commands/binaries that perform network egress.
|
|
19
|
+
_NET = re.compile(
|
|
20
|
+
r"(?i)(?:^|[\s;|&])(curl|wget|nc|ncat|netcat|telnet|ssh|scp|sftp|ftp|rsync|"
|
|
21
|
+
r"http|https)\b|https?://|ftp://")
|
|
22
|
+
|
|
23
|
+
# Always-dangerous shapes blocked when block_network or any policy is active is
|
|
24
|
+
# NOT assumed; these are only used by the built-in 'strict' helper below.
|
|
25
|
+
_DESTRUCTIVE = [
|
|
26
|
+
r"rm\s+-rf\s+/(?:\s|$)",
|
|
27
|
+
r":\(\)\s*\{.*\};:", # fork bomb
|
|
28
|
+
r"mkfs\b", r"dd\s+if=.*of=/dev/",
|
|
29
|
+
r">\s*/dev/sd[a-z]",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_command(sandbox: dict, command: str) -> tuple[bool, str]:
|
|
34
|
+
"""Return (allowed, reason). Empty/whitespace commands are allowed (no-op)."""
|
|
35
|
+
if not sandbox:
|
|
36
|
+
return True, ""
|
|
37
|
+
cmd = (command or "").strip()
|
|
38
|
+
if not cmd:
|
|
39
|
+
return True, ""
|
|
40
|
+
|
|
41
|
+
for pat in sandbox.get("deny_commands", []) or []:
|
|
42
|
+
try:
|
|
43
|
+
if re.search(pat, cmd):
|
|
44
|
+
return False, f"blocked by sandbox.deny_commands rule: /{pat}/"
|
|
45
|
+
except re.error:
|
|
46
|
+
if pat in cmd:
|
|
47
|
+
return False, f"blocked by sandbox.deny_commands rule: {pat!r}"
|
|
48
|
+
|
|
49
|
+
if sandbox.get("block_network") and _NET.search(cmd):
|
|
50
|
+
return False, "network egress blocked by sandbox.block_network"
|
|
51
|
+
|
|
52
|
+
allow = sandbox.get("allow_commands") or []
|
|
53
|
+
if allow:
|
|
54
|
+
for pat in allow:
|
|
55
|
+
try:
|
|
56
|
+
if re.search(pat, cmd):
|
|
57
|
+
return True, ""
|
|
58
|
+
except re.error:
|
|
59
|
+
if pat in cmd:
|
|
60
|
+
return True, ""
|
|
61
|
+
return False, "not in sandbox.allow_commands allowlist (deny-by-default)"
|
|
62
|
+
|
|
63
|
+
return True, ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_destructive(command: str) -> bool:
|
|
67
|
+
"""True for catastrophic shapes (used by `doctor`/strict presets)."""
|
|
68
|
+
cmd = command or ""
|
|
69
|
+
return any(re.search(p, cmd) for p in _DESTRUCTIVE)
|
krnl_agent/scaffold.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Auto-onboarding: scaffold a `.krnl/` wrapper the first time the agent works in
|
|
2
|
+
a project, so memory, a skill, and a project doc exist by default - no setup.
|
|
3
|
+
|
|
4
|
+
Creates (only what's missing):
|
|
5
|
+
.krnl/AGENTS.md - project memory / rules (auto-loaded)
|
|
6
|
+
.krnl/PROJECT.md - a project overview doc (the agent's own MD)
|
|
7
|
+
.krnl/skills/explore-codebase/SKILL.md - a starter skill for understanding the repo
|
|
8
|
+
|
|
9
|
+
Everything is detected from the repo (stack, build/test/lint commands), so the
|
|
10
|
+
files are useful immediately and key-free.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_project(workspace: str) -> dict:
|
|
19
|
+
root = Path(workspace)
|
|
20
|
+
info: dict = {"name": root.name, "stack": [], "build": [], "test": [],
|
|
21
|
+
"lint": [], "run": [], "git": (root / ".git").exists()}
|
|
22
|
+
|
|
23
|
+
def add(cat: str, cmd: str):
|
|
24
|
+
if cmd not in info[cat]:
|
|
25
|
+
info[cat].append(cmd)
|
|
26
|
+
|
|
27
|
+
if (root / "pyproject.toml").exists() or (root / "setup.py").exists() or (root / "requirements.txt").exists():
|
|
28
|
+
info["stack"].append("Python")
|
|
29
|
+
if (root / "requirements.txt").exists():
|
|
30
|
+
add("build", "pip install -r requirements.txt")
|
|
31
|
+
if (root / "pyproject.toml").exists():
|
|
32
|
+
add("build", "pip install -e .")
|
|
33
|
+
add("test", "pytest")
|
|
34
|
+
pkg = root / "package.json"
|
|
35
|
+
if pkg.exists():
|
|
36
|
+
info["stack"].append("Node.js")
|
|
37
|
+
try:
|
|
38
|
+
scripts = json.loads(pkg.read_text(encoding="utf-8")).get("scripts", {})
|
|
39
|
+
cat = {"build": "build", "test": "test", "lint": "lint", "dev": "run", "start": "run"}
|
|
40
|
+
for k, c in cat.items():
|
|
41
|
+
if k in scripts:
|
|
42
|
+
add(c, f"npm run {k}")
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
if (root / "go.mod").exists():
|
|
46
|
+
info["stack"].append("Go"); add("build", "go build ./..."); add("test", "go test ./...")
|
|
47
|
+
if (root / "Cargo.toml").exists():
|
|
48
|
+
info["stack"].append("Rust"); add("build", "cargo build"); add("test", "cargo test")
|
|
49
|
+
if (root / "pom.xml").exists():
|
|
50
|
+
info["stack"].append("Java/Maven"); add("build", "mvn compile"); add("test", "mvn test")
|
|
51
|
+
if (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
|
|
52
|
+
info["stack"].append("Gradle"); add("build", "gradle build"); add("test", "gradle test")
|
|
53
|
+
if (root / "Gemfile").exists():
|
|
54
|
+
info["stack"].append("Ruby")
|
|
55
|
+
if (root / "composer.json").exists():
|
|
56
|
+
info["stack"].append("PHP")
|
|
57
|
+
# top-level layout (a few entries)
|
|
58
|
+
try:
|
|
59
|
+
entries = sorted(p.name for p in root.iterdir()
|
|
60
|
+
if not p.name.startswith(".") and p.name not in ("node_modules", "venv"))
|
|
61
|
+
info["layout"] = entries[:20]
|
|
62
|
+
except Exception:
|
|
63
|
+
info["layout"] = []
|
|
64
|
+
return info
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def needs_scaffold(workspace: str) -> bool:
|
|
68
|
+
return not (Path(workspace) / ".krnl").exists()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _list(items: list, fallback: str = "(detect / fill in)") -> str:
|
|
72
|
+
return "\n".join(f"- `{x}`" for x in items) if items else f"- {fallback}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _agents_md(info: dict) -> str:
|
|
76
|
+
stack = ", ".join(info["stack"]) or "(unknown)"
|
|
77
|
+
return f"""# Project memory - {info['name']}
|
|
78
|
+
|
|
79
|
+
Auto-loaded into the agent's context. Edit freely; this is the source of truth
|
|
80
|
+
for how to work in this repo.
|
|
81
|
+
|
|
82
|
+
## Stack
|
|
83
|
+
{stack}
|
|
84
|
+
|
|
85
|
+
## Commands
|
|
86
|
+
**Build/install**
|
|
87
|
+
{_list(info['build'])}
|
|
88
|
+
**Test**
|
|
89
|
+
{_list(info['test'])}
|
|
90
|
+
**Lint**
|
|
91
|
+
{_list(info['lint'])}
|
|
92
|
+
**Run/dev**
|
|
93
|
+
{_list(info['run'])}
|
|
94
|
+
|
|
95
|
+
## Conventions
|
|
96
|
+
- (e.g. code style, branch naming, files to never touch)
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
- (anything the agent should always know about this project)
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _project_md(info: dict) -> str:
|
|
104
|
+
layout = "\n".join(f"- {x}" for x in info.get("layout", [])) or "- (run `list_files`)"
|
|
105
|
+
return f"""# {info['name']} - project overview
|
|
106
|
+
|
|
107
|
+
> Auto-generated by Krnl Agent onboarding. Enrich it by asking the agent to
|
|
108
|
+
> "study this codebase and update .krnl/PROJECT.md".
|
|
109
|
+
|
|
110
|
+
**Stack:** {', '.join(info['stack']) or 'unknown'}
|
|
111
|
+
**Git:** {'yes' if info['git'] else 'no'}
|
|
112
|
+
|
|
113
|
+
## Top-level layout
|
|
114
|
+
{layout}
|
|
115
|
+
|
|
116
|
+
## How to build & test
|
|
117
|
+
{_list(info['build'] + info['test'])}
|
|
118
|
+
|
|
119
|
+
## Architecture notes
|
|
120
|
+
- (the agent fills these in as it learns the codebase)
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _skill_md(info: dict) -> str:
|
|
125
|
+
return f"""---
|
|
126
|
+
description: How to explore and safely work in the {info['name']} codebase.
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
# Explore the {info['name']} codebase
|
|
130
|
+
|
|
131
|
+
When asked to understand, change, or debug this project:
|
|
132
|
+
|
|
133
|
+
1. Read `.krnl/PROJECT.md` and `.krnl/AGENTS.md` first for stack and commands.
|
|
134
|
+
2. Map the structure with `list_files` and `glob`; read entry points and configs.
|
|
135
|
+
3. Use `search_text` to locate symbols before editing; prefer `edit_file`.
|
|
136
|
+
4. After changes, run the test command from memory and fix failures.
|
|
137
|
+
5. Keep changes minimal and match existing style.
|
|
138
|
+
|
|
139
|
+
Stack: {', '.join(info['stack']) or 'unknown'}
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def scaffold(workspace: str) -> list[str]:
|
|
144
|
+
"""Create the .krnl scaffold (only files that don't already exist)."""
|
|
145
|
+
root = Path(workspace)
|
|
146
|
+
info = detect_project(workspace)
|
|
147
|
+
krnl = root / ".krnl"
|
|
148
|
+
(krnl / "skills" / "explore-codebase").mkdir(parents=True, exist_ok=True)
|
|
149
|
+
created: list[str] = []
|
|
150
|
+
|
|
151
|
+
mem = krnl / "AGENTS.md"
|
|
152
|
+
has_memory = any((root / n).exists() for n in ("AGENTS.md", "KRNL.md", "CLAUDE.md"))
|
|
153
|
+
if not mem.exists() and not has_memory:
|
|
154
|
+
mem.write_text(_agents_md(info), encoding="utf-8")
|
|
155
|
+
created.append(".krnl/AGENTS.md")
|
|
156
|
+
|
|
157
|
+
proj = krnl / "PROJECT.md"
|
|
158
|
+
if not proj.exists():
|
|
159
|
+
proj.write_text(_project_md(info), encoding="utf-8")
|
|
160
|
+
created.append(".krnl/PROJECT.md")
|
|
161
|
+
|
|
162
|
+
skill = krnl / "skills" / "explore-codebase" / "SKILL.md"
|
|
163
|
+
if not skill.exists():
|
|
164
|
+
skill.write_text(_skill_md(info), encoding="utf-8")
|
|
165
|
+
created.append(".krnl/skills/explore-codebase/SKILL.md")
|
|
166
|
+
|
|
167
|
+
return created
|
krnl_agent/schedules.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Scheduled agents — run tasks on cron schedules, independent of any terminal.
|
|
2
|
+
|
|
3
|
+
krnl-agent schedule create "PR summary" --cron "0 9 * * MON-FRI" \
|
|
4
|
+
--prompt "List all open PRs and their review status" --workspace /repo
|
|
5
|
+
krnl-agent schedule list
|
|
6
|
+
krnl-agent schedule run <id>
|
|
7
|
+
krnl-agent schedule daemon # foreground loop that fires due schedules
|
|
8
|
+
|
|
9
|
+
Schedules persist in ~/.krnl-agent/schedules.json across restarts. Includes a
|
|
10
|
+
small dependency-free cron matcher (5 fields, supports *, lists, ranges, steps,
|
|
11
|
+
and day/month names).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
from .settings import SETTINGS_DIR
|
|
20
|
+
|
|
21
|
+
SCHEDULES_FILE = SETTINGS_DIR / "schedules.json"
|
|
22
|
+
|
|
23
|
+
_DOW = {"SUN": 0, "MON": 1, "TUE": 2, "WED": 3, "THU": 4, "FRI": 5, "SAT": 6}
|
|
24
|
+
_MON = {m: i + 1 for i, m in enumerate(
|
|
25
|
+
["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"])}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------- #
|
|
29
|
+
# Cron matching
|
|
30
|
+
# --------------------------------------------------------------------------- #
|
|
31
|
+
def _match_field(expr: str, value: int, names: dict | None = None) -> bool:
|
|
32
|
+
expr = expr.upper()
|
|
33
|
+
if names:
|
|
34
|
+
for n, v in names.items():
|
|
35
|
+
expr = expr.replace(n, str(v))
|
|
36
|
+
for part in expr.split(","):
|
|
37
|
+
step = 1
|
|
38
|
+
if "/" in part:
|
|
39
|
+
part, step_s = part.split("/", 1)
|
|
40
|
+
step = int(step_s)
|
|
41
|
+
if part in ("*", ""):
|
|
42
|
+
lo, hi = None, None
|
|
43
|
+
elif "-" in part:
|
|
44
|
+
a, b = part.split("-", 1)
|
|
45
|
+
lo, hi = int(a), int(b)
|
|
46
|
+
else:
|
|
47
|
+
lo = hi = int(part)
|
|
48
|
+
if lo is None: # wildcard
|
|
49
|
+
if value % step == 0:
|
|
50
|
+
return True
|
|
51
|
+
elif lo <= value <= hi and (value - lo) % step == 0:
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cron_match(expr: str, dt: datetime) -> bool:
|
|
57
|
+
fields = expr.split()
|
|
58
|
+
if len(fields) != 5:
|
|
59
|
+
return False
|
|
60
|
+
minute, hour, dom, month, dow = fields
|
|
61
|
+
cron_dow = (dt.weekday() + 1) % 7 # Python Mon=0..Sun=6 -> cron Sun=0..Sat=6
|
|
62
|
+
dom_r = dom not in ("*", "?")
|
|
63
|
+
dow_r = dow not in ("*", "?")
|
|
64
|
+
base = (
|
|
65
|
+
_match_field(minute, dt.minute)
|
|
66
|
+
and _match_field(hour, dt.hour)
|
|
67
|
+
and _match_field(month, dt.month, _MON)
|
|
68
|
+
)
|
|
69
|
+
if not base:
|
|
70
|
+
return False
|
|
71
|
+
dom_ok = _match_field(dom, dt.day)
|
|
72
|
+
dow_ok = _match_field(dow, cron_dow, _DOW)
|
|
73
|
+
# standard cron: when both day-fields are restricted, it's OR
|
|
74
|
+
if dom_r and dow_r:
|
|
75
|
+
return dom_ok or dow_ok
|
|
76
|
+
return dom_ok and dow_ok
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# Storage
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
def _load() -> list[dict]:
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(SCHEDULES_FILE.read_text(encoding="utf-8")).get("schedules", [])
|
|
85
|
+
except Exception:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _save(rows: list[dict]) -> None:
|
|
90
|
+
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
SCHEDULES_FILE.write_text(json.dumps({"schedules": rows}, indent=2), encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def create(name: str, cron: str, prompt: str, workspace: str,
|
|
95
|
+
provider: str | None = None, model: str | None = None) -> dict:
|
|
96
|
+
rows = _load()
|
|
97
|
+
sched = {
|
|
98
|
+
"id": uuid.uuid4().hex[:8],
|
|
99
|
+
"name": name,
|
|
100
|
+
"cron": cron,
|
|
101
|
+
"prompt": prompt,
|
|
102
|
+
"workspace": workspace,
|
|
103
|
+
"provider": provider,
|
|
104
|
+
"model": model,
|
|
105
|
+
"last_run": None,
|
|
106
|
+
}
|
|
107
|
+
rows.append(sched)
|
|
108
|
+
_save(rows)
|
|
109
|
+
return sched
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_schedules() -> list[dict]:
|
|
113
|
+
return _load()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def remove(schedule_id: str) -> bool:
|
|
117
|
+
rows = _load()
|
|
118
|
+
new = [r for r in rows if r["id"] != schedule_id]
|
|
119
|
+
_save(new)
|
|
120
|
+
return len(new) != len(rows)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get(schedule_id: str) -> dict | None:
|
|
124
|
+
return next((r for r in _load() if r["id"] == schedule_id), None)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def mark_run(schedule_id: str, stamp: str) -> None:
|
|
128
|
+
rows = _load()
|
|
129
|
+
for r in rows:
|
|
130
|
+
if r["id"] == schedule_id:
|
|
131
|
+
r["last_run"] = stamp
|
|
132
|
+
_save(rows)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def due(now: datetime) -> list[dict]:
|
|
136
|
+
stamp = now.strftime("%Y-%m-%d %H:%M")
|
|
137
|
+
return [r for r in _load() if r.get("last_run") != stamp and cron_match(r["cron"], now)]
|
krnl_agent/secrets.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Secret scanner — find hard-coded credentials before they ship.
|
|
2
|
+
|
|
3
|
+
A regex sweep over the workspace for the highest-signal secret shapes (API keys,
|
|
4
|
+
private keys, tokens, connection strings). It is intentionally conservative to
|
|
5
|
+
keep false positives low, reports `path:line` with the match masked, and never
|
|
6
|
+
prints the full secret back into the model's context.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# (label, compiled pattern). Patterns target distinctive secret shapes.
|
|
15
|
+
_RULES: list[tuple[str, re.Pattern]] = [
|
|
16
|
+
("AWS access key id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
17
|
+
("AWS secret access key", re.compile(r"(?i)aws.{0,20}secret.{0,20}['\"][0-9a-zA-Z/+]{40}['\"]")),
|
|
18
|
+
("GitHub token", re.compile(r"\bgh[pousr]_[0-9A-Za-z]{36,}\b")),
|
|
19
|
+
("GitHub fine-grained token", re.compile(r"\bgithub_pat_[0-9A-Za-z_]{60,}\b")),
|
|
20
|
+
("Slack token", re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b")),
|
|
21
|
+
("Google API key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")),
|
|
22
|
+
("OpenAI / sk- key", re.compile(r"\bsk-[A-Za-z0-9]{20,}\b")),
|
|
23
|
+
("Anthropic key", re.compile(r"\bsk-ant-[A-Za-z0-9\-_]{20,}\b")),
|
|
24
|
+
("PyPI token", re.compile(r"\bpypi-[A-Za-z0-9\-_]{40,}\b")),
|
|
25
|
+
("Stripe key", re.compile(r"\b[sr]k_live_[0-9A-Za-z]{20,}\b")),
|
|
26
|
+
("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b")),
|
|
27
|
+
("Private key block", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----")),
|
|
28
|
+
("Generic secret assignment", re.compile(
|
|
29
|
+
r"(?i)(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)"
|
|
30
|
+
r"\s*[:=]\s*['\"][^'\"\s]{8,}['\"]")),
|
|
31
|
+
("Connection string w/ password", re.compile(
|
|
32
|
+
r"(?i)(?:postgres|mysql|mongodb|redis|amqp)(?:\+\w+)?://[^:\s]+:[^@\s]+@")),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Don't flag obvious placeholders.
|
|
36
|
+
_PLACEHOLDER = re.compile(
|
|
37
|
+
r"(?i)(your[_-]?|example|placeholder|changeme|xxx+|\.\.\.|<[^>]+>|dummy|sample|test[_-]?key)")
|
|
38
|
+
|
|
39
|
+
_MAX_HITS = 200
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mask(s: str) -> str:
|
|
43
|
+
s = s.strip().strip("'\"")
|
|
44
|
+
if len(s) <= 12:
|
|
45
|
+
return s[:2] + "…"
|
|
46
|
+
return f"{s[:4]}…{s[-4:]} (len {len(s)})"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan(ctx, path: str = ".") -> tuple[int, str]:
|
|
50
|
+
"""Scan the workspace for likely secrets. Returns (hit_count, report)."""
|
|
51
|
+
base = ctx.resolve(path)
|
|
52
|
+
hits: list[str] = []
|
|
53
|
+
scanned = 0
|
|
54
|
+
if base.is_file():
|
|
55
|
+
files = [base]
|
|
56
|
+
else:
|
|
57
|
+
files = []
|
|
58
|
+
for root, dirs, names in os.walk(base):
|
|
59
|
+
rroot = ctx.rel(Path(root))
|
|
60
|
+
dirs[:] = [d for d in sorted(dirs)
|
|
61
|
+
if not ctx.is_ignored(f"{rroot}/{d}".lstrip("./"))]
|
|
62
|
+
for n in sorted(names):
|
|
63
|
+
fp = Path(root) / n
|
|
64
|
+
if not ctx.is_ignored(ctx.rel(fp)):
|
|
65
|
+
files.append(fp)
|
|
66
|
+
|
|
67
|
+
for fp in files:
|
|
68
|
+
try:
|
|
69
|
+
if fp.stat().st_size > 400_000:
|
|
70
|
+
continue
|
|
71
|
+
with open(fp, "rb") as fb:
|
|
72
|
+
if b"\x00" in fb.read(2048):
|
|
73
|
+
continue
|
|
74
|
+
except Exception:
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
with open(fp, encoding="utf-8", errors="ignore") as fh:
|
|
78
|
+
for i, line in enumerate(fh, 1):
|
|
79
|
+
if len(line) > 1000:
|
|
80
|
+
continue
|
|
81
|
+
for label, rx in _RULES:
|
|
82
|
+
m = rx.search(line)
|
|
83
|
+
if m and not _PLACEHOLDER.search(m.group(0)):
|
|
84
|
+
hits.append(f"{ctx.rel(fp)}:{i}: [{label}] {_mask(m.group(0))}")
|
|
85
|
+
break
|
|
86
|
+
if len(hits) >= _MAX_HITS:
|
|
87
|
+
break
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
scanned += 1
|
|
91
|
+
if len(hits) >= _MAX_HITS:
|
|
92
|
+
hits.append("… (more; truncated)")
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
if not hits:
|
|
96
|
+
return 0, f"No likely secrets found ({scanned} files scanned)."
|
|
97
|
+
report = (f"Found {len(hits)} potential secret(s) across {scanned} files. "
|
|
98
|
+
"Review each; rotate anything real and move it to env/secret storage:\n\n"
|
|
99
|
+
+ "\n".join(hits))
|
|
100
|
+
return len(hits), report
|
krnl_agent/selfheal.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Self-healing — keep a deployment alive.
|
|
2
|
+
|
|
3
|
+
Two cooperating loops (policy: rollback is autonomous, code fixes are PRs):
|
|
4
|
+
|
|
5
|
+
* FAST loop (deterministic, autonomous): after a deploy, poll the app's health
|
|
6
|
+
endpoint; if it's unhealthy, automatically roll back to the last known-good
|
|
7
|
+
release. This only ever reverts to a previous good state, so it is always safe
|
|
8
|
+
to run without asking.
|
|
9
|
+
* SLOW loop (generative, gated): pull current production errors (see monitor.py),
|
|
10
|
+
have the model write a patch + regression test, and open a PR for a human to
|
|
11
|
+
merge. That part is driven by the `heal` command's prompt, not this module — here
|
|
12
|
+
we provide the safe, deterministic primitives it builds on.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
from . import deploy
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def http_health(url: str, timeout: float = 10.0) -> tuple[bool, str]:
|
|
24
|
+
"""Single GET. Healthy = HTTP 2xx/3xx. Never raises."""
|
|
25
|
+
try:
|
|
26
|
+
req = urllib.request.Request(url, headers={"User-Agent": "krnl-agent-health"})
|
|
27
|
+
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
|
|
28
|
+
code = getattr(r, "status", 200)
|
|
29
|
+
return (200 <= code < 400), f"HTTP {code}"
|
|
30
|
+
except urllib.error.HTTPError as e:
|
|
31
|
+
return (200 <= e.code < 400), f"HTTP {e.code}"
|
|
32
|
+
except Exception as e: # noqa: BLE001
|
|
33
|
+
return False, f"unreachable: {type(e).__name__}: {e}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_health(url: str, path: str = "/health", attempts: int = 1,
|
|
37
|
+
delay: float = 0.0, _sleep=time.sleep) -> tuple[bool, str]:
|
|
38
|
+
"""Poll `url`+`path` up to `attempts` times. Returns (healthy, detail)."""
|
|
39
|
+
full = url.rstrip("/") + (path if path.startswith("/") else "/" + path)
|
|
40
|
+
last = ""
|
|
41
|
+
for i in range(max(1, attempts)):
|
|
42
|
+
ok, detail = http_health(full)
|
|
43
|
+
last = detail
|
|
44
|
+
if ok:
|
|
45
|
+
return True, f"healthy ({detail}) at {full}"
|
|
46
|
+
if i < attempts - 1 and delay:
|
|
47
|
+
_sleep(delay)
|
|
48
|
+
return False, f"unhealthy ({last}) at {full}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def rollback(ctx, target_name: str, params: dict | None = None) -> tuple[bool, str]:
|
|
52
|
+
"""Build and RUN the rollback command for a target (autonomous, safe).
|
|
53
|
+
|
|
54
|
+
Returns (ok, output). Rolling back only restores a prior good release.
|
|
55
|
+
"""
|
|
56
|
+
ok, cmd = deploy.rollback_command(target_name, params)
|
|
57
|
+
if not ok:
|
|
58
|
+
return False, cmd
|
|
59
|
+
from .tools import run_command # lazy to avoid import cycle
|
|
60
|
+
|
|
61
|
+
outcome = run_command(ctx, cmd)
|
|
62
|
+
return outcome.ok, outcome.output
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def heal_once(ctx, url: str, target_name: str, params: dict | None = None,
|
|
66
|
+
path: str = "/health", attempts: int = 3, delay: float = 5.0) -> dict:
|
|
67
|
+
"""Fast-loop self-heal: check health; if unhealthy, auto-rollback. Returns a
|
|
68
|
+
report dict (no exceptions). The generative error→PR step is handled by the
|
|
69
|
+
`heal` command prompt, not here."""
|
|
70
|
+
healthy, detail = check_health(url, path, attempts=attempts, delay=delay)
|
|
71
|
+
report = {"url": url, "target": target_name, "healthy": healthy, "detail": detail,
|
|
72
|
+
"action": "none", "rollback_ok": None, "rollback_output": ""}
|
|
73
|
+
if healthy:
|
|
74
|
+
return report
|
|
75
|
+
t = deploy.target(target_name)
|
|
76
|
+
if not t or not t.rollback_cmd:
|
|
77
|
+
report["action"] = "no-rollback-available"
|
|
78
|
+
return report
|
|
79
|
+
report["action"] = "rollback"
|
|
80
|
+
rb_ok, rb_out = rollback(ctx, target_name, params)
|
|
81
|
+
report["rollback_ok"] = rb_ok
|
|
82
|
+
report["rollback_output"] = rb_out[:500]
|
|
83
|
+
# Re-check after rollback.
|
|
84
|
+
healthy2, detail2 = check_health(url, path, attempts=attempts, delay=delay)
|
|
85
|
+
report["healthy_after_rollback"] = healthy2
|
|
86
|
+
report["detail_after_rollback"] = detail2
|
|
87
|
+
return report
|