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

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.
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.
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.
| Command | Effect |
|---|---|
/redacton | Request protection for subsequent supported operations. |
/redactoff | Bypass scans for subsequent operations in this session. |
/redact:status | Show cached readiness, coverage, rules and recent outcomes. |
/redact:config, /redact:add-rule, /redact:remove-rule | Manage 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.
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
mod/index.tsx 1590 lines1import 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 lines1import 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}
323mod/config.ts 259 lines1import 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}
259mod/form.ts 129 lines1import { 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}
129mod/protocol.ts 373 lines1import { 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}
373mod/settings.ts 187 lines1import { 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}
187mod/state.ts 105 lines1export 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}
105mod/storage.ts 235 lines1import { 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}
235mod/view.ts 78 lines1import 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}
78mod/validation.ts 47 lines1export 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}
47helper/src/config.ts 180 lines1export 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