@dreb/coding-agent 2.40.2 → 2.42.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 (45) hide show
  1. package/README.md +22 -9
  2. package/dist/core/agent-session.d.ts +2 -1
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +8 -4
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/context-trust.d.ts +44 -0
  7. package/dist/core/context-trust.d.ts.map +1 -0
  8. package/dist/core/context-trust.js +117 -0
  9. package/dist/core/context-trust.js.map +1 -0
  10. package/dist/core/nested-context.d.ts +4 -3
  11. package/dist/core/nested-context.d.ts.map +1 -1
  12. package/dist/core/nested-context.js +34 -9
  13. package/dist/core/nested-context.js.map +1 -1
  14. package/dist/core/resource-loader.d.ts +3 -1
  15. package/dist/core/resource-loader.d.ts.map +1 -1
  16. package/dist/core/resource-loader.js +29 -11
  17. package/dist/core/resource-loader.js.map +1 -1
  18. package/dist/core/settings-manager.d.ts +38 -2
  19. package/dist/core/settings-manager.d.ts.map +1 -1
  20. package/dist/core/settings-manager.js +161 -6
  21. package/dist/core/settings-manager.js.map +1 -1
  22. package/dist/modes/index.d.ts +1 -1
  23. package/dist/modes/index.d.ts.map +1 -1
  24. package/dist/modes/index.js.map +1 -1
  25. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  26. package/dist/modes/interactive/components/settings-selector.js +2 -2
  27. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  28. package/dist/modes/rpc/index.d.ts +1 -1
  29. package/dist/modes/rpc/index.d.ts.map +1 -1
  30. package/dist/modes/rpc/index.js.map +1 -1
  31. package/dist/modes/rpc/rpc-client.d.ts +22 -8
  32. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  33. package/dist/modes/rpc/rpc-client.js +67 -6
  34. package/dist/modes/rpc/rpc-client.js.map +1 -1
  35. package/dist/modes/rpc/rpc-mode.d.ts +57 -7
  36. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  37. package/dist/modes/rpc/rpc-mode.js +341 -70
  38. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  39. package/dist/modes/rpc/rpc-types.d.ts +114 -3
  40. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  41. package/dist/modes/rpc/rpc-types.js.map +1 -1
  42. package/docs/dashboard.md +29 -9
  43. package/docs/rpc.md +184 -10
  44. package/docs/settings.md +32 -5
  45. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ /** Global-only policy controlling lazy nested context loading. */
2
+ export interface ContextTrustPolicy {
3
+ /** Expert opt-in allowing any strictly resolvable tool target. */
4
+ unrestricted: boolean;
5
+ /** Configured folder roots. Invalid or unavailable roots are ignored fail-closed. */
6
+ trustedFolders: string[];
7
+ }
8
+ export interface ContextTrustMatch {
9
+ /** Canonical directory the tool will operate in. */
10
+ targetDir: string;
11
+ /** Canonical trusted root that grants access, absent for unrestricted access. */
12
+ trustedRoot?: string;
13
+ }
14
+ /** Strict native realpath. Unlike normal path handling, failures never fall back to input. */
15
+ export declare function strictNativeRealpath(path: string): string | null;
16
+ /** True only for a path equal to or below a canonical directory on a segment boundary. */
17
+ export declare function isWithinCanonicalRoot(root: string, path: string): boolean;
18
+ /** Resolve an existing directory through native realpath, or fail without lexical fallback. */
19
+ export declare function canonicalizeDirectory(path: string): string | null;
20
+ /**
21
+ * Canonicalize configured trusted roots into the enforceable set. Only absolute paths (after
22
+ * settings-style `~` expansion) are eligible: relative and empty values must never inherit the
23
+ * process cwd. Missing paths, broken symlinks, and non-directories are ignored fail-closed.
24
+ * Canonically duplicate roots are deduped and roots below another effective root are subsumed.
25
+ */
26
+ export declare function canonicalizeTrustedRoots(configuredRoots: readonly string[]): string[];
27
+ /**
28
+ * Validate one settings/RPC trusted-folder path. Only absolute paths after settings-style
29
+ * `~` expansion are accepted; the result is a strict-native-realpath existing directory.
30
+ */
31
+ export declare function validateTrustedContextFolder(path: unknown): string;
32
+ /**
33
+ * Validate a settings/RPC trusted-folder update before it is applied. Unlike the
34
+ * fail-closed reader above, this rejects every invalid entry so updates are atomic.
35
+ * Returned roots are strict-native-realpath canonical, deduplicated, and subsumed.
36
+ */
37
+ export declare function validateTrustedContextFolders(configuredRoots: unknown): string[];
38
+ /**
39
+ * Resolve a lazy-load target against the global policy. Every configured root and the target
40
+ * must resolve natively at decision time; missing paths, broken symlinks, and non-directories
41
+ * are deliberately denied rather than treated as lexical paths.
42
+ */
43
+ export declare function matchContextTrust(policy: ContextTrustPolicy, targetDir: string): ContextTrustMatch | null;
44
+ //# sourceMappingURL=context-trust.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-trust.d.ts","sourceRoot":"","sources":["../../src/core/context-trust.ts"],"names":[],"mappings":"AAIA,kEAAkE;AAClE,MAAM,WAAW,kBAAkB;IAClC,kEAAkE;IAClE,YAAY,EAAE,OAAO,CAAC;IACtB,qFAAqF;IACrF,cAAc,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,iBAAiB;IACjC,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,8FAA8F;AAC9F,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMhE;AAED,0FAA0F;AAC1F,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzE;AAED,+FAA+F;AAC/F,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQjE;AAgBD;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAYrF;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAalE;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,eAAe,EAAE,OAAO,GAAG,MAAM,EAAE,CAehF;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAWzG","sourcesContent":["import { realpathSync, statSync } from \"node:fs\";\nimport { isAbsolute, sep } from \"node:path\";\nimport { expandPath } from \"./tools/path-utils.js\";\n\n/** Global-only policy controlling lazy nested context loading. */\nexport interface ContextTrustPolicy {\n\t/** Expert opt-in allowing any strictly resolvable tool target. */\n\tunrestricted: boolean;\n\t/** Configured folder roots. Invalid or unavailable roots are ignored fail-closed. */\n\ttrustedFolders: string[];\n}\n\nexport interface ContextTrustMatch {\n\t/** Canonical directory the tool will operate in. */\n\ttargetDir: string;\n\t/** Canonical trusted root that grants access, absent for unrestricted access. */\n\ttrustedRoot?: string;\n}\n\n/** Strict native realpath. Unlike normal path handling, failures never fall back to input. */\nexport function strictNativeRealpath(path: string): string | null {\n\ttry {\n\t\treturn realpathSync.native(path);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/** True only for a path equal to or below a canonical directory on a segment boundary. */\nexport function isWithinCanonicalRoot(root: string, path: string): boolean {\n\treturn path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);\n}\n\n/** Resolve an existing directory through native realpath, or fail without lexical fallback. */\nexport function canonicalizeDirectory(path: string): string | null {\n\tconst canonical = strictNativeRealpath(path);\n\tif (!canonical) return null;\n\ttry {\n\t\treturn statSync(canonical).isDirectory() ? canonical : null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction normalizeCanonicalRoots(canonicalRoots: readonly string[]): string[] {\n\tconst effectiveRoots: string[] = [];\n\tfor (const canonicalRoot of canonicalRoots) {\n\t\tif (effectiveRoots.some((root) => isWithinCanonicalRoot(root, canonicalRoot))) continue;\n\n\t\t// A later parent root subsumes previously configured descendants.\n\t\tfor (let i = effectiveRoots.length - 1; i >= 0; i--) {\n\t\t\tif (isWithinCanonicalRoot(canonicalRoot, effectiveRoots[i])) effectiveRoots.splice(i, 1);\n\t\t}\n\t\teffectiveRoots.push(canonicalRoot);\n\t}\n\treturn effectiveRoots;\n}\n\n/**\n * Canonicalize configured trusted roots into the enforceable set. Only absolute paths (after\n * settings-style `~` expansion) are eligible: relative and empty values must never inherit the\n * process cwd. Missing paths, broken symlinks, and non-directories are ignored fail-closed.\n * Canonically duplicate roots are deduped and roots below another effective root are subsumed.\n */\nexport function canonicalizeTrustedRoots(configuredRoots: readonly string[]): string[] {\n\tconst canonicalRoots: string[] = [];\n\n\tfor (const configuredRoot of configuredRoots) {\n\t\tconst expandedRoot = expandPath(configuredRoot);\n\t\tif (!isAbsolute(expandedRoot)) continue;\n\n\t\tconst canonicalRoot = canonicalizeDirectory(expandedRoot);\n\t\tif (canonicalRoot) canonicalRoots.push(canonicalRoot);\n\t}\n\n\treturn normalizeCanonicalRoots(canonicalRoots);\n}\n\n/**\n * Validate one settings/RPC trusted-folder path. Only absolute paths after settings-style\n * `~` expansion are accepted; the result is a strict-native-realpath existing directory.\n */\nexport function validateTrustedContextFolder(path: unknown): string {\n\tif (typeof path !== \"string\" || path.trim().length === 0) {\n\t\tthrow new Error(\"expected a non-empty path string\");\n\t}\n\tconst expandedPath = expandPath(path);\n\tif (!isAbsolute(expandedPath)) {\n\t\tthrow new Error(\"path must be absolute after ~ expansion\");\n\t}\n\tconst canonicalPath = canonicalizeDirectory(expandedPath);\n\tif (!canonicalPath) {\n\t\tthrow new Error(\"path must be an existing directory\");\n\t}\n\treturn canonicalPath;\n}\n\n/**\n * Validate a settings/RPC trusted-folder update before it is applied. Unlike the\n * fail-closed reader above, this rejects every invalid entry so updates are atomic.\n * Returned roots are strict-native-realpath canonical, deduplicated, and subsumed.\n */\nexport function validateTrustedContextFolders(configuredRoots: unknown): string[] {\n\tif (!Array.isArray(configuredRoots)) {\n\t\tthrow new Error(\"trustedContextFolders must be an array of non-empty path strings\");\n\t}\n\n\tconst canonicalRoots: string[] = [];\n\tfor (const [index, configuredRoot] of configuredRoots.entries()) {\n\t\ttry {\n\t\t\tcanonicalRoots.push(validateTrustedContextFolder(configuredRoot));\n\t\t} catch (error) {\n\t\t\tthrow new Error(`Invalid trustedContextFolders[${index}]: ${(error as Error).message}`);\n\t\t}\n\t}\n\n\treturn normalizeCanonicalRoots(canonicalRoots);\n}\n\n/**\n * Resolve a lazy-load target against the global policy. Every configured root and the target\n * must resolve natively at decision time; missing paths, broken symlinks, and non-directories\n * are deliberately denied rather than treated as lexical paths.\n */\nexport function matchContextTrust(policy: ContextTrustPolicy, targetDir: string): ContextTrustMatch | null {\n\tconst canonicalTarget = canonicalizeDirectory(targetDir);\n\tif (!canonicalTarget) return null;\n\tif (policy.unrestricted) return { targetDir: canonicalTarget };\n\n\tfor (const canonicalRoot of canonicalizeTrustedRoots(policy.trustedFolders)) {\n\t\tif (isWithinCanonicalRoot(canonicalRoot, canonicalTarget)) {\n\t\t\treturn { targetDir: canonicalTarget, trustedRoot: canonicalRoot };\n\t\t}\n\t}\n\treturn null;\n}\n"]}
@@ -0,0 +1,117 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, sep } from "node:path";
3
+ import { expandPath } from "./tools/path-utils.js";
4
+ /** Strict native realpath. Unlike normal path handling, failures never fall back to input. */
5
+ export function strictNativeRealpath(path) {
6
+ try {
7
+ return realpathSync.native(path);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ /** True only for a path equal to or below a canonical directory on a segment boundary. */
14
+ export function isWithinCanonicalRoot(root, path) {
15
+ return path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
16
+ }
17
+ /** Resolve an existing directory through native realpath, or fail without lexical fallback. */
18
+ export function canonicalizeDirectory(path) {
19
+ const canonical = strictNativeRealpath(path);
20
+ if (!canonical)
21
+ return null;
22
+ try {
23
+ return statSync(canonical).isDirectory() ? canonical : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function normalizeCanonicalRoots(canonicalRoots) {
30
+ const effectiveRoots = [];
31
+ for (const canonicalRoot of canonicalRoots) {
32
+ if (effectiveRoots.some((root) => isWithinCanonicalRoot(root, canonicalRoot)))
33
+ continue;
34
+ // A later parent root subsumes previously configured descendants.
35
+ for (let i = effectiveRoots.length - 1; i >= 0; i--) {
36
+ if (isWithinCanonicalRoot(canonicalRoot, effectiveRoots[i]))
37
+ effectiveRoots.splice(i, 1);
38
+ }
39
+ effectiveRoots.push(canonicalRoot);
40
+ }
41
+ return effectiveRoots;
42
+ }
43
+ /**
44
+ * Canonicalize configured trusted roots into the enforceable set. Only absolute paths (after
45
+ * settings-style `~` expansion) are eligible: relative and empty values must never inherit the
46
+ * process cwd. Missing paths, broken symlinks, and non-directories are ignored fail-closed.
47
+ * Canonically duplicate roots are deduped and roots below another effective root are subsumed.
48
+ */
49
+ export function canonicalizeTrustedRoots(configuredRoots) {
50
+ const canonicalRoots = [];
51
+ for (const configuredRoot of configuredRoots) {
52
+ const expandedRoot = expandPath(configuredRoot);
53
+ if (!isAbsolute(expandedRoot))
54
+ continue;
55
+ const canonicalRoot = canonicalizeDirectory(expandedRoot);
56
+ if (canonicalRoot)
57
+ canonicalRoots.push(canonicalRoot);
58
+ }
59
+ return normalizeCanonicalRoots(canonicalRoots);
60
+ }
61
+ /**
62
+ * Validate one settings/RPC trusted-folder path. Only absolute paths after settings-style
63
+ * `~` expansion are accepted; the result is a strict-native-realpath existing directory.
64
+ */
65
+ export function validateTrustedContextFolder(path) {
66
+ if (typeof path !== "string" || path.trim().length === 0) {
67
+ throw new Error("expected a non-empty path string");
68
+ }
69
+ const expandedPath = expandPath(path);
70
+ if (!isAbsolute(expandedPath)) {
71
+ throw new Error("path must be absolute after ~ expansion");
72
+ }
73
+ const canonicalPath = canonicalizeDirectory(expandedPath);
74
+ if (!canonicalPath) {
75
+ throw new Error("path must be an existing directory");
76
+ }
77
+ return canonicalPath;
78
+ }
79
+ /**
80
+ * Validate a settings/RPC trusted-folder update before it is applied. Unlike the
81
+ * fail-closed reader above, this rejects every invalid entry so updates are atomic.
82
+ * Returned roots are strict-native-realpath canonical, deduplicated, and subsumed.
83
+ */
84
+ export function validateTrustedContextFolders(configuredRoots) {
85
+ if (!Array.isArray(configuredRoots)) {
86
+ throw new Error("trustedContextFolders must be an array of non-empty path strings");
87
+ }
88
+ const canonicalRoots = [];
89
+ for (const [index, configuredRoot] of configuredRoots.entries()) {
90
+ try {
91
+ canonicalRoots.push(validateTrustedContextFolder(configuredRoot));
92
+ }
93
+ catch (error) {
94
+ throw new Error(`Invalid trustedContextFolders[${index}]: ${error.message}`);
95
+ }
96
+ }
97
+ return normalizeCanonicalRoots(canonicalRoots);
98
+ }
99
+ /**
100
+ * Resolve a lazy-load target against the global policy. Every configured root and the target
101
+ * must resolve natively at decision time; missing paths, broken symlinks, and non-directories
102
+ * are deliberately denied rather than treated as lexical paths.
103
+ */
104
+ export function matchContextTrust(policy, targetDir) {
105
+ const canonicalTarget = canonicalizeDirectory(targetDir);
106
+ if (!canonicalTarget)
107
+ return null;
108
+ if (policy.unrestricted)
109
+ return { targetDir: canonicalTarget };
110
+ for (const canonicalRoot of canonicalizeTrustedRoots(policy.trustedFolders)) {
111
+ if (isWithinCanonicalRoot(canonicalRoot, canonicalTarget)) {
112
+ return { targetDir: canonicalTarget, trustedRoot: canonicalRoot };
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ //# sourceMappingURL=context-trust.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-trust.js","sourceRoot":"","sources":["../../src/core/context-trust.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAiBnD,8FAA8F;AAC9F,MAAM,UAAU,oBAAoB,CAAC,IAAY,EAAiB;IACjE,IAAI,CAAC;QACJ,OAAO,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AAAA,CACD;AAED,0FAA0F;AAC1F,MAAM,UAAU,qBAAqB,CAAC,IAAY,EAAE,IAAY,EAAW;IAC1E,OAAO,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,GAAG,EAAE,CAAC,CAAC;AAAA,CACrF;AAED,+FAA+F;AAC/F,MAAM,UAAU,qBAAqB,CAAC,IAAY,EAAiB;IAClE,MAAM,SAAS,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;IAC7C,IAAI,CAAC,SAAS;QAAE,OAAO,IAAI,CAAC;IAC5B,IAAI,CAAC;QACJ,OAAO,QAAQ,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7D,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AAAA,CACD;AAED,SAAS,uBAAuB,CAAC,cAAiC,EAAY;IAC7E,MAAM,cAAc,GAAa,EAAE,CAAC;IACpC,KAAK,MAAM,aAAa,IAAI,cAAc,EAAE,CAAC;QAC5C,IAAI,cAAc,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,qBAAqB,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC;YAAE,SAAS;QAExF,kEAAkE;QAClE,KAAK,IAAI,CAAC,GAAG,cAAc,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YACrD,IAAI,qBAAqB,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;gBAAE,cAAc,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAC1F,CAAC;QACD,cAAc,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IACpC,CAAC;IACD,OAAO,cAAc,CAAC;AAAA,CACtB;AAED;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CAAC,eAAkC,EAAY;IACtF,MAAM,cAAc,GAAa,EAAE,CAAC;IAEpC,KAAK,MAAM,cAAc,IAAI,eAAe,EAAE,CAAC;QAC9C,MAAM,YAAY,GAAG,UAAU,CAAC,cAAc,CAAC,CAAC;QAChD,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC;YAAE,SAAS;QAExC,MAAM,aAAa,GAAG,qBAAqB,CAAC,YAAY,CAAC,CAAC;QAC1D,IAAI,aAAa;YAAE,cAAc,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IACvD,CAAC;IAED,OAAO,uBAAuB,CAAC,cAAc,CAAC,CAAC;AAAA,CAC/C;AAED;;;GAGG;AACH,MAAM,UAAU,4BAA4B,CAAC,IAAa,EAAU;IACnE,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1D,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC;IACrD,CAAC;IACD,MAAM,YAAY,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IACtC,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAC;IAC5D,CAAC;IACD,MAAM,aAAa,GAAG,qBAAqB,CAAC,YAAY,CAAC,CAAC;IAC1D,IAAI,CAAC,aAAa,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;IACvD,CAAC;IACD,OAAO,aAAa,CAAC;AAAA,CACrB;AAED;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAAC,eAAwB,EAAY;IACjF,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,eAAe,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;IACrF,CAAC;IAED,MAAM,cAAc,GAAa,EAAE,CAAC;IACpC,KAAK,MAAM,CAAC,KAAK,EAAE,cAAc,CAAC,IAAI,eAAe,CAAC,OAAO,EAAE,EAAE,CAAC;QACjE,IAAI,CAAC;YACJ,cAAc,CAAC,IAAI,CAAC,4BAA4B,CAAC,cAAc,CAAC,CAAC,CAAC;QACnE,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CAAC,iCAAiC,KAAK,MAAO,KAAe,CAAC,OAAO,EAAE,CAAC,CAAC;QACzF,CAAC;IACF,CAAC;IAED,OAAO,uBAAuB,CAAC,cAAc,CAAC,CAAC;AAAA,CAC/C;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAA0B,EAAE,SAAiB,EAA4B;IAC1G,MAAM,eAAe,GAAG,qBAAqB,CAAC,SAAS,CAAC,CAAC;IACzD,IAAI,CAAC,eAAe;QAAE,OAAO,IAAI,CAAC;IAClC,IAAI,MAAM,CAAC,YAAY;QAAE,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,CAAC;IAE/D,KAAK,MAAM,aAAa,IAAI,wBAAwB,CAAC,MAAM,CAAC,cAAc,CAAC,EAAE,CAAC;QAC7E,IAAI,qBAAqB,CAAC,aAAa,EAAE,eAAe,CAAC,EAAE,CAAC;YAC3D,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,WAAW,EAAE,aAAa,EAAE,CAAC;QACnE,CAAC;IACF,CAAC;IACD,OAAO,IAAI,CAAC;AAAA,CACZ","sourcesContent":["import { realpathSync, statSync } from \"node:fs\";\nimport { isAbsolute, sep } from \"node:path\";\nimport { expandPath } from \"./tools/path-utils.js\";\n\n/** Global-only policy controlling lazy nested context loading. */\nexport interface ContextTrustPolicy {\n\t/** Expert opt-in allowing any strictly resolvable tool target. */\n\tunrestricted: boolean;\n\t/** Configured folder roots. Invalid or unavailable roots are ignored fail-closed. */\n\ttrustedFolders: string[];\n}\n\nexport interface ContextTrustMatch {\n\t/** Canonical directory the tool will operate in. */\n\ttargetDir: string;\n\t/** Canonical trusted root that grants access, absent for unrestricted access. */\n\ttrustedRoot?: string;\n}\n\n/** Strict native realpath. Unlike normal path handling, failures never fall back to input. */\nexport function strictNativeRealpath(path: string): string | null {\n\ttry {\n\t\treturn realpathSync.native(path);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/** True only for a path equal to or below a canonical directory on a segment boundary. */\nexport function isWithinCanonicalRoot(root: string, path: string): boolean {\n\treturn path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);\n}\n\n/** Resolve an existing directory through native realpath, or fail without lexical fallback. */\nexport function canonicalizeDirectory(path: string): string | null {\n\tconst canonical = strictNativeRealpath(path);\n\tif (!canonical) return null;\n\ttry {\n\t\treturn statSync(canonical).isDirectory() ? canonical : null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction normalizeCanonicalRoots(canonicalRoots: readonly string[]): string[] {\n\tconst effectiveRoots: string[] = [];\n\tfor (const canonicalRoot of canonicalRoots) {\n\t\tif (effectiveRoots.some((root) => isWithinCanonicalRoot(root, canonicalRoot))) continue;\n\n\t\t// A later parent root subsumes previously configured descendants.\n\t\tfor (let i = effectiveRoots.length - 1; i >= 0; i--) {\n\t\t\tif (isWithinCanonicalRoot(canonicalRoot, effectiveRoots[i])) effectiveRoots.splice(i, 1);\n\t\t}\n\t\teffectiveRoots.push(canonicalRoot);\n\t}\n\treturn effectiveRoots;\n}\n\n/**\n * Canonicalize configured trusted roots into the enforceable set. Only absolute paths (after\n * settings-style `~` expansion) are eligible: relative and empty values must never inherit the\n * process cwd. Missing paths, broken symlinks, and non-directories are ignored fail-closed.\n * Canonically duplicate roots are deduped and roots below another effective root are subsumed.\n */\nexport function canonicalizeTrustedRoots(configuredRoots: readonly string[]): string[] {\n\tconst canonicalRoots: string[] = [];\n\n\tfor (const configuredRoot of configuredRoots) {\n\t\tconst expandedRoot = expandPath(configuredRoot);\n\t\tif (!isAbsolute(expandedRoot)) continue;\n\n\t\tconst canonicalRoot = canonicalizeDirectory(expandedRoot);\n\t\tif (canonicalRoot) canonicalRoots.push(canonicalRoot);\n\t}\n\n\treturn normalizeCanonicalRoots(canonicalRoots);\n}\n\n/**\n * Validate one settings/RPC trusted-folder path. Only absolute paths after settings-style\n * `~` expansion are accepted; the result is a strict-native-realpath existing directory.\n */\nexport function validateTrustedContextFolder(path: unknown): string {\n\tif (typeof path !== \"string\" || path.trim().length === 0) {\n\t\tthrow new Error(\"expected a non-empty path string\");\n\t}\n\tconst expandedPath = expandPath(path);\n\tif (!isAbsolute(expandedPath)) {\n\t\tthrow new Error(\"path must be absolute after ~ expansion\");\n\t}\n\tconst canonicalPath = canonicalizeDirectory(expandedPath);\n\tif (!canonicalPath) {\n\t\tthrow new Error(\"path must be an existing directory\");\n\t}\n\treturn canonicalPath;\n}\n\n/**\n * Validate a settings/RPC trusted-folder update before it is applied. Unlike the\n * fail-closed reader above, this rejects every invalid entry so updates are atomic.\n * Returned roots are strict-native-realpath canonical, deduplicated, and subsumed.\n */\nexport function validateTrustedContextFolders(configuredRoots: unknown): string[] {\n\tif (!Array.isArray(configuredRoots)) {\n\t\tthrow new Error(\"trustedContextFolders must be an array of non-empty path strings\");\n\t}\n\n\tconst canonicalRoots: string[] = [];\n\tfor (const [index, configuredRoot] of configuredRoots.entries()) {\n\t\ttry {\n\t\t\tcanonicalRoots.push(validateTrustedContextFolder(configuredRoot));\n\t\t} catch (error) {\n\t\t\tthrow new Error(`Invalid trustedContextFolders[${index}]: ${(error as Error).message}`);\n\t\t}\n\t}\n\n\treturn normalizeCanonicalRoots(canonicalRoots);\n}\n\n/**\n * Resolve a lazy-load target against the global policy. Every configured root and the target\n * must resolve natively at decision time; missing paths, broken symlinks, and non-directories\n * are deliberately denied rather than treated as lexical paths.\n */\nexport function matchContextTrust(policy: ContextTrustPolicy, targetDir: string): ContextTrustMatch | null {\n\tconst canonicalTarget = canonicalizeDirectory(targetDir);\n\tif (!canonicalTarget) return null;\n\tif (policy.unrestricted) return { targetDir: canonicalTarget };\n\n\tfor (const canonicalRoot of canonicalizeTrustedRoots(policy.trustedFolders)) {\n\t\tif (isWithinCanonicalRoot(canonicalRoot, canonicalTarget)) {\n\t\t\treturn { targetDir: canonicalTarget, trustedRoot: canonicalRoot };\n\t\t}\n\t}\n\treturn null;\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import { type ContextTrustPolicy } from "./context-trust.js";
1
2
  export interface LoadedContextFile {
2
3
  /** Absolute path of the loaded context file. */
3
4
  path: string;
@@ -44,7 +45,7 @@ export type SuppressPredicate = (file: LoadedContextFile) => boolean;
44
45
  * the returned `files` — the triggering tool result already contains it, so re-injecting
45
46
  * would duplicate the content and waste tokens.
46
47
  */
47
- export declare function collectNestedContext(targetDir: string, cwd: string, alreadyLoaded: Set<string>, suppress?: SuppressPredicate): NestedContextCollection;
48
+ export declare function collectNestedContext(targetDir: string, cwd: string, alreadyLoaded: Set<string>, suppress?: SuppressPredicate, trustedRoot?: string): NestedContextCollection;
48
49
  /**
49
50
  * Format collected context files into a single text block for injection into a tool
50
51
  * result. Leads with *why* the load happened and headers each file with its source path.
@@ -88,8 +89,8 @@ export declare function resolveSelfReadFile(toolName: string, args: Record<strin
88
89
  */
89
90
  export declare function resolveBashDeliveredFiles(command: string, workingDir: string): string[];
90
91
  export interface NestedContextState {
91
- /** Whether auto-loading is enabled (the `context.autoLoadNested` setting). */
92
- enabled: boolean;
92
+ /** Global-only policy for this decision. Project settings never enter this value. */
93
+ policy: ContextTrustPolicy;
93
94
  /** The session's working directory. */
94
95
  cwd: string;
95
96
  /** Realpaths of context files already loaded this session (seeded at session start). Mutated. */
@@ -1 +1 @@
1
- {"version":3,"file":"nested-context.d.ts","sourceRoot":"","sources":["../../src/core/nested-context.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,iBAAiB;IACjC,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,uBAAuB;IACvC,8DAA8D;IAC9D,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,oFAAoF;IACpF,YAAY,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsC7D;AAgBD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,GAAG,EAAE,MAAM,GACT,MAAM,GAAG,IAAI,CA6Bf;AAoGD;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,IAAI,EAAE,iBAAiB,KAAK,OAAO,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CACnC,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC1B,uBAAuB,CAwBzB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,EAAE,GAAG,MAAM,CAc9F;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAClC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,GAAG,EAAE,MAAM,GACT,MAAM,GAAG,IAAI,CAUf;AA0CD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CA0CvF;AAED,MAAM,WAAW,kBAAkB;IAClC,8EAA8E;IAC9E,OAAO,EAAE,OAAO,CAAC;IACjB,uCAAuC;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,iGAAiG;IACjG,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,0EAA0E;IAC1E,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAkCD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACxC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,KAAK,EAAE,kBAAkB,GACvB,MAAM,GAAG,IAAI,CAwCf","sourcesContent":["import { existsSync, readFileSync, realpathSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve, sep } from \"node:path\";\nimport { CONTEXT_FILE_CANDIDATES, loadContextFilesFromDir, type ResourceDiagnostic } from \"./resource-loader.js\";\nimport { renderTerminalOutput } from \"./tools/terminal-render.js\";\nimport { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from \"./tools/truncate.js\";\n\n/**\n * Auto-load of nested AGENTS.md/CLAUDE.md context files.\n *\n * Project context files are only loaded at session start by walking *upward* from\n * `cwd`. When the agent (or a subagent) operates in a subdirectory — or in an entirely\n * different repo/project — that directory's context files are never loaded. This module\n * detects the directory a tool is about to operate in, walks up to a sensible ceiling\n * collecting context files, and returns a formatted block for injection into the tool\n * result (which is cache-safe — it does not rebuild the system prompt).\n */\n\n/** A safety bound on how many directories the upward walk will visit. */\nconst MAX_WALK_DEPTH = 64;\n\n/** Tools whose `path` argument identifies the directory being operated on. */\nconst PATH_TOOLS = new Set([\"read\", \"edit\", \"write\", \"grep\", \"find\", \"ls\"]);\n\nexport interface LoadedContextFile {\n\t/** Absolute path of the loaded context file. */\n\tpath: string;\n\t/** File content (HTML comments already stripped by the loader). */\n\tcontent: string;\n}\n\nexport interface NestedContextCollection {\n\t/** Context files newly loaded during this collection pass. */\n\tfiles: LoadedContextFile[];\n\t/** Whether any existing context file failed to read and should be retried later. */\n\thadReadError: boolean;\n}\n\n/**\n * Extract the target of a leading `cd <dir>` from a bash command.\n *\n * Covers the overwhelming majority of directory-changing bash commands (analysis of\n * real session logs: ~75% of bash calls start with `cd`, ~97% of those with an absolute\n * path). Returns the raw, unresolved path string (with a leading `~` preserved) or\n * `null` when the command does not begin with a simple `cd`.\n */\nexport function parseLeadingCd(command: string): string | null {\n\tif (typeof command !== \"string\") return null;\n\n\tconst leadingCd = command.match(/^\\s*cd\\s+/);\n\tif (!leadingCd) return null;\n\n\tlet rest = command.slice(leadingCd[0].length);\n\twhile (true) {\n\t\t// Match either a quoted path or an unquoted token that stops at the first shell\n\t\t// separator (&&, ;, |, newline) or whitespace.\n\t\tconst match = rest.match(/^\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s&;|<>]+))/);\n\t\tif (!match) return null;\n\n\t\tconst target = (match[1] ?? match[2] ?? match[3] ?? \"\").trim();\n\t\tif (!target) return null;\n\n\t\t// `cd -` means \"previous directory\" and cannot be resolved cheaply.\n\t\tif (target === \"-\") return null;\n\n\t\t// Skip leading options (`cd -P /x`, `cd -L /x`). After `--`, the next token is\n\t\t// the path even if it begins with `-`.\n\t\tif (target === \"--\") {\n\t\t\trest = rest.slice(match[0].length);\n\t\t\tconst pathMatch = rest.match(/^\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s&;|<>]+))/);\n\t\t\tif (!pathMatch) return null;\n\t\t\tconst pathTarget = (pathMatch[1] ?? pathMatch[2] ?? pathMatch[3] ?? \"\").trim();\n\t\t\tif (!pathTarget || pathTarget.startsWith(\"$\")) return null;\n\t\t\treturn pathTarget;\n\t\t}\n\t\tif (target.startsWith(\"-\")) {\n\t\t\trest = rest.slice(match[0].length);\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip variable-based targets we cannot resolve cheaply.\n\t\tif (target.startsWith(\"$\")) return null;\n\t\treturn target;\n\t}\n}\n\n/**\n * Expand a leading `~` to the home directory and resolve a raw path string to an absolute\n * path against `baseDir`. Shared by every place that turns a user-supplied path token into\n * an absolute path (`resolveTargetDir`, `resolveSelfReadFile`, bash argument resolution).\n */\nfunction expandToAbsolute(rawPath: string, baseDir: string): string {\n\tif (rawPath === \"~\") {\n\t\trawPath = homedir();\n\t} else if (rawPath.startsWith(`~${sep}`) || rawPath.startsWith(\"~/\")) {\n\t\trawPath = join(homedir(), rawPath.slice(2));\n\t}\n\treturn isAbsolute(rawPath) ? rawPath : resolve(baseDir, rawPath);\n}\n\n/**\n * Resolve the absolute directory a tool call is about to operate in, or `null` when the\n * tool/argument shape does not identify a directory we should react to.\n */\nexport function resolveTargetDir(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tcwd: string,\n): string | null {\n\tif (!args) return null;\n\n\tlet rawPath: string | null = null;\n\n\tif (toolName === \"bash\") {\n\t\trawPath = parseLeadingCd(typeof args.command === \"string\" ? args.command : \"\");\n\t} else if (PATH_TOOLS.has(toolName)) {\n\t\tconst p = args.path;\n\t\tif (typeof p === \"string\" && p.trim() !== \"\") {\n\t\t\trawPath = p;\n\t\t}\n\t}\n\n\tif (!rawPath) return null;\n\n\tconst absolute = expandToAbsolute(rawPath, cwd);\n\n\t// For path-bearing tools the argument is usually a file; for bash `cd` it is a\n\t// directory. Resolve to a directory: existing dirs are used as-is, everything else\n\t// (existing files, not-yet-created files) maps to its parent directory.\n\ttry {\n\t\tif (existsSync(absolute) && statSync(absolute).isDirectory()) {\n\t\t\treturn absolute;\n\t\t}\n\t} catch {\n\t\t// Fall through to dirname on permission/stat errors.\n\t}\n\treturn dirname(absolute);\n}\n\n/** Safe realpath that falls back to the input on error. */\nfunction safeRealpath(p: string): string {\n\ttry {\n\t\t// .native canonicalizes filename case on case-insensitive filesystems\n\t\t// (macOS), which the JS implementation does not.\n\t\treturn realpathSync.native(p);\n\t} catch {\n\t\treturn p;\n\t}\n}\n\nfunction isWithin(parent: string, child: string): boolean {\n\tconst p = safeRealpath(parent);\n\tconst c = safeRealpath(child);\n\treturn c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);\n}\n\n/**\n * Build the ordered list of directories to inspect, from the target directory up to the\n * appropriate ceiling. Ordered outermost-first so the most specific (closest to the\n * target) context appears last, matching session-start precedence.\n *\n * Ceiling priority:\n * 1. `cwd` — when the target is within the cwd subtree (ancestors already loaded at start).\n * 2. The outermost git repo root in the chain (a directory containing `.git`).\n * 3. The outermost directory containing a CLAUDE.md/AGENTS.md.\n * 4. Hard stop at filesystem root, the depth bound, or a permission/stat failure.\n */\nfunction resolveWalkDirs(targetDir: string, cwd: string): string[] {\n\tconst root = resolve(\"/\");\n\n\t// Case 1: target within cwd subtree — never walk above cwd.\n\tif (isWithin(cwd, targetDir)) {\n\t\tconst dirs: string[] = [];\n\t\tlet current = targetDir;\n\t\tconst stop = safeRealpath(cwd);\n\t\tfor (let i = 0; i < MAX_WALK_DEPTH; i++) {\n\t\t\tdirs.push(current);\n\t\t\tif (safeRealpath(current) === stop) break;\n\t\t\tconst parent = resolve(current, \"..\");\n\t\t\tif (parent === current) break;\n\t\t\tcurrent = parent;\n\t\t}\n\t\treturn dirs.reverse();\n\t}\n\n\t// Case 2/3/4: target outside cwd — walk to the hard ceiling, recording git roots and\n\t// directories that hold context files, then bound to the outermost relevant ceiling.\n\tconst chain: string[] = [];\n\tlet highestGitRootIdx = -1;\n\tlet highestContextIdx = -1;\n\tlet current = targetDir;\n\tfor (let i = 0; i < MAX_WALK_DEPTH; i++) {\n\t\t// A permission/stat failure on the directory itself stops the walk.\n\t\ttry {\n\t\t\tstatSync(current);\n\t\t} catch {\n\t\t\tbreak;\n\t\t}\n\t\tchain.push(current);\n\t\tconst idx = chain.length - 1;\n\t\ttry {\n\t\t\tif (existsSync(join(current, \".git\"))) highestGitRootIdx = idx;\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t\tif (dirHasContextFile(current)) highestContextIdx = idx;\n\n\t\tif (current === root) break;\n\t\tconst parent = resolve(current, \"..\");\n\t\tif (parent === current) break;\n\t\tcurrent = parent;\n\t}\n\n\tlet ceilingIdx: number;\n\tif (highestGitRootIdx >= 0) {\n\t\tceilingIdx = highestGitRootIdx;\n\t} else if (highestContextIdx >= 0) {\n\t\tceilingIdx = highestContextIdx;\n\t} else {\n\t\tceilingIdx = chain.length - 1;\n\t}\n\n\treturn chain.slice(0, ceilingIdx + 1).reverse();\n}\n\n/** Cheap check: does this directory hold any candidate context file? */\nfunction dirHasContextFile(dir: string): boolean {\n\tfor (const c of CONTEXT_FILE_CANDIDATES) {\n\t\ttry {\n\t\t\tif (existsSync(join(dir, c))) return true;\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t}\n\treturn false;\n}\n\n/**\n * Predicate deciding whether a collected context file should be *suppressed* from the\n * injected block because the triggering tool call already delivers its content (e.g. a\n * `read` of the file itself, or a `bash` command that prints it). Suppressed files are\n * still marked as loaded so they are never injected later — they are simply not\n * duplicated into the result that already contains them.\n */\nexport type SuppressPredicate = (file: LoadedContextFile) => boolean;\n\n/**\n * Collect nested context files for `targetDir`, walking up to the ceiling described in\n * {@link resolveWalkDirs}. Files whose realpath is already in `alreadyLoaded` are skipped\n * (and not re-reported). Newly collected realpaths are added to `alreadyLoaded` so the\n * caller's per-session set stays authoritative and each file loads at most once. Also\n * reports whether an existing context file failed to read so callers can retry later\n * instead of negatively caching a transient failure.\n *\n * When `suppress` matches a newly-seen file, that file is marked loaded but excluded from\n * the returned `files` — the triggering tool result already contains it, so re-injecting\n * would duplicate the content and waste tokens.\n */\nexport function collectNestedContext(\n\ttargetDir: string,\n\tcwd: string,\n\talreadyLoaded: Set<string>,\n\tsuppress?: SuppressPredicate,\n): NestedContextCollection {\n\tconst dirs = resolveWalkDirs(targetDir, cwd);\n\tconst collected: LoadedContextFile[] = [];\n\tlet hadReadError = false;\n\tfor (const dir of dirs) {\n\t\tconst diagnostics: ResourceDiagnostic[] = [];\n\t\tconst files = loadContextFilesFromDir(dir, diagnostics);\n\t\tfor (const diagnostic of diagnostics) {\n\t\t\tif (diagnostic.type !== \"warning\") continue;\n\t\t\thadReadError = true;\n\t\t\tconsole.warn(\n\t\t\t\t`[nested-context] Nested context file existed but could not be read: ${diagnostic.path ?? dir} — ${diagnostic.message}`,\n\t\t\t);\n\t\t}\n\t\tfor (const file of files) {\n\t\t\tconst real = safeRealpath(file.path);\n\t\t\tif (alreadyLoaded.has(real)) continue;\n\t\t\talreadyLoaded.add(real);\n\t\t\t// Mark loaded but do not inject: the triggering tool already delivers this file.\n\t\t\tif (suppress?.(file)) continue;\n\t\t\tcollected.push(file);\n\t\t}\n\t}\n\treturn { files: collected, hadReadError };\n}\n\n/**\n * Format collected context files into a single text block for injection into a tool\n * result. Leads with *why* the load happened and headers each file with its source path.\n * There is intentionally no size cap — oversized context files are the project's concern.\n */\nexport function formatNestedContextBlock(targetDir: string, files: LoadedContextFile[]): string {\n\tconst header =\n\t\t`[dreb] Auto-loaded project context\\n\\n` +\n\t\t`A tool just operated in \\`${targetDir}\\`, whose project context had not been loaded yet. ` +\n\t\t`The file(s) below were loaded automatically to prevent missing important project context ` +\n\t\t`when working across multiple repos / projects / folders. ` +\n\t\t`(Disable with the \\`context.autoLoadNested\\` setting.)`;\n\n\tconst sections = files.map(\n\t\t(f) =>\n\t\t\t`===== BEGIN project context: ${f.path} =====\\n${f.content.trim()}\\n===== END project context: ${f.path} =====`,\n\t);\n\n\treturn `${header}\\n\\n${sections.join(\"\\n\\n\")}`;\n}\n\n/**\n * Resolve the absolute file path a `read` tool call delivers, or `null` when the tool is\n * not `read` or has no usable `path`. Only `read` returns the *full* file content, so it\n * is the only path-tool whose result fully duplicates an injected context file. (`grep`\n * returns matched lines, `ls`/`edit`/`write` do not echo the whole file — those still\n * benefit from injection.)\n */\nexport function resolveSelfReadFile(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tcwd: string,\n): string | null {\n\tif (!args || toolName !== \"read\") return null;\n\t// A sliced read (`offset`/`limit`) delivers only a fragment of the file — the same\n\t// hazard for which bash partial viewers (`head`/`tail`) are excluded. Treating it as a\n\t// full delivery would suppress (and permanently mark loaded) a file the result only\n\t// partially contains, silently dropping the rest. Fall back to the safe double-load.\n\tif (args.offset !== undefined || args.limit !== undefined) return null;\n\tconst p = args.path;\n\tif (typeof p !== \"string\" || p.trim() === \"\") return null;\n\treturn expandToAbsolute(p, cwd);\n}\n\n/**\n * Bash commands that dump a file's *full* contents to stdout. Deliberately narrow: only\n * commands that emit the whole file qualify. Partial viewers (`head`/`tail`) and\n * interactive pagers (`less`/`more`) are excluded — they may show only a fragment, so\n * treating them as \"delivered\" could silently drop the rest of a context file. The safe\n * failure mode is a harmless double-load (we still inject), never a silent context drop.\n *\n * `bat` is included but is *not* unconditionally a full dump: its `-r`/`--line-range` flag\n * emits only a range (same hazard as `head`/`tail`). Segments carrying that flag are\n * disqualified in {@link resolveBashDeliveredFiles}.\n */\nconst FULL_DUMP_COMMANDS = new Set([\"cat\", \"bat\"]);\n\n/** `bat` flags that limit output to a partial range — disqualify the segment if present. */\nconst BAT_RANGE_FLAGS = [\"-r\", \"--line-range\"];\n\n/**\n * Whether a `bat` token requests a partial line range. Matches the space-separated form\n * (`-r`, `--line-range`), the `=`-attached long form (`--line-range=10:20`), and the\n * attached short form (`-r10:20`) — clap accepts an attached value on a short flag, so a\n * bare `startsWith` check on each known range flag covers every spelling. A partial range\n * is the same hazard as `head`/`tail`: only a fragment is emitted, so the segment must not\n * be treated as a full dump.\n */\nfunction isBatRangeFlag(token: string): boolean {\n\treturn BAT_RANGE_FLAGS.some((flag) => token.startsWith(flag));\n}\n\n/** Strip a single layer of matching surrounding quotes from a shell token. */\nfunction unquoteToken(token: string): string {\n\tif (token.length >= 2) {\n\t\tconst first = token[0];\n\t\tconst last = token[token.length - 1];\n\t\tif ((first === '\"' || first === \"'\") && first === last) {\n\t\t\treturn token.slice(1, -1);\n\t\t}\n\t}\n\treturn token;\n}\n\n/**\n * Resolve the absolute paths of files a bash command fully delivers to stdout via a\n * full-dump command (`cat`/`bat`). Path arguments are resolved against `workingDir` (the\n * command's effective cwd — e.g. a leading `cd` target). Conservative on purpose:\n *\n * - Segments are split on `&&`, `||`, `;`. Segments that are *only* a `cd` produce no\n * stdout and are ignored, but there must be **exactly one** remaining output-producing\n * segment. The bash tool truncates its *combined* command output from the **tail**\n * (keeping the last {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES} bytes and\n * dropping the head), so any *additional* output-producing segment could evict the dumped\n * file from the visible window while {@link deliveredInFull} — which measures the file\n * alone — still reports a full delivery. Bail to the safe double-load in that case.\n * - That sole segment must not contain a pipe (`|`), output redirection (`>`), or input\n * redirection / here-doc / here-string (`<`, `<<`, `<<<`) — its output is filtered /\n * redirected, or its operands are stdin body words rather than dumped files.\n * - Its first token must be `cat`/`bat`; flags (`-…`) are ignored.\n * - A `bat` segment carrying a partial-range flag (`-r`/`--line-range`, any spelling) is\n * skipped — it emits only a fragment, like `head`/`tail`.\n * - It must have **exactly one** file operand. A multi-file dump (`cat A.md B.md`)\n * concatenates several files; under tail truncation an earlier operand can be evicted\n * while still appearing fully sized on disk, so it is not a provable full delivery.\n * - If the command chains more than one `cd`, the effective cwd is ambiguous (we only\n * resolved the *first* `cd`), so operands cannot be resolved reliably — return nothing.\n *\n * Anything we cannot confidently classify as a full delivery is omitted, so the worst case\n * is a double-load rather than a silently dropped context file.\n */\nexport function resolveBashDeliveredFiles(command: string, workingDir: string): string[] {\n\tif (typeof command !== \"string\" || command.trim() === \"\") return [];\n\tconst segments = command.split(/&&|\\|\\||;/);\n\tconst isCdSegment = (s: string) => /^\\s*cd(\\s|$)/.test(s);\n\n\t// More than one `cd` means the effective cwd differs from the first `cd` target we\n\t// resolved as `workingDir`; resolving operands against it would suppress the wrong\n\t// (same-named) file. Bail to the safe double-load.\n\tif (segments.filter(isCdSegment).length > 1) return [];\n\n\t// Segments that are only a `cd` emit no stdout. Everything else produces output, and\n\t// because the bash tool tail-truncates the *combined* output, a context file is only\n\t// provably delivered in full when it is the command's *sole* output-producing segment.\n\tconst outputSegments = segments.filter((s) => s.trim() !== \"\" && !isCdSegment(s));\n\tif (outputSegments.length !== 1) return [];\n\n\tconst segment = outputSegments[0];\n\t// Output piped/redirected, or operands fed via input redirection / here-doc, are not raw\n\t// file dumps to stdout.\n\tif (segment.includes(\"|\") || segment.includes(\">\") || segment.includes(\"<\")) return [];\n\tconst tokens = segment.trim().split(/\\s+/).filter(Boolean);\n\tif (tokens.length === 0) return [];\n\t// Match the command verb case-sensitively: shell PATH lookup is case-sensitive on\n\t// Linux, so `CAT`/`Bat` are command-not-found and emit nothing to stdout. Lowercasing\n\t// would let them match the allowlist and falsely suppress a file they never printed —\n\t// a silent context drop. Exact matching keeps the failure mode a harmless double-load.\n\tconst cmd = tokens[0];\n\tif (!FULL_DUMP_COMMANDS.has(cmd)) return [];\n\t// `bat -r 10:20` / `bat -r10:20` / `bat --line-range=10:20` shows only a range — not a full dump.\n\tif (cmd === \"bat\" && tokens.slice(1).some(isBatRangeFlag)) return [];\n\n\tconst operands: string[] = [];\n\tfor (const token of tokens.slice(1)) {\n\t\tif (token.startsWith(\"-\")) continue; // flag, not a file argument\n\t\tconst arg = unquoteToken(token);\n\t\tif (arg === \"\") continue;\n\t\toperands.push(arg);\n\t}\n\t// A single operand is the only provable full delivery: multi-file dumps concatenate,\n\t// and tail truncation can evict an earlier file while it still looks fully sized.\n\tif (operands.length !== 1) return [];\n\treturn [expandToAbsolute(operands[0], workingDir)];\n}\n\nexport interface NestedContextState {\n\t/** Whether auto-loading is enabled (the `context.autoLoadNested` setting). */\n\tenabled: boolean;\n\t/** The session's working directory. */\n\tcwd: string;\n\t/** Realpaths of context files already loaded this session (seeded at session start). Mutated. */\n\tloaded: Set<string>;\n\t/** Realpaths of directories already scanned (negative cache). Mutated. */\n\tscannedDirs: Set<string>;\n}\n\n/**\n * Whether a tool that delivers `realPath` actually delivers its *full* content. Both `read`\n * and `bash` truncate their output at {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES}\n * bytes, while {@link formatNestedContextBlock} is uncapped. If the file exceeds either limit\n * it is delivered truncated, so suppressing (and permanently marking loaded) would silently\n * drop the remainder. Any stat/read failure also returns `false` — the safe double-load.\n *\n * `rendered` selects which delivery the measure must mirror:\n * - `read` delivers the file's raw content unchanged (`truncateHead` with no transform), so\n * the raw byte/line count is exact.\n * - `bash` delivers `truncateTail(renderTerminalOutput(...))`, and terminal rendering expands\n * tabs to 8-column stops and resolves cursor/ANSI sequences — the rendered output can be\n * *larger* than the file on disk. A tab-dense file just under the budget on disk can render\n * past it and be tail-truncated (its head dropped) while the raw measure still reports a\n * full delivery. Measuring the rendered output keeps the failure mode a harmless double-load\n * rather than a silent context drop.\n */\nfunction deliveredInFull(realPath: string, rendered: boolean): boolean {\n\ttry {\n\t\t// Cheap early-out: the raw on-disk size is a lower bound on the delivered size\n\t\t// (terminal rendering only ever grows the byte count), so a file already over the\n\t\t// byte budget on disk is certainly delivered truncated.\n\t\tif (statSync(realPath).size > DEFAULT_MAX_BYTES) return false;\n\t\tconst raw = readFileSync(realPath, \"utf8\");\n\t\tconst delivered = rendered ? renderTerminalOutput(raw) : raw;\n\t\tif (Buffer.byteLength(delivered, \"utf-8\") > DEFAULT_MAX_BYTES) return false;\n\t\treturn delivered.split(\"\\n\").length <= DEFAULT_MAX_LINES;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * Orchestrate a single nested-context decision for a tool call: gate on the setting,\n * resolve the target directory, skip directories already scanned (negative cache),\n * collect not-yet-loaded context files, and format them. Returns the injection block or\n * `null` when nothing should be injected. Mutates `state.scannedDirs` and `state.loaded`.\n */\nexport function computeNestedContextBlock(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tstate: NestedContextState,\n): string | null {\n\tif (!state.enabled) return null;\n\n\tconst targetDir = resolveTargetDir(toolName, args, state.cwd);\n\tif (!targetDir) return null;\n\n\tconst realTarget = safeRealpath(targetDir);\n\tif (state.scannedDirs.has(realTarget)) return null;\n\n\t// A context file the triggering tool already delivers should be marked loaded but not\n\t// re-injected (the result already contains it). Two cases: a `read` of the file itself,\n\t// or a `bash` command that dumps its full contents (`cat`/`bat`). Both are matched by\n\t// full resolved realpath — never by basename — so printing one file never suppresses a\n\t// same-named sibling/ancestor or a file in a different directory.\n\tconst selfReadFile = resolveSelfReadFile(toolName, args, state.cwd);\n\tconst realSelfReadFile = selfReadFile ? safeRealpath(selfReadFile) : null;\n\tconst bashCommand = toolName === \"bash\" && typeof args?.command === \"string\" ? args.command : null;\n\t// Bash file arguments resolve against the command's effective cwd, which `resolveTargetDir`\n\t// has already computed as `targetDir` (the leading `cd` destination).\n\tconst bashDelivered = bashCommand\n\t\t? new Set(resolveBashDeliveredFiles(bashCommand, targetDir).map(safeRealpath))\n\t\t: null;\n\tconst suppress: SuppressPredicate = (file) => {\n\t\tconst realFile = safeRealpath(file.path);\n\t\t// Only suppress when the file was delivered *in full*: a truncated delivery (oversized\n\t\t// file) would drop the remainder if we marked it fully loaded and skipped injection.\n\t\t// `read` delivers the file's raw content unchanged; `bash` delivers it through terminal\n\t\t// rendering (tab/ANSI expansion can grow it past the truncation budget), so each path\n\t\t// measures fullness against what it actually emits.\n\t\tif (realFile === realSelfReadFile) return deliveredInFull(realFile, false);\n\t\tif (bashDelivered?.has(realFile)) return deliveredInFull(realFile, true);\n\t\treturn false;\n\t};\n\n\tconst collected = collectNestedContext(targetDir, state.cwd, state.loaded, suppress);\n\tif (!collected.hadReadError) {\n\t\tstate.scannedDirs.add(realTarget);\n\t}\n\tif (collected.files.length === 0) return null;\n\treturn formatNestedContextBlock(targetDir, collected.files);\n}\n"]}
1
+ {"version":3,"file":"nested-context.d.ts","sourceRoot":"","sources":["../../src/core/nested-context.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,kBAAkB,EAA4C,MAAM,oBAAoB,CAAC;AAsBvG,MAAM,WAAW,iBAAiB;IACjC,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,uBAAuB;IACvC,8DAA8D;IAC9D,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,oFAAoF;IACpF,YAAY,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsC7D;AAgBD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,GAAG,EAAE,MAAM,GACT,MAAM,GAAG,IAAI,CA6Bf;AAuHD;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,IAAI,EAAE,iBAAiB,KAAK,OAAO,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CACnC,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,WAAW,CAAC,EAAE,MAAM,GAClB,uBAAuB,CAwBzB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,EAAE,GAAG,MAAM,CAc9F;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAClC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,GAAG,EAAE,MAAM,GACT,MAAM,GAAG,IAAI,CAUf;AA0CD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CA0CvF;AAED,MAAM,WAAW,kBAAkB;IAClC,qFAAqF;IACrF,MAAM,EAAE,kBAAkB,CAAC;IAC3B,uCAAuC;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,iGAAiG;IACjG,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,0EAA0E;IAC1E,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAkCD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACxC,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,KAAK,EAAE,kBAAkB,GACvB,MAAM,GAAG,IAAI,CA2Cf","sourcesContent":["import { existsSync, readFileSync, realpathSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve, sep } from \"node:path\";\nimport { type ContextTrustPolicy, isWithinCanonicalRoot, matchContextTrust } from \"./context-trust.js\";\nimport { CONTEXT_FILE_CANDIDATES, loadContextFilesFromDir, type ResourceDiagnostic } from \"./resource-loader.js\";\nimport { renderTerminalOutput } from \"./tools/terminal-render.js\";\nimport { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from \"./tools/truncate.js\";\n\n/**\n * Auto-load of nested AGENTS.md/CLAUDE.md context files.\n *\n * Project context files are only loaded at session start by walking *upward* from\n * `cwd`. When the agent (or a subagent) operates in a subdirectory — or in an entirely\n * different repo/project — that directory's context files are never loaded. This module\n * detects the directory a tool is about to operate in, walks up to a sensible ceiling\n * collecting context files, and returns a formatted block for injection into the tool\n * result (which is cache-safe — it does not rebuild the system prompt).\n */\n\n/** A safety bound on how many directories the upward walk will visit. */\nconst MAX_WALK_DEPTH = 64;\n\n/** Tools whose `path` argument identifies the directory being operated on. */\nconst PATH_TOOLS = new Set([\"read\", \"edit\", \"write\", \"grep\", \"find\", \"ls\"]);\n\nexport interface LoadedContextFile {\n\t/** Absolute path of the loaded context file. */\n\tpath: string;\n\t/** File content (HTML comments already stripped by the loader). */\n\tcontent: string;\n}\n\nexport interface NestedContextCollection {\n\t/** Context files newly loaded during this collection pass. */\n\tfiles: LoadedContextFile[];\n\t/** Whether any existing context file failed to read and should be retried later. */\n\thadReadError: boolean;\n}\n\n/**\n * Extract the target of a leading `cd <dir>` from a bash command.\n *\n * Covers the overwhelming majority of directory-changing bash commands (analysis of\n * real session logs: ~75% of bash calls start with `cd`, ~97% of those with an absolute\n * path). Returns the raw, unresolved path string (with a leading `~` preserved) or\n * `null` when the command does not begin with a simple `cd`.\n */\nexport function parseLeadingCd(command: string): string | null {\n\tif (typeof command !== \"string\") return null;\n\n\tconst leadingCd = command.match(/^\\s*cd\\s+/);\n\tif (!leadingCd) return null;\n\n\tlet rest = command.slice(leadingCd[0].length);\n\twhile (true) {\n\t\t// Match either a quoted path or an unquoted token that stops at the first shell\n\t\t// separator (&&, ;, |, newline) or whitespace.\n\t\tconst match = rest.match(/^\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s&;|<>]+))/);\n\t\tif (!match) return null;\n\n\t\tconst target = (match[1] ?? match[2] ?? match[3] ?? \"\").trim();\n\t\tif (!target) return null;\n\n\t\t// `cd -` means \"previous directory\" and cannot be resolved cheaply.\n\t\tif (target === \"-\") return null;\n\n\t\t// Skip leading options (`cd -P /x`, `cd -L /x`). After `--`, the next token is\n\t\t// the path even if it begins with `-`.\n\t\tif (target === \"--\") {\n\t\t\trest = rest.slice(match[0].length);\n\t\t\tconst pathMatch = rest.match(/^\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s&;|<>]+))/);\n\t\t\tif (!pathMatch) return null;\n\t\t\tconst pathTarget = (pathMatch[1] ?? pathMatch[2] ?? pathMatch[3] ?? \"\").trim();\n\t\t\tif (!pathTarget || pathTarget.startsWith(\"$\")) return null;\n\t\t\treturn pathTarget;\n\t\t}\n\t\tif (target.startsWith(\"-\")) {\n\t\t\trest = rest.slice(match[0].length);\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Skip variable-based targets we cannot resolve cheaply.\n\t\tif (target.startsWith(\"$\")) return null;\n\t\treturn target;\n\t}\n}\n\n/**\n * Expand a leading `~` to the home directory and resolve a raw path string to an absolute\n * path against `baseDir`. Shared by every place that turns a user-supplied path token into\n * an absolute path (`resolveTargetDir`, `resolveSelfReadFile`, bash argument resolution).\n */\nfunction expandToAbsolute(rawPath: string, baseDir: string): string {\n\tif (rawPath === \"~\") {\n\t\trawPath = homedir();\n\t} else if (rawPath.startsWith(`~${sep}`) || rawPath.startsWith(\"~/\")) {\n\t\trawPath = join(homedir(), rawPath.slice(2));\n\t}\n\treturn isAbsolute(rawPath) ? rawPath : resolve(baseDir, rawPath);\n}\n\n/**\n * Resolve the absolute directory a tool call is about to operate in, or `null` when the\n * tool/argument shape does not identify a directory we should react to.\n */\nexport function resolveTargetDir(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tcwd: string,\n): string | null {\n\tif (!args) return null;\n\n\tlet rawPath: string | null = null;\n\n\tif (toolName === \"bash\") {\n\t\trawPath = parseLeadingCd(typeof args.command === \"string\" ? args.command : \"\");\n\t} else if (PATH_TOOLS.has(toolName)) {\n\t\tconst p = args.path;\n\t\tif (typeof p === \"string\" && p.trim() !== \"\") {\n\t\t\trawPath = p;\n\t\t}\n\t}\n\n\tif (!rawPath) return null;\n\n\tconst absolute = expandToAbsolute(rawPath, cwd);\n\n\t// For path-bearing tools the argument is usually a file; for bash `cd` it is a\n\t// directory. Resolve to a directory: existing dirs are used as-is, everything else\n\t// (existing files, not-yet-created files) maps to its parent directory.\n\ttry {\n\t\tif (existsSync(absolute) && statSync(absolute).isDirectory()) {\n\t\t\treturn absolute;\n\t\t}\n\t} catch {\n\t\t// Fall through to dirname on permission/stat errors.\n\t}\n\treturn dirname(absolute);\n}\n\n/** Safe realpath that falls back to the input on error. */\nfunction safeRealpath(p: string): string {\n\ttry {\n\t\t// .native canonicalizes filename case on case-insensitive filesystems\n\t\t// (macOS), which the JS implementation does not.\n\t\treturn realpathSync.native(p);\n\t} catch {\n\t\treturn p;\n\t}\n}\n\nfunction isWithin(parent: string, child: string): boolean {\n\tconst p = safeRealpath(parent);\n\tconst c = safeRealpath(child);\n\treturn c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);\n}\n\n/**\n * Build the ordered list of directories to inspect, from the target directory up to the\n * appropriate ceiling. Ordered outermost-first so the most specific (closest to the\n * target) context appears last, matching session-start precedence.\n *\n * Ceiling priority:\n * 1. `cwd` — when the target is within the cwd subtree (ancestors already loaded at start).\n * 2. The outermost git repo root in the chain (a directory containing `.git`).\n * 3. The outermost directory containing a CLAUDE.md/AGENTS.md.\n * 4. Hard stop at filesystem root, the depth bound, or a permission/stat failure.\n */\nfunction resolveWalkDirs(targetDir: string, cwd: string, trustedRoot?: string): string[] {\n\tconst root = resolve(\"/\");\n\n\t// A narrowly trusted root is a hard ceiling. It also preserves the existing cwd\n\t// ceiling when that is nearer to the target, so startup-loaded ancestors are not\n\t// revisited. `trustedRoot` and target have already passed strict native-realpath\n\t// containment before this function is called.\n\tif (trustedRoot) {\n\t\tconst canonicalCwd = safeRealpath(cwd);\n\t\tconst stopAtCwd = isWithinCanonicalRoot(canonicalCwd, targetDir);\n\t\tconst dirs: string[] = [];\n\t\tlet current = targetDir;\n\t\tfor (let i = 0; i < MAX_WALK_DEPTH; i++) {\n\t\t\tdirs.push(current);\n\t\t\tif (current === trustedRoot || (stopAtCwd && current === canonicalCwd)) break;\n\t\t\tconst parent = resolve(current, \"..\");\n\t\t\tif (parent === current) break;\n\t\t\tcurrent = parent;\n\t\t}\n\t\treturn dirs.reverse();\n\t}\n\n\t// Case 1: target within cwd subtree — never walk above cwd.\n\tif (isWithin(cwd, targetDir)) {\n\t\tconst dirs: string[] = [];\n\t\tlet current = targetDir;\n\t\tconst stop = safeRealpath(cwd);\n\t\tfor (let i = 0; i < MAX_WALK_DEPTH; i++) {\n\t\t\tdirs.push(current);\n\t\t\tif (safeRealpath(current) === stop) break;\n\t\t\tconst parent = resolve(current, \"..\");\n\t\t\tif (parent === current) break;\n\t\t\tcurrent = parent;\n\t\t}\n\t\treturn dirs.reverse();\n\t}\n\n\t// Case 2/3/4: target outside cwd — walk to the hard ceiling, recording git roots and\n\t// directories that hold context files, then bound to the outermost relevant ceiling.\n\tconst chain: string[] = [];\n\tlet highestGitRootIdx = -1;\n\tlet highestContextIdx = -1;\n\tlet current = targetDir;\n\tfor (let i = 0; i < MAX_WALK_DEPTH; i++) {\n\t\t// A permission/stat failure on the directory itself stops the walk.\n\t\ttry {\n\t\t\tstatSync(current);\n\t\t} catch {\n\t\t\tbreak;\n\t\t}\n\t\tchain.push(current);\n\t\tconst idx = chain.length - 1;\n\t\ttry {\n\t\t\tif (existsSync(join(current, \".git\"))) highestGitRootIdx = idx;\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t\tif (dirHasContextFile(current)) highestContextIdx = idx;\n\n\t\tif (current === root) break;\n\t\tconst parent = resolve(current, \"..\");\n\t\tif (parent === current) break;\n\t\tcurrent = parent;\n\t}\n\n\tlet ceilingIdx: number;\n\tif (highestGitRootIdx >= 0) {\n\t\tceilingIdx = highestGitRootIdx;\n\t} else if (highestContextIdx >= 0) {\n\t\tceilingIdx = highestContextIdx;\n\t} else {\n\t\tceilingIdx = chain.length - 1;\n\t}\n\n\treturn chain.slice(0, ceilingIdx + 1).reverse();\n}\n\n/** Cheap check: does this directory hold any candidate context file? */\nfunction dirHasContextFile(dir: string): boolean {\n\tfor (const c of CONTEXT_FILE_CANDIDATES) {\n\t\ttry {\n\t\t\tif (existsSync(join(dir, c))) return true;\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t}\n\treturn false;\n}\n\n/**\n * Predicate deciding whether a collected context file should be *suppressed* from the\n * injected block because the triggering tool call already delivers its content (e.g. a\n * `read` of the file itself, or a `bash` command that prints it). Suppressed files are\n * still marked as loaded so they are never injected later — they are simply not\n * duplicated into the result that already contains them.\n */\nexport type SuppressPredicate = (file: LoadedContextFile) => boolean;\n\n/**\n * Collect nested context files for `targetDir`, walking up to the ceiling described in\n * {@link resolveWalkDirs}. Files whose realpath is already in `alreadyLoaded` are skipped\n * (and not re-reported). Newly collected realpaths are added to `alreadyLoaded` so the\n * caller's per-session set stays authoritative and each file loads at most once. Also\n * reports whether an existing context file failed to read so callers can retry later\n * instead of negatively caching a transient failure.\n *\n * When `suppress` matches a newly-seen file, that file is marked loaded but excluded from\n * the returned `files` — the triggering tool result already contains it, so re-injecting\n * would duplicate the content and waste tokens.\n */\nexport function collectNestedContext(\n\ttargetDir: string,\n\tcwd: string,\n\talreadyLoaded: Set<string>,\n\tsuppress?: SuppressPredicate,\n\ttrustedRoot?: string,\n): NestedContextCollection {\n\tconst dirs = resolveWalkDirs(targetDir, cwd, trustedRoot);\n\tconst collected: LoadedContextFile[] = [];\n\tlet hadReadError = false;\n\tfor (const dir of dirs) {\n\t\tconst diagnostics: ResourceDiagnostic[] = [];\n\t\tconst files = loadContextFilesFromDir(dir, diagnostics, trustedRoot);\n\t\tfor (const diagnostic of diagnostics) {\n\t\t\tif (diagnostic.type !== \"warning\") continue;\n\t\t\thadReadError = true;\n\t\t\tconsole.warn(\n\t\t\t\t`[nested-context] Nested context file existed but could not be read: ${diagnostic.path ?? dir} — ${diagnostic.message}`,\n\t\t\t);\n\t\t}\n\t\tfor (const file of files) {\n\t\t\tconst real = safeRealpath(file.path);\n\t\t\tif (alreadyLoaded.has(real)) continue;\n\t\t\talreadyLoaded.add(real);\n\t\t\t// Mark loaded but do not inject: the triggering tool already delivers this file.\n\t\t\tif (suppress?.(file)) continue;\n\t\t\tcollected.push(file);\n\t\t}\n\t}\n\treturn { files: collected, hadReadError };\n}\n\n/**\n * Format collected context files into a single text block for injection into a tool\n * result. Leads with *why* the load happened and headers each file with its source path.\n * There is intentionally no size cap — oversized context files are the project's concern.\n */\nexport function formatNestedContextBlock(targetDir: string, files: LoadedContextFile[]): string {\n\tconst header =\n\t\t`[dreb] Auto-loaded project context\\n\\n` +\n\t\t`A tool just operated in \\`${targetDir}\\`, whose project context had not been loaded yet. ` +\n\t\t`The file(s) below were loaded automatically to prevent missing important project context ` +\n\t\t`when working across multiple repos / projects / folders. ` +\n\t\t`(Manage folder-specific loading with \\`context.trustedFolders\\`, or disable unrestricted loading with \\`context.autoLoadNested\\`.)`;\n\n\tconst sections = files.map(\n\t\t(f) =>\n\t\t\t`===== BEGIN project context: ${f.path} =====\\n${f.content.trim()}\\n===== END project context: ${f.path} =====`,\n\t);\n\n\treturn `${header}\\n\\n${sections.join(\"\\n\\n\")}`;\n}\n\n/**\n * Resolve the absolute file path a `read` tool call delivers, or `null` when the tool is\n * not `read` or has no usable `path`. Only `read` returns the *full* file content, so it\n * is the only path-tool whose result fully duplicates an injected context file. (`grep`\n * returns matched lines, `ls`/`edit`/`write` do not echo the whole file — those still\n * benefit from injection.)\n */\nexport function resolveSelfReadFile(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tcwd: string,\n): string | null {\n\tif (!args || toolName !== \"read\") return null;\n\t// A sliced read (`offset`/`limit`) delivers only a fragment of the file — the same\n\t// hazard for which bash partial viewers (`head`/`tail`) are excluded. Treating it as a\n\t// full delivery would suppress (and permanently mark loaded) a file the result only\n\t// partially contains, silently dropping the rest. Fall back to the safe double-load.\n\tif (args.offset !== undefined || args.limit !== undefined) return null;\n\tconst p = args.path;\n\tif (typeof p !== \"string\" || p.trim() === \"\") return null;\n\treturn expandToAbsolute(p, cwd);\n}\n\n/**\n * Bash commands that dump a file's *full* contents to stdout. Deliberately narrow: only\n * commands that emit the whole file qualify. Partial viewers (`head`/`tail`) and\n * interactive pagers (`less`/`more`) are excluded — they may show only a fragment, so\n * treating them as \"delivered\" could silently drop the rest of a context file. The safe\n * failure mode is a harmless double-load (we still inject), never a silent context drop.\n *\n * `bat` is included but is *not* unconditionally a full dump: its `-r`/`--line-range` flag\n * emits only a range (same hazard as `head`/`tail`). Segments carrying that flag are\n * disqualified in {@link resolveBashDeliveredFiles}.\n */\nconst FULL_DUMP_COMMANDS = new Set([\"cat\", \"bat\"]);\n\n/** `bat` flags that limit output to a partial range — disqualify the segment if present. */\nconst BAT_RANGE_FLAGS = [\"-r\", \"--line-range\"];\n\n/**\n * Whether a `bat` token requests a partial line range. Matches the space-separated form\n * (`-r`, `--line-range`), the `=`-attached long form (`--line-range=10:20`), and the\n * attached short form (`-r10:20`) — clap accepts an attached value on a short flag, so a\n * bare `startsWith` check on each known range flag covers every spelling. A partial range\n * is the same hazard as `head`/`tail`: only a fragment is emitted, so the segment must not\n * be treated as a full dump.\n */\nfunction isBatRangeFlag(token: string): boolean {\n\treturn BAT_RANGE_FLAGS.some((flag) => token.startsWith(flag));\n}\n\n/** Strip a single layer of matching surrounding quotes from a shell token. */\nfunction unquoteToken(token: string): string {\n\tif (token.length >= 2) {\n\t\tconst first = token[0];\n\t\tconst last = token[token.length - 1];\n\t\tif ((first === '\"' || first === \"'\") && first === last) {\n\t\t\treturn token.slice(1, -1);\n\t\t}\n\t}\n\treturn token;\n}\n\n/**\n * Resolve the absolute paths of files a bash command fully delivers to stdout via a\n * full-dump command (`cat`/`bat`). Path arguments are resolved against `workingDir` (the\n * command's effective cwd — e.g. a leading `cd` target). Conservative on purpose:\n *\n * - Segments are split on `&&`, `||`, `;`. Segments that are *only* a `cd` produce no\n * stdout and are ignored, but there must be **exactly one** remaining output-producing\n * segment. The bash tool truncates its *combined* command output from the **tail**\n * (keeping the last {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES} bytes and\n * dropping the head), so any *additional* output-producing segment could evict the dumped\n * file from the visible window while {@link deliveredInFull} — which measures the file\n * alone — still reports a full delivery. Bail to the safe double-load in that case.\n * - That sole segment must not contain a pipe (`|`), output redirection (`>`), or input\n * redirection / here-doc / here-string (`<`, `<<`, `<<<`) — its output is filtered /\n * redirected, or its operands are stdin body words rather than dumped files.\n * - Its first token must be `cat`/`bat`; flags (`-…`) are ignored.\n * - A `bat` segment carrying a partial-range flag (`-r`/`--line-range`, any spelling) is\n * skipped — it emits only a fragment, like `head`/`tail`.\n * - It must have **exactly one** file operand. A multi-file dump (`cat A.md B.md`)\n * concatenates several files; under tail truncation an earlier operand can be evicted\n * while still appearing fully sized on disk, so it is not a provable full delivery.\n * - If the command chains more than one `cd`, the effective cwd is ambiguous (we only\n * resolved the *first* `cd`), so operands cannot be resolved reliably — return nothing.\n *\n * Anything we cannot confidently classify as a full delivery is omitted, so the worst case\n * is a double-load rather than a silently dropped context file.\n */\nexport function resolveBashDeliveredFiles(command: string, workingDir: string): string[] {\n\tif (typeof command !== \"string\" || command.trim() === \"\") return [];\n\tconst segments = command.split(/&&|\\|\\||;/);\n\tconst isCdSegment = (s: string) => /^\\s*cd(\\s|$)/.test(s);\n\n\t// More than one `cd` means the effective cwd differs from the first `cd` target we\n\t// resolved as `workingDir`; resolving operands against it would suppress the wrong\n\t// (same-named) file. Bail to the safe double-load.\n\tif (segments.filter(isCdSegment).length > 1) return [];\n\n\t// Segments that are only a `cd` emit no stdout. Everything else produces output, and\n\t// because the bash tool tail-truncates the *combined* output, a context file is only\n\t// provably delivered in full when it is the command's *sole* output-producing segment.\n\tconst outputSegments = segments.filter((s) => s.trim() !== \"\" && !isCdSegment(s));\n\tif (outputSegments.length !== 1) return [];\n\n\tconst segment = outputSegments[0];\n\t// Output piped/redirected, or operands fed via input redirection / here-doc, are not raw\n\t// file dumps to stdout.\n\tif (segment.includes(\"|\") || segment.includes(\">\") || segment.includes(\"<\")) return [];\n\tconst tokens = segment.trim().split(/\\s+/).filter(Boolean);\n\tif (tokens.length === 0) return [];\n\t// Match the command verb case-sensitively: shell PATH lookup is case-sensitive on\n\t// Linux, so `CAT`/`Bat` are command-not-found and emit nothing to stdout. Lowercasing\n\t// would let them match the allowlist and falsely suppress a file they never printed —\n\t// a silent context drop. Exact matching keeps the failure mode a harmless double-load.\n\tconst cmd = tokens[0];\n\tif (!FULL_DUMP_COMMANDS.has(cmd)) return [];\n\t// `bat -r 10:20` / `bat -r10:20` / `bat --line-range=10:20` shows only a range — not a full dump.\n\tif (cmd === \"bat\" && tokens.slice(1).some(isBatRangeFlag)) return [];\n\n\tconst operands: string[] = [];\n\tfor (const token of tokens.slice(1)) {\n\t\tif (token.startsWith(\"-\")) continue; // flag, not a file argument\n\t\tconst arg = unquoteToken(token);\n\t\tif (arg === \"\") continue;\n\t\toperands.push(arg);\n\t}\n\t// A single operand is the only provable full delivery: multi-file dumps concatenate,\n\t// and tail truncation can evict an earlier file while it still looks fully sized.\n\tif (operands.length !== 1) return [];\n\treturn [expandToAbsolute(operands[0], workingDir)];\n}\n\nexport interface NestedContextState {\n\t/** Global-only policy for this decision. Project settings never enter this value. */\n\tpolicy: ContextTrustPolicy;\n\t/** The session's working directory. */\n\tcwd: string;\n\t/** Realpaths of context files already loaded this session (seeded at session start). Mutated. */\n\tloaded: Set<string>;\n\t/** Realpaths of directories already scanned (negative cache). Mutated. */\n\tscannedDirs: Set<string>;\n}\n\n/**\n * Whether a tool that delivers `realPath` actually delivers its *full* content. Both `read`\n * and `bash` truncate their output at {@link DEFAULT_MAX_LINES} lines / {@link DEFAULT_MAX_BYTES}\n * bytes, while {@link formatNestedContextBlock} is uncapped. If the file exceeds either limit\n * it is delivered truncated, so suppressing (and permanently marking loaded) would silently\n * drop the remainder. Any stat/read failure also returns `false` — the safe double-load.\n *\n * `rendered` selects which delivery the measure must mirror:\n * - `read` delivers the file's raw content unchanged (`truncateHead` with no transform), so\n * the raw byte/line count is exact.\n * - `bash` delivers `truncateTail(renderTerminalOutput(...))`, and terminal rendering expands\n * tabs to 8-column stops and resolves cursor/ANSI sequences — the rendered output can be\n * *larger* than the file on disk. A tab-dense file just under the budget on disk can render\n * past it and be tail-truncated (its head dropped) while the raw measure still reports a\n * full delivery. Measuring the rendered output keeps the failure mode a harmless double-load\n * rather than a silent context drop.\n */\nfunction deliveredInFull(realPath: string, rendered: boolean): boolean {\n\ttry {\n\t\t// Cheap early-out: the raw on-disk size is a lower bound on the delivered size\n\t\t// (terminal rendering only ever grows the byte count), so a file already over the\n\t\t// byte budget on disk is certainly delivered truncated.\n\t\tif (statSync(realPath).size > DEFAULT_MAX_BYTES) return false;\n\t\tconst raw = readFileSync(realPath, \"utf8\");\n\t\tconst delivered = rendered ? renderTerminalOutput(raw) : raw;\n\t\tif (Buffer.byteLength(delivered, \"utf-8\") > DEFAULT_MAX_BYTES) return false;\n\t\treturn delivered.split(\"\\n\").length <= DEFAULT_MAX_LINES;\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/**\n * Orchestrate a single nested-context decision for a tool call: gate on the setting,\n * resolve the target directory, skip directories already scanned (negative cache),\n * collect not-yet-loaded context files, and format them. Returns the injection block or\n * `null` when nothing should be injected. Mutates `state.scannedDirs` and `state.loaded`.\n */\nexport function computeNestedContextBlock(\n\ttoolName: string,\n\targs: Record<string, unknown> | undefined,\n\tstate: NestedContextState,\n): string | null {\n\tconst targetDir = resolveTargetDir(toolName, args, state.cwd);\n\tif (!targetDir) return null;\n\n\t// Trust is evaluated before consulting or mutating the negative cache. In particular,\n\t// an untrusted touch must not prevent a later global settings refresh from admitting\n\t// the same directory after the user explicitly trusts it.\n\tconst trust = matchContextTrust(state.policy, targetDir);\n\tif (!trust) return null;\n\tconst realTarget = trust.targetDir;\n\tif (state.scannedDirs.has(realTarget)) return null;\n\n\t// A context file the triggering tool already delivers should be marked loaded but not\n\t// re-injected (the result already contains it). Two cases: a `read` of the file itself,\n\t// or a `bash` command that dumps its full contents (`cat`/`bat`). Both are matched by\n\t// full resolved realpath — never by basename — so printing one file never suppresses a\n\t// same-named sibling/ancestor or a file in a different directory.\n\tconst selfReadFile = resolveSelfReadFile(toolName, args, state.cwd);\n\tconst realSelfReadFile = selfReadFile ? safeRealpath(selfReadFile) : null;\n\tconst bashCommand = toolName === \"bash\" && typeof args?.command === \"string\" ? args.command : null;\n\t// Bash file arguments resolve against the command's effective cwd, which `resolveTargetDir`\n\t// has already computed as `targetDir` (the leading `cd` destination).\n\tconst bashDelivered = bashCommand\n\t\t? new Set(resolveBashDeliveredFiles(bashCommand, targetDir).map(safeRealpath))\n\t\t: null;\n\tconst suppress: SuppressPredicate = (file) => {\n\t\tconst realFile = safeRealpath(file.path);\n\t\t// Only suppress when the file was delivered *in full*: a truncated delivery (oversized\n\t\t// file) would drop the remainder if we marked it fully loaded and skipped injection.\n\t\t// `read` delivers the file's raw content unchanged; `bash` delivers it through terminal\n\t\t// rendering (tab/ANSI expansion can grow it past the truncation budget), so each path\n\t\t// measures fullness against what it actually emits.\n\t\tif (realFile === realSelfReadFile) return deliveredInFull(realFile, false);\n\t\tif (bashDelivered?.has(realFile)) return deliveredInFull(realFile, true);\n\t\treturn false;\n\t};\n\n\tconst collected = collectNestedContext(realTarget, state.cwd, state.loaded, suppress, trust.trustedRoot);\n\tif (!collected.hadReadError) {\n\t\tstate.scannedDirs.add(realTarget);\n\t}\n\tif (collected.files.length === 0) return null;\n\treturn formatNestedContextBlock(targetDir, collected.files);\n}\n"]}
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
4
+ import { isWithinCanonicalRoot, matchContextTrust } from "./context-trust.js";
4
5
  import { CONTEXT_FILE_CANDIDATES, loadContextFilesFromDir } from "./resource-loader.js";
5
6
  import { renderTerminalOutput } from "./tools/terminal-render.js";
6
7
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "./tools/truncate.js";
@@ -141,8 +142,28 @@ function isWithin(parent, child) {
141
142
  * 3. The outermost directory containing a CLAUDE.md/AGENTS.md.
142
143
  * 4. Hard stop at filesystem root, the depth bound, or a permission/stat failure.
143
144
  */
144
- function resolveWalkDirs(targetDir, cwd) {
145
+ function resolveWalkDirs(targetDir, cwd, trustedRoot) {
145
146
  const root = resolve("/");
147
+ // A narrowly trusted root is a hard ceiling. It also preserves the existing cwd
148
+ // ceiling when that is nearer to the target, so startup-loaded ancestors are not
149
+ // revisited. `trustedRoot` and target have already passed strict native-realpath
150
+ // containment before this function is called.
151
+ if (trustedRoot) {
152
+ const canonicalCwd = safeRealpath(cwd);
153
+ const stopAtCwd = isWithinCanonicalRoot(canonicalCwd, targetDir);
154
+ const dirs = [];
155
+ let current = targetDir;
156
+ for (let i = 0; i < MAX_WALK_DEPTH; i++) {
157
+ dirs.push(current);
158
+ if (current === trustedRoot || (stopAtCwd && current === canonicalCwd))
159
+ break;
160
+ const parent = resolve(current, "..");
161
+ if (parent === current)
162
+ break;
163
+ current = parent;
164
+ }
165
+ return dirs.reverse();
166
+ }
146
167
  // Case 1: target within cwd subtree — never walk above cwd.
147
168
  if (isWithin(cwd, targetDir)) {
148
169
  const dirs = [];
@@ -228,13 +249,13 @@ function dirHasContextFile(dir) {
228
249
  * the returned `files` — the triggering tool result already contains it, so re-injecting
229
250
  * would duplicate the content and waste tokens.
230
251
  */
231
- export function collectNestedContext(targetDir, cwd, alreadyLoaded, suppress) {
232
- const dirs = resolveWalkDirs(targetDir, cwd);
252
+ export function collectNestedContext(targetDir, cwd, alreadyLoaded, suppress, trustedRoot) {
253
+ const dirs = resolveWalkDirs(targetDir, cwd, trustedRoot);
233
254
  const collected = [];
234
255
  let hadReadError = false;
235
256
  for (const dir of dirs) {
236
257
  const diagnostics = [];
237
- const files = loadContextFilesFromDir(dir, diagnostics);
258
+ const files = loadContextFilesFromDir(dir, diagnostics, trustedRoot);
238
259
  for (const diagnostic of diagnostics) {
239
260
  if (diagnostic.type !== "warning")
240
261
  continue;
@@ -264,7 +285,7 @@ export function formatNestedContextBlock(targetDir, files) {
264
285
  `A tool just operated in \`${targetDir}\`, whose project context had not been loaded yet. ` +
265
286
  `The file(s) below were loaded automatically to prevent missing important project context ` +
266
287
  `when working across multiple repos / projects / folders. ` +
267
- `(Disable with the \`context.autoLoadNested\` setting.)`;
288
+ `(Manage folder-specific loading with \`context.trustedFolders\`, or disable unrestricted loading with \`context.autoLoadNested\`.)`;
268
289
  const sections = files.map((f) => `===== BEGIN project context: ${f.path} =====\n${f.content.trim()}\n===== END project context: ${f.path} =====`);
269
290
  return `${header}\n\n${sections.join("\n\n")}`;
270
291
  }
@@ -442,12 +463,16 @@ function deliveredInFull(realPath, rendered) {
442
463
  * `null` when nothing should be injected. Mutates `state.scannedDirs` and `state.loaded`.
443
464
  */
444
465
  export function computeNestedContextBlock(toolName, args, state) {
445
- if (!state.enabled)
446
- return null;
447
466
  const targetDir = resolveTargetDir(toolName, args, state.cwd);
448
467
  if (!targetDir)
449
468
  return null;
450
- const realTarget = safeRealpath(targetDir);
469
+ // Trust is evaluated before consulting or mutating the negative cache. In particular,
470
+ // an untrusted touch must not prevent a later global settings refresh from admitting
471
+ // the same directory after the user explicitly trusts it.
472
+ const trust = matchContextTrust(state.policy, targetDir);
473
+ if (!trust)
474
+ return null;
475
+ const realTarget = trust.targetDir;
451
476
  if (state.scannedDirs.has(realTarget))
452
477
  return null;
453
478
  // A context file the triggering tool already delivers should be marked loaded but not
@@ -476,7 +501,7 @@ export function computeNestedContextBlock(toolName, args, state) {
476
501
  return deliveredInFull(realFile, true);
477
502
  return false;
478
503
  };
479
- const collected = collectNestedContext(targetDir, state.cwd, state.loaded, suppress);
504
+ const collected = collectNestedContext(realTarget, state.cwd, state.loaded, suppress, trust.trustedRoot);
480
505
  if (!collected.hadReadError) {
481
506
  state.scannedDirs.add(realTarget);
482
507
  }