# Security

tweet-factory stores another person’s posts (everything you liked or bookmarked) plus your own, and it holds a long-lived X refresh token. Treat the checkout like a credential.

## Secrets

- `.env`, `.tokens.json`, and `.cloud.json` are gitignored.
- `.tokens.json` and `.cloud.json` are written atomically, mode `0600`.
- When `tf` updates `.env`, and when `setup_x.sh` writes keys, the file is mode `0600`. A hand-edited `.env` is not. Run `chmod 600 .env` after editing it yourself. `.env.tmp` and `.tokens.json.tmp` are gitignored.
- Opening `tweets.db` for write sets it to mode `0600`, including an existing file that was looser. `backup.sh` sets mode `0600` on each snapshot of `tweets.db` and `persona/PERSONA.md`.
- Do not log token or key values. The docs’ verification commands print `set` or `missing`, not the secret. X token and API errors log the status code only.
- `backups/` contains the database and the persona. It is local on purpose. Do not point it at a public remote.

## OAuth callback

`tf auth` listens on `127.0.0.1` for about five minutes, then stops. Set `OAUTH_BIND_HOST` to something else only when you must authorize from a different machine (for example a Tailscale address). The CLI prints a warning when the bind is not loopback. Register the exact `OAUTH_REDIRECT_URI` on the X app. State is checked; a mismatch aborts as a possible CSRF.

## One refresh token, one machine

X rotates the refresh token when it is used. Two hosts running `tf fetch` against the same `.tokens.json` will invalidate each other. Symptoms: 401s, then `tf auth` required. Keep a single scheduler.

## What is sent off the machine

The default build does not contact tweetfactory.sh. `TF_LICENSE_KEY` is not sent anywhere until a billing provider replaces `resolveProvider()` in `src/entitlements/index.ts`. That provider must not upload `tweets.db`, the persona, or OAuth tokens. See [the hardening plan](https://github.com/abhirupghosh/tweet-factory/blob/main/docs/BINARY_HARDENING.md). `tf login` and `tf sync` are a separate opt-in. Login does not upload the database. Other network calls happen only to services you configured:

| Call | When |
|---|---|
| X API | auth, fetch, enrich of X articles |
| Your LLM | `tf generate`, onboard generate. The default is the Claude Code CLI already logged in on the machine. |
| Tavily | `tf search`, `tf enrich`, `tf generate --timely`, `tf cron` |
| Notion | `tf notion`, cron sync |
| Slack | `tf notify`, `tf generate --post-to-slack`, cron’s pending ping |
| tweetfactory.sh | `tf login`, `tf sync push`, `pull`, `status`, `delete` |

Cloud sync encrypts tweet text, author descriptions, article extracts, Notion notes and page text, and persona and draft bodies before they are stored. The operator who holds `SYNC_ENCRYPTION_KEY` can decrypt them. Ids, usernames, metrics, tags, and filenames are stored in the clear so the account page can list files. Details: [Cloud sync](cloud-sync.md).

`tf analyze`, `tf flush`, and `tf refresh` do not call an LLM. `tf refresh` does call the X API. `tf update` downloads a public release binary and does not call the X API.

## Scraped text

Tweets, article extracts, Tavily results, and Notion pages are untrusted. `tf generate` wraps them in `<reference_data>` and strips a closing fence that appears inside that text. The Claude Code subprocess is started with `--tools ""`, `--disallowedTools "*,mcp__*"`, `--strict-mcp-config`, `--permission-mode dontAsk`, and `--max-turns 1`. `--allowedTools` only pre-approves tools, so an empty allow list is not the control. Direct API backends have no tools in this CLI either; they get a single completion.

## What this program will not do

It does not post, like, or bookmark on your behalf. The OAuth scopes are read plus `offline.access`. Publishing a draft is a separate action you take in the X client.

> **Verify.** `git status --ignored --short -- .env .tokens.json .cloud.json tweets.db` shows those paths ignored (once they exist). `stat -c '%a' .tokens.json` is `600` after a successful auth. After `tf login`, `stat -c '%a' .cloud.json` is `600`.
