approval-md 0.4.2 → 0.4.3
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/dist/src/cli/hook.d.ts +25 -2
- package/dist/src/cli/hook.js +258 -6
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/command-class.d.ts +57 -0
- package/dist/src/core/command-class.js +188 -10
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/live-draw.d.ts +12 -2
- package/dist/src/core/live-draw.js +12 -2
- package/dist/src/core/live-draw.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/package.json +2 -2
package/dist/src/cli/hook.d.ts
CHANGED
|
@@ -762,12 +762,35 @@ export declare function resolveWriteRoots(cwd: string): string[];
|
|
|
762
762
|
*/
|
|
763
763
|
export declare function refineWriteScope(result: CommandClassification, roots: readonly string[], cwd: string): RefinedClassification;
|
|
764
764
|
/**
|
|
765
|
-
*
|
|
765
|
+
* Check every skill-script segment against the disk (APRV-502).
|
|
766
|
+
*
|
|
767
|
+
* IMPURE by design and by contract, like the other passes: it resolves paths.
|
|
768
|
+
* It only ever TIGHTENS. A segment whose script resolves anywhere but a file
|
|
769
|
+
* under the real `skills/<skill>/scripts/` directory it was spelled in (a
|
|
770
|
+
* credential or protected landing, a path outside the real root, a symlinked
|
|
771
|
+
* component of the root) turns the whole classification into an
|
|
772
|
+
* `unclassified` failure naming the landing, which the hook denies as
|
|
773
|
+
* `hook-unclassified`; and one the disk cannot show (unreadable, absent, or
|
|
774
|
+
* after a `cd` the text cannot name) keeps the text's `exec.local` with a note
|
|
775
|
+
* saying the pass could not look. See {@link judgeSkillScript} for why that
|
|
776
|
+
* last case is deliberate, and why a protected landing refuses rather than
|
|
777
|
+
* taking the landing's class.
|
|
778
|
+
*
|
|
779
|
+
* `cwd` is the directory the command runs in: the per-call Hermes `workdir`
|
|
780
|
+
* when the harness states one (absolute only), the hook's own directory
|
|
781
|
+
* elsewhere. It is used only to tighten, so a working directory the party under
|
|
782
|
+
* oversight reported cannot loosen anything here (SPEC.md §11.1 invariant 4).
|
|
783
|
+
* Nothing here reads `HERMES_HOME`: the daemon under `approval serve` does not
|
|
784
|
+
* carry it.
|
|
785
|
+
*/
|
|
786
|
+
export declare function refineSkillScripts(result: CommandClassification, cwd: string, protectedPaths: readonly ProtectedPathEntry[]): RefinedClassification;
|
|
787
|
+
/**
|
|
788
|
+
* The classifier, its context, and all five impure refinements, in the one
|
|
766
789
|
* order every caller must use.
|
|
767
790
|
*
|
|
768
791
|
* `hook classify` printing a different class from the one `hook claude-code`
|
|
769
792
|
* decides would make the explainer a different program (APRV-108's note), and
|
|
770
|
-
* that stays true now there are
|
|
793
|
+
* that stays true now there are five refinements in the chain.
|
|
771
794
|
*
|
|
772
795
|
* `readRoots` is the one argument whose ABSENCE is the loose answer rather than
|
|
773
796
|
* the strict one (APRV-347), so it is passed explicitly at every call site: an
|
package/dist/src/cli/hook.js
CHANGED
|
@@ -72,13 +72,13 @@
|
|
|
72
72
|
* auditor holding the log alone. See `docs/claude-code-hook.md`.
|
|
73
73
|
*/
|
|
74
74
|
import { spawnSync } from "node:child_process";
|
|
75
|
-
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeSync } from "node:fs";
|
|
75
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync, writeSync, } from "node:fs";
|
|
76
76
|
import { randomBytes } from "node:crypto";
|
|
77
77
|
import { tmpdir } from "node:os";
|
|
78
78
|
import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
|
|
79
79
|
import { attestationRefusal, checkAttestation, checkAttestationOfBytes } from "../core/attest.js";
|
|
80
80
|
import { childEnvironment } from "../core/child-env.js";
|
|
81
|
-
import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, protectedPathClass, } from "../core/command-class.js";
|
|
81
|
+
import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, NODE_SKILL_SCRIPT_RULE, protectedPathClass, SKILL_SCRIPT_RULE, } from "../core/command-class.js";
|
|
82
82
|
import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, isProposalTask, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
|
|
83
83
|
import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
|
|
84
84
|
import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
|
|
@@ -2182,13 +2182,256 @@ export function refineWriteScope(result, roots, cwd) {
|
|
|
2182
2182
|
}
|
|
2183
2183
|
return { result: { ok: true, segments, classes }, notes };
|
|
2184
2184
|
}
|
|
2185
|
+
// ===========================================================================
|
|
2186
|
+
// Skill scripts, the disk half (APRV-502)
|
|
2187
|
+
// ===========================================================================
|
|
2188
|
+
/**
|
|
2189
|
+
* The rules whose segments this pass judges: an interpreter running
|
|
2190
|
+
* `skills/<skill>/scripts/<file>`, which the pure classifier answers
|
|
2191
|
+
* `exec.local` from the TEXT alone.
|
|
2192
|
+
*/
|
|
2193
|
+
const SKILL_SCRIPT_RULES = [SKILL_SCRIPT_RULE, NODE_SKILL_SCRIPT_RULE];
|
|
2194
|
+
/** The `code` of a filesystem error, or `null`. */
|
|
2195
|
+
function fsErrorCode(error) {
|
|
2196
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
2197
|
+
return null;
|
|
2198
|
+
const code = error.code;
|
|
2199
|
+
return typeof code === "string" ? code : null;
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Judge one skill script from one working directory (APRV-502).
|
|
2203
|
+
*
|
|
2204
|
+
* `script` is the path the classifier bound, `skills/<skill>/scripts/<file>`
|
|
2205
|
+
* or an absolute `.hermes` spelling of it, so its parent is the spelled root
|
|
2206
|
+
* `<base>/skills/<skill>/scripts` and nothing needs re-parsing. `<base>` is
|
|
2207
|
+
* the directory the shape was spelled from: the working directory for the
|
|
2208
|
+
* relative shape, the `.hermes` or `.hermes/profiles/<p>` directory for the
|
|
2209
|
+
* absolute one. Both shapes put exactly three segments between base and root.
|
|
2210
|
+
*
|
|
2211
|
+
* ## Why an unreadable or absent script lets the text answer stand
|
|
2212
|
+
*
|
|
2213
|
+
* This pass can only tighten, like {@link resolvedPathClasses}, and where the
|
|
2214
|
+
* disk cannot be read it has nothing to tighten WITH. That case is not
|
|
2215
|
+
* hypothetical; it is the production shape. Under co-location the daemon that
|
|
2216
|
+
* answers `approval hook hermes` runs as the `approvald` user and the resident's
|
|
2217
|
+
* Hermes home (`/home/hermes/.hermes`) is mode 0700 owned by `hermes`, so
|
|
2218
|
+
* `realpath` of the script fails with EACCES on every live box (checked
|
|
2219
|
+
* read-only on one 2026-10-07). Refusing there would deny every skill script
|
|
2220
|
+
* fleet-wide, which is the outage APRV-502 exists to end, so the answer is the
|
|
2221
|
+
* text's and a note says the disk pass could not look. `resolvedPathClasses`
|
|
2222
|
+
* has exactly the same property for the same reason. An absent file
|
|
2223
|
+
* (ENOENT, ENOTDIR) is treated the same way: there is nothing on disk to run,
|
|
2224
|
+
* and the interpreter will fail on its own. What the text already guarantees
|
|
2225
|
+
* in both cases is the shape: no `..`, no variable, no glob, and every
|
|
2226
|
+
* protected and credential tier ahead of the rule.
|
|
2227
|
+
*
|
|
2228
|
+
* ## Where the disk CAN be read, everything off the plain shape refuses
|
|
2229
|
+
*
|
|
2230
|
+
* - A real path landing on a credential or protected path refuses, naming the
|
|
2231
|
+
* landing. It does not take the landing's class: for `bun` and `python3` the
|
|
2232
|
+
* direct spelling of a protected path is refused by the classifier (the
|
|
2233
|
+
* protected scan runs only after a table row matches), so taking the class
|
|
2234
|
+
* would answer a symlinked spelling MORE loosely than the direct one under a
|
|
2235
|
+
* policy where that class is looser than a refusal (the A502 refutation's
|
|
2236
|
+
* S2: `cron.manage` autonomous in the village template).
|
|
2237
|
+
* - No component of `skills/<skill>/scripts` may be a symlink: the real root
|
|
2238
|
+
* must equal `realpath(base)/skills/<skill>/scripts`. A symlinked base or
|
|
2239
|
+
* working directory is fine, because both sides are resolved.
|
|
2240
|
+
* - The real path of the script must sit strictly under that real root, so a
|
|
2241
|
+
* file symlink out of `skills/<skill>/scripts/` refuses. A dangling symlink
|
|
2242
|
+
* is judged by where it points, resolved through the deepest existing
|
|
2243
|
+
* ancestor of its target, so a link whose target does not exist YET cannot
|
|
2244
|
+
* be placed now and filled later.
|
|
2245
|
+
*/
|
|
2246
|
+
function judgeSkillScript(script, cwd, protectedPaths) {
|
|
2247
|
+
const spelled = resolvePathSegments(cwd, script);
|
|
2248
|
+
const root = dirname(spelled);
|
|
2249
|
+
let real;
|
|
2250
|
+
try {
|
|
2251
|
+
real = realpathSync(spelled);
|
|
2252
|
+
}
|
|
2253
|
+
catch (error) {
|
|
2254
|
+
const code = fsErrorCode(error);
|
|
2255
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
2256
|
+
return { kind: "unseen", why: `${spelled} cannot be read by this process (${code})` };
|
|
2257
|
+
}
|
|
2258
|
+
if (code === "ELOOP") {
|
|
2259
|
+
return { kind: "refused", detail: `${spelled} is a symlink loop (ELOOP)` };
|
|
2260
|
+
}
|
|
2261
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
2262
|
+
return { kind: "refused", detail: `${spelled} could not be resolved (${code ?? "unknown error"})` };
|
|
2263
|
+
}
|
|
2264
|
+
// Absent, or a dangling symlink. Only the second has anything to judge.
|
|
2265
|
+
let target = null;
|
|
2266
|
+
try {
|
|
2267
|
+
if (lstatSync(spelled).isSymbolicLink())
|
|
2268
|
+
target = readlinkSync(spelled);
|
|
2269
|
+
}
|
|
2270
|
+
catch (inner) {
|
|
2271
|
+
const innerCode = fsErrorCode(inner);
|
|
2272
|
+
if (innerCode === "EACCES" || innerCode === "EPERM") {
|
|
2273
|
+
return { kind: "unseen", why: `${spelled} cannot be read by this process (${innerCode})` };
|
|
2274
|
+
}
|
|
2275
|
+
target = null;
|
|
2276
|
+
}
|
|
2277
|
+
if (target === null) {
|
|
2278
|
+
return { kind: "unseen", why: `${spelled} does not exist (${code})` };
|
|
2279
|
+
}
|
|
2280
|
+
real = canonicalPath(resolvePathSegments(dirname(spelled), target));
|
|
2281
|
+
}
|
|
2282
|
+
if (isCredentialPath(real)) {
|
|
2283
|
+
return {
|
|
2284
|
+
kind: "refused",
|
|
2285
|
+
detail: `${script} resolves to ${real}, a credential path (account.credential)`,
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
const protectedClass = protectedPathClass(real, protectedPaths);
|
|
2289
|
+
if (protectedClass !== null) {
|
|
2290
|
+
return {
|
|
2291
|
+
kind: "refused",
|
|
2292
|
+
detail: `${script} resolves to ${real}, a protected path (${protectedClass})`,
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
// `<base>/skills/<skill>/scripts`: three segments up from the root is base.
|
|
2296
|
+
const skill = basename(dirname(root));
|
|
2297
|
+
const base = dirname(dirname(dirname(root)));
|
|
2298
|
+
let realRoot;
|
|
2299
|
+
let realBase;
|
|
2300
|
+
try {
|
|
2301
|
+
realRoot = realpathSync(root);
|
|
2302
|
+
realBase = realpathSync(base);
|
|
2303
|
+
}
|
|
2304
|
+
catch (error) {
|
|
2305
|
+
// The script resolved and a directory above it did not: two reads of one
|
|
2306
|
+
// disk disagreeing. Nothing here can vouch for that.
|
|
2307
|
+
return {
|
|
2308
|
+
kind: "refused",
|
|
2309
|
+
detail: `${root} could not be resolved (${fsErrorCode(error) ?? "unknown error"}) although the script inside it did`,
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
const expectedRoot = join(realBase, "skills", skill, "scripts");
|
|
2313
|
+
if (realRoot !== expectedRoot) {
|
|
2314
|
+
return {
|
|
2315
|
+
kind: "refused",
|
|
2316
|
+
detail: `${root} resolves to ${realRoot}, not ${expectedRoot}: a component of skills/${skill}/scripts is a symlink`,
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
if (!isBelow(real, realRoot)) {
|
|
2320
|
+
return {
|
|
2321
|
+
kind: "refused",
|
|
2322
|
+
detail: `${script} resolves to ${real}, outside the directory it was spelled in (${realRoot})`,
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
return { kind: "inside" };
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* The directories a segment of a command line may run in: the hook's `cwd`,
|
|
2329
|
+
* plus every directory an earlier literal `cd` or `pushd` could have moved to
|
|
2330
|
+
* (a `cd` may or may not have run, `||` and a failing `cd` included, so both
|
|
2331
|
+
* sides are kept, as {@link resolvedPathClasses} keeps them). `null` when an
|
|
2332
|
+
* earlier segment moves to a directory the text cannot name: `cd $X`, `cd ~`,
|
|
2333
|
+
* a bare `cd`, `cd -`, `popd`, or a `cd` carrying flags.
|
|
2334
|
+
*/
|
|
2335
|
+
function skillScriptCwds(segments, index, cwd) {
|
|
2336
|
+
let cwds = [cwd];
|
|
2337
|
+
for (const earlier of segments.slice(0, index)) {
|
|
2338
|
+
const parsed = commandSegmentWords(earlier.text)?.[0];
|
|
2339
|
+
if (parsed === undefined)
|
|
2340
|
+
continue;
|
|
2341
|
+
const bin = basename(parsed.bin);
|
|
2342
|
+
if (bin === "popd")
|
|
2343
|
+
return null;
|
|
2344
|
+
if (bin !== "cd" && bin !== "pushd")
|
|
2345
|
+
continue;
|
|
2346
|
+
const target = parsed.args[0];
|
|
2347
|
+
if (parsed.args.length !== 1 || target === undefined || target.startsWith("-"))
|
|
2348
|
+
return null;
|
|
2349
|
+
if (/[$`*?[{~]/u.test(target))
|
|
2350
|
+
return null;
|
|
2351
|
+
cwds = [...new Set([...cwds, ...cwds.map((from) => resolvePathSegments(from, target))])];
|
|
2352
|
+
if (cwds.length > 64)
|
|
2353
|
+
return null;
|
|
2354
|
+
}
|
|
2355
|
+
return cwds;
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Check every skill-script segment against the disk (APRV-502).
|
|
2359
|
+
*
|
|
2360
|
+
* IMPURE by design and by contract, like the other passes: it resolves paths.
|
|
2361
|
+
* It only ever TIGHTENS. A segment whose script resolves anywhere but a file
|
|
2362
|
+
* under the real `skills/<skill>/scripts/` directory it was spelled in (a
|
|
2363
|
+
* credential or protected landing, a path outside the real root, a symlinked
|
|
2364
|
+
* component of the root) turns the whole classification into an
|
|
2365
|
+
* `unclassified` failure naming the landing, which the hook denies as
|
|
2366
|
+
* `hook-unclassified`; and one the disk cannot show (unreadable, absent, or
|
|
2367
|
+
* after a `cd` the text cannot name) keeps the text's `exec.local` with a note
|
|
2368
|
+
* saying the pass could not look. See {@link judgeSkillScript} for why that
|
|
2369
|
+
* last case is deliberate, and why a protected landing refuses rather than
|
|
2370
|
+
* taking the landing's class.
|
|
2371
|
+
*
|
|
2372
|
+
* `cwd` is the directory the command runs in: the per-call Hermes `workdir`
|
|
2373
|
+
* when the harness states one (absolute only), the hook's own directory
|
|
2374
|
+
* elsewhere. It is used only to tighten, so a working directory the party under
|
|
2375
|
+
* oversight reported cannot loosen anything here (SPEC.md §11.1 invariant 4).
|
|
2376
|
+
* Nothing here reads `HERMES_HOME`: the daemon under `approval serve` does not
|
|
2377
|
+
* carry it.
|
|
2378
|
+
*/
|
|
2379
|
+
export function refineSkillScripts(result, cwd, protectedPaths) {
|
|
2380
|
+
if (!result.ok)
|
|
2381
|
+
return { result, notes: [] };
|
|
2382
|
+
if (!result.segments.some((segment) => SKILL_SCRIPT_RULES.includes(segment.rule))) {
|
|
2383
|
+
return { result, notes: [] };
|
|
2384
|
+
}
|
|
2385
|
+
const notes = [];
|
|
2386
|
+
for (const [index, segment] of result.segments.entries()) {
|
|
2387
|
+
if (!SKILL_SCRIPT_RULES.includes(segment.rule) || segment.class !== "exec.local")
|
|
2388
|
+
continue;
|
|
2389
|
+
const script = segment.path;
|
|
2390
|
+
if (script === undefined) {
|
|
2391
|
+
// The classifier binds the script on every segment of these rules, so
|
|
2392
|
+
// a segment without one is two reads of the same bytes disagreeing.
|
|
2393
|
+
return {
|
|
2394
|
+
result: {
|
|
2395
|
+
ok: false,
|
|
2396
|
+
code: "unclassified",
|
|
2397
|
+
segment: segment.text,
|
|
2398
|
+
detail: `skill-script segment carries no script path, so it cannot be checked against the disk`,
|
|
2399
|
+
},
|
|
2400
|
+
notes,
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
const cwds = skillScriptCwds(result.segments, index, cwd);
|
|
2404
|
+
if (cwds === null) {
|
|
2405
|
+
notes.push(`skill-script-unverified: an earlier cd moves to a directory the text cannot name, so ${script} was not checked against the disk and stays exec.local`);
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
for (const from of cwds) {
|
|
2409
|
+
const judged = judgeSkillScript(script, from, protectedPaths);
|
|
2410
|
+
if (judged.kind === "refused") {
|
|
2411
|
+
return {
|
|
2412
|
+
result: {
|
|
2413
|
+
ok: false,
|
|
2414
|
+
code: "unclassified",
|
|
2415
|
+
segment: segment.text,
|
|
2416
|
+
detail: `skill script escapes its directory: ${judged.detail}. A skill script must be a file under the real skills/<skill>/scripts/ directory it is spelled in, with no symlink in that directory's own path`,
|
|
2417
|
+
},
|
|
2418
|
+
notes,
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
if (judged.kind === "unseen") {
|
|
2422
|
+
notes.push(`skill-script-unverified: ${judged.why}, so the disk pass could not look and ${script} stays exec.local`);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
return { result, notes };
|
|
2427
|
+
}
|
|
2185
2428
|
/**
|
|
2186
|
-
* The classifier, its context, and all
|
|
2429
|
+
* The classifier, its context, and all five impure refinements, in the one
|
|
2187
2430
|
* order every caller must use.
|
|
2188
2431
|
*
|
|
2189
2432
|
* `hook classify` printing a different class from the one `hook claude-code`
|
|
2190
2433
|
* decides would make the explainer a different program (APRV-108's note), and
|
|
2191
|
-
* that stays true now there are
|
|
2434
|
+
* that stays true now there are five refinements in the chain.
|
|
2192
2435
|
*
|
|
2193
2436
|
* `readRoots` is the one argument whose ABSENCE is the loose answer rather than
|
|
2194
2437
|
* the strict one (APRV-347), so it is passed explicitly at every call site: an
|
|
@@ -2211,9 +2454,18 @@ export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
|
|
|
2211
2454
|
// it is the narrowest, and because nothing after it would re-read a segment
|
|
2212
2455
|
// it has already tightened.
|
|
2213
2456
|
const written = refineWriteScope(scoped.result, resolveWriteRoots(cwd), cwd);
|
|
2457
|
+
// APRV-502. Touches only the two skill-script rules, which no pass above
|
|
2458
|
+
// reads, so its place in the chain changes nothing for any other segment.
|
|
2459
|
+
const scripted = refineSkillScripts(written.result, cwd, protectedPaths);
|
|
2214
2460
|
return {
|
|
2215
|
-
result:
|
|
2216
|
-
notes: [
|
|
2461
|
+
result: scripted.result,
|
|
2462
|
+
notes: [
|
|
2463
|
+
...rewritten.notes,
|
|
2464
|
+
...scratched.notes,
|
|
2465
|
+
...scoped.notes,
|
|
2466
|
+
...written.notes,
|
|
2467
|
+
...scripted.notes,
|
|
2468
|
+
],
|
|
2217
2469
|
};
|
|
2218
2470
|
}
|
|
2219
2471
|
// ===========================================================================
|