@sema-agent/core 7.20.1 → 7.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/dist/core/ask-origin.d.ts +95 -8
- package/dist/core/ask-origin.js +37 -5
- package/dist/core/gate-bounded-await.d.ts +6 -1
- package/dist/core/gate-bounded-await.js +13 -3
- package/dist/core/gate-fold.js +18 -5
- package/dist/core/gate-lanes.js +6 -12
- package/dist/core/memory-engine/consolidation-driver.d.ts +3 -0
- package/dist/core/memory-engine/consolidation-driver.js +20 -1
- package/dist/core/memory-engine/consolidation.d.ts +7 -0
- package/dist/core/memory-engine/consolidation.js +4 -1
- package/dist/core/memory-engine/distiller.d.ts +26 -1
- package/dist/core/memory-engine/distiller.js +9 -2
- package/dist/core/path-identity.d.ts +13 -0
- package/dist/core/path-identity.js +14 -0
- package/dist/core/runner/env-capability-census.d.ts +3 -1
- package/dist/core/runner/env-capability-census.js +24 -7
- package/dist/core/runner/gate-exit.d.ts +7 -0
- package/dist/core/runner/inherited-ask-grants.d.ts +1 -0
- package/dist/core/runner/inherited-ask-grants.js +5 -2
- package/dist/core/runner/memory-consolidation-driver.d.ts +73 -2
- package/dist/core/runner/memory-consolidation-driver.js +57 -0
- package/dist/core/runner/prepare-config-doors.js +2 -0
- package/dist/core/runner/prepare-policy-chain.d.ts +1 -0
- package/dist/core/runner/prepare-policy-chain.js +52 -20
- package/dist/core/runner/prepare-wiring-manifest.js +2 -0
- package/dist/core/runner/run-settle-and-teardown.js +5 -0
- package/dist/core/runner-deps.d.ts +21 -0
- package/dist/core/sensitive-path-policy.js +13 -0
- package/dist/core/wiring-manifest.d.ts +16 -0
- package/dist/core/wiring-manifest.js +1 -0
- package/dist/core/write-protect.d.ts +14 -2
- package/dist/core/write-protect.js +36 -13
- package/dist/engine/execution-env/node-execution-env.d.ts +10 -0
- package/dist/engine/execution-env/node-execution-env.js +29 -1
- package/dist/engine/harness/types.d.ts +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal/harness-types.d.ts +1 -1
- package/dist/tools/fs/bash-readonly-classifier.js +35 -6
- package/dist/tools/fs/read-deny.d.ts +4 -2
- package/dist/tools/fs/read-deny.js +5 -0
- package/dist/tools/fs/safety.d.ts +12 -0
- package/dist/tools/fs/safety.js +43 -38
- package/package.json +2 -1
- package/test/export-surface.snapshot.json +3 -1
- package/wire-changes/core@7.21.0.json +452 -0
- package/wire-changes/core@7.21.1.json +41 -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` (ON)
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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: "
|
|
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
|
-
:
|
|
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
|
|
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
|
|
51
|
-
*
|
|
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;
|