SLOPSHOPPER

redact

Local credential protection. Host failure qualifications and coverage limitations apply.

newpanebandguardcommandprompt
v0.1.1MITupdated 2026-10-09milocosmopolitan/redacton
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redact
│ ┃ redact-config ✕ › fix the failing auth test and add an audit log call │ ┃ ⚠ Redacton OFF · credentials may reach │ ┃ Claude unchanged ⏺ Read(src/auth.ts) │ ┃ Configuration cfg-2-1 · defaults/defaults · ⎿ Read 6 lines │ ┃ custom rules 0. Built-in credentials remain ⏺ Update(src/auth.ts) │ ┃ enabled. Zero findings is not a safety ⎿ Added 2 lines, removed 1 line │ ┃ guarantee. ⏺ Read(/work/app/src/auth.ts) │ ┃ WARNING: local panel is not focused. Do not ⎿ Denied by redact: REDACTON_UNAVAILABLE │ ┃ type patterns into the composer. Focus this │ ┃ panel before editing. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Local declarative patterns only. Never enter │ ┃ a credential or exact secret. Nothing is ✻ Worked for 42s · done 4:20 PM │ ┃ saved until an explicit Save. │ ┃ INVALID_SETTINGS_RESPONSE › /redacton │ ┃ [ Add rule ][ Remove rule ][ Revert ][ Cance ⎿ redact: Redacton ON. Protection unavailable; selected content wi │ ┃ │ ┃ Editing any inherited custom rule creates a │ ┃ session override. Personal and project files │ ┃ stay unchanged until explicit scoped Save. │ ┃ Personal defaults restore at session │ ┃ boundaries. Project settings require │ ┃ explicit load and trust. Precedence: session │ ┃ override, trusted project, personal, │ ┃ defaults. Project trust lasts this session. │ ┃ [ Load personal ][ Load project ] │ ┃ [ Save personal ][ Save project ] ⚠ Redacton OFF — credential protection disabled ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚠ Redacton OFF — credential protection disabled
Pane · redact-config
⚠ Redacton OFF · credentials may reach Claude unchanged Configuration cfg-2-1 · defaults/defaults · custom rules 0. Built-in credentials remain enabled. Zero findings is not a safety guarantee. WARNING: local panel is not focused. Do not type patterns into the composer. Focus this panel before editing. Local declarative patterns only. Never enter a credential or exact secret. Nothing is saved until an explicit Save. INVALID_SETTINGS_RESPONSE [ Add rule ][ Remove rule ][ Revert ][ Cancel / close ] Editing any inherited custom rule creates a session override. Personal and project files stay unchanged until explicit scoped Save. Personal defaults restore at session boundaries. Project settings require explicit load and trust. Precedence: session override, trusted project, personal, defaults. Project trust lasts this session. [ Load personal ][ Load project ] [ Save personal ][ Save project ] [ Reset personal ][ Reset project ] Portable transfer files contain rule definitions only. Export may reveal internal names or prefixes. Review before sharing; no history, OFF state, matched input is exported. Never put actual credentials in rule fields; unknown sensitive values cannot always be recognized. [ Import personal ][ Import project ] [ Export personal transfer file ][ Export project transfer f No pending draft [ Validate ][ Synthetic preview ][ Apply session ][ Reset se
README

Redacton

MIT License TypeScript Claude Code Powered by Redact Secret

Keep recognized credentials out of supported Claude Code model inputs. Redacton scans prompt text/context, Read text, and Bash stdout/stderr, replacing detected credentials with placeholders. Recognized private keys block the entire event.

Redacton targets macOS, Linux and Windows CLI installations with one shared Mod/helper implementation. The published verified release remains macOS ARM64, Claude Code 2.1.294, Node 22.16.0. Portable packages, Unix/PowerShell candidate installers and CI are development paths until each platform's actual host is qualified. See the target and verified matrix.

Powered by Redact Secret, a deterministic Rust detection engine with native and WebAssembly runtimes. Scanning runs locally, without sending content to a detection service. Explore Redact Secret for local redaction in your own apps, and star the project to support it.

Install in two minutes

This command installs the existing verified macOS Apple Silicon release, not a universal cross-platform package.

Have Claude Code and Node.js 22 installed on macOS Apple Silicon. The verified combination is Claude Code 2.1.294 and Node 22.16.0; other platforms, host versions and Desktop have not been qualified. Required follow-on support targets macOS x64, Linux x64/ARM64 and Windows x64/WSL; those targets are not supported by this installer.

curl -fsSL https://raw.githubusercontent.com/milocosmopolitan/redacton/main/scripts/install.sh | bash
claude --plugin-dir "$HOME/.local/share/redacton/current"

The installer downloads the published package, verifies its pinned checksum, and installs it without npm or a build step. You can inspect the installer or download packages from Releases.

The --plugin-dir option loads Redacton for that Claude Code launch. Use the same launch command for future sessions.

Linux, Intel Mac, native Windows and WSL users should follow the candidate installation and qualification guidance. Native Windows and WSL are separate environments; Desktop is a separate host surface. A helper/installer success does not establish protection of model inputs.

Use it

After launch, run /redacton and confirm Protect ready before your first task.

New, resumed and branched CLI sessions start with protection requested ON. Readiness is shown separately as loading, ready or unavailable.

CommandEffect
/redactonRequest protection for subsequent supported operations.
/redactoffBypass scans for subsequent operations in this session.
/redact:statusShow cached readiness, coverage, rules and recent outcomes.
/redact:config, /redact:add-rule, /redact:remove-ruleManage custom formats through local forms.

Commands take no arguments. Never paste secrets, patterns or test text into slash-command arguments; the host can store them before local rejection. Running operations keep the policy they started with. OFF displays a prompt-area warning: ⚠ Redacton OFF — credential protection disabled.

Custom rules use guided token formats or assignment names. Validate, inspect synthetic sample outcomes, then explicitly Apply session; personal/project saving and portable import/export are separate actions. Confirm the form has keyboard focus before typing, and close it before ordinary chat. Never enter actual credential values as rule definitions. See configuration and scope.

Know the boundaries

Protection depends on the functioning host and outer guard. Unsupported selected results and scanner failures withhold content under those guard conditions; a host that bypasses all guards can expose it.

Original prompts and tool arguments can remain in host storage. Encoded credentials can be missed, and zero findings does not mean safe content. MCP, other tools, tool arguments, binary/image/audio content, PII and existing history are outside coverage. Withholding output does not undo a tool's effects.

See compatibility and evidence and the threat model for exact limits. For vulnerabilities, use private security reporting; public issues are not confidential.

Contributing · Security policy · Code of conduct · Dependency notices · MIT license

Source 11 files
mod/index.tsx 1590 lines
1import type {
2  EngineInterface,
3  On,
4  ToolCallInput,
5  ToolCallResult,
6} from 'claude-code';
7import type { TrustedToolResult } from './adapters/text.ts';
8import {
9  extractPrompt,
10  extractToolResult,
11  rebuildPrompt,
12  rebuildToolResult,
13} from './adapters/text.ts';
14import {
15  type ActiveConfiguration,
16  ConfigController,
17  effectiveConfiguration,
18  removeRule,
19  sharedNamesAction,
20  type TokenRule,
21} from './config.ts';
22import {
23  candidateConfiguration,
24  candidateDocument,
25  freshForm,
26  populateForm,
27  type RuleForm,
28} from './form.ts';
29import type { HelperRequest, HelperResponse } from './protocol.ts';
30import {
31  LIMITS,
32  makeRequest,
33  POLICY_ID,
34  utf8Bytes,
35  validateProcessResponse,
36} from './protocol.ts';
37import { ImportReviewController, SavedSettingsController } from './settings.ts';
38import type { OperationSnapshot, SessionState } from './state.ts';
39import {
40  createSessionState,
41  record,
42  requestProtection,
43  setReadiness,
44  snapshot,
45} from './state.ts';
46import {
47  type StorageRequest,
48  type StorageResponse,
49  type TransferResponse,
50  validateStorageResponse,
51  validateTransferResponse,
52} from './storage.ts';
53import { removalView, statusView } from './view.ts';
54
55interface Runtime {
56  sequence: number;
57  pending: number;
58  observedAt: number | null;
59  personalLoad: 'pending' | 'ready' | 'failed';
60  settingsCode: string;
61  generation: number;
62  personalPromise: Promise<void> | null;
63}
64interface OperationSlot {
65  captured: OperationSnapshot;
66  config: ActiveConfiguration;
67  controller: SessionState;
68  trusted: TrustedToolResult | null;
69}
70
71// Child input is stdin only; scanner imports never enter the Mod runtime.
72async function runHelper(
73  $: EngineInterface,
74  controller: SessionState,
75  request: HelperRequest,
76  signal: AbortSignal | undefined,
77  runtime: Runtime,
78): Promise<HelperResponse> {
79  if (signal?.aborted) return { status: 'failed', errorCode: 'CANCELLED' };
80  const payload = JSON.stringify(request);
81  if (utf8Bytes(payload) > LIMITS.inputBytes)
82    return { status: 'failed', errorCode: 'INPUT_LIMIT' };
83  if (runtime.pending >= LIMITS.pending)
84    return { status: 'failed', errorCode: 'QUEUE_SATURATED' };
85  runtime.pending += 1;
86  try {
87    const result = await $.process.run(
88      ['node', `${$.plugin.root}/helper/dist/index.js`],
89      { stdin: payload, timeoutMs: LIMITS.timeoutMs },
90    );
91    if (signal?.aborted) return { status: 'failed', errorCode: 'CANCELLED' };
92    const response = validateProcessResponse(result, request);
93    if (
94      response.status === 'failed' &&
95      (request.operation === 'sanitize' || request.operation === 'self-check')
96    ) {
97      runtime.observedAt = Date.now();
98      setReadiness(controller, 'unavailable');
99      $.ui.invalidate('ui.render');
100    }
101    return response;
102  } catch {
103    if (
104      request.operation === 'sanitize' ||
105      request.operation === 'self-check'
106    ) {
107      setReadiness(controller, 'unavailable');
108      runtime.observedAt = Date.now();
109      $.ui.invalidate('ui.render');
110    }
111    return { status: 'failed', errorCode: 'HELPER_UNAVAILABLE' };
112  } finally {
113    runtime.pending -= 1;
114  }
115}
116
117async function checkReadiness(
118  $: EngineInterface,
119  controller: SessionState,
120  runtime: Runtime,
121  config: ActiveConfiguration,
122): Promise<void> {
123  if (!controller.requestedProtection || runtime.personalLoad !== 'ready')
124    return;
125  const generation = runtime.generation;
126  setReadiness(controller, 'loading');
127  const response = await runHelper(
128    $,
129    controller,
130    {
131      protocolVersion: 2,
132      config,
133      requestId: `check-${++runtime.sequence}`,
134      operation: 'self-check',
135      policyId: POLICY_ID,
136    },
137    undefined,
138    runtime,
139  );
140  if (runtime.generation !== generation) return;
141  setReadiness(controller, response.status === 'ok' ? 'ready' : 'unavailable');
142  runtime.observedAt = Date.now();
143  $.ui.invalidate('ui.render');
144}
145
146async function runSettings(
147  $: EngineInterface,
148  request: StorageRequest,
149  runtime: Runtime,
150): Promise<StorageResponse> {
151  const payload = JSON.stringify(request);
152  if (utf8Bytes(payload) > LIMITS.inputBytes)
153    return { ok: false, code: 'INPUT_LIMIT' };
154  if (runtime.pending >= LIMITS.pending)
155    return { ok: false, code: 'QUEUE_SATURATED' };
156  runtime.pending += 1;
157  try {
158    const result = await $.process.run(
159      ['node', `${$.plugin.root}/helper/dist/index.js`],
160      { stdin: payload, timeoutMs: LIMITS.timeoutMs },
161    );
162    return validateStorageResponse(result, request);
163  } catch {
164    return { ok: false, code: 'SETTINGS_UNAVAILABLE' };
165  } finally {
166    runtime.pending -= 1;
167  }
168}
169
170async function runTransfer(
171  $: EngineInterface,
172  request: StorageRequest,
173  runtime: Runtime,
174): Promise<TransferResponse> {
175  const payload = JSON.stringify(request);
176  if (utf8Bytes(payload) > LIMITS.inputBytes)
177    return { ok: false, code: 'INPUT_LIMIT' };
178  if (runtime.pending >= LIMITS.pending)
179    return { ok: false, code: 'QUEUE_SATURATED' };
180  runtime.pending += 1;
181  try {
182    const result = await $.process.run(
183      ['node', `${$.plugin.root}/helper/dist/index.js`],
184      { stdin: payload, timeoutMs: LIMITS.timeoutMs },
185    );
186    return validateTransferResponse(result, request);
187  } catch {
188    return { ok: false, code: 'SETTINGS_UNAVAILABLE' };
189  } finally {
190    runtime.pending -= 1;
191  }
192}
193
194async function loadPersonal(
195  $: EngineInterface,
196  controller: SessionState,
197  config: ConfigController,
198  settings: SavedSettingsController,
199  runtime: Runtime,
200): Promise<void> {
201  if (!controller.requestedProtection || runtime.personalLoad !== 'pending')
202    return;
203  const generation = runtime.generation;
204  const initial = config.snapshot();
205  const response = await runSettings(
206    $,
207    {
208      protocolVersion: 2,
209      requestId: `personal-${++runtime.sequence}`,
210      operation: 'load-config',
211      policyId: POLICY_ID,
212      storage: { scope: 'personal', approved: true },
213    },
214    runtime,
215  );
216  if (runtime.generation !== generation) return;
217  if (response.ok) {
218    settings.observe(response.settings);
219    if (initial.scope !== 'session')
220      config.replaceApproved(
221        response.settings.document,
222        'personal',
223        initial.revision,
224      );
225    runtime.personalLoad = 'ready';
226    runtime.settingsCode = '';
227  } else {
228    runtime.personalLoad = 'failed';
229    runtime.settingsCode = response.code;
230    setReadiness(controller, 'unavailable');
231    runtime.observedAt = Date.now();
232  }
233  $.ui.invalidate('ui.render');
234}
235
236async function ensurePersonal(
237  $: EngineInterface,
238  controller: SessionState,
239  config: ConfigController,
240  settings: SavedSettingsController,
241  runtime: Runtime,
242): Promise<void> {
243  if (runtime.personalPromise) {
244    await runtime.personalPromise;
245    return;
246  }
247  if (runtime.personalLoad !== 'pending' || !controller.requestedProtection)
248    return;
249  const work = loadPersonal($, controller, config, settings, runtime);
250  runtime.personalPromise = work;
251  try {
252    await work;
253  } finally {
254    if (runtime.personalPromise === work) runtime.personalPromise = null;
255  }
256}
257
258async function focusLocal(
259  $: EngineInterface,
260  target: { requestId: string; key: string },
261): Promise<boolean> {
262  try {
263    const response = await $.ui.focus(target);
264    return !response.deny;
265  } catch {
266    return false;
267  }
268}
269
270function operationKey(event: {
271  tool_use_id?: unknown;
272  agentId?: unknown;
273  tool?: unknown;
274}): string | null {
275  if (
276    typeof event.tool_use_id !== 'string' ||
277    !event.tool_use_id.length ||
278    (event.agentId !== undefined && typeof event.agentId !== 'string')
279  )
280    return null;
281  return JSON.stringify([event.agentId ?? null, event.tool, event.tool_use_id]);
282}
283
284async function sanitizeSelectedTool<E extends ToolCallInput>(
285  $: EngineInterface,
286  e: E,
287  next: ((event: E) => Promise<ToolCallResult>) & {
288    readonly signal: AbortSignal;
289  },
290  slots: Map<string, OperationSlot>,
291  runtime: Runtime,
292): Promise<ToolCallResult> {
293  const key = operationKey(e);
294  const slot = key === null ? undefined : slots.get(key);
295  // OFF bypass is associated by the outer operation, not the current toggle.
296  if (!slot) return { deny: 'REDACTON_WITHHELD' };
297  if (!slot.captured.requestedProtection) return next(e);
298  const answer = await next(e);
299  if (answer.deny) {
300    slot.trusted = { deny: 'REDACTON_TOOL_DENIED' };
301    return slot.trusted;
302  }
303  const extraction = extractToolResult(e.tool, answer);
304  if (extraction.status !== 'ok') {
305    slot.trusted = { deny: 'REDACTON_UNSUPPORTED_SHAPE' };
306    return slot.trusted;
307  }
308  const request = makeRequest(
309    `tool-${++runtime.sequence}`,
310    extraction.segments,
311    slot.config,
312  );
313  if (!request) {
314    slot.trusted = { deny: 'REDACTON_INPUT_LIMIT' };
315    return slot.trusted;
316  }
317  const response = await runHelper(
318    $,
319    slot.controller,
320    request,
321    next.signal,
322    runtime,
323  );
324  if (response.status !== 'ok' || next.signal?.aborted) {
325    slot.trusted = { deny: 'REDACTON_WITHHELD' };
326    return slot.trusted;
327  }
328  const trusted = rebuildToolResult(extraction, response.segments);
329  if ('deny' in trusted) return trusted;
330  record(slot.controller, { errorCode: 'SCANNED', count: response.count });
331  $.ui.invalidate('ui.render');
332  slot.trusted = trusted;
333  return trusted;
334}
335
336function freshFormWithNotice(notice: string): RuleForm {
337  const form = freshForm();
338  form.notice = notice;
339  return form;
340}
341
342export function register(on: On) {
343  let state = createSessionState('initial');
344  const runtime: Runtime = {
345    sequence: 0,
346    pending: 0,
347    observedAt: null,
348    personalLoad: 'pending',
349    settingsCode: '',
350    generation: 0,
351    personalPromise: null,
352  };
353  let config = new ConfigController();
354  let form: RuleForm = freshForm();
355  let paneOpen = false;
356  const settings = new SavedSettingsController();
357  const imports = new ImportReviewController();
358  const slots = new Map<string, OperationSlot>();
359
360  on('session.start', async ($, e, next) => {
361    paneOpen = false;
362    state = createSessionState(await $.session.id());
363    config = new ConfigController();
364    settings.resetSession();
365    imports.cancel();
366    runtime.observedAt = null;
367    runtime.generation += 1;
368    runtime.personalPromise = null;
369    runtime.personalLoad = 'pending';
370    runtime.settingsCode = '';
371    await $.command.register({
372      name: 'redacton',
373      description: 'Request local credential protection for new operations',
374    });
375    await $.command.register({
376      name: 'redactoff',
377      description:
378        'Disable credential protection for new operations in this session',
379    });
380    await ensurePersonal($, state, config, settings, runtime);
381    await checkReadiness($, state, runtime, config.snapshot());
382    return next(e);
383  }).catch((_$, e, next) => next(e));
384
385  // The host does not reload this module on restore/branch; clear OFF at session.end.
386  on('session.end', ($, e, next) => {
387    config = new ConfigController();
388    settings.resetSession();
389    imports.cancel();
390    runtime.observedAt = null;
391    runtime.generation += 1;
392    runtime.personalPromise = null;
393    runtime.personalLoad = 'pending';
394    runtime.settingsCode = '';
395    paneOpen = false;
396    form = freshForm();
397    $.ui.close({ id: 'redact-config' });
398    state = createSessionState(`reset-${++runtime.sequence}`);
399    return next(e);
400  });
401
402  on('command.run', { command: 'redactoff' }, ($, e) => {
403    if (e.args?.trim()) return { text: 'Redacton commands take no arguments.' };
404    requestProtection(state, false);
405    $.ui.invalidate('ui.render');
406    return {
407      text: 'Warning: Redacton is OFF. Credentials may reach Claude unchanged.',
408    };
409  }).catch(() => ({ text: 'REDACTON_COMMAND_UNAVAILABLE' }));
410
411  on('command.run', { command: 'redacton' }, async ($, e) => {
412    if (e.args?.trim()) return { text: 'Redacton commands take no arguments.' };
413    requestProtection(state, true);
414    $.ui.invalidate('ui.render');
415    await ensurePersonal($, state, config, settings, runtime);
416    await checkReadiness($, state, runtime, config.snapshot());
417    return {
418      text:
419        state.readiness === 'ready'
420          ? 'Redacton ON. Protect ready. Partial coverage: supported prompt text, Read text, Bash stdout/stderr.'
421          : 'Redacton ON. Protection unavailable; selected content will be withheld.',
422    };
423  }).catch(() => ({
424    text: 'Redacton ON. Protection unavailable; selected content will be withheld.',
425  }));
426
427  on(
428    'command.run',
429    {
430      command: [
431        'redact:status',
432        'redact:config',
433        'redact:add-rule',
434        'redact:remove-rule',
435      ],
436    },
437    async ($, e) => {
438      if (e.args.trim())
439        return {
440          text: 'Redacton local commands take no arguments. Never put credentials in slash commands.',
441        };
442      if (e.command === 'redact:status') {
443        const view = statusView(state, config.snapshot(), runtime.observedAt);
444        return {
445          text: `Redacton ${view.protection} · Protect ${view.readiness}. Saved settings: ${runtime.settingsCode || runtime.personalLoad}. Cached observation: ${view.observedAt === null ? 'not observed' : new Date(view.observedAt).toISOString()}. This is not a health check.\nConfiguration ${view.revision} · source ${view.source} · scope ${view.scope} · ${view.customRuleCount} custom rules: ${view.ruleIds.join(', ') || 'none'}.\nSupported: prompt text/context, Read text, Bash stdout/stderr. Excluded: other tools/MCP, tool arguments, binary/image/audio, PII, existing history. Zero findings does not mean safe content.\nRecent: ${view.recent.map((item) => `${item.code}:${item.count}`).join(', ') || 'none'}`,
446        };
447      }
448      imports.cancel();
449      form = freshForm();
450      form.mode =
451        e.command === 'redact:add-rule'
452          ? 'add'
453          : e.command === 'redact:remove-rule'
454            ? 'remove'
455            : 'config';
456      await $.prompt.fill({ text: '', mode: 'replace' });
457      const placement = await $.ui.open({
458        id: 'redact-config',
459        title: 'Redacton local configuration',
460        focus: true,
461        closeOnEscape: true,
462        rows: 32,
463      });
464      paneOpen = placement.isPlaced;
465      if (!placement.isPlaced) await $.ui.close({ id: 'redact-config' });
466      if (placement.isPlaced)
467        await focusLocal($, {
468          requestId: 'redact-config',
469          key:
470            form.mode === 'add'
471              ? 'rule-id'
472              : form.mode === 'remove' && config.snapshot().rules[0]
473                ? `remove-${config.snapshot().rules[0]?.id}`
474                : 'edit-add',
475        });
476      return {
477        text: placement.isPlaced
478          ? 'Redacton local panel opened. No credentials or exact secret values. Changes require validation, synthetic preview, then Apply.'
479          : 'Redacton local panel unavailable on this surface. Configuration unchanged.',
480      };
481    },
482  ).catch(() => ({ text: 'REDACTON_LOCAL_COMMAND_UNAVAILABLE' }));
483
484  on('ui.close', ($, e, next) => {
485    if (e.id !== 'redact-config') return next(e);
486    if (form.durableBusy && e.origin.kind !== 'unload') {
487      form.notice =
488        'Operation in progress. Wait for completion; a durable write may already commit.';
489      $.ui.invalidate('ui.render');
490      return { value: undefined };
491    }
492    const draft = config.currentDraft();
493    if (draft) config.cancel(draft.token);
494    imports.cancel();
495    paneOpen = false;
496    form = freshForm();
497    return next(e);
498  });
499
500  on(
501    'ui.render',
502    { component: 'Pane', requestId: 'redact-config', surface: 'terminal' },
503    ($, e) => {
504      const { Box, Text, Input, Button } = $.ui.resolve(e);
505      const owner = config;
506      const formOwner = form;
507      const sessionOwner = state;
508      const active = owner.snapshot();
509      const draft = owner.currentDraft();
510      const update = () => $.ui.invalidate('ui.render');
511      const changed = () => {
512        if (!validOwner()) return;
513        if (draft) owner.cancel(draft.token);
514        form.notice = '';
515        update();
516      };
517      const close = async () => {
518        if (!validOwner()) return;
519        if (form.durableBusy) {
520          form.notice =
521            'Operation in progress. Wait for completion; a durable save may already commit.';
522          update();
523          return;
524        }
525        if (draft) owner.cancel(draft.token);
526        form = freshForm();
527        try {
528          await $.ui.close({ id: 'redact-config' });
529          paneOpen = false;
530          imports.cancel();
531          form = freshForm();
532        } catch {
533          form.notice = 'LOCAL_PANEL_CLOSE_UNAVAILABLE';
534          update();
535        }
536      };
537      const validOwner = () =>
538        owner === config &&
539        sessionOwner === state &&
540        formOwner === form &&
541        paneOpen;
542      return (
543        <Box flexDirection="column">
544          <Text>
545            {!state.requestedProtection
546              ? '⚠ Redacton OFF · credentials may reach Claude unchanged'
547              : `Redacton ON · Protect ${state.readiness} · Partial coverage`}
548          </Text>
549          <Text>{`Configuration ${active.revision} · ${active.source}/${active.scope} · custom rules ${active.rules.length}. Built-in credentials remain enabled. Zero findings is not a safety guarantee.`}</Text>
550          <Text>
551            {e.props.isFocused
552              ? 'Local panel has keyboard focus. Type only in labeled fields.'
553              : 'WARNING: local panel is not focused. Do not type patterns into the composer. Focus this panel before editing.'}
554          </Text>
555          <Text>
556            Local declarative patterns only. Never enter a credential or exact
557            secret. Nothing is saved until an explicit Save.
558          </Text>
559          <Text>{runtime.settingsCode}</Text>
560          <Box>
561            <Button
562              key="edit-add"
563              onPress={() => {
564                if (!validOwner()) return;
565                form = freshForm();
566                form.mode = 'add';
567                update();
568              }}
569            >
570              Add rule
571            </Button>
572            <Button
573              key="edit-remove"
574              onPress={async () => {
575                if (!validOwner()) return;
576                form = freshForm();
577                form.mode = 'remove';
578                update();
579                const first = active.rules[0];
580                if (first)
581                  await focusLocal($, {
582                    requestId: 'redact-config',
583                    key: `remove-${first.id}`,
584                  });
585              }}
586            >
587              Remove rule
588            </Button>
589            <Button
590              key="revert"
591              onPress={async () => {
592                if (!validOwner() || form.busy) return;
593                const result = owner.revert(active.revision);
594                form.notice = result.ok
595                  ? 'Reverted to previous configuration.'
596                  : result.code;
597                if (result.ok && state.requestedProtection)
598                  await checkReadiness($, sessionOwner, runtime, result.config);
599                if (!validOwner()) return;
600                update();
601              }}
602            >
603              Revert
604            </Button>
605            <Button key="cancel" onPress={close}>
606              Cancel / close
607            </Button>
608          </Box>
609          <Text>{form.notice}</Text>
610          <Text>
611            Editing any inherited custom rule creates a session override.
612            Personal and project files stay unchanged until explicit scoped
613            Save.
614          </Text>
615          <Box>
616            {active.rules.map((rule) => (
617              <Button
618                key={`edit-${rule.id}`}
619                onPress={async () => {
620                  if (!validOwner() || form.busy) return;
621                  const pending = owner.currentDraft();
622                  if (pending) owner.cancel(pending.token);
623                  form = populateForm(rule);
624                  form.notice = `Editing custom rule ${rule.id} as a session override. Validate, preview, Apply.`;
625                  update();
626                  await focusLocal($, {
627                    requestId: 'redact-config',
628                    key: 'rule-id',
629                  });
630                }}
631              >{`Edit ${rule.id}`}</Button>
632            ))}
633          </Box>
634          {form.mode === 'add' ? (
635            <Box flexDirection="column">
636              <Button
637                key="kind"
638                onPress={() => {
639                  if (!validOwner()) return;
640                  form.kind = form.kind === 'token' ? 'names' : 'token';
641                  form.namesGroupAction = false;
642                  if (form.kind === 'names')
643                    form.action = sharedNamesAction(active);
644                  changed();
645                }}
646              >{`Kind: ${form.kind}`}</Button>
647              <Input
648                key="rule-id"
649                label="Rule ID"
650                value={form.id}
651                autoFocus
652                onInput={(value) => {
653                  if (!validOwner()) return;
654                  form.id = value;
655                  changed();
656                }}
657                onSubmit={async (value) => {
658                  if (!validOwner()) return;
659                  form.id = value;
660                  changed();
661                  update();
662                  await focusLocal($, {
663                    requestId: 'redact-config',
664                    key: form.kind === 'token' ? 'prefix' : 'names',
665                  });
666                }}
667              />
668              <Button
669                key="action"
670                onPress={() => {
671                  if (!validOwner()) return;
672                  form.action = form.action === 'redact' ? 'block' : 'redact';
673                  if (form.kind === 'names') form.namesGroupAction = true;
674                  changed();
675                }}
676              >
677                {form.kind === 'names'
678                  ? `Action for ALL assignment-name rules: ${form.action}`
679                  : `Action: ${form.action}`}
680              </Button>
681              {form.kind === 'token' ? (
682                <Box flexDirection="column">
683                  <Input
684                    key="prefix"
685                    label="Public prefix, 3–64 bytes"
686                    value={form.prefix}
687                    onInput={(value) => {
688                      if (!validOwner()) return;
689                      form.prefix = value;
690                      changed();
691                    }}
692                    onSubmit={async (value) => {
693                      if (!validOwner()) return;
694                      form.prefix = value;
695                      changed();
696                      update();
697                      await focusLocal($, {
698                        requestId: 'redact-config',
699                        key: 'length',
700                      });
701                    }}
702                  />
703                  <Button
704                    key="alphabet"
705                    onPress={() => {
706                      if (!validOwner()) return;
707                      const alphabets: readonly TokenRule['alphabet'][] = [
708                        'alnum',
709                        'alnum-dash',
710                        'alnum-dash-dot',
711                        'upper-alnum',
712                        'digit',
713                        'lower-hex',
714                        'base64-body',
715                      ];
716                      form.alphabet =
717                        alphabets[
718                          (alphabets.indexOf(form.alphabet) + 1) %
719                            alphabets.length
720                        ] ?? 'alnum';
721                      changed();
722                    }}
723                  >{`Alphabet: ${form.alphabet}`}</Button>
724                  <Button
725                    key="run-kind"
726                    onPress={() => {
727                      if (!validOwner()) return;
728                      form.runKind =
729                        form.runKind === 'exact' ? 'at-least' : 'exact';
730                      changed();
731                    }}
732                  >{`Length mode: ${form.runKind}`}</Button>
733                  <Input
734                    key="length"
735                    label="Run length, 1–4096"
736                    value={form.length}
737                    onInput={(value) => {
738                      if (!validOwner()) return;
739                      form.length = value;
740                      changed();
741                    }}
742                    onSubmit={async (value) => {
743                      if (!validOwner()) return;
744                      form.length = value;
745                      changed();
746                      update();
747                      await focusLocal($, {
748                        requestId: 'redact-config',
749                        key: 'build',
750                      });
751                    }}
752                  />
753                  <Button
754                    key="specificity"
755                    onPress={() => {
756                      if (!validOwner()) return;
757                      form.specificity =
758                        form.specificity === 'contextual'
759                          ? 'entropy'
760                          : 'contextual';
761                      changed();
762                    }}
763                  >{`Specificity: ${form.specificity}`}</Button>
764                  <Button
765                    key="validator"
766                    onPress={() => {
767                      if (!validOwner()) return;
768                      form.validator =
769                        form.validator === 'none'
770                          ? 'trailing-lower-hex'
771                          : 'none';
772                      changed();
773                    }}
774                  >{`Validator: ${form.validator}`}</Button>
775                </Box>
776              ) : (
777                <Box flexDirection="column">
778                  <Input
779                    key="names"
780                    label="Assignment names, comma-separated (max 32)"
781                    value={form.names}
782                    onInput={(value) => {
783                      if (!validOwner()) return;
784                      form.names = value;
785                      changed();
786                    }}
787                    onSubmit={async (value) => {
788                      if (!validOwner()) return;
789                      form.names = value;
790                      changed();
791                      update();
792                      await focusLocal($, {
793                        requestId: 'redact-config',
794                        key: 'build',
795                      });
796                    }}
797                  />
798                  <Text>
799                    Names share one action across all name rules. Names apply
800                    only to contextual assignments. A name is not an exact
801                    value.
802                  </Text>
803                </Box>
804              )}
805              <Button
806                key="build"
807                onPress={async () => {
808                  if (!validOwner()) return;
809                  const document = candidateDocument(active, form);
810                  if (!document) {
811                    form.notice = 'INVALID_CANDIDATE';
812                    update();
813                    return;
814                  }
815                  owner.begin(document);
816                  form.notice = form.editingId
817                    ? 'Atomic edit draft ready. Validate with the pinned core parser.'
818                    : 'Draft ready. Validate with the pinned core parser.';
819                  update();
820                  await focusLocal($, {
821                    requestId: 'redact-config',
822                    key: 'validate',
823                  });
824                }}
825              >
826                Create draft
827              </Button>
828            </Box>
829          ) : null}
830          {form.mode === 'remove' ? (
831            <Box flexDirection="column">
832              {active.rules.map((rule) => {
833                const impact = removalView(active, rule.id);
834                return (
835                  <Box key={rule.id}>
836                    <Text>{`${rule.id} · ${rule.kind} · ${rule.action} · ${active.source}/${active.scope}`}</Text>
837                    <Button
838                      key={`remove-${rule.id}`}
839                      onPress={async () => {
840                        if (!validOwner()) return;
841                        if (!impact?.removable) {
842                          form.notice =
843                            'Inherited rule is read-only. Choose Copy to session to deliberately override.';
844                          update();
845                          return;
846                        }
847                        const document = removeRule(active, rule.id);
848                        if (document) {
849                          owner.begin(document);
850                          form.notice =
851                            'Removal draft. Other rules and built-ins may still match. Validate, preview, then Apply.';
852                          update();
853                          await focusLocal($, {
854                            requestId: 'redact-config',
855                            key: 'validate',
856                          });
857                        }
858                      }}
859                    >
860                      Select removal
861                    </Button>
862                  </Box>
863                );
864              })}
865              <Button
866                key="copy-session"
867                onPress={() => {
868                  if (!validOwner()) return;
869                  owner.begin(active, 'session');
870                  form.notice =
871                    'Explicit session copy. Validate, preview, Apply, then select removal.';
872                  update();
873                }}
874              >
875                Copy to session override
876              </Button>
877            </Box>
878          ) : null}
879          <Text>
880            Personal defaults restore at session boundaries. Project settings
881            require explicit load and trust. Precedence: session override,
882            trusted project, personal, defaults. Project trust lasts this
883            session.
884          </Text>
885          <Box>
886            {(['personal', 'project'] as const).map((scope) => (
887              <Button
888                key={`load-${scope}`}
889                onPress={async () => {
890                  if (!validOwner() || form.busy) return;
891                  if (!state.requestedProtection) {
892                    form.notice = 'TURN_ON_TO_LOAD';
893                    update();
894                    return;
895                  }
896                  form.busy = true;
897                  const storage =
898                    scope === 'project'
899                      ? {
900                          scope,
901                          approved: false,
902                          projectRoot: await $.session.root(),
903                        }
904                      : { scope, approved: true };
905                  const response = await runSettings(
906                    $,
907                    {
908                      protocolVersion: 2,
909                      requestId: `load-${++runtime.sequence}`,
910                      operation: 'load-config',
911                      policyId: POLICY_ID,
912                      storage,
913                    },
914                    runtime,
915                  );
916                  if (validOwner()) {
917                    if (response.ok) {
918                      settings.observe(response.settings);
919                      if (scope === 'personal') {
920                        runtime.personalLoad = 'ready';
921                        runtime.settingsCode = '';
922                        const effective = effectiveConfiguration(
923                          settings.approvedLayers(),
924                        );
925                        if (active.scope !== 'session') {
926                          const changed = owner.replaceApproved(
927                            effective.document,
928                            effective.source,
929                            active.revision,
930                          );
931                          if (changed.ok)
932                            await checkReadiness(
933                              $,
934                              sessionOwner,
935                              runtime,
936                              changed.config,
937                            );
938                        }
939                        form.notice =
940                          'Personal settings loaded. Session overrides keep precedence.';
941                      } else {
942                        form.mode = 'project';
943                        form.notice =
944                          'Project candidate loaded, NOT active. Review rule definitions then Trust exact revision.';
945                      }
946                    } else form.notice = response.code;
947                    form.busy = false;
948                    update();
949                  }
950                }}
951              >{`Load ${scope}`}</Button>
952            ))}
953          </Box>
954          {form.mode === 'project' ? (
955            <Box flexDirection="column">
956              {(() => {
957                const reviewed = settings.loadedConfiguration('project');
958                return reviewed ? (
959                  <Box flexDirection="column">
960                    <Text>{`Project candidate ${reviewed.revision}. Not active before trust. ${reviewed.document.rules.length} rules.`}</Text>
961                    {reviewed.document.rules.map((rule) => (
962                      <Text key={`review-${rule.id}`}>
963                        {rule.kind === 'token'
964                          ? `${rule.id}: ${rule.action}, token prefix ${rule.prefix}, alphabet ${rule.alphabet}, ${rule.run.kind} ${rule.run.length}, ${rule.specificity}, validator ${rule.validator}`
965                          : `${rule.id}: ${rule.action}, assignment names ${rule.names.join(', ')}`}
966                      </Text>
967                    ))}
968                    <Button
969                      key="trust-project"
970                      onPress={async () => {
971                        if (!validOwner() || form.busy) return;
972                        if (!state.requestedProtection) {
973                          form.notice = 'TURN_ON_TO_TRUST';
974                          update();
975                          return;
976                        }
977                        if (
978                          !settings.approveProject(
979                            reviewed.identity,
980                            reviewed.revision,
981                            reviewed.document,
982                          )
983                        ) {
984                          form.notice = 'PROJECT_REVIEW_STALE';
985                          update();
986                          return;
987                        }
988                        const effective = effectiveConfiguration(
989                          settings.approvedLayers(),
990                        );
991                        if (active.scope !== 'session') {
992                          const changed = owner.replaceApproved(
993                            effective.document,
994                            effective.source,
995                            active.revision,
996                          );
997                          if (changed.ok)
998                            await checkReadiness(
999                              $,
1000                              sessionOwner,
1001                              runtime,
1002                              changed.config,
1003                            );
1004                        }
1005                        form.notice =
1006                          'Exact reviewed project configuration trusted for this session. Session override keeps precedence.';
1007                        update();
1008                      }}
1009                    >
1010                      Trust reviewed project
1011                    </Button>
1012                  </Box>
1013                ) : null;
1014              })()}
1015            </Box>
1016          ) : null}
1017          <Box>
1018            {(['personal', 'project'] as const).map((scope) => (
1019              <Button
1020                key={`save-${scope}`}
1021                onPress={async () => {
1022                  if (!validOwner() || form.busy) return;
1023                  if (!state.requestedProtection) {
1024                    form.notice = 'TURN_ON_TO_SAVE';
1025                    update();
1026                    return;
1027                  }
1028                  const loaded = settings.loadedConfiguration(scope);
1029                  const intent = settings.beginSave(scope, active);
1030                  if (!loaded || !intent) {
1031                    form.notice = 'LOAD_AND_REVIEW_SCOPE_FIRST';
1032                    update();
1033                    return;
1034                  }
1035                  form.busy = true;
1036                  form.durableBusy = true;
1037                  form.notice =
1038                    'Explicit save in progress. Closing does not undo an already dispatched durable write.';
1039                  update();
1040                  const storage = {
1041                    scope,
1042                    approved: true,
1043                    ...(scope === 'project'
1044                      ? { projectRoot: await $.session.root() }
1045                      : {}),
1046                    expectedRevision: intent.expectedRevision,
1047                    expectedIdentity: intent.identity,
1048                    expectedDocument: loaded.document,
1049                    document: intent.document,
1050                  };
1051                  const response = await runSettings(
1052                    $,
1053                    {
1054                      protocolVersion: 2,
1055                      requestId: `save-${++runtime.sequence}`,
1056                      operation: 'save-config',
1057                      policyId: POLICY_ID,
1058                      storage,
1059                    },
1060                    runtime,
1061                  );
1062                  if (validOwner()) {
1063                    form.notice =
1064                      response.ok &&
1065                      settings.saved(intent.token, response.settings)
1066                        ? `Saved ${scope}. Current session override retained.`
1067                        : response.ok
1068                          ? 'STALE_SAVE_RECEIPT'
1069                          : response.code;
1070                    form.busy = false;
1071                    form.durableBusy = false;
1072                    update();
1073                  }
1074                }}
1075              >{`Save ${scope}`}</Button>
1076            ))}
1077          </Box>
1078          <Box>
1079            {(['personal', 'project'] as const).map((scope) => (
1080              <Button
1081                key={`reset-${scope}`}
1082                onPress={() => {
1083                  if (!validOwner() || form.busy) return;
1084                  form.resetScope = scope;
1085                  form.notice = `Confirm reset of ${scope} only. Removes additional saved rules; built-ins and other scopes remain. Existing session override remains.`;
1086                  update();
1087                }}
1088              >{`Reset ${scope}`}</Button>
1089            ))}
1090          </Box>
1091          {form.resetScope ? (
1092            <Button
1093              key="confirm-reset"
1094              onPress={async () => {
1095                if (!validOwner() || form.busy) return;
1096                if (!state.requestedProtection) {
1097                  form.notice = 'TURN_ON_TO_RESET';
1098                  update();
1099                  return;
1100                }
1101                const scope = form.resetScope;
1102                if (!scope) return;
1103                const loaded = settings.loadedConfiguration(scope);
1104                const intent = settings.beginSave(scope, {
1105                  schemaVersion: 1,
1106                  rules: [],
1107                });
1108                if (!loaded || !intent) {
1109                  form.notice = 'LOAD_AND_REVIEW_SCOPE_FIRST';
1110                  update();
1111                  return;
1112                }
1113                form.busy = true;
1114                form.durableBusy = true;
1115                const storage = {
1116                  scope,
1117                  approved: true,
1118                  ...(scope === 'project'
1119                    ? { projectRoot: await $.session.root() }
1120                    : {}),
1121                  expectedRevision: intent.expectedRevision,
1122                  expectedIdentity: intent.identity,
1123                  expectedDocument: loaded.document,
1124                };
1125                const response = await runSettings(
1126                  $,
1127                  {
1128                    protocolVersion: 2,
1129                    requestId: `reset-${++runtime.sequence}`,
1130                    operation: 'reset-config',
1131                    policyId: POLICY_ID,
1132                    storage,
1133                  },
1134                  runtime,
1135                );
1136                if (validOwner()) {
1137                  if (
1138                    response.ok &&
1139                    settings.saved(intent.token, response.settings)
1140                  ) {
1141                    const effective = effectiveConfiguration(
1142                      settings.approvedLayers(),
1143                    );
1144                    if (active.scope !== 'session') {
1145                      const changed = owner.replaceApproved(
1146                        effective.document,
1147                        effective.source,
1148                        active.revision,
1149                      );
1150                      if (changed.ok)
1151                        await checkReadiness(
1152                          $,
1153                          sessionOwner,
1154                          runtime,
1155                          changed.config,
1156                        );
1157                    }
1158                    form.notice = `Reset ${scope} only. Built-ins retained.`;
1159                  } else
1160                    form.notice = response.ok
1161                      ? 'STALE_RESET_RECEIPT'
1162                      : response.code;
1163                  form.busy = false;
1164                  form.durableBusy = false;
1165                  form.resetScope = null;
1166                  update();
1167                }
1168              }}
1169            >{`Confirm reset ${form.resetScope}`}</Button>
1170          ) : null}
1171          <Text>
1172            Portable transfer files contain rule definitions only. Export may
1173            reveal internal names or prefixes. Review before sharing; no
1174            history, OFF state, matched input is exported. Never put actual
1175            credentials in rule fields; unknown sensitive values cannot always
1176            be recognized.
1177          </Text>
1178          <Box>
1179            {(['personal', 'project'] as const).map((scope) => (
1180              <Button
1181                key={`import-${scope}`}
1182                onPress={async () => {
1183                  if (!validOwner() || form.busy) return;
1184                  if (!state.requestedProtection) {
1185                    form.notice = 'TURN_ON_TO_IMPORT';
1186                    update();
1187                    return;
1188                  }
1189                  form.busy = true;
1190                  const storage =
1191                    scope === 'project'
1192                      ? {
1193                          scope,
1194                          approved: false,
1195                          projectRoot: await $.session.root(),
1196                        }
1197                      : { scope, approved: true };
1198                  const response = await runTransfer(
1199                    $,
1200                    {
mod/adapters/text.ts 323 lines
1import type { PromptOrigin, PromptSubmitInput } from 'claude-code';
2import type { Segment } from '../protocol.ts';
3import {
4  isNonnegativeInteger,
5  isStringArray,
6  hasOnlyDataKeys as keys,
7  hasOwn as own,
8} from '../validation.ts';
9
10export interface UnsupportedShape {
11  status: 'blocked';
12  errorCode: 'UNSUPPORTED_SHAPE';
13}
14export interface PromptExtraction {
15  status: 'ok';
16  shape: 'prompt';
17  segments: Segment[];
18  metadata: { wait: boolean; origin: PromptOrigin; turnId?: string };
19}
20export interface BashExtraction {
21  status: 'ok';
22  shape: 'Bash';
23  segments: Segment[];
24  metadata: { interrupted: boolean };
25}
26export interface ReadExtraction {
27  status: 'ok';
28  shape: 'Read';
29  segments: Segment[];
30  metadata: { numLines: number; startLine: number; totalLines: number };
31}
32export type ToolExtraction = BashExtraction | ReadExtraction;
33export type Extraction = PromptExtraction | ToolExtraction;
34export type TrustedToolResult =
35  | { deny: string }
36  | {
37      result:
38        | { stdout: string; stderr: string; interrupted: boolean }
39        | {
40            type: 'text';
41            file: {
42              filePath: string;
43              content: string;
44              numLines: number;
45              startLine: number;
46              totalLines: number;
47            };
48          };
49    };
50const blocked = (): UnsupportedShape => ({
51  status: 'blocked',
52  errorCode: 'UNSUPPORTED_SHAPE',
53});
54
55// The model-origin host envelope retains these optional keys as undefined.
56// Populated values can carry paths, aliases or background output, so withhold them.
57const emptyBashFields = [
58  'returnCodeInterpretation',
59  'backgroundTaskId',
60  'backgroundedByUser',
61  'backgroundedByTurnAbort',
62  'backgroundedToDeliverMessage',
63  'timedOutAfterMs',
64  'backgroundEndsWithFinalResponse',
65  'backgroundCwdHint',
66  'dangerouslyDisableSandbox',
67  'persistedOutputPath',
68  'persistedOutputSize',
69  'staleReadFileStateHint',
70  'ghRateLimitHint',
71  'gitOperation',
72  'bashEditDiff',
73];
74const safeOriginKinds = new Set([
75  'composer',
76  'bridge',
77  'sdk',
78  'task-notification',
79  'scheduled-trigger',
80  'peer',
81  'peer-send-message',
82  'projects-relay',
83  'coordinator',
84  'observer',
85  'observer-activity',
86  'auto-continuation',
87  'unclassified',
88  'slack-ping',
89]);
90function validOrigin(origin: unknown): origin is PromptOrigin {
91  if (
92    keys(origin, ['kind']) &&
93    typeof origin.kind === 'string' &&
94    safeOriginKinds.has(origin.kind)
95  )
96    return true;
97  if (
98    keys(origin, ['kind', 'name', 'asUser']) &&
99    origin.kind === 'plugin' &&
100    typeof origin.name === 'string'
101  ) {
102    return !own(origin, 'asUser') || origin.asUser === true;
103  }
104  return (
105    keys(origin, ['kind', 'server']) &&
106    origin.kind === 'channel' &&
107    typeof origin.server === 'string'
108  );
109}
110
111export function extractPrompt(
112  value: unknown,
113): PromptExtraction | UnsupportedShape {
114  if (
115    !keys(value, [
116      'text',
117      'context',
118      'attachments',
119      'wait',
120      'origin',
121      'turnId',
122    ]) ||
123    typeof value.text !== 'string' ||
124    typeof value.wait !== 'boolean' ||
125    !validOrigin(value.origin) ||
126    (own(value, 'turnId') && typeof value.turnId !== 'string') ||
127    (own(value, 'attachments') &&
128      (!Array.isArray(value.attachments) || value.attachments.length)) ||
129    (own(value, 'context') && !isStringArray(value.context))
130  )
131    return blocked();
132  const context = isStringArray(value.context) ? value.context : [];
133  return {
134    status: 'ok',
135    shape: 'prompt',
136    segments: [
137      { id: 'text', text: value.text },
138      ...context.map((text, index) => ({ id: `context${index}`, text })),
139    ],
140    metadata: {
141      wait: value.wait,
142      origin: value.origin,
143      ...(typeof value.turnId === 'string' ? { turnId: value.turnId } : {}),
144    },
145  };
146}
147
148function sanitized(
149  extraction: Extraction | UnsupportedShape,
150  segments: unknown,
151): Map<string, string> | null {
152  if (
153    extraction?.status !== 'ok' ||
154    !Array.isArray(segments) ||
155    segments.length !== extraction.segments.length
156  )
157    return null;
158  const expected = new Set(extraction.segments.map((segment) => segment.id));
159  const output = new Map<string, string>();
160  for (const segment of segments) {
161    if (
162      !keys(segment, ['id', 'text']) ||
163      typeof segment.id !== 'string' ||
164      !expected.has(segment.id) ||
165      output.has(segment.id) ||
166      typeof segment.text !== 'string'
167    )
168      return null;
169    output.set(segment.id, segment.text);
170  }
171  return output;
172}
173
174export function rebuildPrompt(
175  extraction: PromptExtraction | UnsupportedShape,
176  segments: unknown,
177): PromptSubmitInput | { drop: string } {
178  const values = sanitized(extraction, segments);
179  if (!values || extraction.status !== 'ok' || extraction.shape !== 'prompt')
180    return { drop: 'INVALID_RESPONSE' };
181  const text = values.get('text');
182  if (text === undefined) return { drop: 'INVALID_RESPONSE' };
183  const result: PromptSubmitInput = {
184    text,
185    wait: extraction.metadata.wait,
186    origin: extraction.metadata.origin,
187  };
188  if (own(extraction.metadata, 'turnId'))
189    result.turnId = extraction.metadata.turnId;
190  if (values.size > 1)
191    result.context = extraction.segments.slice(1).map((segment) => {
192      const text = values.get(segment.id);
193      if (text === undefined) throw new Error('INVALID_RESPONSE');
194      return text;
195    });
196  return result;
197}
198
199export function extractToolResult(
200  tool: string,
201  value: unknown,
202): ToolExtraction | UnsupportedShape {
203  if (
204    !keys(value, [
205      'result',
206      'text',
207      'ref',
208      'context',
209      'isError',
210      'isReadOnly',
211      'deny',
212    ]) ||
213    value.isError === true ||
214    (own(value, 'deny') && value.deny !== undefined) ||
215    (own(value, 'context') &&
216      (!Array.isArray(value.context) || value.context.length)) ||
217    (own(value, 'isError') && value.isError !== undefined) ||
218    (own(value, 'isReadOnly') &&
219      value.isReadOnly !== true &&
220      value.isReadOnly !== undefined) ||
221    (own(value, 'text') &&
222      value.text !== undefined &&
223      typeof value.text !== 'string') ||
224    (own(value, 'ref') &&
225      value.ref !== undefined &&
226      !Number.isSafeInteger(value.ref))
227  )
228    return blocked();
229  const result = value.result;
230  if (tool === 'Bash') {
231    if (
232      !keys(result, [
233        'stdout',
234        'stderr',
235        'interrupted',
236        'isImage',
237        'noOutputExpected',
238        ...emptyBashFields,
239      ]) ||
240      emptyBashFields.some(
241        (field) => own(result, field) && result[field] !== undefined,
242      ) ||
243      typeof result.stdout !== 'string' ||
244      typeof result.stderr !== 'string' ||
245      typeof result.interrupted !== 'boolean' ||
246      (own(result, 'isImage') && result.isImage !== false) ||
247      (own(result, 'noOutputExpected') &&
248        typeof result.noOutputExpected !== 'boolean')
249    )
250      return blocked();
251    return {
252      status: 'ok',
253      shape: 'Bash',
254      metadata: { interrupted: result.interrupted },
255      segments: [
256        { id: 'stdout', text: result.stdout },
257        { id: 'stderr', text: result.stderr },
258      ],
259    };
260  }
261  if (tool === 'Read') {
262    if (
263      !keys(result, ['type', 'file']) ||
264      result.type !== 'text' ||
265      !keys(result.file, [
266        'filePath',
267        'content',
268        'numLines',
269        'startLine',
270        'totalLines',
271      ]) ||
272      typeof result.file.filePath !== 'string' ||
273      typeof result.file.content !== 'string' ||
274      !isNonnegativeInteger(result.file.numLines) ||
275      !isNonnegativeInteger(result.file.startLine) ||
276      !isNonnegativeInteger(result.file.totalLines)
277    )
278      return blocked();
279    return {
280      status: 'ok',
281      shape: 'Read',
282      metadata: {
283        numLines: result.file.numLines,
284        startLine: result.file.startLine,
285        totalLines: result.file.totalLines,
286      },
287      segments: [
288        { id: 'filePath', text: result.file.filePath },
289        { id: 'content', text: result.file.content },
290      ],
291    };
292  }
293  return blocked();
294}
295
296export function rebuildToolResult(
297  extraction: ToolExtraction | UnsupportedShape,
298  segments: unknown,
299): TrustedToolResult {
300  const values = sanitized(extraction, segments);
301  if (!values || extraction.status !== 'ok')
302    return { deny: 'INVALID_RESPONSE' };
303  if (extraction.shape === 'Bash') {
304    const stdout = values.get('stdout');
305    const stderr = values.get('stderr');
306    if (stdout === undefined || stderr === undefined)
307      return { deny: 'INVALID_RESPONSE' };
308    return {
309      result: { stdout, stderr, interrupted: extraction.metadata.interrupted },
310    };
311  }
312  const filePath = values.get('filePath');
313  const content = values.get('content');
314  if (filePath === undefined || content === undefined)
315    return { deny: 'INVALID_RESPONSE' };
316  return {
317    result: {
318      type: 'text',
319      file: { filePath, content, ...extraction.metadata },
320    },
321  };
322}
323
mod/config.ts 259 lines
1import type {
2  ActiveConfiguration,
3  ConfigDocument,
4  ConfigScope,
5} from '../helper/src/config.ts';
6
7export type {
8  Action,
9  ActiveConfiguration,
10  ConfigDocument,
11  ConfigScope,
12  CustomRule,
13  NamesRule,
14  TokenRule,
15} from '../helper/src/config.ts';
16
17export type ConfigResult =
18  | Readonly<{ ok: true; config: ActiveConfiguration }>
19  | Readonly<{
20      ok: false;
21      code: 'STALE_REVISION' | 'INVALID_STAGE' | 'INVALID_CANDIDATE';
22    }>;
23
24// Defensive copies prevent a caller's form edits from changing in-flight policy.
25export function freezeDocument(document: ConfigDocument): ConfigDocument {
26  return Object.freeze({
27    schemaVersion: 1,
28    rules: Object.freeze(
29      document.rules.map((rule) =>
30        rule.kind === 'token'
31          ? Object.freeze({
32              kind: rule.kind,
33              id: rule.id,
34              action: rule.action,
35              prefix: rule.prefix,
36              alphabet: rule.alphabet,
37              run: Object.freeze({
38                kind: rule.run.kind,
39                length: rule.run.length,
40              }),
41              specificity: rule.specificity,
42              validator: rule.validator,
43            })
44          : Object.freeze({
45              kind: rule.kind,
46              id: rule.id,
47              action: rule.action,
48              names: Object.freeze([...rule.names]),
49            }),
50      ),
51    ),
52  });
53}
54export function effectiveConfiguration(layers: {
55  personal?: ConfigDocument;
56  project?: ConfigDocument;
57  session?: ConfigDocument;
58}): Readonly<{ document: ConfigDocument; source: ConfigScope }> {
59  // Each approved layer replaces the entire custom-rule list, including an empty list.
60  for (const source of ['session', 'project', 'personal'] as const) {
61    const document = layers[source];
62    if (document)
63      return Object.freeze({ document: freezeDocument(document), source });
64  }
65  return Object.freeze({
66    document: freezeDocument({ schemaVersion: 1, rules: [] }),
67    source: 'defaults',
68  });
69}
70
71export interface Draft {
72  readonly token: string;
73  readonly baseRevision: string;
74  readonly scope: ConfigScope;
75  readonly document: ConfigDocument;
76  readonly stage: 'editing' | 'validated' | 'previewed';
77}
78let controllerSequence = 0;
79export class ConfigController {
80  private readonly identity = ++controllerSequence;
81  private epoch = 0;
82  private draftEpoch = 0;
83  private active: ActiveConfiguration;
84  private draft: Draft | undefined;
85  private undo:
86    | Readonly<{ expectedRevision: string; config: ActiveConfiguration }>
87    | undefined;
88  constructor(
89    initial: ConfigDocument = { schemaVersion: 1, rules: [] },
90    source: ConfigScope = 'defaults',
91  ) {
92    this.active = this.activate(initial, source);
93  }
94  private activate(
95    document: ConfigDocument,
96    source: ConfigScope,
97  ): ActiveConfiguration {
98    this.epoch += 1;
99    return Object.freeze({
100      ...freezeDocument(document),
101      revision: `cfg-${this.identity}-${this.epoch}`,
102      source,
103      scope: source,
104    });
105  }
106  snapshot(): ActiveConfiguration {
107    return this.active;
108  }
109  begin(document: ConfigDocument, scope: ConfigScope = 'session'): Draft {
110    this.draftEpoch += 1;
111    this.draft = Object.freeze({
112      token: `draft-${this.identity}-${this.draftEpoch}`,
113      baseRevision: this.active.revision,
114      scope,
115      document: freezeDocument(document),
116      stage: 'editing',
117    });
118    return this.draft;
119  }
120  currentDraft(): Draft | undefined {
121    return this.draft;
122  }
123  // Only the registration adapter invokes these after matching helper replies.
124  validated(token: string, valid: boolean): boolean {
125    if (
126      !this.draft ||
127      this.draft.token !== token ||
128      this.draft.stage !== 'editing' ||
129      !valid
130    )
131      return false;
132    this.draft = Object.freeze({ ...this.draft, stage: 'validated' });
133    return true;
134  }
135  previewed(token: string, success: boolean): boolean {
136    if (
137      !this.draft ||
138      this.draft.token !== token ||
139      this.draft.stage !== 'validated' ||
140      !success
141    )
142      return false;
143    this.draft = Object.freeze({ ...this.draft, stage: 'previewed' });
144    return true;
145  }
146  cancel(token: string): void {
147    if (this.draft?.token === token) this.draft = undefined;
148  }
149  apply(token: string): ConfigResult {
150    const draft = this.draft;
151    if (!draft || draft.token !== token || draft.stage !== 'previewed')
152      return Object.freeze({ ok: false, code: 'INVALID_STAGE' });
153    if (draft.baseRevision !== this.active.revision)
154      return Object.freeze({ ok: false, code: 'STALE_REVISION' });
155    const previous = this.active;
156    this.active = this.activate(draft.document, draft.scope);
157    this.undo = Object.freeze({
158      expectedRevision: this.active.revision,
159      config: previous,
160    });
161    this.draft = undefined;
162    return Object.freeze({ ok: true, config: this.active });
163  }
164  replaceApproved(
165    document: ConfigDocument,
166    scope: ConfigScope,
167    expectedRevision: string,
168  ): ConfigResult {
169    if (this.active.revision !== expectedRevision)
170      return Object.freeze({ ok: false, code: 'STALE_REVISION' });
171    const previous = this.active;
172    this.active = this.activate(document, scope);
173    this.undo = Object.freeze({
174      expectedRevision: this.active.revision,
175      config: previous,
176    });
177    return Object.freeze({ ok: true, config: this.active });
178  }
179  revert(expectedRevision: string): ConfigResult {
180    if (
181      !this.undo ||
182      this.active.revision !== expectedRevision ||
183      this.undo.expectedRevision !== expectedRevision
184    )
185      return Object.freeze({ ok: false, code: 'STALE_REVISION' });
186    this.active = this.activate(this.undo.config, this.undo.config.source);
187    this.undo = undefined;
188    this.draft = undefined;
189    return Object.freeze({ ok: true, config: this.active });
190  }
191}
192
193export function addRule(
194  document: ConfigDocument,
195  rule: import('../helper/src/config.ts').CustomRule,
196): ConfigDocument | null {
197  if (document.rules.some((existing) => existing.id === rule.id)) return null;
198  return freezeDocument({ schemaVersion: 1, rules: [...document.rules, rule] });
199}
200export function removeRule(
201  document: ConfigDocument,
202  id: string,
203): ConfigDocument | null {
204  if (!document.rules.some((rule) => rule.id === id)) return null;
205  return freezeDocument({
206    schemaVersion: 1,
207    rules: document.rules.filter((rule) => rule.id !== id),
208  });
209}
210
211export function operationConfiguration(
212  state: import('./state.ts').OperationSnapshot,
213  config: ActiveConfiguration,
214): Readonly<{
215  protection: import('./state.ts').OperationSnapshot;
216  config: ActiveConfiguration;
217}> {
218  return Object.freeze({ protection: Object.freeze({ ...state }), config });
219}
220
221export function replaceRule(
222  document: ConfigDocument,
223  originalId: string,
224  edited: import('../helper/src/config.ts').CustomRule,
225): ConfigDocument | null {
226  if (
227    document.rules.filter((rule) => rule.id === originalId).length !== 1 ||
228    document.rules.some(
229      (rule) => rule.id !== originalId && rule.id === edited.id,
230    )
231  )
232    return null;
233  return freezeDocument({
234    schemaVersion: 1,
235    rules: document.rules.map((rule) =>
236      rule.id === originalId ? edited : rule,
237    ),
238  });
239}
240
241export function sharedNamesAction(
242  document: ConfigDocument,
243): import('../helper/src/config.ts').Action {
244  return (
245    document.rules.find((rule) => rule.kind === 'names')?.action ?? 'redact'
246  );
247}
248export function setNamesAction(
249  document: ConfigDocument,
250  action: import('../helper/src/config.ts').Action,
251): ConfigDocument {
252  return freezeDocument({
253    schemaVersion: 1,
254    rules: document.rules.map((rule) =>
255      rule.kind === 'names' ? { ...rule, action } : rule,
256    ),
257  });
258}
259
mod/form.ts 129 lines
1import { validateConfigDocument } from '../helper/src/config.ts';
2import {
3  addRule,
4  type ConfigDocument,
5  type CustomRule,
6  freezeDocument,
7  replaceRule,
8  setNamesAction,
9  type TokenRule,
10} from './config.ts';
11export interface RuleForm {
12  mode: 'config' | 'add' | 'remove' | 'project';
13  kind: 'token' | 'names';
14  id: string;
15  prefix: string;
16  names: string;
17  action: 'redact' | 'block';
18  alphabet: TokenRule['alphabet'];
19  runKind: 'exact' | 'at-least';
20  length: string;
21  specificity: 'contextual' | 'entropy';
22  validator: 'none' | 'trailing-lower-hex';
23  notice: string;
24  busy: boolean;
25  durableBusy: boolean;
26  editingId: string | null;
27  namesGroupAction: boolean;
28  resetScope: 'personal' | 'project' | null;
29}
30export function freshForm(): RuleForm {
31  return {
32    mode: 'config',
33    kind: 'token',
34    id: '',
35    prefix: '',
36    names: '',
37    action: 'redact',
38    alphabet: 'alnum',
39    runKind: 'exact',
40    length: '16',
41    specificity: 'contextual',
42    validator: 'none',
43    notice: '',
44    busy: false,
45    durableBusy: false,
46    editingId: null,
47    namesGroupAction: false,
48    resetScope: null,
49  };
50}
51export function formRule(form: RuleForm): CustomRule | null {
52  const rule =
53    form.kind === 'token'
54      ? {
55          kind: 'token',
56          id: form.id,
57          action: form.action,
58          prefix: form.prefix,
59          alphabet: form.alphabet,
60          run: { kind: form.runKind, length: Number(form.length) },
61          specificity: form.specificity,
62          validator: form.validator,
63        }
64      : {
65          kind: 'names',
66          id: form.id,
67          action: form.action,
68          names: form.names.split(',').map((name) => name.trim()),
69        };
70  try {
71    return (
72      validateConfigDocument({ schemaVersion: 1, rules: [rule] }).rules[0] ??
73      null
74    );
75  } catch {
76    return null;
77  }
78}
79export function candidateConfiguration(
80  document: ConfigDocument,
81  token: string,
82): import('./config.ts').ActiveConfiguration {
83  return Object.freeze({
84    ...freezeDocument(document),
85    revision: token,
86    source: 'session',
87    scope: 'session',
88  });
89}
90
91export function populateForm(rule: CustomRule): RuleForm {
92  const form = freshForm();
93  form.mode = 'add';
94  form.editingId = rule.id;
95  form.kind = rule.kind;
96  form.id = rule.id;
97  form.action = rule.action;
98  if (rule.kind === 'token') {
99    form.prefix = rule.prefix;
100    form.alphabet = rule.alphabet;
101    form.runKind = rule.run.kind;
102    form.length = String(rule.run.length);
103    form.specificity = rule.specificity;
104    form.validator = rule.validator;
105  } else form.names = rule.names.join(', ');
106  return form;
107}
108export function candidateDocument(
109  active: ConfigDocument,
110  form: RuleForm,
111): ConfigDocument | null {
112  const rule = formRule(form);
113  if (!rule) return null;
114  const document =
115    form.editingId === null
116      ? addRule(active, rule)
117      : replaceRule(active, form.editingId, rule);
118  if (!document) return null;
119  try {
120    return validateConfigDocument(
121      rule.kind === 'names' && form.namesGroupAction
122        ? setNamesAction(document, rule.action)
123        : document,
124    );
125  } catch {
126    return null;
127  }
128}
129
mod/protocol.ts 373 lines
1import { canonicalTypes } from './canonical-types.ts';
2import type { ActiveConfiguration } from './config.ts';
3import {
4  isNonnegativeInteger,
5  hasOnlyKeys as keys,
6  isPlainRecord as plain,
7} from './validation.ts';
8
9export interface Segment {
10  id: string;
11  text: string;
12}
13interface RequestIdentity {
14  protocolVersion: 1 | 2;
15  config?: ActiveConfiguration;
16  requestId: string;
17  policyId: string;
18}
19export interface SanitizeRequest extends RequestIdentity {
20  operation: 'sanitize';
21  segments: Segment[];
22}
23export interface SelfCheckRequest extends RequestIdentity {
24  operation: 'self-check';
25}
26export interface ConfigurationRequest extends RequestIdentity {
27  operation: 'validate-config' | 'preview';
28  config: ActiveConfiguration;
29}
30export type HelperRequest =
31  | SanitizeRequest
32  | SelfCheckRequest
33  | ConfigurationRequest;
34export interface PreviewOutcome {
35  id: string;
36  positive: {
37    detected: boolean;
38    action: 'redact' | 'block' | 'none';
39    findingCounts: Record<string, number>;
40  };
41  negative: {
42    detected: boolean;
43    action: 'redact' | 'block' | 'none';
44    findingCounts: Record<string, number>;
45  };
46}
47export type HelperResponse =
48  | { status: 'failed' | 'blocked'; errorCode: string }
49  | {
50      status: 'ok';
51      segments?: Segment[];
52      validated?: true;
53      outcomes?: PreviewOutcome[];
54      configRevision?: string;
55      findingCounts: Record<string, number>;
56      count: number;
57      artifact: 'addon' | 'wasm';
58    };
59
60export const LIMITS = Object.freeze({
61  inputBytes: 262144,
62  segments: 256,
63  findings: 1000,
64  outputBytes: 2097152,
65  timeoutMs: 2000,
66  pending: 4,
67});
68export const POLICY_ID = 'credentials-alpha1';
69export const ENGINE_VERSION = '0.1.0-beta.14';
70const typeSet: ReadonlySet<string> = new Set(canonicalTypes);
71const opaqueId = /^[A-Za-z0-9_-]{1,64}$/;
72const codes = new Set([
73  'INVALID_REQUEST',
74  'INPUT_LIMIT',
75  'ENGINE_VERSION',
76  'ENGINE_UNAVAILABLE',
77  'ENGINE_RESPONSE',
78  'FINDING_LIMIT',
79  'POLICY_FAILURE',
80  'PRIVATE_KEY_BLOCKED',
81  'ENGINE_FAILURE',
82  'OUTPUT_LIMIT',
83  'TIMEOUT',
84  'INVALID_JSON',
85  'INPUT_FAILURE',
86  'RULE_BLOCKED',
87  'INVALID_CONFIG',
88  'NAMES_ACTION_CONFLICT',
89]);
90
91export function utf8Bytes(text: string): number {
92  let bytes = 0;
93  for (let i = 0; i < text.length; i++) {
94    const code = text.charCodeAt(i);
95    if (code < 0x80) bytes += 1;
96    else if (code < 0x800) bytes += 2;
97    else if (
98      code >= 0xd800 &&
99      code <= 0xdbff &&
100      i + 1 < text.length &&
101      text.charCodeAt(i + 1) >= 0xdc00 &&
102      text.charCodeAt(i + 1) <= 0xdfff
103    ) {
104      bytes += 4;
105      i += 1;
106    } else bytes += 3;
107  }
108  return bytes;
109}
110
111export function makeRequest(
112  requestId: unknown,
113  segments: unknown,
114  config?: ActiveConfiguration,
115): SanitizeRequest | null {
116  if (
117    typeof requestId !== 'string' ||
118    !opaqueId.test(requestId) ||
119    !Array.isArray(segments) ||
120    segments.length > LIMITS.segments ||
121    !segments.length
122  )
123    return null;
124  const ids = new Set<string>();
125  let bytes = 0;
126  const validated: Segment[] = [];
127  for (const segment of segments) {
128    if (
129      !keys(segment, ['id', 'text']) ||
130      typeof segment.id !== 'string' ||
131      !opaqueId.test(segment.id) ||
132      typeof segment.text !== 'string' ||
133      ids.has(segment.id)
134    )
135      return null;
136    ids.add(segment.id);
137    validated.push({ id: segment.id, text: segment.text });
138    bytes += utf8Bytes(segment.text);
139    if (bytes > LIMITS.inputBytes) return null;
140  }
141  const request: SanitizeRequest = {
142    protocolVersion: config ? 2 : 1,
143    ...(config ? { config } : {}),
144    requestId,
145    operation: 'sanitize',
146    policyId: POLICY_ID,
147    segments: validated,
148  };
149  return utf8Bytes(JSON.stringify(request)) <= LIMITS.inputBytes
150    ? request
151    : null;
152}
153
154export function validateProcessResponse(
155  processResult: unknown,
156  request: HelperRequest,
157): HelperResponse {
158  const invalid: HelperResponse = {
159    status: 'failed',
160    errorCode: 'INVALID_HELPER_RESPONSE',
161  };
162  if (
163    !keys(processResult, [
164      'exitCode',
165      'stdout',
166      'stderr',
167      'isStdoutTruncated',
168      'isStderrTruncated',
169    ]) ||
170    processResult.exitCode !== 0 ||
171    processResult.isStdoutTruncated !== false ||
172    processResult.isStderrTruncated !== false ||
173    processResult.stderr !== '' ||
174    typeof processResult.stdout !== 'string' ||
175    utf8Bytes(processResult.stdout) > LIMITS.outputBytes
176  )
177    return invalid;
178  let value: unknown;
179  try {
180    value = JSON.parse(processResult.stdout);
181  } catch {
182    return invalid;
183  }
184  if (
185    !plain(value) ||
186    value.protocolVersion !== request.protocolVersion ||
187    (request.protocolVersion === 2 &&
188      (!request.config || value.configRevision !== request.config.revision)) ||
189    value.requestId !== request.requestId ||
190    value.engineVersion !== ENGINE_VERSION ||
191    value.policyId !== POLICY_ID
192  )
193    return invalid;
194  if (value.status === 'blocked' || value.status === 'failed') {
195    if (
196      !keys(value, [
197        'protocolVersion',
198        'requestId',
199        'status',
200        'engineVersion',
201        'policyId',
202        'errorCode',
203        ...(request.protocolVersion === 2 ? ['configRevision'] : []),
204      ]) ||
205      typeof value.errorCode !== 'string' ||
206      !codes.has(value.errorCode)
207    )
208      return invalid;
209    return { status: value.status, errorCode: value.errorCode };
210  }
211  const boundKeys = request.protocolVersion === 2 ? ['configRevision'] : [];
212  const allowedTypes = new Set(typeSet);
213  for (const rule of request.config?.rules ?? [])
214    if (rule.kind === 'token') allowedTypes.add(rule.id);
215  function counts(input: unknown): Record<string, number> | null {
216    if (!plain(input)) return null;
217    const result: Record<string, number> = {};
218    let total = 0;
219    for (const [type, count] of Object.entries(input)) {
220      if (!allowedTypes.has(type) || !isNonnegativeInteger(count)) return null;
221      total += count;
222      if (total > LIMITS.findings) return null;
223      result[type] = count;
224    }
225    return result;
226  }
227  if (
228    request.operation === 'validate-config' ||
229    request.operation === 'preview'
230  ) {
231    if (
232      value.status !== 'ok' ||
233      (value.artifact !== 'addon' && value.artifact !== 'wasm')
234    )
235      return invalid;
236    const common = [
237      'protocolVersion',
238      'requestId',
239      'status',
240      'engineVersion',
241      'policyId',
242      'artifact',
243      ...boundKeys,
244    ];
245    if (request.operation === 'validate-config') {
246      if (
247        !keys(value, [...common, 'validated', 'ruleCount']) ||
248        value.validated !== true ||
249        value.ruleCount !== request.config.rules.length
250      )
251        return invalid;
252      return {
253        status: 'ok',
254        validated: true,
255        findingCounts: {},
256        count: 0,
257        artifact: value.artifact,
258        configRevision: request.config.revision,
259      };
260    }
261    if (
262      !keys(value, [...common, 'outcomes']) ||
263      !Array.isArray(value.outcomes) ||
264      value.outcomes.length !== request.config.rules.length
265    )
266      return invalid;
267    const expected = new Map(
268      request.config.rules.map((rule) => [rule.id, rule]),
269    );
270    const outcomes: PreviewOutcome[] = [];
271    let total = 0;
272    function preview(input: unknown): PreviewOutcome['positive'] | null {
273      if (
274        !keys(input, ['detected', 'action', 'findingCounts']) ||
275        typeof input.detected !== 'boolean' ||
276        (input.action !== 'redact' &&
277          input.action !== 'block' &&
278          input.action !== 'none')
279      )
280        return null;
281      const findingCounts = counts(input.findingCounts);
282      if (!findingCounts) return null;
283      const count = Object.values(findingCounts).reduce((a, b) => a + b, 0);
284      total += count;
285      if (
286        total > LIMITS.findings ||
287        input.detected !== count > 0 ||
288        (input.action === 'none') !== (count === 0)
289      )
290        return null;
291      return { detected: input.detected, action: input.action, findingCounts };
292    }
293    for (const outcome of value.outcomes) {
294      if (
295        !keys(outcome, ['id', 'positive', 'negative']) ||
296        typeof outcome.id !== 'string'
297      )
298        return invalid;
299      const rule = expected.get(outcome.id);
300      if (!rule) return invalid;
301      expected.delete(outcome.id);
302      const positive = preview(outcome.positive),
303        negative = preview(outcome.negative);
304      if (!positive || !negative) return invalid;
305      outcomes.push({ id: outcome.id, positive, negative });
306    }
307    if (expected.size) return invalid;
308    return {
309      status: 'ok',
310      outcomes,
311      findingCounts: {},
312      count: total,
313      artifact: value.artifact,
314      configRevision: request.config.revision,
315    };
316  }
317  if (
318    value.status !== 'ok' ||
319    !keys(value, [
320      'protocolVersion',
321      'requestId',
322      'status',
323      'engineVersion',
324      'policyId',
325      'artifact',
326      'segments',
327      'findingCounts',
328      ...boundKeys,
329    ]) ||
330    (value.artifact !== 'addon' && value.artifact !== 'wasm') ||
331    !plain(value.findingCounts)
332  )
333    return invalid;
334  const findingCounts: Record<string, number> = {};
335  let total = 0;
336  for (const [type, count] of Object.entries(value.findingCounts)) {
337    if (!allowedTypes.has(type) || !isNonnegativeInteger(count)) return invalid;
338    findingCounts[type] = count;
339    total += count;
340    if (total > LIMITS.findings) return invalid;
341  }
342  const segments: Segment[] = [];
343  if (request.operation === 'self-check') {
344    if ('segments' in value || total !== 0) return invalid;
345  } else if (request.operation === 'sanitize') {
346    if (
347      !Array.isArray(value.segments) ||
348      value.segments.length !== request.segments.length
349    )
350      return invalid;
351    const expected = new Set(request.segments.map((segment) => segment.id));
352    for (const segment of value.segments) {
353      if (
354        !keys(segment, ['id', 'text']) ||
355        typeof segment.id !== 'string' ||
356        typeof segment.text !== 'string' ||
357        !expected.delete(segment.id)
358      )
359        return invalid;
360      segments.push({ id: segment.id, text: segment.text });
361    }
362    if (expected.size) return invalid;
363  } else return invalid;
364  return {
365    status: 'ok',
366    ...(request.operation === 'sanitize' ? { segments } : {}),
367    findingCounts,
368    count: total,
369    artifact: value.artifact,
370    ...(request.config ? { configRevision: request.config.revision } : {}),
371  };
372}
373
mod/settings.ts 187 lines
1import { type ConfigDocument, freezeDocument } from './config.ts';
2export type SavedScope = 'personal' | 'project';
3export interface SavedConfiguration {
4  readonly scope: SavedScope;
5  readonly identity: string;
6  readonly revision: string;
7  readonly document: ConfigDocument;
8}
9export interface SaveIntent {
10  readonly token: string;
11  readonly scope: SavedScope;
12  readonly identity: string;
13  readonly expectedRevision: string;
14  readonly document: ConfigDocument;
15}
16// The host supplies only validated helper receipts. Project data remains unapproved
17// until a local review explicitly grants trust for the exact loaded revision.
18let settingsSequence = 0;
19export class SavedSettingsController {
20  private readonly identity = ++settingsSequence;
21  private loaded = new Map<SavedScope, SavedConfiguration>();
22  private approved = new Map<SavedScope, string>();
23  private pending: SaveIntent | undefined;
24  private epoch = 0;
25  private pendingBase: ConfigDocument | undefined;
26  resetSession(): void {
27    this.loaded.clear();
28    this.approved.clear();
29    this.pending = undefined;
30    this.pendingBase = undefined;
31  }
32  loadedConfiguration(scope: SavedScope): SavedConfiguration | undefined {
33    return this.loaded.get(scope);
34  }
35  observe(value: SavedConfiguration): void {
36    const prior = this.loaded.get(value.scope);
37    const safe = Object.freeze({
38      scope: value.scope,
39      identity: value.identity,
40      revision: value.revision,
41      document: freezeDocument(value.document),
42    });
43    this.loaded.set(value.scope, safe);
44    if (value.scope === 'personal')
45      this.approved.set(value.scope, value.revision);
46    else if (
47      prior?.identity !== value.identity ||
48      prior.revision !== value.revision ||
49      JSON.stringify(prior.document) !== JSON.stringify(safe.document)
50    )
51      this.approved.delete(value.scope);
52  }
53  approveProject(
54    identity: string,
55    revision: string,
56    reviewed: ConfigDocument,
57  ): boolean {
58    const value = this.loaded.get('project');
59    if (
60      !value ||
61      value.identity !== identity ||
62      value.revision !== revision ||
63      JSON.stringify(value.document) !==
64        JSON.stringify(freezeDocument(reviewed))
65    )
66      return false;
67    this.approved.set('project', revision);
68    return true;
69  }
70  approvedLayers(): Readonly<{
71    personal?: ConfigDocument;
72    project?: ConfigDocument;
73  }> {
74    const result: { personal?: ConfigDocument; project?: ConfigDocument } = {};
75    for (const scope of ['personal', 'project'] as const) {
76      const saved = this.loaded.get(scope);
77      if (saved && this.approved.get(scope) === saved.revision)
78        result[scope] = saved.document;
79    }
80    return Object.freeze(result);
81  }
82  beginSave(scope: SavedScope, document: ConfigDocument): SaveIntent | null {
83    const saved = this.loaded.get(scope);
84    if (
85      !saved ||
86      (scope === 'project' && this.approved.get(scope) !== saved.revision)
87    )
88      return null;
89    this.epoch += 1;
90    this.pendingBase = saved.document;
91    this.pending = Object.freeze({
92      token: `save-${this.identity}-${this.epoch}`,
93      scope,
94      identity: saved.identity,
95      expectedRevision: saved.revision,
96      document: freezeDocument(document),
97    });
98    return this.pending;
99  }
100  cancel(token: string): void {
101    if (this.pending?.token === token) {
102      this.pending = undefined;
103      this.pendingBase = undefined;
104    }
105  }
106  saved(token: string, receipt: SavedConfiguration): boolean {
107    const pending = this.pending;
108    const current = pending && this.loaded.get(pending.scope);
109    if (
110      !pending ||
111      pending.token !== token ||
112      !current ||
113      current.revision !== pending.expectedRevision ||
114      current.identity !== pending.identity ||
115      JSON.stringify(current.document) !== JSON.stringify(this.pendingBase) ||
116      receipt.scope !== pending.scope ||
117      receipt.identity !== pending.identity ||
118      receipt.revision === pending.expectedRevision
119    )
120      return false;
121    // Match the declarative payload as well as the privileged write identity.
122    if (
123      JSON.stringify(freezeDocument(receipt.document)) !==
124      JSON.stringify(pending.document)
125    )
126      return false;
127    this.observe(receipt);
128    this.approved.set(receipt.scope, receipt.revision);
129    this.pending = undefined;
130    this.pendingBase = undefined;
131    return true;
132  }
133}
134
135export interface ImportCandidate {
136  readonly token: string;
137  readonly scope: SavedScope;
138  readonly identity: string;
139  readonly document: ConfigDocument;
140}
141let importSequence = 0;
142export class ImportReviewController {
143  private readonly identity = ++importSequence;
144  private epoch = 0;
145  private candidate: ImportCandidate | undefined;
146  private approvedToken: string | undefined;
147  stage(
148    scope: SavedScope,
149    identity: string,
150    document: ConfigDocument,
151  ): ImportCandidate {
152    this.epoch += 1;
153    this.approvedToken = undefined;
154    this.candidate = Object.freeze({
155      token: `import-${this.identity}-${this.epoch}`,
156      scope,
157      identity,
158      document: freezeDocument(document),
159    });
160    return this.candidate;
161  }
162  snapshot(): ImportCandidate | undefined {
163    return this.candidate;
164  }
165  approve(token: string, reviewed: ConfigDocument): boolean {
166    const candidate = this.candidate;
167    if (
168      !candidate ||
169      candidate.token !== token ||
170      JSON.stringify(candidate.document) !==
171        JSON.stringify(freezeDocument(reviewed))
172    )
173      return false;
174    this.approvedToken = token;
175    return true;
176  }
177  approved(token: string): ConfigDocument | null {
178    return this.candidate?.token === token && this.approvedToken === token
179      ? this.candidate.document
180      : null;
181  }
182  cancel(): void {
183    this.candidate = undefined;
184    this.approvedToken = undefined;
185  }
186}
187
mod/state.ts 105 lines
1export type Readiness = 'loading' | 'ready' | 'unavailable';
2
3export interface RecentEvent {
4  errorCode: string;
5  count: number;
6}
7export interface SessionState {
8  sessionId: string;
9  requestedProtection: boolean;
10  readiness: Readiness;
11  policyEpoch: number;
12  recent: Readonly<RecentEvent>[];
13}
14export type OperationSnapshot = Readonly<Omit<SessionState, 'recent'>>;
15
16const readinessValues = new Set(['loading', 'ready', 'unavailable']);
17const errorCodes = new Set([
18  'SCANNED',
19  'FAILED',
20  'UNSUPPORTED_SHAPE',
21  'INVALID_RESPONSE',
22  'INVALID_REQUEST',
23  'INPUT_LIMIT',
24  'OUTPUT_LIMIT',
25  'FINDING_LIMIT',
26  'TIMEOUT',
27  'ENGINE_VERSION',
28  'ENGINE_UNAVAILABLE',
29  'ENGINE_RESPONSE',
30  'ENGINE_FAILURE',
31  'POLICY_FAILURE',
32  'PRIVATE_KEY_BLOCKED',
33  'HELPER_UNAVAILABLE',
34  'CANCELLED',
35  'QUEUE_LIMIT',
36]);
37
38export function createSessionState(sessionId: string): SessionState {
39  if (typeof sessionId !== 'string' || !sessionId.length)
40    throw new Error('INVALID_SESSION');
41  return {
42    sessionId,
43    requestedProtection: true,
44    readiness: 'loading',
45    policyEpoch: 0,
46    recent: [],
47  };
48}
49
50export function snapshot(state: SessionState): OperationSnapshot {
51  return Object.freeze({
52    sessionId: state.sessionId,
53    requestedProtection: state.requestedProtection,
54    readiness: state.readiness,
55    policyEpoch: state.policyEpoch,
56  });
57}
58
59export function requestProtection(
60  state: SessionState,
61  enabled: boolean,
62): OperationSnapshot {
63  if (typeof enabled !== 'boolean') throw new Error('INVALID_STATE');
64  if (state.requestedProtection !== enabled) {
65    state.requestedProtection = enabled;
66    state.policyEpoch += 1;
67  }
68  return snapshot(state);
69}
70
71export function setReadiness(
72  state: SessionState,
73  readiness: Readiness,
74): OperationSnapshot {
75  if (!readinessValues.has(readiness)) throw new Error('INVALID_READINESS');
76  if (state.readiness !== readiness) {
77    state.readiness = readiness;
78    state.policyEpoch += 1;
79  }
80  return snapshot(state);
81}
82
83export function record(
84  state: SessionState,
85  event: RecentEvent,
86): Readonly<RecentEvent> {
87  if (
88    !event ||
89    !errorCodes.has(event.errorCode) ||
90    !Number.isSafeInteger(event.count) ||
91    event.count < 0
92  ) {
93    throw new Error('INVALID_RECORD');
94  }
95  // Project fixed metadata; never copy arbitrary diagnostic or input fields.
96  const safe = Object.freeze({
97    errorCode: event.errorCode,
98    count: event.count,
99  });
100  state.recent.push(safe);
101  if (state.recent.length > 100)
102    state.recent.splice(0, state.recent.length - 100);
103  return safe;
104}
105
mod/storage.ts 235 lines
1import { validateConfigDocument } from '../helper/src/config.ts';
2import type { ConfigDocument } from './config.ts';
3import { ENGINE_VERSION, LIMITS, POLICY_ID, utf8Bytes } from './protocol.ts';
4import type { SavedConfiguration, SavedScope } from './settings.ts';
5import { hasOnlyKeys as keys, isPlainRecord as plain } from './validation.ts';
6export interface StorageRequest {
7  protocolVersion: 2;
8  requestId: string;
9  operation:
10    | 'load-config'
11    | 'save-config'
12    | 'reset-config'
13    | 'import-config'
14    | 'export-config';
15  policyId: typeof POLICY_ID;
16  storage: {
17    scope: SavedScope;
18    approved: boolean;
19    projectRoot?: string;
20    expectedRevision?: string;
21    expectedIdentity?: string;
22    expectedDocument?: ConfigDocument;
23    document?: ConfigDocument;
24  };
25}
26export type StorageResponse =
27  | { ok: true; settings: SavedConfiguration }
28  | { ok: false; code: string };
29const codes = new Set([
30  'SETTINGS_UNAVAILABLE',
31  'SETTINGS_CONFLICT',
32  'SETTINGS_BUSY',
33  'SETTINGS_CORRUPT',
34  'ENGINE_VERSION',
35  'ENGINE_UNAVAILABLE',
36  'PROJECT_TRUST_REQUIRED',
37  'INVALID_CONFIG',
38  'NAMES_ACTION_CONFLICT',
39  'INVALID_REQUEST',
40  'INPUT_LIMIT',
41  'OUTPUT_LIMIT',
42  'INPUT_FAILURE',
43  'TIMEOUT',
44]);
45export function validateStorageResponse(
46  result: unknown,
47  request: StorageRequest,
48): StorageResponse {
49  const failure: StorageResponse = {
50    ok: false,
51    code: 'INVALID_SETTINGS_RESPONSE',
52  };
53  if (
54    !keys(result, [
55      'exitCode',
56      'stdout',
57      'stderr',
58      'isStdoutTruncated',
59      'isStderrTruncated',
60    ]) ||
61    result.exitCode !== 0 ||
62    result.stderr !== '' ||
63    result.isStdoutTruncated !== false ||
64    result.isStderrTruncated !== false ||
65    typeof result.stdout !== 'string' ||
66    utf8Bytes(result.stdout) > LIMITS.outputBytes
67  )
68    return failure;
69  let value: unknown;
70  try {
71    value = JSON.parse(result.stdout);
72  } catch {
73    return failure;
74  }
75  if (
76    !plain(value) ||
77    value.protocolVersion !== 2 ||
78    value.requestId !== request.requestId ||
79    value.engineVersion !== ENGINE_VERSION ||
80    value.policyId !== POLICY_ID
81  )
82    return failure;
83  if (value.status === 'failed') {
84    if (
85      !keys(value, [
86        'protocolVersion',
87        'requestId',
88        'status',
89        'engineVersion',
90        'policyId',
91        'errorCode',
92      ]) ||
93      typeof value.errorCode !== 'string' ||
94      !codes.has(value.errorCode)
95    )
96      return failure;
97    return { ok: false, code: value.errorCode };
98  }
99  if (
100    value.status !== 'ok' ||
101    (value.artifact !== 'addon' && value.artifact !== 'wasm') ||
102    !keys(value, [
103      'protocolVersion',
104      'requestId',
105      'status',
106      'engineVersion',
107      'policyId',
108      'artifact',
109      'settings',
110    ]) ||
111    !keys(value.settings, ['scope', 'identity', 'revision', 'document']) ||
112    value.settings.scope !== request.storage.scope ||
113    typeof value.settings.identity !== 'string' ||
114    !/^[0-9a-f]{64}$/.test(value.settings.identity) ||
115    typeof value.settings.revision !== 'string' ||
116    !/^(?:absent|[0-9a-f-]{36})$/.test(value.settings.revision)
117  )
118    return failure;
119  try {
120    const document = validateConfigDocument(value.settings.document);
121    return {
122      ok: true,
123      settings: {
124        scope: request.storage.scope,
125        identity: value.settings.identity,
126        revision: value.settings.revision,
127        document,
128      },
129    };
130  } catch {
131    return failure;
132  }
133}
134
135export interface TransferReceipt {
136  scope: SavedScope;
137  identity: string;
138  document: ConfigDocument;
139}
140export type TransferResponse =
141  | { ok: true; transfer: TransferReceipt }
142  | { ok: false; code: string };
143export function validateTransferResponse(
144  result: unknown,
145  request: StorageRequest,
146): TransferResponse {
147  const failure: TransferResponse = {
148    ok: false,
149    code: 'INVALID_TRANSFER_RESPONSE',
150  };
151  if (
152    !keys(result, [
153      'exitCode',
154      'stdout',
155      'stderr',
156      'isStdoutTruncated',
157      'isStderrTruncated',
158    ]) ||
159    result.exitCode !== 0 ||
160    result.stderr !== '' ||
161    result.isStdoutTruncated !== false ||
162    result.isStderrTruncated !== false ||
163    typeof result.stdout !== 'string' ||
164    utf8Bytes(result.stdout) > LIMITS.outputBytes
165  )
166    return failure;
167  let value: unknown;
168  try {
169    value = JSON.parse(result.stdout);
170  } catch {
171    return failure;
172  }
173  if (
174    !plain(value) ||
175    value.protocolVersion !== 2 ||
176    value.requestId !== request.requestId ||
177    value.engineVersion !== ENGINE_VERSION ||
178    value.policyId !== POLICY_ID
179  )
180    return failure;
181  if (value.status === 'failed') {
182    if (
183      !keys(value, [
184        'protocolVersion',
185        'requestId',
186        'status',
187        'engineVersion',
188        'policyId',
189        'errorCode',
190      ]) ||
191      typeof value.errorCode !== 'string' ||
192      !codes.has(value.errorCode)
193    )
194      return failure;
195    return { ok: false, code: value.errorCode };
196  }
197  if (
198    value.status !== 'ok' ||
199    (value.artifact !== 'addon' && value.artifact !== 'wasm') ||
200    !keys(value, [
201      'protocolVersion',
202      'requestId',
203      'status',
204      'engineVersion',
205      'policyId',
206      'artifact',
207      'transfer',
208    ]) ||
209    !keys(value.transfer, ['scope', 'identity', 'document']) ||
210    value.transfer.scope !== request.storage.scope ||
211    typeof value.transfer.identity !== 'string' ||
212    !/^[0-9a-f]{64}$/.test(value.transfer.identity)
213  )
214    return failure;
215  try {
216    const document = validateConfigDocument(value.transfer.document);
217    if (
218      request.operation === 'export-config' &&
219      (value.transfer.identity !== request.storage.expectedIdentity ||
220        JSON.stringify(document) !== JSON.stringify(request.storage.document))
221    )
222      return failure;
223    return {
224      ok: true,
225      transfer: {
226        scope: request.storage.scope,
227        identity: value.transfer.identity,
228        document,
229      },
230    };
231  } catch {
232    return failure;
233  }
234}
235
mod/view.ts 78 lines
1import type { ActiveConfiguration } from './config.ts';
2import type { SessionState } from './state.ts';
3
4export interface StatusView {
5  readonly protection: 'ON' | 'OFF';
6  readonly readiness: 'loading' | 'ready' | 'unavailable';
7  readonly observedAt: number | null;
8  readonly readinessNote: 'CACHED_OBSERVATION_NOT_HEALTH_CHECK';
9  readonly customRuleCount: number;
10  readonly ruleIds: readonly string[];
11  readonly source: ActiveConfiguration['source'];
12  readonly scope: ActiveConfiguration['scope'];
13  readonly revision: string;
14  readonly supported: readonly string[];
15  readonly excluded: readonly string[];
16  readonly recent: readonly Readonly<{ code: string; count: number }>[];
17  readonly zeroFindingsNote: 'ZERO_FINDINGS_NOT_SAFE_CONTENT';
18}
19export function statusView(
20  state: SessionState,
21  config: ActiveConfiguration,
22  observedAt: number | null,
23): StatusView {
24  return Object.freeze({
25    protection: state.requestedProtection ? 'ON' : 'OFF',
26    readiness: state.readiness,
27    observedAt:
28      observedAt !== null && Number.isSafeInteger(observedAt) && observedAt >= 0
29        ? observedAt
30        : null,
31    readinessNote: 'CACHED_OBSERVATION_NOT_HEALTH_CHECK',
32    customRuleCount: config.rules.length,
33    ruleIds: Object.freeze(config.rules.map((rule) => rule.id)),
34    source: config.source,
35    scope: config.scope,
36    revision: config.revision,
37    supported: Object.freeze([
38      'PROMPT_TEXT_CONTEXT',
39      'READ_TEXT',
40      'BASH_STDOUT_STDERR',
41    ]),
42    excluded: Object.freeze([
43      'MCP_OTHER_TOOLS',
44      'TOOL_ARGUMENTS',
45      'BINARY_IMAGE_AUDIO',
46      'PII',
47      'EXISTING_HISTORY',
48    ]),
49    recent: Object.freeze(
50      state.recent
51        .slice(-100)
52        .map((event) =>
53          Object.freeze({ code: event.errorCode, count: event.count }),
54        ),
55    ),
56    zeroFindingsNote: 'ZERO_FINDINGS_NOT_SAFE_CONTENT',
57  });
58}
59export function removalView(
60  config: ActiveConfiguration,
61  id: string,
62): Readonly<{
63  id: string;
64  source: ActiveConfiguration['source'];
65  scope: ActiveConfiguration['scope'];
66  removable: boolean;
67  impact: string;
68}> | null {
69  if (!config.rules.some((rule) => rule.id === id)) return null;
70  return Object.freeze({
71    id,
72    source: config.source,
73    scope: config.scope,
74    removable: config.scope === 'session',
75    impact: 'REMOVES_ADDITIONAL_RULE_BUILTINS_MAY_STILL_MATCH',
76  });
77}
78
mod/validation.ts 47 lines
1export function isPlainRecord(
2  value: unknown,
3): value is Record<string, unknown> {
4  if (value === null || typeof value !== 'object' || Array.isArray(value))
5    return false;
6  const prototype = Object.getPrototypeOf(value);
7  return prototype === Object.prototype || prototype === null;
8}
9
10export function hasOwn(value: object, key: PropertyKey): boolean {
11  return Object.hasOwn(value, key);
12}
13
14// Reject accessors and symbols at content boundaries, before reading their values.
15export function hasOnlyDataKeys(
16  value: unknown,
17  allowed: readonly string[],
18): value is Record<string, unknown> {
19  if (!isPlainRecord(value)) return false;
20  return Reflect.ownKeys(value).every((key) => {
21    if (typeof key !== 'string' || !allowed.includes(key)) return false;
22    const descriptor = Object.getOwnPropertyDescriptor(value, key);
23    return descriptor !== undefined && 'value' in descriptor;
24  });
25}
26
27export function hasOnlyKeys(
28  value: unknown,
29  allowed: readonly string[],
30): value is Record<string, unknown> {
31  return (
32    isPlainRecord(value) &&
33    Object.keys(value).every((key) => allowed.includes(key))
34  );
35}
36
37export function isNonnegativeInteger(value: unknown): value is number {
38  return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
39}
40
41export function isStringArray(value: unknown): value is string[] {
42  return (
43    Array.isArray(value) &&
44    value.every((entry: unknown) => typeof entry === 'string')
45  );
46}
47
helper/src/config.ts 180 lines
1export type Action = 'redact' | 'block';
2export type ConfigScope = 'defaults' | 'personal' | 'project' | 'session';
3export interface TokenRule {
4  readonly kind: 'token';
5  readonly id: string;
6  readonly action: Action;
7  readonly prefix: string;
8  readonly alphabet:
9    | 'alnum'
10    | 'alnum-dash'
11    | 'alnum-dash-dot'
12    | 'upper-alnum'
13    | 'digit'
14    | 'lower-hex'
15    | 'base64-body';
16  readonly run: Readonly<{ kind: 'exact' | 'at-least'; length: number }>;
17  readonly specificity: 'contextual' | 'entropy';
18  readonly validator: 'none' | 'trailing-lower-hex';
19}
20export interface NamesRule {
21  readonly kind: 'names';
22  readonly id: string;
23  readonly action: Action;
24  readonly names: readonly string[];
25}
26export type CustomRule = TokenRule | NamesRule;
27export interface ConfigDocument {
28  readonly schemaVersion: 1;
29  readonly rules: readonly CustomRule[];
30}
31export interface ActiveConfiguration extends ConfigDocument {
32  readonly revision: string;
33  readonly source: ConfigScope;
34  readonly scope: ConfigScope;
35}
36
37const ALPHABETS = new Set([
38  'alnum',
39  'alnum-dash',
40  'alnum-dash-dot',
41  'upper-alnum',
42  'digit',
43  'lower-hex',
44  'base64-body',
45]);
46const RULE_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
47const NAME = /^[A-Za-z][A-Za-z0-9_.-]*$/;
48function record(input: unknown): input is Record<string, unknown> {
49  return input !== null && typeof input === 'object' && !Array.isArray(input);
50}
51function array(input: unknown): input is unknown[] {
52  return Array.isArray(input);
53}
54function fields(
55  input: unknown,
56  names: readonly string[],
57): input is Record<string, unknown> {
58  return (
59    record(input) &&
60    Object.keys(input).length === names.length &&
61    names.every((name) => Object.hasOwn(input, name))
62  );
63}
64function invalid(): never {
65  throw new Error('INVALID_CONFIG');
66}
67function action(input: unknown): Action {
68  return input === 'redact' || input === 'block' ? input : invalid();
69}
70
71// The core parser remains authoritative for reserved detector IDs and normalization.
72export function validateConfigDocument(input: unknown): ConfigDocument {
73  if (
74    !fields(input, ['schemaVersion', 'rules']) ||
75    input.schemaVersion !== 1 ||
76    !array(input.rules) ||
77    input.rules.length > 64
78  )
79    invalid();
80  const rules: CustomRule[] = [];
81  const ids = new Set<string>();
82  let namesCount = 0;
83  let namesAction: Action | undefined;
84  for (const raw of input.rules) {
85    if (
86      !record(raw) ||
87      typeof raw.id !== 'string' ||
88      raw.id.length > 64 ||
89      !RULE_ID.test(raw.id) ||
90      ids.has(raw.id)
91    )
92      invalid();
93    ids.add(raw.id);
94    const selectedAction = action(raw.action);
95    if (raw.kind === 'token') {
96      if (
97        !fields(raw, [
98          'kind',
99          'id',
100          'action',
101          'prefix',
102          'alphabet',
103          'run',
104          'specificity',
105          'validator',
106        ]) ||
107        typeof raw.prefix !== 'string' ||
108        /["\\\r\n\p{Cc}\p{Cf}]/u.test(raw.prefix)
109      )
110        invalid();
111      const bytes = new TextEncoder().encode(raw.prefix).length;
112      if (
113        bytes < 3 ||
114        bytes > 64 ||
115        typeof raw.alphabet !== 'string' ||
116        !ALPHABETS.has(raw.alphabet) ||
117        !fields(raw.run, ['kind', 'length']) ||
118        (raw.run.kind !== 'exact' && raw.run.kind !== 'at-least') ||
119        typeof raw.run.length !== 'number' ||
120        !Number.isSafeInteger(raw.run.length) ||
121        raw.run.length < 1 ||
122        raw.run.length > 4096 ||
123        (raw.specificity !== 'contextual' && raw.specificity !== 'entropy') ||
124        (raw.validator !== 'none' && raw.validator !== 'trailing-lower-hex')
125      )
126        invalid();
127      const alphabet = raw.alphabet;
128      if (
129        alphabet !== 'alnum' &&
130        alphabet !== 'alnum-dash' &&
131        alphabet !== 'alnum-dash-dot' &&
132        alphabet !== 'upper-alnum' &&
133        alphabet !== 'digit' &&
134        alphabet !== 'lower-hex' &&
135        alphabet !== 'base64-body'
136      )
137        invalid();
138      rules.push(
139        Object.freeze({
140          kind: 'token',
141          id: raw.id,
142          action: selectedAction,
143          prefix: raw.prefix,
144          alphabet,
145          run: Object.freeze({ kind: raw.run.kind, length: raw.run.length }),
146          specificity: raw.specificity,
147          validator: raw.validator,
148        }),
149      );
150    } else if (raw.kind === 'names') {
151      if (
152        !fields(raw, ['kind', 'id', 'action', 'names']) ||
153        !array(raw.names) ||
154        raw.names.length === 0
155      )
156        invalid();
157      if (namesAction !== undefined && namesAction !== selectedAction)
158        throw new Error('NAMES_ACTION_CONFLICT');
159      namesAction = selectedAction;
160      const names: string[] = [];
161      for (const name of raw.names) {
162        if (typeof name !== 'string' || name.length > 64 || !NAME.test(name))
163          invalid();
164        names.push(name);
165      }
166      namesCount += names.length;
167      if (namesCount > 32) invalid();
168      rules.push(
169        Object.freeze({
170          kind: 'names',
171          id: raw.id,
172          action: selectedAction,
173          names: Object.freeze(names),
174        }),
175      );
176    } else invalid();
177  }
178  return Object.freeze({ schemaVersion: 1, rules: Object.freeze(rules) });
179}
180