@deftai/directive-core 0.109.0 → 0.109.1
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/design-critique/completed-arc-record.d.ts +8 -1
- package/dist/design-critique/completed-arc-record.js +43 -4
- package/dist/hooks/classify/host-session-identity.d.ts +1 -1
- package/dist/hooks/classify/host-session-identity.js +23 -5
- package/dist/hooks/dispatcher.d.ts +17 -1
- package/dist/hooks/dispatcher.js +83 -7
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/owner-liveness.d.ts +92 -0
- package/dist/hooks/owner-liveness.js +103 -0
- package/dist/hooks/tools.d.ts +47 -18
- package/dist/hooks/tools.js +82 -16
- package/dist/init-deposit/agent-hooks.d.ts +10 -0
- package/dist/init-deposit/agent-hooks.js +39 -0
- package/dist/init-deposit/host-tool-coverage.d.ts +53 -0
- package/dist/init-deposit/host-tool-coverage.js +150 -0
- package/dist/init-deposit/index.d.ts +1 -0
- package/dist/init-deposit/index.js +1 -0
- package/dist/orchestration/subagent-monitor.d.ts +6 -0
- package/dist/orchestration/subagent-monitor.js +23 -1
- package/dist/session/child-occupancy.d.ts +72 -0
- package/dist/session/child-occupancy.js +209 -0
- package/dist/session/host-session-owner.d.ts +40 -0
- package/dist/session/host-session-owner.js +64 -5
- package/dist/session/index.d.ts +1 -0
- package/dist/session/index.js +1 -0
- package/dist/session/occupancy.d.ts +89 -4
- package/dist/session/occupancy.js +211 -31
- package/dist/swarm/complete-cohort.js +2 -0
- package/dist/swarm/pre-dispatch.js +2 -0
- package/dist/swarm/subagent-status-dir.d.ts +2 -1
- package/dist/swarm/subagent-status-dir.js +11 -2
- package/dist/swarm/worktrees.js +2 -0
- package/dist/verify-env/agent-hooks.d.ts +6 -1
- package/dist/verify-env/agent-hooks.js +28 -2
- package/package.json +3 -3
|
@@ -15,7 +15,14 @@ export type ThreadComment = {
|
|
|
15
15
|
readonly id: number;
|
|
16
16
|
readonly body: string;
|
|
17
17
|
};
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Closed reason set, published in `content/contracts/design-critique.md`
|
|
20
|
+
* `### One parser, set membership, observed diagnostics`. The union is derived
|
|
21
|
+
* from this array so a member added here without a contract row fails the
|
|
22
|
+
* content-contract suite (#3942).
|
|
23
|
+
*/
|
|
24
|
+
export declare const COMPLETED_ARC_BLOCK_REASONS: readonly ["missing-record", "lone-shape", "cite-not-lean", "missing-table-cite", "unshaped-table-cite", "ambiguous-table-cite"];
|
|
25
|
+
export type CompletedArcBlockReason = (typeof COMPLETED_ARC_BLOCK_REASONS)[number];
|
|
19
26
|
export type CompletedArcVerdict = {
|
|
20
27
|
readonly status: "not-in-arc";
|
|
21
28
|
} | {
|
|
@@ -13,6 +13,20 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { ACCEPTED_CITATION_FORMS, scanCitations, } from "./citation-grammar.js";
|
|
15
15
|
import { DESIGN_CRITIQUE_CATALOG_CHIPS } from "./exclusive-chip.js";
|
|
16
|
+
/**
|
|
17
|
+
* Closed reason set, published in `content/contracts/design-critique.md`
|
|
18
|
+
* `### One parser, set membership, observed diagnostics`. The union is derived
|
|
19
|
+
* from this array so a member added here without a contract row fails the
|
|
20
|
+
* content-contract suite (#3942).
|
|
21
|
+
*/
|
|
22
|
+
export const COMPLETED_ARC_BLOCK_REASONS = [
|
|
23
|
+
"missing-record",
|
|
24
|
+
"lone-shape",
|
|
25
|
+
"cite-not-lean",
|
|
26
|
+
"missing-table-cite",
|
|
27
|
+
"unshaped-table-cite",
|
|
28
|
+
"ambiguous-table-cite",
|
|
29
|
+
];
|
|
16
30
|
const SYNTHESIS_SHAPE_RE = /(?:^|\n)\s*design-critique:\s*synthesis accepted,\s*because\b/i;
|
|
17
31
|
const LEAN_HEADING_RE = /(?:^|\n)\s*\*{0,2}Lean:\*{0,2}/;
|
|
18
32
|
const TABLE_HEADING_RE = /(?:^|\n)\s*##\s+Verified-claims table\b/;
|
|
@@ -148,6 +162,14 @@ function resolveCitedLean(citations, byCommentId, latestLean) {
|
|
|
148
162
|
* `comment` citations scan as kind `comment`, and that is the published form
|
|
149
163
|
* these records use. Narrowing the citation contract so a table must be named
|
|
150
164
|
* by keyword is a separate decision needing its own migration criteria.
|
|
165
|
+
*
|
|
166
|
+
* Refusal partition (#3942). A typed claim fails in two states, and only one of
|
|
167
|
+
* them is fixed by adding the heading: the id is not a comment on this thread,
|
|
168
|
+
* or it is a comment whose body fails `isVerifiedClaimsTableBody`. One reason
|
|
169
|
+
* and one detail for both asserted the first in either case, so an author whose
|
|
170
|
+
* table is on the thread read a true citation being called false and had no
|
|
171
|
+
* path to the missing heading. Absent ids rank first because a body that is not
|
|
172
|
+
* there cannot be given a heading.
|
|
151
173
|
*/
|
|
152
174
|
function resolveCitedTable(citations, byCommentId) {
|
|
153
175
|
const claimed = citations.filter((row) => row.kind === "table");
|
|
@@ -160,13 +182,30 @@ function resolveCitedTable(citations, byCommentId) {
|
|
|
160
182
|
};
|
|
161
183
|
}
|
|
162
184
|
const resolved = claimed.map((row) => ({ id: row.id, cited: byCommentId.get(row.id) }));
|
|
163
|
-
const
|
|
164
|
-
|
|
185
|
+
const absent = resolved.filter((row) => row.cited === undefined).map((row) => row.id);
|
|
186
|
+
const unshaped = resolved
|
|
187
|
+
.filter((row) => row.cited !== undefined && !isVerifiedClaimsTableBody(row.cited.body))
|
|
188
|
+
.map((row) => row.id);
|
|
189
|
+
if (absent.length > 0) {
|
|
165
190
|
return {
|
|
166
191
|
ok: false,
|
|
167
192
|
reason: "missing-table-cite",
|
|
168
|
-
detail: "synthesis cites a verified-claims table id that is not a
|
|
169
|
-
renderIds(
|
|
193
|
+
detail: "synthesis cites a verified-claims table id that is not a comment on this thread: " +
|
|
194
|
+
renderIds(absent) +
|
|
195
|
+
(unshaped.length > 0
|
|
196
|
+
? "; also cited, on this thread and carrying no verified-claims-table heading: " +
|
|
197
|
+
renderIds(unshaped)
|
|
198
|
+
: ""),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (unshaped.length > 0) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason: "unshaped-table-cite",
|
|
205
|
+
detail: "synthesis cites a verified-claims table id that is a comment on this thread but " +
|
|
206
|
+
"opens no line with the `## Verified-claims table` heading: " +
|
|
207
|
+
renderIds(unshaped) +
|
|
208
|
+
"; add that heading to the cited comment",
|
|
170
209
|
};
|
|
171
210
|
}
|
|
172
211
|
const distinct = [...new Set(claimed.map((row) => row.id))];
|
|
@@ -44,7 +44,7 @@ export declare function resolveHookHostIdentity(host: string, payload: unknown,
|
|
|
44
44
|
* still denies. A present-but-malformed variable is `invalid` and fails closed.
|
|
45
45
|
*/
|
|
46
46
|
export declare function hostIdentityFallsBackToExplicitOwner(host: string, resolution: HookHostIdentityResolution): boolean;
|
|
47
|
-
export declare const EXACT_LIFECYCLE_VERBS: readonly ["session:start", "session:ready", "session:end", "occupancy:steal", "occupancy:release", "occupancy:heartbeat", "swarm:launch"];
|
|
47
|
+
export declare const EXACT_LIFECYCLE_VERBS: readonly ["session:start", "session:ready", "session:end", "occupancy:steal", "occupancy:release", "occupancy:heartbeat", "occupancy:grant", "swarm:launch"];
|
|
48
48
|
export type ExactLifecycleVerb = (typeof EXACT_LIFECYCLE_VERBS)[number];
|
|
49
49
|
export interface ExactLifecycleCommandRewrite {
|
|
50
50
|
readonly kind: "rewrite";
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `session/host-session-owner.ts` because the CLI claim path resolves the same
|
|
8
8
|
* owner from the same host (#3873).
|
|
9
9
|
*/
|
|
10
|
-
import { ambientHostSessionOwner, canonicalHostSessionId, HOST_IDENTITY_PROVIDERS, hookHostIdentitySource, isUsableHostSessionId, MAX_HOOK_HOST_IDENTITY_UTF8_BYTES, readHostEnvIdentity, } from "../../session/host-session-owner.js";
|
|
10
|
+
import { ambientHostSessionOwner, CANONICAL_OWNER_PATTERN, canonicalHostSessionId, HOST_IDENTITY_PROVIDERS, hookHostIdentitySource, isUsableHostSessionId, MAX_HOOK_HOST_IDENTITY_UTF8_BYTES, readHostEnvIdentity, } from "../../session/host-session-owner.js";
|
|
11
11
|
import { isShellTool } from "../tools.js";
|
|
12
12
|
import { record, toolInputRecord } from "./payload.js";
|
|
13
13
|
import { hookToolName } from "./tool-name.js";
|
|
@@ -100,6 +100,10 @@ export const EXACT_LIFECYCLE_VERBS = [
|
|
|
100
100
|
"occupancy:steal",
|
|
101
101
|
"occupancy:release",
|
|
102
102
|
"occupancy:heartbeat",
|
|
103
|
+
// #3954 item 2: `occupancy:grant` (and its `--revoke` arm) was the only
|
|
104
|
+
// occupancy lifecycle verb absent from this table, so the one verb that must
|
|
105
|
+
// be run by the occupant had no way to be told who the occupant is.
|
|
106
|
+
"occupancy:grant",
|
|
103
107
|
"swarm:launch",
|
|
104
108
|
];
|
|
105
109
|
const DIRECT_LIFECYCLE_VERBS = {
|
|
@@ -109,12 +113,9 @@ const DIRECT_LIFECYCLE_VERBS = {
|
|
|
109
113
|
"occupancy:steal": "occupancy:steal",
|
|
110
114
|
"occupancy:release": "occupancy:release",
|
|
111
115
|
"occupancy:heartbeat": "occupancy:heartbeat",
|
|
116
|
+
"occupancy:grant": "occupancy:grant",
|
|
112
117
|
"swarm-launch": "swarm:launch",
|
|
113
118
|
};
|
|
114
|
-
// Derived from the provider list so the rewrite surface cannot drift from the
|
|
115
|
-
// identity surface: a provider added to one is added to both (#3873). Provider
|
|
116
|
-
// ids are lowercase ASCII words, so the alternation needs no escaping.
|
|
117
|
-
const CANONICAL_OWNER_PATTERN = new RegExp(`^host:(?:${HOST_IDENTITY_PROVIDERS.join("|")}):v1:[A-Za-z0-9_-]+$`);
|
|
118
119
|
// Shell expansion markers are deliberately absent: `$`/backticks for POSIX,
|
|
119
120
|
// `@` splatting for PowerShell, and `%NAME%` expansion for command shells.
|
|
120
121
|
// Backslashes are inspectable so Windows path-bearing lifecycle commands fail
|
|
@@ -176,6 +177,23 @@ const LIFECYCLE_ARGUMENT_POLICIES = {
|
|
|
176
177
|
valueFlags: new Set(["--session-id", "--project-root"]),
|
|
177
178
|
rewriteUnsafeFlags: new Set(["--project-root"]),
|
|
178
179
|
},
|
|
180
|
+
"occupancy:grant": {
|
|
181
|
+
booleanFlags: new Set(["--revoke"]),
|
|
182
|
+
valueFlags: new Set([
|
|
183
|
+
"--session-id",
|
|
184
|
+
"--child-session-id",
|
|
185
|
+
"--role",
|
|
186
|
+
"--worktree",
|
|
187
|
+
"--ttl-minutes",
|
|
188
|
+
"--host",
|
|
189
|
+
"--address",
|
|
190
|
+
"--join-protocol",
|
|
191
|
+
"--project-root",
|
|
192
|
+
]),
|
|
193
|
+
// `--worktree` rebinds the tree the grant covers, so it joins the
|
|
194
|
+
// path/destination flags kept outside the auto-approved rewrite surface.
|
|
195
|
+
rewriteUnsafeFlags: new Set(["--project-root", "--worktree"]),
|
|
196
|
+
},
|
|
179
197
|
"swarm:launch": {
|
|
180
198
|
booleanFlags: new Set([
|
|
181
199
|
"--autonomous",
|
|
@@ -5,9 +5,10 @@ import { type RuntimeAuthorityPolicy } from "../policy/runtime-authority.js";
|
|
|
5
5
|
import { type GitRunner } from "../session/git.js";
|
|
6
6
|
import { type DetectWorkSelection, type RitualRunner, type VerifyResult } from "../session/verify-session-ritual.js";
|
|
7
7
|
import { type HookPayloadContext } from "./classify/index.js";
|
|
8
|
+
import { type OwnerLivenessInput, type OwnerLivenessOutcome } from "./owner-liveness.js";
|
|
8
9
|
import { type ActiveScopeInspection } from "./scope.js";
|
|
9
10
|
export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
10
|
-
export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
|
|
11
|
+
export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, HOST_TOOL_SURFACE_AUDIT, type HostMutationToolCatalog, type HostToolSurfaceAudit, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
|
|
11
12
|
export declare const HOOK_HOSTS: readonly ["claude", "grok", "cursor", "codex"];
|
|
12
13
|
export type HookHost = (typeof HOOK_HOSTS)[number];
|
|
13
14
|
export declare const HOOK_EVENTS: readonly ["session.start", "session.compact", "tool.before"];
|
|
@@ -126,6 +127,8 @@ export interface HookPolicySeams {
|
|
|
126
127
|
readonly realpathLifecycleExecutionRoot?: (path: string) => string;
|
|
127
128
|
/** Test seam for Windows drive-only execution-root payloads (#2787). */
|
|
128
129
|
readonly lifecycleExecutionPlatform?: NodeJS.Platform;
|
|
130
|
+
/** Test seam for the #3987 post-decision owner-liveness re-stamp. */
|
|
131
|
+
readonly restampOwnerLiveness?: (input: OwnerLivenessInput) => OwnerLivenessOutcome;
|
|
129
132
|
}
|
|
130
133
|
/** POSIX-ish project-relative path for lifecycle matching. */
|
|
131
134
|
export declare function toProjectRelativePosix(projectRoot: string, targetPath: string): string;
|
|
@@ -200,6 +203,19 @@ export interface EffectiveHookRootAdmission {
|
|
|
200
203
|
* target that resolved to some other toplevel whose identity cannot be read, is
|
|
201
204
|
* a question asked and left unanswered — that fails closed rather than
|
|
202
205
|
* inheriting payloadRoot's occupancy and ritual state (#3794).
|
|
206
|
+
*
|
|
207
|
+
* The `candidateRaw === null` fallback below — a target whose nearest existing
|
|
208
|
+
* ancestor has no Git toplevel at all (OS temp, a home file, anything outside
|
|
209
|
+
* every checkout) — is the third case and is deliberate, not a gap (#4013).
|
|
210
|
+
* `effectiveRoot` selects occupancy, ritual, active scope, the write fence and
|
|
211
|
+
* assist-scratch, so returning no root would drop all of them for an
|
|
212
|
+
* out-of-tree write, not just the lease; occupancy and ritual are cross-checked
|
|
213
|
+
* and the allow path re-stamps the lease, so exempting the lease alone is not a
|
|
214
|
+
* complete state transition; and `admitMutationTargetSet` demands one unique
|
|
215
|
+
* root, so a member contributing none has no defined combining rule and an
|
|
216
|
+
* untrusted patch path would become an authority-selection input. Gate-by-gate
|
|
217
|
+
* disposition, the three-surface matrix and the relative-target
|
|
218
|
+
* canonicalization limitation (#4023): content/docs/hook-root-admission.md.
|
|
203
219
|
*/
|
|
204
220
|
export declare function admitEffectiveHookRoot(payloadRoot: string, writeTarget: string | null, runGit: GitRunner): EffectiveHookRootAdmission;
|
|
205
221
|
/**
|
package/dist/hooks/dispatcher.js
CHANGED
|
@@ -19,6 +19,7 @@ import { formatRitualRecoveryInstruction, inspectSessionRitual, verifySessionRit
|
|
|
19
19
|
import { hookApplyPatchBodyPaths, hookApplyPatchBodyText, hookMcpArgsText, hookMutationTargetPaths, hookShellCommand, hookToolName, hookWriteTargetPath, hostIdentityFallsBackToExplicitOwner, inspectExactLifecycleCommand, missingToolNameMessage, record, resolveHookHostIdentity, rewriteExactLifecycleCommand, toolInputRecord, } from "./classify/index.js";
|
|
20
20
|
import { classifyGitDestructive, classifyProductDestForms, payloadWithInjectedWriteTarget, } from "./dest-form.js";
|
|
21
21
|
import { appendGitDestructiveRecord, GIT_DESTRUCTIVE_LOG_ENV } from "./git-destructive-log.js";
|
|
22
|
+
import { restampOwnerLivenessOnHookEvent, } from "./owner-liveness.js";
|
|
22
23
|
import { isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
23
24
|
import { inspectActiveScope } from "./scope.js";
|
|
24
25
|
import { classifyShellWriteTargets, isInRepoShellWritePath } from "./shell-write-targets.js";
|
|
@@ -26,7 +27,7 @@ import { isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool } from "./tools.
|
|
|
26
27
|
// Pure parse/classify helpers are defined in ./classify/ and re-exported from
|
|
27
28
|
// ./index.ts (#2950). Dispatcher is orchestration: classify → policy → decision.
|
|
28
29
|
export { ASSIST_SESSION_POSTURE_ENV, hookReadOnlyFromPayload, isAssistPosture, isEphemeralSpawn, isExploreSpawn, isReadOnlyHookContext, } from "./readonly.js";
|
|
29
|
-
export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
|
|
30
|
+
export { DIRECT_WRITE_HOOK_MATCHER, DIRECT_WRITE_TOOL_NAMES, GROK_MUTATION_TOOL_CATALOG, GROK_NON_MUTATION_TOOLS, HOST_TOOL_SURFACE_AUDIT, isDirectWriteTool, isMcpTool, isShellTool, isSpawnTool, MCP_HOOK_MATCHER, MCP_PUSH_MERGE_BARE_NAMES, matcherHasLiteralToken, READ_ONLY_HOOK_ENV, SHELL_HOOK_MATCHER, SHELL_TOOL_NAMES, SPAWN_HOOK_MATCHER, SPAWN_TOOL_NAMES, } from "./tools.js";
|
|
30
31
|
export const HOOK_HOSTS = ["claude", "grok", "cursor", "codex"];
|
|
31
32
|
export const HOOK_EVENTS = ["session.start", "session.compact", "tool.before"];
|
|
32
33
|
/** Hosts that receive compact/resume hook deposits via init/update (#2113). */
|
|
@@ -330,6 +331,19 @@ function admitPayload(payload, candidate) {
|
|
|
330
331
|
* target that resolved to some other toplevel whose identity cannot be read, is
|
|
331
332
|
* a question asked and left unanswered — that fails closed rather than
|
|
332
333
|
* inheriting payloadRoot's occupancy and ritual state (#3794).
|
|
334
|
+
*
|
|
335
|
+
* The `candidateRaw === null` fallback below — a target whose nearest existing
|
|
336
|
+
* ancestor has no Git toplevel at all (OS temp, a home file, anything outside
|
|
337
|
+
* every checkout) — is the third case and is deliberate, not a gap (#4013).
|
|
338
|
+
* `effectiveRoot` selects occupancy, ritual, active scope, the write fence and
|
|
339
|
+
* assist-scratch, so returning no root would drop all of them for an
|
|
340
|
+
* out-of-tree write, not just the lease; occupancy and ritual are cross-checked
|
|
341
|
+
* and the allow path re-stamps the lease, so exempting the lease alone is not a
|
|
342
|
+
* complete state transition; and `admitMutationTargetSet` demands one unique
|
|
343
|
+
* root, so a member contributing none has no defined combining rule and an
|
|
344
|
+
* untrusted patch path would become an authority-selection input. Gate-by-gate
|
|
345
|
+
* disposition, the three-surface matrix and the relative-target
|
|
346
|
+
* canonicalization limitation (#4023): content/docs/hook-root-admission.md.
|
|
333
347
|
*/
|
|
334
348
|
export function admitEffectiveHookRoot(payloadRoot, writeTarget, runGit) {
|
|
335
349
|
const payload = normalizeHookProjectRoot(payloadRoot);
|
|
@@ -787,6 +801,13 @@ function inspectMutationGates(input, toolName, seams, options) {
|
|
|
787
801
|
? { root: payloadRoot, foreign: false, candidate: null, refusal: null }
|
|
788
802
|
: admitMutationTargetSet(payloadRoot, mutationTargets, dispatchGit);
|
|
789
803
|
const effectiveRoot = admission.root;
|
|
804
|
+
if (options.observation !== undefined) {
|
|
805
|
+
if (!options.observation.effectiveRoots.includes(effectiveRoot)) {
|
|
806
|
+
options.observation.effectiveRoots.push(effectiveRoot);
|
|
807
|
+
}
|
|
808
|
+
if (admission.foreign)
|
|
809
|
+
options.observation.foreignTarget = true;
|
|
810
|
+
}
|
|
790
811
|
const rootsNote = ` ${formatHookRootNote(payloadRoot, effectiveRoot)}`;
|
|
791
812
|
if (admission.foreign) {
|
|
792
813
|
const candidate = admission.candidate ?? "<none>";
|
|
@@ -1224,7 +1245,7 @@ function decideGitDestructive(input, toolName) {
|
|
|
1224
1245
|
* Authorize recognized in-repo Shell file-write dests through inspectMutationGates (#3983 / #3987).
|
|
1225
1246
|
* Deny is returned; allow falls through so dest-forms and push/merge still run.
|
|
1226
1247
|
*/
|
|
1227
|
-
function decideShellWriteReissue(input, toolName, seams) {
|
|
1248
|
+
function decideShellWriteReissue(input, toolName, seams, observation) {
|
|
1228
1249
|
const command = hookShellCommand(input.payload);
|
|
1229
1250
|
if (command === null)
|
|
1230
1251
|
return null;
|
|
@@ -1245,6 +1266,7 @@ function decideShellWriteReissue(input, toolName, seams) {
|
|
|
1245
1266
|
};
|
|
1246
1267
|
const destDecision = inspectMutationGates(destInput, toolName, seams, {
|
|
1247
1268
|
proposedLifecycleExempt: true,
|
|
1269
|
+
observation,
|
|
1248
1270
|
});
|
|
1249
1271
|
if (destDecision.verdict === "deny")
|
|
1250
1272
|
return destDecision;
|
|
@@ -1255,7 +1277,7 @@ function decideShellWriteReissue(input, toolName, seams) {
|
|
|
1255
1277
|
* Route recognized Shell dest-forms through inspectMutationGates, then push/merge.
|
|
1256
1278
|
* Dest-form allow is kept when runtime authority has nothing classifiable.
|
|
1257
1279
|
*/
|
|
1258
|
-
function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
|
|
1280
|
+
function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams, observation) {
|
|
1259
1281
|
const command = hookShellCommand(input.payload);
|
|
1260
1282
|
let destAllow = null;
|
|
1261
1283
|
let expansionDeny = null;
|
|
@@ -1287,6 +1309,7 @@ function decideShellDestFormsThenRuntimeAuthority(input, toolName, seams) {
|
|
|
1287
1309
|
};
|
|
1288
1310
|
const destDecision = inspectMutationGates(destInput, toolName, seams, {
|
|
1289
1311
|
proposedLifecycleExempt: true,
|
|
1312
|
+
observation,
|
|
1290
1313
|
});
|
|
1291
1314
|
if (destDecision.verdict === "deny")
|
|
1292
1315
|
return destDecision;
|
|
@@ -1463,8 +1486,55 @@ function attachLifecycleIdentityRewrite(input, toolName, decision, seams) {
|
|
|
1463
1486
|
}
|
|
1464
1487
|
return { ...decision, updatedInput: rewrite.updatedInput };
|
|
1465
1488
|
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Renew the owner's occupancy lease from a hook event that already proved the
|
|
1491
|
+
* owner is present (#3987).
|
|
1492
|
+
*
|
|
1493
|
+
* Deliberately runs AFTER the decision and never feeds it. Two reasons: a
|
|
1494
|
+
* liveness re-stamp must not be able to change a verdict, and re-stamping first
|
|
1495
|
+
* would reset the shared age floor and so suppress the mutation gate's own
|
|
1496
|
+
* `markWrite = true` refresh — leaving an actively-writing owner recorded as
|
|
1497
|
+
* having no recorded write. Failure is swallowed: liveness is bookkeeping, and
|
|
1498
|
+
* a lease that cannot be renewed simply ages out as it did before.
|
|
1499
|
+
*/
|
|
1500
|
+
function restampOwnerLiveness(input, seams, observation) {
|
|
1501
|
+
if (input.event !== "tool.before")
|
|
1502
|
+
return;
|
|
1503
|
+
// A refused admission proves no tree at all, so there is nothing to renew.
|
|
1504
|
+
if (observation.foreignTarget)
|
|
1505
|
+
return;
|
|
1506
|
+
try {
|
|
1507
|
+
const actor = resolveMutationActor(input, input.environ ?? process.env);
|
|
1508
|
+
const restamp = seams.restampOwnerLiveness ?? restampOwnerLivenessOnHookEvent;
|
|
1509
|
+
// The trees occupancy was authorized against, not the payload root. Each
|
|
1510
|
+
// was admitted on its own, so each is renewed on its own.
|
|
1511
|
+
const roots = observation.effectiveRoots.length > 0
|
|
1512
|
+
? observation.effectiveRoots
|
|
1513
|
+
: [resolve(input.projectRoot)];
|
|
1514
|
+
for (const projectRoot of roots) {
|
|
1515
|
+
restamp({
|
|
1516
|
+
projectRoot,
|
|
1517
|
+
ownerSessionId: actor.sessionId,
|
|
1518
|
+
hostAuthoritative: actor.hostAuthoritative && actor.issue === null,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
catch {
|
|
1523
|
+
/* liveness is best-effort bookkeeping; never let it affect a verdict */
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1466
1526
|
/** Decide a normalized event using only the P0 direct-write policy. */
|
|
1467
1527
|
export function decideHook(input, seams = {}) {
|
|
1528
|
+
const observation = { effectiveRoots: [], foreignTarget: false };
|
|
1529
|
+
const decision = routeHookDecision(input, seams, observation);
|
|
1530
|
+
// Skipped for the #3039 kill-switch and #2926 opt-out, which short-circuit
|
|
1531
|
+
// before any Directive enforcement runs — including this bookkeeping.
|
|
1532
|
+
if (decision.code !== "directive-disabled" && decision.code !== "session-start-disabled") {
|
|
1533
|
+
restampOwnerLiveness(input, seams, observation);
|
|
1534
|
+
}
|
|
1535
|
+
return decision;
|
|
1536
|
+
}
|
|
1537
|
+
function routeHookDecision(input, seams, observation) {
|
|
1468
1538
|
const projectRoot = resolve(input.projectRoot);
|
|
1469
1539
|
// #3039: local (untracked) `.deft-directive-disable` wins for enforcement
|
|
1470
1540
|
// short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
|
|
@@ -1657,7 +1727,10 @@ export function decideHook(input, seams = {}) {
|
|
|
1657
1727
|
scopePath: null,
|
|
1658
1728
|
};
|
|
1659
1729
|
}
|
|
1660
|
-
return inspectMutationGates(input, toolName, seams, {
|
|
1730
|
+
return inspectMutationGates(input, toolName, seams, {
|
|
1731
|
+
proposedLifecycleExempt: false,
|
|
1732
|
+
observation,
|
|
1733
|
+
});
|
|
1661
1734
|
}
|
|
1662
1735
|
// Tree-wide destructive git (#3917): always-on, independent of shellDestForms.
|
|
1663
1736
|
// Dest-forms (#3438): recognized product mutations share inspectMutationGates
|
|
@@ -1668,11 +1741,11 @@ export function decideHook(input, seams = {}) {
|
|
|
1668
1741
|
if (destructive !== null) {
|
|
1669
1742
|
return attachLifecycleIdentityRewrite(input, toolName, destructive, seams);
|
|
1670
1743
|
}
|
|
1671
|
-
const writeReissue = decideShellWriteReissue(input, toolName, seams);
|
|
1744
|
+
const writeReissue = decideShellWriteReissue(input, toolName, seams, observation);
|
|
1672
1745
|
if (writeReissue !== null) {
|
|
1673
1746
|
return attachLifecycleIdentityRewrite(input, toolName, writeReissue, seams);
|
|
1674
1747
|
}
|
|
1675
|
-
const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams);
|
|
1748
|
+
const decision = decideShellDestFormsThenRuntimeAuthority(input, toolName, seams, observation);
|
|
1676
1749
|
return attachLifecycleIdentityRewrite(input, toolName, decision, seams);
|
|
1677
1750
|
}
|
|
1678
1751
|
// Classifiable MCP: enforce scopes.push / scopes.merge (#2711).
|
|
@@ -1693,7 +1766,10 @@ export function decideHook(input, seams = {}) {
|
|
|
1693
1766
|
scopePath: null,
|
|
1694
1767
|
};
|
|
1695
1768
|
}
|
|
1696
|
-
return inspectMutationGates(input, toolName, seams, {
|
|
1769
|
+
return inspectMutationGates(input, toolName, seams, {
|
|
1770
|
+
proposedLifecycleExempt: true,
|
|
1771
|
+
observation,
|
|
1772
|
+
});
|
|
1697
1773
|
}
|
|
1698
1774
|
/**
|
|
1699
1775
|
* Soft AGENTS re-bind text for host injection when decision carries soft path (#3171).
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./dest-form.js";
|
|
|
4
4
|
export * from "./dispatcher.js";
|
|
5
5
|
export * from "./fixtures/index.js";
|
|
6
6
|
export * from "./git-destructive-log.js";
|
|
7
|
+
export * from "./owner-liveness.js";
|
|
7
8
|
export * from "./scope.js";
|
|
8
9
|
export * from "./shell-write-targets.js";
|
|
9
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/hooks/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./dest-form.js";
|
|
|
4
4
|
export * from "./dispatcher.js";
|
|
5
5
|
export * from "./fixtures/index.js";
|
|
6
6
|
export * from "./git-destructive-log.js";
|
|
7
|
+
export * from "./owner-liveness.js";
|
|
7
8
|
export * from "./scope.js";
|
|
8
9
|
export * from "./shell-write-targets.js";
|
|
9
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner liveness on non-write hook activity (#3987).
|
|
3
|
+
*
|
|
4
|
+
* The occupancy lease renews on one signal: a gated product write
|
|
5
|
+
* (`evaluateOccupancyWriteGate`, refresh path, #3599). After #3990 widened the
|
|
6
|
+
* PreToolUse matchers, coverage is necessary and still not sufficient, because
|
|
7
|
+
* eligibility — not coverage — is what keeps shell traffic off that path. Only
|
|
8
|
+
* five write verbs are recognized, and a dest carrying `$` / `*` / `?`, a temp
|
|
9
|
+
* path, an out-of-root target, or any compound command is refused before the
|
|
10
|
+
* gate re-stamps. `cd <root>; <command>` is compound, and the mandated Windows
|
|
11
|
+
* body-file flow (#2646 / #2744) is compound, `$`-bearing and temp-targeted at
|
|
12
|
+
* once. So an owner can commit and push all session and still starve its own
|
|
13
|
+
* lease, which is the data-loss half of #3987.
|
|
14
|
+
*
|
|
15
|
+
* The remedy is the one `occupancy.ts` already names in the
|
|
16
|
+
* `OCCUPANCY_MAX_LEASE_MS` docblock: "refresh on non-write activity". The hook
|
|
17
|
+
* fires on every matched tool call and resolves the actor from the host
|
|
18
|
+
* payload, so the owner's presence is already proven there — no new identity
|
|
19
|
+
* has to travel anywhere.
|
|
20
|
+
*
|
|
21
|
+
* Bounds, each one load-bearing (5471374558 F4):
|
|
22
|
+
*
|
|
23
|
+
* - Host-authoritative actor only. An ambient inherited `DEFT_SESSION_ID` is
|
|
24
|
+
* never enough. On Grok the owner id is a hook-sibling variable, and
|
|
25
|
+
* publishing it so an agent shell could present it would hand lease renewal
|
|
26
|
+
* to every descendant process — the widening the arc withdrew.
|
|
27
|
+
* - Owner only, via `heartbeatOccupancy`: it never claims, never mints, never
|
|
28
|
+
* resurrects, and refuses a foreign or expired lease. Reusing it keeps one
|
|
29
|
+
* renewal path with one set of refusal semantics.
|
|
30
|
+
* - `markWrite` stays false, so `last_write_at` still means "a product write
|
|
31
|
+
* was recorded" and `no recorded write` stays honest for a would-be stealer.
|
|
32
|
+
* - Keyed on the lease's own worktree path, so a record describing another tree
|
|
33
|
+
* is not renewed from this one. The caller supplies the tree the mutation
|
|
34
|
+
* gates authorized against — a linked worktree, not the payload root — so a
|
|
35
|
+
* worktree write renews the worktree's lease rather than the primary's.
|
|
36
|
+
* - `claimed_at` is untouched, so `OCCUPANCY_MAX_LEASE_MS` — 12 hours — is
|
|
37
|
+
* unmoved. Liveness renewal cannot outlive the absolute cap.
|
|
38
|
+
* - Runs after the decision, never as an input to it. A liveness re-stamp must
|
|
39
|
+
* not change any verdict, and re-stamping before the mutation gates would
|
|
40
|
+
* suppress their own `markWrite = true` refresh by resetting the age floor.
|
|
41
|
+
*
|
|
42
|
+
* Bound this deliberately does not cross: a call with no write target proves
|
|
43
|
+
* only the tree the host named. `projectRootFromHookPayload` takes that from
|
|
44
|
+
* the payload's own `cwd`-class fields, so a session working inside a linked
|
|
45
|
+
* worktree names the worktree and renews it; a session whose host reports the
|
|
46
|
+
* primary checkout renews the primary. When those differ and nothing in the
|
|
47
|
+
* payload says which tree the work is in, this renews neither by guessing —
|
|
48
|
+
* keeping a lease alive for a tree nobody occupies is a widening, not a fix,
|
|
49
|
+
* and the TTL reclaiming an unused tree is the behaviour the lease exists for.
|
|
50
|
+
* `deft occupancy:heartbeat --session-id <owner>` stays the explicit path for
|
|
51
|
+
* a session whose tree the host does not report.
|
|
52
|
+
*/
|
|
53
|
+
import type { LockDeps } from "../slice/lock.js";
|
|
54
|
+
export type OwnerLivenessSkipReason =
|
|
55
|
+
/** No host-authoritative owner resolved; ambient identity is not accepted. */
|
|
56
|
+
"no-host-authoritative-owner"
|
|
57
|
+
/** Nothing to renew: no lease, or the lease is expired or age-capped. */
|
|
58
|
+
| "no-live-lease"
|
|
59
|
+
/** A lease exists but this actor is not its owner. */
|
|
60
|
+
| "not-owner"
|
|
61
|
+
/** The lease record describes a different worktree than the one hooked. */
|
|
62
|
+
| "foreign-worktree"
|
|
63
|
+
/** Younger than the shared re-stamp floor; renewing would rewrite per keystroke. */
|
|
64
|
+
| "within-refresh-floor"
|
|
65
|
+
/** The lock was busy or the lease changed under us; the lease is left alone. */
|
|
66
|
+
| "refresh-unavailable";
|
|
67
|
+
export type OwnerLivenessOutcome = {
|
|
68
|
+
readonly restamped: true;
|
|
69
|
+
readonly sessionId: string;
|
|
70
|
+
readonly heartbeatAt: Date;
|
|
71
|
+
} | {
|
|
72
|
+
readonly restamped: false;
|
|
73
|
+
readonly reason: OwnerLivenessSkipReason;
|
|
74
|
+
};
|
|
75
|
+
export interface OwnerLivenessInput {
|
|
76
|
+
/** Tree the hook fired against; also the tree whose lease may be renewed. */
|
|
77
|
+
readonly projectRoot: string;
|
|
78
|
+
/** Owner id the host payload resolved to, or undefined when none did. */
|
|
79
|
+
readonly ownerSessionId: string | undefined;
|
|
80
|
+
/** False for ambient/environment identity — such an actor never renews. */
|
|
81
|
+
readonly hostAuthoritative: boolean;
|
|
82
|
+
readonly now?: Date;
|
|
83
|
+
readonly lockDeps?: LockDeps;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Renew the owner's lease from a hook event that proved the owner is alive.
|
|
87
|
+
*
|
|
88
|
+
* Pure side effect on `occupancy.json`: the caller's decision is already made
|
|
89
|
+
* and this never alters it. Every refusal path leaves the lease untouched.
|
|
90
|
+
*/
|
|
91
|
+
export declare function restampOwnerLivenessOnHookEvent(input: OwnerLivenessInput): OwnerLivenessOutcome;
|
|
92
|
+
//# sourceMappingURL=owner-liveness.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner liveness on non-write hook activity (#3987).
|
|
3
|
+
*
|
|
4
|
+
* The occupancy lease renews on one signal: a gated product write
|
|
5
|
+
* (`evaluateOccupancyWriteGate`, refresh path, #3599). After #3990 widened the
|
|
6
|
+
* PreToolUse matchers, coverage is necessary and still not sufficient, because
|
|
7
|
+
* eligibility — not coverage — is what keeps shell traffic off that path. Only
|
|
8
|
+
* five write verbs are recognized, and a dest carrying `$` / `*` / `?`, a temp
|
|
9
|
+
* path, an out-of-root target, or any compound command is refused before the
|
|
10
|
+
* gate re-stamps. `cd <root>; <command>` is compound, and the mandated Windows
|
|
11
|
+
* body-file flow (#2646 / #2744) is compound, `$`-bearing and temp-targeted at
|
|
12
|
+
* once. So an owner can commit and push all session and still starve its own
|
|
13
|
+
* lease, which is the data-loss half of #3987.
|
|
14
|
+
*
|
|
15
|
+
* The remedy is the one `occupancy.ts` already names in the
|
|
16
|
+
* `OCCUPANCY_MAX_LEASE_MS` docblock: "refresh on non-write activity". The hook
|
|
17
|
+
* fires on every matched tool call and resolves the actor from the host
|
|
18
|
+
* payload, so the owner's presence is already proven there — no new identity
|
|
19
|
+
* has to travel anywhere.
|
|
20
|
+
*
|
|
21
|
+
* Bounds, each one load-bearing (5471374558 F4):
|
|
22
|
+
*
|
|
23
|
+
* - Host-authoritative actor only. An ambient inherited `DEFT_SESSION_ID` is
|
|
24
|
+
* never enough. On Grok the owner id is a hook-sibling variable, and
|
|
25
|
+
* publishing it so an agent shell could present it would hand lease renewal
|
|
26
|
+
* to every descendant process — the widening the arc withdrew.
|
|
27
|
+
* - Owner only, via `heartbeatOccupancy`: it never claims, never mints, never
|
|
28
|
+
* resurrects, and refuses a foreign or expired lease. Reusing it keeps one
|
|
29
|
+
* renewal path with one set of refusal semantics.
|
|
30
|
+
* - `markWrite` stays false, so `last_write_at` still means "a product write
|
|
31
|
+
* was recorded" and `no recorded write` stays honest for a would-be stealer.
|
|
32
|
+
* - Keyed on the lease's own worktree path, so a record describing another tree
|
|
33
|
+
* is not renewed from this one. The caller supplies the tree the mutation
|
|
34
|
+
* gates authorized against — a linked worktree, not the payload root — so a
|
|
35
|
+
* worktree write renews the worktree's lease rather than the primary's.
|
|
36
|
+
* - `claimed_at` is untouched, so `OCCUPANCY_MAX_LEASE_MS` — 12 hours — is
|
|
37
|
+
* unmoved. Liveness renewal cannot outlive the absolute cap.
|
|
38
|
+
* - Runs after the decision, never as an input to it. A liveness re-stamp must
|
|
39
|
+
* not change any verdict, and re-stamping before the mutation gates would
|
|
40
|
+
* suppress their own `markWrite = true` refresh by resetting the age floor.
|
|
41
|
+
*
|
|
42
|
+
* Bound this deliberately does not cross: a call with no write target proves
|
|
43
|
+
* only the tree the host named. `projectRootFromHookPayload` takes that from
|
|
44
|
+
* the payload's own `cwd`-class fields, so a session working inside a linked
|
|
45
|
+
* worktree names the worktree and renews it; a session whose host reports the
|
|
46
|
+
* primary checkout renews the primary. When those differ and nothing in the
|
|
47
|
+
* payload says which tree the work is in, this renews neither by guessing —
|
|
48
|
+
* keeping a lease alive for a tree nobody occupies is a widening, not a fix,
|
|
49
|
+
* and the TTL reclaiming an unused tree is the behaviour the lease exists for.
|
|
50
|
+
* `deft occupancy:heartbeat --session-id <owner>` stays the explicit path for
|
|
51
|
+
* a session whose tree the host does not report.
|
|
52
|
+
*/
|
|
53
|
+
import { resolve } from "node:path";
|
|
54
|
+
import { heartbeatOccupancy, isOccupancyExpired, OCCUPANCY_REFRESH_AFTER_MS, readOccupancy, } from "../session/occupancy.js";
|
|
55
|
+
function skip(reason) {
|
|
56
|
+
return { restamped: false, reason };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Renew the owner's lease from a hook event that proved the owner is alive.
|
|
60
|
+
*
|
|
61
|
+
* Pure side effect on `occupancy.json`: the caller's decision is already made
|
|
62
|
+
* and this never alters it. Every refusal path leaves the lease untouched.
|
|
63
|
+
*/
|
|
64
|
+
export function restampOwnerLivenessOnHookEvent(input) {
|
|
65
|
+
if (!input.hostAuthoritative)
|
|
66
|
+
return skip("no-host-authoritative-owner");
|
|
67
|
+
const owner = input.ownerSessionId?.trim() ?? "";
|
|
68
|
+
if (owner.length === 0)
|
|
69
|
+
return skip("no-host-authoritative-owner");
|
|
70
|
+
const root = resolve(input.projectRoot);
|
|
71
|
+
const now = input.now ?? new Date();
|
|
72
|
+
const record = readOccupancy(root);
|
|
73
|
+
if (record === null || isOccupancyExpired(record, now))
|
|
74
|
+
return skip("no-live-lease");
|
|
75
|
+
// Owner only. A granted member's presence is not the owner's presence, and
|
|
76
|
+
// `heartbeatOccupancy` refuses it anyway — checking here keeps the reason honest.
|
|
77
|
+
if (record.sessionId !== owner)
|
|
78
|
+
return skip("not-owner");
|
|
79
|
+
if (resolve(record.worktreePath) !== root)
|
|
80
|
+
return skip("foreign-worktree");
|
|
81
|
+
// Same floor the write gate uses, for the same reason: the hook runs on a
|
|
82
|
+
// large fraction of tool calls, so an unconditional renew would rewrite the
|
|
83
|
+
// lease file continuously without lengthening the safe window at all.
|
|
84
|
+
if (now.getTime() - record.heartbeatAt.getTime() < OCCUPANCY_REFRESH_AFTER_MS) {
|
|
85
|
+
return skip("within-refresh-floor");
|
|
86
|
+
}
|
|
87
|
+
const beat = heartbeatOccupancy(root, {
|
|
88
|
+
sessionId: owner,
|
|
89
|
+
// Never fall back to an ambient owner: the resolved host identity is the
|
|
90
|
+
// only thing that proves who is here.
|
|
91
|
+
env: {},
|
|
92
|
+
now,
|
|
93
|
+
lockDeps: input.lockDeps,
|
|
94
|
+
});
|
|
95
|
+
if (beat.code !== 0 || beat.record === null)
|
|
96
|
+
return skip("refresh-unavailable");
|
|
97
|
+
return {
|
|
98
|
+
restamped: true,
|
|
99
|
+
sessionId: beat.record.sessionId,
|
|
100
|
+
heartbeatAt: beat.record.heartbeatAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=owner-liveness.js.map
|