@kaydash9999/banker-plugins 0.20.1 → 0.20.5

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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +195 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +27 -4
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +5 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +137 -30
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +13 -5
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +381 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -45,7 +45,7 @@ argument-hint: "on | off | status | config"
45
45
  |---|---|
46
46
  | `없음` | 아래 "질문" 을 한 뒤 "설정 넘기기" 대로 `on --runtime <런타임> --stdin` 실행 |
47
47
  | 파일 경로 | `on --runtime <런타임>` |
48
- | `올바르지 않음` | 이유를 전하고 설정을 다시 정할지 물음. 정하면 "질문" 뒤 `없음` 행과 같은 명령 실행. 그 파일은 새 파일로 바뀌고(그 자리가 폴더나 특수 파일이거나, 64KiB 보다 크거나, 읽을 수 없는 파일이면 바꾸지 않고 거부함. 링크와 하드 링크(이름이 둘 이상인 일반 파일)는 거부하지 않고 그 이름만 일반 파일로 바꾸며, 가리키던 파일은 그대로 두고 `.bak` 에 남기지 않음), anti-drm 형식이 아니면 `anti-drm.json.bak` 에 한 번 남음. 에이전트가 그 파일을 직접 지우거나 옮기지 않음 |
48
+ | `올바르지 않음` | 이유를 전하고 설정을 다시 정할지 물음. 정하면 "질문" 뒤 `없음` 행과 같은 명령 실행. 그 파일은 새 파일로 바뀌고(그 자리가 폴더나 특수 파일이거나, 64KiB 보다 크거나, 읽을 수 없는 파일이면 바꾸지 않고 거부함. 링크와 하드 링크(이름이 둘 이상인 일반 파일)는 거부하지 않고 그 이름만 일반 파일로 바꾸며, 가리키던 파일은 그대로 두고 `.bak` 에 남기지 않음), anti-drm 형식이 아니면 `anti-drm.json.bak` 에 한 번 남음. 에이전트가 그 파일을 직접 지우거나 옮기지 않음. 그 자리가 링크가 아니고 이유가 `일반 파일이 아닙니다`, `64KiB 보다 큽니다`, `읽을 권한이 없습니다` 면 설정을 다시 묻지 않음. 그 자리가 무엇인지 보이고 옮길지 권한을 고칠지 물은 뒤 다시 실행 |
49
49
 
50
50
  3. 성공하면 출력의 `적용할 규칙:` 아래 규칙을 이 세션에 바로 적용. 다음 세션부터는 자동 적용
51
51
 
@@ -214,6 +214,7 @@ argument-hint: "on | off | status | config"
214
214
  - `.md` 와 `.json` 은 금지할 수 없음. 규칙 파일과 설정 파일의 형식
215
215
  - 목록은 각각 64개까지
216
216
  - 읽는 파일: 64KiB 이하의 일반 파일만(링크면 가리키는 파일을 읽음). 소유자는 이 계정, 또는 root 만 쓸 수 있는 root
217
+ - 설정 자리가 링크면 `status` 는 설정 줄에 링크가 가리키는 곳을 함께 보이고, 그 파일의 틀린 값은 글 대신 형식과 길이만 보임(`(string, 12자)`)
217
218
  - 폴더, FIFO, 64KiB 를 넘는 파일, 읽을 수 없는 파일은 덮어쓰지 않음(링크와 하드 링크는 다음 줄)
218
219
  - 저장할 때 링크와 하드 링크(이름이 둘 이상인 일반 파일)는 그 이름만 새 파일로 바꿈. 가리키던 파일은 읽지 않고 그대로 둠
219
220
  - anti-drm 이 쓰지 않은 파일이 그 자리에 있으면 `anti-drm.json.bak` 에 한 번 남김 (이 계정만 읽기 가능). 링크와 하드 링크는 남기지 않음
@@ -242,7 +243,8 @@ argument-hint: "on | off | status | config"
242
243
  | 설정이 없습니다 | "질문" 으로 설정을 정해 `on` 에 넘김 |
243
244
  | 표준 입력에 설정 줄이 없습니다 | heredoc 이 비었음. "설정 넘기기" 대로 줄을 넣어 다시 실행 |
244
245
  | 표준 입력이 64KiB 보다 큽니다 | "답 처리" 대로 정규화한 값만 넘김 |
245
- | 설정 파일이 올바르지 않습니다 | 이유를 전하고 설정을 다시 정할지 물은 뒤 진행. 정하면 "질문" 뒤 켜는 중이면 `on --runtime <런타임> --stdin`, 설정만 바꿀 때는 `config --stdin` 실행. 그 파일은 새 파일로 바뀌고(그 자리가 폴더나 특수 파일이거나, 64KiB 보다 크거나, 읽을 수 없는 파일이면 바꾸지 않고 거부함. 링크와 하드 링크(이름이 둘 이상인 일반 파일)는 거부하지 않고 그 이름만 일반 파일로 바꾸며, 가리키던 파일은 그대로 두고 `.bak` 에 남기지 않음), anti-drm 형식이 아니면 `anti-drm.json.bak` 에 한 번 남음(먼저 남긴 파일이나 일반 파일이 아닌 것이 그 자리에 있으면 남기지 않고 결과에 알림). 에이전트가 그 파일을 직접 지우거나 옮기지 않음 |
246
+ | 설정 파일이 올바르지 않습니다 (뒤에 `--stdin 이나 --deny, --alt 로 함께 주세요` 가 붙음) | 이유를 전하고 설정을 다시 정할지 물은 뒤 진행. 정하면 "질문" 뒤 켜는 중이면 `on --runtime <런타임> --stdin`, 설정만 바꿀 때는 `config --stdin` 실행. 그 파일은 새 파일로 바뀌고(그 자리가 폴더나 특수 파일이거나, 64KiB 보다 크거나, 읽을 수 없는 파일이면 바꾸지 않고 거부함. 링크와 하드 링크(이름이 둘 이상인 일반 파일)는 거부하지 않고 그 이름만 일반 파일로 바꾸며, 가리키던 파일은 그대로 두고 `.bak` 에 남기지 않음), anti-drm 형식이 아니면 `anti-drm.json.bak` 에 한 번 남음(먼저 남긴 파일이나 일반 파일이 아닌 것이 그 자리에 있으면 남기지 않고 결과에 알림). 에이전트가 그 파일을 직접 지우거나 옮기지 않음 |
247
+ | 설정 파일이 올바르지 않습니다 (뒤에 `anti-drm 은 이 자리를 덮어쓰지 않습니다` 가 붙음) | 설정 자리가 폴더, 특수 파일, 64KiB 보다 큰 파일, 읽을 수 없는 파일임(링크면 이 말이 붙지 않고, 설정을 다시 주면 그 이름이 새 파일로 바뀜). 지우지 않음. 무엇인지 보이고, 옮길지 권한을 고칠지(다른 경로는 `BANKER_ANTI_DRM_CONFIG`) 물은 뒤 다시 실행 |
246
248
  | 일반 파일이 아니라 설정을 저장하지 않았습니다 | 설정 경로의 폴더나 특수 파일은 지우지 않음. 무엇인지 보이고, 옮길지 다른 경로(`BANKER_ANTI_DRM_CONFIG`)를 쓸지 물은 뒤 다시 실행 |
247
249
  | 64KiB 보다 큰 파일이라, 읽을 수 없는 파일이라 설정을 저장하지 않았습니다 | 그 파일이 무엇인지 보이고, 권한을 고칠지 옮길지 물은 뒤 다시 실행 |
248
250
  | 확인하는 사이에 바뀐 자리라 설정을 저장하지 않았습니다 | 설정 파일을 연 사이에 다른 프로그램이 그 자리를 바꿈(Windows, 링크일 수 있음). 지우거나 바꾸지 않음. 무엇인지 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
@@ -256,7 +258,8 @@ argument-hint: "on | off | status | config"
256
258
  | 끊어진 링크라 그대로 두었습니다 | AGENTS.md 가 가리키는 파일이 없음. 링크를 지우거나 바꾸지 않음. 링크와 대상을 보이고 어떻게 할지 물은 뒤 다시 실행 |
257
259
  | AGENTS.md 가 끊어진 링크라 on 이 블록을 옮기지 못함 | AGENTS.override.md 에 블록만 남았고 AGENTS.md 가 가리키는 파일이 없음. 링크를 지우거나 바꾸지 않음. 링크와 대상을 보이고 어떻게 할지 물은 뒤 `on` 실행 |
258
260
  | 쓰는 사이에 같은 이름이 생겨 그대로 두었습니다 | 다른 프로그램이 그 사이에 같은 이름을 만들었음(링크일 수 있음). 지우거나 바꾸지 않음. 무엇인지 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
259
- | 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일은 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
261
+ | 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일, 그 백업 자리(`<파일>.anti-drm.bak`)의 폴더는 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
262
+ | 32MiB 보다 큰 파일 | 지침 파일(AGENTS.md, AGENTS.override.md)이 32MiB 를 넘음. 읽지도 바꾸지도 않음. 그 파일이 무엇인지 보이고, 줄일지 옮길지 물은 뒤 다시 실행 |
260
263
  | 다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다 | 다른 banker 스킬의 `on`, `off` 가 도는 중. 잠시 뒤 다시 실행. 끝나지 않은 작업이 남긴 잠금은 1분 뒤 저절로 넘어감. 1분이 지나도 같거나 잠금 자리가 폴더면 잠금 경로를 보이고 어떻게 할지 물은 뒤 다시 실행. 잠금을 임의로 지우지 않음 |
261
264
  | 네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다 | Codex 폴더가 NFS 같은 네트워크 공유에 있고 공유가 파일 핸들을 잃음. 잠시 뒤 다시 실행. 계속되면 공유 연결 상태를 확인하도록 알림. 잠금을 임의로 지우지 않음 |
262
265
  | 쓸 권한이 없음 | 해당 파일과 폴더 권한 확인 |
@@ -46,6 +46,7 @@ import {
46
46
  openSync,
47
47
  readFileSync,
48
48
  readSync,
49
+ readlinkSync,
49
50
  realpathSync,
50
51
  } from "node:fs";
51
52
  import { homedir } from "node:os";
@@ -97,6 +98,9 @@ const RUNTIME_NAME = { claude: "Claude Code", codex: "Codex" };
97
98
  // forge a line or reorder the text), at most 40 characters stay, and a value
98
99
  // that is not a string shows only its type.
99
100
  const shown = (value) => (typeof value === "string" ? value.replace(/[^ -~]/g, "?").slice(0, 40) : `(${typeof value})`);
101
+ // A value read through a link at the settings path, as a message shows it: its type and length only. The link may lead
102
+ // to a file that is not anti-drm's (the design reads through it, for dotfiles), so none of its words show.
103
+ const sized = (value) => (typeof value === "string" ? `(string, ${[...value].length}자)` : `(${typeof value})`);
100
104
 
101
105
  // ---- settings ----
102
106
 
@@ -118,25 +122,25 @@ function normalExt(word) {
118
122
  return EXT.test(ext) ? ext : null;
119
123
  }
120
124
 
121
- // The extensions in words, normalized and each once, or why they are not a list.
122
- function extList(words, what) {
125
+ // The extensions in words, normalized and each once, or why they are not a list. quote shows a wrong word.
126
+ function extList(words, what, quote) {
123
127
  if (!Array.isArray(words)) return { error: `${what}가 목록이 아닙니다` };
124
128
  if (words.length === 0) return { error: `${what}가 비어 있습니다` };
125
129
  if (words.length > MAX_EXTS) return { error: `${what}는 ${MAX_EXTS}개까지 쓸 수 있습니다` };
126
130
  const list = new Set();
127
131
  for (const word of words) {
128
132
  const ext = normalExt(word);
129
- if (ext === null) return { error: `${what}로 쓸 수 없는 값입니다: ${shown(word)}` };
133
+ if (ext === null) return { error: `${what}로 쓸 수 없는 값입니다: ${quote(word)}` };
130
134
  list.add(ext);
131
135
  }
132
136
  return { list: [...list] };
133
137
  }
134
138
 
135
- /** Settings from their parts, normalized: { config } or { error }. */
136
- export function checkConfig({ deny, alt, tsvDelimiter = "tab" }) {
137
- const d = extList(deny, "금지 확장자");
139
+ /** Settings from their parts, normalized: { config } or { error }. quote shows a wrong value in the error. */
140
+ export function checkConfig({ deny, alt, tsvDelimiter = "tab" }, quote = shown) {
141
+ const d = extList(deny, "금지 확장자", quote);
138
142
  if (d.error) return d;
139
- const a = extList(alt, "대체 확장자");
143
+ const a = extList(alt, "대체 확장자", quote);
140
144
  if (a.error) return a;
141
145
  const reserved = d.list.filter((x) => RESERVED.includes(x));
142
146
  if (reserved.length > 0) return { error: `anti-drm 이 쓰는 형식이라 금지할 수 없습니다: ${reserved.join(" ")}` };
@@ -145,7 +149,7 @@ export function checkConfig({ deny, alt, tsvDelimiter = "tab" }) {
145
149
  // In any letter case, as the --tsv flag takes it; kept lower-cased.
146
150
  const tsv = typeof tsvDelimiter === "string" ? tsvDelimiter.toLowerCase() : tsvDelimiter;
147
151
  if (tsv !== "tab" && tsv !== "comma") {
148
- return { error: `TSV 구분자는 tab 이나 comma 여야 합니다: ${shown(tsvDelimiter)}` };
152
+ return { error: `TSV 구분자는 tab 이나 comma 여야 합니다: ${quote(tsvDelimiter)}` };
149
153
  }
150
154
  return { config: { version: 1, deny: d.list, alt: a.list, tsvDelimiter: tsv } };
151
155
  }
@@ -164,6 +168,19 @@ const OPEN_ERRORS = new Map([
164
168
  ["EACCES", "읽을 권한이 없습니다"],
165
169
  ["EPERM", "읽을 권한이 없습니다"],
166
170
  ]);
171
+ // What is at the settings path that a save refuses to write over (priorSettings): the person moves it or fixes its
172
+ // permissions first, so `on` says that, not to give the settings again. Only for a file or another kind of entry at the
173
+ // path: a link there (to a folder, a big or unreadable file, or in a loop) the save replaces with a file of its own.
174
+ const KEPT_SPOT = new Set([...OPEN_ERRORS.values(), "64KiB 보다 큽니다"]);
175
+ // A path its lstat cannot look at (a folder this account cannot enter) the save refuses too.
176
+ function keptSpot(file, error) {
177
+ if (!KEPT_SPOT.has(error)) return false;
178
+ try {
179
+ return ["file", "other"].includes(pathKind(file));
180
+ } catch {
181
+ return true;
182
+ }
183
+ }
167
184
 
168
185
  // The settings file's text: null when there is none, { error } when it is not a
169
186
  // regular file of at most 64 KiB that a trusted owner holds. Opening without
@@ -201,7 +218,24 @@ export function readConfig(file) {
201
218
  return { error: "JSON 이 아닙니다" };
202
219
  }
203
220
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { error: "JSON 객체가 아닙니다" };
204
- return checkConfig({ deny: raw.deny, alt: raw.alt, tsvDelimiter: raw.tsvDelimiter ?? "tab" });
221
+ return checkConfig({ deny: raw.deny, alt: raw.alt, tsvDelimiter: raw.tsvDelimiter ?? "tab" }, linkAt(file) ? sized : shown);
222
+ }
223
+
224
+ // Whether the settings name is a link (wherever it leads).
225
+ const linkAt = (file) => {
226
+ try {
227
+ return lstatSync(file).isSymbolicLink();
228
+ } catch {
229
+ return false;
230
+ }
231
+ };
232
+ // Where a link at the settings path leads, as status shows it after the path; nothing for a name that is no link.
233
+ function leadsTo(file) {
234
+ try {
235
+ return linkAt(file) ? ` (링크: ${where(readlinkSync(file))})` : "";
236
+ } catch {
237
+ return " (링크)";
238
+ }
205
239
  }
206
240
 
207
241
  // What sits at the settings path: "none", "file" (a regular file with this one name), "link" (a link, wherever it
@@ -399,10 +433,10 @@ const USAGE =
399
433
  // The settings as `status` shows them.
400
434
  function configLines(o, found) {
401
435
  if (found === null) return ["- 설정: 없음 (on 이나 config 로 만듦)"];
402
- if (found.error) return [`- 설정: 올바르지 않음 (${found.error}): ${where(o.configFile)}`];
436
+ if (found.error) return [`- 설정: 올바르지 않음 (${found.error}): ${where(o.configFile)}${leadsTo(o.configFile)}`];
403
437
  const { deny, alt, tsvDelimiter } = found.config;
404
438
  const lines = [
405
- `- 설정: ${where(o.configFile)}`,
439
+ `- 설정: ${where(o.configFile)}${leadsTo(o.configFile)}`,
406
440
  ` - 금지 확장자 ${deny.length}개: ${deny.join(" ")}`,
407
441
  ` - 대체 확장자 ${alt.length}개: ${alt.join(" ")}`,
408
442
  ];
@@ -511,7 +545,10 @@ function savedRules(o) {
511
545
  const found = readConfig(o.configFile);
512
546
  if (found === null) return { refusal: refuse("설정이 없습니다. 금지 확장자와 대체 확장자를 --stdin 이나 --deny, --alt 로 함께 주세요.") };
513
547
  if (found.error) {
514
- return { refusal: refuse(`설정 파일이 올바르지 않습니다 (${found.error}): ${where(o.configFile)}. config 로 다시 만드세요.`) };
548
+ const next = keptSpot(o.configFile, found.error)
549
+ ? "anti-drm 은 이 자리를 덮어쓰지 않습니다. 옮기거나 권한을 고친 뒤 다시 실행하세요."
550
+ : "금지 확장자와 대체 확장자를 --stdin 이나 --deny, --alt 로 함께 주세요.";
551
+ return { refusal: refuse(`설정 파일이 올바르지 않습니다 (${found.error}): ${where(o.configFile)}. ${next}`) };
515
552
  }
516
553
  return { rules: renderRules(loadTemplate(o), found.config), lines: [], ok: true };
517
554
  }
@@ -60,6 +60,7 @@ import {
60
60
  renameSync,
61
61
  rmSync,
62
62
  statSync,
63
+ unlinkSync,
63
64
  writeFileSync,
64
65
  } from "node:fs";
65
66
  import { dirname, join } from "node:path";
@@ -102,6 +103,10 @@ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
102
103
  const SPECIAL = Object.freeze({ bytes: null, text: null, special: true });
103
104
  // What sits at a name that is a link to nothing: Codex finds no file there, and writing would follow the link.
104
105
  const DANGLING = Object.freeze({ bytes: null, text: null, dangling: true });
106
+ // An instructions file past this is no file of hand-written instructions: it is not read, so no .bak doubles it and a
107
+ // file past what Node reads at once (2 GiB) is a refusal, not a crash.
108
+ const MAX_FILE_BYTES = 32 * 1024 * 1024;
109
+ const TOO_BIG = Object.freeze({ bytes: null, text: null, tooBig: true });
105
110
  // What opening a name fails with when no regular file can be there: links that loop (ELOOP) or a socket (ENXIO,
106
111
  // EOPNOTSUPP). macOS answers a socket with errno 102, which Node 22 does not name ("Unknown system error -102"), so
107
112
  // the kind of what is there decides too.
@@ -136,7 +141,9 @@ function readFile(path) {
136
141
  return openFailure(path, e);
137
142
  }
138
143
  try {
139
- if (!fstatSync(fd).isFile()) return SPECIAL;
144
+ const st = fstatSync(fd);
145
+ if (!st.isFile()) return SPECIAL;
146
+ if (st.size > MAX_FILE_BYTES) return TOO_BIG;
140
147
  const bytes = readFileSync(fd);
141
148
  try {
142
149
  return { bytes, text: UTF8.decode(bytes) };
@@ -180,13 +187,25 @@ export function writeFresh(file, data, mode = 0o644) {
180
187
  rmSync(tmp, { force: true });
181
188
  try {
182
189
  writeFileSync(tmp, data, { flag: "wx", mode });
183
- renameSync(tmp, file);
190
+ renameOver(tmp, file);
184
191
  } catch (e) {
185
192
  rmSync(tmp, { force: true });
186
193
  throw e;
187
194
  }
188
195
  }
189
196
 
197
+ // Windows does not rename a file over a link to a folder: there the link goes first, and the folder it
198
+ // leads to stays.
199
+ function renameOver(tmp, file) {
200
+ try {
201
+ renameSync(tmp, file);
202
+ } catch (e) {
203
+ if (process.platform !== "win32" || !lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink()) throw e;
204
+ unlinkSync(file);
205
+ renameSync(tmp, file);
206
+ }
207
+ }
208
+
190
209
  /** Whether an error is about permissions or a read-only file system. */
191
210
  export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS";
192
211
 
@@ -198,8 +217,12 @@ export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code
198
217
  * So do letters that print as nothing (default-ignorable ones such as U+3164),
199
218
  * the braille blank and spaces other than the ASCII one, which hide text as blanks.
200
219
  */
220
+ // Marks drawn over or around the letter before them (a stroke, a slash, a ring), which make a name look like another.
221
+ // U+0338 stays after a sign it makes another of in a decomposed name (= and U+0338 are ≠ in NFD, as macOS may keep it).
222
+ const OVERLAY = /[\p{Me}\u{0334}-\u{0337}\u{20d2}\u{20d3}\u{20d8}-\u{20da}\u{20e5}\u{20e6}\u{20ea}\u{20eb}]|(?<![<=>\u{2190}\u{2192}\u{2194}\u{21d0}\u{21d2}\u{21d4}\u{2203}\u{2208}\u{220b}\u{2223}\u{2225}\u{223c}\u{2243}\u{2245}\u{2248}\u{224d}\u{2261}\u{2264}\u{2265}\u{2272}\u{2273}\u{2276}\u{2277}\u{227a}-\u{227d}\u{2282}\u{2283}\u{2286}\u{2287}\u{2291}\u{2292}\u{22a2}\u{22a8}\u{22a9}\u{22ab}\u{22b2}-\u{22b5}\u{2add}])\u{0338}/gu;
223
+
201
224
  export const where = (value) =>
202
- String(value).replace(
225
+ String(value).replace(OVERLAY, "?").replace(
203
226
  /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}\p{Default_Ignorable_Code_Point}\u{2800}\u{a0}\u{1680}\u{2000}-\u{200a}\u{202f}\u{205f}\u{3000}]/gu,
204
227
  "?",
205
228
  );
@@ -375,6 +398,7 @@ const otherBlocks = (t, text) =>
375
398
  });
376
399
 
377
400
  const special = (f) => f.special === true;
401
+ const tooBig = (f) => f.tooBig === true;
378
402
  const binary = (f) => f.bytes !== null && f.text === null;
379
403
  const unpaired = (f) => f.spans === null;
380
404
  const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
@@ -404,8 +428,8 @@ function codexFiles(t, o) {
404
428
  const read = (name) => {
405
429
  const path = join(o.codexDir, name);
406
430
  const file = readFile(path);
407
- if (file === null || file.special || file.dangling) {
408
- return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true };
431
+ if (file === null || file.special || file.dangling || file.tooBig) {
432
+ return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true, tooBig: file?.tooBig === true };
409
433
  }
410
434
  return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
411
435
  };
@@ -452,10 +476,12 @@ function placeState(files) {
452
476
  // "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
453
477
  // override, so not applied), "hidden" (in the override with no text of its own,
454
478
  // applied but hiding AGENTS.md), "dangling" (nowhere, and AGENTS.md is a link to
455
- // nothing), "unpaired", "notutf8" or "notregular" (a folder, FIFO, socket or
456
- // link loop at a file's name). Without rules any block in its place counts as "on".
479
+ // nothing), "unpaired", "notutf8", "toobig" (a file past 32 MiB, left unread) or
480
+ // "notregular" (a folder, FIFO, socket or link loop at a file's name). Without
481
+ // rules any block in its place counts as "on".
457
482
  function codexState(t, files, rules) {
458
483
  if (files.all.some(special)) return "notregular";
484
+ if (files.all.some(tooBig)) return "toobig";
459
485
  if (files.all.some(binary)) return "notutf8";
460
486
  if (files.all.some(unpaired)) return "unpaired";
461
487
  if (!hasBlock(files.target)) return placeState(files);
@@ -495,23 +521,30 @@ function apply(t, changes) {
495
521
  }
496
522
 
497
523
  // Applies the changes, or the refusal for a write that failed with nothing of the file changed: a name that
498
- // appeared in between (EEXIST), or a write a sandbox refused after access() allowed it. The refusal names the place
499
- // the system refused (a rename's target, as the backup, or the file it was about), and a file changed before the
500
- // failing one. Other errors propagate.
524
+ // appeared in between (EEXIST), a folder where the write goes (EISDIR; Windows answers a rename onto one with EPERM),
525
+ // or a write a sandbox refused after access() allowed it. The refusal names the place the system refused (a rename's
526
+ // target, as the backup, or the file it was about), and a file changed before the failing one. Other errors propagate.
501
527
  function applied(t, changes) {
502
528
  let i = 0;
503
529
  try {
504
530
  for (; i < changes.length; i++) apply(t, [changes[i]]);
505
531
  } catch (e) {
506
- if (e.code !== "EEXIST" && !denied(e)) throw e;
507
- const why = e.code === "EEXIST" ? "쓰는 사이에 같은 이름이 생겨 그대로 두었습니다" : `쓸 권한이 없어 그대로 두었습니다 (${where(e.code)})`;
532
+ if (e.code !== "EEXIST" && e.code !== "EISDIR" && !denied(e)) throw e;
508
533
  const at = [e.dest, e.path].find((p) => typeof p === "string") ?? changes[i].file.path;
509
534
  const before = i === 0 ? "" : `\n앞서 바꾼 파일: ${changes.slice(0, i).map((c) => where(c.file.path)).join(", ")}`;
510
- return refuse(`${why}: ${where(at)}${before}`);
535
+ return refuse(`${refusedWhy(e, at)}: ${where(at)}${before}`);
511
536
  }
512
537
  return null;
513
538
  }
514
539
 
540
+ // Why a write applied() refuses failed: a name that appeared in between, something that is no regular file at the
541
+ // place it went (a folder), or permissions.
542
+ function refusedWhy(e, at) {
543
+ if (e.code === "EEXIST") return "쓰는 사이에 같은 이름이 생겨 그대로 두었습니다";
544
+ if (notAFile(at)) return "일반 파일이 아니라 그대로 두었습니다";
545
+ return `쓸 권한이 없어 그대로 두었습니다 (${where(e.code)})`;
546
+ }
547
+
515
548
  // Whether the file at path is also reachable under another name: a hard link.
516
549
  function hasOtherNames(path) {
517
550
  try {
@@ -530,16 +563,63 @@ const LOCK_STEP_MS = 50;
530
563
  const SLEEP = new Int32Array(new SharedArrayBuffer(4));
531
564
 
532
565
  // 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.
566
+ // stale file handle (ESTALE), which the next try may not. A lock file this try made but could not write is taken
567
+ // away again while the name still holds that very file (one device and one inode), so it keeps no one waiting; "left"
568
+ // when it may still be there (no stats or no inode number to prove it is that file, or the share could not say).
569
+ // Any other error (permissions, EIO) throws, after the same taking away.
534
570
  function tryLock(path, token) {
571
+ let fd;
535
572
  try {
536
- writeFileSync(path, token, { flag: "wx", mode: 0o600 });
537
- return "taken";
573
+ fd = openSync(path, "wx", 0o600);
538
574
  } catch (e) {
539
575
  if (e.code === "EEXIST") return "held";
540
576
  if (e.code === "ESTALE") return "stale";
541
577
  throw e;
542
578
  }
579
+ const failed = writeToken(fd, token);
580
+ if (failed === null) return "taken";
581
+ const gone = dropMade(path, failed.made, token);
582
+ if (failed.error.code !== "ESTALE") throw failed.error;
583
+ return gone ? "stale" : "left";
584
+ }
585
+
586
+ // Writes the token into the lock file just made and closes it: null when both went well, else the first error and
587
+ // the file's bigint stats (null when they could not be had). A share often reports a failed write at the close.
588
+ function writeToken(fd, token) {
589
+ let made = null;
590
+ let error = null;
591
+ try {
592
+ made = fstatSync(fd, { bigint: true });
593
+ writeFileSync(fd, token);
594
+ } catch (e) {
595
+ error = e;
596
+ }
597
+ try {
598
+ closeSync(fd);
599
+ } catch (e) {
600
+ error ??= e;
601
+ }
602
+ return error === null ? null : { made, error };
603
+ }
604
+
605
+ // Removes the lock file a failed try made, only while the name still holds it (one device and one inode): true when
606
+ // that file is not there any more, false when it may be (the share could not say, or gives no inode number; such a
607
+ // lock goes stale in a minute).
608
+ function dropMade(path, made, token) {
609
+ if (made === null || made.ino === 0n) return false;
610
+ try {
611
+ if (sameInode(made, lstatSync(path, { bigint: true })) && heldWords(path, token)) rmSync(path, { force: true });
612
+ return true;
613
+ } catch (e) {
614
+ return e.code === "ENOENT";
615
+ }
616
+ }
617
+
618
+ // Whether the lock file holds no words but a start of this run's token (none, or a write cut short): a lock another
619
+ // run made at the name in between can get the very inode number, but it holds that run's own token, so it stays.
620
+ function heldWords(path, token) {
621
+ const text = readFile(path)?.text;
622
+ return typeof text === "string" && token.startsWith(text);
543
623
  }
544
624
 
545
625
  // 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
@@ -563,16 +643,19 @@ function dropStale(path) {
563
643
  }
564
644
 
565
645
  // 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
646
+ // met a stale file handle instead of a lock, or a lock this run made could not be taken away again, else "held". Every round that does not take the lock rests, so no answer
567
647
  // of the file system makes the wait spin; the deadline is looked at every round, so no lock spot keeps it going past.
568
648
  function takeLock(dir, waitMs) {
569
649
  const path = join(dir, LOCK);
570
650
  const token = `${process.pid} ${Date.now()} ${Math.random()}`;
571
651
  const until = Date.now() + waitMs;
652
+ // a made lock this run could not take away again names ESTALE as the cause, not another banker run
653
+ let left = false;
572
654
  for (;;) {
573
655
  const got = tryLock(path, token);
574
656
  if (got === "taken") return { path, token };
575
- if (Date.now() >= until) return { path, why: got };
657
+ left ||= got === "left";
658
+ if (Date.now() >= until) return { path, why: left ? "stale" : got };
576
659
  if (got === "held") dropStale(path);
577
660
  Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
578
661
  }
@@ -628,7 +711,7 @@ const shadowNote = (files) =>
628
711
  // The file a refused state is about: the folder or FIFO, the file that is not UTF-8, or the one whose markers do
629
712
  // not pair up; null in any other state.
630
713
  function problemFile(files, state) {
631
- const which = { notregular: special, notutf8: binary, unpaired }[state];
714
+ const which = { notregular: special, toobig: tooBig, notutf8: binary, unpaired }[state];
632
715
  return which ? files.all.find(which) : null;
633
716
  }
634
717
 
@@ -638,6 +721,7 @@ function codexRefusal(t, files, state) {
638
721
  if (!file) return null;
639
722
  const why = {
640
723
  notregular: "일반 파일이 아니라 그대로 두었습니다",
724
+ toobig: "32MiB 보다 큰 파일이라 그대로 두었습니다",
641
725
  notutf8: "UTF-8 이 아닌 파일이라 그대로 두었습니다",
642
726
  unpaired: `${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다`,
643
727
  }[state];
@@ -710,6 +794,7 @@ const STATE_TEXT = {
710
794
  hiddenbroken: "켜짐, 단 AGENTS.override.md 에 블록만 있고 AGENTS.md 가 끊어진 링크라 on 이 블록을 옮기지 못함 (링크 확인 필요)",
711
795
  notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
712
796
  notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
797
+ toobig: "32MiB 보다 큰 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
713
798
  unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
714
799
  dangling: "꺼짐, 단 AGENTS.md 가 끊어진 링크라 on 이 거부함 (링크 확인 필요)",
715
800
  };
@@ -760,7 +845,7 @@ export function stateLines(t, o, rules) {
760
845
  */
761
846
  export function present(t, runtime, o) {
762
847
  if (runtime === "claude") return isDir(o.claudeDir) && claudeState(t, readFile(claudeFile(t, o))) === "ours";
763
- return isDir(o.codexDir) && !["off", "notutf8", "notregular", "dangling"].includes(codexState(t, codexFiles(t, o)));
848
+ return isDir(o.codexDir) && !["off", "notutf8", "notregular", "toobig", "dangling"].includes(codexState(t, codexFiles(t, o)));
764
849
  }
765
850
 
766
851
  const knownRuntime = (runtime) => {
@@ -16,7 +16,7 @@ description: "하드코딩된 값, mock, stub, 열거형을 정적 감사로 검
16
16
  - 이전 세션/에이전트의 "stub 없음" 주장을 **직접 재검증**할 때
17
17
 
18
18
  ## 불변 원칙
19
- 1. **read-only**: 소스·`pom.xml`/`.eslintrc`/`pyproject.toml`/CI **일절 무변경**. 규칙·러너·결과는 작업용 디렉터리(예: gitignored `.audit/`)에 격리. ast-grep은 search만(replace 금지).
19
+ 1. **read-only**: 소스·`pom.xml`/`.eslintrc`/`pyproject.toml`/CI **일절 무변경**. 규칙·러너·결과는 감사 대상 저장소 밖의 작업 폴더(`$RESEARCH_DIR`, 없으면 `mktemp -d` 로 만든 개인 폴더)에 격리. 저장소 안의 폴더(예: `.audit/`)는 쓰지 않는다. 저장소가 그 자리에 링크를 심어 두면 결과 쓰기가 링크 너머 파일을 덮는다. 만든 폴더의 경로는 리포트에 적는다. ast-grep은 search만(replace 금지).
20
20
  2. **자격증명 스캔 제외** (audit-security가 담당 — gitleaks/secret 룰 넣지 말 것).
21
21
  3. **verify-before-claim**: 모든 finding은 `file:LINE`+스니펫 동반. "0건 = clean 보증" **금지** — 구조규칙은 강한 규칙이라 데이터경로 분기 stub을 놓칠 수 있음 → 정밀감사(레인 B) 필수. 완료 전 4-field(① 변경 ② Evidence ③ 검증 ④ Unknown), ⏳=0일 때만 "완료".
22
22
  4. **TP/FP·verdict 분류**: literal이 **화면코드/단위/컬럼키/SQL fragment/라벨/config**면 `legit-constant`(재flag 금지). **숫자/라벨이 응답 row의 DATA VALUE로** 주입되면 `hardcoded-stub`. 데이터 부재 시 **+0/dummy 발명**이면 `fabricated-fallback`. 순수연산은 `legit-computed`(flag 안 함).
@@ -17,7 +17,7 @@ consensus는 고신뢰, 단일툴은 needs-review. 산출물은 방안·권고
17
17
  - 이전 세션/에이전트의 "취약점 없음" 주장을 **직접 재검증**할 때
18
18
 
19
19
  ## 불변 원칙
20
- 1. **read-only**: 소스·`pom.xml`/`package.json`/`requirements.txt`/CI **일절 무변경**. 도구·러너·결과는 작업용 디렉터리(예: gitignored `.audit/` 또는 `$RESEARCH_DIR`)에 격리. 도구는 advisory DB만 조회(소스 미수정).
20
+ 1. **read-only**: 소스·`pom.xml`/`package.json`/`requirements.txt`/CI **일절 무변경**. 도구·러너·결과는 감사 대상 저장소 밖의 작업 폴더(`$RESEARCH_DIR`, 없으면 `mktemp -d` 로 만든 개인 폴더)에 격리. 저장소 안의 폴더(예: `.audit/`)는 쓰지 않는다. 저장소가 그 자리에 링크를 심어 두면 결과 쓰기가 링크 너머 파일을 덮는다. 만든 폴더의 경로는 리포트 Evidence 에 적는다. 다시 돌릴 러너와 결과를 오래 두려면 `RESEARCH_DIR` 로 저장소 밖 폴더를 준다(`/tmp` 는 OS 가 비울 수 있다). 도구는 advisory DB만 조회(소스 미수정).
21
21
  2. **시크릿 값 비노출**: 리포트·로그에 **secret value 절대 미기입**. `RuleID + file:LINE`만. 값 확인이 필요하면 사용자에게 "해당 file:LINE 직접 확인" 안내(cat/grep로 값 출력 금지).
22
22
  3. **local-first + 온라인 교차검증**: advisory DB는 로컬/공개(OSV·deps.dev·trivy-db)를 1차로, 온라인은 recall 보강용 교차검증만. **active-verify 금지** — 시크릿을 **실 엔드포인트에 인증 시도**하는 egress는 금지(특히 규제·민감 환경에선 절대 불가).
23
23
  4. **verify-before-claim**: 모든 finding은 `CVE-ID`/`RuleID` + `file:LINE` 동반. "0건 = 안전 보증" **금지**(semgrep CE는 intraprocedural — 함수 경계 taint 미추적; parse 실패 파일 = 미커버). 완료 전 4-field(① 변경 ② Evidence ③ 검증 ④ Unknown), ⏳=0일 때만 "완료".
@@ -28,6 +28,39 @@ consensus는 고신뢰, 단일툴은 needs-review. 산출물은 방안·권고
28
28
  - 각 도구 `command -v` 검사(semgrep은 venv path `-x`) → **누락분만** 설치: Go 도구는 GitHub release asset 다운로드(`~/.local/bin`), pip 도구는 `pip install --user`, semgrep은 격리 venv.
29
29
  - 멱등 setup 러너로 감싸면 재진입이 빠름(전부 있으면 network 0). exit 0=준비됨, exit 1=누락 잔존.
30
30
 
31
+ #### 네트워크 확인 (내려받기 전)
32
+
33
+ 누락분을 받기 전에 그 원천에 10초 안에 닿는지 본다. 닿지 않으면 받지 않고 "오프라인이라 설치하지 않음(필요한 것: <도구>)" 으로 적는다. 그 도구가 맡은 축은 2단계의 "결과로 읽는 조건" 대로 미커버다.
34
+
35
+ ```bash
36
+ [ "${CODEX_SANDBOX_NETWORK_DISABLED:-}" = 1 ] && echo "Codex sandbox 가 네트워크를 막음(아래 실패는 오프라인이 아님)"
37
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://github.com # Go 도구(release asset)
38
+ # pip 이 읽는 설정 전부(PIP_CONFIG_FILE 포함)를 홈 폴더에서 읽는다. install 절이 global 절보다 앞선다
39
+ L=$( (cd ~ && { pip config list || pip3 config list || python3.13 -m pip config list || python3.12 -m pip config list || python3.11 -m pip config list || python3 -m pip config list; }) 2>/dev/null) || echo "pip 설정을 읽지 못함(pip 이 없거나 10 미만). 사내 미러를 쓰면 확인 주소를 그 미러로 바꿔 본다"
40
+ I="${PIP_INDEX_URL:-$(printf '%s\n' "$L" | sed -n "s/^install\.index-url='\(.*\)'$/\1/p")}"
41
+ I="${I:-$(printf '%s\n' "$L" | sed -n "s/^global\.index-url='\(.*\)'$/\1/p")}"
42
+ I=$(printf %s "${I:-https://pypi.org/simple}" | sed -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)[^/]*@#\1#; s#/$##') # 주소의 사용자 정보는 뗌
43
+ printf 'url = "%s"\n' "$I/pip/" | curl --ssl-no-revoke -K - -sSI -o /dev/null --connect-timeout 5 --max-time 10 # pip 도구(pip-audit, detect-secrets), semgrep venv
44
+ ```
45
+
46
+ - `curl -sSI` 는 머리만 받는다(본문을 받지 않음). 닿지 못하면 종료 코드가 0 이 아니다(HTTP 상태와 관계없음). `curl` 은 `HTTPS_PROXY` 를 따른다. 확인 줄은 `--ssl-no-revoke` 를 준다. Windows 의 Git Bash curl 도 Schannel 이라, 폐지 목록 서버에 닿지 못하는 망에서 폐지 확인이 실패하면 종료 60 이다(Windows 박스 실측). 다른 curl 은 이 깃발을 받고 무시한다(macOS curl 8.7.1, Rocky 8.10 curl 7.61.1 실측).
47
+ - PyPI 는 pip 이 쓰는 색인(`PIP_INDEX_URL`, `pip config list` 의 `install.index-url` 이나 `global.index-url`, 없으면 pypi.org)의 작은 쪽(`<색인>/pip/`)을 본다.
48
+ - `pip config get` 은 `PIP_CONFIG_FILE` 로 준 파일을 읽지 않아(pip 25.0 실측) `pip config list` 를 읽는다.
49
+ - Rocky 8 의 기본 pip(9.0.3, `pip3`)은 `config` 명령이 없고 `pip` 이름은 없다(Rocky 8.10 실측). 그래서 판 붙은 python(3.13, 3.12, 3.11)의 pip 도 차례로 본다.
50
+ - 홈 폴더에서 읽는다. `python3 -m pip` 는 지금 폴더를 import 경로 맨 앞에 두어, 지금 폴더에 있는 남의 저장소의 `pip/` 가 실행될 수 있다.
51
+ - bash 블록은 주소를 명령 인자가 아니라 표준 입력의 curl 설정(`-K -`)으로 넘긴다. 명령 인자는 같은 PC 의 다른 사용자가 `ps` 로 볼 수 있어, 경로나 질의에 든 토큰도 거기 싣지 않는다. 사용자 정보(`사용자:토큰@`)는 떼고 묻는다. 닿는지만 보므로 인증은 필요 없다(401 도 닿은 것이다). Windows 에서도 뗀 주소를 쓴다.
52
+ - 사내 미러만 열린 망이면 확인 주소를 그 미러로 바꿔 본다.
53
+ - Codex sandbox 가 네트워크를 막는 모드(기본 `workspace-write`, `read-only`)에서는 `CODEX_SANDBOX_NETWORK_DISABLED=1` 이 서고 확인이 모두 실패한다(curl 종료 7, npm `EPERM`). 블록의 첫 줄이 그렇다고 알린다. 그때는 '오프라인' 이 아니라 'sandbox 가 네트워크를 막음' 으로 보고하고, 승인을 받아 sandbox 밖에서 확인부터 다시 한다. Claude Code sandbox 안(`SANDBOX_RUNTIME=1`)에서는 허용 목록 밖 주소를 sandbox 프록시가 막는다. Windows PowerShell 에서는 `$env:CODEX_SANDBOX_NETWORK_DISABLED -eq '1'` 로 본다.
54
+ - 확인이 실패하면 보고에 그 명령, 종료 코드, stderr 첫 줄을 붙인다(예: `npm error code ETIMEDOUT`, `curl: (28) ...`). 원인을 '오프라인' 하나로 짐작하지 않는다.
55
+ - curl 의 종료 60(인증서 검증 실패)도 오프라인이 아니다. 망은 열려 있고, TLS 를 가로채는 사내 프록시의 인증서를 OS 가 믿지 않는 것이다. '인증서 문제로 설치하지 않음' 으로 보고한다.
56
+
57
+ Go 도구의 release asset 은 새 임시 폴더에 받아 풀고, 그 폴더는 끝나면 지운다(받다 끊긴 파일이 실행 파일 이름으로 남지 않는다). 받는 속도가 초당 1000바이트 밑으로 30초 이어지면 끊는다. https 만 받는다(되돌림 주소도 https 만). 받은 파일의 체크섬은 그 release 의 체크섬 파일과 사람이 견준다(이 블록은 견주지 않는다).
58
+
59
+ ```bash
60
+ T=$(mktemp -d) && curl -fL --proto '=https' --proto-redir '=https' --connect-timeout 10 --speed-limit 1000 --speed-time 30 -o "$T/<asset>" "https://github.com/<저장소>/releases/download/<판>/<asset>" \
61
+ && tar -xzf "$T/<asset>" -C "$T" && mkdir -p ~/.local/bin && mv "$T/<실행 파일>" ~/.local/bin/; [ -n "$T" ] && rm -rf "$T"
62
+ ```
63
+
31
64
  ### 도구 (예시 조합 — 검증된 stack, 대상 박스에 맞게)
32
65
  | 도구 | 위치 | 설치 방식 |
33
66
  |---|---|---|
@@ -41,7 +74,7 @@ consensus는 고신뢰, 단일툴은 needs-review. 산출물은 방안·권고
41
74
 
42
75
  - ⚠️ **오래된 glibc 환경**(예: RHEL8 glibc 2.28): Go 정적 바이너리(osv/trivy/grype/gitleaks)는 glibc 무관해 `~/.local/bin`에 두면 그대로 동작. Python wheel·ast-grep CLI는 glibc 2.29+ 요구로 실패할 수 있음 → semgrep은 **glibc 핀 venv**로 격리.
43
76
  - ⚠️ **`set -u` 주의**: 일부 쉘 init이 `$ZSH_VERSION` 등 unbound 변수를 참조해 `set -u`가 command-substitution 서브셸을 깨뜨릴 수 있음(다운로드 URL이 빈 문자열이 됨). 러너는 `set -o pipefail`만 권장.
44
- - ⚠️ **exit code 1 = 정상**(스캔 한정): osv/grype/gitleaks/npm audit는 "findings present"를 rc=1로 반환 — 에러 아님. (단 setup 러너의 exit 1은 "도구 미설치 잔존"으로 다름.)
77
+ - ⚠️ **exit code 1 = 정상**(스캔 한정): osv/grype/gitleaks/npm audit는 "findings present"를 rc=1로 반환 — 에러 아님. (단 setup 러너의 exit 1은 "도구 미설치 잔존"으로 다름.) 그래도 결과로 읽기 전에 아래 "결과로 읽는 조건" 을 먼저 본다. 네트워크가 없으면 npm audit 도 rc 1 이지만 본문은 오류뿐이다.
45
78
 
46
79
  ## 2단계 — 스캔 (멱등, 전 서브앱)
47
80
  도구는 소스/`pom.xml`/`package.json`/CI 무변경. 3 카테고리:
@@ -55,6 +88,35 @@ consensus는 고신뢰, 단일툴은 needs-review. 산출물은 방안·권고
55
88
  | **npm audit** | `(cd <frontend> && npm audit --json)` | `package-lock.json` | npm registry advisory |
56
89
  | **pip-audit** | `-r <requirements>.txt` | (PyPI+OSV) | ⚠️ 일부 env에서 실패 가능 → Python은 osv로 커버 |
57
90
 
91
+ #### 결과로 읽는 조건 (네트워크 없음과 0건을 가른다)
92
+ SCA 도구는 advisory DB 나 레지스트리를 네트워크에서 받는다. 네트워크가 없으면 결과 JSON 을 그대로 셀 때 0건이 된다.
93
+ 실측(osv-scanner 2.4.0, npm 11, 네트워크 없음):
94
+ - 오프라인 `npm audit --json` 은 rc 1 이고, 본문은 `message` 와 `error` 뿐이다.
95
+ - `osv-scanner scan source` 는 20초 뒤 rc 127 이고, stdout 이 빈다.
96
+ - `--offline-vulnerabilities` 에 로컬 DB 가 없으면 rc 127 과 `{"results": []}` 이다.
97
+ - 로컬 DB 가 비어 있으면 rc 0 과 `{"results": []}` 다. 문제없음과 모양이 같다.
98
+ - 패키지 소스가 없으면 rc 128 이다. 네트워크가 아니라 대상이 없는 것이다.
99
+
100
+ 도구마다 stdout 은 `$OUT/<도구>.json`, stderr 는 `$OUT/<도구>.err` 로 따로 남기고 종료 코드를 적는다. `OUT` 은 1번 규칙의 작업 폴더다(`OUT="${RESEARCH_DIR:-$(mktemp -d)}"; mkdir -p "$OUT"`). 비어 있으면 `/<도구>.json` 에 쓰게 되므로 먼저 정한다. 아래 조건은 이 셋으로 본다. 조건을 모두 채워야 결과로 읽는다.
101
+
102
+ | 도구 | 결과로 읽는 조건 | 실측 |
103
+ |---|---|---|
104
+ | npm audit | 종료 코드 0 이나 1. 본문이 JSON 이고 `error` 키가 없음. `metadata.dependencies.total` 이 0 보다 큼 | 실측 |
105
+ | osv-scanner | 종료 코드 0 이나 1. 본문이 JSON. stderr 의 `Scanned <파일> file and found <N> package` 의 N 합이 0 보다 큼 | 실측 |
106
+ | osv-scanner 오프라인 DB | 위 조건에 더해, stderr 의 `Loaded <생태계> local db from <경로>` 가 가리키는 파일이 항목을 1개 이상 가짐 | 실측 |
107
+ | trivy | 종료 코드 0. 본문이 JSON 이고 `Results` 가 1개 이상 | 미실측 |
108
+ | grype | 종료 코드 0 이나 1. 본문이 JSON 이고 `matches` 키가 있음. stderr 에 DB 오류가 없음 | 미실측 |
109
+ | semgrep | 종료 코드 0 이나 1. 본문이 JSON 이고 `paths.scanned` 가 1개 이상. `errors` 에 규칙 내려받기 실패가 없음 | 미실측 |
110
+ | pip-audit | 종료 코드 0 이나 1. 본문이 JSON 이고 `dependencies` 가 1개 이상 | 미실측 |
111
+
112
+ - 조건을 하나라도 못 채운 도구는 0건으로 세지 않는다. 리포트 Unknown 에 "미커버(네트워크 없음 또는 대상 없음): <도구>, 종료 <코드>, <stderr 첫 줄>" 로 적는다.
113
+ - 이유를 짐작해 하나로 정하지 않는다. osv-scanner 의 128 은 패키지 소스 없음이고, npm audit 의 `ENOLOCK` 은 잠금 파일 없음이다.
114
+ - 미실측 행은 이 머신에 도구가 없어 출력 모양을 재지 못한 조건이다. 실제 출력이 조건과 달라 판단이 서지 않으면 미커버로 적는다.
115
+ - 오프라인 DB 로 돌렸으면 DB 의 기준일(파일 수정 시각)을 Evidence 에 적는다. 그 뒤에 나온 advisory 는 Unknown 에 미커버로 적는다.
116
+ - 한 축의 도구가 모두 미커버면 그 축 전체를 미커버로 적는다.
117
+ - Secrets 축(gitleaks, detect-secrets)은 로컬이라 네트워크와 관계없다.
118
+ - 오프라인에서 돌리는 길: osv-scanner `--offline-vulnerabilities`(미리 받은 DB), trivy `--skip-db-update --offline-scan`(DB 사본), grype `GRYPE_DB_AUTO_UPDATE=false`(가져온 DB), semgrep 은 로컬 규칙 파일(`--config <파일>`). 판마다 이름이 다를 수 있어 `--help` 로 확인한다.
119
+
58
120
  ### SAST (소스 정적) — semgrep CE
59
121
  ```
60
122
  semgrep scan --config p/security-audit --config p/owasp-top-ten \
@@ -54,6 +54,11 @@ URL 중복제거 후 상위 **≤15 소스**(`MAX_FETCH=15`) fetch → 각 소
54
54
  - **출처 목록**: fetch·인용한 소스.
55
55
  - **한계**: 검증 폐기된 주장, 미해결 불확실성, 조사 못 한 각도.
56
56
 
57
+ ## 웹 도구가 모두 실패하면
58
+ 검색과 fetch 가 모두 실패하면(네트워크 없음) 보고서를 만들지 않고 "웹 조사 불가: <오류>" 로 끝낸다.
59
+ - 모델이 이미 아는 내용을 보탤 때는 "출처 없음(모델 지식)" 으로 따로 표시하고, 검증된 주장처럼 쓰지 않는다.
60
+ - 일부 각도만 실패했으면 그 각도를 한계에 "조사 못 한 각도" 로 적고 나머지로 진행한다.
61
+
57
62
  ## 함정
58
63
  - **적대적 검증 생략 금지** — 그럴듯하나 틀린 주장이 살아남는다(2/3 반증 게이트가 핵심).
59
64
  - **단일 검색 각도 의존 금지** — 한 관점은 반드시 놓친다(5각도 병렬).