tamperward 2.15.0 → 2.15.2
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 +23 -1
- package/dist/cli/index.js +363 -77
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -167,6 +167,19 @@ observer down deterministically. Observer health remains advisory; transient fin
|
|
|
167
167
|
remain warnings by default and affect the envelope only when the operator explicitly
|
|
168
168
|
sets `TAMPERWARD_TRANSIENT=block`.
|
|
169
169
|
|
|
170
|
+
Since **2.15.2**, Stop-sweep event consumption uses the saved cursor as a real byte
|
|
171
|
+
offset: it performs positioned reads of only new JSONL bytes instead of decoding the
|
|
172
|
+
whole historical log on every turn. Each physical read is capped at **4 MiB**, and one
|
|
173
|
+
authority decision drains at most **16 MiB** in bounded chunks. Only complete
|
|
174
|
+
newline-terminated records advance the cursor, so a torn final watcher write is replayed
|
|
175
|
+
after completion instead of being lost. Stop commits the cursor only after the entire
|
|
176
|
+
bounded telemetry tail has been parsed and classified; malformed records, an oversized
|
|
177
|
+
single record, a torn tail, or telemetry beyond the 16 MiB aggregate ceiling blocks Stop
|
|
178
|
+
and retains the previous cursor rather than certifying unjudged bytes. Supervised
|
|
179
|
+
`run --observe-transients` keeps observer evidence advisory by default, but when
|
|
180
|
+
`TAMPERWARD_TRANSIENT=block` is explicitly enabled, any unclassified observer tail
|
|
181
|
+
also fails the envelope closed.
|
|
182
|
+
|
|
170
183
|
After the runtime exits its exit code is treated as untrusted, and the envelope
|
|
171
184
|
checks that post-agent `HEAD` still descends from the entry commit; the committed
|
|
172
185
|
changes over `entry...HEAD`; staged, unstaged and untracked non-ignored worktree
|
|
@@ -229,10 +242,19 @@ Since **2.15.0**, `tamperward doctor` is also the one-shot installation/posture
|
|
|
229
242
|
report promised by `init`'s security model. It reuses `init`'s canonical wiring
|
|
230
243
|
planner rather than maintaining a second definition of “installed correctly”, and
|
|
231
244
|
reports named `OK`, `WARN`, or `BROKEN` checks for policy/schema, Claude hooks,
|
|
232
|
-
pre-commit,
|
|
245
|
+
pre-commit, CI authority wiring, CODEOWNERS, workflow permissions, binary/pin
|
|
233
246
|
alignment, verifier trust mode + declared inputs, platform residuals, CI verifier
|
|
234
247
|
outer time, observer health, and (with `--github`) repository authority. Use
|
|
235
248
|
`--json` for one machine-readable document with `authoritative: true|false`.
|
|
249
|
+
|
|
250
|
+
**2.15.1 closes an authority-reporting gap in that surface.** The permission check now
|
|
251
|
+
includes job-level overrides, not only workflow-root permissions; a policy schema newer
|
|
252
|
+
than the running binary is BROKEN rather than certifiable; and the exact workflow(s)
|
|
253
|
+
selected by `--workflow` or discovered as verifier authorities are also the workflow(s)
|
|
254
|
+
used for CI-wiring and token-permission posture. A custom verifier workflow therefore
|
|
255
|
+
does not get judged against an unrelated/missing generated file, and a second verifier
|
|
256
|
+
workflow with a write-scoped job cannot hide behind a safe canonical workflow.
|
|
257
|
+
|
|
236
258
|
Local early-layer gaps remain posture findings rather than silently changing the
|
|
237
259
|
existing generated-CI exit contract; hard CI/GitHub validation failures still exit 2.
|
|
238
260
|
|
package/dist/cli/index.js
CHANGED
|
@@ -6829,7 +6829,7 @@ function runCheck(opts) {
|
|
|
6829
6829
|
}
|
|
6830
6830
|
|
|
6831
6831
|
// src/cli/hook.ts
|
|
6832
|
-
import { readFileSync as
|
|
6832
|
+
import { readFileSync as readFileSync10, appendFileSync as appendFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync8 } from "node:fs";
|
|
6833
6833
|
import { createHash as createHash5 } from "node:crypto";
|
|
6834
6834
|
|
|
6835
6835
|
// src/adapters/claude/deny.ts
|
|
@@ -7253,26 +7253,200 @@ function runWatch(args) {
|
|
|
7253
7253
|
}
|
|
7254
7254
|
|
|
7255
7255
|
// src/detectors/fs-events.ts
|
|
7256
|
-
import {
|
|
7256
|
+
import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync } from "node:fs";
|
|
7257
7257
|
var RULE11 = "transient-protected-mutation";
|
|
7258
|
-
|
|
7258
|
+
var MAX_EVENT_READ_BYTES = 4 * 1024 * 1024;
|
|
7259
|
+
var MAX_EVENT_SWEEP_BYTES = 16 * 1024 * 1024;
|
|
7260
|
+
var DEFAULT_EVENT_READ_IO = {
|
|
7261
|
+
read: (fd, buffer, bufferOffset, length, position) => readSync2(fd, buffer, bufferOffset, length, position)
|
|
7262
|
+
};
|
|
7263
|
+
function readEvents(log, offset, maxBytes = MAX_EVENT_READ_BYTES, io = DEFAULT_EVENT_READ_IO) {
|
|
7259
7264
|
let size = 0;
|
|
7260
7265
|
try {
|
|
7261
7266
|
size = statSync(log).size;
|
|
7262
7267
|
} catch {
|
|
7263
|
-
return {
|
|
7268
|
+
return {
|
|
7269
|
+
events: [],
|
|
7270
|
+
newOffset: Math.max(0, Number.isFinite(offset) ? Math.floor(offset) : 0),
|
|
7271
|
+
bytesRead: 0,
|
|
7272
|
+
limitReached: false,
|
|
7273
|
+
malformedLines: 0,
|
|
7274
|
+
incompleteTail: false
|
|
7275
|
+
};
|
|
7276
|
+
}
|
|
7277
|
+
let start = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
|
|
7278
|
+
if (start > size) start = 0;
|
|
7279
|
+
if (size <= start) {
|
|
7280
|
+
return {
|
|
7281
|
+
events: [],
|
|
7282
|
+
newOffset: start,
|
|
7283
|
+
bytesRead: 0,
|
|
7284
|
+
limitReached: false,
|
|
7285
|
+
malformedLines: 0,
|
|
7286
|
+
incompleteTail: false
|
|
7287
|
+
};
|
|
7288
|
+
}
|
|
7289
|
+
const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? Math.max(1, Math.floor(maxBytes)) : MAX_EVENT_READ_BYTES;
|
|
7290
|
+
const available = size - start;
|
|
7291
|
+
const requested = Math.min(available, cap);
|
|
7292
|
+
const buffer = Buffer.allocUnsafe(requested);
|
|
7293
|
+
const fd = openSync2(log, "r");
|
|
7294
|
+
let bytesRead = 0;
|
|
7295
|
+
try {
|
|
7296
|
+
bytesRead = io.read(fd, buffer, 0, requested, start);
|
|
7297
|
+
} finally {
|
|
7298
|
+
closeSync2(fd);
|
|
7299
|
+
}
|
|
7300
|
+
if (bytesRead <= 0) {
|
|
7301
|
+
return {
|
|
7302
|
+
events: [],
|
|
7303
|
+
newOffset: start,
|
|
7304
|
+
bytesRead: 0,
|
|
7305
|
+
limitReached: available > 0,
|
|
7306
|
+
malformedLines: 0,
|
|
7307
|
+
incompleteTail: available > 0
|
|
7308
|
+
};
|
|
7309
|
+
}
|
|
7310
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
7311
|
+
const lastNewline = chunk.lastIndexOf(10);
|
|
7312
|
+
const limitReached = start + bytesRead < size;
|
|
7313
|
+
if (lastNewline < 0) {
|
|
7314
|
+
return {
|
|
7315
|
+
events: [],
|
|
7316
|
+
newOffset: start,
|
|
7317
|
+
bytesRead,
|
|
7318
|
+
limitReached,
|
|
7319
|
+
malformedLines: 0,
|
|
7320
|
+
incompleteTail: true
|
|
7321
|
+
};
|
|
7264
7322
|
}
|
|
7265
|
-
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
7266
|
-
const raw = readFileSync10(log, "utf8").slice(offset);
|
|
7267
7323
|
const events = [];
|
|
7268
|
-
|
|
7324
|
+
let malformedLines = 0;
|
|
7325
|
+
const complete = chunk.subarray(0, lastNewline + 1).toString("utf8");
|
|
7326
|
+
for (const line of complete.split("\n")) {
|
|
7269
7327
|
if (!line.trim()) continue;
|
|
7270
7328
|
try {
|
|
7271
7329
|
events.push(JSON.parse(line));
|
|
7272
7330
|
} catch {
|
|
7331
|
+
malformedLines++;
|
|
7332
|
+
}
|
|
7333
|
+
}
|
|
7334
|
+
const newOffset = start + lastNewline + 1;
|
|
7335
|
+
return {
|
|
7336
|
+
events,
|
|
7337
|
+
newOffset,
|
|
7338
|
+
bytesRead,
|
|
7339
|
+
limitReached,
|
|
7340
|
+
malformedLines,
|
|
7341
|
+
incompleteTail: newOffset < start + bytesRead
|
|
7342
|
+
};
|
|
7343
|
+
}
|
|
7344
|
+
function drainEvents(log, offset, maxTotalBytes = MAX_EVENT_SWEEP_BYTES, io = DEFAULT_EVENT_READ_IO) {
|
|
7345
|
+
const start = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0;
|
|
7346
|
+
const aggregateCap = Number.isFinite(maxTotalBytes) && maxTotalBytes > 0 ? Math.max(1, Math.floor(maxTotalBytes)) : MAX_EVENT_SWEEP_BYTES;
|
|
7347
|
+
const events = [];
|
|
7348
|
+
let cursor = start;
|
|
7349
|
+
let bytesRead = 0;
|
|
7350
|
+
let batches = 0;
|
|
7351
|
+
let malformedLines = 0;
|
|
7352
|
+
while (bytesRead < aggregateCap) {
|
|
7353
|
+
const remainingBudget = aggregateCap - bytesRead;
|
|
7354
|
+
const batch = readEvents(
|
|
7355
|
+
log,
|
|
7356
|
+
cursor,
|
|
7357
|
+
Math.min(MAX_EVENT_READ_BYTES, remainingBudget),
|
|
7358
|
+
io
|
|
7359
|
+
);
|
|
7360
|
+
batches++;
|
|
7361
|
+
bytesRead += batch.bytesRead;
|
|
7362
|
+
malformedLines += batch.malformedLines;
|
|
7363
|
+
events.push(...batch.events);
|
|
7364
|
+
if (batch.malformedLines > 0) {
|
|
7365
|
+
return {
|
|
7366
|
+
events,
|
|
7367
|
+
newOffset: cursor,
|
|
7368
|
+
bytesRead,
|
|
7369
|
+
batches,
|
|
7370
|
+
malformedLines,
|
|
7371
|
+
complete: false,
|
|
7372
|
+
issue: "malformed-record"
|
|
7373
|
+
};
|
|
7374
|
+
}
|
|
7375
|
+
if (batch.bytesRead === 0) {
|
|
7376
|
+
if (batch.limitReached || batch.incompleteTail) {
|
|
7377
|
+
return {
|
|
7378
|
+
events,
|
|
7379
|
+
newOffset: cursor,
|
|
7380
|
+
bytesRead,
|
|
7381
|
+
batches,
|
|
7382
|
+
malformedLines,
|
|
7383
|
+
complete: false,
|
|
7384
|
+
issue: "read-stalled"
|
|
7385
|
+
};
|
|
7386
|
+
}
|
|
7387
|
+
return {
|
|
7388
|
+
events,
|
|
7389
|
+
newOffset: cursor,
|
|
7390
|
+
bytesRead,
|
|
7391
|
+
batches,
|
|
7392
|
+
malformedLines,
|
|
7393
|
+
complete: true
|
|
7394
|
+
};
|
|
7395
|
+
}
|
|
7396
|
+
if (batch.limitReached && batch.newOffset === cursor) {
|
|
7397
|
+
return {
|
|
7398
|
+
events,
|
|
7399
|
+
newOffset: cursor,
|
|
7400
|
+
bytesRead,
|
|
7401
|
+
batches,
|
|
7402
|
+
malformedLines,
|
|
7403
|
+
complete: false,
|
|
7404
|
+
issue: "oversized-record"
|
|
7405
|
+
};
|
|
7406
|
+
}
|
|
7407
|
+
if (!batch.limitReached && batch.incompleteTail) {
|
|
7408
|
+
return {
|
|
7409
|
+
events,
|
|
7410
|
+
newOffset: cursor,
|
|
7411
|
+
bytesRead,
|
|
7412
|
+
batches,
|
|
7413
|
+
malformedLines,
|
|
7414
|
+
complete: false,
|
|
7415
|
+
issue: "incomplete-record"
|
|
7416
|
+
};
|
|
7417
|
+
}
|
|
7418
|
+
cursor = batch.newOffset;
|
|
7419
|
+
if (!batch.limitReached) {
|
|
7420
|
+
return {
|
|
7421
|
+
events,
|
|
7422
|
+
newOffset: cursor,
|
|
7423
|
+
bytesRead,
|
|
7424
|
+
batches,
|
|
7425
|
+
malformedLines,
|
|
7426
|
+
complete: true
|
|
7427
|
+
};
|
|
7428
|
+
}
|
|
7429
|
+
if (bytesRead >= aggregateCap) {
|
|
7430
|
+
return {
|
|
7431
|
+
events,
|
|
7432
|
+
newOffset: cursor,
|
|
7433
|
+
bytesRead,
|
|
7434
|
+
batches,
|
|
7435
|
+
malformedLines,
|
|
7436
|
+
complete: false,
|
|
7437
|
+
issue: "aggregate-limit"
|
|
7438
|
+
};
|
|
7273
7439
|
}
|
|
7274
7440
|
}
|
|
7275
|
-
return {
|
|
7441
|
+
return {
|
|
7442
|
+
events,
|
|
7443
|
+
newOffset: cursor,
|
|
7444
|
+
bytesRead,
|
|
7445
|
+
batches,
|
|
7446
|
+
malformedLines,
|
|
7447
|
+
complete: false,
|
|
7448
|
+
issue: "aggregate-limit"
|
|
7449
|
+
};
|
|
7276
7450
|
}
|
|
7277
7451
|
function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
7278
7452
|
if (!isEnabled(RULE11, policy)) return [];
|
|
@@ -7316,7 +7490,7 @@ var HookInputError = class extends Error {
|
|
|
7316
7490
|
};
|
|
7317
7491
|
function readStdin() {
|
|
7318
7492
|
try {
|
|
7319
|
-
return
|
|
7493
|
+
return readFileSync10(0, "utf8");
|
|
7320
7494
|
} catch (e) {
|
|
7321
7495
|
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
7322
7496
|
}
|
|
@@ -7564,6 +7738,17 @@ function cursorPath(cwd, sessionId) {
|
|
|
7564
7738
|
const p = ptreePath(cwd, sessionId);
|
|
7565
7739
|
return p ? p.replace(/ptree-/, "fscursor-") : null;
|
|
7566
7740
|
}
|
|
7741
|
+
function observerIntegrityBlock(issue, bytesRead, malformedLines) {
|
|
7742
|
+
const detail = issue === "malformed-record" ? `${malformedLines} malformed complete JSONL record(s)` : issue === "oversized-record" ? `one event record exceeded the ${MAX_EVENT_READ_BYTES}-byte per-record/read ceiling` : issue === "incomplete-record" ? "the event log ended with an incomplete JSONL record" : issue === "aggregate-limit" ? `more than ${MAX_EVENT_SWEEP_BYTES} observer bytes required classification in one Stop sweep` : "the positioned event-log read made no safe progress";
|
|
7743
|
+
return {
|
|
7744
|
+
rule: "tamperward-unavailable",
|
|
7745
|
+
severity: "block",
|
|
7746
|
+
message: `TamperWard could not fully classify transient observer telemetry (${detail}), so Stop is blocked rather than certifying an unjudged tail.`,
|
|
7747
|
+
evidence: `observer_issue=${issue}; bytes_read=${bytesRead}; malformed_lines=${malformedLines}`,
|
|
7748
|
+
remediation: "Let the watcher finish its current record or repair/rotate the malformed observer log, then retry Stop. Do not delete the cursor/log to bypass unjudged telemetry.",
|
|
7749
|
+
signoff: { required: true, command: 'tamperward allow --reason "..."' }
|
|
7750
|
+
};
|
|
7751
|
+
}
|
|
7567
7752
|
function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
7568
7753
|
const none = { blocks: [], commit: () => {
|
|
7569
7754
|
} };
|
|
@@ -7576,16 +7761,34 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
7576
7761
|
if (!existsSync8(log)) return none;
|
|
7577
7762
|
let offset = 0;
|
|
7578
7763
|
if (cp && existsSync8(cp)) {
|
|
7579
|
-
const n = Number(
|
|
7764
|
+
const n = Number(readFileSync10(cp, "utf8"));
|
|
7580
7765
|
offset = Number.isFinite(n) ? n : 0;
|
|
7581
7766
|
}
|
|
7582
|
-
const
|
|
7583
|
-
if (
|
|
7584
|
-
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7767
|
+
const drained = drainEvents(log, offset);
|
|
7768
|
+
if (!drained.complete) {
|
|
7769
|
+
const issue = drained.issue ?? "read-stalled";
|
|
7770
|
+
recordObserverHealth(
|
|
7771
|
+
"degraded",
|
|
7772
|
+
`event telemetry was not fully classified (${issue}); Stop retained the prior cursor`
|
|
7773
|
+
);
|
|
7774
|
+
return {
|
|
7775
|
+
blocks: [observerIntegrityBlock(issue, drained.bytesRead, drained.malformedLines)],
|
|
7776
|
+
commit: () => {
|
|
7777
|
+
}
|
|
7778
|
+
};
|
|
7779
|
+
}
|
|
7780
|
+
const { events, newOffset } = drained;
|
|
7781
|
+
if (events.length === 0) {
|
|
7782
|
+
return {
|
|
7783
|
+
...none,
|
|
7784
|
+
commit: () => {
|
|
7785
|
+
if (cp) try {
|
|
7786
|
+
writeFileSync5(cp, String(newOffset));
|
|
7787
|
+
} catch {
|
|
7788
|
+
}
|
|
7789
|
+
}
|
|
7790
|
+
};
|
|
7791
|
+
}
|
|
7589
7792
|
const persistent = new Set(changes.filter((c) => c.kind === "file").map((c) => c.path));
|
|
7590
7793
|
const finalHash = (path) => {
|
|
7591
7794
|
const e = inspectRel(cwd, path);
|
|
@@ -7742,7 +7945,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
7742
7945
|
// src/cli/init.ts
|
|
7743
7946
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
7744
7947
|
import { createHash as createHash6 } from "node:crypto";
|
|
7745
|
-
import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as
|
|
7948
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync6, chmodSync, statSync as statSync2 } from "node:fs";
|
|
7746
7949
|
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute8, join as join12, relative as relative3, resolve as resolve3 } from "node:path";
|
|
7747
7950
|
|
|
7748
7951
|
// src/verifier-limits.ts
|
|
@@ -7986,7 +8189,7 @@ function planClaudeHooks(cwd) {
|
|
|
7986
8189
|
if (existsSync9(path)) {
|
|
7987
8190
|
let parsed;
|
|
7988
8191
|
try {
|
|
7989
|
-
parsed = JSON.parse(
|
|
8192
|
+
parsed = JSON.parse(readFileSync11(path, "utf8"));
|
|
7990
8193
|
} catch {
|
|
7991
8194
|
return {
|
|
7992
8195
|
item: "agent",
|
|
@@ -8132,7 +8335,7 @@ function planPreCommit(cwd) {
|
|
|
8132
8335
|
if (existsSync9(path) && !statSync2(path).isFile()) {
|
|
8133
8336
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
8134
8337
|
}
|
|
8135
|
-
const existing = existsSync9(path) ?
|
|
8338
|
+
const existing = existsSync9(path) ? readFileSync11(path, "utf8") : null;
|
|
8136
8339
|
if (existing === null) {
|
|
8137
8340
|
return {
|
|
8138
8341
|
item: "pre-commit",
|
|
@@ -8224,7 +8427,7 @@ function planCodeowners(cwd) {
|
|
|
8224
8427
|
(r) => existsSync9(join12(cwd, r))
|
|
8225
8428
|
);
|
|
8226
8429
|
const path = join12(cwd, existingRel ?? rel);
|
|
8227
|
-
const existing = existingRel ?
|
|
8430
|
+
const existing = existingRel ? readFileSync11(path, "utf8") : null;
|
|
8228
8431
|
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
8229
8432
|
if (missing.length === 0) {
|
|
8230
8433
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
@@ -8273,7 +8476,7 @@ function planWorkflow(cwd, force) {
|
|
|
8273
8476
|
}
|
|
8274
8477
|
let src;
|
|
8275
8478
|
try {
|
|
8276
|
-
src =
|
|
8479
|
+
src = readFileSync11(path, "utf8");
|
|
8277
8480
|
} catch {
|
|
8278
8481
|
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
8279
8482
|
}
|
|
@@ -8372,7 +8575,7 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
8372
8575
|
|
|
8373
8576
|
// src/cli/doctor.ts
|
|
8374
8577
|
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
8375
|
-
import { existsSync as existsSync10, readFileSync as
|
|
8578
|
+
import { existsSync as existsSync10, readFileSync as readFileSync12, readdirSync as readdirSync3 } from "node:fs";
|
|
8376
8579
|
import { join as join13, resolve as resolve4 } from "node:path";
|
|
8377
8580
|
import { parse as parse3 } from "yaml";
|
|
8378
8581
|
var VERIFY_COMMAND = /\btamperward(?:@\S+)?\s+verify\b/;
|
|
@@ -8410,58 +8613,96 @@ function pinsInFile(path) {
|
|
|
8410
8613
|
if (!existsSync10(path)) return [];
|
|
8411
8614
|
let src = "";
|
|
8412
8615
|
try {
|
|
8413
|
-
src =
|
|
8616
|
+
src = readFileSync12(path, "utf8");
|
|
8414
8617
|
} catch {
|
|
8415
8618
|
return [];
|
|
8416
8619
|
}
|
|
8417
8620
|
return Array.from(src.matchAll(/\btamperward@((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\b/g), (m) => m[1]);
|
|
8418
8621
|
}
|
|
8419
|
-
function
|
|
8420
|
-
|
|
8421
|
-
|
|
8422
|
-
|
|
8423
|
-
|
|
8424
|
-
try {
|
|
8425
|
-
doc = parse3(readFileSync13(path, "utf8"));
|
|
8426
|
-
} catch (e) {
|
|
8427
|
-
return {
|
|
8428
|
-
id: "workflow-permissions",
|
|
8429
|
-
state: "BROKEN",
|
|
8430
|
-
detail: `${rel} is not valid YAML (${e instanceof Error ? e.message : String(e)})`
|
|
8431
|
-
};
|
|
8622
|
+
function permissionWrites(value) {
|
|
8623
|
+
if (typeof value === "string") {
|
|
8624
|
+
if (/write/i.test(value)) return [value];
|
|
8625
|
+
if (/^(?:read-all|none)$/i.test(value)) return [];
|
|
8626
|
+
return null;
|
|
8432
8627
|
}
|
|
8433
|
-
const
|
|
8434
|
-
|
|
8435
|
-
|
|
8436
|
-
|
|
8437
|
-
|
|
8438
|
-
|
|
8439
|
-
|
|
8440
|
-
|
|
8628
|
+
const mapping = asMapping(value);
|
|
8629
|
+
if (!mapping) return null;
|
|
8630
|
+
return Object.entries(mapping).filter(([, permission]) => typeof permission === "string" && /write/i.test(permission)).map(([name, permission]) => `${name}=${String(permission)}`);
|
|
8631
|
+
}
|
|
8632
|
+
function workflowPermissionCheck(cwd, workflowRels = [".github/workflows/tamperward.yml"]) {
|
|
8633
|
+
const broken = [];
|
|
8634
|
+
const warnings = [];
|
|
8635
|
+
for (const rel of workflowRels) {
|
|
8636
|
+
const path = resolve4(cwd, rel);
|
|
8637
|
+
if (!existsSync10(path)) {
|
|
8638
|
+
broken.push(`${rel} is missing`);
|
|
8639
|
+
continue;
|
|
8640
|
+
}
|
|
8641
|
+
let doc;
|
|
8642
|
+
try {
|
|
8643
|
+
doc = parse3(readFileSync12(path, "utf8"));
|
|
8644
|
+
} catch (e) {
|
|
8645
|
+
broken.push(`${rel} is not valid YAML (${e instanceof Error ? e.message : String(e)})`);
|
|
8646
|
+
continue;
|
|
8647
|
+
}
|
|
8648
|
+
const root = asMapping(doc);
|
|
8649
|
+
if (!root) {
|
|
8650
|
+
broken.push(`${rel} is not a YAML mapping`);
|
|
8651
|
+
continue;
|
|
8652
|
+
}
|
|
8653
|
+
const rootPermissions = root.permissions;
|
|
8654
|
+
if (rootPermissions === void 0) {
|
|
8655
|
+
broken.push(`${rel}: workflow does not declare least-privilege permissions; repository defaults could grant write access`);
|
|
8656
|
+
} else {
|
|
8657
|
+
const writes = permissionWrites(rootPermissions);
|
|
8658
|
+
if (writes === null) {
|
|
8659
|
+
broken.push(`${rel}: workflow permissions shape is unsupported`);
|
|
8660
|
+
} else if (writes.length) {
|
|
8661
|
+
broken.push(`${rel}: workflow grants write permission: ${writes.join(", ")}`);
|
|
8662
|
+
} else {
|
|
8663
|
+
const mapping = asMapping(rootPermissions);
|
|
8664
|
+
if (mapping && mapping.contents !== "read") {
|
|
8665
|
+
warnings.push(`${rel}: workflow permissions contain no writes but contents: read is not declared`);
|
|
8666
|
+
}
|
|
8667
|
+
}
|
|
8668
|
+
}
|
|
8669
|
+
const jobs = asMapping(root.jobs);
|
|
8670
|
+
if (!jobs) continue;
|
|
8671
|
+
for (const [name, rawJob] of Object.entries(jobs)) {
|
|
8672
|
+
const job = asMapping(rawJob);
|
|
8673
|
+
if (!job || job.permissions === void 0) continue;
|
|
8674
|
+
const writes = permissionWrites(job.permissions);
|
|
8675
|
+
if (writes === null) {
|
|
8676
|
+
broken.push(`${rel} job "${name}": permissions shape is unsupported`);
|
|
8677
|
+
} else if (writes.length) {
|
|
8678
|
+
broken.push(`${rel} job "${name}" grants write permission: ${writes.join(", ")}`);
|
|
8679
|
+
}
|
|
8680
|
+
}
|
|
8441
8681
|
}
|
|
8442
|
-
|
|
8443
|
-
([, value]) => typeof value === "string" && /write/i.test(value)
|
|
8444
|
-
);
|
|
8445
|
-
if (writes.length) {
|
|
8682
|
+
if (broken.length) {
|
|
8446
8683
|
return {
|
|
8447
8684
|
id: "workflow-permissions",
|
|
8448
8685
|
state: "BROKEN",
|
|
8449
|
-
detail:
|
|
8686
|
+
detail: broken.join("; ")
|
|
8450
8687
|
};
|
|
8451
8688
|
}
|
|
8452
|
-
if (
|
|
8689
|
+
if (warnings.length) {
|
|
8453
8690
|
return {
|
|
8454
8691
|
id: "workflow-permissions",
|
|
8455
8692
|
state: "WARN",
|
|
8456
|
-
detail: "
|
|
8693
|
+
detail: warnings.join("; ")
|
|
8457
8694
|
};
|
|
8458
8695
|
}
|
|
8459
|
-
return {
|
|
8696
|
+
return {
|
|
8697
|
+
id: "workflow-permissions",
|
|
8698
|
+
state: "OK",
|
|
8699
|
+
detail: `${workflowRels.join(", ")}: workflow/job token permissions contain no write grants and workflow contents access is read-only`
|
|
8700
|
+
};
|
|
8460
8701
|
}
|
|
8461
|
-
function collectLocalPosture(cwd, policy) {
|
|
8702
|
+
function collectLocalPosture(cwd, policy, authorityWorkflows, explicitWorkflow = false) {
|
|
8462
8703
|
const checks = [];
|
|
8463
8704
|
checks.push(
|
|
8464
|
-
policy.version <= POLICY_VERSION ? { id: "policy", state: "OK", detail: `policy version ${policy.version} is understood (current schema ${POLICY_VERSION})` } : { id: "policy", state: "
|
|
8705
|
+
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` }
|
|
8465
8706
|
);
|
|
8466
8707
|
let plan = [];
|
|
8467
8708
|
try {
|
|
@@ -8488,9 +8729,29 @@ function collectLocalPosture(cwd, policy) {
|
|
|
8488
8729
|
};
|
|
8489
8730
|
addWiring("claude-hooks", "agent", true);
|
|
8490
8731
|
addWiring("pre-commit", "pre-commit", false);
|
|
8491
|
-
|
|
8732
|
+
const canonicalWorkflow = ".github/workflows/tamperward.yml";
|
|
8733
|
+
if (authorityWorkflows?.length) {
|
|
8734
|
+
const normalized = authorityWorkflows.map((x) => x.replace(/\\/g, "/"));
|
|
8735
|
+
if (explicitWorkflow) {
|
|
8736
|
+
checks.push({
|
|
8737
|
+
id: "ci-wiring",
|
|
8738
|
+
state: "OK",
|
|
8739
|
+
detail: `explicit verifier authority workflow: ${normalized.join(", ")}; verifier step/timeout validated by doctor`
|
|
8740
|
+
});
|
|
8741
|
+
} else if (normalized.includes(canonicalWorkflow)) {
|
|
8742
|
+
addWiring("ci-wiring", "ci", true);
|
|
8743
|
+
} else {
|
|
8744
|
+
checks.push({
|
|
8745
|
+
id: "ci-wiring",
|
|
8746
|
+
state: "OK",
|
|
8747
|
+
detail: `discovered verifier authority workflow(s): ${normalized.join(", ")}; verifier step/timeout validated by doctor`
|
|
8748
|
+
});
|
|
8749
|
+
}
|
|
8750
|
+
} else {
|
|
8751
|
+
addWiring("ci-wiring", "ci", true);
|
|
8752
|
+
}
|
|
8492
8753
|
addWiring("codeowners", "codeowners", true);
|
|
8493
|
-
checks.push(workflowPermissionCheck(cwd));
|
|
8754
|
+
checks.push(workflowPermissionCheck(cwd, authorityWorkflows));
|
|
8494
8755
|
const pins = [
|
|
8495
8756
|
...pinsInFile(join13(cwd, ".claude", "settings.json")),
|
|
8496
8757
|
...pinsInFile(join13(cwd, ".git", "hooks", "pre-commit")),
|
|
@@ -8744,7 +9005,13 @@ function runDoctor(opts = {}) {
|
|
|
8744
9005
|
"policy"
|
|
8745
9006
|
);
|
|
8746
9007
|
}
|
|
8747
|
-
|
|
9008
|
+
let postureWorkflows = opts.workflow ? [opts.workflow] : void 0;
|
|
9009
|
+
const fail = (id, message) => err(
|
|
9010
|
+
message,
|
|
9011
|
+
opts,
|
|
9012
|
+
id,
|
|
9013
|
+
collectLocalPosture(cwd, policy, postureWorkflows, Boolean(opts.workflow))
|
|
9014
|
+
);
|
|
8748
9015
|
if (!policy.verify?.command) {
|
|
8749
9016
|
return fail("verifier", "trusted policy has no verify.command; generated CI cannot verify this repository");
|
|
8750
9017
|
}
|
|
@@ -8771,6 +9038,7 @@ function runDoctor(opts = {}) {
|
|
|
8771
9038
|
const requiredSecs = requiredVerifierAuthoritySeconds(policy.verify.budget);
|
|
8772
9039
|
const requiredMinutes = Math.ceil(requiredSecs / 60);
|
|
8773
9040
|
let verifyJobCount = 0;
|
|
9041
|
+
const authorityWorkflows = [];
|
|
8774
9042
|
for (const workflowRel of workflowRels) {
|
|
8775
9043
|
const workflowPath = resolve4(cwd, workflowRel);
|
|
8776
9044
|
if (!existsSync10(workflowPath)) {
|
|
@@ -8778,7 +9046,7 @@ function runDoctor(opts = {}) {
|
|
|
8778
9046
|
}
|
|
8779
9047
|
let doc;
|
|
8780
9048
|
try {
|
|
8781
|
-
doc = parse3(
|
|
9049
|
+
doc = parse3(readFileSync12(workflowPath, "utf8"));
|
|
8782
9050
|
} catch (e) {
|
|
8783
9051
|
return fail(
|
|
8784
9052
|
"ci-verifier",
|
|
@@ -8787,6 +9055,10 @@ function runDoctor(opts = {}) {
|
|
|
8787
9055
|
}
|
|
8788
9056
|
const jobs = verifyJobs(doc);
|
|
8789
9057
|
verifyJobCount += jobs.length;
|
|
9058
|
+
if (jobs.length > 0) {
|
|
9059
|
+
authorityWorkflows.push(workflowRel);
|
|
9060
|
+
postureWorkflows = [...authorityWorkflows];
|
|
9061
|
+
}
|
|
8790
9062
|
for (const { name, job } of jobs) {
|
|
8791
9063
|
const timeout = job["timeout-minutes"];
|
|
8792
9064
|
if (timeout === void 0 || timeout === null) {
|
|
@@ -8845,7 +9117,12 @@ function runDoctor(opts = {}) {
|
|
|
8845
9117
|
);
|
|
8846
9118
|
}
|
|
8847
9119
|
}
|
|
8848
|
-
const checks = collectLocalPosture(
|
|
9120
|
+
const checks = collectLocalPosture(
|
|
9121
|
+
cwd,
|
|
9122
|
+
policy,
|
|
9123
|
+
authorityWorkflows,
|
|
9124
|
+
Boolean(opts.workflow)
|
|
9125
|
+
);
|
|
8849
9126
|
checks.push({
|
|
8850
9127
|
id: "ci-verifier",
|
|
8851
9128
|
state: "OK",
|
|
@@ -8877,7 +9154,7 @@ function runDoctor(opts = {}) {
|
|
|
8877
9154
|
// src/cli/verify.ts
|
|
8878
9155
|
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
8879
9156
|
import { createHash as createHash10 } from "node:crypto";
|
|
8880
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync5, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
9157
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync as lstatSync5, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync3, rmSync as rmSync3, symlinkSync, writeFileSync as writeFileSync7 } from "node:fs";
|
|
8881
9158
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
8882
9159
|
import { dirname as dirname7, isAbsolute as isAbsolute10, join as join16, relative as relative5, resolve as resolve7, sep as sep2 } from "node:path";
|
|
8883
9160
|
|
|
@@ -8913,7 +9190,7 @@ import {
|
|
|
8913
9190
|
existsSync as existsSync11,
|
|
8914
9191
|
lstatSync as lstatSync4,
|
|
8915
9192
|
readdirSync as readdirSync4,
|
|
8916
|
-
readFileSync as
|
|
9193
|
+
readFileSync as readFileSync13,
|
|
8917
9194
|
readlinkSync as readlinkSync2,
|
|
8918
9195
|
realpathSync as realpathSync3,
|
|
8919
9196
|
statSync as statSync3
|
|
@@ -8970,7 +9247,7 @@ function fingerprintRoot(root, cwd, h) {
|
|
|
8970
9247
|
st = statSync3(path);
|
|
8971
9248
|
if (!st.isFile()) throw new Error("not a regular file");
|
|
8972
9249
|
h.update(label + "\0external-file\0" + path + "\0" + String(st.mode) + "\0");
|
|
8973
|
-
h.update(
|
|
9250
|
+
h.update(readFileSync13(path));
|
|
8974
9251
|
h.update("\0");
|
|
8975
9252
|
} catch (e) {
|
|
8976
9253
|
throw new Unattestable(
|
|
@@ -9004,7 +9281,7 @@ function fingerprintRoot(root, cwd, h) {
|
|
|
9004
9281
|
h.update(rel + "\0" + ident.kind + "\0" + String(ident.mode) + "\0");
|
|
9005
9282
|
if (ident.kind === "file") {
|
|
9006
9283
|
try {
|
|
9007
|
-
h.update(
|
|
9284
|
+
h.update(readFileSync13(path));
|
|
9008
9285
|
h.update("\0");
|
|
9009
9286
|
} catch (e) {
|
|
9010
9287
|
throw new Unattestable(
|
|
@@ -9165,7 +9442,7 @@ function venvProblem(root) {
|
|
|
9165
9442
|
const cfg = join14(root, "pyvenv.cfg");
|
|
9166
9443
|
if (!existsSync11(cfg)) return "VIRTUAL_ENV " + JSON.stringify(root) + " has no pyvenv.cfg";
|
|
9167
9444
|
try {
|
|
9168
|
-
const body =
|
|
9445
|
+
const body = readFileSync13(cfg, "utf8");
|
|
9169
9446
|
if (/^\s*include-system-site-packages\s*=\s*true\s*$/im.test(body)) {
|
|
9170
9447
|
return "Python environment " + JSON.stringify(root) + " enables system site-packages; those external package roots are not bounded";
|
|
9171
9448
|
}
|
|
@@ -9263,7 +9540,7 @@ function dependencyEnvironmentSummary(descriptor) {
|
|
|
9263
9540
|
// src/verifier-backend.ts
|
|
9264
9541
|
import { createHash as createHash9, randomUUID } from "node:crypto";
|
|
9265
9542
|
import { spawnSync } from "node:child_process";
|
|
9266
|
-
import { accessSync, constants as constants2, readFileSync as
|
|
9543
|
+
import { accessSync, constants as constants2, readFileSync as readFileSync14, realpathSync as realpathSync4, statSync as statSync4 } from "node:fs";
|
|
9267
9544
|
import { delimiter as delimiter2, dirname as dirname6, join as join15, resolve as resolve6 } from "node:path";
|
|
9268
9545
|
var DEFAULT_CONTAINER_RESOURCE_LIMITS = Object.freeze({
|
|
9269
9546
|
memoryBytes: 2 * 1024 * 1024 * 1024,
|
|
@@ -9317,7 +9594,7 @@ function findExecutable(name) {
|
|
|
9317
9594
|
return null;
|
|
9318
9595
|
}
|
|
9319
9596
|
function fileSha256(path) {
|
|
9320
|
-
return createHash9("sha256").update(
|
|
9597
|
+
return createHash9("sha256").update(readFileSync14(path)).digest("hex");
|
|
9321
9598
|
}
|
|
9322
9599
|
function engineClientEnv() {
|
|
9323
9600
|
return {
|
|
@@ -9965,7 +10242,7 @@ function overlayDigest(dest, paths) {
|
|
|
9965
10242
|
try {
|
|
9966
10243
|
const st = lstatSync5(p);
|
|
9967
10244
|
h.update(String(st.mode));
|
|
9968
|
-
h.update(st.isSymbolicLink() ? readlinkSync3(p) :
|
|
10245
|
+
h.update(st.isSymbolicLink() ? readlinkSync3(p) : readFileSync15(p));
|
|
9969
10246
|
} catch {
|
|
9970
10247
|
h.update("<gone>");
|
|
9971
10248
|
}
|
|
@@ -10076,7 +10353,7 @@ function runLocalSuite(dir, cmd, budgetSecs) {
|
|
|
10076
10353
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
10077
10354
|
let r;
|
|
10078
10355
|
try {
|
|
10079
|
-
r = JSON.parse(
|
|
10356
|
+
r = JSON.parse(readFileSync15(outFile, "utf8"));
|
|
10080
10357
|
} catch {
|
|
10081
10358
|
return { exit: null, secs };
|
|
10082
10359
|
}
|
|
@@ -10379,7 +10656,7 @@ function parseVerify(args) {
|
|
|
10379
10656
|
// src/cli/run.ts
|
|
10380
10657
|
import { execFileSync as execFileSync10, spawn, spawnSync as spawnSync3 } from "node:child_process";
|
|
10381
10658
|
import { randomBytes } from "node:crypto";
|
|
10382
|
-
import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync5, readFileSync as
|
|
10659
|
+
import { mkdtempSync as mkdtempSync3, readdirSync as readdirSync5, readFileSync as readFileSync16, readlinkSync as readlinkSync4, realpathSync as realpathSync6, rmSync as rmSync4 } from "node:fs";
|
|
10383
10660
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
10384
10661
|
import { join as join17, resolve as resolve8 } from "node:path";
|
|
10385
10662
|
var out = (s) => process.stdout.write(s + "\n");
|
|
@@ -10501,7 +10778,7 @@ function linuxDescendantPids(rootPid) {
|
|
|
10501
10778
|
for (const name of names) {
|
|
10502
10779
|
const pid = Number(name);
|
|
10503
10780
|
try {
|
|
10504
|
-
const stat =
|
|
10781
|
+
const stat = readFileSync16(`/proc/${name}/stat`, "utf8");
|
|
10505
10782
|
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
10506
10783
|
const ppid = Number(fields[1]);
|
|
10507
10784
|
if (!Number.isFinite(ppid)) continue;
|
|
@@ -10570,7 +10847,7 @@ function runAgentWithBudget(argv2, cwd, budgetSecs) {
|
|
|
10570
10847
|
supervisorTimedOut = Boolean(supervisor.error) && supervisor.error.code === "ETIMEDOUT";
|
|
10571
10848
|
let state = {};
|
|
10572
10849
|
try {
|
|
10573
|
-
state = JSON.parse(
|
|
10850
|
+
state = JSON.parse(readFileSync16(resultFile, "utf8"));
|
|
10574
10851
|
} catch {
|
|
10575
10852
|
}
|
|
10576
10853
|
if (supervisorTimedOut || state.timedOut) {
|
|
@@ -10684,7 +10961,16 @@ function observerSummary(observer, telemetry) {
|
|
|
10684
10961
|
}
|
|
10685
10962
|
function collectObserverFindings(observer, telemetry, cwd, base, head, policy) {
|
|
10686
10963
|
observerSummary(observer, telemetry);
|
|
10687
|
-
const
|
|
10964
|
+
const strict = process.env.TAMPERWARD_TRANSIENT === "block";
|
|
10965
|
+
const drained = drainEvents(observer.log, 0);
|
|
10966
|
+
const { events } = drained;
|
|
10967
|
+
if (!drained.complete) {
|
|
10968
|
+
const issue = drained.issue ?? "read-stalled";
|
|
10969
|
+
out(
|
|
10970
|
+
`tamperward run \u2014 transient observer: telemetry was not fully classified (${issue}; ${drained.bytesRead}/${MAX_EVENT_SWEEP_BYTES} bytes read). ` + (strict ? "Strict transient policy fails closed." : "Observer telemetry remains advisory, so this degrades evidence but does not independently fail the envelope.")
|
|
10971
|
+
);
|
|
10972
|
+
if (strict) return { blocking: true };
|
|
10973
|
+
}
|
|
10688
10974
|
if (events.length === 0) return { blocking: false };
|
|
10689
10975
|
let persistent = /* @__PURE__ */ new Set();
|
|
10690
10976
|
try {
|
|
@@ -10697,9 +10983,9 @@ function collectObserverFindings(observer, telemetry, cwd, base, head, policy) {
|
|
|
10697
10983
|
);
|
|
10698
10984
|
} catch (e) {
|
|
10699
10985
|
out(
|
|
10700
|
-
`tamperward run \u2014 transient observer: recorded ${events.length} event(s), but could not classify them against the final diff (${e instanceof Error ? e.message : String(e)}).`
|
|
10986
|
+
`tamperward run \u2014 transient observer: recorded ${events.length} event(s), but could not classify them against the final diff (${e instanceof Error ? e.message : String(e)}).` + (strict ? " Strict transient policy fails closed." : "")
|
|
10701
10987
|
);
|
|
10702
|
-
return { blocking:
|
|
10988
|
+
return { blocking: strict };
|
|
10703
10989
|
}
|
|
10704
10990
|
const finalHash = (path) => {
|
|
10705
10991
|
const e = inspectRel(cwd, path);
|
|
@@ -10723,7 +11009,7 @@ function git3(args, cwd) {
|
|
|
10723
11009
|
}
|
|
10724
11010
|
function nowTicks() {
|
|
10725
11011
|
try {
|
|
10726
|
-
return Math.floor(parseFloat(
|
|
11012
|
+
return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
10727
11013
|
} catch {
|
|
10728
11014
|
return Number.POSITIVE_INFINITY;
|
|
10729
11015
|
}
|
|
@@ -10746,7 +11032,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
10746
11032
|
const n = Number(pid);
|
|
10747
11033
|
if (n === process.pid) continue;
|
|
10748
11034
|
try {
|
|
10749
|
-
const stat =
|
|
11035
|
+
const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
|
|
10750
11036
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
10751
11037
|
if (!Number.isFinite(startTicks) || startTicks < spawnedAfterTicks) continue;
|
|
10752
11038
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tamperward",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.2",
|
|
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",
|