lcode-cli 0.25.0__py3-none-any.whl → 0.27.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.
- lcode/__init__.py +1 -1
- lcode/action.py +8 -1
- lcode/agent.py +38 -7
- lcode/api.py +2 -0
- lcode/cli.py +15 -0
- lcode/config.py +17 -0
- lcode/context.py +81 -4
- lcode/gitflow.py +15 -0
- lcode/repl.py +2 -1
- lcode/secretscan.py +315 -0
- lcode/tools.py +25 -2
- {lcode_cli-0.25.0.dist-info → lcode_cli-0.27.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.25.0.dist-info → lcode_cli-0.27.0.dist-info}/RECORD +16 -15
- {lcode_cli-0.25.0.dist-info → lcode_cli-0.27.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.25.0.dist-info → lcode_cli-0.27.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.25.0.dist-info → lcode_cli-0.27.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/action.py
CHANGED
|
@@ -302,7 +302,14 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
|
|
|
302
302
|
reply = result.text.strip() or "(no answer)"
|
|
303
303
|
changed = git(cwd, "status", "--porcelain").strip()
|
|
304
304
|
if changed:
|
|
305
|
-
from lcode import gitflow
|
|
305
|
+
from lcode import gitflow, secretscan
|
|
306
|
+
|
|
307
|
+
found = secretscan.in_files(cwd, ["."], staged=False)
|
|
308
|
+
if found:
|
|
309
|
+
raise ActionError(
|
|
310
|
+
secretscan.report("commit", found, excerpts=False) + "\n\nSo lcode didn't commit or push its "
|
|
311
|
+
"changes. Secrets belong in the repository's secrets or in configuration that git ignores."
|
|
312
|
+
)
|
|
306
313
|
|
|
307
314
|
data = gitflow.ask(
|
|
308
315
|
session.agent,
|
lcode/agent.py
CHANGED
|
@@ -233,6 +233,8 @@ class Settings:
|
|
|
233
233
|
notify: bool = False # desktop notifications after long requests (lcode.notify)
|
|
234
234
|
notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
|
|
235
235
|
max_parallel_agents: int = 1
|
|
236
|
+
secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
|
|
237
|
+
compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
|
|
236
238
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
237
239
|
|
|
238
240
|
|
|
@@ -269,6 +271,7 @@ class Agent:
|
|
|
269
271
|
self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
|
|
270
272
|
self.plan = "" # the plan the user approved; kept through compaction
|
|
271
273
|
self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically
|
|
274
|
+
self.summarized_size = 0 # tokens right after the last summary (0: none in this conversation)
|
|
272
275
|
self._extensions: tuple[Path, extensions.Extensions] | None = None
|
|
273
276
|
self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
|
|
274
277
|
# Set on subagents (see lcode.subagents):
|
|
@@ -513,6 +516,7 @@ class Agent:
|
|
|
513
516
|
return WEB_PROMPT.format(tools=tools, date=dt.date.today().isoformat())
|
|
514
517
|
|
|
515
518
|
def reset(self) -> None:
|
|
519
|
+
self.summarized_size = 0
|
|
516
520
|
self.messages = [{"role": "system", "content": self.system_prompt()}]
|
|
517
521
|
self._mcp_prompt = ""
|
|
518
522
|
self.tools.read_mtimes.clear()
|
|
@@ -1045,13 +1049,27 @@ class Agent:
|
|
|
1045
1049
|
return text + ("\n\n" + "\n\n".join(attached) if attached else "")
|
|
1046
1050
|
|
|
1047
1051
|
# -- context management
|
|
1052
|
+
def compact_limit(self) -> int:
|
|
1053
|
+
"""The tokens in use at which lcode makes room: 85% of the window, or `compact_at` when that's lower.
|
|
1054
|
+
Small models lose track in long contexts well before a large window is full."""
|
|
1055
|
+
limit = int(AUTO_COMPACT_RATIO * self.settings.context)
|
|
1056
|
+
if self.settings.compact_at > 0:
|
|
1057
|
+
limit = min(limit, self.settings.compact_at)
|
|
1058
|
+
return limit
|
|
1059
|
+
|
|
1048
1060
|
def maybe_compact(self) -> None:
|
|
1049
|
-
|
|
1061
|
+
limit = self.compact_limit()
|
|
1062
|
+
if self.ctx_used <= limit:
|
|
1050
1063
|
return
|
|
1064
|
+
enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
|
|
1051
1065
|
if self.settings.prune:
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1066
|
+
removed: list[str] = [] # files and pages whose only copy goes
|
|
1067
|
+
saved = context_tools.prune(self.messages, removed=removed) # tool output from before the last two requests
|
|
1068
|
+
if self.ctx_used - saved // 3 > enough:
|
|
1069
|
+
saved += context_tools.prune(
|
|
1070
|
+
self.messages, keep=1, removed=removed
|
|
1071
|
+
) # not enough: before the last request
|
|
1072
|
+
context_tools.note_removed(self.messages, removed)
|
|
1055
1073
|
if saved:
|
|
1056
1074
|
self.pruned += 1
|
|
1057
1075
|
self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
|
|
@@ -1059,9 +1077,17 @@ class Agent:
|
|
|
1059
1077
|
f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to "
|
|
1060
1078
|
"make room.[/]"
|
|
1061
1079
|
)
|
|
1062
|
-
if self.ctx_used <=
|
|
1080
|
+
if self.ctx_used <= enough:
|
|
1063
1081
|
return
|
|
1064
|
-
self.
|
|
1082
|
+
window_full = self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context
|
|
1083
|
+
if not window_full and self.summarized_size and self.ctx_used - self.summarized_size < limit // 4:
|
|
1084
|
+
return # summarized not long ago: summarizing again so soon would only lose more
|
|
1085
|
+
if limit < AUTO_COMPACT_RATIO * self.settings.context:
|
|
1086
|
+
self.console.print(
|
|
1087
|
+
f"[yellow]The conversation reached {format_tokens(limit)} tokens (compact_at) — summarizing it…[/]"
|
|
1088
|
+
)
|
|
1089
|
+
else:
|
|
1090
|
+
self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
|
|
1065
1091
|
self.compacted += 1
|
|
1066
1092
|
self.compact()
|
|
1067
1093
|
|
|
@@ -1082,9 +1108,13 @@ class Agent:
|
|
|
1082
1108
|
with self.console.status("[cyan]Compacting…[/]"):
|
|
1083
1109
|
for chunk in self.chat([*self.messages, {"role": "user", "content": instructions}], None, False):
|
|
1084
1110
|
summary += chunk.get("message", {}).get("content", "")
|
|
1111
|
+
requests = context_tools.user_requests(self.messages)
|
|
1085
1112
|
self.messages = [
|
|
1086
1113
|
{"role": "system", "content": self.system_prompt()},
|
|
1087
|
-
{
|
|
1114
|
+
{
|
|
1115
|
+
"role": "user",
|
|
1116
|
+
"content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
|
|
1117
|
+
},
|
|
1088
1118
|
{"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
|
|
1089
1119
|
]
|
|
1090
1120
|
if self.plan: # the approved plan stays word for word
|
|
@@ -1096,6 +1126,7 @@ class Agent:
|
|
|
1096
1126
|
)
|
|
1097
1127
|
self.skills_loaded.clear()
|
|
1098
1128
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1129
|
+
self.summarized_size = self.ctx_used
|
|
1099
1130
|
self.reflected = len(self.messages)
|
|
1100
1131
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
|
1101
1132
|
|
lcode/api.py
CHANGED
|
@@ -194,7 +194,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
194
194
|
embed_model=cfg["embed_model"],
|
|
195
195
|
notify=cfg["notify"],
|
|
196
196
|
notify_after=cfg["notify_after"],
|
|
197
|
+
secret_check=cfg["secret_check"],
|
|
197
198
|
repeat_limit=cfg["repeat_limit"],
|
|
199
|
+
compact_at=cfg["compact_at"],
|
|
198
200
|
)
|
|
199
201
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
200
202
|
agent.interactive = options.interactive
|
lcode/cli.py
CHANGED
|
@@ -579,6 +579,7 @@ def cmd_bench(args) -> None:
|
|
|
579
579
|
web="off",
|
|
580
580
|
checkpoints=False,
|
|
581
581
|
prune=not args.no_prune,
|
|
582
|
+
compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
|
|
582
583
|
repo_map=args.repo_map,
|
|
583
584
|
embed_model=cfg["embed_model"] if args.code_search else "off",
|
|
584
585
|
)
|
|
@@ -678,6 +679,14 @@ def cmd_chat(args) -> None:
|
|
|
678
679
|
sys.exit(exit_code)
|
|
679
680
|
|
|
680
681
|
|
|
682
|
+
def compact_tokens(value: str) -> int:
|
|
683
|
+
"""`--compact-at`: a token count such as 64k, or 0."""
|
|
684
|
+
try:
|
|
685
|
+
return config.parse_compact_at(value)
|
|
686
|
+
except ConfigError as e:
|
|
687
|
+
raise argparse.ArgumentTypeError(str(e)) from e
|
|
688
|
+
|
|
689
|
+
|
|
681
690
|
def build_parser() -> argparse.ArgumentParser:
|
|
682
691
|
parser = argparse.ArgumentParser(
|
|
683
692
|
prog="lcode",
|
|
@@ -765,6 +774,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
765
774
|
p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
|
|
766
775
|
p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
|
|
767
776
|
p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
|
|
777
|
+
p.add_argument(
|
|
778
|
+
"--compact-at",
|
|
779
|
+
type=compact_tokens,
|
|
780
|
+
metavar="TOKENS",
|
|
781
|
+
help="summarize at this many tokens, e.g. 64k (default: the compact_at setting; 0: only at 85%%)",
|
|
782
|
+
)
|
|
768
783
|
subs["bench"] = p
|
|
769
784
|
p = argparse.ArgumentParser(
|
|
770
785
|
prog="lcode index",
|
lcode/config.py
CHANGED
|
@@ -60,6 +60,8 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
60
60
|
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
|
|
61
61
|
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
|
|
62
62
|
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
63
|
+
"secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
|
|
64
|
+
"compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
|
|
63
65
|
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
|
|
64
66
|
}
|
|
65
67
|
ENV_OVERRIDES = {
|
|
@@ -80,6 +82,19 @@ class ConfigError(ValueError):
|
|
|
80
82
|
pass
|
|
81
83
|
|
|
82
84
|
|
|
85
|
+
MIN_COMPACT_AT = 16384 # below this, the system prompt and a summary alone come close to the ceiling
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_compact_at(value: str | int) -> int:
|
|
89
|
+
"""compact_at: a token count like parse_context's, at least 16K, or 0 (off)."""
|
|
90
|
+
if str(value).strip().lower() in ("0", "off", "false", "none"):
|
|
91
|
+
return 0
|
|
92
|
+
tokens = parse_context(value)
|
|
93
|
+
if tokens < MIN_COMPACT_AT:
|
|
94
|
+
raise ConfigError(f"compact_at {tokens} is too small: use at least 16k, or 0 to turn it off")
|
|
95
|
+
return tokens
|
|
96
|
+
|
|
97
|
+
|
|
83
98
|
def parse_context(value: str | int) -> int:
|
|
84
99
|
"""Parse '131072', '128k', '128K' or '1m' into a token count (k = 1024)."""
|
|
85
100
|
if isinstance(value, int):
|
|
@@ -117,6 +132,8 @@ def coerce(key: str, value: object) -> object:
|
|
|
117
132
|
_, typ, _ = SETTINGS[key]
|
|
118
133
|
if key == "context":
|
|
119
134
|
return parse_context(value) # type: ignore[arg-type]
|
|
135
|
+
if key == "compact_at":
|
|
136
|
+
return parse_compact_at(value) # type: ignore[arg-type]
|
|
120
137
|
if key in ("ollama_host", "searxng_url"):
|
|
121
138
|
return normalize_host(str(value))
|
|
122
139
|
if key == "base_url":
|
lcode/context.py
CHANGED
|
@@ -29,6 +29,10 @@ if TYPE_CHECKING:
|
|
|
29
29
|
KEEP_RECENT_REQUESTS = 2 # tool results of the last requests stay as they are
|
|
30
30
|
MIN_PRUNE_CHARS = 400 # shorter results aren't worth replacing
|
|
31
31
|
PRUNED = "[lcode: " # how stubs start, so they're never pruned twice
|
|
32
|
+
GONE = ( # a removed file read: firm, because models answer from the stub instead of reading it again
|
|
33
|
+
"earlier; its content was removed to save context and isn't in this conversation any more. Read it again "
|
|
34
|
+
"before answering anything about it.]"
|
|
35
|
+
)
|
|
32
36
|
KEEP_TOOLS = {"agent", "skill", "memory", "todo_write", "present_plan"} # short, or still guiding the work
|
|
33
37
|
ERROR_LINE = re.compile(
|
|
34
38
|
r"error|exception|traceback|failed|failure|fatal|panic|assert|denied|not found|cannot|can't|undefined", re.I
|
|
@@ -37,6 +41,56 @@ MAX_ERROR_LINES = 40
|
|
|
37
41
|
OUTPUT_DAYS = 7 # saved full outputs are deleted after this
|
|
38
42
|
|
|
39
43
|
|
|
44
|
+
# ----------------------------------------------------------------------------- the user's requests
|
|
45
|
+
|
|
46
|
+
REQUESTS_HEADER = "[The user's requests in this conversation, word for word, oldest first]"
|
|
47
|
+
MAX_REQUESTS = 8 # the most recent ones are kept through a summary
|
|
48
|
+
MAX_REQUEST_CHARS = 1500
|
|
49
|
+
# What lcode appends to the user's words: attached files, images and folders, and its own notes.
|
|
50
|
+
ATTACHED = re.compile(r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def request_text(content: str) -> str:
|
|
54
|
+
"""The user's own words in a message, without what lcode attached to it."""
|
|
55
|
+
cut = ATTACHED.search(content)
|
|
56
|
+
return (content[: cut.start()] if cut else content).strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def user_requests(messages: list[dict]) -> list[str]:
|
|
60
|
+
"""What the user asked in the conversation, oldest first, including the requests an earlier summary kept."""
|
|
61
|
+
requests: list[str] = []
|
|
62
|
+
for message in messages:
|
|
63
|
+
content = message.get("content") or ""
|
|
64
|
+
if message.get("role") != "user":
|
|
65
|
+
continue
|
|
66
|
+
if content.startswith(SUMMARY_PREFIX):
|
|
67
|
+
start = content.find(REQUESTS_HEADER)
|
|
68
|
+
if start >= 0:
|
|
69
|
+
rest = content[start + len(REQUESTS_HEADER) :]
|
|
70
|
+
try:
|
|
71
|
+
kept, _ = json.JSONDecoder().raw_decode(rest[rest.index("[") :])
|
|
72
|
+
requests += [str(r) for r in kept]
|
|
73
|
+
except ValueError:
|
|
74
|
+
pass
|
|
75
|
+
continue
|
|
76
|
+
if content.startswith("[lcode]"):
|
|
77
|
+
continue # lcode's own nudges
|
|
78
|
+
text = request_text(content)
|
|
79
|
+
if text:
|
|
80
|
+
requests.append(text)
|
|
81
|
+
return requests
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def requests_block(requests: list[str]) -> str:
|
|
85
|
+
"""The user's latest requests, word for word, to keep the goal through a summary."""
|
|
86
|
+
if not requests:
|
|
87
|
+
return ""
|
|
88
|
+
kept = [r if len(r) <= MAX_REQUEST_CHARS else f"{r[: MAX_REQUEST_CHARS - 200]} […] {r[-150:]}" for r in requests]
|
|
89
|
+
older = len(kept) - MAX_REQUESTS
|
|
90
|
+
note = f" (and {older} earlier, in the summary)" if older > 0 else ""
|
|
91
|
+
return f"\n\n{REQUESTS_HEADER}{note}\n{json.dumps(kept[-MAX_REQUESTS:], ensure_ascii=False, indent=1)}"
|
|
92
|
+
|
|
93
|
+
|
|
40
94
|
# ----------------------------------------------------------------------------- pruning
|
|
41
95
|
|
|
42
96
|
|
|
@@ -63,7 +117,7 @@ def stub(name: str, args: dict, content: str) -> str:
|
|
|
63
117
|
if args.get("offset") or args.get("limit"):
|
|
64
118
|
start = int(args.get("offset") or 1)
|
|
65
119
|
span = f" from line {start}"
|
|
66
|
-
return f"{PRUNED}read {args.get('path', 'a file')}{span}
|
|
120
|
+
return f"{PRUNED}read {args.get('path', 'a file')}{span} {GONE}"
|
|
67
121
|
if name == "bash":
|
|
68
122
|
command = " ".join(str(args.get("command", "")).split())
|
|
69
123
|
command = command if len(command) <= 80 else command[:79] + "…"
|
|
@@ -92,8 +146,9 @@ def boundary(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
|
|
|
92
146
|
return requests[-keep] if len(requests) >= keep else 0
|
|
93
147
|
|
|
94
148
|
|
|
95
|
-
def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
|
|
96
|
-
"""Replace stale tool results with stubs, in place. Returns the number of characters saved
|
|
149
|
+
def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS, removed: list[str] | None = None) -> int:
|
|
150
|
+
"""Replace stale tool results with stubs, in place. Returns the number of characters saved; the files and
|
|
151
|
+
pages whose only copy went are added to `removed`."""
|
|
97
152
|
calls = calls_for_results(messages)
|
|
98
153
|
edge = boundary(messages, keep)
|
|
99
154
|
# A read is superseded by a later read of the whole file, or of the same lines: not by a later
|
|
@@ -118,12 +173,34 @@ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
|
|
|
118
173
|
continue
|
|
119
174
|
new = stub(name, args, content)
|
|
120
175
|
if reread:
|
|
121
|
-
new = new.replace(
|
|
176
|
+
new = new.replace(
|
|
177
|
+
GONE, "earlier (read again later); removed to save context. Read it again if you need it.]"
|
|
178
|
+
)
|
|
179
|
+
elif removed is not None and name in ("read_file", "web_fetch"):
|
|
180
|
+
what = str(args.get("path") or args.get("url") or "")
|
|
181
|
+
if what and what not in removed:
|
|
182
|
+
removed.append(what)
|
|
122
183
|
messages[i]["content"] = new
|
|
123
184
|
saved += len(content) - len(new)
|
|
124
185
|
return saved
|
|
125
186
|
|
|
126
187
|
|
|
188
|
+
def note_removed(messages: list[dict], removed: list[str]) -> None:
|
|
189
|
+
"""Tell the model, next to the request it's working on, which files it no longer has: a stub deep in the
|
|
190
|
+
conversation is easy to overlook, and models then answer from memory instead of reading again."""
|
|
191
|
+
if not removed:
|
|
192
|
+
return
|
|
193
|
+
for message in reversed(messages):
|
|
194
|
+
content = str(message.get("content", ""))
|
|
195
|
+
if message.get("role") == "user" and not content.startswith(("[lcode]", SUMMARY_PREFIX)):
|
|
196
|
+
names = ", ".join(removed[:10]) + (f" and {len(removed) - 10} more" if len(removed) > 10 else "")
|
|
197
|
+
message["content"] = (
|
|
198
|
+
f"{content}\n\n[lcode] To make room, lcode removed what you read earlier from: {names}. It isn't in "
|
|
199
|
+
"this conversation any more: read it again before answering anything about it, don't answer from memory."
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
|
|
127
204
|
# ----------------------------------------------------------------------------- long output
|
|
128
205
|
|
|
129
206
|
|
lcode/gitflow.py
CHANGED
|
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING
|
|
|
24
24
|
|
|
25
25
|
from rich.markup import escape
|
|
26
26
|
from rich.panel import Panel
|
|
27
|
+
from rich.text import Text
|
|
27
28
|
|
|
28
29
|
if TYPE_CHECKING:
|
|
29
30
|
from lcode.agent import Agent
|
|
@@ -316,6 +317,9 @@ def commit_command(agent: Agent, hint: str = "") -> None:
|
|
|
316
317
|
chosen = [f for f in answer.get("files") or [] if f in paths]
|
|
317
318
|
files = chosen or paths
|
|
318
319
|
left_out = [p for p in paths if p not in files]
|
|
320
|
+
from lcode import secretscan
|
|
321
|
+
|
|
322
|
+
secrets_found = secretscan.in_files(root, files, bool(staged)) if agent.settings.secret_check else []
|
|
319
323
|
while True:
|
|
320
324
|
c.print(Panel(escape(message), title="Commit message", title_align="left", border_style="cyan"))
|
|
321
325
|
c.print(f" [bold]Files[/] {escape(', '.join(files))}")
|
|
@@ -323,6 +327,9 @@ def commit_command(agent: Agent, hint: str = "") -> None:
|
|
|
323
327
|
c.print(f" [dim]Left out (unrelated, the model thinks): {escape(', '.join(left_out))}[/]")
|
|
324
328
|
if risky:
|
|
325
329
|
c.print(f" [dim]Left out (may hold secrets; stage them yourself if needed): {escape(', '.join(risky))}[/]")
|
|
330
|
+
if secrets_found:
|
|
331
|
+
c.print(Text(secretscan.report("commit", secrets_found), style="red"))
|
|
332
|
+
c.print(" [dim]Commit them only if they aren't secrets; otherwise move them out of the code first.[/]")
|
|
326
333
|
choice = confirm(agent, " [y] commit · [e] edit the message · [n] cancel: ", message)
|
|
327
334
|
if choice == "e":
|
|
328
335
|
from lcode.planning import edit_text
|
|
@@ -552,6 +559,14 @@ def pr_command(agent: Agent, arg: str = "") -> None:
|
|
|
552
559
|
|
|
553
560
|
|
|
554
561
|
def push(agent: Agent, root: Path, remote: str, branch: str) -> bool:
|
|
562
|
+
from lcode import secretscan
|
|
563
|
+
|
|
564
|
+
found = secretscan.outgoing(root, branch) if agent.settings.secret_check else []
|
|
565
|
+
if found:
|
|
566
|
+
agent.console.print(Text(secretscan.report("push", found), style="red"))
|
|
567
|
+
if confirm(agent, " Push anyway? [y]es / [n]o: ") != "y":
|
|
568
|
+
agent.console.print("Not pushed.")
|
|
569
|
+
return False
|
|
555
570
|
try:
|
|
556
571
|
with agent.console.status(f"Pushing {escape(branch)}…", spinner="dots"):
|
|
557
572
|
git(root, "push", "-q", "-u", remote, branch, timeout=300)
|
lcode/repl.py
CHANGED
|
@@ -242,7 +242,8 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
|
242
242
|
c.print(
|
|
243
243
|
f"In use: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
|
|
244
244
|
f"({100 * agent.ctx_used / s.context:.0f}%) in {len(agent.messages)} message"
|
|
245
|
-
f"{'' if len(agent.messages) == 1 else 's'};
|
|
245
|
+
f"{'' if len(agent.messages) == 1 else 's'}; makes room at {format_tokens(agent.compact_limit())} "
|
|
246
|
+
+ ("(compact_at)." if agent.compact_limit() < AUTO_COMPACT_RATIO * s.context else "(85%).")
|
|
246
247
|
)
|
|
247
248
|
if agent.mcp and agent.mcp.ready():
|
|
248
249
|
how = "found on demand" if agent.mcp.searching(s.context) else "sent with every request"
|
lcode/secretscan.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Stop secrets before they're committed or pushed.
|
|
2
|
+
|
|
3
|
+
Before the model runs `git commit` or `git push`, lcode scans what that would add to the repository's
|
|
4
|
+
history: the lines a commit adds (including files a `git add` in the same command stages), or every commit
|
|
5
|
+
a push would send. Passwords and tokens in code, private keys, credentials in URLs (`rtsp://user:pass@…`)
|
|
6
|
+
and `.env` files are reported with their file and line. In `ask` mode the user decides; in the other modes
|
|
7
|
+
the command doesn't run, and the model is told to move the secret out of the code.
|
|
8
|
+
|
|
9
|
+
A line that holds a value which only looks like a secret can carry `lcode: allow-secret` (or
|
|
10
|
+
`gitleaks:allow`) in a comment, and is skipped.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from lcode.memory import SECRET_PATTERNS
|
|
22
|
+
|
|
23
|
+
ALLOW = ("lcode: allow-secret", "lcode:allow-secret", "gitleaks:allow")
|
|
24
|
+
MAX_FINDINGS = 10
|
|
25
|
+
GIT_TIMEOUT = 20
|
|
26
|
+
MAX_FILE = 1_000_000 # bytes of an untracked file that are scanned
|
|
27
|
+
|
|
28
|
+
# Values that only stand in for a secret.
|
|
29
|
+
PLACEHOLDER = re.compile(
|
|
30
|
+
r"^(?:change[_-]?me\w*|your[_-]?\w*|x+|\*+|\.+|…+|<[^>]*>"
|
|
31
|
+
r"|(?:dummy|example|placeholder|redacted|secret|password|passwd|pass|pwd|token|test|changeit|none|null"
|
|
32
|
+
r"|true|false|required|optional|hidden|masked)(?:[_-]?here)?)$",
|
|
33
|
+
re.I,
|
|
34
|
+
)
|
|
35
|
+
# Words that only appear in made-up values: "change-me-in-production", "test-token", "example-key"…
|
|
36
|
+
FAKE_WORDS = (
|
|
37
|
+
"example",
|
|
38
|
+
"sample",
|
|
39
|
+
"dummy",
|
|
40
|
+
"fake",
|
|
41
|
+
"changeme",
|
|
42
|
+
"change-me",
|
|
43
|
+
"change_me",
|
|
44
|
+
"placeholder",
|
|
45
|
+
"redacted",
|
|
46
|
+
"test",
|
|
47
|
+
)
|
|
48
|
+
# A password, key or token written in code or configuration as a literal value.
|
|
49
|
+
ASSIGNED = re.compile(
|
|
50
|
+
r"""(?P<name>\b[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key"""
|
|
51
|
+
r"""|client[_-]?secret)[\w.-]*)['"]?\s*(?::|=|:=|=>)\s*(?P<quote>['"]?)(?P<value>[^\s'"`,;)}\]]{6,})"""
|
|
52
|
+
r"""(?P=quote)""",
|
|
53
|
+
re.I,
|
|
54
|
+
)
|
|
55
|
+
# Names that hold something about a secret (where it is, how it looks), not the secret itself.
|
|
56
|
+
ABOUT = re.compile(
|
|
57
|
+
r"(?:url|uri|endpoint|file|path|dir|env|var|variable|name|field|header|type|label|prompt|hint|length|len|min|"
|
|
58
|
+
r"max|policy|pattern|regex|format|count|expires?|expiry|ttl|timeout|limit|required|enabled|hash|salt_rounds)$",
|
|
59
|
+
re.I,
|
|
60
|
+
)
|
|
61
|
+
EXTRA = [
|
|
62
|
+
("an xAI API key", re.compile(r"\bxai-[A-Za-z0-9]{20,}")),
|
|
63
|
+
("a Hugging Face token", re.compile(r"\bhf_[A-Za-z0-9]{30,}")),
|
|
64
|
+
("a Stripe key", re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}")),
|
|
65
|
+
]
|
|
66
|
+
CONFIG_FILES = re.compile(r"(?:\.(?:env|ini|cfg|conf|config|properties|ya?ml|toml)|(?:^|/)\.env[\w.-]*)$", re.I)
|
|
67
|
+
URL_PASSWORD = re.compile(r"\b[a-z][a-z0-9+.\-]*://(?P<user>[^\s/:@'\"]+):(?P<value>[^\s/@'\"]+)@", re.I)
|
|
68
|
+
STRONG = [p for p in SECRET_PATTERNS if p[0] not in ("a password in a URL", "a password or key")] + EXTRA
|
|
69
|
+
ENV_FILE = re.compile(r"(?:^|/)\.env(?:\.[\w-]+)?$")
|
|
70
|
+
ENV_EXAMPLES = re.compile(r"\.(?:example|sample|template|dist|defaults?)$")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Finding:
|
|
75
|
+
path: str
|
|
76
|
+
line: int # 0: the whole file
|
|
77
|
+
label: str # what it looks like, e.g. "a password"
|
|
78
|
+
excerpt: str # the line, with the secret masked
|
|
79
|
+
commit: str = "" # for a push: the commit that adds it
|
|
80
|
+
|
|
81
|
+
def describe(self, excerpt: bool = True) -> str:
|
|
82
|
+
where = f"{self.path}:{self.line}" if self.line else self.path
|
|
83
|
+
if self.commit:
|
|
84
|
+
where += f" (commit {self.commit})"
|
|
85
|
+
return f"{where}: {self.label}" + (f": {self.excerpt}" if self.excerpt and excerpt else "")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def mask(value: str) -> str:
|
|
89
|
+
return value[:2] + "…" if len(value) > 4 else "…"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def placeholder(value: str) -> bool:
|
|
93
|
+
"""A value that stands in for a secret: CHANGE_ME, <token>, ${PASSWORD}, a path, a web address…"""
|
|
94
|
+
if PLACEHOLDER.match(value) or any(c in value for c in "{}$%<>"):
|
|
95
|
+
return True
|
|
96
|
+
if any(word in value.lower() for word in FAKE_WORDS):
|
|
97
|
+
return True
|
|
98
|
+
return value.startswith(("/", "./", "~/", "http:", "https:", "os.", "process.env", "env."))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def scan_line(text: str, config: bool = False) -> tuple[str, str] | None:
|
|
102
|
+
"""What kind of secret the line holds, and the line with it masked. In configuration files (`config`) a
|
|
103
|
+
value counts without quotes too, as in `PASSWORD=hunter22` or `password: hunter22`."""
|
|
104
|
+
if any(marker in text for marker in ALLOW):
|
|
105
|
+
return None
|
|
106
|
+
shown = text.strip()[:160]
|
|
107
|
+
for label, pattern in STRONG:
|
|
108
|
+
found = pattern.search(text)
|
|
109
|
+
if found:
|
|
110
|
+
return label, shown.replace(found.group(0), mask(found.group(0)))
|
|
111
|
+
found = URL_PASSWORD.search(text)
|
|
112
|
+
if found and not placeholder(found.group("value")) and not placeholder(found.group("user")):
|
|
113
|
+
return "a password in a URL", shown.replace(found.group("value"), mask(found.group("value")))
|
|
114
|
+
for found in ASSIGNED.finditer(text):
|
|
115
|
+
value = found.group("value")
|
|
116
|
+
if placeholder(value) or ABOUT.search(found.group("name")):
|
|
117
|
+
continue
|
|
118
|
+
if not found.group("quote") and not (config and re.match(r"^\s*(?:export\s+)?[\w.-]+\s*[:=]", text)):
|
|
119
|
+
continue # in code, only a quoted literal is a value: `password = args.password` isn't one
|
|
120
|
+
return "a password or key", shown.replace(value, mask(value))
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def scan_file(path: str, lines: list[tuple[int, str]], commit: str = "") -> list[Finding]:
|
|
125
|
+
if ENV_FILE.search(path) and not ENV_EXAMPLES.search(path):
|
|
126
|
+
return [Finding(path, 0, "an environment file (.env), which usually holds secrets", "", commit)]
|
|
127
|
+
findings = []
|
|
128
|
+
config = bool(CONFIG_FILES.search(path))
|
|
129
|
+
for number, text in lines:
|
|
130
|
+
hit = scan_line(text, config)
|
|
131
|
+
if hit:
|
|
132
|
+
findings.append(Finding(path, number, hit[0], hit[1], commit))
|
|
133
|
+
return findings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def scan_diff(diff: str) -> list[Finding]:
|
|
137
|
+
"""The secrets in the lines a unified diff adds (`git diff`, or `git log -p` with commit: lines)."""
|
|
138
|
+
findings: list[Finding] = []
|
|
139
|
+
path, number, commit = "", 0, ""
|
|
140
|
+
added: list[tuple[int, str]] = []
|
|
141
|
+
|
|
142
|
+
def flush() -> None:
|
|
143
|
+
if path and (added or ENV_FILE.search(path)):
|
|
144
|
+
findings.extend(scan_file(path, added, commit))
|
|
145
|
+
added.clear()
|
|
146
|
+
|
|
147
|
+
for line in diff.splitlines():
|
|
148
|
+
if line.startswith("commit:"):
|
|
149
|
+
flush()
|
|
150
|
+
commit, path = line.split(":", 1)[1].strip(), ""
|
|
151
|
+
elif line.startswith("+++ "):
|
|
152
|
+
flush()
|
|
153
|
+
target = line[4:].strip()
|
|
154
|
+
path = "" if target == "/dev/null" else target.removeprefix("b/")
|
|
155
|
+
elif line.startswith("@@"):
|
|
156
|
+
hunk = re.search(r"\+(\d+)", line)
|
|
157
|
+
number = int(hunk.group(1)) if hunk else 0
|
|
158
|
+
elif line.startswith("+") and path:
|
|
159
|
+
added.append((number, line[1:]))
|
|
160
|
+
number += 1
|
|
161
|
+
elif line.startswith(" "):
|
|
162
|
+
number += 1
|
|
163
|
+
flush()
|
|
164
|
+
return findings
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def git(cwd: Path, *args: str) -> str:
|
|
168
|
+
try:
|
|
169
|
+
done = subprocess.run(
|
|
170
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, errors="replace", timeout=GIT_TIMEOUT
|
|
171
|
+
)
|
|
172
|
+
except (OSError, subprocess.SubprocessError):
|
|
173
|
+
return ""
|
|
174
|
+
return done.stdout if done.returncode == 0 else ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
GIT_VERB = re.compile(r"\bgit\b(?:\s+-[-\w=.]+(?:\s+[^\s'\"-]\S*)?)*\s+(commit|push)\b")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class GitSteps:
|
|
182
|
+
adds: list[list[str]] # the arguments of each `git add`
|
|
183
|
+
commit: list[str] | None # the arguments of `git commit`
|
|
184
|
+
push: list[str] | None # the arguments of `git push`
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def git_steps(command: str) -> GitSteps | None:
|
|
188
|
+
"""The git add, commit and push a shell command runs, or None when it commits and pushes nothing."""
|
|
189
|
+
steps = GitSteps([], None, None)
|
|
190
|
+
for segment in re.split(r"&&|\|\||;|\n|\|", command):
|
|
191
|
+
try:
|
|
192
|
+
words = shlex.split(segment)
|
|
193
|
+
except ValueError:
|
|
194
|
+
words = segment.split()
|
|
195
|
+
while words and re.fullmatch(r"\w+=\S*", words[0]):
|
|
196
|
+
words = words[1:] # VAR=value in front
|
|
197
|
+
if len(words) < 2 or words[0] != "git":
|
|
198
|
+
continue
|
|
199
|
+
i = 1
|
|
200
|
+
while i < len(words) and words[i].startswith("-"):
|
|
201
|
+
i += 2 if words[i] in ("-C", "-c") else 1 # git -C dir / -c key=value
|
|
202
|
+
if i >= len(words):
|
|
203
|
+
continue
|
|
204
|
+
verb, rest = words[i].strip("'\""), words[i + 1 :]
|
|
205
|
+
if verb == "add":
|
|
206
|
+
steps.adds.append(rest)
|
|
207
|
+
elif verb == "commit":
|
|
208
|
+
steps.commit = rest
|
|
209
|
+
elif verb == "push":
|
|
210
|
+
steps.push = rest
|
|
211
|
+
# Inside quotes, as in bash -c "git add . && git commit …", sh -c or eval, what gets staged can't be told
|
|
212
|
+
# from the words: assume everything.
|
|
213
|
+
nested = [verb for m in re.finditer(r"(['\"])(.*?)\1", command, re.S) for verb in GIT_VERB.findall(m.group(2))]
|
|
214
|
+
if steps.commit is None and steps.push is None:
|
|
215
|
+
nested += GIT_VERB.findall(command)
|
|
216
|
+
if "commit" in nested:
|
|
217
|
+
steps.commit, steps.adds = ["--all"], [["."]]
|
|
218
|
+
if "push" in nested and steps.push is None:
|
|
219
|
+
steps.push = []
|
|
220
|
+
return steps if steps.commit is not None or steps.push is not None else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def untracked(cwd: Path, paths: list[str]) -> list[Finding]:
|
|
224
|
+
findings = []
|
|
225
|
+
for name in git(cwd, "ls-files", "--others", "--exclude-standard", "--", *paths).splitlines():
|
|
226
|
+
file = cwd / name
|
|
227
|
+
try:
|
|
228
|
+
if file.stat().st_size > MAX_FILE:
|
|
229
|
+
continue
|
|
230
|
+
text = file.read_text(errors="strict")
|
|
231
|
+
except (OSError, UnicodeDecodeError):
|
|
232
|
+
continue # binary or unreadable
|
|
233
|
+
findings.extend(scan_file(name, list(enumerate(text.splitlines(), 1))))
|
|
234
|
+
return findings
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def to_commit(cwd: Path, steps: GitSteps) -> list[Finding]:
|
|
238
|
+
"""What `git add` and `git commit` in the command would commit: the staged changes, plus what it stages."""
|
|
239
|
+
args = steps.commit or []
|
|
240
|
+
everything = any(a in ("-a", "--all") or re.fullmatch(r"-[a-zA-Z]*a[a-zA-Z]*", a) for a in args if a != "--amend")
|
|
241
|
+
findings = scan_diff(git(cwd, "diff", "--cached", "--no-color", "--no-ext-diff", "-U0"))
|
|
242
|
+
if everything:
|
|
243
|
+
findings += scan_diff(git(cwd, "diff", "--no-color", "--no-ext-diff", "-U0"))
|
|
244
|
+
for add in steps.adds:
|
|
245
|
+
options = [a for a in add if a.startswith("-")]
|
|
246
|
+
paths = [a for a in add if not a.startswith("-")] or (["."] if {"-A", "--all"} & set(options) else [])
|
|
247
|
+
if not paths and "-u" not in options and "--update" not in options:
|
|
248
|
+
continue
|
|
249
|
+
paths = paths or ["."]
|
|
250
|
+
findings += scan_diff(git(cwd, "diff", "--no-color", "--no-ext-diff", "-U0", "--", *paths))
|
|
251
|
+
if "-u" not in options and "--update" not in options:
|
|
252
|
+
findings += untracked(cwd, paths)
|
|
253
|
+
return findings
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def to_push(cwd: Path, steps: GitSteps) -> list[Finding]:
|
|
257
|
+
"""The secrets in the commits a push would send that no remote has yet."""
|
|
258
|
+
refs = [a for a in (steps.push or []) if not a.startswith("-")]
|
|
259
|
+
branches = [r.split(":", 1)[0].lstrip("+") for r in refs[1:]] or ["HEAD"]
|
|
260
|
+
findings: list[Finding] = []
|
|
261
|
+
for branch in branches:
|
|
262
|
+
if not branch or not git(cwd, "rev-parse", "--verify", "--quiet", f"{branch}^{{commit}}").strip():
|
|
263
|
+
continue
|
|
264
|
+
log = git(
|
|
265
|
+
cwd, "log", "-p", "--no-color", "--no-ext-diff", "-U0", "--format=commit:%h", branch, "--not", "--remotes"
|
|
266
|
+
)
|
|
267
|
+
findings += scan_diff(log)
|
|
268
|
+
return findings
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check(cwd: Path, command: str) -> tuple[str, list[Finding]]:
|
|
272
|
+
"""('commit' or 'push', the secrets it would add to the history) for a shell command."""
|
|
273
|
+
steps = git_steps(command)
|
|
274
|
+
if steps is None or not git(cwd, "rev-parse", "--git-dir").strip():
|
|
275
|
+
return "", []
|
|
276
|
+
findings: list[Finding] = []
|
|
277
|
+
if steps.commit is not None:
|
|
278
|
+
findings += to_commit(cwd, steps)
|
|
279
|
+
if steps.push is not None:
|
|
280
|
+
findings += to_push(cwd, steps)
|
|
281
|
+
unique = list({(f.path, f.line, f.label): f for f in findings}.values())
|
|
282
|
+
return ("push" if steps.push is not None and steps.commit is None else "commit"), unique
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def in_files(cwd: Path, paths: list[str], staged: bool) -> list[Finding]:
|
|
286
|
+
"""For lcode's /commit: the secrets in the staged changes, or in the changes to these files."""
|
|
287
|
+
if staged:
|
|
288
|
+
return scan_diff(git(cwd, "diff", "--cached", "--no-color", "--no-ext-diff", "-U0"))
|
|
289
|
+
if not paths:
|
|
290
|
+
return []
|
|
291
|
+
diffs = [
|
|
292
|
+
git(cwd, "diff", *staged_flag, "--no-color", "--no-ext-diff", "-U0", "--", *paths)
|
|
293
|
+
for staged_flag in ([], ["--cached"])
|
|
294
|
+
]
|
|
295
|
+
return scan_diff("\n".join(diffs)) + untracked(cwd, paths)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def outgoing(cwd: Path, branch: str = "HEAD") -> list[Finding]:
|
|
299
|
+
"""For lcode's /pr: the secrets in the commits of `branch` that no remote has yet."""
|
|
300
|
+
return to_push(cwd, GitSteps([], None, ["origin", branch]))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def report(action: str, findings: list[Finding], excerpts: bool = True) -> str:
|
|
304
|
+
"""The findings, one per line; without `excerpts` for places others read, like a public comment."""
|
|
305
|
+
lines = [f"- {f.describe(excerpts)}" for f in findings[:MAX_FINDINGS]]
|
|
306
|
+
if len(findings) > MAX_FINDINGS:
|
|
307
|
+
lines.append(f"- … and {len(findings) - MAX_FINDINGS} more")
|
|
308
|
+
return f"This {action} would add what looks like a secret to the repository's history:\n" + "\n".join(lines)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
ADVICE = (
|
|
312
|
+
"lcode didn't run the command. Don't commit secrets: read them from an environment variable or from a "
|
|
313
|
+
"configuration file that git ignores (and commit an example with placeholders instead). If one of these "
|
|
314
|
+
"isn't a secret, tell the user: they decide."
|
|
315
|
+
)
|
lcode/tools.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import difflib
|
|
6
6
|
import fnmatch
|
|
7
|
+
import hashlib
|
|
7
8
|
import inspect
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
@@ -19,11 +20,12 @@ from collections.abc import Callable
|
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import TYPE_CHECKING
|
|
21
22
|
|
|
23
|
+
from rich.console import Group
|
|
22
24
|
from rich.panel import Panel
|
|
23
25
|
from rich.syntax import Syntax
|
|
24
26
|
from rich.text import Text
|
|
25
27
|
|
|
26
|
-
from lcode import web
|
|
28
|
+
from lcode import secretscan, web
|
|
27
29
|
from lcode.context import shorten
|
|
28
30
|
from lcode.jobs import JobError, describe, stop_leftovers
|
|
29
31
|
from lcode.permissions import bash_key, is_read_only
|
|
@@ -823,7 +825,28 @@ class Toolbox:
|
|
|
823
825
|
verdict = self.agent.perms.rule("bash", command)
|
|
824
826
|
if verdict is not None and not verdict[0]: # deny rules apply to read-only commands too
|
|
825
827
|
return verdict[1]
|
|
826
|
-
|
|
828
|
+
approved = False
|
|
829
|
+
if self.agent.settings.secret_check and not is_read_only(command):
|
|
830
|
+
action, found = secretscan.check(self.agent.cwd, command)
|
|
831
|
+
if found:
|
|
832
|
+
text = secretscan.report(action, found)
|
|
833
|
+
if self.agent.perms.mode != "ask": # nobody looks at each command: it doesn't run
|
|
834
|
+
self.console.print(Text(f" ⎿ {text}", style="red"))
|
|
835
|
+
return f"Error: lcode's secret check stopped this command. {text}\n\n{secretscan.ADVICE}"
|
|
836
|
+
body = Group(Text(text + "\n", style="red"), Syntax(command, "bash", theme="monokai", word_wrap=True))
|
|
837
|
+
key = "secret:" + hashlib.sha256(text.encode()).hexdigest()[:16]
|
|
838
|
+
ok, feedback = self.agent.perms.request(
|
|
839
|
+
key, "secret", f"This {action} looks like it holds a secret", body, command
|
|
840
|
+
)
|
|
841
|
+
if not ok:
|
|
842
|
+
return f"{feedback}\n\n{text}\n{secretscan.ADVICE}"
|
|
843
|
+
approved = True
|
|
844
|
+
if (
|
|
845
|
+
not approved
|
|
846
|
+
and not is_read_only(command)
|
|
847
|
+
and not (sandbox and self.agent.perms.mode == "auto-edit")
|
|
848
|
+
and verdict is None
|
|
849
|
+
):
|
|
827
850
|
body = Syntax(command, "bash", theme="monokai", word_wrap=True)
|
|
828
851
|
where = "in the sandbox" if sandbox else f"in {self.agent.cwd}"
|
|
829
852
|
ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body, command)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.27.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=JUcNjfBPUQX6hKCFSklgC04DhC6xrDnysFFhCU7p5Xc,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=Tpc8lsvffZSYbTRI4t7X4E2chahqZIPcKeYIcL5bV3Y,29035
|
|
4
|
-
lcode/action.py,sha256=
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
4
|
+
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
+
lcode/agent.py,sha256=tGNjbs8cw2X7IUBb70juI3JH5IpXmmIXOcteTsMhOtA,57936
|
|
6
|
+
lcode/api.py,sha256=aqWnGCy1jNuIktJEHo9ncbHNTXn2QO3BoNiggyo9ZmQ,15456
|
|
7
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
8
8
|
lcode/bench.py,sha256=7ICipu_2GXW6vE519XeZucc2GSWA44votT8JvqcNv1k,40964
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
10
10
|
lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
|
|
11
11
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
12
12
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
13
|
-
lcode/cli.py,sha256=
|
|
13
|
+
lcode/cli.py,sha256=M6Mjd5BROSGlZj4J6TrSZMJqI-OVfUEWPoC0TQyOQ28,40831
|
|
14
14
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
15
15
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
16
|
-
lcode/config.py,sha256=
|
|
17
|
-
lcode/context.py,sha256=
|
|
16
|
+
lcode/config.py,sha256=q1zGzH3p5U23ai6O9Jai3dS-s30CuQ9gc5cWX0_yxWE,11195
|
|
17
|
+
lcode/context.py,sha256=_f0NbXyjA4SyH2RCr49AiWwtAfr_PVDaqyFQ5fZDhkg,14404
|
|
18
18
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
19
19
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
20
|
-
lcode/gitflow.py,sha256=
|
|
20
|
+
lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
|
|
21
21
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
22
22
|
lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
|
|
23
23
|
lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
|
|
@@ -31,12 +31,13 @@ lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
|
|
|
31
31
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
32
32
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
33
33
|
lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
|
|
34
|
-
lcode/repl.py,sha256=
|
|
34
|
+
lcode/repl.py,sha256=Y53tTvdoTP6CZOtq96CzbvPcwVlYNc9fppMV0hAlRSY,49573
|
|
35
35
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
36
36
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
37
|
+
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
37
38
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
38
39
|
lcode/subagents.py,sha256=Z2RHp7an16dhOGReucVIEOAnXjxM8jx7sHvWd_Fp2V4,29142
|
|
39
|
-
lcode/tools.py,sha256=
|
|
40
|
+
lcode/tools.py,sha256=jqxMcyvvDZs0rV0AROYxpJ4krxbU6LbkOWnxiLEqq1o,48426
|
|
40
41
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
41
42
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
42
43
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -56,8 +57,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
56
57
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
57
58
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
58
59
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
59
|
-
lcode_cli-0.
|
|
60
|
-
lcode_cli-0.
|
|
61
|
-
lcode_cli-0.
|
|
62
|
-
lcode_cli-0.
|
|
63
|
-
lcode_cli-0.
|
|
60
|
+
lcode_cli-0.27.0.dist-info/METADATA,sha256=pASpYtQ_MeN-50aD2sdpTfxMKuX2Tz00LaJa2pT1DmM,12620
|
|
61
|
+
lcode_cli-0.27.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
62
|
+
lcode_cli-0.27.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
63
|
+
lcode_cli-0.27.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
64
|
+
lcode_cli-0.27.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|