@optimuslabs/harness-map-staging 1.5.36 → 1.5.38
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/agent_access/decision.js +20 -0
- package/dist/agent_access/window_runner.js +129 -0
- package/dist/agent_block_window/agent_block_window.jxa +223 -0
- package/dist/approved_tenant_gate.js +149 -24
- package/dist/compliance_prompt_gate.js +79 -18
- package/dist/log_config_files/collection/skill_symlink.js +9 -0
- package/dist/log_config_files/runtime/management_storage.js +34 -0
- package/dist/log_config_files/runtime/remediation_sync.js +64 -1
- package/dist/log_config_files/runtime/skill_block.js +253 -72
- package/dist/log_config_files/runtime/skill_block_sessions.js +76 -20
- package/package.json +2 -2
|
@@ -1,56 +1,131 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skill
|
|
3
|
-
*
|
|
2
|
+
* Skill quarantine (docs/skill-block/DESIGN.md, "Quarantine"): move the skill folders the
|
|
3
|
+
* organization blocked into a vault, as an antivirus quarantines a file, and move them back once the
|
|
4
|
+
* skill is approved again.
|
|
4
5
|
*
|
|
5
6
|
* The remediation sync stores the server's list under `skill_blocks` in
|
|
6
|
-
* remediation_instructions.json and then calls `applySkillBlocks`. A folder is
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* remediation_instructions.json and then calls `applySkillBlocks`. A listed folder is moved into
|
|
8
|
+
* `~/.optimuslabs/management/quarantine/<id>/` with a receipt `<id>.json` (where it came from, when,
|
|
9
|
+
* and each file's hash, never its content); the vault entry is `chmod 000`, and an empty `chmod 000`
|
|
10
|
+
* folder is left at the original path so that no app (Claude Code re-syncing account skills, a
|
|
11
|
+
* plugin update) can put the skill back. A receipt whose folder is no longer listed is restored.
|
|
12
|
+
* A folder is only ever moved when it is a real directory inside the home folder (never the home
|
|
13
|
+
* folder itself) holding a SKILL.md, so a bad path cannot move anything that is not a skill.
|
|
12
14
|
*/
|
|
13
|
-
import {
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
14
17
|
import { homedir } from 'node:os';
|
|
15
|
-
import { join, resolve, sep } from 'node:path';
|
|
18
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
16
19
|
import { complianceRunnerDiag, hookRunLog } from './hook_logger.js';
|
|
17
|
-
import { atomicWriteJson, getSkillBlockStatePath } from './management_storage.js';
|
|
20
|
+
import { atomicWriteJson, getQuarantineDir, getSkillBlockStatePath, } from './management_storage.js';
|
|
18
21
|
function log(message) {
|
|
19
22
|
hookRunLog(message);
|
|
20
23
|
complianceRunnerDiag(message);
|
|
21
24
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
function permissionBits(path) {
|
|
26
|
+
return lstatSync(path).mode & 0o7777;
|
|
27
|
+
}
|
|
28
|
+
/** The vault id of an original path: one path, one entry. */
|
|
29
|
+
function entryId(dir) {
|
|
30
|
+
return createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 16);
|
|
31
|
+
}
|
|
32
|
+
function vaultPath(id) {
|
|
33
|
+
return join(getQuarantineDir(), id);
|
|
34
|
+
}
|
|
35
|
+
function receiptPath(id) {
|
|
36
|
+
return join(getQuarantineDir(), `${id}.json`);
|
|
37
|
+
}
|
|
38
|
+
function readReceipt(id) {
|
|
26
39
|
try {
|
|
27
|
-
const parsed = JSON.parse(readFileSync(
|
|
28
|
-
return parsed && typeof parsed === '
|
|
40
|
+
const parsed = JSON.parse(readFileSync(receiptPath(id), 'utf8'));
|
|
41
|
+
return parsed && typeof parsed.original_path === 'string' ? parsed : null;
|
|
29
42
|
}
|
|
30
43
|
catch {
|
|
31
|
-
return
|
|
44
|
+
return null;
|
|
32
45
|
}
|
|
33
46
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
47
|
+
function writeReceipt(id, receipt) {
|
|
48
|
+
atomicWriteJson(receiptPath(id), receipt);
|
|
36
49
|
}
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
/** Every receipt in the vault, by id. */
|
|
51
|
+
function readReceipts() {
|
|
52
|
+
let names;
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(getQuarantineDir());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const receipts = [];
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const match = name.match(/^([0-9a-f]{16})\.json$/);
|
|
62
|
+
const receipt = match ? readReceipt(match[1]) : null;
|
|
63
|
+
if (match && receipt)
|
|
64
|
+
receipts.push([match[1], receipt]);
|
|
65
|
+
}
|
|
66
|
+
return receipts;
|
|
67
|
+
}
|
|
68
|
+
function exists(path) {
|
|
69
|
+
try {
|
|
70
|
+
lstatSync(path);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
45
74
|
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The empty `chmod 000` folder quarantine leaves at an original path. */
|
|
78
|
+
function isStub(dir) {
|
|
46
79
|
try {
|
|
47
|
-
|
|
80
|
+
const stat = lstatSync(dir);
|
|
81
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && (stat.mode & 0o7777) === 0;
|
|
48
82
|
}
|
|
49
83
|
catch {
|
|
50
84
|
return false;
|
|
51
85
|
}
|
|
52
86
|
}
|
|
53
|
-
|
|
87
|
+
function makeStub(dir) {
|
|
88
|
+
mkdirSync(dir);
|
|
89
|
+
chmodSync(dir, 0o000);
|
|
90
|
+
}
|
|
91
|
+
/** Every file of `dir` with its hash, and every symlink with its target, sorted by path. */
|
|
92
|
+
function hashFolder(dir, prefix = '') {
|
|
93
|
+
const files = [];
|
|
94
|
+
for (const name of readdirSync(dir).sort()) {
|
|
95
|
+
const full = join(dir, name);
|
|
96
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
97
|
+
const stat = lstatSync(full);
|
|
98
|
+
if (stat.isSymbolicLink())
|
|
99
|
+
files.push({ path: rel, link: readlinkSync(full) });
|
|
100
|
+
else if (stat.isDirectory())
|
|
101
|
+
files.push(...hashFolder(full, rel));
|
|
102
|
+
else if (stat.isFile()) {
|
|
103
|
+
const sha256 = createHash('sha256').update(readFileSync(full)).digest('hex');
|
|
104
|
+
files.push({ path: rel, sha256, size: stat.size });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return files;
|
|
108
|
+
}
|
|
109
|
+
function sameFiles(a, b) {
|
|
110
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
111
|
+
}
|
|
112
|
+
/** When the folder at `dir` was quarantined, or null when it is not. */
|
|
113
|
+
export function quarantinedAt(dir) {
|
|
114
|
+
const receipt = readReceipt(entryId(dir));
|
|
115
|
+
return receipt && receipt.status !== 'moving' ? receipt.quarantined_at : null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The receipt of the skill folder quarantined from `dir`, when the empty folder quarantine left
|
|
119
|
+
* is still there. The collection reports such a path as quarantined: its skill cannot have changed
|
|
120
|
+
* since it was moved, and the server keeps the copy it has.
|
|
121
|
+
*/
|
|
122
|
+
export function quarantineReceiptFor(dir) {
|
|
123
|
+
if (!isStub(resolve(dir)))
|
|
124
|
+
return null;
|
|
125
|
+
const receipt = readReceipt(entryId(dir));
|
|
126
|
+
return receipt && receipt.status !== 'moving' ? receipt : null;
|
|
127
|
+
}
|
|
128
|
+
/** Why `dir` must not be moved, or null when it is a real skill folder inside the home folder. */
|
|
54
129
|
export function refusalReason(dir, home = homedir()) {
|
|
55
130
|
const root = resolve(home);
|
|
56
131
|
const target = resolve(dir);
|
|
@@ -69,24 +144,79 @@ export function refusalReason(dir, home = homedir()) {
|
|
|
69
144
|
return 'no SKILL.md';
|
|
70
145
|
return null;
|
|
71
146
|
}
|
|
72
|
-
/**
|
|
73
|
-
function
|
|
74
|
-
|
|
147
|
+
/** Move a listed folder into the vault, with its receipt, and leave the empty folder behind. */
|
|
148
|
+
function moveIn(dir, id, entry, outcome) {
|
|
149
|
+
const receipt = {
|
|
150
|
+
original_path: dir,
|
|
151
|
+
resource_uuid: entry.resource_uuid,
|
|
152
|
+
skill_name: entry.skill_name,
|
|
153
|
+
quarantined_at: new Date().toISOString(),
|
|
154
|
+
mode: permissionBits(dir),
|
|
155
|
+
status: 'moving',
|
|
156
|
+
files: hashFolder(dir),
|
|
157
|
+
};
|
|
158
|
+
writeReceipt(id, receipt);
|
|
159
|
+
renameSync(dir, vaultPath(id));
|
|
160
|
+
finishMove(dir, id, receipt);
|
|
161
|
+
outcome.quarantined.push(dir);
|
|
162
|
+
}
|
|
163
|
+
/** The steps after the move, also run to finish one a crash interrupted. */
|
|
164
|
+
function finishMove(dir, id, receipt) {
|
|
165
|
+
if (!exists(dir))
|
|
166
|
+
makeStub(dir);
|
|
167
|
+
chmodSync(vaultPath(id), 0o000);
|
|
168
|
+
writeReceipt(id, { ...receipt, status: 'done' });
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A listed folder already in the vault: keep its original path closed. The empty folder may have
|
|
172
|
+
* been removed, opened, or replaced by the skill itself (an app re-created it); a copy identical
|
|
173
|
+
* to the vault's is discarded, a different one is kept in the vault beside it.
|
|
174
|
+
*/
|
|
175
|
+
function keepQuarantined(dir, id, receipt, home, outcome) {
|
|
176
|
+
if (isStub(dir))
|
|
177
|
+
return;
|
|
178
|
+
if (!exists(dir)) {
|
|
179
|
+
makeStub(dir);
|
|
180
|
+
outcome.requarantined.push(dir);
|
|
75
181
|
return;
|
|
182
|
+
}
|
|
183
|
+
const stat = lstatSync(dir);
|
|
184
|
+
if (stat.isDirectory() && !stat.isSymbolicLink() && readdirSync(dir).length === 0) {
|
|
185
|
+
chmodSync(dir, 0o000);
|
|
186
|
+
outcome.requarantined.push(dir);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
76
189
|
const reason = refusalReason(dir, home);
|
|
77
190
|
if (reason) {
|
|
78
|
-
outcome.refused.push({ skill_dir:
|
|
191
|
+
outcome.refused.push({ skill_dir: dir, reason });
|
|
79
192
|
return;
|
|
80
193
|
}
|
|
81
|
-
|
|
82
|
-
|
|
194
|
+
if (sameFiles(hashFolder(dir), receipt.files)) {
|
|
195
|
+
rmSync(dir, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const kept = `${vaultPath(id)}.${Date.now()}`;
|
|
199
|
+
renameSync(dir, kept);
|
|
200
|
+
chmodSync(kept, 0o000);
|
|
201
|
+
log(`skill_block: a different copy came back at ${dir}; kept in the vault as ${kept}`);
|
|
202
|
+
}
|
|
203
|
+
makeStub(dir);
|
|
204
|
+
outcome.requarantined.push(dir);
|
|
83
205
|
}
|
|
84
|
-
/**
|
|
85
|
-
function
|
|
206
|
+
/** Quarantine one listed folder, or keep it quarantined. */
|
|
207
|
+
function quarantine(entry, home, outcome) {
|
|
86
208
|
const dir = resolve(entry.skill_dir);
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
209
|
+
const id = entryId(dir);
|
|
210
|
+
let receipt = readReceipt(id);
|
|
211
|
+
if (receipt?.status === 'moving') {
|
|
212
|
+
if (existsSync(vaultPath(id)))
|
|
213
|
+
finishMove(dir, id, receipt);
|
|
214
|
+
else
|
|
215
|
+
unlinkSync(receiptPath(id));
|
|
216
|
+
receipt = readReceipt(id);
|
|
217
|
+
}
|
|
218
|
+
if (receipt) {
|
|
219
|
+
keepQuarantined(dir, id, receipt, home, outcome);
|
|
90
220
|
return;
|
|
91
221
|
}
|
|
92
222
|
const reason = refusalReason(dir, home);
|
|
@@ -94,28 +224,79 @@ function lockFolder(entry, state, outcome, home) {
|
|
|
94
224
|
outcome.refused.push({ skill_dir: entry.skill_dir, reason });
|
|
95
225
|
return;
|
|
96
226
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
227
|
+
moveIn(dir, id, entry, outcome);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Clear the original path for a restore: our empty folder (or an emptied one) is removed. Anything
|
|
231
|
+
* else there now is someone's copy: it is left alone and the vault entry is kept (a conflict).
|
|
232
|
+
*/
|
|
233
|
+
function clearOriginalPath(dir) {
|
|
234
|
+
if (!exists(dir))
|
|
235
|
+
return true;
|
|
236
|
+
const stat = lstatSync(dir);
|
|
237
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
238
|
+
return false;
|
|
239
|
+
chmodSync(dir, 0o700);
|
|
240
|
+
if (readdirSync(dir).length > 0) {
|
|
241
|
+
chmodSync(dir, stat.mode & 0o7777);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
rmdirSync(dir);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
/** Move a folder no longer listed back where it came from, with its mode. */
|
|
248
|
+
function restore(id, receipt, outcome) {
|
|
249
|
+
const dir = receipt.original_path;
|
|
250
|
+
const vault = vaultPath(id);
|
|
251
|
+
if (!existsSync(vault)) {
|
|
252
|
+
unlinkSync(receiptPath(id));
|
|
253
|
+
log(`skill_block: the vault entry of ${dir} is missing; its receipt is dropped`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!clearOriginalPath(dir)) {
|
|
257
|
+
if (receipt.status !== 'conflict')
|
|
258
|
+
writeReceipt(id, { ...receipt, status: 'conflict' });
|
|
259
|
+
outcome.conflicts.push(dir);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
chmodSync(vault, receipt.mode);
|
|
263
|
+
if (!sameFiles(hashFolder(vault), receipt.files))
|
|
264
|
+
log(`skill_block: ${dir} changed in the vault; restored as it is`);
|
|
265
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
266
|
+
renameSync(vault, dir);
|
|
267
|
+
unlinkSync(receiptPath(id));
|
|
268
|
+
outcome.restored.push(dir);
|
|
101
269
|
}
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
270
|
+
/**
|
|
271
|
+
* The previous version locked folders in place (`chmod 000`, modes in skill_block_state.json).
|
|
272
|
+
* Give them their modes back once; the listed ones are then quarantined like any other.
|
|
273
|
+
*/
|
|
274
|
+
function releaseInPlaceLocks() {
|
|
275
|
+
const path = getSkillBlockStatePath();
|
|
276
|
+
if (!existsSync(path))
|
|
277
|
+
return;
|
|
278
|
+
try {
|
|
279
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
280
|
+
for (const [dir, entry] of Object.entries(state)) {
|
|
281
|
+
if (typeof entry?.mode === 'number' && existsSync(dir) && !lstatSync(dir).isSymbolicLink()) {
|
|
282
|
+
chmodSync(dir, entry.mode);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
log(`skill_block: could not read the in-place locks: ${err instanceof Error ? err.message : String(err)}`);
|
|
108
288
|
}
|
|
109
|
-
|
|
289
|
+
unlinkSync(path);
|
|
110
290
|
}
|
|
111
291
|
/**
|
|
112
|
-
* Make the
|
|
113
|
-
*
|
|
114
|
-
* is skipped and logged; one
|
|
292
|
+
* Make the vault match `entries`, the server's whole list for this machine: quarantine the listed
|
|
293
|
+
* folders, keep them quarantined, and restore the ones no longer listed. A folder that fails the
|
|
294
|
+
* checks is skipped and logged; one failure does not stop the others.
|
|
115
295
|
*/
|
|
116
296
|
export function applySkillBlocks(entries, home = homedir()) {
|
|
117
|
-
const outcome = {
|
|
118
|
-
|
|
297
|
+
const outcome = { quarantined: [], requarantined: [], restored: [], conflicts: [], refused: [] };
|
|
298
|
+
mkdirSync(getQuarantineDir(), { recursive: true, mode: 0o700 });
|
|
299
|
+
const lockPath = `${getQuarantineDir()}.lock`;
|
|
119
300
|
if (!takeStateLock(lockPath)) {
|
|
120
301
|
log('skill_block: another sync is applying blocks; the next sync applies these');
|
|
121
302
|
return outcome;
|
|
@@ -129,9 +310,8 @@ export function applySkillBlocks(entries, home = homedir()) {
|
|
|
129
310
|
return outcome;
|
|
130
311
|
}
|
|
131
312
|
/**
|
|
132
|
-
* Take the lock around
|
|
133
|
-
*
|
|
134
|
-
* leaving it locked after an unblock. A lock left by a dead process goes after a minute.
|
|
313
|
+
* Take the lock around the vault: two syncs at once (the prompt gate and the compliance runner)
|
|
314
|
+
* could otherwise move the same folder twice. A lock left by a dead process goes after a minute.
|
|
135
315
|
*/
|
|
136
316
|
function takeStateLock(lockPath) {
|
|
137
317
|
try {
|
|
@@ -153,36 +333,37 @@ function releaseStateLock(lockPath) {
|
|
|
153
333
|
}
|
|
154
334
|
}
|
|
155
335
|
function applyUnderLock(entries, home, outcome) {
|
|
156
|
-
|
|
157
|
-
const wanted = new Set(entries.map((entry) =>
|
|
336
|
+
releaseInPlaceLocks();
|
|
337
|
+
const wanted = new Set(entries.map((entry) => entryId(entry.skill_dir)));
|
|
158
338
|
for (const entry of entries) {
|
|
159
339
|
try {
|
|
160
|
-
|
|
340
|
+
quarantine(entry, home, outcome);
|
|
161
341
|
}
|
|
162
342
|
catch (err) {
|
|
163
343
|
outcome.refused.push({ skill_dir: entry.skill_dir, reason: err instanceof Error ? err.message : String(err) });
|
|
164
344
|
}
|
|
165
345
|
}
|
|
166
|
-
for (const
|
|
167
|
-
if (wanted.has(
|
|
346
|
+
for (const [id, receipt] of readReceipts()) {
|
|
347
|
+
if (wanted.has(id))
|
|
168
348
|
continue;
|
|
169
349
|
try {
|
|
170
|
-
|
|
350
|
+
restore(id, receipt, outcome);
|
|
171
351
|
}
|
|
172
352
|
catch (err) {
|
|
173
|
-
outcome.refused.push({ skill_dir:
|
|
353
|
+
outcome.refused.push({ skill_dir: receipt.original_path, reason: err instanceof Error ? err.message : String(err) });
|
|
174
354
|
}
|
|
175
355
|
}
|
|
176
|
-
atomicWriteJson(getSkillBlockStatePath(), state);
|
|
177
356
|
logOutcome(outcome);
|
|
178
357
|
}
|
|
179
358
|
function logOutcome(outcome) {
|
|
180
|
-
for (const dir of outcome.
|
|
181
|
-
log(`skill_block:
|
|
182
|
-
for (const dir of outcome.
|
|
183
|
-
log(`skill_block:
|
|
359
|
+
for (const dir of outcome.quarantined)
|
|
360
|
+
log(`skill_block: quarantined ${dir}`);
|
|
361
|
+
for (const dir of outcome.requarantined)
|
|
362
|
+
log(`skill_block: closed again ${dir}`);
|
|
184
363
|
for (const dir of outcome.restored)
|
|
185
364
|
log(`skill_block: restored ${dir}`);
|
|
365
|
+
for (const dir of outcome.conflicts)
|
|
366
|
+
log(`skill_block: not restored, something else is at ${dir}`);
|
|
186
367
|
for (const { skill_dir, reason } of outcome.refused)
|
|
187
368
|
log(`skill_block: skipped ${skill_dir} (${reason})`);
|
|
188
369
|
}
|
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* The backend knows which sessions loaded a skill: the tool calls the skill usage view attributes to
|
|
7
7
|
* it. The remediation sync sends this machine's sessions that loaded a blocked skill
|
|
8
8
|
* (`skill_block_sessions` in remediation_instructions.json), and the prompt gate refuses their
|
|
9
|
-
* prompts.
|
|
9
|
+
* prompts. `/clear` gives a new session id but the `claude` process keeps the skill, so the gate also
|
|
10
|
+
* records which session ids each process has run (skill_block_processes.json) and stops every session
|
|
11
|
+
* of a process that ran a listed one. An unblock takes the sessions off the list.
|
|
10
12
|
*/
|
|
11
13
|
import { execFileSync } from 'node:child_process';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { basename } from 'node:path';
|
|
16
|
+
import { atomicWriteJson, getSkillBlockProcessesPath, readRemediationInstructionsFile, } from './management_storage.js';
|
|
17
|
+
import { quarantinedAt } from './skill_block.js';
|
|
15
18
|
/** A process's parent, start time and command name, from `ps`; null when it cannot be read. */
|
|
16
19
|
function processRow(pid) {
|
|
17
20
|
try {
|
|
@@ -25,41 +28,84 @@ function processRow(pid) {
|
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
30
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
+
* The `claude` process running this hook, or null when it cannot be found. It lists its skills when
|
|
32
|
+
* it starts and keeps them across `/clear`, which only changes the session id, so the process (not
|
|
33
|
+
* the session) is what holds a loaded skill.
|
|
31
34
|
*/
|
|
32
|
-
export function
|
|
35
|
+
export function claudeProcess() {
|
|
33
36
|
let pid = process.ppid;
|
|
34
37
|
for (let depth = 0; depth < 12 && pid > 1; depth += 1) {
|
|
35
38
|
const row = processRow(pid);
|
|
36
39
|
if (!row)
|
|
37
40
|
return null;
|
|
38
41
|
if (row.name === 'claude')
|
|
39
|
-
return row.startedAt;
|
|
42
|
+
return Number.isNaN(row.startedAt) ? null : { pid, startedAt: row.startedAt };
|
|
40
43
|
pid = row.parent;
|
|
41
44
|
}
|
|
42
45
|
return null;
|
|
43
46
|
}
|
|
44
|
-
/**
|
|
45
|
-
export function
|
|
47
|
+
/** When the `claude` process running this hook started (ms), or null when it cannot be found. */
|
|
48
|
+
export function claudeProcessStartedAt() {
|
|
49
|
+
return claudeProcess()?.startedAt ?? null;
|
|
50
|
+
}
|
|
51
|
+
/** The table's key: a pid with its start time, since the system reuses pids. */
|
|
52
|
+
function processKey(proc) {
|
|
53
|
+
return `${proc.pid}:${proc.startedAt}`;
|
|
54
|
+
}
|
|
55
|
+
function readProcessTable() {
|
|
56
|
+
const path = getSkillBlockProcessesPath();
|
|
57
|
+
if (!existsSync(path))
|
|
58
|
+
return {};
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Record that `proc` runs `sessionId`, and drop processes not seen for 30 days (the server's window).
|
|
69
|
+
* Two prompts at once can each write the table and lose one session; the next prompt records it again.
|
|
70
|
+
*/
|
|
71
|
+
export function recordSessionProcess(sessionId, proc, now = Date.now()) {
|
|
72
|
+
if (!sessionId || !proc)
|
|
73
|
+
return;
|
|
74
|
+
const table = readProcessTable();
|
|
75
|
+
const cutoff = now - 30 * 24 * 60 * 60 * 1000;
|
|
76
|
+
for (const [key, entry] of Object.entries(table)) {
|
|
77
|
+
if (!(Date.parse(entry?.last_seen ?? '') >= cutoff))
|
|
78
|
+
delete table[key];
|
|
79
|
+
}
|
|
80
|
+
const key = processKey(proc);
|
|
81
|
+
const sessions = new Set(table[key]?.sessions ?? []);
|
|
82
|
+
sessions.add(sessionId);
|
|
83
|
+
table[key] = { sessions: [...sessions], last_seen: new Date(now).toISOString() };
|
|
84
|
+
atomicWriteJson(getSkillBlockProcessesPath(), table);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The names of the blocked skills this session loaded, or its process loaded in an earlier session
|
|
88
|
+
* (before a `/clear`); empty when it may go on. Without the process, only the session id counts.
|
|
89
|
+
*/
|
|
90
|
+
export function stoppingSkills(sessionId, proc = null) {
|
|
46
91
|
if (!sessionId)
|
|
47
92
|
return [];
|
|
93
|
+
const own = new Set([sessionId, ...(proc ? (readProcessTable()[processKey(proc)]?.sessions ?? []) : [])]);
|
|
48
94
|
const sessions = readRemediationInstructionsFile().skill_block_sessions ?? [];
|
|
49
|
-
return sessions.filter((s) => s.session_id
|
|
95
|
+
return sessions.filter((s) => own.has(s.session_id)).map((s) => s.skill_name || 'a skill');
|
|
50
96
|
}
|
|
51
97
|
/** The notice for a stopped session. */
|
|
52
98
|
export function stoppedSessionMessage(skills) {
|
|
53
99
|
const names = [...new Set(skills)].join(', ');
|
|
54
|
-
return (`Your organization blocked ${names}, which this
|
|
55
|
-
'this conversation, then quit Claude Code and start it again to
|
|
56
|
-
'session keeps it stopped.');
|
|
100
|
+
return (`Your organization blocked ${names}, which this Claude Code loaded (a /clear keeps it). Copy ` +
|
|
101
|
+
'anything you need from this conversation, then quit Claude Code and start it again to ' +
|
|
102
|
+
'continue. Resuming this session keeps it stopped.');
|
|
57
103
|
}
|
|
58
104
|
/**
|
|
59
105
|
* 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
|
|
61
|
-
* listed the skill and keeps it, even after `/clear`, so this refuses it. A process
|
|
62
|
-
*
|
|
106
|
+
* `/airtable:airtable-cli …` or `/airtable-cli …`. A process started before the folder was
|
|
107
|
+
* quarantined listed the skill and keeps it, even after `/clear`, so this refuses it. A process
|
|
108
|
+
* started after it never listed it: this answers null, and Claude Code says the skill does not exist.
|
|
63
109
|
* Matches the skill's name or its folder's name, which can differ (`aikido-issues` lives in
|
|
64
110
|
* `skills/issues`). An unknown start time counts as too old, so a block is never skipped.
|
|
65
111
|
*/
|
|
@@ -73,9 +119,8 @@ export function blockedSkillOfCommand(promptFirstLine, processStartedAt = claude
|
|
|
73
119
|
if (hits.length === 0)
|
|
74
120
|
return null;
|
|
75
121
|
const startedAt = processStartedAt();
|
|
76
|
-
const locks = readSkillBlockState();
|
|
77
122
|
const mayStillHaveIt = hits.some((b) => {
|
|
78
|
-
const lockedAt = Date.parse(
|
|
123
|
+
const lockedAt = Date.parse(quarantinedAt(b.skill_dir) ?? '');
|
|
79
124
|
return startedAt === null || Number.isNaN(lockedAt) || startedAt <= lockedAt;
|
|
80
125
|
});
|
|
81
126
|
return mayStillHaveIt ? hits[0].skill_name || typed : null;
|
|
@@ -84,3 +129,14 @@ export function blockedSkillOfCommand(promptFirstLine, processStartedAt = claude
|
|
|
84
129
|
export function blockedCommandMessage(skill) {
|
|
85
130
|
return `Your organization blocked ${skill}. Quit Claude Code and start it again to continue.`;
|
|
86
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* The window to open beside the block of the skill named `skill` (a name `stoppingSkills` or
|
|
134
|
+
* `blockedSkillOfCommand` gave), as the sync sent it for that skill's resource; undefined when the
|
|
135
|
+
* server sent none, so only the one-line block shows.
|
|
136
|
+
*/
|
|
137
|
+
export function skillBlockWindow(skill) {
|
|
138
|
+
const file = readRemediationInstructionsFile();
|
|
139
|
+
const name = skill.toLowerCase();
|
|
140
|
+
const entry = [...(file.skill_block_sessions ?? []), ...(file.skill_blocks ?? [])].find((e) => e.resource_uuid && e.skill_name.toLowerCase() === name);
|
|
141
|
+
return entry ? file.skill_block_windows?.[entry.resource_uuid] : undefined;
|
|
142
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optimuslabs/harness-map-staging",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.38",
|
|
4
4
|
"description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"dialog_prefs-staging": "dist/cli.js"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"build": "tsc -p tsconfig.json && node ../../../scripts/ensure-bin-executable.mjs",
|
|
18
|
+
"build": "tsc -p tsconfig.json && node ../../../scripts/ensure-bin-executable.mjs && node -e \"const fs=require('fs');fs.mkdirSync('dist/agent_block_window',{recursive:true});fs.copyFileSync('src/agent_block_window/agent_block_window.jxa','dist/agent_block_window/agent_block_window.jxa')\"",
|
|
19
19
|
"dev": "ts-node src/cli.ts",
|
|
20
20
|
"lint": "eslint \"src/**/*.ts\"",
|
|
21
21
|
"prepare": "npm run build",
|