@dzhechkov/harness-core 0.8.10 → 0.8.20
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/.dz-manifest.json +490 -170
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/codex-invoke.d.ts +73 -0
- package/dist/codex-invoke.d.ts.map +1 -0
- package/dist/codex-invoke.js +80 -0
- package/dist/codex-invoke.js.map +1 -0
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +173 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/event-chain.d.ts +30 -0
- package/dist/event-chain.d.ts.map +1 -1
- package/dist/event-chain.js +24 -0
- package/dist/event-chain.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +98 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +308 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +32 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +32 -7
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/mutation-gate.d.ts +39 -36
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +111 -5
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +90 -5
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +27 -5
- package/dist/plugin.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/recommend.d.ts +4 -5
- package/dist/recommend.d.ts.map +1 -1
- package/dist/recommend.js +110 -45
- package/dist/recommend.js.map +1 -1
- package/dist/registry.d.ts +32 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +168 -11
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts +3 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +18 -0
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +95 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +289 -6
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skill-selection.d.ts +72 -0
- package/dist/skill-selection.d.ts.map +1 -0
- package/dist/skill-selection.js +76 -0
- package/dist/skill-selection.js.map +1 -0
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/stem.d.ts +12 -0
- package/dist/stem.d.ts.map +1 -0
- package/dist/stem.js +89 -0
- package/dist/stem.js.map +1 -0
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/telemetry-vocabulary.d.ts +7 -0
- package/dist/telemetry-vocabulary.d.ts.map +1 -1
- package/dist/telemetry-vocabulary.js +29 -0
- package/dist/telemetry-vocabulary.js.map +1 -1
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +10 -9
- package/sbom.json +1033 -233
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/codex-invoke.ts +138 -0
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +262 -19
- package/src/event-chain.ts +41 -0
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +358 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +121 -7
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/mutation-gate.ts +165 -5
- package/src/operations.ts +83 -5
- package/src/patterns.ts +33 -0
- package/src/plugin.ts +27 -5
- package/src/publish.ts +27 -2
- package/src/recommend.ts +116 -46
- package/src/registry.ts +147 -13
- package/src/run-records.ts +23 -0
- package/src/score.ts +377 -7
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skill-selection.ts +111 -0
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/stem.ts +87 -0
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- package/src/telemetry-vocabulary.ts +36 -0
- package/src/trace-bundle.ts +10 -0
package/src/setup.ts
CHANGED
|
@@ -17,10 +17,17 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
import { execSync } from 'node:child_process';
|
|
20
|
+
import { basename, dirname, join } from 'node:path';
|
|
21
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
22
22
|
|
|
23
23
|
import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
24
|
+
import {
|
|
25
|
+
CLAUDE_DESTRUCTIVE_HOOK_COMMAND,
|
|
26
|
+
CLAUDE_DESTRUCTIVE_HOOK_MATCHER,
|
|
27
|
+
CLAUDE_DESTRUCTIVE_HOOK_RELPATH,
|
|
28
|
+
generateClaudeDestructiveHook,
|
|
29
|
+
isDzManagedHookBody,
|
|
30
|
+
} from './claude-hooks-assets.js';
|
|
24
31
|
import { applyIntegrationFragments, IntegrationApplyError } from './integration-apply.js';
|
|
25
32
|
|
|
26
33
|
/** Memory backend type. */
|
|
@@ -251,6 +258,102 @@ export function commandsOf(entry: unknown): string[] {
|
|
|
251
258
|
return [String(e?.command ?? '')];
|
|
252
259
|
}
|
|
253
260
|
|
|
261
|
+
export interface GuardInstallReceipt {
|
|
262
|
+
/** True ONLY when the installed file was RUN and refused a command it must refuse. */
|
|
263
|
+
readonly ok: boolean;
|
|
264
|
+
/** What was observed — an exit code and the first line it printed, or why it could not run. */
|
|
265
|
+
readonly detail: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Prove the installed hook WORKS, by running it (feature `destructive-command-guard`, review
|
|
270
|
+
* round 4, P2).
|
|
271
|
+
*
|
|
272
|
+
* Presence is not proof and a successful write is not proof either: the body must load its decider
|
|
273
|
+
* (which lives in another package directory), and that resolution is exactly what broke in the
|
|
274
|
+
* global-install layout one round ago. So the receipt is POSITIVE and end-to-end — the file is
|
|
275
|
+
* spawned with a payload it is obliged to refuse, and only `exit 2` carrying our marker counts.
|
|
276
|
+
* Anything else — a crash, a silent pass, a missing file, a spawn that could not happen — is `ok:
|
|
277
|
+
* false` with the observation named, never an assumption about the cause.
|
|
278
|
+
*
|
|
279
|
+
* The registry entry is written only when this returns `ok`. A hook that is registered but cannot
|
|
280
|
+
* run is worse than no hook at all: the breakage lands on EVERY Bash call instead of on none.
|
|
281
|
+
*
|
|
282
|
+
* SAFETY PRECONDITION (round 11): this SPAWNS the file, so the caller must only ever call it on a
|
|
283
|
+
* body dz owns — one carrying the ownership marker, or one dz has just written itself. Calling it
|
|
284
|
+
* on a preserved foreign body turns `dz setup` into a runner for whatever a cloned repository
|
|
285
|
+
* committed at that path.
|
|
286
|
+
*/
|
|
287
|
+
export function probeInstalledGuard(
|
|
288
|
+
hookPath: string,
|
|
289
|
+
opts: { projectRoot?: string } = {},
|
|
290
|
+
): GuardInstallReceipt {
|
|
291
|
+
const payload = JSON.stringify({
|
|
292
|
+
hook_event_name: 'PreToolUse',
|
|
293
|
+
tool_name: 'Bash',
|
|
294
|
+
tool_input: { command: 'rm -rf .agentic-qe' },
|
|
295
|
+
});
|
|
296
|
+
let run;
|
|
297
|
+
try {
|
|
298
|
+
// DZ_GUARD_TRUSTED_ONLY makes the hook resolve its decision module from the INSTALLED
|
|
299
|
+
// harness-core alone. Without it the probe imports the project's own copy first, so a cloned
|
|
300
|
+
// repository that commits `packages/@dzhechkov/harness-core/dist/destructive-guard-hook.js`
|
|
301
|
+
// gets its top-level JavaScript executed by `dz setup` — the round-11 protection ("a foreign
|
|
302
|
+
// BODY is never spawned") one resolution step further in (cross-family review, gpt-5.6-sol,
|
|
303
|
+
// round 13). MEASURED before the fix: the planted module's marker reached stderr and its
|
|
304
|
+
// decider answered `allow`.
|
|
305
|
+
const env: NodeJS.ProcessEnv = { ...process.env, DZ_GUARD_TRUSTED_ONLY: '1' };
|
|
306
|
+
if (opts.projectRoot !== undefined) env.CLAUDE_PROJECT_DIR = opts.projectRoot;
|
|
307
|
+
run = spawnSync(process.execPath, [hookPath], {
|
|
308
|
+
input: payload,
|
|
309
|
+
encoding: 'utf-8',
|
|
310
|
+
timeout: 15_000,
|
|
311
|
+
env,
|
|
312
|
+
});
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return { ok: false, detail: `не удалось запустить хук: ${String((err as Error).message)}` };
|
|
315
|
+
}
|
|
316
|
+
if (run.error !== undefined) return { ok: false, detail: `не удалось запустить хук: ${run.error.message}` };
|
|
317
|
+
const firstLine = String(run.stderr ?? '').split('\n')[0] ?? '';
|
|
318
|
+
if (run.status !== 2 || !firstLine.includes('DZ-DESTRUCTIVE:')) {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
detail: `установленный хук НЕ отказал на контрольной команде (код выхода ${String(run.status)}; ${firstLine || 'пустой stderr'})`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, detail: 'живая проба: отказ на контрольной команде, код выхода 2' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The destructive-command guard's registry entry (feature `destructive-command-guard`, task T8).
|
|
329
|
+
*
|
|
330
|
+
* Emitted for EVERY backend, because the guard has nothing to do with where learning memory is
|
|
331
|
+
* stored. MEASURED 2026-09-05, before this existed: `dz setup --target claude-code` into a clean
|
|
332
|
+
* project wrote no `PreToolUse` key at all and created no `.claude/hooks/` — the guard we document
|
|
333
|
+
* protected only our own checkout.
|
|
334
|
+
*/
|
|
335
|
+
const CLAUDE_DESTRUCTIVE_HOOK_TIMEOUT_MS = 5000;
|
|
336
|
+
const LEGACY_CLAUDE_DESTRUCTIVE_HOOK_COMMANDS: ReadonlySet<string> = new Set([
|
|
337
|
+
`node "\${CLAUDE_PROJECT_DIR:-.}/${CLAUDE_DESTRUCTIVE_HOOK_RELPATH}"`,
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
/** Attribute only commands dz actually emitted, never arbitrary text that mentions the path. */
|
|
341
|
+
function isManagedClaudeDestructiveHookCommand(command: string): boolean {
|
|
342
|
+
return command === CLAUDE_DESTRUCTIVE_HOOK_COMMAND
|
|
343
|
+
|| LEGACY_CLAUDE_DESTRUCTIVE_HOOK_COMMANDS.has(command);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function destructiveGuardHookEntry(): { matcher: string; hooks: { type: string; command: string; timeout: number }[] } {
|
|
347
|
+
return {
|
|
348
|
+
matcher: CLAUDE_DESTRUCTIVE_HOOK_MATCHER,
|
|
349
|
+
hooks: [{
|
|
350
|
+
type: 'command',
|
|
351
|
+
command: CLAUDE_DESTRUCTIVE_HOOK_COMMAND,
|
|
352
|
+
timeout: CLAUDE_DESTRUCTIVE_HOOK_TIMEOUT_MS,
|
|
353
|
+
}],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
254
357
|
export function generateHooksConfig(projectRoot: string, backend: MemoryBackend): string {
|
|
255
358
|
const dzDir = join(projectRoot, '.dz');
|
|
256
359
|
|
|
@@ -272,6 +375,7 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
|
|
|
272
375
|
SessionStart: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} start` }] }],
|
|
273
376
|
SessionEnd: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} end` }] }],
|
|
274
377
|
PreCompact: [{ hooks: [{ type: 'command', command: `node ${JSON.stringify(writer)} precompact`, runInBackground: true }] }],
|
|
378
|
+
PreToolUse: [destructiveGuardHookEntry()],
|
|
275
379
|
},
|
|
276
380
|
}, null, 2);
|
|
277
381
|
}
|
|
@@ -289,6 +393,7 @@ export function generateHooksConfig(projectRoot: string, backend: MemoryBackend)
|
|
|
289
393
|
SessionStart: [{ hooks: [{ type: 'command', command: jsonlCmd('start') }] }],
|
|
290
394
|
SessionEnd: [{ hooks: [{ type: 'command', command: jsonlCmd('end') }] }],
|
|
291
395
|
PreCompact: [{ hooks: [{ type: 'command', command: jsonlCmd('precompact'), runInBackground: true }] }],
|
|
396
|
+
PreToolUse: [destructiveGuardHookEntry()],
|
|
292
397
|
},
|
|
293
398
|
}, null, 2);
|
|
294
399
|
}
|
|
@@ -614,10 +719,125 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
614
719
|
if (!opts.noHooks) {
|
|
615
720
|
const settingsDir = join(opts.projectRoot, '.claude');
|
|
616
721
|
const settingsPath = join(settingsDir, 'settings.json');
|
|
722
|
+
|
|
723
|
+
// The BODY goes in first, and the ENTRY goes in only after a LIVE receipt that the body runs
|
|
724
|
+
// and refuses. Written from the INSTALLED package, never copied out of our repository — a
|
|
725
|
+
// consumer has no `packages/@dzhechkov/...` above their project.
|
|
726
|
+
//
|
|
727
|
+
// Round 4, P2: these two used to be independent. A failed write was recorded as an error and
|
|
728
|
+
// the entry was merged anyway, so a consumer whose install failed got a `PreToolUse` entry
|
|
729
|
+
// pointing at something that is not a runnable hook — and that breaks EVERY Bash call, not one.
|
|
730
|
+
//
|
|
731
|
+
// Round 5, P1: the write was also UNCONDITIONAL. Setup is additive everywhere else — the
|
|
732
|
+
// settings merge keeps the user's own hooks, `.gitignore` is appended to, an existing skill is
|
|
733
|
+
// skipped — and this path overwrote a well-known filename with no ownership check, so a
|
|
734
|
+
// consumer's hand-authored `.claude/hooks/destructive-guard.cjs` was destroyed by a routine
|
|
735
|
+
// run. A body we wrote carries a MARKER; a file without it is the consumer's, and only an
|
|
736
|
+
// explicit `--force` may replace it, after a timestamped backup.
|
|
737
|
+
const hookPath = join(opts.projectRoot, ...CLAUDE_DESTRUCTIVE_HOOK_RELPATH.split('/'));
|
|
738
|
+
let installError: string | null = null;
|
|
739
|
+
let preserved: string | null = null;
|
|
740
|
+
let backupPath: string | null = null;
|
|
741
|
+
|
|
742
|
+
// Unreadable (absent, or something that is not a file at all) is NOT a claim of ownership: it
|
|
743
|
+
// falls through to the write, whose failure the round-4 receipt below already reports.
|
|
744
|
+
let current: string | null = null;
|
|
745
|
+
try {
|
|
746
|
+
current = readFileSync(hookPath, 'utf-8');
|
|
747
|
+
} catch {
|
|
748
|
+
current = null;
|
|
749
|
+
}
|
|
750
|
+
const foreign = current !== null && !isDzManagedHookBody(current);
|
|
751
|
+
|
|
752
|
+
if (foreign && opts.force !== true) {
|
|
753
|
+
preserved =
|
|
754
|
+
'файл на этом пути не наш (нет маркера dz) — ОСТАВЛЕН нетронутым и НЕ ЗАПУСКАЛСЯ; запись в settings.json на этот путь тоже не трогаем (ни своей не добавляем, ни вашу не снимаем); заменить: dz setup --force';
|
|
755
|
+
} else {
|
|
756
|
+
try {
|
|
757
|
+
if (foreign && current !== null) {
|
|
758
|
+
// Same shape as the codex `hooks.json` backup: the original beside the original, stamped,
|
|
759
|
+
// so `--force` is recoverable rather than merely loud.
|
|
760
|
+
backupPath = `${hookPath}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
761
|
+
writeFileSync(backupPath, current);
|
|
762
|
+
}
|
|
763
|
+
mkdirSync(dirname(hookPath), { recursive: true });
|
|
764
|
+
writeFileSync(hookPath, generateClaudeDestructiveHook(), { mode: 0o755 });
|
|
765
|
+
} catch (err) {
|
|
766
|
+
installError = String((err as Error).message);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const foreignBodyKept = preserved !== null;
|
|
770
|
+
|
|
771
|
+
// The receipt SPAWNS the file, so it may only ever be taken on a body dz owns.
|
|
772
|
+
//
|
|
773
|
+
// Round 11, P1 SECURITY — correcting my own round-5 sentence, "the receipt is taken from the
|
|
774
|
+
// file that IS there". Combined with round 6, which preserves a body dz does not own, that made
|
|
775
|
+
// `dz setup` EXECUTE whatever a repository had committed at this path: clone a hostile repo,
|
|
776
|
+
// run the documented setup command, and its `.claude/hooks/destructive-guard.cjs` ran — with
|
|
777
|
+
// none of the host's hook-trust prompting in between. MEASURED: a foreign body writing a marker
|
|
778
|
+
// file had written it by the time setup returned.
|
|
779
|
+
//
|
|
780
|
+
// So a preserved foreign body is NOT probed, NOT registered, and NOT run. `--force` is consent
|
|
781
|
+
// to REPLACE it (our body is written above, before this line) — never consent to execute it.
|
|
782
|
+
// The receipt is still taken whether or not the write threw, because a failed write over an
|
|
783
|
+
// OLDER BODY OF OURS leaves something we may legitimately run.
|
|
784
|
+
const receipt: GuardInstallReceipt = foreignBodyKept
|
|
785
|
+
? { ok: false, detail: 'проба не проводилась — запускать чужой файл не наше право' }
|
|
786
|
+
: probeInstalledGuard(hookPath);
|
|
787
|
+
// Round 8, P2: ownership of the ENTRY follows ownership of the BODY, never the filename.
|
|
788
|
+
//
|
|
789
|
+
// Round 6 preserved a consumer's hook file; attribution of its registry entry stayed path-only,
|
|
790
|
+
// so a routine run deleted the registration of the very file it had just decided not to touch —
|
|
791
|
+
// their hook left on disk and switched off (MEASURED: their `PreToolUse` entry came back `[]`).
|
|
792
|
+
// The reverse was just as wrong: a foreign body that happened to refuse made dz ADD an entry
|
|
793
|
+
// for somebody else's file (MEASURED), taking responsibility for code it may neither read as
|
|
794
|
+
// its own nor replace.
|
|
795
|
+
//
|
|
796
|
+
// So when a foreign body is kept, dz stands down from the whole event: it adds nothing, and
|
|
797
|
+
// `isManaged` below stops claiming an entry that points at that path. Whether the foreign hook
|
|
798
|
+
// refuses is not merely the consumer's business — it is a question dz no longer ASKS, because
|
|
799
|
+
// asking meant running their file (round 11). All of it is said in one line rather than left
|
|
800
|
+
// for them to find by diffing settings.json.
|
|
801
|
+
const guardArmed = receipt.ok && !foreignBodyKept;
|
|
802
|
+
// The receipt is taken from the file that IS at the path — ours, or the one we preserved. A
|
|
803
|
+
// foreign hook that demonstrably refuses is registered on its own merits; a foreign hook that
|
|
804
|
+
// does not refuse gets no entry, exactly like a failed install (round 4).
|
|
805
|
+
const notes = [
|
|
806
|
+
preserved === null ? '' : `${preserved}; `,
|
|
807
|
+
backupPath === null ? '' : `прежний файл сохранён: ${basename(backupPath)}; `,
|
|
808
|
+
installError === null ? '' : `${installError}; `,
|
|
809
|
+
].join('');
|
|
810
|
+
steps.push(
|
|
811
|
+
guardArmed
|
|
812
|
+
? { name: 'Install destructive guard', status: 'done', detail: `${notes}${CLAUDE_DESTRUCTIVE_HOOK_RELPATH} — ${receipt.detail}` }
|
|
813
|
+
: {
|
|
814
|
+
name: 'Install destructive guard',
|
|
815
|
+
status: preserved === null ? 'error' : 'skipped',
|
|
816
|
+
detail: `${notes}${receipt.detail} — запись в settings.json НЕ добавлена`,
|
|
817
|
+
},
|
|
818
|
+
);
|
|
617
819
|
const generated = JSON.parse(generateHooksConfig(opts.projectRoot, backend)) as {
|
|
618
820
|
hooks: Record<string, { hooks: { type: string; command: string; runInBackground?: boolean }[] }[]>;
|
|
619
821
|
};
|
|
620
822
|
|
|
823
|
+
// No working body ⇒ no entry, and the EVENT KEY STAYS — as an empty managed list when nothing
|
|
824
|
+
// else of ours belongs there.
|
|
825
|
+
//
|
|
826
|
+
// CORRECTION OF RECORD (round 7, P1). The round-5 version DELETED the key and this comment
|
|
827
|
+
// claimed the merge would then also drop a guard entry left by an earlier setup. That was
|
|
828
|
+
// asserted without measuring and it is false: `mergeManagedHookEntries` iterates
|
|
829
|
+
// `Object.keys(managed)`, so an event absent from the managed input is copied through
|
|
830
|
+
// UNTOUCHED — a project whose guard used to be armed kept invoking it on every Bash call while
|
|
831
|
+
// the report said the entry was not added. Handing the event an EMPTY list is what makes the
|
|
832
|
+
// merge EXAMINE it: our entries are dropped by `isManaged`, the user's are preserved in order,
|
|
833
|
+
// and nothing is appended. The round-5 test passed for the wrong reason — its project had no
|
|
834
|
+
// pre-existing settings.json, so there was no stale entry for the claim to be wrong about.
|
|
835
|
+
if (!guardArmed) {
|
|
836
|
+
generated.hooks['PreToolUse'] = (generated.hooks['PreToolUse'] ?? []).filter(
|
|
837
|
+
(entry) => !entry.hooks.some((h) => isManagedClaudeDestructiveHookCommand(h.command)),
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
621
841
|
if (!existsSync(settingsPath)) {
|
|
622
842
|
mkdirSync(settingsDir, { recursive: true });
|
|
623
843
|
writeFileSync(settingsPath, JSON.stringify({ hooks: generated.hooks }, null, 2));
|
|
@@ -625,9 +845,18 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
625
845
|
} else {
|
|
626
846
|
try {
|
|
627
847
|
const existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
|
|
628
|
-
// ONE merge implementation, shared with the Codex target (AM-3 / G-E).
|
|
629
|
-
//
|
|
630
|
-
//
|
|
848
|
+
// ONE merge implementation, shared with the Codex target (AM-3 / G-E). Claude's exact
|
|
849
|
+
// command attribution is passed in rather than reimplemented, so emitted bytes, report
|
|
850
|
+
// tail text, and the no-write path stay on the shared merge contract (AM-37).
|
|
851
|
+
const isManagedCommand = (cmd: string): boolean =>
|
|
852
|
+
cmd.includes('agentdb add') ||
|
|
853
|
+
cmd.includes('agentdb-writer.mjs') ||
|
|
854
|
+
cmd.includes('sessions.jsonl') ||
|
|
855
|
+
// Ours ONLY while the body at that path is ours (round 8, P2). Without the
|
|
856
|
+
// path clause a second `dz setup` would append a duplicate guard entry instead of
|
|
857
|
+
// replacing the first; without the ownership clause it would delete the entry a
|
|
858
|
+
// consumer wrote for their own preserved hook.
|
|
859
|
+
(!foreignBodyKept && isManagedClaudeDestructiveHookCommand(cmd));
|
|
631
860
|
const plan = mergeManagedHookEntries(
|
|
632
861
|
(existing['hooks'] ?? {}) as Record<string, unknown[]>,
|
|
633
862
|
generated.hooks as unknown as Record<string, unknown[]>,
|
|
@@ -635,13 +864,21 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
635
864
|
// Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
|
|
636
865
|
// untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
|
|
637
866
|
// matcher-group shape appended below.
|
|
638
|
-
isManaged: (entry) =>
|
|
639
|
-
commandsOf(entry).some(
|
|
640
|
-
(cmd) => cmd.includes('agentdb add') || cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl'),
|
|
641
|
-
),
|
|
867
|
+
isManaged: (entry) => commandsOf(entry).some(isManagedCommand),
|
|
642
868
|
isLegacy: (entry) =>
|
|
643
869
|
!Array.isArray((entry as { hooks?: unknown[] })?.hooks) ||
|
|
644
870
|
commandsOf(entry).some((cmd) => cmd.includes('agentdb add')),
|
|
871
|
+
// Ownership is per HANDLER, not per matcher group. A user's handler may deliberately
|
|
872
|
+
// share the Bash group with dz's guard; replacing ours must retain their handler object
|
|
873
|
+
// and every surrounding group field byte-for-byte through JSON serialization.
|
|
874
|
+
retainForeign: (entry) => {
|
|
875
|
+
const grouped = entry as { hooks?: { command?: unknown }[] };
|
|
876
|
+
if (!Array.isArray(grouped?.hooks)) return null;
|
|
877
|
+
const kept = grouped.hooks.filter((hook) => !isManagedCommand(String(hook?.command ?? '')));
|
|
878
|
+
return kept.length === 0
|
|
879
|
+
? null
|
|
880
|
+
: { ...(entry as Record<string, unknown>), hooks: kept };
|
|
881
|
+
},
|
|
645
882
|
reportLabel: backend,
|
|
646
883
|
},
|
|
647
884
|
);
|
package/src/sign.ts
CHANGED
|
@@ -504,7 +504,24 @@ export function verifyManifest(
|
|
|
504
504
|
continue;
|
|
505
505
|
}
|
|
506
506
|
if (digest !== entry.sha256) {
|
|
507
|
-
|
|
507
|
+
// ПРИЧИНА НАЗЫВАЕТСЯ ТОЧНО, А НЕ ОБОБЩЁННО. Для файла, который переписывает упаковщик,
|
|
508
|
+
// расхождение чаще означает НЕ подделку, а сверку двух РАЗНЫХ объектов: манифест снят с
|
|
509
|
+
// байтов упакованного пакета, а здесь читается исходный каталог. ИЗМЕРЕНО 2026-09-03: так
|
|
510
|
+
// расходятся ВСЕ 56 подписанных пакетов, ни один не проверяется — на свежеподписанном
|
|
511
|
+
// манифест хранит cd7bd97a…, а хэш байтов с диска при любой версии канонизации даёт
|
|
512
|
+
// 40d926ff…, потому что pnpm вырезает из package.json блок scripts.
|
|
513
|
+
//
|
|
514
|
+
// Вердикт НЕ меняется: расхождение остаётся расхождением и выход остаётся ненулевым.
|
|
515
|
+
// Меняется только прочтение — «подделали» против «сверяете не то». Полное лечение (два
|
|
516
|
+
// режима проверки: исходный каталог упаковывать, установленный сверять напрямую) —
|
|
517
|
+
// запись бэклога 58dc4d58, решение владельца получено, работа не сделана.
|
|
518
|
+
const packerRewritten = CANONICALISED_PACK_FILES.has(entry.path);
|
|
519
|
+
failures.push({
|
|
520
|
+
path: entry.path,
|
|
521
|
+
reason: packerRewritten
|
|
522
|
+
? 'content does not match its signed hash — NOTE: this file is rewritten by the packer, so a mismatch here may mean the manifest was built from PACKED bytes while a SOURCE directory is being verified, not tampering (backlog 58dc4d58)'
|
|
523
|
+
: 'content does not match its signed hash',
|
|
524
|
+
});
|
|
508
525
|
}
|
|
509
526
|
}
|
|
510
527
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--select` resolution: one provider per requested skill, decided ONCE (backlog 9d15b9b6, PR-A).
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS REPLACES. `installSkills` looped the installer once PER ROOT, handing each root
|
|
5
|
+
* the same select list. Asking each root independently produces two failures that look like
|
|
6
|
+
* opposites but share one cause — the REQUEST was never resolved, only re-asked:
|
|
7
|
+
*
|
|
8
|
+
* - a skill present in two roots was installed TWICE and counted twice. The field report's
|
|
9
|
+
* observed `2 skill(s)` was ONE skill installed twice, read by everyone as an orchestrator plus
|
|
10
|
+
* its stage skill (features/digitize-book-codex-init-research/00_research/00-root-cause.md);
|
|
11
|
+
* - a skill present in NO root produced a warning and exit 0. `0 skill(s)` read as success, which
|
|
12
|
+
* is the same class as a health check reporting clean because it could not look.
|
|
13
|
+
*
|
|
14
|
+
* WHY A PURE MODULE. The decision must happen BEFORE anything is written — an exit 1 that arrives
|
|
15
|
+
* after hooks and memory are installed leaves a half-configured project, which is worse than either
|
|
16
|
+
* outcome. A pure resolver can be called at the top of the seam and interrogated without side
|
|
17
|
+
* effects; a resolver tangled with the filesystem cannot.
|
|
18
|
+
*
|
|
19
|
+
* DELIBERATELY NOT HERE: dependency closure (an orchestrator pulling its stage skills). That is
|
|
20
|
+
* PR-B. Mixing the two would put a correctness fix and a semantic change in one unreviewable
|
|
21
|
+
* change, and the exit-contract fix is what unblocks the rest.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One skill root and the ids it offers, in the order discovery returned them. */
|
|
25
|
+
export interface SkillRoot {
|
|
26
|
+
readonly dir: string;
|
|
27
|
+
readonly ids: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The single root chosen to provide one requested id. */
|
|
31
|
+
export interface ChosenProvider {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly dir: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A requested id offered by more than one root. NOT an error: precedence resolves it. But it is
|
|
38
|
+
* NAMED, because an operator who sees one install where two roots offer the skill must be able to
|
|
39
|
+
* learn which root lost — otherwise the precedence is folklore, and the next person re-derives it.
|
|
40
|
+
*/
|
|
41
|
+
export interface ShadowedProvider {
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly chosen: string;
|
|
44
|
+
readonly alsoIn: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SelectResolution {
|
|
48
|
+
/** One entry per DISTINCT requested id that resolved, in request order. */
|
|
49
|
+
readonly chosen: readonly ChosenProvider[];
|
|
50
|
+
/** Requested ids no root offers. Non-empty means the caller must refuse and write nothing. */
|
|
51
|
+
readonly missing: readonly string[];
|
|
52
|
+
/** Requested ids offered by several roots, with the losers named. */
|
|
53
|
+
readonly shadowed: readonly ShadowedProvider[];
|
|
54
|
+
/** True only when EVERY requested id resolved. Shadowing does not make it false. */
|
|
55
|
+
readonly ok: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* PRECEDENCE IS ROOT ORDER, and that is a promise, not an implementation detail: the first root in
|
|
60
|
+
* `roots` that offers an id provides it. Callers pass roots in discovery order, so the rule the
|
|
61
|
+
* operator can state ("the earlier root wins") is the rule that runs. The previous behaviour
|
|
62
|
+
* depended on `readdir` order inside each root and on the loop visiting roots — an order nobody
|
|
63
|
+
* documented and nobody could rely on.
|
|
64
|
+
*
|
|
65
|
+
* Asking for the same id twice is asking once: a request is a SET of skills, and duplicating a name
|
|
66
|
+
* in the argument does not duplicate an installation.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveSelection(
|
|
69
|
+
select: readonly string[],
|
|
70
|
+
roots: readonly SkillRoot[],
|
|
71
|
+
): SelectResolution {
|
|
72
|
+
const chosen: ChosenProvider[] = [];
|
|
73
|
+
const missing: string[] = [];
|
|
74
|
+
const shadowed: ShadowedProvider[] = [];
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
|
|
77
|
+
for (const id of select) {
|
|
78
|
+
if (seen.has(id)) continue;
|
|
79
|
+
seen.add(id);
|
|
80
|
+
const providers = roots.filter((root) => root.ids.includes(id)).map((root) => root.dir);
|
|
81
|
+
const first = providers[0];
|
|
82
|
+
if (first === undefined) {
|
|
83
|
+
missing.push(id);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
chosen.push({ id, dir: first });
|
|
87
|
+
// EVERY losing root is listed, not just the second: with three providers, naming one of the two
|
|
88
|
+
// losers would send the reader to fix the wrong copy.
|
|
89
|
+
if (providers.length > 1) shadowed.push({ id, chosen: first, alsoIn: providers.slice(1) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { chosen, missing, shadowed, ok: missing.length === 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The refusal an explicit `--select` owes its caller. Returns null when there is nothing to refuse.
|
|
97
|
+
*
|
|
98
|
+
* Shaped as text rather than an exception so the seam can print it and return a code without a
|
|
99
|
+
* try/catch that a later edit might swallow — the failure mode this whole change exists to remove
|
|
100
|
+
* is a problem that produced a warning and kept going.
|
|
101
|
+
*/
|
|
102
|
+
export function formatSelectRefusal(resolution: SelectResolution, roots: readonly SkillRoot[]): string | null {
|
|
103
|
+
if (resolution.ok) return null;
|
|
104
|
+
const lines = [
|
|
105
|
+
`dz: --select refused — ${resolution.missing.length} requested skill(s) exist in none of the ${roots.length} skill root(s):`,
|
|
106
|
+
];
|
|
107
|
+
for (const id of resolution.missing) lines.push(` missing: ${id}`);
|
|
108
|
+
for (const root of roots) lines.push(` searched: ${root.dir} (${root.ids.length} skill(s))`);
|
|
109
|
+
lines.push('Nothing was written. Fix the id, or install the pack that provides it, then re-run.');
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
}
|
package/src/skills-verify.ts
CHANGED
|
@@ -23,7 +23,9 @@ import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
|
23
23
|
export type SkillIssueKind =
|
|
24
24
|
| 'no-skill-md' // a skill dir with no SKILL.md at depth 1 → never registers
|
|
25
25
|
| 'buried-skill-md' // a SKILL.md at depth >= 2 → the loader does not scan that deep
|
|
26
|
-
| 'plugin-manifest-trap'
|
|
26
|
+
| 'plugin-manifest-trap' // .claude-plugin/plugin.json under .claude/skills → does NOT auto-register
|
|
27
|
+
| 'wildcard-allowed-tools' // `allowed-tools: *` → every tool granted; a FINDING (see below)
|
|
28
|
+
| 'empty-allowed-tools'; // `allowed-tools:` with no value → looks restrictive, restricts nothing
|
|
27
29
|
|
|
28
30
|
export interface SkillLayoutFinding {
|
|
29
31
|
readonly dir: string;
|
|
@@ -170,6 +172,98 @@ export function looksLikeSkillDir(dir: string): boolean {
|
|
|
170
172
|
return walk(dir, 1);
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
/**
|
|
176
|
+
* What a SKILL.md's front matter says about the tools it may use.
|
|
177
|
+
*
|
|
178
|
+
* Three states, and they are NOT the same thing:
|
|
179
|
+
* `absent` — no `allowed-tools` key. The skill honestly inherits; this is the ordinary case
|
|
180
|
+
* (7 170 SKILL.md files in this tree, MEASURED 2026-09-03).
|
|
181
|
+
* `empty` — the key is present with no value. It LOOKS like a restriction and restricts
|
|
182
|
+
* nothing. Reported, never failed: 70 files are in this state today, and a gate
|
|
183
|
+
* that goes red on the day it is introduced gets switched off — taking the
|
|
184
|
+
* wildcard check down with it.
|
|
185
|
+
* `listed` — actual values, which may include a wildcard.
|
|
186
|
+
*/
|
|
187
|
+
export type AllowedToolsState =
|
|
188
|
+
| { readonly state: 'absent' }
|
|
189
|
+
| { readonly state: 'empty' }
|
|
190
|
+
| { readonly state: 'listed'; readonly values: readonly string[]; readonly wildcard: boolean };
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read `allowed-tools` from the FRONT MATTER only.
|
|
194
|
+
*
|
|
195
|
+
* Scanning the whole file would turn `allowed-tools: *` written inside a skill's own documentation
|
|
196
|
+
* into a violation — and a gate that INVENTS violations is worse than no gate: it teaches people to
|
|
197
|
+
* ignore it. So the parse stops at the closing `---`.
|
|
198
|
+
*
|
|
199
|
+
* Both YAML spellings are accepted, because both appear in this tree: an inline list
|
|
200
|
+
* (`allowed-tools: Read, Write`) and a flow sequence (`allowed-tools: [Read, Write]`). Quotes are
|
|
201
|
+
* stripped before the wildcard test — `allowed-tools: "*"` grants exactly as much as a bare one.
|
|
202
|
+
*/
|
|
203
|
+
export function parseAllowedTools(markdown: string): AllowedToolsState {
|
|
204
|
+
const lines = markdown.split(/\r?\n/);
|
|
205
|
+
if (lines[0]?.trim() !== '---') return { state: 'absent' };
|
|
206
|
+
let raw: string | null = null;
|
|
207
|
+
for (let i = 1; i < lines.length; i++) {
|
|
208
|
+
const line = lines[i] ?? '';
|
|
209
|
+
if (line.trim() === '---') break; // конец заголовка
|
|
210
|
+
const m = /^allowed-tools:(.*)$/.exec(line);
|
|
211
|
+
if (m) { raw = m[1] ?? ''; break; }
|
|
212
|
+
}
|
|
213
|
+
if (raw === null) return { state: 'absent' };
|
|
214
|
+
|
|
215
|
+
// ЯВНО ПУСТОЙ МАССИВ — НЕ ТО ЖЕ, ЧТО ПУСТОЕ ПОЛЕ. `allowed-tools: []` — это утверждение
|
|
216
|
+
// («никаких инструментов»), а `allowed-tools:` — незаполненное поле, которое ВЫГЛЯДИТ
|
|
217
|
+
// ограничением и ничего не ограничивает. Свалить их в одно состояние значило бы сказать про
|
|
218
|
+
// первый случай неправду в тексте предупреждения.
|
|
219
|
+
const trimmed = raw.trim();
|
|
220
|
+
if (trimmed === '') return { state: 'empty' };
|
|
221
|
+
const bracketed = /^\[(.*)\]$/s.exec(trimmed);
|
|
222
|
+
const body = (bracketed ? bracketed[1] ?? '' : trimmed).trim();
|
|
223
|
+
if (body === '') return { state: 'listed', values: [], wildcard: false };
|
|
224
|
+
|
|
225
|
+
const values = body.split(',')
|
|
226
|
+
.map((v) => v.trim().replace(/^["']|["']$/g, '').trim())
|
|
227
|
+
.filter((v) => v !== '');
|
|
228
|
+
return { state: 'listed', values, wildcard: values.includes('*') };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Privilege findings for one skill directory.
|
|
233
|
+
*
|
|
234
|
+
* WHY THIS FAILS RATHER THAN WARNS. The advisory carve-out in this module exists for layout classes
|
|
235
|
+
* whose support this gate CANNOT observe (workspace trust). A wildcard grant is not that: the text
|
|
236
|
+
* is right there in the file, the reading is unambiguous, and nothing about the environment can make
|
|
237
|
+
* it narrow. It is also a pure regression guard — MEASURED 2026-09-03: zero wildcards exist in this
|
|
238
|
+
* tree, so introducing it breaks no one.
|
|
239
|
+
*/
|
|
240
|
+
function scanSkillPrivileges(
|
|
241
|
+
dir: string, label: string, findings: SkillLayoutFinding[], advisories: SkillLayoutFinding[],
|
|
242
|
+
): void {
|
|
243
|
+
let text: string;
|
|
244
|
+
try {
|
|
245
|
+
text = readFileSync(join(dir, 'SKILL.md'), 'utf8');
|
|
246
|
+
} catch {
|
|
247
|
+
return; // нечитаемый файл — забота других проверок этого же слоя, не этой
|
|
248
|
+
}
|
|
249
|
+
const tools = parseAllowedTools(text);
|
|
250
|
+
if (tools.state === 'empty') {
|
|
251
|
+
advisories.push({
|
|
252
|
+
dir: label,
|
|
253
|
+
kind: 'empty-allowed-tools',
|
|
254
|
+
detail: 'allowed-tools is present but empty — it looks like a restriction and grants everything; either list the tools or drop the key',
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (tools.state === 'listed' && tools.wildcard) {
|
|
259
|
+
findings.push({
|
|
260
|
+
dir: label,
|
|
261
|
+
kind: 'wildcard-allowed-tools',
|
|
262
|
+
detail: `allowed-tools grants every tool via "*" (${tools.values.join(', ')}) — name the tools this skill actually needs`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
173
267
|
/**
|
|
174
268
|
* A directory registers only if `SKILL.md` is a regular FILE. `existsSync` also answers true for a
|
|
175
269
|
* DIRECTORY named SKILL.md, which registers nothing yet suppressed every other check (QE2 #4).
|
|
@@ -332,6 +426,10 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
|
|
|
332
426
|
const isPluginContainer = hasPluginManifest(dir);
|
|
333
427
|
const bucket = isPluginContainer ? advisories : findings;
|
|
334
428
|
|
|
429
|
+
// Права проверяются у КАЖДОГО навыка с читаемым SKILL.md, включая одно-навыковый плагин: щедрая
|
|
430
|
+
// выдача не становится безопаснее оттого, что навык лежит в контейнере.
|
|
431
|
+
if (registers) scanSkillPrivileges(dir, name, findings, advisories);
|
|
432
|
+
|
|
335
433
|
if (registers && !isPluginContainer) {
|
|
336
434
|
registrable.push(name);
|
|
337
435
|
} else if (registers && isPluginContainer) {
|