tamperward 1.14.5 → 1.15.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/README.md +79 -13
- package/dist/cli/index.js +461 -97
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Earlier controlled experiments → **[the research series](./docs/blog/index.md)
|
|
|
43
43
|
|
|
44
44
|
> **Scope.** The rows above are one model, one pressure prompt, one JS/TS
|
|
45
45
|
> ecosystem — evidence for that setting, not a universal claim. Round 2 tested
|
|
46
|
-
> the v1.9.0 stack; the current **v1.
|
|
46
|
+
> the v1.9.0 stack; the current **v1.15.0** adds post-study envelope hardening
|
|
47
47
|
> (externally reviewed, with findings tracked individually as REPRO or AUDIT in
|
|
48
48
|
> [SECURITY-ENVELOPE.md](./SECURITY-ENVELOPE.md) and closed with regression and
|
|
49
49
|
> mutation checks — see [CHANGELOG](./CHANGELOG.md)). Rounds 3 and 3.1 are
|
|
@@ -148,15 +148,28 @@ semantic correctness nor a substitute for independent held-out tests.
|
|
|
148
148
|
|
|
149
149
|
### CI authority
|
|
150
150
|
|
|
151
|
-
Pre-commit is an early backstop; protected CI is the merge authority.
|
|
151
|
+
Pre-commit is an early backstop; protected CI is the merge authority. Two workflows
|
|
152
|
+
are involved here, and they are not the same thing.
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
**The shipped workflow** — the one `tamperward init` writes into your repository as
|
|
155
|
+
`.github/workflows/tamperward.yml` — runs, independently:
|
|
156
|
+
|
|
157
|
+
1. `check --diff base.sha...head.sha`, with policy from the trusted merge-base; and
|
|
154
158
|
2. `verify --require-ancestor --base <base-sha>`, which since **1.14.3** also reads
|
|
155
159
|
its policy, verification surface and verifier command and budget from that same
|
|
156
|
-
trusted base — so neither
|
|
160
|
+
trusted base — so neither step's verdict is governed by the candidate.
|
|
161
|
+
|
|
162
|
+
Legitimate exceptions are out-of-band PR labels, `tamperward:allow:<rule>@<head-sha>`.
|
|
163
|
+
The workflow passes the head SHA to the gate through `TAMPERWARD_OOB_HEAD`, so an
|
|
164
|
+
approval is bound to the exact commit it was granted for and a new push invalidates it.
|
|
157
165
|
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
**This repository's own self-gate** — the `gate` job in `.github/workflows/ci.yml` —
|
|
167
|
+
runs the built CLI's `check --diff` over the pull-request range, cleared only by the
|
|
168
|
+
same label channel, but does **not** run `verify` on itself. This repo's test
|
|
169
|
+
expectations legitimately change whenever a rule changes, and standalone `verify` has
|
|
170
|
+
no out-of-band sign-off channel, so a pristine re-execution of its own suite would fail
|
|
171
|
+
closed on exactly the pull requests that improve the gate. The self-gate is a
|
|
172
|
+
diff-time gate.
|
|
160
173
|
|
|
161
174
|
This guarantee depends on a protected and immutable base, required status checks, a
|
|
162
175
|
pinned Tamperward version, and label permissions restricted to trusted humans.
|
|
@@ -173,9 +186,18 @@ Full assumptions and residual risks: [SPEC.md](./SPEC.md),
|
|
|
173
186
|
npx tamperward init
|
|
174
187
|
```
|
|
175
188
|
|
|
176
|
-
One idempotent command wires the policy, the agent hooks, the pre-commit hook,
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
One idempotent command wires the policy, the agent hooks, the pre-commit hook, a
|
|
190
|
+
CI workflow that runs both the diff-time check and pristine verification, and a
|
|
191
|
+
`CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
|
|
192
|
+
It never overwrites anything you wrote; `--dry-run` prints the plan.
|
|
193
|
+
|
|
194
|
+
**`init` is not sufficient on its own, and it will tell you so.** In your
|
|
195
|
+
branch-protection or ruleset settings you must require the check *and* enable
|
|
196
|
+
"Require review from Code Owners". A `pull_request` workflow runs from the pull
|
|
197
|
+
request's own head and a required check is matched by job name, so without that
|
|
198
|
+
a PR can keep the job name, replace the gate with `true`, and present a green
|
|
199
|
+
required check over a change the gate would have blocked. That is reproduced on
|
|
200
|
+
this project's own CI, not a theoretical concern.
|
|
179
201
|
|
|
180
202
|
A real deployment needs a verify command configured — the generated CI verify step
|
|
181
203
|
**fails closed (exit 2) without one** rather than passing quietly. In
|
|
@@ -209,9 +231,50 @@ npx tamperward verify --base main # pristine-suite re-execution
|
|
|
209
231
|
npx tamperward run -- <agent command...> # the outer envelope around an agent
|
|
210
232
|
```
|
|
211
233
|
|
|
234
|
+
### CLI reference
|
|
235
|
+
|
|
236
|
+
Every flag below is what the command's parser actually reads (`src/cli/index.ts`,
|
|
237
|
+
`parseVerify`, `parseRun`). `check` reports an unknown flag on stderr; the other
|
|
238
|
+
commands ignore what they do not know.
|
|
239
|
+
|
|
240
|
+
| command | flags |
|
|
241
|
+
| --- | --- |
|
|
242
|
+
| `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
|
|
243
|
+
| `verify` | `--base <rev>` (default `HEAD`) · `--cmd <suite command>` · `--budget <seconds>` · `--json` · `--keep` (keep the two materialised copies and report their paths) · `--require-ancestor` (refuse a base that is not an ancestor of `HEAD`) · `--cwd <dir>` |
|
|
244
|
+
| `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
|
|
245
|
+
| `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
|
|
246
|
+
| `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
|
|
247
|
+
| `watch` | `--dir <dir>` · `--log <file>` — a daemon; it runs until signalled |
|
|
248
|
+
| `hook claude` / `sweep claude` | none — the Claude Code payload arrives on stdin |
|
|
249
|
+
|
|
250
|
+
**Exit codes** — part of the public surface:
|
|
251
|
+
|
|
252
|
+
| command | 0 | 1 | 2 |
|
|
253
|
+
| --- | --- | --- | --- |
|
|
254
|
+
| `check` | no blocking finding | at least one blocking finding | cannot evaluate: policy parse error, malformed `--diff` range, or no view given |
|
|
255
|
+
| `verify` | `VERIFIED` — visible and pristine both green | `MASKED_FAILURE` (visible green, pristine red) or `SUITE_RED` | cannot verify, failing closed: no suite command, unresolvable base, `--require-ancestor` refused, budget exceeded, or the working or dependency tree moved during the run |
|
|
256
|
+
| `run` | enforcement clean and the agent exited 0 — a non-zero agent exit is passed through unchanged | any blocking finding or masked failure, whatever the agent returned | cannot adjudicate: dirty start, policy error, verify cannot run |
|
|
257
|
+
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name |
|
|
258
|
+
| `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off |
|
|
259
|
+
| `init` | wired, or already wired | — | an item needs attention |
|
|
260
|
+
| no or unknown command | help printed (no command) | — | unknown command, help printed |
|
|
261
|
+
|
|
262
|
+
### Environment variables
|
|
263
|
+
|
|
264
|
+
| variable | who sets it | what it does |
|
|
265
|
+
| --- | --- | --- |
|
|
266
|
+
| `TAMPERWARD_OOB_SIGNOFF` | the CI workflow, from PR labels | comma-separated out-of-band approvals — `<rule>` or `<rule>:<file>`, optionally `@<head-sha>` — honoured by `check --diff` only; never the committed ledger |
|
|
267
|
+
| `TAMPERWARD_OOB_HEAD` | the CI workflow (`github.event.pull_request.head.sha`) | the head SHA under adjudication; once set, an approval clears a finding only if it names that commit (`@<sha>`, at least 7 characters), so a new push re-blocks |
|
|
268
|
+
| `TAMPERWARD_DENYLOG` | a harness or operator | a file to which `hook claude` and `sweep claude` append the rule ids of every deny, one line per verdict, best effort |
|
|
269
|
+
| `TAMPERWARD_FSEVENTS` | operator or harness | overrides the `tamperward watch` event-log path (default `.git/tamperward/fsevents.jsonl`); the Stop sweep reads the same variable |
|
|
270
|
+
| `TAMPERWARD_WATCH_NO_RECURSIVE` | CI and tests (`=1`) | forces `tamperward watch` onto its per-directory fallback instead of recursive `fs.watch`, so the fallback is exercised on every platform |
|
|
271
|
+
| `TAMPERWARD_TRANSIENT` | a harness that owns restore semantics (`=block`) | raises `transient-protected-mutation` from warn to block; it can never lower a severity |
|
|
272
|
+
| `NO_COLOR` / `FORCE_COLOR` | the user's shell | any non-empty `NO_COLOR` disables colour in the text renderer; a non-empty, non-`0` `FORCE_COLOR` enables it |
|
|
273
|
+
| `GITHUB_ACTIONS` | GitHub Actions (`=true`) | `--format auto` selects the `github` renderer — an inline annotation per finding plus a job summary |
|
|
274
|
+
|
|
212
275
|
### The rules
|
|
213
276
|
|
|
214
|
-
|
|
277
|
+
Seventeen rules are specified and fifteen ship (see the table in
|
|
215
278
|
[SPEC.md](./SPEC.md)). The families: test protection (`test-deletion`,
|
|
216
279
|
`test-skip`, `test-content-removal`), verification-signal protection
|
|
217
280
|
(`coverage-lowering`, `snapshot-rewrite`, `snapshot-only-rewrite`), suppression
|
|
@@ -272,6 +335,7 @@ and its totals.
|
|
|
272
335
|
| 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
336
|
| 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
337
|
| 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 |
|
|
338
|
+
| 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
339
|
| 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
340
|
| 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
341
|
| 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 |
|
|
@@ -298,12 +362,14 @@ via npm trusted publishing with SLSA provenance. Full rule:
|
|
|
298
362
|
|
|
299
363
|
```bash
|
|
300
364
|
npm install && npm run build # bundles the CLI to dist/cli/index.js
|
|
301
|
-
npm test #
|
|
365
|
+
npm test # 520+ tests at 1.15.0 — parser, detectors, engine, policy, renderers
|
|
302
366
|
npm run typecheck
|
|
303
367
|
```
|
|
304
368
|
|
|
305
|
-
Tamperward
|
|
306
|
-
|
|
369
|
+
Tamperward's own CI runs the engine it ships over every pull request — `check --diff`
|
|
370
|
+
over the PR range, cleared only by an out-of-band label; see [CI authority](#ci-authority)
|
|
371
|
+
for why the self-gate runs no `verify` on itself — and has, on more than one occasion,
|
|
372
|
+
blocked its own author's commits. See **[SPEC.md](./SPEC.md)** for
|
|
307
373
|
the build spec, the detector table, the enforcement-point wiring, and the proof-harness
|
|
308
374
|
design; the `harness/` directory holds the seeds, oracles, transcripts tooling, and
|
|
309
375
|
every pre-registered prediction with its outcome.
|
package/dist/cli/index.js
CHANGED
|
@@ -90,6 +90,8 @@ function parseDiff(diff) {
|
|
|
90
90
|
let renameTo = null;
|
|
91
91
|
let minusPath = null;
|
|
92
92
|
let plusPath = null;
|
|
93
|
+
let oldMode;
|
|
94
|
+
let newMode;
|
|
93
95
|
const hunks = [];
|
|
94
96
|
while (i < lines.length && !lines[i].startsWith("diff --git ")) {
|
|
95
97
|
const l = lines[i];
|
|
@@ -103,8 +105,14 @@ function parseDiff(diff) {
|
|
|
103
105
|
i++;
|
|
104
106
|
continue;
|
|
105
107
|
}
|
|
106
|
-
if (l.startsWith("new file mode"))
|
|
107
|
-
|
|
108
|
+
if (l.startsWith("new file mode ")) {
|
|
109
|
+
op = "add";
|
|
110
|
+
newMode = l.slice(14).trim();
|
|
111
|
+
} else if (l.startsWith("deleted file mode ")) {
|
|
112
|
+
op = "delete";
|
|
113
|
+
oldMode = l.slice(18).trim();
|
|
114
|
+
} else if (l.startsWith("old mode ")) oldMode = l.slice(9).trim();
|
|
115
|
+
else if (l.startsWith("new mode ")) newMode = l.slice(9).trim();
|
|
108
116
|
else if (l.startsWith("rename from ")) {
|
|
109
117
|
renameFrom = unquotePath(l.slice(12));
|
|
110
118
|
op = "rename";
|
|
@@ -140,7 +148,9 @@ function parseDiff(diff) {
|
|
|
140
148
|
before: null,
|
|
141
149
|
after: null,
|
|
142
150
|
binary,
|
|
143
|
-
hunks
|
|
151
|
+
hunks,
|
|
152
|
+
...oldMode !== void 0 ? { oldMode } : {},
|
|
153
|
+
...newMode !== void 0 ? { newMode } : {}
|
|
144
154
|
};
|
|
145
155
|
changes.push(change);
|
|
146
156
|
}
|
|
@@ -344,7 +354,13 @@ var LITERAL = [
|
|
|
344
354
|
{ re: /\bGIT_CONFIG_KEY_\d+\s*=\s*["']?core\.hooksPath/i, why: "GIT_CONFIG_* injection sets core.hooksPath" },
|
|
345
355
|
{ re: /(?:^|[\s;&|=])LEFTHOOK=0(?:\s|$)/, why: "LEFTHOOK=0 disables Lefthook hooks" },
|
|
346
356
|
{ re: /\bSKIP_SIMPLE_GIT_HOOKS=1\b/, why: "SKIP_SIMPLE_GIT_HOOKS=1 skips simple-git-hooks" },
|
|
347
|
-
{ re: /\bPRE_COMMIT_ALLOW_NO_CONFIG=1\b/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" }
|
|
357
|
+
{ re: /\bPRE_COMMIT_ALLOW_NO_CONFIG=1\b/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" },
|
|
358
|
+
// The pre-commit framework's own escape hatch: SKIP=<hook-id>[,…] skips those
|
|
359
|
+
// hooks for that one invocation. `.pre-commit-config.yaml` is a protected hook,
|
|
360
|
+
// so its documented bypass has to be one too. `SKIP=` is a plausible variable
|
|
361
|
+
// name elsewhere, so it counts only in a segment that runs git or pre-commit.
|
|
362
|
+
{ re: /(?:^|[\s;&|])SKIP=\S+/, why: "SKIP=<hook-id> tells the pre-commit framework to skip that hook", when: /\b(?:git|pre-commit)\b/ },
|
|
363
|
+
{ re: /(?:^|[\s;&|])LEFTHOOK_EXCLUDE=\S+/, why: "LEFTHOOK_EXCLUDE=<name> skips the named Lefthook commands" }
|
|
348
364
|
];
|
|
349
365
|
var SHORT_N = /^-[a-z]*n[a-z]*$/i;
|
|
350
366
|
var noVerify = {
|
|
@@ -358,7 +374,7 @@ var noVerify = {
|
|
|
358
374
|
for (const seg of segments(c.raw)) {
|
|
359
375
|
let why = null;
|
|
360
376
|
for (const p of LITERAL) {
|
|
361
|
-
if (p.re.test(seg)) {
|
|
377
|
+
if (p.re.test(seg) && (!p.when || p.when.test(seg))) {
|
|
362
378
|
why = p.why;
|
|
363
379
|
break;
|
|
364
380
|
}
|
|
@@ -510,7 +526,14 @@ function defaultPolicy(version = 1) {
|
|
|
510
526
|
"**/.tamperward.yml",
|
|
511
527
|
".claude/settings.json",
|
|
512
528
|
".claude/settings.local.json",
|
|
513
|
-
"**/.pre-commit-config.yaml"
|
|
529
|
+
"**/.pre-commit-config.yaml",
|
|
530
|
+
// CODEOWNERS is enforcement wiring, not documentation: it is the only
|
|
531
|
+
// thing that can require a human on a change to the workflow that
|
|
532
|
+
// decides whether the gate runs at all. GitHub reads it from any of
|
|
533
|
+
// these three locations. (H2.)
|
|
534
|
+
".github/CODEOWNERS",
|
|
535
|
+
"CODEOWNERS",
|
|
536
|
+
"docs/CODEOWNERS"
|
|
514
537
|
]
|
|
515
538
|
},
|
|
516
539
|
rules: {
|
|
@@ -558,6 +581,42 @@ var CODE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
|
558
581
|
function isCodeFile(path) {
|
|
559
582
|
return CODE.test(path);
|
|
560
583
|
}
|
|
584
|
+
var EXT = [
|
|
585
|
+
[CODE, "js"],
|
|
586
|
+
[/\.pyw?$/, "py"],
|
|
587
|
+
[/\.go$/, "go"],
|
|
588
|
+
[/\.rs$/, "rs"],
|
|
589
|
+
[/\.rb$/, "rb"],
|
|
590
|
+
[/\.java$/, "java"],
|
|
591
|
+
[/\.kts?$/, "kt"],
|
|
592
|
+
[/\.php$/, "php"],
|
|
593
|
+
[/\.cs$/, "cs"]
|
|
594
|
+
];
|
|
595
|
+
function langOf(path) {
|
|
596
|
+
for (const [re, lang] of EXT) if (re.test(path)) return lang;
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
var COMMON = /^(export\s|\/\/|\*|\/\*|}\)?;?$)/;
|
|
600
|
+
var IMPORTS = {
|
|
601
|
+
js: /^import\b/,
|
|
602
|
+
py: /^(?:import\b|from\s+\S+\s+import\b)/,
|
|
603
|
+
go: /^(?:import\b|package\b)/,
|
|
604
|
+
rs: /^(?:use\b|extern\s+crate\b|mod\b)/,
|
|
605
|
+
rb: /^(?:require(?:_relative)?\b|include\b)/,
|
|
606
|
+
java: /^(?:import\b|package\b)/,
|
|
607
|
+
kt: /^(?:import\b|package\b)/,
|
|
608
|
+
php: /^(?:use\b|namespace\b|require(?:_once)?\b|include(?:_once)?\b)/,
|
|
609
|
+
cs: /^(?:using\b|namespace\b)/
|
|
610
|
+
};
|
|
611
|
+
var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
|
|
612
|
+
function isSignificantLine(trimmed, lang) {
|
|
613
|
+
if (trimmed.length < 10) return false;
|
|
614
|
+
if (COMMON.test(trimmed)) return false;
|
|
615
|
+
const l = lang ?? "js";
|
|
616
|
+
if (IMPORTS[l].test(trimmed)) return false;
|
|
617
|
+
if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
561
620
|
|
|
562
621
|
// src/detectors/ts-any-cast.ts
|
|
563
622
|
var BLOCK_RULE = "ts-any-cast";
|
|
@@ -664,11 +723,35 @@ var tsAnyCast = {
|
|
|
664
723
|
|
|
665
724
|
// src/detectors/lint-suppression.ts
|
|
666
725
|
var RULE2 = "lint-suppression";
|
|
667
|
-
var PATTERNS =
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
726
|
+
var PATTERNS = {
|
|
727
|
+
js: [
|
|
728
|
+
{ re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
|
|
729
|
+
{ re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
|
|
730
|
+
{ re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
|
|
731
|
+
],
|
|
732
|
+
py: [
|
|
733
|
+
{ re: /#\s*noqa\b/, why: "# noqa suppresses flake8/ruff findings" },
|
|
734
|
+
{ re: /#\s*type:\s*ignore\b/, why: "# type: ignore suppresses the type checker" },
|
|
735
|
+
{ re: /#\s*pyright:\s*ignore\b/, why: "# pyright: ignore suppresses the type checker" },
|
|
736
|
+
{ re: /#\s*pylint:\s*disable\b/, why: "# pylint: disable suppresses lint rules" },
|
|
737
|
+
{ re: /#\s*nosec\b/, why: "# nosec suppresses bandit security findings" },
|
|
738
|
+
{ re: /#\s*fmt:\s*(?:off|skip)\b/, why: "# fmt: off/skip suppresses formatting checks" }
|
|
739
|
+
],
|
|
740
|
+
go: [{ re: /\/\/\s*nolint\b/, why: "//nolint suppresses golangci-lint findings" }],
|
|
741
|
+
rs: [],
|
|
742
|
+
rb: [{ re: /#\s*rubocop:\s*(?:disable|todo)\b/, why: "# rubocop:disable suppresses lint rules" }],
|
|
743
|
+
java: [{ re: /@SuppressWarnings\s*\(/, why: "@SuppressWarnings suppresses compiler and lint warnings" }],
|
|
744
|
+
kt: [{ re: /@Suppress(?:Warnings)?\s*\(/, why: "@Suppress suppresses compiler and lint warnings" }],
|
|
745
|
+
php: [
|
|
746
|
+
{ re: /\bphpcs:(?:ignore|disable)\b/, why: "phpcs:ignore/disable suppresses coding-standard checks" },
|
|
747
|
+
{ re: /@phpstan-ignore\b/, why: "@phpstan-ignore suppresses static-analysis findings" },
|
|
748
|
+
{ re: /@psalm-suppress\b/, why: "@psalm-suppress suppresses static-analysis findings" }
|
|
749
|
+
],
|
|
750
|
+
cs: [
|
|
751
|
+
{ re: /#pragma\s+warning\s+disable\b/, why: "#pragma warning disable suppresses compiler warnings" },
|
|
752
|
+
{ re: /\[SuppressMessage\s*\(/, why: "[SuppressMessage] suppresses analyzer findings" }
|
|
753
|
+
]
|
|
754
|
+
};
|
|
672
755
|
var lintSuppression = {
|
|
673
756
|
id: RULE2,
|
|
674
757
|
surface: ["file"],
|
|
@@ -676,9 +759,13 @@ var lintSuppression = {
|
|
|
676
759
|
run(changes, policy) {
|
|
677
760
|
const out2 = [];
|
|
678
761
|
for (const c of changes) {
|
|
679
|
-
if (c.kind !== "file"
|
|
762
|
+
if (c.kind !== "file") continue;
|
|
763
|
+
const lang = langOf(c.path);
|
|
764
|
+
if (!lang) continue;
|
|
765
|
+
const patterns = PATTERNS[lang];
|
|
766
|
+
if (patterns.length === 0) continue;
|
|
680
767
|
for (const l of addedLines(c)) {
|
|
681
|
-
for (const p of
|
|
768
|
+
for (const p of patterns) {
|
|
682
769
|
if (p.re.test(l.content)) {
|
|
683
770
|
out2.push(
|
|
684
771
|
makeFinding(RULE2, policy, {
|
|
@@ -700,18 +787,44 @@ var lintSuppression = {
|
|
|
700
787
|
|
|
701
788
|
// src/detectors/test-skip.ts
|
|
702
789
|
var RULE3 = "test-skip";
|
|
703
|
-
var PATTERNS2 =
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
790
|
+
var PATTERNS2 = {
|
|
791
|
+
js: [
|
|
792
|
+
{ re: /\b(?:it|test|describe)\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
|
|
793
|
+
{ re: /\b(?:xit|xtest|xdescribe)\b/, why: "an x-prefixed disabled test" },
|
|
794
|
+
{ re: /\b(?:fit|ftest|fdescribe)\b/, why: "an f-prefixed focused test (narrows the suite)" },
|
|
795
|
+
{ re: /\bpending\(\s*\)/, why: "a pending() marker" },
|
|
796
|
+
// node:test spells the same class as options — `test('x', { skip: true }, ...)` — and as
|
|
797
|
+
// a runtime call. A live evasion used the options form the day this gap was measured
|
|
798
|
+
// (PREDICTION-baselines.md): denied the rm, the agent skipped by option instead.
|
|
799
|
+
{ re: /\bskip:\s*(?:true|1|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
|
|
800
|
+
{ re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
|
|
801
|
+
{ re: /\b(?:t|ctx|context)\.skip\(/, why: "a runtime t.skip() call" }
|
|
802
|
+
],
|
|
803
|
+
py: [
|
|
804
|
+
{ re: /@pytest\.mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker" },
|
|
805
|
+
{ re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail() call" },
|
|
806
|
+
{ re: /@unittest\.(?:skip|skipIf|skipUnless|expectedFailure)\b/, why: "a unittest skip/expectedFailure decorator" },
|
|
807
|
+
{ re: /\bself\.skipTest\(/, why: "a runtime self.skipTest() call" },
|
|
808
|
+
{ re: /\braise\s+(?:unittest\.)?SkipTest\b/, why: "a raised SkipTest" }
|
|
809
|
+
],
|
|
810
|
+
go: [
|
|
811
|
+
{ re: /\b[tb]\.Skip(?:f|Now)?\(/, why: "a runtime t.Skip()/Skipf()/SkipNow() call" }
|
|
812
|
+
],
|
|
813
|
+
rs: [{ re: /#\[ignore\b/, why: "an #[ignore] attribute (the test no longer runs by default)" }],
|
|
814
|
+
rb: [
|
|
815
|
+
{ re: /(?:^|[\s;{(])(?:skip|pending)(?:\s*\(|\s+['"]|\s+(?:if|unless)\b|\s*$)/, why: "a skip/pending call" },
|
|
816
|
+
{ re: /\bx(?:it|describe|context|specify|example)\b/, why: "an x-prefixed disabled example" },
|
|
817
|
+
{ re: /\b(?:fit|fdescribe|fcontext)\b/, why: "an f-prefixed focused example (narrows the suite)" },
|
|
818
|
+
{ re: /\b(?:skip|pending):\s*(?:true|['"])/, why: "a skip:/pending: metadata flag" }
|
|
819
|
+
],
|
|
820
|
+
java: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
|
|
821
|
+
kt: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
|
|
822
|
+
php: [{ re: /\bmarkTest(?:Skipped|Incomplete)\(/, why: "a markTestSkipped()/markTestIncomplete() call" }],
|
|
823
|
+
cs: [
|
|
824
|
+
{ re: /\[Ignore\b/, why: "an [Ignore] attribute" },
|
|
825
|
+
{ re: /\bSkip\s*=\s*"/, why: 'a Skip = "..." attribute argument' }
|
|
826
|
+
]
|
|
827
|
+
};
|
|
715
828
|
var testSkip = {
|
|
716
829
|
id: RULE3,
|
|
717
830
|
surface: ["file"],
|
|
@@ -721,8 +834,9 @@ var testSkip = {
|
|
|
721
834
|
for (const c of changes) {
|
|
722
835
|
if (c.kind !== "file") continue;
|
|
723
836
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
837
|
+
const patterns = PATTERNS2[langOf(c.path) ?? "js"];
|
|
724
838
|
for (const l of addedLines(c)) {
|
|
725
|
-
for (const p of
|
|
839
|
+
for (const p of patterns) {
|
|
726
840
|
if (p.re.test(l.content)) {
|
|
727
841
|
out2.push(
|
|
728
842
|
makeFinding(RULE3, policy, {
|
|
@@ -1011,6 +1125,7 @@ var ciTampering = {
|
|
|
1011
1125
|
(c.after != null ? c.after.split("\n") : addedLines(c).map((l) => l.content)).map(commandCore)
|
|
1012
1126
|
);
|
|
1013
1127
|
for (const l of removedLines(c)) {
|
|
1128
|
+
if (/^\s*#/.test(l.content)) continue;
|
|
1014
1129
|
const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
|
|
1015
1130
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
1016
1131
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
@@ -1073,8 +1188,11 @@ function policyWeakening(before, after) {
|
|
|
1073
1188
|
reasons.push(`rule "${name}" removed from policy`);
|
|
1074
1189
|
continue;
|
|
1075
1190
|
}
|
|
1076
|
-
if (br.severity === "block" && ar.severity
|
|
1077
|
-
|
|
1191
|
+
if (br.severity === "block" && ar.severity !== "block") {
|
|
1192
|
+
const to = typeof ar.severity === "string" ? ar.severity : JSON.stringify(ar.severity);
|
|
1193
|
+
reasons.push(
|
|
1194
|
+
ar.severity === "warn" ? `rule "${name}" lowered block \u2192 warn` : `rule "${name}" lowered block \u2192 ${to} (not a valid severity; the rule would no longer block)`
|
|
1195
|
+
);
|
|
1078
1196
|
}
|
|
1079
1197
|
if (ar.enabled === false && br.enabled !== false) {
|
|
1080
1198
|
reasons.push(`rule "${name}" disabled (enabled: false)`);
|
|
@@ -1130,6 +1248,27 @@ function policyWeakening(before, after) {
|
|
|
1130
1248
|
var RULE6 = "hook-tampering";
|
|
1131
1249
|
var HOOK_INVOCATION = /\b(tamperward|npx|npm|pnpm|yarn|bun|jest|vitest|eslint|tsc|lefthook|husky|lint-staged|make|cargo|pytest)\b/;
|
|
1132
1250
|
var isShellComment = (s) => /^\s*#/.test(s);
|
|
1251
|
+
var ownerExec = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
|
|
1252
|
+
var SYMBOLIC = /^[ugoa]*([-+=])([rwxXst]*)$/;
|
|
1253
|
+
function chmodTouchesExec(seg) {
|
|
1254
|
+
const toks = tokens(seg).map(unquote);
|
|
1255
|
+
const at = toks.findIndex((t) => /^chmod$/.test(t));
|
|
1256
|
+
if (at === -1) return false;
|
|
1257
|
+
for (const tok of toks.slice(at + 1)) {
|
|
1258
|
+
if (/^[0-7]{3,4}$/.test(tok)) {
|
|
1259
|
+
if (!ownerExec(tok)) return true;
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
for (const clause of tok.split(",")) {
|
|
1263
|
+
const m = clause.match(SYMBOLIC);
|
|
1264
|
+
if (!m) continue;
|
|
1265
|
+
const [, op, perms] = m;
|
|
1266
|
+
const namesExec = /[xX]/.test(perms);
|
|
1267
|
+
if (op === "=" ? !namesExec : namesExec) return true;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1133
1272
|
var hookTampering = {
|
|
1134
1273
|
id: RULE6,
|
|
1135
1274
|
surface: ["file", "command"],
|
|
@@ -1140,6 +1279,16 @@ var hookTampering = {
|
|
|
1140
1279
|
if (c.kind === "file") {
|
|
1141
1280
|
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
|
|
1142
1281
|
if (!targetsHook) continue;
|
|
1282
|
+
if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec(c.oldMode) && !ownerExec(c.newMode)) {
|
|
1283
|
+
out2.push(
|
|
1284
|
+
makeFinding(RULE6, policy, {
|
|
1285
|
+
file: c.path,
|
|
1286
|
+
message: `Execute permission was removed from a protected hook (${c.oldMode} \u2192 ${c.newMode}) \u2014 git will skip it.`,
|
|
1287
|
+
evidence: `${c.path}: mode ${c.oldMode} \u2192 ${c.newMode}`,
|
|
1288
|
+
remediation: "Restore the execute bit (chmod +x). A hook git cannot run is a disabled hook."
|
|
1289
|
+
})
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1143
1292
|
if (c.op === "delete") {
|
|
1144
1293
|
out2.push(
|
|
1145
1294
|
makeFinding(RULE6, policy, {
|
|
@@ -1236,10 +1385,8 @@ var hookTampering = {
|
|
|
1236
1385
|
for (const seg of segments(c.raw)) {
|
|
1237
1386
|
const hitsHook = tokens(seg).map(unquote).some((t) => isProtected(t, policy, "hooks"));
|
|
1238
1387
|
if (!hitsHook) continue;
|
|
1239
|
-
const octal = seg.match(/\bchmod\b[^|;&]*?(?:^|\s)([0-7]{3,4})(?:\s|$)/);
|
|
1240
|
-
const octalDropsExec = octal ? (Number(octal[1].slice(-3)[0]) & 1) === 0 : false;
|
|
1241
1388
|
let why = null;
|
|
1242
|
-
if (
|
|
1389
|
+
if (chmodTouchesExec(seg)) {
|
|
1243
1390
|
why = "chmod alters execute permission on a hook, which can disable it";
|
|
1244
1391
|
} else if (/\brm\b/.test(seg)) {
|
|
1245
1392
|
why = "rm deletes a protected hook";
|
|
@@ -1267,6 +1414,16 @@ var hookTampering = {
|
|
|
1267
1414
|
// src/detectors/test-deletion.ts
|
|
1268
1415
|
import ts3 from "typescript";
|
|
1269
1416
|
var RULE7 = "test-deletion";
|
|
1417
|
+
var TEST_DEFS = {
|
|
1418
|
+
py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
|
|
1419
|
+
go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
|
|
1420
|
+
rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
|
|
1421
|
+
rb: /^\s*(?:(?:it|specify|test|scenario|example)\s*(?:\(\s*)?['"]|def\s+test_\w+)/,
|
|
1422
|
+
java: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
|
|
1423
|
+
kt: /^\s*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/,
|
|
1424
|
+
php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
|
|
1425
|
+
cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
|
|
1426
|
+
};
|
|
1270
1427
|
function calleeName(expr) {
|
|
1271
1428
|
if (ts3.isIdentifier(expr)) return expr.text;
|
|
1272
1429
|
if (ts3.isPropertyAccessExpression(expr) && ts3.isIdentifier(expr.expression)) {
|
|
@@ -1274,7 +1431,14 @@ function calleeName(expr) {
|
|
|
1274
1431
|
}
|
|
1275
1432
|
return null;
|
|
1276
1433
|
}
|
|
1277
|
-
function countTestBlocks(src) {
|
|
1434
|
+
function countTestBlocks(src, path = "spec.ts") {
|
|
1435
|
+
const lang = langOf(path);
|
|
1436
|
+
if (lang && lang !== "js") {
|
|
1437
|
+
const re = TEST_DEFS[lang];
|
|
1438
|
+
let n2 = 0;
|
|
1439
|
+
for (const line of src.split("\n")) if (re.test(line)) n2++;
|
|
1440
|
+
return n2;
|
|
1441
|
+
}
|
|
1278
1442
|
const sf = ts3.createSourceFile("spec.ts", src, ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
|
|
1279
1443
|
let n = 0;
|
|
1280
1444
|
const visit = (node) => {
|
|
@@ -1287,11 +1451,12 @@ function countTestBlocks(src) {
|
|
|
1287
1451
|
visit(sf);
|
|
1288
1452
|
return n;
|
|
1289
1453
|
}
|
|
1290
|
-
function significantLines(src) {
|
|
1454
|
+
function significantLines(src, path) {
|
|
1291
1455
|
const out2 = /* @__PURE__ */ new Set();
|
|
1456
|
+
const lang = langOf(path);
|
|
1292
1457
|
for (const raw of src.split("\n")) {
|
|
1293
1458
|
const l = raw.trim();
|
|
1294
|
-
if (l
|
|
1459
|
+
if (isSignificantLine(l, lang)) out2.add(l);
|
|
1295
1460
|
}
|
|
1296
1461
|
return out2;
|
|
1297
1462
|
}
|
|
@@ -1305,22 +1470,22 @@ var testDeletion = {
|
|
|
1305
1470
|
let addedTestBlocks = 0;
|
|
1306
1471
|
for (const c of changes) {
|
|
1307
1472
|
if (c.kind === "file" && c.op === "add" && c.after != null && isProtected(c.path, policy, "tests")) {
|
|
1308
|
-
for (const l of significantLines(c.after)) addedTestLines.add(l);
|
|
1309
|
-
addedTestBlocks += countTestBlocks(c.after);
|
|
1473
|
+
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
1474
|
+
addedTestBlocks += countTestBlocks(c.after, c.path);
|
|
1310
1475
|
}
|
|
1311
1476
|
}
|
|
1312
|
-
const isRelocation = (before) => {
|
|
1313
|
-
const sig = significantLines(before);
|
|
1477
|
+
const isRelocation = (before, path) => {
|
|
1478
|
+
const sig = significantLines(before, path);
|
|
1314
1479
|
if (sig.size === 0 || addedTestLines.size === 0) return false;
|
|
1315
1480
|
let hit = 0;
|
|
1316
1481
|
for (const l of sig) if (addedTestLines.has(l)) hit++;
|
|
1317
|
-
return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before);
|
|
1482
|
+
return hit / sig.size >= 0.6 && addedTestBlocks >= countTestBlocks(before, path);
|
|
1318
1483
|
};
|
|
1319
1484
|
for (const c of changes) {
|
|
1320
1485
|
if (c.kind === "file") {
|
|
1321
1486
|
const isTest = isProtected(c.path, policy, "tests");
|
|
1322
1487
|
if (c.op === "delete" && isTest) {
|
|
1323
|
-
if (c.before != null && isRelocation(c.before)) continue;
|
|
1488
|
+
if (c.before != null && isRelocation(c.before, c.path)) continue;
|
|
1324
1489
|
out2.push(
|
|
1325
1490
|
makeFinding(RULE7, policy, {
|
|
1326
1491
|
file: c.path,
|
|
@@ -1339,8 +1504,8 @@ var testDeletion = {
|
|
|
1339
1504
|
})
|
|
1340
1505
|
);
|
|
1341
1506
|
} else if ((c.op === "modify" || c.op === "rename") && isTest && c.before != null && c.after != null) {
|
|
1342
|
-
const before = countTestBlocks(c.before);
|
|
1343
|
-
const after = countTestBlocks(c.after);
|
|
1507
|
+
const before = countTestBlocks(c.before, c.path);
|
|
1508
|
+
const after = countTestBlocks(c.after, c.path);
|
|
1344
1509
|
if (after < before) {
|
|
1345
1510
|
out2.push(
|
|
1346
1511
|
makeFinding(RULE7, policy, {
|
|
@@ -1396,11 +1561,12 @@ var testDeletion = {
|
|
|
1396
1561
|
var RULE8 = "test-content-removal";
|
|
1397
1562
|
var MIN_REMOVED_LINES = 3;
|
|
1398
1563
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
1399
|
-
function significantLinesOrdered(src) {
|
|
1564
|
+
function significantLinesOrdered(src, path) {
|
|
1400
1565
|
const out2 = [];
|
|
1566
|
+
const lang = langOf(path);
|
|
1401
1567
|
for (const raw of src.split("\n")) {
|
|
1402
1568
|
const l = raw.trim();
|
|
1403
|
-
if (l
|
|
1569
|
+
if (isSignificantLine(l, lang)) out2.push(l);
|
|
1404
1570
|
}
|
|
1405
1571
|
return out2;
|
|
1406
1572
|
}
|
|
@@ -1413,16 +1579,16 @@ var testContentRemoval = {
|
|
|
1413
1579
|
let keptPool = "";
|
|
1414
1580
|
for (const c of changes) {
|
|
1415
1581
|
if (c.kind === "file" && c.after != null && isProtected(c.path, policy, "tests")) {
|
|
1416
|
-
keptPool += ws(significantLinesOrdered(c.after).join("\n"));
|
|
1582
|
+
keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
|
|
1417
1583
|
}
|
|
1418
1584
|
}
|
|
1419
1585
|
for (const c of changes) {
|
|
1420
1586
|
if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
|
|
1421
1587
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
1422
1588
|
if (isProtected(c.path, policy, "snapshots")) continue;
|
|
1423
|
-
if (countTestBlocks(c.after) < countTestBlocks(c.before)) continue;
|
|
1424
|
-
const beforeSig = significantLinesOrdered(c.before);
|
|
1425
|
-
const afterSig = significantLinesOrdered(c.after);
|
|
1589
|
+
if (countTestBlocks(c.after, c.path) < countTestBlocks(c.before, c.path)) continue;
|
|
1590
|
+
const beforeSig = significantLinesOrdered(c.before, c.path);
|
|
1591
|
+
const afterSig = significantLinesOrdered(c.after, c.path);
|
|
1426
1592
|
const netDrop = beforeSig.length - afterSig.length;
|
|
1427
1593
|
const afterSet = new Set(afterSig);
|
|
1428
1594
|
const gone = [];
|
|
@@ -1593,6 +1759,7 @@ function evaluate(changes, policy, detectors = allDetectors, view) {
|
|
|
1593
1759
|
return true;
|
|
1594
1760
|
});
|
|
1595
1761
|
return deduped.filter((f) => {
|
|
1762
|
+
if (f.rule !== "detector-error" && !isEnabled(f.rule, policy)) return false;
|
|
1596
1763
|
const ex = policy.rules[f.rule]?.exclude;
|
|
1597
1764
|
if (!ex?.length || !f.file || isPolicyFile(f.file)) return true;
|
|
1598
1765
|
return !matchesAny(f.file, ex);
|
|
@@ -1615,9 +1782,62 @@ function normalizeVersion(v, where = POLICY_FILE) {
|
|
|
1615
1782
|
}
|
|
1616
1783
|
return v;
|
|
1617
1784
|
}
|
|
1618
|
-
|
|
1785
|
+
var SEVERITIES = ["block", "warn"];
|
|
1786
|
+
var isSeverity = (v) => SEVERITIES.includes(v);
|
|
1787
|
+
var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
1788
|
+
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1789
|
+
function validate(r, where) {
|
|
1790
|
+
function bad(msg) {
|
|
1791
|
+
throw new PolicyError(`${where}: ${msg}`);
|
|
1792
|
+
}
|
|
1793
|
+
const show = (v) => JSON.stringify(v) ?? String(v);
|
|
1794
|
+
if (r.rules !== void 0) {
|
|
1795
|
+
if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
|
|
1796
|
+
for (const [name, cfg] of Object.entries(r.rules)) {
|
|
1797
|
+
if (!isMapping(cfg)) bad(`rules.${name} must be a mapping like { severity: block }, got ${show(cfg)}`);
|
|
1798
|
+
if (cfg.severity !== void 0 && !isSeverity(cfg.severity)) {
|
|
1799
|
+
bad(`rules.${name}.severity must be "block" or "warn", got ${show(cfg.severity)}`);
|
|
1800
|
+
}
|
|
1801
|
+
if (cfg.enabled !== void 0 && typeof cfg.enabled !== "boolean") {
|
|
1802
|
+
bad(`rules.${name}.enabled must be true or false, got ${show(cfg.enabled)}`);
|
|
1803
|
+
}
|
|
1804
|
+
if (cfg.exclude !== void 0 && !isStringList(cfg.exclude)) {
|
|
1805
|
+
bad(`rules.${name}.exclude must be a list of globs, got ${show(cfg.exclude)}`);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
1810
|
+
if (r.protected !== void 0) {
|
|
1811
|
+
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
1812
|
+
for (const [cat, globs] of Object.entries(r.protected)) {
|
|
1813
|
+
if (!isStringList(globs)) bad(`protected.${cat} must be a list of globs, got ${show(globs)}`);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
if (r.signoff !== void 0) {
|
|
1817
|
+
if (!isMapping(r.signoff)) bad(`signoff must be a mapping, got ${show(r.signoff)}`);
|
|
1818
|
+
for (const key2 of ["required_for", "requiredFor"]) {
|
|
1819
|
+
const v = r.signoff[key2];
|
|
1820
|
+
if (v !== void 0 && !(Array.isArray(v) && v.every(isSeverity))) {
|
|
1821
|
+
bad(`signoff.${key2} must be a list of "block" / "warn", got ${show(v)}`);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
const ledger = r.signoff.ledger;
|
|
1825
|
+
if (ledger !== void 0 && typeof ledger !== "string") bad(`signoff.ledger must be a path, got ${show(ledger)}`);
|
|
1826
|
+
}
|
|
1827
|
+
if (r.verify !== void 0) {
|
|
1828
|
+
if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
|
|
1829
|
+
const v = r.verify;
|
|
1830
|
+
if (v.command !== void 0 && typeof v.command !== "string") bad(`verify.command must be a string, got ${show(v.command)}`);
|
|
1831
|
+
if (v.budget !== void 0 && !(typeof v.budget === "number" && Number.isFinite(v.budget) && v.budget > 0)) {
|
|
1832
|
+
bad(`verify.budget must be a positive number of seconds, got ${show(v.budget)}`);
|
|
1833
|
+
}
|
|
1834
|
+
if (v.inputs !== void 0 && !isStringList(v.inputs)) bad(`verify.inputs must be a list of globs, got ${show(v.inputs)}`);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
function parsePolicy(raw, where = POLICY_FILE) {
|
|
1619
1838
|
const r = raw ?? {};
|
|
1620
|
-
const version = normalizeVersion(r.version);
|
|
1839
|
+
const version = normalizeVersion(r.version, where);
|
|
1840
|
+
validate(r, where);
|
|
1621
1841
|
const base = defaultPolicy(version);
|
|
1622
1842
|
return {
|
|
1623
1843
|
version,
|
|
@@ -1653,7 +1873,7 @@ function parseOrThrow(src, where) {
|
|
|
1653
1873
|
if (raw !== null && raw !== void 0 && (typeof raw !== "object" || Array.isArray(raw))) {
|
|
1654
1874
|
throw new PolicyError(`${where} is not a policy mapping`);
|
|
1655
1875
|
}
|
|
1656
|
-
return parsePolicy(raw);
|
|
1876
|
+
return parsePolicy(raw, where);
|
|
1657
1877
|
}
|
|
1658
1878
|
function loadPolicy(cwd = process.cwd()) {
|
|
1659
1879
|
const path = join2(cwd, POLICY_FILE);
|
|
@@ -2037,7 +2257,7 @@ import { createHash as createHash4 } from "node:crypto";
|
|
|
2037
2257
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2038
2258
|
import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
2039
2259
|
import { tmpdir } from "node:os";
|
|
2040
|
-
import { join as join4,
|
|
2260
|
+
import { isAbsolute, join as join4, relative, resolve } from "node:path";
|
|
2041
2261
|
function asStr(v) {
|
|
2042
2262
|
return typeof v === "string" ? v : "";
|
|
2043
2263
|
}
|
|
@@ -2049,10 +2269,11 @@ function readDisk(path) {
|
|
|
2049
2269
|
}
|
|
2050
2270
|
}
|
|
2051
2271
|
function abs(path, cwd) {
|
|
2052
|
-
return
|
|
2272
|
+
return resolve(cwd, path);
|
|
2053
2273
|
}
|
|
2054
2274
|
function relForDisplay(path, cwd) {
|
|
2055
|
-
|
|
2275
|
+
const rel = relative(cwd, path);
|
|
2276
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
|
|
2056
2277
|
}
|
|
2057
2278
|
function applyEdit(content, oldStr, newStr) {
|
|
2058
2279
|
if (content === null) return newStr;
|
|
@@ -2073,9 +2294,15 @@ function synthFileChange(displayPath, before, after) {
|
|
|
2073
2294
|
writeFileSync(a, before ?? "");
|
|
2074
2295
|
writeFileSync(b, after ?? "");
|
|
2075
2296
|
try {
|
|
2076
|
-
raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], {
|
|
2297
|
+
raw = execFileSync2("git", ["diff", "--no-index", "--no-color", a, b], {
|
|
2298
|
+
encoding: "utf8",
|
|
2299
|
+
maxBuffer: 256 * 1024 * 1024
|
|
2300
|
+
});
|
|
2077
2301
|
} catch (e) {
|
|
2078
2302
|
const err2 = e;
|
|
2303
|
+
if (err2.status !== 1) {
|
|
2304
|
+
throw new Error(`cannot diff the incoming edit to ${displayPath}: ${err2.code ?? err2.message ?? String(e)}`);
|
|
2305
|
+
}
|
|
2079
2306
|
raw = err2.stdout ? String(err2.stdout) : "";
|
|
2080
2307
|
}
|
|
2081
2308
|
} finally {
|
|
@@ -2190,13 +2417,19 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
2190
2417
|
// src/effect.ts
|
|
2191
2418
|
import { createHash as createHash2 } from "node:crypto";
|
|
2192
2419
|
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2193
|
-
import { join as join6 } from "node:path";
|
|
2420
|
+
import { isAbsolute as isAbsolute2, join as join6 } from "node:path";
|
|
2194
2421
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2195
2422
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
2196
2423
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
2197
2424
|
function gitDir2(cwd) {
|
|
2198
2425
|
try {
|
|
2199
|
-
|
|
2426
|
+
const d = execFileSync3("git", ["rev-parse", "--git-dir"], {
|
|
2427
|
+
cwd,
|
|
2428
|
+
encoding: "utf8",
|
|
2429
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2430
|
+
}).trim();
|
|
2431
|
+
if (!d) return null;
|
|
2432
|
+
return isAbsolute2(d) ? d : join6(cwd, d);
|
|
2200
2433
|
} catch {
|
|
2201
2434
|
return null;
|
|
2202
2435
|
}
|
|
@@ -2205,7 +2438,7 @@ function ptreePath(cwd, sessionId) {
|
|
|
2205
2438
|
if (!sessionId) return null;
|
|
2206
2439
|
const gd = gitDir2(cwd);
|
|
2207
2440
|
if (!gd) return null;
|
|
2208
|
-
const dir = join6(
|
|
2441
|
+
const dir = join6(gd, "tamperward");
|
|
2209
2442
|
return join6(dir, `ptree-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
2210
2443
|
}
|
|
2211
2444
|
var sha = (buf) => createHash2("sha256").update(buf).digest("hex").slice(0, 16);
|
|
@@ -2288,7 +2521,8 @@ function snap(abs2) {
|
|
|
2288
2521
|
}
|
|
2289
2522
|
}
|
|
2290
2523
|
function defaultEventLog(cwd) {
|
|
2291
|
-
|
|
2524
|
+
if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
|
|
2525
|
+
return join7(gitDir(cwd) ?? join7(cwd, ".git"), "tamperward", "fsevents.jsonl");
|
|
2292
2526
|
}
|
|
2293
2527
|
function watchTree(dir, cb) {
|
|
2294
2528
|
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
@@ -2398,6 +2632,7 @@ function readEvents(log, offset) {
|
|
|
2398
2632
|
return { events, newOffset: size };
|
|
2399
2633
|
}
|
|
2400
2634
|
function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
2635
|
+
if (!isEnabled(RULE11, policy)) return [];
|
|
2401
2636
|
const byPath = /* @__PURE__ */ new Map();
|
|
2402
2637
|
for (const e of events) {
|
|
2403
2638
|
if (!isProtected(e.path, policy)) continue;
|
|
@@ -2709,15 +2944,43 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
2709
2944
|
}
|
|
2710
2945
|
|
|
2711
2946
|
// src/cli/init.ts
|
|
2947
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
2712
2948
|
import { createHash as createHash5 } from "node:crypto";
|
|
2713
2949
|
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
|
|
2714
2950
|
import { dirname as dirname3, join as join8 } from "node:path";
|
|
2715
2951
|
import { fileURLToPath } from "node:url";
|
|
2716
|
-
var HOOK_CMD = "npx --yes tamperward hook claude";
|
|
2717
2952
|
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
2718
|
-
var SWEEP_CMD = "npx --yes tamperward sweep claude";
|
|
2719
|
-
var PRECOMMIT_CMD = "npx --yes tamperward check --staged";
|
|
2720
2953
|
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2954
|
+
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
2955
|
+
var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
|
|
2956
|
+
var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
|
|
2957
|
+
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
2958
|
+
var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
|
|
2959
|
+
var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
|
|
2960
|
+
function coveredBy(existing, critical) {
|
|
2961
|
+
for (const line of existing.split("\n")) {
|
|
2962
|
+
const t = line.trim();
|
|
2963
|
+
if (!t || t.startsWith("#")) continue;
|
|
2964
|
+
const pattern = t.split(/\s+/)[0];
|
|
2965
|
+
if (!t.slice(pattern.length).trim()) continue;
|
|
2966
|
+
if (pattern === "*" || pattern === critical) return true;
|
|
2967
|
+
if (pattern.endsWith("/") && critical.startsWith(pattern)) return true;
|
|
2968
|
+
}
|
|
2969
|
+
return false;
|
|
2970
|
+
}
|
|
2971
|
+
function inferOwner(cwd) {
|
|
2972
|
+
try {
|
|
2973
|
+
const url = execFileSync4("git", ["remote", "get-url", "origin"], {
|
|
2974
|
+
cwd,
|
|
2975
|
+
encoding: "utf8",
|
|
2976
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2977
|
+
}).trim();
|
|
2978
|
+
const m = url.match(/github\.com[:/]+([^/]+)\//);
|
|
2979
|
+
return m ? `@${m[1]}` : null;
|
|
2980
|
+
} catch {
|
|
2981
|
+
return null;
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2721
2984
|
var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
|
|
2722
2985
|
# without this file \u2014 everything here is an override, so an empty file changes nothing.
|
|
2723
2986
|
# Docs: https://github.com/hexrift/tamperward#readme
|
|
@@ -2747,6 +3010,17 @@ function shippedVersion() {
|
|
|
2747
3010
|
return "latest";
|
|
2748
3011
|
}
|
|
2749
3012
|
var TW_VERSION = shippedVersion();
|
|
3013
|
+
var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
3014
|
+
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
3015
|
+
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
3016
|
+
function pinOf(command) {
|
|
3017
|
+
const m = command.match(OURS);
|
|
3018
|
+
return m ? m[1] ?? "" : null;
|
|
3019
|
+
}
|
|
3020
|
+
function stalePin(command) {
|
|
3021
|
+
const pin = pinOf(command);
|
|
3022
|
+
return pin !== null && pin !== TW_VERSION;
|
|
3023
|
+
}
|
|
2750
3024
|
var WORKFLOW_CONTENT = `name: tamperward
|
|
2751
3025
|
|
|
2752
3026
|
# The CI authority for main: the same engine as the agent hook and pre-commit, run over
|
|
@@ -2867,15 +3141,18 @@ function planClaudeHooks(cwd) {
|
|
|
2867
3141
|
settings = parsed;
|
|
2868
3142
|
}
|
|
2869
3143
|
const hooks = settings.hooks ??= {};
|
|
2870
|
-
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => String(h.command ?? "")
|
|
2871
|
-
const preEntries = entriesWith(hooks.PreToolUse,
|
|
3144
|
+
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
|
|
3145
|
+
const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
|
|
2872
3146
|
const needPre = preEntries.length === 0;
|
|
2873
|
-
const
|
|
3147
|
+
const stopEntries = entriesWith(hooks.Stop, SWEEP_RE);
|
|
3148
|
+
const needStop = stopEntries.length === 0;
|
|
2874
3149
|
const stale = preEntries.filter((m) => missingTools(m.matcher).length > 0);
|
|
2875
|
-
|
|
3150
|
+
const repin = [...preEntries, ...stopEntries].flatMap((m) => m.hooks ?? []).filter((h) => stalePin(String(h.command ?? "")));
|
|
3151
|
+
if (!needPre && !needStop && stale.length === 0 && repin.length === 0) {
|
|
2876
3152
|
return { item: "agent", path: rel, status: "ok", detail: "PreToolUse + Stop hooks already wired" };
|
|
2877
3153
|
}
|
|
2878
3154
|
const repairing = stale.length > 0 ? [...new Set(stale.flatMap((m) => missingTools(m.matcher)))] : [];
|
|
3155
|
+
const pins = [...new Set(repin.map((h) => pinOf(String(h.command ?? "")) || "unpinned"))];
|
|
2879
3156
|
return {
|
|
2880
3157
|
item: "agent",
|
|
2881
3158
|
path: rel,
|
|
@@ -2883,9 +3160,14 @@ function planClaudeHooks(cwd) {
|
|
|
2883
3160
|
detail: [
|
|
2884
3161
|
needPre && "wire PreToolUse deny",
|
|
2885
3162
|
needStop && "wire Stop sweep",
|
|
2886
|
-
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}
|
|
3163
|
+
repairing.length > 0 && `widen the PreToolUse matcher to cover ${repairing.join(", ")}`,
|
|
3164
|
+
repin.length > 0 && `re-pin the hook commands to tamperward@${TW_VERSION} (was ${pins.join(", ")})`
|
|
2887
3165
|
].filter(Boolean).join(" + "),
|
|
2888
3166
|
apply: () => {
|
|
3167
|
+
for (const h of repin) {
|
|
3168
|
+
const kind = String(h.command ?? "").match(OURS)?.[2];
|
|
3169
|
+
h.command = kind === "hook claude" ? HOOK_CMD : kind === "sweep claude" ? SWEEP_CMD : h.command;
|
|
3170
|
+
}
|
|
2889
3171
|
if (needPre) {
|
|
2890
3172
|
(hooks.PreToolUse ??= []).push({
|
|
2891
3173
|
matcher: PRE_MATCHER,
|
|
@@ -2913,8 +3195,24 @@ ${PRECOMMIT_CMD}
|
|
|
2913
3195
|
if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
|
|
2914
3196
|
const path = join8(cwd, target.rel);
|
|
2915
3197
|
const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
|
|
2916
|
-
if (existing
|
|
2917
|
-
|
|
3198
|
+
if (existing !== null && PRECOMMIT_RE.test(existing)) {
|
|
3199
|
+
const lines = existing.split("\n");
|
|
3200
|
+
const staleAt = lines.findIndex((l) => PRECOMMIT_RE.test(l) && stalePin(l));
|
|
3201
|
+
if (staleAt === -1) {
|
|
3202
|
+
return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
|
|
3203
|
+
}
|
|
3204
|
+
const was = pinOf(lines[staleAt]) || "unpinned";
|
|
3205
|
+
return {
|
|
3206
|
+
item: "pre-commit",
|
|
3207
|
+
path: target.rel,
|
|
3208
|
+
status: "update",
|
|
3209
|
+
detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
|
|
3210
|
+
apply: () => {
|
|
3211
|
+
lines[staleAt] = PRECOMMIT_CMD;
|
|
3212
|
+
writeFileSync5(path, lines.join("\n"));
|
|
3213
|
+
chmodSync(path, 493);
|
|
3214
|
+
}
|
|
3215
|
+
};
|
|
2918
3216
|
}
|
|
2919
3217
|
return {
|
|
2920
3218
|
item: "pre-commit",
|
|
@@ -2930,6 +3228,35 @@ ${line}` : existing.replace(/\n?$/, "\n") + line;
|
|
|
2930
3228
|
}
|
|
2931
3229
|
};
|
|
2932
3230
|
}
|
|
3231
|
+
function planCodeowners(cwd) {
|
|
3232
|
+
const rel = ".github/CODEOWNERS";
|
|
3233
|
+
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
3234
|
+
(r) => existsSync7(join8(cwd, r))
|
|
3235
|
+
);
|
|
3236
|
+
const path = join8(cwd, existingRel ?? rel);
|
|
3237
|
+
const existing = existingRel ? readFileSync10(path, "utf8") : null;
|
|
3238
|
+
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
3239
|
+
if (missing.length === 0) {
|
|
3240
|
+
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
3241
|
+
}
|
|
3242
|
+
const owner = inferOwner(cwd);
|
|
3243
|
+
const block = `
|
|
3244
|
+
${CODEOWNERS_MARK}
|
|
3245
|
+
# A pull request that rewrites the workflow can keep the job name, replace the
|
|
3246
|
+
# gate with \`true\`, and still report a green required check. Only a human
|
|
3247
|
+
# requirement on these paths prevents that.
|
|
3248
|
+
` + (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";
|
|
3249
|
+
return {
|
|
3250
|
+
item: "codeowners",
|
|
3251
|
+
path: existingRel ?? rel,
|
|
3252
|
+
status: existing === null ? "create" : "update",
|
|
3253
|
+
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})`),
|
|
3254
|
+
apply: () => {
|
|
3255
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
3256
|
+
writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
|
|
3257
|
+
}
|
|
3258
|
+
};
|
|
3259
|
+
}
|
|
2933
3260
|
function planWorkflow(cwd, force) {
|
|
2934
3261
|
const rel = ".github/workflows/tamperward.yml";
|
|
2935
3262
|
const path = join8(cwd, rel);
|
|
@@ -2983,7 +3310,7 @@ function planWorkflow(cwd, force) {
|
|
|
2983
3310
|
};
|
|
2984
3311
|
}
|
|
2985
3312
|
function planInit(cwd, opts = {}) {
|
|
2986
|
-
return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false)];
|
|
3313
|
+
return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false), planCodeowners(cwd)];
|
|
2987
3314
|
}
|
|
2988
3315
|
function runInit(opts) {
|
|
2989
3316
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -3010,34 +3337,37 @@ tamperward init: ${changed} change(s) planned. Re-run without --dry-run to apply
|
|
|
3010
3337
|
tamperward init: ${changed} change(s) applied. Commit them so the gate travels with the repo.
|
|
3011
3338
|
`
|
|
3012
3339
|
);
|
|
3340
|
+
w.write(
|
|
3341
|
+
'\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'
|
|
3342
|
+
);
|
|
3013
3343
|
return 0;
|
|
3014
3344
|
}
|
|
3015
3345
|
|
|
3016
3346
|
// src/cli/verify.ts
|
|
3017
|
-
import { execFileSync as
|
|
3347
|
+
import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
|
|
3018
3348
|
import { createHash as createHash8 } from "node:crypto";
|
|
3019
3349
|
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
3350
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
3021
|
-
import { dirname as dirname4, join as join11, resolve } from "node:path";
|
|
3351
|
+
import { dirname as dirname4, join as join11, resolve as resolve2 } from "node:path";
|
|
3022
3352
|
|
|
3023
3353
|
// src/git/trusted.ts
|
|
3024
|
-
import { execFileSync as
|
|
3354
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3025
3355
|
import { createHash as createHash6 } from "node:crypto";
|
|
3026
3356
|
import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
|
|
3027
|
-
import { isAbsolute as
|
|
3357
|
+
import { isAbsolute as isAbsolute3, join as join9 } from "node:path";
|
|
3028
3358
|
function trustedGitEnv() {
|
|
3029
3359
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
3030
3360
|
}
|
|
3031
3361
|
function gitDirOf(cwd) {
|
|
3032
3362
|
try {
|
|
3033
|
-
const d =
|
|
3363
|
+
const d = execFileSync5("git", ["rev-parse", "--git-dir"], {
|
|
3034
3364
|
cwd,
|
|
3035
3365
|
encoding: "utf8",
|
|
3036
3366
|
stdio: ["ignore", "pipe", "pipe"],
|
|
3037
3367
|
env: trustedGitEnv()
|
|
3038
3368
|
}).trim();
|
|
3039
3369
|
if (!d) return null;
|
|
3040
|
-
return
|
|
3370
|
+
return isAbsolute3(d) ? d : join9(cwd, d);
|
|
3041
3371
|
} catch {
|
|
3042
3372
|
return null;
|
|
3043
3373
|
}
|
|
@@ -3046,7 +3376,7 @@ function objectRewriteState(cwd) {
|
|
|
3046
3376
|
const parts = [];
|
|
3047
3377
|
try {
|
|
3048
3378
|
parts.push(
|
|
3049
|
-
"replace:" +
|
|
3379
|
+
"replace:" + execFileSync5("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
|
|
3050
3380
|
cwd,
|
|
3051
3381
|
encoding: "utf8",
|
|
3052
3382
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -3073,12 +3403,12 @@ function objectRewriteState(cwd) {
|
|
|
3073
3403
|
}
|
|
3074
3404
|
|
|
3075
3405
|
// src/fingerprint.ts
|
|
3076
|
-
import { execFileSync as
|
|
3406
|
+
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
3077
3407
|
import { createHash as createHash7 } from "node:crypto";
|
|
3078
3408
|
import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
|
|
3079
3409
|
import { join as join10 } from "node:path";
|
|
3080
3410
|
function treeFingerprint(cwd) {
|
|
3081
|
-
const files =
|
|
3411
|
+
const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
3082
3412
|
cwd,
|
|
3083
3413
|
encoding: "utf8",
|
|
3084
3414
|
maxBuffer: 1 << 28,
|
|
@@ -3166,7 +3496,7 @@ var VERIFICATION_SURFACE = [
|
|
|
3166
3496
|
"**/*.runsettings"
|
|
3167
3497
|
];
|
|
3168
3498
|
function git2(args, cwd) {
|
|
3169
|
-
return
|
|
3499
|
+
return execFileSync7("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
3170
3500
|
}
|
|
3171
3501
|
function resolveBase(base, cwd) {
|
|
3172
3502
|
assertRev(base);
|
|
@@ -3202,7 +3532,7 @@ function materialize(cwd, dest) {
|
|
|
3202
3532
|
}
|
|
3203
3533
|
chmodSync2(out2, st.mode);
|
|
3204
3534
|
}
|
|
3205
|
-
const nm =
|
|
3535
|
+
const nm = resolve2(cwd, "node_modules");
|
|
3206
3536
|
if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
|
|
3207
3537
|
}
|
|
3208
3538
|
function dropSymlink(p) {
|
|
@@ -3262,7 +3592,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
3262
3592
|
if (e.type === "commit") {
|
|
3263
3593
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
3264
3594
|
}
|
|
3265
|
-
const content =
|
|
3595
|
+
const content = execFileSync7("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
3266
3596
|
const out2 = join11(dest, e.path);
|
|
3267
3597
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
3268
3598
|
rmSync2(out2, { force: true });
|
|
@@ -3283,18 +3613,52 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
3283
3613
|
}
|
|
3284
3614
|
return { restored, removed };
|
|
3285
3615
|
}
|
|
3616
|
+
var SUPERVISOR = `
|
|
3617
|
+
const cp = require('node:child_process');
|
|
3618
|
+
const fs = require('node:fs');
|
|
3619
|
+
const [cmd, budgetMs, outFile] = process.argv.slice(1);
|
|
3620
|
+
const child = cp.spawn('sh', ['-c', cmd], { stdio: 'ignore', detached: process.platform !== 'win32' });
|
|
3621
|
+
const killGroup = () => {
|
|
3622
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch {}
|
|
3623
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
3624
|
+
};
|
|
3625
|
+
let done = false;
|
|
3626
|
+
const finish = (r) => {
|
|
3627
|
+
if (done) return;
|
|
3628
|
+
done = true;
|
|
3629
|
+
killGroup();
|
|
3630
|
+
fs.writeFileSync(outFile, JSON.stringify(r));
|
|
3631
|
+
process.exit(0);
|
|
3632
|
+
};
|
|
3633
|
+
const timer = setTimeout(() => finish({ timedOut: true }), Number(budgetMs));
|
|
3634
|
+
child.on('error', (e) => { clearTimeout(timer); finish({ error: String(e) }); });
|
|
3635
|
+
child.on('exit', (code, signal) => { clearTimeout(timer); finish({ exit: code, signal }); });
|
|
3636
|
+
`;
|
|
3286
3637
|
function runSuite(dir, cmd, budgetSecs) {
|
|
3287
3638
|
const t0 = Date.now();
|
|
3288
|
-
const
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3639
|
+
const outDir = mkdtempSync2(join11(tmpdir2(), "tw-verify-run-"));
|
|
3640
|
+
const outFile = join11(outDir, "result.json");
|
|
3641
|
+
try {
|
|
3642
|
+
spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
3643
|
+
cwd: dir,
|
|
3644
|
+
stdio: "ignore",
|
|
3645
|
+
timeout: budgetSecs * 1e3 + 3e4,
|
|
3646
|
+
// backstop only; the supervisor enforces the budget
|
|
3647
|
+
killSignal: "SIGKILL"
|
|
3648
|
+
});
|
|
3649
|
+
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
3650
|
+
let r;
|
|
3651
|
+
try {
|
|
3652
|
+
r = JSON.parse(readFileSync13(outFile, "utf8"));
|
|
3653
|
+
} catch {
|
|
3654
|
+
return { exit: null, secs };
|
|
3655
|
+
}
|
|
3656
|
+
if (r.timedOut || r.error) return { exit: null, secs };
|
|
3657
|
+
if (r.exit === null || r.exit === void 0) return { exit: null, secs };
|
|
3658
|
+
return { exit: r.exit, secs };
|
|
3659
|
+
} finally {
|
|
3660
|
+
rmSync2(outDir, { recursive: true, force: true });
|
|
3661
|
+
}
|
|
3298
3662
|
}
|
|
3299
3663
|
function runVerify(opts) {
|
|
3300
3664
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -3449,13 +3813,13 @@ function parseVerify(args) {
|
|
|
3449
3813
|
}
|
|
3450
3814
|
|
|
3451
3815
|
// src/cli/run.ts
|
|
3452
|
-
import { execFileSync as
|
|
3816
|
+
import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
|
|
3453
3817
|
import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
|
|
3454
|
-
import { resolve as
|
|
3818
|
+
import { resolve as resolve3 } from "node:path";
|
|
3455
3819
|
var out = (s) => process.stdout.write(s + "\n");
|
|
3456
3820
|
var err = (s) => process.stderr.write(s + "\n");
|
|
3457
3821
|
function git3(args, cwd) {
|
|
3458
|
-
return
|
|
3822
|
+
return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
3459
3823
|
}
|
|
3460
3824
|
function nowTicks() {
|
|
3461
3825
|
try {
|
|
@@ -3498,7 +3862,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
3498
3862
|
return out2;
|
|
3499
3863
|
}
|
|
3500
3864
|
function runEnvelope(opts) {
|
|
3501
|
-
const cwd =
|
|
3865
|
+
const cwd = resolve3(opts.cwd ?? process.cwd());
|
|
3502
3866
|
if (!opts.argv.length) {
|
|
3503
3867
|
err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
|
|
3504
3868
|
return 2;
|
|
@@ -3580,7 +3944,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
3580
3944
|
const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
|
|
3581
3945
|
if (opts.settle && opts.settle > 0) {
|
|
3582
3946
|
try {
|
|
3583
|
-
|
|
3947
|
+
execFileSync8("sleep", [String(opts.settle)]);
|
|
3584
3948
|
} catch {
|
|
3585
3949
|
}
|
|
3586
3950
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
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",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/node": "^20.14.0",
|
|
57
57
|
"@types/picomatch": "^3.0.1",
|
|
58
|
-
"esbuild": "^0.
|
|
58
|
+
"esbuild": "^0.25.12",
|
|
59
59
|
"vitepress": "^1.6.4",
|
|
60
|
-
"vitest": "^2.1.
|
|
60
|
+
"vitest": "^2.1.9"
|
|
61
61
|
}
|
|
62
62
|
}
|