diff --git a/README.md b/README.md index e982ba9..6fe9693 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ShellWatch is a Human-in-the-Loop platform for agent-driven SSH. Passkey-first a - **Passkey-only auth** — WebAuthn for UI login, agent enrollment, and SSH authentication via OpenSSH's [`webauthn-sk-ecdsa-sha2-nistp256@openssh.com`](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.u2f) signature algorithm - **End-to-end SSH-agent proxy** — local `ssh`/`scp`/`git` reach a passkey via ShellWatch with explicit browser approval per signature -- **Agent forwarding into sessions** — your passkey-backed SSH agent is forwarded into every ShellWatch session, so you can hop to additional hosts and enable SSH-agent-based PAM integration +- **Agent forwarding into sessions** — your passkey-backed SSH agent is forwarded into ShellWatch sessions (per-endpoint toggle), so you can hop to additional hosts and enable SSH-agent-based PAM integration - **PAM integration** — pair with [`pam-ssh-agent-webauthn`](https://github.com/rado0x54/pam-ssh-agent-webauthn) to gate `sudo` (or any PAM-aware step) behind a passkey approval surfaced through ShellWatch - **Human-in-the-loop for agents** — MCP agents request, humans approve; sensitive actions can require per-action consent - **Realtime notifications** — sign requests arrive as Web Push and in-UI toasts diff --git a/client/src/lib/stores/account.ts b/client/src/lib/stores/account.ts index 2636afa..5d0be0f 100644 --- a/client/src/lib/stores/account.ts +++ b/client/src/lib/stores/account.ts @@ -5,7 +5,6 @@ export interface AccountData { id: string; name: string; isAdmin: boolean; - agentForward: boolean; } export const account = writable(null); @@ -36,16 +35,3 @@ export async function updateAccountName(name: string): Promise { } await fetchAccount(); } - -export async function updateAgentForward(agentForward: boolean): Promise { - const res = await fetch("/api/auth/me", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentForward }), - }); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || "Failed to update agent forwarding"); - } - await fetchAccount(); -} diff --git a/client/src/lib/stores/endpoints.ts b/client/src/lib/stores/endpoints.ts index 6b0479a..a8caced 100644 --- a/client/src/lib/stores/endpoints.ts +++ b/client/src/lib/stores/endpoints.ts @@ -18,6 +18,7 @@ export interface Endpoint { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; } @@ -35,6 +36,7 @@ export async function createEndpoint(body: { port: number; username: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; }): Promise { const res = await fetch("/api/endpoints", { diff --git a/client/src/routes/admin/general/+page.svelte b/client/src/routes/admin/general/+page.svelte index f4fd03b..5e1aeb7 100644 --- a/client/src/routes/admin/general/+page.svelte +++ b/client/src/routes/admin/general/+page.svelte @@ -13,6 +13,7 @@ interface SeedEndpoint { label: string; address: string; + agentForward: boolean; passkeyCredentialRef?: string; } @@ -53,6 +54,10 @@ for (const ep of endpoints) { lines.push(` - label: ${yamlStr(ep.label)}`); lines.push(` address: ${yamlStr(ep.address)}`); + // Only emit when off — default is true, keep YAML clean. + if (!ep.agentForward) { + lines.push(" agentForward: false"); + } if (ep.passkeyCredentialRef) { lines.push(` passkeyCredentialRef: ${yamlStr(ep.passkeyCredentialRef)}`); } diff --git a/client/src/routes/settings/endpoints/+page.svelte b/client/src/routes/settings/endpoints/+page.svelte index 7dad21d..8728b10 100644 --- a/client/src/routes/settings/endpoints/+page.svelte +++ b/client/src/routes/settings/endpoints/+page.svelte @@ -28,6 +28,7 @@ let formLabel = $state(""); let formAddress = $state(""); let formUserVerification = $state("required"); + let formAgentForward = $state(true); let formDescription = $state(""); let deleteTarget = $state(null); let deleting = $state(false); @@ -41,6 +42,7 @@ formLabel = ""; formAddress = ""; formUserVerification = "required"; + formAgentForward = true; formDescription = ""; } @@ -49,6 +51,7 @@ formLabel = ep.label; formAddress = formatEndpointAddress(ep); formUserVerification = ep.userVerification; + formAgentForward = ep.agentForward; formDescription = ep.description ?? ""; } @@ -80,6 +83,7 @@ port: parsed.port, username: parsed.username, userVerification: formUserVerification, + agentForward: formAgentForward, description, }); } else { @@ -89,6 +93,7 @@ port: parsed.port, username: parsed.username, userVerification: formUserVerification, + agentForward: formAgentForward, description, }); } @@ -134,7 +139,10 @@ {#snippet primary()} {ep.label} - {ep.userVerification} + UV: {ep.userVerification} + + forward: {ep.agentForward ? "on" : "off"} + {/snippet} {#snippet secondary()}{formatEndpointAddress(ep)}{/snippet} {#snippet actions()} @@ -232,6 +240,28 @@
{formDescription.length} / {ENDPOINT_DESCRIPTION_MAX_LENGTH}
+
+ +
+ + {formAgentForward ? "Enabled" : "Disabled"} +
+ + Forward SSH keys to this host so onward tools (ssh, git) can authenticate. + +
+ {#snippet actions()} - - {$account.agentForward ? "Enabled" : "Disabled"} - - - - Forward SSH keys to remote hosts so programs like ssh and git can authenticate onward. - - {#if agentForwardMessage} - {agentForwardMessage} - {/if} - -
Version
@@ -221,57 +170,4 @@ .message.success { color: var(--green, #4ade80); } - - .toggle-row { - display: flex; - align-items: center; - gap: 0.75rem; - } - - .toggle { - position: relative; - width: 40px; - height: 22px; - border-radius: 11px; - border: 1px solid var(--border); - background: var(--bg-primary); - cursor: pointer; - padding: 0; - transition: background-color 0.2s; - } - - .toggle.active { - background: var(--green, #4ade80); - border-color: var(--green, #4ade80); - } - - .toggle-knob { - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--text-muted); - transition: - transform 0.2s, - background-color 0.2s; - } - - .toggle.active .toggle-knob { - transform: translateX(18px); - background: white; - } - - .toggle-label { - font-size: 0.85rem; - color: var(--text-primary); - } - - .field-hint { - display: block; - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 0.375rem; - } diff --git a/config.sample.yaml b/config.sample.yaml index 28f2584..dd5b3c1 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -40,12 +40,14 @@ keyDirectory: ./keys # Optional: seed endpoints for the admin account on first run (keys are assigned via UI) # Format: [user@]host[:port] — defaults: user=shellwatch, port=22 +# agentForward defaults to true; set to false for hosts that disallow forwarding. # seedAdminEndpoints: # - label: Dev Box # address: ubuntu@dev.example.com # # - label: Staging # address: deploy@staging.example.com:2222 +# agentForward: false # Security settings security: diff --git a/drizzle/0007_endpoint_agent_forward.sql b/drizzle/0007_endpoint_agent_forward.sql new file mode 100644 index 0000000..42104d9 --- /dev/null +++ b/drizzle/0007_endpoint_agent_forward.sql @@ -0,0 +1,2 @@ +ALTER TABLE `endpoints` ADD `agent_forward` integer DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE `accounts` DROP COLUMN `agent_forward`; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..72eee5a --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,973 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "833d8ef2-d304-4fca-a662-6c996f63b68d", + "prevId": "3ef48364-48b4-4c51-8686-c11af4722eb9", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "max_sessions": { + "name": "max_sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_account": { + "name": "admin_account", + "columns": { + "singleton": { + "name": "singleton", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "admin_account_account_id_accounts_id_fk": { + "name": "admin_account_account_id_accounts_id_fk", + "tableFrom": "admin_account", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "single_row": { + "name": "single_row", + "value": "\"admin_account\".\"singleton\" = 1" + } + } + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoints": { + "name": "endpoints", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": ["key_hash"], + "isUnique": true + } + }, + "foreignKeys": { + "api_keys_account_id_accounts_id_fk": { + "name": "api_keys_account_id_accounts_id_fk", + "tableFrom": "api_keys", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_session_lifecycle": { + "name": "audit_session_lifecycle", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_ip": { + "name": "source_ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_reason": { + "name": "mcp_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_client_name": { + "name": "mcp_client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_client_version": { + "name": "mcp_client_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_label": { + "name": "api_key_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_hostname": { + "name": "client_hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_os": { + "name": "client_os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_session_lifecycle_account_created_idx": { + "name": "audit_session_lifecycle_account_created_idx", + "columns": ["account_id", "created_at", "session_id"], + "isUnique": false + }, + "audit_session_lifecycle_account_endpoint_created_idx": { + "name": "audit_session_lifecycle_account_endpoint_created_idx", + "columns": ["account_id", "endpoint_id", "created_at", "session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "audit_session_lifecycle_account_id_accounts_id_fk": { + "name": "audit_session_lifecycle_account_id_accounts_id_fk", + "tableFrom": "audit_session_lifecycle", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "audit_session_lifecycle_status_chk": { + "name": "audit_session_lifecycle_status_chk", + "value": "\"audit_session_lifecycle\".\"status\" IN ('open','closed','error')" + }, + "audit_session_lifecycle_source_chk": { + "name": "audit_session_lifecycle_source_chk", + "value": "\"audit_session_lifecycle\".\"source\" IN ('ui','mcp','ssh')" + } + } + }, + "audit_signing_requests": { + "name": "audit_signing_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_ip": { + "name": "source_ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_label": { + "name": "endpoint_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_address": { + "name": "endpoint_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_reason": { + "name": "mcp_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_client_name": { + "name": "mcp_client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcp_client_version": { + "name": "mcp_client_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_label": { + "name": "api_key_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_hostname": { + "name": "client_hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_os": { + "name": "client_os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passkey_label": { + "name": "passkey_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_label": { + "name": "key_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_signing_requests_account_created_idx": { + "name": "audit_signing_requests_account_created_idx", + "columns": ["account_id", "created_at", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "audit_signing_requests_account_id_accounts_id_fk": { + "name": "audit_signing_requests_account_id_accounts_id_fk", + "tableFrom": "audit_signing_requests", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "audit_signing_requests_type_chk": { + "name": "audit_signing_requests_type_chk", + "value": "\"audit_signing_requests\".\"type\" IN ('webauthn-sign','key-approve')" + }, + "audit_signing_requests_source_chk": { + "name": "audit_signing_requests_source_chk", + "value": "\"audit_signing_requests\".\"source\" IN ('endpoint-auth','agent-forwarding','agent-proxy')" + }, + "audit_signing_requests_outcome_chk": { + "name": "audit_signing_requests_outcome_chk", + "value": "\"audit_signing_requests\".\"outcome\" IS NULL OR \"audit_signing_requests\".\"outcome\" IN ('approved','denied','expired','cancelled')" + } + } + }, + "endpoints": { + "name": "endpoints", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'required'" + }, + "agent_forward": { + "name": "agent_forward", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "endpoints_account_id_accounts_id_fk": { + "name": "endpoints_account_id_accounts_id_fk", + "tableFrom": "endpoints", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": ["endpoint"], + "isUnique": true + } + }, + "foreignKeys": { + "push_subscriptions_account_id_accounts_id_fk": { + "name": "push_subscriptions_account_id_accounts_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_keys": { + "name": "ssh_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'file'" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ssh_keys_fingerprint_unique": { + "name": "ssh_keys_fingerprint_unique", + "columns": ["fingerprint"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key_openssh": { + "name": "public_key_openssh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webauthn_credentials_credential_id_unique": { + "name": "webauthn_credentials_credential_id_unique", + "columns": ["credential_id"], + "isUnique": true + } + }, + "foreignKeys": { + "webauthn_credentials_account_id_accounts_id_fk": { + "name": "webauthn_credentials_account_id_accounts_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 61bfff8..68785a3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1777783781140, "tag": "0006_audit_signing_requests", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1779047498048, + "tag": "0007_endpoint_agent_forward", + "breakpoints": true } ] } diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index e7360ca..53bb145 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -128,6 +128,31 @@ seedAdminEndpoints: expect(() => loadConfig(configPath)).toThrow("Invalid config"); }); + it("defaults agentForward to true on seed endpoints", () => { + const dir = createTempDir(); + const configPath = writeConfig( + dir, + ` +server: + externalUrl: http://localhost:3000 +security: + rpId: localhost + trustedWebauthnOrigins: + - http://localhost +seedAdminEndpoints: + - label: Default + address: host.example.com + - label: NoForward + address: locked.example.com + agentForward: false +`, + ); + + const config = loadConfig(configPath); + expect(config.seedAdminEndpoints[0].agentForward).toBe(true); + expect(config.seedAdminEndpoints[1].agentForward).toBe(false); + }); + it("loads multiple endpoints with different address formats", () => { const dir = createTempDir(); const configPath = writeConfig( diff --git a/src/config/schema.ts b/src/config/schema.ts index 755591e..7234e96 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -8,6 +8,11 @@ export const SeedEndpointSchema = z.object({ .string() .min(1) .transform((addr) => parseEndpointAddress(addr)), + /** + * Whether to enable SSH agent forwarding on sessions to this endpoint. + * Defaults to true — opt out per endpoint when the host disallows forwarding. + */ + agentForward: z.boolean().default(true), }); /** Field-level defaults for optional security settings (rpId and trustedWebauthnOrigins are required) */ diff --git a/src/db/repositories/account-repo.ts b/src/db/repositories/account-repo.ts index ba2eea1..5d5bac2 100644 --- a/src/db/repositories/account-repo.ts +++ b/src/db/repositories/account-repo.ts @@ -9,7 +9,6 @@ export interface AccountInfo { isAdmin: boolean; enabled: boolean; maxSessions: number; - agentForward: boolean; lastUsedAt: string | null; createdAt: string; updatedAt: string; @@ -20,7 +19,7 @@ export interface AccountRepository { findAll(): Promise; update( id: string, - data: Partial>, + data: Partial>, ): Promise; /** Mark account as active. Writes are batched — call flushLastUsed() to persist. */ touchLastUsed(id: string): void; @@ -82,7 +81,7 @@ export class DrizzleAccountRepository implements AccountRepository { async update( id: string, - data: Partial>, + data: Partial>, ): Promise { this.db .update(accounts) diff --git a/src/db/repositories/endpoint-repo.ts b/src/db/repositories/endpoint-repo.ts index 30ebceb..8752605 100644 --- a/src/db/repositories/endpoint-repo.ts +++ b/src/db/repositories/endpoint-repo.ts @@ -27,6 +27,7 @@ export interface EndpointInfo { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; } @@ -45,6 +46,7 @@ export interface EndpointRepository { port: number; username: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; }): Promise; update( @@ -56,6 +58,7 @@ export interface EndpointRepository { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; }>, ): Promise; @@ -70,6 +73,7 @@ const ENDPOINT_COLUMNS = { port: endpoints.port, username: endpoints.username, userVerification: endpoints.userVerification, + agentForward: endpoints.agentForward, description: endpoints.description, } as const; @@ -101,6 +105,7 @@ export class DrizzleEndpointRepository implements EndpointRepository { port: number; username: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; }): Promise { const now = new Date().toISOString(); @@ -109,6 +114,7 @@ export class DrizzleEndpointRepository implements EndpointRepository { .values({ ...data, userVerification: data.userVerification ?? "required", + agentForward: data.agentForward ?? true, description: data.description ?? null, enabled: true, createdAt: now, @@ -126,6 +132,7 @@ export class DrizzleEndpointRepository implements EndpointRepository { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; }>, ): Promise { @@ -150,9 +157,10 @@ export class InMemoryEndpointRepository implements EndpointRepository { constructor( initialEndpoints: Array< - Omit & { + Omit & { accountId?: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; } > = [], @@ -162,6 +170,7 @@ export class InMemoryEndpointRepository implements EndpointRepository { ...e, accountId: e.accountId ?? this.defaultAccountId, userVerification: e.userVerification ?? "required", + agentForward: e.agentForward ?? true, description: e.description ?? null, })); } @@ -182,11 +191,13 @@ export class InMemoryEndpointRepository implements EndpointRepository { port: number; username: string; userVerification?: UserVerification; + agentForward?: boolean; description?: string | null; }): Promise { this.store.push({ ...data, userVerification: data.userVerification ?? "required", + agentForward: data.agentForward ?? true, description: data.description ?? null, }); } @@ -200,6 +211,7 @@ export class InMemoryEndpointRepository implements EndpointRepository { port: number; username: string; userVerification: UserVerification; + agentForward: boolean; description: string | null; }>, ): Promise { diff --git a/src/db/schema.ts b/src/db/schema.ts index b6158b1..adfd95a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -10,7 +10,6 @@ export const accounts = sqliteTable("accounts", { enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), maxSessions: integer("max_sessions").notNull().default(5), - agentForward: integer("agent_forward", { mode: "boolean" }).notNull().default(false), lastUsedAt: text("last_used_at"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), @@ -87,6 +86,13 @@ export const endpoints = sqliteTable("endpoints", { * host / authenticator can't provide UV. */ userVerification: text("user_verification").notNull().default("required"), + /** + * Whether to enable SSH agent forwarding when opening a session to this + * endpoint. Defaults to enabled — some hosts disallow forwarding (e.g. + * AllowAgentForwarding no in sshd_config) and the user can disable it + * per-endpoint to avoid a forwarding-channel-rejected handshake. + */ + agentForward: integer("agent_forward", { mode: "boolean" }).notNull().default(true), /** * Optional free-form description (max 1000 chars). Surfaced to MCP agents in * the per-endpoint instructions so they have context about what runs on the diff --git a/src/db/seed.ts b/src/db/seed.ts index ab9f519..a685be5 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -105,6 +105,7 @@ export function seedFromConfig(db: ShellWatchDB, config: Config): SeedResult { host: ep.address.host, port: ep.address.port, username: ep.address.username, + agentForward: ep.agentForward, enabled: true, createdAt: now, updatedAt: now, diff --git a/src/server/routes/accounts.test.ts b/src/server/routes/accounts.test.ts index 051f2e3..7bd115e 100644 --- a/src/server/routes/accounts.test.ts +++ b/src/server/routes/accounts.test.ts @@ -16,7 +16,6 @@ function stubAccount(id: string, isAdmin: boolean): AccountInfo { isAdmin, enabled: true, maxSessions: 5, - agentForward: false, lastUsedAt: null, createdAt: now, updatedAt: now, diff --git a/src/server/routes/accounts.ts b/src/server/routes/accounts.ts index 817e5e8..f137582 100644 --- a/src/server/routes/accounts.ts +++ b/src/server/routes/accounts.ts @@ -34,33 +34,26 @@ export function registerAccountRoutes(params: AccountRoutesParams) { id: account.id, name: account.name, isAdmin: account.isAdmin, - agentForward: account.agentForward, }; }); - app.put<{ Body: { name?: string; agentForward?: boolean } }>( - "/api/auth/me", - async (request, reply) => { - const accountId = request.accountId; - const { name, agentForward } = request.body; - const updates: Partial<{ name: string; agentForward: boolean }> = {}; - if (name !== undefined) { - const trimmed = name.trim(); - if (!trimmed) { - reply.status(400); - return { error: "Name cannot be empty" }; - } - updates.name = trimmed; - } - if (agentForward !== undefined) { - updates.agentForward = agentForward; - } - if (Object.keys(updates).length > 0) { - await accountRepo.update(accountId, updates); + app.put<{ Body: { name?: string } }>("/api/auth/me", async (request, reply) => { + const accountId = request.accountId; + const { name } = request.body; + const updates: Partial<{ name: string }> = {}; + if (name !== undefined) { + const trimmed = name.trim(); + if (!trimmed) { + reply.status(400); + return { error: "Name cannot be empty" }; } - return { status: "updated" }; - }, - ); + updates.name = trimmed; + } + if (Object.keys(updates).length > 0) { + await accountRepo.update(accountId, updates); + } + return { status: "updated" }; + }); // --- Account Management (admin only) --- @@ -77,7 +70,6 @@ export function registerAccountRoutes(params: AccountRoutesParams) { isAdmin: a.isAdmin, enabled: a.enabled, maxSessions: a.maxSessions, - agentForward: a.agentForward, lastUsedAt: a.lastUsedAt, createdAt: a.createdAt, })), @@ -162,6 +154,7 @@ export function registerAccountRoutes(params: AccountRoutesParams) { host: endpointsTable.host, port: endpointsTable.port, username: endpointsTable.username, + agentForward: endpointsTable.agentForward, }) .from(endpointsTable) .where(eq(endpointsTable.accountId, adminId)) @@ -192,6 +185,7 @@ export function registerAccountRoutes(params: AccountRoutesParams) { host: ep.host, port: ep.port, }), + agentForward: ep.agentForward, })); return { passkeys: seedPasskeys, endpoints: seedEndpoints }; diff --git a/src/server/routes/endpoints.ts b/src/server/routes/endpoints.ts index 6c21059..4040883 100644 --- a/src/server/routes/endpoints.ts +++ b/src/server/routes/endpoints.ts @@ -31,15 +31,18 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { app.get("/api/endpoints", async (request) => { const all = await endpointRepo.findAllForAccount(request.accountId); return { - endpoints: all.map(({ id, label, host, port, username, userVerification, description }) => ({ - id, - label, - host, - port, - username, - userVerification, - description, - })), + endpoints: all.map( + ({ id, label, host, port, username, userVerification, agentForward, description }) => ({ + id, + label, + host, + port, + username, + userVerification, + agentForward, + description, + }), + ), }; }); @@ -50,6 +53,7 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { port?: number; username?: string; userVerification?: string; + agentForward?: boolean; description?: string | null; }; }>("/api/endpoints", async (request, reply) => { @@ -61,6 +65,13 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { error: `userVerification must be one of: ${USER_VERIFICATION_VALUES.join(", ")}`, }; } + if ( + request.body.agentForward !== undefined && + typeof request.body.agentForward !== "boolean" + ) { + reply.status(400); + return { error: "agentForward must be a boolean" }; + } const desc = normalizeDescription(request.body.description); if (!desc.ok) { reply.status(400); @@ -77,6 +88,7 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { port: request.body.port ?? 22, username: request.body.username ?? "shellwatch", userVerification: uv as UserVerification | undefined, + agentForward: request.body.agentForward, description: desc.value, }); return { status: "created", id }; @@ -95,6 +107,7 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { port?: number; username?: string; userVerification?: string; + agentForward?: boolean; description?: string | null; }; }>("/api/endpoints/:id", async (request, reply) => { @@ -106,6 +119,10 @@ export function registerEndpointRoutes(params: EndpointRoutesParams) { error: `userVerification must be one of: ${USER_VERIFICATION_VALUES.join(", ")}`, }; } + if (body.agentForward !== undefined && typeof body.agentForward !== "boolean") { + reply.status(400); + return { error: "agentForward must be a boolean" }; + } const descriptionPatch: Partial<{ description: string | null }> = {}; if (body.description !== undefined) { const desc = normalizeDescription(body.description); diff --git a/src/terminal/terminal-manager.test.ts b/src/terminal/terminal-manager.test.ts index 65572ae..7f98433 100644 --- a/src/terminal/terminal-manager.test.ts +++ b/src/terminal/terminal-manager.test.ts @@ -22,6 +22,7 @@ const testEndpoint: EndpointInfo = { port: 22, username: "testuser", userVerification: "required", + agentForward: true, description: null, }; diff --git a/src/test/helpers/app-server.ts b/src/test/helpers/app-server.ts index afed877..fb90729 100644 --- a/src/test/helpers/app-server.ts +++ b/src/test/helpers/app-server.ts @@ -104,6 +104,7 @@ export async function startTestApp( { label: "Test Server", address: { username: "testuser", host: sshServer.host, port: sshServer.port }, + agentForward: true, }, ], security: { cookieSecret: testCookieSecret }, diff --git a/src/test/integration/agent-forward.test.ts b/src/test/integration/agent-forward.test.ts index 493c32e..b75e7a1 100644 --- a/src/test/integration/agent-forward.test.ts +++ b/src/test/integration/agent-forward.test.ts @@ -60,6 +60,7 @@ describe("SSH Agent Forwarding", () => { host: sshServer.host, port: sshServer.port, username: "testuser", + agentForward, }, ]); const keyRepo = new InMemorySshKeyRepository([ @@ -69,7 +70,6 @@ describe("SSH Agent Forwarding", () => { const factory = new SshTransportFactory(keyRepo, keyProvider, { rpId: "localhost", - getAgentForward: async () => agentForward, isAdmin: () => true, createAgent: ({ fileKeys, agentForward: fwd }) => { const fileKeyEntries = fileKeys diff --git a/src/test/integration/error-scenarios.test.ts b/src/test/integration/error-scenarios.test.ts index 31f542d..9b49793 100644 --- a/src/test/integration/error-scenarios.test.ts +++ b/src/test/integration/error-scenarios.test.ts @@ -129,6 +129,7 @@ describe("Error Scenarios", () => { { label: "No Key", address: { username: "test", host: "localhost", port: 22 }, + agentForward: true, }, ], security: { cookieSecret: testSecret }, diff --git a/src/transport/create-factory.ts b/src/transport/create-factory.ts index b0a54f3..46bf807 100644 --- a/src/transport/create-factory.ts +++ b/src/transport/create-factory.ts @@ -43,11 +43,6 @@ export function createSshTransportFactoryFromConfig( findCredentialsForAccount: (accountId) => findCredentialsForAccount(db, accountId), isAdmin: (accountId) => accountRepo.isAdmin(accountId), - getAgentForward: async (accountId) => { - const account = await accountRepo.findById(accountId); - return account?.agentForward ?? false; - }, - createAgent: ({ endpoint, fileKeys, diff --git a/src/transport/ssh-transport-factory.test.ts b/src/transport/ssh-transport-factory.test.ts index 786bab1..c6517cb 100644 --- a/src/transport/ssh-transport-factory.test.ts +++ b/src/transport/ssh-transport-factory.test.ts @@ -24,6 +24,7 @@ const testEndpoint: EndpointInfo = { username: "user", accountId: "account-1", userVerification: "required", + agentForward: false, description: null, }; @@ -211,7 +212,7 @@ describe("SshTransportFactory", () => { expect(cleanup).toHaveBeenCalledOnce(); }); - it("passes agentForward=true when account has it enabled", async () => { + it("passes agentForward=true when the endpoint has it enabled", async () => { const mockTransport = createMockTransport(); const mockAgent = { sign: vi.fn() } as never; mockConnectSshWithAgent.mockResolvedValue(mockTransport); @@ -226,12 +227,11 @@ describe("SshTransportFactory", () => { createAgent, findCredentialsForAccount: () => [testCredential], isAdmin: () => false, - getAgentForward: async () => true, }, ); await factory.create({ - endpoint: testEndpoint, + endpoint: { ...testEndpoint, agentForward: true }, sessionId: "sess_test", trigger: { kind: "ui", sourceIp: "127.0.0.1" }, }); diff --git a/src/transport/ssh-transport-factory.ts b/src/transport/ssh-transport-factory.ts index 679a569..5a4eafc 100644 --- a/src/transport/ssh-transport-factory.ts +++ b/src/transport/ssh-transport-factory.ts @@ -43,8 +43,6 @@ export interface SshTransportFactoryOptions { createAgent: AgentFactory; findCredentialsForAccount?: CredentialsForAccountLookup; isAdmin?: AdminCheck; - /** Look up whether agent forwarding is enabled for a given account */ - getAgentForward?: (accountId: string) => Promise; } /** @@ -63,7 +61,7 @@ export class SshTransportFactory { async create(params: TransportFactoryParams): Promise { const { endpoint, sessionId, trigger } = params; - const agentForward = (await this.options.getAgentForward?.(endpoint.accountId)) ?? false; + const agentForward = endpoint.agentForward; const isAdmin = this.options.isAdmin?.(endpoint.accountId) ?? false; // Gather file keys (admin only)