@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/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avee1234/selfpatch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The safe self-modification layer for AI agent harnesses. Every self-edit becomes a verifiable, gated, revertible artifact — a pull request your agent opens against its own brain. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"selfpatch": "bin/selfpatch.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"build:site-lib": "node scripts/build-site-lib.mjs"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ai",
|
|
25
|
+
"agent",
|
|
26
|
+
"harness",
|
|
27
|
+
"self-improvement",
|
|
28
|
+
"rsi",
|
|
29
|
+
"governance",
|
|
30
|
+
"reward-hacking",
|
|
31
|
+
"safety",
|
|
32
|
+
"audit",
|
|
33
|
+
"rollback",
|
|
34
|
+
"open-format",
|
|
35
|
+
"zero-dependency"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"homepage": "https://selfpatch.vercel.app",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/abhid1234/selfpatch.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/abhid1234/selfpatch/issues"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// selfpatch — Claude Code skills adapter (v0.1). The Foundry dogfood.
|
|
2
|
+
//
|
|
3
|
+
// Treat a `.agents/skills` tree as an editable *surface* and map a before/after
|
|
4
|
+
// tree diff into a list of validated self-patches — one per changed skill file —
|
|
5
|
+
// by composing the shipped `proposeSelfPatch`. These are the harness's OWN
|
|
6
|
+
// orchestration skills (the factory editing itself), i.e. vision layer 4, so the
|
|
7
|
+
// layer is `scaffolding`, not `skill`.
|
|
8
|
+
//
|
|
9
|
+
// Pure and synchronous, zero-dependency: the module does NO filesystem, process,
|
|
10
|
+
// or network I/O. It operates on in-memory `{ relPath → content }` maps the CLI
|
|
11
|
+
// injects (the CLI does the disk walk), mirroring the core/seam split every other
|
|
12
|
+
// module uses — so it is fully unit-testable without disk. Its only import is
|
|
13
|
+
// `proposeSelfPatch` / `ProposeError`.
|
|
14
|
+
|
|
15
|
+
import { proposeSelfPatch, ProposeError } from "../propose.js";
|
|
16
|
+
|
|
17
|
+
export const SKILL_LAYER = "scaffolding";
|
|
18
|
+
export const DEFAULT_SKILLS_ROOT = ".agents/skills";
|
|
19
|
+
export const DEFAULT_EVAL_COMMAND = "npm test";
|
|
20
|
+
|
|
21
|
+
// Join a skills root and a repo-relative skill path into a stable POSIX
|
|
22
|
+
// repo-relative `target`, independent of the host path separator. The target is
|
|
23
|
+
// a logical repo path, not a filesystem path, so we normalize slashes and trim
|
|
24
|
+
// redundant separators by hand rather than reach for the platform path module
|
|
25
|
+
// (which would also make this module non-pure across OSes).
|
|
26
|
+
export function skillTargetPath(skillsRoot, relPath) {
|
|
27
|
+
const root = String(skillsRoot).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
28
|
+
const rel = String(relPath).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
29
|
+
return root === "" ? rel : `${root}/${rel}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Collect the keys of a plain object or a Map into a Set, so callers may pass
|
|
33
|
+
// either shape for a tree.
|
|
34
|
+
function treeKeys(tree) {
|
|
35
|
+
if (tree instanceof Map) return new Set(tree.keys());
|
|
36
|
+
return new Set(Object.keys(tree ?? {}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Read a rel's content from a plain object or a Map; `undefined` when absent.
|
|
40
|
+
function treeGet(tree, rel) {
|
|
41
|
+
if (tree instanceof Map) return tree.get(rel);
|
|
42
|
+
return tree ? tree[rel] : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Classify a before/after skills-tree diff purely by string content.
|
|
47
|
+
*
|
|
48
|
+
* @param {object|Map} before `{ relPath → content:string }` (or a Map).
|
|
49
|
+
* @param {object|Map} after `{ relPath → content:string }` (or a Map).
|
|
50
|
+
* @returns {{ added: string[], modified: string[], removed: string[], unchanged: string[] }}
|
|
51
|
+
* A rel present only in `after` ⇒ added; only in `before` ⇒ removed; in both
|
|
52
|
+
* and differing ⇒ modified; in both and equal ⇒ unchanged. Each list is sorted
|
|
53
|
+
* for deterministic output.
|
|
54
|
+
*/
|
|
55
|
+
export function diffSkillTrees(before, after) {
|
|
56
|
+
const beforeKeys = treeKeys(before);
|
|
57
|
+
const afterKeys = treeKeys(after);
|
|
58
|
+
const all = new Set([...beforeKeys, ...afterKeys]);
|
|
59
|
+
|
|
60
|
+
const added = [];
|
|
61
|
+
const modified = [];
|
|
62
|
+
const removed = [];
|
|
63
|
+
const unchanged = [];
|
|
64
|
+
|
|
65
|
+
for (const rel of all) {
|
|
66
|
+
const inBefore = beforeKeys.has(rel);
|
|
67
|
+
const inAfter = afterKeys.has(rel);
|
|
68
|
+
if (inAfter && !inBefore) {
|
|
69
|
+
added.push(rel);
|
|
70
|
+
} else if (inBefore && !inAfter) {
|
|
71
|
+
removed.push(rel);
|
|
72
|
+
} else if (treeGet(before, rel) === treeGet(after, rel)) {
|
|
73
|
+
unchanged.push(rel);
|
|
74
|
+
} else {
|
|
75
|
+
modified.push(rel);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
added.sort();
|
|
80
|
+
modified.sort();
|
|
81
|
+
removed.sort();
|
|
82
|
+
unchanged.sort();
|
|
83
|
+
return { added, modified, removed, unchanged };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Diff two skills trees and build a validated self-patch for each changed file.
|
|
88
|
+
*
|
|
89
|
+
* Every added/modified skill becomes one `layer: "scaffolding"` self-patch with
|
|
90
|
+
* a `before_after` diff (an added file has `before: ""`), a per-target blast
|
|
91
|
+
* radius, and an operator-supplied `eval_contract`. Removed skills are surfaced
|
|
92
|
+
* in `changes.removed` but produce NO patch (a `before_after` patch with
|
|
93
|
+
* `after: ""` would write an empty file, not delete it — the honest bound of the
|
|
94
|
+
* shipped `apply`; the CLI routes removals to a human).
|
|
95
|
+
*
|
|
96
|
+
* @param {object|Map} before `{ relPath → content:string }` (or a Map).
|
|
97
|
+
* @param {object|Map} after `{ relPath → content:string }` (or a Map).
|
|
98
|
+
* @param {object} [opts] `{ skillsRoot, author, rationale, evalCommand,
|
|
99
|
+
* requires }`. `skillsRoot` defaults to `.agents/skills`, `evalCommand` to
|
|
100
|
+
* `npm test`, `requires` to `human-gate` (fail-closed). `rationale` defaults
|
|
101
|
+
* to a per-target string.
|
|
102
|
+
* @returns {{ patches: object[], changes: object }} `patches` in `added` then
|
|
103
|
+
* `modified` order (each sorted); `changes` is the raw `diffSkillTrees` result.
|
|
104
|
+
* @throws {ProposeError} Re-thrown from `proposeSelfPatch` if an assembled patch
|
|
105
|
+
* fails the #1 schema, carrying `.errors` so the CLI can exit 2.
|
|
106
|
+
*/
|
|
107
|
+
export function proposeSkillEdits(before, after, opts = {}) {
|
|
108
|
+
const {
|
|
109
|
+
skillsRoot = DEFAULT_SKILLS_ROOT,
|
|
110
|
+
author,
|
|
111
|
+
rationale,
|
|
112
|
+
evalCommand = DEFAULT_EVAL_COMMAND,
|
|
113
|
+
requires = "human-gate",
|
|
114
|
+
} = opts;
|
|
115
|
+
|
|
116
|
+
const changes = diffSkillTrees(before, after);
|
|
117
|
+
const patches = [];
|
|
118
|
+
|
|
119
|
+
// Added first, then modified — both already sorted — so patch order is stable.
|
|
120
|
+
for (const rel of [...changes.added, ...changes.modified]) {
|
|
121
|
+
const target = skillTargetPath(skillsRoot, rel);
|
|
122
|
+
const patch = proposeSelfPatch(
|
|
123
|
+
{
|
|
124
|
+
format: "before_after",
|
|
125
|
+
before: treeGet(before, rel) ?? "",
|
|
126
|
+
after: treeGet(after, rel),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
author,
|
|
130
|
+
layer: SKILL_LAYER,
|
|
131
|
+
target,
|
|
132
|
+
rationale: rationale ?? `factory retro: skill edit to ${target}`,
|
|
133
|
+
command: evalCommand,
|
|
134
|
+
requires,
|
|
135
|
+
surfaces: [target],
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
patches.push(patch);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { patches, changes };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { ProposeError };
|
package/src/canonical.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// selfpatch — canonical serialization (shared).
|
|
2
|
+
//
|
|
3
|
+
// Deterministic, key-order-independent serialization for hashing. Objects emit
|
|
4
|
+
// their keys sorted; arrays keep order; `undefined` collapses to `null` so a
|
|
5
|
+
// missing field hashes identically to an explicit null.
|
|
6
|
+
//
|
|
7
|
+
// Extracted verbatim from `propose.js` so the self-patch content-hash `id` and
|
|
8
|
+
// the ledger's hash chain share one definition. Zero-dependency, pure.
|
|
9
|
+
|
|
10
|
+
export function canonicalize(v) {
|
|
11
|
+
if (v === null || v === undefined) return "null";
|
|
12
|
+
if (Array.isArray(v)) return "[" + v.map(canonicalize).join(",") + "]";
|
|
13
|
+
if (typeof v === "object") {
|
|
14
|
+
return (
|
|
15
|
+
"{" +
|
|
16
|
+
Object.keys(v)
|
|
17
|
+
.sort()
|
|
18
|
+
.map((k) => JSON.stringify(k) + ":" + canonicalize(v[k]))
|
|
19
|
+
.join(",") +
|
|
20
|
+
"}"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return JSON.stringify(v);
|
|
24
|
+
}
|
package/src/gate.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// selfpatch — `gate` (v0.1). The policy engine.
|
|
2
|
+
//
|
|
3
|
+
// Given a self-patch (#1) and a protected-surface policy, decide
|
|
4
|
+
// `approve | human-gate | block` with an explicit human-readable reason. The
|
|
5
|
+
// decision is a *declarative* read of the policy (protected surfaces, blast
|
|
6
|
+
// radius limits, per-target approval rules) — no predicate/escape-hatch code
|
|
7
|
+
// yet (roadmap: "declarative core + optional escape hatch"; the escape hatch is
|
|
8
|
+
// deferred).
|
|
9
|
+
//
|
|
10
|
+
// Pure and synchronous, zero-dependency. This module does NO process,
|
|
11
|
+
// filesystem, or network I/O — the CLI reads/validates the files and prints the
|
|
12
|
+
// verdict. So the whole gate logic is unit-testable with plain objects.
|
|
13
|
+
//
|
|
14
|
+
// Decision, in fail-closed precedence (strongest first):
|
|
15
|
+
// 1. block — the patch touches a **protected surface**, or its declared
|
|
16
|
+
// **blast radius** exceeds a policy limit. Hard policy violations
|
|
17
|
+
// win over everything, regardless of the patch's `requires`.
|
|
18
|
+
// 2. else, resolve the effective `requires` for this patch as the STRICTEST of
|
|
19
|
+
// the patch's own `requires`, every matching `approvals` rule, and (only
|
|
20
|
+
// when no rule matches) the policy's `default_requires` (itself defaulting
|
|
21
|
+
// to `human-gate`). Then map on the strictness lattice auto < human-gate <
|
|
22
|
+
// forbidden:
|
|
23
|
+
// forbidden ⇒ block
|
|
24
|
+
// human-gate ⇒ human-gate
|
|
25
|
+
// auto ⇒ approve
|
|
26
|
+
|
|
27
|
+
// Thrown when handed inputs that cannot be gated: a non-object patch or policy.
|
|
28
|
+
// Fail closed — an ungateable self-patch is not silently "approved". Named/
|
|
29
|
+
// exported to mirror `ProposeError` (#2) and `VerifyError` (#3) so callers can
|
|
30
|
+
// rely on the type. The CLI validates both documents first, so this is a guard.
|
|
31
|
+
export class GateError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "GateError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DECISIONS = ["approve", "human-gate", "block"];
|
|
39
|
+
|
|
40
|
+
// Strictness lattice for `requires`. Higher rank = more restrictive (fail-
|
|
41
|
+
// closed). Index in RANK_TO_DECISION maps the resolved rank to a decision.
|
|
42
|
+
const REQUIRES_RANK = { auto: 0, "human-gate": 1, forbidden: 2 };
|
|
43
|
+
const RANK_TO_DECISION = ["approve", "human-gate", "block"];
|
|
44
|
+
|
|
45
|
+
// An unknown `requires` string is treated as the strictest level (fail closed);
|
|
46
|
+
// the CLI validates first, so this only bites malformed direct callers.
|
|
47
|
+
function requiresRank(r) {
|
|
48
|
+
return Object.prototype.hasOwnProperty.call(REQUIRES_RANK, r)
|
|
49
|
+
? REQUIRES_RANK[r]
|
|
50
|
+
: REQUIRES_RANK.forbidden;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPlainObject(v) {
|
|
54
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isString(v) {
|
|
58
|
+
return typeof v === "string";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function nonEmptyString(v) {
|
|
62
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isNonNegativeNumber(v) {
|
|
66
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isAbsent(v) {
|
|
70
|
+
return v === undefined || v === null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Glob matcher: `*` matches any run of characters (including `/`); a pattern
|
|
74
|
+
// with no `*` is an exact match. So `src/schema.js` matches only itself, `src/*`
|
|
75
|
+
// matches everything under `src/`, and `*` matches any surface. Anchored full
|
|
76
|
+
// match; all other regex metacharacters are matched literally.
|
|
77
|
+
function matchSurface(pattern, value) {
|
|
78
|
+
if (!isString(pattern) || !isString(value)) return false;
|
|
79
|
+
const escaped = pattern
|
|
80
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
81
|
+
.replace(/\\\*/g, ".*");
|
|
82
|
+
return new RegExp("^" + escaped + "$").test(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The concrete surfaces a patch touches: its `target` plus every declared
|
|
86
|
+
// `blast_radius.surfaces` entry. Matched against `protected_surfaces` as the
|
|
87
|
+
// union so a patch can't slip a protected file past by declaring it in only one
|
|
88
|
+
// place.
|
|
89
|
+
function touchedSurfaces(patch) {
|
|
90
|
+
const out = [];
|
|
91
|
+
if (nonEmptyString(patch.target)) out.push(patch.target);
|
|
92
|
+
const br = patch.blast_radius;
|
|
93
|
+
if (isPlainObject(br) && Array.isArray(br.surfaces)) {
|
|
94
|
+
for (const s of br.surfaces) if (isString(s)) out.push(s);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// First protected surface the patch touches, or null. Returns the matching
|
|
100
|
+
// `{ surface, pattern }` so the reason can name both.
|
|
101
|
+
function firstProtectedHit(touched, protectedSurfaces) {
|
|
102
|
+
for (const pattern of protectedSurfaces) {
|
|
103
|
+
for (const surface of touched) {
|
|
104
|
+
if (matchSurface(pattern, surface)) return { surface, pattern };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// First blast-radius limit the patch's declared blast radius exceeds, as a
|
|
111
|
+
// reason string, or null. `surfaces` is always declared (schema-required), so
|
|
112
|
+
// `max_surfaces` is always checkable; `files_changed`/`lines_changed` are only
|
|
113
|
+
// compared when the patch declares them (nothing to "exceed" otherwise).
|
|
114
|
+
function firstBlastViolation(patch, limits) {
|
|
115
|
+
const br = isPlainObject(patch.blast_radius) ? patch.blast_radius : {};
|
|
116
|
+
const surfaces = Array.isArray(br.surfaces) ? br.surfaces : [];
|
|
117
|
+
if (isNonNegativeNumber(limits.max_surfaces) && surfaces.length > limits.max_surfaces) {
|
|
118
|
+
return `declared blast radius of ${surfaces.length} surface(s) exceeds max_surfaces ${limits.max_surfaces}`;
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
isNonNegativeNumber(limits.max_files_changed) &&
|
|
122
|
+
isNonNegativeNumber(br.files_changed) &&
|
|
123
|
+
br.files_changed > limits.max_files_changed
|
|
124
|
+
) {
|
|
125
|
+
return `declared files_changed ${br.files_changed} exceeds max_files_changed ${limits.max_files_changed}`;
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
isNonNegativeNumber(limits.max_lines_changed) &&
|
|
129
|
+
isNonNegativeNumber(br.lines_changed) &&
|
|
130
|
+
br.lines_changed > limits.max_lines_changed
|
|
131
|
+
) {
|
|
132
|
+
return `declared lines_changed ${br.lines_changed} exceeds max_lines_changed ${limits.max_lines_changed}`;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Does an `approvals` rule apply to this patch? A rule constrains by `layer`
|
|
138
|
+
// and/or `target` (schema guarantees at least one). Every present constraint
|
|
139
|
+
// must match; `target` is matched with the same glob as protected surfaces.
|
|
140
|
+
function ruleMatches(rule, patch) {
|
|
141
|
+
if (!isPlainObject(rule)) return false;
|
|
142
|
+
if (isAbsent(rule.layer) && isAbsent(rule.target)) return false;
|
|
143
|
+
if (!isAbsent(rule.layer) && rule.layer !== patch.layer) return false;
|
|
144
|
+
if (!isAbsent(rule.target) && !matchSurface(rule.target, patch.target)) return false;
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Resolve the effective `requires` level: the strictest of the patch's own
|
|
149
|
+
// `requires` and every matching approval rule; when no rule matches, the
|
|
150
|
+
// policy's `default_requires` (defaulting to `human-gate`) applies instead.
|
|
151
|
+
// Returns `{ level, rank, matched }`.
|
|
152
|
+
function effectiveRequires(patch, policy) {
|
|
153
|
+
const levels = [patch.requires];
|
|
154
|
+
const approvals = Array.isArray(policy.approvals) ? policy.approvals : [];
|
|
155
|
+
let matched = false;
|
|
156
|
+
for (const rule of approvals) {
|
|
157
|
+
if (ruleMatches(rule, patch)) {
|
|
158
|
+
levels.push(rule.requires);
|
|
159
|
+
matched = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!matched) {
|
|
163
|
+
levels.push(isString(policy.default_requires) ? policy.default_requires : "human-gate");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let level = "auto";
|
|
167
|
+
let rank = 0;
|
|
168
|
+
for (const l of levels) {
|
|
169
|
+
const r = requiresRank(l);
|
|
170
|
+
if (r >= rank) {
|
|
171
|
+
rank = r;
|
|
172
|
+
level = l;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { level, rank, matched };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Decide whether a self-patch may be applied under a policy.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} patch A self-patch object (valid per #1). Reads `target`,
|
|
182
|
+
* `layer`, `blast_radius`, and `requires`.
|
|
183
|
+
* @param {object} policy A protected-surface policy (valid per #1). Reads
|
|
184
|
+
* `protected_surfaces`, `blast_radius_limits`, `approvals`, `default_requires`.
|
|
185
|
+
* @returns {{ decision: "approve"|"human-gate"|"block", reason: string }}
|
|
186
|
+
* @throws {GateError} If `patch` or `policy` is not a plain object.
|
|
187
|
+
*/
|
|
188
|
+
export function gate(patch, policy) {
|
|
189
|
+
if (!isPlainObject(patch)) {
|
|
190
|
+
throw new GateError("gate: `patch` must be an object");
|
|
191
|
+
}
|
|
192
|
+
if (!isPlainObject(policy)) {
|
|
193
|
+
throw new GateError("gate: `policy` must be an object");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 1. Protected surface — the hardest violation; wins over any `requires`.
|
|
197
|
+
const protectedSurfaces = Array.isArray(policy.protected_surfaces)
|
|
198
|
+
? policy.protected_surfaces
|
|
199
|
+
: [];
|
|
200
|
+
const hit = firstProtectedHit(touchedSurfaces(patch), protectedSurfaces);
|
|
201
|
+
if (hit) {
|
|
202
|
+
const via = hit.pattern === hit.surface ? "" : ` (matched by rule "${hit.pattern}")`;
|
|
203
|
+
return {
|
|
204
|
+
decision: "block",
|
|
205
|
+
reason: `touches protected surface "${hit.surface}"${via}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 2. Blast radius over a declared limit.
|
|
210
|
+
const limits = isPlainObject(policy.blast_radius_limits)
|
|
211
|
+
? policy.blast_radius_limits
|
|
212
|
+
: {};
|
|
213
|
+
const blast = firstBlastViolation(patch, limits);
|
|
214
|
+
if (blast) {
|
|
215
|
+
return { decision: "block", reason: blast };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. Effective approval level.
|
|
219
|
+
const eff = effectiveRequires(patch, policy);
|
|
220
|
+
const decision = RANK_TO_DECISION[eff.rank] || "block";
|
|
221
|
+
const via = eff.matched ? "a policy approval rule" : "the patch/default level";
|
|
222
|
+
if (decision === "block") {
|
|
223
|
+
return { decision, reason: `effective requires "forbidden" (via ${via})` };
|
|
224
|
+
}
|
|
225
|
+
if (decision === "human-gate") {
|
|
226
|
+
return { decision, reason: `effective requires "human-gate" (via ${via})` };
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
decision: "approve",
|
|
230
|
+
reason: `effective requires "auto"; no protected surface touched and within blast-radius limits`,
|
|
231
|
+
};
|
|
232
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export {
|
|
2
|
+
validateSelfPatch,
|
|
3
|
+
validatePolicy,
|
|
4
|
+
LAYERS,
|
|
5
|
+
REQUIRES,
|
|
6
|
+
DIFF_FORMATS,
|
|
7
|
+
ERROR_CODES,
|
|
8
|
+
} from "./schema.js";
|
|
9
|
+
|
|
10
|
+
export { sha256hex } from "./sha256.js";
|
|
11
|
+
|
|
12
|
+
export { proposeSelfPatch, ProposeError } from "./propose.js";
|
|
13
|
+
|
|
14
|
+
export { verifySelfPatch, VerifyError } from "./verify.js";
|
|
15
|
+
|
|
16
|
+
export { gate, GateError, DECISIONS } from "./gate.js";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
LedgerError,
|
|
20
|
+
LEDGER_EVENTS,
|
|
21
|
+
hashEntry,
|
|
22
|
+
blobRef,
|
|
23
|
+
buildAppliedEntry,
|
|
24
|
+
buildRevertedEntry,
|
|
25
|
+
verifyChain,
|
|
26
|
+
findRevertable,
|
|
27
|
+
} from "./ledger.js";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
proposeSkillEdits,
|
|
31
|
+
diffSkillTrees,
|
|
32
|
+
skillTargetPath,
|
|
33
|
+
SKILL_LAYER,
|
|
34
|
+
DEFAULT_SKILLS_ROOT,
|
|
35
|
+
DEFAULT_EVAL_COMMAND,
|
|
36
|
+
} from "./adapters/claude-code-skills.js";
|
package/src/ledger.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// selfpatch — `ledger` (v0.1). The hash-chained, tamper-evident record.
|
|
2
|
+
//
|
|
3
|
+
// The fourth verb: `apply` writes a surface and records the event, `revert <id>`
|
|
4
|
+
// restores the pre-patch surface, and `log` prints the history. This module is
|
|
5
|
+
// the **pure core** — chain construction, hashing, integrity verification, and
|
|
6
|
+
// entry selection. It performs NO filesystem, process, or network I/O; the CLI
|
|
7
|
+
// injects every read/write (the same core/seam split `verify` uses for
|
|
8
|
+
// `runCheck`). Canonical hashing reuses `propose`'s key-order-independent
|
|
9
|
+
// `canonicalize`, so a re-serialized entry hashes identically.
|
|
10
|
+
//
|
|
11
|
+
// Integrity is a **hash chain, not signatures**: each entry stores `prev` (the
|
|
12
|
+
// hash of the preceding entry) and its own `hash` = sha256 over the entry's
|
|
13
|
+
// canonical content excluding `hash` itself. Because the hash covers `prev`, any
|
|
14
|
+
// edit, reorder, insertion, or deletion of a past entry breaks the chain and is
|
|
15
|
+
// detected on read. This is tamper-*evidence* (detection), not prevention or
|
|
16
|
+
// authorship — signatures are a deferred later milestone.
|
|
17
|
+
|
|
18
|
+
import { sha256hex } from "./sha256.js";
|
|
19
|
+
import { canonicalize } from "./canonical.js";
|
|
20
|
+
|
|
21
|
+
// Thrown for un-chainable input (a non-array chain, an entry that isn't a plain
|
|
22
|
+
// object) and for an ambiguous revert id-prefix. Named/exported to mirror
|
|
23
|
+
// `ProposeError`/`VerifyError`/`GateError` so callers can rely on the type.
|
|
24
|
+
export class LedgerError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "LedgerError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The event vocabulary the two mutating verbs write. `log` renders any event
|
|
32
|
+
// type it finds, so a future pipeline can append `proposed`/`gated`/`blocked`
|
|
33
|
+
// lifecycle entries without a format change (a non-goal here).
|
|
34
|
+
export const LEDGER_EVENTS = ["applied", "reverted"];
|
|
35
|
+
|
|
36
|
+
function isPlainObject(v) {
|
|
37
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sha256(content) {
|
|
41
|
+
return sha256hex(content);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The hash of an entry: `"sha256:" + sha256(canonical(entry without hash))`.
|
|
46
|
+
* `prev` IS included in the canonical content, so the hash covers the link to
|
|
47
|
+
* the predecessor — an attacker cannot reorder entries without breaking a hash.
|
|
48
|
+
* @throws {LedgerError} If `entry` is not a plain object.
|
|
49
|
+
*/
|
|
50
|
+
export function hashEntry(entry) {
|
|
51
|
+
if (!isPlainObject(entry)) {
|
|
52
|
+
throw new LedgerError("hashEntry: entry must be a plain object");
|
|
53
|
+
}
|
|
54
|
+
const hashable = {};
|
|
55
|
+
for (const k of Object.keys(entry)) {
|
|
56
|
+
if (k === "hash") continue;
|
|
57
|
+
hashable[k] = entry[k];
|
|
58
|
+
}
|
|
59
|
+
return "sha256:" + sha256(canonicalize(hashable));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A content-addressed reference to a surface snapshot:
|
|
64
|
+
* `"sha256:" + sha256(content)`. `null` content ⇒ `null` ref (an absent
|
|
65
|
+
* surface — a file `apply` created or that `revert` will delete). The
|
|
66
|
+
* object-store key is the hex after the `sha256:` prefix.
|
|
67
|
+
*/
|
|
68
|
+
export function blobRef(content) {
|
|
69
|
+
if (content === null || content === undefined) return null;
|
|
70
|
+
return "sha256:" + sha256(content);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Attach `prev`/`seq`, then the `hash` over everything else. Pure. `partial`
|
|
74
|
+
// carries every semantic field; the caller supplies `seq` and `prev`.
|
|
75
|
+
function linkEntry(prevHash, seq, partial) {
|
|
76
|
+
const entry = { ...partial, seq, prev: prevHash ?? null };
|
|
77
|
+
entry.hash = hashEntry(entry);
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Assemble a hash-linked `applied` entry from a self-patch and its snapshots.
|
|
83
|
+
* `pre`/`post` are blob refs (or `null`); `reason` is omitted when absent.
|
|
84
|
+
*/
|
|
85
|
+
export function buildAppliedEntry({ patch, target, pre, post, ts, seq, prev, reason }) {
|
|
86
|
+
const partial = {
|
|
87
|
+
ts,
|
|
88
|
+
event: "applied",
|
|
89
|
+
patch_id: patch.id,
|
|
90
|
+
layer: patch.layer,
|
|
91
|
+
target,
|
|
92
|
+
rationale: patch.rationale,
|
|
93
|
+
pre,
|
|
94
|
+
post,
|
|
95
|
+
};
|
|
96
|
+
if (reason !== undefined && reason !== null) partial.reason = reason;
|
|
97
|
+
return linkEntry(prev, seq, partial);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Assemble a hash-linked `reverted` entry. `reverts` is the `hash` of the
|
|
102
|
+
* `applied` entry being undone; `pre`/`post` snapshot the surface around the
|
|
103
|
+
* restore.
|
|
104
|
+
*/
|
|
105
|
+
export function buildRevertedEntry({ patchId, target, reverts, pre, post, ts, seq, prev }) {
|
|
106
|
+
return linkEntry(prev, seq, {
|
|
107
|
+
ts,
|
|
108
|
+
event: "reverted",
|
|
109
|
+
patch_id: patchId,
|
|
110
|
+
target,
|
|
111
|
+
reverts,
|
|
112
|
+
pre,
|
|
113
|
+
post,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Verify a parsed chain. For each entry checks `prev` links to the previous
|
|
119
|
+
* entry's `hash` (`null` at genesis) AND `hash === hashEntry(entry)`. Detects
|
|
120
|
+
* mutation, reorder, insertion, and deletion. Returns `{ valid, brokenAt }`
|
|
121
|
+
* where `brokenAt` is the first failing index (`-1` if intact).
|
|
122
|
+
* @throws {LedgerError} If `entries` is not an array.
|
|
123
|
+
*/
|
|
124
|
+
export function verifyChain(entries) {
|
|
125
|
+
if (!Array.isArray(entries)) {
|
|
126
|
+
throw new LedgerError("verifyChain: entries must be an array");
|
|
127
|
+
}
|
|
128
|
+
for (let i = 0; i < entries.length; i++) {
|
|
129
|
+
const entry = entries[i];
|
|
130
|
+
if (!isPlainObject(entry)) return { valid: false, brokenAt: i };
|
|
131
|
+
const expectedPrev = i === 0 ? null : entries[i - 1].hash;
|
|
132
|
+
if (entry.prev !== expectedPrev) return { valid: false, brokenAt: i };
|
|
133
|
+
let recomputed;
|
|
134
|
+
try {
|
|
135
|
+
recomputed = hashEntry(entry);
|
|
136
|
+
} catch {
|
|
137
|
+
return { valid: false, brokenAt: i };
|
|
138
|
+
}
|
|
139
|
+
if (entry.hash !== recomputed) return { valid: false, brokenAt: i };
|
|
140
|
+
}
|
|
141
|
+
return { valid: true, brokenAt: -1 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Resolve an `id` to a single concrete `patch_id`. An **exact** full-id match
|
|
145
|
+
// always wins (never ambiguous, even when it is also a prefix of a longer id).
|
|
146
|
+
// Otherwise `id` is treated as a prefix: unique ⇒ that id, none ⇒ null, more
|
|
147
|
+
// than one distinct id ⇒ throw. Returns `{ patchId }` / `{ patchId: null }`.
|
|
148
|
+
function resolvePatchId(entries, id) {
|
|
149
|
+
const prefixIds = new Set();
|
|
150
|
+
let exact = false;
|
|
151
|
+
for (const e of entries) {
|
|
152
|
+
if (typeof e.patch_id !== "string") continue;
|
|
153
|
+
if (e.patch_id === id) exact = true;
|
|
154
|
+
if (e.patch_id.startsWith(id)) prefixIds.add(e.patch_id);
|
|
155
|
+
}
|
|
156
|
+
if (exact) return { patchId: id };
|
|
157
|
+
if (prefixIds.size > 1) {
|
|
158
|
+
throw new LedgerError(
|
|
159
|
+
`findRevertable: id prefix "${id}" is ambiguous (matches ${prefixIds.size} patches)`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (prefixIds.size === 0) return { patchId: null };
|
|
163
|
+
return { patchId: [...prefixIds][0] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Find the most recent `applied` entry for `id` that has not already been
|
|
168
|
+
* reverted — i.e. whose `hash` is not the `reverts` target of any later
|
|
169
|
+
* `reverted` entry. `id` is a full `sha256:…` or a unique prefix (an exact full
|
|
170
|
+
* id always resolves, even if it is also a prefix of a longer id).
|
|
171
|
+
*
|
|
172
|
+
* @returns {{ entry: object } | { entry: null, reason: "not-found" | "already-reverted" }}
|
|
173
|
+
* @throws {LedgerError} If the prefix matches more than one distinct patch id.
|
|
174
|
+
*/
|
|
175
|
+
export function findRevertable(entries, id) {
|
|
176
|
+
if (!Array.isArray(entries)) {
|
|
177
|
+
throw new LedgerError("findRevertable: entries must be an array");
|
|
178
|
+
}
|
|
179
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
180
|
+
throw new LedgerError("findRevertable: id must be a non-empty string");
|
|
181
|
+
}
|
|
182
|
+
const { patchId } = resolvePatchId(entries, id);
|
|
183
|
+
if (patchId === null) return { entry: null, reason: "not-found" };
|
|
184
|
+
|
|
185
|
+
// The set of applied-entry hashes already undone by a `reverted` entry.
|
|
186
|
+
const revertedHashes = new Set();
|
|
187
|
+
for (const e of entries) {
|
|
188
|
+
if (e.event === "reverted" && typeof e.reverts === "string") {
|
|
189
|
+
revertedHashes.add(e.reverts);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Walk newest-first; return the latest un-reverted apply for this id.
|
|
194
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
195
|
+
const e = entries[i];
|
|
196
|
+
if (e.event !== "applied") continue;
|
|
197
|
+
if (e.patch_id !== patchId) continue;
|
|
198
|
+
if (revertedHashes.has(e.hash)) continue;
|
|
199
|
+
return { entry: e };
|
|
200
|
+
}
|
|
201
|
+
return { entry: null, reason: "already-reverted" };
|
|
202
|
+
}
|