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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 0 additions & 14 deletions client/src/lib/stores/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export interface AccountData {
id: string;
name: string;
isAdmin: boolean;
agentForward: boolean;
}

export const account = writable<AccountData | null>(null);
Expand Down Expand Up @@ -36,16 +35,3 @@ export async function updateAccountName(name: string): Promise<void> {
}
await fetchAccount();
}

export async function updateAgentForward(agentForward: boolean): Promise<void> {
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();
}
2 changes: 2 additions & 0 deletions client/src/lib/stores/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface Endpoint {
port: number;
username: string;
userVerification: UserVerification;
agentForward: boolean;
description: string | null;
}

Expand All @@ -35,6 +36,7 @@ export async function createEndpoint(body: {
port: number;
username: string;
userVerification?: UserVerification;
agentForward?: boolean;
description?: string | null;
}): Promise<void> {
const res = await fetch("/api/endpoints", {
Expand Down
5 changes: 5 additions & 0 deletions client/src/routes/admin/general/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
interface SeedEndpoint {
label: string;
address: string;
agentForward: boolean;
passkeyCredentialRef?: string;
}

Expand Down Expand Up @@ -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)}`);
}
Expand Down
86 changes: 85 additions & 1 deletion client/src/routes/settings/endpoints/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
let formLabel = $state("");
let formAddress = $state("");
let formUserVerification = $state<UserVerification>("required");
let formAgentForward = $state(true);
let formDescription = $state("");
let deleteTarget = $state<Endpoint | null>(null);
let deleting = $state(false);
Expand All @@ -41,6 +42,7 @@
formLabel = "";
formAddress = "";
formUserVerification = "required";
formAgentForward = true;
formDescription = "";
}

Expand All @@ -49,6 +51,7 @@
formLabel = ep.label;
formAddress = formatEndpointAddress(ep);
formUserVerification = ep.userVerification;
formAgentForward = ep.agentForward;
formDescription = ep.description ?? "";
}

Expand Down Expand Up @@ -80,6 +83,7 @@
port: parsed.port,
username: parsed.username,
userVerification: formUserVerification,
agentForward: formAgentForward,
description,
});
} else {
Expand All @@ -89,6 +93,7 @@
port: parsed.port,
username: parsed.username,
userVerification: formUserVerification,
agentForward: formAgentForward,
description,
});
}
Expand Down Expand Up @@ -134,7 +139,10 @@
<SettingsRow detail={ep.description ?? null} detailLabel="Description">
{#snippet primary()}
<span class="row-label">{ep.label}</span>
<span class="badge badge-available">{ep.userVerification}</span>
<span class="badge badge-available">UV: {ep.userVerification}</span>
<span class="badge" class:badge-available={ep.agentForward}>
forward: {ep.agentForward ? "on" : "off"}
</span>
{/snippet}
{#snippet secondary()}{formatEndpointAddress(ep)}{/snippet}
{#snippet actions()}
Expand Down Expand Up @@ -232,6 +240,28 @@
<div class="char-count">{formDescription.length} / {ENDPOINT_DESCRIPTION_MAX_LENGTH}</div>
</div>

<div class="field">
<label for="ep-agent-forward">SSH Agent Forwarding</label>
<div class="toggle-row">
<button
type="button"
id="ep-agent-forward"
class="toggle"
class:active={formAgentForward}
onclick={() => (formAgentForward = !formAgentForward)}
aria-label="SSH Agent Forwarding"
role="switch"
aria-checked={formAgentForward}
>
<span class="toggle-knob"></span>
</button>
<span class="toggle-label">{formAgentForward ? "Enabled" : "Disabled"}</span>
</div>
<span class="field-help">
Forward SSH keys to this host so onward tools (ssh, git) can authenticate.
</span>
</div>

{#snippet actions()}
<button type="button" class="btn btn-secondary" onclick={closeModal} disabled={saving}>
Cancel
Expand Down Expand Up @@ -308,6 +338,60 @@
color: var(--text-muted);
}

.field-help {
display: block;
margin-top: 0.4rem;
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.5;
}

.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 textarea {
resize: vertical;
min-height: 4rem;
Expand Down
106 changes: 1 addition & 105 deletions client/src/routes/settings/general/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@
<script lang="ts">
import { onMount } from "svelte";
import Identicon from "$lib/components/Identicon.svelte";
import {
account,
fetchAccount,
updateAccountName,
updateAgentForward,
} from "$lib/stores/account.js";
import { account, fetchAccount, updateAccountName } from "$lib/stores/account.js";
import { buildInfo } from "$lib/stores/build-info.js";

function formatBuiltAt(value: string | null): string {
Expand All @@ -19,8 +14,6 @@
let nameInput = $state("");
let saving = $state(false);
let message = $state("");
let agentForwardSaving = $state(false);
let agentForwardMessage = $state("");

onMount(async () => {
await fetchAccount();
Expand All @@ -45,20 +38,6 @@
}
saving = false;
}

async function handleAgentForwardToggle() {
if (!$account) return;
agentForwardSaving = true;
agentForwardMessage = "";
try {
await updateAgentForward(!$account.agentForward);
agentForwardMessage = "Saved";
setTimeout(() => (agentForwardMessage = ""), 2000);
} catch (err) {
agentForwardMessage = (err as Error).message;
}
agentForwardSaving = false;
}
</script>

<section>
Expand Down Expand Up @@ -87,36 +66,6 @@
{/if}
</div>

<div class="field">
<label for="agent-forward">SSH Agent Forwarding</label>
<div class="toggle-row">
<button
type="button"
id="agent-forward"
class="toggle"
class:active={$account.agentForward}
disabled={agentForwardSaving}
onclick={handleAgentForwardToggle}
aria-label="SSH Agent Forwarding"
role="switch"
aria-checked={$account.agentForward}
>
<span class="toggle-knob"></span>
</button>
<span class="toggle-label">
{$account.agentForward ? "Enabled" : "Disabled"}
</span>
</div>
<span class="field-hint">
Forward SSH keys to remote hosts so programs like ssh and git can authenticate onward.
</span>
{#if agentForwardMessage}
<span class="message" class:success={agentForwardMessage === "Saved"}
>{agentForwardMessage}</span
>
{/if}
</div>

<div class="field">
<span class="field-label">Version</span>
<div class="version-row">
Expand Down Expand Up @@ -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;
}
</style>
2 changes: 2 additions & 0 deletions config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions drizzle/0007_endpoint_agent_forward.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `endpoints` ADD `agent_forward` integer DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE `accounts` DROP COLUMN `agent_forward`;
Loading
Loading