@bradheitmann/odin-sentinel 0.4.9 → 0.4.10

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.
@@ -2,12 +2,15 @@
2
2
  // scripts/protocol/install-activation-hooks.mjs
3
3
  //
4
4
  // Install (or preview) the ODIN/SCP activation-gate hook that forces full-instruction-read
5
- // proof before an activated role begins implementation, QA acceptance, or ACTIVE_WATCH work.
5
+ // proof and, when supplied, governed-context proof before an activated role begins
6
+ // implementation, QA acceptance, or ACTIVE_WATCH work.
6
7
  //
7
8
  // The hook is intentionally a small, dependency-free shell wrapper that runs the
8
- // instruction-read verifier against a declared proof file and refuses to proceed unless the
9
- // verifier exits 0. This makes "did the agent actually read its instructions?" a
10
- // machine-checkable gate instead of an honor-system claim.
9
+ // instruction-read verifier (and, when a governed-context proof is passed, the governed-context
10
+ // verifier) against declared proof files and refuses to proceed unless they exit 0. This makes
11
+ // "did the agent actually read its instructions?" AND "did the harness actually load and take
12
+ // up the SCP control layer?" machine-checkable gates instead of honor-system claims. Presence
13
+ // of MCP config or a skill file on disk is never sufficient for governed authority.
11
14
  //
12
15
  // Safety: default mode is a dry-run that prints the plan and the hook body. Files are only
13
16
  // written when --install --target <dir> is given explicitly. Zero-secret: no environment
@@ -20,9 +23,11 @@ const HOOK_FILENAME = "odin-activation-precheck.sh";
20
23
 
21
24
  const USAGE = `odin install-activation-hooks
22
25
 
23
- Install or preview the SCP activation-gate precheck hook. The hook runs the
24
- instruction-read verifier before an activated role acts, and blocks activation unless a
25
- full-instruction-read proof verifies against local files.
26
+ Install or preview the SCP activation-gate precheck hook. The hook runs the instruction-read
27
+ verifier before an activated role acts, and when a governed-context proof is supplied — the
28
+ governed-context verifier (scripts/protocol/verify-governed-context.mjs). It blocks activation
29
+ unless the declared proofs verify against local files. Governed authority is fail-closed: MCP
30
+ being configured or a skill existing on disk is never enough; protocol uptake must be verified.
26
31
 
27
32
  Usage:
28
33
  node scripts/protocol/install-activation-hooks.mjs # dry-run: print plan + hook
@@ -30,6 +35,8 @@ Usage:
30
35
  node scripts/protocol/install-activation-hooks.mjs --install --target <dir>
31
36
  node scripts/protocol/install-activation-hooks.mjs --help
32
37
 
38
+ The installed hook accepts: <instruction-read-proof.json> [--governed-context <proof.json>] [--base <dir>].
39
+
33
40
  Options:
34
41
  --install Write the hook file. Requires --target.
35
42
  --target <dir> Directory to write ${HOOK_FILENAME} into (created if absent).
@@ -46,37 +53,61 @@ export function renderHookScript() {
46
53
  return `#!/bin/sh
47
54
  # odin-activation-precheck.sh — installed by scripts/protocol/install-activation-hooks.mjs
48
55
  #
49
- # Block an activated SCP role from acting until its full-instruction-read proof verifies.
50
- # Usage: odin-activation-precheck.sh <proof.json> [--base <dir>]
56
+ # Block an activated SCP role from acting until its activation proofs verify:
57
+ # 1. full-instruction-read proof (verify-instruction-read.mjs) always.
58
+ # 2. governed-context proof (verify-governed-context.mjs) — when --governed-context is given.
59
+ # Governed authority is fail-closed: MCP being configured or a skill on disk is NOT enough;
60
+ # protocol uptake must be verified before a governed occupant acts.
61
+ #
62
+ # Usage: odin-activation-precheck.sh <instruction-read-proof.json> [--governed-context <proof.json>] [--base <dir>]
51
63
  #
52
64
  # Wire this into a harness pre-activation step, a git pre-commit hook, or a launch runbook
53
- # so implementation / QA / ACTIVE_WATCH work cannot start on a skimmed instruction set.
65
+ # so implementation / QA / ACTIVE_WATCH work cannot start on a skimmed instruction set or
66
+ # without proven SCP control-layer uptake.
54
67
  set -eu
55
68
 
56
69
  PROOF="\${1:-}"
57
70
  if [ -z "\$PROOF" ]; then
58
- echo "odin-activation-precheck: missing <proof.json> argument" >&2
71
+ echo "odin-activation-precheck: missing <instruction-read-proof.json> argument" >&2
59
72
  echo "an activated role must declare and verify a full-instruction-read proof first" >&2
60
73
  exit 2
61
74
  fi
62
75
  shift || true
63
76
 
77
+ GOVERNED_PROOF=""
78
+ BASE_DIR=""
79
+ while [ \$# -gt 0 ]; do
80
+ case "\$1" in
81
+ --governed-context) GOVERNED_PROOF="\${2:-}"; shift 2 || true ;;
82
+ --base) BASE_DIR="\${2:-}"; shift 2 || true ;;
83
+ *) shift || true ;;
84
+ esac
85
+ done
86
+
64
87
  SCRIPT_DIR=\$(CDPATH= cd -- "\$(dirname -- "\$0")" && pwd)
65
- VERIFIER="\$SCRIPT_DIR/verify-instruction-read.mjs"
66
- if [ ! -f "\$VERIFIER" ]; then
67
- # Fall back to the repo-relative protocol script location.
68
- VERIFIER="scripts/protocol/verify-instruction-read.mjs"
69
- fi
88
+ IR_VERIFIER="\$SCRIPT_DIR/verify-instruction-read.mjs"
89
+ [ -f "\$IR_VERIFIER" ] || IR_VERIFIER="scripts/protocol/verify-instruction-read.mjs"
90
+ GC_VERIFIER="\$SCRIPT_DIR/verify-governed-context.mjs"
91
+ [ -f "\$GC_VERIFIER" ] || GC_VERIFIER="scripts/protocol/verify-governed-context.mjs"
70
92
 
71
93
  echo "odin-activation-precheck: verifying full-instruction-read proof \$PROOF"
72
- if node "\$VERIFIER" "\$PROOF" "\$@"; then
73
- echo "odin-activation-precheck: PASS — instruction-read proof verified; activation allowed"
74
- exit 0
94
+ if [ -n "\$BASE_DIR" ]; then
95
+ node "\$IR_VERIFIER" "\$PROOF" --base "\$BASE_DIR" || { echo "odin-activation-precheck: FAIL — instruction-read proof did not verify; activation blocked" >&2; exit 1; }
75
96
  else
76
- echo "odin-activation-precheck: FAIL — instruction-read proof did not verify; activation blocked" >&2
77
- echo "read the declared instruction files in full and regenerate the proof before acting" >&2
78
- exit 1
97
+ node "\$IR_VERIFIER" "\$PROOF" || { echo "odin-activation-precheck: FAIL — instruction-read proof did not verify; activation blocked" >&2; exit 1; }
79
98
  fi
99
+
100
+ if [ -n "\$GOVERNED_PROOF" ]; then
101
+ echo "odin-activation-precheck: verifying governed-context proof \$GOVERNED_PROOF"
102
+ if [ -n "\$BASE_DIR" ]; then
103
+ node "\$GC_VERIFIER" "\$GOVERNED_PROOF" --base "\$BASE_DIR" || { echo "odin-activation-precheck: FAIL — governed-context proof did not verify; governed activation blocked" >&2; exit 1; }
104
+ else
105
+ node "\$GC_VERIFIER" "\$GOVERNED_PROOF" || { echo "odin-activation-precheck: FAIL — governed-context proof did not verify; governed activation blocked" >&2; exit 1; }
106
+ fi
107
+ fi
108
+
109
+ echo "odin-activation-precheck: PASS — activation proofs verified; activation allowed"
110
+ exit 0
80
111
  `;
81
112
  }
82
113
 
@@ -85,12 +116,19 @@ export function planInstall(target) {
85
116
  action: "install-activation-hooks",
86
117
  hookFile: target ? join(target, HOOK_FILENAME) : `<target>/${HOOK_FILENAME}`,
87
118
  verifier: "scripts/protocol/verify-instruction-read.mjs",
119
+ governedContextVerifier: "scripts/protocol/verify-governed-context.mjs",
88
120
  gates: [
89
121
  "An activated role must produce a full-instruction-read proof before implementation, QA acceptance, or ACTIVE_WATCH work.",
90
- "The precheck hook runs verify-instruction-read.mjs and blocks activation unless it exits 0.",
122
+ "A governed-role occupant must additionally produce a governed-context proof: presence of MCP config or a skill file on disk is not authority; protocol uptake must be verified.",
123
+ "The precheck hook runs verify-instruction-read.mjs (and verify-governed-context.mjs when --governed-context is given) and blocks activation unless they exit 0.",
91
124
  "CMUX dispatch must additionally satisfy delivery proof: submitted=true plus verified processing on the target surface."
92
125
  ],
93
126
  mcp: ["odin.get_activation_gates", "odin.validate_instruction_read_proof", "odin.validate_cmux_delivery_proof"],
127
+ governedContext: {
128
+ note: "Governed readiness is fail-closed (GOVERNED_READY / FIXABLE_BLOCKED / NON_GOVERNED_ONE_SHOT_ONLY / UNSUPPORTED). The installer never writes into harness skill/config directories; it only writes the precheck hook into the explicit --target dir.",
129
+ verifier: "scripts/protocol/verify-governed-context.mjs",
130
+ surfacedBy: ["odin.get_activation_gates", "odin.get_harness_probe_matrix", "odin.evaluate_readiness_gate", "odin.get_onboarding_plan"]
131
+ },
94
132
  wiring: [
95
133
  "harness pre-activation step (preferred)",
96
134
  "git pre-commit / pre-push hook",
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env node
2
+ // scripts/protocol/verify-governed-context.mjs
3
+ //
4
+ // Verify an ODIN/SCP governed-context proof: proof that a target agent's SCP control layer
5
+ // (native skill, static control file, or MCP bootstrap resource) is not merely PRESENT but was
6
+ // actually LOADED and taken up, with a stable, machine-checkable source marker.
7
+ //
8
+ // This is the fail-closed companion to verify-instruction-read.mjs. "MCP configured" or "skill
9
+ // file on disk" is never enough for governed authority: a governed-context proof must declare a
10
+ // control source with a stable marker, prove that marker on disk when a path is given (sha256),
11
+ // and carry an uptake receipt whose evidence marker matches the control source. A self-asserted
12
+ // boolean with no stable source marker is rejected.
13
+ //
14
+ // Zero-secret: this script reads only the declared control-source file. It never reads
15
+ // environment variables and never prints file contents — only paths, byte counts, digests,
16
+ // markers, freshness, and PASS/FAIL reasons. Any secret-looking value inside the proof itself
17
+ // is a hard failure.
18
+
19
+ import { existsSync, readFileSync, statSync } from "node:fs";
20
+ import { createHash } from "node:crypto";
21
+ import { isAbsolute, join, resolve } from "node:path";
22
+
23
+ const SCHEMA_ID = "odin.governed_context_proof.v1";
24
+ const SOURCE_TYPES = ["native_skill", "static_control_file", "mcp_bootstrap"];
25
+ const DEFAULT_MAX_AGE_SECONDS = 86_400; // 24h: a governed-context proof must be fresh.
26
+
27
+ const USAGE = `odin verify-governed-context
28
+
29
+ Verify a governed-context proof: prove the SCP control layer was loaded and taken up, not just
30
+ present. "MCP configured" or "skill on disk" alone never qualifies — a stable source marker,
31
+ a disk checksum when a path is given, and a matching uptake receipt are required.
32
+
33
+ Usage:
34
+ node scripts/protocol/verify-governed-context.mjs <proof.json> [--base <dir>] [--now <iso>] [--max-age-seconds <n>] [--json]
35
+ node scripts/protocol/verify-governed-context.mjs --record --source <file> --marker <text> [--source-type <type>] [--harness <name>] [--harness-category <cat>] [--role <role>] [--version <v>] [--base <dir>]
36
+ node scripts/protocol/verify-governed-context.mjs --help
37
+
38
+ Modes:
39
+ verify (default) Read <proof.json> and validate schema, control source (disk checksum when a
40
+ path is present), source marker, uptake receipt, freshness, and zero-secret.
41
+ Exit 0 only if everything verifies; exit 1 on any failure.
42
+ --record Compute a governed-context proof skeleton for the declared control source and
43
+ print it as JSON on stdout (does not write any file).
44
+
45
+ Options:
46
+ --base <dir> Resolve control-source paths against <dir> (default: current dir).
47
+ --now <iso> Treat this ISO-8601 instant as "now" for freshness (default: system clock).
48
+ --max-age-seconds <n> Maximum proof age before it is stale (default: ${DEFAULT_MAX_AGE_SECONDS}).
49
+ --source <file> (record) Control-source file to checksum and mark.
50
+ --marker <text> (record) Stable source marker that must exist in the control source.
51
+ --source-type <type> (record) One of: ${SOURCE_TYPES.join(", ")} (default: native_skill).
52
+ --harness <name> (record) Harness name label.
53
+ --harness-category <cat> (record) Harness category label.
54
+ --role <role> (record) Role label (default: UNDECLARED).
55
+ --version <v> (record) Control-source version label.
56
+ --json (verify) Emit machine-readable JSON result.
57
+ --help, -h Show this help and exit 0.
58
+
59
+ Exit codes: 0 = verified / help / record; 1 = proof failed; 2 = usage error.
60
+ Output is zero-secret: only paths, sizes, digests, markers, and reasons are printed.`;
61
+
62
+ function sha256(buf) {
63
+ return createHash("sha256").update(buf).digest("hex");
64
+ }
65
+
66
+ function resolveEntryPath(base, p) {
67
+ return isAbsolute(p) ? p : join(base, p);
68
+ }
69
+
70
+ // Zero-secret guard: flag values that look like tokens/keys/secrets. Mirrors the redaction
71
+ // patterns used by the protocol validators so a proof can never smuggle a credential through.
72
+ const SECRET_PATTERNS = [
73
+ /(sk|pk|ghp|gho|ghu|github_pat|xox[baprs])-?[A-Za-z0-9_=-]{8,}/i,
74
+ /[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD)[A-Z0-9_]*=[^\s"']+/i,
75
+ /Bearer\s+[A-Za-z0-9._=-]{8,}/i
76
+ ];
77
+
78
+ export function containsSecretLikeValue(value) {
79
+ const text = typeof value === "string" ? value : JSON.stringify(value ?? "");
80
+ return SECRET_PATTERNS.some((pattern) => pattern.test(text));
81
+ }
82
+
83
+ function asObject(value) {
84
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
85
+ }
86
+
87
+ function ageSeconds(iso, nowMs) {
88
+ const t = Date.parse(iso);
89
+ if (!Number.isFinite(t)) return null;
90
+ return (nowMs - t) / 1000;
91
+ }
92
+
93
+ // Pure verification core: takes a parsed proof object, returns { ok, reasons, ... }.
94
+ // Exported so tests can exercise it without spawning a process.
95
+ export function verifyGovernedContextProof(proof, opts = {}) {
96
+ const base = opts.base ?? ".";
97
+ const maxAgeSeconds = Number.isFinite(opts.maxAgeSeconds) ? opts.maxAgeSeconds : DEFAULT_MAX_AGE_SECONDS;
98
+ const nowMs = opts.now !== undefined && opts.now !== null ? new Date(opts.now).getTime() : Date.now();
99
+ const reasons = [];
100
+
101
+ if (!proof || typeof proof !== "object" || Array.isArray(proof)) {
102
+ return { ok: false, base, reasons: ["proof is not an object"] };
103
+ }
104
+
105
+ // Zero-secret: reject any secret-looking value anywhere in the proof.
106
+ if (containsSecretLikeValue(proof)) {
107
+ reasons.push("proof contains a secret-looking value (tokens/keys must never appear in a proof)");
108
+ }
109
+
110
+ if (proof.schema !== SCHEMA_ID) {
111
+ reasons.push(`schema must be "${SCHEMA_ID}"`);
112
+ }
113
+ for (const field of ["role", "harness", "source_type", "generated_at"]) {
114
+ if (typeof proof[field] !== "string" || proof[field].trim() === "") {
115
+ reasons.push(`missing or empty field: ${field}`);
116
+ }
117
+ }
118
+ if (typeof proof.source_type === "string" && !SOURCE_TYPES.includes(proof.source_type)) {
119
+ reasons.push(`source_type must be one of: ${SOURCE_TYPES.join(", ")}`);
120
+ }
121
+
122
+ // Control source: a stable marker is mandatory; a declared path is checksum-gated against disk.
123
+ const controlSource = asObject(proof.control_source);
124
+ const marker = typeof controlSource.marker === "string" ? controlSource.marker.trim() : "";
125
+ if (marker === "") {
126
+ reasons.push("control_source.marker is missing (a stable source marker is required)");
127
+ }
128
+ let diskBytes = null;
129
+ let diskSha = null;
130
+ const declaredPath = typeof controlSource.path === "string" && controlSource.path.trim() !== "" ? controlSource.path : null;
131
+ if (declaredPath) {
132
+ const abs = resolveEntryPath(base, declaredPath);
133
+ if (!existsSync(abs) || !statSync(abs).isFile()) {
134
+ reasons.push(`control_source.path missing on disk: ${declaredPath}`);
135
+ } else {
136
+ const buf = readFileSync(abs);
137
+ diskBytes = buf.length;
138
+ diskSha = sha256(buf);
139
+ if (typeof controlSource.sha256 === "string" && controlSource.sha256.trim() !== "") {
140
+ if (controlSource.sha256 !== diskSha) {
141
+ reasons.push("control_source.sha256 mismatch (control file changed, truncated, or wrong file)");
142
+ }
143
+ } else {
144
+ reasons.push("control_source.sha256 is required when control_source.path is present");
145
+ }
146
+ if (marker !== "" && !buf.toString("utf8").includes(marker)) {
147
+ reasons.push("control_source.marker not found in the control file (source not actually present)");
148
+ }
149
+ }
150
+ }
151
+
152
+ // Uptake receipt: proves the marker was actually observed from the loaded control layer.
153
+ // A bare observed:true with no marker linkage is a self-assertion and is rejected.
154
+ const uptake = asObject(proof.uptake_receipt);
155
+ if (!proof.uptake_receipt || typeof proof.uptake_receipt !== "object" || Array.isArray(proof.uptake_receipt)) {
156
+ reasons.push("missing uptake_receipt");
157
+ } else {
158
+ if (uptake.observed !== true) {
159
+ reasons.push("uptake_receipt.observed must be true (protocol uptake not proven)");
160
+ }
161
+ const evidenceMarker = typeof uptake.evidence_marker === "string" ? uptake.evidence_marker.trim() : "";
162
+ if (evidenceMarker === "") {
163
+ reasons.push("uptake_receipt.evidence_marker is missing (self-asserted uptake without a stable source marker is not accepted)");
164
+ } else if (marker !== "" && evidenceMarker !== marker) {
165
+ reasons.push("uptake_receipt.evidence_marker does not match control_source.marker (uptake not linked to the control source)");
166
+ }
167
+ if (typeof uptake.method !== "string" || uptake.method.trim() === "") {
168
+ reasons.push("uptake_receipt.method is missing");
169
+ }
170
+ if (typeof uptake.observed_at === "string" && uptake.observed_at.trim() !== "") {
171
+ const age = ageSeconds(uptake.observed_at, nowMs);
172
+ if (age === null) reasons.push("uptake_receipt.observed_at is not a valid timestamp");
173
+ else if (age > maxAgeSeconds) reasons.push(`stale uptake: observed_at is older than ${maxAgeSeconds}s`);
174
+ } else {
175
+ reasons.push("uptake_receipt.observed_at is missing");
176
+ }
177
+ }
178
+
179
+ // Freshness of the proof itself.
180
+ if (typeof proof.generated_at === "string" && proof.generated_at.trim() !== "") {
181
+ const age = ageSeconds(proof.generated_at, nowMs);
182
+ if (age === null) reasons.push("generated_at is not a valid timestamp");
183
+ else if (age > maxAgeSeconds) reasons.push(`stale proof: generated_at is older than ${maxAgeSeconds}s`);
184
+ }
185
+
186
+ return {
187
+ ok: reasons.length === 0,
188
+ base,
189
+ schema: proof.schema,
190
+ role: typeof proof.role === "string" ? proof.role : null,
191
+ harness: typeof proof.harness === "string" ? proof.harness : null,
192
+ sourceType: typeof proof.source_type === "string" ? proof.source_type : null,
193
+ marker: marker || null,
194
+ declaredPath,
195
+ diskBytes,
196
+ diskSha,
197
+ reasons
198
+ };
199
+ }
200
+
201
+ export function recordGovernedContextProof(options = {}) {
202
+ const {
203
+ sourcePath,
204
+ marker,
205
+ sourceType = "native_skill",
206
+ harness = "unknown",
207
+ harnessCategory = "unknown",
208
+ role = "UNDECLARED",
209
+ version,
210
+ base = ".",
211
+ now
212
+ } = options;
213
+
214
+ if (typeof marker !== "string" || marker.trim() === "") {
215
+ throw new Error("--marker is required and must be a stable, non-secret source marker");
216
+ }
217
+ if (containsSecretLikeValue(marker)) {
218
+ throw new Error("--marker looks like a secret; markers must be non-secret protocol identifiers");
219
+ }
220
+ const generatedAt = (now !== undefined && now !== null ? new Date(now) : new Date()).toISOString();
221
+ const controlSource = { marker };
222
+ if (typeof version === "string" && version.trim() !== "") controlSource.version = version;
223
+
224
+ if (typeof sourcePath === "string" && sourcePath.trim() !== "") {
225
+ const abs = resolveEntryPath(base, sourcePath);
226
+ const buf = readFileSync(abs);
227
+ if (!buf.toString("utf8").includes(marker)) {
228
+ throw new Error(`marker not found in control source ${sourcePath}; cannot record a valid proof`);
229
+ }
230
+ controlSource.path = sourcePath;
231
+ controlSource.bytes = buf.length;
232
+ controlSource.sha256 = sha256(buf);
233
+ }
234
+
235
+ return {
236
+ schema: SCHEMA_ID,
237
+ role,
238
+ harness,
239
+ harness_category: harnessCategory,
240
+ source_type: sourceType,
241
+ control_source: controlSource,
242
+ uptake_receipt: {
243
+ method: "quoted_marker",
244
+ evidence_marker: marker,
245
+ observed: true,
246
+ observed_at: generatedAt
247
+ },
248
+ generated_at: generatedAt
249
+ };
250
+ }
251
+
252
+ function parseArgs(argv) {
253
+ const args = {
254
+ positional: [],
255
+ base: ".",
256
+ role: "UNDECLARED",
257
+ json: false,
258
+ help: false,
259
+ record: false,
260
+ source: undefined,
261
+ marker: undefined,
262
+ sourceType: "native_skill",
263
+ harness: "unknown",
264
+ harnessCategory: "unknown",
265
+ version: undefined,
266
+ now: undefined,
267
+ maxAgeSeconds: undefined
268
+ };
269
+ for (let i = 0; i < argv.length; i++) {
270
+ const a = argv[i];
271
+ if (a === "--help" || a === "-h") args.help = true;
272
+ else if (a === "--json") args.json = true;
273
+ else if (a === "--record") args.record = true;
274
+ else if (a === "--base") args.base = argv[++i] ?? ".";
275
+ else if (a === "--role") args.role = argv[++i] ?? "UNDECLARED";
276
+ else if (a === "--source") args.source = argv[++i];
277
+ else if (a === "--marker") args.marker = argv[++i];
278
+ else if (a === "--source-type") args.sourceType = argv[++i] ?? "native_skill";
279
+ else if (a === "--harness") args.harness = argv[++i] ?? "unknown";
280
+ else if (a === "--harness-category") args.harnessCategory = argv[++i] ?? "unknown";
281
+ else if (a === "--version") args.version = argv[++i];
282
+ else if (a === "--now") args.now = argv[++i];
283
+ else if (a === "--max-age-seconds") args.maxAgeSeconds = Number(argv[++i]);
284
+ else args.positional.push(a);
285
+ }
286
+ return args;
287
+ }
288
+
289
+ function main() {
290
+ const args = parseArgs(process.argv.slice(2));
291
+
292
+ if (args.help) {
293
+ console.log(USAGE);
294
+ process.exit(0);
295
+ }
296
+
297
+ if (args.record) {
298
+ let proof;
299
+ try {
300
+ proof = recordGovernedContextProof({
301
+ sourcePath: args.source,
302
+ marker: args.marker,
303
+ sourceType: args.sourceType,
304
+ harness: args.harness,
305
+ harnessCategory: args.harnessCategory,
306
+ role: args.role,
307
+ version: args.version,
308
+ base: args.base,
309
+ now: args.now
310
+ });
311
+ } catch (err) {
312
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
313
+ process.exit(2);
314
+ }
315
+ console.log(JSON.stringify(proof, null, 2));
316
+ process.exit(0);
317
+ }
318
+
319
+ const proofPath = args.positional[0];
320
+ if (!proofPath) {
321
+ console.error("error: missing <proof.json> argument");
322
+ console.error("run with --help for usage");
323
+ process.exit(2);
324
+ }
325
+
326
+ let proof;
327
+ try {
328
+ proof = JSON.parse(readFileSync(resolve(process.cwd(), proofPath), "utf8"));
329
+ } catch (err) {
330
+ console.error(`error: cannot read or parse proof file: ${err instanceof Error ? err.message : String(err)}`);
331
+ process.exit(2);
332
+ }
333
+
334
+ const result = verifyGovernedContextProof(proof, { base: args.base, now: args.now, maxAgeSeconds: args.maxAgeSeconds });
335
+
336
+ if (args.json) {
337
+ console.log(JSON.stringify(result, null, 2));
338
+ } else {
339
+ console.log(`governed-context verify (base: ${result.base})`);
340
+ console.log(` harness: ${result.harness ?? "<unknown>"} source_type: ${result.sourceType ?? "<unknown>"} marker: ${result.marker ?? "<none>"}`);
341
+ if (result.declaredPath) {
342
+ console.log(` control source: ${result.declaredPath} (${result.diskBytes ?? "?"} bytes, sha256 ${result.diskSha ? result.diskSha.slice(0, 12) + "…" : "n/a"})`);
343
+ }
344
+ if (result.ok) {
345
+ console.log(" [PASS] governed-context proof verified; control layer loaded and uptake proven");
346
+ } else {
347
+ for (const reason of result.reasons) console.log(` [FAIL] ${reason}`);
348
+ }
349
+ }
350
+
351
+ process.exit(result.ok ? 0 : 1);
352
+ }
353
+
354
+ // Only run the CLI when invoked directly, not when imported by tests.
355
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
356
+ if (invokedDirectly) {
357
+ main();
358
+ }