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
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ jobs:
name: ${{ matrix.artifact }}-${{ env.VERSION }}
path: ${{ matrix.artifact }}-${{ env.VERSION }}.tar.gz

# macOS also ships the menu-bar VT.app (ad-hoc signed) alongside the
# bare `vt` binary. `just app` is the single source of the bundle
# layout; VT_APP_BIN reuses the binary just built (no second compile).
# tar.gz to match the binary artifacts; CLI extraction (`tar xzf`)
# avoids the Gatekeeper quarantine that Finder/zip would propagate.
- name: Package VT.app (macOS)
if: matrix.os == 'macos-latest'
run: |
brew install just
VT_APP_BIN="target/${{ matrix.target }}/release/vt" just app
COPYFILE_DISABLE=1 tar czf VT-app-darwin-arm64-${{ env.VERSION }}.tar.gz -C build VT.app

- name: Upload VT.app artifact (macOS)
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: VT-app-darwin-arm64-${{ env.VERSION }}
path: VT-app-darwin-arm64-${{ env.VERSION }}.tar.gz

release:
needs: build
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/.vscode
/target
# VT.app bundle output (just app / install-app)
/build
GEMINI.md
.claude/
.codex
Expand Down
62 changes: 61 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ pin the transport. See [`config.example.toml`](config.example.toml) and
| CLI and command routing | `src/main.rs` |
| Transport selection and client protocol | `src/config.rs`, `src/client.rs`, `src/cf.rs` |
| `vt://` format and cryptography | `src/core.rs`, `src/core/crypto.rs`, `src/core/wire.rs` |
| Agent authorization scopes, grants, and revocation | `src/core/authorization.rs`, `src/server_macos/authorization.rs`, `src/server_macos/ssh_agent.rs` |
| SSH identity and `vt ssh connect` | `src/ssh_sign.rs` |
| macOS Keychain, Touch ID, and SSH agent | `src/server_macos/` |
| AI-agent command hook | `src/hook.rs`, [`docs/hook.md`](docs/hook.md) |
| VT.app menu-bar shell and bundle packaging | `app/VTShell.swift`, `app/Info.plist`, `justfile` (`app`, `install-app`), [`docs/app-bundle.md`](docs/app-bundle.md) |
| Worker routes and admin pages | `cf-worker/src/index.ts`, `cf-worker/pwa/admin/` |
| Worker ceremony/cache lifecycle | `cf-worker/src/do_account.ts` |
| Worker notification channels | `cf-worker/src/notify.ts`, `pushover.ts`, `slack.ts`, `slack_app.ts`, `feishu.ts` |
Expand All @@ -48,6 +50,62 @@ pin the transport. See [`config.example.toml`](config.example.toml) and
behavior and IP binding.
- `auth@vt` and `run@vt` always require a fresh approval. Do not add them to
auth caches.
- `auth@vt`, `run@vt`, SSH signing, and `decrypt@vt` all use the unified
authorization engine. Reusable grants are operation- and subject-scoped,
and a non-cloneable permit is committed only after the protected operation
and, for extensions, response encryption succeed. A live permit holds the
global prompt slot and blocks revocation; handlers must not perform
unbounded-latency work while one is live.
- Grants are activity-scoped
([`docs/authorization-scopes-v2.md`](docs/authorization-scopes-v2.md)):
raw SSH signs bind to the session-bind-verified destination host key
(forwarding-capable or tainted connections are never cached); local vt
callers bind to their kernel-derived `.git` workspace root, falling back
to the exact cwd directory, then — for broad shared cwds (`$HOME`, its
ancestors, temp roots) — to the kernel-derived parent application. Each
fallback is a distinct grant family with its own digest domain; relay
traffic is confined per connection. `[email protected]` is plaintext and
must be handled before the VT_AUTH cipher path, and must not reset the
idle clock. The Touch ID prompt must state the reuse scope whenever an
approval can create a grant, and agent-derived truth lines (relay marker,
`caller:`, raw-sign `dest:`/taint warning, reuse) must precede every
client-reported line
([`docs/approval-transparency.md`](docs/approval-transparency.md)). Cache
duration `0` (the default) maps to the engine's first-class `Fresh`
policy, never `StrictTtl(0)`.
- A live locked/non-interactive check failure revokes all grants. Agent lock,
idle timeout, an observed screen lock, and a detected wake advance the
authorization epoch even when the grant store is empty, so an in-flight
prompt cannot recreate a revoked grant. Screen lock and idle timeout also
wipe decrypted SSH keys from memory (not just grants); `ensure_keys_loaded`
must refuse to reload keys while the screen is non-interactive — checked
both before and after the keychain read — so a request during lock cannot
repopulate RAM ([`docs/app-bundle.md`](docs/app-bundle.md) §10). Idle
timeout is floored at 60s (idle `0` is not a `Fresh`-style special case;
it would busy-loop the sweeper).
- `ui-status@vt` is the ONE deliberate whole-store visibility channel
([`docs/app-bundle.md`](docs/app-bundle.md) §5): plaintext, dispatched with
`[email protected]` before the lock check and the VT_AUTH cipher
path, gated by the 32-byte spawn token the VT.app shell pipes to
`--ui-token-fd` (never env/argv/file; constant-time compare; no token ⇒
every request fails unstructured). It never resets the idle clock, is never
audit-pushed, and its only actions are `status` and the authority-reducing
`revoke_all` — no action may grant, extend, or approve. The relay filter
must keep refusing it. Grant `display` labels are memory-only.
- The master-key wrap is versioned (`KeychainStore.wrap_v`): new stores are
always wrap v2 (path-independent `vt-wrap-v2` label); v1 stores upgrade
transparently at agent startup via the flock-guarded minimal mutator that
touches ONLY `encrypted_passphrase` + `wrap_v`. Never route a rewrap
through `create_and_save_passcode_passphrase` — it mints a fresh
passcode/auth_token and would silently rotate every client's VT_AUTH.
`vt secret rebind` is the manual migration/rollback path.
- Notifications must never block or fail a protected operation: `notify_macos`
is fire-and-forget (reaper thread), prefers the bundled `VTApp notify`
helper (bundle identity) with `osascript` fallback, and both paths share
one sanitize. Cache-hit notifications fire only AFTER `permit.commit()`
returns — a live permit blocks revocation, so no unbounded-latency work
(e.g. the first-use notification permission dialog) may run while one is
held.
- Plaintext secrets and private key seeds must not be written to disk or logs.
Do not add credentials to examples, test output, or command-line arguments
unless the existing design explicitly requires it.
Expand All @@ -68,7 +126,9 @@ pin the transport. See [`config.example.toml`](config.example.toml) and
between the remote client and the upstream agent.
- `diag@vt` is read-only and requires no Touch ID; it must never reset the
agent's idle-activity clock (a polling loop must not keep grants alive), and
its `live_entries` stays scoped to the caller's own cache context.
its `live_entries` counts only grants the caller's own scope classification
could reuse — a caller whose basis never caches reports 0, never a
whole-store count.
- `vt inject -r` decrypts a file briefly and starts a self-exec'd restore
supervisor. Keep the supervisor path before Tokio/clap initialization and
keep `--recover` limited to restoring ciphertext backups.
Expand Down
82 changes: 56 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,38 @@ common paths are:

## Installation

Download prebuilt binaries from [GitHub Releases](https://github.com/timqi/vt/releases) (macOS arm64, Linux amd64).
Download prebuilt artifacts from [GitHub Releases](https://github.com/timqi/vt/releases):
the bare `vt` binary (macOS arm64, Linux amd64) and, for macOS, **VT.app** — a
menu-bar app bundling the same `vt` CLI plus native VT-branded notifications
(including cache-hit transparency), live grant status with one-click
revoke-all, and agent supervision.

Or build from source (recipes live in the `justfile`, run `just` to list them):

```bash
# Builds (musl-static on Linux, native on macOS) and installs to ~/.local/bin
# CLI only: builds (musl-static on Linux, native on macOS) and installs to ~/.local/bin
just install

# macOS menu-bar app: assembles VT.app, installs to /Applications, symlinks ~/.local/bin/vt
just install-app
```

Installing the **downloaded** VT.app tarball (vs `just install-app`):

```bash
tar xzf VT-app-darwin-arm64-*.tar.gz # CLI extract avoids Gatekeeper quarantine
mv VT.app /Applications/
ln -sf /Applications/VT.app/Contents/MacOS/vt ~/.local/bin/vt # put the CLI on PATH
# If Gatekeeper still blocks it (e.g. extracted via Finder):
# xattr -dr com.apple.quarantine /Applications/VT.app
```

The release build is **ad-hoc signed**, so each release re-triggers the
one-time Keychain authorization prompt on first launch. See
[docs/app-bundle.md](docs/app-bundle.md) — including the one-time
`vt secret rebind` migration if your keychain store was created by a `vt`
binary at another path.

## Quick Start

> **Platform note.** Vault bootstrap and key storage (`init`, `secret *`,
Expand Down Expand Up @@ -168,11 +191,9 @@ vt ssh show SHA256:...
# Start the SSH agent (it listens on ~/.ssh/vt.sock):
eval $(vt ssh agent)

# Start with auth caching (skip repeated Touch ID within a time window):
# per-session: cache by terminal session (TTY)
eval $(vt ssh agent --ssh-auth-cache-mode per-session --ssh-auth-cache-duration 300)
# per-app: cache by application (e.g., Terminal.app, iTerm2)
eval $(vt ssh agent --ssh-auth-cache-mode per-app --ssh-auth-cache-duration 300)
# Start with approval reuse (skip repeated Touch ID within a time window;
# grants are activity-scoped — see "Auth Caching" below):
eval $(vt ssh agent --ssh-auth-cache-duration 28800 --decrypt-auth-cache-duration 3600)

# Set SSH_AUTH_SOCK to use the agent (add to your shell profile)
export SSH_AUTH_SOCK=~/.ssh/vt.sock
Expand All @@ -193,25 +214,34 @@ Keys are stored as a single encrypted JSON blob inside `rusty.vault.store` (unde

#### Auth Caching

By default, Touch ID is required for every sign/decrypt request. You can enable auth caching to skip repeated prompts within a time window:

| Mode | `--ssh-auth-cache-mode` | Scope |
|------|-------------------------|-------|
| None (default) | `none` | Touch ID every time |
| Per-session | `per-session` | Shared within same terminal/TTY |
| Per-app | `per-app` | Shared within same application (e.g., Terminal.app) |
| Global | `global` | Shared by orchestrated callers; still partitioned by reported working directory |

`--ssh-auth-cache-duration <SECONDS>` controls the sign cache TTL (default:
120s). `--decrypt-auth-cache-mode` and
`--decrypt-auth-cache-duration` configure a separate cache for v2 envelope
decrypts (default: disabled, 30s when a duration is supplied). Legacy URLs are
never eligible for the decrypt cache. Both caches are cleared when the agent
locks, the screen locks, the Mac wakes from sleep, or the idle timeout clears
keys. `per-session` and `per-app` require a controlling TTY; `global` is the
mode intended for TTY-less orchestrators. Forwarded-agent contexts are narrowed
to the connection; keep caching disabled (`none`) when forwarding to hosts you
do not trust.
By default (`--ssh-auth-cache-duration 0`), Touch ID is required for every
sign/decrypt request. Setting a duration enables **activity-scoped** approval
reuse — the Touch ID prompt always states exactly what is being granted and
for how long:

| Caller | Grant scope |
|--------|-------------|
| `ssh` / `git fetch` / `git push` (OpenSSH ≥ 8.9) | The **destination server** (verified via `[email protected]`): one approval covers repeated one-shot connections to the same host with the same key, from any local caller |
| `ssh-keygen -Y sign` (git commit signing), local `vt ssh connect` | The caller's **git workspace** (kernel-derived `.git` root): one approval covers the project, including multi-host fan-outs and TTY-less AI agents / CI working in the same checkout |
| Local caller outside any git repository | The caller's **exact working directory** (kernel-derived, a separate grant family from git workspaces) |
| Local caller from a broad shared directory (`$HOME`, `/`, temp roots) | The **calling application** (kernel-derived parent process): repeated requests from the same app instance — e.g. a daemon probing `gh` through the hook — share one approval; grants die when the app exits |
| Forwarded / relay traffic (`ssh -A`, `--forward-real-agent`) | vt extensions (`decrypt@vt`, `sign@vt`) are confined **per connection**: a remote host can reuse only its own approvals and never rides local grants. Raw SSH signs arriving through a forwarding-capable connection are never cached at all |
| OpenSSH < 8.9, `auth@vt`, `run@vt`, legacy URLs | Never cached — always prompts |

`--ssh-auth-cache-duration <SECS>` and `--decrypt-auth-cache-duration <SECS>`
are separate knobs (a cached decrypt grant releases per-record DEK material,
so you may want it shorter or disabled). TTLs are strict (no sliding
refresh), and every grant is revoked immediately when the agent locks, the
screen locks, the Mac sleeps/wakes, or the idle timeout fires — the duration
is effectively "within this presence session, at most N hours", so generous
values (8h sign / 1h decrypt) are reasonable.

For **unattended periodic jobs** (editor auto-fetch, cron), caching is the
wrong tool — any TTL eventually prompts while you are away. Give fetch a
read-only credential instead (a GitHub read-only deploy key via a `Host`
alias with `IdentitiesOnly yes`, or HTTPS with a `contents:read` token), or
keep an ssh `ControlMaster`/`ControlPersist` window longer than the fetch
interval so the connection never re-authenticates.

### Portable SSH identity for git (`vt://`)

Expand Down
33 changes: 33 additions & 0 deletions app/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>dev.rustyvault.vt</string>
<key>CFBundleName</key>
<string>VT</string>
<key>CFBundleDisplayName</key>
<string>VT</string>
<!-- "VTApp", not "VT": the Rust CLI lives beside it as "vt", and the
default APFS volume is case-insensitive — "VT" and "vt" would be
the same file. Menu bar / notifications show CFBundleDisplayName. -->
<key>CFBundleExecutable</key>
<string>VTApp</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleShortVersionString</key>
<string>VT_BUNDLE_VERSION</string>
<key>CFBundleVersion</key>
<string>VT_BUNDLE_VERSION</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>VT</string>
</dict>
</plist>
Loading
Loading