tamperward 2.20.6 → 2.21.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 +19 -1
- package/dist/cli/index.js +624 -49
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -368,13 +368,29 @@ Full assumptions and residual risks: [SPEC.md](./SPEC.md),
|
|
|
368
368
|
## Quick start
|
|
369
369
|
|
|
370
370
|
```bash
|
|
371
|
-
npx tamperward
|
|
371
|
+
npx tamperward onboard
|
|
372
372
|
```
|
|
373
373
|
|
|
374
374
|
Requires Node.js 20.19 or later. JavaScript and TypeScript are the fully
|
|
375
375
|
supported detector surface; the other documented ecosystems get file-level and
|
|
376
376
|
pattern-based protection.
|
|
377
377
|
|
|
378
|
+
`onboard` (2.21.0) is the guided first run: it previews the installation with the
|
|
379
|
+
same planner as `init --dry-run`, explains each enforcement point, asks before writing
|
|
380
|
+
anything, runs the canonical `init`, offers the detected suite command for your
|
|
381
|
+
explicit acceptance (it is never written without one), runs and explains the first
|
|
382
|
+
`verify`, offers a safe demonstration of a weakening move on a disposable worktree that
|
|
383
|
+
leaves your tree byte-for-byte as it was, checks the GitHub controls with
|
|
384
|
+
`doctor --github` (or prints them), and ends with a `READY` / `READY WITH WARNINGS` /
|
|
385
|
+
`BROKEN` / `INCOMPLETE` posture taken from `doctor`. Non-interactive stdin refuses
|
|
386
|
+
rather than hangs; `--yes --verify-command "<cmd>"` is the scripted form.
|
|
387
|
+
|
|
388
|
+
The deterministic primitive underneath is unchanged:
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
npx tamperward init
|
|
392
|
+
```
|
|
393
|
+
|
|
378
394
|
One idempotent command wires the policy, the agent hooks, the pre-commit hook, a
|
|
379
395
|
CI workflow that runs both the diff-time check and pristine verification, and a
|
|
380
396
|
`CODEOWNERS` requirement on the paths that decide whether the gate runs at all.
|
|
@@ -566,6 +582,7 @@ option can never be reinterpreted as the agent command.
|
|
|
566
582
|
| `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` (per verifier suite) · `--agent-budget <seconds>` (optional wrapped-agent wall clock) · `--json` (one versioned final envelope document) · `--observe-transients` (start a session-scoped transient observer) · `--allow-dirty` · `--settle <seconds>` (wait before the final quiescence check) · `--allow-dep-drift` · `--cwd <dir>` · then `-- <agent command...>` |
|
|
567
583
|
| `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
|
|
568
584
|
| `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
|
|
585
|
+
| `onboard` | `--cwd <dir>` · `--base <rev>` · `--repo <owner/repo>` · `--branch <name>` · `--skip-demo` / `--demo` (mutually exclusive) · `--no-github` · `--yes` (scripted: no prompts; the demo runs only with `--demo`) · `--verify-command "<suite command>"` (the only way a scripted run configures `verify.command`) |
|
|
569
586
|
| `watch` | `--dir <dir>` · `--log <file>` — a daemon; it runs until signalled |
|
|
570
587
|
| `hook claude` / `sweep claude` | none — the Claude Code payload arrives on stdin |
|
|
571
588
|
|
|
@@ -581,6 +598,7 @@ option can never be reinterpreted as the agent command.
|
|
|
581
598
|
| `hook claude` / `sweep claude` | always — a deny is JSON on stdout at exit 0, never exit 2 | — | only for an unsupported agent name | — |
|
|
582
599
|
| `allow` | sign-off recorded | — | no rule or `--reason`, not a git repo, or no current blocking finding to sign off | — |
|
|
583
600
|
| `init` | wired, or already wired | — | an item needs attention | — |
|
|
601
|
+
| `onboard` | posture `READY` or `READY WITH WARNINGS` (from `doctor`) | posture `BROKEN` or `INCOMPLETE`, including a declined write or an unconfigured verifier | refused (not a git repository, non-interactive stdin without `--yes`, a dirty tree the operator would not continue on) or aborted at a prompt | — |
|
|
584
602
|
| no or unknown command | help printed (no command) | — | unknown command, help printed | — |
|
|
585
603
|
|
|
586
604
|
### Environment variables
|
package/dist/cli/index.js
CHANGED
|
@@ -2202,8 +2202,8 @@ function numericOf(e) {
|
|
|
2202
2202
|
}
|
|
2203
2203
|
return void 0;
|
|
2204
2204
|
}
|
|
2205
|
-
function metricsOf(expr,
|
|
2206
|
-
const obj =
|
|
2205
|
+
function metricsOf(expr, resolve11) {
|
|
2206
|
+
const obj = resolve11(expr);
|
|
2207
2207
|
if (!ts4.isObjectLiteralExpression(obj)) return void 0;
|
|
2208
2208
|
const out2 = { values: {}, opaque: false };
|
|
2209
2209
|
let any = false;
|
|
@@ -2217,7 +2217,7 @@ function metricsOf(expr, resolve10) {
|
|
|
2217
2217
|
const k = keyName(p.name);
|
|
2218
2218
|
if (k === null || !isMetric(k)) continue;
|
|
2219
2219
|
any = true;
|
|
2220
|
-
const n = numericOf(
|
|
2220
|
+
const n = numericOf(resolve11(p.initializer));
|
|
2221
2221
|
if (n === void 0) out2.opaque = true;
|
|
2222
2222
|
else out2.values[k] = n;
|
|
2223
2223
|
}
|
|
@@ -2263,17 +2263,17 @@ function parseThresholds(src) {
|
|
|
2263
2263
|
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
2264
2264
|
try {
|
|
2265
2265
|
const sf = sourceOf(src);
|
|
2266
|
-
const
|
|
2266
|
+
const resolve11 = resolverFor(sf);
|
|
2267
2267
|
const visit = (node) => {
|
|
2268
2268
|
if (ts4.isPropertyAssignment(node)) {
|
|
2269
2269
|
const key2 = keyName(node.name);
|
|
2270
2270
|
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
2271
2271
|
res.present = true;
|
|
2272
|
-
const init =
|
|
2272
|
+
const init = resolve11(node.initializer);
|
|
2273
2273
|
if (!ts4.isObjectLiteralExpression(init)) {
|
|
2274
2274
|
res.opaque = true;
|
|
2275
2275
|
} else {
|
|
2276
|
-
const flat = metricsOf(init,
|
|
2276
|
+
const flat = metricsOf(init, resolve11);
|
|
2277
2277
|
if (flat) res.global = merge(res.global, flat);
|
|
2278
2278
|
for (const p of init.properties) {
|
|
2279
2279
|
if (ts4.isSpreadAssignment(p)) {
|
|
@@ -2287,7 +2287,7 @@ function parseThresholds(src) {
|
|
|
2287
2287
|
res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
|
|
2288
2288
|
continue;
|
|
2289
2289
|
}
|
|
2290
|
-
const m = metricsOf(p.initializer,
|
|
2290
|
+
const m = metricsOf(p.initializer, resolve11);
|
|
2291
2291
|
if (!m) continue;
|
|
2292
2292
|
if (k === "global") res.global = merge(res.global, m);
|
|
2293
2293
|
else res.paths.set(norm(k), m);
|
|
@@ -2320,18 +2320,18 @@ function parseLists(src) {
|
|
|
2320
2320
|
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
2321
2321
|
try {
|
|
2322
2322
|
const sf = sourceOf(src);
|
|
2323
|
-
const
|
|
2323
|
+
const resolve11 = resolverFor(sf);
|
|
2324
2324
|
const visit = (node) => {
|
|
2325
2325
|
if (ts4.isPropertyAssignment(node)) {
|
|
2326
2326
|
const k = keyName(node.name);
|
|
2327
2327
|
const vitestCoverage = ownerKey(node) === "coverage";
|
|
2328
2328
|
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
2329
|
-
const { items, opaque } = literals(
|
|
2329
|
+
const { items, opaque } = literals(resolve11(node.initializer));
|
|
2330
2330
|
res.collectFrom = items;
|
|
2331
2331
|
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
2332
2332
|
if (opaque) res.opaqueCollect = true;
|
|
2333
2333
|
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
2334
|
-
const { items, opaque } = literals(
|
|
2334
|
+
const { items, opaque } = literals(resolve11(node.initializer));
|
|
2335
2335
|
res.ignore.push(...items);
|
|
2336
2336
|
if (opaque) res.opaqueIgnore = true;
|
|
2337
2337
|
}
|
|
@@ -7030,7 +7030,7 @@ function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
|
|
|
7030
7030
|
process.stderr.write(`tamperward: detector "${d.id}" errored: ${String(e)}
|
|
7031
7031
|
`);
|
|
7032
7032
|
if (view === "staged" || view === "worktree" || view === "range") {
|
|
7033
|
-
const
|
|
7033
|
+
const failed2 = {
|
|
7034
7034
|
rule: "detector-error",
|
|
7035
7035
|
severity: "block",
|
|
7036
7036
|
message: `Detector "${d.id}" failed to run; the verdict is incomplete.`,
|
|
@@ -7038,7 +7038,7 @@ function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
|
|
|
7038
7038
|
remediation: "A rule that cannot run is not a rule that passed. Fix the input or the detector; do not read this as clean.",
|
|
7039
7039
|
signoff: { required: true, command: `tamperward allow detector-error --reason "..."` }
|
|
7040
7040
|
};
|
|
7041
|
-
out2.push(
|
|
7041
|
+
out2.push(failed2);
|
|
7042
7042
|
}
|
|
7043
7043
|
}
|
|
7044
7044
|
}
|
|
@@ -11211,6 +11211,7 @@ function runVerify(opts) {
|
|
|
11211
11211
|
const out2 = opts.silent ? (_s) => {
|
|
11212
11212
|
} : (s) => void process.stdout.write(s + "\n");
|
|
11213
11213
|
const cannotVerify = (reason, detail, extra = {}) => {
|
|
11214
|
+
opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason, ...detail ? { detail } : {} });
|
|
11214
11215
|
if (opts.json) {
|
|
11215
11216
|
out2(JSON.stringify({
|
|
11216
11217
|
schema_version: MACHINE_SCHEMA_VERSION,
|
|
@@ -11256,6 +11257,7 @@ function runVerify(opts) {
|
|
|
11256
11257
|
const verifierBackend = opts.verifierBackend ?? prepareVerifierBackend(policy.verify);
|
|
11257
11258
|
const backendReport = () => verifierBackendReport(verifierBackend);
|
|
11258
11259
|
if (!verifierBackend.available) {
|
|
11260
|
+
opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: "VERIFIER_BACKEND_UNAVAILABLE", ...verifierBackend.reason ? { detail: verifierBackend.reason } : {} });
|
|
11259
11261
|
if (opts.json) {
|
|
11260
11262
|
out2(JSON.stringify({
|
|
11261
11263
|
schema_version: MACHINE_SCHEMA_VERSION,
|
|
@@ -11272,6 +11274,7 @@ function runVerify(opts) {
|
|
|
11272
11274
|
}
|
|
11273
11275
|
const isolated = verifierBackend.kind === "container";
|
|
11274
11276
|
if (!isolated && !localVerifierShell(process.platform, cmd)) {
|
|
11277
|
+
opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: "LOCAL_VERIFIER_UNSUPPORTED_PLATFORM", detail: `checkpointed-local verifier is unsupported on ${process.platform}` });
|
|
11275
11278
|
if (opts.json) {
|
|
11276
11279
|
out2(JSON.stringify({
|
|
11277
11280
|
schema_version: MACHINE_SCHEMA_VERSION,
|
|
@@ -11295,6 +11298,7 @@ function runVerify(opts) {
|
|
|
11295
11298
|
image: verifierBackend.image
|
|
11296
11299
|
};
|
|
11297
11300
|
if (dependencyEnvironment?.status === "unattestable" && !opts.allowDepDrift) {
|
|
11301
|
+
opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: "DEPENDENCY_ENVIRONMENT_UNATTESTABLE", ...dependencyEnvironment.reason ? { detail: dependencyEnvironment.reason } : {} });
|
|
11298
11302
|
if (opts.json) {
|
|
11299
11303
|
out2(JSON.stringify({
|
|
11300
11304
|
schema_version: MACHINE_SCHEMA_VERSION,
|
|
@@ -11355,6 +11359,7 @@ function runVerify(opts) {
|
|
|
11355
11359
|
if (visible.failure === "backend" || visible.failure === "resource") {
|
|
11356
11360
|
cleanup([visRoot]);
|
|
11357
11361
|
const exhausted = visible.failure === "resource";
|
|
11362
|
+
opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: exhausted ? "VERIFIER_RESOURCE_EXHAUSTED" : "VERIFIER_BACKEND_RUNTIME_FAILURE", ...visible.reason ? { detail: visible.reason } : {} });
|
|
11358
11363
|
if (opts.json) {
|
|
11359
11364
|
out2(JSON.stringify({
|
|
11360
11365
|
schema_version: MACHINE_SCHEMA_VERSION,
|
|
@@ -11421,6 +11426,7 @@ function runVerify(opts) {
|
|
|
11421
11426
|
if (pristine.failure === "backend" || pristine.failure === "resource") {
|
|
11422
11427
|
cleanup([visRoot, priRoot]);
|
|
11423
11428
|
const exhausted = pristine.failure === "resource";
|
|
11429
|
+
opts.onVerdict?.({ verdict: "CANNOT_VERIFY", reason: exhausted ? "VERIFIER_RESOURCE_EXHAUSTED" : "VERIFIER_BACKEND_RUNTIME_FAILURE", ...pristine.reason ? { detail: pristine.reason } : {} });
|
|
11424
11430
|
if (opts.json) {
|
|
11425
11431
|
out2(JSON.stringify({
|
|
11426
11432
|
schema_version: MACHINE_SCHEMA_VERSION,
|
|
@@ -11476,6 +11482,7 @@ function runVerify(opts) {
|
|
|
11476
11482
|
verdict2 = "SUITE_RED";
|
|
11477
11483
|
code2 = 1;
|
|
11478
11484
|
}
|
|
11485
|
+
opts.onVerdict?.({ verdict: verdict2 });
|
|
11479
11486
|
const signedOff = verdict2 === "MASKED_FAILURE" ? oobToken("verify", oobFromEnv(), oobHeadFromEnv()) : null;
|
|
11480
11487
|
if (signedOff) code2 = 0;
|
|
11481
11488
|
if (opts.json) {
|
|
@@ -12482,22 +12489,17 @@ function lifecyclePlatformCheck(platform = process.platform, linuxPython = platf
|
|
|
12482
12489
|
};
|
|
12483
12490
|
}
|
|
12484
12491
|
var VERIFY_COMMAND = /\btamperward(?:@\S+)?\s+verify\b/;
|
|
12485
|
-
function
|
|
12486
|
-
|
|
12487
|
-
|
|
12488
|
-
|
|
12489
|
-
|
|
12490
|
-
checks
|
|
12491
|
-
|
|
12492
|
-
|
|
12493
|
-
|
|
12494
|
-
|
|
12495
|
-
|
|
12496
|
-
} else {
|
|
12497
|
-
process.stderr.write(`tamperward doctor: ${message}
|
|
12498
|
-
`);
|
|
12499
|
-
}
|
|
12500
|
-
return 2;
|
|
12492
|
+
function failed(message, id = "doctor", checks = []) {
|
|
12493
|
+
return {
|
|
12494
|
+
code: 2,
|
|
12495
|
+
authoritative: false,
|
|
12496
|
+
checks: [
|
|
12497
|
+
...checks.filter((check2) => check2.id !== id),
|
|
12498
|
+
{ id, state: "BROKEN", detail: message }
|
|
12499
|
+
],
|
|
12500
|
+
failure: { id, message },
|
|
12501
|
+
summary: []
|
|
12502
|
+
};
|
|
12501
12503
|
}
|
|
12502
12504
|
function policyFor(opts, cwd) {
|
|
12503
12505
|
if (!opts.base) return loadPolicy(cwd);
|
|
@@ -12701,8 +12703,7 @@ function observerCheck(cwd) {
|
|
|
12701
12703
|
detail: `unavailable${observer.reason ? ": " + observer.reason : ""}; optional/advisory, and zero events are not evidence of no transient activity`
|
|
12702
12704
|
};
|
|
12703
12705
|
}
|
|
12704
|
-
function emitReport(opts, checks) {
|
|
12705
|
-
const authoritative = !checks.some((x) => x.state === "BROKEN");
|
|
12706
|
+
function emitReport(opts, checks, authoritative) {
|
|
12706
12707
|
if (opts.json) {
|
|
12707
12708
|
const report2 = machineOutput({ command: "doctor", authoritative, checks });
|
|
12708
12709
|
process.stdout.write(JSON.stringify(report2) + "\n");
|
|
@@ -12894,22 +12895,20 @@ function verifyJobs(doc) {
|
|
|
12894
12895
|
}
|
|
12895
12896
|
return found;
|
|
12896
12897
|
}
|
|
12897
|
-
function
|
|
12898
|
+
function diagnose(opts = {}) {
|
|
12898
12899
|
const cwd = resolve8(opts.cwd ?? process.cwd());
|
|
12899
12900
|
let policy;
|
|
12900
12901
|
try {
|
|
12901
12902
|
policy = policyFor(opts, cwd);
|
|
12902
12903
|
} catch (e) {
|
|
12903
|
-
return
|
|
12904
|
+
return failed(
|
|
12904
12905
|
e instanceof PolicyError || e instanceof Error ? e.message : String(e),
|
|
12905
|
-
opts,
|
|
12906
12906
|
"policy"
|
|
12907
12907
|
);
|
|
12908
12908
|
}
|
|
12909
12909
|
let postureWorkflows = opts.workflow ? [opts.workflow] : void 0;
|
|
12910
|
-
const fail = (id, message) =>
|
|
12910
|
+
const fail = (id, message) => failed(
|
|
12911
12911
|
message,
|
|
12912
|
-
opts,
|
|
12913
12912
|
id,
|
|
12914
12913
|
collectLocalPosture(cwd, policy, postureWorkflows, Boolean(opts.workflow))
|
|
12915
12914
|
);
|
|
@@ -13037,19 +13036,38 @@ function runDoctor(opts = {}) {
|
|
|
13037
13036
|
});
|
|
13038
13037
|
}
|
|
13039
13038
|
checks.push(observerCheck(cwd));
|
|
13040
|
-
|
|
13041
|
-
|
|
13042
|
-
|
|
13043
|
-
|
|
13039
|
+
const summary = [
|
|
13040
|
+
`tamperward doctor: CI verifier envelope OK \u2014 ${verifyJobCount} verify job(s), trusted budget ${policy.verify.budget}s/stage, requires >=${requiredMinutes}m outer timeout.`
|
|
13041
|
+
];
|
|
13042
|
+
if (github) {
|
|
13043
|
+
summary.push(
|
|
13044
|
+
"tamperward doctor: GitHub repository authority OK \u2014 " + github.repo + "#" + github.branch + " requires tamperward, Code Owner review, and stale-review dismissal on new pushes."
|
|
13044
13045
|
);
|
|
13045
|
-
|
|
13046
|
-
|
|
13047
|
-
|
|
13048
|
-
|
|
13046
|
+
}
|
|
13047
|
+
return {
|
|
13048
|
+
code: 0,
|
|
13049
|
+
authoritative: !checks.some((x) => x.state === "BROKEN"),
|
|
13050
|
+
checks,
|
|
13051
|
+
summary,
|
|
13052
|
+
...github ? { github: { repo: github.repo, branch: github.branch } } : {}
|
|
13053
|
+
};
|
|
13054
|
+
}
|
|
13055
|
+
function runDoctor(opts = {}) {
|
|
13056
|
+
const outcome = diagnose(opts);
|
|
13057
|
+
if (outcome.failure) {
|
|
13058
|
+
if (opts.json) {
|
|
13059
|
+
emitReport(opts, outcome.checks, false);
|
|
13060
|
+
} else {
|
|
13061
|
+
process.stderr.write(`tamperward doctor: ${outcome.failure.message}
|
|
13062
|
+
`);
|
|
13049
13063
|
}
|
|
13064
|
+
return outcome.code;
|
|
13050
13065
|
}
|
|
13051
|
-
|
|
13052
|
-
|
|
13066
|
+
if (!opts.json) {
|
|
13067
|
+
for (const line of outcome.summary) process.stdout.write(line + "\n");
|
|
13068
|
+
}
|
|
13069
|
+
emitReport(opts, outcome.checks, outcome.authoritative);
|
|
13070
|
+
return outcome.code;
|
|
13053
13071
|
}
|
|
13054
13072
|
|
|
13055
13073
|
// src/cli/trace-verify.ts
|
|
@@ -13430,6 +13448,507 @@ function parseTraceVerify(args) {
|
|
|
13430
13448
|
return out2;
|
|
13431
13449
|
}
|
|
13432
13450
|
|
|
13451
|
+
// src/cli/onboard.ts
|
|
13452
|
+
import { execFileSync as execFileSync12 } from "node:child_process";
|
|
13453
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
13454
|
+
import { lstatSync as lstatSync6, mkdtempSync as mkdtempSync6, readFileSync as readFileSync19, realpathSync as realpathSync8, renameSync as renameSync2, rmSync as rmSync7, writeFileSync as writeFileSync10 } from "node:fs";
|
|
13455
|
+
import { tmpdir as tmpdir6 } from "node:os";
|
|
13456
|
+
import { join as join20, resolve as resolve10, sep as sep4 } from "node:path";
|
|
13457
|
+
import { createInterface } from "node:readline";
|
|
13458
|
+
import { parseDocument } from "yaml";
|
|
13459
|
+
function readlineAsker(input, output) {
|
|
13460
|
+
const rl = createInterface({ input, output });
|
|
13461
|
+
let closed = false;
|
|
13462
|
+
let pending = null;
|
|
13463
|
+
rl.on("close", () => {
|
|
13464
|
+
closed = true;
|
|
13465
|
+
const resolve11 = pending;
|
|
13466
|
+
pending = null;
|
|
13467
|
+
resolve11?.(null);
|
|
13468
|
+
});
|
|
13469
|
+
rl.on("SIGINT", () => rl.close());
|
|
13470
|
+
const ask = (question) => new Promise((resolvePromise) => {
|
|
13471
|
+
if (closed) {
|
|
13472
|
+
resolvePromise(null);
|
|
13473
|
+
return;
|
|
13474
|
+
}
|
|
13475
|
+
pending = resolvePromise;
|
|
13476
|
+
rl.question(question, (answer) => {
|
|
13477
|
+
pending = null;
|
|
13478
|
+
resolvePromise(answer);
|
|
13479
|
+
});
|
|
13480
|
+
});
|
|
13481
|
+
ask.close = () => {
|
|
13482
|
+
if (!closed) rl.close();
|
|
13483
|
+
};
|
|
13484
|
+
return ask;
|
|
13485
|
+
}
|
|
13486
|
+
var Aborted = class extends Error {
|
|
13487
|
+
};
|
|
13488
|
+
var STEPS = [
|
|
13489
|
+
"Preflight",
|
|
13490
|
+
"Preview the installation",
|
|
13491
|
+
"Initialize",
|
|
13492
|
+
"Configure verification",
|
|
13493
|
+
"First verification",
|
|
13494
|
+
"Safe demonstration",
|
|
13495
|
+
"GitHub repository authority",
|
|
13496
|
+
"Posture",
|
|
13497
|
+
"Next steps"
|
|
13498
|
+
];
|
|
13499
|
+
var MANUAL_CONTROLS = [
|
|
13500
|
+
"In the branch-protection or ruleset settings of the protected branch, configure ALL THREE:",
|
|
13501
|
+
" 1. require the `tamperward` status check;",
|
|
13502
|
+
' 2. enable "Require review from Code Owners";',
|
|
13503
|
+
' 3. enable "Dismiss stale pull request approvals when new commits are pushed".',
|
|
13504
|
+
"Until all three are enforced the generated CI gate is advisory: a pull request runs the",
|
|
13505
|
+
"workflow from its own head and a required check is matched by job name, so it could keep",
|
|
13506
|
+
"the job called `tamperward`, replace the gate with `true`, and report green."
|
|
13507
|
+
];
|
|
13508
|
+
var JS_TEST_PATH = /(?:^|\/)(?:[^/]+\.(?:test|spec)\.[cm]?[jt]sx?|__tests__\/[^/]+\.[cm]?[jt]sx?)$/;
|
|
13509
|
+
var JS_TEST_BLOCK = /^(\s*)(it|test|describe)(\s*\()/m;
|
|
13510
|
+
function git5(cwd, args) {
|
|
13511
|
+
try {
|
|
13512
|
+
return execFileSync12("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
13513
|
+
} catch {
|
|
13514
|
+
return null;
|
|
13515
|
+
}
|
|
13516
|
+
}
|
|
13517
|
+
function explainVerdict(v, code2) {
|
|
13518
|
+
const verdict2 = v?.verdict ?? (code2 === 0 ? "VERIFIED" : code2 === 1 ? "SUITE_RED" : "CANNOT_VERIFY");
|
|
13519
|
+
switch (verdict2) {
|
|
13520
|
+
case "VERIFIED":
|
|
13521
|
+
return [
|
|
13522
|
+
"VERIFIED: your suite passes, and still passes with every protected test, snapshot and",
|
|
13523
|
+
"config file restored from the base. Nothing was masked. Exit 0."
|
|
13524
|
+
];
|
|
13525
|
+
case "SUITE_RED":
|
|
13526
|
+
return [
|
|
13527
|
+
"SUITE_RED: your suite fails as it is. That is not a TamperWard finding \u2014 fix the code",
|
|
13528
|
+
"(or the suite) first; verify exits 1 until the visible run is green."
|
|
13529
|
+
];
|
|
13530
|
+
case "MASKED_FAILURE":
|
|
13531
|
+
return [
|
|
13532
|
+
"MASKED_FAILURE: your suite passes as it is, but FAILS once the protected files are restored",
|
|
13533
|
+
"from the base. Something weakened the checks; the code does not pass the original suite.",
|
|
13534
|
+
"Exit 1. In CI only a reviewer label (`tamperward:allow:verify@<head-sha>`) clears it."
|
|
13535
|
+
];
|
|
13536
|
+
default:
|
|
13537
|
+
return [
|
|
13538
|
+
`CANNOT VERIFY${v?.reason ? ` (${v.reason})` : ""}: TamperWard could not verify this repository and fails closed`,
|
|
13539
|
+
"(exit 2) rather than reporting success. Nothing above is a verdict about your code.",
|
|
13540
|
+
...v?.detail ? [` ${v.detail}`] : []
|
|
13541
|
+
];
|
|
13542
|
+
}
|
|
13543
|
+
}
|
|
13544
|
+
function atomicReplaceFile(path, content, mode) {
|
|
13545
|
+
const tmp = `${path}.${process.pid}.${randomBytes2(8).toString("hex")}.tmp`;
|
|
13546
|
+
try {
|
|
13547
|
+
writeFileSync10(tmp, content, { encoding: "utf8", flag: "wx", mode });
|
|
13548
|
+
renameSync2(tmp, path);
|
|
13549
|
+
} finally {
|
|
13550
|
+
rmSync7(tmp, { force: true });
|
|
13551
|
+
}
|
|
13552
|
+
}
|
|
13553
|
+
function writeVerifyCommand(cwd, command) {
|
|
13554
|
+
const path = join20(cwd, POLICY_FILE);
|
|
13555
|
+
const st = lstatSync6(path, { throwIfNoEntry: false });
|
|
13556
|
+
if (st && (!st.isFile() || st.isSymbolicLink())) {
|
|
13557
|
+
return `${POLICY_FILE} is not a regular file; refusing to follow or replace a symlink or special file`;
|
|
13558
|
+
}
|
|
13559
|
+
const mode = st ? st.mode & 511 : 420;
|
|
13560
|
+
const original = st ? readFileSync19(path, "utf8") : null;
|
|
13561
|
+
let next;
|
|
13562
|
+
try {
|
|
13563
|
+
const doc = parseDocument(original && original.trim() ? original : "version: 1\n");
|
|
13564
|
+
if (doc.errors.length) return `${POLICY_FILE} is not valid YAML: ${doc.errors[0].message}`;
|
|
13565
|
+
doc.setIn(["verify", "command"], command);
|
|
13566
|
+
if (!doc.hasIn(["verify", "budget"])) doc.setIn(["verify", "budget"], 300);
|
|
13567
|
+
next = doc.toString();
|
|
13568
|
+
} catch (e) {
|
|
13569
|
+
return `${POLICY_FILE} could not be updated: ${errorMessage(e)}`;
|
|
13570
|
+
}
|
|
13571
|
+
atomicReplaceFile(path, next, mode);
|
|
13572
|
+
try {
|
|
13573
|
+
const loaded = loadPolicy(cwd);
|
|
13574
|
+
if (loaded.verify?.command !== command) throw new Error("verify.command did not round-trip");
|
|
13575
|
+
return null;
|
|
13576
|
+
} catch (e) {
|
|
13577
|
+
if (original === null) rmSync7(path, { force: true });
|
|
13578
|
+
else atomicReplaceFile(path, original, mode);
|
|
13579
|
+
return `the updated ${POLICY_FILE} does not load (${errorMessage(e)}); restored the previous file`;
|
|
13580
|
+
}
|
|
13581
|
+
}
|
|
13582
|
+
async function runOnboard(opts, io = {}) {
|
|
13583
|
+
const cwd = resolve10(opts.cwd ?? process.cwd());
|
|
13584
|
+
const out2 = io.out ?? ((line) => void process.stdout.write(line + "\n"));
|
|
13585
|
+
const err2 = (line) => void process.stderr.write(line + "\n");
|
|
13586
|
+
const platform = io.platform ?? process.platform;
|
|
13587
|
+
const runners = {
|
|
13588
|
+
init: runInit,
|
|
13589
|
+
verify: runVerify,
|
|
13590
|
+
doctor: diagnose,
|
|
13591
|
+
check: runCheck,
|
|
13592
|
+
...io.runners
|
|
13593
|
+
};
|
|
13594
|
+
const interactive = io.interactive ?? (Boolean(process.stdin.isTTY) && !process.env.CI && !process.env.GITHUB_ACTIONS);
|
|
13595
|
+
const scripted = Boolean(opts.yes);
|
|
13596
|
+
if (!interactive && !scripted) {
|
|
13597
|
+
err2("tamperward onboard: stdin is not interactive (or a CI environment was detected), so the guided");
|
|
13598
|
+
err2("setup cannot ask questions. Re-run with --yes for the scripted mode (add --verify-command");
|
|
13599
|
+
err2('"<suite command>" to configure verification; a detected command is never written unscripted),');
|
|
13600
|
+
err2("or run the deterministic primitive directly: tamperward init.");
|
|
13601
|
+
return 2;
|
|
13602
|
+
}
|
|
13603
|
+
if (!isGitRepo(cwd)) {
|
|
13604
|
+
err2(`tamperward onboard: ${cwd} is not inside a git repository \u2014 run \`git init\` first; every enforcement point is git-anchored`);
|
|
13605
|
+
return 2;
|
|
13606
|
+
}
|
|
13607
|
+
const prompt = { asker: null };
|
|
13608
|
+
const rawAsk = io.ask ?? ((q) => (prompt.asker ??= readlineAsker(process.stdin, process.stdout))(q));
|
|
13609
|
+
const ask = async (question) => {
|
|
13610
|
+
const answer = await rawAsk(question);
|
|
13611
|
+
if (answer === null) throw new Aborted();
|
|
13612
|
+
return answer.trim();
|
|
13613
|
+
};
|
|
13614
|
+
const confirm = async (question, fallback, scriptedAnswer) => {
|
|
13615
|
+
if (scripted) return scriptedAnswer;
|
|
13616
|
+
const a = (await ask(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).toLowerCase();
|
|
13617
|
+
if (a === "") return fallback;
|
|
13618
|
+
return a === "y" || a === "yes";
|
|
13619
|
+
};
|
|
13620
|
+
let step = 0;
|
|
13621
|
+
const header = (n) => {
|
|
13622
|
+
step = n;
|
|
13623
|
+
out2("");
|
|
13624
|
+
out2(`== ${n}/${STEPS.length} ${STEPS[n - 1]} ==`);
|
|
13625
|
+
};
|
|
13626
|
+
const wrote = [];
|
|
13627
|
+
let verifyCommand;
|
|
13628
|
+
let verifyResult = null;
|
|
13629
|
+
let githubChecked = false;
|
|
13630
|
+
let repo = null;
|
|
13631
|
+
let declined = false;
|
|
13632
|
+
let doctorOutcome = null;
|
|
13633
|
+
try {
|
|
13634
|
+
header(1);
|
|
13635
|
+
out2(`TamperWard ${TW_VERSION} on Node ${process.versions.node} (${platform}/${process.arch}), repository ${cwd}`);
|
|
13636
|
+
const lifecycle = lifecyclePlatformCheck(platform);
|
|
13637
|
+
out2(`platform: [${lifecycle.state}] ${lifecycle.detail}`);
|
|
13638
|
+
const localVerifySupported = localVerifierShell(platform, "true") !== null;
|
|
13639
|
+
if (!localVerifySupported) {
|
|
13640
|
+
out2("platform: checkpointed-local verify is unsupported here; only a digest-pinned container verifier");
|
|
13641
|
+
out2(" (verify.backend: container) can verify on this platform, exactly as the support contract states.");
|
|
13642
|
+
}
|
|
13643
|
+
const head = git5(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])?.trim() ?? null;
|
|
13644
|
+
if (!head) {
|
|
13645
|
+
out2("history: no commit yet. Verification and the demonstration need a committed base; the wiring can still be written.");
|
|
13646
|
+
}
|
|
13647
|
+
const status = git5(cwd, ["status", "--porcelain", "--untracked-files=all"]) ?? "";
|
|
13648
|
+
const dirty = status.split("\n").filter(Boolean).map((line) => line.slice(3).split(" -> ").at(-1) ?? "");
|
|
13649
|
+
const owned = new Set(planInit(cwd).map((a) => a.path));
|
|
13650
|
+
const foreign = dirty.filter((p) => !owned.has(p));
|
|
13651
|
+
if (dirty.length && foreign.length === 0) {
|
|
13652
|
+
out2(`working tree: ${dirty.length} uncommitted path(s), all written by init \u2014 commit them so the gate travels with the repository.`);
|
|
13653
|
+
} else if (dirty.length) {
|
|
13654
|
+
out2(`working tree is not clean: ${dirty.length} changed or untracked path(s). Onboarding never stashes,`);
|
|
13655
|
+
out2("resets or edits your changes, but a clean tree makes the first verification easier to read:");
|
|
13656
|
+
out2("commit or stash your work first for the cleanest result.");
|
|
13657
|
+
if (!await confirm("Continue with a dirty working tree?", false, true)) {
|
|
13658
|
+
out2("Stopped before writing anything. Commit or stash your work, then re-run: tamperward onboard");
|
|
13659
|
+
return 2;
|
|
13660
|
+
}
|
|
13661
|
+
} else {
|
|
13662
|
+
out2("working tree: clean");
|
|
13663
|
+
}
|
|
13664
|
+
header(2);
|
|
13665
|
+
out2("This is the plan `tamperward init` would apply (the same planner as `init --dry-run`):");
|
|
13666
|
+
out2("");
|
|
13667
|
+
runners.init({ cwd, dryRun: true });
|
|
13668
|
+
out2("");
|
|
13669
|
+
out2("What each item is for:");
|
|
13670
|
+
out2(` policy \u2014 ${POLICY_FILE} holds your overrides; the baseline rules apply even without it.`);
|
|
13671
|
+
out2(" agent hooks \u2014 Claude Code PreToolUse deny + Stop sweep: the steering layer, judged in the loop.");
|
|
13672
|
+
out2(" pre-commit \u2014 the same engine over the staged diff, so a shortcut cannot be committed quietly.");
|
|
13673
|
+
out2(" CI \u2014 the authority for the protected branch: check --diff over the PR range plus");
|
|
13674
|
+
out2(" pristine verify; cleared only by a reviewer label bound to the head SHA.");
|
|
13675
|
+
out2(" CODEOWNERS \u2014 a human requirement on the paths that decide whether the gate runs at all.");
|
|
13676
|
+
const plan = planInit(cwd);
|
|
13677
|
+
const pending = plan.filter((a) => a.apply).length;
|
|
13678
|
+
const errors = plan.filter((a) => a.status === "error").length;
|
|
13679
|
+
if (pending === 0) {
|
|
13680
|
+
out2("");
|
|
13681
|
+
out2(errors ? "Nothing to write, but item(s) above need your attention." : "Everything is already wired \u2014 nothing to write.");
|
|
13682
|
+
} else {
|
|
13683
|
+
out2("");
|
|
13684
|
+
out2("init never overwrites a file you wrote: it creates what is absent and merges what is shared.");
|
|
13685
|
+
if (!await confirm(`Write these ${pending} change(s) now?`, false, true)) {
|
|
13686
|
+
declined = true;
|
|
13687
|
+
out2("Declined: nothing was written. Re-run `tamperward onboard` when ready, or apply the plan");
|
|
13688
|
+
out2("yourself with `tamperward init` (`--dry-run` prints it again).");
|
|
13689
|
+
}
|
|
13690
|
+
}
|
|
13691
|
+
if (!declined && pending > 0) {
|
|
13692
|
+
header(3);
|
|
13693
|
+
const code2 = runners.init({ cwd });
|
|
13694
|
+
wrote.push(...plan.filter((a) => a.apply).map((a) => a.path));
|
|
13695
|
+
if (code2 !== 0) out2("init reported item(s) that need your attention (above); onboarding continues so you can see the rest.");
|
|
13696
|
+
}
|
|
13697
|
+
if (!declined) {
|
|
13698
|
+
header(4);
|
|
13699
|
+
let configured;
|
|
13700
|
+
try {
|
|
13701
|
+
configured = loadPolicy(cwd).verify?.command?.trim() || void 0;
|
|
13702
|
+
} catch (e) {
|
|
13703
|
+
out2(`${POLICY_FILE} does not load (${errorMessage(e)}); fix it, then re-run. verify.command was not configured.`);
|
|
13704
|
+
}
|
|
13705
|
+
if (configured) {
|
|
13706
|
+
out2(`verification configured \u2014 ${configured}`);
|
|
13707
|
+
verifyCommand = configured;
|
|
13708
|
+
} else {
|
|
13709
|
+
out2("The verifier command is part of the trust anchor: `verify` re-runs it against a pristine copy of");
|
|
13710
|
+
out2("your suite, and CI fails closed without it. TamperWard never chooses it for you.");
|
|
13711
|
+
const candidates = verifierCandidates(cwd);
|
|
13712
|
+
let chosen;
|
|
13713
|
+
if (scripted) {
|
|
13714
|
+
if (opts.verifyCommand?.trim()) {
|
|
13715
|
+
chosen = opts.verifyCommand.trim();
|
|
13716
|
+
} else {
|
|
13717
|
+
out2(candidates.length ? `Detected candidate(s): ${candidates.join(", ")}.` : "No suite command was detected.");
|
|
13718
|
+
out2('Scripted mode: verify.command was not written. Pass --verify-command "<suite command>" to set it explicitly.');
|
|
13719
|
+
}
|
|
13720
|
+
} else if (candidates.length === 1) {
|
|
13721
|
+
out2(`Detected one high-confidence suite command: ${candidates[0]}`);
|
|
13722
|
+
if (await confirm(`Use \`${candidates[0]}\` as verify.command?`, false, false)) chosen = candidates[0];
|
|
13723
|
+
else chosen = await ask("Enter the suite command to trust (empty to skip): ") || void 0;
|
|
13724
|
+
} else if (candidates.length > 1) {
|
|
13725
|
+
out2("Several suite commands were detected; choose the one that defines merge authority:");
|
|
13726
|
+
candidates.forEach((c, i) => out2(` ${i + 1}. ${c}`));
|
|
13727
|
+
const a = await ask(`Choose 1-${candidates.length} (${candidates.join(" / ")}), type a command, or press Enter to skip: `);
|
|
13728
|
+
const n = Number(a);
|
|
13729
|
+
chosen = a === "" ? void 0 : Number.isInteger(n) && n >= 1 && n <= candidates.length ? candidates[n - 1] : a;
|
|
13730
|
+
} else {
|
|
13731
|
+
out2("No suite command was detected (no test script, pytest/tox config, Cargo or Go module root).");
|
|
13732
|
+
chosen = await ask("Enter the suite command to trust (empty to skip): ") || void 0;
|
|
13733
|
+
}
|
|
13734
|
+
if (chosen) {
|
|
13735
|
+
const problem = writeVerifyCommand(cwd, chosen);
|
|
13736
|
+
if (problem) {
|
|
13737
|
+
out2(`verify.command was not written: ${problem}`);
|
|
13738
|
+
} else {
|
|
13739
|
+
out2(`Wrote verify.command: ${chosen} (budget 300s) to ${POLICY_FILE}. Commit it: with a --base, verify reads`);
|
|
13740
|
+
out2("the policy from that commit, so an uncommitted command is invisible to CI.");
|
|
13741
|
+
verifyCommand = chosen;
|
|
13742
|
+
wrote.push(POLICY_FILE);
|
|
13743
|
+
}
|
|
13744
|
+
} else {
|
|
13745
|
+
out2("verify.command was not configured. CI will fail closed (exit 2) until it is; add it to");
|
|
13746
|
+
out2(`${POLICY_FILE} under verify: and re-run \`tamperward doctor\`.`);
|
|
13747
|
+
}
|
|
13748
|
+
}
|
|
13749
|
+
}
|
|
13750
|
+
if (!declined) {
|
|
13751
|
+
header(5);
|
|
13752
|
+
out2("`verify` runs your suite twice: as-is (visible), and with every protected test/snapshot/config");
|
|
13753
|
+
out2("file restored from the trusted base (pristine). The pair is what no diff rule can fake.");
|
|
13754
|
+
if (!verifyCommand) {
|
|
13755
|
+
out2("Skipped: no verify.command. Standalone `tamperward verify` would report CANNOT_VERIFY");
|
|
13756
|
+
out2("(exit 2) here \u2014 it fails closed rather than pretending to verify.");
|
|
13757
|
+
} else if (!localVerifySupported) {
|
|
13758
|
+
out2(`Skipped: checkpointed-local verify is unsupported on ${platform}; \`tamperward verify\` fails closed`);
|
|
13759
|
+
out2("(exit 2) before running anything. Configure a digest-pinned container verifier for this platform.");
|
|
13760
|
+
} else if (!head) {
|
|
13761
|
+
out2("Skipped: no commit to anchor the pristine copy to. Commit, then run: tamperward verify");
|
|
13762
|
+
} else if (await confirm(`Run the first verification now (${verifyCommand}, twice)?`, true, true)) {
|
|
13763
|
+
let summary = null;
|
|
13764
|
+
const verifyOpts = { cwd, onVerdict: (v) => {
|
|
13765
|
+
summary = v;
|
|
13766
|
+
} };
|
|
13767
|
+
if (opts.base) verifyOpts.base = opts.base;
|
|
13768
|
+
const code2 = runners.verify(verifyOpts);
|
|
13769
|
+
verifyResult = { code: code2, summary };
|
|
13770
|
+
out2("");
|
|
13771
|
+
for (const line of explainVerdict(summary, code2)) out2(line);
|
|
13772
|
+
out2(`(verify exited ${code2}; onboarding reports it and changes nothing about it.)`);
|
|
13773
|
+
} else {
|
|
13774
|
+
out2(`Skipped. Run it any time: tamperward verify${opts.base ? ` --base ${opts.base}` : ""}`);
|
|
13775
|
+
}
|
|
13776
|
+
}
|
|
13777
|
+
if (!declined && !opts.skipDemo) {
|
|
13778
|
+
header(6);
|
|
13779
|
+
out2("Optional: see a finding without risking anything. TamperWard creates a detached temporary");
|
|
13780
|
+
out2("worktree of HEAD, adds `.skip` to one test block THERE, runs `check --worktree` on it, then");
|
|
13781
|
+
out2("removes the worktree. Your working tree is not touched; its fingerprint is shown before and after.");
|
|
13782
|
+
const wanted = scripted ? Boolean(opts.demo) : opts.demo ? true : await confirm("Run the demonstration?", false, false);
|
|
13783
|
+
if (!wanted) {
|
|
13784
|
+
out2("Demo skipped.");
|
|
13785
|
+
} else if (!head) {
|
|
13786
|
+
out2("Demo skipped: no commit to build disposable state from.");
|
|
13787
|
+
} else {
|
|
13788
|
+
runDemo(cwd, head, runners, out2);
|
|
13789
|
+
}
|
|
13790
|
+
}
|
|
13791
|
+
header(7);
|
|
13792
|
+
out2("Local wiring steers the agent and gates commits; only the repository can make CI binding.");
|
|
13793
|
+
repo = opts.repo ?? inferGitHubRepo(cwd);
|
|
13794
|
+
const doctorCommand = `tamperward doctor --github --repo ${repo ?? "OWNER/REPO"} --branch ${opts.branch ?? "<default-branch>"}`;
|
|
13795
|
+
if (opts.noGithub) {
|
|
13796
|
+
out2("--no-github: the GitHub API is not consulted, so nothing below is reported as enforced.");
|
|
13797
|
+
for (const line of MANUAL_CONTROLS) out2(line);
|
|
13798
|
+
out2(`Then verify it with: ${doctorCommand}`);
|
|
13799
|
+
} else if (!repo) {
|
|
13800
|
+
out2("No GitHub repository could be determined (no github.com origin; pass --repo OWNER/REPO).");
|
|
13801
|
+
for (const line of MANUAL_CONTROLS) out2(line);
|
|
13802
|
+
out2(`Then verify it with: ${doctorCommand}`);
|
|
13803
|
+
} else {
|
|
13804
|
+
out2(`Repository: ${repo}${opts.branch ? `, branch ${opts.branch}` : ""}.`);
|
|
13805
|
+
githubChecked = await confirm(
|
|
13806
|
+
"Check the live GitHub controls now with `doctor --github`? (reads the GitHub API; set GH_TOKEN/GITHUB_TOKEN if needed)",
|
|
13807
|
+
true,
|
|
13808
|
+
true
|
|
13809
|
+
);
|
|
13810
|
+
if (!githubChecked) {
|
|
13811
|
+
for (const line of MANUAL_CONTROLS) out2(line);
|
|
13812
|
+
out2(`Then verify it with: ${doctorCommand}`);
|
|
13813
|
+
}
|
|
13814
|
+
}
|
|
13815
|
+
header(8);
|
|
13816
|
+
const doctorOpts = { cwd };
|
|
13817
|
+
if (githubChecked && repo) {
|
|
13818
|
+
doctorOpts.github = true;
|
|
13819
|
+
doctorOpts.repo = repo;
|
|
13820
|
+
if (opts.branch) doctorOpts.branch = opts.branch;
|
|
13821
|
+
}
|
|
13822
|
+
doctorOutcome = runners.doctor(doctorOpts);
|
|
13823
|
+
for (const line of doctorOutcome.summary) out2(line);
|
|
13824
|
+
if (doctorOutcome.failure) out2(`tamperward doctor: ${doctorOutcome.failure.message}`);
|
|
13825
|
+
for (const check2 of doctorOutcome.checks) renderCheck(check2, out2);
|
|
13826
|
+
const posture = postureOf(doctorOutcome);
|
|
13827
|
+
const warnings = doctorOutcome.checks.filter((c) => c.state === "WARN").length;
|
|
13828
|
+
const broken = doctorOutcome.checks.filter((c) => c.state === "BROKEN");
|
|
13829
|
+
out2("");
|
|
13830
|
+
out2(
|
|
13831
|
+
`POSTURE: ${posture}` + (posture === "READY WITH WARNINGS" ? ` \u2014 ${warnings} warning(s) above` : "") + (posture === "BROKEN" ? ` \u2014 ${broken.map((c) => c.id).join(", ")}` : "") + (posture === "INCOMPLETE" && doctorOutcome.failure ? ` \u2014 doctor could not certify: ${doctorOutcome.failure.id}` : "")
|
|
13832
|
+
);
|
|
13833
|
+
if (doctorOutcome.github) {
|
|
13834
|
+
out2(`GitHub repository authority: ENFORCED \u2014 doctor --github verified ${doctorOutcome.github.repo}#${doctorOutcome.github.branch}`);
|
|
13835
|
+
out2(" requires the tamperward check, Code Owner review, and stale-approval dismissal.");
|
|
13836
|
+
} else {
|
|
13837
|
+
out2("GitHub repository authority: NOT VERIFIED \u2014 locally configured only. Until doctor --github");
|
|
13838
|
+
out2(` confirms the three controls, CI is advisory. Run: ${doctorCommand}`);
|
|
13839
|
+
if (githubChecked && doctorOutcome.failure?.id === "github-authority") {
|
|
13840
|
+
for (const line of MANUAL_CONTROLS) out2(line);
|
|
13841
|
+
}
|
|
13842
|
+
}
|
|
13843
|
+
if (declined) out2("Nothing was written this run; the posture above describes the repository as it was.");
|
|
13844
|
+
if (verifyResult) out2(`First verification: ${verifyResult.summary?.verdict ?? `exit ${verifyResult.code}`}.`);
|
|
13845
|
+
header(9);
|
|
13846
|
+
out2("Day to day:");
|
|
13847
|
+
out2(" tamperward check --worktree the stop-sweep view of everything changed since HEAD");
|
|
13848
|
+
out2(` tamperward verify --base ${opts.base ?? "<base>"}${opts.base ? "" : " "}pristine re-execution against a base the agent cannot rewrite`);
|
|
13849
|
+
out2(` tamperward run --base ${opts.base ?? "<base>"} -- <agent...> the enforcement envelope (Linux; fails closed elsewhere)`);
|
|
13850
|
+
out2(` ${doctorCommand}`);
|
|
13851
|
+
out2("When the final verification matters most \u2014 a release, a merge into the protected branch \u2014 prefer");
|
|
13852
|
+
out2("the digest-pinned container verifier (verify.backend: container, image: <ref>@sha256:<digest>):");
|
|
13853
|
+
out2("it owns the runtime and dependencies, mounts the tree read-only and disables the network, so the");
|
|
13854
|
+
out2("candidate cannot reach the verifier from the same host. `run` deliberately does not offer it.");
|
|
13855
|
+
if (wrote.length) out2(`Commit what onboarding wrote (${[...new Set(wrote)].join(", ")}) so the gate travels with the repository.`);
|
|
13856
|
+
return posture === "READY" || posture === "READY WITH WARNINGS" ? 0 : 1;
|
|
13857
|
+
} catch (e) {
|
|
13858
|
+
if (e instanceof Aborted) {
|
|
13859
|
+
out2("");
|
|
13860
|
+
out2(`tamperward onboard: aborted during step ${step} (${STEPS[step - 1]}).`);
|
|
13861
|
+
out2(
|
|
13862
|
+
wrote.length ? `Written so far: ${[...new Set(wrote)].join(", ")} \u2014 all through init, all idempotent; nothing is half-applied.` : "Nothing was written."
|
|
13863
|
+
);
|
|
13864
|
+
out2("Re-run `tamperward onboard` to continue from the same place, or `tamperward doctor` to see the current posture.");
|
|
13865
|
+
return 2;
|
|
13866
|
+
}
|
|
13867
|
+
throw e;
|
|
13868
|
+
} finally {
|
|
13869
|
+
prompt.asker?.close();
|
|
13870
|
+
}
|
|
13871
|
+
}
|
|
13872
|
+
function renderCheck(check2, out2) {
|
|
13873
|
+
if (check2.id === "observer") out2(`tamperward doctor: transient observer: ${check2.detail}`);
|
|
13874
|
+
else out2(`tamperward doctor: [${check2.state}] ${check2.id} \u2014 ${check2.detail}`);
|
|
13875
|
+
}
|
|
13876
|
+
function postureOf(outcome) {
|
|
13877
|
+
if (outcome.failure) return "INCOMPLETE";
|
|
13878
|
+
if (outcome.checks.some((c) => c.state === "BROKEN")) return "BROKEN";
|
|
13879
|
+
if (outcome.checks.some((c) => c.state === "WARN")) return "READY WITH WARNINGS";
|
|
13880
|
+
return "READY";
|
|
13881
|
+
}
|
|
13882
|
+
function runDemo(cwd, head, runners, out2) {
|
|
13883
|
+
const files = (git5(cwd, ["ls-tree", "-r", "-z", head]) ?? "").split("\0").filter(Boolean).flatMap((row) => {
|
|
13884
|
+
const tab = row.indexOf(" ");
|
|
13885
|
+
if (tab < 0) return [];
|
|
13886
|
+
const mode = row.slice(0, tab).split(" ")[0];
|
|
13887
|
+
const path = row.slice(tab + 1);
|
|
13888
|
+
return (mode === "100644" || mode === "100755") && JS_TEST_PATH.test(path) ? [path] : [];
|
|
13889
|
+
});
|
|
13890
|
+
let target = null;
|
|
13891
|
+
for (const path of files) {
|
|
13892
|
+
const before2 = git5(cwd, ["show", `${head}:${path}`]);
|
|
13893
|
+
if (before2 === null || !JS_TEST_BLOCK.test(before2)) continue;
|
|
13894
|
+
target = { path, before: before2, after: before2.replace(JS_TEST_BLOCK, "$1$2.skip$3") };
|
|
13895
|
+
break;
|
|
13896
|
+
}
|
|
13897
|
+
if (!target) {
|
|
13898
|
+
out2("Demo skipped: no JavaScript test block (it/test/describe) at HEAD to demonstrate on.");
|
|
13899
|
+
return;
|
|
13900
|
+
}
|
|
13901
|
+
const before = treeFingerprint(cwd);
|
|
13902
|
+
out2(`working tree fingerprint before: ${before.slice(0, 16)}`);
|
|
13903
|
+
const wt = mkdtempSync6(join20(tmpdir6(), "tw-onboard-demo-"));
|
|
13904
|
+
try {
|
|
13905
|
+
if (git5(cwd, ["worktree", "add", "--detach", wt, head]) === null) {
|
|
13906
|
+
out2("Demo skipped: could not create a temporary worktree (git worktree add failed).");
|
|
13907
|
+
return;
|
|
13908
|
+
}
|
|
13909
|
+
const targetPath = join20(wt, target.path);
|
|
13910
|
+
const targetStat = lstatSync6(targetPath, { throwIfNoEntry: false });
|
|
13911
|
+
if (!targetStat || !targetStat.isFile() || targetStat.isSymbolicLink()) {
|
|
13912
|
+
out2("Demo skipped: the selected test is not a regular file in the disposable worktree.");
|
|
13913
|
+
return;
|
|
13914
|
+
}
|
|
13915
|
+
let worktreeRoot;
|
|
13916
|
+
let realTarget;
|
|
13917
|
+
try {
|
|
13918
|
+
worktreeRoot = realpathSync8(wt);
|
|
13919
|
+
realTarget = realpathSync8(targetPath);
|
|
13920
|
+
} catch {
|
|
13921
|
+
out2("Demo skipped: the selected test could not be resolved safely inside the disposable worktree.");
|
|
13922
|
+
return;
|
|
13923
|
+
}
|
|
13924
|
+
const prefix = worktreeRoot.endsWith(sep4) ? worktreeRoot : worktreeRoot + sep4;
|
|
13925
|
+
if (!realTarget.startsWith(prefix)) {
|
|
13926
|
+
out2("Demo skipped: the selected test resolves outside the disposable worktree.");
|
|
13927
|
+
return;
|
|
13928
|
+
}
|
|
13929
|
+
out2(`disposable worktree: ${wt}`);
|
|
13930
|
+
out2(`weakening move: ${target.path} \u2014 the first test block becomes .skip (only in the disposable copy)`);
|
|
13931
|
+
atomicReplaceFile(targetPath, target.after, targetStat.mode & 511);
|
|
13932
|
+
out2("");
|
|
13933
|
+
out2(`$ tamperward check --worktree (in ${wt})`);
|
|
13934
|
+
const code2 = runners.check({ cwd: wt, worktree: true });
|
|
13935
|
+
out2("");
|
|
13936
|
+
out2(`check exited ${code2}: ${code2 === 1 ? "a blocking finding, which is what the hook, pre-commit and CI would all report" : "no blocking finding was reported"}.`);
|
|
13937
|
+
} finally {
|
|
13938
|
+
git5(cwd, ["worktree", "remove", "--force", wt]);
|
|
13939
|
+
git5(cwd, ["worktree", "prune"]);
|
|
13940
|
+
rmSync7(wt, { recursive: true, force: true });
|
|
13941
|
+
}
|
|
13942
|
+
const after = treeFingerprint(cwd);
|
|
13943
|
+
out2(`working tree fingerprint after: ${after.slice(0, 16)}`);
|
|
13944
|
+
if (after === before) {
|
|
13945
|
+
out2("Identical: your working tree was restored byte-for-byte (it was never edited; the disposable worktree is gone).");
|
|
13946
|
+
} else {
|
|
13947
|
+
out2("WARNING: the fingerprint moved. The demo never wrote into your working tree, so something else");
|
|
13948
|
+
out2("changed it while the demo ran; inspect with `git status`.");
|
|
13949
|
+
}
|
|
13950
|
+
}
|
|
13951
|
+
|
|
13433
13952
|
// src/cli/main.ts
|
|
13434
13953
|
function parseAllow(args) {
|
|
13435
13954
|
const o = {};
|
|
@@ -13461,6 +13980,22 @@ function parseInit(args) {
|
|
|
13461
13980
|
}
|
|
13462
13981
|
return o;
|
|
13463
13982
|
}
|
|
13983
|
+
function parseOnboard(args) {
|
|
13984
|
+
const o = {};
|
|
13985
|
+
for (let i = 0; i < args.length; i++) {
|
|
13986
|
+
const a = args[i];
|
|
13987
|
+
if (a === "--cwd") o.cwd = args[++i];
|
|
13988
|
+
else if (a === "--base") o.base = args[++i];
|
|
13989
|
+
else if (a === "--repo") o.repo = args[++i];
|
|
13990
|
+
else if (a === "--branch") o.branch = args[++i];
|
|
13991
|
+
else if (a === "--verify-command") o.verifyCommand = args[++i];
|
|
13992
|
+
else if (a === "--skip-demo") o.skipDemo = true;
|
|
13993
|
+
else if (a === "--demo") o.demo = true;
|
|
13994
|
+
else if (a === "--no-github") o.noGithub = true;
|
|
13995
|
+
else if (a === "--yes") o.yes = true;
|
|
13996
|
+
}
|
|
13997
|
+
return o;
|
|
13998
|
+
}
|
|
13464
13999
|
function parseDoctor(args) {
|
|
13465
14000
|
const o = {};
|
|
13466
14001
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -13614,6 +14149,21 @@ function validateCliArgs(cmd, args) {
|
|
|
13614
14149
|
}
|
|
13615
14150
|
}).error;
|
|
13616
14151
|
}
|
|
14152
|
+
if (cmd === "onboard") {
|
|
14153
|
+
const parsed = validateFlatArgs(args, {
|
|
14154
|
+
flags: ["--yes", "--skip-demo", "--demo", "--no-github"],
|
|
14155
|
+
values: {
|
|
14156
|
+
"--cwd": "string",
|
|
14157
|
+
"--base": "string",
|
|
14158
|
+
"--repo": "string",
|
|
14159
|
+
"--branch": "string",
|
|
14160
|
+
"--verify-command": "string"
|
|
14161
|
+
}
|
|
14162
|
+
});
|
|
14163
|
+
if (parsed.error) return parsed.error;
|
|
14164
|
+
if (parsed.seen.has("--demo") && parsed.seen.has("--skip-demo")) return "--demo cannot be combined with --skip-demo";
|
|
14165
|
+
return void 0;
|
|
14166
|
+
}
|
|
13617
14167
|
if (cmd === "verify") {
|
|
13618
14168
|
return validateFlatArgs(args, {
|
|
13619
14169
|
flags: ["--json", "--keep", "--require-ancestor"],
|
|
@@ -13704,6 +14254,21 @@ Formats:
|
|
|
13704
14254
|
2 when it cannot adjudicate (fails closed)
|
|
13705
14255
|
tamperward allow <rule> --reason "..." record a human sign-off (local audit ledger)
|
|
13706
14256
|
[--file F] [--cwd D]
|
|
14257
|
+
tamperward onboard [--yes] [--cwd D] guided first-run setup: preflight, the
|
|
14258
|
+
[--base R] [--repo O/R] init plan with a confirmation before any
|
|
14259
|
+
[--branch B] [--verify-command C] write, canonical init, explicit verifier
|
|
14260
|
+
[--skip-demo | --demo] acceptance (a detected suite command is
|
|
14261
|
+
[--no-github] never written without it), the first
|
|
14262
|
+
verify explained, an optional demo on a
|
|
14263
|
+
disposable worktree, GitHub authority via
|
|
14264
|
+
doctor --github or the manual controls,
|
|
14265
|
+
and a READY / READY WITH WARNINGS /
|
|
14266
|
+
BROKEN / INCOMPLETE posture from doctor.
|
|
14267
|
+
Non-interactive stdin refuses unless
|
|
14268
|
+
--yes scripts it (then --verify-command
|
|
14269
|
+
is the only way to configure verify).
|
|
14270
|
+
Exit: 0 READY[ WITH WARNINGS], 1
|
|
14271
|
+
BROKEN/INCOMPLETE, 2 refused or aborted
|
|
13707
14272
|
tamperward init [--dry-run] wire the policy file plus every
|
|
13708
14273
|
[--force-workflow] [--cwd D] enforcement point: Claude Code hooks,
|
|
13709
14274
|
pre-commit, CI. Idempotent; never
|
|
@@ -13757,6 +14322,8 @@ function main(argv2) {
|
|
|
13757
14322
|
return runInit(parseInit(rest));
|
|
13758
14323
|
case "doctor":
|
|
13759
14324
|
return runDoctor(parseDoctor(rest));
|
|
14325
|
+
case "onboard":
|
|
14326
|
+
return runOnboard(parseOnboard(rest));
|
|
13760
14327
|
case "watch":
|
|
13761
14328
|
return runWatch(rest);
|
|
13762
14329
|
case "verify":
|
|
@@ -13783,17 +14350,25 @@ function main(argv2) {
|
|
|
13783
14350
|
}
|
|
13784
14351
|
}
|
|
13785
14352
|
function guardedMain(argv2, stderr = process.stderr) {
|
|
13786
|
-
|
|
13787
|
-
return main(argv2);
|
|
13788
|
-
} catch (e) {
|
|
14353
|
+
const failClosed2 = (e) => {
|
|
13789
14354
|
const first = (e instanceof Error ? e.message : String(e)).split("\n").map((l) => l.trim()).find(Boolean) ?? "";
|
|
13790
14355
|
stderr.write(`tamperward: ${first || "unexpected failure"}
|
|
13791
14356
|
`);
|
|
13792
14357
|
return 2;
|
|
14358
|
+
};
|
|
14359
|
+
try {
|
|
14360
|
+
const result = main(argv2);
|
|
14361
|
+
return typeof result === "number" ? result : result.catch(failClosed2);
|
|
14362
|
+
} catch (e) {
|
|
14363
|
+
return failClosed2(e);
|
|
13793
14364
|
}
|
|
13794
14365
|
}
|
|
13795
14366
|
|
|
13796
14367
|
// src/cli/index.ts
|
|
13797
14368
|
var argv = process.argv.slice(2);
|
|
14369
|
+
var exit = (code2) => {
|
|
14370
|
+
if (code2 >= 0) process.exit(code2);
|
|
14371
|
+
};
|
|
13798
14372
|
var code = guardedMain(argv);
|
|
13799
|
-
if (code
|
|
14373
|
+
if (typeof code === "number") exit(code);
|
|
14374
|
+
else void code.then(exit);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.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",
|