tamperward 2.14.1 → 2.15.1
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 +21 -1
- package/dist/cli/index.js +297 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -225,6 +225,26 @@ but this generated GitHub-hosted authority refuses deterministically and tells y
|
|
|
225
225
|
much outer time is required, so a custom runner/workflow can provide it instead of
|
|
226
226
|
GitHub killing verification mid-verdict.
|
|
227
227
|
|
|
228
|
+
Since **2.15.0**, `tamperward doctor` is also the one-shot installation/posture
|
|
229
|
+
report promised by `init`'s security model. It reuses `init`'s canonical wiring
|
|
230
|
+
planner rather than maintaining a second definition of “installed correctly”, and
|
|
231
|
+
reports named `OK`, `WARN`, or `BROKEN` checks for policy/schema, Claude hooks,
|
|
232
|
+
pre-commit, CI authority wiring, CODEOWNERS, workflow permissions, binary/pin
|
|
233
|
+
alignment, verifier trust mode + declared inputs, platform residuals, CI verifier
|
|
234
|
+
outer time, observer health, and (with `--github`) repository authority. Use
|
|
235
|
+
`--json` for one machine-readable document with `authoritative: true|false`.
|
|
236
|
+
|
|
237
|
+
**2.15.1 closes an authority-reporting gap in that surface.** The permission check now
|
|
238
|
+
includes job-level overrides, not only workflow-root permissions; a policy schema newer
|
|
239
|
+
than the running binary is BROKEN rather than certifiable; and the exact workflow(s)
|
|
240
|
+
selected by `--workflow` or discovered as verifier authorities are also the workflow(s)
|
|
241
|
+
used for CI-wiring and token-permission posture. A custom verifier workflow therefore
|
|
242
|
+
does not get judged against an unrelated/missing generated file, and a second verifier
|
|
243
|
+
workflow with a write-scoped job cannot hide behind a safe canonical workflow.
|
|
244
|
+
|
|
245
|
+
Local early-layer gaps remain posture findings rather than silently changing the
|
|
246
|
+
existing generated-CI exit contract; hard CI/GitHub validation failures still exit 2.
|
|
247
|
+
|
|
228
248
|
Legitimate exceptions are out-of-band PR labels, `tamperward:allow:<rule>@<head-sha>`.
|
|
229
249
|
The workflow passes the head SHA to both steps through `TAMPERWARD_OOB_HEAD`, so an
|
|
230
250
|
approval is bound to the exact commit it was granted for and a new push invalidates it.
|
|
@@ -364,7 +384,7 @@ option can never be reinterpreted as the agent command.
|
|
|
364
384
|
| --- | --- |
|
|
365
385
|
| `check` | one view — `--staged` · `--worktree` · `--diff <base>...<head>` — plus `--format text\|json\|github\|auto` (default `auto`) · `--json` (alias for `--format json`) · `--cwd <dir>` |
|
|
366
386
|
| `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>` |
|
|
367
|
-
| `doctor` | `--base <rev>` (trusted policy revision) · `--workflow <path>`
|
|
387
|
+
| `doctor` | `--base <rev>` (trusted policy revision) · `--workflow <path>` · `--cwd <dir>` · `--json` · `--github` · `--repo <owner/repo>` · `--branch <name>` — read-only installation/authority posture plus CI verifier outer-time validation |
|
|
368
388
|
| `run` | `--base <rev>` · `--cmd <suite command>` · `--budget <seconds>` (per verifier suite) · `--agent-budget <seconds>` (optional wrapped-agent wall clock) · `--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...>` |
|
|
369
389
|
| `allow` | `<rule>` · `--file <path>` · `--reason "<why>"` (required) · `--cwd <dir>` |
|
|
370
390
|
| `init` | `--cwd <dir>` · `--dry-run` · `--force-workflow` |
|
package/dist/cli/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from "node:path";
|
|
|
10
10
|
// src/policy.ts
|
|
11
11
|
import picomatch from "picomatch";
|
|
12
12
|
var POLICY_FILE = ".tamperward.yml";
|
|
13
|
+
var POLICY_VERSION = 1;
|
|
13
14
|
var BLOCK_SINCE = {};
|
|
14
15
|
function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
|
|
15
16
|
const out2 = { ...rules };
|
|
@@ -8375,9 +8376,21 @@ import { existsSync as existsSync10, readFileSync as readFileSync13, readdirSync
|
|
|
8375
8376
|
import { join as join13, resolve as resolve4 } from "node:path";
|
|
8376
8377
|
import { parse as parse3 } from "yaml";
|
|
8377
8378
|
var VERIFY_COMMAND = /\btamperward(?:@\S+)?\s+verify\b/;
|
|
8378
|
-
function err(message) {
|
|
8379
|
-
|
|
8379
|
+
function err(message, opts, id = "doctor", checks = []) {
|
|
8380
|
+
if (opts?.json) {
|
|
8381
|
+
const report2 = {
|
|
8382
|
+
command: "doctor",
|
|
8383
|
+
authoritative: false,
|
|
8384
|
+
checks: [
|
|
8385
|
+
...checks.filter((check2) => check2.id !== id),
|
|
8386
|
+
{ id, state: "BROKEN", detail: message }
|
|
8387
|
+
]
|
|
8388
|
+
};
|
|
8389
|
+
process.stdout.write(JSON.stringify(report2) + "\n");
|
|
8390
|
+
} else {
|
|
8391
|
+
process.stderr.write(`tamperward doctor: ${message}
|
|
8380
8392
|
`);
|
|
8393
|
+
}
|
|
8381
8394
|
return 2;
|
|
8382
8395
|
}
|
|
8383
8396
|
function policyFor(opts, cwd) {
|
|
@@ -8388,6 +8401,219 @@ function policyFor(opts, cwd) {
|
|
|
8388
8401
|
function asMapping(value) {
|
|
8389
8402
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
8390
8403
|
}
|
|
8404
|
+
function wiringState(status, authority) {
|
|
8405
|
+
if (status === "ok") return "OK";
|
|
8406
|
+
if (status === "skip") return "WARN";
|
|
8407
|
+
return authority ? "BROKEN" : "WARN";
|
|
8408
|
+
}
|
|
8409
|
+
function pinsInFile(path) {
|
|
8410
|
+
if (!existsSync10(path)) return [];
|
|
8411
|
+
let src = "";
|
|
8412
|
+
try {
|
|
8413
|
+
src = readFileSync13(path, "utf8");
|
|
8414
|
+
} catch {
|
|
8415
|
+
return [];
|
|
8416
|
+
}
|
|
8417
|
+
return Array.from(src.matchAll(/\btamperward@((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\b/g), (m) => m[1]);
|
|
8418
|
+
}
|
|
8419
|
+
function permissionWrites(value) {
|
|
8420
|
+
if (typeof value === "string") {
|
|
8421
|
+
if (/write/i.test(value)) return [value];
|
|
8422
|
+
if (/^(?:read-all|none)$/i.test(value)) return [];
|
|
8423
|
+
return null;
|
|
8424
|
+
}
|
|
8425
|
+
const mapping = asMapping(value);
|
|
8426
|
+
if (!mapping) return null;
|
|
8427
|
+
return Object.entries(mapping).filter(([, permission]) => typeof permission === "string" && /write/i.test(permission)).map(([name, permission]) => `${name}=${String(permission)}`);
|
|
8428
|
+
}
|
|
8429
|
+
function workflowPermissionCheck(cwd, workflowRels = [".github/workflows/tamperward.yml"]) {
|
|
8430
|
+
const broken = [];
|
|
8431
|
+
const warnings = [];
|
|
8432
|
+
for (const rel of workflowRels) {
|
|
8433
|
+
const path = resolve4(cwd, rel);
|
|
8434
|
+
if (!existsSync10(path)) {
|
|
8435
|
+
broken.push(`${rel} is missing`);
|
|
8436
|
+
continue;
|
|
8437
|
+
}
|
|
8438
|
+
let doc;
|
|
8439
|
+
try {
|
|
8440
|
+
doc = parse3(readFileSync13(path, "utf8"));
|
|
8441
|
+
} catch (e) {
|
|
8442
|
+
broken.push(`${rel} is not valid YAML (${e instanceof Error ? e.message : String(e)})`);
|
|
8443
|
+
continue;
|
|
8444
|
+
}
|
|
8445
|
+
const root = asMapping(doc);
|
|
8446
|
+
if (!root) {
|
|
8447
|
+
broken.push(`${rel} is not a YAML mapping`);
|
|
8448
|
+
continue;
|
|
8449
|
+
}
|
|
8450
|
+
const rootPermissions = root.permissions;
|
|
8451
|
+
if (rootPermissions === void 0) {
|
|
8452
|
+
broken.push(`${rel}: workflow does not declare least-privilege permissions; repository defaults could grant write access`);
|
|
8453
|
+
} else {
|
|
8454
|
+
const writes = permissionWrites(rootPermissions);
|
|
8455
|
+
if (writes === null) {
|
|
8456
|
+
broken.push(`${rel}: workflow permissions shape is unsupported`);
|
|
8457
|
+
} else if (writes.length) {
|
|
8458
|
+
broken.push(`${rel}: workflow grants write permission: ${writes.join(", ")}`);
|
|
8459
|
+
} else {
|
|
8460
|
+
const mapping = asMapping(rootPermissions);
|
|
8461
|
+
if (mapping && mapping.contents !== "read") {
|
|
8462
|
+
warnings.push(`${rel}: workflow permissions contain no writes but contents: read is not declared`);
|
|
8463
|
+
}
|
|
8464
|
+
}
|
|
8465
|
+
}
|
|
8466
|
+
const jobs = asMapping(root.jobs);
|
|
8467
|
+
if (!jobs) continue;
|
|
8468
|
+
for (const [name, rawJob] of Object.entries(jobs)) {
|
|
8469
|
+
const job = asMapping(rawJob);
|
|
8470
|
+
if (!job || job.permissions === void 0) continue;
|
|
8471
|
+
const writes = permissionWrites(job.permissions);
|
|
8472
|
+
if (writes === null) {
|
|
8473
|
+
broken.push(`${rel} job "${name}": permissions shape is unsupported`);
|
|
8474
|
+
} else if (writes.length) {
|
|
8475
|
+
broken.push(`${rel} job "${name}" grants write permission: ${writes.join(", ")}`);
|
|
8476
|
+
}
|
|
8477
|
+
}
|
|
8478
|
+
}
|
|
8479
|
+
if (broken.length) {
|
|
8480
|
+
return {
|
|
8481
|
+
id: "workflow-permissions",
|
|
8482
|
+
state: "BROKEN",
|
|
8483
|
+
detail: broken.join("; ")
|
|
8484
|
+
};
|
|
8485
|
+
}
|
|
8486
|
+
if (warnings.length) {
|
|
8487
|
+
return {
|
|
8488
|
+
id: "workflow-permissions",
|
|
8489
|
+
state: "WARN",
|
|
8490
|
+
detail: warnings.join("; ")
|
|
8491
|
+
};
|
|
8492
|
+
}
|
|
8493
|
+
return {
|
|
8494
|
+
id: "workflow-permissions",
|
|
8495
|
+
state: "OK",
|
|
8496
|
+
detail: `${workflowRels.join(", ")}: workflow/job token permissions contain no write grants and workflow contents access is read-only`
|
|
8497
|
+
};
|
|
8498
|
+
}
|
|
8499
|
+
function collectLocalPosture(cwd, policy, authorityWorkflows, explicitWorkflow = false) {
|
|
8500
|
+
const checks = [];
|
|
8501
|
+
checks.push(
|
|
8502
|
+
policy.version <= POLICY_VERSION ? { id: "policy", state: "OK", detail: `policy version ${policy.version} is understood (current schema ${POLICY_VERSION})` } : { id: "policy", state: "BROKEN", detail: `policy version ${policy.version} is newer than this binary's schema ${POLICY_VERSION}; authority cannot be certified for unknown policy semantics` }
|
|
8503
|
+
);
|
|
8504
|
+
let plan = [];
|
|
8505
|
+
try {
|
|
8506
|
+
plan = planInit(cwd);
|
|
8507
|
+
} catch (e) {
|
|
8508
|
+
checks.push({
|
|
8509
|
+
id: "installation-plan",
|
|
8510
|
+
state: "BROKEN",
|
|
8511
|
+
detail: `canonical wiring could not be evaluated: ${e instanceof Error ? e.message : String(e)}`
|
|
8512
|
+
});
|
|
8513
|
+
}
|
|
8514
|
+
const byItem = new Map(plan.map((x) => [x.item, x]));
|
|
8515
|
+
const addWiring = (id, item, authority) => {
|
|
8516
|
+
const action = byItem.get(item);
|
|
8517
|
+
if (!action) {
|
|
8518
|
+
checks.push({ id, state: authority ? "BROKEN" : "WARN", detail: `${item} wiring could not be evaluated` });
|
|
8519
|
+
return;
|
|
8520
|
+
}
|
|
8521
|
+
checks.push({
|
|
8522
|
+
id,
|
|
8523
|
+
state: wiringState(action.status, authority),
|
|
8524
|
+
detail: action.status === "ok" ? action.detail : `${action.status}: ${action.detail}`
|
|
8525
|
+
});
|
|
8526
|
+
};
|
|
8527
|
+
addWiring("claude-hooks", "agent", true);
|
|
8528
|
+
addWiring("pre-commit", "pre-commit", false);
|
|
8529
|
+
const canonicalWorkflow = ".github/workflows/tamperward.yml";
|
|
8530
|
+
if (authorityWorkflows?.length) {
|
|
8531
|
+
const normalized = authorityWorkflows.map((x) => x.replace(/\\/g, "/"));
|
|
8532
|
+
if (explicitWorkflow) {
|
|
8533
|
+
checks.push({
|
|
8534
|
+
id: "ci-wiring",
|
|
8535
|
+
state: "OK",
|
|
8536
|
+
detail: `explicit verifier authority workflow: ${normalized.join(", ")}; verifier step/timeout validated by doctor`
|
|
8537
|
+
});
|
|
8538
|
+
} else if (normalized.includes(canonicalWorkflow)) {
|
|
8539
|
+
addWiring("ci-wiring", "ci", true);
|
|
8540
|
+
} else {
|
|
8541
|
+
checks.push({
|
|
8542
|
+
id: "ci-wiring",
|
|
8543
|
+
state: "OK",
|
|
8544
|
+
detail: `discovered verifier authority workflow(s): ${normalized.join(", ")}; verifier step/timeout validated by doctor`
|
|
8545
|
+
});
|
|
8546
|
+
}
|
|
8547
|
+
} else {
|
|
8548
|
+
addWiring("ci-wiring", "ci", true);
|
|
8549
|
+
}
|
|
8550
|
+
addWiring("codeowners", "codeowners", true);
|
|
8551
|
+
checks.push(workflowPermissionCheck(cwd, authorityWorkflows));
|
|
8552
|
+
const pins = [
|
|
8553
|
+
...pinsInFile(join13(cwd, ".claude", "settings.json")),
|
|
8554
|
+
...pinsInFile(join13(cwd, ".git", "hooks", "pre-commit")),
|
|
8555
|
+
...pinsInFile(join13(cwd, ".husky", "pre-commit"))
|
|
8556
|
+
];
|
|
8557
|
+
const below = pins.filter((pin) => (compareVersions(pin, TW_VERSION) ?? 0) < 0);
|
|
8558
|
+
const above = pins.filter((pin) => (compareVersions(TW_VERSION, pin) ?? 0) < 0);
|
|
8559
|
+
checks.push(
|
|
8560
|
+
below.length ? { id: "binary-version", state: "BROKEN", detail: `wiring pins older TamperWard version(s): ${Array.from(new Set(below)).join(", ")}; running binary is ${TW_VERSION}` } : above.length ? { id: "binary-version", state: "WARN", detail: `repository wiring targets newer TamperWard version(s): ${Array.from(new Set(above)).join(", ")}; running binary is ${TW_VERSION}` } : pins.length ? { id: "binary-version", state: "OK", detail: `wiring pins agree with running TamperWard ${TW_VERSION}` } : { id: "binary-version", state: "WARN", detail: `no canonical TamperWard version pin was found; running binary is ${TW_VERSION}` }
|
|
8561
|
+
);
|
|
8562
|
+
if (!policy.verify?.command) {
|
|
8563
|
+
checks.push({ id: "verifier", state: "BROKEN", detail: "verify.command is not configured" });
|
|
8564
|
+
} else {
|
|
8565
|
+
const backend = policy.verify.backend ?? "local";
|
|
8566
|
+
const inputs = policy.verify.inputs?.length ?? 0;
|
|
8567
|
+
checks.push({
|
|
8568
|
+
id: "verifier",
|
|
8569
|
+
state: backend === "container" ? "OK" : "WARN",
|
|
8570
|
+
detail: backend === "container" ? `isolated-container verifier; budget ${policy.verify.budget}s/stage; ${inputs} declared verify.inputs glob(s); runtime/image availability is checked by verify` : `checkpointed-local verifier; budget ${policy.verify.budget}s/stage; ${inputs} declared verify.inputs glob(s); same-host/self-restoring mutation remains a documented residual`
|
|
8571
|
+
});
|
|
8572
|
+
}
|
|
8573
|
+
checks.push(
|
|
8574
|
+
process.platform === "linux" ? { id: "platform", state: "OK", detail: "Linux: POSIX generated wiring and /proc runtime/quiescence controls are available" } : process.platform === "win32" ? { id: "platform", state: "WARN", detail: "Windows: generated shell wiring has POSIX assumptions and Linux /proc survivor controls are unavailable" } : { id: "platform", state: "WARN", detail: `${process.platform}: POSIX wiring is available, but Linux /proc survivor controls are unavailable` }
|
|
8575
|
+
);
|
|
8576
|
+
return checks;
|
|
8577
|
+
}
|
|
8578
|
+
function observerCheck(cwd) {
|
|
8579
|
+
const observer = watcherTelemetry(defaultEventLog(cwd));
|
|
8580
|
+
if (observer.state === "healthy" && observer.health) {
|
|
8581
|
+
return {
|
|
8582
|
+
id: "observer",
|
|
8583
|
+
state: "OK",
|
|
8584
|
+
detail: `healthy ${observer.health.backend}; ${observer.health.watched_dirs} watched dir(s), ${observer.health.event_count} event(s); advisory telemetry only`
|
|
8585
|
+
};
|
|
8586
|
+
}
|
|
8587
|
+
if (observer.state === "degraded" && observer.health) {
|
|
8588
|
+
return {
|
|
8589
|
+
id: "observer",
|
|
8590
|
+
state: "WARN",
|
|
8591
|
+
detail: `degraded: ${observer.health.error_count} error(s), ${observer.health.dropped_events} dropped event(s); ${observer.reason ?? "telemetry may be incomplete"}; advisory only`
|
|
8592
|
+
};
|
|
8593
|
+
}
|
|
8594
|
+
return {
|
|
8595
|
+
id: "observer",
|
|
8596
|
+
state: "WARN",
|
|
8597
|
+
detail: `unavailable${observer.reason ? ": " + observer.reason : ""}; optional/advisory, and zero events are not evidence of no transient activity`
|
|
8598
|
+
};
|
|
8599
|
+
}
|
|
8600
|
+
function emitReport(opts, checks) {
|
|
8601
|
+
const authoritative = !checks.some((x) => x.state === "BROKEN");
|
|
8602
|
+
if (opts.json) {
|
|
8603
|
+
const report2 = { command: "doctor", authoritative, checks };
|
|
8604
|
+
process.stdout.write(JSON.stringify(report2) + "\n");
|
|
8605
|
+
return;
|
|
8606
|
+
}
|
|
8607
|
+
for (const check2 of checks) {
|
|
8608
|
+
if (check2.id === "observer") {
|
|
8609
|
+
process.stdout.write(`tamperward doctor: transient observer: ${check2.detail}
|
|
8610
|
+
`);
|
|
8611
|
+
} else {
|
|
8612
|
+
process.stdout.write(`tamperward doctor: [${check2.state}] ${check2.id} \u2014 ${check2.detail}
|
|
8613
|
+
`);
|
|
8614
|
+
}
|
|
8615
|
+
}
|
|
8616
|
+
}
|
|
8391
8617
|
function evaluateGitHubProtection(snapshot2, requiredCheck = "tamperward") {
|
|
8392
8618
|
let hasRequiredCheck = false;
|
|
8393
8619
|
let hasCodeOwnerReview = false;
|
|
@@ -8570,10 +8796,21 @@ function runDoctor(opts = {}) {
|
|
|
8570
8796
|
try {
|
|
8571
8797
|
policy = policyFor(opts, cwd);
|
|
8572
8798
|
} catch (e) {
|
|
8573
|
-
return err(
|
|
8799
|
+
return err(
|
|
8800
|
+
e instanceof PolicyError || e instanceof Error ? e.message : String(e),
|
|
8801
|
+
opts,
|
|
8802
|
+
"policy"
|
|
8803
|
+
);
|
|
8574
8804
|
}
|
|
8805
|
+
let postureWorkflows = opts.workflow ? [opts.workflow] : void 0;
|
|
8806
|
+
const fail = (id, message) => err(
|
|
8807
|
+
message,
|
|
8808
|
+
opts,
|
|
8809
|
+
id,
|
|
8810
|
+
collectLocalPosture(cwd, policy, postureWorkflows, Boolean(opts.workflow))
|
|
8811
|
+
);
|
|
8575
8812
|
if (!policy.verify?.command) {
|
|
8576
|
-
return
|
|
8813
|
+
return fail("verifier", "trusted policy has no verify.command; generated CI cannot verify this repository");
|
|
8577
8814
|
}
|
|
8578
8815
|
const workflowRels = [];
|
|
8579
8816
|
if (opts.workflow) {
|
|
@@ -8582,13 +8819,14 @@ function runDoctor(opts = {}) {
|
|
|
8582
8819
|
const workflowDirRel = ".github/workflows";
|
|
8583
8820
|
const workflowDir = resolve4(cwd, workflowDirRel);
|
|
8584
8821
|
if (!existsSync10(workflowDir)) {
|
|
8585
|
-
return
|
|
8822
|
+
return fail("ci-verifier", `${workflowDirRel}: workflow directory does not exist`);
|
|
8586
8823
|
}
|
|
8587
8824
|
let entries;
|
|
8588
8825
|
try {
|
|
8589
8826
|
entries = readdirSync3(workflowDir).filter((name) => /\.ya?ml$/i.test(name)).sort();
|
|
8590
8827
|
} catch (e) {
|
|
8591
|
-
return
|
|
8828
|
+
return fail(
|
|
8829
|
+
"ci-verifier",
|
|
8592
8830
|
`${workflowDirRel}: could not enumerate workflows (${e instanceof Error ? e.message : String(e)})`
|
|
8593
8831
|
);
|
|
8594
8832
|
}
|
|
@@ -8597,50 +8835,62 @@ function runDoctor(opts = {}) {
|
|
|
8597
8835
|
const requiredSecs = requiredVerifierAuthoritySeconds(policy.verify.budget);
|
|
8598
8836
|
const requiredMinutes = Math.ceil(requiredSecs / 60);
|
|
8599
8837
|
let verifyJobCount = 0;
|
|
8838
|
+
const authorityWorkflows = [];
|
|
8600
8839
|
for (const workflowRel of workflowRels) {
|
|
8601
8840
|
const workflowPath = resolve4(cwd, workflowRel);
|
|
8602
8841
|
if (!existsSync10(workflowPath)) {
|
|
8603
|
-
return
|
|
8842
|
+
return fail("ci-verifier", `${workflowRel}: workflow does not exist`);
|
|
8604
8843
|
}
|
|
8605
8844
|
let doc;
|
|
8606
8845
|
try {
|
|
8607
8846
|
doc = parse3(readFileSync13(workflowPath, "utf8"));
|
|
8608
8847
|
} catch (e) {
|
|
8609
|
-
return
|
|
8848
|
+
return fail(
|
|
8849
|
+
"ci-verifier",
|
|
8610
8850
|
`${workflowRel}: workflow is not valid YAML (${e instanceof Error ? e.message : String(e)})`
|
|
8611
8851
|
);
|
|
8612
8852
|
}
|
|
8613
8853
|
const jobs = verifyJobs(doc);
|
|
8614
8854
|
verifyJobCount += jobs.length;
|
|
8855
|
+
if (jobs.length > 0) {
|
|
8856
|
+
authorityWorkflows.push(workflowRel);
|
|
8857
|
+
postureWorkflows = [...authorityWorkflows];
|
|
8858
|
+
}
|
|
8615
8859
|
for (const { name, job } of jobs) {
|
|
8616
8860
|
const timeout = job["timeout-minutes"];
|
|
8617
8861
|
if (timeout === void 0 || timeout === null) {
|
|
8618
|
-
return
|
|
8862
|
+
return fail(
|
|
8863
|
+
"ci-verifier",
|
|
8619
8864
|
`${workflowRel} job "${name}": timeout-minutes is missing; requires at least ${requiredMinutes} minutes`
|
|
8620
8865
|
);
|
|
8621
8866
|
}
|
|
8622
8867
|
if (typeof timeout === "string" && timeout.includes("${{")) {
|
|
8623
|
-
return
|
|
8868
|
+
return fail(
|
|
8869
|
+
"ci-verifier",
|
|
8624
8870
|
`${workflowRel} job "${name}": timeout-minutes must be a static numeric value; requires at least ${requiredMinutes} minutes`
|
|
8625
8871
|
);
|
|
8626
8872
|
}
|
|
8627
8873
|
if (typeof timeout !== "number" || !Number.isFinite(timeout)) {
|
|
8628
|
-
return
|
|
8874
|
+
return fail(
|
|
8875
|
+
"ci-verifier",
|
|
8629
8876
|
`${workflowRel} job "${name}": timeout-minutes must be numeric; got ${JSON.stringify(timeout)}`
|
|
8630
8877
|
);
|
|
8631
8878
|
}
|
|
8632
8879
|
if (timeout <= 0) {
|
|
8633
|
-
return
|
|
8880
|
+
return fail(
|
|
8881
|
+
"ci-verifier",
|
|
8634
8882
|
`${workflowRel} job "${name}": timeout-minutes must be positive; got ${timeout}`
|
|
8635
8883
|
);
|
|
8636
8884
|
}
|
|
8637
8885
|
if (!Number.isInteger(timeout)) {
|
|
8638
|
-
return
|
|
8886
|
+
return fail(
|
|
8887
|
+
"ci-verifier",
|
|
8639
8888
|
`${workflowRel} job "${name}": timeout-minutes must be a whole number; got ${timeout}`
|
|
8640
8889
|
);
|
|
8641
8890
|
}
|
|
8642
8891
|
if (timeout < requiredMinutes) {
|
|
8643
|
-
return
|
|
8892
|
+
return fail(
|
|
8893
|
+
"ci-verifier",
|
|
8644
8894
|
`${workflowRel} job "${name}": timeout-minutes ${timeout} is too small; trusted verify.budget ${policy.verify.budget}s requires at least ${requiredMinutes} minutes for visible + pristine + authority overhead`
|
|
8645
8895
|
);
|
|
8646
8896
|
}
|
|
@@ -8648,47 +8898,53 @@ function runDoctor(opts = {}) {
|
|
|
8648
8898
|
}
|
|
8649
8899
|
if (verifyJobCount === 0) {
|
|
8650
8900
|
const scope = opts.workflow ?? ".github/workflows/*.yml|*.yaml";
|
|
8651
|
-
return
|
|
8901
|
+
return fail("ci-verifier", `${scope}: no job contains a tamperward verify step`);
|
|
8652
8902
|
}
|
|
8653
8903
|
let github = null;
|
|
8654
8904
|
if (opts.github) {
|
|
8655
8905
|
try {
|
|
8656
8906
|
github = githubAuthority(opts, cwd);
|
|
8657
8907
|
} catch (e) {
|
|
8658
|
-
return
|
|
8908
|
+
return fail("github-authority", e instanceof Error ? e.message : String(e));
|
|
8659
8909
|
}
|
|
8660
8910
|
if (github.findings.length > 0) {
|
|
8661
|
-
return
|
|
8911
|
+
return fail(
|
|
8912
|
+
"github-authority",
|
|
8662
8913
|
"GitHub repository authority for " + github.repo + "#" + github.branch + " is incomplete: " + github.findings.join("; ")
|
|
8663
8914
|
);
|
|
8664
8915
|
}
|
|
8665
8916
|
}
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8917
|
+
const checks = collectLocalPosture(
|
|
8918
|
+
cwd,
|
|
8919
|
+
policy,
|
|
8920
|
+
authorityWorkflows,
|
|
8921
|
+
Boolean(opts.workflow)
|
|
8669
8922
|
);
|
|
8923
|
+
checks.push({
|
|
8924
|
+
id: "ci-verifier",
|
|
8925
|
+
state: "OK",
|
|
8926
|
+
detail: `${verifyJobCount} verify job(s); trusted budget ${policy.verify.budget}s/stage requires >=${requiredMinutes}m outer timeout`
|
|
8927
|
+
});
|
|
8670
8928
|
if (github) {
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8929
|
+
checks.push({
|
|
8930
|
+
id: "github-authority",
|
|
8931
|
+
state: "OK",
|
|
8932
|
+
detail: github.repo + "#" + github.branch + " requires tamperward status, Code Owner review, and stale-review dismissal on new pushes"
|
|
8933
|
+
});
|
|
8674
8934
|
}
|
|
8675
|
-
|
|
8676
|
-
if (
|
|
8677
|
-
process.stdout.write(
|
|
8678
|
-
`tamperward doctor: transient observer: healthy \u2014 ${observer.health.backend}, ${observer.health.watched_dirs} watched dir(s), ${observer.health.event_count} event(s); advisory telemetry only, not verification authority.
|
|
8679
|
-
`
|
|
8680
|
-
);
|
|
8681
|
-
} else if (observer.state === "degraded" && observer.health) {
|
|
8682
|
-
process.stdout.write(
|
|
8683
|
-
`tamperward doctor: transient observer: degraded \u2014 ${observer.health.error_count} error(s), ${observer.health.dropped_events} dropped event(s); ${observer.reason ?? "telemetry may be incomplete"}. Advisory only; zero events are not evidence of no transient activity.
|
|
8684
|
-
`
|
|
8685
|
-
);
|
|
8686
|
-
} else {
|
|
8935
|
+
checks.push(observerCheck(cwd));
|
|
8936
|
+
if (!opts.json) {
|
|
8687
8937
|
process.stdout.write(
|
|
8688
|
-
`tamperward doctor:
|
|
8938
|
+
`tamperward doctor: CI verifier envelope OK \u2014 ${verifyJobCount} verify job(s), trusted budget ${policy.verify.budget}s/stage, requires >=${requiredMinutes}m outer timeout.
|
|
8689
8939
|
`
|
|
8690
8940
|
);
|
|
8941
|
+
if (github) {
|
|
8942
|
+
process.stdout.write(
|
|
8943
|
+
"tamperward doctor: GitHub repository authority OK \u2014 " + github.repo + "#" + github.branch + " requires tamperward, Code Owner review, and stale-review dismissal on new pushes.\n"
|
|
8944
|
+
);
|
|
8945
|
+
}
|
|
8691
8946
|
}
|
|
8947
|
+
emitReport(opts, checks);
|
|
8692
8948
|
return 0;
|
|
8693
8949
|
}
|
|
8694
8950
|
|
|
@@ -10852,6 +11108,7 @@ function parseDoctor(args) {
|
|
|
10852
11108
|
else if (a === "--base") o.base = args[++i];
|
|
10853
11109
|
else if (a === "--workflow") o.workflow = args[++i];
|
|
10854
11110
|
else if (a === "--github") o.github = true;
|
|
11111
|
+
else if (a === "--json") o.json = true;
|
|
10855
11112
|
else if (a === "--repo") o.repo = args[++i];
|
|
10856
11113
|
else if (a === "--branch") o.branch = args[++i];
|
|
10857
11114
|
}
|
|
@@ -10981,7 +11238,7 @@ function validateCliArgs(cmd, args) {
|
|
|
10981
11238
|
}
|
|
10982
11239
|
if (cmd === "doctor") {
|
|
10983
11240
|
return validateFlatArgs(args, {
|
|
10984
|
-
flags: ["--github"],
|
|
11241
|
+
flags: ["--github", "--json"],
|
|
10985
11242
|
values: {
|
|
10986
11243
|
"--cwd": "string",
|
|
10987
11244
|
"--base": "string",
|
|
@@ -11068,8 +11325,9 @@ Formats:
|
|
|
11068
11325
|
every tool the gate must see.
|
|
11069
11326
|
--force-workflow replaces a workflow it
|
|
11070
11327
|
did not write, or one you have edited.
|
|
11071
|
-
tamperward doctor [--base R]
|
|
11072
|
-
[--workflow F] [--cwd D]
|
|
11328
|
+
tamperward doctor [--base R] report installation + authority posture
|
|
11329
|
+
[--workflow F] [--cwd D] and validate the CI verifier's outer-time
|
|
11330
|
+
[--json] envelope against the trusted policy.
|
|
11073
11331
|
[--github] [--repo O/R] --github also validates repository
|
|
11074
11332
|
[--branch B] authority: required tamperward status,
|
|
11075
11333
|
Code Owner review, and stale-review
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.1",
|
|
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",
|