@kontourai/flow-agents 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,7 +33,7 @@ jobs:
33
33
 
34
34
  steps:
35
35
  - name: Checkout
36
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
36
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
37
37
 
38
38
  - name: Set up Node.js
39
39
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -99,7 +99,7 @@ jobs:
99
99
 
100
100
  steps:
101
101
  - name: Checkout
102
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
102
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
103
103
 
104
104
  - name: Set up Node.js
105
105
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -160,7 +160,7 @@ jobs:
160
160
 
161
161
  steps:
162
162
  - name: Checkout
163
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
163
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
164
164
 
165
165
  - name: Set up Node.js
166
166
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -292,7 +292,7 @@ jobs:
292
292
 
293
293
  steps:
294
294
  - name: Checkout
295
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
295
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
296
296
 
297
297
  - name: Set up Node.js
298
298
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -22,7 +22,7 @@ jobs:
22
22
  runs-on: ubuntu-latest
23
23
  steps:
24
24
  - name: Checkout
25
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
25
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
26
26
 
27
27
  - name: Configure Pages
28
28
  uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
@@ -37,7 +37,7 @@ jobs:
37
37
 
38
38
  steps:
39
39
  - name: Checkout
40
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
40
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
41
41
 
42
42
  - name: Set up Node.js
43
43
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -103,7 +103,7 @@ jobs:
103
103
 
104
104
  steps:
105
105
  - name: Checkout
106
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
106
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
107
107
 
108
108
  - name: Set up Node.js
109
109
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -22,7 +22,7 @@ jobs:
22
22
  runs-on: ubuntu-latest
23
23
  steps:
24
24
  - name: Check out repository
25
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
25
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
26
26
  with:
27
27
  fetch-depth: 0
28
28
 
@@ -58,7 +58,7 @@ jobs:
58
58
  id-token: write
59
59
  steps:
60
60
  - name: Check out repository
61
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
61
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
62
62
  with:
63
63
  fetch-depth: 0
64
64
 
@@ -37,7 +37,7 @@ jobs:
37
37
  version: pi --version
38
38
  steps:
39
39
  - name: Checkout
40
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
40
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
41
41
 
42
42
  - name: Set up Node.js
43
43
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -67,7 +67,7 @@ jobs:
67
67
  timeout-minutes: 20
68
68
  steps:
69
69
  - name: Checkout
70
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
70
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
71
71
 
72
72
  - name: Set up Node.js
73
73
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -62,7 +62,7 @@ jobs:
62
62
 
63
63
  steps:
64
64
  - name: Checkout
65
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
65
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
66
66
 
67
67
  - name: Set up Node.js
68
68
  uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.1](https://github.com/kontourai/flow-agents/compare/v2.1.0...v2.1.1) (2026-06-29)
4
+
5
+
6
+ ### Refactoring
7
+
8
+ * **flow-agents:** one shared module for command-log chain helpers (ops[#20](https://github.com/kontourai/flow-agents/issues/20)) ([#249](https://github.com/kontourai/flow-agents/issues/249)) ([67af85f](https://github.com/kontourai/flow-agents/commit/67af85f5010dace3f33b36b86245e0c7aad95f77))
9
+
3
10
  ## [2.1.0](https://github.com/kontourai/flow-agents/compare/v2.0.1...v2.1.0) (2026-06-29)
4
11
 
5
12
 
@@ -790,6 +790,15 @@ echo "=== AC3.1 — Surface unavailable fail-closed ==="
790
790
  echo ""
791
791
  echo "--- AC3.1a: Isolated (no @kontourai/surface) with high-impact claim → BLOCKS ---"
792
792
 
793
+ # The gate imports the shared scripts/lib/command-log-chain.js helpers. A real
794
+ # install rsyncs the whole tree, so the lib always sits beside the hooks. Mirror that:
795
+ # the isolated gates live at "$TMP/surface-iso*/stop-goal-fit.js", so "../lib" resolves
796
+ # to "$TMP/lib" for both. This keeps the test exercising surface-unavailable fail-closed
797
+ # (not a spurious module-not-found crash).
798
+ ISO_LIBDIR="$TMP/lib"
799
+ mkdir -p "$ISO_LIBDIR"
800
+ cp "$ROOT/scripts/lib/command-log-chain.js" "$ISO_LIBDIR/"
801
+
793
802
  # Create isolated node context that can't find @kontourai/surface
794
803
  ISO_DIR="$TMP/surface-iso"
795
804
  mkdir -p "$ISO_DIR/repo/.flow-agents/surftest"
@@ -1014,13 +1023,19 @@ else
1014
1023
  fi
1015
1024
 
1016
1025
  echo ""
1017
- echo "--- AC3.3c: Both files use the SAME genesis constant value ---"
1018
- genesis_ec=$(grep "const CHAIN_GENESIS = " "$ROOT/scripts/hooks/evidence-capture.js" | sed "s/.*= '//;s/'.*//")
1019
- genesis_sg=$(grep "const CHAIN_GENESIS_VERIFY = " "$ROOT/scripts/hooks/stop-goal-fit.js" | sed "s/.*= '//;s/'.*//")
1020
- if [ "$genesis_ec" = "$genesis_sg" ] && [ -n "$genesis_ec" ]; then
1021
- _pass "AC3.3: Both files use the same genesis constant ($genesis_ec)"
1026
+ echo "--- AC3.3c: genesis is single-sourced and imported by writer + verifier (cannot diverge) ---"
1027
+ # Stronger than the old "two literals match" check: the genesis literal now lives in
1028
+ # exactly ONE module, and both the writer and verifier import it — so divergence is
1029
+ # structurally impossible rather than merely currently-equal.
1030
+ genesis_lib=$(grep "const CHAIN_GENESIS = " "$ROOT/scripts/lib/command-log-chain.js" | sed "s/.*= '//;s/'.*//")
1031
+ if [ -n "$genesis_lib" ] \
1032
+ && grep -q "require.*command-log-chain" "$ROOT/scripts/hooks/evidence-capture.js" \
1033
+ && grep -q "require.*command-log-chain" "$ROOT/scripts/hooks/stop-goal-fit.js" \
1034
+ && ! grep -qE "const CHAIN_GENESIS = '" "$ROOT/scripts/hooks/evidence-capture.js" \
1035
+ && ! grep -qE "const CHAIN_GENESIS_VERIFY = '" "$ROOT/scripts/hooks/stop-goal-fit.js"; then
1036
+ _pass "AC3.3: genesis single-sourced in scripts/lib/command-log-chain.js ($genesis_lib); writer + verifier import it, no divergent literal"
1022
1037
  else
1023
- _fail "AC3.3: Genesis constant mismatch: evidence-capture=$genesis_ec stop-goal-fit=$genesis_sg"
1038
+ _fail "AC3.3: genesis not single-sourced (lib='$genesis_lib') or a divergent literal remains in a consumer"
1024
1039
  fi
1025
1040
 
1026
1041
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
5
5
  "keywords": [
6
6
  "agents",
@@ -61,6 +61,10 @@ const { spawnSync } = require('child_process');
61
61
  const fs = require('fs');
62
62
  const os = require('os');
63
63
  const path = require('path');
64
+ // One normative definition shared with scripts/hooks/stop-goal-fit.js — the local
65
+ // copy here had drifted (it was missing the trailing `/bin/true` check), which is
66
+ // exactly why this is now imported rather than duplicated.
67
+ const { hasLaunderingOperator } = require('../lib/command-log-chain.js');
64
68
 
65
69
  // ---------------------------------------------------------------------------
66
70
  // Helpers
@@ -80,29 +84,9 @@ function isPassingValue(v) {
80
84
  return v === true || v === 1 || v === 'true' || v === 'pass';
81
85
  }
82
86
 
83
- /**
84
- * Returns true when a command string contains an exit-code-laundering operator.
85
- * These operators mask real exit codes so the real sub-command may have failed silently.
86
- *
87
- * Rules (applied to claimed verification commands only):
88
- * - ANY || operator — verify commands must not contain ||. This catches:
89
- * || exit 0, || echo ok, || /bin/true, || true, || :, etc.
90
- * - ; or newline followed by true / : / exit 0 — trailing success injection
91
- *
92
- * NOTE: Logic must stay identical to scripts/hooks/stop-goal-fit.js hasLaunderingOperator.
93
- * Centralize into a shared module as a follow-up (coordinate-free duplication for now).
94
- */
95
- function hasLaunderingOperator(cmd) {
96
- // Flag ANY || operator — masks the exit code of the left-hand command.
97
- if (/\|\|/.test(cmd)) return true;
98
- // Flag ; or newline followed by true / : / exit 0
99
- if (/[;\n]\s*true\b/.test(cmd)) return true;
100
- if (/[;\n]\s*:\s*(?:$|\s|;|\n)/.test(cmd)) return true;
101
- if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
102
- // Flag pipe to true (pipeline absorbs exit code)
103
- if (/\|\s*true\b/.test(cmd)) return true;
104
- return false;
105
- }
87
+ // hasLaunderingOperator is imported from ../lib/command-log-chain.js (above) so this
88
+ // CI reconciler and the stop-goal-fit verifier apply the identical exit-code-mask
89
+ // heuristic see that module for the rules.
106
90
 
107
91
  /**
108
92
  * Run a single shell command under bash, capturing exit code.
@@ -62,43 +62,16 @@ const COMMAND_TOOL_NAME = /(^|[^a-z])(bash|shell|sh|exec|run|command|terminal|cm
62
62
 
63
63
  // ─── Hash-chain integrity (tamper-EVIDENCE) ───────────────────────────────────
64
64
  //
65
- // Genesis prevHash: a fixed arbitrary sentinel used when the log is empty or
66
- // the last entry has no _chain field (legacy record). This is NOT the SHA256 of
67
- // any specific input string it is a fixed constant chosen for the original
68
- // implementation. (A previous comment incorrectly claimed it was
69
- // sha256("flow-agents:command-log:genesis"); that is wrong.)
70
- //
71
- // Writer (this file, CHAIN_GENESIS) and verifier (stop-goal-fit.js,
72
- // CHAIN_GENESIS_VERIFY) MUST use the same value. Do not change one without
73
- // changing the other — existing chained logs depend on this constant.
74
- //
75
- // HONEST FRAMING: this makes alteration DETECTABLE, not impossible. An agent
76
- // that rewrites all hashes can still forge the chain. The real tamper-proof
77
- // boundary is the signed checkpoint (B1). We do not oversell this boundary.
78
- const CHAIN_GENESIS = 'a3f9e2b7d5c84f1e6a0d2c3b9f7e1a4d8c6b5f2e9a0d3c7b1f4e8a2d6c0b9f3';
79
-
80
- /**
81
- * Stable canonical JSON for the chain input: the record WITHOUT the `_chain`
82
- * field, keys sorted alphabetically. This ensures the hash is independent of
83
- * key insertion order and that `_chain` itself does not contribute to its own
84
- * hash (circular dependency).
85
- */
86
- function canonicalJsonForChain(record) {
87
- // Strip _chain if present (should not be, but defensive).
88
- const keys = Object.keys(record).filter(k => k !== '_chain').sort();
89
- const obj = {};
90
- for (const k of keys) obj[k] = record[k];
91
- return JSON.stringify(obj);
92
- }
93
-
94
- /**
95
- * Compute the sha256 hex hash for this chain link.
96
- * hash = sha256(prevHash + canonicalJson(record))
97
- */
98
- function computeChainHash(prevHash, record) {
99
- const input = prevHash + canonicalJsonForChain(record);
100
- return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
101
- }
65
+ // CHAIN_GENESIS is a fixed arbitrary sentinel NOT the SHA256 of any specific
66
+ // input string (a previous comment incorrectly claimed sha256("…:genesis")). The
67
+ // writer here and the verifier in stop-goal-fit.js MUST canonicalize and seed
68
+ // identically, so the genesis constant and the canonicalJson/hash helpers live in
69
+ // ONE shared module that both import — divergence is structurally impossible.
70
+ const {
71
+ CHAIN_GENESIS,
72
+ canonicalJsonForChain,
73
+ computeChainHash,
74
+ } = require('../lib/command-log-chain.js');
102
75
 
103
76
  /**
104
77
  * Read the last entry from command-log.jsonl that has a `_chain` block.
@@ -29,6 +29,16 @@ const path = require('path');
29
29
  const { spawnSync } = require('child_process');
30
30
  const crypto = require('crypto');
31
31
 
32
+ // Hash-chain primitives + the exit-code-laundering heuristic come from ONE shared
33
+ // module, so this verifier can never drift from the writer (evidence-capture.js).
34
+ // CHAIN_GENESIS is re-aliased to CHAIN_GENESIS_VERIFY to preserve the long-standing
35
+ // export name consumed by repair-command-log.js and the fork-classification eval.
36
+ const {
37
+ CHAIN_GENESIS: CHAIN_GENESIS_VERIFY,
38
+ canonicalJsonForChain,
39
+ hasLaunderingOperator,
40
+ } = require('../lib/command-log-chain.js');
41
+
32
42
  const MAX_STDIN = 1024 * 1024;
33
43
  const ACTIVE_STATUSES = new Set([
34
44
  'planning',
@@ -733,36 +743,10 @@ function claimAcknowledgesFailure(status, value) {
733
743
  || v === 'fail' || v === 'failed' || v === 'not_verified' || v === 'failing';
734
744
  }
735
745
 
736
- /**
737
- * Returns true when a command string contains an exit-code-neutralizing operator.
738
- * A claimed-pass check whose captured command uses one of these cannot be accepted as a
739
- * deterministic pass — the real sub-command may have failed silently.
740
- *
741
- * R6 extended logic (identical patterns used by scripts/ci/trust-reconcile.js — centralize
742
- * as a follow-up if drift becomes a maintenance concern):
743
- * - ANY || operator is flagged. A legitimate verification command never needs || — its
744
- * only purpose in a verification command is to mask the real exit code (e.g.
745
- * `npm test || exit 0`, `npm test || echo ok`, `npm test || /bin/true`, `npm test || (exit 0)`).
746
- * - | true (single pipe into true — always exits 0)
747
- * - Trailing ; or newline followed by: true : exit 0 /bin/true
748
- *
749
- * Fix D: applied in captureCrossReference's satisfied path and capturedFailReconciliation.
750
- */
751
- function hasLaunderingOperator(cmd) {
752
- // ANY || in a claimed verification command is an exit-code mask.
753
- // Legitimate verification commands never need || — its only purpose there is to
754
- // suppress the real exit code (|| exit 0, || echo ok, || /bin/true, || (exit 0), etc.).
755
- if (/\|\|/.test(cmd)) return true;
756
- // | true — single-pipe into true: `cmd | true` always exits 0 regardless of left-side exit code.
757
- if (/\|\s*true\b/.test(cmd)) return true;
758
- // Trailing ; or \n followed by exit-neutralizing commands (same threat, appended after the real cmd):
759
- // ; true ; : ; exit 0 ; /bin/true (and \n variants)
760
- if (/[;\n]\s*true\b/.test(cmd)) return true;
761
- if (/[;\n]\s*:\s*(?:$|\s|;)/.test(cmd)) return true;
762
- if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
763
- if (/[;\n]\s*\/bin\/true\b/.test(cmd)) return true;
764
- return false;
765
- }
746
+ // hasLaunderingOperator (the exit-code-mask heuristic) is imported from
747
+ // ../lib/command-log-chain.js so this verifier and scripts/ci/trust-reconcile.js
748
+ // share one normative definition. Applied in captureCrossReference's satisfied
749
+ // path and capturedFailReconciliation.
766
750
 
767
751
  // ─── Hash-chain integrity verification (Increment B2, tamper-EVIDENCE) ────────
768
752
  //
@@ -786,20 +770,9 @@ function hasLaunderingOperator(cmd) {
786
770
  // The genesis prevHash is a fixed arbitrary sentinel — NOT the SHA256 of any
787
771
  // specific input string. The comment in evidence-capture.js previously (and
788
772
  // incorrectly) claimed it was sha256("flow-agents:command-log:genesis"); it is not.
789
- // Writer (evidence-capture.js CHAIN_GENESIS) and verifier (CHAIN_GENESIS_VERIFY here)
790
- // MUST use the same value. Do not change one without changing the other.
791
- const CHAIN_GENESIS_VERIFY = 'a3f9e2b7d5c84f1e6a0d2c3b9f7e1a4d8c6b5f2e9a0d3c7b1f4e8a2d6c0b9f3';
792
-
793
- /**
794
- * Canonical JSON for chain verification: record WITHOUT `_chain`, keys sorted.
795
- * Must be byte-identical to canonicalJsonForChain() in evidence-capture.js.
796
- */
797
- function canonicalJsonForVerify(record) {
798
- const keys = Object.keys(record).filter(k => k !== '_chain').sort();
799
- const obj = {};
800
- for (const k of keys) obj[k] = record[k];
801
- return JSON.stringify(obj);
802
- }
773
+ // Both the genesis (CHAIN_GENESIS_VERIFY, imported above) and the canonical-JSON
774
+ // helper (canonicalJsonForChain) come from ../lib/command-log-chain.js, the single
775
+ // source the writer in evidence-capture.js imports too — so they cannot diverge.
803
776
 
804
777
  /**
805
778
  * Verify the hash chain of command-log.jsonl.
@@ -870,7 +843,7 @@ function verifyCommandLogChain(artifactDir) {
870
843
  // (a) Self-consistency. A content edit without rehashing fails here.
871
844
  if (typeof chain.prevHash !== 'string') return { status: 'broken', brokenAt: i, forkAt: null };
872
845
  const selfHash = crypto.createHash('sha256')
873
- .update(chain.prevHash + canonicalJsonForVerify(entry), 'utf8')
846
+ .update(chain.prevHash + canonicalJsonForChain(entry), 'utf8')
874
847
  .digest('hex');
875
848
  if (chain.hash !== selfHash) return { status: 'broken', brokenAt: i, forkAt: null };
876
849
 
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ //
3
+ // Single normative source for the command-log hash-chain primitives and the
4
+ // exit-code-laundering heuristic.
5
+ //
6
+ // These were previously copy-pasted across the writer (hooks/evidence-capture.js),
7
+ // the verifier (hooks/stop-goal-fit.js), the repair tool (repair-command-log.js),
8
+ // and CI reconcile (ci/trust-reconcile.js) under "keep byte-identical" comments —
9
+ // the most security-sensitive path in the bundle, since the chain's integrity
10
+ // claim rests on writer and verifier canonicalizing identically. The copies had
11
+ // ALREADY drifted (ci/trust-reconcile's hasLaunderingOperator was missing the
12
+ // trailing `/bin/true` check), which is exactly the failure mode duplication
13
+ // invites. Importing from one module makes that divergence structurally impossible.
14
+ //
15
+ const crypto = require('crypto');
16
+
17
+ // The genesis prevHash is a FIXED ARBITRARY SENTINEL — NOT the SHA256 of any
18
+ // specific input string. (An earlier comment incorrectly claimed it was
19
+ // sha256("flow-agents:command-log:genesis"); that is wrong.) Writer and verifier
20
+ // MUST share this exact value — existing chained logs depend on it.
21
+ //
22
+ // HONEST FRAMING: this makes alteration DETECTABLE, not impossible. An agent that
23
+ // rewrites all hashes can still forge the chain. The real tamper-proof boundary is
24
+ // the signed checkpoint (B1). We do not oversell this boundary.
25
+ const CHAIN_GENESIS = 'a3f9e2b7d5c84f1e6a0d2c3b9f7e1a4d8c6b5f2e9a0d3c7b1f4e8a2d6c0b9f3';
26
+
27
+ /**
28
+ * Stable canonical JSON for a chain link: the record WITHOUT its `_chain` field,
29
+ * keys sorted alphabetically. This makes the hash independent of key insertion
30
+ * order and keeps `_chain` from contributing to its own hash.
31
+ */
32
+ function canonicalJsonForChain(record) {
33
+ const keys = Object.keys(record).filter((k) => k !== '_chain').sort();
34
+ const obj = {};
35
+ for (const k of keys) obj[k] = record[k];
36
+ return JSON.stringify(obj);
37
+ }
38
+
39
+ /** Chain link hash: sha256(prevHash + canonicalJsonForChain(record)), hex. */
40
+ function computeChainHash(prevHash, record) {
41
+ return crypto
42
+ .createHash('sha256')
43
+ .update(prevHash + canonicalJsonForChain(record), 'utf8')
44
+ .digest('hex');
45
+ }
46
+
47
+ /**
48
+ * True when a claimed verification command contains an exit-code-laundering
49
+ * operator. Legitimate verification commands never need these — their only
50
+ * purpose is to suppress a real non-zero exit:
51
+ * - ANY `||` (e.g. `npm test || exit 0`, `|| echo ok`, `|| /bin/true`)
52
+ * - `| true` (pipe into true — the pipeline absorbs the exit code)
53
+ * - trailing `; true` / `; :` / `; exit 0` / `; /bin/true` (and `\n` variants)
54
+ */
55
+ function hasLaunderingOperator(cmd) {
56
+ // ANY || in a claimed verification command is an exit-code mask.
57
+ if (/\|\|/.test(cmd)) return true;
58
+ // | true — single-pipe into true always exits 0 regardless of the left side.
59
+ if (/\|\s*true\b/.test(cmd)) return true;
60
+ // Trailing ; or \n followed by an exit-neutralizing command:
61
+ if (/[;\n]\s*true\b/.test(cmd)) return true;
62
+ if (/[;\n]\s*:\s*(?:$|\s|;)/.test(cmd)) return true;
63
+ if (/[;\n]\s*exit\s+0\b/.test(cmd)) return true;
64
+ if (/[;\n]\s*\/bin\/true\b/.test(cmd)) return true;
65
+ return false;
66
+ }
67
+
68
+ module.exports = {
69
+ CHAIN_GENESIS,
70
+ canonicalJsonForChain,
71
+ computeChainHash,
72
+ hasLaunderingOperator,
73
+ };
@@ -26,17 +26,10 @@ const path = require('path');
26
26
  const crypto = require('crypto');
27
27
 
28
28
  const gate = require(path.join(__dirname, 'hooks', 'stop-goal-fit.js'));
29
- const GENESIS = gate.CHAIN_GENESIS_VERIFY;
30
-
31
- function canon(rec) {
32
- const keys = Object.keys(rec).filter((k) => k !== '_chain').sort();
33
- const obj = {};
34
- for (const k of keys) obj[k] = rec[k];
35
- return JSON.stringify(obj);
36
- }
37
- function hashLink(prev, rec) {
38
- return crypto.createHash('sha256').update(prev + canon(rec), 'utf8').digest('hex');
39
- }
29
+ // Genesis + canonicalization/hash come from the single shared module, so a repaired
30
+ // chain is re-linked byte-identically to how the writer/verifier compute it.
31
+ const { CHAIN_GENESIS, canonicalJsonForChain, computeChainHash } = require('./lib/command-log-chain.js');
32
+ const GENESIS = CHAIN_GENESIS;
40
33
 
41
34
  function main() {
42
35
  const dir = process.argv[2];
@@ -77,8 +70,8 @@ function main() {
77
70
  records.sort((a, b) => {
78
71
  const ta = String(a.capturedAt || ''), tb = String(b.capturedAt || '');
79
72
  if (ta !== tb) return ta < tb ? -1 : 1;
80
- const ha = crypto.createHash('sha256').update(canon(a)).digest('hex');
81
- const hb = crypto.createHash('sha256').update(canon(b)).digest('hex');
73
+ const ha = crypto.createHash('sha256').update(canonicalJsonForChain(a)).digest('hex');
74
+ const hb = crypto.createHash('sha256').update(canonicalJsonForChain(b)).digest('hex');
82
75
  return ha < hb ? -1 : ha > hb ? 1 : 0;
83
76
  });
84
77
 
@@ -87,7 +80,7 @@ function main() {
87
80
  let prev = GENESIS;
88
81
  let seq = 0;
89
82
  for (const rec of records) {
90
- const h = hashLink(prev, rec);
83
+ const h = computeChainHash(prev, rec);
91
84
  out.push(JSON.stringify({ ...rec, _chain: { seq, prevHash: prev, hash: h } }));
92
85
  prev = h; seq += 1;
93
86
  }
@@ -100,7 +93,7 @@ function main() {
100
93
  source: 'chain-repair',
101
94
  repair: { reason, entries: records.length, forkAt: verdict.forkAt },
102
95
  };
103
- const mh = hashLink(prev, marker);
96
+ const mh = computeChainHash(prev, marker);
104
97
  out.push(JSON.stringify({ ...marker, _chain: { seq, prevHash: prev, hash: mh } }));
105
98
 
106
99
  fs.copyFileSync(file, file + '.prebackup-repair');