approval-md 0.4.2 → 0.4.5

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.
@@ -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
- * The classifier, its context, and all four impure refinements, in the one
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 four refinements in the chain.
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
@@ -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 four impure refinements, in the one
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 four refinements in the chain.
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: written.result,
2216
- notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes, ...written.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
  // ===========================================================================