@deftai/directive-core 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/cache/scanner.d.ts +11 -1
  2. package/dist/cache/scanner.js +29 -4
  3. package/dist/check/gate-lists.js +1 -0
  4. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  5. package/dist/content-contracts/skills/helpers.js +35 -0
  6. package/dist/deposit/copy-tree.d.ts +19 -1
  7. package/dist/deposit/copy-tree.js +134 -5
  8. package/dist/doctor/main.js +48 -0
  9. package/dist/fs/projection-containment.d.ts +18 -0
  10. package/dist/fs/projection-containment.js +40 -0
  11. package/dist/hooks/dispatcher.d.ts +15 -3
  12. package/dist/hooks/dispatcher.js +149 -6
  13. package/dist/hooks/tools.d.ts +31 -0
  14. package/dist/hooks/tools.js +74 -0
  15. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  16. package/dist/init-deposit/agent-hooks.js +38 -2
  17. package/dist/init-deposit/hygiene.d.ts +16 -0
  18. package/dist/init-deposit/hygiene.js +26 -0
  19. package/dist/init-deposit/init-dispatch.js +28 -0
  20. package/dist/init-deposit/prettierignore.js +2 -2
  21. package/dist/init-deposit/refresh.js +38 -7
  22. package/dist/init-deposit/scaffold.js +7 -3
  23. package/dist/init-deposit/xbrief-projections.js +6 -6
  24. package/dist/intake/issue-ingest.js +11 -2
  25. package/dist/packs/pack-render.d.ts +33 -0
  26. package/dist/packs/pack-render.js +155 -9
  27. package/dist/packs/quarantine-ext.d.ts +10 -0
  28. package/dist/packs/quarantine-ext.js +26 -2
  29. package/dist/policy/index.d.ts +1 -0
  30. package/dist/policy/index.js +1 -0
  31. package/dist/policy/no-deft-directive.d.ts +59 -0
  32. package/dist/policy/no-deft-directive.js +103 -0
  33. package/dist/policy/org-force-on-migration.d.ts +52 -0
  34. package/dist/policy/org-force-on-migration.js +260 -22
  35. package/dist/policy/runtime-authority.d.ts +41 -0
  36. package/dist/policy/runtime-authority.js +274 -0
  37. package/dist/session/session-start-hook.d.ts +3 -0
  38. package/dist/session/session-start-hook.js +15 -0
  39. package/dist/session/session-start.js +30 -0
  40. package/dist/verify-source/cursor-tier1.js +7 -2
  41. package/dist/verify-source/openclaw-tier1.js +7 -2
  42. package/package.json +4 -3
@@ -1,15 +1,16 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import { hasArtifactSuffix } from "../layout/resolve.js";
4
- import { evaluateRuntimeAuthorityDirectWrite, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
4
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, } from "../policy/no-deft-directive.js";
5
+ import { classifyMcpTool, evaluateRuntimeAuthorityDirectWrite, evaluateRuntimeAuthorityShellOp, listShellOps, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
5
6
  import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
6
7
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
7
8
  import { inspectSessionRitual } from "../session/verify-session-ritual.js";
8
9
  import { isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
9
10
  import { inspectActiveScope } from "./scope.js";
10
- import { isDirectWriteTool, isSpawnTool } from "./tools.js";
11
+ import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
11
12
  export { hookReadOnlyFromPayload, isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
12
- export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isSpawnTool, READ_ONLY_HOOK_ENV, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
13
+ export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
13
14
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
14
15
  export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
15
16
  /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
@@ -21,7 +22,8 @@ function record(value) {
21
22
  ? value
22
23
  : null;
23
24
  }
24
- function firstString(...values) {
25
+ /** First non-empty trimmed string from candidates (array form — clear arity for static tools). */
26
+ function firstString(values) {
25
27
  for (const value of values) {
26
28
  if (typeof value === "string" && value.trim().length > 0)
27
29
  return value.trim();
@@ -138,7 +140,14 @@ export function hookWriteTargetPath(payload) {
138
140
  if (input === null)
139
141
  return null;
140
142
  const toolInput = toolInputRecord(input);
141
- return firstString(toolInput?.file_path, toolInput?.filePath, toolInput?.path, input.file_path, input.filePath, input.path);
143
+ return firstString([
144
+ toolInput?.file_path,
145
+ toolInput?.filePath,
146
+ toolInput?.path,
147
+ input.file_path,
148
+ input.filePath,
149
+ input.path,
150
+ ]);
142
151
  }
143
152
  /** POSIX-ish project-relative path for lifecycle matching. */
144
153
  export function toProjectRelativePosix(projectRoot, targetPath) {
@@ -305,6 +314,113 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
305
314
  return null;
306
315
  return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, verdict.reason ?? "Directive denied this direct write under runtime authority policy.", scopePath);
307
316
  }
317
+ /**
318
+ * Best-effort shell command string from host PreToolUse payloads (#2711).
319
+ * Hosts disagree on nesting (`tool_input.command` vs top-level `command`).
320
+ */
321
+ export function hookShellCommand(payload) {
322
+ const input = record(payload);
323
+ if (input === null)
324
+ return null;
325
+ const toolInput = toolInputRecord(input);
326
+ return firstString([
327
+ toolInput !== null ? toolInput.command : null,
328
+ toolInput !== null ? toolInput.cmd : null,
329
+ toolInput !== null ? toolInput.shell_command : null,
330
+ input.command,
331
+ input.cmd,
332
+ ]);
333
+ }
334
+ /** Serialize tool args for MCP classification when nested objects are present (#2711). */
335
+ function hookMcpArgsText(payload) {
336
+ const input = record(payload);
337
+ if (input === null)
338
+ return null;
339
+ const toolInput = toolInputRecord(input);
340
+ if (toolInput === null)
341
+ return null;
342
+ try {
343
+ return JSON.stringify(toolInput);
344
+ }
345
+ catch {
346
+ return null;
347
+ }
348
+ }
349
+ function loadRuntimeAuthorityPolicySafe(input, seams) {
350
+ const projectRoot = resolve(input.projectRoot);
351
+ try {
352
+ return (seams.loadRuntimeAuthority ?? loadRuntimeAuthorityFromProject)(projectRoot);
353
+ }
354
+ catch {
355
+ return null;
356
+ }
357
+ }
358
+ /**
359
+ * Evaluate scopes.push / scopes.merge for Shell/Bash or classifiable MCP tools (#2711).
360
+ * Returns a full HookDecision (allow or deny). Unclassifiable ops fail open.
361
+ */
362
+ function decideShellOrMcpRuntimeAuthority(input, toolName, seams) {
363
+ const projectRoot = resolve(input.projectRoot);
364
+ const policy = loadRuntimeAuthorityPolicySafe(input, seams);
365
+ if (policy === null) {
366
+ return {
367
+ verdict: "allow",
368
+ code: "shell-op-unclassifiable",
369
+ event: input.event,
370
+ host: input.host,
371
+ toolName,
372
+ projectRoot,
373
+ message: `Directive allowed ${toolName}: runtimeAuthority policy load failed (fail open).`,
374
+ scopePath: null,
375
+ };
376
+ }
377
+ // Shell: evaluate every classifiable op in compound/multi-line commands (#2711 Greptile).
378
+ // MCP / bare push-merge names: single tool name maps to at most one op
379
+ // (includes bare `merge_pull_request` etc. that isMcpTool does not flag).
380
+ const ops = [];
381
+ if (isShellTool(toolName)) {
382
+ const command = hookShellCommand(input.payload);
383
+ if (command !== null)
384
+ ops.push(...listShellOps(command));
385
+ }
386
+ else {
387
+ const mcpOp = classifyMcpTool(toolName, hookMcpArgsText(input.payload));
388
+ if (mcpOp !== null)
389
+ ops.push(mcpOp);
390
+ }
391
+ if (ops.length === 0) {
392
+ return {
393
+ verdict: "allow",
394
+ code: "shell-op-unclassifiable",
395
+ event: input.event,
396
+ host: input.host,
397
+ toolName,
398
+ projectRoot,
399
+ message: `${toolName} is classifiable as Shell/MCP but the command/tool was not recognized as ` +
400
+ "push or merge — fail open (host gap; see runtime-authority.md).",
401
+ scopePath: null,
402
+ };
403
+ }
404
+ // Deny if any classifiable op is out of scope (compound commands must not short-circuit).
405
+ for (const op of ops) {
406
+ const verdict = evaluateRuntimeAuthorityShellOp({ policy, op });
407
+ if (!verdict.allowed) {
408
+ return deny(input, verdict.code ?? "runtime-policy-deny-scope", toolName, verdict.reason ??
409
+ "Directive denied this shell/MCP operation under runtime authority policy.");
410
+ }
411
+ }
412
+ const opsLabel = ops.join("+");
413
+ return {
414
+ verdict: "allow",
415
+ code: "shell-op-ready",
416
+ event: input.event,
417
+ host: input.host,
418
+ toolName,
419
+ projectRoot,
420
+ message: `Directive runtimeAuthority allowed classifiable ${opsLabel} via ${toolName}.`,
421
+ scopePath: null,
422
+ };
423
+ }
308
424
  function inspectMutationGates(input, toolName, seams, options) {
309
425
  const projectRoot = resolve(input.projectRoot);
310
426
  let ritual;
@@ -431,6 +547,24 @@ export function decideHook(input, seams = {}) {
431
547
  }
432
548
  }
433
549
  if (input.event === "session.start") {
550
+ // #2926: root `.no-deft-directive` wins — skip host SessionStart bookkeeping.
551
+ const detectOptOut = seams.detectNoDeftDirective ?? detectNoDeftDirective;
552
+ const optOut = detectOptOut(projectRoot);
553
+ if (optOut.present) {
554
+ const message = optOut.inconsistent
555
+ ? `${NO_DEFT_DIRECTIVE_DISABLED_MESSAGE}. ${NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE}`
556
+ : NO_DEFT_DIRECTIVE_DISABLED_MESSAGE;
557
+ return {
558
+ verdict: "allow",
559
+ code: "session-start-disabled",
560
+ event: input.event,
561
+ host: input.host,
562
+ toolName: null,
563
+ projectRoot,
564
+ message,
565
+ scopePath: null,
566
+ };
567
+ }
434
568
  try {
435
569
  const result = (seams.sessionStart ?? runSessionStartHookWrite)(projectRoot);
436
570
  if (result.code !== 0) {
@@ -510,6 +644,15 @@ export function decideHook(input, seams = {}) {
510
644
  }
511
645
  return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
512
646
  }
647
+ // Shell/Bash and classifiable MCP: enforce scopes.push / scopes.merge (#2711).
648
+ // Route bare push/merge MCP names (merge_pull_request, git_push, …) even when
649
+ // isMcpTool is false — classifyMcpTool is the gate (dispatcher-side, no tools↔policy cycle).
650
+ // Does not require active-scope ritual — only runtimeAuthority when enabled.
651
+ if (isShellTool(toolName) ||
652
+ isMcpTool(toolName) ||
653
+ classifyMcpTool(toolName, hookMcpArgsText(input.payload)) !== null) {
654
+ return decideShellOrMcpRuntimeAuthority(input, toolName, seams);
655
+ }
513
656
  if (!isDirectWriteTool(toolName)) {
514
657
  return {
515
658
  verdict: "allow",
@@ -518,7 +661,7 @@ export function decideHook(input, seams = {}) {
518
661
  host: input.host,
519
662
  toolName,
520
663
  projectRoot,
521
- message: `${toolName} is outside the P0 direct-write/spawn enforcement slice.`,
664
+ message: `${toolName} is outside the P0 direct-write/spawn/shell enforcement slice.`,
522
665
  scopePath: null,
523
666
  };
524
667
  }
@@ -2,10 +2,41 @@
2
2
  export declare const DIRECT_WRITE_TOOL_NAMES: readonly ["Edit", "Write", "WriteFile", "CreateFile", "MultiEdit", "NotebookEdit", "StrReplace", "SearchReplace", "Delete", "DeleteFile", "ApplyPatch", "apply_patch"];
3
3
  /** PreToolUse spawn / sub-agent dispatch tools (#1185 / #2437). */
4
4
  export declare const SPAWN_TOOL_NAMES: readonly ["Task", "SubagentStart", "spawn_subagent", "start_agent", "CreateAgent"];
5
+ /**
6
+ * Host spellings for Shell/Bash execution tools (#2711).
7
+ * Used for runtimeAuthority scopes.push / scopes.merge classification.
8
+ */
9
+ export declare const SHELL_TOOL_NAMES: readonly ["Shell", "Bash", "BashTool", "shell", "bash"];
5
10
  /** Env override forcing hook-level read-only write denial (#1185). */
6
11
  export declare const READ_ONLY_HOOK_ENV = "DEFT_HOOK_READ_ONLY";
7
12
  export declare function isDirectWriteTool(toolName: string): boolean;
8
13
  export declare function isSpawnTool(toolName: string): boolean;
14
+ /** True for host Shell/Bash-class tools that carry a command string (#2711). */
15
+ export declare function isShellTool(toolName: string): boolean;
16
+ /**
17
+ * Best-effort MCP tool detection for runtimeAuthority push/merge scopes (#2711).
18
+ * Host spellings vary (`mcp__*`, `server__tool`, bare MCP-looking names).
19
+ * Unclassifiable MCP tools fail open at the operation classifier.
20
+ *
21
+ * Bare push/merge names (e.g. `merge_pull_request`) are NOT detected here —
22
+ * they are classified by `classifyMcpTool` and routed from `decideHook` so
23
+ * tools.ts stays free of policy imports (#2711 Greptile conf holdout).
24
+ */
25
+ export declare function isMcpTool(toolName: string): boolean;
26
+ /**
27
+ * Bare tool names that hosts may emit without mcp__/server__ prefixes and that
28
+ * `classifyMcpTool` treats as push or merge (#2711).
29
+ * Keep in sync with classifyMcpTool name patterns (narrow list for PreToolUse matchers).
30
+ */
31
+ export declare const MCP_PUSH_MERGE_BARE_NAMES: readonly ["merge_pull_request", "merge-pull-request", "pull_request_merge", "pull-request-merge", "merge_pr", "pr_merge", "pr-merge", "git_push", "git-push", "push_branch", "push-branch"];
9
32
  export declare const DIRECT_WRITE_HOOK_MATCHER: string;
10
33
  export declare const SPAWN_HOOK_MATCHER: string;
34
+ export declare const SHELL_HOOK_MATCHER: string;
35
+ /**
36
+ * PreToolUse matcher for MCP-class push/merge tools (#2711).
37
+ * Regex-friendly for Claude/nested hosts. Prefer broad install match +
38
+ * fail-open classify in the dispatcher over missing a classifiable push/merge
39
+ * tool name (e.g. `server__push_to_remote` via push+remote).
40
+ */
41
+ export declare const MCP_HOOK_MATCHER: string;
11
42
  //# sourceMappingURL=tools.d.ts.map
@@ -21,6 +21,11 @@ export const SPAWN_TOOL_NAMES = [
21
21
  "start_agent",
22
22
  "CreateAgent",
23
23
  ];
24
+ /**
25
+ * Host spellings for Shell/Bash execution tools (#2711).
26
+ * Used for runtimeAuthority scopes.push / scopes.merge classification.
27
+ */
28
+ export const SHELL_TOOL_NAMES = ["Shell", "Bash", "BashTool", "shell", "bash"];
24
29
  /** Env override forcing hook-level read-only write denial (#1185). */
25
30
  export const READ_ONLY_HOOK_ENV = "DEFT_HOOK_READ_ONLY";
26
31
  function normalizedToolName(toolName) {
@@ -28,12 +33,81 @@ function normalizedToolName(toolName) {
28
33
  }
29
34
  const DIRECT_WRITE_TOOLS = new Set(DIRECT_WRITE_TOOL_NAMES.map(normalizedToolName));
30
35
  const SPAWN_TOOLS = new Set(SPAWN_TOOL_NAMES.map(normalizedToolName));
36
+ const SHELL_TOOLS = new Set(SHELL_TOOL_NAMES.map(normalizedToolName));
31
37
  export function isDirectWriteTool(toolName) {
32
38
  return DIRECT_WRITE_TOOLS.has(normalizedToolName(toolName));
33
39
  }
34
40
  export function isSpawnTool(toolName) {
35
41
  return SPAWN_TOOLS.has(normalizedToolName(toolName));
36
42
  }
43
+ /** True for host Shell/Bash-class tools that carry a command string (#2711). */
44
+ export function isShellTool(toolName) {
45
+ return SHELL_TOOLS.has(normalizedToolName(toolName));
46
+ }
47
+ /**
48
+ * Best-effort MCP tool detection for runtimeAuthority push/merge scopes (#2711).
49
+ * Host spellings vary (`mcp__*`, `server__tool`, bare MCP-looking names).
50
+ * Unclassifiable MCP tools fail open at the operation classifier.
51
+ *
52
+ * Bare push/merge names (e.g. `merge_pull_request`) are NOT detected here —
53
+ * they are classified by `classifyMcpTool` and routed from `decideHook` so
54
+ * tools.ts stays free of policy imports (#2711 Greptile conf holdout).
55
+ */
56
+ export function isMcpTool(toolName) {
57
+ const raw = toolName.trim();
58
+ if (raw.length === 0)
59
+ return false;
60
+ const lower = raw.toLowerCase();
61
+ if (lower.startsWith("mcp__") || lower.startsWith("mcp_"))
62
+ return true;
63
+ if (lower.includes("__") && !isDirectWriteTool(toolName) && !isShellTool(toolName)) {
64
+ // server__tool style used by some MCP bridges
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ /**
70
+ * Bare tool names that hosts may emit without mcp__/server__ prefixes and that
71
+ * `classifyMcpTool` treats as push or merge (#2711).
72
+ * Keep in sync with classifyMcpTool name patterns (narrow list for PreToolUse matchers).
73
+ */
74
+ export const MCP_PUSH_MERGE_BARE_NAMES = [
75
+ "merge_pull_request",
76
+ "merge-pull-request",
77
+ "pull_request_merge",
78
+ "pull-request-merge",
79
+ "merge_pr",
80
+ "pr_merge",
81
+ "pr-merge",
82
+ "git_push",
83
+ "git-push",
84
+ "push_branch",
85
+ "push-branch",
86
+ ];
37
87
  export const DIRECT_WRITE_HOOK_MATCHER = DIRECT_WRITE_TOOL_NAMES.join("|");
38
88
  export const SPAWN_HOOK_MATCHER = SPAWN_TOOL_NAMES.join("|");
89
+ export const SHELL_HOOK_MATCHER = SHELL_TOOL_NAMES.join("|");
90
+ /**
91
+ * PreToolUse matcher for MCP-class push/merge tools (#2711).
92
+ * Regex-friendly for Claude/nested hosts. Prefer broad install match +
93
+ * fail-open classify in the dispatcher over missing a classifiable push/merge
94
+ * tool name (e.g. `server__push_to_remote` via push+remote).
95
+ */
96
+ export const MCP_HOOK_MATCHER = [
97
+ // Prefixed / server-bridge styles (isMcpTool); dispatcher fail-opens non-ops.
98
+ "mcp__.*",
99
+ "mcp_.*",
100
+ ".*__.*",
101
+ // Bare names classifyMcpTool recognizes without prefixes.
102
+ ...MCP_PUSH_MERGE_BARE_NAMES,
103
+ // Name fragments for hosts that strip server prefixes differently.
104
+ ".*merge[_-]?pull[_-]?request.*",
105
+ ".*pull[_-]?request[_-]?merge.*",
106
+ ".*(?:^|[_-])merge_pr(?:$|[_-]).*",
107
+ ".*pr[_-]?merge.*",
108
+ ".*git[_-]?push.*",
109
+ ".*push[_-]?branch.*",
110
+ ".*push.*(?:git|branch|remote|ref).*",
111
+ ".*(?:git|branch|remote|ref).*push.*",
112
+ ].join("|");
39
113
  //# sourceMappingURL=tools.js.map
@@ -1,7 +1,7 @@
1
1
  import type { HookHost } from "../hooks/dispatcher.js";
2
2
  import { type HostHooksPolicy } from "../policy/host-hooks.js";
3
3
  import type { InitDepositIo } from "./constants.js";
4
- export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
4
+ export { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
5
5
  export declare const DEFT_HOOK_COMMAND_MARKER = "deft-hook";
6
6
  export declare const LEGACY_DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
7
7
  export declare const AGENT_HOOK_PATHS: readonly [".claude/settings.json", ".grok/hooks/deft.json", ".cursor/hooks.json", ".codex/hooks.json"];
@@ -1,9 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { assertDepositContained } from "../deposit/contain.js";
4
- import { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
4
+ import { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
5
5
  import { isHostHookDepositEnabled, loadHostHooksPolicyFromProject, } from "../policy/host-hooks.js";
6
- export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
6
+ export { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
7
7
  export const DEFT_HOOK_COMMAND_MARKER = "deft-hook";
8
8
  export const LEGACY_DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
9
9
  export const AGENT_HOOK_PATHS = [
@@ -103,6 +103,10 @@ function mergeNestedConfig(config, path, host, options = {}) {
103
103
  ...preTool,
104
104
  nestedGroup(host, "tool.before", DIRECT_WRITE_HOOK_MATCHER),
105
105
  nestedGroup(host, "tool.before", SPAWN_HOOK_MATCHER),
106
+ // Shell/Bash for runtimeAuthority scopes.push / scopes.merge (#2711)
107
+ nestedGroup(host, "tool.before", SHELL_HOOK_MATCHER),
108
+ // MCP push/merge (mcp__*, bare merge_pull_request / git_push, …) (#2711)
109
+ nestedGroup(host, "tool.before", MCP_HOOK_MATCHER),
106
110
  ];
107
111
  if (options.compact) {
108
112
  const preCompact = eventArray(hooks, "PreCompact", path).filter((entry) => !isManagedNestedGroup(entry));
@@ -164,6 +168,18 @@ function mergeCursorConfig(config, path) {
164
168
  failClosed: true,
165
169
  timeout: 5,
166
170
  },
171
+ {
172
+ command: command("cursor", "tool.before"),
173
+ matcher: SHELL_HOOK_MATCHER,
174
+ failClosed: true,
175
+ timeout: 5,
176
+ },
177
+ {
178
+ command: command("cursor", "tool.before"),
179
+ matcher: MCP_HOOK_MATCHER,
180
+ failClosed: true,
181
+ timeout: 5,
182
+ },
167
183
  ];
168
184
  hooks.preCompact = [...preCompact, { command: command("cursor", "session.compact"), timeout: 5 }];
169
185
  return { ...config, version: 1, hooks };
@@ -285,6 +301,14 @@ function hasNestedRegistration(config, host, options = {}) {
285
301
  preTool.some((entry) => {
286
302
  const group = object(entry);
287
303
  return group?.matcher === SPAWN_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
304
+ }) &&
305
+ preTool.some((entry) => {
306
+ const group = object(entry);
307
+ return group?.matcher === SHELL_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
308
+ }) &&
309
+ preTool.some((entry) => {
310
+ const group = object(entry);
311
+ return group?.matcher === MCP_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
288
312
  });
289
313
  if (!base)
290
314
  return false;
@@ -315,6 +339,18 @@ function hasCursorRegistration(config) {
315
339
  hook.matcher === SPAWN_HOOK_MATCHER &&
316
340
  hook.failClosed === true);
317
341
  }) &&
342
+ preTool.some((entry) => {
343
+ const hook = object(entry);
344
+ return (hook?.command === command("cursor", "tool.before") &&
345
+ hook.matcher === SHELL_HOOK_MATCHER &&
346
+ hook.failClosed === true);
347
+ }) &&
348
+ preTool.some((entry) => {
349
+ const hook = object(entry);
350
+ return (hook?.command === command("cursor", "tool.before") &&
351
+ hook.matcher === MCP_HOOK_MATCHER &&
352
+ hook.failClosed === true);
353
+ }) &&
318
354
  preCompact.some((entry) => object(entry)?.command === command("cursor", "session.compact")));
319
355
  }
320
356
  /** Read-only registration probe shared by verify and doctor. */
@@ -77,8 +77,24 @@ export interface PrunePackageAbsentDepositPathsResult {
77
77
  * Remove deposit files not shipped by `@deftai/directive-content`, preserving
78
78
  * generated deposit metadata such as `VERSION` (#2804). Subsumes the legacy
79
79
  * hard-coded `packages/` prune (#2347).
80
+ *
81
+ * Individual removal failures are reported but do not throw — callers that must
82
+ * refuse a VERSION stamp until the deposit matches the content package should
83
+ * use {@link reconcileDepositToContentPackage} (#2913).
80
84
  */
81
85
  export declare function prunePackageAbsentDepositPaths(deftDir: string, contentRoot: string, io: InitDepositIo): Promise<PrunePackageAbsentDepositPathsResult>;
86
+ /**
87
+ * Fail-closed deposit reconcile against the installed content package (#2913).
88
+ *
89
+ * Runs {@link prunePackageAbsentDepositPaths}, then re-scans. If any
90
+ * package-absent path remains, throws so callers refuse the VERSION stamp
91
+ * (dst-only stale/malicious agent content must not survive a refresh).
92
+ *
93
+ * After a successful {@link replaceTree} full-swap this is typically a no-op
94
+ * verification; it also covers additive copy seams and the already-current
95
+ * refresh path that skips payload copy.
96
+ */
97
+ export declare function reconcileDepositToContentPackage(deftDir: string, contentRoot: string, io: InitDepositIo): Promise<PrunePackageAbsentDepositPathsResult>;
82
98
  export interface PruneStrayDepositPathsResult {
83
99
  readonly pruned: string[];
84
100
  }
@@ -257,6 +257,10 @@ async function pruneEmptyParentsForFile(deftDir, contentDirs, relFile) {
257
257
  * Remove deposit files not shipped by `@deftai/directive-content`, preserving
258
258
  * generated deposit metadata such as `VERSION` (#2804). Subsumes the legacy
259
259
  * hard-coded `packages/` prune (#2347).
260
+ *
261
+ * Individual removal failures are reported but do not throw — callers that must
262
+ * refuse a VERSION stamp until the deposit matches the content package should
263
+ * use {@link reconcileDepositToContentPackage} (#2913).
260
264
  */
261
265
  export async function prunePackageAbsentDepositPaths(deftDir, contentRoot, io) {
262
266
  const absent = await findPackageAbsentDepositPaths(deftDir, contentRoot);
@@ -278,6 +282,28 @@ export async function prunePackageAbsentDepositPaths(deftDir, contentRoot, io) {
278
282
  }
279
283
  return { pruned, prunedDirs };
280
284
  }
285
+ /**
286
+ * Fail-closed deposit reconcile against the installed content package (#2913).
287
+ *
288
+ * Runs {@link prunePackageAbsentDepositPaths}, then re-scans. If any
289
+ * package-absent path remains, throws so callers refuse the VERSION stamp
290
+ * (dst-only stale/malicious agent content must not survive a refresh).
291
+ *
292
+ * After a successful {@link replaceTree} full-swap this is typically a no-op
293
+ * verification; it also covers additive copy seams and the already-current
294
+ * refresh path that skips payload copy.
295
+ */
296
+ export async function reconcileDepositToContentPackage(deftDir, contentRoot, io) {
297
+ const result = await prunePackageAbsentDepositPaths(deftDir, contentRoot, io);
298
+ const remaining = await findPackageAbsentDepositPaths(deftDir, contentRoot);
299
+ if (remaining.length > 0) {
300
+ const sample = remaining.slice(0, 5).join(", ");
301
+ const more = remaining.length > 5 ? ` (+${remaining.length - 5} more)` : "";
302
+ throw new Error(`deposit reconcile failed: ${remaining.length} package-absent path(s) remain under .deft/core ` +
303
+ `(e.g. ${sample}${more}). Refusing VERSION stamp until dst-only content is removed (#2913).`);
304
+ }
305
+ return result;
306
+ }
281
307
  /**
282
308
  * Remove framework-source subdirectories from `.deft/core/` that are not
283
309
  * present in the deposited content package (#2347). Silently skips any path
@@ -21,6 +21,7 @@
21
21
  * dispatcher never re-implements deposit, refresh, or migrate behavior.
22
22
  */
23
23
  import { resolve } from "node:path";
24
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_FLAG_NAME, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY, } from "../policy/no-deft-directive.js";
24
25
  import { classify, plan } from "../resolution/index.js";
25
26
  import { runInitDepositCli } from "./init-deposit.js";
26
27
  import { runMigrateCli } from "./migrate.js";
@@ -119,6 +120,33 @@ export async function runInitDispatchCli(options) {
119
120
  options.writeOut(text);
120
121
  },
121
122
  };
123
+ // #2926: root opt-out — do not scaffold/install/refresh when flag is present.
124
+ const optOut = detectNoDeftDirective(projectDir);
125
+ if (optOut.present) {
126
+ io.printf(`\n[directive init] ${NO_DEFT_DIRECTIVE_DISABLED_MESSAGE}\n`);
127
+ if (optOut.inconsistent) {
128
+ io.printf(`[directive init] ${NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE}\n`);
129
+ }
130
+ if (options.jsonOut) {
131
+ options.writeOut(`${JSON.stringify({
132
+ success: false,
133
+ action: "init",
134
+ disabled: true,
135
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
136
+ inconsistent: optOut.inconsistent,
137
+ ...(optOut.inconsistent
138
+ ? { inconsistent_policy: NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY }
139
+ : {}),
140
+ deposit_present: optOut.depositPresent,
141
+ project_dir: projectDir,
142
+ message: optOut.inconsistent
143
+ ? NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE
144
+ : NO_DEFT_DIRECTIVE_DISABLED_MESSAGE,
145
+ }, null, 2)}\n`);
146
+ }
147
+ // Clean opt-out is a successful short-circuit; inconsistent state fails closed.
148
+ return optOut.inconsistent ? 1 : 0;
149
+ }
122
150
  const classification = classifyInitDispatch(projectDir, seams.classifySeams ?? {});
123
151
  const { decision, plan: resolutionPlan } = classification;
124
152
  printInitDispatchSummary(io, decision, resolutionPlan);
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { assertProjectionContained } from "../fs/projection-containment.js";
12
+ import { assertDestinationNotSymlink } from "../fs/projection-containment.js";
13
13
  import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
14
14
  /** Directory ignore entry for the hybrid deposit. */
15
15
  export const PRETTIERIGNORE_DEFT_CORE_LINE = ".deft/core/";
@@ -18,7 +18,7 @@ const DEFT_CORE_COVERING_LINES = new Set([".deft/core/", ".deft/core", ".deft/co
18
18
  const DEFT_FRAMEWORK_PRETTIERIGNORE_HEADER = "# Deft framework: the vendored payload is outside the consumer Prettier gate (#2534).\n";
19
19
  function projectionTarget(projectDir) {
20
20
  const target = join(projectDir, ".prettierignore");
21
- assertProjectionContained(projectDir, target);
21
+ assertDestinationNotSymlink(projectDir, target);
22
22
  return target;
23
23
  }
24
24
  function prettierIgnoreCoversLine(present, line) {
@@ -12,19 +12,20 @@ import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
12
12
  import { platform as osPlatform } from "node:os";
13
13
  import { join, resolve } from "node:path";
14
14
  import { assertDepositContained } from "../deposit/contain.js";
15
- import { copyTree } from "../deposit/copy-tree.js";
15
+ import { replaceTree } from "../deposit/copy-tree.js";
16
16
  import { prunePythonArtifactsFromDeposit } from "../deposit/python-free.js";
17
17
  import { resolveInstalledContentRoot } from "../deposit/resolve-content.js";
18
18
  import { manifestTagToVersion, parseInstallManifest } from "../doctor/manifest.js";
19
19
  import { readCorePackageVersion } from "../engine-version.js";
20
20
  import { resolveLifecycleRoot } from "../layout/resolve.js";
21
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_FLAG_NAME, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY, } from "../policy/no-deft-directive.js";
21
22
  import { runOrgForceOnMigration } from "../policy/org-force-on-migration.js";
22
23
  import { checkLocalEngineIntegrity, classify, ENGINE_PACKAGE, plan, renderGlobalInstall, resolveEngine, } from "../resolution/index.js";
23
24
  import { gitPorcelain } from "../story-ready/git.js";
24
25
  import { removeStaleMigratedFrameworkNarrative } from "../xbrief-migrate/migrate-project.js";
25
26
  import { writeAgentHookDeposit } from "./agent-hooks.js";
26
27
  import { ensureInitGitignoreLines, isDepositTrackedInGit } from "./gitignore.js";
27
- import { depositStagePaths, isInstallerManagedPath, printCommitGuidance, prunePackageAbsentDepositPaths, } from "./hygiene.js";
28
+ import { depositStagePaths, isInstallerManagedPath, printCommitGuidance, reconcileDepositToContentPackage, } from "./hygiene.js";
28
29
  import { parseInitArgv } from "./init-deposit.js";
29
30
  import { buildLegacyRefusalJson, buildLegacyRefusalMessage, detectLegacyLayout, LEGACY_LAYOUT_REFUSED_EXIT_CODE, LegacyLayoutRefusedError, } from "./legacy-detect.js";
30
31
  import { printMigrateNudgeIfNeeded } from "./migrate.js";
@@ -392,7 +393,9 @@ export async function runRefreshDeposit(args, io, seams = {}) {
392
393
  // the resolved project tree. Writes nothing on refusal.
393
394
  assertDepositContained(projectDir, deftDir);
394
395
  const resolveContent = seams.resolveContentRoot ?? resolveInstalledContentRoot;
395
- const copyContent = seams.copyContent ?? copyTree;
396
+ // #2913: default is full-tree replace (Go swapInCore parity), not additive copyTree.
397
+ // Injected seams.copyContent still wins (tests / specialized callers).
398
+ const copyContent = seams.copyContent ?? replaceTree;
396
399
  const readEngine = seams.readEngineVersion ?? readCorePackageVersion;
397
400
  const readPackageVersion = seams.readPackageVersion ?? readCorePackageVersion;
398
401
  const contentRoot = await resolveContent();
@@ -406,15 +409,19 @@ export async function runRefreshDeposit(args, io, seams = {}) {
406
409
  const strategy = alreadyCurrent ? "no-op" : "file-swap";
407
410
  if (alreadyCurrent) {
408
411
  io.printf("[deft update] Framework payload already current; skipping payload copy.\n");
412
+ // #2913: still fail-closed reconcile so dst-only leftovers cannot linger when
413
+ // VERSION already matches (e.g. pre-#2804 additive deposits). Does not re-stamp.
414
+ await reconcileDepositToContentPackage(deftDir, contentRoot, io);
409
415
  migrateLegacyInstallManifest(projectDir, join(deftDir, "VERSION"));
410
416
  }
411
417
  else {
418
+ // Full-tree replace (or injected seam). Additive copy is no longer the default.
412
419
  await copyContent(contentRoot, deftDir);
413
420
  await prunePythonArtifactsFromDeposit(deftDir, projectDir, io);
414
- // #2804 / #2347: prune deposit paths absent from the content package. The
415
- // additive file-swap never removes them, causing stale bridge-era files to
416
- // survive across upgrades until manually cleaned.
417
- await prunePackageAbsentDepositPaths(deftDir, contentRoot, io);
421
+ // #2913 / #2804 / #2347: fail-closed delete-not-in-source BEFORE VERSION stamp.
422
+ // replaceTree already drops dst-only paths; reconcile verifies and covers
423
+ // additive seams. Throws => no VERSION rewrite (refuse stamp until clean).
424
+ await reconcileDepositToContentPackage(deftDir, contentRoot, io);
418
425
  const nowIso = seams.nowIso ?? (() => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"));
419
426
  const manifestFields = {
420
427
  ref: contentVersion.startsWith("v") ? contentVersion : `v${contentVersion}`,
@@ -586,6 +593,30 @@ export async function runRefreshDepositCli(options) {
586
593
  // Legacy layouts keep their existing refusal path (thrown by runRefreshDeposit
587
594
  // and handled below), so classification is skipped for them.
588
595
  const projectDir = resolve(options.projectDir);
596
+ // #2926: root opt-out wins over update/refresh and ambient force-on.
597
+ const optOut = detectNoDeftDirective(projectDir);
598
+ if (optOut.present) {
599
+ const message = optOut.inconsistent
600
+ ? NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE
601
+ : NO_DEFT_DIRECTIVE_DISABLED_MESSAGE;
602
+ io.printf(`${message}\n`);
603
+ if (options.jsonOut) {
604
+ options.writeOut(`${JSON.stringify({
605
+ success: false,
606
+ action: "update",
607
+ disabled: true,
608
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
609
+ inconsistent: optOut.inconsistent,
610
+ ...(optOut.inconsistent
611
+ ? { inconsistent_policy: NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY }
612
+ : {}),
613
+ deposit_present: optOut.depositPresent,
614
+ project_dir: projectDir,
615
+ message,
616
+ }, null, 2)}\n`);
617
+ }
618
+ return UPDATE_REFUSED_EXIT_CODE;
619
+ }
589
620
  const detectLegacy = options.seams?.detectLegacy ?? detectLegacyLayout;
590
621
  let classification = null;
591
622
  if (!detectLegacy(projectDir).legacy) {