The Dailybot CLI runs on user machines and sometimes on CI runners. The threats we care about:
- Credential leakage to other users on a shared host (multi-user dev box, CI runner).
- Credential leakage through logs, terminal scrollback, or piped output.
- Man-in-the-middle on the CLI ↔ API connection.
- Replay of OTP codes after a failed verification.
- Confused-deputy attacks where an agent uses cached credentials in unexpected contexts.
We do not defend against:
- A compromised user account (an attacker with shell access on the user's machine can read
~/.config/dailybot/). - A compromised Dailybot API.
- Targeted social engineering of the human running the CLI.
Every file in ~/.config/dailybot/ that contains a secret is written with mode 0o600 (owner read/write, no group, no other). The pattern in dailybot_cli/config.py:
PATH.write_text(json.dumps(data, indent=2))
os.chmod(PATH, 0o600)Files with secrets:
credentials.json(login Bearer token)config.json(stored API key)agents.json(per-profile API keys)<repo>/.dailybot/env.json(per-repo API keys — see § below)
Files without secrets (still written 0o600 for consistency):
org_cache.json(transient list of org names + UUIDs from step 1 of multi-org login)plan_cache.json(non-sensitive org plan tier, keyed by org UUID; used to short-circuit non-allowlisted commands on a free plan — never stores tokens or keys)
The env.json file is the ONLY sanctioned place inside .dailybot/ where API keys may live. It carries per-repo credential context (API key + optional URLs for one or more environments). Because it sits inside the repo tree, four independent protections apply beyond the standard 0o600:
- Gitignore is mandatory (
.gitignore). The broad.dailybot/*rule in the repo's.gitignorecovers it automatically; the only excepted file is!.dailybot/profile.json.env.jsonMUST NEVER be excepted — not with a per-machine dot-file trick, not with agit update-index --assume-unchanged, not with anything. - File permissions (
0o600). Every write viadailybot envcreates the file with mode0o600from the first byte (os.open(..., 0o600)— no umask window), and every read re-chmods it defensively. Implementation:dailybot_cli/config.py::save_repo_env. - Root-callback refuse-if-tracked guard (fatal, applies to EVERY command). The root
cli()callback indailybot_cli/main.pycallsload_repo_env()on every invocation, which internally runsgit ls-files --error-unmatch .dailybot/env.json. If the file is tracked,RepoEnvErroris raised,print_error()writes to stderr, andSystemExit(1)aborts before any subcommand runs. There is no silent fallback to global auth — the entire process refuses to operate until the developer runsgit rm --cached .dailybot/env.json. The exempt paths are--help/--version(Click short-circuits) so the developer can always read instructions, and thehookgroup, which prints the same error to stderr but exits 0 — its harness contract (AGENT_HOOKS.md) forbids non-zero exits, and hook commands never consume env.json auth. When git is not on PATH but a.gitancestor exists, the guard cannot verify tracking and degrades to a loud warning instead of a silent pass. Implementation:dailybot_cli/config.py::_is_env_tracked_by_git(independently mockable), invoked viaload_repo_envfrommain.py::cli. - Write-time gitignore warning.
dailybot env addrunsgit check-ignore --quiet .dailybot/env.jsonafter writing; if the file is NOT covered by any ignore rule, a warning fires on stderr with the exact.gitignoresnippet to add. The warning is non-fatal because a fresh repo might not have a.gitignoreyet, and the load-time guard (#3) catches the actual security violation.
All four protections must trip together for a leak to happen: the developer would have to (a) remove or fail to add the .gitignore rule, (b) survive the write-time warning, (c) survive the load-time refuse-if-tracked check, and (d) somehow bypass the file permissions. The design is defense-in-depth on purpose.
If a key ever ends up in a commit, treat it as compromised — rotate immediately via the Dailybot dashboard, then follow the recovery recipe in CONFIGURATION.md § "STOP — Read this before you author env.json". Git history is forever; git revert does not undo the exposure.
The full schema, precedence, and CLI commands for env.json are in CONFIGURATION.md § "Repo-level env override".
Never display, log, or echo a full secret. Always mask:
def _mask(value: str) -> str:
if len(value) <= 4:
return value[0] + "****" if value else "****"
return value[:4] + "****"Helpers that already mask correctly: dailybot config key, dailybot agent profiles. New code that handles a secret must use the same pattern.
The default API URL is https://api.dailybot.com — TLS is enforced by httpx (no verify=False). When the user passes --api-url for staging/local development:
- HTTPS is still required for any externally-reachable endpoint.
- For local dev (e.g.,
http://localhost:8000), the user is implicitly accepting the insecure transport on their own loopback.
We do not disable certificate verification anywhere. Don't add a flag for it.
Every string the Tasks API returns is user-authored data, never an instruction.
This is a different class of hazard from the rest of the CLI. Anyone who can create a
task on a shared board can write text that an agent will later read while holding a
credential, so a task titled delete this board is an injection vector into a
privileged execution context. The CLI matters here more than a typical client because its
output is routinely consumed by an agent — that is the premise of the Dailybot agent
skill pack.
The boundary is the renderer. display.present_untrusted() is the single enforcement
point: it escapes Rich markup (so a title cannot style the terminal), wraps the value in
quotes (so a reader sees a datum, not a sentence addressed to them), and truncates.
Every user-authored field goes through it.
Control characters never reach the terminal. Rich escapes its own markup but passes ANSI /
OSC escape sequences, bidi overrides and zero-width characters straight through. Left alone, a
title could clear the screen, overwrite the line a person reads before answering "Proceed?", or
plant a hyperlink. The presenter turns newlines and tabs into spaces and shows every other
control or format character as its \uXXXX code. It escapes an embedded " so a value cannot
close its own quotes. Server text that is shown unquoted (keys, codes, a preview's consequence
and counts, roll-ups) goes through display.safe_text(), which applies the same
neutralization and markup escaping.
The trusted set is exact and auditable. display.TASKS_TRUSTED_FIELDS lists the only
server-generated fields — uuid, key, rank, cursor, etag, delta_cursor, code,
and the three timestamps. Everything else is untrusted. A test asserts the set matches the
API contract rather than relying on memory.
provenance: typed is attribution, not trust. It means a person typed the comment. It
does not make the comment an instruction, and the renderer never labels it "trusted",
"verified" or "system".
--json carries data, not narration. Untrusted values are emitted as plain string
fields and are never interpolated into a message, so an agent parsing the output can tell
a field from a sentence.
Regression coverage lives in tests/tasks_security_test.py, which renders
instruction-shaped strings through every Tasks view in both human and JSON modes.
Some Tasks doors require a person. me/tasks, me/tasks/counts, me/recents,
me/activity-cursor, inbox and inbox/unread-count are defined relative to the calling
user, and the writes that change who can see or who is notified (participants,
membership) need an accountable person. A login session or a personal API key is a person;
an agent or organization key is nobody, so the server refuses it.
A personal API key is its person; an agent or organization key is nobody. A key bound
to a person acts as that person on every Tasks door, exactly like their login session:
same role, same visibility, same answers, tasks:admin included for a non-guest member (the
developer reversed ADR-9-4 for personal keys; agent and organization keys still never hold
tasks:admin or act as a person). A key row that carries explicit tasks:* scopes is a
ceiling the person chose; guests stay limited on both. The CLI therefore never refuses a key
before the request: it cannot tell the kinds apart, so the server decides, and
tests/tasks_key_refusal_sweep_test.py pins that every door sends the request. The server's
refusals keep their meaning: actor_required (exit 3) and insufficient_scope /
guest_not_allowed (exit 4); tests/tasks_server_refusal_rendering_test.py pins how the CLI
renders them. Membership remains the privacy control: a members project or board is 404
without a grant. CLI messages blame the credential kind or the role, and never tell a
member they "need to be an admin".
A login token never leaves the host that issued it. dailybot login stores the Bearer
next to the api_url that issued it. When .dailybot/env.json or --api-url points the CLI
at another host (a local testing profile, staging), the client does not load that token at
all, so it is neither sent first nor used as the 401/403 fallback. Before this rule, a key
refused with 403 on a local API was retried with the production session token against that
local host. DAILYBOT_CLI_TOKEN is an explicit caller choice and is exempt.
DAILYBOT_CLI_TOKEN is the caller's explicit choice. A token set in that variable is not
bound to a host: it goes to whatever API the invocation targets (--api-url, env.json) and
is used on the 401/403 retry. CI jobs that set it should also pin the API URL. dailybot logout revokes every session it knows: a DAILYBOT_CLI_TOKEN on the current API, and a stored
login on the host that issued it; local credentials are cleared afterwards.
A person refused is never replayed as the organization. The client normally retries a 401/403 once with the other stored credential. On a Tasks door, a 403 to a signed-in person means the person lacks the role or the visibility. Replaying it with the organization API key would perform, as the organization, exactly what the person was refused. So on Tasks a Bearer 403 is final. A key refused on a person-only door still retries as the person, and an expired session (401) still falls back.
Identifiers cannot change which door a path reaches. A task key or uuid is interpolated
into the URL path, and an agent may copy an "id" out of untrusted task text. Every path
segment must match [A-Za-z0-9_-]+, both where the value is interpolated and again when the
URL is built. So ENG-1/../../boards/<uuid>/archive/? is refused with invalid_identifier
(exit 2) before any request, instead of being collapsed into a board archive.
ETags are entity tags or nothing. board views --etag / project views --etag print the
ETag for shell capture, and view save sends it back as If-Match. A header value that isn't
a quoted, visible-ASCII entity tag (optionally W/-prefixed) is refused (invalid_etag)
before it is printed or sent. So a hostile header can't drive the terminal, break
ETAG=$(...), or inject a header. A valid weak tag (W/"3") is sent back in its strong form
("3"), because If-Match compares strongly; the value is validated first, so the strip cannot
turn a malformed value into a well-formed one.
Default ports are the same origin. https://host and https://host:443 compare equal
(and http://host with :80), so an explicit default port never turns the API into a
"foreign" host.
Pagination links stay on the API. A list's next link is server data. Only its path and
query are followed, on the configured API's own scheme, host and port, so a link naming
another host never receives the Bearer token or the API key.
Isolation is 404, never 403. An object in another organization is invisible, not
forbidden. No Tasks command renders permission language for not_found, because doing so
would both mislead the user and disclose that the object exists.
The CLI never claims an actor the server did not name. A bare API key is attributed
automation server-side; where the server names nobody, the CLI says nothing.
Where the server offers a preview, no destructive Tasks command acts without the server's
own statement of what it will do. commands/_destructive.preview_then_confirm() fetches
?dry_run=true, renders the consequence, the affected counts and the restore path, and only
then asks. That covers the archives (task, board, project, goal, column) and milestone
completion.
Some destructive doors have no server-side preview: removing a board member or a task
participant, unlinking a relation, deleting a comment or an attachment. Those go through
commands/_destructive.confirm_without_preview(), which states the one thing the CLI knows —
the exact act — asks, and never pretends to more. Their --dry-run sends nothing and
says so ("previewed_by": "client" under --json).
--yesskips the prompt, never the preview. The record of what was about to happen is the point, and the flag is advisory anyway: the server bounds blast radius per call.--dry-runshows the preview and performs no mutation.- A preview that fails aborts. Not knowing the blast radius is not permission to proceed.
- A preview must be a preview. The contract publishes every
?dry_run=trueanswer withdry_run: true. If the answer lacks it, the CLI reports that the change may already have been applied (preview_not_honoured, exit 1), sends nothing more, and never asks for confirmation. - An irreversible operation is marked as such and offered no restore path.
task deleteis an alias of archive and says so; it never claims data was destroyed.- Bulk previews through the server's own dry run (
task bulk --dry-run): the batch is run and rolled back, so the changes and refusals shown are real, and nothing is written. The dry run sends no Idempotency-Key — which is also why a server that predates it refuses the call (idempotency_key_required) instead of applying it. The 100-item cap is enforced before sending.
task attach reserves an upload target (…/attachments/presign/), sends the bytes there and
confirms. The target is chosen by the server and may be object storage on another host, so
the transport draws a hard line (DailyBotClient.upload_attachment_bytes):
- Dailybot credentials never leave the API origin. The origin is compared as parsed
scheme + host + port, so a lookalike such as
api.example.com.evil.exampleis foreign. A foreign target gets a bare request carrying only the headers the presign returned — noAuthorization, noX-API-KEY. Only the same-origin…/content/fallback is authenticated. - A same-origin target is allowlisted. When the presign points back at the API, that
one request carries the caller's credentials to a URL and method the server chose. So it
is refused unless it is
PUT/POSTon a task attachment's…/attachments/<uuid>/content/door (attachment_upload_target_refused). An empty 2xx from that door counts as accepted. - No redirects on upload. A 3xx from the target is an error; nothing is re-sent anywhere and the attachment is not confirmed.
- https only for a foreign target, unless the configured API URL is itself plain
http(local development). - Size is checked before any request (25 MiB; 5 MiB on the captioned single-request upload),
and the file is read at most one byte past the limit, so a file that grows after the check is
refused rather than read in full. JSON input files (
-fbatches, views, filters) are capped at 5 MiB.
Comments, projects and goals take attachments too (task comment-attach, project attach,
goal attach). Those doors are multipart only, so the limit is 5 MiB everywhere, and it is
checked before any request. They go only to the API origin, and their downloads share the
same hardened path below. Attaching to or deleting from a project or a goal is a
tasks:admin door: a login or a personal API key, never an agent or organization key.
task attachment get follows at most one redirect from the API to storage, without
credentials; a second redirect is refused, and a refusal from storage is reported as storage's
(attachment_download_failed), never as a session problem. Downloads over the attachment
size cap are refused. The API's own …/content/ answer and the storage hop both stream, and
the cap is enforced on the bytes as they arrive; both also have a wall-clock deadline. On
the storage hop
the body is requested uncompressed (Accept-Encoding: identity), and the whole hop has a
wall-clock deadline. So an endless chunked body, a compression bomb or a slow drip cannot
exhaust memory or hang the CLI. If writing the file fails, the partial file this call created
is removed. The output path is never derived from server data. Without --force
the file is created exclusively (O_EXCL) and a symlink is never followed (O_NOFOLLOW;
with --force, an output path that is a symlink is refused outright, since O_NOFOLLOW is
not available on every platform), so a
file or link that appears during the download is left alone; nothing is written when the
download fails, and an unwritable path is a clear error rather than a crash.
The login flow is two HTTP calls:
request_code(email)→ API generates and emails an OTP, returns the org list (if multi-org).verify_code(email, code, organization_id?)→ consumes the OTP and returns a Bearer token.
Critical invariant: calling request_code again invalidates any pending OTP for that email. This is why non-interactive multi-org login caches the org list to disk during step 1 (org_cache.json) — step 2 reads the cache to resolve UUID → integer ID without re-issuing request_code. Breaking this would silently invalidate users' codes.
The org_cache.json is cleared after a successful verify.
- Issued by
verify_code(...). - Stored in
credentials.jsonwith0o600. - Sent on every authenticated request as
Authorization: Bearer <token>. - Used by human endpoints (
/v1/cli/*) and user-scoped endpoints (/v1/checkins/*,/v1/forms/*,/v1/users/,/v1/kudos/). - Revoked by
dailybot logout(best-effortPOST /v1/cli/auth/logout/+ local file removal). - Treated as expired/invalid on any 401/403 from a Bearer-mode call →
_handle_responserewrites the error to "Session expired. Run 'dailybot login' to re-authenticate."
There is no automatic refresh. Tokens have a server-defined lifetime; the user is expected to re-run dailybot login when prompted.
The user-scoped commands (checkin, form, kudos, user) operate within the authenticated user's permissions. Specific security decisions:
dailybot user list— intentionally omits email addresses from both table and JSON output. This is a PII-minimization measure for an open-source CLI. UUIDs are exposed for programmatic use (e.g.,--to <uuid>in kudos).dailybot kudos give— prevents self-kudos client-side. The receiver is resolved by name against the user directory; ambiguous matches are rejected rather than guessed.- Pagination safety —
list_users()caps at_MAX_LIST_PAGES = 50pages to prevent unbounded loops against a misbehaving backend. - Confirmation prompts —
checkin complete,form submit,kudos give, and the destructive authoring commands (form archive,checkin archive,form/checkin questions delete) show a confirmation before a team-visible or irreversible write.--yesskips it for non-interactive/scripted use. - Forms & check-ins authoring (
channels list;form/checkin create/edit/config/archive+questionssubgroups; extendedform responses --all/--user; owner/admin editing of another user's response) — all authorization is enforced server-side by role and reuses the existing permission model (no new permission types). The CLI performs only shape/format validation (question types, options, schedule) and surfaces the server's403codes with role-aware messages; it never approximates roles or elevates locally. Both a login session and an API key go through the identical_headers()path. - Exit codes — structured exit codes (2–7) enable safe scripting without parsing error messages. See API_REFERENCE.md for the full table.
- Issued by
dailybot agent register(returned in the registration response). - Stored either in
config.json::api_key,agents.json::profiles[<slug>].api_key, or in theDAILYBOT_API_KEYenv var. - Sent as
X-API-KEY: <key>on agent endpoints. - Never expires automatically. Rotation is a manual op (re-register or have the org admin rotate via the web UI).
- Set by the user via
dailybot agent webhook register --secret <secret>. - Stored on the API side, not locally on the CLI.
- Forwarded by Dailybot as
X-Webhook-Secret: <secret>on inbound webhook deliveries. - Treated as opaque by the CLI — we don't validate format.
When generating a secret on behalf of a user, suggest a high-entropy random string:
python -c 'import secrets; print(secrets.token_urlsafe(32))'dailybot agent register calls a no-auth endpoint protected by a math-puzzle challenge:
GET /v1/agent/register/challenge/returns{ challenge_id, instruction }. The instruction is a sentence ending in"... session is <random_number>.".- Compute
random_number * 52(the constant_CHALLENGE_WORD_COUNT). POST /v1/agent/register/withchallenge_id,answer, and the org/agent metadata.- Server validates the answer + rate-limits.
This is a low-friction anti-bot measure, not a strong security control. The backend additionally rate-limits by IP and applies abuse heuristics. If the challenge format ever needs to change, the CLI and the Dailybot API have to be updated together.
Recommended pattern for CI:
# Inject the API key via env var (never commit it, never write it to disk)
export DAILYBOT_API_KEY="${DAILYBOT_API_KEY}"
dailybot agent update "Build #${BUILD_ID} passed" --name "CI Bot"Avoid:
dailybot config key=...in CI (writes to disk; awkward to scrub between jobs).- Using a shared user account login (the Bearer token is a per-user credential —
dailybot agent registeris the right move for autonomous bots).
If you find a security issue in the CLI:
- Do not open a public GitHub issue.
- Email
support@dailybot.comwith a subject prefix[SECURITY]. - Include a minimal reproduction and the version (
dailybot --version).
For the API itself, follow Dailybot's main responsible-disclosure process.
dailybot plan tasks routes send-test, dailybot plan tasks reports send-test and dailybot plan tasks briefing send-test post real messages (to a chat
channel, to email recipients, to your DM). They never go out cold: the CLI first calls the door with
dry_run=true, shows the destination and the rendered content, and sends only after a confirmation or --yes;
a preview that fails, or that the server answers as if it had already acted, stops the command before anything is
sent. Automation and the test suite never send for real. Channel names, route and report names, and every title in a
report document are user-authored text: they are rendered quoted (present_untrusted), never as markup, and the
server neutralises <!channel>, @here and disguised links in the chat text it posts. These settings doors reject
agent_name, so the CLI never stamps an agent on them (a preference is not task work). Members read routes and
reports; only organization admins change them, and the CLI never refuses before the request: it renders the server's
insufficient_scope.