loki-mode 7.53.0 → 7.54.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 +1 -2
- package/SKILL.md +2 -3
- package/VERSION +1 -1
- package/autonomy/run.sh +7 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +2 -3
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/src/audit/compliance-scheduler.js +287 -0
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ _The free, source-available autonomous coding agent by [Autonomi](https://www.au
|
|
|
40
40
|
- **Legacy system healing** -- `loki modernize heal` archaeology/stabilize/isolate/modernize/validate phases (v6.67.0, see `skills/healing.md`)
|
|
41
41
|
- **MCP server** -- 34 tools (including ChromaDB code search) plus 3 resources and 2 prompts (`mcp/server.py`, with magic tools registered from `mcp/magic_tools.py` and the managed-memory tool from `mcp/managed_tools.py`). Of the 34, 33 are always available; `loki_memory_redact` is registered but only succeeds when `LOKI_MANAGED_AGENTS=true` and `LOKI_MANAGED_MEMORY=true`. Launch with `loki mcp` (bootstraps the Python MCP SDK on first run).
|
|
42
42
|
- **Full-stack output** -- Source code, tests, Docker Compose stacks (multi-service with healthchecks), CI/CD pipelines, audit logs
|
|
43
|
-
- **Provider-agnostic** -- runs on Claude, Codex, Cline, or Aider with automatic failover (`loki-ts/src/runner/providers.ts`); no vendor lock-in. Gemini CLI deprecated v7.5.18
|
|
43
|
+
- **Provider-agnostic** -- runs on Claude, Codex, Cline, or Aider with automatic failover (`loki-ts/src/runner/providers.ts`); no vendor lock-in. Gemini CLI deprecated v7.5.18.
|
|
44
44
|
- **Source-available (BUSL-1.1)** -- Free for personal, internal, and academic use.
|
|
45
45
|
|
|
46
46
|
---
|
|
@@ -326,7 +326,6 @@ Loki's autonomy and quality loop are the product; the underlying coding CLI is s
|
|
|
326
326
|
| **Cline CLI** | Experimental (Tier 2) | `-y` | Sequential | `npm i -g @anthropic-ai/cline` |
|
|
327
327
|
| **Aider** | Experimental (Tier 3) | `--yes-always` | Sequential | `pip install aider-chat` |
|
|
328
328
|
| **Google Gemini CLI** | DEPRECATED v7.5.18 | -- | -- | Upstream deprecated; runtime removed. `LOKI_PROVIDER=gemini` exits with migration message. |
|
|
329
|
-
| **Anthropic Antigravity CLI** | Coming soon | -- | -- | Integration planned. |
|
|
330
329
|
|
|
331
330
|
Status legend: "E2E-verified" means we run real spec-to-code builds on it ourselves. Claude Code is the primary, fully supported provider and the one Loki Mode is built for; it gets full features (subagents, parallelization, MCP, Task tool). "Experimental" means the wiring is in place but we have not produced an end-to-end verified build ourselves; treat as community-tested. Experimental providers run sequentially. Auto-failover switches providers when rate-limited. See [Provider Guide](skills/providers.md).
|
|
332
331
|
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v7.
|
|
6
|
+
# Loki Mode v7.54.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -238,7 +238,6 @@ loki docker --image IMG start prd.md # override the image
|
|
|
238
238
|
- **Cline**: Multi-provider CLI, degraded mode (sequential only, no Task tool)
|
|
239
239
|
- **Aider**: 18+ provider backends, degraded mode (sequential only, no Task tool)
|
|
240
240
|
- **Google Gemini CLI**: DEPRECATED starting v7.5.18 (upstream deprecated; runtime removed)
|
|
241
|
-
- **Anthropic Antigravity CLI**: Coming soon
|
|
242
241
|
|
|
243
242
|
---
|
|
244
243
|
|
|
@@ -407,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
407
406
|
|
|
408
407
|
---
|
|
409
408
|
|
|
410
|
-
**v7.
|
|
409
|
+
**v7.54.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.54.0
|
package/autonomy/run.sh
CHANGED
|
@@ -1454,7 +1454,13 @@ stop_enterprise_services() {
|
|
|
1454
1454
|
# Exit 0 = ALLOW, Exit 1 = DENY, Exit 2 = REQUIRE_APPROVAL (logged but allowed for now)
|
|
1455
1455
|
check_policy() {
|
|
1456
1456
|
local enforcement_point="$1"
|
|
1457
|
-
|
|
1457
|
+
# Default to a valid empty-object JSON. Do NOT inline `${2:-{}}`: the
|
|
1458
|
+
# closing brace of the parameter expansion eats the first `}` of the
|
|
1459
|
+
# `{}` default, so a non-empty $2 like {"a":1} would pass through as
|
|
1460
|
+
# {"a":1}} (invalid JSON -> check.js JSON.parse fails -> exit 1 DENY
|
|
1461
|
+
# every iteration). Split the default assignment to avoid the footgun.
|
|
1462
|
+
local context_json="${2:-}"
|
|
1463
|
+
[ -z "$context_json" ] && context_json='{}'
|
|
1458
1464
|
|
|
1459
1465
|
# Only check if policy files exist
|
|
1460
1466
|
if [ ! -f ".loki/policies.json" ] && [ ! -f ".loki/policies.yaml" ]; then
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.
|
|
5
|
+
**Version:** v7.54.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -312,7 +312,6 @@ Loki Mode supports four active providers across three tiers, plus historical/upc
|
|
|
312
312
|
| `codex` | Active | Tier 3 (degraded) | Sequential only, no Task tool; aligned with `@openai/codex` v0.125+. |
|
|
313
313
|
| `aider` | Active | Tier 3 (degraded) | Sequential only; `ollama_chat/<model>` works for local models. |
|
|
314
314
|
| `gemini` | DEPRECATED v7.5.18 | -- | Upstream Gemini CLI deprecated by Google. Runtime removed; `LOKI_PROVIDER=gemini` exits with migration message. |
|
|
315
|
-
| `antigravity` | Coming soon | -- | Anthropic Antigravity CLI integration planned. |
|
|
316
315
|
|
|
317
316
|
### Configuration
|
|
318
317
|
|
|
@@ -396,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
|
|
|
396
395
|
# Run Loki Mode in Docker (Claude provider, API-key auth)
|
|
397
396
|
docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
|
398
397
|
-v $(pwd):/workspace -w /workspace \
|
|
399
|
-
asklokesh/loki-mode:7.
|
|
398
|
+
asklokesh/loki-mode:7.54.0 start ./my-spec.md
|
|
400
399
|
```
|
|
401
400
|
|
|
402
401
|
##### docker compose + .env (no host install)
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.
|
|
2
|
+
var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.54.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -790,4 +790,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
790
790
|
`),2}default:return process.stderr.write(`Unknown command: ${Q}
|
|
791
791
|
`),process.stderr.write(s6),2}}l1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var KZ=await XZ(Bun.argv.slice(2));process.exit(KZ);
|
|
792
792
|
|
|
793
|
-
//# debugId=
|
|
793
|
+
//# debugId=45D73957F12E90DF64756E2164756E21
|
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.54.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.54.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,287 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compliance snapshot scheduler (lightweight helper, NOT a daemon).
|
|
5
|
+
*
|
|
6
|
+
* v7.53.0 shipped a live GET /api/compliance endpoint that regenerates a
|
|
7
|
+
* compliance report on demand. This module is the remaining P3-11 piece:
|
|
8
|
+
* OPTIONAL scheduled/continuous generation so a compliance snapshot is
|
|
9
|
+
* periodically PERSISTED to disk. That gives two things a live dashboard
|
|
10
|
+
* call cannot:
|
|
11
|
+
*
|
|
12
|
+
* 1. Trend / history: a series of timestamped snapshots over time.
|
|
13
|
+
* 2. Air-gapped audit evidence: durable, self-contained proof on disk
|
|
14
|
+
* without anyone making a live API call.
|
|
15
|
+
*
|
|
16
|
+
* It is deliberately NOT a background process. The gate function
|
|
17
|
+
* (maybeGenerateSnapshot) is meant to be invoked opportunistically (for
|
|
18
|
+
* example once per autonomous run) and self-rate-limits: it only writes a
|
|
19
|
+
* new snapshot when the configured interval has elapsed since the last one.
|
|
20
|
+
* That makes it "continuous" when enabled without needing a daemon or any
|
|
21
|
+
* always-running loop.
|
|
22
|
+
*
|
|
23
|
+
* HONESTY: every snapshot is generated from the REAL current audit chain
|
|
24
|
+
* (AuditLog.readEntries) and the REAL tamper-evidence verdict
|
|
25
|
+
* (AuditLog.verifyChain), exactly like index.js getReport. An empty chain
|
|
26
|
+
* yields an honest empty snapshot (totalAuditEntries: 0), never a
|
|
27
|
+
* fabricated "compliant" verdict.
|
|
28
|
+
*
|
|
29
|
+
* DEFAULT DISABLED: with no configured interval (LOKI_COMPLIANCE_SNAPSHOT_INTERVAL_HOURS
|
|
30
|
+
* unset or 0), maybeGenerateSnapshot is a no-op and adds zero behavior for
|
|
31
|
+
* existing users.
|
|
32
|
+
*
|
|
33
|
+
* NOT YET AUTO-INVOKED: this wave ships the tested helper only. It is not
|
|
34
|
+
* wired into run.sh or any live loop here (that is integration, owned by the
|
|
35
|
+
* run.sh owners). See "How to invoke" below for the intended call site.
|
|
36
|
+
*
|
|
37
|
+
* How to invoke (intended integration, not yet wired):
|
|
38
|
+
* var scheduler = require('./src/audit/compliance-scheduler');
|
|
39
|
+
* // Once per run, after init:
|
|
40
|
+
* scheduler.maybeGenerateSnapshot({ projectDir: process.cwd() });
|
|
41
|
+
* // Reads LOKI_COMPLIANCE_SNAPSHOT_INTERVAL_HOURS; no-op unless elapsed.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
var fs = require('fs');
|
|
45
|
+
var path = require('path');
|
|
46
|
+
var { AuditLog } = require('./log');
|
|
47
|
+
var compliance = require('./compliance');
|
|
48
|
+
|
|
49
|
+
var ENV_INTERVAL = 'LOKI_COMPLIANCE_SNAPSHOT_INTERVAL_HOURS';
|
|
50
|
+
var SNAPSHOT_DIRNAME = 'compliance-snapshots';
|
|
51
|
+
var MARKER_FILENAME = 'last-snapshot.json';
|
|
52
|
+
var MS_PER_HOUR = 3600 * 1000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a configured interval (hours) into a number. Returns 0 (disabled)
|
|
56
|
+
* for unset, empty, non-numeric, negative, or NaN values. 0 means the
|
|
57
|
+
* scheduler is disabled and maybeGenerateSnapshot is a no-op.
|
|
58
|
+
*
|
|
59
|
+
* @param {*} raw - Raw value (typically process.env.LOKI_COMPLIANCE_SNAPSHOT_INTERVAL_HOURS)
|
|
60
|
+
* @returns {number} Interval in hours, or 0 if disabled / invalid.
|
|
61
|
+
*/
|
|
62
|
+
function parseIntervalHours(raw) {
|
|
63
|
+
if (raw === undefined || raw === null || raw === '') return 0;
|
|
64
|
+
var n = Number(raw);
|
|
65
|
+
if (!isFinite(n) || n <= 0) return 0;
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the snapshot directory for a project: <projectDir>/.loki/audit/compliance-snapshots.
|
|
71
|
+
*/
|
|
72
|
+
function snapshotDir(projectDir) {
|
|
73
|
+
return path.join(projectDir || process.cwd(), '.loki', 'audit', SNAPSHOT_DIRNAME);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the rate-limit marker file path.
|
|
78
|
+
*/
|
|
79
|
+
function markerPath(projectDir) {
|
|
80
|
+
return path.join(snapshotDir(projectDir), MARKER_FILENAME);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the last-generated timestamp (ms) from the marker file. Returns null
|
|
85
|
+
* if no marker exists or it is unreadable / malformed (treated as "never
|
|
86
|
+
* generated" so the next call generates).
|
|
87
|
+
*/
|
|
88
|
+
function readLastGeneratedAtMs(projectDir) {
|
|
89
|
+
var p = markerPath(projectDir);
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(p)) return null;
|
|
92
|
+
var raw = fs.readFileSync(p, 'utf8');
|
|
93
|
+
var obj = JSON.parse(raw);
|
|
94
|
+
var ms = Number(obj && obj.lastGeneratedAtMs);
|
|
95
|
+
if (!isFinite(ms)) return null;
|
|
96
|
+
return ms;
|
|
97
|
+
} catch (_) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build a compliance snapshot from the REAL audit chain for a project.
|
|
104
|
+
*
|
|
105
|
+
* Bundles all three report types (soc2, iso27001, gdpr), each generated
|
|
106
|
+
* from the live audit entries, plus the single shared tamper-evidence
|
|
107
|
+
* verdict. This mirrors index.js getReport's honesty: the chainIntegrity
|
|
108
|
+
* verdict comes from verifyChain(), and a verification error is recorded
|
|
109
|
+
* as a valid:false verdict rather than being allowed to throw or fabricate
|
|
110
|
+
* a pass. An empty chain produces totalAuditEntries: 0 honestly.
|
|
111
|
+
*
|
|
112
|
+
* This does NOT use the index.js singleton; it reads the chain directly so
|
|
113
|
+
* it is self-contained and free of shared-state coupling.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} args
|
|
116
|
+
* @param {string} args.projectDir - Project root whose .loki/audit chain is read.
|
|
117
|
+
* @param {object} [args.reportOpts] - Options forwarded to the generators (projectName, period, etc.)
|
|
118
|
+
* @param {number} [args.nowMs] - Clock for generatedAt (defaults to Date.now()).
|
|
119
|
+
* @returns {object} The snapshot object.
|
|
120
|
+
*/
|
|
121
|
+
function buildSnapshot(args) {
|
|
122
|
+
args = args || {};
|
|
123
|
+
var projectDir = args.projectDir || process.cwd();
|
|
124
|
+
var reportOpts = args.reportOpts || {};
|
|
125
|
+
var nowMs = (typeof args.nowMs === 'number') ? args.nowMs : Date.now();
|
|
126
|
+
|
|
127
|
+
var log = new AuditLog({ projectDir: projectDir });
|
|
128
|
+
var entries;
|
|
129
|
+
try {
|
|
130
|
+
entries = log.readEntries();
|
|
131
|
+
} catch (e) {
|
|
132
|
+
entries = [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Real tamper-evidence verdict. Do not let a verification error fabricate
|
|
136
|
+
// a pass: capture it honestly as a valid:false verdict instead.
|
|
137
|
+
var chainIntegrity;
|
|
138
|
+
try {
|
|
139
|
+
chainIntegrity = log.verifyChain();
|
|
140
|
+
} catch (e) {
|
|
141
|
+
chainIntegrity = {
|
|
142
|
+
valid: false,
|
|
143
|
+
entries: entries.length,
|
|
144
|
+
brokenAt: null,
|
|
145
|
+
error: 'chain verification failed: ' + String((e && e.message) || e),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try { log.destroy(); } catch (_) { /* noop */ }
|
|
150
|
+
|
|
151
|
+
var soc2 = compliance.generateSoc2Report(entries, reportOpts);
|
|
152
|
+
soc2.chainIntegrity = chainIntegrity;
|
|
153
|
+
var iso27001 = compliance.generateIso27001Report(entries, reportOpts);
|
|
154
|
+
iso27001.chainIntegrity = chainIntegrity;
|
|
155
|
+
var gdpr = compliance.generateGdprReport(entries, reportOpts);
|
|
156
|
+
gdpr.chainIntegrity = chainIntegrity;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
snapshotVersion: 1,
|
|
160
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
161
|
+
projectName: reportOpts.projectName || 'Loki Mode',
|
|
162
|
+
totalAuditEntries: entries.length,
|
|
163
|
+
chainIntegrity: chainIntegrity,
|
|
164
|
+
reports: {
|
|
165
|
+
soc2: soc2,
|
|
166
|
+
iso27001: iso27001,
|
|
167
|
+
gdpr: gdpr,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build a filesystem-safe snapshot filename from an ISO timestamp. ISO
|
|
174
|
+
* strings contain colons and dots which are fine on macOS/Linux but are
|
|
175
|
+
* sanitized to hyphens anyway for portability.
|
|
176
|
+
*/
|
|
177
|
+
function snapshotFilename(isoTimestamp) {
|
|
178
|
+
var safe = String(isoTimestamp).replace(/[:.]/g, '-');
|
|
179
|
+
return 'compliance-' + safe + '.json';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Persist a snapshot to disk and update the rate-limit marker.
|
|
184
|
+
*
|
|
185
|
+
* Writes <snapshotDir>/compliance-<timestamp>.json and updates
|
|
186
|
+
* <snapshotDir>/last-snapshot.json with the generation clock so the next
|
|
187
|
+
* gate decision reads the same clock that produced the snapshot.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} args
|
|
190
|
+
* @param {string} args.projectDir - Project root.
|
|
191
|
+
* @param {object} args.snapshot - Snapshot object (from buildSnapshot).
|
|
192
|
+
* @param {number} args.nowMs - Generation clock in ms (stored in the marker).
|
|
193
|
+
* @returns {string} The absolute path of the written snapshot file.
|
|
194
|
+
*/
|
|
195
|
+
function persistSnapshot(args) {
|
|
196
|
+
var projectDir = args.projectDir || process.cwd();
|
|
197
|
+
var snapshot = args.snapshot;
|
|
198
|
+
var nowMs = args.nowMs;
|
|
199
|
+
var dir = snapshotDir(projectDir);
|
|
200
|
+
if (!fs.existsSync(dir)) {
|
|
201
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
202
|
+
}
|
|
203
|
+
var file = path.join(dir, snapshotFilename(snapshot.generatedAt));
|
|
204
|
+
fs.writeFileSync(file, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
205
|
+
fs.writeFileSync(
|
|
206
|
+
markerPath(projectDir),
|
|
207
|
+
JSON.stringify({ lastGeneratedAtMs: nowMs, lastSnapshotFile: path.basename(file) }, null, 2),
|
|
208
|
+
'utf8'
|
|
209
|
+
);
|
|
210
|
+
return file;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Opportunistic, self-rate-limiting snapshot generation.
|
|
215
|
+
*
|
|
216
|
+
* Decides whether to generate a snapshot now based on the configured
|
|
217
|
+
* interval and the persisted last-generated timestamp:
|
|
218
|
+
*
|
|
219
|
+
* - Interval 0 / unset (default) -> no-op, reason 'disabled'.
|
|
220
|
+
* - No prior snapshot and interval > 0 -> generate (first run).
|
|
221
|
+
* - now - lastGeneratedAt >= interval -> generate.
|
|
222
|
+
* - Otherwise -> no-op, reason 'not-elapsed'.
|
|
223
|
+
*
|
|
224
|
+
* Both the interval and the clock are injectable via opts so callers (and
|
|
225
|
+
* tests) can control them; env is the fallback for the interval and
|
|
226
|
+
* Date.now() the fallback for the clock.
|
|
227
|
+
*
|
|
228
|
+
* Return contract:
|
|
229
|
+
* { generated: true, path: <file>, report: <snapshot>, intervalHours }
|
|
230
|
+
* { generated: false, reason: 'disabled', intervalHours: 0 }
|
|
231
|
+
* { generated: false, reason: 'not-elapsed', intervalHours, nextEligibleAtMs }
|
|
232
|
+
*
|
|
233
|
+
* @param {object} [opts]
|
|
234
|
+
* @param {string} [opts.projectDir] - Project root (default process.cwd()).
|
|
235
|
+
* @param {number} [opts.intervalHours] - Interval override; falls back to env.
|
|
236
|
+
* @param {number} [opts.now] - Current time in ms; falls back to Date.now().
|
|
237
|
+
* @param {object} [opts.reportOpts] - Options forwarded to report generators.
|
|
238
|
+
* @returns {object} Result per the return contract above.
|
|
239
|
+
*/
|
|
240
|
+
function maybeGenerateSnapshot(opts) {
|
|
241
|
+
opts = opts || {};
|
|
242
|
+
var projectDir = opts.projectDir || process.cwd();
|
|
243
|
+
var intervalHours = (typeof opts.intervalHours === 'number')
|
|
244
|
+
? parseIntervalHours(opts.intervalHours)
|
|
245
|
+
: parseIntervalHours(process.env[ENV_INTERVAL]);
|
|
246
|
+
var now = (typeof opts.now === 'number') ? opts.now : Date.now();
|
|
247
|
+
|
|
248
|
+
if (intervalHours <= 0) {
|
|
249
|
+
return { generated: false, reason: 'disabled', intervalHours: 0 };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
var lastMs = readLastGeneratedAtMs(projectDir);
|
|
253
|
+
var intervalMs = intervalHours * MS_PER_HOUR;
|
|
254
|
+
|
|
255
|
+
if (lastMs !== null && (now - lastMs) < intervalMs) {
|
|
256
|
+
return {
|
|
257
|
+
generated: false,
|
|
258
|
+
reason: 'not-elapsed',
|
|
259
|
+
intervalHours: intervalHours,
|
|
260
|
+
nextEligibleAtMs: lastMs + intervalMs,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
var snapshot = buildSnapshot({
|
|
265
|
+
projectDir: projectDir,
|
|
266
|
+
reportOpts: opts.reportOpts,
|
|
267
|
+
nowMs: now,
|
|
268
|
+
});
|
|
269
|
+
var file = persistSnapshot({ projectDir: projectDir, snapshot: snapshot, nowMs: now });
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
generated: true,
|
|
273
|
+
path: file,
|
|
274
|
+
report: snapshot,
|
|
275
|
+
intervalHours: intervalHours,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
maybeGenerateSnapshot: maybeGenerateSnapshot,
|
|
281
|
+
buildSnapshot: buildSnapshot,
|
|
282
|
+
persistSnapshot: persistSnapshot,
|
|
283
|
+
parseIntervalHours: parseIntervalHours,
|
|
284
|
+
snapshotDir: snapshotDir,
|
|
285
|
+
markerPath: markerPath,
|
|
286
|
+
ENV_INTERVAL: ENV_INTERVAL,
|
|
287
|
+
};
|