magenta-canon 0.5.0 → 0.7.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 +47 -0
- package/bin/magenta-canon.mjs +7 -0
- package/dist/MANIFEST.json +10 -0
- package/dist/scripts/magenta-sentinel.js +226 -0
- package/dist/server/sentinel/artifact-sentinel.js +286 -0
- package/dist/server/sentinel/ledger-sentinel.js +408 -0
- package/dist/server/sentinel/promotion.js +58 -0
- package/dist/server/sentinel/repository-sentinel.js +204 -0
- package/dist/server/sentinel/rules.js +114 -0
- package/dist/server/sentinel/tar-reader.js +116 -0
- package/dist/server/sentinel/violation.js +73 -0
- package/dist/server/sentinel/witness-sentinel.js +408 -0
- package/dist/shared/invariants.js +1145 -0
- package/docs/FILE_LEDGER.md +9 -0
- package/docs/LEDGER_SENTINEL.md +96 -0
- package/docs/NPM_PACKAGING.md +29 -18
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ npx magenta-canon demo # full local proof loop: allow · block
|
|
|
22
22
|
npx magenta-canon verify --self-test # verifier self-check: all verdict levels incl. a tamper FAIL
|
|
23
23
|
npx magenta-canon gateway <config.json> # the stdio MCP capability gateway
|
|
24
24
|
npx magenta-canon mirror --self-test # external STH mirror: catch operator history rewrites
|
|
25
|
+
npx magenta-canon sentinel pack # Sentinel Mesh: invariant-check the packed artifact
|
|
25
26
|
```
|
|
26
27
|
|
|
27
28
|
Magenta Canon is a proven **reference implementation**, not production-hosted infra
|
|
@@ -221,6 +222,52 @@ still regenerates on restart; old evidence keeps verifying). Keystore format,
|
|
|
221
222
|
rotation protocol, boot-reconciliation table, and operator procedures:
|
|
222
223
|
[`docs/WITNESS_IDENTITY.md`](docs/WITNESS_IDENTITY.md) (ships in the package).
|
|
223
224
|
|
|
225
|
+
### Sentinel Mesh foundation — new in 0.6.0
|
|
226
|
+
|
|
227
|
+
Magenta Canon 0.6.0 introduces an **executable Sentinel Mesh** that protects
|
|
228
|
+
repository state, npm artifacts, dependency boundaries, release promotion,
|
|
229
|
+
witness identity, and cryptographically authorized witness-key rotation:
|
|
230
|
+
versioned invariants, deterministic repository / artifact / witness
|
|
231
|
+
evaluation, evidence-preserving violations, and scoped release-promotion
|
|
232
|
+
blocking.
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
magenta-canon sentinel repository # invariant-check a git repository
|
|
236
|
+
magenta-canon sentinel artifact <tgz|dir> # invariant-check a packed artifact
|
|
237
|
+
magenta-canon sentinel pack # npm pack, then check the result
|
|
238
|
+
magenta-canon sentinel witness --ledger ledger.jsonl # witness rotation-authority check
|
|
239
|
+
magenta-canon sentinel ledger ledger.jsonl # ledger truth: sequence, chains, Merkle, checkpoints (new in 0.7.0)
|
|
240
|
+
magenta-canon sentinel invariants # list the invariant registry
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The **Witness & Rotation Sentinel** (MC-WIT-001…011) watches the 0.5.0
|
|
244
|
+
rotation-authority model itself: pinned-anchor continuity, dual-signed
|
|
245
|
+
rotation validity, exact epoch boundaries, epoch-correct STH signing,
|
|
246
|
+
retired-key resurrection / pre-activation refusal, and keystore↔ledger
|
|
247
|
+
agreement — re-derived independently from the published wire formats, so it
|
|
248
|
+
can disagree with a wrong (or compromised) server.
|
|
249
|
+
|
|
250
|
+
The **Ledger Sentinel** (MC-LEDGER-001…021, new in 0.7.0) is the independent
|
|
251
|
+
judge of ledger truth: contiguous receipt sequence, hash-chain and issuer
|
|
252
|
+
signatures, RFC 6962 Merkle re-derivation, checkpoint monotonicity and
|
|
253
|
+
equivocation refusal, corruption fail-closed vs bounded crash-tail
|
|
254
|
+
quarantine, writer/lock authority (never auto-stolen), witness agreement,
|
|
255
|
+
and mirror consistency. It reports five distinct outcomes — integrity /
|
|
256
|
+
origin (pinned anchor only) / operational exclusivity (lock metadata only) /
|
|
257
|
+
not-evaluated / violation — and is the contract any future storage backend
|
|
258
|
+
(PostgreSQL, hosted) must satisfy before carrying authority
|
|
259
|
+
([`docs/LEDGER_SENTINEL.md`](docs/LEDGER_SENTINEL.md), ships in the package).
|
|
260
|
+
|
|
261
|
+
Sentinels are read-only, redacting (paths + fingerprints, never secret
|
|
262
|
+
bodies), deterministic, network-free, and emit `magenta-sentinel-violation/1`
|
|
263
|
+
evidence records; exit codes map to a scoped promotion decision (`eligible` /
|
|
264
|
+
`blocked` / `requires-independent-review`). Records at this layer are
|
|
265
|
+
**unsigned local diagnostics**, and the freeze is a release/build promotion
|
|
266
|
+
gate — **not** a production kill switch, a hosted Sentinel service, or
|
|
267
|
+
complete Mesh coverage (ledger/execution/configuration sentinels are future
|
|
268
|
+
lanes). Honest scope and the full model:
|
|
269
|
+
[`docs/SENTINEL_MESH.md`](docs/SENTINEL_MESH.md).
|
|
270
|
+
|
|
224
271
|
### From the repo vs. as a CLI
|
|
225
272
|
|
|
226
273
|
- **From a repo checkout** (above): `npm install` then `npm run demo`.
|
package/bin/magenta-canon.mjs
CHANGED
|
@@ -48,6 +48,8 @@ Usage:
|
|
|
48
48
|
verify <mirror>, check <mirror> <bundle>, --self-test.
|
|
49
49
|
magenta-canon mirror-feed ... One feed cycle for a mirror operator:
|
|
50
50
|
(--bundle <file> | --url <evidence-url>) --mirror <file>.
|
|
51
|
+
magenta-canon sentinel <cmd> Sentinel Mesh: repository | artifact <tgz|dir> |
|
|
52
|
+
pack | invariants (deterministic invariant checks).
|
|
51
53
|
magenta-canon --help Show this help.
|
|
52
54
|
magenta-canon --version Print the version.
|
|
53
55
|
|
|
@@ -110,6 +112,11 @@ switch (sub) {
|
|
|
110
112
|
// Plain-Node operator helper; delegates all mirror semantics to `mirror`.
|
|
111
113
|
run(process.execPath, [path.join(ROOT, "scripts", "mirror-feed.mjs"), ...rest], { cwd: process.cwd() });
|
|
112
114
|
break;
|
|
115
|
+
case "sentinel":
|
|
116
|
+
// Sentinel Mesh foundation: deterministic invariant evaluation over a
|
|
117
|
+
// repository or a packed artifact (read-only; unsigned local diagnostics).
|
|
118
|
+
runTs("scripts/magenta-sentinel.ts", rest, { cwd: process.cwd() });
|
|
119
|
+
break;
|
|
113
120
|
case "-v":
|
|
114
121
|
case "--version":
|
|
115
122
|
case "version":
|
package/dist/MANIFEST.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"scripts/demo-control-plane.js": "3a559f39d17c1a403988e731e053d0d071cbd41ab595fd6246910f852337ab2f",
|
|
4
4
|
"scripts/intake-cli.js": "189014db22d6fccb034ed1a93ec11efe8905be820bc468366c68bb2cabaf8a97",
|
|
5
5
|
"scripts/magenta-mirror.js": "cf701065f7a6e20f7f44a9b815396aeb5fe031c3f003bcd793b8014ea8801b85",
|
|
6
|
+
"scripts/magenta-sentinel.js": "74336873d7f7c10825750e34a79988497ba793df593587f1c971b494a4d29f5f",
|
|
6
7
|
"scripts/magenta-verify.js": "0bb65ef6ff1350789eecc4f0434ca94dcf3a89930f8ef1d62cc38100e18094ba",
|
|
7
8
|
"scripts/mcp-gateway.js": "335923004b73511fe42ea0fc6f2e567afe8018dc95464a79027e4c2537e30de1",
|
|
8
9
|
"server/agent-policy.js": "20dd9150f8244c33aaf14ecdf3a09f471210960d246cda9ab8d5abe0e8433518",
|
|
@@ -24,6 +25,14 @@
|
|
|
24
25
|
"server/ledger-store.js": "fc8a46f925908764bac7a076cffa08053af4c1f93237bee408eb0ed1e61eeca2",
|
|
25
26
|
"server/ledger.js": "9bf8f132e08d636070d2ede568b2eaf75810be245c90ecd5979d2d4de69af934",
|
|
26
27
|
"server/persistence.js": "2e6b1d0b1c74babbd581780b028c2593256c5ec96c2d60f4c25159e3f54e4e3f",
|
|
28
|
+
"server/sentinel/artifact-sentinel.js": "93bae917af313c9247f8062af148e7c25ccfd16d0507fa85e755c64056715f8d",
|
|
29
|
+
"server/sentinel/ledger-sentinel.js": "37d1c7040ae2506454247d40b42bad6076273b577190eeb3a6571184849b1e71",
|
|
30
|
+
"server/sentinel/promotion.js": "68ac5e54eb8c07d5c75ba0d2325d595b1583557840cc46322b2053dd9924a0be",
|
|
31
|
+
"server/sentinel/repository-sentinel.js": "722e973c8860cf281b29db8c4a22ec3cf473afcb088247916e8278d5e11638cf",
|
|
32
|
+
"server/sentinel/rules.js": "ab24435df4f6f5b10f8b721fde37fe7fa894453648f17f04923cab56020c459f",
|
|
33
|
+
"server/sentinel/tar-reader.js": "ded811eefebf3e4afa332d1bc0a48318cc22ad824222eb15b2dc08eb51048272",
|
|
34
|
+
"server/sentinel/violation.js": "70d44ae5eb43a560fada399b31eac1a2beccb66916340e1f3e9b549731776b38",
|
|
35
|
+
"server/sentinel/witness-sentinel.js": "f024dbe9fb505c7520ec466ed31bf9b3284c3c3066dbd287121d2e4397d3fb0a",
|
|
27
36
|
"server/storage.js": "7a7556a6d15d736f028698b6094072daa71eed2230427594edac9d29b531081a",
|
|
28
37
|
"server/transparency-log.js": "7f238edc5cad4edc2c70a7b3e1977197cf2087e77789d181ffae0035f948391b",
|
|
29
38
|
"server/trust-bootstrap.js": "57a53a3ee9cd630ada2867e8b087a34b069ba26f86a696d3ead74888dd7e8d5f",
|
|
@@ -31,6 +40,7 @@
|
|
|
31
40
|
"server/witness.js": "b1a8209d4dae4ffafc3ca4a158474b369250472ba80cef03bd92048282be91a4",
|
|
32
41
|
"shared/canonical.js": "476cee4b5c5702e8a2a198e79c2639cf8ff84000ffb92f68a04433ed2a2f5484",
|
|
33
42
|
"shared/certificates.js": "7844e71a38e1b1324586c7d054b2f5c15222b86446318d1e6dea9bab504a4a73",
|
|
43
|
+
"shared/invariants.js": "7cde6f65dc014d9dd09849281734660369cbb0ea0f34faa583671efd2522d0c0",
|
|
34
44
|
"shared/schema.js": "f89190990e3826e44c722d5e8f5b39fd59b4d3fb9ec047229930a17604e4646c",
|
|
35
45
|
"shared/terminating-kernel.js": "8e2aa68d47d6780e4a70822482e3e410924e9d1af843f301a487da0af37ed047"
|
|
36
46
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
20
|
+
if (mod && mod.__esModule) return mod;
|
|
21
|
+
var result = {};
|
|
22
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
23
|
+
__setModuleDefault(result, mod);
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
/**
|
|
28
|
+
* magenta-sentinel — Sentinel Mesh CLI (foundation layer).
|
|
29
|
+
*
|
|
30
|
+
* magenta-canon sentinel repository [--root <dir>] [--json]
|
|
31
|
+
* magenta-canon sentinel artifact <tgz|package-dir> [--expected-version <v>] [--json]
|
|
32
|
+
* magenta-canon sentinel pack [--json] (npm pack, then evaluate the tarball)
|
|
33
|
+
*
|
|
34
|
+
* Exit codes: 0 eligible/diagnostic-only · 1 blocked · 2 requires-independent-review
|
|
35
|
+
* 3 usage / subject unreadable.
|
|
36
|
+
*
|
|
37
|
+
* Honesty: all records emitted here are UNSIGNED LOCAL DIAGNOSTICS. They are
|
|
38
|
+
* structured findings, not third-party-verifiable attestations.
|
|
39
|
+
*/
|
|
40
|
+
const node_child_process_1 = require("node:child_process");
|
|
41
|
+
const node_fs_1 = require("node:fs");
|
|
42
|
+
const path = __importStar(require("node:path"));
|
|
43
|
+
const repository_sentinel_1 = require("../server/sentinel/repository-sentinel");
|
|
44
|
+
const artifact_sentinel_1 = require("../server/sentinel/artifact-sentinel");
|
|
45
|
+
const witness_sentinel_1 = require("../server/sentinel/witness-sentinel");
|
|
46
|
+
const ledger_sentinel_1 = require("../server/sentinel/ledger-sentinel");
|
|
47
|
+
const promotion_1 = require("../server/sentinel/promotion");
|
|
48
|
+
const invariants_1 = require("../shared/invariants");
|
|
49
|
+
const USAGE = `magenta-sentinel — deterministic invariant evaluation (Sentinel Mesh foundation)
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
sentinel repository [--root <dir>] [--json]
|
|
53
|
+
sentinel artifact <tarball.tgz | package-dir> [--expected-version <v>] [--json]
|
|
54
|
+
sentinel pack [--json]
|
|
55
|
+
sentinel witness --ledger <file> [--keystore <file>] [--passphrase-env <VAR>]
|
|
56
|
+
[--anchor <pubkey-hex>] [--json]
|
|
57
|
+
sentinel ledger <ledger.jsonl> [--anchor <pubkey-hex>] [--keystore <file>]
|
|
58
|
+
[--passphrase-env <VAR>] [--mirror <mirror.jsonl>]
|
|
59
|
+
[--no-lock-inspection] [--json]
|
|
60
|
+
sentinel invariants [--json]
|
|
61
|
+
|
|
62
|
+
Exit: 0 eligible/diagnostic-only · 1 blocked · 2 requires review · 3 usage/unreadable
|
|
63
|
+
`;
|
|
64
|
+
function flag(args, name) {
|
|
65
|
+
const i = args.indexOf(name);
|
|
66
|
+
if (i >= 0) {
|
|
67
|
+
args.splice(i, 1);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
function option(args, name) {
|
|
73
|
+
const i = args.indexOf(name);
|
|
74
|
+
if (i >= 0 && i + 1 < args.length) {
|
|
75
|
+
const v = args[i + 1];
|
|
76
|
+
args.splice(i, 2);
|
|
77
|
+
return v;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
function printHuman(kind, subject, result, promo) {
|
|
82
|
+
console.log(`magenta-sentinel ${kind}`);
|
|
83
|
+
console.log(` subject: ${subject}`);
|
|
84
|
+
console.log(` invariants evaluated: ${result.evaluated_invariants.length} — passed ${result.passed.length}, failed ${result.failed.length}`);
|
|
85
|
+
for (const f of result.findings) {
|
|
86
|
+
console.log(` [FAIL] ${f.invariant_id} (${f.severity}, ${f.disposition})`);
|
|
87
|
+
console.log(` observed: ${f.observed}`);
|
|
88
|
+
console.log(` paths: ${f.paths.join(", ") || "-"}`);
|
|
89
|
+
}
|
|
90
|
+
for (const v of result.violations) {
|
|
91
|
+
console.log(` evidence: ${v.invariant_id} record ${v.record_hash.slice(0, 16)}… (${v.assurance})`);
|
|
92
|
+
}
|
|
93
|
+
if (result.findings.length === 0)
|
|
94
|
+
console.log(" all evaluated invariants hold");
|
|
95
|
+
console.log(` PROMOTION: ${promo.decision.toUpperCase()}${promo.evidence_complete ? "" : " (evidence INCOMPLETE)"}`);
|
|
96
|
+
}
|
|
97
|
+
function exitFor(promo) {
|
|
98
|
+
switch (promo.decision) {
|
|
99
|
+
case "eligible":
|
|
100
|
+
case "diagnostic-only": return 0;
|
|
101
|
+
case "blocked": return 1;
|
|
102
|
+
case "requires-independent-review": return 2;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const argv = process.argv.slice(2);
|
|
106
|
+
const sub = argv.shift();
|
|
107
|
+
const json = flag(argv, "--json");
|
|
108
|
+
try {
|
|
109
|
+
if (sub === "repository") {
|
|
110
|
+
const root = option(argv, "--root") ?? process.cwd();
|
|
111
|
+
const res = (0, repository_sentinel_1.runRepositorySentinel)({ repoRoot: path.resolve(root) });
|
|
112
|
+
const promo = (0, promotion_1.decidePromotion)({ subject: `repository:${res.subject_identity}`, findings: res.findings, violations: res.violations });
|
|
113
|
+
if (json)
|
|
114
|
+
console.log(JSON.stringify({ result: res, promotion: promo }, null, 2));
|
|
115
|
+
else
|
|
116
|
+
printHuman("repository", res.subject_identity, res, promo);
|
|
117
|
+
process.exit(exitFor(promo));
|
|
118
|
+
}
|
|
119
|
+
else if (sub === "artifact") {
|
|
120
|
+
const target = argv.find((a) => !a.startsWith("-"));
|
|
121
|
+
if (!target) {
|
|
122
|
+
console.error("sentinel artifact: missing <tarball|dir>\n\n" + USAGE);
|
|
123
|
+
process.exit(3);
|
|
124
|
+
}
|
|
125
|
+
const expectedVersion = option(argv, "--expected-version");
|
|
126
|
+
const res = (0, artifact_sentinel_1.runArtifactSentinel)(path.resolve(target), { expectedVersion });
|
|
127
|
+
const promo = (0, promotion_1.decidePromotion)({ subject: `artifact:${res.subject_identity}`, findings: res.findings, violations: res.violations });
|
|
128
|
+
if (json)
|
|
129
|
+
console.log(JSON.stringify({ result: res, promotion: promo }, null, 2));
|
|
130
|
+
else
|
|
131
|
+
printHuman("artifact", `${res.artifact_name ?? "?"}@${res.artifact_version ?? "?"} (${res.subject_identity.slice(0, 19)}…, ${res.file_count} files)`, res, promo);
|
|
132
|
+
process.exit(exitFor(promo));
|
|
133
|
+
}
|
|
134
|
+
else if (sub === "pack") {
|
|
135
|
+
const out = (0, node_child_process_1.execFileSync)("npm", ["pack", "--silent"], { cwd: process.cwd(), encoding: "utf8", shell: process.platform === "win32" });
|
|
136
|
+
const tgz = out.trim().split(/\r?\n/).pop();
|
|
137
|
+
if (!(0, node_fs_1.existsSync)(tgz)) {
|
|
138
|
+
console.error(`sentinel pack: npm pack did not produce ${tgz}`);
|
|
139
|
+
process.exit(3);
|
|
140
|
+
}
|
|
141
|
+
const res = (0, artifact_sentinel_1.runArtifactSentinel)(path.resolve(tgz));
|
|
142
|
+
const promo = (0, promotion_1.decidePromotion)({ subject: `artifact:${res.subject_identity}`, findings: res.findings, violations: res.violations });
|
|
143
|
+
if (json)
|
|
144
|
+
console.log(JSON.stringify({ result: res, promotion: promo, tarball: tgz }, null, 2));
|
|
145
|
+
else {
|
|
146
|
+
console.log(` packed: ${tgz}`);
|
|
147
|
+
printHuman("artifact", `${res.artifact_name}@${res.artifact_version}`, res, promo);
|
|
148
|
+
}
|
|
149
|
+
process.exit(exitFor(promo));
|
|
150
|
+
}
|
|
151
|
+
else if (sub === "witness") {
|
|
152
|
+
const ledger = option(argv, "--ledger");
|
|
153
|
+
if (!ledger) {
|
|
154
|
+
console.error("sentinel witness: missing --ledger <file>\n\n" + USAGE);
|
|
155
|
+
process.exit(3);
|
|
156
|
+
}
|
|
157
|
+
const keystore = option(argv, "--keystore");
|
|
158
|
+
const res = (0, witness_sentinel_1.runWitnessSentinel)({
|
|
159
|
+
ledgerPath: path.resolve(ledger),
|
|
160
|
+
keystorePath: keystore ? path.resolve(keystore) : undefined,
|
|
161
|
+
passphraseEnv: option(argv, "--passphrase-env"),
|
|
162
|
+
anchorPubkey: option(argv, "--anchor"),
|
|
163
|
+
});
|
|
164
|
+
const promo = (0, promotion_1.decidePromotion)({ subject: `witness:${res.subject_identity}`, findings: res.findings, violations: res.violations });
|
|
165
|
+
if (json)
|
|
166
|
+
console.log(JSON.stringify({ result: res, promotion: promo }, null, 2));
|
|
167
|
+
else {
|
|
168
|
+
printHuman("witness", `${res.subject_identity.slice(0, 27)}… (${res.ledger_sth_count} STH, ${res.ledger_rotation_count} rotation(s), tip ${res.tip_fingerprint ?? "-"})`, res, promo);
|
|
169
|
+
for (const n of res.not_evaluated)
|
|
170
|
+
console.log(` [NOT EVALUATED] ${n.invariant_id}: ${n.reason}`);
|
|
171
|
+
if (res.incomplete_tail_ignored)
|
|
172
|
+
console.log(" note: incomplete trailing ledger line ignored (crash-tail semantics)");
|
|
173
|
+
}
|
|
174
|
+
process.exit(exitFor(promo));
|
|
175
|
+
}
|
|
176
|
+
else if (sub === "ledger") {
|
|
177
|
+
const target = argv.find((a) => !a.startsWith("-"));
|
|
178
|
+
if (!target) {
|
|
179
|
+
console.error("sentinel ledger: missing <ledger.jsonl>\n\n" + USAGE);
|
|
180
|
+
process.exit(3);
|
|
181
|
+
}
|
|
182
|
+
const keystore = option(argv, "--keystore");
|
|
183
|
+
const mirror = option(argv, "--mirror");
|
|
184
|
+
const res = (0, ledger_sentinel_1.runLedgerSentinel)({
|
|
185
|
+
ledgerPath: path.resolve(target),
|
|
186
|
+
anchorPubkey: option(argv, "--anchor") ?? option(argv, "--expected-witness-key"),
|
|
187
|
+
keystorePath: keystore ? path.resolve(keystore) : undefined,
|
|
188
|
+
passphraseEnv: option(argv, "--passphrase-env"),
|
|
189
|
+
mirrorPath: mirror ? path.resolve(mirror) : undefined,
|
|
190
|
+
inspectLock: !flag(argv, "--no-lock-inspection"),
|
|
191
|
+
});
|
|
192
|
+
const promo = (0, promotion_1.decidePromotion)({ subject: `ledger:${res.subject_identity}`, findings: res.findings, violations: res.violations });
|
|
193
|
+
if (json)
|
|
194
|
+
console.log(JSON.stringify({ result: res, promotion: promo }, null, 2));
|
|
195
|
+
else {
|
|
196
|
+
printHuman("ledger", `${res.subject_identity.slice(0, 26)}… (${res.record_count} records: ${res.receipt_count} receipts, ${res.checkpoint_count} STH, ${res.rotation_count} rotation(s))`, res, promo);
|
|
197
|
+
for (const n of res.not_evaluated)
|
|
198
|
+
console.log(` [NOT EVALUATED] ${n.invariant_id}: ${n.reason}`);
|
|
199
|
+
if (res.incomplete_tail_quarantined)
|
|
200
|
+
console.log(" note: incomplete trailing append quarantined (carries no authority)");
|
|
201
|
+
if (res.witness_cross_run)
|
|
202
|
+
console.log(` witness cross-run: ${res.witness_cross_run.failed.length === 0 ? "agrees" : "DISAGREES"} (tip ${res.witness_cross_run.tip_fingerprint ?? "-"})`);
|
|
203
|
+
}
|
|
204
|
+
process.exit(exitFor(promo));
|
|
205
|
+
}
|
|
206
|
+
else if (sub === "invariants") {
|
|
207
|
+
if (json)
|
|
208
|
+
console.log(JSON.stringify(invariants_1.INVARIANTS, null, 2));
|
|
209
|
+
else
|
|
210
|
+
for (const i of invariants_1.INVARIANTS)
|
|
211
|
+
console.log(`${i.id} v${i.version} ${i.status.padEnd(9)} ${i.severity.padEnd(8)} ${i.disposition.padEnd(26)} ${i.name}`);
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
process.stdout.write(USAGE);
|
|
216
|
+
process.exit(sub === undefined || sub === "--help" || sub === "-h" ? 0 : 3);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
if (e instanceof artifact_sentinel_1.TarError) {
|
|
221
|
+
console.error(`sentinel: artifact unreadable (refusing to guess): ${e.message}`);
|
|
222
|
+
process.exit(3);
|
|
223
|
+
}
|
|
224
|
+
console.error(`sentinel: ${e.message}`);
|
|
225
|
+
process.exit(3);
|
|
226
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.TarError = exports.runArtifactSentinel = exports.ARTIFACT_SENTINEL_VERSION = exports.ARTIFACT_SENTINEL_TYPE = void 0;
|
|
27
|
+
/**
|
|
28
|
+
* ARTIFACT SENTINEL — deterministic evaluation of artifact/dependency
|
|
29
|
+
* invariants (MC-ART-*, MC-DEP-*) over a packed npm artifact.
|
|
30
|
+
*
|
|
31
|
+
* Three subject modes:
|
|
32
|
+
* - tarball: a .tgz file (read in-memory via the hardened tar reader);
|
|
33
|
+
* - directory: an extracted package directory (or any package root);
|
|
34
|
+
* - the current `npm pack` output (the CLI wires this; the sentinel itself
|
|
35
|
+
* only ever sees a tarball path or directory path).
|
|
36
|
+
*
|
|
37
|
+
* Read-only (MC-REL-004), no network, deterministic, redacting. It does NOT
|
|
38
|
+
* claim npm-registry origin: subject identity is the sha256 the sentinel
|
|
39
|
+
* computed locally; binding that to a registry artifact requires registry
|
|
40
|
+
* metadata supplied and verified independently.
|
|
41
|
+
*/
|
|
42
|
+
const node_crypto_1 = require("node:crypto");
|
|
43
|
+
const node_fs_1 = require("node:fs");
|
|
44
|
+
const path = __importStar(require("node:path"));
|
|
45
|
+
const invariants_1 = require("../../shared/invariants");
|
|
46
|
+
const violation_1 = require("./violation");
|
|
47
|
+
const tar_reader_1 = require("./tar-reader");
|
|
48
|
+
Object.defineProperty(exports, "TarError", { enumerable: true, get: function () { return tar_reader_1.TarError; } });
|
|
49
|
+
const rules_1 = require("./rules");
|
|
50
|
+
exports.ARTIFACT_SENTINEL_TYPE = "artifact-sentinel";
|
|
51
|
+
exports.ARTIFACT_SENTINEL_VERSION = "1.0.0";
|
|
52
|
+
function finding(invId, observed, expected, paths) {
|
|
53
|
+
const inv = (0, invariants_1.getInvariant)(invId);
|
|
54
|
+
return {
|
|
55
|
+
invariant_id: inv.id, invariant_version: inv.version, severity: inv.severity,
|
|
56
|
+
authority_class: inv.authorityClass, disposition: inv.disposition,
|
|
57
|
+
observed, expected, paths: paths.sort(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function loadTarball(tgzPath) {
|
|
61
|
+
const buf = (0, node_fs_1.readFileSync)(tgzPath);
|
|
62
|
+
const sha256 = (0, node_crypto_1.createHash)("sha256").update(buf).digest("hex");
|
|
63
|
+
const entries = (0, tar_reader_1.readTgz)(buf);
|
|
64
|
+
const suspicious = entries.filter((e) => e.suspiciousPath).map((e) => e.name);
|
|
65
|
+
const files = entries
|
|
66
|
+
.filter((e) => e.typeflag === "0" || e.typeflag === "\0")
|
|
67
|
+
.map((e) => ({ rel: e.name.replace(/^package\//, ""), content: e.content }));
|
|
68
|
+
return { files, sha256, suspicious };
|
|
69
|
+
}
|
|
70
|
+
function walkDir(root) {
|
|
71
|
+
const out = [];
|
|
72
|
+
const stack = [root];
|
|
73
|
+
while (stack.length) {
|
|
74
|
+
const dir = stack.pop();
|
|
75
|
+
for (const name of (0, node_fs_1.readdirSync)(dir).sort()) {
|
|
76
|
+
const abs = path.join(dir, name);
|
|
77
|
+
const st = (0, node_fs_1.statSync)(abs);
|
|
78
|
+
if (st.isDirectory()) {
|
|
79
|
+
if (name === "node_modules" || name === ".git")
|
|
80
|
+
continue;
|
|
81
|
+
stack.push(abs);
|
|
82
|
+
}
|
|
83
|
+
else if (st.isFile()) {
|
|
84
|
+
out.push(abs);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out.sort();
|
|
89
|
+
}
|
|
90
|
+
function loadDirectory(dirPath) {
|
|
91
|
+
const abs = walkDir(dirPath);
|
|
92
|
+
const files = abs.map((a) => ({
|
|
93
|
+
rel: path.relative(dirPath, a).split(path.sep).join("/"),
|
|
94
|
+
content: (0, node_fs_1.readFileSync)(a),
|
|
95
|
+
}));
|
|
96
|
+
// Deterministic directory identity: hash of sorted (path, sha256) pairs.
|
|
97
|
+
const h = (0, node_crypto_1.createHash)("sha256");
|
|
98
|
+
for (const f of files) {
|
|
99
|
+
h.update(f.rel);
|
|
100
|
+
h.update("\0");
|
|
101
|
+
h.update((0, node_crypto_1.createHash)("sha256").update(f.content).digest());
|
|
102
|
+
h.update("\0");
|
|
103
|
+
}
|
|
104
|
+
return { files, sha256: `dir:${h.digest("hex")}` };
|
|
105
|
+
}
|
|
106
|
+
function runArtifactSentinel(subjectPath, opts = {}) {
|
|
107
|
+
if (!(0, node_fs_1.existsSync)(subjectPath)) {
|
|
108
|
+
throw new Error(`artifact-sentinel: subject not found: ${subjectPath}`);
|
|
109
|
+
}
|
|
110
|
+
const isDir = (0, node_fs_1.statSync)(subjectPath).isDirectory();
|
|
111
|
+
const findings = [];
|
|
112
|
+
let files;
|
|
113
|
+
let subjectIdentity;
|
|
114
|
+
const mode = isDir ? "directory" : "tarball";
|
|
115
|
+
if (isDir) {
|
|
116
|
+
({ files, sha256: subjectIdentity } = loadDirectory(subjectPath));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const loaded = loadTarball(subjectPath); // throws TarError on malformed/bomb
|
|
120
|
+
files = loaded.files;
|
|
121
|
+
subjectIdentity = loaded.sha256;
|
|
122
|
+
if (loaded.suspicious.length > 0) {
|
|
123
|
+
// No invariant ID needed to refuse this: a traversal-shaped entry means
|
|
124
|
+
// the archive is not an honest npm artifact. Reported under MC-ART-007
|
|
125
|
+
// (manifest/files integrity) as a structural integrity failure.
|
|
126
|
+
findings.push(finding("MC-ART-007", `archive contains suspicious entry path(s): ${loaded.suspicious.join(", ")}`, "all archive entry paths are relative and traversal-free", loaded.suspicious));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const fileMap = new Map(files.map((f) => [f.rel, f]));
|
|
130
|
+
const relPaths = files.map((f) => f.rel);
|
|
131
|
+
// package.json metadata
|
|
132
|
+
let pkg = null;
|
|
133
|
+
const pkgFile = fileMap.get("package.json");
|
|
134
|
+
if (pkgFile) {
|
|
135
|
+
try {
|
|
136
|
+
pkg = JSON.parse(pkgFile.content.toString("utf8"));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
pkg = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const evaluatedDomains = new Set(["artifact", "dependency"]);
|
|
143
|
+
const evaluated = (0, invariants_1.ratifiedInvariants)().filter((i) => evaluatedDomains.has(i.domain)).map((i) => i.id);
|
|
144
|
+
// ── MC-ART-001 / MC-ART-002: secrets & identities ─────────────────────
|
|
145
|
+
const pemHits = [];
|
|
146
|
+
const prints = [];
|
|
147
|
+
for (const f of files) {
|
|
148
|
+
if (f.content.length > rules_1.MAX_SCAN_BYTES || (0, rules_1.isProbablyBinary)(f.content))
|
|
149
|
+
continue;
|
|
150
|
+
const text = f.content.toString("utf8");
|
|
151
|
+
if (rules_1.PEM_PRIVATE_KEY_WITH_BODY.test(text)) {
|
|
152
|
+
pemHits.push(f.rel);
|
|
153
|
+
prints.push(`${f.rel}=${(0, rules_1.matchFingerprint)(text, rules_1.PEM_PRIVATE_KEY_WITH_BODY)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (pemHits.length > 0) {
|
|
157
|
+
findings.push(finding("MC-ART-001", `PEM private-key material in artifact; fingerprints: ${prints.join(", ")}`, "artifact contains no private-key material", pemHits));
|
|
158
|
+
}
|
|
159
|
+
const identityHits = relPaths.filter((p) => rules_1.FORBIDDEN_IDENTITY_PATHS.some((re) => re.test(p)));
|
|
160
|
+
if (identityHits.length > 0) {
|
|
161
|
+
findings.push(finding("MC-ART-002", "generated identity / keystore entry in artifact", "no identity or keystore entries", identityHits));
|
|
162
|
+
}
|
|
163
|
+
// ── MC-ART-003: tests ──────────────────────────────────────────────────
|
|
164
|
+
const testHits = relPaths.filter((p) => rules_1.TEST_FILE_PATHS.some((re) => re.test(p)));
|
|
165
|
+
if (testHits.length > 0) {
|
|
166
|
+
findings.push(finding("MC-ART-003", "test file(s) in artifact", "no test files ship", testHits));
|
|
167
|
+
}
|
|
168
|
+
// ── MC-ART-004: hosted plane (+ legacy heartbeat surfaces) ────────────
|
|
169
|
+
const hostedHits = relPaths.filter((p) => rules_1.HOSTED_PLANE_PATHS.some((re) => re.test(p)) || rules_1.FORBIDDEN_HEARTBEAT_PATHS.some((re) => re.test(p)));
|
|
170
|
+
if (hostedHits.length > 0) {
|
|
171
|
+
findings.push(finding("MC-ART-004", "hosted-only / legacy module(s) in artifact", "no hosted-plane or legacy heartbeat modules ship", hostedHits));
|
|
172
|
+
}
|
|
173
|
+
// ── MC-ART-005: runtime TS / source maps ──────────────────────────────
|
|
174
|
+
const tsHits = relPaths.filter((p) => rules_1.RUNTIME_TS_PATH.test(p) && !rules_1.TYPE_DECLARATION_PATH.test(p));
|
|
175
|
+
const mapHits = relPaths.filter((p) => rules_1.SOURCE_MAP_PATH.test(p));
|
|
176
|
+
if (tsHits.length + mapHits.length > 0) {
|
|
177
|
+
findings.push(finding("MC-ART-005", "runtime TypeScript or source map(s) in artifact", "precompiled JS only; no .ts (non-decl) or .map", [...tsHits, ...mapHits]));
|
|
178
|
+
}
|
|
179
|
+
// ── MC-ART-006: required entrypoints ──────────────────────────────────
|
|
180
|
+
const missingEntry = rules_1.REQUIRED_DIST_ENTRYPOINTS.filter((e) => !fileMap.has(e));
|
|
181
|
+
if (missingEntry.length > 0) {
|
|
182
|
+
findings.push(finding("MC-ART-006", `missing compiled entrypoint(s): ${missingEntry.join(", ")}`, "all required compiled entrypoints present", missingEntry));
|
|
183
|
+
}
|
|
184
|
+
// ── MC-ART-007: manifest equality ─────────────────────────────────────
|
|
185
|
+
const manifestFile = fileMap.get("dist/MANIFEST.json");
|
|
186
|
+
if (!manifestFile) {
|
|
187
|
+
findings.push(finding("MC-ART-007", "dist/MANIFEST.json missing", "manifest present and matching", ["dist/MANIFEST.json"]));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
try {
|
|
191
|
+
const manifest = JSON.parse(manifestFile.content.toString("utf8"));
|
|
192
|
+
// build-lean writes a flat { "<dist-relative path>": "<sha256>" } map.
|
|
193
|
+
const listed = typeof manifest.files === "object" && manifest.files !== null ? manifest.files : manifest;
|
|
194
|
+
const distFiles = relPaths.filter((p) => p.startsWith("dist/") && p !== "dist/MANIFEST.json");
|
|
195
|
+
const bad = [];
|
|
196
|
+
for (const [rel, hash] of Object.entries(listed)) {
|
|
197
|
+
const f = fileMap.get(`dist/${rel}`);
|
|
198
|
+
if (!f) {
|
|
199
|
+
bad.push(`missing:dist/${rel}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const actual = (0, node_crypto_1.createHash)("sha256").update(f.content).digest("hex");
|
|
203
|
+
if (actual !== hash)
|
|
204
|
+
bad.push(`hash-mismatch:dist/${rel}`);
|
|
205
|
+
}
|
|
206
|
+
for (const p of distFiles) {
|
|
207
|
+
const rel = p.replace(/^dist\//, "");
|
|
208
|
+
if (!(rel in listed))
|
|
209
|
+
bad.push(`unlisted:${p}`);
|
|
210
|
+
}
|
|
211
|
+
if (bad.length > 0) {
|
|
212
|
+
findings.push(finding("MC-ART-007", `manifest disagreement: ${bad.join(", ")}`, "dist/MANIFEST.json lists exactly the dist files with matching sha256", bad));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
findings.push(finding("MC-ART-007", "dist/MANIFEST.json unparseable", "manifest present and matching", ["dist/MANIFEST.json"]));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// ── MC-ART-008: version consistency ───────────────────────────────────
|
|
220
|
+
const artifactVersion = pkg?.version ?? null;
|
|
221
|
+
if (!pkg) {
|
|
222
|
+
findings.push(finding("MC-ART-008", "package.json missing or unparseable", "artifact carries valid package metadata", ["package.json"]));
|
|
223
|
+
}
|
|
224
|
+
else if (opts.expectedVersion && artifactVersion !== opts.expectedVersion) {
|
|
225
|
+
findings.push(finding("MC-ART-008", `artifact version ${artifactVersion} != expected ${opts.expectedVersion}`, "artifact version equals the version under evaluation", ["package.json"]));
|
|
226
|
+
}
|
|
227
|
+
// ── MC-DEP-*: dependency posture ──────────────────────────────────────
|
|
228
|
+
if (pkg) {
|
|
229
|
+
const deps = pkg.dependencies ?? {};
|
|
230
|
+
const depNames = Object.keys(deps).sort();
|
|
231
|
+
const unexpected = depNames.filter((d) => !rules_1.APPROVED_RUNTIME_DEPENDENCIES.includes(d));
|
|
232
|
+
if (unexpected.length > 0) {
|
|
233
|
+
findings.push(finding("MC-DEP-001", `unexpected runtime dependencies: ${unexpected.map((d) => `${d}@${deps[d]}`).join(", ")}`, `runtime dependencies exactly: ${rules_1.APPROVED_RUNTIME_DEPENDENCIES.join(", ")}`, ["package.json"]));
|
|
234
|
+
}
|
|
235
|
+
const installScripts = rules_1.INSTALL_SCRIPT_NAMES.filter((s) => pkg.scripts && s in pkg.scripts);
|
|
236
|
+
if (installScripts.length > 0) {
|
|
237
|
+
findings.push(finding("MC-DEP-002", `install lifecycle script(s) declared: ${installScripts.join(", ")}`, "no install lifecycle scripts", ["package.json"]));
|
|
238
|
+
}
|
|
239
|
+
const transpilers = depNames.filter((d) => rules_1.FORBIDDEN_TRANSPILERS.includes(d));
|
|
240
|
+
if (transpilers.length > 0) {
|
|
241
|
+
findings.push(finding("MC-DEP-004", `runtime transpiler dependency: ${transpilers.join(", ")}`, "no runtime transpiler/loader dependencies", ["package.json"]));
|
|
242
|
+
}
|
|
243
|
+
const hostedDeps = depNames.filter((d) => rules_1.HOSTED_DEPENDENCIES.includes(d));
|
|
244
|
+
if (hostedDeps.length > 0) {
|
|
245
|
+
findings.push(finding("MC-DEP-005", `hosted-plane dependency leaked: ${hostedDeps.join(", ")}`, "no hosted-plane runtime dependencies", ["package.json"]));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const nativeHits = [];
|
|
249
|
+
for (const f of files) {
|
|
250
|
+
const cls = (0, rules_1.nativeBinaryClass)(f.content);
|
|
251
|
+
if (cls || f.rel.endsWith(".node"))
|
|
252
|
+
nativeHits.push(`${f.rel}${cls ? ` (${cls})` : ""}`);
|
|
253
|
+
}
|
|
254
|
+
if (nativeHits.length > 0) {
|
|
255
|
+
findings.push(finding("MC-DEP-003", `native binary entries: ${nativeHits.join(", ")}`, "no native binaries in artifact", nativeHits.map((s) => s.split(" ")[0])));
|
|
256
|
+
}
|
|
257
|
+
const failed = Array.from(new Set(findings.map((f) => f.invariant_id))).sort();
|
|
258
|
+
const passed = evaluated.filter((id) => !failed.includes(id)).sort();
|
|
259
|
+
const violations = findings.map((f) => (0, violation_1.buildViolationRecord)({
|
|
260
|
+
sentinelType: exports.ARTIFACT_SENTINEL_TYPE,
|
|
261
|
+
sentinelVersion: exports.ARTIFACT_SENTINEL_VERSION,
|
|
262
|
+
subjectType: "artifact",
|
|
263
|
+
subjectIdentity,
|
|
264
|
+
finding: f,
|
|
265
|
+
detectedAt: opts.detectedAt,
|
|
266
|
+
detector: opts.detector,
|
|
267
|
+
}));
|
|
268
|
+
const blocking = findings.some((f) => f.disposition !== "report");
|
|
269
|
+
return {
|
|
270
|
+
sentinel_type: exports.ARTIFACT_SENTINEL_TYPE,
|
|
271
|
+
sentinel_version: exports.ARTIFACT_SENTINEL_VERSION,
|
|
272
|
+
subject_type: "artifact",
|
|
273
|
+
subject_identity: subjectIdentity,
|
|
274
|
+
subject_mode: mode,
|
|
275
|
+
artifact_name: pkg?.name ?? null,
|
|
276
|
+
artifact_version: artifactVersion,
|
|
277
|
+
file_count: files.length,
|
|
278
|
+
evaluated_invariants: evaluated.sort(),
|
|
279
|
+
passed,
|
|
280
|
+
failed,
|
|
281
|
+
findings,
|
|
282
|
+
violations,
|
|
283
|
+
release_promotion_eligible: !blocking,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
exports.runArtifactSentinel = runArtifactSentinel;
|