@mmerterden/multi-agent-pipeline 20.8.0 → 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 +48 -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 +112 -114
- package/package.json +5 -2
- 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 -13
- 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/gen-skills-index.mjs +13 -1
- package/pipeline/scripts/log-metric.sh +9 -3
- package/pipeline/scripts/migrate-prefs.mjs +18 -4
- package/pipeline/scripts/pre-commit-check.sh +119 -27
- 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 +30 -30
- package/pipeline/skills/shared/README.md +64 -64
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
- package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
- package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
- package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
- package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
- package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
- package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
- package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
- 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-bluetooth/SKILL.md +4 -2
- package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
- package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
- 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 +6 -6
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +3 -2
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +20 -18
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +4 -4
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +12 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +201 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +64 -30
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +20 -12
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +26 -12
- 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 +51 -24
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +19 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +26 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -46
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +37 -63
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
- 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 +7 -1
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
- package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
- package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
- package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
- package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
- package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
- package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
- package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
- package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
- 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
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,26 +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
|
-
let
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
rule.re.source,
|
|
31
|
-
rule.re.flags.includes("g") ? rule.re.flags : `${rule.re.flags}g`,
|
|
32
|
-
);
|
|
33
|
-
out = out.replace(re, () =>
|
|
34
|
-
rule.name === "url-with-credentials" ? `[redacted:${rule.name}]@` : `[redacted:${rule.name}]`,
|
|
35
|
-
);
|
|
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);
|
|
36
99
|
}
|
|
37
|
-
|
|
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");
|
|
38
112
|
}
|
|
39
113
|
|
|
40
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
|
|
@@ -51,12 +51,24 @@ ATTRIBUTION = re.compile(
|
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _strip_set(ranges):
|
|
55
|
+
out = set()
|
|
56
|
+
for spec in ranges:
|
|
57
|
+
first, _, last = str(spec).partition("-")
|
|
58
|
+
try:
|
|
59
|
+
lo, hi = int(first, 16), int(last or first, 16)
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
out.update(chr(cp) for cp in range(lo, hi + 1))
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
54
66
|
def _load_confusables():
|
|
55
67
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
56
68
|
try:
|
|
57
69
|
with open(os.path.join(here, "..", "lib", "confusables.json"), encoding="utf-8") as fh:
|
|
58
70
|
table = json.load(fh)
|
|
59
|
-
return
|
|
71
|
+
return _strip_set(table.get("stripRanges", [])), dict(table.get("map", {}))
|
|
60
72
|
except (OSError, ValueError):
|
|
61
73
|
return set(), {}
|
|
62
74
|
|
|
@@ -65,12 +77,14 @@ _STRIP, _MAP = _load_confusables()
|
|
|
65
77
|
|
|
66
78
|
|
|
67
79
|
def normalize_for_match(text: str) -> str:
|
|
68
|
-
"""NFKC,
|
|
80
|
+
"""NFKC, Default_Ignorable_Code_Point removed, look-alikes mapped to ASCII.
|
|
69
81
|
|
|
70
82
|
The same fold lib/normalize-text.mjs applies, from the same table. Rules
|
|
71
83
|
that match text content run on the raw command and on this copy, so a
|
|
72
84
|
trailer written with a zero-width space or a Cyrillic letter still matches.
|
|
73
|
-
|
|
85
|
+
The table's map also carries the compatibility letters newer than this
|
|
86
|
+
Python's unicodedata, which NFKC here would leave alone. Without the table
|
|
87
|
+
only NFKC applies.
|
|
74
88
|
"""
|
|
75
89
|
return "".join(
|
|
76
90
|
_MAP.get(ch, ch) for ch in unicodedata.normalize("NFKC", text) if ch not in _STRIP
|
|
@@ -130,10 +144,23 @@ GIT_FLAG_OPTIONS = {
|
|
|
130
144
|
SEPARATORS = re.compile(r"&&|\|\||;|\||\n")
|
|
131
145
|
|
|
132
146
|
|
|
147
|
+
# shlex grows each token one character at a time, which is quadratic in the
|
|
148
|
+
# token's length: a megabyte argument takes longer than the deadline. A segment
|
|
149
|
+
# longer than this is split on whitespace with its quotes and backslashes
|
|
150
|
+
# removed instead, which keeps every word the segment names in order.
|
|
151
|
+
SHLEX_MAX_CHARS = 16384
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def coarse_tokens(cmd: str) -> list:
|
|
155
|
+
return [re.sub(r"[\"'\\]", "", t) for t in cmd.split()]
|
|
156
|
+
|
|
157
|
+
|
|
133
158
|
def tokenize(cmd: str) -> list:
|
|
134
159
|
"""shlex tokens, or whitespace tokens when shlex cannot parse (an unbalanced
|
|
135
|
-
quote). Never returns None: a guard has to
|
|
136
|
-
parse cleanly, not skip it."""
|
|
160
|
+
quote) or the segment is too long for it. Never returns None: a guard has to
|
|
161
|
+
reason about a segment it cannot parse cleanly, not skip it."""
|
|
162
|
+
if len(cmd) > SHLEX_MAX_CHARS:
|
|
163
|
+
return coarse_tokens(cmd)
|
|
137
164
|
try:
|
|
138
165
|
return shlex.split(cmd)
|
|
139
166
|
except Exception:
|
|
@@ -401,11 +428,12 @@ def _decide_push(cmd: str, toks=None, cwd=None) -> str:
|
|
|
401
428
|
force_flag = bool(FORCE.search(cmd))
|
|
402
429
|
if not force_flag and not FORCED_REFSPEC.search(cmd) and not PUSH_GATE.search(cmd):
|
|
403
430
|
return "OK"
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
431
|
+
if len(cmd) <= SHLEX_MAX_CHARS:
|
|
432
|
+
try:
|
|
433
|
+
shlex.split(cmd)
|
|
434
|
+
except Exception:
|
|
435
|
+
# A force-push we can't tokenize -> can't prove it's safe -> block.
|
|
436
|
+
return "BLOCK_FORCE"
|
|
409
437
|
git_dir, opts, positionals = _parse_push(toks)
|
|
410
438
|
keys = {k for k, _ in opts}
|
|
411
439
|
force_flag = any(
|
|
@@ -451,17 +479,97 @@ def _decide_push(cmd: str, toks=None, cwd=None) -> str:
|
|
|
451
479
|
return "OK"
|
|
452
480
|
|
|
453
481
|
|
|
482
|
+
FALLBACK_SPLIT = re.compile(r"&&|\|\||[;|\n()`]|\\n")
|
|
483
|
+
FALLBACK_FORCE_WORDS = {"-d", "--force", "--force-with-lease", "--force-if-includes", "--mirror", "--delete", "--prune"}
|
|
484
|
+
FALLBACK_TARGET_WORDS = {"head", "@", "--all", "--mirror", "--branches"}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _fallback_forced(tok: str) -> bool:
|
|
488
|
+
key = tok.split("=", 1)[0]
|
|
489
|
+
if key in FALLBACK_FORCE_WORDS or tok.startswith("+") or ":+" in tok or tok.startswith(":"):
|
|
490
|
+
return True
|
|
491
|
+
return tok.startswith("-") and not tok.startswith("--") and tok[1:].isalpha() and "f" in tok
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _fallback_protected(tok: str) -> bool:
|
|
495
|
+
name = tok.rsplit(":", 1)[-1].lstrip("+")
|
|
496
|
+
if name.startswith("refs/heads/"):
|
|
497
|
+
name = name[len("refs/heads/"):]
|
|
498
|
+
return name in PROTECTED or tok in FALLBACK_TARGET_WORDS
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def fallback_verdict(text: str) -> str:
|
|
502
|
+
"""The always-on rules as a linear scan, for input the tokenizer could not
|
|
503
|
+
finish in time. Each piece between shell separators is split on whitespace
|
|
504
|
+
with its quotes and backslashes removed, and no regex that can backtrack
|
|
505
|
+
runs over it. A push piece with a force indicator is blocked when it names a
|
|
506
|
+
protected branch, HEAD / @ or every ref, or when the checkout's own branch
|
|
507
|
+
is protected or unknown; a line with a git commit is blocked when the
|
|
508
|
+
attribution pattern matches the raw or the folded text. Stricter than
|
|
509
|
+
decide() by design: it only runs when decide() could not."""
|
|
510
|
+
try:
|
|
511
|
+
folded = normalize_for_match(text)
|
|
512
|
+
except Exception:
|
|
513
|
+
folded = text
|
|
514
|
+
current = os.environ.get("CUR_BRANCH", "")
|
|
515
|
+
commits = False
|
|
516
|
+
for t in (text, folded):
|
|
517
|
+
for piece in FALLBACK_SPLIT.split(t.lower()):
|
|
518
|
+
toks = coarse_tokens(piece)
|
|
519
|
+
if not any(os.path.basename(x) == "git" for x in toks):
|
|
520
|
+
continue
|
|
521
|
+
words = set(toks)
|
|
522
|
+
if "commit" in words:
|
|
523
|
+
commits = True
|
|
524
|
+
if "push" not in words or not any(_fallback_forced(x) for x in toks):
|
|
525
|
+
continue
|
|
526
|
+
if any(_fallback_protected(x) for x in toks) or current in PROTECTED or current in ("", "HEAD"):
|
|
527
|
+
return "BLOCK_FORCE"
|
|
528
|
+
if commits and (ATTRIBUTION.search(text) or ATTRIBUTION.search(folded)):
|
|
529
|
+
return "BLOCK_ATTRIB"
|
|
530
|
+
return "OK"
|
|
531
|
+
|
|
532
|
+
|
|
454
533
|
def unattended() -> bool:
|
|
455
534
|
"""MULTI_AGENT_UNATTENDED=1 in the hook's own environment - the launcher's,
|
|
456
535
|
never anything the run wrote (lib/unattended.sh ma_unattended)."""
|
|
457
536
|
return os.environ.get("MULTI_AGENT_UNATTENDED", "") == "1"
|
|
458
537
|
|
|
459
538
|
|
|
539
|
+
def _arm_deadline(on_deadline) -> None:
|
|
540
|
+
"""Run on_deadline (which must end the process) once deadline_seconds()
|
|
541
|
+
have passed. Where SIGALRM is unavailable the call simply runs to the end."""
|
|
542
|
+
|
|
543
|
+
def _fire(_signum, _frame):
|
|
544
|
+
try:
|
|
545
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
546
|
+
except (ValueError, AttributeError, OSError):
|
|
547
|
+
pass
|
|
548
|
+
on_deadline()
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
signal.signal(signal.SIGALRM, _fire)
|
|
552
|
+
signal.setitimer(signal.ITIMER_REAL, deadline_seconds())
|
|
553
|
+
except (ValueError, AttributeError, OSError):
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _exit_with(line: str) -> None:
|
|
558
|
+
# os._exit skips the stdio flush, so the line is flushed first or the
|
|
559
|
+
# caller reads nothing.
|
|
560
|
+
if line:
|
|
561
|
+
print(line, flush=True)
|
|
562
|
+
os._exit(0)
|
|
563
|
+
|
|
564
|
+
|
|
460
565
|
def git_subcommand_mode() -> None:
|
|
461
566
|
"""`agent-guard.py --git-subcommand`: read a Bash payload (or a raw command
|
|
462
567
|
on argv) and print each git subcommand it runs, one per line, using the same
|
|
463
568
|
tokenizer the guard uses. pre-commit-check.sh reads this to detect a commit
|
|
464
|
-
without a regex. Never executes anything.
|
|
569
|
+
without a regex. Never executes anything. At the deadline it prints
|
|
570
|
+
`commit`, so the caller scans rather than skipping a commit it could not
|
|
571
|
+
rule out."""
|
|
572
|
+
_arm_deadline(lambda: _exit_with("commit"))
|
|
465
573
|
if len(sys.argv) > 2:
|
|
466
574
|
cmd = sys.argv[2]
|
|
467
575
|
else:
|
|
@@ -490,8 +598,9 @@ def commit_dir_mode() -> None:
|
|
|
490
598
|
"""`agent-guard.py --commit-dir`: read a Bash payload and print the
|
|
491
599
|
directory the first commit-writing git command on the line runs in, after
|
|
492
600
|
any `cd` / `git -C`, resolved from the payload's cwd. Prints nothing when
|
|
493
|
-
there is no commit
|
|
494
|
-
keeps its own cwd. Never executes anything."""
|
|
601
|
+
there is no commit, the directory cannot be read literally, or the deadline
|
|
602
|
+
passes, so the caller keeps its own cwd. Never executes anything."""
|
|
603
|
+
_arm_deadline(lambda: _exit_with(""))
|
|
495
604
|
try:
|
|
496
605
|
data = json.load(sys.stdin)
|
|
497
606
|
cmd = (data.get("tool_input") or {}).get("command", "")
|
|
@@ -519,21 +628,37 @@ def main() -> None:
|
|
|
519
628
|
commit_dir_mode()
|
|
520
629
|
return
|
|
521
630
|
strict = unattended()
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
# the
|
|
526
|
-
|
|
527
|
-
|
|
631
|
+
seen = {"text": "", "raw": True}
|
|
632
|
+
|
|
633
|
+
def _unjudged(default: str) -> str:
|
|
634
|
+
# The always-on rules still hold when the full decision could not be
|
|
635
|
+
# reached: a linear scan of whatever text is at hand decides them.
|
|
636
|
+
text = seen["text"]
|
|
637
|
+
if seen.get("raw"):
|
|
638
|
+
try:
|
|
639
|
+
cmd = (json.loads(text).get("tool_input") or {}).get("command", "")
|
|
640
|
+
text = cmd if isinstance(cmd, str) else text
|
|
641
|
+
except Exception:
|
|
642
|
+
pass
|
|
643
|
+
try:
|
|
644
|
+
verdict = fallback_verdict(text)
|
|
645
|
+
except Exception:
|
|
646
|
+
verdict = "OK"
|
|
647
|
+
return verdict if verdict != "OK" else default
|
|
528
648
|
|
|
529
649
|
try:
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
650
|
+
seen["text"] = sys.stdin.read()
|
|
651
|
+
except Exception:
|
|
652
|
+
seen["text"] = ""
|
|
653
|
+
# Armed once the payload is in hand, so the fallback always has the text.
|
|
654
|
+
_arm_deadline(
|
|
655
|
+
lambda: _exit_with(_unjudged("BLOCK_UNATTENDED\tthe guard hit its internal deadline" if strict else "OK"))
|
|
656
|
+
)
|
|
534
657
|
try:
|
|
535
|
-
data = json.
|
|
658
|
+
data = json.loads(seen["text"])
|
|
536
659
|
cmd = (data.get("tool_input") or {}).get("command", "")
|
|
660
|
+
if isinstance(cmd, str):
|
|
661
|
+
seen["text"], seen["raw"] = cmd, False
|
|
537
662
|
verdict = decide(cmd) if isinstance(cmd, str) and cmd else "OK"
|
|
538
663
|
if verdict == "OK" and strict:
|
|
539
664
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -547,7 +672,7 @@ def main() -> None:
|
|
|
547
672
|
# Attended: a guard bug must not break a legitimate tool call.
|
|
548
673
|
# Unattended: nobody is there to notice a gap, so an input the guard
|
|
549
674
|
# cannot judge is refused.
|
|
550
|
-
print("BLOCK_PARSE" if strict else "OK")
|
|
675
|
+
print(_unjudged("BLOCK_PARSE" if strict else "OK"))
|
|
551
676
|
|
|
552
677
|
|
|
553
678
|
if __name__ == "__main__":
|
|
@@ -56,10 +56,9 @@ hash_url() {
|
|
|
56
56
|
elif command -v shasum >/dev/null 2>&1; then
|
|
57
57
|
printf '%s' "$url" | shasum -a 256 | awk '{print $1}'
|
|
58
58
|
else
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# leaking one.
|
|
59
|
+
# No hash tool: record a fixed marker, never the URL itself. The plaintext
|
|
60
|
+
# remote is what the hashing keeps out of the log, and a remote URL can
|
|
61
|
+
# carry embedded credentials.
|
|
63
62
|
echo "unhashable-no-sha256-tool"
|
|
64
63
|
fi
|
|
65
64
|
}
|
|
@@ -263,10 +263,11 @@ const GUARD_MATCHERS = [
|
|
|
263
263
|
],
|
|
264
264
|
],
|
|
265
265
|
];
|
|
266
|
-
// The hook command install/claude.mjs writes
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
266
|
+
// The hook command install/claude.mjs writes (the path quoted, so a HOME with a
|
|
267
|
+
// space still runs it). Only this command, quoted or not, with $HOME written
|
|
268
|
+
// literally or expanded, counts as the guard: a command that merely mentions
|
|
269
|
+
// the script path can run anything and still look registered.
|
|
270
|
+
const GUARD_COMMAND = 'bash "$HOME/.claude/scripts/agent-guard.sh"';
|
|
270
271
|
const MANAGED_SETTINGS = "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
271
272
|
// A phase record without a model is priced at the most expensive tier: an
|
|
272
273
|
// over-estimate stops the spend ceiling early, an under-estimate stops it late.
|
|
@@ -1334,7 +1335,15 @@ function matcherCovers(matcher, tool) {
|
|
|
1334
1335
|
/** The guard script, when the hook command is exactly the installer's and the script exists. */
|
|
1335
1336
|
function guardScriptOf(command, home) {
|
|
1336
1337
|
const script = join(home, ".claude", "scripts", "agent-guard.sh");
|
|
1337
|
-
const
|
|
1338
|
+
const unquoted = GUARD_COMMAND.replaceAll('"', "");
|
|
1339
|
+
const forms = [
|
|
1340
|
+
GUARD_COMMAND,
|
|
1341
|
+
GUARD_COMMAND.replace("$HOME", "${HOME}"),
|
|
1342
|
+
unquoted,
|
|
1343
|
+
unquoted.replace("$HOME", "${HOME}"),
|
|
1344
|
+
`bash "${script}"`,
|
|
1345
|
+
`bash ${script}`,
|
|
1346
|
+
];
|
|
1338
1347
|
return forms.includes(String(command || "").trim()) && existsSync(script) ? script : null;
|
|
1339
1348
|
}
|
|
1340
1349
|
|
|
@@ -465,8 +465,14 @@ function checkHookCoverage() {
|
|
|
465
465
|
for (const h of entry.hooks || []) if (h.command) wantedCmds.add(h.command);
|
|
466
466
|
}
|
|
467
467
|
}
|
|
468
|
-
const
|
|
469
|
-
const
|
|
468
|
+
const haveCmds = new Set();
|
|
469
|
+
for (const group of Object.values(have?.hooks || {})) {
|
|
470
|
+
for (const entry of Array.isArray(group) ? group : []) {
|
|
471
|
+
for (const h of entry?.hooks || [])
|
|
472
|
+
if (typeof h?.command === "string") haveCmds.add(h.command);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const absent = [...wantedCmds].filter((c) => !haveCmds.has(c));
|
|
470
476
|
if (absent.length) {
|
|
471
477
|
report(
|
|
472
478
|
"hook-coverage",
|
|
@@ -100,6 +100,18 @@ for (const k of order) {
|
|
|
100
100
|
md += `\n`;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
const target = join(SKILLS_ROOT, "README.md");
|
|
104
|
+
if (process.argv.includes("--check")) {
|
|
105
|
+
const current = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
106
|
+
if (current !== md) {
|
|
107
|
+
console.error(
|
|
108
|
+
"gen-skills-index: pipeline/skills/shared/README.md is stale - run node pipeline/scripts/gen-skills-index.mjs",
|
|
109
|
+
);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
console.log("gen-skills-index: README.md is current");
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
writeFileSync(target, md);
|
|
104
116
|
const filledCategories = Object.values(buckets).filter((b) => b.length).length;
|
|
105
117
|
console.log(`wrote ${total} skills (${breakdown}) across ${filledCategories} categories`);
|
|
@@ -55,6 +55,12 @@ mkdir -p "$METRICS_DIR" 2>/dev/null || {
|
|
|
55
55
|
exit 0
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
# Same escaping as audit-log.sh: backslash, quote, tab and CR escaped, and a
|
|
59
|
+
# newline written as \n, so any caller text stays inside one JSON string.
|
|
60
|
+
json_escape() {
|
|
61
|
+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g; s/\r/\\r/g' | awk 'NR > 1 { printf "\\n" } { printf "%s", $0 }'
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
# Build details object from key=value args. Keep it shell-portable: no jq required.
|
|
59
65
|
DETAILS="{"
|
|
60
66
|
SEP=""
|
|
@@ -65,10 +71,10 @@ for kv in "$@"; do
|
|
|
65
71
|
# int or true/false becomes a JSON string.
|
|
66
72
|
case "$VAL" in
|
|
67
73
|
true|false) JSON_VAL="$VAL" ;;
|
|
68
|
-
''|*[!0-9]*) JSON_VAL="\"$(
|
|
74
|
+
''|*[!0-9]*) JSON_VAL="\"$(json_escape "$VAL")\"" ;;
|
|
69
75
|
*) JSON_VAL="$VAL" ;;
|
|
70
76
|
esac
|
|
71
|
-
DETAILS="${DETAILS}${SEP}\"$
|
|
77
|
+
DETAILS="${DETAILS}${SEP}\"$(json_escape "$KEY")\":${JSON_VAL}"
|
|
72
78
|
SEP=","
|
|
73
79
|
done
|
|
74
80
|
DETAILS="${DETAILS}}"
|
|
@@ -79,7 +85,7 @@ PHASE_SCHEMA="$(node -e 'const c=require("'"$(cd "$(dirname "$0")" && pwd)"'/../
|
|
|
79
85
|
[ -n "$PHASE_SCHEMA" ] || PHASE_SCHEMA=2
|
|
80
86
|
|
|
81
87
|
TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
82
|
-
LINE="{\"ts\":\"${TS}\",\"task_id\":\"$
|
|
88
|
+
LINE="{\"ts\":\"${TS}\",\"task_id\":\"$(json_escape "$TASK_ID")\",\"phase\":\"$(json_escape "$PHASE")\",\"event\":\"$(json_escape "$EVENT")\",\"details\":${DETAILS},\"phaseSchema\":${PHASE_SCHEMA}}"
|
|
83
89
|
|
|
84
90
|
# Caller-supplied values can carry a credential copied out of an error.
|
|
85
91
|
# redact.mjs replaces it with a marker; without node the line is written as is.
|
|
@@ -522,10 +522,23 @@ function migrateReportChannels(out, changes) {
|
|
|
522
522
|
}
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
// Writes through a symlinked prefs file to its target, so a prefs file kept in
|
|
526
|
+
// a dotfiles repo stays a link and the repo copy receives the migration.
|
|
525
527
|
function atomicWrite(filePath, data) {
|
|
526
|
-
withFileLockSync(
|
|
527
|
-
|
|
528
|
-
|
|
528
|
+
withFileLockSync(
|
|
529
|
+
filePath,
|
|
530
|
+
() => writeJsonAtomicSync(filePath, data, { mode: 0o600, followSymlink: true }),
|
|
531
|
+
{ timeoutMs: lockTimeoutFrom(process.env) },
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// The document as it was before this migration, beside the path given, so a
|
|
536
|
+
// migration that went wrong can be undone by hand.
|
|
537
|
+
function writeBackup(filePath, raw) {
|
|
538
|
+
const bak = `${filePath}.bak`;
|
|
539
|
+
fs.writeFileSync(bak, raw, { mode: 0o600 });
|
|
540
|
+
fs.chmodSync(bak, 0o600);
|
|
541
|
+
return bak;
|
|
529
542
|
}
|
|
530
543
|
|
|
531
544
|
function main() {
|
|
@@ -560,8 +573,9 @@ function main() {
|
|
|
560
573
|
process.exit(0);
|
|
561
574
|
}
|
|
562
575
|
|
|
576
|
+
const bak = writeBackup(args.file, raw);
|
|
563
577
|
atomicWrite(args.file, out);
|
|
564
|
-
console.log(`✓ wrote migrated prefs to ${args.file}`);
|
|
578
|
+
console.log(`✓ wrote migrated prefs to ${args.file} (previous version: ${bak})`);
|
|
565
579
|
}
|
|
566
580
|
|
|
567
581
|
runMain("migrate-prefs", main);
|