Implement announce/watch: a deploy signal that reverses CI's reach
All checks were successful
Docker / build-and-push (push) Successful in 1m58s

CI announces a new image tag to keep (any grant is enough, read-only
included); each deploy target polls for it locally instead of CI
holding standing SSH access to production. keep stays a pure relay —
the tag lives outside the encrypted vault payload in its own table,
and keep never executes anything itself.

Adds vault_announcements, POST/GET /vaults/:key/announce, and the
`keep announce`/`keep watch` CLI commands, per
PROPOSAL-announce-and-agent.md (now marked implemented). Verified live:
a read-only identity announcing successfully and being logged, watch
picking up the tag on its first poll, and watch exiting nonzero for an
unknown/ungranted vault.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-12 21:12:55 +02:00
parent 674e408f09
commit 58d5d787dd
7 changed files with 225 additions and 11 deletions

View File

@@ -114,3 +114,35 @@ export async function vaultLog(vaultKey: string): Promise<void> {
console.log(`${new Date(e.accessed_at * 1000).toISOString()} ${e.action.padEnd(6)} ${e.recipient_id}`);
}
}
export async function vaultAnnounce(vaultKey: string, tag: string): Promise<void> {
await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId();
await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`, { tag }));
console.log(`announced '${vaultKey}' @ ${tag}`);
}
async function fetchAnnouncement(vaultKey: string): Promise<{ tag: string; announcedAt: number; announcedBy: string } | null> {
const { identity, recipientId } = await requireIdentityAndRecipientId();
const res = await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`);
if (res.status === 404) return null;
return await expectOk(res);
}
// Deliberately dumb — plain polling, not a websocket or long-poll. A
// 60s detection lag for a deploy signal is fine; don't build push-based
// notification for this.
export async function vaultWatch(vaultKey: string, intervalSeconds: number): Promise<void> {
await ready();
console.log(`watching '${vaultKey}' every ${intervalSeconds}s (ctrl-c to stop)...`);
let lastTag: string | null = null;
for (;;) {
const announcement = await fetchAnnouncement(vaultKey);
if (announcement && announcement.tag !== lastTag) {
lastTag = announcement.tag;
console.log(`${new Date(announcement.announcedAt * 1000).toISOString()} ${vaultKey} ${announcement.tag} (by ${announcement.announcedBy})`);
}
await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000));
}
}

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { identityInit, identityShow, identitySetId } from './commands/identity.js';
import { recipientAdd, recipientList, recipientRemove, adminOverview } from './commands/recipient.js';
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog } from './commands/vault.js';
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog, vaultAnnounce, vaultWatch } from './commands/vault.js';
function flag(args: string[], name: string, fallback?: string): string | undefined {
const i = args.indexOf(`--${name}`);
@@ -35,6 +35,8 @@ async function main(): Promise<void> {
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
if (cmd === 'log') return await vaultLog(sub);
if (cmd === 'announce') return await vaultAnnounce(sub, flag(rest, 'tag')!);
if (cmd === 'watch') return await vaultWatch(sub, Number(flag(rest, 'interval', '60')));
printUsage();
process.exitCode = cmd ? 1 : 0;
@@ -56,6 +58,8 @@ function printUsage(): void {
keep grant <vault> <recipient-id> [--read-only]
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
keep log <vault>
keep announce <vault> --tag <tag> (requires any grant, read-only is enough)
keep watch <vault> [--interval 60] (polls for a new announced tag; ctrl-c to stop)
keep recipient add --label <label> --pubkey <hex> (admin)
keep recipient list (admin)

View File

@@ -61,6 +61,17 @@ db.exec(`
action TEXT NOT NULL,
accessed_at INTEGER NOT NULL
);
-- Latest known deploy tag for a vault — "current state", not a
-- history, same one-row-per-vault shape as the vaults table's own
-- prev_ciphertext retention. An image tag isn't a secret, so this
-- lives outside the encrypted payload entirely.
CREATE TABLE IF NOT EXISTS vault_announcements (
vault_key TEXT PRIMARY KEY,
tag TEXT NOT NULL,
announced_at INTEGER NOT NULL,
announced_by TEXT NOT NULL
);
`);
export interface VaultRow {
@@ -88,6 +99,13 @@ export interface GrantRow {
granted_at: number;
}
export interface AnnouncementRow {
vault_key: string;
tag: string;
announced_at: number;
announced_by: string;
}
export function getVault(vaultKey: string): VaultRow | undefined {
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined;
}
@@ -225,7 +243,7 @@ export function deleteGrant(vaultKey: string, recipientId: string): void {
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId);
}
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke'): void {
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke' | 'announce'): void {
db.prepare(
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
@@ -236,3 +254,17 @@ export function getAccessLog(vaultKey: string, limit = 50): { recipient_id: stri
`SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`,
).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[];
}
// One row per vault — "latest known tag", not a history. Upsert.
export function setAnnouncement(vaultKey: string, tag: string, announcedBy: string): void {
db.prepare(
`INSERT INTO vault_announcements (vault_key, tag, announced_at, announced_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(vault_key) DO UPDATE SET
tag = excluded.tag, announced_at = excluded.announced_at, announced_by = excluded.announced_by`,
).run(vaultKey, tag, Math.floor(Date.now() / 1000), announcedBy);
}
export function getAnnouncement(vaultKey: string): AnnouncementRow | undefined {
return db.prepare(`SELECT * FROM vault_announcements WHERE vault_key = ?`).get(vaultKey) as AnnouncementRow | undefined;
}

View File

@@ -11,6 +11,8 @@ import {
logAccess,
getAccessLog,
listRecipients,
setAnnouncement,
getAnnouncement,
} from './db.js';
import { requireRecipientAuth, type AuthedRequest } from './auth.js';
@@ -179,3 +181,42 @@ router.get('/vaults/:key/log', (req: AuthedRequest, res) => {
}
res.json(getAccessLog(vaultKey));
});
// A deploy tag isn't a secret — it's not stored in the encrypted payload,
// just a small side table. Only requires ANY grant (read-only is enough)
// since announcing isn't a mutation of vault secrets — a CI identity
// should never need write access on a vault just to say "there's a new
// image". Resist reaching for hasWriteGrant here by analogy with push.
router.post('/vaults/:key/announce', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
const recipientId = req.recipientId!;
if (!hasGrant(vaultKey, recipientId)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const body = req.body as { tag?: string };
if (!body.tag) {
res.status(400).json({ error: 'tag is required' });
return;
}
setAnnouncement(vaultKey, body.tag, recipientId);
logAccess(vaultKey, recipientId, 'announce');
res.json({ ok: true });
});
router.get('/vaults/:key/announce', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
if (!hasGrant(vaultKey, req.recipientId!)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const announcement = getAnnouncement(vaultKey);
if (!announcement) {
res.status(404).json({ error: 'nothing announced yet' });
return;
}
res.json({ tag: announcement.tag, announcedAt: announcement.announced_at, announcedBy: announcement.announced_by });
});