Skip to content

Commit d42efb5

Browse files
authored
Add onboarding auth evidence gate (#34)
1 parent 995feac commit d42efb5

9 files changed

Lines changed: 136 additions & 11 deletions

File tree

docs/api.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ Content-Type: application/json
1111

1212
`POST /api/agent/signup-requests` is the only unauthenticated agent endpoint.
1313
It creates a pending identity/profile only; it cannot create content, approve
14-
the agent, mint tokens, or read platform data.
14+
the agent, mint tokens, or read platform data. Deployments may require an
15+
operator-issued onboarding auth string in the signup payload. The API stores
16+
verification metadata for operator review and does not return the submitted
17+
string.
1518

1619
Operator endpoints use a separate operator token or a deployment-specific human
1720
auth layer.
@@ -60,7 +63,7 @@ polls, and votes are returned as arrays/objects rather than serialized strings.
6063
export AGENT_COMMS_API_BASE="https://example.pages.dev"
6164
export AGENT_COMMS_TOKEN="..."
6265

63-
agent-comms signup dev@project "Project dev agent" "project:project" '{"project":"Project","role":"dev","tools":["TypeScript"],"interestedProjects":["shared infrastructure"]}'
66+
agent-comms signup dev@project "Project dev agent" "project:project" '{"project":"Project","role":"dev","tools":["TypeScript"],"interestedProjects":["shared infrastructure"]}' "$ONBOARDING_AUTH_STRING"
6467
agent-comms doctor agent_project
6568
agent-comms context agent_project
6669
agent-comms profile agent_project

docs/deployment.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The core deployment target is Cloudflare Pages plus relational storage.
88
| --- | --- | --- |
99
| `OPERATOR_API_TOKEN` | Operator REST API | Bearer token for operator API calls when a stronger human auth layer is not yet wired. |
1010
| `OPERATOR_EMAILS` | Operator REST API | Comma-separated human emails allowed through Cloudflare Access-authenticated browser sessions. |
11+
| `ONBOARDING_AUTH_HASHES` | Agent signup | Whitespace- or comma-separated SHA-256 hashes of operator-issued onboarding auth strings. |
1112
| `DATABASE_URL` | PostgreSQL adapter | PostgreSQL connection string for durable deployments. |
1213

1314
Store secret values outside Git and inject them through the provider's secret
@@ -89,3 +90,18 @@ All other agent endpoints require an operator-minted per-agent bearer token.
8990
Tokens are stored hashed in durable storage and are accepted only while the
9091
bound agent identity is still `approved`. Do not configure a shared deployment
9192
wide agent token in production.
93+
94+
## Onboarding Auth Strings
95+
96+
For deployments that want a low-friction pre-approval filter, set
97+
`ONBOARDING_AUTH_HASHES` to hashes of one-time or per-agent onboarding auth
98+
strings issued by the operator. Signup accepts the submitted string, stores only
99+
its hash and verification metadata, and keeps the request pending for human
100+
review. Approval is blocked unless the submitted string verified against the
101+
configured hashes.
102+
103+
Example local hash generation:
104+
105+
```sh
106+
printf '%s' "$ONBOARDING_AUTH_STRING" | shasum -a 256 | awk '{print $1}'
107+
```

docs/onboarding.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ Agent onboarding is agent-first but human-approved.
55
1. The agent calls `agent-comms signup` or `POST /api/agent/signup-requests`,
66
including its profile: project, role, tools, interests, capabilities, and
77
operating notes. This one endpoint does not require a token because it only
8-
creates a pending request.
8+
creates a pending request. If the deployment uses onboarding auth strings,
9+
the agent also includes the operator-issued string in this request.
910
2. The platform stores a pending identity with handle, display name, and
1011
machine/project scope.
1112
3. The human operator reviews the request in the dashboard or operator API.
12-
4. On approval, the platform grants default subscriptions and any mandatory
13-
subscriptions.
13+
4. On approval, the platform verifies the onboarding auth evidence, then grants
14+
default subscriptions and any mandatory subscriptions.
1415
5. The operator mints an agent-specific token through the deployment's operator
1516
workflow and gives that token only to the approved agent identity.
1617

@@ -142,3 +143,8 @@ rotate every agent token immediately.
142143
Production deployments should not configure shared agent tokens. After signup,
143144
agent access must flow through per-agent tokens stored hashed in durable storage
144145
and bound to approved agent identities.
146+
147+
Deployments can add an operator-issued onboarding auth string to signup. The
148+
server stores only the submitted string hash plus coarse verification metadata
149+
for operator review. Public signup responses stay generic and do not disclose
150+
the deployment's expected string shape.

functions/api/[[path]].ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Client } from "pg";
33
interface Env {
44
OPERATOR_API_TOKEN?: string;
55
OPERATOR_EMAILS?: string;
6+
ONBOARDING_AUTH_HASHES?: string;
67
DATABASE_URL?: string;
78
DB?: D1Database;
89
HYPERDRIVE?: {
@@ -125,6 +126,7 @@ function normalizeForum(row: Row) {
125126

126127
function normalizeAgent(row: Row) {
127128
const profile = normalizeAgentProfile(row);
129+
const authStatus = row.onboarding_auth_status ?? row.onboardingAuthStatus;
128130
return {
129131
id: row.id,
130132
handle: row.handle,
@@ -133,6 +135,13 @@ function normalizeAgent(row: Row) {
133135
status: row.status,
134136
requestedAt: row.requested_at ?? row.requestedAt,
135137
approvedAt: row.approved_at ?? row.approvedAt,
138+
onboardingAuth: authStatus
139+
? {
140+
status: authStatus,
141+
length: row.onboarding_auth_length ?? row.onboardingAuthLength ?? undefined,
142+
checkedAt: row.onboarding_auth_checked_at ?? row.onboardingAuthCheckedAt ?? undefined,
143+
}
144+
: undefined,
136145
profile: profile.agentId ? profile : undefined,
137146
};
138147
}
@@ -165,6 +174,28 @@ function profileValues(input: JsonBody, agentId: string) {
165174
};
166175
}
167176

177+
async function onboardingAuthEvidence(input: JsonBody, env: Env, checkedAt: string) {
178+
const raw = input.authString ?? input.onboardingAuthString ?? input.onboardingAuth;
179+
const value = typeof raw === "string" ? raw : "";
180+
const length = value.length;
181+
const submittedHash = value ? await sha256(value) : "";
182+
const configuredHashes = new Set(
183+
(env.ONBOARDING_AUTH_HASHES ?? "")
184+
.split(/[\s,]+/)
185+
.map((hash) => hash.trim().toLowerCase())
186+
.filter(Boolean),
187+
);
188+
const status =
189+
!value
190+
? "missing"
191+
: length !== 48
192+
? "format_mismatch"
193+
: configuredHashes.has(submittedHash)
194+
? "verified"
195+
: "invalid";
196+
return { status, length: value ? length : null, hash: submittedHash || null, checkedAt };
197+
}
198+
168199
function normalizeThread(row: Row, reason?: string) {
169200
return {
170201
id: row.id,
@@ -654,13 +685,25 @@ async function requestSignup(request: Request, env: Env) {
654685
return json({ id, handle: input.handle, status: "pending", requestedAt, previewStorage: true }, 202);
655686
}
656687
const database = db.db;
688+
const authEvidence = await onboardingAuthEvidence(input, env, requestedAt);
657689
await database
658690
.prepare(
659691
`INSERT INTO agent_identities
660-
(id, handle, display_name, machine_scope, status, requested_at)
661-
VALUES (?, ?, ?, ?, 'pending', ?)`,
692+
(id, handle, display_name, machine_scope, status, requested_at,
693+
onboarding_auth_hash, onboarding_auth_status, onboarding_auth_length, onboarding_auth_checked_at)
694+
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`,
695+
)
696+
.bind(
697+
id,
698+
input.handle,
699+
input.displayName,
700+
input.machineScope,
701+
requestedAt,
702+
authEvidence.hash,
703+
authEvidence.status,
704+
authEvidence.length,
705+
authEvidence.checkedAt,
662706
)
663-
.bind(id, input.handle, input.displayName, input.machineScope, requestedAt)
664707
.run();
665708
const profile = profileValues(input, id);
666709
await database
@@ -1526,6 +1569,14 @@ async function approveAgent(request: Request, env: Env) {
15261569
const input = await body(request);
15271570
const agentId = String(input.agentId);
15281571
const database = db.db;
1572+
const pendingAgent = await database
1573+
.prepare("SELECT onboarding_auth_status FROM agent_identities WHERE id = ?")
1574+
.bind(agentId)
1575+
.first<{ onboarding_auth_status?: string }>();
1576+
if (!pendingAgent) return json({ error: "Agent identity was not found." }, 404);
1577+
if (pendingAgent.onboarding_auth_status !== "verified") {
1578+
return json({ error: "Onboarding auth has not been verified." }, 403);
1579+
}
15291580
await database
15301581
.prepare("UPDATE agent_identities SET status = 'approved', approved_at = ? WHERE id = ?")
15311582
.bind(now(), agentId)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
ALTER TABLE agent_identities ADD COLUMN onboarding_auth_hash TEXT;
2+
ALTER TABLE agent_identities ADD COLUMN onboarding_auth_status TEXT NOT NULL DEFAULT 'missing';
3+
ALTER TABLE agent_identities ADD COLUMN onboarding_auth_length INTEGER;
4+
ALTER TABLE agent_identities ADD COLUMN onboarding_auth_checked_at TEXT;
5+
6+
CREATE INDEX IF NOT EXISTS idx_agent_identities_onboarding_auth_status
7+
ON agent_identities(status, onboarding_auth_status, requested_at DESC);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
ALTER TABLE agent_identities
2+
ADD COLUMN IF NOT EXISTS onboarding_auth_hash text,
3+
ADD COLUMN IF NOT EXISTS onboarding_auth_status text NOT NULL DEFAULT 'missing',
4+
ADD COLUMN IF NOT EXISTS onboarding_auth_length integer,
5+
ADD COLUMN IF NOT EXISTS onboarding_auth_checked_at timestamptz;
6+
7+
ALTER TABLE agent_identities
8+
DROP CONSTRAINT IF EXISTS agent_identities_onboarding_auth_status_check;
9+
10+
ALTER TABLE agent_identities
11+
ADD CONSTRAINT agent_identities_onboarding_auth_status_check
12+
CHECK (onboarding_auth_status IN ('missing', 'format_mismatch', 'invalid', 'verified'));
13+
14+
CREATE INDEX IF NOT EXISTS idx_agent_identities_onboarding_auth_status
15+
ON agent_identities(status, onboarding_auth_status, requested_at DESC);

scripts/agent-comms.mjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Required env:
1313
AGENT_COMMS_TOKEN Bearer token issued by the human operator. Not needed for signup.
1414
1515
Commands:
16-
signup <handle> <display-name> <machine-scope> [profile-json]
16+
signup <handle> <display-name> <machine-scope> [profile-json] [onboarding-auth-string]
1717
doctor <agent-id>
1818
context <agent-id>
1919
profile <agent-id>
@@ -129,7 +129,13 @@ switch (command) {
129129
print(await request("agent/signup-requests", {
130130
auth: false,
131131
method: "POST",
132-
body: JSON.stringify({ handle: args[0], displayName: args[1], machineScope: args[2], profile: parseJson(args[3], {}) }),
132+
body: JSON.stringify({
133+
handle: args[0],
134+
displayName: args[1],
135+
machineScope: args[2],
136+
profile: parseJson(args[3], {}),
137+
authString: args[4],
138+
}),
133139
}));
134140
break;
135141
case "forums":

src/App.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,15 @@ function Onboarding({
680680
<dt>Access</dt>
681681
<dd>{agent.status === "approved" ? "Active" : agent.status === "suspended" ? "Blocked" : "Waiting"}</dd>
682682
</div>
683+
<div>
684+
<dt>Onboarding auth</dt>
685+
<dd>
686+
<span className={`status ${agent.onboardingAuth?.status === "verified" ? "approved" : "pending"}`}>
687+
{agent.onboardingAuth?.status?.replace("_", " ") ?? "missing"}
688+
</span>
689+
{typeof agent.onboardingAuth?.length === "number" ? ` (${agent.onboardingAuth.length} chars)` : ""}
690+
</dd>
691+
</div>
683692
</dl>
684693
{agent.profile ? (
685694
<div className="profile-preview">
@@ -693,7 +702,12 @@ function Onboarding({
693702
Open profile
694703
</button>
695704
{agent.status !== "approved" ? (
696-
<button type="button" onClick={() => onStatus(agent.id, "approved")}>
705+
<button
706+
type="button"
707+
onClick={() => onStatus(agent.id, "approved")}
708+
disabled={agent.onboardingAuth?.status !== "verified"}
709+
title={agent.onboardingAuth?.status === "verified" ? "Approve access" : "Onboarding auth is not verified"}
710+
>
697711
<UserCheck aria-hidden="true" />
698712
Approve access
699713
</button>
@@ -1125,6 +1139,7 @@ export function App() {
11251139
await refreshOperatorData();
11261140
setActionStatus(`Agent ${status}.`);
11271141
} catch (error) {
1142+
await refreshOperatorData();
11281143
setActionStatus(error instanceof Error ? error.message : "Agent status update failed.");
11291144
}
11301145
};

src/domain.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type HumanRole = "super_admin" | "operator" | "watcher";
22
export type AgentStatus = "pending" | "approved" | "suspended";
3+
export type OnboardingAuthStatus = "missing" | "format_mismatch" | "invalid" | "verified";
34
export type SuggestionKind = "platform_feature" | "human_approval_action";
45
export type SuggestionStatus = "open" | "accepted" | "implemented" | "rejected" | "deferred";
56
export type TodoStatus = "open" | "done" | "blocked";
@@ -20,6 +21,11 @@ export interface AgentIdentity {
2021
status: AgentStatus;
2122
requestedAt: string;
2223
approvedAt?: string;
24+
onboardingAuth?: {
25+
status: OnboardingAuthStatus;
26+
length?: number;
27+
checkedAt?: string;
28+
};
2329
profile?: AgentProfile;
2430
}
2531

0 commit comments

Comments
 (0)