loki-mode 7.49.0 → 7.50.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 +2 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/prd-analyzer.py +215 -1
- package/autonomy/prd-checklist.sh +315 -0
- package/autonomy/run.sh +124 -0
- package/autonomy/spec-interrogation.sh +224 -4
- package/autonomy/spec.sh +25 -16
- package/autonomy/verify.sh +108 -26
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +202 -21
- package/docs/INSTALLATION.md +2 -2
- package/docs/siem-integration.md +102 -0
- package/loki-ts/dist/loki.js +231 -230
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/references/invariant-checks.md +109 -0
- package/src/audit/crosslink.js +413 -0
- package/src/audit/index.js +32 -0
- package/src/observability/siem-export.js +424 -0
- package/src/policies/cost.js +270 -1
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "7.
|
|
4
|
+
"version": "7.50.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "7.
|
|
5
|
+
"version": "7.50.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Spec-Independent Invariant Checks (P1-4)
|
|
2
|
+
|
|
3
|
+
Deterministic invariant assertions over the produced source code that hold
|
|
4
|
+
regardless of what the spec says. They catch the "spec was silent and the model
|
|
5
|
+
guessed wrong" failure mode: code that ships a hardcoded secret or logs PII is
|
|
6
|
+
wrong no matter what the PRD asked for.
|
|
7
|
+
|
|
8
|
+
Implementation: `tests/detect-invariant-violations.sh`
|
|
9
|
+
Tests: `tests/test-invariant-detector.sh`
|
|
10
|
+
|
|
11
|
+
## What this is (and is not)
|
|
12
|
+
|
|
13
|
+
This is NOT a property-based test generator. The "Kiro Pattern" documented in
|
|
14
|
+
`skills/testing.md` (fast-check / hypothesis) generates randomized property
|
|
15
|
+
tests; that is a separate, larger feature and remains unimplemented. This
|
|
16
|
+
detector instead makes a small set of SOLID deterministic invariant ASSERTIONS
|
|
17
|
+
over the produced code. The design choice is explicit: a small set of solid
|
|
18
|
+
deterministic checks beats a large set of flaky ones.
|
|
19
|
+
|
|
20
|
+
## Checks
|
|
21
|
+
|
|
22
|
+
### Deterministic (blocking under `--strict`)
|
|
23
|
+
|
|
24
|
+
| # | Invariant | Severity | How |
|
|
25
|
+
|---|-----------|----------|-----|
|
|
26
|
+
| 1 | No committed secrets in source/logs | CRITICAL | Known credential prefixes: AWS access keys (`AKIA`/`ASIA`), PEM private-key blocks, GitHub tokens (`ghp_`/`gho_`/`ghs_`/`github_pat_`), Slack (`xox[baprs]-`), Google (`AIza`), Anthropic (`sk-ant-`), Stripe (`sk_live_`/`rk_live_`). Near-zero false positives. |
|
|
27
|
+
| 2 | No PII (email) in logs | HIGH | An email-shaped string literal passed to a log/print call (`console.*`, `logger.*`, `print(`, `fmt.Print*`, `echo`, `System.out.print`, `log.*`). |
|
|
28
|
+
|
|
29
|
+
### Advisory (never blocks)
|
|
30
|
+
|
|
31
|
+
| # | Invariant | Severity | How |
|
|
32
|
+
|---|-----------|----------|-----|
|
|
33
|
+
| 3 | Generic secret-like assignment | MEDIUM | A variable named secret/token/password/apikey assigned a long opaque literal (and not env-var indirection). FP-prone, so advisory only. |
|
|
34
|
+
| 4 | Logged email-bearing variable | LOW | A `.email` / `userEmail` style variable referenced inside a log/print call. Cannot prove PII statically. |
|
|
35
|
+
|
|
36
|
+
### Deferred (honestly NOT implemented)
|
|
37
|
+
|
|
38
|
+
These two requested categories are deferred because a static grep cannot do them
|
|
39
|
+
deterministically without becoming flaky. They are documented here, not faked:
|
|
40
|
+
|
|
41
|
+
- **No unhandled-error path on the happy route.** A grep cannot do control-flow
|
|
42
|
+
analysis; a "no try/catch near await" heuristic is noise. This belongs to a
|
|
43
|
+
real analysis pass (LSP diagnostics / typed exhaustiveness checking), tracked
|
|
44
|
+
separately as the LSP-in-verification work.
|
|
45
|
+
- **Idempotency / round-trip invariants.** Not statically detectable in any
|
|
46
|
+
deterministic way worth shipping. It requires executing generated tests (the
|
|
47
|
+
larger fast-check / metamorphic-testing feature). Deferred, not faked.
|
|
48
|
+
|
|
49
|
+
## False-positive avoidance
|
|
50
|
+
|
|
51
|
+
Generated code legitimately contains placeholders. The detector skips them:
|
|
52
|
+
|
|
53
|
+
- Placeholder allowlist on the matched line: `EXAMPLE`, `example.com`, `your-`,
|
|
54
|
+
`xxxx`, `placeholder`, `changeme`, `REPLACE`, `<...>`, `dummy`, `sample`,
|
|
55
|
+
`redact`, `sk-test-`, `fake`, `FIXME`, `TODO`, `****`. This covers AWS's own
|
|
56
|
+
documented `AKIAIOSFODNN7EXAMPLE` and `your-api-key-here`.
|
|
57
|
+
- Illustration files skipped for secret checks: `*.md`, `*.example`, `*.sample`,
|
|
58
|
+
`*.template`, `*.dist` (they routinely show fake credentials on purpose).
|
|
59
|
+
- Env-var indirection (`process.env`, `os.environ`, `getenv`, `ENV[`) is not a
|
|
60
|
+
hardcoded literal and is skipped for the generic check.
|
|
61
|
+
|
|
62
|
+
## Scan surface
|
|
63
|
+
|
|
64
|
+
Source files only. Extensions: ts, tsx, js, jsx, py, go, rb, java, rs, php, sh,
|
|
65
|
+
env, yml, yaml, json, plus `*.log`. Excludes `node_modules`, `.git`, `dist`,
|
|
66
|
+
`build`, `vendor`, `coverage`, and Loki's own `.loki/` telemetry.
|
|
67
|
+
|
|
68
|
+
Test files are OUT OF SCOPE for all checks (consistent with the "source/logs"
|
|
69
|
+
framing of this invariant). The exclusion covers every common ecosystem's test
|
|
70
|
+
convention, not just the JS glob: `*.test.*` / `*.spec.*`, `test_*.py` /
|
|
71
|
+
`*_test.py`, `test-*.sh`, `*_test.go`, and anything under an anchored
|
|
72
|
+
`tests/` / `__tests__/` / `spec/` directory. Security/redaction test fixtures
|
|
73
|
+
routinely embed realistic fake credentials on purpose, so scanning them would be
|
|
74
|
+
pure noise. Comprehensive secret scanning of generated TESTS (and pre-write
|
|
75
|
+
scanning) is a separate, larger feature tracked as P3-4 (#634), not this gate.
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Advisory run (prints findings, exits 0)
|
|
81
|
+
tests/detect-invariant-violations.sh
|
|
82
|
+
|
|
83
|
+
# CI / gate run (exits 1 iff CRITICAL or HIGH)
|
|
84
|
+
tests/detect-invariant-violations.sh --strict
|
|
85
|
+
|
|
86
|
+
# Scan a target project (the gate wrapper sets this)
|
|
87
|
+
LOKI_SCAN_DIR=/path/to/target tests/detect-invariant-violations.sh --strict
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Exit-code contract mirrors `tests/detect-mock-problems.sh`: `--strict` exits 1
|
|
91
|
+
iff CRITICAL or HIGH findings exist; MEDIUM/LOW never block. Every HIGH (and
|
|
92
|
+
CRITICAL) prints a `[HIGH]` / `[CRITICAL]` token on stdout, so a wrapper can grep
|
|
93
|
+
as an alternative to relying on the exit code.
|
|
94
|
+
|
|
95
|
+
## Wiring as a gate
|
|
96
|
+
|
|
97
|
+
The detector is NOT wired into `autonomy/run.sh` yet. To wire it, add an
|
|
98
|
+
`enforce_invariant_integrity()` wrapper next to `enforce_mock_integrity()`
|
|
99
|
+
(`autonomy/run.sh:7932`) and call it where `enforce_mock_integrity` is called
|
|
100
|
+
(`autonomy/run.sh:14676`). The full wrapper is documented in the header of
|
|
101
|
+
`tests/detect-invariant-violations.sh`. It:
|
|
102
|
+
|
|
103
|
+
- honors `LOKI_SCAN_DIR=TARGET_DIR` (the detector scans the target, not loki-mode)
|
|
104
|
+
- treats detector-not-found and timeout (exit 124) as inconclusive (does not block)
|
|
105
|
+
- persists findings to `${TARGET_DIR}/.loki/quality/invariant-findings.txt`
|
|
106
|
+
- opts out with `LOKI_GATE_INVARIANT=false`
|
|
107
|
+
|
|
108
|
+
After wiring, add a gate row to `skills/quality-gates.md` and cross-reference
|
|
109
|
+
this check from the Kiro Pattern section of `skills/testing.md`.
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loki Mode Audit Cross-Link (P3-9 unification).
|
|
5
|
+
*
|
|
6
|
+
* The system has two independent tamper-evident audit chains:
|
|
7
|
+
*
|
|
8
|
+
* 1. Agent chain -- src/audit/log.js (Node)
|
|
9
|
+
* file: <project>/.loki/audit/audit.jsonl
|
|
10
|
+
* format: per-entry { ..., previousHash, hash }, genesis "GENESIS",
|
|
11
|
+
* hash = sha256(JSON of the linkable fields).
|
|
12
|
+
*
|
|
13
|
+
* 2. Dashboard chain -- dashboard/audit.py (Python)
|
|
14
|
+
* files: ~/.loki/dashboard/audit/audit-YYYY-MM-DD.jsonl (+ rotations)
|
|
15
|
+
* format: per-entry { ..., _integrity_hash }, genesis "0"*64,
|
|
16
|
+
* hash = sha256(prev_hash + entry_json).
|
|
17
|
+
*
|
|
18
|
+
* They use different directories, file layouts, genesis values and hash
|
|
19
|
+
* recipes, so a single physical chain is a large, risky merge. This
|
|
20
|
+
* module instead implements a *verifiable cross-link*: it folds the
|
|
21
|
+
* dashboard chain's current tip into the agent chain as an ordinary
|
|
22
|
+
* `audit_crosslink` record (so the anchor itself is protected by the
|
|
23
|
+
* agent chain's hash linkage), and ships a single `verifyUnified()`
|
|
24
|
+
* command that validates BOTH sub-chains AND reconciles every anchor
|
|
25
|
+
* against the live dashboard chain -- treating the pair as one logical,
|
|
26
|
+
* tamper-evident trail.
|
|
27
|
+
*
|
|
28
|
+
* It also provides an append-only / external-witness OPTION
|
|
29
|
+
* (`writeWitness`) so an external party can timestamp the unified root.
|
|
30
|
+
*
|
|
31
|
+
* Neither existing writer is modified or replaced: the agent writer
|
|
32
|
+
* (AuditLog.record) and the dashboard writer (audit.log_event) keep
|
|
33
|
+
* appending exactly as before. Full single-physical-chain unification
|
|
34
|
+
* (shared hash recipe + shared storage) is documented as follow-up.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var fs = require('fs');
|
|
38
|
+
var path = require('path');
|
|
39
|
+
var os = require('os');
|
|
40
|
+
var crypto = require('crypto');
|
|
41
|
+
var { execFileSync } = require('child_process');
|
|
42
|
+
var { AuditLog } = require('./log');
|
|
43
|
+
|
|
44
|
+
var CROSSLINK_ACTION = 'audit_crosslink';
|
|
45
|
+
var WITNESS_FILE = 'witness.jsonl';
|
|
46
|
+
var PY_GENESIS = '0'.repeat(64);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the default dashboard (Python) audit directory.
|
|
50
|
+
* Mirrors `AUDIT_DIR` in dashboard/audit.py: ~/.loki/dashboard/audit.
|
|
51
|
+
*/
|
|
52
|
+
function defaultDashboardAuditDir() {
|
|
53
|
+
return path.join(os.homedir(), '.loki', 'dashboard', 'audit');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the path to dashboard/audit.py. Allows override via opts for
|
|
58
|
+
* tests and non-standard layouts; otherwise walks up from this file.
|
|
59
|
+
*/
|
|
60
|
+
function resolveAuditPy(opts) {
|
|
61
|
+
if (opts && opts.auditPyPath) return opts.auditPyPath;
|
|
62
|
+
// src/audit/crosslink.js -> repo root is two levels up from src/.
|
|
63
|
+
var candidate = path.join(__dirname, '..', '..', 'dashboard', 'audit.py');
|
|
64
|
+
return candidate;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the python executable. Override via opts.pythonBin or env.
|
|
69
|
+
*/
|
|
70
|
+
function resolvePython(opts) {
|
|
71
|
+
if (opts && opts.pythonBin) return opts.pythonBin;
|
|
72
|
+
return process.env.LOKI_PYTHON || 'python3';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Query the Python dashboard chain for its tip + verdict, by invoking
|
|
77
|
+
* the audit.py CLI shim. Returns a structured object; on any failure
|
|
78
|
+
* returns an `available:false` descriptor so the unified verifier can
|
|
79
|
+
* still report on the agent chain alone (honest partial result).
|
|
80
|
+
*
|
|
81
|
+
* @param {object} [opts]
|
|
82
|
+
* @param {string} [opts.dashboardAuditDir]
|
|
83
|
+
* @param {string} [opts.auditPyPath]
|
|
84
|
+
* @param {string} [opts.pythonBin]
|
|
85
|
+
*/
|
|
86
|
+
function dashboardChainTip(opts) {
|
|
87
|
+
opts = opts || {};
|
|
88
|
+
var dir = opts.dashboardAuditDir || defaultDashboardAuditDir();
|
|
89
|
+
var py = resolvePython(opts);
|
|
90
|
+
var script = resolveAuditPy(opts);
|
|
91
|
+
if (!fs.existsSync(script)) {
|
|
92
|
+
return { available: false, reason: 'audit.py not found at ' + script,
|
|
93
|
+
tip_hash: PY_GENESIS, valid: false, entries: 0 };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
var out = execFileSync(py, [script, 'tip', dir], {
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
|
+
});
|
|
100
|
+
var parsed = JSON.parse(out.trim());
|
|
101
|
+
parsed.available = true;
|
|
102
|
+
return parsed;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// execFileSync throws on non-zero exit. The shim exits 1 when the
|
|
105
|
+
// chain is INVALID but still prints valid JSON on stdout -- recover it.
|
|
106
|
+
if (e && e.stdout) {
|
|
107
|
+
try {
|
|
108
|
+
var recovered = JSON.parse(String(e.stdout).trim());
|
|
109
|
+
recovered.available = true;
|
|
110
|
+
return recovered;
|
|
111
|
+
} catch (_) { /* fall through */ }
|
|
112
|
+
}
|
|
113
|
+
return { available: false, reason: String((e && e.message) || e),
|
|
114
|
+
tip_hash: PY_GENESIS, valid: false, entries: 0 };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Recompute the dashboard chain hash after exactly the first `nEntries`
|
|
120
|
+
* integrity-bearing entries (the prefix pinned by a cross-link anchor),
|
|
121
|
+
* by invoking the audit.py `prefix` shim. Lets the unified verifier tell
|
|
122
|
+
* legitimate append-only GROWTH (prefix still reproduces the anchored
|
|
123
|
+
* tip) from TAMPER (prefix no longer reproduces it).
|
|
124
|
+
*
|
|
125
|
+
* Returns { available, found, prefix_hash, entries_available }.
|
|
126
|
+
*/
|
|
127
|
+
function dashboardPrefixHash(nEntries, opts) {
|
|
128
|
+
opts = opts || {};
|
|
129
|
+
var dir = opts.dashboardAuditDir || defaultDashboardAuditDir();
|
|
130
|
+
var py = resolvePython(opts);
|
|
131
|
+
var script = resolveAuditPy(opts);
|
|
132
|
+
if (!fs.existsSync(script)) {
|
|
133
|
+
return { available: false, found: false, prefix_hash: PY_GENESIS,
|
|
134
|
+
entries_available: 0 };
|
|
135
|
+
}
|
|
136
|
+
function parse(out) {
|
|
137
|
+
var p = JSON.parse(String(out).trim());
|
|
138
|
+
p.available = true;
|
|
139
|
+
return p;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return parse(execFileSync(py, [script, 'prefix', dir, String(nEntries)], {
|
|
143
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
144
|
+
}));
|
|
145
|
+
} catch (e) {
|
|
146
|
+
// Shim exits 1 (found:false) but still prints JSON on stdout.
|
|
147
|
+
if (e && e.stdout) {
|
|
148
|
+
try { return parse(e.stdout); } catch (_) { /* fall through */ }
|
|
149
|
+
}
|
|
150
|
+
return { available: false, found: false, prefix_hash: PY_GENESIS,
|
|
151
|
+
entries_available: 0 };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read the agent (JS) chain tip hash without recording anything.
|
|
157
|
+
*/
|
|
158
|
+
function agentChainTip(opts) {
|
|
159
|
+
var log = new AuditLog(opts || {});
|
|
160
|
+
// _loadChainTip ran in the constructor; expose the loaded tip + count.
|
|
161
|
+
var tip = log._lastHash;
|
|
162
|
+
var count = log._entryCount;
|
|
163
|
+
return { tip_hash: tip, entries: count, chain_id: 'loki-agent-audit',
|
|
164
|
+
genesis: 'GENESIS' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compute the unified root: a deterministic hash binding both chain tips
|
|
169
|
+
* together. This is the value an external witness timestamps.
|
|
170
|
+
*/
|
|
171
|
+
function unifiedRoot(agentTip, dashboardTip) {
|
|
172
|
+
return crypto.createHash('sha256')
|
|
173
|
+
.update('loki-unified-audit-v1\n' + agentTip + '\n' + dashboardTip)
|
|
174
|
+
.digest('hex');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create a cross-link: fold the dashboard chain tip into the agent chain
|
|
179
|
+
* as an `audit_crosslink` record. The anchor is therefore protected by
|
|
180
|
+
* the agent chain's existing hash linkage (tampering with the anchor
|
|
181
|
+
* breaks agent-chain verification), and it pins the dashboard chain
|
|
182
|
+
* state at this point in time (tampering with already-anchored dashboard
|
|
183
|
+
* history is caught by anchor reconciliation in verifyUnified).
|
|
184
|
+
*
|
|
185
|
+
* @param {object} [opts]
|
|
186
|
+
* @param {string} [opts.projectDir] project dir for the agent log
|
|
187
|
+
* @param {string} [opts.logDir] explicit agent log dir (tests)
|
|
188
|
+
* @param {string} [opts.dashboardAuditDir]
|
|
189
|
+
* @param {string} [opts.who] actor recorded on the anchor
|
|
190
|
+
* @returns {object} the recorded anchor entry plus dashboard verdict.
|
|
191
|
+
*/
|
|
192
|
+
function crossLink(opts) {
|
|
193
|
+
opts = opts || {};
|
|
194
|
+
var dash = dashboardChainTip(opts);
|
|
195
|
+
var log = new AuditLog(opts);
|
|
196
|
+
var agentTip = log._lastHash;
|
|
197
|
+
var root = unifiedRoot(agentTip, dash.tip_hash || PY_GENESIS);
|
|
198
|
+
var anchor = log.record({
|
|
199
|
+
who: opts.who || 'audit-crosslink',
|
|
200
|
+
what: CROSSLINK_ACTION,
|
|
201
|
+
where: opts.dashboardAuditDir || defaultDashboardAuditDir(),
|
|
202
|
+
why: 'cross-link dashboard audit chain into agent audit chain',
|
|
203
|
+
metadata: {
|
|
204
|
+
dashboardChainId: dash.chain_id || 'loki-dashboard-audit',
|
|
205
|
+
dashboardTipHash: dash.tip_hash || PY_GENESIS,
|
|
206
|
+
dashboardEntries: dash.entries || 0,
|
|
207
|
+
dashboardValidAtLink: dash.available ? !!dash.valid : null,
|
|
208
|
+
dashboardAvailable: !!dash.available,
|
|
209
|
+
agentTipBeforeLink: agentTip,
|
|
210
|
+
unifiedRoot: root,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
log.flush();
|
|
214
|
+
log.destroy();
|
|
215
|
+
return { anchor: anchor, dashboard: dash, unifiedRoot: root };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Append-only / external-witness OPTION.
|
|
220
|
+
*
|
|
221
|
+
* Writes the current unified root to an append-only witness file (one
|
|
222
|
+
* JSON line per witness, never rewritten). Optionally pipes the line to
|
|
223
|
+
* an external witness command (opts.witnessCommand, e.g. a timestamping
|
|
224
|
+
* authority or `tee` to a WORM mount) so an independent party holds an
|
|
225
|
+
* out-of-band copy. Returns the witness record.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} [opts]
|
|
228
|
+
* @param {string} [opts.witnessFile] path to the append-only file
|
|
229
|
+
* @param {string} [opts.witnessCommand] external command (argv[0])
|
|
230
|
+
* @param {string[]} [opts.witnessArgs] extra args for the command
|
|
231
|
+
*/
|
|
232
|
+
function writeWitness(opts) {
|
|
233
|
+
opts = opts || {};
|
|
234
|
+
var agent = agentChainTip(opts);
|
|
235
|
+
var dash = dashboardChainTip(opts);
|
|
236
|
+
var root = unifiedRoot(agent.tip_hash, dash.tip_hash || PY_GENESIS);
|
|
237
|
+
var record = {
|
|
238
|
+
type: 'loki-unified-audit-witness',
|
|
239
|
+
timestamp: new Date().toISOString(),
|
|
240
|
+
agentTipHash: agent.tip_hash,
|
|
241
|
+
agentEntries: agent.entries,
|
|
242
|
+
dashboardTipHash: dash.tip_hash || PY_GENESIS,
|
|
243
|
+
dashboardEntries: dash.entries || 0,
|
|
244
|
+
unifiedRoot: root,
|
|
245
|
+
};
|
|
246
|
+
var line = JSON.stringify(record);
|
|
247
|
+
var witnessFile = opts.witnessFile ||
|
|
248
|
+
path.join((opts.projectDir || process.cwd()), '.loki', 'audit', WITNESS_FILE);
|
|
249
|
+
var witnessDir = path.dirname(witnessFile);
|
|
250
|
+
if (!fs.existsSync(witnessDir)) fs.mkdirSync(witnessDir, { recursive: true });
|
|
251
|
+
// Append-only: O_APPEND, never truncate or rewrite existing lines.
|
|
252
|
+
fs.appendFileSync(witnessFile, line + '\n', { encoding: 'utf8', flag: 'a' });
|
|
253
|
+
|
|
254
|
+
if (opts.witnessCommand) {
|
|
255
|
+
try {
|
|
256
|
+
execFileSync(opts.witnessCommand, (opts.witnessArgs || []).concat([line]), {
|
|
257
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
258
|
+
});
|
|
259
|
+
record.externalWitness = true;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
record.externalWitness = false;
|
|
262
|
+
record.externalWitnessError = String((e && e.message) || e);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { record: record, witnessFile: witnessFile };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Verify the witness file's own append-only continuity: each line must
|
|
270
|
+
* parse, and (if present) line N's agent/dashboard entry counts must be
|
|
271
|
+
* monotonic non-decreasing relative to line N-1. A shrinking count means
|
|
272
|
+
* the file was rewritten / truncated.
|
|
273
|
+
*/
|
|
274
|
+
function verifyWitnessFile(witnessFile) {
|
|
275
|
+
if (!witnessFile || !fs.existsSync(witnessFile)) {
|
|
276
|
+
return { present: false, valid: true, witnesses: 0, brokenAt: null };
|
|
277
|
+
}
|
|
278
|
+
var content = fs.readFileSync(witnessFile, 'utf8').trim();
|
|
279
|
+
if (!content) return { present: true, valid: true, witnesses: 0, brokenAt: null };
|
|
280
|
+
var lines = content.split('\n');
|
|
281
|
+
var prevAgent = -1;
|
|
282
|
+
var prevDash = -1;
|
|
283
|
+
for (var i = 0; i < lines.length; i++) {
|
|
284
|
+
var rec;
|
|
285
|
+
try { rec = JSON.parse(lines[i]); } catch (e) {
|
|
286
|
+
return { present: true, valid: false, witnesses: i, brokenAt: i,
|
|
287
|
+
error: 'invalid JSON at witness line ' + i };
|
|
288
|
+
}
|
|
289
|
+
var a = typeof rec.agentEntries === 'number' ? rec.agentEntries : 0;
|
|
290
|
+
var d = typeof rec.dashboardEntries === 'number' ? rec.dashboardEntries : 0;
|
|
291
|
+
if (a < prevAgent || d < prevDash) {
|
|
292
|
+
return { present: true, valid: false, witnesses: i, brokenAt: i,
|
|
293
|
+
error: 'witness counts went backwards at line ' + i +
|
|
294
|
+
' (append-only violated)' };
|
|
295
|
+
}
|
|
296
|
+
prevAgent = a;
|
|
297
|
+
prevDash = d;
|
|
298
|
+
}
|
|
299
|
+
return { present: true, valid: true, witnesses: lines.length, brokenAt: null };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Unified verification of the whole logical trail.
|
|
304
|
+
*
|
|
305
|
+
* Steps:
|
|
306
|
+
* 1. Verify the agent (JS) chain via AuditLog.verifyChain().
|
|
307
|
+
* 2. Verify the dashboard (Python) chain via audit.py.
|
|
308
|
+
* 3. For each `audit_crosslink` anchor in the agent chain, reconcile:
|
|
309
|
+
* - the anchor's unifiedRoot must equal
|
|
310
|
+
* sha256(agentTipBeforeLink, dashboardTipHash);
|
|
311
|
+
* - the MOST RECENT anchor's dashboardTipHash must equal the live
|
|
312
|
+
* dashboard tip (catches post-link tampering / truncation of
|
|
313
|
+
* dashboard history). Older anchors pin historical tips and are
|
|
314
|
+
* allowed to differ from the live tip (the chain grew).
|
|
315
|
+
* 4. (Optional) verify witness-file append-only continuity.
|
|
316
|
+
*
|
|
317
|
+
* The trail is `valid` only if every component that is present is valid.
|
|
318
|
+
* If the dashboard side is unavailable (e.g. Python missing), it is
|
|
319
|
+
* reported honestly as `available:false` and does not falsely pass.
|
|
320
|
+
*
|
|
321
|
+
* @param {object} [opts] same resolution opts as crossLink + optional
|
|
322
|
+
* opts.witnessFile and opts.requireDashboard (default true) and
|
|
323
|
+
* opts.requireCrosslink (default false).
|
|
324
|
+
*/
|
|
325
|
+
function verifyUnified(opts) {
|
|
326
|
+
opts = opts || {};
|
|
327
|
+
var requireDashboard = opts.requireDashboard !== false;
|
|
328
|
+
var requireCrosslink = opts.requireCrosslink === true;
|
|
329
|
+
|
|
330
|
+
var log = new AuditLog(opts);
|
|
331
|
+
var agentResult = log.verifyChain();
|
|
332
|
+
var entries = log.readEntries();
|
|
333
|
+
log.destroy();
|
|
334
|
+
|
|
335
|
+
var dash = dashboardChainTip(opts);
|
|
336
|
+
|
|
337
|
+
// Reconcile cross-link anchors.
|
|
338
|
+
//
|
|
339
|
+
// For each anchor we check two things:
|
|
340
|
+
// 1. The anchor's own unifiedRoot is internally consistent (it was
|
|
341
|
+
// not edited in place: unifiedRoot == H(agentTip, dashboardTip)).
|
|
342
|
+
// This is also protected by the agent chain hash, but checking it
|
|
343
|
+
// here gives a precise reconciliation error.
|
|
344
|
+
// 2. The dashboard PREFIX the anchor pinned still reproduces. The
|
|
345
|
+
// dashboard chain is a live, continuously-appended log, so its
|
|
346
|
+
// live tip legitimately moves forward after a cross-link. Instead
|
|
347
|
+
// of comparing to the live tip (which would false-fail on every
|
|
348
|
+
// normal append), we recompute the hash of the first
|
|
349
|
+
// `dashboardEntries` entries and require it to equal the anchored
|
|
350
|
+
// `dashboardTipHash`. Append-only growth keeps that prefix intact;
|
|
351
|
+
// mutation at-or-before the anchor, or truncation below it, breaks
|
|
352
|
+
// reproducibility and is caught here.
|
|
353
|
+
var anchors = entries.filter(function (e) { return e.what === CROSSLINK_ACTION; });
|
|
354
|
+
var anchorReconcile = { count: anchors.length, valid: true, error: null };
|
|
355
|
+
for (var i = 0; i < anchors.length; i++) {
|
|
356
|
+
var m = anchors[i].metadata || {};
|
|
357
|
+
var expectRoot = unifiedRoot(
|
|
358
|
+
m.agentTipBeforeLink || '', m.dashboardTipHash || PY_GENESIS);
|
|
359
|
+
if (m.unifiedRoot !== expectRoot) {
|
|
360
|
+
anchorReconcile.valid = false;
|
|
361
|
+
anchorReconcile.error = 'anchor unifiedRoot mismatch at seq ' + anchors[i].seq;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
// Only reconcile the dashboard prefix when the dashboard side was
|
|
365
|
+
// available at link time AND is available now. An anchor that
|
|
366
|
+
// recorded an unavailable dashboard (dashboardAvailable=false) has
|
|
367
|
+
// nothing to reconcile against.
|
|
368
|
+
if (dash.available && m.dashboardAvailable) {
|
|
369
|
+
var pinnedTip = m.dashboardTipHash || PY_GENESIS;
|
|
370
|
+
var pinnedCount = typeof m.dashboardEntries === 'number' ? m.dashboardEntries : 0;
|
|
371
|
+
var prefix = dashboardPrefixHash(pinnedCount, opts);
|
|
372
|
+
if (!prefix.available || !prefix.found || prefix.prefix_hash !== pinnedTip) {
|
|
373
|
+
anchorReconcile.valid = false;
|
|
374
|
+
anchorReconcile.error =
|
|
375
|
+
'dashboard prefix pinned by anchor seq ' + anchors[i].seq +
|
|
376
|
+
' no longer reproduces (history tampered or truncated below the link point)';
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
var witness = verifyWitnessFile(
|
|
383
|
+
opts.witnessFile ||
|
|
384
|
+
path.join((opts.projectDir || process.cwd()), '.loki', 'audit', WITNESS_FILE));
|
|
385
|
+
|
|
386
|
+
var dashboardOk = dash.available ? !!dash.valid : !requireDashboard;
|
|
387
|
+
var crosslinkOk = requireCrosslink ? anchors.length > 0 : true;
|
|
388
|
+
|
|
389
|
+
var valid = !!agentResult.valid && dashboardOk && anchorReconcile.valid &&
|
|
390
|
+
witness.valid && crosslinkOk;
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
valid: valid,
|
|
394
|
+
agent: agentResult,
|
|
395
|
+
dashboard: dash,
|
|
396
|
+
anchors: anchorReconcile,
|
|
397
|
+
witness: witness,
|
|
398
|
+
requireDashboard: requireDashboard,
|
|
399
|
+
requireCrosslink: requireCrosslink,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
module.exports = {
|
|
404
|
+
crossLink: crossLink,
|
|
405
|
+
verifyUnified: verifyUnified,
|
|
406
|
+
writeWitness: writeWitness,
|
|
407
|
+
verifyWitnessFile: verifyWitnessFile,
|
|
408
|
+
dashboardChainTip: dashboardChainTip,
|
|
409
|
+
agentChainTip: agentChainTip,
|
|
410
|
+
unifiedRoot: unifiedRoot,
|
|
411
|
+
defaultDashboardAuditDir: defaultDashboardAuditDir,
|
|
412
|
+
CROSSLINK_ACTION: CROSSLINK_ACTION,
|
|
413
|
+
};
|
package/src/audit/index.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
var { AuditLog } = require('./log');
|
|
19
19
|
var compliance = require('./compliance');
|
|
20
20
|
var { ResidencyController } = require('./residency');
|
|
21
|
+
var crosslink = require('./crosslink');
|
|
21
22
|
|
|
22
23
|
var _log = null;
|
|
23
24
|
var _residency = null;
|
|
@@ -121,6 +122,34 @@ function flush() {
|
|
|
121
122
|
if (_log) _log.flush();
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Cross-link the dashboard (Python) audit chain into the agent (JS)
|
|
127
|
+
* audit chain, producing a single verifiable tamper-evident trail.
|
|
128
|
+
* See src/audit/crosslink.js.
|
|
129
|
+
*/
|
|
130
|
+
function crossLink(opts) {
|
|
131
|
+
if (!_initialized) init();
|
|
132
|
+
return crosslink.crossLink(Object.assign({ projectDir: _projectDir }, opts || {}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Verify the unified (agent + dashboard) audit trail as one logical
|
|
137
|
+
* chain: both sub-chains valid AND every cross-link anchor reconciled.
|
|
138
|
+
*/
|
|
139
|
+
function verifyUnified(opts) {
|
|
140
|
+
if (!_initialized) init();
|
|
141
|
+
return crosslink.verifyUnified(Object.assign({ projectDir: _projectDir }, opts || {}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Append-only / external-witness option: write the current unified root
|
|
146
|
+
* to an append-only witness file (and optionally an external command).
|
|
147
|
+
*/
|
|
148
|
+
function writeWitness(opts) {
|
|
149
|
+
if (!_initialized) init();
|
|
150
|
+
return crosslink.writeWitness(Object.assign({ projectDir: _projectDir }, opts || {}));
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
/**
|
|
125
154
|
* Destroy audit trail (for testing).
|
|
126
155
|
*/
|
|
@@ -144,4 +173,7 @@ module.exports = {
|
|
|
144
173
|
getSummary: getSummary,
|
|
145
174
|
flush: flush,
|
|
146
175
|
destroy: destroy,
|
|
176
|
+
crossLink: crossLink,
|
|
177
|
+
verifyUnified: verifyUnified,
|
|
178
|
+
writeWitness: writeWitness,
|
|
147
179
|
};
|