From 16bea08721ef9b7ae2b0178a206a7658f495473d Mon Sep 17 00:00:00 2001 From: Operator & Claude Code Date: Sun, 10 May 2026 09:26:17 +0200 Subject: [PATCH] Add read-only hostd ops for update auditing (Sam & Claude) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the third leg of today's update-question arc. Sysadmin (and any tenant agent diagnosing its own jail) can now answer "any updates available?" without root by calling these hostd ops directly: - pkg-audit (with optional jail param) — runs `pkg audit -F` to fetch the vulnerability database and report CVEs against installed packages. Jail-aware via bastille cmd, mirroring the service-status pattern. - freebsd-update-status — runs `freebsd-update updatesready`. Exits 0 (none ready) or 2 (ready); no fetch, no install. The actual fetch + install lives in the daily 06:50 cron from system-update-cron. - freebsd-version — runs `freebsd-version -kru` for kernel/running/ userland visibility. Useful for the reboot-pending check that system-update already does internally. Authorization: all three are unconditionally allowed in hostd-authorization.ts alongside ping / bastille-list / pkg-version — they're diagnostic reads, never mutate state, safe for tenant-agents. Per docs/internal/SUDO_REPLACEMENT.md, this is the right shape: extend hostd's read-only surface rather than reach for mac_do/sudo replacement. --- 2259 tests passing locally (+5 covering host-default dispatch, jail dispatch, freebsd-update-status, freebsd-version, plus tenant-readable auth). Pre-existing argon2/controlplane-*/cms.test failures unchanged. --- Build: FAIL | Tests: FAIL — 16 failed --- Build: FAIL | Tests: FAIL — 16 failed --- src/hostd-authorization.test.ts | 18 ++++++++++++++ src/hostd-authorization.ts | 3 +++ src/hostd/privileged-commands.test.ts | 36 +++++++++++++++++++++++++++ src/hostd/privileged-commands.ts | 20 +++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/src/hostd-authorization.test.ts b/src/hostd-authorization.test.ts index 12471ea..568d4a6 100644 --- a/src/hostd-authorization.test.ts +++ b/src/hostd-authorization.test.ts @@ -75,6 +75,24 @@ describe('authorizeHostdOperation', () => { ).toEqual({ allowed: true, owner: 'shared-platform' }); }); + it('allows read-only update audits for tenant agents', () => { + // pkg-audit, freebsd-update-status, freebsd-version are diagnostic reads — + // tenant agents should be able to call them without operator approval. + for (const op of [ + 'pkg-audit', + 'freebsd-update-status', + 'freebsd-version', + ] as const) { + expect( + authorizeHostdOperation( + op, + {}, + { tenantId: 'alpha', caller: 'tenant-agent', registry }, + ), + ).toEqual({ allowed: true }); + } + }); + it('blocks shared jails for tenant agents', () => { expect( authorizeHostdOperation( diff --git a/src/hostd-authorization.ts b/src/hostd-authorization.ts index dcaf5ed..d8fa868 100644 --- a/src/hostd-authorization.ts +++ b/src/hostd-authorization.ts @@ -81,6 +81,9 @@ export function authorizeHostdOperation( case 'ping': case 'bastille-list': case 'pkg-version': + case 'pkg-audit': + case 'freebsd-update-status': + case 'freebsd-version': return { allowed: true }; case 'service-start': diff --git a/src/hostd/privileged-commands.test.ts b/src/hostd/privileged-commands.test.ts index c9751ec..179de40 100644 --- a/src/hostd/privileged-commands.test.ts +++ b/src/hostd/privileged-commands.test.ts @@ -466,6 +466,42 @@ describe('handleOp — spawnSync args (RC services)', () => { expect.any(Object), ); }); + + it('pkg-audit runs pkg audit -F on the host by default', () => { + handleOp('pkg-audit', {}); + expect(mockSpawnSync).toHaveBeenCalledWith( + 'pkg', + ['audit', '-F'], + expect.any(Object), + ); + }); + + it('pkg-audit dispatches via bastille cmd when jail is provided', () => { + handleOp('pkg-audit', { jail: 'cms' }); + expect(mockSpawnSync).toHaveBeenCalledWith( + 'bastille', + ['cmd', 'cms', 'pkg', 'audit', '-F'], + expect.any(Object), + ); + }); + + it('freebsd-update-status uses updatesready (no fetch, no install)', () => { + handleOp('freebsd-update-status', {}); + expect(mockSpawnSync).toHaveBeenCalledWith( + 'freebsd-update', + ['updatesready'], + expect.any(Object), + ); + }); + + it('freebsd-version reports kernel + running + userland', () => { + handleOp('freebsd-version', {}); + expect(mockSpawnSync).toHaveBeenCalledWith( + 'freebsd-version', + ['-kru'], + expect.any(Object), + ); + }); }); describe('handleOp — spawnSync args (packages + sysrc + sanoid)', () => { diff --git a/src/hostd/privileged-commands.ts b/src/hostd/privileged-commands.ts index 0d648b3..a8f9689 100644 --- a/src/hostd/privileged-commands.ts +++ b/src/hostd/privileged-commands.ts @@ -374,6 +374,26 @@ const OPS: Record = { handler: () => exec('pkg', ['version', '-vRUL=']), }, + // ── Read-only update audits ────────────────────────────────────────────── + // These let sysadmin (and any tenant agent diagnosing its own jail) answer + // "any updates available?" without root. None of them mutate state. + 'pkg-audit': { + schema: z.object({ jail: jailName.optional() }), + handler: (p) => p.jail + ? exec('bastille', ['cmd', String(p.jail), 'pkg', 'audit', '-F']) + : exec('pkg', ['audit', '-F']), + }, + 'freebsd-update-status': { + // updatesready exits 0 (none ready) or 2 (ready) — no fetching, no + // installing. The daily cron at 06:50 handles actual fetch+install. + schema: z.object({}), + handler: () => exec('freebsd-update', ['updatesready']), + }, + 'freebsd-version': { + schema: z.object({}), + handler: () => exec('freebsd-version', ['-kru']), + }, + 'pkg-upgrade': { schema: z.object({ repo: pkgRepo, dryRun: z.boolean().optional() }), handler: (p) => {