Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ All notable changes to lcode are documented here. The format follows

## [Unreleased]

### Added

- lcode checks for secrets before the model runs `git commit` or `git push`: passwords, keys and
tokens in code or configuration, credentials in URLs (`rtsp://user:pass@…`), private keys,
tokens with a known form, and `.env` files, in the lines a commit adds or the commits a push
sends. In `ask` mode you decide; in `auto-edit` and `yolo` the command doesn't run, and the model
is told to move the secret out of the code. `/commit` and `/pr` show the findings before you
confirm, and the GitHub Action doesn't commit at all. `lcode: allow-secret` in a comment allows a
line; `secret_check = false` turns the check off. See
[Secrets in commits](https://nasser1941.github.io/lcode/usage/#secrets-in-commits).

## [0.25.0] - 2026-10-10

### Changed
Expand Down
2 changes: 2 additions & 0 deletions docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ with Session("path/to/repo", permission_mode="auto-edit") as session:
- **Events while it runs:** `session.stream(prompt)` yields the events above, ending with the result.
- **Permissions:** pass `approve` to decide permission requests. It gets
`{"kind": "bash", "title": "…", "target": "pytest -q"}`; without it, every request is refused.
A `git commit` or `git push` that would add [what looks like a secret](usage.md#secrets-in-commits)
comes as `{"kind": "secret", …}` in `ask` mode, and doesn't run in the other modes.
- **Options:** `model`, `context`, `permission_mode`, `allowed_tools`, `max_steps`, `think`,
`web`, `memory`, `mcp` and `sandbox`. Leave one out to use your
[configuration](configuration.md).
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ lcode config path # print the file location
| `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
| `notify_after` | `30` | Seconds a request runs before it notifies |
| `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off |
| `secret_check` | `true` | Before `git commit` and `git push`, look for passwords, keys and tokens they'd add ([details](usage.md#secrets-in-commits)) |
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |

Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
Expand Down
33 changes: 33 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,39 @@ deny lists, per user or per repository; deny always wins, even in `yolo`. Text a
With the [sandbox](sandbox.md) on, `auto-edit` mode runs shell commands without asking too: they can
only reach the project, and `/undo` can take their changes back.

### Secrets in commits

Before the model runs `git commit` or `git push`, lcode checks what would go into the repository's
history: the lines a commit adds (including files a `git add` in the same command stages), or every
commit a push would send. It looks for:

- passwords, keys and tokens written as values in code or configuration, like
`"password": "…"` or `API_KEY=…`;
- credentials in addresses, like `rtsp://admin:…@192.168.1.10`;
- private keys and tokens with a known form (GitHub, GitLab, AWS, Slack, OpenAI, Anthropic, xAI,
Google, Hugging Face, Stripe);
- `.env` files.

Placeholders such as `CHANGE_ME`, `your_password`, `${PASSWORD}` or `os.environ["PASSWORD"]` don't
count.

```text
This commit would add what looks like a secret to the repository's history:
- camera_wall.py:42: a password or key: "password": "Xk…"
```

- **In `ask` mode,** you decide: lcode shows the findings and the command.
- **In `auto-edit` and `yolo`,** the command doesn't run. The model is told to read the secret from
an environment variable or a configuration file that git ignores, and to ask you if it isn't one.
- **`/commit` and `/pr`** show the same findings before you confirm.
- **The [GitHub Action](automation.md#github-action)** doesn't commit or push at all when it finds
one.

If a line only looks like a secret, add `lcode: allow-secret` (or `gitleaks:allow`) in a comment
on it. `lcode config set secret_check false` turns the check off. It's a safety net, not a full
scanner: for a public repository, also run a tool such as [gitleaks](https://github.com/gitleaks/gitleaks)
before you push.

## Plan mode

Local models are slower than cloud models and make more mistakes, so on a bigger change it pays to
Expand Down
9 changes: 8 additions & 1 deletion src/lcode/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,14 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
reply = result.text.strip() or "(no answer)"
changed = git(cwd, "status", "--porcelain").strip()
if changed:
from lcode import gitflow
from lcode import gitflow, secretscan

found = secretscan.in_files(cwd, ["."], staged=False)
if found:
raise ActionError(
secretscan.report("commit", found, excerpts=False) + "\n\nSo lcode didn't commit or push its "
"changes. Secrets belong in the repository's secrets or in configuration that git ignores."
)

data = gitflow.ask(
session.agent,
Expand Down
1 change: 1 addition & 0 deletions src/lcode/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ class Settings:
notify: bool = False # desktop notifications after long requests (lcode.notify)
notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
max_parallel_agents: int = 1
secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)


Expand Down
1 change: 1 addition & 0 deletions src/lcode/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
embed_model=cfg["embed_model"],
notify=cfg["notify"],
notify_after=cfg["notify_after"],
secret_check=cfg["secret_check"],
repeat_limit=cfg["repeat_limit"],
)
agent = Agent(ollama, settings, cwd, console=console)
Expand Down
1 change: 1 addition & 0 deletions src/lcode/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
"secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
}
ENV_OVERRIDES = {
Expand Down
15 changes: 15 additions & 0 deletions src/lcode/gitflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from rich.markup import escape
from rich.panel import Panel
from rich.text import Text

if TYPE_CHECKING:
from lcode.agent import Agent
Expand Down Expand Up @@ -316,13 +317,19 @@ def commit_command(agent: Agent, hint: str = "") -> None:
chosen = [f for f in answer.get("files") or [] if f in paths]
files = chosen or paths
left_out = [p for p in paths if p not in files]
from lcode import secretscan

secrets_found = secretscan.in_files(root, files, bool(staged)) if agent.settings.secret_check else []
while True:
c.print(Panel(escape(message), title="Commit message", title_align="left", border_style="cyan"))
c.print(f" [bold]Files[/] {escape(', '.join(files))}")
if left_out:
c.print(f" [dim]Left out (unrelated, the model thinks): {escape(', '.join(left_out))}[/]")
if risky:
c.print(f" [dim]Left out (may hold secrets; stage them yourself if needed): {escape(', '.join(risky))}[/]")
if secrets_found:
c.print(Text(secretscan.report("commit", secrets_found), style="red"))
c.print(" [dim]Commit them only if they aren't secrets; otherwise move them out of the code first.[/]")
choice = confirm(agent, " [y] commit · [e] edit the message · [n] cancel: ", message)
if choice == "e":
from lcode.planning import edit_text
Expand Down Expand Up @@ -552,6 +559,14 @@ def pr_command(agent: Agent, arg: str = "") -> None:


def push(agent: Agent, root: Path, remote: str, branch: str) -> bool:
from lcode import secretscan

found = secretscan.outgoing(root, branch) if agent.settings.secret_check else []
if found:
agent.console.print(Text(secretscan.report("push", found), style="red"))
if confirm(agent, " Push anyway? [y]es / [n]o: ") != "y":
agent.console.print("Not pushed.")
return False
try:
with agent.console.status(f"Pushing {escape(branch)}…", spinner="dots"):
git(root, "push", "-q", "-u", remote, branch, timeout=300)
Expand Down
Loading
Loading