feat(network): network exposure classifier + tests (ADR-107)

Pure functions consumed by the bind-time guard, settings UI, and MCP
initialize.instructions injector: classify(NetworkState) → Verdict,
resolveListenHost(), resolveBindAxis(), normalizeBindInput(),
classifyFromSettings(), agentInstructionsForVerdict(). Single source
of truth for "how exposed is the current network configuration?".

The 9-cell ADR-107 table maps (protocol × bind × certSource) → one of
🟢 ok / 🟡 warn / 🔴 jail. Custom-input normalization collapses
loopback aliases (127.x.x.x / localhost / ::1) and wildcard aliases
(0.0.0.0 / ::) to the corresponding explicit BindMode so the same
intent expressed three ways converges to one state.

40 unit tests cover all 9 table cells, all normalization paths, the
defensive empty-custom fallback, and the agent-instructions formatter.
This commit is contained in:
Aaron Bockelie 2026-05-24 16:55:50 -05:00
parent 5884ac492d
commit ef4f6c0814
2 changed files with 361 additions and 0 deletions

View file

@ -0,0 +1,178 @@
/**
* ADR-107: Network exposure modes as a classified state machine.
*
* Pure functions consumed by server-bind code, settings UI, and the
* MCP `initialize` instructions injector single source of truth for
* "how exposed is the current network configuration?"
*/
export type BindMode = 'loopback' | 'all' | 'custom';
export type Protocol = 'http' | 'https';
export type CertSource = 'self' | 'user';
export type ResolvedBind = 'loopback' | 'all' | 'custom-loopback' | 'custom-other';
export interface NetworkState {
protocol: Protocol;
bind: ResolvedBind;
certSource: CertSource;
}
export type VerdictClass = 'ok' | 'warn' | 'jail';
export interface Verdict {
class: VerdictClass;
reason: string;
}
const LOOPBACK_ALIASES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127.0.0.1']);
const WILDCARD_ALIASES = new Set(['0.0.0.0', '::']);
const LOOPBACK_PREFIXES = ['127.'];
function isLoopbackHost(host: string): boolean {
const h = host.trim().toLowerCase();
if (LOOPBACK_ALIASES.has(h)) return true;
return LOOPBACK_PREFIXES.some((p) => h.startsWith(p));
}
function isWildcardHost(host: string): boolean {
return WILDCARD_ALIASES.has(host.trim());
}
/**
* If the user typed a loopback alias or wildcard into the custom field,
* collapse it to the corresponding explicit mode. Returns the canonical
* mode + the host to persist (blank for non-custom).
*/
export function normalizeBindInput(
mode: BindMode,
customHost: string
): { mode: BindMode; customHost: string } {
if (mode !== 'custom') return { mode, customHost: '' };
const trimmed = customHost.trim();
if (trimmed === '') return { mode: 'custom', customHost: '' };
if (isLoopbackHost(trimmed)) return { mode: 'loopback', customHost: '' };
if (isWildcardHost(trimmed)) return { mode: 'all', customHost: '' };
return { mode: 'custom', customHost: trimmed };
}
/**
* Resolve settings to the actual host string passed to server.listen().
*/
export function resolveListenHost(mode: BindMode, customHost: string): string {
switch (mode) {
case 'all':
return '0.0.0.0';
case 'custom': {
const trimmed = customHost.trim();
return trimmed === '' ? '127.0.0.1' : trimmed;
}
case 'loopback':
default:
return '127.0.0.1';
}
}
/**
* Resolve settings to the bind axis used by classify().
* `custom` collapses to `custom-loopback` or `custom-other` based on
* whether the typed host is a loopback alias.
*/
export function resolveBindAxis(mode: BindMode, customHost: string): ResolvedBind {
if (mode === 'loopback') return 'loopback';
if (mode === 'all') return 'all';
const trimmed = customHost.trim();
if (trimmed === '' || isLoopbackHost(trimmed)) return 'custom-loopback';
return 'custom-other';
}
/**
* The full classification table from ADR-107.
*/
export function classify(state: NetworkState): Verdict {
const { protocol, bind, certSource } = state;
if (protocol === 'http') {
if (bind === 'loopback' || bind === 'custom-loopback') {
return {
class: 'ok',
reason: 'HTTP on loopback — traffic never leaves this machine.'
};
}
if (bind === 'all') {
return {
class: 'jail',
reason:
'HTTP bound to every interface. API key and vault content travel in cleartext to anyone on the network.'
};
}
return {
class: 'jail',
reason:
'HTTP bound to a non-loopback address. API key and vault content travel in cleartext on the wire.'
};
}
// protocol === 'https'
if (bind === 'loopback' || bind === 'custom-loopback') {
return {
class: 'ok',
reason: 'HTTPS on loopback — self-signed certificate is fine here.'
};
}
if (certSource === 'user') {
return {
class: 'ok',
reason:
bind === 'all'
? 'HTTPS on every interface with a user-supplied certificate — the intended public deployment.'
: 'HTTPS on a custom interface with a user-supplied certificate — the intended LAN/remote deployment.'
};
}
// self-signed on non-loopback
return {
class: 'warn',
reason:
bind === 'all'
? 'HTTPS on every interface with a self-signed certificate — encrypted but clients must trust-on-first-use.'
: 'HTTPS on a custom interface with a self-signed certificate — encrypted but clients must trust-on-first-use.'
};
}
/**
* Convenience: build NetworkState from settings + classify in one call.
*/
export function classifyFromSettings(args: {
httpsEnabled: boolean;
bindMode: BindMode;
customBindHost: string;
userSuppliedCert: boolean;
}): Verdict {
return classify({
protocol: args.httpsEnabled ? 'https' : 'http',
bind: resolveBindAxis(args.bindMode, args.customBindHost),
certSource: args.userSuppliedCert ? 'user' : 'self'
});
}
/**
* The agent-visible warning copy injected into MCP initialize.instructions
* when the verdict is `jail`. Returns null otherwise.
*/
export function agentInstructionsForVerdict(
verdict: Verdict,
resolvedHost: string,
port: number
): string | null {
if (verdict.class !== 'jail') return null;
return [
'SECURITY WARNING: This Obsidian MCP server is configured to serve vault contents',
`over an unencrypted network interface (${resolvedHost}:${port}). The API key and all`,
'document text travel in cleartext over the network. If the user did not intend this,',
'advise them to reconfigure the plugin to use HTTPS or to bind to loopback (127.0.0.1) only.'
].join(' ');
}

View file

@ -0,0 +1,183 @@
import {
classify,
classifyFromSettings,
resolveListenHost,
resolveBindAxis,
normalizeBindInput,
agentInstructionsForVerdict,
NetworkState
} from '../src/utils/network-classifier';
describe('classify() — 9-cell ADR-107 table', () => {
const cases: Array<{ name: string; state: NetworkState; expected: 'ok' | 'warn' | 'jail' }> = [
{ name: '1: http + loopback', state: { protocol: 'http', bind: 'loopback', certSource: 'self' }, expected: 'ok' },
{ name: '2: http + custom-other', state: { protocol: 'http', bind: 'custom-other', certSource: 'self' }, expected: 'jail' },
{ name: '3: http + all', state: { protocol: 'http', bind: 'all', certSource: 'self' }, expected: 'jail' },
{ name: '4: https + loopback + self', state: { protocol: 'https', bind: 'loopback', certSource: 'self' }, expected: 'ok' },
{ name: '5: https + loopback + user', state: { protocol: 'https', bind: 'loopback', certSource: 'user' }, expected: 'ok' },
{ name: '6: https + custom-other + self',state: { protocol: 'https', bind: 'custom-other', certSource: 'self' }, expected: 'warn' },
{ name: '7: https + custom-other + user',state: { protocol: 'https', bind: 'custom-other', certSource: 'user' }, expected: 'ok' },
{ name: '8: https + all + self', state: { protocol: 'https', bind: 'all', certSource: 'self' }, expected: 'warn' },
{ name: '9: https + all + user', state: { protocol: 'https', bind: 'all', certSource: 'user' }, expected: 'ok' }
];
for (const c of cases) {
test(c.name, () => {
const v = classify(c.state);
expect(v.class).toBe(c.expected);
expect(v.reason).toBeTruthy();
});
}
test('custom-loopback under http classifies as ok (not jail)', () => {
expect(classify({ protocol: 'http', bind: 'custom-loopback', certSource: 'self' }).class).toBe('ok');
});
test('custom-loopback under https + self classifies as ok (not warn)', () => {
expect(classify({ protocol: 'https', bind: 'custom-loopback', certSource: 'self' }).class).toBe('ok');
});
});
describe('normalizeBindInput()', () => {
test.each([
['127.0.0.1', 'loopback'],
['localhost', 'loopback'],
['LOCALHOST', 'loopback'],
['::1', 'loopback'],
['::ffff:127.0.0.1', 'loopback'],
['127.0.0.99', 'loopback'],
[' 127.0.0.1 ', 'loopback']
])('"%s" collapses to loopback mode', (input, expectedMode) => {
const out = normalizeBindInput('custom', input);
expect(out.mode).toBe(expectedMode);
expect(out.customHost).toBe('');
});
test.each([
['0.0.0.0', 'all'],
['::', 'all'],
[' 0.0.0.0', 'all']
])('"%s" collapses to all mode', (input, expectedMode) => {
const out = normalizeBindInput('custom', input);
expect(out.mode).toBe(expectedMode);
expect(out.customHost).toBe('');
});
test('arbitrary LAN address stays custom and is trimmed', () => {
const out = normalizeBindInput('custom', ' 192.168.1.50 ');
expect(out.mode).toBe('custom');
expect(out.customHost).toBe('192.168.1.50');
});
test('empty custom stays custom with empty host', () => {
expect(normalizeBindInput('custom', '')).toEqual({ mode: 'custom', customHost: '' });
expect(normalizeBindInput('custom', ' ')).toEqual({ mode: 'custom', customHost: '' });
});
test('non-custom modes blank the customHost', () => {
expect(normalizeBindInput('loopback', '192.168.1.5')).toEqual({ mode: 'loopback', customHost: '' });
expect(normalizeBindInput('all', '192.168.1.5')).toEqual({ mode: 'all', customHost: '' });
});
});
describe('resolveListenHost()', () => {
test('loopback → 127.0.0.1', () => {
expect(resolveListenHost('loopback', '')).toBe('127.0.0.1');
});
test('all → 0.0.0.0', () => {
expect(resolveListenHost('all', '')).toBe('0.0.0.0');
});
test('custom → trimmed customHost', () => {
expect(resolveListenHost('custom', ' 192.168.1.50 ')).toBe('192.168.1.50');
});
test('custom with empty host falls back to loopback (defensive)', () => {
expect(resolveListenHost('custom', '')).toBe('127.0.0.1');
expect(resolveListenHost('custom', ' ')).toBe('127.0.0.1');
});
});
describe('resolveBindAxis()', () => {
test('loopback mode → loopback axis', () => {
expect(resolveBindAxis('loopback', '')).toBe('loopback');
});
test('all mode → all axis', () => {
expect(resolveBindAxis('all', '')).toBe('all');
});
test('custom with empty host → custom-loopback (defensive)', () => {
expect(resolveBindAxis('custom', '')).toBe('custom-loopback');
});
test('custom with loopback alias → custom-loopback', () => {
expect(resolveBindAxis('custom', '127.0.0.1')).toBe('custom-loopback');
expect(resolveBindAxis('custom', 'localhost')).toBe('custom-loopback');
});
test('custom with LAN address → custom-other', () => {
expect(resolveBindAxis('custom', '192.168.1.50')).toBe('custom-other');
});
});
describe('classifyFromSettings()', () => {
test('default install (http + loopback) → ok', () => {
const v = classifyFromSettings({
httpsEnabled: false,
bindMode: 'loopback',
customBindHost: '',
userSuppliedCert: false
});
expect(v.class).toBe('ok');
});
test('http + all → jail', () => {
const v = classifyFromSettings({
httpsEnabled: false,
bindMode: 'all',
customBindHost: '',
userSuppliedCert: false
});
expect(v.class).toBe('jail');
});
test('https + all + self-signed → warn', () => {
const v = classifyFromSettings({
httpsEnabled: true,
bindMode: 'all',
customBindHost: '',
userSuppliedCert: false
});
expect(v.class).toBe('warn');
});
test('https + all + user cert → ok', () => {
const v = classifyFromSettings({
httpsEnabled: true,
bindMode: 'all',
customBindHost: '',
userSuppliedCert: true
});
expect(v.class).toBe('ok');
});
});
describe('agentInstructionsForVerdict()', () => {
test('returns null for ok', () => {
expect(agentInstructionsForVerdict({ class: 'ok', reason: 'x' }, '127.0.0.1', 3001)).toBeNull();
});
test('returns null for warn', () => {
expect(agentInstructionsForVerdict({ class: 'warn', reason: 'x' }, '0.0.0.0', 3443)).toBeNull();
});
test('returns warning string for jail, includes host and port', () => {
const s = agentInstructionsForVerdict({ class: 'jail', reason: 'x' }, '0.0.0.0', 3001);
expect(s).not.toBeNull();
expect(s).toContain('SECURITY WARNING');
expect(s).toContain('0.0.0.0:3001');
expect(s).toContain('cleartext');
});
});