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 +13 -3
- package/dist/cli/index.js +79 -13
- package/package.json +1 -1
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,
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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:" +
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|