@holdyourvoice/hyv 3.2.0 → 3.3.0

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 +23 -10
  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:** the public CLI is published as [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv). It runs locally and makes no runtime network requests. Version 3.3.0 adds pre-edit judgments, range edits, and authorized rebuild. Writer-study kits remain blocked optional research. Product publish uses the version bump and CI.
18
18
 
19
19
  ## Why it exists
20
20
 
@@ -55,7 +55,7 @@ To contribute, clone this repository, run `npm install`, then run `npm test` and
55
55
 
56
56
  ### Use it in Claude Desktop
57
57
 
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).
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. 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
59
 
60
60
  ### Build a local VoiceDNA profile
61
61
 
@@ -167,7 +167,7 @@ Give the brief and draft to a human editor or any model you trust. This reposito
167
167
  npx @holdyourvoice/hyv verify draft.md candidate.md profile.json
168
168
  ```
169
169
 
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.
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. 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
171
 
172
172
  ### Lock factual claims with a CopySpec
173
173
 
@@ -199,7 +199,7 @@ The check is deterministic. Without `atoms`, an immutable claim remains a verbat
199
199
 
200
200
  ### Local voice memory
201
201
 
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.
202
+ 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
203
 
204
204
  ```bash
205
205
  hyv learning show profile.json
@@ -268,7 +268,7 @@ Read the full [VoiceDNA reference](docs/VOICE-DNA.md) and [Wiki guide](https://g
268
268
 
269
269
  ## AI Editor: inspectable rules
270
270
 
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.
271
+ 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
272
 
273
273
  Run this command to see the rules and ruleset version that actually execute in the published CLI:
274
274
 
@@ -302,13 +302,20 @@ The preservation score is a guardrail based on retained original words longer th
302
302
  | `hyv hygiene <draft> [--fix] [--output=path]` | Draft | Hygiene report or cleaned copy plus receipt | You need to inspect or conservatively clean hidden Unicode. |
303
303
  | `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
304
  | `hyv rewrite-prompt <draft> <profile.json>` | Draft and profile | Markdown editing brief | You need a constrained request for an editor or model. |
305
+ | `hyv prepare-rewrite <draft> <profile.json> <task.json>` | Draft and profile | Versioned task file plus metadata | A host needs a fingerprint-bound sentence-edit task. |
306
+ | `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. |
305
307
  | `hyv verify <original> <candidate> <profile.json>` | Original, candidate, profile | Verification JSON and exit code | You need the candidate gate. |
306
308
  | `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. |
309
+ | `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. |
310
+ | `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
311
  | `hyv patterns` | None | Ruleset JSON | You need the exact enabled rules. |
309
312
 
310
313
  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
314
 
315
+ 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.
316
+
317
+ The standalone CLI supports normal-policy semantic review. High-assurance review requires a trusted embedding and is rejected by the CLI. Approval 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`, `lifecycle submit-verdict`, and `lifecycle finalize` exit `2` when the candidate or transition is not accepted, while usage and runtime failures exit `1`.
318
+
312
319
  ## Project map
313
320
 
314
321
  | Path | Responsibility |
@@ -320,17 +327,23 @@ Every file argument can be `-` when the command accepts text input from standard
320
327
  | `src/ai-editor.ts` | Owns the versioned deterministic editorial rules. |
321
328
  | `src/editorial-packs.ts` | Parses WritingBrief context and runs format and batch checks. |
322
329
  | `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. |
330
+ | `src/pipeline.ts` | Combines scored pass states, makes briefs, and verifies candidates. |
331
+ | `src/rewrite-task.ts` | Prepares and evaluates fingerprint-bound sentence-replacement tasks. |
332
+ | `src/semantic-review.ts` | Defines and reduces semantic and human-review lifecycle artifacts. |
333
+ | `src/approval-capability.ts` | Verifies canonical signed approval capabilities. |
334
+ | `src/approval-context.ts` | Loads permission-checked trust roots and evaluator authorization. |
335
+ | `src/lifecycle-adapter.ts` | Shares lifecycle operations across CLI and MCP adapters. |
324
336
  | `src/cli.ts` | Local file and standard-input command adapter. |
337
+ | `src/mcp.ts` | Local stdio MCP registration and host-capability gating. |
325
338
  | `src/pipeline.test.ts` | Contract and regression tests. |
326
339
  | `CONTRIBUTING.md` | Public-safety rules and the contributor model. |
327
340
  | `scripts/release-audit.mjs` | Checks source files for credential and network markers. |
328
341
 
329
- `pipeline.ts` is the sole composition point. It combines pass states and preserves each engine’s separate score.
342
+ `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
343
 
331
344
  ## Privacy and data rights
332
345
 
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.
346
+ 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
347
 
335
348
  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
349
 
@@ -365,7 +378,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Keep chan
365
378
 
366
379
  ## npm releases
367
380
 
368
- `@holdyourvoice/hyv` is published automatically after a change to the package source reaches `main`. The workflow publishes only when the version in `package.json` is not already on npm, so bump that version in the same pull request as a release-worthy change. It runs the tests and release audit before publishing, then verifies that npm reports the package as MIT licensed.
381
+ `@holdyourvoice/hyv` is published automatically after a change to the package source reaches `main`. The workflow publishes only when the version in `package.json` is not already on npm, so bump that version in the same pull request as a release-worthy change. It runs the tests and release audit before publishing, then verifies that npm reports the package as MIT licensed. Writer-study kits stay optional research. Product publish uses the version bump and CI. Keep writer-checkpoint claims off the publish.
369
382
 
370
383
  ## Support
371
384
 
@@ -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
+ }
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert/strict';
2
+ import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { approvalContextMetadataIsSafe, loadApprovalContext } from './approval-context.js';
7
+ const context = { now: 0, trustStore: { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [] }, authorizedSemanticEvaluatorIds: { normal: ['reviewer-1'], highAssurance: [] }, authorizedHumanFinalizerIds: ['human-1'] };
8
+ test('loads only a permission-checked installed approval context and replaces its clock', () => {
9
+ const root = mkdtempSync(join(tmpdir(), 'hyv-approval-context-'));
10
+ try {
11
+ const safe = join(root, 'safe.json');
12
+ writeFileSync(safe, JSON.stringify(context), { mode: 0o600 });
13
+ const loaded = loadApprovalContext(safe);
14
+ assert.deepEqual(loaded.authorizedSemanticEvaluatorIds.normal, ['reviewer-1']);
15
+ assert.notEqual(loaded.now, 0);
16
+ chmodSync(safe, 0o644);
17
+ assert.throws(() => loadApprovalContext(safe), /unavailable or unsafe/);
18
+ chmodSync(safe, 0o600);
19
+ const link = join(root, 'link.json');
20
+ symlinkSync(safe, link);
21
+ assert.throws(() => loadApprovalContext(link), /unavailable or unsafe/);
22
+ const malformed = join(root, 'malformed.json');
23
+ writeFileSync(malformed, JSON.stringify({ ...context, authorizedSemanticEvaluatorIds: { normal: ['bad id'], highAssurance: [] } }), { mode: 0o600 });
24
+ assert.throws(() => loadApprovalContext(malformed), /unavailable or unsafe/);
25
+ const malformedTrust = join(root, 'malformed-trust.json');
26
+ writeFileSync(malformedTrust, JSON.stringify({ ...context, trustStore: { ...context.trustStore, maxCapabilityLifetimeSeconds: 0 } }), { mode: 0o600 });
27
+ assert.throws(() => loadApprovalContext(malformedTrust), /unavailable or unsafe/);
28
+ }
29
+ finally {
30
+ rmSync(root, { recursive: true, force: true });
31
+ }
32
+ });
33
+ test('fails closed when POSIX ownership metadata is unavailable on this host', () => {
34
+ const metadata = { isFile: () => true, nlink: 1, size: 10, uid: 501, mode: 0o600 };
35
+ assert.equal(approvalContextMetadataIsSafe(metadata, undefined), process.platform === 'win32');
36
+ assert.equal(approvalContextMetadataIsSafe(metadata, 501), true);
37
+ assert.equal(approvalContextMetadataIsSafe({ ...metadata, mode: 0o644 }, 501), false);
38
+ });