@avee1234/selfpatch 0.1.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/LICENSE +21 -0
- package/README.md +63 -0
- package/bin/selfpatch.js +1377 -0
- package/package.json +46 -0
- package/src/adapters/claude-code-skills.js +144 -0
- package/src/canonical.js +24 -0
- package/src/gate.js +232 -0
- package/src/index.js +36 -0
- package/src/ledger.js +202 -0
- package/src/propose.js +170 -0
- package/src/schema.js +344 -0
- package/src/sha256.js +93 -0
- package/src/verify.js +103 -0
package/src/propose.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// selfpatch — `propose` (v0.1).
|
|
2
|
+
//
|
|
3
|
+
// Turn a raw self-edit (a diff, or two versions of a surface) plus its metadata
|
|
4
|
+
// into a *validated* self-patch artifact (see `schema.js`) carrying a
|
|
5
|
+
// deterministic content-hash `id`. Intent is captured; nothing is applied.
|
|
6
|
+
//
|
|
7
|
+
// Pure and synchronous, zero-dependency and zero `node:` imports: the content
|
|
8
|
+
// hash routes through the pure-JS `sha256hex` seam (`sha256.js`), so this module
|
|
9
|
+
// runs unchanged in the browser copy (`site/lib/`).
|
|
10
|
+
//
|
|
11
|
+
// Determinism / id-stability contract: the `id` is a sha-256 over the
|
|
12
|
+
// *semantic* content of the patch — every field except `created` (a wall-clock
|
|
13
|
+
// timestamp: provenance, not identity) and `id` itself. So the same edit yields
|
|
14
|
+
// the same id regardless of when it was proposed or the key order of the inputs,
|
|
15
|
+
// while any change to a semantic field — including the free-form `meta`
|
|
16
|
+
// annotation bag — produces a different id.
|
|
17
|
+
|
|
18
|
+
import { sha256hex } from "./sha256.js";
|
|
19
|
+
import { validateSelfPatch } from "./schema.js";
|
|
20
|
+
import { canonicalize } from "./canonical.js";
|
|
21
|
+
|
|
22
|
+
// Thrown when the assembled self-patch fails `validateSelfPatch`. Carries the
|
|
23
|
+
// structured `{ path, code, message }[]` list so callers get every fix in one
|
|
24
|
+
// pass. Named/exported per the #2 spec so callers can rely on the type.
|
|
25
|
+
export class ProposeError extends Error {
|
|
26
|
+
constructor(message, errors) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "ProposeError";
|
|
29
|
+
this.errors = errors;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Field order that produces a readable serialized artifact.
|
|
34
|
+
const FIELD_ORDER = [
|
|
35
|
+
"id",
|
|
36
|
+
"created",
|
|
37
|
+
"author",
|
|
38
|
+
"layer",
|
|
39
|
+
"target",
|
|
40
|
+
"diff",
|
|
41
|
+
"rationale",
|
|
42
|
+
"eval_contract",
|
|
43
|
+
"blast_radius",
|
|
44
|
+
"requires",
|
|
45
|
+
"meta",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Fields excluded from the content id: `id` (self-reference) and `created`
|
|
49
|
+
// (provenance, not identity). Everything else — including `meta` — is hashed.
|
|
50
|
+
const UNHASHED_FIELDS = new Set(["id", "created"]);
|
|
51
|
+
|
|
52
|
+
function isPlainObject(v) {
|
|
53
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isString(v) {
|
|
57
|
+
return typeof v === "string";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function contentHash(patch) {
|
|
61
|
+
const hashable = {};
|
|
62
|
+
for (const k of Object.keys(patch)) {
|
|
63
|
+
if (UNHASHED_FIELDS.has(k)) continue;
|
|
64
|
+
hashable[k] = patch[k];
|
|
65
|
+
}
|
|
66
|
+
return "sha256:" + sha256hex(canonicalize(hashable));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Resolve the diff from the `edit`. The edit *is* the diff: a plain string
|
|
70
|
+
// (⇒ unified), a diff object with a `format` (used verbatim), or the shorthand
|
|
71
|
+
// forms `{ patch }` (⇒ unified) / `{ before, after }` (⇒ before_after). Returns
|
|
72
|
+
// `undefined` when no diff source is present so the validator reports it.
|
|
73
|
+
function resolveDiff(edit) {
|
|
74
|
+
if (isString(edit)) return { format: "unified", patch: edit };
|
|
75
|
+
if (isString(edit.format)) return edit;
|
|
76
|
+
if (isString(edit.patch)) return { format: "unified", patch: edit.patch };
|
|
77
|
+
if (edit.before !== undefined || edit.after !== undefined) {
|
|
78
|
+
return { format: "before_after", before: edit.before, after: edit.after };
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolve blast_radius from `meta`: an explicit object wins; else a `surfaces`
|
|
84
|
+
// shorthand; else default to the single edited target (when it is a usable
|
|
85
|
+
// string).
|
|
86
|
+
function resolveBlastRadius(meta) {
|
|
87
|
+
if (meta.blast_radius !== undefined) return meta.blast_radius;
|
|
88
|
+
if (meta.surfaces !== undefined) return { surfaces: meta.surfaces };
|
|
89
|
+
if (isString(meta.target) && meta.target.trim().length > 0) {
|
|
90
|
+
return { surfaces: [meta.target] };
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Resolve eval_contract: a full object wins; else a `command` string shorthand.
|
|
96
|
+
function resolveEvalContract(meta) {
|
|
97
|
+
if (meta.eval_contract !== undefined) return meta.eval_contract;
|
|
98
|
+
if (isString(meta.command)) return { kind: "command", command: meta.command };
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Order a patch's keys for stable, readable JSON output.
|
|
103
|
+
function ordered(patch) {
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const k of FIELD_ORDER) {
|
|
106
|
+
if (patch[k] !== undefined) out[k] = patch[k];
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Capture a raw self-edit as a validated self-patch artifact.
|
|
113
|
+
*
|
|
114
|
+
* @param {string|object} edit The raw change, which becomes the `diff`: a
|
|
115
|
+
* plain unified-diff string, `{ format: "unified", patch }`, or
|
|
116
|
+
* `{ format: "before_after", before, after }`.
|
|
117
|
+
* @param {object} [meta] The rest of the artifact: `{ author, layer, target,
|
|
118
|
+
* rationale, eval_contract | command, requires?, blast_radius | surfaces?,
|
|
119
|
+
* created?, meta? }`. `requires` defaults to `"human-gate"` (fail-closed);
|
|
120
|
+
* `blast_radius` defaults to `{ surfaces: [target] }`; `created` defaults to
|
|
121
|
+
* now (it is excluded from the id, so this never perturbs id-stability).
|
|
122
|
+
* @returns {object} A complete, valid self-patch with a deterministic
|
|
123
|
+
* `sha256:…` content-hash `id`.
|
|
124
|
+
* @throws {Error} If `edit` is not a string or object, or `meta` is not an object.
|
|
125
|
+
* @throws {ProposeError} If the assembled patch fails `validateSelfPatch`. The
|
|
126
|
+
* thrown error carries `.errors` (the full `{ path, code, message }[]` list)
|
|
127
|
+
* so callers get every fix in one pass.
|
|
128
|
+
*/
|
|
129
|
+
export function proposeSelfPatch(edit, meta = {}) {
|
|
130
|
+
if (!isString(edit) && !isPlainObject(edit)) {
|
|
131
|
+
throw new Error("proposeSelfPatch: `edit` must be a string or object");
|
|
132
|
+
}
|
|
133
|
+
if (!isPlainObject(meta)) {
|
|
134
|
+
throw new Error("proposeSelfPatch: `meta` must be an object");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Assemble the substantive content first so the id is computed over the
|
|
138
|
+
// normalized shape (shorthand and full forms hash identically).
|
|
139
|
+
const patch = {
|
|
140
|
+
author: meta.author,
|
|
141
|
+
layer: meta.layer,
|
|
142
|
+
target: meta.target,
|
|
143
|
+
diff: resolveDiff(edit),
|
|
144
|
+
rationale: meta.rationale,
|
|
145
|
+
eval_contract: resolveEvalContract(meta),
|
|
146
|
+
blast_radius: resolveBlastRadius(meta),
|
|
147
|
+
requires: meta.requires !== undefined ? meta.requires : "human-gate",
|
|
148
|
+
};
|
|
149
|
+
// `meta` is part of the artifact's identity, so it must be present before the
|
|
150
|
+
// id is hashed (unlike `created`, which is provenance and set afterward).
|
|
151
|
+
if (meta.meta !== undefined) patch.meta = meta.meta;
|
|
152
|
+
|
|
153
|
+
patch.id = contentHash(patch);
|
|
154
|
+
patch.created =
|
|
155
|
+
meta.created !== undefined ? meta.created : new Date().toISOString();
|
|
156
|
+
|
|
157
|
+
const result = validateSelfPatch(ordered(patch));
|
|
158
|
+
if (!result.valid) {
|
|
159
|
+
const lines = result.errors.map((e) => {
|
|
160
|
+
const loc = e.path === "" ? "(root)" : e.path;
|
|
161
|
+
return ` ${loc}: ${e.message} [${e.code}]`;
|
|
162
|
+
});
|
|
163
|
+
throw new ProposeError(
|
|
164
|
+
"proposeSelfPatch: assembled self-patch is invalid\n" + lines.join("\n"),
|
|
165
|
+
result.errors
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return ordered(patch);
|
|
170
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// selfpatch — self-patch + policy schema and validators (v0.1).
|
|
2
|
+
//
|
|
3
|
+
// Hand-written, zero-dependency, pure and synchronous. Both validators return
|
|
4
|
+
// `{ valid, errors }` and never throw on JSON-derived input; a non-object root
|
|
5
|
+
// is reported as a single `not_object` error. Every violation is collected in
|
|
6
|
+
// one pass so a caller (proposer, gate) gets the full fix list at once.
|
|
7
|
+
|
|
8
|
+
// --- Enums / constants ------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export const LAYERS = ["context", "skill", "weights", "scaffolding"];
|
|
11
|
+
export const REQUIRES = ["auto", "human-gate", "forbidden"];
|
|
12
|
+
export const DIFF_FORMATS = ["unified", "before_after"];
|
|
13
|
+
export const ERROR_CODES = [
|
|
14
|
+
"not_object",
|
|
15
|
+
"required",
|
|
16
|
+
"type",
|
|
17
|
+
"enum",
|
|
18
|
+
"empty",
|
|
19
|
+
"format",
|
|
20
|
+
"mismatch",
|
|
21
|
+
"unknown_field",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// --- Predicate helpers ------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function isPlainObject(v) {
|
|
27
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isString(v) {
|
|
31
|
+
return typeof v === "string";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function nonEmptyString(v) {
|
|
35
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isNonNegativeNumber(v) {
|
|
39
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Lenient ISO-8601 check: accept a date or date-time with optional time,
|
|
43
|
+
// fractional seconds, and a `Z`/±hh:mm offset. Strict enough to reject obvious
|
|
44
|
+
// garbage, lenient enough not to reject valid producers. No date library.
|
|
45
|
+
function isIsoTimestamp(s) {
|
|
46
|
+
if (typeof s !== "string" || s.trim().length === 0) return false;
|
|
47
|
+
const iso =
|
|
48
|
+
/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
49
|
+
if (!iso.test(s)) return false;
|
|
50
|
+
return !Number.isNaN(Date.parse(s));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `undefined` and `null` both count as absent (for optional fields).
|
|
54
|
+
function isAbsent(v) {
|
|
55
|
+
return v === undefined || v === null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function err(errors, path, code, message) {
|
|
59
|
+
errors.push({ path, code, message });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate a required non-empty string at `path`, pushing the right code.
|
|
63
|
+
function checkRequiredString(errors, obj, key) {
|
|
64
|
+
const v = obj[key];
|
|
65
|
+
if (isAbsent(v)) {
|
|
66
|
+
err(errors, key, "required", `\`${key}\` is required`);
|
|
67
|
+
} else if (!isString(v)) {
|
|
68
|
+
err(errors, key, "type", `\`${key}\` must be a string`);
|
|
69
|
+
} else if (v.trim().length === 0) {
|
|
70
|
+
err(errors, key, "empty", `\`${key}\` must not be empty`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate a required enum value at `path`.
|
|
75
|
+
function checkRequiredEnum(errors, obj, key, allowed) {
|
|
76
|
+
const v = obj[key];
|
|
77
|
+
if (isAbsent(v)) {
|
|
78
|
+
err(errors, key, "required", `\`${key}\` is required`);
|
|
79
|
+
} else if (!isString(v)) {
|
|
80
|
+
err(errors, key, "type", `\`${key}\` must be a string`);
|
|
81
|
+
} else if (!allowed.includes(v)) {
|
|
82
|
+
err(errors, key, "enum", `\`${key}\` must be one of: ${allowed.join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Self-patch -------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const SELFPATCH_KEYS = new Set([
|
|
89
|
+
"id",
|
|
90
|
+
"created",
|
|
91
|
+
"author",
|
|
92
|
+
"layer",
|
|
93
|
+
"target",
|
|
94
|
+
"diff",
|
|
95
|
+
"rationale",
|
|
96
|
+
"eval_contract",
|
|
97
|
+
"blast_radius",
|
|
98
|
+
"requires",
|
|
99
|
+
"meta",
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
export function validateSelfPatch(obj) {
|
|
103
|
+
const errors = [];
|
|
104
|
+
if (!isPlainObject(obj)) {
|
|
105
|
+
err(errors, "", "not_object", "self-patch must be a plain object");
|
|
106
|
+
return { valid: false, errors };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
checkRequiredString(errors, obj, "id");
|
|
110
|
+
checkRequiredString(errors, obj, "author");
|
|
111
|
+
checkRequiredString(errors, obj, "target");
|
|
112
|
+
checkRequiredString(errors, obj, "rationale");
|
|
113
|
+
|
|
114
|
+
// created: required non-empty ISO-8601 timestamp
|
|
115
|
+
if (isAbsent(obj.created)) {
|
|
116
|
+
err(errors, "created", "required", "`created` is required");
|
|
117
|
+
} else if (!isString(obj.created)) {
|
|
118
|
+
err(errors, "created", "type", "`created` must be a string");
|
|
119
|
+
} else if (obj.created.trim().length === 0) {
|
|
120
|
+
err(errors, "created", "empty", "`created` must not be empty");
|
|
121
|
+
} else if (!isIsoTimestamp(obj.created)) {
|
|
122
|
+
err(errors, "created", "format", "`created` must be an ISO-8601 timestamp");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
checkRequiredEnum(errors, obj, "layer", LAYERS);
|
|
126
|
+
checkRequiredEnum(errors, obj, "requires", REQUIRES);
|
|
127
|
+
|
|
128
|
+
validateDiff(errors, obj.diff);
|
|
129
|
+
validateEvalContract(errors, obj.eval_contract);
|
|
130
|
+
validateBlastRadius(errors, obj.blast_radius);
|
|
131
|
+
|
|
132
|
+
checkUnknownKeys(errors, obj, SELFPATCH_KEYS);
|
|
133
|
+
|
|
134
|
+
return { valid: errors.length === 0, errors };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateDiff(errors, diff) {
|
|
138
|
+
if (isAbsent(diff)) {
|
|
139
|
+
err(errors, "diff", "required", "`diff` is required");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!isPlainObject(diff)) {
|
|
143
|
+
err(errors, "diff", "type", "`diff` must be an object");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const format = diff.format;
|
|
147
|
+
if (isAbsent(format)) {
|
|
148
|
+
err(errors, "diff.format", "required", "`diff.format` is required");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!DIFF_FORMATS.includes(format)) {
|
|
152
|
+
err(
|
|
153
|
+
errors,
|
|
154
|
+
"diff.format",
|
|
155
|
+
"enum",
|
|
156
|
+
`\`diff.format\` must be one of: ${DIFF_FORMATS.join(", ")}`
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (format === "unified") {
|
|
162
|
+
if (isAbsent(diff.patch)) {
|
|
163
|
+
err(errors, "diff.patch", "required", "`diff.patch` is required for unified format");
|
|
164
|
+
} else if (!isString(diff.patch)) {
|
|
165
|
+
err(errors, "diff.patch", "type", "`diff.patch` must be a string");
|
|
166
|
+
}
|
|
167
|
+
// Keys inconsistent with the declared format.
|
|
168
|
+
for (const k of ["before", "after"]) {
|
|
169
|
+
if (k in diff) {
|
|
170
|
+
err(errors, `diff.${k}`, "mismatch", `\`diff.${k}\` is not valid for format "unified"`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// before_after
|
|
175
|
+
for (const k of ["before", "after"]) {
|
|
176
|
+
if (isAbsent(diff[k])) {
|
|
177
|
+
err(errors, `diff.${k}`, "required", `\`diff.${k}\` is required for before_after format`);
|
|
178
|
+
} else if (!isString(diff[k])) {
|
|
179
|
+
err(errors, `diff.${k}`, "type", `\`diff.${k}\` must be a string`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if ("patch" in diff) {
|
|
183
|
+
err(errors, "diff.patch", "mismatch", '`diff.patch` is not valid for format "before_after"');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateEvalContract(errors, ec) {
|
|
189
|
+
if (isAbsent(ec)) {
|
|
190
|
+
err(errors, "eval_contract", "required", "`eval_contract` is required");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (!isPlainObject(ec)) {
|
|
194
|
+
err(errors, "eval_contract", "type", "`eval_contract` must be an object");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (isAbsent(ec.kind)) {
|
|
198
|
+
err(errors, "eval_contract.kind", "required", "`eval_contract.kind` is required");
|
|
199
|
+
} else if (ec.kind !== "command") {
|
|
200
|
+
err(errors, "eval_contract.kind", "enum", '`eval_contract.kind` must be "command"');
|
|
201
|
+
}
|
|
202
|
+
if (isAbsent(ec.command)) {
|
|
203
|
+
err(errors, "eval_contract.command", "required", "`eval_contract.command` is required");
|
|
204
|
+
} else if (!isString(ec.command)) {
|
|
205
|
+
err(errors, "eval_contract.command", "type", "`eval_contract.command` must be a string");
|
|
206
|
+
} else if (ec.command.trim().length === 0) {
|
|
207
|
+
err(errors, "eval_contract.command", "empty", "`eval_contract.command` must not be empty");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function validateBlastRadius(errors, br) {
|
|
212
|
+
if (isAbsent(br)) {
|
|
213
|
+
err(errors, "blast_radius", "required", "`blast_radius` is required");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!isPlainObject(br)) {
|
|
217
|
+
err(errors, "blast_radius", "type", "`blast_radius` must be an object");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const surfaces = br.surfaces;
|
|
221
|
+
if (isAbsent(surfaces)) {
|
|
222
|
+
err(errors, "blast_radius.surfaces", "required", "`blast_radius.surfaces` is required");
|
|
223
|
+
} else if (!Array.isArray(surfaces)) {
|
|
224
|
+
err(errors, "blast_radius.surfaces", "type", "`blast_radius.surfaces` must be an array");
|
|
225
|
+
} else if (surfaces.length === 0) {
|
|
226
|
+
err(errors, "blast_radius.surfaces", "empty", "`blast_radius.surfaces` must not be empty");
|
|
227
|
+
} else {
|
|
228
|
+
surfaces.forEach((s, i) => {
|
|
229
|
+
if (!isString(s)) {
|
|
230
|
+
err(errors, `blast_radius.surfaces[${i}]`, "type", "surface must be a string");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
for (const k of ["files_changed", "lines_changed"]) {
|
|
235
|
+
if (!isAbsent(br[k]) && !isNonNegativeNumber(br[k])) {
|
|
236
|
+
err(errors, `blast_radius.${k}`, "type", `\`blast_radius.${k}\` must be a non-negative number`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- Policy -----------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
const POLICY_KEYS = new Set([
|
|
244
|
+
"protected_surfaces",
|
|
245
|
+
"blast_radius_limits",
|
|
246
|
+
"approvals",
|
|
247
|
+
"default_requires",
|
|
248
|
+
"meta",
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
export function validatePolicy(obj) {
|
|
252
|
+
const errors = [];
|
|
253
|
+
if (!isPlainObject(obj)) {
|
|
254
|
+
err(errors, "", "not_object", "policy must be a plain object");
|
|
255
|
+
return { valid: false, errors };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// protected_surfaces: required string[] (may be empty)
|
|
259
|
+
const ps = obj.protected_surfaces;
|
|
260
|
+
if (isAbsent(ps)) {
|
|
261
|
+
err(errors, "protected_surfaces", "required", "`protected_surfaces` is required");
|
|
262
|
+
} else if (!Array.isArray(ps)) {
|
|
263
|
+
err(errors, "protected_surfaces", "type", "`protected_surfaces` must be an array");
|
|
264
|
+
} else {
|
|
265
|
+
ps.forEach((s, i) => {
|
|
266
|
+
if (!isString(s)) {
|
|
267
|
+
err(errors, `protected_surfaces[${i}]`, "type", "surface must be a string");
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
validateBlastRadiusLimits(errors, obj.blast_radius_limits);
|
|
273
|
+
validateApprovals(errors, obj.approvals);
|
|
274
|
+
|
|
275
|
+
if (!isAbsent(obj.default_requires)) {
|
|
276
|
+
if (!isString(obj.default_requires)) {
|
|
277
|
+
err(errors, "default_requires", "type", "`default_requires` must be a string");
|
|
278
|
+
} else if (!REQUIRES.includes(obj.default_requires)) {
|
|
279
|
+
err(errors, "default_requires", "enum", `\`default_requires\` must be one of: ${REQUIRES.join(", ")}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
checkUnknownKeys(errors, obj, POLICY_KEYS);
|
|
284
|
+
|
|
285
|
+
return { valid: errors.length === 0, errors };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function validateBlastRadiusLimits(errors, brl) {
|
|
289
|
+
if (isAbsent(brl)) return;
|
|
290
|
+
if (!isPlainObject(brl)) {
|
|
291
|
+
err(errors, "blast_radius_limits", "type", "`blast_radius_limits` must be an object");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
for (const k of ["max_surfaces", "max_files_changed", "max_lines_changed"]) {
|
|
295
|
+
if (!isAbsent(brl[k]) && !isNonNegativeNumber(brl[k])) {
|
|
296
|
+
err(errors, `blast_radius_limits.${k}`, "type", `\`blast_radius_limits.${k}\` must be a non-negative number`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validateApprovals(errors, approvals) {
|
|
302
|
+
if (isAbsent(approvals)) return;
|
|
303
|
+
if (!Array.isArray(approvals)) {
|
|
304
|
+
err(errors, "approvals", "type", "`approvals` must be an array");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
approvals.forEach((rule, i) => {
|
|
308
|
+
const base = `approvals[${i}]`;
|
|
309
|
+
if (!isPlainObject(rule)) {
|
|
310
|
+
err(errors, base, "type", "approval rule must be an object");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (!isAbsent(rule.layer)) {
|
|
314
|
+
if (!isString(rule.layer)) {
|
|
315
|
+
err(errors, `${base}.layer`, "type", "`layer` must be a string");
|
|
316
|
+
} else if (!LAYERS.includes(rule.layer)) {
|
|
317
|
+
err(errors, `${base}.layer`, "enum", `\`layer\` must be one of: ${LAYERS.join(", ")}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (!isAbsent(rule.target) && !isString(rule.target)) {
|
|
321
|
+
err(errors, `${base}.target`, "type", "`target` must be a string");
|
|
322
|
+
}
|
|
323
|
+
if (isAbsent(rule.requires)) {
|
|
324
|
+
err(errors, `${base}.requires`, "required", "`requires` is required");
|
|
325
|
+
} else if (!isString(rule.requires)) {
|
|
326
|
+
err(errors, `${base}.requires`, "type", "`requires` must be a string");
|
|
327
|
+
} else if (!REQUIRES.includes(rule.requires)) {
|
|
328
|
+
err(errors, `${base}.requires`, "enum", `\`requires\` must be one of: ${REQUIRES.join(", ")}`);
|
|
329
|
+
}
|
|
330
|
+
if (isAbsent(rule.layer) && isAbsent(rule.target)) {
|
|
331
|
+
err(errors, base, "required", "approval rule needs at least one of `layer` or `target`");
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- Shared -----------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
function checkUnknownKeys(errors, obj, known) {
|
|
339
|
+
for (const k of Object.keys(obj)) {
|
|
340
|
+
if (!known.has(k)) {
|
|
341
|
+
err(errors, k, "unknown_field", `unknown field \`${k}\``);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
package/src/sha256.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// selfpatch — pure, synchronous SHA-256 (zero-dependency).
|
|
2
|
+
//
|
|
3
|
+
// A standard FIPS-180-4 SHA-256 over a UTF-8 string, returning lowercase hex.
|
|
4
|
+
// Exists so `propose` and `ledger` can hash without `node:crypto`, making the
|
|
5
|
+
// whole `src/` module graph pure JS with no `node:` imports — so the exact same
|
|
6
|
+
// bytes run in Node and in the browser copy (`site/lib/`). SHA-256 is SHA-256,
|
|
7
|
+
// so ids and chain hashes are byte-identical to the previous `node:crypto`
|
|
8
|
+
// implementation (cross-checked against it in the test suite).
|
|
9
|
+
//
|
|
10
|
+
// Inputs here are small canonical strings (self-patch content, ledger entries),
|
|
11
|
+
// so performance is a non-issue. This is a content hash for addressing and
|
|
12
|
+
// tamper-*evidence*, not authentication. No imports — the browser copy is a
|
|
13
|
+
// literal copy of this file.
|
|
14
|
+
|
|
15
|
+
// Round constants: first 32 bits of the fractional parts of the cube roots of
|
|
16
|
+
// the first 64 primes.
|
|
17
|
+
const K = new Uint32Array([
|
|
18
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
|
|
19
|
+
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
|
20
|
+
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
|
21
|
+
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
22
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
|
23
|
+
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
24
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
|
|
25
|
+
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
26
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
|
27
|
+
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
|
28
|
+
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function rotr(x, n) {
|
|
32
|
+
return (x >>> n) | (x << (32 - n));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Synchronous SHA-256 of a UTF-8 string.
|
|
37
|
+
* @param {string} input
|
|
38
|
+
* @returns {string} lowercase hex digest (64 chars)
|
|
39
|
+
* @throws {TypeError} If `input` is not a string.
|
|
40
|
+
*/
|
|
41
|
+
export function sha256hex(input) {
|
|
42
|
+
if (typeof input !== "string") {
|
|
43
|
+
throw new TypeError("sha256hex: input must be a string");
|
|
44
|
+
}
|
|
45
|
+
const bytes = new TextEncoder().encode(input);
|
|
46
|
+
|
|
47
|
+
// Padding: append 0x80, then zeros until the length ≡ 56 (mod 64), then the
|
|
48
|
+
// 64-bit big-endian message length in bits.
|
|
49
|
+
const bitLen = bytes.length * 8;
|
|
50
|
+
const withOne = bytes.length + 1;
|
|
51
|
+
const totalLen = withOne + ((56 - (withOne % 64) + 64) % 64) + 8;
|
|
52
|
+
const msg = new Uint8Array(totalLen);
|
|
53
|
+
msg.set(bytes);
|
|
54
|
+
msg[bytes.length] = 0x80;
|
|
55
|
+
const dv = new DataView(msg.buffer);
|
|
56
|
+
// 64-bit length, big-endian; bitLen is well within the 53-bit safe range.
|
|
57
|
+
dv.setUint32(totalLen - 8, Math.floor(bitLen / 0x100000000));
|
|
58
|
+
dv.setUint32(totalLen - 4, bitLen >>> 0);
|
|
59
|
+
|
|
60
|
+
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
|
|
61
|
+
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
|
|
62
|
+
|
|
63
|
+
const w = new Uint32Array(64);
|
|
64
|
+
for (let off = 0; off < totalLen; off += 64) {
|
|
65
|
+
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
|
66
|
+
for (let i = 16; i < 64; i++) {
|
|
67
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
68
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
69
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
|
|
73
|
+
for (let i = 0; i < 64; i++) {
|
|
74
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
75
|
+
const ch = (e & f) ^ (~e & g);
|
|
76
|
+
const t1 = (h + S1 + ch + K[i] + w[i]) | 0;
|
|
77
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
78
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
79
|
+
const t2 = (S0 + maj) | 0;
|
|
80
|
+
h = g; g = f; f = e; e = (d + t1) | 0;
|
|
81
|
+
d = c; c = b; b = a; a = (t1 + t2) | 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0;
|
|
85
|
+
h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let hex = "";
|
|
89
|
+
for (const v of [h0, h1, h2, h3, h4, h5, h6, h7]) {
|
|
90
|
+
hex += (v >>> 0).toString(16).padStart(8, "0");
|
|
91
|
+
}
|
|
92
|
+
return hex;
|
|
93
|
+
}
|