From 786c9885f2f4d08c5ca70dbe2f623b1d2a999612 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 5 Oct 2026 07:40:53 -0300 Subject: [PATCH 1/4] feat(agent-config): three-state field access over reporting instances Layer 4 of 21 in the stacked split of compliance-framework/ui#318. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/field-access.spec.ts | 350 +++++++++++++ src/utils/agent-config/field-access.ts | 488 ++++++++++++++++++ 2 files changed, 838 insertions(+) create mode 100644 src/utils/agent-config/__tests__/field-access.spec.ts create mode 100644 src/utils/agent-config/field-access.ts diff --git a/src/utils/agent-config/__tests__/field-access.spec.ts b/src/utils/agent-config/__tests__/field-access.spec.ts new file mode 100644 index 00000000..9db217e0 --- /dev/null +++ b/src/utils/agent-config/__tests__/field-access.spec.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentInstanceSummary } from '@/types/agent-config'; +import { + accessPopulation, + accessTooltip, + addPluginAccess, + addPluginTooltip, + classifySource, + fieldAccess, + installVerdict, + instanceFieldVerdict, + isForbiddenPointer, + isOciSource, + sourceKind, +} from '../field-access'; + +function inst( + over: Partial & { instanceId: string }, +): AgentInstanceSummary { + return { + hostname: null, + agentVersion: null, + mode: 'apply_safe', + firstSeenAt: '', + lastSeenAt: '', + reportedAt: '2026-10-01T00:00:00Z', + stale: false, + appliedRevision: 1, + attemptedRevision: 1, + status: 'applied', + reason: null, + error: null, + syncStatus: 'in-sync', + effectiveDigest: null, + heartbeatConfigRevision: 1, + reportStale: false, + unsafe: [], + ...over, + }; +} + +const safe = (id: string, rc: AgentInstanceSummary['remoteConfig'] = {}) => + inst({ instanceId: id, hostname: id, remoteConfig: { ...rc } }); +const all = (id: string) => + inst({ instanceId: id, hostname: id, mode: 'apply_all', remoteConfig: {} }); +const report = (id: string) => + inst({ + instanceId: id, + hostname: id, + mode: 'report', + remoteConfig: { mode: 'report' }, + }); + +describe('field access (R71)', () => { + it('forbids api.*, daemon and remote_config.* only, whatever the instances', () => { + expect(isForbiddenPointer('/api/url')).toBe(true); + expect(isForbiddenPointer('/daemon')).toBe(true); + expect(isForbiddenPointer('/remote_config/mode')).toBe(true); + expect(isForbiddenPointer('/verbosity')).toBe(false); + expect(fieldAccess('/api/auth/client_id', []).state).toBe('forbidden'); + expect(fieldAccess('/daemon', [all('a')]).state).toBe('forbidden'); + }); + + it('zero reporting instances: editable without restrictions', () => { + const a = fieldAccess('/plugins/ssh/config/user', []); + expect(a).toEqual({ + state: 'editable', + restrictions: [], + total: 0, + applying: 0, + }); + // Stale and never-reported instances are not counted either. + const ignored = [ + inst({ instanceId: 's', stale: true, mode: 'report' }), + inst({ instanceId: 'n', mode: '', reportedAt: null, remoteConfig: null }), + ]; + expect(accessPopulation(ignored)).toEqual([]); + expect(fieldAccess('/verbosity', ignored).state).toBe('editable'); + }); + + it('all / some / none for a config key (overridable_config_flags)', () => { + const flagged = safe('host-a', { overridable_config_flags: ['ssh:port'] }); + const bare = safe('host-b', { overridable_config_flags: [] }); + const ptr = '/plugins/ssh/config/port'; + + const allOf = fieldAccess(ptr, [flagged, all('host-c')]); + expect(allOf.state).toBe('editable'); + expect(allOf.applying).toBe(2); + + const some = fieldAccess(ptr, [flagged, bare, all('host-c')]); + expect(some.state).toBe('restricted'); + expect(some.restrictions).toEqual([ + { + instance: 'host-b', + reason: + 'apply_safe without an overridable_config_flags entry for ssh:port', + }, + ]); + expect(accessTooltip(some)).toBe( + 'May not apply on 1 of 3 reporting instances — apply_safe without an overridable_config_flags entry for ssh:port: host-b', + ); + + const none = fieldAccess('/plugins/ssh/config/user', [flagged, bare]); + expect(none.state).toBe('readonly'); + expect(accessTooltip(none)).toBe( + 'Read-only: no reporting instance would apply a change here — apply_safe without an overridable_config_flags entry for ssh:user: host-a, host-b', + ); + }); + + it('unscoped and globbed flags follow MatchOverridableConfigFlag', () => { + const i = safe('h', { overridable_config_flags: ['time*', 'ss?:user'] }); + expect(instanceFieldVerdict('/plugins/any/config/timeout', i).verdict).toBe( + 'yes', + ); + expect(instanceFieldVerdict('/plugins/ssh/config/user', i).verdict).toBe( + 'yes', + ); + expect(instanceFieldVerdict('/plugins/sshd/config/user', i).verdict).toBe( + 'no', + ); + // The whole map: some flag must target the plugin. + expect(instanceFieldVerdict('/plugins/sshd/config', i).verdict).toBe('yes'); + const scoped = safe('h2', { overridable_config_flags: ['ssh:port'] }); + expect(instanceFieldVerdict('/plugins/other/config', scoped).verdict).toBe( + 'no', + ); + }); + + it('report and off modes never apply: a report-only fleet is read-only', () => { + const fleet = [report('r1'), report('r2')]; + for (const ptr of [ + '/verbosity', + '/plugins/ssh/schedule', + '/plugins/ssh/policy_data/max', + ]) { + const a = fieldAccess(ptr, fleet); + expect(a.state).toBe('readonly'); + expect(accessTooltip(a)).toContain('report-only mode'); + expect(accessTooltip(a)).toContain('r1, r2'); + } + const off = inst({ instanceId: 'o', mode: 'off', remoteConfig: {} }); + expect(instanceFieldVerdict('/verbosity', off)).toEqual({ + verdict: 'no', + reason: 'remote configuration is off (mode: off)', + }); + // One report-only instance next to an applying one: restricted. + expect(fieldAccess('/verbosity', [report('r'), all('a')]).state).toBe( + 'restricted', + ); + }); + + it('data-only and logging fields apply on every apply mode', () => { + const fleet = [safe('a'), all('b')]; + for (const ptr of [ + '/verbosity', + '/agent_evidence/interval', + '/plugins/ssh', + '/plugins/ssh/schedule', + '/plugins/ssh/enabled', + '/plugins/ssh/labels/team', + '/plugins/ssh/policy_data', + '/plugins/ssh/policy_data/nested/deep', + '/plugins/ssh/policy_behavior', + ]) { + expect([ptr, fieldAccess(ptr, fleet).state]).toEqual([ptr, 'editable']); + } + }); + + it('sources and policies: apply_safe with / without trusted_sources', () => { + const trusted = safe('t', { trusted_sources: ['ghcr.io/org/*'] }); + const untrusted = safe('u', { trusted_sources: [] }); + expect(fieldAccess('/plugins/ssh/source', [trusted]).state).toBe( + 'editable', + ); + expect(fieldAccess('/plugins/ssh/source', [untrusted]).state).toBe( + 'readonly', + ); + expect( + fieldAccess('/plugins/ssh/source', [untrusted, all('x')]).state, + ).toBe('restricted'); + // Removing / reordering policy entries is Safe (reduces-scope): never read-only. + const pol = fieldAccess('/plugins/ssh/policies', [untrusted]); + expect(pol.state).toBe('restricted'); + expect(pol.restrictions[0]).toMatchObject({ instance: 'u', partial: true }); + expect(accessTooltip(pol)).toContain('only removing or reordering'); + expect(fieldAccess('/plugins/ssh/policies', [trusted]).state).toBe( + 'editable', + ); + }); + + it('labels instances by hostname, else the short id, and caps the host list', () => { + const many = Array.from({ length: 7 }, (_, n) => + inst({ + instanceId: `abcdefgh-${n}`, + hostname: n === 0 ? null : `h${n}`, + mode: 'report', + }), + ); + const text = accessTooltip(fieldAccess('/verbosity', many)); + expect(text).toContain('abcdefgh, h1, h2, h3, h4 and 2 more'); + }); +}); + +describe('sources (sources.go KindOf, classify.go sourceClass)', () => { + it('isOciSource mirrors the API strict tag validation', () => { + const cases: [string, boolean][] = [ + ['ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0', true], + ['ghcr.io/compliance-framework/plugin-local-ssh-policies:latest', true], + ['docker.io/library/alpine:3.20', true], + ['localhost:5000/plugin:v1', true], + ['registry.example.com:5000/a/b/c:1.2.3', true], + ['ghcr.io/x/y', false], // an explicit tag is required + ['ghcr.io/X/Y:v1', false], + ['ghcr.io/x/y:', false], + ['docker.io/alpine:3', false], // implicit library/ namespace + ['./plugins/foo', false], + ['/opt/plugin', false], + ['plugin', false], + ['', false], + ['inline:ssh', false], + ]; + for (const [s, oci] of cases) { + expect([s, isOciSource(s)]).toEqual([s, oci]); + expect(sourceKind(s)).toBe(oci ? 'oci' : 'local'); + } + }); + + it('classifySource: already used, local, trusted, untrusted', () => { + const used = new Set(['/opt/used', 'docker.io/acme/used:v1']); + const s = safe('s', { + trusted_sources: ['ghcr.io/org/*'], + allow_local_sources: true, + }); + const a = inst({ + instanceId: 'a', + mode: 'apply_all', + remoteConfig: { allow_local_sources: true }, + }); + expect(classifySource(s, '/opt/used', used)).toEqual({ + safety: 'safe', + reason: 'already-used', + }); + // allow_local_sources only counts in apply_all. + expect(classifySource(s, '/opt/new', used).safety).toBe('forbidden'); + expect(classifySource(a, '/opt/new', used)).toEqual({ + safety: 'unsafe', + reason: 'new-local-source', + }); + expect(classifySource(all('b'), '/opt/new', used).safety).toBe('forbidden'); + expect(classifySource(s, 'ghcr.io/org/p:v1', used).reason).toBe( + 'trusted-source', + ); + // '*' does not cross '/'. + expect(classifySource(s, 'ghcr.io/org/sub/p:v1', used).reason).toBe( + 'untrusted-source', + ); + }); +}); + +describe('adding a plugin (installVerdict / addPluginAccess)', () => { + const trusted = safe('trusted', { trusted_sources: ['ghcr.io/org/*'] }); + const bare = safe('bare', { trusted_sources: [], allow_local_sources: true }); + + it('without a source: who could install any new plugin', () => { + expect(installVerdict(report('r'), undefined, null).verdict).toBe('no'); + expect(installVerdict(all('a'), undefined, null).verdict).toBe('yes'); + expect(installVerdict(trusted, undefined, null).verdict).toBe('yes'); + // No trusted_sources: only reusing a source the file already has is Safe. + expect(installVerdict(bare, undefined, null)).toEqual({ + verdict: 'no', + reason: + 'apply_safe without trusted_sources: a new source needs apply_all', + }); + const base = { plugins: { ssh: { source: 'ghcr.io/x/ssh:v1' } } }; + expect(installVerdict(bare, undefined, base).verdict).toBe('partial'); + + expect(addPluginAccess([report('r1'), report('r2')]).state).toBe( + 'readonly', + ); + expect(addPluginAccess([report('r1'), trusted]).state).toBe('restricted'); + expect(addPluginAccess([trusted, all('a')]).state).toBe('editable'); + expect(addPluginAccess([]).state).toBe('editable'); + expect(addPluginTooltip(addPluginAccess([report('r1'), bare]))).toBe( + 'No reporting instance would install a new plugin — report-only mode: does not apply remote configuration: r1; apply_safe without trusted_sources: a new source needs apply_all: bare', + ); + }); + + it('with a source: the concrete classification decides', () => { + const fleet = [trusted, bare, all('a')]; + const t = addPluginAccess(fleet, 'ghcr.io/org/p:v1'); + expect(t.state).toBe('restricted'); + expect(t.restrictions.map((r) => r.instance)).toEqual(['bare']); + expect(addPluginAccess([trusted, all('a')], 'ghcr.io/org/p:v1').state).toBe( + 'editable', + ); + // An untrusted OCI source: only apply_all installs it. + const u = addPluginAccess(fleet, 'docker.io/acme/p:v1'); + expect(u.state).toBe('restricted'); + expect(u.applying).toBe(1); + expect(addPluginTooltip(u, 'docker.io/acme/p:v1')).toContain( + 'May not be installed on 2 of 3 reporting instances', + ); + // A local path: Forbidden everywhere without apply_all + allow_local_sources. + expect(addPluginAccess(fleet, '/opt/p').state).toBe('readonly'); + // An already-used source is Safe even without trusted_sources. + const ctx = { + instances: [bare], + bases: new Map([ + ['bare', { plugins: { ssh: { source: 'docker.io/acme/p:v1' } } }], + ]), + }; + expect(addPluginAccess(ctx, 'docker.io/acme/p:v1').state).toBe('editable'); + }); + + it("a plugin a host's file lacks applies there only if its source is installed", () => { + const base = { plugins: { ssh: { source: 'ghcr.io/org/ssh:v1' } } }; + const instances = [trusted, all('a')]; + const bases = new Map([ + ['trusted', base], + ['a', base], + ]); + const ctx = (source?: string) => ({ + instances, + bases, + overlay: { plugins: { extra: source ? { source } : { schedule: '' } } }, + }); + // Untrusted: apply_safe won't install it, so none of its fields apply there. + const untrusted = ctx('docker.io/acme/extra:v1'); + for (const ptr of ['/plugins/extra', '/plugins/extra/schedule']) { + const acc = fieldAccess(ptr, untrusted); + expect(acc.state).toBe('restricted'); + expect(acc.restrictions[0].instance).toBe('trusted'); + } + // Its own source field follows the field rule (a trusted source would apply). + expect(fieldAccess('/plugins/extra/source', untrusted).state).toBe( + 'editable', + ); + expect( + fieldAccess('/plugins/extra/schedule', ctx('ghcr.io/org/e:v1')).state, + ).toBe('editable'); + // No source in the overlay for a host that lacks the plugin. + const noSource = fieldAccess('/plugins/extra/schedule', ctx()); + expect(noSource.state).toBe('readonly'); + expect(accessTooltip(noSource)).toContain('the overlay sets no source'); + // Plugins the file has are unaffected. + expect(fieldAccess('/plugins/ssh/schedule', untrusted).state).toBe( + 'editable', + ); + }); +}); diff --git a/src/utils/agent-config/field-access.ts b/src/utils/agent-config/field-access.ts new file mode 100644 index 00000000..2910d82f --- /dev/null +++ b/src/utils/agent-config/field-access.ts @@ -0,0 +1,488 @@ +// Field states on the Effective view (R71), derived per instance from the API's +// pkg/agentconfig rules (classify.go Classify + WillApply, remoteconfig.go): +// forbidden — `api.*`, `daemon`, `remote_config.*`: set on the agent host, never overlaid +// (Classify: locked-key → Forbidden, rejected in every mode); +// readonly — no reporting instance would apply a change there (e.g. every instance is in +// report mode, or none has a matching overridable_config_flags entry); +// restricted — editable, but some reporting instances would not apply (all of) it; +// editable — every reporting instance would apply a change there. +// +// "Reporting instances" (the population) are the instances whose report is fresh: not stale +// and reported at least once (they have a mode and a remote_config). Stale and never-reported +// instances are left out, as the shields always did: their remote_config is unknown or +// outdated, and the API's preview flags them as stale too. +// +// A plugin a host's file does not have (e.g. one the draft adds) is installed by that host +// only if it accepts the plugin's source (installVerdict); its fields apply there only then. +// Adding a plugin (addPluginAccess) follows the same rule. +// +// ADVISORY: the decision is made per field, before a value is known. The preview (and the +// agents) decide on the actual values: a value-dependent rule (a source that the host already +// uses, a new ${env:} reference in a config value) can still change the verdict for a +// particular change. + +import { LOCKED_KEYS } from '@/types/agent-config'; +import type { + AgentInstanceSummary, + ChangeSafety, + ConfigDoc, + OverlayDoc, +} from '@/types/agent-config'; +import { getAt, parsePointer, pointer } from './json-pointer'; +import { isPlainObject } from './merge-patch'; +import { configKeyOverridable, pathMatch, sourceTrusted } from './glob'; + +export type FieldState = 'editable' | 'restricted' | 'readonly' | 'forbidden'; + +/** + * Whether one instance applies a change at a pointer: every change ('yes'), only some + * changes ('partial', e.g. removing a policy entry but not adding one), or none ('no'). + */ +export type ApplyVerdict = 'yes' | 'partial' | 'no'; + +export interface InstanceVerdict { + verdict: ApplyVerdict; + /** Why not ('' for 'yes'). */ + reason: string; +} + +export interface FieldRestriction { + /** Hostname, or the short instance id. */ + instance: string; + reason: string; + /** Some changes here still apply on this instance. */ + partial?: boolean; +} + +export interface FieldAccess { + state: FieldState; + /** One entry per reporting instance that would not apply every change here. */ + restrictions: FieldRestriction[]; + /** Reporting instances considered. */ + total: number; + /** Reporting instances that would apply every change here. */ + applying: number; +} + +/** What the access rules read. */ +export interface AccessContext { + instances: readonly AgentInstanceSummary[]; + /** Loaded instance bases (the host's file) by instance id: which plugins / sources it has. */ + bases?: ReadonlyMap; + /** The draft overlay: the source of a plugin a host's file does not have. */ + overlay?: OverlayDoc | null; +} + +export const FORBIDDEN_TOOLTIP = + 'Set on the agent host; can never be changed remotely'; + +/** `api`, `daemon`, `remote_config` and anything below them (R23, R30). */ +export function isForbiddenPointer(ptr: string): boolean { + const first = parsePointer(ptr)[0]; + return (LOCKED_KEYS as readonly string[]).includes(first ?? ''); +} + +export function instanceLabel(i: AgentInstanceSummary): string { + return i.hostname || i.instanceId.slice(0, 8); +} + +/** The instances the access rules count: fresh and reported (see the header). */ +export function accessPopulation( + instances: readonly AgentInstanceSummary[], +): AgentInstanceSummary[] { + return instances.filter( + (i) => !i.stale && i.reportedAt != null && i.mode !== '', + ); +} + +/** + * The instance's remote_config with the API's Normalize defaults (R29). The mode is the one + * the instance reported (already normalized: an unset mode is `report` with credentials, + * `off` without); '' only for an instance that never reported, which is not counted. + */ +export function normalizedRemoteConfig(inst: AgentInstanceSummary) { + const rc = inst.remoteConfig ?? {}; + return { + mode: inst.mode || rc.mode || '', + trusted: rc.trusted_sources ?? [], + flags: rc.overridable_config_flags ?? [], + allowLocal: rc.allow_local_sources ?? false, + }; +} + +export const REASON_MODE_OFF = 'remote configuration is off (mode: off)'; +export const REASON_MODE_REPORT = + 'report-only mode: does not apply remote configuration'; +const NO_TRUSTED = + 'apply_safe without trusted_sources: a new source needs apply_all'; +const NO_TRUSTED_POLICIES = + 'apply_safe without trusted_sources: only removing or reordering entries applies; a new entry needs apply_all'; + +const YES: InstanceVerdict = { verdict: 'yes', reason: '' }; +const no = (reason: string): InstanceVerdict => ({ verdict: 'no', reason }); + +/** + * WillApply's mode gate: off and report never apply; only apply_safe and apply_all do (an + * unknown mode is treated like off, as the API does). null = the mode may apply. + */ +export function modeVerdict(mode: string): InstanceVerdict | null { + if (mode === 'apply_safe' || mode === 'apply_all') return null; + return no(mode === 'report' ? REASON_MODE_REPORT : REASON_MODE_OFF); +} + +/** + * Whether `inst` would apply a change at `ptr` (Classify + WillApply, per field): + * - off / report: never (WillApply mode-off / mode-report); + * - apply_all: every change but a forbidden one (Unsafe is applied); + * - apply_safe, by field (Unsafe is rejected): + * - `verbosity`, `agent_evidence.*`: logging → Safe; + * - plugin `schedule`, `enabled`, `protocol_version`, `labels`, `policy_data`, + * `policy_behavior` (any depth) and removing a plugin: data-only / reduces-scope → Safe; + * - plugin `config.`: Safe iff MatchOverridableConfigFlag(plugin, k); the whole + * `config` map needs at least one flag whose plugin glob matches; + * - plugin `source`: a new source is Safe only when trusted (or already used), so it + * needs trusted_sources; + * - plugin `policies`: removing / reordering entries is Safe (reduces-scope), a new entry + * follows the source rule, so without trusted_sources it is 'partial'. + */ +export function instanceFieldVerdict( + ptr: string, + inst: AgentInstanceSummary, +): InstanceVerdict { + const rc = normalizedRemoteConfig(inst); + const gate = modeVerdict(rc.mode); + if (gate) return gate; + const t = parsePointer(ptr); + if (t[0] !== 'plugins' || t.length < 3 || rc.mode === 'apply_all') { + return YES; + } + const plugin = t[1]; + switch (t[2]) { + case 'config': { + if (t.length === 3) { + const any = rc.flags.some((f) => { + const idx = f.indexOf(':'); + return idx < 0 || pathMatch(f.slice(0, idx), plugin); + }); + return any + ? YES + : no( + `apply_safe without overridable_config_flags for ${plugin}: config changes need apply_all`, + ); + } + return configKeyOverridable(rc.flags, plugin, t[3]) + ? YES + : no( + `apply_safe without an overridable_config_flags entry for ${plugin}:${t[3]}`, + ); + } + case 'source': + return rc.trusted.length ? YES : no(NO_TRUSTED); + case 'policies': + return rc.trusted.length + ? YES + : { verdict: 'partial', reason: NO_TRUSTED_POLICIES }; + default: + return YES; + } +} + +// ---- Sources (sources.go KindOf, classify.go sourceClass) ---- + +const TAG_CHARS = /^[A-Za-z0-9_.-]{1,128}$/; +const REPO_CHARS = /^[a-z0-9_./-]{2,255}$/; +// RFC 3986 authority as Go's url.Parse("//"+name) accepts it back unchanged: host characters +// and an optional all-digit port. +const REGISTRY_RE = + /^[A-Za-z0-9._~!$&'()*+,;=%-]+(:[0-9]*)?$|^\[[0-9A-Fa-f:.]+\](:[0-9]*)?$/; + +/** + * agentconfig.IsOCISource: go-containerregistry name.NewTag(s, StrictValidation): an explicit + * registry (the first path segment has a '.' or ':'), a lowercase repository and an explicit + * tag. Everything else is a local path (KindOf). + */ +export function isOciSource(s: string): boolean { + const parts = s.split(':'); + let base = s; + let tag = ''; + if (parts.length > 1 && !parts[parts.length - 1].includes('/')) { + tag = parts[parts.length - 1]; + base = parts.slice(0, -1).join(':'); + } + if (!TAG_CHARS.test(tag)) return false; + const slash = base.indexOf('/'); + if (slash < 0) return false; + const registry = base.slice(0, slash); + const repo = base.slice(slash + 1); + if (!registry.includes('.') && !registry.includes(':')) return false; + // Docker Hub's implicit "library/" namespace is rejected by strict validation. + if ( + (registry === 'docker.io' || registry === 'index.docker.io') && + !repo.includes('/') + ) { + return false; + } + return REPO_CHARS.test(repo) && REGISTRY_RE.test(registry); +} + +export type SourceKind = 'oci' | 'local'; + +export function sourceKind(s: string): SourceKind { + return isOciSource(s) ? 'oci' : 'local'; +} + +/** + * classify.go usedSources: every plugin source and policy entry of the host's file (disabled + * plugins included). Without a loaded base, the plugin sources the instance reported (R76). + */ +export function usedSources( + inst: AgentInstanceSummary, + base: ConfigDoc | null | undefined, +): Set { + const used = new Set(); + if (base) { + for (const p of Object.values(base.plugins ?? {})) { + if (!isPlainObject(p)) continue; + if (typeof p.source === 'string' && p.source) used.add(p.source); + for (const e of Array.isArray(p.policies) ? p.policies : []) used.add(e); + } + return used; + } + for (const r of inst.plugins ?? []) if (r.source) used.add(r.source); + return used; +} + +export interface SourceClass { + safety: ChangeSafety; + /** classify.go change reason code (constants.ts CHANGE_REASON_LABELS). */ + reason: string; +} + +/** classify.go sourceClass: the class of a NEW source / policy entry on one instance. */ +export function classifySource( + inst: AgentInstanceSummary, + source: string, + used: ReadonlySet, +): SourceClass { + const rc = normalizedRemoteConfig(inst); + if (used.has(source)) return { safety: 'safe', reason: 'already-used' }; + if (sourceKind(source) === 'local') { + return rc.mode === 'apply_all' && rc.allowLocal + ? { safety: 'unsafe', reason: 'new-local-source' } + : { safety: 'forbidden', reason: 'local-source-not-allowed' }; + } + if (sourceTrusted(rc.trusted, source)) { + return { safety: 'safe', reason: 'trusted-source' }; + } + return { safety: 'unsafe', reason: 'untrusted-source' }; +} + +const SOURCE_REASON_TEXT: Record = { + 'local-source-not-allowed': + 'a local source needs apply_all with allow_local_sources', + 'untrusted-source': + 'apply_safe: the source matches no trusted_sources entry (needs apply_all)', +}; + +/** + * Whether `inst` would install a NEW plugin (one its file does not have) with `source` + * (Classify of `plugins.` + WillApply). A new plugin is the class of its parts and only + * its source can be unsafe, so: + * - off / report: never; + * - with a source: sourceClass → Safe (already used, or trusted) applies in apply_safe and + * apply_all; Unsafe (untrusted OCI; a local source with apply_all + allow_local_sources) + * only in apply_all; Forbidden (a local source otherwise) never; + * - without a source yet ("could it install any new plugin?"): apply_all yes (any OCI + * source); apply_safe yes with a trusted_sources entry, 'partial' without one when its + * file already uses some source (reusing it is Safe), else no. allow_local_sources plays + * no part in apply_safe: a new local source is Forbidden there. + */ +export function installVerdict( + inst: AgentInstanceSummary, + source: string | undefined, + base: ConfigDoc | null | undefined, +): InstanceVerdict { + const rc = normalizedRemoteConfig(inst); + const gate = modeVerdict(rc.mode); + if (gate) return gate; + const used = usedSources(inst, base); + if (source) { + const c = classifySource(inst, source, used); + if (c.safety === 'forbidden') return no(SOURCE_REASON_TEXT[c.reason]); + if (c.safety === 'unsafe' && rc.mode === 'apply_safe') { + return no(SOURCE_REASON_TEXT['untrusted-source']); + } + return YES; + } + if (rc.mode === 'apply_all' || rc.trusted.length) return YES; + if (used.size) { + return { + verdict: 'partial', + reason: + 'apply_safe without trusted_sources: only a source this host already uses', + }; + } + return no(NO_TRUSTED); +} + +const RANK: Record = { no: 0, partial: 1, yes: 2 }; + +function worst(a: InstanceVerdict, b: InstanceVerdict): InstanceVerdict { + return RANK[b.verdict] < RANK[a.verdict] ? b : a; +} + +/** + * Whether the host's file has `plugin`: from its loaded base, else from its reported plugins + * (R76), else unknown (assumed present). + */ +function hostHasPlugin( + inst: AgentInstanceSummary, + plugin: string, + base: ConfigDoc | null | undefined, +): boolean { + if (base) return isPlainObject(base.plugins?.[plugin]); + if (inst.plugins?.length) return inst.plugins.some((r) => r.name === plugin); + return true; +} + +/** instanceFieldVerdict, plus the install rule for a plugin the host's file lacks. */ +function verdictAt( + ptr: string, + inst: AgentInstanceSummary, + ctx: AccessContext, +): InstanceVerdict { + const field = instanceFieldVerdict(ptr, inst); + const t = parsePointer(ptr); + if (field.verdict === 'no' || t[0] !== 'plugins' || t.length < 2) { + return field; + } + const base = ctx.bases?.get(inst.instanceId); + if (hostHasPlugin(inst, t[1], base)) return field; + // Its own source is judged by the field rule ("could another source apply?"). + if (t[2] === 'source') return field; + const src = getAt(ctx.overlay ?? {}, pointer('plugins', t[1], 'source')); + if (typeof src !== 'string' || !src) { + return no("not in this host's file, and the overlay sets no source"); + } + return worst(field, installVerdict(inst, src, base)); +} + +function contextOf( + ctx: AccessContext | readonly AgentInstanceSummary[], +): AccessContext { + return Array.isArray(ctx) + ? { instances: ctx as readonly AgentInstanceSummary[] } + : (ctx as AccessContext); +} + +/** Aggregates per-instance verdicts into the three-state access. */ +export function aggregateAccess( + verdicts: { inst: AgentInstanceSummary; v: InstanceVerdict }[], +): FieldAccess { + const total = verdicts.length; + // No reporting instance yet: there is nothing to check against. The field stays editable + // without a shield (rather than read-only, which would block configuring an agent before + // its first report): the overlay is stored, and each instance applies it according to its + // own remote_config when it reports. + if (!total) { + return { state: 'editable', restrictions: [], total: 0, applying: 0 }; + } + const restrictions: FieldRestriction[] = []; + let applying = 0; + let some = 0; + for (const { inst, v } of verdicts) { + if (v.verdict === 'yes') { + applying++; + continue; + } + if (v.verdict === 'partial') some++; + restrictions.push({ + instance: instanceLabel(inst), + reason: v.reason, + ...(v.verdict === 'partial' ? { partial: true } : {}), + }); + } + let state: FieldState = 'restricted'; + if (applying === total) state = 'editable'; + else if (applying + some === 0) state = 'readonly'; + return { state, restrictions, total, applying }; +} + +/** The three-state access of the field at `ptr` over the reporting instances. */ +export function fieldAccess( + ptr: string, + ctx: AccessContext | readonly AgentInstanceSummary[], +): FieldAccess { + if (isForbiddenPointer(ptr)) { + return { state: 'forbidden', restrictions: [], total: 0, applying: 0 }; + } + const c = contextOf(ctx); + return aggregateAccess( + accessPopulation(c.instances).map((inst) => ({ + inst, + v: verdictAt(ptr, inst, c), + })), + ); +} + +/** + * Whether the reporting instances would install a new plugin: with `source` once it is known, + * else whether they could install one at all (installVerdict). + */ +export function addPluginAccess( + ctx: AccessContext | readonly AgentInstanceSummary[], + source?: string, +): FieldAccess { + const c = contextOf(ctx); + const src = source?.trim() || undefined; + return aggregateAccess( + accessPopulation(c.instances).map((inst) => ({ + inst, + v: installVerdict(inst, src, c.bases?.get(inst.instanceId)), + })), + ); +} + +/** Tooltip / aria text of the add-plugin action ('' when every instance would install it). */ +export function addPluginTooltip(access: FieldAccess, source?: string): string { + if (!access.restrictions.length) return ''; + const what = source ? 'this plugin' : 'a new plugin'; + const why = groupRestrictions(access.restrictions); + if (access.state === 'readonly') { + return `No reporting instance would install ${what} — ${why}`; + } + return `May not be installed on ${access.restrictions.length} of ${ + access.total + } reporting instance${access.total === 1 ? '' : 's'} — ${why}`; +} + +const MAX_HOSTS = 5; + +/** "reason (host-a, host-b); reason2 (host-c)": restrictions grouped by reason. */ +export function groupRestrictions(restrictions: FieldRestriction[]): string { + const groups = new Map(); + for (const r of restrictions) { + const list = groups.get(r.reason) ?? []; + list.push(r.instance); + groups.set(r.reason, list); + } + return Array.from(groups, ([reason, hosts]) => { + const shown = hosts.slice(0, MAX_HOSTS).join(', '); + const more = + hosts.length > MAX_HOSTS ? ` and ${hosts.length - MAX_HOSTS} more` : ''; + return `${reason}: ${shown}${more}`; + }).join('; '); +} + +/** Tooltip / aria text of a restricted or read-only field ('' otherwise). */ +export function accessTooltip(access: FieldAccess): string { + if (!access.restrictions.length) return ''; + const why = groupRestrictions(access.restrictions); + if (access.state === 'readonly') { + return `Read-only: no reporting instance would apply a change here — ${why}`; + } + const n = access.restrictions.length; + return `May not apply on ${n} of ${access.total} reporting instance${ + access.total === 1 ? '' : 's' + } — ${why}`; +} From 43cbfc569a450280387dd6066834b376949a3738 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:26:00 -0300 Subject: [PATCH 2/4] fix(agent-config): follow Classify for re-enabled plugins and reused sources - apply_safe: re-enabling a plugin the host's file disables applies only with a trusted effective source, and re-checks the policies and ${env:} references it keeps (classify.go reenables-plugin) - apply_safe without trusted_sources: reusing a source the file already uses is Safe, so `source` is partial instead of read-only - usedSources skips disabled plugins, as the API does - registry authorities with % are local sources (Go url.Parse) - agentconfig-conformance.json: one table of expected results from the API's pkg/agentconfig tests, asserted against glob, cron5 and field-access so the copies cannot drift silently Co-Authored-By: Claude Opus 5.5 --- .../__tests__/agentconfig-conformance.spec.ts | 116 ++++++++ .../__tests__/field-access.spec.ts | 100 +++++++ .../fixtures/agentconfig-conformance.json | 255 ++++++++++++++++++ src/utils/agent-config/field-access.ts | 116 ++++++-- 4 files changed, 570 insertions(+), 17 deletions(-) create mode 100644 src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts create mode 100644 src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json diff --git a/src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts b/src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts new file mode 100644 index 00000000..655658f6 --- /dev/null +++ b/src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts @@ -0,0 +1,116 @@ +// The UI's copies of the API's pkg/agentconfig rules against one table of expected results +// (fixtures/agentconfig-conformance.json, from the API's own tests), so a rule that changes on +// one side and not the other fails here. +import { describe, expect, it } from 'vitest'; +import type { + AgentInstanceSummary, + ConfigDoc, + OverlayDoc, +} from '@/types/agent-config'; +import fixture from './fixtures/agentconfig-conformance.json'; +import { configKeyOverridable, sourceTrusted } from '../glob'; +import { validateCron5 } from '../cron5'; +import { fieldAccess, sourceKind } from '../field-access'; + +interface Conformance { + trustedSources: { + patterns: string[]; + cases: [string, boolean][]; + extra: { patterns: string[]; source: string; want: boolean }[]; + }; + overridableConfigFlags: { + name: string; + flags: string[]; + plugin: string; + key: string; + want: boolean; + }[]; + sourceKinds: [string, 'oci' | 'local'][]; + schedules: { valid: string[]; invalid: string[] }; + applySafe: { + cases: { + name: string; + trusted: string[]; + file: NonNullable; + overlay: OverlayDoc | null; + path: string; + state: string; + }[]; + }; +} + +const cases = fixture as unknown as Conformance; + +function applySafe(trusted: string[]): AgentInstanceSummary { + return { + instanceId: 'h', + hostname: 'h', + agentVersion: null, + mode: 'apply_safe', + firstSeenAt: '', + lastSeenAt: '', + reportedAt: '2026-10-01T00:00:00Z', + stale: false, + appliedRevision: 1, + attemptedRevision: 1, + status: 'applied', + reason: null, + error: null, + syncStatus: 'in-sync', + effectiveDigest: null, + heartbeatConfigRevision: 1, + reportStale: false, + unsafe: [], + remoteConfig: { mode: 'apply_safe', trusted_sources: trusted }, + }; +} + +describe('pkg/agentconfig conformance', () => { + it.each(cases.trustedSources.cases)( + 'MatchTrustedSource(%j)', + (source, want) => { + expect(sourceTrusted(cases.trustedSources.patterns, source)).toBe(want); + }, + ); + + it.each(cases.trustedSources.extra)( + 'MatchTrustedSource($patterns, $source)', + ({ patterns, source, want }) => { + expect(sourceTrusted(patterns, source)).toBe(want); + }, + ); + + it.each(cases.overridableConfigFlags)( + 'MatchOverridableConfigFlag: $name', + ({ flags, plugin, key, want }) => { + expect(configKeyOverridable(flags, plugin, key)).toBe(want); + }, + ); + + it.each(cases.sourceKinds)('KindOf(%j) = %s', (source, kind) => { + expect(sourceKind(source)).toBe(kind); + }); + + it.each(cases.schedules.valid)('ParseSchedule(%j) succeeds', (expr) => { + expect(validateCron5(expr)).toBeNull(); + }); + + it.each(cases.schedules.invalid)('ParseSchedule(%j) fails', (expr) => { + expect(validateCron5(expr)).not.toBeNull(); + }); + + it.each(cases.applySafe.cases)( + 'apply_safe: $name', + ({ trusted, file, overlay, path, state }) => { + const inst = applySafe(trusted); + const base: ConfigDoc = { plugins: file }; + expect( + fieldAccess(path, { + instances: [inst], + bases: new Map([[inst.instanceId, base]]), + overlay, + }).state, + ).toBe(state); + }, + ); +}); diff --git a/src/utils/agent-config/__tests__/field-access.spec.ts b/src/utils/agent-config/__tests__/field-access.spec.ts index 9db217e0..2939ab04 100644 --- a/src/utils/agent-config/__tests__/field-access.spec.ts +++ b/src/utils/agent-config/__tests__/field-access.spec.ts @@ -188,6 +188,103 @@ describe('field access (R71)', () => { ); }); + it("apply_safe: re-enabling a plugin the host's file disables needs a trusted source", () => { + const base = { + plugins: { ssh: { enabled: false, source: 'ghcr.io/org/ssh:v1' } }, + }; + const u = safe('u', { trusted_sources: [] }); + const t = safe('t', { trusted_sources: ['ghcr.io/org/*'] }); + const ctx = ( + instances: AgentInstanceSummary[], + overlay: Record | null = null, + ) => ({ + instances, + bases: new Map(instances.map((i) => [i.instanceId, base])), + overlay, + }); + const untrusted = fieldAccess('/plugins/ssh/enabled', ctx([u])); + expect(untrusted.state).toBe('readonly'); + expect(accessTooltip(untrusted)).toContain('re-enabling a plugin'); + expect(fieldAccess('/plugins/ssh/enabled', ctx([t])).state).toBe( + 'editable', + ); + expect(fieldAccess('/plugins/ssh/enabled', ctx([u, all('a')])).state).toBe( + 'restricted', + ); + // The overlay's source is the one that runs. + const moved = { plugins: { ssh: { source: 'docker.io/x/ssh:v1' } } }; + expect(fieldAccess('/plugins/ssh/enabled', ctx([t], moved)).state).toBe( + 'readonly', + ); + // An enabled plugin, or one without a loaded file, stays data-only. + const on = new Map([['u', { plugins: { ssh: { source: 'x' } } }]]); + expect( + fieldAccess('/plugins/ssh/enabled', { instances: [u], bases: on }).state, + ).toBe('editable'); + expect(fieldAccess('/plugins/ssh/enabled', [u]).state).toBe('editable'); + }); + + it("re-enabling re-checks the plugin's kept policies and ${env:} references", () => { + // classify_test.go TestClassifyReenableKeptParts. + const rc = { trusted_sources: ['ghcr.io/trusted/*'] }; + const t = safe('t', rc); + const at = (plugin: Record, overlay = null as unknown) => + fieldAccess('/plugins/x/enabled', { + instances: [t], + bases: new Map([['t', { plugins: { x: plugin } }]]), + overlay: overlay as Record | null, + }); + const x = { + enabled: false, + source: 'ghcr.io/trusted/p:v1', + policies: ['ghcr.io/evil/pol:v9', '/tmp/local-policy'], + config: { host: 'db', token: '${env:DB_TOKEN}' }, + }; + expect(accessTooltip(at(x))).toContain('re-checks its policies'); + const noPolicies = { ...x, policies: ['ghcr.io/trusted/pol:v1'] }; + expect(accessTooltip(at(noPolicies))).toContain('${env:}'); + // The overlay drops the untrusted entries and the env reference. + expect( + at(x, { + plugins: { + x: { + policies: ['ghcr.io/trusted/pol:v1'], + config: { token: null }, + }, + }, + }).state, + ).toBe('editable'); + }); + + it("apply_safe without trusted_sources: a source the host's file uses can be reused", () => { + const u = safe('u', { trusted_sources: [] }); + const withOther = { + plugins: { + ssh: { source: 'ghcr.io/org/ssh:v1' }, + other: { source: 'ghcr.io/org/other:v2' }, + }, + }; + const reuse = fieldAccess('/plugins/ssh/source', { + instances: [u], + bases: new Map([['u', withOther]]), + }); + expect(reuse.state).toBe('restricted'); + expect(reuse.restrictions[0]).toMatchObject({ + partial: true, + reason: expect.stringContaining('only a source this host already uses'), + }); + // A disabled plugin's sources are not "already used" (classify.go usedSources). + const onlyDisabled = { + plugins: { ssh: { enabled: false, source: 'ghcr.io/org/ssh:v1' } }, + }; + expect( + fieldAccess('/plugins/ssh/source', { + instances: [u], + bases: new Map([['u', onlyDisabled]]), + }).state, + ).toBe('readonly'); + }); + it('labels instances by hostname, else the short id, and caps the host list', () => { const many = Array.from({ length: 7 }, (_, n) => inst({ @@ -218,6 +315,9 @@ describe('sources (sources.go KindOf, classify.go sourceClass)', () => { ['plugin', false], ['', false], ['inline:ssh', false], + // Go's url.Parse rejects or decodes a percent escape in the authority. + ['foo%.com/acme/plugin:v1', false], + ['foo%41.com/acme/plugin:v1', false], ]; for (const [s, oci] of cases) { expect([s, isOciSource(s)]).toEqual([s, oci]); diff --git a/src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json b/src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json new file mode 100644 index 00000000..5c939675 --- /dev/null +++ b/src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json @@ -0,0 +1,255 @@ +{ + "_comment": "Expected results of the API's pkg/agentconfig rules that the UI re-implements (glob.ts, cron5.ts, field-access.ts, validation.ts). Cases are the API's own test tables (remoteconfig_test.go, sources_test.go, cron_test.go, classify_test.go) plus the drift cases found in review. Keep in step with the API: when a rule changes there, change it here and let agentconfig-conformance.spec.ts show what the UI must follow.", + "_source": "compliance-framework/api pkg/agentconfig @ 83ed7d6", + "trustedSources": { + "patterns": [ + "ghcr.io/compliance-framework/*", + "docker.io/acme/plugin-?:v1", + "[bad" + ], + "cases": [ + ["ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", true], + ["ghcr.io/compliance-framework/sub/plugin:v1", false], + ["ghcr.io/Compliance-Framework/plugin:v1", false], + ["ghcr.io/compliance-framework", false], + ["ghcr.io/other/plugin:v1", false], + ["docker.io/acme/plugin-a:v1", true], + ["docker.io/acme/plugin-ab:v1", false], + ["", false] + ], + "extra": [ + { "patterns": [], "source": "ghcr.io/x/y:v1", "want": false }, + { "patterns": ["*/*/*"], "source": "ghcr.io/x/y:v1", "want": true } + ] + }, + "overridableConfigFlags": [ + { + "name": "default empty", + "flags": [], + "plugin": "local-ssh", + "key": "port", + "want": false + }, + { + "name": "star", + "flags": ["*"], + "plugin": "local-ssh", + "key": "port", + "want": true + }, + { + "name": "star any plugin", + "flags": ["*"], + "plugin": "other", + "key": "anything", + "want": true + }, + { + "name": "scoped match", + "flags": ["local-ssh:port"], + "plugin": "local-ssh", + "key": "port", + "want": true + }, + { + "name": "scoped other key", + "flags": ["local-ssh:port"], + "plugin": "local-ssh", + "key": "host", + "want": false + }, + { + "name": "scoped other plugin", + "flags": ["local-ssh:port"], + "plugin": "remote-ssh", + "key": "port", + "want": false + }, + { + "name": "unscoped key any plugin", + "flags": ["port"], + "plugin": "remote-ssh", + "key": "port", + "want": true + }, + { + "name": "plugin glob", + "flags": ["*-ssh:port"], + "plugin": "remote-ssh", + "key": "port", + "want": true + }, + { + "name": "key glob", + "flags": ["local-ssh:tls_*"], + "plugin": "local-ssh", + "key": "tls_verify", + "want": true + }, + { + "name": "case-sensitive", + "flags": ["local-ssh:Port"], + "plugin": "local-ssh", + "key": "port", + "want": false + }, + { + "name": "split at first colon", + "flags": ["p*:a:b"], + "plugin": "p1", + "key": "a:b", + "want": true + }, + { + "name": "split at first colon, plugin side", + "flags": ["p*:a:b"], + "plugin": "p1:a", + "key": "b", + "want": false + }, + { + "name": "bad glob skipped", + "flags": ["[x:port", "local-ssh:[", "local-ssh:port"], + "plugin": "local-ssh", + "key": "port", + "want": true + }, + { + "name": "only bad globs", + "flags": ["[x:port", "local-ssh:["], + "plugin": "local-ssh", + "key": "port", + "want": false + } + ], + "sourceKinds": [ + ["ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", "oci"], + ["ghcr.io/compliance-framework/plugin-local-ssh-policies:latest", "oci"], + ["docker.io/library/alpine:3.20", "oci"], + ["localhost:5000/plugin:v1", "oci"], + ["registry.example.com:5000/a/b/c:1.2.3", "oci"], + ["ghcr.io/x/y", "local"], + ["ghcr.io/X/Y:v1", "local"], + ["ghcr.io/x/y:", "local"], + ["./plugins/foo", "local"], + ["/opt/plugin", "local"], + ["plugin", "local"], + ["", "local"], + ["inline:ssh", "local"], + ["foo%.com/acme/plugin:v1", "local"], + ["foo%41.com/acme/plugin:v1", "local"] + ], + "schedules": { + "valid": [ + "TZ=UTC 0 * * * *", + "CRON_TZ=Europe/London 0 * * * *", + "*/5 * * * *", + "@hourly" + ], + "invalid": [ + "TZ=UTC", + "CRON_TZ=UTC", + "TZ=", + "CRON_TZ=", + "TZ=utc 0 * * * *", + "CRON_TZ=europe/london 0 * * * *" + ] + }, + "applySafe": { + "_comment": "classify_test.go: whether an apply_safe host applies a change at `path` (Classify + WillApply), for the field-level prediction of field-access.ts. `state` is fieldAccess over that one host.", + "cases": [ + { + "name": "re-enable, untrusted source", + "trusted": [], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/other/plugin-disabled:v1" + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "readonly" + }, + { + "name": "re-enable, trusted source", + "trusted": ["ghcr.io/trusted/*"], + "file": { "x": { "enabled": false, "source": "ghcr.io/trusted/p:v1" } }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "editable" + }, + { + "name": "re-enable keeps untrusted and local policies (TestClassifyReenableKeptParts)", + "trusted": ["ghcr.io/trusted/*"], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/trusted/p:v1", + "policies": ["ghcr.io/evil/pol:v9", "/tmp/local-policy"] + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "readonly" + }, + { + "name": "re-enable keeps ${env:} references (TestClassifyReenableKeptParts)", + "trusted": ["ghcr.io/trusted/*"], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/trusted/p:v1", + "config": { "token": "${env:DB_TOKEN}" } + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "readonly" + }, + { + "name": "disabling is data-only", + "trusted": [], + "file": { "x": { "source": "ghcr.io/other/p:v1" } }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "editable" + }, + { + "name": "a new source needs trusted_sources, but reusing one is already-used", + "trusted": [], + "file": { + "x": { "source": "ghcr.io/a/x:v1" }, + "y": { "source": "ghcr.io/a/y:v1" } + }, + "overlay": null, + "path": "/plugins/x/source", + "state": "restricted" + }, + { + "name": "a disabled plugin's sources are not already used", + "trusted": [], + "file": { "x": { "enabled": false, "source": "ghcr.io/a/x:v1" } }, + "overlay": null, + "path": "/plugins/x/source", + "state": "readonly" + }, + { + "name": "config key needs an overridable_config_flags entry", + "trusted": [], + "file": { "local-ssh": { "source": "s" } }, + "overlay": null, + "path": "/plugins/local-ssh/config/host", + "state": "readonly" + }, + { + "name": "data-only fields", + "trusted": [], + "file": { "local-ssh": { "source": "s" } }, + "overlay": null, + "path": "/plugins/local-ssh/policy_data/threshold", + "state": "editable" + } + ] + } +} diff --git a/src/utils/agent-config/field-access.ts b/src/utils/agent-config/field-access.ts index 2910d82f..498a84ea 100644 --- a/src/utils/agent-config/field-access.ts +++ b/src/utils/agent-config/field-access.ts @@ -27,6 +27,7 @@ import type { ChangeSafety, ConfigDoc, OverlayDoc, + PluginDoc, } from '@/types/agent-config'; import { getAt, parsePointer, pointer } from './json-pointer'; import { isPlainObject } from './merge-patch'; @@ -115,6 +116,14 @@ export const REASON_MODE_REPORT = 'report-only mode: does not apply remote configuration'; const NO_TRUSTED = 'apply_safe without trusted_sources: a new source needs apply_all'; +const ONLY_USED_SOURCE = + 'apply_safe without trusted_sources: only a source this host already uses'; +const REASON_REENABLE = + 'apply_safe: re-enabling a plugin this host disabled needs a trusted source (or apply_all)'; +const REASON_REENABLE_POLICIES = + 'apply_safe: re-enabling a plugin this host disabled re-checks its policies, and one is not trusted'; +const REASON_REENABLE_ENV = + 'apply_safe: re-enabling a plugin this host disabled re-checks its ${env:} references (needs apply_all)'; const NO_TRUSTED_POLICIES = 'apply_safe without trusted_sources: only removing or reordering entries applies; a new entry needs apply_all'; @@ -138,10 +147,13 @@ export function modeVerdict(mode: string): InstanceVerdict | null { * - `verbosity`, `agent_evidence.*`: logging → Safe; * - plugin `schedule`, `enabled`, `protocol_version`, `labels`, `policy_data`, * `policy_behavior` (any depth) and removing a plugin: data-only / reduces-scope → Safe; + * but re-enabling a plugin the host's file disables is Safe only with a trusted source + * (verdictAt, which knows the host's file); * - plugin `config.`: Safe iff MatchOverridableConfigFlag(plugin, k); the whole * `config` map needs at least one flag whose plugin glob matches; - * - plugin `source`: a new source is Safe only when trusted (or already used), so it - * needs trusted_sources; + * - plugin `source`: a new source is Safe only when trusted or already used, so without + * trusted_sources only reusing a source applies (verdictAt: 'partial' when the host's + * file uses one, else 'no'); * - plugin `policies`: removing / reordering entries is Safe (reduces-scope), a new entry * follows the source rule, so without trusted_sources it is 'partial'. */ @@ -192,9 +204,10 @@ export function instanceFieldVerdict( const TAG_CHARS = /^[A-Za-z0-9_.-]{1,128}$/; const REPO_CHARS = /^[a-z0-9_./-]{2,255}$/; // RFC 3986 authority as Go's url.Parse("//"+name) accepts it back unchanged: host characters -// and an optional all-digit port. +// and an optional all-digit port. No '%': Go either rejects the escape or decodes it, so the +// parsed host no longer equals the name. const REGISTRY_RE = - /^[A-Za-z0-9._~!$&'()*+,;=%-]+(:[0-9]*)?$|^\[[0-9A-Fa-f:.]+\](:[0-9]*)?$/; + /^[A-Za-z0-9._~!$&'()*+,;=-]+(:[0-9]*)?$|^\[[0-9A-Fa-f:.]+\](:[0-9]*)?$/; /** * agentconfig.IsOCISource: go-containerregistry name.NewTag(s, StrictValidation): an explicit @@ -232,8 +245,9 @@ export function sourceKind(s: string): SourceKind { } /** - * classify.go usedSources: every plugin source and policy entry of the host's file (disabled - * plugins included). Without a loaded base, the plugin sources the instance reported (R76). + * classify.go usedSources: every plugin source and policy entry of the host's file's ENABLED + * plugins (pointing another plugin at a disabled one's source is a new source). Without a + * loaded base, the plugin sources the instance reported (R76). */ export function usedSources( inst: AgentInstanceSummary, @@ -242,7 +256,7 @@ export function usedSources( const used = new Set(); if (base) { for (const p of Object.values(base.plugins ?? {})) { - if (!isPlainObject(p)) continue; + if (!isPlainObject(p) || p.enabled === false) continue; if (typeof p.source === 'string' && p.source) used.add(p.source); for (const e of Array.isArray(p.policies) ? p.policies : []) used.add(e); } @@ -315,13 +329,7 @@ export function installVerdict( return YES; } if (rc.mode === 'apply_all' || rc.trusted.length) return YES; - if (used.size) { - return { - verdict: 'partial', - reason: - 'apply_safe without trusted_sources: only a source this host already uses', - }; - } + if (used.size) return { verdict: 'partial', reason: ONLY_USED_SOURCE }; return no(NO_TRUSTED); } @@ -345,18 +353,92 @@ function hostHasPlugin( return true; } -/** instanceFieldVerdict, plus the install rule for a plugin the host's file lacks. */ +const ENV_REF = /\$\{env:[A-Za-z_][A-Za-z0-9_]*\}/; + +/** + * classifyPlugin with `reenabled` on apply_safe: re-enabling is Unsafe (reenables-plugin) + * unless the effective source (the overlay's, else the file's) is trusted, and since the host + * disabled the plugin none of its parts is in use: every policy entry it keeps goes through + * the source rules, and every ${env:} reference in its effective config is a new one (Unsafe, + * or Forbidden for CCF_API_AUTH_*). Any of those keeps an apply_safe host from applying it. + */ +function reenableVerdict( + inst: AgentInstanceSummary, + filePlugin: PluginDoc, + base: ConfigDoc | null | undefined, + overlayPlugin: PluginDoc | null | undefined, +): InstanceVerdict { + const rc = normalizedRemoteConfig(inst); + const ov: PluginDoc = isPlainObject(overlayPlugin) ? overlayPlugin : {}; + const src = ov.source || filePlugin.source; + if (!src || !sourceTrusted(rc.trusted, src)) return no(REASON_REENABLE); + const filePolicies = filePlugin.policies ?? []; + const kept = Array.isArray(ov.policies) + ? filePolicies.filter((e) => ov.policies!.includes(e)) + : filePolicies; + const used = usedSources(inst, base); + if (kept.some((e) => classifySource(inst, e, used).safety !== 'safe')) { + return no(REASON_REENABLE_POLICIES); + } + const config: Record = { + ...(filePlugin.config ?? {}), + ...(isPlainObject(ov.config) ? ov.config : {}), + }; + if (Object.values(config).some((v) => v != null && ENV_REF.test(v))) { + return no(REASON_REENABLE_ENV); + } + return YES; +} + +/** + * The rules of instanceFieldVerdict that depend on the host's file (apply_safe): + * - `source`: without trusted_sources, reusing a source the file already uses is Safe; + * - `enabled`: re-enabling a plugin the file disables (reenableVerdict). + * null = no file-dependent rule applies. + */ +function hostFileVerdict( + t: string[], + inst: AgentInstanceSummary, + base: ConfigDoc | null | undefined, + overlay: OverlayDoc | null | undefined, +): InstanceVerdict | null { + const rc = normalizedRemoteConfig(inst); + if (rc.mode !== 'apply_safe' || t[0] !== 'plugins' || t.length !== 3) { + return null; + } + if (t[2] === 'source' && !rc.trusted.length) { + return usedSources(inst, base).size + ? { verdict: 'partial', reason: ONLY_USED_SOURCE } + : null; + } + const filePlugin = base?.plugins?.[t[1]]; + if ( + t[2] === 'enabled' && + isPlainObject(filePlugin) && + filePlugin.enabled === false + ) { + return reenableVerdict(inst, filePlugin, base, overlay?.plugins?.[t[1]]); + } + return null; +} + +/** + * instanceFieldVerdict, plus the rules that need the host's file (hostFileVerdict) and the + * install rule for a plugin the host's file lacks. + */ function verdictAt( ptr: string, inst: AgentInstanceSummary, ctx: AccessContext, ): InstanceVerdict { - const field = instanceFieldVerdict(ptr, inst); const t = parsePointer(ptr); + const base = ctx.bases?.get(inst.instanceId); + const field = + hostFileVerdict(t, inst, base, ctx.overlay) ?? + instanceFieldVerdict(ptr, inst); if (field.verdict === 'no' || t[0] !== 'plugins' || t.length < 2) { return field; } - const base = ctx.bases?.get(inst.instanceId); if (hostHasPlugin(inst, t[1], base)) return field; // Its own source is judged by the field rule ("could another source apply?"). if (t[2] === 'source') return field; From 777a899fb8195339d3ab717e74157ad0e6126fbd Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:10:15 -0300 Subject: [PATCH 3/4] fix(agent-config): forbidden-key tooltip text lives in tooltips.ts FORBIDDEN_TOOLTIP reads TOOLTIPS['agents.config.field.forbidden'], per docs/TOOLTIPS.md (UI-COMP-001). Co-Authored-By: Claude Opus 5.5 --- src/config/tooltips.ts | 4 ++++ src/utils/agent-config/field-access.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/config/tooltips.ts b/src/config/tooltips.ts index 0c4b0eb6..984e0efb 100644 --- a/src/config/tooltips.ts +++ b/src/config/tooltips.ts @@ -50,6 +50,10 @@ export const TOOLTIPS = { 'statement.props': '', // TODO: Add tooltip 'statement.links': '', // TODO: Add tooltip + // Agent configuration + 'agents.config.field.forbidden': + 'Set on the agent host; can never be changed remotely', + // Add more tooltips here as needed // 'feature.name': 'Tooltip text here', } as const; diff --git a/src/utils/agent-config/field-access.ts b/src/utils/agent-config/field-access.ts index 498a84ea..a2e86222 100644 --- a/src/utils/agent-config/field-access.ts +++ b/src/utils/agent-config/field-access.ts @@ -22,6 +22,7 @@ // particular change. import { LOCKED_KEYS } from '@/types/agent-config'; +import { TOOLTIPS } from '@/config/tooltips'; import type { AgentInstanceSummary, ChangeSafety, @@ -74,8 +75,7 @@ export interface AccessContext { overlay?: OverlayDoc | null; } -export const FORBIDDEN_TOOLTIP = - 'Set on the agent host; can never be changed remotely'; +export const FORBIDDEN_TOOLTIP = TOOLTIPS['agents.config.field.forbidden']; /** `api`, `daemon`, `remote_config` and anything below them (R23, R30). */ export function isForbiddenPointer(ptr: string): boolean { From e23b899ed6b238ff501c980d7466d817ed4c956c Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:27:52 -0300 Subject: [PATCH 4/4] test(agent-config): vendor the API's agentconfig conformance golden file The conformance fixture is now the API's generated golden file (pkg/agentconfig/testdata/conformance.json @ c0b3792), byte for byte, instead of a hand-copied table (CORE-DUP-001). - fixtures/README.md records the API commit and the update flow - scripts/sync-agentconfig-conformance.sh [ref] copies it (npm run sync:agentconfig-conformance); --check compares it, passing with a notice while the API has not published the file - CI job conformance-drift (pull-request.yml) runs the check against api@main; make reviewable does not (no network) - .prettierignore keeps prettier from reformatting the vendored file - the spec fails when the golden file gains a table it does not run The golden file adds the case "re-enable keeps a local plugin source", which field-access.ts already passes. The two case-mismatched time zones it drops stay as UI-only cases in cron5.spec.ts. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pull-request.yml | 12 + .prettierignore | 2 + package.json | 1 + scripts/sync-agentconfig-conformance.sh | 50 +++ .../__tests__/agentconfig-conformance.spec.ts | 23 +- .../agent-config/__tests__/fixtures/README.md | 27 ++ .../fixtures/agentconfig-conformance.json | 289 ++++++++++++++---- 7 files changed, 345 insertions(+), 59 deletions(-) create mode 100644 .prettierignore create mode 100755 scripts/sync-agentconfig-conformance.sh create mode 100644 src/utils/agent-config/__tests__/fixtures/README.md diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 14b6ca69..7759de5c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -26,6 +26,18 @@ jobs: run: | make reviewable + conformance-drift: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # The vendored agentconfig conformance file must match the API's main (a notice, not a + # failure, while the API has not published it yet). Not in `make reviewable`: it needs + # the network. + - name: Compare the agentconfig conformance file with api@main + run: scripts/sync-agentconfig-conformance.sh --check main + type-check: runs-on: ubuntu-latest steps: diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..532e8611 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Vendored byte-for-byte from compliance-framework/api (see the README next to it). +src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json diff --git a/package.json b/package.json index 6e7759c7..048dae74 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format:fix": "prettier --write src/", "format:staged": "lint-staged", "format:check": "prettier --check src/", + "sync:agentconfig-conformance": "scripts/sync-agentconfig-conformance.sh", "prepare": "husky" }, "lint-staged": { diff --git a/scripts/sync-agentconfig-conformance.sh b/scripts/sync-agentconfig-conformance.sh new file mode 100755 index 00000000..722c1c73 --- /dev/null +++ b/scripts/sync-agentconfig-conformance.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Vendors the API's agentconfig conformance file (pkg/agentconfig/testdata/conformance.json) +# as the UI's fixture, byte for byte. See src/utils/agent-config/__tests__/fixtures/README.md. +# +# scripts/sync-agentconfig-conformance.sh [ref] copy api@ (default main) over the fixture +# scripts/sync-agentconfig-conformance.sh --check [ref] fail when the fixture differs from api@; +# passes with a notice while has no file (404) +set -euo pipefail + +check=false +if [ "${1:-}" = "--check" ]; then + check=true + shift +fi +ref="${1:-main}" +url="https://raw.githubusercontent.com/compliance-framework/api/${ref}/pkg/agentconfig/testdata/conformance.json" +fixture="src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json" +cd "$(dirname "$0")/.." + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +status="$(curl -sSL -o "$tmp" -w '%{http_code}' "$url")" + +if [ "$status" = "404" ]; then + if $check; then + echo "::notice title=agentconfig conformance::api@${ref} has no pkg/agentconfig/testdata/conformance.json yet; nothing to compare." + exit 0 + fi + echo "error: api@${ref} has no pkg/agentconfig/testdata/conformance.json ($url)" >&2 + exit 1 +fi +if [ "$status" != "200" ]; then + echo "error: HTTP $status for $url" >&2 + exit 1 +fi + +if $check; then + if cmp -s "$tmp" "$fixture"; then + echo "$fixture matches api@${ref}." + exit 0 + fi + echo "::error file=${fixture}::The vendored conformance file differs from api@${ref}. Run scripts/sync-agentconfig-conformance.sh ${ref}, then fix the conformance cases that fail." + diff -u "$fixture" "$tmp" || true + exit 1 +fi + +cp "$tmp" "$fixture" +echo "Copied api@${ref} to $fixture." +echo "Record the API commit in src/utils/agent-config/__tests__/fixtures/README.md, then run:" +echo " npx vitest run src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts" diff --git a/src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts b/src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts index 655658f6..ece52ec9 100644 --- a/src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts +++ b/src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts @@ -1,6 +1,7 @@ -// The UI's copies of the API's pkg/agentconfig rules against one table of expected results -// (fixtures/agentconfig-conformance.json, from the API's own tests), so a rule that changes on -// one side and not the other fails here. +// The UI's copies of the API's pkg/agentconfig rules against the API's golden file of expected +// results (fixtures/agentconfig-conformance.json, vendored byte for byte; see fixtures/README.md), +// so a rule that changes on one side and not the other fails here. Every table is run; the +// expectations all come from the file. import { describe, expect, it } from 'vitest'; import type { AgentInstanceSummary, @@ -66,6 +67,22 @@ function applySafe(trusted: string[]): AgentInstanceSummary { } describe('pkg/agentconfig conformance', () => { + it('knows every table of the golden file', () => { + // A table the API adds must get a runner here (pluginNames runs with validation.ts). + expect( + Object.keys(fixture) + .filter((k) => !k.startsWith('_')) + .sort(), + ).toEqual([ + 'applySafe', + 'overridableConfigFlags', + 'pluginNames', + 'schedules', + 'sourceKinds', + 'trustedSources', + ]); + }); + it.each(cases.trustedSources.cases)( 'MatchTrustedSource(%j)', (source, want) => { diff --git a/src/utils/agent-config/__tests__/fixtures/README.md b/src/utils/agent-config/__tests__/fixtures/README.md new file mode 100644 index 00000000..0ce876b5 --- /dev/null +++ b/src/utils/agent-config/__tests__/fixtures/README.md @@ -0,0 +1,27 @@ +# agentconfig conformance fixture + +`agentconfig-conformance.json` is the API's golden file +`pkg/agentconfig/testdata/conformance.json`, copied byte for byte (it is in +`.prettierignore`; do not edit it here). It holds the expected results of the +`pkg/agentconfig` rules that the UI re-implements (`glob.ts`, `cron5.ts`, +`field-access.ts`, `validation.ts`), and `../agentconfig-conformance.spec.ts` +runs every table against the UI's code. + +Copied from: compliance-framework/api@c0b3792 (branch +`lisa/agent-config/05-overlay-validation`, api#473). + +## Updating + +1. The API changes a rule and regenerates the file: + `go test ./pkg/agentconfig -run TestConformanceGolden -update`. +2. The UI copies it: `npm run sync:agentconfig-conformance -- [ref]` (the ref + defaults to `main`), and records the API commit above. +3. Run `npx vitest run src/utils/agent-config/__tests__/agentconfig-conformance.spec.ts` + and fix the UI code until every case passes. + +CI (`conformance-drift` in `.github/workflows/pull-request.yml`) fails when this +copy differs from the API's `main`; until the file exists on `main` it passes +with a notice. `make reviewable` does not run it (no network in the local gate). + +UI-only cases (stricter than the golden file on purpose) live in the rule's own +spec, e.g. the case-mismatched time zones in `cron5.spec.ts`. diff --git a/src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json b/src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json index 5c939675..98fe59d7 100644 --- a/src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json +++ b/src/utils/agent-config/__tests__/fixtures/agentconfig-conformance.json @@ -1,6 +1,6 @@ { - "_comment": "Expected results of the API's pkg/agentconfig rules that the UI re-implements (glob.ts, cron5.ts, field-access.ts, validation.ts). Cases are the API's own test tables (remoteconfig_test.go, sources_test.go, cron_test.go, classify_test.go) plus the drift cases found in review. Keep in step with the API: when a rule changes there, change it here and let agentconfig-conformance.spec.ts show what the UI must follow.", - "_source": "compliance-framework/api pkg/agentconfig @ 83ed7d6", + "_comment": "Expected results of the pkg/agentconfig rules that clients re-implement (the UI's glob.ts, cron5.ts, field-access.ts, validation.ts). Generated from the API's own test tables (remoteconfig_test.go, sources_test.go incl. TestNamePatterns, cron_test.go, classify_test.go), every expected value computed by the real functions. Do not edit: regenerate with go test ./pkg/agentconfig -run TestConformanceGolden -update.", + "_source": "compliance-framework/api pkg/agentconfig/testdata/conformance.json", "trustedSources": { "patterns": [ "ghcr.io/compliance-framework/*", @@ -8,18 +8,52 @@ "[bad" ], "cases": [ - ["ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", true], - ["ghcr.io/compliance-framework/sub/plugin:v1", false], - ["ghcr.io/Compliance-Framework/plugin:v1", false], - ["ghcr.io/compliance-framework", false], - ["ghcr.io/other/plugin:v1", false], - ["docker.io/acme/plugin-a:v1", true], - ["docker.io/acme/plugin-ab:v1", false], - ["", false] + [ + "ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", + true + ], + [ + "ghcr.io/compliance-framework/sub/plugin:v1", + false + ], + [ + "ghcr.io/Compliance-Framework/plugin:v1", + false + ], + [ + "ghcr.io/compliance-framework", + false + ], + [ + "ghcr.io/other/plugin:v1", + false + ], + [ + "docker.io/acme/plugin-a:v1", + true + ], + [ + "docker.io/acme/plugin-ab:v1", + false + ], + [ + "", + false + ] ], "extra": [ - { "patterns": [], "source": "ghcr.io/x/y:v1", "want": false }, - { "patterns": ["*/*/*"], "source": "ghcr.io/x/y:v1", "want": true } + { + "patterns": [], + "source": "ghcr.io/x/y:v1", + "want": false + }, + { + "patterns": [ + "*/*/*" + ], + "source": "ghcr.io/x/y:v1", + "want": true + } ] }, "overridableConfigFlags": [ @@ -32,112 +66,186 @@ }, { "name": "star", - "flags": ["*"], + "flags": [ + "*" + ], "plugin": "local-ssh", "key": "port", "want": true }, { "name": "star any plugin", - "flags": ["*"], + "flags": [ + "*" + ], "plugin": "other", "key": "anything", "want": true }, { "name": "scoped match", - "flags": ["local-ssh:port"], + "flags": [ + "local-ssh:port" + ], "plugin": "local-ssh", "key": "port", "want": true }, { "name": "scoped other key", - "flags": ["local-ssh:port"], + "flags": [ + "local-ssh:port" + ], "plugin": "local-ssh", "key": "host", "want": false }, { "name": "scoped other plugin", - "flags": ["local-ssh:port"], + "flags": [ + "local-ssh:port" + ], "plugin": "remote-ssh", "key": "port", "want": false }, { "name": "unscoped key any plugin", - "flags": ["port"], + "flags": [ + "port" + ], "plugin": "remote-ssh", "key": "port", "want": true }, { "name": "plugin glob", - "flags": ["*-ssh:port"], + "flags": [ + "*-ssh:port" + ], "plugin": "remote-ssh", "key": "port", "want": true }, { "name": "key glob", - "flags": ["local-ssh:tls_*"], + "flags": [ + "local-ssh:tls_*" + ], "plugin": "local-ssh", "key": "tls_verify", "want": true }, { "name": "case-sensitive", - "flags": ["local-ssh:Port"], + "flags": [ + "local-ssh:Port" + ], "plugin": "local-ssh", "key": "port", "want": false }, { "name": "split at first colon", - "flags": ["p*:a:b"], + "flags": [ + "p*:a:b" + ], "plugin": "p1", "key": "a:b", "want": true }, { "name": "split at first colon, plugin side", - "flags": ["p*:a:b"], + "flags": [ + "p*:a:b" + ], "plugin": "p1:a", "key": "b", "want": false }, { "name": "bad glob skipped", - "flags": ["[x:port", "local-ssh:[", "local-ssh:port"], + "flags": [ + "[x:port", + "local-ssh:[", + "local-ssh:port" + ], "plugin": "local-ssh", "key": "port", "want": true }, { "name": "only bad globs", - "flags": ["[x:port", "local-ssh:["], + "flags": [ + "[x:port", + "local-ssh:[" + ], "plugin": "local-ssh", "key": "port", "want": false } ], "sourceKinds": [ - ["ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", "oci"], - ["ghcr.io/compliance-framework/plugin-local-ssh-policies:latest", "oci"], - ["docker.io/library/alpine:3.20", "oci"], - ["localhost:5000/plugin:v1", "oci"], - ["registry.example.com:5000/a/b/c:1.2.3", "oci"], - ["ghcr.io/x/y", "local"], - ["ghcr.io/X/Y:v1", "local"], - ["ghcr.io/x/y:", "local"], - ["./plugins/foo", "local"], - ["/opt/plugin", "local"], - ["plugin", "local"], - ["", "local"], - ["inline:ssh", "local"], - ["foo%.com/acme/plugin:v1", "local"], - ["foo%41.com/acme/plugin:v1", "local"] + [ + "ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", + "oci" + ], + [ + "ghcr.io/compliance-framework/plugin-local-ssh-policies:latest", + "oci" + ], + [ + "docker.io/library/alpine:3.20", + "oci" + ], + [ + "localhost:5000/plugin:v1", + "oci" + ], + [ + "registry.example.com:5000/a/b/c:1.2.3", + "oci" + ], + [ + "ghcr.io/x/y", + "local" + ], + [ + "ghcr.io/X/Y:v1", + "local" + ], + [ + "ghcr.io/x/y:", + "local" + ], + [ + "./plugins/foo", + "local" + ], + [ + "/opt/plugin", + "local" + ], + [ + "plugin", + "local" + ], + [ + "", + "local" + ], + [ + "inline:ssh", + "local" + ], + [ + "foo%.com/acme/plugin:v1", + "local" + ], + [ + "foo%41.com/acme/plugin:v1", + "local" + ] ], "schedules": { "valid": [ @@ -150,13 +258,11 @@ "TZ=UTC", "CRON_TZ=UTC", "TZ=", - "CRON_TZ=", - "TZ=utc 0 * * * *", - "CRON_TZ=europe/london 0 * * * *" + "CRON_TZ=" ] }, "applySafe": { - "_comment": "classify_test.go: whether an apply_safe host applies a change at `path` (Classify + WillApply), for the field-level prediction of field-access.ts. `state` is fieldAccess over that one host.", + "_comment": "classify_test.go applySafeCases: whether an apply_safe host applies a change at `path` (Classify + WillApply over the case's probe overlays), for the field-level prediction of field-access.ts. `state` is fieldAccess over that one host: editable (every probe applies), restricted (some do), readonly (none do).", "cases": [ { "name": "re-enable, untrusted source", @@ -173,20 +279,32 @@ }, { "name": "re-enable, trusted source", - "trusted": ["ghcr.io/trusted/*"], - "file": { "x": { "enabled": false, "source": "ghcr.io/trusted/p:v1" } }, + "trusted": [ + "ghcr.io/trusted/*" + ], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/trusted/p:v1" + } + }, "overlay": null, "path": "/plugins/x/enabled", "state": "editable" }, { "name": "re-enable keeps untrusted and local policies (TestClassifyReenableKeptParts)", - "trusted": ["ghcr.io/trusted/*"], + "trusted": [ + "ghcr.io/trusted/*" + ], "file": { "x": { "enabled": false, "source": "ghcr.io/trusted/p:v1", - "policies": ["ghcr.io/evil/pol:v9", "/tmp/local-policy"] + "policies": [ + "ghcr.io/evil/pol:v9", + "/tmp/local-policy" + ] } }, "overlay": null, @@ -195,12 +313,31 @@ }, { "name": "re-enable keeps ${env:} references (TestClassifyReenableKeptParts)", - "trusted": ["ghcr.io/trusted/*"], + "trusted": [ + "ghcr.io/trusted/*" + ], "file": { "x": { "enabled": false, "source": "ghcr.io/trusted/p:v1", - "config": { "token": "${env:DB_TOKEN}" } + "config": { + "token": "${env:DB_TOKEN}" + } + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "readonly" + }, + { + "name": "re-enable keeps a local plugin source (fp 2db275ed2d26)", + "trusted": [ + "ghcr.io/trusted/*" + ], + "file": { + "x": { + "enabled": false, + "source": "./bin/local-plugin" } }, "overlay": null, @@ -210,7 +347,11 @@ { "name": "disabling is data-only", "trusted": [], - "file": { "x": { "source": "ghcr.io/other/p:v1" } }, + "file": { + "x": { + "source": "ghcr.io/other/p:v1" + } + }, "overlay": null, "path": "/plugins/x/enabled", "state": "editable" @@ -219,8 +360,12 @@ "name": "a new source needs trusted_sources, but reusing one is already-used", "trusted": [], "file": { - "x": { "source": "ghcr.io/a/x:v1" }, - "y": { "source": "ghcr.io/a/y:v1" } + "x": { + "source": "ghcr.io/a/x:v1" + }, + "y": { + "source": "ghcr.io/a/y:v1" + } }, "overlay": null, "path": "/plugins/x/source", @@ -229,7 +374,12 @@ { "name": "a disabled plugin's sources are not already used", "trusted": [], - "file": { "x": { "enabled": false, "source": "ghcr.io/a/x:v1" } }, + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/a/x:v1" + } + }, "overlay": null, "path": "/plugins/x/source", "state": "readonly" @@ -237,7 +387,11 @@ { "name": "config key needs an overridable_config_flags entry", "trusted": [], - "file": { "local-ssh": { "source": "s" } }, + "file": { + "local-ssh": { + "source": "s" + } + }, "overlay": null, "path": "/plugins/local-ssh/config/host", "state": "readonly" @@ -245,11 +399,34 @@ { "name": "data-only fields", "trusted": [], - "file": { "local-ssh": { "source": "s" } }, + "file": { + "local-ssh": { + "source": "s" + } + }, "overlay": null, "path": "/plugins/local-ssh/policy_data/threshold", "state": "editable" } ] + }, + "pluginNames": { + "valid": [ + "a", + "0", + "local-ssh", + "ssh_tuned", + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk" + ], + "invalid": [ + "", + "GitHub", + "Ssh.Tuned", + "-a", + "_a", + "a.b", + "a b", + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl" + ] } }