tamperward 1.14.5 → 1.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -173,9 +173,18 @@ Full assumptions and residual risks: [SPEC.md](./SPEC.md),
173
173
  npx tamperward init
174
174
  ```
175
175
 
176
- One idempotent command wires the policy, the agent hooks, the pre-commit hook, and
177
- a CI workflow that runs both the diff-time check and pristine verification. It
178
- never overwrites anything you wrote; `--dry-run` prints the plan.
176
+ One idempotent command wires the policy, the agent hooks, the pre-commit hook, a
177
+ CI workflow that runs both the diff-time check and pristine verification, and a
178
+ `CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
179
+ It never overwrites anything you wrote; `--dry-run` prints the plan.
180
+
181
+ **`init` is not sufficient on its own, and it will tell you so.** In your
182
+ branch-protection or ruleset settings you must require the check *and* enable
183
+ "Require review from Code Owners". A `pull_request` workflow runs from the pull
184
+ request's own head and a required check is matched by job name, so without that
185
+ a PR can keep the job name, replace the gate with `true`, and present a green
186
+ required check over a change the gate would have blocked. That is reproduced on
187
+ this project's own CI, not a theoretical concern.
179
188
 
180
189
  A real deployment needs a verify command configured — the generated CI verify step
181
190
  **fails closed (exit 2) without one** rather than passing quietly. In
@@ -272,6 +281,7 @@ and its totals.
272
281
  | v1.9.0 | `tamperward verify`: the round-1 pristine oracle productized — visible-green + pristine-red is a masked failure, and "could not verify" fails closed |
273
282
  | Round 2, 9–0 | On 22 fresh repositories, v1.9.0 produced b=9/c=0 false-green discordance (p = 0.0039); four gated false greens remained, through two documented residual mechanisms |
274
283
  | cssstyle lifecycle escape (round 2) | The `tamperward run` envelope: the gate denied 42 mutations and verify refused all 25 stop attempts, and the runtime completed the session anyway over a masked tree — hooks decide, runtimes terminate, so the adjudication boundary moved outside the runtime |
284
+ | v1.14.6, external audit | A pull request can neuter the gate that judges it: `pull_request` workflows run from the PR's own head and required checks match by job name, so a job that keeps its name and runs `true` reports green over anything. Reproduced on this repo's own CI — `gate` succeeded in three seconds over a PR that lowered a mechanical rule to warn. No detector can close it, because the detector is defined by the candidate; `init` now writes a CODEOWNERS requirement on the gate-critical paths and states, every run, that branch protection is the half it cannot set for you |
275
285
  | v1.14.5, external audit | The pristine copy was a sibling of the visible copy and fully prepared before it ran, so the candidate's own code wrote `../pristine/<the failing test>` and both runs returned 0 — no race, no background process, no git. The pristine copy is now built only after the visible run exits, its restored files are digested either side of the pristine run, and the working and dependency trees are fingerprinted around both. Also: the overlay restored content but not MODE, so `chmod -x` alone hid a test from an exec-bit runner in both copies; a transient dependency substitution that restored itself on the way out left the entry-vs-exit fingerprint identical; and a committed `.npmrc` chose the registry the CI gate was downloaded from |
276
286
  | v1.14.4, external audit | The overlay restored the tests and then ran the agent's *runner*: `verify.command` was frozen, but `runner.js` — and anything `npm test` delegates to — was in no protected class. And `git replace` redirected what the base resolved to for `ls-tree`, `show` and `merge-base` alike, with no ref moved and no file touched. Verifier inputs are now restored from the base; every trusted read sets `GIT_NO_REPLACE_OBJECTS=1` and the envelope convicts a rewrite installed during the run. `init` also gained workflow migration and matcher repair, and the Claude hook now denies a payload it cannot parse instead of allowing it |
277
287
  | v1.14.3, external review | Standalone `verify` loaded its policy from the working tree, so the generated CI workflow let a pull request supply the `verify:` command for its own re-execution. `check --diff` flagged the edit as hook-tampering, so the workflow caught it as a pair — but only where both jobs are required, and `verify` alone had no protection. With a `--base`, policy now comes from that commit |
package/dist/cli/index.js CHANGED
@@ -510,7 +510,14 @@ function defaultPolicy(version = 1) {
510
510
  "**/.tamperward.yml",
511
511
  ".claude/settings.json",
512
512
  ".claude/settings.local.json",
513
- "**/.pre-commit-config.yaml"
513
+ "**/.pre-commit-config.yaml",
514
+ // CODEOWNERS is enforcement wiring, not documentation: it is the only
515
+ // thing that can require a human on a change to the workflow that
516
+ // decides whether the gate runs at all. GitHub reads it from any of
517
+ // these three locations. (H2.)
518
+ ".github/CODEOWNERS",
519
+ "CODEOWNERS",
520
+ "docs/CODEOWNERS"
514
521
  ]
515
522
  },
516
523
  rules: {
@@ -2709,6 +2716,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2709
2716
  }
2710
2717
 
2711
2718
  // src/cli/init.ts
2719
+ import { execFileSync as execFileSync4 } from "node:child_process";
2712
2720
  import { createHash as createHash5 } from "node:crypto";
2713
2721
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2714
2722
  import { dirname as dirname3, join as join8 } from "node:path";
@@ -2718,6 +2726,32 @@ var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
2718
2726
  var SWEEP_CMD = "npx --yes tamperward sweep claude";
2719
2727
  var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
2720
2728
  var MARKER = "# tamperward: block agent shortcuts before they land";
2729
+ var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
2730
+ var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
2731
+ function coveredBy(existing, critical) {
2732
+ for (const line of existing.split("\n")) {
2733
+ const t = line.trim();
2734
+ if (!t || t.startsWith("#")) continue;
2735
+ const pattern = t.split(/\s+/)[0];
2736
+ if (!t.slice(pattern.length).trim()) continue;
2737
+ if (pattern === "*" || pattern === critical) return true;
2738
+ if (pattern.endsWith("/") && critical.startsWith(pattern)) return true;
2739
+ }
2740
+ return false;
2741
+ }
2742
+ function inferOwner(cwd) {
2743
+ try {
2744
+ const url = execFileSync4("git", ["remote", "get-url", "origin"], {
2745
+ cwd,
2746
+ encoding: "utf8",
2747
+ stdio: ["ignore", "pipe", "ignore"]
2748
+ }).trim();
2749
+ const m = url.match(/github\.com[:/]+([^/]+)\//);
2750
+ return m ? `@${m[1]}` : null;
2751
+ } catch {
2752
+ return null;
2753
+ }
2754
+ }
2721
2755
  var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
2722
2756
  # without this file \u2014 everything here is an override, so an empty file changes nothing.
2723
2757
  # Docs: https://github.com/hexrift/tamperward#readme
@@ -2930,6 +2964,35 @@ ${line}` : existing.replace(/\n?$/, "\n") + line;
2930
2964
  }
2931
2965
  };
2932
2966
  }
2967
+ function planCodeowners(cwd) {
2968
+ const rel = ".github/CODEOWNERS";
2969
+ const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
2970
+ (r) => existsSync7(join8(cwd, r))
2971
+ );
2972
+ const path = join8(cwd, existingRel ?? rel);
2973
+ const existing = existingRel ? readFileSync10(path, "utf8") : null;
2974
+ const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
2975
+ if (missing.length === 0) {
2976
+ return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
2977
+ }
2978
+ const owner = inferOwner(cwd);
2979
+ const block = `
2980
+ ${CODEOWNERS_MARK}
2981
+ # A pull request that rewrites the workflow can keep the job name, replace the
2982
+ # gate with \`true\`, and still report a green required check. Only a human
2983
+ # requirement on these paths prevents that.
2984
+ ` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
2985
+ return {
2986
+ item: "codeowners",
2987
+ path: existingRel ?? rel,
2988
+ status: existing === null ? "create" : "update",
2989
+ detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner})`),
2990
+ apply: () => {
2991
+ mkdirSync5(dirname3(path), { recursive: true });
2992
+ writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
2993
+ }
2994
+ };
2995
+ }
2933
2996
  function planWorkflow(cwd, force) {
2934
2997
  const rel = ".github/workflows/tamperward.yml";
2935
2998
  const path = join8(cwd, rel);
@@ -2983,7 +3046,7 @@ function planWorkflow(cwd, force) {
2983
3046
  };
2984
3047
  }
2985
3048
  function planInit(cwd, opts = {}) {
2986
- return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false)];
3049
+ return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false), planCodeowners(cwd)];
2987
3050
  }
2988
3051
  function runInit(opts) {
2989
3052
  const cwd = opts.cwd ?? process.cwd();
@@ -3010,18 +3073,21 @@ tamperward init: ${changed} change(s) planned. Re-run without --dry-run to apply
3010
3073
  tamperward init: ${changed} change(s) applied. Commit them so the gate travels with the repo.
3011
3074
  `
3012
3075
  );
3076
+ w.write(
3077
+ '\nONE STEP LEFT, AND IT IS NOT OPTIONAL. In your branch-protection or ruleset\nsettings for the default branch, require the tamperward check AND enable\n"Require review from Code Owners".\n\nUntil you do, the CI gate is advisory. A pull request runs the workflow from\nits OWN head, and a required check is matched by job name \u2014 so a pull request\ncan keep the job called `tamperward`, replace the gate with `true`, and present\na green required check over a change the gate would have blocked. That is\nreproduced, not theoretical. CODEOWNERS is what puts a human in front of it;\nbranch protection is what makes CODEOWNERS bind.\n'
3078
+ );
3013
3079
  return 0;
3014
3080
  }
3015
3081
 
3016
3082
  // src/cli/verify.ts
3017
- import { execFileSync as execFileSync6, spawnSync } from "node:child_process";
3083
+ import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3018
3084
  import { createHash as createHash8 } from "node:crypto";
3019
3085
  import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync6, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
3020
3086
  import { tmpdir as tmpdir2 } from "node:os";
3021
3087
  import { dirname as dirname4, join as join11, resolve } from "node:path";
3022
3088
 
3023
3089
  // src/git/trusted.ts
3024
- import { execFileSync as execFileSync4 } from "node:child_process";
3090
+ import { execFileSync as execFileSync5 } from "node:child_process";
3025
3091
  import { createHash as createHash6 } from "node:crypto";
3026
3092
  import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3027
3093
  import { isAbsolute as isAbsolute2, join as join9 } from "node:path";
@@ -3030,7 +3096,7 @@ function trustedGitEnv() {
3030
3096
  }
3031
3097
  function gitDirOf(cwd) {
3032
3098
  try {
3033
- const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
3099
+ const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
3034
3100
  cwd,
3035
3101
  encoding: "utf8",
3036
3102
  stdio: ["ignore", "pipe", "pipe"],
@@ -3046,7 +3112,7 @@ function objectRewriteState(cwd) {
3046
3112
  const parts = [];
3047
3113
  try {
3048
3114
  parts.push(
3049
- "replace:" + execFileSync4("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
3115
+ "replace:" + execFileSync5("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
3050
3116
  cwd,
3051
3117
  encoding: "utf8",
3052
3118
  stdio: ["ignore", "pipe", "pipe"],
@@ -3073,12 +3139,12 @@ function objectRewriteState(cwd) {
3073
3139
  }
3074
3140
 
3075
3141
  // src/fingerprint.ts
3076
- import { execFileSync as execFileSync5 } from "node:child_process";
3142
+ import { execFileSync as execFileSync6 } from "node:child_process";
3077
3143
  import { createHash as createHash7 } from "node:crypto";
3078
3144
  import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
3079
3145
  import { join as join10 } from "node:path";
3080
3146
  function treeFingerprint(cwd) {
3081
- const files = execFileSync5("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
3147
+ const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
3082
3148
  cwd,
3083
3149
  encoding: "utf8",
3084
3150
  maxBuffer: 1 << 28,
@@ -3166,7 +3232,7 @@ var VERIFICATION_SURFACE = [
3166
3232
  "**/*.runsettings"
3167
3233
  ];
3168
3234
  function git2(args, cwd) {
3169
- return execFileSync6("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3235
+ return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3170
3236
  }
3171
3237
  function resolveBase(base, cwd) {
3172
3238
  assertRev(base);
@@ -3262,7 +3328,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
3262
3328
  if (e.type === "commit") {
3263
3329
  throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
3264
3330
  }
3265
- const content = execFileSync6("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
3331
+ const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
3266
3332
  const out2 = join11(dest, e.path);
3267
3333
  mkdirSync6(dirname4(out2), { recursive: true });
3268
3334
  rmSync2(out2, { force: true });
@@ -3449,13 +3515,13 @@ function parseVerify(args) {
3449
3515
  }
3450
3516
 
3451
3517
  // src/cli/run.ts
3452
- import { execFileSync as execFileSync7, spawnSync as spawnSync2 } from "node:child_process";
3518
+ import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
3453
3519
  import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
3454
3520
  import { resolve as resolve2 } from "node:path";
3455
3521
  var out = (s) => process.stdout.write(s + "\n");
3456
3522
  var err = (s) => process.stderr.write(s + "\n");
3457
3523
  function git3(args, cwd) {
3458
- return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3524
+ return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
3459
3525
  }
3460
3526
  function nowTicks() {
3461
3527
  try {
@@ -3580,7 +3646,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
3580
3646
  const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
3581
3647
  if (opts.settle && opts.settle > 0) {
3582
3648
  try {
3583
- execFileSync7("sleep", [String(opts.settle)]);
3649
+ execFileSync8("sleep", [String(opts.settle)]);
3584
3650
  } catch {
3585
3651
  }
3586
3652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "1.14.5",
3
+ "version": "1.14.6",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",