Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/release-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ jobs:
platform: linux
arch: x64
bun-target: bun-linux-x64
# Linux arm64: cross-compile from ubuntu-latest via Bun's --target flag.
# No Swift helper on Linux, so cross-compilation is straightforward.
- os: ubuntu-latest
platform: linux
arch: arm64
bun-target: bun-linux-arm64
runs-on: ${{ matrix.os }}
permissions:
contents: write
Expand Down Expand Up @@ -150,6 +156,11 @@ jobs:
end

on_linux do
on_arm do
url "https://github.com/ameshdev/amesh/releases/download/v${VERSION}/amesh-${VERSION}-linux-arm64.tar.gz"
sha256 "${{ steps.hashes.outputs.linux_arm64 }}"
end

on_intel do
url "https://github.com/ameshdev/amesh/releases/download/v${VERSION}/amesh-${VERSION}-linux-x64.tar.gz"
sha256 "${{ steps.hashes.outputs.linux_x64 }}"
Expand All @@ -158,6 +169,7 @@ jobs:

def install
bin.install "amesh"
bin.install "amesh-agent" if File.exist?("amesh-agent")
bin.install "amesh-se-helper" if File.exist?("amesh-se-helper")
end

Expand Down
14 changes: 10 additions & 4 deletions docs/architecture-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,21 @@ The controller CLI displays this code; the target CLI prompts the operator to en

## ADR-011: Remote shell in the CLI with explicit shell permission

**Decision:** The remote shell feature is part of `@authmesh/cli` — one package, one binary. `amesh shell` connects to a remote target. `amesh agent start` runs the daemon. Shell access requires explicit `amesh grant --shell` after pairing.
> **Status: Partially superseded (2026-04-05).** The single-package design was reversed: the agent daemon now ships in a separate `@authmesh/agent` package exposing an `amesh-agent` binary, while `@authmesh/cli` (`amesh`) keeps the controller-side commands (`init`, `list`, `invite`, `shell`, etc.) without the daemon.
>
> **Why the split:** the daemon uses `Bun.spawn({ terminal })` for PTY support, a Bun-only API. Bundling it with `@authmesh/cli` forced the entire controller install to depend on Bun even for users who only wanted `amesh init` + `amesh.fetch()`. Splitting lets `@authmesh/cli` ship a Node-compatible CLI and `@authmesh/agent` ship a Bun-dependent (or prebuilt-binary-only) daemon. The per-architecture prebuilt binaries are produced by the same release pipeline, so end users `brew install ameshdev/tap/amesh` to get both.
>
> **The security argument below is unchanged:** `amesh grant --shell` is still the real boundary, not the package boundary. The split is purely a runtime-dependency concern.

**Original decision (superseded):** The remote shell feature is part of `@authmesh/cli` — one package, one binary. `amesh shell` connects to a remote target. `amesh agent start` runs the daemon. Shell access requires explicit `amesh grant --shell` after pairing.

**Why:**

1. **One install:** Developers install one thing (`@authmesh/cli`) and get everything — identity management, pairing, API auth, shell client, and agent daemon.

2. **Explicit consent:** Pairing for API authentication (`amesh invite`) does not grant shell access. A `permissions.shell` flag in the allow list defaults to `false`. The target admin must explicitly run `amesh grant <device-id> --shell`. This is the security boundary, not the package boundary.

3. **The daemon is opt-in by invocation:** `amesh agent start` must be explicitly run. It doesn't auto-start, doesn't install as a service, and refuses to run as root without `--allow-root`.
3. **The daemon is opt-in by invocation:** `amesh-agent agent start` must be explicitly run. It doesn't auto-start, doesn't install as a service, and refuses to run as root without `--allow-root`.

**Security design choices:**

Expand All @@ -187,8 +193,8 @@ The controller CLI displays this code; the target CLI prompts the operator to en
- **Root guard** — agent refuses `root` without `--allow-root`
- **Per-controller session limits** — prevents DoS by authorized-but-misbehaving peers

**Rejected alternatives:**
- Separate `@authmesh/agent` package — adds install confusion without meaningful security benefit; the permission gate (`amesh grant --shell`) is the real security boundary, not the package boundary
**Rejected alternatives (at the time of the original decision):**
- ~~Separate `@authmesh/agent` package — adds install confusion without meaningful security benefit; the permission gate (`amesh grant --shell`) is the real security boundary, not the package boundary~~ *This was later reversed — see the Status note above. The runtime-dependency concern (Bun for PTY) outweighed the install-confusion concern once prebuilt binaries were shipped via the release pipeline.*
- Auto-granting shell on pairing — violates principle of least privilege
- Reusing pairing handshake's random-nonce encryption — birthday-bound risk over long sessions
- Session resumption — complexity and nonce-reuse risk outweigh the latency benefit
Expand Down
22 changes: 12 additions & 10 deletions docs/remote-shell-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ amesh already solves these problems for HTTP APIs. The remote shell extends the
```
Controller (laptop) Relay Target (server)
───────────────── ───── ───────────────
amesh shell am_7f2e amesh agent (daemon)
amesh shell am_7f2e amesh-agent agent (daemon)
│ │ │
│──── { type: 'shell', otc } ─────►│ │
│ │◄── { type: 'listen' } ────│ (agent is always connected)
Expand Down Expand Up @@ -85,7 +85,7 @@ No new pairing ceremony is needed. If `amesh list` on the target shows the contr

## 5. Components

### 5.1 `amesh agent` (target-side daemon)
### 5.1 `amesh-agent agent` (target-side daemon)

A long-running process on the target machine that:

Expand All @@ -97,12 +97,14 @@ A long-running process on the target machine that:
6. Streams encrypted I/O between the PTY and the relay tunnel

```bash
amesh agent start # start daemon (foreground)
amesh agent start --daemon # start as background process
amesh agent stop # stop the daemon
amesh agent status # show running state + connected controllers
amesh-agent agent start # start daemon (foreground)
amesh-agent agent start --daemon # start as background process
amesh-agent agent stop # stop the daemon
amesh-agent agent status # show running state + connected controllers
```

> **Note:** The daemon ships in a separate npm package (`@authmesh/agent`), installed on the target. The controller-side `amesh` command from `@authmesh/cli` does not include `agent start`. See [ADR: remote shell packaging](./architecture-decisions.md) for the rationale.

**Daemon lifecycle:**
- Reconnects to relay on disconnect (exponential backoff: 1s, 2s, 4s, ..., max 30s)
- Heartbeat every 30 seconds to keep WebSocket alive
Expand Down Expand Up @@ -243,8 +245,8 @@ With a 12-byte incrementing nonce and the high-bit split, each side can send 2^9
### Starting the agent (target)

```
$ amesh agent start
amesh agent listening on relay.authmesh.dev
$ amesh-agent agent start
amesh-agent listening on relay.authmesh.dev
Device: am_7f2e8a1b (prod-api)
Authorized controllers: 2

Expand Down Expand Up @@ -280,7 +282,7 @@ $ echo $?
```
$ amesh shell prod-api
Error: agent not connected for prod-api (am_7f2e8a1b).
Start the agent on the target: amesh agent start
Start the agent on the target: amesh-agent agent start
```

---
Expand All @@ -300,7 +302,7 @@ $ amesh shell prod-api
- Tests: handshake succeeds for paired devices, fails for unknown devices

### Phase 3 — Target agent daemon
- `amesh agent start` command (oclif)
- `amesh-agent agent start` command (oclif, shipped via `@authmesh/agent`)
- Persistent relay connection with reconnect
- PTY spawning via `Bun.spawn({ terminal: ... })`
- Encrypted I/O streaming (frame protocol)
Expand Down
16 changes: 9 additions & 7 deletions docs/why-amesh.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This model has a fundamental flaw: **the secret IS the identity.** Anyone who ha

### 1. Secrets leak constantly

GitHub's secret scanning detected over **1 million leaked secrets in public repos in 2024 alone.** This includes API keys, database credentials, and cloud provider tokens. These aren't just mistakes by juniors — Uber, Samsung, Toyota, and Twitch have all had major secret leaks.
GitHub's secret scanning detected over **1 million leaked secrets in public repos in 2024 alone.** This includes API keys, database credentials, and cloud provider tokens. These aren't hypothetical — real keys committed to real public repositories by real engineers at companies of every size. Every year, major breaches trace back to leaked credentials. These aren't junior mistakes; they're what happens when the design makes leaks easy.

Common leak vectors:
- `.env` committed to git (`.gitignore` missed or not set up)
Expand Down Expand Up @@ -55,16 +55,18 @@ A Bearer token doesn't tell you *who* is calling. If three servers and a develop

**amesh gives every machine a unique, verifiable identity.** When a request arrives, the server knows exactly which device sent it (`req.authMesh.deviceId`), its friendly name, and when it was verified. If one machine is compromised, you revoke it by device ID — the others are unaffected.

### 4. Secrets managers add complexity, not security
### 4. Secrets managers don't change the shape of the problem

Services like AWS Secrets Manager, HashiCorp Vault, and Doppler improve *management* of secrets but don't solve the fundamental problem: the secret still exists as a copyable string that must be fetched, held in memory, and sent over the wire.
Secrets managers improve the *management* of secrets, but they don't change the fundamental shape of the problem: the secret still exists as a copyable string that must be fetched, held in memory, and sent over the wire.

A secrets manager:
- Adds a dependency (if Vault is down, your service can't authenticate)
- Adds a runtime dependency if the manager is unreachable, your service can't authenticate
- Still delivers the secret as a string to your application
- Requires its own authentication (how does your server authenticate to Secrets Manager? With... another secret)
- Requires its own authentication (how does your server authenticate to the secrets manager? With *another secret*, which has the same problem)
- Adds latency on every cold start
- Costs money at scale
- Costs real money at scale

Secrets managers are a genuine improvement over loose `.env` files and they solve a real problem. But the secret is still a string, and a string is still copyable.

**amesh removes the secret entirely.** There is no string to manage, fetch, cache, or protect. The signing happens on the device. The only thing that crosses the wire is a cryptographic signature that is useless to an attacker — it's bound to a specific request, timestamp, and nonce.

Expand Down Expand Up @@ -92,7 +94,7 @@ This matters for:
| **Revocation** | Breaks everything using that key | Revokes one device. Others unaffected |
| **Audit trail** | "Someone with this key called the API" | "Device am_8f3a (prod-api-east) called the API at 10:05:32" |
| **Replay protection** | None (same token works forever) | Every request has a unique nonce + 30-second timestamp window |
| **What to protect** | .env files, CI variables, Vault access, Slack threads | Access to the device (same as SSH keys, passkeys) |
| **What to protect** | .env files, CI variables, secrets manager access, Slack threads | Access to the device (same as SSH keys, passkeys) |
| **What you store in git** | Nothing (and hope you never accidentally do) | Everything. There are no secrets in the codebase |

---
Expand Down
48 changes: 48 additions & 0 deletions landpage/src/lib/blog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export interface BlogPost {
slug: string;
title: string;
description: string;
date: string;
author: string;
readingTime: string;
tags: string[];
}

// Posts listed newest first. Individual post content lives in
// src/routes/blog/[slug]/+page.svelte — this file is for metadata only.
export const posts: BlogPost[] = [
{
slug: 'introducing-amesh-0-3',
title: 'Introducing amesh 0.3',
description:
'Auto-generated passphrases, verbose backend detection, a docs sidebar, and the security hardening that shipped across the 0.3.x line.',
date: '2026-04-04',
author: 'The amesh team',
readingTime: '5 min read',
tags: ['release', 'changelog'],
},
{
slug: 'why-we-built-amesh',
title: 'Why we built amesh',
description:
"Static API keys are a broken model. Over 1 million were leaked on GitHub in 2024. Here's why we think device-bound identity is the only honest fix.",
date: '2026-04-01',
author: 'The amesh team',
readingTime: '7 min read',
tags: ['essay', 'security'],
},
];

export function getPost(slug: string): BlogPost | undefined {
return posts.find((p) => p.slug === slug);
}

export function getPostNav(slug: string): { prev?: BlogPost; next?: BlogPost } {
const idx = posts.findIndex((p) => p.slug === slug);
if (idx === -1) return {};
return {
// posts[] is newest-first, so "next (newer)" is idx-1 and "prev (older)" is idx+1
prev: idx < posts.length - 1 ? posts[idx + 1] : undefined,
next: idx > 0 ? posts[idx - 1] : undefined,
};
}
122 changes: 54 additions & 68 deletions landpage/src/lib/components/DocsSidebar.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import { docPages } from '$lib/navigation.js';
import { docSections } from '$lib/navigation.js';

interface Props {
currentSlug: string;
Expand All @@ -9,18 +9,6 @@
let { currentSlug }: Props = $props();
let mobileOpen = $state(false);

const REPO = 'https://github.com/ameshdev/amesh';

const guides = [
...docPages.map((p) => ({ href: `/docs/${p.slug}`, label: p.title, external: false })),
{ href: `${REPO}/blob/main/docs/guide.md`, label: 'Usage Guide', external: true },
];

const reference = [
{ href: `${REPO}/blob/main/docs/protocol-spec.md`, label: 'Protocol Spec', external: true },
{ href: `${REPO}/blob/main/docs/architecture-decisions.md`, label: 'Architecture Decisions', external: true },
];

const packages = [
{ name: '@authmesh/core', href: 'https://www.npmjs.com/package/@authmesh/core' },
{ name: '@authmesh/keystore', href: 'https://www.npmjs.com/package/@authmesh/keystore' },
Expand All @@ -29,41 +17,39 @@
{ name: '@authmesh/relay', href: 'https://www.npmjs.com/package/@authmesh/relay' },
];

function isActive(href: string): boolean {
return href === `/docs/${currentSlug}`;
function isActive(slug: string): boolean {
return slug === currentSlug;
}
</script>

<!-- Desktop: always visible -->
<nav class="hidden lg:block">
<div class="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 mb-3">Guides</div>
<div class="space-y-0.5 border-l border-zinc-800 mb-6">
{#each guides as item}
<a
href={item.href}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
class="block py-1 pl-3 text-xs no-underline transition
{isActive(item.href) ? 'text-zinc-50 border-l-2 border-emerald-400 -ml-px font-medium' : 'text-zinc-400 hover:text-zinc-200'}"
>
{item.label}{#if item.external}<span class="ml-1 text-zinc-600">↗</span>{/if}
</a>
{/each}
</div>

<div class="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 mb-3">Reference</div>
<div class="space-y-0.5 border-l border-zinc-800 mb-6">
{#each reference as item}
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
class="block py-1 pl-3 text-xs text-zinc-400 no-underline transition hover:text-zinc-200"
>
{item.label}<span class="ml-1 text-zinc-600">↗</span>
</a>
{/each}
</div>
{#each docSections as section}
<div class="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 mb-3">{section.title}</div>
<div class="space-y-0.5 border-l border-zinc-800 mb-6">
{#each section.items as item}
<a
href="/docs/{item.slug}"
class="block py-1 pl-3 text-xs no-underline transition
{isActive(item.slug) ? 'text-zinc-50 border-l-2 border-emerald-400 -ml-px font-medium' : 'text-zinc-400 hover:text-zinc-200'}"
>
{item.title}
</a>
{/each}
{#if section.externals}
{#each section.externals as ext}
<a
href={ext.href}
target="_blank"
rel="noopener noreferrer"
class="block py-1 pl-3 text-xs text-zinc-400 no-underline transition hover:text-zinc-200"
>
{ext.label}<span class="ml-1 text-zinc-600">↗</span>
</a>
{/each}
{/if}
</div>
{/each}

<div class="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 mb-3">Packages</div>
<div class="space-y-0.5 border-l border-zinc-800">
Expand All @@ -83,38 +69,38 @@
<!-- Mobile: collapsible dropdown -->
<div class="lg:hidden mb-6">
<button
onclick={() => mobileOpen = !mobileOpen}
onclick={() => (mobileOpen = !mobileOpen)}
class="flex w-full items-center justify-between rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-xs text-zinc-400 cursor-pointer bg-transparent transition hover:border-zinc-700"
>
<span>Navigation</span>
<ChevronDown size={14} class="transition {mobileOpen ? 'rotate-180' : ''}" />
</button>
{#if mobileOpen}
<div class="mt-1 rounded-lg border border-zinc-800 bg-zinc-900 p-2">
<div class="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 px-3 pt-1 pb-2">Guides</div>
{#each guides as item}
<a
href={item.href}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
onclick={() => { if (!item.external) mobileOpen = false; }}
class="block rounded px-3 py-1.5 text-xs no-underline transition
{isActive(item.href) ? 'text-zinc-50 bg-zinc-800 font-medium' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300'}"
>
{item.label}{#if item.external}<span class="ml-1 text-zinc-600">↗</span>{/if}
</a>
{/each}

<div class="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 px-3 pt-3 pb-2">Reference</div>
{#each reference as item}
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
class="block rounded px-3 py-1.5 text-xs text-zinc-400 no-underline transition hover:bg-zinc-800 hover:text-zinc-300"
>
{item.label}<span class="ml-1 text-zinc-600">↗</span>
</a>
{#each docSections as section}
<div class="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 px-3 pt-2 pb-2">{section.title}</div>
{#each section.items as item}
<a
href="/docs/{item.slug}"
onclick={() => (mobileOpen = false)}
class="block rounded px-3 py-1.5 text-xs no-underline transition
{isActive(item.slug) ? 'text-zinc-50 bg-zinc-800 font-medium' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300'}"
>
{item.title}
</a>
{/each}
{#if section.externals}
{#each section.externals as ext}
<a
href={ext.href}
target="_blank"
rel="noopener noreferrer"
class="block rounded px-3 py-1.5 text-xs text-zinc-400 no-underline transition hover:bg-zinc-800 hover:text-zinc-300"
>
{ext.label}<span class="ml-1 text-zinc-600">↗</span>
</a>
{/each}
{/if}
{/each}

<div class="text-[10px] font-semibold uppercase tracking-widest text-zinc-600 px-3 pt-3 pb-2">Packages</div>
Expand Down
Loading
Loading