@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.
- package/Readme.md +51 -11
- package/dist/ai-editor-rules.js +5 -2
- package/dist/ai-editor.js +52 -9
- package/dist/ai-editor.test.js +62 -10
- package/dist/approval-capability.js +111 -0
- package/dist/approval-capability.test.js +52 -0
- package/dist/approval-context.js +54 -0
- package/dist/approval-context.test.js +38 -0
- package/dist/benchmark.js +232 -0
- package/dist/benchmark.test.js +328 -0
- package/dist/canonical-json.js +123 -0
- package/dist/canonical-json.test.js +24 -0
- package/dist/cli.js +272 -19
- package/dist/cli.test.js +205 -8
- package/dist/hygiene.js +6 -0
- package/dist/hygiene.test.js +7 -1
- package/dist/judgment-task.js +171 -0
- package/dist/judgment-task.test.js +162 -0
- package/dist/learning.js +240 -100
- package/dist/learning.test.js +203 -3
- package/dist/lifecycle-adapter.js +75 -0
- package/dist/lifecycle-adapter.test.js +56 -0
- package/dist/mcp-tools.js +101 -7
- package/dist/mcp-tools.test.js +156 -6
- package/dist/mcp.js +213 -6
- package/dist/mcp.test.js +210 -11
- package/dist/pipeline.js +78 -14
- package/dist/pipeline.test.js +36 -2
- package/dist/preservation.js +89 -0
- package/dist/preservation.test.js +22 -0
- package/dist/profile.js +87 -0
- package/dist/profile.test.js +114 -0
- package/dist/rebuild-task.js +226 -0
- package/dist/rebuild-task.test.js +179 -0
- package/dist/release-audit.test.js +111 -2
- package/dist/rewrite-task.js +136 -16
- package/dist/rewrite-task.test.js +62 -7
- package/dist/rule-reconciliation.test.js +50 -0
- package/dist/semantic-review.js +176 -7
- package/dist/semantic-review.test.js +98 -14
- package/dist/stage1-dry-run.test.js +39 -0
- package/dist/stage1-evaluation.js +579 -0
- package/dist/stage1-evaluation.test.js +184 -0
- package/dist/stage1-human-packet.test.js +102 -0
- package/dist/stage1-schema-contract.test.js +95 -0
- package/dist/stage2-human-packet.test.js +81 -0
- package/dist/version.js +1 -1
- package/dist/voice-dna.js +53 -1
- package/dist/voice-dna.test.js +79 -1
- 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:**
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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>
|
|
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.
|
|
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
|
|
package/dist/ai-editor-rules.js
CHANGED
|
@@ -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:
|
|
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: "
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
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
|
}
|
package/dist/ai-editor.test.js
CHANGED
|
@@ -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(
|
|
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,
|
|
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('
|
|
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'
|
|
118
|
-
assert.equal(report.score,
|
|
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
|
+
}
|