loki-mode 11.76.0 → 11.78.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.
Files changed (103) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/check_author.py +146 -0
  4. package/autonomy/lib/claude-flags.sh +5 -1
  5. package/autonomy/lib/mcp-config.sh +41 -1
  6. package/autonomy/lib/proof-analytics-props.py +115 -6
  7. package/autonomy/lib/proof-verify.py +22 -0
  8. package/autonomy/lib/telemetry-schema.json +52 -0
  9. package/autonomy/loki +398 -7
  10. package/autonomy/telemetry.sh +70 -31
  11. package/completions/_loki +19 -0
  12. package/completions/loki.bash +17 -1
  13. package/dashboard/__init__.py +1 -1
  14. package/dashboard/api_start.py +2 -10
  15. package/dashboard/secure_tmp.py +92 -0
  16. package/dashboard/telemetry.py +81 -15
  17. package/docs/ARCHITECTURE.md +6 -4
  18. package/docs/{v10/CLI-MODERN.md → CLI-INVENTORY.md} +6 -6
  19. package/docs/CLI-REFERENCE.md +3 -2
  20. package/docs/COMPARISON.md +6 -8
  21. package/docs/COMPONENTS.md +1 -1
  22. package/docs/INSTALLATION.md +1 -1
  23. package/docs/PRIVACY.md +47 -11
  24. package/docs/V10-RESEARCH.md +1 -1
  25. package/docs/architecture/ADR-001-runtime-migration.md +7 -7
  26. package/docs/cursor-comparison.md +1 -1
  27. package/docs/dev/architecture-reference.md +7 -2
  28. package/docs/dev/project-structure.md +2 -1
  29. package/docs/image-provenance.md +32 -7
  30. package/docs/slack.md +2 -0
  31. package/docs/v11/COST-HALF.md +2 -1
  32. package/loki-ts/dist/loki.js +1744 -1604
  33. package/loki-ts/package.json +2 -2
  34. package/mcp/__init__.py +1 -1
  35. package/mcp/requirements.txt +1 -1
  36. package/package.json +3 -3
  37. package/packages/control-plane/dist/server.js +20045 -19119
  38. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  39. package/providers/aider.sh +7 -0
  40. package/providers/claude.sh +8 -1
  41. package/providers/cline.sh +3 -0
  42. package/providers/loader.sh +5 -0
  43. package/providers/model_catalog.json +3 -0
  44. package/providers/models.sh +59 -0
  45. package/providers/opencode.sh +9 -3
  46. package/skills/providers.md +1 -0
  47. package/web-app/dist/assets/{AdminPage-DoGPqZIa.js → AdminPage-BPF2Xehf.js} +1 -1
  48. package/web-app/dist/assets/{Avatar-Dyf7xJsz.js → Avatar-CSW9BhBU.js} +1 -1
  49. package/web-app/dist/assets/{Badge-Cm4dADew.js → Badge-wgm79wim.js} +1 -1
  50. package/web-app/dist/assets/{Button-LGxjldY5.js → Button-BcIAcPKr.js} +1 -1
  51. package/web-app/dist/assets/{CockpitPage-DuC9g48J.js → CockpitPage-BFi-0Kwj.js} +1 -1
  52. package/web-app/dist/assets/{ComparePage-DuTrmedK.js → ComparePage-B7Wibrfn.js} +1 -1
  53. package/web-app/dist/assets/{ErrorBoundary-B0f7NEiT.js → ErrorBoundary-BYyOVh2c.js} +1 -1
  54. package/web-app/dist/assets/{EvidenceReceiptPanel-DsE8B9Hw.js → EvidenceReceiptPanel-C9QcrjNl.js} +1 -1
  55. package/web-app/dist/assets/{GitHubIssuesPanel-aYflOqd4.js → GitHubIssuesPanel-CWbuBxVZ.js} +1 -1
  56. package/web-app/dist/assets/{GitHubPRsPanel-CCJGck6c.js → GitHubPRsPanel-qcBHK615.js} +1 -1
  57. package/web-app/dist/assets/{HomePage-BJU3PVkJ.js → HomePage-B1mYFU4t.js} +2 -2
  58. package/web-app/dist/assets/{LoginPage-UzYoF5CJ.js → LoginPage-CmIO1xL7.js} +1 -1
  59. package/web-app/dist/assets/{MagicPage-5X3hQ7lt.js → MagicPage-L7UP4qKP.js} +1 -1
  60. package/web-app/dist/assets/{MetricsPage-CFsTNyOu.js → MetricsPage-DVPCjN1U.js} +1 -1
  61. package/web-app/dist/assets/{NotFoundPage-Ba0nMlqG.js → NotFoundPage-D3XZmCH5.js} +1 -1
  62. package/web-app/dist/assets/{ProjectPage-fvU-EYZb.js → ProjectPage-BlaMzlVi.js} +3 -3
  63. package/web-app/dist/assets/{ProjectsPage-CVEKCorG.js → ProjectsPage-DN7W28Zq.js} +1 -1
  64. package/web-app/dist/assets/{SettingsPage-JGbsNmqD.js → SettingsPage-2u9ODYB5.js} +1 -1
  65. package/web-app/dist/assets/{ShowcasePage-Dr2c_V-y.js → ShowcasePage-CoKuTfab.js} +1 -1
  66. package/web-app/dist/assets/{SystemSettingsPage-D9dCc4Ir.js → SystemSettingsPage-Crhj2XAU.js} +1 -1
  67. package/web-app/dist/assets/{TeamsPage-B56u-bod.js → TeamsPage-DehMgnKh.js} +1 -1
  68. package/web-app/dist/assets/{TemplatesPage-CUwqj6ge.js → TemplatesPage-DGtwfZgU.js} +1 -1
  69. package/web-app/dist/assets/{TerminalOutput-Cm920SDi.js → TerminalOutput-7HZRtXoC.js} +1 -1
  70. package/web-app/dist/assets/{activity-DDF6juD-.js → activity-DAp5wswo.js} +1 -1
  71. package/web-app/dist/assets/{bell-7MW_oAcb.js → bell-Ncvnrz7d.js} +1 -1
  72. package/web-app/dist/assets/{bot-b63uz2Ov.js → bot-BWob3K3E.js} +1 -1
  73. package/web-app/dist/assets/{check-2oBMAALa.js → check-DReWA2vm.js} +1 -1
  74. package/web-app/dist/assets/{chevron-left-UuhI6pbU.js → chevron-left-CBJpzVU1.js} +1 -1
  75. package/web-app/dist/assets/{circle-alert-BikRzQA7.js → circle-alert-2c_vs5PW.js} +1 -1
  76. package/web-app/dist/assets/{clock-BdkTaAgO.js → clock-BrS_ZKKi.js} +1 -1
  77. package/web-app/dist/assets/{cloud-oKg24kTO.js → cloud-Czvuo51R.js} +1 -1
  78. package/web-app/dist/assets/{code-xml-CpDmdnYQ.js → code-xml-BWEqJ2I8.js} +1 -1
  79. package/web-app/dist/assets/{database-BT133u2w.js → database-CJ6M-oK-.js} +1 -1
  80. package/web-app/dist/assets/{dollar-sign-khUEgmXm.js → dollar-sign-DSObKmVv.js} +1 -1
  81. package/web-app/dist/assets/{file-code-corner-_TlPqN5g.js → file-code-corner-CVEW-gF5.js} +1 -1
  82. package/web-app/dist/assets/{file-plus-B5UT0SZU.js → file-plus-CAK0I0tE.js} +1 -1
  83. package/web-app/dist/assets/{globe-Chhrgg2K.js → globe-DeUgcsSZ.js} +1 -1
  84. package/web-app/dist/assets/{hammer-DJ6Z4tiA.js → hammer-B74PyjS0.js} +1 -1
  85. package/web-app/dist/assets/{index-BprNVRFx.js → index-DW3_HRqp.js} +3 -3
  86. package/web-app/dist/assets/{layers-CtYWuenz.js → layers-Dw7kaGyJ.js} +1 -1
  87. package/web-app/dist/assets/{loader-circle-BRxnh_Va.js → loader-circle-B6xzlAjw.js} +1 -1
  88. package/web-app/dist/assets/{lock-btaZm2bN.js → lock-5ncCPzIG.js} +1 -1
  89. package/web-app/dist/assets/{package-BFU4d6gL.js → package-x59_dTx0.js} +1 -1
  90. package/web-app/dist/assets/{plus-BogXrZQi.js → plus-BcVBzjpS.js} +1 -1
  91. package/web-app/dist/assets/{refresh-cw-CsdQkR5n.js → refresh-cw-CNrJhks6.js} +1 -1
  92. package/web-app/dist/assets/{rotate-ccw-BeLxChlE.js → rotate-ccw-BpmS_ezI.js} +1 -1
  93. package/web-app/dist/assets/{scroll-text-DAXSJc0i.js → scroll-text-CwBYNyoj.js} +1 -1
  94. package/web-app/dist/assets/{server-Cf_yJUqU.js → server-C4hnxkk6.js} +1 -1
  95. package/web-app/dist/assets/{shield-alert-B2gh1hM3.js → shield-alert-CSVUyWCM.js} +1 -1
  96. package/web-app/dist/assets/{thumbs-up-DxYjJ1v8.js → thumbs-up-DEU9fmDX.js} +1 -1
  97. package/web-app/dist/assets/{trash-2-BaAsSOYk.js → trash-2-DJKMevAm.js} +1 -1
  98. package/web-app/dist/assets/{trending-down-Kc-Tady7.js → trending-down-Dq3wAM59.js} +1 -1
  99. package/web-app/dist/assets/{trending-up-BDsns895.js → trending-up-DribptN4.js} +1 -1
  100. package/web-app/dist/assets/{upload-CCJzLwO9.js → upload-DUlZxsH1.js} +1 -1
  101. package/web-app/dist/assets/{usePolling-4aqc_A7F.js → usePolling-PecqVZ0k.js} +1 -1
  102. package/web-app/dist/assets/{user-BzoHXLDe.js → user-BcLYLDVU.js} +1 -1
  103. package/web-app/dist/index.html +1 -1
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Runs an autonomous software factory that knows what it is supposed to deliver and proves it did, with a signed receipt. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
4
4
  ---
5
5
 
6
- # Loki Mode v11.76.0
6
+ # Loki Mode v11.78.0
7
7
 
8
8
  **Current entry points (Loki 10 engine):** `loki "<task>"`, `loki owner/repo#N` and `loki quick "<task>"` run the Loki 10 engine and end with a signed receipt (`loki verify`). `loki backlog owner/repo --all` runs many issues in parallel. Guide: `docs/v10/GUIDE.md`. The skill instructions below, and `loki start`, describe the previous bash loop. PRD-file starts (`loki start ./prd.md`) and the opencode provider still run on it until FC38-SWEEP ports them; the legacy-engine command and the LOKI_ENGINE switch remain removed. Prefer `loki owner/repo#N` for issues.
9
9
 
@@ -475,4 +475,4 @@ Implemented, planned and deprecated items, with the version each landed in: `ski
475
475
 
476
476
  ---
477
477
 
478
- **v11.76.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
478
+ **v11.78.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 11.76.0
1
+ 11.78.0
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Check-author context (moat P3, interface A in tests/moat/p3-the-wall.sh).
3
+
4
+ check_author.py context --spec <file> --repo <dir>
5
+
6
+ Prints ONE JSON object on stdout: the exact and complete input handed to the
7
+ check-authoring model. No model call, no network, deterministic.
8
+
9
+ {"spec_sha256": "<hex>", "inputs": [{"path", "sha256"}...], "prompt": "<text>"}
10
+
11
+ Input selection mirrors the engine10 Wall (loki-ts/src/features/wall_manifest_wire.ts,
12
+ the manifest builder wallManifestFor) and is deliberately narrower: of the files
13
+ the Wall manifest reads, only the CONFIG class (runner and project config, same
14
+ regex as CONFIG there, applied anchored to the whole repo-relative path, so
15
+ only root-level config files qualify) is kept. Files come from git ls-files
16
+ (tracked regular files, no symlinks), never a filesystem walk, so untracked and
17
+ ignored files cannot reach the author. A non-git --repo fails closed: spec only.
18
+ Same MAX_BLOB size cap and control-character path rule as the Wall. The Wall's
19
+ other two classes are implementation-bearing and are excluded here: TESTISH test
20
+ sources (style examples) and "named" source files (signatures of files the task
21
+ names). Anything outside the spec and CONFIG files never reaches the author.
22
+ """
23
+ import argparse
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import re
28
+ import subprocess
29
+ import sys
30
+
31
+ # Mirrors CONFIG, MAX_BLOB, MAX_FILES in wall_manifest_wire.ts.
32
+ CONFIG = re.compile(
33
+ r"^(package\.json|bunfig\.toml|(vitest|jest)\.config\.[cm]?[jt]s|pytest\.ini|"
34
+ r"pyproject\.toml|setup\.cfg|tox\.ini|go\.mod|Cargo\.toml)$"
35
+ )
36
+ MAX_BLOB = 200_000
37
+ MAX_FILES = 300
38
+ # Mirrors TESTISH in wall_manifest_wire.ts.
39
+ TESTISH = re.compile(r"(^|/)(tests?|__tests__|spec)/|\.(test|spec)\.[jt]sx?$|(^|/)test_[^/]*\.py$|_test\.(py|go)$")
40
+ CTRL = re.compile(r"[\x00-\x1f\x7f]")
41
+
42
+ PROMPT_HEAD = (
43
+ "You are the acceptance check author. You see only the specification and the "
44
+ "project's runner configuration below. You cannot see the implementation. "
45
+ "Write acceptance checks that prove the specification is met."
46
+ )
47
+
48
+
49
+ def sha256(data: bytes) -> str:
50
+ return hashlib.sha256(data).hexdigest()
51
+
52
+
53
+ def read_bytes(path: str) -> bytes:
54
+ with open(path, "rb") as fh:
55
+ return fh.read()
56
+
57
+
58
+ def config_files(repo: str, spec_real: str):
59
+ found = []
60
+ for rel in tracked_regular_files(repo) or []:
61
+ # Anchored CONFIG against the whole repo-relative path, as the Wall does
62
+ # (CONFIG.test(e.path)): only root-level config files qualify.
63
+ if not CONFIG.match(rel) or TESTISH.search(rel) or CTRL.search(rel):
64
+ continue
65
+ full = os.path.join(repo, rel)
66
+ if os.path.islink(full) or not os.path.isfile(full) or os.path.realpath(full) == spec_real:
67
+ continue
68
+ try:
69
+ if os.path.getsize(full) > MAX_BLOB:
70
+ continue
71
+ except OSError:
72
+ continue
73
+ found.append((rel, full))
74
+ if len(found) >= MAX_FILES:
75
+ break
76
+ return found
77
+
78
+
79
+ def tracked_regular_files(repo: str):
80
+ """git ls-files regular blobs (modes 100644/100755), like the Wall's ls-tree
81
+ read. None when repo is not a git repo or git fails: the caller fails closed."""
82
+ # Hardening mirrors SAFE_GIT_CONFIG in loki-ts/src/util/safe_git.ts: a repo-planted
83
+ # fsmonitor, hook or ssh command must never run, and a caller GIT_DIR must not
84
+ # redirect the listing.
85
+ env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
86
+ env["GIT_CONFIG_NOSYSTEM"] = "1"
87
+ try:
88
+ r = subprocess.run(["git", "-c", "core.fsmonitor=", "-c", "core.hooksPath=/dev/null",
89
+ "-c", "core.sshCommand=", "-c", "protocol.ext.allow=never",
90
+ "-C", repo, "--no-replace-objects", "ls-files", "-s", "-z"],
91
+ capture_output=True, timeout=10, check=False, env=env)
92
+ except (OSError, subprocess.SubprocessError):
93
+ return None
94
+ if r.returncode != 0:
95
+ return None
96
+ out = []
97
+ for raw in r.stdout.split(b"\0"):
98
+ if not raw:
99
+ continue
100
+ meta, _, path = raw.partition(b"\t")
101
+ if meta.split(b" ")[0] not in (b"100644", b"100755"):
102
+ continue
103
+ try:
104
+ out.append(path.decode("utf-8"))
105
+ except UnicodeDecodeError:
106
+ continue
107
+ return out
108
+
109
+
110
+ def build_context(spec: str, repo: str) -> dict:
111
+ spec_bytes = read_bytes(spec)
112
+ spec_real = os.path.realpath(spec)
113
+ repo_real = os.path.realpath(repo)
114
+ if spec_real.startswith(repo_real + os.sep):
115
+ spec_path = os.path.relpath(spec_real, repo_real).replace(os.sep, "/")
116
+ else:
117
+ spec_path = spec_real
118
+ inputs = [{"path": spec_path, "sha256": sha256(spec_bytes)}]
119
+ parts = [PROMPT_HEAD, "Specification (%s):\n%s" % (spec_path, spec_bytes.decode("utf-8", "replace"))]
120
+ for rel, full in config_files(repo, spec_real):
121
+ data = read_bytes(full)
122
+ inputs.append({"path": rel, "sha256": sha256(data)})
123
+ parts.append("Runner config (%s):\n%s" % (rel, data.decode("utf-8", "replace")))
124
+ return {"spec_sha256": inputs[0]["sha256"], "inputs": inputs, "prompt": "\n\n".join(parts)}
125
+
126
+
127
+ def main(argv=None) -> int:
128
+ ap = argparse.ArgumentParser(prog="check_author.py")
129
+ sub = ap.add_subparsers(dest="cmd", required=True)
130
+ c = sub.add_parser("context", help="print the exact check-author input as JSON")
131
+ c.add_argument("--spec", required=True)
132
+ c.add_argument("--repo", required=True)
133
+ args = ap.parse_args(argv)
134
+ if not os.path.isfile(args.spec):
135
+ print("check_author: spec not found: %s" % args.spec, file=sys.stderr)
136
+ return 2
137
+ if not os.path.isdir(args.repo):
138
+ print("check_author: repo not a directory: %s" % args.repo, file=sys.stderr)
139
+ return 2
140
+ json.dump(build_context(args.spec, args.repo), sys.stdout, sort_keys=True)
141
+ sys.stdout.write("\n")
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
@@ -38,8 +38,12 @@ loki_effort_for_tier() {
38
38
  cheap) effort="low" ;;
39
39
  *) effort="high" ;;
40
40
  esac
41
+ # LOKI_EFFORT is the one cross-provider knob: an explicit level replaces the tier-derived one
42
+ # (no complexity bump) and is still clamped to the model's catalog levels below.
43
+ local explicit=""
44
+ case "${LOKI_EFFORT:-}" in low|medium|high|xhigh|max) effort="$LOKI_EFFORT"; explicit=1 ;; esac
41
45
  # Complex projects get one bump up. "max" is the ceiling and only for explicit user override (not auto-bumped to from xhigh).
42
- if [ "$complexity" = "complex" ]; then
46
+ if [ "$complexity" = "complex" ] && [ -z "$explicit" ]; then
43
47
  case "$effort" in
44
48
  low) effort="medium" ;;
45
49
  medium) effort="high" ;;
@@ -59,6 +59,37 @@ __LOKI_MCP_CONFIG_SH_LOADED=1
59
59
  # stdio MCP server backed by `python3 -m mcp.server`. Caller may extend
60
60
  # this in the future without API breakage; consumers should treat the
61
61
  # bundle as opaque JSON.
62
+ # Remove stale temp files left by a writer killed between temp write and rename.
63
+ # Mirrors sweepStaleTmp in loki-ts/src/util/atomic.ts. A temp is
64
+ # removed only when it is named mcp-config.json.tmp.<pid>, is a regular file
65
+ # (not a symlink) in the same directory, is owned by the current uid, is not
66
+ # ours, and either its pid is dead or it is older than 60 minutes (pid reuse).
67
+ _loki_mcp_sweep_stale_tmp() {
68
+ local dir="$1" f base pid sig alive
69
+ # Iterate the glob, never find output: a filename holding a newline must
70
+ # stay ONE entry, not become a second cwd-relative path.
71
+ for f in "$dir"/mcp-config.json.tmp.*; do
72
+ [ -L "$f" ] && continue
73
+ [ -f "$f" ] && [ -O "$f" ] || continue
74
+ base="${f##*/}"
75
+ pid="${base#mcp-config.json.tmp.}"
76
+ case "$pid" in '' | *[!0-9]*) continue ;; esac
77
+ [ "$pid" = "$$" ] && continue
78
+ alive=0
79
+ if sig="$(LC_ALL=C kill -0 "$pid" 2>&1)"; then
80
+ alive=1
81
+ else
82
+ # EPERM means the process exists under another uid: alive, as in the TS route.
83
+ case "$sig" in *"not permitted"*) alive=1 ;; esac
84
+ fi
85
+ if [ "$alive" = 1 ]; then
86
+ # Live pid: keep unless older than 60 minutes.
87
+ [ -n "$(find "$f" -maxdepth 0 -mmin +60 2>/dev/null)" ] || continue
88
+ fi
89
+ rm -f -- "$f" 2>/dev/null
90
+ done
91
+ }
92
+
62
93
  loki_mcp_config_path() {
63
94
  local base="${TARGET_DIR:-.}"
64
95
  local mcp_dir="${base}/.loki"
@@ -88,7 +119,9 @@ loki_mcp_config_path() {
88
119
  # This makes the helper idempotent: identical bundle bytes -> no write,
89
120
  # mtime stable. python3 is used so JSON encoding is canonical.
90
121
  local tmp_bundle
122
+ _loki_mcp_sweep_stale_tmp "$mcp_dir"
91
123
  tmp_bundle="${mcp_path}.tmp.$$"
124
+ rm -f -- "$tmp_bundle" 2>/dev/null # our pid left over from a reused pid
92
125
  if ! _MCP_OUT="$tmp_bundle" _MCP_LSP="$lsp_detected" python3 -c "
93
126
  import json, os
94
127
  out = os.environ['_MCP_OUT']
@@ -105,13 +138,20 @@ if lsp:
105
138
  'args': ['-m', 'mcp.lsp_proxy'],
106
139
  }
107
140
  bundle = {'mcpServers': servers}
108
- with open(out, 'w') as f:
141
+ # O_EXCL|O_NOFOLLOW, 0600: never write through a planted symlink or file.
142
+ fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0), 0o600)
143
+ with os.fdopen(fd, 'w') as f:
109
144
  json.dump(bundle, f, indent=2)
110
145
  " 2>/dev/null; then
111
146
  rm -f "$tmp_bundle" 2>/dev/null
112
147
  return 1
113
148
  fi
114
149
 
150
+ # Test hook, test-mode only (LOKI_TEST): die between the temp write and the rename.
151
+ if [ -n "${LOKI_TEST:-}" ] && [ "${LOKI_TEST_DIE_AFTER_MCP_TMP:-}" = "1" ]; then
152
+ kill -9 $$
153
+ fi
154
+
115
155
  # Idempotent write: only replace the file when bytes differ. cmp -s
116
156
  # exits 0 when identical, so we keep the existing file in that case.
117
157
  if [ -f "$mcp_path" ] && cmp -s "$tmp_bundle" "$mcp_path" 2>/dev/null; then
@@ -2,7 +2,6 @@
2
2
  """Emit the FIXED analytics allowlist from a finished proof.json as key=value
3
3
  lines, one per line, for loki_emit_build_verified (autonomy/telemetry.sh).
4
4
 
5
- This is the SINGLE authoritative allowlist for opt-in build-outcome analytics.
6
5
  Only already-computed scalars are ever emitted -- never code, spec/PRD text,
7
6
  paths, or file names. Anything not named here is never read, so a proof.json
8
7
  schema change cannot silently start leaking a new field.
@@ -12,16 +11,124 @@ Free-text-shaped fields are excluded on purpose:
12
11
  computed deterministically from facts -- never spec/project text.
13
12
  - files_changed is emitted as a COUNT only, never the paths list.
14
13
 
14
+ The allowlist of keys, types, ranges and enums is NOT defined here. It lives in
15
+ autonomy/lib/telemetry-schema.json, the one schema shared with
16
+ autonomy/telemetry.sh and dashboard/telemetry.py. This file only maps proof.json
17
+ fields to schema keys and applies the schema (`sanitize`).
18
+
15
19
  Usage: proof-analytics-props.py <proof.json>
20
+ proof-analytics-props.py --payload <event> <distinct_id> <api_key> [k=v ...]
21
+ prints the final capture body for the bash sender after schema
22
+ filtering; prints nothing and exits non-zero for an event the schema
23
+ does not list.
16
24
  Fail-open: any error prints nothing and exits non-zero; the caller drops it.
17
25
  """
18
26
  import json
27
+ import math
28
+ import os
29
+ import re
19
30
  import sys
20
31
 
32
+ SCHEMA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "telemetry-schema.json")
33
+ _SEMVER = re.compile(r"^\d{1,6}\.\d{1,6}\.\d{1,6}(-[0-9A-Za-z.]{1,24})?$")
34
+ _INT = re.compile(r"^-?\d{1,12}$")
35
+
36
+
37
+ def load_schema(path=None):
38
+ with open(path or SCHEMA_PATH) as f:
39
+ return json.load(f)
40
+
41
+
42
+ def _coerce(spec, v):
43
+ """Return (ok, value). A value that fails its spec is dropped, never forwarded."""
44
+ t = spec.get("type")
45
+ if t == "bool":
46
+ if isinstance(v, bool):
47
+ return True, v
48
+ if isinstance(v, str) and v in ("true", "false"):
49
+ return True, v == "true"
50
+ return False, None
51
+ if t == "enum":
52
+ if not isinstance(v, str):
53
+ return False, None
54
+ if spec.get("fold") == "upper":
55
+ v = v.upper()
56
+ return (v in spec["values"]), v
57
+ if t == "int":
58
+ if isinstance(v, bool):
59
+ return False, None
60
+ if isinstance(v, int):
61
+ n = v
62
+ elif isinstance(v, str) and _INT.match(v):
63
+ n = int(v)
64
+ else:
65
+ return False, None
66
+ return (spec["min"] <= n <= spec["max"]), n
67
+ if t == "number":
68
+ if isinstance(v, bool):
69
+ return False, None
70
+ if isinstance(v, str) and len(v) > 24:
71
+ return False, None
72
+ try:
73
+ n = float(v)
74
+ except (TypeError, ValueError):
75
+ return False, None
76
+ if not math.isfinite(n):
77
+ return False, None
78
+ return (spec["min"] <= n <= spec["max"]), n
79
+ if t == "semver":
80
+ return (isinstance(v, str) and bool(_SEMVER.match(v))), v
81
+ return False, None
82
+
83
+
84
+ def sanitize(schema, event, props):
85
+ """Return the filtered property dict, or None when the event is not listed."""
86
+ if not isinstance(event, str) or event not in schema["events"]:
87
+ return None
88
+ specs = dict(schema["common"])
89
+ specs.update(schema["events"][event])
90
+ out = {}
91
+ for k, v in (props or {}).items():
92
+ spec = specs.get(k) if isinstance(k, str) else None
93
+ if spec is None:
94
+ continue
95
+ ok, val = _coerce(spec, v)
96
+ if ok:
97
+ out[k] = val
98
+ # Every event carries the flag; events with no command source are False.
99
+ src = schema.get("introspective_source", {}).get(event)
100
+ out["introspective"] = bool(src) and out.get(src) in schema["introspective_commands"]
101
+ return out
102
+
103
+
104
+ def payload_main(argv):
105
+ """argv: event distinct_id api_key [k=v ...]. Later pairs win, so the trusted
106
+ base properties the shell appends last cannot be overridden by a caller."""
107
+ if len(argv) < 3:
108
+ return 1
109
+ event, distinct_id, api_key = argv[0], argv[1], argv[2]
110
+ raw = {}
111
+ for arg in argv[3:]:
112
+ if "=" in arg:
113
+ k, v = arg.split("=", 1)
114
+ raw[k] = v
115
+ props = sanitize(load_schema(), event, raw)
116
+ if props is None:
117
+ return 1
118
+ sys.stdout.write(json.dumps({
119
+ "api_key": api_key,
120
+ "event": event,
121
+ "distinct_id": distinct_id,
122
+ "properties": props,
123
+ }))
124
+ return 0
125
+
21
126
 
22
127
  def main():
23
128
  if len(sys.argv) < 2:
24
129
  return 1
130
+ if sys.argv[1] == "--payload":
131
+ return payload_main(sys.argv[2:])
25
132
  try:
26
133
  p = json.load(open(sys.argv[1]))
27
134
  except Exception:
@@ -55,15 +162,17 @@ def main():
55
162
  "gates_total": gates_total,
56
163
  "gate_pass_rate": rate,
57
164
  }
165
+ out = {k: v for k, v in out.items() if v is not None and v != ""}
166
+ out = sanitize(load_schema(), "build_verified", out) or {}
58
167
 
59
168
  for k, v in out.items():
60
- if v is None or v == "":
61
- continue
62
- # key=value; sanitize so a value can never inject a second pair or a newline.
63
- v = str(v).replace("\n", " ").replace("=", "-")
169
+ # key=value; the schema already rejected anything with newline or "=".
64
170
  sys.stdout.write("%s=%s\n" % (k, v))
65
171
  return 0
66
172
 
67
173
 
68
174
  if __name__ == "__main__":
69
- sys.exit(main())
175
+ try:
176
+ sys.exit(main())
177
+ except Exception:
178
+ sys.exit(1)
@@ -578,6 +578,24 @@ class ProofLoadError(Exception):
578
578
  """Raised for a missing file / malformed JSON / unusable proof shape."""
579
579
 
580
580
 
581
+ MAX_PROOF_DEPTH = 256 # mirrors MAX_RECEIPT_DEPTH in loki-ts verify_cmd.ts; real proofs are under 20 deep
582
+
583
+
584
+ def _too_deep(data):
585
+ """Iterative nesting check, so every interpreter version gives the same answer."""
586
+ stack = [(data, 1)]
587
+ while stack:
588
+ node, depth = stack.pop()
589
+ if not isinstance(node, (dict, list)):
590
+ continue
591
+ if depth > MAX_PROOF_DEPTH:
592
+ return True
593
+ for child in (node.values() if isinstance(node, dict) else node):
594
+ if isinstance(child, (dict, list)):
595
+ stack.append((child, depth + 1))
596
+ return False
597
+
598
+
581
599
  def _load_proof(proof_path):
582
600
  if not os.path.isfile(proof_path):
583
601
  raise ProofLoadError("proof file not found: %s" % proof_path)
@@ -586,10 +604,14 @@ def _load_proof(proof_path):
586
604
  data = json.load(f)
587
605
  except json.JSONDecodeError as exc:
588
606
  raise ProofLoadError("malformed JSON in %s: %s" % (proof_path, exc))
607
+ except RecursionError:
608
+ raise ProofLoadError("proof nested too deeply: %s" % proof_path)
589
609
  except OSError as exc:
590
610
  raise ProofLoadError("could not read %s: %s" % (proof_path, exc))
591
611
  if not isinstance(data, dict):
592
612
  raise ProofLoadError("proof root is not a JSON object: %s" % proof_path)
613
+ if _too_deep(data):
614
+ raise ProofLoadError("proof nested too deeply: %s" % proof_path)
593
615
  return data
594
616
 
595
617
 
@@ -0,0 +1,52 @@
1
+ {
2
+ "_comment": "Single allowlist for every telemetry sender (autonomy/telemetry.sh, dashboard/telemetry.py, autonomy/lib/proof-analytics-props.py). An event not listed here is never sent. A key not listed is dropped. A value outside its type, range or enum is dropped. Documented in docs/PRIVACY.md.",
3
+ "schema_version": 1,
4
+ "common": {
5
+ "os_family": {"type": "enum", "values": ["linux", "darwin", "windows", "other"]},
6
+ "arch": {"type": "enum", "values": ["x64", "arm64", "other"]},
7
+ "version": {"type": "semver"},
8
+ "channel": {"type": "enum", "values": ["npm", "docker", "homebrew", "skill", "source", "unknown"]},
9
+ "traffic": {"type": "enum", "values": ["user", "ci", "internal"]},
10
+ "introspective": {"type": "bool"}
11
+ },
12
+ "introspective_commands": ["--help", "--version", "-h", "-v", "completion", "completions", "help", "internal", "memory", "version"],
13
+ "introspective_source": {"cli_command": "command", "installed": "first_command"},
14
+ "events": {
15
+ "installed": {
16
+ "first_command": {"type": "enum", "values": ["--help", "--version", "-h", "-v", "agent", "analyze", "api", "assets", "audit", "bench", "checkpoint", "ci", "cleanup", "cluster", "cockpit", "code", "completion", "completions", "compliance", "compound", "config", "context", "control", "cost", "council", "cp", "crash", "ctx", "dashboard", "demo", "deploy", "docker", "docs", "doctor", "dogfood", "enterprise", "explain", "export", "factory", "failover", "github", "grill", "handoff", "heal", "help", "import", "init", "internal", "issue", "kpis", "logs", "magic", "mcp", "memory", "metrics", "migrate", "modernize", "monitor", "next", "notify", "onboard", "open", "optimize", "otel", "own", "pause", "plan", "preview", "projects", "proof", "provider", "quick", "quickstart", "rc", "receipt", "remote", "report", "reset", "resume", "review", "rollback", "run", "sandbox", "secrets", "secure", "self_update", "self-update", "sentrux", "serve", "setup-skill", "share", "ship", "spec", "start", "state", "stats", "status", "steer", "stop", "syslog", "telemetry", "template", "test", "tour", "trigger", "trust", "trust-metrics", "ultracode", "update", "verify", "version", "voice", "watch", "watchdog", "web", "welcome", "why", "wiki", "worktree", "wow", "wt", "other"]}
17
+ },
18
+ "cli_command": {
19
+ "command": {"type": "enum", "values": ["--help", "--version", "-h", "-v", "agent", "analyze", "api", "assets", "audit", "bench", "checkpoint", "ci", "cleanup", "cluster", "cockpit", "code", "completion", "completions", "compliance", "compound", "config", "context", "control", "cost", "council", "cp", "crash", "ctx", "dashboard", "demo", "deploy", "docker", "docs", "doctor", "dogfood", "enterprise", "explain", "export", "factory", "failover", "github", "grill", "handoff", "heal", "help", "import", "init", "internal", "issue", "kpis", "logs", "magic", "mcp", "memory", "metrics", "migrate", "modernize", "monitor", "next", "notify", "onboard", "open", "optimize", "otel", "own", "pause", "plan", "preview", "projects", "proof", "provider", "quick", "quickstart", "rc", "receipt", "remote", "report", "reset", "resume", "review", "rollback", "run", "sandbox", "secrets", "secure", "self_update", "self-update", "sentrux", "serve", "setup-skill", "share", "ship", "spec", "start", "state", "stats", "status", "steer", "stop", "syslog", "telemetry", "template", "test", "tour", "trigger", "trust", "trust-metrics", "ultracode", "update", "verify", "version", "voice", "watch", "watchdog", "web", "welcome", "why", "wiki", "worktree", "wow", "wt", "other"]}
20
+ },
21
+ "session_start": {
22
+ "provider": {"type": "enum", "values": ["claude", "codex", "cline", "aider", "opencode"]},
23
+ "complexity": {"type": "enum", "values": ["simple", "standard", "complex"]},
24
+ "parallel": {"type": "bool"}
25
+ },
26
+ "session_end": {
27
+ "provider": {"type": "enum", "values": ["claude", "codex", "cline", "aider", "opencode"]},
28
+ "duration": {"type": "int", "min": 0, "max": 31536000},
29
+ "iterations": {"type": "int", "min": 0, "max": 100000},
30
+ "result": {"type": "int", "min": 0, "max": 255}
31
+ },
32
+ "dashboard_start": {},
33
+ "first_start_attempted": {
34
+ "entry": {"type": "enum", "values": ["--help", "--version", "-h", "-v", "agent", "analyze", "api", "assets", "audit", "bench", "checkpoint", "ci", "cleanup", "cluster", "cockpit", "code", "completion", "completions", "compliance", "compound", "config", "context", "control", "cost", "council", "cp", "crash", "ctx", "dashboard", "demo", "deploy", "docker", "docs", "doctor", "dogfood", "enterprise", "explain", "export", "factory", "failover", "github", "grill", "handoff", "heal", "help", "import", "init", "internal", "issue", "kpis", "logs", "magic", "mcp", "memory", "metrics", "migrate", "modernize", "monitor", "next", "notify", "onboard", "open", "optimize", "otel", "own", "pause", "plan", "preview", "projects", "proof", "provider", "quick", "quickstart", "rc", "receipt", "remote", "report", "reset", "resume", "review", "rollback", "run", "sandbox", "secrets", "secure", "self_update", "self-update", "sentrux", "serve", "setup-skill", "share", "ship", "spec", "start", "state", "stats", "status", "steer", "stop", "syslog", "telemetry", "template", "test", "tour", "trigger", "trust", "trust-metrics", "ultracode", "update", "verify", "version", "voice", "watch", "watchdog", "web", "welcome", "why", "wiki", "worktree", "wow", "wt", "other"]}
35
+ },
36
+ "first_run_blocked": {
37
+ "blocker": {"type": "enum", "values": ["no_provider", "not_logged_in", "not_a_repo", "git_identity", "node", "python3", "jq", "git", "curl", "disk", "skill_symlink", "other"]}
38
+ },
39
+ "build_verified": {
40
+ "headline": {"type": "enum", "values": ["VERIFIED", "VERIFIED WITH GAPS", "NOT VERIFIED"]},
41
+ "route": {"type": "enum", "values": ["engine10"]},
42
+ "evidence_gate_verdict": {"type": "enum", "fold": "upper", "values": ["PASS", "BLOCK"]},
43
+ "final_verdict": {"type": "enum", "fold": "upper", "values": ["APPROVED", "REJECTED", "APPROVE", "REJECT", "COMPLETE", "CONTINUE"]},
44
+ "iterations": {"type": "int", "min": 0, "max": 100000},
45
+ "files_changed_count": {"type": "int", "min": 0, "max": 10000000},
46
+ "wall_clock_sec": {"type": "number", "min": 0, "max": 31536000},
47
+ "gates_passed": {"type": "int", "min": 0, "max": 1000},
48
+ "gates_total": {"type": "int", "min": 0, "max": 1000},
49
+ "gate_pass_rate": {"type": "number", "min": 0, "max": 1}
50
+ }
51
+ }
52
+ }