---
title: "Security — Krowk"
canonical_url: "https://krowk.com/security"
last_updated: "2026-09-02T08:07:53.083Z"
meta:
  description: "Push root confinement, credential-file and hard-link refusal, connection-time judgement of upload targets, one-shot key delivery, and what a keyless read means for your files."
  "og:description": "Push root confinement, credential-file and hard-link refusal, connection-time judgement of upload targets, one-shot key delivery, and what a keyless read means for your files."
  "og:title": "Security — Krowk"
---

**Security**

# **Security posture**

A tool that publishes whatever path it is handed, driven by a model that reads untrusted text, has to refuse things. Here is what it refuses and why.

[Read the docs](https://krowk.com/docs) [Email security@krowk.com](mailto:security@krowk.com)

Krowk publishes a file at a URL that needs no credential to read. That is the product, and it is also the thing that has to be got right: a tool an agent points at a path, which then publishes whatever is at that path, is one prompt injection away from being an exfiltration channel.

What follows is the posture, with the reasoning left in. Each item exists because the obvious implementation has a specific hole, and the hole is worth stating — a refusal whose reason is not written down gets simplified away by somebody who assumed it was caution.

## Pushes are confined to a root

The MCP server resolves every path against a root — the working directory, or `--root` / `KROWK_MCP_ROOT` — and refuses anything outside it. This matters more over MCP than on the command line: there a person types the path, here a model picks it, and a model reads repository files, web pages and issue bodies. Without the boundary, `krowk_push` means "read any file on this machine and publish it somewhere I can fetch".

Three things a plain prefix check gets wrong, all of them the common case rather than the exotic one:

- **Symlinks are resolved before the check, not after.** A link inside the repository pointing at \~/.ssh/id\_rsa sails through a prefix test done on the unresolved path.
- **The root may not be $HOME or /.** An agent started outside a checkout would otherwise take the home directory as its boundary — and \~/.ssh, \~/.aws and \~/.config/krowk are all inside it. A root that broad is refused, with a message saying to pass --root.
- **A file with more than one name is refused.** A hard link is not a link to anything; it is a second name for the same inode, so a path inside the root can be a key outside it with nothing to resolve. Nothing reports where the other names are, so any file with a link count above one is refused. Copy it in and push the copy.

Credential files are refused *inside* the root too, because a checkout is not free of secrets: `.env*`, `.ssh`, `.aws`, `.gnupg`, `.kube`, `.docker`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`, `credentials.json`, and the usual private key filenames, wherever they sit. Nobody publishes these on purpose, so the cost of refusing is an error message and the cost of allowing is a secret with a permalink.

One thing this is not: a sandbox. The boundary constrains the tool, not the agent. An agent that can also run a shell can read whatever it likes; this stops Krowk from being the thing that publishes it.

## Upload targets are storage, not anything reachable

An upload goes to a presigned URL, which by design names a foreign host — so a response body would otherwise choose the method, host, path, headers and body of a request the client makes from its own network position. In CI that position reaches a great deal the registry cannot.

- **https only, and always PUT.** Any method the registry returns is ignored.
- **Loopback, link-local, private and carrier-grade NAT are refused.** Including 100.64.0.0/10, where Tailscale lives, 240.0.0.0/4, multicast, and the NAT64 prefix 64:ff9b::/96 — which on an IPv6-only runner is how you reach a metadata service without naming a link-local address at all.
- **The check is on the connection, not only the URL.** Whoever returned the name can answer a pre-resolution check and the actual dial differently.
- **Redirects on an upload target are refused.** A presigned URL is where the bytes belong; a 302 would change the host and arrive as a GET, taking the method restriction with it. The one exception is a redirect within the API's own origin, and the https and dial checks still apply to the hop.

Two origins are exempt because the caller chose them: a local registry, where local targets are the whole point, and whatever host `KROWK_API_URL` names — a self-hosted registry serves blobs on its own host, and that host is configuration rather than the registry steering the client.

A proxy is exempt at its own address only. Corporate proxies sit on private addresses, so refusing those outright would refuse every upload from the environments this tool exists for. But the exemption is the address of the proxy the transport selected for that request — not any proxy in the environment, and not a switch that turns the boundary off. A request that skipped the proxy is judged like any other.

## A browser login separates collecting the key from approving it

`krowk auth login` opens a login and gets two capabilities back, and they are not interchangeable. The slug is what the CLI polls, and it never appears in a browser. The approval code — eight characters in two groups, from an alphabet without 0/O or 1/I — is what a person confirms, and approving or denying is the whole of what it can do.

The split is the point rather than a convenience. The code is the half that travels: printed into a terminal, pasted into a chat, read out on a call, left in a browser's history and visible to whatever extension is reading the page. So knowing a code must never yield a key, and it does not — no response to a request bearing only the code carries one.

Delivery is one-shot, and the plaintext is destroyed rather than withheld: the read that hands the key over leaves no copy, so a second read answers 410 spent and a lapsed window answers 410 expired. A dropped response therefore costs the login, which is the right way round — a key that can be fetched twice can be fetched by somebody else.

Nothing is opened in a browser over SSH, or on a session with no display server; the code and the page are printed instead. The URL that would be opened comes from a response body and is handed to the desktop's URL handler, which reaches far past an HTTP client — file:// walks the local disk, and desktops register schemes that start programs with arguments. So the CLI opens http and https and nothing else, as a single argument with no shell in the way.

## Reading is by slug, and the slug is the capability

A slug is 24 random lowercase base36 characters, prefixed by type. It is not derived from the content — a digest-derived id would make an anonymous link guessable, which is also why identical bytes do not dedupe into one artifact.

Reading one artifact is scoped to no workspace whether or not a key is sent, and that is deliberate: a scope a reader escapes by dropping the Authorization header protects nothing, and a pasted link has to resolve for whoever holds it. Writing stays scoped. The practical consequence is worth being blunt about — **anyone with the URL can read the file and its metadata**. Pasting a link is choosing to publish what is behind it, so nothing secret belongs in an artifact or in its metadata.

Card pages are noindex, so they do not turn up in search results. That is a courtesy, not a security boundary.

## Rate ceilings, and what they are not

Creating an artifact works with no credential at all, which is the product working as intended. It also means the one write path that costs a row and reserves storage is open to anybody who can reach it. Expiry bounds how long an abusive dump survives and does nothing about how fast one arrives, so creates are capped per minute: **10 per address without a key, 120 per key**, over which the API answers 429 with Retry-After.

Per address for keyless callers because an address is the only identity they have — a NAT therefore shares one bucket, which is the trade against having no ceiling on an unauthenticated write. Per key rather than per workspace for keyed ones, so one agent looping cannot throttle the others on the same runner.

A rate ceiling is not a spend cap. It slows a bill rather than stopping one. A hard spend cap is stated as coming and is not built, and the free tier's daily quota is a separate mechanism counted per workspace.

## Expiry moves the bytes; takedown deletes them

The 30-day recovery window cannot be "leave the object where it is": the CDN serves the whole bucket, so bytes left under the public key stay fetchable by anyone who ever held the link, and the expiry would not have happened. At expiry the object is moved to a key derived from the claim token digest — which never leaves the server and cannot be computed from the pasted link — and the edge is purged. A claim inside the window copies the bytes back under the public key, which is derived from the slug, so the link that was pasted is the link that revives.

Takedown never routes through that window. A secret that can be restored is still leaked, so a takedown deletes outright.

**FAQ**

## **What people ask first**

<details>

<summary>

Can anyone with the link see my file?

</summary>

Yes. Reading an artifact is by slug alone — 24 random lowercase base36 characters — with no key required, because a pasted link has to resolve for the person it was pasted to. Treat a Krowk URL as public. Nothing secret belongs in an artifact or its metadata.

</details>

<details>

<summary>

Can a prompt injection make an agent publish my SSH key?

</summary>

Not through krowk\_push. Every path is resolved against a root and refused if it lands outside; symlinks are resolved before the check rather than after; a file with more than one name is refused because a hard link has nothing to resolve; and credential files are refused inside the root as well. A root as broad as $HOME or / is itself refused. This constrains the tool, not the agent — an agent that can run a shell can read anything, but Krowk will not be what published it.

</details>

<details>

<summary>

Is the metadata on a card private?

</summary>

No. The card page is keyless, so the repository, commit, branch and agent recorded on an artifact are readable by anyone holding the URL. That is deliberate — the card exists to render for a reader who has no key — and it is why the rule is never to record a secret in metadata.

</details>

<details>

<summary>

How do I take something down?

</summary>

krowk uploads delete &lt;artifact&gt; removes it immediately and unrecoverably, and purges the edge. For something somebody else uploaded, email abuse@krowk.com. A taken-down URL answers a tombstone that names neither the filename nor the upload time — unlike an expired one, which names both.

</details>

<details>

<summary>

Do you have a bug bounty?

</summary>

No, and no formal disclosure timeline either. Reports go to security@krowk.com and are read.

</details>

## Reporting something

Security reports go to security@krowk.com. Abuse — a link hosting something that should not be hosted — goes to abuse@krowk.com; a takedown deletes the bytes and purges the edge, and the URL then answers a tombstone that names neither the filename nor the upload time.

There is no bug bounty programme and no formal disclosure timeline yet. Saying so is better than implying one exists.

## **The refusals are in the client, and the client is MIT.**

Read the code rather than taking this page's word for it.

[github.com/krowkcom/cli](https://github.com/krowkcom/cli) [Read the docs](https://krowk.com/docs)