@kaydash9999/banker-plugins 0.19.2 → 0.19.3
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +7 -0
- package/hooks/update-checkin.mjs +96 -13
- package/package.json +1 -1
- package/skills/anti-drm/SKILL.md +1 -0
- package/skills/anti-drm/scripts/instruction-toggle.mjs +39 -23
- package/skills/lineage/SKILL.md +3 -1
- package/skills/lineage/lineage.py +342 -25
- package/skills/tone-compact/SKILL.md +1 -0
- package/skills/tone-compact/scripts/instruction-toggle.mjs +39 -23
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.3",
|
|
4
4
|
"description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "smallOpenSource",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.19.3] - 2026-10-17
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- 업데이트 확인 체크인 훅: 보내기 전에 사용 기록의 이름을 바꿔 차지한다. 예전에는 첫 체크인이 보내는 사이 함께 시작한 세션이 같은 기록을 다시 보내 대시보드 수가 실제보다 커졌다. 보내는 동안 더해진 줄은 다음 체크인이 보낸다(예전에는 지워졌다). 기록 자리가 FIFO 면 기다리지 않는다. 기록이 링크면 링크만 옮겨 지우고 가리키던 파일은 그대로 두며, 다음 기록은 일반 파일로 새로 생긴다. `last-flush` 자리가 링크면 그 너머를 덮지 않는다. 백신 등이 기록을 잠시 쥐어 이름을 못 바꾸면 두 번 더 해 보고, 그래도 안 되면 다음 체크인에 남긴다. 죽은 체크인이 남긴 차지 파일은 차지한 지 한 시간이 지나면 지운다.
|
|
7
|
+
- `tone-compact`, `anti-drm`: 잠금을 잡지 못한 회차마다 쉰다. 예전에는 잠금 자리를 볼 때는 없다고 하고 만들 때는 있다고 하는 공유(NFS 의 캐시 등)에서 기다리는 동안 쉬지 않고 돌았다. 잠금을 만들거나 죽은 실행의 잠금을 지울 때 ESTALE 이 나면 쉬고 다시 본다. 만들 때의 ESTALE 이 기다림이 끝나도 계속되면 그 원인을 밝혀 거부한다(종료 1). 예전에는 예기치 못한 오류로 종료 2 였다. 일을 마친 뒤 잠금을 돌려줄 때의 ESTALE 은 세 번까지 다시 한다. 예전에는 잠금이 1분 남아 그동안 다음 작업이 거부됐다.
|
|
8
|
+
- `lineage` 3.0.8: 쪼개진 키, 비밀번호, `--redact-extra` 키워드도 통째로 가린다. 쪼개는 글자는 보이지 않는 글자(폭 없는 공백, 소프트 하이픈, 방향 제어 같은 기본 무시 글자와 그 밖의 서식 문자), 공백, 탭, 줄바꿈이 아닌 제어 문자와 특수 공백(줄바꿈 없는 공백 등), 사용자 정의 영역 글자, 점자 빈칸, U+FFFC, U+FFFD 다. 예전에는 쪼개진 조각이 가림 패턴에 걸리지 않아 페이지와 검토자 글에 남을 수 있었다. 다시 이은 결과가 보이는 글자 가운데 3.0.7 의 한 번이 가린 글자는 3.0.7 의 표식으로 다시 가리고, 어떤 종류의 값이든 3.0.7 보다 적게 가리면 3.0.7 한 번의 결과에서 이어 간다. 그래서 3.0.7 이 가리던 글자는 모두 그대로 가린다. 비밀 밖의 글은 그대로이고, 다시 이은 비밀 바로 옆의 그런 글자는 함께 빠질 수 있다.
|
|
9
|
+
|
|
3
10
|
## [0.19.2] - 2026-10-16
|
|
4
11
|
|
|
5
12
|
### Changed
|
package/hooks/update-checkin.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
3
|
* banker 카운팅 체크인 훅 - usage-log 를 스킬별 x 시간대(hour 0-23)별 count 로 집계해 엔드포인트로
|
|
4
|
-
* 한 번 POST 하고, 응답의 latest 로 update-check 캐시를 겸해 갱신한다.
|
|
5
|
-
*
|
|
4
|
+
* 한 번 POST 하고, 응답의 latest 로 update-check 캐시를 겸해 갱신한다. 보내기 전에 로그 이름을 바꿔
|
|
5
|
+
* 차지하고, 시도 뒤(성공/실패 무관) 차지한 파일을 지워 24h 윈도우로 상한한다. telemetry-flush 를
|
|
6
|
+
* 대체(evolve)한다.
|
|
6
7
|
*
|
|
7
8
|
* fail-closed 규약(각 항목은 update-checkin.test.mjs 가 검증한다):
|
|
8
9
|
* - 미활성(countingActive) 또는 엔드포인트 없음이면 무동작(로그도 건드리지 않는다).
|
|
@@ -10,13 +11,19 @@
|
|
|
10
11
|
* counts=스킬별 x 시간대별 호출수).
|
|
11
12
|
* - 자체 네트워크 timeout 으로 hang 방지. 어떤 오류도 삼키고 항상 exit 0. 어떤 경로도 reject 안 함.
|
|
12
13
|
* - 비-loopback 평문 http 는 전송하지 않는다(https-only). loopback http 만 로컬 테스트 mock 에 허용.
|
|
14
|
+
* - 함께 시작한 체크인은 같은 줄을 두 번 보내지 않는다(로그 이름 바꾸기는 하나만 성공한다).
|
|
15
|
+
* - 로그 자리가 FIFO 면 기다리지 않고, 로그나 last-flush 자리가 링크면 링크 너머 파일을 비우거나 덮지 않는다.
|
|
13
16
|
*
|
|
14
17
|
* count-default-on: 이 훅은 hooks.json 에 배선되지 않는다(스탠드얼론 .mjs). update-notify 가
|
|
15
18
|
* countingActive + 엔드포인트 설정 시 fetcher 로 detached spawn 한다(응답 latest 가 알림 캐시를 겸함).
|
|
16
19
|
*/
|
|
17
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
18
24
|
import http from 'node:http';
|
|
19
25
|
import https from 'node:https';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
20
27
|
|
|
21
28
|
import {
|
|
22
29
|
countingActive,
|
|
@@ -160,13 +167,80 @@ function fetchSkillChangesMap(url) {
|
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
// 보낼 본문. PII/IP/지속ID 없음: version + os(플랫폼 계열) + counts(스킬별 x 시간대)뿐.
|
|
163
|
-
//
|
|
164
|
-
function checkinBody() {
|
|
165
|
-
let raw = '';
|
|
166
|
-
try { raw = readFileSync(USAGE_LOG_PATH, 'utf8'); } catch { raw = ''; }
|
|
170
|
+
// 차지한 줄이 없어도 유효한 체크인(설치 펄스 + 알림 fetch 겸함): 빈 counts 로 진행한다.
|
|
171
|
+
function checkinBody(raw) {
|
|
167
172
|
return JSON.stringify({ version: installedVersion() || 'unknown', os: process.platform, counts: aggregate(raw) });
|
|
168
173
|
}
|
|
169
174
|
|
|
175
|
+
// 차지한 로그의 이름(usage-log.sending-<pid>-<차지한 ms>-<난수>)과, 죽은 체크인이 남긴 것으로 볼 나이. 나이는
|
|
176
|
+
// 이름의 시각으로 센다: 이름을 바꿔도 수정 시각은 로그를 마지막으로 쓴 때 그대로다. 난수는 PID 가 같은 다른
|
|
177
|
+
// 이름공간(컨테이너)의 체크인과 이름이 겹치지 않게 한다.
|
|
178
|
+
const CLAIM_RE = /^usage-log\.sending-\d+-(\d+)(?:-[0-9a-f]+)?$/;
|
|
179
|
+
const CLAIM_MAX_AGE_MS = 3_600_000;
|
|
180
|
+
// 백신이나 동기화 도구가 로그를 잠시 쥐어 이름을 못 바꿀 때(Windows 의 EPERM, EBUSY) 쉬었다 다시 해 볼 횟수와 쉼.
|
|
181
|
+
const RENAME_TRIES = 3;
|
|
182
|
+
const RENAME_REST_MS = 50;
|
|
183
|
+
const SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
184
|
+
|
|
185
|
+
// 일반 파일의 글. 그 밖(FIFO, 폴더)과 오류는 ''. 막지 않고 열어 FIFO 를 기다리지 않는다.
|
|
186
|
+
function readRegular(path) {
|
|
187
|
+
let fd;
|
|
188
|
+
try {
|
|
189
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
190
|
+
return fstatSync(fd).isFile() ? readFileSync(fd, 'utf8') : '';
|
|
191
|
+
} catch {
|
|
192
|
+
return '';
|
|
193
|
+
} finally {
|
|
194
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* best-effort */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 보내기 전에 로그 이름을 바꿔 차지한다. rename 은 원자적이라 함께 시작한 체크인 가운데 하나만 그 줄을 읽고,
|
|
199
|
+
// 보내는 동안 더해진 줄은 새 로그로 가 다음 체크인이 보낸다. 링크면 링크 자체가 옮겨진다(대상은 그대로).
|
|
200
|
+
// 로그가 없거나 폴더, FIFO 이거나 이름을 바꾸지 못하면(다른 체크인이 먼저 차지, Windows 에서 백신이 쥠)
|
|
201
|
+
// 아무것도 차지하지 않고 로그는 다음 체크인에 남긴다.
|
|
202
|
+
function claimLog() {
|
|
203
|
+
try {
|
|
204
|
+
const st = lstatSync(USAGE_LOG_PATH);
|
|
205
|
+
if (!st.isFile() && !st.isSymbolicLink()) return { raw: '', claim: null };
|
|
206
|
+
} catch {
|
|
207
|
+
return { raw: '', claim: null };
|
|
208
|
+
}
|
|
209
|
+
const claim = join(dirname(USAGE_LOG_PATH), `usage-log.sending-${process.pid}-${Date.now()}-${randomBytes(4).toString('hex')}`);
|
|
210
|
+
return renameAside(USAGE_LOG_PATH, claim) ? { raw: readRegular(claim), claim } : { raw: '', claim: null };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 이름을 바꿨으면 true. 잠시 쥔 것(EPERM, EBUSY, EACCES)은 RENAME_TRIES 번까지 쉬었다 다시 하고, 없어졌으면
|
|
214
|
+
// (다른 체크인이 먼저 차지) 곧 false.
|
|
215
|
+
function renameAside(from, to) {
|
|
216
|
+
for (let tried = 1; ; tried++) {
|
|
217
|
+
try {
|
|
218
|
+
renameSync(from, to);
|
|
219
|
+
return true;
|
|
220
|
+
} catch (e) {
|
|
221
|
+
if (tried >= RENAME_TRIES || !['EPERM', 'EBUSY', 'EACCES'].includes(e.code)) return false;
|
|
222
|
+
Atomics.wait(SLEEP, 0, 0, RENAME_REST_MS);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 죽은 체크인이 남긴 차지 파일(차지한 지 한 시간이 넘은 것, 시계를 되돌려 한 시간 넘게 앞선 것)을 지운다(배치
|
|
228
|
+
// 유실 허용). 더 젊은 것은 아직 보내는 다른 체크인의 것이라 둔다. 이름 자체를 보고 지운다(링크면 링크만 지워진다).
|
|
229
|
+
function dropOldClaims(now = Date.now()) {
|
|
230
|
+
const dir = dirname(USAGE_LOG_PATH);
|
|
231
|
+
let names;
|
|
232
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
233
|
+
for (const name of names) {
|
|
234
|
+
const made = CLAIM_RE.exec(name);
|
|
235
|
+
if (!made || Math.abs(now - Number(made[1])) <= CLAIM_MAX_AGE_MS) continue;
|
|
236
|
+
const path = join(dir, name);
|
|
237
|
+
try {
|
|
238
|
+
const st = lstatSync(path);
|
|
239
|
+
if (st.isFile() || st.isSymbolicLink()) unlinkSync(path);
|
|
240
|
+
} catch { /* best-effort */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
170
244
|
// 응답의 latest 로 공유 update-check 캐시를 갱신(알림 겸함). 없으면 캐시 미변경.
|
|
171
245
|
// update-fetch 처럼 notified:null 재무장을 하지 않아도 등가다: dedupe 판정이 notified===latest
|
|
172
246
|
// (update-notify)라 전진 버전에서 구 notified 는 항상 신 latest 와 달라 재고지가 정상 발화하고,
|
|
@@ -182,18 +256,27 @@ async function refreshUpdateCache(responseBody) {
|
|
|
182
256
|
try { writeUpdateCache(patch); } catch { /* best-effort */ }
|
|
183
257
|
}
|
|
184
258
|
|
|
185
|
-
// 전송 시도 후(성공/실패 무관)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
try {
|
|
259
|
+
// 전송 시도 후(성공/실패 무관) 차지한 파일을 지워 24h 윈도우로 상한(배치 유실 허용)하고 last-flush 를 남긴다.
|
|
260
|
+
// last-flush 는 새 파일에 쓴 뒤 rename 으로 놓아, 그 이름 자리의 링크를 따라 다른 파일을 덮지 않는다.
|
|
261
|
+
function finishClaim(claim) {
|
|
262
|
+
if (claim) try { unlinkSync(claim); } catch { /* best-effort */ }
|
|
263
|
+
const tmp = `${LAST_FLUSH_PATH}.${process.pid}.tmp`;
|
|
264
|
+
try {
|
|
265
|
+
writeFileSync(tmp, new Date().toISOString(), { flag: 'wx' });
|
|
266
|
+
renameSync(tmp, LAST_FLUSH_PATH);
|
|
267
|
+
} catch {
|
|
268
|
+
try { unlinkSync(tmp); } catch { /* best-effort */ }
|
|
269
|
+
}
|
|
189
270
|
}
|
|
190
271
|
|
|
191
272
|
async function main() {
|
|
192
273
|
if (!countingActive()) return; // 미활성 - 무동작(로그 미변경).
|
|
193
274
|
const url = endpoint();
|
|
194
275
|
if (!url) return; // 엔드포인트 없음 - 무동작(로그 미변경).
|
|
195
|
-
|
|
196
|
-
|
|
276
|
+
dropOldClaims();
|
|
277
|
+
const { raw, claim } = claimLog();
|
|
278
|
+
const responseBody = await post(url, checkinBody(raw));
|
|
279
|
+
finishClaim(claim); // 보내는 동안 더해진 줄은 새 로그에 남아 다음 체크인이 보낸다.
|
|
197
280
|
await refreshUpdateCache(responseBody);
|
|
198
281
|
}
|
|
199
282
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.3",
|
|
4
4
|
"description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"banker": "bin/banker.js"
|
package/skills/anti-drm/SKILL.md
CHANGED
|
@@ -258,4 +258,5 @@ argument-hint: "on | off | status | config"
|
|
|
258
258
|
| 쓰는 사이에 같은 이름이 생겨 그대로 두었습니다 | 다른 프로그램이 그 사이에 같은 이름을 만들었음(링크일 수 있음). 지우거나 바꾸지 않음. 무엇인지 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
|
|
259
259
|
| 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일은 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
|
|
260
260
|
| 다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다 | 다른 banker 스킬의 `on`, `off` 가 도는 중. 잠시 뒤 다시 실행. 끝나지 않은 작업이 남긴 잠금은 1분 뒤 저절로 넘어감. 1분이 지나도 같거나 잠금 자리가 폴더면 잠금 경로를 보이고 어떻게 할지 물은 뒤 다시 실행. 잠금을 임의로 지우지 않음 |
|
|
261
|
+
| 네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다 | Codex 폴더가 NFS 같은 네트워크 공유에 있고 공유가 파일 핸들을 잃음. 잠시 뒤 다시 실행. 계속되면 공유 연결 상태를 확인하도록 알림. 잠금을 임의로 지우지 않음 |
|
|
261
262
|
| 쓸 권한이 없음 | 해당 파일과 폴더 권한 확인 |
|
|
@@ -529,55 +529,67 @@ const LOCK_STALE_MS = 60_000;
|
|
|
529
529
|
const LOCK_STEP_MS = 50;
|
|
530
530
|
const SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
531
531
|
|
|
532
|
-
// Makes the lock file
|
|
532
|
+
// Makes the lock file: "taken", "held" when one is there already, or "stale" when a network share answered with a
|
|
533
|
+
// stale file handle (ESTALE), which the next try may not. Anything else (permissions) throws.
|
|
533
534
|
function tryLock(path, token) {
|
|
534
535
|
try {
|
|
535
536
|
writeFileSync(path, token, { flag: "wx", mode: 0o600 });
|
|
536
|
-
return
|
|
537
|
+
return "taken";
|
|
537
538
|
} catch (e) {
|
|
538
|
-
if (e.code === "EEXIST") return
|
|
539
|
+
if (e.code === "EEXIST") return "held";
|
|
540
|
+
if (e.code === "ESTALE") return "stale";
|
|
539
541
|
throw e;
|
|
540
542
|
}
|
|
541
543
|
}
|
|
542
544
|
|
|
543
|
-
// Removes a lock a dead run left
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
545
|
+
// Removes a lock a dead run left. The name itself is looked at, not where it leads: a link to nothing or a link loop
|
|
546
|
+
// there is a lock like any other. A lock dated more than a minute away either way (a clock set back) is stale too.
|
|
547
|
+
// A folder there is none of this module's: it stays, and the wait ends in a refusal. A name gone in between, or a
|
|
548
|
+
// network share's stale file handle (ESTALE) at the look or the removal, is left for the next round.
|
|
547
549
|
function dropStale(path) {
|
|
548
550
|
let st;
|
|
549
551
|
try {
|
|
550
552
|
st = lstatSync(path);
|
|
551
553
|
} catch (e) {
|
|
552
|
-
if (e.code === "ENOENT"
|
|
553
|
-
if (e.code === "ESTALE") return false; /* a network share's stale handle: rest and look again, not spin */
|
|
554
|
+
if (e.code === "ENOENT" || e.code === "ESTALE") return;
|
|
554
555
|
throw e;
|
|
555
556
|
}
|
|
556
|
-
if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return
|
|
557
|
-
|
|
558
|
-
|
|
557
|
+
if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return;
|
|
558
|
+
try {
|
|
559
|
+
rmSync(path, { force: true });
|
|
560
|
+
} catch (e) {
|
|
561
|
+
if (e.code !== "ESTALE") throw e;
|
|
562
|
+
}
|
|
559
563
|
}
|
|
560
564
|
|
|
561
|
-
// Takes the lock: { path, token }, or
|
|
562
|
-
//
|
|
565
|
+
// Takes the lock: { path, token }, or { path, why } once the wait ends without it: why is "stale" when the last try
|
|
566
|
+
// met a stale file handle instead of a lock, else "held". Every round that does not take the lock rests, so no answer
|
|
567
|
+
// of the file system makes the wait spin; the deadline is looked at every round, so no lock spot keeps it going past.
|
|
563
568
|
function takeLock(dir, waitMs) {
|
|
564
569
|
const path = join(dir, LOCK);
|
|
565
570
|
const token = `${process.pid} ${Date.now()} ${Math.random()}`;
|
|
566
571
|
const until = Date.now() + waitMs;
|
|
567
572
|
for (;;) {
|
|
568
|
-
|
|
569
|
-
if (
|
|
570
|
-
if (
|
|
573
|
+
const got = tryLock(path, token);
|
|
574
|
+
if (got === "taken") return { path, token };
|
|
575
|
+
if (Date.now() >= until) return { path, why: got };
|
|
576
|
+
if (got === "held") dropStale(path);
|
|
577
|
+
Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
|
|
571
578
|
}
|
|
572
579
|
}
|
|
573
580
|
|
|
574
581
|
// Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
|
|
575
|
-
// this run's lock and stays.
|
|
582
|
+
// this run's lock and stays. A network share's stale file handle (ESTALE) is tried again after a rest, three times
|
|
583
|
+
// at most; a lock still left goes stale in a minute.
|
|
576
584
|
function dropLock(lock) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
585
|
+
for (let tried = 1; ; tried++) {
|
|
586
|
+
try {
|
|
587
|
+
if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
|
|
588
|
+
return;
|
|
589
|
+
} catch (e) {
|
|
590
|
+
if (e.code !== "ESTALE" || tried >= 3) return; /* already gone, or not this run's to take away */
|
|
591
|
+
Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
|
|
592
|
+
}
|
|
581
593
|
}
|
|
582
594
|
}
|
|
583
595
|
|
|
@@ -592,7 +604,11 @@ function locked(o, fn) {
|
|
|
592
604
|
if (denied(e)) return fn();
|
|
593
605
|
throw e;
|
|
594
606
|
}
|
|
595
|
-
if (!lock)
|
|
607
|
+
if (!lock.token) {
|
|
608
|
+
return refuse(lock.why === "stale"
|
|
609
|
+
? `네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다: ${where(lock.path)}`
|
|
610
|
+
: `다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(lock.path)}`);
|
|
611
|
+
}
|
|
596
612
|
try {
|
|
597
613
|
return fn();
|
|
598
614
|
} finally {
|
package/skills/lineage/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: lineage
|
|
3
3
|
description: "현재 세션 대화를 카카오톡 스타일의 단일 HTML 로 내보냄. Claude 답변은 한 줄 요약 아래에 원문을 접어 두고, 펼치면 마크다운으로 렌더해 보여 줌. 기본은 세션 모델이 요약을 쓰고 노이즈 턴을 걸러 내며, `--rulebase` 는 모델 없이 규칙만 씀. 여러 세션을 합칠 수 있음. 'lineage'/'대화 export'/'카톡 스타일 html' 시 사용."
|
|
4
4
|
invocation: /lineage
|
|
5
|
-
version: 3.0.
|
|
5
|
+
version: 3.0.8
|
|
6
6
|
schema_version: 1
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -225,6 +225,7 @@ Schema-tolerant 파서: 미지 record type → stderr WARN + 다음 line. `v1`/`
|
|
|
225
225
|
1차 `detect-secrets>=1.5`(pip 설치 시), 미설치 → 내장 fallback + WARN.
|
|
226
226
|
내장: AWS IAM(AKIA/ASIA)·GitHub PAT·Slack·JWT·private key·한/영 평문 password·Shannon entropy≥4.5.
|
|
227
227
|
확장 `--redact-extra "k1,k2"`. 부분 마스킹 `--redact-mode mask`(페이지 패턴만. 검토자 패턴이 찾은 값은 늘 전부 가린다).
|
|
228
|
+
쪼개진 값도, 쪼개는 글자를 뺀 모양을 패턴이 찾으면 그 구간에서만 글자를 빼고 통째로 가린다(3.0.8). 쪼개는 글자는 보이지 않는 글자(폭 없는 공백, 소프트 하이픈, 변형 선택자 같은 기본 무시 글자와 서식 문자), 공백, 탭, 줄바꿈이 아닌 제어 문자와 특수 공백, 사용자 정의 영역 글자, 점자 빈칸, U+FFFC, U+FFFD 다. 지금 모양 그대로 패턴이 찾는 값을 반으로 가르게 되면 잇지 않는다. 다시 이은 결과가 보이는 글자 가운데 3.0.7 의 한 번이 가린 글자는 3.0.7 의 표식으로 다시 가리고(`KEY=` 처럼 3.0.7 이 값과 한 덩어리로 가린 앞 글자는 값 표식 옆의 표식 하나가 된다), 어떤 종류의 값이든 3.0.7 보다 적게 가리면 3.0.7 한 번의 결과에서 이어 간다. 그래서 3.0.7 이 가리던 글자는 모두 그대로 가린다. 비밀 밖의 글은 바꾸지 않고, 다시 이은 비밀 바로 옆의 그런 글자는 함께 빠질 수 있다. 무엇이든 이었으면 결과가 더 바뀌지 않을 때까지 되풀이한다(최대 네 번).
|
|
228
229
|
|
|
229
230
|
### 5. Render
|
|
230
231
|
|
|
@@ -331,6 +332,7 @@ pip install 'detect-secrets>=1.5' # (선택) 강한 redaction
|
|
|
331
332
|
- 규칙 요약 캐시는 본문만으로 키를 잡는다. `--redact-mode` 나 `--redact-extra` 를 바꿔 다시 돌린 `--rulebase` 는 앞선 실행의 요약을 쓸 수 있다(2.x 동작 그대로). 기본 흐름의 묶음은 요약을 매번 새로 만든다. 묶음 형식은 `lineage-review/2` 라 0.15.1 이 만든 묶음은 `--emit-review` 부터 다시 한다.
|
|
332
333
|
- 검토자는 긴 턴의 앞뒤만 본다(`clipped: true`). 가운데에만 있는 결론은 놓칠 수 있다.
|
|
333
334
|
- 검토자 패턴은 흔한 모양만 가린다. `/` 가 든 URL 비밀번호와 키 이름이 100자를 넘는 16진 키는 가리지 못한다(값이 길고 무작위면 엔트로피 규칙이 가릴 수 있다). curl `-u` 패턴은 값 전체가 `docker -u 1000:1000` 같은 uid:gid, `rsync -u host:/path`, `date -u +%H:%M` 같은 날짜 형식(`+%` 뒤에 글자가 오면 나머지는 보지 않는다), 양쪽 모두 변수인 꼴(`$UID:$GID`, `${UID}:${GID}`, `%USER%:%PASS%`, `$(id -u):$(id -g)`), 기본값이 콜론 없는 경로(`/`, `~`, `.` 로 시작)인 변수(`mktemp -u "${TMPDIR:-/tmp}/x"`), 앞선 가림이 남긴 표식(`-u [REDACTED:JWT]`)일 때만 둔다. 이스케이프한 따옴표 안에서도 같다. 다른 명령의 `-u 이름:값`(`-u root:root`, `ps -u postgres:postgres`, `rsync -avu host:dir/`, `ls -lu a:bcd`), 기본값이 경로가 아닌 변수(`${UID:-1000}:${GID:-1000}`, `${API_KEY:-…}`)는 가린다. 숫자만으로 된 이름과 비밀번호, `/` 로 시작하는 비밀번호, `$` 로 시작해 변수처럼 보이는 비밀번호(`$USER:$ecret`), `-U` 와 `--proxy-user`, 붙여 쓴 묶음(`-suUSER:PW`), `-u=USER:PW`, 공백이 든 비밀번호의 둘째 낱말부터, 인자 목록에서 다른 종류의 따옴표가 든 비밀번호(`'-u', "USER:P'W"`), 사용자 안에서 따옴표 뒤에 `-eu`, `--user` 같은 플래그 꼴과 따옴표가 오는 꼴(`"$USER"-eu":PW"`), requests 의 `auth=(...)`, value 가 name 보다 앞에 온 HAR 은 가리지 못한다. 백틱 명령 치환(`` `id -u`:`id -g` ``)과 `\$(id -u):\$(id -g)` 는 가운데 일부가 가려진다(3.0.0 과 같다). 값이 `/`, `~`, `$` 로 시작하는 password 값(`$PWD:/workspace`, `OLDPWD=/home/...`, `password=$DB_PASSWORD`), 이미 가린 표식, 값 전체가 마스크 꼴(`abcd****wxyz`, `****`)인 값은 두고, `PGPASSWORD=`, `MYSQL_PWD=` 의 값은 가린다. `password=os.environ[...]`, `암호: AES-256-GCM` 처럼 코드나 이름인 값과 `sk-` 로 시작하는 긴 세션 이름은 지나치게 가린다. Basic 인증 패턴은 `HTTP_AUTHORIZATION` 처럼 앞에 `_` 가 붙은 이름, 구분자 없는 `Authorization Basic …`, 백틱이나 대괄호(`["Basic …"]`) 안의 값을 가리지 못한다.
|
|
335
|
+
- 쪼갠 값 가운데 detect-secrets 만 찾는 값은 찾지 못한다. 키 바로 앞이 글자나 숫자이고 그 사이와 키 안에 쪼개는 글자가 있으면, 앞 글자 조건이 있는 검토자 패턴(Anthropic, OpenAI, Google 키, Bearer, Basic 인증)은 값 꼬리를 놓칠 수 있다. 태그 글자로 통째로 적거나 base64 로 감싼 값처럼 일부러 숨긴 값은 풀어 보지 않는다. ANSI 색 코드처럼 ESC 뒤에 `[01;31m` 같은 글자가 붙는 열은 ESC 만 쪼개는 글자라 다시 잇지 못한다. 3.0.7 한 번의 결과에서 이어 갈 때는 3.0.7 이 앞부분만 가린 키의 나머지가 남을 수 있다(3.0.7 과 같다). `--redact-mode mask` 의 앞뒤 4글자에는 쪼개는 글자도 한 글자로 든다. 쪼개는 글자가 있는 글은 이을 곳을 찾느라 3.0.7 의 약 3배 걸리고, 이은 곳이 있으면 가림을 몇 번 더 돌아 약 6배 걸린다(5MB 한 글에서 잼).
|
|
334
336
|
- 페이지 패턴 가운데 JWT 와 개인 키 패턴은 같은 머리(`eyJ`, `-----BEGIN`)가 긴 줄에 되풀이되면 시간이 줄 길이의 제곱으로 는다(2.x 동작 그대로). 기본 흐름은 턴마다 여러 번 가리므로 그만큼 더 걸린다.
|
|
335
337
|
- 마크다운의 **중첩 리스트는 평탄화**되고 각주는 미지원.
|
|
336
338
|
- 사용자가 질문에 래퍼 블록(`<task-notification>…`)을 **인용**하면 문장은 남고 그 블록만 사라진다.
|
|
@@ -10,7 +10,9 @@ Runtime-agnostic: works from any transcript via --session / --from-transcript.
|
|
|
10
10
|
Auto-discovery assumes Claude Code's ~/.claude/projects/<encoded-cwd>/ layout.
|
|
11
11
|
"""
|
|
12
12
|
import argparse
|
|
13
|
+
import bisect
|
|
13
14
|
import hashlib
|
|
15
|
+
import heapq
|
|
14
16
|
import hmac
|
|
15
17
|
import html
|
|
16
18
|
import json
|
|
@@ -22,6 +24,7 @@ import stat
|
|
|
22
24
|
import sys
|
|
23
25
|
import tempfile
|
|
24
26
|
import unicodedata
|
|
27
|
+
from array import array
|
|
25
28
|
from html.parser import HTMLParser
|
|
26
29
|
from typing import Any
|
|
27
30
|
|
|
@@ -170,15 +173,187 @@ def _mask(s: str) -> str:
|
|
|
170
173
|
return (s[:4] + "****" + s[-4:]) if len(s) > 8 else "[REDACTED]"
|
|
171
174
|
|
|
172
175
|
|
|
173
|
-
|
|
174
|
-
"""
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
class _Trail:
|
|
177
|
+
"""Where each character of a text that redaction rewrites stood in the text it started as (-1 for one a marker
|
|
178
|
+
or a mask put in). A text whose split secrets were joined first is kept only if it shows no character that the
|
|
179
|
+
one pass over the text as it stands (3.0.7) hid (3.0.8)."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, n):
|
|
182
|
+
self.n, self.pos = n, None # None: each character is where it was
|
|
183
|
+
|
|
184
|
+
def carry(self, edits):
|
|
185
|
+
"""Follow `edits` (start, end, replacement), sorted and apart: an empty replacement drops what it covers, a
|
|
186
|
+
mask keeps where its first and last four characters were, a marker is all new."""
|
|
187
|
+
if not edits:
|
|
188
|
+
return
|
|
189
|
+
pos = array("q", range(self.n)) if self.pos is None else self.pos
|
|
190
|
+
out, at, new = array("q"), 0, array("q", [-1])
|
|
191
|
+
for s, e, r in edits:
|
|
192
|
+
out += pos[at:s]
|
|
193
|
+
if r.startswith("[REDACTED"):
|
|
194
|
+
out += new * len(r)
|
|
195
|
+
elif r:
|
|
196
|
+
out += pos[s:s + 4] + new * (len(r) - 8) + pos[e - 4:e]
|
|
197
|
+
at = e
|
|
198
|
+
self.pos = out + pos[at:]
|
|
199
|
+
|
|
200
|
+
def sub(self, pat, repl, text):
|
|
201
|
+
"""pat.sub(repl, text), the trail following what it replaced."""
|
|
202
|
+
edits = []
|
|
203
|
+
|
|
204
|
+
def noted(m):
|
|
205
|
+
r = repl(m)
|
|
206
|
+
if r != m.group(0):
|
|
207
|
+
edits.append((m.start(), m.end(), r))
|
|
208
|
+
return r
|
|
209
|
+
out = pat.sub(noted, text)
|
|
210
|
+
self.carry(edits)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
def shown(self):
|
|
214
|
+
"""A byte for each character of the text it started as: 1 where the text it follows shows that character."""
|
|
215
|
+
if self.pos is None:
|
|
216
|
+
return bytearray(b"\x01") * self.n
|
|
217
|
+
seen = bytearray(self.n)
|
|
218
|
+
for p in self.pos:
|
|
219
|
+
if p >= 0:
|
|
220
|
+
seen[p] = 1
|
|
221
|
+
return seen
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _plain_sub(pat, repl, text):
|
|
225
|
+
return pat.sub(repl, text)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _sub_for(trail):
|
|
229
|
+
"""The substitution a redaction layer makes its replacements with: kept on `trail` when there is one."""
|
|
230
|
+
return _plain_sub if trail is None else trail.sub
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _shows_more(trail, shown):
|
|
234
|
+
"""Whether the text `trail` follows shows a character of the text it started as that `shown` (a _Trail.shown of
|
|
235
|
+
another text from the same start) has hidden."""
|
|
236
|
+
return (int.from_bytes(trail.shown(), "little") & ~int.from_bytes(shown, "little")) != 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Characters that cut a value for the patterns while no person reads them as a separator: Unicode's default-ignorable
|
|
240
|
+
# code points (the zero-width space and joiners, the soft hyphen, the direction controls, the variation selectors,
|
|
241
|
+
# the tag characters, the Hangul fillers) and the other format characters, the controls and spaces that are no
|
|
242
|
+
# space, tab or line break, private use, the braille blank, and U+FFFC and U+FFFD, which stand for something lost
|
|
243
|
+
# (a lone surrogate became U+FFFD). A value cut by them is put back together where a pattern finds it once they
|
|
244
|
+
# are gone (3.0.8).
|
|
245
|
+
_SPLITTER_RANGES = (
|
|
246
|
+
(0x0000, 0x0008), (0x000E, 0x001B), (0x007F, 0x0084), (0x0086, 0x009F), (0x00A0, 0x00A0), (0x00AD, 0x00AD),
|
|
247
|
+
(0x034F, 0x034F), (0x0600, 0x0605), (0x061C, 0x061C), (0x06DD, 0x06DD), (0x070F, 0x070F), (0x0890, 0x0891),
|
|
248
|
+
(0x08E2, 0x08E2), (0x115F, 0x1160), (0x1680, 0x1680), (0x17B4, 0x17B5), (0x180B, 0x180F), (0x2000, 0x200F),
|
|
249
|
+
(0x202A, 0x202F), (0x205F, 0x206F), (0x2800, 0x2800), (0x3000, 0x3000), (0x3164, 0x3164), (0xE000, 0xF8FF),
|
|
250
|
+
(0xFE00, 0xFE0F), (0xFEFF, 0xFEFF), (0xFFA0, 0xFFA0), (0xFFF0, 0xFFFD), (0x110BD, 0x110BD), (0x110CD, 0x110CD),
|
|
251
|
+
(0x13430, 0x1343F), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF), (0xF0000, 0xFFFFD),
|
|
252
|
+
(0x100000, 0x10FFFD))
|
|
253
|
+
_SPLITTERS = "".join(chr(a) if a == b else chr(a) + "-" + chr(b) for a, b in _SPLITTER_RANGES)
|
|
254
|
+
_SPLITTER_RE = re.compile("[" + _SPLITTERS + "]")
|
|
255
|
+
_UNSPLIT_RE = re.compile("[^" + _SPLITTERS + "]+")
|
|
256
|
+
# A character a value can hold: past a splitter next to a found value, one of these means another value begins.
|
|
257
|
+
_VALUE_CHAR_RE = re.compile(r"[A-Za-z0-9_+/=.\-]")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _unsplit_runs(text):
|
|
261
|
+
"""`text` without its splitters, and where each run of other characters starts in it and in `text`."""
|
|
262
|
+
firsts, origins, parts, at = array("q"), array("q"), [], 0
|
|
263
|
+
for m in _UNSPLIT_RE.finditer(text):
|
|
264
|
+
firsts.append(at)
|
|
265
|
+
origins.append(m.start())
|
|
266
|
+
parts.append(m.group(0))
|
|
267
|
+
at += m.end() - m.start()
|
|
268
|
+
return "".join(parts), firsts, origins
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _matches(text, patterns, entropy):
|
|
272
|
+
"""The matches of `patterns` in `text`, and with `entropy` the runs the entropy rule hides."""
|
|
273
|
+
found = [m for pat in patterns for m in pat.finditer(text)]
|
|
274
|
+
if entropy:
|
|
275
|
+
found += [m for m in ENTROPY_LONG.finditer(text) if shannon_entropy(m.group(0)) >= 4.5]
|
|
276
|
+
return found
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _cut_spans(text, joined, firsts, origins, matches):
|
|
280
|
+
"""Where in `text` the values `matches` found in `joined` lie, for those splitters cut or touch. A stretch takes
|
|
281
|
+
in the splitters next to its value when the character past them is no value character (so the value's
|
|
282
|
+
neighbours are those it had in `joined`); a splitter between the value and another value stays."""
|
|
283
|
+
def at(j): # where joined[j] is in text
|
|
284
|
+
k = bisect.bisect_right(firsts, j) - 1
|
|
285
|
+
return origins[k] + j - firsts[k]
|
|
286
|
+
spans = []
|
|
287
|
+
for m in matches:
|
|
288
|
+
s, e = at(m.start()), at(m.end() - 1) + 1
|
|
289
|
+
before = at(m.start() - 1) + 1 if m.start() else 0
|
|
290
|
+
after = at(m.end()) if m.end() < len(joined) else len(text)
|
|
291
|
+
if before == 0 or not _VALUE_CHAR_RE.match(text[before - 1]):
|
|
292
|
+
s = before
|
|
293
|
+
if after == len(text) or not _VALUE_CHAR_RE.match(text[after]):
|
|
294
|
+
e = after
|
|
295
|
+
if e - s > m.end() - m.start():
|
|
296
|
+
spans.append((s, e))
|
|
297
|
+
return spans
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _kept(spans, plain):
|
|
301
|
+
"""The stretches of `spans` to join: those no value the patterns find in the text as it stands holds already,
|
|
302
|
+
and that cut none of those values in two. Joining across such a value would glue it to its neighbour, so the
|
|
303
|
+
neighbour's own pattern (a lookbehind, a keyword the greedy class ate) no longer finds it. Both lists are swept
|
|
304
|
+
in order with a heap of the values still open, so many of them stay fast."""
|
|
305
|
+
plain, out, heap, at = sorted(plain), [], [], 0
|
|
306
|
+
for s, e in sorted(spans):
|
|
307
|
+
while at < len(plain) and plain[at][0] < e:
|
|
308
|
+
heapq.heappush(heap, (plain[at][1], plain[at][0]))
|
|
309
|
+
at += 1
|
|
310
|
+
while heap and heap[0][0] <= s:
|
|
311
|
+
heapq.heappop(heap)
|
|
312
|
+
if not any((o_s <= s and e <= o_e) or not (s <= o_s and o_e <= e) for o_e, o_s in heap):
|
|
313
|
+
out.append((s, e))
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _merged(spans):
|
|
318
|
+
"""Sorted stretches with those that overlap or touch put together."""
|
|
319
|
+
out = []
|
|
320
|
+
for s, e in spans:
|
|
321
|
+
if out and s <= out[-1][1]:
|
|
322
|
+
out[-1][1] = max(out[-1][1], e)
|
|
323
|
+
else:
|
|
324
|
+
out.append([s, e])
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _rejoin_split(text, patterns, entropy=False, trail=None):
|
|
329
|
+
"""`text` with the splitters taken out of each stretch where, once they are gone, one of `patterns` (and with
|
|
330
|
+
`entropy` the entropy rule) finds a value that the text as it stands does not show whole: the layers after
|
|
331
|
+
then see that value and hide it. The rest of the text is left as it is (3.0.8). `trail` follows what is taken out."""
|
|
332
|
+
if not _SPLITTER_RE.search(text):
|
|
333
|
+
return text
|
|
334
|
+
joined, firsts, origins = _unsplit_runs(text)
|
|
335
|
+
spans = _cut_spans(text, joined, firsts, origins, _matches(joined, patterns, entropy))
|
|
336
|
+
if spans:
|
|
337
|
+
spans = _kept(spans, [(m.start(), m.end()) for m in _matches(text, patterns, entropy)])
|
|
338
|
+
drops = [(m.start(), m.end(), "") for s, e in _merged(sorted(spans)) for m in _SPLITTER_RE.finditer(text, s, e)]
|
|
339
|
+
if trail is not None:
|
|
340
|
+
trail.carry(drops)
|
|
341
|
+
out, at = [], 0
|
|
342
|
+
for s, e, _ in drops:
|
|
343
|
+
out.append(text[at:s])
|
|
344
|
+
at = e
|
|
345
|
+
return "".join(out) + text[at:]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None):
|
|
349
|
+
"""Multi-layer redaction; page_redact and review_redact join split secrets first (3.0.8). Returns (text, counts).
|
|
350
|
+
With `trail` (a _Trail), where each character of the result came from is followed too."""
|
|
351
|
+
counts, out, sub = {}, text, _sub_for(trail)
|
|
177
352
|
for name, pat in SECRET_PATTERNS:
|
|
178
353
|
def repl(m, n=name):
|
|
179
354
|
counts[n] = counts.get(n, 0) + 1
|
|
180
355
|
return _mask(m.group(0)) if mode == "mask" else f"[REDACTED:{n}]"
|
|
181
|
-
out =
|
|
356
|
+
out = sub(pat, repl, out)
|
|
182
357
|
|
|
183
358
|
def ent_repl(m):
|
|
184
359
|
s = m.group(0)
|
|
@@ -186,7 +361,7 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
|
|
|
186
361
|
counts["entropy"] = counts.get("entropy", 0) + 1
|
|
187
362
|
return _mask(s) if mode == "mask" else "[REDACTED:entropy]"
|
|
188
363
|
return s
|
|
189
|
-
out =
|
|
364
|
+
out = sub(ENTROPY_LONG, ent_repl, out)
|
|
190
365
|
|
|
191
366
|
try:
|
|
192
367
|
from detect_secrets import SecretsCollection
|
|
@@ -204,7 +379,7 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
|
|
|
204
379
|
for s in col[fname]:
|
|
205
380
|
sv = s.secret_value
|
|
206
381
|
if sv and sv in out:
|
|
207
|
-
out =
|
|
382
|
+
out = sub(re.compile(re.escape(sv)), lambda m: "[REDACTED:ds]", out)
|
|
208
383
|
counts["ds:detect-secrets"] = counts.get(
|
|
209
384
|
"ds:detect-secrets", 0) + 1
|
|
210
385
|
finally:
|
|
@@ -228,8 +403,8 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
|
|
|
228
403
|
# a marker an earlier pass left stays whole: a keyword inside its name is no secret
|
|
229
404
|
pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
|
|
230
405
|
hits = []
|
|
231
|
-
out =
|
|
232
|
-
|
|
406
|
+
out = sub(pat, lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
|
|
407
|
+
else hits.append(1) or "[REDACTED]", out)
|
|
233
408
|
if hits:
|
|
234
409
|
counts[f"custom:{kw}"] = len(hits)
|
|
235
410
|
return out, counts
|
|
@@ -315,35 +490,177 @@ REVIEW_SECRET_PATTERNS = [
|
|
|
315
490
|
]
|
|
316
491
|
|
|
317
492
|
|
|
318
|
-
def _reviewer_patterns(text):
|
|
493
|
+
def _reviewer_patterns(text, trail=None):
|
|
319
494
|
"""`text` with what the reviewer patterns find replaced whole by [REDACTED:<name>], and the
|
|
320
|
-
count by name."""
|
|
321
|
-
counts = {}
|
|
495
|
+
count by name. A split secret is put back together by its callers first (3.0.8)."""
|
|
496
|
+
counts, sub = {}, _sub_for(trail)
|
|
322
497
|
for name, pat in REVIEW_SECRET_PATTERNS:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
498
|
+
hits = []
|
|
499
|
+
text = sub(pat, lambda m, marker=f"[REDACTED:{name}]": hits.append(1) or marker, text)
|
|
500
|
+
if hits:
|
|
501
|
+
counts[name] = len(hits)
|
|
326
502
|
return text, counts
|
|
327
503
|
|
|
328
504
|
|
|
505
|
+
def _rejoin_secrets(text, extra=None, trail=None):
|
|
506
|
+
"""`text` with each secret a splitter cuts put back together, for every pattern a page or a reviewer's text is
|
|
507
|
+
hidden with: the reviewer's, the page's, the entropy rule and the --redact-extra keywords (3.0.8)."""
|
|
508
|
+
keywords = [k.strip() for k in (extra or "").split(",") if k.strip()]
|
|
509
|
+
patterns = ([p for _, p in REVIEW_SECRET_PATTERNS] + [p for _, p in SECRET_PATTERNS]
|
|
510
|
+
+ [re.compile(re.escape(k), re.IGNORECASE) for k in keywords])
|
|
511
|
+
return _rejoin_split(text, patterns, entropy=True, trail=trail)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _join_first(text, extra, one_pass):
|
|
515
|
+
"""`one_pass` (text, trail -> (text, found)) over `text` with its split secrets put back together. Once anything
|
|
516
|
+
was joined, again over its output until a pass changes nothing (at most four passes): a value next to one a
|
|
517
|
+
marker replaced may be found only then. Returns (text, [found], the _Trail of the text, or None when nothing
|
|
518
|
+
was joined)."""
|
|
519
|
+
trail = _Trail(len(text)) if _SPLITTER_RE.search(text) else None
|
|
520
|
+
joined = _rejoin_secrets(text, extra, trail)
|
|
521
|
+
moved = joined != text
|
|
522
|
+
red, found = one_pass(joined, trail)
|
|
523
|
+
founds = [found]
|
|
524
|
+
for _ in range(3):
|
|
525
|
+
joined = _rejoin_secrets(red, extra, trail)
|
|
526
|
+
if joined == red and not moved:
|
|
527
|
+
break # nothing joined: 3.0.7's one pass is final
|
|
528
|
+
moved = True
|
|
529
|
+
again, found = one_pass(joined, trail)
|
|
530
|
+
if again == red:
|
|
531
|
+
break
|
|
532
|
+
red = again
|
|
533
|
+
founds.append(found)
|
|
534
|
+
return red, founds, trail if moved else None
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# The key patterns that may find the same key: one kind when passes are compared.
|
|
538
|
+
_KEY_FAMILY = {"AnthropicKey": "key", "OpenAIKey": "key", "GoogleKey": "key"}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _kinds(founds):
|
|
542
|
+
"""How many values of each kind the passes hid, the entropy rule and detect-secrets apart: joining pieces
|
|
543
|
+
changes how many runs the entropy rule sees."""
|
|
544
|
+
kinds = {}
|
|
545
|
+
for found in founds:
|
|
546
|
+
for k, v in found.items():
|
|
547
|
+
if k != "entropy" and not k.startswith("ds:"):
|
|
548
|
+
k = _KEY_FAMILY.get(k, k)
|
|
549
|
+
kinds[k] = kinds.get(k, 0) + v
|
|
550
|
+
return kinds
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _from_floor(red, found, extra, one_pass):
|
|
554
|
+
"""Joining as _join_first does, but from the output of one pass over the text as it stands."""
|
|
555
|
+
founds = [found]
|
|
556
|
+
for _ in range(4):
|
|
557
|
+
joined = _rejoin_secrets(red, extra)
|
|
558
|
+
if joined == red:
|
|
559
|
+
break
|
|
560
|
+
again, found = one_pass(joined)
|
|
561
|
+
if again == red:
|
|
562
|
+
break
|
|
563
|
+
red = again
|
|
564
|
+
founds.append(found)
|
|
565
|
+
return red, founds
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _hidden_by(floor, base):
|
|
569
|
+
"""The stretches of the text it started as that `floor` (whose trail is `base`) hides, in order, each with what
|
|
570
|
+
stands for it in `floor`: [(start, end, marker)]."""
|
|
571
|
+
pos, out, i, n = base.pos, [], 0, len(floor)
|
|
572
|
+
while i < n:
|
|
573
|
+
if pos[i] >= 0:
|
|
574
|
+
i += 1
|
|
575
|
+
continue
|
|
576
|
+
j = i
|
|
577
|
+
while j < n and pos[j] < 0:
|
|
578
|
+
j += 1
|
|
579
|
+
out.append((pos[i - 1] + 1 if i else 0, pos[j] if j < n else base.n, floor[i:j]))
|
|
580
|
+
i = j
|
|
581
|
+
return out
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _patched(red, trail, shown, hidden):
|
|
585
|
+
"""`red` (whose trail is `trail`) with each run of characters it shows that `shown` has hidden put under what
|
|
586
|
+
stands for them in that text (`hidden`, from _hidden_by), and how many markers that took by name."""
|
|
587
|
+
pos, starts = trail.pos, [lo for lo, _, _ in hidden]
|
|
588
|
+
out, counts, at, i = [], {}, 0, 0
|
|
589
|
+
while i < len(red):
|
|
590
|
+
if pos[i] < 0 or shown[pos[i]]:
|
|
591
|
+
i += 1
|
|
592
|
+
continue
|
|
593
|
+
j = i + 1
|
|
594
|
+
while j < len(red) and pos[j] >= 0 and not shown[pos[j]]:
|
|
595
|
+
j += 1
|
|
596
|
+
marker = hidden[bisect.bisect_right(starts, pos[i]) - 1][2]
|
|
597
|
+
name = re.fullmatch(r"\[REDACTED:(\w+)\]", marker)
|
|
598
|
+
if name:
|
|
599
|
+
counts[name.group(1)] = counts.get(name.group(1), 0) + 1
|
|
600
|
+
out += [red[at:i], marker]
|
|
601
|
+
at = i = j
|
|
602
|
+
return "".join(out) + red[at:], counts
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _with_rejoins(text, extra, one_pass):
|
|
606
|
+
"""_join_first, with each character it shows that one pass over the text as it stands hid (3.0.7 did that one
|
|
607
|
+
pass) put back under that pass's marker; but if it hides fewer values of some kind, the joining starts from that
|
|
608
|
+
pass's output instead. Each pass only hides more, so no character 3.0.7 hid shows (3.0.8). Returns (text,
|
|
609
|
+
[found])."""
|
|
610
|
+
red, founds, trail = _join_first(text, extra, one_pass)
|
|
611
|
+
if trail is None:
|
|
612
|
+
return red, founds # nothing joined: that was the one pass over the text as it stands
|
|
613
|
+
base = _Trail(len(text))
|
|
614
|
+
floor, found = one_pass(text, base)
|
|
615
|
+
joined = _kinds(founds)
|
|
616
|
+
if not all(joined.get(k, 0) >= v for k, v in _kinds([found]).items()):
|
|
617
|
+
return _from_floor(floor, found, extra, one_pass)
|
|
618
|
+
shown = base.shown()
|
|
619
|
+
if not _shows_more(trail, shown):
|
|
620
|
+
return red, founds
|
|
621
|
+
patched, put_back = _patched(red, trail, shown, _hidden_by(floor, base))
|
|
622
|
+
again, found = one_pass(patched)
|
|
623
|
+
out, more = _from_floor(again, found, extra, one_pass)
|
|
624
|
+
return out, founds + [put_back] + more
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _review_pass(text, extra, trail=None):
|
|
628
|
+
pre, counts = _reviewer_patterns(text, trail)
|
|
629
|
+
red, found = redact(pre, extra=extra, mode="full", trail=trail)
|
|
630
|
+
for k, v in found.items():
|
|
631
|
+
counts[k] = counts.get(k, 0) + v
|
|
632
|
+
return red, counts
|
|
633
|
+
|
|
634
|
+
|
|
329
635
|
def review_redact(text, extra=None):
|
|
330
636
|
"""Text as a reviewer model reads it: the reviewer-only patterns first, while the keys
|
|
331
637
|
are whole (the entropy rule would otherwise cut a key and leave the rest), then fully
|
|
332
|
-
redacted (never masked, whatever --redact-mode says).
|
|
333
|
-
|
|
334
|
-
red,
|
|
335
|
-
return red, sum(
|
|
638
|
+
redacted (never masked, whatever --redact-mode says). A secret a splitter cuts is put back
|
|
639
|
+
together (3.0.8). Returns (text, count)."""
|
|
640
|
+
red, founds = _with_rejoins(text, extra, lambda t, trail=None: _review_pass(t, extra, trail))
|
|
641
|
+
return red, sum(sum(f.values()) for f in founds)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _page_pass(text, extra, mode, trail=None):
|
|
645
|
+
pre, counts = _reviewer_patterns(text, trail)
|
|
646
|
+
red, found = redact(pre, extra=extra, mode=mode, trail=trail)
|
|
647
|
+
for k, v in found.items():
|
|
648
|
+
counts[k] = counts.get(k, 0) + v
|
|
649
|
+
return red, counts
|
|
336
650
|
|
|
337
651
|
|
|
338
652
|
def page_redact(text, extra=None, mode="full"):
|
|
339
653
|
"""Text as every page shows it (3.0.2): the reviewer patterns first, while keys are whole,
|
|
340
654
|
each hit replaced whole whatever --redact-mode says (a mask keeps 4+4 characters, most of
|
|
341
|
-
a short password); then the page's redaction with this run's mode and keywords.
|
|
342
|
-
(text, count_by_kind)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
655
|
+
a short password); then the page's redaction with this run's mode and keywords. A secret a
|
|
656
|
+
splitter cuts is put back together (3.0.8). Returns (text, count_by_kind). A second pass
|
|
657
|
+
over its output finds nothing, but for two values glued with no separator at all (a key
|
|
658
|
+
run into `Bearer`), where the second shows only once the first is a marker, as in 3.0.7."""
|
|
659
|
+
red, founds = _with_rejoins(text, extra, lambda t, trail=None: _page_pass(t, extra, mode, trail))
|
|
660
|
+
counts = {}
|
|
661
|
+
for found in founds:
|
|
662
|
+
for k, v in found.items():
|
|
663
|
+
counts[k] = counts.get(k, 0) + v
|
|
347
664
|
return red, counts
|
|
348
665
|
|
|
349
666
|
|
|
@@ -209,4 +209,5 @@ argument-hint: "on | off | status"
|
|
|
209
209
|
| 쓰는 사이에 같은 이름이 생겨 그대로 두었습니다 | 다른 프로그램이 그 사이에 같은 이름을 만들었음(링크일 수 있음). 지우거나 바꾸지 않음. 무엇인지 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
|
|
210
210
|
| 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일은 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
|
|
211
211
|
| 다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다 | 다른 banker 스킬의 `on`, `off` 가 도는 중. 잠시 뒤 다시 실행. 끝나지 않은 작업이 남긴 잠금은 1분 뒤 저절로 넘어감. 1분이 지나도 같거나 잠금 자리가 폴더면 잠금 경로를 보이고 어떻게 할지 물은 뒤 다시 실행. 잠금을 임의로 지우지 않음 |
|
|
212
|
+
| 네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다 | Codex 폴더가 NFS 같은 네트워크 공유에 있고 공유가 파일 핸들을 잃음. 잠시 뒤 다시 실행. 계속되면 공유 연결 상태를 확인하도록 알림. 잠금을 임의로 지우지 않음 |
|
|
212
213
|
| 쓸 권한이 없음 | 해당 파일과 폴더 권한 확인 |
|
|
@@ -529,55 +529,67 @@ const LOCK_STALE_MS = 60_000;
|
|
|
529
529
|
const LOCK_STEP_MS = 50;
|
|
530
530
|
const SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
531
531
|
|
|
532
|
-
// Makes the lock file
|
|
532
|
+
// Makes the lock file: "taken", "held" when one is there already, or "stale" when a network share answered with a
|
|
533
|
+
// stale file handle (ESTALE), which the next try may not. Anything else (permissions) throws.
|
|
533
534
|
function tryLock(path, token) {
|
|
534
535
|
try {
|
|
535
536
|
writeFileSync(path, token, { flag: "wx", mode: 0o600 });
|
|
536
|
-
return
|
|
537
|
+
return "taken";
|
|
537
538
|
} catch (e) {
|
|
538
|
-
if (e.code === "EEXIST") return
|
|
539
|
+
if (e.code === "EEXIST") return "held";
|
|
540
|
+
if (e.code === "ESTALE") return "stale";
|
|
539
541
|
throw e;
|
|
540
542
|
}
|
|
541
543
|
}
|
|
542
544
|
|
|
543
|
-
// Removes a lock a dead run left
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
545
|
+
// Removes a lock a dead run left. The name itself is looked at, not where it leads: a link to nothing or a link loop
|
|
546
|
+
// there is a lock like any other. A lock dated more than a minute away either way (a clock set back) is stale too.
|
|
547
|
+
// A folder there is none of this module's: it stays, and the wait ends in a refusal. A name gone in between, or a
|
|
548
|
+
// network share's stale file handle (ESTALE) at the look or the removal, is left for the next round.
|
|
547
549
|
function dropStale(path) {
|
|
548
550
|
let st;
|
|
549
551
|
try {
|
|
550
552
|
st = lstatSync(path);
|
|
551
553
|
} catch (e) {
|
|
552
|
-
if (e.code === "ENOENT"
|
|
553
|
-
if (e.code === "ESTALE") return false; /* a network share's stale handle: rest and look again, not spin */
|
|
554
|
+
if (e.code === "ENOENT" || e.code === "ESTALE") return;
|
|
554
555
|
throw e;
|
|
555
556
|
}
|
|
556
|
-
if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return
|
|
557
|
-
|
|
558
|
-
|
|
557
|
+
if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return;
|
|
558
|
+
try {
|
|
559
|
+
rmSync(path, { force: true });
|
|
560
|
+
} catch (e) {
|
|
561
|
+
if (e.code !== "ESTALE") throw e;
|
|
562
|
+
}
|
|
559
563
|
}
|
|
560
564
|
|
|
561
|
-
// Takes the lock: { path, token }, or
|
|
562
|
-
//
|
|
565
|
+
// Takes the lock: { path, token }, or { path, why } once the wait ends without it: why is "stale" when the last try
|
|
566
|
+
// met a stale file handle instead of a lock, else "held". Every round that does not take the lock rests, so no answer
|
|
567
|
+
// of the file system makes the wait spin; the deadline is looked at every round, so no lock spot keeps it going past.
|
|
563
568
|
function takeLock(dir, waitMs) {
|
|
564
569
|
const path = join(dir, LOCK);
|
|
565
570
|
const token = `${process.pid} ${Date.now()} ${Math.random()}`;
|
|
566
571
|
const until = Date.now() + waitMs;
|
|
567
572
|
for (;;) {
|
|
568
|
-
|
|
569
|
-
if (
|
|
570
|
-
if (
|
|
573
|
+
const got = tryLock(path, token);
|
|
574
|
+
if (got === "taken") return { path, token };
|
|
575
|
+
if (Date.now() >= until) return { path, why: got };
|
|
576
|
+
if (got === "held") dropStale(path);
|
|
577
|
+
Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
|
|
571
578
|
}
|
|
572
579
|
}
|
|
573
580
|
|
|
574
581
|
// Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
|
|
575
|
-
// this run's lock and stays.
|
|
582
|
+
// this run's lock and stays. A network share's stale file handle (ESTALE) is tried again after a rest, three times
|
|
583
|
+
// at most; a lock still left goes stale in a minute.
|
|
576
584
|
function dropLock(lock) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
585
|
+
for (let tried = 1; ; tried++) {
|
|
586
|
+
try {
|
|
587
|
+
if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
|
|
588
|
+
return;
|
|
589
|
+
} catch (e) {
|
|
590
|
+
if (e.code !== "ESTALE" || tried >= 3) return; /* already gone, or not this run's to take away */
|
|
591
|
+
Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
|
|
592
|
+
}
|
|
581
593
|
}
|
|
582
594
|
}
|
|
583
595
|
|
|
@@ -592,7 +604,11 @@ function locked(o, fn) {
|
|
|
592
604
|
if (denied(e)) return fn();
|
|
593
605
|
throw e;
|
|
594
606
|
}
|
|
595
|
-
if (!lock)
|
|
607
|
+
if (!lock.token) {
|
|
608
|
+
return refuse(lock.why === "stale"
|
|
609
|
+
? `네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다: ${where(lock.path)}`
|
|
610
|
+
: `다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(lock.path)}`);
|
|
611
|
+
}
|
|
596
612
|
try {
|
|
597
613
|
return fn();
|
|
598
614
|
} finally {
|