@optimuslabs/harness-map-staging 1.5.38 → 1.5.40
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/compliance_prompt_gate.js +8 -12
- package/dist/log_config_files/runtime/management_storage.js +15 -4
- package/dist/log_config_files/runtime/remediation_sync.js +4 -1
- package/dist/log_config_files/runtime/skill_block.js +5 -5
- package/dist/log_config_files/runtime/skill_block_sessions.js +62 -9
- package/package.json +1 -1
|
@@ -22,7 +22,7 @@ import { parseJsonWithJsoncFallback } from './log_config_files/readers/file_read
|
|
|
22
22
|
import { PORTABLE_CURSOR_USER_SETTINGS, resolveRemediationConfigPath, resolveRemediationUploadFileType, } from './log_config_files/runtime/remediation_config_path.js';
|
|
23
23
|
import { tryResolveHardwareUuid } from './log_config_files/runtime/hardware_uuid.js';
|
|
24
24
|
import { cursorAutofixDefersInlineVerify, syncRemediations, } from './log_config_files/runtime/remediation_sync.js';
|
|
25
|
-
import { blockedCommandMessage, blockedSkillOfCommand, claudeProcess, recordSessionProcess, stoppedSessionMessage,
|
|
25
|
+
import { blockedCommandMessage, blockedSkillOfCommand, claudeProcess, recordSessionProcess, stoppedSessionMessage, openSkillBlockWindow, stoppingSkills, } from './log_config_files/runtime/skill_block_sessions.js';
|
|
26
26
|
import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
|
|
27
27
|
import { agentAccessDecision, approvalId } from './agent_access/decision.js';
|
|
28
28
|
import { launchAgentAccessWindow } from './agent_access/window_runner.js';
|
|
@@ -103,7 +103,9 @@ function titled(violationMessage) {
|
|
|
103
103
|
function blockPayload(ide, violationMessage, windowShown = false) {
|
|
104
104
|
const text = titled(violationMessage);
|
|
105
105
|
if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
|
|
106
|
-
|
|
106
|
+
// Claude's hook skips its own alert too, and takes the field out before Claude Code reads it.
|
|
107
|
+
const flag = windowShown && ide === 'claude' ? { __optimus_window: true } : {};
|
|
108
|
+
return JSON.stringify({ decision: 'block', reason: text, systemMessage: text, ...flag });
|
|
107
109
|
}
|
|
108
110
|
return JSON.stringify({ continue: false, user_message: text, ...(windowShown ? { __optimus_window: true } : {}) });
|
|
109
111
|
}
|
|
@@ -348,12 +350,6 @@ async function syncApprovals() {
|
|
|
348
350
|
// Network or auth failure — fall back to local file state.
|
|
349
351
|
}
|
|
350
352
|
}
|
|
351
|
-
/** Open the skill block window beside the block, in the background, when the sync sent its words. */
|
|
352
|
-
function openSkillBlockWindow(skill) {
|
|
353
|
-
const words = skillBlockWindow(skill);
|
|
354
|
-
if (words)
|
|
355
|
-
launchAgentAccessWindow('claude', words);
|
|
356
|
-
}
|
|
357
353
|
/**
|
|
358
354
|
* Skill block killswitch, Claude Code only for now: refuse the prompt of a session that loaded a
|
|
359
355
|
* skill which is now blocked, or whose `claude` process did in an earlier session (a `/clear` keeps
|
|
@@ -372,16 +368,16 @@ function blockedBySkillBlock(agent, ide, sessionId) {
|
|
|
372
368
|
const skills = stoppingSkills(sessionId, proc);
|
|
373
369
|
if (skills.length > 0) {
|
|
374
370
|
logRemediationApplyFailure('skill_block_session_stopped', { ide, agent, skills: skills.join(',') });
|
|
375
|
-
openSkillBlockWindow(skills[0]);
|
|
376
|
-
console.log(blockPayload(ide, stoppedSessionMessage(skills)));
|
|
371
|
+
const windowShown = openSkillBlockWindow(skills[0]);
|
|
372
|
+
console.log(blockPayload(ide, stoppedSessionMessage(skills), windowShown));
|
|
377
373
|
return true;
|
|
378
374
|
}
|
|
379
375
|
const typed = blockedSkillOfCommand(process.env.OPTIMUS_PROMPT_FIRST_LINE || '', () => proc?.startedAt ?? null);
|
|
380
376
|
if (!typed)
|
|
381
377
|
return false;
|
|
382
378
|
logRemediationApplyFailure('skill_block_command_refused', { ide, agent, skills: typed });
|
|
383
|
-
openSkillBlockWindow(typed);
|
|
384
|
-
console.log(blockPayload(ide, blockedCommandMessage(typed)));
|
|
379
|
+
const windowShown = openSkillBlockWindow(typed);
|
|
380
|
+
console.log(blockPayload(ide, blockedCommandMessage(typed), windowShown));
|
|
385
381
|
return true;
|
|
386
382
|
}
|
|
387
383
|
/** Agents whose remediations are JSON files written to disk at once (no deferred SQLite apply). */
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Paths and JSON persistence for ~/.optimuslabs/management:
|
|
3
3
|
* - remediation_instructions.json (remediations[])
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
7
|
import { dirname, join } from 'node:path';
|
|
7
8
|
import { homedir } from 'node:os';
|
|
8
9
|
import { OPTIMUSLABS_MANAGEMENT_REL, OPTIMUSLABS_LOGS_REL } from '../../bootstrap_constants.js';
|
|
@@ -123,13 +124,23 @@ export function writeFileCollectionVscdbContract(contract) {
|
|
|
123
124
|
return;
|
|
124
125
|
atomicWriteJson(getFileCollectionVscdbContractPath(), sanitized);
|
|
125
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Write `data` as JSON to `filePath` in one step: to a temporary file of this write's own, then a
|
|
129
|
+
* rename. Two hooks writing at once (two Claude Code windows, the gate and the compliance runner)
|
|
130
|
+
* each use their own temporary file, so neither can rename the other's half-written one into place.
|
|
131
|
+
*/
|
|
126
132
|
export function atomicWriteJson(filePath, data) {
|
|
127
133
|
const dir = dirname(filePath);
|
|
128
134
|
if (!existsSync(dir))
|
|
129
135
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
130
|
-
const tmp = `${filePath}.tmp`;
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
const tmp = `${filePath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
137
|
+
try {
|
|
138
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
139
|
+
renameSync(tmp, filePath);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
rmSync(tmp, { force: true });
|
|
143
|
+
}
|
|
133
144
|
}
|
|
134
145
|
function parseInstructionsRaw(raw) {
|
|
135
146
|
try {
|
|
@@ -6,6 +6,7 @@ import { parseJsonWithJsoncFallback, parseTomlText, stringifyToml } from '../rea
|
|
|
6
6
|
import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
|
|
7
7
|
import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeApprovedTenants, writeUnapprovedEmailDomains, writeAllowedEmailDomains, writeTeamEmailDomains, writeApprovedAgents, writeAgentAccessWindows, writeRemediationInstructionsFile, writeSkillBlockSessions, writeSkillBlockWindows, writeSkillBlocks, } from './management_storage.js';
|
|
8
8
|
import { applySkillBlocks } from './skill_block.js';
|
|
9
|
+
import { openQuarantineWindows } from './skill_block_sessions.js';
|
|
9
10
|
import { readStoredAuthKey } from '../auth/auth_key_store.js';
|
|
10
11
|
import { ensureAuthentication } from '../auth/auth_flow.js';
|
|
11
12
|
import { createSignature } from '../sender/signing.js';
|
|
@@ -1711,7 +1712,9 @@ function enforceSkillBlocks(syncResult) {
|
|
|
1711
1712
|
storeSkillBlockSessions(syncResult.skill_block_sessions);
|
|
1712
1713
|
storeSkillBlockWindows(syncResult.skill_block_windows);
|
|
1713
1714
|
try {
|
|
1714
|
-
applySkillBlocks(entries);
|
|
1715
|
+
const outcome = applySkillBlocks(entries);
|
|
1716
|
+
// Tell the user when a skill is quarantined, whether or not a session of theirs loaded it.
|
|
1717
|
+
openQuarantineWindows(outcome.quarantined, entries);
|
|
1715
1718
|
}
|
|
1716
1719
|
catch (err) {
|
|
1717
1720
|
const msg = `skill_block_error: ${errorText(err)}`;
|
|
@@ -89,7 +89,7 @@ function makeStub(dir) {
|
|
|
89
89
|
chmodSync(dir, 0o000);
|
|
90
90
|
}
|
|
91
91
|
/** Every file of `dir` with its hash, and every symlink with its target, sorted by path. */
|
|
92
|
-
function
|
|
92
|
+
function readFolderHashes(dir, prefix = '') {
|
|
93
93
|
const files = [];
|
|
94
94
|
for (const name of readdirSync(dir).sort()) {
|
|
95
95
|
const full = join(dir, name);
|
|
@@ -98,7 +98,7 @@ function hashFolder(dir, prefix = '') {
|
|
|
98
98
|
if (stat.isSymbolicLink())
|
|
99
99
|
files.push({ path: rel, link: readlinkSync(full) });
|
|
100
100
|
else if (stat.isDirectory())
|
|
101
|
-
files.push(...
|
|
101
|
+
files.push(...readFolderHashes(full, rel));
|
|
102
102
|
else if (stat.isFile()) {
|
|
103
103
|
const sha256 = createHash('sha256').update(readFileSync(full)).digest('hex');
|
|
104
104
|
files.push({ path: rel, sha256, size: stat.size });
|
|
@@ -153,7 +153,7 @@ function moveIn(dir, id, entry, outcome) {
|
|
|
153
153
|
quarantined_at: new Date().toISOString(),
|
|
154
154
|
mode: permissionBits(dir),
|
|
155
155
|
status: 'moving',
|
|
156
|
-
files:
|
|
156
|
+
files: readFolderHashes(dir),
|
|
157
157
|
};
|
|
158
158
|
writeReceipt(id, receipt);
|
|
159
159
|
renameSync(dir, vaultPath(id));
|
|
@@ -191,7 +191,7 @@ function keepQuarantined(dir, id, receipt, home, outcome) {
|
|
|
191
191
|
outcome.refused.push({ skill_dir: dir, reason });
|
|
192
192
|
return;
|
|
193
193
|
}
|
|
194
|
-
if (sameFiles(
|
|
194
|
+
if (sameFiles(readFolderHashes(dir), receipt.files)) {
|
|
195
195
|
rmSync(dir, { recursive: true, force: true });
|
|
196
196
|
}
|
|
197
197
|
else {
|
|
@@ -260,7 +260,7 @@ function restore(id, receipt, outcome) {
|
|
|
260
260
|
return;
|
|
261
261
|
}
|
|
262
262
|
chmodSync(vault, receipt.mode);
|
|
263
|
-
if (!sameFiles(
|
|
263
|
+
if (!sameFiles(readFolderHashes(vault), receipt.files))
|
|
264
264
|
log(`skill_block: ${dir} changed in the vault; restored as it is`);
|
|
265
265
|
mkdirSync(dirname(dir), { recursive: true });
|
|
266
266
|
renameSync(vault, dir);
|
|
@@ -14,12 +14,18 @@ import { execFileSync } from 'node:child_process';
|
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
15
|
import { basename } from 'node:path';
|
|
16
16
|
import { atomicWriteJson, getSkillBlockProcessesPath, readRemediationInstructionsFile, } from './management_storage.js';
|
|
17
|
+
import { launchAgentAccessWindow } from '../../agent_access/window_runner.js';
|
|
17
18
|
import { quarantinedAt } from './skill_block.js';
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* A process's parent, start time and command name, from `ps`; null when it cannot be read. `ps`
|
|
21
|
+
* writes the start time in the user's language ("Do. 5 Nov." in German), which the pattern below
|
|
22
|
+
* cannot read, so it runs in the C locale.
|
|
23
|
+
*/
|
|
24
|
+
export function processRow(pid) {
|
|
20
25
|
try {
|
|
26
|
+
const options = { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } };
|
|
21
27
|
// A fixed command, no shell, and a process id that is always a number.
|
|
22
|
-
const out = execFileSync('ps', ['-o', 'ppid=,lstart=,comm=', '-p', String(pid)],
|
|
28
|
+
const out = execFileSync('ps', ['-o', 'ppid=,lstart=,comm=', '-p', String(pid)], options).trim(); // nosafe: SAFE801
|
|
23
29
|
const row = out.match(/^(\d+)\s+(\S+\s+\S+\s+\d+\s+[\d:]+\s+\d+)\s+(.*)$/);
|
|
24
30
|
return row ? { parent: Number(row[1]), startedAt: Date.parse(row[2]), name: basename(row[3]).toLowerCase() } : null;
|
|
25
31
|
}
|
|
@@ -97,9 +103,7 @@ export function stoppingSkills(sessionId, proc = null) {
|
|
|
97
103
|
/** The notice for a stopped session. */
|
|
98
104
|
export function stoppedSessionMessage(skills) {
|
|
99
105
|
const names = [...new Set(skills)].join(', ');
|
|
100
|
-
return
|
|
101
|
-
'anything you need from this conversation, then quit Claude Code and start it again to ' +
|
|
102
|
-
'continue. Resuming this session keeps it stopped.');
|
|
106
|
+
return `Your organization blocked ${names}. Quit Claude Code and start it again to clear this session's memory and continue.`;
|
|
103
107
|
}
|
|
104
108
|
/**
|
|
105
109
|
* The blocked skill a typed slash command runs, when this process may still have it, or null:
|
|
@@ -114,7 +118,8 @@ export function blockedSkillOfCommand(promptFirstLine, processStartedAt = claude
|
|
|
114
118
|
if (!command.startsWith('/'))
|
|
115
119
|
return null;
|
|
116
120
|
const typed = command.slice(command.lastIndexOf(':') + 1).replace(/^\//, '').toLowerCase();
|
|
117
|
-
|
|
121
|
+
// A plugin skill only: a running Claude Code drops a custom skill by itself once it is quarantined.
|
|
122
|
+
const blocks = (readRemediationInstructionsFile().skill_blocks ?? []).filter((b) => isPluginSkillDir(b.skill_dir));
|
|
118
123
|
const hits = blocks.filter((b) => b.skill_name.toLowerCase() === typed || basename(b.skill_dir).toLowerCase() === typed);
|
|
119
124
|
if (hits.length === 0)
|
|
120
125
|
return null;
|
|
@@ -125,9 +130,18 @@ export function blockedSkillOfCommand(promptFirstLine, processStartedAt = claude
|
|
|
125
130
|
});
|
|
126
131
|
return mayStillHaveIt ? hits[0].skill_name || typed : null;
|
|
127
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Whether `dir` is a Claude Code plugin's skill (`~/.claude/plugins/...`). Claude Code reads plugin
|
|
135
|
+
* skills once, when it starts, and keeps them until it quits, so a running one still offers a
|
|
136
|
+
* quarantined plugin skill. A custom skill (`~/.claude/skills`, a project's `.claude/skills`) is read
|
|
137
|
+
* again as its folder changes: Claude Code drops it at quarantine (docs/skill-block/DESIGN.md).
|
|
138
|
+
*/
|
|
139
|
+
export function isPluginSkillDir(dir) {
|
|
140
|
+
return dir.includes('/.claude/plugins/');
|
|
141
|
+
}
|
|
128
142
|
/** The notice for a typed command of a blocked skill in a process that may still have it. */
|
|
129
143
|
export function blockedCommandMessage(skill) {
|
|
130
|
-
return `Your organization blocked ${skill}. Quit Claude Code and start it again to continue.`;
|
|
144
|
+
return `Your organization blocked ${skill}. Quit Claude Code and start it again to clear this session's memory and continue.`;
|
|
131
145
|
}
|
|
132
146
|
/**
|
|
133
147
|
* The window to open beside the block of the skill named `skill` (a name `stoppingSkills` or
|
|
@@ -135,8 +149,47 @@ export function blockedCommandMessage(skill) {
|
|
|
135
149
|
* server sent none, so only the one-line block shows.
|
|
136
150
|
*/
|
|
137
151
|
export function skillBlockWindow(skill) {
|
|
152
|
+
const uuid = resourceUuidOf(skill);
|
|
153
|
+
return uuid ? readRemediationInstructionsFile().skill_block_windows?.[uuid] : undefined;
|
|
154
|
+
}
|
|
155
|
+
/** The resource uuid of the blocked skill named `skill`, from the synced lists; '' when unknown. */
|
|
156
|
+
function resourceUuidOf(skill) {
|
|
138
157
|
const file = readRemediationInstructionsFile();
|
|
139
158
|
const name = skill.toLowerCase();
|
|
140
159
|
const entry = [...(file.skill_block_sessions ?? []), ...(file.skill_blocks ?? [])].find((e) => e.resource_uuid && e.skill_name.toLowerCase() === name);
|
|
141
|
-
return entry
|
|
160
|
+
return entry?.resource_uuid ?? '';
|
|
161
|
+
}
|
|
162
|
+
/** The skills this run already opened the window for, so a prompt that quarantines a skill and is
|
|
163
|
+
* refused for it opens one window, not two. */
|
|
164
|
+
const windowsShown = new Set();
|
|
165
|
+
/**
|
|
166
|
+
* Open the window of one blocked skill, by resource uuid, once per run. True when it is open: just
|
|
167
|
+
* started, or already started this run; false when the server sent no window for it.
|
|
168
|
+
*/
|
|
169
|
+
function openWindowOf(uuid) {
|
|
170
|
+
if (!uuid)
|
|
171
|
+
return false;
|
|
172
|
+
if (windowsShown.has(uuid))
|
|
173
|
+
return true;
|
|
174
|
+
const words = readRemediationInstructionsFile().skill_block_windows?.[uuid];
|
|
175
|
+
if (!words)
|
|
176
|
+
return false;
|
|
177
|
+
windowsShown.add(uuid);
|
|
178
|
+
return launchAgentAccessWindow('claude', words);
|
|
179
|
+
}
|
|
180
|
+
/** Open the window beside the block of the skill named `skill` (a stopped session, a typed command). */
|
|
181
|
+
export function openSkillBlockWindow(skill) {
|
|
182
|
+
return openWindowOf(resourceUuidOf(skill));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Open the window for each skill this sync moved into quarantine (`dirs`, the folders it moved), so
|
|
186
|
+
* the user hears of it when it happens, even when no session of theirs is known to have loaded it
|
|
187
|
+
* (a typed command whose turn made no tool call reaches the server as nothing). One window per
|
|
188
|
+
* skill however many of its folders moved.
|
|
189
|
+
*/
|
|
190
|
+
export function openQuarantineWindows(dirs, entries) {
|
|
191
|
+
const moved = new Set(dirs);
|
|
192
|
+
const uuids = new Set(entries.filter((e) => moved.has(e.skill_dir)).map((e) => e.resource_uuid));
|
|
193
|
+
for (const uuid of uuids)
|
|
194
|
+
openWindowOf(uuid);
|
|
142
195
|
}
|