@optimuslabs/harness-map-staging 1.5.34 → 1.5.35

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.
@@ -1,4 +1,6 @@
1
- /** Path suffix for file collection patterns (backend: api/file-path-registry/file-patterns/). */
2
- export const FILE_PATH_REGISTRY_FILE_PATTERNS_PATH = '/api/file-path-registry/file-patterns/';
1
+ /** Path suffix for file collection patterns (backend: api/file-path-registry/file-patterns/).
2
+ * `allowed_keys=1` says this sensor applies each pattern's allowed_keys and allowed_entries, so the backend
3
+ * also sends the file types that may only send those keys (hosting project links, OPT-1408). */
4
+ export const FILE_PATH_REGISTRY_FILE_PATTERNS_PATH = '/api/file-path-registry/file-patterns/?allowed_keys=1';
3
5
  /** Path suffix for sensitive paths audit candidates (backend: api/file-path-registry/sensitive-paths-audit-candidates/). */
4
6
  export const FILE_PATH_REGISTRY_SENSITIVE_PATHS_PATH = '/api/file-path-registry/sensitive-paths-audit-candidates/';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Keep only the keys a file type allows (OPT-1408, docs/infra-inventory DESIGN.md I25).
3
+ *
4
+ * A hosting project link (`.vercel/project.json`, `.netlify/state.json`, `fly.toml`) is needed
5
+ * only for the project it names, so the backend's pattern lists the identifiers to keep in
6
+ * `allowed_keys`, and nothing else in the file leaves the device. A file that did not parse as an
7
+ * object, or holds none of the keys, is not sent at all, so its text can never be sent instead.
8
+ */
9
+ /** The pattern's allowed keys copied onto one of its targets. */
10
+ export function copyAllowedKeys(target, pattern) {
11
+ if (pattern.allowed_keys?.length)
12
+ target.allowed_keys = pattern.allowed_keys;
13
+ if (pattern.allowed_entries)
14
+ target.allowed_entries = pattern.allowed_entries;
15
+ }
16
+ /** Whether the target's files may only send the identifiers its rules keep. */
17
+ export function hasAllowedKeys(target) {
18
+ return Boolean(target.allowed_keys?.length || target.allowed_entries);
19
+ }
20
+ /** The keys of `source` that `keep` names, with string or number values. */
21
+ function plainValues(source, keep) {
22
+ const kept = {};
23
+ for (const key of keep) {
24
+ const value = source[key];
25
+ // An identifier is a plain value; anything nested could carry more than the identifier.
26
+ if (typeof value === 'string' || typeof value === 'number')
27
+ kept[key] = value;
28
+ }
29
+ return kept;
30
+ }
31
+ function isObject(value) {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+ /** Each entry of the map `value` cut to `keep`; entries that are not objects, or keep nothing, are dropped. */
35
+ function keptEntries(value, keep) {
36
+ const kept = {};
37
+ if (!isObject(value))
38
+ return kept;
39
+ for (const [name, entry] of Object.entries(value)) {
40
+ const cut = isObject(entry) ? plainValues(entry, keep) : {};
41
+ if (Object.keys(cut).length > 0)
42
+ kept[name] = cut;
43
+ }
44
+ return kept;
45
+ }
46
+ /**
47
+ * The top-level keys of `raw` that `keep` names, with string or number values, and each map that
48
+ * `entries` names with every entry cut to its keys (the Railway CLI's linked folders); null when
49
+ * nothing is kept.
50
+ */
51
+ export function keepAllowedKeys(raw, keep, entries = {}) {
52
+ if (!isObject(raw))
53
+ return null;
54
+ const kept = plainValues(raw, keep);
55
+ for (const [key, fields] of Object.entries(entries)) {
56
+ const cut = keptEntries(raw[key], fields);
57
+ if (Object.keys(cut).length > 0)
58
+ kept[key] = cut;
59
+ }
60
+ return Object.keys(kept).length > 0 ? kept : null;
61
+ }
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { readJSONFile, readMCPConfig, readMarkdownFile, readInstalledExtensions } from '../readers/file_readers.js';
3
+ import { readJSONFile, readMCPConfig, readMarkdownFile, readInstalledExtensions, parseTomlText } from '../readers/file_readers.js';
4
4
  import { getExtensionsCachePath, getExtensionsCacheInstalledSuffix, getVscdbPath } from '../paths/path_constants_helpers.js';
5
5
  import { resolvePatternToTargets, normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
6
6
  import { enrichRawFromRecipe } from './enrichment_helpers.js';
7
7
  import { applyCoworkSessionWhitelist } from './cowork_session_whitelist.js';
8
+ import { copyAllowedKeys, hasAllowedKeys, keepAllowedKeys } from './allowed_keys.js';
8
9
  import { collectDirectoryEntries, collectDirectoryMetadata } from './directory_collector.js';
9
10
  import { collectVscdbEntries } from '../readers/vscdb_config_builder.js';
10
11
  import { pushDerivedFilesFromRecipe } from './openclaw_helpers.js';
@@ -65,6 +66,7 @@ function buildCollectionContext(patterns, projectRoot, home, homeRecurseSkipDirs
65
66
  for (const t of resolvePatternToTargets(p.path, p.type, p.file_type, projectRoot, home, pathSkipPrefixes, p.collect_style, p.content_format, p.dir_glob, homeRecurseSkipDirs, p.path_resolution, p.resolve_under_home !== false, absolutePathPrefixes, mcpToolGlobSpec, clientPathConstants, pathResolutionSpecs)) {
66
67
  if (p.raw_key)
67
68
  t.raw_key = p.raw_key;
69
+ copyAllowedKeys(t, p);
68
70
  if (p.dir_subdir_filename)
69
71
  t.dir_subdir_filename = p.dir_subdir_filename;
70
72
  if (p.dir_subdir_source)
@@ -83,6 +85,10 @@ function readContentByFormat(path, format) {
83
85
  return readMarkdownFile(path);
84
86
  if (format === 'sqlite_vscdb')
85
87
  return null; // handled upstream by collectVscdbEntries
88
+ if (format === 'toml') {
89
+ const text = readMarkdownFile(path);
90
+ return text === null ? null : parseTomlText(text);
91
+ }
86
92
  // json (default) — fall back to markdown for .md files from agents not yet annotated
87
93
  return readJSONFile(path) ?? readMCPConfig(path) ?? (path.endsWith('.md') ? readMarkdownFile(path) : null);
88
94
  }
@@ -92,6 +98,21 @@ function isPortableCursorUserSettingsTarget(t) {
92
98
  p.endsWith('/cursor/user/settings.json') ||
93
99
  p.includes('application support/cursor/user/settings.json'));
94
100
  }
101
+ /** The kept identifiers of a file whose pattern limits what may leave the device, read in the
102
+ * pattern's content format; null when nothing is kept (OPT-1408). */
103
+ export function keptFileContent(path, pattern) {
104
+ const content = readContentByFormat(path, pattern.content_format || 'json');
105
+ return keepAllowedKeys(content, pattern.allowed_keys ?? [], pattern.allowed_entries ?? {});
106
+ }
107
+ /** A file whose pattern limits what may leave the device: the kept identifiers, or nothing. */
108
+ function collectKeptFileEntry(t) {
109
+ const kept = keptFileContent(t.path, t);
110
+ return kept === null ? null : { file_type: t.file_type, file_path: t.logicalFilePath ?? t.path, raw_content: kept };
111
+ }
112
+ /** A regular file's entry: cut to its pattern's kept identifiers when it has any. */
113
+ function collectFileEntry(t, enrichByFileType) {
114
+ return hasAllowedKeys(t) ? collectKeptFileEntry(t) : collectRegularFileEntry(t, enrichByFileType);
115
+ }
95
116
  function collectRegularFileEntry(t, enrichByFileType) {
96
117
  const format = t.content_format || 'json';
97
118
  let content = readContentByFormat(t.path, format);
@@ -212,7 +233,7 @@ function collectConfigFilesFromPatterns(patterns, projectRoot, onProgress, optio
212
233
  configFiles.push(entry);
213
234
  continue;
214
235
  }
215
- const entry = collectRegularFileEntry(t, enrichByFileType);
236
+ const entry = collectFileEntry(t, enrichByFileType);
216
237
  if (entry) {
217
238
  configFiles.push(entry);
218
239
  const recipe = enrichByFileType[t.file_type];
@@ -229,6 +250,7 @@ export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles } fr
229
250
  export { collectMcpFromClaudeJsonProjects } from './claude_known_projects_collector.js';
230
251
  export { collectClaudeTokenUsage } from './claude_token_usage_collector.js';
231
252
  export { collectCopilotTokenUsage } from './copilot_token_usage_collector.js';
253
+ export { collectCopilotUsageWithAccount } from './copilot_account.js';
232
254
  export { collectOpencodeTokenUsage } from './opencode_token_usage_collector.js';
233
255
  export { collectCodexTokenUsage } from './codex_token_usage_collector.js';
234
256
  export { collectCursorTokenUsage } from './cursor_token_usage_collector.js';
@@ -0,0 +1,149 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { OPTIMUSLABS_MANAGEMENT_REL } from '../../bootstrap_constants.js';
5
+ import { atomicWriteJson } from '../runtime/management_storage.js';
6
+ import { collectCopilotTokenUsage } from './copilot_token_usage_collector.js';
7
+ const GH_TIMEOUT_MS = 5000;
8
+ const POLICY_FIELDS = [
9
+ 'chat_enabled',
10
+ 'cli_enabled',
11
+ 'copilot_app_enabled',
12
+ 'is_mcp_enabled',
13
+ 'editor_preview_features_enabled',
14
+ 'copilotignore_enabled',
15
+ ];
16
+ function asObject(value) {
17
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
18
+ }
19
+ function text(value) {
20
+ return typeof value === 'string' ? value.trim() : '';
21
+ }
22
+ /** `lastLoggedInUser` from Copilot's config, which starts with `//` comment lines. */
23
+ export function readCopilotLogin(home) {
24
+ let raw;
25
+ try {
26
+ raw = readFileSync(join(home, '.copilot', 'config.json'), 'utf8');
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ let config;
32
+ try {
33
+ config = asObject(JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ const user = asObject(config.lastLoggedInUser);
39
+ const login = text(user.login);
40
+ return login ? { login, host: text(user.host) || 'https://github.com' } : null;
41
+ }
42
+ function parsedGhOutput(output) {
43
+ try {
44
+ return JSON.parse(output);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** The account `gh` is signed in as. Fixed arguments: nothing outside shapes the command. */
51
+ function ghUser() {
52
+ try {
53
+ const output = execFileSync('gh', ['api', 'user'], {
54
+ encoding: 'utf8',
55
+ timeout: GH_TIMEOUT_MS,
56
+ stdio: ['ignore', 'pipe', 'ignore'],
57
+ });
58
+ return parsedGhOutput(output);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /** That account's Copilot seat, as Copilot's /status reads it. */
65
+ function ghCopilotSeat() {
66
+ try {
67
+ const output = execFileSync('gh', ['api', '/copilot_internal/user'], {
68
+ encoding: 'utf8',
69
+ timeout: GH_TIMEOUT_MS,
70
+ stdio: ['ignore', 'pipe', 'ignore'],
71
+ });
72
+ return parsedGhOutput(output);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ const GITHUB_CLI = { user: ghUser, copilotSeat: ghCopilotSeat };
79
+ /** The seat fields worth keeping from a `/copilot_internal/user` answer. */
80
+ export function copilotSeat(answer) {
81
+ const data = asObject(answer);
82
+ const organizations = (Array.isArray(data.organization_list) ? data.organization_list : [])
83
+ .map((org) => text(asObject(org).name) || text(asObject(org).login))
84
+ .filter(Boolean);
85
+ const policies = {};
86
+ for (const field of POLICY_FIELDS) {
87
+ if (typeof data[field] === 'boolean')
88
+ policies[field] = data[field];
89
+ }
90
+ return {
91
+ ...(text(data.copilot_plan) ? { plan: text(data.copilot_plan) } : {}),
92
+ ...(text(data.access_type_sku) ? { sku: text(data.access_type_sku) } : {}),
93
+ ...(organizations.length ? { organizations } : {}),
94
+ ...(Object.keys(policies).length ? { policies } : {}),
95
+ };
96
+ }
97
+ const CACHE_BASENAME = 'copilot_account.json';
98
+ const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
99
+ function cachePath(home) {
100
+ return join(home, OPTIMUSLABS_MANAGEMENT_REL, CACHE_BASENAME);
101
+ }
102
+ /** Yesterday's answer for this login, or null when there is none or it is out of date. */
103
+ function readCachedAccount(home, login, now) {
104
+ try {
105
+ const cached = asObject(JSON.parse(readFileSync(cachePath(home), 'utf8')));
106
+ const account = asObject(cached.account);
107
+ const fresh = typeof cached.checked_at === 'number' && now - cached.checked_at < CACHE_MAX_AGE_MS;
108
+ return fresh && account.login === login ? account : null;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ function writeCachedAccount(home, account, now) {
115
+ try {
116
+ atomicWriteJson(cachePath(home), { checked_at: now, account });
117
+ }
118
+ catch {
119
+ // An unwritable cache only means asking GitHub again next time.
120
+ }
121
+ }
122
+ export function readCopilotAccount(home, gh = GITHUB_CLI, now = Date.now()) {
123
+ const signedIn = readCopilotLogin(home);
124
+ if (!signedIn)
125
+ return null;
126
+ const cached = readCachedAccount(home, signedIn.login, now);
127
+ if (cached)
128
+ return cached;
129
+ const account = lookUpSeat(signedIn, gh);
130
+ writeCachedAccount(home, account, now);
131
+ return account;
132
+ }
133
+ function lookUpSeat(signedIn, gh) {
134
+ // gh answers for github.com; an enterprise server host would need gh's --hostname.
135
+ if (signedIn.host.replace(/\/+$/, '') !== 'https://github.com')
136
+ return signedIn;
137
+ const ghLogin = text(asObject(gh.user()).login);
138
+ if (!ghLogin || ghLogin.toLowerCase() !== signedIn.login.toLowerCase())
139
+ return signedIn;
140
+ return { ...signedIn, ...copilotSeat(gh.copilotSeat()) };
141
+ }
142
+ /** The Copilot usage summary with the account added, as one Copilot row per machine. */
143
+ export function collectCopilotUsageWithAccount(home) {
144
+ const usage = collectCopilotTokenUsage(home);
145
+ const account = usage.length ? readCopilotAccount(home) : null;
146
+ if (!account)
147
+ return usage;
148
+ return usage.map((entry) => ({ ...entry, raw_content: { ...asObject(entry.raw_content), account } }));
149
+ }
@@ -15,7 +15,8 @@ import { ensureAuthentication } from '../auth/auth_flow.js';
15
15
  import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
16
16
  import { isVscdbVirtualPath, tryReadVscdbVirtualFile, summarizeComposerPayloadForDiagnostics, } from '../readers/vscdb_config_builder.js';
17
17
  import { persistVscdbComposerContractFromPatternsResponse } from '../readers/vscdb_reader.js';
18
- import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeTokenUsage, collectCopilotTokenUsage, collectOpencodeTokenUsage, collectCodexTokenUsage, collectCursorTokenUsage, collectHermesTokenUsage, collectOpenclawTokenUsage, collectPiTokenUsage, collectCoworkDesktopVersion, collectClaudeDesktopOrgs, collectGrokCliVersion, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
18
+ import { hasAllowedKeys } from '../collection/allowed_keys.js';
19
+ import { keptFileContent, collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeTokenUsage, collectCopilotUsageWithAccount, collectOpencodeTokenUsage, collectCodexTokenUsage, collectCursorTokenUsage, collectHermesTokenUsage, collectOpenclawTokenUsage, collectPiTokenUsage, collectCoworkDesktopVersion, collectClaudeDesktopOrgs, collectGrokCliVersion, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
19
20
  import { ensureCursorUserSettingsSnapshotInBatch } from '../collection/ensure_cursor_user_settings_snapshot.js';
20
21
  import { collectSkillsCliInstalled } from '../collection/skills_cli_collector.js';
21
22
  import { collectWorkspaceVscdbs } from '../collection/mcp_tool_collector.js';
@@ -43,7 +44,7 @@ function addUnseen(configFiles, seen, entries) {
43
44
  const HOME_COLLECTORS = [
44
45
  ['scanning Claude known projects from ~/.claude.json', collectMcpFromClaudeJsonProjects],
45
46
  ['aggregating Claude token usage from ~/.claude/projects transcripts', collectClaudeTokenUsage],
46
- ['aggregating Copilot token usage from ~/.copilot/session-state transcripts', collectCopilotTokenUsage],
47
+ ['aggregating Copilot token usage and account from ~/.copilot', collectCopilotUsageWithAccount],
47
48
  ['aggregating OpenCode token usage from ~/.local/share/opencode/opencode.db', collectOpencodeTokenUsage],
48
49
  ['aggregating Codex token usage from ~/.codex/sessions rollouts', collectCodexTokenUsage],
49
50
  ['aggregating Cursor token usage from state.vscdb', collectCursorTokenUsage],
@@ -299,49 +300,68 @@ async function main() {
299
300
  }
300
301
  await sendAllConfigFiles(configFiles, worktreeReport, hardwareUuid, authKey);
301
302
  }
303
+ /** The pattern of a file type that limits what may leave the device (OPT-1408), if it has one. */
304
+ function limitedPattern(fileType, patterns) {
305
+ return patterns.find((p) => p.file_type === fileType && hasAllowedKeys(p));
306
+ }
307
+ /** Send a limited file cut to its kept identifiers, or nothing when it holds none of them. */
308
+ async function logLimitedFile(filePath, pattern, hardwareUuid, authKey) {
309
+ const kept = keptFileContent(filePath, pattern);
310
+ if (!kept) {
311
+ console.error(`Nothing to send from: ${filePath}`);
312
+ return false;
313
+ }
314
+ return sendConfigFile({ file_type: pattern.file_type, file_path: filePath, raw_content: kept }, hardwareUuid, authKey);
315
+ }
316
+ /** Whether the file to log is missing (reported); a vscdb virtual path has no file of its own. */
317
+ function logMissingSingleFile(filePath, isVscdb) {
318
+ if (isVscdb || existsSync(filePath))
319
+ return false;
320
+ console.error(`File not found: ${filePath}`);
321
+ return true;
322
+ }
323
+ /** A vscdb virtual file's content and type; null (reported) when it cannot be read. */
324
+ function readVscdbSingleFile(filePath, patternsResponse) {
325
+ const result = tryReadVscdbVirtualFile(filePath, patternsResponse);
326
+ if (!result) {
327
+ console.error(`Could not read vscdb virtual file: ${filePath}`);
328
+ return null;
329
+ }
330
+ return { rawContent: result.rawContent, fileType: result.fileType };
331
+ }
332
+ /** A file read as JSON, else as markdown; null (reported) when neither reads. */
333
+ function readPlainSingleFile(filePath, fileType) {
334
+ const json = readJSONFile(filePath);
335
+ if (json)
336
+ return { rawContent: json, fileType };
337
+ const markdown = readMarkdownFile(filePath);
338
+ if (markdown === null) {
339
+ console.error(`Could not read file: ${filePath}`);
340
+ return null;
341
+ }
342
+ return { rawContent: { content: markdown, source: 'file' }, fileType };
343
+ }
302
344
  async function logSingleFile(filePath) {
303
345
  const hardwareUuid = resolveHardwareUuid();
304
346
  const authKey = await ensureAuthentication(hardwareUuid);
305
- const patternsResponse = await getFileCollectionPatterns(loadEndpointBase());
306
- const isVscdb = isVscdbVirtualPath(filePath, patternsResponse ?? null);
307
- if (!existsSync(filePath) && !isVscdb) {
308
- console.error(`File not found: ${filePath}`);
347
+ const patternsResponse = (await getFileCollectionPatterns(loadEndpointBase())) ?? null;
348
+ const isVscdb = isVscdbVirtualPath(filePath, patternsResponse);
349
+ if (logMissingSingleFile(filePath, isVscdb))
309
350
  return false;
310
- }
311
351
  const patterns = patternsResponse?.patterns ?? [];
312
- let rawContent = null;
313
- let fileType = determineFileTypeFromPath(filePath, patterns) ?? undefined;
314
- if (isVscdb) {
315
- const vscdbResult = tryReadVscdbVirtualFile(filePath, patternsResponse ?? null);
316
- if (!vscdbResult) {
317
- console.error(`Could not read vscdb virtual file: ${filePath}`);
318
- return false;
319
- }
320
- rawContent = vscdbResult.rawContent;
321
- fileType = vscdbResult.fileType;
322
- }
323
- else {
324
- rawContent = readJSONFile(filePath);
325
- if (!rawContent) {
326
- const markdown = readMarkdownFile(filePath);
327
- if (markdown !== null) {
328
- rawContent = { content: markdown, source: 'file' };
329
- if (!fileType)
330
- fileType = determineFileTypeFromPath(filePath, patterns) ?? undefined;
331
- }
332
- }
333
- }
334
- if (!rawContent) {
335
- console.error(`Could not read file: ${filePath}`);
352
+ const pathType = determineFileTypeFromPath(filePath, patterns) ?? undefined;
353
+ // A file type with allowed keys is sent cut to its kept keys, or not at all (OPT-1408).
354
+ const limited = limitedPattern(pathType, patterns);
355
+ if (limited)
356
+ return logLimitedFile(filePath, limited, hardwareUuid, authKey);
357
+ const read = isVscdb ? readVscdbSingleFile(filePath, patternsResponse) : readPlainSingleFile(filePath, pathType);
358
+ if (!read)
336
359
  return false;
337
- }
360
+ const fileType = read.fileType ?? pathType;
338
361
  if (!fileType) {
339
- fileType = determineFileTypeFromPath(filePath, patterns) ?? undefined;
340
- if (!fileType) {
341
- console.error(`Could not determine file type for: ${filePath}`);
342
- return false;
343
- }
362
+ console.error(`Could not determine file type for: ${filePath}`);
363
+ return false;
344
364
  }
345
- return sendConfigFile({ file_type: fileType, file_path: filePath, raw_content: rawContent }, hardwareUuid, authKey);
365
+ return sendConfigFile({ file_type: fileType, file_path: filePath, raw_content: read.rawContent }, hardwareUuid, authKey);
346
366
  }
347
367
  export { main, logSingleFile, createSignature, canonicalizePayload, sendConfigFile, sendConfigFilesBatch, BATCH_CHUNK_SIZE, addSensitivePathsAudit, };
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Skill block (docs/skill-block/DESIGN.md): lock the skill folders the organization blocked so no
3
+ * agent can load or run them, and give a folder its permissions back once the skill is unblocked.
4
+ *
5
+ * The remediation sync stores the server's list under `skill_blocks` in
6
+ * remediation_instructions.json and then calls `applySkillBlocks`. A folder is locked with
7
+ * `chmod 000` after its mode is recorded in skill_block_state.json; a recorded folder that is no
8
+ * longer listed gets that mode back. The folders are where the collection found each skill's
9
+ * SKILL.md, for any agent. A folder is locked only when it is a real directory inside the home
10
+ * folder (never the home folder itself) holding a SKILL.md, so a bad path cannot lock anything
11
+ * that is not a skill.
12
+ */
13
+ import { chmodSync, existsSync, lstatSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join, resolve, sep } from 'node:path';
16
+ import { complianceRunnerDiag, hookRunLog } from './hook_logger.js';
17
+ import { atomicWriteJson, getSkillBlockStatePath } from './management_storage.js';
18
+ function log(message) {
19
+ hookRunLog(message);
20
+ complianceRunnerDiag(message);
21
+ }
22
+ export function readSkillBlockState() {
23
+ const path = getSkillBlockStatePath();
24
+ if (!existsSync(path))
25
+ return {};
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
28
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
34
+ function permissionBits(path) {
35
+ return lstatSync(path).mode & 0o7777;
36
+ }
37
+ /**
38
+ * Whether `dir` is a skill folder this endpoint locked for a block and that is still locked. Its
39
+ * files cannot be read, and nothing can have changed in it since it was locked, so the collection
40
+ * reports it as locked and the backend keeps the copy it has.
41
+ */
42
+ export function isLockedByOptimus(dir) {
43
+ const target = resolve(dir);
44
+ if (!readSkillBlockState()[target])
45
+ return false;
46
+ try {
47
+ return permissionBits(target) === 0;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ /** Why `dir` must not be locked, or null when it is a real skill folder inside the home folder. */
54
+ export function refusalReason(dir, home = homedir()) {
55
+ const root = resolve(home);
56
+ const target = resolve(dir);
57
+ if (!target.startsWith(root + sep))
58
+ return 'outside the home folder';
59
+ if (!existsSync(target))
60
+ return 'missing';
61
+ const stat = lstatSync(target);
62
+ if (stat.isSymbolicLink())
63
+ return 'symlink';
64
+ if (!stat.isDirectory())
65
+ return 'not a directory';
66
+ if (!realpathSync(target).startsWith(realpathSync(root) + sep))
67
+ return 'resolves outside the home folder';
68
+ if (!existsSync(join(target, 'SKILL.md')))
69
+ return 'no SKILL.md';
70
+ return null;
71
+ }
72
+ /** Lock a recorded folder again if it was opened; its path may have been replaced, so check it. */
73
+ function relockIfOpened(dir, entry, outcome, home) {
74
+ if (!existsSync(dir) || permissionBits(dir) === 0)
75
+ return;
76
+ const reason = refusalReason(dir, home);
77
+ if (reason) {
78
+ outcome.refused.push({ skill_dir: entry.skill_dir, reason });
79
+ return;
80
+ }
81
+ chmodSync(dir, 0o000);
82
+ outcome.relocked.push(dir);
83
+ }
84
+ /** Lock a listed folder, or lock it again when its permissions were opened since. */
85
+ function lockFolder(entry, state, outcome, home) {
86
+ const dir = resolve(entry.skill_dir);
87
+ const known = state[dir];
88
+ if (known) {
89
+ relockIfOpened(dir, entry, outcome, home);
90
+ return;
91
+ }
92
+ const reason = refusalReason(dir, home);
93
+ if (reason) {
94
+ outcome.refused.push({ skill_dir: entry.skill_dir, reason });
95
+ return;
96
+ }
97
+ state[dir] = { mode: permissionBits(dir), resource_uuid: entry.resource_uuid, locked_at: new Date().toISOString() };
98
+ atomicWriteJson(getSkillBlockStatePath(), state);
99
+ chmodSync(dir, 0o000);
100
+ outcome.locked.push(dir);
101
+ }
102
+ /** Give a folder no longer listed the mode it had before it was locked. */
103
+ function restoreFolder(dir, state, outcome) {
104
+ const { mode } = state[dir];
105
+ if (existsSync(dir) && !lstatSync(dir).isSymbolicLink()) {
106
+ chmodSync(dir, mode);
107
+ outcome.restored.push(dir);
108
+ }
109
+ delete state[dir];
110
+ }
111
+ /**
112
+ * Make the folders on disk match `entries`, the server's whole list for this machine: lock the
113
+ * listed ones, restore the ones locked before and no longer listed. A folder that fails the checks
114
+ * is skipped and logged; one failing chmod does not stop the others.
115
+ */
116
+ export function applySkillBlocks(entries, home = homedir()) {
117
+ const outcome = { locked: [], relocked: [], restored: [], refused: [] };
118
+ const lockPath = `${getSkillBlockStatePath()}.lock`;
119
+ if (!takeStateLock(lockPath)) {
120
+ log('skill_block: another sync is applying blocks; the next sync applies these');
121
+ return outcome;
122
+ }
123
+ try {
124
+ applyUnderLock(entries, home, outcome);
125
+ }
126
+ finally {
127
+ releaseStateLock(lockPath);
128
+ }
129
+ return outcome;
130
+ }
131
+ /**
132
+ * Take the lock around reading the state, changing permissions and writing the state. Two syncs at
133
+ * once (the prompt gate and the compliance runner) could otherwise lose a folder's original mode,
134
+ * leaving it locked after an unblock. A lock left by a dead process goes after a minute.
135
+ */
136
+ function takeStateLock(lockPath) {
137
+ try {
138
+ if (existsSync(lockPath) && Date.now() - statSync(lockPath).mtimeMs > 60_000)
139
+ unlinkSync(lockPath);
140
+ writeFileSync(lockPath, '', { flag: 'wx' });
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ function releaseStateLock(lockPath) {
148
+ try {
149
+ unlinkSync(lockPath);
150
+ }
151
+ catch {
152
+ // Already gone; the next sync takes it again.
153
+ }
154
+ }
155
+ function applyUnderLock(entries, home, outcome) {
156
+ const state = readSkillBlockState();
157
+ const wanted = new Set(entries.map((entry) => resolve(entry.skill_dir)));
158
+ for (const entry of entries) {
159
+ try {
160
+ lockFolder(entry, state, outcome, home);
161
+ }
162
+ catch (err) {
163
+ outcome.refused.push({ skill_dir: entry.skill_dir, reason: err instanceof Error ? err.message : String(err) });
164
+ }
165
+ }
166
+ for (const dir of Object.keys(state)) {
167
+ if (wanted.has(dir))
168
+ continue;
169
+ try {
170
+ restoreFolder(dir, state, outcome);
171
+ }
172
+ catch (err) {
173
+ outcome.refused.push({ skill_dir: dir, reason: err instanceof Error ? err.message : String(err) });
174
+ }
175
+ }
176
+ atomicWriteJson(getSkillBlockStatePath(), state);
177
+ logOutcome(outcome);
178
+ }
179
+ function logOutcome(outcome) {
180
+ for (const dir of outcome.locked)
181
+ log(`skill_block: locked ${dir}`);
182
+ for (const dir of outcome.relocked)
183
+ log(`skill_block: locked again (permissions were opened) ${dir}`);
184
+ for (const dir of outcome.restored)
185
+ log(`skill_block: restored ${dir}`);
186
+ for (const { skill_dir, reason } of outcome.refused)
187
+ log(`skill_block: skipped ${skill_dir} (${reason})`);
188
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Skill block killswitch (docs/skill-block/DESIGN.md): a session that loaded a skill keeps it in its
3
+ * conversation, so when that skill is blocked the session is stopped until the user restarts Claude
4
+ * Code. A restarted process reads its skills with the folder locked, so the skill does not exist.
5
+ *
6
+ * The backend knows which sessions loaded a skill: the tool calls the skill usage view attributes to
7
+ * it. The remediation sync sends this machine's sessions that loaded a blocked skill
8
+ * (`skill_block_sessions` in remediation_instructions.json), and the prompt gate refuses their
9
+ * prompts. Only those sessions are stopped; an unblock takes them off the list.
10
+ */
11
+ import { execFileSync } from 'node:child_process';
12
+ import { basename, resolve } from 'node:path';
13
+ import { readRemediationInstructionsFile } from './management_storage.js';
14
+ import { readSkillBlockState } from './skill_block.js';
15
+ /** A process's parent, start time and command name, from `ps`; null when it cannot be read. */
16
+ function processRow(pid) {
17
+ try {
18
+ // A fixed command, no shell, and a process id that is always a number.
19
+ const out = execFileSync('ps', ['-o', 'ppid=,lstart=,comm=', '-p', String(pid)], { encoding: 'utf8' }).trim(); // nosafe: SAFE801
20
+ const row = out.match(/^(\d+)\s+(\S+\s+\S+\s+\d+\s+[\d:]+\s+\d+)\s+(.*)$/);
21
+ return row ? { parent: Number(row[1]), startedAt: Date.parse(row[2]), name: basename(row[3]).toLowerCase() } : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /**
28
+ * When the `claude` process running this hook started (ms), or null when it cannot be found. It
29
+ * lists its skills when it starts and keeps them across `/clear`, so this is what tells a real
30
+ * restart from `/clear`, which only changes the session id.
31
+ */
32
+ export function claudeProcessStartedAt() {
33
+ let pid = process.ppid;
34
+ for (let depth = 0; depth < 12 && pid > 1; depth += 1) {
35
+ const row = processRow(pid);
36
+ if (!row)
37
+ return null;
38
+ if (row.name === 'claude')
39
+ return row.startedAt;
40
+ pid = row.parent;
41
+ }
42
+ return null;
43
+ }
44
+ /** The names of the blocked skills this session loaded; empty when it may go on. */
45
+ export function stoppingSkills(sessionId) {
46
+ if (!sessionId)
47
+ return [];
48
+ const sessions = readRemediationInstructionsFile().skill_block_sessions ?? [];
49
+ return sessions.filter((s) => s.session_id === sessionId).map((s) => s.skill_name || 'a skill');
50
+ }
51
+ /** The notice for a stopped session. */
52
+ export function stoppedSessionMessage(skills) {
53
+ const names = [...new Set(skills)].join(', ');
54
+ return (`Your organization blocked ${names}, which this session loaded. Copy anything you need from ` +
55
+ 'this conversation, then quit Claude Code and start it again to continue. Resuming this ' +
56
+ 'session keeps it stopped.');
57
+ }
58
+ /**
59
+ * The blocked skill a typed slash command runs, when this process may still have it, or null:
60
+ * `/airtable:airtable-cli …` or `/airtable-cli …`. A process started before the folder was locked
61
+ * listed the skill and keeps it, even after `/clear`, so this refuses it. A process started after
62
+ * the lock never listed it: this answers null, and Claude Code says the skill does not exist.
63
+ * Matches the skill's name or its folder's name, which can differ (`aikido-issues` lives in
64
+ * `skills/issues`). An unknown start time counts as too old, so a block is never skipped.
65
+ */
66
+ export function blockedSkillOfCommand(promptFirstLine, processStartedAt = claudeProcessStartedAt) {
67
+ const command = promptFirstLine.trim().split(/\s+/)[0] ?? '';
68
+ if (!command.startsWith('/'))
69
+ return null;
70
+ const typed = command.slice(command.lastIndexOf(':') + 1).replace(/^\//, '').toLowerCase();
71
+ const blocks = readRemediationInstructionsFile().skill_blocks ?? [];
72
+ const hits = blocks.filter((b) => b.skill_name.toLowerCase() === typed || basename(b.skill_dir).toLowerCase() === typed);
73
+ if (hits.length === 0)
74
+ return null;
75
+ const startedAt = processStartedAt();
76
+ const locks = readSkillBlockState();
77
+ const mayStillHaveIt = hits.some((b) => {
78
+ const lockedAt = Date.parse(locks[resolve(b.skill_dir)]?.locked_at ?? '');
79
+ return startedAt === null || Number.isNaN(lockedAt) || startedAt <= lockedAt;
80
+ });
81
+ return mayStillHaveIt ? hits[0].skill_name || typed : null;
82
+ }
83
+ /** The notice for a typed command of a blocked skill in a process that may still have it. */
84
+ export function blockedCommandMessage(skill) {
85
+ return `Your organization blocked ${skill}. Quit Claude Code and start it again to continue.`;
86
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * pii-scrub shim: the one place that imports @optimuslabs/pii-scrub-staging.
3
+ *
4
+ * Loaded the way tofu.ts loads tofu: the sibling local dist when environment=development and it
5
+ * is built, the published npm package otherwise.
6
+ */
7
+ import path from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { shouldUseLocalTofuDist } from "./tofu_environment.js";
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const localPiiScrubPath = path.join(__dirname, "../../pii-scrub/dist/index.js");
12
+ const piiScrub = (shouldUseLocalTofuDist(localPiiScrubPath)
13
+ ? await import(localPiiScrubPath)
14
+ : await import("@optimuslabs/pii-scrub-staging"));
15
+ export const { SCRUBBER_VERSION, containsPlaceholder, scrubFields, scrubText, scrubValue } = piiScrub;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimuslabs/harness-map-staging",
3
- "version": "1.5.34",
3
+ "version": "1.5.35",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {