How an FTP password leaked to a free AI model: the aredel case

Contents
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:33An 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:15The filled-in file, password included, is pasted into the same chat as a user message.
2026-09-29 18:42In 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:40The agent loads the file with . ~/.config/aredel/ftp-creds.env in four FTP test commands. The values are used, not printed.
2026-10-14opencode.jsonc appears, with a read deny on *creds*. It stops the read tool, not the shell.
2026-10-22The search finds both sessions. Read and edit deny rules go into both tools, and the password is changed on the hosting.

How it happened

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

What changed

Rules from this case

Article author: Arthur Isaev

Related articles
AI
AI security: secrets and access rights
How an FTP password leaked to a free AI model
Deny rules for OpenCode and Claude Code
Protected files in the aredel repo
Free OpenCode model
Agent setup audit: the aredel case

Search this site

Channel @aofeed Chat @aofeedchat

Contacts and cooperation:
I recommend our hosting beget.com
Write to info@aredel.com if you:
1. Want to write an article for our site or translate an article into your native language.
2. Want to place thematically relevant ads on the site.
3. Ads on my site pass maximum censorship. If you see an ad block unsuitable for school-age children, shocking or misleading - please contact us by e-mail
4. Found a mistake, inaccuracy, bug, etc. on the site. ... .......
5. Articles can be shared on social media by clicking a network icon: