@optimuslabs/harness-map-staging 1.5.19 → 1.5.21

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.
@@ -5,6 +5,7 @@ const HOME_DIR = homedir();
5
5
  import { getFileCollectionPatterns, FILE_PATH_REGISTRY_FILE_PATTERNS_PATH } from '../../endpoint_client/index.js';
6
6
  import { OPTIMUSLABS_LOGS_REL, LOG_GROUP_AUDIT } from '../../bootstrap_constants.js';
7
7
  import { runSensitivePathsAudit } from '../../log_sensitive_paths_audit.js';
8
+ import { buildPathPermissionsAuditEntry, grantPathsFromConfig } from '../../log_path_permissions_audit.js';
8
9
  import { loadEndpointBase, getEndpointSource } from '../sender/endpoint_config.js';
9
10
  import { hookLogReplace, hookRunLog, hookLogError, readHookLog } from './hook_logger.js';
10
11
  import { getStoryId, flushBufferedClientEvents, reportFunctionalRun, reportFunctionalFailureAwait, } from './client_event_reporter.js';
@@ -227,6 +228,31 @@ async function addSensitivePathsAudit(endpointBase, configFiles) {
227
228
  configFiles.push({ file_type: 'sensitive_paths_audit', file_path: 'sensitive_paths_audit.txt', raw_content: { paths: [] } });
228
229
  }
229
230
  }
231
+ /** POSIX mode of every collected file, plus granted folders (and their ancestors) and sensitive audit hits within a budget. stat() only. */
232
+ function addPathPermissionsAudit(configFiles) {
233
+ const visited = [];
234
+ const grants = [];
235
+ const sensitive = [];
236
+ for (const c of configFiles) {
237
+ if (c.file_type === 'sensitive_paths_audit') {
238
+ const auditPaths = c.raw_content.paths;
239
+ if (Array.isArray(auditPaths))
240
+ sensitive.push(...auditPaths.filter((p) => typeof p === 'string'));
241
+ continue;
242
+ }
243
+ visited.push(c.file_path);
244
+ grants.push(...grantPathsFromConfig(c.raw_content));
245
+ }
246
+ try {
247
+ const entry = buildPathPermissionsAuditEntry(visited, grants, sensitive);
248
+ hookRunLog(`path_permissions_audit: ${entry.raw_content.entries.length} path(s) grants=${grants.length} truncated=${entry.raw_content.truncated}`);
249
+ configFiles.push(entry);
250
+ }
251
+ catch (err) {
252
+ hookRunLog(`path_permissions_audit_error: ${err instanceof Error ? err.message : String(err)}`);
253
+ hookLogError('path_permissions_audit', err, {});
254
+ }
255
+ }
230
256
  async function sendAllConfigFiles(configFiles, worktreeReport, hardwareUuid, authKey) {
231
257
  const hookType = resolveHookTypeFromEnv();
232
258
  const manifest = configFiles.map((c) => canonicalCursorUserStateVscdbPath(c.file_path));
@@ -309,6 +335,7 @@ async function main() {
309
335
  }
310
336
  await addSensitivePathsAudit(endpointBase, configFiles);
311
337
  ensureCursorUserSettingsSnapshotInBatch(configFiles);
338
+ addPathPermissionsAudit(configFiles);
312
339
  const fileTypes = [...new Set(configFiles.map((c) => c.file_type))].join(', ');
313
340
  hookRunLog(`collected ${configFiles.length} config file(s) file_types=${fileTypes}`);
314
341
  for (const c of configFiles) {
@@ -264,16 +264,46 @@ async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000)
264
264
  // Enforce / remove
265
265
  // ---------------------------------------------------------------------------
266
266
  function setByPath(obj, path, value) {
267
- const parts = path.split('.');
267
+ const parts = path.split('.').filter((part) => part.length > 0);
268
+ if (parts.length === 0)
269
+ return;
268
270
  let current = obj;
269
271
  for (let i = 0; i < parts.length - 1; i++) {
270
272
  const part = parts[i];
271
- if (current[part] == null || typeof current[part] !== 'object' || Array.isArray(current[part])) {
272
- current[part] = {};
273
+ if (Array.isArray(current)) {
274
+ if (!/^\d+$/.test(part))
275
+ return;
276
+ const idx = Number(part);
277
+ if (idx >= current.length)
278
+ return;
279
+ current = current[idx];
280
+ continue;
273
281
  }
274
- current = current[part];
282
+ if (current == null || typeof current !== 'object')
283
+ return;
284
+ const rec = current;
285
+ const next = rec[part];
286
+ if (Array.isArray(next)) {
287
+ current = next;
288
+ continue;
289
+ }
290
+ if (next == null || typeof next !== 'object') {
291
+ rec[part] = {};
292
+ }
293
+ current = rec[part];
294
+ }
295
+ const leaf = parts[parts.length - 1];
296
+ if (Array.isArray(current)) {
297
+ if (!/^\d+$/.test(leaf))
298
+ return;
299
+ const idx = Number(leaf);
300
+ if (idx >= 0 && idx < current.length)
301
+ current[idx] = value;
302
+ return;
303
+ }
304
+ if (current != null && typeof current === 'object') {
305
+ current[leaf] = value;
275
306
  }
276
- current[parts[parts.length - 1]] = value;
277
307
  }
278
308
  /** Traverse a JSON object using dot-notation path. Returns undefined if any segment is missing. */
279
309
  function getByPath(obj, path) {
@@ -57,6 +57,9 @@ const KNOWN_SECRET_PATTERNS = [
57
57
  { re: /dop_v1_[a-f0-9]{64}/i, label: 'DigitalOcean personal access token' },
58
58
  { re: /pat_[a-zA-Z0-9]{22}_[a-fA-F0-9]{59}/i, label: 'Atlassian API token' },
59
59
  { re: /Bearer\s+[a-zA-Z0-9\-_.]{20,}/i, label: 'Bearer token' },
60
+ // Isolated 32-hex (Azure OpenAI). Lookarounds keep a longer hex string from matching.
61
+ // Same pattern as HardcodedSecretRule — MCP tokens in args (ISO-005) are this shape.
62
+ { re: /(?<![a-zA-Z0-9])[a-fA-F0-9]{32}(?![a-zA-Z0-9])/, label: 'Azure OpenAI key' },
60
63
  ];
61
64
  const BASIC_AUTH_IN_URL = /[a-zA-Z0-9._%+-]+:[a-zA-Z0-9._%+-]+@/gi;
62
65
  // Hyphens/underscores count as part of one token so hyphenated keys (ctx7sk-..., sk-ant-...)
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Path permissions audit: POSIX mode of home, `/` and every file the collector uploads,
3
+ * plus, within MAX_EXTRA_ENTRIES and in this priority order:
4
+ *
5
+ * 1. each folder an agent config grants (additionalDirectories, Read()/Edit() allow rules,
6
+ * trustedFolders, external_directory) and its ancestors (an ancestor the user can't
7
+ * enter makes everything under it unreachable). Never the folder's contents, so a
8
+ * grant of ~ or / costs a handful of stats, not a walk of the disk.
9
+ * 2. sensitive paths audit hits
10
+ * 3. the direct children of each sensitive directory (e.g. ~/.ssh/id_rsa), capped per dir
11
+ *
12
+ * stat()/readdir() only — never reads file contents. Sent in the same batch as the config
13
+ * files as file_type 'path_permissions_audit'.
14
+ */
15
+ import { readdirSync, statSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, isAbsolute, join } from 'node:path';
18
+ export const PATH_PERMISSIONS_AUDIT_FILENAME = 'path_permissions_audit.txt';
19
+ export const PATH_PERMISSIONS_AUDIT_FILE_TYPE = 'path_permissions_audit';
20
+ /** Upper bound on entries beyond the collected files, whatever is granted. */
21
+ export const MAX_EXTRA_ENTRIES = 500;
22
+ /** Owner permission triad from st_mode (e.g. 0o640 → "rw-"). */
23
+ export function ownerTriad(mode) {
24
+ return `${mode & 0o400 ? 'r' : '-'}${mode & 0o200 ? 'w' : '-'}${mode & 0o100 ? 'x' : '-'}`;
25
+ }
26
+ /** Strip virtual suffixes some collectors append (e.g. state.vscdb#composerState). */
27
+ function diskPath(filePath) {
28
+ const hash = filePath.indexOf('#');
29
+ return hash >= 0 ? filePath.slice(0, hash) : filePath;
30
+ }
31
+ /**
32
+ * stat each distinct absolute path, stopping once ``limit`` entries are collected; paths
33
+ * that no longer exist or can't be stat'd are skipped. ``truncated`` when paths were left.
34
+ */
35
+ export function collectPathPermissions(paths, limit = Infinity) {
36
+ const seen = new Set();
37
+ const entries = [];
38
+ for (const raw of paths) {
39
+ const path = diskPath(raw);
40
+ if (!path || !isAbsolute(path) || seen.has(path))
41
+ continue;
42
+ if (entries.length >= limit)
43
+ return { entries, truncated: true };
44
+ seen.add(path);
45
+ try {
46
+ const st = statSync(path);
47
+ const perm = st.mode & 0o7777;
48
+ entries.push({
49
+ path,
50
+ is_dir: st.isDirectory(),
51
+ mode: perm.toString(8).padStart(4, '0'),
52
+ owner: ownerTriad(perm),
53
+ uid: st.uid,
54
+ gid: st.gid,
55
+ });
56
+ }
57
+ catch {
58
+ // Gone or unreadable since collection; nothing to report.
59
+ }
60
+ }
61
+ return { entries, truncated: false };
62
+ }
63
+ /** Cap per sensitive directory so a large ~/.config can't bloat the upload. */
64
+ export const MAX_CHILDREN_PER_SENSITIVE_DIR = 50;
65
+ /** Direct children of a directory (names only, sorted, capped); [] when not a readable dir. */
66
+ export function sensitiveDirChildren(dir) {
67
+ try {
68
+ return readdirSync(dir)
69
+ .sort()
70
+ .slice(0, MAX_CHILDREN_PER_SENSITIVE_DIR)
71
+ .map((name) => join(dir, name));
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ }
77
+ function stringList(value) {
78
+ if (typeof value === 'string')
79
+ return [value];
80
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
81
+ }
82
+ const FILE_RULE = /^\s*(Read|Edit|Write|MultiEdit|NotebookEdit)\((.+)\)\s*$/;
83
+ /**
84
+ * Folders a config grants, as written: Claude permissions.additionalDirectories and
85
+ * Read()/Edit() allow rules, Copilot trustedFolders, OpenCode permission.external_directory.
86
+ */
87
+ export function grantPathsFromConfig(raw) {
88
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
89
+ return [];
90
+ const config = raw;
91
+ const grants = [];
92
+ const permissions = config.permissions;
93
+ if (permissions && typeof permissions === 'object') {
94
+ const p = permissions;
95
+ grants.push(...stringList(p.additionalDirectories));
96
+ for (const rule of stringList(p.allow)) {
97
+ const match = FILE_RULE.exec(rule);
98
+ if (match)
99
+ grants.push(match[2].trim());
100
+ }
101
+ }
102
+ grants.push(...stringList(config.trustedFolders));
103
+ const permission = config.permission;
104
+ if (permission && typeof permission === 'object') {
105
+ const external = permission.external_directory;
106
+ if (external && typeof external === 'object' && !Array.isArray(external)) {
107
+ for (const [pattern, action] of Object.entries(external)) {
108
+ if (action !== 'deny')
109
+ grants.push(pattern);
110
+ }
111
+ }
112
+ else {
113
+ grants.push(...stringList(external));
114
+ }
115
+ }
116
+ return grants;
117
+ }
118
+ /**
119
+ * A grant as an absolute folder on this machine: ``~/x`` under home, ``//x`` → ``/x``, the
120
+ * part before any glob (``~/.kube/**`` → ``~/.kube``). Null for relative grants.
121
+ */
122
+ export function grantFolder(grant, home) {
123
+ let path = grant.trim();
124
+ if (path.startsWith('//'))
125
+ path = path.slice(1);
126
+ if (path === '~' || path.startsWith('~/'))
127
+ path = home + path.slice(1);
128
+ if (!isAbsolute(path))
129
+ return null;
130
+ const segments = path.split('/');
131
+ const glob = segments.findIndex((s) => /[*?[]/.test(s));
132
+ const folder = (glob >= 0 ? segments.slice(0, glob) : segments).join('/').replace(/\/+$/, '');
133
+ return folder || '/';
134
+ }
135
+ /** The folder and each ancestor up to ``/``. */
136
+ function withAncestors(folder) {
137
+ const chain = [folder];
138
+ for (let p = folder; p !== '/';) {
139
+ p = dirname(p);
140
+ chain.push(p);
141
+ }
142
+ return chain;
143
+ }
144
+ function* extraCandidates(grants, sensitivePaths, home) {
145
+ for (const grant of grants) {
146
+ const folder = grantFolder(grant, home);
147
+ if (folder)
148
+ yield* withAncestors(folder);
149
+ }
150
+ yield* sensitivePaths;
151
+ // Only listed while the budget lasts: generated lazily, so no readdir past the cap.
152
+ for (const dir of sensitivePaths)
153
+ yield* sensitiveDirChildren(dir);
154
+ }
155
+ export function buildPathPermissionsAuditEntry(visitedPaths, grants = [], sensitivePaths = [], home = homedir()) {
156
+ const collected = collectPathPermissions([home, '/', ...visitedPaths]).entries;
157
+ const listed = new Set(collected.map((e) => e.path));
158
+ const extra = collectPathPermissions((function* () {
159
+ for (const p of extraCandidates(grants, sensitivePaths, home)) {
160
+ if (!listed.has(diskPath(p)))
161
+ yield p;
162
+ }
163
+ })(), MAX_EXTRA_ENTRIES);
164
+ return {
165
+ file_type: PATH_PERMISSIONS_AUDIT_FILE_TYPE,
166
+ file_path: PATH_PERMISSIONS_AUDIT_FILENAME,
167
+ raw_content: { home, entries: [...collected, ...extra.entries], truncated: extra.truncated },
168
+ };
169
+ }
@@ -178,6 +178,11 @@ const readMacAccountGeneratedUid = () => {
178
178
  }
179
179
  return '';
180
180
  };
181
+ /** Always from the running process — env/file profile overrides predate these fields. */
182
+ const readProcessIdentity = () => ({
183
+ mac_uid: typeof process.getuid === 'function' ? process.getuid() : null,
184
+ mac_gids: typeof process.getgroups === 'function' ? process.getgroups() : [],
185
+ });
181
186
  const buildDefaultUserProfile = () => ({
182
187
  generateduuid: readMacAccountGeneratedUid() || crypto.randomUUID(),
183
188
  machineuuid: resolveHardwareUuid(),
@@ -188,5 +193,8 @@ const buildDefaultUserProfile = () => ({
188
193
  cursor_email: readCursorCachedEmail(),
189
194
  });
190
195
  /** Best-effort local user profile object for startup metadata. */
191
- const resolveUserProfile = () => readUserProfileFromEnvOrFile() ?? buildDefaultUserProfile();
196
+ const resolveUserProfile = () => ({
197
+ ...(readUserProfileFromEnvOrFile() ?? buildDefaultUserProfile()),
198
+ ...readProcessIdentity(),
199
+ });
192
200
  export { resolveUserProfile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimuslabs/harness-map-staging",
3
- "version": "1.5.19",
3
+ "version": "1.5.21",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,9 +56,9 @@
56
56
  "vitest": "^4.1.8"
57
57
  },
58
58
  "dependencies": {
59
- "axios": "^1.18.1",
60
- "canonicalize": "^2.1.0",
61
59
  "@optimuslabs/tofu-staging": "^0.1.18",
60
+ "axios": "^1.20.0",
61
+ "canonicalize": "^2.1.0",
62
62
  "smol-toml": "^1.7.1"
63
63
  },
64
64
  "publishConfig": {