@holdyourvoice/hyv 3.1.1 → 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 +76 -17
- package/dist/ai-editor-rules.js +151 -0
- package/dist/ai-editor.js +104 -8
- package/dist/ai-editor.test.js +135 -22
- 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 +359 -21
- package/dist/cli.test.js +275 -7
- package/dist/copy-spec.js +35 -8
- package/dist/editorial-packs.js +25 -1
- package/dist/editorial-packs.test.js +45 -0
- package/dist/hygiene.js +91 -0
- package/dist/hygiene.test.js +73 -0
- 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 +110 -9
- package/dist/mcp-tools.test.js +188 -10
- package/dist/mcp.js +228 -9
- package/dist/mcp.test.js +248 -12
- package/dist/pipeline.js +81 -15
- package/dist/pipeline.test.js +94 -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 +144 -2
- package/dist/rewrite-task.js +136 -16
- package/dist/rewrite-task.test.js +72 -4
- 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 -0
- package/dist/voice-dna.js +53 -1
- package/dist/voice-dna.test.js +79 -1
- package/package.json +2 -2
package/dist/ai-editor.test.js
CHANGED
|
@@ -1,15 +1,73 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { analyzeAiEditor, rules } 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.
|
|
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');
|
|
10
|
+
assert.equal(new Set(rules.map((rule) => rule.id)).size, rules.length);
|
|
6
11
|
for (const rule of rules) {
|
|
7
|
-
assert.match(rule.id, /^ai\./);
|
|
12
|
+
assert.match(rule.id, /^(ai|formula|hedge|struct|punct|bait|cringe|insider|ogilvy)\./);
|
|
8
13
|
assert.ok(rule.reason.length > 0);
|
|
9
14
|
assert.ok(rule.suggestion.length > 0);
|
|
15
|
+
assert.equal(rule.expression.global, false, rule.id);
|
|
16
|
+
assert.equal(rule.expression.sticky, false, rule.id);
|
|
10
17
|
}
|
|
11
18
|
});
|
|
12
|
-
|
|
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
|
+
});
|
|
70
|
+
test('detects representative rules from every inherited rule family', () => {
|
|
13
71
|
const examples = [
|
|
14
72
|
['ai.delve', 'we will delve into it.'],
|
|
15
73
|
['ai.leverage', 'we leverage the existing logs.'],
|
|
@@ -18,21 +76,23 @@ test('detects every executable rule against its exact sentence', () => {
|
|
|
18
76
|
['ai.robust', 'robust evidence supports the claim.'],
|
|
19
77
|
['ai.landscape', 'the market landscape changed.'],
|
|
20
78
|
['ai.game-changer', 'this is a game-changer.'],
|
|
21
|
-
['
|
|
22
|
-
['
|
|
23
|
-
['
|
|
24
|
-
['
|
|
25
|
-
['
|
|
26
|
-
['
|
|
27
|
-
['
|
|
28
|
-
['
|
|
79
|
+
['formula.firstly', 'Firstly, check the invoice.'],
|
|
80
|
+
['hedge.perhaps', 'Perhaps the invoice is late.'],
|
|
81
|
+
['struct.this-is-why', 'This is why the invoice matters.'],
|
|
82
|
+
['struct.not-just-but-also', 'this is not just fast but reliable.'],
|
|
83
|
+
['struct.rhetorical-truth', 'the hard truth is in the logs.'],
|
|
84
|
+
['punct.em-dash', 'the logs failed — retry later.'],
|
|
85
|
+
['bait.let-that-sink', 'Let that sink in.'],
|
|
86
|
+
['cringe.10x', 'The change delivered a 10x result.'],
|
|
87
|
+
['insider.nobody-tells', 'What nobody tells you is in the report.'],
|
|
88
|
+
['ogilvy.bandwidth', 'We lack the bandwidth this week.'],
|
|
29
89
|
];
|
|
30
90
|
for (const [id, example] of examples) {
|
|
31
91
|
const report = analyzeAiEditor(example);
|
|
32
|
-
assert.
|
|
92
|
+
assert.ok(report.findings.some((finding) => finding.id === id && finding.sentence === 1), id);
|
|
33
93
|
}
|
|
34
94
|
});
|
|
35
|
-
test('keeps
|
|
95
|
+
test('keeps counterexamples for representative inherited rules', () => {
|
|
36
96
|
const counterexamples = [
|
|
37
97
|
['ai.delve', 'we inspect the logs.'],
|
|
38
98
|
['ai.leverage', 'we use the existing logs.'],
|
|
@@ -41,14 +101,16 @@ test('keeps a counterexample for every executable rule', () => {
|
|
|
41
101
|
['ai.robust', 'the evidence includes three dated reports.'],
|
|
42
102
|
['ai.landscape', 'the market changed after the price cut.'],
|
|
43
103
|
['ai.game-changer', 'the release removed a manual step.'],
|
|
44
|
-
['
|
|
45
|
-
['
|
|
46
|
-
['
|
|
47
|
-
['
|
|
48
|
-
['
|
|
49
|
-
['
|
|
50
|
-
['
|
|
51
|
-
['
|
|
104
|
+
['formula.firstly', 'next, check the invoice.'],
|
|
105
|
+
['hedge.perhaps', 'the invoice is late.'],
|
|
106
|
+
['struct.this-is-why', 'the invoice matters because it is overdue.'],
|
|
107
|
+
['struct.not-just-but-also', 'the service is fast and reliable.'],
|
|
108
|
+
['struct.rhetorical-truth', 'the logs show the service failed.'],
|
|
109
|
+
['punct.em-dash', 'the logs failed; retry later.'],
|
|
110
|
+
['bait.let-that-sink', 'The invoice is overdue.'],
|
|
111
|
+
['cringe.10x', 'The result rose from 2 to 20.'],
|
|
112
|
+
['insider.nobody-tells', 'The report explains the missing step.'],
|
|
113
|
+
['ogilvy.bandwidth', 'We lack time this week.'],
|
|
52
114
|
];
|
|
53
115
|
for (const [id, example] of counterexamples) {
|
|
54
116
|
assert.equal(analyzeAiEditor(example).findings.some((finding) => finding.id === id), false, id);
|
|
@@ -59,3 +121,54 @@ test('keeps yellow findings as review cues rather than release blockers', () =>
|
|
|
59
121
|
assert.ok(report.findings.every((finding) => finding.severity === 'yellow'));
|
|
60
122
|
assert.equal(report.passed, true);
|
|
61
123
|
});
|
|
124
|
+
test('restores the benchmark signals from the 2.9.24 executable catalog', () => {
|
|
125
|
+
const report = analyzeAiEditor("This work is meaningful. Here's the part nobody is talking about. The change delivered a 10x result.");
|
|
126
|
+
assert.deepEqual(report.findings.map((finding) => [finding.id, finding.sentence]), [
|
|
127
|
+
['ai.meaningful', 1],
|
|
128
|
+
['struct.heres-where', 2],
|
|
129
|
+
['cringe.10x', 3],
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
test('returns the same findings across repeated sentence and line analysis', () => {
|
|
133
|
+
for (const text of ['We leverage logs. We leverage traces.', 'No demos. No decks. No distractions. Same team. Better results.']) {
|
|
134
|
+
assert.deepEqual(analyzeAiEditor(text), analyzeAiEditor(text));
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
test('reports the same rule in multiple sentences', () => {
|
|
138
|
+
const report = analyzeAiEditor('We leverage logs. We leverage traces.');
|
|
139
|
+
assert.deepEqual(report.findings.filter((finding) => finding.id === 'ai.leverage').map((finding) => finding.sentence), [1, 2]);
|
|
140
|
+
});
|
|
141
|
+
test('executes inherited cross-sentence rules and maps them to the first sentence', () => {
|
|
142
|
+
const report = analyzeAiEditor('No demos. No decks. No distractions. Same team. Better results.');
|
|
143
|
+
assert.deepEqual(report.findings
|
|
144
|
+
.filter((finding) => finding.id === 'struct.negation-cascade' || finding.id === 'struct.same-better')
|
|
145
|
+
.map((finding) => [finding.id, finding.sentence]), [['struct.negation-cascade', 1]]);
|
|
146
|
+
});
|
|
147
|
+
test('preserves inherited physical-line matching and line-start anchors', () => {
|
|
148
|
+
const sameLine = analyzeAiEditor("This isn't positioning. This is proof. Forget vanity metrics. You need retention.");
|
|
149
|
+
assert.ok(sameLine.findings.some((finding) => finding.id === 'struct.this-isnt-x-this-is-y'));
|
|
150
|
+
assert.ok(sameLine.findings.some((finding) => finding.id === 'struct.forget-x'));
|
|
151
|
+
assert.equal(analyzeAiEditor("This isn't positioning.\nThis is proof.").findings.some((finding) => finding.id === 'struct.this-isnt-x-this-is-y'), false);
|
|
152
|
+
assert.equal(analyzeAiEditor('The logs failed. Of course we can retry.').findings.some((finding) => finding.id === 'cringe.of-course'), false);
|
|
153
|
+
assert.ok(analyzeAiEditor('The logs failed.\nOf course we can retry.').findings.some((finding) => finding.id === 'cringe.of-course'));
|
|
154
|
+
});
|
|
155
|
+
test('retains the current question-hook and abstract-cluster detectors', () => {
|
|
156
|
+
assert.ok(analyzeAiEditor('Have you checked the invoice?').findings.some((finding) => finding.id === 'ai.question-hook'));
|
|
157
|
+
assert.ok(analyzeAiEditor('Clarity and strategy are missing.').findings.some((finding) => finding.id === 'ai.abstract-cluster'));
|
|
158
|
+
});
|
|
159
|
+
test('serializes reconstructable regular expressions and explicit scopes', () => {
|
|
160
|
+
const catalog = serializedRules();
|
|
161
|
+
assert.equal(catalog.length, 148);
|
|
162
|
+
assert.ok(catalog.every((rule) => rule.scope === 'sentence' || rule.scope === 'line'));
|
|
163
|
+
const meaningful = catalog.find((rule) => rule.id === 'ai.meaningful');
|
|
164
|
+
assert.ok(meaningful);
|
|
165
|
+
assert.equal(new RegExp(meaningful.expression.source, meaningful.expression.flags).test('Meaningful work.'), true);
|
|
166
|
+
});
|
|
167
|
+
test('suppresses intentional inherited overlaps before scoring', () => {
|
|
168
|
+
const report = analyzeAiEditor('In other words, use logs.');
|
|
169
|
+
assert.deepEqual(report.findings.map((finding) => finding.id), ['formula.in-other-words']);
|
|
170
|
+
assert.equal(report.score, 82);
|
|
171
|
+
});
|
|
172
|
+
test('returns zero AI findings for clean input', () => {
|
|
173
|
+
assert.deepEqual(analyzeAiEditor('The launch starts Tuesday. The owner signed the release checklist.').findings, []);
|
|
174
|
+
});
|
|
@@ -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
|
+
});
|