How an FTP password leaked to a free AI model: the aredel case
| Summary | |
| Timeline | |
| How it happened | |
| How it was found | |
| Check your own history | |
| What leaked and what did not | |
| What changed | |
| Rules from this case |
Summary
On 2026-10-22, while the deny rules of the aredel repo were being tested, a search of the local OpenCode history showed that the password of the FTP account used for deploys had reached a model twice in September: once pasted into a chat by hand, once printed by an agent that ran cat on the credentials file. Both sessions ran on the free contributor tier of Muse Spark 1.3, which may use prompts and replies to train future models. Nothing can call that back, so the password was changed on the hosting the same day.
This page is the write-up: what happened, why, how it was found without printing the secret again, and what changed. The password, its length and the account name stay out of it.
Timeline
| When (UTC) | What happened |
|---|---|
| 2026-09-22 08:33 | An agent writes the deploy script and a credentials template. The template says to copy it to ~/.config/aredel/ftp-creds.env, outside the repo, and fill it in. |
| 2026-09-22 09:15 | The filled-in file, password included, is pasted into the same chat as a user message. |
| 2026-09-29 18:42 | In another session the agent runs cat ~/.config/aredel/ftp-creds.env. The output goes to the model, and its reply repeats the file. There was no project config yet, so the shell ran without asking. |
| 2026-09-30 05:13–05:40 | The agent loads the file with . ~/.config/aredel/ftp-creds.env in four FTP test commands. The values are used, not printed. |
| 2026-10-14 | opencode.jsonc appears, with a read deny on *creds*. It stops the read tool, not the shell. |
| 2026-10-22 | The search finds both sessions. Read and edit deny rules go into both tools, and the password is changed on the hosting. |
How it happened
- A secret pasted into a chat. Whatever goes into the prompt goes to the provider, and the file template sat right there in the conversation, inviting a paste of the filled-in version.
- A shell without approval. With no project config, the default agent ran shell commands without asking, and cat on a credentials file is an ordinary command.
- A free contributor tier as the default for everything. The guides on this site recommended it at the time; audit finding 2 records the correction.
- A read rule is not a shell rule. Even after 2026-10-14 the *creds* read deny would not have stopped cat, as test 5 shows.
- A plain-text history. Every session stays in ~/.local/share/opencode/opencode.db, password included, until it is deleted.
How it was found
The rule for the search: never print the secret. Every step either counts, lists names, or masks values.
Step 1 — does the deploy script print the password?
grep -n "PASS" container/tools/deploy/deploy-ftp.sh | grep -i "echo\|print"
Three lines, all naming the variable in a warning or an error, none printing its value. Runs of the script, by people or agents, are not the leak.
Step 2 — since when does a rule protect the file?
git log --format="%h %ad %s" --date=short --reverse -S"*creds*" -- opencode.jsonc
The first commit with the rule is from 2026-10-14. Everything before ran without any rule.
Step 3 — which histories mention the file at all?
grep -rlE "ftp-cr.ds\.env" ~/.claude/projects
grep -ac "ftp-creds.env" ~/.local/share/opencode/opencode.db
Claude Code: only the session that ran this audit, and only by name; it never read the file. OpenCode: 428 lines of its database name the file, so the next steps looked there.
Step 4 — where does a real value appear? Every FTP_PASSWORD="…" assignment in the OpenCode database was listed with its value masked, letters and digits replaced by x and 9, next to the line before it. Placeholders, ${{FTP_PASSWORD}} references and test dummies with example hosts fell away; four matches came right after the real host and account lines.
Step 5 — which sessions, which tools? Read-only queries on the same database mapped those matches to sessions, message roles and tool calls: the two sessions in the timeline, one user message and one cat through the shell. No session had ever been shared: the share table was empty.
Check your own history
If you ever had a secret in a project that an agent worked on, search your OpenCode history for it. The script below asks for the old value without showing it, opens the database read-only and prints only when, who and in which session the value appears. Save it as find_leaks.py:
vi find_leaks.py
import datetime import getpass import sqlite3 from pathlib import Path DB = Path.home() / ".local/share/opencode/opencode.db" def when(ms: int) -> str: moment = datetime.datetime.fromtimestamp(ms / 1000, datetime.UTC) return moment.strftime("%Y-%m-%d %H:%M UTC") def main() -> None: secret = getpass.getpass("Old password (not shown): ") con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True) titles = dict(con.execute("SELECT id, title FROM session")) titles.update(con.execute("SELECT id, title FROM session_v2")) rows = con.execute("SELECT session_id, type, time_created, data FROM session_message") for session_id, kind, created, data in rows: if secret in (data or ""): print(when(created), kind, titles.get(session_id, session_id)) main()
python3 find_leaks.py
Each line of output is one place the value was sent to a model, for example:
2026-09-22 09:15 UTC user Unable to update App Center
Run it before you delete old sessions, and rotate the secret first: a hit means the value left your machine. The database stores messages as JSON, so a value with quotes or backslashes is stored escaped; search for a distinctive part of it instead. For Claude Code, the same question is a plain grep -rl over ~/.claude/projects.
What leaked and what did not
- Leaked: the FTP password of the deploy account, with its host and account name, sent twice to a free contributor-tier model.
- Not shared: no session had a public share link.
- Not seen by the auditing session: the Claude Code session that ran this audit never read the file; the deploy script loads it itself and prints only the host and the account name.
- Not recoverable: what a provider received cannot be deleted from here. Only rotation makes it worthless.
What changed
- The password was changed on the hosting on 2026-10-22.
- Deny rules: the credentials folder and every *creds* and *.env file are denied to reading and editing in both tools; Claude Code also refuses cat and stat on them. Each file is listed in Protected files in the aredel repo.
- Roles on the free model: the default model is still a free OpenCode model, whose terms can change. Triage, pinned to it, has no shell at all; the default build agent asks before every shell command.
- The shell: since 2026-10-22, OpenCode denies every shell command that names the config folder or contains creds, for every role. The rule is repeated in the builder and reviewer files, whose git diff allows would otherwise reopen it (check 7). A script that opens the file without naming it is still not covered.
Rules from this case
- Never paste a secret into an AI chat, on any model. If you did, rotate it; deleting the message does not help.
- An agent with a shell can read every file your user can. Keep the shell on ask and read each command before approving it: cat of a credentials file is a no.
- No free contributor tier in a repo that has secrets on disk.
- Deny reading and editing, and remember that neither stops the shell.
- Your agent history is another copy of every secret it saw. Search it after any doubt and delete what you no longer need.
- Rotate first, investigate second.
Article author: Arthur Isaev