@deftai/directive-core 0.86.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 (70) 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 +2 -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.d.ts +6 -5
  9. package/dist/doctor/main.js +80 -18
  10. package/dist/doctor/taskfile.d.ts +8 -0
  11. package/dist/doctor/taskfile.js +19 -0
  12. package/dist/fs/projection-containment.d.ts +18 -0
  13. package/dist/fs/projection-containment.js +40 -0
  14. package/dist/hooks/dispatcher.d.ts +34 -2
  15. package/dist/hooks/dispatcher.js +234 -21
  16. package/dist/hooks/tools.d.ts +31 -0
  17. package/dist/hooks/tools.js +74 -0
  18. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  19. package/dist/init-deposit/agent-hooks.js +38 -2
  20. package/dist/init-deposit/hygiene.d.ts +16 -0
  21. package/dist/init-deposit/hygiene.js +26 -0
  22. package/dist/init-deposit/init-dispatch.js +28 -0
  23. package/dist/init-deposit/prettierignore.js +2 -2
  24. package/dist/init-deposit/refresh.js +38 -7
  25. package/dist/init-deposit/scaffold.js +7 -3
  26. package/dist/init-deposit/xbrief-projections.js +6 -6
  27. package/dist/intake/issue-emit.d.ts +45 -2
  28. package/dist/intake/issue-emit.js +420 -17
  29. package/dist/intake/issue-ingest.js +65 -6
  30. package/dist/packs/pack-render.d.ts +33 -0
  31. package/dist/packs/pack-render.js +155 -9
  32. package/dist/packs/quarantine-ext.d.ts +10 -0
  33. package/dist/packs/quarantine-ext.js +26 -2
  34. package/dist/platform/platform-capabilities.js +3 -0
  35. package/dist/policy/index.d.ts +1 -0
  36. package/dist/policy/index.js +1 -0
  37. package/dist/policy/no-deft-directive.d.ts +59 -0
  38. package/dist/policy/no-deft-directive.js +103 -0
  39. package/dist/policy/org-force-on-migration.d.ts +52 -0
  40. package/dist/policy/org-force-on-migration.js +260 -22
  41. package/dist/policy/runtime-authority.d.ts +41 -0
  42. package/dist/policy/runtime-authority.js +274 -0
  43. package/dist/review-monitor/constants.js +3 -2
  44. package/dist/review-monitor/tier-detection.d.ts +6 -2
  45. package/dist/review-monitor/tier-detection.js +27 -2
  46. package/dist/scope/transition.js +43 -0
  47. package/dist/session/release-availability.d.ts +2 -0
  48. package/dist/session/release-availability.js +23 -8
  49. package/dist/session/session-start-hook.d.ts +3 -0
  50. package/dist/session/session-start-hook.js +15 -0
  51. package/dist/session/session-start.js +30 -0
  52. package/dist/swarm/routing-set-cli.js +5 -10
  53. package/dist/swarm/routing.d.ts +3 -2
  54. package/dist/swarm/routing.js +16 -4
  55. package/dist/triage/help/registry-data.d.ts +7 -7
  56. package/dist/triage/help/registry-data.js +15 -6
  57. package/dist/triage/queue/index.d.ts +1 -0
  58. package/dist/triage/queue/index.js +1 -0
  59. package/dist/triage/queue/show.d.ts +69 -0
  60. package/dist/triage/queue/show.js +293 -0
  61. package/dist/triage/scope/cli.js +3 -0
  62. package/dist/triage/scope/coverage.d.ts +2 -0
  63. package/dist/triage/scope/coverage.js +18 -3
  64. package/dist/verify-source/cursor-tier1.js +7 -2
  65. package/dist/verify-source/index.d.ts +1 -0
  66. package/dist/verify-source/index.js +1 -0
  67. package/dist/verify-source/openclaw-tier1.d.ts +37 -0
  68. package/dist/verify-source/openclaw-tier1.js +105 -0
  69. package/dist/xbrief-migrate/migrate-project.js +9 -5
  70. package/package.json +4 -3
@@ -133,6 +133,46 @@ export function assertWriteTargetSafe(projectDir, targetPath) {
133
133
  throw new ProjectionContainmentError(`projection write refused: ${targetAbs} is a symlink on the write path`, { projectDir: resolve(projectDir), targetPath: targetAbs, offendingPath: targetAbs });
134
134
  }
135
135
  }
136
+ /**
137
+ * Refuse projection writes that would follow an IN-TREE destination symlink on
138
+ * the write path (#2912).
139
+ *
140
+ * {@link assertProjectionContained} only rejects symlinks that ESCAPE the
141
+ * project tree, so an in-tree symlink (leaf or parent) pointing at another
142
+ * checked-in path is silently followed — letting a malicious or mistaken repo
143
+ * symlink divert a consumer projection write (AGENTS.md, .githooks/**,
144
+ * .gitattributes, .github/**, .agents/**, vbrief|xbrief/**, package.json, …)
145
+ * onto an unintended file under operator credentials.
146
+ *
147
+ * This guard first runs the escape checks in {@link assertProjectionContained},
148
+ * then walks every EXISTING component from the project root down to the write
149
+ * target and refuses the write if ANY of them is a symlink — regardless of
150
+ * whether the link resolves inside the tree. Call it BEFORE any projection
151
+ * read/write/mkdir/append on consumer deposit sinks.
152
+ */
153
+ export function assertDestinationNotSymlink(projectDir, targetPath) {
154
+ assertProjectionContained(projectDir, targetPath);
155
+ const projectAbs = resolve(projectDir);
156
+ const targetAbs = resolve(targetPath);
157
+ const rel = relative(projectAbs, targetAbs);
158
+ const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0);
159
+ let current = projectAbs;
160
+ for (const segment of segments) {
161
+ current = join(current, segment);
162
+ let info;
163
+ try {
164
+ info = lstatSync(current);
165
+ }
166
+ catch {
167
+ // Component does not exist yet; nothing deeper can exist to follow.
168
+ break;
169
+ }
170
+ if (info.isSymbolicLink()) {
171
+ throw new ProjectionContainmentError(`projection write refused: ${current} is a symlink on the destination path ` +
172
+ `(in-tree destination symlinks are refused)`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
173
+ }
174
+ }
175
+ }
136
176
  /**
137
177
  * Refuse when a lifecycle corpus root is itself a symlink (#2626 category-b).
138
178
  */
@@ -1,8 +1,9 @@
1
+ import { detectNoDeftDirective } from "../policy/no-deft-directive.js";
1
2
  import { type RuntimeAuthorityPolicy } from "../policy/runtime-authority.js";
2
3
  import { type VerifyResult } from "../session/verify-session-ritual.js";
3
4
  import { type ActiveScopeInspection } from "./scope.js";
4
5
  export { hookReadOnlyFromPayload, isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
5
- export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, isDirectWriteTool, isSpawnTool, READ_ONLY_HOOK_ENV, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
6
+ 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";
6
7
  export declare const HOOK_HOSTS: readonly ["claude", "grok", "cursor", "codex"];
7
8
  export type HookHost = (typeof HOOK_HOSTS)[number];
8
9
  export declare const HOOK_EVENTS: readonly ["session.start", "session.compact", "tool.before"];
@@ -13,7 +14,13 @@ export type CompactHookHost = (typeof COMPACT_HOOK_HOSTS)[number];
13
14
  /** Hosts without a native compact hook surface — deposit skips cleanly (#2113). */
14
15
  export declare const COMPACT_HOOK_SKIP_HOSTS: readonly ["codex"];
15
16
  export type HookVerdict = "allow" | "deny";
16
- export type HookDecisionCode = "session-start" | "session-start-degraded" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready" | "write-ready" | "read-only-deny" | "spawn-explore-ready" | "spawn-ready" | "spawn-not-ready" | "runtime-policy-deny-path" | "runtime-policy-deny-scope";
17
+ export type HookDecisionCode = "session-start" | "session-start-disabled" | "session-start-degraded" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input"
18
+ /** Host closed stdin with zero bytes — integration failure, not a policy gate (#2864). */
19
+ | "stdin-empty" | "ritual-not-ready" | "scope-not-ready" | "write-propose-ready" | "write-ready" | "read-only-deny" | "spawn-explore-ready" | "spawn-ready" | "spawn-not-ready" | "runtime-policy-deny-path" | "runtime-policy-deny-scope"
20
+ /** Shell/MCP classifiable push/merge allowed under runtimeAuthority (#2711). */
21
+ | "shell-op-ready"
22
+ /** Shell/MCP tool seen but command/tool not classifiable as push/merge — fail open (#2711). */
23
+ | "shell-op-unclassifiable";
17
24
  export interface HookDecision {
18
25
  readonly verdict: HookVerdict;
19
26
  readonly code: HookDecisionCode;
@@ -46,6 +53,8 @@ export interface HookPolicySeams {
46
53
  stdout: string;
47
54
  stderr: string;
48
55
  };
56
+ /** Test seam for #2926 root opt-out on SessionStart. */
57
+ readonly detectNoDeftDirective?: typeof detectNoDeftDirective;
49
58
  readonly markCompactStale?: (projectRoot: string) => {
50
59
  changed: boolean;
51
60
  statePath: string;
@@ -62,6 +71,19 @@ export declare function hookToolName(payload: unknown, host?: HookHost): string
62
71
  export declare function hookWriteTargetPath(payload: unknown): string | null;
63
72
  /** POSIX-ish project-relative path for lifecycle matching. */
64
73
  export declare function toProjectRelativePosix(projectRoot: string, targetPath: string): string;
74
+ /**
75
+ * Lexical "outside project root" predicate used by #2885.
76
+ * - `".."` / `"../…"` are outside (not bare `startsWith("..")` — that matches `..secret`).
77
+ * - Absolute relatives and win32 cross-drive paths (`D:/…`) are outside.
78
+ * - Drive-letter form is win32-only so POSIX children like `D:/tmp/x` stay in-repo.
79
+ */
80
+ export declare function isLexicalOutsideProjectRoot(relPosix: string): boolean;
81
+ /**
82
+ * True when a write target is outside `projectRoot` for the active-scope skip (#2885).
83
+ * Lexically outside paths still fail the skip when a symlink/junction re-enters the project.
84
+ * When the project root cannot be realpath'd (unit fixtures), lexical classification wins.
85
+ */
86
+ export declare function isOutsideProjectRootWrite(projectRoot: string, targetPath: string): boolean;
65
87
  /**
66
88
  * Proposing a scope under xbrief/proposed/ (or legacy vbrief/proposed/) is
67
89
  * planning, not implementation dispatch — exempt from the active-scope gate (#2625).
@@ -72,6 +94,11 @@ export declare function normalizeHookProjectRoot(path: string): string;
72
94
  export declare function projectRootFromHookPayload(payload: unknown, fallback: string): string;
73
95
  export declare function isHookHost(value: string): value is HookHost;
74
96
  export declare function isHookEvent(value: string): value is HookEvent;
97
+ /**
98
+ * Best-effort shell command string from host PreToolUse payloads (#2711).
99
+ * Hosts disagree on nesting (`tool_input.command` vs top-level `command`).
100
+ */
101
+ export declare function hookShellCommand(payload: unknown): string | null;
75
102
  /** Decide a normalized event using only the P0 direct-write policy. */
76
103
  export declare function decideHook(input: HookDispatchInput, seams?: HookPolicySeams): HookDecision;
77
104
  /**
@@ -81,6 +108,11 @@ export declare function decideHook(input: HookDispatchInput, seams?: HookPolicyS
81
108
  * hook failure and blocks the tool — so Cursor allows must emit explicit
82
109
  * `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
83
110
  * flow is unchanged.
111
+ *
112
+ * Cursor stdout always includes `code` (stable machine-readable decision code)
113
+ * so agents can distinguish policy denials from host-integration failures
114
+ * without parsing English (#2864). Exit status still does not encode the
115
+ * verdict — see hook-dispatch `run()` exit-code contract.
84
116
  */
85
117
  export declare function renderHostDecision(host: HookHost, decision: HookDecision): string;
86
118
  //# sourceMappingURL=dispatcher.d.ts.map
@@ -1,14 +1,16 @@
1
- import { relative, resolve, sep } from "node:path";
1
+ import { realpathSync } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
2
3
  import { hasArtifactSuffix } from "../layout/resolve.js";
3
- 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";
4
6
  import { markRitualStaleAfterCompact } from "../session/ritual-sentinel.js";
5
7
  import { runSessionStartHookWrite } from "../session/session-start-hook.js";
6
8
  import { inspectSessionRitual } from "../session/verify-session-ritual.js";
7
9
  import { isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
8
10
  import { inspectActiveScope } from "./scope.js";
9
- import { isDirectWriteTool, isSpawnTool } from "./tools.js";
11
+ import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.js";
10
12
  export { hookReadOnlyFromPayload, isExploreSpawn, isReadOnlyHookContext } from "./readonly.js";
11
- 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";
12
14
  export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
13
15
  export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
14
16
  /** Hosts that receive compact/resume hook deposits via init/update (#2113). */
@@ -20,7 +22,8 @@ function record(value) {
20
22
  ? value
21
23
  : null;
22
24
  }
23
- function firstString(...values) {
25
+ /** First non-empty trimmed string from candidates (array form — clear arity for static tools). */
26
+ function firstString(values) {
24
27
  for (const value of values) {
25
28
  if (typeof value === "string" && value.trim().length > 0)
26
29
  return value.trim();
@@ -137,7 +140,14 @@ export function hookWriteTargetPath(payload) {
137
140
  if (input === null)
138
141
  return null;
139
142
  const toolInput = toolInputRecord(input);
140
- 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
+ ]);
141
151
  }
142
152
  /** POSIX-ish project-relative path for lifecycle matching. */
143
153
  export function toProjectRelativePosix(projectRoot, targetPath) {
@@ -145,6 +155,57 @@ export function toProjectRelativePosix(projectRoot, targetPath) {
145
155
  const rel = relative(resolve(projectRoot), abs);
146
156
  return rel.split(sep).join("/").replace(/\\/g, "/");
147
157
  }
158
+ function posixRelative(fromAbs, toAbs) {
159
+ return relative(fromAbs, toAbs).split(sep).join("/").replace(/\\/g, "/");
160
+ }
161
+ /**
162
+ * Lexical "outside project root" predicate used by #2885.
163
+ * - `".."` / `"../…"` are outside (not bare `startsWith("..")` — that matches `..secret`).
164
+ * - Absolute relatives and win32 cross-drive paths (`D:/…`) are outside.
165
+ * - Drive-letter form is win32-only so POSIX children like `D:/tmp/x` stay in-repo.
166
+ */
167
+ export function isLexicalOutsideProjectRoot(relPosix) {
168
+ if (relPosix === ".." || relPosix.startsWith("../") || isAbsolute(relPosix)) {
169
+ return true;
170
+ }
171
+ // path.relative returns absolute drive paths across volumes on Windows only.
172
+ return process.platform === "win32" && /^[A-Za-z]:\//.test(relPosix);
173
+ }
174
+ /**
175
+ * True when a write target is outside `projectRoot` for the active-scope skip (#2885).
176
+ * Lexically outside paths still fail the skip when a symlink/junction re-enters the project.
177
+ * When the project root cannot be realpath'd (unit fixtures), lexical classification wins.
178
+ */
179
+ export function isOutsideProjectRootWrite(projectRoot, targetPath) {
180
+ const projectAbs = resolve(projectRoot);
181
+ const targetAbs = resolve(projectRoot, targetPath.replace(/\\/g, "/"));
182
+ const rel = posixRelative(projectAbs, targetAbs);
183
+ if (!isLexicalOutsideProjectRoot(rel))
184
+ return false;
185
+ try {
186
+ const projectReal = realpathSync(projectAbs);
187
+ let probe = targetAbs;
188
+ for (;;) {
189
+ try {
190
+ const probeReal = realpathSync(probe);
191
+ const reenter = posixRelative(projectReal, probeReal);
192
+ // Empty reenter ⇒ probeReal === projectReal (inside). Lexical-outside reenter ⇒ truly out.
193
+ if (reenter === "" || !isLexicalOutsideProjectRoot(reenter))
194
+ return false;
195
+ return true;
196
+ }
197
+ catch {
198
+ const parent = dirname(probe);
199
+ if (parent === probe)
200
+ return true;
201
+ probe = parent;
202
+ }
203
+ }
204
+ }
205
+ catch {
206
+ return true;
207
+ }
208
+ }
148
209
  /**
149
210
  * Proposing a scope under xbrief/proposed/ (or legacy vbrief/proposed/) is
150
211
  * planning, not implementation dispatch — exempt from the active-scope gate (#2625).
@@ -253,6 +314,113 @@ function runtimeAuthorityForDirectWrite(input, toolName, seams, scopePath) {
253
314
  return null;
254
315
  return deny(input, verdict.code ?? "runtime-policy-deny-path", toolName, verdict.reason ?? "Directive denied this direct write under runtime authority policy.", scopePath);
255
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
+ }
256
424
  function inspectMutationGates(input, toolName, seams, options) {
257
425
  const projectRoot = resolve(input.projectRoot);
258
426
  let ritual;
@@ -298,18 +466,25 @@ function inspectMutationGates(input, toolName, seams, options) {
298
466
  if (!scope.ready) {
299
467
  const writeTarget = hookWriteTargetPath(input.payload);
300
468
  const relTarget = writeTarget !== null ? toProjectRelativePosix(projectRoot, writeTarget) : null;
301
- const proposedPathHint = options.proposedLifecycleExempt &&
302
- relTarget !== null &&
303
- (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))
304
- ? " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
305
- "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
306
- "planning writes (#2625)."
307
- : " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
308
- (options.proposedLifecycleExempt
309
- ? "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption)."
310
- : "then re-run the pre-start_agent gate stack.");
311
- const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
312
- return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
469
+ // Active-scope governs in-repo lifecycle work only. Outside-root Write/Edit
470
+ // (agent memory, $TMPDIR, user config) skips the deny; null/unparseable
471
+ // targets stay fail-closed. Spawn has no write target → still requires scope (#2885).
472
+ // Lexical ../ + realpath re-entry guard (not bare startsWith(".."); not symlink aliases).
473
+ const outsideRoot = writeTarget !== null && isOutsideProjectRootWrite(projectRoot, writeTarget);
474
+ if (!outsideRoot || isSpawnTool(toolName)) {
475
+ const proposedPathHint = options.proposedLifecycleExempt &&
476
+ relTarget !== null &&
477
+ (relTarget.startsWith("xbrief/proposed/") || relTarget.startsWith("vbrief/proposed/"))
478
+ ? " For a new proposal under xbrief/proposed/, include a lifecycle artifact " +
479
+ "filename (*.xbrief.json) in the Write/Edit payload so the gate can exempt " +
480
+ "planning writes (#2625)."
481
+ : " Recovery: run `deft scope:activate -- <path>` for the approved xBRIEF, " +
482
+ (options.proposedLifecycleExempt
483
+ ? "or Write a new proposal to xbrief/proposed/*.xbrief.json (planning exemption)."
484
+ : "then re-run the pre-start_agent gate stack.");
485
+ const denyCode = isSpawnTool(toolName) ? "spawn-not-ready" : "scope-not-ready";
486
+ return deny(input, denyCode, toolName, `Directive denied ${toolName}: ${scope.message}${proposedPathHint}`);
487
+ }
313
488
  }
314
489
  const allowCode = isSpawnTool(toolName) ? "spawn-ready" : "write-ready";
315
490
  if (!isSpawnTool(toolName)) {
@@ -372,6 +547,24 @@ export function decideHook(input, seams = {}) {
372
547
  }
373
548
  }
374
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
+ }
375
568
  try {
376
569
  const result = (seams.sessionStart ?? runSessionStartHookWrite)(projectRoot);
377
570
  if (result.code !== 0) {
@@ -414,7 +607,12 @@ export function decideHook(input, seams = {}) {
414
607
  }
415
608
  const toolName = hookToolName(input.payload, input.host);
416
609
  if (toolName === null) {
417
- return deny(input, "invalid-input", null, missingToolNameMessage({
610
+ // stdin-empty is a host-integration failure; keep it distinct from invalid-input
611
+ // so agents can retry without treating it as a policy refusal (#2864).
612
+ const missingCode = input.payloadContext?.stdinEmpty
613
+ ? "stdin-empty"
614
+ : "invalid-input";
615
+ return deny(input, missingCode, null, missingToolNameMessage({
418
616
  host: input.host,
419
617
  payload: input.payload,
420
618
  context: input.payloadContext,
@@ -446,6 +644,15 @@ export function decideHook(input, seams = {}) {
446
644
  }
447
645
  return inspectMutationGates(input, toolName, seams, { proposedLifecycleExempt: false });
448
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
+ }
449
656
  if (!isDirectWriteTool(toolName)) {
450
657
  return {
451
658
  verdict: "allow",
@@ -454,7 +661,7 @@ export function decideHook(input, seams = {}) {
454
661
  host: input.host,
455
662
  toolName,
456
663
  projectRoot,
457
- message: `${toolName} is outside the P0 direct-write/spawn enforcement slice.`,
664
+ message: `${toolName} is outside the P0 direct-write/spawn/shell enforcement slice.`,
458
665
  scopePath: null,
459
666
  };
460
667
  }
@@ -467,11 +674,16 @@ export function decideHook(input, seams = {}) {
467
674
  * hook failure and blocks the tool — so Cursor allows must emit explicit
468
675
  * `{"permission":"allow"}`. Other hosts keep empty allow so the host permission
469
676
  * flow is unchanged.
677
+ *
678
+ * Cursor stdout always includes `code` (stable machine-readable decision code)
679
+ * so agents can distinguish policy denials from host-integration failures
680
+ * without parsing English (#2864). Exit status still does not encode the
681
+ * verdict — see hook-dispatch `run()` exit-code contract.
470
682
  */
471
683
  export function renderHostDecision(host, decision) {
472
684
  if (decision.verdict === "allow") {
473
685
  if (host === "cursor") {
474
- return JSON.stringify({ permission: "allow" });
686
+ return JSON.stringify({ permission: "allow", code: decision.code });
475
687
  }
476
688
  return "";
477
689
  }
@@ -492,6 +704,7 @@ export function renderHostDecision(host, decision) {
492
704
  permission: "deny",
493
705
  user_message: decision.message,
494
706
  agent_message: decision.message,
707
+ code: decision.code,
495
708
  });
496
709
  }
497
710
  }
@@ -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"];