@sema-agent/core 7.20.0 → 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 (58) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/core/ask-origin.d.ts +95 -8
  3. package/dist/core/ask-origin.js +37 -5
  4. package/dist/core/engine-notice.d.ts +9 -0
  5. package/dist/core/gate-bounded-await.d.ts +6 -1
  6. package/dist/core/gate-bounded-await.js +13 -3
  7. package/dist/core/gate-fold.js +18 -5
  8. package/dist/core/gate-lanes.js +6 -12
  9. package/dist/core/governance-codes.d.ts +1 -1
  10. package/dist/core/governance-codes.js +2 -0
  11. package/dist/core/memory-engine/consolidation-driver.d.ts +3 -0
  12. package/dist/core/memory-engine/consolidation-driver.js +20 -1
  13. package/dist/core/memory-engine/consolidation.d.ts +7 -0
  14. package/dist/core/memory-engine/consolidation.js +4 -1
  15. package/dist/core/memory-engine/distiller.d.ts +26 -1
  16. package/dist/core/memory-engine/distiller.js +9 -2
  17. package/dist/core/memory-engine/tools.js +6 -0
  18. package/dist/core/path-identity.d.ts +13 -0
  19. package/dist/core/path-identity.js +14 -0
  20. package/dist/core/runner/env-capability-census.d.ts +3 -1
  21. package/dist/core/runner/env-capability-census.js +24 -7
  22. package/dist/core/runner/gate-exit.d.ts +7 -0
  23. package/dist/core/runner/inherited-ask-grants.d.ts +1 -0
  24. package/dist/core/runner/inherited-ask-grants.js +5 -2
  25. package/dist/core/runner/memory-consolidation-driver.d.ts +72 -2
  26. package/dist/core/runner/memory-consolidation-driver.js +57 -0
  27. package/dist/core/runner/prepare-config-doors.js +2 -0
  28. package/dist/core/runner/prepare-gate-stations.d.ts +6 -8
  29. package/dist/core/runner/prepare-gate-stations.js +1 -7
  30. package/dist/core/runner/prepare-policy-chain.d.ts +1 -0
  31. package/dist/core/runner/prepare-policy-chain.js +52 -20
  32. package/dist/core/runner/prepare-task.js +2 -2
  33. package/dist/core/runner/prepare-wiring-manifest.d.ts +8 -1
  34. package/dist/core/runner/prepare-wiring-manifest.js +7 -2
  35. package/dist/core/runner/run-settle-and-teardown.js +5 -0
  36. package/dist/core/runner/write-protection-station.d.ts +59 -0
  37. package/dist/core/runner/write-protection-station.js +32 -0
  38. package/dist/core/runner-deps.d.ts +21 -0
  39. package/dist/core/sensitive-path-policy.js +13 -0
  40. package/dist/core/wiring-manifest.d.ts +47 -0
  41. package/dist/core/wiring-manifest.js +2 -0
  42. package/dist/core/write-protect.d.ts +41 -3
  43. package/dist/core/write-protect.js +40 -14
  44. package/dist/engine/execution-env/node-execution-env.d.ts +10 -0
  45. package/dist/engine/execution-env/node-execution-env.js +29 -1
  46. package/dist/engine/harness/types.d.ts +10 -0
  47. package/dist/index.d.ts +2 -2
  48. package/dist/index.js +1 -1
  49. package/dist/internal/harness-types.d.ts +1 -1
  50. package/dist/tools/fs/bash-readonly-classifier.js +36 -7
  51. package/dist/tools/fs/read-deny.d.ts +4 -2
  52. package/dist/tools/fs/read-deny.js +5 -0
  53. package/dist/tools/fs/safety.d.ts +12 -0
  54. package/dist/tools/fs/safety.js +43 -38
  55. package/package.json +2 -1
  56. package/test/export-surface.snapshot.json +9 -1
  57. package/wire-changes/core@7.20.1.json +183 -0
  58. package/wire-changes/core@7.21.0.json +452 -0
@@ -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" },
@@ -156,6 +157,7 @@ function relativeUnderDataRoot(path, roots) {
156
157
  }
157
158
  return null;
158
159
  }
160
+ export const WRITE_PROTECTION_TARGET_VIEWS = ["spelling-only", "target"];
159
161
  export function compileWriteProtection(entries, opts) {
160
162
  const rows = resolveWriteProtectedTable(entries);
161
163
  if (rows.length === 0)
@@ -206,16 +208,18 @@ export function compileWriteProtection(entries, opts) {
206
208
  const judgeViews = (spelling) => judgeSegments(pathViewSegments(spelling, false, POSIX_SEPARATOR_FAMILY, uncAnchorSegments(spelling))) ??
207
209
  judgeSegments(pathViewSegments(spelling, false)) ??
208
210
  judgeSegments(pathViewSegments(spelling, true));
211
+ const judgeSpelled = (spelled) => judgeViews(relativeUnderDataRoot(spelled, rootViews) ?? spelled);
209
212
  const judgeSpelling = (path, cwd) => {
210
213
  const base = typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
211
214
  const family = pathFamilyOf({ cwd: base });
212
215
  const spelled = base !== undefined && family !== undefined && !isAbsoluteForFamily(family, path) ? joinForFamily(family, base, path) : path;
213
- return judgeViews(relativeUnderDataRoot(spelled, rootViews) ?? spelled);
216
+ return judgeSpelled(spelled);
214
217
  };
215
218
  const env = opts?.env;
216
219
  const rootPath = opts?.rootPath;
217
220
  return {
218
221
  rows,
222
+ targetView: env === undefined ? "spelling-only" : "target",
219
223
  matchPath(path, cwd) {
220
224
  return judgeSpelling(path, cwd);
221
225
  },
@@ -225,22 +229,35 @@ export function compileWriteProtection(entries, opts) {
225
229
  return { kind: "hit", hit: bySpelling, via: "spelling" };
226
230
  if (env === undefined)
227
231
  return { kind: "clear" };
228
- 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);
229
235
  if (!canon.ok)
230
236
  return { kind: "unresolvable", cause: canon.message, unresolvedSymlink: canon.unresolvedSymlink === true };
231
237
  const byTarget = judgeViews(canon.key);
232
- if (byTarget === null)
233
- return { kind: "clear" };
234
- for (const rootKey of await canonicalDataRootKeys(env, dataRootPaths, ctx.signal)) {
235
- const relative = relativeUnderRoot(canon.key, rootKey);
236
- 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)
237
253
  continue;
238
- if (relative === "")
239
- return { kind: "clear" };
240
- const remainder = judgeViews(relative);
241
- 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" };
242
259
  }
243
- return { kind: "hit", hit: byTarget, via: "target" };
260
+ return { kind: "clear" };
244
261
  },
245
262
  };
246
263
  }
@@ -250,7 +267,9 @@ export function writeProtectionTightenDecision(toolName, verdict) {
250
267
  action: "ask",
251
268
  message: verdict.via === "target"
252
269
  ? `tool "${toolName}" writes to a write-protected path — its real target resolves onto table entry "${verdict.hit.name}" — explicit approval required`
253
- : `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`,
254
273
  decisionReason: "safety",
255
274
  };
256
275
  }
@@ -265,11 +284,17 @@ export function writeProtectionTightenDecision(toolName, verdict) {
265
284
  }
266
285
  return undefined;
267
286
  }
287
+ export function writeProtectionMandated(verdict) {
288
+ return verdict.kind === "hit";
289
+ }
290
+ export function writeProtectionUnresolved(verdict) {
291
+ return verdict.kind === "unresolvable";
292
+ }
268
293
  export function createWriteProtectionCheck(entries, opts) {
269
294
  const matcher = compileWriteProtection(entries, opts);
270
295
  if (matcher === undefined)
271
296
  return undefined;
272
- return (toolName, args, face, cwd, signal) => {
297
+ const check = (toolName, args, face, cwd, signal) => {
273
298
  const call = { toolName, args, ...(face !== undefined ? { face } : {}) };
274
299
  const protective = protectivePathTargetOf(call);
275
300
  if (protective === undefined || protective.access === "read")
@@ -279,4 +304,5 @@ export function createWriteProtectionCheck(entries, opts) {
279
304
  return null;
280
305
  return matcher.judge(path, { ...(cwd !== undefined ? { cwd } : {}), ...(signal !== undefined ? { signal } : {}), ...(() => { const b = pathTargetBaseOf(protective, { root: opts?.rootPath, cwd }); return b !== undefined ? { base: b } : {}; })() });
281
306
  };
307
+ return { check, targetView: matcher.targetView };
282
308
  }
@@ -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";
@@ -105,7 +105,7 @@ export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, ty
105
105
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
106
106
  export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY_BUILTIN_TIERS, READ_DENY_DEFAULT_TIERS, resolveReadDenyBuiltins, compileReadDeny, type ReadDenyEntry, type ReadDenyMatcher, type NormalizedReadDenyEntry, type ReadDenyBuiltinTier, type ReadDenyBuiltinRow, type ReadDenyBuiltinConfig, } from "./tools/fs/index.js";
107
107
  export { deploymentFaceClampNotice, resolveContainmentFace, type ContainmentFace, type ContainmentFaceInputs, type ContainmentFaceKind, type ContainmentFaceLayer, } from "./tools/fs/index.js";
108
- export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, type WriteProtectedEntry, type WriteProtectedRow, type WriteProtectedKind, type WriteProtectedHit, type WriteProtectionMatcher, type WriteProtectionVerdict, type WriteProtectionJudgeContext, type WriteProtectionCompileOptions, type WriteProtectionCheck, } from "./core/write-protect.js";
108
+ export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, type WriteProtectedEntry, type WriteProtectedRow, type WriteProtectedKind, type WriteProtectedHit, type WriteProtectionMatcher, type WriteProtectionVerdict, type WriteProtectionJudgeContext, type WriteProtectionCompileOptions, type WriteProtectionCheck, WRITE_PROTECTION_TARGET_VIEWS, type WriteProtectionTargetView, type WriteProtectionMount, } from "./core/write-protect.js";
109
109
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, type ToolResultProvenance, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, type ToolResultStore, type ToolResultSlice, type ToolResultDeletionReport, } from "./core/tool-result-store.js";
110
110
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointId, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type ProbeCause, type ProbeCauseOperands, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type TerminalClaimIntent, type TerminalClaimOutcome, type SafetyAxis, type RealApprovalGateBit, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, type CheckpointRow, realApprovalOrgFact, resolveCheckpointStore, checkpointStoreExecutionUndeclared, } from "./core/checkpoint-store.js";
111
111
  export { EXECUTION_OUTCOME_RECORD_WORDS, EXECUTION_RECORD_LEAVES_ROW_UNRECORDED, type ExecutionOutcomeRecordWord, type ExecutionRecordRowFacts, type ExecutionVerdict, ExecutionOutcomeConflictError, checkpointExecutionRecorded, executionRecordDisposition, sameExecutionRecord, executionVerdict, } from "./core/checkpoint-execution-record.js";
package/dist/index.js CHANGED
@@ -83,7 +83,7 @@ export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, }
83
83
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
84
84
  export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY_BUILTIN_TIERS, READ_DENY_DEFAULT_TIERS, resolveReadDenyBuiltins, compileReadDeny, } from "./tools/fs/index.js";
85
85
  export { deploymentFaceClampNotice, resolveContainmentFace, } from "./tools/fs/index.js";
86
- export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, } from "./core/write-protect.js";
86
+ export { WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, compileWriteProtection, WRITE_PROTECTION_TARGET_VIEWS, } from "./core/write-protect.js";
87
87
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, } from "./core/tool-result-store.js";
88
88
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointId, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, realApprovalOrgFact, resolveCheckpointStore, checkpointStoreExecutionUndeclared, } from "./core/checkpoint-store.js";
89
89
  export { EXECUTION_OUTCOME_RECORD_WORDS, EXECUTION_RECORD_LEAVES_ROW_UNRECORDED, ExecutionOutcomeConflictError, checkpointExecutionRecorded, executionRecordDisposition, sameExecutionRecord, executionVerdict, } from "./core/checkpoint-execution-record.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";
@@ -307,11 +307,21 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
307
307
  const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], word, boundary.homeDir);
308
308
  if (resolved === undefined || boundary.denyMatch?.(resolved) != null)
309
309
  return false;
310
- return withinAnyRoot(boundary.roots, resolved);
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;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { sliceHeadSafe } from "../../core/surrogate-safe-slice.js";
3
3
  import { pathTargetValue, protectivePathTargetOf } from "../../core/tool-registry.js";
4
+ import { withGateTimeout, DEFAULT_GATE_AWAIT_TIMEOUT_MS } from "../../core/gate-bounded-await.js";
4
5
  export function fileArgPath(args) {
5
6
  const a = args;
6
7
  const fp = a?.file_path;
@@ -291,29 +292,6 @@ function stripWin32ExtendedPrefix(p) {
291
292
  }
292
293
  return unc ? "\\\\" + rest.slice(unc[0].length) : rest;
293
294
  }
294
- function foldUncLexically(p) {
295
- const body = p.slice(2);
296
- const segs = body.split(/[\\/]+/).filter((s) => s !== "");
297
- const host = segs.shift() ?? "";
298
- const share = segs.shift();
299
- const out = [];
300
- let climbed = false;
301
- for (const seg of segs) {
302
- if (seg === ".")
303
- continue;
304
- if (seg === "..") {
305
- if (out.length === 0)
306
- climbed = true;
307
- else
308
- out.pop();
309
- continue;
310
- }
311
- const stripped = seg.replace(/[. ]+$/, "");
312
- out.push(stripped.length > 0 ? stripped : seg);
313
- }
314
- const anchor = `\\\\${host}${share !== undefined ? `\\${share}` : ""}`;
315
- return { key: out.length > 0 ? `${anchor}\\${out.join("\\")}` : anchor, climbedAboveShare: climbed };
316
- }
317
295
  export function hasWin32AliasedComponent(p) {
318
296
  if (!isWinFormPath(p))
319
297
  return false;
@@ -325,7 +303,7 @@ export function pathFamilyOf(bases) {
325
303
  const tree = bases.root ?? bases.cwd;
326
304
  return tree === undefined ? undefined : isWinFormPath(tree) ? "win" : "posix";
327
305
  }
328
- function isUncSpelling(p) {
306
+ export function isUncSpelling(p) {
329
307
  return /^[\\/]{2}[^\\/]+[\\/]+[^\\/]/.test(p);
330
308
  }
331
309
  export function isAbsoluteForFamily(family, p) {
@@ -511,16 +489,26 @@ export async function canonicalizeTarget(env, path, signal, family, baseCwd) {
511
489
  if (!screened.ok) {
512
490
  return { ok: false, message: `path "${clipSpelling(path)}" ${WIN32_NAMESPACE_REFUSAL_TEXT[screened.refuse]}; refused (${screened.refuse}).`, refused: screened.refuse };
513
491
  }
514
- const unprefixed = screened.spelled;
515
- if (family === "win" && isUncSpelling(unprefixed)) {
516
- const folded = foldUncLexically(unprefixed);
517
- if (folded.climbedAboveShare) {
518
- return { ok: false, message: `path "${path}" climbs above its UNC share root via ".." (\\\\host\\share is the root of a network path); refused.` };
519
- }
520
- return { ok: true, key: folded.key };
521
- }
492
+ const unprefixed = nativeUncSpellingOf(family, screened.spelled);
522
493
  const spelled = family === "posix" && unprefixed.startsWith("//") ? unprefixed.replace(/^\/+/, "/") : unprefixed;
523
494
  const target = baseCwd && !isAbsoluteForFamily(family, spelled) ? joinForFamily(family, baseCwd, spelled) : spelled;
495
+ let thrownByProbe;
496
+ let seenThrow = false;
497
+ const probe = probeRealTarget(env, path, target, family, signal).catch((e) => {
498
+ thrownByProbe = e;
499
+ seenThrow = true;
500
+ throw e;
501
+ });
502
+ try {
503
+ return await withGateTimeout(probe, DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "pathCanonicalization");
504
+ }
505
+ catch (err) {
506
+ if (seenThrow && err === thrownByProbe)
507
+ throw err;
508
+ return { ok: false, message: `cannot resolve path "${path}": ${err instanceof Error ? err.message : String(err)}` };
509
+ }
510
+ }
511
+ async function probeRealTarget(env, path, target, family, signal) {
524
512
  const absR = await env.absolutePath(target, signal);
525
513
  if (!absR.ok)
526
514
  return { ok: false, message: `cannot resolve path "${path}": ${absR.error.message}` };
@@ -531,7 +519,7 @@ export async function canonicalizeTarget(env, path, signal, family, baseCwd) {
531
519
  return { ok: false, message: `cannot determine whether "${path}" exists: ${exists.error.message}`, unresolvedSymlink: true };
532
520
  }
533
521
  if (!exists.value) {
534
- const newPath = await canonicalizeNewPath(env, abs, signal);
522
+ const newPath = await canonicalizeNewPath(env, abs, family, signal);
535
523
  if (!newPath.ok) {
536
524
  return { ok: false, message: newPath.message, unresolvedSymlink: true };
537
525
  }
@@ -545,7 +533,7 @@ export async function canonicalizeTarget(env, path, signal, family, baseCwd) {
545
533
  return { ok: false, message: `cannot determine whether "${path}" is a symlink: ${info.error.message}`, unresolvedSymlink: true };
546
534
  }
547
535
  if (info.value.kind !== "symlink") {
548
- const viaAncestors = await canonicalizeNewPath(env, abs, signal, { skipLeaf: true });
536
+ const viaAncestors = await canonicalizeNewPath(env, abs, family, signal, { skipLeaf: true });
549
537
  if (!viaAncestors.ok) {
550
538
  return { ok: false, message: viaAncestors.message, unresolvedSymlink: true };
551
539
  }
@@ -574,13 +562,26 @@ function parentDir(abs) {
574
562
  const head = abs.slice(0, i);
575
563
  return /^[A-Za-z]:$/.test(head) ? head + abs[i] : head;
576
564
  }
577
- async function canonicalizeNewPath(env, abs, signal, opts) {
565
+ function uncAnchorIndex(parts) {
566
+ let seen = 0;
567
+ for (let i = 0; i < parts.length; i++) {
568
+ if (parts[i] === "")
569
+ continue;
570
+ seen++;
571
+ if (seen === 2)
572
+ return i;
573
+ }
574
+ return parts.length;
575
+ }
576
+ async function canonicalizeNewPath(env, abs, family, signal, opts) {
578
577
  const sep = nativeSepOf(abs);
579
- const parts = isWinFormPath(abs) ? abs.split(/[\\/]/) : abs.split("/");
578
+ const winForm = family === "win" || isWinFormPath(abs);
579
+ const parts = winForm ? abs.split(/[\\/]/) : abs.split("/");
580
580
  const tail = [];
581
+ const floor = family === "win" && isUncSpelling(abs) ? uncAnchorIndex(parts) : 1;
581
582
  if (opts?.skipLeaf === true && parts.length > 1)
582
583
  tail.push(parts.pop());
583
- while (parts.length > 1) {
584
+ while (parts.length > floor) {
584
585
  const candidate = parts.join(sep) || sep;
585
586
  const ex = await env.exists(candidate, signal);
586
587
  if (!ex.ok) {
@@ -591,7 +592,11 @@ async function canonicalizeNewPath(env, abs, signal, opts) {
591
592
  if (!canon.ok) {
592
593
  return { ok: false, message: `cannot canonicalize existing ancestor "${candidate}": ${canon.error.message}` };
593
594
  }
594
- return { ok: true, key: [canon.value, ...tail.reverse()].join(nativeSepOf(canon.value)) };
595
+ const tailSegments = tail.reverse();
596
+ const canonSep = nativeSepOf(canon.value);
597
+ if (tailSegments.length === 0)
598
+ return { ok: true, key: canon.value };
599
+ return { ok: true, key: [canon.value.replace(family === "win" ? /[\\/]+$/ : /\/+$/, ""), ...tailSegments].join(canonSep) };
595
600
  }
596
601
  tail.push(parts.pop());
597
602
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.20.0",
3
+ "version": "7.21.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -88,6 +88,7 @@
88
88
  "gate:docs-tool-names": "node scripts/verify-docs-tool-names.mjs",
89
89
  "gate:comment-ratio": "vitest run test/comment-ratio-ratchet.test.ts",
90
90
  "gate:negative-controls": "vitest run test/gate-negative-controls.test.ts",
91
+ "gate:memory-ga": "node scripts/gate-memory-distillation-ga.mjs",
91
92
  "gate:all": "node scripts/gate-all.mjs",
92
93
  "car:gate": "node scripts/car-gate.mjs"
93
94
  },