@smthrs/std 1.0.0-rc.6 → 1.0.0-rc.8
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 +4 -0
- package/README.md +39 -37
- package/dist/cjs/Bash.d.ts +10 -0
- package/dist/cjs/Bash.d.ts.map +1 -1
- package/dist/cjs/Bash.js +13 -12
- package/dist/cjs/Bash.js.map +3 -3
- package/dist/cjs/Checkpoints.d.ts +37 -0
- package/dist/cjs/Checkpoints.d.ts.map +1 -1
- package/dist/cjs/Checkpoints.js +75 -0
- package/dist/cjs/Checkpoints.js.map +2 -2
- package/dist/cjs/internal/EnvelopePrecheck.d.ts +2 -0
- package/dist/cjs/internal/EnvelopePrecheck.d.ts.map +1 -1
- package/dist/cjs/internal/EnvelopePrecheck.js +217 -34
- package/dist/cjs/internal/EnvelopePrecheck.js.map +3 -3
- package/dist/cjs/internal/Exec.d.ts +26 -14
- package/dist/cjs/internal/Exec.d.ts.map +1 -1
- package/dist/cjs/internal/Exec.js +73 -31
- package/dist/cjs/internal/Exec.js.map +2 -2
- package/dist/cjs/internal/Text.d.ts +11 -0
- package/dist/cjs/internal/Text.d.ts.map +1 -1
- package/dist/cjs/internal/Text.js +3 -0
- package/dist/cjs/internal/Text.js.map +2 -2
- package/dist/esm/Bash.d.ts +10 -0
- package/dist/esm/Bash.d.ts.map +1 -1
- package/dist/esm/Bash.js +22 -13
- package/dist/esm/Bash.js.map +1 -1
- package/dist/esm/Checkpoints.d.ts +37 -0
- package/dist/esm/Checkpoints.d.ts.map +1 -1
- package/dist/esm/Checkpoints.js +85 -0
- package/dist/esm/Checkpoints.js.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.d.ts +2 -0
- package/dist/esm/internal/EnvelopePrecheck.d.ts.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.js +295 -60
- package/dist/esm/internal/EnvelopePrecheck.js.map +1 -1
- package/dist/esm/internal/Exec.d.ts +26 -14
- package/dist/esm/internal/Exec.d.ts.map +1 -1
- package/dist/esm/internal/Exec.js +116 -30
- package/dist/esm/internal/Exec.js.map +1 -1
- package/dist/esm/internal/Text.d.ts +11 -0
- package/dist/esm/internal/Text.d.ts.map +1 -1
- package/dist/esm/internal/Text.js +11 -0
- package/dist/esm/internal/Text.js.map +1 -1
- package/docs/README.md +3 -1
- package/docs/api.md +21 -18
- package/docs/concepts/limits-and-disclosure.md +8 -5
- package/docs/guides/pin-a-checkpoint.md +20 -0
- package/docs/guides/run-a-shell-command.md +16 -6
- package/docs/installation.md +13 -9
- package/docs/quickstart.md +1 -1
- package/docs/reference/flows.md +2 -2
- package/docs/troubleshooting.md +15 -3
- package/package.json +6 -6
- package/src/Bash.ts +25 -13
- package/src/Checkpoints.ts +137 -0
- package/src/internal/EnvelopePrecheck.ts +295 -63
- package/src/internal/Exec.ts +157 -51
- package/src/internal/Text.ts +15 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/Checkpoints.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Pinned trees, and the scratch checkouts a call runs against.\n *\n * A checkpoint is a **value**: minting one changes nothing about the workspace,\n * and running a call against one leaves the live tree exactly as it stands.\n * That is the whole of what it buys. Before it existed, the only way a run\n * could answer \"did this command fail before my change\" was to undo the change\n * \u2014 and the journals price that exactly. On `sympy__sympy-13878` the r95repl\n * lane applied one byte-identical 4,789-character patch **five times**, four of\n * those applications preceded by `git checkout -- sympy/stats/crv_types.py`,\n * because a clean fails-before proof required reverting the very work it was\n * meant to prove.\n *\n * This module is the host half of `@smthrs/harness`'s `ctx.checkpoint()` and\n * `ctx.call(flow, input, { at })`. The harness owns the surface, the identity,\n * the bound and the refusals; nothing in it knows what a tree is. This owns the\n * two host operations that need one:\n *\n * - {@link Checkpoints.capture} records the working tree under an id, and\n * - {@link Checkpoints.materialize} gives that tree back as a directory for the\n * length of one call.\n *\n * ## The git binding, and the container constraint it is shaped by\n *\n * {@link layerGit} records a checkpoint with `git stash create`, then names the\n * commit it prints in this repository's own git config rather than with a ref \u2014\n * see {@link configSection} for why a ref would hand the agent its own edit back\n * as history. `stash create` is the one git command\n * that records the working tree and changes nothing else: it does not move the\n * worktree and does not touch the stash ref. It runs against a shadow\n * `GIT_DIR` over a copy of the workspace index (see `internal/GitWorktree.ts`),\n * so it never writes the repository's index and never runs a program the\n * workspace's `.git` names. That matters more than it sounds. The agent runs `git` in this same\n * workspace and its own `git diff` is the run's evidence, so a capture that\n * staged into the real index would be the harness editing the evidence while\n * recording it. A tree with nothing to record prints nothing, and the\n * checkpoint is then `HEAD` \u2014 which is exactly what the tree is.\n *\n * Untracked files are not in the recorded tree, which matches how the rig\n * captures a patch: `capture-patch.sh` drops paths that did not exist when the\n * agent started, so a checkpoint holds what a patch would hold.\n *\n * The materialization is a detached checkout at `<root>/.flows-checkpoints/<id>-<lease>`\n * \u2014 **inside** the workspace, which looks wrong and is the only thing that\n * works. A benchmark check runs through `docker exec` inside `/testbed`, and\n * `/testbed` is a bind mount of the live workspace. A scratch checkout anywhere\n * else on the host is simply not visible to the container, so the call would run\n * against a path that does not exist. Placed under the workspace it is visible\n * at the same subpath under the mount, needs no second mount, and needs no\n * change to how the rig starts its container. `TestRun`'s `against: \"base\"`\n * reached the same conclusion first and this reuses its shape;\n * `evals/swebench/run-instance.sh` excludes the directory from patch capture for\n * the same reason it excludes `.flows-test-base`.\n *\n * The checkout is added and removed around **one call**, which is stricter than\n * the frame lifetime the design asks for and much simpler: there is no cache to\n * invalidate, no id to leak, and a checkpoint used by three calls pays three\n * checkouts rather than risking one stale directory. A run that reuses a\n * checkpoint across frames simply re-materializes it, because the ref is what\n * persists.\n *\n * ## What can be pointed at a checkpoint, and what cannot\n *\n * {@link relocate} is the closed answer, and `Relocate.ts` owns it. That half\n * knows the input shapes of `bash`, `read`, `ls`, `grep` and `glob`; this half\n * knows git. They change for different reasons, so they are different modules,\n * and {@link relocate} is re-exported here because the harness reaches the whole\n * feature through this one.\n *\n * @since 1.0.0\n */\n\nimport { ChildProcessSpawner } from \"@smthrs/kernel/ChildProcessSpawner\"\nimport { Context, Effect, Layer, Schema } from \"effect\"\nimport { GitWorktree } from \"./internal/GitWorktree.ts\"\nimport { withoutTrailingSlash } from \"./internal/Paths.ts\"\nimport * as Prune from \"./internal/Prune.ts\"\nimport * as StdError from \"./StdError.ts\"\nimport * as TestRunner from \"./TestRunner.ts\"\n\n/**\n * The id naming the tree a run opened on.\n *\n * It is not minted: the host either recorded one before the agent started or it\n * did not. {@link layerGit} resolves it to {@link TestRunner.captureBase} and\n * then to `HEAD`, which is the same precedence `TestRun` uses for the same\n * question \u2014 so a workspace that has one answer for a baseline has one answer\n * for both.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const baseId = \"base\"\n\n/**\n * The directory checkpoints are materialized into, relative to the repository\n * root and therefore also relative to a container's view of it.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const scratchDirectory = Prune.checkpointScratch\n\n/**\n * The git-config section {@link layerGit} records minted checkpoints under.\n *\n * Config and **not a ref**, and that is the whole of the decision. A ref is\n * history: `git log --all` lists it, `git show` prints it, and `git log --all\n * -S` searches it \u2014 so a checkpoint named by a ref would hand an agent a commit\n * containing its own edit and let it read that back as if it were upstream\n * work. That exact class cost the measured program real money before the rig\n * moved jj's store out of the workspace (`evals/swebench/run-instance.sh`:\n * django-13346 applied two of its own snapshots as a fake fix), and a ref here\n * would reintroduce it one wave later.\n *\n * The commit object itself is unreferenced. It stays alive for the life of the\n * run \u2014 nothing in a run prunes \u2014 and a checkout by id works perfectly well,\n * which is measured in `CheckpointsFixture.test.ts`. So the tree is reachable\n * by id and no command that walks refs can reach it:\n * `git log`, `git log --all`, `git log -S` and `git show <ref>` all answer\n * exactly as they did before the capture.\n *\n * One command still sees it, measured rather than argued in\n * `CheckpointsFixture.test.ts`: `git fsck` reports it as a dangling commit,\n * because that is what an unreferenced commit is. A checkout is a standalone\n * repository, not a worktree of this one, so `git log --all` does not list it\n * even while a call runs in it. It is not reachable by a name that looks like project history, and the cell\n * contract's environment section says outright that a dangling commit is this\n * harness holding the agent's own tree \u2014 which is the half of the django-13346\n * lesson that a store cannot enforce.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const configSection = \"flows-checkpoint\"\n\n/**\n * One tree this run has pinned.\n *\n * `ref` is the store's own name for it. Nothing above this module interprets\n * it; it travels so the journal says which tree a checkpointed call read.\n *\n * @category models\n * @since 1.0.0\n */\nexport class Snapshot extends Schema.Class<Snapshot>(\"@smthrs/std/Checkpoints/Snapshot\")({\n id: Schema.String,\n ref: Schema.String\n}) {}\n\n/**\n * A tree handed back as a directory, for the length of one call.\n *\n * `host` is where the process running on this machine finds it. `guest` is\n * where a container finds the same directory, which is the same path when no\n * container is in play. They differ exactly as `TestRunner.Runner`'s `cwd` and\n * `root` differ, and for the same reason: one directory, two names.\n *\n * `root` and `guestRoot` are the workspace itself under those same two names.\n * They travel because a call that named a directory *inside* the workspace has\n * to keep it: a check that runs in `tests/` and is pointed at a checkpoint must\n * run in the checkpoint's `tests/`, not at its top. Silently dropping that\n * subpath is how a baseline comes back failing for a reason nobody chose, which\n * is the one failure a checkpoint exists to make impossible.\n *\n * @category models\n * @since 1.0.0\n */\nexport interface Materialized {\n readonly id: string\n readonly host: string\n readonly guest: string\n /** The workspace this checkpoint is a tree of, as this machine names it. */\n readonly root: string\n /** The workspace as a container names it; the same as {@link Materialized.root} when none does. */\n readonly guestRoot: string\n}\n\n/**\n * The two host operations a checkpoint needs.\n *\n * @category services\n * @since 1.0.0\n */\nexport interface Checkpoints {\n /** Records the working tree as it stands, under `id`. */\n readonly capture: (id: string) => Effect.Effect<Snapshot, StdError.StdError>\n /**\n * Hands `id` back as a directory for the length of the effect it wraps.\n *\n * Scoped rather than returned, because the directory has to be removed\n * however the call ends \u2014 a benchmark run killed at its wall-clock budget\n * would otherwise leave a second checkout of the whole repository in a tree\n * whose diff is the run's answer.\n */\n readonly materialize: <A, E, R>(\n id: string,\n use: (materialized: Materialized) => Effect.Effect<A, E, R>\n ) => Effect.Effect<A, E | StdError.StdError, R>\n}\n\n/**\n * The {@link Checkpoints} service tag.\n *\n * @category services\n * @since 1.0.0\n */\nexport const Checkpoints: Context.Service<Checkpoints, Checkpoints> = Context.Service(\"@smthrs/std/Checkpoints\")\n\n/**\n * Builds a store from its two operations.\n *\n * @category constructors\n * @since 1.0.0\n */\nexport const make = (service: Checkpoints): Checkpoints => Checkpoints.of(service)\n\n/**\n * The refusal a host with nowhere to pin a tree answers with.\n *\n * @category errors\n * @since 1.0.0\n */\nexport const unavailable = (id: string): StdError.StdError =>\n new StdError.StdError({\n code: \"provider_unavailable\",\n message: `This host pins no trees, so it holds no checkpoint \"${id}\". Take the reading on the live tree instead.`\n })\n\n/**\n * Builds a store for a host that pins nothing.\n *\n * @category constructors\n * @since 1.0.0\n */\nexport const makeNoop = (): Checkpoints =>\n make({\n capture: (id) => Effect.fail(unavailable(id)),\n materialize: (id) => Effect.fail(unavailable(id))\n })\n\n/**\n * Provides {@link makeNoop}.\n *\n * @category layers\n * @since 1.0.0\n */\nexport const layerNoop: Layer.Layer<Checkpoints> = Layer.succeed(Checkpoints, makeNoop())\n\n/**\n * What {@link layerGit} needs to know about the repository it pins.\n *\n * @category models\n * @since 1.0.0\n */\nexport interface GitOptions {\n /** The host path of the git repository whose trees are pinned. */\n readonly root: string\n /**\n * Where a container sees {@link GitOptions.root}, when one does.\n *\n * Absent means there is no container, and the guest path of a materialized\n * checkpoint is then its host path.\n */\n readonly cwd?: string | undefined\n /**\n * The ref naming the tree the run opened on, for {@link baseId}.\n *\n * Absent takes `TestRunner.captureBase` and then `HEAD`, which is the\n * precedence `TestRun` already uses.\n */\n readonly baseRef?: string | undefined\n}\n\nconst failed = (message: string, code: StdError.Code = \"command_failed\"): StdError.StdError =>\n new StdError.StdError({ code, message })\n\n/**\n * A checkpoint id, restricted to what can safely become a git-config key and a\n * directory name. No dots: a dot would split the config key into another\n * subsection.\n */\nconst namable = /^[A-Za-z0-9][A-Za-z0-9-]*$/\n\n/**\n * Builds the git-backed store.\n *\n * Every git command that records or checks out a tree runs against a shadow\n * `GIT_DIR` the harness owns, never the workspace's `.git`, which the agent\n * can write; see `internal/GitWorktree.ts`. The capture reads a copy of the\n * workspace index, so the agent's own index is untouched: its `git diff` is the\n * run's evidence and must not move because the harness recorded something.\n *\n * @category constructors\n * @since 1.0.0\n */\nexport const makeGit = (\n options: GitOptions\n): Effect.Effect<Checkpoints, never, ChildProcessSpawner> =>\n Effect.gen(function*() {\n const spawner = yield* ChildProcessSpawner\n return gitStore(options, spawner)\n })\n\n/**\n * The store itself, with the spawner it runs git through already resolved.\n *\n * Resolving it here rather than threading it through {@link Checkpoints} keeps\n * the service's own requirement empty, which is what lets a browser host bind\n * {@link layerNoop} against the identical interface \u2014 the same shape\n * `TestRunner` and `Container` take.\n */\nconst gitStore = (options: GitOptions, spawner: ChildProcessSpawner[\"Service\"]): Checkpoints => {\n const spawn = <A, E>(effect: Effect.Effect<A, E, ChildProcessSpawner>): Effect.Effect<A, E> =>\n Effect.provideService(effect, ChildProcessSpawner, spawner)\n const root = withoutTrailingSlash(options.root)\n const guestRoot = options.cwd === undefined ? root : withoutTrailingSlash(options.cwd)\n const keyOf = (id: string) => `${configSection}.${id}`\n const baseRefs = options.baseRef === undefined ? [TestRunner.captureBase, \"HEAD\"] : [options.baseRef]\n\n /**\n * The commit one id names.\n *\n * `base` is the one id nobody mints, so it is resolved from refs \u2014 the same\n * precedence `TestRun` uses. Every other id was recorded by {@link capture}\n * into this repository's config file, which is where a checkpoint is named\n * *without* becoming history. See {@link configSection}.\n */\n const commitOf = (id: string) =>\n Effect.gen(function*() {\n if (id === baseId) return (yield* GitWorktree.resolveCommit(root, baseRefs)).commit\n const repository = yield* GitWorktree.open(root)\n const found = yield* GitWorktree.config(repository, [\"--get\", keyOf(id)])\n const commit = found.stdout.trim()\n if (found.exitCode !== 0 || commit === \"\") {\n return yield* Effect.fail(\n failed(\n `No checkpoint is stored under ${id} in ${root}. Take the reading on the live tree instead.`,\n \"not_found\"\n )\n )\n }\n return commit\n })\n\n const capture = (id: string) =>\n spawn(Effect.gen(function*() {\n if (!namable.test(id)) {\n return yield* Effect.fail(\n failed(`A checkpoint id must match ${namable.source}; ${id} does not.`, \"invalid_input\")\n )\n }\n const repository = yield* GitWorktree.open(root)\n const commit = yield* GitWorktree.withShadow(repository, (shadow) =>\n Effect.gen(function*() {\n const head = (yield* GitWorktree.resolveCommit(root, [\"HEAD\"])).commit\n yield* shadow.adoptIndex\n const pinned = yield* shadow.git([\"update-ref\", \"--no-deref\", \"HEAD\", head])\n if (pinned.exitCode !== 0) {\n return yield* Effect.fail(failed(`Could not record the working tree: ${pinned.stderr.trim()}`))\n }\n const recorded = yield* shadow.git([\"stash\", \"create\", `flows checkpoint ${id}`], root)\n if (recorded.exitCode !== 0) {\n return yield* Effect.fail(failed(`Could not record the working tree: ${recorded.stderr.trim()}`))\n }\n // Nothing to record means the working tree IS the commit it is on, so\n // that commit is the checkpoint. `stash create` says so by printing\n // nothing, which is not an error and must not be read as one.\n return recorded.stdout.trim() === \"\" ? head : recorded.stdout.trim()\n }))\n const named = yield* GitWorktree.config(repository, [keyOf(id), commit])\n if (named.exitCode !== 0) {\n return yield* Effect.fail(failed(`Could not name the checkpoint: ${named.stderr.trim()}`))\n }\n return new Snapshot({ id, ref: commit })\n }))\n\n const materialize = <A, E, R>(\n id: string,\n use: (materialized: Materialized) => Effect.Effect<A, E, R>\n ): Effect.Effect<A, E | StdError.StdError, R> =>\n Effect.gen(function*() {\n if (!namable.test(id)) {\n return yield* Effect.fail(\n failed(`A checkpoint id must match ${namable.source}; ${id} does not.`, \"invalid_input\")\n )\n }\n const commit = yield* spawn(commitOf(id))\n const context = yield* Effect.context<R>()\n return yield* GitWorktree.withDetachedWorktree(\n root,\n `${scratchDirectory}/${id}`,\n commit,\n (host) =>\n use({ id, host, guest: `${guestRoot}${host.slice(root.length)}`, root, guestRoot }).pipe(\n Effect.provideContext(context)\n )\n ).pipe(Effect.provideService(ChildProcessSpawner, spawner))\n })\n\n return make({ capture, materialize })\n}\n\n/**\n * Provides {@link makeGit}.\n *\n * @category layers\n * @since 1.0.0\n */\nexport const layerGit = (options: GitOptions): Layer.Layer<Checkpoints, never, ChildProcessSpawner> =>\n Layer.effect(Checkpoints)(makeGit(options))\n/**\n * Rewrites one call's input so the call runs against a materialized checkpoint.\n *\n * Re-exported from {@link Relocate}, which owns the table of flows and fields\n * this rewrite is driven by. It is exported here because the harness reaches the\n * whole checkpoint feature through this module.\n *\n * @category conversions\n * @since 1.0.0\n */\nexport { relocate, type Relocation } from \"./Relocate.ts\"\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwEA,iCAAoC;AACpC,oBAA+C;AAC/C,yBAA4B;AAC5B,mBAAqC;AACrC,YAAuB;AACvB,eAA0B;AAC1B,iBAA4B;
|
|
4
|
+
"sourcesContent": ["/**\n * Pinned trees, and the scratch checkouts a call runs against.\n *\n * A checkpoint is a **value**: minting one changes nothing about the workspace,\n * and running a call against one leaves the live tree exactly as it stands.\n * That is the whole of what it buys. Before it existed, the only way a run\n * could answer \"did this command fail before my change\" was to undo the change\n * \u2014 and the journals price that exactly. On `sympy__sympy-13878` the r95repl\n * lane applied one byte-identical 4,789-character patch **five times**, four of\n * those applications preceded by `git checkout -- sympy/stats/crv_types.py`,\n * because a clean fails-before proof required reverting the very work it was\n * meant to prove.\n *\n * This module is the host half of `@smthrs/harness`'s `ctx.checkpoint()` and\n * `ctx.call(flow, input, { at })`. The harness owns the surface, the identity,\n * the bound and the refusals; nothing in it knows what a tree is. This owns the\n * two host operations that need one:\n *\n * - {@link Checkpoints.capture} records the working tree under an id, and\n * - {@link Checkpoints.materialize} gives that tree back as a directory for the\n * length of one call.\n *\n * ## The git binding, and the container constraint it is shaped by\n *\n * {@link layerGit} records a checkpoint with `git stash create`, then names the\n * commit it prints in this repository's own git config rather than with a ref \u2014\n * see {@link configSection} for why a ref would hand the agent its own edit back\n * as history. `stash create` is the one git command\n * that records the working tree and changes nothing else: it does not move the\n * worktree and does not touch the stash ref. It runs against a shadow\n * `GIT_DIR` over a copy of the workspace index (see `internal/GitWorktree.ts`),\n * so it never writes the repository's index and never runs a program the\n * workspace's `.git` names. That matters more than it sounds. The agent runs `git` in this same\n * workspace and its own `git diff` is the run's evidence, so a capture that\n * staged into the real index would be the harness editing the evidence while\n * recording it. A tree with nothing to record prints nothing, and the\n * checkpoint is then `HEAD` \u2014 which is exactly what the tree is.\n *\n * Untracked files are not in the recorded tree, which matches how the rig\n * captures a patch: `capture-patch.sh` drops paths that did not exist when the\n * agent started, so a checkpoint holds what a patch would hold.\n *\n * The materialization is a detached checkout at `<root>/.flows-checkpoints/<id>-<lease>`\n * \u2014 **inside** the workspace, which looks wrong and is the only thing that\n * works. A benchmark check runs through `docker exec` inside `/testbed`, and\n * `/testbed` is a bind mount of the live workspace. A scratch checkout anywhere\n * else on the host is simply not visible to the container, so the call would run\n * against a path that does not exist. Placed under the workspace it is visible\n * at the same subpath under the mount, needs no second mount, and needs no\n * change to how the rig starts its container. `TestRun`'s `against: \"base\"`\n * reached the same conclusion first and this reuses its shape;\n * `evals/swebench/run-instance.sh` excludes the directory from patch capture for\n * the same reason it excludes `.flows-test-base`.\n *\n * The checkout is added and removed around **one call**, which is stricter than\n * the frame lifetime the design asks for and much simpler: there is no cache to\n * invalidate, no id to leak, and a checkpoint used by three calls pays three\n * checkouts rather than risking one stale directory. A run that reuses a\n * checkpoint across frames simply re-materializes it, because the ref is what\n * persists.\n *\n * ## What can be pointed at a checkpoint, and what cannot\n *\n * {@link relocate} is the closed answer, and `Relocate.ts` owns it. That half\n * knows the input shapes of `bash`, `read`, `ls`, `grep` and `glob`; this half\n * knows git. They change for different reasons, so they are different modules,\n * and {@link relocate} is re-exported here because the harness reaches the whole\n * feature through this one.\n *\n * @since 1.0.0\n */\n\nimport { ChildProcessSpawner } from \"@smthrs/kernel/ChildProcessSpawner\"\nimport { Context, Effect, Layer, Schema } from \"effect\"\nimport * as Container from \"./Container.ts\"\nimport * as Exec from \"./internal/Exec.ts\"\nimport { GitWorktree } from \"./internal/GitWorktree.ts\"\nimport { withoutTrailingSlash } from \"./internal/Paths.ts\"\nimport * as Prune from \"./internal/Prune.ts\"\nimport * as StdError from \"./StdError.ts\"\nimport * as TestRunner from \"./TestRunner.ts\"\n\n/**\n * The id naming the tree a run opened on.\n *\n * It is not minted: the host either recorded one before the agent started or it\n * did not. {@link layerGit} resolves it to {@link TestRunner.captureBase} and\n * then to `HEAD`, which is the same precedence `TestRun` uses for the same\n * question \u2014 so a workspace that has one answer for a baseline has one answer\n * for both.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const baseId = \"base\"\n\n/**\n * The directory checkpoints are materialized into, relative to the repository\n * root and therefore also relative to a container's view of it.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const scratchDirectory = Prune.checkpointScratch\n\n/**\n * The git-config section {@link layerGit} records minted checkpoints under.\n *\n * Config and **not a ref**, and that is the whole of the decision. A ref is\n * history: `git log --all` lists it, `git show` prints it, and `git log --all\n * -S` searches it \u2014 so a checkpoint named by a ref would hand an agent a commit\n * containing its own edit and let it read that back as if it were upstream\n * work. That exact class cost the measured program real money before the rig\n * moved jj's store out of the workspace (`evals/swebench/run-instance.sh`:\n * django-13346 applied two of its own snapshots as a fake fix), and a ref here\n * would reintroduce it one wave later.\n *\n * The commit object itself is unreferenced. It stays alive for the life of the\n * run \u2014 nothing in a run prunes \u2014 and a checkout by id works perfectly well,\n * which is measured in `CheckpointsFixture.test.ts`. So the tree is reachable\n * by id and no command that walks refs can reach it:\n * `git log`, `git log --all`, `git log -S` and `git show <ref>` all answer\n * exactly as they did before the capture.\n *\n * One command still sees it, measured rather than argued in\n * `CheckpointsFixture.test.ts`: `git fsck` reports it as a dangling commit,\n * because that is what an unreferenced commit is. A checkout is a standalone\n * repository, not a worktree of this one, so `git log --all` does not list it\n * even while a call runs in it. It is not reachable by a name that looks like project history, and the cell\n * contract's environment section says outright that a dangling commit is this\n * harness holding the agent's own tree \u2014 which is the half of the django-13346\n * lesson that a store cannot enforce.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const configSection = \"flows-checkpoint\"\n\n/**\n * One tree this run has pinned.\n *\n * `ref` is the store's own name for it. Nothing above this module interprets\n * it; it travels so the journal says which tree a checkpointed call read.\n *\n * @category models\n * @since 1.0.0\n */\nexport class Snapshot extends Schema.Class<Snapshot>(\"@smthrs/std/Checkpoints/Snapshot\")({\n id: Schema.String,\n ref: Schema.String\n}) {}\n\n/**\n * A tree handed back as a directory, for the length of one call.\n *\n * `host` is where the process running on this machine finds it. `guest` is\n * where a container finds the same directory, which is the same path when no\n * container is in play. They differ exactly as `TestRunner.Runner`'s `cwd` and\n * `root` differ, and for the same reason: one directory, two names.\n *\n * `root` and `guestRoot` are the workspace itself under those same two names.\n * They travel because a call that named a directory *inside* the workspace has\n * to keep it: a check that runs in `tests/` and is pointed at a checkpoint must\n * run in the checkpoint's `tests/`, not at its top. Silently dropping that\n * subpath is how a baseline comes back failing for a reason nobody chose, which\n * is the one failure a checkpoint exists to make impossible.\n *\n * @category models\n * @since 1.0.0\n */\nexport interface Materialized {\n readonly id: string\n readonly host: string\n readonly guest: string\n /** The workspace this checkpoint is a tree of, as this machine names it. */\n readonly root: string\n /** The workspace as a container names it; the same as {@link Materialized.root} when none does. */\n readonly guestRoot: string\n}\n\n/**\n * The two host operations a checkpoint needs.\n *\n * @category services\n * @since 1.0.0\n */\nexport interface Checkpoints {\n /** Records the working tree as it stands, under `id`. */\n readonly capture: (id: string) => Effect.Effect<Snapshot, StdError.StdError>\n /**\n * Hands `id` back as a directory for the length of the effect it wraps.\n *\n * Scoped rather than returned, because the directory has to be removed\n * however the call ends \u2014 a benchmark run killed at its wall-clock budget\n * would otherwise leave a second checkout of the whole repository in a tree\n * whose diff is the run's answer.\n */\n readonly materialize: <A, E, R>(\n id: string,\n use: (materialized: Materialized) => Effect.Effect<A, E, R>\n ) => Effect.Effect<A, E | StdError.StdError, R>\n}\n\n/**\n * The {@link Checkpoints} service tag.\n *\n * @category services\n * @since 1.0.0\n */\nexport const Checkpoints: Context.Service<Checkpoints, Checkpoints> = Context.Service(\"@smthrs/std/Checkpoints\")\n\n/**\n * Builds a store from its two operations.\n *\n * @category constructors\n * @since 1.0.0\n */\nexport const make = (service: Checkpoints): Checkpoints => Checkpoints.of(service)\n\n/**\n * The refusal a host with nowhere to pin a tree answers with.\n *\n * @category errors\n * @since 1.0.0\n */\nexport const unavailable = (id: string): StdError.StdError =>\n new StdError.StdError({\n code: \"provider_unavailable\",\n message: `This host pins no trees, so it holds no checkpoint \"${id}\". Take the reading on the live tree instead.`\n })\n\n/**\n * Builds a store for a host that pins nothing.\n *\n * @category constructors\n * @since 1.0.0\n */\nexport const makeNoop = (): Checkpoints =>\n make({\n capture: (id) => Effect.fail(unavailable(id)),\n materialize: (id) => Effect.fail(unavailable(id))\n })\n\n/**\n * Provides {@link makeNoop}.\n *\n * @category layers\n * @since 1.0.0\n */\nexport const layerNoop: Layer.Layer<Checkpoints> = Layer.succeed(Checkpoints, makeNoop())\n\n/**\n * What {@link layerGit} needs to know about the repository it pins.\n *\n * @category models\n * @since 1.0.0\n */\nexport interface GitOptions {\n /** The host path of the git repository whose trees are pinned. */\n readonly root: string\n /**\n * Where a container sees {@link GitOptions.root}, when one does.\n *\n * Absent means there is no container, and the guest path of a materialized\n * checkpoint is then its host path.\n */\n readonly cwd?: string | undefined\n /**\n * The ref naming the tree the run opened on, for {@link baseId}.\n *\n * Absent takes `TestRunner.captureBase` and then `HEAD`, which is the\n * precedence `TestRun` already uses.\n */\n readonly baseRef?: string | undefined\n}\n\nconst failed = (message: string, code: StdError.Code = \"command_failed\"): StdError.StdError =>\n new StdError.StdError({ code, message })\n\n/**\n * A checkpoint id, restricted to what can safely become a git-config key and a\n * directory name. No dots: a dot would split the config key into another\n * subsection.\n */\nconst namable = /^[A-Za-z0-9][A-Za-z0-9-]*$/\n\n/**\n * Builds the git-backed store.\n *\n * Every git command that records or checks out a tree runs against a shadow\n * `GIT_DIR` the harness owns, never the workspace's `.git`, which the agent\n * can write; see `internal/GitWorktree.ts`. The capture reads a copy of the\n * workspace index, so the agent's own index is untouched: its `git diff` is the\n * run's evidence and must not move because the harness recorded something.\n *\n * @category constructors\n * @since 1.0.0\n */\nexport const makeGit = (\n options: GitOptions\n): Effect.Effect<Checkpoints, never, ChildProcessSpawner> =>\n Effect.gen(function*() {\n const spawner = yield* ChildProcessSpawner\n return gitStore(options, spawner)\n })\n\n/**\n * The store itself, with the spawner it runs git through already resolved.\n *\n * Resolving it here rather than threading it through {@link Checkpoints} keeps\n * the service's own requirement empty, which is what lets a browser host bind\n * {@link layerNoop} against the identical interface \u2014 the same shape\n * `TestRunner` and `Container` take.\n */\nconst gitStore = (options: GitOptions, spawner: ChildProcessSpawner[\"Service\"]): Checkpoints => {\n const spawn = <A, E>(effect: Effect.Effect<A, E, ChildProcessSpawner>): Effect.Effect<A, E> =>\n Effect.provideService(effect, ChildProcessSpawner, spawner)\n const root = withoutTrailingSlash(options.root)\n const guestRoot = options.cwd === undefined ? root : withoutTrailingSlash(options.cwd)\n const keyOf = (id: string) => `${configSection}.${id}`\n const baseRefs = options.baseRef === undefined ? [TestRunner.captureBase, \"HEAD\"] : [options.baseRef]\n\n /**\n * The commit one id names.\n *\n * `base` is the one id nobody mints, so it is resolved from refs \u2014 the same\n * precedence `TestRun` uses. Every other id was recorded by {@link capture}\n * into this repository's config file, which is where a checkpoint is named\n * *without* becoming history. See {@link configSection}.\n */\n const commitOf = (id: string) =>\n Effect.gen(function*() {\n if (id === baseId) return (yield* GitWorktree.resolveCommit(root, baseRefs)).commit\n const repository = yield* GitWorktree.open(root)\n const found = yield* GitWorktree.config(repository, [\"--get\", keyOf(id)])\n const commit = found.stdout.trim()\n if (found.exitCode !== 0 || commit === \"\") {\n return yield* Effect.fail(\n failed(\n `No checkpoint is stored under ${id} in ${root}. Take the reading on the live tree instead.`,\n \"not_found\"\n )\n )\n }\n return commit\n })\n\n const capture = (id: string) =>\n spawn(Effect.gen(function*() {\n if (!namable.test(id)) {\n return yield* Effect.fail(\n failed(`A checkpoint id must match ${namable.source}; ${id} does not.`, \"invalid_input\")\n )\n }\n const repository = yield* GitWorktree.open(root)\n const commit = yield* GitWorktree.withShadow(repository, (shadow) =>\n Effect.gen(function*() {\n const head = (yield* GitWorktree.resolveCommit(root, [\"HEAD\"])).commit\n yield* shadow.adoptIndex\n const pinned = yield* shadow.git([\"update-ref\", \"--no-deref\", \"HEAD\", head])\n if (pinned.exitCode !== 0) {\n return yield* Effect.fail(failed(`Could not record the working tree: ${pinned.stderr.trim()}`))\n }\n const recorded = yield* shadow.git([\"stash\", \"create\", `flows checkpoint ${id}`], root)\n if (recorded.exitCode !== 0) {\n return yield* Effect.fail(failed(`Could not record the working tree: ${recorded.stderr.trim()}`))\n }\n // Nothing to record means the working tree IS the commit it is on, so\n // that commit is the checkpoint. `stash create` says so by printing\n // nothing, which is not an error and must not be read as one.\n return recorded.stdout.trim() === \"\" ? head : recorded.stdout.trim()\n }))\n const named = yield* GitWorktree.config(repository, [keyOf(id), commit])\n if (named.exitCode !== 0) {\n return yield* Effect.fail(failed(`Could not name the checkpoint: ${named.stderr.trim()}`))\n }\n return new Snapshot({ id, ref: commit })\n }))\n\n const materialize = <A, E, R>(\n id: string,\n use: (materialized: Materialized) => Effect.Effect<A, E, R>\n ): Effect.Effect<A, E | StdError.StdError, R> =>\n Effect.gen(function*() {\n if (!namable.test(id)) {\n return yield* Effect.fail(\n failed(`A checkpoint id must match ${namable.source}; ${id} does not.`, \"invalid_input\")\n )\n }\n const commit = yield* spawn(commitOf(id))\n const context = yield* Effect.context<R>()\n return yield* GitWorktree.withDetachedWorktree(\n root,\n `${scratchDirectory}/${id}`,\n commit,\n (host) =>\n use({ id, host, guest: `${guestRoot}${host.slice(root.length)}`, root, guestRoot }).pipe(\n Effect.provideContext(context)\n )\n ).pipe(Effect.provideService(ChildProcessSpawner, spawner))\n })\n\n return make({ capture, materialize })\n}\n\n/**\n * Provides {@link makeGit}.\n *\n * @category layers\n * @since 1.0.0\n */\nexport const layerGit = (options: GitOptions): Layer.Layer<Checkpoints, never, ChildProcessSpawner> =>\n Layer.effect(Checkpoints)(makeGit(options))\n\n/**\n * What {@link makeContainer} needs to know about the container it pins in.\n *\n * @category models\n * @since 1.0.0-rc.8\n */\nexport interface ContainerOptions {\n /** The container `bash` runs in. */\n readonly container: string\n /** A directory of the repository inside it; absent is the container's own working directory. */\n readonly cwd?: string | undefined\n}\n\n/**\n * `$1` is the id. Records the working tree with `stash create` over a copy of\n * the index, names the commit in config as {@link makeGit} does, and prints it.\n */\nconst pinScript = [\n \"set -e\",\n \"cd \\\"$(git rev-parse --show-toplevel)\\\"\",\n \"copy=$(mktemp -d)\",\n \"trap 'rm -rf -- \\\"$copy\\\"' EXIT\",\n \"index=$(git rev-parse --git-path index)\",\n \"if [ -e \\\"$index\\\" ]; then cp -p -- \\\"$index\\\" \\\"$copy/index\\\"; fi\",\n \"commit=$(GIT_INDEX_FILE=\\\"$copy/index\\\" git -c user.name=flows -c user.email=flows@localhost stash create \\\"flows checkpoint $1\\\")\",\n \"[ -n \\\"$commit\\\" ] || commit=$(git rev-parse --verify 'HEAD^{commit}')\",\n `git config \"${configSection}.$1\" \"$commit\"`,\n \"printf '%s\\\\n' \\\"$commit\\\"\"\n].join(\"\\n\")\n\n/**\n * `$1` is the id, `$2` the lease. Checks the tree out at\n * `<top>/.flows-checkpoints/<id>-<lease>` in a repository of its own whose\n * objects are the workspace's, and prints the top and the checkout.\n */\nconst checkoutScript = [\n \"set -e\",\n \"top=$(git rev-parse --show-toplevel)\",\n \"cd \\\"$top\\\"\",\n \"common=$(cd \\\"$(git rev-parse --git-common-dir)\\\" && pwd -P)\",\n `if [ \"$1\" = ${baseId} ]; then`,\n ` commit=$(git rev-parse --verify --quiet '${TestRunner.captureBase}^{commit}' || git rev-parse --verify --quiet 'HEAD^{commit}') || { echo \"No commit resolves from ${TestRunner.captureBase} or HEAD in $top.\" >&2; exit 1; }`,\n \"else\",\n ` commit=$(git config --get \"${configSection}.$1\") || { echo \"No checkpoint is stored under $1 in $top. Take the reading on the live tree instead.\" >&2; exit 1; }`,\n \"fi\",\n `dir=\"$top/${scratchDirectory}/$1-$2\"`,\n \"trap 'rm -rf -- \\\"$dir\\\"' EXIT\",\n `mkdir -p -- \"$top/${scratchDirectory}\"`,\n \"git init --quiet \\\"$dir\\\"\",\n \"printf '%s/objects\\\\n' \\\"$common\\\" > \\\"$dir/.git/objects/info/alternates\\\"\",\n \"git -C \\\"$dir\\\" checkout --quiet --detach \\\"$commit\\\"\",\n \"trap - EXIT\",\n \"printf '%s\\\\n%s\\\\n' \\\"$top\\\" \\\"$dir\\\"\"\n].join(\"\\n\")\n\n/**\n * Builds the store for a host whose `bash` runs in one container.\n *\n * The repository is the container's, so every git command runs there, through\n * the same {@link Container.Container} transport `bash` takes. The host store\n * looked in the CLI's own directory instead and refused every `at` in 112 of\n * 123 trials of the 2026-10-14 SWE-bench Pro run. This keeps that store's\n * shape: a checkpoint is a `stash create` commit named in config, and a tree\n * is checked out under the repository for one call, so a relocated `bash`\n * call keeps its subdirectory and still finds what the workspace installs\n * beside it. That git may run what the repository configures, but only inside\n * the container the agent already commands.\n *\n * @category constructors\n * @since 1.0.0-rc.8\n */\nexport const makeContainer = (\n options: ContainerOptions\n): Effect.Effect<Checkpoints, never, ChildProcessSpawner | Container.Container> =>\n Effect.gen(function*() {\n const spawner = yield* ChildProcessSpawner\n const transport = yield* Container.Container\n const sh = (script: string, args: ReadonlyArray<string>) =>\n transport.exec({\n container: options.container,\n file: \"sh\",\n args: [\"-c\", script, \"sh\", ...args],\n cwd: options.cwd,\n stdin: false\n }).pipe(\n Effect.flatMap((plan) =>\n Exec.exec(plan.file, { args: plan.args, env: plan.env }).pipe(\n Effect.mapError((error) => Exec.toStdError(plan.file, error))\n )\n ),\n Effect.flatMap((result) =>\n result.exitCode === 0\n ? Effect.succeed(result.stdout.trim().split(\"\\n\"))\n : Effect.fail(failed(`git in container ${options.container} failed: ${result.stderr.trim()}`))\n ),\n Effect.provideService(ChildProcessSpawner, spawner)\n )\n const named = (id: string) =>\n namable.test(id)\n ? Effect.void\n : Effect.fail(failed(`A checkpoint id must match ${namable.source}; ${id} does not.`, \"invalid_input\"))\n return make({\n capture: (id) =>\n named(id).pipe(\n Effect.andThen(sh(pinScript, [id])),\n Effect.map(([commit]) => new Snapshot({ id, ref: commit! }))\n ),\n materialize: (id, use) =>\n named(id).pipe(Effect.andThen(Effect.acquireUseRelease(\n sh(checkoutScript, [id, globalThis.crypto.randomUUID()]),\n ([root, checkout]) => use({ id, host: checkout!, guest: checkout!, root: root!, guestRoot: root! }),\n ([, checkout]) =>\n sh(\"rm -rf -- \\\"$1\\\"\", [checkout!]).pipe(\n Effect.catch((error) =>\n Effect.logWarning(\"could not remove a scratch checkout\").pipe(\n Effect.annotateLogs({ path: checkout, reason: error.message })\n )\n )\n )\n )))\n })\n })\n\n/**\n * Provides {@link makeContainer}.\n *\n * @category layers\n * @since 1.0.0-rc.8\n */\nexport const layerContainer = (\n options: ContainerOptions\n): Layer.Layer<Checkpoints, never, ChildProcessSpawner | Container.Container> =>\n Layer.effect(Checkpoints)(makeContainer(options))\n\n/**\n * Rewrites one call's input so the call runs against a materialized checkpoint.\n *\n * Re-exported from {@link Relocate}, which owns the table of flows and fields\n * this rewrite is driven by. It is exported here because the harness reaches the\n * whole checkpoint feature through this module.\n *\n * @category conversions\n * @since 1.0.0\n */\nexport { relocate, type Relocation } from \"./Relocate.ts\"\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -52,6 +52,8 @@ export interface PathReference {
|
|
|
52
52
|
/**
|
|
53
53
|
* Every path token the shell text names, one segment at a time.
|
|
54
54
|
*
|
|
55
|
+
* @throws {StdError.StdError} `invalid_input` when substitutions or quotes nest
|
|
56
|
+
* deeper than the scan reads.
|
|
55
57
|
* @category pre-check
|
|
56
58
|
* @since 1.0.0
|
|
57
59
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EnvelopePrecheck.d.ts","sourceRoot":"","sources":["../../../src/internal/EnvelopePrecheck.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,KAAK,IAAI,MAAM,qBAAqB,CAAA;AAChD,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAA;AAG1C;;;;;;;;;GASG;AACH,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACrC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CACvC;AAED;;;;;GAKG;AACH,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAErC;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;
|
|
1
|
+
{"version":3,"file":"EnvelopePrecheck.d.ts","sourceRoot":"","sources":["../../../src/internal/EnvelopePrecheck.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,KAAK,IAAI,MAAM,qBAAqB,CAAA;AAChD,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAA;AAG1C;;;;;;;;;GASG;AACH,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACrC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CACvC;AAED;;;;;GAKG;AACH,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAErC;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAkUD;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAAI,MAAM,IAAI,CAAC,IAAI,EAAE,SAAS,MAAM,KAAG,aAAa,CAAC,aAAa,CAClE,CAAA;AAmB9B;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAO,QAAQ,EACf,MAAM,MAAM,EACZ,MAAM,IAAI,CAAC,IAAI,KACd,QAAQ,CAAC,QAAQ,GAAG,SAgDtB,CAAA"}
|
|
@@ -34,63 +34,240 @@ __export(EnvelopePrecheck_exports, {
|
|
|
34
34
|
module.exports = __toCommonJS(EnvelopePrecheck_exports);
|
|
35
35
|
var StdError = __toESM(require("../StdError.js"), 1);
|
|
36
36
|
var import_Paths = require("./Paths.js");
|
|
37
|
-
const commandSeparators = /* @__PURE__ */ new Set(["&&", "||", ";", "|", "&", "\n"]);
|
|
38
|
-
const
|
|
37
|
+
const commandSeparators = /* @__PURE__ */ new Set(["&&", "||", ";", ";;", ";&", ";;&", "|", "|&", "&", "\n", "(", ")"]);
|
|
38
|
+
const writeRedirections = /* @__PURE__ */ new Set([">", ">>", ">|", "&>", "&>>", ">&", "<>"]);
|
|
39
|
+
const readRedirections = /* @__PURE__ */ new Set(["<", "<&"]);
|
|
40
|
+
const textRedirections = /* @__PURE__ */ new Set(["<<", "<<-", "<<<"]);
|
|
41
|
+
const isRedirection = (token) => token.operator && (writeRedirections.has(token.text) || readRedirections.has(token.text) || textRedirections.has(token.text));
|
|
42
|
+
const operators = [
|
|
43
|
+
";;&",
|
|
44
|
+
"&>>",
|
|
45
|
+
"<<<",
|
|
46
|
+
"<<-",
|
|
47
|
+
";;",
|
|
48
|
+
";&",
|
|
49
|
+
"&&",
|
|
50
|
+
"||",
|
|
51
|
+
"|&",
|
|
52
|
+
"&>",
|
|
53
|
+
">>",
|
|
54
|
+
">|",
|
|
55
|
+
">&",
|
|
56
|
+
"<>",
|
|
57
|
+
"<<",
|
|
58
|
+
"<&",
|
|
59
|
+
"\n",
|
|
60
|
+
";",
|
|
61
|
+
"|",
|
|
62
|
+
"&",
|
|
63
|
+
"(",
|
|
64
|
+
")",
|
|
65
|
+
">",
|
|
66
|
+
"<"
|
|
67
|
+
];
|
|
68
|
+
const reservedWords = /* @__PURE__ */ new Set([
|
|
69
|
+
"!",
|
|
70
|
+
"{",
|
|
71
|
+
"}",
|
|
72
|
+
"case",
|
|
73
|
+
"do",
|
|
74
|
+
"done",
|
|
75
|
+
"elif",
|
|
76
|
+
"else",
|
|
77
|
+
"esac",
|
|
78
|
+
"fi",
|
|
79
|
+
"for",
|
|
80
|
+
"function",
|
|
81
|
+
"if",
|
|
82
|
+
"in",
|
|
83
|
+
"select",
|
|
84
|
+
"then",
|
|
85
|
+
"until",
|
|
86
|
+
"while"
|
|
87
|
+
]);
|
|
39
88
|
const writeCommands = /* @__PURE__ */ new Set(["chmod", "chown", "mkdir", "rm", "rmdir", "tee", "touch", "truncate", "unlink"]);
|
|
40
89
|
const destinationCommands = /* @__PURE__ */ new Set(["cp", "install", "mv"]);
|
|
41
90
|
const prefixWrappers = /* @__PURE__ */ new Set(["env", "nice", "time", "sudo", "nohup", "xargs", "command", "exec"]);
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
91
|
+
const maxNesting = 32;
|
|
92
|
+
const tooDeep = () => new StdError.StdError({
|
|
93
|
+
code: "invalid_input",
|
|
94
|
+
message: `Command nests substitutions or quotes deeper than ${maxNesting} levels`
|
|
95
|
+
});
|
|
96
|
+
const substitutionEnd = (text, from, nesting) => {
|
|
97
|
+
if (nesting > maxNesting) throw tooDeep();
|
|
98
|
+
let depth = 1;
|
|
99
|
+
let index = from;
|
|
100
|
+
while (index < text.length) {
|
|
101
|
+
const char = text[index];
|
|
102
|
+
if (char === "\\") {
|
|
103
|
+
index += 2;
|
|
104
|
+
} else if (char === "'") {
|
|
105
|
+
const close = text.indexOf("'", index + 1);
|
|
106
|
+
index = close === -1 ? text.length : close + 1;
|
|
107
|
+
} else if (char === '"') {
|
|
108
|
+
index = doubleQuoteEnd(text, index + 1, nesting + 1) + 1;
|
|
109
|
+
} else if (char === "`") {
|
|
110
|
+
index = backtickEnd(text, index + 1) + 1;
|
|
111
|
+
} else {
|
|
112
|
+
if (char === "(") depth++;
|
|
113
|
+
if (char === ")" && --depth === 0) return index;
|
|
114
|
+
index++;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return text.length;
|
|
118
|
+
};
|
|
119
|
+
const backtickEnd = (text, from) => {
|
|
120
|
+
let index = from;
|
|
121
|
+
while (index < text.length && text[index] !== "`") index += text[index] === "\\" ? 2 : 1;
|
|
122
|
+
return Math.min(index, text.length);
|
|
47
123
|
};
|
|
48
|
-
const
|
|
124
|
+
const doubleQuoteEnd = (text, from, nesting) => {
|
|
125
|
+
if (nesting > maxNesting) throw tooDeep();
|
|
126
|
+
let index = from;
|
|
127
|
+
while (index < text.length && text[index] !== '"') {
|
|
128
|
+
const char = text[index];
|
|
129
|
+
if (char === "\\") index += 2;
|
|
130
|
+
else if (char === "`") index = backtickEnd(text, index + 1) + 1;
|
|
131
|
+
else if (char === "$" && text[index + 1] === "(") index = substitutionEnd(text, index + 2, nesting + 1) + 1;
|
|
132
|
+
else index++;
|
|
133
|
+
}
|
|
134
|
+
return Math.min(index, text.length);
|
|
135
|
+
};
|
|
136
|
+
const descriptorPattern = /\d+(?=[<>])/y;
|
|
137
|
+
const lex = (command, nesting) => {
|
|
138
|
+
const tokens = [];
|
|
139
|
+
const substitutions = [];
|
|
140
|
+
let index = 0;
|
|
141
|
+
const substitution = (from, to) => {
|
|
142
|
+
substitutions.push(command.slice(from, to));
|
|
143
|
+
return "$()";
|
|
144
|
+
};
|
|
145
|
+
while (index < command.length) {
|
|
146
|
+
const char = command[index];
|
|
147
|
+
if (char !== "\n" && /\s/.test(char)) {
|
|
148
|
+
index++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (char === "\\" && command[index + 1] === "\n") {
|
|
152
|
+
index += 2;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (char === "#") {
|
|
156
|
+
while (index < command.length && command[index] !== "\n") index++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
descriptorPattern.lastIndex = index;
|
|
160
|
+
const descriptor = descriptorPattern.exec(command);
|
|
161
|
+
if (descriptor !== null) {
|
|
162
|
+
index += descriptor[0].length;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const operator = operators.find((candidate) => command.startsWith(candidate, index));
|
|
166
|
+
if (operator !== void 0) {
|
|
167
|
+
tokens.push({ operator: true, text: operator });
|
|
168
|
+
index += operator.length;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
let text = "";
|
|
172
|
+
while (index < command.length) {
|
|
173
|
+
const next = command[index];
|
|
174
|
+
if (/[\s<>;|&()]/.test(next)) break;
|
|
175
|
+
if (next === "\\") {
|
|
176
|
+
const escaped = command[index + 1] ?? "";
|
|
177
|
+
if (escaped !== "\n") text += /[\s<>;|&()'"`$\\#]/.test(escaped) ? escaped : `\\${escaped}`;
|
|
178
|
+
index += 2;
|
|
179
|
+
} else if (next === "'") {
|
|
180
|
+
const close = command.indexOf("'", index + 1);
|
|
181
|
+
const end = close === -1 ? command.length : close;
|
|
182
|
+
text += command.slice(index + 1, end);
|
|
183
|
+
index = end + 1;
|
|
184
|
+
} else if (next === '"') {
|
|
185
|
+
const end = doubleQuoteEnd(command, index + 1, nesting + 1);
|
|
186
|
+
let inner = index + 1;
|
|
187
|
+
while (inner < end) {
|
|
188
|
+
const quoted = command[inner];
|
|
189
|
+
if (quoted === "\\" && /[$`"\\\n]/.test(command[inner + 1] ?? "")) {
|
|
190
|
+
if (command[inner + 1] !== "\n") text += command[inner + 1];
|
|
191
|
+
inner += 2;
|
|
192
|
+
} else if (quoted === "`") {
|
|
193
|
+
const close = backtickEnd(command, inner + 1);
|
|
194
|
+
text += substitution(inner + 1, close);
|
|
195
|
+
inner = close + 1;
|
|
196
|
+
} else if (quoted === "$" && command[inner + 1] === "(") {
|
|
197
|
+
const close = substitutionEnd(command, inner + 2, nesting + 2);
|
|
198
|
+
text += substitution(inner + 2, close);
|
|
199
|
+
inner = close + 1;
|
|
200
|
+
} else {
|
|
201
|
+
text += quoted;
|
|
202
|
+
inner++;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
index = end + 1;
|
|
206
|
+
} else if (next === "`") {
|
|
207
|
+
const close = backtickEnd(command, index + 1);
|
|
208
|
+
text += substitution(index + 1, close);
|
|
209
|
+
index = close + 1;
|
|
210
|
+
} else if (next === "$" && command[index + 1] === "(") {
|
|
211
|
+
const close = substitutionEnd(command, index + 2, nesting + 1);
|
|
212
|
+
text += substitution(index + 2, close);
|
|
213
|
+
index = close + 1;
|
|
214
|
+
} else {
|
|
215
|
+
text += next;
|
|
216
|
+
index++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
tokens.push({ operator: false, text });
|
|
220
|
+
}
|
|
221
|
+
return { tokens, substitutions };
|
|
222
|
+
};
|
|
223
|
+
const pathValue = (token) => token.replace(/,+$/, "");
|
|
49
224
|
const isPathToken = (path, token) => token === "." || token === ".." || token.startsWith("./") || token.startsWith("../") || path.isAbsolute(token) || /^[A-Za-z]:[\\/]/.test(token) || token.includes("/");
|
|
50
225
|
const segmentReferences = (path, tokens) => {
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
226
|
+
const isTarget = (index) => index > 0 && isRedirection(tokens[index - 1]);
|
|
227
|
+
const isWord = (index) => !tokens[index].operator && !isTarget(index);
|
|
228
|
+
const name = (index) => path.basename(pathValue(tokens[index].text));
|
|
229
|
+
const hasWrapper = tokens.some((_, index) => isWord(index) && prefixWrappers.has(name(index)));
|
|
230
|
+
let commandIndex = tokens.findIndex(
|
|
231
|
+
(token, index) => isWord(index) && !token.text.includes("=") && !prefixWrappers.has(name(index)) && !reservedWords.has(token.text) && !token.text.startsWith("-") && token.text !== ""
|
|
56
232
|
);
|
|
57
233
|
if (hasWrapper) {
|
|
58
|
-
const mutatingIndex =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
});
|
|
234
|
+
const mutatingIndex = tokens.findIndex(
|
|
235
|
+
(_, index) => isWord(index) && (writeCommands.has(name(index)) || destinationCommands.has(name(index)))
|
|
236
|
+
);
|
|
62
237
|
if (mutatingIndex !== -1) commandIndex = mutatingIndex;
|
|
63
238
|
}
|
|
64
|
-
|
|
65
|
-
const command = path.basename(pathValue(active[commandIndex]));
|
|
239
|
+
const command = commandIndex === -1 ? "" : name(commandIndex);
|
|
66
240
|
const candidates = [];
|
|
67
|
-
for (let index = 0; index <
|
|
68
|
-
const token =
|
|
69
|
-
if (index === commandIndex ||
|
|
70
|
-
|
|
241
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
242
|
+
const token = tokens[index];
|
|
243
|
+
if (index === commandIndex || token.operator || !isTarget(index) && token.text.startsWith("-")) continue;
|
|
244
|
+
if (isTarget(index) && textRedirections.has(tokens[index - 1].text)) continue;
|
|
245
|
+
const value = pathValue(token.text);
|
|
71
246
|
if (value !== "" && isPathToken(path, value)) candidates.push({ index, value });
|
|
72
247
|
}
|
|
73
|
-
const destinationIndex = destinationCommands.has(command) ? candidates
|
|
248
|
+
const destinationIndex = destinationCommands.has(command) ? candidates.filter(({ index }) => !isTarget(index)).at(-1)?.index : void 0;
|
|
74
249
|
return candidates.map(({ index, value }) => {
|
|
75
|
-
const
|
|
76
|
-
const access = previous === ">" || previous === ">>" || writeCommands.has(command) || destinationIndex === index ? "write" : "read";
|
|
250
|
+
const access = isTarget(index) ? writeRedirections.has(tokens[index - 1].text) ? "write" : "read" : writeCommands.has(command) || destinationIndex === index ? "write" : "read";
|
|
77
251
|
return { access, value };
|
|
78
252
|
});
|
|
79
253
|
};
|
|
80
|
-
const
|
|
81
|
-
const
|
|
254
|
+
const references = (path, command, nesting) => {
|
|
255
|
+
const { substitutions, tokens } = lex(command, nesting);
|
|
256
|
+
const found = [];
|
|
82
257
|
let segment = [];
|
|
83
|
-
for (const token of
|
|
84
|
-
if (commandSeparators.has(token)) {
|
|
85
|
-
|
|
258
|
+
for (const token of tokens) {
|
|
259
|
+
if (token.operator && commandSeparators.has(token.text)) {
|
|
260
|
+
found.push(...segmentReferences(path, segment));
|
|
86
261
|
segment = [];
|
|
87
262
|
} else {
|
|
88
263
|
segment.push(token);
|
|
89
264
|
}
|
|
90
265
|
}
|
|
91
|
-
|
|
92
|
-
|
|
266
|
+
found.push(...segmentReferences(path, segment));
|
|
267
|
+
for (const body of substitutions) found.push(...references(path, body, nesting + 1));
|
|
268
|
+
return found;
|
|
93
269
|
};
|
|
270
|
+
const commandReferences = (path, command) => references(path, command, 0);
|
|
94
271
|
const resolvePath = (path, cwd, value) => path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value);
|
|
95
272
|
const isDeclared = (path, cwd, declared, source) => (0, import_Paths.withinEnvelope)(
|
|
96
273
|
declared.map((entry) => resolvePath(path, cwd, entry)),
|
|
@@ -106,7 +283,13 @@ const outsideEnvelope = (input, text, path) => {
|
|
|
106
283
|
path: cwd
|
|
107
284
|
});
|
|
108
285
|
}
|
|
109
|
-
|
|
286
|
+
let named;
|
|
287
|
+
try {
|
|
288
|
+
named = commandReferences(path, text);
|
|
289
|
+
} catch {
|
|
290
|
+
return tooDeep();
|
|
291
|
+
}
|
|
292
|
+
for (const reference of named) {
|
|
110
293
|
const resolved = resolvePath(path, cwd, reference.value);
|
|
111
294
|
if (resolved.startsWith("/dev/")) continue;
|
|
112
295
|
const declared = reference.access === "write" ? input.writes : input.reads;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/internal/EnvelopePrecheck.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * The hermetic pre-check: a lexical scan of shell text for the paths it names,\n * and the refusal when one of them falls outside the declared envelope.\n *\n * The scan is fail-closed over explicit path tokens and nothing more. It\n * cannot prove confinement \u2014 a shell can name a path no lexer sees, through a\n * variable, a substitution, or a program that opens a file of its own \u2014 so a\n * kernel or host sandbox must eventually enforce and report the complete read\n * and write sets.\n *\n * It lives beside the flow rather than inside it because it is its own\n * concept, with its own vocabulary (Access, PathReference) and its own\n * security history: three independent bypasses, each sufficient on its own to\n * defeat it, are closed here.\n *\n * @since 1.0.0\n */\n\nimport type * as Path from \"@smthrs/kernel/Path\"\nimport * as StdError from \"../StdError.ts\"\nimport { withinEnvelope } from \"./Paths.ts\"\n\n/**\n * The filesystem envelope one invocation declared.\n *\n * Structural rather than the hermetic `Bash.Input` itself: the scan needs the\n * three fields and none of the rest, and depending on the flow module would\n * make the flow and its pre-check import each other.\n *\n * @category models\n * @since 1.0.0\n */\nexport interface Envelope {\n readonly cwd?: string | undefined\n readonly reads: ReadonlyArray<string>\n readonly writes: ReadonlyArray<string>\n}\n\n/**\n * How a command touches a path it names.\n *\n * @category models\n * @since 1.0.0\n */\nexport type Access = \"read\" | \"write\"\n\n/**\n * One path a command names, and how it touches it.\n *\n * @category models\n * @since 1.0.0\n */\nexport interface PathReference {\n readonly access: Access\n readonly value: string\n}\n\nconst commandSeparators = new Set([\"&&\", \"||\", \";\", \"|\", \"&\", \"\\n\"])\nconst redirections = new Set([\">\", \">>\", \"<\", \"<<\"])\nconst writeCommands = new Set([\"chmod\", \"chown\", \"mkdir\", \"rm\", \"rmdir\", \"tee\", \"touch\", \"truncate\", \"unlink\"])\nconst destinationCommands = new Set([\"cp\", \"install\", \"mv\"])\nconst prefixWrappers = new Set([\"env\", \"nice\", \"time\", \"sudo\", \"nohup\", \"xargs\", \"command\", \"exec\"])\n\n// Shell spaces may disappear, but a physical line ends one command. Retaining\n// LF prevents a command on line one from classifying every later line's paths.\n//\n// A word is one or more runs of bare characters and quoted sections, not a\n// bare run alone: `FOO=\"a b\" rm /work/target` used to tokenize as `FOO=\"a`,\n// `b\"`, `rm`, `/work/target`, and since only the first token carried an `=`\n// the command was read as `b\"` rather than `rm` \u2014 so the delete was\n// classified as a read and a hermetic call with `writes: []` spawned. The\n// quoted alternatives come first inside the group so a quoted span is taken\n// whole; the bare class excludes quotes so the branches cannot overlap.\nconst tokenize = (command: string): ReadonlyArray<string> =>\n command.match(/\\n|>>|<<|&&|\\|\\||[<>;|&]|(?:\"(?:\\\\.|[^\"\\\\])*\"|'[^']*'|[^\\s<>;|&\"']+)+/g) ?? []\n\nconst unquote = (token: string): string => {\n const first = token[0]\n const last = token[token.length - 1]\n return token.length >= 2 && (first === \"'\" || first === \"\\\"\") && last === first\n ? token.slice(1, -1)\n : token\n}\n\nconst pathValue = (token: string): string =>\n unquote(token)\n .replace(/^\\$\\(+/, \"\")\n .replace(/[),]+$/, \"\")\n\nconst isPathToken = (path: Path.Path, token: string): boolean =>\n token === \".\" ||\n token === \"..\" ||\n token.startsWith(\"./\") ||\n token.startsWith(\"../\") ||\n path.isAbsolute(token) ||\n /^[A-Za-z]:[\\\\/]/.test(token) ||\n token.includes(\"/\")\n\nconst segmentReferences = (path: Path.Path, tokens: ReadonlyArray<string>): ReadonlyArray<PathReference> => {\n const commentIndex = tokens.findIndex((token) => token.startsWith(\"#\"))\n const active = commentIndex === -1 ? tokens : tokens.slice(0, commentIndex)\n const hasWrapper = active.some((token) => prefixWrappers.has(path.basename(pathValue(token))))\n let commandIndex = active.findIndex((token) =>\n !redirections.has(token) &&\n !token.includes(\"=\") &&\n !prefixWrappers.has(path.basename(pathValue(token))) &&\n !token.startsWith(\"-\") &&\n token !== \"\"\n )\n if (hasWrapper) {\n // Wrapper options can carry their own values. Prefer a later known\n // mutating command so an option value cannot lend it a read classification.\n const mutatingIndex = active.findIndex((token) => {\n const command = path.basename(pathValue(token))\n return writeCommands.has(command) || destinationCommands.has(command)\n })\n if (mutatingIndex !== -1) commandIndex = mutatingIndex\n }\n if (commandIndex === -1) return []\n\n const command = path.basename(pathValue(active[commandIndex]!))\n const candidates: Array<{ readonly index: number; readonly value: string }> = []\n for (let index = 0; index < active.length; index++) {\n const token = active[index]!\n if (index === commandIndex || redirections.has(token) || token.startsWith(\"-\")) continue\n const value = pathValue(token)\n if (value !== \"\" && isPathToken(path, value)) candidates.push({ index, value })\n }\n\n const destinationIndex = destinationCommands.has(command)\n ? candidates[candidates.length - 1]?.index\n : undefined\n\n return candidates.map(({ index, value }) => {\n const previous = active[index - 1]\n const access: Access = previous === \">\" || previous === \">>\" ||\n writeCommands.has(command) || destinationIndex === index\n ? \"write\"\n : \"read\"\n return { access, value }\n })\n}\n\n/**\n * Every path token the shell text names, one segment at a time.\n *\n * @category pre-check\n * @since 1.0.0\n */\nexport const commandReferences = (path: Path.Path, command: string): ReadonlyArray<PathReference> => {\n const references: Array<PathReference> = []\n let segment: Array<string> = []\n for (const token of tokenize(command)) {\n if (commandSeparators.has(token)) {\n references.push(...segmentReferences(path, segment))\n segment = []\n } else {\n segment.push(token)\n }\n }\n references.push(...segmentReferences(path, segment))\n return references\n}\n\nconst resolvePath = (\n path: Path.Path,\n cwd: string,\n value: string\n): string => path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)\n\nconst isDeclared = (\n path: Path.Path,\n cwd: string,\n declared: ReadonlyArray<string>,\n source: string\n): boolean =>\n withinEnvelope(\n declared.map((entry) => resolvePath(path, cwd, entry)),\n resolvePath(path, cwd, source)\n )\n\n/**\n * The refusal shell text earns by naming a path outside its declared\n * envelope, or `undefined` when every path it names is declared.\n *\n * @category pre-check\n * @since 1.0.0\n */\nexport const outsideEnvelope = (\n input: Envelope,\n text: string,\n path: Path.Path\n): StdError.StdError | undefined => {\n const base = path.resolve(\".\")\n const cwd = path.resolve(input.cwd ?? \".\")\n // Passing the default explicitly is not a declaration the caller owes. A\n // working directory is where the command runs, not a file it reads, and\n // `cwd: \".\"` names exactly what omitting `cwd` names \u2014 so refusing it asked\n // the caller to list the workspace root among its reads to say nothing at\n // all. Agents hit this constantly: one SWE-bench run spent ten of its\n // forty-eight tool calls re-issuing the same command after\n // \"Working directory is outside declared reads: <the workspace root>\". A\n // cwd that points somewhere else is still declared or refused.\n if (input.cwd !== undefined && cwd !== base && !isDeclared(path, cwd, input.reads, cwd)) {\n return new StdError.StdError({\n code: \"outside_declared_reads\",\n message: `Working directory is outside declared reads: ${cwd}`,\n path: cwd\n })\n }\n\n // The Shell contract currently has no sandbox or access-reporting surface.\n // This lexical scan is intentionally only a fail-closed pre-check for\n // explicit path tokens. It cannot prove confinement; a kernel/host sandbox\n // must eventually enforce and report the complete read and write sets.\n for (const reference of commandReferences(path, text)) {\n const resolved = resolvePath(path, cwd, reference.value)\n // /dev/* is the process plumbing every shell one-liner leans on:\n // `2>/dev/null`, `cat /dev/stdin`. Normalize first so a dot-dot segment\n // cannot disguise an ordinary filesystem path as process plumbing.\n if (resolved.startsWith(\"/dev/\")) continue\n const declared = reference.access === \"write\" ? input.writes : input.reads\n if (!isDeclared(path, cwd, declared, resolved)) {\n const code = reference.access === \"write\" ? \"outside_declared_writes\" : \"outside_declared_reads\"\n return new StdError.StdError({\n code,\n message: `Command path is outside declared ${reference.access}s: ${resolved}`,\n path: resolved\n })\n }\n }\n return undefined\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBA,eAA0B;AAC1B,mBAA+B;AAqC/B,MAAM,oBAAoB,oBAAI,IAAI,CAAC,MAAM,MAAM,KAAK,KAAK,KAAK,IAAI,CAAC;
|
|
6
|
-
"names": [
|
|
4
|
+
"sourcesContent": ["/**\n * The hermetic pre-check: a lexical scan of shell text for the paths it names,\n * and the refusal when one of them falls outside the declared envelope.\n *\n * The scan is fail-closed over explicit path tokens and nothing more. It\n * cannot prove confinement \u2014 a shell can name a path no lexer sees, through a\n * variable, a substitution, or a program that opens a file of its own \u2014 so a\n * kernel or host sandbox must eventually enforce and report the complete read\n * and write sets.\n *\n * It lives beside the flow rather than inside it because it is its own\n * concept, with its own vocabulary (Access, PathReference) and its own\n * security history: three independent bypasses, each sufficient on its own to\n * defeat it, are closed here.\n *\n * @since 1.0.0\n */\n\nimport type * as Path from \"@smthrs/kernel/Path\"\nimport * as StdError from \"../StdError.ts\"\nimport { withinEnvelope } from \"./Paths.ts\"\n\n/**\n * The filesystem envelope one invocation declared.\n *\n * Structural rather than the hermetic `Bash.Input` itself: the scan needs the\n * three fields and none of the rest, and depending on the flow module would\n * make the flow and its pre-check import each other.\n *\n * @category models\n * @since 1.0.0\n */\nexport interface Envelope {\n readonly cwd?: string | undefined\n readonly reads: ReadonlyArray<string>\n readonly writes: ReadonlyArray<string>\n}\n\n/**\n * How a command touches a path it names.\n *\n * @category models\n * @since 1.0.0\n */\nexport type Access = \"read\" | \"write\"\n\n/**\n * One path a command names, and how it touches it.\n *\n * @category models\n * @since 1.0.0\n */\nexport interface PathReference {\n readonly access: Access\n readonly value: string\n}\n\nconst commandSeparators = new Set([\"&&\", \"||\", \";\", \";;\", \";&\", \";;&\", \"|\", \"|&\", \"&\", \"\\n\", \"(\", \")\"])\nconst writeRedirections = new Set([\">\", \">>\", \">|\", \"&>\", \"&>>\", \">&\", \"<>\"])\nconst readRedirections = new Set([\"<\", \"<&\"])\n// A here-document delimiter and a here-string are text, not file names.\nconst textRedirections = new Set([\"<<\", \"<<-\", \"<<<\"])\nconst isRedirection = (token: Token): boolean =>\n token.operator &&\n (writeRedirections.has(token.text) || readRedirections.has(token.text) || textRedirections.has(token.text))\n// Longest first, so `&>>` is not read as `&` then `>>`.\nconst operators = [\n \";;&\",\n \"&>>\",\n \"<<<\",\n \"<<-\",\n \";;\",\n \";&\",\n \"&&\",\n \"||\",\n \"|&\",\n \"&>\",\n \">>\",\n \">|\",\n \">&\",\n \"<>\",\n \"<<\",\n \"<&\",\n \"\\n\",\n \";\",\n \"|\",\n \"&\",\n \"(\",\n \")\",\n \">\",\n \"<\"\n]\n// Reserved words and grouping braces open or close a compound command; the\n// command they wrap stands after them.\nconst reservedWords = new Set([\n \"!\",\n \"{\",\n \"}\",\n \"case\",\n \"do\",\n \"done\",\n \"elif\",\n \"else\",\n \"esac\",\n \"fi\",\n \"for\",\n \"function\",\n \"if\",\n \"in\",\n \"select\",\n \"then\",\n \"until\",\n \"while\"\n])\nconst writeCommands = new Set([\"chmod\", \"chown\", \"mkdir\", \"rm\", \"rmdir\", \"tee\", \"touch\", \"truncate\", \"unlink\"])\nconst destinationCommands = new Set([\"cp\", \"install\", \"mv\"])\nconst prefixWrappers = new Set([\"env\", \"nice\", \"time\", \"sudo\", \"nohup\", \"xargs\", \"command\", \"exec\"])\n\n/**\n * One lexical unit of shell text: an operator, or a word with its quotes\n * removed. A command substitution inside a word stands as `$()` in the word;\n * its body is scanned as a command of its own.\n */\ninterface Token {\n readonly operator: boolean\n readonly text: string\n}\n\ninterface Lexed {\n readonly tokens: ReadonlyArray<Token>\n readonly substitutions: ReadonlyArray<string>\n}\n\n// The end of a `$(` body that starts at `from`: the index of its closing\n// parenthesis, skipping quoted spans and nested parentheses. An unclosed body\n// runs to the end of the text, which is how a fail-closed scan must read it.\n// Substitutions and quotes nest without limit in a shell, but each level\n// costs this scan a pass over the rest of the text and a stack frame. Fifty\n// thousand `$(` used to run for half a minute and then overflow the stack, so\n// past this depth the text is refused rather than scanned.\nconst maxNesting = 32\n\nconst tooDeep = (): StdError.StdError =>\n new StdError.StdError({\n code: \"invalid_input\",\n message: `Command nests substitutions or quotes deeper than ${maxNesting} levels`\n })\n\nconst substitutionEnd = (text: string, from: number, nesting: number): number => {\n if (nesting > maxNesting) throw tooDeep()\n let depth = 1\n let index = from\n while (index < text.length) {\n const char = text[index]!\n if (char === \"\\\\\") {\n index += 2\n } else if (char === \"'\") {\n const close = text.indexOf(\"'\", index + 1)\n index = close === -1 ? text.length : close + 1\n } else if (char === \"\\\"\") {\n index = doubleQuoteEnd(text, index + 1, nesting + 1) + 1\n } else if (char === \"`\") {\n index = backtickEnd(text, index + 1) + 1\n } else {\n if (char === \"(\") depth++\n if (char === \")\" && --depth === 0) return index\n index++\n }\n }\n return text.length\n}\n\nconst backtickEnd = (text: string, from: number): number => {\n let index = from\n while (index < text.length && text[index] !== \"`\") index += text[index] === \"\\\\\" ? 2 : 1\n return Math.min(index, text.length)\n}\n\nconst doubleQuoteEnd = (text: string, from: number, nesting: number): number => {\n if (nesting > maxNesting) throw tooDeep()\n let index = from\n while (index < text.length && text[index] !== \"\\\"\") {\n const char = text[index]!\n if (char === \"\\\\\") index += 2\n else if (char === \"`\") index = backtickEnd(text, index + 1) + 1\n else if (char === \"$\" && text[index + 1] === \"(\") index = substitutionEnd(text, index + 2, nesting + 1) + 1\n else index++\n }\n return Math.min(index, text.length)\n}\n\n// A hand-rolled lexer rather than one regular expression: a word is any run\n// of bare characters, quoted spans, and substitutions, and a substitution can\n// hold quotes and operators of its own. Splitting on operator characters\n// alone let `echo \"$(rm /work/target)\"` hide a delete inside one quoted word,\n// read `>|` as a pipe that turned the target into the next command's name,\n// and read `(rm /work/target)` as a command named `(rm`.\n//\n// Quotes are removed from a word's text, because `\"/outs\"ide/x` names\n// `/outside/x`; keeping the quotes made it a relative path under the\n// working directory.\n// Sticky, so the match starts at `lastIndex` without copying the rest of the\n// text for every word.\nconst descriptorPattern = /\\d+(?=[<>])/y\n\nconst lex = (command: string, nesting: number): Lexed => {\n const tokens: Array<Token> = []\n const substitutions: Array<string> = []\n let index = 0\n const substitution = (from: number, to: number): string => {\n substitutions.push(command.slice(from, to))\n return \"$()\"\n }\n while (index < command.length) {\n const char = command[index]!\n // Every blank the word loop stops at must be skipped here, or the lexer\n // would push an empty word without advancing.\n if (char !== \"\\n\" && /\\s/.test(char)) {\n index++\n continue\n }\n if (char === \"\\\\\" && command[index + 1] === \"\\n\") {\n index += 2\n continue\n }\n if (char === \"#\") {\n while (index < command.length && command[index] !== \"\\n\") index++\n continue\n }\n // A file-descriptor number belongs to the redirection after it: in\n // `2>/dev/null rm /work/target` the command is `rm`, not `2`.\n descriptorPattern.lastIndex = index\n const descriptor = descriptorPattern.exec(command)\n if (descriptor !== null) {\n index += descriptor[0].length\n continue\n }\n const operator = operators.find((candidate) => command.startsWith(candidate, index))\n if (operator !== undefined) {\n tokens.push({ operator: true, text: operator })\n index += operator.length\n continue\n }\n let text = \"\"\n while (index < command.length) {\n const next = command[index]!\n if (/[\\s<>;|&()]/.test(next)) break\n if (next === \"\\\\\") {\n // A backslash escapes shell syntax. Before any other character it is\n // kept, so `C:\\work\\input` stays the path it names on Windows rather\n // than collapsing to a word the scan cannot see as a path.\n const escaped = command[index + 1] ?? \"\"\n if (escaped !== \"\\n\") text += /[\\s<>;|&()'\"`$\\\\#]/.test(escaped) ? escaped : `\\\\${escaped}`\n index += 2\n } else if (next === \"'\") {\n const close = command.indexOf(\"'\", index + 1)\n const end = close === -1 ? command.length : close\n text += command.slice(index + 1, end)\n index = end + 1\n } else if (next === \"\\\"\") {\n const end = doubleQuoteEnd(command, index + 1, nesting + 1)\n let inner = index + 1\n while (inner < end) {\n const quoted = command[inner]!\n if (quoted === \"\\\\\" && /[$`\"\\\\\\n]/.test(command[inner + 1] ?? \"\")) {\n if (command[inner + 1] !== \"\\n\") text += command[inner + 1]\n inner += 2\n } else if (quoted === \"`\") {\n const close = backtickEnd(command, inner + 1)\n text += substitution(inner + 1, close)\n inner = close + 1\n } else if (quoted === \"$\" && command[inner + 1] === \"(\") {\n const close = substitutionEnd(command, inner + 2, nesting + 2)\n text += substitution(inner + 2, close)\n inner = close + 1\n } else {\n text += quoted\n inner++\n }\n }\n index = end + 1\n } else if (next === \"`\") {\n const close = backtickEnd(command, index + 1)\n text += substitution(index + 1, close)\n index = close + 1\n } else if (next === \"$\" && command[index + 1] === \"(\") {\n const close = substitutionEnd(command, index + 2, nesting + 1)\n text += substitution(index + 2, close)\n index = close + 1\n } else {\n text += next\n index++\n }\n }\n tokens.push({ operator: false, text })\n }\n return { tokens, substitutions }\n}\n\n// A parenthesis is an operator to the lexer, so one left in a word was quoted\n// and is part of the name; only a trailing comma is punctuation.\nconst pathValue = (token: string): string => token.replace(/,+$/, \"\")\n\nconst isPathToken = (path: Path.Path, token: string): boolean =>\n token === \".\" ||\n token === \"..\" ||\n token.startsWith(\"./\") ||\n token.startsWith(\"../\") ||\n path.isAbsolute(token) ||\n /^[A-Za-z]:[\\\\/]/.test(token) ||\n token.includes(\"/\")\n\nconst segmentReferences = (path: Path.Path, tokens: ReadonlyArray<Token>): ReadonlyArray<PathReference> => {\n // The word after a redirection operator is its target, never the command.\n const isTarget = (index: number): boolean => index > 0 && isRedirection(tokens[index - 1]!)\n const isWord = (index: number): boolean => !tokens[index]!.operator && !isTarget(index)\n const name = (index: number): string => path.basename(pathValue(tokens[index]!.text))\n const hasWrapper = tokens.some((_, index) => isWord(index) && prefixWrappers.has(name(index)))\n let commandIndex = tokens.findIndex((token, index) =>\n isWord(index) &&\n !token.text.includes(\"=\") &&\n !prefixWrappers.has(name(index)) &&\n !reservedWords.has(token.text) &&\n !token.text.startsWith(\"-\") &&\n token.text !== \"\"\n )\n if (hasWrapper) {\n // Wrapper options can carry their own values. Prefer a later known\n // mutating command so an option value cannot lend it a read classification.\n const mutatingIndex = tokens.findIndex((_, index) =>\n isWord(index) && (writeCommands.has(name(index)) || destinationCommands.has(name(index)))\n )\n if (mutatingIndex !== -1) commandIndex = mutatingIndex\n }\n\n const command = commandIndex === -1 ? \"\" : name(commandIndex)\n const candidates: Array<{ readonly index: number; readonly value: string }> = []\n for (let index = 0; index < tokens.length; index++) {\n const token = tokens[index]!\n if (index === commandIndex || token.operator || (!isTarget(index) && token.text.startsWith(\"-\"))) continue\n if (isTarget(index) && textRedirections.has(tokens[index - 1]!.text)) continue\n const value = pathValue(token.text)\n if (value !== \"\" && isPathToken(path, value)) candidates.push({ index, value })\n }\n\n const destinationIndex = destinationCommands.has(command)\n ? candidates.filter(({ index }) => !isTarget(index)).at(-1)?.index\n : undefined\n\n return candidates.map(({ index, value }) => {\n const access: Access = isTarget(index)\n ? writeRedirections.has(tokens[index - 1]!.text) ? \"write\" : \"read\"\n : writeCommands.has(command) || destinationIndex === index\n ? \"write\"\n : \"read\"\n return { access, value }\n })\n}\n\nconst references = (path: Path.Path, command: string, nesting: number): ReadonlyArray<PathReference> => {\n const { substitutions, tokens } = lex(command, nesting)\n const found: Array<PathReference> = []\n let segment: Array<Token> = []\n for (const token of tokens) {\n if (token.operator && commandSeparators.has(token.text)) {\n found.push(...segmentReferences(path, segment))\n segment = []\n } else {\n segment.push(token)\n }\n }\n found.push(...segmentReferences(path, segment))\n // A substitution runs its body as a command; its paths count like any other.\n for (const body of substitutions) found.push(...references(path, body, nesting + 1))\n return found\n}\n\n/**\n * Every path token the shell text names, one segment at a time.\n *\n * @throws {StdError.StdError} `invalid_input` when substitutions or quotes nest\n * deeper than the scan reads.\n * @category pre-check\n * @since 1.0.0\n */\nexport const commandReferences = (path: Path.Path, command: string): ReadonlyArray<PathReference> =>\n references(path, command, 0)\n\nconst resolvePath = (\n path: Path.Path,\n cwd: string,\n value: string\n): string => path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)\n\nconst isDeclared = (\n path: Path.Path,\n cwd: string,\n declared: ReadonlyArray<string>,\n source: string\n): boolean =>\n withinEnvelope(\n declared.map((entry) => resolvePath(path, cwd, entry)),\n resolvePath(path, cwd, source)\n )\n\n/**\n * The refusal shell text earns by naming a path outside its declared\n * envelope, or `undefined` when every path it names is declared.\n *\n * @category pre-check\n * @since 1.0.0\n */\nexport const outsideEnvelope = (\n input: Envelope,\n text: string,\n path: Path.Path\n): StdError.StdError | undefined => {\n const base = path.resolve(\".\")\n const cwd = path.resolve(input.cwd ?? \".\")\n // Passing the default explicitly is not a declaration the caller owes. A\n // working directory is where the command runs, not a file it reads, and\n // `cwd: \".\"` names exactly what omitting `cwd` names \u2014 so refusing it asked\n // the caller to list the workspace root among its reads to say nothing at\n // all. Agents hit this constantly: one SWE-bench run spent ten of its\n // forty-eight tool calls re-issuing the same command after\n // \"Working directory is outside declared reads: <the workspace root>\". A\n // cwd that points somewhere else is still declared or refused.\n if (input.cwd !== undefined && cwd !== base && !isDeclared(path, cwd, input.reads, cwd)) {\n return new StdError.StdError({\n code: \"outside_declared_reads\",\n message: `Working directory is outside declared reads: ${cwd}`,\n path: cwd\n })\n }\n\n // The Shell contract currently has no sandbox or access-reporting surface.\n // This lexical scan is intentionally only a fail-closed pre-check for\n // explicit path tokens. It cannot prove confinement; a kernel/host sandbox\n // must eventually enforce and report the complete read and write sets.\n let named: ReadonlyArray<PathReference>\n try {\n named = commandReferences(path, text)\n } catch {\n // The depth bound is the one failure the scan raises, and a text the scan\n // cannot read is refused whatever the reason, never run unchecked.\n return tooDeep()\n }\n for (const reference of named) {\n const resolved = resolvePath(path, cwd, reference.value)\n // /dev/* is the process plumbing every shell one-liner leans on:\n // `2>/dev/null`, `cat /dev/stdin`. Normalize first so a dot-dot segment\n // cannot disguise an ordinary filesystem path as process plumbing.\n if (resolved.startsWith(\"/dev/\")) continue\n const declared = reference.access === \"write\" ? input.writes : input.reads\n if (!isDeclared(path, cwd, declared, resolved)) {\n const code = reference.access === \"write\" ? \"outside_declared_writes\" : \"outside_declared_reads\"\n return new StdError.StdError({\n code,\n message: `Command path is outside declared ${reference.access}s: ${resolved}`,\n path: resolved\n })\n }\n }\n return undefined\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBA,eAA0B;AAC1B,mBAA+B;AAqC/B,MAAM,oBAAoB,oBAAI,IAAI,CAAC,MAAM,MAAM,KAAK,MAAM,MAAM,OAAO,KAAK,MAAM,KAAK,MAAM,KAAK,GAAG,CAAC;AACtG,MAAM,oBAAoB,oBAAI,IAAI,CAAC,KAAK,MAAM,MAAM,MAAM,OAAO,MAAM,IAAI,CAAC;AAC5E,MAAM,mBAAmB,oBAAI,IAAI,CAAC,KAAK,IAAI,CAAC;AAE5C,MAAM,mBAAmB,oBAAI,IAAI,CAAC,MAAM,OAAO,KAAK,CAAC;AACrD,MAAM,gBAAgB,CAAC,UACrB,MAAM,aACL,kBAAkB,IAAI,MAAM,IAAI,KAAK,iBAAiB,IAAI,MAAM,IAAI,KAAK,iBAAiB,IAAI,MAAM,IAAI;AAE3G,MAAM,YAAY;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGA,MAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AACD,MAAM,gBAAgB,oBAAI,IAAI,CAAC,SAAS,SAAS,SAAS,MAAM,SAAS,OAAO,SAAS,YAAY,QAAQ,CAAC;AAC9G,MAAM,sBAAsB,oBAAI,IAAI,CAAC,MAAM,WAAW,IAAI,CAAC;AAC3D,MAAM,iBAAiB,oBAAI,IAAI,CAAC,OAAO,QAAQ,QAAQ,QAAQ,SAAS,SAAS,WAAW,MAAM,CAAC;AAwBnG,MAAM,aAAa;AAEnB,MAAM,UAAU,MACd,IAAI,SAAS,SAAS;AAAA,EACpB,MAAM;AAAA,EACN,SAAS,qDAAqD,UAAU;AAC1E,CAAC;AAEH,MAAM,kBAAkB,CAAC,MAAc,MAAc,YAA4B;AAC/E,MAAI,UAAU,WAAY,OAAM,QAAQ;AACxC,MAAI,QAAQ;AACZ,MAAI,QAAQ;AACZ,SAAO,QAAQ,KAAK,QAAQ;AAC1B,UAAM,OAAO,KAAK,KAAK;AACvB,QAAI,SAAS,MAAM;AACjB,eAAS;AAAA,IACX,WAAW,SAAS,KAAK;AACvB,YAAM,QAAQ,KAAK,QAAQ,KAAK,QAAQ,CAAC;AACzC,cAAQ,UAAU,KAAK,KAAK,SAAS,QAAQ;AAAA,IAC/C,WAAW,SAAS,KAAM;AACxB,cAAQ,eAAe,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI;AAAA,IACzD,WAAW,SAAS,KAAK;AACvB,cAAQ,YAAY,MAAM,QAAQ,CAAC,IAAI;AAAA,IACzC,OAAO;AACL,UAAI,SAAS,IAAK;AAClB,UAAI,SAAS,OAAO,EAAE,UAAU,EAAG,QAAO;AAC1C;AAAA,IACF;AAAA,EACF;AACA,SAAO,KAAK;AACd;AAEA,MAAM,cAAc,CAAC,MAAc,SAAyB;AAC1D,MAAI,QAAQ;AACZ,SAAO,QAAQ,KAAK,UAAU,KAAK,KAAK,MAAM,IAAK,UAAS,KAAK,KAAK,MAAM,OAAO,IAAI;AACvF,SAAO,KAAK,IAAI,OAAO,KAAK,MAAM;AACpC;AAEA,MAAM,iBAAiB,CAAC,MAAc,MAAc,YAA4B;AAC9E,MAAI,UAAU,WAAY,OAAM,QAAQ;AACxC,MAAI,QAAQ;AACZ,SAAO,QAAQ,KAAK,UAAU,KAAK,KAAK,MAAM,KAAM;AAClD,UAAM,OAAO,KAAK,KAAK;AACvB,QAAI,SAAS,KAAM,UAAS;AAAA,aACnB,SAAS,IAAK,SAAQ,YAAY,MAAM,QAAQ,CAAC,IAAI;AAAA,aACrD,SAAS,OAAO,KAAK,QAAQ,CAAC,MAAM,IAAK,SAAQ,gBAAgB,MAAM,QAAQ,GAAG,UAAU,CAAC,IAAI;AAAA,QACrG;AAAA,EACP;AACA,SAAO,KAAK,IAAI,OAAO,KAAK,MAAM;AACpC;AAcA,MAAM,oBAAoB;AAE1B,MAAM,MAAM,CAAC,SAAiB,YAA2B;AACvD,QAAM,SAAuB,CAAC;AAC9B,QAAM,gBAA+B,CAAC;AACtC,MAAI,QAAQ;AACZ,QAAM,eAAe,CAAC,MAAc,OAAuB;AACzD,kBAAc,KAAK,QAAQ,MAAM,MAAM,EAAE,CAAC;AAC1C,WAAO;AAAA,EACT;AACA,SAAO,QAAQ,QAAQ,QAAQ;AAC7B,UAAM,OAAO,QAAQ,KAAK;AAG1B,QAAI,SAAS,QAAQ,KAAK,KAAK,IAAI,GAAG;AACpC;AACA;AAAA,IACF;AACA,QAAI,SAAS,QAAQ,QAAQ,QAAQ,CAAC,MAAM,MAAM;AAChD,eAAS;AACT;AAAA,IACF;AACA,QAAI,SAAS,KAAK;AAChB,aAAO,QAAQ,QAAQ,UAAU,QAAQ,KAAK,MAAM,KAAM;AAC1D;AAAA,IACF;AAGA,sBAAkB,YAAY;AAC9B,UAAM,aAAa,kBAAkB,KAAK,OAAO;AACjD,QAAI,eAAe,MAAM;AACvB,eAAS,WAAW,CAAC,EAAE;AACvB;AAAA,IACF;AACA,UAAM,WAAW,UAAU,KAAK,CAAC,cAAc,QAAQ,WAAW,WAAW,KAAK,CAAC;AACnF,QAAI,aAAa,QAAW;AAC1B,aAAO,KAAK,EAAE,UAAU,MAAM,MAAM,SAAS,CAAC;AAC9C,eAAS,SAAS;AAClB;AAAA,IACF;AACA,QAAI,OAAO;AACX,WAAO,QAAQ,QAAQ,QAAQ;AAC7B,YAAM,OAAO,QAAQ,KAAK;AAC1B,UAAI,cAAc,KAAK,IAAI,EAAG;AAC9B,UAAI,SAAS,MAAM;AAIjB,cAAM,UAAU,QAAQ,QAAQ,CAAC,KAAK;AACtC,YAAI,YAAY,KAAM,SAAQ,qBAAqB,KAAK,OAAO,IAAI,UAAU,KAAK,OAAO;AACzF,iBAAS;AAAA,MACX,WAAW,SAAS,KAAK;AACvB,cAAM,QAAQ,QAAQ,QAAQ,KAAK,QAAQ,CAAC;AAC5C,cAAM,MAAM,UAAU,KAAK,QAAQ,SAAS;AAC5C,gBAAQ,QAAQ,MAAM,QAAQ,GAAG,GAAG;AACpC,gBAAQ,MAAM;AAAA,MAChB,WAAW,SAAS,KAAM;AACxB,cAAM,MAAM,eAAe,SAAS,QAAQ,GAAG,UAAU,CAAC;AAC1D,YAAI,QAAQ,QAAQ;AACpB,eAAO,QAAQ,KAAK;AAClB,gBAAM,SAAS,QAAQ,KAAK;AAC5B,cAAI,WAAW,QAAQ,YAAY,KAAK,QAAQ,QAAQ,CAAC,KAAK,EAAE,GAAG;AACjE,gBAAI,QAAQ,QAAQ,CAAC,MAAM,KAAM,SAAQ,QAAQ,QAAQ,CAAC;AAC1D,qBAAS;AAAA,UACX,WAAW,WAAW,KAAK;AACzB,kBAAM,QAAQ,YAAY,SAAS,QAAQ,CAAC;AAC5C,oBAAQ,aAAa,QAAQ,GAAG,KAAK;AACrC,oBAAQ,QAAQ;AAAA,UAClB,WAAW,WAAW,OAAO,QAAQ,QAAQ,CAAC,MAAM,KAAK;AACvD,kBAAM,QAAQ,gBAAgB,SAAS,QAAQ,GAAG,UAAU,CAAC;AAC7D,oBAAQ,aAAa,QAAQ,GAAG,KAAK;AACrC,oBAAQ,QAAQ;AAAA,UAClB,OAAO;AACL,oBAAQ;AACR;AAAA,UACF;AAAA,QACF;AACA,gBAAQ,MAAM;AAAA,MAChB,WAAW,SAAS,KAAK;AACvB,cAAM,QAAQ,YAAY,SAAS,QAAQ,CAAC;AAC5C,gBAAQ,aAAa,QAAQ,GAAG,KAAK;AACrC,gBAAQ,QAAQ;AAAA,MAClB,WAAW,SAAS,OAAO,QAAQ,QAAQ,CAAC,MAAM,KAAK;AACrD,cAAM,QAAQ,gBAAgB,SAAS,QAAQ,GAAG,UAAU,CAAC;AAC7D,gBAAQ,aAAa,QAAQ,GAAG,KAAK;AACrC,gBAAQ,QAAQ;AAAA,MAClB,OAAO;AACL,gBAAQ;AACR;AAAA,MACF;AAAA,IACF;AACA,WAAO,KAAK,EAAE,UAAU,OAAO,KAAK,CAAC;AAAA,EACvC;AACA,SAAO,EAAE,QAAQ,cAAc;AACjC;AAIA,MAAM,YAAY,CAAC,UAA0B,MAAM,QAAQ,OAAO,EAAE;AAEpE,MAAM,cAAc,CAAC,MAAiB,UACpC,UAAU,OACV,UAAU,QACV,MAAM,WAAW,IAAI,KACrB,MAAM,WAAW,KAAK,KACtB,KAAK,WAAW,KAAK,KACrB,kBAAkB,KAAK,KAAK,KAC5B,MAAM,SAAS,GAAG;AAEpB,MAAM,oBAAoB,CAAC,MAAiB,WAA+D;AAEzG,QAAM,WAAW,CAAC,UAA2B,QAAQ,KAAK,cAAc,OAAO,QAAQ,CAAC,CAAE;AAC1F,QAAM,SAAS,CAAC,UAA2B,CAAC,OAAO,KAAK,EAAG,YAAY,CAAC,SAAS,KAAK;AACtF,QAAM,OAAO,CAAC,UAA0B,KAAK,SAAS,UAAU,OAAO,KAAK,EAAG,IAAI,CAAC;AACpF,QAAM,aAAa,OAAO,KAAK,CAAC,GAAG,UAAU,OAAO,KAAK,KAAK,eAAe,IAAI,KAAK,KAAK,CAAC,CAAC;AAC7F,MAAI,eAAe,OAAO;AAAA,IAAU,CAAC,OAAO,UAC1C,OAAO,KAAK,KACZ,CAAC,MAAM,KAAK,SAAS,GAAG,KACxB,CAAC,eAAe,IAAI,KAAK,KAAK,CAAC,KAC/B,CAAC,cAAc,IAAI,MAAM,IAAI,KAC7B,CAAC,MAAM,KAAK,WAAW,GAAG,KAC1B,MAAM,SAAS;AAAA,EACjB;AACA,MAAI,YAAY;AAGd,UAAM,gBAAgB,OAAO;AAAA,MAAU,CAAC,GAAG,UACzC,OAAO,KAAK,MAAM,cAAc,IAAI,KAAK,KAAK,CAAC,KAAK,oBAAoB,IAAI,KAAK,KAAK,CAAC;AAAA,IACzF;AACA,QAAI,kBAAkB,GAAI,gBAAe;AAAA,EAC3C;AAEA,QAAM,UAAU,iBAAiB,KAAK,KAAK,KAAK,YAAY;AAC5D,QAAM,aAAwE,CAAC;AAC/E,WAAS,QAAQ,GAAG,QAAQ,OAAO,QAAQ,SAAS;AAClD,UAAM,QAAQ,OAAO,KAAK;AAC1B,QAAI,UAAU,gBAAgB,MAAM,YAAa,CAAC,SAAS,KAAK,KAAK,MAAM,KAAK,WAAW,GAAG,EAAI;AAClG,QAAI,SAAS,KAAK,KAAK,iBAAiB,IAAI,OAAO,QAAQ,CAAC,EAAG,IAAI,EAAG;AACtE,UAAM,QAAQ,UAAU,MAAM,IAAI;AAClC,QAAI,UAAU,MAAM,YAAY,MAAM,KAAK,EAAG,YAAW,KAAK,EAAE,OAAO,MAAM,CAAC;AAAA,EAChF;AAEA,QAAM,mBAAmB,oBAAoB,IAAI,OAAO,IACpD,WAAW,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC,SAAS,KAAK,CAAC,EAAE,GAAG,EAAE,GAAG,QAC3D;AAEJ,SAAO,WAAW,IAAI,CAAC,EAAE,OAAO,MAAM,MAAM;AAC1C,UAAM,SAAiB,SAAS,KAAK,IACjC,kBAAkB,IAAI,OAAO,QAAQ,CAAC,EAAG,IAAI,IAAI,UAAU,SAC3D,cAAc,IAAI,OAAO,KAAK,qBAAqB,QACnD,UACA;AACJ,WAAO,EAAE,QAAQ,MAAM;AAAA,EACzB,CAAC;AACH;AAEA,MAAM,aAAa,CAAC,MAAiB,SAAiB,YAAkD;AACtG,QAAM,EAAE,eAAe,OAAO,IAAI,IAAI,SAAS,OAAO;AACtD,QAAM,QAA8B,CAAC;AACrC,MAAI,UAAwB,CAAC;AAC7B,aAAW,SAAS,QAAQ;AAC1B,QAAI,MAAM,YAAY,kBAAkB,IAAI,MAAM,IAAI,GAAG;AACvD,YAAM,KAAK,GAAG,kBAAkB,MAAM,OAAO,CAAC;AAC9C,gBAAU,CAAC;AAAA,IACb,OAAO;AACL,cAAQ,KAAK,KAAK;AAAA,IACpB;AAAA,EACF;AACA,QAAM,KAAK,GAAG,kBAAkB,MAAM,OAAO,CAAC;AAE9C,aAAW,QAAQ,cAAe,OAAM,KAAK,GAAG,WAAW,MAAM,MAAM,UAAU,CAAC,CAAC;AACnF,SAAO;AACT;AAUO,MAAM,oBAAoB,CAAC,MAAiB,YACjD,WAAW,MAAM,SAAS,CAAC;AAE7B,MAAM,cAAc,CAClB,MACA,KACA,UACW,KAAK,WAAW,KAAK,IAAI,KAAK,UAAU,KAAK,IAAI,KAAK,QAAQ,KAAK,KAAK;AAErF,MAAM,aAAa,CACjB,MACA,KACA,UACA,eAEA;AAAA,EACE,SAAS,IAAI,CAAC,UAAU,YAAY,MAAM,KAAK,KAAK,CAAC;AAAA,EACrD,YAAY,MAAM,KAAK,MAAM;AAC/B;AASK,MAAM,kBAAkB,CAC7B,OACA,MACA,SACkC;AAClC,QAAM,OAAO,KAAK,QAAQ,GAAG;AAC7B,QAAM,MAAM,KAAK,QAAQ,MAAM,OAAO,GAAG;AASzC,MAAI,MAAM,QAAQ,UAAa,QAAQ,QAAQ,CAAC,WAAW,MAAM,KAAK,MAAM,OAAO,GAAG,GAAG;AACvF,WAAO,IAAI,SAAS,SAAS;AAAA,MAC3B,MAAM;AAAA,MACN,SAAS,gDAAgD,GAAG;AAAA,MAC5D,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAMA,MAAI;AACJ,MAAI;AACF,YAAQ,kBAAkB,MAAM,IAAI;AAAA,EACtC,QAAQ;AAGN,WAAO,QAAQ;AAAA,EACjB;AACA,aAAW,aAAa,OAAO;AAC7B,UAAM,WAAW,YAAY,MAAM,KAAK,UAAU,KAAK;AAIvD,QAAI,SAAS,WAAW,OAAO,EAAG;AAClC,UAAM,WAAW,UAAU,WAAW,UAAU,MAAM,SAAS,MAAM;AACrE,QAAI,CAAC,WAAW,MAAM,KAAK,UAAU,QAAQ,GAAG;AAC9C,YAAM,OAAO,UAAU,WAAW,UAAU,4BAA4B;AACxE,aAAO,IAAI,SAAS,SAAS;AAAA,QAC3B;AAAA,QACA,SAAS,oCAAoC,UAAU,MAAM,MAAM,QAAQ;AAAA,QAC3E,MAAM;AAAA,MACR,CAAC;AAAA,IACH;AAAA,EACF;AACA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
7
|
}
|