diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 105082eb..7cec78a8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,6 +17,13 @@ permissions:
env:
CARGO_TERM_COLOR: always
+ # Code signing (spec 15.5). With the certificate's thumbprint in the
+ # repository variable BOTLOFT_SIGN_THUMBPRINT, scripts/sign.mjs signs every
+ # binary; without it the release goes out unsigned, as today. The step that
+ # puts the certificate in the runner's certificate store (signing in to the
+ # cloud key's client) goes before "Build the signed installer".
+ BOTLOFT_SIGN_THUMBPRINT: ${{ vars.BOTLOFT_SIGN_THUMBPRINT }}
+ BOTLOFT_SIGN_TIMESTAMP: ${{ vars.BOTLOFT_SIGN_TIMESTAMP }}
jobs:
installer:
diff --git a/CLAUDE.md b/CLAUDE.md
index c6f20746..9cb7c8a7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,7 +46,7 @@ pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release
pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup
```
-Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou.
+Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A assinatura de código passa por `app/scripts/sign.mjs` e só liga com a variável `BOTLOFT_SIGN_THUMBPRINT` (spec 15.5); a política pública fica em `docs/code-signing-policy.md`. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou.
Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá.
diff --git a/README.md b/README.md
index 7d1c8a96..33b9dc45 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,9 @@ The full design, from the protocol to the states of a bot, lives in [`docs/spec.
cooperative: a bot can read other bots' folders if it decides to. Give bots only the permissions
you would give Claude Code directly.
+What Botloft sends over the network, what it changes on your computer and how to remove it are in
+the [code signing policy](docs/code-signing-policy.md).
+
## FAQ
@@ -150,7 +153,8 @@ that you can point at a project of yours. Botloft's own data (SQLite, logs, secr
The installer is not code-signed yet, so SmartScreen does not know its publisher. Each release is
built by GitHub Actions from this repository, and updates are signed and checked by the app before
-they install.
+they install. The [code signing policy](docs/code-signing-policy.md) says how releases are made and
+how to check a download.
diff --git a/app/scripts/setup.mjs b/app/scripts/setup.mjs
index aa94e19e..f5a777bd 100644
--- a/app/scripts/setup.mjs
+++ b/app/scripts/setup.mjs
@@ -40,6 +40,12 @@ execFileSync(
{ cwd: root, stdio: "inherit", env },
);
+execFileSync(
+ process.execPath,
+ [join(app, "scripts", "sign.mjs"), join(target, "release", "botloft-setup.exe")],
+ { stdio: "inherit" },
+);
+
const out = join(target, "release", "bundle", "setup");
mkdirSync(out, { recursive: true });
const setup = join(out, `Botloft_${version}_x64-setup.exe`);
diff --git a/app/scripts/sign.mjs b/app/scripts/sign.mjs
new file mode 100644
index 00000000..e6543b4e
--- /dev/null
+++ b/app/scripts/sign.mjs
@@ -0,0 +1,76 @@
+// Signs one Windows binary with the release certificate (spec 15.5).
+//
+// node scripts/sign.mjs
+//
+// Tauri runs it for everything that goes into the installer
+// (`bundle.windows.signCommand`): the app, the daemon, the NSIS plugins, the
+// uninstaller and the installer itself. setup.mjs runs it for the setup
+// window, which Tauri does not build. The certificate lives in the Windows
+// certificate store of the machine that builds (a cloud key shows up there
+// once its client is signed in), and is picked by its SHA-1 thumbprint:
+//
+// BOTLOFT_SIGN_THUMBPRINT the certificate's thumbprint; without it nothing
+// is signed, as in dev builds and in releases
+// made before the certificate exists
+// BOTLOFT_SIGN_TIMESTAMP RFC 3161 timestamp server (a public one by default)
+// BOTLOFT_SIGNTOOL signtool.exe, when it is not in the Windows SDK
+
+import { execFileSync } from "node:child_process";
+import { existsSync, readdirSync } from "node:fs";
+import { basename, join } from "node:path";
+
+const file = process.argv[2];
+if (!file) {
+ throw new Error("usage: sign.mjs ");
+}
+const thumbprint = (process.env.BOTLOFT_SIGN_THUMBPRINT ?? "").replace(/\s/g, "");
+if (!thumbprint) {
+ console.log(`not signing ${basename(file)}: BOTLOFT_SIGN_THUMBPRINT is not set`);
+ process.exit(0);
+}
+
+/** signtool.exe from the newest Windows SDK on this machine. */
+function signtool() {
+ if (process.env.BOTLOFT_SIGNTOOL) {
+ return process.env.BOTLOFT_SIGNTOOL;
+ }
+ const kits = join(process.env["ProgramFiles(x86)"] ?? "", "Windows Kits", "10", "bin");
+ const versions = existsSync(kits)
+ ? readdirSync(kits)
+ .filter((name) => /^10\./.test(name))
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
+ : [];
+ for (const version of versions) {
+ const tool = join(kits, version, "x64", "signtool.exe");
+ if (existsSync(tool)) {
+ return tool;
+ }
+ }
+ throw new Error(`signtool.exe not found under ${kits}; set BOTLOFT_SIGNTOOL`);
+}
+
+const tool = signtool();
+const timestamp = process.env.BOTLOFT_SIGN_TIMESTAMP || "http://timestamp.digicert.com";
+execFileSync(
+ tool,
+ [
+ "sign",
+ "/sha1",
+ thumbprint,
+ "/fd",
+ "sha256",
+ "/tr",
+ timestamp,
+ "/td",
+ "sha256",
+ "/d",
+ "Botloft",
+ "/du",
+ "https://github.com/httpsphl/botloft",
+ file,
+ ],
+ { stdio: "inherit" },
+);
+// A signature Windows does not trust would only show up on the owner's PC.
+execFileSync(tool, ["verify", "/pa", file], { stdio: "inherit" });
+console.log(`signed ${basename(file)}`);
diff --git a/app/src-tauri/tauri.bundle.conf.json b/app/src-tauri/tauri.bundle.conf.json
index fc42e1dc..e6bb535e 100644
--- a/app/src-tauri/tauri.bundle.conf.json
+++ b/app/src-tauri/tauri.bundle.conf.json
@@ -4,6 +4,7 @@
"externalBin": ["binaries/botloftd"],
"createUpdaterArtifacts": true,
"windows": {
+ "signCommand": { "cmd": "node", "args": ["../scripts/sign.mjs", "%1"] },
"nsis": {
"installMode": "currentUser",
"installerIcon": "icons/icon.ico",
diff --git a/crates/botloftd/botloftd.rc b/crates/botloftd/botloftd.rc
index fac54ca2..03081418 100644
--- a/crates/botloftd/botloftd.rc
+++ b/crates/botloftd/botloftd.rc
@@ -1,3 +1,31 @@
-// The daemon's Windows resources: only its manifest (spec 14).
+// The daemon's Windows resources (spec 14): its manifest and its version
+// information. build.rs fills in the @PLACEHOLDERS@ and compiles the result.
#define RT_MANIFEST 24
-1 RT_MANIFEST "botloftd.manifest"
+1 RT_MANIFEST "@MANIFEST@"
+
+// What Windows shows in the file's properties and in Task Manager, and what a
+// code signing service checks against the product it signs (spec 15.5).
+1 VERSIONINFO
+FILEVERSION @VERSION_NUMBERS@
+PRODUCTVERSION @VERSION_NUMBERS@
+FILEOS 0x40004L
+FILETYPE 0x1L
+BEGIN
+ BLOCK "StringFileInfo"
+ BEGIN
+ BLOCK "040904b0"
+ BEGIN
+ VALUE "ProductName", "Botloft"
+ VALUE "FileDescription", "Botloft background service"
+ VALUE "FileVersion", "@VERSION@"
+ VALUE "ProductVersion", "@VERSION@"
+ VALUE "LegalCopyright", "Copyright 2026 Phelipe Lorran"
+ VALUE "InternalName", "botloftd"
+ VALUE "OriginalFilename", "botloftd.exe"
+ END
+ END
+ BLOCK "VarFileInfo"
+ BEGIN
+ VALUE "Translation", 0x409, 1200
+ END
+END
diff --git a/crates/botloftd/build.rs b/crates/botloftd/build.rs
index 9c3a71d0..9c7f8d7b 100644
--- a/crates/botloftd/build.rs
+++ b/crates/botloftd/build.rs
@@ -1,14 +1,42 @@
-//! Embeds `botloftd.manifest` in the Windows binary (spec 14). It goes in
-//! as a resource: the linker's own manifest tool predates
-//! `consoleAllocationPolicy` and warns about it on every build.
+//! Embeds the daemon's Windows resources (spec 14): `botloftd.manifest` and
+//! the version information. The manifest goes in as a resource because the
+//! linker's own manifest tool predates `consoleAllocationPolicy` and warns
+//! about it on every build.
+
+use std::path::PathBuf;
+use std::{env, fs};
+
+/// `1.2.3` (with or without a tag) as the four numbers Windows wants.
+fn version_numbers(version: &str) -> String {
+ let core = version.split(['-', '+']).next().unwrap_or_default();
+ let mut parts: Vec<&str> = core.split('.').collect();
+ parts.resize(4, "0");
+ parts.join(",")
+}
fn main() {
println!("cargo:rerun-if-changed=botloftd.rc");
println!("cargo:rerun-if-changed=botloftd.manifest");
- if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
- && let Err(err) =
- embed_resource::compile("botloftd.rc", embed_resource::NONE).manifest_required()
- {
- panic!("cannot embed the manifest: {err}");
+ if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
+ return;
+ }
+ let here = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets the crate dir"));
+ let version = env::var("CARGO_PKG_VERSION").expect("cargo sets the version");
+ // Forward slashes: a backslash starts an escape in a resource script.
+ let manifest = here
+ .join("botloftd.manifest")
+ .display()
+ .to_string()
+ .replace('\\', "/");
+ let script = fs::read_to_string(here.join("botloftd.rc"))
+ .expect("cannot read botloftd.rc")
+ .replace("@MANIFEST@", &manifest)
+ .replace("@VERSION_NUMBERS@", &version_numbers(&version))
+ .replace("@VERSION@", &version);
+ let out =
+ PathBuf::from(env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")).join("botloftd.rc");
+ fs::write(&out, script).expect("cannot write the resource script");
+ if let Err(err) = embed_resource::compile(&out, embed_resource::NONE).manifest_required() {
+ panic!("cannot embed the resources: {err}");
}
}
diff --git a/docs/code-signing-policy.md b/docs/code-signing-policy.md
new file mode 100644
index 00000000..6e7b3666
--- /dev/null
+++ b/docs/code-signing-policy.md
@@ -0,0 +1,71 @@
+# Code signing policy
+
+This page says how Botloft's releases are made, who may release them, what Botloft sends over the
+network and what it changes on your computer.
+
+## Status
+
+Botloft's releases are **not code-signed yet**. Until they are, Windows SmartScreen may ask you to
+confirm when you run the installer, and an antivirus may flag it by guesswork (a "machine-learning"
+or "heuristic" detection), as happens with new programs that have no signature. If that happens to
+you, please [open an issue](https://github.com/httpsphl/botloft/issues); we report every false
+positive to the vendor.
+
+The release pipeline is ready to sign `Botloft.exe`, `botloftd.exe`, the setup window, the installer
+and the uninstaller as soon as a certificate is in place. All of them carry the product name
+"Botloft" and the version of the release.
+
+## How releases are made
+
+- Every release is built by GitHub Actions ([`release.yml`](../.github/workflows/release.yml)) from
+ a `vX.Y.Z` tag on `main`, on GitHub's own runners. Nothing is built on a maintainer's computer.
+- The workflow opens a draft release. A person reads it and publishes it: no release goes out
+ without that manual approval.
+- Each installer gets a build provenance attestation, and the release notes list its SHA-256. To
+ check that a download came from this repository's workflow, with the
+ [GitHub CLI](https://cli.github.com/):
+
+ ```
+ gh attestation verify Botloft__x64-setup.exe --repo httpsphl/botloft
+ ```
+
+ This applies to releases after 0.6.0.
+- Updates are signed with Botloft's own updater key. The app checks the signature, and that it was
+ made for the version announced, before it installs anything.
+
+## Team roles
+
+- **Committers and reviewers:** [Phelipe Lorran](https://github.com/httpsphl)
+- **Approvers:** [Phelipe Lorran](https://github.com/httpsphl)
+
+## Privacy
+
+Botloft has no telemetry, no analytics and no accounts of its own. The background service listens
+on `127.0.0.1` only. What goes over the network:
+
+- **Update check.** When the app opens, and every 6 hours while it is open, it asks GitHub for the
+ latest release's `latest.json`. It downloads an update only when you click **Update now**. No
+ information about you or your bots is sent.
+- **Your bots.** Each bot is Claude Code running on your computer with your own Claude account. What
+ it sends to Anthropic, and the websites it opens when its work needs them, are the same as when
+ you use Claude Code yourself.
+
+## What Botloft changes on your computer
+
+- It installs for your Windows user only, under `%LOCALAPPDATA%\Botloft`, with shortcuts in the
+ Start menu and on the desktop. It never asks for an administrator.
+- It registers a scheduled task for your user, named "Botloft", that keeps the bots running: it
+ starts when you sign in and brings the background service back if it stops. Settings > General
+ turns off starting with Windows, or stops the bots when you close the app.
+- While the bots start with Windows and Botloft stays near the clock or opens its window at sign-in,
+ the app adds itself to your user's startup programs. Settings > General turns each of these off.
+- While a bot is working, it asks Windows not to put the computer to sleep. Settings > General turns
+ this off.
+- Bots keep their work in `%USERPROFILE%\Botloft`, or in the folder you pick for a crew.
+
+## Uninstalling
+
+Settings > Apps > Installed apps > Botloft > Uninstall removes the app, stops the background
+service, and removes its scheduled task and the startup entry. Your bots, their conversations and
+their files stay in `%LOCALAPPDATA%\Botloft` and `%USERPROFILE%\Botloft`; delete those two folders
+to remove everything.
diff --git a/docs/spec.md b/docs/spec.md
index 1be68ac7..062638e2 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -718,6 +718,10 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a
- Os artefatos são assinados com a chave do updater do Tauri (`createUpdaterArtifacts`), e a chave pública fica no `tauri.conf.json`. `requireSignedVersion` exige que a assinatura traga a versão, para um feed adulterado não empurrar uma versão antiga de volta. O instalador não tem assinatura Authenticode, então o SmartScreen avisa na primeira execução.
- Release: a versão fica só no `[workspace.package]` do `Cargo.toml` (o `tauri.conf.json` não repete a versão e usa a do crate). Um push de tag `vX.Y.Z` roda `.github/workflows/release.yml`, que confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório), depois `pnpm bundle:setup` (15.7), `release.mjs stage`, o atestado de origem e `release.mjs publish`, que abre um release **rascunho** com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) e o `.sig` dele, e o `latest.json`. O updater só enxerga o release depois que o dono o publica.
- Atestado de origem: entre o `stage` e o `publish`, o workflow roda `actions/attest@v4` nos dois `.exe` (permissões `id-token`, `attestations` e `artifact-metadata`). É um atestado de proveniência SLSA assinado pelo Sigstore: qualquer pessoa confere com `gh attestation verify --repo httpsphl/botloft` que o instalador saiu deste workflow, deste repositório e daquele commit. As notas do rascunho começam com "Verify your download": o SHA-256 dos dois instaladores e esse comando. O VirusTotal fica de fora das notas enquanto a tela de instalação tiver alertas de aprendizado de máquina (na 0.6.0, 2 de 71: Microsoft `Wacatac.C!ml` e Trapmine), à espera da análise de falso positivo.
+- Assinatura de código (Authenticode): ainda não há certificado, e sem ele o release sai sem assinatura. O caminho já está pronto: `app/scripts/sign.mjs ` assina com o `signtool` do Windows SDK o certificado que estiver no repositório de certificados da máquina, escolhido pela impressão digital em `BOTLOFT_SIGN_THUMBPRINT` (uma chave em nuvem aparece ali depois que o cliente dela entra na conta), com carimbo de tempo RFC 3161 (`BOTLOFT_SIGN_TIMESTAMP`) e confere a assinatura em seguida. Sem a variável, não faz nada; com ela e sem o certificado, falha, para um release nunca sair sem assinatura por engano. Quem chama: o Tauri, pelo `bundle.windows.signCommand` do `tauri.bundle.conf.json`, para tudo que entra no instalador (o `Botloft.exe`, o sidecar `botloftd.exe`, os plugins do NSIS, o desinstalador e o próprio instalador; ele pula o que já estiver assinado); e o `setup.mjs` para a tela de instalação, que o Tauri não monta. A cópia do daemon em `\bin` é o mesmo arquivo assinado do instalador. O `release.yml` passa as duas variáveis do repositório. O `.sig` do atualizador e o atestado de origem são feitos depois, sobre os arquivos já assinados.
+- Todo binário leva nome do produto e versão nos metadados, que é o que um serviço de assinatura confere: o app e a tela de instalação pelo `tauri-build`, o instalador pelo NSIS e o `botloftd.exe` por um `VERSIONINFO` no `botloftd.rc` (produto "Botloft", descrição "Botloft background service", versão do crate), preenchido pelo `build.rs`.
+- Opções de certificado vistas em 2026-09-30, para uma pessoa física no Brasil: o Azure Artifact Signing não atende (pessoa física só nos EUA e no Canadá); a SignPath Foundation assina de graça projetos abertos aprovados, com o nome dela como editor e uma aprovação manual por release; o certificado Open Source da Certum (a partir de € 49, chave em nuvem) sai no nome do desenvolvedor; a SSL.com tem validação individual com assinatura em nuvem e action oficial, por cerca de US$ 300 por ano. Nenhuma tira o aviso do SmartScreen de imediato: a reputação cresce a cada release assinado com a mesma identidade. A política pública está em `docs/code-signing-policy.md`.
+- Falso positivo do Defender (0.6.0, 2026-09-30): no PC do dono, o Microsoft Defender pôs em quarentena, por detecção de nuvem, o `-setup.exe` baixado (`Trojan:Win32/Wacatac.C!ml`) e depois o `Botloft.exe` instalado e em uso (`Trojan:Script/Wacatac.C!ml`), apagando os atalhos; o daemon não foi tocado e os bots seguiram. São palpites de aprendizado de máquina sobre binários novos e sem assinatura. Os dois arquivos foram enviados à Microsoft como falso positivo. É o motivo de a assinatura ter deixado de ser opcional para distribuir o app.
### 15.6 Idiomas
@@ -768,7 +772,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a
- `/S` por cima de uma versão instalada: se desinstala a anterior (como o `/P` faz pela página de reinstalação) ou só sobrescreve, e se algum arquivo antigo fica. **Mesma versão** (0.5.0 sobre 0.5.0, 2026-09-29): nenhum processo de desinstalador apareceu (olhando a cada 100 ms); o NSIS só sobrescreveu os arquivos. Falta ver com uma versão mais antiga (I3).
- `/S` com o app aberto: se o NSIS fecha o app sozinho, pergunta ou falha. O setup fecha o app antes (verificado: "Instalar de novo" com o app aberto fechou o app, instalou e abriu de novo), mas o comportamento do NSIS sozinho precisa ficar anotado.
- `/S` com uma versão mais nova instalada: se sai com erro (`silentDowngrades`) e com qual código.
-- O atualizador aceita o instalador com o nome novo (`-update.exe`) e o roda como NSIS. **Na fonte** (`tauri-plugin-updater` 2.13, `extract_exe`): o tipo vem dos bytes baixados, e qualquer `.exe` é tratado como NSIS; o nome não conta. Falta ver a atualização de verdade da 0.5.0 para a 0.6.0.
+- O atualizador aceita o instalador com o nome novo (`-update.exe`) e o roda como NSIS. **Na fonte** (`tauri-plugin-updater` 2.13, `extract_exe`): o tipo vem dos bytes baixados, e qualquer `.exe` é tratado como NSIS; o nome não conta. **Verificado** (2026-09-30): o app instalado na 0.5.0 se atualizou para a 0.6.0 pelo `-update.exe`, com a janela passiva do NSIS, e a entrada em Aplicativos instalados e a pasta ficaram as mesmas.
- Com o manifesto `asInvoker`, o Windows não pede administrador para o setup. **Verificado** (Windows 11 25H2, 2026-09-29): o `Botloft_0.5.0_x64-setup.exe` abriu a janela direto, sem pedido do UAC.
- O identificador dos atalhos continua o mesmo, para os avisos saírem com o nome do Botloft.