@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.
- package/Readme.md +23 -10
- 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:** 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.
|
|
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.
|
|
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
|
|
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.
|
|
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>
|
|
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.
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
+
});
|