@optimuslabs/harness-map-staging 1.5.34 → 1.5.36
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.
- package/dist/approved_tenant_gate.js +62 -96
- package/dist/compliance_prompt_gate.js +15 -13
- package/dist/endpoint_client/types.js +4 -2
- package/dist/log_config_files/collection/allowed_keys.js +61 -0
- package/dist/log_config_files/collection/config_collector.js +24 -2
- package/dist/log_config_files/collection/copilot_account.js +149 -0
- package/dist/log_config_files/runtime/main_runner.js +58 -38
- package/dist/log_config_files/runtime/skill_block.js +188 -0
- package/dist/log_config_files/runtime/skill_block_sessions.js +86 -0
- package/dist/pii_scrub.js +15 -0
- package/package.json +1 -1
|
@@ -87,7 +87,7 @@ export function readClaudeSignedInTenant(env = process.env) {
|
|
|
87
87
|
/**
|
|
88
88
|
* Block messages, one shape for every agent (Optimus Labs' prompt-block wording):
|
|
89
89
|
* "This device only allows <agent> <under/on/in> the <approved> <kind>. You're signed in to
|
|
90
|
-
* <what>. <how to switch>." With nothing approved yet: "This device doesn't allow <agent> signed in
|
|
90
|
+
* <what>. <how to switch>." With nothing approved yet: "This device doesn't allow <agent> to be signed in
|
|
91
91
|
* to <what>." and the same last sentence.
|
|
92
92
|
*/
|
|
93
93
|
const TENANT_WORDS = {
|
|
@@ -96,6 +96,8 @@ const TENANT_WORDS = {
|
|
|
96
96
|
cursor: { name: 'Cursor', kind: 'team', prep: 'on' },
|
|
97
97
|
codex: { name: 'Codex', kind: 'workspace', prep: 'in' },
|
|
98
98
|
};
|
|
99
|
+
/** How a block that names the organization's own rule begins; such a block needs no title above it. */
|
|
100
|
+
export const ORGANIZATION_LEAD = 'Your organization';
|
|
99
101
|
/** A tenant's name as the admin approved it, or its id when it has none. */
|
|
100
102
|
function nameOf(t) {
|
|
101
103
|
return (t.tenant_name || '').trim() || t.tenant_id.trim();
|
|
@@ -108,35 +110,54 @@ function emailDomain(email) {
|
|
|
108
110
|
function capitalised(word) {
|
|
109
111
|
return word ? word[0].toUpperCase() + word.slice(1) : word;
|
|
110
112
|
}
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
+
/**
|
|
114
|
+
* What to pick to get back in: the one approved tenant, one of several, or the company's own,
|
|
115
|
+
* named after `company` when it is known ("your Optimus Labs organization").
|
|
116
|
+
*/
|
|
117
|
+
function choice(approved, kind, company) {
|
|
113
118
|
if (approved.length === 1)
|
|
114
119
|
return approved[0];
|
|
115
|
-
return approved.length > 1 ? 'one of them' : `your company's ${kind}`;
|
|
120
|
+
return approved.length > 1 ? 'one of them' : `your ${company || "company's"} ${kind}`;
|
|
116
121
|
}
|
|
117
|
-
function switchStep(agent, approved) {
|
|
122
|
+
function switchStep(agent, approved, company) {
|
|
118
123
|
const kind = TENANT_WORDS[agent].kind;
|
|
119
124
|
if (agent === 'claude_desktop')
|
|
120
|
-
return `Switch Claude Desktop to ${choice(approved, kind)}.`;
|
|
121
|
-
if (agent === 'codex')
|
|
122
|
-
return `Run codex logout, then codex login and choose ${choice(approved, kind)}.`;
|
|
125
|
+
return `Switch Claude Desktop to ${choice(approved, kind, company)}.`;
|
|
126
|
+
if (agent === 'codex') {
|
|
127
|
+
return `Run codex logout, then codex login and choose ${choice(approved, kind, company)}.`;
|
|
128
|
+
}
|
|
123
129
|
if (agent === 'cursor') {
|
|
124
|
-
|
|
125
|
-
return `Sign out of Cursor and sign in with your ${approved[0]} team account.`;
|
|
130
|
+
// Reached only without one account to name (see cursorTeamViolation).
|
|
126
131
|
return approved.length > 1
|
|
127
132
|
? 'Sign out of Cursor and sign in with an account on one of those teams.'
|
|
128
133
|
: "Sign out of Cursor and sign in with your company's team account.";
|
|
129
134
|
}
|
|
130
|
-
return `Run /login and choose ${choice(approved, kind)}.`;
|
|
135
|
+
return `Run /login and choose ${choice(approved, kind, company)}.`;
|
|
131
136
|
}
|
|
132
|
-
/**
|
|
133
|
-
|
|
137
|
+
/**
|
|
138
|
+
* The block message for `agent` signed in to `signedIn` (already worded) with these approved.
|
|
139
|
+
* `company` names the company when the agent has nothing approved of its own; '' says "company's".
|
|
140
|
+
*/
|
|
141
|
+
export function tenantBlockMessage(agent, approved, signedIn, company = '') {
|
|
134
142
|
const w = TENANT_WORDS[agent];
|
|
135
143
|
const lead = approved.length
|
|
136
144
|
? `This device only allows ${w.name} ${w.prep} the ${approved.join(' or ')} ${w.kind}. ` +
|
|
137
145
|
`You're signed in to ${signedIn}.`
|
|
138
|
-
: `This device doesn't allow ${w.name} signed in to ${signedIn}.`;
|
|
139
|
-
return `${lead} ${switchStep(agent, approved)}`;
|
|
146
|
+
: `This device doesn't allow ${w.name} to be signed in to ${signedIn}.`;
|
|
147
|
+
return `${lead} ${switchStep(agent, approved, company)}`;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The company's name for "your <company> team account": the one name its approved tenants go by,
|
|
151
|
+
* across every agent (a Cursor login on no approved team is pointed at the company's Claude
|
|
152
|
+
* organization's name). '' when there is none, or several. Personal accounts (`@<domain>`) and a
|
|
153
|
+
* person's own org ("ada@gmail.com's Organization") are not the company's.
|
|
154
|
+
*/
|
|
155
|
+
export function companyName(approved) {
|
|
156
|
+
const names = new Set(Object.values(approved ?? {})
|
|
157
|
+
.flatMap((policy) => tenantIds(policy?.tenants))
|
|
158
|
+
.filter((t) => !t.tenant_id.trim().startsWith('@') && !/'s organization$/i.test(nameOf(t)))
|
|
159
|
+
.map(nameOf));
|
|
160
|
+
return names.size === 1 ? [...names][0] : '';
|
|
140
161
|
}
|
|
141
162
|
const CLAUDE_PERSONAL_PLANS = new Set(['free', 'pro', 'max']);
|
|
142
163
|
/** "a personal Max account (gmail.com)" or "the Beta Corp organization". */
|
|
@@ -159,23 +180,18 @@ function tenantIds(tenants) {
|
|
|
159
180
|
/**
|
|
160
181
|
* The block message when `signedIn` may not be used for `agent`, else null.
|
|
161
182
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
183
|
+
* Only a tenant the organization blocked (marked unapproved) is refused. One never decided is
|
|
184
|
+
* awaiting review and gets through, as an approved one does. Ids compare exactly, as the backend
|
|
185
|
+
* does -- vendor ids are not ours to case-fold.
|
|
165
186
|
*/
|
|
166
187
|
export function approvedTenantViolation(agent, approved, signedIn) {
|
|
167
188
|
if (!signedIn)
|
|
168
189
|
return null;
|
|
169
190
|
const policy = approved?.[agent];
|
|
170
|
-
const approvedTenants = tenantIds(policy?.tenants);
|
|
171
|
-
const unapprovedTenants = tenantIds(policy?.unapproved_tenants);
|
|
172
191
|
const signedInId = signedIn.tenant_id.trim();
|
|
173
|
-
|
|
174
|
-
const blocked = isIn(unapprovedTenants) ||
|
|
175
|
-
((policy?.enforced || approvedTenants.length > 0) && !isIn(approvedTenants));
|
|
176
|
-
if (!blocked)
|
|
192
|
+
if (!tenantIds(policy?.unapproved_tenants).some((t) => t.tenant_id.trim() === signedInId))
|
|
177
193
|
return null;
|
|
178
|
-
return tenantBlockMessage(agent,
|
|
194
|
+
return tenantBlockMessage(agent, tenantIds(policy?.tenants).map(nameOf), claudeSignedInText(signedIn), companyName(approved));
|
|
179
195
|
}
|
|
180
196
|
/**
|
|
181
197
|
* The tenants a Cursor login is in: its teams, or, on no team, `@` and its email domain. Mirrors the
|
|
@@ -203,42 +219,30 @@ export function readCursorSignedInTeams(home = homedir()) {
|
|
|
203
219
|
/**
|
|
204
220
|
* Why a login in these tenants may not be used, or null when it may. Shared by every agent whose
|
|
205
221
|
* login is a list of tenants (Cursor's teams) or one tenant that may be a personal account's
|
|
206
|
-
* `@<domain>` (Codex)
|
|
207
|
-
*
|
|
208
|
-
*
|
|
222
|
+
* `@<domain>` (Codex). Blocked only when every tenant the login is in was blocked (marked
|
|
223
|
+
* unapproved): Cursor admits a login when any of its teams is allowed, and a tenant awaiting review
|
|
224
|
+
* is allowed, as an approved one is.
|
|
209
225
|
*/
|
|
210
226
|
function listedTenantViolation(policy, tenants) {
|
|
211
|
-
const
|
|
212
|
-
if (tenants.
|
|
227
|
+
const unapprovedList = tenantIds(policy?.unapproved_tenants);
|
|
228
|
+
if (!tenants.length || !tenants.every((id) => isListed(unapprovedList, id)))
|
|
213
229
|
return null;
|
|
214
|
-
|
|
215
|
-
if (!isRefused(tenants, enforced, tenantIds(policy?.unapproved_tenants)))
|
|
216
|
-
return null;
|
|
217
|
-
return { approved: approvedList.map(nameOf) };
|
|
230
|
+
return { approved: tenantIds(policy?.tenants).map(nameOf) };
|
|
218
231
|
}
|
|
219
232
|
function isListed(list, id) {
|
|
220
233
|
return list.some((t) => t.tenant_id.trim() === id);
|
|
221
234
|
}
|
|
222
|
-
/** Whether a login in these tenants is refused: one marked unapproved always; any other only once
|
|
223
|
-
* the agent is enforced (an older backend that sends no flag, with nothing approved, lets it pass). */
|
|
224
|
-
function isRefused(tenants, enforced, unapprovedList) {
|
|
225
|
-
if (tenants.some((id) => isListed(unapprovedList, id)))
|
|
226
|
-
return true;
|
|
227
|
-
return enforced;
|
|
228
|
-
}
|
|
229
235
|
/** A tenant's name from the organization's lists, or '' when it has none there. */
|
|
230
236
|
function knownName(policy, id) {
|
|
231
237
|
const all = [...tenantIds(policy?.tenants), ...tenantIds(policy?.unapproved_tenants)];
|
|
232
238
|
const hit = all.find((t) => t.tenant_id.trim() === id);
|
|
233
239
|
return (hit?.tenant_name || '').trim();
|
|
234
240
|
}
|
|
235
|
-
/** "a personal account
|
|
241
|
+
/** "a personal account", "the Beta team" or "team 9876". */
|
|
236
242
|
function cursorSignedInText(signedIn, policy) {
|
|
237
243
|
const tenants = cursorTenants(signedIn);
|
|
238
|
-
if (!tenants.length || tenants[0].startsWith('@'))
|
|
239
|
-
|
|
240
|
-
return `a personal account on no team${domain ? ` (${domain})` : ''}`;
|
|
241
|
-
}
|
|
244
|
+
if (!tenants.length || tenants[0].startsWith('@'))
|
|
245
|
+
return 'a personal account';
|
|
242
246
|
return tenants
|
|
243
247
|
.map((id) => (knownName(policy, id) ? `the ${knownName(policy, id)} team` : `team ${id}`))
|
|
244
248
|
.join(' and ');
|
|
@@ -255,13 +259,22 @@ function codexSignedInText(signedIn, tenants, policy) {
|
|
|
255
259
|
return name ? `the ${name} workspace` : `workspace ${tenant}`;
|
|
256
260
|
}
|
|
257
261
|
/** The block message when Cursor's login may not be used, else null. */
|
|
258
|
-
export function cursorTeamViolation(approved, signedIn) {
|
|
262
|
+
export function cursorTeamViolation(approved, signedIn, teamDomains = []) {
|
|
259
263
|
if (!signedIn)
|
|
260
264
|
return null;
|
|
261
265
|
const v = listedTenantViolation(approved?.cursor, cursorTenants(signedIn));
|
|
262
266
|
if (!v)
|
|
263
267
|
return null;
|
|
264
|
-
|
|
268
|
+
const signedInText = cursorSignedInText(signedIn, approved?.cursor);
|
|
269
|
+
// The one account to point at: the approved team, else the company's name. Several approved
|
|
270
|
+
// teams, or no name known, keep the generic message.
|
|
271
|
+
const account = v.approved.length === 1 ? v.approved[0] : v.approved.length ? '' : companyName(approved);
|
|
272
|
+
if (!account)
|
|
273
|
+
return tenantBlockMessage('cursor', v.approved, signedInText);
|
|
274
|
+
const domains = teamDomains.length ? ` (${teamDomains.join(', ')})` : '';
|
|
275
|
+
return (`${ORGANIZATION_LEAD} does not allow Cursor to be signed in to ${signedInText}. ` +
|
|
276
|
+
`The approved organization's team account for Cursor is ${account}${domains}. ` +
|
|
277
|
+
'Please sign out and sign in with the approved account to use Cursor.');
|
|
265
278
|
}
|
|
266
279
|
/**
|
|
267
280
|
* Codex plans that are personal, mirroring the backend's `CodexPlans.PERSONAL`. On one of these
|
|
@@ -307,34 +320,7 @@ export function codexTenantViolation(approved, signedIn) {
|
|
|
307
320
|
const v = listedTenantViolation(approved?.codex, tenants);
|
|
308
321
|
if (!v)
|
|
309
322
|
return null;
|
|
310
|
-
return tenantBlockMessage('codex', v.approved, codexSignedInText(signedIn, tenants, approved?.codex));
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Whether a personal account's `@<domain>` tenant is on a team domain: an allowlisted domain that
|
|
314
|
-
* someone on an approved team or enterprise tenant signs in with (any vendor's). A subdomain counts.
|
|
315
|
-
*/
|
|
316
|
-
export function onTeamDomain(tenant, teamDomains) {
|
|
317
|
-
if (!tenant.startsWith('@') || !teamDomains?.length)
|
|
318
|
-
return false;
|
|
319
|
-
const domain = tenant.slice(1);
|
|
320
|
-
return teamDomains.some((d) => domain === d || domain.endsWith(`.${d}`));
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* The block message for Cursor's login, else null. A login on a team is checked against the
|
|
324
|
-
* approved teams. A personal account is only checked once its email is on a team domain; then it
|
|
325
|
-
* is blocked unless an admin approved that personal tenant (`@<domain>`).
|
|
326
|
-
*/
|
|
327
|
-
export function cursorLoginViolation(approved, signedIn, teamDomains) {
|
|
328
|
-
if (!signedIn)
|
|
329
|
-
return null;
|
|
330
|
-
const checked = signedIn.team_ids.length > 0 || cursorTenants(signedIn).some((t) => onTeamDomain(t, teamDomains));
|
|
331
|
-
return checked ? cursorTeamViolation(approved, signedIn) : null;
|
|
332
|
-
}
|
|
333
|
-
/** The block message for Codex's login, else null; a personal plan is checked as Cursor's is. */
|
|
334
|
-
export function codexLoginViolation(approved, signedIn, teamDomains) {
|
|
335
|
-
const tenants = signedIn ? codexTenants(signedIn) : null;
|
|
336
|
-
const checked = tenants?.some((t) => !t.startsWith('@') || onTeamDomain(t, teamDomains));
|
|
337
|
-
return checked ? codexTenantViolation(approved, signedIn) : null;
|
|
323
|
+
return tenantBlockMessage('codex', v.approved, codexSignedInText(signedIn, tenants, approved?.codex), companyName(approved));
|
|
338
324
|
}
|
|
339
325
|
/**
|
|
340
326
|
* The org Claude Desktop is signed into now, for its Code tab's prompts, or null when unknown.
|
|
@@ -378,26 +364,6 @@ export function unapprovedEmailDomainViolation(email, unapproved) {
|
|
|
378
364
|
return (`This device doesn't allow accounts on ${hit}. You're signed in as ${address}. ` +
|
|
379
365
|
'Sign in with your work account.');
|
|
380
366
|
}
|
|
381
|
-
/**
|
|
382
|
-
* The block message when a login's email is not on the organization's email allowlist, else null.
|
|
383
|
-
*
|
|
384
|
-
* The allowlist is the organization's approved email domains; a domain covers its subdomains
|
|
385
|
-
* (`mail.acme.com` under `acme.com`). Null, so the login is allowed, when no allowlist has been
|
|
386
|
-
* synced to this machine yet or the login has no email we can read.
|
|
387
|
-
*/
|
|
388
|
-
export function emailAllowlistViolation(email, allowed) {
|
|
389
|
-
if (!Array.isArray(allowed))
|
|
390
|
-
return null;
|
|
391
|
-
const address = (email ?? '').trim().toLowerCase();
|
|
392
|
-
const at = address.lastIndexOf('@');
|
|
393
|
-
if (at < 0 || at === address.length - 1)
|
|
394
|
-
return null;
|
|
395
|
-
const domain = address.slice(at + 1);
|
|
396
|
-
if (allowed.some((d) => domain === d || domain.endsWith(`.${d}`)))
|
|
397
|
-
return null;
|
|
398
|
-
return (`This device only allows accounts on your organization's email domains. You're signed in as ${address}. ` +
|
|
399
|
-
'Sign in with your work account.');
|
|
400
|
-
}
|
|
401
367
|
/** The id an agent is approved under on the Agents page: Desktop's Code tab runs Claude Code. */
|
|
402
368
|
const APPROVAL_ID = { claude_desktop: 'claude' };
|
|
403
369
|
const AGENT_NAME = {
|
|
@@ -24,7 +24,7 @@ import { tryResolveHardwareUuid } from './log_config_files/runtime/hardware_uuid
|
|
|
24
24
|
import { cursorAutofixDefersInlineVerify, syncRemediations, } from './log_config_files/runtime/remediation_sync.js';
|
|
25
25
|
import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
|
|
26
26
|
import { formatRemediationChangePreviewForApplied } from './remediation_change_preview.js';
|
|
27
|
-
import { agentApprovalViolation, approvedTenantViolation,
|
|
27
|
+
import { agentApprovalViolation, approvedTenantViolation, codexTenantViolation, cursorTeamViolation, ORGANIZATION_LEAD, readClaudeDesktopSignedInTenant, readClaudeSignedInTenant, readCodexSignedIn, readCursorSignedInTeams, unapprovedEmailDomainViolation, } from './approved_tenant_gate.js';
|
|
28
28
|
const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
29
29
|
/** After verified (or failed verify), push enforcement + session log immediately — do not wait for background runner. */
|
|
30
30
|
async function flushRemediationOutcomeToServer(complianceLogPath) {
|
|
@@ -89,8 +89,12 @@ function printAllowWithAdvisory(ide, advisoryMessage) {
|
|
|
89
89
|
}
|
|
90
90
|
/** Every block reads as an Optimus Labs notice: a title line, then the message. */
|
|
91
91
|
const BLOCK_TITLE = 'Optimus Labs\n\n';
|
|
92
|
+
/** The block as shown: titled, unless it already says it is the organization's rule. */
|
|
93
|
+
function titled(violationMessage) {
|
|
94
|
+
return violationMessage.startsWith(ORGANIZATION_LEAD) ? violationMessage : BLOCK_TITLE + violationMessage;
|
|
95
|
+
}
|
|
92
96
|
function blockPayload(ide, violationMessage) {
|
|
93
|
-
const text =
|
|
97
|
+
const text = titled(violationMessage);
|
|
94
98
|
if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
|
|
95
99
|
return JSON.stringify({ decision: 'block', reason: text, systemMessage: text });
|
|
96
100
|
}
|
|
@@ -101,7 +105,7 @@ function blockPayload(ide, violationMessage) {
|
|
|
101
105
|
* warning, so the Claude-shaped payload (which carries both) prints the same sentence twice.
|
|
102
106
|
*/
|
|
103
107
|
function codexBlockPayload(violationMessage) {
|
|
104
|
-
return JSON.stringify({ decision: 'block', reason:
|
|
108
|
+
return JSON.stringify({ decision: 'block', reason: titled(violationMessage) });
|
|
105
109
|
}
|
|
106
110
|
function getManifestStalenessMs() {
|
|
107
111
|
try {
|
|
@@ -264,7 +268,7 @@ function loginEmail(agent) {
|
|
|
264
268
|
return readCursorSignedInTeams()?.email ?? '';
|
|
265
269
|
return null;
|
|
266
270
|
}
|
|
267
|
-
/** Why this agent's signed-in tenant is
|
|
271
|
+
/** Why this agent's signed-in tenant is blocked, or null when it is not (or cannot be read). */
|
|
268
272
|
function tenantViolation(agent) {
|
|
269
273
|
const { approved_tenants: approved, team_email_domains: teamDomains } = readRemediationInstructionsFile();
|
|
270
274
|
if (agent === 'claude')
|
|
@@ -272,12 +276,10 @@ function tenantViolation(agent) {
|
|
|
272
276
|
if (agent === 'claude_desktop') {
|
|
273
277
|
return approvedTenantViolation('claude_desktop', approved, readClaudeDesktopSignedInTenant());
|
|
274
278
|
}
|
|
275
|
-
// A personal account (Cursor on no team, Codex on a personal plan) is left to the email allowlist
|
|
276
|
-
// until its domain is a team domain; then it needs its personal tenant approved.
|
|
277
279
|
if (agent === 'codex')
|
|
278
|
-
return
|
|
280
|
+
return codexTenantViolation(approved, readCodexSignedIn());
|
|
279
281
|
if (agent === 'cursor')
|
|
280
|
-
return
|
|
282
|
+
return cursorTeamViolation(approved, readCursorSignedInTeams(), teamDomains);
|
|
281
283
|
return null;
|
|
282
284
|
}
|
|
283
285
|
/** The block for a login: which check refused it and why, or null when every check passes. */
|
|
@@ -288,16 +290,16 @@ function loginBlock(agent, email) {
|
|
|
288
290
|
return { event: 'prompt_gate_block_unapproved_agent', reason: agentMsg };
|
|
289
291
|
if (email === null)
|
|
290
292
|
return null;
|
|
291
|
-
const emailMsg =
|
|
293
|
+
const emailMsg = unapprovedEmailDomainViolation(email, synced.unapproved_email_domains);
|
|
292
294
|
if (emailMsg)
|
|
293
|
-
return { event: '
|
|
295
|
+
return { event: 'prompt_gate_block_unapproved_email_domain', reason: emailMsg };
|
|
294
296
|
const tenantMsg = tenantViolation(agent);
|
|
295
297
|
return tenantMsg ? { event: 'prompt_gate_block_unapproved_tenant', reason: tenantMsg } : null;
|
|
296
298
|
}
|
|
297
299
|
/**
|
|
298
|
-
* Block the prompt
|
|
299
|
-
*
|
|
300
|
-
*
|
|
300
|
+
* Block the prompt when the agent is not approved, or the login's email domain or its tenant was
|
|
301
|
+
* blocked (all as remediation sync stored them), printing the block payload; true when it did. A
|
|
302
|
+
* domain or tenant awaiting review gets through. A login whose email or tenant cannot be read passes that check, and a
|
|
301
303
|
* machine that has synced nothing yet is not blocked.
|
|
302
304
|
*/
|
|
303
305
|
function blockedByLogin(agent, ide) {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
/** Path suffix for file collection patterns (backend: api/file-path-registry/file-patterns/).
|
|
2
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
307
|
-
if (
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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;
|