@mmerterden/multi-agent-pipeline 20.8.1 → 20.8.2
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/CHANGELOG.md +36 -0
- package/docs/facts.json +3 -3
- package/index.js +1 -0
- package/install/_codex-agents.mjs +1 -1
- package/install/_common.mjs +486 -53
- package/install/_mcp-register.mjs +173 -117
- package/install/claude.mjs +281 -220
- package/install/codex.mjs +7 -7
- package/install/copilot.mjs +13 -11
- package/install/index.mjs +92 -27
- package/install/templates/claude-hooks.json +9 -9
- package/manifest.json +75 -78
- package/package.json +4 -1
- package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
- package/pipeline/lib/confusables.json +79 -33
- package/pipeline/lib/extract-conventions.sh +3 -3
- package/pipeline/lib/json-file-lock.mjs +27 -7
- package/pipeline/lib/normalize-text.mjs +86 -17
- package/pipeline/lib/outbound-gate.mjs +13 -4
- package/pipeline/lib/redact.mjs +87 -14
- package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
- package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
- package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
- package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
- package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
- package/pipeline/scripts/agent-guard.py +150 -25
- package/pipeline/scripts/audit-log.sh +3 -4
- package/pipeline/scripts/autopilot-runner.mjs +14 -5
- package/pipeline/scripts/doctor.mjs +8 -2
- package/pipeline/scripts/log-metric.sh +9 -3
- package/pipeline/scripts/migrate-prefs.mjs +18 -4
- package/pipeline/scripts/pre-commit-check.sh +119 -31
- package/pipeline/scripts/scan-agent-config.sh +9 -9
- package/pipeline/scripts/unattended_policy.py +12 -3
- package/pipeline/scripts/uninstall.mjs +88 -1
- package/pipeline/scripts/usage-identity.mjs +1 -1
- package/pipeline/scripts/usage-register.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +11 -11
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
- package/pipeline/scripts/gen-ref-toc.mjs +0 -279
- package/pipeline/scripts/make-manifest.mjs +0 -199
- 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. `
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
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 '
|
|
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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
6
|
-
* look-alike letters and punctuation mapped to ASCII. A guard
|
|
7
|
-
* raw text and on this folded copy, so a trailer or a token
|
|
8
|
-
* zero-width space or a Cyrillic letter is still recognised. The
|
|
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
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
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
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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", () =>
|
|
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 (
|
|
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.
|
package/pipeline/lib/redact.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 | `*
|
|
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 |
|
|
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 →
|
|
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>
|
|
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
|