@bjoernrochel/sevro 0.1.0-rc.15 → 0.1.0-rc.17

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.
@@ -106,8 +106,16 @@ trial in the Git fixture after source history and working-tree files are built,
106
106
  before extension artifacts are mounted or the host starts. The command is never
107
107
  interpreted through a shell by Sevro; an extension may explicitly select a
108
108
  shell executable. `{{sevro.project}}` and `{{sevro.workspace}}` in environment
109
- values resolve to the declared roots at execution time. The symbolic values and
110
- command enter fixture and comparison identity, while retained configuration
109
+ values resolve to the declared roots at execution time. Setup's `HOME` is the
110
+ candidate commands' private trial home, outside the Git fixture. Ordinary
111
+ `$HOME` paths can initialize directories and seed prior state before the candidate
112
+ starts, including without a runtime configuration file. This home is separate
113
+ from host authentication and native configuration, other trials, and grader homes.
114
+ Setup's `TMPDIR` remains disposable and is removed when setup finishes; the shared
115
+ home survives through candidate execution and grading, then is removed even on
116
+ setup failure, cancellation, or dry runs. Trusted injected hosts must use the
117
+ supplied `candidateRuntimeRoot` and runtime policy to preserve this boundary.
118
+ The symbolic values and command enter fixture and comparison identity, while retained configuration
111
119
  records only their digest. Setup has a two-minute limit, inherits no credential
112
120
  environment variables, and its output is discarded. Failure or interruption
113
121
  stops the run before host execution; it cannot become a passing assessment.
@@ -23,10 +23,10 @@ Local installation records the selected version in the project lockfile.
23
23
 
24
24
  ## Current release
25
25
 
26
- The current release is `0.1.0-rc.15`. Install this exact version:
26
+ The current release is `0.1.0-rc.17`. Install this exact version:
27
27
 
28
28
  ```sh
29
- bun add --exact @bjoernrochel/sevro@0.1.0-rc.15
29
+ bun add --exact @bjoernrochel/sevro@0.1.0-rc.17
30
30
  ```
31
31
 
32
32
  <!-- sevro-current-release:end -->
@@ -5,7 +5,8 @@ retain an observation with that ID. It exposes host facts for extension policy:
5
5
 
6
6
  - `method: native_control_calls`;
7
7
  - ordered `calls` containing only `ordinal`, `namespace`, and `name`;
8
- - `acceptedAgentCount`, a verified Codex acceptance count or `null` on Claude;
8
+ - `acceptedAgentCount`, a verified direct native Codex acceptance count when
9
+ overall coverage is complete, otherwise `null` (also `null` on Claude);
9
10
  - `submittedExecCalls`, the Codex submitted-code count or `null` on Claude; and
10
11
  - `truncated`, which is `false` for a complete record and may be unknown (`null`)
11
12
  for an incomplete Codex record.
@@ -16,6 +17,18 @@ invocation attempt, not success. Extensions decide which calls are prohibited.
16
17
  An unknown acceptance count cannot establish agent acceptance.
17
18
 
18
19
  Codex projects labels and counts from its bound native-session observation.
20
+ It additionally retains `directAcceptedAgentCount`, the number of positively
21
+ correlated direct native spawn acceptances in that source, independently of exec
22
+ inspection. This is an observed count, not a total across unknown launch paths.
23
+ `coverage.nativeCalls` records native-session completeness;
24
+ `coverage.execInspection` records whether all submitted exec sources were
25
+ understood and matched to the direct submission inventory;
26
+ `coverage.acceptedAgents: direct_native_calls` names the scope of both acceptance
27
+ counts. Accepted launches through exec are not included in these direct counts.
28
+ When native capture is incomplete, even a zero observed count cannot establish
29
+ absence. Native labels remain usable as positive attempt evidence regardless
30
+ of an unrelated exec inspection gap. A rejected attempt is never an acceptance.
31
+
19
32
  When submitted code is present, both observations also retain `execCalls`:
20
33
  one receipt per native `exec` submission, with its native `ordinal`, inspection
21
34
  `completeness`, and ordered `calls` containing `index`, `namespace`, and `name`.
@@ -27,25 +40,46 @@ programs without executing them. It accepts literal tool calls, constant result
27
40
  bindings, template interpolation and `+` composition of inspected expressions,
28
41
  direct `text`/`store` output, and literal `Promise.all`/`allSettled` batches.
29
42
  Batch results may use flat constant array bindings or result-only
30
- `forEach(text)` or `for ... of` output. Destructuring defaults, rest elements,
43
+ `forEach(text)` or `for ... of` output, including blocks and conditional
44
+ result-field selection. `JSON.stringify`, `String`, object spread/shorthand,
45
+ and nested result fields are supported. Numeric output loops must use the
46
+ bounded shape `for (let i = 0; i < results.length; i++)`, indexing the known
47
+ batch with that loop variable. Output loops and conditional expressions cannot
48
+ invoke tools. Destructuring defaults, rest elements,
31
49
  holes, nested bindings, duplicate names, and shadowed globals remain unsupported.
32
- It requires one
33
- unique, later native completion for the same call ID with an intact successful
34
- script envelope. Tool argument strings, comments and printed tool-looking text
50
+ Tool argument strings, comments and printed tool-looking text
35
51
  do not become call labels. A rejected tool request returned by a completed
36
52
  script still appears in its submitted-call inventory. This inventory proves
37
53
  submission of those requests, not successful effects or acceptance; the existing
38
54
  acceptance count describes independently correlated direct native spawns.
39
55
 
40
56
  Dynamic lookup, aliases, shadowed globals, callbacks that invoke tools,
41
- conditional code, nested executors, malformed or truncated results, duplicate
42
- receipts, and missing completion keep inspection partial. Source is bounded at
57
+ conditional tool execution, and nested executors keep source inspection partial.
58
+ Each receipt's `inspectionReason` is `null` on success, or `unsupported_source`,
59
+ `malformed_source`, `source_limit`, or `foreign_namespace`. Unsupported syntax
60
+ and expression/call-budget exhaustion use `unsupported_source`.
61
+ Source is bounded at
43
62
  64 KiB, traversed expressions at 2,048, and calls at 128 per submission; tool
44
63
  labels are bounded at 64 characters. Private source and output bytes are discarded.
45
64
  The portable observation becomes partial if any submission is uninspected or
46
- receipt counts/ordinals disagree with the native list. Incomplete counts remain
47
- `null`. Native-session completeness still describes session capture separately
48
- from each submission's inspection completeness.
65
+ receipt counts/ordinals disagree with the native list. `acceptedAgentCount`
66
+ remains `null` in that case, while native coverage, direct acceptance facts and
67
+ the native `submittedExecCalls` count remain independently available.
68
+ `submittedExecCalls` and `truncated` depend on native capture, not exec output.
69
+
70
+ Receipt `completeness` describes source inspection, independently of execution
71
+ or displayed output. `execution` is `completed`, `failed`, or `incomplete`;
72
+ `output` is `complete`, `truncated`, or `unavailable`. Output evidence requires
73
+ a unique later output bound to the same native call ID, with recognized text
74
+ blocks. `complete` describes the displayed receipt, not the whole program's
75
+ execution. Execution status additionally requires a successful or failed script
76
+ header. Missing, ambiguous or
77
+ malformed output yields `incomplete`/`unavailable`; a yielded or unrecognized
78
+ header stays `incomplete`. Displayed truncation does not change source coverage.
79
+ A fully inspected source with no launch call proves that absence even if its
80
+ shell output was truncated or the exec cell failed. Source inspection cannot
81
+ prove execution or success. Missing fields in historical receipts are unknown;
82
+ historical artifacts retain their original results and meanings.
49
83
 
50
84
  Claude records every tool-use label from the bounded stream, with namespace
51
85
  `claude`, without filtering to Skill or Agent. It requires a completed successful
@@ -55,11 +89,12 @@ Tool-looking text in arguments or output does not become a native call.
55
89
 
56
90
  Consumers must verify the source, completeness, shape, ordering, and counts
57
91
  before using an empty call list to establish absence. Missing or partial
58
- evidence cannot establish a negative assertion. For Codex, consumers must
92
+ source coverage cannot establish a negative assertion. For Codex, consumers must
59
93
  inspect every `execCalls` receipt in addition to the direct `calls` list,
60
94
  validate receipt ordinals against the direct `exec` submissions, and require
61
95
  all receipts to be complete with counts matching `submittedExecCalls`.
62
- They must examine submitted labels for prohibited controls even when
96
+ They can use positively observed native attempt labels before requiring complete
97
+ coverage for an absence pass. They must examine submitted labels for prohibited controls even when
63
98
  `acceptedAgentCount` is zero. Older consumers that require
64
99
  `submittedExecCalls === 0` remain conservative but cannot use this new evidence
65
100
  until they adopt receipt validation.
@@ -260,7 +260,10 @@ so zsh here-documents use their existing private temporary storage while shared
260
260
  host temporary directories remain denied. `TMPPREFIX` is runner-owned and cannot
261
261
  be inherited or set through runtime configuration.
262
262
  Sevro supplies private `HOME` and temporary storage and sets `UV_OFFLINE=1`
263
- and `PYTHONDONTWRITEBYTECODE=1`. Candidate, hook, checker, semantic, and advisory
263
+ and `PYTHONDONTWRITEBYTECODE=1`. Fixture setup uses the candidate commands'
264
+ private `HOME`, so `$HOME` paths initialize the same state the candidate will
265
+ use. Its temporary directory stays disposable.
266
+ Candidate, hook, checker, semantic, and advisory
264
267
  state are separate. With a runtime policy, native candidate, semantic, and
265
268
  advisory roots live in a unique runner-owned host directory outside the Git
266
269
  fixture. The root contains `home`, `tmp`, and declared seed slots; plugins can
@@ -270,7 +273,8 @@ configuration, and live transcript sources. Checker and hook caches also live
270
273
  outside candidate-controlled workspace state. Private roots survive native
271
274
  continuation turns and children, then are removed with their owning trial or
272
275
  host state after owned hooks terminate. Opted-in candidate artifact runtimes
273
- remain alive through grading as described below. Runs without a selected runtime policy
276
+ remain alive through grading as described below. Runs with neither fixture setup
277
+ nor a selected runtime policy
274
278
  retain their previous writable-state locations.
275
279
 
276
280
  Claude refuses managed workspace or runtime namespaces overlapping its
@@ -297,7 +301,8 @@ candidate's private home after execution.
297
301
  }
298
302
  ```
299
303
 
300
- The example permits only the candidate-created `~/.darrow/reviews` subtree.
304
+ The example permits only the `~/.darrow/reviews` subtree, which may be initialized
305
+ by trusted fixture setup or created by the candidate.
301
306
  Its original absolute path reaches isolated shell checks and native
302
307
  semantic/advisory commands as `SEVRO_CANDIDATE_ARTIFACT_REVIEWS`. Graders keep
303
308
  their own private `HOME`; use the supplied variable to read candidate evidence.
@@ -313,12 +318,31 @@ to 16,384 entries, including its root. Missing directories, symlinked ancestors,
313
318
  descendant symlinks, hardlinks and special files make grading an error; missing
314
319
  evidence cannot produce a pass.
315
320
 
321
+ Initialize an empty directory or seed prior Review state in the extension's
322
+ trusted `fixtureSetup` command before the candidate starts. For example:
323
+
324
+ ```sh
325
+ mkdir -p "$HOME/.darrow/reviews"
326
+ printf '%s\n' '{"verdict":"passed"}' > "$HOME/.darrow/reviews/prior.json"
327
+ ```
328
+
329
+ Setup and candidate commands share this private trial home, including when no
330
+ artifact grants or runtime file are selected. Graders keep their own homes and
331
+ receive only declared read-only artifact grants. To test missing reports or
332
+ absence of new artifacts, initialize the directory without writing a report;
333
+ an empty safe directory reaches ordinary task checks. Missing required reports
334
+ can then fail those checks, while negative cases can assert the directory is
335
+ empty. Sevro does not automatically create declared artifact directories: if
336
+ setup and candidate both leave one absent, or replace it with an unsafe path,
337
+ grading still errors. Setup state is temporary and is removed with its trial,
338
+ including setup failure, cancellation, and dry runs.
339
+
316
340
  The grant permits reads and denies writes, creation, deletion and renaming.
317
341
  Credentials, other home contents and peer role/trial state stay protected.
318
342
  Ordinary `filesystem.readOnlyRoots` cannot select this private-state exception.
319
343
  Artifact grants and native transcript access are independent opt-ins.
320
344
 
321
- The trial allocates the retained runtime before launching its candidate and
345
+ The trial allocates the retained runtime before fixture setup and
322
346
  removes it after grading, advisory review and evidence persistence, including
323
347
  failure and cancellation paths. Review state remains outside the Git fixture.
324
348
  These artifacts are temporary; declaring access does not copy their bytes into
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjoernrochel/sevro",
3
- "version": "0.1.0-rc.15",
3
+ "version": "0.1.0-rc.17",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "A standalone evaluation runner for agentic coding capabilities",
6
6
  "type": "module",
@@ -65,10 +65,12 @@ function validArtifactPath(path: string): boolean {
65
65
  /** Allocate before host execution so exceptions cannot lose cleanup ownership. */
66
66
  export async function allocateCandidateRuntime(
67
67
  policy: RuntimePolicy | undefined,
68
+ retainHome = false,
68
69
  ): Promise<string | undefined> {
69
70
  const directories = policy?.artifactDirectories ?? [];
70
71
  const codexState = codexStateEnabled(policy);
71
- if (!directories.length && !codexState) return undefined;
72
+ if (!candidateRuntimeRequired(directories, codexState, retainHome))
73
+ return undefined;
72
74
  requireArtifactDirectories(directories);
73
75
  const root = await allocateNativeState("candidate-runtime-");
74
76
  leases.set(root, directories);
@@ -76,6 +78,14 @@ export async function allocateCandidateRuntime(
76
78
  return root;
77
79
  }
78
80
 
81
+ function candidateRuntimeRequired(
82
+ directories: RuntimeArtifactDirectory[],
83
+ codexState: boolean,
84
+ retainHome: boolean,
85
+ ): boolean {
86
+ return directories.length > 0 || codexState || retainHome;
87
+ }
88
+
79
89
  export async function requireCandidateRuntime(
80
90
  root: string | undefined,
81
91
  role?: RuntimeRole,
@@ -181,6 +181,7 @@ export async function prepareTrialWorkspace(
181
181
  invocation: PreparedInvocation,
182
182
  workspaces: RunWorkspaceReservations,
183
183
  trial: number,
184
+ candidateRuntimeRoot?: string,
184
185
  ) {
185
186
  const reservation = workspaces.candidate(trial);
186
187
  workspaces.retain(reservation);
@@ -195,6 +196,7 @@ export async function prepareTrialWorkspace(
195
196
  fixture.projectRoot,
196
197
  options.signal,
197
198
  reservation,
199
+ candidateRuntimeRoot,
198
200
  ).catch((error: unknown) => {
199
201
  if (
200
202
  !options.signal?.aborted ||
@@ -203,12 +205,13 @@ export async function prepareTrialWorkspace(
203
205
  throw error;
204
206
  return null;
205
207
  });
206
- return trialWorkspaceDetails(
208
+ const details = await trialWorkspaceDetails(
207
209
  options.case,
208
210
  invocation,
209
211
  reservation,
210
212
  workspace,
211
213
  );
214
+ return { ...details, candidateRuntimeRoot };
212
215
  }
213
216
 
214
217
  async function trialWorkspaceDetails(
@@ -231,6 +231,7 @@ class EvaluationTrial {
231
231
  private readonly fixture: Awaited<ReturnType<typeof prepareTrialWorkspace>>,
232
232
  ) {
233
233
  this.state = initialTrialState(options.dry);
234
+ this.state.candidateRuntimeRoot = fixture.candidateRuntimeRoot;
234
235
  this.evidence = new TrialEvidenceWriter({
235
236
  runDir: context.resources.runDir,
236
237
  trial,
@@ -296,7 +297,7 @@ class EvaluationTrial {
296
297
  workspace: this.fixture.workspace,
297
298
  condition: this.options.condition,
298
299
  fixtureBinDir: this.fixture.fixtureBinDir,
299
- runtimePolicy: this.options.runtimePolicy,
300
+ runtimePolicy: this.candidateRuntimePolicy(),
300
301
  instrumentation: snapshotInstrumentation(
301
302
  this.context.prepared.requestedInstrumentation,
302
303
  ),
@@ -313,6 +314,16 @@ class EvaluationTrial {
313
314
  };
314
315
  }
315
316
 
317
+ private candidateRuntimePolicy() {
318
+ if (this.options.runtimePolicy) return this.options.runtimePolicy;
319
+ if (!this.context.prepared.fixture.fixtureSetup) return undefined;
320
+ return {
321
+ format: "sevro.runtime.v1" as const,
322
+ environment: {},
323
+ readOnlyRoots: [],
324
+ };
325
+ }
326
+
316
327
  private candidateReservedIds(): Set<string> {
317
328
  return new Set([
318
329
  "sevro.observation.final-message",
@@ -387,9 +398,6 @@ class EvaluationTrial {
387
398
  const started = performance.now();
388
399
  try {
389
400
  ensureNotCancelled(this.options.signal);
390
- this.state.candidateRuntimeRoot = await allocateCandidateRuntime(
391
- this.options.runtimePolicy,
392
- );
393
401
  this.state.hostResult = await this.options.host.run(
394
402
  this.candidateRequest(),
395
403
  );
@@ -781,7 +789,6 @@ class EvaluationTrial {
781
789
  evaluationProgress(this.options, stage, status, this.trial);
782
790
  }
783
791
  private async cleanup(): Promise<void> {
784
- await releaseCandidateRuntime(this.state.candidateRuntimeRoot);
785
792
  if (this.state.candidateTranscriptRoot)
786
793
  await rm(this.state.candidateTranscriptRoot, {
787
794
  recursive: true,
@@ -819,21 +826,30 @@ export async function executeEvaluationTrials(
819
826
  signal: options.signal,
820
827
  },
821
828
  async (trial, stopAdmission) => {
822
- const fixture = await prepareTrialWorkspace(
823
- options,
824
- context.prepared.fixture,
825
- context.prepared.invocation,
826
- context.resources.workspaces,
827
- trial,
829
+ const candidateRuntimeRoot = await allocateCandidateRuntime(
830
+ options.runtimePolicy,
831
+ Boolean(context.prepared.fixture.fixtureSetup),
828
832
  );
829
- evaluationProgress(options, "preparation", "completed", trial);
830
- return new EvaluationTrial(
831
- options,
832
- context,
833
- trial,
834
- stopAdmission,
835
- fixture,
836
- ).run();
833
+ try {
834
+ const fixture = await prepareTrialWorkspace(
835
+ options,
836
+ context.prepared.fixture,
837
+ context.prepared.invocation,
838
+ context.resources.workspaces,
839
+ trial,
840
+ candidateRuntimeRoot,
841
+ );
842
+ evaluationProgress(options, "preparation", "completed", trial);
843
+ return await new EvaluationTrial(
844
+ options,
845
+ context,
846
+ trial,
847
+ stopAdmission,
848
+ fixture,
849
+ ).run();
850
+ } finally {
851
+ await releaseCandidateRuntime(candidateRuntimeRoot);
852
+ }
837
853
  },
838
854
  );
839
855
  }
@@ -36,6 +36,7 @@ export async function createFixture(
36
36
  projectRoot: string,
37
37
  signal?: AbortSignal,
38
38
  reservedWorkspace?: string,
39
+ candidateRuntimeRoot?: string,
39
40
  ): Promise<string> {
40
41
  const paths = inlineFixturePaths(fixture, generated, repository);
41
42
  const workspace = reservedWorkspace ?? (await allocateWorkspace());
@@ -53,7 +54,13 @@ export async function createFixture(
53
54
  workspace,
54
55
  );
55
56
  await writeInlineFixtureFiles(paths, workspace);
56
- await setupFixture(setup, workspace, projectRoot, signal);
57
+ await setupFixture(
58
+ setup,
59
+ workspace,
60
+ projectRoot,
61
+ signal,
62
+ candidateRuntimeRoot,
63
+ );
57
64
  await mountPreparationArtifacts(artifacts, workspace);
58
65
  await excludePreparationArtifacts(artifacts, workspace);
59
66
  await fixtureBinDirectory(workspace);
@@ -131,6 +138,7 @@ async function setupFixture(
131
138
  workspace: string,
132
139
  projectRoot: string,
133
140
  signal?: AbortSignal,
141
+ candidateRuntimeRoot?: string,
134
142
  ): Promise<void> {
135
143
  if (!setup) return;
136
144
  await runFixtureSetup(setup, {
@@ -138,6 +146,7 @@ async function setupFixture(
138
146
  projectRoot,
139
147
  fixtureBinDir: (await fixtureBinDirectory(workspace)) ?? undefined,
140
148
  signal,
149
+ candidateRuntimeRoot,
141
150
  });
142
151
  }
143
152
 
@@ -1,7 +1,8 @@
1
- import { mkdtemp, rm } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { delimiter, isAbsolute, join } from "node:path";
4
4
  import { isRecord } from "../value-guards";
5
+ import { requireCandidateRuntime } from "../candidate-artifacts";
5
6
  import {
6
7
  ProcessDeadline,
7
8
  terminateOwnedProcessGroup,
@@ -112,6 +113,7 @@ interface FixtureSetupOptions {
112
113
  projectRoot: string;
113
114
  fixtureBinDir?: string;
114
115
  signal?: AbortSignal;
116
+ candidateRuntimeRoot?: string;
115
117
  }
116
118
 
117
119
  function setupPath(directory: string | undefined): string {
@@ -134,7 +136,9 @@ function executionEnvironment(
134
136
  );
135
137
  return {
136
138
  PATH: setupPath(options.fixtureBinDir),
137
- HOME: home,
139
+ HOME: options.candidateRuntimeRoot
140
+ ? join(options.candidateRuntimeRoot, "home")
141
+ : home,
138
142
  TMPDIR: home,
139
143
  LANG: process.env.LANG ?? "C",
140
144
  GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
@@ -201,6 +205,12 @@ export async function runFixtureSetup(
201
205
  options: FixtureSetupOptions,
202
206
  ): Promise<void> {
203
207
  if (options.signal?.aborted) throw new Error("fixture setup cancelled");
208
+ await requireCandidateRuntime(options.candidateRuntimeRoot);
209
+ if (options.candidateRuntimeRoot)
210
+ await mkdir(join(options.candidateRuntimeRoot, "home"), {
211
+ recursive: true,
212
+ mode: 0o700,
213
+ });
204
214
  const home = await mkdtemp(join(tmpdir(), "sevro-setup-"));
205
215
  const execution = new FixtureSetupExecution(setup, options, home);
206
216
  try {
@@ -21,6 +21,14 @@ export interface ExecCallReceipt {
21
21
  ordinal: number;
22
22
  completeness: "complete" | "partial";
23
23
  calls: SubmittedCall[];
24
+ inspectionReason?:
25
+ | "unsupported_source"
26
+ | "malformed_source"
27
+ | "source_limit"
28
+ | "foreign_namespace"
29
+ | null;
30
+ execution?: "completed" | "failed" | "incomplete";
31
+ output?: "complete" | "truncated" | "unavailable";
24
32
  }
25
33
 
26
34
  function toolNamespace(name: string): SubmittedCall["namespace"] {
@@ -77,7 +85,15 @@ function member(node: ts.Expression, owner: string, names: string[]): boolean {
77
85
  function localName(node: ts.BindingName): string | undefined {
78
86
  const ts = typescript();
79
87
  if (!ts.isIdentifier(node)) return undefined;
80
- return ["tools", "text", "store", "Promise", "load"].includes(node.text)
88
+ return [
89
+ "tools",
90
+ "text",
91
+ "store",
92
+ "Promise",
93
+ "load",
94
+ "JSON",
95
+ "String",
96
+ ].includes(node.text)
81
97
  ? undefined
82
98
  : node.text;
83
99
  }
@@ -85,6 +101,7 @@ class Submission {
85
101
  readonly calls: SubmittedCall[] = [];
86
102
  private readonly locals = new Set<string>();
87
103
  private readonly collections = new Set<string>();
104
+ private readonly indices = new Set<string>();
88
105
  private nodes = 0;
89
106
  expression(node: ts.Expression): boolean {
90
107
  const ts = typescript();
@@ -110,17 +127,42 @@ class Submission {
110
127
  return this.localProperty(node);
111
128
  case ts.isCallExpression(node):
112
129
  return this.call(node);
130
+ case ts.isConditionalExpression(node):
131
+ return [node.condition, node.whenTrue, node.whenFalse].every((part) =>
132
+ this.outputExpression(part),
133
+ );
134
+ case ts.isElementAccessExpression(node):
135
+ return (
136
+ this.collection(node.expression) &&
137
+ typescript().isIdentifier(node.argumentExpression) &&
138
+ this.indices.has(node.argumentExpression.text)
139
+ );
113
140
  default:
114
141
  return false;
115
142
  }
116
143
  }
117
144
  private property(node: ts.ObjectLiteralElementLike): boolean {
118
145
  const ts = typescript();
146
+ if (ts.isSpreadAssignment(node))
147
+ return this.outputExpression(node.expression);
148
+ if (ts.isShorthandPropertyAssignment(node)) return this.shorthand(node);
119
149
  if (!ts.isPropertyAssignment(node) || ts.isComputedPropertyName(node.name))
120
150
  return false;
121
151
  return this.expression(node.initializer);
122
152
  }
153
+ private shorthand(node: ts.ShorthandPropertyAssignment): boolean {
154
+ return !node.objectAssignmentInitializer && this.expression(node.name);
155
+ }
123
156
  private composition(node: ts.BinaryExpression): boolean {
157
+ if (
158
+ [
159
+ typescript().SyntaxKind.EqualsEqualsEqualsToken,
160
+ typescript().SyntaxKind.ExclamationEqualsEqualsToken,
161
+ ].includes(node.operatorToken.kind)
162
+ )
163
+ return (
164
+ this.outputExpression(node.left) && this.outputExpression(node.right)
165
+ );
124
166
  return (
125
167
  node.operatorToken.kind === typescript().SyntaxKind.PlusToken &&
126
168
  this.expression(node.left) &&
@@ -148,13 +190,12 @@ class Submission {
148
190
  if (ts.isVariableStatement(node))
149
191
  return this.declarations(node.declarationList);
150
192
  if (ts.isForOfStatement(node)) return this.outputLoop(node);
193
+ if (ts.isForStatement(node)) return this.numericOutputLoop(node);
151
194
  return ts.isExpressionStatement(node) && this.expression(node.expression);
152
195
  }
153
196
  private localProperty(node: ts.PropertyAccessExpression): boolean {
154
- const ts = typescript();
155
197
  return (
156
- ts.isIdentifier(node.expression) &&
157
- this.locals.has(node.expression.text) &&
198
+ this.expression(node.expression) &&
158
199
  !["constructor", "__proto__", "prototype"].includes(node.name.text)
159
200
  );
160
201
  }
@@ -231,10 +272,43 @@ class Submission {
231
272
  if (!this.collection(node.expression)) return false;
232
273
  if (!ts.isVariableDeclarationList(node.initializer)) return false;
233
274
  const name = loopVariable(node.initializer);
275
+ if (!name || this.locals.has(name)) return false;
276
+ this.locals.add(name);
277
+ const valid = this.outputBody(node.statement);
278
+ this.locals.delete(name);
279
+ return valid;
280
+ }
281
+ private outputExpression(node: ts.Expression): boolean {
282
+ const count = this.calls.length;
283
+ return this.expression(node) && this.calls.length === count;
284
+ }
285
+ private numericOutputLoop(node: ts.ForStatement): boolean {
286
+ const name = numericLoopVariable(node);
287
+ if (!name || this.locals.has(name)) return false;
288
+ const condition = node.condition as ts.BinaryExpression;
289
+ const bound = condition.right as ts.PropertyAccessExpression;
290
+ if (!this.collection(bound.expression)) return false;
291
+ this.locals.add(name);
292
+ this.indices.add(name);
293
+ const valid = this.outputBody(node.statement);
294
+ this.locals.delete(name);
295
+ this.indices.delete(name);
296
+ return valid;
297
+ }
298
+ private outputBody(node: ts.Statement): boolean {
299
+ const ts = typescript();
300
+ if (ts.isBlock(node))
301
+ return node.statements.every((statement) => this.outputBody(statement));
302
+ if (ts.isIfStatement(node)) return this.outputConditional(node);
234
303
  return (
235
- name !== undefined &&
236
- !this.locals.has(name) &&
237
- outputLoopBody(node.statement, name)
304
+ ts.isExpressionStatement(node) && this.outputExpression(node.expression)
305
+ );
306
+ }
307
+ private outputConditional(node: ts.IfStatement): boolean {
308
+ return (
309
+ this.outputExpression(node.expression) &&
310
+ this.outputBody(node.thenStatement) &&
311
+ (!node.elseStatement || this.outputBody(node.elseStatement))
238
312
  );
239
313
  }
240
314
  private collection(node: ts.Expression): boolean {
@@ -244,9 +318,12 @@ class Submission {
244
318
  }
245
319
  function helperCall(node: ts.Expression): boolean {
246
320
  const ts = typescript();
247
- if (ts.isIdentifier(node) && ["text", "store"].includes(node.text))
321
+ if (ts.isIdentifier(node) && ["text", "store", "String"].includes(node.text))
248
322
  return true;
249
- return member(node, "Promise", ["all", "allSettled"]);
323
+ return (
324
+ member(node, "Promise", ["all", "allSettled"]) ||
325
+ member(node, "JSON", ["stringify"])
326
+ );
250
327
  }
251
328
  function namedIdentifier(
252
329
  node: ts.Expression | undefined,
@@ -271,22 +348,69 @@ function loopVariable(list: ts.VariableDeclarationList): string | undefined {
271
348
  ? localName(declaration.name)
272
349
  : undefined;
273
350
  }
274
- function outputLoopBody(statement: ts.Statement, name: string): boolean {
351
+ function numericLoopVariable(node: ts.ForStatement): string | undefined {
275
352
  const ts = typescript();
353
+ if (!node.initializer || !ts.isVariableDeclarationList(node.initializer))
354
+ return undefined;
355
+ const name = numericBinding(node.initializer);
356
+ return name && numericLoopBounds(node, name) ? name : undefined;
357
+ }
358
+ function numericBinding(list: ts.VariableDeclarationList): string | undefined {
276
359
  if (
277
- !ts.isExpressionStatement(statement) ||
278
- !ts.isCallExpression(statement.expression)
360
+ !(list.flags & typescript().NodeFlags.Let) ||
361
+ list.declarations.length !== 1
279
362
  )
280
- return false;
281
- const call = statement.expression;
363
+ return undefined;
364
+ const declaration = list.declarations[0];
365
+ return declaration && zeroInitializer(declaration.initializer)
366
+ ? localName(declaration.name)
367
+ : undefined;
368
+ }
369
+ function zeroInitializer(node: ts.Expression | undefined): boolean {
370
+ return (
371
+ node !== undefined &&
372
+ typescript().isNumericLiteral(node) &&
373
+ node.text === "0"
374
+ );
375
+ }
376
+ function numericLoopBounds(node: ts.ForStatement, name: string): boolean {
377
+ const ts = typescript();
378
+ const condition = node.condition;
379
+ if (!condition || !ts.isBinaryExpression(condition)) return false;
282
380
  return (
283
- namedIdentifier(call.expression, "text") &&
284
- call.arguments.length === 1 &&
285
- namedIdentifier(call.arguments[0], name)
381
+ numericCondition(condition, name) &&
382
+ numericIncrement(node.incrementor, name)
286
383
  );
287
384
  }
288
- function inspectedSource(code: unknown): Submission | undefined {
289
- if (typeof code !== "string" || code.length > 65536) return undefined;
385
+ function numericCondition(node: ts.BinaryExpression, name: string): boolean {
386
+ return (
387
+ node.operatorToken.kind === typescript().SyntaxKind.LessThanToken &&
388
+ namedIdentifier(node.left, name) &&
389
+ lengthProperty(node.right)
390
+ );
391
+ }
392
+ function lengthProperty(node: ts.Expression): boolean {
393
+ return (
394
+ typescript().isPropertyAccessExpression(node) && node.name.text === "length"
395
+ );
396
+ }
397
+ function numericIncrement(
398
+ node: ts.Expression | undefined,
399
+ name: string,
400
+ ): boolean {
401
+ return (
402
+ node !== undefined &&
403
+ typescript().isPostfixUnaryExpression(node) &&
404
+ node.operator === typescript().SyntaxKind.PlusPlusToken &&
405
+ namedIdentifier(node.operand, name)
406
+ );
407
+ }
408
+ function inspectedSource(code: unknown): {
409
+ submission?: Submission;
410
+ reason: ExecCallReceipt["inspectionReason"];
411
+ } {
412
+ if (typeof code !== "string") return { reason: "unsupported_source" };
413
+ if (code.length > 65536) return { reason: "source_limit" };
290
414
  const ts = typescript();
291
415
  const source = ts.createSourceFile(
292
416
  "submitted.js",
@@ -298,33 +422,26 @@ function inspectedSource(code: unknown): Submission | undefined {
298
422
  const diagnostics = (
299
423
  source as ts.SourceFile & { parseDiagnostics: readonly ts.Diagnostic[] }
300
424
  ).parseDiagnostics;
301
- if (diagnostics.length !== 0) return undefined;
425
+ if (diagnostics.length !== 0) return { reason: "malformed_source" };
302
426
  const submission = new Submission();
303
427
  return source.statements.every((statement) => submission.statement(statement))
304
- ? submission
305
- : undefined;
428
+ ? { submission, reason: null }
429
+ : { reason: "unsupported_source" };
306
430
  }
307
- function completedOutput(output: unknown): boolean {
308
- if (isUnknownArray(output)) return completedBlocks(output);
309
- if (typeof output !== "string") return false;
431
+ function outputBlocks(output: unknown): unknown[] {
432
+ if (isUnknownArray(output)) return output;
433
+ if (typeof output !== "string") return [];
310
434
  try {
311
435
  const blocks: unknown = JSON.parse(output);
312
- if (!isUnknownArray(blocks)) return false;
313
- return completedBlocks(blocks);
436
+ return isUnknownArray(blocks) ? blocks : [];
314
437
  } catch {
315
- return false;
438
+ return [];
316
439
  }
317
440
  }
318
- function completedBlocks(blocks: unknown[]): boolean {
319
- return blocks.every(completeTextBlock) && completedHeader(blocks[0]);
320
- }
321
441
  function completeTextBlock(block: unknown): boolean {
322
442
  if (!isRecord(block) || !["text", "input_text"].includes(String(block.type)))
323
443
  return false;
324
- return (
325
- typeof block.text === "string" &&
326
- !block.text.includes("Warning: truncated output")
327
- );
444
+ return typeof block.text === "string";
328
445
  }
329
446
  function completedHeader(block: unknown): boolean {
330
447
  return (
@@ -335,23 +452,35 @@ function completedHeader(block: unknown): boolean {
335
452
  )
336
453
  );
337
454
  }
338
- function completedSubmission(entry: Entry, entries: Entry[]): boolean {
339
- if (!nativeExecNamespace(entry.payload.namespace)) return false;
455
+ function submissionOutput(entry: Entry, entries: Entry[]): unknown[] {
340
456
  const id = entry.payload.call_id;
341
- if (typeof id !== "string") return false;
457
+ if (typeof id !== "string") return [];
342
458
  const matches = entries.filter((other) => other.payload.call_id === id);
343
- if (matches.length !== 2) return false;
459
+ if (matches.length !== 2) return [];
344
460
  const result = matches.find(
345
461
  (other) => other.payload.type === "custom_tool_call_output",
346
462
  );
347
- return laterCompletion(result, entry.ordinal);
463
+ return result && result.ordinal > entry.ordinal
464
+ ? outputBlocks(result.payload.output)
465
+ : [];
348
466
  }
349
- function laterCompletion(result: Entry | undefined, ordinal: number): boolean {
350
- return (
351
- result !== undefined &&
352
- result.ordinal > ordinal &&
353
- completedOutput(result.payload.output)
467
+ function outputEvidence(
468
+ blocks: unknown[],
469
+ ): Pick<ExecCallReceipt, "execution" | "output"> {
470
+ if (!blocks.length || !blocks.every(completeTextBlock))
471
+ return { execution: "incomplete", output: "unavailable" };
472
+ const header = blocks[0] as { text: string };
473
+ const execution = executionStatus(header);
474
+ const truncated = blocks.some((block) =>
475
+ (block as { text: string }).text.includes("Warning: truncated output"),
354
476
  );
477
+ return { execution, output: truncated ? "truncated" : "complete" };
478
+ }
479
+ function executionStatus(header: {
480
+ text: string;
481
+ }): ExecCallReceipt["execution"] {
482
+ if (completedHeader(header)) return "completed";
483
+ return /^Script failed\b/.test(header.text) ? "failed" : "incomplete";
355
484
  }
356
485
  function nativeExecNamespace(namespace: unknown): boolean {
357
486
  return namespace === undefined || namespace === "functions";
@@ -363,14 +492,25 @@ export function codexExecCallReceipts(entries: Entry[]): ExecCallReceipt[] {
363
492
  ({ payload }) =>
364
493
  payload.type === "custom_tool_call" && payload.name === "exec",
365
494
  )
366
- .map((entry) => {
367
- const submission = inspectedSource(entry.payload.input);
368
- const complete =
369
- submission !== undefined && completedSubmission(entry, entries);
370
- return {
371
- ordinal: entry.ordinal,
372
- completeness: complete ? "complete" : "partial",
373
- calls: submission?.calls ?? [],
374
- };
375
- });
495
+ .map((entry) => ({
496
+ ordinal: entry.ordinal,
497
+ ...inspectionEvidence(entry),
498
+ ...outputEvidence(submissionOutput(entry, entries)),
499
+ }));
500
+ }
501
+ function inspectionEvidence(
502
+ entry: Entry,
503
+ ): Pick<ExecCallReceipt, "completeness" | "calls" | "inspectionReason"> {
504
+ if (!nativeExecNamespace(entry.payload.namespace))
505
+ return {
506
+ completeness: "partial",
507
+ calls: [],
508
+ inspectionReason: "foreign_namespace",
509
+ };
510
+ const inspected = inspectedSource(entry.payload.input);
511
+ return {
512
+ completeness: inspected.submission ? "complete" : "partial",
513
+ calls: inspected.submission?.calls ?? [],
514
+ inspectionReason: inspected.reason,
515
+ };
376
516
  }
@@ -14,6 +14,12 @@ export interface NativeControlObservation {
14
14
  method: "native_control_calls";
15
15
  calls: Call[];
16
16
  acceptedAgentCount: number | null;
17
+ directAcceptedAgentCount?: number;
18
+ coverage?: {
19
+ nativeCalls: NativeControlObservation["completeness"];
20
+ execInspection: "complete" | "partial";
21
+ acceptedAgents: "direct_native_calls";
22
+ };
17
23
  submittedExecCalls: number | null;
18
24
  execCalls?: ExecCallReceipt[];
19
25
  truncated: boolean | null;
@@ -47,12 +53,28 @@ export function codexNativeControls(
47
53
  acceptedAgentCount: complete
48
54
  ? observation.data.acceptedSpawns.length
49
55
  : null,
50
- submittedExecCalls: complete ? observation.data.submittedExecCalls : null,
56
+ directAcceptedAgentCount: observation.data.acceptedSpawns.length,
57
+ coverage: {
58
+ nativeCalls: observation.completeness,
59
+ execInspection: inspectedExecCalls(observation)
60
+ ? "complete"
61
+ : "partial",
62
+ acceptedAgents: "direct_native_calls",
63
+ },
64
+ ...nativeCounts(observation),
51
65
  ...execEvidence(observation.data.execCalls),
52
- truncated: complete ? false : null,
53
66
  },
54
67
  };
55
68
  }
69
+ function nativeCounts(
70
+ observation: Awaited<ReturnType<typeof codexNativeCallObservation>>,
71
+ ) {
72
+ const complete = observation.completeness === "complete";
73
+ return {
74
+ submittedExecCalls: complete ? observation.data.submittedExecCalls : null,
75
+ truncated: complete ? false : null,
76
+ };
77
+ }
56
78
  function controlCompleteness(
57
79
  source: NativeControlObservation["completeness"],
58
80
  complete: boolean,