@sema-agent/core 7.20.1 → 7.21.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/CHANGELOG.md +68 -0
  2. package/dist/core/ask-origin.d.ts +95 -8
  3. package/dist/core/ask-origin.js +37 -5
  4. package/dist/core/gate-bounded-await.d.ts +6 -1
  5. package/dist/core/gate-bounded-await.js +13 -3
  6. package/dist/core/gate-fold.js +18 -5
  7. package/dist/core/gate-lanes.js +6 -12
  8. package/dist/core/memory-engine/consolidation-driver.d.ts +3 -0
  9. package/dist/core/memory-engine/consolidation-driver.js +20 -1
  10. package/dist/core/memory-engine/consolidation.d.ts +7 -0
  11. package/dist/core/memory-engine/consolidation.js +4 -1
  12. package/dist/core/memory-engine/distiller.d.ts +26 -1
  13. package/dist/core/memory-engine/distiller.js +9 -2
  14. package/dist/core/path-identity.d.ts +13 -0
  15. package/dist/core/path-identity.js +14 -0
  16. package/dist/core/runner/env-capability-census.d.ts +3 -1
  17. package/dist/core/runner/env-capability-census.js +24 -7
  18. package/dist/core/runner/gate-exit.d.ts +7 -0
  19. package/dist/core/runner/inherited-ask-grants.d.ts +1 -0
  20. package/dist/core/runner/inherited-ask-grants.js +5 -2
  21. package/dist/core/runner/memory-consolidation-driver.d.ts +72 -2
  22. package/dist/core/runner/memory-consolidation-driver.js +57 -0
  23. package/dist/core/runner/prepare-config-doors.js +2 -0
  24. package/dist/core/runner/prepare-policy-chain.d.ts +1 -0
  25. package/dist/core/runner/prepare-policy-chain.js +52 -20
  26. package/dist/core/runner/prepare-wiring-manifest.js +2 -0
  27. package/dist/core/runner/run-settle-and-teardown.js +5 -0
  28. package/dist/core/runner-deps.d.ts +21 -0
  29. package/dist/core/sensitive-path-policy.js +13 -0
  30. package/dist/core/wiring-manifest.d.ts +16 -0
  31. package/dist/core/wiring-manifest.js +1 -0
  32. package/dist/core/write-protect.d.ts +14 -2
  33. package/dist/core/write-protect.js +36 -13
  34. package/dist/engine/execution-env/node-execution-env.d.ts +10 -0
  35. package/dist/engine/execution-env/node-execution-env.js +29 -1
  36. package/dist/engine/harness/types.d.ts +10 -0
  37. package/dist/index.d.ts +1 -1
  38. package/dist/internal/harness-types.d.ts +1 -1
  39. package/dist/tools/fs/bash-readonly-classifier.js +35 -6
  40. package/dist/tools/fs/read-deny.d.ts +4 -2
  41. package/dist/tools/fs/read-deny.js +5 -0
  42. package/dist/tools/fs/safety.d.ts +12 -0
  43. package/dist/tools/fs/safety.js +43 -38
  44. package/package.json +2 -1
  45. package/test/export-surface.snapshot.json +3 -1
  46. package/wire-changes/core@7.21.0.json +452 -0
@@ -391,6 +391,27 @@ export interface RunnerDeps {
391
391
  maxCycles?: number;
392
392
  mintRepairBudget?: number;
393
393
  };
394
+ /**
395
+ * The advisory-to-execution BRIDGE: when `true`, a terminal harvest that crosses
396
+ * the consolidation threshold (the same edge that mints `memory.consolidation_recommended`) fires
397
+ * ONE host consolidation run for the recommended scope at the post-task tail — fire-and-forget,
398
+ * result-first, never on the harvest tail itself. Explicit `false` = OFF (the deployment's own off
399
+ * switch; it beats the default). ABSENT = the named default `AUTO_RUN_ON_RECOMMENDATION_DEFAULT`
400
+ * (`memory-consolidation-driver.ts`, read through `autoRunOnRecommendationEffective` — the ONE
401
+ * resolver), applied ONLY where a run could actually happen (the protocol switch wired AND a model
402
+ * seat resolvable): a default-on value never arms a deployment that wired no consolidation.
403
+ *
404
+ * DEPLOYMENT seat ONLY (no TaskSpec twin), the same law as {@link RunnerDeps.memoryConsolidation} /
405
+ * {@link RunnerDeps.memoryConsolidationDriver}: arming this is an EGRESS decision — memory content crosses
406
+ * to the consolidation model automatically after every recommending task, never a task author's call.
407
+ *
408
+ * `true` with nothing to run (no {@link RunnerDeps.memoryConsolidation}, or no resolvable driver model
409
+ * seat), or a non-boolean, is refused at prepare (`config.auto_consolidation`) — never folded to off.
410
+ *
411
+ * Reported on the effective wiring manifest as `WiringManifest.autoConsolidation`, PRESENT IFF armed
412
+ * (absent and fingerprint-identical when off).
413
+ */
414
+ autoRunOnRecommendation?: boolean;
394
415
  /**
395
416
  * design/199 件A — the DEPLOYMENT's read-face declaration
396
417
  * ({@link import("../tools/fs/containment-face.js").ContainmentFace}; see {@link TaskSpec.readFace} for the
@@ -4,6 +4,7 @@ import { canonicalizeTarget, expandHomeTilde, isAbsoluteForFamily, isWinFormPath
4
4
  import { compileSegmentPattern, matchSegmentPatterns, READ_FACE_BUILTIN_DENY_TABLE } from "../tools/fs/read-deny.js";
5
5
  import { isProtectedWrite, protectivePathTargetOf } from "./tool-registry.js";
6
6
  import { pathTargetBaseOf } from "./effective-path-target.js";
7
+ import { symlinkChainHops } from "./path-identity.js";
7
8
  export function agentConfigDenyPatterns() {
8
9
  return READ_FACE_BUILTIN_DENY_TABLE.filter((row) => row.tier === "agent-config").map((row) => row.pattern);
9
10
  }
@@ -147,6 +148,18 @@ export function createSensitivePathPolicy(opts) {
147
148
  decisionReason: "safety",
148
149
  };
149
150
  }
151
+ if (relative === null) {
152
+ for (const hop of await symlinkChainHops(opts.env, path, signal, pathFamilyOf({ root: opts.rootPath, cwd: req.cwd ?? opts.env.cwd }), pathTargetBaseOf(protective, { root: opts.rootPath, cwd: req.cwd }))) {
153
+ const viaHop = matchSensitive(hop, compiled, false);
154
+ if (viaHop) {
155
+ return {
156
+ action: "deny",
157
+ message: `write to "${path}" is blocked: its symlink chain passes through the guarded sensitive path "${viaHop}"`,
158
+ decisionReason: "safety",
159
+ };
160
+ }
161
+ }
162
+ }
150
163
  return { action: "allow" };
151
164
  },
152
165
  };
@@ -347,6 +347,19 @@ export interface WiringManifest {
347
347
  writeProtection?: {
348
348
  targetView: WriteProtectionTargetView;
349
349
  };
350
+ /**
351
+ * EFFECTIVE half only. PRESENT IFF auto-consolidation is EFFECTIVELY armed on this
352
+ * deployment (`autoRunOnRecommendationEffective`: an explicit `true`, or an absent seat under the
353
+ * named default `AUTO_RUN_ON_RECOMMENDATION_DEFAULT` where both consolidation seats are wired): a
354
+ * terminal harvest that crosses the threshold auto-runs one host consolidation — memory content
355
+ * auto-egresses to the configured model. ABSENT (and the manifest, including `configFingerprint`,
356
+ * byte-identical) when not armed — an explicit `false`, or a default that does not arm it.
357
+ * IN the fingerprint (an assembly fact, the same class as {@link WiringManifest.writeProtection}); always
358
+ * `{ onRecommendation: true }` when present — the prepare door refuses an armed-but-unrunnable state.
359
+ */
360
+ autoConsolidation?: {
361
+ onRecommendation: true;
362
+ };
350
363
  /** EFFECTIVE half only — a short, non-sensitive fingerprint (sha256 prefix over the canonical
351
364
  * JSON of this manifest's own resolved facts; every field here is an enum/boolean/count, no
352
365
  * secrets) so an operator can correlate legs that ran under the same resolved assembly. */
@@ -428,6 +441,9 @@ export interface WiringFacts {
428
441
  /** Effective half only — the compiled write-protection judge's OWN target-view word
429
442
  * ({@link WiringManifest.writeProtection}); absent ⇒ this leg compiled no judge (an emptied table). */
430
443
  writeProtectionTargetView?: WriteProtectionTargetView;
444
+ /** Effective half only — `RunnerDeps.autoRunOnRecommendation === true`
445
+ * ({@link WiringManifest.autoConsolidation}); false ⇒ no section (byte-identical manifest). */
446
+ autoConsolidationOnRecommendation?: boolean;
431
447
  }
432
448
  /** Named view of the deps seats the static half reads (a `Pick` of the real {@link RunnerDeps} —
433
449
  * single-source shapes, no parallel hand-copied interface). */
@@ -142,6 +142,7 @@ export function deriveWiringManifest(facts) {
142
142
  ...(facts.half === "effective" && facts.hooks !== undefined && facts.hooks.length > 0 ? { hooks: facts.hooks.map((h) => ({ ...h })) } : {}),
143
143
  ...(facts.half === "effective" && facts.lsp !== undefined ? { lsp: { ...facts.lsp } } : {}),
144
144
  ...(facts.half === "effective" && facts.writeProtectionTargetView !== undefined ? { writeProtection: { targetView: facts.writeProtectionTargetView } } : {}),
145
+ ...(facts.half === "effective" && facts.autoConsolidationOnRecommendation === true ? { autoConsolidation: { onRecommendation: true } } : {}),
145
146
  };
146
147
  if (facts.half === "effective") {
147
148
  const { leg: _leg, mcp: _mcp, tools: _tools, lsp: _lsp, hooks: _hooks, ...assembly } = manifest;
@@ -51,7 +51,8 @@ export declare function resolveWriteProtectedTable(entries?: readonly WriteProte
51
51
  * The judge's answer for ONE covered write ({@link WriteProtectionMatcher.judge}):
52
52
  * · `clear` — no row on either view: the write is not this layer's business;
53
53
  * · `hit` — a row matched; `via` says which view named it: `"spelling"` (the anchored spelling, no
54
- * filesystem consulted) or `"target"` (the resolved real target, the spelling being innocent);
54
+ * filesystem consulted), `"chain"` (a spelling the leaf symlink chain passes through, judged as a
55
+ * spelling) or `"target"` (the resolved real target, the spelling being innocent);
55
56
  * · `unresolvable` — the real target could not be resolved (an unfollowable symlink, an errored probe, a
56
57
  * namespace spelling the tree refuses); `cause` is the canonicalizer's own sentence and the consumer
57
58
  * REFUSES, an unjudged target never being vouched.
@@ -61,7 +62,7 @@ export type WriteProtectionVerdict = {
61
62
  } | {
62
63
  readonly kind: "hit";
63
64
  readonly hit: WriteProtectedHit;
64
- readonly via: "spelling" | "target";
65
+ readonly via: "spelling" | "chain" | "target";
65
66
  } | {
66
67
  readonly kind: "unresolvable";
67
68
  readonly cause: string;
@@ -161,6 +162,17 @@ export declare function compileWriteProtection(entries?: readonly WriteProtected
161
162
  * · `clear` ⇒ `undefined`: the surviving decision stands.
162
163
  */
163
164
  export declare function writeProtectionTightenDecision(toolName: string, verdict: WriteProtectionVerdict): PermissionResult | undefined;
165
+ /**
166
+ * @contract write_protection.verdict_is_the_mandate — the judge's two non-clear verdicts are the two facts
167
+ * a standing allow rule may not retire, and `clear` is neither: a MATCHED row is the mandate a live decider
168
+ * may still clear, a target it could not RESOLVE is the mandate no automatic lane may. These two predicates
169
+ * are the ONE reading of a verdict as either fact, beside the decision mint, so the fold records what the
170
+ * judge said and never re-derives "was this write protection?" from the decision it got back.
171
+ */
172
+ export declare function writeProtectionMandated(verdict: WriteProtectionVerdict): boolean;
173
+ /** The second half of {@link writeProtectionMandated}'s contract: the judge could not name this target
174
+ * (the arm that owns the ACTION denies on the same verdict). */
175
+ export declare function writeProtectionUnresolved(verdict: WriteProtectionVerdict): boolean;
164
176
  /**
165
177
  * The ENGINE-FILLED gate input (`ToolGateInput.writeProtectionCheck`). Returns `null` SYNCHRONOUSLY when the
166
178
  * call is not this layer's business — not a covered write, or no resolvable target string — and otherwise
@@ -2,6 +2,7 @@ import { canonicalizeTarget, isWinFormPath, writeTargetPath, isUncPath, isAbsolu
2
2
  import { protectivePathTargetOf } from "./tool-registry.js";
3
3
  import { pathTargetBaseOf } from "./effective-path-target.js";
4
4
  import { canonicalDataRootKeys, dataRootSpellings, relativeUnderRoot } from "./sensitive-path-policy.js";
5
+ import { symlinkChainHops } from "./path-identity.js";
5
6
  const freezeTable = (rows) => Object.freeze(rows.map((r) => Object.freeze(r)));
6
7
  export const WRITE_PROTECTED_DEFAULT_TABLE = freezeTable([
7
8
  { name: ".gitconfig", kind: "basename" },
@@ -207,11 +208,12 @@ export function compileWriteProtection(entries, opts) {
207
208
  const judgeViews = (spelling) => judgeSegments(pathViewSegments(spelling, false, POSIX_SEPARATOR_FAMILY, uncAnchorSegments(spelling))) ??
208
209
  judgeSegments(pathViewSegments(spelling, false)) ??
209
210
  judgeSegments(pathViewSegments(spelling, true));
211
+ const judgeSpelled = (spelled) => judgeViews(relativeUnderDataRoot(spelled, rootViews) ?? spelled);
210
212
  const judgeSpelling = (path, cwd) => {
211
213
  const base = typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
212
214
  const family = pathFamilyOf({ cwd: base });
213
215
  const spelled = base !== undefined && family !== undefined && !isAbsoluteForFamily(family, path) ? joinForFamily(family, base, path) : path;
214
- return judgeViews(relativeUnderDataRoot(spelled, rootViews) ?? spelled);
216
+ return judgeSpelled(spelled);
215
217
  };
216
218
  const env = opts?.env;
217
219
  const rootPath = opts?.rootPath;
@@ -227,22 +229,35 @@ export function compileWriteProtection(entries, opts) {
227
229
  return { kind: "hit", hit: bySpelling, via: "spelling" };
228
230
  if (env === undefined)
229
231
  return { kind: "clear" };
230
- const canon = await canonicalizeTarget(env, path, ctx.signal, pathFamilyOf({ root: rootPath, cwd: ctx.cwd ?? env.cwd }), ctx.base ?? ctx.cwd ?? rootPath);
232
+ const family = pathFamilyOf({ root: rootPath, cwd: ctx.cwd ?? env.cwd });
233
+ const base = ctx.base ?? ctx.cwd ?? rootPath;
234
+ const canon = await canonicalizeTarget(env, path, ctx.signal, family, base);
231
235
  if (!canon.ok)
232
236
  return { kind: "unresolvable", cause: canon.message, unresolvedSymlink: canon.unresolvedSymlink === true };
233
237
  const byTarget = judgeViews(canon.key);
234
- if (byTarget === null)
235
- return { kind: "clear" };
236
- for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) {
237
- const relative = relativeUnderRoot(canon.key, rootKey);
238
- if (relative === null)
238
+ if (byTarget !== null) {
239
+ for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) {
240
+ const relative = relativeUnderRoot(canon.key, rootKey);
241
+ if (relative === null)
242
+ continue;
243
+ if (relative === "")
244
+ return { kind: "clear" };
245
+ const remainder = judgeViews(relative);
246
+ return remainder === null ? { kind: "clear" } : { kind: "hit", hit: remainder, via: "target" };
247
+ }
248
+ return { kind: "hit", hit: byTarget, via: "target" };
249
+ }
250
+ for (const hop of await symlinkChainHops(env, path, ctx.signal, family, base)) {
251
+ const byHop = judgeSpelled(hop);
252
+ if (byHop === null)
239
253
  continue;
240
- if (relative === "")
241
- return { kind: "clear" };
242
- const remainder = judgeViews(relative);
243
- return remainder === null ? { kind: "clear" } : { kind: "hit", hit: remainder, via: "target" };
254
+ for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) {
255
+ if (relativeUnderRoot(canon.key, rootKey) !== null)
256
+ return { kind: "clear" };
257
+ }
258
+ return { kind: "hit", hit: byHop, via: "chain" };
244
259
  }
245
- return { kind: "hit", hit: byTarget, via: "target" };
260
+ return { kind: "clear" };
246
261
  },
247
262
  };
248
263
  }
@@ -252,7 +267,9 @@ export function writeProtectionTightenDecision(toolName, verdict) {
252
267
  action: "ask",
253
268
  message: verdict.via === "target"
254
269
  ? `tool "${toolName}" writes to a write-protected path — its real target resolves onto table entry "${verdict.hit.name}" — explicit approval required`
255
- : `tool "${toolName}" writes to a write-protected path (table entry "${verdict.hit.name}") — explicit approval required`,
270
+ : verdict.via === "chain"
271
+ ? `tool "${toolName}" writes to a write-protected path — its symlink chain passes through table entry "${verdict.hit.name}" — explicit approval required`
272
+ : `tool "${toolName}" writes to a write-protected path (table entry "${verdict.hit.name}") — explicit approval required`,
256
273
  decisionReason: "safety",
257
274
  };
258
275
  }
@@ -267,6 +284,12 @@ export function writeProtectionTightenDecision(toolName, verdict) {
267
284
  }
268
285
  return undefined;
269
286
  }
287
+ export function writeProtectionMandated(verdict) {
288
+ return verdict.kind === "hit";
289
+ }
290
+ export function writeProtectionUnresolved(verdict) {
291
+ return verdict.kind === "unresolvable";
292
+ }
270
293
  export function createWriteProtectionCheck(entries, opts) {
271
294
  const matcher = compileWriteProtection(entries, opts);
272
295
  if (matcher === undefined)
@@ -1,5 +1,6 @@
1
1
  import { type ExecResult, type ExecutionEnv, ExecutionError, type ExecutionEnvExecOptions, FileError, type FileInfo, type Result } from "../harness/types.js";
2
2
  import { type SecretEnvFinding } from "../../core/secret-env.js";
3
+ import type { SymlinkChain } from "../harness/types.js";
3
4
  import type { BackgroundShellCapability, BackgroundShellId, BackgroundPoll, BackgroundSpawnOptions } from "../../core/background-shell.js";
4
5
  import { BackgroundShellError } from "../../core/background-shell.js";
5
6
  import type { SchedulerCapability, ScheduledIntent, SchedulerContext, ScheduledTaskId, ScheduledTaskSummary } from "../../core/scheduler.js";
@@ -165,6 +166,15 @@ export declare class NodeExecutionEnv implements ExecutionEnv, BackgroundShellCa
165
166
  listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
166
167
  canonicalPath(path: string): Promise<Result<string, FileError>>;
167
168
  readLink(path: string): Promise<Result<string, FileError>>;
169
+ /**
170
+ * The LEAF symlink chain ({@link FileSystem.canonicalChain}): `readlink` on the whole spelling, then on each
171
+ * target, until a hop is not a link (`EINVAL`), is absent (`ENOENT` / `ENOTDIR`), repeats, or the bound is
172
+ * reached. A relative target is ANCHORED at its link's directory and never folded — `..` is the filesystem's
173
+ * to resolve during the next `readlink`'s own lookup (a lexical fold reads `bridge/../next` as `next` and misses
174
+ * every link behind `bridge`). One syscall per hop, no `lstat`. On a win32 host a share-shaped hop is kept and
175
+ * ends the walk unprobed. Any other errno is an answer nobody can use (`err`), never a shortened chain.
176
+ */
177
+ canonicalChain(path: string): Promise<Result<SymlinkChain, FileError>>;
168
178
  exists(path: string): Promise<Result<boolean, FileError>>;
169
179
  createDir(path: string, options?: {
170
180
  recursive?: boolean;
@@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
3
3
  import { closeSync, constants, createReadStream, mkdtempSync, openSync, readSync, statSync, truncateSync, unlinkSync } from "node:fs";
4
4
  import { access, appendFile, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink, rename, rm, unlink, writeFile, } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
- import { isAbsolute, join, resolve } from "node:path";
6
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { ExecutionError, err, FileError, ok, toError, } from "../harness/types.js";
9
9
  import { killProcessTree, shutdownDebug } from "./kill-tree.js";
10
10
  import { scrubSecretEnv } from "../../core/secret-env.js";
11
11
  import { physicalPathOfAsync } from "../../core/physical-path.js";
12
+ import { isUncSpelling } from "../../tools/fs/safety.js";
12
13
  import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
13
14
  import { BackgroundShellError } from "../../core/background-shell.js";
14
15
  import { SchedulerError } from "../../core/scheduler.js";
@@ -41,6 +42,7 @@ function fileKindFromStats(stats) {
41
42
  return undefined;
42
43
  }
43
44
  const HAS_POSIX_MODE_FACE = process.platform !== "win32";
45
+ const SYMLINK_CHAIN_HOP_BOUND = 64;
44
46
  function fileInfoFromStats(path, stats) {
45
47
  const kind = fileKindFromStats(stats);
46
48
  if (!kind) {
@@ -1026,6 +1028,32 @@ export class NodeExecutionEnv {
1026
1028
  return err(toFileError(error, resolved));
1027
1029
  }
1028
1030
  }
1031
+ async canonicalChain(path) {
1032
+ const resolved = resolvePath(this.cwd, path);
1033
+ const hops = [];
1034
+ const seen = new Set([resolved]);
1035
+ let cur = resolved;
1036
+ for (let hop = 0; hop < SYMLINK_CHAIN_HOP_BOUND; hop++) {
1037
+ let target;
1038
+ try {
1039
+ target = await readlink(cur);
1040
+ }
1041
+ catch (error) {
1042
+ if (isNodeError(error) && (error.code === "EINVAL" || error.code === "ENOENT" || error.code === "ENOTDIR"))
1043
+ return ok({ hops });
1044
+ return err(toFileError(error, cur));
1045
+ }
1046
+ const next = isAbsolute(target) ? target : `${dirname(cur)}${sep}${target}`;
1047
+ if (seen.has(next))
1048
+ break;
1049
+ hops.push(next);
1050
+ if (process.platform === "win32" && isUncSpelling(next))
1051
+ break;
1052
+ seen.add(next);
1053
+ cur = next;
1054
+ }
1055
+ return ok({ hops });
1056
+ }
1029
1057
  async exists(path) {
1030
1058
  const result = await this.fileInfo(path);
1031
1059
  if (result.ok) {
@@ -271,6 +271,14 @@ export interface FileInfo {
271
271
  */
272
272
  mode?: number;
273
273
  }
274
+ /**
275
+ * @contract fs.canonical_chain.leaf_hops_only — `hops` lists, in order, the target each link on the LEAF chain writes
276
+ * ({@link FileSystem.canonicalChain}): a relative target anchored, unfolded, at its own link's directory (`..` is the
277
+ * filesystem's to resolve on the next lookup, never the walk's); the addressed spelling and the canonical terminal ({@link FileSystem.canonicalPath}'s one answer) are not repeated; a directory-component symlink is not expanded; a non-link leaf answers `[]`.
278
+ */
279
+ export interface SymlinkChain {
280
+ readonly hops: readonly string[];
281
+ }
274
282
  /** Options for {@link Shell.exec}. */
275
283
  export interface ExecutionEnvExecOptions {
276
284
  /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
@@ -397,6 +405,8 @@ export interface FileSystem {
397
405
  * target of `write_file innocent.txt` where `innocent.txt -> .git/hooks/payload`.
398
406
  */
399
407
  readLink?(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
408
+ /** OPTIONAL: the LEAF symlink chain of `path` ({@link SymlinkChain}). Omitted, or `ok: false` ⇒ the path judges read the spelling and the canonical terminal only (the reach they had before this seat); a remote adapter answers in one `readlink` round trip and a caller never emulates it through {@link readLink}; bounded (64 hops, a cycle ends it), a network hop kept unprobed. */
409
+ canonicalChain?(path: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
400
410
  /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
401
411
  exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
402
412
  /** Create a directory. Defaults: `recursive: true`, no abort signal. */
package/dist/index.d.ts CHANGED
@@ -87,7 +87,7 @@ export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
87
87
  export type { SecretEnvFinding, SecretEnvFindingKind } from "./core/secret-env.js";
88
88
  export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
89
89
  export { physicalPathOf, physicalPathOfExisting } from "./core/physical-path.js";
90
- export type { ExecutionEnv, FileInfo, Result, FileErrorCode, ExecutionErrorCode, WriteExpectation, WriteReceipt } from "./internal/harness.js";
90
+ export type { ExecutionEnv, FileInfo, Result, FileErrorCode, ExecutionErrorCode, WriteExpectation, WriteReceipt, SymlinkChain } from "./internal/harness.js";
91
91
  export type { ExecResult } from "./internal/harness.js";
92
92
  export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, canonicalPathIsAuthoritative, missingRestoreSurface, isRetryableRemoteErrorCode, RETRYABLE_REMOTE_ERROR_CODES, } from "./core/remote-env.js";
93
93
  export { withRetry } from "./core/with-retry.js";
@@ -8,7 +8,7 @@ export type { CompactionPreparation, SummarizationClampDryRun } from "../engine/
8
8
  export type { InvokedSkillRetention } from "../engine/compaction/utils.js";
9
9
  export type { AgentCoreRuntimeDeps } from "../engine/loop/runtime-deps.js";
10
10
  export type { AgentMessage, AgentTool, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel, ToolExecutionMode, ToolInputValidationContext, ToolInputVerdict, } from "../engine/loop/types.js";
11
- export type { AgentHarnessEvent, CompactionSettings, ExecutionEnv, ExecutionErrorCode, FileError, FileErrorCode, FileInfo, Result, WriteExpectation, WriteReceipt, Session, SessionMetadata, SessionRepo, SessionStorage, SessionTreeEntry, Skill, } from "../engine/harness/types.js";
11
+ export type { AgentHarnessEvent, CompactionSettings, ExecutionEnv, ExecutionErrorCode, FileError, FileErrorCode, FileInfo, Result, WriteExpectation, WriteReceipt, SymlinkChain, Session, SessionMetadata, SessionRepo, SessionStorage, SessionTreeEntry, Skill, } from "../engine/harness/types.js";
12
12
  export type { ExecutionEnvExecOptions, ExecResult } from "../engine/harness/types.js";
13
13
  export type { SessionWriteOptions, CompactionEntry } from "../engine/harness/types.js";
14
14
  export type { ActiveWorktreeSession, WorkspaceState } from "../engine/harness/types.js";
@@ -309,9 +309,19 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
309
309
  return false;
310
310
  return boundary.face === "open" || withinAnyRoot(boundary.roots, resolved);
311
311
  };
312
- const unlistedSegmentJudgedInside = (words, from) => {
312
+ const unlistedSegmentJudgedInside = (words, from, leadingAssignments = []) => {
313
313
  if (boundary === undefined)
314
314
  return true;
315
+ const assignmentJudgedInside = (word) => {
316
+ const payload = assignmentWordPayload(word);
317
+ return payload === undefined || (payload.kind === "value" && stdinFileJudgedInside(payload.value));
318
+ };
319
+ for (const k of leadingAssignments) {
320
+ if (k >= from)
321
+ continue;
322
+ if (!assignmentJudgedInside(words.folded[k]))
323
+ return false;
324
+ }
315
325
  let endOfOptions = false;
316
326
  for (let j = from; j < words.folded.length; j++) {
317
327
  const word = words.folded[j];
@@ -330,6 +340,8 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
330
340
  if (!stdinFileJudgedInside(candidate))
331
341
  return false;
332
342
  }
343
+ if (!optionShaped && !assignmentJudgedInside(word))
344
+ return false;
333
345
  }
334
346
  return true;
335
347
  };
@@ -352,7 +364,7 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
352
364
  continue;
353
365
  if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS))
354
366
  return false;
355
- if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index))
367
+ if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
356
368
  return false;
357
369
  continue;
358
370
  }
@@ -366,7 +378,7 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
366
378
  if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS))
367
379
  return false;
368
380
  if (!listed(program.basename)) {
369
- if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index))
381
+ if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
370
382
  return false;
371
383
  continue;
372
384
  }
@@ -375,7 +387,7 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
375
387
  if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
376
388
  continue;
377
389
  if (!allow.has(head)) {
378
- if (!unlistedSegmentJudgedInside(words, program.index))
390
+ if (!unlistedSegmentJudgedInside(words, program.index, pos.assignments))
379
391
  return false;
380
392
  continue;
381
393
  }
@@ -1516,6 +1528,18 @@ function isGrepFileStdinLongOption(tok) {
1516
1528
  return name !== undefined && isLongOptionAbbrevOf(name, "file");
1517
1529
  }
1518
1530
  const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
1531
+ const ASSIGNMENT_OR_APPEND_WORD = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
1532
+ function assignmentWordPayload(word) {
1533
+ if (!ASSIGNMENT_OR_APPEND_WORD.test(word))
1534
+ return undefined;
1535
+ const eq = word.indexOf("=");
1536
+ const value = word.slice(eq + 1);
1537
+ if (word[eq - 1] === "+")
1538
+ return { kind: "unresolved", reason: `"${word}" appends to a value the text does not contain` };
1539
+ if (value.includes("~"))
1540
+ return { kind: "unresolved", reason: `"${word}" carries a tilde whose expansion the text does not settle` };
1541
+ return value === "" ? undefined : { kind: "value", value };
1542
+ }
1519
1543
  function attachedOptionPayloads(tok) {
1520
1544
  const literal = literalTildeSpelling;
1521
1545
  const out = [];
@@ -1651,6 +1675,11 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1651
1675
  continue;
1652
1676
  positionalOperands.push(t);
1653
1677
  judgeOperand(t, argGlobs(k));
1678
+ const payload = assignmentWordPayload(t);
1679
+ if (payload?.kind === "value")
1680
+ judgeOperand(payload.value, argGlobs(k));
1681
+ else if (payload?.kind === "unresolved")
1682
+ findings.push({ kind: "unresolvable", operand: t, reason: `"${name}": ${payload.reason} ${NOT_AUTO_ALLOWED}` });
1654
1683
  }
1655
1684
  }
1656
1685
  for (let k = 0; k < args.length; k++) {
@@ -2163,7 +2192,7 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
2163
2192
  return evaluateReadBoundary([toks], boundary, true);
2164
2193
  }
2165
2194
  const NOT_GATED = { gated: false, paths: [] };
2166
- const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}~]/;
2195
+ const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}]/;
2167
2196
  const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
2168
2197
  export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
2169
2198
  const LAUNCHER_SCAN_HEAD = "cat";
@@ -2211,7 +2240,7 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
2211
2240
  if (UNMODELLED_BASE_MOVERS.has(name))
2212
2241
  baseMoved = true;
2213
2242
  if (!allow.has(name) && name !== "cd") {
2214
- pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)], slotVerb: name });
2243
+ pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw], slotVerb: name });
2215
2244
  continue;
2216
2245
  }
2217
2246
  pushReader(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
@@ -47,10 +47,12 @@ export interface ReadDenyMatcher {
47
47
  matchPath(path: string, opts?: {
48
48
  readonly aliasResolved?: boolean;
49
49
  }): ReadDenyHit | null;
50
- /** §3.3 target judgment: canonical view + lexical view; EITHER hit decides. `aliasResolved`
51
- * applies to the CANONICAL arm only — the lexical view is an unresolved spelling. */
50
+ /** §3.3 target judgment: canonical view + lexical view + every hop of the leaf symlink chain (`hops`,
51
+ * each an unresolved spelling some link wrote); ANY hit decides. `aliasResolved` applies to the
52
+ * CANONICAL arm only — the lexical view and the hops are unresolved spellings. */
52
53
  matchTarget(canonicalKey: string, lexicalView?: string, opts?: {
53
54
  readonly aliasResolved?: boolean;
55
+ readonly hops?: readonly string[];
54
56
  }): ReadDenyHit | null;
55
57
  /** Traversal exclusions for the rg legs: `!`-polarity glob pairs — `!**\/<p>` plus `!**\/<p>/…`
56
58
  * (the entry itself and its descendants). */
@@ -227,6 +227,11 @@ export function compileReadDeny(additions = [], layer = "additions", builtin) {
227
227
  if (lexical !== null)
228
228
  return { ...lexical, matchedView: lexicalView };
229
229
  }
230
+ for (const hop of opts?.hops ?? []) {
231
+ const viaHop = matchPath(hop);
232
+ if (viaHop !== null)
233
+ return { ...viaHop, matchedView: hop };
234
+ }
230
235
  return null;
231
236
  },
232
237
  rgExclusionGlobs,
@@ -290,6 +290,10 @@ export declare function pathFamilyOf(bases: {
290
290
  readonly root?: string | undefined;
291
291
  readonly cwd?: string | undefined;
292
292
  }): PathFamily | undefined;
293
+ /** UNC under the win family: two leading separators, a host, a share — either separator, as `path.win32`
294
+ * reads it. The ONE UNC recognizer behind {@link isAbsoluteForFamily}, {@link pathIdentityOf} and
295
+ * {@link joinForFamily}. */
296
+ export declare function isUncSpelling(p: string): boolean;
293
297
  /**
294
298
  * FULLY QUALIFIED under the family. win: drive-rooted (`C:\`/`C:/`) or UNC — a `/x` / `\x` is rooted-not-
295
299
  * qualified and joins the base's drive or share root ({@link joinForFamily}), a `C:rel` is drive-relative.
@@ -422,6 +426,14 @@ export declare function resolveKey(env: ExecutionEnv, rootCanonical: string, pat
422
426
  * `createSensitivePathPolicy` (which matches the real target against a guarded list).
423
427
  * A single source of truth for symlink resolution: a sensitive-path policy that matched the raw path
424
428
  * instead of the real target would compare against the spelling, not the destination.
429
+ *
430
+ * @contract fs.canonical_target.probed_and_bounded — every model-spelled path is resolved by asking the
431
+ * execution environment where it really lands; no spelling shape is exempt from that, and the win-form
432
+ * UNC spelling stopped being the one that was. The ask is bounded by the tree's shared bounded-await
433
+ * deadline rather than by a timer of this module's own, and a wait that elapses or a probe that fails
434
+ * comes back as a refusal carrying no key — the spelling is never handed back as a substitute for the
435
+ * answer. What the deadline bounds is the CALLER'S wait: the underlying filesystem call is not
436
+ * cancellable and may go on occupying the runtime's I/O capacity until the kernel gives up.
425
437
  */
426
438
  export declare function canonicalizeTarget(env: ExecutionEnv, path: string, signal: AbortSignal | undefined, family: PathFamily, baseCwd?: string): Promise<{
427
439
  ok: true;