@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.
- package/dist/cache/scanner.d.ts +11 -1
- package/dist/cache/scanner.js +29 -4
- package/dist/check/gate-lists.js +2 -0
- package/dist/content-contracts/skills/helpers.d.ts +10 -0
- package/dist/content-contracts/skills/helpers.js +35 -0
- package/dist/deposit/copy-tree.d.ts +19 -1
- package/dist/deposit/copy-tree.js +134 -5
- package/dist/doctor/main.d.ts +6 -5
- package/dist/doctor/main.js +80 -18
- package/dist/doctor/taskfile.d.ts +8 -0
- package/dist/doctor/taskfile.js +19 -0
- package/dist/fs/projection-containment.d.ts +18 -0
- package/dist/fs/projection-containment.js +40 -0
- package/dist/hooks/dispatcher.d.ts +34 -2
- package/dist/hooks/dispatcher.js +234 -21
- package/dist/hooks/tools.d.ts +31 -0
- package/dist/hooks/tools.js +74 -0
- package/dist/init-deposit/agent-hooks.d.ts +1 -1
- package/dist/init-deposit/agent-hooks.js +38 -2
- package/dist/init-deposit/hygiene.d.ts +16 -0
- package/dist/init-deposit/hygiene.js +26 -0
- package/dist/init-deposit/init-dispatch.js +28 -0
- package/dist/init-deposit/prettierignore.js +2 -2
- package/dist/init-deposit/refresh.js +38 -7
- package/dist/init-deposit/scaffold.js +7 -3
- package/dist/init-deposit/xbrief-projections.js +6 -6
- package/dist/intake/issue-emit.d.ts +45 -2
- package/dist/intake/issue-emit.js +420 -17
- package/dist/intake/issue-ingest.js +65 -6
- package/dist/packs/pack-render.d.ts +33 -0
- package/dist/packs/pack-render.js +155 -9
- package/dist/packs/quarantine-ext.d.ts +10 -0
- package/dist/packs/quarantine-ext.js +26 -2
- package/dist/platform/platform-capabilities.js +3 -0
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.js +1 -0
- package/dist/policy/no-deft-directive.d.ts +59 -0
- package/dist/policy/no-deft-directive.js +103 -0
- package/dist/policy/org-force-on-migration.d.ts +52 -0
- package/dist/policy/org-force-on-migration.js +260 -22
- package/dist/policy/runtime-authority.d.ts +41 -0
- package/dist/policy/runtime-authority.js +274 -0
- package/dist/review-monitor/constants.js +3 -2
- package/dist/review-monitor/tier-detection.d.ts +6 -2
- package/dist/review-monitor/tier-detection.js +27 -2
- package/dist/scope/transition.js +43 -0
- package/dist/session/release-availability.d.ts +2 -0
- package/dist/session/release-availability.js +23 -8
- package/dist/session/session-start-hook.d.ts +3 -0
- package/dist/session/session-start-hook.js +15 -0
- package/dist/session/session-start.js +30 -0
- package/dist/swarm/routing-set-cli.js +5 -10
- package/dist/swarm/routing.d.ts +3 -2
- package/dist/swarm/routing.js +16 -4
- package/dist/triage/help/registry-data.d.ts +7 -7
- package/dist/triage/help/registry-data.js +15 -6
- package/dist/triage/queue/index.d.ts +1 -0
- package/dist/triage/queue/index.js +1 -0
- package/dist/triage/queue/show.d.ts +69 -0
- package/dist/triage/queue/show.js +293 -0
- package/dist/triage/scope/cli.js +3 -0
- package/dist/triage/scope/coverage.d.ts +2 -0
- package/dist/triage/scope/coverage.js +18 -3
- package/dist/verify-source/cursor-tier1.js +7 -2
- package/dist/verify-source/index.d.ts +1 -0
- package/dist/verify-source/index.js +1 -0
- package/dist/verify-source/openclaw-tier1.d.ts +37 -0
- package/dist/verify-source/openclaw-tier1.js +105 -0
- package/dist/xbrief-migrate/migrate-project.js +9 -5
- 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"
|
|
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
|
package/dist/hooks/dispatcher.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/hooks/tools.d.ts
CHANGED
|
@@ -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
|
package/dist/hooks/tools.js
CHANGED
|
@@ -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"];
|