bareloop 0.1.0 → 0.2.0
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 +82 -2
- package/NOTICE +13 -0
- package/bareloop.context.md +79 -12
- package/package.json +2 -1
- package/src/index.js +4 -0
- package/src/interpret.js +55 -22
- package/src/job.js +252 -0
- package/src/ralph.js +17 -4
- package/src/validate.js +133 -15
- package/types/index.d.ts +1 -0
- package/types/interpret.d.ts +6 -1
- package/types/job.d.ts +57 -0
- package/types/ralph.d.ts +15 -2
- package/types/validate.d.ts +38 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,87 @@ All notable changes to bareloop are documented here. Format:
|
|
|
5
5
|
[SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
|
|
6
6
|
feature lands, **patch** = docs, fixes, scaffolding.
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.2.0] — 2026-07-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **N1 — the job/close schema (rung 2 of the ladder).** `validateJob` (`src/job.js`):
|
|
12
|
+
the operator-owned `job-v1` spec — the arbiter's rulebook as pure declarative data
|
|
13
|
+
(close chain, budget, outer write fence, environment label, escalation), validated
|
|
14
|
+
reds-before-tokens with pinned `code:path` reds. The arbiter split is guarded from
|
|
15
|
+
both sides by inexpressibility (workflow config can't say `close`/`provider`; job spec
|
|
16
|
+
can't say `hooks`/`loop`/`memory`, minting claims, or the shell-owned retry cap).
|
|
17
|
+
Close-authoring hierarchy (PRD §7) enforced as a class menu keyed by close type —
|
|
18
|
+
verdict-class laundering (`rubric` claiming `hard`) is a named red `close-hierarchy`.
|
|
19
|
+
`jobSpecHash` + `checkApproval`: the pure half of human-signs-always (sha256 over
|
|
20
|
+
canonical JSON; an edited spec is unapproved by construction; the N2 runner enforces).
|
|
21
|
+
Design record: `docs/plans/2026-07-12-n1-job-close-schema-design.md`; POC verdict: F4.
|
|
22
|
+
- **Two-layer write fence.** `validateConfig` accepts `jobWriteScope` (the job spec's
|
|
23
|
+
operator-owned outer fence); every workflow scope must fit inside it — path-boundary
|
|
24
|
+
aware (`src2` is not inside `src`) — or it reds `scope-escape`. Same containment law,
|
|
25
|
+
same code, both layers (the F9 lesson).
|
|
26
|
+
- **Reserved spine vocabulary: `coordination-red`** (V7, PRD v1.7 #1) — documented in
|
|
27
|
+
`bareloop.context.md`; no machinery until job #1 surfaces one.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- **`validateConfig` returns `{ ok, reds, config }`** — the parsed config on ok, `null`
|
|
31
|
+
on any red; kills the interpreter's double-parse (N2+ queue item absorbed). Additive
|
|
32
|
+
for callers reading `ok`/`reds`.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- **Review hardening (post-build /code-review, 8 findings fixed + 6 sub-cap cleanups;
|
|
36
|
+
all fixes negative-tested and mutation-checked, zero feature regressions):**
|
|
37
|
+
cadence/escalation red unknown keys (the last smuggling level in a signed spec is
|
|
38
|
+
closed); the `jobWriteScope` fence opt fails CLOSED — a malformed fence is its own
|
|
39
|
+
`fence-invalid` red, never silently skipped, and each escaping scope reds at its own
|
|
40
|
+
indexed path (`gate.writeScope.N`); scope normalization moved into the shared
|
|
41
|
+
`globToPrefix` (leading `./`, interior `/./`, `//`, trailing `/` collapse) so a
|
|
42
|
+
validateJob-green fence like `src/` no longer deadlocks contained workflow configs;
|
|
43
|
+
`canon()` follows JSON semantics (undefined-valued keys dropped) so approvals survive
|
|
44
|
+
a disk round-trip, and `checkApproval` never throws (non-JSON spec → `false`);
|
|
45
|
+
`SECRET_RE` gained a left boundary (`flask-sqlalchemy` no longer reds) and the sweep
|
|
46
|
+
is shared by BOTH validators — the agent-authored workflow config is now swept too;
|
|
47
|
+
`interpret` accepts `jobWriteScope` and enforces the fence at the choke point (entry
|
|
48
|
+
+ revision candidates); revision candidates are judged and installed on their PARSED
|
|
49
|
+
form (a JSON-string candidate no longer false-reds arbiter-touch); exported arbiter
|
|
50
|
+
menus are frozen; `isObj`/`isNonEmptyString` single-copied in `validate.js`.
|
|
51
|
+
- **Second-round review (self-review of the hardening commit found a regression IT
|
|
52
|
+
introduced — all fixes TDD'd, mutation-checked, zero feature regressions):**
|
|
53
|
+
**critical containment escape** — the fence-normalization added to `globToPrefix`
|
|
54
|
+
stripped a leading `./` before collapsing `//`, so `.//src/**` minted the ABSOLUTE
|
|
55
|
+
prefix `/src`, validated green, and resolved outside the run directory at enforcement
|
|
56
|
+
(design law #1); fixed by collapsing `//`+`/./` first (so `.//src/**` → `src`, safe),
|
|
57
|
+
a belt in `scopeContained` rejecting any normalized-absolute prefix, and an enforcement
|
|
58
|
+
belt in `interpret` that refuses to build a Gate whose resolved scope escapes the
|
|
59
|
+
workdir. `canon()` now honors `toJSON` (a `Date` no longer hashes as `{}`; distinct
|
|
60
|
+
values no longer collide) and `jobSpecHash` never throws (the minting path the runner
|
|
61
|
+
calls directly is now crash-free on `BigInt`/cycles). `SECRET_RE` left boundary extended
|
|
62
|
+
to `-`/`_` (`pipeline-sk-transform-utils-v2` no longer false-reds; real keys still red).
|
|
63
|
+
`jobWriteScope: null`/`undefined` are the legitimate no-fence spellings (no more deadlock
|
|
64
|
+
on every config); a malformed fence reds `fence-invalid` at path `jobWriteScope`, not the
|
|
65
|
+
innocent workflow field (no ledger misattribution), with the detail bounded. Shared
|
|
66
|
+
`legalScopeEntry` gives the scope-legality law one home across all three call sites.
|
|
67
|
+
- **Secrets never enter the spine (hard line), enforced at the source.** `runClose`
|
|
68
|
+
(`src/ralph.js`) scrubs close-command output the moment it is captured, so a secret a
|
|
69
|
+
checked command echoes (a 401 dumping a `Bearer …`/`sk-…` header) never reaches the
|
|
70
|
+
append-only spine or the next worker prompt. The redactor is injected (the shell stays
|
|
71
|
+
stdlib-only); `interpret` wires bareguard's exported `redact`. A benign gap is returned
|
|
72
|
+
byte-identical — the failure still reaches the human, just without the token (design
|
|
73
|
+
law #7 / V4 intact: the redactor is a fixed shell primitive, not an emergent component).
|
|
74
|
+
- `NOTICE` ships in the tarball (npm auto-includes LICENSE/README but not NOTICE; Apache-2.0
|
|
75
|
+
wants both) — found validating the installed 0.1.0 artifact.
|
|
76
|
+
- **Release-gate review (fresh `/security` + `/diff-review` on the whole release diff;
|
|
77
|
+
every finding execution-verified, every fix TDD'd):** the spine redactor now scrubs
|
|
78
|
+
**every shape the validator reds** — bareguard's defaults cover only `Bearer`/`sk-`,
|
|
79
|
+
so a git close echoing a `ghp_`/`github_pat_`/`AKIA`/`xox` token passed unredacted
|
|
80
|
+
into the append-only spine (the most plausible leak for job #1, a GitHub PR workflow);
|
|
81
|
+
`SECRET_PATTERNS` is now the one shape inventory shared by detection and redaction.
|
|
82
|
+
`interpret` normalizes `workdir` once at entry — a trailing-slash or relative spelling
|
|
83
|
+
made the enforcement belt false-red every legal scope — and the belt now treats a scope
|
|
84
|
+
resolving to the run dir itself as escaped. `checkApproval` no longer routes through the
|
|
85
|
+
un-hashable sentinel (two distinct un-hashable specs cross-approved each other; now
|
|
86
|
+
un-hashable = unapproved). The secrets sweep tests object **keys**, not just values (a
|
|
87
|
+
token could ride a key in a `gold` `expected` onto the spine through a green spec). A
|
|
88
|
+
non-object `cadence` reds once at `cadence`, not twice at paths that don't exist.
|
|
9
89
|
|
|
10
90
|
## [0.1.0] — 2026-07-11
|
|
11
91
|
|
|
@@ -119,6 +199,6 @@ feature lands, **patch** = docs, fixes, scaffolding.
|
|
|
119
199
|
`docs/UPSTREAM-ASKS.md`, guardrails pre-tool hook (local), `.gitignore`.
|
|
120
200
|
- Public GitHub repo `hamr0/bareloop`, `main` branch.
|
|
121
201
|
|
|
122
|
-
[
|
|
202
|
+
[0.2.0]: https://github.com/hamr0/bareloop/compare/v0.1.0...v0.2.0
|
|
123
203
|
[0.1.0]: https://github.com/hamr0/bareloop/compare/v0.0.1...v0.1.0
|
|
124
204
|
[0.0.1]: https://github.com/hamr0/bareloop/releases/tag/v0.0.1
|
package/NOTICE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
bareloop
|
|
2
|
+
Copyright 2026 hamr0
|
|
3
|
+
|
|
4
|
+
This product includes software developed by hamr0
|
|
5
|
+
(https://github.com/hamr0/bareloop).
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License").
|
|
8
|
+
You may obtain a copy of the License at:
|
|
9
|
+
|
|
10
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
|
|
12
|
+
This NOTICE file is required by Section 4(d) of the License.
|
|
13
|
+
Forks and derivative works MUST preserve this attribution.
|
package/bareloop.context.md
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# bareloop — Integration Guide
|
|
2
2
|
|
|
3
|
-
> **
|
|
4
|
-
>
|
|
5
|
-
>
|
|
6
|
-
>
|
|
7
|
-
> contract; the README is only the pitch.
|
|
3
|
+
> **Current through N1** (job/close schema); API sections fill in as build-ladder rungs
|
|
4
|
+
> land (PRD §10). What is settled — the boundary, the architecture, the refusals, the
|
|
5
|
+
> constraints — is settled for good. Per LIBRARY_CONVENTIONS §3 this file ships with the
|
|
6
|
+
> package and is the complete adopter contract; the README is only the pitch.
|
|
8
7
|
|
|
9
8
|
## What this is
|
|
10
9
|
|
|
@@ -30,8 +29,42 @@ checkpoints + verdict classes, budget, cadence, provider) → run → watch the
|
|
|
30
29
|
|
|
31
30
|
## All options
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
Two documents, two validators — the schema is the option surface; config-red before
|
|
33
|
+
tokens burn. The arbiter split is guarded from both sides by INEXPRESSIBILITY: the
|
|
34
|
+
workflow config cannot say `close`/`provider`; the job spec cannot say
|
|
35
|
+
`hooks`/`loop`/`memory`, any minting claim, or the shell-owned retry cap — all
|
|
36
|
+
unknown-field reds.
|
|
37
|
+
|
|
38
|
+
**Job spec (`schema: "job-v1"`, operator-owned — the arbiter's rulebook):**
|
|
39
|
+
|
|
40
|
+
| field | shape | notes |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `job` | kebab-case slug | |
|
|
43
|
+
| `description` | non-empty string | |
|
|
44
|
+
| `provider` | `anthropic-api` | menu; part of the lineage key by definition |
|
|
45
|
+
| `conditions` | `{ providerPath?, closeVerbosity?, taskFraming?, scaffold? }` | declared keys only, string values — the environment label (consumed by the N3 lineage key; recorded on spines from run one) |
|
|
46
|
+
| `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
|
|
47
|
+
| `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
|
|
48
|
+
| `writeScope` | array of contained globs | the operator's outer fence; same containment law as the workflow layer, same code |
|
|
49
|
+
| `steps` | array of `{ id, close, class }` | unique slug ids; every step names its close |
|
|
50
|
+
| `escalation` | `{ mode: "decision-ready" }` | the pain channel is not optional |
|
|
51
|
+
|
|
52
|
+
**Close types and the hierarchy** (a close is data, never code; verdict-class laundering
|
|
53
|
+
is a named red `close-hierarchy`):
|
|
54
|
+
|
|
55
|
+
| type | fields (exact — extras red) | legal class |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `predicate` | `cmd`, `expect` (int exit code) | `hard` |
|
|
58
|
+
| `gold` | `expected`, `compare: exact\|json-equal` | `hard` |
|
|
59
|
+
| `rubric` | `criteria` | `soft` only — can never mint automatically |
|
|
60
|
+
| `hitl` | `prompt` | `hitl` — a human IS the close |
|
|
61
|
+
|
|
62
|
+
Red vocabulary (both validators): `parse-error`, `unknown-field`, `missing-required`,
|
|
63
|
+
`invalid-value`, `bounds`, `duplicate-id`, `close-type`, `close-hierarchy`,
|
|
64
|
+
`secret-literal`, `scope-escape`, `fence-invalid` (a malformed `jobWriteScope` fence — attributed to `jobWriteScope`, never the workflow config), plus the workflow-side verb reds (`verb-illegal`,
|
|
65
|
+
`verb-placement`, `verb-params`, `slot-overflow`). The `secret-literal` sweep is
|
|
66
|
+
defense-in-depth against known token shapes — env-only loading remains the law, not the
|
|
67
|
+
sweep.
|
|
35
68
|
|
|
36
69
|
## Public API
|
|
37
70
|
|
|
@@ -51,23 +84,57 @@ The dumb outer shell: `while close-red and under-cap: run the middle`. `close` i
|
|
|
51
84
|
whose exit code is truth (`runClose` is also exported); the red gap text feeds the next
|
|
52
85
|
iteration. Escalations are decision-ready (category, options, spend); cap-halt is its own
|
|
53
86
|
category, never merged with "wrong". A thrown middle is relayed by its `category`
|
|
54
|
-
property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`.
|
|
87
|
+
property (`cap-halt`, `gate-red`, …); an unnamed throw is `interpreter-red`. Close output
|
|
88
|
+
is scrubbed at capture (an injected `redact`, wired to bareguard by `interpret` with the
|
|
89
|
+
validators' full secret-shape inventory — Bearer/sk-/ghp_/github_pat_/AKIA/xox) so a
|
|
90
|
+
secret a checked command echoes never enters the append-only spine or a worker prompt —
|
|
91
|
+
a benign gap is byte-identical (secrets hard line; design law #7 intact).
|
|
55
92
|
|
|
56
|
-
### `validateConfig(input, { shellCapUsd? })` → `{ ok, reds }` — `src/validate.js`
|
|
93
|
+
### `validateConfig(input, { shellCapUsd?, jobWriteScope? })` → `{ ok, reds, config }` — `src/validate.js`
|
|
57
94
|
|
|
58
95
|
Deterministic schema-v1 predicate; never throws. Every failure is a named red
|
|
59
|
-
`{ code, path, detail? }` — reds before tokens burn.
|
|
96
|
+
`{ code, path, detail? }` — reds before tokens burn. Returns the parsed config on ok
|
|
97
|
+
(single parse; `null` on any red). Pass the job spec's `writeScope` as `jobWriteScope`
|
|
98
|
+
and every workflow scope must fit inside the fence (path-boundary aware: `src2` is not
|
|
99
|
+
inside `src`) or it reds `scope-escape`; pass `min(shell cap, job budgetUsd)` as
|
|
100
|
+
`shellCapUsd` to complete the ceiling chain. Verb vocabulary bound from litectx
|
|
60
101
|
(`LOOP_SHAPES`, `SLOTS`, `VERBS` exported). `diffPaths(a, b)` returns changed JSON paths —
|
|
61
102
|
the one-knob mutation checker.
|
|
62
103
|
|
|
104
|
+
### `validateJob(input, { shellCapUsd? })` → `{ ok, reds, job }` — `src/job.js`
|
|
105
|
+
|
|
106
|
+
The operator-owned sibling (never an extension) of `validateConfig`: validates a
|
|
107
|
+
`job-v1` spec — see **All options** for the full schema, close types, and hierarchy.
|
|
108
|
+
Never throws on JSON text or plain parsed data (the ingest contract); returns the
|
|
109
|
+
parsed spec on ok, `null` on any red. Menus exported:
|
|
110
|
+
`CLOSE_TYPES`, `CLASSES`, `CLASS_BY_CLOSE`, `GOLD_COMPARE`, `CADENCE_UNITS`,
|
|
111
|
+
`PROVIDERS`, `CONDITION_KEYS`.
|
|
112
|
+
|
|
113
|
+
### `jobSpecHash(job)` / `checkApproval(job, approvals)` — `src/job.js`
|
|
114
|
+
|
|
115
|
+
The pure half of **human-signs-always**: an agent may draft a job spec, but no job runs
|
|
116
|
+
until a human approves that exact version. `jobSpecHash` is sha256 over canonical JSON
|
|
117
|
+
(key-order independent) — any edit changes the hash, so an edited spec is unapproved by
|
|
118
|
+
construction. `checkApproval(job, approvals)` is a pure predicate over
|
|
119
|
+
`{ specHash, signer, ts }` records; the approval record lives OUTSIDE the document it
|
|
120
|
+
signs and is shell/human territory, never agent-writable. The N2 runner enforces it.
|
|
121
|
+
Reserved spine vocabulary (V7, machinery-free until job #1 surfaces one):
|
|
122
|
+
`coordination-red` — a failure between units (scope contention, step order, store
|
|
123
|
+
races), never to be folded into worker/interpreter reds.
|
|
124
|
+
|
|
63
125
|
### `interpret(configRaw, opts)` → `'green' | 'escalated' | 'config-red'` — `src/interpret.js`
|
|
64
126
|
|
|
65
127
|
The only code that reads a config. Composes bareguard `Gate` (write scopes, USD budget),
|
|
66
128
|
litectx (recall/compress/stash/remember hook ops), and the bareagent `Loop` under `ralph`.
|
|
67
129
|
The provider and the close arrive from the shell, never the config. Optional `revisor`
|
|
68
130
|
seam fires once after `STALL_REDS` consecutive close reds; the interpreter owns acceptance
|
|
69
|
-
(arbiter-touch / cap-touch / validation reds)
|
|
70
|
-
config (design law #2) — on every
|
|
131
|
+
(arbiter-touch / cap-touch / validation reds), judged and installed on the candidate's
|
|
132
|
+
PARSED form. Emits `config-final` — the run-as-executed config (design law #2) — on every
|
|
133
|
+
run. Pass the job spec's fence as `jobWriteScope` (or `null`/omit for no fence): it is
|
|
134
|
+
enforced HERE — the one choke point where a config becomes a Gate — on entry AND on every
|
|
135
|
+
revision candidate, so a workflow scope outside the operator's fence reds before tokens.
|
|
136
|
+
An enforcement belt resolves every scope and refuses to build a Gate that escapes the
|
|
137
|
+
workdir, independent of validator correctness (law #1).
|
|
71
138
|
|
|
72
139
|
### `extractRules({ config, provider, priorRules, revisionDiff? })` — `src/extract.js`
|
|
73
140
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bareloop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"src/",
|
|
41
41
|
"types/",
|
|
42
|
+
"NOTICE",
|
|
42
43
|
"bareloop.context.md",
|
|
43
44
|
"CHANGELOG.md"
|
|
44
45
|
],
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// bareloop public surface — grows one rung at a time (PRD §10). N0: the
|
|
2
2
|
// token-free spine. The five modules compose bottom-up: makeSpine feeds ralph,
|
|
3
3
|
// interpret is the only config reader, extractRules distills a green run.
|
|
4
|
+
// N1: the operator-owned job spec (job-v1) — validateJob is validate.js's
|
|
5
|
+
// sibling (never an extension); jobSpecHash/checkApproval are the pure half
|
|
6
|
+
// of human-signs-always (the N2 runner enforces).
|
|
4
7
|
|
|
5
8
|
export { makeSpine } from './spine.js';
|
|
6
9
|
export { ralph, runClose } from './ralph.js';
|
|
7
10
|
export { validateConfig, diffPaths, globToPrefix, LOOP_SHAPES, SLOTS, VERBS } from './validate.js';
|
|
11
|
+
export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS } from './job.js';
|
|
8
12
|
export { interpret, STALL_REDS } from './interpret.js';
|
|
9
13
|
export { extractRules, MAX_RULES, MAX_RULE_CHARS } from './extract.js';
|
package/src/interpret.js
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
14
|
import { writeFileSync, readFileSync } from 'node:fs';
|
|
15
|
-
import { join, resolve } from 'node:path';
|
|
16
|
-
import { Gate } from 'bareguard';
|
|
15
|
+
import { join, resolve, sep } from 'node:path';
|
|
16
|
+
import { Gate, redact } from 'bareguard';
|
|
17
17
|
import { LiteCtx, compress } from 'litectx';
|
|
18
|
-
import { validateConfig, diffPaths, globToPrefix } from './validate.js';
|
|
18
|
+
import { validateConfig, diffPaths, globToPrefix, SECRET_PATTERNS } from './validate.js';
|
|
19
19
|
import { ralph } from './ralph.js';
|
|
20
20
|
|
|
21
21
|
/** @typedef {Error & {category?: string}} CategorizedError the failure map's carrier: ralph relays by `category` */
|
|
@@ -45,6 +45,10 @@ const PERSONA = 'You are a senior engineer. Reply with ONLY the complete content
|
|
|
45
45
|
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
46
46
|
* @param {object} opts.provider a bareagent provider — SHELL-owned binding (adaptlearn F8: an unsealed binding is a gate bypass)
|
|
47
47
|
* @param {number} [opts.shellCapUsd=2] the shell's USD cap; config budgetUsd is clamped by validation
|
|
48
|
+
* @param {string[]} [opts.jobWriteScope] the job spec's outer write fence (operator law,
|
|
49
|
+
* job-v1) — enforced HERE, the one choke point where a config becomes a Gate:
|
|
50
|
+
* every workflow scope must fit inside it (scope-escape config-red otherwise),
|
|
51
|
+
* on entry validation and on every revision candidate alike
|
|
48
52
|
* @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
|
|
49
53
|
* optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
|
|
50
54
|
* close reds. The interpreter — never the revisor — owns acceptance: the candidate
|
|
@@ -54,22 +58,40 @@ const PERSONA = 'You are a senior engineer. Reply with ONLY the complete content
|
|
|
54
58
|
* the worker.
|
|
55
59
|
* @returns {Promise<'green'|'escalated'|'config-red'>}
|
|
56
60
|
*/
|
|
57
|
-
export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, revisor }) {
|
|
58
|
-
|
|
61
|
+
export async function interpret(configRaw, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd = 2, jobWriteScope, revisor }) {
|
|
62
|
+
// Normalize ONCE: a trailing slash or a relative spelling must mean the same
|
|
63
|
+
// directory everywhere below — the enforcement belt compares string prefixes,
|
|
64
|
+
// and "/run/" vs "/run" would false-red every legal scope (release review).
|
|
65
|
+
workdir = resolve(workdir);
|
|
66
|
+
const v = validateConfig(configRaw, { shellCapUsd, jobWriteScope });
|
|
59
67
|
emit('config-validate', { ok: v.ok, reds: v.reds });
|
|
60
68
|
if (!v.ok) {
|
|
61
69
|
for (const r of v.reds) emit('config-red', r);
|
|
62
70
|
emit('run-end', { outcome: 'config-red', iterations: 0 });
|
|
63
71
|
return 'config-red';
|
|
64
72
|
}
|
|
65
|
-
let config =
|
|
73
|
+
let config = /** @type {any} */ (v.config); // single parse — validateConfig returns the parsed config on ok
|
|
66
74
|
|
|
67
75
|
const lc = new LiteCtx({ root: workdir });
|
|
76
|
+
// Enforcement belt (law #1, un-gameable gate): resolve every scope and prove
|
|
77
|
+
// it stays under workdir BEFORE building the Gate. validateConfig already
|
|
78
|
+
// rejects escaping scopes, so this is defense in depth — a future globToPrefix
|
|
79
|
+
// regression (a spelling that normalizes to an absolute path) can never reach
|
|
80
|
+
// a live Gate fence. The interpreter and the validator must never disagree (F9).
|
|
81
|
+
const resolvedScopes = config.gate.writeScope.map((/** @type {string} */ g) => resolve(workdir, globToPrefix(g)));
|
|
82
|
+
// equality counts as escaped: no legal scope resolves to workdir itself (the
|
|
83
|
+
// close lives there), so a scope normalizing to ''/'.' is a regression, not a grant
|
|
84
|
+
const escaped = resolvedScopes.filter((/** @type {string} */ abs) => !abs.startsWith(workdir + sep));
|
|
85
|
+
if (escaped.length) {
|
|
86
|
+
for (const abs of escaped) emit('config-red', { code: 'scope-escape', path: 'gate.writeScope', detail: `resolved scope ${abs} escapes the run directory` });
|
|
87
|
+
emit('run-end', { outcome: 'config-red', iterations: 0 });
|
|
88
|
+
return 'config-red';
|
|
89
|
+
}
|
|
68
90
|
const gate = new Gate({
|
|
69
91
|
// bareguard fs.writeScope is prefix-containment, not glob (adaptlearn F4); globToPrefix
|
|
70
92
|
// is the ONE transform shared with the validator's legality rule — mid-path wildcards
|
|
71
93
|
// and workdir-escaping scopes were already rejected up front (adaptlearn F9, law #1).
|
|
72
|
-
fs: { writeScope:
|
|
94
|
+
fs: { writeScope: resolvedScopes },
|
|
73
95
|
budget: { maxCostUsd: config.gate.budgetUsd },
|
|
74
96
|
limits: { maxTurns: 8 * (capRuns + 1) },
|
|
75
97
|
audit: { path: join(workdir, 'gate-audit.jsonl') },
|
|
@@ -139,17 +161,22 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
139
161
|
// already snapshotted, so gate/escalation (arbiter) and loop.maxIterations
|
|
140
162
|
// (cap) must be byte-identical; anything else that validates is a legal
|
|
141
163
|
// free-axis revision.
|
|
142
|
-
/** @param {any} candidate
|
|
164
|
+
/** @param {any} candidate
|
|
165
|
+
* @returns {{ red: {code: string, reds?: object[]} } | { red?: undefined, config: any }} */
|
|
143
166
|
const acceptRevision = (candidate) => {
|
|
144
|
-
if (!candidate) return { code: 'parse-error' };
|
|
145
|
-
const cv = validateConfig(candidate, { shellCapUsd });
|
|
146
|
-
if (!cv.ok) return { code: 'validation', reds: cv.reds };
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
167
|
+
if (!candidate) return { red: { code: 'parse-error' } };
|
|
168
|
+
const cv = validateConfig(candidate, { shellCapUsd, jobWriteScope });
|
|
169
|
+
if (!cv.ok) return { red: { code: 'validation', reds: cv.reds } };
|
|
170
|
+
// judged and installed on the PARSED form (single-parse contract) — a
|
|
171
|
+
// string candidate compared raw would false-red arbiter-touch (its .gate
|
|
172
|
+
// is undefined), and installing it raw would crash every later read
|
|
173
|
+
const cand = /** @type {any} */ (cv.config);
|
|
174
|
+
if (JSON.stringify(cand.gate) !== JSON.stringify(config.gate)
|
|
175
|
+
|| JSON.stringify(cand.escalation) !== JSON.stringify(config.escalation)) {
|
|
176
|
+
return { red: { code: 'arbiter-touch' } };
|
|
150
177
|
}
|
|
151
|
-
if (
|
|
152
|
-
return
|
|
178
|
+
if (cand.loop?.maxIterations !== config.loop.maxIterations) return { red: { code: 'cap-touch' } };
|
|
179
|
+
return { config: cand };
|
|
153
180
|
};
|
|
154
181
|
|
|
155
182
|
/** @type {string[]} */
|
|
@@ -174,12 +201,12 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
174
201
|
if (e instanceof HaltError) /** @type {CategorizedError} */ (e).category = 'cap-halt';
|
|
175
202
|
throw e;
|
|
176
203
|
}
|
|
177
|
-
const
|
|
178
|
-
if (red) {
|
|
179
|
-
emit('revision-red', { iteration, ...red, detail: rv.parseError ?? undefined, costUsd: rv.costUsd ?? 0 });
|
|
204
|
+
const rr = acceptRevision(rv.candidate);
|
|
205
|
+
if (rr.red) {
|
|
206
|
+
emit('revision-red', { iteration, ...rr.red, detail: rv.parseError ?? undefined, costUsd: rv.costUsd ?? 0 });
|
|
180
207
|
} else {
|
|
181
|
-
emit('revision-accepted', { iteration, changedPaths: diffPaths(config,
|
|
182
|
-
config =
|
|
208
|
+
emit('revision-accepted', { iteration, changedPaths: diffPaths(config, rr.config), costUsd: rv.costUsd ?? 0 });
|
|
209
|
+
config = rr.config;
|
|
183
210
|
}
|
|
184
211
|
}
|
|
185
212
|
if (gap) await runOps('after-red', { iteration, gap, context: {} });
|
|
@@ -208,7 +235,13 @@ export async function interpret(configRaw, { task, target, close, workdir, capRu
|
|
|
208
235
|
|
|
209
236
|
// the config may tighten the shell's iteration budget, never exceed it (mirrors budgetUsd)
|
|
210
237
|
const effectiveCap = Math.min(capRuns, config.loop.maxIterations ?? capRuns);
|
|
211
|
-
|
|
238
|
+
// Secrets never enter the spine (hard line): the shell scrubs close output at
|
|
239
|
+
// the source with bareguard's redactor — BG-1 defaults (Bearer/sk-…) PLUS the
|
|
240
|
+
// validator's whole shape inventory (SECRET_PATTERNS), so redaction can never
|
|
241
|
+
// pass a shape detection reds (a git close echoing a ghp_ token was the gap).
|
|
242
|
+
// Injected here (the layer that owns bareguard) so ralph stays stdlib-only and
|
|
243
|
+
// the scrub is a fixed shell primitive, not an emergent component (V4 holds).
|
|
244
|
+
const outcome = await ralph({ middle, close, capRuns: effectiveCap, emit, redact: (/** @type {string} */ s) => redact(s, { patterns: SECRET_PATTERNS }) });
|
|
212
245
|
if (outcome === 'green') {
|
|
213
246
|
// The close already passed — a retention hiccup must not un-green a real
|
|
214
247
|
// green (it would corrupt the learning curve). It degrades loudly:
|
package/src/job.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Job-spec validator — the OPERATOR-owned half of the config story (N1,
|
|
2
|
+
// design record: docs/plans/2026-07-12-n1-job-close-schema-design.md).
|
|
3
|
+
// A job spec is the arbiter's rulebook: the close chain, the budget, the
|
|
4
|
+
// outer write fence, the escalation route. It is pure declarative data —
|
|
5
|
+
// no freeform code is expressible anywhere in it — and it validates
|
|
6
|
+
// reds-before-tokens like its sibling validate.js (workflow, agent-authored).
|
|
7
|
+
// The arbiter split is guarded from both sides by INEXPRESSIBILITY: the
|
|
8
|
+
// workflow config cannot say close/provider (validate.js, shipped at N0);
|
|
9
|
+
// the job spec cannot say hooks/loop/memory, any minting claim, or the
|
|
10
|
+
// shell-owned retry cap — all unknown-field reds here.
|
|
11
|
+
//
|
|
12
|
+
// Close-authoring hierarchy (PRD §7) as a class menu keyed by close type:
|
|
13
|
+
// verdict-class laundering (a rubric claiming hard) is a named red, never a
|
|
14
|
+
// judgment call. Minting policy is product doctrine, not job-authorable.
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
|
|
18
|
+
|
|
19
|
+
// The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
|
|
20
|
+
// they are read at call time, so a mutable export would let adopter code
|
|
21
|
+
// enable verdict-class laundering process-wide with one push().
|
|
22
|
+
export const CLOSE_TYPES = Object.freeze(['predicate', 'gold', 'rubric', 'hitl']);
|
|
23
|
+
export const CLASSES = Object.freeze(['hard', 'soft', 'hitl']);
|
|
24
|
+
/** the hierarchy: which verdict classes a close type may claim (PRD §7) */
|
|
25
|
+
export const CLASS_BY_CLOSE = Object.freeze({ predicate: Object.freeze(['hard']), gold: Object.freeze(['hard']), rubric: Object.freeze(['soft']), hitl: Object.freeze(['hitl']) });
|
|
26
|
+
export const GOLD_COMPARE = Object.freeze(['exact', 'json-equal']);
|
|
27
|
+
export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
|
|
28
|
+
export const PROVIDERS = Object.freeze(['anthropic-api']); // SP-2: API-first; local deferred (PRD §5/§8)
|
|
29
|
+
/** V3 environment label: declared keys only — every field is a lineage-key
|
|
30
|
+
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
31
|
+
* not duplicated here). */
|
|
32
|
+
export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', 'taskFraming', 'scaffold']);
|
|
33
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'writeScope', 'steps', 'escalation'];
|
|
34
|
+
/** exact field set per close type — anything else is an unknown-field red
|
|
35
|
+
* (freeform code, script bodies, and minting claims all land there) */
|
|
36
|
+
const CLOSE_FIELDS = {
|
|
37
|
+
predicate: ['type', 'cmd', 'expect'],
|
|
38
|
+
gold: ['type', 'expected', 'compare'],
|
|
39
|
+
rubric: ['type', 'criteria'],
|
|
40
|
+
hitl: ['type', 'prompt'],
|
|
41
|
+
};
|
|
42
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
43
|
+
|
|
44
|
+
/** @typedef {{code: string, path: string, detail?: string}} Red */
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
|
|
48
|
+
* JSON text or plain parsed data — the ingest contract; a live object with a
|
|
49
|
+
* hostile accessor is outside it. Every failure is a named red. Returns the
|
|
50
|
+
* parsed spec on ok (single parse), null on any red.
|
|
51
|
+
* @param {object|string} input parsed spec, or raw JSON text (parse failures are a red)
|
|
52
|
+
* @param {{ shellCapUsd?: number }} [opts] the shell's hard ceiling — the job
|
|
53
|
+
* budget may tighten it, never exceed it (ceiling chain: workflow ≤ job ≤ shell)
|
|
54
|
+
* @returns {{ ok: boolean, reds: Red[], job: object|null }}
|
|
55
|
+
*/
|
|
56
|
+
export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
57
|
+
/** @type {Red[]} */
|
|
58
|
+
const reds = [];
|
|
59
|
+
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
60
|
+
const red = (code, path, detail) => { reds.push(detail ? { code, path, detail } : { code, path }); };
|
|
61
|
+
|
|
62
|
+
let j = input;
|
|
63
|
+
if (typeof j === 'string') {
|
|
64
|
+
try { j = JSON.parse(j); } catch (e) {
|
|
65
|
+
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], job: null };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!isObj(j)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'job spec must be a JSON object' }], job: null };
|
|
69
|
+
const spec = /** @type {Record<string, any>} */ (j);
|
|
70
|
+
|
|
71
|
+
// 1. shape — unknown fields red here: hooks/loop/memory (agent domain),
|
|
72
|
+
// capRuns (shell domain), minting claims — inexpressibility is the guard.
|
|
73
|
+
for (const key of Object.keys(spec)) {
|
|
74
|
+
if (!JOB_FIELDS.includes(key)) red('unknown-field', key);
|
|
75
|
+
}
|
|
76
|
+
if (spec.schema === undefined) red('missing-required', 'schema');
|
|
77
|
+
else if (spec.schema !== 'job-v1') red('invalid-value', 'schema', `expected "job-v1", got ${JSON.stringify(spec.schema)}`);
|
|
78
|
+
if (!isNonEmptyString(spec.job) || !SLUG_RE.test(spec.job)) red('invalid-value', 'job', 'kebab-case slug');
|
|
79
|
+
if (!isNonEmptyString(spec.description)) red('missing-required', 'description');
|
|
80
|
+
|
|
81
|
+
if (spec.provider === undefined) red('missing-required', 'provider');
|
|
82
|
+
else if (!PROVIDERS.includes(spec.provider)) red('invalid-value', 'provider', `menu: ${PROVIDERS.join('|')}`);
|
|
83
|
+
|
|
84
|
+
// 2. the environment label (V3, design law #5): declared keys only
|
|
85
|
+
if (spec.conditions !== undefined) {
|
|
86
|
+
if (!isObj(spec.conditions)) red('invalid-value', 'conditions', 'flat object of declared condition keys');
|
|
87
|
+
else {
|
|
88
|
+
for (const [key, value] of Object.entries(spec.conditions)) {
|
|
89
|
+
if (!CONDITION_KEYS.includes(key)) red('unknown-field', `conditions.${key}`, `declared keys only: ${CONDITION_KEYS.join('|')} (every field is a lineage-key dimension, V3)`);
|
|
90
|
+
else if (!isNonEmptyString(value)) red('invalid-value', `conditions.${key}`, 'non-empty string');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (spec.cadence === undefined) red('missing-required', 'cadence');
|
|
96
|
+
else if (!isObj(spec.cadence)) red('invalid-value', 'cadence', 'object of {unit, every}');
|
|
97
|
+
else {
|
|
98
|
+
const cad = spec.cadence;
|
|
99
|
+
for (const key of Object.keys(cad)) {
|
|
100
|
+
if (key !== 'unit' && key !== 'every') red('unknown-field', `cadence.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
|
|
101
|
+
}
|
|
102
|
+
if (!CADENCE_UNITS.includes(cad.unit)) red('invalid-value', 'cadence.unit', CADENCE_UNITS.join('|'));
|
|
103
|
+
if (!(Number.isInteger(cad.every) && cad.every >= 1 && cad.every <= 30)) red('bounds', 'cadence.every', '1..30');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (spec.budgetUsd === undefined) red('missing-required', 'budgetUsd');
|
|
107
|
+
else if (!(typeof spec.budgetUsd === 'number' && spec.budgetUsd > 0 && spec.budgetUsd <= shellCapUsd)) {
|
|
108
|
+
red('bounds', 'budgetUsd', `0 < budget <= shell cap ${shellCapUsd} (cap-not-estimate; no self-adjusted budgets, ever)`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 3. the outer write fence — operator law (interview decision #4), same
|
|
112
|
+
// containment rules as the workflow layer THROUGH THE SAME CODE (F9: two
|
|
113
|
+
// transforms would let a fence validate green and mean something else).
|
|
114
|
+
if (spec.writeScope === undefined) red('missing-required', 'writeScope', 'the fence is operator law — the workflow scope must fit inside it');
|
|
115
|
+
else if (!(Array.isArray(spec.writeScope) && spec.writeScope.length > 0
|
|
116
|
+
&& spec.writeScope.every((/** @type {unknown} */ s) => isNonEmptyString(s)))) {
|
|
117
|
+
red('invalid-value', 'writeScope', 'non-empty array of glob strings');
|
|
118
|
+
} else if (!spec.writeScope.every((/** @type {string} */ s) => !globToPrefix(s).includes('*'))) {
|
|
119
|
+
red('invalid-value', 'writeScope', 'wildcards only as a trailing "/**" or "/*" (enforcement is prefix-containment, adaptlearn F9)');
|
|
120
|
+
} else if (!spec.writeScope.every(scopeContained)) {
|
|
121
|
+
red('invalid-value', 'writeScope', 'must be a proper subdirectory of the run directory — no absolute paths, no ".." segments, not the run dir itself (design law #1)');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (spec.escalation === undefined) red('missing-required', 'escalation.mode', 'the pain channel is not optional (law #7)');
|
|
125
|
+
else {
|
|
126
|
+
const esc = isObj(spec.escalation) ? spec.escalation : {};
|
|
127
|
+
for (const key of Object.keys(esc)) {
|
|
128
|
+
if (key !== 'mode') red('unknown-field', `escalation.${key}`, 'nested objects red unknown keys too — no smuggling level exists in a signed spec');
|
|
129
|
+
}
|
|
130
|
+
if (esc.mode !== 'decision-ready') red('invalid-value', 'escalation.mode', 'must be "decision-ready"');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 4. steps + the close chain
|
|
134
|
+
if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
|
|
135
|
+
red('missing-required', 'steps', 'non-empty array — a job without closes is ungated spend');
|
|
136
|
+
} else {
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
spec.steps.forEach((/** @type {any} */ s, /** @type {number} */ i) => {
|
|
139
|
+
const at = `steps.${i}`;
|
|
140
|
+
if (!isObj(s)) { red('invalid-value', at, 'step must be an object'); return; }
|
|
141
|
+
for (const key of Object.keys(s)) {
|
|
142
|
+
if (!['id', 'close', 'class'].includes(key)) red('unknown-field', `${at}.${key}`);
|
|
143
|
+
}
|
|
144
|
+
if (!isNonEmptyString(s.id) || !SLUG_RE.test(s.id)) red('invalid-value', `${at}.id`, 'kebab-case slug');
|
|
145
|
+
else if (seen.has(s.id)) red('duplicate-id', `${at}.id`, s.id);
|
|
146
|
+
else seen.add(s.id);
|
|
147
|
+
|
|
148
|
+
if (s.class === undefined) red('missing-required', `${at}.class`);
|
|
149
|
+
else if (!CLASSES.includes(s.class)) red('invalid-value', `${at}.class`, CLASSES.join('|'));
|
|
150
|
+
|
|
151
|
+
if (!isObj(s.close)) { red('missing-required', `${at}.close`, 'every step names its close — a step without one is ungated spend'); return; }
|
|
152
|
+
const close = s.close;
|
|
153
|
+
if (!CLOSE_TYPES.includes(close.type)) {
|
|
154
|
+
red('close-type', `${at}.close.type`, `menu: ${CLOSE_TYPES.join('|')} — a close is data, never code`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// per-type contracts — fixed menus only
|
|
158
|
+
if (close.type === 'predicate') {
|
|
159
|
+
if (!isNonEmptyString(close.cmd)) red('missing-required', `${at}.close.cmd`);
|
|
160
|
+
if (!Number.isInteger(close.expect)) red('invalid-value', `${at}.close.expect`, 'integer exit code');
|
|
161
|
+
} else if (close.type === 'gold') {
|
|
162
|
+
if (close.expected === undefined) red('missing-required', `${at}.close.expected`);
|
|
163
|
+
if (!GOLD_COMPARE.includes(close.compare)) red('invalid-value', `${at}.close.compare`, GOLD_COMPARE.join('|'));
|
|
164
|
+
} else if (close.type === 'rubric') {
|
|
165
|
+
if (!isNonEmptyString(close.criteria)) red('missing-required', `${at}.close.criteria`);
|
|
166
|
+
} else if (close.type === 'hitl') {
|
|
167
|
+
if (!isNonEmptyString(close.prompt)) red('missing-required', `${at}.close.prompt`);
|
|
168
|
+
}
|
|
169
|
+
for (const key of Object.keys(close)) {
|
|
170
|
+
if (!CLOSE_FIELDS[close.type].includes(key)) {
|
|
171
|
+
red('unknown-field', `${at}.close.${key}`, `not a ${close.type} field (script bodies and minting claims land here)`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// the hierarchy: one check covers both directions — a class outside the
|
|
175
|
+
// close type's menu is laundering (rubric-as-hard) or delegation
|
|
176
|
+
// (hitl-class on a script close); hitl ⇔ hitl falls out of the menus.
|
|
177
|
+
const legal = CLASS_BY_CLOSE[close.type];
|
|
178
|
+
if (CLASSES.includes(s.class) && !legal.includes(s.class)) {
|
|
179
|
+
red('close-hierarchy', `${at}.class`, `${close.type} close admits class ${legal.join('|')} only (a rubric can never be hard; a human IS the hitl close — PRD §7)`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 5. secrets sweep — the SAME shared guard the workflow validator runs
|
|
185
|
+
// (defense-in-depth against known token shapes; env-only loading is the law)
|
|
186
|
+
sweepSecretLiterals(spec, red);
|
|
187
|
+
|
|
188
|
+
return { ok: reds.length === 0, reds, job: reds.length === 0 ? spec : null };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Canonical JSON — recursive key sort, so the hash binds to CONTENT, not to
|
|
193
|
+
* the accident of key order a drafting UX or an editor produced.
|
|
194
|
+
* @param {any} v
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function canon(v) {
|
|
198
|
+
// Follow JSON.stringify semantics — the hash must equal the hash of the
|
|
199
|
+
// disk round-trip, or an approval minted in memory fails after save/reload
|
|
200
|
+
// (human-signs-always rejecting an unedited spec). That means honoring toJSON
|
|
201
|
+
// (a Date serializes to its ISO string, not walked as {}) and dropping
|
|
202
|
+
// undefined-valued keys, both of which JSON.stringify does. One divergence:
|
|
203
|
+
// a function/symbol-VALUED key canonizes as null where stringify drops it —
|
|
204
|
+
// reachable only on non-JSON specs, where a round-trip mismatch is correct.
|
|
205
|
+
if (v !== null && typeof v === 'object' && typeof (/** @type {any} */ (v).toJSON) === 'function') {
|
|
206
|
+
return canon(/** @type {any} */ (v).toJSON());
|
|
207
|
+
}
|
|
208
|
+
if (Array.isArray(v)) return `[${v.map((x) => (x === undefined ? 'null' : canon(x))).join(',')}]`;
|
|
209
|
+
if (isObj(v)) {
|
|
210
|
+
const obj = /** @type {Record<string, any>} */ (v);
|
|
211
|
+
return `{${Object.keys(obj).sort().filter((k) => obj[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canon(obj[k])}`).join(',')}}`;
|
|
212
|
+
}
|
|
213
|
+
const s = JSON.stringify(v);
|
|
214
|
+
return s === undefined ? 'null' : s; // undefined/function/symbol leaf: not JSON-representable — 'null', never throw
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Content hash of a job spec — what an approval record binds to. Any edit to
|
|
219
|
+
* the spec changes the hash, so an edited spec is unapproved by construction
|
|
220
|
+
* (interview decision #1: human signs, always, once per spec VERSION).
|
|
221
|
+
* @param {object} job
|
|
222
|
+
* @returns {string} sha256 hex
|
|
223
|
+
*/
|
|
224
|
+
export function jobSpecHash(job) {
|
|
225
|
+
// NEVER throws: the N2 runner mints {specHash, signer, ts} by calling this
|
|
226
|
+
// directly on the raw job object, so a non-JSON value (BigInt, a cycle, a
|
|
227
|
+
// throwing getter) must yield a hash, not crash the signer. canon handles
|
|
228
|
+
// JSON-representable inputs; the guard covers cycles and hostile getters.
|
|
229
|
+
let c;
|
|
230
|
+
try { c = canon(job); } catch { c = '\u0000unhashable'; }
|
|
231
|
+
return createHash('sha256').update(c).digest('hex');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Does an approval record cover this exact spec version? Pure predicate — the
|
|
236
|
+
* N2 runner refuses to run without it; nothing here writes or prompts. The
|
|
237
|
+
* approval record lives OUTSIDE the document it signs (a spec never contains
|
|
238
|
+
* its own signature) and is shell/human territory, never agent-writable.
|
|
239
|
+
* @param {object} job
|
|
240
|
+
* @param {unknown} approvals array of { specHash, signer, ts } records
|
|
241
|
+
* @returns {boolean}
|
|
242
|
+
*/
|
|
243
|
+
export function checkApproval(job, approvals) {
|
|
244
|
+
if (!Array.isArray(approvals)) return false;
|
|
245
|
+
// NOT jobSpecHash: its un-hashable sentinel would make every un-hashable
|
|
246
|
+
// spec hash-equal — an approval minted for one would authorize any other.
|
|
247
|
+
// Here un-hashable simply means unapproved, full stop.
|
|
248
|
+
let c;
|
|
249
|
+
try { c = canon(job); } catch { return false; }
|
|
250
|
+
const h = createHash('sha256').update(c).digest('hex');
|
|
251
|
+
return approvals.some((a) => isObj(a) && /** @type {Record<string, unknown>} */ (a).specHash === h);
|
|
252
|
+
}
|
package/src/ralph.js
CHANGED
|
@@ -9,9 +9,19 @@ import { spawnSync } from 'node:child_process';
|
|
|
9
9
|
/**
|
|
10
10
|
* Run a close command; its exit code is the verdict (hard green, PRD §4).
|
|
11
11
|
* 0 → satisfied; nonzero → needs_revision (gap fed back); spawn error → failed (terminal).
|
|
12
|
+
*
|
|
13
|
+
* `redact` scrubs the close's own output at the SOURCE — before the gap becomes
|
|
14
|
+
* a spine event or a worker prompt — so a secret a checked command echoes
|
|
15
|
+
* (a 401 dumping an `Authorization: Bearer …` header) never enters the
|
|
16
|
+
* append-only spine (the hard line). It is INJECTED, not imported: the shell
|
|
17
|
+
* stays stdlib-only and un-gameable, and the redactor is a fixed shell
|
|
18
|
+
* primitive (bareguard's, wired by the interpreter), never an emergent
|
|
19
|
+
* component — so V4 holds. The shell's canonical emission IS the redacted text;
|
|
20
|
+
* a benign gap is returned byte-identical (default: identity).
|
|
12
21
|
* @param {string[]} close argv, e.g. ['node', '--test', 'close/']
|
|
22
|
+
* @param {(s: string) => string} [redact] source scrubber; identity by default
|
|
13
23
|
*/
|
|
14
|
-
export function runClose(close) {
|
|
24
|
+
export function runClose(close, redact = (s) => s) {
|
|
15
25
|
const env = { ...process.env };
|
|
16
26
|
delete env.NODE_TEST_CONTEXT; // a `node --test` close inherits this from a test runner and silently no-ops — a fake green
|
|
17
27
|
const r = spawnSync(close[0], close.slice(1), { env, encoding: 'utf8', timeout: 120_000 });
|
|
@@ -20,7 +30,8 @@ export function runClose(close) {
|
|
|
20
30
|
// The gap must never be falsy: every consumer guards with `if (gap)` — an
|
|
21
31
|
// empty-output red would silently kill gap feedback, after-red hooks, AND
|
|
22
32
|
// stall detection, leaving the worker re-prompted byte-identically to the cap.
|
|
23
|
-
|
|
33
|
+
// Redact BEFORE slicing so a token straddling the 2000-char bound can't survive.
|
|
34
|
+
return { verdict: 'needs_revision', gap: redact(r.stderr || r.stdout || '').slice(0, 2000) || '(close exited nonzero with no output)', exitCode: r.status };
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
/**
|
|
@@ -42,9 +53,11 @@ export function runClose(close) {
|
|
|
42
53
|
* @param {string[]} opts.close argv whose exit code is truth
|
|
43
54
|
* @param {number} opts.capRuns budget: max middle runs
|
|
44
55
|
* @param {(type: string, data?: object) => object} opts.emit a spine emitter
|
|
56
|
+
* @param {(s: string) => string} [opts.redact] source scrubber for close output
|
|
57
|
+
* (secrets never enter the spine); injected so the shell stays stdlib-only
|
|
45
58
|
* @returns {Promise<'green'|'escalated'>}
|
|
46
59
|
*/
|
|
47
|
-
export async function ralph({ middle, close, capRuns, emit }) {
|
|
60
|
+
export async function ralph({ middle, close, capRuns, emit, redact }) {
|
|
48
61
|
emit('run-start', { capRuns, close: close.join(' ') });
|
|
49
62
|
const verdicts = [];
|
|
50
63
|
let gap;
|
|
@@ -77,7 +90,7 @@ export async function ralph({ middle, close, capRuns, emit }) {
|
|
|
77
90
|
return 'escalated';
|
|
78
91
|
}
|
|
79
92
|
emit('middle-done', { iteration });
|
|
80
|
-
const v = runClose(close);
|
|
93
|
+
const v = runClose(close, redact);
|
|
81
94
|
verdicts.push(v.verdict);
|
|
82
95
|
emit('close-verdict', { iteration, ...v });
|
|
83
96
|
if (v.verdict === 'satisfied') {
|
package/src/validate.js
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { COMPRESS_LEVELS, KINDS, WRITE_KINDS } from 'litectx';
|
|
13
13
|
|
|
14
|
-
export const LOOP_SHAPES = ['refine', 'plan'];
|
|
15
|
-
export const SLOTS = ['before-attempt', 'after-red', 'on-green'];
|
|
16
|
-
export const VERBS = ['recall', 'compress', 'stash', 'remember'];
|
|
14
|
+
export const LOOP_SHAPES = Object.freeze(['refine', 'plan']);
|
|
15
|
+
export const SLOTS = Object.freeze(['before-attempt', 'after-red', 'on-green']);
|
|
16
|
+
export const VERBS = Object.freeze(['recall', 'compress', 'stash', 'remember']);
|
|
17
17
|
const TOP_FIELDS = ['schema', 'loop', 'memory', 'hooks', 'gate', 'escalation'];
|
|
18
18
|
const MAX_OPS_PER_SLOT = 2;
|
|
19
19
|
|
|
@@ -40,7 +40,23 @@ const VERB_PARAMS = {
|
|
|
40
40
|
* @param {string} scope
|
|
41
41
|
*/
|
|
42
42
|
export function globToPrefix(scope) {
|
|
43
|
-
|
|
43
|
+
// One canonical spelling for equivalent paths — leading "./", interior "/./",
|
|
44
|
+
// doubled "//", trailing "/" all collapse — so the legality rule, the fence
|
|
45
|
+
// comparison, and bareguard enforcement agree on what a scope NAMES. Without
|
|
46
|
+
// this, a validateJob-green fence like "src/" would reject every workflow
|
|
47
|
+
// scope inside it (the F9 red-class across two documents).
|
|
48
|
+
//
|
|
49
|
+
// Order is load-bearing: "//" collapses and interior "/./" drops BEFORE the
|
|
50
|
+
// leading "./" strip, so ".//src" → "src" and never "/src". Stripping first
|
|
51
|
+
// would MINT an absolute prefix from a relative scope — a design-law-#1
|
|
52
|
+
// containment escape (resolve(workdir, "/src") ignores workdir entirely).
|
|
53
|
+
//
|
|
54
|
+
// Deliberately NOT path.posix.normalize: it resolves ".." segments
|
|
55
|
+
// (normalize("src/../etc") === "etc"), which must stay VISIBLE so
|
|
56
|
+
// scopeContained can reject them — the whole point of the containment law.
|
|
57
|
+
let p = scope.replace(/\/\*\*?$/, '');
|
|
58
|
+
p = p.replace(/\/{2,}/g, '/').replace(/\/\.(?=\/|$)/g, '').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
59
|
+
return p;
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
/**
|
|
@@ -48,15 +64,35 @@ export function globToPrefix(scope) {
|
|
|
48
64
|
* resolve to a PROPER subdirectory of the run directory. Absolute paths and
|
|
49
65
|
* ".." segments escape it; "." / "./**" cover the whole run directory — where
|
|
50
66
|
* the close suite lives. Windows spellings ("..\", "C:\") count as escapes.
|
|
67
|
+
* Exported for the job validator: the operator's outer fence obeys the SAME
|
|
68
|
+
* law through the same code — two containment transforms would be the F9
|
|
69
|
+
* red-class one level up.
|
|
51
70
|
* @param {string} s
|
|
52
71
|
*/
|
|
53
|
-
function scopeContained(s) {
|
|
72
|
+
export function scopeContained(s) {
|
|
54
73
|
if (s.startsWith('/') || s.includes('\\') || /^[a-zA-Z]:/.test(s)) return false;
|
|
55
74
|
const prefix = globToPrefix(s);
|
|
56
|
-
|
|
75
|
+
// Belt: reject a prefix that is empty, the run dir, absolute, or a drive —
|
|
76
|
+
// checked on the NORMALIZED prefix, not just the raw string, so a spelling
|
|
77
|
+
// that normalizes to something absolute can never pass (defense in depth
|
|
78
|
+
// against a future globToPrefix regression; the un-gameable gate, law #1).
|
|
79
|
+
if (prefix === '' || prefix === '.' || prefix.startsWith('/') || /^[a-zA-Z]:/.test(prefix)) return false;
|
|
57
80
|
return prefix.split('/').every((seg) => seg !== '..');
|
|
58
81
|
}
|
|
59
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The per-entry write-scope legality law, one home for all three call sites
|
|
85
|
+
* (workflow gate.writeScope, the job fence, the operator's job-v1 writeScope):
|
|
86
|
+
* a non-empty string, no mid-path wildcard (enforcement is prefix-containment,
|
|
87
|
+
* F9), contained under the run dir (law #1). The chains that need per-check
|
|
88
|
+
* reds still spell the three steps; fenceOk uses this so a fence can never be
|
|
89
|
+
* blessed that the job validator would reject.
|
|
90
|
+
* @param {unknown} s
|
|
91
|
+
*/
|
|
92
|
+
function legalScopeEntry(s) {
|
|
93
|
+
return isNonEmptyString(s) && !globToPrefix(s).includes('*') && scopeContained(s);
|
|
94
|
+
}
|
|
95
|
+
|
|
60
96
|
// Each verb is legal ONLY in the slot where it has effect: recall/compress
|
|
61
97
|
// build the attempt context (consumed by before-attempt only), stash parks the
|
|
62
98
|
// gap (which exists only after-red), remember is verdict-gated retention
|
|
@@ -69,17 +105,72 @@ const VERB_SLOT = { recall: 'before-attempt', compress: 'before-attempt', stash:
|
|
|
69
105
|
function isKinds(v) {
|
|
70
106
|
return Array.isArray(v) && v.length > 0 && v.every((k) => KINDS.includes(k));
|
|
71
107
|
}
|
|
72
|
-
|
|
108
|
+
/** Shared with the job validator — one definition of "a JSON object" for both
|
|
109
|
+
* documents (the sibling-validator drift class).
|
|
110
|
+
* @param {unknown} v */
|
|
111
|
+
export function isObj(v) {
|
|
73
112
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
74
113
|
}
|
|
114
|
+
/** @param {unknown} v @returns {v is string} */
|
|
115
|
+
export function isNonEmptyString(v) {
|
|
116
|
+
return typeof v === 'string' && v.length > 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Secrets never enter the tree/spine/configs (hard line) — BOTH config
|
|
120
|
+
// documents get the same sweep: the agent-authored workflow config is the
|
|
121
|
+
// riskier entry point (machine-written), the operator's job spec the other.
|
|
122
|
+
// Known token shapes only, left-bounded so hyphenated words ("flask-sqlalchemy")
|
|
123
|
+
// never red — defense-in-depth; env-only loading is the law, not this regex.
|
|
124
|
+
// ONE shape inventory for the whole system: detection (this sweep) and the
|
|
125
|
+
// spine redactor (interpret wires these into bareguard's redact) must never
|
|
126
|
+
// disagree on what a secret looks like — a shape the validator reds but the
|
|
127
|
+
// redactor passes is a leak on the very output the validator was guarding.
|
|
128
|
+
export const SECRET_PATTERNS = [
|
|
129
|
+
/(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{16,}/,
|
|
130
|
+
/(?<![A-Za-z0-9_-])ghp_[A-Za-z0-9]{20,}/,
|
|
131
|
+
/(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/,
|
|
132
|
+
/(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{16}/,
|
|
133
|
+
/(?<![A-Za-z0-9_-])xox[bap]-[A-Za-z0-9-]{10,}/,
|
|
134
|
+
];
|
|
135
|
+
const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
|
|
75
136
|
|
|
76
137
|
/**
|
|
77
|
-
*
|
|
138
|
+
* Red every string in a config tree that carries a known secret-token shape.
|
|
139
|
+
* @param {any} root
|
|
140
|
+
* @param {(code: string, path: string, detail?: string) => void} red
|
|
141
|
+
*/
|
|
142
|
+
export function sweepSecretLiterals(root, red) {
|
|
143
|
+
(/** @type {(node: any, at: string) => void} */
|
|
144
|
+
function sweep(node, at) {
|
|
145
|
+
if (typeof node === 'string') {
|
|
146
|
+
if (SECRET_RE.test(node)) red('secret-literal', at, 'secrets load from the environment; an append-only record that captures a key captures it forever');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(node)) node.forEach((v, i) => sweep(v, `${at}.${i}`));
|
|
150
|
+
else if (isObj(node)) {
|
|
151
|
+
for (const [k, v] of Object.entries(node)) {
|
|
152
|
+
const path = at ? `${at}.${k}` : k;
|
|
153
|
+
// keys too: inside free-form values (a gold expected, a conditions map)
|
|
154
|
+
// a key is unconstrained, so a token can ride a KEY onto the spine
|
|
155
|
+
if (SECRET_RE.test(k)) red('secret-literal', path, 'secrets load from the environment; an append-only record that captures a key captures it forever');
|
|
156
|
+
sweep(v, path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})(root, '');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate a workflow config against schema v1. Returns the parsed config on
|
|
164
|
+
* ok (single parse — callers never re-parse; null on any red).
|
|
78
165
|
* @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
|
|
79
|
-
* @param {{ shellCapUsd?: number }} [opts] the shell's
|
|
80
|
-
*
|
|
166
|
+
* @param {{ shellCapUsd?: number, jobWriteScope?: string[] }} [opts] the shell's
|
|
167
|
+
* ceilings — a config may tighten either, never exceed. `shellCapUsd`: at N2+
|
|
168
|
+
* the shell passes min(shell cap, job budgetUsd) — the ceiling chain
|
|
169
|
+
* workflow ≤ job ≤ shell. `jobWriteScope`: the job spec's outer fence
|
|
170
|
+
* (operator law, job-v1) — every workflow scope must fit inside it.
|
|
171
|
+
* @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}>, config: object|null }}
|
|
81
172
|
*/
|
|
82
|
-
export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
173
|
+
export function validateConfig(input, { shellCapUsd = 2, jobWriteScope } = {}) {
|
|
83
174
|
/** @type {Array<{code: string, path: string, detail?: string}>} */
|
|
84
175
|
const reds = [];
|
|
85
176
|
/** @type {(code: string, path: string, detail?: string) => void} */
|
|
@@ -88,10 +179,10 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
|
88
179
|
let c = input;
|
|
89
180
|
if (typeof c === 'string') {
|
|
90
181
|
try { c = JSON.parse(c); } catch (e) {
|
|
91
|
-
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }] };
|
|
182
|
+
return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: String(/** @type {Error} */ (e).message) }], config: null };
|
|
92
183
|
}
|
|
93
184
|
}
|
|
94
|
-
if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }] };
|
|
185
|
+
if (!isObj(c)) return { ok: false, reds: [{ code: 'parse-error', path: '$', detail: 'config must be a JSON object' }], config: null };
|
|
95
186
|
|
|
96
187
|
// 1. shape — unknown top-level fields red here (smuggled close/provider included)
|
|
97
188
|
for (const key of Object.keys(c)) {
|
|
@@ -128,7 +219,7 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
|
128
219
|
}
|
|
129
220
|
if (gate.writeScope === undefined) red('missing-required', 'gate.writeScope');
|
|
130
221
|
else if (!(Array.isArray(gate.writeScope) && gate.writeScope.length > 0
|
|
131
|
-
&& gate.writeScope.every(
|
|
222
|
+
&& gate.writeScope.every(isNonEmptyString))) {
|
|
132
223
|
red('invalid-value', 'gate.writeScope', 'non-empty array of glob strings');
|
|
133
224
|
} else if (!gate.writeScope.every((s) => !globToPrefix(s).includes('*'))) {
|
|
134
225
|
// adaptlearn F9: enforcement (bareguard fs.writeScope) is prefix-containment — a wildcard
|
|
@@ -139,6 +230,29 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
|
139
230
|
// A scope that can reach the arbiter's inputs is the config-level
|
|
140
231
|
// fit-to-pass surface (design law #1). Reds-before-tokens.
|
|
141
232
|
red('invalid-value', 'gate.writeScope', 'must be a proper subdirectory of the run directory — no absolute paths, no ".." segments, not the run dir itself (design law #1)');
|
|
233
|
+
} else if (jobWriteScope !== undefined && jobWriteScope !== null) {
|
|
234
|
+
// Two-layer fence: the job spec's writeScope is operator law; the authored
|
|
235
|
+
// config may tighten it, never exceed it (the budget pattern, on paths).
|
|
236
|
+
// null/undefined are the legitimate "no fence" spellings (job optional at
|
|
237
|
+
// N2, and the only forms that survive JSON transit) — the layer is skipped,
|
|
238
|
+
// never a deadlock. A PRESENT-but-malformed fence fails CLOSED with its own
|
|
239
|
+
// red, attributed to jobWriteScope (NOT the innocent workflow field — the
|
|
240
|
+
// ledger's contrast attribution must not charge the agent's config for the
|
|
241
|
+
// operator's broken spec).
|
|
242
|
+
const fenceOk = Array.isArray(jobWriteScope) && jobWriteScope.length > 0 && jobWriteScope.every(legalScopeEntry);
|
|
243
|
+
if (!fenceOk) {
|
|
244
|
+
const shown = JSON.stringify(jobWriteScope).slice(0, 200); // bound: a malformed fence is untrusted caller input, and this rides the append-only spine (ralph's 2000-char precedent)
|
|
245
|
+
red('fence-invalid', 'jobWriteScope', `not a validateJob-green fence (${shown}) — validate the job spec before threading its fence`);
|
|
246
|
+
} else {
|
|
247
|
+
// Boundary-aware: "src2" is not inside "src" — prefix means PATH prefix.
|
|
248
|
+
const fence = jobWriteScope.map(globToPrefix);
|
|
249
|
+
const inside = (/** @type {string} */ p) => fence.some((f) => p === f || p.startsWith(f + '/'));
|
|
250
|
+
gate.writeScope.forEach((/** @type {string} */ s, /** @type {number} */ i) => {
|
|
251
|
+
if (!inside(globToPrefix(s))) {
|
|
252
|
+
red('scope-escape', `gate.writeScope.${i}`, `"${s}" is outside the job's fence [${jobWriteScope.join(', ')}] — the workflow may tighten the operator's bound, never exceed it`);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
142
256
|
}
|
|
143
257
|
|
|
144
258
|
const escalation = isObj(c.escalation) ? c.escalation : {};
|
|
@@ -173,7 +287,11 @@ export function validateConfig(input, { shellCapUsd = 2 } = {}) {
|
|
|
173
287
|
}
|
|
174
288
|
}
|
|
175
289
|
|
|
176
|
-
|
|
290
|
+
// 5. secrets sweep — same guard as the job spec (the agent-authored document
|
|
291
|
+
// is the riskier one; a green config is a ledger fact and inherits forever)
|
|
292
|
+
sweepSecretLiterals(c, red);
|
|
293
|
+
|
|
294
|
+
return { ok: reds.length === 0, reds, config: reds.length === 0 ? c : null };
|
|
177
295
|
}
|
|
178
296
|
|
|
179
297
|
/**
|
package/types/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { makeSpine } from "./spine.js";
|
|
2
2
|
export { ralph, runClose } from "./ralph.js";
|
|
3
3
|
export { validateConfig, diffPaths, globToPrefix, LOOP_SHAPES, SLOTS, VERBS } from "./validate.js";
|
|
4
|
+
export { validateJob, jobSpecHash, checkApproval, CLOSE_TYPES, CLASSES, CLASS_BY_CLOSE, GOLD_COMPARE, CADENCE_UNITS, PROVIDERS, CONDITION_KEYS } from "./job.js";
|
|
4
5
|
export { interpret, STALL_REDS } from "./interpret.js";
|
|
5
6
|
export { extractRules, MAX_RULES, MAX_RULE_CHARS } from "./extract.js";
|
package/types/interpret.d.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* @param {(type: string, data?: object) => object} opts.emit spine emitter
|
|
12
12
|
* @param {object} opts.provider a bareagent provider — SHELL-owned binding (adaptlearn F8: an unsealed binding is a gate bypass)
|
|
13
13
|
* @param {number} [opts.shellCapUsd=2] the shell's USD cap; config budgetUsd is clamped by validation
|
|
14
|
+
* @param {string[]} [opts.jobWriteScope] the job spec's outer write fence (operator law,
|
|
15
|
+
* job-v1) — enforced HERE, the one choke point where a config becomes a Gate:
|
|
16
|
+
* every workflow scope must fit inside it (scope-escape config-red otherwise),
|
|
17
|
+
* on entry validation and on every revision candidate alike
|
|
14
18
|
* @param {(o: {config: object, gaps: string[], policy: any, onLlmResult: any}) => Promise<{candidate: object|null, parseError?: string|null, costUsd?: number}>} [opts.revisor]
|
|
15
19
|
* optional mid-run revision seam. Fires ONCE per run after STALL_REDS consecutive
|
|
16
20
|
* close reds. The interpreter — never the revisor — owns acceptance: the candidate
|
|
@@ -20,7 +24,7 @@
|
|
|
20
24
|
* the worker.
|
|
21
25
|
* @returns {Promise<'green'|'escalated'|'config-red'>}
|
|
22
26
|
*/
|
|
23
|
-
export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, revisor }: {
|
|
27
|
+
export function interpret(configRaw: object | string, { task, target, close, workdir, capRuns, emit, provider, shellCapUsd, jobWriteScope, revisor }: {
|
|
24
28
|
task: string;
|
|
25
29
|
target: string;
|
|
26
30
|
close: string[];
|
|
@@ -29,6 +33,7 @@ export function interpret(configRaw: object | string, { task, target, close, wor
|
|
|
29
33
|
emit: (type: string, data?: object) => object;
|
|
30
34
|
provider: object;
|
|
31
35
|
shellCapUsd?: number | undefined;
|
|
36
|
+
jobWriteScope?: string[] | undefined;
|
|
32
37
|
revisor?: ((o: {
|
|
33
38
|
config: object;
|
|
34
39
|
gaps: string[];
|
package/types/job.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** @typedef {{code: string, path: string, detail?: string}} Red */
|
|
2
|
+
/**
|
|
3
|
+
* Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
|
|
4
|
+
* JSON text or plain parsed data — the ingest contract; a live object with a
|
|
5
|
+
* hostile accessor is outside it. Every failure is a named red. Returns the
|
|
6
|
+
* parsed spec on ok (single parse), null on any red.
|
|
7
|
+
* @param {object|string} input parsed spec, or raw JSON text (parse failures are a red)
|
|
8
|
+
* @param {{ shellCapUsd?: number }} [opts] the shell's hard ceiling — the job
|
|
9
|
+
* budget may tighten it, never exceed it (ceiling chain: workflow ≤ job ≤ shell)
|
|
10
|
+
* @returns {{ ok: boolean, reds: Red[], job: object|null }}
|
|
11
|
+
*/
|
|
12
|
+
export function validateJob(input: object | string, { shellCapUsd }?: {
|
|
13
|
+
shellCapUsd?: number;
|
|
14
|
+
}): {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
reds: Red[];
|
|
17
|
+
job: object | null;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Content hash of a job spec — what an approval record binds to. Any edit to
|
|
21
|
+
* the spec changes the hash, so an edited spec is unapproved by construction
|
|
22
|
+
* (interview decision #1: human signs, always, once per spec VERSION).
|
|
23
|
+
* @param {object} job
|
|
24
|
+
* @returns {string} sha256 hex
|
|
25
|
+
*/
|
|
26
|
+
export function jobSpecHash(job: object): string;
|
|
27
|
+
/**
|
|
28
|
+
* Does an approval record cover this exact spec version? Pure predicate — the
|
|
29
|
+
* N2 runner refuses to run without it; nothing here writes or prompts. The
|
|
30
|
+
* approval record lives OUTSIDE the document it signs (a spec never contains
|
|
31
|
+
* its own signature) and is shell/human territory, never agent-writable.
|
|
32
|
+
* @param {object} job
|
|
33
|
+
* @param {unknown} approvals array of { specHash, signer, ts } records
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function checkApproval(job: object, approvals: unknown): boolean;
|
|
37
|
+
export const CLOSE_TYPES: readonly string[];
|
|
38
|
+
export const CLASSES: readonly string[];
|
|
39
|
+
/** the hierarchy: which verdict classes a close type may claim (PRD §7) */
|
|
40
|
+
export const CLASS_BY_CLOSE: Readonly<{
|
|
41
|
+
predicate: readonly string[];
|
|
42
|
+
gold: readonly string[];
|
|
43
|
+
rubric: readonly string[];
|
|
44
|
+
hitl: readonly string[];
|
|
45
|
+
}>;
|
|
46
|
+
export const GOLD_COMPARE: readonly string[];
|
|
47
|
+
export const CADENCE_UNITS: readonly string[];
|
|
48
|
+
export const PROVIDERS: readonly string[];
|
|
49
|
+
/** V3 environment label: declared keys only — every field is a lineage-key
|
|
50
|
+
* candidate at N3. `provider` is part of the key by definition (top-level,
|
|
51
|
+
* not duplicated here). */
|
|
52
|
+
export const CONDITION_KEYS: readonly string[];
|
|
53
|
+
export type Red = {
|
|
54
|
+
code: string;
|
|
55
|
+
path: string;
|
|
56
|
+
detail?: string;
|
|
57
|
+
};
|
package/types/ralph.d.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Run a close command; its exit code is the verdict (hard green, PRD §4).
|
|
3
3
|
* 0 → satisfied; nonzero → needs_revision (gap fed back); spawn error → failed (terminal).
|
|
4
|
+
*
|
|
5
|
+
* `redact` scrubs the close's own output at the SOURCE — before the gap becomes
|
|
6
|
+
* a spine event or a worker prompt — so a secret a checked command echoes
|
|
7
|
+
* (a 401 dumping an `Authorization: Bearer …` header) never enters the
|
|
8
|
+
* append-only spine (the hard line). It is INJECTED, not imported: the shell
|
|
9
|
+
* stays stdlib-only and un-gameable, and the redactor is a fixed shell
|
|
10
|
+
* primitive (bareguard's, wired by the interpreter), never an emergent
|
|
11
|
+
* component — so V4 holds. The shell's canonical emission IS the redacted text;
|
|
12
|
+
* a benign gap is returned byte-identical (default: identity).
|
|
4
13
|
* @param {string[]} close argv, e.g. ['node', '--test', 'close/']
|
|
14
|
+
* @param {(s: string) => string} [redact] source scrubber; identity by default
|
|
5
15
|
*/
|
|
6
|
-
export function runClose(close: string[]): {
|
|
16
|
+
export function runClose(close: string[], redact?: (s: string) => string): {
|
|
7
17
|
verdict: string;
|
|
8
18
|
detail: string;
|
|
9
19
|
gap?: undefined;
|
|
@@ -38,11 +48,14 @@ export function runClose(close: string[]): {
|
|
|
38
48
|
* @param {string[]} opts.close argv whose exit code is truth
|
|
39
49
|
* @param {number} opts.capRuns budget: max middle runs
|
|
40
50
|
* @param {(type: string, data?: object) => object} opts.emit a spine emitter
|
|
51
|
+
* @param {(s: string) => string} [opts.redact] source scrubber for close output
|
|
52
|
+
* (secrets never enter the spine); injected so the shell stays stdlib-only
|
|
41
53
|
* @returns {Promise<'green'|'escalated'>}
|
|
42
54
|
*/
|
|
43
|
-
export function ralph({ middle, close, capRuns, emit }: {
|
|
55
|
+
export function ralph({ middle, close, capRuns, emit, redact }: {
|
|
44
56
|
middle: (iteration: number, gap?: string) => void | Promise<void>;
|
|
45
57
|
close: string[];
|
|
46
58
|
capRuns: number;
|
|
47
59
|
emit: (type: string, data?: object) => object;
|
|
60
|
+
redact?: ((s: string) => string) | undefined;
|
|
48
61
|
}): Promise<"green" | "escalated">;
|
package/types/validate.d.ts
CHANGED
|
@@ -9,13 +9,42 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export function globToPrefix(scope: string): string;
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* A scope may never reach the arbiter's inputs (design law #1): it must
|
|
13
|
+
* resolve to a PROPER subdirectory of the run directory. Absolute paths and
|
|
14
|
+
* ".." segments escape it; "." / "./**" cover the whole run directory — where
|
|
15
|
+
* the close suite lives. Windows spellings ("..\", "C:\") count as escapes.
|
|
16
|
+
* Exported for the job validator: the operator's outer fence obeys the SAME
|
|
17
|
+
* law through the same code — two containment transforms would be the F9
|
|
18
|
+
* red-class one level up.
|
|
19
|
+
* @param {string} s
|
|
20
|
+
*/
|
|
21
|
+
export function scopeContained(s: string): boolean;
|
|
22
|
+
/** Shared with the job validator — one definition of "a JSON object" for both
|
|
23
|
+
* documents (the sibling-validator drift class).
|
|
24
|
+
* @param {unknown} v */
|
|
25
|
+
export function isObj(v: unknown): boolean;
|
|
26
|
+
/** @param {unknown} v @returns {v is string} */
|
|
27
|
+
export function isNonEmptyString(v: unknown): v is string;
|
|
28
|
+
/**
|
|
29
|
+
* Red every string in a config tree that carries a known secret-token shape.
|
|
30
|
+
* @param {any} root
|
|
31
|
+
* @param {(code: string, path: string, detail?: string) => void} red
|
|
32
|
+
*/
|
|
33
|
+
export function sweepSecretLiterals(root: any, red: (code: string, path: string, detail?: string) => void): void;
|
|
34
|
+
/**
|
|
35
|
+
* Validate a workflow config against schema v1. Returns the parsed config on
|
|
36
|
+
* ok (single parse — callers never re-parse; null on any red).
|
|
13
37
|
* @param {object|string} input parsed config, or raw JSON text (parse failures are a red)
|
|
14
|
-
* @param {{ shellCapUsd?: number }} [opts] the shell's
|
|
15
|
-
*
|
|
38
|
+
* @param {{ shellCapUsd?: number, jobWriteScope?: string[] }} [opts] the shell's
|
|
39
|
+
* ceilings — a config may tighten either, never exceed. `shellCapUsd`: at N2+
|
|
40
|
+
* the shell passes min(shell cap, job budgetUsd) — the ceiling chain
|
|
41
|
+
* workflow ≤ job ≤ shell. `jobWriteScope`: the job spec's outer fence
|
|
42
|
+
* (operator law, job-v1) — every workflow scope must fit inside it.
|
|
43
|
+
* @returns {{ ok: boolean, reds: Array<{code: string, path: string, detail?: string}>, config: object|null }}
|
|
16
44
|
*/
|
|
17
|
-
export function validateConfig(input: object | string, { shellCapUsd }?: {
|
|
45
|
+
export function validateConfig(input: object | string, { shellCapUsd, jobWriteScope }?: {
|
|
18
46
|
shellCapUsd?: number;
|
|
47
|
+
jobWriteScope?: string[];
|
|
19
48
|
}): {
|
|
20
49
|
ok: boolean;
|
|
21
50
|
reds: Array<{
|
|
@@ -23,6 +52,7 @@ export function validateConfig(input: object | string, { shellCapUsd }?: {
|
|
|
23
52
|
path: string;
|
|
24
53
|
detail?: string;
|
|
25
54
|
}>;
|
|
55
|
+
config: object | null;
|
|
26
56
|
};
|
|
27
57
|
/**
|
|
28
58
|
* Changed JSON paths between two configs — the one-knob mutation checker
|
|
@@ -33,6 +63,7 @@ export function validateConfig(input: object | string, { shellCapUsd }?: {
|
|
|
33
63
|
* @returns {string[]} sorted changed paths, dot-notation with array indices
|
|
34
64
|
*/
|
|
35
65
|
export function diffPaths(a: object, b: object): string[];
|
|
36
|
-
export const LOOP_SHAPES: string[];
|
|
37
|
-
export const SLOTS: string[];
|
|
38
|
-
export const VERBS: string[];
|
|
66
|
+
export const LOOP_SHAPES: readonly string[];
|
|
67
|
+
export const SLOTS: readonly string[];
|
|
68
|
+
export const VERBS: readonly string[];
|
|
69
|
+
export const SECRET_PATTERNS: RegExp[];
|