@deftai/directive-core 0.91.0 → 0.92.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 (46) hide show
  1. package/dist/cache/fetch.js +3 -2
  2. package/dist/cache/io.d.ts +13 -2
  3. package/dist/cache/io.js +36 -7
  4. package/dist/cache/operations.js +5 -3
  5. package/dist/doctor/main.js +37 -0
  6. package/dist/hooks/dispatcher.d.ts +6 -1
  7. package/dist/hooks/dispatcher.js +30 -0
  8. package/dist/init-deposit/gitignore.js +3 -0
  9. package/dist/init-deposit/hygiene.d.ts +43 -1
  10. package/dist/init-deposit/hygiene.js +112 -10
  11. package/dist/init-deposit/scaffold.js +3 -1
  12. package/dist/orchestration/probe-session.d.ts +5 -1
  13. package/dist/orchestration/probe-session.js +24 -13
  14. package/dist/platform/agents-md.js +1 -1
  15. package/dist/policy/deft-directive-disable.d.ts +86 -0
  16. package/dist/policy/deft-directive-disable.js +167 -0
  17. package/dist/policy/delivery-branch.d.ts +33 -0
  18. package/dist/policy/delivery-branch.js +124 -0
  19. package/dist/policy/index.d.ts +2 -0
  20. package/dist/policy/index.js +17 -1
  21. package/dist/scope/brief-io.d.ts +3 -1
  22. package/dist/scope/brief-io.js +5 -3
  23. package/dist/scope/delivery-evidence.d.ts +112 -0
  24. package/dist/scope/delivery-evidence.js +419 -0
  25. package/dist/scope/index.d.ts +1 -0
  26. package/dist/scope/index.js +1 -0
  27. package/dist/scope/main.d.ts +5 -0
  28. package/dist/scope/main.js +98 -3
  29. package/dist/scope/registry-artifact-sync.js +4 -1
  30. package/dist/scope/transition.d.ts +11 -1
  31. package/dist/scope/transition.js +30 -4
  32. package/dist/session/ritual-sentinel.js +21 -12
  33. package/dist/session/session-start-hook.d.ts +3 -0
  34. package/dist/session/session-start-hook.js +21 -0
  35. package/dist/session/session-start.js +31 -0
  36. package/dist/swarm/complete-cohort.d.ts +20 -1
  37. package/dist/swarm/complete-cohort.js +56 -9
  38. package/dist/swarm/finalize-cohort-cli.js +9 -0
  39. package/dist/swarm/finalize-cohort.d.ts +8 -0
  40. package/dist/swarm/finalize-cohort.js +217 -21
  41. package/dist/user-config/experimental-rules.d.ts +43 -0
  42. package/dist/user-config/experimental-rules.js +162 -0
  43. package/dist/user-config/index.d.ts +1 -0
  44. package/dist/user-config/index.js +1 -0
  45. package/dist/verify-source/contained-writes.js +1 -1
  46. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { dirname, join, resolve } from "node:path";
3
3
  import { ContainedWriteError, containedWrite } from "../fs/contained-write.js";
4
4
  import { GhRestError, InvalidRepoError, restIssueView, runGhApi, } from "../scm/gh-rest.js";
5
5
  import { DEFAULT_BATCH_SIZE, DEFAULT_DELAY_MS } from "./constants.js";
@@ -45,7 +45,8 @@ export function writeOpenInventoryStamp(options) {
45
45
  fetched_at: utcIso(clock, fetchedAt),
46
46
  open_count: 0,
47
47
  };
48
- atomicWriteText(openInventoryStampPath(options.cacheRoot, options.source, options.repo), `${JSON.stringify(payload)}\n`);
48
+ // #3042: contain stamp write against project root (parent of cacheRoot).
49
+ atomicWriteText(openInventoryStampPath(options.cacheRoot, options.source, options.repo), `${JSON.stringify(payload)}\n`, { projectRoot: dirname(resolve(options.cacheRoot)) });
49
50
  }
50
51
  export const REST_MAX_PER_PAGE = 100;
51
52
  export const REST_PAGINATION_MAX_PAGES = 100;
@@ -1,9 +1,20 @@
1
1
  import { tmpdir } from "node:os";
2
+ export interface AtomicWriteTextOptions {
3
+ /**
4
+ * Project / checkout root used as the containment boundary (#3042).
5
+ * When set, refuses symlink parents and out-of-root targets before temp+rename.
6
+ * Prefer always passing this for product sinks (brief stay-path, agents refresh, cache).
7
+ * When omitted, falls back to parent-dir containment (legacy test / low-risk helpers).
8
+ */
9
+ readonly projectRoot?: string;
10
+ }
2
11
  /**
3
12
  * Write text via tempfile + rename (mirrors Python `_atomic_write_text`).
4
- * #2951 Phase 2: temp payload write uses containedWrite under the parent dir.
13
+ * #2951 Phase 2: temp payload write uses containedWrite.
14
+ * #3042: when `projectRoot` is set, containment root is projectRoot (not dirname(path))
15
+ * so force-added lifecycle / cache directory symlinks fail closed.
5
16
  */
6
- export declare function atomicWriteText(path: string, text: string): void;
17
+ export declare function atomicWriteText(path: string, text: string, options?: AtomicWriteTextOptions): void;
7
18
  /**
8
19
  * Append one JSON audit record (mirrors `_append_audit`).
9
20
  * #2951 Phase 2: product write sink routes through containedWrite.
package/dist/cache/io.js CHANGED
@@ -3,24 +3,53 @@ import { mkdirSync, renameSync, rmSync, statSync, utimesSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { containedWrite } from "../fs/contained-write.js";
6
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
6
7
  import { pythonJsonLine } from "./json.js";
7
8
  /**
8
9
  * Write text via tempfile + rename (mirrors Python `_atomic_write_text`).
9
- * #2951 Phase 2: temp payload write uses containedWrite under the parent dir.
10
+ * #2951 Phase 2: temp payload write uses containedWrite.
11
+ * #3042: when `projectRoot` is set, containment root is projectRoot (not dirname(path))
12
+ * so force-added lifecycle / cache directory symlinks fail closed.
10
13
  */
11
- export function atomicWriteText(path, text) {
12
- const dir = dirname(path);
14
+ export function atomicWriteText(path, text, options = {}) {
15
+ const targetAbs = resolve(path);
16
+ const dir = dirname(targetAbs);
17
+ const tmpBase = `${basename(targetAbs)}.${randomBytes(4).toString("hex")}.tmp`;
18
+ const tmp = join(dir, tmpBase);
19
+ if (options.projectRoot !== undefined) {
20
+ // Contain against projectRoot (parent-as-root fix #3042 / authz writeJsonContained pattern).
21
+ const root = resolve(options.projectRoot);
22
+ assertWriteTargetSafe(root, targetAbs);
23
+ try {
24
+ containedWrite({
25
+ root,
26
+ target: tmp,
27
+ data: text,
28
+ mode: "create",
29
+ });
30
+ renameSync(tmp, targetAbs);
31
+ }
32
+ catch (err) {
33
+ try {
34
+ rmSync(tmp, { force: true });
35
+ }
36
+ catch {
37
+ /* v8 ignore next -- best-effort cleanup */
38
+ }
39
+ throw err;
40
+ }
41
+ return;
42
+ }
43
+ // Legacy: parent-dir containment when no projectRoot (unit helpers / transitional call sites).
13
44
  mkdirSync(dir, { recursive: true });
14
- const tmpName = `${basename(path)}.${randomBytes(4).toString("hex")}.tmp`;
15
- const tmp = join(dir, tmpName);
16
45
  try {
17
46
  containedWrite({
18
47
  root: resolve(dir),
19
- target: tmpName,
48
+ target: tmpBase,
20
49
  data: text,
21
50
  mode: "create",
22
51
  });
23
- renameSync(tmp, path);
52
+ renameSync(tmp, targetAbs);
24
53
  }
25
54
  catch (err) {
26
55
  try {
@@ -129,14 +129,16 @@ export function cachePut(source, key, raw, options = {}) {
129
129
  incomingBytes: incomingDelta,
130
130
  });
131
131
  }
132
- atomicWriteText(join(edir, "raw.json"), rawText);
132
+ // #3042: contain cache entry writes against project root (parent of cacheRoot).
133
+ const projectRoot = dirname(resolve(cacheRoot));
134
+ atomicWriteText(join(edir, "raw.json"), rawText, { projectRoot });
133
135
  const authoritativeSize = fileSize(join(edir, "raw.json"));
134
136
  const rendered = renderContent(source, raw);
135
137
  const scanResult = scan(rendered, utcIso(clock, fetched));
136
138
  const contentPath = join(edir, "content.md");
137
139
  let contentWritten = false;
138
140
  if (scanResult.passed) {
139
- atomicWriteText(contentPath, scanResult.transformed_content);
141
+ atomicWriteText(contentPath, scanResult.transformed_content, { projectRoot });
140
142
  contentWritten = true;
141
143
  }
142
144
  else if (existsSync(contentPath)) {
@@ -158,7 +160,7 @@ export function cachePut(source, key, raw, options = {}) {
158
160
  clock,
159
161
  });
160
162
  validateMeta(meta);
161
- atomicWriteText(join(edir, "meta.json"), pythonJsonDump(meta));
163
+ atomicWriteText(join(edir, "meta.json"), pythonJsonDump(meta), { projectRoot });
162
164
  appendAudit({
163
165
  event: "cache:put",
164
166
  source,
@@ -4,6 +4,7 @@ import { VBRIEF_VERSION } from "@deftai/directive-types";
4
4
  import { evaluate as evaluateAgentsMdAdvisory } from "../agents-md-advisory/evaluate.js";
5
5
  import { contentRoot } from "../content-root.js";
6
6
  import { resolveProjectDefinitionPath } from "../layout/resolve.js";
7
+ import { DEFT_DIRECTIVE_DISABLE_FLAG_NAME, DEFT_DIRECTIVE_DISABLE_STATUS, DEFT_DIRECTIVE_DISABLE_TRACKED_WARNING, detectDeftDirectiveDisable, formatDeftDirectiveDisableMessage, isDeftDirectiveDisableActive, } from "../policy/deft-directive-disable.js";
7
8
  import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_FLAG_NAME, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY, } from "../policy/no-deft-directive.js";
8
9
  import { describeShadowedPlanExtension, detectShadowedPlanExtensions, } from "../policy/plan-extensions.js";
9
10
  import { loadProjectDefinition } from "../policy/resolve.js";
@@ -70,6 +71,42 @@ export function cmdDoctor(args, seams = {}) {
70
71
  const consumerContext = resolve(projectRoot) !== resolve(frameworkRoot);
71
72
  const whichFn = seams.whichFn ?? defaultWhich;
72
73
  const nowFn = seams.now ?? (() => new Date());
74
+ // #3039: temporary test kill-switch. Active (untracked) → disabled short-circuit.
75
+ // Tracked/committed flag → warn only and continue normal doctor (no enforcement bypass).
76
+ const killSwitch = detectDeftDirectiveDisable(projectRoot, { skipTrackedCache: true });
77
+ if (killSwitch.present &&
78
+ killSwitch.trackedByGit &&
79
+ !isDeftDirectiveDisableActive(projectRoot, { skipTrackedCache: true })) {
80
+ if (!jsonMode && !quietMode) {
81
+ process.stderr.write(`${DEFT_DIRECTIVE_DISABLE_TRACKED_WARNING}\n`);
82
+ }
83
+ // Continue into full doctor; do not short-circuit.
84
+ }
85
+ else if (isDeftDirectiveDisableActive(projectRoot, { skipTrackedCache: true })) {
86
+ const optOutAlso = detectNoDeftDirective(projectRoot);
87
+ const message = formatDeftDirectiveDisableMessage({
88
+ permanentOptOutAlsoPresent: optOutAlso.present,
89
+ trackedByGit: false,
90
+ });
91
+ if (jsonMode) {
92
+ const payload = {
93
+ status: DEFT_DIRECTIVE_DISABLE_STATUS,
94
+ disabled: true,
95
+ disabled_via: DEFT_DIRECTIVE_DISABLE_FLAG_NAME,
96
+ kill_switch: true,
97
+ inconsistent: false,
98
+ deposit_present: killSwitch.depositPresent,
99
+ tracked_by_git: false,
100
+ permanent_opt_out_also_present: optOutAlso.present,
101
+ message,
102
+ };
103
+ process.stdout.write(`${pythonJsonDump(payload)}\n`);
104
+ }
105
+ else if (!quietMode) {
106
+ process.stdout.write(`${message}\n`);
107
+ }
108
+ return 0;
109
+ }
73
110
  // #2926: official root opt-out — short-circuit Directive doctor when clean;
74
111
  // diagnose flag+deposit inconsistency (warn; exit dirty).
75
112
  const optOut = detectNoDeftDirective(projectRoot);
@@ -1,4 +1,5 @@
1
1
  import { type AuthzState, type HumanOriginGrant } from "../authz/index.js";
2
+ import { detectDeftDirectiveDisable } from "../policy/deft-directive-disable.js";
2
3
  import { detectNoDeftDirective } from "../policy/no-deft-directive.js";
3
4
  import { type RuntimeAuthorityPolicy } from "../policy/runtime-authority.js";
4
5
  import { type VerifyResult } from "../session/verify-session-ritual.js";
@@ -16,7 +17,9 @@ export type CompactHookHost = (typeof COMPACT_HOOK_HOSTS)[number];
16
17
  /** Hosts without a native compact hook surface — deposit skips cleanly (#2113). */
17
18
  export declare const COMPACT_HOOK_SKIP_HOSTS: readonly ["codex"];
18
19
  export type HookVerdict = "allow" | "deny";
19
- 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"
20
+ export type HookDecisionCode = "session-start" | "session-start-disabled" | "session-start-degraded"
21
+ /** Enforcement skipped: root `.deft-directive-disable` test kill-switch (#3039). */
22
+ | "directive-disabled" | "session-compact-rearm" | "session-compact-rearm-degraded" | "session-compact-noop" | "not-direct-write" | "invalid-input"
20
23
  /** Host closed stdin with zero bytes — integration failure, not a policy gate (#2864). */
21
24
  | "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"
22
25
  /** Shell/MCP classifiable push/merge allowed under runtimeAuthority (#2711). */
@@ -56,6 +59,8 @@ export interface HookPolicySeams {
56
59
  };
57
60
  /** Test seam for #2926 root opt-out on SessionStart. */
58
61
  readonly detectNoDeftDirective?: typeof detectNoDeftDirective;
62
+ /** Test seam for #3039 root test kill-switch (precedence over #2926 for enforcement). */
63
+ readonly detectDeftDirectiveDisable?: typeof detectDeftDirectiveDisable;
59
64
  readonly markCompactStale?: (projectRoot: string) => {
60
65
  changed: boolean;
61
66
  statePath: string;
@@ -2,6 +2,7 @@ import { realpathSync } from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import { appendAuthzAudit, classifyHookAuthzOps, evaluateAuthzMutation, evidenceSatisfiesImplementationApproval, listActiveHumanGrants, loadAuthzStateResult, markGrantUsed, shouldConsumeSingleUseGrant, utcIso, } from "../authz/index.js";
4
4
  import { hasArtifactSuffix } from "../layout/resolve.js";
5
+ import { detectDeftDirectiveDisable, formatDeftDirectiveDisableMessage, isDeftDirectiveDisableActive, } from "../policy/deft-directive-disable.js";
5
6
  import { evaluateIntentCeilingFromEnv } from "../policy/intent-ceiling.js";
6
7
  import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, } from "../policy/no-deft-directive.js";
7
8
  import { classifyMcpTool, DEFAULT_RUNTIME_AUTHORITY_POLICY, evaluateRuntimeAuthorityDirectWrite, evaluateRuntimeAuthorityShellOp, listShellOps, loadRuntimeAuthorityFromProject, } from "../policy/runtime-authority.js";
@@ -570,6 +571,35 @@ function inspectMutationGates(input, toolName, seams, options) {
570
571
  /** Decide a normalized event using only the P0 direct-write policy. */
571
572
  export function decideHook(input, seams = {}) {
572
573
  const projectRoot = resolve(input.projectRoot);
574
+ // #3039: local (untracked) `.deft-directive-disable` wins for enforcement
575
+ // short-circuit (SessionStart / compact / PreToolUse). Deposit may remain.
576
+ // Tracked/committed flags do NOT bypass gates (repo-controlled content must
577
+ // not disable enforcement for clones) — doctor warns instead.
578
+ {
579
+ const detectKill = seams.detectDeftDirectiveDisable ?? detectDeftDirectiveDisable;
580
+ const kill = detectKill(projectRoot);
581
+ const killActive = seams.detectDeftDirectiveDisable !== undefined
582
+ ? kill.active
583
+ : isDeftDirectiveDisableActive(projectRoot);
584
+ if (killActive) {
585
+ const detectOptOut = seams.detectNoDeftDirective ?? detectNoDeftDirective;
586
+ const optOut = detectOptOut(projectRoot);
587
+ const message = formatDeftDirectiveDisableMessage({
588
+ permanentOptOutAlsoPresent: optOut.present,
589
+ trackedByGit: false,
590
+ });
591
+ return {
592
+ verdict: "allow",
593
+ code: input.event === "session.start" ? "session-start-disabled" : "directive-disabled",
594
+ event: input.event,
595
+ host: input.host,
596
+ toolName: null,
597
+ projectRoot,
598
+ message,
599
+ scopePath: null,
600
+ };
601
+ }
602
+ }
573
603
  if (input.event === "session.compact") {
574
604
  try {
575
605
  const result = (seams.markCompactStale ?? markRitualStaleAfterCompact)(projectRoot);
@@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "node:fs";
13
13
  import { join, resolve } from "node:path";
14
14
  import { containedWrite } from "../fs/contained-write.js";
15
15
  import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
16
+ import { DEFT_DIRECTIVE_DISABLE_GITIGNORE_LINE } from "../policy/deft-directive-disable.js";
16
17
  import { FORBIDDEN_BLANKET_EVAL_LINES, stripGitignoreInlineComment, } from "../triage/bootstrap/gitignore.js";
17
18
  /** Directory ignore entry for the hybrid deposit (greenfield only). */
18
19
  export const GITIGNORE_DEFT_CORE_LINE = ".deft/core/";
@@ -29,6 +30,8 @@ export const CANONICAL_GITIGNORE_BASELINE = [
29
30
  ".deft/ritual-state.json",
30
31
  ".deft/last-session.json",
31
32
  ".deft/routing.local.json",
33
+ // Temporary test/local kill-switch — must stay untracked (#3039).
34
+ DEFT_DIRECTIVE_DISABLE_GITIGNORE_LINE,
32
35
  "vbrief/.triage-cache/candidates.jsonl",
33
36
  "vbrief/.triage-cache/summary-history.jsonl",
34
37
  "vbrief/.triage-cache/scope-lifecycle.jsonl",
@@ -2,7 +2,13 @@
2
2
  * Scoped staging + installer-managed allowlist for TS-native init/update (#1453).
3
3
  *
4
4
  * Mirrors cmd/deft-install/hygiene.go + deposit.go installerManagedMatchers.
5
- * Refs #1576, #1453, #1430.
5
+ *
6
+ * CRITICAL (#1430 / #3030): the allowlist MUST honor the SPEC consumer-path
7
+ * denylist (`CONSUMER_GUARD_MUST_FIRE`). Consumer-authored PROJECT-DEFINITION
8
+ * and scope briefs are never installer-managed; if they reappear in
9
+ * `installerManagedMatchers()`, unit tests and deposit-time assert fail closed.
10
+ *
11
+ * Refs #1576, #1453, #1430, #3029, #3030.
6
12
  */
7
13
  import { type InitDepositIo } from "./constants.js";
8
14
  export declare const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
@@ -11,11 +17,47 @@ export interface InstallerManagedMatcher {
11
17
  readonly exact?: string;
12
18
  readonly prefix?: string;
13
19
  }
20
+ /**
21
+ * Consumer paths that MUST trip no-mixed-core-and-app when mixed with
22
+ * `.deft/core/**` (#1430 SPEC). These probe paths must never match
23
+ * `installerManagedMatchers()` / the deposited guard ERE.
24
+ *
25
+ * Legitimate installer scaffolding (xbrief/.deft-version, lifecycle .gitkeep,
26
+ * schemas/, migration/, xbrief.md) is NOT in this denylist — see #2277.
27
+ * Init may still *create* PROJECT-DEFINITION (#3013); create ≠ allowlist.
28
+ *
29
+ * Refs #3030, #3029, #1430.
30
+ */
31
+ export declare const CONSUMER_GUARD_MUST_FIRE: readonly string[];
14
32
  /** Single source of truth for installer-managed paths (#1440 / #1576). */
15
33
  export declare function installerManagedMatchers(): InstallerManagedMatcher[];
34
+ /**
35
+ * Fail closed when the installer-managed allowlist would exempt consumer
36
+ * PROJECT-DEFINITION or scope briefs (#3030 / #1430). Pure over `matchers` so
37
+ * tests can inject a bad matcher without mutating production state.
38
+ *
39
+ * Checks both:
40
+ * 1. Explicit probe paths in {@link CONSUMER_GUARD_MUST_FIRE}
41
+ * 2. Structural patterns (any exact consumer scope brief / PD; forbidden prefixes)
42
+ * so an unlisted `xbrief/active/another-scope.xbrief.json` matcher still fails.
43
+ */
44
+ export declare function assertInstallerAllowlistHonors1430(matchers?: readonly InstallerManagedMatcher[]): void;
16
45
  /** POSIX ERE alternation embedded in the deposited deft-core-guard workflow. */
17
46
  export declare function installerManagedGuardEre(): string;
18
47
  export declare function isInstallerManagedPath(path: string): boolean;
48
+ export interface MixedCoreAndAppClassification {
49
+ readonly core: string[];
50
+ readonly installerManaged: string[];
51
+ readonly app: string[];
52
+ /** True when both core and app are non-empty — the deposited guard fails. */
53
+ readonly wouldFail: boolean;
54
+ }
55
+ /**
56
+ * TS twin of Go `classifyChangedPaths` / deposited shell guard (#1430).
57
+ * Core = `.deft/core/**`; installer-managed = allowlist; app = everything else.
58
+ * Guard fails iff both core and app are non-empty.
59
+ */
60
+ export declare function classifyMixedCoreAndApp(changedPaths: readonly string[], matchers?: readonly InstallerManagedMatcher[]): MixedCoreAndAppClassification;
19
61
  export interface FrameworkStagePathsOptions {
20
62
  /**
21
63
  * Include the vendored `.deft/core` payload in the stage set. Defaults to
@@ -2,7 +2,13 @@
2
2
  * Scoped staging + installer-managed allowlist for TS-native init/update (#1453).
3
3
  *
4
4
  * Mirrors cmd/deft-install/hygiene.go + deposit.go installerManagedMatchers.
5
- * Refs #1576, #1453, #1430.
5
+ *
6
+ * CRITICAL (#1430 / #3030): the allowlist MUST honor the SPEC consumer-path
7
+ * denylist (`CONSUMER_GUARD_MUST_FIRE`). Consumer-authored PROJECT-DEFINITION
8
+ * and scope briefs are never installer-managed; if they reappear in
9
+ * `installerManagedMatchers()`, unit tests and deposit-time assert fail closed.
10
+ *
11
+ * Refs #1576, #1453, #1430, #3029, #3030.
6
12
  */
7
13
  import { execFileSync } from "node:child_process";
8
14
  import { existsSync, readdirSync, rmSync } from "node:fs";
@@ -15,6 +21,26 @@ export const CORE_GUARD_WORKFLOW_REL = ".github/workflows/deft-core-guard.yml";
15
21
  // The lifecycle dir names are identical across the legacy `vbrief/` tree and the
16
22
  // post-#2034 / #2110 `xbrief/` tree, so both allowlist families reuse this list.
17
23
  const VBRIEF_LIFECYCLE_DIRS = ["proposed", "pending", "active", "completed", "cancelled"];
24
+ /**
25
+ * Consumer paths that MUST trip no-mixed-core-and-app when mixed with
26
+ * `.deft/core/**` (#1430 SPEC). These probe paths must never match
27
+ * `installerManagedMatchers()` / the deposited guard ERE.
28
+ *
29
+ * Legitimate installer scaffolding (xbrief/.deft-version, lifecycle .gitkeep,
30
+ * schemas/, migration/, xbrief.md) is NOT in this denylist — see #2277.
31
+ * Init may still *create* PROJECT-DEFINITION (#3013); create ≠ allowlist.
32
+ *
33
+ * Refs #3030, #3029, #1430.
34
+ */
35
+ export const CONSUMER_GUARD_MUST_FIRE = [
36
+ "xbrief/PROJECT-DEFINITION.xbrief.json",
37
+ "vbrief/PROJECT-DEFINITION.vbrief.json",
38
+ // Representative consumer scope briefs (not scaffolding markers).
39
+ "xbrief/active/example-scope.xbrief.json",
40
+ "vbrief/active/example-scope.vbrief.json",
41
+ "xbrief/proposed/example-scope.xbrief.json",
42
+ "vbrief/pending/example-scope.vbrief.json",
43
+ ];
18
44
  /** Single source of truth for installer-managed paths (#1440 / #1576). */
19
45
  export function installerManagedMatchers() {
20
46
  return [
@@ -45,9 +71,10 @@ export function installerManagedMatchers() {
45
71
  // `deft update` framework-deposit PR trips no-mixed-core-and-app (#2277).
46
72
  { exact: "xbrief/.deft-version" },
47
73
  { exact: "xbrief/xbrief.md" },
48
- // Minimal render-ready seed from init (#3013); operator may later edit identity.
49
- { exact: "xbrief/PROJECT-DEFINITION.xbrief.json" },
50
- { exact: "vbrief/PROJECT-DEFINITION.vbrief.json" },
74
+ // CRITICAL (#1430 / #3029 / #3030): do NOT allowlist consumer-authored
75
+ // PROJECT-DEFINITION (xbrief/ or vbrief/) or consumer scope briefs.
76
+ // Init may still seed PD (#3013); the seed is app-owned for guard classification
77
+ // so core+PD mixed PRs fail no-mixed-core-and-app. See CONSUMER_GUARD_MUST_FIRE.
51
78
  { prefix: "xbrief/schemas/" },
52
79
  { prefix: "xbrief/migration/" },
53
80
  ...VBRIEF_LIFECYCLE_DIRS.map((sub) => ({ exact: `xbrief/${sub}/.gitkeep` })),
@@ -61,12 +88,6 @@ function matcherToEre(matcher) {
61
88
  return `^${escapeEre(matcher.exact)}$`;
62
89
  return `^${escapeEre(matcher.prefix ?? "")}`;
63
90
  }
64
- /** POSIX ERE alternation embedded in the deposited deft-core-guard workflow. */
65
- export function installerManagedGuardEre() {
66
- return installerManagedMatchers()
67
- .map((matcher) => matcherToEre(matcher))
68
- .join("|");
69
- }
70
91
  function matchesInstallerManaged(path, matchers) {
71
92
  for (const matcher of matchers) {
72
93
  if (matcher.exact && path === matcher.exact)
@@ -76,9 +97,90 @@ function matchesInstallerManaged(path, matchers) {
76
97
  }
77
98
  return false;
78
99
  }
100
+ /** Consumer scope-brief filenames under lifecycle dirs (not scaffolding). */
101
+ const CONSUMER_SCOPE_BRIEF_EXACT = /^(xbrief|vbrief)\/(proposed|pending|active|completed|cancelled)\/.+\.(x|v)brief\.json$/;
102
+ /** PROJECT-DEFINITION exact paths (xbrief or legacy vbrief). */
103
+ const CONSUMER_PROJECT_DEFINITION_EXACT = /^(xbrief|vbrief)\/PROJECT-DEFINITION\.(x|v)brief\.json$/;
104
+ /**
105
+ * Prefixes that would exempt entire consumer lifecycle trees or the whole
106
+ * xbrief/vbrief tree — never installer-managed (#1430).
107
+ */
108
+ const FORBIDDEN_CONSUMER_PREFIXES = new Set([
109
+ "xbrief/",
110
+ "vbrief/",
111
+ ...VBRIEF_LIFECYCLE_DIRS.flatMap((sub) => [`xbrief/${sub}/`, `vbrief/${sub}/`]),
112
+ ]);
113
+ /**
114
+ * Fail closed when the installer-managed allowlist would exempt consumer
115
+ * PROJECT-DEFINITION or scope briefs (#3030 / #1430). Pure over `matchers` so
116
+ * tests can inject a bad matcher without mutating production state.
117
+ *
118
+ * Checks both:
119
+ * 1. Explicit probe paths in {@link CONSUMER_GUARD_MUST_FIRE}
120
+ * 2. Structural patterns (any exact consumer scope brief / PD; forbidden prefixes)
121
+ * so an unlisted `xbrief/active/another-scope.xbrief.json` matcher still fails.
122
+ */
123
+ export function assertInstallerAllowlistHonors1430(matchers = installerManagedMatchers()) {
124
+ for (const path of CONSUMER_GUARD_MUST_FIRE) {
125
+ if (matchesInstallerManaged(path, matchers)) {
126
+ throw new Error(`#1430 violation: ${path} must not be installer-managed (SPEC consumer denylist; see CONSUMER_GUARD_MUST_FIRE / #3030)`);
127
+ }
128
+ }
129
+ for (const matcher of matchers) {
130
+ if (matcher.exact) {
131
+ if (CONSUMER_PROJECT_DEFINITION_EXACT.test(matcher.exact) ||
132
+ CONSUMER_SCOPE_BRIEF_EXACT.test(matcher.exact)) {
133
+ throw new Error(`#1430 violation: exact matcher ${matcher.exact} must not be installer-managed (consumer brief denylist / #3030)`);
134
+ }
135
+ }
136
+ if (matcher.prefix) {
137
+ const normalized = matcher.prefix.endsWith("/") ? matcher.prefix : `${matcher.prefix}/`;
138
+ if (FORBIDDEN_CONSUMER_PREFIXES.has(normalized)) {
139
+ throw new Error(`#1430 violation: prefix matcher ${matcher.prefix} must not cover consumer brief trees (#3030)`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ /** POSIX ERE alternation embedded in the deposited deft-core-guard workflow. */
145
+ export function installerManagedGuardEre() {
146
+ const matchers = installerManagedMatchers();
147
+ // Refuse to emit a guard workflow that would exempt consumer denylist paths.
148
+ assertInstallerAllowlistHonors1430(matchers);
149
+ return matchers.map((matcher) => matcherToEre(matcher)).join("|");
150
+ }
79
151
  export function isInstallerManagedPath(path) {
80
152
  return matchesInstallerManaged(path, installerManagedMatchers());
81
153
  }
154
+ /**
155
+ * TS twin of Go `classifyChangedPaths` / deposited shell guard (#1430).
156
+ * Core = `.deft/core/**`; installer-managed = allowlist; app = everything else.
157
+ * Guard fails iff both core and app are non-empty.
158
+ */
159
+ export function classifyMixedCoreAndApp(changedPaths, matchers = installerManagedMatchers()) {
160
+ const core = [];
161
+ const installerManaged = [];
162
+ const app = [];
163
+ for (const raw of changedPaths) {
164
+ const path = raw.replace(/\\/g, "/");
165
+ if (!path)
166
+ continue;
167
+ if (path === ".deft/core" || path.startsWith(".deft/core/")) {
168
+ core.push(path);
169
+ }
170
+ else if (matchesInstallerManaged(path, matchers)) {
171
+ installerManaged.push(path);
172
+ }
173
+ else {
174
+ app.push(path);
175
+ }
176
+ }
177
+ return {
178
+ core,
179
+ installerManaged,
180
+ app,
181
+ wouldFail: core.length > 0 && app.length > 0,
182
+ };
183
+ }
82
184
  export function frameworkStagePaths(projectDir, deftDir, options = {}) {
83
185
  const paths = [];
84
186
  const seen = new Set();
@@ -14,7 +14,7 @@ import { assertDestinationNotSymlink, ProjectionContainmentError, } from "../fs/
14
14
  import { agentsRefreshPlan } from "../platform/agents-md.js";
15
15
  import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
16
16
  import { CANONICAL_INSTALL_ROOT } from "./constants.js";
17
- import { installerManagedGuardEre } from "./hygiene.js";
17
+ import { assertInstallerAllowlistHonors1430, installerManagedGuardEre } from "./hygiene.js";
18
18
  import { syncConsumerXbriefSchemas } from "./xbrief-projections.js";
19
19
  export { CANONICAL_INSTALL_ROOT };
20
20
  export const CORE_GLOB = ".deft/core/**";
@@ -632,6 +632,8 @@ export function mergeCoreGuardWorkflowRefresh(existing, desired) {
632
632
  return desired.slice(0, idx) + existingCheckout + desired.slice(idx + desiredCheckout.length);
633
633
  }
634
634
  function coreGuardWorkflowContent() {
635
+ // Fail closed before emitting guard ERE if allowlist violates #1430 denylist (#3030).
636
+ assertInstallerAllowlistHonors1430();
635
637
  const baseSha = githubActionsExpr("github.event.pull_request.base.sha");
636
638
  const headSha = githubActionsExpr("github.event.pull_request.head.sha");
637
639
  return ("name: deft-core-guard\n\n" +
@@ -36,7 +36,11 @@ export declare function detectGitBranch(projectRoot: string, execGit?: (args: st
36
36
  }): string;
37
37
  /** Read probe session from project root. */
38
38
  export declare function readSession(projectRoot: string): ProbeSession | null;
39
- /** Atomically persist session to .deft/probe-session.json. */
39
+ /**
40
+ * Atomically persist session to .deft/probe-session.json.
41
+ * #3042: contain against projectRoot; refuse escaping `.deft` symlink parents
42
+ * (no bare open/write/rename).
43
+ */
40
44
  export declare function writeSession(projectRoot: string, session: ProbeSession): string;
41
45
  export declare function startSession(projectRoot: string, options: {
42
46
  target: string;
@@ -3,8 +3,10 @@
3
3
  */
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { randomBytes } from "node:crypto";
6
- import { closeSync, existsSync, fdatasyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync, } from "node:fs";
6
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
7
7
  import { join, resolve } from "node:path";
8
+ import { containedWrite } from "../fs/contained-write.js";
9
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
8
10
  export const SCHEMA_VERSION = 1;
9
11
  export const SESSION_RELPATH = [".deft", "probe-session.json"];
10
12
  export const STATE_INTERROGATE = "interrogate";
@@ -188,11 +190,16 @@ export function readSession(projectRoot) {
188
190
  completed_at: completedAt,
189
191
  };
190
192
  }
191
- /** Atomically persist session to .deft/probe-session.json. */
193
+ /**
194
+ * Atomically persist session to .deft/probe-session.json.
195
+ * #3042: contain against projectRoot; refuse escaping `.deft` symlink parents
196
+ * (no bare open/write/rename).
197
+ */
192
198
  export function writeSession(projectRoot, session) {
193
- const sessionFile = sessionPath(projectRoot);
194
- mkdirSync(join(projectRoot, ".deft"), { recursive: true });
195
- const tmpName = join(projectRoot, ".deft", `.probe-session.${randomBytes(8).toString("hex")}.json.tmp`);
199
+ const root = resolve(projectRoot);
200
+ const sessionFile = sessionPath(root);
201
+ assertWriteTargetSafe(root, sessionFile);
202
+ const tmpName = join(root, ".deft", `.probe-session.${randomBytes(8).toString("hex")}.json.tmp`);
196
203
  const sortedPayload = sessionToDict(session);
197
204
  const sortedKeys = Object.keys(sortedPayload).sort();
198
205
  const sortedObj = {};
@@ -200,20 +207,24 @@ export function writeSession(projectRoot, session) {
200
207
  sortedObj[k] = sortedPayload[k];
201
208
  }
202
209
  const finalContent = `${JSON.stringify(sortedObj, null, 2)}\n`;
203
- const fd = openSync(tmpName, "w");
204
210
  try {
205
- writeSync(fd, finalContent, undefined, "utf8");
211
+ containedWrite({
212
+ root,
213
+ target: tmpName,
214
+ data: finalContent,
215
+ mode: "create",
216
+ });
217
+ renameSync(tmpName, sessionFile);
218
+ }
219
+ catch (err) {
206
220
  try {
207
- fdatasyncSync(fd);
221
+ rmSync(tmpName, { force: true });
208
222
  }
209
223
  catch {
210
- // best effort
224
+ /* best-effort cleanup */
211
225
  }
226
+ throw err;
212
227
  }
213
- finally {
214
- closeSync(fd);
215
- }
216
- renameSync(tmpName, sessionFile);
217
228
  return sessionFile;
218
229
  }
219
230
  export function startSession(projectRoot, options) {
@@ -324,7 +324,7 @@ export function applyAgentsRefresh(projectRoot, options = {}, seams = {}, lockDe
324
324
  const writable = AGENTS_REFRESH_WRITABLE_STATES.has(state) && typeof newContent === "string";
325
325
  return { state, path, wrote: false, writable };
326
326
  }
327
- atomicWriteText(path, newContent);
327
+ atomicWriteText(path, newContent, { projectRoot });
328
328
  return { state, path, wrote: true, writable: true };
329
329
  }, lockDeps);
330
330
  }