@optimuslabs/harness-map-staging 1.5.29 → 1.5.31
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.
|
@@ -62,6 +62,7 @@ export function readClaudeSignedInTenant(env = process.env) {
|
|
|
62
62
|
tenant_name: text('organizationName'),
|
|
63
63
|
email: text('emailAddress'),
|
|
64
64
|
account_uuid: text('accountUuid'),
|
|
65
|
+
plan: text('organizationType').toLowerCase().replace(/^claude_/, ''),
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
catch {
|
|
@@ -69,14 +70,74 @@ export function readClaudeSignedInTenant(env = process.env) {
|
|
|
69
70
|
return null;
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Block messages, one shape for every agent (Optimus Labs' prompt-block wording):
|
|
75
|
+
* "This device only allows <agent> <under/on/in> the <approved> <kind>. You're signed in to
|
|
76
|
+
* <what>. <how to switch>." With nothing approved yet: "This device doesn't allow <agent> signed in
|
|
77
|
+
* to <what>." and the same last sentence.
|
|
78
|
+
*/
|
|
79
|
+
const TENANT_WORDS = {
|
|
80
|
+
claude: { name: 'Claude Code', kind: 'organization', prep: 'under' },
|
|
81
|
+
claude_desktop: { name: 'Claude Desktop', kind: 'organization', prep: 'under' },
|
|
82
|
+
cursor: { name: 'Cursor', kind: 'team', prep: 'on' },
|
|
83
|
+
codex: { name: 'Codex', kind: 'workspace', prep: 'in' },
|
|
84
|
+
};
|
|
85
|
+
/** A tenant's name as the admin approved it, or its id when it has none. */
|
|
86
|
+
function nameOf(t) {
|
|
87
|
+
return (t.tenant_name || '').trim() || t.tenant_id.trim();
|
|
88
|
+
}
|
|
89
|
+
function emailDomain(email) {
|
|
90
|
+
const address = (email ?? '').trim().toLowerCase();
|
|
91
|
+
const at = address.lastIndexOf('@');
|
|
92
|
+
return at >= 0 && at < address.length - 1 ? address.slice(at + 1) : '';
|
|
77
93
|
}
|
|
78
|
-
function
|
|
79
|
-
return
|
|
94
|
+
function capitalised(word) {
|
|
95
|
+
return word ? word[0].toUpperCase() + word.slice(1) : word;
|
|
96
|
+
}
|
|
97
|
+
/** What to pick to get back in: the one approved tenant, one of several, or the company's own. */
|
|
98
|
+
function choice(approved, kind) {
|
|
99
|
+
if (approved.length === 1)
|
|
100
|
+
return approved[0];
|
|
101
|
+
return approved.length > 1 ? 'one of them' : `your company's ${kind}`;
|
|
102
|
+
}
|
|
103
|
+
function switchStep(agent, approved) {
|
|
104
|
+
const kind = TENANT_WORDS[agent].kind;
|
|
105
|
+
if (agent === 'claude_desktop')
|
|
106
|
+
return `Switch Claude Desktop to ${choice(approved, kind)}.`;
|
|
107
|
+
if (agent === 'codex')
|
|
108
|
+
return `Run codex logout, then codex login and choose ${choice(approved, kind)}.`;
|
|
109
|
+
if (agent === 'cursor') {
|
|
110
|
+
if (approved.length === 1)
|
|
111
|
+
return `Sign out of Cursor and sign in with your ${approved[0]} team account.`;
|
|
112
|
+
return approved.length > 1
|
|
113
|
+
? 'Sign out of Cursor and sign in with an account on one of those teams.'
|
|
114
|
+
: "Sign out of Cursor and sign in with your company's team account.";
|
|
115
|
+
}
|
|
116
|
+
return `Run /login and choose ${choice(approved, kind)}.`;
|
|
117
|
+
}
|
|
118
|
+
/** The block message for `agent` signed in to `signedIn` (already worded) with these approved. */
|
|
119
|
+
export function tenantBlockMessage(agent, approved, signedIn) {
|
|
120
|
+
const w = TENANT_WORDS[agent];
|
|
121
|
+
const lead = approved.length
|
|
122
|
+
? `This device only allows ${w.name} ${w.prep} the ${approved.join(' or ')} ${w.kind}. ` +
|
|
123
|
+
`You're signed in to ${signedIn}.`
|
|
124
|
+
: `This device doesn't allow ${w.name} signed in to ${signedIn}.`;
|
|
125
|
+
return `${lead} ${switchStep(agent, approved)}`;
|
|
126
|
+
}
|
|
127
|
+
const CLAUDE_PERSONAL_PLANS = new Set(['free', 'pro', 'max']);
|
|
128
|
+
/** "a personal Max account (gmail.com)" or "the Beta Corp organization". */
|
|
129
|
+
function claudeSignedInText(signedIn) {
|
|
130
|
+
const plan = (signedIn.plan ?? '').trim().toLowerCase();
|
|
131
|
+
const domain = emailDomain(signedIn.email);
|
|
132
|
+
const at = domain ? ` (${domain})` : '';
|
|
133
|
+
// A personal claude.ai org is named after its owner: "ada@gmail.com's Organization".
|
|
134
|
+
if (CLAUDE_PERSONAL_PLANS.has(plan) || /'s organization$/i.test(signedIn.tenant_name)) {
|
|
135
|
+
const planWord = CLAUDE_PERSONAL_PLANS.has(plan) ? `${capitalised(plan)} ` : '';
|
|
136
|
+
return `a personal ${planWord}account${at}`;
|
|
137
|
+
}
|
|
138
|
+
if (signedIn.tenant_name)
|
|
139
|
+
return `the ${signedIn.tenant_name} organization`;
|
|
140
|
+
return `an organization your company has not approved${at}`;
|
|
80
141
|
}
|
|
81
142
|
function tenantIds(tenants) {
|
|
82
143
|
return (tenants ?? []).filter((t) => t && typeof t.tenant_id === 'string' && t.tenant_id.trim());
|
|
@@ -96,21 +157,11 @@ export function approvedTenantViolation(agent, approved, signedIn) {
|
|
|
96
157
|
const unapprovedTenants = tenantIds(policy?.unapproved_tenants);
|
|
97
158
|
const signedInId = signedIn.tenant_id.trim();
|
|
98
159
|
const isIn = (list) => list.some((t) => t.tenant_id.trim() === signedInId);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
else if ((policy?.enforced || approvedTenants.length > 0) && !isIn(approvedTenants)) {
|
|
104
|
-
reason = 'which your organization has not approved';
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
160
|
+
const blocked = isIn(unapprovedTenants) ||
|
|
161
|
+
((policy?.enforced || approvedTenants.length > 0) && !isIn(approvedTenants));
|
|
162
|
+
if (!blocked)
|
|
107
163
|
return null;
|
|
108
|
-
|
|
109
|
-
const allowed = approvedTenants.map((t) => label(t.tenant_name, t.tenant_id)).join(', ');
|
|
110
|
-
return (`this session is signed into ${label(signedIn.tenant_name, signedIn.tenant_id)}, ${reason}. ` +
|
|
111
|
-
(allowed
|
|
112
|
-
? `Approved: ${allowed}. ${fix(agent, 'an approved')}`
|
|
113
|
-
: fix(agent, 'a different')));
|
|
164
|
+
return tenantBlockMessage(agent, approvedTenants.map(nameOf), claudeSignedInText(signedIn));
|
|
114
165
|
}
|
|
115
166
|
/**
|
|
116
167
|
* The tenants a Cursor login is in: its teams, or, on no team, `@` and its email domain. Mirrors the
|
|
@@ -142,43 +193,61 @@ export function readCursorSignedInTeams(home = homedir()) {
|
|
|
142
193
|
* the login; failing that, one marked unapproved is always blocked, and any other -- an unknown
|
|
143
194
|
* tenant, or a personal account -- only once at least one tenant is approved for this agent.
|
|
144
195
|
*/
|
|
145
|
-
function listedTenantViolation(policy, tenants
|
|
196
|
+
function listedTenantViolation(policy, tenants) {
|
|
146
197
|
const approvedList = tenantIds(policy?.tenants);
|
|
147
198
|
if (tenants.some((id) => isListed(approvedList, id)))
|
|
148
199
|
return null;
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
200
|
+
const enforced = approvedList.length > 0 || Boolean(policy?.enforced);
|
|
201
|
+
if (!isRefused(tenants, enforced, tenantIds(policy?.unapproved_tenants)))
|
|
151
202
|
return null;
|
|
152
|
-
return {
|
|
203
|
+
return { approved: approvedList.map(nameOf) };
|
|
153
204
|
}
|
|
154
205
|
function isListed(list, id) {
|
|
155
206
|
return list.some((t) => t.tenant_id.trim() === id);
|
|
156
207
|
}
|
|
157
|
-
|
|
158
|
-
|
|
208
|
+
/** Whether a login in these tenants is refused: one marked unapproved always; any other only once
|
|
209
|
+
* the agent is enforced (an older backend that sends no flag, with nothing approved, lets it pass). */
|
|
210
|
+
function isRefused(tenants, enforced, unapprovedList) {
|
|
211
|
+
if (tenants.some((id) => isListed(unapprovedList, id)))
|
|
212
|
+
return true;
|
|
213
|
+
return enforced;
|
|
159
214
|
}
|
|
160
|
-
/**
|
|
161
|
-
function
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
215
|
+
/** A tenant's name from the organization's lists, or '' when it has none there. */
|
|
216
|
+
function knownName(policy, id) {
|
|
217
|
+
const all = [...tenantIds(policy?.tenants), ...tenantIds(policy?.unapproved_tenants)];
|
|
218
|
+
const hit = all.find((t) => t.tenant_id.trim() === id);
|
|
219
|
+
return (hit?.tenant_name || '').trim();
|
|
220
|
+
}
|
|
221
|
+
/** "a personal account on no team (gmail.com)", "the Beta team" or "team 9876". */
|
|
222
|
+
function cursorSignedInText(signedIn, policy) {
|
|
223
|
+
const tenants = cursorTenants(signedIn);
|
|
224
|
+
if (!tenants.length || tenants[0].startsWith('@')) {
|
|
225
|
+
const domain = emailDomain(signedIn.email) || (tenants[0] ?? '').slice(1);
|
|
226
|
+
return `a personal account on no team${domain ? ` (${domain})` : ''}`;
|
|
227
|
+
}
|
|
228
|
+
return tenants
|
|
229
|
+
.map((id) => (knownName(policy, id) ? `the ${knownName(policy, id)} team` : `team ${id}`))
|
|
230
|
+
.join(' and ');
|
|
231
|
+
}
|
|
232
|
+
/** "a personal ChatGPT Plus account (gmail.com)", "the Beta workspace" or "workspace ws-1". */
|
|
233
|
+
function codexSignedInText(signedIn, tenants, policy) {
|
|
234
|
+
const tenant = tenants[0] ?? '';
|
|
235
|
+
if (!tenant || tenant.startsWith('@')) {
|
|
236
|
+
const plan = capitalised((signedIn.plan ?? '').trim().toLowerCase());
|
|
237
|
+
const domain = emailDomain(signedIn.email) || tenant.slice(1);
|
|
238
|
+
return `a personal ChatGPT ${plan ? `${plan} ` : ''}account${domain ? ` (${domain})` : ''}`;
|
|
239
|
+
}
|
|
240
|
+
const name = knownName(policy, tenant);
|
|
241
|
+
return name ? `the ${name} workspace` : `workspace ${tenant}`;
|
|
171
242
|
}
|
|
172
243
|
/** The block message when Cursor's login may not be used, else null. */
|
|
173
244
|
export function cursorTeamViolation(approved, signedIn) {
|
|
174
245
|
if (!signedIn)
|
|
175
246
|
return null;
|
|
176
|
-
const v = listedTenantViolation(approved?.cursor, cursorTenants(signedIn)
|
|
247
|
+
const v = listedTenantViolation(approved?.cursor, cursorTenants(signedIn));
|
|
177
248
|
if (!v)
|
|
178
249
|
return null;
|
|
179
|
-
return (
|
|
180
|
-
(v.allowed ? `Approved: ${v.allowed}. ` : '') +
|
|
181
|
-
'Sign out of Cursor and sign in with an account on an approved team.');
|
|
250
|
+
return tenantBlockMessage('cursor', v.approved, cursorSignedInText(signedIn, approved?.cursor));
|
|
182
251
|
}
|
|
183
252
|
/**
|
|
184
253
|
* Codex plans that are personal, mirroring the backend's `CodexPlans.PERSONAL`. On one of these
|
|
@@ -221,12 +290,10 @@ export function codexTenantViolation(approved, signedIn) {
|
|
|
221
290
|
const tenants = signedIn ? codexTenants(signedIn) : null;
|
|
222
291
|
if (!tenants)
|
|
223
292
|
return null;
|
|
224
|
-
const v = listedTenantViolation(approved?.codex, tenants
|
|
293
|
+
const v = listedTenantViolation(approved?.codex, tenants);
|
|
225
294
|
if (!v)
|
|
226
295
|
return null;
|
|
227
|
-
return (
|
|
228
|
-
(v.allowed ? `Approved: ${v.allowed}. ` : '') +
|
|
229
|
-
'Run `codex logout`, then sign in to an approved workspace.');
|
|
296
|
+
return tenantBlockMessage('codex', v.approved, codexSignedInText(signedIn, tenants, approved?.codex));
|
|
230
297
|
}
|
|
231
298
|
/**
|
|
232
299
|
* Whether a personal account's `@<domain>` tenant is on a team domain: an allowlisted domain that
|
|
@@ -276,6 +343,7 @@ export function readClaudeDesktopSignedInTenant(home = homedir(), env = process.
|
|
|
276
343
|
tenant_name: same ? fromClaudeCode.tenant_name : '',
|
|
277
344
|
email: same ? fromClaudeCode.email : '',
|
|
278
345
|
account_uuid: account || (same ? fromClaudeCode.account_uuid : ''),
|
|
346
|
+
plan: same ? fromClaudeCode.plan : '',
|
|
279
347
|
};
|
|
280
348
|
}
|
|
281
349
|
/**
|
|
@@ -293,8 +361,8 @@ export function unapprovedEmailDomainViolation(email, unapproved) {
|
|
|
293
361
|
const hit = unapproved.find((d) => domain === d || domain.endsWith(`.${d}`));
|
|
294
362
|
if (!hit)
|
|
295
363
|
return null;
|
|
296
|
-
return (`
|
|
297
|
-
'Sign in with
|
|
364
|
+
return (`This device doesn't allow accounts on ${hit}. You're signed in as ${address}. ` +
|
|
365
|
+
'Sign in with your work account.');
|
|
298
366
|
}
|
|
299
367
|
/**
|
|
300
368
|
* The block message when a login's email is not on the organization's email allowlist, else null.
|
|
@@ -313,8 +381,8 @@ export function emailAllowlistViolation(email, allowed) {
|
|
|
313
381
|
const domain = address.slice(at + 1);
|
|
314
382
|
if (allowed.some((d) => domain === d || domain.endsWith(`.${d}`)))
|
|
315
383
|
return null;
|
|
316
|
-
return (`
|
|
317
|
-
'Sign in with
|
|
384
|
+
return (`This device only allows accounts on your organization's email domains. You're signed in as ${address}. ` +
|
|
385
|
+
'Sign in with your work account.');
|
|
318
386
|
}
|
|
319
387
|
/** The id an agent is approved under on the Agents page: Desktop's Code tab runs Claude Code. */
|
|
320
388
|
const APPROVAL_ID = { claude_desktop: 'claude' };
|
|
@@ -87,9 +87,10 @@ function printAllowWithAdvisory(ide, advisoryMessage) {
|
|
|
87
87
|
}));
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
/** Every block reads as an Optimus Labs notice: a title line, then the message. */
|
|
91
|
+
const BLOCK_TITLE = 'Optimus Labs\n\n';
|
|
90
92
|
function blockPayload(ide, violationMessage) {
|
|
91
|
-
const
|
|
92
|
-
const text = prefix + violationMessage;
|
|
93
|
+
const text = BLOCK_TITLE + violationMessage;
|
|
93
94
|
if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
|
|
94
95
|
return JSON.stringify({ decision: 'block', reason: text, systemMessage: text });
|
|
95
96
|
}
|
|
@@ -100,7 +101,7 @@ function blockPayload(ide, violationMessage) {
|
|
|
100
101
|
* warning, so the Claude-shaped payload (which carries both) prints the same sentence twice.
|
|
101
102
|
*/
|
|
102
103
|
function codexBlockPayload(violationMessage) {
|
|
103
|
-
return JSON.stringify({ decision: 'block', reason:
|
|
104
|
+
return JSON.stringify({ decision: 'block', reason: BLOCK_TITLE + violationMessage });
|
|
104
105
|
}
|
|
105
106
|
function getManifestStalenessMs() {
|
|
106
107
|
try {
|
|
@@ -13,6 +13,9 @@ function buildGetOptions(url, timeoutMs) {
|
|
|
13
13
|
path: url.pathname + url.search,
|
|
14
14
|
method: 'GET',
|
|
15
15
|
timeout: timeoutMs,
|
|
16
|
+
// A fresh socket per GET: Node 19+ keep-alive would reuse a socket left idle
|
|
17
|
+
// during the inventory disk scan, and a server FIN then looks like a failed fetch.
|
|
18
|
+
agent: false,
|
|
16
19
|
};
|
|
17
20
|
}
|
|
18
21
|
function buildBodyOptions(url, payload, method, timeoutMs) {
|
|
@@ -31,23 +34,36 @@ function buildBodyOptions(url, payload, method, timeoutMs) {
|
|
|
31
34
|
timeout: timeoutMs,
|
|
32
35
|
};
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
export function executeGet(urlStr, timeoutMs) {
|
|
37
|
+
function executeGetOnce(urlStr, timeoutMs) {
|
|
36
38
|
const url = new URL(urlStr);
|
|
37
39
|
const options = buildGetOptions(url, timeoutMs);
|
|
38
40
|
const transport = url.protocol === 'https:' ? https.request : http.request;
|
|
39
41
|
return new Promise((resolve) => {
|
|
42
|
+
let done = false;
|
|
43
|
+
const finish = (result) => {
|
|
44
|
+
if (done)
|
|
45
|
+
return;
|
|
46
|
+
done = true;
|
|
47
|
+
resolve(result);
|
|
48
|
+
};
|
|
40
49
|
const req = transport(options, (res) => {
|
|
41
50
|
let body = '';
|
|
42
51
|
res.setEncoding('utf8');
|
|
43
52
|
res.on('data', (chunk) => { body += chunk; });
|
|
44
|
-
res.on('end', () =>
|
|
53
|
+
res.on('end', () => finish({ statusCode: res.statusCode ?? 0, body }));
|
|
45
54
|
});
|
|
46
|
-
req.on('error', () =>
|
|
47
|
-
req.on('timeout', () => { req.destroy();
|
|
55
|
+
req.on('error', () => finish({ statusCode: 0, body: '' }));
|
|
56
|
+
req.on('timeout', () => { req.destroy(); finish({ statusCode: 0, body: '' }); });
|
|
48
57
|
req.end();
|
|
49
58
|
});
|
|
50
59
|
}
|
|
60
|
+
/** Execute a GET. Retries once on a dropped connection (status 0). */
|
|
61
|
+
export async function executeGet(urlStr, timeoutMs) {
|
|
62
|
+
const first = await executeGetOnce(urlStr, timeoutMs);
|
|
63
|
+
if (first.statusCode !== 0)
|
|
64
|
+
return first;
|
|
65
|
+
return executeGetOnce(urlStr, timeoutMs);
|
|
66
|
+
}
|
|
51
67
|
/** Execute a POST/PATCH request. A Buffer payload is sent as `Content-Encoding: gzip`. Rejects on network error or timeout. */
|
|
52
68
|
export function executeBody(urlStr, method, payload, timeoutMs) {
|
|
53
69
|
const url = new URL(urlStr);
|
|
@@ -2,9 +2,9 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
const HOME_DIR = homedir();
|
|
5
|
-
import { getFileCollectionPatterns, FILE_PATH_REGISTRY_FILE_PATTERNS_PATH } from '../../endpoint_client/index.js';
|
|
5
|
+
import { getFileCollectionPatterns, getSensitivePathsAuditCandidates, 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
|
-
import { runSensitivePathsAudit } from '../../log_sensitive_paths_audit.js';
|
|
7
|
+
import { CandidatesFetchError, runSensitivePathsAudit } from '../../log_sensitive_paths_audit.js';
|
|
8
8
|
import { buildPathPermissionsAuditEntry, grantPathsFromConfig } from '../../log_path_permissions_audit.js';
|
|
9
9
|
import { loadEndpointBase, getEndpointSource } from '../sender/endpoint_config.js';
|
|
10
10
|
import { hookLogReplace, hookRunLog, hookLogError, readHookLog } from './hook_logger.js';
|
|
@@ -115,6 +115,8 @@ function filterToActiveWorktrees(configFiles) {
|
|
|
115
115
|
return { configFiles: filtered, worktreeReport };
|
|
116
116
|
}
|
|
117
117
|
async function collectAllConfigFiles(endpointBase) {
|
|
118
|
+
// Same moment as file-patterns, not after the disk scan: that gap is what dropped this GET.
|
|
119
|
+
const candidatesPromise = getSensitivePathsAuditCandidates(endpointBase);
|
|
118
120
|
const patternsResponse = await fetchPatterns(endpointBase);
|
|
119
121
|
const configFiles = scanPatterns(patternsResponse);
|
|
120
122
|
const seen = new Set(configFiles.map(configFileKey));
|
|
@@ -134,26 +136,45 @@ async function collectAllConfigFiles(endpointBase) {
|
|
|
134
136
|
const skillsCliEntry = collectSkillsCliInstalled(PROJECT_ROOT, hookRunLog);
|
|
135
137
|
if (skillsCliEntry)
|
|
136
138
|
addUnseen(configFiles, seen, [skillsCliEntry]);
|
|
137
|
-
return filterToActiveWorktrees(configFiles);
|
|
139
|
+
return { ...filterToActiveWorktrees(configFiles), candidatesPromise };
|
|
138
140
|
}
|
|
139
141
|
/** Adds the sensitive paths audit to the batch; false when it could not be run (nothing added). */
|
|
140
|
-
async function addSensitivePathsAudit(endpointBase, configFiles) {
|
|
142
|
+
async function addSensitivePathsAudit(endpointBase, configFiles, candidatesPromise) {
|
|
141
143
|
hookRunLog(`running sensitive paths audit`);
|
|
142
144
|
const auditOutputDir = join(homedir(), OPTIMUSLABS_LOGS_REL, LOG_GROUP_AUDIT);
|
|
145
|
+
const prefetched = await candidatesPromise;
|
|
143
146
|
try {
|
|
144
|
-
const auditResult = await runSensitivePathsAudit(endpointBase, auditOutputDir, PROJECT_ROOT);
|
|
147
|
+
const auditResult = await runSensitivePathsAudit(endpointBase, auditOutputDir, PROJECT_ROOT, prefetched);
|
|
145
148
|
hookRunLog(`sensitive_paths_audit: ${auditResult.paths.length} path(s)`);
|
|
146
149
|
configFiles.push({ file_type: 'sensitive_paths_audit', file_path: 'sensitive_paths_audit.txt', raw_content: { paths: auditResult.paths } });
|
|
147
150
|
return true;
|
|
148
151
|
}
|
|
149
152
|
catch (err) {
|
|
150
|
-
//
|
|
151
|
-
|
|
153
|
+
// Keep the last good audit either way. Only a missed candidates GET is transient;
|
|
154
|
+
// a local write failure (EACCES, ENOSPC) is an endpoint fault and must still
|
|
155
|
+
// report a failed FunctionalRun so Sensor failing stays visible.
|
|
156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
157
|
+
hookRunLog(`sensitive_paths_audit_error: ${message} (audit not sent)`);
|
|
152
158
|
hookLogError('sensitive_paths_audit', err, { endpoint: endpointBase, output_dir: auditOutputDir });
|
|
153
|
-
|
|
159
|
+
console.error(`sensitive_paths_audit: ${message}`);
|
|
160
|
+
if (!(err instanceof CandidatesFetchError)) {
|
|
161
|
+
await reportFunctionalFailureAwait('inventory', 'sensitive_paths_audit', err);
|
|
162
|
+
}
|
|
154
163
|
return false;
|
|
155
164
|
}
|
|
156
165
|
}
|
|
166
|
+
async function collectInventory(endpointBase) {
|
|
167
|
+
const collected = await collectAllConfigFiles(endpointBase);
|
|
168
|
+
const sensitiveAudited = await addSensitivePathsAudit(endpointBase, collected.configFiles, collected.candidatesPromise);
|
|
169
|
+
ensureCursorUserSettingsSnapshotInBatch(collected.configFiles);
|
|
170
|
+
if (sensitiveAudited) {
|
|
171
|
+
addPathPermissionsAudit(collected.configFiles);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
hookRunLog('path_permissions_audit: skipped (no sensitive paths audit this run)');
|
|
175
|
+
}
|
|
176
|
+
return collected;
|
|
177
|
+
}
|
|
157
178
|
/** POSIX mode of every collected file, plus granted folders (and their ancestors) and sensitive audit hits within a budget. stat() only. */
|
|
158
179
|
function addPathPermissionsAudit(configFiles) {
|
|
159
180
|
const visited = [];
|
|
@@ -251,7 +272,7 @@ async function main() {
|
|
|
251
272
|
let configFiles;
|
|
252
273
|
let worktreeReport = [];
|
|
253
274
|
try {
|
|
254
|
-
const collected = await
|
|
275
|
+
const collected = await collectInventory(endpointBase);
|
|
255
276
|
configFiles = collected.configFiles;
|
|
256
277
|
worktreeReport = collected.worktreeReport;
|
|
257
278
|
}
|
|
@@ -259,16 +280,6 @@ async function main() {
|
|
|
259
280
|
hookRunLog(`patterns_error: ${err instanceof Error ? err.message : String(err)}`);
|
|
260
281
|
throw err;
|
|
261
282
|
}
|
|
262
|
-
const sensitiveAudited = await addSensitivePathsAudit(endpointBase, configFiles);
|
|
263
|
-
ensureCursorUserSettingsSnapshotInBatch(configFiles);
|
|
264
|
-
if (sensitiveAudited) {
|
|
265
|
-
addPathPermissionsAudit(configFiles);
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
// Built without the sensitive paths, it would replace the backend's last good permissions
|
|
269
|
-
// audit and drop those paths' modes; skip it so both audits stay as last sent.
|
|
270
|
-
hookRunLog('path_permissions_audit: skipped (no sensitive paths audit this run)');
|
|
271
|
-
}
|
|
272
283
|
const fileTypes = [...new Set(configFiles.map((c) => c.file_type))].join(', ');
|
|
273
284
|
hookRunLog(`collected ${configFiles.length} config file(s) file_types=${fileTypes}`);
|
|
274
285
|
for (const c of configFiles) {
|
|
@@ -333,4 +344,4 @@ async function logSingleFile(filePath) {
|
|
|
333
344
|
}
|
|
334
345
|
return sendConfigFile({ file_type: fileType, file_path: filePath, raw_content: rawContent }, hardwareUuid, authKey);
|
|
335
346
|
}
|
|
336
|
-
export { main, logSingleFile, createSignature, canonicalizePayload, sendConfigFile, sendConfigFilesBatch, BATCH_CHUNK_SIZE };
|
|
347
|
+
export { main, logSingleFile, createSignature, canonicalizePayload, sendConfigFile, sendConfigFilesBatch, BATCH_CHUNK_SIZE, addSensitivePathsAudit, };
|
|
@@ -9,10 +9,17 @@
|
|
|
9
9
|
import { existsSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
-
import { getSensitivePathsAuditCandidates } from './endpoint_client/index.js';
|
|
12
|
+
import { getSensitivePathsAuditCandidates, } from './endpoint_client/index.js';
|
|
13
13
|
import { OPTIMUSLABS_LOGS_REL, LOG_GROUP_AUDIT } from './bootstrap_constants.js';
|
|
14
14
|
import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
|
|
15
15
|
const AUDIT_FILENAME = 'sensitive_paths_audit.txt';
|
|
16
|
+
/** Thrown when the candidates GET returned nothing usable. Transient: do not mark the sensor as failing. */
|
|
17
|
+
export class CandidatesFetchError extends Error {
|
|
18
|
+
constructor(endpointBase) {
|
|
19
|
+
super(`could not fetch sensitive path candidates from ${endpointBase}`);
|
|
20
|
+
this.name = 'CandidatesFetchError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
16
23
|
function expandPath(template, cwd) {
|
|
17
24
|
if (template.startsWith('~/')) {
|
|
18
25
|
return join(homedir(), template.slice(2));
|
|
@@ -66,10 +73,13 @@ export function writeSensitivePathsAudit(outputDir, pathTemplates, cwd = process
|
|
|
66
73
|
* Throws when the candidates can't be fetched: an audit run without them would report no
|
|
67
74
|
* sensitive paths, and sending it would replace the machine's last good audit on the backend.
|
|
68
75
|
*/
|
|
69
|
-
export async function runSensitivePathsAudit(endpointBase, outputDir = join(homedir(), OPTIMUSLABS_LOGS_REL, LOG_GROUP_AUDIT), cwd = process.cwd()) {
|
|
70
|
-
|
|
76
|
+
export async function runSensitivePathsAudit(endpointBase, outputDir = join(homedir(), OPTIMUSLABS_LOGS_REL, LOG_GROUP_AUDIT), cwd = process.cwd(), prefetched) {
|
|
77
|
+
let response = prefetched;
|
|
78
|
+
if (response == null) {
|
|
79
|
+
response = await getSensitivePathsAuditCandidates(endpointBase);
|
|
80
|
+
}
|
|
71
81
|
if (!response) {
|
|
72
|
-
throw new
|
|
82
|
+
throw new CandidatesFetchError(endpointBase);
|
|
73
83
|
}
|
|
74
84
|
const paths = writeSensitivePathsAudit(outputDir, response.paths, cwd);
|
|
75
85
|
const outPath = join(outputDir, AUDIT_FILENAME);
|