jeo-code 0.12.26 → 0.12.28
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 +15 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/AGENTS.md +3 -2
- package/src/agent/engine.ts +82 -29
- package/src/agent/loop-guards.ts +366 -42
- package/src/agent/sandbox/docker.ts +15 -1
- package/src/agent/sandbox/hands-source.text +705 -705
- package/src/agent/snapshot-target.ts +188 -0
- package/src/agent/workspace-snapshot.ts +84 -19
- package/src/ai/types.ts +6 -2
- package/src/commands/update.ts +32 -11
package/src/agent/loop-guards.ts
CHANGED
|
@@ -82,57 +82,308 @@ export const GUARD_LIMITS = Object.freeze({
|
|
|
82
82
|
/**
|
|
83
83
|
* Recognized standalone verification commands. Matching the full command prevents
|
|
84
84
|
* filenames, prose, and shell output from impersonating verification evidence.
|
|
85
|
+
*
|
|
86
|
+
* Two tables share one argument grammar: TEST_CORES run tests and print a pass summary, so output can
|
|
87
|
+
* corroborate them; SILENT_CORES (type checkers, linters, formatters in check mode, builds) report only through
|
|
88
|
+
* their exit status and print little or nothing on success.
|
|
89
|
+
*/
|
|
90
|
+
// Argument tail: plain tokens or balanced quoted strings (`pytest -k "foo and bar"`); `$`, backticks and
|
|
91
|
+
// backslashes never appear inside it, so nothing in a quoted argument can expand or escape.
|
|
92
|
+
const ARG = String.raw`(?:\s+(?:[A-Za-z0-9_./:@+*?,=-]+|"[^"$\x60\\\r\n]*"|'[^'$\x60\\\r\n]*'))*`;
|
|
93
|
+
const PM_VALUE = String.raw`[A-Za-z0-9_./:@+*?,~^-]+`;
|
|
94
|
+
// Workspace / directory selection that may sit between a package manager and its script.
|
|
95
|
+
const PM_OPT = String.raw`(?:\s+(?:-r|--recursive|--workspaces|--silent|-s|--quiet|-q)|\s+(?:--filter|-F|--prefix|--cwd|-C|--workspace|-w|--dir)(?:=|\s+)${PM_VALUE})*`;
|
|
96
|
+
const PM = String.raw`(?:(?:bun|npm|pnpm|yarn)${PM_OPT}|yarn\s+workspace\s+[A-Za-z0-9_./@-]+)(?:\s+(?:run|exec|x|dlx))?${PM_OPT}`;
|
|
97
|
+
// make/just/rake/gradle/mvn take TARGETS (further commands), not script arguments: `make ci deploy` would run
|
|
98
|
+
// `deploy`. Their tails are therefore a closed grammar: any flag (judged by TASK_DENY below) together with the value a
|
|
99
|
+
// value-taking flag consumes, NAME=VALUE, and allow-listed targets. Any other bare token rejects the command.
|
|
100
|
+
const TARGETS = ["test", "tests", "check", "ci", "verify", "lint", "typecheck", "type-check", "vet", "clean", "build", "compile", "all", "unit", "integration", "e2e", "coverage", "format-check", "spec"];
|
|
101
|
+
const BUILD_TEST_TARGETS = ["test", "tests", "ci", "verify", "spec", "unit", "integration", "e2e", "coverage"];
|
|
102
|
+
const BUILD_SILENT_TARGETS = ["check", "lint", "typecheck", "type-check", "vet", "build", "compile", "format-check"];
|
|
103
|
+
// NAME=VALUE: never an update/snapshot/skip switch and never one of make's program-selecting variables.
|
|
104
|
+
const ASSIGN = String.raw`(?![A-Za-z0-9_]*(?:UPDATE|SNAPSHOT|GOLDEN|SKIP|IGNORE)[A-Za-z0-9_]*=)(?!(?:SHELL|MAKE|MAKEFLAGS|MAKEFILES)=)[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_./:@+,%=-]*`;
|
|
105
|
+
const TASK_VALUE = String.raw`[A-Za-z0-9_./:@+*?,~^#!%=-]`;
|
|
106
|
+
const QUOTED_VALUE = String.raw`(?:${TASK_VALUE}+|"[^"$\x60\\\r\n]*"|'[^'$\x60\\\r\n]*')`;
|
|
107
|
+
const DOTNET_PROJECT = String.raw`(?!-)[A-Za-z0-9_~-]*[./][A-Za-z0-9_./~-]*`;
|
|
108
|
+
interface TaskSpec {
|
|
109
|
+
/** Flags that consume the next token (or `=value`) as their argument, as a regex alternation. */
|
|
110
|
+
value: string;
|
|
111
|
+
/** Flags whose argument is an OPTIONAL number (`make -j` vs `make -j 4`). */
|
|
112
|
+
numeric?: string;
|
|
113
|
+
/** Whether bare `NAME=VALUE` tokens are accepted (make, just, rake). */
|
|
114
|
+
assign?: boolean;
|
|
115
|
+
/** Whether targets may carry a Gradle project path (`:app:test`); only the LAST segment must be allow-listed. */
|
|
116
|
+
path?: boolean;
|
|
117
|
+
/** Value token shape; defaults to unquoted TASK_VALUE. */
|
|
118
|
+
token?: string;
|
|
119
|
+
}
|
|
120
|
+
const MAKE_SPEC: TaskSpec = { value: "-C|--directory|-f|--file|--makefile|-I|--include-dir", numeric: "-j|--jobs|-l|--load-average|--max-load", assign: true };
|
|
121
|
+
const JUST_SPEC: TaskSpec = { value: "-f|--justfile|-d|--working-directory", assign: true };
|
|
122
|
+
const RAKE_SPEC: TaskSpec = { value: "-f|--rakefile|-I|--libdir|-R|--rakelibdir", numeric: "-j|--jobs", assign: true };
|
|
123
|
+
const GRADLE_SPEC: TaskSpec = { value: "-p|--project-dir|-b|--build-file|-c|--settings-file|-g|--gradle-user-home|--tests|--max-workers|--console|--warning-mode|--project-cache-dir", path: true };
|
|
124
|
+
const MVN_SPEC: TaskSpec = { value: "-pl|--projects|-f|--file|-T|--threads|-P|--activate-profiles|-s|--settings|-gs|--global-settings|-t|--toolchains|-rf|--resume-from|-l|--log-file" };
|
|
125
|
+
const DOTNET_SPEC: TaskSpec = { value: "-c|--configuration|-f|--framework|-v|--verbosity|-l|--logger|--filter|-r|--runtime|-a|--arch|--os|-s|--settings" };
|
|
126
|
+
/**
|
|
127
|
+
* One flag token: a value-taking flag with its argument, an optional-number flag, any other `-x`/`--long[=v]` flag, or
|
|
128
|
+
* (if enabled) NAME=VALUE. The generic alternative excludes the exact names handled by the first two, so every token
|
|
129
|
+
* has ONE parse (`-C test` consumes `test` as the directory) and matching stays linear. Whether a flag is acceptable is
|
|
130
|
+
* decided afterwards by TASK_DENY on the whole command, never by this grammar.
|
|
85
131
|
*/
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
132
|
+
function taskFlag(spec: TaskSpec): string {
|
|
133
|
+
const token = QUOTED_VALUE;
|
|
134
|
+
const names = spec.numeric ? `${spec.value}|${spec.numeric}` : spec.value;
|
|
135
|
+
const alts = [
|
|
136
|
+
String.raw`(?:${spec.value})(?:=|\s+)${token}`,
|
|
137
|
+
String.raw`(?!(?:${names})(?=[=\s]|$))--?[A-Za-z][\w.:-]*(?:=${TASK_VALUE}*)?`,
|
|
138
|
+
];
|
|
139
|
+
if (spec.numeric) alts.push(String.raw`(?:${spec.numeric})(?:(?:=|\s+)\d+(?:\.\d+)?)?`);
|
|
140
|
+
if (spec.assign) alts.push(ASSIGN);
|
|
141
|
+
return alts.join("|");
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `head (flag | non-primary target)* PRIMARY (flag | allow-listed target)*`. The leading run excludes the primary
|
|
145
|
+
* targets so the split point is unique; every token after `head` must be a flag, NAME=VALUE or an allow-listed target.
|
|
146
|
+
*/
|
|
147
|
+
function taskCore(head: string, spec: TaskSpec, primary: readonly string[]): string {
|
|
148
|
+
const flag = taskFlag(spec);
|
|
149
|
+
const pre = spec.path ? String.raw`(?:(?!-)[\w.-]*:)*` : "";
|
|
150
|
+
const rest = TARGETS.filter(target => !primary.includes(target)).join("|");
|
|
151
|
+
return String.raw`${head}(?:\s+(?:${flag}|${pre}(?:${rest})))*\s+${pre}(?:${primary.join("|")})(?:\s+(?:${flag}|${pre}(?:${TARGETS.join("|")})))*`;
|
|
152
|
+
}
|
|
153
|
+
const PRETTIER = String.raw`(?:(?:npx|bunx)\s+|(?:pnpm|npm|yarn|bun)\s+(?:exec|x|dlx)\s+)?prettier`;
|
|
154
|
+
const TEST_CORES: readonly string[] = [
|
|
155
|
+
String.raw`${PM}\s+(?:test|tests|spec|vitest|jest)(?::[\w-]+)?`,
|
|
156
|
+
String.raw`(?:npx|bunx)\s+(?:vitest|jest|pytest)`,
|
|
157
|
+
String.raw`(?:(?:npx|bunx)|(?:pnpm|npm|yarn|bun)\s+(?:exec|x|dlx))\s+playwright\s+test`,
|
|
158
|
+
String.raw`(?:vitest|jest|pytest|phpunit|playwright\s+test)`,
|
|
159
|
+
String.raw`(?:bundle\s+exec\s+)?rspec`,
|
|
160
|
+
String.raw`(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:(?:-m\s+)?pytest|-m\s+unittest)`,
|
|
161
|
+
String.raw`node\s+--test`,
|
|
162
|
+
String.raw`deno\s+test`,
|
|
163
|
+
String.raw`cargo\s+test`,
|
|
164
|
+
String.raw`go\s+test`,
|
|
165
|
+
];
|
|
166
|
+
const SILENT_CORES: readonly string[] = [
|
|
167
|
+
String.raw`${PM}\s+(?:typecheck|lint|build|check|tsc|eslint)(?::[\w-]+)?`,
|
|
168
|
+
String.raw`(?:npx|bunx)\s+(?:tsc|eslint)`,
|
|
169
|
+
String.raw`(?:tsc|eslint|mypy|flake8|staticcheck|ruff\s+check|golangci-lint\s+run)`,
|
|
170
|
+
String.raw`(?:python(?:3(?:\.\d+)?)?|uv)\s+-m\s+(?:py_compile|mypy|flake8|ruff\s+check)`,
|
|
171
|
+
String.raw`node\s+(?:--check|-c)`,
|
|
172
|
+
String.raw`deno\s+(?:check|lint)`,
|
|
173
|
+
String.raw`cargo\s+(?:check|build|clippy)`,
|
|
174
|
+
String.raw`cargo\s+fmt(?=(?:\s+\S+)*\s+--check(?:\s|$))`,
|
|
175
|
+
String.raw`go\s+(?:vet|build)`,
|
|
176
|
+
String.raw`${PRETTIER}(?=(?:\s+\S+)*\s+(?:--check|-c|--list-different|-l)(?:\s|$))`,
|
|
177
|
+
];
|
|
178
|
+
// Task-runner cores carry their own closed tail (see taskCore); VERIFY_SIGNAL_RE must not append ARG to them.
|
|
179
|
+
const STRICT_TEST_CORES: readonly string[] = [
|
|
180
|
+
taskCore("make", MAKE_SPEC, BUILD_TEST_TARGETS),
|
|
181
|
+
taskCore("just", JUST_SPEC, BUILD_TEST_TARGETS),
|
|
182
|
+
taskCore(String.raw`(?:bundle\s+exec\s+)?rake`, RAKE_SPEC, ["test", "tests", "spec"]),
|
|
183
|
+
taskCore("(?:gradle|gradlew)", GRADLE_SPEC, ["test"]),
|
|
184
|
+
taskCore("(?:mvn|mvnw)", MVN_SPEC, ["test", "verify"]),
|
|
185
|
+
String.raw`dotnet\s+test(?:\s+(?:${taskFlag(DOTNET_SPEC)}|${DOTNET_PROJECT}))*`,
|
|
186
|
+
];
|
|
187
|
+
const STRICT_SILENT_CORES: readonly string[] = [
|
|
188
|
+
taskCore("make", MAKE_SPEC, BUILD_SILENT_TARGETS),
|
|
189
|
+
taskCore("just", JUST_SPEC, BUILD_SILENT_TARGETS),
|
|
190
|
+
taskCore("(?:gradle|gradlew)", GRADLE_SPEC, ["check", "build"]),
|
|
191
|
+
];
|
|
192
|
+
// Loose cores match case-insensitively; the task-runner cores stay case-sensitive because their flags differ by case
|
|
193
|
+
// (`mvn -B` batch vs `-b` builder, `-P` profile vs `-p`, `make -C` vs `-c`): `i` would let one swallow the target.
|
|
194
|
+
/** Anchored match of every recognized verifier core (test runners and silent checkers) with its tail. */
|
|
195
|
+
export const VERIFY_SIGNAL_RE = new RegExp(`^(?:(?i:${[...TEST_CORES, ...SILENT_CORES].join("|")})${ARG}|${[...STRICT_TEST_CORES, ...STRICT_SILENT_CORES].join("|")})$`);
|
|
196
|
+
/** Cores that run tests and print a pass summary: the only ones whose output can stand in for a trustworthy exit status. */
|
|
197
|
+
const TEST_CORE_RE = new RegExp(`^(?:(?i:${TEST_CORES.join("|")})${ARG}|${STRICT_TEST_CORES.join("|")})$`);
|
|
198
|
+
// `-x test` (gradle skip) and `-u` (jest update) are lowercase-only: mvn's `-X` (debug) and `-U` (update dependencies) are not them.
|
|
199
|
+
const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--co|--show-?config|--show-settings|--show-files|--init|--dry-run|--no-run|--passWithNoTests|--if-present|-DskipTests|-Dmaven\.test\.skip|-Dmaven\.test\.failure\.ignore|-Dtest\.failure\.ignore|-DfailIfNoTests|-Dsurefire\.failIfNoSpecifiedTests|(?-i:-x)\s+test)(?=$|\s|=)/i;
|
|
200
|
+
const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--fix-only|--unsafe-fixes|--add-noqa|--install-types|-fix|--write|--update|(?-i:-u)|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
|
|
201
|
+
// Judged on the first script token after any manager options, so `pnpm --filter web test:fix` is still caught.
|
|
202
|
+
const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\b(?:\s+\S+)*?\s+[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
|
|
203
|
+
// Any short-flag cluster containing `w` (`-w`, `-cw`, `-wc`) writes files; `--write` is caught by MUTATING_VERIFY_FLAG_RE.
|
|
204
|
+
const PRETTIER_WRITE_RE = new RegExp(String.raw`^${PRETTIER}\b.*(?:^|\s)-[A-Za-z]*w[A-Za-z]*(?=\s|$)`, "i");
|
|
205
|
+
// Per-tool flag deny-list, judged on the command text with quotes turned into spaces. The same letter means different
|
|
206
|
+
// things per tool (`-t` touches in make, selects toolchains in mvn; `-i` ignores errors in make, logs in gradle), so
|
|
207
|
+
// each tool has its own list. Denied: dry-run / listing / touch / "what if" modes and anything that skips tests, masks a
|
|
208
|
+
// failing exit status, or rewrites lock files. make/just/rake may bundle short flags (`-kn`), so those match clusters.
|
|
209
|
+
const PROP_DENY_RE = /(?:^|\s)(?:-D|-P|--define|--project-prop|--system-prop|--activate-profiles|-p:)\s*\S*?(?:skip|ignore|failure|dry.?run|update|snapshot|golden|exec=true)/i;
|
|
210
|
+
const TASK_DENY: readonly { head: RegExp; deny: readonly RegExp[] }[] = [
|
|
211
|
+
{ head: /^make\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[ntqiWo][A-Za-z]*|--(?:dry-run|just-print|recon|touch|question|what-if|new-file|assume-new|old-file|assume-old|ignore-errors|eval))(?=\s|=|$)/] },
|
|
212
|
+
{ head: /^just\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[nlse][A-Za-z]*|--(?:dry-run|list|show|evaluate|edit|init|dump|summary|choose|fmt|variables|changelog|completions|command))(?=\s|=|$)/] },
|
|
213
|
+
{ head: /^(?:bundle\s+exec\s+)?rake\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[nTPDWperEhHV][A-Za-z]*|--(?:dry-run|tasks|prereqs|describe|where|execute|execute-print|execute-continue|require|help|version))(?=\s|=|$)/] },
|
|
214
|
+
{ head: /^(?:gradle|gradlew)\b/, deny: [/(?:^|\s)(?:-[mta](?=\s|$)|-[xI]\S*|--(?:dry-run|exclude-task|continuous|init-script|scan|write-locks|update-locks|write-verification-metadata|refresh-dependencies|export-keys|no-rebuild)(?=\s|=|$))/, PROP_DENY_RE] },
|
|
215
|
+
{ head: /^(?:mvn|mvnw)\b/, deny: [/(?:^|\s)(?:-v|--version|-h|--help|-fn|--fail-never)(?=\s|=|$)/, PROP_DENY_RE] },
|
|
216
|
+
{ head: /^dotnet\b/, deny: [/(?:^|\s)(?:-t|--list-tests|-h|--help|-\?)(?=\s|=|$)/, PROP_DENY_RE] },
|
|
217
|
+
];
|
|
90
218
|
|
|
91
219
|
// ── Verifier command grammar ──────────────────────────────────────────────────
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
220
|
+
// The done-gate trusts the shell's exit status of a verifier. A form whose status is NOT the verifier's own can
|
|
221
|
+
// report success while the verifier failed, so it is "weak": it counts ONLY when the OUTPUT shows positive pass
|
|
222
|
+
// evidence AND no failure marker (see verificationProducedEvidence). Direct forms keep tool success + evidence.
|
|
223
|
+
//
|
|
224
|
+
// command := "(" body ")" | body one outer subshell level, always weak; `2>&1` tokens removed
|
|
225
|
+
// body := ( (set|cd) ";" )* [cdSeg "&&"] pipeline ( "&&" pipeline )* [ ("||" | ";") trailer ]
|
|
226
|
+
// pipeline := verifierSeg ( "|" display )* display := tail [-N | -n N] | tee [-a] (never a file, never head)
|
|
227
|
+
// trailer := true | ":" | echo <plain words> echo only at the top level; its text must hold no digit/pass/ok/success
|
|
228
|
+
// verifierSeg:= prefix* core | (bash|sh|zsh|dash) -c "<body>" nested -c is judged on the inner body, one level deep
|
|
229
|
+
// prefix := `NAME=VALUE`, `env [-u NAME]... [NAME=VALUE]...`, `timeout N[s|m]`, `time [-p]`,
|
|
230
|
+
// `uv|poetry|pipenv run`, `rtk [test]` each propagates the verifier's exit status
|
|
97
231
|
// core := first `/`-bearing token replaced by its basename, then VERIFY_SIGNAL_RE with no
|
|
98
232
|
// non-executing / mutating flag, no mutating script, and no short no-op form.
|
|
99
|
-
//
|
|
100
|
-
//
|
|
233
|
+
// Weak = any pipe, `||`/`;` trailer, `set`/`cd ;` prefix or subshell. A weak form is recognized only when EVERY core
|
|
234
|
+
// is a test runner (TEST_CORE_RE): a silent linter has no output to corroborate it, and `grep`/`awk`/`sed`/`sort`/
|
|
235
|
+
// `cut`/`head` stages, `rm`, `git commit`, installers or any other command never qualify as display or trailer.
|
|
236
|
+
// make/just/rake/gradle/mvn/dotnet cores use a closed tail (see taskCore): any flag not on that tool's TASK_DENY list,
|
|
237
|
+
// its value, NAME=VALUE and allow-listed targets; any other bare target (deploy, install, publish, db:drop) rejects.
|
|
238
|
+
// Only plain tokens or balanced quotes are accepted: `$`, backticks, backslashes, redirections and grouping
|
|
239
|
+
// outside the single subshell are rejected before any stage is read.
|
|
101
240
|
const STDERR_MERGE_RE = /(^|\s)2>&1(?=\s|$)/g;
|
|
102
241
|
const ASSIGN_PREFIX_RE = /^([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*\s+/;
|
|
103
242
|
const ENV_PREFIX_RE = /^env(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)*(?:\s+([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*)*\s+/;
|
|
104
243
|
const TIMEOUT_PREFIX_RE = /^timeout\s+\d+[sm]?\s+/;
|
|
105
244
|
const RUNNER_PREFIX_RE = /^(?:uv|poetry|pipenv)\s+run\s+/;
|
|
106
245
|
const RTK_PREFIX_RE = /^rtk\s+(?:test\s+)?/;
|
|
107
|
-
const
|
|
246
|
+
const TIME_PREFIX_RE = /^time\s+(?:-p\s+)?/;
|
|
247
|
+
const CD_SEGMENT_RE = /^cd\s+(?:[A-Za-z0-9_./:@+,=~-]+|"[^"$`\\]*"|'[^'$`\\]*')$/;
|
|
248
|
+
const SET_STMT_RE = /^set(?:\s+(?:-[A-Za-z]+|-o\s+pipefail|pipefail))+$/;
|
|
108
249
|
const BINARY_PATH_RE = /^([A-Za-z0-9_.@+-]*\/[A-Za-z0-9_./@+-]*)(?=\s|$)/;
|
|
250
|
+
const SHELL_C_RE = /^(?:ba|z|da)?sh\s+(?:-o\s+pipefail\s+)?-[elux]*c\s+(?:"([^"]*)"|'([^']*)')$/;
|
|
251
|
+
// `head` is not display-only (it closes the pipe early, hiding a later failure) and `tee <file>` could overwrite a
|
|
252
|
+
// tracked file through a symlink: a verified call is never snapshot-bracketed, so that write would be invisible.
|
|
253
|
+
const DISPLAY_STAGE_RE = /^(?:tail(?:\s+(?:-\d+|-n\s*\d+))?|tee(?:\s+-a)?)$/;
|
|
254
|
+
/** Echo text that could read as a pass summary (or contains a count) is refused rather than discounted. */
|
|
255
|
+
const ECHO_PASSLIKE_RE = /\d|\bpass(?:ed|ing)?\b|\bok\b|success/i;
|
|
256
|
+
const TRAILER_RE = /^(?:true|:|echo((?:\s+(?:[A-Za-z0-9_.,:@%+=\/][A-Za-z0-9_.,:@%+=\/-]*|"[^"$`\\]*"|'[^'$`\\]*'))*))$/;
|
|
109
257
|
/** Env names that are the env-form of an update/snapshot flag (parity with MUTATING_VERIFY_FLAG_RE). */
|
|
110
258
|
const MUTATING_VERIFY_ENV_RE = /UPDATE|SNAPSHOT|GOLDEN/i;
|
|
111
259
|
const ENV_ASSIGNMENT_NAME_RE = /([A-Za-z_][A-Za-z0-9_]*)=/g;
|
|
112
260
|
|
|
261
|
+
/** Result of {@link parseVerifier}: the verifier invocations and how far the shell's exit status can be trusted. */
|
|
262
|
+
export interface VerifierParse {
|
|
263
|
+
/** Normalized verifier invocations (wrapper prefixes, `cd`, pipes and trailers stripped). */
|
|
264
|
+
cores: string[];
|
|
265
|
+
/** True when the shell's exit status IS the verifier's own, so tool success is itself the pass signal. */
|
|
266
|
+
trusted: boolean;
|
|
267
|
+
/** Literal text a trailing `echo` prints; the evidence check discounts it so it cannot fake a pass line. */
|
|
268
|
+
echoed: string | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface Chain { cores: string[]; weak: boolean; echoed: string | null }
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Parse a command as a recognized verification, or null when it is not one. See the grammar above for what is
|
|
275
|
+
* accepted and which forms are weak (`trusted: false`).
|
|
276
|
+
*/
|
|
277
|
+
export function parseVerifier(cmd: string): VerifierParse | null {
|
|
278
|
+
let command = cmd.trim().replace(STDERR_MERGE_RE, "$1").trim();
|
|
279
|
+
if (!command) return null;
|
|
280
|
+
let wrapped = false;
|
|
281
|
+
if (command.startsWith("(") && command.endsWith(")")) {
|
|
282
|
+
command = command.slice(1, -1).trim();
|
|
283
|
+
wrapped = true;
|
|
284
|
+
}
|
|
285
|
+
const chain = parseChain(command, false);
|
|
286
|
+
if (!chain) return null;
|
|
287
|
+
const weak = wrapped || chain.weak;
|
|
288
|
+
if (weak && !chain.cores.every(core => TEST_CORE_RE.test(core))) return null;
|
|
289
|
+
return { cores: chain.cores, trusted: !weak, echoed: chain.echoed };
|
|
290
|
+
}
|
|
291
|
+
|
|
113
292
|
/**
|
|
114
293
|
* Normalized verifier invocations (cores) of a command, or null when the command is not a
|
|
115
|
-
*
|
|
294
|
+
* recognized verification. Wrapper prefixes and a leading `cd <path> &&` are stripped,
|
|
116
295
|
* so flag/script exclusions are judged on the verifier itself, never on the wrappers.
|
|
117
296
|
*/
|
|
118
297
|
export function verifierCores(cmd: string): string[] | null {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
298
|
+
return parseVerifier(cmd)?.cores ?? null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Split on top-level `&&`, `||`, `|` and `;` while leaving quoted text intact. Rejects anything that could change
|
|
303
|
+
* what runs: expansion, escapes, newlines, redirections, grouping and a lone `&` outside quotes.
|
|
304
|
+
*/
|
|
305
|
+
function splitShell(command: string): { stages: string[]; ops: string[] } | null {
|
|
306
|
+
const stages: string[] = [];
|
|
307
|
+
const ops: string[] = [];
|
|
308
|
+
let start = 0;
|
|
309
|
+
let quote = "";
|
|
310
|
+
for (let i = 0; i < command.length; i++) {
|
|
311
|
+
const ch = command[i];
|
|
312
|
+
if (ch === "$" || ch === "`" || ch === "\\" || ch === "\n" || ch === "\r") return null;
|
|
313
|
+
if (quote) {
|
|
314
|
+
if (ch === quote) quote = "";
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
|
318
|
+
if ("<>(){}".includes(ch)) return null;
|
|
319
|
+
let op: string;
|
|
320
|
+
if (ch === "&") {
|
|
321
|
+
if (command[i + 1] !== "&") return null;
|
|
322
|
+
op = "&&";
|
|
323
|
+
} else if (ch === "|") op = command[i + 1] === "|" ? "||" : "|";
|
|
324
|
+
else if (ch === ";") op = ";";
|
|
325
|
+
else continue;
|
|
326
|
+
stages.push(command.slice(start, i).trim());
|
|
327
|
+
ops.push(op);
|
|
328
|
+
i += op.length - 1;
|
|
329
|
+
start = i + 1;
|
|
330
|
+
}
|
|
331
|
+
if (quote) return null;
|
|
332
|
+
stages.push(command.slice(start).trim());
|
|
333
|
+
return stages.some(stage => !stage) ? null : { stages, ops };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseChain(command: string, nested: boolean): Chain | null {
|
|
337
|
+
const shell = splitShell(command);
|
|
338
|
+
if (!shell) return null;
|
|
339
|
+
const { stages, ops } = shell;
|
|
340
|
+
const last = stages.length - 1;
|
|
341
|
+
let p = 0;
|
|
342
|
+
let weak = false;
|
|
343
|
+
// `set -e;` / `cd x;` change state only; they cannot mask the next command's status, but are not the verifier's own.
|
|
344
|
+
while (p < last && ops[p] === ";" && (SET_STMT_RE.test(stages[p]) || CD_SEGMENT_RE.test(stages[p]))) { weak = true; p++; }
|
|
345
|
+
if (p < last && ops[p] === "&&" && CD_SEGMENT_RE.test(stages[p])) p++;
|
|
126
346
|
const cores: string[] = [];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
347
|
+
let echoed: string | null = null;
|
|
348
|
+
for (;;) {
|
|
349
|
+
const stage = parseStage(stages[p], nested);
|
|
350
|
+
if (!stage) return null;
|
|
351
|
+
cores.push(...stage.cores);
|
|
352
|
+
if (stage.weak) weak = true;
|
|
353
|
+
p++;
|
|
354
|
+
while (p <= last && ops[p - 1] === "|") {
|
|
355
|
+
if (!DISPLAY_STAGE_RE.test(stages[p])) return null;
|
|
356
|
+
weak = true;
|
|
357
|
+
p++;
|
|
358
|
+
}
|
|
359
|
+
if (p > last) break;
|
|
360
|
+
if (ops[p - 1] === "&&") continue;
|
|
361
|
+
// `||` or `;`: exactly one trailing stage that cannot run anything else.
|
|
362
|
+
if (p !== last) return null;
|
|
363
|
+
const trailer = TRAILER_RE.exec(stages[p]);
|
|
364
|
+
if (!trailer) return null;
|
|
365
|
+
if (trailer[1] !== undefined) {
|
|
366
|
+
if (nested) return null; // only a top-level echo is the tail of the whole output, where it can be discounted
|
|
367
|
+
echoed = [...trailer[1].matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(t => t[1] ?? t[2] ?? t[3]).join(" ");
|
|
368
|
+
if (ECHO_PASSLIKE_RE.test(echoed)) return null;
|
|
369
|
+
}
|
|
370
|
+
weak = true;
|
|
371
|
+
break;
|
|
131
372
|
}
|
|
132
|
-
return cores
|
|
373
|
+
return { cores, weak, echoed };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function parseStage(segment: string, nested: boolean): Chain | null {
|
|
377
|
+
const rest = unwrap(segment);
|
|
378
|
+
if (rest === null) return null;
|
|
379
|
+
const shell = SHELL_C_RE.exec(rest);
|
|
380
|
+
if (shell) return nested ? null : parseChain(shell[1] ?? shell[2], true);
|
|
381
|
+
const core = verifierCore(rest);
|
|
382
|
+
return core ? { cores: [core], weak: false, echoed: null } : null;
|
|
133
383
|
}
|
|
134
384
|
|
|
135
|
-
|
|
385
|
+
/** Strip exit-code-propagating wrapper prefixes and normalize a path-qualified binary; null on a mutating env. */
|
|
386
|
+
function unwrap(segment: string): string | null {
|
|
136
387
|
let rest = segment;
|
|
137
388
|
for (;;) { // terminates: every successful match strictly shortens `rest`
|
|
138
389
|
const assign = ASSIGN_PREFIX_RE.exec(rest);
|
|
@@ -147,7 +398,7 @@ function verifierCore(segment: string): string | null {
|
|
|
147
398
|
rest = rest.slice(env[0].length);
|
|
148
399
|
continue;
|
|
149
400
|
}
|
|
150
|
-
const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest);
|
|
401
|
+
const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest) ?? TIME_PREFIX_RE.exec(rest);
|
|
151
402
|
if (other) {
|
|
152
403
|
rest = rest.slice(other[0].length);
|
|
153
404
|
continue;
|
|
@@ -155,37 +406,110 @@ function verifierCore(segment: string): string | null {
|
|
|
155
406
|
break;
|
|
156
407
|
}
|
|
157
408
|
const bin = BINARY_PATH_RE.exec(rest);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
409
|
+
return bin ? bin[1].slice(bin[1].lastIndexOf("/") + 1) + rest.slice(bin[1].length) : rest;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function verifierCore(rest: string): string | null {
|
|
413
|
+
// Flag exclusions are judged with quote characters turned into spaces: `bun test "--update-snapshots"` must not hide
|
|
414
|
+
// the flag behind a quote. A quoted `-k` expression that merely contains a flag-like word fails closed.
|
|
415
|
+
const flat = rest.replace(/["']/g, " ");
|
|
416
|
+
const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(flat)
|
|
417
|
+
|| /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(flat)
|
|
418
|
+
|| PRETTIER_WRITE_RE.test(flat);
|
|
419
|
+
const task = TASK_DENY.find(entry => entry.head.test(rest));
|
|
420
|
+
return !shortNoOp && VERIFY_SIGNAL_RE.test(rest) && !NONEXECUTING_VERIFY_FLAG_RE.test(flat)
|
|
421
|
+
&& !MUTATING_VERIFY_FLAG_RE.test(flat) && !MUTATING_VERIFY_SCRIPT_RE.test(flat)
|
|
422
|
+
&& !task?.deny.some(re => re.test(flat)) ? rest : null;
|
|
163
423
|
}
|
|
164
424
|
|
|
165
425
|
/**
|
|
166
426
|
* True only when the command itself is a recognized verifier invocation (or a pure `&&`
|
|
167
427
|
* chain of them, optionally led by `cd <path> &&` and wrapped in exit-code-propagating
|
|
168
|
-
* prefixes — see {@link
|
|
169
|
-
* print words such as "tests passed".
|
|
428
|
+
* prefixes — see {@link parseVerifier}, which also admits the weak forms). Output is never what
|
|
429
|
+
* RECOGNIZES a command: arbitrary commands can print words such as "tests passed".
|
|
170
430
|
*/
|
|
171
431
|
export function isVerificationSignal(cmd: string): boolean {
|
|
172
|
-
return
|
|
432
|
+
return parseVerifier(cmd) !== null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Output evidence. Every pattern is anchored to a summary position so a test TITLE cannot impersonate a summary.
|
|
436
|
+
const PASS_LINE_RES: readonly RegExp[] = [
|
|
437
|
+
// bun "12 pass", mocha "12 passing", jest "Tests: 1 failed, 4 passed", vitest "Tests 5 passed", pytest "== 5 passed",
|
|
438
|
+
// playwright "5 passed (3s)", deno "ok | 3 passed"
|
|
439
|
+
/^[ \t=]*(?:ok[ \t]*\|[ \t]*)?(?:Tests?:?[ \t]+)?(?:\d+ [A-Za-z]+(?:[ \t]*[,|][ \t]*|[ \t]+))*[1-9]\d* pass(?:ed|ing)?\b/,
|
|
440
|
+
/^\s*(?:\u2139|#)\s*pass\s+[1-9]\d*\b/, // node --test
|
|
441
|
+
/^ok\s+\S+\s+(?:\(cached\)|[\d.]+s)/, // go (the `[no tests ...]` forms are excluded by the caller)
|
|
442
|
+
/^\s*--- PASS:/, // go test -v
|
|
443
|
+
/^\s*[1-9]\d* examples?, 0 failures?/, // rspec
|
|
444
|
+
/Tests run:\s*[1-9]\d*,\s*Failures:\s*0,\s*Errors:\s*0/, // maven
|
|
445
|
+
/^\s*Passed!.*\bPassed:\s*[1-9]\d*/, // dotnet
|
|
446
|
+
/^OK \([1-9]\d* tests?,/, // phpunit
|
|
447
|
+
];
|
|
448
|
+
const FAILURE_LINE_RES: readonly RegExp[] = [
|
|
449
|
+
/(?:^|[^\d])[1-9]\d*\s+(?:fail(?:ed|ing|ures?)?|errors?)\b/i,
|
|
450
|
+
/\b(?:Failures|Errors|Failed):\s*[1-9]/,
|
|
451
|
+
/^\s*\(fail\)/,
|
|
452
|
+
/^\s*(?:FAIL|FAILED|ERROR)\b/,
|
|
453
|
+
/^\s*--- FAIL/,
|
|
454
|
+
/^\s*panic:|panicked at/,
|
|
455
|
+
/test result: FAILED|\.\.\. FAILED\b|^failures:\s*$/, // cargo
|
|
456
|
+
/^\s*error: .*(?:target|test).*failed|\bcould not compile\b/i, // cargo: the FAILED line can scroll out of a `tail` window
|
|
457
|
+
/^\[ERROR\]|BUILD (?:FAILED|FAILURE)|^FAILURE:/, // maven / gradle
|
|
458
|
+
/Tests run:.*(?:Failures|Errors): [1-9]/, // maven
|
|
459
|
+
/\b(?:ERRORS|FAILURES)!/, // phpunit
|
|
460
|
+
/^\s*Failed!/, // dotnet
|
|
461
|
+
/^\s*(?:\u2139|#)\s*fail\s+[1-9]/,
|
|
462
|
+
/\bSegmentation fault\b|\bBus error\b|\bKilled\b|\btimed out\b/,
|
|
463
|
+
/\berror TS\d+/,
|
|
464
|
+
];
|
|
465
|
+
/** A line a runner prints for a test that PASSED: its title may legitimately mention "errors" or "failed". */
|
|
466
|
+
const PASSED_TITLE_RE = /^\s*(?:\((?:pass|skip|todo)\)|[\u2713\u2714\u221a])/;
|
|
467
|
+
/** Explicit "nothing ran" output; only consulted when no positive pass summary is present. */
|
|
468
|
+
const NO_TESTS_RE = /(?:^|[^\d])0 pass(?:ed|ing)?\b|(?:^|[^\d])0 examples?\b|\bno tests? (?:ran|found|to run|executed)\b|\bno test files found\b|\bNo test is available\b|\bcollected 0 items\b|\bRan 0 tests\b|\bTests run:\s*0\b|(?:\u2139|#)\s*pass\s+0\b/i;
|
|
469
|
+
const ANSI_RE = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
|
|
470
|
+
|
|
471
|
+
function hasPassSummary(text: string): boolean {
|
|
472
|
+
if (/\bBUILD SUCCESSFUL\b/.test(text) && !/\bNO-SOURCE\b/.test(text)) return true; // gradle prints no counts
|
|
473
|
+
if (/test result: ok\.\s+[1-9]\d* passed/.test(text)) return true;
|
|
474
|
+
return text.split(/\r?\n/).some(line => PASS_LINE_RES.some(re => re.test(line)) && !/\[no tests to run\]|\[no test files\]/i.test(line));
|
|
173
475
|
}
|
|
174
476
|
|
|
175
477
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
|
|
478
|
+
* Drop the text a trailing `echo` printed so it cannot supply (or hide) a pass line. The bash tool joins stdout then
|
|
479
|
+
* stderr, so the echo can sit mid-output: every whole-line occurrence goes, and so does a glued trailing suffix.
|
|
480
|
+
*/
|
|
481
|
+
function withoutEchoText(text: string, echoed: string | null): string {
|
|
482
|
+
if (!echoed) return text;
|
|
483
|
+
const kept = text.split(/\r?\n/).filter(line => line.trim() !== echoed).join("\n");
|
|
484
|
+
const trimmed = kept.trimEnd();
|
|
485
|
+
return trimmed.endsWith(echoed) ? trimmed.slice(0, trimmed.length - echoed.length) : kept;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Output-side judgment of a verifier run the caller already knows succeeded (exit 0).
|
|
490
|
+
* - Go and Cargo need real test-run evidence, judged on the verifier cores so a wrapped
|
|
491
|
+
* `timeout 60 go test ./...` or `cd x && cargo test` still needs it.
|
|
492
|
+
* - A direct test runner whose output says nothing ran (`0 pass`, "no tests ran") is not evidence unless a positive
|
|
493
|
+
* pass summary is also present; silent linters and empty output stay evidence on tool success alone.
|
|
494
|
+
* - A weak form (see the grammar above) has no trustworthy exit status: it needs a positive pass summary and no
|
|
495
|
+
* failure marker anywhere in the output.
|
|
179
496
|
*/
|
|
180
497
|
export function verificationProducedEvidence(cmd: string, output: string): boolean {
|
|
181
|
-
const
|
|
498
|
+
const parsed = parseVerifier(cmd);
|
|
499
|
+
const cores = parsed?.cores ?? [cmd.trim()];
|
|
500
|
+
const text = withoutEchoText(output.replace(ANSI_RE, ""), parsed?.echoed ?? null);
|
|
182
501
|
if (cores.some(c => /^go\s+test(?:\s|$)/.test(c))) {
|
|
183
|
-
const summaries =
|
|
502
|
+
const summaries = text.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
|
|
184
503
|
if (!summaries.some(line => !/\[no tests to run\]|\[no test files\]/i.test(line))) return false;
|
|
185
504
|
}
|
|
186
505
|
if (cores.some(c => /^cargo\s+test(?:\s|$)/.test(c))
|
|
187
|
-
&& ![...
|
|
188
|
-
return true;
|
|
506
|
+
&& ![...text.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0)) return false;
|
|
507
|
+
if (!parsed) return true;
|
|
508
|
+
if (!parsed.trusted) {
|
|
509
|
+
const failed = text.split(/\r?\n/).some(line => !PASSED_TITLE_RE.test(line) && FAILURE_LINE_RES.some(re => re.test(line)));
|
|
510
|
+
return hasPassSummary(text) && !failed;
|
|
511
|
+
}
|
|
512
|
+
return !(cores.some(c => TEST_CORE_RE.test(c)) && !hasPassSummary(text) && NO_TESTS_RE.test(text));
|
|
189
513
|
}
|
|
190
514
|
|
|
191
515
|
/** Marker that prefixes a candidate answer the completion gate never accepted. */
|
|
@@ -168,7 +168,21 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
|
|
|
168
168
|
if (mount.Type === "tmpfs" && mount.Destination === "/tmp") continue;
|
|
169
169
|
const normalizedSource = typeof mount.Source === "string" ? normalizeMountSource(mount.Source) : "";
|
|
170
170
|
if (mount.Type !== "bind" || !normalizedSource || !path.isAbsolute(normalizedSource)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
|
|
171
|
-
|
|
171
|
+
let source: string;
|
|
172
|
+
try { source = await canonicalSandboxPath(normalizedSource); }
|
|
173
|
+
catch (error) {
|
|
174
|
+
// A leaked/exited managed container whose temp workspace was deleted must not wedge every later launch.
|
|
175
|
+
// Still guard identical-or-disjoint overlap: resolve its nearest existing ancestor (so aliases such as
|
|
176
|
+
// macOS /var -> /private/var compare equal to canonical candidates), and compare lexically when even that
|
|
177
|
+
// cannot resolve (ENOTDIR). Every other failure (EACCES, loops, invalid metadata) still fails closed.
|
|
178
|
+
const lost = (value: unknown): boolean => { const code = (value as NodeJS.ErrnoException).code; return code === "ENOENT" || code === "ENOTDIR"; };
|
|
179
|
+
if (!lost(error)) throw error;
|
|
180
|
+
try { source = await canonicalSandboxPath(normalizedSource, false); }
|
|
181
|
+
catch (inner) {
|
|
182
|
+
if (!lost(inner)) throw inner;
|
|
183
|
+
source = path.resolve(normalizedSource);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
172
186
|
if (sources.some(candidate => candidate !== source && (sandboxPathContains(candidate, source) || sandboxPathContains(source, candidate)))) {
|
|
173
187
|
throw new Error("SANDBOX_MOUNT_OVERLAP: managed mount roots must be identical or disjoint");
|
|
174
188
|
}
|