@holdyourvoice/hyv 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/Readme.md +51 -11
  2. package/dist/ai-editor-rules.js +5 -2
  3. package/dist/ai-editor.js +52 -9
  4. package/dist/ai-editor.test.js +62 -10
  5. package/dist/approval-capability.js +111 -0
  6. package/dist/approval-capability.test.js +52 -0
  7. package/dist/approval-context.js +54 -0
  8. package/dist/approval-context.test.js +38 -0
  9. package/dist/benchmark.js +232 -0
  10. package/dist/benchmark.test.js +328 -0
  11. package/dist/canonical-json.js +123 -0
  12. package/dist/canonical-json.test.js +24 -0
  13. package/dist/cli.js +272 -19
  14. package/dist/cli.test.js +205 -8
  15. package/dist/hygiene.js +6 -0
  16. package/dist/hygiene.test.js +7 -1
  17. package/dist/judgment-task.js +171 -0
  18. package/dist/judgment-task.test.js +162 -0
  19. package/dist/learning.js +240 -100
  20. package/dist/learning.test.js +203 -3
  21. package/dist/lifecycle-adapter.js +75 -0
  22. package/dist/lifecycle-adapter.test.js +56 -0
  23. package/dist/mcp-tools.js +101 -7
  24. package/dist/mcp-tools.test.js +156 -6
  25. package/dist/mcp.js +213 -6
  26. package/dist/mcp.test.js +210 -11
  27. package/dist/pipeline.js +78 -14
  28. package/dist/pipeline.test.js +36 -2
  29. package/dist/preservation.js +89 -0
  30. package/dist/preservation.test.js +22 -0
  31. package/dist/profile.js +87 -0
  32. package/dist/profile.test.js +114 -0
  33. package/dist/rebuild-task.js +226 -0
  34. package/dist/rebuild-task.test.js +179 -0
  35. package/dist/release-audit.test.js +111 -2
  36. package/dist/rewrite-task.js +136 -16
  37. package/dist/rewrite-task.test.js +62 -7
  38. package/dist/rule-reconciliation.test.js +50 -0
  39. package/dist/semantic-review.js +176 -7
  40. package/dist/semantic-review.test.js +98 -14
  41. package/dist/stage1-dry-run.test.js +39 -0
  42. package/dist/stage1-evaluation.js +579 -0
  43. package/dist/stage1-evaluation.test.js +184 -0
  44. package/dist/stage1-human-packet.test.js +102 -0
  45. package/dist/stage1-schema-contract.test.js +95 -0
  46. package/dist/stage2-human-packet.test.js +81 -0
  47. package/dist/version.js +1 -1
  48. package/dist/voice-dna.js +53 -1
  49. package/dist/voice-dna.test.js +79 -1
  50. package/package.json +2 -2
package/Readme.md CHANGED
@@ -14,7 +14,7 @@ Those programs keep separate findings, scores, and pass states. A strong result
14
14
 
15
15
  Everything in the CLI runs from local files: accounts, API calls, telemetry, payment collection, and runtime network requests stay out of the core path. The optional Claude extension adds a local stdio MCP adapter around that same engine; it is not a hosted service.
16
16
 
17
- > **Status:** the public CLI is published as [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv). It runs locally and makes no runtime network requests.
17
+ > **Status:** [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv) **3.3.1** is the public founder-aware rewrite. It runs locally and makes no runtime network requests. The package includes Profile v3 policy, pre-edit SHIP/EDIT/REBUILD judgments, contiguous range edits, authorized rebuild, and a signed semantic lifecycle.
18
18
 
19
19
  ## Why it exists
20
20
 
@@ -27,6 +27,7 @@ Hold Your Voice keeps the work visible:
27
27
  | Does the draft still resemble this writer’s observable mechanics? | VoiceDNA | A profile-based score, findings, and pass state. |
28
28
  | Does the draft contain a configured editorial pattern worth inspecting? | AI Editor | A rule-based score, sentence findings, and pass state. |
29
29
  | Did the rewrite introduce a new blocker or replace too much? | Verification | Regressions, preservation score, and a release decision. |
30
+ | Should this draft ship, take a bounded edit, or rebuild? | Judgment | A SHIP, EDIT, or REBUILD recommendation bound to the draft and profile. |
30
31
 
31
32
  Its scope is a local writing gate. Authorship detection, fact checking, plagiarism review, and hosted generation each need their own tools. Hold Your Voice gives a writer or chosen model a narrow editing brief, then asks the same two engines to inspect the result.
32
33
 
@@ -55,7 +56,7 @@ To contribute, clone this repository, run `npm install`, then run `npm test` and
55
56
 
56
57
  ### Use it in Claude Desktop
57
58
 
58
- Build the fully local Claude Desktop extension with `npm run pack:claude`, then install `dist/hold-your-voice.mcpb` from **Settings → Extensions → Advanced settings → Install Extension**. The extension accepts text and portable profile JSON in the current conversation only. A successful verification records resolved finding IDs in local learning state; it never retains writing text or makes network requests. See the [Claude Desktop guide](docs/CLAUDE-DESKTOP.md).
59
+ Build the fully local Claude Desktop extension with `npm run pack:claude`, then install `dist/hold-your-voice.mcpb` from **Settings → Extensions → Advanced settings → Install Extension**. The extension accepts text and portable profile JSON in the current conversation only. Verification is read-only. Learning requires an explicit learning command or an approved lifecycle transition; neither path retains writing text or makes network requests. See the [Claude Desktop guide](docs/CLAUDE-DESKTOP.md).
59
60
 
60
61
  ### Build a local VoiceDNA profile
61
62
 
@@ -167,7 +168,7 @@ Give the brief and draft to a human editor or any model you trust. This reposito
167
168
  npx @holdyourvoice/hyv verify draft.md candidate.md profile.json
168
169
  ```
169
170
 
170
- `verify` returns the original and candidate reports, identifies newly introduced findings, calculates a coarse preservation score, and exits with status `2` when the candidate fails the dual gate. A passing verification automatically records only the resolved finding IDs for that profile in local learning state. It exits with `1` for a usage or runtime error. Treat status `2` as a release signal in scripts or CI.
171
+ `verify` returns the original and candidate reports, identifies newly introduced findings, calculates a coarse preservation score, and exits with status `2` when the candidate fails the dual gate. It does not mutate learning state. It exits with `1` for a usage or runtime error. Treat status `2` as a release signal in scripts or CI.
171
172
 
172
173
  ### Lock factual claims with a CopySpec
173
174
 
@@ -199,7 +200,7 @@ The check is deterministic. Without `atoms`, an immutable claim remains a verbat
199
200
 
200
201
  ### Local voice memory
201
202
 
202
- Learning is on by default. After a successful `verify`, Hold Your Voice records resolved rule IDs under `~/.hyv/learning/`, scoped to a fingerprint of the portable profile. It stores no draft or candidate text. The next `rewrite-prompt` uses a bounded list of those verified repairs.
203
+ Learning changes are explicit. Use the learning commands below, or complete the separately authorized semantic-review and final-approval lifecycle before recording approved learning. State lives under `~/.hyv/learning/`, scoped to the portable profile, and stores no draft or candidate text. The next `rewrite-prompt` uses a bounded list of approved repairs.
203
204
 
204
205
  ```bash
205
206
  hyv learning show profile.json
@@ -228,7 +229,27 @@ flowchart LR
228
229
  G --> R[Pass or inspect regressions]
229
230
  ```
230
231
 
231
- The tool never applies changes to your draft. You decide which findings are valid, apply replacement sentences deliberately, and run the final check.
232
+ The tool never applies changes to your draft. You decide which findings are valid, apply replacement sentences or an authorized rebuild deliberately, and run the final check.
233
+
234
+ ## Founder-aware rewrite
235
+
236
+ 3.3.0 keeps the original analyze → brief → verify loop and adds a structured rewrite path.
237
+
238
+ 1. Prepare a pre-edit judgment. Findings reduce to **SHIP**, bounded **EDIT**, or **REBUILD**.
239
+ 2. **SHIP** returns the original bytes. No model call.
240
+ 3. **EDIT** applies eligible sentence replacements or contiguous range edits through `prepare-rewrite` / `apply-rewrite`. Clean and unflagged text stays in place. Overlapping, out-of-order, or partly locked ranges fail before a candidate is built.
241
+ 4. **REBUILD** prepares a whole-document candidate only after a matching REBUILD recommendation, a CopySpec, and a signed `hyv.rebuild-authorization` capability. `prepare-rebuild` / `apply-rebuild` re-check that capability and the bound profile. Claim, polarity, hygiene, and semantic gates stay in force. Edit and rebuild responses are mutually incompatible.
242
+
243
+ ```bash
244
+ hyv prepare-judgment pre-edit argument draft.md profile.json task.json
245
+ hyv reduce-judgment envelope-a.json envelope-b.json envelope-c.json
246
+ hyv prepare-rewrite draft.md profile.json task.json
247
+ hyv apply-rewrite task.json response.json profile.json
248
+ hyv prepare-rebuild draft.md profile.json reduction.json copy-spec.json task.json --capability-file capability.json
249
+ hyv apply-rebuild task.json response.json profile.json --capability-file capability.json
250
+ ```
251
+
252
+ CLI and MCP expose the same contracts. The engine never calls a model. An editor or chosen model still sits outside the package.
232
253
 
233
254
  ## The five rewrite tiers
234
255
 
@@ -240,7 +261,7 @@ The prompt has an order. Lower tiers can refine a higher tier; they cannot overr
240
261
  4. **Tier 3: AI Editor.** Inspect yellow findings. Change a line only when the repair helps.
241
262
  5. **Tier 4: output.** Return replacement sentences keyed by sentence number.
242
263
 
243
- This order protects meaning before style. Read the complete [prompt contract](docs/PROMPT-CONTRACT.md) before changing it.
264
+ This order protects meaning before style. Rebuild is a separate whole-document contract; it does not use sentence-number replacements. Read the complete [prompt contract](docs/PROMPT-CONTRACT.md) before changing either path.
244
265
 
245
266
  ## VoiceDNA: 13 observable elements
246
267
 
@@ -268,7 +289,7 @@ Read the full [VoiceDNA reference](docs/VOICE-DNA.md) and [Wiki guide](https://g
268
289
 
269
290
  ## AI Editor: inspectable rules
270
291
 
271
- AI Editor uses a local, deterministic ruleset. The current `2.9.24-static.2` ruleset restores the reviewed 143-rule static catalog from the published `@holdyourvoice/hyv@2.9.24` `signals.ts` artifact and retains two detectors introduced in 3.1, for 145 rules total. Most rules inspect sentences; selected inherited rules inspect one physical line to preserve multi-sentence and line-start behavior. Each rule has a stable ID, severity, reason, repair direction, reconstructable expression, and explicit scope. Intentional inherited overlaps remain visible as separate findings.
292
+ AI Editor uses a local, deterministic ruleset. The current `3.2.0-reconciled.1` ruleset contains 148 stable catalog entries: the inherited catalog plus en-dash and performative-sincerity coverage. Applied profile policy determines whether a match blocks, advises, requires judgment, or is disabled. Duplicate legacy expressions remain cataloged for ID compatibility but emit one canonical finding. Most rules inspect sentences; selected inherited rules inspect one physical line to preserve multi-sentence and line-start behavior.
272
293
 
273
294
  Run this command to see the rules and ruleset version that actually execute in the published CLI:
274
295
 
@@ -302,13 +323,24 @@ The preservation score is a guardrail based on retained original words longer th
302
323
  | `hyv hygiene <draft> [--fix] [--output=path]` | Draft | Hygiene report or cleaned copy plus receipt | You need to inspect or conservatively clean hidden Unicode. |
303
324
  | `hyv final-check <path\|->` | Any final text | Exact accepted text on stdout or a withheld-output report on stderr | Text is about to cross a user-facing boundary. |
304
325
  | `hyv rewrite-prompt <draft> <profile.json>` | Draft and profile | Markdown editing brief | You need a constrained request for an editor or model. |
326
+ | `hyv prepare-rewrite <draft> <profile.json> <task.json>` | Draft and profile | Versioned task file plus metadata | A host needs a fingerprint-bound sentence-edit or range-edit task. |
327
+ | `hyv apply-rewrite <task.json> <response.json> <profile.json>` | Task, response, and profile | Candidate evaluation JSON | A host needs to apply and recheck eligible sentence replacements. |
328
+ | `hyv prepare-judgment <pre-edit\|post-candidate> <kind> <draft> <profile.json> <task.json> [candidate.md]` | Draft, profile, and optional candidate | Versioned judgment task | Findings need a SHIP, EDIT, or REBUILD recommendation. |
329
+ | `hyv reduce-judgment <envelope.json> <envelope.json> [envelope.json...]` | Signed judgment envelopes | Recommendation JSON | Multiple judgment envelopes must reduce to one decision. |
330
+ | `hyv prepare-rebuild <draft> <profile.json> <reduction.json> <copy-spec.json> <task.json>` | Draft, recommendation, CopySpec, and capability | Versioned rebuild task | An upstream REBUILD recommendation needs a whole-document candidate. |
331
+ | `hyv apply-rebuild <task.json> <response.json> <profile.json>` | Task, response, profile, and capability | Candidate evaluation JSON | A host needs to apply and recheck an authorized rebuild. |
305
332
  | `hyv verify <original> <candidate> <profile.json>` | Original, candidate, profile | Verification JSON and exit code | You need the candidate gate. |
306
333
  | `hyv verify-spec <original> <candidate> <profile.json> <copy-spec.json>` | Original, candidate, profile, CopySpec | Verification JSON with hard claim gate | A brief contains locked facts or prohibited claims. |
307
- | `hyv learning <show\|add\|clear> <profile.json>` | Profile and optional instruction | Local learning JSON | You need to inspect or manage profile-scoped learning. |
334
+ | `hyv learning <show\|inspect\|add\|record\|ratify\|supersede\|migrate\|clear> ...` | Profile, operation value, and bounded metadata options | Preferences or a text-free mutation receipt | You need to inspect, migrate, or manage profile-scoped learning. |
335
+ | `hyv lifecycle <prepare-semantic\|submit-verdict\|inspect\|validate-final-approval\|finalize> ...` | Versioned lifecycle artifacts | Canonical lifecycle artifact or metadata | A normal-policy semantic review or human decision must advance through the shared reducer. |
308
336
  | `hyv patterns` | None | Ruleset JSON | You need the exact enabled rules. |
309
337
 
310
338
  Every file argument can be `-` when the command accepts text input from standard input. Profile output is always written to the path you give it. Use `npx @holdyourvoice/hyv <command>` in place of `hyv <command>` when you have not installed the CLI globally.
311
339
 
340
+ Profile v3 learning is keyed by its stable local profile ID, so compatible history survives profile revisions. `record`, `ratify`, and `supersede` accept bounded `--mutation-id`, `--authority`, `--provenance`, `--weight`, and `--compatibility` options. `ratify` and `supersede` require Profile v3. `migrate` explicitly copies compatible legacy Profile v2 learning into one Profile v3 identity. Replaying an identical mutation is idempotent; reusing its ID for a different operation returns a conflict. Inspection and receipts expose event metadata only, never stored instructions or draft text.
341
+
342
+ The standalone CLI supports normal-policy semantic review. High-assurance review requires a trusted embedding and is rejected by the CLI. Approval and rebuild capabilities are accepted only through `--capability-stdin` or a permission-checked `--capability-file`; adapters validate capabilities but never mint them. Rejection needs no capability. Approval and `learning record-approved` require the matching signed final-approval capability. `apply-rewrite`, `apply-rebuild`, `lifecycle submit-verdict`, and `lifecycle finalize` exit `2` when the candidate or transition is not accepted, while usage and runtime failures exit `1`.
343
+
312
344
  ## Project map
313
345
 
314
346
  | Path | Responsibility |
@@ -320,17 +352,25 @@ Every file argument can be `-` when the command accepts text input from standard
320
352
  | `src/ai-editor.ts` | Owns the versioned deterministic editorial rules. |
321
353
  | `src/editorial-packs.ts` | Parses WritingBrief context and runs format and batch checks. |
322
354
  | `src/learning.ts` | Stores text-free, profile-scoped verified repairs and composes bounded local preferences. |
323
- | `src/pipeline.ts` | Combines pass states, makes briefs, and verifies candidates. |
355
+ | `src/pipeline.ts` | Combines scored pass states, makes briefs, and verifies candidates. |
356
+ | `src/rewrite-task.ts` | Prepares and evaluates fingerprint-bound sentence-replacement and range-edit tasks. |
357
+ | `src/judgment-task.ts` | Reduces pre-edit SHIP/EDIT/REBUILD recommendations and post-candidate clearance. |
358
+ | `src/rebuild-task.ts` | Prepares whole-document rebuild after a matching recommendation, CopySpec, and signed capability. |
359
+ | `src/semantic-review.ts` | Defines and reduces semantic and human-review lifecycle artifacts. |
360
+ | `src/approval-capability.ts` | Verifies canonical signed approval capabilities. |
361
+ | `src/approval-context.ts` | Loads permission-checked trust roots and evaluator authorization. |
362
+ | `src/lifecycle-adapter.ts` | Shares lifecycle operations across CLI and MCP adapters. |
324
363
  | `src/cli.ts` | Local file and standard-input command adapter. |
364
+ | `src/mcp.ts` | Local stdio MCP registration and host-capability gating. |
325
365
  | `src/pipeline.test.ts` | Contract and regression tests. |
326
366
  | `CONTRIBUTING.md` | Public-safety rules and the contributor model. |
327
367
  | `scripts/release-audit.mjs` | Checks source files for credential and network markers. |
328
368
 
329
- `pipeline.ts` is the sole composition point. It combines pass states and preserves each engine’s separate score.
369
+ `pipeline.ts` is the sole scored output-composition point. It combines pass states and preserves each engine’s separate score. Rewrite-task and lifecycle modules compose their own versioned, non-scoring artifacts.
330
370
 
331
371
  ## Privacy and data rights
332
372
 
333
- The runtime uses files on your machine. Samples, drafts, profiles, candidates, and client data stay there. Successful verification writes a text-free local learning event under `~/.hyv/learning/`: profile fingerprint, finding IDs, severities, counts, timestamp, and an opaque one-way candidate digest for retry deduplication. An instruction added through `hyv learning add` is stored as entered.
373
+ The runtime uses files on your machine. Samples, drafts, profiles, candidates, and client data stay there. Verification is read-only. Explicit learning commands and approved lifecycle recording can write text-free local events under `~/.hyv/learning/`: profile fingerprint, finding IDs, severities, counts, timestamp, and an opaque one-way candidate digest for retry deduplication. An instruction added through `hyv learning add` is stored as entered.
334
374
 
335
375
  The package does not upload writing, use embeddings, or make runtime network requests. Keep writing samples, edit histories, client text, local learning files, and datasets out of public commits unless you hold explicit rights and a provenance record. A profile is aggregated JSON and can still reveal vocabulary and preferences. Store private profiles outside public repositories.
336
376
 
@@ -114,9 +114,10 @@ export const rules = [
114
114
  { id: "struct.heres-where", severity: "yellow", expression: /\b(?:here'?s|here\s+is)\s+(?:where|why|what|the\s+part|the\s+(?:harder|real|actual|main|bigger)\s+problem)\b/i, reason: "A formulaic structure can make the sentence feel manufactured.", suggestion: "just make the point without the signpost" },
115
115
  { id: "struct.generic-buyer", severity: "red", expression: /\bpeople\s+don'?t\s+just\s+buy\b|\bpeople\s+buy\s+the\s+feeling\b/i, reason: "A formulaic structure can make the sentence feel manufactured.", suggestion: "generic buyer psychology is AI filler" },
116
116
  { id: "struct.this-isnt-x-this-is-y", severity: "red", expression: /\bthis isn'?t .{2,40}\.?\s*(?:this is|it'?s) .{2,40}/i, reason: "A formulaic structure can make the sentence feel manufactured.", suggestion: "FATAL: delete the negation, just state the positive claim", scope: "line" },
117
- { id: "struct.not-x-y", severity: "red", expression: /\bnot .{2,30}\.?\s*.{2,30}\b/i, reason: "A formulaic structure can make the sentence feel manufactured.", suggestion: 'the "Not X. Y." pattern is an AI tell \u2014 just state Y', scope: "line" },
117
+ { id: "struct.not-x-y", severity: "red", expression: /^\s*not\s+[^.!?\n]{1,60}\.\s+[^.!?\n]{1,60}(?:[.!?]|$)/i, reason: "A formulaic structure can make the sentence feel manufactured.", suggestion: 'the "Not X. Y." pattern is an AI tell \u2014 just state Y', scope: "line" },
118
118
  { id: "struct.forget-x", severity: "red", expression: /\bforget .{2,40}\.?\s*(?:this is|it'?s|you need)/i, reason: "A formulaic structure can make the sentence feel manufactured.", suggestion: "don't negate \u2014 just state what you mean", scope: "line" },
119
- { id: "punct.em-dash", severity: "yellow", expression: /—/, reason: "A stock phrase can flatten the writer's meaning.", suggestion: "em dashes are an AI tell \u2014 use a period, comma, or parentheses" },
119
+ { id: "punct.em-dash", severity: "red", expression: /—/, reason: "A stock phrase can flatten the writer's meaning.", suggestion: "em dashes are an AI tell \u2014 use a period, comma, or parentheses" },
120
+ { id: "punct.en-dash", severity: "red", expression: /–/, reason: "A stock phrase can flatten the writer's meaning.", suggestion: "en dashes are an AI tell \u2014 use a plain hyphen, period, comma, or parentheses" },
120
121
  { id: "bait.let-that-sink", severity: "red", expression: /\blet that sink in\b/i, reason: "The phrase asks for attention instead of earning it.", suggestion: "cut the sink. make your point and move on." },
121
122
  { id: "bait.read-that-again", severity: "red", expression: /\bread that again\b/i, reason: "The phrase asks for attention instead of earning it.", suggestion: "if it needs repeating, repeat it yourself" },
122
123
  { id: "bait.full-stop", severity: "red", expression: /\bfull stop\b/i, reason: "The phrase asks for attention instead of earning it.", suggestion: "the period already does this job" },
@@ -143,6 +144,8 @@ export const rules = [
143
144
  { id: "ogilvy.deep-dive", severity: "yellow", expression: /\bdeep dive\b/i, reason: "A stock phrase can flatten the writer's meaning.", suggestion: 'Ogilvy: say "look closely at" or "examine"' },
144
145
  { id: "ogilvy.preamble-i-want-to", severity: "yellow", expression: /^\s*i want to (?:share|talk about|discuss|mention)\b/i, reason: "A hedge or preamble weakens the direct claim.", suggestion: "Ogilvy: just say it. skip the preamble.", scope: "line" },
145
146
  { id: "ogilvy.preamble-just-wanted", severity: "yellow", expression: /^\s*(?:i just wanted|i wanted to)\b/i, reason: "A hedge or preamble weakens the direct claim.", suggestion: "Ogilvy: just say it.", scope: "line" },
147
+ { id: "formula.performative-sincerity", severity: "red", expression: /\b(?:to be honest|in all honesty)\b/i, reason: "The phrase announces sincerity instead of making the claim directly.", suggestion: "cut the sincerity preamble and state the claim" },
148
+ { id: "hedge.performative-sincerity-adverb", severity: "yellow", expression: /\b(?:honestly|genuinely|truly|frankly|actually)\b/i, reason: "The adverb performs sincerity instead of adding evidence.", suggestion: "cut the adverb or replace it with the evidence" },
146
149
  { id: "ai.question-hook", severity: "yellow", expression: /^(?:have you|do you|what if|why do|how do)\b/i, reason: "A question opener delays the concrete observation.", suggestion: "Open from an observation." },
147
150
  { id: "ai.abstract-cluster", severity: "yellow", expression: /\b(?:alignment|authenticity|clarity|strategy|value)\b.*\b(?:alignment|authenticity|clarity|strategy|value)\b/i, reason: "Abstract nouns pile up without a mechanism.", suggestion: "Use concrete nouns and actions." },
148
151
  ];
package/dist/ai-editor.js CHANGED
@@ -1,10 +1,42 @@
1
1
  import { rules } from './ai-editor-rules.js';
2
2
  import { sentences } from './text.js';
3
3
  export { rules } from './ai-editor-rules.js';
4
- export const RULESET_VERSION = '2.9.24-static.2';
4
+ export const RULESET_VERSION = '3.2.0-reconciled.1';
5
5
  const sentenceRules = rules.filter((rule) => rule.scope !== 'line');
6
6
  const lineRules = rules.filter((rule) => rule.scope === 'line');
7
7
  const ruleOrder = new Map(rules.map((rule, index) => [rule.id, index]));
8
+ const ruleIds = new Set(rules.map((rule) => rule.id));
9
+ const policyStates = new Set(['blocking', 'advisory', 'judgment-required', 'disabled']);
10
+ const suppressedDuplicateIds = new Set(['hedge.worth-noting', 'struct.in-other-words']);
11
+ const reconciledPolicies = {
12
+ 'struct.not-x-y': 'advisory',
13
+ 'struct.this-isnt-x-this-is-y': 'advisory',
14
+ 'struct.same-better': 'disabled',
15
+ 'struct.moment-becomes': 'disabled',
16
+ 'hedge.i-think': 'disabled',
17
+ 'ai.question-hook': 'advisory',
18
+ };
19
+ function defaultPolicy(id, severity) {
20
+ const reconciled = reconciledPolicies[id];
21
+ if (reconciled)
22
+ return reconciled;
23
+ if (severity === 'red' && (id.startsWith('ai.') || id.startsWith('ogilvy.')))
24
+ return 'judgment-required';
25
+ return severity === 'red' ? 'blocking' : 'advisory';
26
+ }
27
+ function policiesFor(profile) {
28
+ if (profile?.version === '3') {
29
+ for (const [id, state] of Object.entries(profile.rulePolicy)) {
30
+ if (!ruleIds.has(id))
31
+ throw new Error(`Profile rulePolicy contains unknown rule ID: ${id}`);
32
+ if (!policyStates.has(state))
33
+ throw new Error(`Profile rulePolicy contains invalid state for rule ID: ${id}`);
34
+ }
35
+ }
36
+ return new Map(rules.map((rule) => [rule.id, profile?.version === '3' && profile.rulePolicy[rule.id]
37
+ ? profile.rulePolicy[rule.id]
38
+ : defaultPolicy(rule.id, rule.severity)]));
39
+ }
8
40
  export function serializedRules() {
9
41
  return rules.map((rule) => ({
10
42
  id: rule.id,
@@ -15,13 +47,13 @@ export function serializedRules() {
15
47
  scope: rule.scope ?? 'sentence',
16
48
  }));
17
49
  }
18
- export function analyzeAiEditor(text) {
19
- const findings = [];
50
+ export function analyzeAiEditor(text, profile) {
51
+ const matched = [];
20
52
  const mapped = sentences(text);
21
53
  for (const sentence of mapped) {
22
54
  for (const rule of sentenceRules) {
23
55
  if (rule.expression.test(sentence.text)) {
24
- findings.push({
56
+ matched.push({
25
57
  engine: 'ai_editor',
26
58
  id: rule.id,
27
59
  severity: rule.severity,
@@ -43,7 +75,7 @@ export function analyzeAiEditor(text) {
43
75
  const sentence = mapped.find((candidate) => candidate.start <= matchStart && matchStart < candidate.end);
44
76
  if (!sentence)
45
77
  continue;
46
- findings.push({
78
+ matched.push({
47
79
  engine: 'ai_editor',
48
80
  id: rule.id,
49
81
  severity: rule.severity,
@@ -55,8 +87,19 @@ export function analyzeAiEditor(text) {
55
87
  }
56
88
  lineStart += line.length + 1;
57
89
  }
58
- findings.sort((left, right) => left.sentence - right.sentence || (ruleOrder.get(left.id) ?? 0) - (ruleOrder.get(right.id) ?? 0));
59
- const reds = findings.reduce((count, finding) => count + Number(finding.severity === 'red'), 0);
60
- const score = Math.max(0, 100 - reds * 18 - (findings.length - reds) * 6);
61
- return { engine: 'ai_editor', version: RULESET_VERSION, score, passed: reds === 0, findings };
90
+ matched.sort((left, right) => left.sentence - right.sentence || (ruleOrder.get(left.id) ?? 0) - (ruleOrder.get(right.id) ?? 0));
91
+ const policies = policiesFor(profile);
92
+ const findings = matched.flatMap((finding) => {
93
+ if (suppressedDuplicateIds.has(finding.id))
94
+ return [];
95
+ if (finding.id === 'ai.question-hook' && finding.sentence !== 1)
96
+ return [];
97
+ const appliedPolicy = policies.get(finding.id);
98
+ if (!appliedPolicy || appliedPolicy === 'disabled')
99
+ return [];
100
+ return [{ ...finding, appliedPolicy, severity: appliedPolicy === 'blocking' ? 'red' : 'yellow' }];
101
+ });
102
+ const blocking = findings.reduce((count, finding) => count + Number(finding.appliedPolicy === 'blocking'), 0);
103
+ const score = Math.max(0, 100 - blocking * 18 - (findings.length - blocking) * 6);
104
+ return { engine: 'ai_editor', version: RULESET_VERSION, score, passed: blocking === 0, findings };
62
105
  }
@@ -1,8 +1,12 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { analyzeAiEditor, rules, serializedRules } from './ai-editor.js';
3
+ import { analyzeAiEditor, RULESET_VERSION, rules, serializedRules } from './ai-editor.js';
4
+ import { createHash } from 'node:crypto';
4
5
  test('publishes executable rules with stable IDs and repair directions', () => {
5
- assert.equal(rules.length, 145);
6
+ assert.equal(RULESET_VERSION, '3.2.0-reconciled.1');
7
+ assert.equal(rules.length, 148);
8
+ assert.equal(createHash('sha256').update(JSON.stringify(rules.map((rule) => rule.id))).digest('hex'), '8d3cdde1922686076cb3baa79c55db95f37c9088d246f47c24405417fe58f979');
9
+ assert.equal(createHash('sha256').update(JSON.stringify(serializedRules())).digest('hex'), 'a758d7cd8e53e42d1a3ada81aff3e61f2994555d286f8915a9fc52767f145094');
6
10
  assert.equal(new Set(rules.map((rule) => rule.id)).size, rules.length);
7
11
  for (const rule of rules) {
8
12
  assert.match(rule.id, /^(ai|formula|hedge|struct|punct|bait|cringe|insider|ogilvy)\./);
@@ -12,6 +16,57 @@ test('publishes executable rules with stable IDs and repair directions', () => {
12
16
  assert.equal(rule.expression.sticky, false, rule.id);
13
17
  }
14
18
  });
19
+ function profileWithPolicies(rulePolicy) {
20
+ return {
21
+ version: '3', id: 'founder.test', revision: 1, revisionDigest: '0'.repeat(64), sampleCount: 2,
22
+ metrics: { sentenceLength: 5, sentenceVariation: 1, sentenceStructure: [], rhythm: 1, paragraphLength: 1, openingMoves: [], vocabulary: [], lexicalDensity: 0.5, pointOfView: 'mixed', punctuation: {}, caseStyle: 'mixed', questionRate: 0, transitions: [] },
23
+ avoid: [], provenance: { source: 'test', rights: 'test', createdAt: '2026-08-13T00:00:00.000Z' }, rulePolicy,
24
+ fingerprint: { contractionRate: 0, sentenceLengthDistribution: { short: 1, medium: 0, long: 0 }, bulletRate: 0, enDashRate: 0 },
25
+ tolerances: { contractionRate: { absolute: 0, calibrated: false }, sentenceLengthDistribution: { absolute: 0, calibrated: false }, bulletRate: { absolute: 0, calibrated: false }, enDashRate: { absolute: 0, calibrated: false } },
26
+ metricFixtures: { contractionRate: ['test'], sentenceLengthDistribution: ['test'], bulletRate: ['test'], enDashRate: ['test'] },
27
+ };
28
+ }
29
+ test('applies all four v3 policy states after matching and preserves catalog order', () => {
30
+ const report = analyzeAiEditor('Firstly, perhaps we leverage a holistic plan.', profileWithPolicies({
31
+ 'formula.firstly': 'blocking',
32
+ 'hedge.perhaps': 'advisory',
33
+ 'ai.leverage': 'judgment-required',
34
+ 'ai.holistic': 'disabled',
35
+ }));
36
+ assert.deepEqual(report.findings.map((finding) => [finding.id, finding.appliedPolicy, finding.severity]), [
37
+ ['ai.leverage', 'judgment-required', 'yellow'],
38
+ ['formula.firstly', 'blocking', 'red'],
39
+ ['hedge.perhaps', 'advisory', 'yellow'],
40
+ ]);
41
+ assert.equal(report.passed, false);
42
+ });
43
+ test('fails closed when a v3 policy names a rule outside the catalog', () => {
44
+ assert.throws(() => analyzeAiEditor('Plain text.', profileWithPolicies({ 'ai.missing': 'blocking' })), /unknown rule ID/);
45
+ });
46
+ test('uses reconciled defaults for v2 profiles and suppresses inherited duplicate emissions', () => {
47
+ const report = analyzeAiEditor("It's worth noting: in other words, I think the same plan. Better results.");
48
+ assert.equal(report.findings.some((finding) => finding.id === 'hedge.worth-noting'), false);
49
+ assert.equal(report.findings.some((finding) => finding.id === 'struct.in-other-words'), false);
50
+ assert.equal(report.findings.some((finding) => finding.id === 'hedge.i-think'), false);
51
+ assert.equal(report.findings.some((finding) => finding.id === 'struct.same-better'), false);
52
+ assert.ok(report.findings.every((finding) => finding.appliedPolicy !== undefined));
53
+ });
54
+ test('treats bare red vocabulary as pending judgment and clear sincerity or dashes as blocking', () => {
55
+ const vocabulary = analyzeAiEditor('We leverage the existing scheduler.');
56
+ assert.deepEqual(vocabulary.findings.find((finding) => finding.id === 'ai.leverage')?.appliedPolicy, 'judgment-required');
57
+ assert.equal(vocabulary.passed, true);
58
+ const blocked = analyzeAiEditor('To be honest, the scheduler failed — twice.');
59
+ assert.ok(blocked.findings.some((finding) => finding.id === 'formula.performative-sincerity' && finding.appliedPolicy === 'blocking'));
60
+ assert.ok(blocked.findings.some((finding) => finding.id === 'punct.em-dash' && finding.appliedPolicy === 'blocking'));
61
+ assert.equal(blocked.passed, false);
62
+ const advisory = analyzeAiEditor('Honestly, the scheduler failed twice.');
63
+ assert.ok(advisory.findings.some((finding) => finding.id === 'hedge.performative-sincerity-adverb' && finding.appliedPolicy === 'advisory'));
64
+ assert.equal(advisory.passed, true);
65
+ });
66
+ test('only applies the question-hook policy to document sentence one', () => {
67
+ assert.ok(analyzeAiEditor('Have you checked the invoice? It is overdue.').findings.some((finding) => finding.id === 'ai.question-hook'));
68
+ assert.equal(analyzeAiEditor('The invoice is overdue. Have you checked it?').findings.some((finding) => finding.id === 'ai.question-hook'), false);
69
+ });
15
70
  test('detects representative rules from every inherited rule family', () => {
16
71
  const examples = [
17
72
  ['ai.delve', 'we will delve into it.'],
@@ -87,10 +142,7 @@ test('executes inherited cross-sentence rules and maps them to the first sentenc
87
142
  const report = analyzeAiEditor('No demos. No decks. No distractions. Same team. Better results.');
88
143
  assert.deepEqual(report.findings
89
144
  .filter((finding) => finding.id === 'struct.negation-cascade' || finding.id === 'struct.same-better')
90
- .map((finding) => [finding.id, finding.sentence]), [
91
- ['struct.negation-cascade', 1],
92
- ['struct.same-better', 4],
93
- ]);
145
+ .map((finding) => [finding.id, finding.sentence]), [['struct.negation-cascade', 1]]);
94
146
  });
95
147
  test('preserves inherited physical-line matching and line-start anchors', () => {
96
148
  const sameLine = analyzeAiEditor("This isn't positioning. This is proof. Forget vanity metrics. You need retention.");
@@ -106,16 +158,16 @@ test('retains the current question-hook and abstract-cluster detectors', () => {
106
158
  });
107
159
  test('serializes reconstructable regular expressions and explicit scopes', () => {
108
160
  const catalog = serializedRules();
109
- assert.equal(catalog.length, 145);
161
+ assert.equal(catalog.length, 148);
110
162
  assert.ok(catalog.every((rule) => rule.scope === 'sentence' || rule.scope === 'line'));
111
163
  const meaningful = catalog.find((rule) => rule.id === 'ai.meaningful');
112
164
  assert.ok(meaningful);
113
165
  assert.equal(new RegExp(meaningful.expression.source, meaningful.expression.flags).test('Meaningful work.'), true);
114
166
  });
115
- test('keeps intentional inherited overlaps visible and scores each finding', () => {
167
+ test('suppresses intentional inherited overlaps before scoring', () => {
116
168
  const report = analyzeAiEditor('In other words, use logs.');
117
- assert.deepEqual(report.findings.map((finding) => finding.id), ['formula.in-other-words', 'struct.in-other-words']);
118
- assert.equal(report.score, 64);
169
+ assert.deepEqual(report.findings.map((finding) => finding.id), ['formula.in-other-words']);
170
+ assert.equal(report.score, 82);
119
171
  });
120
172
  test('returns zero AI findings for clean input', () => {
121
173
  assert.deepEqual(analyzeAiEditor('The launch starts Tuesday. The owner signed the release checklist.').findings, []);
@@ -0,0 +1,111 @@
1
+ import { createHash, createPublicKey, verify } from 'node:crypto';
2
+ import { parseCanonicalJson } from './canonical-json.js';
3
+ const DIGEST = /^[a-f0-9]{64}$/;
4
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
5
+ const CLAIM_KEYS = ['version', 'purpose', 'issuer', 'audience', 'subjectArtifactFingerprint', 'sourceHash', 'candidateHash', 'profileId', 'profileRevisionDigest', 'keyId', 'issuedAt', 'notBefore', 'expiresAt', 'nonce'];
6
+ const STORE_KEYS = ['version', 'audience', 'maxCapabilityLifetimeSeconds', 'keys'];
7
+ function fail(error) { return { ok: false, error }; }
8
+ function plain(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; }
9
+ function exactKeys(value, required, optional = []) {
10
+ return required.every((key) => key in value) && Object.keys(value).every((key) => required.includes(key) || optional.includes(key));
11
+ }
12
+ function bounded(value) { return typeof value === 'string' && value.length > 0 && value.length <= 128; }
13
+ function safeTime(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; }
14
+ function validClaims(value) {
15
+ if (!plain(value) || !exactKeys(value, CLAIM_KEYS))
16
+ return false;
17
+ return bounded(value.issuer) && bounded(value.keyId) && bounded(value.profileId) && bounded(value.nonce)
18
+ && typeof value.version === 'string' && typeof value.purpose === 'string' && typeof value.audience === 'string'
19
+ && [value.subjectArtifactFingerprint, value.sourceHash, value.candidateHash].every((item) => typeof item === 'string' && DIGEST.test(item)) && bounded(value.profileRevisionDigest)
20
+ && safeTime(value.issuedAt) && safeTime(value.notBefore) && safeTime(value.expiresAt);
21
+ }
22
+ export function parseApprovalTrustStore(value) {
23
+ if (!plain(value) || !exactKeys(value, STORE_KEYS) || value.version !== '1' || value.audience !== '@holdyourvoice/hyv'
24
+ || !Number.isSafeInteger(value.maxCapabilityLifetimeSeconds) || value.maxCapabilityLifetimeSeconds < 1 || value.maxCapabilityLifetimeSeconds > 86400 || !Array.isArray(value.keys) || value.keys.length > 128)
25
+ return undefined;
26
+ const pairs = new Set();
27
+ for (const item of value.keys) {
28
+ if (!plain(item) || !exactKeys(item, ['issuer', 'keyId', 'publicKeySpki', 'status'], ['activeFrom', 'activeUntil']) || !bounded(item.issuer) || !bounded(item.keyId)
29
+ || typeof item.publicKeySpki !== 'string' || !BASE64URL.test(item.publicKeySpki) || !['active', 'revoked'].includes(item.status)
30
+ || (item.activeFrom !== undefined && !safeTime(item.activeFrom)) || (item.activeUntil !== undefined && !safeTime(item.activeUntil)))
31
+ return undefined;
32
+ const pair = `${item.issuer}\0${item.keyId}`;
33
+ if (pairs.has(pair))
34
+ return undefined;
35
+ pairs.add(pair);
36
+ try {
37
+ if (item.publicKeySpki.length > 128)
38
+ return undefined;
39
+ const der = Buffer.from(item.publicKeySpki, 'base64url');
40
+ const key = createPublicKey({ key: der, format: 'der', type: 'spki' });
41
+ if (der.length !== 44 || key.asymmetricKeyType !== 'ed25519')
42
+ return undefined;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ return value;
49
+ }
50
+ export function verifyApprovalCapability(envelope, trustValue, expected) {
51
+ if (!safeTime(expected.now))
52
+ return fail('invalid_schema');
53
+ if (!plain(envelope) || !exactKeys(envelope, ['payload', 'signature']) || typeof envelope.payload !== 'string' || typeof envelope.signature !== 'string'
54
+ || !BASE64URL.test(envelope.payload) || !BASE64URL.test(envelope.signature))
55
+ return fail('invalid_encoding');
56
+ if (envelope.payload.length > 5462 || envelope.signature.length !== 86)
57
+ return fail('size_exceeded');
58
+ const payload = Buffer.from(envelope.payload, 'base64url');
59
+ const signature = Buffer.from(envelope.signature, 'base64url');
60
+ if (payload.length > 4096 || signature.length !== 64)
61
+ return fail('size_exceeded');
62
+ if (payload.toString('base64url') !== envelope.payload || signature.toString('base64url') !== envelope.signature)
63
+ return fail('invalid_encoding');
64
+ let parsed;
65
+ try {
66
+ parsed = parseCanonicalJson(payload);
67
+ }
68
+ catch (error) {
69
+ return fail(error instanceof Error && /canonical/i.test(error.message) ? 'non_canonical' : 'invalid_schema');
70
+ }
71
+ if (!validClaims(parsed))
72
+ return fail('invalid_schema');
73
+ const claims = parsed;
74
+ if (claims.version !== '1')
75
+ return fail('wrong_version');
76
+ if (claims.purpose !== expected.expectedPurpose)
77
+ return fail('wrong_purpose');
78
+ if (claims.audience !== '@holdyourvoice/hyv')
79
+ return fail('wrong_audience');
80
+ if (claims.subjectArtifactFingerprint !== expected.expectedSubjectArtifactFingerprint || claims.sourceHash !== expected.binding.sourceHash || claims.candidateHash !== expected.binding.candidateHash || claims.profileId !== expected.binding.profileId || claims.profileRevisionDigest !== expected.binding.profileRevisionDigest)
81
+ return fail('binding_mismatch');
82
+ const trustStore = parseApprovalTrustStore(trustValue);
83
+ if (!trustStore)
84
+ return fail('invalid_schema');
85
+ if (claims.audience !== trustStore.audience)
86
+ return fail('wrong_audience');
87
+ const key = trustStore.keys.find((item) => item.issuer === claims.issuer && item.keyId === claims.keyId);
88
+ if (!key)
89
+ return fail('unknown_key');
90
+ if (key.status === 'revoked')
91
+ return fail('revoked_key');
92
+ if ((key.activeFrom !== undefined && (claims.issuedAt < key.activeFrom || expected.now < key.activeFrom)) || (key.activeUntil !== undefined && (claims.issuedAt >= key.activeUntil || expected.now >= key.activeUntil)))
93
+ return fail('inactive_key');
94
+ if (!(claims.issuedAt <= claims.notBefore && claims.notBefore < claims.expiresAt))
95
+ return fail('invalid_schema');
96
+ if (claims.expiresAt - claims.issuedAt > trustStore.maxCapabilityLifetimeSeconds)
97
+ return fail('lifetime_exceeded');
98
+ if (expected.now < claims.notBefore)
99
+ return fail('premature');
100
+ if (expected.now >= claims.expiresAt)
101
+ return fail('expired');
102
+ try {
103
+ const publicKey = createPublicKey({ key: Buffer.from(key.publicKeySpki, 'base64url'), format: 'der', type: 'spki' });
104
+ if (!verify(null, payload, publicKey, signature))
105
+ return fail('invalid_signature');
106
+ }
107
+ catch {
108
+ return fail('invalid_signature');
109
+ }
110
+ return { ok: true, capabilityFingerprint: createHash('sha256').update('hyv:approval-capability:v1\0').update(payload).update(signature).digest('hex') };
111
+ }
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import { generateKeyPairSync, sign } from 'node:crypto';
3
+ import test from 'node:test';
4
+ import { canonicalJsonBytes } from './canonical-json.js';
5
+ import { verifyApprovalCapability } from './approval-capability.js';
6
+ const binding = {
7
+ rewriteTaskFingerprint: '1'.repeat(64), rewriteResponseFingerprint: '2'.repeat(64), deterministicArtifactFingerprint: '3'.repeat(64),
8
+ sourceHash: '4'.repeat(64), candidateHash: '5'.repeat(64), profileId: 'founder.primary', profileRevisionDigest: '6'.repeat(64),
9
+ rulesetVersion: '3.2.0', schemaVersion: '1',
10
+ };
11
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
12
+ const publicKeySpki = publicKey.export({ format: 'der', type: 'spki' }).toString('base64url');
13
+ const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'host.example', keyId: 'key-1', publicKeySpki, status: 'active' }] };
14
+ function envelope(overrides = {}, signer = privateKey) {
15
+ const claims = {
16
+ version: '1', purpose: 'hyv.final-approval', issuer: 'host.example', audience: '@holdyourvoice/hyv',
17
+ subjectArtifactFingerprint: '7'.repeat(64), sourceHash: binding.sourceHash, candidateHash: binding.candidateHash,
18
+ profileId: binding.profileId, profileRevisionDigest: binding.profileRevisionDigest, keyId: 'key-1',
19
+ issuedAt: 100, notBefore: 100, expiresAt: 200, nonce: 'nonce-1', ...overrides,
20
+ };
21
+ const payload = canonicalJsonBytes(claims);
22
+ return { payload: payload.toString('base64url'), signature: sign(null, payload, signer).toString('base64url') };
23
+ }
24
+ test('verifies one canonical bound Ed25519 final-approval capability', () => {
25
+ const result = verifyApprovalCapability(envelope(), trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' });
26
+ assert.equal(result.ok, true);
27
+ if (result.ok)
28
+ assert.match(result.capabilityFingerprint, /^[a-f0-9]{64}$/);
29
+ });
30
+ test('fails closed for purpose, binding, trust, time, signature, and canonical encoding', () => {
31
+ assert.deepEqual(verifyApprovalCapability(envelope({ purpose: 'hyv.rebuild-authorization' }), trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'wrong_purpose' });
32
+ assert.deepEqual(verifyApprovalCapability(envelope({ candidateHash: '8'.repeat(64) }), trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'binding_mismatch' });
33
+ assert.deepEqual(verifyApprovalCapability(envelope(), { ...trustStore, keys: [{ ...trustStore.keys[0], status: 'revoked' }] }, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'revoked_key' });
34
+ assert.deepEqual(verifyApprovalCapability(envelope(), trustStore, { now: 201, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'expired' });
35
+ const forged = envelope();
36
+ const forgedBytes = Buffer.from(forged.signature, 'base64url');
37
+ forgedBytes[0] = forgedBytes[0] ^ 1;
38
+ forged.signature = forgedBytes.toString('base64url');
39
+ assert.deepEqual(verifyApprovalCapability(forged, trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'invalid_signature' });
40
+ const nonCanonical = envelope();
41
+ nonCanonical.payload = Buffer.from(` ${Buffer.from(nonCanonical.payload, 'base64url').toString('utf8')}`).toString('base64url');
42
+ assert.deepEqual(verifyApprovalCapability(nonCanonical, trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'non_canonical' });
43
+ });
44
+ test('rejects malformed envelopes and invalid trust stores without returning secret material', () => {
45
+ const result = verifyApprovalCapability({ payload: `${envelope().payload}=`, signature: envelope().signature }, trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' });
46
+ assert.deepEqual(result, { ok: false, error: 'invalid_encoding' });
47
+ assert.doesNotMatch(JSON.stringify(result), /nonce-1|signature|publicKeySpki/);
48
+ assert.deepEqual(verifyApprovalCapability(envelope(), { ...trustStore, keys: [...trustStore.keys, trustStore.keys[0]] }, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'invalid_schema' });
49
+ });
50
+ test('rejects an invalid host clock', () => {
51
+ assert.deepEqual(verifyApprovalCapability(envelope(), trustStore, { now: Number.NaN, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'invalid_schema' });
52
+ });
@@ -0,0 +1,54 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
2
+ import { userInfo } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { parseApprovalTrustStore } from './approval-capability.js';
5
+ const MAX_BYTES = 1024 * 1024;
6
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
7
+ function readBounded(descriptor) {
8
+ const chunks = [];
9
+ let size = 0;
10
+ while (size <= MAX_BYTES) {
11
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_BYTES + 1 - size));
12
+ const count = readSync(descriptor, chunk, 0, chunk.length, null);
13
+ if (!count)
14
+ break;
15
+ chunks.push(chunk.subarray(0, count));
16
+ size += count;
17
+ }
18
+ if (size > MAX_BYTES)
19
+ throw new Error();
20
+ return Buffer.concat(chunks, size).toString('utf8');
21
+ }
22
+ function validIds(value) {
23
+ return Array.isArray(value) && value.length <= 128 && value.every((item) => typeof item === 'string' && ID.test(item)) && new Set(value).size === value.length;
24
+ }
25
+ export function approvalContextMetadataIsSafe(value, effectiveUserId) {
26
+ if (!value.isFile() || value.nlink !== 1 || value.size > MAX_BYTES)
27
+ return false;
28
+ if (effectiveUserId === undefined)
29
+ return process.platform === 'win32';
30
+ return value.uid === effectiveUserId && (value.mode & 0o077) === 0;
31
+ }
32
+ export function loadApprovalContext(path = join(userInfo().homedir, '.config', 'holdyourvoice', 'approval-context.json')) {
33
+ let descriptor;
34
+ try {
35
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
36
+ const before = fstatSync(descriptor);
37
+ if (!approvalContextMetadataIsSafe(before, process.geteuid?.()))
38
+ throw new Error();
39
+ const value = JSON.parse(readBounded(descriptor));
40
+ const after = fstatSync(descriptor);
41
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs
42
+ || !value || typeof value !== 'object' || !parseApprovalTrustStore(value.trustStore) || !value.authorizedSemanticEvaluatorIds
43
+ || !validIds(value.authorizedSemanticEvaluatorIds.normal) || !validIds(value.authorizedSemanticEvaluatorIds.highAssurance) || !validIds(value.authorizedHumanFinalizerIds))
44
+ throw new Error();
45
+ return { ...value, now: Math.floor(Date.now() / 1000) };
46
+ }
47
+ catch {
48
+ throw new Error('Approval context is unavailable or unsafe.');
49
+ }
50
+ finally {
51
+ if (descriptor !== undefined)
52
+ closeSync(descriptor);
53
+ }
54
+ }