@mmerterden/multi-agent-pipeline 20.8.1 → 20.8.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +535 -53
  6. package/install/_dev-only-files.mjs +1 -0
  7. package/install/_mcp-register.mjs +173 -117
  8. package/install/catalog-history.json +1 -0
  9. package/install/claude.mjs +293 -226
  10. package/install/codex.mjs +7 -7
  11. package/install/copilot.mjs +13 -11
  12. package/install/index.mjs +92 -27
  13. package/install/templates/claude-hooks.json +9 -9
  14. package/manifest.json +77 -79
  15. package/package.json +6 -2
  16. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  17. package/pipeline/lib/confusables.json +79 -33
  18. package/pipeline/lib/extract-conventions.sh +3 -3
  19. package/pipeline/lib/json-file-lock.mjs +27 -7
  20. package/pipeline/lib/normalize-text.mjs +86 -17
  21. package/pipeline/lib/outbound-gate.mjs +13 -4
  22. package/pipeline/lib/redact.mjs +87 -14
  23. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  24. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  25. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  26. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  27. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  28. package/pipeline/scripts/agent-guard.py +150 -25
  29. package/pipeline/scripts/audit-log.sh +3 -4
  30. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  31. package/pipeline/scripts/doctor.mjs +8 -2
  32. package/pipeline/scripts/log-metric.sh +9 -3
  33. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  34. package/pipeline/scripts/pre-commit-check.sh +119 -31
  35. package/pipeline/scripts/scan-agent-config.sh +9 -9
  36. package/pipeline/scripts/unattended_policy.py +12 -3
  37. package/pipeline/scripts/uninstall.mjs +88 -1
  38. package/pipeline/scripts/usage-identity.mjs +1 -1
  39. package/pipeline/scripts/usage-register.mjs +1 -1
  40. package/pipeline/skills/.skill-manifest.json +11 -11
  41. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  42. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  43. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  44. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  45. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  46. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  47. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
  48. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
  49. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  50. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
  51. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
  52. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
  53. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
  54. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
  55. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
  56. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  57. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
  58. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
  59. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
  60. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
  61. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
  62. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  63. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  64. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  65. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  66. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  67. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  68. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  69. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  70. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  71. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  72. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  73. package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
  74. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  75. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  76. package/pipeline/scripts/make-manifest.mjs +0 -199
  77. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -1,36 +1,23 @@
1
1
  {
2
- "_readme": "Characters a guard folds before matching, applied after NFKC. `strip` lists invisible characters that are removed. `map` sends a character that renders like an ASCII one to that ASCII character: the dash family, and the Cyrillic and Greek letters whose glyphs match Latin ones. Chosen from the Unicode confusables data for the letters and punctuation the guard rules use. Read by lib/normalize-text.mjs and scripts/agent-guard.py.",
3
- "strip": [
4
- "\u00ad",
5
- "\u034f",
6
- "\u061c",
7
- "\u115f",
8
- "\u1160",
9
- "\u17b4",
10
- "\u17b5",
11
- "\u180e",
12
- "\u200b",
13
- "\u200c",
14
- "\u200d",
15
- "\u200e",
16
- "\u200f",
17
- "\u202a",
18
- "\u202b",
19
- "\u202c",
20
- "\u202d",
21
- "\u202e",
22
- "\u2060",
23
- "\u2061",
24
- "\u2062",
25
- "\u2063",
26
- "\u2064",
27
- "\u2066",
28
- "\u2067",
29
- "\u2068",
30
- "\u2069",
31
- "\u3164",
32
- "\ufeff",
33
- "\uffa0"
2
+ "_readme": "Characters a guard folds before matching, applied after NFKC. `stripRanges` lists the Default_Ignorable_Code_Point set as hex code point ranges (a single value or `FIRST-LAST`); every character in them is removed. `map` sends a character that renders like an ASCII one to that ASCII character: the dash family, the Cyrillic and Greek letters whose glyphs match Latin ones, and the compatibility letters and digits added after Unicode 13 whose NFKC form is ASCII, so a Python whose unicodedata predates them folds them the way Node does. Chosen from the Unicode confusables data for the letters and punctuation the guard rules use. Read by lib/normalize-text.mjs and scripts/agent-guard.py.",
3
+ "stripRanges": [
4
+ "00AD",
5
+ "034F",
6
+ "061C",
7
+ "115F-1160",
8
+ "17B4-17B5",
9
+ "180B-180F",
10
+ "200B-200F",
11
+ "202A-202E",
12
+ "2060-206F",
13
+ "3164",
14
+ "FE00-FE0F",
15
+ "FEFF",
16
+ "FFA0",
17
+ "FFF0-FFF8",
18
+ "1BCA0-1BCA3",
19
+ "1D173-1D17A",
20
+ "E0000-E0FFF"
34
21
  ],
35
22
  "map": {
36
23
  "\u2010": "-",
@@ -95,6 +82,65 @@
95
82
  "\u03b1": "a",
96
83
  "\u03bd": "v",
97
84
  "\u03c1": "p",
98
- "\u0131": "i"
85
+ "\u0131": "i",
86
+ "\ua7f1": "S",
87
+ "\ua7f2": "C",
88
+ "\ua7f3": "F",
89
+ "\ua7f4": "Q",
90
+ "\ud801\udf81": ":",
91
+ "\ud801\udfa5": "q",
92
+ "\ud833\udcd6": "A",
93
+ "\ud833\udcd7": "B",
94
+ "\ud833\udcd8": "C",
95
+ "\ud833\udcd9": "D",
96
+ "\ud833\udcda": "E",
97
+ "\ud833\udcdb": "F",
98
+ "\ud833\udcdc": "G",
99
+ "\ud833\udcdd": "H",
100
+ "\ud833\udcde": "I",
101
+ "\ud833\udcdf": "J",
102
+ "\ud833\udce0": "K",
103
+ "\ud833\udce1": "L",
104
+ "\ud833\udce2": "M",
105
+ "\ud833\udce3": "N",
106
+ "\ud833\udce4": "O",
107
+ "\ud833\udce5": "P",
108
+ "\ud833\udce6": "Q",
109
+ "\ud833\udce7": "R",
110
+ "\ud833\udce8": "S",
111
+ "\ud833\udce9": "T",
112
+ "\ud833\udcea": "U",
113
+ "\ud833\udceb": "V",
114
+ "\ud833\udcec": "W",
115
+ "\ud833\udced": "X",
116
+ "\ud833\udcee": "Y",
117
+ "\ud833\udcef": "Z",
118
+ "\ud833\udcf0": "0",
119
+ "\ud833\udcf1": "1",
120
+ "\ud833\udcf2": "2",
121
+ "\ud833\udcf3": "3",
122
+ "\ud833\udcf4": "4",
123
+ "\ud833\udcf5": "5",
124
+ "\ud833\udcf6": "6",
125
+ "\ud833\udcf7": "7",
126
+ "\ud833\udcf8": "8",
127
+ "\ud833\udcf9": "9",
128
+ "\ud838\udc30": "a",
129
+ "\ud838\udc35": "e",
130
+ "\ud838\udc3c": "o",
131
+ "\ud838\udc3e": "p",
132
+ "\ud838\udc3f": "c",
133
+ "\ud838\udc41": "y",
134
+ "\ud838\udc43": "x",
135
+ "\ud838\udc4c": "i",
136
+ "\ud838\udc4d": "j",
137
+ "\ud838\udc51": "a",
138
+ "\ud838\udc56": "e",
139
+ "\ud838\udc5c": "o",
140
+ "\ud838\udc5e": "c",
141
+ "\ud838\udc5f": "y",
142
+ "\ud838\udc61": "x",
143
+ "\ud838\udc68": "i",
144
+ "\ud838\udc69": "s"
99
145
  }
100
146
  }
@@ -927,11 +927,11 @@ bucket_di_registration() {
927
927
  case "$PLATFORM" in
928
928
  ios)
929
929
  local configurators
930
- configurators=$(run_find -type f \( -name '*DependencyConfigurator.swift' -o -name '*Assembly.swift' -o -name '*Module.swift' \) 2>/dev/null | head -20 || true)
930
+ configurators=$(run_find -type f \( -name '*DependencyConfigurator.swift' -o -name '*DependencyRegistrar.swift' -o -name '*Assembly.swift' -o -name '*Module.swift' \) 2>/dev/null | head -20 || true)
931
931
  local resolver_count factory_count manual_count swinject_count
932
932
  manual_count=0; resolver_count=0; factory_count=0; swinject_count=0
933
933
  if [ -n "$configurators" ]; then
934
- manual_count=$(printf '%s\n' "$configurators" | count_matches 'DependencyConfigurator')
934
+ manual_count=$(printf '%s\n' "$configurators" | count_matches -E 'Dependency(Configurator|Registrar)')
935
935
  fi
936
936
  files=$(run_find -type f -name '*.swift' 2>/dev/null || true)
937
937
  if [ -n "$files" ]; then
@@ -943,7 +943,7 @@ bucket_di_registration() {
943
943
 
944
944
  sample=$(printf '%s\n' "$configurators" | head -3 | sed "s|^$REPO_PATH/||")
945
945
  if [ "$manual_count" -ge "$resolver_count" ] && [ "$manual_count" -ge "$factory_count" ] && [ "$manual_count" -ge "$swinject_count" ] && [ "$manual_count" -gt 0 ]; then
946
- emit_bucket "manual *DependencyConfigurator" "FooDependencyConfigurator.swift" "$(confidence_for "$manual_count")" "$(files_to_json "$sample")" '["Resolver","Factory","Swinject"]'
946
+ emit_bucket "manual per-module registrar" "FooDependencyRegistrar.swift" "$(confidence_for "$manual_count")" "$(files_to_json "$sample")" '["Resolver","Factory","Swinject"]'
947
947
  elif [ "$resolver_count" -ge "$factory_count" ] && [ "$resolver_count" -ge "$swinject_count" ] && [ "$resolver_count" -gt 0 ]; then
948
948
  emit_bucket "Resolver" "Resolver.register { FooViewModel() }" "$(confidence_for "$resolver_count")" "[]" '["manual-configurator","Factory","Swinject"]'
949
949
  elif [ "$factory_count" -ge "$swinject_count" ] && [ "$factory_count" -gt 0 ]; then
@@ -19,7 +19,9 @@
19
19
  import {
20
20
  chmodSync,
21
21
  linkSync,
22
+ lstatSync,
22
23
  readFileSync,
24
+ realpathSync,
23
25
  renameSync,
24
26
  rmSync,
25
27
  statSync,
@@ -125,17 +127,31 @@ export function withFileLockSync(
125
127
  /**
126
128
  * Replace `path` with `data` as JSON, atomically, at `mode`.
127
129
  *
130
+ * `followSymlink` is for a file the user owns and may keep in a dotfiles repo
131
+ * (the preferences file): when `path` is a symlink, the rename lands on its
132
+ * target, so the link and the dotfiles copy stay in step. Without it a
133
+ * symlink at `path` is replaced by a regular file, which is the safe default
134
+ * for a path an unattended run could plant a link at.
135
+ *
128
136
  * @param {string} path
129
137
  * @param {unknown} data
130
- * @param {{mode?: number}} [opts]
138
+ * @param {{mode?: number, followSymlink?: boolean}} [opts]
131
139
  */
132
- export function writeJsonAtomicSync(path, data, { mode = 0o600 } = {}) {
133
- const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
140
+ export function writeJsonAtomicSync(path, data, { mode = 0o600, followSymlink = false } = {}) {
141
+ let target = path;
142
+ if (followSymlink) {
143
+ try {
144
+ if (lstatSync(path).isSymbolicLink()) target = realpathSync(path);
145
+ } catch {
146
+ // Missing file: write the path as given.
147
+ }
148
+ }
149
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
134
150
  rmSync(tmp, { force: true });
135
151
  try {
136
152
  writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode, flag: "wx" });
137
153
  chmodSync(tmp, mode);
138
- renameSync(tmp, path);
154
+ renameSync(tmp, target);
139
155
  } catch (err) {
140
156
  rmSync(tmp, { force: true });
141
157
  throw err;
@@ -149,16 +165,20 @@ export function writeJsonAtomicSync(path, data, { mode = 0o600 } = {}) {
149
165
  *
150
166
  * @param {string} path
151
167
  * @param {(current: any) => any} mutate
152
- * @param {{fallback?: any, mode?: number, timeoutMs?: number, staleMs?: number}} [opts]
168
+ * @param {{fallback?: any, mode?: number, followSymlink?: boolean, timeoutMs?: number, staleMs?: number}} [opts]
153
169
  * @returns {any} the document written, or undefined when nothing was
154
170
  */
155
- export function updateJsonFileSync(path, mutate, { fallback = null, mode = 0o600, ...lock } = {}) {
171
+ export function updateJsonFileSync(
172
+ path,
173
+ mutate,
174
+ { fallback = null, mode = 0o600, followSymlink = false, ...lock } = {},
175
+ ) {
156
176
  return withFileLockSync(
157
177
  path,
158
178
  () => {
159
179
  const next = mutate(readRegularJson(path, fallback));
160
180
  if (next === undefined) return undefined;
161
- writeJsonAtomicSync(path, next, { mode });
181
+ writeJsonAtomicSync(path, next, { mode, followSymlink });
162
182
  return next;
163
183
  },
164
184
  lock,
@@ -2,13 +2,20 @@
2
2
  /**
3
3
  * normalize-text.mjs - fold look-alike text to the form a guard matches on.
4
4
  *
5
- * NFKC, then the invisible characters in confusables.json removed and its
6
- * look-alike letters and punctuation mapped to ASCII. A guard matches on the
7
- * raw text and on this folded copy, so a trailer or a token written with a
8
- * zero-width space or a Cyrillic letter is still recognised. The folded copy is
9
- * only ever matched against; nothing written or shown uses it.
5
+ * NFKC, then the Default_Ignorable_Code_Point ranges in confusables.json
6
+ * removed and its look-alike letters and punctuation mapped to ASCII. A guard
7
+ * matches on the raw text and on this folded copy, so a trailer or a token
8
+ * written with a zero-width space or a Cyrillic letter is still recognised. The
9
+ * folded copy is only ever matched against; nothing written or shown uses it.
10
10
  *
11
- * As a CLI it reads stdin and writes the folded text to stdout.
11
+ * The table is loaded fail-soft: when confusables.json cannot be read the fold
12
+ * is NFKC alone, `TABLE_LOADED` is false and one notice goes to stderr. The log
13
+ * redactor keeps working on that reduced fold; the outbound gate checks
14
+ * `TABLE_LOADED` and refuses to scan.
15
+ *
16
+ * As a CLI it reads stdin and writes the folded text to stdout. With `--nul`
17
+ * it reads NUL-separated records and writes each folded record followed by a
18
+ * NUL, so a caller folds many texts in one process.
12
19
  *
13
20
  * @module pipeline/lib/normalize-text
14
21
  */
@@ -17,32 +24,94 @@ import { readFileSync } from "node:fs";
17
24
  import { join } from "node:path";
18
25
  import { invokedDirectly } from "./invoked-directly.mjs";
19
26
 
20
- const TABLE = JSON.parse(readFileSync(join(import.meta.dirname, "confusables.json"), "utf8"));
21
- const STRIP = new Set(TABLE.strip);
22
- const MAP = new Map(Object.entries(TABLE.map));
27
+ function loadTable() {
28
+ try {
29
+ const table = JSON.parse(readFileSync(join(import.meta.dirname, "confusables.json"), "utf8"));
30
+ return { table, loaded: true };
31
+ } catch (err) {
32
+ process.stderr.write(
33
+ `normalize-text: confusables.json unreadable (${err.code || err.name}) - folding with NFKC only\n`,
34
+ );
35
+ return { table: {}, loaded: false };
36
+ }
37
+ }
38
+
39
+ const { table: TABLE, loaded } = loadTable();
40
+
41
+ /** False when confusables.json could not be read and the fold is NFKC only. */
42
+ export const TABLE_LOADED = loaded;
43
+
44
+ function stripSet(table) {
45
+ const set = new Set();
46
+ for (const range of table.stripRanges || []) {
47
+ const [first, last = first] = String(range).split("-");
48
+ const lo = parseInt(first, 16);
49
+ const hi = parseInt(last, 16);
50
+ if (Number.isNaN(lo) || Number.isNaN(hi)) continue;
51
+ for (let cp = lo; cp <= hi; cp++) set.add(cp);
52
+ }
53
+ return set;
54
+ }
55
+
56
+ const STRIP = stripSet(TABLE);
57
+ const MAP = new Map(Object.entries(TABLE.map || {}));
23
58
 
24
59
  /** @param {string} text */
25
60
  export function normalizeForMatch(text) {
26
61
  if (typeof text !== "string" || !text) return text;
27
62
  let out = "";
28
63
  for (const ch of text.normalize("NFKC")) {
29
- if (STRIP.has(ch)) continue;
64
+ if (STRIP.has(ch.codePointAt(0))) continue;
30
65
  out += MAP.get(ch) ?? ch;
31
66
  }
32
67
  return out;
33
68
  }
34
69
 
35
- /** The text with its invisible characters removed and nothing else changed. */
36
- export function stripInvisible(text) {
37
- if (typeof text !== "string" || !text) return text;
38
- let out = "";
39
- for (const ch of text) if (!STRIP.has(ch)) out += ch;
40
- return out;
70
+ /**
71
+ * The fold of `text` one code point at a time, with the span of the original
72
+ * each folded UTF-16 unit came from: folded unit `j` came from
73
+ * `text.slice(start[j], end[j])`. A match on `folded` from `a` to `b` covers
74
+ * `text.slice(start[a], end[b - 1])`. Folding per code point rather than over
75
+ * the whole string differs from normalizeForMatch only where NFKC would
76
+ * compose a base letter with a following combining mark, which no credential
77
+ * shape contains.
78
+ *
79
+ * @param {string} text
80
+ * @returns {{ folded: string, start: number[], end: number[] }}
81
+ */
82
+ export function foldWithIndex(text) {
83
+ const start = [];
84
+ const end = [];
85
+ let folded = "";
86
+ let i = 0;
87
+ for (const ch of text) {
88
+ const next = i + ch.length;
89
+ for (const part of ch.normalize("NFKC")) {
90
+ if (STRIP.has(part.codePointAt(0))) continue;
91
+ const mapped = MAP.get(part) ?? part;
92
+ for (let k = 0; k < mapped.length; k++) {
93
+ start.push(i);
94
+ end.push(next);
95
+ }
96
+ folded += mapped;
97
+ }
98
+ i = next;
99
+ }
100
+ return { folded, start, end };
41
101
  }
42
102
 
43
103
  if (invokedDirectly(import.meta.url)) {
104
+ const nul = process.argv.includes("--nul");
44
105
  let input = "";
45
106
  process.stdin.setEncoding("utf8");
46
107
  process.stdin.on("data", (d) => (input += d));
47
- process.stdin.on("end", () => process.stdout.write(normalizeForMatch(input)));
108
+ process.stdin.on("end", () => {
109
+ if (!nul) {
110
+ process.stdout.write(normalizeForMatch(input));
111
+ return;
112
+ }
113
+ const records = input.split("\0");
114
+ if (records.at(-1) === "") records.pop();
115
+ process.stdout.write(records.map((r) => `${normalizeForMatch(r)}\0`).join(""));
116
+ });
48
117
  }
@@ -42,14 +42,15 @@
42
42
  * cat body.md | outbound-gate.mjs --warn # report, never block
43
43
  *
44
44
  * Exit codes:
45
- * 0 - nothing found
45
+ * 0 - nothing found, or an empty body
46
46
  * 7 - at least one finding; the caller must not publish
47
- * 2 - usage error (no input)
47
+ * 2 - usage error (--file without a path)
48
+ * or a stdin that cannot be read at all
48
49
  */
49
50
 
50
51
  import { readFileSync } from "node:fs";
51
52
  import { invokedDirectly } from "./invoked-directly.mjs";
52
- import { normalizeForMatch } from "./normalize-text.mjs";
53
+ import { TABLE_LOADED, normalizeForMatch } from "./normalize-text.mjs";
53
54
 
54
55
  /**
55
56
  * Each rule is a SHAPE, not a dictionary. A list of known token prefixes goes
@@ -148,17 +149,25 @@ export const SURFACES = [
148
149
  },
149
150
  ];
150
151
 
152
+ // Printable ASCII and tab fold to themselves; any other line is folded.
153
+ const NEEDS_FOLD = /[^\t -~]/;
154
+
151
155
  /**
152
156
  * @param {string} text
153
157
  * @returns {{rule: string, certain: boolean, line: number}[]}
154
158
  */
155
159
  export function scan(text) {
160
+ // Without the fold table a disguised credential would pass as clean text,
161
+ // so the gate refuses to scan rather than report nothing.
162
+ if (!TABLE_LOADED) {
163
+ throw new Error("outbound-gate: confusables.json unreadable - refusing to scan");
164
+ }
156
165
  const findings = [];
157
166
  const lines = String(text).split("\n");
158
167
  for (let i = 0; i < lines.length; i++) {
159
168
  // A credential written with an invisible character or a look-alike letter
160
169
  // still matches: the certain rules also run on the folded line.
161
- const folded = normalizeForMatch(lines[i]);
170
+ const folded = NEEDS_FOLD.test(lines[i]) ? normalizeForMatch(lines[i]) : lines[i];
162
171
  for (const rule of RULES) {
163
172
  // `lastIndex` survives between calls on a /g regex and would make every
164
173
  // second line skip its start. Reset per line.
@@ -7,6 +7,13 @@
7
7
  * `[redacted:<rule>]`; the text around it is kept, because a log line is read
8
8
  * to debug a failure and the message is what makes it useful.
9
9
  *
10
+ * The rules run on the folded copy of the text (normalize-text.mjs), so a token
11
+ * carrying an invisible character or a look-alike letter is found, and the
12
+ * span of the ORIGINAL text it came from is the one replaced. Everything
13
+ * outside a match is written back as it was: a Persian zero-width non-joiner,
14
+ * a direction mark, an emoji joined by U+200D and a soft hyphen all survive,
15
+ * and a text with no match is returned unchanged.
16
+ *
10
17
  * As a CLI it reads stdin and writes the redacted text to stdout. It never
11
18
  * fails its caller: on an internal error it writes the input unchanged.
12
19
  *
@@ -15,27 +22,93 @@
15
22
 
16
23
  import { RULES } from "./outbound-gate.mjs";
17
24
  import { invokedDirectly } from "./invoked-directly.mjs";
18
- import { stripInvisible } from "./normalize-text.mjs";
25
+ import { foldWithIndex, normalizeForMatch } from "./normalize-text.mjs";
19
26
 
20
27
  const CERTAIN = RULES.filter((r) => r.certain);
21
28
 
29
+ const globalCopy = (re) =>
30
+ new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
31
+ const marker = (name) =>
32
+ name === "url-with-credentials" ? `[redacted:${name}]@` : `[redacted:${name}]`;
33
+
34
+ // Where two rules match overlapping text, the one with the longer literal
35
+ // prefix names the span: `sk-ant-...` is an Anthropic key even though the
36
+ // shorter `sk-` OpenAI rule matches it too. Equal prefixes keep rule order.
37
+ const literalPrefix = (re) => (re.source.replace(/^\\b/, "").match(/^[A-Za-z0-9_-]*/) || [""])[0];
38
+ const RANK = new Map(
39
+ CERTAIN.map((r, i) => [r.name, { specificity: literalPrefix(r.re).length, order: i }]),
40
+ );
41
+ const moreSpecific = (a, b) => {
42
+ const x = RANK.get(a);
43
+ const y = RANK.get(b);
44
+ return x.specificity > y.specificity || (x.specificity === y.specificity && x.order < y.order);
45
+ };
46
+
47
+ /**
48
+ * Spans of `text` a certain rule matches, as [from, to, rule], merged and in
49
+ * order. The rules run on the raw text and on the folded copy: a letter that
50
+ * folds to a word character (the Turkish dotless i, a ligature, a
51
+ * mathematical letter) right before a token removes the word boundary the
52
+ * rule needs in the folded copy only, and a look-alike inside a token hides it
53
+ * from the raw text only. Raw spans are already in original indices; folded
54
+ * ones are mapped back through the fold's index.
55
+ */
56
+ function findSpans(text) {
57
+ const spans = [];
58
+ for (const rule of CERTAIN) {
59
+ for (const m of text.matchAll(globalCopy(rule.re))) {
60
+ if (m[0]) spans.push([m.index, m.index + m[0].length, rule.name]);
61
+ }
62
+ }
63
+ const { folded, start, end } = foldWithIndex(text);
64
+ if (folded !== text) {
65
+ for (const rule of CERTAIN) {
66
+ for (const m of folded.matchAll(globalCopy(rule.re))) {
67
+ if (!m[0]) continue;
68
+ spans.push([start[m.index], end[m.index + m[0].length - 1], rule.name]);
69
+ }
70
+ }
71
+ }
72
+ spans.sort((x, y) => x[0] - y[0] || y[1] - x[1]);
73
+ const merged = [];
74
+ for (const s of spans) {
75
+ const last = merged.at(-1);
76
+ if (last && s[0] < last[1]) {
77
+ if (s[1] > last[1]) last[1] = s[1];
78
+ if (moreSpecific(s[2], last[2])) last[2] = s[2];
79
+ } else {
80
+ merged.push([...s]);
81
+ }
82
+ }
83
+ return merged;
84
+ }
85
+
86
+ function stillMatches(text) {
87
+ const folded = normalizeForMatch(text);
88
+ return CERTAIN.find((r) => globalCopy(r.re).test(text) || globalCopy(r.re).test(folded));
89
+ }
90
+
22
91
  /** @param {string} text */
23
92
  export function redact(text) {
24
93
  if (typeof text !== "string" || !text) return text;
25
- // Invisible characters are removed first, joiners included, so a token split
26
- // by one cannot slip past the rules. The logged line loses them too: an emoji
27
- // sequence joined by U+200D prints as its separate parts.
28
- let out = stripInvisible(text);
29
- for (const rule of CERTAIN) {
30
- const re = new RegExp(
31
- rule.re.source,
32
- rule.re.flags.includes("g") ? rule.re.flags : `${rule.re.flags}g`,
33
- );
34
- out = out.replace(re, () =>
35
- rule.name === "url-with-credentials" ? `[redacted:${rule.name}]@` : `[redacted:${rule.name}]`,
36
- );
94
+ const spans = findSpans(text);
95
+ let out = text;
96
+ for (let k = spans.length - 1; k >= 0; k--) {
97
+ const [from, to, name] = spans[k];
98
+ out = out.slice(0, from) + marker(name) + out.slice(to);
37
99
  }
38
- return out;
100
+ // The per-code-point fold that locates spans can differ from the whole-text
101
+ // fold the gate uses where NFKC composes across characters. A line the gate
102
+ // would still flag after both the raw and the folded spans are replaced is
103
+ // replaced whole rather than written with a credential.
104
+ if (!stillMatches(out)) return out;
105
+ return out
106
+ .split("\n")
107
+ .map((line) => {
108
+ const rule = stillMatches(line);
109
+ return rule ? marker(rule.name) : line;
110
+ })
111
+ .join("\n");
39
112
  }
40
113
 
41
114
  if (invokedDirectly(import.meta.url)) {
@@ -107,7 +107,7 @@ the bucket patterns use (`docs/adr/0010-own-code-graph.md`).
107
107
  | domainEntities | `Domain/Entities/*.swift` | `domain/entities/*.kt` / `entities/*.py` |
108
108
  | routes | Public `enum *Route` matching `DomainRoute` | `*Route` sealed / NavRoute / `routes.*` modules |
109
109
  | coordinators | `*Coordinator.swift` + `CoordinatorProtocol`/`DomainRouter` | Navigator / NavController helpers |
110
- | diConfigurators | `*DependencyConfigurator.swift`, `Module.kt` (Hilt) | DI container registrations |
110
+ | diConfigurators | `*DependencyRegistrar.swift`, `*Assembly.swift`, `Module.kt` (Hilt) | DI container registrations |
111
111
  | uiComponents | `Common/UIComponents/.../Components/**/*.swift` triplet (Configuration + View + +Modifiers) | Compose @Composable functions / React components |
112
112
  | tokens | `*Token` enums under `UIAssetTokens/Generated/` | `Theme.kt` / `tokens.ts` / `tailwind.config.*` |
113
113
  | localizationKeys | `LocalizationStringKeys.swift` public enum + cases | `strings.xml` keys / `i18n/*.json` keys |
@@ -49,7 +49,7 @@ Convention preview - Pass B will render with:
49
49
  | Test naming | @Test func scenario_expected() ^[C4 medium: 4 examples] | fun scenario_expectedBehavior() ^[C4 high] |
50
50
  | Identifier | userProfile.continueButton ^[C5 high] | userProfileContinueButton ^[C5 medium] |
51
51
  | Localization key | UserProfile.ContinueButton ^[C6 high] | user_profile_continue_button ^[C6 high] |
52
- | DI | UserProfileDependencyConfigurator ^[C7 high] | UserProfileModule (Hilt) ^[C7 fallback: defaults] |
52
+ | DI | UserProfileDependencyRegistrar ^[C7 high] | UserProfileModule (Hilt) ^[C7 fallback: defaults] |
53
53
 
54
54
  Confidence summary:
55
55
  iOS: 5/5 high, 0 medium, 0 low, 0 fallback
@@ -47,7 +47,7 @@ run produced entities and a mapper but left the screen half-wired.
47
47
 
48
48
  | `state.componentScope` | Meaning | iOS skill | Android skill |
49
49
  |---|---|---|---|
50
- | `screen` (default when the frame is a full screen, or the task names a screen) | Full clean-architecture vertical: Entity → Repository → Mapper → UseCase → LocalizedText → AnalyticsTracking → CoordinatorEvent → ViewModel → Scene → Preview, then verify | `ai-ios-toolkit:create-screen` | `ai-android-toolkit:create-screen` |
50
+ | `screen` (default when the frame is a full screen, or the task names a screen) | Full clean-architecture vertical: Entity → Repository → Mapper → UseCase → Copy → AnalyticsTracking → CoordinatorEvent → ViewModel → Scene → Preview, then verify | `ai-ios-toolkit:create-screen` | `ai-android-toolkit:create-screen` |
51
51
  | `component` | One reusable UI component (Configuration / View / +Modifiers / Code Connect) | `ai-ios-toolkit:create-component` (fallback `create-ui-component`) | `ai-android-toolkit:create-component` (fallback `create-ui-component`) |
52
52
  | `evolve` | Change an existing component | `evolve-component` (fallback `evolve-ui-component`) | same |
53
53
 
@@ -160,7 +160,7 @@ Detection: scan up to 10 SwiftUI view files in the candidate set; majority pick
160
160
 
161
161
  | Platform | Default | Example | Rationale |
162
162
  |---|---|---|---|
163
- | iOS | Manual configurator | `<Domain>DependencyConfigurator.register<Feature>()` | Avoids framework lock-in; testable |
163
+ | iOS | Manual configurator | `<Domain>DependencyRegistrar.register<Feature>()` | Avoids framework lock-in; testable |
164
164
  | Android | Hilt module | `@Module @InstallIn(SingletonComponent::class) class <Feature>Module` | Standard Android DI |
165
165
  | Backend FastAPI | `Depends(get_<feature>_service)` | `service: OrderSummaryService = Depends(get_order_summary_service)` | Native FastAPI pattern |
166
166
  | Web | hook factory | `useOrderSummaryApi()` returns a memoized client | No framework DI needed |
@@ -287,8 +287,8 @@ Before every launch - the dev run, a research pass, a resume - the runner:
287
287
  settings, read the way `install/claude.mjs` writes it: a matcher is a regex
288
288
  over the tool name (the legacy `Bash(git push:*)` covers nothing, and the web
289
289
  entry has to cover `agent_run_steps` and both `open_url` tools), the hook
290
- command is exactly the installer's `bash $HOME/.claude/scripts/agent-guard.sh`
291
- (or the same path with `$HOME` expanded) and that script exists. The
290
+ command is exactly the installer's `bash "$HOME/.claude/scripts/agent-guard.sh"`
291
+ (quoted or not, or the same path with `$HOME` expanded) and that script exists. The
292
292
  checkout's `.claude/` settings never count toward the registration - the repo
293
293
  under work is not where the guard may come from - but `disableAllHooks` in
294
294
  any of these files, the checkout's included, turns every hook off. It also