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
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ woff2, strict CSP, no runtime CDN) for air-gap use.

- `src/main.tsx` - entry; mounts the theme + auth providers and the router.
- `src/App.tsx` - route table (Fleet `/`, Nodes `/nodes`, node detail `/nodes/:name`, Operators,
Agent keys `/agent-keys`, Audit, 404). The MCP sign-in consent page `/oauth/consent` is routed
Agent keys `/agent-keys`, Approvals `/approvals` (accepts `?id=<approval>` from the manager's
approval link), Audit, 404). The MCP sign-in consent page `/oauth/consent` is routed
outside the auth gate on purpose: the manager authenticates that request with the browser's
client certificate, and it talks to `/oauth2/consent/<id>` with plain `fetch`, not Connect.
- `src/components/layout/` - app shell: header, sidebar nav, wordmark, theme toggle, auth gate.
Expand Down Expand Up @@ -49,3 +50,7 @@ woff2, strict CSP, no runtime CDN) for air-gap use.
`errorCode()` from `src/lib/fleet/error-code.ts`, never on the message text, and add a code to
its `ErrorCode` map when a page handles one. The Operators page does this for 1400 (no
`operator_ca_node` configured) and shows a not-configured view instead of the refusal.
- The Approvals nav badge counts pending approvals through `listApprovals`. It recounts on every
navigation, every 30 seconds, and when `decideApproval` dispatches the `APPROVALS_CHANGED`
window event (`src/lib/approvals.ts`). A new surface that changes an approval should go through
`decideApproval` so the badge stays current.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The Fleet Manager serves an MCP endpoint so AI agents can manage the fleet. An a

- 🔑 **Sign-in consent** at `/oauth/consent?req=<id>`. An MCP client's OAuth sign-in lands here in the operator's browser. The page shows the client's name, the loopback host the code returns to, and the operator certificate that will own the key, and lets the operator pick a level ceiling (up to their own level) and a label before choosing Approve or Deny. It sits outside the console's Log in step, because the manager authenticates the request with the certificate the browser presents. It talks to the manager's `/oauth2/consent/<id>` endpoint as plain JSON on the same origin.
- 🗝️ **Agent keys** (next to Operators) lists your keys with their client, ceiling, creation time, last use and status, and lets you revoke one. An admin can switch to every operator's keys. **Create key** mints a key for a client that cannot open the browser sign-in. The key is shown once, with a copy button, and is discarded when the dialog closes; the manager keeps only a hash of it.
- ✋ **Approvals** (next to Agent keys, with a count of pending requests) is where a person decides the step-up requests an agent raises before a tool that changes the fleet runs. It lists pending requests by default, and a status filter switches to approved, denied, expired, used or all. Each row shows the tool, a summary, who asked (CN), the agent key, the required level, when it was created and expires, and its status. **Approve** and **Deny** each ask for confirmation, repeating the approval ID and summary so they can be matched against what the agent showed you. The request digest (SHA-256 of the call's arguments) is shown as a detail; the agent never receives it, and the audit log records it. The buttons are disabled unless the request is pending and your level is at least the one it requires; the manager enforces the same rule, and an agent key can never list or decide an approval. The manager's approval link opens `/approvals?id=<id>`, which shows every status and highlights that request.
- 📜 **Audit** shows who acted (the certificate CN, and whether it acted directly or through an agent key), the surface it came through (web, mcp or api), the MCP tool and the outcome. Entries recorded before the manager captured an actor show a dash.

A key is bound to the operator certificate that created it. It stops working when that certificate is revoked or renewed, and its effective level is the certificate's live level or its ceiling, whichever is lower.
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AppShell } from "@/components/layout/app-shell";
import { AuthGate } from "@/components/layout/auth-gate";
import { AdoptPage } from "@/pages/adopt";
import { AgentKeysPage } from "@/pages/agent-keys";
import { ApprovalsPage } from "@/pages/approvals";
import { AuditPage } from "@/pages/audit";
import { CertificatesPage } from "@/pages/certificates";
import { DashboardPage } from "@/pages/dashboard";
Expand Down Expand Up @@ -71,6 +72,7 @@ export const App = () => {
<Route element={<AuditPage />} path="audit" />
<Route element={<OperatorsPage />} path="operators" />
<Route element={<AgentKeysPage />} path="agent-keys" />
<Route element={<ApprovalsPage />} path="approvals" />
<Route element={<AdoptPage />} path="adopt" />
<Route element={<CertificatesPage />} path="certificates" />
<Route element={<ProfilesPage />} path="profiles" />
Expand Down
106 changes: 106 additions & 0 deletions src/components/approval-decide-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
Copyright The CryptOS Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { ApprovalDecideDialog } from "@/components/approval-decide-dialog";

const decideApproval = vi.fn();
vi.mock("@/lib/approvals", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/approvals")>()),
decideApproval: (...a: unknown[]) => decideApproval(...a),
}));

const digest = "0123456789abcdef".repeat(4);
const target = {
id: "ap-1",
requestDigest: digest,
requestedByCn: "operator@example.org",
requiredLevel: "operator",
summary: "Revoke certificate 0a1b on issuing-1",
tool: "cert_revoke",
};

describe("ApprovalDecideDialog", () => {
beforeEach(() => decideApproval.mockReset());

it("repeats the approval ID and summary to check, and the digest as an audit detail", async () => {
decideApproval.mockResolvedValue({ ...target, status: "approved" });
const onDecided = vi.fn();
const onClose = vi.fn();
render(
<ApprovalDecideDialog approve onClose={onClose} onDecided={onDecided} target={target} />,
);

const dialog = screen.getByRole("dialog");
expect(dialog).toHaveTextContent("ap-1");
expect(dialog).toHaveTextContent("Revoke certificate 0a1b on issuing-1");
expect(dialog).toHaveTextContent(
"Check that the approval ID and summary match what the agent showed you.",
);
expect(dialog).toHaveTextContent(digest);
expect(dialog).toHaveTextContent("Recorded in the audit log");
expect(dialog).not.toHaveTextContent(/digest match/i);
expect(decideApproval).not.toHaveBeenCalled();

fireEvent.click(screen.getByRole("button", { name: /^approve$/i }));

await waitFor(() => expect(onDecided).toHaveBeenCalled());
expect(decideApproval).toHaveBeenCalledWith({ approve: true, id: "ap-1" });
expect(onClose).toHaveBeenCalled();
});

it("denies through the same confirmation", async () => {
decideApproval.mockResolvedValue({ ...target, status: "denied" });
const onDecided = vi.fn();
render(
<ApprovalDecideDialog
approve={false}
onClose={vi.fn()}
onDecided={onDecided}
target={target}
/>,
);

expect(screen.getByRole("dialog")).toHaveTextContent(digest);
fireEvent.click(screen.getByRole("button", { name: /^deny$/i }));

await waitFor(() => expect(onDecided).toHaveBeenCalled());
expect(decideApproval).toHaveBeenCalledWith({ approve: false, id: "ap-1" });
});

it("cancels without deciding", () => {
const onClose = vi.fn();
render(<ApprovalDecideDialog approve onClose={onClose} onDecided={vi.fn()} target={target} />);

fireEvent.click(screen.getByRole("button", { name: /cancel/i }));

expect(onClose).toHaveBeenCalled();
expect(decideApproval).not.toHaveBeenCalled();
});

it("shows a refusal inline and stays open", async () => {
decideApproval.mockRejectedValueOnce(new Error("approval expired"));
const onClose = vi.fn();
render(<ApprovalDecideDialog approve onClose={onClose} onDecided={vi.fn()} target={target} />);

fireEvent.click(screen.getByRole("button", { name: /^approve$/i }));

expect(await screen.findByRole("alert")).toHaveTextContent("approval expired");
expect(onClose).not.toHaveBeenCalled();
});
});
125 changes: 125 additions & 0 deletions src/components/approval-decide-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
Copyright The CryptOS Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { useState } from "react";

import { Button } from "@/components/ui/button";
import { type ApprovalRow, decideApproval } from "@/lib/approvals";

// ApprovalDecideDialog confirms an approve or deny. It repeats the approval ID
// and summary, which the manager's pending_approval response also gives the
// agent, so the operator can match them against what the agent showed before
// letting the action through. The request digest is shown as a detail only: the
// agent never receives it, and the audit log records it with the decision.
export const ApprovalDecideDialog = ({
approve,
onClose,
onDecided,
target,
}: {
approve: boolean;
onClose: () => void;
onDecided: () => void;
target: Pick<
ApprovalRow,
"id" | "requestDigest" | "requestedByCn" | "requiredLevel" | "summary" | "tool"
>;
}) => {
const [pending, setPending] = useState(false);
const [error, setError] = useState<null | string>(null);

const verb = approve ? "Approve" : "Deny";

const confirm = () => {
setPending(true);
setError(null);
decideApproval({ approve, id: target.id })
.then(() => {
onDecided();
onClose();
})
.catch((error_: unknown) => {
setError(error_ instanceof Error ? error_.message : `${verb} failed`);
setPending(false);
});
};

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
role="presentation"
>
<div
aria-labelledby="approval-decide-title"
aria-modal="true"
className="w-full max-w-lg space-y-4 rounded-xl border bg-card p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
>
<div className="space-y-1">
<h2 className="text-lg font-bold" id="approval-decide-title">
{verb} request
</h2>
<p className="font-mono text-xs text-muted-foreground">
{target.tool} &middot; {target.requestedByCn} &middot; needs {target.requiredLevel}
</p>
</div>
<div className="space-y-1">
<p className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
Approval ID
</p>
<p className="break-all font-mono text-sm">{target.id}</p>
</div>
<p className="text-sm">{target.summary}</p>
<div className="space-y-1">
<p className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
Request digest (SHA-256)
</p>
<p className="break-all rounded-md border bg-secondary/50 p-2 font-mono text-xs">
{target.requestDigest}
</p>
<p className="text-xs text-muted-foreground">
Recorded in the audit log with the decision and the agent&apos;s call.
</p>
</div>
<p className="text-sm text-muted-foreground">
{approve
? "The agent can run this exact request once. Check that the approval ID and summary match what the agent showed you."
: "The agent's request is refused and cannot be retried with this approval."}
</p>
{error ? (
<p className="font-mono text-xs text-destructive" role="alert">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button disabled={pending} onClick={onClose} size="sm" variant="outline">
Cancel
</Button>
<Button
disabled={pending}
onClick={confirm}
size="sm"
variant={approve ? "default" : "destructive"}
>
{pending ? (approve ? "Approving…" : "Denying…") : verb}
</Button>
</div>
</div>
</div>
);
};
67 changes: 65 additions & 2 deletions src/components/layout/top-nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { TopNav } from "@/components/layout/top-nav";
import { APPROVALS_CHANGED } from "@/lib/approvals";

const listApprovals = vi.fn();
vi.mock("@/lib/approvals", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/approvals")>()),
listApprovals: (...a: unknown[]) => listApprovals(...a),
}));

beforeEach(() => {
listApprovals.mockReset();
listApprovals.mockResolvedValue([]);
});

describe("TopNav", () => {
// Root before Nodes: that is the order the hierarchy reads, and /nodes
Expand Down Expand Up @@ -69,4 +81,55 @@ describe("TopNav", () => {
const hrefs = screen.getAllByRole("link").map((a) => a.getAttribute("href"));
expect(hrefs.indexOf("/agent-keys")).toBe(hrefs.indexOf("/operators") + 1);
});

it("places Approvals right after Agent keys", () => {
render(
<MemoryRouter>
<TopNav />
</MemoryRouter>,
);
const hrefs = screen.getAllByRole("link").map((a) => a.getAttribute("href"));
expect(hrefs.indexOf("/approvals")).toBe(hrefs.indexOf("/agent-keys") + 1);
});

it("badges Approvals with the pending count", async () => {
listApprovals.mockResolvedValue([{ id: "ap-1" }, { id: "ap-2" }]);
render(
<MemoryRouter>
<TopNav />
</MemoryRouter>,
);

expect(await screen.findByLabelText("2 pending approvals")).toHaveTextContent("2");
expect(listApprovals).toHaveBeenCalledWith({ status: "pending" });
});

it("shows no badge when nothing is pending or the count cannot be read", async () => {
listApprovals.mockRejectedValue(new Error("manager unreachable"));
render(
<MemoryRouter>
<TopNav />
</MemoryRouter>,
);
await act(async () => {});

expect(screen.queryByLabelText(/pending approvals/)).not.toBeInTheDocument();
});

it("recounts after a decision", async () => {
listApprovals.mockResolvedValue([{ id: "ap-1" }, { id: "ap-2" }]);
render(
<MemoryRouter>
<TopNav />
</MemoryRouter>,
);
await screen.findByLabelText("2 pending approvals");

listApprovals.mockResolvedValue([{ id: "ap-2" }]);
act(() => {
globalThis.dispatchEvent(new Event(APPROVALS_CHANGED));
});

expect(await screen.findByLabelText("1 pending approval")).toHaveTextContent("1");
});
});
Loading
Loading