@sema-agent/core 7.17.0 → 7.17.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 +36 -0
- package/dist/core/memory-engine/engine.js +2 -1
- package/dist/core/memory-engine/layout.d.ts +18 -6
- package/dist/core/memory-engine/layout.js +40 -21
- package/dist/core/physical-path.d.ts +37 -0
- package/dist/core/physical-path.js +30 -0
- package/dist/core/runner/contracts.d.ts +1 -1
- package/dist/core/runner/prepare-artifact.d.ts +4 -5
- package/dist/core/runner/prepare-artifact.js +2 -16
- package/dist/core/runner/prepare-policy-chain.js +2 -2
- package/dist/core/runner/prepare-question-face.js +2 -1
- package/dist/core/runner/prepare-task.js +2 -2
- package/dist/core/runner/run-harness-handlers.js +4 -1
- package/dist/core/sensitive-path-policy.js +7 -8
- package/dist/core/skills-directory.js +4 -3
- package/dist/core/spec-contract.js +5 -4
- package/dist/core/task-registry-shared.d.ts +5 -1
- package/dist/core/task-registry-shared.js +1 -0
- package/dist/core/tool-catalog-entries.js +1 -1
- package/dist/core/tool-policy.d.ts +15 -0
- package/dist/core/tool-policy.js +3 -0
- package/dist/engine/execution-env/node-execution-env.js +4 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/orchestration/workflow-script-store.js +9 -25
- package/dist/orchestration/workflow.js +6 -3
- package/dist/stores/cc/task-list-store.js +2 -10
- package/dist/stores/file/fs-atomic.d.ts +15 -18
- package/dist/stores/file/fs-atomic.js +4 -14
- package/dist/stores/file/mailbox-store.d.ts +7 -11
- package/dist/stores/file/mailbox-store.js +4 -11
- package/dist/tools/artifact/local-stub.js +4 -3
- package/dist/tools/fs/bash-readonly-classifier.d.ts +17 -1
- package/dist/tools/fs/bash-readonly-classifier.js +125 -12
- package/dist/tools/fs/fs-bash.js +42 -17
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +5 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { closeSync, constants, createReadStream, mkdtempSync, openSync, readSync, statSync, truncateSync, unlinkSync } from "node:fs";
|
|
4
|
-
import { access, appendFile, lstat, mkdir, mkdtemp, open, readdir, readFile, readlink,
|
|
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
6
|
import { isAbsolute, join, resolve } 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
|
+
import { physicalPathOfAsync } from "../../core/physical-path.js";
|
|
11
12
|
import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
|
|
12
13
|
import { BackgroundShellError } from "../../core/background-shell.js";
|
|
13
14
|
import { SchedulerError } from "../../core/scheduler.js";
|
|
@@ -802,7 +803,7 @@ export class NodeExecutionEnv {
|
|
|
802
803
|
let hop;
|
|
803
804
|
try {
|
|
804
805
|
const link = await readlink(resolved);
|
|
805
|
-
const parentReal = await
|
|
806
|
+
const parentReal = await physicalPathOfAsync(resolve(resolved, "..")).catch(() => resolve(resolved, ".."));
|
|
806
807
|
hop = isAbsolute(link) ? link : resolve(parentReal, link);
|
|
807
808
|
}
|
|
808
809
|
catch {
|
|
@@ -1004,7 +1005,7 @@ export class NodeExecutionEnv {
|
|
|
1004
1005
|
async canonicalPath(path) {
|
|
1005
1006
|
const resolved = resolvePath(this.cwd, path);
|
|
1006
1007
|
try {
|
|
1007
|
-
return ok(await
|
|
1008
|
+
return ok(await physicalPathOfAsync(resolved));
|
|
1008
1009
|
}
|
|
1009
1010
|
catch (error) {
|
|
1010
1011
|
return err(toFileError(error, resolved));
|
package/dist/index.d.ts
CHANGED
|
@@ -83,6 +83,7 @@ export { getShellConfig, isWslBashLauncher } from "./engine/execution-env/node-e
|
|
|
83
83
|
export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
|
|
84
84
|
export type { SecretEnvFinding, SecretEnvFindingKind } from "./core/secret-env.js";
|
|
85
85
|
export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
|
|
86
|
+
export { physicalPathOf, physicalPathOfExisting } from "./core/physical-path.js";
|
|
86
87
|
export type { ExecutionEnv, FileInfo, Result, FileErrorCode, ExecutionErrorCode, WriteExpectation, WriteReceipt } from "./internal/harness.js";
|
|
87
88
|
export type { ExecResult } from "./internal/harness.js";
|
|
88
89
|
export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, missingRestoreSurface, isRetryableRemoteErrorCode, RETRYABLE_REMOTE_ERROR_CODES, } from "./core/remote-env.js";
|
package/dist/index.js
CHANGED
|
@@ -68,6 +68,7 @@ export { killProcessTree, signalProcessTree } from "./engine/execution-env/kill-
|
|
|
68
68
|
export { getShellConfig, isWslBashLauncher } from "./engine/execution-env/node-execution-env.js";
|
|
69
69
|
export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
|
|
70
70
|
export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
|
|
71
|
+
export { physicalPathOf, physicalPathOfExisting } from "./core/physical-path.js";
|
|
71
72
|
export { RemoteExecutionError, hasDestroy, isRemoteExecutionEnv, isSuspendable, isIsolated, missingRestoreSurface, isRetryableRemoteErrorCode, RETRYABLE_REMOTE_ERROR_CODES, } from "./core/remote-env.js";
|
|
72
73
|
export { withRetry } from "./core/with-retry.js";
|
|
73
74
|
export { addWorktree, pruneWorktrees, WORKTREE_PARENT } from "./core/git-worktree-env.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync,
|
|
2
|
-
import {
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { physicalPathOfExisting } from "../core/physical-path.js";
|
|
3
4
|
import { sanitizeScope } from "../stores/file/fs-atomic.js";
|
|
4
5
|
function isPlainObject(v) {
|
|
5
6
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -33,23 +34,6 @@ function safeFileStem(id) {
|
|
|
33
34
|
function containedBy(root, p) {
|
|
34
35
|
return p === root || p.startsWith(root + sep);
|
|
35
36
|
}
|
|
36
|
-
function canonicalizeDeepestExisting(p) {
|
|
37
|
-
let cur = p;
|
|
38
|
-
const missingTail = [];
|
|
39
|
-
for (;;) {
|
|
40
|
-
try {
|
|
41
|
-
const real = realpathSync(cur);
|
|
42
|
-
return missingTail.length === 0 ? real : join(real, ...[...missingTail].reverse());
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
const parent = dirname(cur);
|
|
46
|
-
if (parent === cur)
|
|
47
|
-
return undefined;
|
|
48
|
-
missingTail.push(basename(cur));
|
|
49
|
-
cur = parent;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
37
|
export function createFileWorkflowScriptStore(dir) {
|
|
54
38
|
const root = resolve(dir);
|
|
55
39
|
const scopeDir = (scope) => join(root, sanitizeScope(scope));
|
|
@@ -71,17 +55,17 @@ export function createFileWorkflowScriptStore(dir) {
|
|
|
71
55
|
if (!containedBy(base, full)) {
|
|
72
56
|
throw new Error(`workflow script store: scriptPath is outside this scope's script partition`);
|
|
73
57
|
}
|
|
74
|
-
const physRoot =
|
|
75
|
-
const physBase =
|
|
76
|
-
const physFull =
|
|
58
|
+
const physRoot = physicalPathOfExisting(root);
|
|
59
|
+
const physBase = physicalPathOfExisting(base);
|
|
60
|
+
const physFull = physicalPathOfExisting(full);
|
|
77
61
|
const refusePhysical = () => {
|
|
78
62
|
throw new Error(`workflow script store: scriptPath is outside this scope's script partition after symlink resolution`);
|
|
79
63
|
};
|
|
80
|
-
if (
|
|
64
|
+
if (!containedBy(physRoot, physBase))
|
|
81
65
|
refusePhysical();
|
|
82
|
-
if (
|
|
66
|
+
if (!containedBy(physBase, physFull))
|
|
83
67
|
refusePhysical();
|
|
84
|
-
return readFileSync(physFull
|
|
68
|
+
return readFileSync(physFull, "utf-8");
|
|
85
69
|
},
|
|
86
70
|
resolveName(name) {
|
|
87
71
|
if (/^wf_/i.test(name))
|
|
@@ -460,6 +460,9 @@ export const workflowResumeClaimFallback = {
|
|
|
460
460
|
table.delete(key);
|
|
461
461
|
},
|
|
462
462
|
};
|
|
463
|
+
function parkLaneArmed(opts) {
|
|
464
|
+
return opts.store !== undefined && opts.checkpointStore !== undefined;
|
|
465
|
+
}
|
|
463
466
|
export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
464
467
|
const alsDepth = currentWorkflowDepth();
|
|
465
468
|
const internalDepth = internals?.workflowDepth;
|
|
@@ -472,10 +475,10 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
472
475
|
if (opts.resumeFromRunId !== undefined && opts.journalStore === undefined) {
|
|
473
476
|
throw new Error("runWorkflow: resumeFromRunId requires a journalStore to load the prior run's journal");
|
|
474
477
|
}
|
|
475
|
-
if (opts.checkpointStore !== undefined && opts
|
|
478
|
+
if (opts.checkpointStore !== undefined && !parkLaneArmed(opts)) {
|
|
476
479
|
throw new WorkflowParkRefusal("workflow.park_requires_run_store", "runWorkflow: checkpointStore (the store this run's children park in) requires a WorkflowRunStore — a workflow child's park is recorded on its wa* row and proven against the checkpoint store at the next resume, and without a run store there is no durable row for a host to route the parked approval by", {});
|
|
477
480
|
}
|
|
478
|
-
if (opts.defaultDurableApproval !== undefined && (opts
|
|
481
|
+
if (opts.defaultDurableApproval !== undefined && !parkLaneArmed(opts)) {
|
|
479
482
|
throw new WorkflowParkRefusal("workflow.park_requires_run_store", "runWorkflow: defaultDurableApproval requires a WorkflowRunStore and a checkpointStore — a workflow child's park is recorded on its wa* row and read back from the checkpoint store at the next resume; without both there is no durable row for a host to route the parked approval by, or no way for a resume to prove the park", {});
|
|
480
483
|
}
|
|
481
484
|
if (opts.parkedResume !== undefined && opts.resumeFromRunId === undefined) {
|
|
@@ -917,7 +920,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
|
|
|
917
920
|
const seat = s.checkpointStore;
|
|
918
921
|
if (seat === undefined || seat === "disabled")
|
|
919
922
|
return;
|
|
920
|
-
if (
|
|
923
|
+
if (!parkLaneArmed(opts)) {
|
|
921
924
|
throw new WorkflowParkRefusal("workflow.park_requires_run_store", "workflow agent: the child's checkpointStore would let it park where this workflow cannot read the park back — a workflow whose children can park needs a WorkflowRunStore and that checkpoint store as its read seat (RunWorkflowOptions.store + checkpointStore); wire both, or leave the child's seat unset.", {});
|
|
922
925
|
}
|
|
923
926
|
if (seat !== parkSeat) {
|
|
@@ -2,21 +2,13 @@ import { readFileSync, readdirSync, mkdirSync, unlinkSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { assertJsonMetadata, normalizeTaskShape } from "../../tools/task-list.js";
|
|
4
4
|
import { atomicWriteFile } from "../file/fs-atomic.js";
|
|
5
|
-
import {
|
|
6
|
-
function realpathSyncSafe(p) {
|
|
7
|
-
try {
|
|
8
|
-
return realpathSync(p);
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
return p;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
5
|
+
import { physicalPathOf } from "../../core/physical-path.js";
|
|
14
6
|
import { withCcLock } from "./lockfile.js";
|
|
15
7
|
export function createCcFileTaskListStore(listDir) {
|
|
16
8
|
const dir = resolve(listDir);
|
|
17
9
|
mkdirSync(dir, { recursive: true });
|
|
18
10
|
const hwmPath = join(dir, ".highwatermark");
|
|
19
|
-
const lockTarget =
|
|
11
|
+
const lockTarget = physicalPathOf(dir);
|
|
20
12
|
const CC_TASK_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
21
13
|
const validId = (id) => CC_TASK_ID_RE.test(id);
|
|
22
14
|
const taskPath = (id) => {
|
|
@@ -11,10 +11,8 @@ export declare function sanitizePathComponent(raw: string): string;
|
|
|
11
11
|
* on-disk dir name, never the stored data. Never `path.join` a raw scope.
|
|
12
12
|
*/
|
|
13
13
|
export declare function sanitizeScope(scope: string): string;
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
* NFC-normalized, and `realpath`-canonicalized once the dir exists. Creates the dir (0o700) if absent.
|
|
17
|
-
*/
|
|
14
|
+
/** Resolve the data root (CC `getClaudeConfigHomeDir` analog): `$AGENT_DATA_DIR ?? ~/.ai-agent`,
|
|
15
|
+
* NFC-normalized, canonicalized once the dir exists. Creates the dir (0o700) if absent. */
|
|
18
16
|
export declare function resolveDataRoot(explicit?: string): string;
|
|
19
17
|
/** Ensure a directory exists with 0o700 perms (idempotent). */
|
|
20
18
|
/**
|
|
@@ -59,25 +57,24 @@ export declare function readJsonlRecords<T>(path: string, onCorrupt?: (info: {
|
|
|
59
57
|
* append (the checkpoint commit point ALWAYS fsyncs; session/memory cadence is the caller's `fsyncEvery`).
|
|
60
58
|
*/
|
|
61
59
|
/**
|
|
62
|
-
* RB-144
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* session store, RB-119 added case folding there, and a defect hunt then found the agent store, the run store
|
|
68
|
-
* and the task-list still keying on a lexical `resolve()` while two of them cite the mailbox as the
|
|
69
|
-
* precedent they copied. Point fixes kept missing siblings, so the rule now lives in ONE place that all of
|
|
70
|
-
* them call.
|
|
60
|
+
* RB-144: THE canonical key for "one authority per physical location". Every file backend keeps a
|
|
61
|
+
* module-level table so that N instances over one directory collapse into one CAS authority; getting the
|
|
62
|
+
* KEY wrong reopens the defect the table exists to close, which this repo paid for four times (mailbox,
|
|
63
|
+
* session store, then the agent/run/task-list stores still keying on a lexical `resolve()`). Point fixes
|
|
64
|
+
* kept missing siblings, so the rule lives in ONE place they all call.
|
|
71
65
|
*
|
|
72
66
|
* Two normalizations, each for a demonstrated failure:
|
|
73
|
-
* -
|
|
67
|
+
* - PHYSICAL PATH — a symlinked data dir (`/var` → `/private/var` on macOS, a bind-mount, a linked
|
|
74
68
|
* `~/.ai-agent`) otherwise yields two authorities for one directory, and both writers win the CAS.
|
|
75
|
-
* - CASE FOLD — on a case-insensitive filesystem
|
|
76
|
-
*
|
|
69
|
+
* - CASE FOLD — on a case-insensitive filesystem `Foo/` and `foo/` are the same directory. The mint
|
|
70
|
+
* answers with the on-disk spelling, which folds the two only where the entry EXISTS; the fold here
|
|
71
|
+
* also covers a not-yet-created tail, and folding a PATH would name a different directory on a
|
|
72
|
+
* case-SENSITIVE volume (see the cc task-list store's lock target, which must not fold) — so it stays
|
|
73
|
+
* at this call site, which keys, rather than inside the mint, which answers about paths.
|
|
77
74
|
*
|
|
78
75
|
* The key is used ONLY for table lookup — never for I/O, so the on-disk name stays verbatim. A path that
|
|
79
|
-
* does not exist yet resolves through its
|
|
80
|
-
* (a store must not fail to construct because canonicalization is unavailable).
|
|
76
|
+
* does not exist yet resolves through its deepest existing ancestor; a path with no resolvable ancestor at
|
|
77
|
+
* all keys on its lexical form (a store must not fail to construct because canonicalization is unavailable).
|
|
81
78
|
*/
|
|
82
79
|
export declare function canonicalStoreKey(p: string): string;
|
|
83
80
|
export declare class AppendLog {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync,
|
|
1
|
+
import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
|
|
2
2
|
import { homedir, hostname, uptime } from "node:os";
|
|
3
3
|
import { hrtime } from "node:process";
|
|
4
4
|
import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
5
5
|
import { createHash, randomBytes } from "node:crypto";
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { physicalPathOf, physicalPathOfExisting } from "../../core/physical-path.js";
|
|
7
8
|
const SAFE_COMPONENT = /^[A-Za-z0-9_.-]+$/;
|
|
8
9
|
export function sanitizePathComponent(raw) {
|
|
9
10
|
if (raw === "" || raw === "." || raw === ".." || !SAFE_COMPONENT.test(raw)) {
|
|
@@ -18,7 +19,7 @@ export function sanitizeScope(scope) {
|
|
|
18
19
|
export function resolveDataRoot(explicit) {
|
|
19
20
|
const raw = (explicit ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent")).normalize("NFC");
|
|
20
21
|
mkdirSync(raw, { recursive: true, mode: 0o700 });
|
|
21
|
-
return
|
|
22
|
+
return physicalPathOf(raw);
|
|
22
23
|
}
|
|
23
24
|
export function writeThenLink(target, content) {
|
|
24
25
|
ensureDir(dirnameOf(target));
|
|
@@ -134,18 +135,7 @@ export function readJsonlRecords(path, onCorrupt) {
|
|
|
134
135
|
return out;
|
|
135
136
|
}
|
|
136
137
|
export function canonicalStoreKey(p) {
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
return realpathSync(abs).toLowerCase();
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
try {
|
|
143
|
-
return join(realpathSync(dirname(abs)), basename(abs)).toLowerCase();
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
return abs.toLowerCase();
|
|
147
|
-
}
|
|
148
|
-
}
|
|
138
|
+
return physicalPathOfExisting(p).toLowerCase();
|
|
149
139
|
}
|
|
150
140
|
export class AppendLog {
|
|
151
141
|
fd;
|
|
@@ -81,17 +81,13 @@ export declare class FileMailboxStore implements MailboxStore {
|
|
|
81
81
|
/**
|
|
82
82
|
* The canonical mutex / shared-state key — instance-independent (X-5).
|
|
83
83
|
*
|
|
84
|
-
* RB-162
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* Worse for the record: the enumerative guard written for this exact family tested
|
|
93
|
-
* `src.includes("canonicalStoreKey")`, which that one comment satisfied. The guard was green because the
|
|
94
|
-
* file talked about the rule.
|
|
84
|
+
* RB-162: this went through the physical path but NOT the case fold, even though the module header
|
|
85
|
+
* claimed otherwise. The handle component was folded by `boxPath` while the ROOT was not, so two
|
|
86
|
+
* spellings of one directory on a case-insensitive filesystem produced two shared-state slots over one
|
|
87
|
+
* physical file, each allocating its own `seq` — the same contract violation the RB-62 comment above
|
|
88
|
+
* describes, reached by the other half of the same canonicalization. The enumerative guard written for
|
|
89
|
+
* this family tested `src.includes("canonicalStoreKey")`, which the CLAIM satisfied: a guard that reads
|
|
90
|
+
* prose is green while the code diverges.
|
|
95
91
|
*/
|
|
96
92
|
private lockKey;
|
|
97
93
|
/**
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { join, resolve, sep } from "node:path";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { assertRetentionPolicy } from "../../core/retention-policy.js";
|
|
4
|
-
import { existsSync, linkSync, readdirSync, readFileSync,
|
|
4
|
+
import { existsSync, linkSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { detachMailboxMessage, newestSentAt, readMailboxPeerMeta, } from "../../core/mailbox-store.js";
|
|
6
6
|
import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, sanitizeScope, sanitizePathComponent } from "./fs-atomic.js";
|
|
7
|
+
import { physicalPathOf } from "../../core/physical-path.js";
|
|
7
8
|
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
8
|
-
function realpathSyncSafe(p) {
|
|
9
|
-
try {
|
|
10
|
-
return realpathSync(p);
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
return p;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
9
|
function diskMarkOf(path) {
|
|
17
10
|
try {
|
|
18
11
|
const st = statSync(path);
|
|
@@ -361,8 +354,8 @@ export class FileMailboxStore {
|
|
|
361
354
|
const lexicalTmp = resolve(join(root, "tmp"));
|
|
362
355
|
ensureDir(lexicalDir);
|
|
363
356
|
ensureDir(lexicalTmp);
|
|
364
|
-
this.dir =
|
|
365
|
-
this.tmpDir =
|
|
357
|
+
this.dir = physicalPathOf(lexicalDir);
|
|
358
|
+
this.tmpDir = physicalPathOf(lexicalTmp);
|
|
366
359
|
}
|
|
367
360
|
boxPath(scope, handle) {
|
|
368
361
|
return join(this.dir, sanitizeScope(scope), `${sanitizePathComponent(handle).toLowerCase()}.jsonl`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync,
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import {} from "../../core/artifact-host.js";
|
|
5
|
+
import { physicalPathOf } from "../../core/physical-path.js";
|
|
5
6
|
import { acquireStoreDirLock } from "../../stores/file/fs-atomic.js";
|
|
6
7
|
import { normalizePublishedPath } from "./artifact-tool.js";
|
|
7
8
|
export const EVAL_STUB_URL_PREFIX = "eval-stub://artifact/";
|
|
@@ -112,9 +113,9 @@ export class LocalArtifactStub {
|
|
|
112
113
|
}
|
|
113
114
|
#snapshotOf(row) {
|
|
114
115
|
const base = this.#versionDir(row.slug, row.version);
|
|
115
|
-
const realBase =
|
|
116
|
+
const realBase = physicalPathOf(base);
|
|
116
117
|
const files = row.files.map((f) => {
|
|
117
|
-
const target =
|
|
118
|
+
const target = physicalPathOf(resolve(base, ...f.path.split("/")));
|
|
118
119
|
if (!target.startsWith(realBase + sep))
|
|
119
120
|
throw new Error(`artifact stub ledger is corrupt (a file resolves outside its version directory): ${this.#ledgerPath()}`);
|
|
120
121
|
return { path: f.path, content: new Uint8Array(readFileSync(target)), ...(f.mediaType !== undefined ? { mediaType: f.mediaType } : {}) };
|
|
@@ -321,9 +321,18 @@ export interface CompoundReadonlyVerdict {
|
|
|
321
321
|
* (`cd -`, bare `cd`, a pattern). The STRUCTURAL form of the unresolvable sentence: `reason` carries the
|
|
322
322
|
* first one's sentence, this member every one of them, and both survive whatever other sentence (a shape
|
|
323
323
|
* refusal) takes precedence. CONSUMER CONTRACT: non-empty ⇒ the boundary could not read where the command
|
|
324
|
-
* reads — a fail-closed ask (plain
|
|
324
|
+
* reads — a fail-closed ask, never a vouch (plain on the classify seat, whose classifier asks beside it; MANDATED on the
|
|
325
|
+
* boundary-only seat, where nothing else would ask).
|
|
325
326
|
*/
|
|
326
327
|
unresolvedOperands?: readonly string[];
|
|
328
|
+
/**
|
|
329
|
+
* The grammar gate refused the WHOLE command before the walk ran (an escape, a substitution, a subshell, a
|
|
330
|
+
* redirection, a line break — see {@link rejectedSansRedirection} and the compound gate): no operand was judged,
|
|
331
|
+
* and what the shell would run is not knowable from the text (a reader can be spelled `ca\t`, fed by `<`, or hidden
|
|
332
|
+
* in `$(…)`). CONSUMER CONTRACT: present ⇒ the boundary judged nothing — MANDATED on both probe seats (a name-reading
|
|
333
|
+
* arm or a stored allow rule must not retire a read nobody judged). Structure, never the sentence.
|
|
334
|
+
*/
|
|
335
|
+
refusedWhole?: true;
|
|
327
336
|
/**
|
|
328
337
|
* Operands of a RECURSIVE/EXPANDING read form (`grep -r`, `ls -R`, `du`, … — see
|
|
329
338
|
* {@link RECURSIVE_READ_FORMS}) judged with a {@link BashReadonlyRootBoundary.denyMatch} seat wired.
|
|
@@ -380,6 +389,13 @@ export declare function formatOutOfRootReadApprovalOption(directory: string): st
|
|
|
380
389
|
* has no filesystem and stays lexical, and a REMOTE env keeps the lexical behaviour (its
|
|
381
390
|
* `canonicalPath` is an RPC per candidate). */
|
|
382
391
|
export declare function resolveOperandLexically(base: string | undefined, operand: string, homeDir: string | undefined): string | undefined;
|
|
392
|
+
/** Whether a command the grammar gate REFUSED WHOLE carries evidence that it may READ: a listed reader or a shell
|
|
393
|
+
* re-entry program anywhere in it, a substitution where a program name stands (`$C /etc/passwd`), or a stdin
|
|
394
|
+
* redirection from a non-literal target (`cat < $F`). The out-of-root and base-mover readings over its literal path
|
|
395
|
+
* tokens are the containment gate's, asked separately. A refused command with none of these — a write through a
|
|
396
|
+
* redirection (`echo hi > out.txt`), a function definition — names no read for the boundary to mandate: the syntax
|
|
397
|
+
* refusal stays the classify seat's own plain ask. */
|
|
398
|
+
export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>): boolean;
|
|
383
399
|
/**
|
|
384
400
|
* The home directory's VARIABLE spellings, SUBSTITUTED with the declared value before a read face segments the
|
|
385
401
|
* command: `$HOME` / `${HOME}` at the START of a word (an empty quote pair before it included), unquoted or
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, pathFamilyOf, withinAnyRoot } from "./safety.js";
|
|
1
|
+
import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
|
|
2
2
|
export const NOT_AUTO_ALLOWED = "— not auto-allowed";
|
|
3
3
|
export const BASH_READONLY_DEFAULT_ALLOW = [
|
|
4
4
|
"ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
|
|
@@ -224,6 +224,8 @@ export function resolveOperandLexically(base, operand, homeDir) {
|
|
|
224
224
|
return undefined;
|
|
225
225
|
}
|
|
226
226
|
const family = base === undefined ? undefined : pathFamilyOf({ root: base });
|
|
227
|
+
if (!win32NamespaceScreen(raw, family).ok)
|
|
228
|
+
return undefined;
|
|
227
229
|
if (isAbsoluteForFamily(family, raw))
|
|
228
230
|
return normalizeAbsPathLexically(nativeUncSpellingOf(family, raw));
|
|
229
231
|
if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
|
|
@@ -232,6 +234,78 @@ export function resolveOperandLexically(base, operand, homeDir) {
|
|
|
232
234
|
return undefined;
|
|
233
235
|
return normalizeAbsPathLexically(joinForFamily(family, base, raw));
|
|
234
236
|
}
|
|
237
|
+
function reheadSegment(toks, head, from = 0) {
|
|
238
|
+
return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
|
|
239
|
+
}
|
|
240
|
+
const SHELL_CONTROL_PREFIXES = new Set(["!", "if", "then", "elif", "else", "while", "until", "do", "time"]);
|
|
241
|
+
const SHELL_BLOCK_WORDS = new Set(["for", "select", "case", "esac", "fi", "done", "function", "coproc", "[[", "]]", "in"]);
|
|
242
|
+
function isBareControlWord(toks, i, words) {
|
|
243
|
+
const word = toks.folded[i];
|
|
244
|
+
return word !== undefined && toks.raw[i] === word && words.has(word);
|
|
245
|
+
}
|
|
246
|
+
function tokensMoveBase(toks, allow) {
|
|
247
|
+
let from = 0;
|
|
248
|
+
while (from < toks.folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
|
|
249
|
+
from++;
|
|
250
|
+
const head = toks.folded[from];
|
|
251
|
+
if (head === undefined)
|
|
252
|
+
return false;
|
|
253
|
+
if (toks.raw[from] !== head)
|
|
254
|
+
return true;
|
|
255
|
+
if (head === "cd")
|
|
256
|
+
return true;
|
|
257
|
+
if (!COMMAND_LAUNCHERS.has(head))
|
|
258
|
+
return false;
|
|
259
|
+
const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
|
|
260
|
+
return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
|
|
261
|
+
}
|
|
262
|
+
const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
|
|
263
|
+
export function refusedCommandMayRead(command, allow) {
|
|
264
|
+
for (const m of command.matchAll(/(?<![<>&\d])<(?![<&])\s*(\S*)/g)) {
|
|
265
|
+
const target = (m[1] ?? "").replace(/^["']+|["']+$/g, "");
|
|
266
|
+
if (target === "" || /^[$`(~]/.test(target))
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const fold = (w) => w.replace(/\\(.)/g, "$1").replace(/["']/g, "");
|
|
270
|
+
const programOf = (w) => {
|
|
271
|
+
const inner = fold(w).replace(/^[$`(]+|[)`]+$/g, "");
|
|
272
|
+
return inner.includes("/") ? inner.slice(inner.lastIndexOf("/") + 1) : inner;
|
|
273
|
+
};
|
|
274
|
+
for (const word of command.split(/[\s<>|;&()]+/)) {
|
|
275
|
+
const base = programOf(word);
|
|
276
|
+
if (base === "")
|
|
277
|
+
continue;
|
|
278
|
+
if ((allow.has(base) && !NO_PATH_OPERAND_COMMANDS.has(base)) || SHELL_REENTRY_PROGRAM_RE.test(base))
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
for (const segment of command.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/)) {
|
|
282
|
+
for (const raw of segment.trim().split(/\s+/)) {
|
|
283
|
+
const word = fold(raw);
|
|
284
|
+
if (word === "")
|
|
285
|
+
continue;
|
|
286
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || SHELL_CONTROL_PREFIXES.has(word) || SHELL_BLOCK_WORDS.has(word) || word === "{" || word === "}" || COMMAND_LAUNCHERS.has(programOf(word)) || word.startsWith("-"))
|
|
287
|
+
continue;
|
|
288
|
+
if (/^[$`(]/.test(word))
|
|
289
|
+
return true;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
function unwrapLauncher(folded, allow) {
|
|
296
|
+
for (let i = 1; i < folded.length; i++) {
|
|
297
|
+
const t = folded[i];
|
|
298
|
+
if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t) || COMMAND_LAUNCHERS.has(t))
|
|
299
|
+
continue;
|
|
300
|
+
if (t.startsWith("-"))
|
|
301
|
+
return { kind: "refused", reason: `"${folded[0]}" is given an option before the program it runs — which program runs is not readable without the launcher's option table ${NOT_AUTO_ALLOWED}` };
|
|
302
|
+
const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
|
|
303
|
+
if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
|
|
304
|
+
return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
|
|
305
|
+
return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted" };
|
|
306
|
+
}
|
|
307
|
+
return { kind: "unlisted" };
|
|
308
|
+
}
|
|
235
309
|
function tokenizeSegment(segment) {
|
|
236
310
|
const raw = splitWordsQuoteAware(segment);
|
|
237
311
|
return { folded: raw.map((r) => { const f = foldQuoteRemovalToken(r); return tildeIsLiteral(r) ? literalTildeSpelling(f) : f; }), raw };
|
|
@@ -803,12 +877,51 @@ function segmentCompoundForReadonly(command, homeDir) {
|
|
|
803
877
|
export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
|
|
804
878
|
const split = segmentCompoundForReadonly(command, boundary?.homeDir);
|
|
805
879
|
if ("reject" in split)
|
|
806
|
-
return { reason: split.reject };
|
|
880
|
+
return { reason: split.reject, refusedWhole: true };
|
|
807
881
|
const { segments, pipeFed } = split;
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
882
|
+
let nameRefusal;
|
|
883
|
+
const unlistedSegments = new Set();
|
|
884
|
+
const reheaded = new Map();
|
|
885
|
+
for (let si = 0; si < segments.length; si++) {
|
|
886
|
+
const toks = tokenizeSegment(segments[si]);
|
|
887
|
+
const folded = toks.folded;
|
|
888
|
+
let from = 0;
|
|
889
|
+
while (from < folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
|
|
890
|
+
from++;
|
|
891
|
+
const written = folded[from];
|
|
892
|
+
if (written === undefined)
|
|
893
|
+
return { reason: "empty command" };
|
|
894
|
+
if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
|
|
895
|
+
unlistedSegments.add(si);
|
|
896
|
+
nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
|
|
900
|
+
if ("reject" in parsed)
|
|
901
|
+
return { reason: parsed.reject, refusedWhole: true };
|
|
902
|
+
const pathPrefixed = parsed.name.includes("/");
|
|
903
|
+
const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
|
|
904
|
+
if (pathPrefixed)
|
|
905
|
+
nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
|
|
906
|
+
if (allow.has(name)) {
|
|
907
|
+
if (pathPrefixed || from > 0)
|
|
908
|
+
reheaded.set(si, { from, head: name });
|
|
909
|
+
if (from > 0)
|
|
910
|
+
nameRefusal ??= `command "${folded[0]}" is not in the read-only allowlist`;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
|
|
914
|
+
if (COMMAND_LAUNCHERS.has(name)) {
|
|
915
|
+
const unwrapped = unwrapLauncher(folded.slice(from), allow);
|
|
916
|
+
if (unwrapped.kind === "refused")
|
|
917
|
+
return { reason: unwrapped.reason, refusedWhole: true };
|
|
918
|
+
if (unwrapped.kind === "unlisted")
|
|
919
|
+
unlistedSegments.add(si);
|
|
920
|
+
else
|
|
921
|
+
reheaded.set(si, { from: from + unwrapped.index, head: unwrapped.head });
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
unlistedSegments.add(si);
|
|
812
925
|
}
|
|
813
926
|
const HEAD_AUTO_ALLOW_MAX = 1_000_000;
|
|
814
927
|
const headBoundIsSmall = (name, toks) => {
|
|
@@ -840,9 +953,12 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
840
953
|
};
|
|
841
954
|
const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
|
|
842
955
|
const foldedSegments = [];
|
|
843
|
-
let shapeRefusal;
|
|
956
|
+
let shapeRefusal = nameRefusal;
|
|
844
957
|
for (let si = 0; si < segments.length; si++) {
|
|
845
|
-
|
|
958
|
+
if (unlistedSegments.has(si))
|
|
959
|
+
continue;
|
|
960
|
+
const rehead = reheaded.get(si);
|
|
961
|
+
const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
|
|
846
962
|
const toks = segmentTokens.folded;
|
|
847
963
|
if (toks.length === 0)
|
|
848
964
|
continue;
|
|
@@ -1187,10 +1303,7 @@ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary
|
|
|
1187
1303
|
if (readSegments.length === 0) {
|
|
1188
1304
|
return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
|
|
1189
1305
|
}
|
|
1190
|
-
const bodyHasCd = readSegments.some((seg) =>
|
|
1191
|
-
const p = parseLeadingCommandName(seg);
|
|
1192
|
-
return "name" in p && p.name === "cd";
|
|
1193
|
-
});
|
|
1306
|
+
const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg), allow));
|
|
1194
1307
|
const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
|
|
1195
1308
|
const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
|
|
1196
1309
|
if (verdict.reason !== undefined)
|