@js-eyes/protocol 2.6.1 → 2.7.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.
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ // extra-integrity: optional snapshot-and-verify layer for extraSkillDirs.
4
+ //
5
+ // Gated by `security.verifyExtraSkillDirs` (default false for 2.6.1
6
+ // compatibility). When enabled:
7
+ // * `js-eyes skills link <path>` calls `snapshotExtraDir(path)` to record a
8
+ // per-file sha256 map under ~/.js-eyes/state/extras/<hash>.json (the state
9
+ // file lives outside the external dir so js-eyes never writes to it);
10
+ // * the plugin's SkillRegistry calls `verifyExtraDir(path)` before loading
11
+ // each extra; on drift the load is refused and the operator is told to
12
+ // run `js-eyes skills relink <path>` after reviewing the changes.
13
+ //
14
+ // ClawHub / OpenClaw flagged extraSkillDirs as "read-only but bypass integrity
15
+ // verification"; this module closes that gap without breaking the default
16
+ // behaviour. See SECURITY_SCAN_NOTES.md.
17
+
18
+ const crypto = require('crypto');
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+
23
+ const { ensureDir } = require('./fs-io');
24
+
25
+ const STATE_DIR_NAME = 'state';
26
+ const EXTRAS_DIR_NAME = 'extras';
27
+ const SNAPSHOT_VERSION = 1;
28
+
29
+ function sha1(text) {
30
+ return crypto.createHash('sha1').update(text).digest('hex');
31
+ }
32
+
33
+ function sha256File(filePath) {
34
+ const hash = crypto.createHash('sha256');
35
+ hash.update(fs.readFileSync(filePath));
36
+ return hash.digest('hex');
37
+ }
38
+
39
+ function resolveBaseDir(options = {}) {
40
+ if (options.baseDir) return path.resolve(options.baseDir);
41
+ if (process.env.JS_EYES_HOME) return path.resolve(process.env.JS_EYES_HOME);
42
+ return path.join(options.home || os.homedir(), '.js-eyes');
43
+ }
44
+
45
+ function getSnapshotPath(absPath, options = {}) {
46
+ if (!absPath || typeof absPath !== 'string') {
47
+ throw new Error('getSnapshotPath: absPath required');
48
+ }
49
+ const baseDir = resolveBaseDir(options);
50
+ const key = sha1(path.resolve(absPath));
51
+ return path.join(baseDir, STATE_DIR_NAME, EXTRAS_DIR_NAME, `${key}.json`);
52
+ }
53
+
54
+ function listFilesRecursive(dir) {
55
+ const out = [];
56
+ function walk(current) {
57
+ let entries;
58
+ try {
59
+ entries = fs.readdirSync(current, { withFileTypes: true });
60
+ } catch (_) {
61
+ return;
62
+ }
63
+ for (const entry of entries) {
64
+ const full = path.join(current, entry.name);
65
+ const rel = path.relative(dir, full);
66
+ if (rel.split(path.sep)[0] === 'node_modules') continue;
67
+ if (entry.isDirectory()) {
68
+ walk(full);
69
+ } else if (entry.isFile()) {
70
+ out.push(rel.split(path.sep).join('/'));
71
+ }
72
+ }
73
+ }
74
+ walk(dir);
75
+ return out.sort();
76
+ }
77
+
78
+ function buildFileMap(absPath) {
79
+ const files = {};
80
+ for (const rel of listFilesRecursive(absPath)) {
81
+ const full = path.join(absPath, rel);
82
+ try {
83
+ files[rel] = sha256File(full);
84
+ } catch (_) {
85
+ // Skip unreadable files; they'll appear as missing in verify.
86
+ }
87
+ }
88
+ return files;
89
+ }
90
+
91
+ function writeSnapshot(absPath, snapshot, options = {}) {
92
+ const target = getSnapshotPath(absPath, options);
93
+ ensureDir(path.dirname(target));
94
+ fs.writeFileSync(target, JSON.stringify(snapshot, null, 2) + '\n', 'utf8');
95
+ try { fs.chmodSync(target, 0o600); } catch (_) { /* best-effort on POSIX */ }
96
+ return target;
97
+ }
98
+
99
+ function readSnapshot(absPath, options = {}) {
100
+ const target = getSnapshotPath(absPath, options);
101
+ if (!fs.existsSync(target)) return null;
102
+ try {
103
+ return JSON.parse(fs.readFileSync(target, 'utf8'));
104
+ } catch (_) {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function snapshotExtraDir(absPath, options = {}) {
110
+ if (!fs.existsSync(absPath)) {
111
+ throw new Error(`snapshotExtraDir: path does not exist: ${absPath}`);
112
+ }
113
+ const snapshot = {
114
+ version: SNAPSHOT_VERSION,
115
+ path: path.resolve(absPath),
116
+ createdAt: new Date().toISOString(),
117
+ files: buildFileMap(absPath),
118
+ };
119
+ const snapshotPath = writeSnapshot(absPath, snapshot, options);
120
+ return { snapshot, snapshotPath };
121
+ }
122
+
123
+ function verifyExtraDir(absPath, options = {}) {
124
+ const snapshot = readSnapshot(absPath, options);
125
+ if (!snapshot || !snapshot.files) {
126
+ return {
127
+ ok: false,
128
+ hasSnapshot: false,
129
+ drifted: [],
130
+ missing: [],
131
+ extra: [],
132
+ checked: 0,
133
+ };
134
+ }
135
+
136
+ const expected = snapshot.files;
137
+ const expectedKeys = Object.keys(expected);
138
+ const actual = buildFileMap(absPath);
139
+
140
+ const drifted = [];
141
+ const missing = [];
142
+ for (const rel of expectedKeys) {
143
+ if (!Object.prototype.hasOwnProperty.call(actual, rel)) {
144
+ missing.push(rel);
145
+ continue;
146
+ }
147
+ if (actual[rel] !== expected[rel]) {
148
+ drifted.push(rel);
149
+ }
150
+ }
151
+ const extra = Object.keys(actual).filter(
152
+ (rel) => !Object.prototype.hasOwnProperty.call(expected, rel),
153
+ );
154
+
155
+ return {
156
+ ok: drifted.length === 0 && missing.length === 0 && extra.length === 0,
157
+ hasSnapshot: true,
158
+ drifted,
159
+ missing,
160
+ extra,
161
+ checked: expectedKeys.length,
162
+ snapshotCreatedAt: snapshot.createdAt || null,
163
+ };
164
+ }
165
+
166
+ function clearSnapshotForExtraDir(absPath, options = {}) {
167
+ const target = getSnapshotPath(absPath, options);
168
+ if (fs.existsSync(target)) {
169
+ try { fs.rmSync(target, { force: true }); } catch (_) { /* best-effort */ }
170
+ return true;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ // Returns one of: 'verified' | 'drifted' | 'missing-snapshot' | 'off' | 'error'.
176
+ // `off` means the global toggle is disabled.
177
+ function classifyExtraDir(absPath, { enabled, options = {} } = {}) {
178
+ if (!enabled) return { state: 'off' };
179
+ let result;
180
+ try {
181
+ result = verifyExtraDir(absPath, options);
182
+ } catch (error) {
183
+ return { state: 'error', error: error.message };
184
+ }
185
+ if (!result.hasSnapshot) return { state: 'missing-snapshot', detail: result };
186
+ if (result.ok) return { state: 'verified', detail: result };
187
+ return { state: 'drifted', detail: result };
188
+ }
189
+
190
+ module.exports = {
191
+ SNAPSHOT_VERSION,
192
+ getSnapshotPath,
193
+ snapshotExtraDir,
194
+ verifyExtraDir,
195
+ clearSnapshotForExtraDir,
196
+ classifyExtraDir,
197
+ // Exposed for tests only.
198
+ _internals: { buildFileMap, listFilesRecursive, resolveBaseDir },
199
+ };
package/fs-io.js ADDED
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // fs-io: pure filesystem helpers.
4
+ //
5
+ // Scoped deliberately: the functions here do local-only disk I/O and JSON
6
+ // parsing. They never touch the network, and this module MUST NOT import
7
+ // `ws`, `http`, `https`, `net`, or any network helper. The invariant is
8
+ // verified by test/import-boundaries.test.js.
9
+ //
10
+ // See SECURITY_SCAN_NOTES.md ("File read combined with network send") for
11
+ // the reason this module is kept separate from skills.js.
12
+
13
+ const fs = require('fs');
14
+
15
+ function ensureDir(dir) {
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ return dir;
18
+ }
19
+
20
+ function readJson(filePath) {
21
+ if (!fs.existsSync(filePath)) return null;
22
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
23
+ }
24
+
25
+ function safeStat(target) {
26
+ try {
27
+ return fs.statSync(target);
28
+ } catch (_) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ module.exports = {
34
+ ensureDir,
35
+ readJson,
36
+ safeStat,
37
+ };